Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/stackflow/activities/MainTabsActivity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -387,7 +386,7 @@ export const MainTabsActivity: ActivityComponentType<MainTabsParams> = ({ 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}`;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MiniappTransferConfirmJob
params={{
appName: 'Org App',
appIcon: '',
from: 'b_sender_1',
to: 'b_receiver_1',
amount: '1000000000',
chain: 'BFMetaV2',
asset: 'USDT',
}}
/>,
);

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(
<MiniappTransferConfirmJob
params={{
appName: 'Org App',
appIcon: '',
from: 'b_sender_1',
to: 'b_receiver_1',
amount: '10.00000000',
chain: 'BFMetaV2',
asset: 'USDT',
}}
/>,
);

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 () =>
Expand Down Expand Up @@ -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<typeof getChainProvider>);

render(
<MiniappTransferConfirmJob
params={{
appName: 'Org App',
appIcon: '',
from: 'b_sender_1',
to: 'b_receiver_1',
amount: '1000000000',
chain: 'BFMetaV2',
asset: 'USDT',
}}
/>,
);

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 () => {
Expand Down
48 changes: 34 additions & 14 deletions src/stackflow/activities/sheets/MiniappTransferConfirmJob.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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 } : {}),
});

Expand Down Expand Up @@ -446,7 +460,7 @@ function MiniappTransferConfirmJobContent() {

return { txHash, transaction };
},
[resolvedChainId, asset, amount, from, to, walletId],
[resolvedChainId, parsedAmount, asset, from, to, walletId],
);

const handleTransferFailure = useCallback(
Expand Down Expand Up @@ -674,11 +688,11 @@ function MiniappTransferConfirmJobContent() {
<div className="space-y-4 p-4">
<div className="bg-muted/50 rounded-xl p-4 text-center">
<AmountDisplay
value={amount}
value={displayAmount}
symbol={displayAsset}
size="xl"
weight="bold"
decimals={8}
decimals={displayDecimals}
fixedDecimals={true}
/>
</div>
Expand All @@ -705,6 +719,12 @@ function MiniappTransferConfirmJobContent() {
</div>
)}

{amountInvalidMessage && (
<div className="bg-destructive/10 text-destructive rounded-xl p-3 text-sm">
{amountInvalidMessage}
</div>
)}

<div className="bg-muted/50 flex items-center justify-between rounded-xl p-3">
<span className="text-muted-foreground text-sm"> {t('network')}</span>
<ChainBadge chainId={resolvedChainId} />
Expand All @@ -727,7 +747,7 @@ function MiniappTransferConfirmJobContent() {
<button
data-testid="miniapp-transfer-review-confirm"
onClick={handleEnterWalletLockStep}
disabled={isBusy || !walletId || isResolvingTwoStepSecret}
disabled={isBusy || !walletId || isResolvingTwoStepSecret || !parsedAmount}
className={cn(
'flex-1 rounded-xl py-3 font-medium transition-colors',
'bg-primary text-primary-foreground hover:bg-primary/90',
Expand Down Expand Up @@ -847,11 +867,11 @@ function MiniappTransferConfirmJobContent() {
<div className="space-y-4 p-4">
<div className="bg-muted/50 rounded-xl p-4 text-center">
<AmountDisplay
value={amount}
value={displayAmount}
symbol={displayAsset}
size="xl"
weight="bold"
decimals={8}
decimals={displayDecimals}
fixedDecimals={true}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import {
formatMiniappTransferAmountForDisplay,
isMiniappRawAmount,
parseMiniappTransferAmountRaw,
} from '../miniapp-transfer-amount';

describe('miniapp-transfer-amount', () => {
it('accepts raw integer amount', () => {
expect(isMiniappRawAmount('1000000000')).toBe(true);
expect(isMiniappRawAmount(' 1000000000 ')).toBe(true);
});

it('rejects non-raw amount', () => {
expect(isMiniappRawAmount('10.00000000')).toBe(false);
expect(isMiniappRawAmount('-100')).toBe(false);
expect(isMiniappRawAmount('1e9')).toBe(false);
});

it('parses raw amount with decimals correctly', () => {
const amount = parseMiniappTransferAmountRaw('1000000000', 8, 'USDT');
expect(amount.toRawString()).toBe('1000000000');
expect(amount.toFormatted({ trimTrailingZeros: false })).toBe('10.00000000');
});

it('formats display amount from raw', () => {
expect(formatMiniappTransferAmountForDisplay('1000000000', 8, 'USDT')).toBe('10.00000000');
});

it('throws when amount is not raw integer', () => {
expect(() => parseMiniappTransferAmountRaw('10.00000000', 8, 'USDT')).toThrow('Invalid miniapp transfer amount');
});
});
21 changes: 21 additions & 0 deletions src/stackflow/activities/sheets/miniapp-transfer-amount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Amount } from '@/types/amount';

const RAW_AMOUNT_PATTERN = /^\d+$/;

export function isMiniappRawAmount(value: string): boolean {
const normalized = value.trim();
return RAW_AMOUNT_PATTERN.test(normalized);
}

export function parseMiniappTransferAmountRaw(value: string, decimals: number, symbol: string): Amount {
const normalized = value.trim();
if (!RAW_AMOUNT_PATTERN.test(normalized)) {
throw new Error('Invalid miniapp transfer amount');
}
return Amount.fromRaw(normalized, decimals, symbol);
}

export function formatMiniappTransferAmountForDisplay(value: string, decimals: number, symbol: string): string {
return parseMiniappTransferAmountRaw(value, decimals, symbol).toFormatted({ trimTrailingZeros: false });
}