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,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* licenseRecord.ts
|
|
3
|
+
*
|
|
4
|
+
* The `LicenseRecord` shape and related pure types/constants. Split out from
|
|
5
|
+
* licenseStore.ts (which pulls in Node `fs`) so browser-side modules — the
|
|
6
|
+
* buy client, the stats client, the country-stats computation — can consume
|
|
7
|
+
* the same type without dragging in filesystem code. licenseStore.ts
|
|
8
|
+
* re-exports these to keep existing Node imports working.
|
|
9
|
+
*/
|
|
10
|
+
export type LicenseRecord = {
|
|
11
|
+
zkAppAddress: string;
|
|
12
|
+
licenseHash: string;
|
|
13
|
+
expirySlot: number;
|
|
14
|
+
purchaseSlot: number;
|
|
15
|
+
purchaseAmount: string;
|
|
16
|
+
txHash: string;
|
|
17
|
+
createdAt: string;
|
|
18
|
+
buyerAddress?: string;
|
|
19
|
+
buyerCountry?: string;
|
|
20
|
+
pending?: boolean;
|
|
21
|
+
provedAtSlot?: number;
|
|
22
|
+
initialExpirySlot?: number;
|
|
23
|
+
tier?: 'monthly' | 'yearly' | 'fiveYear';
|
|
24
|
+
renewedAtLeastOnce?: boolean;
|
|
25
|
+
refunded?: boolean;
|
|
26
|
+
released?: boolean;
|
|
27
|
+
releaseSlot?: number;
|
|
28
|
+
renewals?: Array<{
|
|
29
|
+
slot: number;
|
|
30
|
+
amount: string;
|
|
31
|
+
newExpiry: number;
|
|
32
|
+
txHash?: string;
|
|
33
|
+
}>;
|
|
34
|
+
refundWaiverAcceptedAt?: string;
|
|
35
|
+
escrowless?: boolean;
|
|
36
|
+
};
|
|
37
|
+
export declare const PENDING_REAP_SLOTS = 60;
|
|
38
|
+
export type LicenseStatus = 'active' | 'refunded' | 'expired' | 'unknown';
|
|
39
|
+
//# sourceMappingURL=licenseRecord.d.ts.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// All rights reserved.
|
|
3
|
+
// Reap pending records this many slots after prove time if no event ever lands.
|
|
4
|
+
// 60 slots ≈ 90 minutes — a generous window for tx inclusion under devnet conditions.
|
|
5
|
+
export const PENDING_REAP_SLOTS = 60;
|
|
6
|
+
//# sourceMappingURL=licenseRecord.js.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { MerkleMap, MerkleMapWitness } from 'o1js';
|
|
2
|
+
import { type LicenseRecord as _LicenseRecord, type LicenseStatus as _LicenseStatus } from './licenseRecord.js';
|
|
3
|
+
export type LicenseRecord = _LicenseRecord;
|
|
4
|
+
export type LicenseStatus = _LicenseStatus;
|
|
5
|
+
export declare const PENDING_REAP_SLOTS = 60;
|
|
6
|
+
export declare function licenseStorePath(): string;
|
|
7
|
+
export declare function readRecords(): Promise<LicenseRecord[]>;
|
|
8
|
+
export declare function buildMap(records: LicenseRecord[], zkAppAddress: string): MerkleMap;
|
|
9
|
+
export declare function buildEscrowMap(records: LicenseRecord[], zkAppAddress: string): MerkleMap;
|
|
10
|
+
export declare function serializeWitness(witness: MerkleMapWitness): {
|
|
11
|
+
isLefts: boolean[];
|
|
12
|
+
siblings: string[];
|
|
13
|
+
};
|
|
14
|
+
export declare function deserializeWitness(json: {
|
|
15
|
+
isLefts: boolean[];
|
|
16
|
+
siblings: string[];
|
|
17
|
+
}): MerkleMapWitness;
|
|
18
|
+
export declare function getWitness(zkAppAddress: string, licenseHash: string): Promise<{
|
|
19
|
+
root: string;
|
|
20
|
+
currentValue: string;
|
|
21
|
+
witness: MerkleMapWitness;
|
|
22
|
+
serialized: ReturnType<typeof serializeWitness>;
|
|
23
|
+
}>;
|
|
24
|
+
export declare function getLicense(zkAppAddress: string, licenseHash: string): Promise<LicenseRecord | null>;
|
|
25
|
+
export declare function licenseStatus(record: LicenseRecord | null, currentSlot: number): LicenseStatus;
|
|
26
|
+
export declare function upsertLicense(record: LicenseRecord): Promise<void>;
|
|
27
|
+
export declare function updateExpiry(zkAppAddress: string, licenseHash: string, expirySlot: number, opts?: {
|
|
28
|
+
markRenewed?: boolean;
|
|
29
|
+
renewal?: {
|
|
30
|
+
slot: number;
|
|
31
|
+
amount: string;
|
|
32
|
+
newExpiry: number;
|
|
33
|
+
txHash?: string;
|
|
34
|
+
};
|
|
35
|
+
}): Promise<boolean>;
|
|
36
|
+
export declare function recordRefund(zkAppAddress: string, licenseHash: string): Promise<boolean>;
|
|
37
|
+
export declare function recordRelease(zkAppAddress: string, licenseHash: string, releaseSlot?: number): Promise<boolean>;
|
|
38
|
+
export declare function confirmRecord(zkAppAddress: string, licenseHash: string): Promise<boolean>;
|
|
39
|
+
export declare function applyLicenseIssued(zkAppAddress: string, licenseHash: string, fields: {
|
|
40
|
+
expirySlot: number;
|
|
41
|
+
purchaseSlot: number;
|
|
42
|
+
purchaseAmount: string;
|
|
43
|
+
tier?: 'monthly' | 'yearly' | 'fiveYear';
|
|
44
|
+
escrowless?: boolean;
|
|
45
|
+
}): Promise<void>;
|
|
46
|
+
export declare function deleteRecord(zkAppAddress: string, licenseHash: string): Promise<boolean>;
|
|
47
|
+
export declare function getStalePendingRecords(currentSlot: number, maxAgeSlots?: number): Promise<LicenseRecord[]>;
|
|
48
|
+
export declare function getReleasableRecords(currentSlot: number): Promise<LicenseRecord[]>;
|
|
49
|
+
//# sourceMappingURL=licenseStore.d.ts.map
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// All rights reserved.
|
|
3
|
+
/**
|
|
4
|
+
* licenseStore.ts
|
|
5
|
+
*
|
|
6
|
+
* Off-chain MerkleMap state for the licensing system.
|
|
7
|
+
*
|
|
8
|
+
* Records are keyed by (zkAppAddress, licenseHash). Each LicensingApp deploys
|
|
9
|
+
* its own on-chain MerkleMap, so a passphrase used on two different apps
|
|
10
|
+
* produces the same `licenseHash` but two distinct records. Lookups that omit
|
|
11
|
+
* `zkAppAddress` would collide across apps — every API here takes it.
|
|
12
|
+
*
|
|
13
|
+
* Used by:
|
|
14
|
+
* - interact.ts / buyLicense.ts (CLI) — reads/writes directly
|
|
15
|
+
* - keeperService.ts — exposes the same logic over HTTP
|
|
16
|
+
* - proofServer.ts — reads expirySlot for a given licenseHash
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'fs/promises';
|
|
19
|
+
import { Bool, Field, MerkleMap, MerkleMapWitness, Poseidon, PublicKey, UInt32, UInt64 } from 'o1js';
|
|
20
|
+
import { GRACE_PERIOD_N, REFUND_WINDOW_N } from './contractInterface.js';
|
|
21
|
+
import { PENDING_REAP_SLOTS as _PENDING_REAP_SLOTS } from './licenseRecord.js';
|
|
22
|
+
export const PENDING_REAP_SLOTS = _PENDING_REAP_SLOTS;
|
|
23
|
+
export function licenseStorePath() {
|
|
24
|
+
return process.env.LICENSE_STORE_PATH ?? 'licenses.json';
|
|
25
|
+
}
|
|
26
|
+
export async function readRecords() {
|
|
27
|
+
let raw;
|
|
28
|
+
try {
|
|
29
|
+
raw = JSON.parse(await fs.readFile(licenseStorePath(), 'utf8'));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
if (!Array.isArray(raw))
|
|
35
|
+
return [];
|
|
36
|
+
const records = raw;
|
|
37
|
+
// Records pre-dating composite keying lack `zkAppAddress` and cannot be
|
|
38
|
+
// safely matched against any specific zkApp. Drop them with a warning so
|
|
39
|
+
// bootstrap rebuilds them fresh from on-chain events.
|
|
40
|
+
const filtered = records.filter(r => typeof r.zkAppAddress === 'string' && r.zkAppAddress.length > 0);
|
|
41
|
+
if (filtered.length < records.length) {
|
|
42
|
+
console.warn(`[licenseStore] dropped ${records.length - filtered.length} record(s) missing zkAppAddress — re-bootstrap to recover`);
|
|
43
|
+
}
|
|
44
|
+
return filtered;
|
|
45
|
+
}
|
|
46
|
+
async function writeRecords(records) {
|
|
47
|
+
await fs.writeFile(licenseStorePath(), JSON.stringify(records, null, 2));
|
|
48
|
+
}
|
|
49
|
+
// Serialize all read-modify-write operations against licenses.json. Each
|
|
50
|
+
// mutating function does (readRecords → mutate → writeRecords); without a lock,
|
|
51
|
+
// two concurrent calls can interleave such that the second one's read predates
|
|
52
|
+
// the first one's write, and the second one's write then clobbers the first.
|
|
53
|
+
// This was visible during archive sync (applyEventsToStore fires many writes
|
|
54
|
+
// back-to-back) racing with /confirm/* writes from buyer flows.
|
|
55
|
+
let writeChain = Promise.resolve();
|
|
56
|
+
function withWriteLock(fn) {
|
|
57
|
+
const result = writeChain.then(fn, fn);
|
|
58
|
+
writeChain = result.then(() => { }, () => { });
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
// Records belonging to a specific zkApp. Each zkApp owns an independent
|
|
62
|
+
// on-chain MerkleMap, so all map operations must filter first.
|
|
63
|
+
function recordsForApp(records, zkAppAddress) {
|
|
64
|
+
return records.filter(r => r.zkAppAddress === zkAppAddress);
|
|
65
|
+
}
|
|
66
|
+
// Rebuild the license MerkleMap for a single zkApp. Only inserts entries
|
|
67
|
+
// with expirySlot > 0.
|
|
68
|
+
export function buildMap(records, zkAppAddress) {
|
|
69
|
+
const map = new MerkleMap();
|
|
70
|
+
for (const r of recordsForApp(records, zkAppAddress)) {
|
|
71
|
+
if (r.expirySlot !== 0) {
|
|
72
|
+
map.set(Field(r.licenseHash), Field(r.expirySlot));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return map;
|
|
76
|
+
}
|
|
77
|
+
// Rebuild the escrow MerkleMap for a single zkApp:
|
|
78
|
+
// licenseHash → escrowCommitment(amount, slot, buyerKey). Only includes
|
|
79
|
+
// entries with live escrow (not released, not refunded) and a known buyer.
|
|
80
|
+
export function buildEscrowMap(records, zkAppAddress) {
|
|
81
|
+
const map = new MerkleMap();
|
|
82
|
+
for (const r of recordsForApp(records, zkAppAddress)) {
|
|
83
|
+
if (!r.released && !r.refunded && r.purchaseSlot > 0 && r.buyerAddress) {
|
|
84
|
+
const buyerKey = PublicKey.fromBase58(r.buyerAddress);
|
|
85
|
+
const commitment = Poseidon.hash([
|
|
86
|
+
UInt64.from(BigInt(r.purchaseAmount)).value,
|
|
87
|
+
UInt32.from(r.purchaseSlot).value,
|
|
88
|
+
buyerKey.x,
|
|
89
|
+
buyerKey.isOdd.toField(),
|
|
90
|
+
]);
|
|
91
|
+
map.set(Field(r.licenseHash), commitment);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return map;
|
|
95
|
+
}
|
|
96
|
+
// Serialize a MerkleMapWitness to plain JSON (safe to send over HTTP).
|
|
97
|
+
export function serializeWitness(witness) {
|
|
98
|
+
return {
|
|
99
|
+
isLefts: witness.isLefts.map((b) => b.toBoolean()),
|
|
100
|
+
siblings: witness.siblings.map((f) => f.toString()),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// Reconstruct a MerkleMapWitness from serialized JSON.
|
|
104
|
+
export function deserializeWitness(json) {
|
|
105
|
+
return new MerkleMapWitness(json.isLefts.map((b) => Bool(b)), json.siblings.map((s) => Field(s)));
|
|
106
|
+
}
|
|
107
|
+
// Returns the witness for a licenseHash key in the given zkApp's map.
|
|
108
|
+
export async function getWitness(zkAppAddress, licenseHash) {
|
|
109
|
+
const records = await readRecords();
|
|
110
|
+
const map = buildMap(records, zkAppAddress);
|
|
111
|
+
const key = Field(licenseHash);
|
|
112
|
+
const witness = map.getWitness(key);
|
|
113
|
+
return {
|
|
114
|
+
root: map.getRoot().toString(),
|
|
115
|
+
currentValue: map.get(key).toString(),
|
|
116
|
+
witness,
|
|
117
|
+
serialized: serializeWitness(witness),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// Look up a single license record by its composite key.
|
|
121
|
+
export async function getLicense(zkAppAddress, licenseHash) {
|
|
122
|
+
const records = await readRecords();
|
|
123
|
+
return records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash) ?? null;
|
|
124
|
+
}
|
|
125
|
+
// Determine the status of a license. A license in the 7-day grace window is
|
|
126
|
+
// still considered 'active' so renewal is not urgently forced.
|
|
127
|
+
export function licenseStatus(record, currentSlot) {
|
|
128
|
+
if (!record)
|
|
129
|
+
return 'unknown';
|
|
130
|
+
if (record.expirySlot === 0)
|
|
131
|
+
return 'refunded';
|
|
132
|
+
if (record.expirySlot + GRACE_PERIOD_N < currentSlot)
|
|
133
|
+
return 'expired';
|
|
134
|
+
return 'active';
|
|
135
|
+
}
|
|
136
|
+
// Add or update a license record (upsert by composite key).
|
|
137
|
+
export async function upsertLicense(record) {
|
|
138
|
+
if (!record.zkAppAddress) {
|
|
139
|
+
throw new Error('upsertLicense: record.zkAppAddress is required');
|
|
140
|
+
}
|
|
141
|
+
return withWriteLock(async () => {
|
|
142
|
+
const records = await readRecords();
|
|
143
|
+
const idx = records.findIndex(r => r.zkAppAddress === record.zkAppAddress && r.licenseHash === record.licenseHash);
|
|
144
|
+
if (idx >= 0) {
|
|
145
|
+
records[idx] = record;
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
records.push(record);
|
|
149
|
+
}
|
|
150
|
+
await writeRecords(records);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
// Update the expirySlot for an existing record (used by renew).
|
|
154
|
+
// `opts.markRenewed` flips renewedAtLeastOnce so stats can detect first-renewal
|
|
155
|
+
// without re-walking events. `opts.renewal` appends to the renewals[] log so
|
|
156
|
+
// stats can compute renewal revenue from the store alone.
|
|
157
|
+
export async function updateExpiry(zkAppAddress, licenseHash, expirySlot, opts = {}) {
|
|
158
|
+
return withWriteLock(async () => {
|
|
159
|
+
const records = await readRecords();
|
|
160
|
+
const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
|
|
161
|
+
if (!record)
|
|
162
|
+
return false;
|
|
163
|
+
record.expirySlot = expirySlot;
|
|
164
|
+
if (opts.markRenewed)
|
|
165
|
+
record.renewedAtLeastOnce = true;
|
|
166
|
+
if (opts.renewal) {
|
|
167
|
+
const renewals = (record.renewals ??= []);
|
|
168
|
+
// Dedup by newExpiry — confirm-time and event-sync may both try to log
|
|
169
|
+
// the same renewal, but each renewal advances expiry by a unique tier
|
|
170
|
+
// amount, so newExpiry is a stable identifier.
|
|
171
|
+
if (!renewals.some(r => r.newExpiry === opts.renewal.newExpiry)) {
|
|
172
|
+
renewals.push(opts.renewal);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
await writeRecords(records);
|
|
176
|
+
return true;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
// Clear license and escrow data after a confirmed refund. Also flips `refunded`.
|
|
180
|
+
export async function recordRefund(zkAppAddress, licenseHash) {
|
|
181
|
+
return withWriteLock(async () => {
|
|
182
|
+
const records = await readRecords();
|
|
183
|
+
const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
|
|
184
|
+
if (!record)
|
|
185
|
+
return false;
|
|
186
|
+
record.expirySlot = 0;
|
|
187
|
+
record.purchaseSlot = 0;
|
|
188
|
+
record.purchaseAmount = '0';
|
|
189
|
+
record.refunded = true;
|
|
190
|
+
await writeRecords(records);
|
|
191
|
+
return true;
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
// Mark escrow as released. Preserves purchaseAmount/purchaseSlot so stats can
|
|
195
|
+
// compute released revenue. The `released` flag is the truth signal —
|
|
196
|
+
// buildEscrowMap and getReleasableRecords filter on it directly.
|
|
197
|
+
export async function recordRelease(zkAppAddress, licenseHash, releaseSlot) {
|
|
198
|
+
return withWriteLock(async () => {
|
|
199
|
+
const records = await readRecords();
|
|
200
|
+
const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
|
|
201
|
+
if (!record)
|
|
202
|
+
return false;
|
|
203
|
+
record.released = true;
|
|
204
|
+
if (releaseSlot !== undefined)
|
|
205
|
+
record.releaseSlot = releaseSlot;
|
|
206
|
+
await writeRecords(records);
|
|
207
|
+
return true;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
// Clear the pending flag on a record after a chain event confirms it landed.
|
|
211
|
+
export async function confirmRecord(zkAppAddress, licenseHash) {
|
|
212
|
+
return withWriteLock(async () => {
|
|
213
|
+
const records = await readRecords();
|
|
214
|
+
const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
|
|
215
|
+
if (!record)
|
|
216
|
+
return false;
|
|
217
|
+
if (!record.pending)
|
|
218
|
+
return false;
|
|
219
|
+
delete record.pending;
|
|
220
|
+
delete record.provedAtSlot;
|
|
221
|
+
await writeRecords(records);
|
|
222
|
+
return true;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
// Apply a licenseIssued event atomically: confirm any pending /confirm/buy
|
|
226
|
+
// write, backfill initialExpirySlot/tier if missing, or create a fresh record.
|
|
227
|
+
// Doing the whole read-decide-write inside a single lock acquisition avoids the
|
|
228
|
+
// stale-snapshot hazard that would arise if archiveBootstrap called
|
|
229
|
+
// getLicense + confirmRecord + upsertLicense as three separate locked steps —
|
|
230
|
+
// a concurrent write between them could be clobbered by the upsert's
|
|
231
|
+
// pre-confirm snapshot.
|
|
232
|
+
export async function applyLicenseIssued(zkAppAddress, licenseHash, fields) {
|
|
233
|
+
return withWriteLock(async () => {
|
|
234
|
+
const records = await readRecords();
|
|
235
|
+
const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
|
|
236
|
+
let dirty = false;
|
|
237
|
+
if (record) {
|
|
238
|
+
if (record.pending) {
|
|
239
|
+
delete record.pending;
|
|
240
|
+
delete record.provedAtSlot;
|
|
241
|
+
dirty = true;
|
|
242
|
+
}
|
|
243
|
+
if (record.initialExpirySlot === undefined) {
|
|
244
|
+
record.initialExpirySlot = fields.expirySlot;
|
|
245
|
+
dirty = true;
|
|
246
|
+
}
|
|
247
|
+
if (!record.tier && fields.tier) {
|
|
248
|
+
record.tier = fields.tier;
|
|
249
|
+
dirty = true;
|
|
250
|
+
}
|
|
251
|
+
if (fields.escrowless === true && record.escrowless !== true) {
|
|
252
|
+
record.escrowless = true;
|
|
253
|
+
dirty = true;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
records.push({
|
|
258
|
+
zkAppAddress,
|
|
259
|
+
licenseHash,
|
|
260
|
+
expirySlot: fields.expirySlot,
|
|
261
|
+
purchaseSlot: fields.purchaseSlot,
|
|
262
|
+
purchaseAmount: fields.purchaseAmount,
|
|
263
|
+
txHash: '',
|
|
264
|
+
createdAt: new Date().toISOString(),
|
|
265
|
+
initialExpirySlot: fields.expirySlot,
|
|
266
|
+
...(fields.tier ? { tier: fields.tier } : {}),
|
|
267
|
+
...(fields.escrowless === true ? { escrowless: true } : {}),
|
|
268
|
+
});
|
|
269
|
+
dirty = true;
|
|
270
|
+
}
|
|
271
|
+
if (dirty)
|
|
272
|
+
await writeRecords(records);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
// Delete a record entirely (used to reap stale pending entries the buyer never submitted).
|
|
276
|
+
export async function deleteRecord(zkAppAddress, licenseHash) {
|
|
277
|
+
return withWriteLock(async () => {
|
|
278
|
+
const records = await readRecords();
|
|
279
|
+
const idx = records.findIndex(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
|
|
280
|
+
if (idx < 0)
|
|
281
|
+
return false;
|
|
282
|
+
records.splice(idx, 1);
|
|
283
|
+
await writeRecords(records);
|
|
284
|
+
return true;
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
// List pending records whose prove slot is older than `currentSlot - maxAgeSlots`.
|
|
288
|
+
// Used by the reaper to drop records the buyer never actually signed/submitted.
|
|
289
|
+
export async function getStalePendingRecords(currentSlot, maxAgeSlots = PENDING_REAP_SLOTS) {
|
|
290
|
+
const records = await readRecords();
|
|
291
|
+
return records.filter((r) => r.pending && typeof r.provedAtSlot === 'number' && currentSlot - r.provedAtSlot > maxAgeSlots);
|
|
292
|
+
}
|
|
293
|
+
// Return all records where the refund window has passed and escrow is still live.
|
|
294
|
+
// Filters on the released/refunded flags so we don't re-release or release after refund.
|
|
295
|
+
export async function getReleasableRecords(currentSlot) {
|
|
296
|
+
const records = await readRecords();
|
|
297
|
+
return records.filter((r) => !r.released &&
|
|
298
|
+
!r.refunded &&
|
|
299
|
+
r.purchaseSlot > 0 &&
|
|
300
|
+
r.zkAppAddress &&
|
|
301
|
+
currentSlot > r.purchaseSlot + REFUND_WINDOW_N);
|
|
302
|
+
}
|
|
303
|
+
//# sourceMappingURL=licenseStore.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* manifestVerify.ts
|
|
3
|
+
*
|
|
4
|
+
* Browser + Node Ed25519 signature verification for platform-signed manifests
|
|
5
|
+
* (currently: the generation-list served at GET /apps/:appId, next: the
|
|
6
|
+
* sales-freeze manifest). The keeper signs with the platform manifest key
|
|
7
|
+
* loaded via PLATFORM_MANIFEST_KEY_FILE; the SDK pins the public key against
|
|
8
|
+
* a caller-supplied allow-list and verifies before trusting anything.
|
|
9
|
+
*
|
|
10
|
+
* Runtime: browsers and Node ≥ 20 expose `globalThis.crypto.subtle`. The
|
|
11
|
+
* SDK's `engines.node` is `>=20` for this reason — Node 18 child workers
|
|
12
|
+
* (spawned by `node --test`) do not populate `globalThis.crypto`.
|
|
13
|
+
*
|
|
14
|
+
* Canonicalization must match `src/manifestSign.ts` byte-for-byte:
|
|
15
|
+
* - sorted object keys
|
|
16
|
+
* - no whitespace
|
|
17
|
+
* - arrays preserved in order
|
|
18
|
+
* Any divergence silently breaks verification, so keep the algorithm dead
|
|
19
|
+
* simple and mirrored.
|
|
20
|
+
*/
|
|
21
|
+
export declare function canonicalize(value: unknown): string;
|
|
22
|
+
/**
|
|
23
|
+
* Verify a base64 Ed25519 signature over a canonicalized payload against
|
|
24
|
+
* one of the pinned base64 public keys. Returns true iff at least one
|
|
25
|
+
* pinned key verifies. Fails closed on any error (unknown key format,
|
|
26
|
+
* WebCrypto rejection, missing subtle) — callers must interpret `false`
|
|
27
|
+
* as "do not trust."
|
|
28
|
+
*/
|
|
29
|
+
export declare function verifyManifestSignature(payload: unknown, signatureBase64: string, pinnedPublicKeysBase64: readonly string[]): Promise<boolean>;
|
|
30
|
+
//# sourceMappingURL=manifestVerify.d.ts.map
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// All rights reserved.
|
|
3
|
+
/**
|
|
4
|
+
* manifestVerify.ts
|
|
5
|
+
*
|
|
6
|
+
* Browser + Node Ed25519 signature verification for platform-signed manifests
|
|
7
|
+
* (currently: the generation-list served at GET /apps/:appId, next: the
|
|
8
|
+
* sales-freeze manifest). The keeper signs with the platform manifest key
|
|
9
|
+
* loaded via PLATFORM_MANIFEST_KEY_FILE; the SDK pins the public key against
|
|
10
|
+
* a caller-supplied allow-list and verifies before trusting anything.
|
|
11
|
+
*
|
|
12
|
+
* Runtime: browsers and Node ≥ 20 expose `globalThis.crypto.subtle`. The
|
|
13
|
+
* SDK's `engines.node` is `>=20` for this reason — Node 18 child workers
|
|
14
|
+
* (spawned by `node --test`) do not populate `globalThis.crypto`.
|
|
15
|
+
*
|
|
16
|
+
* Canonicalization must match `src/manifestSign.ts` byte-for-byte:
|
|
17
|
+
* - sorted object keys
|
|
18
|
+
* - no whitespace
|
|
19
|
+
* - arrays preserved in order
|
|
20
|
+
* Any divergence silently breaks verification, so keep the algorithm dead
|
|
21
|
+
* simple and mirrored.
|
|
22
|
+
*/
|
|
23
|
+
// Canonical JSON: sorted keys, no whitespace, arrays preserved in order.
|
|
24
|
+
// Mirror of src/manifestSign.ts:canonicalize. Divergence here silently
|
|
25
|
+
// breaks verification.
|
|
26
|
+
export function canonicalize(value) {
|
|
27
|
+
if (value === null || typeof value !== 'object')
|
|
28
|
+
return JSON.stringify(value);
|
|
29
|
+
if (Array.isArray(value))
|
|
30
|
+
return '[' + value.map(canonicalize).join(',') + ']';
|
|
31
|
+
const keys = Object.keys(value).sort();
|
|
32
|
+
return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalize(value[k])).join(',') + '}';
|
|
33
|
+
}
|
|
34
|
+
function base64ToBytes(b64) {
|
|
35
|
+
// atob is present in every modern JS runtime we target (browser + Node ≥ 16).
|
|
36
|
+
const bin = atob(b64);
|
|
37
|
+
const out = new Uint8Array(bin.length);
|
|
38
|
+
for (let i = 0; i < bin.length; i++)
|
|
39
|
+
out[i] = bin.charCodeAt(i);
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
function utf8Bytes(s) {
|
|
43
|
+
return new TextEncoder().encode(s);
|
|
44
|
+
}
|
|
45
|
+
// SPKI DER wrapper around a raw 32-byte Ed25519 public key.
|
|
46
|
+
// Same prefix used by src/manifestSign.ts:verifyPayload — WebCrypto needs
|
|
47
|
+
// SPKI to import; the wire format is raw base64 for space.
|
|
48
|
+
const ED25519_SPKI_PREFIX = new Uint8Array([
|
|
49
|
+
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
|
50
|
+
]);
|
|
51
|
+
function rawEd25519ToSpki(raw) {
|
|
52
|
+
if (raw.length !== 32)
|
|
53
|
+
throw new Error(`Ed25519 pubkey must be 32 bytes, got ${raw.length}`);
|
|
54
|
+
const out = new Uint8Array(ED25519_SPKI_PREFIX.length + raw.length);
|
|
55
|
+
out.set(ED25519_SPKI_PREFIX, 0);
|
|
56
|
+
out.set(raw, ED25519_SPKI_PREFIX.length);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Verify a base64 Ed25519 signature over a canonicalized payload against
|
|
61
|
+
* one of the pinned base64 public keys. Returns true iff at least one
|
|
62
|
+
* pinned key verifies. Fails closed on any error (unknown key format,
|
|
63
|
+
* WebCrypto rejection, missing subtle) — callers must interpret `false`
|
|
64
|
+
* as "do not trust."
|
|
65
|
+
*/
|
|
66
|
+
export async function verifyManifestSignature(payload, signatureBase64, pinnedPublicKeysBase64) {
|
|
67
|
+
if (!pinnedPublicKeysBase64.length)
|
|
68
|
+
return false;
|
|
69
|
+
const subtle = globalThis.crypto?.subtle;
|
|
70
|
+
if (!subtle)
|
|
71
|
+
return false;
|
|
72
|
+
const messageBytes = utf8Bytes(canonicalize(payload));
|
|
73
|
+
let signatureBytes;
|
|
74
|
+
try {
|
|
75
|
+
signatureBytes = base64ToBytes(signatureBase64);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
for (const pinB64 of pinnedPublicKeysBase64) {
|
|
81
|
+
try {
|
|
82
|
+
const spki = rawEd25519ToSpki(base64ToBytes(pinB64));
|
|
83
|
+
const key = await subtle.importKey('spki', spki, { name: 'Ed25519' }, false, ['verify']);
|
|
84
|
+
const ok = await subtle.verify({ name: 'Ed25519' }, key, signatureBytes, messageBytes);
|
|
85
|
+
if (ok)
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Try the next pin — one bad entry in the pin list must not mask
|
|
90
|
+
// a valid signature under a different pin.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=manifestVerify.js.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* migrateClient.ts
|
|
3
|
+
*
|
|
4
|
+
* Browser-safe client for /prove/migrate. Assembles a single-hop cross-
|
|
5
|
+
* generation proof against the current live map — the keeper walks the
|
|
6
|
+
* deploymentHistory once, gathers all three witnesses (ancestor / pred /
|
|
7
|
+
* live-empty), and returns a signable tx.
|
|
8
|
+
*
|
|
9
|
+
* Failure surfaces:
|
|
10
|
+
* - 404 "license refunded or unknown": no leaf anywhere in history for
|
|
11
|
+
* this licenseHash. Caller should not retry — it's terminal.
|
|
12
|
+
* - 409 "already migrated": leaf is present in the live map. Caller
|
|
13
|
+
* should refresh their license record and stop.
|
|
14
|
+
* - 409 "migration in flight": another caller is proving right now.
|
|
15
|
+
* `retryAfterMs` on the error tells the caller how long to wait
|
|
16
|
+
* before trying again. See `ProveMigrateError.retryAfterMs`.
|
|
17
|
+
*/
|
|
18
|
+
export interface ProveMigrateRequest {
|
|
19
|
+
keeperUrl: string;
|
|
20
|
+
licenseHash: string;
|
|
21
|
+
appId: string;
|
|
22
|
+
buyerAddress: string;
|
|
23
|
+
fetcher?: typeof fetch;
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
}
|
|
26
|
+
export interface ProveMigrateResponse {
|
|
27
|
+
provenTxJson: string;
|
|
28
|
+
ancestorGen: number;
|
|
29
|
+
expirySlot: number;
|
|
30
|
+
licenseHash: string;
|
|
31
|
+
zkAppAddress: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Extends Error with the retryAfterMs hint the keeper returns on the
|
|
35
|
+
* "migration in flight" 409. Callers ignore it for other 4xx surfaces.
|
|
36
|
+
*/
|
|
37
|
+
export declare class ProveMigrateError extends Error {
|
|
38
|
+
readonly status: number;
|
|
39
|
+
readonly retryAfterMs?: number;
|
|
40
|
+
constructor(message: string, status: number, retryAfterMs?: number);
|
|
41
|
+
}
|
|
42
|
+
export declare function proveMigrate(req: ProveMigrateRequest): Promise<ProveMigrateResponse>;
|
|
43
|
+
//# sourceMappingURL=migrateClient.d.ts.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// All rights reserved.
|
|
3
|
+
/**
|
|
4
|
+
* Extends Error with the retryAfterMs hint the keeper returns on the
|
|
5
|
+
* "migration in flight" 409. Callers ignore it for other 4xx surfaces.
|
|
6
|
+
*/
|
|
7
|
+
export class ProveMigrateError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
retryAfterMs;
|
|
10
|
+
constructor(message, status, retryAfterMs) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'ProveMigrateError';
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.retryAfterMs = retryAfterMs;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function baseUrl(url) {
|
|
18
|
+
return url.replace(/\/+$/, '');
|
|
19
|
+
}
|
|
20
|
+
export async function proveMigrate(req) {
|
|
21
|
+
const fetcher = req.fetcher ?? fetch;
|
|
22
|
+
const resp = await fetcher(`${baseUrl(req.keeperUrl)}/prove/migrate`, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: { 'Content-Type': 'application/json' },
|
|
25
|
+
signal: req.signal,
|
|
26
|
+
body: JSON.stringify({
|
|
27
|
+
licenseHash: req.licenseHash,
|
|
28
|
+
appId: req.appId,
|
|
29
|
+
buyerAddress: req.buyerAddress,
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
if (!resp.ok) {
|
|
33
|
+
let msg = `HTTP ${resp.status}`;
|
|
34
|
+
let retryAfterMs;
|
|
35
|
+
try {
|
|
36
|
+
const body = await resp.json();
|
|
37
|
+
if (typeof body.error === 'string')
|
|
38
|
+
msg = body.error;
|
|
39
|
+
if (typeof body.retryAfterMs === 'number')
|
|
40
|
+
retryAfterMs = body.retryAfterMs;
|
|
41
|
+
}
|
|
42
|
+
catch { /* body wasn't JSON */ }
|
|
43
|
+
throw new ProveMigrateError(msg, resp.status, retryAfterMs);
|
|
44
|
+
}
|
|
45
|
+
return await resp.json();
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=migrateClient.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export declare const OWNERSHIP_CHALLENGE_TTL_MS = 600000;
|
|
2
|
+
export declare const OWNERSHIP_TOKEN_TTL_MS: number;
|
|
3
|
+
export type OwnershipTokenPayload = {
|
|
4
|
+
z: string;
|
|
5
|
+
l: string;
|
|
6
|
+
e: number;
|
|
7
|
+
jti?: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function newJti(): string;
|
|
10
|
+
export type ChallengeStore = {
|
|
11
|
+
issue(licenseHash: string): {
|
|
12
|
+
nonce: string;
|
|
13
|
+
ttl: number;
|
|
14
|
+
};
|
|
15
|
+
consume(licenseHash: string, nonce: string): 'ok' | 'unknown' | 'expired';
|
|
16
|
+
size(): number;
|
|
17
|
+
};
|
|
18
|
+
export declare function createChallengeStore(ttlMs?: number): ChallengeStore;
|
|
19
|
+
export declare function signOwnershipToken(key: Buffer, payload: OwnershipTokenPayload): string;
|
|
20
|
+
export declare function verifyOwnershipToken(key: Buffer, token: string): OwnershipTokenPayload | null;
|
|
21
|
+
export declare function newOwnershipKey(): Buffer;
|
|
22
|
+
export type SessionStore = {
|
|
23
|
+
insert(licenseHash: string, jti: string, exp: number, cap: number): {
|
|
24
|
+
state: 'ok' | 'at-cap';
|
|
25
|
+
active: number;
|
|
26
|
+
};
|
|
27
|
+
has(licenseHash: string, jti: string): boolean;
|
|
28
|
+
releaseSeat(licenseHash: string, jti: string): boolean;
|
|
29
|
+
reset(licenseHash: string): number;
|
|
30
|
+
activeCount(licenseHash: string): number;
|
|
31
|
+
size(): number;
|
|
32
|
+
};
|
|
33
|
+
export declare function createSessionStore(): SessionStore;
|
|
34
|
+
//# sourceMappingURL=ownership.d.ts.map
|