use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::error::AppError;
use crate::store::KeyspaceHandle;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Standing {
PendingRegistration,
RegistrationRefused,
Registered,
Closed,
Tallied,
Cancelled,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Delivery {
pub attempts: u32,
pub next_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BoxRefusal {
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ElectionRecord {
pub election_id: String,
pub manifest: Value,
pub manifest_digest: String,
pub standing: Standing,
pub binding: bool,
pub ballot_box: String,
pub registration_opens: DateTime<Utc>,
pub voting_opens: DateTime<Utc>,
pub voting_closes: DateTime<Utc>,
pub created_by: String,
pub created_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action_id: Option<String>,
#[serde(default)]
pub issuance_stopped: bool,
#[serde(default)]
pub close_in_flight: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issuance_closed_by_restore: Option<DateTime<Utc>>,
#[serde(default)]
pub tickets_issued: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delivery: Option<Delivery>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refusal: Option<BoxRefusal>,
#[serde(default)]
pub checkpoints: Vec<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checkpoint_asked_at: Option<DateTime<Utc>>,
#[serde(default)]
pub final_checkpoint: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub certified: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub election_key: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ceremony: Option<Value>,
#[serde(default)]
pub decryption_authorized: bool,
}
impl ElectionRecord {
#[must_use]
pub fn state(&self, now: DateTime<Utc>) -> &'static str {
match self.standing {
Standing::PendingRegistration => "pendingRegistration",
Standing::RegistrationRefused => "registrationRefused",
Standing::Cancelled => "cancelled",
Standing::Tallied => "tallied",
Standing::Closed => "closed",
Standing::Registered if now < self.registration_opens => "registered",
Standing::Registered if now < self.voting_opens => "registration",
Standing::Registered if now < self.voting_closes => "voting",
Standing::Registered => "closed",
}
}
#[must_use]
pub fn is_registered(&self) -> bool {
matches!(
self.standing,
Standing::Registered | Standing::Closed | Standing::Tallied
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TicketIssued {
pub election_id: String,
pub signer: String,
pub issued_at: DateTime<Utc>,
pub doc_id: String,
pub doc_digest: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pre_credential: Option<String>,
}
const ELECTION_PREFIX: &str = "election:";
const TICKET_PREFIX: &str = "ticket:";
fn election_key(election_id: &str) -> String {
format!("{ELECTION_PREFIX}{election_id}")
}
fn ticket_key(election_id: &str, signer: &str) -> String {
let mut h = Sha256::new();
for part in [election_id, signer] {
h.update((part.len() as u64).to_be_bytes());
h.update(part.as_bytes());
}
format!("{TICKET_PREFIX}{election_id}:{}", hex::encode(h.finalize()))
}
pub async fn get(
ks: &KeyspaceHandle,
election_id: &str,
) -> Result<Option<ElectionRecord>, AppError> {
ks.get(election_key(election_id)).await
}
pub async fn put(ks: &KeyspaceHandle, record: &ElectionRecord) -> Result<(), AppError> {
ks.insert(election_key(&record.election_id), record).await
}
pub async fn put_new(ks: &KeyspaceHandle, record: &ElectionRecord) -> Result<bool, AppError> {
ks.insert_if_absent(election_key(&record.election_id), record)
.await
}
pub async fn remove(ks: &KeyspaceHandle, election_id: &str) -> Result<(), AppError> {
ks.remove(election_key(election_id)).await
}
pub async fn all(ks: &KeyspaceHandle) -> Result<Vec<ElectionRecord>, AppError> {
ks.prefix_iter_raw(ELECTION_PREFIX)
.await?
.into_iter()
.map(|(_, v)| {
serde_json::from_slice(&v)
.map_err(|e| AppError::Internal(format!("an election record does not parse: {e}")))
})
.collect()
}
pub async fn ticket(
ks: &KeyspaceHandle,
election_id: &str,
signer: &str,
) -> Result<Option<TicketIssued>, AppError> {
ks.get(ticket_key(election_id, signer)).await
}
pub async fn put_ticket(ks: &KeyspaceHandle, t: &TicketIssued) -> Result<bool, AppError> {
ks.insert_if_absent(ticket_key(&t.election_id, &t.signer), t)
.await
}
pub async fn carry_tickets(ks: &KeyspaceHandle, from: &str, to: &str) -> Result<usize, AppError> {
let mut carried = 0;
for (_, v) in ks.prefix_iter_raw(TICKET_PREFIX).await? {
let t: TicketIssued = serde_json::from_slice(&v)
.map_err(|e| AppError::Internal(format!("a ticket record does not parse: {e}")))?;
if t.signer != from {
continue;
}
let moved = TicketIssued {
signer: to.to_string(),
..t
};
if put_ticket(ks, &moved).await? {
carried += 1;
}
}
Ok(carried)
}
pub async fn tickets(
ks: &KeyspaceHandle,
election_id: &str,
) -> Result<Vec<TicketIssued>, AppError> {
ks.prefix_iter_raw(format!("{TICKET_PREFIX}{election_id}:"))
.await?
.into_iter()
.map(|(_, v)| {
serde_json::from_slice(&v)
.map_err(|e| AppError::Internal(format!("a ticket record does not parse: {e}")))
})
.collect()
}
pub async fn replace_ticket(ks: &KeyspaceHandle, t: &TicketIssued) -> Result<(), AppError> {
ks.insert(ticket_key(&t.election_id, &t.signer), t).await
}
#[cfg(all(test, feature = "voting"))]
pub(crate) fn test_record(now: DateTime<Utc>) -> ElectionRecord {
tests::record(now)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
pub(super) fn record(now: DateTime<Utc>) -> ElectionRecord {
ElectionRecord {
election_id: "agm".into(),
manifest: Value::Null,
manifest_digest: "z".into(),
standing: Standing::Registered,
binding: false,
ballot_box: "did:key:z6MkBox".into(),
registration_opens: now + Duration::days(1),
voting_opens: now + Duration::days(2),
voting_closes: now + Duration::days(3),
created_by: "did:key:z6MkAdmin".into(),
created_at: now,
action_id: None,
issuance_stopped: false,
close_in_flight: false,
issuance_closed_by_restore: None,
tickets_issued: 0,
delivery: None,
refusal: None,
checkpoints: Vec::new(),
checkpoint_asked_at: None,
final_checkpoint: false,
certified: None,
election_key: None,
ceremony: None,
decryption_authorized: false,
}
}
#[test]
fn a_registered_election_reads_its_state_off_the_clock() {
let now = Utc::now();
let r = record(now);
assert_eq!(r.state(now), "registered");
assert_eq!(r.state(now + Duration::hours(25)), "registration");
assert_eq!(r.state(now + Duration::hours(49)), "voting");
assert_eq!(r.state(now + Duration::hours(73)), "closed");
let cancelled = ElectionRecord {
standing: Standing::Cancelled,
..r
};
assert_eq!(cancelled.state(now + Duration::hours(49)), "cancelled");
}
#[tokio::test]
async fn vot_022_a_rotation_carries_the_tickets_to_the_new_did() {
let dir = tempfile::tempdir().unwrap();
let store = vti_common::store::test_fixture::open_with_keyspaces(
&dir.path().join("db"),
&["elections"],
)
.unwrap();
let ks = store.keyspace("elections").unwrap();
let ticket = |election: &str, signer: &str| TicketIssued {
election_id: election.into(),
signer: signer.into(),
issued_at: Utc::now(),
doc_id: "urn:uuid:1".into(),
doc_digest: "z".into(),
pre_credential: None,
};
for t in [
ticket("agm", "did:key:A"),
ticket("budget", "did:key:A"),
ticket("agm", "did:key:C"),
] {
assert!(put_ticket(&ks, &t).await.unwrap());
}
assert_eq!(
carry_tickets(&ks, "did:key:A", "did:key:B").await.unwrap(),
2
);
for election in ["agm", "budget"] {
let carried = super::ticket(&ks, election, "did:key:B").await.unwrap();
assert_eq!(carried.unwrap().signer, "did:key:B");
assert!(
!put_ticket(&ks, &ticket(election, "did:key:B"))
.await
.unwrap()
);
}
assert!(
super::ticket(&ks, "budget", "did:key:C")
.await
.unwrap()
.is_none()
);
assert_eq!(
carry_tickets(&ks, "did:key:A", "did:key:B").await.unwrap(),
0
);
}
#[test]
fn ticket_keys_separate_elections_and_signers() {
assert_ne!(ticket_key("a", "did:x"), ticket_key("a", "did:y"));
assert_ne!(ticket_key("a", "did:x"), ticket_key("b", "did:x"));
assert!(ticket_key("a", "did:x").starts_with("ticket:a:"));
}
}