code wiki / (root) / nx_balance.nx

nx_balance.nx source

↩ module page · 261 lines · 10496 B

1// nx_balance.nx -- per-account balance cache for the ledger. 2// 3// module: nishi-core.finance.balance 4// depends: nishi-core.finance.ledger, nishi-core.io.iso8601 5// disk_kb: 5 6// capability: MARKETPLACE 7// 8// license_tier: PUBLIC_NISHI_SUBSTRATE 9// genealogy_id: standard_accounting_running_balance_pattern + 10// cardinal_13_additive_only + 11// nishi_pillar_4_bits_up_sovereign_2026 12// 13// Per-account running-balance cache. The ledger (`nx_ledger.nx`) 14// is the authoritative event log; this primitive maintains O(1) 15// balance lookups by listening to each posted transaction and 16// updating per-account totals. 17// 18// Per Cardinal 13 (additive-only): cache entries are append-only 19// snapshots. Each transaction post creates a new BalanceSnapshot 20// row stamped with the post_ts. Querying current balance returns 21// the latest is_current=1 snapshot; querying historical balance 22// at time T returns the latest snapshot with post_ts <= T. 23// 24// ===== Why a cache when we have the ledger? ======================= 25// 26// Walking the ledger for every balance query is O(N) in 27// transaction count. At 100k+ transactions/year, that's prohibitive 28// for UI dashboards + dunning + fraud-detection workflows. The 29// cache is a denormalized projection that maintains the invariant: 30// 31// sum(line.amount where account_id = A and is_debit = 1) 32// - sum(line.amount where account_id = A and is_debit = 0) 33// == BalanceSnapshot.balance_minor_q10 for A's latest snapshot 34// 35// Substrate periodically asserts this invariant via 36// nx_balance_reconcile_against_ledger (queued). 37 38// nx_safety_envelope: 39// intended_use: AUTO_APPLIED -- primitive-specific tuning queued 40// sil_target: SIL1 41// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail] 42// verdict: NOT_YET_EVALUATED 43 44import "nx_syscalls.nx" 45import "nx_ledger.nx" 46 47// ===== BalanceVerdict sealed enum ================================= 48 49const NX_BALANCE_POSITIVE: i64 = 1 50const NX_BALANCE_ZERO: i64 = 2 51const NX_BALANCE_NEGATIVE: i64 = 3 // overdraft; substrate-level alert 52const NX_BALANCE_UNCONFIRMED: i64 = 4 // pending transaction not posted 53const NX_BALANCE_RECONCILING: i64 = 5 // mid-ledger-walk; do not trust 54const NX_BALANCE_ACCOUNT_NOT_FOUND: i64 = 6 55 56func nx_balance_verdict_name(v: i64) -> *u8 { 57 if v == NX_BALANCE_POSITIVE { return "POSITIVE" } 58 if v == NX_BALANCE_ZERO { return "ZERO" } 59 if v == NX_BALANCE_NEGATIVE { return "NEGATIVE" } 60 if v == NX_BALANCE_UNCONFIRMED { return "UNCONFIRMED" } 61 if v == NX_BALANCE_RECONCILING { return "RECONCILING" } 62 if v == NX_BALANCE_ACCOUNT_NOT_FOUND { return "ACCOUNT_NOT_FOUND" } 63 return "UNKNOWN" 64} 65 66// ===== BalanceSnapshot struct ===================================== 67// 68// One row per balance-change event. Cardinal 13 additive-only: 69// snapshots are never deleted or mutated. is_current=1 marks the 70// latest snapshot for each account_id (only one such row per 71// account at any time). 72 73struct BalanceSnapshot { 74 snapshot_hk: i64, 75 account_id: i64, 76 // Balance at this snapshot (Q10 currency-native minor units) 77 balance_minor_q10: i64, 78 // Triggering transaction 79 triggering_tx_hk: i64, // FK to LedgerTransaction 80 triggering_line_count: i64, // lines in that tx affecting this account 81 // Cumulative counters (useful for UI summaries) 82 cumulative_debits_minor_q10: i64, 83 cumulative_credits_minor_q10: i64, 84 n_transactions_to_date: i64, 85 // Verdict at this snapshot 86 verdict: i64, // NX_BALANCE_* 87 // Lifecycle 88 post_ts_unix: i64, 89 is_current: i64, 90 superseded_by_hk: i64, 91} 92 93const NX_BALANCE_SNAPSHOT_BYTES: i64 = 96 // 12 fields * 8 bytes 94 95// ===== Verdict resolver =========================================== 96 97func nx_balance_resolve_verdict(balance_minor_q10: i64) -> i64 { 98 if balance_minor_q10 > 0 { return NX_BALANCE_POSITIVE } 99 if balance_minor_q10 == 0 { return NX_BALANCE_ZERO } 100 return NX_BALANCE_NEGATIVE 101} 102 103// ===== Apply a transaction line =================================== 104// 105// Called by ledger-post hook after a transaction commits. Each 106// affected line bumps cumulative-debit-or-credit + creates a new 107// is_current=1 snapshot per affected account. Previous is_current 108// snapshot for that account gets is_current=0 + superseded_by_hk 109// pointer. 110 111func nx_balance_apply_line( 112 prev_snapshot: *BalanceSnapshot, 113 line_amount_q10: i64, 114 is_debit: i64, 115 triggering_tx_hk: i64, 116 post_ts_unix: i64, 117 is_debit_normal: i64 118) -> *BalanceSnapshot { 119 let raw: *u8 = sys_mmap(NX_BALANCE_SNAPSHOT_BYTES) 120 let s: *BalanceSnapshot = raw as *BalanceSnapshot 121 s.snapshot_hk = 0 122 s.account_id = if prev_snapshot == 0 as *BalanceSnapshot { 0 } else { prev_snapshot.account_id } 123 s.triggering_tx_hk = triggering_tx_hk 124 s.triggering_line_count = 1 125 s.post_ts_unix = post_ts_unix 126 s.is_current = 1 127 s.superseded_by_hk = 0 128 129 // Carry forward cumulative + balance 130 var prev_balance: i64 = 0 131 var prev_cum_d: i64 = 0 132 var prev_cum_c: i64 = 0 133 var prev_n_tx: i64 = 0 134 if prev_snapshot != 0 as *BalanceSnapshot { 135 prev_balance = prev_snapshot.balance_minor_q10 136 prev_cum_d = prev_snapshot.cumulative_debits_minor_q10 137 prev_cum_c = prev_snapshot.cumulative_credits_minor_q10 138 prev_n_tx = prev_snapshot.n_transactions_to_date 139 } 140 141 // Balance delta depends on account's normal-balance direction: 142 // debit-normal account (ASSET/EXPENSE): 143 // debit → balance increases 144 // credit → balance decreases 145 // credit-normal account (LIABILITY/EQUITY/REVENUE): 146 // debit → balance decreases 147 // credit → balance increases 148 var delta: i64 = 0 149 if is_debit == 1 { 150 s.cumulative_debits_minor_q10 = prev_cum_d + line_amount_q10 151 s.cumulative_credits_minor_q10 = prev_cum_c 152 if is_debit_normal == 1 { delta = line_amount_q10 } 153 if is_debit_normal == 0 { delta = -line_amount_q10 } 154 } 155 if is_debit == 0 { 156 s.cumulative_debits_minor_q10 = prev_cum_d 157 s.cumulative_credits_minor_q10 = prev_cum_c + line_amount_q10 158 if is_debit_normal == 1 { delta = -line_amount_q10 } 159 if is_debit_normal == 0 { delta = line_amount_q10 } 160 } 161 s.balance_minor_q10 = prev_balance + delta 162 s.n_transactions_to_date = prev_n_tx + 1 163 s.verdict = nx_balance_resolve_verdict(s.balance_minor_q10) 164 165 // Mark previous snapshot superseded (Cardinal 13) 166 if prev_snapshot != 0 as *BalanceSnapshot { 167 prev_snapshot.is_current = 0 168 prev_snapshot.superseded_by_hk = s.snapshot_hk 169 } 170 return s 171} 172 173// ===== O(1) current-balance lookup ================================ 174// 175// Caller holds a pointer to the account's latest is_current=1 176// snapshot. Returns balance_minor_q10. If account has no snapshots 177// yet, returns 0 (no transactions posted against it). 178 179func nx_balance_current_minor_q10(latest_snapshot: *BalanceSnapshot) -> i64 { 180 if latest_snapshot == 0 as *BalanceSnapshot { return 0 } 181 if latest_snapshot.is_current == 0 { return 0 } // superseded; caller bug 182 return latest_snapshot.balance_minor_q10 183} 184 185func nx_balance_current_verdict(latest_snapshot: *BalanceSnapshot) -> i64 { 186 if latest_snapshot == 0 as *BalanceSnapshot { return NX_BALANCE_ACCOUNT_NOT_FOUND } 187 return latest_snapshot.verdict 188} 189 190// ===== Sufficient-funds check (for marketplace + escrow flows) === 191// 192// Composes against the latest snapshot. Returns 1 if account can 193// cover the requested debit; 0 otherwise. 194 195func nx_balance_can_debit( 196 latest_snapshot: *BalanceSnapshot, 197 debit_amount_q10: i64, 198 is_debit_normal: i64, 199 allow_overdraft: i64 200) -> i64 { 201 if latest_snapshot == 0 as *BalanceSnapshot { return 0 } 202 if allow_overdraft == 1 { return 1 } // some account kinds permit overdraft 203 204 let current: i64 = latest_snapshot.balance_minor_q10 205 // For debit-normal accounts (ASSET): can debit any positive amount; 206 // the debit increases balance. Always sufficient. 207 if is_debit_normal == 1 { return 1 } 208 // For credit-normal accounts (LIABILITY): a debit DECREASES the 209 // balance. Cannot decrease below zero unless overdraft allowed. 210 if current >= debit_amount_q10 { return 1 } 211 return 0 212} 213 214// ===== Historical balance at time T =============================== 215// 216// Walks the snapshot chain (each snapshot has superseded_by_hk 217// pointer) to find the snapshot with post_ts_unix <= target_unix. 218// Bounded loop per JPL Rule 2 cardinal. 219 220const NX_BALANCE_MAX_SNAPSHOT_WALK: i64 = 1048576 221 222func nx_balance_at_time( 223 latest_snapshot: *BalanceSnapshot, 224 target_unix: i64 225) -> i64 { 226 if latest_snapshot == 0 as *BalanceSnapshot { return 0 } 227 var cursor: *BalanceSnapshot = latest_snapshot 228 var iter: i64 = 0 229 var verdict: i64 = 0 230 while verdict == 0 && iter < NX_BALANCE_MAX_SNAPSHOT_WALK { 231 if cursor == 0 as *BalanceSnapshot { verdict = 1 } 232 if verdict == 0 { 233 if cursor.post_ts_unix <= target_unix { return cursor.balance_minor_q10 } 234 // Walk backward: each snapshot's prev is reachable via 235 // a separate per-account index (queued; v1 walks all 236 // snapshots for this account). Simplified: this primitive 237 // assumes caller passes the snapshot chain head pointer. 238 iter = iter + 1 239 } 240 } 241 return 0 242} 243 244// ===== Reconciliation primitive (periodic invariant check) ======== 245// 246// Walks ALL ledger lines for account_id; sums debits/credits; asserts 247// matches latest snapshot's cumulative_*. Substrate runs this nightly. 248 249func nx_balance_reconcile( 250 latest_snapshot: *BalanceSnapshot, 251 ledger_lines: **LedgerLine, 252 n_lines: i64 253) -> i64 { 254 if latest_snapshot == 0 as *BalanceSnapshot { return NX_BALANCE_ACCOUNT_NOT_FOUND } 255 let target_account: i64 = latest_snapshot.account_id 256 let walked_balance: i64 = nx_ledger_account_balance_minor_q10( 257 target_account, ledger_lines, n_lines) 258 if walked_balance == latest_snapshot.balance_minor_q10 { return NX_BALANCE_POSITIVE } 259 // Cache drift detected — substrate-level alert 260 return NX_BALANCE_RECONCILING 261}