code wiki / (root) / nx_fin_ledger.nx

nx_fin_ledger.nx source

↩ module page · 401 lines · 15922 B

1// nx_fin_ledger.nx -- R2 of THE NISHI FINANCIAL ECOSYSTEM: the content-addressed, append-only, 2// DOUBLE-ENTRY ledger. Net worth is a VIEW (folded from history), never a stored number. 3// 4// WHY THIS SHAPE: 5// * NO-FLOAT: every amount is exact i64 minor units (cents); all arithmetic via nx_money (mny_add). 6// * ADDITIVE-ONLY (#13): there is NO delete/update of a transaction. History is the truth. A 7// correction is a new compensating transaction, never an edit. 8// * CONTENT-ADDRESSED (self-proof of integrity): each transaction is canon-encoded -> a CID 9// (nxc1-<sha256>); the CID is its storage key. Same content -> same id everywhere. 10// * IDEMPOTENT (#10): re-posting the SAME (extid + content) is a no-op (same CID already present). 11// `extid` is the caller's idempotency key so two GENUINELY distinct but identical-looking 12// transactions stay distinct (different extid -> different CID). 13// * NEVER-BRICK FINANCIAL ANALOG (#26): an UNBALANCED transaction (postings not netting to zero) 14// is REJECTED BY CONSTRUCTION before anything is written -- a corrupt entry can never be recorded. 15// 16// SIGN CONVENTION (unified accounting-equation space): 17// Every posting is a signed cents delta. A transaction is VALID iff the signed sum of its postings 18// == 0 (debits == credits). Asset balances are positive (owned); liability balances are negative 19// (owed). NET WORTH = sum of balances over accounts whose class is asset or liability 20// (equity/income/expense are excluded -- they reconcile the books, they are not net worth). 21// Proof the math is real: paying a liability from an asset (asset down, liability up toward zero) 22// leaves net worth INVARIANT; spending on an expense (asset down, no asset/liability offset) lowers 23// net worth. The R2 gate asserts exactly these. 24// 25// STORAGE: sovereign seg-store (nx_seg_store) under a path prefix (e.g. "knowledge/store/finled-"). 26// key "fin:txnidx" -> newline-joined list of transaction CIDs (the append-only log index) 27// key "fin:acctidx" -> newline-joined list of account names 28// key <txn-CID> -> the canonical NXR1 transaction record 29// key "acctclass:<name>" -> the account's class string (NUL-terminated) 30// segid = sys_now_us(): microsecond ids are distinct across the fsync+rename gap between commits, so 31// no two commits share a segment file (a ms-resolution id could collide in a tight loop and lose a 32// record -- unacceptable for money). SCALING (#21): one segment per post is O(segments^2) at open; 33// nx_seg_store ss_compact merges segments -- that is the later durability/scale rung. 34// license_tier: ORIGINAL 35import "nx_syscalls.nx" 36import "nx_money.nx" 37import "nx_canon_cid.nx" 38import "nx_uxf_decode.nx" 39import "nx_seg_store.nx" 40const K_MAGIC_262144: i64 = 262144 41const K_MAGIC_8192: i64 = 8192 42const K_MAGIC_1048576: i64 = 1048576 43 44func fl_len(s: *u8) -> i64 { 45 var n: i64 = 0 46 while s[n] != (0 as u8) { n = n + 1 } 47 return n 48} 49 50func fl_streq(a: *u8, b: *u8) -> i64 { 51 var i: i64 = 0 52 while 1 == 1 { 53 if a[i] != b[i] { return 0 } 54 if a[i] == (0 as u8) { return 1 } 55 i = i + 1 56 } 57 return 1 58} 59 60// copy null-terminated src -> dst; returns length (position of the NUL). 61func fl_cpy(dst: *u8, src: *u8) -> i64 { 62 var i: i64 = 0 63 while src[i] != (0 as u8) { dst[i] = src[i]; i = i + 1 } 64 dst[i] = 0 as u8 65 return i 66} 67 68// signed decimal of v into out (NUL-terminated); returns length. 69func fl_itoa(v: i64, out: *u8) -> i64 { 70 if v == 0 { out[0] = 0x30 as u8; out[1] = 0 as u8; return 1 } 71 var neg: i64 = 0 72 var m: i64 = v 73 if m < 0 { neg = 1; m = 0 - m } 74 let tmp: *u8 = sys_mmap(32) 75 var k: i64 = 0 76 while m > 0 { tmp[k] = (0x30 + (m % 10)) as u8; m = m / 10; k = k + 1 } 77 var p: i64 = 0 78 if neg == 1 { out[p] = 0x2d as u8; p = p + 1 } 79 var i: i64 = k - 1 80 while i >= 0 { out[p] = tmp[i]; p = p + 1; i = i - 1 } 81 out[p] = 0 as u8 82 return p 83} 84 85// parse a signed decimal string ([-]digits) -> i64. 86func fl_atoi(s: *u8) -> i64 { 87 var i: i64 = 0 88 var neg: i64 = 0 89 if s[0] == (0x2d as u8) { neg = 1; i = 1 } 90 var v: i64 = 0 91 while s[i] != (0 as u8) { 92 let c: i64 = s[i] 93 if c >= 0x30 { if c <= 0x39 { v = v * 10 + (c - 0x30) } } 94 i = i + 1 95 } 96 if neg == 1 { return 0 - v } 97 return v 98} 99 100// build a posting field key: a single char `c` followed by the decimal index (e.g. 'a'+3 -> "a3"). 101func fl_pkey(c: i64, idx: i64, out: *u8) -> i64 { 102 out[0] = c as u8 103 var p: i64 = 1 104 if idx == 0 { out[1] = 0x30 as u8; out[2] = 0 as u8; return 2 } 105 let tmp: *u8 = sys_mmap(32) 106 var m: i64 = idx 107 var k: i64 = 0 108 while m > 0 { tmp[k] = (0x30 + (m % 10)) as u8; m = m / 10; k = k + 1 } 109 var i: i64 = k - 1 110 while i >= 0 { out[p] = tmp[i]; p = p + 1; i = i - 1 } 111 out[p] = 0 as u8 112 return p 113} 114 115// linear-find a decoded field value by key name; null pointer if absent. 116func fl_get(keys: *i64, vals: *i64, nf: i64, key: *u8) -> *u8 { 117 var i: i64 = 0 118 while i < nf { 119 let kp: *u8 = keys[i] as *u8 120 if fl_streq(kp, key) == 1 { return vals[i] as *u8 } 121 i = i + 1 122 } 123 return 0 as *u8 124} 125 126// is `item` (length il) present as a whole newline-bounded line in list[0..listlen)? 127func fl_idx_has(list: *u8, listlen: i64, item: *u8, il: i64) -> i64 { 128 var i: i64 = 0 129 var found: i64 = 0 130 while i < listlen { 131 if found == 0 { 132 var k: i64 = 0 133 var eqf: i64 = 1 134 while k < il { 135 if (i + k) >= listlen { eqf = 0; k = il } 136 else { 137 let lc: i64 = list[i + k] 138 let ic: i64 = item[k] 139 if lc != ic { eqf = 0; k = il } else { k = k + 1 } 140 } 141 } 142 if eqf == 1 { 143 let endpos: i64 = i + il 144 if endpos >= listlen { found = 1 } 145 else { if list[endpos] == (10 as u8) { found = 1 } } 146 } 147 var adv: i64 = 1 148 while adv == 1 { 149 if i >= listlen { adv = 0 } 150 else { 151 let c: i64 = list[i] 152 i = i + 1 153 if c == 10 { adv = 0 } 154 } 155 } 156 } else { i = listlen } 157 } 158 return found 159} 160 161// signed sum of n posting amounts (via the no-float money engine). 162func fl_sum(amts: *i64, n: i64) -> i64 { 163 var s: i64 = 0 164 var i: i64 = 0 165 while i < n { s = mny_add(s, amts[i]); i = i + 1 } 166 return s 167} 168 169// sum the postings in a decoded transaction record that target `name`. 170func fl_txn_acct_sum(keys: *i64, vals: *i64, nf: i64, name: *u8) -> i64 { 171 let npp: *u8 = fl_get(keys, vals, nf, "np\x00" as *u8) 172 if (npp as i64) == 0 { return 0 } 173 let np: i64 = fl_atoi(npp) 174 var s: i64 = 0 175 var i: i64 = 0 176 while i < np { 177 let akey: *u8 = sys_mmap(24) 178 fl_pkey(0x61, i, akey) 179 let av: *u8 = fl_get(keys, vals, nf, akey) 180 if (av as i64) != 0 { 181 if fl_streq(av, name) == 1 { 182 let mkey: *u8 = sys_mmap(24) 183 fl_pkey(0x6d, i, mkey) 184 let mv: *u8 = fl_get(keys, vals, nf, mkey) 185 if (mv as i64) != 0 { s = mny_add(s, fl_atoi(mv)) } 186 } 187 } 188 i = i + 1 189 } 190 return s 191} 192 193// canon-encode a transaction (type/extid/date/memo/np + a{i}/m{i} postings) into out; returns length. 194func fl_build_txn(extid: *u8, date: *u8, memo: *u8, accts: *i64, amts: *i64, n: i64, out: *u8) -> i64 { 195 let keys: *i64 = sys_mmap(8 * 64) as *i64 196 let vals: *i64 = sys_mmap(8 * 64) as *i64 197 var nf: i64 = 0 198 keys[nf] = "type\x00" as *u8 as i64; vals[nf] = "txn\x00" as *u8 as i64; nf = nf + 1 199 keys[nf] = "extid\x00" as *u8 as i64; vals[nf] = extid as i64; nf = nf + 1 200 keys[nf] = "date\x00" as *u8 as i64; vals[nf] = date as i64; nf = nf + 1 201 keys[nf] = "memo\x00" as *u8 as i64; vals[nf] = memo as i64; nf = nf + 1 202 let npbuf: *u8 = sys_mmap(24) 203 fl_itoa(n, npbuf) 204 keys[nf] = "np\x00" as *u8 as i64; vals[nf] = npbuf as i64; nf = nf + 1 205 var i: i64 = 0 206 while i < n { 207 let ak: *u8 = sys_mmap(24) 208 fl_pkey(0x61, i, ak) 209 let mk: *u8 = sys_mmap(24) 210 fl_pkey(0x6d, i, mk) 211 let mv: *u8 = sys_mmap(32) 212 fl_itoa(amts[i], mv) 213 keys[nf] = ak as i64; vals[nf] = accts[i]; nf = nf + 1 214 keys[nf] = mk as i64; vals[nf] = mv as i64; nf = nf + 1 215 i = i + 1 216 } 217 return canon_encode(keys, vals, nf, out) 218} 219 220// register an account with its class (asset|liability|equity|income|expense). Idempotent. 221// returns 1 newly added, 2 already present, negative on store error. 222func fl_acct_open(prefix: *u8, name: *u8, class: *u8) -> i64 { 223 let nl: i64 = fl_len(name) 224 let idxbuf: *u8 = sys_mmap(K_MAGIC_262144) 225 var idxlen: i64 = 0 226 let h: *i64 = ss_open(prefix) 227 if (h as i64) != 0 { 228 let pp: *i64 = sys_mmap(16) as *i64 229 let ll: *i64 = sys_mmap(16) as *i64 230 if ss_hget(h, "fin:acctidx\x00" as *u8, pp, ll) == 1 { 231 let src: *u8 = pp[0] as *u8 232 let sn: i64 = ll[0] 233 var t: i64 = 0 234 while t < sn { idxbuf[t] = src[t]; t = t + 1 } 235 idxlen = sn 236 } 237 } 238 if fl_idx_has(idxbuf, idxlen, name, nl) == 1 { return 2 } 239 var t2: i64 = 0 240 while t2 < nl { idxbuf[idxlen] = name[t2]; idxlen = idxlen + 1; t2 = t2 + 1 } 241 idxbuf[idxlen] = 10 as u8; idxlen = idxlen + 1 242 let ck: *u8 = sys_mmap(512) 243 var cp: i64 = fl_cpy(ck, "acctclass:\x00" as *u8) 244 var s: i64 = 0 245 while name[s] != (0 as u8) { ck[cp] = name[s]; cp = cp + 1; s = s + 1 } 246 ck[cp] = 0 as u8 247 let w: *i64 = ss_begin() 248 // store class WITH its trailing NUL (fl_len+1) so reads come back NUL-terminated. 249 if ss_add(w, 1, ck, class, fl_len(class) + 1) != 0 { return 0 - 10 } 250 if ss_add(w, 1, "fin:acctidx\x00" as *u8, idxbuf, idxlen) != 0 { return 0 - 11 } 251 if ss_commit(prefix, w, sys_now_us()) != 0 { return 0 - 12 } 252 return 1 253} 254 255// post a balanced transaction. extid = idempotency key. accts[] = i64 array of account-name *u8 256// pointers; amts[] = i64 array of signed cents; n = posting count. cidout (>=96 bytes) gets the CID. 257// returns 1 newly recorded, 2 duplicate (idempotent no-op), negative = REJECTED (unbalanced/error). 258func fl_post(prefix: *u8, extid: *u8, date: *u8, memo: *u8, accts: *i64, amts: *i64, n: i64, cidout: *u8) -> i64 { 259 // NEVER-BRICK guard: an unbalanced or degenerate transaction is rejected BEFORE any write. 260 if n < 2 { return 0 - 1 } 261 if fl_sum(amts, n) != 0 { return 0 - 2 } 262 let rec: *u8 = sys_mmap(K_MAGIC_8192) 263 let rl: i64 = fl_build_txn(extid, date, memo, accts, amts, n, rec) 264 cid_of(rec, rl, cidout) 265 let cl: i64 = fl_len(cidout) 266 let idxbuf: *u8 = sys_mmap(K_MAGIC_1048576) 267 var idxlen: i64 = 0 268 let h: *i64 = ss_open(prefix) 269 if (h as i64) != 0 { 270 let pp: *i64 = sys_mmap(16) as *i64 271 let ll: *i64 = sys_mmap(16) as *i64 272 if ss_hget(h, "fin:txnidx\x00" as *u8, pp, ll) == 1 { 273 let src: *u8 = pp[0] as *u8 274 let sn: i64 = ll[0] 275 var t: i64 = 0 276 while t < sn { idxbuf[t] = src[t]; t = t + 1 } 277 idxlen = sn 278 } 279 } 280 // idempotency: same content already recorded -> no-op 281 if fl_idx_has(idxbuf, idxlen, cidout, cl) == 1 { return 2 } 282 var t2: i64 = 0 283 while t2 < cl { idxbuf[idxlen] = cidout[t2]; idxlen = idxlen + 1; t2 = t2 + 1 } 284 idxbuf[idxlen] = 10 as u8; idxlen = idxlen + 1 285 // atomic: the record (keyed by CID) + the updated index, in ONE segment commit. 286 let w: *i64 = ss_begin() 287 if ss_add(w, 1, cidout, rec, rl) != 0 { return 0 - 10 } 288 if ss_add(w, 1, "fin:txnidx\x00" as *u8, idxbuf, idxlen) != 0 { return 0 - 11 } 289 if ss_commit(prefix, w, sys_now_us()) != 0 { return 0 - 12 } 290 return 1 291} 292 293// fold all postings for `name` across the append-only log; returns balance in cents. 294func fl_balance(prefix: *u8, name: *u8) -> i64 { 295 let h: *i64 = ss_open(prefix) 296 if (h as i64) == 0 { return 0 } 297 let pp: *i64 = sys_mmap(16) as *i64 298 let ll: *i64 = sys_mmap(16) as *i64 299 if ss_hget(h, "fin:txnidx\x00" as *u8, pp, ll) != 1 { return 0 } 300 let list: *u8 = pp[0] as *u8 301 let listlen: i64 = ll[0] 302 var bal: i64 = 0 303 var i: i64 = 0 304 while i < listlen { 305 let cidbuf: *u8 = sys_mmap(96) 306 var k: i64 = 0 307 var adv: i64 = 1 308 while adv == 1 { 309 if i >= listlen { adv = 0 } 310 else { 311 let c: i64 = list[i] 312 i = i + 1 313 if c == 10 { adv = 0 } else { cidbuf[k] = c as u8; k = k + 1 } 314 } 315 } 316 cidbuf[k] = 0 as u8 317 if k > 0 { 318 let rp: *i64 = sys_mmap(16) as *i64 319 let rn: *i64 = sys_mmap(16) as *i64 320 if ss_hget(h, cidbuf, rp, rn) == 1 { 321 let rbytes: *u8 = rp[0] as *u8 322 let rlen: i64 = rn[0] 323 let keys: *i64 = sys_mmap(8 * 256) as *i64 324 let vals: *i64 = sys_mmap(8 * 256) as *i64 325 let nf: i64 = canon_decode(rbytes, rlen, keys, vals, 256) 326 if nf > 0 { bal = mny_add(bal, fl_txn_acct_sum(keys, vals, nf, name)) } 327 } 328 } 329 } 330 return bal 331} 332 333// net worth = sum of balances over accounts whose class is asset or liability. 334func fl_networth(prefix: *u8) -> i64 { 335 let h: *i64 = ss_open(prefix) 336 if (h as i64) == 0 { return 0 } 337 let pp: *i64 = sys_mmap(16) as *i64 338 let ll: *i64 = sys_mmap(16) as *i64 339 if ss_hget(h, "fin:acctidx\x00" as *u8, pp, ll) != 1 { return 0 } 340 let list: *u8 = pp[0] as *u8 341 let listlen: i64 = ll[0] 342 var nw: i64 = 0 343 var i: i64 = 0 344 while i < listlen { 345 let namebuf: *u8 = sys_mmap(256) 346 var k: i64 = 0 347 var adv: i64 = 1 348 while adv == 1 { 349 if i >= listlen { adv = 0 } 350 else { 351 let c: i64 = list[i] 352 i = i + 1 353 if c == 10 { adv = 0 } else { namebuf[k] = c as u8; k = k + 1 } 354 } 355 } 356 namebuf[k] = 0 as u8 357 if k > 0 { 358 let ck: *u8 = sys_mmap(512) 359 var cp: i64 = fl_cpy(ck, "acctclass:\x00" as *u8) 360 var s: i64 = 0 361 while namebuf[s] != (0 as u8) { ck[cp] = namebuf[s]; cp = cp + 1; s = s + 1 } 362 ck[cp] = 0 as u8 363 let rp: *i64 = sys_mmap(16) as *i64 364 let rn: *i64 = sys_mmap(16) as *i64 365 var counts: i64 = 0 366 if ss_hget(h, ck, rp, rn) == 1 { 367 let clz: *u8 = rp[0] as *u8 368 if fl_streq(clz, "asset\x00" as *u8) == 1 { counts = 1 } 369 if fl_streq(clz, "liability\x00" as *u8) == 1 { counts = 1 } 370 } 371 if counts == 1 { nw = mny_add(nw, fl_balance(prefix, namebuf)) } 372 } 373 } 374 return nw 375} 376 377// number of distinct transactions in the append-only log. 378func fl_txn_count(prefix: *u8) -> i64 { 379 let h: *i64 = ss_open(prefix) 380 if (h as i64) == 0 { return 0 } 381 let pp: *i64 = sys_mmap(16) as *i64 382 let ll: *i64 = sys_mmap(16) as *i64 383 if ss_hget(h, "fin:txnidx\x00" as *u8, pp, ll) != 1 { return 0 } 384 let list: *u8 = pp[0] as *u8 385 let listlen: i64 = ll[0] 386 var cnt: i64 = 0 387 var i: i64 = 0 388 while i < listlen { 389 if list[i] == (10 as u8) { cnt = cnt + 1 } 390 i = i + 1 391 } 392 return cnt 393} 394 395// retrieve a transaction's raw record bytes by CID (proves history is intact + immutable). 396// ptrout[0]/lenout[0] receive bytes ptr + length. returns 1 found, 0 tombstoned, -1 absent. 397func fl_get_txn(prefix: *u8, cid: *u8, ptrout: *i64, lenout: *i64) -> i64 { 398 let h: *i64 = ss_open(prefix) 399 if (h as i64) == 0 { return 0 - 1 } 400 return ss_hget(h, cid, ptrout, lenout) 401}