Skip to content

Commit b2021f7

Browse files
authored
feat: add voting receipt download with commitment validation details (#234)
1 parent 2cc660c commit b2021f7

3 files changed

Lines changed: 132 additions & 8 deletions

File tree

dapp/src/components/page/proposal/VotingModal.tsx

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const VotingModal: React.FC<VotersModalProps> = ({
3434
const [selectedWeight, setSelectedWeight] = useState<number>(0);
3535
const [isTokenVoting, setIsTokenVoting] = useState(false);
3636
const [tokenBalance, setTokenBalance] = useState<number>(0);
37+
const [voteReceipt, setVoteReceipt] = useState<import("types/proposal").VoteReceipt | null>(null);
3738
const isInsufficientVotingPower = maxWeight <= 0;
3839

3940
const insufficientPowerMessage = isTokenVoting
@@ -122,18 +123,20 @@ const VotingModal: React.FC<VotersModalProps> = ({
122123
const { voteToProposal } = await import("@service/ContractService");
123124

124125
try {
125-
await voteToProposal(
126+
const receipt = await voteToProposal(
126127
projectName,
127128
proposalId!,
128129
selectedOption as VoteType,
129130
selectedWeight,
130131
);
132+
133+
setVoteReceipt(receipt);
131134
onVoteSuccess?.();
132135
toast.success(
133136
"Congratulations!",
134137
"Your vote was submitted successfully.",
135138
);
136-
onClose();
139+
// We do not close the modal here, we show the receipt instead
137140
} catch (error: any) {
138141
let errorMessage = "Failed to cast vote";
139142

@@ -154,6 +157,93 @@ const VotingModal: React.FC<VotersModalProps> = ({
154157
}
155158
};
156159

160+
if (voteReceipt) {
161+
return (
162+
<Modal onClose={onClose}>
163+
<style>{`
164+
@media print {
165+
body * { visibility: hidden; }
166+
#printable-receipt, #printable-receipt * { visibility: visible; }
167+
#printable-receipt { position: absolute; left: 0; top: 0; width: 100%; padding: 20px; }
168+
.no-print { display: none !important; }
169+
}
170+
`}</style>
171+
<div id="printable-receipt" className="flex flex-col gap-6 w-full max-w-2xl mx-auto">
172+
<div className="flex flex-col gap-2 border-b pb-4">
173+
<h2 className="text-2xl font-bold text-primary">Vote Receipt</h2>
174+
<p className="text-sm text-secondary">
175+
{voteReceipt.isPublicVoting
176+
? "Your vote was public. You can check the transaction hash on a Stellar explorer to verify it is on-chain."
177+
: "Your vote was anonymous. Keep this receipt safe. You can verify your commitment on-chain using the transaction hash. At the end of the vote, the tally can be checked by verifying all commitments against the encrypted votes."}
178+
</p>
179+
</div>
180+
181+
<div className="flex flex-col gap-4 bg-gray-50 p-4 rounded-lg overflow-x-auto text-sm">
182+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
183+
<div><span className="font-semibold">Project:</span> {voteReceipt.projectName}</div>
184+
<div><span className="font-semibold">Proposal ID:</span> {voteReceipt.proposalId}</div>
185+
<div><span className="font-semibold">Vote Type:</span> <span className="capitalize">{voteReceipt.voteType}</span></div>
186+
<div><span className="font-semibold">Weight:</span> {voteReceipt.weight.toLocaleString()}</div>
187+
<div><span className="font-semibold">Voting Type:</span> {voteReceipt.isPublicVoting ? "Public" : "Anonymous"}</div>
188+
</div>
189+
190+
{voteReceipt.transactionHash && (
191+
<div className="mt-2">
192+
<span className="font-semibold block mb-1">Transaction Hash:</span>
193+
<code className="bg-white px-2 py-1 rounded border break-all text-xs">{voteReceipt.transactionHash}</code>
194+
</div>
195+
)}
196+
197+
{!voteReceipt.isPublicVoting && (
198+
<>
199+
<div className="mt-2">
200+
<span className="font-semibold block mb-1">Public Key:</span>
201+
<code className="bg-white px-2 py-1 rounded border break-all text-xs">{voteReceipt.publicKey}</code>
202+
</div>
203+
204+
<div className="mt-2">
205+
<span className="font-semibold block mb-1">Encrypted Votes (Approve, Reject, Abstain):</span>
206+
<div className="flex flex-col gap-1">
207+
{voteReceipt.votes?.map((v, i) => (
208+
<code key={i} className="bg-white px-2 py-1 rounded border break-all text-xs">{v}</code>
209+
))}
210+
</div>
211+
</div>
212+
213+
<div className="mt-2">
214+
<span className="font-semibold block mb-1">Cryptographic Seeds:</span>
215+
<div className="flex flex-col gap-1">
216+
{voteReceipt.seeds?.map((s, i) => (
217+
<code key={i} className="bg-white px-2 py-1 rounded border break-all text-xs">{s}</code>
218+
))}
219+
</div>
220+
</div>
221+
222+
<div className="mt-2">
223+
<span className="font-semibold block mb-1">Commitments:</span>
224+
<div className="flex flex-col gap-1">
225+
{voteReceipt.commitments?.map((c, i) => (
226+
<code key={i} className="bg-white px-2 py-1 rounded border break-all text-xs">{c}</code>
227+
))}
228+
</div>
229+
</div>
230+
</>
231+
)}
232+
</div>
233+
234+
<div className="flex justify-end gap-3 no-print mt-4">
235+
<Button type="secondary" onClick={onClose}>
236+
Close
237+
</Button>
238+
<Button onClick={() => window.print()}>
239+
Print / Save PDF
240+
</Button>
241+
</div>
242+
</div>
243+
</Modal>
244+
);
245+
}
246+
157247
return (
158248
<Modal onClose={onClose}>
159249
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6 sm:gap-9">

dapp/src/service/ContractService.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ export async function voteToProposal(
260260
proposal_id: number,
261261
vote: VoteType,
262262
customWeight?: number,
263-
): Promise<boolean> {
263+
): Promise<import("types/proposal").VoteReceipt> {
264264
const client = getClient();
265265
const maintainer = client.options.publicKey;
266266
if (!maintainer) throw new Error("Wallet not connected");
@@ -307,6 +307,12 @@ export async function voteToProposal(
307307
}
308308

309309
let votePayload: Vote;
310+
311+
// Variables for receipt
312+
let seedsArr: number[] | undefined;
313+
let votesArr: number[] | undefined;
314+
let commitments: string[] | undefined;
315+
let publicKeyStr: string | undefined;
310316

311317
if (isPublicVoting) {
312318
votePayload = {
@@ -326,12 +332,12 @@ export async function voteToProposal(
326332
// Weight is applied on-chain when validating/aggregating, so the encoded
327333
// vote should be 1 for the chosen option and 0 for others to avoid u32
328334
// overflow in later tallies.
329-
const votesArr: number[] = [0, 0, 0];
335+
votesArr = [0, 0, 0];
330336
votesArr[voteIndex] = 1;
331337

332338
// Generate cryptographically secure 32-bit seeds.
333339
const r = crypto.getRandomValues(new Uint32Array(3));
334-
const seedsArr: number[] = [Number(r[0]), Number(r[1]), Number(r[2])];
340+
seedsArr = [Number(r[0]), Number(r[1]), Number(r[2])];
335341

336342
// Get anonymous voting config
337343
const configTx = await client.get_anonymous_voting_config({
@@ -340,7 +346,7 @@ export async function voteToProposal(
340346
// Ensure config actually exists (not an error bubbled in result)
341347
checkSimulationError(configTx);
342348

343-
const publicKeyStr = configTx.result?.public_key;
349+
publicKeyStr = configTx.result?.public_key;
344350
if (!publicKeyStr)
345351
throw new Error("Anonymous voting config missing public key");
346352

@@ -375,6 +381,7 @@ export async function voteToProposal(
375381
]);
376382
// Ensure the helper call did not surface a simulation error payload
377383
checkSimulationError(commitmentsTx);
384+
commitments = commitmentsTx.result?.map((c: any) => c.toString());
378385
} catch (e: any) {
379386
// Normalize Wasm VM/host errors to user-friendly text
380387
throw new Error(parseContractError(e), { cause: e });
@@ -404,9 +411,23 @@ export async function voteToProposal(
404411
// Check for simulation errors (contract errors) before submitting
405412
checkSimulationError(assembledTx);
406413

407-
await submitTransaction(assembledTx);
414+
const result = await submitTransaction(assembledTx);
408415
invalidateProposalCache(project_name, proposal_id);
409-
return true;
416+
417+
return {
418+
projectName: project_name,
419+
proposalId: proposal_id,
420+
voteType: vote,
421+
weight,
422+
isPublicVoting,
423+
transactionHash: result?.hash, // if available
424+
...(isPublicVoting ? {} : {
425+
seeds: seedsArr?.map(String),
426+
votes: votesArr?.map(String),
427+
commitments,
428+
publicKey: publicKeyStr
429+
})
430+
};
410431
}
411432

412433
/**

dapp/src/types/proposal.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,16 @@ export interface ProposalOutcome {
8888
contract?: OutcomeContract; // New contract-based outcome
8989
};
9090
}
91+
92+
export interface VoteReceipt {
93+
projectName: string;
94+
proposalId: number;
95+
voteType: string;
96+
weight: number;
97+
isPublicVoting: boolean;
98+
transactionHash?: string;
99+
seeds?: string[];
100+
votes?: string[];
101+
commitments?: string[];
102+
publicKey?: string;
103+
}

0 commit comments

Comments
 (0)