1use std::path::PathBuf;
19use std::time::Duration;
20
21use rand::RngExt as _;
22use serde_json::{Value, json};
23use vta_sdk::client::VtaClient;
24use vtc_client::VtcClient;
25
26use crate::render::{BOLD, DIM, GREEN, RESET, YELLOW, is_json_output, print_json};
27
28type CliResult<T = ()> = Result<T, Box<dyn std::error::Error>>;
29
30pub struct VoteTarget<'a> {
32 pub vtc_url: &'a str,
34 pub vtc_did: &'a str,
36 pub member: &'a str,
38 pub context: &'a str,
40}
41
42#[derive(Debug, Clone, Copy, Default)]
45pub struct Presenting<'a> {
46 pub credential: Option<&'a str>,
48 pub member_of: Option<&'a str>,
51}
52
53const MAX_CAST_DELAY: Duration = Duration::from_secs(20);
55
56fn community(t: &VoteTarget<'_>) -> VtcClient {
61 VtcClient::anonymous(t.vtc_url, t.vtc_did)
62}
63
64async fn read(
66 client: &VtaClient,
67 t: &VoteTarget<'_>,
68 which: &str,
69 payload: Value,
70) -> CliResult<Value> {
71 read_presenting(client, t, which, payload, Presenting::default()).await
72}
73
74async fn read_presenting(
78 client: &VtaClient,
79 t: &VoteTarget<'_>,
80 which: &str,
81 payload: Value,
82 presenting: Presenting<'_>,
83) -> CliResult<Value> {
84 let mut req = json!({
85 "contextId": t.context, "community": t.vtc_did, "member": t.member,
86 "read": which, "payload": payload,
87 });
88 if let Some(c) = presenting.credential {
89 req["membershipCredential"] = json!(c);
90 if let Some(m) = presenting.member_of {
91 req["memberOf"] = json!(m);
92 }
93 }
94 let signed = client
95 .pcs_voter_read_json(req)
96 .await
97 .map_err(|e| hint_member(e.to_string(), t.member))?;
98 community(t)
99 .send_signed_document(&signed["document"])
100 .await
101 .map_err(|e| community_refusal(t.vtc_did, &e.to_string()))
102}
103
104fn community_refusal(vtc_did: &str, text: &str) -> Box<dyn std::error::Error> {
109 if text.contains("\"indeterminate\"") {
110 return format!(
111 "{vtc_did}: {text}\n\nThe community could not complete a check of your membership \
112 credential just now (its issuer's status list or DID document could not be read). \
113 Nothing was recorded: run the same command again in a little while."
114 )
115 .into();
116 }
117 format!("{vtc_did}: {text}").into()
118}
119
120fn hint_member(text: String, member: &str) -> Box<dyn std::error::Error> {
121 if text.contains("membershipCredentialRequired") {
122 return format!(
123 "{text}\n\nThis election's roll is the holders of a membership credential: claim \
124 your ticket with `pnm elections ticket <id> --membership-credential <handle>`, \
125 naming the credential in your VTA's vault the roll names."
126 )
127 .into();
128 }
129 if text.contains("membershipCredentialMismatch") {
130 return format!(
131 "{text}\n\nThat credential was not issued by the --member-of DID. Your VTA presents \
132 nothing unrelated to the election: name the credential that party issued you."
133 )
134 .into();
135 }
136 if text.contains("memberNotHeld") {
137 return format!(
138 "{text}\n\nYour VTA holds no authentication key of {member} that you may act as. Pass \
139 the persona DID the community knows you by as --member, and the context it lives \
140 in as --context."
141 )
142 .into();
143 }
144 text.into()
145}
146
147pub async fn cmd_list(client: &VtaClient, t: VoteTarget<'_>) -> CliResult {
149 let answer = read(client, &t, "list", json!({})).await?;
150 if is_json_output() {
151 return Ok(print_json(&answer)?);
152 }
153 let items = answer["elections"].as_array().cloned().unwrap_or_default();
154 if items.is_empty() {
155 println!("{DIM}no elections you may vote in{RESET}");
156 }
157 for e in items {
158 println!(
159 "{BOLD}{}{RESET} {} {} → {}{} {DIM}{}{RESET}",
160 e["electionId"].as_str().unwrap_or_default(),
161 e["state"].as_str().unwrap_or_default(),
162 e["votingOpensAt"].as_str().unwrap_or_default(),
163 e["votingClosesAt"].as_str().unwrap_or_default(),
164 if e["ticketHeld"] == true {
165 " (ticket held)"
166 } else {
167 ""
168 },
169 e["title"].as_str().unwrap_or_default(),
170 );
171 }
172 Ok(())
173}
174
175fn print_disclosure(shown: &Value) {
177 let m = &shown["manifest"];
178 let d = &shown["disclosure"];
179 println!(
180 "{BOLD}{}{RESET} {}",
181 m["title"]
182 .as_str()
183 .unwrap_or(m["electionId"].as_str().unwrap_or_default()),
184 shown["state"].as_str().unwrap_or_default()
185 );
186 println!(
187 " voting {} → {}",
188 m["votingOpensAt"].as_str().unwrap_or_default(),
189 m["votingClosesAt"].as_str().unwrap_or_default()
190 );
191 println!(
192 " ballot box {} (run by {})",
193 d["ballotBox"].as_str().unwrap_or_default(),
194 d["ballotBoxOperator"].as_str().unwrap_or_default()
195 );
196 if d["sameOperator"] == true {
197 println!(
198 " {YELLOW}the same operator runs this community and the ballot box: they could link \
199 your ticket to your ballot{RESET}"
200 );
201 }
202 println!(
203 " board {}; a later vote {}",
204 d["boardVisibility"].as_str().unwrap_or_default(),
205 if d["revote"] == "replace" {
206 "replaces the earlier one"
207 } else {
208 "is refused"
209 }
210 );
211 println!(
212 " {DIM}you can prove how you voted, so this election cannot protect you against being made to vote a certain way{RESET}"
213 );
214 if let Some(roll) = roll_credential(d) {
215 println!(" roll {roll}");
216 println!(
217 " {YELLOW}{}{RESET}",
218 roll_credential_warning(d).unwrap_or_default()
219 );
220 }
221 if let Some(release) = written_answers(m) {
222 println!(" written {release}");
223 }
224 if let Some(warning) = written_answers_warning(m) {
225 println!(" {YELLOW}{warning}{RESET}");
226 }
227 for q in m["questions"].as_array().into_iter().flatten() {
228 let opts: Vec<&str> = q["options"]
229 .as_array()
230 .into_iter()
231 .flatten()
232 .filter_map(|o| o["key"].as_str())
233 .collect();
234 println!(
235 " {BOLD}{}{RESET} {} [{}]{}",
236 q["key"].as_str().unwrap_or_default(),
237 q["text"].as_str().unwrap_or_default(),
238 opts.join(", "),
239 if q["optional"] == true {
240 " (optional)"
241 } else {
242 ""
243 }
244 );
245 }
246}
247
248fn written_answers(m: &Value) -> Option<String> {
251 let threshold = m["textReleaseThreshold"].as_u64()?;
252 let asks = m["questions"]
253 .as_array()
254 .into_iter()
255 .flatten()
256 .any(|q| q["type"] == "text");
257 (asks || threshold < 10).then(|| {
258 format!(
259 "released once {threshold} ballot{} counted, never while voting is open",
260 if threshold == 1 { " is" } else { "s are" }
261 )
262 })
263}
264
265fn written_answers_warning(m: &Value) -> Option<String> {
269 let threshold = m["textReleaseThreshold"].as_u64()?;
270 (threshold < 10).then(|| {
271 format!(
272 "written answers are released after only {threshold} ballot{}, fewer than the 10 \
273 recommended: a written answer may identify who wrote it",
274 if threshold == 1 { "" } else { "s" }
275 )
276 })
277}
278
279fn roll_credential(d: &Value) -> Option<String> {
282 let r = d.get("rollCredential")?;
283 let did = r["memberOf"].as_str()?;
284 let what = match r["memberOfKind"].as_str() {
285 Some("thisCommunity") => "this community",
286 Some("communityGroup") => "a group of this community",
287 Some("memberParty") => "a party that is itself a member of this community",
288 _ => "a party",
289 };
290 let name = r["memberOfName"]
291 .as_str()
292 .map(|n| format!(" \"{n}\""))
293 .unwrap_or_default();
294 Some(format!(
295 "holders of a membership credential issued by {what}{name} ({did})"
296 ))
297}
298
299fn roll_credential_warning(d: &Value) -> Option<String> {
302 d.get("rollCredential")?;
303 Some(
304 "claiming a ticket presents that credential: the community learns that you are a \
305 member of that party"
306 .to_string(),
307 )
308}
309
310pub async fn cmd_show(client: &VtaClient, t: VoteTarget<'_>, election_id: &str) -> CliResult {
312 cmd_show_presenting(client, t, election_id, Presenting::default()).await
313}
314
315pub async fn cmd_show_presenting(
320 client: &VtaClient,
321 t: VoteTarget<'_>,
322 election_id: &str,
323 presenting: Presenting<'_>,
324) -> CliResult {
325 let shown = read_presenting(
326 client,
327 &t,
328 "show",
329 json!({ "electionId": election_id }),
330 presenting,
331 )
332 .await?;
333 if is_json_output() {
334 return Ok(print_json(&shown)?);
335 }
336 print_disclosure(&shown);
337 if let Some(told) = presenting.member_of
338 && let Some(names) = shown["manifest"]["roll"]["memberOf"].as_str()
339 && names != told
340 {
341 println!(
342 " {YELLOW}you were told this election is put to the members of {told}, but its roll \
343 names {names}{RESET}"
344 );
345 }
346 Ok(())
347}
348
349fn ballot_from_answers(manifest: &Value, digest: &str, answers: &[String]) -> CliResult<Value> {
352 let mut picked = std::collections::BTreeMap::new();
353 for a in answers {
354 let (q, o) = a
355 .split_once('=')
356 .ok_or_else(|| format!("--answer {a:?}: write it as question=option"))?;
357 picked
358 .entry(q.to_string())
359 .or_insert_with(Vec::new)
360 .push(o.to_string());
361 }
362 let mut out = Vec::new();
363 for q in manifest["questions"].as_array().into_iter().flatten() {
364 let key = q["key"].as_str().unwrap_or_default();
365 match picked.remove(key) {
366 Some(choice) => {
367 if q["type"] != "choice" {
368 return Err(format!(
369 "question {key} is a {} question: give the whole ballot with --ballot",
370 q["type"]
371 )
372 .into());
373 }
374 out.push(json!({ "question": key, "choice": choice }));
375 }
376 None if q["optional"] == true => out.push(json!({ "question": key, "abstain": true })),
377 None => {
378 return Err(
379 format!("question {key} needs an answer: --answer {key}=<option>").into(),
380 );
381 }
382 }
383 }
384 if let Some(q) = picked.keys().next() {
385 return Err(format!("the election asks no question {q}").into());
386 }
387 Ok(json!({
388 "electionId": manifest["electionId"],
389 "manifestDigest": digest,
390 "answers": out,
391 }))
392}
393
394pub(crate) async fn ballot_box_url(did: &str) -> CliResult<String> {
397 let resolver = vta_sdk::resolver::shared_did_resolver_from_env()
398 .await
399 .map_err(|e| format!("build a DID resolver: {e}"))?;
400 let resolved = resolver
401 .resolve(did)
402 .await
403 .map_err(|e| format!("resolve the ballot box {did}: {e}"))?;
404 let doc = serde_json::to_value(&resolved.doc)?;
405 vta_sdk::protocol::matching::ServiceCapabilities::from_did_document(&doc)
406 .rest
407 .filter(|u| vta_sdk::protocol::matching::is_https_or_loopback(u))
408 .ok_or_else(|| {
409 format!("the ballot box {did} advertises no https TrustTaskHTTPS endpoint to cast on")
410 .into()
411 })
412}
413
414async fn take_ticket(
417 client: &VtaClient,
418 t: &VoteTarget<'_>,
419 shown: &Value,
420 credential: Option<&str>,
421) -> CliResult<bool> {
422 let election_id = &shown["manifest"]["electionId"];
423 let mut reg = register_payload(t, shown);
424 if let Some(c) = credential {
425 reg["membershipCredential"] = json!(c);
426 }
427 let registered = match client.pcs_voter_register_json(reg).await {
428 Ok(registered) => registered,
429 Err(e) if e.to_string().contains("ticketHeld") => return Ok(false),
430 Err(e) => return Err(hint_member(e.to_string(), t.member)),
431 };
432 if registered["manifestDigest"] != shown["manifestDigest"] {
433 return Err(
434 "your VTA computed another manifest digest than the community shows; not taking a \
435 ticket"
436 .into(),
437 );
438 }
439 let issued = community(t)
440 .send_signed_document(®istered["ticketDocument"])
441 .await
442 .map_err(|e| community_refusal(t.vtc_did, &e.to_string()))?;
443 let mut response = issued;
445 if let Some(obj) = response.as_object_mut() {
446 obj.remove("ext");
447 }
448 let accept = json!({
449 "contextId": t.context, "community": t.vtc_did, "electionId": election_id,
450 "parameters": shown["parameters"], "response": response,
451 });
452 client.pcs_voter_accept_json(accept).await?;
453 Ok(true)
454}
455
456pub async fn ticket_for(
465 client: &VtaClient,
466 t: &VoteTarget<'_>,
467 election_id: &str,
468) -> CliResult<bool> {
469 let shown = read(client, t, "show", json!({ "electionId": election_id })).await?;
470 take_ticket(client, t, &shown, None).await
471}
472
473fn register_payload(t: &VoteTarget<'_>, shown: &Value) -> Value {
476 json!({
477 "contextId": t.context, "community": t.vtc_did, "member": t.member,
478 "parameters": shown["parameters"], "manifest": shown["manifest"],
479 })
480}
481
482fn print_ticket_taken(election_id: &str) {
484 println!("{GREEN}ticket taken{RESET} — the community signed it blind");
485 println!(
486 "{DIM}vote later with `pnm elections vote {election_id} …`: a cast sent now could be paired \
487 with your ticket by when each arrived{RESET}"
488 );
489}
490
491pub async fn cmd_ticket(client: &VtaClient, t: VoteTarget<'_>, election_id: &str) -> CliResult {
494 cmd_ticket_presenting(client, t, election_id, Presenting::default()).await
495}
496
497pub async fn cmd_ticket_presenting(
502 client: &VtaClient,
503 t: VoteTarget<'_>,
504 election_id: &str,
505 presenting: Presenting<'_>,
506) -> CliResult {
507 let read_with = if presenting.member_of.is_some() {
510 presenting
511 } else {
512 Presenting::default()
513 };
514 let shown = read_presenting(
515 client,
516 &t,
517 "show",
518 json!({ "electionId": election_id }),
519 read_with,
520 )
521 .await?;
522 if !is_json_output() {
523 print_disclosure(&shown);
524 }
525 let taken = take_ticket(client, &t, &shown, presenting.credential).await?;
526 if is_json_output() {
527 return Ok(print_json(
528 &json!({ "electionId": election_id, "ticketHeld": true, "taken": taken }),
529 )?);
530 }
531 if taken {
532 print_ticket_taken(election_id);
533 } else {
534 println!("{DIM}your VTA already holds a ticket for {election_id}{RESET}");
535 }
536 Ok(())
537}
538
539pub async fn cmd_vote(
542 client: &VtaClient,
543 t: VoteTarget<'_>,
544 election_id: &str,
545 answers: &[String],
546 ballot_file: Option<&PathBuf>,
547 now: bool,
548 no_delay: bool,
549) -> CliResult {
550 let shown = read(client, &t, "show", json!({ "electionId": election_id })).await?;
552 if !is_json_output() {
553 print_disclosure(&shown);
554 }
555 let manifest = shown["manifest"].clone();
556 let parameters = shown["parameters"].clone();
557 let digest = shown["manifestDigest"]
558 .as_str()
559 .unwrap_or_default()
560 .to_string();
561 let ballot = match ballot_file {
562 Some(p) => serde_json::from_str(&std::fs::read_to_string(p)?)?,
563 None => ballot_from_answers(&manifest, &digest, answers)?,
564 };
565
566 if take_ticket(client, &t, &shown, None).await? {
568 if !now {
569 if is_json_output() {
570 return Ok(print_json(
571 &json!({ "electionId": election_id, "ticketHeld": true, "voted": false }),
572 )?);
573 }
574 print_ticket_taken(election_id);
575 return Ok(());
576 }
577 if !is_json_output() {
578 println!(
579 "{GREEN}ticket taken{RESET} — {YELLOW}casting in the same run: the community and \
580 the ballot box could pair the two by time{RESET}"
581 );
582 }
583 }
584 let base = json!({ "contextId": t.context, "community": t.vtc_did });
585
586 let ballot_box = manifest["ballotBox"]
589 .as_str()
590 .unwrap_or_default()
591 .to_string();
592 let url = ballot_box_url(&ballot_box).await?;
593 let mut vote = base.clone();
594 vote["parameters"] = parameters;
595 vote["manifest"] = manifest.clone();
596 vote["ballot"] = ballot;
597 let cast = if manifest["secrecy"] == "open" {
598 client.pcs_vote_json(vote).await?
599 } else {
600 vote["ceremony"] = shown
603 .get("ceremony")
604 .cloned()
605 .ok_or("the trustees have not formed the election key yet; vote once voting opens")?;
606 client.pcs_vote_encrypted_json(vote).await?
607 };
608 let cast_doc = &cast["castDocument"];
609
610 if !no_delay {
612 let ms = rand::rng().random_range(0..MAX_CAST_DELAY.as_millis() as u64);
613 if !is_json_output() {
614 println!(
615 "{DIM}waiting {:.1}s before casting{RESET}",
616 ms as f64 / 1000.0
617 );
618 }
619 tokio::time::sleep(Duration::from_millis(ms)).await;
620 }
621 let http = vta_sdk::http::foreign_fetch_client();
622 let resp = http
623 .post(format!("{}/trust-tasks", url.trim_end_matches('/')))
624 .header("content-type", "application/json")
625 .body(serde_json::to_string(cast_doc)?)
626 .send()
627 .await
628 .map_err(|e| format!("the ballot box did not answer ({e}); run the same command again to resend the same cast"))?;
629 let status = resp.status();
630 let receipt: Value = resp.json().await?;
631 if !status.is_success() {
632 return Err(format!(
633 "the ballot box refused the cast: {}",
634 receipt["payload"]["code"].as_str().unwrap_or("unknown")
635 )
636 .into());
637 }
638
639 let mut keep = base;
641 keep["electionId"] = json!(election_id);
642 keep["receipt"] = receipt.clone();
643 let kept = client.pcs_voter_receipt_json(keep).await?;
644 if is_json_output() {
645 return Ok(print_json(&json!({ "receipt": receipt, "kept": kept }))?);
646 }
647 println!(
648 "{GREEN}voted{RESET} in {election_id}: {} (board entry {})",
649 receipt["payload"]["outcome"].as_str().unwrap_or("counted"),
650 receipt["payload"]["leafIndex"]
651 );
652 Ok(())
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658
659 fn manifest() -> Value {
660 json!({
661 "electionId": "agm",
662 "questions": [
663 { "key": "chair", "type": "choice", "options": [{ "key": "a" }, { "key": "b" }], "optional": false },
664 { "key": "budget", "type": "choice", "options": [{ "key": "yes" }, { "key": "no" }], "optional": true }
665 ]
666 })
667 }
668
669 #[test]
670 fn answers_build_a_ballot_and_optional_questions_are_abstained() {
671 let b = ballot_from_answers(&manifest(), "zDigest", &["chair=a".into()]).unwrap();
672 assert_eq!(
673 b["answers"][0],
674 json!({ "question": "chair", "choice": ["a"] })
675 );
676 assert_eq!(
677 b["answers"][1],
678 json!({ "question": "budget", "abstain": true })
679 );
680 assert_eq!(b["manifestDigest"], "zDigest");
681 }
682
683 #[test]
686 fn the_manifest_is_registered_byte_for_byte() {
687 let shown = json!({
688 "manifest": { "electionId": "agm", "votingOpensAt": "2027-01-01T00:00:00.000Z" },
689 "parameters": { "suite": "ps-ddh-bls12381" },
690 });
691 let t = VoteTarget {
692 vtc_url: "https://v",
693 vtc_did: "did:web:v",
694 member: "did:web:m",
695 context: "c",
696 };
697 let reg = register_payload(&t, &shown);
698 assert_eq!(
699 serde_json::to_string(®["manifest"]).unwrap(),
700 r#"{"electionId":"agm","votingOpensAt":"2027-01-01T00:00:00.000Z"}"#
701 );
702 assert_eq!(reg["parameters"], shown["parameters"]);
703 }
704
705 #[test]
706 fn a_required_question_unanswered_or_an_unknown_one_is_refused() {
707 assert!(ballot_from_answers(&manifest(), "z", &[]).is_err());
708 assert!(
709 ballot_from_answers(&manifest(), "z", &["chair=a".into(), "nope=x".into()]).is_err()
710 );
711 assert!(ballot_from_answers(&manifest(), "z", &["chair".into()]).is_err());
712 }
713
714 #[test]
717 fn vti_vot_090_a_threshold_below_ten_is_disclosed_with_its_warning() {
718 let mut m = manifest();
719 m["textReleaseThreshold"] = json!(10);
720 assert_eq!(written_answers(&m), None, "no text question, threshold 10");
721 assert_eq!(written_answers_warning(&m), None);
722
723 m["questions"][1]["type"] = json!("text");
724 assert_eq!(
725 written_answers(&m).as_deref(),
726 Some("released once 10 ballots are counted, never while voting is open")
727 );
728 assert_eq!(written_answers_warning(&m), None);
729
730 m["textReleaseThreshold"] = json!(1);
731 assert_eq!(
732 written_answers(&m).as_deref(),
733 Some("released once 1 ballot is counted, never while voting is open")
734 );
735 let warning = written_answers_warning(&m).unwrap();
736 assert!(warning.contains("only 1 ballot,"), "{warning}");
737 assert!(warning.contains("may identify who wrote it"), "{warning}");
738 }
739
740 #[test]
744 fn vti_vot_090_a_credential_roll_is_disclosed_with_what_claiming_shows() {
745 let d = json!({
746 "rollCredential": {
747 "memberOf": "did:webvh:QmG:vtc.example:groups:g7",
748 "memberOfKind": "communityGroup",
749 "memberOfName": "Engineering",
750 "presenterDisclosed": true,
751 }
752 });
753 let roll = roll_credential(&d).unwrap();
754 assert!(roll.contains("a group of this community"), "{roll}");
755 assert!(roll.contains("\"Engineering\""), "{roll}");
756 assert!(
757 roll.contains("did:webvh:QmG:vtc.example:groups:g7"),
758 "{roll}"
759 );
760 let warning = roll_credential_warning(&d).unwrap();
761 assert!(warning.contains("member of that party"), "{warning}");
762
763 let party = json!({ "rollCredential": {
764 "memberOf": "did:webvh:QmB:b.example", "memberOfKind": "memberParty",
765 "presenterDisclosed": true } });
766 assert!(
767 roll_credential(&party)
768 .unwrap()
769 .contains("itself a member of this community")
770 );
771 assert_eq!(roll_credential(&json!({})), None);
772 assert_eq!(roll_credential_warning(&json!({})), None);
773 }
774
775 #[test]
777 fn vti_vot_018_an_indeterminate_answer_says_to_try_again() {
778 let text =
779 community_refusal("did:c", r#"{"code":"indeterminate","retryable":true}"#).to_string();
780 assert!(text.contains("run the same command again"), "{text}");
781 let other = community_refusal("did:c", "electionUnknown").to_string();
782 assert!(!other.contains("run the same command again"));
783 }
784}