Skip to content
Open
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
3 changes: 2 additions & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
130 changes: 101 additions & 29 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -159,6 +159,7 @@ interface Member {
funded: boolean;
fundHash?: string;
freighterKey?: string;
pending?: boolean; // Optimistic flag while transaction is in flight
}

interface ClaimResult {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -318,7 +319,7 @@ function MemberRing({ members, revealed }: { members: { funded: boolean }[]; rev
<div
key={i}
aria-hidden="true"
className={`ring-node ${m.funded ? "funded" : ""}`}
className={`ring-node ${m.funded ? "funded" : ""} ${m.pending ? "pending" : ""}`}
style={{ transform: `translate(${x}px, ${y}px)` }}
>
{i + 1}
Expand Down Expand Up @@ -441,6 +442,7 @@ export default function App() {
}, []);
const [round, setRound] = useState(0);
const [pot, setPot] = useState(0n);
const [onChainContributors, setOnChainContributors] = useState<string[]>([]);
const [claimantIndex, setClaimantIndex] = useState(0);
const [proof, setProof] = useState<ContractProof | null>(null);
const [nullifierHash, setNullifierHash] = useState<bigint | null>(null);
Expand All @@ -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}`);
Expand Down Expand Up @@ -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);

Expand All @@ -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);
Expand All @@ -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() {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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")
]);
Expand Down Expand Up @@ -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 {
Expand All @@ -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")
]);
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -1010,12 +1080,14 @@ export default function App() {
<h2>Fund</h2>
<div className="members">
{members.map((m, i) => (
<div key={i} className={`member ${m.funded ? "funded" : ""}`}>
<div key={i} className={`member ${m.funded ? "funded" : ""} ${m.pending ? "pending" : ""}`}>
<span className="member-addr">
member {i + 1} · {short(m.keypair.publicKey())}
<CopyButton value={m.keypair.publicKey()} label={`member ${i + 1} address`} />
</span>
{m.funded ? (
{m.pending ? (
<span className="pending-indicator">⟳ submitting…</span>
) : m.funded ? (
<a
className="link"
href={explorerTx(m.fundHash!)}
Expand Down
6 changes: 4 additions & 2 deletions app/src/components/FundingList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ export function FundingList({
<h2>Fund</h2>
<div className="members">
{members.map((m, i) => (
<div key={i} className={`member ${m.funded ? "funded" : ""}`}>
<div key={i} className={`member ${m.funded ? "funded" : ""} ${m.pending ? "pending" : ""}`}>
<span className="member-addr">
member {i + 1} · {short(m.keypair.publicKey())}
</span>
{m.funded ? (
{m.pending ? (
<span className="pending-indicator">⟳ submitting…</span>
) : m.funded ? (
<a className="link" href={explorerTx(m.fundHash!)} target="_blank" rel="noreferrer">
✓ funded ↗
</a>
Expand Down
39 changes: 36 additions & 3 deletions app/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -570,7 +603,6 @@ code {
padding: 2px 4px;
}

<<<<<<< HEAD
/* ── Mobile (< --bp-md) ─────────────────────────────────────────────── */
@media (max-width: 767px) {
:root {
Expand Down Expand Up @@ -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)
}
}