zklicensing 0.1.0-beta.2 → 0.1.0-beta.21
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 +417 -201
- package/build/agent.d.ts +371 -0
- package/build/agent.d.ts.map +1 -0
- package/build/agent.js +553 -0
- package/build/agent.js.map +1 -0
- package/build/agentActions.d.ts +841 -0
- package/build/agentActions.d.ts.map +1 -0
- package/build/agentActions.js +581 -0
- package/build/agentActions.js.map +1 -0
- package/build/agentRecord.d.ts +83 -0
- package/build/agentRecord.d.ts.map +1 -0
- package/build/agentRecord.js +69 -0
- package/build/agentRecord.js.map +1 -0
- package/build/agentVendor.d.ts +220 -0
- package/build/agentVendor.d.ts.map +1 -0
- package/build/agentVendor.js +306 -0
- package/build/agentVendor.js.map +1 -0
- package/build/appsLookup.d.ts +14 -0
- package/build/appsLookup.d.ts.map +1 -0
- package/build/appsLookup.js +12 -0
- package/build/appsLookup.js.map +1 -0
- package/build/archiveBootstrap.d.ts +31 -5
- package/build/archiveBootstrap.d.ts.map +1 -0
- package/build/archiveBootstrap.js +140 -47
- package/build/archiveBootstrap.js.map +1 -0
- package/build/atomicJson.d.ts +5 -0
- package/build/atomicJson.d.ts.map +1 -0
- package/build/atomicJson.js +55 -0
- package/build/atomicJson.js.map +1 -0
- package/build/bindClient.d.ts +85 -0
- package/build/bindClient.d.ts.map +1 -0
- package/build/bindClient.js +187 -0
- package/build/bindClient.js.map +1 -0
- package/build/buyClient.d.ts +27 -4
- package/build/buyClient.d.ts.map +1 -0
- package/build/buyClient.js +72 -26
- package/build/buyClient.js.map +1 -0
- package/build/chainHead.d.ts +61 -0
- package/build/chainHead.d.ts.map +1 -0
- package/build/chainHead.js +88 -0
- package/build/chainHead.js.map +1 -0
- package/build/compactMerkleMap.d.ts +72 -0
- package/build/compactMerkleMap.d.ts.map +1 -0
- package/build/compactMerkleMap.js +224 -0
- package/build/compactMerkleMap.js.map +1 -0
- package/build/contractInterface.d.ts +191 -51
- package/build/contractInterface.d.ts.map +1 -0
- package/build/contractInterface.js +234 -43
- package/build/contractInterface.js.map +1 -0
- package/build/contractTypes.d.ts +9 -0
- package/build/contractTypes.d.ts.map +1 -0
- package/build/contractTypes.js +28 -0
- package/build/contractTypes.js.map +1 -0
- package/build/countryStats.d.ts +12 -3
- package/build/countryStats.d.ts.map +1 -0
- package/build/countryStats.js +87 -23
- package/build/countryStats.js.map +1 -0
- package/build/deployClient.d.ts +138 -24
- package/build/deployClient.d.ts.map +1 -0
- package/build/deployClient.js +91 -31
- package/build/deployClient.js.map +1 -0
- package/build/deviceIdentity.d.ts +68 -0
- package/build/deviceIdentity.d.ts.map +1 -0
- package/build/deviceIdentity.js +209 -0
- package/build/deviceIdentity.js.map +1 -0
- package/build/freezeClient.d.ts +26 -6
- package/build/freezeClient.d.ts.map +1 -0
- package/build/freezeClient.js +30 -6
- package/build/freezeClient.js.map +1 -0
- package/build/generationClient.d.ts +6 -3
- package/build/generationClient.d.ts.map +1 -0
- package/build/generationClient.js +25 -38
- package/build/generationClient.js.map +1 -0
- package/build/holderSecret.d.ts +70 -0
- package/build/holderSecret.d.ts.map +1 -0
- package/build/holderSecret.js +76 -0
- package/build/holderSecret.js.map +1 -0
- package/build/index.d.ts +38 -38
- package/build/index.d.ts.map +1 -0
- package/build/index.js +97 -121
- package/build/index.js.map +1 -0
- package/build/keysClient.d.ts +142 -0
- package/build/keysClient.d.ts.map +1 -0
- package/build/keysClient.js +109 -0
- package/build/keysClient.js.map +1 -0
- package/build/licenseLeaf.d.ts +76 -0
- package/build/licenseLeaf.d.ts.map +1 -0
- package/build/licenseLeaf.js +189 -0
- package/build/licenseLeaf.js.map +1 -0
- package/build/licenseRecord.d.ts +68 -5
- package/build/licenseRecord.d.ts.map +1 -0
- package/build/licenseRecord.js +26 -1
- package/build/licenseRecord.js.map +1 -0
- package/build/licenseStore.d.ts +81 -4
- package/build/licenseStore.d.ts.map +1 -0
- package/build/licenseStore.js +400 -30
- package/build/licenseStore.js.map +1 -0
- package/build/licenseTrustless.d.ts +43 -0
- package/build/licenseTrustless.d.ts.map +1 -0
- package/build/licenseTrustless.js +180 -0
- package/build/licenseTrustless.js.map +1 -0
- package/build/manifestVerify.d.ts +29 -1
- package/build/manifestVerify.d.ts.map +1 -0
- package/build/manifestVerify.js +46 -4
- package/build/manifestVerify.js.map +1 -0
- package/build/minaPriceHistory.d.ts +9 -0
- package/build/minaPriceHistory.d.ts.map +1 -0
- package/build/minaPriceHistory.js +152 -0
- package/build/minaPriceHistory.js.map +1 -0
- package/build/onChainRoot.d.ts +60 -0
- package/build/onChainRoot.d.ts.map +1 -0
- package/build/onChainRoot.js +74 -0
- package/build/onChainRoot.js.map +1 -0
- package/build/ownershipLicenseHash.d.ts +25 -0
- package/build/ownershipLicenseHash.d.ts.map +1 -0
- package/build/ownershipLicenseHash.js +29 -0
- package/build/ownershipLicenseHash.js.map +1 -0
- package/build/ownershipSignature.d.ts +153 -0
- package/build/ownershipSignature.d.ts.map +1 -0
- package/build/ownershipSignature.js +297 -0
- package/build/ownershipSignature.js.map +1 -0
- package/build/permitCheckFlow.d.ts +155 -0
- package/build/permitCheckFlow.d.ts.map +1 -0
- package/build/permitCheckFlow.js +196 -0
- package/build/permitCheckFlow.js.map +1 -0
- package/build/permitDescribe.d.ts +124 -0
- package/build/permitDescribe.d.ts.map +1 -0
- package/build/permitDescribe.js +324 -0
- package/build/permitDescribe.js.map +1 -0
- package/build/permitInterface.d.ts +85 -0
- package/build/permitInterface.d.ts.map +1 -0
- package/build/permitInterface.js +121 -0
- package/build/permitInterface.js.map +1 -0
- package/build/permitMessages.d.ts +313 -0
- package/build/permitMessages.d.ts.map +1 -0
- package/build/permitMessages.js +462 -0
- package/build/permitMessages.js.map +1 -0
- package/build/permitProof.d.ts +108 -0
- package/build/permitProof.d.ts.map +1 -0
- package/build/permitProof.js +202 -0
- package/build/permitProof.js.map +1 -0
- package/build/permitRegistryRebuild.d.ts +166 -0
- package/build/permitRegistryRebuild.d.ts.map +1 -0
- package/build/permitRegistryRebuild.js +347 -0
- package/build/permitRegistryRebuild.js.map +1 -0
- package/build/permitTree.d.ts +88 -0
- package/build/permitTree.d.ts.map +1 -0
- package/build/permitTree.js +230 -0
- package/build/permitTree.js.map +1 -0
- package/build/permitsClient.d.ts +680 -0
- package/build/permitsClient.d.ts.map +1 -0
- package/build/permitsClient.js +597 -0
- package/build/permitsClient.js.map +1 -0
- package/build/proveErrors.d.ts +50 -0
- package/build/proveErrors.d.ts.map +1 -0
- package/build/proveErrors.js +76 -0
- package/build/proveErrors.js.map +1 -0
- package/build/receipt.d.ts +95 -0
- package/build/receipt.d.ts.map +1 -0
- package/build/receipt.js +84 -0
- package/build/receipt.js.map +1 -0
- package/build/refundClient.d.ts +7 -12
- package/build/refundClient.d.ts.map +1 -0
- package/build/refundClient.js +52 -21
- package/build/refundClient.js.map +1 -0
- package/build/registryTrustVerify.d.ts +78 -0
- package/build/registryTrustVerify.d.ts.map +1 -0
- package/build/registryTrustVerify.js +129 -0
- package/build/registryTrustVerify.js.map +1 -0
- package/build/releaseClient.d.ts +26 -0
- package/build/releaseClient.d.ts.map +1 -0
- package/build/releaseClient.js +65 -0
- package/build/releaseClient.js.map +1 -0
- package/build/renewClient.d.ts +28 -2
- package/build/renewClient.d.ts.map +1 -0
- package/build/renewClient.js +31 -27
- package/build/renewClient.js.map +1 -0
- package/build/seatName.d.ts +8 -0
- package/build/seatName.d.ts.map +1 -0
- package/build/seatName.js +136 -0
- package/build/seatName.js.map +1 -0
- package/build/slotConstants.d.ts +15 -0
- package/build/slotConstants.d.ts.map +1 -0
- package/build/slotConstants.js +17 -0
- package/build/slotConstants.js.map +1 -0
- package/build/statsClient.d.ts +61 -7
- package/build/statsClient.d.ts.map +1 -0
- package/build/statsClient.js +44 -5
- package/build/statsClient.js.map +1 -0
- package/build/successorChain.d.ts +35 -0
- package/build/successorChain.d.ts.map +1 -0
- package/build/successorChain.js +69 -0
- package/build/successorChain.js.map +1 -0
- package/build/taxId.d.ts +44 -0
- package/build/taxId.d.ts.map +1 -0
- package/build/taxId.js +212 -0
- package/build/taxId.js.map +1 -0
- package/build/trustAnchorVerify.d.ts +49 -0
- package/build/trustAnchorVerify.d.ts.map +1 -0
- package/build/trustAnchorVerify.js +432 -0
- package/build/trustAnchorVerify.js.map +1 -0
- package/build/trustAnchorVerifyCli.d.ts +45 -0
- package/build/trustAnchorVerifyCli.d.ts.map +1 -0
- package/build/trustAnchorVerifyCli.js +235 -0
- package/build/trustAnchorVerifyCli.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/unbindClient.d.ts +77 -0
- package/build/unbindClient.d.ts.map +1 -0
- package/build/unbindClient.js +169 -0
- package/build/unbindClient.js.map +1 -0
- package/build/units.d.ts +28 -0
- package/build/units.d.ts.map +1 -0
- package/build/units.js +65 -0
- package/build/units.js.map +1 -0
- package/build/vatRates.d.ts +54 -0
- package/build/vatRates.d.ts.map +1 -0
- package/build/vatRates.js +82 -0
- package/build/vatRates.js.map +1 -0
- package/build/vatRatesData.d.ts +5 -0
- package/build/vatRatesData.d.ts.map +1 -0
- package/build/vatRatesData.js +42 -0
- package/build/vatRatesData.js.map +1 -0
- package/build/vendorAccountClient.d.ts +66 -0
- package/build/vendorAccountClient.d.ts.map +1 -0
- package/build/vendorAccountClient.js +47 -0
- package/build/vendorAccountClient.js.map +1 -0
- package/build/verifyCore.d.ts +4 -1
- package/build/verifyCore.d.ts.map +1 -0
- package/build/verifyCore.js +21 -10
- package/build/verifyCore.js.map +1 -0
- package/build/verifyOffline.d.ts +85 -0
- package/build/verifyOffline.d.ts.map +1 -0
- package/build/verifyOffline.js +139 -0
- package/build/verifyOffline.js.map +1 -0
- package/build/verifyService.d.ts.map +1 -0
- package/build/verifyService.js +571 -501
- package/build/verifyService.js.map +1 -0
- package/build/verifyWithAncestors.d.ts +19 -0
- package/build/verifyWithAncestors.d.ts.map +1 -0
- package/build/verifyWithAncestors.js +127 -0
- package/build/verifyWithAncestors.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 +141 -16
- package/src/agent.ts +682 -0
- package/src/agentActions.ts +637 -0
- package/src/agentRecord.ts +121 -0
- package/src/agentVendor.ts +356 -0
- package/src/appsLookup.ts +48 -0
- package/src/archiveBootstrap.ts +386 -0
- package/src/atomicJson.ts +53 -0
- package/src/bindClient.ts +297 -0
- package/src/buyClient.ts +344 -0
- package/src/chainHead.ts +106 -0
- package/src/compactMerkleMap.ts +222 -0
- package/src/contractInterface.ts +465 -0
- package/src/contractTypes.ts +31 -0
- package/src/countryStats.ts +200 -0
- package/src/deployClient.ts +517 -0
- package/src/deviceIdentity.ts +249 -0
- package/src/freezeClient.ts +162 -0
- package/src/generationClient.ts +145 -0
- package/src/holderSecret.ts +91 -0
- package/src/index.ts +362 -0
- package/src/keysClient.ts +135 -0
- package/src/licenseLeaf.ts +221 -0
- package/src/licenseRecord.ts +190 -0
- package/src/licenseStore.ts +767 -0
- package/src/licenseTrustless.ts +207 -0
- package/src/manifestVerify.ts +164 -0
- package/src/minaPriceHistory.ts +166 -0
- package/src/onChainRoot.ts +90 -0
- package/src/ownershipLicenseHash.ts +31 -0
- package/src/ownershipSignature.ts +421 -0
- package/src/permitCheckFlow.ts +227 -0
- package/src/permitDescribe.ts +362 -0
- package/src/permitInterface.ts +146 -0
- package/src/permitMessages.ts +572 -0
- package/src/permitProof.ts +230 -0
- package/src/permitRegistryRebuild.ts +423 -0
- package/src/permitTree.ts +232 -0
- package/src/permitsClient.ts +746 -0
- package/src/proveErrors.ts +98 -0
- package/src/receipt.ts +156 -0
- package/src/refundClient.ts +177 -0
- package/src/registryTrustVerify.ts +192 -0
- package/src/releaseClient.ts +101 -0
- package/src/renewClient.ts +234 -0
- package/src/seatName.ts +165 -0
- package/src/slotConstants.ts +18 -0
- package/src/statsClient.ts +249 -0
- package/src/successorChain.ts +87 -0
- package/src/taxId.ts +227 -0
- package/src/trustAnchorVerify.ts +550 -0
- package/src/trustAnchorVerifyCli.ts +234 -0
- package/src/txBroadcastClient.ts +48 -0
- package/src/unbindClient.ts +233 -0
- package/src/units.ts +74 -0
- package/src/vatRates.ts +100 -0
- package/src/vatRatesData.ts +43 -0
- package/src/vendorAccountClient.ts +120 -0
- package/src/verifyCore.ts +100 -0
- package/src/verifyOffline.ts +212 -0
- package/src/verifyService.ts +982 -0
- package/src/verifyWithAncestors.ts +163 -0
- package/src/zipToState.ts +94 -0
- package/build/licenseProof.d.ts +0 -26
- package/build/licenseProof.js +0 -41
- package/build/migrateClient.d.ts +0 -43
- package/build/migrateClient.js +0 -47
- package/build/ownership.d.ts +0 -34
- package/build/ownership.js +0 -147
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* licenseStore.ts
|
|
6
|
+
*
|
|
7
|
+
* Off-chain MerkleMap state for the licensing system.
|
|
8
|
+
*
|
|
9
|
+
* Records are keyed by (zkAppAddress, licenseHash). Each LicensingApp deploys
|
|
10
|
+
* its own on-chain MerkleMap, so a passphrase used on two different apps
|
|
11
|
+
* produces the same `licenseHash` but two distinct records. Lookups that omit
|
|
12
|
+
* `zkAppAddress` would collide across apps — every API here takes it.
|
|
13
|
+
*
|
|
14
|
+
* Used by:
|
|
15
|
+
* - interact.ts / buyLicense.ts (CLI) — reads/writes directly
|
|
16
|
+
* - keeperService.ts — exposes the same logic over HTTP
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { Bool, Field, MerkleMap, MerkleMapWitness, Poseidon, PublicKey, UInt32, UInt64 } from 'o1js';
|
|
20
|
+
import { GRACE_PERIOD_N, REFUND_WINDOW_N, effectiveExpirySlot } from './contractInterface.js';
|
|
21
|
+
import { computeDeviceSlotsRoot, DEVICE_SLOT_CAP_N, leafFromRecord, leafHash } from './licenseLeaf.js';
|
|
22
|
+
import { PENDING_REAP_SLOTS as _PENDING_REAP_SLOTS, type LicenseRecord as _LicenseRecord, type LicenseStatus as _LicenseStatus } from './licenseRecord.js';
|
|
23
|
+
import { readJsonOr, writeJsonAtomic } from './atomicJson.js';
|
|
24
|
+
|
|
25
|
+
// The record shape and pure types now live in licenseRecord.ts so browser
|
|
26
|
+
// modules (buyClient, statsClient, countryStats) can consume them without
|
|
27
|
+
// importing Node's `fs`. Re-exported here so existing Node imports of
|
|
28
|
+
// `zklicensing/internal/licenseStore` continue to resolve.
|
|
29
|
+
export type LicenseRecord = _LicenseRecord;
|
|
30
|
+
export type LicenseStatus = _LicenseStatus;
|
|
31
|
+
export type { AgentWaiverRecord } from './licenseRecord.js';
|
|
32
|
+
export const PENDING_REAP_SLOTS = _PENDING_REAP_SLOTS;
|
|
33
|
+
|
|
34
|
+
export function licenseStorePath(): string {
|
|
35
|
+
return process.env.LICENSE_STORE_PATH ?? 'licenses.json';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Where records live. Default: the JSON file at licenseStorePath() (the
|
|
40
|
+
* verify service and SDK users). The keeper installs its SQLite store with
|
|
41
|
+
* setLicenseStoreBackend, including a lock that works across processes.
|
|
42
|
+
*/
|
|
43
|
+
export type LicenseStoreBackend = {
|
|
44
|
+
read(): Promise<unknown>;
|
|
45
|
+
write(records: LicenseRecord[]): Promise<void>;
|
|
46
|
+
/** Wraps each read-modify-write; default none (in-process lock only). */
|
|
47
|
+
withLock?<T>(fn: () => Promise<T>): Promise<T>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const fileBackend: LicenseStoreBackend = {
|
|
51
|
+
// Missing file → empty store; a damaged file is an error (see atomicJson).
|
|
52
|
+
read: () => readJsonOr<unknown>(licenseStorePath(), []),
|
|
53
|
+
write: records => writeJsonAtomic(licenseStorePath(), records),
|
|
54
|
+
};
|
|
55
|
+
let backend: LicenseStoreBackend = fileBackend;
|
|
56
|
+
|
|
57
|
+
export function setLicenseStoreBackend(b: LicenseStoreBackend | null): void {
|
|
58
|
+
backend = b ?? fileBackend;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function readRecords(): Promise<LicenseRecord[]> {
|
|
62
|
+
const raw: unknown = await backend.read();
|
|
63
|
+
if (!Array.isArray(raw)) return [];
|
|
64
|
+
const records = raw as LicenseRecord[];
|
|
65
|
+
// Records pre-dating composite keying lack `zkAppAddress` and cannot be
|
|
66
|
+
// safely matched against any specific zkApp. Drop them with a warning so
|
|
67
|
+
// bootstrap rebuilds them fresh from on-chain events.
|
|
68
|
+
const filtered = records.filter(r => typeof r.zkAppAddress === 'string' && r.zkAppAddress.length > 0);
|
|
69
|
+
if (filtered.length < records.length) {
|
|
70
|
+
console.warn(`[licenseStore] dropped ${records.length - filtered.length} record(s) missing zkAppAddress — re-bootstrap to recover`);
|
|
71
|
+
}
|
|
72
|
+
return filtered;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function writeRecords(records: LicenseRecord[]): Promise<void> {
|
|
76
|
+
await backend.write(records);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Serialize all read-modify-write operations against licenses.json. Each
|
|
80
|
+
// mutating function does (readRecords → mutate → writeRecords); without a lock,
|
|
81
|
+
// two concurrent calls can interleave such that the second one's read predates
|
|
82
|
+
// the first one's write, and the second one's write then clobbers the first.
|
|
83
|
+
// This was visible during archive sync (applyEventsToStore fires many writes
|
|
84
|
+
// back-to-back) racing with /confirm/* writes from buyer flows.
|
|
85
|
+
let writeChain: Promise<void> = Promise.resolve();
|
|
86
|
+
function withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
87
|
+
const locked = () => (backend.withLock ? backend.withLock(fn) : fn());
|
|
88
|
+
const result = writeChain.then(locked, locked);
|
|
89
|
+
writeChain = result.then(() => {}, () => {});
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Records belonging to a specific zkApp. Each zkApp owns an independent
|
|
94
|
+
// on-chain MerkleMap, so all map operations must filter first.
|
|
95
|
+
function recordsForApp(records: LicenseRecord[], zkAppAddress: string): LicenseRecord[] {
|
|
96
|
+
return records.filter(r => r.zkAppAddress === zkAppAddress);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Rebuild the license MerkleMap for a single zkApp. Only inserts entries
|
|
100
|
+
// with expirySlot > 0. The value stored at each key is `leaf.hash()` — the
|
|
101
|
+
// Poseidon commitment over (licenseHash, expirySlot, cap, deviceSlotsRoot,
|
|
102
|
+
// deviceSlotsFilled) — matching what LicensingApp writes. Records that
|
|
103
|
+
// predate device binding rebuild to a leaf with empty slots (see
|
|
104
|
+
// leafFromRecord for the exact defaults); on those records the rebuilt
|
|
105
|
+
// root matches what the old contract wrote, but any mutation call will
|
|
106
|
+
// fail the in-circuit hash check until the missing fields are backfilled
|
|
107
|
+
// from chain events.
|
|
108
|
+
export function buildMap(records: LicenseRecord[], zkAppAddress: string): MerkleMap {
|
|
109
|
+
const map = new MerkleMap();
|
|
110
|
+
for (const r of recordsForApp(records, zkAppAddress)) {
|
|
111
|
+
if (r.expirySlot !== 0) {
|
|
112
|
+
map.set(Field(r.licenseHash), leafHash(leafFromRecord(r)));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return map;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The license expiry the escrow commitment was made against — the expiry at
|
|
119
|
+
// purchase time, before any renewal. `initialExpirySlot` is written at every
|
|
120
|
+
// issue path (prove/buy staging, the licenseIssued event apply); the
|
|
121
|
+
// `expirySlot` fallback only covers legacy records, which cannot have been
|
|
122
|
+
// renewed without also receiving the backfill.
|
|
123
|
+
export function escrowExpirySlot(r: LicenseRecord): number {
|
|
124
|
+
return r.initialExpirySlot ?? r.expirySlot;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Rebuild the escrow MerkleMap for a single zkApp:
|
|
128
|
+
// licenseHash → escrowCommitment(amount, slot, buyerKey, initialExpirySlot).
|
|
129
|
+
// Only includes entries with live escrow (not released, not refunded, not an
|
|
130
|
+
// escrowless purchase) and a known buyer. Must match LicensingApp.ts
|
|
131
|
+
// escrowCommitment field-for-field.
|
|
132
|
+
export function buildEscrowMap(records: LicenseRecord[], zkAppAddress: string): MerkleMap {
|
|
133
|
+
const map = new MerkleMap();
|
|
134
|
+
for (const r of recordsForApp(records, zkAppAddress)) {
|
|
135
|
+
if (!r.released && !r.refunded && !r.escrowless && r.purchaseSlot > 0 && r.buyerAddress) {
|
|
136
|
+
const buyerKey = PublicKey.fromBase58(r.buyerAddress);
|
|
137
|
+
const commitment = Poseidon.hash([
|
|
138
|
+
UInt64.from(BigInt(r.purchaseAmount)).value,
|
|
139
|
+
UInt32.from(r.purchaseSlot).value,
|
|
140
|
+
buyerKey.x,
|
|
141
|
+
buyerKey.isOdd.toField(),
|
|
142
|
+
UInt32.from(escrowExpirySlot(r)).value,
|
|
143
|
+
]);
|
|
144
|
+
map.set(Field(r.licenseHash), commitment);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return map;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Serialize a MerkleMapWitness to plain JSON (safe to send over HTTP).
|
|
151
|
+
export function serializeWitness(witness: MerkleMapWitness): {
|
|
152
|
+
isLefts: boolean[];
|
|
153
|
+
siblings: string[];
|
|
154
|
+
} {
|
|
155
|
+
return {
|
|
156
|
+
isLefts: witness.isLefts.map((b) => b.toBoolean()),
|
|
157
|
+
siblings: witness.siblings.map((f) => f.toString()),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Reconstruct a MerkleMapWitness from serialized JSON.
|
|
162
|
+
export function deserializeWitness(json: {
|
|
163
|
+
isLefts: boolean[];
|
|
164
|
+
siblings: string[];
|
|
165
|
+
}): MerkleMapWitness {
|
|
166
|
+
return new MerkleMapWitness(
|
|
167
|
+
json.isLefts.map((b: boolean) => Bool(b)),
|
|
168
|
+
json.siblings.map((s: string) => Field(s))
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Returns the witness for a licenseHash key in the given zkApp's map.
|
|
173
|
+
export async function getWitness(zkAppAddress: string, licenseHash: string): Promise<{
|
|
174
|
+
root: string;
|
|
175
|
+
currentValue: string;
|
|
176
|
+
witness: MerkleMapWitness;
|
|
177
|
+
serialized: ReturnType<typeof serializeWitness>;
|
|
178
|
+
}> {
|
|
179
|
+
const records = await readRecords();
|
|
180
|
+
const map = buildMap(records, zkAppAddress);
|
|
181
|
+
const key = Field(licenseHash);
|
|
182
|
+
const witness = map.getWitness(key);
|
|
183
|
+
return {
|
|
184
|
+
root: map.getRoot().toString(),
|
|
185
|
+
currentValue: map.get(key).toString(),
|
|
186
|
+
witness,
|
|
187
|
+
serialized: serializeWitness(witness),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Look up a single license record by its composite key.
|
|
192
|
+
export async function getLicense(zkAppAddress: string, licenseHash: string): Promise<LicenseRecord | null> {
|
|
193
|
+
const records = await readRecords();
|
|
194
|
+
return records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash) ?? null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Determine the status of a license. A license in the 7-day grace window is
|
|
198
|
+
// still considered 'active' so renewal is not urgently forced.
|
|
199
|
+
export function licenseStatus(record: LicenseRecord | null, currentSlot: number, forkActivationSlot = 0): LicenseStatus {
|
|
200
|
+
if (!record) return 'unknown';
|
|
201
|
+
if (record.transferredTo) return 'transferred';
|
|
202
|
+
if (record.expirySlot === 0) return 'refunded';
|
|
203
|
+
if (effectiveExpirySlot(record.expirySlot, forkActivationSlot) + GRACE_PERIOD_N < currentSlot) return 'expired';
|
|
204
|
+
return 'active';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A redeployed app's successor starts with every license of `from` (its
|
|
209
|
+
* frozen tree, checked on chain): copy them to `to`, marked carriedOverFrom
|
|
210
|
+
* (not new sales). Refunded and already-present licenses are skipped.
|
|
211
|
+
* Returns how many were copied.
|
|
212
|
+
*/
|
|
213
|
+
export async function carryOverRecords(from: string, to: string): Promise<number> {
|
|
214
|
+
return withWriteLock(async () => {
|
|
215
|
+
const records = await readRecords();
|
|
216
|
+
const present = new Set(records.filter(r => r.zkAppAddress === to).map(r => r.licenseHash));
|
|
217
|
+
const copies = records
|
|
218
|
+
.filter(r => r.zkAppAddress === from && r.expirySlot > 0 && !present.has(r.licenseHash))
|
|
219
|
+
.map((r): LicenseRecord => ({
|
|
220
|
+
// License state only: the sale, its renewals and its escrow stay on
|
|
221
|
+
// the earlier deployment's record, so they're counted once.
|
|
222
|
+
zkAppAddress: to, carriedOverFrom: from,
|
|
223
|
+
licenseHash: r.licenseHash, expirySlot: r.expirySlot,
|
|
224
|
+
purchaseSlot: 0, purchaseAmount: '0', txHash: r.txHash, createdAt: r.createdAt,
|
|
225
|
+
buyerAddress: r.buyerAddress, deviceCap: r.deviceCap, deviceSlotsRoot: r.deviceSlotsRoot,
|
|
226
|
+
deviceSlotsFilled: r.deviceSlotsFilled, devices: r.devices,
|
|
227
|
+
initialExpirySlot: r.initialExpirySlot, tier: r.tier,
|
|
228
|
+
renewedAtLeastOnce: r.renewedAtLeastOnce || (r.renewals?.length ?? 0) > 0 || undefined,
|
|
229
|
+
}));
|
|
230
|
+
if (copies.length) await writeRecords([...records, ...copies]);
|
|
231
|
+
return copies.length;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* A forced retirement left `zkAppAddress`'s escrowed purchases on the
|
|
237
|
+
* retired contract: marks them stranded (no refund or release follows).
|
|
238
|
+
* Returns how many.
|
|
239
|
+
*/
|
|
240
|
+
export async function markEscrowStranded(zkAppAddress: string, txHash: string, at = new Date().toISOString()): Promise<number> {
|
|
241
|
+
return withWriteLock(async () => {
|
|
242
|
+
const records = await readRecords();
|
|
243
|
+
let n = 0;
|
|
244
|
+
for (const r of records) {
|
|
245
|
+
if (r.zkAppAddress !== zkAppAddress || r.released || r.refunded || r.escrowless || r.escrowStranded || !(r.purchaseSlot > 0)) continue;
|
|
246
|
+
r.escrowStranded = { txHash, at };
|
|
247
|
+
n++;
|
|
248
|
+
}
|
|
249
|
+
if (n) await writeRecords(records);
|
|
250
|
+
return n;
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Add or update a license record (upsert by composite key).
|
|
255
|
+
export async function upsertLicense(record: LicenseRecord): Promise<void> {
|
|
256
|
+
if (!record.zkAppAddress) {
|
|
257
|
+
throw new Error('upsertLicense: record.zkAppAddress is required');
|
|
258
|
+
}
|
|
259
|
+
// Populate the empty-slot mirror at insert time when the license is capped.
|
|
260
|
+
// Without this, /confirm/buy writes deviceCap:N but leaves deviceSlotsRoot
|
|
261
|
+
// and deviceSlotsFilled undefined until the first bind — leafFromRecord's
|
|
262
|
+
// fallbacks paper over it for hash reconstruction, but every consumer that
|
|
263
|
+
// reads the mirror directly (preflight logs, /license responses, offline
|
|
264
|
+
// verify) would see undefined and either misreport or need its own fallback.
|
|
265
|
+
if ((record.deviceCap ?? 0) > 0 && (record.deviceSlotsRoot === undefined || record.deviceSlotsFilled === undefined)) {
|
|
266
|
+
refreshSlotMirror(record);
|
|
267
|
+
}
|
|
268
|
+
return withWriteLock(async () => {
|
|
269
|
+
const records = await readRecords();
|
|
270
|
+
const idx = records.findIndex(r => r.zkAppAddress === record.zkAppAddress && r.licenseHash === record.licenseHash);
|
|
271
|
+
if (idx >= 0) {
|
|
272
|
+
records[idx] = record;
|
|
273
|
+
} else {
|
|
274
|
+
records.push(record);
|
|
275
|
+
}
|
|
276
|
+
await writeRecords(records);
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Update the expirySlot for an existing record (used by renew).
|
|
281
|
+
// `opts.markRenewed` flips renewedAtLeastOnce so stats can detect first-renewal
|
|
282
|
+
// without re-walking events. `opts.renewal` appends to the renewals[] log so
|
|
283
|
+
// stats can compute renewal revenue from the store alone.
|
|
284
|
+
export async function updateExpiry(
|
|
285
|
+
zkAppAddress: string,
|
|
286
|
+
licenseHash: string,
|
|
287
|
+
expirySlot: number,
|
|
288
|
+
opts: { markRenewed?: boolean; renewal?: { slot: number; amount: string; newExpiry: number; txHash?: string } } = {},
|
|
289
|
+
): Promise<boolean> {
|
|
290
|
+
return withWriteLock(async () => {
|
|
291
|
+
const records = await readRecords();
|
|
292
|
+
const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
|
|
293
|
+
if (!record) return false;
|
|
294
|
+
record.expirySlot = expirySlot;
|
|
295
|
+
if (opts.markRenewed) record.renewedAtLeastOnce = true;
|
|
296
|
+
if (opts.renewal) {
|
|
297
|
+
const renewals = (record.renewals ??= []);
|
|
298
|
+
// Dedup by newExpiry — confirm-time and event-sync may both try to log
|
|
299
|
+
// the same renewal, but each renewal advances expiry by a unique tier
|
|
300
|
+
// amount, so newExpiry is a stable identifier.
|
|
301
|
+
if (!renewals.some(r => r.newExpiry === opts.renewal!.newExpiry)) {
|
|
302
|
+
renewals.push(opts.renewal);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
await writeRecords(records);
|
|
306
|
+
return true;
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Clear license and escrow data after a refund. Also flips `refunded`.
|
|
311
|
+
//
|
|
312
|
+
// Two modes:
|
|
313
|
+
// provisional=false (default, called from archive event-sync on a canonical
|
|
314
|
+
// refundIssued event): sets refunded=true, zeros expiry/purchase, clears
|
|
315
|
+
// pending. This is the authoritative confirm — includes upgrading a prior
|
|
316
|
+
// 'fast' flip.
|
|
317
|
+
// provisional=true (called from the tx-land watcher on first bestchain
|
|
318
|
+
// observation): sets refunded='fast' and records provisionalFlipAtSlot,
|
|
319
|
+
// but does NOT zero expirySlot/purchaseSlot yet — those stay live until
|
|
320
|
+
// the event is canonical, so a reorg revert restores refunded=false
|
|
321
|
+
// without needing snapshots.
|
|
322
|
+
export async function recordRefund(
|
|
323
|
+
zkAppAddress: string,
|
|
324
|
+
licenseHash: string,
|
|
325
|
+
opts?: { provisional?: boolean; nowSlot?: number },
|
|
326
|
+
): Promise<boolean> {
|
|
327
|
+
return withWriteLock(async () => {
|
|
328
|
+
const records = await readRecords();
|
|
329
|
+
const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
|
|
330
|
+
if (!record) return false;
|
|
331
|
+
if (opts?.provisional) {
|
|
332
|
+
// Preserve expirySlot/purchaseSlot for revertability. UI treats
|
|
333
|
+
// 'fast' as refunded (truthy); circuit ops see 409 via pre-flight
|
|
334
|
+
// guards if the local state diverges from chain during the window.
|
|
335
|
+
if (record.refunded !== true) record.refunded = 'fast';
|
|
336
|
+
if (opts.nowSlot !== undefined) record.provisionalFlipAtSlot = opts.nowSlot;
|
|
337
|
+
} else {
|
|
338
|
+
record.expirySlot = 0;
|
|
339
|
+
record.purchaseSlot = 0;
|
|
340
|
+
record.purchaseAmount = '0';
|
|
341
|
+
record.refunded = true;
|
|
342
|
+
// /confirm/refund wrote pending:true optimistically; the on-chain event
|
|
343
|
+
// landing is what authorizes clearing it. Without this the SDK poller
|
|
344
|
+
// (which requires pending===false && status==='refunded') hangs forever.
|
|
345
|
+
delete record.pending;
|
|
346
|
+
delete record.pendingPrior;
|
|
347
|
+
delete record.provedAtSlot;
|
|
348
|
+
// Any prior 'fast' provisional flip is now confirmed — drop the marker.
|
|
349
|
+
delete record.provisionalFlipAtSlot;
|
|
350
|
+
}
|
|
351
|
+
await writeRecords(records);
|
|
352
|
+
return true;
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Mark escrow as released. Preserves purchaseAmount/purchaseSlot so stats can
|
|
357
|
+
// compute released revenue. The `released` flag is the truth signal —
|
|
358
|
+
// buildEscrowMap and getReleasableRecords filter on it directly.
|
|
359
|
+
//
|
|
360
|
+
// Two modes: same semantics as recordRefund above.
|
|
361
|
+
// provisional=true → released='fast', provisionalFlipAtSlot=nowSlot
|
|
362
|
+
// provisional=false → released=true (upgrades any 'fast' state), clears
|
|
363
|
+
// provisionalFlipAtSlot
|
|
364
|
+
export async function recordRelease(
|
|
365
|
+
zkAppAddress: string,
|
|
366
|
+
licenseHash: string,
|
|
367
|
+
releaseSlot?: number,
|
|
368
|
+
txHash?: string,
|
|
369
|
+
opts?: { provisional?: boolean; nowSlot?: number },
|
|
370
|
+
): Promise<boolean> {
|
|
371
|
+
return withWriteLock(async () => {
|
|
372
|
+
const records = await readRecords();
|
|
373
|
+
const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
|
|
374
|
+
if (!record) return false;
|
|
375
|
+
if (opts?.provisional) {
|
|
376
|
+
if (record.released !== true) record.released = 'fast';
|
|
377
|
+
if (opts.nowSlot !== undefined) record.provisionalFlipAtSlot = opts.nowSlot;
|
|
378
|
+
} else {
|
|
379
|
+
record.released = true;
|
|
380
|
+
delete record.provisionalFlipAtSlot;
|
|
381
|
+
}
|
|
382
|
+
if (releaseSlot !== undefined) record.releaseSlot = releaseSlot;
|
|
383
|
+
if (txHash !== undefined) record.releaseTxHash = txHash;
|
|
384
|
+
await writeRecords(records);
|
|
385
|
+
return true;
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Roll back a provisional 'fast' flip that never got confirmed by the archive.
|
|
390
|
+
// Called by the reorg-revert reaper after REORG_REVERT_AFTER_SLOTS elapses
|
|
391
|
+
// without a canonical event. Only reverts if the field is still 'fast' —
|
|
392
|
+
// confirmed (true) flips are irreversible from this path.
|
|
393
|
+
export async function revertProvisionalFlip(
|
|
394
|
+
zkAppAddress: string,
|
|
395
|
+
licenseHash: string,
|
|
396
|
+
kind: 'release' | 'refund',
|
|
397
|
+
): Promise<boolean> {
|
|
398
|
+
return withWriteLock(async () => {
|
|
399
|
+
const records = await readRecords();
|
|
400
|
+
const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
|
|
401
|
+
if (!record) return false;
|
|
402
|
+
if (kind === 'release') {
|
|
403
|
+
if (record.released !== 'fast') return false;
|
|
404
|
+
record.released = false;
|
|
405
|
+
delete record.releaseSlot;
|
|
406
|
+
delete record.releaseTxHash;
|
|
407
|
+
} else {
|
|
408
|
+
if (record.refunded !== 'fast') return false;
|
|
409
|
+
record.refunded = false;
|
|
410
|
+
// expirySlot/purchaseSlot were never zeroed for provisional refunds, so
|
|
411
|
+
// nothing to restore there.
|
|
412
|
+
}
|
|
413
|
+
// Clear the marker only if no OTHER field is still provisional.
|
|
414
|
+
if (record.released !== 'fast' && record.refunded !== 'fast') {
|
|
415
|
+
delete record.provisionalFlipAtSlot;
|
|
416
|
+
}
|
|
417
|
+
await writeRecords(records);
|
|
418
|
+
return true;
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Clear the pending flag on a record after a chain event confirms it landed.
|
|
423
|
+
export async function confirmRecord(zkAppAddress: string, licenseHash: string): Promise<boolean> {
|
|
424
|
+
return withWriteLock(async () => {
|
|
425
|
+
const records = await readRecords();
|
|
426
|
+
const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
|
|
427
|
+
if (!record) return false;
|
|
428
|
+
if (!record.pending) return false;
|
|
429
|
+
delete record.pending;
|
|
430
|
+
delete record.pendingPrior;
|
|
431
|
+
delete record.provedAtSlot;
|
|
432
|
+
await writeRecords(records);
|
|
433
|
+
return true;
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Apply a licenseIssued event atomically: confirm any pending /confirm/buy
|
|
438
|
+
// write, backfill initialExpirySlot/tier if missing, or create a fresh record.
|
|
439
|
+
// Doing the whole read-decide-write inside a single lock acquisition avoids the
|
|
440
|
+
// stale-snapshot hazard that would arise if archiveBootstrap called
|
|
441
|
+
// getLicense + confirmRecord + upsertLicense as three separate locked steps —
|
|
442
|
+
// a concurrent write between them could be clobbered by the upsert's
|
|
443
|
+
// pre-confirm snapshot.
|
|
444
|
+
export async function applyLicenseIssued(
|
|
445
|
+
zkAppAddress: string,
|
|
446
|
+
licenseHash: string,
|
|
447
|
+
fields: {
|
|
448
|
+
expirySlot: number;
|
|
449
|
+
purchaseSlot: number;
|
|
450
|
+
purchaseAmount: string;
|
|
451
|
+
tier?: 'monthly' | 'yearly' | 'fiveYear' | 'perpetual';
|
|
452
|
+
// True for licenses minted through buyLicenseNoEscrow: funds went straight
|
|
453
|
+
// to vendor + platform, no escrow entry. Refund CTA must be suppressed.
|
|
454
|
+
escrowless?: boolean;
|
|
455
|
+
// Leaf fields carried by LicenseIssuedEvent so a record rehydrated purely
|
|
456
|
+
// from archive replay (no prior /confirm/buy) reconstructs the exact leaf
|
|
457
|
+
// hash the circuit committed. Optional so pre-event-extension replays
|
|
458
|
+
// (which never carried them) still bootstrap — those records will fail
|
|
459
|
+
// in-circuit hash checks on the next mutation, matching prior behavior.
|
|
460
|
+
// No ownerPubKeyHash field: the leaf's owner-pubkey commitment IS
|
|
461
|
+
// `licenseHash` itself (buyLicense asserts them equal), already the key
|
|
462
|
+
// this function is called with.
|
|
463
|
+
deviceCap?: number;
|
|
464
|
+
deviceSlotsRoot?: string;
|
|
465
|
+
deviceSlotsFilled?: number;
|
|
466
|
+
// Buyer's public key, carried by LicenseIssuedEvent. Required for
|
|
467
|
+
// buildEscrowMap (funds-release witness reconstruction) AND for
|
|
468
|
+
// GET /licenses?buyer=<addr> enumeration (the primary UI source for the
|
|
469
|
+
// "My Licenses" view). Migration-replay callers must look up the
|
|
470
|
+
// predecessor record's buyerAddress before calling this.
|
|
471
|
+
buyerAddress: string;
|
|
472
|
+
},
|
|
473
|
+
): Promise<void> {
|
|
474
|
+
return withWriteLock(async () => {
|
|
475
|
+
const records = await readRecords();
|
|
476
|
+
const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
|
|
477
|
+
let dirty = false;
|
|
478
|
+
if (record) {
|
|
479
|
+
if (record.pending) {
|
|
480
|
+
delete record.pending;
|
|
481
|
+
delete record.pendingPrior;
|
|
482
|
+
delete record.provedAtSlot;
|
|
483
|
+
dirty = true;
|
|
484
|
+
}
|
|
485
|
+
if (record.initialExpirySlot === undefined) {
|
|
486
|
+
record.initialExpirySlot = fields.expirySlot;
|
|
487
|
+
dirty = true;
|
|
488
|
+
}
|
|
489
|
+
if (!record.tier && fields.tier) {
|
|
490
|
+
record.tier = fields.tier;
|
|
491
|
+
dirty = true;
|
|
492
|
+
}
|
|
493
|
+
if (fields.escrowless === true && record.escrowless !== true) {
|
|
494
|
+
record.escrowless = true;
|
|
495
|
+
dirty = true;
|
|
496
|
+
}
|
|
497
|
+
// Backfill leaf fields if the record was created before the event
|
|
498
|
+
// carried them, or if a prior partial rehydrate is still missing them.
|
|
499
|
+
if (fields.deviceCap !== undefined && record.deviceCap === undefined) {
|
|
500
|
+
record.deviceCap = fields.deviceCap;
|
|
501
|
+
dirty = true;
|
|
502
|
+
}
|
|
503
|
+
if (fields.deviceSlotsRoot !== undefined && record.deviceSlotsRoot === undefined) {
|
|
504
|
+
record.deviceSlotsRoot = fields.deviceSlotsRoot;
|
|
505
|
+
dirty = true;
|
|
506
|
+
}
|
|
507
|
+
if (fields.deviceSlotsFilled !== undefined && record.deviceSlotsFilled === undefined) {
|
|
508
|
+
record.deviceSlotsFilled = fields.deviceSlotsFilled;
|
|
509
|
+
dirty = true;
|
|
510
|
+
}
|
|
511
|
+
if (record.buyerAddress === undefined) {
|
|
512
|
+
// Legacy record predating the required-buyerAddress change — backfill
|
|
513
|
+
// from the fresh event so it becomes enumerable via /licenses?buyer=.
|
|
514
|
+
record.buyerAddress = fields.buyerAddress;
|
|
515
|
+
dirty = true;
|
|
516
|
+
}
|
|
517
|
+
} else {
|
|
518
|
+
const inserted: LicenseRecord = {
|
|
519
|
+
zkAppAddress,
|
|
520
|
+
licenseHash,
|
|
521
|
+
expirySlot: fields.expirySlot,
|
|
522
|
+
purchaseSlot: fields.purchaseSlot,
|
|
523
|
+
purchaseAmount: fields.purchaseAmount,
|
|
524
|
+
txHash: '',
|
|
525
|
+
createdAt: new Date().toISOString(),
|
|
526
|
+
initialExpirySlot: fields.expirySlot,
|
|
527
|
+
...(fields.tier ? { tier: fields.tier } : {}),
|
|
528
|
+
...(fields.escrowless === true ? { escrowless: true } : {}),
|
|
529
|
+
...(fields.deviceCap !== undefined ? { deviceCap: fields.deviceCap } : {}),
|
|
530
|
+
...(fields.deviceSlotsRoot !== undefined ? { deviceSlotsRoot: fields.deviceSlotsRoot } : {}),
|
|
531
|
+
...(fields.deviceSlotsFilled !== undefined ? { deviceSlotsFilled: fields.deviceSlotsFilled } : {}),
|
|
532
|
+
buyerAddress: fields.buyerAddress,
|
|
533
|
+
};
|
|
534
|
+
// Same rationale as upsertLicense: populate the empty-slot mirror on
|
|
535
|
+
// first insert so the record is immediately queryable without relying
|
|
536
|
+
// on leafFromRecord's undefined fallbacks.
|
|
537
|
+
if ((inserted.deviceCap ?? 0) > 0 && (inserted.deviceSlotsRoot === undefined || inserted.deviceSlotsFilled === undefined)) {
|
|
538
|
+
refreshSlotMirror(inserted);
|
|
539
|
+
}
|
|
540
|
+
records.push(inserted);
|
|
541
|
+
dirty = true;
|
|
542
|
+
}
|
|
543
|
+
if (dirty) await writeRecords(records);
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Apply a licenseRenewed event atomically: clear any pending /confirm/renew
|
|
548
|
+
// write, align expirySlot with the event, and flip renewedAtLeastOnce.
|
|
549
|
+
// Symmetric to applyLicenseIssued — the archive-events path is what closes
|
|
550
|
+
// out a renew that the bestChain sweep missed (tx landed after the sweep's
|
|
551
|
+
// BESTCHAIN_DEPTH window rolled past), and without a pending-clear step here
|
|
552
|
+
// the flag on a landed record could be stuck forever.
|
|
553
|
+
//
|
|
554
|
+
// Does NOT append to renewals[] — that log is populated at /confirm/renew
|
|
555
|
+
// time from the intent (which carries amount + txHash); the archive event
|
|
556
|
+
// carries only newExpiry, so replaying it must not overwrite the richer
|
|
557
|
+
// confirm-time entry.
|
|
558
|
+
export async function applyLicenseRenewed(
|
|
559
|
+
zkAppAddress: string,
|
|
560
|
+
licenseHash: string,
|
|
561
|
+
fields: { newExpiry: number },
|
|
562
|
+
): Promise<boolean> {
|
|
563
|
+
return withWriteLock(async () => {
|
|
564
|
+
const records = await readRecords();
|
|
565
|
+
const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
|
|
566
|
+
if (!record) return false;
|
|
567
|
+
let dirty = false;
|
|
568
|
+
if (record.pending) {
|
|
569
|
+
delete record.pending;
|
|
570
|
+
delete record.pendingPrior;
|
|
571
|
+
delete record.provedAtSlot;
|
|
572
|
+
dirty = true;
|
|
573
|
+
}
|
|
574
|
+
if (record.expirySlot !== fields.newExpiry) {
|
|
575
|
+
record.expirySlot = fields.newExpiry;
|
|
576
|
+
dirty = true;
|
|
577
|
+
}
|
|
578
|
+
if (!record.renewedAtLeastOnce) {
|
|
579
|
+
record.renewedAtLeastOnce = true;
|
|
580
|
+
dirty = true;
|
|
581
|
+
}
|
|
582
|
+
if (dirty) await writeRecords(records);
|
|
583
|
+
return true;
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* A license moved to a new owner key (transferLicense): the license state
|
|
589
|
+
* (expiry, cap, bound devices) goes to a record under `to`, with no sale of
|
|
590
|
+
* its own (the sale and its receipt stay on the original record), listed
|
|
591
|
+
* under `wallet` (the transfer's sender, the new owner's wallet, which
|
|
592
|
+
* paid), and the original leaves the licenses tree (expirySlot 0) marked
|
|
593
|
+
* `transferredTo`.
|
|
594
|
+
*/
|
|
595
|
+
export async function applyLicenseTransferred(
|
|
596
|
+
zkAppAddress: string,
|
|
597
|
+
from: string,
|
|
598
|
+
to: string,
|
|
599
|
+
opts: { wallet?: string; txHash?: string } = {},
|
|
600
|
+
): Promise<boolean> {
|
|
601
|
+
return withWriteLock(async () => {
|
|
602
|
+
const records = await readRecords();
|
|
603
|
+
const old = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === from);
|
|
604
|
+
if (!old) return false;
|
|
605
|
+
if (old.transferredTo === to) return true; // already applied
|
|
606
|
+
const moved: LicenseRecord = {
|
|
607
|
+
zkAppAddress, licenseHash: to, expirySlot: old.expirySlot, purchaseSlot: 0, purchaseAmount: '0',
|
|
608
|
+
txHash: opts.txHash ?? '', createdAt: new Date().toISOString(), buyerAddress: opts.wallet ?? '',
|
|
609
|
+
transferredFrom: from,
|
|
610
|
+
...(old.deviceCap !== undefined ? { deviceCap: old.deviceCap } : {}),
|
|
611
|
+
...(old.deviceSlotsRoot !== undefined ? { deviceSlotsRoot: old.deviceSlotsRoot } : {}),
|
|
612
|
+
...(old.deviceSlotsFilled !== undefined ? { deviceSlotsFilled: old.deviceSlotsFilled } : {}),
|
|
613
|
+
...(old.devices ? { devices: old.devices.map(d => ({ ...d })) } : {}),
|
|
614
|
+
...(old.escrowless ? { escrowless: true } : {}),
|
|
615
|
+
...(old.tier ? { tier: old.tier } : {}),
|
|
616
|
+
};
|
|
617
|
+
const existing = records.findIndex(r => r.zkAppAddress === zkAppAddress && r.licenseHash === to);
|
|
618
|
+
if (existing >= 0) records[existing] = moved; else records.push(moved);
|
|
619
|
+
old.transferredTo = to;
|
|
620
|
+
old.expirySlot = 0;
|
|
621
|
+
delete old.devices;
|
|
622
|
+
delete old.pending;
|
|
623
|
+
delete old.pendingPrior;
|
|
624
|
+
delete old.provedAtSlot;
|
|
625
|
+
await writeRecords(records);
|
|
626
|
+
return true;
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Recompute deviceSlotsRoot + deviceSlotsFilled from the record's mirror after
|
|
631
|
+
// a bind/unbind mutation. `leafFromRecord` uses those two persisted fields when
|
|
632
|
+
// building the on-chain leaf hash; if they drift from the on-chain state the
|
|
633
|
+
// next mutation call fails the in-circuit leaf-hash assert. Mirror the exact
|
|
634
|
+
// packing the circuit uses (packSlotTimes: freedAt in low 32 bits, boundAt in
|
|
635
|
+
// high 32) so the fold matches the on-chain deviceSlotsRoot bit-for-bit.
|
|
636
|
+
function refreshSlotMirror(record: LicenseRecord): void {
|
|
637
|
+
const devices = record.devices ?? [];
|
|
638
|
+
const occupied: Field[] = [];
|
|
639
|
+
const freedAt: Field[] = [];
|
|
640
|
+
for (let i = 0; i < DEVICE_SLOT_CAP_N; i++) {
|
|
641
|
+
const entry = devices.find(d => d.slotIndex === i);
|
|
642
|
+
const occ = entry?.deviceCommitment ?? '0';
|
|
643
|
+
occupied.push(Field(occ));
|
|
644
|
+
const boundAt = BigInt(entry?.boundAtSlot ?? 0);
|
|
645
|
+
const freed = BigInt(entry?.freedAtSlot ?? 0);
|
|
646
|
+
// packSlotTimes(boundAt, freedAt) in licenseLeaf.ts: freedAt low 32, boundAt high 32.
|
|
647
|
+
const packed = freed + (boundAt << 32n);
|
|
648
|
+
freedAt.push(Field(packed));
|
|
649
|
+
}
|
|
650
|
+
record.deviceSlotsRoot = computeDeviceSlotsRoot(occupied, freedAt).toString();
|
|
651
|
+
record.deviceSlotsFilled = devices.filter(d => d.deviceCommitment !== '0').length;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Apply a deviceBound event atomically: mirrors the on-chain slot mutation
|
|
655
|
+
// into the record.devices[] array and recomputes deviceSlotsRoot / filled so
|
|
656
|
+
// leafFromRecord reconstructs the exact leaf hash the chain committed.
|
|
657
|
+
//
|
|
658
|
+
// `boundAtSlot` MUST be the circuit's proof-time `globalSlotSinceGenesis` (the
|
|
659
|
+
// value emitted in `DeviceBoundEvent.boundAtSlot`). The block slot when the tx
|
|
660
|
+
// landed is NOT interchangeable: TOLERANCE_WINDOW allows them to differ, and
|
|
661
|
+
// any drift flips the packed `freedAt` field → wrong leaf → root divergence.
|
|
662
|
+
export async function applyDeviceBound(
|
|
663
|
+
zkAppAddress: string,
|
|
664
|
+
licenseHash: string,
|
|
665
|
+
fields: { slotIndex: number; deviceCommitment: string; boundAtSlot: number },
|
|
666
|
+
): Promise<boolean> {
|
|
667
|
+
return withWriteLock(async () => {
|
|
668
|
+
const records = await readRecords();
|
|
669
|
+
const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
|
|
670
|
+
if (!record) return false;
|
|
671
|
+
const devices = (record.devices ??= []);
|
|
672
|
+
// Ensure the array is long enough — pad with empty slots.
|
|
673
|
+
while (devices.length <= fields.slotIndex) {
|
|
674
|
+
devices.push({ slotIndex: devices.length, deviceCommitment: '0', boundAtSlot: 0, freedAtSlot: 0 });
|
|
675
|
+
}
|
|
676
|
+
devices[fields.slotIndex] = {
|
|
677
|
+
slotIndex: fields.slotIndex,
|
|
678
|
+
deviceCommitment: fields.deviceCommitment,
|
|
679
|
+
boundAtSlot: fields.boundAtSlot,
|
|
680
|
+
freedAtSlot: 0, // pristine → fresh bind resets freedAt
|
|
681
|
+
};
|
|
682
|
+
refreshSlotMirror(record);
|
|
683
|
+
await writeRecords(records);
|
|
684
|
+
return true;
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Apply a deviceUnbound event atomically. Preserves boundAtSlot for audit,
|
|
689
|
+
// stamps freedAtSlot for the cooldown timer the circuit's next bind enforces.
|
|
690
|
+
//
|
|
691
|
+
// `freedAtSlot` MUST be the circuit's proof-time `globalSlotSinceGenesis` (the
|
|
692
|
+
// value emitted in `DeviceUnboundEvent.freedAtSlot`). Same reason as
|
|
693
|
+
// `applyDeviceBound.boundAtSlot`.
|
|
694
|
+
export async function applyDeviceUnbound(
|
|
695
|
+
zkAppAddress: string,
|
|
696
|
+
licenseHash: string,
|
|
697
|
+
fields: { slotIndex: number; freedAtSlot: number },
|
|
698
|
+
): Promise<boolean> {
|
|
699
|
+
return withWriteLock(async () => {
|
|
700
|
+
const records = await readRecords();
|
|
701
|
+
const record = records.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
|
|
702
|
+
if (!record || !record.devices) return false;
|
|
703
|
+
const slot = record.devices.find(d => d.slotIndex === fields.slotIndex);
|
|
704
|
+
if (!slot) return false;
|
|
705
|
+
slot.deviceCommitment = '0';
|
|
706
|
+
slot.freedAtSlot = fields.freedAtSlot;
|
|
707
|
+
// boundAtSlot stays as the last bind time — historical / audit.
|
|
708
|
+
refreshSlotMirror(record);
|
|
709
|
+
await writeRecords(records);
|
|
710
|
+
return true;
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Undo a pending change whose transaction failed on chain or never landed:
|
|
715
|
+
// restore the record it replaced (a renewal or refund), or remove it (a
|
|
716
|
+
// purchase). With `txHash`, only if the pending change is that transaction's.
|
|
717
|
+
export async function revertPending(zkAppAddress: string, licenseHash: string, txHash?: string): Promise<'restored' | 'removed' | false> {
|
|
718
|
+
return withWriteLock(async () => {
|
|
719
|
+
const records = await readRecords();
|
|
720
|
+
const idx = records.findIndex(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
|
|
721
|
+
if (idx < 0) return false;
|
|
722
|
+
const record = records[idx];
|
|
723
|
+
if (!record.pending || (txHash !== undefined && record.txHash !== txHash)) return false;
|
|
724
|
+
const outcome = record.pendingPrior ? 'restored' : 'removed';
|
|
725
|
+
if (record.pendingPrior) records[idx] = record.pendingPrior;
|
|
726
|
+
else records.splice(idx, 1);
|
|
727
|
+
await writeRecords(records);
|
|
728
|
+
return outcome;
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Delete a record entirely (used to reap stale pending entries the buyer never submitted).
|
|
733
|
+
export async function deleteRecord(zkAppAddress: string, licenseHash: string): Promise<boolean> {
|
|
734
|
+
return withWriteLock(async () => {
|
|
735
|
+
const records = await readRecords();
|
|
736
|
+
const idx = records.findIndex(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHash);
|
|
737
|
+
if (idx < 0) return false;
|
|
738
|
+
records.splice(idx, 1);
|
|
739
|
+
await writeRecords(records);
|
|
740
|
+
return true;
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// List pending records whose prove slot is older than `currentSlot - maxAgeSlots`.
|
|
745
|
+
// Used by the reaper to drop records the buyer never actually signed/submitted.
|
|
746
|
+
export async function getStalePendingRecords(currentSlot: number, maxAgeSlots = PENDING_REAP_SLOTS): Promise<LicenseRecord[]> {
|
|
747
|
+
const records = await readRecords();
|
|
748
|
+
return records.filter(
|
|
749
|
+
(r) => r.pending && typeof r.provedAtSlot === 'number' && currentSlot - r.provedAtSlot > maxAgeSlots,
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Return all records where the refund window has passed and escrow is still live.
|
|
754
|
+
// Filters on the released/refunded flags so we don't re-release or release after refund.
|
|
755
|
+
export async function getReleasableRecords(currentSlot: number): Promise<LicenseRecord[]> {
|
|
756
|
+
const records = await readRecords();
|
|
757
|
+
return records.filter(
|
|
758
|
+
(r) =>
|
|
759
|
+
!r.released &&
|
|
760
|
+
!r.refunded &&
|
|
761
|
+
!r.escrowless &&
|
|
762
|
+
!r.escrowStranded &&
|
|
763
|
+
r.purchaseSlot > 0 &&
|
|
764
|
+
r.zkAppAddress &&
|
|
765
|
+
currentSlot > r.purchaseSlot + REFUND_WINDOW_N,
|
|
766
|
+
);
|
|
767
|
+
}
|