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 && (
-
- )}
+
+
+ {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
+
+
Register in the pool
+
+
+
-
+ >
);
}
diff --git a/demo/src/components/InfoPanel.tsx b/demo/src/components/InfoPanel.tsx
index e1e30191c..f0f2e84b4 100644
--- a/demo/src/components/InfoPanel.tsx
+++ b/demo/src/components/InfoPanel.tsx
@@ -1,5 +1,9 @@
+import { useState } from "react";
+import type { ChannelGroup, NoteDisplay } from "../hooks/usePrivateState.ts";
import type { PrivateState } from "../hooks/usePrivateState.ts";
+const NOTES_PER_PAGE = 10;
+
type Props = {
state: PrivateState;
loading: boolean;
@@ -11,6 +15,76 @@ function formatAmount(value: bigint): string {
return value.toLocaleString("en-US");
}
+function ChannelCard({ group }: { group: ChannelGroup }) {
+ const [page, setPage] = useState(0);
+
+ const totalPages = Math.max(1, Math.ceil(group.notes.length / NOTES_PER_PAGE));
+ const clampedPage = Math.min(page, totalPages - 1);
+ const visibleNotes = group.notes.slice(
+ clampedPage * NOTES_PER_PAGE,
+ (clampedPage + 1) * NOTES_PER_PAGE,
+ );
+
+ return (
+
+
+ Sender{" "}
+ {group.sender}
+ {group.senderName && {group.senderName}}
+ |
+ Token{" "}
+ {group.token}
+ |
+ Channel Key{" "}
+ {group.channelKey}
+ |
+ Unspent Notes{" "}
+ {group.notes.length}
+
+
+
+
+ | Note ID |
+ Amount |
+ Index |
+
+
+
+ {visibleNotes.map((note: NoteDisplay) => (
+
+ | {note.id} |
+ {formatAmount(note.amount)} |
+
+ {note.nonce}
+ {note.open && open}
+ |
+
+ ))}
+
+
+ {totalPages > 1 && (
+
+
+
+ {clampedPage + 1} / {totalPages}
+
+
+
+ )}
+
+ );
+}
+
export function InfoPanel({ state, loading, error, onRefresh }: Props) {
return (
@@ -54,40 +128,13 @@ export function InfoPanel({ state, loading, error, onRefresh }: Props) {
-
Notes ({state.notes.length})
- {state.notes.length === 0 ? (
+
Unspent Notes ({state.notes.length})
+ {state.channelGroups.length === 0 ? (
No notes discovered
) : (
-
-
-
- | Note ID |
- Sender |
- Token |
- Amount |
- Index |
- Channel Key |
-
-
-
- {state.notes.map((note) => (
-
- |
- {note.id}
- {note.open && open}
- |
-
- {note.sender}
- {note.isSelfSender && self}
- |
- {note.token} |
- {formatAmount(note.amount)} |
- {note.nonce} |
- {note.channelKey} |
-
- ))}
-
-
+ state.channelGroups.map((group) => (
+
+ ))
)}
@@ -101,8 +148,8 @@ export function InfoPanel({ state, loading, error, onRefresh }: Props) {
| Recipient |
Token |
- Next Note Index |
Channel Key |
+ Next Note Index |
@@ -110,7 +157,7 @@ export function InfoPanel({ state, loading, error, onRefresh }: Props) {
|
{channel.recipient}
- {channel.isSelf && self}
+ {channel.recipientName && {channel.recipientName}}
|
{channel.tokens.map((tokenEntry, tokenIndex) => (
@@ -119,8 +166,8 @@ export function InfoPanel({ state, loading, error, onRefresh }: Props) {
))}
|
- {channel.noteNonce} |
{channel.channelKey} |
+ {channel.noteNonce} |
))}
diff --git a/demo/src/components/PoolSelector.tsx b/demo/src/components/PoolSelector.tsx
index 311ac3689..65096a47b 100644
--- a/demo/src/components/PoolSelector.tsx
+++ b/demo/src/components/PoolSelector.tsx
@@ -1,3 +1,4 @@
+import { useState } from "react";
import type { PoolEntry } from "../hooks/usePoolSelector.ts";
type Props = {
@@ -6,13 +7,15 @@ type Props = {
loading: boolean;
deploying: boolean;
deployError: string | null;
+ classHash: string;
onSelect: (address: string) => void;
onDeploy: () => void;
+ onClassHashChange: (hash: string) => void;
};
-function truncatePoolAddress(address: string): string {
- if (address.length <= 14) return address;
- return `${address.slice(0, 8)}...${address.slice(-4)}`;
+function truncateHash(hex: string): string {
+ if (hex.length <= 14) return hex;
+ return `${hex.slice(0, 8)}...${hex.slice(-4)}`;
}
export function PoolSelector({
@@ -21,28 +24,85 @@ export function PoolSelector({
loading,
deploying,
deployError,
+ classHash,
onSelect,
onDeploy,
+ onClassHashChange,
}: Props) {
+ const [editing, setEditing] = useState(false);
+ const [draft, setDraft] = useState("");
+ const [copied, setCopied] = useState(false);
+
return (
-
-