From 24cde49f3209c849ca4ad96c1ea2cedbb5070ddb Mon Sep 17 00:00:00 2001 From: Olajumoke Emmanuel <41354996+Emmyhack@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:07:55 +0100 Subject: [PATCH 1/5] fix(mobile): a production-bug sweep across both platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worst one first: on decimal-comma locales the numeric keyboard types a comma, and the amount parser stripped commas as thousands separators — '1,5' silently became 15, a tenfold overpayment with no error anywhere. Amounts are now normalised by the device's own decimal separator before parsing, with the other character treated as grouping. The rest, each found by reading the code the way a bad day uses it: - A failed background poll no longer evicts a healthy screen: react-query keeps the data, so error states now render only when there is nothing to show. One dropped packet in a tunnel used to replace the dashboard with 'could not reach the vault'. - Android notifications get an explicit high-importance channel; without one, 8.0+ treats pushes as minimum-importance — no heads-up, no sound, sometimes nothing at all. - The QR scanner accepts one hit per opening: the camera reports the same code every frame, which double-closed the sheet and set state after unmount. - Sheets carry their own keyboard avoidance — a Modal escapes the screen-level one, so every input-bearing sheet (add member, invite, policy editor, profile) hid its field behind the iOS keyboard. - Screens without a top bar start below the status bar / Dynamic Island instead of under the clock. - A proposal outside the fetched window says so instead of reading forever (stale notification taps land somewhere honest). - Every external open (mailto, docs, issues) is caught: a device with no mail client rejected the promise unhandled. - The splash holds until fonts exist instead of flashing a bare canvas. - The unused wallet-relay dependencies are out of the manifest. 60 tests, typecheck clean, both platforms bundle, verify.sh 11/11. --- mobile/App.tsx | 8 ++++++ mobile/src/components/Scan.tsx | 9 ++++++- mobile/src/components/ui.tsx | 6 +++-- mobile/src/lib/amount.ts | 25 +++++++++++++++++++ mobile/src/lib/notifications.ts | 20 ++++++++++++++- mobile/src/lib/polyfills.ts | 20 +++++++++++++++ mobile/src/screens/ActivityLog.tsx | 2 +- mobile/src/screens/Dashboard.tsx | 2 +- mobile/src/screens/Help.tsx | 8 +++--- mobile/src/screens/Home.tsx | 2 +- mobile/src/screens/Send.tsx | 3 ++- mobile/src/screens/Team.tsx | 2 +- mobile/src/screens/Transactions.tsx | 2 +- mobile/src/screens/TxDetail.tsx | 12 ++++++++- .../src/screens/onboarding/ConnectWallet.tsx | 2 +- mobile/test/lib.test.tsx | 11 ++++++++ 16 files changed, 118 insertions(+), 16 deletions(-) create mode 100644 mobile/src/lib/amount.ts 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'); + }); +}); From 19ddd39d615be981db91f4f5d888f9e71e064bd9 Mon Sep 17 00:00:00 2001 From: Olajumoke Emmanuel <41354996+Emmyhack@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:28:50 +0100 Subject: [PATCH 2/5] fix(web): the publish card states the account's balance before the wallet is asked A wallet extension follows its own network switch, and signing from a mismatched network or an empty account came back as a blank '{}' error in the console and nothing useful on screen. The card now reads the connected account's SUI balance on the app's network first and says plainly when it cannot cover the ~0.4 SUI a publish costs; the network is pinned on the sign call; and a blank wallet error is translated into the two causes it almost always means. --- app/src/components/PublishPackage.tsx | 46 ++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/app/src/components/PublishPackage.tsx b/app/src/components/PublishPackage.tsx index 593564c..a54784e 100644 --- a/app/src/components/PublishPackage.tsx +++ b/app/src/components/PublishPackage.tsx @@ -1,8 +1,8 @@ '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 { useEffect, useState } from 'react'; import { dAppKit } from '@/dapp-kit'; import { NETWORK } from '@/config'; import { Notice } from '@/components/ui'; @@ -25,7 +25,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,7 +61,7 @@ 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 }); + const result = await dAppKit.signAndExecuteTransaction({ transaction: tx, network: NETWORK }); if ('FailedTransaction' in result && result.FailedTransaction) { throw new Error( result.FailedTransaction.status?.error?.message ?? 'the network refused the publish', @@ -61,9 +83,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 +120,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.

) : ( From c4a8dabf67cd7b35bef4b6933593662ad1c5e105 Mon Sep 17 00:00:00 2001 From: Olajumoke Emmanuel <41354996+Emmyhack@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:39:16 +0100 Subject: [PATCH 3/5] =?UTF-8?q?fix(web):=20one=20build,=20one=20network=20?= =?UTF-8?q?=E2=80=94=20a=20stale=20persisted=20'localnet'=20can=20no=20lon?= =?UTF-8?q?ger=20hijack=20signing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dApp Kit persists the selected network in the browser. Offering the full network list let a leftover localnet selection from an earlier session be restored over the build's own setting, so sign requests went out stamped sui:localnet — the wallet answered with an empty error and showed localhost in its prompt while the page claimed testnet. The kit now registers exactly the network the app was built for; there is nothing stale to restore. --- app/src/dapp-kit.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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({ From 94804102ea6a3819c1c6067ac6f4eaa7e3d9ce22 Mon Sep 17 00:00:00 2001 From: Olajumoke Emmanuel <41354996+Emmyhack@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:48:08 +0100 Subject: [PATCH 4/5] fix(web): the publish flow builds and executes locally; the wallet only signs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every blank '{}' failure so far was born inside the wallet's own machinery — its preview, its build, its execute, each running against whatever network the extension happens to be on. The flow now leaves it nothing to get wrong: the page builds the transaction bytes against its own testnet client (gas selection included), hands the wallet raw bytes to sign, and executes the signed bytes through the same client, reading the package id out of the effects (the one immutable created object). The wallet's one remaining job is the one only it can do. --- app/src/components/PublishPackage.tsx | 42 ++++++++++++++++++++------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/app/src/components/PublishPackage.tsx b/app/src/components/PublishPackage.tsx index a54784e..bc04020 100644 --- a/app/src/components/PublishPackage.tsx +++ b/app/src/components/PublishPackage.tsx @@ -2,6 +2,7 @@ import { useCurrentAccount, useCurrentClient } from '@mysten/dapp-kit-react'; import { Transaction } from '@mysten/sui/transactions'; +import { fromBase64, toBase64 } from '@mysten/sui/utils'; import { useEffect, useState } from 'react'; import { dAppKit } from '@/dapp-kit'; import { NETWORK } from '@/config'; @@ -60,22 +61,43 @@ export function PublishPackage() { const tx = new Transaction(); const cap = tx.publish({ modules: dump.modules, dependencies: dump.dependencies }); tx.transferObjects([cap], account.address); + tx.setSender(account.address); - const result = await dAppKit.signAndExecuteTransaction({ transaction: tx, network: NETWORK }); - if ('FailedTransaction' in result && result.FailedTransaction) { + // The division of labour that leaves the wallet nothing to get wrong: THIS page builds + // the bytes against its own testnet client (gas selection included), the wallet only + // signs them, and this page executes the signed bytes through the same client. The + // wallet's build/preview/execute machinery — where every blank '{}' error so far was + // born — is out of the loop entirely. + 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.status?.error?.message ?? 'the network refused the publish', + (result.FailedTransaction as { status?: { error?: { message?: string } } }).status?.error?.message ?? + 'the network refused the publish', ); } - const executed = (result as { Transaction: { digest: string; effects?: unknown } }).Transaction; + const executed = result.Transaction as unknown as { + digest: string; + effects?: { + status?: { success?: boolean; error?: { message?: string } }; + changedObjects?: { objectId?: string; id?: string; idOperation?: string; outputOwner?: { $kind?: string } }[]; + }; + }; + if (executed.effects?.status && executed.effects.status.success === false) { + throw new Error(executed.effects.status.error?.message ?? 'the network refused the publish'); + } + await client.core.waitForTransaction({ digest: executed.digest }); - // 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; } From 584b2e567cddb79837ce055dc2797dd491b62d9d Mon Sep 17 00:00:00 2001 From: Olajumoke Emmanuel <41354996+Emmyhack@users.noreply.github.com> Date: Fri, 9 Oct 2026 03:12:30 +0100 Subject: [PATCH 5/5] fix(web): every transaction signs-only through one shared path, and the entry page sheds its word-weight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vault creation failed exactly the way publishing did — through the wallet's own execute machinery. There is now one submission path for the whole app (lib/execute): build the bytes against our client, hand the wallet raw bytes to sign, execute the signed bytes ourselves. useVaultTx and the publish card both ride it, so create/approve/execute/policy all stop depending on anything wallet-side but the signature. The Create tab also offers the publish card while no package exists, instead of a form that cannot possibly succeed. And the page says less: one-line hero, one-line card hints, policy cards without the audience essay, roles note cut to its point. --- app/src/app/HomeApp.tsx | 15 +++----- app/src/components/CreateVault.tsx | 21 +++------- app/src/components/PublishPackage.tsx | 34 +---------------- app/src/components/Shell.tsx | 5 +-- app/src/hooks/useVault.ts | 16 +++----- app/src/lib/execute.ts | 55 +++++++++++++++++++++++++++ 6 files changed, 75 insertions(+), 71 deletions(-) create mode 100644 app/src/lib/execute.ts 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 bc04020..76a269d 100644 --- a/app/src/components/PublishPackage.tsx +++ b/app/src/components/PublishPackage.tsx @@ -2,7 +2,7 @@ import { useCurrentAccount, useCurrentClient } from '@mysten/dapp-kit-react'; import { Transaction } from '@mysten/sui/transactions'; -import { fromBase64, toBase64 } from '@mysten/sui/utils'; +import { signAndExecute } from '@/lib/execute'; import { useEffect, useState } from 'react'; import { dAppKit } from '@/dapp-kit'; import { NETWORK } from '@/config'; @@ -61,38 +61,8 @@ export function PublishPackage() { const tx = new Transaction(); const cap = tx.publish({ modules: dump.modules, dependencies: dump.dependencies }); tx.transferObjects([cap], account.address); - tx.setSender(account.address); - // The division of labour that leaves the wallet nothing to get wrong: THIS page builds - // the bytes against its own testnet client (gas selection included), the wallet only - // signs them, and this page executes the signed bytes through the same client. The - // wallet's build/preview/execute machinery — where every blank '{}' error so far was - // born — is out of the loop entirely. - 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 the publish', - ); - } - const executed = result.Transaction as unknown as { - digest: string; - effects?: { - status?: { success?: boolean; error?: { message?: string } }; - changedObjects?: { objectId?: string; id?: string; idOperation?: string; outputOwner?: { $kind?: string } }[]; - }; - }; - if (executed.effects?.status && executed.effects.status.success === false) { - throw new Error(executed.effects.status.error?.message ?? 'the network refused the publish'); - } - await client.core.waitForTransaction({ digest: executed.digest }); + const executed = await signAndExecute(client, tx, account.address); // A published package lands as an immutable created object; that id is the package id. let packageId: string | null = null; 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/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; +}