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
42const MAX_CAST_DELAY: Duration = Duration::from_secs(20);
44
45fn community(t: &VoteTarget<'_>) -> VtcClient {
50 VtcClient::anonymous(t.vtc_url, t.vtc_did)
51}
52
53async fn read(
55 client: &VtaClient,
56 t: &VoteTarget<'_>,
57 which: &str,
58 payload: Value,
59) -> CliResult<Value> {
60 let req = json!({
61 "contextId": t.context, "community": t.vtc_did, "member": t.member,
62 "read": which, "payload": payload,
63 });
64 let signed = client
65 .pcs_voter_read_json(req)
66 .await
67 .map_err(|e| hint_member(e.to_string(), t.member))?;
68 Ok(community(t)
69 .send_signed_document(&signed["document"])
70 .await
71 .map_err(|e| format!("{}: {e}", t.vtc_did))?)
72}
73
74fn hint_member(text: String, member: &str) -> Box<dyn std::error::Error> {
75 if text.contains("memberNotHeld") {
76 return format!(
77 "{text}\n\nYour VTA holds no authentication key of {member} that you may act as. Pass \
78 the persona DID the community knows you by as --member, and the context it lives \
79 in as --context."
80 )
81 .into();
82 }
83 text.into()
84}
85
86pub async fn cmd_list(client: &VtaClient, t: VoteTarget<'_>) -> CliResult {
88 let answer = read(client, &t, "list", json!({})).await?;
89 if is_json_output() {
90 return Ok(print_json(&answer)?);
91 }
92 let items = answer["elections"].as_array().cloned().unwrap_or_default();
93 if items.is_empty() {
94 println!("{DIM}no elections you may vote in{RESET}");
95 }
96 for e in items {
97 println!(
98 "{BOLD}{}{RESET} {} {} → {}{} {DIM}{}{RESET}",
99 e["electionId"].as_str().unwrap_or_default(),
100 e["state"].as_str().unwrap_or_default(),
101 e["votingOpensAt"].as_str().unwrap_or_default(),
102 e["votingClosesAt"].as_str().unwrap_or_default(),
103 if e["ticketHeld"] == true {
104 " (ticket held)"
105 } else {
106 ""
107 },
108 e["title"].as_str().unwrap_or_default(),
109 );
110 }
111 Ok(())
112}
113
114fn print_disclosure(shown: &Value) {
116 let m = &shown["manifest"];
117 let d = &shown["disclosure"];
118 println!(
119 "{BOLD}{}{RESET} {}",
120 m["title"]
121 .as_str()
122 .unwrap_or(m["electionId"].as_str().unwrap_or_default()),
123 shown["state"].as_str().unwrap_or_default()
124 );
125 println!(
126 " voting {} → {}",
127 m["votingOpensAt"].as_str().unwrap_or_default(),
128 m["votingClosesAt"].as_str().unwrap_or_default()
129 );
130 println!(
131 " ballot box {} (run by {})",
132 d["ballotBox"].as_str().unwrap_or_default(),
133 d["ballotBoxOperator"].as_str().unwrap_or_default()
134 );
135 if d["sameOperator"] == true {
136 println!(
137 " {YELLOW}the same operator runs this community and the ballot box: they could link \
138 your ticket to your ballot{RESET}"
139 );
140 }
141 println!(
142 " board {}; a later vote {}",
143 d["boardVisibility"].as_str().unwrap_or_default(),
144 if d["revote"] == "replace" {
145 "replaces the earlier one"
146 } else {
147 "is refused"
148 }
149 );
150 println!(
151 " {DIM}you can prove how you voted, so this election cannot protect you against being made to vote a certain way{RESET}"
152 );
153 if let Some(release) = written_answers(m) {
154 println!(" written {release}");
155 }
156 if let Some(warning) = written_answers_warning(m) {
157 println!(" {YELLOW}{warning}{RESET}");
158 }
159 for q in m["questions"].as_array().into_iter().flatten() {
160 let opts: Vec<&str> = q["options"]
161 .as_array()
162 .into_iter()
163 .flatten()
164 .filter_map(|o| o["key"].as_str())
165 .collect();
166 println!(
167 " {BOLD}{}{RESET} {} [{}]{}",
168 q["key"].as_str().unwrap_or_default(),
169 q["text"].as_str().unwrap_or_default(),
170 opts.join(", "),
171 if q["optional"] == true {
172 " (optional)"
173 } else {
174 ""
175 }
176 );
177 }
178}
179
180fn written_answers(m: &Value) -> Option<String> {
183 let threshold = m["textReleaseThreshold"].as_u64()?;
184 let asks = m["questions"]
185 .as_array()
186 .into_iter()
187 .flatten()
188 .any(|q| q["type"] == "text");
189 (asks || threshold < 10).then(|| {
190 format!(
191 "released once {threshold} ballot{} counted, never while voting is open",
192 if threshold == 1 { " is" } else { "s are" }
193 )
194 })
195}
196
197fn written_answers_warning(m: &Value) -> Option<String> {
201 let threshold = m["textReleaseThreshold"].as_u64()?;
202 (threshold < 10).then(|| {
203 format!(
204 "written answers are released after only {threshold} ballot{}, fewer than the 10 \
205 recommended: a written answer may identify who wrote it",
206 if threshold == 1 { "" } else { "s" }
207 )
208 })
209}
210
211pub async fn cmd_show(client: &VtaClient, t: VoteTarget<'_>, election_id: &str) -> CliResult {
213 let shown = read(client, &t, "show", json!({ "electionId": election_id })).await?;
214 if is_json_output() {
215 return Ok(print_json(&shown)?);
216 }
217 print_disclosure(&shown);
218 Ok(())
219}
220
221fn ballot_from_answers(manifest: &Value, digest: &str, answers: &[String]) -> CliResult<Value> {
224 let mut picked = std::collections::BTreeMap::new();
225 for a in answers {
226 let (q, o) = a
227 .split_once('=')
228 .ok_or_else(|| format!("--answer {a:?}: write it as question=option"))?;
229 picked
230 .entry(q.to_string())
231 .or_insert_with(Vec::new)
232 .push(o.to_string());
233 }
234 let mut out = Vec::new();
235 for q in manifest["questions"].as_array().into_iter().flatten() {
236 let key = q["key"].as_str().unwrap_or_default();
237 match picked.remove(key) {
238 Some(choice) => {
239 if q["type"] != "choice" {
240 return Err(format!(
241 "question {key} is a {} question: give the whole ballot with --ballot",
242 q["type"]
243 )
244 .into());
245 }
246 out.push(json!({ "question": key, "choice": choice }));
247 }
248 None if q["optional"] == true => out.push(json!({ "question": key, "abstain": true })),
249 None => {
250 return Err(
251 format!("question {key} needs an answer: --answer {key}=<option>").into(),
252 );
253 }
254 }
255 }
256 if let Some(q) = picked.keys().next() {
257 return Err(format!("the election asks no question {q}").into());
258 }
259 Ok(json!({
260 "electionId": manifest["electionId"],
261 "manifestDigest": digest,
262 "answers": out,
263 }))
264}
265
266pub(crate) async fn ballot_box_url(did: &str) -> CliResult<String> {
269 let resolver = vta_sdk::resolver::shared_did_resolver_from_env()
270 .await
271 .map_err(|e| format!("build a DID resolver: {e}"))?;
272 let resolved = resolver
273 .resolve(did)
274 .await
275 .map_err(|e| format!("resolve the ballot box {did}: {e}"))?;
276 let doc = serde_json::to_value(&resolved.doc)?;
277 vta_sdk::protocol::matching::ServiceCapabilities::from_did_document(&doc)
278 .rest
279 .filter(|u| vta_sdk::protocol::matching::is_https_or_loopback(u))
280 .ok_or_else(|| {
281 format!("the ballot box {did} advertises no https TrustTaskHTTPS endpoint to cast on")
282 .into()
283 })
284}
285
286async fn take_ticket(client: &VtaClient, t: &VoteTarget<'_>, shown: &Value) -> CliResult<bool> {
289 let election_id = &shown["manifest"]["electionId"];
290 let reg = register_payload(t, shown);
291 let registered = match client.pcs_voter_register_json(reg).await {
292 Ok(registered) => registered,
293 Err(e) if e.to_string().contains("ticketHeld") => return Ok(false),
294 Err(e) => return Err(hint_member(e.to_string(), t.member)),
295 };
296 if registered["manifestDigest"] != shown["manifestDigest"] {
297 return Err(
298 "your VTA computed another manifest digest than the community shows; not taking a \
299 ticket"
300 .into(),
301 );
302 }
303 let issued = community(t)
304 .send_signed_document(®istered["ticketDocument"])
305 .await
306 .map_err(|e| format!("{}: {e}", t.vtc_did))?;
307 let mut response = issued;
309 if let Some(obj) = response.as_object_mut() {
310 obj.remove("ext");
311 }
312 let accept = json!({
313 "contextId": t.context, "community": t.vtc_did, "electionId": election_id,
314 "parameters": shown["parameters"], "response": response,
315 });
316 client.pcs_voter_accept_json(accept).await?;
317 Ok(true)
318}
319
320fn register_payload(t: &VoteTarget<'_>, shown: &Value) -> Value {
323 json!({
324 "contextId": t.context, "community": t.vtc_did, "member": t.member,
325 "parameters": shown["parameters"], "manifest": shown["manifest"],
326 })
327}
328
329fn print_ticket_taken(election_id: &str) {
331 println!("{GREEN}ticket taken{RESET} — the community signed it blind");
332 println!(
333 "{DIM}vote later with `pnm elections vote {election_id} …`: a cast sent now could be paired \
334 with your ticket by when each arrived{RESET}"
335 );
336}
337
338pub async fn cmd_ticket(client: &VtaClient, t: VoteTarget<'_>, election_id: &str) -> CliResult {
341 let shown = read(client, &t, "show", json!({ "electionId": election_id })).await?;
342 if !is_json_output() {
343 print_disclosure(&shown);
344 }
345 let taken = take_ticket(client, &t, &shown).await?;
346 if is_json_output() {
347 return Ok(print_json(
348 &json!({ "electionId": election_id, "ticketHeld": true, "taken": taken }),
349 )?);
350 }
351 if taken {
352 print_ticket_taken(election_id);
353 } else {
354 println!("{DIM}your VTA already holds a ticket for {election_id}{RESET}");
355 }
356 Ok(())
357}
358
359pub async fn cmd_vote(
362 client: &VtaClient,
363 t: VoteTarget<'_>,
364 election_id: &str,
365 answers: &[String],
366 ballot_file: Option<&PathBuf>,
367 now: bool,
368 no_delay: bool,
369) -> CliResult {
370 let shown = read(client, &t, "show", json!({ "electionId": election_id })).await?;
372 if !is_json_output() {
373 print_disclosure(&shown);
374 }
375 let manifest = shown["manifest"].clone();
376 let parameters = shown["parameters"].clone();
377 let digest = shown["manifestDigest"]
378 .as_str()
379 .unwrap_or_default()
380 .to_string();
381 let ballot = match ballot_file {
382 Some(p) => serde_json::from_str(&std::fs::read_to_string(p)?)?,
383 None => ballot_from_answers(&manifest, &digest, answers)?,
384 };
385
386 if take_ticket(client, &t, &shown).await? {
388 if !now {
389 if is_json_output() {
390 return Ok(print_json(
391 &json!({ "electionId": election_id, "ticketHeld": true, "voted": false }),
392 )?);
393 }
394 print_ticket_taken(election_id);
395 return Ok(());
396 }
397 if !is_json_output() {
398 println!(
399 "{GREEN}ticket taken{RESET} — {YELLOW}casting in the same run: the community and \
400 the ballot box could pair the two by time{RESET}"
401 );
402 }
403 }
404 let base = json!({ "contextId": t.context, "community": t.vtc_did });
405
406 let ballot_box = manifest["ballotBox"]
409 .as_str()
410 .unwrap_or_default()
411 .to_string();
412 let url = ballot_box_url(&ballot_box).await?;
413 let mut vote = base.clone();
414 vote["parameters"] = parameters;
415 vote["manifest"] = manifest.clone();
416 vote["ballot"] = ballot;
417 let cast = if manifest["secrecy"] == "open" {
418 client.pcs_vote_json(vote).await?
419 } else {
420 vote["ceremony"] = shown
423 .get("ceremony")
424 .cloned()
425 .ok_or("the trustees have not formed the election key yet; vote once voting opens")?;
426 client.pcs_vote_encrypted_json(vote).await?
427 };
428 let cast_doc = &cast["castDocument"];
429
430 if !no_delay {
432 let ms = rand::rng().random_range(0..MAX_CAST_DELAY.as_millis() as u64);
433 if !is_json_output() {
434 println!(
435 "{DIM}waiting {:.1}s before casting{RESET}",
436 ms as f64 / 1000.0
437 );
438 }
439 tokio::time::sleep(Duration::from_millis(ms)).await;
440 }
441 let http = vta_sdk::http::foreign_fetch_client();
442 let resp = http
443 .post(format!("{}/trust-tasks", url.trim_end_matches('/')))
444 .header("content-type", "application/json")
445 .body(serde_json::to_string(cast_doc)?)
446 .send()
447 .await
448 .map_err(|e| format!("the ballot box did not answer ({e}); run the same command again to resend the same cast"))?;
449 let status = resp.status();
450 let receipt: Value = resp.json().await?;
451 if !status.is_success() {
452 return Err(format!(
453 "the ballot box refused the cast: {}",
454 receipt["payload"]["code"].as_str().unwrap_or("unknown")
455 )
456 .into());
457 }
458
459 let mut keep = base;
461 keep["electionId"] = json!(election_id);
462 keep["receipt"] = receipt.clone();
463 let kept = client.pcs_voter_receipt_json(keep).await?;
464 if is_json_output() {
465 return Ok(print_json(&json!({ "receipt": receipt, "kept": kept }))?);
466 }
467 println!(
468 "{GREEN}voted{RESET} in {election_id}: {} (board entry {})",
469 receipt["payload"]["outcome"].as_str().unwrap_or("counted"),
470 receipt["payload"]["leafIndex"]
471 );
472 Ok(())
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478
479 fn manifest() -> Value {
480 json!({
481 "electionId": "agm",
482 "questions": [
483 { "key": "chair", "type": "choice", "options": [{ "key": "a" }, { "key": "b" }], "optional": false },
484 { "key": "budget", "type": "choice", "options": [{ "key": "yes" }, { "key": "no" }], "optional": true }
485 ]
486 })
487 }
488
489 #[test]
490 fn answers_build_a_ballot_and_optional_questions_are_abstained() {
491 let b = ballot_from_answers(&manifest(), "zDigest", &["chair=a".into()]).unwrap();
492 assert_eq!(
493 b["answers"][0],
494 json!({ "question": "chair", "choice": ["a"] })
495 );
496 assert_eq!(
497 b["answers"][1],
498 json!({ "question": "budget", "abstain": true })
499 );
500 assert_eq!(b["manifestDigest"], "zDigest");
501 }
502
503 #[test]
506 fn the_manifest_is_registered_byte_for_byte() {
507 let shown = json!({
508 "manifest": { "electionId": "agm", "votingOpensAt": "2027-01-01T00:00:00.000Z" },
509 "parameters": { "suite": "ps-ddh-bls12381" },
510 });
511 let t = VoteTarget {
512 vtc_url: "https://v",
513 vtc_did: "did:web:v",
514 member: "did:web:m",
515 context: "c",
516 };
517 let reg = register_payload(&t, &shown);
518 assert_eq!(
519 serde_json::to_string(®["manifest"]).unwrap(),
520 r#"{"electionId":"agm","votingOpensAt":"2027-01-01T00:00:00.000Z"}"#
521 );
522 assert_eq!(reg["parameters"], shown["parameters"]);
523 }
524
525 #[test]
526 fn a_required_question_unanswered_or_an_unknown_one_is_refused() {
527 assert!(ballot_from_answers(&manifest(), "z", &[]).is_err());
528 assert!(
529 ballot_from_answers(&manifest(), "z", &["chair=a".into(), "nope=x".into()]).is_err()
530 );
531 assert!(ballot_from_answers(&manifest(), "z", &["chair".into()]).is_err());
532 }
533
534 #[test]
537 fn vti_vot_090_a_threshold_below_ten_is_disclosed_with_its_warning() {
538 let mut m = manifest();
539 m["textReleaseThreshold"] = json!(10);
540 assert_eq!(written_answers(&m), None, "no text question, threshold 10");
541 assert_eq!(written_answers_warning(&m), None);
542
543 m["questions"][1]["type"] = json!("text");
544 assert_eq!(
545 written_answers(&m).as_deref(),
546 Some("released once 10 ballots are counted, never while voting is open")
547 );
548 assert_eq!(written_answers_warning(&m), None);
549
550 m["textReleaseThreshold"] = json!(1);
551 assert_eq!(
552 written_answers(&m).as_deref(),
553 Some("released once 1 ballot is counted, never while voting is open")
554 );
555 let warning = written_answers_warning(&m).unwrap();
556 assert!(warning.contains("only 1 ballot,"), "{warning}");
557 assert!(warning.contains("may identify who wrote it"), "{warning}");
558 }
559}