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 for q in m["questions"].as_array().into_iter().flatten() {
154 let opts: Vec<&str> = q["options"]
155 .as_array()
156 .into_iter()
157 .flatten()
158 .filter_map(|o| o["key"].as_str())
159 .collect();
160 println!(
161 " {BOLD}{}{RESET} {} [{}]{}",
162 q["key"].as_str().unwrap_or_default(),
163 q["text"].as_str().unwrap_or_default(),
164 opts.join(", "),
165 if q["optional"] == true {
166 " (optional)"
167 } else {
168 ""
169 }
170 );
171 }
172}
173
174pub async fn cmd_show(client: &VtaClient, t: VoteTarget<'_>, election_id: &str) -> CliResult {
176 let shown = read(client, &t, "show", json!({ "electionId": election_id })).await?;
177 if is_json_output() {
178 return Ok(print_json(&shown)?);
179 }
180 print_disclosure(&shown);
181 Ok(())
182}
183
184fn ballot_from_answers(manifest: &Value, digest: &str, answers: &[String]) -> CliResult<Value> {
187 let mut picked = std::collections::BTreeMap::new();
188 for a in answers {
189 let (q, o) = a
190 .split_once('=')
191 .ok_or_else(|| format!("--answer {a:?}: write it as question=option"))?;
192 picked
193 .entry(q.to_string())
194 .or_insert_with(Vec::new)
195 .push(o.to_string());
196 }
197 let mut out = Vec::new();
198 for q in manifest["questions"].as_array().into_iter().flatten() {
199 let key = q["key"].as_str().unwrap_or_default();
200 match picked.remove(key) {
201 Some(choice) => {
202 if q["type"] != "choice" {
203 return Err(format!(
204 "question {key} is a {} question: give the whole ballot with --ballot",
205 q["type"]
206 )
207 .into());
208 }
209 out.push(json!({ "question": key, "choice": choice }));
210 }
211 None if q["optional"] == true => out.push(json!({ "question": key, "abstain": true })),
212 None => {
213 return Err(
214 format!("question {key} needs an answer: --answer {key}=<option>").into(),
215 );
216 }
217 }
218 }
219 if let Some(q) = picked.keys().next() {
220 return Err(format!("the election asks no question {q}").into());
221 }
222 Ok(json!({
223 "electionId": manifest["electionId"],
224 "manifestDigest": digest,
225 "answers": out,
226 }))
227}
228
229pub(crate) async fn ballot_box_url(did: &str) -> CliResult<String> {
232 let resolver = vta_sdk::resolver::shared_did_resolver_from_env()
233 .await
234 .map_err(|e| format!("build a DID resolver: {e}"))?;
235 let resolved = resolver
236 .resolve(did)
237 .await
238 .map_err(|e| format!("resolve the ballot box {did}: {e}"))?;
239 let doc = serde_json::to_value(&resolved.doc)?;
240 vta_sdk::protocol::matching::ServiceCapabilities::from_did_document(&doc)
241 .rest
242 .filter(|u| vta_sdk::protocol::matching::is_https_or_loopback(u))
243 .ok_or_else(|| {
244 format!("the ballot box {did} advertises no https TrustTaskHTTPS endpoint to cast on")
245 .into()
246 })
247}
248
249async fn take_ticket(client: &VtaClient, t: &VoteTarget<'_>, shown: &Value) -> CliResult<bool> {
252 let election_id = &shown["manifest"]["electionId"];
253 let reg = register_payload(t, shown);
254 let registered = match client.pcs_voter_register_json(reg).await {
255 Ok(registered) => registered,
256 Err(e) if e.to_string().contains("ticketHeld") => return Ok(false),
257 Err(e) => return Err(hint_member(e.to_string(), t.member)),
258 };
259 if registered["manifestDigest"] != shown["manifestDigest"] {
260 return Err(
261 "your VTA computed another manifest digest than the community shows; not taking a \
262 ticket"
263 .into(),
264 );
265 }
266 let issued = community(t)
267 .send_signed_document(®istered["ticketDocument"])
268 .await
269 .map_err(|e| format!("{}: {e}", t.vtc_did))?;
270 let mut response = issued;
272 if let Some(obj) = response.as_object_mut() {
273 obj.remove("ext");
274 }
275 let accept = json!({
276 "contextId": t.context, "community": t.vtc_did, "electionId": election_id,
277 "parameters": shown["parameters"], "response": response,
278 });
279 client.pcs_voter_accept_json(accept).await?;
280 Ok(true)
281}
282
283fn register_payload(t: &VoteTarget<'_>, shown: &Value) -> Value {
286 json!({
287 "contextId": t.context, "community": t.vtc_did, "member": t.member,
288 "parameters": shown["parameters"], "manifest": shown["manifest"],
289 })
290}
291
292fn print_ticket_taken(election_id: &str) {
294 println!("{GREEN}ticket taken{RESET} — the community signed it blind");
295 println!(
296 "{DIM}vote later with `pnm elections vote {election_id} …`: a cast sent now could be paired \
297 with your ticket by when each arrived{RESET}"
298 );
299}
300
301pub async fn cmd_ticket(client: &VtaClient, t: VoteTarget<'_>, election_id: &str) -> CliResult {
304 let shown = read(client, &t, "show", json!({ "electionId": election_id })).await?;
305 if !is_json_output() {
306 print_disclosure(&shown);
307 }
308 let taken = take_ticket(client, &t, &shown).await?;
309 if is_json_output() {
310 return Ok(print_json(
311 &json!({ "electionId": election_id, "ticketHeld": true, "taken": taken }),
312 )?);
313 }
314 if taken {
315 print_ticket_taken(election_id);
316 } else {
317 println!("{DIM}your VTA already holds a ticket for {election_id}{RESET}");
318 }
319 Ok(())
320}
321
322pub async fn cmd_vote(
325 client: &VtaClient,
326 t: VoteTarget<'_>,
327 election_id: &str,
328 answers: &[String],
329 ballot_file: Option<&PathBuf>,
330 now: bool,
331 no_delay: bool,
332) -> CliResult {
333 let shown = read(client, &t, "show", json!({ "electionId": election_id })).await?;
335 if !is_json_output() {
336 print_disclosure(&shown);
337 }
338 let manifest = shown["manifest"].clone();
339 let parameters = shown["parameters"].clone();
340 let digest = shown["manifestDigest"]
341 .as_str()
342 .unwrap_or_default()
343 .to_string();
344 let ballot = match ballot_file {
345 Some(p) => serde_json::from_str(&std::fs::read_to_string(p)?)?,
346 None => ballot_from_answers(&manifest, &digest, answers)?,
347 };
348
349 if take_ticket(client, &t, &shown).await? {
351 if !now {
352 if is_json_output() {
353 return Ok(print_json(
354 &json!({ "electionId": election_id, "ticketHeld": true, "voted": false }),
355 )?);
356 }
357 print_ticket_taken(election_id);
358 return Ok(());
359 }
360 if !is_json_output() {
361 println!(
362 "{GREEN}ticket taken{RESET} — {YELLOW}casting in the same run: the community and \
363 the ballot box could pair the two by time{RESET}"
364 );
365 }
366 }
367 let base = json!({ "contextId": t.context, "community": t.vtc_did });
368
369 let ballot_box = manifest["ballotBox"]
372 .as_str()
373 .unwrap_or_default()
374 .to_string();
375 let url = ballot_box_url(&ballot_box).await?;
376 let mut vote = base.clone();
377 vote["parameters"] = parameters;
378 vote["manifest"] = manifest.clone();
379 vote["ballot"] = ballot;
380 let cast = if manifest["secrecy"] == "open" {
381 client.pcs_vote_json(vote).await?
382 } else {
383 vote["ceremony"] = shown
386 .get("ceremony")
387 .cloned()
388 .ok_or("the trustees have not formed the election key yet; vote once voting opens")?;
389 client.pcs_vote_encrypted_json(vote).await?
390 };
391 let cast_doc = &cast["castDocument"];
392
393 if !no_delay {
395 let ms = rand::rng().random_range(0..MAX_CAST_DELAY.as_millis() as u64);
396 if !is_json_output() {
397 println!(
398 "{DIM}waiting {:.1}s before casting{RESET}",
399 ms as f64 / 1000.0
400 );
401 }
402 tokio::time::sleep(Duration::from_millis(ms)).await;
403 }
404 let http = vta_sdk::http::foreign_fetch_client();
405 let resp = http
406 .post(format!("{}/trust-tasks", url.trim_end_matches('/')))
407 .header("content-type", "application/json")
408 .body(serde_json::to_string(cast_doc)?)
409 .send()
410 .await
411 .map_err(|e| format!("the ballot box did not answer ({e}); run the same command again to resend the same cast"))?;
412 let status = resp.status();
413 let receipt: Value = resp.json().await?;
414 if !status.is_success() {
415 return Err(format!(
416 "the ballot box refused the cast: {}",
417 receipt["payload"]["code"].as_str().unwrap_or("unknown")
418 )
419 .into());
420 }
421
422 let mut keep = base;
424 keep["electionId"] = json!(election_id);
425 keep["receipt"] = receipt.clone();
426 let kept = client.pcs_voter_receipt_json(keep).await?;
427 if is_json_output() {
428 return Ok(print_json(&json!({ "receipt": receipt, "kept": kept }))?);
429 }
430 println!(
431 "{GREEN}voted{RESET} in {election_id}: {} (board entry {})",
432 receipt["payload"]["outcome"].as_str().unwrap_or("counted"),
433 receipt["payload"]["leafIndex"]
434 );
435 Ok(())
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 fn manifest() -> Value {
443 json!({
444 "electionId": "agm",
445 "questions": [
446 { "key": "chair", "type": "choice", "options": [{ "key": "a" }, { "key": "b" }], "optional": false },
447 { "key": "budget", "type": "choice", "options": [{ "key": "yes" }, { "key": "no" }], "optional": true }
448 ]
449 })
450 }
451
452 #[test]
453 fn answers_build_a_ballot_and_optional_questions_are_abstained() {
454 let b = ballot_from_answers(&manifest(), "zDigest", &["chair=a".into()]).unwrap();
455 assert_eq!(
456 b["answers"][0],
457 json!({ "question": "chair", "choice": ["a"] })
458 );
459 assert_eq!(
460 b["answers"][1],
461 json!({ "question": "budget", "abstain": true })
462 );
463 assert_eq!(b["manifestDigest"], "zDigest");
464 }
465
466 #[test]
469 fn the_manifest_is_registered_byte_for_byte() {
470 let shown = json!({
471 "manifest": { "electionId": "agm", "votingOpensAt": "2027-01-01T00:00:00.000Z" },
472 "parameters": { "suite": "ps-ddh-bls12381" },
473 });
474 let t = VoteTarget {
475 vtc_url: "https://v",
476 vtc_did: "did:web:v",
477 member: "did:web:m",
478 context: "c",
479 };
480 let reg = register_payload(&t, &shown);
481 assert_eq!(
482 serde_json::to_string(®["manifest"]).unwrap(),
483 r#"{"electionId":"agm","votingOpensAt":"2027-01-01T00:00:00.000Z"}"#
484 );
485 assert_eq!(reg["parameters"], shown["parameters"]);
486 }
487
488 #[test]
489 fn a_required_question_unanswered_or_an_unknown_one_is_refused() {
490 assert!(ballot_from_answers(&manifest(), "z", &[]).is_err());
491 assert!(
492 ballot_from_answers(&manifest(), "z", &["chair=a".into(), "nope=x".into()]).is_err()
493 );
494 assert!(ballot_from_answers(&manifest(), "z", &["chair".into()]).is_err());
495 }
496}