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
45fn community(t: &VoteTarget<'_>) -> VtcClient {
46    VtcClient::anonymous(t.vtc_url, t.vtc_did)
47}
48
49/// Have the VTA sign one election read as the member, and send it to the community.
50async 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
83/// `pnm elections list`.
84pub 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
111/// What a member is told about an election before taking a ticket (VTI-VOT-090).
112fn 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
171/// `pnm elections show <id>`.
172pub 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
181/// The ballot from `--answer question=option` pairs (choice questions), every other question
182/// abstained where it is optional.
183fn 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
226/// The ballot box's HTTPS Trust Task base, from its DID document (`TrustTaskHTTPS`) — the door
227/// a cast from a one-time key is always accepted on (VTI-VOT-004).
228pub(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
246/// Take the member's ticket for the election `shown` describes, unless the VTA already holds
247/// one. True when this call took it.
248async 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(&registered)?,
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(&registered["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
282/// What a member is told after taking a ticket in a run that does not cast.
283fn 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
291/// `pnm elections ticket <id>`: take the ticket, and nothing else — ahead of voting, so the cast
292/// that follows is not timed to it.
293pub 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
312/// `pnm elections vote <id>`: read, vote with the ticket the VTA holds, keep the receipt. With no
313/// ticket held, take one and stop — or, with `now`, cast in the same run.
314pub 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    // 1. The election as the community shows the member.
324    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    // 2. The ticket, unless the VTA already holds it — and then no cast in this run.
340    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    // 3. The cast, built and signed in the VTA under a key made for it alone — sealed or
360    //    encrypted under the trustees' election key where the manifest says so.
361    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        // The trustees' signed steps, from the community (show item 6): the VTA forms the key
374        // from them itself and encrypts under no other (CONVENTIONS §9.6).
375        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    // 4. To the ballot box, from this connection, after a pause.
388    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    // 5. The receipt, kept and checked by the VTA.
417    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}