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/// The longest pause before a cast is sent, so its arrival is not timed to the ticket's.
43const MAX_CAST_DELAY: Duration = Duration::from_secs(20);
44
45// The VTA's voter tasks go through the SDK's `_json` forms (`pcs_voter_read_json`, …), never
46// the typed ones: those re-serialise timestamps, so a manifest would reach the VTA with another
47// digest and a signed read or cast would no longer be the bytes its proof covers.
48
49fn community(t: &VoteTarget<'_>) -> VtcClient {
50    VtcClient::anonymous(t.vtc_url, t.vtc_did)
51}
52
53/// Have the VTA sign one election read as the member, and send it to the community.
54async 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
86/// `pnm elections list`.
87pub 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
114/// What a member is told about an election before taking a ticket (VTI-VOT-090).
115fn 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
180/// When the manifest `m` releases written answers, if it asks for any or releases them below
181/// the SHOULD of 10 (VTI-VOT-065).
182fn 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
197/// What a member must be told before taking a ticket when `m` releases written answers after
198/// fewer than 10 ballots: that it does, and that a written answer may identify its author
199/// (VTI-VOT-065, VTI-VOT-090). `None` at 10 or more.
200fn 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
211/// `pnm elections show <id>`.
212pub 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
221/// The ballot from `--answer question=option` pairs (choice questions), every other question
222/// abstained where it is optional.
223fn 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
266/// The ballot box's HTTPS Trust Task base, from its DID document (`TrustTaskHTTPS`) — the door
267/// a cast from a one-time key is always accepted on (VTI-VOT-004).
268pub(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
286/// Take the member's ticket for the election `shown` describes, unless the VTA already holds
287/// one. True when this call took it.
288async 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(&registered["ticketDocument"])
305        .await
306        .map_err(|e| format!("{}: {e}", t.vtc_did))?;
307    // The payload of the community's answer, without its ext (voter/accept item 1).
308    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
320/// `vta/pcs/voter/register` for the election `shown` describes: the manifest and parameters
321/// exactly as the community published them, so the VTA's digest is the community's.
322fn 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
329/// What a member is told after taking a ticket in a run that does not cast.
330fn 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
338/// `pnm elections ticket <id>`: take the ticket, and nothing else — ahead of voting, so the cast
339/// that follows is not timed to it.
340pub 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
359/// `pnm elections vote <id>`: read, vote with the ticket the VTA holds, keep the receipt. With no
360/// ticket held, take one and stop — or, with `now`, cast in the same run.
361pub 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    // 1. The election as the community shows the member.
371    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    // 2. The ticket, unless the VTA already holds it — and then no cast in this run.
387    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    // 3. The cast, built and signed in the VTA under a key made for it alone — sealed or
407    //    encrypted under the trustees' election key where the manifest says so.
408    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        // The trustees' signed steps, from the community (show item 6): the VTA forms the key
421        // from them itself and encrypts under no other (CONVENTIONS §9.6).
422        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    // 4. To the ballot box, from this connection, after a pause.
431    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    // 5. The receipt, kept and checked by the VTA.
460    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    /// The manifest reaches the VTA as the community published it: a typed round trip would
504    /// rewrite `.000Z` timestamps and the VTA would compute another manifest's digest.
505    #[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(&reg["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    /// VTI-VOT-090: below a threshold of 10 the member is told, before taking a ticket, that
535    /// written answers come out after so few ballots and may identify their authors.
536    #[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}