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}