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

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);
 pub const MERCHANT: AccountId = AccountId::new(103);
 
 
 /// Human-readable labels for the seeded accounts, surfaced by the API so the
 /// 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> {
 pub fn account_label(id: AccountId) -> Option<&'static str> {
-    Some(match id {
+    Some(match id.base() {
         TREASURY => "Treasury",
         TREASURY => "Treasury",
         EXTERNAL => "External",
         EXTERNAL => "External",
         ALICE => "Alice",
         ALICE => "Alice",

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

@@ -83,7 +83,7 @@ pub struct AccountId {
 /// public API uses [`AccountId`].
 /// public API uses [`AccountId`].
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct AccountSnapshotId {
 pub struct AccountSnapshotId {
-    /// The account this snapshot belongs to.
+    /// The account (subaccount) this snapshot belongs to.
     pub account: AccountId,
     pub account: AccountId,
     /// Double-SHA256 of the account's state at the time of the snapshot.
     /// Double-SHA256 of the account's state at the time of the snapshot.
     pub snapshot_id: [u8; 32],
     pub snapshot_id: [u8; 32],
@@ -609,7 +609,7 @@ pub enum PostingStatus {
 pub struct Posting {
 pub struct Posting {
     /// Unique identifier derived from the creating transfer.
     /// Unique identifier derived from the creating transfer.
     pub id: PostingId,
     pub id: PostingId,
-    /// The account that owns this posting.
+    /// The account (subaccount) that owns this posting.
     pub owner: AccountId,
     pub owner: AccountId,
     /// The asset this posting denominates.
     /// The asset this posting denominates.
     pub asset: AssetId,
     pub asset: AssetId,
@@ -646,7 +646,7 @@ impl Posting {
 /// on the [`EnvelopeId`], which is computed during validation.
 /// on the [`EnvelopeId`], which is computed during validation.
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct NewPosting {
 pub struct NewPosting {
-    /// The account that will own the created posting.
+    /// The account (subaccount) that will own the created posting.
     pub owner: AccountId,
     pub owner: AccountId,
     /// The asset this posting denominates.
     /// The asset this posting denominates.
     pub asset: AssetId,
     pub asset: AssetId,
@@ -724,7 +724,7 @@ impl Envelope {
         &self.metadata
         &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> {
     pub fn referenced_accounts(&self) -> Vec<AccountId> {
         let mut ids: Vec<AccountId> = self.creates.iter().map(|p| p.owner).collect();
         let mut ids: Vec<AccountId> = self.creates.iter().map(|p| p.owner).collect();
         ids.sort();
         ids.sort();
@@ -855,7 +855,7 @@ bitflags::bitflags! {
 /// A registered entity that must exist before it can transact.
 /// A registered entity that must exist before it can transact.
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct Account {
 pub struct Account {
-    /// Stable identity for this account.
+    /// Stable identity for this account (base account plus subaccount).
     pub id: AccountId,
     pub id: AccountId,
     /// Monotonically increasing version, starts at 1 on creation.
     /// Monotonically increasing version, starts at 1 on creation.
     pub version: u64,
     pub version: u64,
@@ -872,10 +872,15 @@ pub struct Account {
 }
 }
 
 
 impl 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 {
     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 {
         Self {
             id,
             id,
             version: 1,
             version: 1,
@@ -920,9 +925,9 @@ pub struct Receipt {
 /// postings only for accounts with a positive net debit.
 /// postings only for accounts with a positive net debit.
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct Movement {
 pub struct Movement {
-    /// Account being debited.
+    /// Account (subaccount) being debited.
     pub from: AccountId,
     pub from: AccountId,
-    /// Account being credited.
+    /// Account (subaccount) being credited.
     pub to: AccountId,
     pub to: AccountId,
     /// Asset to transfer.
     /// Asset to transfer.
     pub asset: AssetId,
     pub asset: AssetId,
@@ -959,8 +964,13 @@ impl TransferBuilder {
         Self::default()
         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,
         mut self,
         from: AccountId,
         from: AccountId,
         to: AccountId,
         to: AccountId,
@@ -976,28 +986,15 @@ impl TransferBuilder {
         self
         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 {
     pub fn pay(self, from: AccountId, to: AccountId, asset: AssetId, amount: Cent) -> Self {
         self.movement(from, to, asset, amount)
         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
     /// Add a pay movement between two specific subaccounts. See
     /// [`movement_ref`](Self::movement_ref).
     /// [`movement_ref`](Self::movement_ref).
     pub fn pay_ref(self, from: AccountId, to: AccountId, asset: AssetId, amount: Cent) -> Self {
     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
     /// 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.
 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 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
 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
 account does not roll up its subaccounts, so several independent balances live
 under one owner, each individually addressable, drained, and closed. Aggregate
 under one owner, each individually addressable, drained, and closed. Aggregate
 reads take a base `id` plus an optional subaccount filter; exact entity
 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
 `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.
 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
 mistyped ids. It is a presentation/routing form (the dashboard URLs); storage
 keeps the two `i64` legs.
 keeps the two `i64` legs.