lib.rs 45 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327
  1. //! Domain types for the ledger.
  2. //!
  3. //! These types model the UTXO-style ledger where value is held as **postings** —
  4. //! signed amounts owned by exactly one account. An account's balance is simply the
  5. //! sum of its active postings, which eliminates the need for running balance fields
  6. //! and makes the system trivially auditable by replaying the transfer log.
  7. pub mod autoid;
  8. use serde::{Deserialize, Serialize};
  9. use std::collections::BTreeMap;
  10. use std::fmt;
  11. use std::sync::atomic::{AtomicU64, Ordering};
  12. // ---------------------------------------------------------------------------
  13. // ToBytes trait
  14. // ---------------------------------------------------------------------------
  15. /// Deterministic binary serialization. Every domain type can produce its
  16. /// canonical byte representation.
  17. pub trait ToBytes {
  18. /// Returns the canonical byte representation of this value.
  19. fn to_bytes(&self) -> Vec<u8>;
  20. }
  21. // ---------------------------------------------------------------------------
  22. // Binary encoding helpers — big-endian, deterministic
  23. // ---------------------------------------------------------------------------
  24. /// Version byte prepended to canonical serializations for forward compatibility.
  25. /// Bumped to 2 when `Cent` moved to a fixed 16-byte canonical encoding (ADR-0011).
  26. /// Bumped to 3 when `AccountId` gained a `subaccount` leg folded into its
  27. /// canonical bytes (ADR-0012).
  28. /// Bumped to 4 when the vestigial `UserData` fields were removed from the
  29. /// `Envelope` and `Account` preimages.
  30. pub const CANONICAL_VERSION: u8 = 4;
  31. /// Append a `u16` in big-endian to `buf`.
  32. pub fn write_u16(buf: &mut Vec<u8>, v: u16) {
  33. buf.extend_from_slice(&v.to_be_bytes());
  34. }
  35. /// Append a `u32` in big-endian to `buf`.
  36. pub fn write_u32(buf: &mut Vec<u8>, v: u32) {
  37. buf.extend_from_slice(&v.to_be_bytes());
  38. }
  39. /// Append a `u64` in big-endian to `buf`.
  40. pub fn write_u64(buf: &mut Vec<u8>, v: u64) {
  41. buf.extend_from_slice(&v.to_be_bytes());
  42. }
  43. /// Append an `i64` in big-endian to `buf`.
  44. pub fn write_i64(buf: &mut Vec<u8>, v: i64) {
  45. buf.extend_from_slice(&v.to_be_bytes());
  46. }
  47. /// Append a `u128` in big-endian to `buf`.
  48. pub fn write_u128(buf: &mut Vec<u8>, v: u128) {
  49. buf.extend_from_slice(&v.to_be_bytes());
  50. }
  51. // ---------------------------------------------------------------------------
  52. // Identifiers
  53. // ---------------------------------------------------------------------------
  54. /// Stable account identity. Used in all public APIs.
  55. ///
  56. /// An account is a base `id` plus a `subaccount`. `sub = 0` is the main account
  57. /// (the default when subaccounts are not used); a non-zero `sub` is a
  58. /// subaccount of the same base id. `sub` is an opaque id (an `i64`, like the
  59. /// base id), so the whole range is usable. Each `(id, sub)` is a full account
  60. /// record with its own policy and lifecycle. See ADR-0012.
  61. #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
  62. pub struct AccountId {
  63. /// Base account id.
  64. pub id: i64,
  65. /// Subaccount id; `0` is the main account.
  66. pub sub: i64,
  67. }
  68. /// Pairs an [`AccountId`] with a snapshot hash — the double-SHA256 of the
  69. /// account's state at a point in time. Stored on [`Transfer`] to record which
  70. /// account versions a transfer was executed against. Internal type — the
  71. /// public API uses [`AccountId`].
  72. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
  73. pub struct AccountSnapshotId {
  74. /// The account (subaccount) this snapshot belongs to.
  75. pub account: AccountId,
  76. /// Double-SHA256 of the account's state at the time of the snapshot.
  77. pub snapshot_id: [u8; 32],
  78. }
  79. /// Identifies an asset (USD, EUR, BTC, …). Conservation is enforced per asset,
  80. /// so each asset is an independent conservation boundary.
  81. #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
  82. pub struct AssetId(pub u32);
  83. /// Content-addressed transfer identifier — the double-SHA256 of the canonical
  84. /// serialization. This makes the id both the idempotency key and the
  85. /// tamper-evidence artifact.
  86. #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
  87. pub struct EnvelopeId(pub [u8; 32]);
  88. /// Uniquely identifies a posting within the ledger. The `(transfer, index)` pair
  89. /// ties every posting back to the transfer that created it, which is the basis
  90. /// of the provenance graph.
  91. #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
  92. pub struct PostingId {
  93. /// The transfer that created this posting.
  94. pub transfer: EnvelopeId,
  95. /// Zero-based position within the transfer's created postings.
  96. pub index: u16,
  97. }
  98. // ---------------------------------------------------------------------------
  99. // Cent — re-exported from kuatia-money (swappable integer backing)
  100. // ---------------------------------------------------------------------------
  101. pub use kuatia_money::{Amount, Cent, OverflowError, ParseAmountError};
  102. impl ToBytes for Cent {
  103. fn to_bytes(&self) -> Vec<u8> {
  104. self.to_canonical_bytes().to_vec()
  105. }
  106. }
  107. // ---------------------------------------------------------------------------
  108. // Debug / Display impls for identifiers
  109. // ---------------------------------------------------------------------------
  110. impl fmt::Debug for AccountId {
  111. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  112. if self.sub == 0 {
  113. write!(f, "AccountId({})", self.id)
  114. } else {
  115. write!(f, "AccountId({}.{})", self.id, self.sub)
  116. }
  117. }
  118. }
  119. impl fmt::Display for AccountId {
  120. /// IBAN-style machine format: two ISO 7064 mod-97 check digits, then a
  121. /// 26-character base-36 body. There is no country code. The `(id, sub)` pair
  122. /// is run through a keyed 128-bit Feistel permutation (see [`set_id_seed`])
  123. /// before encoding, so the body does not reveal the raw ids. Round-trips via
  124. /// [`FromStr`](std::str::FromStr); [`to_grouped`](AccountId::to_grouped) adds
  125. /// the presentation spacing.
  126. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  127. let (l, r) = feistel(self.id as u64, self.sub as u64, id_seed());
  128. let body = format!("{}{}", base36_u64(l), base36_u64(r));
  129. write!(f, "{:02}{body}", check_digits(&body))
  130. }
  131. }
  132. impl fmt::Debug for AssetId {
  133. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  134. write!(f, "AssetId({:#010x})", self.0)
  135. }
  136. }
  137. impl fmt::Debug for EnvelopeId {
  138. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  139. write!(f, "EnvelopeId({})", hex(&self.0))
  140. }
  141. }
  142. impl fmt::Debug for PostingId {
  143. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  144. f.debug_struct("PostingId")
  145. .field("transfer", &self.transfer)
  146. .field("index", &self.index)
  147. .finish()
  148. }
  149. }
  150. fn hex(bytes: &[u8]) -> String {
  151. bytes.iter().map(|b| format!("{b:02x}")).collect()
  152. }
  153. // ---------------------------------------------------------------------------
  154. // IBAN-style string view for AccountId (ADR-0012)
  155. // ---------------------------------------------------------------------------
  156. /// Encode a `u64` as exactly 13 base-36 digits (`0-9A-Z`), zero-padded on the
  157. /// left. 13 digits is the widest a `u64` needs (`36^13 > u64::MAX`), so this
  158. /// never truncates.
  159. fn base36_u64(mut v: u64) -> String {
  160. const D: &[u8; 36] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
  161. let mut out = [b'0'; 13];
  162. let mut i = out.len();
  163. while v > 0 && i > 0 {
  164. i -= 1;
  165. out[i] = D[(v % 36) as usize];
  166. v /= 36;
  167. }
  168. out.iter().map(|&b| b as char).collect()
  169. }
  170. /// Expand an IBAN string to its numeric form for the checksum: digits stay,
  171. /// letters `A-Z` become `10..35`.
  172. fn iban_expand(s: &str) -> String {
  173. let mut out = String::with_capacity(s.len() * 2);
  174. for c in s.bytes() {
  175. if c.is_ascii_digit() {
  176. out.push(c as char);
  177. } else {
  178. let v = (c - b'A') as u32 + 10;
  179. out.push_str(&v.to_string());
  180. }
  181. }
  182. out
  183. }
  184. /// ISO 7064 mod-97-10 over a decimal string, computed iteratively so the input
  185. /// length is unbounded.
  186. fn mod97(digits: &str) -> u32 {
  187. let mut rem = 0u32;
  188. for b in digits.bytes() {
  189. rem = (rem * 10 + (b - b'0') as u32) % 97;
  190. }
  191. rem
  192. }
  193. /// The two mod-97 check digits for a base-36 body, IBAN-style but with no
  194. /// country code: `98 - (expand(body ++ "00") mod 97)`.
  195. fn check_digits(body: &str) -> u32 {
  196. 98 - mod97(&iban_expand(&format!("{body}00")))
  197. }
  198. // ---------------------------------------------------------------------------
  199. // Account-code obfuscation (ADR-0012)
  200. //
  201. // The account code's body is a base-36 rendering of the two i64 legs. Without
  202. // mixing, small ids render as long runs of zeros that reveal their value and
  203. // sequence. To hide that from outsiders, the (id, sub) pair is run through a
  204. // keyed 128-bit Feistel permutation before encoding, and inverted on parse.
  205. // This is obfuscation, not security: anyone with the seed can decode it, so it
  206. // is not a substitute for authorization. The seed has a default and can be set
  207. // once at startup via `set_id_seed`; changing it changes every code, so it must
  208. // be stable across a deployment.
  209. // ---------------------------------------------------------------------------
  210. /// Default seed for the account-code obfuscation permutation. Override at
  211. /// startup with [`set_id_seed`], before any code is issued or parsed.
  212. pub const DEFAULT_ID_SEED: u64 = 0x9E37_79B9_7F4A_7C15;
  213. /// Process-global seed keying the account-code permutation.
  214. static ID_SEED: AtomicU64 = AtomicU64::new(DEFAULT_ID_SEED);
  215. /// Set the process-global seed that keys the account-code obfuscation. Call once
  216. /// at startup: every [`AccountId`] string form depends on it, so changing it
  217. /// after codes are issued invalidates the previously issued ones.
  218. pub fn set_id_seed(seed: u64) {
  219. ID_SEED.store(seed, Ordering::Relaxed);
  220. }
  221. /// The current process-global account-code seed.
  222. pub fn id_seed() -> u64 {
  223. ID_SEED.load(Ordering::Relaxed)
  224. }
  225. /// Number of Feistel rounds. Four rounds of a strong round function give a
  226. /// strong pseudo-random permutation (Luby-Rackoff), which is ample for
  227. /// obfuscation.
  228. const FEISTEL_ROUNDS: usize = 4;
  229. /// SplitMix64 finalizer: a strong 64-bit avalanche mixer.
  230. fn mix64(mut z: u64) -> u64 {
  231. z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
  232. z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
  233. z ^ (z >> 31)
  234. }
  235. /// Per-round subkey derived from the seed and round index.
  236. fn round_key(seed: u64, round: usize) -> u64 {
  237. mix64(seed ^ (round as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15))
  238. }
  239. /// Keyed 128-bit Feistel permutation over the two halves `(l, r)`.
  240. fn feistel(mut l: u64, mut r: u64, seed: u64) -> (u64, u64) {
  241. for round in 0..FEISTEL_ROUNDS {
  242. let next = l ^ mix64(r ^ round_key(seed, round));
  243. l = r;
  244. r = next;
  245. }
  246. (l, r)
  247. }
  248. /// Inverse of [`feistel`] under the same seed.
  249. fn feistel_inv(mut l: u64, mut r: u64, seed: u64) -> (u64, u64) {
  250. for round in (0..FEISTEL_ROUNDS).rev() {
  251. let prev = r ^ mix64(l ^ round_key(seed, round));
  252. r = l;
  253. l = prev;
  254. }
  255. (l, r)
  256. }
  257. // ---------------------------------------------------------------------------
  258. // Identifier constructors
  259. // ---------------------------------------------------------------------------
  260. impl Default for AccountId {
  261. fn default() -> Self {
  262. // Process-global generator: a per-thread one could mint the same id on
  263. // two threads within a millisecond, yielding duplicate account ids.
  264. static GEN: crate::autoid::AutoId = crate::autoid::AutoId::new();
  265. Self {
  266. id: GEN.next(),
  267. sub: 0,
  268. }
  269. }
  270. }
  271. impl AccountId {
  272. /// Create the main account (`sub = 0`) for a base `id`.
  273. pub const fn new(id: i64) -> Self {
  274. Self { id, sub: 0 }
  275. }
  276. /// Create a specific subaccount of a base `id`.
  277. pub const fn with_sub(id: i64, sub: i64) -> Self {
  278. Self { id, sub }
  279. }
  280. /// Return the main account of this id (`sub` set to `0`).
  281. pub const fn base(&self) -> Self {
  282. Self {
  283. id: self.id,
  284. sub: 0,
  285. }
  286. }
  287. /// Whether this is the main account (`sub == 0`).
  288. pub const fn is_main(&self) -> bool {
  289. self.sub == 0
  290. }
  291. /// IBAN-style presentation format: the machine [`Display`](fmt::Display)
  292. /// form grouped into blocks of four with a single space
  293. /// (e.g. `9200 0000 0000 0050 0000 0000 07`).
  294. pub fn to_grouped(&self) -> String {
  295. let machine = self.to_string();
  296. let mut out = String::with_capacity(machine.len() + machine.len() / 4);
  297. for (i, c) in machine.chars().enumerate() {
  298. if i > 0 && i % 4 == 0 {
  299. out.push(' ');
  300. }
  301. out.push(c);
  302. }
  303. out
  304. }
  305. }
  306. impl From<AccountSnapshotId> for AccountId {
  307. fn from(snap: AccountSnapshotId) -> Self {
  308. snap.account
  309. }
  310. }
  311. /// Returned when a string is not a valid [`AccountId`] code: wrong structure,
  312. /// non-base-36 body, or a failed mod-97 checksum.
  313. #[derive(Debug, Clone, PartialEq, Eq)]
  314. pub struct ParseAccountIdError;
  315. impl fmt::Display for ParseAccountIdError {
  316. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  317. write!(f, "invalid AccountId: not a checksum-valid account code")
  318. }
  319. }
  320. impl std::error::Error for ParseAccountIdError {}
  321. impl std::str::FromStr for AccountId {
  322. type Err = ParseAccountIdError;
  323. /// Parse an IBAN-style account code back into the two i64 legs. Any spaces
  324. /// (grouped display format) and dashes (URL-safe separator) are ignored and
  325. /// the input is upper-cased first, so `5000...`, `5000 0000 ...`, and
  326. /// `5000-0000-...` all parse to the same id. The value must reduce to two
  327. /// check digits followed by a 26-character base-36 body, and the ISO 7064
  328. /// mod-97 checksum must pass — so a mistyped or otherwise invalid id is
  329. /// rejected here rather than reaching the store. Each 13-char half is read as
  330. /// a `u64` bit pattern and reinterpreted as `i64`.
  331. fn from_str(s: &str) -> Result<Self, Self::Err> {
  332. let cleaned: String = s
  333. .chars()
  334. .filter(|c| !c.is_whitespace() && *c != '-')
  335. .map(|c| c.to_ascii_uppercase())
  336. .collect();
  337. // 2 check digits + 26-char base-36 body.
  338. if cleaned.len() != 28 {
  339. return Err(ParseAccountIdError);
  340. }
  341. let check = &cleaned[0..2];
  342. let body = &cleaned[2..28];
  343. let is_base36 = |b: u8| b.is_ascii_digit() || b.is_ascii_uppercase();
  344. if !check.bytes().all(|b| b.is_ascii_digit()) || !body.bytes().all(is_base36) {
  345. return Err(ParseAccountIdError);
  346. }
  347. // Checksum-valid iff the expanded (body ++ check) reduces to 1 under
  348. // mod-97.
  349. if mod97(&iban_expand(&format!("{body}{check}"))) != 1 {
  350. return Err(ParseAccountIdError);
  351. }
  352. // Decode the two halves, then invert the Feistel permutation to recover
  353. // the raw legs.
  354. let l = u64::from_str_radix(&body[0..13], 36).map_err(|_| ParseAccountIdError)?;
  355. let r = u64::from_str_radix(&body[13..26], 36).map_err(|_| ParseAccountIdError)?;
  356. let (id, sub) = feistel_inv(l, r, id_seed());
  357. Ok(Self {
  358. id: id as i64,
  359. sub: sub as i64,
  360. })
  361. }
  362. }
  363. impl AssetId {
  364. /// Create an `AssetId` from a `u32`.
  365. pub const fn new(id: u32) -> Self {
  366. Self(id)
  367. }
  368. }
  369. /// Identifies a book — a named scope for transfers.
  370. #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
  371. pub struct BookId(pub i64);
  372. impl fmt::Debug for BookId {
  373. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  374. write!(f, "BookId({})", self.0)
  375. }
  376. }
  377. /// The implicit book used when a transfer does not name one. Fixed so that two
  378. /// otherwise-identical transfers hash to the same [`EnvelopeId`] — a random
  379. /// default would break content-addressed idempotency.
  380. pub const DEFAULT_BOOK: BookId = BookId(0);
  381. impl Default for BookId {
  382. /// Deterministic: returns [`DEFAULT_BOOK`]. Use [`BookId::generate`] to mint
  383. /// a fresh unique id for a real book.
  384. fn default() -> Self {
  385. DEFAULT_BOOK
  386. }
  387. }
  388. impl BookId {
  389. /// Create a `BookId` from an `i64`.
  390. pub const fn new(id: i64) -> Self {
  391. Self(id)
  392. }
  393. /// Mint a fresh, process-unique book id. Unlike [`Default`], this is not
  394. /// stable across calls — use it when creating a new [`Book`], never for the
  395. /// implicit book of a transfer.
  396. pub fn generate() -> Self {
  397. // Process-global so the "process-unique" contract holds across threads;
  398. // a per-thread generator can repeat an id on another thread.
  399. static GEN: crate::autoid::AutoId = crate::autoid::AutoId::new();
  400. Self(GEN.next())
  401. }
  402. }
  403. /// Identifies a reservation — the owner token stamped on a posting while it is
  404. /// `PendingInactive`, so only the saga that reserved it may finalize or release it.
  405. #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
  406. pub struct ReservationId(pub i64);
  407. impl fmt::Debug for ReservationId {
  408. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  409. write!(f, "ReservationId({})", self.0)
  410. }
  411. }
  412. impl ReservationId {
  413. /// Create a `ReservationId` from an `i64`.
  414. pub const fn new(id: i64) -> Self {
  415. Self(id)
  416. }
  417. }
  418. impl Default for ReservationId {
  419. fn default() -> Self {
  420. // One process-global generator, not one per thread: its atomic counter
  421. // makes every reservation id unique across threads. A `thread_local`
  422. // generator lets two sagas on different threads mint the same id within
  423. // a millisecond, which collapses the reservation-ownership check and
  424. // allows a double-spend under concurrency.
  425. static GEN: crate::autoid::AutoId = crate::autoid::AutoId::new();
  426. Self(GEN.next())
  427. }
  428. }
  429. // ---------------------------------------------------------------------------
  430. // Book
  431. // ---------------------------------------------------------------------------
  432. /// A Book is a transfer policy scope: it gates which accounts and assets may
  433. /// participate in a transfer. It is **not** the chronological entry log (the
  434. /// transfer log plays that role), and it does **not** partition balances —
  435. /// balances are global; a Book only gates participation.
  436. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
  437. pub struct Book {
  438. /// Stable identity for this book.
  439. pub id: BookId,
  440. /// Human-readable name.
  441. pub name: String,
  442. /// Participation rules for this book.
  443. pub policy: BookPolicy,
  444. }
  445. /// The participation rules for a [`Book`]. An empty field means "no restriction".
  446. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
  447. pub struct BookPolicy {
  448. /// If non-empty, only these assets may appear in movements.
  449. pub allowed_assets: Vec<AssetId>,
  450. /// If non-empty, accounts with ANY of these flags may participate.
  451. pub allowed_flags: AccountFlags,
  452. /// If non-empty, these specific accounts may participate (in addition to flag matches).
  453. pub allowed_accounts: Vec<AccountId>,
  454. }
  455. /// Builder for constructing [`Book`] values.
  456. pub struct BookBuilder {
  457. book: Book,
  458. }
  459. impl BookBuilder {
  460. /// Create a new book builder with the given name.
  461. pub fn new(name: impl Into<String>) -> Self {
  462. Self {
  463. book: Book {
  464. id: BookId::generate(),
  465. name: name.into(),
  466. policy: BookPolicy {
  467. allowed_assets: Vec::new(),
  468. allowed_flags: AccountFlags::empty(),
  469. allowed_accounts: Vec::new(),
  470. },
  471. },
  472. }
  473. }
  474. /// Set the book id explicitly.
  475. pub fn id(mut self, id: BookId) -> Self {
  476. self.book.id = id;
  477. self
  478. }
  479. /// Add an allowed asset.
  480. pub fn allow_asset(mut self, asset: AssetId) -> Self {
  481. self.book.policy.allowed_assets.push(asset);
  482. self
  483. }
  484. /// Set allowed account flags — accounts with ANY of these flags may participate.
  485. pub fn allow_flags(mut self, flags: AccountFlags) -> Self {
  486. self.book.policy.allowed_flags = flags;
  487. self
  488. }
  489. /// Add a specific allowed account.
  490. pub fn allow_account(mut self, account: AccountId) -> Self {
  491. self.book.policy.allowed_accounts.push(account);
  492. self
  493. }
  494. /// Consume the builder and return the [`Book`].
  495. pub fn build(self) -> Book {
  496. self.book
  497. }
  498. }
  499. // ---------------------------------------------------------------------------
  500. // Posting
  501. // ---------------------------------------------------------------------------
  502. /// Lifecycle state of a [`Posting`].
  503. ///
  504. /// ```text
  505. /// Active ──reserve──▶ PendingInactive ──finalize──▶ Inactive (void)
  506. /// ▲ ▲ │
  507. /// │ └─── release (no-op) ┘
  508. /// └────── release ────────┘ (compensation)
  509. /// ```
  510. ///
  511. /// `reserve_postings` and `release_postings` are batch operations:
  512. /// - **reserve**: all postings must be Active, otherwise the batch fails.
  513. /// - **release**: Active is a no-op, PendingInactive reverts to Active,
  514. /// Inactive (void) fails the batch.
  515. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
  516. pub enum PostingStatus {
  517. /// Available for consumption and counted in balance.
  518. Active,
  519. /// Reserved for a transfer; not available for other consumption.
  520. /// Reverts to `Active` on compensation via `release_postings`.
  521. PendingInactive,
  522. /// Consumed by a committed transfer. Kept for audit trail (void).
  523. /// Cannot be released.
  524. Inactive,
  525. }
  526. /// A signed amount of one asset, owned by exactly one account.
  527. ///
  528. /// A positive posting is value controlled by the account; a negative posting is
  529. /// an offset position (issuance, external flow, overdraft, or system balancing).
  530. /// Negative postings are allowed on every policy except `NoOverdraft`.
  531. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
  532. pub struct Posting {
  533. /// Unique identifier derived from the creating transfer.
  534. pub id: PostingId,
  535. /// The account (subaccount) that owns this posting.
  536. pub owner: AccountId,
  537. /// The asset this posting denominates.
  538. pub asset: AssetId,
  539. /// Signed: positive = value controlled by the account, negative = offset position.
  540. pub value: Cent,
  541. /// Lifecycle state — only `Active` postings count toward balance.
  542. pub status: PostingStatus,
  543. /// Owner token while `PendingInactive`. `Some(rid)` iff reserved by saga
  544. /// `rid`; `None` when `Active` or `Inactive`. Only the holder of a matching
  545. /// `ReservationId` may finalize or release a reserved posting.
  546. pub reservation: Option<ReservationId>,
  547. }
  548. impl Posting {
  549. /// Construct an `Active`, unreserved posting.
  550. pub fn new(id: PostingId, owner: AccountId, asset: AssetId, value: Cent) -> Self {
  551. Self {
  552. id,
  553. owner,
  554. asset,
  555. value,
  556. status: PostingStatus::Active,
  557. reservation: None,
  558. }
  559. }
  560. /// Returns `true` if this posting's status is [`PostingStatus::Active`].
  561. pub fn is_active(&self) -> bool {
  562. self.status == PostingStatus::Active
  563. }
  564. }
  565. /// A posting to be created — carries no id yet because the [`PostingId`] depends
  566. /// on the [`EnvelopeId`], which is computed during validation.
  567. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
  568. pub struct NewPosting {
  569. /// The account (subaccount) that will own the created posting.
  570. pub owner: AccountId,
  571. /// The asset this posting denominates.
  572. pub asset: AssetId,
  573. /// Signed amount: positive = value controlled by the account, negative = offset position.
  574. pub value: Cent,
  575. /// Informational provenance — who funded this posting.
  576. pub payer: Option<AccountId>,
  577. }
  578. // ---------------------------------------------------------------------------
  579. // Transfer
  580. // ---------------------------------------------------------------------------
  581. /// Free-form key→value metadata.
  582. pub type Metadata = BTreeMap<String, Vec<u8>>;
  583. /// The unit of atomicity — all of its consumptions and creations apply together
  584. /// or not at all. This is the resolved, internal form produced by the saga
  585. /// pipeline from a [`Transfer`] intent.
  586. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
  587. pub struct Envelope {
  588. /// Posting ids consumed (spent) by this envelope.
  589. pub consumes: Vec<PostingId>,
  590. /// New postings created by this envelope.
  591. pub creates: Vec<NewPosting>,
  592. /// Account version pins for optimistic concurrency.
  593. pub account_snapshots: Vec<AccountSnapshotId>,
  594. /// Book this envelope belongs to.
  595. pub book: BookId,
  596. /// Free-form key-value metadata.
  597. pub metadata: Metadata,
  598. }
  599. impl Envelope {
  600. /// Posting ids consumed (spent) by this envelope.
  601. pub fn consumes(&self) -> &[PostingId] {
  602. &self.consumes
  603. }
  604. /// New postings created by this envelope.
  605. pub fn creates(&self) -> &[NewPosting] {
  606. &self.creates
  607. }
  608. /// Account version pins for optimistic concurrency.
  609. pub fn account_snapshots(&self) -> &[AccountSnapshotId] {
  610. &self.account_snapshots
  611. }
  612. /// Book this envelope belongs to.
  613. pub fn book(&self) -> BookId {
  614. self.book
  615. }
  616. /// Free-form key-value metadata.
  617. pub fn metadata(&self) -> &Metadata {
  618. &self.metadata
  619. }
  620. /// Deduplicated, sorted list of account references in the created postings.
  621. pub fn referenced_accounts(&self) -> Vec<AccountId> {
  622. let mut ids: Vec<AccountId> = self.creates.iter().map(|p| p.owner).collect();
  623. ids.sort();
  624. ids.dedup();
  625. ids
  626. }
  627. /// Set account snapshots.
  628. pub fn set_account_snapshots(&mut self, snapshots: Vec<AccountSnapshotId>) {
  629. self.account_snapshots = snapshots;
  630. }
  631. }
  632. // ---------------------------------------------------------------------------
  633. // EnvelopeBuilder
  634. // ---------------------------------------------------------------------------
  635. /// Builder for constructing [`Envelope`] values.
  636. #[derive(Default)]
  637. pub struct EnvelopeBuilder {
  638. envelope: Envelope,
  639. }
  640. impl EnvelopeBuilder {
  641. /// Create an empty builder.
  642. pub fn new() -> Self {
  643. Self::default()
  644. }
  645. /// Set the posting ids to consume.
  646. pub fn consumes(mut self, ids: Vec<PostingId>) -> Self {
  647. self.envelope.consumes = ids;
  648. self
  649. }
  650. /// Set the new postings to create.
  651. pub fn creates(mut self, postings: Vec<NewPosting>) -> Self {
  652. self.envelope.creates = postings;
  653. self
  654. }
  655. /// Set the book.
  656. pub fn book(mut self, book: BookId) -> Self {
  657. self.envelope.book = book;
  658. self
  659. }
  660. /// Set the account version pins.
  661. pub fn account_snapshots(mut self, snapshots: Vec<AccountSnapshotId>) -> Self {
  662. self.envelope.account_snapshots = snapshots;
  663. self
  664. }
  665. /// Set the free-form metadata.
  666. pub fn metadata(mut self, metadata: Metadata) -> Self {
  667. self.envelope.metadata = metadata;
  668. self
  669. }
  670. /// Consume the builder and return the [`Envelope`].
  671. pub fn build(self) -> Envelope {
  672. self.envelope
  673. }
  674. }
  675. // ---------------------------------------------------------------------------
  676. // Account
  677. // ---------------------------------------------------------------------------
  678. /// Controls how much an account can spend beyond its posting-backed balance.
  679. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
  680. pub enum AccountPolicy {
  681. /// Balance must stay >= 0.
  682. NoOverdraft,
  683. /// Balance must stay >= `floor` (floor < 0).
  684. CappedOverdraft {
  685. /// Minimum allowed balance (must be negative).
  686. floor: Cent,
  687. },
  688. /// No floor — the account can go arbitrarily negative.
  689. UncappedOverdraft,
  690. /// Fees, settlement, market-making, minting. No balance constraints.
  691. SystemAccount,
  692. /// Boundary account representing value entering/leaving the ledger; holds
  693. /// the offset (negative) side of deposits.
  694. ExternalAccount,
  695. }
  696. bitflags::bitflags! {
  697. /// Lifecycle and user-defined flags for an [`Account`].
  698. ///
  699. /// Bits 0–7 are reserved for system flags. Bits 8–31 are available for
  700. /// user-defined flags, which can be used with [`BookPolicy::allowed_flags`]
  701. /// to scope which accounts may participate in a book.
  702. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
  703. pub struct AccountFlags: u32 {
  704. /// Account may not be the source or destination of any transfer.
  705. const FROZEN = 1 << 0;
  706. /// Terminal — no further activity.
  707. const CLOSED = 1 << 1;
  708. /// Holding account for an inflight (authorize/confirm/void) transaction.
  709. /// Parks funds between authorize and settlement; closed once drained.
  710. const INFLIGHT = 1 << 2;
  711. // Bits 3–7: reserved for future system flags.
  712. // Bits 8–31: user-defined.
  713. /// User-defined flag 0.
  714. const USER_0 = 1 << 8;
  715. /// User-defined flag 1.
  716. const USER_1 = 1 << 9;
  717. /// User-defined flag 2.
  718. const USER_2 = 1 << 10;
  719. /// User-defined flag 3.
  720. const USER_3 = 1 << 11;
  721. /// User-defined flag 4.
  722. const USER_4 = 1 << 12;
  723. /// User-defined flag 5.
  724. const USER_5 = 1 << 13;
  725. /// User-defined flag 6.
  726. const USER_6 = 1 << 14;
  727. /// User-defined flag 7.
  728. const USER_7 = 1 << 15;
  729. }
  730. }
  731. /// A registered entity that must exist before it can transact.
  732. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
  733. pub struct Account {
  734. /// Stable identity for this account (base account plus subaccount).
  735. pub id: AccountId,
  736. /// Monotonically increasing version, starts at 1 on creation.
  737. pub version: u64,
  738. /// Overdraft / balance policy.
  739. pub policy: AccountPolicy,
  740. /// Lifecycle flags (frozen, closed).
  741. pub flags: AccountFlags,
  742. /// Book this entity belongs to.
  743. pub book: BookId,
  744. /// Free-form key-value metadata.
  745. pub metadata: Metadata,
  746. }
  747. impl Account {
  748. /// Create a version-1 main-subaccount account with the given policy: no flags,
  749. /// the default book, and empty metadata. Convenience for the common case; set
  750. /// the other fields explicitly when you need them.
  751. pub fn new(id: AccountId, policy: AccountPolicy) -> Self {
  752. Self::new_ref(id, policy)
  753. }
  754. /// Like [`Account::new`] but for a specific subaccount reference.
  755. pub fn new_ref(id: AccountId, policy: AccountPolicy) -> Self {
  756. Self {
  757. id,
  758. version: 1,
  759. policy,
  760. flags: AccountFlags::empty(),
  761. book: DEFAULT_BOOK,
  762. metadata: Metadata::new(),
  763. }
  764. }
  765. /// Returns `true` if the account has the `FROZEN` flag set.
  766. pub fn is_frozen(&self) -> bool {
  767. self.flags.contains(AccountFlags::FROZEN)
  768. }
  769. /// Returns `true` if the account has the `CLOSED` flag set.
  770. pub fn is_closed(&self) -> bool {
  771. self.flags.contains(AccountFlags::CLOSED)
  772. }
  773. }
  774. // ---------------------------------------------------------------------------
  775. // Receipt
  776. // ---------------------------------------------------------------------------
  777. /// Confirmation of a committed transfer, carrying its content-addressed id.
  778. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
  779. pub struct Receipt {
  780. /// Content-addressed id of the committed transfer.
  781. pub transfer_id: EnvelopeId,
  782. }
  783. // ---------------------------------------------------------------------------
  784. // Transfer — intent-based API
  785. // ---------------------------------------------------------------------------
  786. /// A single movement within a transfer: move value from one account to another.
  787. ///
  788. /// Every operation (pay, deposit, withdraw) is expressed as one or more
  789. /// movements. The resolve step aggregates net debits per account and selects
  790. /// postings only for accounts with a positive net debit.
  791. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
  792. pub struct Movement {
  793. /// Account (subaccount) being debited.
  794. pub from: AccountId,
  795. /// Account (subaccount) being credited.
  796. pub to: AccountId,
  797. /// Asset to transfer.
  798. pub asset: AssetId,
  799. /// Amount to transfer (may be negative for offset postings).
  800. pub amount: Cent,
  801. }
  802. /// A transfer intent — one or more movements to execute atomically.
  803. ///
  804. /// The saga pipeline resolves movements into concrete postings ([`Envelope`])
  805. /// during execution. Callers express *what* should happen, not *which postings*
  806. /// to consume.
  807. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
  808. pub struct Transfer {
  809. /// Movements to execute atomically.
  810. pub movements: Vec<Movement>,
  811. /// Book this entity belongs to.
  812. pub book: BookId,
  813. /// Free-form key-value metadata.
  814. pub metadata: Metadata,
  815. }
  816. /// Builder for constructing [`Transfer`] values.
  817. #[derive(Default)]
  818. pub struct TransferBuilder {
  819. transfer: Transfer,
  820. }
  821. impl TransferBuilder {
  822. /// Create an empty builder.
  823. pub fn new() -> Self {
  824. Self::default()
  825. }
  826. /// Add a raw movement between main subaccounts.
  827. pub fn movement(self, from: AccountId, to: AccountId, asset: AssetId, amount: Cent) -> Self {
  828. self.movement_ref(from, to, asset, amount)
  829. }
  830. /// Add a raw movement between specific subaccounts.
  831. pub fn movement_ref(
  832. mut self,
  833. from: AccountId,
  834. to: AccountId,
  835. asset: AssetId,
  836. amount: Cent,
  837. ) -> Self {
  838. self.transfer.movements.push(Movement {
  839. from,
  840. to,
  841. asset,
  842. amount,
  843. });
  844. self
  845. }
  846. /// Add a pay movement between main subaccounts.
  847. pub fn pay(self, from: AccountId, to: AccountId, asset: AssetId, amount: Cent) -> Self {
  848. self.movement(from, to, asset, amount)
  849. }
  850. /// Add a pay movement between two specific subaccounts. See
  851. /// [`movement_ref`](Self::movement_ref).
  852. pub fn pay_ref(self, from: AccountId, to: AccountId, asset: AssetId, amount: Cent) -> Self {
  853. self.movement_ref(from, to, asset, amount)
  854. }
  855. /// Add a deposit: creates an offset posting on the external account and
  856. /// credits the target account. Pushes two movements whose net debit on the
  857. /// external account is zero.
  858. pub fn deposit(
  859. self,
  860. to: AccountId,
  861. asset: AssetId,
  862. amount: Cent,
  863. external: AccountId,
  864. ) -> Result<Self, OverflowError> {
  865. let neg = amount.checked_neg()?;
  866. Ok(self
  867. .movement(external, external, asset, neg)
  868. .movement(external, to, asset, amount))
  869. }
  870. /// Add a withdrawal: move value from an account to an external destination.
  871. pub fn withdraw(
  872. self,
  873. from: AccountId,
  874. asset: AssetId,
  875. amount: Cent,
  876. external: AccountId,
  877. ) -> Self {
  878. self.movement(from, external, asset, amount)
  879. }
  880. /// Set the book.
  881. pub fn book(mut self, book: BookId) -> Self {
  882. self.transfer.book = book;
  883. self
  884. }
  885. /// Set the free-form metadata.
  886. pub fn metadata(mut self, metadata: Metadata) -> Self {
  887. self.transfer.metadata = metadata;
  888. self
  889. }
  890. /// Consume the builder and return the [`Transfer`].
  891. pub fn build(self) -> Transfer {
  892. self.transfer
  893. }
  894. }
  895. // ---------------------------------------------------------------------------
  896. // ToBytes implementations
  897. // ---------------------------------------------------------------------------
  898. impl ToBytes for AccountId {
  899. fn to_bytes(&self) -> Vec<u8> {
  900. // Base id then subaccount, both big-endian, so the subaccount is folded
  901. // into every content hash (envelope ids, posting ids, snapshots).
  902. let mut buf = Vec::with_capacity(16);
  903. buf.extend_from_slice(&self.id.to_be_bytes());
  904. buf.extend_from_slice(&self.sub.to_be_bytes());
  905. buf
  906. }
  907. }
  908. impl ToBytes for AccountSnapshotId {
  909. fn to_bytes(&self) -> Vec<u8> {
  910. let mut buf = Vec::with_capacity(48);
  911. buf.extend_from_slice(&self.account.to_bytes());
  912. buf.extend_from_slice(&self.snapshot_id);
  913. buf
  914. }
  915. }
  916. impl ToBytes for AssetId {
  917. fn to_bytes(&self) -> Vec<u8> {
  918. self.0.to_be_bytes().to_vec()
  919. }
  920. }
  921. impl ToBytes for EnvelopeId {
  922. fn to_bytes(&self) -> Vec<u8> {
  923. self.0.to_vec()
  924. }
  925. }
  926. impl ToBytes for PostingId {
  927. fn to_bytes(&self) -> Vec<u8> {
  928. let mut buf = Vec::with_capacity(34);
  929. buf.extend_from_slice(&self.transfer.0);
  930. write_u16(&mut buf, self.index);
  931. buf
  932. }
  933. }
  934. impl ToBytes for AccountPolicy {
  935. fn to_bytes(&self) -> Vec<u8> {
  936. let mut buf = Vec::with_capacity(9);
  937. match self {
  938. Self::NoOverdraft => buf.push(0),
  939. Self::CappedOverdraft { floor } => {
  940. buf.push(1);
  941. buf.extend(floor.to_bytes());
  942. }
  943. Self::UncappedOverdraft => buf.push(2),
  944. Self::SystemAccount => buf.push(3),
  945. Self::ExternalAccount => buf.push(4),
  946. }
  947. buf
  948. }
  949. }
  950. impl ToBytes for AccountFlags {
  951. fn to_bytes(&self) -> Vec<u8> {
  952. self.bits().to_be_bytes().to_vec()
  953. }
  954. }
  955. impl ToBytes for BookId {
  956. fn to_bytes(&self) -> Vec<u8> {
  957. self.0.to_be_bytes().to_vec()
  958. }
  959. }
  960. impl ToBytes for NewPosting {
  961. fn to_bytes(&self) -> Vec<u8> {
  962. let mut buf = Vec::new();
  963. buf.extend(self.owner.to_bytes());
  964. buf.extend_from_slice(&self.asset.0.to_be_bytes());
  965. buf.extend(self.value.to_bytes());
  966. match &self.payer {
  967. Some(p) => {
  968. buf.push(1);
  969. buf.extend(p.to_bytes());
  970. }
  971. None => buf.push(0),
  972. }
  973. buf
  974. }
  975. }
  976. impl ToBytes for Posting {
  977. fn to_bytes(&self) -> Vec<u8> {
  978. let mut buf = Vec::new();
  979. buf.extend(self.id.to_bytes());
  980. buf.extend(self.owner.to_bytes());
  981. buf.extend_from_slice(&self.asset.0.to_be_bytes());
  982. buf.extend(self.value.to_bytes());
  983. buf.push(match self.status {
  984. PostingStatus::Active => 0,
  985. PostingStatus::PendingInactive => 1,
  986. PostingStatus::Inactive => 2,
  987. });
  988. buf
  989. }
  990. }
  991. impl ToBytes for Envelope {
  992. fn to_bytes(&self) -> Vec<u8> {
  993. let mut buf = Vec::new();
  994. buf.push(CANONICAL_VERSION);
  995. write_u32(&mut buf, self.consumes.len() as u32);
  996. for pid in &self.consumes {
  997. buf.extend(pid.to_bytes());
  998. }
  999. write_u32(&mut buf, self.creates.len() as u32);
  1000. for np in &self.creates {
  1001. buf.extend(np.to_bytes());
  1002. }
  1003. write_u32(&mut buf, self.account_snapshots.len() as u32);
  1004. for snap in &self.account_snapshots {
  1005. buf.extend(snap.to_bytes());
  1006. }
  1007. buf.extend(self.book.to_bytes());
  1008. write_u32(&mut buf, self.metadata.len() as u32);
  1009. for (key, value) in &self.metadata {
  1010. let key_bytes = key.as_bytes();
  1011. write_u32(&mut buf, key_bytes.len() as u32);
  1012. buf.extend_from_slice(key_bytes);
  1013. write_u32(&mut buf, value.len() as u32);
  1014. buf.extend_from_slice(value);
  1015. }
  1016. buf
  1017. }
  1018. }
  1019. impl ToBytes for Account {
  1020. fn to_bytes(&self) -> Vec<u8> {
  1021. let mut buf = Vec::new();
  1022. buf.push(CANONICAL_VERSION);
  1023. buf.extend(self.id.to_bytes());
  1024. write_u64(&mut buf, self.version);
  1025. buf.extend(self.policy.to_bytes());
  1026. buf.extend(self.flags.to_bytes());
  1027. buf.extend(self.book.to_bytes());
  1028. write_u32(&mut buf, self.metadata.len() as u32);
  1029. for (key, value) in &self.metadata {
  1030. let key_bytes = key.as_bytes();
  1031. write_u32(&mut buf, key_bytes.len() as u32);
  1032. buf.extend_from_slice(key_bytes);
  1033. write_u32(&mut buf, value.len() as u32);
  1034. buf.extend_from_slice(value);
  1035. }
  1036. buf
  1037. }
  1038. }
  1039. impl ToBytes for Receipt {
  1040. fn to_bytes(&self) -> Vec<u8> {
  1041. self.transfer_id.0.to_vec()
  1042. }
  1043. }
  1044. #[cfg(test)]
  1045. mod account_id_tests {
  1046. use super::*;
  1047. use std::str::FromStr;
  1048. #[test]
  1049. fn code_structure() {
  1050. let s = AccountId::with_sub(5, 7).to_string();
  1051. // 2 check digits + 26-char base-36 body. No country code.
  1052. assert_eq!(s.len(), 28);
  1053. assert!(s[0..2].bytes().all(|b| b.is_ascii_digit()));
  1054. // The body is Feistel-permuted, so it does NOT expose the raw legs the
  1055. // way an unmixed base-36 rendering (all zeros then "5"/"7") would.
  1056. assert_ne!(&s[2..], "00000000000050000000000007");
  1057. }
  1058. #[test]
  1059. fn code_round_trips() {
  1060. for acc in [
  1061. AccountId::new(0),
  1062. AccountId::new(100),
  1063. AccountId::with_sub(5, 7),
  1064. // High-bit subaccount: exercises the u64-bit-pattern reinterpretation.
  1065. AccountId::with_sub(1, -1),
  1066. AccountId::with_sub(i64::MAX, i64::MIN),
  1067. ] {
  1068. let s = acc.to_string();
  1069. assert_eq!(AccountId::from_str(&s).unwrap(), acc, "round-trip {s}");
  1070. }
  1071. }
  1072. #[test]
  1073. fn parses_a_fixed_vector() {
  1074. // A hardcoded, checksum-valid code (under DEFAULT_ID_SEED) pins the
  1075. // exact encoding, permutation, and checksum, so an accidental change to
  1076. // any of them is caught by a failing parse.
  1077. let code = "123PER2Q81K52QL1HA26CYE1IZH5";
  1078. let expected = AccountId::with_sub(987654321, 12345);
  1079. assert_eq!(AccountId::from_str(code).unwrap(), expected);
  1080. // The grouped (spaced, lower-cased) form parses to the same value.
  1081. assert_eq!(
  1082. AccountId::from_str("123p er2q 81k5 2ql1 ha26 cye1 izh5").unwrap(),
  1083. expected
  1084. );
  1085. // Display reproduces the exact machine form.
  1086. assert_eq!(expected.to_string(), code);
  1087. }
  1088. #[test]
  1089. fn feistel_is_invertible_across_seeds() {
  1090. for &seed in &[0u64, 1, DEFAULT_ID_SEED, u64::MAX] {
  1091. for &(l, r) in &[(0u64, 0u64), (5, 7), (u64::MAX, 1), (42, u64::MAX)] {
  1092. let (el, er) = feistel(l, r, seed);
  1093. assert_eq!(feistel_inv(el, er, seed), (l, r), "seed={seed} l={l} r={r}");
  1094. }
  1095. }
  1096. }
  1097. #[test]
  1098. fn obfuscation_hides_structure() {
  1099. // The default seed is in force.
  1100. assert_eq!(id_seed(), DEFAULT_ID_SEED);
  1101. // Sequential base ids do not produce visibly related codes (avalanche).
  1102. let a = AccountId::new(100).to_string();
  1103. let b = AccountId::new(101).to_string();
  1104. let shared = a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count();
  1105. assert!(shared < 4, "codes share too long a prefix: {a} vs {b}");
  1106. // A base account and its subaccount are likewise not obviously related.
  1107. let main = AccountId::new(100).to_string();
  1108. let sub = AccountId::with_sub(100, 1).to_string();
  1109. assert_ne!(main, sub);
  1110. }
  1111. #[test]
  1112. fn grouped_format_groups_by_four_and_re_parses() {
  1113. let acc = AccountId::with_sub(5, 7);
  1114. let grouped = acc.to_grouped();
  1115. assert!(grouped.contains(' '));
  1116. assert!(grouped.split(' ').all(|g| g.len() <= 4));
  1117. // Grouped format (with spaces) and lower case both parse back.
  1118. assert_eq!(AccountId::from_str(&grouped).unwrap(), acc);
  1119. assert_eq!(AccountId::from_str(&grouped.to_lowercase()).unwrap(), acc);
  1120. }
  1121. #[test]
  1122. fn parses_with_spaces_or_dashes_for_url_safety() {
  1123. let acc = AccountId::with_sub(987654321, 12345);
  1124. let machine = acc.to_string(); // 28 chars, no separators (URL-safe)
  1125. // The same code grouped with spaces (display) or dashes (URL-safe
  1126. // separator) parses back to the same id, as does a mixed/irregular form.
  1127. let spaced = acc.to_grouped();
  1128. let dashed = spaced.replace(' ', "-");
  1129. let mixed = format!("{}-{} {}", &machine[0..4], &machine[4..20], &machine[20..]);
  1130. for s in [&machine, &spaced, &dashed, &mixed] {
  1131. assert_eq!(AccountId::from_str(s).unwrap(), acc, "parse {s}");
  1132. }
  1133. }
  1134. #[test]
  1135. fn from_str_rejects_bad_checksum_and_junk() {
  1136. let good = AccountId::with_sub(5, 7).to_string();
  1137. assert!(AccountId::from_str(&good).is_ok());
  1138. // A helper to overwrite one character while keeping the length.
  1139. let with_char_at = |i: usize, c: char| {
  1140. let mut v: Vec<char> = good.chars().collect();
  1141. v[i] = c;
  1142. v.into_iter().collect::<String>()
  1143. };
  1144. // Flip the last body digit: still base-36 and right length, but the
  1145. // checksum no longer matches, so it is rejected.
  1146. let last = good.len() - 1;
  1147. let flipped = with_char_at(last, if good.ends_with('8') { '9' } else { '8' });
  1148. assert!(AccountId::from_str(&flipped).is_err(), "bad checksum");
  1149. // Structurally malformed inputs are all rejected.
  1150. assert!(AccountId::from_str("").is_err(), "empty");
  1151. assert!(AccountId::from_str("not-a-code").is_err(), "junk");
  1152. assert!(AccountId::from_str(&good[..27]).is_err(), "too short");
  1153. assert!(
  1154. AccountId::from_str(&format!("{good}0")).is_err(),
  1155. "too long"
  1156. );
  1157. // A check digit that is not a digit.
  1158. assert!(
  1159. AccountId::from_str(&with_char_at(0, 'A')).is_err(),
  1160. "alpha check"
  1161. );
  1162. // A non-base-36 character in the body (survives space/dash stripping).
  1163. assert!(
  1164. AccountId::from_str(&with_char_at(5, '*')).is_err(),
  1165. "non-base36 body"
  1166. );
  1167. }
  1168. }