zklicensing 0.1.0-beta.0
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/LICENSE +104 -0
- package/README.md +603 -0
- package/build/archiveBootstrap.d.ts +60 -0
- package/build/archiveBootstrap.js +234 -0
- package/build/buyClient.d.ts +58 -0
- package/build/buyClient.js +162 -0
- package/build/contractInterface.d.ts +55 -0
- package/build/contractInterface.js +213 -0
- package/build/countryStats.d.ts +37 -0
- package/build/countryStats.js +89 -0
- package/build/deployClient.d.ts +168 -0
- package/build/deployClient.js +172 -0
- package/build/freezeClient.d.ts +44 -0
- package/build/freezeClient.js +59 -0
- package/build/generationClient.d.ts +54 -0
- package/build/generationClient.js +113 -0
- package/build/index.d.ts +72 -0
- package/build/index.js +258 -0
- package/build/licenseProof.d.ts +26 -0
- package/build/licenseProof.js +41 -0
- package/build/licenseRecord.d.ts +39 -0
- package/build/licenseRecord.js +6 -0
- package/build/licenseStore.d.ts +49 -0
- package/build/licenseStore.js +303 -0
- package/build/manifestVerify.d.ts +30 -0
- package/build/manifestVerify.js +95 -0
- package/build/migrateClient.d.ts +43 -0
- package/build/migrateClient.js +47 -0
- package/build/ownership.d.ts +34 -0
- package/build/ownership.js +147 -0
- package/build/refundClient.d.ts +52 -0
- package/build/refundClient.js +92 -0
- package/build/renewClient.d.ts +49 -0
- package/build/renewClient.js +135 -0
- package/build/statsClient.d.ts +96 -0
- package/build/statsClient.js +28 -0
- package/build/verifyCore.d.ts +26 -0
- package/build/verifyCore.js +57 -0
- package/build/verifyService.d.ts +3 -0
- package/build/verifyService.js +690 -0
- package/package.json +118 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* freezeClient.ts
|
|
3
|
+
*
|
|
4
|
+
* Client-side view of the keeper's sales-freeze schedule. A freeze is set
|
|
5
|
+
* ahead of a hardfork so every open escrow window can drain (refund or
|
|
6
|
+
* release) before v1 → v2 migration. Once past the freeze slot the keeper
|
|
7
|
+
* refuses new buys and renewals; refunds and verify remain open.
|
|
8
|
+
*
|
|
9
|
+
* Resolution order for buy/renew clients that want to know the current
|
|
10
|
+
* freeze state:
|
|
11
|
+
* 1. Caller-supplied `freezeAfterSlot` (explicit override — build-time
|
|
12
|
+
* constant, or pulled from the vendor's own env at boot).
|
|
13
|
+
* 2. `process.env.FREEZE_AFTER_SLOT` when running under Node (server-side
|
|
14
|
+
* integrators / CLIs).
|
|
15
|
+
* 3. Keeper's own `/health` payload — the authoritative source of truth.
|
|
16
|
+
*
|
|
17
|
+
* A `freezeAfterSlot` of 0 / null / undefined means "no freeze scheduled".
|
|
18
|
+
*/
|
|
19
|
+
export type KeeperHealth = {
|
|
20
|
+
status: string;
|
|
21
|
+
network: string;
|
|
22
|
+
slot: number;
|
|
23
|
+
salesFrozen: boolean;
|
|
24
|
+
freezeAfterSlot: number | null;
|
|
25
|
+
};
|
|
26
|
+
export type FreezeState = {
|
|
27
|
+
salesFrozen: boolean;
|
|
28
|
+
freezeAfterSlot: number | null;
|
|
29
|
+
currentSlot: number | null;
|
|
30
|
+
};
|
|
31
|
+
export type GetKeeperHealthOptions = {
|
|
32
|
+
keeperUrl: string;
|
|
33
|
+
fetcher?: typeof fetch;
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
};
|
|
36
|
+
export declare function getKeeperHealth(opts: GetKeeperHealthOptions): Promise<KeeperHealth>;
|
|
37
|
+
export type ResolveFreezeStateOptions = {
|
|
38
|
+
keeperUrl: string;
|
|
39
|
+
freezeAfterSlot?: number | null;
|
|
40
|
+
fetcher?: typeof fetch;
|
|
41
|
+
signal?: AbortSignal;
|
|
42
|
+
};
|
|
43
|
+
export declare function resolveFreezeState(opts: ResolveFreezeStateOptions): Promise<FreezeState>;
|
|
44
|
+
//# sourceMappingURL=freezeClient.d.ts.map
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// All rights reserved.
|
|
3
|
+
function baseUrl(url) {
|
|
4
|
+
return url.replace(/\/+$/, '');
|
|
5
|
+
}
|
|
6
|
+
function envFreezeSlot() {
|
|
7
|
+
// Runs both in Node (process.env) and browser (no `process`). The typeof
|
|
8
|
+
// guard keeps bundlers from choking on the reference.
|
|
9
|
+
if (typeof process === 'undefined' || !process.env)
|
|
10
|
+
return null;
|
|
11
|
+
const raw = process.env.FREEZE_AFTER_SLOT;
|
|
12
|
+
if (!raw)
|
|
13
|
+
return null;
|
|
14
|
+
const n = parseInt(raw, 10);
|
|
15
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
16
|
+
}
|
|
17
|
+
// GET /health — raw payload. Callers that only need the freeze state
|
|
18
|
+
// should use resolveFreezeState() instead; this is exposed for UIs that
|
|
19
|
+
// surface slot / network info alongside freeze.
|
|
20
|
+
export async function getKeeperHealth(opts) {
|
|
21
|
+
const fetcher = opts.fetcher ?? fetch;
|
|
22
|
+
const resp = await fetcher(`${baseUrl(opts.keeperUrl)}/health`, { signal: opts.signal });
|
|
23
|
+
if (!resp.ok)
|
|
24
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
25
|
+
return await resp.json();
|
|
26
|
+
}
|
|
27
|
+
// Resolves the effective freeze state for the caller. When the source is
|
|
28
|
+
// the caller override or process.env, the current slot is not known
|
|
29
|
+
// client-side, so salesFrozen is filled from /health only — the override
|
|
30
|
+
// path returns { freezeAfterSlot: N, salesFrozen: false, currentSlot: null }
|
|
31
|
+
// and lets the keeper reject the actual prove call if the freeze has fired.
|
|
32
|
+
export async function resolveFreezeState(opts) {
|
|
33
|
+
if (typeof opts.freezeAfterSlot === 'number' && opts.freezeAfterSlot > 0) {
|
|
34
|
+
return { salesFrozen: false, freezeAfterSlot: opts.freezeAfterSlot, currentSlot: null };
|
|
35
|
+
}
|
|
36
|
+
const envSlot = envFreezeSlot();
|
|
37
|
+
if (envSlot !== null) {
|
|
38
|
+
return { salesFrozen: false, freezeAfterSlot: envSlot, currentSlot: null };
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const health = await getKeeperHealth({
|
|
42
|
+
keeperUrl: opts.keeperUrl,
|
|
43
|
+
fetcher: opts.fetcher,
|
|
44
|
+
signal: opts.signal,
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
salesFrozen: health.salesFrozen === true,
|
|
48
|
+
freezeAfterSlot: typeof health.freezeAfterSlot === 'number' ? health.freezeAfterSlot : null,
|
|
49
|
+
currentSlot: typeof health.slot === 'number' ? health.slot : null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// /health unreachable — treat as no freeze so we don't block buys on a
|
|
54
|
+
// transient network hiccup. The keeper's own guard is still the last
|
|
55
|
+
// line of defense.
|
|
56
|
+
return { salesFrozen: false, freezeAfterSlot: null, currentSlot: null };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=freezeClient.js.map
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export interface GenerationEntry {
|
|
2
|
+
gen: number;
|
|
3
|
+
zkAppAddress: string;
|
|
4
|
+
status: 'retired' | 'live';
|
|
5
|
+
frozenRoot?: string;
|
|
6
|
+
deployedAt?: string;
|
|
7
|
+
retiredAt?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface GenerationInfo {
|
|
10
|
+
/** Live-generation index (matches the entry with status: 'live'). Null when unverified or missing. */
|
|
11
|
+
current: number | null;
|
|
12
|
+
generations: GenerationEntry[];
|
|
13
|
+
/** True iff signature verified against one of the pinned pubkeys. */
|
|
14
|
+
verified: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface FetchGenerationRequest {
|
|
17
|
+
keeperUrl: string;
|
|
18
|
+
appId: string;
|
|
19
|
+
/** Base64-encoded Ed25519 public keys pinned by the caller. Empty ⇒ verified:false. */
|
|
20
|
+
pinnedPublicKeysBase64: readonly string[];
|
|
21
|
+
fetcher?: typeof fetch;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
}
|
|
24
|
+
export interface FetchGenerationByAddressRequest {
|
|
25
|
+
keeperUrl: string;
|
|
26
|
+
/** Any zkApp address in the app's generation chain — current live or any retired. */
|
|
27
|
+
zkAppAddress: string;
|
|
28
|
+
/** Base64-encoded Ed25519 public keys pinned by the caller. Empty ⇒ verified:false. */
|
|
29
|
+
pinnedPublicKeysBase64: readonly string[];
|
|
30
|
+
fetcher?: typeof fetch;
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* GET /apps/:appId — returns { appId, current, generations, issuedAt, publicKey, signature }.
|
|
35
|
+
* We reconstruct the canonical payload (everything except `publicKey` +
|
|
36
|
+
* `signature`) and verify against the pin list. `publicKey` in the response
|
|
37
|
+
* is advisory only — it's the key the keeper claims to have signed with;
|
|
38
|
+
* the SDK never trusts it in place of a pin.
|
|
39
|
+
*/
|
|
40
|
+
export declare function fetchGenerationInfo(req: FetchGenerationRequest): Promise<GenerationInfo>;
|
|
41
|
+
/**
|
|
42
|
+
* GET /apps/by-address/:zkAppAddress — same signed payload as fetchGenerationInfo,
|
|
43
|
+
* but keyed by an address the caller already has (direct-paste buys, renew on a
|
|
44
|
+
* since-retired address). Buyers arriving without an appId would otherwise have
|
|
45
|
+
* to trust the keeper's unsigned `/apps` list to bind their address to an appId
|
|
46
|
+
* first — defeating the pin.
|
|
47
|
+
*
|
|
48
|
+
* Extra assertion beyond the appId flow: the queried address MUST appear in the
|
|
49
|
+
* returned `generations[]`. Without this, a malicious keeper could return a
|
|
50
|
+
* validly-signed payload for a DIFFERENT app in response to the query, and the
|
|
51
|
+
* signature check alone wouldn't catch it.
|
|
52
|
+
*/
|
|
53
|
+
export declare function fetchGenerationInfoByAddress(req: FetchGenerationByAddressRequest): Promise<GenerationInfo>;
|
|
54
|
+
//# sourceMappingURL=generationClient.d.ts.map
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// All rights reserved.
|
|
3
|
+
/**
|
|
4
|
+
* generationClient.ts
|
|
5
|
+
*
|
|
6
|
+
* Browser-safe fetcher for the keeper's signed generation-list. Returns the
|
|
7
|
+
* app's deployment history — every past address plus the current live one —
|
|
8
|
+
* verified against a caller-supplied pin list of platform manifest public
|
|
9
|
+
* keys.
|
|
10
|
+
*
|
|
11
|
+
* Trust model: the keeper is not the trust anchor for this data — it can
|
|
12
|
+
* be re-hosted, mirrored, cached, or (worst case) impersonated. The pin
|
|
13
|
+
* list is. Any response that fails signature verification is surfaced as
|
|
14
|
+
* `verified: false` with `generations: []` so callers cannot accidentally
|
|
15
|
+
* route a wallet prompt to an unverified address.
|
|
16
|
+
*
|
|
17
|
+
* Optional corroboration channel: an operator that publishes
|
|
18
|
+
* https://<vendor-host>/.well-known/zklicensing/<appId>.json
|
|
19
|
+
* (using the same keeper-signed payload) can be cross-checked from the
|
|
20
|
+
* on-chain zkappUri. Per-app filename because a vendor may host several
|
|
21
|
+
* apps at one origin. The cross-check is opt-in per-app — a 404 at that
|
|
22
|
+
* URL is not a failure signal, just "no corroboration published for this
|
|
23
|
+
* app." That's the caller's job, not this module's.
|
|
24
|
+
*/
|
|
25
|
+
import { verifyManifestSignature } from './manifestVerify.js';
|
|
26
|
+
function baseUrl(url) {
|
|
27
|
+
return url.replace(/\/+$/, '');
|
|
28
|
+
}
|
|
29
|
+
function parseGenerationBody(body) {
|
|
30
|
+
const gensRaw = body.generations;
|
|
31
|
+
return {
|
|
32
|
+
current: typeof body.current === 'number' ? body.current : null,
|
|
33
|
+
generations: Array.isArray(gensRaw) ? gensRaw : [],
|
|
34
|
+
signature: typeof body.signature === 'string' ? body.signature : null,
|
|
35
|
+
issuedAt: typeof body.issuedAt === 'string' ? body.issuedAt : null,
|
|
36
|
+
appId: typeof body.appId === 'string' ? body.appId : null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
async function verifyAndProject(parsed, pinnedPublicKeysBase64) {
|
|
40
|
+
const { current, generations, signature, issuedAt, appId } = parsed;
|
|
41
|
+
if (!signature || !issuedAt || !appId || current === null) {
|
|
42
|
+
return { current, generations, verified: false };
|
|
43
|
+
}
|
|
44
|
+
// Reconstruct the canonical payload — must match keeperService's
|
|
45
|
+
// signPayload input exactly.
|
|
46
|
+
const canonical = { appId, current, generations, issuedAt };
|
|
47
|
+
const verified = await verifyManifestSignature(canonical, signature, pinnedPublicKeysBase64);
|
|
48
|
+
if (!verified) {
|
|
49
|
+
// Never surface unverified `current`/`generations` — a caller must not
|
|
50
|
+
// accidentally route a wallet prompt to an address we couldn't authenticate.
|
|
51
|
+
return { current: null, generations: [], verified: false };
|
|
52
|
+
}
|
|
53
|
+
return { current, generations, verified: true };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* GET /apps/:appId — returns { appId, current, generations, issuedAt, publicKey, signature }.
|
|
57
|
+
* We reconstruct the canonical payload (everything except `publicKey` +
|
|
58
|
+
* `signature`) and verify against the pin list. `publicKey` in the response
|
|
59
|
+
* is advisory only — it's the key the keeper claims to have signed with;
|
|
60
|
+
* the SDK never trusts it in place of a pin.
|
|
61
|
+
*/
|
|
62
|
+
export async function fetchGenerationInfo(req) {
|
|
63
|
+
const fetcher = req.fetcher ?? fetch;
|
|
64
|
+
const resp = await fetcher(`${baseUrl(req.keeperUrl)}/apps/${encodeURIComponent(req.appId)}`, {
|
|
65
|
+
method: 'GET',
|
|
66
|
+
signal: req.signal,
|
|
67
|
+
});
|
|
68
|
+
if (!resp.ok) {
|
|
69
|
+
return { current: null, generations: [], verified: false };
|
|
70
|
+
}
|
|
71
|
+
let body;
|
|
72
|
+
try {
|
|
73
|
+
body = await resp.json();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return { current: null, generations: [], verified: false };
|
|
77
|
+
}
|
|
78
|
+
return verifyAndProject(parseGenerationBody(body), req.pinnedPublicKeysBase64);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* GET /apps/by-address/:zkAppAddress — same signed payload as fetchGenerationInfo,
|
|
82
|
+
* but keyed by an address the caller already has (direct-paste buys, renew on a
|
|
83
|
+
* since-retired address). Buyers arriving without an appId would otherwise have
|
|
84
|
+
* to trust the keeper's unsigned `/apps` list to bind their address to an appId
|
|
85
|
+
* first — defeating the pin.
|
|
86
|
+
*
|
|
87
|
+
* Extra assertion beyond the appId flow: the queried address MUST appear in the
|
|
88
|
+
* returned `generations[]`. Without this, a malicious keeper could return a
|
|
89
|
+
* validly-signed payload for a DIFFERENT app in response to the query, and the
|
|
90
|
+
* signature check alone wouldn't catch it.
|
|
91
|
+
*/
|
|
92
|
+
export async function fetchGenerationInfoByAddress(req) {
|
|
93
|
+
const fetcher = req.fetcher ?? fetch;
|
|
94
|
+
const resp = await fetcher(`${baseUrl(req.keeperUrl)}/apps/by-address/${encodeURIComponent(req.zkAppAddress)}`, { method: 'GET', signal: req.signal });
|
|
95
|
+
if (!resp.ok) {
|
|
96
|
+
return { current: null, generations: [], verified: false };
|
|
97
|
+
}
|
|
98
|
+
let body;
|
|
99
|
+
try {
|
|
100
|
+
body = await resp.json();
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return { current: null, generations: [], verified: false };
|
|
104
|
+
}
|
|
105
|
+
const projected = await verifyAndProject(parseGenerationBody(body), req.pinnedPublicKeysBase64);
|
|
106
|
+
if (!projected.verified)
|
|
107
|
+
return projected;
|
|
108
|
+
if (!projected.generations.some(g => g.zkAppAddress === req.zkAppAddress)) {
|
|
109
|
+
return { current: null, generations: [], verified: false };
|
|
110
|
+
}
|
|
111
|
+
return projected;
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=generationClient.js.map
|
package/build/index.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* verifyLicense — client SDK.
|
|
3
|
+
*
|
|
4
|
+
* Calls the keeper's GET /verify until the on-chain refund window for the
|
|
5
|
+
* license has closed; from that point on returns from a local anchor and
|
|
6
|
+
* never touches the network again. The refund-window decision is server-
|
|
7
|
+
* side: the keeper returns `refundWindowClosed: boolean` on its /verify
|
|
8
|
+
* response, computed from its own record of `purchaseSlot + REFUND_WINDOW_N`
|
|
9
|
+
* against the on-chain `currentSlot`. The SDK gates anchor writes on that
|
|
10
|
+
* boolean and never trusts a `purchaseSlot` supplied by the local proof
|
|
11
|
+
* file, so a user who forges their proof metadata cannot force the offline
|
|
12
|
+
* branch prematurely.
|
|
13
|
+
*
|
|
14
|
+
* Anchor purity: every field persisted in the Anchor derives from the
|
|
15
|
+
* keeper's /verify response — `anchoredSlot`, `expirySlot`, `ownership`.
|
|
16
|
+
* The only client-supplied input to the offline branch is
|
|
17
|
+
* `proof.expirySlot`, used solely as the loadAnchor binding key. A
|
|
18
|
+
* tampered proof file therefore never gets a wrong answer; it gets no
|
|
19
|
+
* offline branch at all and falls through to the keeper on every call.
|
|
20
|
+
*/
|
|
21
|
+
import { REFUND_WINDOW_N, GRACE_PERIOD_N } from './contractInterface.js';
|
|
22
|
+
declare const MS_PER_SLOT = 90000;
|
|
23
|
+
export interface Storage {
|
|
24
|
+
getItem(key: string): string | null;
|
|
25
|
+
setItem(key: string, value: string): void;
|
|
26
|
+
}
|
|
27
|
+
export interface ActivationRecord {
|
|
28
|
+
token: string;
|
|
29
|
+
expiresAt: number;
|
|
30
|
+
secretHash?: string;
|
|
31
|
+
}
|
|
32
|
+
export declare function loadActivation(storage: Storage, zkAppAddress: string, licenseHash: string, nowMs?: number): ActivationRecord | null;
|
|
33
|
+
export declare function saveActivation(storage: Storage, zkAppAddress: string, licenseHash: string, rec: ActivationRecord): void;
|
|
34
|
+
export interface ProofInput {
|
|
35
|
+
licenseHash: string;
|
|
36
|
+
zkAppAddress: string;
|
|
37
|
+
expirySlot: number;
|
|
38
|
+
verifierUrl?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Callback that proves an OwnershipChallenge using a stored secretHash.
|
|
42
|
+
* Callers wire this up with their own o1js + LicenseProof imports so the SDK
|
|
43
|
+
* stays dependency-free for the verify-only path. Return value is the
|
|
44
|
+
* `proof.toJSON()` payload the verifier's POST /respond expects.
|
|
45
|
+
*/
|
|
46
|
+
export type TokenProver = (input: {
|
|
47
|
+
licenseHash: string;
|
|
48
|
+
nonce: string;
|
|
49
|
+
secretHash: string;
|
|
50
|
+
}) => Promise<unknown>;
|
|
51
|
+
export interface VerifyOptions {
|
|
52
|
+
verifierUrl?: string;
|
|
53
|
+
storage?: Storage | null;
|
|
54
|
+
fetcher?: typeof fetch;
|
|
55
|
+
now?: () => number;
|
|
56
|
+
prover?: TokenProver;
|
|
57
|
+
secretHash?: string;
|
|
58
|
+
}
|
|
59
|
+
export interface VerifyResult {
|
|
60
|
+
valid: boolean;
|
|
61
|
+
expirySlot: number;
|
|
62
|
+
expiresAt: string | null;
|
|
63
|
+
currentSlot: number;
|
|
64
|
+
inGracePeriod: boolean;
|
|
65
|
+
remainingDays: number;
|
|
66
|
+
reason: string | null;
|
|
67
|
+
source: 'offline' | 'chain';
|
|
68
|
+
ownership: 'verified' | 'unverified';
|
|
69
|
+
}
|
|
70
|
+
export declare function verifyLicense(proof: ProofInput, options?: VerifyOptions): Promise<VerifyResult>;
|
|
71
|
+
export { REFUND_WINDOW_N, GRACE_PERIOD_N, MS_PER_SLOT };
|
|
72
|
+
//# sourceMappingURL=index.d.ts.map
|
package/build/index.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// All rights reserved.
|
|
3
|
+
/**
|
|
4
|
+
* verifyLicense — client SDK.
|
|
5
|
+
*
|
|
6
|
+
* Calls the keeper's GET /verify until the on-chain refund window for the
|
|
7
|
+
* license has closed; from that point on returns from a local anchor and
|
|
8
|
+
* never touches the network again. The refund-window decision is server-
|
|
9
|
+
* side: the keeper returns `refundWindowClosed: boolean` on its /verify
|
|
10
|
+
* response, computed from its own record of `purchaseSlot + REFUND_WINDOW_N`
|
|
11
|
+
* against the on-chain `currentSlot`. The SDK gates anchor writes on that
|
|
12
|
+
* boolean and never trusts a `purchaseSlot` supplied by the local proof
|
|
13
|
+
* file, so a user who forges their proof metadata cannot force the offline
|
|
14
|
+
* branch prematurely.
|
|
15
|
+
*
|
|
16
|
+
* Anchor purity: every field persisted in the Anchor derives from the
|
|
17
|
+
* keeper's /verify response — `anchoredSlot`, `expirySlot`, `ownership`.
|
|
18
|
+
* The only client-supplied input to the offline branch is
|
|
19
|
+
* `proof.expirySlot`, used solely as the loadAnchor binding key. A
|
|
20
|
+
* tampered proof file therefore never gets a wrong answer; it gets no
|
|
21
|
+
* offline branch at all and falls through to the keeper on every call.
|
|
22
|
+
*/
|
|
23
|
+
import { REFUND_WINDOW_N, GRACE_PERIOD_N, MS_PER_SLOT_N, SLOTS_PER_DAY_N, } from './contractInterface.js';
|
|
24
|
+
const MS_PER_SLOT = MS_PER_SLOT_N;
|
|
25
|
+
const SLOTS_PER_DAY = SLOTS_PER_DAY_N;
|
|
26
|
+
const ANCHOR_KEY_PREFIX = 'zklic_anchor_';
|
|
27
|
+
const ACTIVATION_KEY_PREFIX = 'zklic_activation_';
|
|
28
|
+
function activationKey(zkAppAddress, licenseHash) {
|
|
29
|
+
return `${ACTIVATION_KEY_PREFIX}${zkAppAddress}_${licenseHash}`;
|
|
30
|
+
}
|
|
31
|
+
export function loadActivation(storage, zkAppAddress, licenseHash, nowMs = Date.now()) {
|
|
32
|
+
try {
|
|
33
|
+
const raw = storage.getItem(activationKey(zkAppAddress, licenseHash));
|
|
34
|
+
if (!raw)
|
|
35
|
+
return null;
|
|
36
|
+
const rec = JSON.parse(raw);
|
|
37
|
+
if (nowMs > rec.expiresAt)
|
|
38
|
+
return null;
|
|
39
|
+
return rec;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function saveActivation(storage, zkAppAddress, licenseHash, rec) {
|
|
46
|
+
try {
|
|
47
|
+
storage.setItem(activationKey(zkAppAddress, licenseHash), JSON.stringify(rec));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* quota / read-only — non-fatal */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function defaultStorage() {
|
|
54
|
+
if (typeof globalThis !== 'undefined' && globalThis.localStorage) {
|
|
55
|
+
return globalThis.localStorage;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
function loadAnchor(storage, licenseHash, expirySlot) {
|
|
60
|
+
try {
|
|
61
|
+
const raw = storage.getItem(ANCHOR_KEY_PREFIX + licenseHash);
|
|
62
|
+
if (!raw)
|
|
63
|
+
return null;
|
|
64
|
+
const a = JSON.parse(raw);
|
|
65
|
+
// `a.expirySlot` is the KEEPER's value captured at anchor time; the
|
|
66
|
+
// `expirySlot` argument is the LOCAL proof file's value. Match ⇒ local
|
|
67
|
+
// file agrees with chain truth as of anchoring ⇒ offline is safe.
|
|
68
|
+
// Mismatch has two honest meanings:
|
|
69
|
+
// - renewal: chain moved, local file updated ⇒ re-anchor via chain check
|
|
70
|
+
// - forgery: local file edited ⇒ every call falls through to the keeper,
|
|
71
|
+
// which reports truth. The forger gets no offline branch at all.
|
|
72
|
+
if (a.expirySlot !== expirySlot)
|
|
73
|
+
return null;
|
|
74
|
+
return a;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function saveAnchor(storage, licenseHash, anchor) {
|
|
81
|
+
try {
|
|
82
|
+
storage.setItem(ANCHOR_KEY_PREFIX + licenseHash, JSON.stringify(anchor));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// Storage may be full or read-only; the offline path simply won't activate.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function computeRemainingDays(expirySlot, currentSlot) {
|
|
89
|
+
if (expirySlot === 0)
|
|
90
|
+
return 0;
|
|
91
|
+
return Math.floor((expirySlot - currentSlot) / SLOTS_PER_DAY);
|
|
92
|
+
}
|
|
93
|
+
// Renew the activation token when within this window of expiry. Picked so
|
|
94
|
+
// that a daily verify call has many chances to refresh before the token dies.
|
|
95
|
+
const TOKEN_RENEW_THRESHOLD_MS = 24 * 60 * 60 * 1000;
|
|
96
|
+
async function tryActivate(storage, zkAppAddress, licenseHash, current, bootstrapSecretHash, prover, verifierUrl, fetcher, nowMs) {
|
|
97
|
+
const secretHash = current?.secretHash ?? bootstrapSecretHash;
|
|
98
|
+
if (!secretHash)
|
|
99
|
+
return current;
|
|
100
|
+
// Existing token still fresh — nothing to do.
|
|
101
|
+
if (current?.token && current.expiresAt - nowMs > TOKEN_RENEW_THRESHOLD_MS)
|
|
102
|
+
return current;
|
|
103
|
+
try {
|
|
104
|
+
const base = verifierUrl.replace(/\/+$/, '');
|
|
105
|
+
const challengeResp = await fetcher(`${base}/challenge?licenseHash=${encodeURIComponent(licenseHash)}`);
|
|
106
|
+
if (!challengeResp.ok)
|
|
107
|
+
return current;
|
|
108
|
+
const { nonce } = (await challengeResp.json());
|
|
109
|
+
const proofJson = await prover({ licenseHash, nonce, secretHash });
|
|
110
|
+
const respondResp = await fetcher(`${base}/respond`, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'Content-Type': 'application/json' },
|
|
113
|
+
body: JSON.stringify({ zkAppAddress, proof: proofJson }),
|
|
114
|
+
});
|
|
115
|
+
if (!respondResp.ok)
|
|
116
|
+
return current;
|
|
117
|
+
const { token, expiresAt } = (await respondResp.json());
|
|
118
|
+
const next = { token, expiresAt, secretHash };
|
|
119
|
+
saveActivation(storage, zkAppAddress, licenseHash, next);
|
|
120
|
+
return next;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return current;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function offlineCheck(anchor, nowMs) {
|
|
127
|
+
const elapsedSlots = Math.max(0, Math.floor((nowMs - anchor.anchoredAtMs) / MS_PER_SLOT));
|
|
128
|
+
const estCurrentSlot = anchor.anchoredSlot + elapsedSlots;
|
|
129
|
+
const expired = estCurrentSlot > anchor.expirySlot + GRACE_PERIOD_N;
|
|
130
|
+
const inGracePeriod = !expired && estCurrentSlot > anchor.expirySlot;
|
|
131
|
+
const expiresAt = new Date(nowMs + (anchor.expirySlot - estCurrentSlot) * MS_PER_SLOT).toISOString();
|
|
132
|
+
return {
|
|
133
|
+
valid: !expired,
|
|
134
|
+
expirySlot: anchor.expirySlot,
|
|
135
|
+
expiresAt,
|
|
136
|
+
currentSlot: estCurrentSlot,
|
|
137
|
+
inGracePeriod,
|
|
138
|
+
remainingDays: computeRemainingDays(anchor.expirySlot, estCurrentSlot),
|
|
139
|
+
reason: expired
|
|
140
|
+
? 'License has expired'
|
|
141
|
+
: inGracePeriod
|
|
142
|
+
? 'License is in grace period — renew to avoid interruption'
|
|
143
|
+
: null,
|
|
144
|
+
source: 'offline',
|
|
145
|
+
ownership: anchor.ownership,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
export async function verifyLicense(proof, options = {}) {
|
|
149
|
+
const storage = options.storage === null ? null : options.storage ?? defaultStorage();
|
|
150
|
+
const fetcher = options.fetcher ?? (typeof fetch !== 'undefined' ? fetch : undefined);
|
|
151
|
+
const nowFn = options.now ?? (() => Date.now());
|
|
152
|
+
const verifierUrl = options.verifierUrl ?? proof.verifierUrl;
|
|
153
|
+
if (storage) {
|
|
154
|
+
const anchor = loadAnchor(storage, proof.licenseHash, proof.expirySlot);
|
|
155
|
+
if (anchor)
|
|
156
|
+
return offlineCheck(anchor, nowFn());
|
|
157
|
+
}
|
|
158
|
+
if (!verifierUrl || !fetcher) {
|
|
159
|
+
return {
|
|
160
|
+
valid: false,
|
|
161
|
+
expirySlot: proof.expirySlot,
|
|
162
|
+
expiresAt: null,
|
|
163
|
+
currentSlot: 0,
|
|
164
|
+
inGracePeriod: false,
|
|
165
|
+
remainingDays: 0,
|
|
166
|
+
reason: 'No verifier URL or fetcher available; cannot verify',
|
|
167
|
+
source: 'chain',
|
|
168
|
+
ownership: 'unverified',
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
let activation = storage
|
|
172
|
+
? loadActivation(storage, proof.zkAppAddress, proof.licenseHash, nowFn())
|
|
173
|
+
: null;
|
|
174
|
+
if (storage && options.prover && (activation || options.secretHash)) {
|
|
175
|
+
activation = await tryActivate(storage, proof.zkAppAddress, proof.licenseHash, activation, options.secretHash, options.prover, verifierUrl, fetcher, nowFn());
|
|
176
|
+
}
|
|
177
|
+
const tokenParam = activation?.token
|
|
178
|
+
? `&token=${encodeURIComponent(activation.token)}`
|
|
179
|
+
: '';
|
|
180
|
+
const url = `${verifierUrl.replace(/\/+$/, '')}` +
|
|
181
|
+
`?licenseHash=${encodeURIComponent(proof.licenseHash)}` +
|
|
182
|
+
`&zkAppAddress=${encodeURIComponent(proof.zkAppAddress)}` +
|
|
183
|
+
tokenParam;
|
|
184
|
+
let data;
|
|
185
|
+
try {
|
|
186
|
+
const resp = await fetcher(url);
|
|
187
|
+
if (!resp.ok) {
|
|
188
|
+
return {
|
|
189
|
+
valid: false,
|
|
190
|
+
expirySlot: proof.expirySlot,
|
|
191
|
+
expiresAt: null,
|
|
192
|
+
currentSlot: 0,
|
|
193
|
+
inGracePeriod: false,
|
|
194
|
+
remainingDays: 0,
|
|
195
|
+
reason: `Verification request failed (HTTP ${resp.status})`,
|
|
196
|
+
source: 'chain',
|
|
197
|
+
ownership: 'unverified',
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
data = await resp.json();
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
return {
|
|
204
|
+
valid: false,
|
|
205
|
+
expirySlot: proof.expirySlot,
|
|
206
|
+
expiresAt: null,
|
|
207
|
+
currentSlot: 0,
|
|
208
|
+
inGracePeriod: false,
|
|
209
|
+
remainingDays: 0,
|
|
210
|
+
reason: err?.message ?? 'Verification request errored',
|
|
211
|
+
source: 'chain',
|
|
212
|
+
ownership: 'unverified',
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
// Keeper's authoritative expiry, or null if the response omits it. Only
|
|
216
|
+
// the null-safe form may be written into the anchor; the chain-result
|
|
217
|
+
// display value below falls back to the local proof file for user-facing
|
|
218
|
+
// fields, which is safe (display only).
|
|
219
|
+
const respExpirySlotStrict = typeof data.expirySlot === 'number' ? data.expirySlot : null;
|
|
220
|
+
const respExpirySlot = respExpirySlotStrict ?? proof.expirySlot;
|
|
221
|
+
const respCurrentSlot = typeof data.currentSlot === 'number' ? data.currentSlot : 0;
|
|
222
|
+
const result = {
|
|
223
|
+
valid: !!data.valid,
|
|
224
|
+
expirySlot: respExpirySlot,
|
|
225
|
+
expiresAt: typeof data.expiresAt === 'string' ? data.expiresAt : null,
|
|
226
|
+
currentSlot: respCurrentSlot,
|
|
227
|
+
inGracePeriod: !!data.inGracePeriod,
|
|
228
|
+
remainingDays: typeof data.remainingDays === 'number'
|
|
229
|
+
? data.remainingDays
|
|
230
|
+
: computeRemainingDays(respExpirySlot, respCurrentSlot),
|
|
231
|
+
reason: data.reason ?? null,
|
|
232
|
+
source: 'chain',
|
|
233
|
+
ownership: data.ownership === 'verified' ? 'verified' : 'unverified',
|
|
234
|
+
};
|
|
235
|
+
// Anchor gate uses the keeper's own refund-window decision. The keeper
|
|
236
|
+
// knows the true purchaseSlot (stored in its LicenseRecord) and the true
|
|
237
|
+
// currentSlot; the SDK never trusts a purchaseSlot or expirySlot supplied
|
|
238
|
+
// by the local proof file, since that file is client-writable and could
|
|
239
|
+
// be forged. If the keeper omits `expirySlot`, we fail-closed on the
|
|
240
|
+
// offline path — no anchor written, subsequent calls stay online.
|
|
241
|
+
if (storage &&
|
|
242
|
+
result.valid &&
|
|
243
|
+
data.refundWindowClosed === true &&
|
|
244
|
+
typeof data.currentSlot === 'number' &&
|
|
245
|
+
respExpirySlotStrict !== null) {
|
|
246
|
+
saveAnchor(storage, proof.licenseHash, {
|
|
247
|
+
anchoredSlot: data.currentSlot,
|
|
248
|
+
anchoredAtMs: nowFn(),
|
|
249
|
+
expirySlot: respExpirySlotStrict,
|
|
250
|
+
ownership: result.ownership,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
// Re-exported so tests and downstream tooling can reference the same values
|
|
256
|
+
// the SDK uses internally without re-deriving them.
|
|
257
|
+
export { REFUND_WINDOW_N, GRACE_PERIOD_N, MS_PER_SLOT };
|
|
258
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Hand-written declarations for licenseProof.ts.
|
|
2
|
+
// tsc can't emit these automatically because the inferred return types of
|
|
3
|
+
// Struct() and ZkProgram() 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, Proof, etc.) in their own code.
|
|
7
|
+
|
|
8
|
+
export const OwnershipChallenge: any;
|
|
9
|
+
export type OwnershipChallenge = any;
|
|
10
|
+
|
|
11
|
+
export const LicenseProof: any;
|
|
12
|
+
|
|
13
|
+
export class LicenseProofProof {
|
|
14
|
+
static publicInputType: any;
|
|
15
|
+
static publicOutputType: any;
|
|
16
|
+
static fromJSON(json: any): Promise<LicenseProofProof>;
|
|
17
|
+
static dummy(...args: any[]): Promise<LicenseProofProof>;
|
|
18
|
+
publicInput: any;
|
|
19
|
+
publicOutput: any;
|
|
20
|
+
proof: any;
|
|
21
|
+
maxProofsVerified: 0 | 1 | 2;
|
|
22
|
+
shouldVerify: any;
|
|
23
|
+
verify(): void;
|
|
24
|
+
verifyIf(condition: any): void;
|
|
25
|
+
toJSON(): { publicInput: string[]; publicOutput: string[]; maxProofsVerified: 0 | 1 | 2; proof: string };
|
|
26
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// All rights reserved.
|
|
3
|
+
/**
|
|
4
|
+
* licenseProof.ts — ZkProgram for ownership challenge-response.
|
|
5
|
+
*
|
|
6
|
+
* Loaded only when the caller needs to *produce* an ownership proof on the
|
|
7
|
+
* client (the verify path never imports this file). Lives in a dedicated
|
|
8
|
+
* subpath (`zklicensing/prove`) so the verify-only entry stays free of o1js.
|
|
9
|
+
*
|
|
10
|
+
* Proves: Poseidon.hash([secretHash]) === licenseHash
|
|
11
|
+
* where secretHash = Poseidon.hash(Encoding.stringToFields(rawSecret)).
|
|
12
|
+
*
|
|
13
|
+
* The nonce sits in the public input. Its sole purpose is replay-resistance:
|
|
14
|
+
* a proof generated for nonce A is mathematically distinct from one for nonce
|
|
15
|
+
* B, so a verifier that issued nonce A will reject any proof not produced for
|
|
16
|
+
* exactly that challenge.
|
|
17
|
+
*/
|
|
18
|
+
import { ZkProgram, Field, Poseidon, Struct } from 'o1js';
|
|
19
|
+
// Struct() / ZkProgram / ZkProgram.Proof() return types reference o1js
|
|
20
|
+
// internal paths outside this package's rootDir, triggering TS2742 on
|
|
21
|
+
// declaration emit. Annotating each export as `any` suppresses the inference;
|
|
22
|
+
// consumers get real types via the hand-written licenseProof.d.ts shim.
|
|
23
|
+
// See contractInterface.ts for the longer rationale on why this workaround.
|
|
24
|
+
export const OwnershipChallenge = Struct({
|
|
25
|
+
licenseHash: Field,
|
|
26
|
+
nonce: Field,
|
|
27
|
+
});
|
|
28
|
+
export const LicenseProof = ZkProgram({
|
|
29
|
+
name: 'license-proof',
|
|
30
|
+
publicInput: OwnershipChallenge,
|
|
31
|
+
methods: {
|
|
32
|
+
prove: {
|
|
33
|
+
privateInputs: [Field], // secretHash — never sent anywhere
|
|
34
|
+
async method(pub, secretHash) {
|
|
35
|
+
Poseidon.hash([secretHash]).assertEquals(pub.licenseHash);
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
export const LicenseProofProof = ZkProgram.Proof(LicenseProof);
|
|
41
|
+
//# sourceMappingURL=licenseProof.js.map
|