zklicensing 0.1.0-beta.1 → 0.1.0-beta.10

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 (181) hide show
  1. package/README.md +350 -140
  2. package/build/appsLookup.d.ts +14 -0
  3. package/build/appsLookup.d.ts.map +1 -0
  4. package/build/appsLookup.js +12 -0
  5. package/build/appsLookup.js.map +1 -0
  6. package/build/archiveBootstrap.d.ts +19 -1
  7. package/build/archiveBootstrap.d.ts.map +1 -0
  8. package/build/archiveBootstrap.js +109 -29
  9. package/build/archiveBootstrap.js.map +1 -0
  10. package/build/bindClient.d.ts +71 -0
  11. package/build/bindClient.d.ts.map +1 -0
  12. package/build/bindClient.js +164 -0
  13. package/build/bindClient.js.map +1 -0
  14. package/build/buyClient.d.ts +11 -4
  15. package/build/buyClient.d.ts.map +1 -0
  16. package/build/buyClient.js +42 -23
  17. package/build/buyClient.js.map +1 -0
  18. package/build/contractInterface.d.ts +11 -5
  19. package/build/contractInterface.d.ts.map +1 -0
  20. package/build/contractInterface.js +85 -20
  21. package/build/contractInterface.js.map +1 -0
  22. package/build/countryStats.d.ts +7 -2
  23. package/build/countryStats.d.ts.map +1 -0
  24. package/build/countryStats.js +33 -9
  25. package/build/countryStats.js.map +1 -0
  26. package/build/deployClient.d.ts +5 -3
  27. package/build/deployClient.d.ts.map +1 -0
  28. package/build/deployClient.js +20 -17
  29. package/build/deployClient.js.map +1 -0
  30. package/build/deviceIdentity.d.ts +68 -0
  31. package/build/deviceIdentity.d.ts.map +1 -0
  32. package/build/deviceIdentity.js +209 -0
  33. package/build/deviceIdentity.js.map +1 -0
  34. package/build/freezeClient.d.ts +3 -3
  35. package/build/freezeClient.d.ts.map +1 -0
  36. package/build/freezeClient.js +3 -3
  37. package/build/freezeClient.js.map +1 -0
  38. package/build/generationClient.d.ts +4 -3
  39. package/build/generationClient.d.ts.map +1 -0
  40. package/build/generationClient.js +8 -8
  41. package/build/generationClient.js.map +1 -0
  42. package/build/index.d.ts +27 -37
  43. package/build/index.d.ts.map +1 -0
  44. package/build/index.js +85 -116
  45. package/build/index.js.map +1 -0
  46. package/build/licenseLeaf.d.ts +76 -0
  47. package/build/licenseLeaf.d.ts.map +1 -0
  48. package/build/licenseLeaf.js +189 -0
  49. package/build/licenseLeaf.js.map +1 -0
  50. package/build/licenseRecord.d.ts +20 -1
  51. package/build/licenseRecord.d.ts.map +1 -0
  52. package/build/licenseRecord.js +1 -1
  53. package/build/licenseRecord.js.map +1 -0
  54. package/build/licenseStore.d.ts +18 -2
  55. package/build/licenseStore.d.ts.map +1 -0
  56. package/build/licenseStore.js +172 -7
  57. package/build/licenseStore.js.map +1 -0
  58. package/build/manifestVerify.d.ts +1 -1
  59. package/build/manifestVerify.d.ts.map +1 -0
  60. package/build/manifestVerify.js +9 -4
  61. package/build/manifestVerify.js.map +1 -0
  62. package/build/migrateClient.d.ts +6 -21
  63. package/build/migrateClient.d.ts.map +1 -0
  64. package/build/migrateClient.js +30 -17
  65. package/build/migrateClient.js.map +1 -0
  66. package/build/minaPriceHistory.d.ts +9 -0
  67. package/build/minaPriceHistory.d.ts.map +1 -0
  68. package/build/minaPriceHistory.js +152 -0
  69. package/build/minaPriceHistory.js.map +1 -0
  70. package/build/onChainRoot.d.ts +67 -0
  71. package/build/onChainRoot.d.ts.map +1 -0
  72. package/build/onChainRoot.js +89 -0
  73. package/build/onChainRoot.js.map +1 -0
  74. package/build/ownershipLicenseHash.d.ts +25 -0
  75. package/build/ownershipLicenseHash.d.ts.map +1 -0
  76. package/build/ownershipLicenseHash.js +29 -0
  77. package/build/ownershipLicenseHash.js.map +1 -0
  78. package/build/ownershipSignature.d.ts +122 -0
  79. package/build/ownershipSignature.d.ts.map +1 -0
  80. package/build/ownershipSignature.js +244 -0
  81. package/build/ownershipSignature.js.map +1 -0
  82. package/build/proveErrors.d.ts +35 -0
  83. package/build/proveErrors.d.ts.map +1 -0
  84. package/build/proveErrors.js +49 -0
  85. package/build/proveErrors.js.map +1 -0
  86. package/build/refundClient.d.ts +2 -11
  87. package/build/refundClient.d.ts.map +1 -0
  88. package/build/refundClient.js +37 -19
  89. package/build/refundClient.js.map +1 -0
  90. package/build/renewClient.d.ts +4 -1
  91. package/build/renewClient.d.ts.map +1 -0
  92. package/build/renewClient.js +16 -9
  93. package/build/renewClient.js.map +1 -0
  94. package/build/seatName.d.ts +8 -0
  95. package/build/seatName.d.ts.map +1 -0
  96. package/build/seatName.js +136 -0
  97. package/build/seatName.js.map +1 -0
  98. package/build/statsClient.d.ts +54 -7
  99. package/build/statsClient.d.ts.map +1 -0
  100. package/build/statsClient.js +44 -5
  101. package/build/statsClient.js.map +1 -0
  102. package/build/trustAnchorVerify.d.ts +48 -0
  103. package/build/trustAnchorVerify.d.ts.map +1 -0
  104. package/build/trustAnchorVerify.js +459 -0
  105. package/build/trustAnchorVerify.js.map +1 -0
  106. package/build/trustAnchorVerifyCli.d.ts +35 -0
  107. package/build/trustAnchorVerifyCli.d.ts.map +1 -0
  108. package/build/trustAnchorVerifyCli.js +193 -0
  109. package/build/trustAnchorVerifyCli.js.map +1 -0
  110. package/build/txBroadcastClient.d.ts +26 -0
  111. package/build/txBroadcastClient.d.ts.map +1 -0
  112. package/build/txBroadcastClient.js +20 -0
  113. package/build/txBroadcastClient.js.map +1 -0
  114. package/build/unbindClient.d.ts +75 -0
  115. package/build/unbindClient.d.ts.map +1 -0
  116. package/build/unbindClient.js +162 -0
  117. package/build/unbindClient.js.map +1 -0
  118. package/build/vendorAccountClient.d.ts +60 -0
  119. package/build/vendorAccountClient.d.ts.map +1 -0
  120. package/build/vendorAccountClient.js +47 -0
  121. package/build/vendorAccountClient.js.map +1 -0
  122. package/build/verifyCore.d.ts +1 -1
  123. package/build/verifyCore.d.ts.map +1 -0
  124. package/build/verifyCore.js +11 -4
  125. package/build/verifyCore.js.map +1 -0
  126. package/build/verifyOffline.d.ts +85 -0
  127. package/build/verifyOffline.d.ts.map +1 -0
  128. package/build/verifyOffline.js +139 -0
  129. package/build/verifyOffline.js.map +1 -0
  130. package/build/verifyService.d.ts.map +1 -0
  131. package/build/verifyService.js +589 -460
  132. package/build/verifyService.js.map +1 -0
  133. package/build/verifyWithAncestors.d.ts +18 -0
  134. package/build/verifyWithAncestors.d.ts.map +1 -0
  135. package/build/verifyWithAncestors.js +119 -0
  136. package/build/verifyWithAncestors.js.map +1 -0
  137. package/build/zipToState.d.ts +2 -0
  138. package/build/zipToState.d.ts.map +1 -0
  139. package/build/zipToState.js +94 -0
  140. package/build/zipToState.js.map +1 -0
  141. package/package.json +57 -13
  142. package/src/appsLookup.ts +48 -0
  143. package/src/archiveBootstrap.ts +370 -0
  144. package/src/bindClient.ts +255 -0
  145. package/src/buyClient.ts +310 -0
  146. package/src/contractInterface.d.ts +61 -0
  147. package/src/contractInterface.ts +313 -0
  148. package/src/countryStats.ts +171 -0
  149. package/src/deployClient.ts +399 -0
  150. package/src/deviceIdentity.ts +249 -0
  151. package/src/freezeClient.ts +117 -0
  152. package/src/generationClient.ts +165 -0
  153. package/src/index.ts +333 -0
  154. package/src/licenseLeaf.ts +221 -0
  155. package/src/licenseRecord.ts +115 -0
  156. package/src/licenseStore.ts +547 -0
  157. package/src/manifestVerify.ts +111 -0
  158. package/src/migrateClient.ts +84 -0
  159. package/src/minaPriceHistory.ts +166 -0
  160. package/src/onChainRoot.ts +109 -0
  161. package/src/ownershipLicenseHash.ts +31 -0
  162. package/src/ownershipSignature.ts +354 -0
  163. package/src/proveErrors.ts +70 -0
  164. package/src/refundClient.ts +162 -0
  165. package/src/renewClient.ts +214 -0
  166. package/src/seatName.ts +165 -0
  167. package/src/statsClient.ts +242 -0
  168. package/src/trustAnchorVerify.ts +578 -0
  169. package/src/trustAnchorVerifyCli.ts +196 -0
  170. package/src/txBroadcastClient.ts +48 -0
  171. package/src/unbindClient.ts +222 -0
  172. package/src/vendorAccountClient.ts +114 -0
  173. package/src/verifyCore.ts +92 -0
  174. package/src/verifyOffline.ts +212 -0
  175. package/src/verifyService.ts +972 -0
  176. package/src/verifyWithAncestors.ts +153 -0
  177. package/src/zipToState.ts +94 -0
  178. package/build/licenseProof.d.ts +0 -26
  179. package/build/licenseProof.js +0 -41
  180. package/build/ownership.d.ts +0 -34
  181. package/build/ownership.js +0 -147
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # zklicensing
2
2
 
3
- Client SDK for verifying [zkLicensing](https://zklicensing.com) software licenses.
3
+ Client SDK for verifying [zkLicensing](https://dev.zklicensing.com) software licenses.
4
4
 
5
5
  `verifyLicense` calls the verifier's `GET /verify` endpoint during the on-chain refund window (~14 days), then writes a one-shot anchor to local storage and verifies fully offline from that point on. Works in browsers (via `localStorage`) and in Node (with an injected storage adapter). Zero runtime dependencies.
6
6
 
@@ -18,10 +18,8 @@ import { verifyLicense } from 'zklicensing';
18
18
  // `proof` is the JSON the buyer downloaded after purchase
19
19
  const result = await verifyLicense({
20
20
  licenseHash: proof.licenseHash,
21
- zkAppAddress: proof.zkAppAddress, // your deployed zkApp address (B62q…)
21
+ zkAppAddress: proof.zkAppAddress, // your deployed zkApp address (B62q…)
22
22
  expirySlot: proof.expirySlot,
23
- purchaseSlot: proof.purchaseSlot,
24
- verifierUrl: 'https://zklicensing.com/api/verify', // hosted verify service
25
23
  });
26
24
 
27
25
  if (result.valid) {
@@ -41,9 +39,7 @@ purchase ─────► verifier (every call) ─────► anchor writ
41
39
 
42
40
  For the first ~14 days the licence can still be refunded on-chain, so the SDK calls the verifier on every check to catch refunds. Once the verifier itself reports the refund window has closed (`currentSlot > purchaseSlot + 13 440 slots`, ≈ 14 days at one slot every 90 s post-Mesa), the SDK writes an anchor into local storage and never touches the network again for that licence.
43
41
 
44
- > **Note:** "Offline" here refers to the validity/expiry check performed by `verifyLicense`. If the vendor set a concurrent-device cap (`maxConcurrentDevices > 0`) on their app, the *separate* activation-token flow (`/challenge` → `/respond` → session token) still round-trips the verifier on every session — that's stateful by design and cannot be anchored locally. Apps with `maxConcurrentDevices = 0` (unlimited, the default) are fully offline post-refund.
45
-
46
- The anchor stores `{ anchoredSlot, anchoredAtMs, expirySlot, ownership }`. In the offline branch the current slot is estimated as `anchoredSlot + (now − anchoredAtMs) / 90 000 ms`, and the same 7-day grace window (`6 720 slots`) is applied either side.
42
+ The anchor stores `{ anchoredSlot, anchoredAtMs, expirySlot }`. In the offline branch the current slot is estimated as `anchoredSlot + (now − anchoredAtMs) / 90 000 ms`, and the same 7-day grace window (`6 720 slots`) is applied either side.
47
43
 
48
44
  A renewal advances the proof's `expirySlot`; that mismatch invalidates the anchor and the SDK falls back to one more verifier call before re-anchoring with the new expiry.
49
45
 
@@ -58,12 +54,9 @@ type ProofInput = {
58
54
  licenseHash: string; // Poseidon hash identifying the licence on-chain
59
55
  zkAppAddress: string; // Your deployed zkApp's Mina address (B62q…)
60
56
  expirySlot: number; // From the proof; invalidates stale anchors on renewal
61
- purchaseSlot: number; // From the proof; decides when the refund window has closed
62
- verifierUrl?: string;
63
57
  };
64
58
 
65
59
  type VerifyOptions = {
66
- verifierUrl?: string;
67
60
  storage?: Storage | null; // default: globalThis.localStorage; null disables anchoring
68
61
  fetcher?: typeof fetch; // default: globalThis.fetch
69
62
  now?: () => number; // default: Date.now
@@ -72,7 +65,10 @@ type VerifyOptions = {
72
65
  type VerifyResult = {
73
66
  valid: boolean;
74
67
  expirySlot: number;
68
+ expiresAt: string | null; // ISO-8601; estimated in the offline branch
69
+ currentSlot: number; // from the verifier, or estimated offline
75
70
  inGracePeriod: boolean;
71
+ remainingDays: number; // negative through grace and beyond
76
72
  reason: string | null;
77
73
  source: 'chain' | 'offline';
78
74
  };
@@ -80,6 +76,8 @@ type VerifyResult = {
80
76
  function verifyLicense(proof: ProofInput, options?: VerifyOptions): Promise<VerifyResult>;
81
77
  ```
82
78
 
79
+ The refund-window decision is server-side: the verifier returns `refundWindowClosed: boolean` on its `/verify` response, computed from its own `purchaseSlot` record against the on-chain `currentSlot`. The SDK gates anchor writes on that boolean and never trusts a `purchaseSlot` supplied by the local proof file, so a user who forges their proof metadata cannot force the offline branch prematurely.
80
+
83
81
  The package also re-exports the three constants the SDK uses internally:
84
82
 
85
83
  ```ts
@@ -87,6 +85,62 @@ import { REFUND_WINDOW_N, GRACE_PERIOD_N, MS_PER_SLOT } from 'zklicensing';
87
85
  // 13_440, 6_720, 90_000
88
86
  ```
89
87
 
88
+ ### Non-custodial device binding
89
+
90
+ For apps with a per-license device cap (`deviceCap > 0`), the SDK ships a bind/unbind client that writes device slots directly to the on-chain `LicenseLeaf`. There is no session server: the leaf carries a fixed-width slot array (`DEVICE_SLOT_CAP_N = 5`), the on-chain `bind()` / `unbind()` methods re-verify the buyer's Pallas Schnorr ownership signature IN-CIRCUIT via `Signature.verify()`, and offline verification checks the device's local commitment against the cached leaf.
91
+
92
+ ```ts
93
+ import {
94
+ proveBind,
95
+ confirmBind,
96
+ proveUnbind,
97
+ confirmUnbind,
98
+ getOrCreateDeviceIdentity,
99
+ deriveOwnershipKeypair,
100
+ } from 'zklicensing';
101
+
102
+ // Fresh device: generate (or load) a Pallas keypair kept in OS secure
103
+ // storage. Only its Poseidon commitment ever crosses the wire.
104
+ const { deviceCommitment } = await getOrCreateDeviceIdentity('myapp/license/device');
105
+
106
+ // Owner key — derived from the buyer's passphrase + the app's zkApp
107
+ // address. Same keypair `licenseHash` was derived from at buy time.
108
+ const { privateKey: ownerPrivateKey } = await deriveOwnershipKeypair(
109
+ passphrase,
110
+ zkAppAddress,
111
+ );
112
+
113
+ // Bind — the on-chain circuit re-verifies this signature, so a
114
+ // compromised backend cannot forge a bind.
115
+ const { provenTxJson, slotIndex } = await proveBind({
116
+ keeperUrl: 'https://dev.zklicensing.com/api/keeper',
117
+ network: 'mainnet',
118
+ zkAppAddress,
119
+ licenseHash,
120
+ ownerPrivateKey,
121
+ deviceCommitment,
122
+ slotIndex: 0, // 0..deviceCap-1
123
+ buyerAddress: wallet.address,
124
+ });
125
+
126
+ const txHash = await wallet.sendTransaction(provenTxJson);
127
+ await confirmBind({ keeperUrl, zkAppAddress, licenseHash, txHash });
128
+ ```
129
+
130
+ **Remote log-off.** `proveUnbind` needs only the ownership key — not physical possession of the target device. Any device with the passphrase-derived key can unbind any slot on the license, including one bound to a lost or stolen device.
131
+
132
+ **Slot behaviour.**
133
+
134
+ - Binding into an empty slot succeeds immediately.
135
+ - Unbinding stamps `freedAt = currentSlot`; the slot cannot be re-bound until `COOLDOWN_SLOTS_N = 6_720` slots (≈ 7 days at 90 s/slot) have elapsed. The cooldown is a flat one-week wait for every tier (monthly, yearly, five-year), independent of `deviceCap` — one number to reason about, not a per-tier formula.
136
+ - `deviceCap = 0` disables binding entirely — `bind()` rejects with "bindings disabled" and no slot state is written to the leaf.
137
+ - Cap is committed to the leaf at buy time and immutable for the life of the license. A vendor cannot tighten enforcement retroactively.
138
+ - Bindings survive cross-generation migration: `migrateLicense` copies the ancestor leaf verbatim (including the device slot array) into the successor tree.
139
+
140
+ **Offline verification.** After bind, the SDK caches the leaf's slot array locally. `verifyOffline` compares the device's local Poseidon commitment against `occupiedSlot[]`; a stolen laptop's commitment vanishing from the slot array denies access on the next launch without any network call.
141
+
142
+ Non-custodial guarantees preserved: the keeper cannot bind or unbind (no signature to sign with), the vendor cannot revoke a bound device (no admin unbind path in the circuit), and the platform cannot revoke either. The only party who can add or remove a device commitment is whoever holds the passphrase-derived ownership private key.
143
+
90
144
  ## Node storage adapter
91
145
 
92
146
  In the browser the SDK uses `localStorage` automatically. In Node, supply your own — otherwise the offline branch will never activate and every call will hit the verifier:
@@ -115,7 +169,7 @@ Pass `storage: null` to disable the offline path entirely (every call hits the v
115
169
 
116
170
  ## Verifier response contract
117
171
 
118
- If you self-host the verification endpoint, it must return:
172
+ The verifier returns:
119
173
 
120
174
  ```ts
121
175
  {
@@ -134,36 +188,26 @@ For non-JavaScript clients (Android, iOS, Python, …) call the verifier's `GET
134
188
 
135
189
  ## Platform examples
136
190
 
137
- Two variants matter per platform:
138
-
139
- - **Unlimited** — the vendor set `maxConcurrentDevices = 0`. Just call `GET /verify`; there is no ownership token.
140
- - **JTI-capped** — the vendor set `maxConcurrentDevices > 0`. Obtain a session token once via a small backend helper that runs `LicenseProof.compile()`, drives `POST /verify/challenge` + `/respond`, and returns the HMAC-JTI token to cache client-side (o1js can't run on-device). Pass `&token=` on every `GET /verify`. Renew silently against `POST /verify/refresh`; release the seat on logout with `POST /verify/releaseSeat`.
191
+ Device-cap enforcement is fully on-chain via [Non-custodial device binding](#non-custodial-device-binding) — the verifier never mints session tokens, so every platform reduces to the same `GET /verify` call. If the vendor set `deviceCap > 0` at registration, the SDK's bind/unbind clients drive on-chain slot writes; the verifier is stateless and passphrase-free.
141
192
 
142
193
  ### Android (Kotlin)
143
194
 
144
195
  ```kotlin
145
- // Unlimited — no token
146
196
  val licenseHash = prefs.getString("licenseHash", "") ?: ""
147
197
  val zkApp = "B62q…"
148
- val url = "https://zklicensing.com/api/verify" +
198
+ val url = "https://dev.zklicensing.com/api/verify" +
149
199
  "?licenseHash=$licenseHash&zkAppAddress=$zkApp"
150
200
  val body = OkHttpClient().newCall(Request.Builder().url(url).build())
151
201
  .execute().body?.string() ?: "{}"
152
202
  val r = JSONObject(body)
153
203
  if (r.getBoolean("valid")) unlockFeatures()
154
-
155
- // JTI-capped — append &token, refreshed off the backend helper
156
- val token = prefs.getString("ownershipToken", null)
157
- val capped = url + (token?.let { "&token=$it" } ?: "")
158
- // … same fetch + parse; on 429 { activeSessions, deviceLimit } prompt "log out other device"
159
204
  ```
160
205
 
161
206
  ### iOS (Swift)
162
207
 
163
208
  ```swift
164
- // Unlimited — no token
165
209
  let zkApp = "B62q…"
166
- var c = URLComponents(string: "https://zklicensing.com/api/verify")!
210
+ var c = URLComponents(string: "https://dev.zklicensing.com/api/verify")!
167
211
  c.queryItems = [
168
212
  URLQueryItem(name: "licenseHash", value: licenseHash),
169
213
  URLQueryItem(name: "zkAppAddress", value: zkApp),
@@ -171,41 +215,11 @@ c.queryItems = [
171
215
  let (data, _) = try await URLSession.shared.data(from: c.url!)
172
216
  let r = try JSONDecoder().decode(VerifyResult.self, from: data)
173
217
  if r.valid { unlockFeatures() }
174
-
175
- // JTI-capped — append &token; refresh via POST /verify/refresh
176
- if let token = UserDefaults.standard.string(forKey: "ownershipToken") {
177
- c.queryItems! += [URLQueryItem(name: "token", value: token)]
178
- }
179
218
  ```
180
219
 
181
220
  ### Browser / PWA
182
221
 
183
- Unlimited licenses are just the top-of-file `verifyLicense` example — no `secretHash`, no `prover`. For JTI-capped apps, load `zklicensing/prove` + `o1js` lazily on first verify only, then let the SDK's stored `secretHash` renew silently:
184
-
185
- ```ts
186
- import { verifyLicense } from 'zklicensing';
187
-
188
- // `promptPassphraseOnce` is your helper — check localStorage yourself and
189
- // skip the modal when an activation already exists. On the first successful
190
- // /challenge + /respond the SDK persists { token, expiresAt, secretHash }
191
- // to `options.storage` (defaults to localStorage in browsers), and from
192
- // then on renews silently using the stored secretHash.
193
- const rawSecret = await promptPassphraseOnce();
194
- const [o1js, prove] = await Promise.all([import('o1js'), import('zklicensing/prove')]);
195
- const secretHash = o1js.Poseidon.hash(o1js.Encoding.stringToFields(rawSecret)).toString();
196
- await prove.LicenseProof.compile();
197
-
198
- const r = await verifyLicense(proof, {
199
- secretHash,
200
- prover: async ({ licenseHash, nonce, secretHash }) => {
201
- const pub = new prove.OwnershipChallenge({
202
- licenseHash: o1js.Field(licenseHash), nonce: o1js.Field(nonce),
203
- });
204
- return (await prove.LicenseProof.prove(pub, o1js.Field(secretHash))).proof.toJSON();
205
- },
206
- });
207
- if (r.valid && r.ownership === 'verified') unlockFeatures();
208
- ```
222
+ `verifyLicense` is the top-of-file example — no `signer`, no passphrase round-trip. If your app also needs to bind/unbind devices, see [Non-custodial device binding](#non-custodial-device-binding) below for the `proveBind` / `proveUnbind` flow that uses the buyer's passphrase-derived Pallas Schnorr ownership key to authorize on-chain slot writes.
209
223
 
210
224
  ## Quick Start: Building a vendor site
211
225
 
@@ -214,6 +228,8 @@ Three drop-in pages, one SDK. Fork them, restyle, ship.
214
228
  - **`examples/buy.html`** — passphrase → wallet sign → licence file.
215
229
  - **`examples/renew.html`** — one click, one wallet prompt.
216
230
  - **`examples/refund.html`** — 14-day window, handled.
231
+ - **`examples/bind.html`** / **`examples/unbind.html`** — device-slot writes for `deviceCap > 0` apps.
232
+ - **`examples/verify.html`** — non-custodial verify: paste proof.json → witness + record from the service, root from Mina, folded locally.
217
233
 
218
234
  Each page is a self-contained HTML + TS pair calling one SDK subpath.
219
235
 
@@ -221,7 +237,7 @@ Each page is a self-contained HTML + TS pair calling one SDK subpath.
221
237
 
222
238
  ```bash
223
239
  # 1. Register your app and download apps.json
224
- open https://zklicensing.com/register.html # click "Download apps.json"
240
+ open https://dev.zklicensing.com/register.html # click "Download apps.json"
225
241
 
226
242
  # 2. Drop apps.json into the examples/ directory
227
243
  mv ~/Downloads/apps.json packages/sdk/examples/
@@ -232,7 +248,7 @@ npm install
232
248
  npm run dev
233
249
  ```
234
250
 
235
- Vite serves `index.html`, `buy.html`, `renew.html`, and `refund.html` at
251
+ Vite serves `index.html`, `buy.html`, `renew.html`, `refund.html`, `bind.html`, `unbind.html`, and `verify.html` at
236
252
  `http://localhost:5173`. The pages `fetch('/apps.json')` for their config —
237
253
  no `.env` file needed. Ship them as static assets with `npm run build`.
238
254
 
@@ -244,6 +260,16 @@ via `VITE_WALLETCONNECT_PROJECT_ID` to enable the mobile QR fallback.
244
260
 
245
261
  The main entry point (`zklicensing`) covers licence verification. Additional entry points are shipped alongside for integrators building a buy page, a vendor dashboard, or tax-report tooling — all browser-safe, no `fs`.
246
262
 
263
+ ### Per-app in-flight proving guard
264
+
265
+ `/prove/buy`, `/prove/buy-no-escrow`, `/prove/renew`, `/prove/migrate`, and `/prove/refund` all mutate the same on-chain `licensesRoot` (and, for buy/refund, `escrowRoot`) preconditions. Two proofs assembled against the same pre-state root within one landing window are mutually exclusive — the chain admits whichever lands first, the second's precondition fails and the tx is dropped. Left unguarded, the second buyer pays the wallet-popup + fee for a doomed tx.
266
+
267
+ The keeper defends against this with a **per-app in-flight slot** shared across all five endpoints. While the slot is held, a second concurrent request for the same app receives an immediate `409 { error: "proving queue busy", retryAfterMs, queueDepth }` — before any circuit work or wallet prompt. The SDK surfaces this as a shared `ProveQueueBusyError` (from `zklicensing/prove-errors`) thrown by `proveBuy`, `proveRenew`, `proveMigrate`, and `proveRefund`. Callers wait `retryAfterMs`, then retry — no popup, no fee, no signed-tx-doomed-to-fail.
268
+
269
+ Each prove response carries an `appKey` field — the same value the keeper uses to key the slot. After the wallet returns a tx hash, the SDK client calls `notifyBroadcast({ keeperUrl, appId: appKey, txHash })` (from `zklicensing/tx-broadcast`, fire-and-forget) to extend the slot's TTL from signing-TTL to landing-TTL, giving the honest broadcast room to land before another prove goes through.
270
+
271
+ See [`zklicensing/prove-errors`](#zklicensingprove-errors--shared-typed-errors) and [`zklicensing/tx-broadcast`](#zklicensingtx-broadcast--broadcast-signal) below; each of `zklicensing/buy`, `zklicensing/renew`, `zklicensing/migrate` references this contract.
272
+
247
273
  ### `zklicensing/deploy` — deploy flow client
248
274
 
249
275
  Wraps the backend's `/prove/deploy` endpoint. The backend generates a throwaway zkApp keypair, bundles `deploy() + initialize()` as a single atomic tx, proves it, signs the zkApp AccountUpdate with the throwaway key, and returns the provenTxJson — the vendor's wallet only adds the fee-payer signature.
@@ -252,12 +278,13 @@ Wraps the backend's `/prove/deploy` endpoint. The backend generates a throwaway
252
278
  import { proveDeploy, pollDeployStatus } from 'zklicensing/deploy';
253
279
 
254
280
  const { provenTxJson, zkAppAddress, verificationKeyHash } = await proveDeploy({
255
- keeperUrl: 'https://zklicensing.com/api/keeper',
281
+ keeperUrl: 'https://dev.zklicensing.com/api/keeper',
256
282
  senderPublicKey: 'B62q…', // vendor's fee-payer wallet
257
283
  vendorAddress: 'B62q…',
258
- priceMonthlyMina: 1,
259
- priceYearlyMina: 10,
260
- priceFiveYearMina: 40,
284
+ priceMonthlyMina: 1,
285
+ priceYearlyMina: 10,
286
+ priceFiveYearMina: 40,
287
+ pricePerpetualMina: 10000,
261
288
  });
262
289
 
263
290
  // Send `provenTxJson` to the wallet for signing, obtain txHash, register the
@@ -269,35 +296,7 @@ The SDK does not expose a raw `initialize()` helper. `initialize()` sets `vendor
269
296
 
270
297
  #### Post-hardfork redeploy
271
298
 
272
- After a Mina hardfork relaxes `impossibleDuringCurrentVersion` back to `signature()`, every existing zkApp becomes uneditable in place (deploy keys are already discarded). The platform migrates each app to a fresh v2 address via admin endpoints, guarded by `KEEPER_ADMIN_TOKEN`:
273
-
274
- ```ts
275
- import { redeployApp, redeployAll } from 'zklicensing/deploy';
276
-
277
- // Migrate one app.
278
- const { zkAppAddress, txHash, verificationKeyHash, onChainVersion } = await redeployApp({
279
- keeperUrl: 'https://zklicensing.example.com',
280
- appId: 'acme-editor',
281
- adminToken: process.env.KEEPER_ADMIN_TOKEN!,
282
- });
283
-
284
- // Batch — top-level `ok` is always true unless the whole request errored;
285
- // per-app failures surface as { ok: false, error } inside `results`.
286
- const batch = await redeployAll({ keeperUrl, adminToken });
287
- for (const r of batch.results) {
288
- if (!r.ok) console.warn(`${r.appId}: ${r.error}`);
289
- }
290
- ```
291
-
292
- The backend reads authoritative `vendor` + prices from the old v1 contract (no vendor RPC), mints a fresh throwaway zkApp keypair, and broadcasts a merged-sign atomic redeploy. Guards (HTTP 409): sales frozen, refund window closed, `escrowRoot === EMPTY_ROOT`, on-chain version still in `SUPPORTED_LICENSING_APP_VERSIONS`. Both endpoints are idempotent — already-migrated apps surface as `{ ok: false, error: "already outside supported range" }`, so reruns after a partial batch are safe.
293
-
294
- Pass `force: true` on either helper to bypass the `escrowRoot === EMPTY_ROOT` guard when the platform accepts responsibility for the unrefunded balance. Other guards remain strict. Successful responses that used the override return `forcedEscrowNonEmpty: true` alongside the usual fields; unrefunded buyers keep their independent refund path against the retired v1 address (its on-chain state persists), so pointing them at `deploymentHistory[]` addresses out-of-band remains the mitigation.
295
-
296
- Confirmation polling is not baked in — reuse `pollDeployStatus({ keeperUrl, zkAppAddress })` against the returned address.
297
-
298
- #### Vendor-triggered redeploy
299
-
300
- Vendors don't have to wait for the platform to migrate their app — they can trigger the redeploy themselves through the SDK. Authentication is a Mina signature against the on-chain vendor of the target app (no `KEEPER_ADMIN_TOKEN` required), and the vendor pays gas via their own fee-payer AU. Three-step flow, mirroring the first-time deploy:
299
+ After a Mina hardfork relaxes `impossibleDuringCurrentVersion` back to `signature()`, every existing zkApp becomes uneditable in place (deploy keys are already discarded). Vendors migrate their app to a fresh v2 address themselves. Authentication is a Mina signature against the on-chain vendor of the target app, and the vendor pays gas via their own fee-payer AU. Three-step flow, mirroring the first-time deploy:
301
300
 
302
301
  ```ts
303
302
  import {
@@ -337,9 +336,9 @@ await registerVendorRedeploy({ keeperUrl, appId, redeployId, txHash });
337
336
  await pollDeployStatus({ keeperUrl, zkAppAddress });
338
337
  ```
339
338
 
340
- Guards are identical to the admin path: freeze slot configured, refund window closed past `FREEZE_AFTER_SLOT + REFUND_WINDOW`, app status `active`, on-chain version in `SUPPORTED_LICENSING_APP_VERSIONS`. The nonce expires 60 seconds after `getVendorRedeployChallenge` returns; consumed on `proveVendorRedeploy`.
339
+ Guards enforced by the backend before proving: freeze slot configured, refund window closed past `FREEZE_AFTER_SLOT + REFUND_WINDOW`, app status `active`, on-chain version in `SUPPORTED_LICENSING_APP_VERSIONS`. The nonce expires 60 seconds after `getVendorRedeployChallenge` returns; consumed on `proveVendorRedeploy`.
341
340
 
342
- `force: true` is available to vendors too, with the same semantics as the admin path — useful when the freeze is scheduled late enough that a straggling refund is still in flight. Unrefunded buyers retain the refund path against the retired v1 address; its on-chain state persists indefinitely, and the retired address stays in `AppRecord.deploymentHistory[]` for out-of-band buyer notification.
341
+ Pass `force: true` to bypass the `escrowRoot === EMPTY_ROOT` guard when a straggling refund is still in flight and the vendor accepts responsibility for the unrefunded balance. Other guards remain strict. Unrefunded buyers retain the refund path against the retired v1 address; its on-chain state persists indefinitely, and the retired address stays in `AppRecord.deploymentHistory[]` for out-of-band buyer notification.
343
342
 
344
343
  > **Buy links auto-update — vendor-embedded links do not.** `proveVendorRedeploy` returns a fresh `zkAppAddress`. The platform marketplace stores your `buyUrl`/`renewUrl`/`refundUrl` as **base URLs** and appends `?app=<zkAppAddress>&network=<net>` at render time, so the "Get" button on the `/apps` listing routes buyers to the new v2 address automatically the moment `finalizeRedeploy` promotes it onto the AppRecord — no vendor action required. Only surfaces where you have hand-embedded the raw `zkAppAddress` yourself (landing pages, docs, emails, screenshots) need manual updating. Existing licenses on the retired v1 address remain independently verifiable.
345
344
 
@@ -364,6 +363,22 @@ Guards are identical to the admin path: freeze slot configured, refund window cl
364
363
 
365
364
  The buy/renew receipts include a `generation: number` field baked into `ProofFile`, so a licence bought against gen 2 can be told apart from one bought against gen 5 without a second network call.
366
365
 
366
+ **Resolving by `zkAppAddress` instead of `appId`.** The keeper also exposes `GET /apps/by-address/:zkAppAddress`, which resolves any registered address — current or historical — to the same signed generation-list body **plus** an `app: AppRecord` field:
367
+
368
+ ```ts
369
+ {
370
+ ...signedGenerationList, // same shape as GET /apps/:appId
371
+ app: AppRecord // full record; usable for name / prices / vendor legal
372
+ }
373
+ ```
374
+
375
+ Two properties matter for integrators:
376
+
377
+ 1. It accepts *any* generation's address (live or retired) and always returns the current lineage — so a buyer holding a link with a retired `?app=<addr>` still reaches the correct app.
378
+ 2. It does **not** filter by marketplace status. `pending`, `active`, `inactive`, and `rejected` apps all resolve. Contrast with `GET /apps`, which is `status: 'active'`-only for the storefront listing.
379
+
380
+ This is what `buy.html` and `renew.html` on zklicensing.com call — an inactive-listing app can still be bought and renewed against so long as its on-chain LicensingApp is live. Use this endpoint when starting from a `zkAppAddress` (URL param, wallet paste); use `GET /apps/:appId` when you already know the stable app ID and only need the lineage.
381
+
367
382
  On renew, `renew.ts` fetches this endpoint and, if the licence's `zkAppAddress` is retired, presents a **blocking** "Migrate & Renew" flow (the keeper 409s `/prove/renew` and `/prove/buy*` on retired addresses — buyer-held renewals against a retired tree would silently strand). Migration is a single call — no hop-walking — and copies the current expiry verbatim into the live map; see `zklicensing/migrate` below.
368
383
 
369
384
  **Signed generation-list, pin-verified.** The response body carries a `publicKey` + `signature` over the canonical `{ appId, current, generations, issuedAt }` payload, signed with the platform manifest Ed25519 key (the same key material that signs the freeze manifest). The SDK verifies against a caller-supplied pin list (`fetchGenerationInfo({ pinnedPublicKeysBase64 })`, see `zklicensing/generation` below) and fails closed — `verified: false` with `current: null` and `generations: []` — when the signature doesn't match. The keeper is not the trust anchor; the pin list is.
@@ -374,28 +389,58 @@ Never trusts `current` blindly: renew always continues against the licence's own
374
389
 
375
390
  ### `zklicensing/buy` — buy flow client
376
391
 
377
- Wraps the backend's three-step buy roundtrip so a custom marketplace or CLI can drive it without re-deriving the crypto. Peer dep: `o1js` (for the `secretHash` Poseidon derivation).
392
+ Wraps the backend's three-step buy roundtrip so a custom marketplace or CLI can drive it without re-deriving the crypto. Peer dep: `o1js` (for the `licenseHash` binding to the buyer's derived ownership pubkey).
378
393
 
379
394
  ```ts
380
395
  import { deriveBuyIdentity, proveBuy, confirmBuy, pollBuyStatus } from 'zklicensing/buy';
396
+ import { ProveQueueBusyError } from 'zklicensing/prove-errors';
397
+ import { notifyBroadcast } from 'zklicensing/tx-broadcast';
398
+
399
+ // 1. Derive the identity from the buyer's passphrase and the app's zkApp
400
+ // address (deterministic — the resulting ownership pubkey is hashed into
401
+ // the on-chain licenseHash). Binding to zkAppAddress means the same
402
+ // passphrase yields different keypairs across apps, so a leaked
403
+ // passphrase for one app cannot be replayed against any other.
404
+ const { licenseHash } = await deriveBuyIdentity(passphrase, zkAppAddress);
405
+
406
+ // 2. Ask the backend to compile + prove the buy tx. Retry on shared per-app
407
+ // queue-busy — server drives cadence via `retryAfterMs`, which caps at
408
+ // ~one slot (~90s at Mesa) once /tx-broadcast has fired. 10 attempts
409
+ // covers ~15 min of slot-by-slot polling — at 40 % fill that's < 1 %
410
+ // chance of exhausting before a block lands.
411
+ async function proveBuyWithRetry(): Promise<Awaited<ReturnType<typeof proveBuy>>> {
412
+ const MAX_ATTEMPTS = 10;
413
+ for (let n = 1; n <= MAX_ATTEMPTS; n++) {
414
+ try {
415
+ return await proveBuy({
416
+ keeperUrl: 'https://dev.zklicensing.com/api/keeper',
417
+ licenseHash,
418
+ buyerAddress: 'B62q…',
419
+ zkAppAddress: 'B62q…',
420
+ network: 'mainnet', // 'devnet' or 'mainnet' — must match your ProofFile
421
+ duration: 0, // 0 = Monthly, 1 = Yearly, 2 = 5 Years, 3 = Perpetual
422
+ buyerCountry: 'FI', // ISO 3166-1 alpha-2, required
423
+ // Required only when a sales freeze is scheduled — see `zklicensing/freeze`
424
+ // below. Ignored (safe to pass anyway) when no freeze is announced.
425
+ refundWaiverAccepted: true,
426
+ });
427
+ } catch (err) {
428
+ if (err instanceof ProveQueueBusyError && n < MAX_ATTEMPTS) {
429
+ await new Promise(r => setTimeout(r, err.retryAfterMs));
430
+ continue;
431
+ }
432
+ throw err;
433
+ }
434
+ }
435
+ throw new Error('unreachable');
436
+ }
381
437
 
382
- // 1. Derive the identity from the buyer's passphrase (deterministic).
383
- const { secretHash, licenseHash } = deriveBuyIdentity(passphrase);
384
-
385
- // 2. Ask the backend to compile + prove the buy tx.
386
- const { provenTxJson, expirySlot, purchaseAmount, verificationKeyHash } = await proveBuy({
387
- keeperUrl: 'https://zklicensing.com/api/keeper',
388
- secretHash,
389
- buyerAddress: 'B62q…',
390
- zkAppAddress: 'B62q…',
391
- duration: 0, // 0 = Monthly, 1 = Yearly, 2 = 5 Years
392
- buyerCountry: 'FI', // ISO 3166-1 alpha-2, required
393
- // Required only when a sales freeze is scheduled — see `zklicensing/freeze`
394
- // below. Ignored (safe to pass anyway) when no freeze is announced.
395
- refundWaiverAccepted: true,
396
- });
438
+ const { provenTxJson, expirySlot, purchaseAmount, verificationKeyHash, appKey } = await proveBuyWithRetry();
397
439
 
398
440
  // 3. Send `provenTxJson` to the buyer's wallet for signing, obtain txHash.
441
+ // …then signal the keeper so the in-flight slot extends from signing-TTL
442
+ // to landing-TTL. Fire-and-forget; a network failure here is not fatal.
443
+ void notifyBroadcast({ keeperUrl, appId: appKey, txHash });
399
444
 
400
445
  // 4. Persist the pending record — the caller drives this only after the wallet returned.
401
446
  await confirmBuy({ keeperUrl, zkAppAddress, licenseHash, txHash });
@@ -416,21 +461,39 @@ Same shape as `zklicensing/buy`. `proveRenew` takes an explicit `waiverAccepted`
416
461
 
417
462
  ```ts
418
463
  import { proveRenew, confirmRenew } from 'zklicensing/renew';
464
+ import { ProveQueueBusyError } from 'zklicensing/prove-errors';
465
+ import { notifyBroadcast } from 'zklicensing/tx-broadcast';
419
466
 
420
- const { provenTxJson, newExpirySlot, renewAmount } = await proveRenew({
421
- keeperUrl: 'https://zklicensing.com/api/keeper',
467
+ const proveArgs = {
468
+ keeperUrl: 'https://dev.zklicensing.com/api/keeper',
422
469
  licenseHash: '…',
423
470
  buyerAddress: 'B62q…',
424
471
  zkAppAddress: 'B62q…',
425
- duration: 0, // 0 = Monthly, 1 = Yearly, 2 = 5 Years
426
- waiverAccepted: true, // required — throws before the network call if false
427
- });
472
+ network: 'mainnet' as 'devnet' | 'mainnet', // must match your ProofFile
473
+ duration: 0 as 0 | 1 | 2 | 3, // 0 = Monthly, 1 = Yearly, 2 = 5 Years, 3 = Perpetual
474
+ waiverAccepted: true, // required — throws before the network call if false
475
+ };
476
+
477
+ const MAX_ATTEMPTS = 10; // ~15 min slot-by-slot coverage at Mesa/0.4
478
+ let proved;
479
+ for (let n = 1; n <= MAX_ATTEMPTS; n++) {
480
+ try { proved = await proveRenew(proveArgs); break; }
481
+ catch (err) {
482
+ if (err instanceof ProveQueueBusyError && n < MAX_ATTEMPTS) {
483
+ await new Promise(r => setTimeout(r, err.retryAfterMs));
484
+ continue;
485
+ }
486
+ throw err;
487
+ }
488
+ }
489
+ const { provenTxJson, newExpirySlot, renewAmount, appKey } = proved!;
428
490
 
429
491
  // Send `provenTxJson` to the wallet, obtain txHash, then:
492
+ void notifyBroadcast({ keeperUrl, appId: appKey, txHash }); // fire-and-forget
430
493
  await confirmRenew({ keeperUrl, zkAppAddress, licenseHash, txHash });
431
494
  ```
432
495
 
433
- `proveRenew` also refuses locally when the backend's sales freeze is in effect (fetched from `GET /health`), for the same reason as `proveBuy`.
496
+ `proveRenew` also refuses locally when the backend's sales freeze is in effect (fetched from `GET /health`), for the same reason as `proveBuy`. Concurrent-prove serialization is via the shared per-app in-flight guard — see [Per-app in-flight proving guard](#per-app-in-flight-proving-guard).
434
497
 
435
498
  ### `zklicensing/migrate` — cross-generation migrate client
436
499
 
@@ -446,27 +509,31 @@ The keeper 409s `/prove/renew` and `/prove/buy*` for any retired `zkAppAddress`,
446
509
 
447
510
  ```ts
448
511
  import { proveMigrate, ProveMigrateError } from 'zklicensing/migrate';
512
+ import { ProveQueueBusyError } from 'zklicensing/prove-errors';
449
513
 
450
514
  try {
451
- const { provenTxJson, ancestorGen, expirySlot, zkAppAddress } = await proveMigrate({
452
- keeperUrl: 'https://zklicensing.com/api/keeper',
515
+ const { provenTxJson, ancestorGen, expirySlot, zkAppAddress, appKey } = await proveMigrate({
516
+ keeperUrl: 'https://dev.zklicensing.com/api/keeper',
453
517
  licenseHash: '…',
454
518
  appId: 'app_…',
455
519
  buyerAddress: 'B62q…', // fee payer (buyer-signed mode)
456
520
  });
457
521
  // Wallet signs provenTxJson → broadcast → poll landing → resume with renew.
458
522
  } catch (e) {
459
- if (e instanceof ProveMigrateError && e.status === 409 && e.retryAfterMs) {
460
- // "migration in flight" — another caller is proving right now.
461
- // Wait retryAfterMs before retrying rather than opening a second wallet prompt.
523
+ if (e instanceof ProveQueueBusyError) {
524
+ // Another root-mutating prove (buy / renew / migrate) is in flight on this app.
525
+ // Wait e.retryAfterMs, then retry — no wallet popup was opened.
526
+ }
527
+ if (e instanceof ProveMigrateError) {
528
+ // Terminal 4xx: 404 "license refunded or unknown", 409 "already migrated", etc.
462
529
  }
463
530
  throw e;
464
531
  }
465
532
  ```
466
533
 
467
- Single-flight is enforced keeper-side via a per-`licenseHash` in-flight marker: the second concurrent caller receives a 409 with `retryAfterMs` (surfaced on `ProveMigrateError`) before any wallet prompt, so the buyer never signs a transaction for a live-map that another proof is about to change.
534
+ Single-flight is enforced keeper-side by the shared per-app in-flight slot (see [§Per-app in-flight proving guard](#per-app-in-flight-proving-guard)) — not a per-`licenseHash` marker. The second concurrent caller on the same app receives an immediate `409 { error: "proving queue busy", retryAfterMs, queueDepth }` before any wallet prompt, whether the other request was a buy, renew, or migrate against the same `licensesRoot`. This is stricter than the previous per-`licenseHash` marker: two migrates of *different* licenses on the same app are also serialized, because both mutate the same root.
468
535
 
469
- Terminal 4xx surfaces: 404 "license refunded or unknown" (no leaf in any generation — stop), 409 "already migrated" (leaf already in the current live map — refresh the record).
536
+ Terminal 4xx surfaces (`ProveMigrateError`): 404 "license refunded or unknown" (no leaf in any generation — stop), 409 "already migrated" (leaf already in the current live map — refresh the record).
470
537
 
471
538
  ### `zklicensing/generation` — signed generation-list client
472
539
 
@@ -476,7 +543,7 @@ Wraps `GET /apps/:appId` — every past `zkAppAddress` plus the currently live o
476
543
  import { fetchGenerationInfo } from 'zklicensing/generation';
477
544
 
478
545
  const info = await fetchGenerationInfo({
479
- keeperUrl: 'https://zklicensing.com/api/keeper',
546
+ keeperUrl: 'https://dev.zklicensing.com/api/keeper',
480
547
  appId: 'app_…',
481
548
  pinnedPublicKeysBase64: [/* platform manifest pubkeys, base64 Ed25519 */],
482
549
  });
@@ -491,21 +558,69 @@ Trust model: the keeper is not the trust anchor for this data — it can be re-h
491
558
 
492
559
  Rotation-friendly: the pin list is a set. Multiple pins can be listed during a key rotation window; verification succeeds on the first pin that matches.
493
560
 
494
- Optional out-of-band corroboration: an operator that publishes `https://<vendor-host>/.well-known/zklicensing/<appId>.json` under vendor TLS (with the same keeper-signed payload) can be cross-checked from the on-chain `zkappUri`. Per-app filename because a vendor may host several apps at one origin; the cross-check is opt-in per app — absence of the file (404) is not a failure signal, just "no corroboration published for this app." That's the caller's job, not this module's; the platform signature is the trust anchor.
561
+ Optional out-of-band corroboration: an operator that publishes `https://<vendor-host>/.well-known/zklicensing/<zkAppAddress>.json` under vendor TLS (with the same keeper-signed payload) can be cross-checked from the on-chain `zkappUri`. Per-address filename because a buyer's wallet shows the zkApp address, not the platform appId — keying by address lets any buyer verify from just the address they were prompted to sign, including addresses for retired generations they may still hold licenses under. The cross-check is opt-in per app — absence of the file (404) is not a failure signal, just "no corroboration published for this address." That's the caller's job, not this module's; the platform signature is the trust anchor.
562
+
563
+ #### Publishing the manifest (vendor side)
564
+
565
+ Vendors who want to enable the corroboration path host the same signed payload the keeper returns for their app. The vendor console exposes this as a one-click flow:
566
+
567
+ 1. Open the vendor dashboard, find the app card, and click **Download manifest**. This downloads the exact bytes served by `GET /apps/:appId` — one `<zkAppAddress>.json` file per generation address (current live + every retired address), all byte-identical, Ed25519 signature intact.
568
+ 2. Serve each downloaded file, unchanged, at `https://<vendor-host>/.well-known/zklicensing/<zkAppAddress>.json` under the vendor's TLS certificate. Any static host works; no dynamic logic needed.
569
+ 3. Make sure the app's on-chain `zkappUri` points at `https://<vendor-host>` (the origin — the `.well-known` path is fixed). Callers derive the corroboration URL from that origin plus the address their wallet already showed them.
570
+
571
+ Re-download and re-publish after each retirement or redeploy: the `current` index changes, the `generations[]` list grows by one, and the payload's `issuedAt` timestamp rolls forward — every address file must be replaced with the new signed bytes. Serving a stale copy is not a security failure (the signature still verifies), but callers that compare `current` against the keeper's response will treat the vendor copy as behind.
572
+
573
+ #### `zklicensing-verify-trust` — trust-anchor CLI
574
+
575
+ Ships as a `bin` in this package. Runs the full trust-anchor pipeline for a single zkApp address end-to-end: fetches `.well-known/zklicensing/<zkAppAddress>.json` from the vendor origin, verifies the platform signature against caller-provided pins, asserts the address appears in the signed `generations[]`, then reads the on-chain zkApp and checks that its `zkappUri` binds back to the same origin, that its verification-key hash equals the pinned canonical value for that generation, and that the six-line permission matrix from `LicensingApp.init()` is intact (URI freeze, meta-freeze, VK lock, `send`/`editState` proof-only). Structured findings — no boolean collapse — so vendor CI can pin the exact check that regressed.
576
+
577
+ ```sh
578
+ zklicensing-verify-trust \
579
+ --origin https://store.example.com \
580
+ --address B62q… \
581
+ --pinned-key O5BBGskSzE+BOxgf3rukMiV2e4t0I1oTwi0ERtvbUfA= \
582
+ --mina-graphql https://api.minascan.io/node/mainnet/v1/graphql \
583
+ --cross-check \
584
+ --json
585
+ ```
586
+
587
+ `--cross-check` adds a `manifest_matches_backend` finding: fetches `https://dev.zklicensing.com/apps/by-address/<address>`, verifies its signature against the same `--pinned-key` values (never trusts the backend response blind), and byte-compares the canonicalized payload against the vendor's `.well-known` copy. Catches a vendor serving a stale or rolled-back manifest even when its signature is still valid. Opt-in — adds one extra network fetch and couples the verifier to zklicensing.com being reachable.
588
+
589
+ `--address` is the zkApp address the buyer's wallet displays — the same address they'd see in a purchase or renewal signature prompt. The platform appId is not an input; the CLI reads it out of the signed payload after the signature verifies. Buyers with only a wallet-shown address (including addresses for retired-generation licenses) can self-verify with nothing else. Exit 0 on all-ok, 1 on any check failure, 2 on bad args. Default output is a human summary; `--json` emits the `TrustAnchorReport` for CI. Add `--skip-chain-checks` for offline manifest-only audits (no o1js peer dep required in that mode).
590
+
591
+ > **Badge policy.** Per Vendor Terms §7.7, the zkLicensing trust-anchor badge (and any equivalent "trust anchor by zkLicensing" mark) may be displayed on vendor pages **only while this CLI returns all-`ok`** against your live `origin` and vendor `--address`, with `--pinned-key` sourced from the attestation table at <https://dev.zklicensing.com/trustanchor.html>. If a chain-side or manifest-side check starts failing, remove the badge until compliance is restored. Wiring this CLI into CI (fail the build on non-zero exit) is the simplest way to keep the two in sync.
592
+
593
+ **Where `--pinned-key` comes from.** Read the current platform manifest pubkey(s) off the published trust-anchor page — <https://dev.zklicensing.com/trustanchor.html> lists them alongside each generation's verification-key hash. The same data is served as JSON for CI: <https://dev.zklicensing.com/trustanchor/manifest-pubkeys.json> (and VK hashes at <https://dev.zklicensing.com/trustanchor/vk-hashes.json>). Copy that base64 value into your build config and pass it as `--pinned-key`. **Do not** rely on the pubkey embedded inside the `.well-known` manifest itself — it is the signer's self-declaration and is not trusted; only the caller-pinned list is. Repeat `--pinned-key <b64>` once per key during a rotation window.
495
594
 
496
595
  ### `zklicensing/refund` — refund flow client
497
596
 
498
- Same shape. The backend enforces the 14-day refund window and returns HTTP 409 when it has closed — the error surfaces through the thrown `Error` message, so the SDK doesn't second-guess it locally. Refunds stay open during a sales freeze (that's the whole point of freezing).
597
+ Same shape as buy/renew. The backend enforces the 14-day refund window and returns HTTP 409 when it has closed — the error surfaces through the thrown `Error` message, so the SDK doesn't second-guess it locally. Refunds stay open during a sales freeze (that's the whole point of freezing). Refund shares the per-app in-flight guard with buy/renew/migrate, so `proveRefund` throws `ProveQueueBusyError` on concurrent-prove collisions.
499
598
 
500
599
  ```ts
501
600
  import { proveRefund, confirmRefund } from 'zklicensing/refund';
601
+ import { ProveQueueBusyError } from 'zklicensing/prove-errors';
502
602
 
503
- const { provenTxJson } = await proveRefund({
504
- keeperUrl: 'https://zklicensing.com/api/keeper',
603
+ const proveArgs = {
604
+ keeperUrl: 'https://dev.zklicensing.com/api/keeper',
505
605
  licenseHash: '…',
506
606
  buyerAddress: 'B62q…',
507
607
  zkAppAddress: 'B62q…',
508
- });
608
+ network: 'mainnet' as 'devnet' | 'mainnet', // must match your ProofFile
609
+ };
610
+
611
+ const MAX_ATTEMPTS = 10; // ~15 min slot-by-slot coverage at Mesa/0.4
612
+ let proved;
613
+ for (let n = 1; n <= MAX_ATTEMPTS; n++) {
614
+ try { proved = await proveRefund(proveArgs); break; }
615
+ catch (err) {
616
+ if (err instanceof ProveQueueBusyError && n < MAX_ATTEMPTS) {
617
+ await new Promise(r => setTimeout(r, err.retryAfterMs));
618
+ continue;
619
+ }
620
+ throw err;
621
+ }
622
+ }
623
+ const { provenTxJson } = proved!;
509
624
 
510
625
  // Send `provenTxJson` to the wallet, obtain txHash, then:
511
626
  await confirmRefund({ keeperUrl, zkAppAddress, licenseHash, txHash });
@@ -562,6 +677,49 @@ const breakdown = computeCountryBreakdown(apps, records, currentSlot, nowMs);
562
677
 
563
678
  Purchase revenue attributes to `createdAt` (falling back to slot-derived time for legacy records). Renewals attribute to the renewal slot. Refunded purchases contribute zero revenue but still count as licences.
564
679
 
680
+ ### `zklicensing/prove-errors` — shared typed errors
681
+
682
+ Typed error surface for the per-app in-flight guard shared across `proveBuy`, `proveRenew`, `proveMigrate`, and `proveRefund`. See [Per-app in-flight proving guard](#per-app-in-flight-proving-guard) for the wire contract.
683
+
684
+ ```ts
685
+ import { ProveQueueBusyError } from 'zklicensing/prove-errors';
686
+
687
+ try {
688
+ const proved = await proveBuy(args);
689
+ } catch (err) {
690
+ if (err instanceof ProveQueueBusyError) {
691
+ // err.status === 409
692
+ // err.retryAfterMs — ms to wait before retrying; server drives the cadence
693
+ // err.queueDepth — always 1 in v1 (forward-compat hook for a future queue)
694
+ await new Promise(r => setTimeout(r, err.retryAfterMs));
695
+ // retry — no wallet popup was opened, no fee was paid
696
+ }
697
+ throw err;
698
+ }
699
+ ```
700
+
701
+ Also exports `readErrorBody(resp)` + `tryQueueBusyFromBody(parsed)` — pure helpers used internally by the buy/renew/migrate clients to consume the response body exactly once and then classify it. Available for driver integrations that need to inspect a keeper 409 without re-parsing.
702
+
703
+ ### `zklicensing/tx-broadcast` — broadcast signal
704
+
705
+ Fire-and-forget `POST /tx-broadcast` from the SDK the moment the buyer's wallet returns a tx hash. Extends the in-flight slot from signing-TTL (~90 s) to landing-TTL (~11 min at Mesa/40 %) so the honest broadcast has room to land before the next prove goes through.
706
+
707
+ ```ts
708
+ import { notifyBroadcast } from 'zklicensing/tx-broadcast';
709
+
710
+ // After sendWithWallet returns:
711
+ void notifyBroadcast({
712
+ keeperUrl,
713
+ appId: appKey, // the `appKey` returned by proveBuy / proveRenew / proveMigrate
714
+ txHash,
715
+ });
716
+ ```
717
+
718
+ Contract:
719
+ - **Fire-and-forget.** Network failures are swallowed (optionally reported via `onError`); a missed extension only means the slot self-clears at signing-TTL, at worst delaying the next prove by seconds — not a correctness dependency.
720
+ - **First-come only.** The keeper accepts one extension per slot; a second call for the same slot returns `409 { ok: false, reason: 'already extended' }`. Combined with the corroboration strike (~11 min later, revert on non-observation) this keeps the endpoint DoS-safe despite being unauthenticated.
721
+ - **`appId` = `appKey`.** Slot keys are computed keeper-side (`slotKeyForApp(zkAppAddress, apps)`) — pass the `appKey` from the prove response verbatim rather than trying to reconstruct it.
722
+
565
723
  ### `zklicensing/record` — `LicenseRecord` type
566
724
 
567
725
  The off-chain record shape written by the backend. Import if you're building tooling that consumes the raw store (Node) or that consumes it via the analytics endpoints.
@@ -570,29 +728,81 @@ The off-chain record shape written by the backend. Import if you're building too
570
728
  import type { LicenseRecord, LicenseStatus } from 'zklicensing/record';
571
729
  ```
572
730
 
573
- ### `zklicensing/prove` — ownership `ZkProgram`
731
+ ### `zklicensing/internal/ownershipSignature` — ownership challenge signer
732
+
733
+ `deriveOwnershipKeypair(passphrase, zkAppAddress)` + `signBinding(privateKey, { licenseHash, deviceCommitment, slotIndex, nonce })` — the Pallas Schnorr signer used by the bind/unbind clients to authorize on-chain slot writes. Derivation is bound to the app's zkApp address, so a leaked passphrase for one app cannot bind against any other. Signatures are re-verified IN-CIRCUIT by `Signature.verify()`, so a compromised backend cannot forge a bind or unbind. Runs via `o1js` — the same primitive `licenseHash` was derived from at buy time.
734
+
735
+ ### `zklicensing/verify-offline` — non-custodial offline verify
736
+
737
+ Pure fold verifier. Given a `licensesRoot` you have anchored to, a serialized `MerkleMapWitness`, and the `LicenseLeaf` field set, `verifyOffline` reconstructs the leaf hash, walks the witness back to the root, cross-checks against the anchor, and (when `deviceCap > 0`) folds the device slot array and confirms the caller's device commitment appears in an occupied slot. No network, no service round-trip — the trust question reduces entirely to "where did `licensesRoot` come from?".
738
+
739
+ ```ts
740
+ import { verifyOffline } from 'zklicensing/verify-offline';
741
+
742
+ const result = verifyOffline({
743
+ licensesRoot, // see below — trust anchor
744
+ currentSlot, // Mina slot to check expiry against
745
+ licenseHash,
746
+ expirySlot,
747
+ deviceCap,
748
+ devices, // DeviceSlotSnapshot[]
749
+ witness, // { root, isLefts[], siblings[] } — from GET /witness
750
+ devicePubKey, // optional; required when deviceCap > 0
751
+ });
752
+
753
+ if (!result.valid) throw new Error(result.reason ?? 'invalid');
754
+ ```
755
+
756
+ ### `zklicensing/on-chain-root` — non-custodial trust anchor
757
+
758
+ Reads `licensesRoot` (`zkapp.appState[0]`) directly from Mina via `fetchAccount`. Pair it with `verify-offline` to remove the licensing service from the trust boundary — the service supplies the witness and license record, Mina supplies the root, and the two are cross-checked before any leaf is trusted. `o1js` is a peer dep and imported dynamically so this only costs the o1js payload for callers that actually use it.
574
759
 
575
- `LicenseOwnershipProgram` — the challenge/response proof used by verifiers to bind a proof to a fresh nonce. Loaded on demand by clients that produce ownership proofs (verify itself never imports it).
760
+ ```ts
761
+ import { fetchOnChainLicensesRoot } from 'zklicensing/on-chain-root';
762
+ import { verifyOffline } from 'zklicensing/verify-offline';
763
+
764
+ // 1. Fetch witness + license record from the licensing service.
765
+ const [w, rec] = await Promise.all([
766
+ fetch(`${service}/witness/${zkApp}/${licenseHash}`).then(r => r.json()),
767
+ fetch(`${service}/license/${zkApp}/${licenseHash}?network=mainnet`).then(r => r.json()),
768
+ ]);
769
+
770
+ // 2. Fetch the on-chain root INDEPENDENTLY.
771
+ const onChainRoot = await fetchOnChainLicensesRoot({ zkAppAddress: zkApp, network: 'mainnet' });
772
+
773
+ // 3. Cross-check. If they disagree the service is stale/wrong; bail.
774
+ if (w.root !== onChainRoot) throw new Error('service root disagrees with chain');
775
+
776
+ // 4. Fold-verify.
777
+ const res = verifyOffline({
778
+ licensesRoot: onChainRoot,
779
+ currentSlot,
780
+ licenseHash,
781
+ expirySlot: rec.expirySlot,
782
+ deviceCap: rec.deviceCap ?? 0,
783
+ devices: rec.devices ?? [],
784
+ witness: w,
785
+ });
786
+ ```
576
787
 
577
- ### Verify service — ownership tokens & concurrent-device cap
788
+ A worked end-to-end example — proof.json paste box, three-way fetch, cross-check, verdict — lives at `packages/sdk/examples/verify.html` alongside the buy / renew / refund / bind / unbind pages.
578
789
 
579
- The verify service issues an HMAC-SHA256 bearer **token** through a challenge/response flow: `GET /challenge?licenseHash=…` returns a fresh nonce, the client produces a `LicenseProof` binding `(licenseHash, nonce)`, `POST /respond` verifies the proof and returns `{ token, expiresAt, jti, activeSessions, deviceLimit }`. Token TTL is 7 days; the client renews in the background via `POST /refresh` while the app is running so the user only redoes the passphrase-gated `/respond` after a full week of inactivity or an explicit logout. Passing `token` on subsequent `GET /?licenseHash=…&zkAppAddress=…&token=…` calls upgrades the response from "on-chain state" to "verified caller owns this license." Tokens carry a random `jti` (JWT-style unique id) that ties them to an in-memory session-store row so revocation and concurrency caps are enforceable on top of the stateless HMAC.
790
+ Two sourcing modes for `licensesRoot`:
580
791
 
581
- The AppRecord field `maxConcurrentDevices` (register-time or dashboard-editable, `0` = unlimited, capped at 10,000) is the per-license active-device cap. `POST /respond` reserves a session slot for the newly minted `jti` before signing; when the cap is reached and no expired session is available, it returns `429` with `{ activeSessions, deviceLimit }`. The store also prunes on TTL, so the cap is self-recovering.
792
+ - **Non-custodial (recommended).** Fetch it via `fetchOnChainLicensesRoot`. The service never gets to say "trust me, this leaf is real" — a service serving a stale or forged witness fails the cross-check and the leaf is refused.
793
+ - **Custodial (trusted-service).** Reuse the `root` field the service returned on its `/verify` or `/witness` response. Faster (no o1js + Mina RPC round-trip) but relies on the service to report the true chain state. Fine for callers that already trust the service; not the recommended default.
582
794
 
583
- Three lifecycle endpoints back the cap:
795
+ Default public Mina GraphQL endpoints are wired in (`DEFAULT_MINA_GRAPHQL_URLS.mainnet` / `.devnet`); pass `graphqlUrl` to point at your own node.
584
796
 
585
- - **`POST /refresh`** — body `{ token }`. Verifies HMAC + `jti` still active, reissues a token with the same `jti` and a fresh expiry. No LicenseProof needed. Used by running apps to keep sessions warm across the 7-day TTL. If the seat was released in the meantime, returns `401 Seat released`.
586
- - **`POST /releaseSeat`** — body `{ token }`. Releases the seat identified by the token's `jti`. Idempotent. Same threat model as any bearer token: possession is authorization. Enables an in-app "Log this device out" button.
587
- - **`POST /reset-sessions`** — body `{ zkAppAddress, proof }`. Drops **all** sessions for the license proved by the `LicenseProof`. Requires the same passphrase-gated challenge/response as `/respond`, so only the license owner can invoke it. Intended as a self-service recovery for owners who hit the cap after an app reinstall or a lost device didn't `POST /releaseSeat` on the way out. The caller runs a fresh `/respond` afterwards to re-provision under a zero active count.
797
+ ### Verify service — on-chain state only
588
798
 
589
- Sessions are in-memory by design — a service restart drops every session, forcing devices to re-prove. That is intentional: persisted sessions would let a stolen token bypass the cap across restarts with no signal to the license owner.
799
+ The verify service is a stateless mirror of on-chain license state. `GET /verify?licenseHash=…&zkAppAddress=…` returns `{ valid, expirySlot, inGracePeriod, reason, currentSlot, purchaseSlot }`. No session tokens, no HMAC-signed bearer state, no challenge/response — the verifier reads the archive and reports. Device-cap enforcement lives entirely on-chain via the [Non-custodial device binding](#non-custodial-device-binding) flow: the buyer's `bind()` / `unbind()` transactions write into the `LicenseLeaf`'s slot array, and offline verification checks the local device commitment against the cached leaf. There is no server-side seat concept the verifier or keeper can revoke; the only party who can add or remove a device commitment is whoever holds the passphrase-derived ownership private key.
590
800
 
591
801
  ## Documentation
592
802
 
593
- - Integration walkthrough — [zklicensing.com/docs](https://zklicensing.com/docs)
594
- - FAQ — [zklicensing.com/faq](https://zklicensing.com/faq)
595
- - Register an app — [zklicensing.com/register](https://zklicensing.com/register)
803
+ - Integration walkthrough — [dev.zklicensing.com/docs.html](https://dev.zklicensing.com/docs.html)
804
+ - FAQ — [dev.zklicensing.com/faq.html](https://dev.zklicensing.com/faq.html)
805
+ - Register an app — [dev.zklicensing.com/register.html](https://dev.zklicensing.com/register.html)
596
806
 
597
807
  ## Licence
598
808