diff --git a/app/src/app/HomeApp.tsx b/app/src/app/HomeApp.tsx index 80809c0..c51e73d 100644 --- a/app/src/app/HomeApp.tsx +++ b/app/src/app/HomeApp.tsx @@ -20,12 +20,10 @@ export default function HomeApp() { return ( -
+

A valid signature is not a safe payment

-

- Skur decides how much authorization a payment needs, and whether it may settle right now, - from the amount, the destination, the recent outflow and the vault's own security - posture. The rules are enforced in Move, in a shared object anyone can read. +

+ Policy-enforced treasuries on Sui. Open yours, or create one.

@@ -39,7 +37,7 @@ export default function HomeApp() { {mode === 'create' ? ( - + CONFIGURED ? : ) : (
@@ -87,10 +85,7 @@ export default function HomeApp() {

Open by id

-

- A vault is a shared object. Anyone can read one; acting on it needs the key that - holds the role. -

+

Anyone can read a vault; acting needs a role.

Pick a starting policy

-

- Each one is a complete, valid policy. Start with the closest fit and tighten it afterwards - through governance. -

+

Complete policies. Tighten later through governance.

{TEMPLATES.map((t) => ( ))} @@ -148,10 +142,7 @@ export function CreateVault({ tx }: { tx: TxState }) {

Who holds what

-

- You are the first owner, approver and executor. Add the rest — and note that the - contract will not let one address be both a guardian and anything else. -

+

You hold all roles to start. A guardian can hold nothing else.

{me ? (
diff --git a/app/src/components/PublishPackage.tsx b/app/src/components/PublishPackage.tsx index 593564c..76a269d 100644 --- a/app/src/components/PublishPackage.tsx +++ b/app/src/components/PublishPackage.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useCurrentAccount } from '@mysten/dapp-kit-react'; +import { useCurrentAccount, useCurrentClient } from '@mysten/dapp-kit-react'; import { Transaction } from '@mysten/sui/transactions'; -import { useState } from 'react'; +import { signAndExecute } from '@/lib/execute'; +import { useEffect, useState } from 'react'; import { dAppKit } from '@/dapp-kit'; import { NETWORK } from '@/config'; import { Notice } from '@/components/ui'; @@ -25,7 +26,29 @@ type State = export function PublishPackage() { const account = useCurrentAccount(); + const client = useCurrentClient(); const [state, setState] = useState({ phase: 'idle' }); + const [balance, setBalance] = useState(null); + + // Preflight: what this account actually holds on THIS network. Wallet extensions follow their + // own network switch, and a mismatch (or an empty account) comes back as a blank error — so + // the page states the number before the wallet is ever asked. + useEffect(() => { + let live = true; + setBalance(null); + if (!account) return; + client.core + .getBalance({ owner: account.address, coinType: '0x2::sui::SUI' }) + .then((r) => { + if (live) setBalance(BigInt((r as { balance?: { balance?: string | bigint } }).balance?.balance ?? 0)); + }) + .catch(() => { + if (live) setBalance(null); + }); + return () => { + live = false; + }; + }, [account, client]); const publish = async () => { if (!account) return; @@ -39,21 +62,12 @@ export function PublishPackage() { const cap = tx.publish({ modules: dump.modules, dependencies: dump.dependencies }); tx.transferObjects([cap], account.address); - const result = await dAppKit.signAndExecuteTransaction({ transaction: tx }); - if ('FailedTransaction' in result && result.FailedTransaction) { - throw new Error( - result.FailedTransaction.status?.error?.message ?? 'the network refused the publish', - ); - } - const executed = (result as { Transaction: { digest: string; effects?: unknown } }).Transaction; + const executed = await signAndExecute(client, tx, account.address); - // The package id, when the effects carry it; the chain is authoritative either way. + // A published package lands as an immutable created object; that id is the package id. let packageId: string | null = null; - const effects = executed.effects as - | { changedObjects?: { objectId?: string; id?: string; idOperation?: string; outputOwner?: unknown; objectType?: string }[] } - | undefined; - for (const c of effects?.changedObjects ?? []) { - if (c.idOperation === 'Created' && (c.objectType === 'package' || c.outputOwner === undefined)) { + for (const c of executed.effects?.changedObjects ?? []) { + if (c.idOperation === 'Created' && c.outputOwner?.$kind === 'Immutable') { packageId = c.objectId ?? c.id ?? null; break; } @@ -61,9 +75,14 @@ export function PublishPackage() { setState({ phase: 'done', digest: executed.digest, packageId }); } catch (e) { const message = e instanceof Error ? e.message : String(e); + const blank = !message || message === '{}' || /^\[object/.test(message); setState({ phase: 'error', - message: /reject|denied|dismiss/i.test(message) ? 'The wallet declined it. Nothing was published.' : message, + message: /reject|denied|dismiss/i.test(message) + ? 'The wallet declined it. Nothing was published.' + : blank + ? `The wallet returned an empty error. The usual causes: the wallet's own network switch is not on ${NETWORK}, or this account holds no SUI on ${NETWORK}. Check both and try again.` + : message, }); } }; @@ -93,6 +112,17 @@ export function PublishPackage() { it right here — publishing is a transaction like any other, and costs a fraction of a SUI in storage. The upgrade rights land in your wallet.

+ {account ? ( +

+ Signing as {account.address.slice(0, 10)}…{account.address.slice(-4)} + {balance !== null ? ( + <> + {' '}· holds {(Number(balance) / 1e9).toFixed(2)} SUI on {NETWORK} + {balance < 400_000_000n ? ' — not enough to publish (~0.4 SUI needed); fund it or switch account' : ''} + + ) : null} +

+ ) : null} {!account ? (

Connect a wallet first.

) : ( diff --git a/app/src/components/Shell.tsx b/app/src/components/Shell.tsx index 88eeeee..eb98ec5 100644 --- a/app/src/components/Shell.tsx +++ b/app/src/components/Shell.tsx @@ -40,10 +40,7 @@ export function Shell({ {!account ? (

Connect a wallet to act

-

- Reading a vault needs no wallet — the policy, the queue and the history are public. - Approving, executing or opening a payment needs the key that holds the role. -

+

Reading is public. Acting needs the key that holds the role.

) : null} {children} diff --git a/app/src/dapp-kit.ts b/app/src/dapp-kit.ts index b75d019..522d0ef 100644 --- a/app/src/dapp-kit.ts +++ b/app/src/dapp-kit.ts @@ -11,7 +11,11 @@ import { NETWORK } from './config'; * of decommissioning. */ export const dAppKit = createDAppKit({ - networks: ['mainnet', 'testnet', 'devnet', 'localnet'] as const, + // One build, one network. dApp Kit persists the selected network in the browser, and a list + // here let a stale 'localnet' from an earlier session be restored over the build's own + // setting — the wallet then received sign requests stamped for a chain it has never heard + // of and answered with an empty error. With a single entry there is nothing to restore. + networks: [NETWORK] as const, defaultNetwork: NETWORK, createClient: (network) => new SuiGrpcClient({ diff --git a/app/src/hooks/useVault.ts b/app/src/hooks/useVault.ts index 58c4f54..fd7b8d8 100644 --- a/app/src/hooks/useVault.ts +++ b/app/src/hooks/useVault.ts @@ -1,6 +1,6 @@ 'use client'; -import { useCurrentClient, useDAppKit } from '@mysten/dapp-kit-react'; +import { useCurrentAccount, useCurrentClient, useDAppKit } from '@mysten/dapp-kit-react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useCallback, useState } from 'react'; import type { Transaction } from '@mysten/sui/transactions'; @@ -11,6 +11,7 @@ import { type TransferPreview, type VaultView, } from '@skur/sdk'; +import { signAndExecute } from '@/lib/execute'; import { NETWORK, PACKAGE_ID } from '@/config'; /** Everything one vault screen needs, in as few round trips as the transport allows. */ @@ -86,17 +87,12 @@ export function useVaultTx(vaultId: string | undefined): TxState { const [error, setError] = useState(null); const [lastDigest, setLastDigest] = useState(null); + const account = useCurrentAccount(); const mutation = useMutation({ mutationFn: async ({ tx }: { tx: Transaction; label: string }) => { - const result = await dAppKit.signAndExecuteTransaction({ transaction: tx }); - if (result.FailedTransaction) { - throw new Error( - describeFailure(result.FailedTransaction.status.error?.message ?? 'the vault refused it'), - ); - } - const digest = result.Transaction.digest; - await client.core.waitForTransaction({ digest }); - return digest; + if (!account) throw new Error('connect a wallet first'); + const executed = await signAndExecute(client, tx, account.address); + return executed.digest; }, }); diff --git a/app/src/lib/execute.ts b/app/src/lib/execute.ts new file mode 100644 index 0000000..12e6a25 --- /dev/null +++ b/app/src/lib/execute.ts @@ -0,0 +1,55 @@ +import type { ClientWithCoreApi } from '@mysten/sui/client'; +import type { Transaction } from '@mysten/sui/transactions'; +import { fromBase64, toBase64 } from '@mysten/sui/utils'; +import { dAppKit } from '@/dapp-kit'; +import { NETWORK } from '@/config'; + +/** + * One submission path for every web transaction: this app builds the bytes against its own + * client (gas selection included), the wallet signs raw bytes — the one job only it can do — + * and this app executes the signed bytes through the same client. + * + * The wallet's build/preview/execute machinery never runs. Every blank '{}' failure this app + * has seen came from exactly that machinery, pointed at whatever network the extension happened + * to be on. + */ +export type Executed = { + digest: string; + effects?: { + status?: { success?: boolean; error?: { message?: string } }; + changedObjects?: { + objectId?: string; + id?: string; + idOperation?: string; + outputOwner?: { $kind?: string; ObjectOwner?: string; AddressOwner?: string }; + }[]; + }; +}; + +export async function signAndExecute( + client: ClientWithCoreApi, + tx: Transaction, + sender: string, +): Promise { + tx.setSenderIfNotSet(sender); + const built = await tx.build({ client }); + const signed = await dAppKit.signTransaction({ transaction: toBase64(built), network: NETWORK }); + + const result = await client.core.executeTransaction({ + transaction: fromBase64(signed.bytes), + signatures: [signed.signature], + include: { effects: true }, + }); + if (result.FailedTransaction) { + throw new Error( + (result.FailedTransaction as { status?: { error?: { message?: string } } }).status?.error?.message ?? + 'the network refused it', + ); + } + const executed = result.Transaction as unknown as Executed; + if (executed.effects?.status && executed.effects.status.success === false) { + throw new Error(executed.effects.status.error?.message ?? 'the network refused it'); + } + await client.core.waitForTransaction({ digest: executed.digest }); + return executed; +} diff --git a/mobile/App.tsx b/mobile/App.tsx index 9a73f3a..8b8c168 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -16,6 +16,7 @@ import { createBottomTabNavigator, type BottomTabNavigationProp } from '@react-n import { createNativeStackNavigator, type NativeStackScreenProps } from '@react-navigation/native-stack'; import { QueryClient, useQueryClient } from '@tanstack/react-query'; import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; +import * as SplashScreen from 'expo-splash-screen'; import { StatusBar } from 'expo-status-bar'; import * as Linking from 'expo-linking'; import { useEffect, useRef, useState } from 'react'; @@ -57,6 +58,9 @@ import { Team } from './src/screens/Team'; import { Transactions } from './src/screens/Transactions'; import { TxDetail } from './src/screens/TxDetail'; +// Keep the mark on screen until the fonts exist; hiding early flashes a bare canvas. +void SplashScreen.preventAutoHideAsync().catch(() => undefined); + /** * Reads are cached to disk, so a cold start offline shows the vault as it last was — the offline * banner states its age. Only vault reads persist: a transfer preview is a quote, and a stale @@ -435,6 +439,10 @@ export default function App() { SpaceGrotesk_700Bold, }); + useEffect(() => { + if (loaded) void SplashScreen.hideAsync().catch(() => undefined); + }, [loaded]); + return ( diff --git a/mobile/src/components/Scan.tsx b/mobile/src/components/Scan.tsx index be5e168..4bfff50 100644 --- a/mobile/src/components/Scan.tsx +++ b/mobile/src/components/Scan.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { Text, View } from 'react-native'; import { CameraView, useCameraPermissions } from 'expo-camera'; import { Button, Notice, Sheet } from './ui'; @@ -23,10 +23,17 @@ export function ScanSheet({ const C = useTheme(); const [permission, requestPermission] = useCameraPermissions(); const [rejected, setRejected] = useState(null); + // The camera fires for every frame the code stays visible; the first accepted hit wins and + // everything after it is ignored, or the sheet closes twice and state lands after unmount. + const done = useRef(false); + + if (!open && done.current) done.current = false; const handle = (data: string) => { + if (done.current) return; const match = ADDRESS.exec(data.trim()); if (match) { + done.current = true; setRejected(null); onAddress(match[0]); onClose(); diff --git a/mobile/src/components/ui.tsx b/mobile/src/components/ui.tsx index e9aca46..d318259 100644 --- a/mobile/src/components/ui.tsx +++ b/mobile/src/components/ui.tsx @@ -34,7 +34,7 @@ export function Screen({ children, top, footer, refreshControl, padded = true, c {top ? {top} : null} {/* `centered`: sparse screens (the launch, the fork, a lone empty state) sit in the middle of the viewport instead of hugging the status bar with a void below. */} - {children} + {children} {footer ? {footer} : null} ); @@ -367,7 +367,8 @@ export function Tape({ height = 8 }: { height?: number }) { export function Sheet({ open, onClose, title, children, action }: { open: boolean; onClose: () => void; title: string; children: ReactNode; action?: ReactNode }) { const s = useStyles(); const insets = useSafeAreaInsets(); return ( - + + @@ -377,6 +378,7 @@ export function Sheet({ open, onClose, title, children, action }: { open: boolea {children} + ); } diff --git a/mobile/src/lib/amount.ts b/mobile/src/lib/amount.ts new file mode 100644 index 0000000..e39c810 --- /dev/null +++ b/mobile/src/lib/amount.ts @@ -0,0 +1,25 @@ +/** + * Normalise a typed amount to the dot-decimal form the SDK parses. + * + * On decimal-comma locales the numeric keyboard offers a comma, and the SDK's parser strips + * commas as thousands separators — so "1,5" silently became 15: a tenfold overpayment with no + * error anywhere. The device's own locale says which character is the decimal separator; the + * other one is grouping and is dropped. + */ +const DECIMAL = (() => { + try { + const s = (1.1).toLocaleString(); + const ch = s.charAt(1); + return ch === ',' || ch === '.' ? ch : '.'; + } catch { + return '.'; + } +})(); + +export function normalizeAmount(text: string): string { + const t = text.replace(/[\s_]/g, ''); + if (DECIMAL === ',') { + return t.replace(/\./g, '').replace(/,/g, '.'); + } + return t.replace(/,/g, ''); +} diff --git a/mobile/src/lib/notifications.ts b/mobile/src/lib/notifications.ts index cd16bea..cc2f2c8 100644 --- a/mobile/src/lib/notifications.ts +++ b/mobile/src/lib/notifications.ts @@ -2,6 +2,7 @@ import Constants from 'expo-constants'; import * as Linking from 'expo-linking'; import * as Notifications from 'expo-notifications'; import { useEffect } from 'react'; +import { Platform } from 'react-native'; import { API_URL } from './config'; /** @@ -33,7 +34,24 @@ function projectId(): string | null { export type PushResult = { ok: true; token: string } | { ok: false; reason: string }; +/** + * Android 8+ routes every notification through a channel, and without one Expo falls back to a + * minimum-importance default: no heads-up, no sound, sometimes nothing visible at all. One + * channel, declared before the first token is minted, importance high — a payment that needs a + * signer is exactly what heads-up display is for. + */ +async function ensureAndroidChannel(): Promise { + if (Platform.OS !== 'android') return; + await Notifications.setNotificationChannelAsync('default', { + name: 'Vault activity', + importance: Notifications.AndroidImportance.HIGH, + vibrationPattern: [0, 200, 100, 200], + lightColor: '#ffd000', + }); +} + export async function enablePush(vaultId: string, address: string): Promise { + await ensureAndroidChannel(); if (!API_URL) { return { ok: false, reason: 'Push needs the indexer. Set EXPO_PUBLIC_SKUR_API.' }; } @@ -66,7 +84,7 @@ export function useNotificationTaps() { useEffect(() => { const open = (response: Notifications.NotificationResponse | null) => { const url = (response?.notification.request.content.data as { url?: string } | null)?.url; - if (url && url.startsWith('skur://')) void Linking.openURL(url); + if (url && url.startsWith('skur://')) void Linking.openURL(url).catch(() => undefined); }; void Notifications.getLastNotificationResponseAsync().then(open); const sub = Notifications.addNotificationResponseReceivedListener(open); diff --git a/mobile/src/lib/polyfills.ts b/mobile/src/lib/polyfills.ts index b808839..5fef989 100644 --- a/mobile/src/lib/polyfills.ts +++ b/mobile/src/lib/polyfills.ts @@ -246,3 +246,23 @@ if (typeof signalProto.throwIfAborted !== 'function') { } }; } + +/** + * Nightly Connect's base package touches `localStorage` at import time (via + * isomorphic-localstorage, which otherwise reaches for node's fs). Hermes has neither. An + * in-memory shim is enough: session persistence is handled explicitly with AsyncStorage and the + * relay's `persistentSessionId`, not through this. + */ +if (typeof (globalThis as { localStorage?: unknown }).localStorage === 'undefined') { + const mem = new Map(); + (globalThis as { localStorage?: unknown }).localStorage = { + getItem: (k: string) => (mem.has(k) ? mem.get(k)! : null), + setItem: (k: string, v: string) => void mem.set(k, String(v)), + removeItem: (k: string) => void mem.delete(k), + clear: () => mem.clear(), + key: (i: number) => [...mem.keys()][i] ?? null, + get length() { + return mem.size; + }, + }; +} diff --git a/mobile/src/screens/ActivityLog.tsx b/mobile/src/screens/ActivityLog.tsx index 5e87490..3483cc6 100644 --- a/mobile/src/screens/ActivityLog.tsx +++ b/mobile/src/screens/ActivityLog.tsx @@ -121,7 +121,7 @@ export function ActivityLog({ onBack }: { onBack: () => void }) { onChange={setFilter} /> {q.isLoading ? : null} - {q.error ? q.refetch()} /> : null} + {q.error && !q.data ? q.refetch()} /> : null} {!q.isLoading && visible.length === 0 ? ( Nothing in this view yet. ) : ( diff --git a/mobile/src/screens/Dashboard.tsx b/mobile/src/screens/Dashboard.tsx index 17c2cb5..aab55c3 100644 --- a/mobile/src/screens/Dashboard.tsx +++ b/mobile/src/screens/Dashboard.tsx @@ -51,7 +51,7 @@ export function Dashboard({ ); } - if (q.error || !q.data) { + if (!q.data) { const message = q.error instanceof Error ? q.error.message : ''; const badId = /not found|deleted|invalid|parse/i.test(message); return ( diff --git a/mobile/src/screens/Help.tsx b/mobile/src/screens/Help.tsx index ec41547..d82fe71 100644 --- a/mobile/src/screens/Help.tsx +++ b/mobile/src/screens/Help.tsx @@ -35,7 +35,7 @@ export function Help({ onBack }: { onBack: () => void }) { } title="Help & Support" />} footer={ - } @@ -67,21 +67,21 @@ export function Help({ onBack }: { onBack: () => void }) { leading={} title="Documentation" subtitle="Learn more about Skur" - onPress={() => void Linking.openURL(DOCS_URL)} + onPress={() => void Linking.openURL(DOCS_URL).catch(() => undefined)} chevron /> } title="Contact Support" subtitle={SUPPORT_EMAIL} - onPress={() => void Linking.openURL(`mailto:${SUPPORT_EMAIL}`)} + onPress={() => void Linking.openURL(`mailto:${SUPPORT_EMAIL}`).catch(() => undefined)} chevron /> } title="Report an Issue" subtitle="Bug or feedback" - onPress={() => void Linking.openURL(ISSUES_URL)} + onPress={() => void Linking.openURL(ISSUES_URL).catch(() => undefined)} chevron last /> diff --git a/mobile/src/screens/Home.tsx b/mobile/src/screens/Home.tsx index 731a5b9..e9fab1b 100644 --- a/mobile/src/screens/Home.tsx +++ b/mobile/src/screens/Home.tsx @@ -50,7 +50,7 @@ export function Home({ ); } - if (q.error || !q.data) { + if (!q.data) { const message = q.error instanceof Error ? q.error.message : ''; const badId = /not found|deleted|invalid|parse/i.test(message); return ( diff --git a/mobile/src/screens/Send.tsx b/mobile/src/screens/Send.tsx index 48fb73b..e07135e 100644 --- a/mobile/src/screens/Send.tsx +++ b/mobile/src/screens/Send.tsx @@ -24,6 +24,7 @@ import { TrustBadge, TxStatus, } from '../components/ui'; +import { normalizeAmount } from '../lib/amount'; import { Segmented } from '../components/kit'; import { ScanSheet } from '../components/Scan'; import { IconButton } from '../components/ui'; @@ -59,7 +60,7 @@ export function Send({ onDone }: { onDone: () => void }) { const asset = approved.find((a) => a.coinType === coinType) ?? approved[0]; const activeType = asset?.coinType ?? ''; const decimals = activeType ? coinDecimals(activeType) : 9; - const amount = useMemo(() => parseAmount(amountText, decimals) ?? 0n, [amountText, decimals]); + const amount = useMemo(() => parseAmount(normalizeAmount(amountText), decimals) ?? 0n, [amountText, decimals]); const validTo = /^0x[0-9a-fA-F]{1,64}$/.test(to); const known = q.data?.recipients.find((r) => r.address === to); diff --git a/mobile/src/screens/Team.tsx b/mobile/src/screens/Team.tsx index 972a5e1..72c953d 100644 --- a/mobile/src/screens/Team.tsx +++ b/mobile/src/screens/Team.tsx @@ -56,7 +56,7 @@ export function Team({ onOpenRoles }: { onOpenRoles: () => void }) { ); } - if (q.error || !q.data) { + if (!q.data) { return ( }> q.refetch()} /> diff --git a/mobile/src/screens/Transactions.tsx b/mobile/src/screens/Transactions.tsx index 38dd969..867d988 100644 --- a/mobile/src/screens/Transactions.tsx +++ b/mobile/src/screens/Transactions.tsx @@ -98,7 +98,7 @@ export function Transactions({ onOpen }: { onOpen: (id: bigint) => void }) { /> {q.isLoading ? : null} - {q.error ? q.refetch()} /> : null} + {q.error && !q.data ? q.refetch()} /> : null} {filter === 'all' ? ( <> diff --git a/mobile/src/screens/TxDetail.tsx b/mobile/src/screens/TxDetail.tsx index 70cc719..15c4704 100644 --- a/mobile/src/screens/TxDetail.tsx +++ b/mobile/src/screens/TxDetail.tsx @@ -72,13 +72,23 @@ export function TxDetail({ id, onBack }: { id: bigint; onBack: () => void }) { enabled: simulating && Boolean(isTransfer && p?.asset), }); - if (!p || !q.data) { + if (!q.data) { return ( } title="Proposal" />}> Reading… ); } + if (!p) { + return ( + } title={`#${id}`} />}> + + Proposal #{String(id)} is not in the vault's recent window. It may have settled long + ago — the chain remembers it even when this list does not. + + + ); + } const voteRole = isTransfer ? Role.APPROVER : Role.OWNER; const canVote = hasRole(roles, voteRole); diff --git a/mobile/src/screens/onboarding/ConnectWallet.tsx b/mobile/src/screens/onboarding/ConnectWallet.tsx index 2d9c7f2..ce6842e 100644 --- a/mobile/src/screens/onboarding/ConnectWallet.tsx +++ b/mobile/src/screens/onboarding/ConnectWallet.tsx @@ -281,7 +281,7 @@ export function ConnectWallet({ ) : null} void Linking.openURL('mailto:support@skur.app')} + onPress={() => void Linking.openURL('mailto:support@skur.app').catch(() => undefined)} accessibilityRole="link" style={{ fontFamily: F.bodyMedium, fontSize: 14, color: C.info, textAlign: 'center', paddingVertical: 6 }} > diff --git a/mobile/test/lib.test.tsx b/mobile/test/lib.test.tsx index 1e00340..fda6efb 100644 --- a/mobile/test/lib.test.tsx +++ b/mobile/test/lib.test.tsx @@ -161,3 +161,14 @@ describe('restoring the open vault', () => { expect(await AsyncStorage.getItem('skur.vault')).toBeNull(); }); }); + +describe('amount input', () => { + it('drops grouping and keeps the decimal point', () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { normalizeAmount } = require('../src/lib/amount') as typeof import('../src/lib/amount'); + // This machine is a dot-decimal locale: commas are grouping, never value. + expect(normalizeAmount('1,500')).toBe('1500'); + expect(normalizeAmount('1 000.5')).toBe('1000.5'); + expect(normalizeAmount('2_000')).toBe('2000'); + }); +});