diff --git a/app/src/App.tsx b/app/src/App.tsx index 8a959af..3705cb0 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -515,6 +515,37 @@ export default function App() { payoutHeadingRef.current?.focus(); } }, [claimResult]); + + // When the claim step becomes available, pre-check each member's nullifier + // against `has_claimed` so we can mark ineligible members immediately + // (avoids generating a slow proof only to be rejected on-chain). + useEffect(() => { + let mounted = true; + async function checkEligibility() { + if (!fullyFunded || claimResult || !circleId || !admin) return; + try { + setBusy("Checking member eligibility…"); + const client = await import("@sharibo/client"); + const { computeExternalNullifier, computeNullifierHash, connect, hasClaimed } = client; + const external = await computeExternalNullifier(circleId, BigInt(round)); + const adminClient = await connect(NETWORK, admin); + const results = await Promise.all( + members.map(async (m) => { + const nullifier = computeNullifierHash(m.identity.identityNullifier, external); + return await hasClaimed(adminClient, circleId, nullifier); + }), + ); + if (!mounted) return; + setMembers((prev) => prev.map((m, i) => ({ ...m, ineligible: results[i], ineligibleReason: results[i] ? "Already claimed in this circle" : undefined }))); + } catch (e) { + setError(toUiError(e)); + } finally { + if (mounted) setBusy(null); + } + } + checkEligibility(); + return () => { mounted = false; }; + }, [fullyFunded, claimResult, circleId, round, admin]); // ──────────────────────────────────────────────────────────────────────── // Every hook above must run on every render, so this check — which used to @@ -576,6 +607,7 @@ export default function App() { identity: m.identity, funded: m.funded, fundHash: m.fundHash, + ineligible: m.ineligible ?? false, })); setMembers(loadedMembers); @@ -618,6 +650,7 @@ export default function App() { keypair: Keypair.random(), identity: generateIdentity(), funded: false, + ineligible: false, })); const newTree = MerkleTree.create( @@ -1059,15 +1092,16 @@ export default function App() { which one.

- {members.map((_, i) => ( + {members.map((m, i) => ( ))}
diff --git a/app/src/components/ClaimSection.tsx b/app/src/components/ClaimSection.tsx index bc8905f..c405081 100644 --- a/app/src/components/ClaimSection.tsx +++ b/app/src/components/ClaimSection.tsx @@ -21,15 +21,16 @@ export function ClaimSection({ real member without revealing which one.

- {members.map((_, i) => ( + {members.map((m, i) => ( ))}
diff --git a/app/src/components/MemberRing.tsx b/app/src/components/MemberRing.tsx index 86e21f4..3e0387f 100644 --- a/app/src/components/MemberRing.tsx +++ b/app/src/components/MemberRing.tsx @@ -2,11 +2,13 @@ // as "the one that claimed" — that's the point. From outside the ring, all // five remain equally plausible; only the demo operator (via the radio // picker below) ever knows which one actually did. +import type { Member } from "../types.js"; + export function MemberRing({ members, revealed, }: { - members: { funded: boolean }[]; + members: Member[]; revealed: boolean; }) { const radius = 100; @@ -21,10 +23,11 @@ export function MemberRing({ return (
- {i + 1} + {m.ineligible ? "×" : i + 1}
); })} diff --git a/app/src/types.ts b/app/src/types.ts index a269d26..4ff49c6 100644 --- a/app/src/types.ts +++ b/app/src/types.ts @@ -6,6 +6,8 @@ export interface Member { identity: Identity; funded: boolean; fundHash?: string; + ineligible?: boolean; + ineligibleReason?: string; } export interface ClaimResult { diff --git a/packages/client/src/contract.ts b/packages/client/src/contract.ts index 5fe4149..e838c07 100644 --- a/packages/client/src/contract.ts +++ b/packages/client/src/contract.ts @@ -246,10 +246,12 @@ export async function hasClaimed( circleId: bigint, nullifierHash: bigint, ): Promise { - const tx = await withRetry(() => client.has_claimed({ + // `has_claimed` is a pure read — don't submit or force a transaction. + // The SDK returns the raw result for read-only contract calls, so just + // invoke it and return the boolean directly. + const res = await withRetry(() => client.has_claimed({ circle_id: circleId, nullifier_hash: nullifierHash, })); - const sent = await tx.signAndSend({ force: true }); - return sent.result; + return res as boolean; }