zklicensing 0.1.0-beta.0

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 (41) hide show
  1. package/LICENSE +104 -0
  2. package/README.md +603 -0
  3. package/build/archiveBootstrap.d.ts +60 -0
  4. package/build/archiveBootstrap.js +234 -0
  5. package/build/buyClient.d.ts +58 -0
  6. package/build/buyClient.js +162 -0
  7. package/build/contractInterface.d.ts +55 -0
  8. package/build/contractInterface.js +213 -0
  9. package/build/countryStats.d.ts +37 -0
  10. package/build/countryStats.js +89 -0
  11. package/build/deployClient.d.ts +168 -0
  12. package/build/deployClient.js +172 -0
  13. package/build/freezeClient.d.ts +44 -0
  14. package/build/freezeClient.js +59 -0
  15. package/build/generationClient.d.ts +54 -0
  16. package/build/generationClient.js +113 -0
  17. package/build/index.d.ts +72 -0
  18. package/build/index.js +258 -0
  19. package/build/licenseProof.d.ts +26 -0
  20. package/build/licenseProof.js +41 -0
  21. package/build/licenseRecord.d.ts +39 -0
  22. package/build/licenseRecord.js +6 -0
  23. package/build/licenseStore.d.ts +49 -0
  24. package/build/licenseStore.js +303 -0
  25. package/build/manifestVerify.d.ts +30 -0
  26. package/build/manifestVerify.js +95 -0
  27. package/build/migrateClient.d.ts +43 -0
  28. package/build/migrateClient.js +47 -0
  29. package/build/ownership.d.ts +34 -0
  30. package/build/ownership.js +147 -0
  31. package/build/refundClient.d.ts +52 -0
  32. package/build/refundClient.js +92 -0
  33. package/build/renewClient.d.ts +49 -0
  34. package/build/renewClient.js +135 -0
  35. package/build/statsClient.d.ts +96 -0
  36. package/build/statsClient.js +28 -0
  37. package/build/verifyCore.d.ts +26 -0
  38. package/build/verifyCore.js +57 -0
  39. package/build/verifyService.d.ts +3 -0
  40. package/build/verifyService.js +690 -0
  41. package/package.json +118 -0
package/README.md ADDED
@@ -0,0 +1,603 @@
1
+ # zklicensing
2
+
3
+ Client SDK for verifying [zkLicensing](https://zklicensing.com) software licenses.
4
+
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
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install zklicensing
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ import { verifyLicense } from 'zklicensing';
17
+
18
+ // `proof` is the JSON the buyer downloaded after purchase
19
+ const result = await verifyLicense({
20
+ licenseHash: proof.licenseHash,
21
+ zkAppAddress: proof.zkAppAddress, // your deployed zkApp address (B62q…)
22
+ expirySlot: proof.expirySlot,
23
+ purchaseSlot: proof.purchaseSlot,
24
+ verifierUrl: 'https://zklicensing.com/api/verify', // hosted verify service
25
+ });
26
+
27
+ if (result.valid) {
28
+ unlockFeatures();
29
+ }
30
+ ```
31
+
32
+ `result.source` is `'chain'` while the SDK is still calling the verifier, and `'offline'` after the anchor has graduated.
33
+
34
+ ## How it works
35
+
36
+ ```
37
+ purchase ─────► verifier (every call) ─────► anchor written ─────► offline only
38
+ │ │
39
+ │◄── ~14 days, refund window ───────►│
40
+ ```
41
+
42
+ 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
+
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.
47
+
48
+ 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
+
50
+ ### Clock-warp safety
51
+
52
+ Only the verifier's reported `currentSlot` can graduate the anchor — a buyer who winds their local clock forward cannot force the offline branch prematurely.
53
+
54
+ ## API
55
+
56
+ ```ts
57
+ type ProofInput = {
58
+ licenseHash: string; // Poseidon hash identifying the licence on-chain
59
+ zkAppAddress: string; // Your deployed zkApp's Mina address (B62q…)
60
+ 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
+ };
64
+
65
+ type VerifyOptions = {
66
+ verifierUrl?: string;
67
+ storage?: Storage | null; // default: globalThis.localStorage; null disables anchoring
68
+ fetcher?: typeof fetch; // default: globalThis.fetch
69
+ now?: () => number; // default: Date.now
70
+ };
71
+
72
+ type VerifyResult = {
73
+ valid: boolean;
74
+ expirySlot: number;
75
+ inGracePeriod: boolean;
76
+ reason: string | null;
77
+ source: 'chain' | 'offline';
78
+ };
79
+
80
+ function verifyLicense(proof: ProofInput, options?: VerifyOptions): Promise<VerifyResult>;
81
+ ```
82
+
83
+ The package also re-exports the three constants the SDK uses internally:
84
+
85
+ ```ts
86
+ import { REFUND_WINDOW_N, GRACE_PERIOD_N, MS_PER_SLOT } from 'zklicensing';
87
+ // 13_440, 6_720, 90_000
88
+ ```
89
+
90
+ ## Node storage adapter
91
+
92
+ 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:
93
+
94
+ ```ts
95
+ import fs from 'node:fs';
96
+ import { verifyLicense } from 'zklicensing';
97
+
98
+ const ANCHOR_FILE = '/var/lib/myapp/anchor.json';
99
+ const storage = {
100
+ getItem: (k: string) => {
101
+ try { return JSON.parse(fs.readFileSync(ANCHOR_FILE, 'utf8'))[k] ?? null; }
102
+ catch { return null; }
103
+ },
104
+ setItem: (k: string, v: string) => {
105
+ const s = (() => { try { return JSON.parse(fs.readFileSync(ANCHOR_FILE, 'utf8')); } catch { return {}; } })();
106
+ s[k] = v;
107
+ fs.writeFileSync(ANCHOR_FILE, JSON.stringify(s));
108
+ },
109
+ };
110
+
111
+ await verifyLicense(proof, { storage });
112
+ ```
113
+
114
+ Pass `storage: null` to disable the offline path entirely (every call hits the verifier).
115
+
116
+ ## Verifier response contract
117
+
118
+ If you self-host the verification endpoint, it must return:
119
+
120
+ ```ts
121
+ {
122
+ valid: boolean,
123
+ expirySlot: number,
124
+ inGracePeriod: boolean,
125
+ reason: string | null,
126
+ currentSlot: number, // ← SDK reads this to decide when to anchor
127
+ purchaseSlot: number,
128
+ }
129
+ ```
130
+
131
+ `currentSlot` and `purchaseSlot` are the keys to the anchor logic — they must be present for the offline branch to activate.
132
+
133
+ For non-JavaScript clients (Android, iOS, Python, …) call the verifier's `GET /verify` directly and replicate the same anchor logic in idiomatic platform storage.
134
+
135
+ ## Platform examples
136
+
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`.
141
+
142
+ ### Android (Kotlin)
143
+
144
+ ```kotlin
145
+ // Unlimited — no token
146
+ val licenseHash = prefs.getString("licenseHash", "") ?: ""
147
+ val zkApp = "B62q…"
148
+ val url = "https://zklicensing.com/api/verify" +
149
+ "?licenseHash=$licenseHash&zkAppAddress=$zkApp"
150
+ val body = OkHttpClient().newCall(Request.Builder().url(url).build())
151
+ .execute().body?.string() ?: "{}"
152
+ val r = JSONObject(body)
153
+ 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
+ ```
160
+
161
+ ### iOS (Swift)
162
+
163
+ ```swift
164
+ // Unlimited — no token
165
+ let zkApp = "B62q…"
166
+ var c = URLComponents(string: "https://zklicensing.com/api/verify")!
167
+ c.queryItems = [
168
+ URLQueryItem(name: "licenseHash", value: licenseHash),
169
+ URLQueryItem(name: "zkAppAddress", value: zkApp),
170
+ ]
171
+ let (data, _) = try await URLSession.shared.data(from: c.url!)
172
+ let r = try JSONDecoder().decode(VerifyResult.self, from: data)
173
+ 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
+ ```
180
+
181
+ ### Browser / PWA
182
+
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
+ ```
209
+
210
+ ## Quick Start: Building a vendor site
211
+
212
+ Three drop-in pages, one SDK. Fork them, restyle, ship.
213
+
214
+ - **`examples/buy.html`** — passphrase → wallet sign → licence file.
215
+ - **`examples/renew.html`** — one click, one wallet prompt.
216
+ - **`examples/refund.html`** — 14-day window, handled.
217
+
218
+ Each page is a self-contained HTML + TS pair calling one SDK subpath.
219
+
220
+ **Run them:**
221
+
222
+ ```bash
223
+ # 1. Register your app and download apps.json
224
+ open https://zklicensing.com/register.html # click "Download apps.json"
225
+
226
+ # 2. Drop apps.json into the examples/ directory
227
+ mv ~/Downloads/apps.json packages/sdk/examples/
228
+
229
+ # 3. Install + dev
230
+ cd packages/sdk/examples
231
+ npm install
232
+ npm run dev
233
+ ```
234
+
235
+ Vite serves `index.html`, `buy.html`, `renew.html`, and `refund.html` at
236
+ `http://localhost:5173`. The pages `fetch('/apps.json')` for their config —
237
+ no `.env` file needed. Ship them as static assets with `npm run build`.
238
+
239
+ Wallet support: Auro browser extension out of the box; add a
240
+ [Reown](https://dashboard.reown.com) (formerly WalletConnect Cloud) project ID
241
+ via `VITE_WALLETCONNECT_PROJECT_ID` to enable the mobile QR fallback.
242
+
243
+ ## Subpaths
244
+
245
+ 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
+
247
+ ### `zklicensing/deploy` — deploy flow client
248
+
249
+ 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.
250
+
251
+ ```ts
252
+ import { proveDeploy, pollDeployStatus } from 'zklicensing/deploy';
253
+
254
+ const { provenTxJson, zkAppAddress, verificationKeyHash } = await proveDeploy({
255
+ keeperUrl: 'https://zklicensing.com/api/keeper',
256
+ senderPublicKey: 'B62q…', // vendor's fee-payer wallet
257
+ vendorAddress: 'B62q…',
258
+ priceMonthlyMina: 1,
259
+ priceYearlyMina: 10,
260
+ priceFiveYearMina: 40,
261
+ });
262
+
263
+ // Send `provenTxJson` to the wallet for signing, obtain txHash, register the
264
+ // app via /register/app, then optionally watch inclusion:
265
+ const status = await pollDeployStatus({ keeperUrl, zkAppAddress });
266
+ ```
267
+
268
+ The SDK does not expose a raw `initialize()` helper. `initialize()` sets `vendor` on-chain and is guarded only by a `vendor.requireEquals(PublicKey.empty())` precondition — no in-circuit signature check (adding one collides with the proof-based `editState` permission that `deploy()` installs). Front-running is prevented operationally instead: the backend puts `deploy()` and `initialize()` in the same `Mina.transaction`, and the throwaway zkApp keypair is generated inside the handler and dropped at return, so the target address is not observable to an attacker until the initialize AU is already signed and locked to `(vendor, prices)`. Drivers bypassing `proveDeploy` are responsible for the same discipline.
269
+
270
+ #### Post-hardfork redeploy
271
+
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:
301
+
302
+ ```ts
303
+ import {
304
+ getVendorRedeployChallenge,
305
+ proveVendorRedeploy,
306
+ registerVendorRedeploy,
307
+ pollDeployStatus,
308
+ } from 'zklicensing/deploy';
309
+
310
+ // 1. Ask the backend for a single-use signing challenge.
311
+ const { nonce } = await getVendorRedeployChallenge({ keeperUrl, appId });
312
+
313
+ // 2. Vendor's wallet signs the nonce (Auro / Mina wallet's mina_signMessage
314
+ // or the equivalent that returns a Signature.toObject() form).
315
+ const signature = await wallet.signMessage(nonce); // { field, scalar }
316
+
317
+ // 3. Backend verifies signature against on-chain vendor, runs all redeploy
318
+ // guards, generates a throwaway zkApp keypair, and returns the proven tx
319
+ // for the wallet to sign + broadcast.
320
+ const { provenTxJson, zkAppAddress, redeployId } = await proveVendorRedeploy({
321
+ keeperUrl, appId,
322
+ nonce,
323
+ signature,
324
+ senderPublicKey: vendorAddress, // fee payer
325
+ // force: true, // optional — bypass escrow-empty guard
326
+ });
327
+
328
+ // 4. Wallet adds the fee-payer signature and broadcasts (same as proveDeploy).
329
+ const { hash: txHash } = await wallet.sendTransaction({ transaction: provenTxJson });
330
+
331
+ // 5. Register the pending redeploy so the backend arms its deploy-watcher.
332
+ // On landing, the backend promotes the new deployment onto the AppRecord
333
+ // (push old to deploymentHistory[], overwrite live fields, clear sync cursors).
334
+ await registerVendorRedeploy({ keeperUrl, appId, redeployId, txHash });
335
+
336
+ // 6. Poll for landing.
337
+ await pollDeployStatus({ keeperUrl, zkAppAddress });
338
+ ```
339
+
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`.
341
+
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.
343
+
344
+ > **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
+
346
+ #### Per-buyer generation list
347
+
348
+ `GET /apps/:appId` returns the full deployment lineage of an app — every retired zkApp address plus the currently live one, each tagged with a monotonic `gen` and a captured `frozenRoot` (the license Merkle root at retirement). Response shape:
349
+
350
+ ```ts
351
+ {
352
+ appId: string,
353
+ current: number, // gen of the live deployment
354
+ generations: Array<{
355
+ gen: number, // 1-indexed
356
+ zkAppAddress: string,
357
+ status: 'retired' | 'live',
358
+ frozenRoot?: string, // captured at retirement — absent on live
359
+ deployedAt?: string,
360
+ retiredAt?: string,
361
+ }>
362
+ }
363
+ ```
364
+
365
+ 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
+
367
+ 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
+
369
+ **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.
370
+
371
+ Snapshot atomicity: `finalizeRedeploy` gates a retiring generation from all `/prove/*` handlers **before** draining the tolerance window, capturing `frozenRoot`, and persisting — so any buyer-held `provenTxJson` proven against the retiring tree becomes unlandable inside the drain window rather than accidentally landing after the ancestor root is composed.
372
+
373
+ Never trusts `current` blindly: renew always continues against the licence's own stored `zkAppAddress` unless the buyer explicitly opts into the migrate flow. This means a compromised or misreporting keeper cannot redirect a renew to an address the buyer didn't already receive at purchase time. A verified signature is required to even enable the "Migrate & Renew" button.
374
+
375
+ ### `zklicensing/buy` — buy flow client
376
+
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).
378
+
379
+ ```ts
380
+ import { deriveBuyIdentity, proveBuy, confirmBuy, pollBuyStatus } from 'zklicensing/buy';
381
+
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
+ });
397
+
398
+ // 3. Send `provenTxJson` to the buyer's wallet for signing, obtain txHash.
399
+
400
+ // 4. Persist the pending record — the caller drives this only after the wallet returned.
401
+ await confirmBuy({ keeperUrl, zkAppAddress, licenseHash, txHash });
402
+
403
+ // Optional: watch for on-chain inclusion.
404
+ const status = await pollBuyStatus({ keeperUrl, licenseHash });
405
+ ```
406
+
407
+ The SDK never touches a wallet — the caller drives the sign step between `proveBuy` and `confirmBuy` — so this client works for any wallet integration (Auro, WalletConnect, mobile, CLI).
408
+
409
+ `proveBuy` also refuses locally when the backend's sales freeze is in effect (fetched from `GET /health`) — the request would 423 anyway, so surfacing the message before the round-trip saves a wasted call. See `zklicensing/freeze` below.
410
+
411
+ **No-escrow routing.** When the buyer's advertised 14-day refund window would land past `FREEZE_AFTER_SLOT` (checked as `currentSlot + REFUND_WINDOW_N + TOLERANCE_WINDOW_N > FREEZE_AFTER_SLOT`), `proveBuy` transparently switches to `POST /prove/buy-no-escrow` — the backend endpoint for a distinct circuit method (`buyLicenseNoEscrow`) that pays vendor + platform directly at buy time (98 / 2, same as `renewLicense`) and never writes an escrow entry. This is the mechanism that makes the pre-hardfork waiver materially real: with no escrow entry there is nothing for the freeze-window migration guard to strand, and no refund path exists at all (a refund would fail the `escrowCommitment` check by construction). Passing `refundWaiverAccepted: true` is mandatory on those routed calls; the backend refuses the endpoint without it. When the backend hasn't yet been redeployed with a build that offers `/prove/buy-no-escrow`, the client throws an actionable message rather than silently falling back to an escrow-backed buy that would strand funds — vendors on old backend builds must complete their §12.13 redeploy before the overlap-window sales resume.
412
+
413
+ ### `zklicensing/renew` — renew flow client
414
+
415
+ Same shape as `zklicensing/buy`. `proveRenew` takes an explicit `waiverAccepted` flag and refuses locally when it's not `true` — that's the EU/UK CRD Article 16(m) waiver (consent to immediate delivery + acknowledgment of loss of the 14-day withdrawal right) which must be an express affirmative act from the buyer. Integrators wiring a renewal UI from a different surface than `renew.html` must expose the same unticked-by-default checkbox and pass its state through.
416
+
417
+ ```ts
418
+ import { proveRenew, confirmRenew } from 'zklicensing/renew';
419
+
420
+ const { provenTxJson, newExpirySlot, renewAmount } = await proveRenew({
421
+ keeperUrl: 'https://zklicensing.com/api/keeper',
422
+ licenseHash: '…',
423
+ buyerAddress: 'B62q…',
424
+ zkAppAddress: 'B62q…',
425
+ duration: 0, // 0 = Monthly, 1 = Yearly, 2 = 5 Years
426
+ waiverAccepted: true, // required — throws before the network call if false
427
+ });
428
+
429
+ // Send `provenTxJson` to the wallet, obtain txHash, then:
430
+ await confirmRenew({ keeperUrl, zkAppAddress, licenseHash, txHash });
431
+ ```
432
+
433
+ `proveRenew` also refuses locally when the backend's sales freeze is in effect (fetched from `GET /health`), for the same reason as `proveBuy`.
434
+
435
+ ### `zklicensing/migrate` — cross-generation migrate client
436
+
437
+ Wraps `POST /prove/migrate`. The keeper locates the buyer's licence in the freshest ancestor generation where it still exists, assembles the three witnesses (ancestor / predecessor / current-live-empty), and returns a signable transaction that copies the expiry verbatim into the current live map at zero payment.
438
+
439
+ **Full "Migrate & Renew" sequence** (two txs, one wallet sign each):
440
+
441
+ 1. `fetchGenerationInfo` (see `zklicensing/generation`) — pin-verified signed lineage. If the licence's `zkAppAddress` is retired, present the buyer with a blocking "Migrate & Renew" step.
442
+ 2. `proveMigrate` → wallet signs `provenTxJson` → broadcast → poll landing on the current `zkAppAddress`.
443
+ 3. `proveRenew` (see `zklicensing/renew`) against the current `zkAppAddress` → wallet signs → broadcast → poll → rewrite `ProofFile` with the new `{ generation, zkAppAddress, expirySlot }`.
444
+
445
+ The keeper 409s `/prove/renew` and `/prove/buy*` for any retired `zkAppAddress`, so skipping step 2 is not an option.
446
+
447
+ ```ts
448
+ import { proveMigrate, ProveMigrateError } from 'zklicensing/migrate';
449
+
450
+ try {
451
+ const { provenTxJson, ancestorGen, expirySlot, zkAppAddress } = await proveMigrate({
452
+ keeperUrl: 'https://zklicensing.com/api/keeper',
453
+ licenseHash: '…',
454
+ appId: 'app_…',
455
+ buyerAddress: 'B62q…', // fee payer (buyer-signed mode)
456
+ });
457
+ // Wallet signs provenTxJson → broadcast → poll landing → resume with renew.
458
+ } 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.
462
+ }
463
+ throw e;
464
+ }
465
+ ```
466
+
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.
468
+
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).
470
+
471
+ ### `zklicensing/generation` — signed generation-list client
472
+
473
+ Wraps `GET /apps/:appId` — every past `zkAppAddress` plus the currently live one, with the response's Ed25519 signature verified against a caller-supplied pin list of platform manifest public keys.
474
+
475
+ ```ts
476
+ import { fetchGenerationInfo } from 'zklicensing/generation';
477
+
478
+ const info = await fetchGenerationInfo({
479
+ keeperUrl: 'https://zklicensing.com/api/keeper',
480
+ appId: 'app_…',
481
+ pinnedPublicKeysBase64: [/* platform manifest pubkeys, base64 Ed25519 */],
482
+ });
483
+
484
+ if (info.verified) {
485
+ // info.current is the live generation index; info.generations lists all with
486
+ // { gen, zkAppAddress, status: 'retired' | 'live', frozenRoot?, deployedAt?, retiredAt? }
487
+ }
488
+ ```
489
+
490
+ Trust model: the keeper is not the trust anchor for this data — it can be re-hosted, mirrored, cached, or (worst case) impersonated. The pin list is. On any failure — missing signature, wrong pubkey, tampered payload, empty pin list — the client returns `{ current: null, generations: [], verified: false }` so callers cannot accidentally route a wallet prompt to an unverified address.
491
+
492
+ 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
+
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.
495
+
496
+ ### `zklicensing/refund` — refund flow client
497
+
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).
499
+
500
+ ```ts
501
+ import { proveRefund, confirmRefund } from 'zklicensing/refund';
502
+
503
+ const { provenTxJson } = await proveRefund({
504
+ keeperUrl: 'https://zklicensing.com/api/keeper',
505
+ licenseHash: '…',
506
+ buyerAddress: 'B62q…',
507
+ zkAppAddress: 'B62q…',
508
+ });
509
+
510
+ // Send `provenTxJson` to the wallet, obtain txHash, then:
511
+ await confirmRefund({ keeperUrl, zkAppAddress, licenseHash, txHash });
512
+ ```
513
+
514
+ ### `zklicensing/freeze` — sales-freeze state
515
+
516
+ Read helpers for the backend's sales-freeze schedule. A platform operator sets `FREEZE_AFTER_SLOT` on the backend ahead of a hardfork migration; from that slot on the backend refuses new buys and renewals so open escrow can drain. Refunds, releases, and verify stay open throughout.
517
+
518
+ ```ts
519
+ import { getKeeperHealth, resolveFreezeState } from 'zklicensing/freeze';
520
+
521
+ // Raw /health payload — status, network, current slot, plus freeze state.
522
+ const h = await getKeeperHealth({ keeperUrl });
523
+
524
+ // Effective freeze state for the caller. Resolves in this precedence:
525
+ // 1. `freezeAfterSlot` override arg (if > 0)
526
+ // 2. `process.env.FREEZE_AFTER_SLOT` (Node only)
527
+ // 3. GET /health (authoritative fallback — always fetched fresh)
528
+ const { salesFrozen, freezeAfterSlot } = await resolveFreezeState({ keeperUrl });
529
+ ```
530
+
531
+ `salesFrozen` computed vs. an authoritative slot is only available on the `/health` path — the override / env paths return `salesFrozen: false` and let the backend reject if the freeze has fired. This is fine because `proveBuy` / `proveRenew` also fall back on the backend's own 423 response.
532
+
533
+ The buy client requires an express **`refundWaiverAccepted`** boolean whenever the buyer's advertised refund window would overlap `freezeAfterSlot` — the scheduled hardfork can truncate that window, so their express acknowledgment is a legal precondition. UIs must expose this as an unticked-by-default checkbox, matching the Article 16(m) renewal waiver. Under the hood the SDK routes waivered overlap-buys to `POST /prove/buy-no-escrow` (the `buyLicenseNoEscrow` circuit method), which pays vendor + platform directly at buy time so no funds sit in escrow across the freeze; buys whose window ends before the freeze proceed through the standard escrow-backed `POST /prove/buy` and no waiver is required.
534
+
535
+ No SDK restart or user intervention is required to pick up freeze changes — `resolveFreezeState` re-reads env and re-fetches `/health` on every call.
536
+
537
+ ### `zklicensing/stats` — vendor dashboard client
538
+
539
+ Fetch helpers for the backend's vendor-facing analytics endpoints. Types mirror the backend's response shape so a dashboard implementation doesn't have to re-derive them.
540
+
541
+ ```ts
542
+ import { fetchVendorStats, fetchTransactions, fetchVendorApps } from 'zklicensing/stats';
543
+
544
+ const stats = await fetchVendorStats({ keeperUrl, vendorAddress });
545
+ // stats.totalRevenueMina, .activeLicenses, .monthlyRevenueMina[], .licensesByTier, .countryBreakdown
546
+ const txs = await fetchTransactions({ keeperUrl, vendorAddress, days: 30 });
547
+ const apps = await fetchVendorApps({ keeperUrl, vendorAddress });
548
+ ```
549
+
550
+ Each helper returns `null` on non-OK response so a degraded backend doesn't tear the dashboard down.
551
+
552
+ ### `zklicensing/country-stats` — buyer-country breakdown
553
+
554
+ Pure computation over `LicenseRecord[]`, bucketed by UTC calendar year and structured for tax reporting (one row per `year × appId × countryCode`). Used by both the backend (for `/stats.countryBreakdown`) and any downstream dashboard, so numbers can never drift between them.
555
+
556
+ ```ts
557
+ import { computeCountryBreakdown } from 'zklicensing/country-stats';
558
+
559
+ const breakdown = computeCountryBreakdown(apps, records, currentSlot, nowMs);
560
+ // { years: [2026, 2025, …], byYear: { 2026: { aggregate: { FI: { count, revenueMina }, … }, byApp: { … } }, … } }
561
+ ```
562
+
563
+ 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
+
565
+ ### `zklicensing/record` — `LicenseRecord` type
566
+
567
+ 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.
568
+
569
+ ```ts
570
+ import type { LicenseRecord, LicenseStatus } from 'zklicensing/record';
571
+ ```
572
+
573
+ ### `zklicensing/prove` — ownership `ZkProgram`
574
+
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).
576
+
577
+ ### Verify service — ownership tokens & concurrent-device cap
578
+
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.
580
+
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.
582
+
583
+ Three lifecycle endpoints back the cap:
584
+
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.
588
+
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.
590
+
591
+ ## Documentation
592
+
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)
596
+
597
+ ## Licence
598
+
599
+ Business Source License 1.1 (BUSL-1.1). See [LICENSE](./LICENSE) for the full text.
600
+
601
+ The Additional Use Grant permits production use of the SDK to build, distribute, and operate applications and services that consume, verify, purchase, renew, refund, or migrate licences issued via the zkLicensing backend protocol. It does not permit operating a competing licensing backend for third parties.
602
+
603
+ On **2030-07-11** (or four years after this version's first distribution, whichever comes first) the licence automatically converts to Apache License 2.0.
@@ -0,0 +1,60 @@
1
+ export declare const SYNC_CHUNK_SLOTS = 10000;
2
+ export declare const MAX_LOOKBACK_SLOTS = 525600;
3
+ export type Tier = 'monthly' | 'yearly' | 'fiveYear';
4
+ export type NormalizedEvent = {
5
+ kind: 'licenseIssued';
6
+ slot: number;
7
+ licenseHash: string;
8
+ amount: bigint;
9
+ issuedSlot: number;
10
+ expirySlot: number;
11
+ } | {
12
+ kind: 'licenseIssuedNoEscrow';
13
+ slot: number;
14
+ licenseHash: string;
15
+ amount: bigint;
16
+ issuedSlot: number;
17
+ expirySlot: number;
18
+ } | {
19
+ kind: 'licenseRenewed';
20
+ slot: number;
21
+ licenseHash: string;
22
+ newExpiry: number;
23
+ } | {
24
+ kind: 'licenseMigrated';
25
+ slot: number;
26
+ licenseHash: string;
27
+ expirySlot: number;
28
+ ancestorGen: number;
29
+ } | {
30
+ kind: 'refundIssued';
31
+ slot: number;
32
+ licenseHash: string;
33
+ } | {
34
+ kind: 'fundsReleased';
35
+ slot: number;
36
+ licenseHash: string;
37
+ } | {
38
+ kind: 'priceUpdated';
39
+ slot: number;
40
+ priceMonthly: bigint;
41
+ priceYearly: bigint;
42
+ priceFiveYear: bigint;
43
+ };
44
+ export declare function tierFromExpiryDelta(slots: number): Tier | null;
45
+ export type FetchRange = {
46
+ from?: number;
47
+ to?: number;
48
+ };
49
+ export type BootstrapResult = {
50
+ maxSeenSlot: number;
51
+ events: NormalizedEvent[];
52
+ };
53
+ export declare function bootstrapFromArchive(zkAppAddress: string, range?: FetchRange): Promise<BootstrapResult>;
54
+ export declare function applyEventsToStore(zkAppAddress: string, events: NormalizedEvent[]): Promise<{
55
+ count: number;
56
+ warnings: string[];
57
+ }>;
58
+ export declare function discoverAppCreatedSlot(zkAppAddress: string, nowSlot: number, lookbackSlots?: number): Promise<number | null>;
59
+ export declare function lazyBootstrap(zkAppAddress: string): Promise<void>;
60
+ //# sourceMappingURL=archiveBootstrap.d.ts.map