use crate::auth::Auth;
use crate::error::{ClientError, ClientResult};
use crate::keystore::Signer;
use crate::proto::guardian_client::GuardianClient as GuardianGrpcClient;
use crate::proto::{
AbandonDeltaCandidateRequest, AbandonDeltaCandidateResponse, AuthConfig, ConfigureRequest,
ConfigureResponse, GetAccountByKeyCommitmentRequest, GetAccountByKeyCommitmentResponse,
GetCanonicalNonceRequest, GetCanonicalNonceResponse, GetDeltaHistoryRequest,
GetDeltaHistoryResponse, GetDeltaProposalRequest, GetDeltaProposalResponse,
GetDeltaProposalsRequest, GetDeltaProposalsResponse, GetDeltaRequest, GetDeltaResponse,
GetDeltaSinceRequest, GetDeltaSinceResponse, GetPubkeyRequest, GetStateRequest,
GetStateResponse, ProposalSignature as ProtoProposalSignature, PushDeltaProposalRequest,
PushDeltaProposalResponse, PushDeltaRequest, PushDeltaResponse, RevokeAllSessionsRequest,
RevokeSessionRequest, SessionGrantFields, SignDeltaProposalRequest, SignDeltaProposalResponse,
};
use chrono::Utc;
use guardian_shared::ProposalSignature as JsonProposalSignature;
use guardian_shared::auth_request_message::AuthRequestMessage;
use guardian_shared::auth_request_payload::AuthRequestPayload;
use guardian_shared::hex::{FromHex, IntoHex};
use guardian_shared::lookup_auth_message::LookupAuthMessage;
use guardian_shared::session_grant::{SessionGrant, SessionLogoutMessage, SessionRevokeAllMessage};
use guardian_shared::session_key::SessionKey;
use miden_protocol::Word;
use miden_protocol::account::AccountId;
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::time::Duration;
use tonic::metadata::MetadataValue;
use tonic::transport::Channel;
const REPLAY_RETRY_LIMIT: u32 = 2;
const REPLAY_RETRY_BACKOFF: Duration = Duration::from_millis(50);
pub struct GuardianClient {
client: GuardianGrpcClient<Channel>,
auth: Option<Auth>,
signer: Option<Arc<dyn Signer>>,
session: SessionSlot,
last_timestamp: AtomicI64,
}
#[derive(Debug, Clone)]
pub struct StartSessionOptions {
pub network: String,
pub ttl: Duration,
}
pub const MIN_SESSION_TTL: Duration = Duration::from_secs(300);
pub const MAX_SESSION_TTL: Duration = Duration::from_secs(8 * 60 * 60);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionInfo {
pub signer_commitment: String,
pub session_public_key: String,
pub expires_at: u64,
}
struct ActiveSession {
key: SessionKey,
info: SessionInfo,
}
#[derive(Clone, Default)]
pub struct SessionSlot(Arc<Mutex<Option<ActiveSession>>>);
impl SessionSlot {
pub fn info(&self) -> Option<SessionInfo> {
let now = Utc::now().timestamp().max(0) as u64;
self.lock()
.as_ref()
.filter(|session| now + SESSION_EXPIRY_MARGIN_SECS < session.info.expires_at)
.map(|session| session.info.clone())
}
fn lock(&self) -> MutexGuard<'_, Option<ActiveSession>> {
self.0.lock().unwrap_or_else(PoisonError::into_inner)
}
fn clear_if(&self, ended: impl FnOnce(&ActiveSession) -> bool) {
let mut session = self.lock();
if session.as_ref().is_some_and(ended) {
*session = None;
}
}
fn clear_key(&self, session_public_key: &str) {
self.clear_if(|session| session.info.session_public_key == session_public_key);
}
}
const SESSION_EXPIRY_MARGIN_SECS: u64 = 30;
#[derive(Clone, Copy, PartialEq, Eq)]
enum AuthMode {
Session,
Wallet,
}
impl GuardianClient {
pub async fn connect(endpoint: impl Into<String>) -> ClientResult<Self> {
let endpoint = endpoint.into();
let client = GuardianGrpcClient::connect(endpoint).await?;
Ok(Self {
client,
auth: None,
signer: None,
session: SessionSlot::default(),
last_timestamp: AtomicI64::new(0),
})
}
fn next_timestamp(&self) -> i64 {
next_monotonic_timestamp(&self.last_timestamp, Utc::now().timestamp_millis())
}
pub fn with_auth(mut self, auth: Auth) -> Self {
self.auth = Some(auth);
self
}
pub fn with_signer(mut self, signer: Arc<dyn Signer>) -> Self {
self.signer = Some(signer);
self
}
pub fn auth_pubkey_hex(&self) -> Result<String, ClientError> {
self.auth
.as_ref()
.map(|auth| auth.public_key_hex())
.or_else(|| self.signer.as_ref().map(|signer| signer.public_key_hex()))
.ok_or_else(|| {
ClientError::InvalidResponse("GUARDIAN client has no signer configured".to_string())
})
}
pub fn signer_pubkey_hex(&self) -> Result<String, ClientError> {
self.auth_pubkey_hex()
}
fn add_auth_metadata(
&self,
request: &mut tonic::Request<impl prost::Message + std::fmt::Debug>,
account_id: &AccountId,
mode: AuthMode,
) -> ClientResult<Option<String>> {
let request_payload = AuthRequestPayload::from_protobuf_message(request.get_ref());
let timestamp = self.next_timestamp();
if mode == AuthMode::Session {
let digest =
AuthRequestMessage::new(*account_id, timestamp, request_payload.clone()).to_word();
if let Some((session_public_key, signature_hex)) = self.with_active_session(|session| {
(
session.info.session_public_key.clone(),
session.key.sign_hex(digest),
)
}) {
attach_auth_headers(request, &session_public_key, &signature_hex, timestamp)?;
request
.metadata_mut()
.insert("x-auth-format", MetadataValue::from_static("session"));
return Ok(Some(session_public_key));
}
}
let (pubkey_hex, signature_hex) = if let Some(auth) = &self.auth {
let pubkey_hex = auth.public_key_hex();
let signature_hex = auth.sign_request_message(account_id, timestamp, request_payload);
(pubkey_hex, signature_hex)
} else if let Some(signer) = &self.signer {
let pubkey_hex = signer.public_key_hex();
let digest = AuthRequestMessage::new(*account_id, timestamp, request_payload).to_word();
let signature_hex = signer.sign_word_hex(digest);
(pubkey_hex, signature_hex)
} else {
return Ok(None);
};
attach_auth_headers(request, &pubkey_hex, &signature_hex, timestamp)?;
Ok(None)
}
fn add_lookup_auth_metadata<T: prost::Message + std::fmt::Debug>(
&self,
request: &mut tonic::Request<T>,
key_commitment: Word,
) -> ClientResult<()> {
let timestamp = self.next_timestamp();
let digest = LookupAuthMessage::new(timestamp, key_commitment).to_word();
let (pubkey_hex, signature_hex) = if let Some(auth) = &self.auth {
(auth.public_key_hex(), auth.sign_word_hex(digest))
} else if let Some(signer) = &self.signer {
(signer.public_key_hex(), signer.sign_word_hex(digest))
} else {
return Err(ClientError::InvalidResponse(
"GUARDIAN client has no signer configured".to_string(),
));
};
attach_auth_headers(request, &pubkey_hex, &signature_hex, timestamp)
}
async fn send_with_replay_retry<Req, Resp, F>(
&mut self,
account_id: &AccountId,
message: Req,
send: F,
) -> ClientResult<Resp>
where
Req: prost::Message + Clone + std::fmt::Debug,
F: AsyncFnMut(
&mut GuardianGrpcClient<Channel>,
tonic::Request<Req>,
) -> Result<tonic::Response<Resp>, tonic::Status>,
{
self.send_with_replay_retry_as(account_id, message, AuthMode::Wallet, send)
.await
}
async fn send_allowing_session<Req, Resp, F>(
&mut self,
account_id: &AccountId,
message: Req,
send: F,
) -> ClientResult<Resp>
where
Req: prost::Message + Clone + std::fmt::Debug,
F: AsyncFnMut(
&mut GuardianGrpcClient<Channel>,
tonic::Request<Req>,
) -> Result<tonic::Response<Resp>, tonic::Status>,
{
self.send_with_replay_retry_as(account_id, message, AuthMode::Session, send)
.await
}
async fn send_with_replay_retry_as<Req, Resp, F>(
&mut self,
account_id: &AccountId,
message: Req,
mode: AuthMode,
mut send: F,
) -> ClientResult<Resp>
where
Req: prost::Message + Clone + std::fmt::Debug,
F: AsyncFnMut(
&mut GuardianGrpcClient<Channel>,
tonic::Request<Req>,
) -> Result<tonic::Response<Resp>, tonic::Status>,
{
self.discard_expired_session();
let mut retries_left = REPLAY_RETRY_LIMIT;
loop {
let mut request = tonic::Request::new(message.clone());
let session_key = self.add_auth_metadata(&mut request, account_id, mode)?;
match send(&mut self.client, request).await {
Ok(response) => return Ok(response.into_inner()),
Err(status) => {
let error = ClientError::from(status);
if let Some(session_public_key) = &session_key
&& error.is_rejected_session()
{
self.session.clear_key(session_public_key);
}
if retries_left == 0 || !error.is_replay_rejection() {
return Err(error);
}
retries_left -= 1;
tokio::time::sleep(REPLAY_RETRY_BACKOFF).await;
}
}
}
}
pub async fn start_session(
&mut self,
options: StartSessionOptions,
) -> ClientResult<SessionInfo> {
let ttl_secs = options.ttl.as_secs();
if ttl_secs <= MIN_SESSION_TTL.as_secs() || ttl_secs > MAX_SESSION_TTL.as_secs() {
return Err(ClientError::InvalidResponse(format!(
"Session lifetime must be more than {}s and at most {}s",
MIN_SESSION_TTL.as_secs(),
MAX_SESSION_TTL.as_secs()
)));
}
let scheme = self.wallet()?.scheme();
let guardian_commitment = self.get_pubkey(Some(scheme.as_str())).await?.0;
let issued_at = Utc::now().timestamp().max(0) as u64;
let expires_at = issued_at + ttl_secs;
let key = SessionKey::generate();
let wallet = self.wallet()?;
let commitment = wallet.commitment();
let grant = SessionGrant::new(
commitment,
&key.public_key(),
"",
issued_at,
expires_at,
Word::from_hex(&guardian_commitment).map_err(|e| {
ClientError::InvalidResponse(format!("Invalid Guardian commitment: {e}"))
})?,
options.network.as_str(),
)
.map_err(ClientError::InvalidResponse)?;
let request = crate::proto::CreateSessionRequest {
scheme: scheme.as_str().to_string(),
auth_format: None,
public_key: matches!(scheme, guardian_shared::SignatureScheme::Ecdsa)
.then(|| wallet.public_key_hex()),
signature: wallet.sign_word_hex(grant.to_word()),
grant: Some(SessionGrantFields {
signer_commitment: commitment.into_hex(),
session_public_key: key.public_key_hex(),
issued_at,
expires_at,
guardian_commitment,
network: options.network,
origin: String::new(),
}),
};
let response = self
.client
.create_session(tonic::Request::new(request))
.await?
.into_inner();
let info = SessionInfo {
signer_commitment: response.signer_commitment,
session_public_key: key.public_key_hex(),
expires_at,
};
*self.session.lock() = Some(ActiveSession {
key,
info: info.clone(),
});
Ok(info)
}
pub fn session(&self) -> Option<SessionInfo> {
self.with_active_session(|session| session.info.clone())
}
pub fn with_session_slot(mut self, slot: SessionSlot) -> Self {
self.session = slot;
self
}
pub fn session_slot(&self) -> SessionSlot {
self.session.clone()
}
pub async fn revoke_session(&mut self) -> ClientResult<bool> {
let timestamp = self.next_timestamp();
let Some((session_public_key, signature_hex)) =
self.session.lock().as_ref().map(|session| {
let digest =
SessionLogoutMessage::new(&session.key.public_key(), timestamp).to_word();
(
session.info.session_public_key.clone(),
session.key.sign_hex(digest),
)
})
else {
return Ok(false);
};
let mut request = tonic::Request::new(RevokeSessionRequest {});
attach_auth_headers(&mut request, &session_public_key, &signature_hex, timestamp)?;
let revoked = self
.client
.revoke_session(request)
.await?
.into_inner()
.revoked;
self.session.clear_key(&session_public_key);
Ok(revoked)
}
pub async fn revoke_all_sessions(&mut self) -> ClientResult<u64> {
let timestamp = self.next_timestamp();
let (commitment, pubkey_hex, signature_hex) = {
let wallet = self.wallet()?;
let commitment = wallet.commitment();
let digest = SessionRevokeAllMessage::new(commitment, timestamp).to_word();
(
commitment,
wallet.public_key_hex(),
wallet.sign_word_hex(digest),
)
};
let commitment = commitment.into_hex();
let revoking = self.session.lock().as_ref().and_then(|session| {
session
.info
.signer_commitment
.eq_ignore_ascii_case(&commitment)
.then(|| session.info.session_public_key.clone())
});
let mut request = tonic::Request::new(RevokeAllSessionsRequest {
signer_commitment: commitment,
});
attach_auth_headers(&mut request, &pubkey_hex, &signature_hex, timestamp)?;
let revoked = self
.client
.revoke_all_sessions(request)
.await?
.into_inner()
.revoked;
if let Some(session_public_key) = revoking {
self.session.clear_key(&session_public_key);
}
Ok(revoked)
}
fn discard_expired_session(&mut self) {
let now = Utc::now().timestamp().max(0) as u64;
self.session
.clear_if(|session| now + SESSION_EXPIRY_MARGIN_SECS >= session.info.expires_at);
}
fn with_active_session<T>(&self, f: impl FnOnce(&ActiveSession) -> T) -> Option<T> {
let now = Utc::now().timestamp().max(0) as u64;
let wallet = self.wallet().ok()?.commitment_hex();
self.session
.lock()
.as_ref()
.filter(|session| {
now + SESSION_EXPIRY_MARGIN_SECS < session.info.expires_at
&& session.info.signer_commitment.eq_ignore_ascii_case(&wallet)
})
.map(f)
}
fn wallet(&self) -> ClientResult<&dyn Signer> {
self.auth
.as_ref()
.map(|auth| auth as &dyn Signer)
.or(self.signer.as_deref())
.ok_or_else(|| {
ClientError::InvalidResponse("GUARDIAN client has no signer configured".to_string())
})
}
pub async fn configure(
&mut self,
account_id: &AccountId,
auth: AuthConfig,
initial_state: impl serde::Serialize,
) -> ClientResult<ConfigureResponse> {
let initial_state_json = serde_json::to_string(&initial_state)?;
let message = ConfigureRequest {
account_id: account_id.to_string(),
auth: Some(auth),
initial_state: initial_state_json,
};
let inner = self
.send_with_replay_retry(account_id, message, async |client, request| {
client.configure(request).await
})
.await?;
if !inner.success {
return Err(ClientError::ServerError(inner.message.clone()));
}
Ok(inner)
}
pub async fn push_delta(
&mut self,
account_id: &AccountId,
nonce: u64,
prev_commitment: impl Into<String>,
delta_payload: impl serde::Serialize,
) -> ClientResult<PushDeltaResponse> {
let delta_payload_json = serde_json::to_string(&delta_payload)?;
let message = PushDeltaRequest {
account_id: account_id.to_string(),
nonce,
prev_commitment: prev_commitment.into(),
delta_payload: delta_payload_json,
};
let inner = self
.send_with_replay_retry(account_id, message, async |client, request| {
client.push_delta(request).await
})
.await?;
if !inner.success {
return Err(ClientError::ServerError(inner.message.clone()));
}
Ok(inner)
}
pub async fn get_delta(
&mut self,
account_id: &AccountId,
nonce: u64,
) -> ClientResult<GetDeltaResponse> {
let message = GetDeltaRequest {
account_id: account_id.to_string(),
nonce,
};
let inner = self
.send_allowing_session(account_id, message, async |client, request| {
client.get_delta(request).await
})
.await?;
if !inner.success {
return Err(ClientError::ServerError(inner.message.clone()));
}
Ok(inner)
}
pub async fn get_delta_since(
&mut self,
account_id: &AccountId,
from_nonce: u64,
) -> ClientResult<GetDeltaSinceResponse> {
let message = GetDeltaSinceRequest {
account_id: account_id.to_string(),
from_nonce,
};
let inner = self
.send_allowing_session(account_id, message, async |client, request| {
client.get_delta_since(request).await
})
.await?;
if !inner.success {
return Err(ClientError::ServerError(inner.message.clone()));
}
Ok(inner)
}
pub async fn get_delta_history(
&mut self,
account_id: &AccountId,
limit: Option<u32>,
cursor: Option<String>,
) -> ClientResult<GetDeltaHistoryResponse> {
let message = GetDeltaHistoryRequest {
account_id: account_id.to_string(),
limit,
cursor,
};
let inner = self
.send_allowing_session(account_id, message, async |client, request| {
client.get_delta_history(request).await
})
.await?;
if !inner.success {
return Err(ClientError::ServerError(inner.message.clone()));
}
Ok(inner)
}
pub async fn get_state(&mut self, account_id: &AccountId) -> ClientResult<GetStateResponse> {
let message = GetStateRequest {
account_id: account_id.to_string(),
};
let inner = self
.send_allowing_session(account_id, message, async |client, request| {
client.get_state(request).await
})
.await?;
if !inner.success {
return Err(ClientError::ServerError(inner.message.clone()));
}
Ok(inner)
}
pub async fn get_canonical_nonce(
&mut self,
account_id: &AccountId,
) -> ClientResult<GetCanonicalNonceResponse> {
let message = GetCanonicalNonceRequest {
account_id: account_id.to_string(),
};
let inner = self
.send_allowing_session(account_id, message, async |client, request| {
client.get_canonical_nonce(request).await
})
.await?;
if !inner.success {
return Err(ClientError::ServerError(inner.message.clone()));
}
Ok(inner)
}
pub async fn lookup_account_by_key_commitment(
&mut self,
key_commitment: &str,
) -> ClientResult<GetAccountByKeyCommitmentResponse> {
let key_commitment_word = Word::from_hex(key_commitment).map_err(|e| {
ClientError::InvalidResponse(format!("Invalid key_commitment hex: {e}"))
})?;
let mut request = tonic::Request::new(GetAccountByKeyCommitmentRequest {
key_commitment: key_commitment.to_string(),
});
self.add_lookup_auth_metadata(&mut request, key_commitment_word)?;
let response = self.client.get_account_by_key_commitment(request).await?;
Ok(response.into_inner())
}
pub async fn get_pubkey(
&mut self,
scheme: Option<&str>,
) -> ClientResult<(String, Option<String>)> {
let request = tonic::Request::new(GetPubkeyRequest {
scheme: scheme.map(|s| s.to_string()),
});
let response = self.client.get_pubkey(request).await?;
let inner = response.into_inner();
Ok((inner.pubkey, inner.raw_pubkey))
}
pub async fn push_delta_proposal(
&mut self,
account_id: &AccountId,
nonce: u64,
delta_payload: impl serde::Serialize,
) -> ClientResult<PushDeltaProposalResponse> {
let delta_payload_json = serde_json::to_string(&delta_payload)?;
let message = PushDeltaProposalRequest {
account_id: account_id.to_string(),
nonce,
delta_payload: delta_payload_json,
};
let inner = self
.send_allowing_session(account_id, message, async |client, request| {
client.push_delta_proposal(request).await
})
.await?;
if !inner.success {
return Err(ClientError::ServerError(inner.message.clone()));
}
Ok(inner)
}
pub async fn get_delta_proposals(
&mut self,
account_id: &AccountId,
) -> ClientResult<GetDeltaProposalsResponse> {
let message = GetDeltaProposalsRequest {
account_id: account_id.to_string(),
};
let inner = self
.send_allowing_session(account_id, message, async |client, request| {
client.get_delta_proposals(request).await
})
.await?;
if !inner.success {
return Err(ClientError::ServerError(inner.message.clone()));
}
Ok(inner)
}
pub async fn get_delta_proposal(
&mut self,
account_id: &AccountId,
commitment: impl Into<String>,
) -> ClientResult<GetDeltaProposalResponse> {
let message = GetDeltaProposalRequest {
account_id: account_id.to_string(),
commitment: commitment.into(),
};
let inner = self
.send_allowing_session(account_id, message, async |client, request| {
client.get_delta_proposal(request).await
})
.await?;
if !inner.success {
return Err(ClientError::ServerError(inner.message.clone()));
}
Ok(inner)
}
pub async fn sign_delta_proposal(
&mut self,
account_id: &AccountId,
commitment: impl Into<String>,
signature: JsonProposalSignature,
) -> ClientResult<SignDeltaProposalResponse> {
let proto_signature = Some(proto_signature_from_json(&signature));
let message = SignDeltaProposalRequest {
account_id: account_id.to_string(),
commitment: commitment.into(),
signature: proto_signature,
};
let inner = self
.send_allowing_session(account_id, message, async |client, request| {
client.sign_delta_proposal(request).await
})
.await?;
if !inner.success {
return Err(ClientError::ServerError(inner.message.clone()));
}
Ok(inner)
}
pub async fn abandon_candidate(
&mut self,
account_id: &AccountId,
nonce: u64,
) -> ClientResult<AbandonDeltaCandidateResponse> {
let message = AbandonDeltaCandidateRequest {
account_id: account_id.to_string(),
nonce,
};
let inner = self
.send_with_replay_retry(account_id, message, async |client, request| {
client.abandon_delta_candidate(request).await
})
.await?;
if !inner.success {
return Err(ClientError::ServerError(inner.message.clone()));
}
Ok(inner)
}
}
fn next_monotonic_timestamp(last_timestamp: &AtomicI64, now_ms: i64) -> i64 {
let mut previous = last_timestamp.load(Ordering::SeqCst);
loop {
let next = previous.saturating_add(1).max(now_ms);
match last_timestamp.compare_exchange_weak(
previous,
next,
Ordering::SeqCst,
Ordering::SeqCst,
) {
Ok(_) => return next,
Err(actual) => previous = actual,
}
}
}
fn attach_auth_headers<T: prost::Message>(
request: &mut tonic::Request<T>,
pubkey_hex: &str,
signature_hex: &str,
timestamp: i64,
) -> ClientResult<()> {
let pubkey_metadata = MetadataValue::try_from(pubkey_hex)
.map_err(|e| ClientError::InvalidResponse(format!("Invalid pubkey: {e}")))?;
let signature_metadata = MetadataValue::try_from(signature_hex)
.map_err(|e| ClientError::InvalidResponse(format!("Invalid signature: {e}")))?;
let timestamp_metadata = MetadataValue::try_from(timestamp.to_string())
.map_err(|e| ClientError::InvalidResponse(format!("Invalid timestamp: {e}")))?;
let metadata = request.metadata_mut();
metadata.insert("x-pubkey", pubkey_metadata);
metadata.insert("x-signature", signature_metadata);
metadata.insert("x-timestamp", timestamp_metadata);
Ok(())
}
fn proto_signature_from_json(signature: &JsonProposalSignature) -> ProtoProposalSignature {
match signature {
JsonProposalSignature::Falcon { signature } => ProtoProposalSignature {
scheme: "falcon".to_string(),
signature: signature.clone(),
public_key: None,
message_format: String::new(),
},
JsonProposalSignature::Ecdsa {
signature,
public_key,
message_format,
} => ProtoProposalSignature {
scheme: "ecdsa".to_string(),
signature: signature.clone(),
public_key: public_key.clone(),
message_format: match message_format {
guardian_shared::EcdsaMessageFormat::Raw => String::new(),
guardian_shared::EcdsaMessageFormat::Eip712 => "eip712".to_string(),
},
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn monotonic_timestamp_follows_the_clock_when_ahead() {
let last = AtomicI64::new(0);
assert_eq!(next_monotonic_timestamp(&last, 1_000), 1_000);
assert_eq!(next_monotonic_timestamp(&last, 2_000), 2_000);
}
#[test]
fn monotonic_timestamp_never_repeats_within_one_instance() {
let last = AtomicI64::new(0);
let first = next_monotonic_timestamp(&last, 1_000);
let second = next_monotonic_timestamp(&last, 1_000);
let third = next_monotonic_timestamp(&last, 1_000);
assert_eq!((first, second, third), (1_000, 1_001, 1_002));
}
#[test]
fn monotonic_timestamp_survives_a_clock_step_backwards() {
let last = AtomicI64::new(0);
assert_eq!(next_monotonic_timestamp(&last, 5_000), 5_000);
assert_eq!(next_monotonic_timestamp(&last, 4_000), 5_001);
}
#[test]
fn concurrent_timestamps_are_unique() {
let last = Arc::new(AtomicI64::new(0));
let handles: Vec<_> = (0..8)
.map(|_| {
let last = last.clone();
std::thread::spawn(move || {
(0..100)
.map(|_| next_monotonic_timestamp(&last, 1_000))
.collect::<Vec<_>>()
})
})
.collect();
let mut minted: Vec<i64> = handles
.into_iter()
.flat_map(|handle| handle.join().unwrap())
.collect();
let count = minted.len();
minted.sort_unstable();
minted.dedup();
assert_eq!(
minted.len(),
count,
"timestamps must be unique across threads"
);
}
}