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 }