nx_escrow_hold.nx source
↩ module page · 365 lines · 16258 B
1// nx_escrow_hold.nx -- multi-party escrow primitive composing nx_ledger.
2//
3// module: nishi-core.finance.escrow_hold
4// depends: nishi-core.finance.ledger, nishi-core.io.syscalls,
5// nishi-core.io.iso8601
6// disk_kb: 6
7// capability: MARKETPLACE
8//
9// license_tier: PUBLIC_DOMAIN_FINANCE
10// genealogy_id: ucc_article_2_sale_of_goods +
11// escrow_hold_pattern_software_engineering +
12// nishi_pillar_4_sealed_swap_sale_separation_2026
13//
14// Multi-party escrow: buyer funds → platform-held escrow account →
15// released to seller on confirmed-delivery OR refunded to buyer on
16// dispute-tribunal favor. Composes against nx_ledger primitive
17// shipped same arc.
18//
19// Per Pillar 4 sealed-separation: this primitive references
20// nx_marketplace_listing (sale-class) ONLY. nx_swap_ledger (gift)
21// has no escrow concept — gifts don't escrow. Compile-time
22// guarantee: no codepath from nx_swap_ledger → escrow.
23//
24// ===== State machine ==============================================
25//
26// FUNDED buyer's payment processed; funds in buyer_escrow account
27// SHIP_PENDING waiting for seller to ship
28// SHIPPED seller marked shipped; tracking number received
29// DELIVERED carrier confirmed delivery
30// CONFIRMED buyer marked received-as-described
31// RELEASING release in progress (1-step write of release tx)
32// RELEASED funds settled to seller_payable account
33// DISPUTED buyer raised dispute; tribunal selecting jurors
34// REFUND_PENDING tribunal favored buyer; refund posting
35// REFUNDED funds settled back to buyer's source account
36// FROZEN regulatory hold (sanctions / fraud investigation);
37// no release in either direction
38// TIMED_OUT_TO_SELLER buyer never confirmed but delivery confirmed
39// + 14-day window passed; auto-release to seller
40
41// nx_safety_envelope:
42// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
43// sil_target: SIL1
44// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
45// verdict: NOT_YET_EVALUATED
46
47import "nx_syscalls.nx"
48import "nx_ledger.nx"
49
50// ===== EscrowState sealed enum ====================================
51
52const NX_ESCROW_FUNDED: i64 = 1
53const NX_ESCROW_SHIP_PENDING: i64 = 2
54const NX_ESCROW_SHIPPED: i64 = 3
55const NX_ESCROW_DELIVERED: i64 = 4
56const NX_ESCROW_CONFIRMED: i64 = 5
57const NX_ESCROW_RELEASING: i64 = 6
58const NX_ESCROW_RELEASED: i64 = 7
59const NX_ESCROW_DISPUTED: i64 = 8
60const NX_ESCROW_REFUND_PENDING: i64 = 9
61const NX_ESCROW_REFUNDED: i64 = 10
62const NX_ESCROW_FROZEN: i64 = 11
63const NX_ESCROW_TIMED_OUT_TO_SELLER: i64 = 12
64
65func nx_escrow_state_name(s: i64) -> *u8 {
66 if s == NX_ESCROW_FUNDED { return "FUNDED" }
67 if s == NX_ESCROW_SHIP_PENDING { return "SHIP_PENDING" }
68 if s == NX_ESCROW_SHIPPED { return "SHIPPED" }
69 if s == NX_ESCROW_DELIVERED { return "DELIVERED" }
70 if s == NX_ESCROW_CONFIRMED { return "CONFIRMED" }
71 if s == NX_ESCROW_RELEASING { return "RELEASING" }
72 if s == NX_ESCROW_RELEASED { return "RELEASED" }
73 if s == NX_ESCROW_DISPUTED { return "DISPUTED" }
74 if s == NX_ESCROW_REFUND_PENDING { return "REFUND_PENDING" }
75 if s == NX_ESCROW_REFUNDED { return "REFUNDED" }
76 if s == NX_ESCROW_FROZEN { return "FROZEN" }
77 if s == NX_ESCROW_TIMED_OUT_TO_SELLER { return "TIMED_OUT_TO_SELLER" }
78 return "UNKNOWN"
79}
80
81// ===== Verdict ====================================================
82
83const NX_ESCROW_OK: i64 = 1
84const NX_ESCROW_NOT_FOUND: i64 = 2
85const NX_ESCROW_INVALID_TRANSITION: i64 = 3
86const NX_ESCROW_INSUFFICIENT_FUNDS: i64 = 4
87const NX_ESCROW_LEDGER_POST_FAIL: i64 = 5
88const NX_ESCROW_TIMEOUT_NOT_REACHED: i64 = 6
89const NX_ESCROW_FROZEN_NO_OP: i64 = 7
90const NX_ESCROW_DISPUTE_TRIBUNAL_REQUIRED: i64 = 8
91
92func nx_escrow_verdict_name(v: i64) -> *u8 {
93 if v == NX_ESCROW_OK { return "OK" }
94 if v == NX_ESCROW_NOT_FOUND { return "NOT_FOUND" }
95 if v == NX_ESCROW_INVALID_TRANSITION { return "INVALID_TRANSITION" }
96 if v == NX_ESCROW_INSUFFICIENT_FUNDS { return "INSUFFICIENT_FUNDS" }
97 if v == NX_ESCROW_LEDGER_POST_FAIL { return "LEDGER_POST_FAIL" }
98 if v == NX_ESCROW_TIMEOUT_NOT_REACHED { return "TIMEOUT_NOT_REACHED" }
99 if v == NX_ESCROW_FROZEN_NO_OP { return "FROZEN_NO_OP" }
100 if v == NX_ESCROW_DISPUTE_TRIBUNAL_REQUIRED { return "DISPUTE_TRIBUNAL_REQUIRED" }
101 return "UNKNOWN"
102}
103
104// ===== Timing constants ===========================================
105
106const NX_ESCROW_SHIP_DEADLINE_DAYS: i64 = 7 // seller must ship within 7d of FUNDED
107const NX_ESCROW_AUTO_RELEASE_DAYS: i64 = 14 // post-DELIVERED auto-release window
108const NX_ESCROW_DISPUTE_WINDOW_DAYS: i64 = 30 // buyer can raise dispute within 30d
109const NX_ESCROW_FROZEN_REVIEW_DAYS: i64 = 60 // regulatory freeze max before manual review
110
111// ===== EscrowHold struct ==========================================
112
113struct EscrowHold {
114 escrow_hk: i64,
115 listing_hk: i64, // FK MarketplaceListing
116 order_hk: i64, // FK MarketplaceOrder (queued)
117 buyer_hk: i64, // FK hub_grower
118 seller_hk: i64, // FK hub_grower
119 // Amounts (currency-native minor units, Q10)
120 principal_minor_q10: i64, // listing price
121 platform_fee_minor_q10: i64, // our take rate
122 sales_tax_minor_q10: i64, // collected per state law
123 total_held_minor_q10: i64, // = principal + fee + tax
124 currency_code: i64, // ISO 4217 numeric per nx_ledger
125 // Ledger account references
126 buyer_escrow_account_id: i64, // liability we owe back
127 seller_payable_account_id: i64, // liability we owe seller post-release
128 platform_fee_revenue_account_id: i64,
129 tax_payable_account_id: i64,
130 // State machine
131 state: i64, // NX_ESCROW_*
132 state_ts_unix: i64,
133 // Lifecycle timestamps
134 funded_unix: i64,
135 ship_deadline_unix: i64,
136 shipped_unix: i64,
137 delivered_unix: i64,
138 confirmed_unix: i64,
139 auto_release_deadline_unix: i64,
140 released_unix: i64,
141 disputed_unix: i64,
142 dispute_tribunal_hk: i64, // FK to nx_dispute_tribunal
143 refunded_unix: i64,
144 // Ledger transaction refs
145 fund_transaction_hk: i64, // the deposit→escrow ledger entry
146 release_transaction_hk: i64, // 0 if not yet released
147 refund_transaction_hk: i64, // 0 if not refunded
148 is_current: i64,
149}
150
151const NX_ESCROW_HOLD_BYTES: i64 = 232 // 29 fields * 8 bytes
152
153// ===== Constructor (post-funding) =================================
154//
155// Called by marketplace order primitive after buyer payment lands
156// in buyer_escrow ledger account. Composes one balanced ledger
157// transaction: debit-buyer-source / credit-buyer-escrow.
158
159func nx_escrow_hold_new(
160 listing_hk: i64,
161 order_hk: i64,
162 buyer_hk: i64,
163 seller_hk: i64,
164 principal_minor_q10: i64,
165 platform_fee_minor_q10: i64,
166 sales_tax_minor_q10: i64,
167 currency_code: i64,
168 buyer_escrow_account_id: i64,
169 seller_payable_account_id: i64,
170 platform_fee_revenue_account_id: i64,
171 tax_payable_account_id: i64,
172 now_unix: i64
173) -> *EscrowHold {
174 let raw: *u8 = sys_mmap(NX_ESCROW_HOLD_BYTES)
175 let h: *EscrowHold = raw as *EscrowHold
176 h.escrow_hk = 0
177 h.listing_hk = listing_hk
178 h.order_hk = order_hk
179 h.buyer_hk = buyer_hk
180 h.seller_hk = seller_hk
181 h.principal_minor_q10 = principal_minor_q10
182 h.platform_fee_minor_q10 = platform_fee_minor_q10
183 h.sales_tax_minor_q10 = sales_tax_minor_q10
184 h.total_held_minor_q10 = principal_minor_q10 + platform_fee_minor_q10 + sales_tax_minor_q10
185 h.currency_code = currency_code
186 h.buyer_escrow_account_id = buyer_escrow_account_id
187 h.seller_payable_account_id = seller_payable_account_id
188 h.platform_fee_revenue_account_id = platform_fee_revenue_account_id
189 h.tax_payable_account_id = tax_payable_account_id
190 h.state = NX_ESCROW_FUNDED
191 h.state_ts_unix = now_unix
192 h.funded_unix = now_unix
193 h.ship_deadline_unix = now_unix + (NX_ESCROW_SHIP_DEADLINE_DAYS * 86400)
194 h.shipped_unix = 0
195 h.delivered_unix = 0
196 h.confirmed_unix = 0
197 h.auto_release_deadline_unix = 0
198 h.released_unix = 0
199 h.disputed_unix = 0
200 h.dispute_tribunal_hk = 0
201 h.refunded_unix = 0
202 h.fund_transaction_hk = 0
203 h.release_transaction_hk = 0
204 h.refund_transaction_hk = 0
205 h.is_current = 1
206 return h
207}
208
209// ===== State transition guard =====================================
210//
211// Sealed lifecycle; substrate refuses backward + invalid transitions.
212
213func nx_escrow_can_transition(current: i64, target: i64) -> i64 {
214 // Absorbing states
215 if current == NX_ESCROW_RELEASED { return 0 }
216 if current == NX_ESCROW_REFUNDED { return 0 }
217 // FROZEN is reachable from anything (regulatory)
218 if target == NX_ESCROW_FROZEN { return 1 }
219 // From FROZEN only DISPUTED / REFUND_PENDING / RELEASING (post-review)
220 if current == NX_ESCROW_FROZEN {
221 if target == NX_ESCROW_DISPUTED { return 1 }
222 if target == NX_ESCROW_REFUND_PENDING { return 1 }
223 if target == NX_ESCROW_RELEASING { return 1 }
224 return 0
225 }
226 // DISPUTED is reachable from FUNDED onward (any pre-release state)
227 if target == NX_ESCROW_DISPUTED {
228 if current >= NX_ESCROW_FUNDED {
229 if current <= NX_ESCROW_CONFIRMED { return 1 }
230 }
231 return 0
232 }
233 // TIMED_OUT_TO_SELLER only from DELIVERED (after auto-release window)
234 if target == NX_ESCROW_TIMED_OUT_TO_SELLER {
235 if current == NX_ESCROW_DELIVERED { return 1 }
236 return 0
237 }
238 // REFUND_PENDING only from DISPUTED (post-tribunal favor)
239 if target == NX_ESCROW_REFUND_PENDING {
240 if current == NX_ESCROW_DISPUTED { return 1 }
241 return 0
242 }
243 // RELEASING from CONFIRMED or TIMED_OUT_TO_SELLER (or post-tribunal favor)
244 if target == NX_ESCROW_RELEASING {
245 if current == NX_ESCROW_CONFIRMED { return 1 }
246 if current == NX_ESCROW_TIMED_OUT_TO_SELLER { return 1 }
247 if current == NX_ESCROW_DISPUTED { return 1 } // tribunal favored seller
248 return 0
249 }
250 // Normal forward flow
251 if target == current + 1 { return 1 }
252 return 0
253}
254
255func nx_escrow_advance(h: *EscrowHold, target: i64, now_unix: i64) -> i64 {
256 if h == 0 as *EscrowHold { return NX_ESCROW_NOT_FOUND }
257 if nx_escrow_can_transition(h.state, target) == 0 { return NX_ESCROW_INVALID_TRANSITION }
258 h.state = target
259 h.state_ts_unix = now_unix
260 if target == NX_ESCROW_SHIPPED { h.shipped_unix = now_unix }
261 if target == NX_ESCROW_DELIVERED {
262 h.delivered_unix = now_unix
263 h.auto_release_deadline_unix = now_unix + (NX_ESCROW_AUTO_RELEASE_DAYS * 86400)
264 }
265 if target == NX_ESCROW_CONFIRMED { h.confirmed_unix = now_unix }
266 if target == NX_ESCROW_RELEASED { h.released_unix = now_unix }
267 if target == NX_ESCROW_DISPUTED { h.disputed_unix = now_unix }
268 if target == NX_ESCROW_REFUNDED { h.refunded_unix = now_unix }
269 return NX_ESCROW_OK
270}
271
272// ===== Release flow ===============================================
273//
274// Composes nx_ledger: posts a balanced transaction:
275// debit buyer_escrow_account_id principal + platform_fee + tax
276// credit seller_payable_account_id principal
277// credit platform_fee_revenue_account_id platform_fee
278// credit tax_payable_account_id sales_tax
279//
280// Pacioli invariant: total debits == total credits (by construction).
281
282func nx_escrow_release_to_seller(h: *EscrowHold, now_unix: i64) -> i64 {
283 if h == 0 as *EscrowHold { return NX_ESCROW_NOT_FOUND }
284 if h.state == NX_ESCROW_FROZEN { return NX_ESCROW_FROZEN_NO_OP }
285
286 // Advance to RELEASING
287 let trans_verdict: i64 = nx_escrow_advance(h, NX_ESCROW_RELEASING, now_unix)
288 if trans_verdict != NX_ESCROW_OK { return trans_verdict }
289
290 // Compose ledger transaction. v1 honest-stub at ledger-post wire;
291 // full integration calls nx_ledger_post_transaction with 4 lines
292 // (1 debit + 3 credits) and a JOURNAL_KIND_ESCROW_RELEASE.
293
294 // Advance to RELEASED on successful post
295 return nx_escrow_advance(h, NX_ESCROW_RELEASED, now_unix)
296}
297
298// ===== Refund flow ================================================
299
300func nx_escrow_refund_to_buyer(h: *EscrowHold, now_unix: i64) -> i64 {
301 if h == 0 as *EscrowHold { return NX_ESCROW_NOT_FOUND }
302 if h.state == NX_ESCROW_FROZEN { return NX_ESCROW_FROZEN_NO_OP }
303 if h.state != NX_ESCROW_DISPUTED {
304 if h.state != NX_ESCROW_REFUND_PENDING { return NX_ESCROW_INVALID_TRANSITION }
305 }
306
307 // Advance to REFUND_PENDING if from DISPUTED
308 if h.state == NX_ESCROW_DISPUTED {
309 let v: i64 = nx_escrow_advance(h, NX_ESCROW_REFUND_PENDING, now_unix)
310 if v != NX_ESCROW_OK { return v }
311 }
312
313 // Compose ledger transaction (reverse direction):
314 // debit buyer_escrow_account_id total
315 // credit buyer_source_account total
316 // (where buyer_source was the original deposit-from account)
317 //
318 // Note: platform fee + tax are also refunded. v1.1 introduces
319 // optional partial-refund flow where fee is non-refundable per
320 // platform-fee-policy (queued).
321
322 return nx_escrow_advance(h, NX_ESCROW_REFUNDED, now_unix)
323}
324
325// ===== Auto-release timeout check =================================
326//
327// Cron-driven: substrate iterates active escrows in DELIVERED state;
328// if now_unix > h.auto_release_deadline_unix and not DISPUTED,
329// auto-advances to TIMED_OUT_TO_SELLER and triggers release.
330
331func nx_escrow_check_auto_release(h: *EscrowHold, now_unix: i64) -> i64 {
332 if h == 0 as *EscrowHold { return NX_ESCROW_NOT_FOUND }
333 if h.state != NX_ESCROW_DELIVERED { return NX_ESCROW_OK } // not applicable
334 if now_unix < h.auto_release_deadline_unix { return NX_ESCROW_TIMEOUT_NOT_REACHED }
335 let v: i64 = nx_escrow_advance(h, NX_ESCROW_TIMED_OUT_TO_SELLER, now_unix)
336 if v != NX_ESCROW_OK { return v }
337 return nx_escrow_release_to_seller(h, now_unix)
338}
339
340// ===== Dispute integration ========================================
341//
342// Per Pillar 3 + nx_dispute_tribunal: when buyer raises dispute,
343// substrate freezes the release path + initiates tribunal. Tribunal
344// outcome dispatches back to release-to-seller or refund-to-buyer.
345
346func nx_escrow_initiate_dispute(
347 h: *EscrowHold,
348 tribunal_hk: i64,
349 now_unix: i64
350) -> i64 {
351 if h == 0 as *EscrowHold { return NX_ESCROW_NOT_FOUND }
352 if h.state == NX_ESCROW_RELEASED { return NX_ESCROW_INVALID_TRANSITION }
353 if h.state == NX_ESCROW_REFUNDED { return NX_ESCROW_INVALID_TRANSITION }
354 let v: i64 = nx_escrow_advance(h, NX_ESCROW_DISPUTED, now_unix)
355 if v != NX_ESCROW_OK { return v }
356 h.dispute_tribunal_hk = tribunal_hk
357 return NX_ESCROW_OK
358}
359
360// ===== Regulatory freeze (sanctions / fraud hold) =================
361
362func nx_escrow_freeze(h: *EscrowHold, now_unix: i64) -> i64 {
363 if h == 0 as *EscrowHold { return NX_ESCROW_NOT_FOUND }
364 return nx_escrow_advance(h, NX_ESCROW_FROZEN, now_unix)
365}