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={
-
+ {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.
- 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;
+}