diff --git a/app/package.json b/app/package.json
index f45c232..416a9c2 100644
--- a/app/package.json
+++ b/app/package.json
@@ -20,7 +20,8 @@
"scripts": {
"sync-circuit": "node scripts/sync-circuit.mjs",
"dev:circuits": "node scripts/sync-circuit.mjs --watch",
- "dev": "npm run sync-circuit && vite",
+ "dev": "vite",
+ "dev:full": "npm run sync-circuit && vite",
"build": "npm run sync-circuit && vite build",
"preview": "vite preview",
"test": "vitest run"
diff --git a/app/src/App.tsx b/app/src/App.tsx
index 8a959af..c946cbf 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -1,4 +1,4 @@
-import { useState, useRef, useEffect } from "react";
+import { useState, useRef, useEffect, useCallback } from "react";
import { Keypair } from "@stellar/stellar-sdk";
import {
isConnected,
@@ -159,6 +159,7 @@ interface Member {
funded: boolean;
fundHash?: string;
freighterKey?: string;
+ pending?: boolean; // Optimistic flag while transaction is in flight
}
interface ClaimResult {
@@ -281,7 +282,7 @@ function useRingRadius(): number {
return radius;
}
-function MemberRing({ members, revealed }: { members: { funded: boolean }[]; revealed: boolean }) {
+function MemberRing({ members, revealed }: { members: { funded: boolean; pending?: boolean }[]; revealed: boolean }) {
const radius = useRingRadius();
const fundedCount = members.filter((m) => m.funded).length;
@@ -318,7 +319,7 @@ function MemberRing({ members, revealed }: { members: { funded: boolean }[]; rev
{i + 1}
@@ -441,6 +442,7 @@ export default function App() {
}, []);
const [round, setRound] = useState(0);
const [pot, setPot] = useState(0n);
+ const [onChainContributors, setOnChainContributors] = useState
([]);
const [claimantIndex, setClaimantIndex] = useState(0);
const [proof, setProof] = useState(null);
const [nullifierHash, setNullifierHash] = useState(null);
@@ -464,6 +466,47 @@ export default function App() {
const fullyFunded = pot === contribution * BigInt(CIRCLE_SIZE);
const { announce, message: liveRegionMessage } = usePoliteLiveRegion(120);
+ // Sync funding state from on-chain data
+ const syncFundingState = useCallback(async () => {
+ if (!admin || circleId === null) return;
+ try {
+ const { connect, getCircleStatus } = await import("@sharibo/client");
+ const adminClient = await connect(NETWORK, admin);
+ const status = await getCircleStatus(adminClient, circleId);
+
+ setPot(status.pot);
+ setOnChainContributors(status.contributors);
+
+ // Update member funded status based on on-chain contributors
+ setMembers((prev) =>
+ prev.map((m) => {
+ const hasFunded = status.contributors.includes(m.keypair.publicKey()) ||
+ (m.freighterKey && status.contributors.includes(m.freighterKey));
+ return { ...m, funded: hasFunded, pending: false };
+ })
+ );
+ } catch (e) {
+ console.error("Failed to sync funding state:", e);
+ }
+ }, [admin, circleId]);
+
+ // Sync funding state when circleId changes or on mount
+ useEffect(() => {
+ if (circleId !== null && admin) {
+ syncFundingState();
+ }
+ }, [circleId, admin, syncFundingState]);
+
+ // Poll for third-party funding updates every 10 seconds when circle is active
+ useEffect(() => {
+ if (circleId !== null && admin && screen === "circle" && !claimResult) {
+ const interval = setInterval(() => {
+ syncFundingState();
+ }, 10000); // Poll every 10 seconds
+ return () => clearInterval(interval);
+ }
+ }, [circleId, admin, screen, claimResult, syncFundingState]);
+
useEffect(() => {
if (busy) {
announce(`Help: ${busy}`);
@@ -574,8 +617,9 @@ export default function App() {
const loadedMembers = parsed.members.map((m: any) => ({
keypair: Keypair.fromSecret(m.secret),
identity: m.identity,
- funded: m.funded,
+ funded: false, // Will be synced from on-chain
fundHash: m.fundHash,
+ pending: false,
}));
setMembers(loadedMembers);
@@ -587,7 +631,7 @@ export default function App() {
setCircleId(parsed.circleId);
setRound(parsed.round);
- setPot(parsed.pot);
+ setPot(0n); // Will be synced from on-chain
setClaimantIndex(parsed.claimantIndex);
setProof(parsed.proof);
setNullifierHash(parsed.nullifierHash);
@@ -596,6 +640,9 @@ export default function App() {
setScreen("circle");
setResumePrompt(null);
+
+ // Sync from on-chain after loading state
+ setTimeout(() => syncFundingState(), 100);
}
async function startCircle() {
@@ -659,27 +706,42 @@ export default function App() {
setError(null);
setBusy(`Funding from member ${i + 1}…`);
try {
- const [{ Keypair }, { connect, fund, getCircle }] = await Promise.all([
+ const [{ Keypair }, { connect, fund }] = await Promise.all([
import("@stellar/stellar-sdk"),
import("@sharibo/client")
]);
const m = members[i];
await fundWithFriendbot(m.keypair.publicKey());
+
+ // Set optimistic pending state
+ setMembers((prev) =>
+ prev.map((mm, idx) =>
+ idx === i ? { ...mm, pending: true } : mm,
+ ),
+ );
+
const memberClient = await connect(NETWORK, m.keypair);
const { hash } = await fund(memberClient, {
circleId,
from: m.keypair.publicKey(),
});
+
+ // Sync with on-chain state after submission
+ await syncFundingState();
+
+ // Update fund hash for the successful transaction
setMembers((prev) =>
prev.map((mm, idx) =>
- idx === i ? { ...mm, funded: true, fundHash: hash } : mm,
+ idx === i ? { ...mm, fundHash: hash } : mm,
),
);
- const adminClient = await connect(NETWORK, admin);
- const circle = await getCircle(adminClient, circleId);
- setPot(circle.pot);
- setRound(circle.round);
} catch (e) {
+ // Clear pending state on error
+ setMembers((prev) =>
+ prev.map((mm, idx) =>
+ idx === i ? { ...mm, pending: false } : mm,
+ ),
+ );
setError(toUiError(e));
} finally {
setBusy(null);
@@ -721,21 +783,34 @@ export default function App() {
}
};
+ // Set optimistic pending state
+ setMembers((prev) =>
+ prev.map((mm, idx) =>
+ idx === i ? { ...mm, pending: true } : mm,
+ ),
+ );
+
+ const { connect, fund } = await import("@sharibo/client");
const memberClient = await connect(NETWORK, freighterSigner);
const { hash } = await fund(memberClient, {
circleId,
from: pubKey,
});
+ // Sync with on-chain state after submission
+ await syncFundingState();
+
+ // Update fund hash and freighter key for the successful transaction
setMembers((prev) =>
- prev.map((mm, idx) => (idx === i ? { ...mm, funded: true, fundHash: hash, freighterKey: pubKey } : mm)),
+ prev.map((mm, idx) => (idx === i ? { ...mm, fundHash: hash, freighterKey: pubKey } : mm)),
);
-
- const adminClient = await connect(NETWORK, admin);
- const circle = await getCircle(adminClient, circleId);
- setPot(circle.pot);
- setRound(circle.round);
} catch (e) {
+ // Clear pending state on error
+ setMembers((prev) =>
+ prev.map((mm, idx) =>
+ idx === i ? { ...mm, pending: false } : mm,
+ ),
+ );
setError(getErrorMessage(e));
} finally {
setBusy(null);
@@ -749,7 +824,7 @@ export default function App() {
setRejection(null);
setBusy("Claiming…");
try {
- const [{ Keypair }, { computeExternalNullifier, generateProof, connect, claim, getCircle }] = await Promise.all([
+ const [{ Keypair }, { computeExternalNullifier, generateProof, connect, claim, hasClaimed }] = await Promise.all([
import("@stellar/stellar-sdk"),
import("@sharibo/client")
]);
@@ -807,9 +882,8 @@ export default function App() {
setClaimResult({ recipient: recipient.publicKey(), hash, proofDurationMs });
setNullifierClaimed(await hasClaimed(adminClient, circleId, generated.nullifierHash));
- const circle = await getCircle(adminClient, circleId);
- setPot(circle.pot);
- setRound(circle.round);
+ // Sync with on-chain state after claim
+ await syncFundingState();
} catch (e) {
setError(toUiError(e));
} finally {
@@ -826,7 +900,7 @@ export default function App() {
"Refunding a new round, then replaying the same proof's nullifier…",
);
try {
- const [{ Keypair }, { connect, fund, computeExternalNullifier, claim, getCircle }] = await Promise.all([
+ const [{ Keypair }, { connect, fund, computeExternalNullifier, claim }] = await Promise.all([
import("@stellar/stellar-sdk"),
import("@sharibo/client")
]);
@@ -860,11 +934,7 @@ export default function App() {
// Reflect the on-chain state either way: the re-funding above happened
// for real even though the replayed claim itself was rejected.
try {
- const { connect, getCircle } = await import("@sharibo/client");
- const adminClient = await connect(NETWORK, admin);
- const circle = await getCircle(adminClient, circleId);
- setPot(circle.pot);
- setRound(circle.round);
+ await syncFundingState();
} catch {
// best-effort refresh only
}
@@ -1010,12 +1080,14 @@ export default function App() {
Fund
{members.map((m, i) => (
-
+
member {i + 1} · {short(m.keypair.publicKey())}
- {m.funded ? (
+ {m.pending ? (
+
⟳ submitting…
+ ) : m.funded ? (
Fund
{members.map((m, i) => (
-
+
member {i + 1} · {short(m.keypair.publicKey())}
- {m.funded ? (
+ {m.pending ? (
+
⟳ submitting…
+ ) : m.funded ? (
✓ funded ↗
diff --git a/app/src/style.css b/app/src/style.css
index eac15b5..303af8a 100644
--- a/app/src/style.css
+++ b/app/src/style.css
@@ -339,6 +339,11 @@ h2 {
.ring-node.funded {
background: var(--accent2);
}
+.ring-node.pending {
+ background: #ffefd5;
+ border-color: #ffcf3f;
+ animation: pulse 1.5s ease-in-out infinite;
+}
.ring-node.ring-recipient {
background: var(--accent);
border-style: dashed;
@@ -393,6 +398,15 @@ h2 {
}
}
+@keyframes pulse {
+ 0%, 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+
.claim-explainer {
margin-top: 12px;
border: 2px dashed var(--ink);
@@ -453,6 +467,25 @@ h2 {
.member.funded {
background: var(--accent2);
}
+.member.pending {
+ background: #ffefd5;
+ border-color: #ffcf3f;
+}
+.pending-indicator {
+ font-family: "JetBrains Mono", monospace;
+ font-size: 12px;
+ color: #666;
+ animation: pulse 1.5s ease-in-out infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
.member-addr {
font-family: "JetBrains Mono", monospace;
font-size: 13px;
@@ -570,7 +603,6 @@ code {
padding: 2px 4px;
}
-<<<<<<< HEAD
/* ── Mobile (< --bp-md) ─────────────────────────────────────────────── */
@media (max-width: 767px) {
:root {
@@ -713,12 +745,13 @@ code {
.step {
flex: 1 1 calc(50% - 2px);
font-size: 8px;
-=======
+ }
+}
+
@media (prefers-reduced-motion: reduce) {
.btn,
.pot-bar,
.ring-node {
transition: none !important;
->>>>>>> 2e9a849 (Add prefers-reduced-motion media query to disable animations)
}
}