diff --git a/src/stackflow/activities/MainTabsActivity.tsx b/src/stackflow/activities/MainTabsActivity.tsx index fbf7383c9..392c471b3 100644 --- a/src/stackflow/activities/MainTabsActivity.tsx +++ b/src/stackflow/activities/MainTabsActivity.tsx @@ -25,7 +25,6 @@ import { setWalletPicker, } from "@/services/ecosystem"; import { getKeyAppChainId } from "@biochain/bio-sdk"; -import { formatUnits } from "viem"; import { walletSelectors, walletStore, type ChainAddress } from "@/stores"; import { miniappRuntimeStore } from "@/services/miniapp-runtime"; @@ -387,7 +386,7 @@ export const MainTabsActivity: ActivityComponentType = ({ params } const valueBigInt = BigInt(value ?? "0x0"); - const amount = formatUnits(valueBigInt, 18); + const amount = valueBigInt.toString(); const keyAppChainId = chainId ? getKeyAppChainId(chainId) : null; const requestId = `transfer-${Date.now()}-${++transferSheetSeqRef.current}`; diff --git a/src/stackflow/activities/sheets/MiniappConfirmJobs.regression.test.tsx b/src/stackflow/activities/sheets/MiniappConfirmJobs.regression.test.tsx index e3f9c8737..6c30d2f11 100644 --- a/src/stackflow/activities/sheets/MiniappConfirmJobs.regression.test.tsx +++ b/src/stackflow/activities/sheets/MiniappConfirmJobs.regression.test.tsx @@ -226,6 +226,47 @@ describe('miniapp confirm jobs regressions', () => { expect(screen.getByTestId('miniapp-sheet-header')).toBeInTheDocument(); }); + it('does not pass raw amount directly to display layer', () => { + render( + , + ); + + const amountDisplayNode = screen.getByText((content) => content.endsWith('-USDT')); + expect(amountDisplayNode).toBeInTheDocument(); + expect(amountDisplayNode.textContent).not.toBe('1000000000-USDT'); + }); + + it('keeps confirm button disabled for non-raw amount input', async () => { + render( + , + ); + + const confirmButton = screen.getByTestId('miniapp-transfer-review-confirm'); + await waitFor(() => { + expect(confirmButton).toBeDisabled(); + }); + }); + it('shows building transaction copy in wallet-lock step while generating tx', async () => { vi.mocked(signUnsignedTransaction).mockImplementation( async () => @@ -261,6 +302,50 @@ describe('miniapp confirm jobs regressions', () => { expect(await screen.findByTestId('miniapp-transfer-building-status')).toBeInTheDocument(); }); + it('builds transfer transaction with raw-equivalent amount', async () => { + const buildTransaction = vi.fn(async (intent: unknown) => ({ + chainId: 'bfmetav2', + intentType: 'transfer', + data: intent, + })); + + vi.mocked(getChainProvider).mockReturnValueOnce({ + supportsFullTransaction: true, + buildTransaction, + signTransaction: vi.fn(), + broadcastTransaction: vi.fn(async () => 'tx-hash'), + } as unknown as ReturnType); + + render( + , + ); + + const confirmButton = screen.getByTestId('miniapp-transfer-review-confirm'); + await waitFor(() => { + expect(confirmButton).not.toBeDisabled(); + }); + + fireEvent.click(confirmButton); + fireEvent.click(screen.getByTestId('pattern-lock')); + + await waitFor(() => { + expect(buildTransaction).toHaveBeenCalledTimes(1); + }); + + const intent = buildTransaction.mock.calls[0]?.[0] as { amount: { toRawString: () => string } }; + expect(intent.amount.toRawString()).toBe('1000000000'); + }); + it('ignores duplicated unlock submission while transfer is in-flight', async () => { vi.mocked(signUnsignedTransaction).mockImplementation(async () => { diff --git a/src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx b/src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx index 607c8e8cd..bef27720d 100644 --- a/src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx +++ b/src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx @@ -19,12 +19,12 @@ import { ChainBadge } from '@/components/wallet/chain-icon'; import { PatternLock, patternToString } from '@/components/security/pattern-lock'; import { PasswordInput } from '@/components/security/password-input'; import { getChainProvider } from '@/services/chain-adapter/providers'; -import { Amount } from '@/types/amount'; import { signUnsignedTransaction } from '@/services/ecosystem/handlers'; import { chainConfigService } from '@/services/chain-config/service'; import { useToast } from '@/services'; import { findMiniappWalletIdByAddress, resolveMiniappChainId } from './miniapp-wallet'; import { createMiniappUnsupportedPipelineError, mapMiniappTransferErrorToMessage } from './miniapp-transfer-error'; +import { parseMiniappTransferAmountRaw } from './miniapp-transfer-amount'; import { isMiniappWalletLockError, isMiniappTwoStepSecretError, @@ -143,10 +143,25 @@ function MiniappTransferConfirmJobContent() { const chainSymbol = chainConfigService.getSymbol(resolvedChainId); return chainSymbol || resolvedChainId.toUpperCase(); }, [asset, resolvedChainId]); + const displayDecimals = useMemo(() => chainConfigService.getDecimals(resolvedChainId), [resolvedChainId]); + + const parsedAmount = useMemo(() => { + try { + return parseMiniappTransferAmountRaw(amount, displayDecimals, displayAsset); + } catch { + return null; + } + }, [amount, displayDecimals, displayAsset]); + + const displayAmount = useMemo(() => parsedAmount?.toFormatted({ trimTrailingZeros: false }) ?? amount, [parsedAmount, amount]); + const amountInvalidMessage = useMemo( + () => (parsedAmount ? null : t('transaction:broadcast.invalidParams')), + [parsedAmount, t], + ); const transferShortTitle = useMemo( - () => t('transaction:miniappTransfer.shortTitle', { amount, asset: displayAsset }), - [t, amount, displayAsset], + () => t('transaction:miniappTransfer.shortTitle', { amount: displayAmount, asset: displayAsset }), + [t, displayAmount, displayAsset], ); const isBuilding = phase === 'building'; const isBroadcasting = phase === 'broadcasting'; @@ -405,16 +420,15 @@ function MiniappTransferConfirmJobContent() { throw createMiniappUnsupportedPipelineError(resolvedChainId); } - const decimals = chainConfigService.getDecimals(resolvedChainId); - const chainSymbol = chainConfigService.getSymbol(resolvedChainId); - const symbol = (asset ?? chainSymbol) || resolvedChainId.toUpperCase(); - const valueAmount = Amount.parse(amount, decimals, symbol); + if (!parsedAmount) { + throw new Error('Invalid miniapp transfer amount'); + } const unsignedTx = await provider.buildTransaction({ type: 'transfer', from, to, - amount: valueAmount, + amount: parsedAmount, ...(asset ? { bioAssetType: asset } : {}), }); @@ -446,7 +460,7 @@ function MiniappTransferConfirmJobContent() { return { txHash, transaction }; }, - [resolvedChainId, asset, amount, from, to, walletId], + [resolvedChainId, parsedAmount, asset, from, to, walletId], ); const handleTransferFailure = useCallback( @@ -674,11 +688,11 @@ function MiniappTransferConfirmJobContent() {
@@ -705,6 +719,12 @@ function MiniappTransferConfirmJobContent() {
)} + {amountInvalidMessage && ( +
+ {amountInvalidMessage} +
+ )} +
{t('network')} @@ -727,7 +747,7 @@ function MiniappTransferConfirmJobContent() {