use std::path::PathBuf;
use std::time::Duration;
use rand::RngExt as _;
use serde_json::{Value, json};
use vta_sdk::client::VtaClient;
use vtc_client::VtcClient;
use crate::render::{BOLD, DIM, GREEN, RESET, YELLOW, is_json_output, print_json};
type CliResult<T = ()> = Result<T, Box<dyn std::error::Error>>;
pub struct VoteTarget<'a> {
pub vtc_url: &'a str,
pub vtc_did: &'a str,
pub member: &'a str,
pub context: &'a str,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Presenting<'a> {
pub credential: Option<&'a str>,
pub member_of: Option<&'a str>,
}
const MAX_CAST_DELAY: Duration = Duration::from_secs(20);
fn community(t: &VoteTarget<'_>) -> VtcClient {
VtcClient::anonymous(t.vtc_url, t.vtc_did)
}
async fn read(
client: &VtaClient,
t: &VoteTarget<'_>,
which: &str,
payload: Value,
) -> CliResult<Value> {
read_presenting(client, t, which, payload, Presenting::default()).await
}
async fn read_presenting(
client: &VtaClient,
t: &VoteTarget<'_>,
which: &str,
payload: Value,
presenting: Presenting<'_>,
) -> CliResult<Value> {
let mut req = json!({
"contextId": t.context, "community": t.vtc_did, "member": t.member,
"read": which, "payload": payload,
});
if let Some(c) = presenting.credential {
req["membershipCredential"] = json!(c);
if let Some(m) = presenting.member_of {
req["memberOf"] = json!(m);
}
}
let signed = client
.pcs_voter_read_json(req)
.await
.map_err(|e| hint_member(e.to_string(), t.member))?;
community(t)
.send_signed_document(&signed["document"])
.await
.map_err(|e| community_refusal(t.vtc_did, &e.to_string()))
}
fn community_refusal(vtc_did: &str, text: &str) -> Box<dyn std::error::Error> {
if text.contains("\"indeterminate\"") {
return format!(
"{vtc_did}: {text}\n\nThe community could not complete a check of your membership \
credential just now (its issuer's status list or DID document could not be read). \
Nothing was recorded: run the same command again in a little while."
)
.into();
}
format!("{vtc_did}: {text}").into()
}
fn hint_member(text: String, member: &str) -> Box<dyn std::error::Error> {
if text.contains("membershipCredentialRequired") {
return format!(
"{text}\n\nThis election's roll is the holders of a membership credential: claim \
your ticket with `pnm elections ticket <id> --membership-credential <handle>`, \
naming the credential in your VTA's vault the roll names."
)
.into();
}
if text.contains("membershipCredentialMismatch") {
return format!(
"{text}\n\nThat credential was not issued by the --member-of DID. Your VTA presents \
nothing unrelated to the election: name the credential that party issued you."
)
.into();
}
if text.contains("memberNotHeld") {
return format!(
"{text}\n\nYour VTA holds no authentication key of {member} that you may act as. Pass \
the persona DID the community knows you by as --member, and the context it lives \
in as --context."
)
.into();
}
text.into()
}
pub async fn cmd_list(client: &VtaClient, t: VoteTarget<'_>) -> CliResult {
let answer = read(client, &t, "list", json!({})).await?;
if is_json_output() {
return Ok(print_json(&answer)?);
}
let items = answer["elections"].as_array().cloned().unwrap_or_default();
if items.is_empty() {
println!("{DIM}no elections you may vote in{RESET}");
}
for e in items {
println!(
"{BOLD}{}{RESET} {} {} → {}{} {DIM}{}{RESET}",
e["electionId"].as_str().unwrap_or_default(),
e["state"].as_str().unwrap_or_default(),
e["votingOpensAt"].as_str().unwrap_or_default(),
e["votingClosesAt"].as_str().unwrap_or_default(),
if e["ticketHeld"] == true {
" (ticket held)"
} else {
""
},
e["title"].as_str().unwrap_or_default(),
);
}
Ok(())
}
fn print_disclosure(shown: &Value) {
let m = &shown["manifest"];
let d = &shown["disclosure"];
println!(
"{BOLD}{}{RESET} {}",
m["title"]
.as_str()
.unwrap_or(m["electionId"].as_str().unwrap_or_default()),
shown["state"].as_str().unwrap_or_default()
);
println!(
" voting {} → {}",
m["votingOpensAt"].as_str().unwrap_or_default(),
m["votingClosesAt"].as_str().unwrap_or_default()
);
println!(
" ballot box {} (run by {})",
d["ballotBox"].as_str().unwrap_or_default(),
d["ballotBoxOperator"].as_str().unwrap_or_default()
);
if d["sameOperator"] == true {
println!(
" {YELLOW}the same operator runs this community and the ballot box: they could link \
your ticket to your ballot{RESET}"
);
}
println!(
" board {}; a later vote {}",
d["boardVisibility"].as_str().unwrap_or_default(),
if d["revote"] == "replace" {
"replaces the earlier one"
} else {
"is refused"
}
);
println!(
" {DIM}you can prove how you voted, so this election cannot protect you against being made to vote a certain way{RESET}"
);
if let Some(roll) = roll_credential(d) {
println!(" roll {roll}");
println!(
" {YELLOW}{}{RESET}",
roll_credential_warning(d).unwrap_or_default()
);
}
if let Some(release) = written_answers(m) {
println!(" written {release}");
}
if let Some(warning) = written_answers_warning(m) {
println!(" {YELLOW}{warning}{RESET}");
}
for q in m["questions"].as_array().into_iter().flatten() {
let opts: Vec<&str> = q["options"]
.as_array()
.into_iter()
.flatten()
.filter_map(|o| o["key"].as_str())
.collect();
println!(
" {BOLD}{}{RESET} {} [{}]{}",
q["key"].as_str().unwrap_or_default(),
q["text"].as_str().unwrap_or_default(),
opts.join(", "),
if q["optional"] == true {
" (optional)"
} else {
""
}
);
}
}
fn written_answers(m: &Value) -> Option<String> {
let threshold = m["textReleaseThreshold"].as_u64()?;
let asks = m["questions"]
.as_array()
.into_iter()
.flatten()
.any(|q| q["type"] == "text");
(asks || threshold < 10).then(|| {
format!(
"released once {threshold} ballot{} counted, never while voting is open",
if threshold == 1 { " is" } else { "s are" }
)
})
}
fn written_answers_warning(m: &Value) -> Option<String> {
let threshold = m["textReleaseThreshold"].as_u64()?;
(threshold < 10).then(|| {
format!(
"written answers are released after only {threshold} ballot{}, fewer than the 10 \
recommended: a written answer may identify who wrote it",
if threshold == 1 { "" } else { "s" }
)
})
}
fn roll_credential(d: &Value) -> Option<String> {
let r = d.get("rollCredential")?;
let did = r["memberOf"].as_str()?;
let what = match r["memberOfKind"].as_str() {
Some("thisCommunity") => "this community",
Some("communityGroup") => "a group of this community",
Some("memberParty") => "a party that is itself a member of this community",
_ => "a party",
};
let name = r["memberOfName"]
.as_str()
.map(|n| format!(" \"{n}\""))
.unwrap_or_default();
Some(format!(
"holders of a membership credential issued by {what}{name} ({did})"
))
}
fn roll_credential_warning(d: &Value) -> Option<String> {
d.get("rollCredential")?;
Some(
"claiming a ticket presents that credential: the community learns that you are a \
member of that party"
.to_string(),
)
}
pub async fn cmd_show(client: &VtaClient, t: VoteTarget<'_>, election_id: &str) -> CliResult {
cmd_show_presenting(client, t, election_id, Presenting::default()).await
}
pub async fn cmd_show_presenting(
client: &VtaClient,
t: VoteTarget<'_>,
election_id: &str,
presenting: Presenting<'_>,
) -> CliResult {
let shown = read_presenting(
client,
&t,
"show",
json!({ "electionId": election_id }),
presenting,
)
.await?;
if is_json_output() {
return Ok(print_json(&shown)?);
}
print_disclosure(&shown);
if let Some(told) = presenting.member_of
&& let Some(names) = shown["manifest"]["roll"]["memberOf"].as_str()
&& names != told
{
println!(
" {YELLOW}you were told this election is put to the members of {told}, but its roll \
names {names}{RESET}"
);
}
Ok(())
}
fn ballot_from_answers(manifest: &Value, digest: &str, answers: &[String]) -> CliResult<Value> {
let mut picked = std::collections::BTreeMap::new();
for a in answers {
let (q, o) = a
.split_once('=')
.ok_or_else(|| format!("--answer {a:?}: write it as question=option"))?;
picked
.entry(q.to_string())
.or_insert_with(Vec::new)
.push(o.to_string());
}
let mut out = Vec::new();
for q in manifest["questions"].as_array().into_iter().flatten() {
let key = q["key"].as_str().unwrap_or_default();
match picked.remove(key) {
Some(choice) => {
if q["type"] != "choice" {
return Err(format!(
"question {key} is a {} question: give the whole ballot with --ballot",
q["type"]
)
.into());
}
out.push(json!({ "question": key, "choice": choice }));
}
None if q["optional"] == true => out.push(json!({ "question": key, "abstain": true })),
None => {
return Err(
format!("question {key} needs an answer: --answer {key}=<option>").into(),
);
}
}
}
if let Some(q) = picked.keys().next() {
return Err(format!("the election asks no question {q}").into());
}
Ok(json!({
"electionId": manifest["electionId"],
"manifestDigest": digest,
"answers": out,
}))
}
pub(crate) async fn ballot_box_url(did: &str) -> CliResult<String> {
let resolver = vta_sdk::resolver::shared_did_resolver_from_env()
.await
.map_err(|e| format!("build a DID resolver: {e}"))?;
let resolved = resolver
.resolve(did)
.await
.map_err(|e| format!("resolve the ballot box {did}: {e}"))?;
let doc = serde_json::to_value(&resolved.doc)?;
vta_sdk::protocol::matching::ServiceCapabilities::from_did_document(&doc)
.rest
.filter(|u| vta_sdk::protocol::matching::is_https_or_loopback(u))
.ok_or_else(|| {
format!("the ballot box {did} advertises no https TrustTaskHTTPS endpoint to cast on")
.into()
})
}
async fn take_ticket(
client: &VtaClient,
t: &VoteTarget<'_>,
shown: &Value,
credential: Option<&str>,
) -> CliResult<bool> {
let election_id = &shown["manifest"]["electionId"];
let mut reg = register_payload(t, shown);
if let Some(c) = credential {
reg["membershipCredential"] = json!(c);
}
let registered = match client.pcs_voter_register_json(reg).await {
Ok(registered) => registered,
Err(e) if e.to_string().contains("ticketHeld") => return Ok(false),
Err(e) => return Err(hint_member(e.to_string(), t.member)),
};
if registered["manifestDigest"] != shown["manifestDigest"] {
return Err(
"your VTA computed another manifest digest than the community shows; not taking a \
ticket"
.into(),
);
}
let issued = community(t)
.send_signed_document(®istered["ticketDocument"])
.await
.map_err(|e| community_refusal(t.vtc_did, &e.to_string()))?;
let mut response = issued;
if let Some(obj) = response.as_object_mut() {
obj.remove("ext");
}
let accept = json!({
"contextId": t.context, "community": t.vtc_did, "electionId": election_id,
"parameters": shown["parameters"], "response": response,
});
client.pcs_voter_accept_json(accept).await?;
Ok(true)
}
fn register_payload(t: &VoteTarget<'_>, shown: &Value) -> Value {
json!({
"contextId": t.context, "community": t.vtc_did, "member": t.member,
"parameters": shown["parameters"], "manifest": shown["manifest"],
})
}
fn print_ticket_taken(election_id: &str) {
println!("{GREEN}ticket taken{RESET} — the community signed it blind");
println!(
"{DIM}vote later with `pnm elections vote {election_id} …`: a cast sent now could be paired \
with your ticket by when each arrived{RESET}"
);
}
pub async fn cmd_ticket(client: &VtaClient, t: VoteTarget<'_>, election_id: &str) -> CliResult {
cmd_ticket_presenting(client, t, election_id, Presenting::default()).await
}
pub async fn cmd_ticket_presenting(
client: &VtaClient,
t: VoteTarget<'_>,
election_id: &str,
presenting: Presenting<'_>,
) -> CliResult {
let read_with = if presenting.member_of.is_some() {
presenting
} else {
Presenting::default()
};
let shown = read_presenting(
client,
&t,
"show",
json!({ "electionId": election_id }),
read_with,
)
.await?;
if !is_json_output() {
print_disclosure(&shown);
}
let taken = take_ticket(client, &t, &shown, presenting.credential).await?;
if is_json_output() {
return Ok(print_json(
&json!({ "electionId": election_id, "ticketHeld": true, "taken": taken }),
)?);
}
if taken {
print_ticket_taken(election_id);
} else {
println!("{DIM}your VTA already holds a ticket for {election_id}{RESET}");
}
Ok(())
}
pub async fn cmd_vote(
client: &VtaClient,
t: VoteTarget<'_>,
election_id: &str,
answers: &[String],
ballot_file: Option<&PathBuf>,
now: bool,
no_delay: bool,
) -> CliResult {
let shown = read(client, &t, "show", json!({ "electionId": election_id })).await?;
if !is_json_output() {
print_disclosure(&shown);
}
let manifest = shown["manifest"].clone();
let parameters = shown["parameters"].clone();
let digest = shown["manifestDigest"]
.as_str()
.unwrap_or_default()
.to_string();
let ballot = match ballot_file {
Some(p) => serde_json::from_str(&std::fs::read_to_string(p)?)?,
None => ballot_from_answers(&manifest, &digest, answers)?,
};
if take_ticket(client, &t, &shown, None).await? {
if !now {
if is_json_output() {
return Ok(print_json(
&json!({ "electionId": election_id, "ticketHeld": true, "voted": false }),
)?);
}
print_ticket_taken(election_id);
return Ok(());
}
if !is_json_output() {
println!(
"{GREEN}ticket taken{RESET} — {YELLOW}casting in the same run: the community and \
the ballot box could pair the two by time{RESET}"
);
}
}
let base = json!({ "contextId": t.context, "community": t.vtc_did });
let ballot_box = manifest["ballotBox"]
.as_str()
.unwrap_or_default()
.to_string();
let url = ballot_box_url(&ballot_box).await?;
let mut vote = base.clone();
vote["parameters"] = parameters;
vote["manifest"] = manifest.clone();
vote["ballot"] = ballot;
let cast = if manifest["secrecy"] == "open" {
client.pcs_vote_json(vote).await?
} else {
vote["ceremony"] = shown
.get("ceremony")
.cloned()
.ok_or("the trustees have not formed the election key yet; vote once voting opens")?;
client.pcs_vote_encrypted_json(vote).await?
};
let cast_doc = &cast["castDocument"];
if !no_delay {
let ms = rand::rng().random_range(0..MAX_CAST_DELAY.as_millis() as u64);
if !is_json_output() {
println!(
"{DIM}waiting {:.1}s before casting{RESET}",
ms as f64 / 1000.0
);
}
tokio::time::sleep(Duration::from_millis(ms)).await;
}
let http = vta_sdk::http::foreign_fetch_client();
let resp = http
.post(format!("{}/trust-tasks", url.trim_end_matches('/')))
.header("content-type", "application/json")
.body(serde_json::to_string(cast_doc)?)
.send()
.await
.map_err(|e| format!("the ballot box did not answer ({e}); run the same command again to resend the same cast"))?;
let status = resp.status();
let receipt: Value = resp.json().await?;
if !status.is_success() {
return Err(format!(
"the ballot box refused the cast: {}",
receipt["payload"]["code"].as_str().unwrap_or("unknown")
)
.into());
}
let mut keep = base;
keep["electionId"] = json!(election_id);
keep["receipt"] = receipt.clone();
let kept = client.pcs_voter_receipt_json(keep).await?;
if is_json_output() {
return Ok(print_json(&json!({ "receipt": receipt, "kept": kept }))?);
}
println!(
"{GREEN}voted{RESET} in {election_id}: {} (board entry {})",
receipt["payload"]["outcome"].as_str().unwrap_or("counted"),
receipt["payload"]["leafIndex"]
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn manifest() -> Value {
json!({
"electionId": "agm",
"questions": [
{ "key": "chair", "type": "choice", "options": [{ "key": "a" }, { "key": "b" }], "optional": false },
{ "key": "budget", "type": "choice", "options": [{ "key": "yes" }, { "key": "no" }], "optional": true }
]
})
}
#[test]
fn answers_build_a_ballot_and_optional_questions_are_abstained() {
let b = ballot_from_answers(&manifest(), "zDigest", &["chair=a".into()]).unwrap();
assert_eq!(
b["answers"][0],
json!({ "question": "chair", "choice": ["a"] })
);
assert_eq!(
b["answers"][1],
json!({ "question": "budget", "abstain": true })
);
assert_eq!(b["manifestDigest"], "zDigest");
}
#[test]
fn the_manifest_is_registered_byte_for_byte() {
let shown = json!({
"manifest": { "electionId": "agm", "votingOpensAt": "2027-01-01T00:00:00.000Z" },
"parameters": { "suite": "ps-ddh-bls12381" },
});
let t = VoteTarget {
vtc_url: "https://v",
vtc_did: "did:web:v",
member: "did:web:m",
context: "c",
};
let reg = register_payload(&t, &shown);
assert_eq!(
serde_json::to_string(®["manifest"]).unwrap(),
r#"{"electionId":"agm","votingOpensAt":"2027-01-01T00:00:00.000Z"}"#
);
assert_eq!(reg["parameters"], shown["parameters"]);
}
#[test]
fn a_required_question_unanswered_or_an_unknown_one_is_refused() {
assert!(ballot_from_answers(&manifest(), "z", &[]).is_err());
assert!(
ballot_from_answers(&manifest(), "z", &["chair=a".into(), "nope=x".into()]).is_err()
);
assert!(ballot_from_answers(&manifest(), "z", &["chair".into()]).is_err());
}
#[test]
fn vti_vot_090_a_threshold_below_ten_is_disclosed_with_its_warning() {
let mut m = manifest();
m["textReleaseThreshold"] = json!(10);
assert_eq!(written_answers(&m), None, "no text question, threshold 10");
assert_eq!(written_answers_warning(&m), None);
m["questions"][1]["type"] = json!("text");
assert_eq!(
written_answers(&m).as_deref(),
Some("released once 10 ballots are counted, never while voting is open")
);
assert_eq!(written_answers_warning(&m), None);
m["textReleaseThreshold"] = json!(1);
assert_eq!(
written_answers(&m).as_deref(),
Some("released once 1 ballot is counted, never while voting is open")
);
let warning = written_answers_warning(&m).unwrap();
assert!(warning.contains("only 1 ballot,"), "{warning}");
assert!(warning.contains("may identify who wrote it"), "{warning}");
}
#[test]
fn vti_vot_090_a_credential_roll_is_disclosed_with_what_claiming_shows() {
let d = json!({
"rollCredential": {
"memberOf": "did:webvh:QmG:vtc.example:groups:g7",
"memberOfKind": "communityGroup",
"memberOfName": "Engineering",
"presenterDisclosed": true,
}
});
let roll = roll_credential(&d).unwrap();
assert!(roll.contains("a group of this community"), "{roll}");
assert!(roll.contains("\"Engineering\""), "{roll}");
assert!(
roll.contains("did:webvh:QmG:vtc.example:groups:g7"),
"{roll}"
);
let warning = roll_credential_warning(&d).unwrap();
assert!(warning.contains("member of that party"), "{warning}");
let party = json!({ "rollCredential": {
"memberOf": "did:webvh:QmB:b.example", "memberOfKind": "memberParty",
"presenterDisclosed": true } });
assert!(
roll_credential(&party)
.unwrap()
.contains("itself a member of this community")
);
assert_eq!(roll_credential(&json!({})), None);
assert_eq!(roll_credential_warning(&json!({})), None);
}
#[test]
fn vti_vot_018_an_indeterminate_answer_says_to_try_again() {
let text =
community_refusal("did:c", r#"{"code":"indeterminate","retryable":true}"#).to_string();
assert!(text.contains("run the same command again"), "{text}");
let other = community_refusal("did:c", "electionUnknown").to_string();
assert!(!other.contains("run the same command again"));
}
}