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,285 @@
1
+ // Copyright (c) 2025-2026 zkLicensing project developers
2
+ // SPDX-License-Identifier: BUSL-1.1
3
+
4
+ /**
5
+ * archiveBootstrap.ts
6
+ *
7
+ * Two layers:
8
+ * - bootstrapFromArchive() fetches zkApp events and returns them normalized.
9
+ * No writes. Caller decides what to do with the events.
10
+ * - applyEventsToStore() writes the events to licenseStore.
11
+ *
12
+ * Events handled (write side):
13
+ * licenseIssued → applyLicenseIssued
14
+ * licenseIssuedNoEscrow → applyLicenseIssued (escrowless: true)
15
+ * licenseRenewed → updateExpiry
16
+ * licenseMigrated → applyLicenseIssued (escrowless: true, migrated: true)
17
+ * refundIssued → recordRefund
18
+ * fundsReleased → recordRelease
19
+ * priceUpdated → ignored (price-history tracking lives in the private
20
+ * zklicensing-app keeper alongside vendor-stats)
21
+ *
22
+ * Uses LicensingAppEventsContract (from contractInterface) for fetchEvents —
23
+ * this lets the public verify service read events without depending on the
24
+ * private LicensingApp circuit source.
25
+ */
26
+
27
+ import { PublicKey, UInt32 } from 'o1js';
28
+ import {
29
+ LicensingAppEventsContract,
30
+ ExpiryUpdateEvent,
31
+ LicenseIssuedEvent,
32
+ LicenseMigratedEvent,
33
+ PriceUpdatedEvent,
34
+ MONTHLY_SLOTS_N,
35
+ ONE_YEAR_SLOTS_N,
36
+ FIVE_YEAR_SLOTS_N,
37
+ } from './contractInterface.js';
38
+ import {
39
+ applyLicenseIssued,
40
+ updateExpiry,
41
+ recordRefund,
42
+ recordRelease,
43
+ } from './licenseStore.js';
44
+
45
+ export const SYNC_CHUNK_SLOTS = 10_000;
46
+ export const MAX_LOOKBACK_SLOTS = 525_600;
47
+
48
+ export type Tier = 'monthly' | 'yearly' | 'fiveYear';
49
+
50
+ export type NormalizedEvent =
51
+ | { kind: 'licenseIssued'; slot: number; licenseHash: string; amount: bigint; issuedSlot: number; expirySlot: number }
52
+ | { kind: 'licenseIssuedNoEscrow'; slot: number; licenseHash: string; amount: bigint; issuedSlot: number; expirySlot: number }
53
+ | { kind: 'licenseRenewed'; slot: number; licenseHash: string; newExpiry: number }
54
+ | { kind: 'licenseMigrated'; slot: number; licenseHash: string; expirySlot: number; ancestorGen: number }
55
+ | { kind: 'refundIssued'; slot: number; licenseHash: string }
56
+ | { kind: 'fundsReleased'; slot: number; licenseHash: string }
57
+ | { kind: 'priceUpdated'; slot: number; priceMonthly: bigint; priceYearly: bigint; priceFiveYear: bigint };
58
+
59
+ // Back-derive the duration tier from (expirySlot - purchaseSlot). Tolerance
60
+ // of ±2 slots matches the SLOT_TOLERANCE used in the circuit.
61
+ export function tierFromExpiryDelta(slots: number): Tier | null {
62
+ const within = (target: number) => Math.abs(slots - target) <= 2;
63
+ if (within(MONTHLY_SLOTS_N)) return 'monthly';
64
+ if (within(ONE_YEAR_SLOTS_N)) return 'yearly';
65
+ if (within(FIVE_YEAR_SLOTS_N)) return 'fiveYear';
66
+ return null;
67
+ }
68
+
69
+ export type FetchRange = {
70
+ from?: number;
71
+ to?: number;
72
+ };
73
+
74
+ export type BootstrapResult = {
75
+ maxSeenSlot: number;
76
+ events: NormalizedEvent[];
77
+ };
78
+
79
+ export async function bootstrapFromArchive(
80
+ zkAppAddress: string,
81
+ range: FetchRange = {},
82
+ ): Promise<BootstrapResult> {
83
+ const pubKey = PublicKey.fromBase58(zkAppAddress);
84
+ const zkApp = new LicensingAppEventsContract(pubKey);
85
+
86
+ const start = range.from !== undefined ? UInt32.from(range.from) : undefined;
87
+ const end = range.to !== undefined ? UInt32.from(range.to) : undefined;
88
+ const allEvents = await zkApp.fetchEvents(start, end);
89
+
90
+ const canonical = [...allEvents]
91
+ .filter(e => e.chainStatus === 'canonical')
92
+ .sort((a, b) => Number(a.globalSlot.toString()) - Number(b.globalSlot.toString()));
93
+
94
+ const events: NormalizedEvent[] = [];
95
+
96
+ for (const ev of canonical) {
97
+ const slot = Number(ev.globalSlot.toString());
98
+ switch (ev.type) {
99
+ case 'priceUpdated': {
100
+ const data = ev.event.data as unknown as PriceUpdatedEvent;
101
+ events.push({
102
+ kind: 'priceUpdated',
103
+ slot,
104
+ priceMonthly: BigInt(data.priceMonthly.toString()),
105
+ priceYearly: BigInt(data.priceYearly.toString()),
106
+ priceFiveYear: BigInt(data.priceFiveYear.toString()),
107
+ });
108
+ break;
109
+ }
110
+ case 'licenseIssued': {
111
+ const data = ev.event.data as unknown as LicenseIssuedEvent;
112
+ events.push({
113
+ kind: 'licenseIssued',
114
+ slot,
115
+ licenseHash: data.licenseHash.toString(),
116
+ amount: BigInt(data.amount.toString()),
117
+ issuedSlot: Number(data.slot.toString()),
118
+ expirySlot: Number(data.expirySlot.toString()),
119
+ });
120
+ break;
121
+ }
122
+ case 'licenseIssuedNoEscrow': {
123
+ const data = ev.event.data as unknown as LicenseIssuedEvent;
124
+ events.push({
125
+ kind: 'licenseIssuedNoEscrow',
126
+ slot,
127
+ licenseHash: data.licenseHash.toString(),
128
+ amount: BigInt(data.amount.toString()),
129
+ issuedSlot: Number(data.slot.toString()),
130
+ expirySlot: Number(data.expirySlot.toString()),
131
+ });
132
+ break;
133
+ }
134
+ case 'licenseRenewed': {
135
+ const data = ev.event.data as unknown as ExpiryUpdateEvent;
136
+ events.push({
137
+ kind: 'licenseRenewed',
138
+ slot,
139
+ licenseHash: data.licenseHash.toString(),
140
+ newExpiry: Number(data.newExpiry.toString()),
141
+ });
142
+ break;
143
+ }
144
+ case 'licenseMigrated': {
145
+ const data = ev.event.data as unknown as LicenseMigratedEvent;
146
+ events.push({
147
+ kind: 'licenseMigrated',
148
+ slot,
149
+ licenseHash: data.licenseHash.toString(),
150
+ expirySlot: Number(data.expirySlot.toString()),
151
+ ancestorGen: Number(data.ancestorGen.toString()),
152
+ });
153
+ break;
154
+ }
155
+ case 'refundIssued':
156
+ events.push({ kind: 'refundIssued', slot, licenseHash: (ev.event.data as any).toString() });
157
+ break;
158
+ case 'fundsReleased':
159
+ events.push({ kind: 'fundsReleased', slot, licenseHash: (ev.event.data as any).toString() });
160
+ break;
161
+ }
162
+ }
163
+
164
+ const maxSeenSlot = canonical.length === 0
165
+ ? -1
166
+ : Number(canonical[canonical.length - 1].globalSlot.toString());
167
+
168
+ return { maxSeenSlot, events };
169
+ }
170
+
171
+ export async function applyEventsToStore(
172
+ zkAppAddress: string,
173
+ events: NormalizedEvent[],
174
+ ): Promise<{ count: number; warnings: string[] }> {
175
+ const warnings: string[] = [];
176
+ let count = 0;
177
+
178
+ for (const ev of events) {
179
+ switch (ev.kind) {
180
+ case 'priceUpdated':
181
+ break;
182
+ case 'licenseIssued': {
183
+ const tier = tierFromExpiryDelta(ev.expirySlot - ev.issuedSlot) ?? undefined;
184
+ await applyLicenseIssued(zkAppAddress, ev.licenseHash, {
185
+ expirySlot: ev.expirySlot,
186
+ purchaseSlot: ev.issuedSlot,
187
+ purchaseAmount: ev.amount.toString(),
188
+ ...(tier ? { tier } : {}),
189
+ });
190
+ count++;
191
+ break;
192
+ }
193
+ case 'licenseIssuedNoEscrow': {
194
+ const tier = tierFromExpiryDelta(ev.expirySlot - ev.issuedSlot) ?? undefined;
195
+ await applyLicenseIssued(zkAppAddress, ev.licenseHash, {
196
+ expirySlot: ev.expirySlot,
197
+ purchaseSlot: ev.issuedSlot,
198
+ purchaseAmount: ev.amount.toString(),
199
+ escrowless: true,
200
+ ...(tier ? { tier } : {}),
201
+ });
202
+ count++;
203
+ break;
204
+ }
205
+ case 'licenseRenewed': {
206
+ const ok = await updateExpiry(zkAppAddress, ev.licenseHash, ev.newExpiry, {
207
+ markRenewed: true,
208
+ });
209
+ if (!ok) warnings.push(`renew for unknown license ${ev.licenseHash.slice(0, 16)}…`);
210
+ break;
211
+ }
212
+ case 'licenseMigrated': {
213
+ await applyLicenseIssued(zkAppAddress, ev.licenseHash, {
214
+ expirySlot: ev.expirySlot,
215
+ purchaseSlot: 0,
216
+ purchaseAmount: '0',
217
+ escrowless: true,
218
+ });
219
+ count++;
220
+ break;
221
+ }
222
+ case 'refundIssued': {
223
+ const ok = await recordRefund(zkAppAddress, ev.licenseHash);
224
+ if (!ok) warnings.push(`refund for unknown license ${ev.licenseHash.slice(0, 16)}…`);
225
+ break;
226
+ }
227
+ case 'fundsReleased': {
228
+ const ok = await recordRelease(zkAppAddress, ev.licenseHash, ev.slot);
229
+ if (!ok) warnings.push(`release for unknown license ${ev.licenseHash.slice(0, 16)}…`);
230
+ break;
231
+ }
232
+ }
233
+ }
234
+
235
+ return { count, warnings };
236
+ }
237
+
238
+ export async function discoverAppCreatedSlot(
239
+ zkAppAddress: string,
240
+ nowSlot: number,
241
+ lookbackSlots: number = MAX_LOOKBACK_SLOTS,
242
+ ): Promise<number | null> {
243
+ const pubKey = PublicKey.fromBase58(zkAppAddress);
244
+ const zkApp = new LicensingAppEventsContract(pubKey);
245
+ const floor = Math.max(1, nowSlot - lookbackSlots);
246
+
247
+ let to = nowSlot;
248
+ while (to >= floor) {
249
+ const from = Math.max(floor, to - SYNC_CHUNK_SLOTS + 1);
250
+ const events = await zkApp.fetchEvents(UInt32.from(from), UInt32.from(to));
251
+ const created = events
252
+ .filter(e => e.chainStatus === 'canonical' && e.type === 'appCreated')
253
+ .sort((a, b) => Number(a.globalSlot.toString()) - Number(b.globalSlot.toString()));
254
+ if (created.length > 0) {
255
+ return Number(created[0].globalSlot.toString());
256
+ }
257
+ if (from <= floor) break;
258
+ to = from - 1;
259
+ }
260
+ return null;
261
+ }
262
+
263
+ const inFlightBootstraps = new Map<string, Promise<void>>();
264
+
265
+ export async function lazyBootstrap(zkAppAddress: string): Promise<void> {
266
+ const existing = inFlightBootstraps.get(zkAppAddress);
267
+ if (existing) return existing;
268
+ const short = zkAppAddress.slice(0, 12) + '…';
269
+ const p = (async () => {
270
+ try {
271
+ const { events } = await bootstrapFromArchive(zkAppAddress);
272
+ const { count, warnings } = await applyEventsToStore(zkAppAddress, events);
273
+ for (const w of warnings) console.warn(`[lazy-bootstrap] ${short}: ${w}`);
274
+ if (count > 0) {
275
+ console.log(`[lazy-bootstrap] ${short}: replayed ${count} record(s)`);
276
+ }
277
+ } catch (e: any) {
278
+ console.warn(`[lazy-bootstrap] ${short}: ${e?.message ?? e}`);
279
+ } finally {
280
+ inFlightBootstraps.delete(zkAppAddress);
281
+ }
282
+ })();
283
+ inFlightBootstraps.set(zkAppAddress, p);
284
+ return p;
285
+ }
@@ -0,0 +1,259 @@
1
+ // Copyright (c) 2025-2026 zkLicensing project developers
2
+ // SPDX-License-Identifier: BUSL-1.1
3
+
4
+ /**
5
+ * buyClient.ts
6
+ *
7
+ * Browser-safe client for the buy flow. Exposes the three keeper roundtrips
8
+ * (derive identity → prove → confirm) as small, individually testable
9
+ * functions plus a `pollBuyStatus` helper. Deliberately does NOT talk to a
10
+ * wallet — the caller drives the wallet step between `proveBuy` and
11
+ * `confirmBuy`, so the SDK stays wallet-agnostic and can be reused by CLI
12
+ * tools, alternate UIs, and third-party integrators.
13
+ *
14
+ * Peer dep: o1js (for Poseidon + Encoding). Consumers of ./buy must have
15
+ * o1js in their dependency graph.
16
+ */
17
+
18
+ import { Encoding, Poseidon } from 'o1js';
19
+ import { resolveFreezeState } from './freezeClient.js';
20
+ import { REFUND_WINDOW_N, TOLERANCE_WINDOW_N } from './contractInterface.js';
21
+ import { readErrorBody, tryQueueBusyFromBody } from './proveErrors.js';
22
+
23
+ export type BuyIdentity = {
24
+ secretHash: string; // Poseidon.hash(Encoding.stringToFields(passphrase))
25
+ licenseHash: string; // Poseidon.hash([secretHash])
26
+ };
27
+
28
+ // Derive the two hashes the buy flow keys off. Deterministic in the
29
+ // passphrase — same passphrase always maps to the same licenseHash on a
30
+ // given zkApp, so a buyer's passphrase choice locks in their license
31
+ // identity from day one. Only `licenseHash` is sent to the keeper (see
32
+ // proveBuy); `secretHash` stays on the buyer's device and is used only by
33
+ // the client-side LicenseOwnershipProgram challenge/response.
34
+ export function deriveBuyIdentity(passphrase: string): BuyIdentity {
35
+ const secretHash = Poseidon.hash(Encoding.stringToFields(passphrase));
36
+ const licenseHash = Poseidon.hash([secretHash]);
37
+ return { secretHash: secretHash.toString(), licenseHash: licenseHash.toString() };
38
+ }
39
+
40
+ export type ProveBuyRequest = {
41
+ keeperUrl: string;
42
+ // Poseidon.hash([Poseidon.hash(Encoding.stringToFields(passphrase))]) —
43
+ // computed client-side via deriveBuyIdentity(). The keeper never sees
44
+ // `secretHash`; the ordering argument (Fix 2 in AUDIT_BRIEF §4) is that a
45
+ // keeper leak of `licenseHash` reveals nothing beyond what is already
46
+ // public on-chain, since the same value shows up in the LicenseIssuedEvent
47
+ // once the buy lands.
48
+ licenseHash: string;
49
+ buyerAddress: string;
50
+ zkAppAddress: string;
51
+ duration: 0 | 1 | 2; // 0 = Monthly, 1 = Yearly, 2 = 5 Years
52
+ buyerCountry: string; // ISO 3166-1 alpha-2
53
+ // Buyer's postal code — MANDATORY when buyerCountry === 'US' (state-level
54
+ // sales-tax attribution). Ignored server-side for non-US buyers. Format is
55
+ // enforced by the keeper: 5 digits or ZIP+4 ("12345" or "12345-6789").
56
+ buyerZip?: string;
57
+ // Off by default. Mandatory only when this buy's 14-day refund window
58
+ // would overlap the scheduled hardfork — i.e. currentSlot + REFUND_WINDOW_N
59
+ // > freezeAfterSlot. Buyers whose full window ends before the freeze get
60
+ // no warning (nothing to warn about). When the current slot cannot be
61
+ // resolved client-side (caller-supplied freezeAfterSlot or env override),
62
+ // the SDK falls back to requiring the waiver as a safe default; the
63
+ // keeper's own guard runs the same overlap check against its authoritative
64
+ // slot. Persisted on the LicenseRecord as `refundWaiverAcceptedAt`.
65
+ refundWaiverAccepted?: boolean;
66
+ // Optional freeze-slot override. When omitted the SDK reads
67
+ // `process.env.FREEZE_AFTER_SLOT` (Node only), else GET /health.
68
+ freezeAfterSlot?: number | null;
69
+ fetcher?: typeof fetch;
70
+ signal?: AbortSignal;
71
+ };
72
+
73
+ export type ProveBuyResponse = {
74
+ provenTxJson: string;
75
+ expirySlot: number;
76
+ licenseHash: string;
77
+ verificationKeyHash: string;
78
+ purchaseSlot: number;
79
+ purchaseAmount: string; // nanomina
80
+ duration: number;
81
+ durationLabel: string;
82
+ // Deployment generation this buy is proved against. Threaded through into
83
+ // ProofFile so a later `renew.ts` can detect generation drift against
84
+ // GET /apps/:appId and trigger auto-migration.
85
+ generation: number;
86
+ // Key the keeper stored the per-app in-flight slot under. Callers pass
87
+ // this back to POST /tx-broadcast after the wallet returns a hash so the
88
+ // slot is extended from signing-TTL to landing-TTL. For registered apps
89
+ // this is the appId; for direct-paste buys it falls back to zkAppAddress.
90
+ appKey: string;
91
+ };
92
+
93
+ export type ConfirmBuyRequest = {
94
+ keeperUrl: string;
95
+ zkAppAddress: string;
96
+ licenseHash: string;
97
+ txHash: string;
98
+ fetcher?: typeof fetch;
99
+ signal?: AbortSignal;
100
+ };
101
+
102
+ export type BuyStatus = {
103
+ status: 'unknown' | 'pending' | 'confirmed' | 'failed';
104
+ txHash?: string;
105
+ landedAtBlockHeight?: number;
106
+ landedAtSlot?: number;
107
+ elapsedMs?: number;
108
+ };
109
+
110
+ function baseUrl(url: string): string {
111
+ return url.replace(/\/+$/, '');
112
+ }
113
+
114
+ async function readJsonError(resp: Response): Promise<string> {
115
+ try {
116
+ const body = await resp.json() as Record<string, unknown>;
117
+ if (typeof body.error === 'string') return body.error;
118
+ } catch { /* body wasn't JSON */ }
119
+ return `HTTP ${resp.status}`;
120
+ }
121
+
122
+ // POST /prove/buy — keeper compiles + proves the buy tx.
123
+ // The buyer's wallet then signs the returned provenTxJson.
124
+ //
125
+ // Pre-network guards:
126
+ // - salesFrozen === true → refuse immediately (keeper would 423 anyway).
127
+ // - freezeAfterSlot announced AND this buy's 14-day refund window would
128
+ // extend past the freeze slot AND refundWaiverAccepted !== true →
129
+ // refuse (buyer must explicitly accept that the scheduled hardfork
130
+ // can truncate their refund window). Buys whose full window ends
131
+ // before the freeze proceed without a waiver.
132
+ //
133
+ // Endpoint routing:
134
+ // - refundWaiverAccepted === true → POST /prove/buy-no-escrow. The no-escrow
135
+ // circuit method pays vendor+platform directly (98/2 split, no escrow
136
+ // tree write). This is chosen either voluntarily by the buyer (opting out
137
+ // of the 14-day statutory right of withdrawal in exchange for immediate
138
+ // delivery) or forced by a freeze overlap.
139
+ // - refundWaiverAccepted !== true → POST /prove/buy (standard escrow path
140
+ // with a 14-day refund window).
141
+ export async function proveBuy(req: ProveBuyRequest): Promise<ProveBuyResponse> {
142
+ const fetcher = req.fetcher ?? fetch;
143
+ const freeze = await resolveFreezeState({
144
+ keeperUrl: req.keeperUrl,
145
+ freezeAfterSlot: req.freezeAfterSlot,
146
+ fetcher,
147
+ signal: req.signal,
148
+ });
149
+ if (freeze.salesFrozen) {
150
+ throw new Error('Sales frozen — new buys refused ahead of scheduled hardfork migration');
151
+ }
152
+ let windowOverlapsFreeze = false;
153
+ if (freeze.freezeAfterSlot !== null) {
154
+ // Waiver required when the refund window would overlap the freeze, or
155
+ // when the current slot is unknown client-side (safe default). The
156
+ // +TOLERANCE_WINDOW_N margin accounts for slot-precondition slop
157
+ // between prove and inclusion: the real inclusion slot can be up to
158
+ // TOLERANCE_WINDOW_N slots past `currentSlot`, so we include that in
159
+ // the overlap comparison rather than let a late inclusion silently
160
+ // push the refund deadline past the freeze without a waiver.
161
+ windowOverlapsFreeze =
162
+ freeze.currentSlot === null ||
163
+ freeze.currentSlot + REFUND_WINDOW_N + TOLERANCE_WINDOW_N > freeze.freezeAfterSlot;
164
+ if (windowOverlapsFreeze && req.refundWaiverAccepted !== true) {
165
+ throw new Error(
166
+ `refundWaiverAccepted must be true — the scheduled hardfork at slot ${freeze.freezeAfterSlot} can truncate the 14-day refund window`,
167
+ );
168
+ }
169
+ }
170
+ const useNoEscrow = req.refundWaiverAccepted === true;
171
+ const endpoint = useNoEscrow ? '/prove/buy-no-escrow' : '/prove/buy';
172
+ const resp = await fetcher(`${baseUrl(req.keeperUrl)}${endpoint}`, {
173
+ method: 'POST',
174
+ headers: { 'Content-Type': 'application/json' },
175
+ signal: req.signal,
176
+ body: JSON.stringify({
177
+ licenseHash: req.licenseHash,
178
+ buyerAddress: req.buyerAddress,
179
+ zkAppAddress: req.zkAppAddress,
180
+ duration: req.duration,
181
+ buyerCountry: req.buyerCountry,
182
+ ...(req.buyerZip ? { buyerZip: req.buyerZip } : {}),
183
+ ...(req.refundWaiverAccepted === true ? { refundWaiverAccepted: true } : {}),
184
+ }),
185
+ });
186
+ if (!resp.ok) {
187
+ // Distinct guidance for the no-escrow path: a 404 here means the
188
+ // vendor's zkApp was deployed before the no-escrow method existed. Fix
189
+ // is a redeploy through the hardfork-migration flow (§12.13); until
190
+ // then the escrowless (waiver) path is unavailable on this vendor.
191
+ if (useNoEscrow && resp.status === 404) {
192
+ throw new Error(
193
+ 'Keeper does not offer /prove/buy-no-escrow — this vendor\'s zkApp does not support the escrowless (waiver) path; vendor must redeploy via the hardfork-migration flow',
194
+ );
195
+ }
196
+ const parsed = await readErrorBody(resp);
197
+ const queueBusy = tryQueueBusyFromBody(parsed);
198
+ if (queueBusy) throw queueBusy;
199
+ throw new Error(parsed.message);
200
+ }
201
+ return await resp.json() as ProveBuyResponse;
202
+ }
203
+
204
+ // POST /confirm/buy — promotes the in-memory intent staged by /prove/buy
205
+ // into a persisted (pending: true) LicenseRecord. Call this only after the
206
+ // wallet has actually returned a txHash — a cancelled or dropped signature
207
+ // leaves the intent to expire on its own.
208
+ export async function confirmBuy(req: ConfirmBuyRequest): Promise<{ ok: true }> {
209
+ const fetcher = req.fetcher ?? fetch;
210
+ const resp = await fetcher(`${baseUrl(req.keeperUrl)}/confirm/buy`, {
211
+ method: 'POST',
212
+ headers: { 'Content-Type': 'application/json' },
213
+ signal: req.signal,
214
+ body: JSON.stringify({
215
+ zkAppAddress: req.zkAppAddress,
216
+ licenseHash: req.licenseHash,
217
+ txHash: req.txHash,
218
+ }),
219
+ });
220
+ if (!resp.ok) throw new Error(await readJsonError(resp));
221
+ return { ok: true };
222
+ }
223
+
224
+ export type PollBuyStatusOptions = {
225
+ keeperUrl: string;
226
+ licenseHash: string;
227
+ fetcher?: typeof fetch;
228
+ // How often to poll and how long to wait total. Buy-watch flips 'pending'
229
+ // → 'confirmed' within seconds of tx inclusion, so a snappy interval keeps
230
+ // the UI live without hammering the keeper.
231
+ intervalMs?: number; // default 3 000
232
+ timeoutMs?: number; // default 10 min
233
+ signal?: AbortSignal;
234
+ onTick?: (status: BuyStatus) => void;
235
+ };
236
+
237
+ // Polls GET /buy/status until it returns a terminal state ('confirmed' or
238
+ // 'failed') or the timeout fires. Terminal state is resolved via the
239
+ // returned status; 'unknown' just means the keeper hasn't seen the tx yet.
240
+ export async function pollBuyStatus(opts: PollBuyStatusOptions): Promise<BuyStatus> {
241
+ const fetcher = opts.fetcher ?? fetch;
242
+ const interval = opts.intervalMs ?? 3_000;
243
+ const timeout = opts.timeoutMs ?? 10 * 60 * 1000;
244
+ const deadline = Date.now() + timeout;
245
+ const url = `${baseUrl(opts.keeperUrl)}/buy/status?licenseHash=${encodeURIComponent(opts.licenseHash)}`;
246
+
247
+ while (true) {
248
+ if (opts.signal?.aborted) throw new Error('pollBuyStatus aborted');
249
+ let status: BuyStatus = { status: 'unknown' };
250
+ try {
251
+ const resp = await fetcher(url, { signal: opts.signal });
252
+ if (resp.ok) status = await resp.json() as BuyStatus;
253
+ } catch { /* transient network — try again on next tick */ }
254
+ opts.onTick?.(status);
255
+ if (status.status === 'confirmed' || status.status === 'failed') return status;
256
+ if (Date.now() >= deadline) return status;
257
+ await new Promise<void>((r) => setTimeout(r, interval));
258
+ }
259
+ }
@@ -0,0 +1,55 @@
1
+ // Hand-written declarations for contractInterface.ts.
2
+ // tsc can't emit these automatically because the inferred return types of
3
+ // Struct() and SmartContract reference internal o1js paths outside this
4
+ // package's rootDir, triggering TS2742. Callers get full typing on the
5
+ // methods they actually invoke by importing the corresponding o1js types
6
+ // directly (Field, PublicKey, etc.) in their own code.
7
+
8
+ import type { Field, UInt64 } from 'o1js';
9
+
10
+ export const LicenseIssuedEvent: any;
11
+ export type LicenseIssuedEvent = any;
12
+
13
+ export const ExpiryUpdateEvent: any;
14
+ export type ExpiryUpdateEvent = any;
15
+
16
+ export const PriceUpdatedEvent: any;
17
+ export type PriceUpdatedEvent = any;
18
+
19
+ export const AppCreatedEvent: any;
20
+ export type AppCreatedEvent = any;
21
+
22
+ export const LicenseMigratedEvent: any;
23
+ export type LicenseMigratedEvent = any;
24
+
25
+ export function unpackPrices(packed: Field): { priceMonthly: UInt64; priceYearly: UInt64; priceFiveYear: UInt64 };
26
+
27
+ export const MONTHLY_SLOTS_N: number;
28
+ export const ONE_YEAR_SLOTS_N: number;
29
+ export const FIVE_YEAR_SLOTS_N: number;
30
+ export const REFUND_WINDOW_N: number;
31
+ export const GRACE_PERIOD_N: number;
32
+ export const MS_PER_SLOT_N: number;
33
+ export const SLOTS_PER_DAY_N: number;
34
+ export const TOLERANCE_WINDOW_N: number;
35
+
36
+ export const EMPTY: Field;
37
+ export const EMPTY_ROOT: Field;
38
+
39
+ export const DURATION_LABELS: readonly ['1 Month', '1 Year', '5 Years'];
40
+ export type DurationLabel = '1 Month' | '1 Year' | '5 Years';
41
+ export const DURATION_INDEX: { readonly '1 Month': 0; readonly '1 Year': 1; readonly '5 Years': 2 };
42
+
43
+ export function durationSlotsFromLabel(label?: string | null): number | null;
44
+ export function durationIndexFromLabel(label?: string | null): number | null;
45
+ export function durationLabelFromIndex(idx: number): DurationLabel | null;
46
+
47
+ export const LicensingAppEventsContract: any;
48
+
49
+ export const SUPPORTED_LICENSING_APP_VERSIONS: readonly number[];
50
+ export function fetchLicensingAppVersion(zkAppAddress: string): Promise<number | null>;
51
+
52
+ export const CANONICAL_VK_HASHES: Readonly<Record<number, string>>;
53
+ export const CURRENT_GENERATION: number;
54
+ export const CANONICAL_VK_HASH: string;
55
+ export const CANONICAL_LICENSE_PROOF_VK_HASH: string;