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
@@ -1,219 +1,425 @@
1
1
  #!/usr/bin/env node
2
2
  // Copyright (c) 2025-2026 zkLicensing project developers
3
- // All rights reserved.
4
- /**
5
- * verifyService.ts
6
- *
7
- * Lightweight, standalone license verification service.
8
- *
9
- * Exposes the same state endpoints as the keeper but contains NO ZK prover —
10
- * no circuit compilation, no o1js native backend. Starts in ~2 seconds and
11
- * runs on any small VPS or container.
12
- *
13
- * No write endpoints — chain events are the source of truth. License state
14
- * is rebuilt from the archive node in three ways:
15
- * - auto-bootstrap on startup when licenses.json is empty,
16
- * - lazy bootstrap inside GET / when the computed Merkle root
17
- * disagrees with the on-chain root for the requested app (the canonical
18
- * "we're stale" signal). One replay + one retry is enough — root is
19
- * consensus. This catches licenses issued, renewed, or refunded after
20
- * boot on the very next verify call, with zero work done when no one is
21
- * asking. Concurrent verifies for the same app share one in-flight
22
- * bootstrap so a popular license under load does not stampede the
23
- * archive node.
24
- * - POST /bootstrap (admin) for ad-hoc reasons: a newly registered zkApp
25
- * (verify won't trigger a sync for an app no client knows about yet), a
26
- * corrupted licenses.json, or repopulating the offline /license/...
27
- * debug endpoint without first hitting /verify.
28
- *
29
- * Deployment modes
30
- * ----------------
31
- * 1. Same-machine as the keeper
32
- * - Run from the keeper's working directory so apps.json and licenses.json
33
- * are shared transparently. Optionally set LICENSE_STORE_PATH to point at
34
- * a shared file.
35
- *
36
- * 2. Separate machine (replica)
37
- * - Copy the keeper's apps.json into the verifyService working directory.
38
- * apps.json tells the service which zkApp addresses to replay events for.
39
- * - Start the service. On boot it auto-bootstraps every app in apps.json
40
- * from the archive node. MINA_ARCHIVE_URL defaults to the Minascan public
41
- * archive matching MINA_NETWORK_ID:
42
- * mainnet → https://api.minascan.io/archive/mainnet/v1/graphql
43
- * else → https://api.minascan.io/archive/devnet/v1/graphql
44
- * Override by setting MINA_ARCHIVE_URL in the environment.
45
- * - To register a NEW app on the replica afterwards:
46
- * a) Re-copy apps.json from the keeper (it owns the registry), then
47
- * b) POST /bootstrap with the new zkAppAddress to ingest its events.
48
- * - Re-copying apps.json on a cron (or pulling it via GET /apps from the
49
- * keeper) is the recommended way to keep the registry in sync.
50
- *
51
- * Usage:
52
- * node build/src/verifyService.js
53
- *
54
- * Configuration is read from environment variables (see README §Configuration).
55
- * Required: MINA_GRAPHQL_URL, PLATFORM_ADDRESS.
56
- * Optional: MINA_NETWORK_ID (default testnet), MINA_ARCHIVE_URL (defaults by
57
- * network), VERIFY_PORT (default 8081), VERIFY_ADMIN_TOKEN.
58
- *
59
- * Endpoints:
60
- * GET /health
61
- * GET /?licenseHash=...&zkAppAddress=... ← primary endpoint for apps
62
- * GET /challenge?licenseHash=... ← ownership nonce
63
- * POST /respond ← ownership proof + token
64
- * POST /refresh ← extend token TTL (no re-prove)
65
- * POST /releaseSeat ← release this device's seat
66
- * POST /reset-sessions ← drop all sessions (re-prove)
67
- * GET /root?zkApp=...
68
- * GET /witness/:zkAppAddress/:licenseHash
69
- * GET /license/:zkAppAddress/:licenseHash
70
- * GET /apps
71
- * POST /bootstrap ← replay events from archive
72
- */
73
- import fs from 'fs/promises';
74
- import { mkdirSync } from 'fs';
75
- import { homedir } from 'os';
76
- import { join } from 'path';
3
+ // SPDX-License-Identifier: BUSL-1.1
77
4
  import express from 'express';
78
- import { Field, Mina, fetchLastBlock, fetchAccount, PublicKey, Cache, setBackend, verify, } from 'o1js';
5
+ import { Field, Mina, fetchLastBlock, fetchAccount, PublicKey, setBackend, setGraphqlEndpoint, setArchiveGraphqlEndpoint, } from 'o1js';
6
+ import { createRequire } from 'node:module';
7
+ import { pathToFileURL } from 'node:url';
8
+ import { dirname, resolve as pathResolve } from 'path';
79
9
  import { buildMap, getLicense, licenseStatus, readRecords, serializeWitness, } from './licenseStore.js';
80
- import { verifyLicenseCore } from './verifyCore.js';
81
- import { bootstrapFromArchive, applyEventsToStore, lazyBootstrap } from './archiveBootstrap.js';
82
- import { LicenseProof, LicenseProofProof } from './licenseProof.js';
83
- import { CANONICAL_VK_HASHES, CANONICAL_LICENSE_PROOF_VK_HASH, SUPPORTED_LICENSING_APP_VERSIONS, fetchLicensingAppVersion, } from './contractInterface.js';
84
- import { createChallengeStore, createSessionStore, newJti, signOwnershipToken, verifyOwnershipToken, newOwnershipKey, OWNERSHIP_TOKEN_TTL_MS, } from './ownership.js';
10
+ import { verifyLicenseWithAncestors } from './verifyWithAncestors.js';
11
+ import { bootstrapFromArchive, applyEventsToStore } from './archiveBootstrap.js';
12
+ import { CANONICAL_VK_HASHES, LICENSING_APP_STATE, SUPPORTED_LICENSING_APP_VERSIONS, fetchLicensingAppVersion, } from './contractInterface.js';
13
+ import { readJsonOr } from './atomicJson.js';
85
14
  setBackend('native');
86
- const CIRCUIT_CACHE_DIR = join(homedir(), '.cache', 'zklic-verifier');
87
- mkdirSync(CIRCUIT_CACHE_DIR, { recursive: true });
88
- // ------------------------------------------------------------
89
- // CONFIG (env vars) & NETWORK
90
- // ------------------------------------------------------------
91
- function requireEnv(name, hint) {
92
- const v = process.env[name];
93
- if (!v) {
94
- console.error(`❌ ${name} env var is required. ${hint}`);
95
- process.exit(1);
96
- }
97
- return v;
98
- }
99
- const MINA_NETWORK_ID = process.env.MINA_NETWORK_ID ?? 'devnet';
100
- const MINA_GRAPHQL_URL = requireEnv('MINA_GRAPHQL_URL', 'e.g. https://api.minascan.io/node/devnet/v1/graphql');
15
+ const NETWORKS = ['devnet', 'mainnet'];
16
+ const MINA_DEVNET_GRAPHQL_URL = process.env.MINA_DEVNET_GRAPHQL_URL;
17
+ const MINA_DEVNET_ARCHIVE_URL = process.env.MINA_DEVNET_ARCHIVE_URL;
18
+ const MINA_DEVNET_GRAPHQL_FALLBACK = process.env.MINA_DEVNET_GRAPHQL_FALLBACK;
19
+ const MINA_DEVNET_ARCHIVE_FALLBACK = process.env.MINA_DEVNET_ARCHIVE_FALLBACK;
20
+ const MINA_MAINNET_GRAPHQL_URL = process.env.MINA_MAINNET_GRAPHQL_URL;
21
+ const MINA_MAINNET_ARCHIVE_URL = process.env.MINA_MAINNET_ARCHIVE_URL;
22
+ const MINA_MAINNET_GRAPHQL_FALLBACK = process.env.MINA_MAINNET_GRAPHQL_FALLBACK;
23
+ const MINA_MAINNET_ARCHIVE_FALLBACK = process.env.MINA_MAINNET_ARCHIVE_FALLBACK;
101
24
  const PORT = parseInt(process.env.VERIFY_PORT ?? '8081', 10);
102
- function defaultArchiveUrl(networkId) {
103
- switch (networkId) {
104
- case 'mainnet':
105
- return 'https://api.minascan.io/archive/mainnet/v1/graphql';
106
- case 'devnet':
107
- return 'https://api.minascan.io/archive/devnet/v1/graphql';
108
- default:
109
- return null;
110
- }
25
+ // Default archive endpoint per network. Devnet uses o1Labs' Mesa archive
26
+ // (devnet-archive-node-api.gcp.o1test.net); mainnet falls back to Minascan's
27
+ // public archive. When the operator sets an explicit MINA_*_ARCHIVE_URL env
28
+ // var, that value wins.
29
+ const DEFAULT_ARCHIVE_URL = {
30
+ devnet: 'https://devnet-archive-node-api.gcp.o1test.net',
31
+ mainnet: 'https://api.minascan.io/archive/mainnet/v1/graphql',
32
+ };
33
+ // Build per-network Mina.Network instances. Any network the operator didn't
34
+ // configure (no RPC URL) stays out of the map — requests targeting it fall
35
+ // through to `unregistered-address`. At least one must be configured.
36
+ const NETWORK_CONFIG = {};
37
+ function buildNetworkConfig(networkId, graphqlUrl, archiveEnv, graphqlFallbackEnv, archiveFallbackEnv) {
38
+ if (!graphqlUrl)
39
+ return;
40
+ const archiveUrl = archiveEnv ?? DEFAULT_ARCHIVE_URL[networkId];
41
+ const trim = (s) => (s ? s.trim() : '');
42
+ const graphqlFallback = trim(graphqlFallbackEnv) && trim(graphqlFallbackEnv) !== graphqlUrl
43
+ ? trim(graphqlFallbackEnv) : null;
44
+ const archiveFallback = trim(archiveFallbackEnv) && trim(archiveFallbackEnv) !== archiveUrl
45
+ ? trim(archiveFallbackEnv) : null;
46
+ // See keeperService.ts for the full rationale: o1js's array-based fallback
47
+ // form (`mina: [primary, fallback]`) is unusable — Promise.race lets a fast
48
+ // 5xx from primary beat a slow 200 from fallback, and the winning-URL
49
+ // cache-key bug causes subsequent `.get()` calls to miss. Manual rotation
50
+ // via a second single-URL Mina.Network instance is the sole failover path.
51
+ const fallbackMinaArg = graphqlFallback ? graphqlFallback : null;
52
+ const fallbackArchiveArg = graphqlFallback ? (archiveFallback ?? archiveUrl) : null;
53
+ NETWORK_CONFIG[networkId] = {
54
+ networkId,
55
+ graphqlUrl,
56
+ archiveUrl,
57
+ graphqlFallback,
58
+ archiveFallback,
59
+ network: Mina.Network({ networkId: networkId, mina: graphqlUrl, archive: archiveUrl }),
60
+ fallbackNetwork: fallbackMinaArg
61
+ ? Mina.Network({ networkId: networkId, mina: fallbackMinaArg, archive: fallbackArchiveArg })
62
+ : null,
63
+ };
64
+ console.log(`🔗 ${networkId.padEnd(7)} Mina node: ${graphqlUrl}`);
65
+ if (graphqlFallback)
66
+ console.log(` ${networkId.padEnd(7)} fallback: ${graphqlFallback}`);
67
+ console.log(`📚 ${networkId.padEnd(7)} Archive node: ${archiveUrl}${archiveEnv ? ' (from env)' : ' (default)'}`);
68
+ if (archiveFallback)
69
+ console.log(` ${networkId.padEnd(7)} fallback: ${archiveFallback}`);
111
70
  }
112
- const archiveUrl = process.env.MINA_ARCHIVE_URL ?? defaultArchiveUrl(MINA_NETWORK_ID);
113
- if (!archiveUrl) {
114
- console.error(`❌ No archive URL for MINA_NETWORK_ID="${MINA_NETWORK_ID}" and no built-in default for this network.\n` +
115
- ` Set MINA_ARCHIVE_URL to the archive node GraphQL endpoint.`);
71
+ buildNetworkConfig('devnet', MINA_DEVNET_GRAPHQL_URL, MINA_DEVNET_ARCHIVE_URL, MINA_DEVNET_GRAPHQL_FALLBACK, MINA_DEVNET_ARCHIVE_FALLBACK);
72
+ buildNetworkConfig('mainnet', MINA_MAINNET_GRAPHQL_URL, MINA_MAINNET_ARCHIVE_URL, MINA_MAINNET_GRAPHQL_FALLBACK, MINA_MAINNET_ARCHIVE_FALLBACK);
73
+ if (Object.keys(NETWORK_CONFIG).length === 0) {
74
+ console.error('❌ No Mina network configured. Set at least one of MINA_DEVNET_GRAPHQL_URL / MINA_MAINNET_GRAPHQL_URL.');
116
75
  process.exit(1);
117
76
  }
118
- console.log(`🔗 Mina node: ${MINA_GRAPHQL_URL}`);
119
- console.log(`📚 Archive node: ${archiveUrl}${process.env.MINA_ARCHIVE_URL ? ' (from MINA_ARCHIVE_URL)' : ' (default — set MINA_ARCHIVE_URL to override)'}`);
120
- Mina.setActiveInstance(Mina.Network({
121
- networkId: MINA_NETWORK_ID,
122
- mina: MINA_GRAPHQL_URL,
123
- archive: archiveUrl,
124
- }));
125
- // ------------------------------------------------------------
126
- // CIRCUIT — LicenseProof (ownership challenge/response)
127
- // ------------------------------------------------------------
128
- let licenseProofVk = null;
129
- let licenseProofCompile = null;
130
- async function ensureCompiled() {
131
- if (licenseProofVk)
77
+ // Leaked-transaction-context reset scaffolding. The verifier has no
78
+ // Mina.transaction() calls today, so the leak defense is theoretical — but
79
+ // it's cheap (one dynamic import at boot) and future-proofs against a code
80
+ // path we haven't accounted for taking the process out until systemd
81
+ // restarts it. Same shape as keeperService.ts.
82
+ const _o1jsRequire = createRequire(import.meta.url);
83
+ let _o1jsCurrentTransaction = null;
84
+ try {
85
+ const o1jsMainPath = _o1jsRequire.resolve('o1js');
86
+ const contextUrl = pathToFileURL(pathResolve(dirname(o1jsMainPath), 'lib/mina/v1/transaction-context.js')).href;
87
+ const mod = await import(contextUrl);
88
+ _o1jsCurrentTransaction = mod.currentTransaction ?? null;
89
+ }
90
+ catch (e) {
91
+ console.warn('[withNetwork] o1js leak defense disabled — could not reach internal transaction-context:', e?.message ?? e);
92
+ }
93
+ function resetLeakedTransactionContext() {
94
+ if (!_o1jsCurrentTransaction)
132
95
  return;
133
- licenseProofCompile ??= (async () => {
134
- const cached = Cache.FileSystem(CIRCUIT_CACHE_DIR);
135
- const t = Date.now();
136
- console.log(`[verify-prover] Compiling LicenseProof (cache: ${CIRCUIT_CACHE_DIR})…`);
137
- const lpVk = await LicenseProof.compile({ cache: cached });
138
- const vk = lpVk.verificationKey;
139
- if (CANONICAL_LICENSE_PROOF_VK_HASH && vk.hash.toString() !== CANONICAL_LICENSE_PROOF_VK_HASH) {
140
- throw new Error(`LicenseProof VK hash ${vk.hash.toString()} does not match CANONICAL_LICENSE_PROOF_VK_HASH ${CANONICAL_LICENSE_PROOF_VK_HASH}`);
96
+ while (_o1jsCurrentTransaction.has()) {
97
+ try {
98
+ _o1jsCurrentTransaction.leave(_o1jsCurrentTransaction.id());
99
+ }
100
+ catch {
101
+ break;
141
102
  }
142
- licenseProofVk = vk;
143
- console.log(`[verify-prover] Ready in ${((Date.now() - t) / 1000).toFixed(1)}s — vk ${vk.hash.toString().slice(0, 16)}…`);
144
- })();
145
- await licenseProofCompile;
103
+ }
146
104
  }
147
- ensureCompiled().catch((e) => console.error('[verify-prover] Compile failed:', e));
148
- // ------------------------------------------------------------
149
- // OWNERSHIP — challenge + activation token (helpers live in ownership.ts)
150
- // ------------------------------------------------------------
151
- const ownershipChallenges = createChallengeStore();
152
- setInterval(() => { /* opportunistic GC */ ownershipChallenges.size(); }, 30_000).unref();
153
- // Per-license active-device session store. Backs the concurrent-device cap
154
- // (AppRecord.maxConcurrentDevices), /releaseSeat, and /reset-sessions. In-memory
155
- // by design — a keeper restart drops every session, forcing devices to
156
- // re-prove ownership. That is a security feature: persisted sessions would
157
- // let a stolen token bypass concurrency caps across restarts.
158
- const ownershipSessions = createSessionStore();
159
- setInterval(() => { /* opportunistic GC — activeCount touches prune */ ownershipSessions.size(); }, 30_000).unref();
160
- async function loadAppByAddress(zkAppAddress) {
161
- const apps = await readApps();
162
- return apps.find((a) => a.zkAppAddress === zkAppAddress) ?? null;
105
+ let _networkMutexTail = Promise.resolve();
106
+ // Which endpoint (primary vs fallback) currently succeeds for each network.
107
+ // Updated by the rotation helpers whenever a call succeeds on a specific
108
+ // endpoint. `withNetwork` restores from this map at region entry so that a
109
+ // successful fallback rotation in one region persists into the next region
110
+ // — otherwise a resetToPrimary here would stomp the fallback state and the
111
+ // following .get() cache lookup would miss on primary (the o1js account
112
+ // cache is keyed by graphql endpoint URL).
113
+ //
114
+ // Sticky-until-proven-broken semantics: we don't automatically retry primary
115
+ // on every region entry. If primary comes back up, the next rotation call
116
+ // will hit it first (attempt 1) and flip us back.
117
+ const _activeEndpointByNetwork = new Map();
118
+ // Swap the active Mina instance AND the o1js module-global fetch endpoint
119
+ // atomically. These are two independent globals inside o1js:
120
+ // - `activeInstance` (mina-instance.js) — what zkApp.<state>.get() closes
121
+ // over for its cache lookups
122
+ // - `networkConfig.minaEndpoint` (fetch.js) — the DEFAULT endpoint for
123
+ // bare `fetchAccount({publicKey})` / `fetchLastBlock()` calls
124
+ // `Mina.setActiveInstance` only touches the first. Because we build TWO
125
+ // Mina.Network() instances at startup (primary + fallback) and the second
126
+ // call's Fetch.setGraphqlEndpoint side-effect leaves `networkConfig.minaEndpoint`
127
+ // pinned to the LAST-constructed URL (the fallback), a plain
128
+ // setActiveInstance(cfg.network) leaves fetchAccount hitting the fallback URL
129
+ // while zkApp.<state>.get() looks up under the primary URL — cache miss →
130
+ // "Could not find account". We must always keep the two endpoints in sync,
131
+ // which this helper does.
132
+ function activateNetworkEndpoints(cfg, useFallback) {
133
+ if (useFallback && cfg.fallbackNetwork) {
134
+ Mina.setActiveInstance(cfg.fallbackNetwork);
135
+ setGraphqlEndpoint(cfg.graphqlFallback ?? cfg.graphqlUrl);
136
+ setArchiveGraphqlEndpoint(cfg.archiveFallback ?? cfg.archiveUrl);
137
+ _activeEndpointByNetwork.set(cfg.networkId, 'fallback');
138
+ }
139
+ else {
140
+ Mina.setActiveInstance(cfg.network);
141
+ setGraphqlEndpoint(cfg.graphqlUrl);
142
+ setArchiveGraphqlEndpoint(cfg.archiveUrl);
143
+ _activeEndpointByNetwork.set(cfg.networkId, 'primary');
144
+ }
163
145
  }
164
- // Resolve which generation a zkAppAddress claims to belong to by walking the
165
- // keeper's AppRecord.deploymentHistory[]. The top-level address is the current
166
- // generation (history.length + 1); older addresses live in history at
167
- // increasing index. Returns null when the address is not present in any
168
- // app's history — which is exactly what stops a "genuine v1 circuit
169
- // redeployed at a fresh address" impersonation: unlisted address → no
170
- // generation → verify rejects. See CANONICAL_VK_HASHES in contractInterface.ts.
171
- async function resolveGenerationByAddress(zkAppAddress) {
172
- const apps = await readApps();
173
- for (const a of apps) {
174
- const historyLen = a.deploymentHistory?.length ?? 0;
175
- if (a.zkAppAddress === zkAppAddress)
176
- return historyLen + 1;
177
- if (a.deploymentHistory) {
178
- for (let i = 0; i < a.deploymentHistory.length; i++) {
179
- if (a.deploymentHistory[i].zkAppAddress === zkAppAddress)
180
- return i + 1;
181
- }
146
+ // Active-instance juggling: o1js/Mina keeps a single global active network
147
+ // instance, so multi-network handlers must switch it before making any
148
+ // on-chain call. Wrap the region that needs a specific network in
149
+ // withNetwork(network, () => …). Handlers that don't touch chain state
150
+ // (local record reads, witness computation) don't need this.
151
+ async function withNetwork(networkId, fn) {
152
+ const cfg = NETWORK_CONFIG[networkId];
153
+ if (!cfg)
154
+ throw new Error(`Network ${networkId} is not configured on this verifier`);
155
+ let release = () => { };
156
+ const prev = _networkMutexTail;
157
+ _networkMutexTail = new Promise((r) => { release = r; });
158
+ await prev;
159
+ try {
160
+ const stickyFallback = _activeEndpointByNetwork.get(networkId) === 'fallback';
161
+ activateNetworkEndpoints(cfg, stickyFallback);
162
+ return await fn();
163
+ }
164
+ catch (err) {
165
+ resetLeakedTransactionContext();
166
+ throw err;
167
+ }
168
+ finally {
169
+ release();
170
+ }
171
+ }
172
+ // Per-attempt timeout for fetchLastBlock. Sized for the SLOW path: the
173
+ // Minascan public node (often used as fallback) frequently rate-limits and
174
+ // can take 60–120s to return during load spikes. Cutting the timeout shorter
175
+ // would surface as a spurious "fetchLastBlock failed" after primary is down
176
+ // — the exact situation this fallback exists to cover. Primary endpoints
177
+ // (minataur, self-hosted) that return quickly aren't penalized because a
178
+ // fast success short-circuits the timer.
179
+ const CURRENT_SLOT_TIMEOUT_MS = 180_000;
180
+ // 5xx and 408 (o1js's own timeout stand-in) rotate to the fallback. 404 means
181
+ // "account genuinely doesn't exist" and short-circuits — no point rotating.
182
+ // Anything else (4xx GraphQL error, 400 malformed request) also short-circuits
183
+ // because it's not endpoint-health related.
184
+ function shouldRotateOnError(statusCode) {
185
+ if (statusCode === undefined)
186
+ return true; // network throw with no status — treat as transport
187
+ if (statusCode === 408)
188
+ return true;
189
+ if (statusCode >= 500 && statusCode <= 599)
190
+ return true;
191
+ return false;
192
+ }
193
+ // Manual endpoint rotation for fetchAccount. Must be called from inside a
194
+ // withNetwork() region so the mutex prevents concurrent handlers from
195
+ // observing a mid-flight setActiveInstance swap. On success leaves the
196
+ // winning instance active so the next zkApp.<state>.get() cache lookup finds
197
+ // the freshly-fetched account under the same graphql-endpoint cache key.
198
+ async function fetchAccountWithRotation(networkId, publicKey, attempts, backoffMs = (a) => 400 * a) {
199
+ const cfg = NETWORK_CONFIG[networkId];
200
+ if (!cfg)
201
+ throw new Error(`Network ${networkId} is not configured on this verifier`);
202
+ let last = { account: undefined, error: { statusCode: 500, statusText: 'no attempt made' } };
203
+ let succeededOn = null;
204
+ const trace = [];
205
+ for (let attempt = 1; attempt <= attempts; attempt++) {
206
+ // Alternate primary / fallback across attempts when fallback exists:
207
+ // attempt 1 → primary, 2 → fallback, 3 → primary, …
208
+ const useFallback = cfg.fallbackNetwork !== null && attempt % 2 === 0;
209
+ const url = useFallback ? (cfg.graphqlFallback ?? cfg.graphqlUrl) : cfg.graphqlUrl;
210
+ activateNetworkEndpoints(cfg, useFallback);
211
+ last = await fetchAccount({ publicKey });
212
+ if (last.account) {
213
+ succeededOn = useFallback ? 'fallback' : 'primary';
214
+ trace.push({ url, outcome: 'ok' });
215
+ break;
182
216
  }
217
+ const status = last.error?.statusCode;
218
+ const outcome = status === 404 ? '404' : `HTTP ${status ?? '?'} ${last.error?.statusText ?? ''}`.trim();
219
+ trace.push({ url, outcome });
220
+ if (status === 404)
221
+ break; // real not-found
222
+ if (!shouldRotateOnError(status))
223
+ break; // non-transport error
224
+ if (attempt < attempts)
225
+ await new Promise((r) => setTimeout(r, backoffMs(attempt)));
226
+ }
227
+ if (!last.account && last.error?.statusCode !== 404) {
228
+ logUtc('fetchAccountWithRotation', '✗ all attempts failed', {
229
+ network: networkId,
230
+ publicKey: publicKey.toBase58(),
231
+ attempts,
232
+ trace,
233
+ });
183
234
  }
184
- return null;
235
+ if (succeededOn === null) {
236
+ activateNetworkEndpoints(cfg, false);
237
+ }
238
+ return last;
185
239
  }
186
- const OWNERSHIP_KEY_PATH = process.env.OWNERSHIP_KEY_PATH ?? join(CIRCUIT_CACHE_DIR, 'ownership-hmac.key');
187
- let ownershipKey;
188
- try {
189
- ownershipKey = await fs.readFile(OWNERSHIP_KEY_PATH);
190
- console.log(`🔑 Ownership HMAC key loaded from ${OWNERSHIP_KEY_PATH}`);
240
+ // Same rotation semantics as fetchAccountWithRotation but for fetchLastBlock.
241
+ // Wraps the o1js call in the CURRENT_SLOT_TIMEOUT_MS timeout race so a dead
242
+ // endpoint doesn't hang the whole attempt. Must be called from inside a
243
+ // withNetwork() region. Returns null on terminal failure so callers can decide
244
+ // (throw, retry higher up, or degrade gracefully — currentSlot returns 0).
245
+ async function fetchLastBlockWithRotation(networkId, attempts, backoffMs = (a) => 400 * a) {
246
+ const cfg = NETWORK_CONFIG[networkId];
247
+ if (!cfg)
248
+ throw new Error(`Network ${networkId} is not configured on this verifier`);
249
+ let succeededOn = null;
250
+ const trace = [];
251
+ for (let attempt = 1; attempt <= attempts; attempt++) {
252
+ const useFallback = cfg.fallbackNetwork !== null && attempt % 2 === 0;
253
+ const url = useFallback ? (cfg.graphqlFallback ?? cfg.graphqlUrl) : cfg.graphqlUrl;
254
+ activateNetworkEndpoints(cfg, useFallback);
255
+ try {
256
+ const block = await Promise.race([
257
+ fetchLastBlock(),
258
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`fetchLastBlock timed out after ${CURRENT_SLOT_TIMEOUT_MS}ms`)), CURRENT_SLOT_TIMEOUT_MS)),
259
+ ]);
260
+ succeededOn = useFallback ? 'fallback' : 'primary';
261
+ trace.push({ url, outcome: 'ok' });
262
+ return block;
263
+ }
264
+ catch (err) {
265
+ trace.push({ url, outcome: err?.message ?? String(err) });
266
+ if (attempt < attempts)
267
+ await new Promise((r) => setTimeout(r, backoffMs(attempt)));
268
+ }
269
+ }
270
+ if (succeededOn === null) {
271
+ activateNetworkEndpoints(cfg, false);
272
+ }
273
+ logUtc('fetchLastBlockWithRotation', '✗ all attempts failed', {
274
+ network: networkId,
275
+ attempts,
276
+ trace,
277
+ });
278
+ return null;
191
279
  }
192
- catch {
193
- ownershipKey = newOwnershipKey();
194
- await fs.writeFile(OWNERSHIP_KEY_PATH, ownershipKey, { mode: 0o600 });
195
- console.log(`🔑 Ownership HMAC key generated → ${OWNERSHIP_KEY_PATH}`);
280
+ // UTC-stamped structured log. Arrays/objects in `extra` are JSON-stringified
281
+ // so per-attempt traces don't render as [object Object].
282
+ function logUtc(scope, msg, extra) {
283
+ const ts = new Date().toISOString();
284
+ const tail = extra
285
+ ? ' ' + Object.entries(extra).map(([k, v]) => {
286
+ const s = (v !== null && typeof v === 'object') ? JSON.stringify(v) : `${v}`;
287
+ return `${k}=${s}`;
288
+ }).join(' ')
289
+ : '';
290
+ console.log(`[${ts}] [${scope}] ${msg}${tail}`);
196
291
  }
292
+ // Default the active instance to whichever network is configured (mainnet
293
+ // wins if both are up, so idle background code without explicit context lands
294
+ // on the production network). withNetwork() overrides this per-request.
295
+ activateNetworkEndpoints((NETWORK_CONFIG.mainnet ?? NETWORK_CONFIG.devnet), false);
197
296
  // ------------------------------------------------------------
198
297
  // HELPERS
199
298
  // ------------------------------------------------------------
200
- async function currentSlot() {
299
+ // Fetch the current slot on the specified network. Rotation-aware. Callers
300
+ // that need the value in a broader on-chain region should pass the network
301
+ // they already resolved from the zkAppAddress.
302
+ async function currentSlot(network) {
201
303
  try {
202
- const block = await fetchLastBlock();
304
+ const block = await withNetwork(network, () => fetchLastBlockWithRotation(network, 3));
305
+ if (!block)
306
+ return 0;
203
307
  return Number(block.globalSlotSinceGenesis.toString());
204
308
  }
205
- catch {
309
+ catch (e) {
310
+ logUtc('currentSlot', '✗ fetchLastBlock failed', { network, reason: e?.message ?? String(e) });
206
311
  return 0;
207
312
  }
208
313
  }
209
- const APPS_FILE = 'apps.json';
210
- async function readApps() {
314
+ // Return an error message if `zkAppAddress` isn't a well-formed B62q address,
315
+ // null otherwise. Called at request-parse time so garbage input fails with a
316
+ // legible 400 in a couple of ms instead of ~1 s of proof-verify CPU followed
317
+ // by a generic 502 from `fromBase58` throwing deep inside checkOnChainLicense.
318
+ function validateZkAppAddress(zkAppAddress) {
211
319
  try {
212
- return JSON.parse(await fs.readFile(APPS_FILE, 'utf8'));
320
+ PublicKey.fromBase58(zkAppAddress);
321
+ return null;
322
+ }
323
+ catch (err) {
324
+ return `Invalid zkAppAddress: ${err?.message || 'not a valid Mina B62q address'}`;
213
325
  }
214
- catch {
215
- return [];
326
+ }
327
+ // Resolve a zkAppAddress to its owning AppRecord + network + deployment
328
+ // slice. Walks both networks' current-live address and every entry in
329
+ // deploymentHistory. Under the backend-routing design a buyer who pinned an
330
+ // original gen-1 address stays valid forever even after N generation bumps.
331
+ function resolveAppAndNetwork(apps, zkAppAddress) {
332
+ for (const app of apps) {
333
+ for (const net of NETWORKS) {
334
+ const dep = app.deployments[net];
335
+ if (!dep)
336
+ continue;
337
+ if (dep.zkAppAddress === zkAppAddress ||
338
+ (dep.deploymentHistory ?? []).some(h => h.zkAppAddress === zkAppAddress)) {
339
+ return { app, network: net, deployment: dep };
340
+ }
341
+ }
216
342
  }
343
+ return null;
344
+ }
345
+ // Fetch fresh on-chain state and delegate address routing + ancestor walk
346
+ // to verifyLicenseWithAncestors. Resolves the target network from the
347
+ // zkAppAddress; unknown addresses (not in any configured network's registry)
348
+ // surface as `unregistered-address`. Throws only on network / RPC failure.
349
+ //
350
+ // The `generation` field echoes the *request* address's own stable
351
+ // generation, so a client that pinned an older address at build time sees
352
+ // a token whose `g` still matches its local expectation across bumps.
353
+ async function checkOnChainLicense(zkAppAddress, licenseHash) {
354
+ const apps = await readApps();
355
+ const resolved = resolveAppAndNetwork(apps, zkAppAddress);
356
+ if (!resolved)
357
+ return { kind: 'unregistered-address' };
358
+ const { network, deployment } = resolved;
359
+ if (!NETWORK_CONFIG[network])
360
+ return { kind: 'unregistered-address' };
361
+ const historyLen = deployment.deploymentHistory?.length ?? 0;
362
+ const currentGeneration = historyLen + 1;
363
+ const currentLive = deployment.zkAppAddress;
364
+ // Echo: the request address's own stable generation. Stays consistent
365
+ // across bumps for any client that pinned an older address.
366
+ let requestGeneration = currentGeneration;
367
+ if (zkAppAddress !== currentLive) {
368
+ const idx = (deployment.deploymentHistory ?? []).findIndex((d) => d.zkAppAddress === zkAppAddress);
369
+ if (idx >= 0)
370
+ requestGeneration = idx + 1;
371
+ }
372
+ // Sequential (was Promise.all): the rotation helpers do Mina.setActiveInstance
373
+ // internally, so running fetchAccount + fetchLastBlock in parallel would race
374
+ // the instance swap. Same pattern as the backend sweep.
375
+ const records = await readRecords();
376
+ const { account } = await withNetwork(network, () => fetchAccountWithRotation(network, PublicKey.fromBase58(currentLive), 3));
377
+ const lastBlock = await withNetwork(network, () => fetchLastBlockWithRotation(network, 3));
378
+ if (!lastBlock) {
379
+ throw new Error('fetchLastBlock failed');
380
+ }
381
+ const currentRoot = account?.zkapp?.appState?.[0]?.toString();
382
+ if (!currentRoot)
383
+ return { kind: 'zkapp-not-found' };
384
+ // `version` is state slot 5 (licensesRoot, vendor ×2, escrowRoot,
385
+ // pricesPacked, version, …).
386
+ const zkAppVersion = Number(account?.zkapp?.appState?.[5]?.toString() ?? '0');
387
+ // The live contract must run one of the published circuits. Pinned by
388
+ // circuit, not by this app's redeploy count: a redeploy doesn't imply a
389
+ // new circuit (and an app deployed after a circuit bump starts at app
390
+ // generation 1 on the newer circuit). Fail closed when the VK is missing
391
+ // or unknown — previously a redeployed app had no table entry for its
392
+ // generation and the check was skipped entirely.
393
+ const onChainVk = account?.zkapp?.verificationKey?.hash?.toString();
394
+ if (!onChainVk || !Object.values(CANONICAL_VK_HASHES).includes(onChainVk)) {
395
+ return { kind: 'vk-mismatch', generation: currentGeneration };
396
+ }
397
+ const currentSlotNum = Number(lastBlock.globalSlotSinceGenesis.toString());
398
+ // verifyLicenseWithAncestors uses bare fetchAccount internally on retired
399
+ // addresses — wrap it in withNetwork so the active instance points at the
400
+ // correct network for the whole walk.
401
+ const walked = await withNetwork(network, () => verifyLicenseWithAncestors({
402
+ deployment,
403
+ licenseHash,
404
+ currentSlot: currentSlotNum,
405
+ currentLiveRoot: currentRoot,
406
+ currentLiveForkSlot: Number(account?.zkapp?.appState?.[LICENSING_APP_STATE.forkActivationSlot]?.toString() ?? '0'),
407
+ records,
408
+ }));
409
+ return {
410
+ kind: 'checked',
411
+ onChainRoot: walked.onChainRoot,
412
+ currentSlot: currentSlotNum,
413
+ zkAppVersion,
414
+ generation: requestGeneration,
415
+ resolvedAddress: walked.resolvedAddress,
416
+ liveRecords: walked.liveRecords,
417
+ result: walked.result,
418
+ };
419
+ }
420
+ const APPS_FILE = 'apps.json';
421
+ async function readApps() {
422
+ return readJsonOr(APPS_FILE, []);
217
423
  }
218
424
  // ------------------------------------------------------------
219
425
  // EXPRESS SERVER
@@ -230,6 +436,26 @@ app.use((_req, res, next) => {
230
436
  }
231
437
  next();
232
438
  });
439
+ // Per-request access log. One line per request: method + path + status + ms.
440
+ // Skips CORS preflights (already short-circuited above with 204). Path is the
441
+ // raw req.path — query strings are dropped so the licenseHash from `GET /`
442
+ // doesn't leak into logs (the redacted-prefix log on the / handler below is
443
+ // the canonical place to see which license was queried).
444
+ // Toggle off with VERIFY_ACCESS_LOG=off if the verifier ever runs behind a
445
+ // hot-path API where per-request lines become noise.
446
+ const ACCESS_LOG_ENABLED = (process.env.VERIFY_ACCESS_LOG ?? '').toLowerCase() !== 'off';
447
+ if (ACCESS_LOG_ENABLED) {
448
+ app.use((req, res, next) => {
449
+ const started = Date.now();
450
+ res.on('finish', () => {
451
+ logUtc('access', `${req.method} ${req.path}`, {
452
+ status: res.statusCode,
453
+ elapsed_ms: Date.now() - started,
454
+ });
455
+ });
456
+ next();
457
+ });
458
+ }
233
459
  const VERIFY_ADMIN_TOKEN = process.env.VERIFY_ADMIN_TOKEN ?? '';
234
460
  function requireAdmin(req, res, next) {
235
461
  if (!VERIFY_ADMIN_TOKEN) {
@@ -244,11 +470,29 @@ function requireAdmin(req, res, next) {
244
470
  }
245
471
  next();
246
472
  }
247
- // Health
473
+ // Health.
474
+ //
475
+ // `networkId` per entry is the authoritative "which Mina chain" answer — a
476
+ // stable identifier the client can compare against exactly. `network` (the
477
+ // raw GraphQL URL) is kept for operator-facing observability only, since it
478
+ // can rotate providers (Minascan ↔ Minataur ↔ self-hosted) without the chain
479
+ // changing. `fallback` mirrors the same for the configured fallback endpoint.
248
480
  app.get('/health', (_req, res) => {
249
- res.json({ status: 'ok', service: 'verify', network: MINA_GRAPHQL_URL });
481
+ const networks = {};
482
+ for (const net of NETWORKS) {
483
+ const cfg = NETWORK_CONFIG[net];
484
+ if (!cfg)
485
+ continue;
486
+ networks[net] = {
487
+ networkId: cfg.networkId,
488
+ network: cfg.graphqlUrl,
489
+ fallback: cfg.graphqlFallback,
490
+ };
491
+ }
492
+ res.json({ status: 'ok', service: 'verify', networks });
250
493
  });
251
- // Current MerkleMap root for a specific zkApp.
494
+ // Current MerkleMap root for a specific zkApp. Network-agnostic — reads local
495
+ // records only.
252
496
  app.get('/root', async (req, res) => {
253
497
  const zkAppAddress = req.query.zkApp ?? '';
254
498
  if (!zkAppAddress) {
@@ -258,308 +502,99 @@ app.get('/root', async (req, res) => {
258
502
  const map = buildMap(records, zkAppAddress);
259
503
  res.json({ root: map.getRoot().toString() });
260
504
  });
261
- // GET /challenge?licenseHash=...
262
- // Issues a nonce for the ownership challenge-response flow.
263
- app.get('/challenge', (req, res) => {
264
- const licenseHash = String(req.query.licenseHash ?? '');
265
- if (!licenseHash) {
266
- return res.status(400).json({ error: 'Missing licenseHash' });
267
- }
268
- res.json(ownershipChallenges.issue(licenseHash));
269
- });
270
- // POST /respond — body: { zkAppAddress, proof }
271
- // Verifies a LicenseProof against the issued nonce, consumes the nonce,
272
- // enforces the vendor's concurrent-device cap (if any), and returns
273
- // { ownership: 'verified', token, expiresAt, jti, activeSessions, deviceLimit }
274
- // on success. On cap overflow returns 429 without minting a token — clients
275
- // must release a seat (POST /releaseSeat), reset all sessions (POST
276
- // /reset-sessions), or wait for the oldest to hit TTL.
277
- app.post('/respond', async (req, res) => {
278
- await ensureCompiled();
279
- if (!licenseProofVk) {
280
- return res.status(503).json({ error: 'Verify service not ready — circuits compiling' });
281
- }
282
- const { zkAppAddress, proof: proofJson } = req.body;
283
- if (!zkAppAddress || !proofJson) {
284
- return res.status(400).json({ error: 'Missing zkAppAddress or proof' });
285
- }
286
- let proof;
287
- try {
288
- proof = await LicenseProofProof.fromJSON(proofJson);
289
- }
290
- catch {
291
- return res.status(400).json({ error: 'Invalid proof format' });
292
- }
293
- let proofValid = false;
294
- try {
295
- proofValid = await verify(proof, licenseProofVk);
296
- }
297
- catch {
298
- return res.status(400).json({ error: 'Proof verification threw' });
299
- }
300
- if (!proofValid) {
301
- return res.status(400).json({ error: 'Proof is cryptographically invalid' });
302
- }
303
- const licenseHash = proof.publicInput.licenseHash.toString();
304
- const nonce = proof.publicInput.nonce.toString();
305
- const nonceState = ownershipChallenges.consume(licenseHash, nonce);
306
- if (nonceState !== 'ok') {
307
- return res.status(400).json({ error: `Nonce ${nonceState} — request a fresh /challenge` });
308
- }
309
- // Concurrent-device cap enforcement. Read the vendor's per-app limit
310
- // (0/undefined = unlimited) and try to reserve a session slot BEFORE
311
- // minting the HMAC — a token without a matching session-store entry is
312
- // dead on arrival at GET / anyway, so refusing here avoids handing the
313
- // caller a bearer that they can only use to be rejected.
314
- const app_record = await loadAppByAddress(zkAppAddress);
315
- const cap = Math.max(0, Math.floor(app_record?.maxConcurrentDevices ?? 0));
316
- const jti = newJti();
317
- const exp = Date.now() + OWNERSHIP_TOKEN_TTL_MS;
318
- const reserved = ownershipSessions.insert(licenseHash, jti, exp, cap);
319
- if (reserved.state === 'at-cap') {
320
- return res.status(429).json({
321
- error: 'Concurrent-device limit reached — release a seat, run POST /reset-sessions, or wait for the oldest session to expire',
322
- activeSessions: reserved.active,
323
- deviceLimit: cap,
324
- });
325
- }
326
- const token = signOwnershipToken(ownershipKey, { z: zkAppAddress, l: licenseHash, e: exp, jti });
327
- res.json({
328
- ownership: 'verified',
329
- token,
330
- expiresAt: exp,
331
- jti,
332
- activeSessions: reserved.active,
333
- deviceLimit: cap,
334
- });
335
- });
336
- // POST /releaseSeat — body: { token }
337
- // Releases the seat identified by the token's jti. Possession of the token
338
- // is authorization to release it (same threat model as any bearer token);
339
- // no proof-of-ownership is required. Enables an in-app "log out this device"
340
- // button. Idempotent — a second call with the same token silently no-ops.
341
- app.post('/releaseSeat', (req, res) => {
342
- const { token } = req.body;
343
- if (!token)
344
- return res.status(400).json({ error: 'Missing token' });
345
- const payload = verifyOwnershipToken(ownershipKey, token);
346
- if (!payload)
347
- return res.status(401).json({ error: 'Invalid or expired token' });
348
- if (!payload.jti) {
349
- // Pre-jti tokens aren't tracked in the session store, so there's no seat
350
- // to release. Return 200 for idempotency — the caller's intent (log this
351
- // device out) is satisfied by the local token delete.
352
- return res.json({ ok: true, released: false, reason: 'token has no jti (pre-jti issue)' });
353
- }
354
- const released = ownershipSessions.releaseSeat(payload.l, payload.jti);
355
- res.json({ ok: true, released, jti: payload.jti });
356
- });
357
- // POST /refresh — body: { token }
358
- // Extends the lifetime of the current session without a fresh ownership proof.
359
- // The caller sends the token they already hold; the server verifies the HMAC,
360
- // checks the jti is still active in the session store (i.e. not released or
361
- // evicted), and issues a new token with the same jti and a fresh exp. The
362
- // session store's insert is idempotent per jti, so no cap check is needed —
363
- // the slot was already reserved at /respond time.
364
- //
365
- // Intended for background renewal from a running app: the client polls this
366
- // endpoint before the token expires so a user who keeps the app open never
367
- // has to redo the passphrase-gated challenge/response. A stolen token can be
368
- // refreshed too, which is why /releaseSeat exists — losing a device means the
369
- // owner explicitly logs it out, and subsequent refreshes fail.
370
- //
371
- // Pre-jti tokens (legacy /respond without session tracking) can also refresh:
372
- // they're valid HMAC, they carry no jti, and there's no session-store row to
373
- // gate against. The reissued token is identically pre-jti. Callers that want
374
- // concurrency enforcement must reissue via /respond so a jti gets minted.
375
- app.post('/refresh', (req, res) => {
376
- const { token } = req.body;
377
- if (!token)
378
- return res.status(400).json({ error: 'Missing token' });
379
- const payload = verifyOwnershipToken(ownershipKey, token);
380
- if (!payload)
381
- return res.status(401).json({ error: 'Invalid or expired token' });
382
- const newExp = Date.now() + OWNERSHIP_TOKEN_TTL_MS;
383
- if (payload.jti) {
384
- if (!ownershipSessions.has(payload.l, payload.jti)) {
385
- return res.status(401).json({ error: 'Seat released' });
386
- }
387
- // Idempotent for existing jti — updates the expiry, never trips the cap.
388
- ownershipSessions.insert(payload.l, payload.jti, newExp, 0);
389
- }
390
- const next = {
391
- z: payload.z,
392
- l: payload.l,
393
- e: newExp,
394
- ...(payload.jti ? { jti: payload.jti } : {}),
395
- };
396
- const nextToken = signOwnershipToken(ownershipKey, next);
397
- res.json({ token: nextToken, expiresAt: newExp, jti: payload.jti });
398
- });
399
- // POST /reset-sessions — body: { zkAppAddress, proof }
400
- // Drops ALL active sessions for the licenseHash proved by the LicenseProof.
401
- // Requires the same challenge/response as /respond — only the passphrase
402
- // holder can invoke it. Intended for "reinstall unsticks a device that
403
- // burned a slot without logging out" recovery. After this returns, the
404
- // caller runs a fresh /respond to re-provision a session under a zero
405
- // active count.
406
- app.post('/reset-sessions', async (req, res) => {
407
- await ensureCompiled();
408
- if (!licenseProofVk) {
409
- return res.status(503).json({ error: 'Verify service not ready — circuits compiling' });
410
- }
411
- const { zkAppAddress, proof: proofJson } = req.body;
412
- if (!zkAppAddress || !proofJson) {
413
- return res.status(400).json({ error: 'Missing zkAppAddress or proof' });
414
- }
415
- let proof;
416
- try {
417
- proof = await LicenseProofProof.fromJSON(proofJson);
418
- }
419
- catch {
420
- return res.status(400).json({ error: 'Invalid proof format' });
421
- }
422
- let proofValid = false;
423
- try {
424
- proofValid = await verify(proof, licenseProofVk);
425
- }
426
- catch {
427
- return res.status(400).json({ error: 'Proof verification threw' });
428
- }
429
- if (!proofValid) {
430
- return res.status(400).json({ error: 'Proof is cryptographically invalid' });
431
- }
432
- const licenseHash = proof.publicInput.licenseHash.toString();
433
- const nonce = proof.publicInput.nonce.toString();
434
- const nonceState = ownershipChallenges.consume(licenseHash, nonce);
435
- if (nonceState !== 'ok') {
436
- return res.status(400).json({ error: `Nonce ${nonceState} — request a fresh /challenge` });
437
- }
438
- const dropped = ownershipSessions.reset(licenseHash);
439
- res.json({ ok: true, dropped });
440
- });
441
- // GET /?licenseHash=...&zkAppAddress=...[&token=...]
442
- // Primary endpoint for Android, iOS, PWA, and Node SDK license verification.
443
- //
444
- // Ownership semantics:
445
- // - Without `token`, the response describes the state of the on-chain leaf
446
- // only — it does NOT prove the caller owns the license. This is fine for
447
- // status displays; gate access behind /respond or pass a fresh
448
- // activation `token` in the query string.
449
- // - With `token`, the service checks the HMAC and refuses if the token isn't
450
- // for this (zkAppAddress, licenseHash) pair.
505
+ // GET /?licenseHash=...&zkAppAddress=...
506
+ // Primary endpoint for Android, PWA, and Node SDK license verification.
507
+ // Describes the on-chain state of the requested license leaf — validity,
508
+ // expiry slot, grace-period flag, purchaseSlot. Callers that need to prove
509
+ // ownership of the license (e.g. before revealing app features) do so via
510
+ // the passphrase-derived Pallas Schnorr keypair off-band — this endpoint is
511
+ // state-only.
451
512
  app.get('/', async (req, res) => {
452
- const { licenseHash: licenseHashStr, zkAppAddress, token } = req.query;
513
+ const { licenseHash: licenseHashStr, zkAppAddress } = req.query;
453
514
  if (!licenseHashStr || !zkAppAddress) {
454
515
  return res.status(400).json({ error: 'Missing required query params: licenseHash, zkAppAddress' });
455
516
  }
456
- let ownershipVerified = false;
457
- if (token) {
458
- const payload = verifyOwnershipToken(ownershipKey, token);
459
- if (!payload || payload.z !== zkAppAddress || payload.l !== licenseHashStr) {
460
- return res.status(401).json({ error: 'Invalid or expired ownership token' });
461
- }
462
- // Session-store gate: a jti-bearing token must still be in the active-
463
- // session set for its license. A token whose jti was released (via
464
- // /releaseSeat, /reset-sessions, or evicted by TTL prune) is refused even if
465
- // it's still HMAC-valid and inside its exp. Pre-jti tokens (issued
466
- // before the session-tracking rollout) skip this check and remain
467
- // usable until their exp — no forced logout across the migration.
468
- if (payload.jti && !ownershipSessions.has(payload.l, payload.jti)) {
469
- return res.status(401).json({ error: 'Seat released — call POST /respond to acquire a fresh token' });
470
- }
471
- ownershipVerified = true;
472
- }
473
- try {
474
- const [records, { account }, lastBlock] = await Promise.all([
475
- readRecords(),
476
- fetchAccount({ publicKey: PublicKey.fromBase58(zkAppAddress) }),
477
- fetchLastBlock(),
478
- ]);
479
- const onChainRoot = account?.zkapp?.appState?.[0]?.toString();
480
- if (!onChainRoot) {
481
- return res.status(404).json({ error: 'zkApp not found on chain' });
482
- }
483
- // Source-version tag from the already-fetched account state — free byte
484
- // for the SDK / dashboard to label which protocol revision a license lives
485
- // on. Not a security gate (that's the boot + bootstrap check); purely
486
- // informational.
487
- const zkAppVersion = Number(account?.zkapp?.appState?.[7]?.toString() ?? '0');
488
- // Defense in depth against impersonation. The contract locks
489
- // `setVerificationKey` via `Permissions.VerificationKey
490
- // .impossibleDuringCurrentVersion()` plus deploy-key disposal, so VK
491
- // rotation post-deploy is impossible for any *listed* address. The check
492
- // below resolves the address's generation via keeper deploymentHistory
493
- // and asserts its on-chain VK equals that generation's canonical hash.
494
- // Two failure modes: (a) the address is unknown (not the current live
495
- // deployment of any registered app, not in any deploymentHistory[]) —
496
- // reject; that stops a genuine old circuit deployed at a fresh address.
497
- // (b) the address is listed but its VK doesn't equal its generation's
498
- // hash — reject; that's a loud index-corruption tripwire.
499
- const onChainVk = account?.zkapp?.verificationKey?.hash?.toString();
500
- const generation = await resolveGenerationByAddress(zkAppAddress);
501
- if (generation === null) {
502
- return res.json({
503
- valid: false,
504
- expiresAt: null,
505
- expirySlot: 0,
506
- inGracePeriod: false,
507
- remainingDays: 0,
508
- reason: 'zkAppAddress is not in any registered app deployment history',
509
- ownership: ownershipVerified ? 'verified' : 'unverified',
510
- });
511
- }
512
- const expectedVk = CANONICAL_VK_HASHES[generation];
513
- if (expectedVk && onChainVk !== expectedVk) {
514
- return res.json({
515
- valid: false,
516
- expiresAt: null,
517
- expirySlot: 0,
518
- inGracePeriod: false,
519
- remainingDays: 0,
520
- reason: `zkApp verification key does not match generation ${generation}'s canonical LicensingApp circuit`,
521
- ownership: ownershipVerified ? 'verified' : 'unverified',
522
- });
523
- }
524
- const currentSlot = Number(lastBlock.globalSlotSinceGenesis.toString());
525
- let liveRecords = records;
526
- let result = verifyLicenseCore({
527
- zkAppAddress,
528
- licenseHash: licenseHashStr,
529
- records: liveRecords,
530
- onChainRoot,
531
- currentSlot,
517
+ // Per-request verdict log. Redacts licenseHash to a 12-char prefix so logs
518
+ // stay pseudonymous but retain enough entropy to correlate a user's report
519
+ // ("my activation says invalid at 03:52") to the exact decision without
520
+ // grepping full hashes. resolvedAddress lets you see when an older
521
+ // generation was the actual match (pre-bump buyer).
522
+ const startedAt = Date.now();
523
+ const hashShort = licenseHashStr.slice(0, 12) + '…';
524
+ const emitVerdict = (verdict, reason, resolvedAddress) => {
525
+ logUtc('verify', 'verdict', {
526
+ licenseHash: hashShort,
527
+ zkAppAddress: zkAppAddress.slice(0, 12) + '…',
528
+ resolvedAddress: resolvedAddress ? resolvedAddress.slice(0, 12) + '…' : undefined,
529
+ verdict,
530
+ reason: reason ?? undefined,
531
+ elapsed_ms: Date.now() - startedAt,
532
532
  });
533
- // Lazy self-heal: a root mismatch means the local store is stale (a
534
- // license has been bought, renewed, or refunded since we last replayed).
535
- // One replay + one retry is enough — the on-chain root is the source of
536
- // truth and the archive node will reflect it.
537
- if (!result.valid && result.reason === 'License state does not match on-chain root') {
538
- await lazyBootstrap(zkAppAddress);
539
- liveRecords = await readRecords();
540
- result = verifyLicenseCore({
541
- zkAppAddress,
542
- licenseHash: licenseHashStr,
543
- records: liveRecords,
544
- onChainRoot,
545
- currentSlot,
546
- });
533
+ };
534
+ // Defense-in-depth against impersonation: the helper resolves the address's
535
+ // generation via backend deploymentHistory and asserts its on-chain VK equals
536
+ // that generation's canonical hash. Two rejection modes it covers: (a) the
537
+ // address is not in any registered app's deployment history — stops a
538
+ // genuine old circuit redeployed at a fresh address; (b) the address is
539
+ // listed but its VK doesn't match — a loud index-corruption tripwire.
540
+ try {
541
+ const onChain = await checkOnChainLicense(zkAppAddress, licenseHashStr);
542
+ switch (onChain.kind) {
543
+ case 'zkapp-not-found':
544
+ emitVerdict('zkapp-not-found');
545
+ return res.status(404).json({ error: 'zkApp not found on chain' });
546
+ case 'unregistered-address':
547
+ emitVerdict('unregistered-address');
548
+ return res.json({
549
+ valid: false,
550
+ expiresAt: null,
551
+ expirySlot: 0,
552
+ inGracePeriod: false,
553
+ remainingDays: 0,
554
+ reason: 'zkAppAddress is not in any registered app deployment history',
555
+ });
556
+ case 'vk-mismatch':
557
+ emitVerdict('vk-mismatch', `generation=${onChain.generation}`);
558
+ return res.json({
559
+ valid: false,
560
+ expiresAt: null,
561
+ expirySlot: 0,
562
+ inGracePeriod: false,
563
+ remainingDays: 0,
564
+ reason: `zkApp verification key does not match generation ${onChain.generation}'s canonical LicensingApp circuit`,
565
+ });
566
+ case 'checked': {
567
+ // Key on resolvedAddress (the address the license was actually found
568
+ // under) rather than the request address — for pre-bump buyers who
569
+ // never migrated, these differ: request is the retired addr the
570
+ // client pinned, resolved is the retired addr the record lives on.
571
+ const record = onChain.liveRecords.find(r => r.zkAppAddress === onChain.resolvedAddress && r.licenseHash === licenseHashStr);
572
+ // Collapse the VerifyResponse shape into a single-word verdict for
573
+ // log scanning: valid | grace | invalid. `invalid` carries the
574
+ // core's own reason string ("License not found", "License state
575
+ // does not match on-chain root", …) so the log line is enough to
576
+ // triage without opening the JSON body.
577
+ const verdict = onChain.result.valid
578
+ ? (onChain.result.inGracePeriod ? 'grace' : 'valid')
579
+ : 'invalid';
580
+ emitVerdict(verdict, onChain.result.reason, onChain.resolvedAddress);
581
+ return res.json({
582
+ ...onChain.result,
583
+ currentSlot: onChain.currentSlot,
584
+ purchaseSlot: record?.purchaseSlot ?? 0,
585
+ zkAppVersion: onChain.zkAppVersion,
586
+ });
587
+ }
547
588
  }
548
- const record = liveRecords.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHashStr);
549
- return res.json({
550
- ...result,
551
- currentSlot,
552
- purchaseSlot: record?.purchaseSlot ?? 0,
553
- ownership: ownershipVerified ? 'verified' : 'unverified',
554
- zkAppVersion,
555
- });
556
589
  }
557
590
  catch (err) {
591
+ emitVerdict('error', err?.message ?? String(err));
558
592
  console.error('[verify]', err?.message ?? err);
559
593
  res.status(500).json({ error: err?.message ?? 'Verification failed' });
560
594
  }
561
595
  });
562
- // License map witness — composite (zkAppAddress, licenseHash) key.
596
+ // License map witness — composite (zkAppAddress, licenseHash) key. Network-
597
+ // agnostic — witness comes from the local MerkleMap of records.
563
598
  app.get('/witness/:zkAppAddress/:licenseHash', async (req, res) => {
564
599
  const { zkAppAddress, licenseHash } = req.params;
565
600
  try {
@@ -578,16 +613,31 @@ app.get('/witness/:zkAppAddress/:licenseHash', async (req, res) => {
578
613
  }
579
614
  });
580
615
  // License status — composite (zkAppAddress, licenseHash) key.
616
+ // deviceCap + devices ride along so vendor SDKs can drive their bind flow
617
+ // (activate() reads these to decide whether to prompt for a bind and which
618
+ // slot to target). Sourced from the license record, which is the on-chain
619
+ // mirror populated by eventSyncLoop from DeviceBound/DeviceUnbound events.
581
620
  app.get('/license/:zkAppAddress/:licenseHash', async (req, res) => {
582
621
  const { zkAppAddress, licenseHash } = req.params;
583
622
  const record = await getLicense(zkAppAddress, licenseHash);
584
- const slot = await currentSlot();
585
- const status = licenseStatus(record, slot);
623
+ // Resolve the address's network so currentSlot hits the right chain. An
624
+ // unregistered address (or one whose network isn't configured here) falls
625
+ // back to slot 0 — status computation still returns a sensible verdict from
626
+ // the record's own expirySlot.
627
+ const apps = await readApps();
628
+ const resolved = resolveAppAndNetwork(apps, zkAppAddress);
629
+ const slot = resolved && NETWORK_CONFIG[resolved.network]
630
+ ? await currentSlot(resolved.network)
631
+ : 0;
632
+ const fork = resolved?.deployment.zkAppAddress === zkAppAddress ? resolved.deployment.forkActivationSlot ?? 0 : 0;
633
+ const status = licenseStatus(record, slot, fork);
586
634
  res.json({
587
635
  found: record !== null,
588
636
  expirySlot: record?.expirySlot ?? 0,
589
637
  status,
590
638
  txHash: record?.txHash ?? null,
639
+ deviceCap: record?.deviceCap ?? 0,
640
+ devices: record?.devices ?? [],
591
641
  });
592
642
  });
593
643
  // List registered apps
@@ -605,11 +655,23 @@ app.post('/bootstrap', requireAdmin, async (req, res) => {
605
655
  if (!zkAppAddress) {
606
656
  return res.status(400).json({ error: 'Missing zkAppAddress' });
607
657
  }
658
+ const addrErr = validateZkAppAddress(zkAppAddress);
659
+ if (addrErr)
660
+ return res.status(400).json({ error: addrErr });
608
661
  try {
662
+ const apps = await readApps();
663
+ const resolved = resolveAppAndNetwork(apps, zkAppAddress);
664
+ if (!resolved) {
665
+ return res.status(400).json({ error: `zkAppAddress not in any registered app deployment history` });
666
+ }
667
+ const { network } = resolved;
668
+ if (!NETWORK_CONFIG[network]) {
669
+ return res.status(400).json({ error: `zkAppAddress belongs to ${network} but that network is not configured on this verifier` });
670
+ }
609
671
  // Refuse to replay a zkApp whose immutable source-version tag doesn't match
610
672
  // one this verifier understands. Otherwise a v2-deployed contract's events
611
673
  // could be decoded with a v1 schema and yield subtly wrong state.
612
- const onChainVersion = await fetchLicensingAppVersion(zkAppAddress);
674
+ const onChainVersion = await withNetwork(network, () => fetchLicensingAppVersion(zkAppAddress));
613
675
  if (onChainVersion === null) {
614
676
  return res.status(400).json({ error: `zkApp ${zkAppAddress.slice(0, 16)}… not visible on chain` });
615
677
  }
@@ -618,8 +680,9 @@ app.post('/bootstrap', requireAdmin, async (req, res) => {
618
680
  error: `unsupported LicensingApp version — on-chain v${onChainVersion}, this verifier supports v${SUPPORTED_LICENSING_APP_VERSIONS.join(', v')}`,
619
681
  });
620
682
  }
621
- console.log(`[bootstrap] Replaying events for ${zkAppAddress.slice(0, 16)}… (v${onChainVersion}) from ${archiveUrl}`);
622
- const { events } = await bootstrapFromArchive(zkAppAddress);
683
+ const cfg = NETWORK_CONFIG[network];
684
+ console.log(`[bootstrap] Replaying events for ${zkAppAddress.slice(0, 16)}… (${network} v${onChainVersion}) from ${cfg.archiveUrl}`);
685
+ const { events } = await withNetwork(network, () => bootstrapFromArchive(zkAppAddress));
623
686
  const { count, warnings } = await applyEventsToStore(zkAppAddress, events);
624
687
  for (const w of warnings)
625
688
  console.warn('[bootstrap]', w);
@@ -635,32 +698,43 @@ app.post('/bootstrap', requireAdmin, async (req, res) => {
635
698
  // START
636
699
  // ------------------------------------------------------------
637
700
  let records = await readRecords();
638
- // Auto-bootstrap from archive if store is empty.
701
+ // Auto-bootstrap from archive if store is empty. Iterates every configured
702
+ // network and each of that network's registered apps. Apps whose network
703
+ // isn't configured on this verifier are skipped silently.
639
704
  if (records.length === 0) {
640
705
  const apps = await readApps();
641
- if (apps.length > 0) {
642
- console.log(`📦 licenses.json is empty — bootstrapping ${apps.length} app(s) from archive node…`);
643
- for (const appRecord of apps) {
706
+ const configuredNetworks = NETWORKS.filter(n => !!NETWORK_CONFIG[n]);
707
+ const bootstrapPlan = [];
708
+ for (const network of configuredNetworks) {
709
+ for (const app of apps) {
710
+ const dep = app.deployments[network];
711
+ if (dep)
712
+ bootstrapPlan.push({ network, app, dep });
713
+ }
714
+ }
715
+ if (bootstrapPlan.length > 0) {
716
+ console.log(`📦 licenses.json is empty — bootstrapping ${bootstrapPlan.length} app deployment(s) from archive node…`);
717
+ for (const { network, app: appRecord, dep } of bootstrapPlan) {
644
718
  try {
645
719
  // Same version guard as POST /bootstrap — refuse to replay any address
646
720
  // whose on-chain source-version tag this verifier doesn't understand.
647
- const onChainVersion = await fetchLicensingAppVersion(appRecord.zkAppAddress);
721
+ const onChainVersion = await withNetwork(network, () => fetchLicensingAppVersion(dep.zkAppAddress));
648
722
  if (onChainVersion === null) {
649
- console.warn(` ⚠️ ${appRecord.name}: zkApp not visible on chain — skipping`);
723
+ console.warn(` ⚠️ ${appRecord.name} (${network}): zkApp not visible on chain — skipping`);
650
724
  continue;
651
725
  }
652
726
  if (!SUPPORTED_LICENSING_APP_VERSIONS.includes(onChainVersion)) {
653
- console.warn(` ⚠️ ${appRecord.name}: on-chain v${onChainVersion} not supported (supports v${SUPPORTED_LICENSING_APP_VERSIONS.join(', v')}) — skipping`);
727
+ console.warn(` ⚠️ ${appRecord.name} (${network}): on-chain v${onChainVersion} not supported (supports v${SUPPORTED_LICENSING_APP_VERSIONS.join(', v')}) — skipping`);
654
728
  continue;
655
729
  }
656
- const { events } = await bootstrapFromArchive(appRecord.zkAppAddress);
657
- const { count, warnings } = await applyEventsToStore(appRecord.zkAppAddress, events);
730
+ const { events } = await withNetwork(network, () => bootstrapFromArchive(dep.zkAppAddress));
731
+ const { count, warnings } = await applyEventsToStore(dep.zkAppAddress, events);
658
732
  for (const w of warnings)
659
733
  console.warn('⚠️', w);
660
- console.log(` ✅ ${appRecord.name} (v${onChainVersion}): ${count} records`);
734
+ console.log(` ✅ ${appRecord.name} (${network} v${onChainVersion}): ${count} records`);
661
735
  }
662
736
  catch (err) {
663
- console.warn(` ⚠️ ${appRecord.name}: bootstrap failed — ${err?.message ?? err}`);
737
+ console.warn(` ⚠️ ${appRecord.name} (${network}): bootstrap failed — ${err?.message ?? err}`);
664
738
  }
665
739
  }
666
740
  records = await readRecords();
@@ -669,12 +743,7 @@ if (records.length === 0) {
669
743
  console.log(`📋 Loaded ${records.length} license records across ${new Set(records.map(r => r.zkAppAddress)).size} zkApp(s)`);
670
744
  app.listen(PORT, () => {
671
745
  console.log(`🔍 Verify service listening on http://localhost:${PORT}`);
672
- console.log(` GET /?licenseHash=...&zkAppAddress=...[&token=...]`);
673
- console.log(` GET /challenge?licenseHash=...`);
674
- console.log(` POST /respond`);
675
- console.log(` POST /refresh`);
676
- console.log(` POST /releaseSeat`);
677
- console.log(` POST /reset-sessions`);
746
+ console.log(` GET /?licenseHash=...&zkAppAddress=...`);
678
747
  console.log(` GET /witness/:zkAppAddress/:licenseHash`);
679
748
  console.log(` GET /license/:zkAppAddress/:licenseHash`);
680
749
  console.log(` GET /apps`);
@@ -686,5 +755,6 @@ app.listen(PORT, () => {
686
755
  console.log(`⚠️ Admin endpoints disabled (set VERIFY_ADMIN_TOKEN to enable)`);
687
756
  }
688
757
  console.log(`🔁 Lazy bootstrap on root mismatch (no periodic poller)`);
758
+ console.log(`📝 Access log: ${ACCESS_LOG_ENABLED ? 'ON' : 'off (VERIFY_ACCESS_LOG=off)'}`);
689
759
  });
690
760
  //# sourceMappingURL=verifyService.js.map