Skip to main content

vta_cli_common/commands/
elections.rs

1//! A member's side of a community election (VTI-VOT-020 – 053): read the elections, take a
2//! ticket, vote, keep the receipt — with every key in the member's VTA.
3//!
4//! The VTA signs as the member for the two parties that need the member: the community's reads
5//! (`vta/pcs/voter/read`) and the ticket request (`vta/pcs/voter/register`'s `ticketDocument`).
6//! This client only carries those documents, over the community's HTTPS Trust Task door — a
7//! persona-signed document never rides this client's own session. The ticket is unblinded into
8//! the VTA (`voter/accept`); the cast is built and signed there under a key made for that cast
9//! alone (`vta/pcs/vote`), and this client posts it to the ballot box itself, from its own
10//! connection, after a pause, so the ballot box never sees the member and the community never
11//! sees the ballot (VTI-VOT-001 – 004). The receipt goes back to the VTA (`voter/receipt`).
12//!
13//! Taking the ticket and casting are separate runs (CONVENTIONS §6 item 3): the community knows
14//! when it issued a member's ticket and the ballot box when a cast arrived, so a cast sent
15//! straight after its ticket can be paired by the two clocks. `vote` with no ticket held takes
16//! one and stops, unless told `--now`.
17
18use 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
30/// The community, and who the member is there.
31pub struct VoteTarget<'a> {
32    /// Base URL of the community's Trust Task endpoint (e.g. `https://vtc.example.com/v1`).
33    pub vtc_url: &'a str,
34    /// The community's DID.
35    pub vtc_did: &'a str,
36    /// The member's DID at the community: a persona this VTA holds.
37    pub member: &'a str,
38    /// The VTA context the member's voter keys are derived in.
39    pub context: &'a str,
40}
41
42/// A membership credential the member chose to present (VTI-VOT-016, VTI-VOT-090): its handle
43/// in their VTA's vault, and the DID they were told the election's credential roll names.
44#[derive(Debug, Clone, Copy, Default)]
45pub struct Presenting<'a> {
46    /// `membershipCredential`: the vault handle (`vault/credentials/query`).
47    pub credential: Option<&'a str>,
48    /// `memberOf`: the party whose membership the read proves; the VTA presents the credential
49    /// only where that party issued it.
50    pub member_of: Option<&'a str>,
51}
52
53/// The longest pause before a cast is sent, so its arrival is not timed to the ticket's.
54const MAX_CAST_DELAY: Duration = Duration::from_secs(20);
55
56// The VTA's voter tasks go through the SDK's `_json` forms (`pcs_voter_read_json`, …), never
57// the typed ones: those re-serialise timestamps, so a manifest would reach the VTA with another
58// digest and a signed read or cast would no longer be the bytes its proof covers.
59
60fn community(t: &VoteTarget<'_>) -> VtcClient {
61    VtcClient::anonymous(t.vtc_url, t.vtc_did)
62}
63
64/// Have the VTA sign one election read as the member, and send it to the community.
65async 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
74/// [`read`], presenting the member's membership credential in a `show` (`vta/pcs/voter/read/0.6`,
75/// VTI-VOT-015): to read an election put to the members of a party that is itself a member of
76/// the community, which the community shows nobody else.
77async 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
104/// A community's refusal, with what to do about an `indeterminate` answer: a check the
105/// community makes on a presentation could not be completed (a status list or a DID document
106/// it could not fetch), nothing was recorded, and the same request may be made again later
107/// (VTI-VOT-018).
108fn 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
147/// `pnm elections list`.
148pub 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
175/// What a member is told about an election before taking a ticket (VTI-VOT-090).
176fn 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
248/// When the manifest `m` releases written answers, if it asks for any or releases them below
249/// the SHOULD of 10 (VTI-VOT-065).
250fn 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
265/// What a member must be told before taking a ticket when `m` releases written answers after
266/// fewer than 10 ballots: that it does, and that a written answer may identify its author
267/// (VTI-VOT-065, VTI-VOT-090). `None` at 10 or more.
268fn 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
279/// Whose membership credential admits a voter, under a credential roll: the disclosure's
280/// `rollCredential` (VTI-VOT-090). `None` under every other rule.
281fn 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
299/// What claiming a ticket under a credential roll shows the community (VTI-VOT-025,
300/// VTI-VOT-090). `None` under every other rule.
301fn 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
310/// `pnm elections show <id>`.
311pub 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
315/// `pnm elections show <id> --membership-credential <handle> [--member-of <did>]`: the read
316/// presents the member's membership credential, which the community needs to show an election
317/// put to the members of a party that is itself its member (VTI-VOT-015). Reading it this way
318/// shows the community that membership before the member has seen the election.
319pub 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
349/// The ballot from `--answer question=option` pairs (choice questions), every other question
350/// abstained where it is optional.
351fn 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
394/// The ballot box's HTTPS Trust Task base, from its DID document (`TrustTaskHTTPS`) — the door
395/// a cast from a one-time key is always accepted on (VTI-VOT-004).
396pub(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
414/// Take the member's ticket for the election `shown` describes, unless the VTA already holds
415/// one. True when this call took it.
416async 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(&registered["ticketDocument"])
441        .await
442        .map_err(|e| community_refusal(t.vtc_did, &e.to_string()))?;
443    // The payload of the community's answer, without its ext (voter/accept item 1).
444    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
456/// Read `election_id` without presenting anything and take its ticket, printing nothing: for a
457/// caller taking several in one run (`pnm boards take-tickets`). True when this call took it,
458/// false when the VTA held it already. An election under a credential roll is refused by the
459/// community here (`membershipCredentialRequired`): take that one alone with
460/// `pnm elections ticket <id> --membership-credential <handle>`.
461///
462/// # Errors
463/// The community's or the VTA's refusal, with the hint `pnm elections ticket` would give.
464pub 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
473/// `vta/pcs/voter/register` for the election `shown` describes: the manifest and parameters
474/// exactly as the community published them, so the VTA's digest is the community's.
475fn 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
482/// What a member is told after taking a ticket in a run that does not cast.
483fn 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
491/// `pnm elections ticket <id>`: take the ticket, and nothing else — ahead of voting, so the cast
492/// that follows is not timed to it.
493pub 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
497/// `pnm elections ticket <id> --membership-credential <handle> [--member-of <did>]`: under a
498/// credential roll, the VTA presents that credential in the ticket request (VTI-VOT-025). With
499/// `member_of`, the read of the election presents it too, as an election put to a member
500/// party's members is shown to nobody else (VTI-VOT-015).
501pub async fn cmd_ticket_presenting(
502    client: &VtaClient,
503    t: VoteTarget<'_>,
504    election_id: &str,
505    presenting: Presenting<'_>,
506) -> CliResult {
507    // The read presents only where the member said whose roll it is: an election they can
508    // already see is read without showing the community anything (VTI-VOT-016).
509    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
539/// `pnm elections vote <id>`: read, vote with the ticket the VTA holds, keep the receipt. With no
540/// ticket held, take one and stop — or, with `now`, cast in the same run.
541pub 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    // 1. The election as the community shows the member.
551    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    // 2. The ticket, unless the VTA already holds it — and then no cast in this run.
567    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    // 3. The cast, built and signed in the VTA under a key made for it alone — sealed or
587    //    encrypted under the trustees' election key where the manifest says so.
588    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        // The trustees' signed steps, from the community (show item 6): the VTA forms the key
601        // from them itself and encrypts under no other (CONVENTIONS §9.6).
602        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    // 4. To the ballot box, from this connection, after a pause.
611    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    // 5. The receipt, kept and checked by the VTA.
640    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    /// The manifest reaches the VTA as the community published it: a typed round trip would
684    /// rewrite `.000Z` timestamps and the VTA would compute another manifest's digest.
685    #[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(&reg["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    /// VTI-VOT-090: below a threshold of 10 the member is told, before taking a ticket, that
715    /// written answers come out after so few ballots and may identify their authors.
716    #[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    /// VTI-VOT-090: under a credential roll the member is told whose membership credential
741    /// admits them, what that party is to the community, and that claiming shows the community
742    /// their membership of it; under any other rule nothing is said.
743    #[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    /// VTI-VOT-018: an `indeterminate` answer is explained as retryable, never as a refusal.
776    #[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}