Skip to content

Commit f52451c

Browse files
authored
fix: miniapp transfer amount must be raw-int semantics (#437)
* fix(miniapp): enforce raw amount semantics in transfer sheet * fix(i18n): use existing invalid params translation key
1 parent dcfff7a commit f52451c

5 files changed

Lines changed: 174 additions & 16 deletions

File tree

src/stackflow/activities/MainTabsActivity.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import {
2525
setWalletPicker,
2626
} from "@/services/ecosystem";
2727
import { getKeyAppChainId } from "@biochain/bio-sdk";
28-
import { formatUnits } from "viem";
2928
import { walletSelectors, walletStore, type ChainAddress } from "@/stores";
3029
import { miniappRuntimeStore } from "@/services/miniapp-runtime";
3130

@@ -387,7 +386,7 @@ export const MainTabsActivity: ActivityComponentType<MainTabsParams> = ({ params
387386
}
388387

389388
const valueBigInt = BigInt(value ?? "0x0");
390-
const amount = formatUnits(valueBigInt, 18);
389+
const amount = valueBigInt.toString();
391390
const keyAppChainId = chainId ? getKeyAppChainId(chainId) : null;
392391
const requestId = `transfer-${Date.now()}-${++transferSheetSeqRef.current}`;
393392

src/stackflow/activities/sheets/MiniappConfirmJobs.regression.test.tsx

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,47 @@ describe('miniapp confirm jobs regressions', () => {
226226
expect(screen.getByTestId('miniapp-sheet-header')).toBeInTheDocument();
227227
});
228228

229+
it('does not pass raw amount directly to display layer', () => {
230+
render(
231+
<MiniappTransferConfirmJob
232+
params={{
233+
appName: 'Org App',
234+
appIcon: '',
235+
from: 'b_sender_1',
236+
to: 'b_receiver_1',
237+
amount: '1000000000',
238+
chain: 'BFMetaV2',
239+
asset: 'USDT',
240+
}}
241+
/>,
242+
);
243+
244+
const amountDisplayNode = screen.getByText((content) => content.endsWith('-USDT'));
245+
expect(amountDisplayNode).toBeInTheDocument();
246+
expect(amountDisplayNode.textContent).not.toBe('1000000000-USDT');
247+
});
248+
249+
it('keeps confirm button disabled for non-raw amount input', async () => {
250+
render(
251+
<MiniappTransferConfirmJob
252+
params={{
253+
appName: 'Org App',
254+
appIcon: '',
255+
from: 'b_sender_1',
256+
to: 'b_receiver_1',
257+
amount: '10.00000000',
258+
chain: 'BFMetaV2',
259+
asset: 'USDT',
260+
}}
261+
/>,
262+
);
263+
264+
const confirmButton = screen.getByTestId('miniapp-transfer-review-confirm');
265+
await waitFor(() => {
266+
expect(confirmButton).toBeDisabled();
267+
});
268+
});
269+
229270
it('shows building transaction copy in wallet-lock step while generating tx', async () => {
230271
vi.mocked(signUnsignedTransaction).mockImplementation(
231272
async () =>
@@ -261,6 +302,50 @@ describe('miniapp confirm jobs regressions', () => {
261302
expect(await screen.findByTestId('miniapp-transfer-building-status')).toBeInTheDocument();
262303
});
263304

305+
it('builds transfer transaction with raw-equivalent amount', async () => {
306+
const buildTransaction = vi.fn(async (intent: unknown) => ({
307+
chainId: 'bfmetav2',
308+
intentType: 'transfer',
309+
data: intent,
310+
}));
311+
312+
vi.mocked(getChainProvider).mockReturnValueOnce({
313+
supportsFullTransaction: true,
314+
buildTransaction,
315+
signTransaction: vi.fn(),
316+
broadcastTransaction: vi.fn(async () => 'tx-hash'),
317+
} as unknown as ReturnType<typeof getChainProvider>);
318+
319+
render(
320+
<MiniappTransferConfirmJob
321+
params={{
322+
appName: 'Org App',
323+
appIcon: '',
324+
from: 'b_sender_1',
325+
to: 'b_receiver_1',
326+
amount: '1000000000',
327+
chain: 'BFMetaV2',
328+
asset: 'USDT',
329+
}}
330+
/>,
331+
);
332+
333+
const confirmButton = screen.getByTestId('miniapp-transfer-review-confirm');
334+
await waitFor(() => {
335+
expect(confirmButton).not.toBeDisabled();
336+
});
337+
338+
fireEvent.click(confirmButton);
339+
fireEvent.click(screen.getByTestId('pattern-lock'));
340+
341+
await waitFor(() => {
342+
expect(buildTransaction).toHaveBeenCalledTimes(1);
343+
});
344+
345+
const intent = buildTransaction.mock.calls[0]?.[0] as { amount: { toRawString: () => string } };
346+
expect(intent.amount.toRawString()).toBe('1000000000');
347+
});
348+
264349

265350
it('ignores duplicated unlock submission while transfer is in-flight', async () => {
266351
vi.mocked(signUnsignedTransaction).mockImplementation(async () => {

src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ import { ChainBadge } from '@/components/wallet/chain-icon';
1919
import { PatternLock, patternToString } from '@/components/security/pattern-lock';
2020
import { PasswordInput } from '@/components/security/password-input';
2121
import { getChainProvider } from '@/services/chain-adapter/providers';
22-
import { Amount } from '@/types/amount';
2322
import { signUnsignedTransaction } from '@/services/ecosystem/handlers';
2423
import { chainConfigService } from '@/services/chain-config/service';
2524
import { useToast } from '@/services';
2625
import { findMiniappWalletIdByAddress, resolveMiniappChainId } from './miniapp-wallet';
2726
import { createMiniappUnsupportedPipelineError, mapMiniappTransferErrorToMessage } from './miniapp-transfer-error';
27+
import { parseMiniappTransferAmountRaw } from './miniapp-transfer-amount';
2828
import {
2929
isMiniappWalletLockError,
3030
isMiniappTwoStepSecretError,
@@ -143,10 +143,25 @@ function MiniappTransferConfirmJobContent() {
143143
const chainSymbol = chainConfigService.getSymbol(resolvedChainId);
144144
return chainSymbol || resolvedChainId.toUpperCase();
145145
}, [asset, resolvedChainId]);
146+
const displayDecimals = useMemo(() => chainConfigService.getDecimals(resolvedChainId), [resolvedChainId]);
147+
148+
const parsedAmount = useMemo(() => {
149+
try {
150+
return parseMiniappTransferAmountRaw(amount, displayDecimals, displayAsset);
151+
} catch {
152+
return null;
153+
}
154+
}, [amount, displayDecimals, displayAsset]);
155+
156+
const displayAmount = useMemo(() => parsedAmount?.toFormatted({ trimTrailingZeros: false }) ?? amount, [parsedAmount, amount]);
157+
const amountInvalidMessage = useMemo(
158+
() => (parsedAmount ? null : t('transaction:broadcast.invalidParams')),
159+
[parsedAmount, t],
160+
);
146161

147162
const transferShortTitle = useMemo(
148-
() => t('transaction:miniappTransfer.shortTitle', { amount, asset: displayAsset }),
149-
[t, amount, displayAsset],
163+
() => t('transaction:miniappTransfer.shortTitle', { amount: displayAmount, asset: displayAsset }),
164+
[t, displayAmount, displayAsset],
150165
);
151166
const isBuilding = phase === 'building';
152167
const isBroadcasting = phase === 'broadcasting';
@@ -405,16 +420,15 @@ function MiniappTransferConfirmJobContent() {
405420
throw createMiniappUnsupportedPipelineError(resolvedChainId);
406421
}
407422

408-
const decimals = chainConfigService.getDecimals(resolvedChainId);
409-
const chainSymbol = chainConfigService.getSymbol(resolvedChainId);
410-
const symbol = (asset ?? chainSymbol) || resolvedChainId.toUpperCase();
411-
const valueAmount = Amount.parse(amount, decimals, symbol);
423+
if (!parsedAmount) {
424+
throw new Error('Invalid miniapp transfer amount');
425+
}
412426

413427
const unsignedTx = await provider.buildTransaction({
414428
type: 'transfer',
415429
from,
416430
to,
417-
amount: valueAmount,
431+
amount: parsedAmount,
418432
...(asset ? { bioAssetType: asset } : {}),
419433
});
420434

@@ -446,7 +460,7 @@ function MiniappTransferConfirmJobContent() {
446460

447461
return { txHash, transaction };
448462
},
449-
[resolvedChainId, asset, amount, from, to, walletId],
463+
[resolvedChainId, parsedAmount, asset, from, to, walletId],
450464
);
451465

452466
const handleTransferFailure = useCallback(
@@ -674,11 +688,11 @@ function MiniappTransferConfirmJobContent() {
674688
<div className="space-y-4 p-4">
675689
<div className="bg-muted/50 rounded-xl p-4 text-center">
676690
<AmountDisplay
677-
value={amount}
691+
value={displayAmount}
678692
symbol={displayAsset}
679693
size="xl"
680694
weight="bold"
681-
decimals={8}
695+
decimals={displayDecimals}
682696
fixedDecimals={true}
683697
/>
684698
</div>
@@ -705,6 +719,12 @@ function MiniappTransferConfirmJobContent() {
705719
</div>
706720
)}
707721

722+
{amountInvalidMessage && (
723+
<div className="bg-destructive/10 text-destructive rounded-xl p-3 text-sm">
724+
{amountInvalidMessage}
725+
</div>
726+
)}
727+
708728
<div className="bg-muted/50 flex items-center justify-between rounded-xl p-3">
709729
<span className="text-muted-foreground text-sm"> {t('network')}</span>
710730
<ChainBadge chainId={resolvedChainId} />
@@ -727,7 +747,7 @@ function MiniappTransferConfirmJobContent() {
727747
<button
728748
data-testid="miniapp-transfer-review-confirm"
729749
onClick={handleEnterWalletLockStep}
730-
disabled={isBusy || !walletId || isResolvingTwoStepSecret}
750+
disabled={isBusy || !walletId || isResolvingTwoStepSecret || !parsedAmount}
731751
className={cn(
732752
'flex-1 rounded-xl py-3 font-medium transition-colors',
733753
'bg-primary text-primary-foreground hover:bg-primary/90',
@@ -847,11 +867,11 @@ function MiniappTransferConfirmJobContent() {
847867
<div className="space-y-4 p-4">
848868
<div className="bg-muted/50 rounded-xl p-4 text-center">
849869
<AmountDisplay
850-
value={amount}
870+
value={displayAmount}
851871
symbol={displayAsset}
852872
size="xl"
853873
weight="bold"
854-
decimals={8}
874+
decimals={displayDecimals}
855875
fixedDecimals={true}
856876
/>
857877
</div>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
formatMiniappTransferAmountForDisplay,
4+
isMiniappRawAmount,
5+
parseMiniappTransferAmountRaw,
6+
} from '../miniapp-transfer-amount';
7+
8+
describe('miniapp-transfer-amount', () => {
9+
it('accepts raw integer amount', () => {
10+
expect(isMiniappRawAmount('1000000000')).toBe(true);
11+
expect(isMiniappRawAmount(' 1000000000 ')).toBe(true);
12+
});
13+
14+
it('rejects non-raw amount', () => {
15+
expect(isMiniappRawAmount('10.00000000')).toBe(false);
16+
expect(isMiniappRawAmount('-100')).toBe(false);
17+
expect(isMiniappRawAmount('1e9')).toBe(false);
18+
});
19+
20+
it('parses raw amount with decimals correctly', () => {
21+
const amount = parseMiniappTransferAmountRaw('1000000000', 8, 'USDT');
22+
expect(amount.toRawString()).toBe('1000000000');
23+
expect(amount.toFormatted({ trimTrailingZeros: false })).toBe('10.00000000');
24+
});
25+
26+
it('formats display amount from raw', () => {
27+
expect(formatMiniappTransferAmountForDisplay('1000000000', 8, 'USDT')).toBe('10.00000000');
28+
});
29+
30+
it('throws when amount is not raw integer', () => {
31+
expect(() => parseMiniappTransferAmountRaw('10.00000000', 8, 'USDT')).toThrow('Invalid miniapp transfer amount');
32+
});
33+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Amount } from '@/types/amount';
2+
3+
const RAW_AMOUNT_PATTERN = /^\d+$/;
4+
5+
export function isMiniappRawAmount(value: string): boolean {
6+
const normalized = value.trim();
7+
return RAW_AMOUNT_PATTERN.test(normalized);
8+
}
9+
10+
export function parseMiniappTransferAmountRaw(value: string, decimals: number, symbol: string): Amount {
11+
const normalized = value.trim();
12+
if (!RAW_AMOUNT_PATTERN.test(normalized)) {
13+
throw new Error('Invalid miniapp transfer amount');
14+
}
15+
return Amount.fromRaw(normalized, decimals, symbol);
16+
}
17+
18+
export function formatMiniappTransferAmountForDisplay(value: string, decimals: number, symbol: string): string {
19+
return parseMiniappTransferAmountRaw(value, decimals, symbol).toFormatted({ trimTrailingZeros: false });
20+
}
21+

0 commit comments

Comments
 (0)