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.
- package/README.md +132 -23
- package/build/archiveBootstrap.d.ts.map +1 -0
- package/build/archiveBootstrap.js +1 -1
- package/build/archiveBootstrap.js.map +1 -0
- package/build/buyClient.d.ts +2 -0
- package/build/buyClient.d.ts.map +1 -0
- package/build/buyClient.js +8 -2
- package/build/buyClient.js.map +1 -0
- package/build/contractInterface.d.ts.map +1 -0
- package/build/contractInterface.js +1 -1
- package/build/contractInterface.js.map +1 -0
- package/build/countryStats.d.ts +6 -1
- package/build/countryStats.d.ts.map +1 -0
- package/build/countryStats.js +33 -9
- package/build/countryStats.js.map +1 -0
- package/build/deployClient.d.ts.map +1 -0
- package/build/deployClient.js +1 -1
- package/build/deployClient.js.map +1 -0
- package/build/freezeClient.d.ts.map +1 -0
- package/build/freezeClient.js +1 -1
- package/build/freezeClient.js.map +1 -0
- package/build/generationClient.d.ts.map +1 -0
- package/build/generationClient.js +1 -1
- package/build/generationClient.js.map +1 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +1 -1
- package/build/index.js.map +1 -0
- package/build/licenseProof.d.ts.map +1 -0
- package/build/licenseProof.js +1 -1
- package/build/licenseProof.js.map +1 -0
- package/build/licenseRecord.d.ts +10 -0
- package/build/licenseRecord.d.ts.map +1 -0
- package/build/licenseRecord.js +1 -1
- package/build/licenseRecord.js.map +1 -0
- package/build/licenseStore.d.ts +1 -1
- package/build/licenseStore.d.ts.map +1 -0
- package/build/licenseStore.js +4 -2
- package/build/licenseStore.js.map +1 -0
- package/build/manifestVerify.d.ts.map +1 -0
- package/build/manifestVerify.js +1 -1
- package/build/manifestVerify.js.map +1 -0
- package/build/migrateClient.d.ts +5 -21
- package/build/migrateClient.d.ts.map +1 -0
- package/build/migrateClient.js +29 -17
- package/build/migrateClient.js.map +1 -0
- package/build/ownership.d.ts.map +1 -0
- package/build/ownership.js +1 -1
- package/build/ownership.js.map +1 -0
- package/build/proveErrors.d.ts +35 -0
- package/build/proveErrors.d.ts.map +1 -0
- package/build/proveErrors.js +49 -0
- package/build/proveErrors.js.map +1 -0
- package/build/proveRetry.d.ts +38 -0
- package/build/proveRetry.d.ts.map +1 -0
- package/build/proveRetry.js +89 -0
- package/build/proveRetry.js.map +1 -0
- package/build/refundClient.d.ts.map +1 -0
- package/build/refundClient.js +1 -1
- package/build/refundClient.js.map +1 -0
- package/build/renewClient.d.ts +1 -0
- package/build/renewClient.d.ts.map +1 -0
- package/build/renewClient.js +9 -3
- package/build/renewClient.js.map +1 -0
- package/build/statsClient.d.ts +28 -0
- package/build/statsClient.d.ts.map +1 -0
- package/build/statsClient.js +34 -5
- package/build/statsClient.js.map +1 -0
- package/build/txBroadcastClient.d.ts +26 -0
- package/build/txBroadcastClient.d.ts.map +1 -0
- package/build/txBroadcastClient.js +20 -0
- package/build/txBroadcastClient.js.map +1 -0
- package/build/vendorAccountClient.d.ts +55 -0
- package/build/vendorAccountClient.d.ts.map +1 -0
- package/build/vendorAccountClient.js +44 -0
- package/build/vendorAccountClient.js.map +1 -0
- package/build/verifyCore.d.ts.map +1 -0
- package/build/verifyCore.js +1 -1
- package/build/verifyCore.js.map +1 -0
- package/build/verifyService.d.ts.map +1 -0
- package/build/verifyService.js +1 -1
- package/build/verifyService.js.map +1 -0
- package/build/zipToState.d.ts +2 -0
- package/build/zipToState.d.ts.map +1 -0
- package/build/zipToState.js +94 -0
- package/build/zipToState.js.map +1 -0
- package/package.json +19 -4
- package/src/archiveBootstrap.ts +285 -0
- package/src/buyClient.ts +259 -0
- package/src/contractInterface.d.ts +55 -0
- package/src/contractInterface.ts +242 -0
- package/src/countryStats.ts +171 -0
- package/src/deployClient.ts +388 -0
- package/src/freezeClient.ts +117 -0
- package/src/generationClient.ts +164 -0
- package/src/index.ts +392 -0
- package/src/licenseProof.d.ts +26 -0
- package/src/licenseProof.ts +48 -0
- package/src/licenseRecord.ts +91 -0
- package/src/licenseStore.ts +358 -0
- package/src/manifestVerify.ts +106 -0
- package/src/migrateClient.ts +82 -0
- package/src/ownership.ts +198 -0
- package/src/proveErrors.ts +70 -0
- package/src/refundClient.ts +144 -0
- package/src/renewClient.ts +209 -0
- package/src/statsClient.ts +190 -0
- package/src/txBroadcastClient.ts +48 -0
- package/src/vendorAccountClient.ts +106 -0
- package/src/verifyCore.ts +84 -0
- package/src/verifyService.ts +799 -0
- 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
|
+
}
|