use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const HIDDEN_VETTING_NS: &str = "org.openvtc.hidden-vetting";
pub const SUITE: &str = "ps-ddh-bls12381";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HiddenParams {
pub suite: String,
pub helper_key: String,
pub token_key: String,
pub vetter_labels: Vec<String>,
pub token_labels: Vec<String>,
#[serde(default = "default_drip_per_tick")]
pub drip_per_tick: usize,
#[serde(default)]
pub events: Vec<HiddenEventOffer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tick_length: Option<String>,
}
impl HiddenParams {
#[must_use]
pub fn tick_length(&self) -> chrono::Duration {
match self.tick_length.as_deref() {
None => DEFAULT_TICK_LENGTH,
Some(text) => parse_tick_length(text).unwrap_or(DEFAULT_TICK_LENGTH),
}
}
#[must_use]
pub fn same_keys(&self, other: &HiddenParams) -> bool {
self.suite == other.suite
&& self.helper_key == other.helper_key
&& self.token_key == other.token_key
}
}
pub const DEFAULT_TICK_LENGTH: chrono::Duration = chrono::Duration::days(3);
pub const MIN_TICK_LENGTH: chrono::Duration = chrono::Duration::hours(1);
#[must_use]
pub fn parse_tick_length(text: &str) -> Option<chrono::Duration> {
let rest = text.strip_prefix('P')?;
let (date, time) = match rest.split_once('T') {
Some((date, time)) => {
if time.is_empty() {
return None;
}
(date, Some(time))
}
None => (rest, None),
};
fn component(part: &str, unit: char) -> Option<Option<i64>> {
if part.is_empty() {
return Some(None);
}
let digits = part.strip_suffix(unit)?;
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
digits.parse::<i64>().ok().map(Some)
}
let days = component(date, 'D')?;
let hours = match time {
Some(time) => component(time, 'H')?,
None => None,
};
if days.is_none() && hours.is_none() {
return None;
}
let total = chrono::Duration::try_days(days.unwrap_or(0))?
.checked_add(&chrono::Duration::try_hours(hours.unwrap_or(0))?)?;
(total >= MIN_TICK_LENGTH).then_some(total)
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HiddenEventOffer {
pub event_id: String,
pub start_date: chrono::NaiveDate,
pub end_date: chrono::NaiveDate,
#[serde(default)]
pub group_floor: usize,
#[serde(default)]
pub tiers: Vec<HiddenEventTier>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HiddenEventTier {
pub name: String,
pub drip_per_tick: usize,
}
fn default_drip_per_tick() -> usize {
openvtc_vetting_pcs::vtc::DEFAULT_DRIP_PER_TICK
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum HiddenError {
#[error(
"this community requires `{0}`, which this version of OpenVTC does not implement — \
applying without it would send the community something it does not accept"
)]
UnsupportedExtension(String),
#[error("this community's `{HIDDEN_VETTING_NS}` parameters could not be read: {0}")]
Unreadable(String),
#[error("this community uses the `{0}` proof suite, which this version does not implement")]
UnsupportedSuite(String),
#[error(
"you are not enrolled for this community's PCS ZKP vetting yet, so there is no \
credential to attest under"
)]
NotEnrolled,
#[error("you hold no vetting token for this community yet — it issues them on a schedule")]
NoToken,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Mode {
Named,
Hidden(Box<HiddenParams>),
}
pub fn read_mode(raw: &Value) -> Result<Mode, HiddenError> {
let vetting = raw.get("vetting");
let critical: Vec<&str> = vetting
.and_then(|v| v.get("extCritical"))
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
if let Some(unknown) = critical.iter().find(|ns| **ns != HIDDEN_VETTING_NS) {
return Err(HiddenError::UnsupportedExtension((*unknown).to_string()));
}
let ours = vetting
.and_then(|v| v.get("ext"))
.and_then(|e| e.get(HIDDEN_VETTING_NS));
let Some(ours) = ours else {
if critical.contains(&HIDDEN_VETTING_NS) {
return Err(HiddenError::Unreadable(
"named in extCritical but absent from ext".into(),
));
}
return Ok(Mode::Named);
};
let params: HiddenParams = match serde_json::from_value(ours.clone()) {
Ok(p) => p,
Err(e) => {
return if critical.contains(&HIDDEN_VETTING_NS) {
Err(HiddenError::Unreadable(e.to_string()))
} else {
Ok(Mode::Named)
};
}
};
if params.suite != SUITE {
return if critical.contains(&HIDDEN_VETTING_NS) {
Err(HiddenError::UnsupportedSuite(params.suite))
} else {
Ok(Mode::Named)
};
}
if let Some(text) = params.tick_length.as_deref()
&& parse_tick_length(text).is_none()
{
tracing::warn!(
tick_length = %text,
default = "P3D",
"a community publishes a hidden-vetting tick length this build cannot read; \
drawing on the default"
);
}
Ok(Mode::Hidden(Box::new(params)))
}
#[must_use]
pub fn accepts_named(raw: &Value) -> bool {
!raw.get("vetting")
.and_then(|v| v.get("extCritical"))
.and_then(Value::as_array)
.is_some_and(|a| a.iter().any(|ns| ns.as_str() == Some(HIDDEN_VETTING_NS)))
}
pub use openvtc_vetting_pcs::wire::EXTENSIONS_MEMBER;
use openvtc_vetting_pcs::{
applicant::ApplicantEngine,
community::CommunityParams,
meta::StatementMeta,
snapshot::{ApplicantSnapshot, VetterSnapshot},
vetter::{HiddenAttestation, VetterEngine},
wire::SubmissionWire,
};
pub fn community(
community_did: &str,
params: &HiddenParams,
) -> Result<CommunityParams, HiddenError> {
CommunityParams::published(
community_did,
¶ms.helper_key,
¶ms.token_key,
params.vetter_labels.clone(),
params.token_labels.clone(),
params.drip_per_tick,
)
.map_err(|e| HiddenError::Unreadable(e.to_string()))
}
pub fn vetter_start<R: rand::RngCore + rand::CryptoRng>(
community_did: &str,
params: &HiddenParams,
member_did: &str,
rng: &mut R,
) -> Result<VetterSnapshot, HiddenError> {
let community = community(community_did, params)?;
VetterEngine::enrol_new(member_did, &community, rng)
.and_then(|engine| engine.snapshot())
.map_err(|e| HiddenError::Unreadable(e.to_string()))
}
pub fn start<R: rand::RngCore + rand::CryptoRng>(
community_did: &str,
params: &HiddenParams,
join_did: &str,
rng: &mut R,
) -> Result<ApplicantSnapshot, HiddenError> {
let community = community(community_did, params)?;
let engine = ApplicantEngine::new(&community, join_did, rng)
.map_err(|e| HiddenError::Unreadable(e.to_string()))?;
engine
.snapshot()
.map_err(|e| HiddenError::Unreadable(e.to_string()))
}
pub fn receive(
community_did: &str,
params: &HiddenParams,
state: &mut ApplicantSnapshot,
attestation: &Value,
) -> Result<(), HiddenError> {
let community = community(community_did, params)?;
let held: openvtc_vetting_pcs::snapshot::HeldAttestation =
serde_json::from_value(attestation.clone())
.map_err(|e| HiddenError::Unreadable(e.to_string()))?;
let att: HiddenAttestation = held
.restore()
.map_err(|e| HiddenError::Unreadable(e.to_string()))?;
let mut engine =
ApplicantEngine::restore(state).map_err(|e| HiddenError::Unreadable(e.to_string()))?;
engine
.receive(&community, att)
.map_err(|e| HiddenError::Unreadable(e.to_string()))?;
*state = engine
.snapshot()
.map_err(|e| HiddenError::Unreadable(e.to_string()))?;
Ok(())
}
pub fn prove<R: rand::RngCore + rand::CryptoRng>(
community_did: &str,
params: &HiddenParams,
requirements_digest: &str,
state: &ApplicantSnapshot,
challenge: &str,
rng: &mut R,
) -> Result<Value, HiddenError> {
let community = community(community_did, params)?;
let engine =
ApplicantEngine::restore(state).map_err(|e| HiddenError::Unreadable(e.to_string()))?;
let submission = engine
.submit(&community, requirements_digest, challenge, rng)
.map_err(|e| HiddenError::Unreadable(e.to_string()))?;
let wire = SubmissionWire::from_submission(&submission)
.map_err(|e| HiddenError::Unreadable(e.to_string()))?;
serde_json::to_value(&wire).map_err(|e| HiddenError::Unreadable(e.to_string()))
}
pub fn attest<R: rand::RngCore + rand::CryptoRng>(
community_did: &str,
params: &HiddenParams,
state: &mut VetterSnapshot,
applicant_id: &str,
meta: StatementMeta,
rng: &mut R,
) -> Result<Value, HiddenError> {
let community = community(community_did, params)?;
let mut engine = VetterEngine::restore(state, community_did)
.map_err(|e| HiddenError::Unreadable(e.to_string()))?;
let id = openvtc_vetting_pcs::scheme::point_from_text(applicant_id)
.map_err(|e| HiddenError::Unreadable(e.to_string()))?;
let enrolled = params.vetter_labels.iter().any(|l| {
state
.credentials
.contains_key(l.trim_start_matches("vetter/"))
});
if !enrolled {
return Err(HiddenError::NotEnrolled);
}
let reservation = engine.accept(None, 0).map_err(attest_refusal)?;
let attestation = engine
.attest(&community, &reservation, &id, meta, rng)
.map_err(attest_refusal)?;
*state = engine
.snapshot()
.map_err(|e| HiddenError::Unreadable(e.to_string()))?;
let held = openvtc_vetting_pcs::snapshot::HeldAttestation::of(&attestation)
.map_err(|e| HiddenError::Unreadable(e.to_string()))?;
serde_json::to_value(&held).map_err(|e| HiddenError::Unreadable(e.to_string()))
}
fn attest_refusal(e: openvtc_vetting_pcs::ProtoError) -> HiddenError {
use openvtc_vetting_pcs::ProtoError;
match e {
ProtoError::AtCapacity { .. } => HiddenError::NoToken,
ProtoError::NoLiveCredential => HiddenError::NotEnrolled,
other => HiddenError::Unreadable(other.to_string()),
}
}
pub const REQUEST_EXT_MEMBER: &str = HIDDEN_VETTING_NS;
#[must_use]
pub fn request_ext(suite: &str, applicant_id: &str) -> serde_json::Value {
serde_json::json!({ REQUEST_EXT_MEMBER: { "suite": suite, "id": applicant_id } })
}
pub fn read_request_ext(ext: Option<&serde_json::Value>) -> Result<Option<String>, HiddenError> {
let Some(ours) = ext.and_then(|e| e.get(REQUEST_EXT_MEMBER)) else {
return Ok(None);
};
let suite = ours
.get("suite")
.and_then(|s| s.as_str())
.unwrap_or_default();
if suite != SUITE {
return Err(HiddenError::UnsupportedSuite(suite.to_string()));
}
match ours.get("id").and_then(|s| s.as_str()) {
Some(id) if !id.is_empty() => Ok(Some(id.to_string())),
_ => Err(HiddenError::Unreadable(
"the request names this namespace but carries no identifier".into(),
)),
}
}
#[must_use]
pub fn statement_meta(
draft: &vta_sdk::vetting::statement::StatementDraft,
community: &str,
requirements_digest: &str,
) -> Option<StatementMeta> {
let e = &draft.value;
let members = e.vetter_members()?;
Some(StatementMeta {
community: community.to_string(),
requirements_digest: requirements_digest.to_string(),
method: e.method,
claims_verified: e
.claims_verified
.iter()
.map(|c| c.as_str().to_string())
.collect(),
liveness_confirmed: e.liveness_confirmed,
declared_relationship: members.declared_relationship,
identity_commitment: members.identity_commitment.to_string(),
card_digest_multibase: members.card_digest_multibase.to_string(),
valid_from: draft.valid_from.date_naive(),
valid_until: draft.valid_until.date_naive(),
token_label: String::new(),
token_serial: String::new(),
})
}
pub fn enrolment_request<R: rand::RngCore + rand::CryptoRng>(
community_did: &str,
params: &HiddenParams,
snapshot: &VetterSnapshot,
period: &str,
rng: &mut R,
) -> Result<(crate::vetting::wire::pcs::RootRequest, Blinding), HiddenError> {
let params = community(community_did, params)?;
let engine = VetterEngine::restore(snapshot, community_did)
.map_err(|e| HiddenError::Unreadable(e.to_string()))?;
let (wire, state) = engine
.enrolment_request(¶ms, period, rng)
.map_err(|e| HiddenError::Unreadable(e.to_string()))?;
Ok((
crate::vetting::wire::pcs::RootRequest {
label: wire.label,
id: wire.id,
request: wire.request,
},
state,
))
}
pub type Blinding = openvtc_vetting_pcs::vetter::EnrolmentBlinding;
pub fn blinding_text(blinding: &Blinding) -> Result<String, HiddenError> {
use base64::Engine;
let bytes = blinding
.to_bytes()
.map_err(|e| HiddenError::Unreadable(e.to_string()))?;
Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes.as_slice()))
}
pub fn blinding_from_text(text: &str) -> Result<Blinding, HiddenError> {
use base64::Engine;
let bytes = zeroize::Zeroizing::new(
base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(text)
.map_err(|e| HiddenError::Unreadable(e.to_string()))?,
);
Blinding::from_bytes(&bytes).map_err(|e| HiddenError::Unreadable(e.to_string()))
}
pub fn accept_enrolment(
community_did: &str,
params: &HiddenParams,
snapshot: &mut VetterSnapshot,
answer: &crate::vetting::wire::pcs::RootResponse,
blinding: &Blinding,
) -> Result<(), HiddenError> {
let params = community(community_did, params)?;
let mut engine = VetterEngine::restore(snapshot, community_did)
.map_err(|e| HiddenError::Unreadable(e.to_string()))?;
engine
.accept_enrolment(
¶ms,
&openvtc_vetting_pcs::issuer::RootCredentialWire {
label: answer.label.clone(),
pre_credential: answer.pre_credential.clone(),
},
blinding,
)
.map_err(|e| HiddenError::Unreadable(e.to_string()))?;
*snapshot = engine
.snapshot()
.map_err(|e| HiddenError::Unreadable(e.to_string()))?;
Ok(())
}
pub fn drip_request<R: rand::RngCore + rand::CryptoRng>(
community_did: &str,
params: &HiddenParams,
snapshot: &mut VetterSnapshot,
tick: u32,
label: &str,
rate: usize,
rng: &mut R,
) -> Result<crate::vetting::wire::pcs::TokensRequest, HiddenError> {
let params_built = community(community_did, params)?;
let mut engine = VetterEngine::restore(snapshot, community_did)
.map_err(|e| HiddenError::Unreadable(e.to_string()))?;
let batch = engine
.drip_request(¶ms_built, tick, label, rate, rng)
.map_err(|e| HiddenError::Unreadable(e.to_string()))?;
*snapshot = engine
.snapshot()
.map_err(|e| HiddenError::Unreadable(e.to_string()))?;
Ok(crate::vetting::wire::pcs::TokensRequest {
label: batch.label,
tick: batch.tick,
requests: batch
.requests
.into_iter()
.map(|r| crate::vetting::wire::pcs::TokenRequest {
commitment: r.commitment,
opening_proof: r.opening_proof,
})
.collect(),
})
}
pub fn accept_drip(
community_did: &str,
params: &HiddenParams,
snapshot: &mut VetterSnapshot,
served: &crate::vetting::wire::pcs::TokensResponse,
) -> Result<usize, HiddenError> {
let params_built = community(community_did, params)?;
let mut engine = VetterEngine::restore(snapshot, community_did)
.map_err(|e| HiddenError::Unreadable(e.to_string()))?;
let taken = engine
.accept_drip(
¶ms_built,
&openvtc_vetting_pcs::issuer::TokenBatchWire {
label: served.label.clone(),
tick: served.tick,
pre_credentials: served.pre_credentials.clone(),
},
)
.map_err(|e| HiddenError::Unreadable(e.to_string()))?;
*snapshot = engine
.snapshot()
.map_err(|e| HiddenError::Unreadable(e.to_string()))?;
Ok(taken)
}
pub fn forget_draw(
community_did: &str,
snapshot: &mut VetterSnapshot,
label: &str,
tick: u32,
) -> Result<(), HiddenError> {
let mut engine = VetterEngine::restore(snapshot, community_did)
.map_err(|e| HiddenError::Unreadable(e.to_string()))?;
engine.forget_draw(label, tick);
*snapshot = engine
.snapshot()
.map_err(|e| HiddenError::Unreadable(e.to_string()))?;
Ok(())
}
pub const TOKENS_TICK_NOT_YET: &str =
trust_tasks_rs::specs::vtc::vetting::vetters::pcs_tokens::v0_1::error_codes::TICK_NOT_YET.code;
pub const TOKENS_ALREADY_SERVED: &str =
trust_tasks_rs::specs::vtc::vetting::vetters::pcs_tokens::v0_1::error_codes::ALREADY_SERVED
.code;
pub const TOKENS_OVER_QUOTA: &str =
trust_tasks_rs::specs::vtc::vetting::vetters::pcs_tokens::v0_1::error_codes::OVER_QUOTA.code;
#[must_use]
pub fn refusal_words(code: &str) -> String {
let local = code.rsplit_once(':').map_or(code, |(_, local)| local);
let task = code
.split_once(':')
.map(|(task, _)| task.rsplit('/').next().unwrap_or(task))
.unwrap_or("");
let words = match (task, local) {
(_, "notAVetter") => {
"the community holds no live vetter grant for you. Ask its admins to name you a \
vetter again, or for your vetter credential (g on the desk)."
}
("pcs-root", "wrongLabel") => {
"it is not enrolling under that month's label any more. The next pass enrols under \
the current one."
}
("pcs-root", "alreadyEnrolled") => {
"it already enrolled you under its current label, but this client lost the answer \
before it could be opened, and it issues one credential per label. You can attest \
there again once it publishes a new label."
}
("pcs-root", "identifierRebound") => {
"it has you enrolled under a different hidden-vetting key. This install's key is not \
the one the community knows — vet from the install that enrolled first."
}
("pcs-root", "badRequest") | ("pcs-tokens", "badOpeningProof") => {
"it could not verify the request this client built. Update OpenVTC; if that does \
not help, the community and this client disagree about the scheme."
}
("pcs-tokens", "labelNotLive") => {
"it is not issuing tokens under that label any more. The next pass draws under the \
label the community publishes now."
}
("pcs-tokens", "alreadyServed") => "that tick was already served.",
("pcs-tokens", "tickNotYet") => {
"that tick has not begun at the community yet. It is asked for again when its \
window opens."
}
("pcs-tokens", "overQuota") => {
"the request asked for more tokens than it issues a tick — this client's copy of \
its rate was out of date. Nothing was signed. Its rate is being read again now, and \
nothing more is drawn until the rate it publishes has been taken."
}
("pcs-tokens", "eventRefused") => {
"you are not in the approved group for that event, so it will not issue its tokens \
to you. Your ordinary tokens are unaffected."
}
("event-mode", "unknownEvent") => "it is not running that event.",
("event-mode", "unknownTier") => "it does not offer that rate for the event.",
("event-mode", "badWindow") => "the days asked for are not the event's own.",
("event-mode", "alreadyRequested") => {
"you have already asked to vet at that event; its answer stands."
}
("event-mode", "eventClosed") => "that event has closed.",
("pcs-challenge", "notHiddenVetting") => {
"it no longer counts vetting from a zero-knowledge proof for this criterion."
}
_ => return format!("it refused, with a code this client does not know ({code})."),
};
words.to_string()
}
pub const MAX_DRAWS_PER_PASS: usize = 16;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Due {
Nothing,
Enrol {
period: String,
},
Draw {
label: String,
tick: u32,
rate: usize,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EventDraw {
pub label: String,
pub rate: usize,
pub opens_on: chrono::NaiveDate,
pub closes_after: chrono::NaiveDate,
}
fn midnight(day: chrono::NaiveDate) -> chrono::DateTime<chrono::Utc> {
day.and_time(chrono::NaiveTime::MIN).and_utc()
}
#[must_use]
pub fn month_of_label(
label: &str,
) -> Option<(chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)> {
let period = label.strip_prefix("token/")?;
let (year, month) = period.split_once('-')?;
if year.len() != 4 || month.len() != 2 {
return None;
}
let year: i32 = year.parse().ok()?;
let month: u32 = month.parse().ok()?;
let first = chrono::NaiveDate::from_ymd_opt(year, month, 1)?;
let next = first.checked_add_months(chrono::Months::new(1))?;
Some((midnight(first), midnight(next)))
}
#[must_use]
pub fn tick_of(
start: chrono::DateTime<chrono::Utc>,
length: chrono::Duration,
now: chrono::DateTime<chrono::Utc>,
) -> Option<u32> {
if now < start || length <= chrono::Duration::zero() {
return None;
}
let elapsed = (now - start).num_seconds();
u32::try_from(elapsed / length.num_seconds()).ok()
}
#[must_use]
pub fn tick_start(
start: chrono::DateTime<chrono::Utc>,
length: chrono::Duration,
tick: u32,
) -> chrono::DateTime<chrono::Utc> {
start + length * i32::try_from(tick).unwrap_or(i32::MAX)
}
#[must_use]
pub fn current_month_label(
params: &HiddenParams,
now: chrono::DateTime<chrono::Utc>,
) -> Option<(
&str,
chrono::DateTime<chrono::Utc>,
chrono::DateTime<chrono::Utc>,
)> {
params.token_labels.iter().find_map(|label| {
let (start, end) = month_of_label(label)?;
(start <= now && now < end).then_some((label.as_str(), start, end))
})
}
fn next_tick(
start: chrono::DateTime<chrono::Utc>,
length: chrono::Duration,
last: Option<u32>,
now: chrono::DateTime<chrono::Utc>,
) -> Option<u32> {
let current = tick_of(start, length, now)?;
let next = last
.filter(|t| *t <= current)
.map_or(0, |t| t.saturating_add(1));
(next <= current).then_some(next)
}
#[must_use]
pub fn due(
params: &HiddenParams,
snapshot: Option<&VetterSnapshot>,
last_ticks: &std::collections::BTreeMap<String, u32>,
events: &[EventDraw],
now: chrono::DateTime<chrono::Utc>,
) -> Due {
let Some(period) = params
.vetter_labels
.first()
.map(|l| l.trim_start_matches("vetter/").to_string())
else {
return Due::Nothing;
};
let Some(snapshot) = snapshot else {
return Due::Enrol { period };
};
if !snapshot.credentials.contains_key(&period) {
return Due::Enrol { period };
}
let length = params.tick_length();
let today = now.date_naive();
if let Some((label, start, _)) = current_month_label(params, now)
&& let Some(tick) = next_tick(start, length, last_ticks.get(label).copied(), now)
{
return Due::Draw {
label: label.to_string(),
tick,
rate: params.drip_per_tick,
};
}
for event in events {
if today > event.closes_after {
continue;
}
if let Some(tick) = next_tick(
midnight(event.opens_on),
length,
last_ticks.get(&event.label).copied(),
now,
) {
return Due::Draw {
label: event.label.clone(),
tick,
rate: event.rate,
};
}
}
Due::Nothing
}
#[must_use]
pub fn next_window(
params: &HiddenParams,
events: &[EventDraw],
now: chrono::DateTime<chrono::Utc>,
) -> Option<chrono::DateTime<chrono::Utc>> {
let length = params.tick_length();
let today = now.date_naive();
let next_of = |start: chrono::DateTime<chrono::Utc>| match tick_of(start, length, now) {
None => start,
Some(t) => tick_start(start, length, t.saturating_add(1)),
};
let mut candidates = Vec::new();
if let Some((_, start, end)) = current_month_label(params, now) {
candidates.push(next_of(start).min(end));
}
for event in events.iter().filter(|e| today <= e.closes_after) {
candidates.push(next_of(midnight(event.opens_on)));
}
candidates.into_iter().min()
}
#[must_use]
pub fn current_window(
params: &HiddenParams,
label: &str,
events: &[EventDraw],
now: chrono::DateTime<chrono::Utc>,
) -> Option<(
u32,
chrono::DateTime<chrono::Utc>,
chrono::DateTime<chrono::Utc>,
)> {
let start = label_start(label, events)?;
let length = params.tick_length();
let tick = tick_of(start, length, now)?;
Some((
tick,
tick_start(start, length, tick),
tick_start(start, length, tick.saturating_add(1)),
))
}
#[must_use]
pub fn label_start(label: &str, events: &[EventDraw]) -> Option<chrono::DateTime<chrono::Utc>> {
if let Some((start, _)) = month_of_label(label) {
return Some(start);
}
events
.iter()
.find(|e| e.label == label)
.map(|e| midnight(e.opens_on))
}
pub fn settle_ticks(
last_ticks: &mut std::collections::BTreeMap<String, u32>,
params: &HiddenParams,
events: &[EventDraw],
now: chrono::DateTime<chrono::Utc>,
) -> bool {
let length = params.tick_length();
let before = last_ticks.len();
last_ticks.retain(|label, tick| {
let live = params.token_labels.iter().any(|l| l == label)
|| events.iter().any(|e| &e.label == label);
live && label_start(label, events)
.and_then(|start| tick_of(start, length, now))
.is_some_and(|current| *tick <= current)
});
last_ticks.len() != before
}
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng;
use serde_json::json;
fn params() -> Value {
json!({
"suite": SUITE,
"helperKey": "zHelperKey",
"tokenKey": "zTokenKey",
"vetterLabels": ["vetter/2026-10", "vetter/2026-09"],
"tokenLabels": ["token/2026-10"]
})
}
fn as_the_service_serves_it() -> Value {
let mut rng = rand::rngs::StdRng::from_seed([9u8; 32]);
let vtc = openvtc_vetting_pcs::vtc::Vtc::new(
"did:example:kernel-vtc",
"2026-09",
json!({
"version": "0.1",
"statementType": vta_sdk::protocols::vetting::VETTED_PREDICATE,
"minStatements": 3,
"acceptedMethods": ["inPerson", "video"],
"eligibleVetters": { "role": "vetter" }
}),
&mut rng,
)
.expect("a community");
let hvk = openvtc_vetting_pcs::scheme::key_text(vtc.hvk()).expect("hvk");
let tvk = openvtc_vetting_pcs::scheme::key_text(vtc.tvk()).expect("tvk");
json!({
"id": "kernel-developer",
"vetting": {
"version": "0.1",
"statementType": vta_sdk::protocols::vetting::VETTED_PREDICATE,
"minStatements": 3,
"acceptedMethods": ["inPerson", "video"],
"eligibleVetters": { "role": "vetter" },
"ext": {
HIDDEN_VETTING_NS: {
"suite": SUITE,
"helperKey": hvk,
"tokenKey": tvk,
"vetterLabels": ["vetter/2026-09"],
"tokenLabels": ["token/2026-09", "token/event/kernel-summit-2026"],
"dripPerTick": 3,
"tickLength": "PT12H",
"events": [{
"eventId": "kernel-summit-2026",
"startDate": "2026-10-12",
"endDate": "2026-10-14",
"groupFloor": 3,
"tiers": [{ "name": "desk", "dripPerTick": 20 }]
}]
}
}
},
"requirementsDigest": "zQmDigest"
})
}
#[test]
fn a_stored_criterion_knows_whether_its_vetters_are_hidden() {
let known = |raw: &Value| crate::vetting::book::KnownCriterion {
community: "did:web:vtc".into(),
criterion_id: "c".into(),
requirements_digest: None,
requirements: serde_json::from_value(raw["vetting"].clone()).expect("requirements"),
fetched_at: chrono::Utc::now(),
description: None,
};
assert!(known(&as_the_service_serves_it()).hidden_vetting());
let mut named = as_the_service_serves_it();
named["vetting"].as_object_mut().unwrap().remove("ext");
named["vetting"]
.as_object_mut()
.unwrap()
.remove("extCritical");
assert!(!known(&named).hidden_vetting());
}
#[test]
fn the_shape_the_service_serves_is_the_shape_this_client_reads() {
let params = match read_mode(&as_the_service_serves_it()).expect("readable") {
Mode::Hidden(p) => *p,
Mode::Named => panic!("the criterion publishes parameters; this read them as named"),
};
assert_eq!(params.suite, SUITE);
assert_eq!(params.vetter_labels, ["vetter/2026-09"]);
assert_eq!(params.drip_per_tick, 3);
assert_eq!(params.tick_length(), chrono::Duration::hours(12));
assert_eq!(params.token_labels.len(), 2);
let event = params.events.first().expect("the event menu is published");
assert_eq!(event.event_id, "kernel-summit-2026");
assert_eq!(event.group_floor, 3);
assert_eq!(event.tiers[0].drip_per_tick, 20);
community("did:example:kernel-vtc", ¶ms)
.expect("the published keys decode into engine parameters");
}
#[test]
fn the_stored_shape_is_not_the_published_one() {
let stored = json!({
"suite": SUITE,
"hvk": "zHelperKey",
"tvk": "zTokenKey",
"livePeriods": ["2026-09"],
"liveTokenLabels": ["token/2026-09"],
"dripPerTick": 3,
"events": []
});
assert_eq!(
read_mode(&criterion(json!({ HIDDEN_VETTING_NS: stored }), None)).unwrap(),
Mode::Named,
"the stored configuration must not be mistaken for the published parameters"
);
}
fn criterion(ext: Value, critical: Option<Value>) -> Value {
let mut vetting = json!({ "version": "0.1", "minStatements": 2, "ext": ext });
if let Some(c) = critical {
vetting["extCritical"] = c;
}
json!({ "id": "kernel-developer", "vetting": vetting })
}
#[test]
fn a_criterion_without_the_namespace_is_the_named_path() {
assert_eq!(read_mode(&json!({ "id": "plain" })).unwrap(), Mode::Named);
assert_eq!(
read_mode(&json!({ "id": "plain", "vetting": { "minStatements": 2 } })).unwrap(),
Mode::Named
);
}
#[test]
fn our_namespace_is_read_whether_or_not_it_is_marked() {
let ext = json!({ HIDDEN_VETTING_NS: params() });
for critical in [None, Some(json!([HIDDEN_VETTING_NS]))] {
match read_mode(&criterion(ext.clone(), critical)).unwrap() {
Mode::Hidden(p) => {
assert_eq!(p.helper_key, "zHelperKey");
assert_eq!(p.vetter_labels.len(), 2);
}
Mode::Named => panic!("the namespace is present and readable"),
}
}
}
#[test]
fn a_critical_namespace_we_do_not_implement_is_refused() {
let raw = criterion(
json!({ "com.example.some-scheme": { "a": 1 } }),
Some(json!(["com.example.some-scheme"])),
);
assert_eq!(
read_mode(&raw),
Err(HiddenError::UnsupportedExtension(
"com.example.some-scheme".into()
))
);
}
#[test]
fn an_unmarked_namespace_we_do_not_implement_is_ignored() {
let raw = criterion(json!({ "com.example.hint": { "a": 1 } }), None);
assert_eq!(read_mode(&raw).unwrap(), Mode::Named);
}
#[test]
fn marked_but_broken_parameters_refuse_and_unmarked_ones_do_not() {
let broken = json!({ HIDDEN_VETTING_NS: { "suite": SUITE } }); assert!(matches!(
read_mode(&criterion(broken.clone(), Some(json!([HIDDEN_VETTING_NS])))),
Err(HiddenError::Unreadable(_))
));
assert_eq!(read_mode(&criterion(broken, None)).unwrap(), Mode::Named);
let mut other_suite = params();
other_suite["suite"] = json!("bbs-ddh-bls12381");
let ext = json!({ HIDDEN_VETTING_NS: other_suite });
assert_eq!(
read_mode(&criterion(ext.clone(), Some(json!([HIDDEN_VETTING_NS])))),
Err(HiddenError::UnsupportedSuite("bbs-ddh-bls12381".into()))
);
assert_eq!(read_mode(&criterion(ext, None)).unwrap(), Mode::Named);
}
#[test]
fn marked_but_absent_is_a_contradiction_and_refused() {
let raw = criterion(
json!({ "com.example.hint": {} }),
Some(json!([HIDDEN_VETTING_NS])),
);
assert!(matches!(read_mode(&raw), Err(HiddenError::Unreadable(_))));
}
}
#[cfg(test)]
mod schedule_tests {
use super::*;
use chrono::{NaiveDate, TimeZone, Utc};
use std::collections::BTreeMap;
fn fresh() -> BTreeMap<String, u32> {
BTreeMap::new()
}
fn drawn(label: &str, tick: u32) -> BTreeMap<String, u32> {
BTreeMap::from([(label.to_string(), tick)])
}
fn at(y: i32, m: u32, d: u32, h: u32, min: u32, s: u32) -> chrono::DateTime<Utc> {
Utc.with_ymd_and_hms(y, m, d, h, min, s).unwrap()
}
fn params(drip: usize) -> HiddenParams {
HiddenParams {
suite: SUITE.into(),
helper_key: "zHelper".into(),
token_key: "zToken".into(),
vetter_labels: vec!["vetter/2026-09".into()],
token_labels: vec!["token/2026-09".into()],
drip_per_tick: drip,
events: Vec::new(),
tick_length: None,
}
}
fn with_tick_length(text: &str) -> HiddenParams {
HiddenParams {
tick_length: Some(text.into()),
..params(3)
}
}
fn enrolled() -> VetterSnapshot {
let mut snapshot = VetterSnapshot::without_keys("member-1");
snapshot
.credentials
.insert("2026-09".into(), "zCredential".into());
snapshot
}
fn summit() -> EventDraw {
EventDraw {
label: "token/event/summit".into(),
rate: 20,
opens_on: NaiveDate::from_ymd_opt(2026, 9, 18).unwrap(),
closes_after: NaiveDate::from_ymd_opt(2026, 10, 4).unwrap(),
}
}
fn plan(
params: &HiddenParams,
mut ticks: BTreeMap<String, u32>,
events: &[EventDraw],
now: chrono::DateTime<Utc>,
) -> Vec<(String, u32)> {
let snapshot = enrolled();
let mut out = Vec::new();
for _ in 0..100 {
match due(params, Some(&snapshot), &ticks, events, now) {
Due::Draw { label, tick, .. } => {
ticks.insert(label.clone(), tick);
out.push((label, tick));
}
Due::Nothing => return out,
Due::Enrol { .. } => panic!("enrolled"),
}
}
panic!("the plan never settles: {out:?}")
}
#[test]
fn tick_lengths_are_days_and_hours_and_at_least_an_hour() {
assert_eq!(parse_tick_length("P3D"), Some(chrono::Duration::days(3)));
assert_eq!(
parse_tick_length("PT12H"),
Some(chrono::Duration::hours(12))
);
assert_eq!(
parse_tick_length("P1DT6H"),
Some(chrono::Duration::hours(30))
);
assert_eq!(parse_tick_length("PT1H"), Some(chrono::Duration::hours(1)));
for bad in [
"", "P", "PT", "P3", "3D", "P1W", "P1M", "PT30M", "PT0H", "P0D", "P-1D", "P1.5D",
"P1DT", "p3d",
] {
assert_eq!(parse_tick_length(bad), None, "{bad:?} is malformed");
}
}
#[test]
fn a_missing_or_malformed_tick_length_is_three_days() {
assert_eq!(params(3).tick_length(), chrono::Duration::days(3));
assert_eq!(with_tick_length("PT30M").tick_length(), DEFAULT_TICK_LENGTH);
assert_eq!(
with_tick_length("weekly").tick_length(),
DEFAULT_TICK_LENGTH
);
assert_eq!(
with_tick_length("PT12H").tick_length(),
chrono::Duration::hours(12)
);
}
#[test]
fn a_malformed_tick_length_does_not_cost_the_criterion() {
let raw = serde_json::json!({
"id": "c",
"vetting": {
"ext": { HIDDEN_VETTING_NS: {
"suite": SUITE,
"helperKey": "zH",
"tokenKey": "zT",
"vetterLabels": ["vetter/2026-09"],
"tokenLabels": ["token/2026-09"],
"tickLength": "fortnightly"
}},
"extCritical": [HIDDEN_VETTING_NS]
}
});
let Mode::Hidden(p) = read_mode(&raw).expect("readable") else {
panic!("hidden");
};
assert_eq!(p.tick_length(), DEFAULT_TICK_LENGTH);
}
#[test]
fn a_month_labels_ticks_are_windows_from_the_first_of_the_month() {
let (start, end) = month_of_label("token/2026-09").unwrap();
assert_eq!(start, at(2026, 9, 1, 0, 0, 0));
assert_eq!(end, at(2026, 10, 1, 0, 0, 0));
let len = chrono::Duration::days(3);
assert_eq!(tick_of(start, len, at(2026, 8, 31, 23, 59, 59)), None);
assert_eq!(tick_of(start, len, at(2026, 9, 1, 0, 0, 0)), Some(0));
assert_eq!(tick_of(start, len, at(2026, 9, 3, 23, 59, 59)), Some(0));
assert_eq!(tick_of(start, len, at(2026, 9, 4, 0, 0, 0)), Some(1));
assert_eq!(tick_of(start, len, at(2026, 9, 20, 9, 0, 0)), Some(6));
assert_eq!(tick_start(start, len, 6), at(2026, 9, 19, 0, 0, 0));
let (_, end) = month_of_label("token/2026-12").unwrap();
assert_eq!(end, at(2027, 1, 1, 0, 0, 0));
for label in [
"token/event/summit",
"token/2026-9",
"token/2026-13",
"vetter/2026-09",
] {
assert_eq!(month_of_label(label), None, "{label}");
}
}
#[test]
fn an_event_labels_ticks_start_on_its_first_day() {
let events = [summit()];
let start = label_start("token/event/summit", &events).unwrap();
assert_eq!(start, at(2026, 9, 18, 0, 0, 0));
let p = params(3);
assert_eq!(
current_window(&p, "token/event/summit", &events, at(2026, 9, 21, 0, 0, 0)),
Some((1, at(2026, 9, 21, 0, 0, 0), at(2026, 9, 24, 0, 0, 0)))
);
assert_eq!(
current_window(&p, "token/event/summit", &events, at(2026, 9, 17, 23, 0, 0)),
None,
"not begun"
);
assert_eq!(label_start("token/event/other", &events), None);
}
#[test]
fn a_twelve_hour_tick_length_is_two_ticks_a_day() {
let p = with_tick_length("PT12H");
let (start, _) = month_of_label("token/2026-09").unwrap();
assert_eq!(
tick_of(start, p.tick_length(), at(2026, 9, 1, 11, 59, 59)),
Some(0)
);
assert_eq!(
tick_of(start, p.tick_length(), at(2026, 9, 1, 12, 0, 0)),
Some(1)
);
assert_eq!(
tick_of(start, p.tick_length(), at(2026, 9, 2, 0, 0, 0)),
Some(2)
);
assert_eq!(
plan(&p, drawn("token/2026-09", 0), &[], at(2026, 9, 2, 1, 0, 0)),
[
("token/2026-09".to_string(), 1),
("token/2026-09".to_string(), 2)
]
);
}
#[test]
fn enrolment_is_owed_before_the_first_credential_and_after_every_rotation() {
let now = at(2026, 9, 20, 9, 0, 0);
assert_eq!(
due(¶ms(3), None, &fresh(), &[], now),
Due::Enrol {
period: "2026-09".into()
}
);
}
#[test]
fn the_draw_is_owed_by_the_clock_and_not_by_the_balance() {
let now = at(2026, 9, 20, 9, 0, 0); let snapshot = enrolled();
assert_eq!(
due(
¶ms(3),
Some(&snapshot),
&drawn("token/2026-09", 6),
&[],
now
),
Due::Nothing
);
assert_eq!(
due(
¶ms(3),
Some(&snapshot),
&drawn("token/2026-09", 5),
&[],
now
),
Due::Draw {
label: "token/2026-09".into(),
tick: 6,
rate: 3,
}
);
}
#[test]
fn a_vetter_who_was_offline_catches_up_oldest_first_and_never_ahead() {
let now = at(2026, 9, 20, 9, 0, 0); let owed = plan(¶ms(3), drawn("token/2026-09", 2), &[], now);
assert_eq!(
owed,
(3..=6)
.map(|t| ("token/2026-09".to_string(), t))
.collect::<Vec<_>>()
);
let edge = at(2026, 9, 21, 23, 59, 59);
assert_eq!(plan(¶ms(3), drawn("token/2026-09", 6), &[], edge), []);
assert_eq!(
plan(
¶ms(3),
drawn("token/2026-09", 6),
&[],
at(2026, 9, 22, 0, 0, 0)
),
[("token/2026-09".to_string(), 7)]
);
assert_eq!(
plan(¶ms(3), fresh(), &[], at(2026, 9, 1, 9, 0, 0)),
[("token/2026-09".to_string(), 0)]
);
}
#[test]
fn an_old_epoch_day_tick_does_not_block_the_new_ones() {
let now = at(2026, 9, 1, 9, 0, 0);
let old = drawn("token/2026-09", 20_697);
assert_eq!(
due(¶ms(3), Some(&enrolled()), &old, &[], now),
Due::Draw {
label: "token/2026-09".into(),
tick: 0,
rate: 3,
}
);
let mut stored = BTreeMap::from([
("token/2026-09".to_string(), 20_697),
("token/event/summit".to_string(), 20_697),
("token/2026-08".to_string(), 3),
]);
assert!(settle_ticks(&mut stored, ¶ms(3), &[summit()], now));
assert!(stored.is_empty(), "{stored:?}");
let mut real = drawn("token/2026-09", 0);
assert!(!settle_ticks(&mut real, ¶ms(3), &[], now));
assert_eq!(real, drawn("token/2026-09", 0));
}
#[test]
fn an_event_draw_is_owed_beside_the_ordinary_one_and_never_instead_of_it() {
let now = at(2026, 9, 20, 9, 0, 0); let summit = [summit()];
assert_eq!(
due(
¶ms(3),
Some(&enrolled()),
&drawn("token/2026-09", 5),
&summit,
now
),
Due::Draw {
label: "token/2026-09".into(),
tick: 6,
rate: 3,
}
);
assert_eq!(
due(
¶ms(3),
Some(&enrolled()),
&drawn("token/2026-09", 6),
&summit,
now
),
Due::Draw {
label: "token/event/summit".into(),
tick: 0,
rate: 20,
}
);
}
#[test]
fn a_closed_event_is_no_longer_drawn_under() {
let now = at(2026, 9, 25, 9, 0, 0);
let p = HiddenParams {
token_labels: vec!["token/2026-09".into()],
..params(3)
};
let closed = [EventDraw {
closes_after: NaiveDate::from_ymd_opt(2026, 9, 24).unwrap(),
..summit()
}];
assert_eq!(plan(&p, drawn("token/2026-09", 8), &closed, now), []);
}
#[test]
fn a_new_month_is_drawn_under_the_labels_published_now() {
let october = at(2026, 10, 1, 8, 0, 0);
let stale = params(3);
assert_eq!(
due(
&stale,
Some(&enrolled()),
&drawn("token/2026-09", 9),
&[],
october
),
Due::Nothing,
"never last month's label"
);
assert_eq!(next_window(&stale, &[], october), None);
let live = HiddenParams {
vetter_labels: vec!["vetter/2026-10".into(), "vetter/2026-09".into()],
token_labels: vec!["token/2026-10".into(), "token/2026-09".into()],
..params(3)
};
assert_eq!(
due(
&live,
Some(&enrolled()),
&drawn("token/2026-09", 9),
&[],
october
),
Due::Enrol {
period: "2026-10".into()
}
);
let mut both = enrolled();
both.credentials.insert("2026-10".into(), "zOctober".into());
assert_eq!(
due(&live, Some(&both), &drawn("token/2026-09", 9), &[], october),
Due::Draw {
label: "token/2026-10".into(),
tick: 0,
rate: 3,
}
);
}
#[test]
fn the_next_window_is_the_next_tick_or_the_months_end() {
let p = params(3);
assert_eq!(
next_window(&p, &[], at(2026, 9, 20, 9, 0, 0)),
Some(at(2026, 9, 22, 0, 0, 0))
);
assert_eq!(
next_window(&p, &[], at(2026, 9, 29, 12, 0, 0)),
Some(at(2026, 10, 1, 0, 0, 0))
);
let soon = [EventDraw {
opens_on: NaiveDate::from_ymd_opt(2026, 9, 21).unwrap(),
..summit()
}];
assert_eq!(
next_window(&p, &soon, at(2026, 9, 20, 9, 0, 0)),
Some(at(2026, 9, 21, 0, 0, 0))
);
}
#[test]
fn the_declared_refusals_are_said_in_words() {
assert!(refusal_words(TOKENS_TICK_NOT_YET).contains("has not begun"));
assert!(
refusal_words("vtc/vetting/vetters/pcs-tokens:labelNotLive")
.contains("not issuing tokens under that label")
);
assert!(refusal_words("vtc/vetting/vetters/pcs-root:notAVetter").contains("vetter grant"));
assert!(refusal_words("vtc/vetting/vetters/event-mode:eventClosed").contains("has closed"));
assert!(
refusal_words("vtc/vetting/vetters/pcs-tokens:somethingNew")
.contains("(vtc/vetting/vetters/pcs-tokens:somethingNew)")
);
}
}