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 {
46 VtcClient::anonymous(t.vtc_url, t.vtc_did)
47}
48
49async fn read(
51 client: &VtaClient,
52 t: &VoteTarget<'_>,
53 which: &str,
54 payload: Value,
55) -> CliResult<Value> {
56 let req = serde_json::from_value(json!({
57 "contextId": t.context, "community": t.vtc_did, "member": t.member,
58 "read": which, "payload": payload,
59 }))?;
60 let signed = client
61 .pcs_voter_read(&req)
62 .await
63 .map_err(|e| hint_member(e.to_string(), t.member))?;
64 let document = serde_json::to_value(&signed.document)?;
65 Ok(community(t)
66 .send_signed_document(&document)
67 .await
68 .map_err(|e| format!("{}: {e}", t.vtc_did))?)
69}
70
71fn hint_member(text: String, member: &str) -> Box<dyn std::error::Error> {
72 if text.contains("memberNotHeld") {
73 return format!(
74 "{text}\n\nYour VTA holds no authentication key of {member} that you may act as. Pass \
75 the persona DID the community knows you by as --member, and the context it lives \
76 in as --context."
77 )
78 .into();
79 }
80 text.into()
81}
82
83pub async fn cmd_list(client: &VtaClient, t: VoteTarget<'_>) -> CliResult {
85 let answer = read(client, &t, "list", json!({})).await?;
86 if is_json_output() {
87 return Ok(print_json(&answer)?);
88 }
89 let items = answer["elections"].as_array().cloned().unwrap_or_default();
90 if items.is_empty() {
91 println!("{DIM}no elections you may vote in{RESET}");
92 }
93 for e in items {
94 println!(
95 "{BOLD}{}{RESET} {} {} → {}{} {DIM}{}{RESET}",
96 e["electionId"].as_str().unwrap_or_default(),
97 e["state"].as_str().unwrap_or_default(),
98 e["votingOpensAt"].as_str().unwrap_or_default(),
99 e["votingClosesAt"].as_str().unwrap_or_default(),
100 if e["ticketHeld"] == true {
101 " (ticket held)"
102 } else {
103 ""
104 },
105 e["title"].as_str().unwrap_or_default(),
106 );
107 }
108 Ok(())
109}
110
111fn print_disclosure(shown: &Value) {
113 let m = &shown["manifest"];
114 let d = &shown["disclosure"];
115 println!(
116 "{BOLD}{}{RESET} {}",
117 m["title"]
118 .as_str()
119 .unwrap_or(m["electionId"].as_str().unwrap_or_default()),
120 shown["state"].as_str().unwrap_or_default()
121 );
122 println!(
123 " voting {} → {}",
124 m["votingOpensAt"].as_str().unwrap_or_default(),
125 m["votingClosesAt"].as_str().unwrap_or_default()
126 );
127 println!(
128 " ballot box {} (run by {})",
129 d["ballotBox"].as_str().unwrap_or_default(),
130 d["ballotBoxOperator"].as_str().unwrap_or_default()
131 );
132 if d["sameOperator"] == true {
133 println!(
134 " {YELLOW}the same operator runs this community and the ballot box: they could link \
135 your ticket to your ballot{RESET}"
136 );
137 }
138 println!(
139 " board {}; a later vote {}",
140 d["boardVisibility"].as_str().unwrap_or_default(),
141 if d["revote"] == "replace" {
142 "replaces the earlier one"
143 } else {
144 "is refused"
145 }
146 );
147 println!(
148 " {DIM}you can prove how you voted, so this election cannot protect you against being made to vote a certain way{RESET}"
149 );
150 for q in m["questions"].as_array().into_iter().flatten() {
151 let opts: Vec<&str> = q["options"]
152 .as_array()
153 .into_iter()
154 .flatten()
155 .filter_map(|o| o["key"].as_str())
156 .collect();
157 println!(
158 " {BOLD}{}{RESET} {} [{}]{}",
159 q["key"].as_str().unwrap_or_default(),
160 q["text"].as_str().unwrap_or_default(),
161 opts.join(", "),
162 if q["optional"] == true {
163 " (optional)"
164 } else {
165 ""
166 }
167 );
168 }
169}
170
171pub async fn cmd_show(client: &VtaClient, t: VoteTarget<'_>, election_id: &str) -> CliResult {
173 let shown = read(client, &t, "show", json!({ "electionId": election_id })).await?;
174 if is_json_output() {
175 return Ok(print_json(&shown)?);
176 }
177 print_disclosure(&shown);
178 Ok(())
179}
180
181fn ballot_from_answers(manifest: &Value, digest: &str, answers: &[String]) -> CliResult<Value> {
184 let mut picked = std::collections::BTreeMap::new();
185 for a in answers {
186 let (q, o) = a
187 .split_once('=')
188 .ok_or_else(|| format!("--answer {a:?}: write it as question=option"))?;
189 picked
190 .entry(q.to_string())
191 .or_insert_with(Vec::new)
192 .push(o.to_string());
193 }
194 let mut out = Vec::new();
195 for q in manifest["questions"].as_array().into_iter().flatten() {
196 let key = q["key"].as_str().unwrap_or_default();
197 match picked.remove(key) {
198 Some(choice) => {
199 if q["type"] != "choice" {
200 return Err(format!(
201 "question {key} is a {} question: give the whole ballot with --ballot",
202 q["type"]
203 )
204 .into());
205 }
206 out.push(json!({ "question": key, "choice": choice }));
207 }
208 None if q["optional"] == true => out.push(json!({ "question": key, "abstain": true })),
209 None => {
210 return Err(
211 format!("question {key} needs an answer: --answer {key}=<option>").into(),
212 );
213 }
214 }
215 }
216 if let Some(q) = picked.keys().next() {
217 return Err(format!("the election asks no question {q}").into());
218 }
219 Ok(json!({
220 "electionId": manifest["electionId"],
221 "manifestDigest": digest,
222 "answers": out,
223 }))
224}
225
226pub(crate) async fn ballot_box_url(did: &str) -> CliResult<String> {
229 let resolver = vta_sdk::resolver::shared_did_resolver_from_env()
230 .await
231 .map_err(|e| format!("build a DID resolver: {e}"))?;
232 let resolved = resolver
233 .resolve(did)
234 .await
235 .map_err(|e| format!("resolve the ballot box {did}: {e}"))?;
236 let doc = serde_json::to_value(&resolved.doc)?;
237 vta_sdk::protocol::matching::ServiceCapabilities::from_did_document(&doc)
238 .rest
239 .filter(|u| vta_sdk::protocol::matching::is_https_or_loopback(u))
240 .ok_or_else(|| {
241 format!("the ballot box {did} advertises no https TrustTaskHTTPS endpoint to cast on")
242 .into()
243 })
244}
245
246async fn take_ticket(client: &VtaClient, t: &VoteTarget<'_>, shown: &Value) -> CliResult<bool> {
249 let election_id = &shown["manifest"]["electionId"];
250 let mut reg = json!({ "contextId": t.context, "community": t.vtc_did, "member": t.member });
251 reg["parameters"] = shown["parameters"].clone();
252 reg["manifest"] = shown["manifest"].clone();
253 let registered = match client
254 .pcs_voter_register(&serde_json::from_value(reg)?)
255 .await
256 {
257 Ok(registered) => serde_json::to_value(®istered)?,
258 Err(e) if e.to_string().contains("ticketHeld") => return Ok(false),
259 Err(e) => return Err(hint_member(e.to_string(), t.member)),
260 };
261 if registered["manifestDigest"] != shown["manifestDigest"] {
262 return Err(
263 "your VTA computed another manifest digest than the community shows; not taking a \
264 ticket"
265 .into(),
266 );
267 }
268 let issued = community(t)
269 .send_signed_document(®istered["ticketDocument"])
270 .await
271 .map_err(|e| format!("{}: {e}", t.vtc_did))?;
272 let accept = json!({
273 "contextId": t.context, "community": t.vtc_did, "electionId": election_id,
274 "parameters": shown["parameters"], "response": issued,
275 });
276 client
277 .pcs_voter_accept(&serde_json::from_value(accept)?)
278 .await?;
279 Ok(true)
280}
281
282fn print_ticket_taken(election_id: &str) {
284 println!("{GREEN}ticket taken{RESET} — the community signed it blind");
285 println!(
286 "{DIM}vote later with `pnm elections vote {election_id} …`: a cast sent now could be paired \
287 with your ticket by when each arrived{RESET}"
288 );
289}
290
291pub async fn cmd_ticket(client: &VtaClient, t: VoteTarget<'_>, election_id: &str) -> CliResult {
294 let shown = read(client, &t, "show", json!({ "electionId": election_id })).await?;
295 if !is_json_output() {
296 print_disclosure(&shown);
297 }
298 let taken = take_ticket(client, &t, &shown).await?;
299 if is_json_output() {
300 return Ok(print_json(
301 &json!({ "electionId": election_id, "ticketHeld": true, "taken": taken }),
302 )?);
303 }
304 if taken {
305 print_ticket_taken(election_id);
306 } else {
307 println!("{DIM}your VTA already holds a ticket for {election_id}{RESET}");
308 }
309 Ok(())
310}
311
312pub async fn cmd_vote(
315 client: &VtaClient,
316 t: VoteTarget<'_>,
317 election_id: &str,
318 answers: &[String],
319 ballot_file: Option<&PathBuf>,
320 now: bool,
321 no_delay: bool,
322) -> CliResult {
323 let shown = read(client, &t, "show", json!({ "electionId": election_id })).await?;
325 if !is_json_output() {
326 print_disclosure(&shown);
327 }
328 let manifest = shown["manifest"].clone();
329 let parameters = shown["parameters"].clone();
330 let digest = shown["manifestDigest"]
331 .as_str()
332 .unwrap_or_default()
333 .to_string();
334 let ballot = match ballot_file {
335 Some(p) => serde_json::from_str(&std::fs::read_to_string(p)?)?,
336 None => ballot_from_answers(&manifest, &digest, answers)?,
337 };
338
339 if take_ticket(client, &t, &shown).await? {
341 if !now {
342 if is_json_output() {
343 return Ok(print_json(
344 &json!({ "electionId": election_id, "ticketHeld": true, "voted": false }),
345 )?);
346 }
347 print_ticket_taken(election_id);
348 return Ok(());
349 }
350 if !is_json_output() {
351 println!(
352 "{GREEN}ticket taken{RESET} — {YELLOW}casting in the same run: the community and \
353 the ballot box could pair the two by time{RESET}"
354 );
355 }
356 }
357 let base = json!({ "contextId": t.context, "community": t.vtc_did });
358
359 let ballot_box = manifest["ballotBox"]
362 .as_str()
363 .unwrap_or_default()
364 .to_string();
365 let url = ballot_box_url(&ballot_box).await?;
366 let mut vote = base.clone();
367 vote["parameters"] = parameters;
368 vote["manifest"] = manifest.clone();
369 vote["ballot"] = ballot;
370 let cast = if manifest["secrecy"] == "open" {
371 serde_json::to_value(client.pcs_vote(&serde_json::from_value(vote)?).await?)?
372 } else {
373 vote["ceremony"] = shown
376 .get("ceremony")
377 .cloned()
378 .ok_or("the trustees have not formed the election key yet; vote once voting opens")?;
379 serde_json::to_value(
380 client
381 .pcs_vote_encrypted(&serde_json::from_value(vote)?)
382 .await?,
383 )?
384 };
385 let cast_doc = &cast["castDocument"];
386
387 if !no_delay {
389 let ms = rand::rng().random_range(0..MAX_CAST_DELAY.as_millis() as u64);
390 if !is_json_output() {
391 println!(
392 "{DIM}waiting {:.1}s before casting{RESET}",
393 ms as f64 / 1000.0
394 );
395 }
396 tokio::time::sleep(Duration::from_millis(ms)).await;
397 }
398 let http = vta_sdk::http::foreign_fetch_client();
399 let resp = http
400 .post(format!("{}/trust-tasks", url.trim_end_matches('/')))
401 .header("content-type", "application/json")
402 .body(serde_json::to_string(cast_doc)?)
403 .send()
404 .await
405 .map_err(|e| format!("the ballot box did not answer ({e}); run the same command again to resend the same cast"))?;
406 let status = resp.status();
407 let receipt: Value = resp.json().await?;
408 if !status.is_success() {
409 return Err(format!(
410 "the ballot box refused the cast: {}",
411 receipt["payload"]["code"].as_str().unwrap_or("unknown")
412 )
413 .into());
414 }
415
416 let mut keep = base;
418 keep["electionId"] = json!(election_id);
419 keep["receipt"] = receipt.clone();
420 let kept = serde_json::to_value(
421 client
422 .pcs_voter_receipt(&serde_json::from_value(keep)?)
423 .await?,
424 )?;
425 if is_json_output() {
426 return Ok(print_json(&json!({ "receipt": receipt, "kept": kept }))?);
427 }
428 println!(
429 "{GREEN}voted{RESET} in {election_id}: {} (board entry {})",
430 receipt["payload"]["outcome"].as_str().unwrap_or("counted"),
431 receipt["payload"]["leafIndex"]
432 );
433 Ok(())
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439
440 fn manifest() -> Value {
441 json!({
442 "electionId": "agm",
443 "questions": [
444 { "key": "chair", "type": "choice", "options": [{ "key": "a" }, { "key": "b" }], "optional": false },
445 { "key": "budget", "type": "choice", "options": [{ "key": "yes" }, { "key": "no" }], "optional": true }
446 ]
447 })
448 }
449
450 #[test]
451 fn answers_build_a_ballot_and_optional_questions_are_abstained() {
452 let b = ballot_from_answers(&manifest(), "zDigest", &["chair=a".into()]).unwrap();
453 assert_eq!(
454 b["answers"][0],
455 json!({ "question": "chair", "choice": ["a"] })
456 );
457 assert_eq!(
458 b["answers"][1],
459 json!({ "question": "budget", "abstain": true })
460 );
461 assert_eq!(b["manifestDigest"], "zDigest");
462 }
463
464 #[test]
465 fn a_required_question_unanswered_or_an_unknown_one_is_refused() {
466 assert!(ballot_from_answers(&manifest(), "z", &[]).is_err());
467 assert!(
468 ballot_from_answers(&manifest(), "z", &["chair=a".into(), "nope=x".into()]).is_err()
469 );
470 assert!(ballot_from_answers(&manifest(), "z", &["chair".into()]).is_err());
471 }
472}