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
package/src/index.ts
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* verifyLicense — client SDK.
|
|
6
|
+
*
|
|
7
|
+
* Calls the keeper's GET /verify until the on-chain refund window for the
|
|
8
|
+
* license has closed; from that point on returns from a local anchor and
|
|
9
|
+
* never touches the network again. The refund-window decision is server-
|
|
10
|
+
* side: the keeper returns `refundWindowClosed: boolean` on its /verify
|
|
11
|
+
* response, computed from its own record of `purchaseSlot + REFUND_WINDOW_N`
|
|
12
|
+
* against the on-chain `currentSlot`. The SDK gates anchor writes on that
|
|
13
|
+
* boolean and never trusts a `purchaseSlot` supplied by the local proof
|
|
14
|
+
* file, so a user who forges their proof metadata cannot force the offline
|
|
15
|
+
* branch prematurely.
|
|
16
|
+
*
|
|
17
|
+
* Anchor purity: every field persisted in the Anchor derives from the
|
|
18
|
+
* keeper's /verify response — `anchoredSlot`, `expirySlot`, `ownership`.
|
|
19
|
+
* The only client-supplied input to the offline branch is
|
|
20
|
+
* `proof.expirySlot`, used solely as the loadAnchor binding key. A
|
|
21
|
+
* tampered proof file therefore never gets a wrong answer; it gets no
|
|
22
|
+
* offline branch at all and falls through to the keeper on every call.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
REFUND_WINDOW_N,
|
|
27
|
+
GRACE_PERIOD_N,
|
|
28
|
+
MS_PER_SLOT_N,
|
|
29
|
+
SLOTS_PER_DAY_N,
|
|
30
|
+
} from './contractInterface.js';
|
|
31
|
+
|
|
32
|
+
const MS_PER_SLOT = MS_PER_SLOT_N;
|
|
33
|
+
const SLOTS_PER_DAY = SLOTS_PER_DAY_N;
|
|
34
|
+
|
|
35
|
+
const ANCHOR_KEY_PREFIX = 'zklic_anchor_';
|
|
36
|
+
const ACTIVATION_KEY_PREFIX = 'zklic_activation_';
|
|
37
|
+
|
|
38
|
+
export interface Storage {
|
|
39
|
+
getItem(key: string): string | null;
|
|
40
|
+
setItem(key: string, value: string): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ActivationRecord {
|
|
44
|
+
token: string;
|
|
45
|
+
expiresAt: number;
|
|
46
|
+
// Optional: kept so the SDK can refresh the token without prompting again.
|
|
47
|
+
// Treated as a bearer credential — same trust level as a session cookie.
|
|
48
|
+
secretHash?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function activationKey(zkAppAddress: string, licenseHash: string): string {
|
|
52
|
+
return `${ACTIVATION_KEY_PREFIX}${zkAppAddress}_${licenseHash}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function loadActivation(
|
|
56
|
+
storage: Storage,
|
|
57
|
+
zkAppAddress: string,
|
|
58
|
+
licenseHash: string,
|
|
59
|
+
nowMs: number = Date.now()
|
|
60
|
+
): ActivationRecord | null {
|
|
61
|
+
try {
|
|
62
|
+
const raw = storage.getItem(activationKey(zkAppAddress, licenseHash));
|
|
63
|
+
if (!raw) return null;
|
|
64
|
+
const rec = JSON.parse(raw) as ActivationRecord;
|
|
65
|
+
if (nowMs > rec.expiresAt) return null;
|
|
66
|
+
return rec;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function saveActivation(
|
|
73
|
+
storage: Storage,
|
|
74
|
+
zkAppAddress: string,
|
|
75
|
+
licenseHash: string,
|
|
76
|
+
rec: ActivationRecord
|
|
77
|
+
): void {
|
|
78
|
+
try {
|
|
79
|
+
storage.setItem(activationKey(zkAppAddress, licenseHash), JSON.stringify(rec));
|
|
80
|
+
} catch {
|
|
81
|
+
/* quota / read-only — non-fatal */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ProofInput {
|
|
86
|
+
licenseHash: string;
|
|
87
|
+
zkAppAddress: string;
|
|
88
|
+
expirySlot: number;
|
|
89
|
+
verifierUrl?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Callback that proves an OwnershipChallenge using a stored secretHash.
|
|
94
|
+
* Callers wire this up with their own o1js + LicenseProof imports so the SDK
|
|
95
|
+
* stays dependency-free for the verify-only path. Return value is the
|
|
96
|
+
* `proof.toJSON()` payload the verifier's POST /respond expects.
|
|
97
|
+
*/
|
|
98
|
+
export type TokenProver = (input: {
|
|
99
|
+
licenseHash: string;
|
|
100
|
+
nonce: string;
|
|
101
|
+
secretHash: string;
|
|
102
|
+
}) => Promise<unknown>;
|
|
103
|
+
|
|
104
|
+
export interface VerifyOptions {
|
|
105
|
+
verifierUrl?: string;
|
|
106
|
+
storage?: Storage | null;
|
|
107
|
+
fetcher?: typeof fetch;
|
|
108
|
+
now?: () => number;
|
|
109
|
+
// When supplied with `secretHash`, the SDK runs activation on the first
|
|
110
|
+
// verify after install. From then on it silently re-runs the challenge-
|
|
111
|
+
// response flow using the stored secretHash whenever the token is missing
|
|
112
|
+
// or within ~1 day of expiry — the user is never prompted again.
|
|
113
|
+
prover?: TokenProver;
|
|
114
|
+
// First-verify-after-install bootstrap. Caller obtains this from the user
|
|
115
|
+
// (e.g., passphrase prompt → Poseidon.hash) and passes it once; the SDK
|
|
116
|
+
// persists it as part of the activation record so subsequent verifies
|
|
117
|
+
// renew silently. Ignored when an activation already exists in storage.
|
|
118
|
+
secretHash?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface VerifyResult {
|
|
122
|
+
valid: boolean;
|
|
123
|
+
expirySlot: number;
|
|
124
|
+
// ISO-8601 timestamp of estimated expiry. `null` when the license is
|
|
125
|
+
// unknown / refunded / unreachable. In the offline branch this is
|
|
126
|
+
// estimated from the anchor + local clock.
|
|
127
|
+
expiresAt: string | null;
|
|
128
|
+
// Estimated current Mina slot. From the keeper in the chain branch;
|
|
129
|
+
// estimated from the anchor + elapsed local time in the offline branch.
|
|
130
|
+
currentSlot: number;
|
|
131
|
+
inGracePeriod: boolean;
|
|
132
|
+
// Whole days until expiry. Negative while in the grace period (down to ~-7)
|
|
133
|
+
// and below that once fully expired. 0 when expirySlot is unknown.
|
|
134
|
+
remainingDays: number;
|
|
135
|
+
reason: string | null;
|
|
136
|
+
source: 'offline' | 'chain';
|
|
137
|
+
// 'verified' when the keeper accepted a valid ownership token on this
|
|
138
|
+
// request; 'unverified' otherwise (no token, expired token, or offline
|
|
139
|
+
// branch replaying a chain result that itself was unverified).
|
|
140
|
+
ownership: 'verified' | 'unverified';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface Anchor {
|
|
144
|
+
anchoredSlot: number;
|
|
145
|
+
anchoredAtMs: number;
|
|
146
|
+
expirySlot: number;
|
|
147
|
+
// Captured from the keeper's /verify response at anchor time — offline
|
|
148
|
+
// checks return this stored value verbatim rather than fabricating one.
|
|
149
|
+
ownership: 'verified' | 'unverified';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function defaultStorage(): Storage | null {
|
|
153
|
+
if (typeof globalThis !== 'undefined' && (globalThis as any).localStorage) {
|
|
154
|
+
return (globalThis as any).localStorage as Storage;
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function loadAnchor(storage: Storage, licenseHash: string, expirySlot: number): Anchor | null {
|
|
160
|
+
try {
|
|
161
|
+
const raw = storage.getItem(ANCHOR_KEY_PREFIX + licenseHash);
|
|
162
|
+
if (!raw) return null;
|
|
163
|
+
const a = JSON.parse(raw) as Anchor;
|
|
164
|
+
// `a.expirySlot` is the KEEPER's value captured at anchor time; the
|
|
165
|
+
// `expirySlot` argument is the LOCAL proof file's value. Match ⇒ local
|
|
166
|
+
// file agrees with chain truth as of anchoring ⇒ offline is safe.
|
|
167
|
+
// Mismatch has two honest meanings:
|
|
168
|
+
// - renewal: chain moved, local file updated ⇒ re-anchor via chain check
|
|
169
|
+
// - forgery: local file edited ⇒ every call falls through to the keeper,
|
|
170
|
+
// which reports truth. The forger gets no offline branch at all.
|
|
171
|
+
if (a.expirySlot !== expirySlot) return null;
|
|
172
|
+
return a;
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function saveAnchor(storage: Storage, licenseHash: string, anchor: Anchor): void {
|
|
179
|
+
try {
|
|
180
|
+
storage.setItem(ANCHOR_KEY_PREFIX + licenseHash, JSON.stringify(anchor));
|
|
181
|
+
} catch {
|
|
182
|
+
// Storage may be full or read-only; the offline path simply won't activate.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function computeRemainingDays(expirySlot: number, currentSlot: number): number {
|
|
187
|
+
if (expirySlot === 0) return 0;
|
|
188
|
+
return Math.floor((expirySlot - currentSlot) / SLOTS_PER_DAY);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Renew the activation token when within this window of expiry. Picked so
|
|
192
|
+
// that a daily verify call has many chances to refresh before the token dies.
|
|
193
|
+
const TOKEN_RENEW_THRESHOLD_MS = 24 * 60 * 60 * 1000;
|
|
194
|
+
|
|
195
|
+
async function tryActivate(
|
|
196
|
+
storage: Storage,
|
|
197
|
+
zkAppAddress: string,
|
|
198
|
+
licenseHash: string,
|
|
199
|
+
current: ActivationRecord | null,
|
|
200
|
+
bootstrapSecretHash: string | undefined,
|
|
201
|
+
prover: TokenProver,
|
|
202
|
+
verifierUrl: string,
|
|
203
|
+
fetcher: typeof fetch,
|
|
204
|
+
nowMs: number
|
|
205
|
+
): Promise<ActivationRecord | null> {
|
|
206
|
+
const secretHash = current?.secretHash ?? bootstrapSecretHash;
|
|
207
|
+
if (!secretHash) return current;
|
|
208
|
+
// Existing token still fresh — nothing to do.
|
|
209
|
+
if (current?.token && current.expiresAt - nowMs > TOKEN_RENEW_THRESHOLD_MS) return current;
|
|
210
|
+
try {
|
|
211
|
+
const base = verifierUrl.replace(/\/+$/, '');
|
|
212
|
+
const challengeResp = await fetcher(
|
|
213
|
+
`${base}/challenge?licenseHash=${encodeURIComponent(licenseHash)}`
|
|
214
|
+
);
|
|
215
|
+
if (!challengeResp.ok) return current;
|
|
216
|
+
const { nonce } = (await challengeResp.json()) as { nonce: string };
|
|
217
|
+
const proofJson = await prover({ licenseHash, nonce, secretHash });
|
|
218
|
+
const respondResp = await fetcher(`${base}/respond`, {
|
|
219
|
+
method: 'POST',
|
|
220
|
+
headers: { 'Content-Type': 'application/json' },
|
|
221
|
+
body: JSON.stringify({ zkAppAddress, proof: proofJson }),
|
|
222
|
+
});
|
|
223
|
+
if (!respondResp.ok) return current;
|
|
224
|
+
const { token, expiresAt } = (await respondResp.json()) as {
|
|
225
|
+
token: string;
|
|
226
|
+
expiresAt: number;
|
|
227
|
+
};
|
|
228
|
+
const next: ActivationRecord = { token, expiresAt, secretHash };
|
|
229
|
+
saveActivation(storage, zkAppAddress, licenseHash, next);
|
|
230
|
+
return next;
|
|
231
|
+
} catch {
|
|
232
|
+
return current;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function offlineCheck(anchor: Anchor, nowMs: number): VerifyResult {
|
|
237
|
+
const elapsedSlots = Math.max(0, Math.floor((nowMs - anchor.anchoredAtMs) / MS_PER_SLOT));
|
|
238
|
+
const estCurrentSlot = anchor.anchoredSlot + elapsedSlots;
|
|
239
|
+
const expired = estCurrentSlot > anchor.expirySlot + GRACE_PERIOD_N;
|
|
240
|
+
const inGracePeriod = !expired && estCurrentSlot > anchor.expirySlot;
|
|
241
|
+
const expiresAt = new Date(nowMs + (anchor.expirySlot - estCurrentSlot) * MS_PER_SLOT).toISOString();
|
|
242
|
+
return {
|
|
243
|
+
valid: !expired,
|
|
244
|
+
expirySlot: anchor.expirySlot,
|
|
245
|
+
expiresAt,
|
|
246
|
+
currentSlot: estCurrentSlot,
|
|
247
|
+
inGracePeriod,
|
|
248
|
+
remainingDays: computeRemainingDays(anchor.expirySlot, estCurrentSlot),
|
|
249
|
+
reason: expired
|
|
250
|
+
? 'License has expired'
|
|
251
|
+
: inGracePeriod
|
|
252
|
+
? 'License is in grace period — renew to avoid interruption'
|
|
253
|
+
: null,
|
|
254
|
+
source: 'offline',
|
|
255
|
+
ownership: anchor.ownership,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function verifyLicense(
|
|
260
|
+
proof: ProofInput,
|
|
261
|
+
options: VerifyOptions = {}
|
|
262
|
+
): Promise<VerifyResult> {
|
|
263
|
+
const storage = options.storage === null ? null : options.storage ?? defaultStorage();
|
|
264
|
+
const fetcher = options.fetcher ?? (typeof fetch !== 'undefined' ? fetch : undefined);
|
|
265
|
+
const nowFn = options.now ?? (() => Date.now());
|
|
266
|
+
const verifierUrl = options.verifierUrl ?? proof.verifierUrl;
|
|
267
|
+
|
|
268
|
+
if (storage) {
|
|
269
|
+
const anchor = loadAnchor(storage, proof.licenseHash, proof.expirySlot);
|
|
270
|
+
if (anchor) return offlineCheck(anchor, nowFn());
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!verifierUrl || !fetcher) {
|
|
274
|
+
return {
|
|
275
|
+
valid: false,
|
|
276
|
+
expirySlot: proof.expirySlot,
|
|
277
|
+
expiresAt: null,
|
|
278
|
+
currentSlot: 0,
|
|
279
|
+
inGracePeriod: false,
|
|
280
|
+
remainingDays: 0,
|
|
281
|
+
reason: 'No verifier URL or fetcher available; cannot verify',
|
|
282
|
+
source: 'chain',
|
|
283
|
+
ownership: 'unverified',
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
let activation = storage
|
|
288
|
+
? loadActivation(storage, proof.zkAppAddress, proof.licenseHash, nowFn())
|
|
289
|
+
: null;
|
|
290
|
+
if (storage && options.prover && (activation || options.secretHash)) {
|
|
291
|
+
activation = await tryActivate(
|
|
292
|
+
storage,
|
|
293
|
+
proof.zkAppAddress,
|
|
294
|
+
proof.licenseHash,
|
|
295
|
+
activation,
|
|
296
|
+
options.secretHash,
|
|
297
|
+
options.prover,
|
|
298
|
+
verifierUrl,
|
|
299
|
+
fetcher,
|
|
300
|
+
nowFn()
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const tokenParam = activation?.token
|
|
305
|
+
? `&token=${encodeURIComponent(activation.token)}`
|
|
306
|
+
: '';
|
|
307
|
+
const url =
|
|
308
|
+
`${verifierUrl.replace(/\/+$/, '')}` +
|
|
309
|
+
`?licenseHash=${encodeURIComponent(proof.licenseHash)}` +
|
|
310
|
+
`&zkAppAddress=${encodeURIComponent(proof.zkAppAddress)}` +
|
|
311
|
+
tokenParam;
|
|
312
|
+
|
|
313
|
+
let data: any;
|
|
314
|
+
try {
|
|
315
|
+
const resp = await fetcher(url);
|
|
316
|
+
if (!resp.ok) {
|
|
317
|
+
return {
|
|
318
|
+
valid: false,
|
|
319
|
+
expirySlot: proof.expirySlot,
|
|
320
|
+
expiresAt: null,
|
|
321
|
+
currentSlot: 0,
|
|
322
|
+
inGracePeriod: false,
|
|
323
|
+
remainingDays: 0,
|
|
324
|
+
reason: `Verification request failed (HTTP ${resp.status})`,
|
|
325
|
+
source: 'chain',
|
|
326
|
+
ownership: 'unverified',
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
data = await resp.json();
|
|
330
|
+
} catch (err: any) {
|
|
331
|
+
return {
|
|
332
|
+
valid: false,
|
|
333
|
+
expirySlot: proof.expirySlot,
|
|
334
|
+
expiresAt: null,
|
|
335
|
+
currentSlot: 0,
|
|
336
|
+
inGracePeriod: false,
|
|
337
|
+
remainingDays: 0,
|
|
338
|
+
reason: err?.message ?? 'Verification request errored',
|
|
339
|
+
source: 'chain',
|
|
340
|
+
ownership: 'unverified',
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Keeper's authoritative expiry, or null if the response omits it. Only
|
|
345
|
+
// the null-safe form may be written into the anchor; the chain-result
|
|
346
|
+
// display value below falls back to the local proof file for user-facing
|
|
347
|
+
// fields, which is safe (display only).
|
|
348
|
+
const respExpirySlotStrict: number | null =
|
|
349
|
+
typeof data.expirySlot === 'number' ? data.expirySlot : null;
|
|
350
|
+
const respExpirySlot: number = respExpirySlotStrict ?? proof.expirySlot;
|
|
351
|
+
const respCurrentSlot: number = typeof data.currentSlot === 'number' ? data.currentSlot : 0;
|
|
352
|
+
const result: VerifyResult = {
|
|
353
|
+
valid: !!data.valid,
|
|
354
|
+
expirySlot: respExpirySlot,
|
|
355
|
+
expiresAt: typeof data.expiresAt === 'string' ? data.expiresAt : null,
|
|
356
|
+
currentSlot: respCurrentSlot,
|
|
357
|
+
inGracePeriod: !!data.inGracePeriod,
|
|
358
|
+
remainingDays: typeof data.remainingDays === 'number'
|
|
359
|
+
? data.remainingDays
|
|
360
|
+
: computeRemainingDays(respExpirySlot, respCurrentSlot),
|
|
361
|
+
reason: data.reason ?? null,
|
|
362
|
+
source: 'chain',
|
|
363
|
+
ownership: data.ownership === 'verified' ? 'verified' : 'unverified',
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
// Anchor gate uses the keeper's own refund-window decision. The keeper
|
|
367
|
+
// knows the true purchaseSlot (stored in its LicenseRecord) and the true
|
|
368
|
+
// currentSlot; the SDK never trusts a purchaseSlot or expirySlot supplied
|
|
369
|
+
// by the local proof file, since that file is client-writable and could
|
|
370
|
+
// be forged. If the keeper omits `expirySlot`, we fail-closed on the
|
|
371
|
+
// offline path — no anchor written, subsequent calls stay online.
|
|
372
|
+
if (
|
|
373
|
+
storage &&
|
|
374
|
+
result.valid &&
|
|
375
|
+
data.refundWindowClosed === true &&
|
|
376
|
+
typeof data.currentSlot === 'number' &&
|
|
377
|
+
respExpirySlotStrict !== null
|
|
378
|
+
) {
|
|
379
|
+
saveAnchor(storage, proof.licenseHash, {
|
|
380
|
+
anchoredSlot: data.currentSlot,
|
|
381
|
+
anchoredAtMs: nowFn(),
|
|
382
|
+
expirySlot: respExpirySlotStrict,
|
|
383
|
+
ownership: result.ownership,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return result;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Re-exported so tests and downstream tooling can reference the same values
|
|
391
|
+
// the SDK uses internally without re-deriving them.
|
|
392
|
+
export { REFUND_WINDOW_N, GRACE_PERIOD_N, MS_PER_SLOT };
|
|
@@ -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,48 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* licenseProof.ts — ZkProgram for ownership challenge-response.
|
|
6
|
+
*
|
|
7
|
+
* Loaded only when the caller needs to *produce* an ownership proof on the
|
|
8
|
+
* client (the verify path never imports this file). Lives in a dedicated
|
|
9
|
+
* subpath (`zklicensing/prove`) so the verify-only entry stays free of o1js.
|
|
10
|
+
*
|
|
11
|
+
* Proves: Poseidon.hash([secretHash]) === licenseHash
|
|
12
|
+
* where secretHash = Poseidon.hash(Encoding.stringToFields(rawSecret)).
|
|
13
|
+
*
|
|
14
|
+
* The nonce sits in the public input. Its sole purpose is replay-resistance:
|
|
15
|
+
* a proof generated for nonce A is mathematically distinct from one for nonce
|
|
16
|
+
* B, so a verifier that issued nonce A will reject any proof not produced for
|
|
17
|
+
* exactly that challenge.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { ZkProgram, Field, Poseidon, Struct } from 'o1js';
|
|
21
|
+
|
|
22
|
+
// Struct() / ZkProgram / ZkProgram.Proof() return types reference o1js
|
|
23
|
+
// internal paths outside this package's rootDir, triggering TS2742 on
|
|
24
|
+
// declaration emit. Annotating each export as `any` suppresses the inference;
|
|
25
|
+
// consumers get real types via the hand-written licenseProof.d.ts shim.
|
|
26
|
+
// See contractInterface.ts for the longer rationale on why this workaround.
|
|
27
|
+
|
|
28
|
+
export const OwnershipChallenge: any = Struct({
|
|
29
|
+
licenseHash: Field,
|
|
30
|
+
nonce: Field,
|
|
31
|
+
});
|
|
32
|
+
export type OwnershipChallenge = any;
|
|
33
|
+
|
|
34
|
+
export const LicenseProof: any = ZkProgram({
|
|
35
|
+
name: 'license-proof',
|
|
36
|
+
publicInput: OwnershipChallenge,
|
|
37
|
+
methods: {
|
|
38
|
+
prove: {
|
|
39
|
+
privateInputs: [Field], // secretHash — never sent anywhere
|
|
40
|
+
async method(pub: OwnershipChallenge, secretHash: Field) {
|
|
41
|
+
Poseidon.hash([secretHash]).assertEquals(pub.licenseHash);
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const LicenseProofProof: any = ZkProgram.Proof(LicenseProof);
|
|
48
|
+
export type LicenseProofProof = any;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* licenseRecord.ts
|
|
6
|
+
*
|
|
7
|
+
* The `LicenseRecord` shape and related pure types/constants. Split out from
|
|
8
|
+
* licenseStore.ts (which pulls in Node `fs`) so browser-side modules — the
|
|
9
|
+
* buy client, the stats client, the country-stats computation — can consume
|
|
10
|
+
* the same type without dragging in filesystem code. licenseStore.ts
|
|
11
|
+
* re-exports these to keep existing Node imports working.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// MINA→fiat rate snapshot captured at the moment a business event happens
|
|
15
|
+
// (registration, purchase, renewal). Immutable — the value at time of event
|
|
16
|
+
// is what tax authorities want, so the keeper snapshots against the same
|
|
17
|
+
// 5-minute CoinGecko cache used by receipts and never re-fetches. Records
|
|
18
|
+
// created before the very first successful CoinGecko response get no
|
|
19
|
+
// snapshot; dashboards render fiat as `—` for those.
|
|
20
|
+
export type FiatRateSnapshot = {
|
|
21
|
+
minaEurRate: number;
|
|
22
|
+
minaUsdRate: number;
|
|
23
|
+
minaGbpRate: number;
|
|
24
|
+
recordedAt: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type LicenseRecord = {
|
|
28
|
+
zkAppAddress: string; // REQUIRED — composite key with licenseHash
|
|
29
|
+
licenseHash: string; // Poseidon.hash([secretHash]).toString()
|
|
30
|
+
expirySlot: number; // 0 = refunded / empty
|
|
31
|
+
purchaseSlot: number; // block slot when purchased (0 after escrow released/refunded)
|
|
32
|
+
purchaseAmount: string; // nanomina paid (as string to avoid BigInt serialisation issues)
|
|
33
|
+
txHash: string;
|
|
34
|
+
createdAt: string;
|
|
35
|
+
buyerAddress?: string; // buyer's public key (B62q...) — required for escrow commitment
|
|
36
|
+
buyerCountry?: string; // ISO 3166-1 alpha-2 self-declared buyer jurisdiction (required at buy time; optional here for backward-compat with pre-existing records)
|
|
37
|
+
buyerZip?: string; // Buyer-declared postal code — required at buy time only when buyerCountry === 'US' (state-level sales-tax attribution). Optional here for backward-compat and non-US buyers.
|
|
38
|
+
pending?: boolean; // true if staged at prove-time but not yet confirmed by chain events
|
|
39
|
+
provedAtSlot?: number; // slot at which prove succeeded — used by reaper to drop unsubmitted records
|
|
40
|
+
// Captured at licenseIssued and never mutated by renewal. Lets stats decide
|
|
41
|
+
// who's past their first paid period without re-walking events.
|
|
42
|
+
initialExpirySlot?: number;
|
|
43
|
+
// Back-derived from (initialExpirySlot - purchaseSlot) at issue time. Lets
|
|
44
|
+
// licensesByTier roll up active counts without keeping the issued slot.
|
|
45
|
+
tier?: 'monthly' | 'yearly' | 'fiveYear';
|
|
46
|
+
// True after a licenseRenewed event has been observed. Lets the stats
|
|
47
|
+
// accumulator detect the FIRST renewal per license without re-counting.
|
|
48
|
+
renewedAtLeastOnce?: boolean;
|
|
49
|
+
// True after a refundIssued event. Distinct from expirySlot=0 for legacy
|
|
50
|
+
// records that lacked refund tracking.
|
|
51
|
+
refunded?: boolean;
|
|
52
|
+
// True after a fundsReleased event. Distinct from purchaseAmount=0 so stats
|
|
53
|
+
// can compute released revenue without re-walking events.
|
|
54
|
+
released?: boolean;
|
|
55
|
+
releaseSlot?: number;
|
|
56
|
+
// Tx hash of the fundsReleased broadcast. Persisted so the vendor's
|
|
57
|
+
// transaction feed can link the release row to the explorer. Optional for
|
|
58
|
+
// backward-compat: pre-tracking records and archive-replayed events
|
|
59
|
+
// (which don't carry the tx hash) simply omit it.
|
|
60
|
+
releaseTxHash?: string;
|
|
61
|
+
// Each renewal pushes { slot, amount, newExpiry, txHash?, fiatSnapshot? }
|
|
62
|
+
// where amount is the nanomina paid. newExpiry is the resulting expiry —
|
|
63
|
+
// used as a dedup key so confirm-time entries don't double up when archive
|
|
64
|
+
// sync later replays the same renewedEvent. fiatSnapshot captures the
|
|
65
|
+
// MINA→fiat rates at renewal confirm time (missing on archive-replayed
|
|
66
|
+
// renewals, which have no live keeper context).
|
|
67
|
+
renewals?: Array<{ slot: number; amount: string; newExpiry: number; txHash?: string; fiatSnapshot?: FiatRateSnapshot }>;
|
|
68
|
+
// MINA→fiat rates captured at buy confirm time. Immutable — the rate at
|
|
69
|
+
// time of purchase is what tax authorities want. Missing on records created
|
|
70
|
+
// before the first successful CoinGecko response and on archive-replayed
|
|
71
|
+
// purchases (dashboards render fiat as `—`).
|
|
72
|
+
fiatSnapshot?: FiatRateSnapshot;
|
|
73
|
+
// ISO timestamp of the buyer's express waiver of the 14-day refund window,
|
|
74
|
+
// captured at buy time when a FREEZE_AFTER_SLOT was scheduled on the
|
|
75
|
+
// keeper. Absent for normal (non-freeze-window) buys. Kept for audit /
|
|
76
|
+
// dispute purposes — the waiver is the buyer's acknowledgment that the
|
|
77
|
+
// scheduled hardfork can truncate their refund window.
|
|
78
|
+
refundWaiverAcceptedAt?: string;
|
|
79
|
+
// True when the buy was minted through the no-escrow circuit method
|
|
80
|
+
// (buyLicenseNoEscrow). Funds went straight to vendor + platform (98/2)
|
|
81
|
+
// rather than into the contract's escrow tree, so there is nothing to
|
|
82
|
+
// refund. The buyer accepted this at purchase (see refundWaiverAcceptedAt).
|
|
83
|
+
// The UI uses this flag to hide the "Refund" CTA on the licenses page.
|
|
84
|
+
escrowless?: boolean;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Reap pending records this many slots after prove time if no event ever lands.
|
|
88
|
+
// 60 slots ≈ 90 minutes — a generous window for tx inclusion under devnet conditions.
|
|
89
|
+
export const PENDING_REAP_SLOTS = 60;
|
|
90
|
+
|
|
91
|
+
export type LicenseStatus = 'active' | 'refunded' | 'expired' | 'unknown';
|