diff --git a/core/account/src/account_log.rs b/core/account/src/account_log.rs new file mode 100644 index 0000000..80d0ceb --- /dev/null +++ b/core/account/src/account_log.rs @@ -0,0 +1,177 @@ +//! Signed account operation log: the append-only record of an account's +//! associated key and data. +//! +//! ```text +//! SignedAccountLog payload + account signature over its exact bytes +//! └── EncodedAccountLog the log as canonical bytes (wire form — see codec) +//! └── AccountLog the log as validated entries (working form) +//! └── AccountEntry Add(EntryData) | Remove { index } +//! └── EntryData Ed25519Key | Text +//! ``` +//! +//! Invariants: +//! - Append-only: a newer log strictly extends the older one +//! ([`verify_extension`](crate::verify_extension)). There is no version +//! counter — a longer log is a newer log. A log that is longer but does not +//! extend the old one has rewritten history: either the signer is showing +//! different histories to different readers, or the account key is +//! compromised. +//! - A `Remove` tombstones a strictly earlier, still-live `Add`; anything else +//! rejects the whole log — fail closed ([`AccountLog::new`]). +//! +//! Replaying ([`AccountLog::live_entries`]) yields the account's current state. + +use crypto::Ed25519Signature; + +use crate::error::AccountLogError; + +/// An [`AccountLog`] in its canonical byte encoding, plus the account's +/// signature over exactly those bytes. +/// +/// The account key is not carried: the account address *is* the verifying key, +/// supplied by the caller on verify. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SignedAccountLog { + pub payload: EncodedAccountLog, + pub signature: Ed25519Signature, +} + +/// An [`AccountLog`] as canonical bytes — exactly what is signed and +/// transmitted. Holding one proves the bytes decode to a valid log: construct +/// via [`AccountLog::encode`] or [`parse`](Self::parse). Byte layout: see +/// [`AccountLog::encode`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EncodedAccountLog(pub(crate) Vec); + +/// The log as a validated entry list. Construction checks every `Remove`, so +/// a held log always replays ([`live_entries`](Self::live_entries) cannot +/// fail). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountLog { + entries: Vec, +} + +/// One operation in the log. An entry's index is its position — derived, not +/// stored, so an entry cannot lie about where it sits. One enum rather than +/// separate op/data fields: illegal combinations are unrepresentable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AccountEntry { + /// Endorse new data under this account. + Add(EntryData), + /// Tombstone the `Add` at position `index`. Must point at a strictly + /// earlier, still-live `Add`; anything else rejects the whole log — + /// fail closed, so verifiers can never skip their way to different sets. + Remove { index: u32 }, +} + +/// Data an account can endorse. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EntryData { + /// A device (LocalIdentity) verifying key. + Ed25519Key([u8; 32]), + /// An arbitrary UTF-8 record. + Text(String), +} + +impl AccountLog { + /// Validate `entries` as a log (see [`AccountEntry::Remove`]). + /// [`EncodedAccountLog::parse`] applies the same gate to received bytes, + /// so a broken log — un-rewritable once published — never gets published. + pub fn new(entries: Vec) -> Result { + removed_flags(&entries)?; + Ok(Self { entries }) + } + + pub fn entries(&self) -> &[AccountEntry] { + &self.entries + } + + /// Replay the log into its live entry set — the account's current state, + /// in add order. + pub fn live_entries(&self) -> Vec { + let removed = removed_flags(&self.entries).expect("validated at construction"); + self.entries + .iter() + .enumerate() + .filter_map(|(i, entry)| match entry { + AccountEntry::Add(data) if !removed[i] => Some(data.clone()), + _ => None, + }) + .collect() + } +} + +/// Which entries have been tombstoned, or the `Remove` that broke the log. +/// `index < position` means every target was already seen: single-pass. +fn removed_flags(entries: &[AccountEntry]) -> Result, AccountLogError> { + let mut removed = vec![false; entries.len()]; + for (position, entry) in entries.iter().enumerate() { + let AccountEntry::Remove { index } = entry else { + continue; + }; + let target = *index as usize; + let targets_live_add = target < position + && !removed[target] + && matches!(entries[target], AccountEntry::Add(_)); + if !targets_live_add { + return Err(AccountLogError::InvalidRemove { + position, + index: *index, + }); + } + removed[target] = true; + } + Ok(removed) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(byte: u8) -> AccountEntry { + AccountEntry::Add(EntryData::Ed25519Key([byte; 32])) + } + + /// Replay applies tombstones and preserves add order. + #[test] + fn live_entries_applies_removes() { + let log = AccountLog::new(vec![ + key(1), + AccountEntry::Add(EntryData::Text("name".into())), + AccountEntry::Remove { index: 0 }, + key(2), + ]) + .unwrap(); + assert_eq!( + log.live_entries(), + vec![ + EntryData::Text("name".into()), + EntryData::Ed25519Key([2; 32]), + ] + ); + } + + /// Every malformed remove rejects the whole log: forward and self + /// references, removing a remove, and removing twice. + #[test] + fn new_rejects_invalid_removes() { + let dangling = vec![key(1), AccountEntry::Remove { index: 7 }]; + let self_ref = vec![AccountEntry::Remove { index: 0 }]; + let of_remove = vec![ + key(1), + AccountEntry::Remove { index: 0 }, + AccountEntry::Remove { index: 1 }, + ]; + let twice = vec![ + key(1), + AccountEntry::Remove { index: 0 }, + AccountEntry::Remove { index: 0 }, + ]; + for entries in [dangling, self_ref, of_remove, twice] { + assert!(matches!( + AccountLog::new(entries), + Err(AccountLogError::InvalidRemove { .. }) + )); + } + } +} diff --git a/core/account/src/addr.rs b/core/account/src/addr.rs new file mode 100644 index 0000000..2c0c9bc --- /dev/null +++ b/core/account/src/addr.rs @@ -0,0 +1,68 @@ +use std::fmt; + +use crypto::Ed25519VerifyingKey; + +use crate::error::AccountError; + +/// A routable representation of an account +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountAddr { + pubkey: Ed25519VerifyingKey, +} + +impl AccountAddr { + pub fn to_bytes(&self) -> &[u8] { + self.pubkey.as_ref() + } +} + +/// Displays as the hex of the account verifying key — the same form the +/// directory and keypackage registry use for ids. +impl fmt::Display for AccountAddr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&hex::encode(self.pubkey.as_ref())) + } +} + +impl From<&Ed25519VerifyingKey> for AccountAddr { + fn from(value: &Ed25519VerifyingKey) -> Self { + Self { + pubkey: value.clone(), + } + } +} + +/// Not every byte string is an address: exactly 32 bytes forming a valid +/// Ed25519 key. +impl TryFrom<&[u8]> for AccountAddr { + type Error = AccountError; + + fn try_from(value: &[u8]) -> Result { + let bytes: [u8; 32] = value + .try_into() + .map_err(|_| AccountError::InvalidAddress)?; + let pubkey = + Ed25519VerifyingKey::from_bytes(&bytes).map_err(|_| AccountError::InvalidAddress)?; + Ok(Self { pubkey }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crypto::Ed25519SigningKey; + + /// Display is the hex of the key; TryFrom round-trips the bytes. + #[test] + fn display_and_try_from_roundtrip() { + let key = Ed25519SigningKey::generate().verifying_key(); + let addr = AccountAddr::from(&key); + assert_eq!(addr.to_string(), hex::encode(key.as_ref())); + assert_eq!(AccountAddr::try_from(addr.to_bytes()).unwrap(), addr); + } + + #[test] + fn try_from_rejects_wrong_length() { + assert!(AccountAddr::try_from(&[0u8; 31][..]).is_err()); + } +} diff --git a/core/account/src/codec.rs b/core/account/src/codec.rs new file mode 100644 index 0000000..919cd65 --- /dev/null +++ b/core/account/src/codec.rs @@ -0,0 +1,409 @@ +//! Wire format for [`EncodedAccountLog`]: the canonical byte encoding of an +//! [`AccountLog`]. Encoding and decoding live together so they cannot drift +//! apart. The bytes are opaque to the server except for the fixed-offset +//! header the extension check reads. + +use crypto::Ed25519VerifyingKey; + +use crate::error::AccountLogError; +use crate::account_log::{AccountEntry, AccountLog, EncodedAccountLog, EntryData, SignedAccountLog}; + +/// Domain-separation tag, prepended to every signed payload: +/// +/// ```text +/// logos:accounts:\0 +/// ``` +/// +/// Binds the signature to this exact purpose; bump `` on layout change. +pub const ACCOUNT_LOG_DOMAIN: &[u8] = b"logos:accounts:1\0"; + +/// [`ACCOUNT_LOG_DOMAIN`] without the version segment. +const DOMAIN_STEM: &[u8] = b"logos:accounts:"; + +// Entry wire tags. One tag byte determines exactly how many bytes follow, so +// every byte string parses one way. +const TAG_ADD: u8 = 1; +const TAG_REMOVE: u8 = 2; +const DATA_ED25519: u8 = 1; +const DATA_TEXT: u8 = 2; + +/// Header bytes after the domain prefix: the entry count (u32 LE). The count +/// doubles as the freshness marker; u32 so no plausible client bug can +/// exhaust it (a u16 could be burned by a publish loop, and the counter has +/// no reset mechanism). +const HEADER: usize = 4; + +impl AccountLog { + /// Canonical binary encoding — the bytes that are both signed and + /// transmitted: + /// + /// ```text + /// domain : ACCOUNT_LOG_DOMAIN (constant prefix incl. version, NUL-terminated) + /// count : u32 LE (4 bytes) — number of entries that follow + /// entries : count entries, each: + /// 0x01 0x01 <32 bytes> Add(Ed25519Key) + /// 0x01 0x02 Add(Text), UTF-8 + /// 0x02 Remove + /// ``` + /// + /// The account key is *not* embedded: the account is identified + /// out-of-band by the account verifying key the caller requests, and + /// [`verify_log`] checks the signature under that key — so a log for one + /// account cannot be passed off as another's. + pub fn encode(&self) -> EncodedAccountLog { + let entries = self.entries(); + let mut out = Vec::with_capacity(ACCOUNT_LOG_DOMAIN.len() + HEADER + entries.len() * 34); + out.extend_from_slice(ACCOUNT_LOG_DOMAIN); + out.extend_from_slice(&(entries.len() as u32).to_le_bytes()); + for entry in entries { + match entry { + AccountEntry::Add(EntryData::Ed25519Key(key)) => { + out.push(TAG_ADD); + out.push(DATA_ED25519); + out.extend_from_slice(key); + } + AccountEntry::Add(EntryData::Text(text)) => { + out.push(TAG_ADD); + out.push(DATA_TEXT); + out.extend_from_slice(&(text.len() as u16).to_le_bytes()); + out.extend_from_slice(text.as_bytes()); + } + AccountEntry::Remove { index } => { + out.push(TAG_REMOVE); + out.extend_from_slice(&index.to_le_bytes()); + } + } + } + EncodedAccountLog(out) + } +} + +impl EncodedAccountLog { + /// Validate received bytes: checks the domain prefix and version, parses + /// exactly the declared number of entries with no bytes left over, and + /// validates the log itself via [`AccountLog::new`]. + pub fn parse(bytes: Vec) -> Result { + AccountLog::new(decode_entries(&bytes)?)?; + Ok(Self(bytes)) + } + + /// Decode. Cannot fail: construction validated the bytes. + pub fn decode(&self) -> AccountLog { + AccountLog::new(decode_entries(&self.0).expect("validated at construction")) + .expect("validated at construction") + } + + /// The exact bytes that are signed and transmitted. + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } + + /// The entry count from the header — the freshness marker. + fn count(&self) -> u32 { + let at = ACCOUNT_LOG_DOMAIN.len(); + u32::from_le_bytes(self.0[at..at + 4].try_into().expect("4 bytes")) + } + + /// The entry bytes after the header — the region the extension check + /// compares. + fn entry_bytes(&self) -> &[u8] { + &self.0[ACCOUNT_LOG_DOMAIN.len() + HEADER..] + } +} + +/// Decoder behind [`EncodedAccountLog::parse`] and [`EncodedAccountLog::decode`]. +fn decode_entries(payload: &[u8]) -> Result, AccountLogError> { + let payload = match payload.strip_prefix(ACCOUNT_LOG_DOMAIN) { + Some(rest) => rest, + None => return Err(domain_error(payload)), + }; + if payload.len() < HEADER { + return Err(AccountLogError::Short); + } + let count = u32::from_le_bytes(payload[..4].try_into().expect("4 bytes")) as usize; + + let mut body = &payload[HEADER..]; + let mut entries = Vec::with_capacity(count.min(1024)); + for _ in 0..count { + let (entry, rest) = decode_entry(body)?; + entries.push(entry); + body = rest; + } + if !body.is_empty() { + return Err(AccountLogError::Trailing); + } + Ok(entries) +} + +/// Parse one entry off the front of `body`, returning it and the rest. +fn decode_entry(body: &[u8]) -> Result<(AccountEntry, &[u8]), AccountLogError> { + let (&tag, body) = body.split_first().ok_or(AccountLogError::Short)?; + match tag { + TAG_ADD => { + let (&data_tag, body) = body.split_first().ok_or(AccountLogError::Short)?; + match data_tag { + DATA_ED25519 => { + let (key, rest) = split_at_checked(body, 32)?; + let key = key.try_into().expect("split yields 32 bytes"); + Ok((AccountEntry::Add(EntryData::Ed25519Key(key)), rest)) + } + DATA_TEXT => { + let (len, body) = split_at_checked(body, 2)?; + let len = u16::from_le_bytes(len.try_into().expect("2 bytes")) as usize; + let (text, rest) = split_at_checked(body, len)?; + let text = String::from_utf8(text.to_vec()).map_err(|_| AccountLogError::Utf8)?; + Ok((AccountEntry::Add(EntryData::Text(text)), rest)) + } + other => Err(AccountLogError::Tag(other)), + } + } + TAG_REMOVE => { + let (index, rest) = split_at_checked(body, 4)?; + let index = u32::from_le_bytes(index.try_into().expect("4 bytes")); + Ok((AccountEntry::Remove { index }, rest)) + } + other => Err(AccountLogError::Tag(other)), + } +} + +/// Classify a payload that failed the domain check: our stem with a different +/// version segment, or a foreign domain altogether. +fn domain_error(payload: &[u8]) -> AccountLogError { + let Some(rest) = payload.strip_prefix(DOMAIN_STEM) else { + return AccountLogError::Domain; + }; + match rest.iter().take(16).position(|&b| b == 0) { + Some(end) => AccountLogError::Version(String::from_utf8_lossy(&rest[..end]).into_owned()), + None => AccountLogError::Domain, + } +} + +/// `split_at` that reports a truncated payload instead of panicking. +fn split_at_checked(body: &[u8], mid: usize) -> Result<(&[u8], &[u8]), AccountLogError> { + if body.len() < mid { + return Err(AccountLogError::Short); + } + Ok(body.split_at(mid)) +} + +/// Verify the account signature over the exact payload bytes, returning the +/// decoded log. +/// +/// Verifying under the *requested* account key is what binds the log to that +/// account: another account's validly-signed log won't verify under this key, +/// so an untrusted server cannot substitute one. +pub fn verify_log( + expected_account: &Ed25519VerifyingKey, + log: &SignedAccountLog, +) -> Result { + expected_account + .verify(log.payload.as_bytes(), &log.signature) + .map_err(|_| AccountLogError::SignatureInvalid)?; + Ok(log.payload.decode()) +} + +/// Check that `new` strictly extends `old`: strictly more entries, and the old +/// entry bytes are a prefix of the new ones. (The count field itself changes +/// between versions, so the check is over the entry region, not the whole +/// payload.) +/// +/// The server runs this on publish to refuse stale or rewritten logs, and +/// consumers run it against the last log they saw as defence in depth. It +/// compares bytes, so the server needs no knowledge of entry semantics. +pub fn verify_extension(old: &EncodedAccountLog, new: &EncodedAccountLog) -> Result<(), AccountLogError> { + if new.count() <= old.count() { + return Err(AccountLogError::NotLonger { + old: old.count(), + new: new.count(), + }); + } + if !new.entry_bytes().starts_with(old.entry_bytes()) { + return Err(AccountLogError::Forked); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crypto::Ed25519SigningKey; + + fn key(byte: u8) -> AccountEntry { + AccountEntry::Add(EntryData::Ed25519Key([byte; 32])) + } + + fn make_log(entries: Vec) -> AccountLog { + AccountLog::new(entries).unwrap() + } + + /// encode → decode round-trips, and parse accepts encode's bytes, + /// including the empty log and every variant. + #[test] + fn payload_roundtrips() { + let log = make_log(vec![ + key(1), + AccountEntry::Add(EntryData::Text("display name".into())), + AccountEntry::Remove { index: 0 }, + key(2), + ]); + let payload = log.encode(); + assert_eq!(payload.decode(), log); + assert_eq!( + EncodedAccountLog::parse(payload.as_bytes().to_vec()).unwrap(), + payload + ); + + // Empty log is valid (an account with no entries yet). + assert!(make_log(vec![]).encode().decode().entries().is_empty()); + } + + #[test] + fn parse_rejects_short_and_truncated() { + // A domain-prefixed payload too short to hold the header. + let mut short = ACCOUNT_LOG_DOMAIN.to_vec(); + short.extend_from_slice(&[0u8; 3]); + assert!(matches!( + EncodedAccountLog::parse(short), + Err(AccountLogError::Short) + )); + + // Drop a key byte: the last entry no longer fits. + let mut bytes = make_log(vec![key(1)]).encode().as_bytes().to_vec(); + bytes.pop(); + assert!(matches!( + EncodedAccountLog::parse(bytes), + Err(AccountLogError::Short) + )); + } + + #[test] + fn parse_rejects_trailing_bytes() { + let mut bytes = make_log(vec![key(1)]).encode().as_bytes().to_vec(); + bytes.push(0); + assert!(matches!( + EncodedAccountLog::parse(bytes), + Err(AccountLogError::Trailing) + )); + } + + #[test] + fn parse_rejects_missing_domain() { + let payload = make_log(vec![]).encode(); + let without_domain = payload.as_bytes()[ACCOUNT_LOG_DOMAIN.len()..].to_vec(); + assert!(matches!( + EncodedAccountLog::parse(without_domain), + Err(AccountLogError::Domain) + )); + } + + #[test] + fn parse_rejects_bad_version_and_tag() { + let mut bytes = make_log(vec![]).encode().as_bytes().to_vec(); + bytes[ACCOUNT_LOG_DOMAIN.len() - 2] = b'9'; // the version character + assert!(matches!( + EncodedAccountLog::parse(bytes), + Err(AccountLogError::Version(v)) if v == "9" + )); + + let mut bytes = make_log(vec![key(1)]).encode().as_bytes().to_vec(); + bytes[ACCOUNT_LOG_DOMAIN.len() + HEADER] = 77; // first entry's tag byte + assert!(matches!( + EncodedAccountLog::parse(bytes), + Err(AccountLogError::Tag(77)) + )); + } + + /// Well-formed bytes carrying an invalid log (a self-referencing remove) + /// are rejected at parse: broken logs never get past the boundary. Such + /// bytes cannot be produced through the API, so they are handcrafted. + #[test] + fn parse_rejects_invalid_log() { + let mut bytes = ACCOUNT_LOG_DOMAIN.to_vec(); + bytes.extend_from_slice(&1u32.to_le_bytes()); + bytes.push(TAG_REMOVE); + bytes.extend_from_slice(&0u32.to_le_bytes()); // Remove{0} at position 0 + assert!(matches!( + EncodedAccountLog::parse(bytes), + Err(AccountLogError::InvalidRemove { .. }) + )); + } + + /// Full happy path: sign with the account key, verify under the account key. + #[test] + fn verify_accepts_well_formed_log() { + let account_key = Ed25519SigningKey::generate(); + let account_pub = account_key.verifying_key(); + let log = make_log(vec![key(1), key(2)]); + + let payload = log.encode(); + let signed = SignedAccountLog { + signature: account_key.sign(payload.as_bytes()), + payload, + }; + + assert_eq!(verify_log(&account_pub, &signed).unwrap(), log); + } + + /// A log validly signed by account A, served as the answer to a query for + /// account B, fails: B's key does not verify A's signature. This is the + /// anti-substitution guarantee. + #[test] + fn verify_rejects_wrong_account() { + let account_key = Ed25519SigningKey::generate(); + let payload = make_log(vec![]).encode(); + let signed = SignedAccountLog { + signature: account_key.sign(payload.as_bytes()), + payload, + }; + + let other = Ed25519SigningKey::generate().verifying_key(); + assert!(matches!( + verify_log(&other, &signed), + Err(AccountLogError::SignatureInvalid) + )); + } + + /// A signature over one entry list does not verify another. + #[test] + fn verify_rejects_swapped_payload() { + let account_key = Ed25519SigningKey::generate(); + let account_pub = account_key.verifying_key(); + + let signature = account_key.sign(make_log(vec![key(1)]).encode().as_bytes()); + let signed = SignedAccountLog { + payload: make_log(vec![key(2)]).encode(), + signature, + }; + assert!(matches!( + verify_log(&account_pub, &signed), + Err(AccountLogError::SignatureInvalid) + )); + } + + /// Appending entries is an extension; anything else is stale or a fork. + #[test] + fn extension_accepts_appends_only() { + let old = make_log(vec![key(1)]).encode(); + let new = make_log(vec![key(1), key(2)]).encode(); + verify_extension(&old, &new).unwrap(); + + // Same length: stale, even with identical contents. + assert!(matches!( + verify_extension(&old, &old), + Err(AccountLogError::NotLonger { old: 1, new: 1 }) + )); + + // Shrinking: stale. + assert!(matches!( + verify_extension(&new, &old), + Err(AccountLogError::NotLonger { old: 2, new: 1 }) + )); + + // Longer but rewrites entry 0: fork. + let fork = make_log(vec![key(3), key(2)]).encode(); + assert!(matches!( + verify_extension(&old, &fork), + Err(AccountLogError::Forked) + )); + } +} diff --git a/core/account/src/error.rs b/core/account/src/error.rs new file mode 100644 index 0000000..bb5a846 --- /dev/null +++ b/core/account/src/error.rs @@ -0,0 +1,36 @@ +pub use thiserror::Error; + +#[derive(Error, Debug)] +pub enum AccountError { + #[error("Generic: {0}")] + Generic(String), + #[error("No account entry for id: {0} ")] + MissingEntry(String), + #[error("invalid account address")] + InvalidAddress, +} + +/// Failures decoding, verifying, or replaying an account log. +#[derive(Debug, Error)] +pub enum AccountLogError { + #[error("payload shorter than its declared layout")] + Short, + #[error("payload is missing the account-log domain prefix")] + Domain, + #[error("unsupported log version {0}")] + Version(String), + #[error("unknown entry tag {0}")] + Tag(u8), + #[error("payload has bytes past its declared entries")] + Trailing, + #[error("text entry is not valid UTF-8")] + Utf8, + #[error("account signature verification failed")] + SignatureInvalid, + #[error("remove at position {position} does not point at an earlier live add ({index})")] + InvalidRemove { position: usize, index: u32 }, + #[error("new log does not have more entries than the old ({new} <= {old})")] + NotLonger { old: u32, new: u32 }, + #[error("new log rewrites history instead of extending it (fork)")] + Forked, +} diff --git a/core/account/src/lib.rs b/core/account/src/lib.rs index 1b90c8c..794417f 100644 --- a/core/account/src/lib.rs +++ b/core/account/src/lib.rs @@ -1,13 +1,22 @@ +#[cfg(feature = "dev")] +mod account; +mod addr; +mod codec; mod directory; +mod error; +mod account_log; +use crypto::Ed25519VerifyingKey; pub use directory::{ AccountDirectory, BUNDLE_VERSION, BundleError, DecodedBundle, DeviceId, DeviceSet, Lamport, ResolveError, SignedDeviceBundle, decode_bundle_payload, encode_bundle_payload, resolve_device_ids, verify_bundle, }; +pub use codec::{ACCOUNT_LOG_DOMAIN, verify_extension, verify_log}; +pub use error::{AccountError, AccountLogError}; +pub use account_log::{AccountEntry, AccountLog, EncodedAccountLog, EntryData, SignedAccountLog}; +pub use addr::AccountAddr; #[cfg(feature = "dev")] -mod account; +pub use account::TestLogosAccount; -#[cfg(feature = "dev")] -pub use account::{AddDelegateSignerError, TestLogosAccount};