diff --git a/.github/workflows/demo-deploy.yml b/.github/workflows/demo-deploy.yml index d5eb5237a..aa5d6248a 100644 --- a/.github/workflows/demo-deploy.yml +++ b/.github/workflows/demo-deploy.yml @@ -36,8 +36,8 @@ jobs: # Generate vercel.json with rewrites to proxy HTTP backends through # the Vercel deployment, avoiding mixed HTTP/HTTPS content errors. eval "$(grep '^BACKEND_' ".vercel/.env.preview.local")" - printf '{"installCommand":"echo skipped","outputDirectory":"dist","rewrites":[{"source":"/api/rpc/:path*","destination":"%s/rpc/:path*"},{"source":"/api/indexer/:path*","destination":"%s/:path*"},{"source":"/api/prover/:path*","destination":"%s/:path*"}]}\n' \ - "$BACKEND_RPC_URL" "$BACKEND_INDEXER_URL" "$BACKEND_PROVER_URL" > vercel.json + printf '{"installCommand":"echo skipped","outputDirectory":"dist","rewrites":[{"source":"/api/rpc/:path*","destination":"%s/rpc/:path*"},{"source":"/api/indexer/:path*","destination":"%s/:path*"},{"source":"/api/prover/:path*","destination":"%s/:path*"},{"source":"/api/gateway/:path*","destination":"%s/:path*"}]}\n' \ + "$BACKEND_RPC_URL" "$BACKEND_INDEXER_URL" "$BACKEND_PROVER_URL" "$BACKEND_GATEWAY_URL" > vercel.json npx vercel build env: diff --git a/demo/src/App.css b/demo/src/App.css index 4e3453352..a7306e232 100644 --- a/demo/src/App.css +++ b/demo/src/App.css @@ -34,18 +34,98 @@ h3 { margin: 0 auto; } -.pool-selector, +.pool-selector-grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 6px 8px; + align-items: center; + margin-top: 12px; + margin-bottom: 12px; + padding: 12px 12px 8px; + background: #161b22; + border: 1px solid #30363d; + border-radius: 4px; +} + +.pool-selector-label { + color: #8b949e; + white-space: nowrap; +} + +.pool-selector-value { + display: flex; + align-items: center; + gap: 8px; +} + +.pool-selector-value code { + color: #c9d1d9; + font-size: 13px; + padding-left: 12px; +} + +.pool-selector-value input { + width: 540px; +} + +.pool-selector-value > button { + font-size: 11px; + padding: 2px 8px; +} + +.copy-button { + background: #21262d; + border-color: #30363d; + color: #8b949e; +} + +.copy-button:hover:not(:disabled) { + background: #30363d; +} + .account-selector { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; - padding: 8px; + padding: 8px 12px; background: #161b22; border: 1px solid #30363d; border-radius: 4px; } +.account-tabs { + display: flex; + gap: 4px; +} + +.account-tab { + background: #21262d; + border: 1px solid #30363d; + border-radius: 4px; + padding: 4px 12px; + color: #8b949e; + cursor: pointer; + font-size: 13px; +} + +.account-tab.active { + background: #238636; + border-color: #2ea043; + color: #fff; +} + +.account-tab:hover:not(.active) { + background: #30363d; +} + +.account-tab-address { + font-size: 11px; + margin-left: 4px; + color: inherit; + opacity: 0.7; +} + select { background: #0d1117; color: #c9d1d9; @@ -56,12 +136,6 @@ select { font-size: 13px; } -.add-account-form { - display: flex; - gap: 4px; - flex-wrap: wrap; -} - .main-layout { display: grid; grid-template-columns: 1fr 300px; @@ -147,6 +221,9 @@ input[type="number"] { .action-form button { margin-top: 4px; + font-size: 12px; + padding: 3px 9px; + margin-left: 6px; } .status-bar { @@ -189,8 +266,11 @@ input[type="number"] { .subtitle code { color: #c9d1d9; font-size: 11px; + cursor: default; + word-break: break-all; } + .chip { display: inline-block; background: #30363d; @@ -211,3 +291,182 @@ input[type="number"] { background: #3d1a1a; color: #f85149; } + +.view-toggle { + display: flex; + gap: 4px; + margin-bottom: 12px; +} + +.view-toggle button { + flex: 1; + background: #21262d; + border-color: #30363d; + color: #8b949e; +} + +.view-toggle button.active { + background: #238636; + border-color: #2ea043; + color: #fff; +} + +.builder-checkbox { + display: flex; + align-items: center; + gap: 6px; + align-self: flex-start; + padding: 4px 0; +} + +.builder-checkbox input[type="checkbox"] { + width: auto; + margin: 0; +} + +.builder-add { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid #21262d; +} + +.builder-add select, +.builder-add input { + width: 100%; +} + +.builder-operation { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 0; + border-bottom: 1px solid #21262d; + font-size: 12px; +} + +.builder-operation span:nth-child(2) { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.builder-operation .remove-button { + background: #3d1a1a; + border-color: #f85149; + color: #f85149; + padding: 1px 6px; + font-size: 11px; + min-width: auto; +} + +.balances, +.notes-section, +.channels-section { + margin-top: 16px; +} + +.notes-section h3 { + margin-bottom: 12px; +} + +.channel-card { + background: #161b22; + border: 1px solid #30363d; + border-radius: 6px; + padding: 12px 16px; + margin-bottom: 8px; +} + +.channel-card:last-child { + margin-bottom: 0; +} + +.channel-card-header { + font-size: 12px; + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px solid #21262d; + line-height: 1.6; +} + +.channel-card-label { + color: #8b949e; +} + +.channel-card-value { + color: #c9d1d9; +} + +.channel-card-sep { + color: #30363d; + margin: 0 6px; +} + +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-top: 8px; +} + +.pagination span { + color: #8b949e; + font-size: 12px; +} + +.pagination button { + font-size: 11px; + padding: 2px 8px; +} + +.service-health-bar { + display: inline-flex; + align-items: center; + gap: 12px; + margin-left: 8px; +} + +.health-indicator { + display: flex; + align-items: center; + gap: 5px; +} + +.health-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.health-label { + color: #c9d1d9; +} + +.health-detail { + color: #8b949e; + font-size: 11px; +} + +.app-footer { + text-align: center; + color: #8b949e; + font-size: 12px; + padding: 24px 0 12px; + border-top: 1px solid #30363d; + margin-top: 24px; +} + +.app-footer a { + color: #58a6ff; + text-decoration: none; +} + +.app-footer a:hover { + text-decoration: underline; +} diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 65c0f28ae..f0227ab01 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useMemo } from "react"; -import { formatChainId, truncateAddress } from "./format.ts"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { formatChainId } from "./format.ts"; import { loadConfig } from "./config.ts"; import { createProvider, createAccount, createTransfers } from "./starknet.ts"; import { useAccounts } from "./hooks/useAccounts.ts"; @@ -11,21 +11,32 @@ import { AccountSelector } from "./components/AccountSelector.tsx"; import { PoolSelector } from "./components/PoolSelector.tsx"; import { InfoPanel } from "./components/InfoPanel.tsx"; import { ActionPanel } from "./components/ActionPanel.tsx"; +import { TransactionBuilder } from "./components/TransactionBuilder.tsx"; import { StatusBar } from "./components/StatusBar.tsx"; +import { ServiceHealthBar } from "./components/ServiceHealthBar.tsx"; +import { useTransactionBuilder } from "./hooks/useTransactionBuilder.ts"; +import { useServiceHealth } from "./hooks/useServiceHealth.ts"; import "./App.css"; const config = loadConfig(); export function App() { - const { accounts, activeIndex, activeAccount, setActiveIndex, addAccount } = + const [classHash, setClassHash] = useState(config.poolClassHash); + + const { accounts, activeIndex, activeAccount, setActiveIndex } = useAccounts(config.accounts); const provider = useMemo(() => createProvider(config.rpcUrl), []); const { pools, activePool, selectPool, addPool, loading: poolsLoading } = - usePoolSelector(provider, config.poolAddress, config.poolClassHash); + usePoolSelector(provider, config.poolAddress, classHash); + + const configWithClassHash = useMemo( + () => ({ ...config, poolClassHash: classHash }), + [classHash], + ); - const { deploying, deployError, deploy } = useDeployPool(provider, config); + const { deploying, deployError, deploy } = useDeployPool(provider, configWithClassHash); const handleDeploy = useCallback(async () => { try { @@ -50,6 +61,7 @@ export function App() { provider, transfers, activeAccount, + accounts, activePool.address, config, ); @@ -58,7 +70,16 @@ export function App() { refresh(); }, [refresh]); - const { status, mint, deposit, withdraw, transfer } = useTransactions( + const { status, register, mint, deposit, withdraw, transfer } = useTransactions( + provider, + transfers, + activeAccount?.address, + activePool.address, + config, + refresh, + ); + + const { status: builderStatus, executeBatch } = useTransactionBuilder( provider, transfers, activeAccount?.address, @@ -67,11 +88,16 @@ export function App() { refresh, ); + const serviceHealth = useServiceHealth(provider, config); + + const [activeView, setActiveView] = useState<"actions" | "builder">("actions"); + return (

Privacy Pool Explorer

- Chain: {formatChainId(config.chainId)} | Pool class: {truncateAddress(config.poolClassHash)} + Chain: {formatChainId(config.chainId)} +
- +
- index !== activeIndex)} - onMint={mint} - onDeposit={deposit} - onWithdraw={withdraw} - onTransfer={transfer} - /> +
+
+ + +
+ {activeView === "actions" ? ( + index !== activeIndex)} + onRegister={register} + onMint={mint} + onDeposit={deposit} + onWithdraw={withdraw} + onTransfer={transfer} + /> + ) : ( + index !== activeIndex)} + onExecute={executeBatch} + /> + )} +
+
); } diff --git a/demo/src/components/AccountSelector.tsx b/demo/src/components/AccountSelector.tsx index 3c65fb976..a38e3b523 100644 --- a/demo/src/components/AccountSelector.tsx +++ b/demo/src/components/AccountSelector.tsx @@ -1,58 +1,26 @@ -import { useState, type FormEvent } from "react"; import type { AccountConfig } from "../config.ts"; type Props = { accounts: AccountConfig[]; activeIndex: number; onSelect: (index: number) => void; - onAdd: (account: AccountConfig) => void; }; -export function AccountSelector({ accounts, activeIndex, onSelect, onAdd }: Props) { - const [showForm, setShowForm] = useState(false); - const [name, setName] = useState(""); - const [address, setAddress] = useState(""); - const [privateKey, setPrivateKey] = useState(""); - const [viewingKey, setViewingKey] = useState(""); - - function handleSubmit(event: FormEvent) { - event.preventDefault(); - if (!name || !address || !privateKey || !viewingKey) return; - onAdd({ name, address, privateKey, viewingKey }); - setName(""); - setAddress(""); - setPrivateKey(""); - setViewingKey(""); - setShowForm(false); - } - +export function AccountSelector({ accounts, activeIndex, onSelect }: Props) { return (
- - - {showForm && ( -
- setName(event.target.value)} /> - setAddress(event.target.value)} /> - setPrivateKey(event.target.value)} /> - setViewingKey(event.target.value)} /> - -
- )} + +
+ {accounts.map((account, index) => ( + + ))} +
); } diff --git a/demo/src/components/ActionPanel.tsx b/demo/src/components/ActionPanel.tsx index 93256f034..d386174d5 100644 --- a/demo/src/components/ActionPanel.tsx +++ b/demo/src/components/ActionPanel.tsx @@ -3,7 +3,9 @@ import type { AccountConfig } from "../config.ts"; type Props = { pending: boolean; + activeAddress: string; otherAccounts: AccountConfig[]; + onRegister: () => void; onMint: (amount: bigint) => void; onDeposit: (amount: bigint) => void; onWithdraw: (amount: bigint) => void; @@ -12,7 +14,9 @@ type Props = { export function ActionPanel({ pending, + activeAddress, otherAccounts, + onRegister, onMint, onDeposit, onWithdraw, @@ -46,11 +50,11 @@ export function ActionPanel({ } return ( -
+ <>

Actions

-

Mint (admin)

+

Mint tokens (transparent)

+
+

Register in the pool

+ +
+
-

Deposit

+

Deposit to self (auto setup)

-

Withdraw

+

Withdraw to self

-

Transfer

+

Transfer to someone (or sweep)

setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + onClassHashChange(draft); + setEditing(false); + } + if (event.key === "Escape") setEditing(false); + }} + autoFocus + /> + + + ) : ( + <> + {truncateHash(classHash)} + + + )} +
+ + Address: +
- - - {deployError && {deployError}} + + + {deployError && {deployError}} +
); } diff --git a/demo/src/components/ServiceHealthBar.tsx b/demo/src/components/ServiceHealthBar.tsx new file mode 100644 index 000000000..bb06fec72 --- /dev/null +++ b/demo/src/components/ServiceHealthBar.tsx @@ -0,0 +1,30 @@ +import type { HealthStatus, ServiceHealthState, SubsystemHealth } from "../hooks/useServiceHealth.ts"; + +const STATUS_COLORS: Record = { + healthy: "#3fb950", + unhealthy: "#f85149", + unknown: "#d29922", + checking: "#484f58", +}; + +function Indicator({ label, health }: { label: string; health: SubsystemHealth }) { + return ( + + + {label} + + ); +} + +export function ServiceHealthBar({ health }: { health: ServiceHealthState }) { + return ( + + | Status: + + + {health.proving && } + {health.gateway && } + {health.feederGateway && } + + ); +} diff --git a/demo/src/components/TransactionBuilder.tsx b/demo/src/components/TransactionBuilder.tsx new file mode 100644 index 000000000..b8aa6fb04 --- /dev/null +++ b/demo/src/components/TransactionBuilder.tsx @@ -0,0 +1,227 @@ +import { useState, useRef, type FormEvent } from "react"; +import type { AccountConfig } from "../config.ts"; + +export type OperationType = "deposit" | "transfer" | "withdraw" | "surplus" | "invoke"; + +const PHASE_ORDER: Record = { + deposit: 3, + transfer: 5, + withdraw: 6, + surplus: 7, + invoke: 8, +}; + +const OPERATION_LABELS: Record = { + deposit: "Deposit", + transfer: "Transfer", + withdraw: "Withdraw", + surplus: "Surplus To", + invoke: "Invoke", +}; + +export type BuilderOperation = { + id: number; + operationType: OperationType; + amount: string; + recipient?: string; + withdrawSurplus?: boolean; + contractAddress?: string; + calldata?: string; +}; + +type Props = { + pending: boolean; + activeAddress: string; + otherAccounts: AccountConfig[]; + onExecute: (operations: BuilderOperation[]) => void; +}; + +export function TransactionBuilder({ pending, activeAddress, otherAccounts, onExecute }: Props) { + const [operations, setOperations] = useState([]); + const [selectedType, setSelectedType] = useState("deposit"); + const [amount, setAmount] = useState("100"); + const [recipient, setRecipient] = useState(""); + const [contractAddress, setContractAddress] = useState(""); + const [calldata, setCalldata] = useState(""); + const [withdrawSurplus, setWithdrawSurplus] = useState(false); + const nextOperationId = useRef(0); + + const hasInvoke = operations.some((op) => op.operationType === "invoke"); + const hasSurplus = operations.some((op) => op.operationType === "surplus"); + + function handleAdd(event: FormEvent) { + event.preventDefault(); + const needsRecipient = + selectedType === "transfer" || selectedType === "surplus" || + ((selectedType === "deposit" || selectedType === "withdraw") && recipient); + + const operation: BuilderOperation = { + id: nextOperationId.current++, + operationType: selectedType, + amount, + ...(needsRecipient ? { recipient } : {}), + ...(selectedType === "surplus" ? { withdrawSurplus } : {}), + ...(selectedType === "invoke" + ? { contractAddress, calldata } + : {}), + }; + + setOperations((previous) => { + const updated = [...previous, operation]; + updated.sort( + (a, b) => PHASE_ORDER[a.operationType] - PHASE_ORDER[b.operationType], + ); + return updated; + }); + } + + function handleRemove(operationId: number) { + setOperations((previous) => previous.filter((op) => op.id !== operationId)); + } + + function handleExecute() { + onExecute(operations); + } + + function formatOperationDetails(operation: BuilderOperation): string { + if (operation.operationType === "invoke") { + return `${operation.contractAddress?.slice(0, 10)}...`; + } + if (operation.operationType === "surplus") { + const target = operation.recipient + ? `${operation.recipient.slice(0, 10)}...` + : "self"; + return `→ ${target}${operation.withdrawSurplus ? " (withdraw)" : ""}`; + } + const details = `${operation.amount} STRK`; + if (operation.recipient) { + return `${details} → ${operation.recipient.slice(0, 10)}...`; + } + return details; + } + + return ( + <> +

Transaction Builder

+ + + + + {selectedType !== "invoke" && selectedType !== "surplus" && ( + setAmount(event.target.value)} + placeholder="Amount" + min="1" + /> + )} + + {(selectedType === "transfer" || selectedType === "surplus" || + selectedType === "deposit" || selectedType === "withdraw") && ( + + )} + + {selectedType === "surplus" && ( + + )} + + {selectedType === "invoke" && ( + <> + setContractAddress(event.target.value)} + placeholder="Contract address (0x...)" + /> + setCalldata(event.target.value)} + placeholder="Calldata (comma-separated)" + /> + + )} + + + + + {operations.length === 0 ? ( +

No actions added

+ ) : ( +
+ {operations.map((operation) => ( +
+ {OPERATION_LABELS[operation.operationType]} + {formatOperationDetails(operation)} + +
+ ))} + {!hasSurplus && ( +
+ Surplus To + → self +
+ )} +
+ )} + + + + ); +} diff --git a/demo/src/config.ts b/demo/src/config.ts index b75c8403c..61e5463ab 100644 --- a/demo/src/config.ts +++ b/demo/src/config.ts @@ -22,6 +22,8 @@ export type AppConfig = { accounts: AccountConfig[]; /** Proving service URL. If set, uses real prover; otherwise mock. */ provingServiceUrl?: string; + gatewayUrl?: string; + feederGatewayUrl?: string; }; function requireEnv(key: string): string { @@ -55,5 +57,7 @@ export function loadConfig(): AppConfig { provingServiceUrl: import.meta.env.VITE_PROVING_SERVICE_URL as | string | undefined, + gatewayUrl: import.meta.env.VITE_GATEWAY_URL as string | undefined, + feederGatewayUrl: import.meta.env.VITE_FEEDER_GATEWAY_URL as string | undefined, }; } diff --git a/demo/src/hooks/useAccounts.ts b/demo/src/hooks/useAccounts.ts index 7b6296b35..327bd7754 100644 --- a/demo/src/hooks/useAccounts.ts +++ b/demo/src/hooks/useAccounts.ts @@ -1,27 +1,44 @@ import { useState, useCallback } from "react"; import type { AccountConfig } from "../config.ts"; +const STORAGE_KEY = "activeAccountIndex"; + +function loadSavedIndex(maxIndex: number): number { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved === null) return 0; + const parsed = Number(saved); + return parsed >= 0 && parsed <= maxIndex ? parsed : 0; + } catch { + return 0; + } +} + export type UseAccountsResult = { accounts: AccountConfig[]; activeIndex: number; activeAccount: AccountConfig | undefined; setActiveIndex: (index: number) => void; - addAccount: (account: AccountConfig) => void; }; export function useAccounts(initialAccounts: AccountConfig[]): UseAccountsResult { - const [accounts, setAccounts] = useState(initialAccounts); - const [activeIndex, setActiveIndex] = useState(0); + const [activeIndex, setActiveIndexState] = useState(() => + loadSavedIndex(initialAccounts.length - 1), + ); - const addAccount = useCallback((account: AccountConfig) => { - setAccounts((prev) => [...prev, account]); + const setActiveIndex = useCallback((index: number) => { + setActiveIndexState(index); + try { + localStorage.setItem(STORAGE_KEY, String(index)); + } catch { + // localStorage unavailable — ignore + } }, []); return { - accounts, + accounts: initialAccounts, activeIndex, - activeAccount: accounts[activeIndex], + activeAccount: initialAccounts[activeIndex], setActiveIndex, - addAccount, }; } diff --git a/demo/src/hooks/usePrivateState.ts b/demo/src/hooks/usePrivateState.ts index a976cf1f9..ccd2591f5 100644 --- a/demo/src/hooks/usePrivateState.ts +++ b/demo/src/hooks/usePrivateState.ts @@ -9,10 +9,11 @@ import { getErc20Balance } from "../starknet.ts"; export type NoteDisplay = { id: string; + rawId: bigint; amount: bigint; token: string; sender: string; - isSelfSender: boolean; + senderName: string | null; channelKey: string; nonce: number; open: boolean; @@ -20,19 +21,28 @@ export type NoteDisplay = { export type ChannelDisplay = { recipient: string; - isSelf: boolean; + recipientName: string | null; publicKey: string; channelKey: string; noteNonce: number; tokens: Array<{ tokenAddress: string }>; }; +export type ChannelGroup = { + channelKey: string; + sender: string; + senderName: string | null; + token: string; + notes: NoteDisplay[]; +}; + export type PrivateState = { isRegistered: boolean | null; tokenBalance: bigint; feeTokenBalance: bigint; privateBalance: bigint; notes: NoteDisplay[]; + channelGroups: ChannelGroup[]; channels: ChannelDisplay[]; }; @@ -42,6 +52,7 @@ const EMPTY_STATE: PrivateState = { feeTokenBalance: 0n, privateBalance: 0n, notes: [], + channelGroups: [], channels: [], }; @@ -65,6 +76,7 @@ export function usePrivateState( provider: RpcProvider | undefined, transfers: PrivateTransfersInterface | undefined, account: AccountConfig | undefined, + allAccounts: AccountConfig[], poolAddress: string, config: AppConfig, ) { @@ -110,18 +122,24 @@ export function usePrivateState( 0n, ); - const ownerAddress = BigInt(account.address); + const nameByAddress = new Map(); + for (const acc of allAccounts) { + const accAddress = BigInt(acc.address); + nameByAddress.set(accAddress, accAddress === BigInt(account.address) ? "self" : acc.name.toLowerCase()); + } const tokenAddressBigInt = BigInt(config.tokenAddress); const notes: NoteDisplay[] = tokenNotes.map((note: Note) => { const witness = readWitness(note.witness); const senderBigInt = toBigInt(note.sender); + const noteIdBigInt = toBigInt(note.id); return { - id: formatBigInt(toBigInt(note.id)), + id: formatBigInt(noteIdBigInt), + rawId: noteIdBigInt, amount: note.amount, token: truncateAddress(tokenAddressBigInt.toString(16)), sender: truncateAddress(senderBigInt.toString(16)), - isSelfSender: senderBigInt === ownerAddress, + senderName: nameByAddress.get(senderBigInt) ?? null, channelKey: formatBigInt(witness.channelKey), nonce: witness.nonce, open: note.open ?? false, @@ -138,10 +156,9 @@ export function usePrivateState( }); noteNonce = Math.max(noteNonce, tokenChannel.noteNonce); } - const isSelf = recipient === ownerAddress; channels.push({ recipient: truncateAddress(recipient.toString(16)), - isSelf, + recipientName: nameByAddress.get(recipient) ?? null, publicKey: formatBigInt(internal.publicKey), channelKey: internal.key ? formatBigInt(internal.key) : "N/A", noteNonce, @@ -149,13 +166,37 @@ export function usePrivateState( }); } - setState({ isRegistered, tokenBalance, feeTokenBalance, privateBalance, notes, channels }); + const groupsByKey = new Map(); + for (const note of notes) { + const existing = groupsByKey.get(note.channelKey); + if (existing) { + existing.push(note); + } else { + groupsByKey.set(note.channelKey, [note]); + } + } + + const channelGroups: ChannelGroup[] = []; + for (const [channelKey, groupNotes] of groupsByKey) { + groupNotes.sort((a, b) => b.nonce - a.nonce); + const firstNote = groupNotes[0]; + channelGroups.push({ + channelKey, + sender: firstNote.sender, + senderName: firstNote.senderName, + token: firstNote.token, + notes: groupNotes, + }); + } + channelGroups.sort((a, b) => b.notes[0].nonce - a.notes[0].nonce); + + setState({ isRegistered, tokenBalance, feeTokenBalance, privateBalance, notes, channelGroups, channels }); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setLoading(false); } - }, [provider, transfers, account, poolAddress, config]); + }, [provider, transfers, account, allAccounts, poolAddress, config]); return { state, loading, error, refresh }; } diff --git a/demo/src/hooks/useServiceHealth.ts b/demo/src/hooks/useServiceHealth.ts new file mode 100644 index 000000000..9136b9fa6 --- /dev/null +++ b/demo/src/hooks/useServiceHealth.ts @@ -0,0 +1,131 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { ProvingService } from "starknet-sdk"; +// @ts-expect-error — deep import into dist, not part of the declared exports +import { IndexerDiscoveryProvider } from "starknet-sdk/dist/internal/indexer-discovery.js"; +import type { RpcProvider } from "starknet"; +import type { AppConfig } from "../config.ts"; + +export type HealthStatus = "checking" | "healthy" | "unhealthy" | "unknown"; + +export type SubsystemHealth = { + status: HealthStatus; + detail: string | null; +}; + +export type ServiceHealthState = { + discovery: SubsystemHealth; + rpc: SubsystemHealth; + proving: SubsystemHealth | null; + gateway: SubsystemHealth | null; + feederGateway: SubsystemHealth | null; +}; + +const POLL_INTERVAL_MS = 30_000; +const RPC_STALENESS_THRESHOLD_SECS = 120; +const FETCH_TIMEOUT_MS = 8_000; + +function pending(): SubsystemHealth { + return { status: "checking", detail: null }; +} + +async function checkDiscovery(indexerUrl: string): Promise { + try { + const indexer = new IndexerDiscoveryProvider(indexerUrl, "0x0"); + const health = await indexer.getHealth(); + if (health.status === "OK") { + const detail = health.lag_secs != null ? `lag: ${health.lag_secs}s` : null; + return { status: "healthy", detail }; + } + return { status: "unhealthy", detail: `status: ${health.status}` }; + } catch (error) { + return { status: "unhealthy", detail: error instanceof Error ? error.message : "unreachable" }; + } +} + +async function checkRpc(provider: RpcProvider): Promise { + try { + const block = await provider.getBlock("latest"); + const blockTimestamp = block.timestamp; + const nowSecs = Math.floor(Date.now() / 1000); + const lagSecs = nowSecs - blockTimestamp; + if (lagSecs <= RPC_STALENESS_THRESHOLD_SECS) { + return { status: "healthy", detail: `lag: ${lagSecs}s` }; + } + return { status: "unhealthy", detail: `stale: ${lagSecs}s behind` }; + } catch (error) { + return { status: "unhealthy", detail: error instanceof Error ? error.message : "unreachable" }; + } +} + +async function checkAliveEndpoint(url: string): Promise { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (response.ok) return { status: "healthy", detail: null }; + return { status: "unhealthy", detail: `HTTP ${response.status}` }; + } catch { + return { status: "unknown", detail: "unreachable (CORS?)" }; + } +} + +async function checkProving(provingServiceUrl: string): Promise { + try { + const service = new ProvingService({ baseUrl: provingServiceUrl }); + const healthy = await service.isHealthy(); + return healthy + ? { status: "healthy", detail: null } + : { status: "unhealthy", detail: "spec version check failed" }; + } catch (error) { + return { status: "unhealthy", detail: error instanceof Error ? error.message : "unreachable" }; + } +} + +function initialState(config: AppConfig): ServiceHealthState { + return { + discovery: pending(), + rpc: pending(), + proving: config.provingServiceUrl ? pending() : null, + gateway: config.gatewayUrl ? pending() : null, + feederGateway: config.feederGatewayUrl ? pending() : null, + }; +} + +export function useServiceHealth( + provider: RpcProvider, + config: AppConfig, +): ServiceHealthState { + const [state, setState] = useState(() => initialState(config)); + const mountedRef = useRef(true); + + const runChecks = useCallback(() => { + const update = (key: keyof ServiceHealthState) => (value: SubsystemHealth) => { + if (mountedRef.current) setState((prev) => ({ ...prev, [key]: value })); + }; + + checkDiscovery(config.indexerUrl).then(update("discovery")); + checkRpc(provider).then(update("rpc")); + + if (config.provingServiceUrl) { + checkProving(config.provingServiceUrl).then(update("proving")); + } + if (config.gatewayUrl) { + checkAliveEndpoint(`${config.gatewayUrl}/gateway/is_alive`).then(update("gateway")); + } + if (config.feederGatewayUrl) { + checkAliveEndpoint(`${config.feederGatewayUrl}/feeder_gateway/is_alive`).then( + update("feederGateway"), + ); + } + }, [provider, config]); + + useEffect(() => { + mountedRef.current = true; + runChecks(); + const intervalId = setInterval(runChecks, POLL_INTERVAL_MS); + return () => { + mountedRef.current = false; + clearInterval(intervalId); + }; + }, [runChecks]); + + return state; +} diff --git a/demo/src/hooks/useTransactionBuilder.ts b/demo/src/hooks/useTransactionBuilder.ts new file mode 100644 index 000000000..cfcb9bded --- /dev/null +++ b/demo/src/hooks/useTransactionBuilder.ts @@ -0,0 +1,139 @@ +import { useState, useCallback, useMemo } from "react"; +import { Account, type RpcProvider } from "starknet"; +import type { PrivateTransfersInterface } from "starknet-sdk"; +import type { AppConfig } from "../config.ts"; +import type { TransactionStatus } from "./useTransactions.ts"; +import type { BuilderOperation } from "../components/TransactionBuilder.tsx"; +import { + ERC20_RESOURCE_BOUNDS, + POOL_RESOURCE_BOUNDS, +} from "../starknet.ts"; + +export function useTransactionBuilder( + provider: RpcProvider | undefined, + transfers: PrivateTransfersInterface | undefined, + activeAddress: string | undefined, + poolAddress: string, + config: AppConfig, + onSuccess: () => void, +) { + const [status, setStatus] = useState({ + pending: false, + lastTxHash: null, + lastError: null, + }); + + const userAccount = useMemo(() => { + if (!provider || !activeAddress) return undefined; + const accountConfig = config.accounts.find( + (account) => account.address === activeAddress, + ); + if (!accountConfig) return undefined; + return new Account({ + provider, + address: accountConfig.address, + signer: accountConfig.privateKey, + cairoVersion: "1", + }); + }, [provider, activeAddress, config.accounts]); + + const executeBatch = useCallback( + (operations: BuilderOperation[]) => { + const action = "Builder"; + console.log(`[${action}] starting with ${operations.length} operations...`); + setStatus({ pending: true, lastTxHash: null, lastError: null }); + + (async () => { + try { + if (!userAccount || !transfers || !provider || !activeAddress) + throw new Error("Not ready"); + + // Approve pool for total deposit amount + const totalDepositAmount = operations + .filter((op) => op.operationType === "deposit") + .reduce((sum, op) => sum + BigInt(op.amount), 0n); + + if (totalDepositAmount > 0n) { + const approveTx = await userAccount.execute( + { + contractAddress: config.tokenAddress, + entrypoint: "approve", + calldata: [poolAddress, totalDepositAmount.toString(), "0"], + }, + { resourceBounds: ERC20_RESOURCE_BOUNDS }, + ); + console.log(`[${action}] approve tx: ${approveTx.transaction_hash}`); + await provider.waitForTransaction(approveTx.transaction_hash); + } + + const surplusOperation = operations.find( + (op) => op.operationType === "surplus", + ); + + const builder = transfers.build({ + autoRegister: true, + autoSetup: true, + autoDiscover: { notes: "refresh", channels: "refresh" }, + autoSelectNotes: "naive", + }).surplusTo( + surplusOperation?.recipient ?? activeAddress, + surplusOperation?.withdrawSurplus, + ); + + const invokeOperation = operations.find( + (op) => op.operationType === "invoke", + ); + if (invokeOperation) { + builder.invoke({ + contractAddress: invokeOperation.contractAddress!, + calldata: invokeOperation.calldata + ? invokeOperation.calldata.split(",").map((segment) => segment.trim()) + : [], + }); + } + + const provingBlockId = await provider.getBlockNumber() - 10; + const { callAndProof } = await builder + .with(config.tokenAddress, (tokenBuilder) => { + for (const op of operations) { + if (op.operationType === "deposit") + tokenBuilder.deposit({ amount: BigInt(op.amount), recipient: op.recipient || undefined }); + if (op.operationType === "transfer") + tokenBuilder.transfer({ recipient: op.recipient!, amount: BigInt(op.amount) }); + if (op.operationType === "withdraw") + tokenBuilder.withdraw({ amount: BigInt(op.amount), recipient: op.recipient || activeAddress }); + } + }) + .execute({ provingBlockId }); + console.log(`[${action}] callAndProof built, submitting pool tx...`); + + const executeTx = await userAccount.execute(callAndProof.call, { + resourceBounds: POOL_RESOURCE_BOUNDS, + ...(callAndProof.proof.proofFacts?.length + ? { + proofFacts: callAndProof.proof.proofFacts, + proof: callAndProof.proof.data, + } + : {}), + }); + console.log(`[${action}] pool tx: ${executeTx.transaction_hash}`); + const receipt = await provider.waitForTransaction(executeTx.transaction_hash); + if (!receipt.isSuccess()) { + throw new Error(`Transaction reverted: ${JSON.stringify(receipt)}`); + } + + console.log(`[${action}] confirmed: ${executeTx.transaction_hash}`); + setStatus({ pending: false, lastTxHash: executeTx.transaction_hash, lastError: null }); + onSuccess(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`[${action}] failed:`, err); + setStatus({ pending: false, lastTxHash: null, lastError: `${action}: ${message}` }); + } + })(); + }, + [userAccount, transfers, provider, activeAddress, config, poolAddress, onSuccess], + ); + + return { status, executeBatch }; +} diff --git a/demo/src/hooks/useTransactions.ts b/demo/src/hooks/useTransactions.ts index 4d5b675ee..264f51748 100644 --- a/demo/src/hooks/useTransactions.ts +++ b/demo/src/hooks/useTransactions.ts @@ -70,6 +70,42 @@ export function useTransactions( [onSuccess], ); + const register = useCallback( + () => + execute("Register", async () => { + if (!userAccount || !transfers || !provider) + throw new Error("Not ready"); + + console.log("[Register] registering account in privacy pool..."); + + const provingBlockId = await provider.getBlockNumber() - 10; + const { callAndProof } = await transfers + .build() + .register() + .execute({ provingBlockId }); + console.log("[Register] callAndProof built, submitting tx..."); + + const executeTx = await userAccount.execute(callAndProof.call, { + resourceBounds: POOL_RESOURCE_BOUNDS, + ...(callAndProof.proof.proofFacts?.length + ? { + proofFacts: callAndProof.proof.proofFacts, + proof: callAndProof.proof.data, + } + : {}), + }); + console.log(`[Register] tx: ${executeTx.transaction_hash}`); + const receipt = await provider.waitForTransaction( + executeTx.transaction_hash, + ); + if (!receipt.isSuccess()) { + throw new Error(`Transaction reverted: ${JSON.stringify(receipt)}`); + } + return executeTx.transaction_hash; + }), + [userAccount, transfers, provider, execute], + ); + const mint = useCallback( (amount: bigint) => execute("Mint", async () => { @@ -231,5 +267,5 @@ export function useTransactions( [userAccount, transfers, provider, activeAddress, config, execute], ); - return { status, mint, deposit, withdraw, transfer }; + return { status, register, mint, deposit, withdraw, transfer }; } diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 11af7db35..381a4d575 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -12,3 +12,5 @@ export { ProvingServiceProofProvider } from "./internal/proving-service-provider export type { ProvingServiceProofProviderOptions } from "./internal/proving-service-provider.js"; export type { RateLimitOptions } from "./utils/rate-limiter.js"; export type { DiscoveryOptions } from "./internal/contract-discovery.js"; +export { IndexerDiscoveryProvider } from "./internal/indexer-discovery.js"; +export type { DiscoveryHealthResponse } from "./internal/indexer-discovery.js"; diff --git a/sdk/src/internal/indexer-discovery.ts b/sdk/src/internal/indexer-discovery.ts index 34a317b4e..67b943fcb 100644 --- a/sdk/src/internal/indexer-discovery.ts +++ b/sdk/src/internal/indexer-discovery.ts @@ -96,6 +96,12 @@ type ApiOutgoingSyncResponse = { cursor: ApiDiscoveryCursor; }; +export type DiscoveryHealthResponse = { + status: string; + chain_head?: { block_number: number; block_hash: string; timestamp: number }; + lag_secs?: number; +}; + export class IndexerDiscoveryProvider extends AbstractDiscoveryProvider { constructor( private readonly apiUrl: string, @@ -104,6 +110,27 @@ export class IndexerDiscoveryProvider extends AbstractDiscoveryProvider { super(); } + async isHealthy(): Promise { + try { + const response = await fetch(`${this.apiUrl}/health`, { + signal: AbortSignal.timeout(8_000), + }); + if (!response.ok) return false; + const body = (await response.json()) as { status: string }; + return body.status === "OK"; + } catch { + return false; + } + } + + async getHealth(): Promise { + const response = await fetch(`${this.apiUrl}/health`, { + signal: AbortSignal.timeout(8_000), + }); + if (!response.ok) throw new Error(`Discovery service returned HTTP ${response.status}`); + return (await response.json()) as DiscoveryHealthResponse; + } + async discoverNotes( address: StarknetAddressBigint, viewingKey: ViewingKey,