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,358 @@
1
+ // Copyright (c) 2025-2026 zkLicensing project developers
2
+ // SPDX-License-Identifier: BUSL-1.1
3
+
4
+ /**
5
+ * licenseStore.ts
6
+ *
7
+ * Off-chain MerkleMap state for the licensing system.
8
+ *
9
+ * Records are keyed by (zkAppAddress, licenseHash). Each LicensingApp deploys
10
+ * its own on-chain MerkleMap, so a passphrase used on two different apps
11
+ * produces the same `licenseHash` but two distinct records. Lookups that omit
12
+ * `zkAppAddress` would collide across apps — every API here takes it.
13
+ *
14
+ * Used by:
15
+ * - interact.ts / buyLicense.ts (CLI) — reads/writes directly
16
+ * - keeperService.ts — exposes the same logic over HTTP
17
+ * - proofServer.ts — reads expirySlot for a given licenseHash
18
+ */
19
+
20
+ import fs from 'fs/promises';
21
+ import { Bool, Field, MerkleMap, MerkleMapWitness, Poseidon, PublicKey, UInt32, UInt64 } from 'o1js';
22
+ import { GRACE_PERIOD_N, REFUND_WINDOW_N } from './contractInterface.js';
23
+ import { PENDING_REAP_SLOTS as _PENDING_REAP_SLOTS, type LicenseRecord as _LicenseRecord, type LicenseStatus as _LicenseStatus } from './licenseRecord.js';
24
+
25
+ // The record shape and pure types now live in licenseRecord.ts so browser
26
+ // modules (buyClient, statsClient, countryStats) can consume them without
27
+ // importing Node's `fs`. Re-exported here so existing Node imports of
28
+ // `zklicensing/internal/licenseStore` continue to resolve.
29
+ export type LicenseRecord = _LicenseRecord;
30
+ export type LicenseStatus = _LicenseStatus;
31
+ export const PENDING_REAP_SLOTS = _PENDING_REAP_SLOTS;
32
+
33
+ export function licenseStorePath(): string {
34
+ return process.env.LICENSE_STORE_PATH ?? 'licenses.json';
35
+ }
36
+
37
+ export async function readRecords(): Promise<LicenseRecord[]> {
38
+ let raw: unknown;
39
+ try {
40
+ raw = JSON.parse(await fs.readFile(licenseStorePath(), 'utf8'));
41
+ } catch {
42
+ return [];
43
+ }
44
+ if (!Array.isArray(raw)) return [];
45
+ const records = raw as LicenseRecord[];
46
+ // Records pre-dating composite keying lack `zkAppAddress` and cannot be
47
+ // safely matched against any specific zkApp. Drop them with a warning so
48
+ // bootstrap rebuilds them fresh from on-chain events.
49
+ const filtered = records.filter(r => typeof r.zkAppAddress === 'string' && r.zkAppAddress.length > 0);
50
+ if (filtered.length < records.length) {
51
+ console.warn(`[licenseStore] dropped ${records.length - filtered.length} record(s) missing zkAppAddress — re-bootstrap to recover`);
52
+ }
53
+ return filtered;
54
+ }
55
+
56
+ async function writeRecords(records: LicenseRecord[]): Promise<void> {
57
+ await fs.writeFile(licenseStorePath(), JSON.stringify(records, null, 2));
58
+ }
59
+
60
+ // Serialize all read-modify-write operations against licenses.json. Each
61
+ // mutating function does (readRecords → mutate → writeRecords); without a lock,
62
+ // two concurrent calls can interleave such that the second one's read predates
63
+ // the first one's write, and the second one's write then clobbers the first.
64
+ // This was visible during archive sync (applyEventsToStore fires many writes
65
+ // back-to-back) racing with /confirm/* writes from buyer flows.
66
+ let writeChain: Promise<void> = Promise.resolve();
67
+ function withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
68
+ const result = writeChain.then(fn, fn);
69
+ writeChain = result.then(() => {}, () => {});
70
+ return result;
71
+ }
72
+
73
+ // Records belonging to a specific zkApp. Each zkApp owns an independent
74
+ // on-chain MerkleMap, so all map operations must filter first.
75
+ function recordsForApp(records: LicenseRecord[], zkAppAddress: string): LicenseRecord[] {
76
+ return records.filter(r => r.zkAppAddress === zkAppAddress);
77
+ }
78
+
79
+ // Rebuild the license MerkleMap for a single zkApp. Only inserts entries
80
+ // with expirySlot > 0.
81
+ export function buildMap(records: LicenseRecord[], zkAppAddress: string): MerkleMap {
82
+ const map = new MerkleMap();
83
+ for (const r of recordsForApp(records, zkAppAddress)) {
84
+ if (r.expirySlot !== 0) {
85
+ map.set(Field(r.licenseHash), Field(r.expirySlot));
86
+ }
87
+ }
88
+ return map;
89
+ }
90
+
91
+ // Rebuild the escrow MerkleMap for a single zkApp:
92
+ // licenseHash → escrowCommitment(amount, slot, buyerKey). Only includes
93
+ // entries with live escrow (not released, not refunded) and a known buyer.
94
+ export function buildEscrowMap(records: LicenseRecord[], zkAppAddress: string): MerkleMap {
95
+ const map = new MerkleMap();
96
+ for (const r of recordsForApp(records, zkAppAddress)) {
97
+ if (!r.released && !r.refunded && r.purchaseSlot > 0 && r.buyerAddress) {
98
+ const buyerKey = PublicKey.fromBase58(r.buyerAddress);
99
+ const commitment = Poseidon.hash([
100
+ UInt64.from(BigInt(r.purchaseAmount)).value,
101
+ UInt32.from(r.purchaseSlot).value,
102
+ buyerKey.x,
103
+ buyerKey.isOdd.toField(),
104
+ ]);
105
+ map.set(Field(r.licenseHash), commitment);
106
+ }
107
+ }
108
+ return map;
109
+ }
110
+
111
+ // Serialize a MerkleMapWitness to plain JSON (safe to send over HTTP).
112
+ export function serializeWitness(witness: MerkleMapWitness): {
113
+ isLefts: boolean[];
114
+ siblings: string[];
115
+ } {
116
+ return {
117
+ isLefts: witness.isLefts.map((b) => b.toBoolean()),
118
+ siblings: witness.siblings.map((f) => f.toString()),
119
+ };
120
+ }
121
+
122
+ // Reconstruct a MerkleMapWitness from serialized JSON.
123
+ export function deserializeWitness(json: {
124
+ isLefts: boolean[];
125
+ siblings: string[];
126
+ }): MerkleMapWitness {
127
+ return new MerkleMapWitness(
128
+ json.isLefts.map((b: boolean) => Bool(b)),
129
+ json.siblings.map((s: string) => Field(s))
130
+ );
131
+ }
132
+
133
+ // Returns the witness for a licenseHash key in the given zkApp's map.
134
+ export async function getWitness(zkAppAddress: string, licenseHash: string): Promise<{
135
+ root: string;
136
+ currentValue: string;
137
+ witness: MerkleMapWitness;
138
+ serialized: ReturnType<typeof serializeWitness>;
139
+ }> {
140
+ const records = await readRecords();
141
+ const map = buildMap(records, zkAppAddress);
142
+ const key = Field(licenseHash);
143
+ const witness = map.getWitness(key);
144
+ return {
145
+ root: map.getRoot().toString(),
146
+ currentValue: map.get(key).toString(),
147
+ witness,
148
+ serialized: serializeWitness(witness),
149
+ };
150
+ }
151
+
152
+ // Look up a single license record by its composite key.
153
+ export async function getLicense(zkAppAddress: string, licenseHash: string): Promise<LicenseRecord | null> {
154
+ const records = await readRecords();
155
+ return records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash) ?? null;
156
+ }
157
+
158
+ // Determine the status of a license. A license in the 7-day grace window is
159
+ // still considered 'active' so renewal is not urgently forced.
160
+ export function licenseStatus(record: LicenseRecord | null, currentSlot: number): LicenseStatus {
161
+ if (!record) return 'unknown';
162
+ if (record.expirySlot === 0) return 'refunded';
163
+ if (record.expirySlot + GRACE_PERIOD_N < currentSlot) return 'expired';
164
+ return 'active';
165
+ }
166
+
167
+ // Add or update a license record (upsert by composite key).
168
+ export async function upsertLicense(record: LicenseRecord): Promise<void> {
169
+ if (!record.zkAppAddress) {
170
+ throw new Error('upsertLicense: record.zkAppAddress is required');
171
+ }
172
+ return withWriteLock(async () => {
173
+ const records = await readRecords();
174
+ const idx = records.findIndex(r => r.zkAppAddress === record.zkAppAddress && r.licenseHash === record.licenseHash);
175
+ if (idx >= 0) {
176
+ records[idx] = record;
177
+ } else {
178
+ records.push(record);
179
+ }
180
+ await writeRecords(records);
181
+ });
182
+ }
183
+
184
+ // Update the expirySlot for an existing record (used by renew).
185
+ // `opts.markRenewed` flips renewedAtLeastOnce so stats can detect first-renewal
186
+ // without re-walking events. `opts.renewal` appends to the renewals[] log so
187
+ // stats can compute renewal revenue from the store alone.
188
+ export async function updateExpiry(
189
+ zkAppAddress: string,
190
+ licenseHash: string,
191
+ expirySlot: number,
192
+ opts: { markRenewed?: boolean; renewal?: { slot: number; amount: string; newExpiry: number; txHash?: string } } = {},
193
+ ): Promise<boolean> {
194
+ return withWriteLock(async () => {
195
+ const records = await readRecords();
196
+ const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
197
+ if (!record) return false;
198
+ record.expirySlot = expirySlot;
199
+ if (opts.markRenewed) record.renewedAtLeastOnce = true;
200
+ if (opts.renewal) {
201
+ const renewals = (record.renewals ??= []);
202
+ // Dedup by newExpiry — confirm-time and event-sync may both try to log
203
+ // the same renewal, but each renewal advances expiry by a unique tier
204
+ // amount, so newExpiry is a stable identifier.
205
+ if (!renewals.some(r => r.newExpiry === opts.renewal!.newExpiry)) {
206
+ renewals.push(opts.renewal);
207
+ }
208
+ }
209
+ await writeRecords(records);
210
+ return true;
211
+ });
212
+ }
213
+
214
+ // Clear license and escrow data after a confirmed refund. Also flips `refunded`.
215
+ export async function recordRefund(zkAppAddress: string, licenseHash: string): Promise<boolean> {
216
+ return withWriteLock(async () => {
217
+ const records = await readRecords();
218
+ const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
219
+ if (!record) return false;
220
+ record.expirySlot = 0;
221
+ record.purchaseSlot = 0;
222
+ record.purchaseAmount = '0';
223
+ record.refunded = true;
224
+ await writeRecords(records);
225
+ return true;
226
+ });
227
+ }
228
+
229
+ // Mark escrow as released. Preserves purchaseAmount/purchaseSlot so stats can
230
+ // compute released revenue. The `released` flag is the truth signal —
231
+ // buildEscrowMap and getReleasableRecords filter on it directly.
232
+ export async function recordRelease(
233
+ zkAppAddress: string,
234
+ licenseHash: string,
235
+ releaseSlot?: number,
236
+ txHash?: string,
237
+ ): Promise<boolean> {
238
+ return withWriteLock(async () => {
239
+ const records = await readRecords();
240
+ const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
241
+ if (!record) return false;
242
+ record.released = true;
243
+ if (releaseSlot !== undefined) record.releaseSlot = releaseSlot;
244
+ if (txHash !== undefined) record.releaseTxHash = txHash;
245
+ await writeRecords(records);
246
+ return true;
247
+ });
248
+ }
249
+
250
+ // Clear the pending flag on a record after a chain event confirms it landed.
251
+ export async function confirmRecord(zkAppAddress: string, licenseHash: string): Promise<boolean> {
252
+ return withWriteLock(async () => {
253
+ const records = await readRecords();
254
+ const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
255
+ if (!record) return false;
256
+ if (!record.pending) return false;
257
+ delete record.pending;
258
+ delete record.provedAtSlot;
259
+ await writeRecords(records);
260
+ return true;
261
+ });
262
+ }
263
+
264
+ // Apply a licenseIssued event atomically: confirm any pending /confirm/buy
265
+ // write, backfill initialExpirySlot/tier if missing, or create a fresh record.
266
+ // Doing the whole read-decide-write inside a single lock acquisition avoids the
267
+ // stale-snapshot hazard that would arise if archiveBootstrap called
268
+ // getLicense + confirmRecord + upsertLicense as three separate locked steps —
269
+ // a concurrent write between them could be clobbered by the upsert's
270
+ // pre-confirm snapshot.
271
+ export async function applyLicenseIssued(
272
+ zkAppAddress: string,
273
+ licenseHash: string,
274
+ fields: {
275
+ expirySlot: number;
276
+ purchaseSlot: number;
277
+ purchaseAmount: string;
278
+ tier?: 'monthly' | 'yearly' | 'fiveYear';
279
+ // True for licenses minted through buyLicenseNoEscrow: funds went straight
280
+ // to vendor + platform, no escrow entry. Refund CTA must be suppressed.
281
+ escrowless?: boolean;
282
+ },
283
+ ): Promise<void> {
284
+ return withWriteLock(async () => {
285
+ const records = await readRecords();
286
+ const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
287
+ let dirty = false;
288
+ if (record) {
289
+ if (record.pending) {
290
+ delete record.pending;
291
+ delete record.provedAtSlot;
292
+ dirty = true;
293
+ }
294
+ if (record.initialExpirySlot === undefined) {
295
+ record.initialExpirySlot = fields.expirySlot;
296
+ dirty = true;
297
+ }
298
+ if (!record.tier && fields.tier) {
299
+ record.tier = fields.tier;
300
+ dirty = true;
301
+ }
302
+ if (fields.escrowless === true && record.escrowless !== true) {
303
+ record.escrowless = true;
304
+ dirty = true;
305
+ }
306
+ } else {
307
+ records.push({
308
+ zkAppAddress,
309
+ licenseHash,
310
+ expirySlot: fields.expirySlot,
311
+ purchaseSlot: fields.purchaseSlot,
312
+ purchaseAmount: fields.purchaseAmount,
313
+ txHash: '',
314
+ createdAt: new Date().toISOString(),
315
+ initialExpirySlot: fields.expirySlot,
316
+ ...(fields.tier ? { tier: fields.tier } : {}),
317
+ ...(fields.escrowless === true ? { escrowless: true } : {}),
318
+ });
319
+ dirty = true;
320
+ }
321
+ if (dirty) await writeRecords(records);
322
+ });
323
+ }
324
+
325
+ // Delete a record entirely (used to reap stale pending entries the buyer never submitted).
326
+ export async function deleteRecord(zkAppAddress: string, licenseHash: string): Promise<boolean> {
327
+ return withWriteLock(async () => {
328
+ const records = await readRecords();
329
+ const idx = records.findIndex(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
330
+ if (idx < 0) return false;
331
+ records.splice(idx, 1);
332
+ await writeRecords(records);
333
+ return true;
334
+ });
335
+ }
336
+
337
+ // List pending records whose prove slot is older than `currentSlot - maxAgeSlots`.
338
+ // Used by the reaper to drop records the buyer never actually signed/submitted.
339
+ export async function getStalePendingRecords(currentSlot: number, maxAgeSlots = PENDING_REAP_SLOTS): Promise<LicenseRecord[]> {
340
+ const records = await readRecords();
341
+ return records.filter(
342
+ (r) => r.pending && typeof r.provedAtSlot === 'number' && currentSlot - r.provedAtSlot > maxAgeSlots,
343
+ );
344
+ }
345
+
346
+ // Return all records where the refund window has passed and escrow is still live.
347
+ // Filters on the released/refunded flags so we don't re-release or release after refund.
348
+ export async function getReleasableRecords(currentSlot: number): Promise<LicenseRecord[]> {
349
+ const records = await readRecords();
350
+ return records.filter(
351
+ (r) =>
352
+ !r.released &&
353
+ !r.refunded &&
354
+ r.purchaseSlot > 0 &&
355
+ r.zkAppAddress &&
356
+ currentSlot > r.purchaseSlot + REFUND_WINDOW_N,
357
+ );
358
+ }
@@ -0,0 +1,106 @@
1
+ // Copyright (c) 2025-2026 zkLicensing project developers
2
+ // SPDX-License-Identifier: BUSL-1.1
3
+
4
+ /**
5
+ * manifestVerify.ts
6
+ *
7
+ * Browser + Node Ed25519 signature verification for platform-signed manifests
8
+ * (currently: the generation-list served at GET /apps/:appId, next: the
9
+ * sales-freeze manifest). The keeper signs with the platform manifest key
10
+ * loaded via PLATFORM_MANIFEST_KEY_FILE; the SDK pins the public key against
11
+ * a caller-supplied allow-list and verifies before trusting anything.
12
+ *
13
+ * Runtime: browsers and Node ≥ 20 expose `globalThis.crypto.subtle`. The
14
+ * SDK's `engines.node` is `>=20` for this reason — Node 18 child workers
15
+ * (spawned by `node --test`) do not populate `globalThis.crypto`.
16
+ *
17
+ * Canonicalization must match `src/manifestSign.ts` byte-for-byte:
18
+ * - sorted object keys
19
+ * - no whitespace
20
+ * - arrays preserved in order
21
+ * Any divergence silently breaks verification, so keep the algorithm dead
22
+ * simple and mirrored.
23
+ */
24
+
25
+ // Canonical JSON: sorted keys, no whitespace, arrays preserved in order.
26
+ // Mirror of src/manifestSign.ts:canonicalize. Divergence here silently
27
+ // breaks verification.
28
+ export function canonicalize(value: unknown): string {
29
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
30
+ if (Array.isArray(value)) return '[' + value.map(canonicalize).join(',') + ']';
31
+ const keys = Object.keys(value as Record<string, unknown>).sort();
32
+ return '{' + keys.map(k =>
33
+ JSON.stringify(k) + ':' + canonicalize((value as Record<string, unknown>)[k])
34
+ ).join(',') + '}';
35
+ }
36
+
37
+ function base64ToBytes(b64: string): Uint8Array {
38
+ // atob is present in every modern JS runtime we target (browser + Node ≥ 16).
39
+ const bin = atob(b64);
40
+ const out = new Uint8Array(bin.length);
41
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
42
+ return out;
43
+ }
44
+
45
+ function utf8Bytes(s: string): Uint8Array {
46
+ return new TextEncoder().encode(s);
47
+ }
48
+
49
+ // SPKI DER wrapper around a raw 32-byte Ed25519 public key.
50
+ // Same prefix used by src/manifestSign.ts:verifyPayload — WebCrypto needs
51
+ // SPKI to import; the wire format is raw base64 for space.
52
+ const ED25519_SPKI_PREFIX = new Uint8Array([
53
+ 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
54
+ ]);
55
+
56
+ function rawEd25519ToSpki(raw: Uint8Array): Uint8Array {
57
+ if (raw.length !== 32) throw new Error(`Ed25519 pubkey must be 32 bytes, got ${raw.length}`);
58
+ const out = new Uint8Array(ED25519_SPKI_PREFIX.length + raw.length);
59
+ out.set(ED25519_SPKI_PREFIX, 0);
60
+ out.set(raw, ED25519_SPKI_PREFIX.length);
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * Verify a base64 Ed25519 signature over a canonicalized payload against
66
+ * one of the pinned base64 public keys. Returns true iff at least one
67
+ * pinned key verifies. Fails closed on any error (unknown key format,
68
+ * WebCrypto rejection, missing subtle) — callers must interpret `false`
69
+ * as "do not trust."
70
+ */
71
+ export async function verifyManifestSignature(
72
+ payload: unknown,
73
+ signatureBase64: string,
74
+ pinnedPublicKeysBase64: readonly string[],
75
+ ): Promise<boolean> {
76
+ if (!pinnedPublicKeysBase64.length) return false;
77
+ const subtle = (globalThis.crypto as Crypto | undefined)?.subtle;
78
+ if (!subtle) return false;
79
+ const messageBytes = utf8Bytes(canonicalize(payload));
80
+ let signatureBytes: Uint8Array;
81
+ try { signatureBytes = base64ToBytes(signatureBase64); }
82
+ catch { return false; }
83
+ for (const pinB64 of pinnedPublicKeysBase64) {
84
+ try {
85
+ const spki = rawEd25519ToSpki(base64ToBytes(pinB64));
86
+ const key = await subtle.importKey(
87
+ 'spki',
88
+ spki as unknown as ArrayBuffer,
89
+ { name: 'Ed25519' },
90
+ false,
91
+ ['verify'],
92
+ );
93
+ const ok = await subtle.verify(
94
+ { name: 'Ed25519' },
95
+ key,
96
+ signatureBytes as unknown as ArrayBuffer,
97
+ messageBytes as unknown as ArrayBuffer,
98
+ );
99
+ if (ok) return true;
100
+ } catch {
101
+ // Try the next pin — one bad entry in the pin list must not mask
102
+ // a valid signature under a different pin.
103
+ }
104
+ }
105
+ return false;
106
+ }
@@ -0,0 +1,82 @@
1
+ // Copyright (c) 2025-2026 zkLicensing project developers
2
+ // SPDX-License-Identifier: BUSL-1.1
3
+
4
+ /**
5
+ * migrateClient.ts
6
+ *
7
+ * Browser-safe client for /prove/migrate. Assembles a single-hop cross-
8
+ * generation proof against the current live map — the keeper walks the
9
+ * deploymentHistory once, gathers all three witnesses (ancestor / pred /
10
+ * live-empty), and returns a signable tx.
11
+ *
12
+ * Failure surfaces:
13
+ * - 404 "license refunded or unknown": no leaf anywhere in history for
14
+ * this licenseHash. Caller should not retry — it's terminal.
15
+ * - 409 "already migrated": leaf is present in the live map. Caller
16
+ * should refresh their license record and stop.
17
+ * - 409 "proving queue busy": another root-mutating request (buy, renew,
18
+ * migrate) is holding this app's in-flight slot. Thrown as the shared
19
+ * ProveQueueBusyError so callers can wait+retry uniformly across the
20
+ * three endpoints.
21
+ */
22
+
23
+ import { readErrorBody, tryQueueBusyFromBody } from './proveErrors.js';
24
+
25
+ export interface ProveMigrateRequest {
26
+ keeperUrl: string;
27
+ licenseHash: string;
28
+ appId: string;
29
+ buyerAddress: string;
30
+ fetcher?: typeof fetch;
31
+ signal?: AbortSignal;
32
+ }
33
+
34
+ export interface ProveMigrateResponse {
35
+ provenTxJson: string;
36
+ ancestorGen: number;
37
+ expirySlot: number;
38
+ licenseHash: string;
39
+ zkAppAddress: string;
40
+ // Key the keeper stored the per-app in-flight slot under. Passed back to
41
+ // POST /tx-broadcast after the wallet returns a hash. See ProveBuyResponse.
42
+ appKey: string;
43
+ }
44
+
45
+ /**
46
+ * Terminal migrate failures (404 refunded/unknown, 409 already migrated).
47
+ * The queue-busy 409 is surfaced separately as ProveQueueBusyError so
48
+ * callers can share one retry helper across buy/renew/migrate.
49
+ */
50
+ export class ProveMigrateError extends Error {
51
+ readonly status: number;
52
+ constructor(message: string, status: number) {
53
+ super(message);
54
+ this.name = 'ProveMigrateError';
55
+ this.status = status;
56
+ }
57
+ }
58
+
59
+ function baseUrl(url: string): string {
60
+ return url.replace(/\/+$/, '');
61
+ }
62
+
63
+ export async function proveMigrate(req: ProveMigrateRequest): Promise<ProveMigrateResponse> {
64
+ const fetcher = req.fetcher ?? fetch;
65
+ const resp = await fetcher(`${baseUrl(req.keeperUrl)}/prove/migrate`, {
66
+ method: 'POST',
67
+ headers: { 'Content-Type': 'application/json' },
68
+ signal: req.signal,
69
+ body: JSON.stringify({
70
+ licenseHash: req.licenseHash,
71
+ appId: req.appId,
72
+ buyerAddress: req.buyerAddress,
73
+ }),
74
+ });
75
+ if (!resp.ok) {
76
+ const parsed = await readErrorBody(resp);
77
+ const queueBusy = tryQueueBusyFromBody(parsed);
78
+ if (queueBusy) throw queueBusy;
79
+ throw new ProveMigrateError(parsed.message, resp.status);
80
+ }
81
+ return await resp.json() as ProveMigrateResponse;
82
+ }