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    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
174/// `pnm elections show <id>`.
175pub 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
184/// The ballot from `--answer question=option` pairs (choice questions), every other question
185/// abstained where it is optional.
186fn 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
229/// The ballot box's HTTPS Trust Task base, from its DID document (`TrustTaskHTTPS`) — the door
230/// a cast from a one-time key is always accepted on (VTI-VOT-004).
231pub(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
249/// Take the member's ticket for the election `shown` describes, unless the VTA already holds
250/// one. True when this call took it.
251async 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(&registered["ticketDocument"])
268        .await
269        .map_err(|e| format!("{}: {e}", t.vtc_did))?;
270    // The payload of the community's answer, without its ext (voter/accept item 1).
271    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
283/// `vta/pcs/voter/register` for the election `shown` describes: the manifest and parameters
284/// exactly as the community published them, so the VTA's digest is the community's.
285fn 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
292/// What a member is told after taking a ticket in a run that does not cast.
293fn 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
301/// `pnm elections ticket <id>`: take the ticket, and nothing else — ahead of voting, so the cast
302/// that follows is not timed to it.
303pub 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
322/// `pnm elections vote <id>`: read, vote with the ticket the VTA holds, keep the receipt. With no
323/// ticket held, take one and stop — or, with `now`, cast in the same run.
324pub 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    // 1. The election as the community shows the member.
334    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    // 2. The ticket, unless the VTA already holds it — and then no cast in this run.
350    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    // 3. The cast, built and signed in the VTA under a key made for it alone — sealed or
370    //    encrypted under the trustees' election key where the manifest says so.
371    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        // The trustees' signed steps, from the community (show item 6): the VTA forms the key
384        // from them itself and encrypts under no other (CONVENTIONS §9.6).
385        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    // 4. To the ballot box, from this connection, after a pause.
394    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    // 5. The receipt, kept and checked by the VTA.
423    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    /// The manifest reaches the VTA as the community published it: a typed round trip would
467    /// rewrite `.000Z` timestamps and the VTA would compute another manifest's digest.
468    #[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(&reg["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}