use std::collections::HashMap;
use chrono::{DateTime, NaiveDate, Utc};
use hkdf::Hkdf;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use vti_vetting_pcs::{
ProtoError,
token::{SpendOutcome, SpentLedger, TokenVerifier},
verifier::{Decision, Submission, Verifier, VerifierParams},
wire::SubmissionWire,
};
use super::pcs_challenge::{ChallengeRefusal, ConsumeError};
use crate::server::AppState;
const KEY_DOMAIN: &[u8] = b"vtc-vetting-pcs-spent/v1\0";
const MASK_KEY_INFO: &[u8] = b"vtc-vetting-pcs-tagmask/v1";
fn mask(key: &[u8; 32], applicant_did: &str, tag: &str) -> Result<String, ProtoError> {
let mut out = [0u8; 32];
Hkdf::<Sha256>::new(Some(applicant_did.as_bytes()), key)
.expand(tag.as_bytes(), &mut out)
.map_err(|e| ProtoError::Serialization(format!("mask a tag: {e}")))?;
Ok(multibase::encode(multibase::Base::Base58Btc, out))
}
fn mask_key(state: &AppState) -> Result<[u8; 32], ProtoError> {
let signer = state.credential_signer.as_ref().ok_or_else(|| {
ProtoError::Serialization("credential signer not initialised — cannot mask tags".into())
})?;
let master = signer.ed25519_signing_key().ok_or_else(|| {
ProtoError::Serialization("the credential signer holds no Ed25519 key".into())
})?;
let mut key = [0u8; 32];
Hkdf::<Sha256>::new(None, &master.to_bytes())
.expand(MASK_KEY_INFO, &mut key)
.map_err(|e| ProtoError::Serialization(format!("derive the tag-mask key: {e}")))?;
Ok(key)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HiddenVettingConfig {
pub suite: String,
pub hvk: String,
pub tvk: String,
pub live_periods: Vec<String>,
pub live_token_labels: Vec<String>,
#[serde(default = "default_drip_per_tick")]
pub drip_per_tick: usize,
#[serde(default = "default_tick_length")]
pub tick_length: String,
#[serde(default)]
pub events: Vec<HiddenVettingEvent>,
}
pub const DEFAULT_TICK_LENGTH: &str = "P3D";
fn default_tick_length() -> String {
DEFAULT_TICK_LENGTH.to_string()
}
#[must_use]
pub fn parse_tick_length(text: &str) -> Option<chrono::Duration> {
let rest = text.strip_prefix('P')?;
let (days, hours) = match rest.split_once('T') {
Some((d, h)) => (d, Some(h.strip_suffix('H')?)),
None => (rest, None),
};
let days: i64 = if days.is_empty() {
0
} else {
days.strip_suffix('D')?.parse().ok()?
};
let hours: i64 = match hours {
Some(h) => h.parse().ok()?,
None => 0,
};
let length = chrono::Duration::days(days) + chrono::Duration::hours(hours);
(length >= chrono::Duration::hours(1)).then_some(length)
}
impl HiddenVettingConfig {
#[must_use]
pub fn label_start(&self, label: &str) -> Option<DateTime<Utc>> {
let day = if let Some(id) = label.strip_prefix("token/event/") {
self.events.iter().find(|e| e.event_id == id)?.start_date
} else {
let period = label.strip_prefix("token/")?;
NaiveDate::parse_from_str(&format!("{period}-01"), "%Y-%m-%d").ok()?
};
Some(day.and_hms_opt(0, 0, 0)?.and_utc())
}
#[must_use]
pub fn current_tick(&self, label: &str, now: DateTime<Utc>) -> Option<u64> {
let start = self.label_start(label)?;
let length = parse_tick_length(&self.tick_length)?;
if now < start {
return None;
}
let elapsed = (now - start).num_seconds();
u64::try_from(elapsed / length.num_seconds()).ok()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HiddenVettingEvent {
pub event_id: String,
pub start_date: NaiveDate,
pub end_date: NaiveDate,
#[serde(default = "default_event_grace_days")]
pub grace_days: u32,
#[serde(default = "default_group_floor")]
pub group_floor: usize,
pub tiers: Vec<HiddenVettingTier>,
#[serde(default)]
pub approved_by: Option<String>,
}
impl HiddenVettingEvent {
#[must_use]
pub fn label(&self) -> String {
format!("token/event/{}", self.event_id)
}
#[must_use]
pub fn closes_after(&self) -> NaiveDate {
self.end_date + chrono::Duration::days(i64::from(self.grace_days))
}
#[must_use]
pub fn tier(&self, name: &str) -> Option<&HiddenVettingTier> {
self.tiers.iter().find(|t| t.name == name)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HiddenVettingTier {
pub name: String,
pub drip_per_tick: usize,
}
fn default_event_grace_days() -> u32 {
14
}
fn default_group_floor() -> usize {
3
}
fn default_drip_per_tick() -> usize {
3
}
pub const HIDDEN_VETTING_NS: &str = "org.openvtc.hidden-vetting";
impl HiddenVettingConfig {
#[must_use]
pub fn published(&self) -> serde_json::Value {
serde_json::json!({
"suite": self.suite,
"helperKey": self.hvk,
"tokenKey": self.tvk,
"vetterLabels": self
.live_periods
.iter()
.map(|p| format!("vetter/{p}"))
.collect::<Vec<_>>(),
"tokenLabels": self.live_token_labels,
"dripPerTick": self.drip_per_tick,
"tickLength": self.tick_length,
"events": self
.events
.iter()
.map(|e| serde_json::json!({
"eventId": e.event_id,
"startDate": e.start_date,
"endDate": e.end_date,
"groupFloor": e.group_floor,
"tiers": e
.tiers
.iter()
.map(|t| serde_json::json!({
"name": t.name,
"dripPerTick": t.drip_per_tick,
}))
.collect::<Vec<_>>(),
}))
.collect::<Vec<_>>(),
})
}
}
#[must_use]
fn accepted_token_labels(config: &HiddenVettingConfig, now: DateTime<Utc>) -> Vec<String> {
let today = now.date_naive();
config
.live_token_labels
.iter()
.filter(|label| {
super::pcs_event::event_of(config, label).is_none_or(|e| today <= e.closes_after())
})
.cloned()
.collect()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SpentToken {
id: String,
tag: String,
spent_at: DateTime<Utc>,
}
fn spent_key(label: &str, serial: &str) -> Vec<u8> {
let mut h = Sha256::new();
h.update(KEY_DOMAIN);
for part in [label, serial] {
h.update((part.len() as u64).to_le_bytes());
h.update(part.as_bytes());
}
h.finalize().to_vec()
}
struct PreloadedLedger {
known: HashMap<(String, String), SpentToken>,
taken: Vec<(String, String, SpentToken)>,
key: [u8; 32],
applicant: String,
}
impl PreloadedLedger {
fn new(key: [u8; 32], applicant: &str) -> Self {
Self {
known: HashMap::new(),
taken: Vec::new(),
key,
applicant: applicant.to_string(),
}
}
}
impl SpentLedger for PreloadedLedger {
fn into_any(self: Box<Self>) -> Box<dyn std::any::Any> {
self
}
fn record(
&mut self,
label: &str,
serial: &str,
id: &str,
tag: &str,
) -> Result<SpendOutcome, ProtoError> {
let id = mask(&self.key, &self.applicant, id)?;
let tag = mask(&self.key, &self.applicant, tag)?;
let key = (label.to_string(), serial.to_string());
if let Some(existing) = self.known.get(&key) {
return Ok(if existing.id == id && existing.tag == tag {
SpendOutcome::AlreadyCounted
} else {
SpendOutcome::DoubleSpend
});
}
let row = SpentToken {
id,
tag,
spent_at: Utc::now(),
};
self.known.insert(key.clone(), row.clone());
self.taken.push((key.0, key.1, row));
Ok(SpendOutcome::Fresh)
}
fn forget_label(&mut self, _label: &str) {}
}
#[derive(Debug)]
pub enum DecideError {
Challenge(ChallengeRefusal),
Proof(ProtoError),
Store(vti_common::error::AppError),
}
impl From<ProtoError> for DecideError {
fn from(e: ProtoError) -> Self {
Self::Proof(e)
}
}
impl From<predicate_credential_system::Error> for DecideError {
fn from(e: predicate_credential_system::Error) -> Self {
Self::Proof(e.into())
}
}
impl std::fmt::Display for DecideError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Challenge(r) => r.fmt(f),
Self::Proof(e) => e.fmt(f),
Self::Store(e) => e.fmt(f),
}
}
}
pub async fn decide(
state: &AppState,
community_did: &str,
applicant_did: &str,
requirements: &vta_sdk::protocols::vetting::VettingRequirements,
requirements_digest: &str,
config: &HiddenVettingConfig,
extensions: &serde_json::Value,
now: DateTime<Utc>,
) -> Result<Option<Decision>, DecideError> {
use predicate_credential_system::serialization::{from_bytes, from_multibase};
let Some(wire) = SubmissionWire::from_extensions(extensions)? else {
return Ok(None);
};
if config.suite != vti_vetting_pcs::wire::SUITE {
return Err(ProtoError::Serialization(format!(
"unknown hidden-vetting suite {}",
config.suite
))
.into());
}
let submission: Submission = wire.to_submission()?;
super::pcs_challenge::consume(
&state.join_requests_ks,
applicant_did,
&submission.challenge,
now,
)
.await
.map_err(|e| match e {
ConsumeError::Refused(r) => DecideError::Challenge(r),
ConsumeError::Store(e) => DecideError::Store(e),
})?;
let mask_key = mask_key(state)?;
let mut ledger = PreloadedLedger::new(mask_key, applicant_did);
for (_, token) in &submission.statements {
let serial = vti_vetting_pcs::scheme::scalar_text(&token.serial)?;
let key = spent_key(&token.label, &serial);
let row = state
.vetting_pcs_spent_ks
.get_raw(key)
.await
.map_err(|e| ProtoError::Serialization(format!("spent-token store: {e}")))?;
if let Some(bytes) = row {
let row: SpentToken = serde_json::from_slice(&bytes)
.map_err(|e| ProtoError::Serialization(e.to_string()))?;
ledger.known.insert((token.label.clone(), serial), row);
}
}
let hvk = from_bytes(&from_multibase(&config.hvk)?)?;
let tvk = from_bytes(&from_multibase(&config.tvk)?)?;
let accepted_labels = accepted_token_labels(config, now);
let tokens = TokenVerifier::new(community_did, tvk, accepted_labels.clone())?;
let mut verifier = Verifier::new(
VerifierParams {
community: community_did.to_string(),
audience: community_did.to_string(),
requirements: requirements.clone(),
requirements_digest: requirements_digest.to_string(),
},
hvk,
tokens,
config.live_periods.clone(),
)?;
let ledger = Box::new(ledger);
verifier.tokens.set_ledger(ledger);
let mut decision = verifier.submit(&submission, now)?;
explain_statements(
applicant_did,
requirements_digest,
&accepted_labels,
&submission,
&mut decision,
)?;
for statement in &mut decision.statements {
statement.issuer = mask(&mask_key, applicant_did, &statement.issuer)?;
}
let taken = verifier
.tokens
.take_ledger()
.downcast::<PreloadedLedger>()
.map(|l| l.taken)
.unwrap_or_default();
for (label, serial, row) in taken {
let bytes =
serde_json::to_vec(&row).map_err(|e| ProtoError::Serialization(e.to_string()))?;
let inserted = state
.vetting_pcs_spent_ks
.insert_raw_if_absent(spent_key(&label, &serial), bytes)
.await
.map_err(|e| ProtoError::Serialization(format!("spent-token store: {e}")))?;
if !inserted {
return Err(ProtoError::Serialization(format!(
"token {serial} under {label} was spent concurrently; the submission is refused"
))
.into());
}
}
Ok(Some(decision))
}
pub const TOKEN_LABEL_NOT_LIVE: &str = "token-label-not-live";
fn explain_statements(
applicant_did: &str,
requirements_digest: &str,
accepted_labels: &[String],
submission: &Submission,
decision: &mut Decision,
) -> Result<(), ProtoError> {
let mut stale_labels = HashMap::new();
let mut stale_digests = Vec::new();
for (meta, spend) in &submission.statements {
if !accepted_labels.contains(&spend.label) {
stale_labels.insert(meta.digest()?, spend.label.clone());
}
if meta.requirements_digest != requirements_digest
&& !stale_digests.contains(&meta.requirements_digest)
{
stale_digests.push(meta.requirements_digest.clone());
}
}
for statement in &mut decision.statements {
if stale_labels.contains_key(&statement.id) {
for failure in &mut statement.failures {
if failure == "no-token" {
*failure = TOKEN_LABEL_NOT_LIVE.to_string();
}
}
}
}
if !stale_labels.is_empty() {
let mut labels: Vec<&String> = stale_labels.values().collect();
labels.sort();
labels.dedup();
tracing::info!(
applicant = %applicant_did,
labels = ?labels,
accepted = ?accepted_labels,
"hidden-vetting proof spends tokens under labels this community no longer accepts — \
those attestations are not counted"
);
}
if !stale_digests.is_empty() {
tracing::info!(
applicant = %applicant_did,
attested_under = ?stale_digests,
criterion_digest = %requirements_digest,
"hidden-vetting proof carries attestations made under another version of the \
criterion (its published parameters changed since) — those attestations are not \
counted"
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const FIXTURE: &str = include_str!("../../../vti-vetting-pcs/tests/fixtures/submission.json");
#[tokio::test]
async fn a_submission_spends_the_challenge_the_community_issued() {
use crate::test_support::TestVtc;
use chrono::Duration;
let f: serde_json::Value = serde_json::from_str(FIXTURE).expect("fixture parses");
let community = f["community"].as_str().unwrap();
let requirements = serde_json::from_value(f["requirements"].clone()).unwrap();
let digest = f["requirementsDigest"].as_str().unwrap();
let now: DateTime<Utc> = f["now"].as_str().unwrap().parse().unwrap();
let extensions = f["extensions"].clone();
let hidden = &extensions["hiddenVetting"];
let applicant = hidden["joinDid"].as_str().unwrap();
let challenge = hidden["challenge"].as_str().unwrap();
let config = HiddenVettingConfig {
suite: f["extensions"]["hiddenVetting"]["suite"]
.as_str()
.unwrap()
.to_string(),
hvk: f["hvk"].as_str().unwrap().to_string(),
tvk: f["tvk"].as_str().unwrap().to_string(),
live_periods: serde_json::from_value(f["livePeriods"].clone()).unwrap(),
live_token_labels: serde_json::from_value(f["liveTokenLabels"].clone()).unwrap(),
drip_per_tick: default_drip_per_tick(),
tick_length: default_tick_length(),
events: Vec::new(),
};
let tv = TestVtc::builder()
.vtc_did(community)
.with_signers(true)
.build()
.await;
let err = decide(
&tv.state,
community,
applicant,
&requirements,
digest,
&config,
&extensions,
now,
)
.await
.expect_err("this community issued no challenge");
assert!(
matches!(err, DecideError::Challenge(ChallengeRefusal::NotIssued)),
"{err}"
);
super::super::pcs_challenge::record(
&tv.state.join_requests_ks,
applicant,
challenge,
Duration::minutes(15),
now,
)
.await
.unwrap();
let decision = decide(
&tv.state,
community,
applicant,
&requirements,
digest,
&config,
&extensions,
now,
)
.await
.expect("the proof verifies under the published parameters")
.expect("the submission carries a hidden-vetting proof");
assert!(decision.evaluation.satisfied(), "{:?}", decision.evaluation);
assert_eq!(
decision.evaluation.distinct_vetters(),
f["expect"]["distinctVetters"].as_u64().unwrap() as usize
);
let issuers: std::collections::HashSet<&str> = decision
.statements
.iter()
.map(|s| s.issuer.as_str())
.collect();
assert_eq!(issuers.len(), decision.statements.len(), "{issuers:?}");
let key = mask_key(&tv.state).unwrap();
for s in &decision.statements {
assert!(s.issuer.starts_with('z'), "{}", s.issuer);
assert_ne!(s.issuer, applicant);
assert_ne!(s.issuer, mask(&key, applicant, &s.issuer).unwrap());
}
let err = decide(
&tv.state,
community,
applicant,
&requirements,
digest,
&config,
&extensions,
now,
)
.await
.expect_err("the challenge is spent");
assert!(
matches!(err, DecideError::Challenge(ChallengeRefusal::AlreadyUsed)),
"{err}"
);
}
#[tokio::test]
async fn a_token_under_a_label_no_longer_live_is_named_as_such() {
use crate::test_support::TestVtc;
use chrono::Duration;
let f: serde_json::Value = serde_json::from_str(FIXTURE).expect("fixture parses");
let community = f["community"].as_str().unwrap();
let requirements = serde_json::from_value(f["requirements"].clone()).unwrap();
let digest = f["requirementsDigest"].as_str().unwrap();
let now: DateTime<Utc> = f["now"].as_str().unwrap().parse().unwrap();
let extensions = f["extensions"].clone();
let hidden = &extensions["hiddenVetting"];
let applicant = hidden["joinDid"].as_str().unwrap();
let challenge = hidden["challenge"].as_str().unwrap();
let config = HiddenVettingConfig {
suite: hidden["suite"].as_str().unwrap().to_string(),
hvk: f["hvk"].as_str().unwrap().to_string(),
tvk: f["tvk"].as_str().unwrap().to_string(),
live_periods: serde_json::from_value(f["livePeriods"].clone()).unwrap(),
live_token_labels: vec!["token/1999-01".into()],
drip_per_tick: default_drip_per_tick(),
tick_length: default_tick_length(),
events: Vec::new(),
};
let tv = TestVtc::builder()
.vtc_did(community)
.with_signers(true)
.build()
.await;
super::super::pcs_challenge::record(
&tv.state.join_requests_ks,
applicant,
challenge,
Duration::minutes(15),
now,
)
.await
.unwrap();
let decision = decide(
&tv.state,
community,
applicant,
&requirements,
digest,
&config,
&extensions,
now,
)
.await
.expect("the proof itself verifies")
.expect("the submission carries a proof");
assert!(
!decision.evaluation.satisfied(),
"{:?}",
decision.evaluation
);
assert!(!decision.statements.is_empty());
for s in &decision.statements {
assert!(!s.counted);
assert!(
s.failures.iter().any(|f| f == TOKEN_LABEL_NOT_LIVE),
"{:?}",
s.failures
);
assert!(
!s.failures.iter().any(|f| f == "no-token"),
"{:?}",
s.failures
);
}
}
#[test]
fn a_masked_tag_is_stable_per_applicant_and_nowhere_else() {
let a = mask(&MASK_KEY, APPLICANT, "zTagA").unwrap();
assert_eq!(a, mask(&MASK_KEY, APPLICANT, "zTagA").unwrap());
assert!(a.starts_with('z'), "{a}");
assert!(!a.contains("zTagA"));
assert_ne!(a, mask(&MASK_KEY, APPLICANT, "zTagB").unwrap());
assert_ne!(
a,
mask(&MASK_KEY, "did:key:z6MkSomeoneElse", "zTagA").unwrap()
);
assert_ne!(a, mask(&[8u8; 32], APPLICANT, "zTagA").unwrap());
}
#[test]
fn the_extensions_member_matches_the_crates_own() {
assert_eq!(
super::super::HIDDEN_VETTING_MEMBER,
vti_vetting_pcs::wire::EXTENSIONS_MEMBER
);
}
#[test]
fn spent_keys_separate_labels_and_serials() {
let a = spent_key("token/2026-09", "zSerial");
assert_ne!(a, spent_key("token/2026-10", "zSerial"));
assert_ne!(a, spent_key("token/2026-09", "zOther"));
assert_eq!(a, spent_key("token/2026-09", "zSerial"));
assert_ne!(
spent_key("token/2026-09z", "Serial"),
spent_key("token/2026-09", "zSerial")
);
}
const MASK_KEY: [u8; 32] = [9u8; 32];
const APPLICANT: &str = "did:key:z6MkBobsJoinDid";
#[test]
fn a_preloaded_row_for_another_applicant_is_a_double_spend() {
let mut ledger = PreloadedLedger::new(MASK_KEY, APPLICANT);
ledger.known.insert(
("token/2026-09".into(), "zSerial".into()),
SpentToken {
id: mask(&MASK_KEY, APPLICANT, "zAlice").unwrap(),
tag: mask(&MASK_KEY, APPLICANT, "zTagA").unwrap(),
spent_at: Utc::now(),
},
);
assert_eq!(
ledger
.record("token/2026-09", "zSerial", "zBob", "zTagA")
.unwrap(),
SpendOutcome::DoubleSpend
);
assert_eq!(
ledger
.record("token/2026-09", "zSerial", "zAlice", "zTagA")
.unwrap(),
SpendOutcome::AlreadyCounted
);
assert!(ledger.taken.is_empty());
assert_eq!(
ledger
.record("token/2026-09", "zFresh", "zAlice", "zTagB")
.unwrap(),
SpendOutcome::Fresh
);
assert_eq!(ledger.taken.len(), 1);
}
fn config_with(events: Vec<HiddenVettingEvent>, labels: &[&str]) -> HiddenVettingConfig {
HiddenVettingConfig {
suite: vti_vetting_pcs::wire::SUITE.into(),
hvk: "zHvk".into(),
tvk: "zTvk".into(),
live_periods: vec!["2026-09".into()],
live_token_labels: labels.iter().map(|s| (*s).to_string()).collect(),
drip_per_tick: 3,
tick_length: default_tick_length(),
events,
}
}
fn event(id: &str, ends: chrono::NaiveDate, grace: u32) -> HiddenVettingEvent {
HiddenVettingEvent {
event_id: id.into(),
start_date: ends - chrono::Duration::days(2),
end_date: ends,
grace_days: grace,
group_floor: 3,
tiers: vec![HiddenVettingTier {
name: "desk".into(),
drip_per_tick: 20,
}],
approved_by: Some("did:key:zApprover".into()),
}
}
#[test]
fn an_event_label_stops_being_accepted_when_its_grace_runs_out() {
let today = chrono::Utc::now().date_naive();
let config = config_with(
vec![
event("open-summit", today, 14),
event("last-years-summit", today - chrono::Duration::days(30), 14),
],
&[
"token/2026-09",
"token/event/open-summit",
"token/event/last-years-summit",
],
);
let accepted = accepted_token_labels(&config, chrono::Utc::now());
assert_eq!(
accepted,
vec![
"token/2026-09".to_string(),
"token/event/open-summit".to_string()
],
"the closed event's label is dropped though the operator still lists it"
);
}
#[test]
fn the_grace_period_includes_its_last_day() {
let today = chrono::Utc::now().date_naive();
let closing = config_with(
vec![event("summit", today - chrono::Duration::days(14), 14)],
&["token/event/summit"],
);
assert_eq!(
accepted_token_labels(&closing, chrono::Utc::now()),
vec!["token/event/summit".to_string()],
);
let closed = config_with(
vec![event("summit", today - chrono::Duration::days(15), 14)],
&["token/event/summit"],
);
assert!(accepted_token_labels(&closed, chrono::Utc::now()).is_empty());
}
#[test]
fn tick_lengths_are_days_and_hours_of_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))
);
for bad in ["", "P", "PT0H", "P0D", "P1W", "P1M", "3D", "PT30M"] {
assert_eq!(parse_tick_length(bad), None, "{bad}");
}
}
#[test]
fn a_label_is_in_the_tick_its_window_says() {
let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
let config = config_with(Vec::new(), &["token/2026-10"]);
assert_eq!(
config.current_tick("token/2026-10", at("2026-10-01T00:00:00Z")),
Some(0)
);
assert_eq!(
config.current_tick("token/2026-10", at("2026-10-03T23:59:59Z")),
Some(0)
);
assert_eq!(
config.current_tick("token/2026-10", at("2026-10-04T00:00:00Z")),
Some(1)
);
assert_eq!(
config.current_tick("token/2026-10", at("2026-09-30T23:59:59Z")),
None
);
let summit = HiddenVettingEvent {
event_id: "summit".into(),
start_date: NaiveDate::from_ymd_opt(2026, 10, 14).unwrap(),
end_date: NaiveDate::from_ymd_opt(2026, 10, 16).unwrap(),
grace_days: 14,
group_floor: 3,
tiers: Vec::new(),
approved_by: None,
};
let mut config = config_with(vec![summit], &["token/event/summit"]);
config.tick_length = "PT12H".into();
assert_eq!(
config.current_tick("token/event/summit", at("2026-10-13T12:00:00Z")),
None
);
assert_eq!(
config.current_tick("token/event/summit", at("2026-10-14T13:00:00Z")),
Some(1)
);
assert_eq!(
config.current_tick("token/event/nope", at("2026-10-14T13:00:00Z")),
None
);
}
#[test]
fn a_label_with_no_event_behind_it_is_left_alone() {
let config = config_with(Vec::new(), &["token/2026-09", "token/event/who-knows"]);
assert_eq!(
accepted_token_labels(&config, chrono::Utc::now()).len(),
2,
"an event label with no configuration behind it is not silently dropped"
);
}
}