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.
Files changed (41) hide show
  1. package/LICENSE +104 -0
  2. package/README.md +603 -0
  3. package/build/archiveBootstrap.d.ts +60 -0
  4. package/build/archiveBootstrap.js +234 -0
  5. package/build/buyClient.d.ts +58 -0
  6. package/build/buyClient.js +162 -0
  7. package/build/contractInterface.d.ts +55 -0
  8. package/build/contractInterface.js +213 -0
  9. package/build/countryStats.d.ts +37 -0
  10. package/build/countryStats.js +89 -0
  11. package/build/deployClient.d.ts +168 -0
  12. package/build/deployClient.js +172 -0
  13. package/build/freezeClient.d.ts +44 -0
  14. package/build/freezeClient.js +59 -0
  15. package/build/generationClient.d.ts +54 -0
  16. package/build/generationClient.js +113 -0
  17. package/build/index.d.ts +72 -0
  18. package/build/index.js +258 -0
  19. package/build/licenseProof.d.ts +26 -0
  20. package/build/licenseProof.js +41 -0
  21. package/build/licenseRecord.d.ts +39 -0
  22. package/build/licenseRecord.js +6 -0
  23. package/build/licenseStore.d.ts +49 -0
  24. package/build/licenseStore.js +303 -0
  25. package/build/manifestVerify.d.ts +30 -0
  26. package/build/manifestVerify.js +95 -0
  27. package/build/migrateClient.d.ts +43 -0
  28. package/build/migrateClient.js +47 -0
  29. package/build/ownership.d.ts +34 -0
  30. package/build/ownership.js +147 -0
  31. package/build/refundClient.d.ts +52 -0
  32. package/build/refundClient.js +92 -0
  33. package/build/renewClient.d.ts +49 -0
  34. package/build/renewClient.js +135 -0
  35. package/build/statsClient.d.ts +96 -0
  36. package/build/statsClient.js +28 -0
  37. package/build/verifyCore.d.ts +26 -0
  38. package/build/verifyCore.js +57 -0
  39. package/build/verifyService.d.ts +3 -0
  40. package/build/verifyService.js +690 -0
  41. 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
@@ -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