zklicensing 0.1.0-beta.2 → 0.1.0-beta.3

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 (111) hide show
  1. package/README.md +132 -23
  2. package/build/archiveBootstrap.d.ts.map +1 -0
  3. package/build/archiveBootstrap.js +1 -1
  4. package/build/archiveBootstrap.js.map +1 -0
  5. package/build/buyClient.d.ts +2 -0
  6. package/build/buyClient.d.ts.map +1 -0
  7. package/build/buyClient.js +8 -2
  8. package/build/buyClient.js.map +1 -0
  9. package/build/contractInterface.d.ts.map +1 -0
  10. package/build/contractInterface.js +1 -1
  11. package/build/contractInterface.js.map +1 -0
  12. package/build/countryStats.d.ts +6 -1
  13. package/build/countryStats.d.ts.map +1 -0
  14. package/build/countryStats.js +33 -9
  15. package/build/countryStats.js.map +1 -0
  16. package/build/deployClient.d.ts.map +1 -0
  17. package/build/deployClient.js +1 -1
  18. package/build/deployClient.js.map +1 -0
  19. package/build/freezeClient.d.ts.map +1 -0
  20. package/build/freezeClient.js +1 -1
  21. package/build/freezeClient.js.map +1 -0
  22. package/build/generationClient.d.ts.map +1 -0
  23. package/build/generationClient.js +1 -1
  24. package/build/generationClient.js.map +1 -0
  25. package/build/index.d.ts.map +1 -0
  26. package/build/index.js +1 -1
  27. package/build/index.js.map +1 -0
  28. package/build/licenseProof.d.ts.map +1 -0
  29. package/build/licenseProof.js +1 -1
  30. package/build/licenseProof.js.map +1 -0
  31. package/build/licenseRecord.d.ts +10 -0
  32. package/build/licenseRecord.d.ts.map +1 -0
  33. package/build/licenseRecord.js +1 -1
  34. package/build/licenseRecord.js.map +1 -0
  35. package/build/licenseStore.d.ts +1 -1
  36. package/build/licenseStore.d.ts.map +1 -0
  37. package/build/licenseStore.js +4 -2
  38. package/build/licenseStore.js.map +1 -0
  39. package/build/manifestVerify.d.ts.map +1 -0
  40. package/build/manifestVerify.js +1 -1
  41. package/build/manifestVerify.js.map +1 -0
  42. package/build/migrateClient.d.ts +5 -21
  43. package/build/migrateClient.d.ts.map +1 -0
  44. package/build/migrateClient.js +29 -17
  45. package/build/migrateClient.js.map +1 -0
  46. package/build/ownership.d.ts.map +1 -0
  47. package/build/ownership.js +1 -1
  48. package/build/ownership.js.map +1 -0
  49. package/build/proveErrors.d.ts +35 -0
  50. package/build/proveErrors.d.ts.map +1 -0
  51. package/build/proveErrors.js +49 -0
  52. package/build/proveErrors.js.map +1 -0
  53. package/build/proveRetry.d.ts +38 -0
  54. package/build/proveRetry.d.ts.map +1 -0
  55. package/build/proveRetry.js +89 -0
  56. package/build/proveRetry.js.map +1 -0
  57. package/build/refundClient.d.ts.map +1 -0
  58. package/build/refundClient.js +1 -1
  59. package/build/refundClient.js.map +1 -0
  60. package/build/renewClient.d.ts +1 -0
  61. package/build/renewClient.d.ts.map +1 -0
  62. package/build/renewClient.js +9 -3
  63. package/build/renewClient.js.map +1 -0
  64. package/build/statsClient.d.ts +28 -0
  65. package/build/statsClient.d.ts.map +1 -0
  66. package/build/statsClient.js +34 -5
  67. package/build/statsClient.js.map +1 -0
  68. package/build/txBroadcastClient.d.ts +26 -0
  69. package/build/txBroadcastClient.d.ts.map +1 -0
  70. package/build/txBroadcastClient.js +20 -0
  71. package/build/txBroadcastClient.js.map +1 -0
  72. package/build/vendorAccountClient.d.ts +55 -0
  73. package/build/vendorAccountClient.d.ts.map +1 -0
  74. package/build/vendorAccountClient.js +44 -0
  75. package/build/vendorAccountClient.js.map +1 -0
  76. package/build/verifyCore.d.ts.map +1 -0
  77. package/build/verifyCore.js +1 -1
  78. package/build/verifyCore.js.map +1 -0
  79. package/build/verifyService.d.ts.map +1 -0
  80. package/build/verifyService.js +1 -1
  81. package/build/verifyService.js.map +1 -0
  82. package/build/zipToState.d.ts +2 -0
  83. package/build/zipToState.d.ts.map +1 -0
  84. package/build/zipToState.js +94 -0
  85. package/build/zipToState.js.map +1 -0
  86. package/package.json +19 -4
  87. package/src/archiveBootstrap.ts +285 -0
  88. package/src/buyClient.ts +259 -0
  89. package/src/contractInterface.d.ts +55 -0
  90. package/src/contractInterface.ts +242 -0
  91. package/src/countryStats.ts +171 -0
  92. package/src/deployClient.ts +388 -0
  93. package/src/freezeClient.ts +117 -0
  94. package/src/generationClient.ts +164 -0
  95. package/src/index.ts +392 -0
  96. package/src/licenseProof.d.ts +26 -0
  97. package/src/licenseProof.ts +48 -0
  98. package/src/licenseRecord.ts +91 -0
  99. package/src/licenseStore.ts +358 -0
  100. package/src/manifestVerify.ts +106 -0
  101. package/src/migrateClient.ts +82 -0
  102. package/src/ownership.ts +198 -0
  103. package/src/proveErrors.ts +70 -0
  104. package/src/refundClient.ts +144 -0
  105. package/src/renewClient.ts +209 -0
  106. package/src/statsClient.ts +190 -0
  107. package/src/txBroadcastClient.ts +48 -0
  108. package/src/vendorAccountClient.ts +106 -0
  109. package/src/verifyCore.ts +84 -0
  110. package/src/verifyService.ts +799 -0
  111. package/src/zipToState.ts +94 -0
@@ -0,0 +1,388 @@
1
+ // Copyright (c) 2025-2026 zkLicensing project developers
2
+ // SPDX-License-Identifier: BUSL-1.1
3
+
4
+ /**
5
+ * deployClient.ts
6
+ *
7
+ * Browser-safe client for the deploy flow. Mirrors the buy / renew / refund
8
+ * client shape: `proveDeploy` posts to the keeper's `/prove/deploy` endpoint
9
+ * and returns the atomic deploy+initialize tx already signed with the
10
+ * throwaway zkApp key. The vendor's wallet then adds the fee-payer signature
11
+ * and broadcasts.
12
+ *
13
+ * The atomic bundling is what makes single-shot initialization safe:
14
+ * `provenTxJson` holds both `zkApp.deploy()` and `zkApp.initialize()`
15
+ * AccountUpdates in the same transaction, and the keeper generates a
16
+ * throwaway zkApp keypair per request that is discarded before the tx is
17
+ * returned — so the target address is not observable to an attacker until
18
+ * the initialize AU inside is already signed and locked to `(vendor,
19
+ * prices)`. Combined with the in-circuit `vendor.requireEquals(empty())`
20
+ * precondition on `initialize()`, this yields a one-shot init per account.
21
+ *
22
+ * Also exports admin-side `redeployApp` / `redeployAll` for the platform
23
+ * operator to trigger post-hardfork v1→v2 migrations via the keeper's
24
+ * `/admin/redeploy*` endpoints (bearer-token auth).
25
+ */
26
+
27
+ export type ProveDeployRequest = {
28
+ keeperUrl: string;
29
+ senderPublicKey: string; // vendor's fee-payer wallet
30
+ vendorAddress: string; // owner slot; may equal senderPublicKey
31
+ priceMonthlyMina: number;
32
+ priceYearlyMina: number;
33
+ priceFiveYearMina: number;
34
+ fetcher?: typeof fetch;
35
+ signal?: AbortSignal;
36
+ };
37
+
38
+ export type ProveDeployResponse = {
39
+ provenTxJson: string;
40
+ verificationKeyHash: string;
41
+ zkAppAddress: string;
42
+ };
43
+
44
+ export type DeployStatus = {
45
+ status: 'unknown' | 'pending' | 'confirmed' | 'failed';
46
+ landedAtSlot?: number;
47
+ elapsedMs?: number;
48
+ };
49
+
50
+ function baseUrl(url: string): string {
51
+ return url.replace(/\/+$/, '');
52
+ }
53
+
54
+ async function readJsonError(resp: Response): Promise<string> {
55
+ try {
56
+ const body = await resp.json() as Record<string, unknown>;
57
+ if (typeof body.error === 'string') return body.error;
58
+ } catch { /* body wasn't JSON */ }
59
+ return `HTTP ${resp.status}`;
60
+ }
61
+
62
+ // POST /prove/deploy — keeper generates the throwaway zkApp keypair, assembles
63
+ // the deploy+initialize tx, proves it, and signs the zkApp AccountUpdate with
64
+ // the throwaway key. The caller forwards `provenTxJson` to the vendor's wallet
65
+ // for the fee-payer signature.
66
+ //
67
+ // At least one of the three prices must be > 0 (enforced by the keeper *and*
68
+ // by the circuit's `initialize()`).
69
+ export async function proveDeploy(req: ProveDeployRequest): Promise<ProveDeployResponse> {
70
+ const fetcher = req.fetcher ?? fetch;
71
+ const resp = await fetcher(`${baseUrl(req.keeperUrl)}/prove/deploy`, {
72
+ method: 'POST',
73
+ headers: { 'Content-Type': 'application/json' },
74
+ signal: req.signal,
75
+ body: JSON.stringify({
76
+ senderPublicKey: req.senderPublicKey,
77
+ vendorAddress: req.vendorAddress,
78
+ priceMonthlyMina: req.priceMonthlyMina,
79
+ priceYearlyMina: req.priceYearlyMina,
80
+ priceFiveYearMina: req.priceFiveYearMina,
81
+ }),
82
+ });
83
+ if (!resp.ok) throw new Error(await readJsonError(resp));
84
+ return await resp.json() as ProveDeployResponse;
85
+ }
86
+
87
+ export type PollDeployStatusOptions = {
88
+ keeperUrl: string;
89
+ zkAppAddress: string;
90
+ fetcher?: typeof fetch;
91
+ intervalMs?: number; // default 3 000
92
+ timeoutMs?: number; // default 10 min
93
+ signal?: AbortSignal;
94
+ onTick?: (status: DeployStatus) => void;
95
+ };
96
+
97
+ // Polls GET /deploy/status until it returns a terminal state ('confirmed' or
98
+ // 'failed') or the timeout fires. 'unknown' means the keeper hasn't seen the
99
+ // tx yet (register step not called or watch not armed).
100
+ export async function pollDeployStatus(opts: PollDeployStatusOptions): Promise<DeployStatus> {
101
+ const fetcher = opts.fetcher ?? fetch;
102
+ const interval = opts.intervalMs ?? 3_000;
103
+ const timeout = opts.timeoutMs ?? 10 * 60 * 1000;
104
+ const deadline = Date.now() + timeout;
105
+ const url = `${baseUrl(opts.keeperUrl)}/deploy/status?zkAppAddress=${encodeURIComponent(opts.zkAppAddress)}`;
106
+
107
+ while (true) {
108
+ if (opts.signal?.aborted) throw new Error('pollDeployStatus aborted');
109
+ let status: DeployStatus = { status: 'unknown' };
110
+ try {
111
+ const resp = await fetcher(url, { signal: opts.signal });
112
+ if (resp.ok) status = await resp.json() as DeployStatus;
113
+ } catch { /* transient network — try again on next tick */ }
114
+ opts.onTick?.(status);
115
+ if (status.status === 'confirmed' || status.status === 'failed') return status;
116
+ if (Date.now() >= deadline) return status;
117
+ await new Promise<void>((r) => setTimeout(r, interval));
118
+ }
119
+ }
120
+
121
+ // ─── Admin redeploy (post-hardfork v1 → v2 migration) ────────────────────────
122
+ //
123
+ // Wraps the keeper's `/admin/redeploy*` endpoints. These are platform-operator
124
+ // tools — the keeper pays gas with its own FEEPAYER_KEY and reads authoritative
125
+ // vendor/prices from the on-chain v1 contract, so the caller supplies no
126
+ // per-app parameters. Guards enforced keeper-side: refund window closed,
127
+ // escrow empty, on-chain version still in supported range. Auth is
128
+ // KEEPER_ADMIN_TOKEN as a Bearer header.
129
+
130
+ export type RedeployAppRequest = {
131
+ keeperUrl: string;
132
+ appId: string;
133
+ adminToken: string;
134
+ // Bypass the `escrowRoot === EMPTY_ROOT` guard. Other guards (refund
135
+ // window, app status, on-chain version, keeper key configuration) are not
136
+ // bypassable. Buyers with unrefunded escrow can still call requestRefund
137
+ // against the retired v1 address directly (its on-chain state persists).
138
+ force?: boolean;
139
+ fetcher?: typeof fetch;
140
+ signal?: AbortSignal;
141
+ };
142
+
143
+ export type RedeployAppResponse = {
144
+ ok: true;
145
+ zkAppAddress: string;
146
+ txHash: string;
147
+ verificationKeyHash: string;
148
+ onChainVersion: number;
149
+ // Present (=== true) when `force: true` was used and the v1 escrowRoot was
150
+ // non-empty at the time of redeploy. Absent otherwise.
151
+ forcedEscrowNonEmpty?: true;
152
+ };
153
+
154
+ export type RedeployAllRequest = {
155
+ keeperUrl: string;
156
+ adminToken: string;
157
+ // Applied uniformly to every app in the batch. See RedeployAppRequest.force.
158
+ force?: boolean;
159
+ fetcher?: typeof fetch;
160
+ signal?: AbortSignal;
161
+ };
162
+
163
+ export type RedeployAllResponse = {
164
+ ok: true;
165
+ count: number;
166
+ succeeded: number;
167
+ results: Array<
168
+ | { appId: string; ok: true; zkAppAddress: string; txHash: string; verificationKeyHash: string; onChainVersion: number; forcedEscrowNonEmpty?: true }
169
+ | { appId: string; ok: false; error: string }
170
+ >;
171
+ };
172
+
173
+ function adminHeaders(token: string): Record<string, string> {
174
+ return {
175
+ 'Content-Type': 'application/json',
176
+ 'Authorization': `Bearer ${token}`,
177
+ };
178
+ }
179
+
180
+ // POST /admin/redeploy/:appId — one-shot migration for a single app. Rejects
181
+ // with a descriptive error if any keeper-side guard fails (409) or the deploy
182
+ // itself throws (500). The returned `zkAppAddress` is the *new* v2 address; the
183
+ // old one stays on chain but is no longer referenced by keeper records.
184
+ export async function redeployApp(req: RedeployAppRequest): Promise<RedeployAppResponse> {
185
+ const fetcher = req.fetcher ?? fetch;
186
+ const resp = await fetcher(`${baseUrl(req.keeperUrl)}/admin/redeploy/${encodeURIComponent(req.appId)}`, {
187
+ method: 'POST',
188
+ headers: adminHeaders(req.adminToken),
189
+ signal: req.signal,
190
+ body: JSON.stringify(req.force ? { force: true } : {}),
191
+ });
192
+ if (!resp.ok) throw new Error(await readJsonError(resp));
193
+ return await resp.json() as RedeployAppResponse;
194
+ }
195
+
196
+ // POST /admin/redeploy-all — iterate every registered app and attempt migration.
197
+ // Idempotent: apps already migrated surface as ok:false with a clear error, so
198
+ // a rerun after a partial failure retries only the remaining ones. Callers
199
+ // should inspect `results` for per-app outcomes rather than relying on the
200
+ // top-level `ok`, which is always true unless the whole request failed.
201
+ export async function redeployAll(req: RedeployAllRequest): Promise<RedeployAllResponse> {
202
+ const fetcher = req.fetcher ?? fetch;
203
+ const resp = await fetcher(`${baseUrl(req.keeperUrl)}/admin/redeploy-all`, {
204
+ method: 'POST',
205
+ headers: adminHeaders(req.adminToken),
206
+ signal: req.signal,
207
+ body: JSON.stringify(req.force ? { force: true } : {}),
208
+ });
209
+ if (!resp.ok) throw new Error(await readJsonError(resp));
210
+ return await resp.json() as RedeployAllResponse;
211
+ }
212
+
213
+ // ─── Vendor-triggered redeploy (post-hardfork v1 → v2 migration) ─────────────
214
+ //
215
+ // Same migration flow as the admin path, but authenticated by a Mina signature
216
+ // against the on-chain vendor of the target app (no admin token required).
217
+ // Vendor pays gas via their own fee-payer AU. Three-step flow, mirroring
218
+ // first-time /prove/deploy → wallet-broadcast → /apps register:
219
+ //
220
+ // 1. getVendorRedeployChallenge → { nonce, expiresAt }
221
+ // 2. vendor's wallet signs `nonce` → { field, scalar }
222
+ // 3. proveVendorRedeploy → { provenTxJson, zkAppAddress, redeployId }
223
+ // 4. wallet adds fee-payer sig + broadcasts (mirrors proveDeploy flow)
224
+ // 5. registerVendorRedeploy → arms the keeper's deploy-watcher on
225
+ // the new address. On landing, keeper
226
+ // promotes the new deployment onto the
227
+ // AppRecord (deploymentHistory[] etc).
228
+ //
229
+ // `force: true` is allowed on the vendor path (same semantics as admin —
230
+ // bypasses the escrow-empty guard; unrefunded buyers retain the v1 refund
231
+ // path against the retired address). All other guards (freeze slot
232
+ // configured, refund window closed, active status, supported version) apply.
233
+ //
234
+ // ⚠ Vendor-embedded buy links — after redeploy, `proveVendorRedeploy` returns
235
+ // the NEW v2 `zkAppAddress`. Platform-hosted marketplace pages handle this
236
+ // automatically: the marketplace stores your registered `buyUrl`/`renewUrl`/
237
+ // `refundUrl` as base URLs and appends `?app=<zkAppAddress>&network=<net>`
238
+ // at render time, so the "Get" button routes to the new v2 address the
239
+ // moment `finalizeRedeploy` promotes it onto the AppRecord — no vendor
240
+ // action required. Only vendor-hosted surfaces where the raw `zkAppAddress`
241
+ // is hand-embedded (docs, storefront, emails) need manual updating.
242
+ // Existing licenses on the retired v1 address remain independently
243
+ // verifiable (v1 on-chain state persists), but new buys against the old URL
244
+ // would land on the retired contract and be refused (its v1 version is
245
+ // outside the supported range
246
+ // after migration).
247
+
248
+ export type VendorRedeployPreflight = {
249
+ // True iff the vendor path can proceed without `force`. Escrow non-empty is
250
+ // NOT part of `ok`: the button should still be actionable with a
251
+ // force-required warning when the only failing guard is escrow.
252
+ ok: boolean;
253
+ freezeSlotConfigured: boolean;
254
+ appExists: boolean;
255
+ status?: 'active' | 'inactive' | 'pending';
256
+ refundWindow: { closed: boolean; opensAtSlot: number; currentSlot: number };
257
+ onChainAccount: { exists: boolean };
258
+ // `empty: false` alone does not disqualify the redeploy — `force: true` on
259
+ // /prove bypasses this guard. UIs should surface the state as a warning.
260
+ escrow: { empty: boolean };
261
+ version: { current: number; supported: boolean; supportedList: readonly number[] };
262
+ // Human-readable reasons for every failing guard, in evaluation order. Empty
263
+ // array iff every guard passes and no force is needed.
264
+ reasons: string[];
265
+ };
266
+
267
+ // GET /vendor/redeploy/:appId/preflight — read-only guard status. No nonce
268
+ // consumed, no signature required. UI decides button state from the returned
269
+ // fields; 404 means the app itself is unknown (a distinct failure mode from a
270
+ // known app failing individual guards).
271
+ export async function fetchVendorRedeployPreflight(opts: {
272
+ keeperUrl: string;
273
+ appId: string;
274
+ fetcher?: typeof fetch;
275
+ signal?: AbortSignal;
276
+ }): Promise<VendorRedeployPreflight> {
277
+ const fetcher = opts.fetcher ?? fetch;
278
+ const resp = await fetcher(`${baseUrl(opts.keeperUrl)}/vendor/redeploy/${encodeURIComponent(opts.appId)}/preflight`, {
279
+ signal: opts.signal,
280
+ });
281
+ if (!resp.ok) throw new Error(await readJsonError(resp));
282
+ return await resp.json() as VendorRedeployPreflight;
283
+ }
284
+
285
+ export type VendorRedeployChallenge = {
286
+ nonce: string;
287
+ expiresAt: number; // ms epoch
288
+ };
289
+
290
+ // GET /vendor/redeploy/:appId/challenge — mint a one-shot nonce that the
291
+ // vendor's wallet signs. The nonce is bound to the appId and expires in 60s;
292
+ // consumed on /prove.
293
+ export async function getVendorRedeployChallenge(opts: {
294
+ keeperUrl: string;
295
+ appId: string;
296
+ fetcher?: typeof fetch;
297
+ signal?: AbortSignal;
298
+ }): Promise<VendorRedeployChallenge> {
299
+ const fetcher = opts.fetcher ?? fetch;
300
+ const resp = await fetcher(`${baseUrl(opts.keeperUrl)}/vendor/redeploy/${encodeURIComponent(opts.appId)}/challenge`, {
301
+ signal: opts.signal,
302
+ });
303
+ if (!resp.ok) throw new Error(await readJsonError(resp));
304
+ return await resp.json() as VendorRedeployChallenge;
305
+ }
306
+
307
+ export type ProveVendorRedeployRequest = {
308
+ keeperUrl: string;
309
+ appId: string;
310
+ nonce: string;
311
+ signature: { field: string; scalar: string };
312
+ senderPublicKey: string; // vendor's fee-payer wallet
313
+ // Bypass the `escrowRoot === EMPTY_ROOT` guard. Unlike the admin path this
314
+ // is available to the vendor as well — in case the freeze is scheduled
315
+ // late enough that a straggling refund is still pending when they want to
316
+ // migrate. Buyers with unrefunded escrow can still call requestRefund
317
+ // against the retired v1 address directly (its on-chain state persists).
318
+ force?: boolean;
319
+ fetcher?: typeof fetch;
320
+ signal?: AbortSignal;
321
+ };
322
+
323
+ export type ProveVendorRedeployResponse = {
324
+ provenTxJson: string;
325
+ // New v2 zkApp address (fresh throwaway keypair, discarded by the keeper).
326
+ // Marketplace links auto-update: the platform appends `?app=<zkAppAddress>
327
+ // &network=<net>` to your registered `buyUrl`/`renewUrl`/`refundUrl` at
328
+ // render time, so the "Get" button routes to this new address once
329
+ // `finalizeRedeploy` promotes it onto the AppRecord — no vendor action
330
+ // required. Only vendor-hosted surfaces where the raw address is embedded
331
+ // by hand need manual updating.
332
+ zkAppAddress: string;
333
+ verificationKeyHash: string;
334
+ redeployId: string; // capability token — pass to registerVendorRedeploy
335
+ // Present (=== true) when `force: true` was used and the v1 escrowRoot was
336
+ // non-empty at the time of the prove call. Absent otherwise.
337
+ forcedEscrowNonEmpty?: true;
338
+ };
339
+
340
+ // POST /vendor/redeploy/:appId/prove — keeper verifies the nonce + signature
341
+ // against the on-chain vendor of the app, runs all redeploy guards, generates
342
+ // a throwaway zkApp keypair, builds and proves the deploy+initialize tx, and
343
+ // signs the zkApp AU with the throwaway key. Returns the tx JSON for the
344
+ // vendor's wallet to add the fee-payer signature and broadcast.
345
+ export async function proveVendorRedeploy(req: ProveVendorRedeployRequest): Promise<ProveVendorRedeployResponse> {
346
+ const fetcher = req.fetcher ?? fetch;
347
+ const resp = await fetcher(`${baseUrl(req.keeperUrl)}/vendor/redeploy/${encodeURIComponent(req.appId)}/prove`, {
348
+ method: 'POST',
349
+ headers: { 'Content-Type': 'application/json' },
350
+ signal: req.signal,
351
+ body: JSON.stringify({
352
+ nonce: req.nonce,
353
+ signature: req.signature,
354
+ senderPublicKey: req.senderPublicKey,
355
+ ...(req.force ? { force: true } : {}),
356
+ }),
357
+ });
358
+ if (!resp.ok) throw new Error(await readJsonError(resp));
359
+ return await resp.json() as ProveVendorRedeployResponse;
360
+ }
361
+
362
+ export type RegisterVendorRedeployRequest = {
363
+ keeperUrl: string;
364
+ appId: string;
365
+ redeployId: string; // capability token from proveVendorRedeploy
366
+ txHash: string; // returned by the vendor's wallet after broadcast
367
+ fetcher?: typeof fetch;
368
+ signal?: AbortSignal;
369
+ };
370
+
371
+ // POST /vendor/redeploy/:appId/register — arm the keeper's deploy-watcher on
372
+ // the new address. On confirmation the keeper mutates AppRecord to promote
373
+ // the new deployment (push old to deploymentHistory[], overwrite live fields,
374
+ // clear event-sync cursors). Idempotent to duplicate calls (the watcher
375
+ // checks whether one is already armed for the address). Poll GET
376
+ // /deploy/status?zkAppAddress=<new> — or use pollDeployStatus — for landing
377
+ // progress, same as first-time deploys.
378
+ export async function registerVendorRedeploy(req: RegisterVendorRedeployRequest): Promise<{ ok: true }> {
379
+ const fetcher = req.fetcher ?? fetch;
380
+ const resp = await fetcher(`${baseUrl(req.keeperUrl)}/vendor/redeploy/${encodeURIComponent(req.appId)}/register`, {
381
+ method: 'POST',
382
+ headers: { 'Content-Type': 'application/json' },
383
+ signal: req.signal,
384
+ body: JSON.stringify({ redeployId: req.redeployId, txHash: req.txHash }),
385
+ });
386
+ if (!resp.ok) throw new Error(await readJsonError(resp));
387
+ return await resp.json() as { ok: true };
388
+ }
@@ -0,0 +1,117 @@
1
+ // Copyright (c) 2025-2026 zkLicensing project developers
2
+ // SPDX-License-Identifier: BUSL-1.1
3
+
4
+ /**
5
+ * freezeClient.ts
6
+ *
7
+ * Client-side view of the keeper's sales-freeze schedule. A freeze is set
8
+ * ahead of a hardfork so every open escrow window can drain (refund or
9
+ * release) before v1 → v2 migration. Once past the freeze slot the keeper
10
+ * refuses new buys and renewals; refunds and verify remain open.
11
+ *
12
+ * Resolution order for buy/renew clients that want to know the current
13
+ * freeze state:
14
+ * 1. Caller-supplied `freezeAfterSlot` (explicit override — build-time
15
+ * constant, or pulled from the vendor's own env at boot).
16
+ * 2. `process.env.FREEZE_AFTER_SLOT` when running under Node (server-side
17
+ * integrators / CLIs).
18
+ * 3. Keeper's own `/health` payload — the authoritative source of truth.
19
+ *
20
+ * A `freezeAfterSlot` of 0 / null / undefined means "no freeze scheduled".
21
+ */
22
+
23
+ export type KeeperHealth = {
24
+ status: string;
25
+ network: string;
26
+ slot: number;
27
+ salesFrozen: boolean;
28
+ freezeAfterSlot: number | null;
29
+ };
30
+
31
+ export type FreezeState = {
32
+ // True when the current slot is past freezeAfterSlot — new buys and
33
+ // renewals are refused keeper-side.
34
+ salesFrozen: boolean;
35
+ // Scheduled freeze slot (announced ahead of a hardfork). Null when no
36
+ // freeze is configured. Buy clients should require an explicit
37
+ // refund-window waiver only when the buyer's 14-day refund window would
38
+ // extend past this slot (i.e. currentSlot + REFUND_WINDOW_N > freezeAfterSlot).
39
+ freezeAfterSlot: number | null;
40
+ // Chain slot at resolution time. Populated from /health; null on the
41
+ // caller-override / env paths where the slot is not known client-side.
42
+ // Callers computing refund-window overlap should treat null
43
+ // conservatively (require the waiver) — the keeper's own guard is still
44
+ // the last line of defence.
45
+ currentSlot: number | null;
46
+ };
47
+
48
+ function baseUrl(url: string): string {
49
+ return url.replace(/\/+$/, '');
50
+ }
51
+
52
+ function envFreezeSlot(): number | null {
53
+ // Runs both in Node (process.env) and browser (no `process`). The typeof
54
+ // guard keeps bundlers from choking on the reference.
55
+ if (typeof process === 'undefined' || !process.env) return null;
56
+ const raw = process.env.FREEZE_AFTER_SLOT;
57
+ if (!raw) return null;
58
+ const n = parseInt(raw, 10);
59
+ return Number.isFinite(n) && n > 0 ? n : null;
60
+ }
61
+
62
+ export type GetKeeperHealthOptions = {
63
+ keeperUrl: string;
64
+ fetcher?: typeof fetch;
65
+ signal?: AbortSignal;
66
+ };
67
+
68
+ // GET /health — raw payload. Callers that only need the freeze state
69
+ // should use resolveFreezeState() instead; this is exposed for UIs that
70
+ // surface slot / network info alongside freeze.
71
+ export async function getKeeperHealth(opts: GetKeeperHealthOptions): Promise<KeeperHealth> {
72
+ const fetcher = opts.fetcher ?? fetch;
73
+ const resp = await fetcher(`${baseUrl(opts.keeperUrl)}/health`, { signal: opts.signal });
74
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
75
+ return await resp.json() as KeeperHealth;
76
+ }
77
+
78
+ export type ResolveFreezeStateOptions = {
79
+ keeperUrl: string;
80
+ // Explicit override (highest precedence). Null/undefined falls through
81
+ // to env, then /health.
82
+ freezeAfterSlot?: number | null;
83
+ fetcher?: typeof fetch;
84
+ signal?: AbortSignal;
85
+ };
86
+
87
+ // Resolves the effective freeze state for the caller. When the source is
88
+ // the caller override or process.env, the current slot is not known
89
+ // client-side, so salesFrozen is filled from /health only — the override
90
+ // path returns { freezeAfterSlot: N, salesFrozen: false, currentSlot: null }
91
+ // and lets the keeper reject the actual prove call if the freeze has fired.
92
+ export async function resolveFreezeState(opts: ResolveFreezeStateOptions): Promise<FreezeState> {
93
+ if (typeof opts.freezeAfterSlot === 'number' && opts.freezeAfterSlot > 0) {
94
+ return { salesFrozen: false, freezeAfterSlot: opts.freezeAfterSlot, currentSlot: null };
95
+ }
96
+ const envSlot = envFreezeSlot();
97
+ if (envSlot !== null) {
98
+ return { salesFrozen: false, freezeAfterSlot: envSlot, currentSlot: null };
99
+ }
100
+ try {
101
+ const health = await getKeeperHealth({
102
+ keeperUrl: opts.keeperUrl,
103
+ fetcher: opts.fetcher,
104
+ signal: opts.signal,
105
+ });
106
+ return {
107
+ salesFrozen: health.salesFrozen === true,
108
+ freezeAfterSlot: typeof health.freezeAfterSlot === 'number' ? health.freezeAfterSlot : null,
109
+ currentSlot: typeof health.slot === 'number' ? health.slot : null,
110
+ };
111
+ } catch {
112
+ // /health unreachable — treat as no freeze so we don't block buys on a
113
+ // transient network hiccup. The keeper's own guard is still the last
114
+ // line of defense.
115
+ return { salesFrozen: false, freezeAfterSlot: null, currentSlot: null };
116
+ }
117
+ }
@@ -0,0 +1,164 @@
1
+ // Copyright (c) 2025-2026 zkLicensing project developers
2
+ // SPDX-License-Identifier: BUSL-1.1
3
+
4
+ /**
5
+ * generationClient.ts
6
+ *
7
+ * Browser-safe fetcher for the keeper's signed generation-list. Returns the
8
+ * app's deployment history — every past address plus the current live one —
9
+ * verified against a caller-supplied pin list of platform manifest public
10
+ * keys.
11
+ *
12
+ * Trust model: the keeper is not the trust anchor for this data — it can
13
+ * be re-hosted, mirrored, cached, or (worst case) impersonated. The pin
14
+ * list is. Any response that fails signature verification is surfaced as
15
+ * `verified: false` with `generations: []` so callers cannot accidentally
16
+ * route a wallet prompt to an unverified address.
17
+ *
18
+ * Optional corroboration channel: an operator that publishes
19
+ * https://<vendor-host>/.well-known/zklicensing/<appId>.json
20
+ * (using the same keeper-signed payload) can be cross-checked from the
21
+ * on-chain zkappUri. Per-app filename because a vendor may host several
22
+ * apps at one origin. The cross-check is opt-in per-app — a 404 at that
23
+ * URL is not a failure signal, just "no corroboration published for this
24
+ * app." That's the caller's job, not this module's.
25
+ */
26
+
27
+ import { verifyManifestSignature } from './manifestVerify.js';
28
+
29
+ export interface GenerationEntry {
30
+ gen: number;
31
+ zkAppAddress: string;
32
+ status: 'retired' | 'live';
33
+ frozenRoot?: string;
34
+ deployedAt?: string;
35
+ retiredAt?: string;
36
+ }
37
+
38
+ export interface GenerationInfo {
39
+ /** Live-generation index (matches the entry with status: 'live'). Null when unverified or missing. */
40
+ current: number | null;
41
+ generations: GenerationEntry[];
42
+ /** True iff signature verified against one of the pinned pubkeys. */
43
+ verified: boolean;
44
+ }
45
+
46
+ export interface FetchGenerationRequest {
47
+ keeperUrl: string;
48
+ appId: string;
49
+ /** Base64-encoded Ed25519 public keys pinned by the caller. Empty ⇒ verified:false. */
50
+ pinnedPublicKeysBase64: readonly string[];
51
+ fetcher?: typeof fetch;
52
+ signal?: AbortSignal;
53
+ }
54
+
55
+ export interface FetchGenerationByAddressRequest {
56
+ keeperUrl: string;
57
+ /** Any zkApp address in the app's generation chain — current live or any retired. */
58
+ zkAppAddress: string;
59
+ /** Base64-encoded Ed25519 public keys pinned by the caller. Empty ⇒ verified:false. */
60
+ pinnedPublicKeysBase64: readonly string[];
61
+ fetcher?: typeof fetch;
62
+ signal?: AbortSignal;
63
+ }
64
+
65
+ function baseUrl(url: string): string {
66
+ return url.replace(/\/+$/, '');
67
+ }
68
+
69
+ interface ParsedGenerationBody {
70
+ current: number | null;
71
+ generations: GenerationEntry[];
72
+ signature: string | null;
73
+ issuedAt: string | null;
74
+ appId: string | null;
75
+ }
76
+
77
+ function parseGenerationBody(body: Record<string, unknown>): ParsedGenerationBody {
78
+ const gensRaw = body.generations;
79
+ return {
80
+ current: typeof body.current === 'number' ? body.current : null,
81
+ generations: Array.isArray(gensRaw) ? gensRaw as GenerationEntry[] : [],
82
+ signature: typeof body.signature === 'string' ? body.signature : null,
83
+ issuedAt: typeof body.issuedAt === 'string' ? body.issuedAt : null,
84
+ appId: typeof body.appId === 'string' ? body.appId : null,
85
+ };
86
+ }
87
+
88
+ async function verifyAndProject(
89
+ parsed: ParsedGenerationBody,
90
+ pinnedPublicKeysBase64: readonly string[],
91
+ ): Promise<GenerationInfo> {
92
+ const { current, generations, signature, issuedAt, appId } = parsed;
93
+ if (!signature || !issuedAt || !appId || current === null) {
94
+ return { current, generations, verified: false };
95
+ }
96
+ // Reconstruct the canonical payload — must match keeperService's
97
+ // signPayload input exactly.
98
+ const canonical = { appId, current, generations, issuedAt };
99
+ const verified = await verifyManifestSignature(canonical, signature, pinnedPublicKeysBase64);
100
+ if (!verified) {
101
+ // Never surface unverified `current`/`generations` — a caller must not
102
+ // accidentally route a wallet prompt to an address we couldn't authenticate.
103
+ return { current: null, generations: [], verified: false };
104
+ }
105
+ return { current, generations, verified: true };
106
+ }
107
+
108
+ /**
109
+ * GET /apps/:appId — returns { appId, current, generations, issuedAt, publicKey, signature }.
110
+ * We reconstruct the canonical payload (everything except `publicKey` +
111
+ * `signature`) and verify against the pin list. `publicKey` in the response
112
+ * is advisory only — it's the key the keeper claims to have signed with;
113
+ * the SDK never trusts it in place of a pin.
114
+ */
115
+ export async function fetchGenerationInfo(req: FetchGenerationRequest): Promise<GenerationInfo> {
116
+ const fetcher = req.fetcher ?? fetch;
117
+ const resp = await fetcher(`${baseUrl(req.keeperUrl)}/apps/${encodeURIComponent(req.appId)}`, {
118
+ method: 'GET',
119
+ signal: req.signal,
120
+ });
121
+ if (!resp.ok) {
122
+ return { current: null, generations: [], verified: false };
123
+ }
124
+ let body: Record<string, unknown>;
125
+ try { body = await resp.json() as Record<string, unknown>; }
126
+ catch { return { current: null, generations: [], verified: false }; }
127
+
128
+ return verifyAndProject(parseGenerationBody(body), req.pinnedPublicKeysBase64);
129
+ }
130
+
131
+ /**
132
+ * GET /apps/by-address/:zkAppAddress — same signed payload as fetchGenerationInfo,
133
+ * but keyed by an address the caller already has (direct-paste buys, renew on a
134
+ * since-retired address). Buyers arriving without an appId would otherwise have
135
+ * to trust the keeper's unsigned `/apps` list to bind their address to an appId
136
+ * first — defeating the pin.
137
+ *
138
+ * Extra assertion beyond the appId flow: the queried address MUST appear in the
139
+ * returned `generations[]`. Without this, a malicious keeper could return a
140
+ * validly-signed payload for a DIFFERENT app in response to the query, and the
141
+ * signature check alone wouldn't catch it.
142
+ */
143
+ export async function fetchGenerationInfoByAddress(
144
+ req: FetchGenerationByAddressRequest,
145
+ ): Promise<GenerationInfo> {
146
+ const fetcher = req.fetcher ?? fetch;
147
+ const resp = await fetcher(
148
+ `${baseUrl(req.keeperUrl)}/apps/by-address/${encodeURIComponent(req.zkAppAddress)}`,
149
+ { method: 'GET', signal: req.signal },
150
+ );
151
+ if (!resp.ok) {
152
+ return { current: null, generations: [], verified: false };
153
+ }
154
+ let body: Record<string, unknown>;
155
+ try { body = await resp.json() as Record<string, unknown>; }
156
+ catch { return { current: null, generations: [], verified: false }; }
157
+
158
+ const projected = await verifyAndProject(parseGenerationBody(body), req.pinnedPublicKeysBase64);
159
+ if (!projected.verified) return projected;
160
+ if (!projected.generations.some(g => g.zkAppAddress === req.zkAppAddress)) {
161
+ return { current: null, generations: [], verified: false };
162
+ }
163
+ return projected;
164
+ }