use std::fmt::Debug;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::core::{EffectKey, Spend, StoreError, Timestamp};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct AuthorityId(pub String);
impl AuthorityId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for AuthorityId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StandingAuthority {
pub id: AuthorityId,
pub basis: String,
pub ceiling: Spend,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_draws: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<Timestamp>,
}
impl StandingAuthority {
#[must_use]
pub fn new(id: impl Into<String>, basis: impl Into<String>, ceiling: Spend) -> Self {
Self {
id: AuthorityId::new(id),
basis: basis.into(),
ceiling,
max_draws: None,
expires_at: None,
}
}
#[must_use]
pub const fn max_draws(mut self, n: u32) -> Self {
self.max_draws = Some(n);
self
}
#[must_use]
pub const fn expires_at(mut self, at: Timestamp) -> Self {
self.expires_at = Some(at);
self
}
pub fn validate(&self) -> Result<(), AuthorityError> {
if self.id.as_str().trim().is_empty() {
return Err(AuthorityError::Malformed("the authority has no id"));
}
if self.basis.trim().is_empty() {
return Err(AuthorityError::Malformed(
"the authority states no basis — a ceiling nobody can trace to a decision \
is the thing an audit asks for first",
));
}
if self.ceiling.is_zero() {
return Err(AuthorityError::Malformed(
"the authority permits nothing — issue a ceiling, or do not issue it. \
Zero and unlimited are opposite readings of the same silence",
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthorityState {
pub authority: StandingAuthority,
pub drawn: Spend,
pub draws: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revoked: Option<Revocation>,
}
impl AuthorityState {
#[must_use]
pub fn remaining(&self) -> Spend {
Spend {
tokens: self
.authority
.ceiling
.tokens
.saturating_sub(self.drawn.tokens),
minor_units: self
.authority
.ceiling
.minor_units
.saturating_sub(self.drawn.minor_units),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Revocation {
pub at: Timestamp,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Drawn {
pub authority: AuthorityId,
pub amount: Spend,
pub remaining: Spend,
pub draws: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum AuthorityError {
#[error(
"no standing authority '{0}' — it was never issued, or it belongs to \
another tenant"
)]
Unknown(AuthorityId),
#[error(
"standing authority '{authority}' has {remaining:?} left and the draw asked \
for {asked:?} — this does not replenish; issue another authority if more \
was intended"
)]
Exhausted {
authority: AuthorityId,
asked: Spend,
remaining: Spend,
},
#[error(
"standing authority '{authority}' permitted {allowed} draws and has taken \
them all"
)]
DrawsSpent {
authority: AuthorityId,
allowed: u32,
},
#[error("standing authority '{authority}' was revoked: {reason}")]
Revoked {
authority: AuthorityId,
reason: String,
},
#[error("standing authority '{authority}' expired at {expired_at}")]
Expired {
authority: AuthorityId,
expired_at: Timestamp,
},
#[error("the standing authority is not well formed: {0}")]
Malformed(&'static str),
#[error(
"standing authority '{0}' is already issued with different terms — a \
ceiling somebody agreed to must not be editable under them; revoke it \
and issue another"
)]
AlreadyIssued(AuthorityId),
#[error("the standing-authority store is unavailable: {0}")]
Unavailable(String),
}
impl From<StoreError> for AuthorityError {
fn from(e: StoreError) -> Self {
Self::Unavailable(e.to_string())
}
}
#[async_trait]
pub trait AuthorityStore: Send + Sync + Debug {
async fn issue(&self, authority: &StandingAuthority) -> Result<(), AuthorityError>;
async fn draw(
&self,
id: &AuthorityId,
key: EffectKey,
amount: Spend,
at: Timestamp,
) -> Result<Drawn, AuthorityError>;
async fn revoke(
&self,
id: &AuthorityId,
reason: &str,
at: Timestamp,
) -> Result<(), AuthorityError>;
async fn state(&self, id: &AuthorityId) -> Result<Option<AuthorityState>, StoreError>;
}
pub fn permits(
authority: &StandingAuthority,
amount: Spend,
drawn: Spend,
taken: u32,
revoked: Option<&str>,
now: i64,
) -> Result<Spend, AuthorityError> {
let id = || authority.id.clone();
if let Some(reason) = revoked {
return Err(AuthorityError::Revoked {
authority: id(),
reason: reason.to_owned(),
});
}
if let Some(expires) = authority.expires_at
&& now >= expires.unix_timestamp()
{
return Err(AuthorityError::Expired {
authority: id(),
expired_at: expires,
});
}
if let Some(allowed) = authority.max_draws
&& taken >= allowed
{
return Err(AuthorityError::DrawsSpent {
authority: id(),
allowed,
});
}
let remaining = Spend {
tokens: authority.ceiling.tokens.saturating_sub(drawn.tokens),
minor_units: authority
.ceiling
.minor_units
.saturating_sub(drawn.minor_units),
};
if amount.tokens > remaining.tokens || amount.minor_units > remaining.minor_units {
return Err(AuthorityError::Exhausted {
authority: id(),
asked: amount,
remaining,
});
}
Ok(remaining)
}
#[cfg(test)]
mod tests {
use super::*;
fn sound() -> StandingAuthority {
StandingAuthority::new("mandate-42", "approval:SET-42", Spend::money(50_000))
}
#[test]
fn a_malformed_authority_is_refused_and_a_sound_one_is_not() {
sound().validate().expect("the baseline is sound");
let mut no_id = sound();
no_id.id = AuthorityId::new(" ");
assert!(matches!(
no_id.validate(),
Err(AuthorityError::Malformed(_))
));
let mut no_basis = sound();
no_basis.basis = "\t".to_owned();
assert!(matches!(
no_basis.validate(),
Err(AuthorityError::Malformed(_))
));
let mut nothing = sound();
nothing.ceiling = Spend::default();
assert!(
matches!(nothing.validate(), Err(AuthorityError::Malformed(_))),
"a zero ceiling must be refused, not read as unlimited"
);
}
#[test]
fn remaining_never_reports_more_than_was_authorized() {
let state = AuthorityState {
authority: sound(),
drawn: Spend::money(50_001),
draws: 1,
revoked: None,
};
assert_eq!(state.remaining(), Spend::default());
let half = AuthorityState {
authority: sound(),
drawn: Spend::money(20_000),
draws: 1,
revoked: None,
};
assert_eq!(half.remaining(), Spend::money(30_000));
}
}