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,982 @@
1
+ #!/usr/bin/env node
2
+ // Copyright (c) 2025-2026 zkLicensing project developers
3
+ // SPDX-License-Identifier: BUSL-1.1
4
+
5
+ /**
6
+ * verifyService.ts
7
+ *
8
+ * Lightweight, standalone license verification service.
9
+ *
10
+ * Exposes the same state endpoints as the backend but contains NO ZK prover —
11
+ * no circuit compilation, no o1js native backend. Starts in ~2 seconds and
12
+ * runs on any small VPS or container.
13
+ *
14
+ * Multi-network: one process can serve both devnet and mainnet. Every
15
+ * state endpoint receives a `zkAppAddress`, which routes unambiguously to
16
+ * one network via AppRecord.deployments (each generation has its own
17
+ * keypair — zkAppAddresses are globally unique across networks). The active
18
+ * o1js instance is switched per-request via withNetwork(), which also holds
19
+ * a process-global mutex so two concurrent handlers on different networks
20
+ * can't race the Mina.setActiveInstance swap.
21
+ *
22
+ * No write endpoints — chain events are the source of truth. License state
23
+ * is rebuilt from the archive node in three ways:
24
+ * - auto-bootstrap on startup when licenses.json is empty (loops over
25
+ * every configured network's apps),
26
+ * - lazy bootstrap inside GET / when the computed Merkle root
27
+ * disagrees with the on-chain root for the requested app,
28
+ * - POST /bootstrap (admin) for ad-hoc reasons.
29
+ *
30
+ * Deployment modes
31
+ * ----------------
32
+ * 1. Same-machine as the backend
33
+ * - Run from the backend's working directory so apps.json and licenses.json
34
+ * are shared transparently. Optionally set LICENSE_STORE_PATH to point at
35
+ * a shared file.
36
+ *
37
+ * 2. Separate machine (replica)
38
+ * - Copy the backend's apps.json into the verifyService working directory.
39
+ * - Set at least one of MINA_DEVNET_GRAPHQL_URL / MINA_MAINNET_GRAPHQL_URL.
40
+ * - Optionally set MINA_{DEVNET,MAINNET}_{GRAPHQL,ARCHIVE}_FALLBACK for a
41
+ * per-network fallback endpoint (used by the manual rotation on 5xx).
42
+ * - Re-copying apps.json on a cron (or pulling it via GET /apps from the
43
+ * backend) is the recommended way to keep the registry in sync.
44
+ *
45
+ * Usage:
46
+ * node build/src/verifyService.js
47
+ *
48
+ * Configuration is read from environment variables (see README §Configuration).
49
+ * Required: at least one of MINA_DEVNET_GRAPHQL_URL or MINA_MAINNET_GRAPHQL_URL
50
+ * (each also picks up MINA_{DEVNET,MAINNET}_ARCHIVE_URL, defaulting
51
+ * to Minascan public archive per-network).
52
+ * Optional: VERIFY_PORT (default 8081), VERIFY_ADMIN_TOKEN,
53
+ * MINA_{DEVNET,MAINNET}_{GRAPHQL,ARCHIVE}_FALLBACK (single fallback
54
+ * URL per network — manual rotation retries it on 5xx).
55
+ *
56
+ * NOTE: the pre-multi-network single-network trio (MINA_NETWORK_ID +
57
+ * MINA_GRAPHQL_URL + MINA_ARCHIVE_URL) is no longer accepted. Operators must
58
+ * migrate systemd units to the per-network variable names.
59
+ *
60
+ * Endpoints:
61
+ * GET /health
62
+ * GET /?licenseHash=...&zkAppAddress=... ← primary endpoint for apps
63
+ * GET /root?zkApp=...
64
+ * GET /witness/:zkAppAddress/:licenseHash
65
+ * GET /license/:zkAppAddress/:licenseHash
66
+ * GET /apps
67
+ * POST /bootstrap ← replay events from archive
68
+ */
69
+
70
+ import fs from 'fs/promises';
71
+ import express, { Request, Response } from 'express';
72
+ import {
73
+ Field,
74
+ Mina,
75
+ NetworkId as O1NetworkId,
76
+ fetchLastBlock,
77
+ fetchAccount,
78
+ PublicKey,
79
+ setBackend,
80
+ setGraphqlEndpoint,
81
+ setArchiveGraphqlEndpoint,
82
+ } from 'o1js';
83
+ import { createRequire } from 'node:module';
84
+ import { pathToFileURL } from 'node:url';
85
+ import { dirname, resolve as pathResolve } from 'path';
86
+ import {
87
+ buildMap,
88
+ getLicense,
89
+ licenseStatus,
90
+ readRecords,
91
+ serializeWitness,
92
+ type LicenseRecord,
93
+ } from './licenseStore.js';
94
+ import { type VerifyResponse } from './verifyCore.js';
95
+ import { verifyLicenseWithAncestors } from './verifyWithAncestors.js';
96
+ import { bootstrapFromArchive, applyEventsToStore } from './archiveBootstrap.js';
97
+ import {
98
+ CANONICAL_VK_HASHES,
99
+ LICENSING_APP_STATE,
100
+ SUPPORTED_LICENSING_APP_VERSIONS,
101
+ fetchLicensingAppVersion,
102
+ } from './contractInterface.js';
103
+ import { readJsonOr } from './atomicJson.js';
104
+
105
+ setBackend('native');
106
+
107
+ // ------------------------------------------------------------
108
+ // CONFIG (env vars) & NETWORK
109
+ // ------------------------------------------------------------
110
+
111
+ // Local NetworkId — the two Mina chains we support. Distinct from o1js's
112
+ // NetworkId (which is `'mainnet' | 'testnet' | { custom: string }`); we alias
113
+ // o1js's as O1NetworkId above and use it only where a Mina.Network()
114
+ // constructor demands it.
115
+ type NetworkId = 'devnet' | 'mainnet';
116
+ const NETWORKS: readonly NetworkId[] = ['devnet', 'mainnet'] as const;
117
+
118
+ const MINA_DEVNET_GRAPHQL_URL = process.env.MINA_DEVNET_GRAPHQL_URL;
119
+ const MINA_DEVNET_ARCHIVE_URL = process.env.MINA_DEVNET_ARCHIVE_URL;
120
+ const MINA_DEVNET_GRAPHQL_FALLBACK = process.env.MINA_DEVNET_GRAPHQL_FALLBACK;
121
+ const MINA_DEVNET_ARCHIVE_FALLBACK = process.env.MINA_DEVNET_ARCHIVE_FALLBACK;
122
+ const MINA_MAINNET_GRAPHQL_URL = process.env.MINA_MAINNET_GRAPHQL_URL;
123
+ const MINA_MAINNET_ARCHIVE_URL = process.env.MINA_MAINNET_ARCHIVE_URL;
124
+ const MINA_MAINNET_GRAPHQL_FALLBACK = process.env.MINA_MAINNET_GRAPHQL_FALLBACK;
125
+ const MINA_MAINNET_ARCHIVE_FALLBACK = process.env.MINA_MAINNET_ARCHIVE_FALLBACK;
126
+ const PORT = parseInt(process.env.VERIFY_PORT ?? '8081', 10);
127
+
128
+ // Default archive endpoint per network. Devnet uses o1Labs' Mesa archive
129
+ // (devnet-archive-node-api.gcp.o1test.net); mainnet falls back to Minascan's
130
+ // public archive. When the operator sets an explicit MINA_*_ARCHIVE_URL env
131
+ // var, that value wins.
132
+ const DEFAULT_ARCHIVE_URL: Record<NetworkId, string> = {
133
+ devnet: 'https://devnet-archive-node-api.gcp.o1test.net',
134
+ mainnet: 'https://api.minascan.io/archive/mainnet/v1/graphql',
135
+ };
136
+
137
+ type NetworkConfig = {
138
+ networkId: NetworkId;
139
+ graphqlUrl: string;
140
+ archiveUrl: string;
141
+ graphqlFallback: string | null;
142
+ archiveFallback: string | null;
143
+ network: ReturnType<typeof Mina.Network>;
144
+ // Same URLs but with fallback listed first — used by manual rotation on 5xx
145
+ // (see fetchAccountWithRotation). o1js's built-in fallback races both
146
+ // endpoints and returns whichever resolves first, so a fast 502 from primary
147
+ // beats a slower 200 from fallback. Manual rotation forces the second
148
+ // attempt onto fallback-first so we actually hit the healthy endpoint.
149
+ // Null when no fallback is configured.
150
+ fallbackNetwork: ReturnType<typeof Mina.Network> | null;
151
+ };
152
+
153
+ // Build per-network Mina.Network instances. Any network the operator didn't
154
+ // configure (no RPC URL) stays out of the map — requests targeting it fall
155
+ // through to `unregistered-address`. At least one must be configured.
156
+ const NETWORK_CONFIG: Partial<Record<NetworkId, NetworkConfig>> = {};
157
+ function buildNetworkConfig(
158
+ networkId: NetworkId,
159
+ graphqlUrl: string | undefined,
160
+ archiveEnv: string | undefined,
161
+ graphqlFallbackEnv: string | undefined,
162
+ archiveFallbackEnv: string | undefined,
163
+ ): void {
164
+ if (!graphqlUrl) return;
165
+ const archiveUrl = archiveEnv ?? DEFAULT_ARCHIVE_URL[networkId];
166
+ const trim = (s: string | undefined) => (s ? s.trim() : '');
167
+ const graphqlFallback = trim(graphqlFallbackEnv) && trim(graphqlFallbackEnv) !== graphqlUrl
168
+ ? trim(graphqlFallbackEnv) : null;
169
+ const archiveFallback = trim(archiveFallbackEnv) && trim(archiveFallbackEnv) !== archiveUrl
170
+ ? trim(archiveFallbackEnv) : null;
171
+ // See keeperService.ts for the full rationale: o1js's array-based fallback
172
+ // form (`mina: [primary, fallback]`) is unusable — Promise.race lets a fast
173
+ // 5xx from primary beat a slow 200 from fallback, and the winning-URL
174
+ // cache-key bug causes subsequent `.get()` calls to miss. Manual rotation
175
+ // via a second single-URL Mina.Network instance is the sole failover path.
176
+ const fallbackMinaArg = graphqlFallback ? graphqlFallback : null;
177
+ const fallbackArchiveArg = graphqlFallback ? (archiveFallback ?? archiveUrl) : null;
178
+ NETWORK_CONFIG[networkId] = {
179
+ networkId,
180
+ graphqlUrl,
181
+ archiveUrl,
182
+ graphqlFallback,
183
+ archiveFallback,
184
+ network: Mina.Network({ networkId: networkId as O1NetworkId, mina: graphqlUrl, archive: archiveUrl }),
185
+ fallbackNetwork: fallbackMinaArg
186
+ ? Mina.Network({ networkId: networkId as O1NetworkId, mina: fallbackMinaArg, archive: fallbackArchiveArg! })
187
+ : null,
188
+ };
189
+ console.log(`🔗 ${networkId.padEnd(7)} Mina node: ${graphqlUrl}`);
190
+ if (graphqlFallback) console.log(` ${networkId.padEnd(7)} fallback: ${graphqlFallback}`);
191
+ console.log(`📚 ${networkId.padEnd(7)} Archive node: ${archiveUrl}${archiveEnv ? ' (from env)' : ' (default)'}`);
192
+ if (archiveFallback) console.log(` ${networkId.padEnd(7)} fallback: ${archiveFallback}`);
193
+ }
194
+ buildNetworkConfig('devnet', MINA_DEVNET_GRAPHQL_URL, MINA_DEVNET_ARCHIVE_URL, MINA_DEVNET_GRAPHQL_FALLBACK, MINA_DEVNET_ARCHIVE_FALLBACK);
195
+ buildNetworkConfig('mainnet', MINA_MAINNET_GRAPHQL_URL, MINA_MAINNET_ARCHIVE_URL, MINA_MAINNET_GRAPHQL_FALLBACK, MINA_MAINNET_ARCHIVE_FALLBACK);
196
+ if (Object.keys(NETWORK_CONFIG).length === 0) {
197
+ console.error('❌ No Mina network configured. Set at least one of MINA_DEVNET_GRAPHQL_URL / MINA_MAINNET_GRAPHQL_URL.');
198
+ process.exit(1);
199
+ }
200
+
201
+ // Leaked-transaction-context reset scaffolding. The verifier has no
202
+ // Mina.transaction() calls today, so the leak defense is theoretical — but
203
+ // it's cheap (one dynamic import at boot) and future-proofs against a code
204
+ // path we haven't accounted for taking the process out until systemd
205
+ // restarts it. Same shape as keeperService.ts.
206
+ const _o1jsRequire = createRequire(import.meta.url);
207
+ let _o1jsCurrentTransaction: {
208
+ has(): boolean;
209
+ id(): number;
210
+ leave(id: number): unknown;
211
+ } | null = null;
212
+ try {
213
+ const o1jsMainPath = _o1jsRequire.resolve('o1js');
214
+ const contextUrl = pathToFileURL(
215
+ pathResolve(dirname(o1jsMainPath), 'lib/mina/v1/transaction-context.js')
216
+ ).href;
217
+ const mod = await import(contextUrl) as { currentTransaction?: NonNullable<typeof _o1jsCurrentTransaction> };
218
+ _o1jsCurrentTransaction = mod.currentTransaction ?? null;
219
+ } catch (e: any) {
220
+ console.warn('[withNetwork] o1js leak defense disabled — could not reach internal transaction-context:', e?.message ?? e);
221
+ }
222
+
223
+ function resetLeakedTransactionContext(): void {
224
+ if (!_o1jsCurrentTransaction) return;
225
+ while (_o1jsCurrentTransaction.has()) {
226
+ try { _o1jsCurrentTransaction.leave(_o1jsCurrentTransaction.id()); }
227
+ catch { break; }
228
+ }
229
+ }
230
+
231
+ let _networkMutexTail: Promise<void> = Promise.resolve();
232
+
233
+ // Which endpoint (primary vs fallback) currently succeeds for each network.
234
+ // Updated by the rotation helpers whenever a call succeeds on a specific
235
+ // endpoint. `withNetwork` restores from this map at region entry so that a
236
+ // successful fallback rotation in one region persists into the next region
237
+ // — otherwise a resetToPrimary here would stomp the fallback state and the
238
+ // following .get() cache lookup would miss on primary (the o1js account
239
+ // cache is keyed by graphql endpoint URL).
240
+ //
241
+ // Sticky-until-proven-broken semantics: we don't automatically retry primary
242
+ // on every region entry. If primary comes back up, the next rotation call
243
+ // will hit it first (attempt 1) and flip us back.
244
+ const _activeEndpointByNetwork: Map<NetworkId, 'primary' | 'fallback'> = new Map();
245
+
246
+ // Swap the active Mina instance AND the o1js module-global fetch endpoint
247
+ // atomically. These are two independent globals inside o1js:
248
+ // - `activeInstance` (mina-instance.js) — what zkApp.<state>.get() closes
249
+ // over for its cache lookups
250
+ // - `networkConfig.minaEndpoint` (fetch.js) — the DEFAULT endpoint for
251
+ // bare `fetchAccount({publicKey})` / `fetchLastBlock()` calls
252
+ // `Mina.setActiveInstance` only touches the first. Because we build TWO
253
+ // Mina.Network() instances at startup (primary + fallback) and the second
254
+ // call's Fetch.setGraphqlEndpoint side-effect leaves `networkConfig.minaEndpoint`
255
+ // pinned to the LAST-constructed URL (the fallback), a plain
256
+ // setActiveInstance(cfg.network) leaves fetchAccount hitting the fallback URL
257
+ // while zkApp.<state>.get() looks up under the primary URL — cache miss →
258
+ // "Could not find account". We must always keep the two endpoints in sync,
259
+ // which this helper does.
260
+ function activateNetworkEndpoints(cfg: NetworkConfig, useFallback: boolean): void {
261
+ if (useFallback && cfg.fallbackNetwork) {
262
+ Mina.setActiveInstance(cfg.fallbackNetwork);
263
+ setGraphqlEndpoint(cfg.graphqlFallback ?? cfg.graphqlUrl);
264
+ setArchiveGraphqlEndpoint(cfg.archiveFallback ?? cfg.archiveUrl);
265
+ _activeEndpointByNetwork.set(cfg.networkId, 'fallback');
266
+ } else {
267
+ Mina.setActiveInstance(cfg.network);
268
+ setGraphqlEndpoint(cfg.graphqlUrl);
269
+ setArchiveGraphqlEndpoint(cfg.archiveUrl);
270
+ _activeEndpointByNetwork.set(cfg.networkId, 'primary');
271
+ }
272
+ }
273
+
274
+ // Active-instance juggling: o1js/Mina keeps a single global active network
275
+ // instance, so multi-network handlers must switch it before making any
276
+ // on-chain call. Wrap the region that needs a specific network in
277
+ // withNetwork(network, () => …). Handlers that don't touch chain state
278
+ // (local record reads, witness computation) don't need this.
279
+ async function withNetwork<T>(networkId: NetworkId, fn: () => Promise<T>): Promise<T> {
280
+ const cfg = NETWORK_CONFIG[networkId];
281
+ if (!cfg) throw new Error(`Network ${networkId} is not configured on this verifier`);
282
+ let release: () => void = () => {};
283
+ const prev = _networkMutexTail;
284
+ _networkMutexTail = new Promise<void>((r) => { release = r; });
285
+ await prev;
286
+ try {
287
+ const stickyFallback = _activeEndpointByNetwork.get(networkId) === 'fallback';
288
+ activateNetworkEndpoints(cfg, stickyFallback);
289
+ return await fn();
290
+ } catch (err) {
291
+ resetLeakedTransactionContext();
292
+ throw err;
293
+ } finally {
294
+ release();
295
+ }
296
+ }
297
+
298
+ // Per-attempt timeout for fetchLastBlock. Sized for the SLOW path: the
299
+ // Minascan public node (often used as fallback) frequently rate-limits and
300
+ // can take 60–120s to return during load spikes. Cutting the timeout shorter
301
+ // would surface as a spurious "fetchLastBlock failed" after primary is down
302
+ // — the exact situation this fallback exists to cover. Primary endpoints
303
+ // (minataur, self-hosted) that return quickly aren't penalized because a
304
+ // fast success short-circuits the timer.
305
+ const CURRENT_SLOT_TIMEOUT_MS = 180_000;
306
+
307
+ // 5xx and 408 (o1js's own timeout stand-in) rotate to the fallback. 404 means
308
+ // "account genuinely doesn't exist" and short-circuits — no point rotating.
309
+ // Anything else (4xx GraphQL error, 400 malformed request) also short-circuits
310
+ // because it's not endpoint-health related.
311
+ function shouldRotateOnError(statusCode: number | undefined): boolean {
312
+ if (statusCode === undefined) return true; // network throw with no status — treat as transport
313
+ if (statusCode === 408) return true;
314
+ if (statusCode >= 500 && statusCode <= 599) return true;
315
+ return false;
316
+ }
317
+
318
+ // Manual endpoint rotation for fetchAccount. Must be called from inside a
319
+ // withNetwork() region so the mutex prevents concurrent handlers from
320
+ // observing a mid-flight setActiveInstance swap. On success leaves the
321
+ // winning instance active so the next zkApp.<state>.get() cache lookup finds
322
+ // the freshly-fetched account under the same graphql-endpoint cache key.
323
+ async function fetchAccountWithRotation(
324
+ networkId: NetworkId,
325
+ publicKey: PublicKey,
326
+ attempts: number,
327
+ backoffMs: (attempt: number) => number = (a) => 400 * a,
328
+ ): Promise<Awaited<ReturnType<typeof fetchAccount>>> {
329
+ const cfg = NETWORK_CONFIG[networkId];
330
+ if (!cfg) throw new Error(`Network ${networkId} is not configured on this verifier`);
331
+ let last: Awaited<ReturnType<typeof fetchAccount>> = { account: undefined, error: { statusCode: 500, statusText: 'no attempt made' } as any };
332
+ let succeededOn: 'primary' | 'fallback' | null = null;
333
+ const trace: Array<{ url: string; outcome: string }> = [];
334
+ for (let attempt = 1; attempt <= attempts; attempt++) {
335
+ // Alternate primary / fallback across attempts when fallback exists:
336
+ // attempt 1 → primary, 2 → fallback, 3 → primary, …
337
+ const useFallback = cfg.fallbackNetwork !== null && attempt % 2 === 0;
338
+ const url = useFallback ? (cfg.graphqlFallback ?? cfg.graphqlUrl) : cfg.graphqlUrl;
339
+ activateNetworkEndpoints(cfg, useFallback);
340
+ last = await fetchAccount({ publicKey });
341
+ if (last.account) {
342
+ succeededOn = useFallback ? 'fallback' : 'primary';
343
+ trace.push({ url, outcome: 'ok' });
344
+ break;
345
+ }
346
+ const status = last.error?.statusCode;
347
+ const outcome = status === 404 ? '404' : `HTTP ${status ?? '?'} ${last.error?.statusText ?? ''}`.trim();
348
+ trace.push({ url, outcome });
349
+ if (status === 404) break; // real not-found
350
+ if (!shouldRotateOnError(status)) break; // non-transport error
351
+ if (attempt < attempts) await new Promise((r) => setTimeout(r, backoffMs(attempt)));
352
+ }
353
+ if (!last.account && last.error?.statusCode !== 404) {
354
+ logUtc('fetchAccountWithRotation', '✗ all attempts failed', {
355
+ network: networkId,
356
+ publicKey: publicKey.toBase58(),
357
+ attempts,
358
+ trace,
359
+ });
360
+ }
361
+ if (succeededOn === null) {
362
+ activateNetworkEndpoints(cfg, false);
363
+ }
364
+ return last;
365
+ }
366
+
367
+ // Same rotation semantics as fetchAccountWithRotation but for fetchLastBlock.
368
+ // Wraps the o1js call in the CURRENT_SLOT_TIMEOUT_MS timeout race so a dead
369
+ // endpoint doesn't hang the whole attempt. Must be called from inside a
370
+ // withNetwork() region. Returns null on terminal failure so callers can decide
371
+ // (throw, retry higher up, or degrade gracefully — currentSlot returns 0).
372
+ async function fetchLastBlockWithRotation(
373
+ networkId: NetworkId,
374
+ attempts: number,
375
+ backoffMs: (attempt: number) => number = (a) => 400 * a,
376
+ ): Promise<Awaited<ReturnType<typeof fetchLastBlock>> | null> {
377
+ const cfg = NETWORK_CONFIG[networkId];
378
+ if (!cfg) throw new Error(`Network ${networkId} is not configured on this verifier`);
379
+ let succeededOn: 'primary' | 'fallback' | null = null;
380
+ const trace: Array<{ url: string; outcome: string }> = [];
381
+ for (let attempt = 1; attempt <= attempts; attempt++) {
382
+ const useFallback = cfg.fallbackNetwork !== null && attempt % 2 === 0;
383
+ const url = useFallback ? (cfg.graphqlFallback ?? cfg.graphqlUrl) : cfg.graphqlUrl;
384
+ activateNetworkEndpoints(cfg, useFallback);
385
+ try {
386
+ const block = await Promise.race([
387
+ fetchLastBlock(),
388
+ new Promise<never>((_, reject) =>
389
+ setTimeout(() => reject(new Error(`fetchLastBlock timed out after ${CURRENT_SLOT_TIMEOUT_MS}ms`)),
390
+ CURRENT_SLOT_TIMEOUT_MS),
391
+ ),
392
+ ]);
393
+ succeededOn = useFallback ? 'fallback' : 'primary';
394
+ trace.push({ url, outcome: 'ok' });
395
+ return block;
396
+ } catch (err: any) {
397
+ trace.push({ url, outcome: err?.message ?? String(err) });
398
+ if (attempt < attempts) await new Promise((r) => setTimeout(r, backoffMs(attempt)));
399
+ }
400
+ }
401
+ if (succeededOn === null) {
402
+ activateNetworkEndpoints(cfg, false);
403
+ }
404
+ logUtc('fetchLastBlockWithRotation', '✗ all attempts failed', {
405
+ network: networkId,
406
+ attempts,
407
+ trace,
408
+ });
409
+ return null;
410
+ }
411
+
412
+ // UTC-stamped structured log. Arrays/objects in `extra` are JSON-stringified
413
+ // so per-attempt traces don't render as [object Object].
414
+ function logUtc(scope: string, msg: string, extra?: Record<string, unknown>): void {
415
+ const ts = new Date().toISOString();
416
+ const tail = extra
417
+ ? ' ' + Object.entries(extra).map(([k, v]) => {
418
+ const s = (v !== null && typeof v === 'object') ? JSON.stringify(v) : `${v}`;
419
+ return `${k}=${s}`;
420
+ }).join(' ')
421
+ : '';
422
+ console.log(`[${ts}] [${scope}] ${msg}${tail}`);
423
+ }
424
+
425
+ // Default the active instance to whichever network is configured (mainnet
426
+ // wins if both are up, so idle background code without explicit context lands
427
+ // on the production network). withNetwork() overrides this per-request.
428
+ activateNetworkEndpoints((NETWORK_CONFIG.mainnet ?? NETWORK_CONFIG.devnet!), false);
429
+
430
+
431
+ // ------------------------------------------------------------
432
+ // HELPERS
433
+ // ------------------------------------------------------------
434
+
435
+ // Fetch the current slot on the specified network. Rotation-aware. Callers
436
+ // that need the value in a broader on-chain region should pass the network
437
+ // they already resolved from the zkAppAddress.
438
+ async function currentSlot(network: NetworkId): Promise<number> {
439
+ try {
440
+ const block = await withNetwork(network, () => fetchLastBlockWithRotation(network, 3));
441
+ if (!block) return 0;
442
+ return Number(block.globalSlotSinceGenesis.toString());
443
+ } catch (e: any) {
444
+ logUtc('currentSlot', '✗ fetchLastBlock failed', { network, reason: e?.message ?? String(e) });
445
+ return 0;
446
+ }
447
+ }
448
+
449
+ // Return an error message if `zkAppAddress` isn't a well-formed B62q address,
450
+ // null otherwise. Called at request-parse time so garbage input fails with a
451
+ // legible 400 in a couple of ms instead of ~1 s of proof-verify CPU followed
452
+ // by a generic 502 from `fromBase58` throwing deep inside checkOnChainLicense.
453
+ function validateZkAppAddress(zkAppAddress: string): string | null {
454
+ try {
455
+ PublicKey.fromBase58(zkAppAddress);
456
+ return null;
457
+ } catch (err: any) {
458
+ return `Invalid zkAppAddress: ${err?.message || 'not a valid Mina B62q address'}`;
459
+ }
460
+ }
461
+
462
+ // Resolve a zkAppAddress to its owning AppRecord + network + deployment
463
+ // slice. Walks both networks' current-live address and every entry in
464
+ // deploymentHistory. Under the backend-routing design a buyer who pinned an
465
+ // original gen-1 address stays valid forever even after N generation bumps.
466
+ function resolveAppAndNetwork(
467
+ apps: AppRecord[],
468
+ zkAppAddress: string,
469
+ ): { app: AppRecord; network: NetworkId; deployment: DeploymentInfo } | null {
470
+ for (const app of apps) {
471
+ for (const net of NETWORKS) {
472
+ const dep = app.deployments[net];
473
+ if (!dep) continue;
474
+ if (dep.zkAppAddress === zkAppAddress ||
475
+ (dep.deploymentHistory ?? []).some(h => h.zkAppAddress === zkAppAddress)) {
476
+ return { app, network: net, deployment: dep };
477
+ }
478
+ }
479
+ }
480
+ return null;
481
+ }
482
+
483
+ // Tagged verdict from checkOnChainLicense. Kept as a discriminated union
484
+ // (rather than a boolean + error string) so the caller — GET / — can
485
+ // pick its own response envelope per failure kind without leaking HTTP
486
+ // concerns into the helper.
487
+ type OnChainLicenseCheck =
488
+ | { kind: 'zkapp-not-found' }
489
+ | { kind: 'unregistered-address' }
490
+ | { kind: 'vk-mismatch'; generation: number }
491
+ | {
492
+ kind: 'checked';
493
+ onChainRoot: string;
494
+ currentSlot: number;
495
+ zkAppVersion: number;
496
+ generation: number;
497
+ resolvedAddress: string;
498
+ liveRecords: LicenseRecord[];
499
+ result: VerifyResponse;
500
+ };
501
+
502
+ // Fetch fresh on-chain state and delegate address routing + ancestor walk
503
+ // to verifyLicenseWithAncestors. Resolves the target network from the
504
+ // zkAppAddress; unknown addresses (not in any configured network's registry)
505
+ // surface as `unregistered-address`. Throws only on network / RPC failure.
506
+ //
507
+ // The `generation` field echoes the *request* address's own stable
508
+ // generation, so a client that pinned an older address at build time sees
509
+ // a token whose `g` still matches its local expectation across bumps.
510
+ async function checkOnChainLicense(
511
+ zkAppAddress: string,
512
+ licenseHash: string,
513
+ ): Promise<OnChainLicenseCheck> {
514
+ const apps = await readApps();
515
+ const resolved = resolveAppAndNetwork(apps, zkAppAddress);
516
+ if (!resolved) return { kind: 'unregistered-address' };
517
+ const { network, deployment } = resolved;
518
+ if (!NETWORK_CONFIG[network]) return { kind: 'unregistered-address' };
519
+
520
+ const historyLen = deployment.deploymentHistory?.length ?? 0;
521
+ const currentGeneration = historyLen + 1;
522
+ const currentLive = deployment.zkAppAddress;
523
+
524
+ // Echo: the request address's own stable generation. Stays consistent
525
+ // across bumps for any client that pinned an older address.
526
+ let requestGeneration = currentGeneration;
527
+ if (zkAppAddress !== currentLive) {
528
+ const idx = (deployment.deploymentHistory ?? []).findIndex((d) => d.zkAppAddress === zkAppAddress);
529
+ if (idx >= 0) requestGeneration = idx + 1;
530
+ }
531
+
532
+ // Sequential (was Promise.all): the rotation helpers do Mina.setActiveInstance
533
+ // internally, so running fetchAccount + fetchLastBlock in parallel would race
534
+ // the instance swap. Same pattern as the backend sweep.
535
+ const records = await readRecords();
536
+ const { account } = await withNetwork(network, () =>
537
+ fetchAccountWithRotation(network, PublicKey.fromBase58(currentLive), 3));
538
+ const lastBlock = await withNetwork(network, () =>
539
+ fetchLastBlockWithRotation(network, 3));
540
+ if (!lastBlock) {
541
+ throw new Error('fetchLastBlock failed');
542
+ }
543
+
544
+ const currentRoot = account?.zkapp?.appState?.[0]?.toString();
545
+ if (!currentRoot) return { kind: 'zkapp-not-found' };
546
+ // `version` is state slot 5 (licensesRoot, vendor ×2, escrowRoot,
547
+ // pricesPacked, version, …).
548
+ const zkAppVersion = Number(account?.zkapp?.appState?.[5]?.toString() ?? '0');
549
+ // The live contract must run one of the published circuits. Pinned by
550
+ // circuit, not by this app's redeploy count: a redeploy doesn't imply a
551
+ // new circuit (and an app deployed after a circuit bump starts at app
552
+ // generation 1 on the newer circuit). Fail closed when the VK is missing
553
+ // or unknown — previously a redeployed app had no table entry for its
554
+ // generation and the check was skipped entirely.
555
+ const onChainVk = account?.zkapp?.verificationKey?.hash?.toString();
556
+ if (!onChainVk || !Object.values(CANONICAL_VK_HASHES).includes(onChainVk)) {
557
+ return { kind: 'vk-mismatch', generation: currentGeneration };
558
+ }
559
+
560
+ const currentSlotNum = Number(lastBlock.globalSlotSinceGenesis.toString());
561
+ // verifyLicenseWithAncestors uses bare fetchAccount internally on retired
562
+ // addresses — wrap it in withNetwork so the active instance points at the
563
+ // correct network for the whole walk.
564
+ const walked = await withNetwork(network, () => verifyLicenseWithAncestors({
565
+ deployment,
566
+ licenseHash,
567
+ currentSlot: currentSlotNum,
568
+ currentLiveRoot: currentRoot,
569
+ currentLiveForkSlot: Number(account?.zkapp?.appState?.[LICENSING_APP_STATE.forkActivationSlot]?.toString() ?? '0'),
570
+ records,
571
+ }));
572
+ return {
573
+ kind: 'checked',
574
+ onChainRoot: walked.onChainRoot,
575
+ currentSlot: currentSlotNum,
576
+ zkAppVersion,
577
+ generation: requestGeneration,
578
+ resolvedAddress: walked.resolvedAddress,
579
+ liveRecords: walked.liveRecords,
580
+ result: walked.result,
581
+ };
582
+ }
583
+
584
+ // ------------------------------------------------------------
585
+ // APPS REGISTRY
586
+ // ------------------------------------------------------------
587
+
588
+ type Deployment = {
589
+ zkAppAddress: string;
590
+ txHash?: string;
591
+ verificationKeyHash?: string;
592
+ version?: number;
593
+ retiredAt?: string;
594
+ };
595
+
596
+ // Per-network on-chain slot on an AppRecord. Mirrors the backend-side
597
+ // DeploymentInfo but only carries the fields verifyService needs (address
598
+ // + retirement history). Each network keeps its own history — a mainnet
599
+ // bump doesn't touch the devnet slot and vice-versa.
600
+ type DeploymentInfo = {
601
+ zkAppAddress: string;
602
+ txHash?: string;
603
+ verificationKeyHash?: string;
604
+ forkActivationSlot?: number;
605
+ deploymentHistory?: Deployment[];
606
+ };
607
+
608
+ type AppRecord = {
609
+ appId: string;
610
+ name: string;
611
+ description: string;
612
+ category: string;
613
+ website: string;
614
+ vendorAddress: string;
615
+ // Four-tier prices (MINA). 0 means the tier is not offered.
616
+ priceMonthlyMina: number;
617
+ priceYearlyMina: number;
618
+ priceFiveYearMina: number;
619
+ pricePerpetualMina: number;
620
+ createdAt: string;
621
+ // Per-network on-chain state — a single AppRecord can carry both devnet
622
+ // and mainnet deployment slots simultaneously. Requests route to the slot
623
+ // matching the request's zkAppAddress (see resolveAppAndNetwork).
624
+ deployments: {
625
+ devnet?: DeploymentInfo;
626
+ mainnet?: DeploymentInfo;
627
+ };
628
+ };
629
+
630
+ const APPS_FILE = 'apps.json';
631
+
632
+ async function readApps(): Promise<AppRecord[]> {
633
+ return readJsonOr<AppRecord[]>(APPS_FILE, []);
634
+ }
635
+
636
+ // ------------------------------------------------------------
637
+ // EXPRESS SERVER
638
+ // ------------------------------------------------------------
639
+
640
+ const app = express();
641
+ app.use(express.json());
642
+ app.use((_req, res, next) => {
643
+ res.setHeader('Access-Control-Allow-Origin', '*');
644
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
645
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS');
646
+ if (_req.method === 'OPTIONS') { res.status(204).end(); return; }
647
+ next();
648
+ });
649
+
650
+ // Per-request access log. One line per request: method + path + status + ms.
651
+ // Skips CORS preflights (already short-circuited above with 204). Path is the
652
+ // raw req.path — query strings are dropped so the licenseHash from `GET /`
653
+ // doesn't leak into logs (the redacted-prefix log on the / handler below is
654
+ // the canonical place to see which license was queried).
655
+ // Toggle off with VERIFY_ACCESS_LOG=off if the verifier ever runs behind a
656
+ // hot-path API where per-request lines become noise.
657
+ const ACCESS_LOG_ENABLED = (process.env.VERIFY_ACCESS_LOG ?? '').toLowerCase() !== 'off';
658
+ if (ACCESS_LOG_ENABLED) {
659
+ app.use((req, res, next) => {
660
+ const started = Date.now();
661
+ res.on('finish', () => {
662
+ logUtc('access', `${req.method} ${req.path}`, {
663
+ status: res.statusCode,
664
+ elapsed_ms: Date.now() - started,
665
+ });
666
+ });
667
+ next();
668
+ });
669
+ }
670
+
671
+ const VERIFY_ADMIN_TOKEN = process.env.VERIFY_ADMIN_TOKEN ?? '';
672
+
673
+ function requireAdmin(req: Request, res: Response, next: () => void): void {
674
+ if (!VERIFY_ADMIN_TOKEN) {
675
+ res.status(503).json({ error: 'Admin endpoint disabled (VERIFY_ADMIN_TOKEN env var not set)' });
676
+ return;
677
+ }
678
+ const auth = req.headers.authorization ?? '';
679
+ const match = auth.match(/^Bearer\s+(.+)$/);
680
+ if (!match || match[1] !== VERIFY_ADMIN_TOKEN) {
681
+ res.status(401).json({ error: 'Unauthorized' });
682
+ return;
683
+ }
684
+ next();
685
+ }
686
+
687
+ // Health.
688
+ //
689
+ // `networkId` per entry is the authoritative "which Mina chain" answer — a
690
+ // stable identifier the client can compare against exactly. `network` (the
691
+ // raw GraphQL URL) is kept for operator-facing observability only, since it
692
+ // can rotate providers (Minascan ↔ Minataur ↔ self-hosted) without the chain
693
+ // changing. `fallback` mirrors the same for the configured fallback endpoint.
694
+ app.get('/health', (_req: Request, res: Response) => {
695
+ const networks: Record<string, { networkId: NetworkId; network: string; fallback: string | null }> = {};
696
+ for (const net of NETWORKS) {
697
+ const cfg = NETWORK_CONFIG[net];
698
+ if (!cfg) continue;
699
+ networks[net] = {
700
+ networkId: cfg.networkId,
701
+ network: cfg.graphqlUrl,
702
+ fallback: cfg.graphqlFallback,
703
+ };
704
+ }
705
+ res.json({ status: 'ok', service: 'verify', networks });
706
+ });
707
+
708
+ // Current MerkleMap root for a specific zkApp. Network-agnostic — reads local
709
+ // records only.
710
+ app.get('/root', async (req: Request, res: Response) => {
711
+ const zkAppAddress = (req.query.zkApp as string | undefined) ?? '';
712
+ if (!zkAppAddress) {
713
+ return res.status(400).json({ error: 'Missing zkApp query param' });
714
+ }
715
+ const records = await readRecords();
716
+ const map = buildMap(records, zkAppAddress);
717
+ res.json({ root: map.getRoot().toString() });
718
+ });
719
+
720
+
721
+ // GET /?licenseHash=...&zkAppAddress=...
722
+ // Primary endpoint for Android, PWA, and Node SDK license verification.
723
+ // Describes the on-chain state of the requested license leaf — validity,
724
+ // expiry slot, grace-period flag, purchaseSlot. Callers that need to prove
725
+ // ownership of the license (e.g. before revealing app features) do so via
726
+ // the passphrase-derived Pallas Schnorr keypair off-band — this endpoint is
727
+ // state-only.
728
+ app.get('/', async (req: Request, res: Response) => {
729
+ const { licenseHash: licenseHashStr, zkAppAddress } = req.query as {
730
+ licenseHash?: string;
731
+ zkAppAddress?: string;
732
+ };
733
+
734
+ if (!licenseHashStr || !zkAppAddress) {
735
+ return res.status(400).json({ error: 'Missing required query params: licenseHash, zkAppAddress' });
736
+ }
737
+
738
+ // Per-request verdict log. Redacts licenseHash to a 12-char prefix so logs
739
+ // stay pseudonymous but retain enough entropy to correlate a user's report
740
+ // ("my activation says invalid at 03:52") to the exact decision without
741
+ // grepping full hashes. resolvedAddress lets you see when an older
742
+ // generation was the actual match (pre-bump buyer).
743
+ const startedAt = Date.now();
744
+ const hashShort = licenseHashStr.slice(0, 12) + '…';
745
+ const emitVerdict = (verdict: string, reason?: string | null, resolvedAddress?: string): void => {
746
+ logUtc('verify', 'verdict', {
747
+ licenseHash: hashShort,
748
+ zkAppAddress: zkAppAddress.slice(0, 12) + '…',
749
+ resolvedAddress: resolvedAddress ? resolvedAddress.slice(0, 12) + '…' : undefined,
750
+ verdict,
751
+ reason: reason ?? undefined,
752
+ elapsed_ms: Date.now() - startedAt,
753
+ });
754
+ };
755
+
756
+ // Defense-in-depth against impersonation: the helper resolves the address's
757
+ // generation via backend deploymentHistory and asserts its on-chain VK equals
758
+ // that generation's canonical hash. Two rejection modes it covers: (a) the
759
+ // address is not in any registered app's deployment history — stops a
760
+ // genuine old circuit redeployed at a fresh address; (b) the address is
761
+ // listed but its VK doesn't match — a loud index-corruption tripwire.
762
+ try {
763
+ const onChain = await checkOnChainLicense(zkAppAddress, licenseHashStr);
764
+ switch (onChain.kind) {
765
+ case 'zkapp-not-found':
766
+ emitVerdict('zkapp-not-found');
767
+ return res.status(404).json({ error: 'zkApp not found on chain' });
768
+ case 'unregistered-address':
769
+ emitVerdict('unregistered-address');
770
+ return res.json({
771
+ valid: false,
772
+ expiresAt: null,
773
+ expirySlot: 0,
774
+ inGracePeriod: false,
775
+ remainingDays: 0,
776
+ reason: 'zkAppAddress is not in any registered app deployment history',
777
+ });
778
+ case 'vk-mismatch':
779
+ emitVerdict('vk-mismatch', `generation=${onChain.generation}`);
780
+ return res.json({
781
+ valid: false,
782
+ expiresAt: null,
783
+ expirySlot: 0,
784
+ inGracePeriod: false,
785
+ remainingDays: 0,
786
+ reason: `zkApp verification key does not match generation ${onChain.generation}'s canonical LicensingApp circuit`,
787
+ });
788
+ case 'checked': {
789
+ // Key on resolvedAddress (the address the license was actually found
790
+ // under) rather than the request address — for pre-bump buyers who
791
+ // never migrated, these differ: request is the retired addr the
792
+ // client pinned, resolved is the retired addr the record lives on.
793
+ const record = onChain.liveRecords.find(r => r.zkAppAddress === onChain.resolvedAddress && r.licenseHash === licenseHashStr);
794
+ // Collapse the VerifyResponse shape into a single-word verdict for
795
+ // log scanning: valid | grace | invalid. `invalid` carries the
796
+ // core's own reason string ("License not found", "License state
797
+ // does not match on-chain root", …) so the log line is enough to
798
+ // triage without opening the JSON body.
799
+ const verdict = onChain.result.valid
800
+ ? (onChain.result.inGracePeriod ? 'grace' : 'valid')
801
+ : 'invalid';
802
+ emitVerdict(verdict, onChain.result.reason, onChain.resolvedAddress);
803
+ return res.json({
804
+ ...onChain.result,
805
+ currentSlot: onChain.currentSlot,
806
+ purchaseSlot: record?.purchaseSlot ?? 0,
807
+ zkAppVersion: onChain.zkAppVersion,
808
+ });
809
+ }
810
+ }
811
+ } catch (err: any) {
812
+ emitVerdict('error', err?.message ?? String(err));
813
+ console.error('[verify]', err?.message ?? err);
814
+ res.status(500).json({ error: err?.message ?? 'Verification failed' });
815
+ }
816
+ });
817
+
818
+ // License map witness — composite (zkAppAddress, licenseHash) key. Network-
819
+ // agnostic — witness comes from the local MerkleMap of records.
820
+ app.get('/witness/:zkAppAddress/:licenseHash', async (req: Request, res: Response) => {
821
+ const { zkAppAddress, licenseHash } = req.params as { zkAppAddress: string; licenseHash: string };
822
+ try {
823
+ const records = await readRecords();
824
+ const map = buildMap(records, zkAppAddress);
825
+ const key = Field(licenseHash);
826
+ const witness = map.getWitness(key);
827
+ res.json({
828
+ root: map.getRoot().toString(),
829
+ currentValue: map.get(key).toString(),
830
+ ...serializeWitness(witness),
831
+ });
832
+ } catch (err: any) {
833
+ res.status(400).json({ error: err.message });
834
+ }
835
+ });
836
+
837
+ // License status — composite (zkAppAddress, licenseHash) key.
838
+ // deviceCap + devices ride along so vendor SDKs can drive their bind flow
839
+ // (activate() reads these to decide whether to prompt for a bind and which
840
+ // slot to target). Sourced from the license record, which is the on-chain
841
+ // mirror populated by eventSyncLoop from DeviceBound/DeviceUnbound events.
842
+ app.get('/license/:zkAppAddress/:licenseHash', async (req: Request, res: Response) => {
843
+ const { zkAppAddress, licenseHash } = req.params as { zkAppAddress: string; licenseHash: string };
844
+ const record = await getLicense(zkAppAddress, licenseHash);
845
+ // Resolve the address's network so currentSlot hits the right chain. An
846
+ // unregistered address (or one whose network isn't configured here) falls
847
+ // back to slot 0 — status computation still returns a sensible verdict from
848
+ // the record's own expirySlot.
849
+ const apps = await readApps();
850
+ const resolved = resolveAppAndNetwork(apps, zkAppAddress);
851
+ const slot = resolved && NETWORK_CONFIG[resolved.network]
852
+ ? await currentSlot(resolved.network)
853
+ : 0;
854
+ const fork = resolved?.deployment.zkAppAddress === zkAppAddress ? resolved.deployment.forkActivationSlot ?? 0 : 0;
855
+ const status = licenseStatus(record, slot, fork);
856
+ res.json({
857
+ found: record !== null,
858
+ expirySlot: record?.expirySlot ?? 0,
859
+ status,
860
+ txHash: record?.txHash ?? null,
861
+ deviceCap: record?.deviceCap ?? 0,
862
+ devices: record?.devices ?? [],
863
+ });
864
+ });
865
+
866
+ // List registered apps
867
+ app.get('/apps', async (_req: Request, res: Response) => {
868
+ const apps = await readApps();
869
+ res.json({ apps });
870
+ });
871
+
872
+ // POST /bootstrap — admin
873
+ // Body: { zkAppAddress: string }
874
+ // Replays all canonical events from the archive node and rebuilds licenses.json.
875
+ // Gated because an open archive-replay endpoint is a DoS vector (each call hits
876
+ // the archive node and rewrites the local store).
877
+ app.post('/bootstrap', requireAdmin, async (req: Request, res: Response) => {
878
+ const { zkAppAddress } = req.body as { zkAppAddress?: string };
879
+ if (!zkAppAddress) {
880
+ return res.status(400).json({ error: 'Missing zkAppAddress' });
881
+ }
882
+ const addrErr = validateZkAppAddress(zkAppAddress);
883
+ if (addrErr) return res.status(400).json({ error: addrErr });
884
+ try {
885
+ const apps = await readApps();
886
+ const resolved = resolveAppAndNetwork(apps, zkAppAddress);
887
+ if (!resolved) {
888
+ return res.status(400).json({ error: `zkAppAddress not in any registered app deployment history` });
889
+ }
890
+ const { network } = resolved;
891
+ if (!NETWORK_CONFIG[network]) {
892
+ return res.status(400).json({ error: `zkAppAddress belongs to ${network} but that network is not configured on this verifier` });
893
+ }
894
+ // Refuse to replay a zkApp whose immutable source-version tag doesn't match
895
+ // one this verifier understands. Otherwise a v2-deployed contract's events
896
+ // could be decoded with a v1 schema and yield subtly wrong state.
897
+ const onChainVersion = await withNetwork(network, () => fetchLicensingAppVersion(zkAppAddress));
898
+ if (onChainVersion === null) {
899
+ return res.status(400).json({ error: `zkApp ${zkAppAddress.slice(0, 16)}… not visible on chain` });
900
+ }
901
+ if (!SUPPORTED_LICENSING_APP_VERSIONS.includes(onChainVersion)) {
902
+ return res.status(400).json({
903
+ error: `unsupported LicensingApp version — on-chain v${onChainVersion}, this verifier supports v${SUPPORTED_LICENSING_APP_VERSIONS.join(', v')}`,
904
+ });
905
+ }
906
+ const cfg = NETWORK_CONFIG[network]!;
907
+ console.log(`[bootstrap] Replaying events for ${zkAppAddress.slice(0, 16)}… (${network} v${onChainVersion}) from ${cfg.archiveUrl}`);
908
+ const { events } = await withNetwork(network, () => bootstrapFromArchive(zkAppAddress));
909
+ const { count, warnings } = await applyEventsToStore(zkAppAddress, events);
910
+ for (const w of warnings) console.warn('[bootstrap]', w);
911
+ console.log(`[bootstrap] Done — ${count} license records written`);
912
+ res.json({ ok: true, count, warnings, onChainVersion });
913
+ } catch (err: any) {
914
+ console.error('[bootstrap]', err?.message ?? err);
915
+ res.status(500).json({ error: err?.message ?? 'Bootstrap failed' });
916
+ }
917
+ });
918
+
919
+
920
+ // ------------------------------------------------------------
921
+ // START
922
+ // ------------------------------------------------------------
923
+
924
+ let records = await readRecords();
925
+
926
+ // Auto-bootstrap from archive if store is empty. Iterates every configured
927
+ // network and each of that network's registered apps. Apps whose network
928
+ // isn't configured on this verifier are skipped silently.
929
+ if (records.length === 0) {
930
+ const apps = await readApps();
931
+ const configuredNetworks = NETWORKS.filter(n => !!NETWORK_CONFIG[n]);
932
+ const bootstrapPlan: Array<{ network: NetworkId; app: AppRecord; dep: DeploymentInfo }> = [];
933
+ for (const network of configuredNetworks) {
934
+ for (const app of apps) {
935
+ const dep = app.deployments[network];
936
+ if (dep) bootstrapPlan.push({ network, app, dep });
937
+ }
938
+ }
939
+ if (bootstrapPlan.length > 0) {
940
+ console.log(`📦 licenses.json is empty — bootstrapping ${bootstrapPlan.length} app deployment(s) from archive node…`);
941
+ for (const { network, app: appRecord, dep } of bootstrapPlan) {
942
+ try {
943
+ // Same version guard as POST /bootstrap — refuse to replay any address
944
+ // whose on-chain source-version tag this verifier doesn't understand.
945
+ const onChainVersion = await withNetwork(network, () => fetchLicensingAppVersion(dep.zkAppAddress));
946
+ if (onChainVersion === null) {
947
+ console.warn(` ⚠️ ${appRecord.name} (${network}): zkApp not visible on chain — skipping`);
948
+ continue;
949
+ }
950
+ if (!SUPPORTED_LICENSING_APP_VERSIONS.includes(onChainVersion)) {
951
+ console.warn(` ⚠️ ${appRecord.name} (${network}): on-chain v${onChainVersion} not supported (supports v${SUPPORTED_LICENSING_APP_VERSIONS.join(', v')}) — skipping`);
952
+ continue;
953
+ }
954
+ const { events } = await withNetwork(network, () => bootstrapFromArchive(dep.zkAppAddress));
955
+ const { count, warnings } = await applyEventsToStore(dep.zkAppAddress, events);
956
+ for (const w of warnings) console.warn('⚠️', w);
957
+ console.log(` ✅ ${appRecord.name} (${network} v${onChainVersion}): ${count} records`);
958
+ } catch (err: any) {
959
+ console.warn(` ⚠️ ${appRecord.name} (${network}): bootstrap failed — ${err?.message ?? err}`);
960
+ }
961
+ }
962
+ records = await readRecords();
963
+ }
964
+ }
965
+
966
+ console.log(`📋 Loaded ${records.length} license records across ${new Set(records.map(r => r.zkAppAddress)).size} zkApp(s)`);
967
+
968
+ app.listen(PORT, () => {
969
+ console.log(`🔍 Verify service listening on http://localhost:${PORT}`);
970
+ console.log(` GET /?licenseHash=...&zkAppAddress=...`);
971
+ console.log(` GET /witness/:zkAppAddress/:licenseHash`);
972
+ console.log(` GET /license/:zkAppAddress/:licenseHash`);
973
+ console.log(` GET /apps`);
974
+ console.log(` POST /bootstrap`);
975
+ if (VERIFY_ADMIN_TOKEN) {
976
+ console.log(`🔒 Admin endpoints (bootstrap) enabled`);
977
+ } else {
978
+ console.log(`⚠️ Admin endpoints disabled (set VERIFY_ADMIN_TOKEN to enable)`);
979
+ }
980
+ console.log(`🔁 Lazy bootstrap on root mismatch (no periodic poller)`);
981
+ console.log(`📝 Access log: ${ACCESS_LOG_ENABLED ? 'ON' : 'off (VERIFY_ACCESS_LOG=off)'}`);
982
+ });