zklicensing 0.1.0-beta.2 → 0.1.0-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (317) hide show
  1. package/README.md +417 -201
  2. package/build/agent.d.ts +371 -0
  3. package/build/agent.d.ts.map +1 -0
  4. package/build/agent.js +553 -0
  5. package/build/agent.js.map +1 -0
  6. package/build/agentActions.d.ts +841 -0
  7. package/build/agentActions.d.ts.map +1 -0
  8. package/build/agentActions.js +581 -0
  9. package/build/agentActions.js.map +1 -0
  10. package/build/agentRecord.d.ts +83 -0
  11. package/build/agentRecord.d.ts.map +1 -0
  12. package/build/agentRecord.js +69 -0
  13. package/build/agentRecord.js.map +1 -0
  14. package/build/agentVendor.d.ts +220 -0
  15. package/build/agentVendor.d.ts.map +1 -0
  16. package/build/agentVendor.js +306 -0
  17. package/build/agentVendor.js.map +1 -0
  18. package/build/appsLookup.d.ts +14 -0
  19. package/build/appsLookup.d.ts.map +1 -0
  20. package/build/appsLookup.js +12 -0
  21. package/build/appsLookup.js.map +1 -0
  22. package/build/archiveBootstrap.d.ts +31 -5
  23. package/build/archiveBootstrap.d.ts.map +1 -0
  24. package/build/archiveBootstrap.js +140 -47
  25. package/build/archiveBootstrap.js.map +1 -0
  26. package/build/atomicJson.d.ts +5 -0
  27. package/build/atomicJson.d.ts.map +1 -0
  28. package/build/atomicJson.js +55 -0
  29. package/build/atomicJson.js.map +1 -0
  30. package/build/bindClient.d.ts +85 -0
  31. package/build/bindClient.d.ts.map +1 -0
  32. package/build/bindClient.js +187 -0
  33. package/build/bindClient.js.map +1 -0
  34. package/build/buyClient.d.ts +27 -4
  35. package/build/buyClient.d.ts.map +1 -0
  36. package/build/buyClient.js +72 -26
  37. package/build/buyClient.js.map +1 -0
  38. package/build/chainHead.d.ts +61 -0
  39. package/build/chainHead.d.ts.map +1 -0
  40. package/build/chainHead.js +88 -0
  41. package/build/chainHead.js.map +1 -0
  42. package/build/compactMerkleMap.d.ts +72 -0
  43. package/build/compactMerkleMap.d.ts.map +1 -0
  44. package/build/compactMerkleMap.js +224 -0
  45. package/build/compactMerkleMap.js.map +1 -0
  46. package/build/contractInterface.d.ts +191 -51
  47. package/build/contractInterface.d.ts.map +1 -0
  48. package/build/contractInterface.js +234 -43
  49. package/build/contractInterface.js.map +1 -0
  50. package/build/contractTypes.d.ts +9 -0
  51. package/build/contractTypes.d.ts.map +1 -0
  52. package/build/contractTypes.js +28 -0
  53. package/build/contractTypes.js.map +1 -0
  54. package/build/countryStats.d.ts +12 -3
  55. package/build/countryStats.d.ts.map +1 -0
  56. package/build/countryStats.js +87 -23
  57. package/build/countryStats.js.map +1 -0
  58. package/build/deployClient.d.ts +138 -24
  59. package/build/deployClient.d.ts.map +1 -0
  60. package/build/deployClient.js +91 -31
  61. package/build/deployClient.js.map +1 -0
  62. package/build/deviceIdentity.d.ts +68 -0
  63. package/build/deviceIdentity.d.ts.map +1 -0
  64. package/build/deviceIdentity.js +209 -0
  65. package/build/deviceIdentity.js.map +1 -0
  66. package/build/freezeClient.d.ts +26 -6
  67. package/build/freezeClient.d.ts.map +1 -0
  68. package/build/freezeClient.js +30 -6
  69. package/build/freezeClient.js.map +1 -0
  70. package/build/generationClient.d.ts +6 -3
  71. package/build/generationClient.d.ts.map +1 -0
  72. package/build/generationClient.js +25 -38
  73. package/build/generationClient.js.map +1 -0
  74. package/build/holderSecret.d.ts +70 -0
  75. package/build/holderSecret.d.ts.map +1 -0
  76. package/build/holderSecret.js +76 -0
  77. package/build/holderSecret.js.map +1 -0
  78. package/build/index.d.ts +38 -38
  79. package/build/index.d.ts.map +1 -0
  80. package/build/index.js +97 -121
  81. package/build/index.js.map +1 -0
  82. package/build/keysClient.d.ts +142 -0
  83. package/build/keysClient.d.ts.map +1 -0
  84. package/build/keysClient.js +109 -0
  85. package/build/keysClient.js.map +1 -0
  86. package/build/licenseLeaf.d.ts +76 -0
  87. package/build/licenseLeaf.d.ts.map +1 -0
  88. package/build/licenseLeaf.js +189 -0
  89. package/build/licenseLeaf.js.map +1 -0
  90. package/build/licenseRecord.d.ts +68 -5
  91. package/build/licenseRecord.d.ts.map +1 -0
  92. package/build/licenseRecord.js +26 -1
  93. package/build/licenseRecord.js.map +1 -0
  94. package/build/licenseStore.d.ts +81 -4
  95. package/build/licenseStore.d.ts.map +1 -0
  96. package/build/licenseStore.js +400 -30
  97. package/build/licenseStore.js.map +1 -0
  98. package/build/licenseTrustless.d.ts +43 -0
  99. package/build/licenseTrustless.d.ts.map +1 -0
  100. package/build/licenseTrustless.js +180 -0
  101. package/build/licenseTrustless.js.map +1 -0
  102. package/build/manifestVerify.d.ts +29 -1
  103. package/build/manifestVerify.d.ts.map +1 -0
  104. package/build/manifestVerify.js +46 -4
  105. package/build/manifestVerify.js.map +1 -0
  106. package/build/minaPriceHistory.d.ts +9 -0
  107. package/build/minaPriceHistory.d.ts.map +1 -0
  108. package/build/minaPriceHistory.js +152 -0
  109. package/build/minaPriceHistory.js.map +1 -0
  110. package/build/onChainRoot.d.ts +60 -0
  111. package/build/onChainRoot.d.ts.map +1 -0
  112. package/build/onChainRoot.js +74 -0
  113. package/build/onChainRoot.js.map +1 -0
  114. package/build/ownershipLicenseHash.d.ts +25 -0
  115. package/build/ownershipLicenseHash.d.ts.map +1 -0
  116. package/build/ownershipLicenseHash.js +29 -0
  117. package/build/ownershipLicenseHash.js.map +1 -0
  118. package/build/ownershipSignature.d.ts +153 -0
  119. package/build/ownershipSignature.d.ts.map +1 -0
  120. package/build/ownershipSignature.js +297 -0
  121. package/build/ownershipSignature.js.map +1 -0
  122. package/build/permitCheckFlow.d.ts +155 -0
  123. package/build/permitCheckFlow.d.ts.map +1 -0
  124. package/build/permitCheckFlow.js +196 -0
  125. package/build/permitCheckFlow.js.map +1 -0
  126. package/build/permitDescribe.d.ts +124 -0
  127. package/build/permitDescribe.d.ts.map +1 -0
  128. package/build/permitDescribe.js +324 -0
  129. package/build/permitDescribe.js.map +1 -0
  130. package/build/permitInterface.d.ts +85 -0
  131. package/build/permitInterface.d.ts.map +1 -0
  132. package/build/permitInterface.js +121 -0
  133. package/build/permitInterface.js.map +1 -0
  134. package/build/permitMessages.d.ts +313 -0
  135. package/build/permitMessages.d.ts.map +1 -0
  136. package/build/permitMessages.js +462 -0
  137. package/build/permitMessages.js.map +1 -0
  138. package/build/permitProof.d.ts +108 -0
  139. package/build/permitProof.d.ts.map +1 -0
  140. package/build/permitProof.js +202 -0
  141. package/build/permitProof.js.map +1 -0
  142. package/build/permitRegistryRebuild.d.ts +166 -0
  143. package/build/permitRegistryRebuild.d.ts.map +1 -0
  144. package/build/permitRegistryRebuild.js +347 -0
  145. package/build/permitRegistryRebuild.js.map +1 -0
  146. package/build/permitTree.d.ts +88 -0
  147. package/build/permitTree.d.ts.map +1 -0
  148. package/build/permitTree.js +230 -0
  149. package/build/permitTree.js.map +1 -0
  150. package/build/permitsClient.d.ts +680 -0
  151. package/build/permitsClient.d.ts.map +1 -0
  152. package/build/permitsClient.js +597 -0
  153. package/build/permitsClient.js.map +1 -0
  154. package/build/proveErrors.d.ts +50 -0
  155. package/build/proveErrors.d.ts.map +1 -0
  156. package/build/proveErrors.js +76 -0
  157. package/build/proveErrors.js.map +1 -0
  158. package/build/receipt.d.ts +95 -0
  159. package/build/receipt.d.ts.map +1 -0
  160. package/build/receipt.js +84 -0
  161. package/build/receipt.js.map +1 -0
  162. package/build/refundClient.d.ts +7 -12
  163. package/build/refundClient.d.ts.map +1 -0
  164. package/build/refundClient.js +52 -21
  165. package/build/refundClient.js.map +1 -0
  166. package/build/registryTrustVerify.d.ts +78 -0
  167. package/build/registryTrustVerify.d.ts.map +1 -0
  168. package/build/registryTrustVerify.js +129 -0
  169. package/build/registryTrustVerify.js.map +1 -0
  170. package/build/releaseClient.d.ts +26 -0
  171. package/build/releaseClient.d.ts.map +1 -0
  172. package/build/releaseClient.js +65 -0
  173. package/build/releaseClient.js.map +1 -0
  174. package/build/renewClient.d.ts +28 -2
  175. package/build/renewClient.d.ts.map +1 -0
  176. package/build/renewClient.js +31 -27
  177. package/build/renewClient.js.map +1 -0
  178. package/build/seatName.d.ts +8 -0
  179. package/build/seatName.d.ts.map +1 -0
  180. package/build/seatName.js +136 -0
  181. package/build/seatName.js.map +1 -0
  182. package/build/slotConstants.d.ts +15 -0
  183. package/build/slotConstants.d.ts.map +1 -0
  184. package/build/slotConstants.js +17 -0
  185. package/build/slotConstants.js.map +1 -0
  186. package/build/statsClient.d.ts +61 -7
  187. package/build/statsClient.d.ts.map +1 -0
  188. package/build/statsClient.js +44 -5
  189. package/build/statsClient.js.map +1 -0
  190. package/build/successorChain.d.ts +35 -0
  191. package/build/successorChain.d.ts.map +1 -0
  192. package/build/successorChain.js +69 -0
  193. package/build/successorChain.js.map +1 -0
  194. package/build/taxId.d.ts +44 -0
  195. package/build/taxId.d.ts.map +1 -0
  196. package/build/taxId.js +212 -0
  197. package/build/taxId.js.map +1 -0
  198. package/build/trustAnchorVerify.d.ts +49 -0
  199. package/build/trustAnchorVerify.d.ts.map +1 -0
  200. package/build/trustAnchorVerify.js +432 -0
  201. package/build/trustAnchorVerify.js.map +1 -0
  202. package/build/trustAnchorVerifyCli.d.ts +45 -0
  203. package/build/trustAnchorVerifyCli.d.ts.map +1 -0
  204. package/build/trustAnchorVerifyCli.js +235 -0
  205. package/build/trustAnchorVerifyCli.js.map +1 -0
  206. package/build/txBroadcastClient.d.ts +26 -0
  207. package/build/txBroadcastClient.d.ts.map +1 -0
  208. package/build/txBroadcastClient.js +20 -0
  209. package/build/txBroadcastClient.js.map +1 -0
  210. package/build/unbindClient.d.ts +77 -0
  211. package/build/unbindClient.d.ts.map +1 -0
  212. package/build/unbindClient.js +169 -0
  213. package/build/unbindClient.js.map +1 -0
  214. package/build/units.d.ts +28 -0
  215. package/build/units.d.ts.map +1 -0
  216. package/build/units.js +65 -0
  217. package/build/units.js.map +1 -0
  218. package/build/vatRates.d.ts +54 -0
  219. package/build/vatRates.d.ts.map +1 -0
  220. package/build/vatRates.js +82 -0
  221. package/build/vatRates.js.map +1 -0
  222. package/build/vatRatesData.d.ts +5 -0
  223. package/build/vatRatesData.d.ts.map +1 -0
  224. package/build/vatRatesData.js +42 -0
  225. package/build/vatRatesData.js.map +1 -0
  226. package/build/vendorAccountClient.d.ts +66 -0
  227. package/build/vendorAccountClient.d.ts.map +1 -0
  228. package/build/vendorAccountClient.js +47 -0
  229. package/build/vendorAccountClient.js.map +1 -0
  230. package/build/verifyCore.d.ts +4 -1
  231. package/build/verifyCore.d.ts.map +1 -0
  232. package/build/verifyCore.js +21 -10
  233. package/build/verifyCore.js.map +1 -0
  234. package/build/verifyOffline.d.ts +85 -0
  235. package/build/verifyOffline.d.ts.map +1 -0
  236. package/build/verifyOffline.js +139 -0
  237. package/build/verifyOffline.js.map +1 -0
  238. package/build/verifyService.d.ts.map +1 -0
  239. package/build/verifyService.js +571 -501
  240. package/build/verifyService.js.map +1 -0
  241. package/build/verifyWithAncestors.d.ts +19 -0
  242. package/build/verifyWithAncestors.d.ts.map +1 -0
  243. package/build/verifyWithAncestors.js +127 -0
  244. package/build/verifyWithAncestors.js.map +1 -0
  245. package/build/zipToState.d.ts +2 -0
  246. package/build/zipToState.d.ts.map +1 -0
  247. package/build/zipToState.js +94 -0
  248. package/build/zipToState.js.map +1 -0
  249. package/package.json +141 -16
  250. package/src/agent.ts +682 -0
  251. package/src/agentActions.ts +637 -0
  252. package/src/agentRecord.ts +121 -0
  253. package/src/agentVendor.ts +356 -0
  254. package/src/appsLookup.ts +48 -0
  255. package/src/archiveBootstrap.ts +386 -0
  256. package/src/atomicJson.ts +53 -0
  257. package/src/bindClient.ts +297 -0
  258. package/src/buyClient.ts +344 -0
  259. package/src/chainHead.ts +106 -0
  260. package/src/compactMerkleMap.ts +222 -0
  261. package/src/contractInterface.ts +465 -0
  262. package/src/contractTypes.ts +31 -0
  263. package/src/countryStats.ts +200 -0
  264. package/src/deployClient.ts +517 -0
  265. package/src/deviceIdentity.ts +249 -0
  266. package/src/freezeClient.ts +162 -0
  267. package/src/generationClient.ts +145 -0
  268. package/src/holderSecret.ts +91 -0
  269. package/src/index.ts +362 -0
  270. package/src/keysClient.ts +135 -0
  271. package/src/licenseLeaf.ts +221 -0
  272. package/src/licenseRecord.ts +190 -0
  273. package/src/licenseStore.ts +767 -0
  274. package/src/licenseTrustless.ts +207 -0
  275. package/src/manifestVerify.ts +164 -0
  276. package/src/minaPriceHistory.ts +166 -0
  277. package/src/onChainRoot.ts +90 -0
  278. package/src/ownershipLicenseHash.ts +31 -0
  279. package/src/ownershipSignature.ts +421 -0
  280. package/src/permitCheckFlow.ts +227 -0
  281. package/src/permitDescribe.ts +362 -0
  282. package/src/permitInterface.ts +146 -0
  283. package/src/permitMessages.ts +572 -0
  284. package/src/permitProof.ts +230 -0
  285. package/src/permitRegistryRebuild.ts +423 -0
  286. package/src/permitTree.ts +232 -0
  287. package/src/permitsClient.ts +746 -0
  288. package/src/proveErrors.ts +98 -0
  289. package/src/receipt.ts +156 -0
  290. package/src/refundClient.ts +177 -0
  291. package/src/registryTrustVerify.ts +192 -0
  292. package/src/releaseClient.ts +101 -0
  293. package/src/renewClient.ts +234 -0
  294. package/src/seatName.ts +165 -0
  295. package/src/slotConstants.ts +18 -0
  296. package/src/statsClient.ts +249 -0
  297. package/src/successorChain.ts +87 -0
  298. package/src/taxId.ts +227 -0
  299. package/src/trustAnchorVerify.ts +550 -0
  300. package/src/trustAnchorVerifyCli.ts +234 -0
  301. package/src/txBroadcastClient.ts +48 -0
  302. package/src/unbindClient.ts +233 -0
  303. package/src/units.ts +74 -0
  304. package/src/vatRates.ts +100 -0
  305. package/src/vatRatesData.ts +43 -0
  306. package/src/vendorAccountClient.ts +120 -0
  307. package/src/verifyCore.ts +100 -0
  308. package/src/verifyOffline.ts +212 -0
  309. package/src/verifyService.ts +982 -0
  310. package/src/verifyWithAncestors.ts +163 -0
  311. package/src/zipToState.ts +94 -0
  312. package/build/licenseProof.d.ts +0 -26
  313. package/build/licenseProof.js +0 -41
  314. package/build/migrateClient.d.ts +0 -43
  315. package/build/migrateClient.js +0 -47
  316. package/build/ownership.d.ts +0 -34
  317. package/build/ownership.js +0 -147
@@ -0,0 +1,767 @@
1
+ // Copyright (c) 2025-2026 zkLicensing project developers
2
+ // SPDX-License-Identifier: BUSL-1.1
3
+
4
+ /**
5
+ * licenseStore.ts
6
+ *
7
+ * Off-chain MerkleMap state for the licensing system.
8
+ *
9
+ * Records are keyed by (zkAppAddress, licenseHash). Each LicensingApp deploys
10
+ * its own on-chain MerkleMap, so a passphrase used on two different apps
11
+ * produces the same `licenseHash` but two distinct records. Lookups that omit
12
+ * `zkAppAddress` would collide across apps — every API here takes it.
13
+ *
14
+ * Used by:
15
+ * - interact.ts / buyLicense.ts (CLI) — reads/writes directly
16
+ * - keeperService.ts — exposes the same logic over HTTP
17
+ */
18
+
19
+ import { Bool, Field, MerkleMap, MerkleMapWitness, Poseidon, PublicKey, UInt32, UInt64 } from 'o1js';
20
+ import { GRACE_PERIOD_N, REFUND_WINDOW_N, effectiveExpirySlot } from './contractInterface.js';
21
+ import { computeDeviceSlotsRoot, DEVICE_SLOT_CAP_N, leafFromRecord, leafHash } from './licenseLeaf.js';
22
+ import { PENDING_REAP_SLOTS as _PENDING_REAP_SLOTS, type LicenseRecord as _LicenseRecord, type LicenseStatus as _LicenseStatus } from './licenseRecord.js';
23
+ import { readJsonOr, writeJsonAtomic } from './atomicJson.js';
24
+
25
+ // The record shape and pure types now live in licenseRecord.ts so browser
26
+ // modules (buyClient, statsClient, countryStats) can consume them without
27
+ // importing Node's `fs`. Re-exported here so existing Node imports of
28
+ // `zklicensing/internal/licenseStore` continue to resolve.
29
+ export type LicenseRecord = _LicenseRecord;
30
+ export type LicenseStatus = _LicenseStatus;
31
+ export type { AgentWaiverRecord } from './licenseRecord.js';
32
+ export const PENDING_REAP_SLOTS = _PENDING_REAP_SLOTS;
33
+
34
+ export function licenseStorePath(): string {
35
+ return process.env.LICENSE_STORE_PATH ?? 'licenses.json';
36
+ }
37
+
38
+ /**
39
+ * Where records live. Default: the JSON file at licenseStorePath() (the
40
+ * verify service and SDK users). The keeper installs its SQLite store with
41
+ * setLicenseStoreBackend, including a lock that works across processes.
42
+ */
43
+ export type LicenseStoreBackend = {
44
+ read(): Promise<unknown>;
45
+ write(records: LicenseRecord[]): Promise<void>;
46
+ /** Wraps each read-modify-write; default none (in-process lock only). */
47
+ withLock?<T>(fn: () => Promise<T>): Promise<T>;
48
+ };
49
+
50
+ const fileBackend: LicenseStoreBackend = {
51
+ // Missing file → empty store; a damaged file is an error (see atomicJson).
52
+ read: () => readJsonOr<unknown>(licenseStorePath(), []),
53
+ write: records => writeJsonAtomic(licenseStorePath(), records),
54
+ };
55
+ let backend: LicenseStoreBackend = fileBackend;
56
+
57
+ export function setLicenseStoreBackend(b: LicenseStoreBackend | null): void {
58
+ backend = b ?? fileBackend;
59
+ }
60
+
61
+ export async function readRecords(): Promise<LicenseRecord[]> {
62
+ const raw: unknown = await backend.read();
63
+ if (!Array.isArray(raw)) return [];
64
+ const records = raw as LicenseRecord[];
65
+ // Records pre-dating composite keying lack `zkAppAddress` and cannot be
66
+ // safely matched against any specific zkApp. Drop them with a warning so
67
+ // bootstrap rebuilds them fresh from on-chain events.
68
+ const filtered = records.filter(r => typeof r.zkAppAddress === 'string' && r.zkAppAddress.length > 0);
69
+ if (filtered.length < records.length) {
70
+ console.warn(`[licenseStore] dropped ${records.length - filtered.length} record(s) missing zkAppAddress — re-bootstrap to recover`);
71
+ }
72
+ return filtered;
73
+ }
74
+
75
+ async function writeRecords(records: LicenseRecord[]): Promise<void> {
76
+ await backend.write(records);
77
+ }
78
+
79
+ // Serialize all read-modify-write operations against licenses.json. Each
80
+ // mutating function does (readRecords → mutate → writeRecords); without a lock,
81
+ // two concurrent calls can interleave such that the second one's read predates
82
+ // the first one's write, and the second one's write then clobbers the first.
83
+ // This was visible during archive sync (applyEventsToStore fires many writes
84
+ // back-to-back) racing with /confirm/* writes from buyer flows.
85
+ let writeChain: Promise<void> = Promise.resolve();
86
+ function withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
87
+ const locked = () => (backend.withLock ? backend.withLock(fn) : fn());
88
+ const result = writeChain.then(locked, locked);
89
+ writeChain = result.then(() => {}, () => {});
90
+ return result;
91
+ }
92
+
93
+ // Records belonging to a specific zkApp. Each zkApp owns an independent
94
+ // on-chain MerkleMap, so all map operations must filter first.
95
+ function recordsForApp(records: LicenseRecord[], zkAppAddress: string): LicenseRecord[] {
96
+ return records.filter(r => r.zkAppAddress === zkAppAddress);
97
+ }
98
+
99
+ // Rebuild the license MerkleMap for a single zkApp. Only inserts entries
100
+ // with expirySlot > 0. The value stored at each key is `leaf.hash()` — the
101
+ // Poseidon commitment over (licenseHash, expirySlot, cap, deviceSlotsRoot,
102
+ // deviceSlotsFilled) — matching what LicensingApp writes. Records that
103
+ // predate device binding rebuild to a leaf with empty slots (see
104
+ // leafFromRecord for the exact defaults); on those records the rebuilt
105
+ // root matches what the old contract wrote, but any mutation call will
106
+ // fail the in-circuit hash check until the missing fields are backfilled
107
+ // from chain events.
108
+ export function buildMap(records: LicenseRecord[], zkAppAddress: string): MerkleMap {
109
+ const map = new MerkleMap();
110
+ for (const r of recordsForApp(records, zkAppAddress)) {
111
+ if (r.expirySlot !== 0) {
112
+ map.set(Field(r.licenseHash), leafHash(leafFromRecord(r)));
113
+ }
114
+ }
115
+ return map;
116
+ }
117
+
118
+ // The license expiry the escrow commitment was made against — the expiry at
119
+ // purchase time, before any renewal. `initialExpirySlot` is written at every
120
+ // issue path (prove/buy staging, the licenseIssued event apply); the
121
+ // `expirySlot` fallback only covers legacy records, which cannot have been
122
+ // renewed without also receiving the backfill.
123
+ export function escrowExpirySlot(r: LicenseRecord): number {
124
+ return r.initialExpirySlot ?? r.expirySlot;
125
+ }
126
+
127
+ // Rebuild the escrow MerkleMap for a single zkApp:
128
+ // licenseHash → escrowCommitment(amount, slot, buyerKey, initialExpirySlot).
129
+ // Only includes entries with live escrow (not released, not refunded, not an
130
+ // escrowless purchase) and a known buyer. Must match LicensingApp.ts
131
+ // escrowCommitment field-for-field.
132
+ export function buildEscrowMap(records: LicenseRecord[], zkAppAddress: string): MerkleMap {
133
+ const map = new MerkleMap();
134
+ for (const r of recordsForApp(records, zkAppAddress)) {
135
+ if (!r.released && !r.refunded && !r.escrowless && r.purchaseSlot > 0 && r.buyerAddress) {
136
+ const buyerKey = PublicKey.fromBase58(r.buyerAddress);
137
+ const commitment = Poseidon.hash([
138
+ UInt64.from(BigInt(r.purchaseAmount)).value,
139
+ UInt32.from(r.purchaseSlot).value,
140
+ buyerKey.x,
141
+ buyerKey.isOdd.toField(),
142
+ UInt32.from(escrowExpirySlot(r)).value,
143
+ ]);
144
+ map.set(Field(r.licenseHash), commitment);
145
+ }
146
+ }
147
+ return map;
148
+ }
149
+
150
+ // Serialize a MerkleMapWitness to plain JSON (safe to send over HTTP).
151
+ export function serializeWitness(witness: MerkleMapWitness): {
152
+ isLefts: boolean[];
153
+ siblings: string[];
154
+ } {
155
+ return {
156
+ isLefts: witness.isLefts.map((b) => b.toBoolean()),
157
+ siblings: witness.siblings.map((f) => f.toString()),
158
+ };
159
+ }
160
+
161
+ // Reconstruct a MerkleMapWitness from serialized JSON.
162
+ export function deserializeWitness(json: {
163
+ isLefts: boolean[];
164
+ siblings: string[];
165
+ }): MerkleMapWitness {
166
+ return new MerkleMapWitness(
167
+ json.isLefts.map((b: boolean) => Bool(b)),
168
+ json.siblings.map((s: string) => Field(s))
169
+ );
170
+ }
171
+
172
+ // Returns the witness for a licenseHash key in the given zkApp's map.
173
+ export async function getWitness(zkAppAddress: string, licenseHash: string): Promise<{
174
+ root: string;
175
+ currentValue: string;
176
+ witness: MerkleMapWitness;
177
+ serialized: ReturnType<typeof serializeWitness>;
178
+ }> {
179
+ const records = await readRecords();
180
+ const map = buildMap(records, zkAppAddress);
181
+ const key = Field(licenseHash);
182
+ const witness = map.getWitness(key);
183
+ return {
184
+ root: map.getRoot().toString(),
185
+ currentValue: map.get(key).toString(),
186
+ witness,
187
+ serialized: serializeWitness(witness),
188
+ };
189
+ }
190
+
191
+ // Look up a single license record by its composite key.
192
+ export async function getLicense(zkAppAddress: string, licenseHash: string): Promise<LicenseRecord | null> {
193
+ const records = await readRecords();
194
+ return records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash) ?? null;
195
+ }
196
+
197
+ // Determine the status of a license. A license in the 7-day grace window is
198
+ // still considered 'active' so renewal is not urgently forced.
199
+ export function licenseStatus(record: LicenseRecord | null, currentSlot: number, forkActivationSlot = 0): LicenseStatus {
200
+ if (!record) return 'unknown';
201
+ if (record.transferredTo) return 'transferred';
202
+ if (record.expirySlot === 0) return 'refunded';
203
+ if (effectiveExpirySlot(record.expirySlot, forkActivationSlot) + GRACE_PERIOD_N < currentSlot) return 'expired';
204
+ return 'active';
205
+ }
206
+
207
+ /**
208
+ * A redeployed app's successor starts with every license of `from` (its
209
+ * frozen tree, checked on chain): copy them to `to`, marked carriedOverFrom
210
+ * (not new sales). Refunded and already-present licenses are skipped.
211
+ * Returns how many were copied.
212
+ */
213
+ export async function carryOverRecords(from: string, to: string): Promise<number> {
214
+ return withWriteLock(async () => {
215
+ const records = await readRecords();
216
+ const present = new Set(records.filter(r => r.zkAppAddress === to).map(r => r.licenseHash));
217
+ const copies = records
218
+ .filter(r => r.zkAppAddress === from && r.expirySlot > 0 && !present.has(r.licenseHash))
219
+ .map((r): LicenseRecord => ({
220
+ // License state only: the sale, its renewals and its escrow stay on
221
+ // the earlier deployment's record, so they're counted once.
222
+ zkAppAddress: to, carriedOverFrom: from,
223
+ licenseHash: r.licenseHash, expirySlot: r.expirySlot,
224
+ purchaseSlot: 0, purchaseAmount: '0', txHash: r.txHash, createdAt: r.createdAt,
225
+ buyerAddress: r.buyerAddress, deviceCap: r.deviceCap, deviceSlotsRoot: r.deviceSlotsRoot,
226
+ deviceSlotsFilled: r.deviceSlotsFilled, devices: r.devices,
227
+ initialExpirySlot: r.initialExpirySlot, tier: r.tier,
228
+ renewedAtLeastOnce: r.renewedAtLeastOnce || (r.renewals?.length ?? 0) > 0 || undefined,
229
+ }));
230
+ if (copies.length) await writeRecords([...records, ...copies]);
231
+ return copies.length;
232
+ });
233
+ }
234
+
235
+ /**
236
+ * A forced retirement left `zkAppAddress`'s escrowed purchases on the
237
+ * retired contract: marks them stranded (no refund or release follows).
238
+ * Returns how many.
239
+ */
240
+ export async function markEscrowStranded(zkAppAddress: string, txHash: string, at = new Date().toISOString()): Promise<number> {
241
+ return withWriteLock(async () => {
242
+ const records = await readRecords();
243
+ let n = 0;
244
+ for (const r of records) {
245
+ if (r.zkAppAddress !== zkAppAddress || r.released || r.refunded || r.escrowless || r.escrowStranded || !(r.purchaseSlot > 0)) continue;
246
+ r.escrowStranded = { txHash, at };
247
+ n++;
248
+ }
249
+ if (n) await writeRecords(records);
250
+ return n;
251
+ });
252
+ }
253
+
254
+ // Add or update a license record (upsert by composite key).
255
+ export async function upsertLicense(record: LicenseRecord): Promise<void> {
256
+ if (!record.zkAppAddress) {
257
+ throw new Error('upsertLicense: record.zkAppAddress is required');
258
+ }
259
+ // Populate the empty-slot mirror at insert time when the license is capped.
260
+ // Without this, /confirm/buy writes deviceCap:N but leaves deviceSlotsRoot
261
+ // and deviceSlotsFilled undefined until the first bind — leafFromRecord's
262
+ // fallbacks paper over it for hash reconstruction, but every consumer that
263
+ // reads the mirror directly (preflight logs, /license responses, offline
264
+ // verify) would see undefined and either misreport or need its own fallback.
265
+ if ((record.deviceCap ?? 0) > 0 && (record.deviceSlotsRoot === undefined || record.deviceSlotsFilled === undefined)) {
266
+ refreshSlotMirror(record);
267
+ }
268
+ return withWriteLock(async () => {
269
+ const records = await readRecords();
270
+ const idx = records.findIndex(r => r.zkAppAddress === record.zkAppAddress && r.licenseHash === record.licenseHash);
271
+ if (idx >= 0) {
272
+ records[idx] = record;
273
+ } else {
274
+ records.push(record);
275
+ }
276
+ await writeRecords(records);
277
+ });
278
+ }
279
+
280
+ // Update the expirySlot for an existing record (used by renew).
281
+ // `opts.markRenewed` flips renewedAtLeastOnce so stats can detect first-renewal
282
+ // without re-walking events. `opts.renewal` appends to the renewals[] log so
283
+ // stats can compute renewal revenue from the store alone.
284
+ export async function updateExpiry(
285
+ zkAppAddress: string,
286
+ licenseHash: string,
287
+ expirySlot: number,
288
+ opts: { markRenewed?: boolean; renewal?: { slot: number; amount: string; newExpiry: number; txHash?: string } } = {},
289
+ ): Promise<boolean> {
290
+ return withWriteLock(async () => {
291
+ const records = await readRecords();
292
+ const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
293
+ if (!record) return false;
294
+ record.expirySlot = expirySlot;
295
+ if (opts.markRenewed) record.renewedAtLeastOnce = true;
296
+ if (opts.renewal) {
297
+ const renewals = (record.renewals ??= []);
298
+ // Dedup by newExpiry — confirm-time and event-sync may both try to log
299
+ // the same renewal, but each renewal advances expiry by a unique tier
300
+ // amount, so newExpiry is a stable identifier.
301
+ if (!renewals.some(r => r.newExpiry === opts.renewal!.newExpiry)) {
302
+ renewals.push(opts.renewal);
303
+ }
304
+ }
305
+ await writeRecords(records);
306
+ return true;
307
+ });
308
+ }
309
+
310
+ // Clear license and escrow data after a refund. Also flips `refunded`.
311
+ //
312
+ // Two modes:
313
+ // provisional=false (default, called from archive event-sync on a canonical
314
+ // refundIssued event): sets refunded=true, zeros expiry/purchase, clears
315
+ // pending. This is the authoritative confirm — includes upgrading a prior
316
+ // 'fast' flip.
317
+ // provisional=true (called from the tx-land watcher on first bestchain
318
+ // observation): sets refunded='fast' and records provisionalFlipAtSlot,
319
+ // but does NOT zero expirySlot/purchaseSlot yet — those stay live until
320
+ // the event is canonical, so a reorg revert restores refunded=false
321
+ // without needing snapshots.
322
+ export async function recordRefund(
323
+ zkAppAddress: string,
324
+ licenseHash: string,
325
+ opts?: { provisional?: boolean; nowSlot?: number },
326
+ ): Promise<boolean> {
327
+ return withWriteLock(async () => {
328
+ const records = await readRecords();
329
+ const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
330
+ if (!record) return false;
331
+ if (opts?.provisional) {
332
+ // Preserve expirySlot/purchaseSlot for revertability. UI treats
333
+ // 'fast' as refunded (truthy); circuit ops see 409 via pre-flight
334
+ // guards if the local state diverges from chain during the window.
335
+ if (record.refunded !== true) record.refunded = 'fast';
336
+ if (opts.nowSlot !== undefined) record.provisionalFlipAtSlot = opts.nowSlot;
337
+ } else {
338
+ record.expirySlot = 0;
339
+ record.purchaseSlot = 0;
340
+ record.purchaseAmount = '0';
341
+ record.refunded = true;
342
+ // /confirm/refund wrote pending:true optimistically; the on-chain event
343
+ // landing is what authorizes clearing it. Without this the SDK poller
344
+ // (which requires pending===false && status==='refunded') hangs forever.
345
+ delete record.pending;
346
+ delete record.pendingPrior;
347
+ delete record.provedAtSlot;
348
+ // Any prior 'fast' provisional flip is now confirmed — drop the marker.
349
+ delete record.provisionalFlipAtSlot;
350
+ }
351
+ await writeRecords(records);
352
+ return true;
353
+ });
354
+ }
355
+
356
+ // Mark escrow as released. Preserves purchaseAmount/purchaseSlot so stats can
357
+ // compute released revenue. The `released` flag is the truth signal —
358
+ // buildEscrowMap and getReleasableRecords filter on it directly.
359
+ //
360
+ // Two modes: same semantics as recordRefund above.
361
+ // provisional=true → released='fast', provisionalFlipAtSlot=nowSlot
362
+ // provisional=false → released=true (upgrades any 'fast' state), clears
363
+ // provisionalFlipAtSlot
364
+ export async function recordRelease(
365
+ zkAppAddress: string,
366
+ licenseHash: string,
367
+ releaseSlot?: number,
368
+ txHash?: string,
369
+ opts?: { provisional?: boolean; nowSlot?: number },
370
+ ): Promise<boolean> {
371
+ return withWriteLock(async () => {
372
+ const records = await readRecords();
373
+ const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
374
+ if (!record) return false;
375
+ if (opts?.provisional) {
376
+ if (record.released !== true) record.released = 'fast';
377
+ if (opts.nowSlot !== undefined) record.provisionalFlipAtSlot = opts.nowSlot;
378
+ } else {
379
+ record.released = true;
380
+ delete record.provisionalFlipAtSlot;
381
+ }
382
+ if (releaseSlot !== undefined) record.releaseSlot = releaseSlot;
383
+ if (txHash !== undefined) record.releaseTxHash = txHash;
384
+ await writeRecords(records);
385
+ return true;
386
+ });
387
+ }
388
+
389
+ // Roll back a provisional 'fast' flip that never got confirmed by the archive.
390
+ // Called by the reorg-revert reaper after REORG_REVERT_AFTER_SLOTS elapses
391
+ // without a canonical event. Only reverts if the field is still 'fast' —
392
+ // confirmed (true) flips are irreversible from this path.
393
+ export async function revertProvisionalFlip(
394
+ zkAppAddress: string,
395
+ licenseHash: string,
396
+ kind: 'release' | 'refund',
397
+ ): Promise<boolean> {
398
+ return withWriteLock(async () => {
399
+ const records = await readRecords();
400
+ const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
401
+ if (!record) return false;
402
+ if (kind === 'release') {
403
+ if (record.released !== 'fast') return false;
404
+ record.released = false;
405
+ delete record.releaseSlot;
406
+ delete record.releaseTxHash;
407
+ } else {
408
+ if (record.refunded !== 'fast') return false;
409
+ record.refunded = false;
410
+ // expirySlot/purchaseSlot were never zeroed for provisional refunds, so
411
+ // nothing to restore there.
412
+ }
413
+ // Clear the marker only if no OTHER field is still provisional.
414
+ if (record.released !== 'fast' && record.refunded !== 'fast') {
415
+ delete record.provisionalFlipAtSlot;
416
+ }
417
+ await writeRecords(records);
418
+ return true;
419
+ });
420
+ }
421
+
422
+ // Clear the pending flag on a record after a chain event confirms it landed.
423
+ export async function confirmRecord(zkAppAddress: string, licenseHash: string): Promise<boolean> {
424
+ return withWriteLock(async () => {
425
+ const records = await readRecords();
426
+ const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
427
+ if (!record) return false;
428
+ if (!record.pending) return false;
429
+ delete record.pending;
430
+ delete record.pendingPrior;
431
+ delete record.provedAtSlot;
432
+ await writeRecords(records);
433
+ return true;
434
+ });
435
+ }
436
+
437
+ // Apply a licenseIssued event atomically: confirm any pending /confirm/buy
438
+ // write, backfill initialExpirySlot/tier if missing, or create a fresh record.
439
+ // Doing the whole read-decide-write inside a single lock acquisition avoids the
440
+ // stale-snapshot hazard that would arise if archiveBootstrap called
441
+ // getLicense + confirmRecord + upsertLicense as three separate locked steps —
442
+ // a concurrent write between them could be clobbered by the upsert's
443
+ // pre-confirm snapshot.
444
+ export async function applyLicenseIssued(
445
+ zkAppAddress: string,
446
+ licenseHash: string,
447
+ fields: {
448
+ expirySlot: number;
449
+ purchaseSlot: number;
450
+ purchaseAmount: string;
451
+ tier?: 'monthly' | 'yearly' | 'fiveYear' | 'perpetual';
452
+ // True for licenses minted through buyLicenseNoEscrow: funds went straight
453
+ // to vendor + platform, no escrow entry. Refund CTA must be suppressed.
454
+ escrowless?: boolean;
455
+ // Leaf fields carried by LicenseIssuedEvent so a record rehydrated purely
456
+ // from archive replay (no prior /confirm/buy) reconstructs the exact leaf
457
+ // hash the circuit committed. Optional so pre-event-extension replays
458
+ // (which never carried them) still bootstrap — those records will fail
459
+ // in-circuit hash checks on the next mutation, matching prior behavior.
460
+ // No ownerPubKeyHash field: the leaf's owner-pubkey commitment IS
461
+ // `licenseHash` itself (buyLicense asserts them equal), already the key
462
+ // this function is called with.
463
+ deviceCap?: number;
464
+ deviceSlotsRoot?: string;
465
+ deviceSlotsFilled?: number;
466
+ // Buyer's public key, carried by LicenseIssuedEvent. Required for
467
+ // buildEscrowMap (funds-release witness reconstruction) AND for
468
+ // GET /licenses?buyer=<addr> enumeration (the primary UI source for the
469
+ // "My Licenses" view). Migration-replay callers must look up the
470
+ // predecessor record's buyerAddress before calling this.
471
+ buyerAddress: string;
472
+ },
473
+ ): Promise<void> {
474
+ return withWriteLock(async () => {
475
+ const records = await readRecords();
476
+ const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
477
+ let dirty = false;
478
+ if (record) {
479
+ if (record.pending) {
480
+ delete record.pending;
481
+ delete record.pendingPrior;
482
+ delete record.provedAtSlot;
483
+ dirty = true;
484
+ }
485
+ if (record.initialExpirySlot === undefined) {
486
+ record.initialExpirySlot = fields.expirySlot;
487
+ dirty = true;
488
+ }
489
+ if (!record.tier && fields.tier) {
490
+ record.tier = fields.tier;
491
+ dirty = true;
492
+ }
493
+ if (fields.escrowless === true && record.escrowless !== true) {
494
+ record.escrowless = true;
495
+ dirty = true;
496
+ }
497
+ // Backfill leaf fields if the record was created before the event
498
+ // carried them, or if a prior partial rehydrate is still missing them.
499
+ if (fields.deviceCap !== undefined && record.deviceCap === undefined) {
500
+ record.deviceCap = fields.deviceCap;
501
+ dirty = true;
502
+ }
503
+ if (fields.deviceSlotsRoot !== undefined && record.deviceSlotsRoot === undefined) {
504
+ record.deviceSlotsRoot = fields.deviceSlotsRoot;
505
+ dirty = true;
506
+ }
507
+ if (fields.deviceSlotsFilled !== undefined && record.deviceSlotsFilled === undefined) {
508
+ record.deviceSlotsFilled = fields.deviceSlotsFilled;
509
+ dirty = true;
510
+ }
511
+ if (record.buyerAddress === undefined) {
512
+ // Legacy record predating the required-buyerAddress change — backfill
513
+ // from the fresh event so it becomes enumerable via /licenses?buyer=.
514
+ record.buyerAddress = fields.buyerAddress;
515
+ dirty = true;
516
+ }
517
+ } else {
518
+ const inserted: LicenseRecord = {
519
+ zkAppAddress,
520
+ licenseHash,
521
+ expirySlot: fields.expirySlot,
522
+ purchaseSlot: fields.purchaseSlot,
523
+ purchaseAmount: fields.purchaseAmount,
524
+ txHash: '',
525
+ createdAt: new Date().toISOString(),
526
+ initialExpirySlot: fields.expirySlot,
527
+ ...(fields.tier ? { tier: fields.tier } : {}),
528
+ ...(fields.escrowless === true ? { escrowless: true } : {}),
529
+ ...(fields.deviceCap !== undefined ? { deviceCap: fields.deviceCap } : {}),
530
+ ...(fields.deviceSlotsRoot !== undefined ? { deviceSlotsRoot: fields.deviceSlotsRoot } : {}),
531
+ ...(fields.deviceSlotsFilled !== undefined ? { deviceSlotsFilled: fields.deviceSlotsFilled } : {}),
532
+ buyerAddress: fields.buyerAddress,
533
+ };
534
+ // Same rationale as upsertLicense: populate the empty-slot mirror on
535
+ // first insert so the record is immediately queryable without relying
536
+ // on leafFromRecord's undefined fallbacks.
537
+ if ((inserted.deviceCap ?? 0) > 0 && (inserted.deviceSlotsRoot === undefined || inserted.deviceSlotsFilled === undefined)) {
538
+ refreshSlotMirror(inserted);
539
+ }
540
+ records.push(inserted);
541
+ dirty = true;
542
+ }
543
+ if (dirty) await writeRecords(records);
544
+ });
545
+ }
546
+
547
+ // Apply a licenseRenewed event atomically: clear any pending /confirm/renew
548
+ // write, align expirySlot with the event, and flip renewedAtLeastOnce.
549
+ // Symmetric to applyLicenseIssued — the archive-events path is what closes
550
+ // out a renew that the bestChain sweep missed (tx landed after the sweep's
551
+ // BESTCHAIN_DEPTH window rolled past), and without a pending-clear step here
552
+ // the flag on a landed record could be stuck forever.
553
+ //
554
+ // Does NOT append to renewals[] — that log is populated at /confirm/renew
555
+ // time from the intent (which carries amount + txHash); the archive event
556
+ // carries only newExpiry, so replaying it must not overwrite the richer
557
+ // confirm-time entry.
558
+ export async function applyLicenseRenewed(
559
+ zkAppAddress: string,
560
+ licenseHash: string,
561
+ fields: { newExpiry: number },
562
+ ): Promise<boolean> {
563
+ return withWriteLock(async () => {
564
+ const records = await readRecords();
565
+ const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
566
+ if (!record) return false;
567
+ let dirty = false;
568
+ if (record.pending) {
569
+ delete record.pending;
570
+ delete record.pendingPrior;
571
+ delete record.provedAtSlot;
572
+ dirty = true;
573
+ }
574
+ if (record.expirySlot !== fields.newExpiry) {
575
+ record.expirySlot = fields.newExpiry;
576
+ dirty = true;
577
+ }
578
+ if (!record.renewedAtLeastOnce) {
579
+ record.renewedAtLeastOnce = true;
580
+ dirty = true;
581
+ }
582
+ if (dirty) await writeRecords(records);
583
+ return true;
584
+ });
585
+ }
586
+
587
+ /**
588
+ * A license moved to a new owner key (transferLicense): the license state
589
+ * (expiry, cap, bound devices) goes to a record under `to`, with no sale of
590
+ * its own (the sale and its receipt stay on the original record), listed
591
+ * under `wallet` (the transfer's sender, the new owner's wallet, which
592
+ * paid), and the original leaves the licenses tree (expirySlot 0) marked
593
+ * `transferredTo`.
594
+ */
595
+ export async function applyLicenseTransferred(
596
+ zkAppAddress: string,
597
+ from: string,
598
+ to: string,
599
+ opts: { wallet?: string; txHash?: string } = {},
600
+ ): Promise<boolean> {
601
+ return withWriteLock(async () => {
602
+ const records = await readRecords();
603
+ const old = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === from);
604
+ if (!old) return false;
605
+ if (old.transferredTo === to) return true; // already applied
606
+ const moved: LicenseRecord = {
607
+ zkAppAddress, licenseHash: to, expirySlot: old.expirySlot, purchaseSlot: 0, purchaseAmount: '0',
608
+ txHash: opts.txHash ?? '', createdAt: new Date().toISOString(), buyerAddress: opts.wallet ?? '',
609
+ transferredFrom: from,
610
+ ...(old.deviceCap !== undefined ? { deviceCap: old.deviceCap } : {}),
611
+ ...(old.deviceSlotsRoot !== undefined ? { deviceSlotsRoot: old.deviceSlotsRoot } : {}),
612
+ ...(old.deviceSlotsFilled !== undefined ? { deviceSlotsFilled: old.deviceSlotsFilled } : {}),
613
+ ...(old.devices ? { devices: old.devices.map(d => ({ ...d })) } : {}),
614
+ ...(old.escrowless ? { escrowless: true } : {}),
615
+ ...(old.tier ? { tier: old.tier } : {}),
616
+ };
617
+ const existing = records.findIndex(r => r.zkAppAddress === zkAppAddress && r.licenseHash === to);
618
+ if (existing >= 0) records[existing] = moved; else records.push(moved);
619
+ old.transferredTo = to;
620
+ old.expirySlot = 0;
621
+ delete old.devices;
622
+ delete old.pending;
623
+ delete old.pendingPrior;
624
+ delete old.provedAtSlot;
625
+ await writeRecords(records);
626
+ return true;
627
+ });
628
+ }
629
+
630
+ // Recompute deviceSlotsRoot + deviceSlotsFilled from the record's mirror after
631
+ // a bind/unbind mutation. `leafFromRecord` uses those two persisted fields when
632
+ // building the on-chain leaf hash; if they drift from the on-chain state the
633
+ // next mutation call fails the in-circuit leaf-hash assert. Mirror the exact
634
+ // packing the circuit uses (packSlotTimes: freedAt in low 32 bits, boundAt in
635
+ // high 32) so the fold matches the on-chain deviceSlotsRoot bit-for-bit.
636
+ function refreshSlotMirror(record: LicenseRecord): void {
637
+ const devices = record.devices ?? [];
638
+ const occupied: Field[] = [];
639
+ const freedAt: Field[] = [];
640
+ for (let i = 0; i < DEVICE_SLOT_CAP_N; i++) {
641
+ const entry = devices.find(d => d.slotIndex === i);
642
+ const occ = entry?.deviceCommitment ?? '0';
643
+ occupied.push(Field(occ));
644
+ const boundAt = BigInt(entry?.boundAtSlot ?? 0);
645
+ const freed = BigInt(entry?.freedAtSlot ?? 0);
646
+ // packSlotTimes(boundAt, freedAt) in licenseLeaf.ts: freedAt low 32, boundAt high 32.
647
+ const packed = freed + (boundAt << 32n);
648
+ freedAt.push(Field(packed));
649
+ }
650
+ record.deviceSlotsRoot = computeDeviceSlotsRoot(occupied, freedAt).toString();
651
+ record.deviceSlotsFilled = devices.filter(d => d.deviceCommitment !== '0').length;
652
+ }
653
+
654
+ // Apply a deviceBound event atomically: mirrors the on-chain slot mutation
655
+ // into the record.devices[] array and recomputes deviceSlotsRoot / filled so
656
+ // leafFromRecord reconstructs the exact leaf hash the chain committed.
657
+ //
658
+ // `boundAtSlot` MUST be the circuit's proof-time `globalSlotSinceGenesis` (the
659
+ // value emitted in `DeviceBoundEvent.boundAtSlot`). The block slot when the tx
660
+ // landed is NOT interchangeable: TOLERANCE_WINDOW allows them to differ, and
661
+ // any drift flips the packed `freedAt` field → wrong leaf → root divergence.
662
+ export async function applyDeviceBound(
663
+ zkAppAddress: string,
664
+ licenseHash: string,
665
+ fields: { slotIndex: number; deviceCommitment: string; boundAtSlot: number },
666
+ ): Promise<boolean> {
667
+ return withWriteLock(async () => {
668
+ const records = await readRecords();
669
+ const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
670
+ if (!record) return false;
671
+ const devices = (record.devices ??= []);
672
+ // Ensure the array is long enough — pad with empty slots.
673
+ while (devices.length <= fields.slotIndex) {
674
+ devices.push({ slotIndex: devices.length, deviceCommitment: '0', boundAtSlot: 0, freedAtSlot: 0 });
675
+ }
676
+ devices[fields.slotIndex] = {
677
+ slotIndex: fields.slotIndex,
678
+ deviceCommitment: fields.deviceCommitment,
679
+ boundAtSlot: fields.boundAtSlot,
680
+ freedAtSlot: 0, // pristine → fresh bind resets freedAt
681
+ };
682
+ refreshSlotMirror(record);
683
+ await writeRecords(records);
684
+ return true;
685
+ });
686
+ }
687
+
688
+ // Apply a deviceUnbound event atomically. Preserves boundAtSlot for audit,
689
+ // stamps freedAtSlot for the cooldown timer the circuit's next bind enforces.
690
+ //
691
+ // `freedAtSlot` MUST be the circuit's proof-time `globalSlotSinceGenesis` (the
692
+ // value emitted in `DeviceUnboundEvent.freedAtSlot`). Same reason as
693
+ // `applyDeviceBound.boundAtSlot`.
694
+ export async function applyDeviceUnbound(
695
+ zkAppAddress: string,
696
+ licenseHash: string,
697
+ fields: { slotIndex: number; freedAtSlot: number },
698
+ ): Promise<boolean> {
699
+ return withWriteLock(async () => {
700
+ const records = await readRecords();
701
+ const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
702
+ if (!record || !record.devices) return false;
703
+ const slot = record.devices.find(d => d.slotIndex === fields.slotIndex);
704
+ if (!slot) return false;
705
+ slot.deviceCommitment = '0';
706
+ slot.freedAtSlot = fields.freedAtSlot;
707
+ // boundAtSlot stays as the last bind time — historical / audit.
708
+ refreshSlotMirror(record);
709
+ await writeRecords(records);
710
+ return true;
711
+ });
712
+ }
713
+
714
+ // Undo a pending change whose transaction failed on chain or never landed:
715
+ // restore the record it replaced (a renewal or refund), or remove it (a
716
+ // purchase). With `txHash`, only if the pending change is that transaction's.
717
+ export async function revertPending(zkAppAddress: string, licenseHash: string, txHash?: string): Promise<'restored' | 'removed' | false> {
718
+ return withWriteLock(async () => {
719
+ const records = await readRecords();
720
+ const idx = records.findIndex(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
721
+ if (idx < 0) return false;
722
+ const record = records[idx];
723
+ if (!record.pending || (txHash !== undefined && record.txHash !== txHash)) return false;
724
+ const outcome = record.pendingPrior ? 'restored' : 'removed';
725
+ if (record.pendingPrior) records[idx] = record.pendingPrior;
726
+ else records.splice(idx, 1);
727
+ await writeRecords(records);
728
+ return outcome;
729
+ });
730
+ }
731
+
732
+ // Delete a record entirely (used to reap stale pending entries the buyer never submitted).
733
+ export async function deleteRecord(zkAppAddress: string, licenseHash: string): Promise<boolean> {
734
+ return withWriteLock(async () => {
735
+ const records = await readRecords();
736
+ const idx = records.findIndex(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
737
+ if (idx < 0) return false;
738
+ records.splice(idx, 1);
739
+ await writeRecords(records);
740
+ return true;
741
+ });
742
+ }
743
+
744
+ // List pending records whose prove slot is older than `currentSlot - maxAgeSlots`.
745
+ // Used by the reaper to drop records the buyer never actually signed/submitted.
746
+ export async function getStalePendingRecords(currentSlot: number, maxAgeSlots = PENDING_REAP_SLOTS): Promise<LicenseRecord[]> {
747
+ const records = await readRecords();
748
+ return records.filter(
749
+ (r) => r.pending && typeof r.provedAtSlot === 'number' && currentSlot - r.provedAtSlot > maxAgeSlots,
750
+ );
751
+ }
752
+
753
+ // Return all records where the refund window has passed and escrow is still live.
754
+ // Filters on the released/refunded flags so we don't re-release or release after refund.
755
+ export async function getReleasableRecords(currentSlot: number): Promise<LicenseRecord[]> {
756
+ const records = await readRecords();
757
+ return records.filter(
758
+ (r) =>
759
+ !r.released &&
760
+ !r.refunded &&
761
+ !r.escrowless &&
762
+ !r.escrowStranded &&
763
+ r.purchaseSlot > 0 &&
764
+ r.zkAppAddress &&
765
+ currentSlot > r.purchaseSlot + REFUND_WINDOW_N,
766
+ );
767
+ }