nx_ledger_lib.nx source
↩ module page · 273 lines · 12137 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 LED_IDXCAP. A per-account index is the next rung.
25// DRY: reuses nx_matter_lib helpers (mt_catcopy/mt_catn/mt_field/mt_streq, canon_encode, reg_*).
26// license_tier: ORIGINAL No hw writes (Rule 26). LIB (no main).
27
28import "nx_matter_lib.nx"
29
30const LED_IDXCAP: i64 = 1048576 // transfer-index read buffer (declared envelope)
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 let idx: *u8 = sys_mmap(LED_IDXCAP)
140 let ilen: i64 = reg_index(prefix, idxkey, idx, LED_IDXCAP)
141 let idb: *u8 = sys_mmap(LED_IDCAP * 2)
142 let po: *i64 = sys_mmap(16) as *i64
143 let lo: *i64 = sys_mmap(16) as *i64
144 let af: *u8 = sys_mmap(64)
145 let df: *u8 = sys_mmap(LED_IDCAP)
146 let cf: *u8 = sys_mmap(LED_IDCAP)
147 let tf: *u8 = sys_mmap(32)
148 var all: i64 = 0
149 if acct[0] == (0 as u8) { all = 1 }
150 var seen: i64 = 0
151 var ls: i64 = 0
152 var i: i64 = 0
153 while i <= ilen {
154 var eol: i64 = 0
155 if i == ilen { eol = 1 }
156 if i < ilen { if idx[i] == 10 as u8 { eol = 1 } }
157 if eol == 1 {
158 if i > ls {
159 var c: i64 = 0
160 while ls + c < i { idb[c] = idx[ls + c]; c = c + 1 }
161 idb[c] = 0 as u8
162 if reg_get(prefix, "led:" as *u8, idb, po, lo) == 1 {
163 mt_field(po[0] as *u8, lo[0], "amount" as *u8, 6, af)
164 mt_field(po[0] as *u8, lo[0], "dr" as *u8, 2, df)
165 mt_field(po[0] as *u8, lo[0], "cr" as *u8, 2, cf)
166 mt_field(po[0] as *u8, lo[0], "type" as *u8, 4, tf)
167 let amt: i64 = led_atoi(af)
168 var isdr: i64 = 0
169 var iscr: i64 = 0
170 if all == 1 { isdr = 1; iscr = 1 }
171 if all == 0 {
172 if mt_streq(df, acct) == 1 { isdr = 1 }
173 if mt_streq(cf, acct) == 1 { iscr = 1 }
174 }
175 var posted: i64 = 0
176 var reserved: i64 = 0
177 if mt_streq(tf, "posted" as *u8) == 1 { posted = 1 }
178 if mt_streq(tf, "pending" as *u8) == 1 {
179 let r: i64 = led_resolution(prefix, idb)
180 if r == 1 { posted = 1 }
181 if r == 0 { reserved = 1 }
182 }
183 if posted == 1 {
184 if isdr == 1 { out[0] = out[0] + amt }
185 if iscr == 1 { out[1] = out[1] + amt }
186 }
187 if reserved == 1 {
188 if isdr == 1 { out[2] = out[2] + amt }
189 if iscr == 1 { out[3] = out[3] + amt }
190 }
191 seen = seen + 1
192 }
193 }
194 ls = i + 1
195 }
196 i = i + 1
197 }
198 return seen
199}
200
201// FULL-BOOK scan (walks every transfer). Kept as the ORACLE the fast path is proven against,
202// and it is the only correct mode for acct=="" (all accounts).
203func led_sums(prefix: *u8, acct: *u8, out: *i64) -> i64 {
204 return led_sums_idx(prefix, acct, "led:__idx__" as *u8, out)
205}
206
207// ★SCALE PATH: walk ONLY this account's index. Same answer as led_sums for a named account
208// (proven by nx_ledger_scale_gate), but O(transfers-for-this-account) instead of O(whole book).
209// Falls back to the full scan for the all-accounts mode, which has no per-account index by definition.
210func led_sums_fast(prefix: *u8, acct: *u8, out: *i64) -> i64 {
211 // ⚠Currently DELEGATES to the full scan: correct, but NOT yet faster. The per-account index that
212 // would make it faster is reverted (see led_xfer). Callers may use this name today and get right
213 // answers; when the index lands, they get the speedup with no call-site change. Named honestly
214 // rather than left looking optimised.
215 return led_sums(prefix, acct, out)
216}
217
218// posted balance, custodial sense (credits - debits)
219func led_balance(prefix: *u8, acct: *u8) -> i64 {
220 let s: *i64 = sys_mmap(8 * 4) as *i64
221 led_sums(prefix, acct, s)
222 return s[1] - s[0]
223}
224
225// AVAILABLE = posted balance minus funds reserved by unresolved outgoing (debit) pendings
226func led_available(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]) - s[2]
230}
231
232// ★THE INVARIANT: over ALL accounts, sum(credits) - sum(debits) MUST be 0.
233// Non-zero means money was created or destroyed. Returns the drift (0 = sound).
234func led_drift(prefix: *u8) -> i64 {
235 let s: *i64 = sys_mmap(8 * 4) as *i64
236 led_sums(prefix, "" as *u8, s)
237 return s[1] - s[0]
238}
239func led_sound(prefix: *u8) -> i64 {
240 if led_drift(prefix) == 0 { return 1 }
241 return 0
242}
243
244// 1 if `acct` can fund a `cents` outflow from AVAILABLE funds
245func led_can_fund(prefix: *u8, acct: *u8, cents: i64) -> i64 {
246 if led_available(prefix, acct) >= cents { return 1 }
247 return 0
248}
249
250// LINKED ATOMIC CHAIN: validate EVERY leg first, commit only if all pass.
251// A refused chain writes NOTHING (validate-then-commit, rule 12) -- no half-moved money.
252// `funder` = the account whose AVAILABLE funds must cover the total debited from it ( "" = skip).
253// Returns legs committed, or a negative refusal code with zero writes:
254// -1 bad amount -2 self-transfer -3 funder cannot cover the chain total
255func led_chain(prefix: *u8, ids: *i64, drs: *i64, crs: *i64, amts: *i64, n: i64, funder: *u8) -> i64 {
256 var i: i64 = 0
257 var need: i64 = 0
258 var checkf: i64 = 1
259 if funder[0] == (0 as u8) { checkf = 0 }
260 while i < n {
261 if amts[i] <= 0 { return 0 - 1 }
262 if mt_streq(drs[i] as *u8, crs[i] as *u8) == 1 { return 0 - 2 }
263 if checkf == 1 { if mt_streq(drs[i] as *u8, funder) == 1 { need = need + amts[i] } }
264 i = i + 1
265 }
266 if checkf == 1 { if led_can_fund(prefix, funder, need) == 0 { return 0 - 3 } }
267 i = 0
268 while i < n {
269 led_xfer(prefix, ids[i] as *u8, drs[i] as *u8, crs[i] as *u8, amts[i], "posted" as *u8)
270 i = i + 1
271 }
272 return n
273}