Skip to content
Merged
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
40 changes: 37 additions & 3 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -576,6 +607,7 @@ export default function App() {
identity: m.identity,
funded: m.funded,
fundHash: m.fundHash,
ineligible: m.ineligible ?? false,
}));
setMembers(loadedMembers);

Expand Down Expand Up @@ -618,6 +650,7 @@ export default function App() {
keypair: Keypair.random(),
identity: generateIdentity(),
funded: false,
ineligible: false,
}));

const newTree = MerkleTree.create(
Expand Down Expand Up @@ -1059,15 +1092,16 @@ export default function App() {
which one.
</p>
<div className="row">
{members.map((_, i) => (
{members.map((m, i) => (
<label key={i} className="radio">
<input
type="radio"
checked={claimantIndex === i}
onChange={() => setClaimantIndex(i)}
disabled={!!busy}
disabled={!!busy || !!m.ineligible}
title={m.ineligible ? m.ineligibleReason ?? "Ineligible to claim" : undefined}
/>
member {i + 1}
member {i + 1}{m.ineligible ? " (ineligible)" : ""}
</label>
))}
</div>
Expand Down
7 changes: 4 additions & 3 deletions app/src/components/ClaimSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,16 @@ export function ClaimSection({
real member <em>without</em> revealing which one.
</p>
<div className="row">
{members.map((_, i) => (
{members.map((m, i) => (
<label key={i} className="radio">
<input
type="radio"
checked={claimantIndex === i}
onChange={() => onSelectClaimant(i)}
disabled={!!busy}
disabled={!!busy || !!m.ineligible}
title={m.ineligible ? m.ineligibleReason ?? "Ineligible to claim" : undefined}
/>
member {i + 1}
member {i + 1}{m.ineligible ? ` (ineligible)` : ""}
</label>
))}
</div>
Expand Down
9 changes: 6 additions & 3 deletions app/src/components/MemberRing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,10 +23,11 @@ export function MemberRing({
return (
<div
key={i}
className={`ring-node ${m.funded ? "funded" : ""}`}
className={`ring-node ${m.funded ? "funded" : ""} ${m.ineligible ? "ineligible" : ""}`}
style={{ transform: `translate(${x}px, ${y}px)` }}
aria-label={`member ${i + 1}${m.ineligible ? ", ineligible: already claimed" : ""}`}
>
{i + 1}
{m.ineligible ? "×" : i + 1}
</div>
);
})}
Expand Down
2 changes: 2 additions & 0 deletions app/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export interface Member {
identity: Identity;
funded: boolean;
fundHash?: string;
ineligible?: boolean;
ineligibleReason?: string;
}

export interface ClaimResult {
Expand Down
8 changes: 5 additions & 3 deletions packages/client/src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,10 +246,12 @@ export async function hasClaimed(
circleId: bigint,
nullifierHash: bigint,
): Promise<boolean> {
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;
}