From 7250b9447a6e4e02ef5c5c36ea9f60664b94c5b0 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Tue, 17 Feb 2026 21:35:44 +0800 Subject: [PATCH] fix: stabilize tron send flow and miniapp transfer sheets --- packages/chain-effect/src/http.test.ts | 27 +++ packages/chain-effect/src/http.ts | 2 +- src/hooks/use-send.submit-message.test.ts | 83 +++++++ src/hooks/use-send.test.ts | 52 +++- src/hooks/use-send.ts | 138 +++++++---- src/hooks/use-send.web3.test.ts | 154 ++++++++++++ src/hooks/use-send.web3.ts | 162 ++++++++++++- src/i18n/locales/ar/transaction.json | 1 + src/i18n/locales/en/transaction.json | 1 + src/i18n/locales/zh-CN/transaction.json | 1 + src/i18n/locales/zh-TW/transaction.json | 1 + src/pages/send/index.tsx | 23 +- .../providers/tronwallet-provider.effect.ts | 225 +++++++++++++++--- .../tron/transaction-mixin.test.ts | 48 ++++ .../chain-adapter/tron/transaction-mixin.ts | 105 +++++++- .../sheets/TransferWalletLockJob.tsx | 6 +- .../__tests__/miniapp-transfer-error.test.ts | 31 ++- .../sheets/miniapp-transfer-error.ts | 61 ++++- 18 files changed, 1005 insertions(+), 116 deletions(-) create mode 100644 packages/chain-effect/src/http.test.ts create mode 100644 src/hooks/use-send.submit-message.test.ts create mode 100644 src/hooks/use-send.web3.test.ts create mode 100644 src/services/chain-adapter/tron/transaction-mixin.test.ts diff --git a/packages/chain-effect/src/http.test.ts b/packages/chain-effect/src/http.test.ts new file mode 100644 index 000000000..5759da845 --- /dev/null +++ b/packages/chain-effect/src/http.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Effect } from 'effect' +import { httpFetch } from './http' + +describe('httpFetch', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('serializes bigint body values before POST', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })) + + await Effect.runPromise( + httpFetch({ + url: 'https://example.test/api', + method: 'POST', + body: { amount: 1000000000n }, + }), + ) + + expect(fetchSpy).toHaveBeenCalledTimes(1) + const requestInit = fetchSpy.mock.calls[0]?.[1] + expect(requestInit?.body).toBe('{"amount":"1000000000"}') + }) +}) diff --git a/packages/chain-effect/src/http.ts b/packages/chain-effect/src/http.ts index 390e84e8b..befed1f6b 100644 --- a/packages/chain-effect/src/http.ts +++ b/packages/chain-effect/src/http.ts @@ -147,7 +147,7 @@ export function httpFetch(options: FetchOptions): Effect.Effect ({ + mockSubmitWeb3Transfer: vi.fn(), + mockFetchWeb3Fee: vi.fn(), +})); + +vi.mock('./use-send.web3', async () => { + const actual = await vi.importActual('./use-send.web3'); + return { + ...actual, + fetchWeb3Fee: mockFetchWeb3Fee, + submitWeb3Transfer: mockSubmitWeb3Transfer, + validateWeb3Address: vi.fn(() => null), + }; +}); + +import { useSend } from './use-send'; + +const mockAsset: AssetInfo = { + assetType: 'TRX', + name: 'Tron', + amount: Amount.fromRaw('100000000', 6, 'TRX'), + decimals: 6, +}; + +const mockChainConfig = { + id: 'tron', + name: 'Tron', + symbol: 'TRX', + decimals: 6, + chainKind: 'tron', +} as ChainConfig; + +describe('useSend submit message propagation', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetchWeb3Fee.mockResolvedValue({ + amount: Amount.fromRaw('1000', 6, 'TRX'), + symbol: 'TRX', + }); + }); + + it('returns and stores detailed submit error message for web3 transfer', async () => { + const detailedMessage = 'Broadcast failed: SIGERROR'; + mockSubmitWeb3Transfer.mockResolvedValue({ + status: 'error', + message: detailedMessage, + }); + + const { result } = renderHook(() => + useSend({ + initialAsset: mockAsset, + useMock: false, + walletId: 'wallet-1', + fromAddress: 'TFromAddress', + chainConfig: mockChainConfig, + }), + ); + + act(() => { + result.current.setToAddress('TToAddress'); + result.current.setAmount(Amount.fromRaw('100000', 6, 'TRX')); + }); + + let submitResult: Awaited>; + await act(async () => { + submitResult = await result.current.submit('wallet-lock'); + }); + + expect(submitResult).toEqual({ + status: 'error', + message: detailedMessage, + }); + await waitFor(() => { + expect(result.current.state.errorMessage).toBe(detailedMessage); + }); + }); +}); diff --git a/src/hooks/use-send.test.ts b/src/hooks/use-send.test.ts index 673360369..64ddb82e7 100644 --- a/src/hooks/use-send.test.ts +++ b/src/hooks/use-send.test.ts @@ -93,25 +93,69 @@ describe('useSend', () => { }) describe('setAsset', () => { - it('updates asset and estimates fee', async () => { + it('updates asset without estimating fee before form completion', async () => { const { result } = renderHook(() => useSend()) act(() => { result.current.setAsset(mockAsset) }) expect(result.current.state.asset).toEqual(mockAsset) + expect(result.current.state.feeLoading).toBe(false) + expect(result.current.state.feeAmount).toBeNull() + expect(result.current.state.feeSymbol).toBe('') + }) + + it('estimates fee after form becomes complete with debounce', async () => { + const { result } = renderHook(() => useSend({ initialAsset: mockAsset })) + + act(() => { + result.current.setToAddress('0x1234567890abcdef1234567890abcdef12345678') + result.current.setAmount(Amount.fromFormatted('0.5', 18, 'ETH')) + }) + + act(() => { + vi.advanceTimersByTime(299) + }) + expect(result.current.state.feeAmount).toBeNull() expect(result.current.state.feeLoading).toBe(true) - // Wait for fee estimation act(() => { - vi.advanceTimersByTime(300) + vi.advanceTimersByTime(1) }) expect(result.current.state.feeLoading).toBe(false) - expect(result.current.state.feeAmount).not.toBeNull() expect(result.current.state.feeAmount?.toFormatted()).toBe('0.002') expect(result.current.state.feeSymbol).toBe('ETH') }) + + it('re-estimates fee after amount change with debounce', async () => { + const { result } = renderHook(() => useSend({ initialAsset: mockAsset })) + + act(() => { + result.current.setToAddress('0x1234567890abcdef1234567890abcdef12345678') + result.current.setAmount(Amount.fromFormatted('0.5', 18, 'ETH')) + }) + act(() => { + vi.advanceTimersByTime(300) + }) + expect(result.current.state.feeAmount?.toFormatted()).toBe('0.002') + + act(() => { + result.current.setAmount(Amount.fromFormatted('0.6', 18, 'ETH')) + }) + expect(result.current.state.feeLoading).toBe(true) + + act(() => { + vi.advanceTimersByTime(299) + }) + expect(result.current.state.feeAmount).toBeNull() + + act(() => { + vi.advanceTimersByTime(1) + }) + expect(result.current.state.feeLoading).toBe(false) + expect(result.current.state.feeAmount?.toFormatted()).toBe('0.002') + }) }) describe('canProceed', () => { diff --git a/src/hooks/use-send.ts b/src/hooks/use-send.ts index d836d455b..0ecee4fa4 100644 --- a/src/hooks/use-send.ts +++ b/src/hooks/use-send.ts @@ -22,7 +22,8 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { asset: initialAsset ?? null, }); - const feeInitKeyRef = useRef(null); + const feeEstimateDebounceRef = useRef | null>(null); + const feeEstimateSeqRef = useRef(0); const isBioforestChain = chainConfig?.chainKind === 'bioforest'; const isWeb3Chain = @@ -81,33 +82,83 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { setState((prev) => ({ ...prev, asset, - feeLoading: true, + feeAmount: null, + feeMinAmount: null, + feeSymbol: '', + feeLoading: false, })); + }, + [], + ); - const shouldUseMock = useMock || (!isBioforestChain && !isWeb3Chain) || !chainConfig || !fromAddress; + useEffect(() => { + if (feeEstimateDebounceRef.current) { + clearTimeout(feeEstimateDebounceRef.current); + feeEstimateDebounceRef.current = null; + } - if (shouldUseMock) { - // Mock fee estimation delay - setTimeout(() => { - const fee = MOCK_FEES[asset.assetType] ?? { amount: '0.001', symbol: asset.assetType }; - const feeAmount = Amount.fromFormatted(fee.amount, asset.decimals, fee.symbol); - setState((prev) => ({ - ...prev, - feeAmount: feeAmount, - feeMinAmount: feeAmount, - feeSymbol: fee.symbol, - feeLoading: false, - })); - }, 300); - return; - } + if (!state.asset) { + return; + } + + const shouldUseMock = useMock || (!isBioforestChain && !isWeb3Chain) || !chainConfig || !fromAddress; + const toAddress = state.toAddress.trim(); + const hasValidAmount = state.amount?.isPositive() === true; + const isTronSelfTransfer = + chainConfig?.chainKind === 'tron' && + fromAddress !== undefined && + fromAddress.trim().length > 0 && + fromAddress.trim() === toAddress; + const canEstimateFee = toAddress.length > 0 && hasValidAmount && !isTronSelfTransfer; + + if (!canEstimateFee) { + setState((prev) => { + if (!prev.feeLoading && prev.feeAmount === null && prev.feeMinAmount === null && prev.feeSymbol === '') { + return prev; + } + return { + ...prev, + feeLoading: false, + feeAmount: null, + feeMinAmount: null, + feeSymbol: '', + }; + }); + return; + } + setState((prev) => ({ + ...prev, + feeLoading: true, + feeAmount: null, + feeMinAmount: null, + feeSymbol: '', + })); + + const requestSeq = ++feeEstimateSeqRef.current; + feeEstimateDebounceRef.current = setTimeout(() => { void (async () => { try { - // Use appropriate fee fetcher based on chain type - const feeEstimate = isWeb3Chain - ? await fetchWeb3Fee(chainConfig, fromAddress) - : await fetchBioforestFee(chainConfig, fromAddress); + const feeEstimate = shouldUseMock + ? (() => { + const fee = MOCK_FEES[state.asset!.assetType] ?? { amount: '0.001', symbol: state.asset!.assetType }; + return { + amount: Amount.fromFormatted(fee.amount, state.asset!.decimals, fee.symbol), + symbol: fee.symbol, + }; + })() + : isWeb3Chain && chainConfig && fromAddress + ? await fetchWeb3Fee({ + chainConfig, + fromAddress, + toAddress, + amount: state.amount ?? undefined, + }) + : await fetchBioforestFee(chainConfig!, fromAddress!); + + if (requestSeq !== feeEstimateSeqRef.current) { + return; + } setState((prev) => ({ ...prev, @@ -117,32 +168,37 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { feeLoading: false, })); } catch (error) { + if (requestSeq !== feeEstimateSeqRef.current) { + return; + } setState((prev) => ({ ...prev, + feeAmount: null, + feeMinAmount: null, + feeSymbol: '', feeLoading: false, errorMessage: error instanceof Error ? error.message : t('error:transaction.feeEstimateFailed'), })); } })(); - }, - [chainConfig, fromAddress, isBioforestChain, isWeb3Chain, useMock], - ); + }, 300); - useEffect(() => { - if (!state.asset) return; - if (state.feeLoading) return; - - const feeKey = `${chainConfig?.id ?? 'unknown'}:${fromAddress ?? ''}:${state.asset.assetType}`; - const feeKeyChanged = feeInitKeyRef.current !== feeKey; - if (feeKeyChanged) { - feeInitKeyRef.current = feeKey; - } - - if (!feeKeyChanged && state.feeAmount) return; - if (!feeKeyChanged && !state.feeAmount) return; - - setAsset(state.asset); - }, [chainConfig?.id, fromAddress, setAsset, state.asset, state.feeAmount, state.feeLoading]); + return () => { + if (feeEstimateDebounceRef.current) { + clearTimeout(feeEstimateDebounceRef.current); + feeEstimateDebounceRef.current = null; + } + }; + }, [ + chainConfig, + fromAddress, + isBioforestChain, + isWeb3Chain, + state.amount, + state.asset, + state.toAddress, + useMock, + ]); // Get current balance from external source (single source of truth) const currentBalance = useMemo(() => { @@ -291,7 +347,7 @@ export function useSend(options: UseSendOptions = {}): UseSendReturn { txHash: null, errorMessage: result.message, })); - return { status: 'error' as const }; + return { status: 'error' as const, message: result.message }; } setState((prev) => ({ diff --git a/src/hooks/use-send.web3.test.ts b/src/hooks/use-send.web3.test.ts new file mode 100644 index 000000000..8c3ccda91 --- /dev/null +++ b/src/hooks/use-send.web3.test.ts @@ -0,0 +1,154 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ChainConfig } from '@/services/chain-config'; +import { Amount } from '@/types/amount'; +import { ChainErrorCodes, ChainServiceError } from '@/services/chain-adapter/types'; +import i18n from '@/i18n'; + +const { mockGetMnemonic, mockGetChainProvider } = vi.hoisted(() => ({ + mockGetMnemonic: vi.fn(), + mockGetChainProvider: vi.fn(), +})); + +vi.mock('@/services/wallet-storage', async () => { + const actual = await vi.importActual('@/services/wallet-storage'); + return { + ...actual, + walletStorageService: { + ...actual.walletStorageService, + getMnemonic: mockGetMnemonic, + }, + }; +}); + +vi.mock('@/services/chain-adapter/providers', async () => { + const actual = await vi.importActual( + '@/services/chain-adapter/providers', + ); + return { + ...actual, + getChainProvider: mockGetChainProvider, + }; +}); + +import { submitWeb3Transfer } from './use-send.web3'; + +type MockChainProvider = { + supportsFullTransaction: boolean; + buildTransaction: (intent: unknown) => Promise; + signTransaction: (unsignedTx: unknown, options: { privateKey: Uint8Array }) => Promise; + broadcastTransaction: (signedTx: unknown) => Promise; +}; + +function createChainConfig(): ChainConfig { + return { + id: 'tron', + name: 'Tron', + symbol: 'TRX', + decimals: 6, + chainKind: 'tron', + } as ChainConfig; +} + +function createMockProvider(overrides?: Partial): MockChainProvider { + const buildTransaction = vi.fn(async () => ({ data: { txID: 'mock-tx' } })); + const signTransaction = vi.fn(async () => ({ data: { txID: 'mock-tx', signature: ['sig'] } })); + const broadcastTransaction = vi.fn(async () => 'mock-hash'); + + return { + supportsFullTransaction: true, + buildTransaction, + signTransaction, + broadcastTransaction, + ...overrides, + }; +} + +describe('submitWeb3Transfer', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetMnemonic.mockResolvedValue( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + ); + }); + + it('maps tx build self-transfer error to localized copy', async () => { + const provider = createMockProvider({ + buildTransaction: vi.fn(async () => { + throw new ChainServiceError( + ChainErrorCodes.TX_BUILD_FAILED, + 'Failed to create Tron transaction: Cannot transfer TRX to yourself.', + { reason: 'Cannot transfer TRX to yourself.' }, + ); + }), + }); + mockGetChainProvider.mockReturnValue(provider); + + const result = await submitWeb3Transfer({ + chainConfig: createChainConfig(), + walletId: 'wallet-1', + password: 'pwd', + fromAddress: 'TFromAddress', + toAddress: 'TFromAddress', + amount: Amount.fromRaw('1000000', 6, 'TRX'), + }); + + expect(result).toEqual({ + status: 'error', + message: i18n.t('error:validation.cannotTransferToSelf'), + }); + }); + + it('returns tx build reason for non-self-transfer errors', async () => { + const reason = 'account does not exist'; + const provider = createMockProvider({ + buildTransaction: vi.fn(async () => { + throw new ChainServiceError( + ChainErrorCodes.TX_BUILD_FAILED, + `Failed to create Tron transaction: ${reason}`, + { reason }, + ); + }), + }); + mockGetChainProvider.mockReturnValue(provider); + + const result = await submitWeb3Transfer({ + chainConfig: createChainConfig(), + walletId: 'wallet-1', + password: 'pwd', + fromAddress: 'TFromAddress', + toAddress: 'TAnotherAddress', + amount: Amount.fromRaw('1000000', 6, 'TRX'), + }); + + expect(result).toEqual({ + status: 'error', + message: reason, + }); + }); + + it('uses 32-byte private key for tron signing', async () => { + const signTransaction = vi.fn(async (_unsignedTx: unknown, options: { privateKey: Uint8Array }) => { + expect(options.privateKey).toBeInstanceOf(Uint8Array); + expect(options.privateKey.length).toBe(32); + return { data: { txID: 'signed-tx', signature: ['sig'] } }; + }); + + const provider = createMockProvider({ + buildTransaction: vi.fn(async () => ({ data: { txID: 'mock-tx' } })), + signTransaction, + broadcastTransaction: vi.fn(async () => 'tx-hash'), + }); + mockGetChainProvider.mockReturnValue(provider); + + const result = await submitWeb3Transfer({ + chainConfig: createChainConfig(), + walletId: 'wallet-1', + password: 'pwd', + fromAddress: 'TFromAddress', + toAddress: 'TToAddress', + amount: Amount.fromRaw('1000000', 6, 'TRX'), + }); + + expect(result).toEqual({ status: 'ok', txHash: 'tx-hash' }); + }); +}); diff --git a/src/hooks/use-send.web3.ts b/src/hooks/use-send.web3.ts index 03472ba00..8c714fa91 100644 --- a/src/hooks/use-send.web3.ts +++ b/src/hooks/use-send.web3.ts @@ -9,29 +9,148 @@ import type { ChainConfig } from '@/services/chain-config'; import { Amount } from '@/types/amount'; import { walletStorageService, WalletStorageError, WalletStorageErrorCode } from '@/services/wallet-storage'; import { getChainProvider } from '@/services/chain-adapter/providers'; -import { mnemonicToSeedSync } from '@scure/bip39'; +import { ChainErrorCodes, ChainServiceError } from '@/services/chain-adapter/types'; +import { deriveKey } from '@/lib/crypto/derivation'; +import { hexToBytes } from '@noble/hashes/utils.js'; import i18n from '@/i18n'; const t = i18n.t.bind(i18n); +function collectErrorMessages(error: unknown): string[] { + const messages: string[] = []; + const visited = new Set(); + let current: unknown = error; + + while (current instanceof Error && !visited.has(current)) { + visited.add(current); + if (current.message) { + messages.push(current.message); + } + current = (current as Error & { cause?: unknown }).cause; + } + + return messages; +} + +function isTimeoutMessage(message: string): boolean { + return /timeout|timed out|etimedout|aborterror|aborted/i.test(message); +} + +function isBroadcastFailureMessage(message: string): boolean { + return /failed to broadcast transaction|broadcast failed|tx_broadcast_failed/i.test(message); +} + +function isSelfTransferMessage(message: string): boolean { + return /cannot transfer(?:\s+\w+)*\s+to yourself|不能转账给自己|不能给自己转账/i.test(message); +} + +function isGenericBroadcastFailureMessage(message: string): boolean { + return /^broadcast failed:?$/i.test(message.trim()) + || /^failed to broadcast transaction:?$/i.test(message.trim()); +} + +function extractBuildFailureReason(error: unknown): string | null { + if (error instanceof ChainServiceError && typeof error.details?.reason === 'string') { + return error.details.reason.trim() || null; + } + + for (const message of collectErrorMessages(error)) { + const normalized = message.trim(); + const match = normalized.match(/^(?:failed to create tron transaction|trc20 transaction build failed)\s*:\s*(.+)$/i); + if (match?.[1]) { + return match[1].trim(); + } + } + + return null; +} + +function extractBroadcastFailureReason(error: unknown): string | null { + if (error instanceof ChainServiceError && typeof error.details?.reason === 'string') { + return error.details.reason.trim() || null; + } + + for (const message of collectErrorMessages(error)) { + const normalized = message.trim(); + const match = normalized.match(/^broadcast failed:\s*(.+)$/i); + if (match?.[1]) { + return match[1].trim(); + } + if (isBroadcastFailureMessage(normalized) && !isGenericBroadcastFailureMessage(normalized)) { + return normalized; + } + } + + return null; +} + +function hasTimeoutInError(error: unknown): boolean { + return collectErrorMessages(error).some((message) => isTimeoutMessage(message)); +} + +function parseHexPrivateKey(secret: string): Uint8Array | null { + const normalized = secret.trim().replace(/^0x/i, ''); + if (!/^[0-9a-fA-F]{64}$/.test(normalized)) { + return null; + } + try { + return hexToBytes(normalized); + } catch { + return null; + } +} + +function deriveWeb3PrivateKey(secret: string, chainConfig: ChainConfig): Uint8Array { + const fallbackPrivateKey = parseHexPrivateKey(secret); + try { + if (chainConfig.chainKind === 'evm') { + return hexToBytes(deriveKey(secret, 'ethereum', 0, 0).privateKey); + } + if (chainConfig.chainKind === 'tron') { + return hexToBytes(deriveKey(secret, 'tron', 0, 0).privateKey); + } + if (chainConfig.chainKind === 'bitcoin') { + return hexToBytes(deriveKey(secret, 'bitcoin', 0, 0).privateKey); + } + } catch { + if (fallbackPrivateKey) return fallbackPrivateKey; + throw new Error(t('error:crypto.keyDerivationFailed')); + } + + if (fallbackPrivateKey) return fallbackPrivateKey; + throw new Error(t('error:chain.transferNotImplemented')); +} + export interface Web3FeeResult { amount: Amount; symbol: string; } -export async function fetchWeb3Fee(chainConfig: ChainConfig, fromAddress: string): Promise { +export interface FetchWeb3FeeParams { + chainConfig: ChainConfig; + fromAddress: string; + toAddress: string; + amount?: Amount | undefined; +} + +export async function fetchWeb3Fee({ chainConfig, fromAddress, toAddress, amount }: FetchWeb3FeeParams): Promise { const chainProvider = getChainProvider(chainConfig.id); if (!chainProvider.supportsFeeEstimate || !chainProvider.supportsBuildTransaction) { throw new Error(`Chain ${chainConfig.id} does not support fee estimation`); } + const estimateAmount = + amount && amount.isPositive() + ? amount + : Amount.fromRaw('1', chainConfig.decimals, chainConfig.symbol); + // 新流程:先构建交易,再估算手续费 const unsignedTx = await chainProvider.buildTransaction!({ type: 'transfer', from: fromAddress, - to: fromAddress, - amount: Amount.fromRaw('1', chainConfig.decimals, chainConfig.symbol), + to: toAddress, + amount: estimateAmount, }); const feeEstimate = await chainProvider.estimateFee!(unsignedTx); @@ -77,9 +196,9 @@ export async function submitWeb3Transfer({ amount, }: SubmitWeb3Params): Promise { // Get mnemonic from wallet storage - let mnemonic: string; + let secret: string; try { - mnemonic = await walletStorageService.getMnemonic(walletId, password); + secret = await walletStorageService.getMnemonic(walletId, password); } catch (error) { if (error instanceof WalletStorageError && error.code === WalletStorageErrorCode.DECRYPTION_FAILED) { return { status: 'password' }; @@ -101,8 +220,7 @@ export async function submitWeb3Transfer({ return { status: 'error', message: t('error:transaction.chainNotSupported', { chainId: chainConfig.id }) }; } - // Derive private key from mnemonic - const seed = mnemonicToSeedSync(mnemonic); + const privateKey = deriveWeb3PrivateKey(secret, chainConfig); // Build unsigned transaction const unsignedTx = await chainProvider.buildTransaction!({ @@ -113,7 +231,7 @@ export async function submitWeb3Transfer({ }); // Sign transaction - const signedTx = await chainProvider.signTransaction!(unsignedTx, { privateKey: seed }); + const signedTx = await chainProvider.signTransaction!(unsignedTx, { privateKey }); // Broadcast transaction const txHash = await chainProvider.broadcastTransaction!(signedTx); @@ -122,6 +240,28 @@ export async function submitWeb3Transfer({ } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); + if (error instanceof ChainServiceError) { + if (error.code === ChainErrorCodes.TX_BUILD_FAILED) { + const reason = extractBuildFailureReason(error); + if (reason && isSelfTransferMessage(reason)) { + return { status: 'error', message: t('error:validation.cannotTransferToSelf') }; + } + return { status: 'error', message: reason ?? errorMessage }; + } + + if (error.code === ChainErrorCodes.TRANSACTION_TIMEOUT || hasTimeoutInError(error)) { + return { status: 'error', message: t('transaction:broadcast.timeout') }; + } + + if (error.code === ChainErrorCodes.TX_BROADCAST_FAILED) { + const reason = extractBroadcastFailureReason(error); + return { + status: 'error', + message: reason ? `${t('transaction:broadcast.failed')}: ${reason}` : t('transaction:broadcast.failed'), + }; + } + } + // Handle specific error cases if (errorMessage.includes('insufficient') || errorMessage.includes('balance')) { return { status: 'error', message: t('error:insufficientFunds') }; @@ -135,6 +275,10 @@ export async function submitWeb3Transfer({ return { status: 'error', message: t('error:chain.transferNotImplemented') }; } + if (isSelfTransferMessage(errorMessage)) { + return { status: 'error', message: t('error:validation.cannotTransferToSelf') }; + } + return { status: 'error', message: errorMessage || t('error:transaction.failed'), diff --git a/src/i18n/locales/ar/transaction.json b/src/i18n/locales/ar/transaction.json index 6f34bc7d3..37519d6d1 100644 --- a/src/i18n/locales/ar/transaction.json +++ b/src/i18n/locales/ar/transaction.json @@ -244,6 +244,7 @@ "explorerNotImplemented": "ميزة مستكشف البلوك قادمة قريبًا", "fee": "رسوم المعاملة", "feeEstimating": "جارٍ التقدير...", + "feePending": "بانتظار التقدير", "feeUnavailable": "الرسوم غير متاحة", "from": "من", "networkWarning": "يرجى التأكد من أن عنوان المستلم هو عنوان شبكة {{chain}}. لا يمكن استرداد المرسل إلى الشبكة الخاطئة", diff --git a/src/i18n/locales/en/transaction.json b/src/i18n/locales/en/transaction.json index 2ae2e6d35..0f2df8b5d 100644 --- a/src/i18n/locales/en/transaction.json +++ b/src/i18n/locales/en/transaction.json @@ -244,6 +244,7 @@ "explorerNotImplemented": "Block explorer feature coming soon", "fee": "Fee", "feeEstimating": "Estimating...", + "feePending": "Pending estimate", "feeUnavailable": "Fee unavailable", "from": "From", "networkWarning": "Please ensure the recipient address is a {{chain}} network address. Sending to the wrong network cannot be recovered", diff --git a/src/i18n/locales/zh-CN/transaction.json b/src/i18n/locales/zh-CN/transaction.json index 99cb51da4..e5693f9a4 100644 --- a/src/i18n/locales/zh-CN/transaction.json +++ b/src/i18n/locales/zh-CN/transaction.json @@ -69,6 +69,7 @@ "twoStepSecretError": "安全密码错误", "fee": "手续费", "feeEstimating": "预估中...", + "feePending": "待估算", "feeUnavailable": "手续费不可用" }, "destroyPage": { diff --git a/src/i18n/locales/zh-TW/transaction.json b/src/i18n/locales/zh-TW/transaction.json index fb5d69ce7..74bd1e85c 100644 --- a/src/i18n/locales/zh-TW/transaction.json +++ b/src/i18n/locales/zh-TW/transaction.json @@ -244,6 +244,7 @@ "explorerNotImplemented": "區塊瀏覽器功能待實現", "fee": "手續費", "feeEstimating": "預估中...", + "feePending": "待估算", "feeUnavailable": "手續費不可用", "from": "發送地址", "networkWarning": "請確保收款地址為 {{chain}} 網絡地址,發送到錯誤網絡將無法找回", diff --git a/src/pages/send/index.tsx b/src/pages/send/index.tsx index eb3075585..be6aecb9a 100644 --- a/src/pages/send/index.tsx +++ b/src/pages/send/index.tsx @@ -213,6 +213,21 @@ function SendPageContent() { const symbol = state.asset?.assetType ?? 'TOKEN'; const feeSymbol = state.feeSymbol || chainConfig?.symbol; const isFeeSameAsset = !!feeSymbol && feeSymbol === symbol; + const hasFeeEstimatePrerequisites = useMemo(() => { + if (!state.asset) return false; + const toAddress = state.toAddress.trim(); + if (toAddress.length === 0) return false; + if (!state.amount?.isPositive()) return false; + return true; + }, [state.amount, state.asset, state.toAddress]); + + const feeDisplayText = useMemo(() => { + if (state.feeLoading) return t('sendPage.feeEstimating'); + if (state.feeAmount) return `${state.feeAmount.toFormatted()} ${state.feeSymbol || symbol}`; + if (!hasFeeEstimatePrerequisites) return t('sendPage.feePending'); + return t('sendPage.feeUnavailable'); + }, [hasFeeEstimatePrerequisites, state.feeAmount, state.feeLoading, state.feeSymbol, symbol, t]); + const maxAmount = useMemo(() => { if (!state.asset || !balance) return undefined; if (!state.feeAmount) return balance; @@ -412,13 +427,7 @@ function SendPageContent() { {/* Fee estimate */}
{t('sendPage.fee')} - - {state.feeLoading - ? t('sendPage.feeEstimating') - : state.feeAmount - ? `${state.feeAmount.toFormatted()} ${state.feeSymbol || symbol}` - : t('sendPage.feeUnavailable')} - + {feeDisplayText}
{/* Network warning */} diff --git a/src/services/chain-adapter/providers/tronwallet-provider.effect.ts b/src/services/chain-adapter/providers/tronwallet-provider.effect.ts index 4f1a6d8df..37861a233 100644 --- a/src/services/chain-adapter/providers/tronwallet-provider.effect.ts +++ b/src/services/chain-adapter/providers/tronwallet-provider.effect.ts @@ -172,6 +172,15 @@ function isRecord(value: unknown): value is UnknownRecord { return typeof value === "object" && value !== null } +function formatTronSignature(txId: string, privateKey: Uint8Array): string { + const txIdBytes = hexToBytes(txId) + const recovered = secp256k1.sign(txIdBytes, privateKey, { prehash: false, format: "recovered" }) + const recovery = recovered[0] + const compact = recovered.subarray(1) + const v = (recovery + 27).toString(16).padStart(2, "0") + return `${bytesToHex(compact)}${v}` +} + function toRawString(value: string | number | undefined | null): string { if (value === undefined || value === null) return "0" return String(value) @@ -225,6 +234,137 @@ function mergeTransactions(nativeTxs: Transaction[], tokenTxs: Transaction[]): T return Array.from(map.values()).sort((a, b) => b.timestamp - a.timestamp) } +function decodeTronErrorMessage(value: unknown): string | null { + if (typeof value !== "string") return null + const trimmed = value.trim() + if (!trimmed) return null + + const normalized = trimmed.startsWith("0x") ? trimmed.slice(2) : trimmed + if (!/^[0-9a-fA-F]+$/.test(normalized) || normalized.length % 2 !== 0) { + return trimmed + } + + try { + const decoded = new TextDecoder().decode(hexToBytes(normalized)).trim() + return decoded || trimmed + } catch { + return trimmed + } +} + +function resolveBroadcastFailureReason(raw: unknown): string { + if (!isRecord(raw)) { + return "Unknown error" + } + + const candidates: unknown[] = [raw.message, raw.code, raw.error] + if (isRecord(raw.result)) { + candidates.push(raw.result.message, raw.result.code, raw.result.error) + } + + for (const candidate of candidates) { + const reason = decodeTronErrorMessage(candidate) + if (reason) return reason + } + + return "Unknown error" +} + +function resolveBuildFailureReason(raw: unknown): string { + if (!isRecord(raw)) { + return "Unknown error" + } + + const errorRecord = isRecord(raw.error) ? raw.error : null + const resultRecord = isRecord(raw.result) ? raw.result : null + const resultErrorRecord = resultRecord && isRecord(resultRecord.error) ? resultRecord.error : null + const dataRecord = isRecord(raw.data) ? raw.data : null + const dataErrorRecord = dataRecord && isRecord(dataRecord.error) ? dataRecord.error : null + + const candidates: unknown[] = [ + errorRecord?.message, + errorRecord?.info, + errorRecord?.code, + raw.message, + raw.code, + resultRecord?.message, + resultRecord?.code, + resultErrorRecord?.message, + resultErrorRecord?.info, + resultErrorRecord?.code, + dataRecord?.message, + dataRecord?.code, + dataErrorRecord?.message, + dataErrorRecord?.info, + dataErrorRecord?.code, + ] + + for (const candidate of candidates) { + const reason = decodeTronErrorMessage(candidate) + if (reason) return reason + } + + return "Unknown error" +} + +function isBroadcastSuccess(raw: unknown): boolean { + if (!isRecord(raw)) return false + if (typeof raw.result === "boolean") return raw.result + if (typeof raw.success === "boolean") return raw.success + return false +} + +function toSignedPayload(data: unknown): TronWalletSignedPayload { + if (isRecord(data) && isRecord(data.signedTx)) { + return data as TronWalletSignedPayload + } + + const signedTx = data as TronSignedTransaction + return { + signedTx, + detail: { + from: "", + to: "", + amount: "0", + fee: "0", + assetSymbol: "", + }, + isToken: false, + } +} + +function pickTronRawTransaction(raw: unknown): TronRawTransaction | null { + if (!isRecord(raw)) return null + + if (typeof raw.txID === "string") { + return raw as TronRawTransaction + } + + if (isRecord(raw.transaction) && typeof raw.transaction.txID === "string") { + return raw.transaction as TronRawTransaction + } + + if (isRecord(raw.result)) { + if (typeof raw.result.txID === "string") { + return raw.result as TronRawTransaction + } + if (isRecord(raw.result.transaction) && typeof raw.result.transaction.txID === "string") { + return raw.result.transaction as TronRawTransaction + } + } + + if (isRecord(raw.data)) { + if (typeof raw.data.txID === "string") { + return raw.data as TronRawTransaction + } + if (isRecord(raw.data.transaction) && typeof raw.data.transaction.txID === "string") { + return raw.data.transaction as TronRawTransaction + } + } + + return null +} + function isConfirmedReceipt(raw: ReceiptResponse): boolean { if (!isRecord(raw)) return false return Object.keys(raw).length > 0 @@ -526,7 +666,7 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM const fromHex = tronAddressToHex(transferIntent.from) const toHex = tronAddressToHex(transferIntent.to) const hasCustomFee = Boolean(transferIntent.fee) - const feeRaw = transferIntent.fee?.raw ?? "0" + const feeRaw = transferIntent.fee?.toRawString() ?? "0" const tokenAddress = transferIntent.tokenAddress?.trim() const isToken = Boolean(tokenAddress) let assetSymbol = this.symbol @@ -569,7 +709,7 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM const detail: TronWalletBroadcastDetail = { from: fromHex, to: toHex, - amount: transferIntent.amount.raw, + amount: transferIntent.amount.toRawString(), fee: estimatedFeeRaw, assetSymbol, } @@ -585,7 +725,7 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM } } - const rawTx = await Effect.runPromise( + const rawTxResponse = await Effect.runPromise( httpFetch({ url: `${this.baseUrl}/trans/create`, method: "POST", @@ -597,6 +737,7 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM }, }) ) + const rawTx = this.extractNativeTransaction(rawTxResponse) const estimatedFeeRaw = hasCustomFee ? feeRaw @@ -604,7 +745,7 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM const detail: TronWalletBroadcastDetail = { from: fromHex, to: toHex, - amount: transferIntent.amount.raw, + amount: transferIntent.amount.toRawString(), fee: estimatedFeeRaw, assetSymbol, } @@ -613,7 +754,7 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM chainId: this.chainId, intentType: "transfer", data: { - rawTx: rawTx as TronRawTransaction, + rawTx, detail, isToken: false, } satisfies TronWalletUnsignedPayload, @@ -629,9 +770,7 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM } const payload = unsignedTx.data as TronWalletUnsignedPayload - const txIdBytes = hexToBytes(payload.rawTx.txID) - const sigBytes = secp256k1.sign(txIdBytes, options.privateKey, { prehash: false, format: "recovered" }) - const signature = bytesToHex(sigBytes) + const signature = formatTronSignature(payload.rawTx.txID, options.privateKey) const signedTx: TronSignedTransaction = { ...payload.rawTx, signature: [signature], @@ -649,24 +788,36 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM } async broadcastTransaction(signedTx: SignedTransaction): Promise { - const payload = signedTx.data as TronWalletSignedPayload + const payload = toSignedPayload(signedTx.data) const url = payload.isToken ? `${this.baseUrl}/trans/trc20/broadcast` : `${this.baseUrl}/trans/broadcast` + const requestBody = payload.detail.assetSymbol + ? { + ...payload.signedTx, + detail: payload.detail, + } + : payload.signedTx const result = await Effect.runPromise( httpFetch({ url, method: "POST", - body: { - ...payload.signedTx, - detail: payload.detail, - }, + body: requestBody, }) ) - const response = result as { result?: boolean; txid?: string } - if (!response.result) { - throw new ChainServiceError(ChainErrorCodes.TX_BROADCAST_FAILED, "Broadcast failed") + if (!isBroadcastSuccess(result)) { + const reason = resolveBroadcastFailureReason(result) + throw new ChainServiceError( + ChainErrorCodes.TX_BROADCAST_FAILED, + `Broadcast failed: ${reason}`, + isRecord(result) ? { provider: this.type, response: result, reason } : { provider: this.type, reason }, + ) + } + + if (isRecord(result) && typeof result.txid === "string" && result.txid.length > 0) { + return result.txid } + return payload.signedTx.txID } @@ -1365,27 +1516,27 @@ export class TronWalletProviderEffect extends TronIdentityMixin(TronTransactionM } 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 - } - } - throw new ChainServiceError(ChainErrorCodes.TX_BUILD_FAILED, "Invalid TRC20 transaction response") + const tx = pickTronRawTransaction(raw) + if (tx) return tx + + const reason = resolveBuildFailureReason(raw) + throw new ChainServiceError( + ChainErrorCodes.TX_BUILD_FAILED, + `TRC20 transaction build failed: ${reason}`, + isRecord(raw) ? { provider: this.type, response: raw, reason } : { provider: this.type, reason }, + ) + } + + private extractNativeTransaction(raw: unknown): TronRawTransaction { + const tx = pickTronRawTransaction(raw) + if (tx) return tx + + const reason = resolveBuildFailureReason(raw) + throw new ChainServiceError( + ChainErrorCodes.TX_BUILD_FAILED, + `Failed to create Tron transaction: ${reason}`, + isRecord(raw) ? { provider: this.type, response: raw, reason } : { provider: this.type, reason }, + ) } private fetchPendingTransactions(address: string): Effect.Effect { diff --git a/src/services/chain-adapter/tron/transaction-mixin.test.ts b/src/services/chain-adapter/tron/transaction-mixin.test.ts new file mode 100644 index 000000000..e3360a9d2 --- /dev/null +++ b/src/services/chain-adapter/tron/transaction-mixin.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { secp256k1 } from '@noble/curves/secp256k1.js' +import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js' +import { TronTransactionMixin } from './transaction-mixin' +import type { SignedTransaction, UnsignedTransaction } from '../types' +import type { TronSignedTransaction } from './types' + +class TronTestBase { + constructor(public readonly chainId: string) {} +} + +const TronTransactionService = TronTransactionMixin(TronTestBase) + +function expectedTronSignature(txId: string, privateKey: Uint8Array): string { + const recovered = secp256k1.sign(hexToBytes(txId), privateKey, { prehash: false, format: 'recovered' }) + const recovery = recovered[0] + const compact = recovered.subarray(1) + const v = (recovery + 27).toString(16).padStart(2, '0') + return `${bytesToHex(compact)}${v}` +} + +describe('TronTransactionMixin signature format', () => { + it('signs transaction with tron-compatible r+s+v format', async () => { + const service = new TronTransactionService('tron') + const txId = 'd1f6f7cf0ecfdb4f6f07ac8b5f9c5cf6a3dc731fd3704d98ea5f6f5b8d493f0f' + const privateKey = Uint8Array.from([ + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, + 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x10, + 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80, 0x90, + 0xa0, 0xb0, 0xc0, 0xd0, 0xe0, 0xf0, 0x12, 0x34, + ]) + + const unsignedTx: UnsignedTransaction = { + chainId: 'tron', + intentType: 'transfer', + data: { txID: txId } as UnsignedTransaction['data'], + } + + const signed = await service.signTransaction(unsignedTx, { privateKey }) + const trxSigned = signed as SignedTransaction + const signedData = trxSigned.data as TronSignedTransaction + const signature = signedData.signature?.[0] + + expect(signature).toBe(expectedTronSignature(txId, privateKey)) + expect(signature?.length).toBe(130) + expect(signature?.slice(-2)).toMatch(/1b|1c/) + }) +}) diff --git a/src/services/chain-adapter/tron/transaction-mixin.ts b/src/services/chain-adapter/tron/transaction-mixin.ts index 3133630d4..068555cf1 100644 --- a/src/services/chain-adapter/tron/transaction-mixin.ts +++ b/src/services/chain-adapter/tron/transaction-mixin.ts @@ -35,6 +35,89 @@ type Constructor = new (...args: any[]) => T /** Default Tron RPC 端点 (fallback) */ const DEFAULT_RPC_URL = 'https://api.trongrid.io' +type UnknownRecord = Record + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === 'object' && value !== null +} + +function formatTronSignature(txId: string, privateKey: Uint8Array): string { + const txIdBytes = hexToBytes(txId) + const recovered = secp256k1.sign(txIdBytes, privateKey, { prehash: false, format: 'recovered' }) + const recovery = recovered[0] + const compact = recovered.subarray(1) + const v = (recovery + 27).toString(16).padStart(2, '0') + return `${bytesToHex(compact)}${v}` +} + +function decodeTronErrorMessage(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + + const trimmed = value.trim() + if (!trimmed) { + return null + } + + const normalized = trimmed.startsWith('0x') ? trimmed.slice(2) : trimmed + if (!/^[0-9a-fA-F]+$/.test(normalized) || normalized.length % 2 !== 0) { + return trimmed + } + + try { + const decoded = new TextDecoder().decode(hexToBytes(normalized)).trim() + return decoded || trimmed + } catch { + return trimmed + } +} + +function resolveBroadcastFailureReason(raw: unknown): string { + if (!isRecord(raw)) { + return 'Unknown error' + } + + const candidates: unknown[] = [raw.message, raw.code, raw.error] + + if (isRecord(raw.result)) { + candidates.push(raw.result.message, raw.result.code, raw.result.error) + } + + for (const candidate of candidates) { + const reason = decodeTronErrorMessage(candidate) + if (reason) { + return reason + } + } + + return 'Unknown error' +} + +function isBroadcastSuccess(raw: unknown): boolean { + if (!isRecord(raw)) { + return false + } + + if (typeof raw.result === 'boolean') { + return raw.result + } + + if (typeof raw.success === 'boolean') { + return raw.success + } + + return false +} + +function unwrapSignedTransactionPayload(signedTx: SignedTransaction): TronSignedTransaction { + const payload = signedTx.data + if (isRecord(payload) && isRecord(payload.signedTx)) { + return payload.signedTx as TronSignedTransaction + } + return payload as TronSignedTransaction +} + /** * Tron Transaction Mixin - 为任意类添加 Tron 交易能力 * @@ -164,9 +247,7 @@ export function TronTransactionMixin { - const tx = signedTx.data as TronSignedTransaction + const tx = unwrapSignedTransactionPayload(signedTx) const result = await this.#api<{ result?: boolean; txid?: string; code?: string; message?: string }>( '/wallet/broadcasttransaction', tx, ) - if (!result.result) { - const errorMsg = result.message - ? Buffer.from(result.message, 'hex').toString('utf8') - : result.code ?? 'Unknown error' - throw new ChainServiceError(ChainErrorCodes.TX_BROADCAST_FAILED, `Broadcast failed: ${errorMsg}`) + if (!isBroadcastSuccess(result)) { + const errorMsg = resolveBroadcastFailureReason(result) + throw new ChainServiceError( + ChainErrorCodes.TX_BROADCAST_FAILED, + `Broadcast failed: ${errorMsg}`, + { reason: errorMsg, response: isRecord(result) ? result : undefined }, + ) + } + + if (typeof result.txid === 'string' && result.txid.length > 0) { + return result.txid } return tx.txID diff --git a/src/stackflow/activities/sheets/TransferWalletLockJob.tsx b/src/stackflow/activities/sheets/TransferWalletLockJob.tsx index 8020dac57..6999c0f47 100644 --- a/src/stackflow/activities/sheets/TransferWalletLockJob.tsx +++ b/src/stackflow/activities/sheets/TransferWalletLockJob.tsx @@ -203,8 +203,8 @@ function TransferWalletLockJobContent() { } return; } - } catch { - setError(t("transaction:broadcast.unknown")); + } catch (err) { + setError(err instanceof Error ? err.message : t("transaction:broadcast.unknown")); setTxStatus("failed"); } finally { setIsVerifying(false); @@ -295,7 +295,7 @@ function TransferWalletLockJobContent() { title={{ broadcasted: t("transaction:txStatus.broadcasted"), confirmed: t("transaction:sendResult.success"), - failed: t("transaction:broadcast.failed"), + failed: t("transaction:txStatus.failed"), }} description={{ broadcasted: t("transaction:txStatus.broadcastedDesc"), diff --git a/src/stackflow/activities/sheets/__tests__/miniapp-transfer-error.test.ts b/src/stackflow/activities/sheets/__tests__/miniapp-transfer-error.test.ts index d1c3de476..73a64c2a5 100644 --- a/src/stackflow/activities/sheets/__tests__/miniapp-transfer-error.test.ts +++ b/src/stackflow/activities/sheets/__tests__/miniapp-transfer-error.test.ts @@ -51,8 +51,35 @@ describe('miniapp-transfer-error mapper', () => { expect(mapMiniappTransferErrorToMessage(t, error, chainId)).toBe('transaction:broadcast.timeout'); }); - it('falls back to unknown broadcast error', () => { + it('maps broadcast failure with detailed reason', () => { + const error = new ChainServiceError( + ChainErrorCodes.TX_BROADCAST_FAILED, + 'Broadcast failed: SIGERROR', + { reason: 'SIGERROR' }, + ); + expect(mapMiniappTransferErrorToMessage(t, error, chainId)).toBe('transaction:broadcast.failed: SIGERROR'); + }); + + it('maps tx build self-transfer failure', () => { + const error = new ChainServiceError( + ChainErrorCodes.TX_BUILD_FAILED, + 'Failed to create Tron transaction: Cannot transfer TRX to yourself.', + { reason: 'Cannot transfer TRX to yourself.' }, + ); + expect(mapMiniappTransferErrorToMessage(t, error, chainId)).toBe('error:validation.cannotTransferToSelf'); + }); + + it('maps tx build failure reason directly', () => { + const error = new ChainServiceError( + ChainErrorCodes.TX_BUILD_FAILED, + 'Failed to create Tron transaction: account does not exist', + { reason: 'account does not exist' }, + ); + expect(mapMiniappTransferErrorToMessage(t, error, chainId)).toBe('account does not exist'); + }); + + it('falls back to original error message for unknown error', () => { const error = new Error('some-random-error'); - expect(mapMiniappTransferErrorToMessage(t, error, chainId)).toBe('transaction:broadcast.unknown'); + expect(mapMiniappTransferErrorToMessage(t, error, chainId)).toBe('some-random-error'); }); }); diff --git a/src/stackflow/activities/sheets/miniapp-transfer-error.ts b/src/stackflow/activities/sheets/miniapp-transfer-error.ts index df8ca948e..69ceaa659 100644 --- a/src/stackflow/activities/sheets/miniapp-transfer-error.ts +++ b/src/stackflow/activities/sheets/miniapp-transfer-error.ts @@ -11,6 +11,14 @@ function isBroadcastFailedMessage(message: string): boolean { return /failed to broadcast transaction|broadcast failed|tx_broadcast_failed/i.test(message); } +function isSelfTransferMessage(message: string): boolean { + return /cannot transfer(?:\s+\w+)*\s+to yourself|不能转账给自己|不能给自己转账/i.test(message); +} + +function isGenericBroadcastFailureMessage(message: string): boolean { + return /^broadcast failed:?$/i.test(message.trim()) + || /^failed to broadcast transaction:?$/i.test(message.trim()); +} function collectErrorMessages(error: unknown): string[] { const messages: string[] = []; @@ -36,6 +44,33 @@ function hasBroadcastFailedMessageInError(error: unknown): boolean { return collectErrorMessages(error).some((message) => isBroadcastFailedMessage(message)); } +function extractBroadcastFailureReason(error: unknown): string | null { + if (error instanceof ChainServiceError && typeof error.details?.reason === 'string') { + return error.details.reason.trim() || null; + } + + for (const message of collectErrorMessages(error)) { + const normalized = message.trim(); + const match = normalized.match(/^broadcast failed:\s*(.+)$/i); + if (match?.[1]) { + return match[1].trim(); + } + if (!isGenericBroadcastFailureMessage(normalized) && !isBroadcastFailedMessage(normalized)) { + continue; + } + if (!isGenericBroadcastFailureMessage(normalized)) { + return normalized; + } + } + + return null; +} + +function withBroadcastReason(baseMessage: string, reason: string | null): string { + if (!reason) return baseMessage; + return `${baseMessage}: ${reason}`; +} + export function createMiniappUnsupportedPipelineError(chainId: string): ChainServiceError { return new ChainServiceError(ChainErrorCodes.NOT_SUPPORTED, MINIAPP_TRANSFER_UNSUPPORTED_PIPELINE, { scope: 'miniapp-transfer', @@ -57,7 +92,18 @@ export function mapMiniappTransferErrorToMessage(t: TFunction, error: unknown, c if (hasTimeoutMessageInError(error)) { return t('transaction:broadcast.timeout'); } - return t('transaction:broadcast.failed'); + return withBroadcastReason(t('transaction:broadcast.failed'), extractBroadcastFailureReason(error)); + } + + if (error.code === ChainErrorCodes.TX_BUILD_FAILED) { + const reason = typeof error.details?.reason === 'string' ? error.details.reason.trim() : ''; + const displayMessage = reason || error.message; + if (isSelfTransferMessage(displayMessage)) { + return t('error:validation.cannotTransferToSelf'); + } + if (displayMessage) { + return displayMessage; + } } if (error.code === ChainErrorCodes.NETWORK_ERROR) { @@ -65,7 +111,7 @@ export function mapMiniappTransferErrorToMessage(t: TFunction, error: unknown, c return t('transaction:broadcast.timeout'); } if (hasBroadcastFailedMessageInError(error)) { - return t('transaction:broadcast.failed'); + return withBroadcastReason(t('transaction:broadcast.failed'), extractBroadcastFailureReason(error)); } } } @@ -80,7 +126,16 @@ export function mapMiniappTransferErrorToMessage(t: TFunction, error: unknown, c } if (hasBroadcastFailedMessageInError(error)) { - return t('transaction:broadcast.failed'); + return withBroadcastReason(t('transaction:broadcast.failed'), extractBroadcastFailureReason(error)); + } + + if (isSelfTransferMessage(error.message)) { + return t('error:validation.cannotTransferToSelf'); + } + + const [firstMessage] = collectErrorMessages(error); + if (firstMessage) { + return firstMessage; } }