lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

account.rs (12067B)


      1 //! Public, transport-neutral account value types.
      2 //!
      3 //! [`AccountId`] is derived from the same canonical public bytes as an
      4 //! identity. [`Record`] combines that identifier with a [`PublicIdentity`], an
      5 //! optional host-facing label, and caller-supplied timestamps while preserving
      6 //! their cross-field invariants. [`Status`] reports only observable readiness;
      7 //! it contains no secret, signer, persistence, or host-selection state.
      8 //!
      9 //! This module does not read a clock or persist mutations. In particular,
     10 //! [`Record::set_label`] and [`Record::touch_updated`] are separate operations
     11 //! so the composing host retains explicit timestamp and commit policy.
     12 
     13 use alloc::string::String;
     14 
     15 use crate::{Error, IdentityId, PublicIdentity, key::define_identifier};
     16 
     17 define_identifier! {
     18     /// A canonical public account identifier.
     19     pub struct AccountId;
     20 }
     21 
     22 impl AccountId {
     23     /// Derives the account identifier from its public identity identifier.
     24     #[must_use]
     25     pub const fn from_identity_id(identity_id: IdentityId) -> Self {
     26         Self::from_validated_bytes(identity_id.into_bytes())
     27     }
     28 
     29     /// Derives the account identifier from a public identity.
     30     #[must_use]
     31     pub const fn from_public_identity(public_identity: &PublicIdentity) -> Self {
     32         Self::from_identity_id(public_identity.id())
     33     }
     34 }
     35 
     36 impl From<IdentityId> for AccountId {
     37     fn from(value: IdentityId) -> Self {
     38         Self::from_identity_id(value)
     39     }
     40 }
     41 
     42 impl From<&PublicIdentity> for AccountId {
     43     fn from(value: &PublicIdentity) -> Self {
     44         Self::from_public_identity(value)
     45     }
     46 }
     47 
     48 /// A portable public account record.
     49 ///
     50 /// The account identifier is always derived from `public_identity`. Secret
     51 /// material, persistence location, account selection, and signer state are
     52 /// intentionally absent.
     53 #[cfg_attr(feature = "serde", derive(serde::Serialize))]
     54 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
     55 #[derive(Clone, Debug, PartialEq, Eq)]
     56 pub struct Record {
     57     account_id: AccountId,
     58     public_identity: PublicIdentity,
     59     #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
     60     label: Option<String>,
     61     created_at_unix: u64,
     62     updated_at_unix: u64,
     63 }
     64 
     65 impl Record {
     66     /// Creates a public account record at the supplied Unix timestamp.
     67     #[must_use]
     68     pub fn new(
     69         public_identity: PublicIdentity,
     70         label: Option<String>,
     71         created_at_unix: u64,
     72     ) -> Self {
     73         Self {
     74             account_id: AccountId::from_public_identity(&public_identity),
     75             public_identity,
     76             label,
     77             created_at_unix,
     78             updated_at_unix: created_at_unix,
     79         }
     80     }
     81 
     82     /// Validates an account record assembled from separately decoded parts.
     83     pub fn try_from_parts(
     84         account_id: AccountId,
     85         public_identity: PublicIdentity,
     86         label: Option<String>,
     87         created_at_unix: u64,
     88         updated_at_unix: u64,
     89     ) -> Result<Self, Error> {
     90         if account_id != AccountId::from_public_identity(&public_identity) {
     91             return Err(Error::AccountIdMismatch);
     92         }
     93         if updated_at_unix < created_at_unix {
     94             return Err(Error::AccountUpdatedBeforeCreated {
     95                 created_at_unix,
     96                 updated_at_unix,
     97             });
     98         }
     99         Ok(Self {
    100             account_id,
    101             public_identity,
    102             label,
    103             created_at_unix,
    104             updated_at_unix,
    105         })
    106     }
    107 
    108     /// Returns the canonical account identifier.
    109     #[must_use]
    110     pub const fn id(&self) -> AccountId {
    111         self.account_id
    112     }
    113 
    114     /// Borrows the public identity represented by this account.
    115     #[must_use]
    116     pub const fn public_identity(&self) -> &PublicIdentity {
    117         &self.public_identity
    118     }
    119 
    120     /// Borrows the optional host-facing label.
    121     #[must_use]
    122     pub fn label(&self) -> Option<&str> {
    123         self.label.as_deref()
    124     }
    125 
    126     /// Replaces the optional host-facing label without changing identity.
    127     pub fn set_label(&mut self, label: Option<String>) {
    128         self.label = label;
    129     }
    130 
    131     /// Returns the record creation timestamp as Unix seconds.
    132     #[must_use]
    133     pub const fn created_at_unix(&self) -> u64 {
    134         self.created_at_unix
    135     }
    136 
    137     /// Returns the latest record update timestamp as Unix seconds.
    138     #[must_use]
    139     pub const fn updated_at_unix(&self) -> u64 {
    140         self.updated_at_unix
    141     }
    142 
    143     /// Advances the record update timestamp without permitting time reversal.
    144     pub fn touch_updated(&mut self, updated_at_unix: u64) -> Result<(), Error> {
    145         if updated_at_unix < self.updated_at_unix {
    146             return Err(Error::AccountUpdateRegressed {
    147                 current_updated_at_unix: self.updated_at_unix,
    148                 proposed_updated_at_unix: updated_at_unix,
    149             });
    150         }
    151         self.updated_at_unix = updated_at_unix;
    152         Ok(())
    153     }
    154 }
    155 
    156 #[cfg(feature = "serde")]
    157 impl<'de> serde::Deserialize<'de> for Record {
    158     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    159     where
    160         D: serde::Deserializer<'de>,
    161     {
    162         #[derive(serde::Deserialize)]
    163         #[serde(deny_unknown_fields)]
    164         struct RecordRepr {
    165             account_id: AccountId,
    166             public_identity: PublicIdentity,
    167             #[serde(default)]
    168             label: Option<String>,
    169             created_at_unix: u64,
    170             updated_at_unix: u64,
    171         }
    172 
    173         let value = RecordRepr::deserialize(deserializer)?;
    174         Self::try_from_parts(
    175             value.account_id,
    176             value.public_identity,
    177             value.label,
    178             value.created_at_unix,
    179             value.updated_at_unix,
    180         )
    181         .map_err(serde::de::Error::custom)
    182     }
    183 }
    184 
    185 /// Publicly observable account readiness without secret or signer details.
    186 #[non_exhaustive]
    187 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
    188 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
    189 #[derive(Clone, Debug, Default, PartialEq, Eq)]
    190 pub enum Status {
    191     /// No account has been configured by the composing host.
    192     #[default]
    193     NotConfigured,
    194     /// The account can be observed but has no available signing capability.
    195     PublicOnly { account: Record },
    196     /// The composing host reports that the account can sign.
    197     Ready { account: Record },
    198 }
    199 
    200 impl Status {
    201     /// Borrows the configured account, when present.
    202     #[must_use]
    203     pub const fn account(&self) -> Option<&Record> {
    204         match self {
    205             Self::NotConfigured => None,
    206             Self::PublicOnly { account } | Self::Ready { account } => Some(account),
    207         }
    208     }
    209 
    210     /// Reports whether the composing host marked the account ready to sign.
    211     #[must_use]
    212     pub const fn is_ready(&self) -> bool {
    213         matches!(self, Self::Ready { .. })
    214     }
    215 }
    216 
    217 #[cfg(test)]
    218 mod tests {
    219     #[cfg(feature = "serde")]
    220     use alloc::format;
    221     use alloc::string::ToString;
    222     use core::str::FromStr;
    223 
    224     use super::*;
    225 
    226     const ALICE: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df";
    227     const BOB: &str = "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af";
    228 
    229     #[test]
    230     fn account_ids_round_trip_through_identity_ids() {
    231         let identity_id = IdentityId::from_hex(ALICE).expect("valid identity ID");
    232         let account_id = AccountId::from(identity_id);
    233 
    234         assert_eq!(account_id.to_hex(), ALICE);
    235         assert_eq!(account_id.as_bytes(), identity_id.as_bytes());
    236         assert_eq!(AccountId::from_str(ALICE).unwrap(), account_id);
    237     }
    238 
    239     #[test]
    240     fn account_ids_validate_and_order_canonical_bytes() {
    241         let alice = AccountId::from_hex(ALICE).expect("alice account");
    242         let bob = AccountId::from_hex(BOB).expect("bob account");
    243 
    244         assert_eq!(AccountId::from_bytes(alice.into_bytes()).unwrap(), alice);
    245         assert!(alice < bob);
    246         assert!(AccountId::from_hex("not-an-account").is_err());
    247     }
    248 
    249     #[cfg(feature = "serde")]
    250     #[test]
    251     fn account_ids_serde_as_validated_canonical_hex() {
    252         let account_id = AccountId::from_hex(ALICE).expect("valid account ID");
    253         let encoded = serde_json::to_string(&account_id).expect("serialize account ID");
    254 
    255         assert_eq!(encoded, format!("\"{ALICE}\""));
    256         assert_eq!(
    257             serde_json::from_str::<AccountId>(&encoded).expect("deserialize account ID"),
    258             account_id
    259         );
    260         assert!(serde_json::from_str::<AccountId>("\"not-an-account\"").is_err());
    261     }
    262 
    263     fn public_identity(value: &str) -> PublicIdentity {
    264         PublicIdentity::new(crate::PublicKey::from_hex(value).expect("public key"))
    265     }
    266 
    267     #[test]
    268     fn records_derive_identity_and_enforce_monotonic_timestamps() {
    269         let identity = public_identity(ALICE);
    270         let mut record = Record::new(identity.clone(), Some("primary".to_string()), 10);
    271 
    272         assert_eq!(record.id(), AccountId::from_public_identity(&identity));
    273         assert_eq!(record.public_identity(), &identity);
    274         assert_eq!(record.label(), Some("primary"));
    275         assert_eq!(record.created_at_unix(), 10);
    276         assert_eq!(record.updated_at_unix(), 10);
    277         assert!(matches!(
    278             record.touch_updated(9),
    279             Err(Error::AccountUpdateRegressed {
    280                 current_updated_at_unix: 10,
    281                 proposed_updated_at_unix: 9,
    282             })
    283         ));
    284         assert_eq!(record.updated_at_unix(), 10);
    285 
    286         record.touch_updated(12).expect("advance timestamp");
    287         assert!(matches!(
    288             record.touch_updated(11),
    289             Err(Error::AccountUpdateRegressed {
    290                 current_updated_at_unix: 12,
    291                 proposed_updated_at_unix: 11,
    292             })
    293         ));
    294         record.set_label(None);
    295         assert_eq!(record.updated_at_unix(), 12);
    296         assert_eq!(record.label(), None);
    297 
    298         let wrong_id = AccountId::from_identity_id(IdentityId::from_hex(BOB).unwrap());
    299         assert!(matches!(
    300             Record::try_from_parts(wrong_id, identity, None, 10, 10),
    301             Err(Error::AccountIdMismatch)
    302         ));
    303     }
    304 
    305     #[test]
    306     fn status_exposes_only_public_account_readiness() {
    307         let record = Record::new(public_identity(ALICE), None, 10);
    308         let not_configured = Status::default();
    309         let public_only = Status::PublicOnly {
    310             account: record.clone(),
    311         };
    312         let ready = Status::Ready {
    313             account: record.clone(),
    314         };
    315 
    316         assert!(not_configured.account().is_none());
    317         assert_eq!(public_only.account(), Some(&record));
    318         assert!(!public_only.is_ready());
    319         assert_eq!(ready.account(), Some(&record));
    320         assert!(ready.is_ready());
    321     }
    322 
    323     #[cfg(feature = "serde")]
    324     #[test]
    325     fn records_and_status_serde_revalidate_public_identity_and_timestamps() {
    326         let record = Record::new(public_identity(ALICE), Some("primary".to_string()), 10);
    327         let status = Status::Ready {
    328             account: record.clone(),
    329         };
    330         let encoded_record = serde_json::to_string(&record).expect("serialize record");
    331         let encoded_status = serde_json::to_string(&status).expect("serialize status");
    332 
    333         assert_eq!(
    334             serde_json::from_str::<Record>(&encoded_record).expect("deserialize record"),
    335             record
    336         );
    337         assert_eq!(
    338             serde_json::from_str::<Status>(&encoded_status).expect("deserialize status"),
    339             status
    340         );
    341         assert!(
    342             serde_json::from_str::<Record>(&encoded_record.replace(
    343                 &format!("\"account_id\":\"{ALICE}\""),
    344                 &format!("\"account_id\":\"{BOB}\""),
    345             ))
    346             .is_err()
    347         );
    348         assert!(
    349             serde_json::from_str::<Record>(
    350                 &encoded_record.replace("\"created_at_unix\":10", "\"created_at_unix\":11")
    351             )
    352             .is_err()
    353         );
    354         assert!(!encoded_record.contains("secret"));
    355         assert!(!encoded_status.contains("signer"));
    356     }
    357 }