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,198 @@
1
+ // Copyright (c) 2025-2026 zkLicensing project developers
2
+ // SPDX-License-Identifier: BUSL-1.1
3
+
4
+ /**
5
+ * ownership.ts
6
+ *
7
+ * Ownership-token helpers and a small in-memory nonce store for the
8
+ * challenge-response /verify flow.
9
+ *
10
+ * Replay-resistance: the LicenseProof's public input binds (licenseHash,
11
+ * nonce). The keeper hands out a nonce, the client proves against it, the
12
+ * keeper consumes the nonce on the way through. Tokens are HMAC-SHA256 of
13
+ * the (zkAppAddress, licenseHash, exp, jti?) tuple so signature validation
14
+ * is purely cryptographic — no server-side lookup, restart-safe as long as
15
+ * the HMAC key persists.
16
+ *
17
+ * Session tracking: the optional `jti` field lets the caller run a
18
+ * revocation / concurrency layer on top of the stateless HMAC — the token
19
+ * is still validated cryptographically, but the caller can also check that
20
+ * the jti is present in a per-license active-session set. Absent jti keeps
21
+ * old callers working unchanged (there is no session tracking to begin with,
22
+ * so nothing needs to migrate).
23
+ */
24
+
25
+ import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
26
+ import { Field } from 'o1js';
27
+
28
+ export const OWNERSHIP_CHALLENGE_TTL_MS = 600_000;
29
+ // A device that stays active refreshes via a fresh /challenge + /respond;
30
+ // a lost/uninstalled device burns its slot for at most this long before
31
+ // the server prunes it (see verifyService session store). 7 days keeps
32
+ // re-prove pressure low on daily-use apps while still bounding the recovery
33
+ // window for owners hitting the concurrent-device cap.
34
+ export const OWNERSHIP_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
35
+
36
+ export type OwnershipTokenPayload = {
37
+ z: string;
38
+ l: string;
39
+ e: number;
40
+ // Random device-session id. Optional so pre-jti callers still round-trip.
41
+ // When present, the server-side session store gates verify-time access:
42
+ // an HMAC-valid token whose jti was released (or evicted by the concurrent-
43
+ // device cap) is refused.
44
+ jti?: string;
45
+ };
46
+
47
+ // Generate a 128-bit random jti. base64url so it drops into token payloads
48
+ // and query strings without escaping.
49
+ export function newJti(): string {
50
+ return randomBytes(16).toString('base64url');
51
+ }
52
+
53
+ // --- challenge store ---------------------------------------------------------
54
+
55
+ export type ChallengeStore = {
56
+ issue(licenseHash: string): { nonce: string; ttl: number };
57
+ consume(licenseHash: string, nonce: string): 'ok' | 'unknown' | 'expired';
58
+ size(): number;
59
+ };
60
+
61
+ export function createChallengeStore(ttlMs: number = OWNERSHIP_CHALLENGE_TTL_MS): ChallengeStore {
62
+ const issued = new Map<string, number>(); // `${lh}:${nonce}` → expiresAt
63
+ return {
64
+ issue(licenseHash: string) {
65
+ const nonce = Field.random().toString();
66
+ issued.set(`${licenseHash}:${nonce}`, Date.now() + ttlMs);
67
+ return { nonce, ttl: ttlMs };
68
+ },
69
+ consume(licenseHash: string, nonce: string) {
70
+ const key = `${licenseHash}:${nonce}`;
71
+ const expiresAt = issued.get(key);
72
+ if (expiresAt === undefined) return 'unknown';
73
+ issued.delete(key);
74
+ if (Date.now() > expiresAt) return 'expired';
75
+ return 'ok';
76
+ },
77
+ size: () => issued.size,
78
+ };
79
+ }
80
+
81
+ // --- token signing -----------------------------------------------------------
82
+
83
+ export function signOwnershipToken(key: Buffer, payload: OwnershipTokenPayload): string {
84
+ const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
85
+ const mac = createHmac('sha256', key).update(body).digest('base64url');
86
+ return `${body}.${mac}`;
87
+ }
88
+
89
+ export function verifyOwnershipToken(key: Buffer, token: string): OwnershipTokenPayload | null {
90
+ const dot = token.indexOf('.');
91
+ if (dot < 0) return null;
92
+ const body = token.slice(0, dot);
93
+ const mac = token.slice(dot + 1);
94
+ const expected = createHmac('sha256', key).update(body).digest('base64url');
95
+ const a = Buffer.from(mac);
96
+ const b = Buffer.from(expected);
97
+ if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
98
+ try {
99
+ const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as OwnershipTokenPayload;
100
+ if (Date.now() > payload.e) return null;
101
+ return payload;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ export function newOwnershipKey(): Buffer {
108
+ return randomBytes(32);
109
+ }
110
+
111
+ // --- session store -----------------------------------------------------------
112
+ //
113
+ // Tracks active device-sessions per license, enforced by the caller (see
114
+ // verifyService /respond, /releaseSeat, /reset-sessions). The store is a plain
115
+ // in-memory Map — restart-safe by design: a keeper restart drops all sessions,
116
+ // requiring devices to re-prove ownership. That is intentional; the alternative
117
+ // (persisted sessions) would let a stolen /respond bypass concurrency caps
118
+ // across restarts without any signal to the license owner.
119
+ //
120
+ // Cap semantics:
121
+ // - cap == 0 (or undefined): no limit — insert always succeeds.
122
+ // - cap > 0: prune expired first; if remaining active count >= cap, refuse
123
+ // the insert. The caller decides how to surface that (429 in verifyService).
124
+ //
125
+ // Explicit release (POST /releaseSeat by a caller holding the token) and full
126
+ // reset (POST /reset-sessions after re-proving ownership) both flow through
127
+ // this same store.
128
+
129
+ export type SessionStore = {
130
+ // Insert a new session. Prunes expired first. Returns:
131
+ // 'ok' → session inserted; `active` includes this one.
132
+ // 'at-cap' → cap reached; caller should surface 429.
133
+ insert(licenseHash: string, jti: string, exp: number, cap: number): { state: 'ok' | 'at-cap'; active: number };
134
+ // Membership check for verify-time. Prunes expired opportunistically so a
135
+ // token whose jti expired isn't kept alive by verify calls hitting a stale
136
+ // slot in the map.
137
+ has(licenseHash: string, jti: string): boolean;
138
+ // Release a single seat. Idempotent — returns whether the jti was present.
139
+ releaseSeat(licenseHash: string, jti: string): boolean;
140
+ // Drop all sessions for a license. Used by /reset-sessions after a fresh
141
+ // ownership proof. Returns the count of sessions dropped (0 if none).
142
+ reset(licenseHash: string): number;
143
+ // Live counts per license, for admin / support tooling. Prunes expired
144
+ // entries before counting.
145
+ activeCount(licenseHash: string): number;
146
+ // Total sessions across all licenses — for /health telemetry.
147
+ size(): number;
148
+ };
149
+
150
+ export function createSessionStore(): SessionStore {
151
+ // licenseHash → jti → expiresAt
152
+ const sessions = new Map<string, Map<string, number>>();
153
+ const pruneLicense = (licenseHash: string): Map<string, number> | undefined => {
154
+ const perLic = sessions.get(licenseHash);
155
+ if (!perLic) return undefined;
156
+ const now = Date.now();
157
+ for (const [jti, exp] of perLic) if (exp <= now) perLic.delete(jti);
158
+ if (perLic.size === 0) { sessions.delete(licenseHash); return undefined; }
159
+ return perLic;
160
+ };
161
+ return {
162
+ insert(licenseHash, jti, exp, cap) {
163
+ const perLic = pruneLicense(licenseHash) ?? new Map<string, number>();
164
+ if (cap > 0 && perLic.size >= cap && !perLic.has(jti)) {
165
+ return { state: 'at-cap', active: perLic.size };
166
+ }
167
+ perLic.set(jti, exp);
168
+ sessions.set(licenseHash, perLic);
169
+ return { state: 'ok', active: perLic.size };
170
+ },
171
+ has(licenseHash, jti) {
172
+ const perLic = pruneLicense(licenseHash);
173
+ return perLic?.has(jti) ?? false;
174
+ },
175
+ releaseSeat(licenseHash, jti) {
176
+ const perLic = sessions.get(licenseHash);
177
+ if (!perLic) return false;
178
+ const had = perLic.delete(jti);
179
+ if (perLic.size === 0) sessions.delete(licenseHash);
180
+ return had;
181
+ },
182
+ reset(licenseHash) {
183
+ const perLic = sessions.get(licenseHash);
184
+ if (!perLic) return 0;
185
+ const n = perLic.size;
186
+ sessions.delete(licenseHash);
187
+ return n;
188
+ },
189
+ activeCount(licenseHash) {
190
+ return pruneLicense(licenseHash)?.size ?? 0;
191
+ },
192
+ size() {
193
+ let n = 0;
194
+ for (const perLic of sessions.values()) n += perLic.size;
195
+ return n;
196
+ },
197
+ };
198
+ }
@@ -0,0 +1,70 @@
1
+ // Copyright (c) 2025-2026 zkLicensing project developers
2
+ // SPDX-License-Identifier: BUSL-1.1
3
+
4
+ /**
5
+ * proveErrors.ts
6
+ *
7
+ * Shared typed error for the "proving queue busy" 409 surface returned by
8
+ * /prove/buy, /prove/buy-no-escrow, /prove/renew, and /prove/migrate. The
9
+ * keeper serializes all four endpoints on a per-app in-flight slot: while a
10
+ * proof is out with a buyer waiting to be signed (or a signed tx is waiting
11
+ * to land), further requests against the same app get an immediate 409 with
12
+ * a `retryAfterMs` hint. Callers should catch this and wait+retry rather
13
+ * than surfacing the failure — the wallet popup never opens for a proof
14
+ * with no live chance of landing.
15
+ *
16
+ * `queueDepth` is 1 in v1 (single slot per app); the field exists so that a
17
+ * future Tier-1 optimistic-chaining pass can widen it without breaking
18
+ * clients written today.
19
+ */
20
+
21
+ export class ProveQueueBusyError extends Error {
22
+ readonly status = 409;
23
+ readonly retryAfterMs: number;
24
+ readonly queueDepth: number;
25
+ constructor(message: string, retryAfterMs: number, queueDepth: number) {
26
+ super(message);
27
+ this.name = 'ProveQueueBusyError';
28
+ this.retryAfterMs = retryAfterMs;
29
+ this.queueDepth = queueDepth;
30
+ }
31
+ }
32
+
33
+ // Shared 409-body shape used by all four prove endpoints. Matches keeper.
34
+ export type ProveQueueBusyBody = {
35
+ error: 'proving queue busy';
36
+ retryAfterMs: number;
37
+ queueDepth?: number;
38
+ };
39
+
40
+ // Reads the response body as JSON exactly once and returns a normalized
41
+ // view. Callers use this in the non-2xx branch to decide the specific
42
+ // error surface (queue-busy, endpoint-specific, or generic).
43
+ export type ParsedErrorBody = {
44
+ status: number;
45
+ body: Record<string, unknown>;
46
+ // Convenience: extracted `body.error` when it's a string, else the HTTP fallback.
47
+ message: string;
48
+ };
49
+
50
+ export async function readErrorBody(resp: Response): Promise<ParsedErrorBody> {
51
+ let body: Record<string, unknown> = {};
52
+ try {
53
+ body = await resp.json() as Record<string, unknown>;
54
+ } catch { /* body wasn't JSON */ }
55
+ const message = typeof body.error === 'string' ? body.error : `HTTP ${resp.status}`;
56
+ return { status: resp.status, body, message };
57
+ }
58
+
59
+ // Returns a ProveQueueBusyError if the parsed body matches the queue-busy
60
+ // shape; otherwise null. Cheap to call — no I/O.
61
+ export function tryQueueBusyFromBody(parsed: ParsedErrorBody): ProveQueueBusyError | null {
62
+ if (parsed.status !== 409) return null;
63
+ const { body } = parsed;
64
+ if (body.error !== 'proving queue busy' || typeof body.retryAfterMs !== 'number') return null;
65
+ return new ProveQueueBusyError(
66
+ body.error,
67
+ body.retryAfterMs,
68
+ typeof body.queueDepth === 'number' ? body.queueDepth : 1,
69
+ );
70
+ }
@@ -0,0 +1,144 @@
1
+ // Copyright (c) 2025-2026 zkLicensing project developers
2
+ // SPDX-License-Identifier: BUSL-1.1
3
+
4
+ /**
5
+ * refundClient.ts
6
+ *
7
+ * Browser-safe client for the refund flow. Mirrors `buyClient.ts` in shape:
8
+ * the caller drives the wallet step between `proveRefund` and
9
+ * `confirmRefund` so the SDK stays wallet-agnostic.
10
+ *
11
+ * The 14-day refund window is enforced on the keeper side (returns HTTP 409
12
+ * when closed) and cross-checked in the circuit; the SDK doesn't second-guess
13
+ * it locally so a single source of truth stays authoritative.
14
+ */
15
+
16
+ export type ProveRefundRequest = {
17
+ keeperUrl: string;
18
+ licenseHash: string;
19
+ buyerAddress: string;
20
+ zkAppAddress: string;
21
+ fetcher?: typeof fetch;
22
+ signal?: AbortSignal;
23
+ };
24
+
25
+ export type ProveRefundResponse = {
26
+ provenTxJson: string;
27
+ licenseHash: string;
28
+ };
29
+
30
+ export type ConfirmRefundRequest = {
31
+ keeperUrl: string;
32
+ zkAppAddress: string;
33
+ licenseHash: string;
34
+ txHash: string;
35
+ fetcher?: typeof fetch;
36
+ signal?: AbortSignal;
37
+ };
38
+
39
+ function baseUrl(url: string): string {
40
+ return url.replace(/\/+$/, '');
41
+ }
42
+
43
+ async function readJsonError(resp: Response): Promise<string> {
44
+ try {
45
+ const body = await resp.json() as Record<string, unknown>;
46
+ if (typeof body.error === 'string') return body.error;
47
+ } catch { /* body wasn't JSON */ }
48
+ return `HTTP ${resp.status}`;
49
+ }
50
+
51
+ // POST /prove/refund — keeper compiles + proves the refund tx.
52
+ // The keeper 409s when the 14-day window has closed; that error surfaces
53
+ // through the thrown `Error` message.
54
+ export async function proveRefund(req: ProveRefundRequest): Promise<ProveRefundResponse> {
55
+ const fetcher = req.fetcher ?? fetch;
56
+ const resp = await fetcher(`${baseUrl(req.keeperUrl)}/prove/refund`, {
57
+ method: 'POST',
58
+ headers: { 'Content-Type': 'application/json' },
59
+ signal: req.signal,
60
+ body: JSON.stringify({
61
+ licenseHash: req.licenseHash,
62
+ buyerAddress: req.buyerAddress,
63
+ zkAppAddress: req.zkAppAddress,
64
+ }),
65
+ });
66
+ if (!resp.ok) throw new Error(await readJsonError(resp));
67
+ return await resp.json() as ProveRefundResponse;
68
+ }
69
+
70
+ // POST /confirm/refund — promotes the in-memory refund intent to a
71
+ // pending (refunded: true) LicenseRecord. Best-effort: if the caller
72
+ // swallows the failure, eventSyncLoop will still pick up the on-chain
73
+ // refund event.
74
+ export async function confirmRefund(req: ConfirmRefundRequest): Promise<{ ok: true }> {
75
+ const fetcher = req.fetcher ?? fetch;
76
+ const resp = await fetcher(`${baseUrl(req.keeperUrl)}/confirm/refund`, {
77
+ method: 'POST',
78
+ headers: { 'Content-Type': 'application/json' },
79
+ signal: req.signal,
80
+ body: JSON.stringify({
81
+ zkAppAddress: req.zkAppAddress,
82
+ licenseHash: req.licenseHash,
83
+ txHash: req.txHash,
84
+ }),
85
+ });
86
+ if (!resp.ok) throw new Error(await readJsonError(resp));
87
+ return { ok: true };
88
+ }
89
+
90
+ export type RefundStatus = {
91
+ // 'confirmed' once the eventSync sweep has observed the refundIssued event
92
+ // on-chain (record.status flips to 'refunded'). 'pending' while the tx is
93
+ // in flight. 'unknown' if no record has been persisted yet.
94
+ status: 'confirmed' | 'pending' | 'unknown';
95
+ txHash?: string | null;
96
+ elapsedMs?: number;
97
+ };
98
+
99
+ export type PollRefundStatusOptions = {
100
+ keeperUrl: string;
101
+ zkAppAddress: string;
102
+ licenseHash: string;
103
+ fetcher?: typeof fetch;
104
+ intervalMs?: number; // default 10 000
105
+ timeoutMs?: number; // default 20 min
106
+ signal?: AbortSignal;
107
+ onTick?: (status: RefundStatus) => void;
108
+ };
109
+
110
+ // Polls GET /license/:zkAppAddress/:licenseHash until the refund has landed
111
+ // on-chain (record.status === 'refunded') or the timeout fires. Mirrors
112
+ // pollBuyStatus / pollRenewStatus.
113
+ export async function pollRefundStatus(opts: PollRefundStatusOptions): Promise<RefundStatus> {
114
+ const fetcher = opts.fetcher ?? fetch;
115
+ const interval = opts.intervalMs ?? 10_000;
116
+ const timeout = opts.timeoutMs ?? 20 * 60 * 1000;
117
+ const startedAt = Date.now();
118
+ const deadline = startedAt + timeout;
119
+ const url = `${baseUrl(opts.keeperUrl)}/license/${encodeURIComponent(opts.zkAppAddress)}/${encodeURIComponent(opts.licenseHash)}`;
120
+
121
+ while (true) {
122
+ if (opts.signal?.aborted) throw new Error('pollRefundStatus aborted');
123
+ let status: RefundStatus = { status: 'unknown', elapsedMs: Date.now() - startedAt };
124
+ try {
125
+ const resp = await fetcher(url, { signal: opts.signal });
126
+ if (resp.ok) {
127
+ const body = await resp.json() as {
128
+ found?: boolean; status?: 'active' | 'refunded' | 'expired' | 'unknown'; txHash?: string | null;
129
+ };
130
+ if (!body.found) {
131
+ status = { status: 'unknown', elapsedMs: Date.now() - startedAt };
132
+ } else if (body.status === 'refunded') {
133
+ status = { status: 'confirmed', txHash: body.txHash ?? null, elapsedMs: Date.now() - startedAt };
134
+ } else {
135
+ status = { status: 'pending', txHash: body.txHash ?? null, elapsedMs: Date.now() - startedAt };
136
+ }
137
+ }
138
+ } catch { /* transient network — try again on next tick */ }
139
+ opts.onTick?.(status);
140
+ if (status.status === 'confirmed') return status;
141
+ if (Date.now() >= deadline) return status;
142
+ await new Promise<void>((r) => setTimeout(r, interval));
143
+ }
144
+ }
@@ -0,0 +1,209 @@
1
+ // Copyright (c) 2025-2026 zkLicensing project developers
2
+ // SPDX-License-Identifier: BUSL-1.1
3
+
4
+ /**
5
+ * renewClient.ts
6
+ *
7
+ * Browser-safe client for the renew flow. Mirrors `buyClient.ts` in shape:
8
+ * the caller drives the wallet step between `proveRenew` and `confirmRenew`
9
+ * so the SDK stays wallet-agnostic.
10
+ *
11
+ * Unlike buy, renew carries a legally load-bearing `waiverAccepted` flag —
12
+ * EU/UK CRD Article 16(m) extinguishes the 14-day right of withdrawal only
13
+ * on the buyer's express affirmative act (consent to immediate delivery +
14
+ * acknowledgment of loss of withdrawal). We refuse the /prove/renew call
15
+ * when the flag is false to keep third-party integrators from silently
16
+ * shipping a UI that omits the waiver gate.
17
+ *
18
+ * Also refused pre-network when the keeper's sales-freeze schedule has
19
+ * fired (salesFrozen === true) — the keeper would 423 the request anyway,
20
+ * so surfacing the specific message client-side avoids a wasted round-trip.
21
+ */
22
+
23
+ import { resolveFreezeState } from './freezeClient.js';
24
+ import { readErrorBody, tryQueueBusyFromBody } from './proveErrors.js';
25
+
26
+ export type ProveRenewRequest = {
27
+ keeperUrl: string;
28
+ licenseHash: string;
29
+ buyerAddress: string;
30
+ zkAppAddress: string;
31
+ duration: 0 | 1 | 2; // 0 = Monthly, 1 = Yearly, 2 = 5 Years
32
+ // Set from the UI's Article 16(m) consent gate. Missing/false raises
33
+ // before the network call — the SDK never proves a renew without it.
34
+ waiverAccepted: boolean;
35
+ // Optional freeze-slot override. When omitted the SDK reads
36
+ // `process.env.FREEZE_AFTER_SLOT` (Node only), else GET /health.
37
+ freezeAfterSlot?: number | null;
38
+ fetcher?: typeof fetch;
39
+ signal?: AbortSignal;
40
+ };
41
+
42
+ export type ProveRenewResponse = {
43
+ provenTxJson: string;
44
+ newExpirySlot: number;
45
+ licenseHash: string;
46
+ renewAmount: string; // nanomina
47
+ // Deployment generation the keeper proved against. If the buyer's stored
48
+ // ProofFile.generation is smaller, renew.ts walks ancestors and calls
49
+ // migrateLicense on each hop before submitting the renew.
50
+ generation: number;
51
+ // Key the keeper stored the per-app in-flight slot under. Passed back to
52
+ // POST /tx-broadcast after the wallet returns a hash. See ProveBuyResponse.
53
+ appKey: string;
54
+ };
55
+
56
+ export type ConfirmRenewRequest = {
57
+ keeperUrl: string;
58
+ zkAppAddress: string;
59
+ licenseHash: string;
60
+ txHash: string;
61
+ fetcher?: typeof fetch;
62
+ signal?: AbortSignal;
63
+ };
64
+
65
+ function baseUrl(url: string): string {
66
+ return url.replace(/\/+$/, '');
67
+ }
68
+
69
+ async function readJsonError(resp: Response): Promise<string> {
70
+ try {
71
+ const body = await resp.json() as Record<string, unknown>;
72
+ if (typeof body.error === 'string') return body.error;
73
+ } catch { /* body wasn't JSON */ }
74
+ return `HTTP ${resp.status}`;
75
+ }
76
+
77
+ // POST /prove/renew — keeper compiles + proves the renew tx.
78
+ // Rejects locally when `waiverAccepted` is false; a network call in that
79
+ // state would be wasted work and would give the caller no signal that the
80
+ // legal precondition was missing. Also rejects when the keeper's freeze
81
+ // has fired — same reasoning.
82
+ export async function proveRenew(req: ProveRenewRequest): Promise<ProveRenewResponse> {
83
+ if (req.waiverAccepted !== true) {
84
+ throw new Error('waiverAccepted must be true — Article 16(m) waiver required to renew');
85
+ }
86
+ const fetcher = req.fetcher ?? fetch;
87
+ const freeze = await resolveFreezeState({
88
+ keeperUrl: req.keeperUrl,
89
+ freezeAfterSlot: req.freezeAfterSlot,
90
+ fetcher,
91
+ signal: req.signal,
92
+ });
93
+ if (freeze.salesFrozen) {
94
+ throw new Error('Sales frozen — new renewals refused ahead of scheduled hardfork migration');
95
+ }
96
+ const resp = await fetcher(`${baseUrl(req.keeperUrl)}/prove/renew`, {
97
+ method: 'POST',
98
+ headers: { 'Content-Type': 'application/json' },
99
+ signal: req.signal,
100
+ body: JSON.stringify({
101
+ licenseHash: req.licenseHash,
102
+ buyerAddress: req.buyerAddress,
103
+ zkAppAddress: req.zkAppAddress,
104
+ duration: req.duration,
105
+ }),
106
+ });
107
+ if (!resp.ok) {
108
+ const parsed = await readErrorBody(resp);
109
+ const queueBusy = tryQueueBusyFromBody(parsed);
110
+ if (queueBusy) throw queueBusy;
111
+ throw new Error(parsed.message);
112
+ }
113
+ return await resp.json() as ProveRenewResponse;
114
+ }
115
+
116
+ // POST /confirm/renew — promotes the in-memory renew intent to a pending
117
+ // LicenseRecord. Call this only after the wallet returns a txHash.
118
+ export async function confirmRenew(req: ConfirmRenewRequest): Promise<{ ok: true }> {
119
+ const fetcher = req.fetcher ?? fetch;
120
+ const resp = await fetcher(`${baseUrl(req.keeperUrl)}/confirm/renew`, {
121
+ method: 'POST',
122
+ headers: { 'Content-Type': 'application/json' },
123
+ signal: req.signal,
124
+ body: JSON.stringify({
125
+ zkAppAddress: req.zkAppAddress,
126
+ licenseHash: req.licenseHash,
127
+ txHash: req.txHash,
128
+ }),
129
+ });
130
+ if (!resp.ok) throw new Error(await readJsonError(resp));
131
+ return { ok: true };
132
+ }
133
+
134
+ export type RenewStatus = {
135
+ // 'confirmed' once the eventSync sweep has observed the licenseRenewed event
136
+ // on-chain and cleared the pending flag with the new expiry in place.
137
+ // 'pending' while the tx is in flight. 'unknown' if the license record
138
+ // hasn't been persisted yet (confirmRenew hasn't run or the intent expired).
139
+ status: 'confirmed' | 'pending' | 'unknown';
140
+ expirySlot?: number;
141
+ txHash?: string | null;
142
+ elapsedMs?: number;
143
+ };
144
+
145
+ export type PollRenewStatusOptions = {
146
+ keeperUrl: string;
147
+ zkAppAddress: string;
148
+ licenseHash: string;
149
+ // The expected new expiry (from ProveRenewResponse). Confirmation requires
150
+ // BOTH pending=false AND the on-chain record's expirySlot matching or
151
+ // exceeding this — the pending flag alone flips before the sweep, so
152
+ // matching against the expected slot avoids reporting confirmed on the
153
+ // stale pre-renew record.
154
+ newExpirySlot: number;
155
+ fetcher?: typeof fetch;
156
+ // Mina slots are ~90 s and a tx usually lands within a few slots, so a
157
+ // 10 s tick keeps the UI live without hammering the keeper.
158
+ intervalMs?: number; // default 10 000
159
+ timeoutMs?: number; // default 20 min
160
+ signal?: AbortSignal;
161
+ onTick?: (status: RenewStatus) => void;
162
+ };
163
+
164
+ // Polls GET /license/:zkAppAddress/:licenseHash until the renew has landed
165
+ // on-chain (pending flipped and new expiry recorded) or the timeout fires.
166
+ // Mirrors pollBuyStatus, except the terminal condition is Mina-side rather
167
+ // than keeper-side buy-watch — renew has no dedicated watch map.
168
+ export async function pollRenewStatus(opts: PollRenewStatusOptions): Promise<RenewStatus> {
169
+ const fetcher = opts.fetcher ?? fetch;
170
+ const interval = opts.intervalMs ?? 10_000;
171
+ const timeout = opts.timeoutMs ?? 20 * 60 * 1000;
172
+ const startedAt = Date.now();
173
+ const deadline = startedAt + timeout;
174
+ const url = `${baseUrl(opts.keeperUrl)}/license/${encodeURIComponent(opts.zkAppAddress)}/${encodeURIComponent(opts.licenseHash)}`;
175
+
176
+ while (true) {
177
+ if (opts.signal?.aborted) throw new Error('pollRenewStatus aborted');
178
+ let status: RenewStatus = { status: 'unknown', elapsedMs: Date.now() - startedAt };
179
+ try {
180
+ const resp = await fetcher(url, { signal: opts.signal });
181
+ if (resp.ok) {
182
+ const body = await resp.json() as {
183
+ found?: boolean; expirySlot?: number; pending?: boolean; txHash?: string | null;
184
+ };
185
+ if (!body.found) {
186
+ status = { status: 'unknown', elapsedMs: Date.now() - startedAt };
187
+ } else if (body.pending === false && (body.expirySlot ?? 0) >= opts.newExpirySlot) {
188
+ status = {
189
+ status: 'confirmed',
190
+ expirySlot: body.expirySlot,
191
+ txHash: body.txHash ?? null,
192
+ elapsedMs: Date.now() - startedAt,
193
+ };
194
+ } else {
195
+ status = {
196
+ status: 'pending',
197
+ expirySlot: body.expirySlot,
198
+ txHash: body.txHash ?? null,
199
+ elapsedMs: Date.now() - startedAt,
200
+ };
201
+ }
202
+ }
203
+ } catch { /* transient network — try again on next tick */ }
204
+ opts.onTick?.(status);
205
+ if (status.status === 'confirmed') return status;
206
+ if (Date.now() >= deadline) return status;
207
+ await new Promise<void>((r) => setTimeout(r, interval));
208
+ }
209
+ }