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
15 changes: 5 additions & 10 deletions app/src/app/HomeApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,10 @@ export default function HomeApp() {

return (
<Shell>
<div style={{ maxWidth: 680, marginBottom: 32 }}>
<div style={{ maxWidth: 680, marginBottom: 24 }}>
<h1>A valid signature is not a safe payment</h1>
<p className="soft" style={{ marginTop: 12 }}>
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&apos;s own security
posture. The rules are enforced in Move, in a shared object anyone can read.
<p className="soft" style={{ marginTop: 10 }}>
Policy-enforced treasuries on Sui. Open yours, or create one.
</p>
</div>

Expand All @@ -39,7 +37,7 @@ export default function HomeApp() {
</nav>

{mode === 'create' ? (
<CreateVault tx={tx} />
CONFIGURED ? <CreateVault tx={tx} /> : <PublishPackage />
) : (
<div className="grid cols-2">
<div className="card">
Expand Down Expand Up @@ -87,10 +85,7 @@ export default function HomeApp() {
<div className="stack">
<div className="card">
<h3>Open by id</h3>
<p className="small soft">
A vault is a shared object. Anyone can read one; acting on it needs the key that
holds the role.
</p>
<p className="small soft">Anyone can read a vault; acting needs a role.</p>
<div className="row" style={{ marginTop: 14 }}>
<input
placeholder="0x…"
Expand Down
21 changes: 6 additions & 15 deletions app/src/components/CreateVault.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,7 @@ export function CreateVault({ tx }: { tx: TxState }) {
<div className="grid cols-2">
<div className="card">
<h3>Pick a starting policy</h3>
<p className="small soft">
Each one is a complete, valid policy. Start with the closest fit and tighten it afterwards
through governance.
</p>
<p className="small soft">Complete policies. Tighten later through governance.</p>
<div className="stack tight" style={{ marginTop: 14 }}>
{TEMPLATES.map((t) => (
<label
Expand Down Expand Up @@ -125,13 +122,10 @@ export function CreateVault({ tx }: { tx: TxState }) {
<p className="small soft" style={{ marginTop: 5 }}>
{t.tagline}
</p>
<p className="small faint" style={{ marginTop: 3 }}>
{t.audience}
</p>
<p className="small faint" style={{ marginTop: 5 }}>
Critical payments wait {fmtDuration(t.policy.delayCritical)} · new recipients wait{' '}
{fmtDuration(t.policy.recipientActivationDelay)} · loss envelope{' '}
{t.policy.envelopeBps / 100}% per {fmtDuration(t.policy.envelopeWindow)}
<p className="small faint" style={{ marginTop: 4 }}>
Critical waits {fmtDuration(t.policy.delayCritical)} · new recipients{' '}
{fmtDuration(t.policy.recipientActivationDelay)} · envelope {t.policy.envelopeBps / 100}%/
{fmtDuration(t.policy.envelopeWindow)}
</p>
</label>
))}
Expand All @@ -148,10 +142,7 @@ export function CreateVault({ tx }: { tx: TxState }) {

<div className="card">
<h3>Who holds what</h3>
<p className="small soft">
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.
</p>
<p className="small soft">You hold all roles to start. A guardian can hold nothing else.</p>

{me ? (
<div className="card sunk" style={{ marginTop: 14, padding: 12 }}>
Expand Down
62 changes: 46 additions & 16 deletions app/src/components/PublishPackage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -25,7 +26,29 @@ type State =

export function PublishPackage() {
const account = useCurrentAccount();
const client = useCurrentClient();
const [state, setState] = useState<State>({ phase: 'idle' });
const [balance, setBalance] = useState<bigint | null>(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;
Expand All @@ -39,31 +62,27 @@ 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;
}
}
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,
});
}
};
Expand Down Expand Up @@ -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.
</p>
{account ? (
<p className="small faint" style={{ marginTop: 10 }}>
Signing as <code className="mono">{account.address.slice(0, 10)}…{account.address.slice(-4)}</code>
{balance !== null ? (
<>
{' '}· holds <b>{(Number(balance) / 1e9).toFixed(2)} SUI</b> on {NETWORK}
{balance < 400_000_000n ? ' — not enough to publish (~0.4 SUI needed); fund it or switch account' : ''}
</>
) : null}
</p>
) : null}
{!account ? (
<p className="small faint" style={{ marginTop: 10 }}>Connect a wallet first.</p>
) : (
Expand Down
5 changes: 1 addition & 4 deletions app/src/components/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,7 @@ export function Shell({
{!account ? (
<div className="card" style={{ marginBottom: 16 }}>
<h3>Connect a wallet to act</h3>
<p className="soft small">
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.
</p>
<p className="soft small">Reading is public. Acting needs the key that holds the role.</p>
</div>
) : null}
{children}
Expand Down
6 changes: 5 additions & 1 deletion app/src/dapp-kit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
16 changes: 6 additions & 10 deletions app/src/hooks/useVault.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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. */
Expand Down Expand Up @@ -86,17 +87,12 @@ export function useVaultTx(vaultId: string | undefined): TxState {
const [error, setError] = useState<string | null>(null);
const [lastDigest, setLastDigest] = useState<string | null>(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;
},
});

Expand Down
55 changes: 55 additions & 0 deletions app/src/lib/execute.ts
Original file line number Diff line number Diff line change
@@ -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<Executed> {
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;
}
8 changes: 8 additions & 0 deletions mobile/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -435,6 +439,10 @@ export default function App() {
SpaceGrotesk_700Bold,
});

useEffect(() => {
if (loaded) void SplashScreen.hideAsync().catch(() => undefined);
}, [loaded]);

return (
<SafeAreaProvider>
<ErrorBoundary>
Expand Down
9 changes: 8 additions & 1 deletion mobile/src/components/Scan.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -23,10 +23,17 @@ export function ScanSheet({
const C = useTheme();
const [permission, requestPermission] = useCameraPermissions();
const [rejected, setRejected] = useState<string | null>(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();
Expand Down
6 changes: 4 additions & 2 deletions mobile/src/components/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function Screen({ children, top, footer, refreshControl, padded = true, c
{top ? <View style={{ paddingTop: insets.top + 6 }}>{top}</View> : 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. */}
<ScrollView contentContainerStyle={{ padding: padded ? 16 : 0, paddingBottom: footer ? 120 : 40, ...(centered ? { flexGrow: 1, justifyContent: "center" } : null) }} refreshControl={refreshControl as never} keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag" indicatorStyle={C.canvas === "#ffffff" ? "black" : "white"}>{children}</ScrollView>
<ScrollView contentContainerStyle={{ padding: padded ? 16 : 0, paddingTop: top ? (padded ? 16 : 0) : insets.top + 16, paddingBottom: footer ? 120 : 40, ...(centered ? { flexGrow: 1, justifyContent: "center" } : null) }} refreshControl={refreshControl as never} keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag" indicatorStyle={C.canvas === "#ffffff" ? "black" : "white"}>{children}</ScrollView>
{footer ? <View style={[s.footer, { paddingBottom: Math.max(insets.bottom, 12) }]}>{footer}</View> : null}
</KeyboardAvoidingView>
);
Expand Down Expand Up @@ -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 (
<Modal visible={open} transparent animationType="slide" onRequestClose={onClose}>
<Modal visible={open} transparent animationType="slide" onRequestClose={onClose} statusBarTranslucent>
<KeyboardAvoidingView style={{ flex: 1, justifyContent: "flex-end" }} behavior={Platform.OS === "ios" ? "padding" : undefined}>
<Pressable style={s.sheetBackdrop} onPress={onClose} />
<View style={[s.sheet, { paddingBottom: Math.max(insets.bottom, 16) }]}>
<View style={s.grabber} />
Expand All @@ -377,6 +378,7 @@ export function Sheet({ open, onClose, title, children, action }: { open: boolea
</View>
<ScrollView style={{ maxHeight: 520 }} keyboardShouldPersistTaps="handled">{children}</ScrollView>
</View>
</KeyboardAvoidingView>
</Modal>
);
}
Expand Down
Loading
Loading