Просмотр исходного кода

Add subaccount-scoped account and movement API and docs

Split the reusable subaccount surface out of the inflight work so it can
land on its own. `AccountId` already carries a subaccount dimension
(ADR-0012); this exposes the ergonomics and documentation that go with it.

`Account::new_ref` builds an account for a specific subaccount reference,
with `Account::new` delegating to it for the main subaccount. On the
builder, `movement_ref`/`pay_ref` are now the primitives that target a
specific subaccount and `movement`/`pay` delegate to them, inverting the
previous arrangement. Doc comments on the posting, movement, and account
types are clarified to note the subaccount dimension.

The dashboard's `account_label` now keys on the base account so a
subaccount inherits its base account's label.

Docs gain a Subaccounts section in accounts.md (the `{id, sub}` model,
per-subaccount balances, `list_subaccounts`) and a corrected IBAN example
plus a subaccount note in the glossary.
Cesar Rodas 1 день назад
Родитель
Сommit
25d490cb45
4 измененных файлов с 51 добавлено и 33 удалено
  1. 3 2
      crates/kuatia-dashboard/src/seed.rs
  2. 24 27
      crates/kuatia-types/src/lib.rs
  3. 20 1
      doc/accounts.md
  4. 4 3
      doc/glossary.md

+ 3 - 2
crates/kuatia-dashboard/src/seed.rs

@@ -22,9 +22,10 @@ pub const CAROL: AccountId = AccountId::new(102);
 pub const MERCHANT: AccountId = AccountId::new(103);
 
 /// Human-readable labels for the seeded accounts, surfaced by the API so the
-/// frontend can show names instead of raw ids.
+/// frontend can show names instead of raw ids. Labels are per base account;
+/// a subaccount (an inflight hold) shares its base account's label.
 pub fn account_label(id: AccountId) -> Option<&'static str> {
-    Some(match id {
+    Some(match id.base() {
         TREASURY => "Treasury",
         EXTERNAL => "External",
         ALICE => "Alice",

+ 24 - 27
crates/kuatia-types/src/lib.rs

@@ -83,7 +83,7 @@ pub struct AccountId {
 /// public API uses [`AccountId`].
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct AccountSnapshotId {
-    /// The account this snapshot belongs to.
+    /// The account (subaccount) this snapshot belongs to.
     pub account: AccountId,
     /// Double-SHA256 of the account's state at the time of the snapshot.
     pub snapshot_id: [u8; 32],
@@ -609,7 +609,7 @@ pub enum PostingStatus {
 pub struct Posting {
     /// Unique identifier derived from the creating transfer.
     pub id: PostingId,
-    /// The account that owns this posting.
+    /// The account (subaccount) that owns this posting.
     pub owner: AccountId,
     /// The asset this posting denominates.
     pub asset: AssetId,
@@ -646,7 +646,7 @@ impl Posting {
 /// on the [`EnvelopeId`], which is computed during validation.
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct NewPosting {
-    /// The account that will own the created posting.
+    /// The account (subaccount) that will own the created posting.
     pub owner: AccountId,
     /// The asset this posting denominates.
     pub asset: AssetId,
@@ -724,7 +724,7 @@ impl Envelope {
         &self.metadata
     }
 
-    /// Deduplicated, sorted list of accounts referenced in the created postings.
+    /// Deduplicated, sorted list of account references in the created postings.
     pub fn referenced_accounts(&self) -> Vec<AccountId> {
         let mut ids: Vec<AccountId> = self.creates.iter().map(|p| p.owner).collect();
         ids.sort();
@@ -855,7 +855,7 @@ bitflags::bitflags! {
 /// A registered entity that must exist before it can transact.
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct Account {
-    /// Stable identity for this account.
+    /// Stable identity for this account (base account plus subaccount).
     pub id: AccountId,
     /// Monotonically increasing version, starts at 1 on creation.
     pub version: u64,
@@ -872,10 +872,15 @@ pub struct Account {
 }
 
 impl Account {
-    /// Create a version-1 account with the given policy: no flags, the default
-    /// book, and empty user data / metadata. Convenience for the common case —
-    /// set the other fields explicitly when you need them.
+    /// Create a version-1 main-subaccount account with the given policy: no flags,
+    /// the default book, and empty user data / metadata. Convenience for the common
+    /// case — set the other fields explicitly when you need them.
     pub fn new(id: AccountId, policy: AccountPolicy) -> Self {
+        Self::new_ref(id, policy)
+    }
+
+    /// Like [`Account::new`] but for a specific subaccount reference.
+    pub fn new_ref(id: AccountId, policy: AccountPolicy) -> Self {
         Self {
             id,
             version: 1,
@@ -920,9 +925,9 @@ pub struct Receipt {
 /// postings only for accounts with a positive net debit.
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct Movement {
-    /// Account being debited.
+    /// Account (subaccount) being debited.
     pub from: AccountId,
-    /// Account being credited.
+    /// Account (subaccount) being credited.
     pub to: AccountId,
     /// Asset to transfer.
     pub asset: AssetId,
@@ -959,8 +964,13 @@ impl TransferBuilder {
         Self::default()
     }
 
-    /// Add a raw movement.
-    pub fn movement(
+    /// Add a raw movement between main subaccounts.
+    pub fn movement(self, from: AccountId, to: AccountId, asset: AssetId, amount: Cent) -> Self {
+        self.movement_ref(from, to, asset, amount)
+    }
+
+    /// Add a raw movement between specific subaccounts.
+    pub fn movement_ref(
         mut self,
         from: AccountId,
         to: AccountId,
@@ -976,28 +986,15 @@ impl TransferBuilder {
         self
     }
 
-    /// Add a pay movement: transfer value between two accounts.
+    /// Add a pay movement between main subaccounts.
     pub fn pay(self, from: AccountId, to: AccountId, asset: AssetId, amount: Cent) -> Self {
         self.movement(from, to, asset, amount)
     }
 
-    /// Add a movement between two specific subaccounts. Identical to
-    /// [`movement`](Self::movement) — `from`/`to` already carry a subaccount —
-    /// but names the subaccount intent at the call site (ADR-0012).
-    pub fn movement_ref(
-        self,
-        from: AccountId,
-        to: AccountId,
-        asset: AssetId,
-        amount: Cent,
-    ) -> Self {
-        self.movement(from, to, asset, amount)
-    }
-
     /// Add a pay movement between two specific subaccounts. See
     /// [`movement_ref`](Self::movement_ref).
     pub fn pay_ref(self, from: AccountId, to: AccountId, asset: AssetId, amount: Cent) -> Self {
-        self.movement(from, to, asset, amount)
+        self.movement_ref(from, to, asset, amount)
     }
 
     /// Add a deposit: creates an offset posting on the external account and

+ 20 - 1
doc/accounts.md

@@ -137,9 +137,28 @@ preventing TOCTOU races where an account is mutated between load and apply.
 
 The saga `commit()` path auto-populates snapshots when none are provided.
 
+## Subaccounts
+
+An account is identified by a base id plus an `i64` **subaccount**, written
+`AccountId { id, sub }`; `sub = 0` is the main account. Each `(id,
+sub)` is its own record with its own policy, flags, book, and version, so a
+subaccount is a full account that happens to share a base id. Subaccounts are how
+one account holds many concurrent inflights: an inflight hold is a subaccount of
+its destination, keyed by a value derived from the trade (see
+[adr/0012-subaccounts.md](adr/0012-subaccounts.md)).
+
+Balances are reported **segregated per subaccount**, never summed across them:
+
+- `balance(&AccountId, asset)` reads one subaccount.
+- `balances(&AccountId, asset, sub)` returns one entry per non-closed subaccount
+  (`sub = None` spans all; `Some(s)` filters to one). Closed subaccounts are
+  excluded.
+- `list_subaccounts(&AccountId)` lists the non-closed subaccounts of a base
+  account.
+
 ## Balance Computation
 
-Balance for an (account, asset) pair is computed as:
+Balance for an (account reference, asset) pair is computed as:
 
 ```
 balance(account, asset) = sum(p.value for p in postings

+ 4 - 3
doc/glossary.md

@@ -45,12 +45,13 @@ sub)` names one; `AccountId::base()` returns the main account of an id. A base
 account does not roll up its subaccounts, so several independent balances live
 under one owner, each individually addressable, drained, and closed. Aggregate
 reads take a base `id` plus an optional subaccount filter; exact entity
-operations take the full `AccountId`.
+operations take the full `AccountId`. Subaccounts let one account hold many
+concurrent inflights (a hold is a subaccount of its destination).
 
 `AccountId` also has an IBAN-style string view via `Display` / `FromStr`: two
 mod-97 check digits + a base-36 body of the two legs (no country code), e.g.
-`9200000000000050000000000007` for `{ id: 5, sub: 7 }` (grouped:
-`9200 0000 0000 0050 0000 0000 07`). Parsing validates the checksum, rejecting
+`221RDWNSN4VCQNK2NN42KJFSAOLI` for `{ id: 5, sub: 7 }` (grouped:
+`221R DWNS N4VC QNK2 NN42 KJFS AOLI`). Parsing validates the checksum, rejecting
 mistyped ids. It is a presentation/routing form (the dashboard URLs); storage
 keeps the two `i64` legs.