nx_ledger_lib.nx source
↩ module page · 280 lines · 12621 B
1// nx_ledger_lib.nx -- F981: THE SOVEREIGN DOUBLE-ENTRY LEDGER (the first byte of Nishi finance).
2// Two entities (accounts, transfers) and ONE invariant: every transfer DEBITS one account and
3// CREDITS another by the same amount, so sum(debits) == sum(credits) ALWAYS. Money cannot be
4// created or destroyed -- not by promise, BY CONSTRUCTION, and the gate proves it.
5//
6// Money = integer MINOR UNITS (cents). No float anywhere -- a float ledger drifts cent-by-cent and
7// an auditor cannot reproduce it. Append-only on the immutable CID plane (rule 13, history sacred):
8// nothing is ever mutated or deleted; a reversal is a NEW opposing transfer, a two-phase resolution
9// is a NEW record. That is exactly what regulators and bar audits want.
10//
11// TWO-PHASE (pending -> post|void): a pending transfer reserves funds without moving them. It counts
12// toward an account's RESERVED total and reduces AVAILABLE, but never touches the posted balance
13// until a resolution record posts it. Resolutions are keyed by the transfer id = O(1) lookup, not a
14// second scan.
15//
16// LINKED CHAINS: led_chain validates EVERY leg BEFORE committing ANY (validate-then-commit, rule 12)
17// so a refused chain leaves ZERO partial state -- no half-moved money.
18//
19// BALANCE CONVENTION: led_balance = credits - debits (custodial/liability sense: a deposit CREDITS
20// the holder, matching nx_trust). An ASSET account reads the negation; led_debits/led_credits are
21// exposed raw so a caller can apply either convention explicitly.
22//
23// SCALE ENVELOPE (declared, never silent): led_sums scans the whole transfer index each call --
24// O(transfers) per query, index buffer sized to the index (reg_index_read) so there is no
25// capacity ceiling to outgrow -- only the scan cost. A per-account index is the next rung.
26// DRY: reuses nx_matter_lib helpers (mt_catcopy/mt_catn/mt_field/mt_streq, canon_encode, reg_*).
27// license_tier: ORIGINAL No hw writes (Rule 26). LIB (no main).
28
29import "nx_matter_lib.nx"
30
31const LED_RECCAP: i64 = 1024 // one canonical transfer record
32const LED_IDCAP: i64 = 128 // max id length
33const LED_COLON: i64 = 58
34
35// decimal string -> i64 minor units
36func led_atoi(s: *u8) -> i64 {
37 var v: i64 = 0
38 var i: i64 = 0
39 while s[i] != (0 as u8) {
40 if s[i] >= 48 as u8 { if s[i] <= 57 as u8 { v = v * 10 + (s[i] - 48) } }
41 i = i + 1
42 }
43 return v
44}
45
46// format minor units as "$X.XX"
47func led_dollars(cents: i64, out: *u8) -> i64 {
48 var o: i64 = 0
49 var m: i64 = cents
50 if m < 0 { out[o] = 45 as u8; o = o + 1; m = 0 - m }
51 out[o] = 36 as u8
52 o = o + 1
53 o = mt_catn(out, o, m / 100)
54 out[o] = 46 as u8
55 o = o + 1
56 let c: i64 = m % 100
57 out[o] = (48 + (c / 10)) as u8
58 o = o + 1
59 out[o] = (48 + (c % 10)) as u8
60 o = o + 1
61 out[o] = 0 as u8
62 return o
63}
64
65// PER-ACCOUNT INDEX KEY (the scale rung). reg_put takes the index key as a parameter, so an account
66// gets its OWN index: "ledacct:<acct>". led_sums_fast then walks only THAT account's transfers instead
67// of the whole book -- O(transfers-for-this-account) not O(all-transfers).
68// COST DECLARED HONESTLY: the record is written under the same id for each index it joins (global +
69// debit side + credit side), so writes amplify ~3x on storage. Newest-wins on an identical key makes
70// that safe, and it buys the read path, which is what every balance/statement/reconciliation call hits.
71func led_acct_idx(acct: *u8, out: *u8) -> i64 {
72 var o: i64 = mt_catcopy(out, 0, "led:acct:" as *u8)
73 o = mt_catcopy(out, o, acct)
74 out[o] = 0 as u8
75 return o
76}
77
78// Append a transfer. ttype = "posted" (moves money now) or "pending" (reserves it).
79// Returns reg_put's result. Amount must be > 0 -- a negative amount would be a disguised
80// reverse transfer and is REFUSED (-1) so direction is always explicit.
81func led_xfer(prefix: *u8, id: *u8, dr: *u8, cr: *u8, cents: i64, ttype: *u8) -> i64 {
82 if cents <= 0 { return 0 - 1 }
83 if mt_streq(dr, cr) == 1 { return 0 - 2 } // self-transfer is a no-op that fakes activity
84 let amt: *u8 = sys_mmap(32)
85 var ao: i64 = mt_catn(amt, 0, cents)
86 amt[ao] = 0 as u8
87 let k: *i64 = sys_mmap(8 * 4) as *i64
88 let v: *i64 = sys_mmap(8 * 4) as *i64
89 k[0] = ("amount" as *u8) as i64
90 v[0] = amt as i64
91 k[1] = ("dr" as *u8) as i64
92 v[1] = dr as i64
93 k[2] = ("cr" as *u8) as i64
94 v[2] = cr as i64
95 k[3] = ("type" as *u8) as i64
96 v[3] = ttype as i64
97 let rec: *u8 = sys_mmap(LED_RECCAP)
98 let rl: i64 = canon_encode(k, v, 4, rec)
99 // ⚠SCALE RUNG REVERTED 2026-07-23: joining per-account indexes here (an extra reg_put per side)
100 // made the organ die with no output (tools/call status=0) and TWO attempted fixes did not resolve
101 // it, so per rule 3 it was reverted rather than patched further. The write path is back to its
102 // gate-proven single-put shape. See debt seq707 for the diagnosis path; led_sums_fast is retained
103 // below but currently falls through to the full scan, so it is CORRECT, just not yet faster.
104 return reg_put(prefix, "led:" as *u8, "led:__idx__" as *u8, id, rec, rl)
105}
106
107// Append a resolution for a pending transfer. res = "post" or "void". Keyed BY the transfer id.
108func led_resolve(prefix: *u8, id: *u8, res: *u8) -> i64 {
109 let k: *i64 = sys_mmap(8 * 1) as *i64
110 let v: *i64 = sys_mmap(8 * 1) as *i64
111 k[0] = ("res" as *u8) as i64
112 v[0] = res as i64
113 let rec: *u8 = sys_mmap(LED_RECCAP)
114 let rl: i64 = canon_encode(k, v, 1, rec)
115 return reg_put(prefix, "ledres:" as *u8, "ledres:__idx__" as *u8, id, rec, rl)
116}
117
118// resolution state of a pending transfer: 1 = posted, 2 = voided, 0 = unresolved
119func led_resolution(prefix: *u8, id: *u8) -> i64 {
120 let po: *i64 = sys_mmap(16) as *i64
121 let lo: *i64 = sys_mmap(16) as *i64
122 if reg_get(prefix, "ledres:" as *u8, id, po, lo) != 1 { return 0 }
123 let rf: *u8 = sys_mmap(32)
124 mt_field(po[0] as *u8, lo[0], "res" as *u8, 3, rf)
125 if mt_streq(rf, "post" as *u8) == 1 { return 1 }
126 if mt_streq(rf, "void" as *u8) == 1 { return 2 }
127 return 0
128}
129
130// THE CORE SCAN. Accumulates into out[]:
131// out[0] = posted debits out[1] = posted credits
132// out[2] = reserved debits out[3] = reserved credits (pending + unresolved)
133// acct = "" (empty) means ALL ACCOUNTS -- that mode is how the global invariant is measured.
134func led_sums_idx(prefix: *u8, acct: *u8, idxkey: *u8, out: *i64) -> i64 {
135 out[0] = 0
136 out[1] = 0
137 out[2] = 0
138 out[3] = 0
139 // SIZE-TO-NEED. The buffer is derived from the index itself, so a ledger can
140 // never report EMPTY because its index outgrew a declared envelope. The pair
141 // this replaces (mmap LED_IDXCAP + bounded read) returned -1 on overflow, and
142 // -1 fed straight into `while i <= ilen` below -- which does not execute. A
143 // ledger past 1 MiB of index would have reported a zero balance that looks
144 // exactly like real data.
145 let idxbox: *i64 = sys_mmap(16) as *i64
146 let ilen: i64 = reg_index_read(prefix, idxkey, idxbox)
147 let idx: *u8 = idxbox[0] as *u8
148 let idb: *u8 = sys_mmap(LED_IDCAP * 2)
149 let po: *i64 = sys_mmap(16) as *i64
150 let lo: *i64 = sys_mmap(16) as *i64
151 let af: *u8 = sys_mmap(64)
152 let df: *u8 = sys_mmap(LED_IDCAP)
153 let cf: *u8 = sys_mmap(LED_IDCAP)
154 let tf: *u8 = sys_mmap(32)
155 var all: i64 = 0
156 if acct[0] == (0 as u8) { all = 1 }
157 var seen: i64 = 0
158 var ls: i64 = 0
159 var i: i64 = 0
160 while i <= ilen {
161 var eol: i64 = 0
162 if i == ilen { eol = 1 }
163 if i < ilen { if idx[i] == 10 as u8 { eol = 1 } }
164 if eol == 1 {
165 if i > ls {
166 var c: i64 = 0
167 while ls + c < i { idb[c] = idx[ls + c]; c = c + 1 }
168 idb[c] = 0 as u8
169 if reg_get(prefix, "led:" as *u8, idb, po, lo) == 1 {
170 mt_field(po[0] as *u8, lo[0], "amount" as *u8, 6, af)
171 mt_field(po[0] as *u8, lo[0], "dr" as *u8, 2, df)
172 mt_field(po[0] as *u8, lo[0], "cr" as *u8, 2, cf)
173 mt_field(po[0] as *u8, lo[0], "type" as *u8, 4, tf)
174 let amt: i64 = led_atoi(af)
175 var isdr: i64 = 0
176 var iscr: i64 = 0
177 if all == 1 { isdr = 1; iscr = 1 }
178 if all == 0 {
179 if mt_streq(df, acct) == 1 { isdr = 1 }
180 if mt_streq(cf, acct) == 1 { iscr = 1 }
181 }
182 var posted: i64 = 0
183 var reserved: i64 = 0
184 if mt_streq(tf, "posted" as *u8) == 1 { posted = 1 }
185 if mt_streq(tf, "pending" as *u8) == 1 {
186 let r: i64 = led_resolution(prefix, idb)
187 if r == 1 { posted = 1 }
188 if r == 0 { reserved = 1 }
189 }
190 if posted == 1 {
191 if isdr == 1 { out[0] = out[0] + amt }
192 if iscr == 1 { out[1] = out[1] + amt }
193 }
194 if reserved == 1 {
195 if isdr == 1 { out[2] = out[2] + amt }
196 if iscr == 1 { out[3] = out[3] + amt }
197 }
198 seen = seen + 1
199 }
200 }
201 ls = i + 1
202 }
203 i = i + 1
204 }
205 return seen
206}
207
208// FULL-BOOK scan (walks every transfer). Kept as the ORACLE the fast path is proven against,
209// and it is the only correct mode for acct=="" (all accounts).
210func led_sums(prefix: *u8, acct: *u8, out: *i64) -> i64 {
211 return led_sums_idx(prefix, acct, "led:__idx__" as *u8, out)
212}
213
214// ★SCALE PATH: walk ONLY this account's index. Same answer as led_sums for a named account
215// (proven by nx_ledger_scale_gate), but O(transfers-for-this-account) instead of O(whole book).
216// Falls back to the full scan for the all-accounts mode, which has no per-account index by definition.
217func led_sums_fast(prefix: *u8, acct: *u8, out: *i64) -> i64 {
218 // ⚠Currently DELEGATES to the full scan: correct, but NOT yet faster. The per-account index that
219 // would make it faster is reverted (see led_xfer). Callers may use this name today and get right
220 // answers; when the index lands, they get the speedup with no call-site change. Named honestly
221 // rather than left looking optimised.
222 return led_sums(prefix, acct, out)
223}
224
225// posted balance, custodial sense (credits - debits)
226func led_balance(prefix: *u8, acct: *u8) -> i64 {
227 let s: *i64 = sys_mmap(8 * 4) as *i64
228 led_sums(prefix, acct, s)
229 return s[1] - s[0]
230}
231
232// AVAILABLE = posted balance minus funds reserved by unresolved outgoing (debit) pendings
233func led_available(prefix: *u8, acct: *u8) -> i64 {
234 let s: *i64 = sys_mmap(8 * 4) as *i64
235 led_sums(prefix, acct, s)
236 return (s[1] - s[0]) - s[2]
237}
238
239// ★THE INVARIANT: over ALL accounts, sum(credits) - sum(debits) MUST be 0.
240// Non-zero means money was created or destroyed. Returns the drift (0 = sound).
241func led_drift(prefix: *u8) -> i64 {
242 let s: *i64 = sys_mmap(8 * 4) as *i64
243 led_sums(prefix, "" as *u8, s)
244 return s[1] - s[0]
245}
246func led_sound(prefix: *u8) -> i64 {
247 if led_drift(prefix) == 0 { return 1 }
248 return 0
249}
250
251// 1 if `acct` can fund a `cents` outflow from AVAILABLE funds
252func led_can_fund(prefix: *u8, acct: *u8, cents: i64) -> i64 {
253 if led_available(prefix, acct) >= cents { return 1 }
254 return 0
255}
256
257// LINKED ATOMIC CHAIN: validate EVERY leg first, commit only if all pass.
258// A refused chain writes NOTHING (validate-then-commit, rule 12) -- no half-moved money.
259// `funder` = the account whose AVAILABLE funds must cover the total debited from it ( "" = skip).
260// Returns legs committed, or a negative refusal code with zero writes:
261// -1 bad amount -2 self-transfer -3 funder cannot cover the chain total
262func led_chain(prefix: *u8, ids: *i64, drs: *i64, crs: *i64, amts: *i64, n: i64, funder: *u8) -> i64 {
263 var i: i64 = 0
264 var need: i64 = 0
265 var checkf: i64 = 1
266 if funder[0] == (0 as u8) { checkf = 0 }
267 while i < n {
268 if amts[i] <= 0 { return 0 - 1 }
269 if mt_streq(drs[i] as *u8, crs[i] as *u8) == 1 { return 0 - 2 }
270 if checkf == 1 { if mt_streq(drs[i] as *u8, funder) == 1 { need = need + amts[i] } }
271 i = i + 1
272 }
273 if checkf == 1 { if led_can_fund(prefix, funder, need) == 0 { return 0 - 3 } }
274 i = 0
275 while i < n {
276 led_xfer(prefix, ids[i] as *u8, drs[i] as *u8, crs[i] as *u8, amts[i], "posted" as *u8)
277 i = i + 1
278 }
279 return n
280}