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
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# zklicensing
|
|
2
2
|
|
|
3
|
-
Client SDK for verifying [zkLicensing](https://zklicensing.com) software licenses.
|
|
3
|
+
Client SDK for verifying [zkLicensing](https://dev.zklicensing.com) software licenses.
|
|
4
4
|
|
|
5
5
|
`verifyLicense` calls the verifier's `GET /verify` endpoint during the on-chain refund window (~14 days), then writes a one-shot anchor to local storage and verifies fully offline from that point on. Works in browsers (via `localStorage`) and in Node (with an injected storage adapter). Zero runtime dependencies.
|
|
6
6
|
|
|
@@ -18,10 +18,8 @@ import { verifyLicense } from 'zklicensing';
|
|
|
18
18
|
// `proof` is the JSON the buyer downloaded after purchase
|
|
19
19
|
const result = await verifyLicense({
|
|
20
20
|
licenseHash: proof.licenseHash,
|
|
21
|
-
zkAppAddress: proof.zkAppAddress,
|
|
21
|
+
zkAppAddress: proof.zkAppAddress, // your deployed zkApp address (B62q…)
|
|
22
22
|
expirySlot: proof.expirySlot,
|
|
23
|
-
purchaseSlot: proof.purchaseSlot,
|
|
24
|
-
verifierUrl: 'https://zklicensing.com/api/verify', // hosted verify service
|
|
25
23
|
});
|
|
26
24
|
|
|
27
25
|
if (result.valid) {
|
|
@@ -41,9 +39,7 @@ purchase ─────► verifier (every call) ─────► anchor writ
|
|
|
41
39
|
|
|
42
40
|
For the first ~14 days the licence can still be refunded on-chain, so the SDK calls the verifier on every check to catch refunds. Once the verifier itself reports the refund window has closed (`currentSlot > purchaseSlot + 13 440 slots`, ≈ 14 days at one slot every 90 s post-Mesa), the SDK writes an anchor into local storage and never touches the network again for that licence.
|
|
43
41
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
The anchor stores `{ anchoredSlot, anchoredAtMs, expirySlot, ownership }`. In the offline branch the current slot is estimated as `anchoredSlot + (now − anchoredAtMs) / 90 000 ms`, and the same 7-day grace window (`6 720 slots`) is applied either side.
|
|
42
|
+
The anchor stores `{ anchoredSlot, anchoredAtMs, expirySlot }`. In the offline branch the current slot is estimated as `anchoredSlot + (now − anchoredAtMs) / 90 000 ms`, and the same 7-day grace window (`6 720 slots`) is applied either side.
|
|
47
43
|
|
|
48
44
|
A renewal advances the proof's `expirySlot`; that mismatch invalidates the anchor and the SDK falls back to one more verifier call before re-anchoring with the new expiry.
|
|
49
45
|
|
|
@@ -58,12 +54,9 @@ type ProofInput = {
|
|
|
58
54
|
licenseHash: string; // Poseidon hash identifying the licence on-chain
|
|
59
55
|
zkAppAddress: string; // Your deployed zkApp's Mina address (B62q…)
|
|
60
56
|
expirySlot: number; // From the proof; invalidates stale anchors on renewal
|
|
61
|
-
purchaseSlot: number; // From the proof; decides when the refund window has closed
|
|
62
|
-
verifierUrl?: string;
|
|
63
57
|
};
|
|
64
58
|
|
|
65
59
|
type VerifyOptions = {
|
|
66
|
-
verifierUrl?: string;
|
|
67
60
|
storage?: Storage | null; // default: globalThis.localStorage; null disables anchoring
|
|
68
61
|
fetcher?: typeof fetch; // default: globalThis.fetch
|
|
69
62
|
now?: () => number; // default: Date.now
|
|
@@ -72,7 +65,10 @@ type VerifyOptions = {
|
|
|
72
65
|
type VerifyResult = {
|
|
73
66
|
valid: boolean;
|
|
74
67
|
expirySlot: number;
|
|
68
|
+
expiresAt: string | null; // ISO-8601; estimated in the offline branch
|
|
69
|
+
currentSlot: number; // from the verifier, or estimated offline
|
|
75
70
|
inGracePeriod: boolean;
|
|
71
|
+
remainingDays: number; // negative through grace and beyond
|
|
76
72
|
reason: string | null;
|
|
77
73
|
source: 'chain' | 'offline';
|
|
78
74
|
};
|
|
@@ -80,6 +76,8 @@ type VerifyResult = {
|
|
|
80
76
|
function verifyLicense(proof: ProofInput, options?: VerifyOptions): Promise<VerifyResult>;
|
|
81
77
|
```
|
|
82
78
|
|
|
79
|
+
The refund-window decision is server-side: the verifier returns `refundWindowClosed: boolean` on its `/verify` response, computed from its own `purchaseSlot` record against the on-chain `currentSlot`. The SDK gates anchor writes on that boolean and never trusts a `purchaseSlot` supplied by the local license file, so a user who forges their proof metadata cannot force the offline branch prematurely.
|
|
80
|
+
|
|
83
81
|
The package also re-exports the three constants the SDK uses internally:
|
|
84
82
|
|
|
85
83
|
```ts
|
|
@@ -87,6 +85,62 @@ import { REFUND_WINDOW_N, GRACE_PERIOD_N, MS_PER_SLOT } from 'zklicensing';
|
|
|
87
85
|
// 13_440, 6_720, 90_000
|
|
88
86
|
```
|
|
89
87
|
|
|
88
|
+
### Non-custodial device binding
|
|
89
|
+
|
|
90
|
+
For apps with a per-license device cap (`deviceCap > 0`), the SDK ships a bind/unbind client that writes device slots directly to the on-chain `LicenseLeaf`. There is no session server: the leaf carries a fixed-width slot array (`DEVICE_SLOT_CAP_N = 5`), the on-chain `bind()` / `unbind()` methods re-verify the buyer's Pallas Schnorr ownership signature IN-CIRCUIT via `Signature.verify()`, and offline verification checks the device's local commitment against the cached leaf.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import {
|
|
94
|
+
proveBind,
|
|
95
|
+
confirmBind,
|
|
96
|
+
proveUnbind,
|
|
97
|
+
confirmUnbind,
|
|
98
|
+
getOrCreateDeviceIdentity,
|
|
99
|
+
deriveOwnershipKeypair,
|
|
100
|
+
} from 'zklicensing';
|
|
101
|
+
|
|
102
|
+
// Fresh device: generate (or load) a Pallas keypair kept in OS secure
|
|
103
|
+
// storage. Only its Poseidon commitment ever crosses the wire.
|
|
104
|
+
const { deviceCommitment } = await getOrCreateDeviceIdentity('myapp/license/device');
|
|
105
|
+
|
|
106
|
+
// Owner key — derived from the buyer's passphrase + the app's zkApp
|
|
107
|
+
// address. Same keypair `licenseHash` was derived from at buy time.
|
|
108
|
+
const { privateKey: ownerPrivateKey } = await deriveOwnershipKeypair(
|
|
109
|
+
passphrase,
|
|
110
|
+
zkAppAddress,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// Bind — the on-chain circuit re-verifies this signature, so a
|
|
114
|
+
// compromised backend cannot forge a bind.
|
|
115
|
+
const { provenTxJson, slotIndex } = await proveBind({
|
|
116
|
+
keeperUrl: 'https://dev.zklicensing.com/api/keeper',
|
|
117
|
+
network: 'mainnet',
|
|
118
|
+
zkAppAddress,
|
|
119
|
+
licenseHash,
|
|
120
|
+
ownerPrivateKey,
|
|
121
|
+
deviceCommitment,
|
|
122
|
+
slotIndex: 0, // 0..deviceCap-1
|
|
123
|
+
buyerAddress: wallet.address,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const txHash = await wallet.sendTransaction(provenTxJson);
|
|
127
|
+
await confirmBind({ keeperUrl, zkAppAddress, licenseHash, txHash });
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**Remote log-off.** `proveUnbind` needs only the ownership key — not physical possession of the target device. Any device with the passphrase-derived key can unbind any slot on the license, including one bound to a lost or stolen device.
|
|
131
|
+
|
|
132
|
+
**Slot behaviour.**
|
|
133
|
+
|
|
134
|
+
- Binding into an empty slot succeeds immediately.
|
|
135
|
+
- Unbinding stamps `freedAt = currentSlot`; the slot cannot be re-bound until `COOLDOWN_SLOTS_N = 6_720` slots (≈ 7 days at 90 s/slot) have elapsed. The cooldown is a flat one-week wait for every tier (monthly, yearly, five-year), independent of `deviceCap` — one number to reason about, not a per-tier formula.
|
|
136
|
+
- `deviceCap = 0` disables binding entirely — `bind()` rejects with "bindings disabled" and no slot state is written to the leaf.
|
|
137
|
+
- Cap is committed to the leaf at buy time and immutable for the life of the license. A vendor cannot tighten enforcement retroactively.
|
|
138
|
+
- Bindings survive a redeploy: the successor starts with its predecessor's licenses tree as it was at retirement (device slots included), checked on chain.
|
|
139
|
+
|
|
140
|
+
**Offline verification.** After bind, the SDK caches the leaf's slot array locally. `verifyOffline` compares the device's local Poseidon commitment against `occupiedSlot[]`; a stolen laptop's commitment vanishing from the slot array denies access on the next launch without any network call.
|
|
141
|
+
|
|
142
|
+
Non-custodial guarantees preserved: the keeper cannot bind or unbind (no signature to sign with), the vendor cannot revoke a bound device (no admin unbind path in the circuit), and the platform cannot revoke either. The only party who can add or remove a device commitment is whoever holds the passphrase-derived ownership private key.
|
|
143
|
+
|
|
90
144
|
## Node storage adapter
|
|
91
145
|
|
|
92
146
|
In the browser the SDK uses `localStorage` automatically. In Node, supply your own — otherwise the offline branch will never activate and every call will hit the verifier:
|
|
@@ -115,7 +169,7 @@ Pass `storage: null` to disable the offline path entirely (every call hits the v
|
|
|
115
169
|
|
|
116
170
|
## Verifier response contract
|
|
117
171
|
|
|
118
|
-
|
|
172
|
+
The verifier returns:
|
|
119
173
|
|
|
120
174
|
```ts
|
|
121
175
|
{
|
|
@@ -134,36 +188,26 @@ For non-JavaScript clients (Android, iOS, Python, …) call the verifier's `GET
|
|
|
134
188
|
|
|
135
189
|
## Platform examples
|
|
136
190
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
- **Unlimited** — the vendor set `maxConcurrentDevices = 0`. Just call `GET /verify`; there is no ownership token.
|
|
140
|
-
- **JTI-capped** — the vendor set `maxConcurrentDevices > 0`. Obtain a session token once via a small backend helper that runs `LicenseProof.compile()`, drives `POST /verify/challenge` + `/respond`, and returns the HMAC-JTI token to cache client-side (o1js can't run on-device). Pass `&token=` on every `GET /verify`. Renew silently against `POST /verify/refresh`; release the seat on logout with `POST /verify/releaseSeat`.
|
|
191
|
+
Device-cap enforcement is fully on-chain via [Non-custodial device binding](#non-custodial-device-binding) — the verifier never mints session tokens, so every platform reduces to the same `GET /verify` call. If the vendor set `deviceCap > 0` at registration, the SDK's bind/unbind clients drive on-chain slot writes; the verifier is stateless and passphrase-free.
|
|
141
192
|
|
|
142
193
|
### Android (Kotlin)
|
|
143
194
|
|
|
144
195
|
```kotlin
|
|
145
|
-
// Unlimited — no token
|
|
146
196
|
val licenseHash = prefs.getString("licenseHash", "") ?: ""
|
|
147
197
|
val zkApp = "B62q…"
|
|
148
|
-
val url = "https://zklicensing.com/api/verify" +
|
|
198
|
+
val url = "https://dev.zklicensing.com/api/verify" +
|
|
149
199
|
"?licenseHash=$licenseHash&zkAppAddress=$zkApp"
|
|
150
200
|
val body = OkHttpClient().newCall(Request.Builder().url(url).build())
|
|
151
201
|
.execute().body?.string() ?: "{}"
|
|
152
202
|
val r = JSONObject(body)
|
|
153
203
|
if (r.getBoolean("valid")) unlockFeatures()
|
|
154
|
-
|
|
155
|
-
// JTI-capped — append &token, refreshed off the backend helper
|
|
156
|
-
val token = prefs.getString("ownershipToken", null)
|
|
157
|
-
val capped = url + (token?.let { "&token=$it" } ?: "")
|
|
158
|
-
// … same fetch + parse; on 429 { activeSessions, deviceLimit } prompt "log out other device"
|
|
159
204
|
```
|
|
160
205
|
|
|
161
206
|
### iOS (Swift)
|
|
162
207
|
|
|
163
208
|
```swift
|
|
164
|
-
// Unlimited — no token
|
|
165
209
|
let zkApp = "B62q…"
|
|
166
|
-
var c = URLComponents(string: "https://zklicensing.com/api/verify")!
|
|
210
|
+
var c = URLComponents(string: "https://dev.zklicensing.com/api/verify")!
|
|
167
211
|
c.queryItems = [
|
|
168
212
|
URLQueryItem(name: "licenseHash", value: licenseHash),
|
|
169
213
|
URLQueryItem(name: "zkAppAddress", value: zkApp),
|
|
@@ -171,41 +215,11 @@ c.queryItems = [
|
|
|
171
215
|
let (data, _) = try await URLSession.shared.data(from: c.url!)
|
|
172
216
|
let r = try JSONDecoder().decode(VerifyResult.self, from: data)
|
|
173
217
|
if r.valid { unlockFeatures() }
|
|
174
|
-
|
|
175
|
-
// JTI-capped — append &token; refresh via POST /verify/refresh
|
|
176
|
-
if let token = UserDefaults.standard.string(forKey: "ownershipToken") {
|
|
177
|
-
c.queryItems! += [URLQueryItem(name: "token", value: token)]
|
|
178
|
-
}
|
|
179
218
|
```
|
|
180
219
|
|
|
181
220
|
### Browser / PWA
|
|
182
221
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
```ts
|
|
186
|
-
import { verifyLicense } from 'zklicensing';
|
|
187
|
-
|
|
188
|
-
// `promptPassphraseOnce` is your helper — check localStorage yourself and
|
|
189
|
-
// skip the modal when an activation already exists. On the first successful
|
|
190
|
-
// /challenge + /respond the SDK persists { token, expiresAt, secretHash }
|
|
191
|
-
// to `options.storage` (defaults to localStorage in browsers), and from
|
|
192
|
-
// then on renews silently using the stored secretHash.
|
|
193
|
-
const rawSecret = await promptPassphraseOnce();
|
|
194
|
-
const [o1js, prove] = await Promise.all([import('o1js'), import('zklicensing/prove')]);
|
|
195
|
-
const secretHash = o1js.Poseidon.hash(o1js.Encoding.stringToFields(rawSecret)).toString();
|
|
196
|
-
await prove.LicenseProof.compile();
|
|
197
|
-
|
|
198
|
-
const r = await verifyLicense(proof, {
|
|
199
|
-
secretHash,
|
|
200
|
-
prover: async ({ licenseHash, nonce, secretHash }) => {
|
|
201
|
-
const pub = new prove.OwnershipChallenge({
|
|
202
|
-
licenseHash: o1js.Field(licenseHash), nonce: o1js.Field(nonce),
|
|
203
|
-
});
|
|
204
|
-
return (await prove.LicenseProof.prove(pub, o1js.Field(secretHash))).proof.toJSON();
|
|
205
|
-
},
|
|
206
|
-
});
|
|
207
|
-
if (r.valid && r.ownership === 'verified') unlockFeatures();
|
|
208
|
-
```
|
|
222
|
+
`verifyLicense` is the top-of-file example — no `signer`, no passphrase round-trip. If your app also needs to bind/unbind devices, see [Non-custodial device binding](#non-custodial-device-binding) below for the `proveBind` / `proveUnbind` flow that uses the buyer's passphrase-derived Pallas Schnorr ownership key to authorize on-chain slot writes.
|
|
209
223
|
|
|
210
224
|
## Quick Start: Building a vendor site
|
|
211
225
|
|
|
@@ -214,6 +228,8 @@ Three drop-in pages, one SDK. Fork them, restyle, ship.
|
|
|
214
228
|
- **`examples/buy.html`** — passphrase → wallet sign → licence file.
|
|
215
229
|
- **`examples/renew.html`** — one click, one wallet prompt.
|
|
216
230
|
- **`examples/refund.html`** — 14-day window, handled.
|
|
231
|
+
- **`examples/bind.html`** / **`examples/unbind.html`** — device-slot writes for `deviceCap > 0` apps.
|
|
232
|
+
- **`examples/verify.html`** — non-custodial verify: paste license file → witness + record from the service, root from Mina, folded locally.
|
|
217
233
|
|
|
218
234
|
Each page is a self-contained HTML + TS pair calling one SDK subpath.
|
|
219
235
|
|
|
@@ -221,7 +237,7 @@ Each page is a self-contained HTML + TS pair calling one SDK subpath.
|
|
|
221
237
|
|
|
222
238
|
```bash
|
|
223
239
|
# 1. Register your app and download apps.json
|
|
224
|
-
open https://zklicensing.com/register.html # click "Download apps.json"
|
|
240
|
+
open https://dev.zklicensing.com/register.html # click "Download apps.json"
|
|
225
241
|
|
|
226
242
|
# 2. Drop apps.json into the examples/ directory
|
|
227
243
|
mv ~/Downloads/apps.json packages/sdk/examples/
|
|
@@ -232,7 +248,7 @@ npm install
|
|
|
232
248
|
npm run dev
|
|
233
249
|
```
|
|
234
250
|
|
|
235
|
-
Vite serves `index.html`, `buy.html`, `renew.html`, and `
|
|
251
|
+
Vite serves `index.html`, `buy.html`, `renew.html`, `refund.html`, `bind.html`, `unbind.html`, and `verify.html` at
|
|
236
252
|
`http://localhost:5173`. The pages `fetch('/apps.json')` for their config —
|
|
237
253
|
no `.env` file needed. Ship them as static assets with `npm run build`.
|
|
238
254
|
|
|
@@ -244,6 +260,16 @@ via `VITE_WALLETCONNECT_PROJECT_ID` to enable the mobile QR fallback.
|
|
|
244
260
|
|
|
245
261
|
The main entry point (`zklicensing`) covers licence verification. Additional entry points are shipped alongside for integrators building a buy page, a vendor dashboard, or tax-report tooling — all browser-safe, no `fs`.
|
|
246
262
|
|
|
263
|
+
### Per-app in-flight proving guard
|
|
264
|
+
|
|
265
|
+
`/prove/buy`, `/prove/buy-no-escrow`, `/prove/renew`, and `/prove/refund` all mutate the same on-chain `licensesRoot` (and, for buy/refund, `escrowRoot`) preconditions. Two proofs assembled against the same pre-state root within one landing window are mutually exclusive — the chain admits whichever lands first, the second's precondition fails and the tx is dropped. Left unguarded, the second buyer pays the wallet-popup + fee for a doomed tx.
|
|
266
|
+
|
|
267
|
+
The keeper defends against this with a **per-app in-flight slot** shared across all four endpoints. While the slot is held, a second concurrent request for the same app receives an immediate `409 { error: "proving queue busy", retryAfterMs, queueDepth }` — before any circuit work or wallet prompt. The SDK surfaces this as a shared `ProveQueueBusyError` (from `zklicensing/prove-errors`) thrown by `proveBuy`, `proveRenew`, and `proveRefund`. Callers wait `retryAfterMs`, then retry — no popup, no fee, no signed-tx-doomed-to-fail.
|
|
268
|
+
|
|
269
|
+
Each prove response carries an `appKey` field — the same value the keeper uses to key the slot. After the wallet returns a tx hash, the SDK client calls `notifyBroadcast({ keeperUrl, appId: appKey, txHash })` (from `zklicensing/tx-broadcast`, fire-and-forget) to extend the slot's TTL from signing-TTL to landing-TTL, giving the honest broadcast room to land before another prove goes through.
|
|
270
|
+
|
|
271
|
+
See [`zklicensing/prove-errors`](#zklicensingprove-errors--shared-typed-errors) and [`zklicensing/tx-broadcast`](#zklicensingtx-broadcast--broadcast-signal) below; each of `zklicensing/buy` and `zklicensing/renew` references this contract.
|
|
272
|
+
|
|
247
273
|
### `zklicensing/deploy` — deploy flow client
|
|
248
274
|
|
|
249
275
|
Wraps the backend's `/prove/deploy` endpoint. The backend generates a throwaway zkApp keypair, bundles `deploy() + initialize()` as a single atomic tx, proves it, signs the zkApp AccountUpdate with the throwaway key, and returns the provenTxJson — the vendor's wallet only adds the fee-payer signature.
|
|
@@ -252,12 +278,13 @@ Wraps the backend's `/prove/deploy` endpoint. The backend generates a throwaway
|
|
|
252
278
|
import { proveDeploy, pollDeployStatus } from 'zklicensing/deploy';
|
|
253
279
|
|
|
254
280
|
const { provenTxJson, zkAppAddress, verificationKeyHash } = await proveDeploy({
|
|
255
|
-
keeperUrl: 'https://zklicensing.com/api/keeper',
|
|
281
|
+
keeperUrl: 'https://dev.zklicensing.com/api/keeper',
|
|
256
282
|
senderPublicKey: 'B62q…', // vendor's fee-payer wallet
|
|
257
283
|
vendorAddress: 'B62q…',
|
|
258
|
-
priceMonthlyMina:
|
|
259
|
-
priceYearlyMina:
|
|
260
|
-
priceFiveYearMina:
|
|
284
|
+
priceMonthlyMina: 1,
|
|
285
|
+
priceYearlyMina: 10,
|
|
286
|
+
priceFiveYearMina: 40,
|
|
287
|
+
pricePerpetualMina: 10000,
|
|
261
288
|
});
|
|
262
289
|
|
|
263
290
|
// Send `provenTxJson` to the wallet for signing, obtain txHash, register the
|
|
@@ -267,37 +294,11 @@ const status = await pollDeployStatus({ keeperUrl, zkAppAddress });
|
|
|
267
294
|
|
|
268
295
|
The SDK does not expose a raw `initialize()` helper. `initialize()` sets `vendor` on-chain and is guarded only by a `vendor.requireEquals(PublicKey.empty())` precondition — no in-circuit signature check (adding one collides with the proof-based `editState` permission that `deploy()` installs). Front-running is prevented operationally instead: the backend puts `deploy()` and `initialize()` in the same `Mina.transaction`, and the throwaway zkApp keypair is generated inside the handler and dropped at return, so the target address is not observable to an attacker until the initialize AU is already signed and locked to `(vendor, prices)`. Drivers bypassing `proveDeploy` are responsible for the same discipline.
|
|
269
296
|
|
|
270
|
-
####
|
|
271
|
-
|
|
272
|
-
After a Mina hardfork relaxes `impossibleDuringCurrentVersion` back to `signature()`, every existing zkApp becomes uneditable in place (deploy keys are already discarded). The platform migrates each app to a fresh v2 address via admin endpoints, guarded by `KEEPER_ADMIN_TOKEN`:
|
|
273
|
-
|
|
274
|
-
```ts
|
|
275
|
-
import { redeployApp, redeployAll } from 'zklicensing/deploy';
|
|
276
|
-
|
|
277
|
-
// Migrate one app.
|
|
278
|
-
const { zkAppAddress, txHash, verificationKeyHash, onChainVersion } = await redeployApp({
|
|
279
|
-
keeperUrl: 'https://zklicensing.example.com',
|
|
280
|
-
appId: 'acme-editor',
|
|
281
|
-
adminToken: process.env.KEEPER_ADMIN_TOKEN!,
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
// Batch — top-level `ok` is always true unless the whole request errored;
|
|
285
|
-
// per-app failures surface as { ok: false, error } inside `results`.
|
|
286
|
-
const batch = await redeployAll({ keeperUrl, adminToken });
|
|
287
|
-
for (const r of batch.results) {
|
|
288
|
-
if (!r.ok) console.warn(`${r.appId}: ${r.error}`);
|
|
289
|
-
}
|
|
290
|
-
```
|
|
291
|
-
|
|
292
|
-
The backend reads authoritative `vendor` + prices from the old v1 contract (no vendor RPC), mints a fresh throwaway zkApp keypair, and broadcasts a merged-sign atomic redeploy. Guards (HTTP 409): sales frozen, refund window closed, `escrowRoot === EMPTY_ROOT`, on-chain version still in `SUPPORTED_LICENSING_APP_VERSIONS`. Both endpoints are idempotent — already-migrated apps surface as `{ ok: false, error: "already outside supported range" }`, so reruns after a partial batch are safe.
|
|
293
|
-
|
|
294
|
-
Pass `force: true` on either helper to bypass the `escrowRoot === EMPTY_ROOT` guard when the platform accepts responsibility for the unrefunded balance. Other guards remain strict. Successful responses that used the override return `forcedEscrowNonEmpty: true` alongside the usual fields; unrefunded buyers keep their independent refund path against the retired v1 address (its on-chain state persists), so pointing them at `deploymentHistory[]` addresses out-of-band remains the mitigation.
|
|
295
|
-
|
|
296
|
-
Confirmation polling is not baked in — reuse `pollDeployStatus({ keeperUrl, zkAppAddress })` against the returned address.
|
|
297
|
+
#### Redeploy (hard fork)
|
|
297
298
|
|
|
298
|
-
|
|
299
|
+
A redeploy moves an app to a fresh zkApp address in two on-chain steps. **Retire**: the old app, with an empty escrow, freezes its licenses tree; the vendor's key (or the platform key, on a platform redeploy) signs it. **Successor**: the new app starts with that frozen tree — its initialization checks on chain that the predecessor is retired at that root with the same vendor, prices and device cap, and its `appCreated` event names the predecessor. Licenses carry on there under the same hashes; nothing for buyers to do. Before a hard fork only the retire happens (the old circuit can't prove after the fork); the successor follows after it. A license that lapsed during the pre-fork sales freeze renews from the fork slot (`effectiveExpirySlot`).
|
|
299
300
|
|
|
300
|
-
|
|
301
|
+
Authentication is a Mina signature against the on-chain vendor of the target app, and the vendor's wallet pays for every step (the retire and the successor), as the admin wallet does on a platform redeploy: the keeper proves each step and holds no key.
|
|
301
302
|
|
|
302
303
|
```ts
|
|
303
304
|
import {
|
|
@@ -307,39 +308,34 @@ import {
|
|
|
307
308
|
pollDeployStatus,
|
|
308
309
|
} from 'zklicensing/deploy';
|
|
309
310
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
senderPublicKey: vendorAddress, // fee payer
|
|
325
|
-
// force: true, // optional — bypass escrow-empty guard
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
// 4. Wallet adds the fee-payer signature and broadcasts (same as proveDeploy).
|
|
329
|
-
const { hash: txHash } = await wallet.sendTransaction({ transaction: provenTxJson });
|
|
330
|
-
|
|
331
|
-
// 5. Register the pending redeploy so the backend arms its deploy-watcher.
|
|
332
|
-
// On landing, the backend promotes the new deployment onto the AppRecord
|
|
333
|
-
// (push old to deploymentHistory[], overwrite live fields, clear sync cursors).
|
|
334
|
-
await registerVendorRedeploy({ keeperUrl, appId, redeployId, txHash });
|
|
311
|
+
let retireSignature: string | undefined;
|
|
312
|
+
for (;;) {
|
|
313
|
+
// 1. A single-use signing challenge (one per prove call), signed by the vendor's wallet.
|
|
314
|
+
const { nonce } = await getVendorRedeployChallenge({ keeperUrl, appId });
|
|
315
|
+
const signature = await wallet.signMessage(nonce); // { field, scalar }
|
|
316
|
+
|
|
317
|
+
// 2. The backend verifies the signature against the on-chain vendor, runs
|
|
318
|
+
// the redeploy guards and answers with the next step.
|
|
319
|
+
const r = await proveVendorRedeploy({ keeperUrl, appId, nonce, signature, senderPublicKey: vendorAddress, retireSignature });
|
|
320
|
+
if ('status' in r && r.status === 'draining') break; // sales paused; come back after r.readyAt
|
|
321
|
+
if ('status' in r && r.status === 'sign-retire') { // the vendor signs the retirement
|
|
322
|
+
retireSignature = await wallet.signFields(r.retireMessage); // base58
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
335
325
|
|
|
336
|
-
//
|
|
337
|
-
await
|
|
326
|
+
// 3. The wallet adds the fee-payer signature, sends and pays for the step.
|
|
327
|
+
const { hash: txHash } = await wallet.sendTransaction({ transaction: r.provenTxJson });
|
|
328
|
+
// 4. Register it: a retire is marked (before a fork; the successor follows
|
|
329
|
+
// after it), a deploy is promoted when it lands.
|
|
330
|
+
await registerVendorRedeploy({ keeperUrl, appId, redeployId: r.redeployId, txHash });
|
|
331
|
+
if (r.step !== 'retire') await pollDeployStatus({ keeperUrl, zkAppAddress: r.zkAppAddress });
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
338
334
|
```
|
|
339
335
|
|
|
340
|
-
Guards
|
|
336
|
+
Guards enforced by the backend before proving: freeze slot configured, refund window closed past `FREEZE_AFTER_SLOT + REFUND_WINDOW`, escrow empty, app status `active`, on-chain version in `SUPPORTED_LICENSING_APP_VERSIONS`. The nonce expires 60 seconds after `getVendorRedeployChallenge` returns; consumed on `proveVendorRedeploy`.
|
|
341
337
|
|
|
342
|
-
|
|
338
|
+
An app retires only with an empty escrow (the circuit requires it): refunds and releases must be done first.
|
|
343
339
|
|
|
344
340
|
> **Buy links auto-update — vendor-embedded links do not.** `proveVendorRedeploy` returns a fresh `zkAppAddress`. The platform marketplace stores your `buyUrl`/`renewUrl`/`refundUrl` as **base URLs** and appends `?app=<zkAppAddress>&network=<net>` at render time, so the "Get" button on the `/apps` listing routes buyers to the new v2 address automatically the moment `finalizeRedeploy` promotes it onto the AppRecord — no vendor action required. Only surfaces where you have hand-embedded the raw `zkAppAddress` yourself (landing pages, docs, emails, screenshots) need manual updating. Existing licenses on the retired v1 address remain independently verifiable.
|
|
345
341
|
|
|
@@ -364,38 +360,84 @@ Guards are identical to the admin path: freeze slot configured, refund window cl
|
|
|
364
360
|
|
|
365
361
|
The buy/renew receipts include a `generation: number` field baked into `ProofFile`, so a licence bought against gen 2 can be told apart from one bought against gen 5 without a second network call.
|
|
366
362
|
|
|
367
|
-
|
|
363
|
+
**Resolving by `zkAppAddress` instead of `appId`.** The keeper also exposes `GET /apps/by-address/:zkAppAddress`, which resolves any registered address — current or historical — to the same signed generation-list body **plus** an `app: AppRecord` field:
|
|
364
|
+
|
|
365
|
+
```ts
|
|
366
|
+
{
|
|
367
|
+
...signedGenerationList, // same shape as GET /apps/:appId
|
|
368
|
+
app: AppRecord // full record; usable for name / prices / vendor legal
|
|
369
|
+
}
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
Two properties matter for integrators:
|
|
373
|
+
|
|
374
|
+
1. It accepts *any* generation's address (live or retired) and always returns the current lineage — so a buyer holding a link with a retired `?app=<addr>` still reaches the correct app.
|
|
375
|
+
2. It does **not** filter by marketplace status. `pending`, `active`, `inactive`, and `rejected` apps all resolve. Contrast with `GET /apps`, which is `status: 'active'`-only for the storefront listing.
|
|
376
|
+
|
|
377
|
+
This is what `buy.html` and `renew.html` on zklicensing.com call — an inactive-listing app can still be bought and renewed against so long as its on-chain LicensingApp is live. Use this endpoint when starting from a `zkAppAddress` (URL param, wallet paste); use `GET /apps/:appId` when you already know the stable app ID and only need the lineage.
|
|
378
|
+
|
|
379
|
+
On renew, `renew.ts` fetches this endpoint and, if the licence's `zkAppAddress` is retired, renews on the current one instead: the successor started with the retired app's licenses, so the licence is there under the same hash (the keeper 409s `/prove/renew` and `/prove/buy*` on retired addresses, and a retired app refuses them on chain).
|
|
368
380
|
|
|
369
381
|
**Signed generation-list, pin-verified.** The response body carries a `publicKey` + `signature` over the canonical `{ appId, current, generations, issuedAt }` payload, signed with the platform manifest Ed25519 key (the same key material that signs the freeze manifest). The SDK verifies against a caller-supplied pin list (`fetchGenerationInfo({ pinnedPublicKeysBase64 })`, see `zklicensing/generation` below) and fails closed — `verified: false` with `current: null` and `generations: []` — when the signature doesn't match. The keeper is not the trust anchor; the pin list is.
|
|
370
382
|
|
|
371
|
-
|
|
383
|
+
Retirement atomicity: the keeper gates a retiring generation from all `/prove/*` handlers **before** draining the tolerance window and retiring it — so any buyer-held `provenTxJson` proven against the retiring tree becomes unlandable inside the drain window; after `retire` the app refuses writes on chain.
|
|
372
384
|
|
|
373
|
-
Never trusts `current` blindly: renew
|
|
385
|
+
Never trusts `current` blindly: renew moves to the current address only when the generation list verifies against the pinned key, so a compromised or misreporting keeper cannot redirect a renew to an address of its choosing.
|
|
374
386
|
|
|
375
387
|
### `zklicensing/buy` — buy flow client
|
|
376
388
|
|
|
377
|
-
Wraps the backend's three-step buy roundtrip so a custom marketplace or CLI can drive it without re-deriving the crypto. Peer dep: `o1js` (for the `
|
|
389
|
+
Wraps the backend's three-step buy roundtrip so a custom marketplace or CLI can drive it without re-deriving the crypto. Peer dep: `o1js` (for the `licenseHash` binding to the buyer's derived ownership pubkey).
|
|
378
390
|
|
|
379
391
|
```ts
|
|
380
392
|
import { deriveBuyIdentity, proveBuy, confirmBuy, pollBuyStatus } from 'zklicensing/buy';
|
|
393
|
+
import { ProveQueueBusyError } from 'zklicensing/prove-errors';
|
|
394
|
+
import { notifyBroadcast } from 'zklicensing/tx-broadcast';
|
|
395
|
+
|
|
396
|
+
// 1. Derive the identity from the buyer's passphrase and the app's zkApp
|
|
397
|
+
// address (deterministic — the resulting ownership pubkey is hashed into
|
|
398
|
+
// the on-chain licenseHash). Binding to zkAppAddress means the same
|
|
399
|
+
// passphrase yields different keypairs across apps, so a leaked
|
|
400
|
+
// passphrase for one app cannot be replayed against any other.
|
|
401
|
+
const { licenseHash } = await deriveBuyIdentity(passphrase, zkAppAddress);
|
|
402
|
+
|
|
403
|
+
// 2. Ask the backend to compile + prove the buy tx. Retry on shared per-app
|
|
404
|
+
// queue-busy — server drives cadence via `retryAfterMs`, which caps at
|
|
405
|
+
// ~one slot (~90s at Mesa) once /tx-broadcast has fired. 10 attempts
|
|
406
|
+
// covers ~15 min of slot-by-slot polling — at 40 % fill that's < 1 %
|
|
407
|
+
// chance of exhausting before a block lands.
|
|
408
|
+
async function proveBuyWithRetry(): Promise<Awaited<ReturnType<typeof proveBuy>>> {
|
|
409
|
+
const MAX_ATTEMPTS = 10;
|
|
410
|
+
for (let n = 1; n <= MAX_ATTEMPTS; n++) {
|
|
411
|
+
try {
|
|
412
|
+
return await proveBuy({
|
|
413
|
+
keeperUrl: 'https://dev.zklicensing.com/api/keeper',
|
|
414
|
+
licenseHash,
|
|
415
|
+
buyerAddress: 'B62q…',
|
|
416
|
+
zkAppAddress: 'B62q…',
|
|
417
|
+
network: 'mainnet', // 'devnet' or 'mainnet' — must match your ProofFile
|
|
418
|
+
duration: 0, // 0 = Monthly, 1 = Yearly, 2 = 5 Years, 3 = Perpetual
|
|
419
|
+
buyerCountry: 'FI', // ISO 3166-1 alpha-2, required
|
|
420
|
+
// Required only when a sales freeze is scheduled — see `zklicensing/freeze`
|
|
421
|
+
// below. Ignored (safe to pass anyway) when no freeze is announced.
|
|
422
|
+
refundWaiverAccepted: true,
|
|
423
|
+
});
|
|
424
|
+
} catch (err) {
|
|
425
|
+
if (err instanceof ProveQueueBusyError && n < MAX_ATTEMPTS) {
|
|
426
|
+
await new Promise(r => setTimeout(r, err.retryAfterMs));
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
throw err;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
throw new Error('unreachable');
|
|
433
|
+
}
|
|
381
434
|
|
|
382
|
-
|
|
383
|
-
const { secretHash, licenseHash } = deriveBuyIdentity(passphrase);
|
|
384
|
-
|
|
385
|
-
// 2. Ask the backend to compile + prove the buy tx.
|
|
386
|
-
const { provenTxJson, expirySlot, purchaseAmount, verificationKeyHash } = await proveBuy({
|
|
387
|
-
keeperUrl: 'https://zklicensing.com/api/keeper',
|
|
388
|
-
secretHash,
|
|
389
|
-
buyerAddress: 'B62q…',
|
|
390
|
-
zkAppAddress: 'B62q…',
|
|
391
|
-
duration: 0, // 0 = Monthly, 1 = Yearly, 2 = 5 Years
|
|
392
|
-
buyerCountry: 'FI', // ISO 3166-1 alpha-2, required
|
|
393
|
-
// Required only when a sales freeze is scheduled — see `zklicensing/freeze`
|
|
394
|
-
// below. Ignored (safe to pass anyway) when no freeze is announced.
|
|
395
|
-
refundWaiverAccepted: true,
|
|
396
|
-
});
|
|
435
|
+
const { provenTxJson, expirySlot, purchaseAmount, verificationKeyHash, appKey } = await proveBuyWithRetry();
|
|
397
436
|
|
|
398
437
|
// 3. Send `provenTxJson` to the buyer's wallet for signing, obtain txHash.
|
|
438
|
+
// …then signal the keeper so the in-flight slot extends from signing-TTL
|
|
439
|
+
// to landing-TTL. Fire-and-forget; a network failure here is not fatal.
|
|
440
|
+
void notifyBroadcast({ keeperUrl, appId: appKey, txHash });
|
|
399
441
|
|
|
400
442
|
// 4. Persist the pending record — the caller drives this only after the wallet returned.
|
|
401
443
|
await confirmBuy({ keeperUrl, zkAppAddress, licenseHash, txHash });
|
|
@@ -404,11 +446,13 @@ await confirmBuy({ keeperUrl, zkAppAddress, licenseHash, txHash });
|
|
|
404
446
|
const status = await pollBuyStatus({ keeperUrl, licenseHash });
|
|
405
447
|
```
|
|
406
448
|
|
|
449
|
+
`status.status` ends as `'landed'`, `'timeout'` (not included in time) or `'failed'`: included but failed on chain, with `failureReason` (usually the wallet could pay the fee but not the price). A failed purchase issues no license, only the network fee is paid, and the keeper drops the pending record and marks its receipt `voided`. `pollRenewStatus` and `pollRefundStatus` report `'failed'` the same way, and the license stays as it was.
|
|
450
|
+
|
|
407
451
|
The SDK never touches a wallet — the caller drives the sign step between `proveBuy` and `confirmBuy` — so this client works for any wallet integration (Auro, WalletConnect, mobile, CLI).
|
|
408
452
|
|
|
409
453
|
`proveBuy` also refuses locally when the backend's sales freeze is in effect (fetched from `GET /health`) — the request would 423 anyway, so surfacing the message before the round-trip saves a wasted call. See `zklicensing/freeze` below.
|
|
410
454
|
|
|
411
|
-
**No-escrow routing.** When the buyer's advertised 14-day refund window would land past `FREEZE_AFTER_SLOT` (checked as `currentSlot + REFUND_WINDOW_N + TOLERANCE_WINDOW_N > FREEZE_AFTER_SLOT`), `proveBuy` transparently switches to `POST /prove/buy-no-escrow` — the backend endpoint for a distinct circuit method (`buyLicenseNoEscrow`) that pays vendor + platform directly at buy time (98 / 2, same as `renewLicense`) and never writes an escrow entry. This is the mechanism that makes the pre-hardfork waiver materially real: with no escrow entry
|
|
455
|
+
**No-escrow routing.** When the buyer's advertised 14-day refund window would land past `FREEZE_AFTER_SLOT` (checked as `currentSlot + REFUND_WINDOW_N + TOLERANCE_WINDOW_N > FREEZE_AFTER_SLOT`), `proveBuy` transparently switches to `POST /prove/buy-no-escrow` — the backend endpoint for a distinct circuit method (`buyLicenseNoEscrow`) that pays vendor + platform directly at buy time (98 / 2, same as `renewLicense`) and never writes an escrow entry. This is the mechanism that makes the pre-hardfork waiver materially real: with no escrow entry nothing blocks the redeploy (an app retires only with an empty escrow), and no refund path exists at all (a refund would fail the `escrowCommitment` check by construction). Passing `refundWaiverAccepted: true` is mandatory on those routed calls; the backend refuses the endpoint without it. When the backend hasn't yet been redeployed with a build that offers `/prove/buy-no-escrow`, the client throws an actionable message rather than silently falling back to an escrow-backed buy that would strand funds — vendors on old backend builds must complete their §12.13 redeploy before the overlap-window sales resume.
|
|
412
456
|
|
|
413
457
|
### `zklicensing/renew` — renew flow client
|
|
414
458
|
|
|
@@ -416,57 +460,39 @@ Same shape as `zklicensing/buy`. `proveRenew` takes an explicit `waiverAccepted`
|
|
|
416
460
|
|
|
417
461
|
```ts
|
|
418
462
|
import { proveRenew, confirmRenew } from 'zklicensing/renew';
|
|
463
|
+
import { ProveQueueBusyError } from 'zklicensing/prove-errors';
|
|
464
|
+
import { notifyBroadcast } from 'zklicensing/tx-broadcast';
|
|
419
465
|
|
|
420
|
-
const
|
|
421
|
-
keeperUrl: 'https://zklicensing.com/api/keeper',
|
|
466
|
+
const proveArgs = {
|
|
467
|
+
keeperUrl: 'https://dev.zklicensing.com/api/keeper',
|
|
422
468
|
licenseHash: '…',
|
|
423
469
|
buyerAddress: 'B62q…',
|
|
424
470
|
zkAppAddress: 'B62q…',
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
// Send `provenTxJson` to the wallet, obtain txHash, then:
|
|
430
|
-
await confirmRenew({ keeperUrl, zkAppAddress, licenseHash, txHash });
|
|
431
|
-
```
|
|
432
|
-
|
|
433
|
-
`proveRenew` also refuses locally when the backend's sales freeze is in effect (fetched from `GET /health`), for the same reason as `proveBuy`.
|
|
434
|
-
|
|
435
|
-
### `zklicensing/migrate` — cross-generation migrate client
|
|
436
|
-
|
|
437
|
-
Wraps `POST /prove/migrate`. The keeper locates the buyer's licence in the freshest ancestor generation where it still exists, assembles the three witnesses (ancestor / predecessor / current-live-empty), and returns a signable transaction that copies the expiry verbatim into the current live map at zero payment.
|
|
438
|
-
|
|
439
|
-
**Full "Migrate & Renew" sequence** (two txs, one wallet sign each):
|
|
440
|
-
|
|
441
|
-
1. `fetchGenerationInfo` (see `zklicensing/generation`) — pin-verified signed lineage. If the licence's `zkAppAddress` is retired, present the buyer with a blocking "Migrate & Renew" step.
|
|
442
|
-
2. `proveMigrate` → wallet signs `provenTxJson` → broadcast → poll landing on the current `zkAppAddress`.
|
|
443
|
-
3. `proveRenew` (see `zklicensing/renew`) against the current `zkAppAddress` → wallet signs → broadcast → poll → rewrite `ProofFile` with the new `{ generation, zkAppAddress, expirySlot }`.
|
|
444
|
-
|
|
445
|
-
The keeper 409s `/prove/renew` and `/prove/buy*` for any retired `zkAppAddress`, so skipping step 2 is not an option.
|
|
446
|
-
|
|
447
|
-
```ts
|
|
448
|
-
import { proveMigrate, ProveMigrateError } from 'zklicensing/migrate';
|
|
471
|
+
network: 'mainnet' as 'devnet' | 'mainnet', // must match your ProofFile
|
|
472
|
+
duration: 0 as 0 | 1 | 2 | 3, // 0 = Monthly, 1 = Yearly, 2 = 5 Years, 3 = Perpetual
|
|
473
|
+
waiverAccepted: true, // required — throws before the network call if false
|
|
474
|
+
};
|
|
449
475
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// "migration in flight" — another caller is proving right now.
|
|
461
|
-
// Wait retryAfterMs before retrying rather than opening a second wallet prompt.
|
|
476
|
+
const MAX_ATTEMPTS = 10; // ~15 min slot-by-slot coverage at Mesa/0.4
|
|
477
|
+
let proved;
|
|
478
|
+
for (let n = 1; n <= MAX_ATTEMPTS; n++) {
|
|
479
|
+
try { proved = await proveRenew(proveArgs); break; }
|
|
480
|
+
catch (err) {
|
|
481
|
+
if (err instanceof ProveQueueBusyError && n < MAX_ATTEMPTS) {
|
|
482
|
+
await new Promise(r => setTimeout(r, err.retryAfterMs));
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
throw err;
|
|
462
486
|
}
|
|
463
|
-
throw e;
|
|
464
487
|
}
|
|
465
|
-
|
|
488
|
+
const { provenTxJson, newExpirySlot, renewAmount, appKey } = proved!;
|
|
466
489
|
|
|
467
|
-
|
|
490
|
+
// Send `provenTxJson` to the wallet, obtain txHash, then:
|
|
491
|
+
void notifyBroadcast({ keeperUrl, appId: appKey, txHash }); // fire-and-forget
|
|
492
|
+
await confirmRenew({ keeperUrl, zkAppAddress, licenseHash, txHash });
|
|
493
|
+
```
|
|
468
494
|
|
|
469
|
-
|
|
495
|
+
`proveRenew` also refuses locally when the backend's sales freeze is in effect (fetched from `GET /health`), for the same reason as `proveBuy`. Concurrent-prove serialization is via the shared per-app in-flight guard — see [Per-app in-flight proving guard](#per-app-in-flight-proving-guard).
|
|
470
496
|
|
|
471
497
|
### `zklicensing/generation` — signed generation-list client
|
|
472
498
|
|
|
@@ -476,7 +502,7 @@ Wraps `GET /apps/:appId` — every past `zkAppAddress` plus the currently live o
|
|
|
476
502
|
import { fetchGenerationInfo } from 'zklicensing/generation';
|
|
477
503
|
|
|
478
504
|
const info = await fetchGenerationInfo({
|
|
479
|
-
keeperUrl: 'https://zklicensing.com/api/keeper',
|
|
505
|
+
keeperUrl: 'https://dev.zklicensing.com/api/keeper',
|
|
480
506
|
appId: 'app_…',
|
|
481
507
|
pinnedPublicKeysBase64: [/* platform manifest pubkeys, base64 Ed25519 */],
|
|
482
508
|
});
|
|
@@ -491,21 +517,75 @@ Trust model: the keeper is not the trust anchor for this data — it can be re-h
|
|
|
491
517
|
|
|
492
518
|
Rotation-friendly: the pin list is a set. Multiple pins can be listed during a key rotation window; verification succeeds on the first pin that matches.
|
|
493
519
|
|
|
494
|
-
Optional out-of-band corroboration: an operator that publishes `https://<vendor-host>/.well-known/zklicensing/<
|
|
520
|
+
Optional out-of-band corroboration: an operator that publishes `https://<vendor-host>/.well-known/zklicensing/<zkAppAddress>.json` under vendor TLS (with the same keeper-signed payload) can be cross-checked from the on-chain `zkappUri`. Per-address filename because a buyer's wallet shows the zkApp address, not the platform appId — keying by address lets any buyer verify from just the address they were prompted to sign, including addresses for retired generations they may still hold licenses under. The cross-check is opt-in per app — absence of the file (404) is not a failure signal, just "no corroboration published for this address." That's the caller's job, not this module's; the platform signature is the trust anchor.
|
|
521
|
+
|
|
522
|
+
#### Publishing the manifest (vendor side)
|
|
523
|
+
|
|
524
|
+
Vendors who want to enable the corroboration path host the same signed payload the keeper returns for their app. The vendor console exposes this as a one-click flow:
|
|
525
|
+
|
|
526
|
+
1. Open the vendor dashboard, find the app card, and click **Download manifest**. This downloads the exact bytes served by `GET /apps/:appId` — one `<zkAppAddress>.json` file per generation address (current live + every retired address), all byte-identical, Ed25519 signature intact.
|
|
527
|
+
2. Serve each downloaded file, unchanged, at `https://<vendor-host>/.well-known/zklicensing/<zkAppAddress>.json` under the vendor's TLS certificate. Any static host works; no dynamic logic needed.
|
|
528
|
+
3. Make sure the app's on-chain `zkappUri` points at `https://<vendor-host>` (the origin — the `.well-known` path is fixed). Callers derive the corroboration URL from that origin plus the address their wallet already showed them.
|
|
529
|
+
|
|
530
|
+
Re-download and re-publish after each retirement or redeploy: the `current` index changes, the `generations[]` list grows by one, and the payload's `issuedAt` timestamp rolls forward — every address file must be replaced with the new signed bytes. Serving a stale copy is not a security failure (the signature still verifies), but callers that compare `current` against the keeper's response will treat the vendor copy as behind.
|
|
531
|
+
|
|
532
|
+
#### `zklicensing-verify-trust` — trust-anchor CLI
|
|
533
|
+
|
|
534
|
+
Ships as a `bin` in this package. Runs the full trust-anchor pipeline for a single zkApp address end-to-end: fetches `.well-known/zklicensing/<zkAppAddress>.json` from the vendor origin, verifies the platform signature against caller-provided pins, asserts the address appears in the signed `generations[]`, then reads the on-chain zkApp and checks that its `zkappUri` binds back to the same origin, that its verification-key hash equals the pinned canonical value for that generation, and that the six-line permission matrix from `LicensingApp.init()` is intact (URI freeze, meta-freeze, VK lock, `send`/`editState` proof-only). Structured findings — no boolean collapse — so vendor CI can pin the exact check that regressed.
|
|
535
|
+
|
|
536
|
+
```sh
|
|
537
|
+
zklicensing-verify-trust \
|
|
538
|
+
--origin https://store.example.com \
|
|
539
|
+
--address B62q… \
|
|
540
|
+
--pinned-key O5BBGskSzE+BOxgf3rukMiV2e4t0I1oTwi0ERtvbUfA= \
|
|
541
|
+
--mina-graphql https://api.minascan.io/node/mainnet/v1/graphql \
|
|
542
|
+
--cross-check \
|
|
543
|
+
--json
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
`--cross-check` adds a `manifest_matches_backend` finding: fetches `https://dev.zklicensing.com/apps/by-address/<address>`, verifies its signature against the same `--pinned-key` values (never trusts the backend response blind), and byte-compares the canonicalized payload against the vendor's `.well-known` copy. Catches a vendor serving a stale or rolled-back manifest even when its signature is still valid. Opt-in — adds one extra network fetch and couples the verifier to zklicensing.com being reachable.
|
|
547
|
+
|
|
548
|
+
`--address` is the zkApp address the buyer's wallet displays — the same address they'd see in a purchase or renewal signature prompt. The platform appId is not an input; the CLI reads it out of the signed payload after the signature verifies. Buyers with only a wallet-shown address (including addresses for retired-generation licenses) can self-verify with nothing else. Exit 0 on all-ok, 1 on any check failure, 2 on bad args. Default output is a human summary; `--json` emits the `TrustAnchorReport` for CI. Add `--skip-chain-checks` for offline manifest-only audits (no o1js peer dep required in that mode).
|
|
549
|
+
|
|
550
|
+
> **Badge policy.** Per Vendor Terms §7.7, the zkLicensing trust-anchor badge (and any equivalent "trust anchor by zkLicensing" mark) may be displayed on vendor pages **only while this CLI returns all-`ok`** against your live `origin` and vendor `--address`, with `--pinned-key` sourced from the attestation table at <https://dev.zklicensing.com/trustanchor.html>. If a chain-side or manifest-side check starts failing, remove the badge until compliance is restored. Wiring this CLI into CI (fail the build on non-zero exit) is the simplest way to keep the two in sync.
|
|
551
|
+
|
|
552
|
+
**Where `--pinned-key` comes from.** Read the current platform manifest pubkey(s) off the published trust-anchor page — <https://dev.zklicensing.com/trustanchor.html> lists them alongside each generation's verification-key hash. The same data is served as JSON for CI: <https://dev.zklicensing.com/trustanchor/manifest-pubkeys.json> (and VK hashes at <https://dev.zklicensing.com/trustanchor/vk-hashes.json>). Copy that base64 value into your build config and pass it as `--pinned-key`. **Do not** rely on the pubkey embedded inside the `.well-known` manifest itself — it is the signer's self-declaration and is not trusted; only the caller-pinned list is. Repeat `--pinned-key <b64>` once per key during a rotation window.
|
|
553
|
+
|
|
554
|
+
**Permit registries.** `--registry <address>` checks a zkPermits registry instead (`verifyRegistryTrust` in `zklicensing/permits/trust`): the on-chain verification key is a published `PermitRegistry` release (`CANONICAL_PERMIT_VK_HASHES`), initialize ran, the registry is bound to the network, and its permissions are locked (verification key, permissions, zkApp URI, delegate and timing impossible; `send` and `editState` proof-only). It also checks who the registry belongs to: the authority's https website is written into its `zkappUri` at deployment (frozen), `--origin` compares that website's host with the one you expect, and the website must serve `/.well-known/zkpermits/<address>.json` naming the registry and network (`wellKnownFile(address, network)` builds it; skip the fetch with `--no-well-known`). With `--keeper-url` it also checks that the keeper's authority keys, payout address and website match the chain.
|
|
555
|
+
|
|
556
|
+
```sh
|
|
557
|
+
zklicensing-verify-trust --registry B62q… --origin https://permits.city.example --mina-network-id mainnet --keeper-url https://zklicensing.com/api/keeper
|
|
558
|
+
```
|
|
495
559
|
|
|
496
560
|
### `zklicensing/refund` — refund flow client
|
|
497
561
|
|
|
498
|
-
Same shape. The backend enforces the 14-day refund window and returns HTTP 409 when it has closed — the error surfaces through the thrown `Error` message, so the SDK doesn't second-guess it locally. Refunds stay open during a sales freeze (that's the whole point of freezing).
|
|
562
|
+
Same shape as buy/renew. The backend enforces the 14-day refund window and returns HTTP 409 when it has closed — the error surfaces through the thrown `Error` message, so the SDK doesn't second-guess it locally. Refunds stay open during a sales freeze (that's the whole point of freezing). Refund shares the per-app in-flight guard with buy/renew, so `proveRefund` throws `ProveQueueBusyError` on concurrent-prove collisions.
|
|
499
563
|
|
|
500
564
|
```ts
|
|
501
565
|
import { proveRefund, confirmRefund } from 'zklicensing/refund';
|
|
566
|
+
import { ProveQueueBusyError } from 'zklicensing/prove-errors';
|
|
502
567
|
|
|
503
|
-
const
|
|
504
|
-
keeperUrl: 'https://zklicensing.com/api/keeper',
|
|
568
|
+
const proveArgs = {
|
|
569
|
+
keeperUrl: 'https://dev.zklicensing.com/api/keeper',
|
|
505
570
|
licenseHash: '…',
|
|
506
571
|
buyerAddress: 'B62q…',
|
|
507
572
|
zkAppAddress: 'B62q…',
|
|
508
|
-
|
|
573
|
+
network: 'mainnet' as 'devnet' | 'mainnet', // must match your ProofFile
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
const MAX_ATTEMPTS = 10; // ~15 min slot-by-slot coverage at Mesa/0.4
|
|
577
|
+
let proved;
|
|
578
|
+
for (let n = 1; n <= MAX_ATTEMPTS; n++) {
|
|
579
|
+
try { proved = await proveRefund(proveArgs); break; }
|
|
580
|
+
catch (err) {
|
|
581
|
+
if (err instanceof ProveQueueBusyError && n < MAX_ATTEMPTS) {
|
|
582
|
+
await new Promise(r => setTimeout(r, err.retryAfterMs));
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
throw err;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const { provenTxJson } = proved!;
|
|
509
589
|
|
|
510
590
|
// Send `provenTxJson` to the wallet, obtain txHash, then:
|
|
511
591
|
await confirmRefund({ keeperUrl, zkAppAddress, licenseHash, txHash });
|
|
@@ -513,7 +593,7 @@ await confirmRefund({ keeperUrl, zkAppAddress, licenseHash, txHash });
|
|
|
513
593
|
|
|
514
594
|
### `zklicensing/freeze` — sales-freeze state
|
|
515
595
|
|
|
516
|
-
Read helpers for the backend's sales-freeze schedule. A platform operator sets `FREEZE_AFTER_SLOT` on the backend ahead of a hardfork
|
|
596
|
+
Read helpers for the backend's sales-freeze schedule. A platform operator sets `FREEZE_AFTER_SLOT` on the backend ahead of a hardfork redeploy; from that slot on the backend refuses new buys and renewals so open escrow can drain. Refunds, releases, and verify stay open throughout.
|
|
517
597
|
|
|
518
598
|
```ts
|
|
519
599
|
import { getKeeperHealth, resolveFreezeState } from 'zklicensing/freeze';
|
|
@@ -549,6 +629,14 @@ const apps = await fetchVendorApps({ keeperUrl, vendorAddress });
|
|
|
549
629
|
|
|
550
630
|
Each helper returns `null` on non-OK response so a degraded backend doesn't tear the dashboard down.
|
|
551
631
|
|
|
632
|
+
### `zklicensing/vat` — VAT rates and VAT-inclusive prices
|
|
633
|
+
|
|
634
|
+
Prices on zkLicensing include VAT. `standardVatRate(country, date)` gives the standard rate in force on a date (EU member states and the UK, with history; pinned from `ibericode/vat-rates`, MIT; regenerate with `node scripts/build-vat-rates.mjs`). `vatIncludedNano(totalNano, rate)` splits a VAT-inclusive amount exactly; `priceWithVat(totalNano, country)` gives what a buyer sees (net + VAT = total); `vatReceipt(...)` is the `vat` block for a license file. Countries outside the table give `null`. For display and vendors' own reporting — an estimate for consumer sales, not tax advice.
|
|
635
|
+
|
|
636
|
+
### `zklicensing/tax-id` — vendor tax IDs
|
|
637
|
+
|
|
638
|
+
`checkTaxId(country, taxId)` checks a vendor's tax ID for their country, offline: EU VAT numbers (format per member state; the keeper also looks them up in VIES), UK VAT, Norwegian MVA, Swiss UID, Australian ABN, New Zealand GST, Canadian BN and Indian GSTIN (format and check digits), US EIN, Japanese and Singapore registration numbers (format). Other countries are accepted for an admin to review. Returns the normalized form, or an error and an `example`; `taxIdExample(country)` gives a form placeholder.
|
|
639
|
+
|
|
552
640
|
### `zklicensing/country-stats` — buyer-country breakdown
|
|
553
641
|
|
|
554
642
|
Pure computation over `LicenseRecord[]`, bucketed by UTC calendar year and structured for tax reporting (one row per `year × appId × countryCode`). Used by both the backend (for `/stats.countryBreakdown`) and any downstream dashboard, so numbers can never drift between them.
|
|
@@ -562,6 +650,49 @@ const breakdown = computeCountryBreakdown(apps, records, currentSlot, nowMs);
|
|
|
562
650
|
|
|
563
651
|
Purchase revenue attributes to `createdAt` (falling back to slot-derived time for legacy records). Renewals attribute to the renewal slot. Refunded purchases contribute zero revenue but still count as licences.
|
|
564
652
|
|
|
653
|
+
### `zklicensing/prove-errors` — shared typed errors
|
|
654
|
+
|
|
655
|
+
Typed error surface for the per-app in-flight guard shared across `proveBuy`, `proveRenew`, and `proveRefund`. See [Per-app in-flight proving guard](#per-app-in-flight-proving-guard) for the wire contract.
|
|
656
|
+
|
|
657
|
+
```ts
|
|
658
|
+
import { ProveQueueBusyError } from 'zklicensing/prove-errors';
|
|
659
|
+
|
|
660
|
+
try {
|
|
661
|
+
const proved = await proveBuy(args);
|
|
662
|
+
} catch (err) {
|
|
663
|
+
if (err instanceof ProveQueueBusyError) {
|
|
664
|
+
// err.status === 409
|
|
665
|
+
// err.retryAfterMs — ms to wait before retrying; server drives the cadence
|
|
666
|
+
// err.queueDepth — always 1 in v1 (forward-compat hook for a future queue)
|
|
667
|
+
await new Promise(r => setTimeout(r, err.retryAfterMs));
|
|
668
|
+
// retry — no wallet popup was opened, no fee was paid
|
|
669
|
+
}
|
|
670
|
+
throw err;
|
|
671
|
+
}
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
Also exports `readErrorBody(resp)` + `tryQueueBusyFromBody(parsed)` — pure helpers used internally by the buy/renew/refund clients to consume the response body exactly once and then classify it. Available for driver integrations that need to inspect a keeper 409 without re-parsing.
|
|
675
|
+
|
|
676
|
+
### `zklicensing/tx-broadcast` — broadcast signal
|
|
677
|
+
|
|
678
|
+
Fire-and-forget `POST /tx-broadcast` from the SDK the moment the buyer's wallet returns a tx hash. Extends the in-flight slot from signing-TTL (~90 s) to landing-TTL (~11 min at Mesa/40 %) so the honest broadcast has room to land before the next prove goes through.
|
|
679
|
+
|
|
680
|
+
```ts
|
|
681
|
+
import { notifyBroadcast } from 'zklicensing/tx-broadcast';
|
|
682
|
+
|
|
683
|
+
// After sendWithWallet returns:
|
|
684
|
+
void notifyBroadcast({
|
|
685
|
+
keeperUrl,
|
|
686
|
+
appId: appKey, // the `appKey` returned by proveBuy / proveRenew
|
|
687
|
+
txHash,
|
|
688
|
+
});
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
Contract:
|
|
692
|
+
- **Fire-and-forget.** Network failures are swallowed (optionally reported via `onError`); a missed extension only means the slot self-clears at signing-TTL, at worst delaying the next prove by seconds — not a correctness dependency.
|
|
693
|
+
- **First-come only.** The keeper accepts one extension per slot; a second call for the same slot returns `409 { ok: false, reason: 'already extended' }`. Combined with the corroboration strike (~11 min later, revert on non-observation) this keeps the endpoint DoS-safe despite being unauthenticated.
|
|
694
|
+
- **`appId` = `appKey`.** Slot keys are computed keeper-side (`slotKeyForApp(zkAppAddress, apps)`) — pass the `appKey` from the prove response verbatim rather than trying to reconstruct it.
|
|
695
|
+
|
|
565
696
|
### `zklicensing/record` — `LicenseRecord` type
|
|
566
697
|
|
|
567
698
|
The off-chain record shape written by the backend. Import if you're building tooling that consumes the raw store (Node) or that consumes it via the analytics endpoints.
|
|
@@ -570,29 +701,114 @@ The off-chain record shape written by the backend. Import if you're building too
|
|
|
570
701
|
import type { LicenseRecord, LicenseStatus } from 'zklicensing/record';
|
|
571
702
|
```
|
|
572
703
|
|
|
573
|
-
### `zklicensing/
|
|
704
|
+
### `zklicensing/internal/ownershipSignature` — ownership challenge signer
|
|
705
|
+
|
|
706
|
+
`deriveOwnershipKeypair(passphrase, zkAppAddress)` + `signBinding(privateKey, { licenseHash, deviceCommitment, slotIndex, nonce, slotState, expirySlot, zkAppAddress })` — the Pallas Schnorr signer used by the bind/unbind clients to authorize on-chain slot writes. `slotState` is the target slot's current packed `(boundAt, freedAt)` (`slotStateOf(devices[slotIndex])` from `GET /license`) and `expirySlot` the license's current expiry (`fetchBindingState(...)` returns both); together they make each signature single-use, so a captured signature cannot be replayed once the slot changes — including after a refund and re-buy with the same passphrase. `proveBind` / `proveUnbind` fill it in automatically. Derivation is bound to the app's zkApp address, so a leaked passphrase for one app cannot bind against any other. Signatures are re-verified IN-CIRCUIT by `Signature.verify()`, so a compromised backend cannot forge a bind or unbind. Runs via `o1js` — the same primitive `licenseHash` was derived from at buy time.
|
|
707
|
+
|
|
708
|
+
### `zklicensing/verify-offline` — non-custodial offline verify
|
|
709
|
+
|
|
710
|
+
Pure fold verifier. Given a `licensesRoot` you have anchored to, a serialized `MerkleMapWitness`, and the `LicenseLeaf` field set, `verifyOffline` reconstructs the leaf hash, walks the witness back to the root, cross-checks against the anchor, and (when `deviceCap > 0`) folds the device slot array and confirms the caller's device commitment appears in an occupied slot. No network, no service round-trip — the trust question reduces entirely to "where did `licensesRoot` come from?".
|
|
711
|
+
|
|
712
|
+
```ts
|
|
713
|
+
import { verifyOffline } from 'zklicensing/verify-offline';
|
|
714
|
+
|
|
715
|
+
const result = verifyOffline({
|
|
716
|
+
licensesRoot, // see below — trust anchor
|
|
717
|
+
currentSlot, // Mina slot to check expiry against
|
|
718
|
+
licenseHash,
|
|
719
|
+
expirySlot,
|
|
720
|
+
deviceCap,
|
|
721
|
+
devices, // DeviceSlotSnapshot[]
|
|
722
|
+
witness, // { root, isLefts[], siblings[] } — from GET /witness
|
|
723
|
+
devicePubKey, // optional; required when deviceCap > 0
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
if (!result.valid) throw new Error(result.reason ?? 'invalid');
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
### `zklicensing/on-chain-root` — non-custodial trust anchor
|
|
730
|
+
|
|
731
|
+
Reads `licensesRoot` (`zkapp.appState[0]`) directly from Mina via `fetchAccount`. Pair it with `verify-offline` to remove the licensing service from the trust boundary — the service supplies the witness and license record, Mina supplies the root, and the two are cross-checked before any leaf is trusted. `o1js` is a peer dep and imported dynamically so this only costs the o1js payload for callers that actually use it.
|
|
732
|
+
|
|
733
|
+
```ts
|
|
734
|
+
import { fetchOnChainLicensesRoot } from 'zklicensing/on-chain-root';
|
|
735
|
+
import { verifyOffline } from 'zklicensing/verify-offline';
|
|
736
|
+
|
|
737
|
+
// 1. Fetch witness + license record from the licensing service.
|
|
738
|
+
const [w, rec] = await Promise.all([
|
|
739
|
+
fetch(`${service}/witness/${zkApp}/${licenseHash}`).then(r => r.json()),
|
|
740
|
+
fetch(`${service}/license/${zkApp}/${licenseHash}?network=mainnet`).then(r => r.json()),
|
|
741
|
+
]);
|
|
742
|
+
|
|
743
|
+
// 2. Fetch the on-chain root INDEPENDENTLY.
|
|
744
|
+
const onChainRoot = await fetchOnChainLicensesRoot({ zkAppAddress: zkApp, network: 'mainnet' });
|
|
745
|
+
|
|
746
|
+
// 3. Cross-check. If they disagree the service is stale/wrong; bail.
|
|
747
|
+
if (w.root !== onChainRoot) throw new Error('service root disagrees with chain');
|
|
748
|
+
|
|
749
|
+
// 4. Fold-verify.
|
|
750
|
+
const res = verifyOffline({
|
|
751
|
+
licensesRoot: onChainRoot,
|
|
752
|
+
currentSlot,
|
|
753
|
+
licenseHash,
|
|
754
|
+
expirySlot: rec.expirySlot,
|
|
755
|
+
deviceCap: rec.deviceCap ?? 0,
|
|
756
|
+
devices: rec.devices ?? [],
|
|
757
|
+
witness: w,
|
|
758
|
+
});
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
A worked end-to-end example — license file paste box, three-way fetch, cross-check, verdict — lives at `packages/sdk/examples/verify.html` alongside the buy / renew / refund / bind / unbind pages.
|
|
762
|
+
|
|
763
|
+
Two sourcing modes for `licensesRoot`:
|
|
764
|
+
|
|
765
|
+
- **Non-custodial (recommended).** Fetch it via `fetchOnChainLicensesRoot`. The service never gets to say "trust me, this leaf is real" — a service serving a stale or forged witness fails the cross-check and the leaf is refused.
|
|
766
|
+
- **Custodial (trusted-service).** Reuse the `root` field the service returned on its `/verify` or `/witness` response. Faster (no o1js + Mina RPC round-trip) but relies on the service to report the true chain state. Fine for callers that already trust the service; not the recommended default.
|
|
767
|
+
|
|
768
|
+
Default public Mina GraphQL endpoints are wired in (`DEFAULT_MINA_GRAPHQL_URLS.mainnet` / `.devnet`); pass `graphqlUrl` to point at your own node.
|
|
769
|
+
|
|
770
|
+
### `zklicensing/verify-trustless` — the same, in one call
|
|
574
771
|
|
|
575
|
-
`
|
|
772
|
+
`verifyLicenseTrustless` does the steps above for you, plus the checks a raw root read leaves out: the contract at the address must run a published LicensingApp release (its verification key is one of `CANONICAL_VK_HASHES`) and be locked (state changes only by proof, no new verification key, no new permissions), and the current slot comes from the node too. The service supplies only the leaf fields and Merkle path; they must hash to the on-chain root, so a stale or lying service can make the check fail but never pass. An absent license is proved the same way. The app's contract generations come from the platform-signed list (checked against the pinned manifest keys), current generation first, so a license file naming a retired contract still verifies (the license carried over to the current one). The chain names the current generation too: a retired contract stores its successor, and each successor must run a published release and have started from its predecessor's frozen root. Without a valid signed list, the given address and its on-chain successors are checked (`generations: 'given-address-only'`).
|
|
773
|
+
|
|
774
|
+
```ts
|
|
775
|
+
import { verifyLicenseTrustless } from 'zklicensing/verify-trustless';
|
|
776
|
+
|
|
777
|
+
const r = await verifyLicenseTrustless({ keeperUrl, network: 'mainnet', zkAppAddress, licenseHash /*, graphqlUrl: your own node */ });
|
|
778
|
+
// { valid, expiresAt, expirySlot, inGracePeriod, remainingDays, reason, trustless: true,
|
|
779
|
+
// resolvedAddress, generation, slot, generations: 'verified' | 'given-address-only' }
|
|
780
|
+
```
|
|
576
781
|
|
|
577
|
-
|
|
782
|
+
A service that is a block or two behind a fresh purchase gets one more read; after that the call throws `STATE_MISMATCH` (retryable). Whether the address is the app you think it is (the seller's website) is `verifyTrustAnchor`. The agent client's `verifyLicense({ …, trustless: true })` and the MCP `verify_license` tool use it.
|
|
578
783
|
|
|
579
|
-
|
|
784
|
+
### Verify service — on-chain state only
|
|
580
785
|
|
|
581
|
-
The
|
|
786
|
+
The verify service is a stateless mirror of on-chain license state. `GET /verify?licenseHash=…&zkAppAddress=…` returns `{ valid, expirySlot, inGracePeriod, reason, currentSlot, purchaseSlot }`. No session tokens, no HMAC-signed bearer state, no challenge/response — the verifier reads the archive and reports. Device-cap enforcement lives entirely on-chain via the [Non-custodial device binding](#non-custodial-device-binding) flow: the buyer's `bind()` / `unbind()` transactions write into the `LicenseLeaf`'s slot array, and offline verification checks the local device commitment against the cached leaf. There is no server-side seat concept the verifier or keeper can revoke; the only party who can add or remove a device commitment is whoever holds the passphrase-derived ownership private key.
|
|
582
787
|
|
|
583
|
-
|
|
788
|
+
### `zklicensing/permits/*` — zkPermits
|
|
584
789
|
|
|
585
|
-
|
|
586
|
-
- **`POST /releaseSeat`** — body `{ token }`. Releases the seat identified by the token's `jti`. Idempotent. Same threat model as any bearer token: possession is authorization. Enables an in-app "Log this device out" button.
|
|
587
|
-
- **`POST /reset-sessions`** — body `{ zkAppAddress, proof }`. Drops **all** sessions for the license proved by the `LicenseProof`. Requires the same passphrase-gated challenge/response as `/respond`, so only the license owner can invoke it. Intended as a self-service recovery for owners who hit the cap after an app reinstall or a lost device didn't `POST /releaseSeat` on the way out. The caller runs a fresh `/respond` afterwards to re-provision under a zero active count.
|
|
790
|
+
Registries of permits run by an authority (multi-key approval), held by a holder's wallet, checkable by anyone. Working pages for each role are in [`examples/`](examples/README.md#permits).
|
|
588
791
|
|
|
589
|
-
|
|
792
|
+
| Subpath | What it gives you |
|
|
793
|
+
|---|---|
|
|
794
|
+
| `zklicensing/permits/client` | `createPermitsClient({ keeperUrl, network, wallet })`: reads (`listRegistries`, `getRegistry`, `getPermit`, `verifyPermit`), the holder flow (`apply`, which pays the fee into escrow, or `apply(…, { pay: false })` then `fileApplication(id, payer?)`; `getApplication`, `withdrawApplication`, `reclaimApplication` after an undecided deadline, `acceptGrant` for a renewal the authority offers, `surrender`) and the authority flow (`acceptAuthorityTerms`, `createRegistry` with `processingFeeMina` and `decisionWindow`, `signRequest`, `deployRegistry`, `approveApplication` / `rejectApplication` (exactly as filed), `requestRenewal` / `requestStatus` / `requestFees` / `requestRotation`, `executeRequest`, and `redeployRegistry` / `cancelRedeploy` / `getDeployment` for moving a registry to the current release: it drains open applications, then the keys sign `retire` (the old registry freezes) and the next generation's deployment, which commits to the frozen permits; `getPermit` and `verifyPermitTrustless` follow the generations). An approval pays the escrowed fee out 98/2; a rejection or withdrawal keeps the registry's processing fee and refunds the rest to the payer; past the decision deadline the payer gets it all back. Writes sign in first; the sign-in text names the site and network and the session works only on that network. `acceptAuthorityTerms` takes `AuthorityDetails`: on mainnet also `registrationNumber` and `billingAddress`, and `vatId` only if the authority is VAT-registered; `authorityFeeStatement(month)` returns the platform's monthly fee statement for the wallet's registries. Also `verifyPermitTrustless` (the keeper's leaf and witness must fold to the root read from a Mina node, dated by the node's slot), `readRegistryHead` (a registry's root and the current slot from a Mina node) and `minaSignerWallet` (a `PermitWallet` from a private key, for scripts and agents). Options `confirmSignature(description)` (called before every field signature with what it does in words and a fingerprint to compare with the wallet; return `false` to cancel, error code `CANCELLED`) and `describeContext(registry)` (activity names and dates for those descriptions). The client rebuilds every message from the request's details first and refuses one that doesn't match (`SIGNATURE_INVALID`). |
|
|
795
|
+
| `zklicensing/receipt` | The purchase receipt (`PurchaseReceipt`): the durable confirmation the keeper issues at `confirmBuy`, `confirmRenew` and permit `acceptGrant` (each returns it as `receipt`), for an approved permit application when the approval is sent, and for the processing fee kept on a rejection or withdrawal (kind `permit-processing`; `withdrawApplication` returns it, otherwise `getReceipt(txHash)` with the holder's sign-in), in the seller's name and numbered in the seller's own series; `confirmRefund` returns a credit note (`creditNote`, kind `license-refund`, negative amounts, `creditFor` naming the receipt it cancels) in the seller's credit-note series. It is a document of its own, not part of the license or permit file: offer it as a separate download, `receiptHtml(receipt or list)` (a self-contained printable page, no scripts) saved as `receiptFileName(…)` (Vendor Terms §7.1). The buyer can fetch them again from `GET /receipts` with their wallet sign-in. |
|
|
796
|
+
| `zklicensing/permits/describe` | Field signatures appear in wallets as bare numbers. `describeRequest(request, ctx)`, `describeAccept`, `describeSurrender` rebuild the message from its details and return `{ title, facts, verified, problem?, fingerprint }` (`fingerprint`: "7 numbers, the last ending …123456", to compare with the wallet's screen); `identifyPermitMessage(fields)` reads the kind, network and registry from any message's numbers alone. |
|
|
797
|
+
| `zklicensing/permits/holder-secret` | The holder secret behind anonymous proofs, without loading the proof: `deriveHolderSecret(wallet, { lineage, network, index })` (one text `signMessage` over `holderSecretText(lineage, network, index)`, which the wallet shows in words; `lineage` is the registry's first address, `registry.lineage` from the keeper or `client.registryLineage(registry)`, the same in every generation so a permit's secret survives redeploys; `index` counts the key's permits in the registry: 0, 1, …; signatures are deterministic, so nothing needs keeping), `findHolderSecret(wallet, { lineage, network, subject })` (recovers a permit's secret from the chain alone: tries 0, 1, … until the subject matches, usually the first), `saltFromHolderSecret`, and `holderSecretMatches(holderKey, subject, secret)`. Only sign that text on pages you trust: anyone holding the signature can recognise that permit's nullifiers (not prove with it). |
|
|
798
|
+
| `zklicensing/permits/rebuild` (also) | `RegistryView.tree` is a `CompactMerkleMap`: a MerkleMap drop-in (same roots and witnesses) that stores only the leaves and the split-children values, so `registryViewToStored` / `registryViewFromStored` save and load a registry without re-hashing (10,000 permits: 4 MB, 0.3 s to load; a rebuild from events re-hashes a 256-level path per permit, about 17 min), and `refreshRegistryView(view, events)` re-hashes only the permits that changed. |
|
|
799
|
+
| `zklicensing/permits/tree` | A compact copy of a registry's permits tree: every permit plus the 2(N − 1) node values where two permits' paths split, so a holder builds its Merkle witness without the keeper learning which permit and without re-hashing the 256-level tree (about 0.1 s per permit). `compactTreeFrom` (keeper side, from the full tree), `witnessFromCompactTree` (checked by folding to the root), `applyTreeDiff` (the permits changed since a root; re-hashes only above them), `syncRegistryTree({ registry, fetchTree, cache })` (cached copy brought up to date, else a full download). Measured: 10,000 permits ≈ 1.5 MB gzipped and 0.4 s on the device; 100,000 ≈ 15 MB and 3.5 s. |
|
|
800
|
+
| `zklicensing/permits/check` | Anonymous checks through the keeper's relay (the site's verify.html Anonymous mode and prove.html use it). `createCheckRelay(keeperUrl)`: `openCheck` (own nonce, nullifiers never repeat), `createEvent` + `openEventCheck` (one nonce per event: a permit's nullifier repeats there, so second use shows), `getCheck`, `submitProof`, `markSeen` / `seenCount` (the event's shared list, with its key), `getTree`. `proveForCheck({ checkId, permit, wallet, origin, treeCache? })` (holder: builds the witness from a compact copy of the whole registry so the keeper can't tell which permit, re-derives the secret, proves, sends) and `verifyCheck({ checkId, origin, trustless? })` (checker). `origin` is the site both sides are on: the challenge is `verifierContext(origin, nonce)`. |
|
|
801
|
+
| `zklicensing/permits/proof` | The holder's anonymous proof: `proveHolder` (runs on the holder's device — it needs the holder's signature over `holderProofMessage(registry, root, context)` and the holder secret, which the holder's wallet re-derives from the chain with `findHolderSecret` (`zklicensing/permits/holder-secret`); the client's `apply` derives it and sends only its hash, the salt) and `verifyHolderProof` (checks the proof, its registry, root, slot, required activities and challenge; returns the nullifier, the same for one holder and one challenge). The nullifier is `Poseidon(tag, holderSecret, context)`: subjects are public and the keeper and authority see the salt, but only the holder's wallet can produce the secret, so nobody else can match a nullifier to a permit. The challenge is bound to the verifier: `context = verifierContext(verifier, nonce)`, from the verifier's identity as the holder reaches it (a URL counts as its origin, `normalizeVerifier`) and a `randomNonce()`; verifiers pass `{ verifier, nonce }` to `verifyHolderProof`. Two verifiers that agree on one nonce still get different challenges, so they can't link a holder by comparing nullifiers. |
|
|
802
|
+
| `zklicensing/permits/trust` | `verifyRegistryTrust`: is an address a genuine, unmodifiable registry (a published verification key from `CANONICAL_PERMIT_VK_HASHES`, initialized, bound to the network, permissions locked), and does it belong to the website it names (`zkappUri`, options `website` and `checkWellKnown`; `wellKnownPath` / `wellKnownFile` for the authority's claim file)? Also the `--registry` mode of `zklicensing-verify-trust`. |
|
|
803
|
+
| `zklicensing/permits/messages` | The permit leaf, its hash and subject, packed fees and policy, the escrowed `Application` (its `filing` number is the registry's `filingCount` when filed, so every filing hashes differently and a decision or withdrawal signature settles only that filing; `applicationTermsHash`, which the holder signs, leaves out the payer, deadline and filing number) and the exact field messages the authority keys and the holder sign (`toSignableFields` turns one into wallet `signFields` input). |
|
|
804
|
+
| `zklicensing/permits/rebuild` | `rebuildRegistry` / `applyRegistryEvents`: a registry's permits and root from its on-chain events alone, without the keeper; `permitStatusAt` for a permit's status at a slot. |
|
|
805
|
+
| `zklicensing/permits/interface` | Event shapes, the on-chain state layout, `CANONICAL_PERMIT_VK_HASHES` and `CANONICAL_PERMIT_PROOF_VK_HASH`. |
|
|
590
806
|
|
|
591
807
|
## Documentation
|
|
592
808
|
|
|
593
|
-
- Integration walkthrough — [zklicensing.com/docs](https://zklicensing.com/docs)
|
|
594
|
-
- FAQ — [zklicensing.com/faq](https://zklicensing.com/faq)
|
|
595
|
-
- Register an app — [zklicensing.com/register](https://zklicensing.com/register)
|
|
809
|
+
- Integration walkthrough — [dev.zklicensing.com/docs.html](https://dev.zklicensing.com/docs.html)
|
|
810
|
+
- FAQ — [dev.zklicensing.com/faq.html](https://dev.zklicensing.com/faq.html)
|
|
811
|
+
- Register an app — [dev.zklicensing.com/register.html](https://dev.zklicensing.com/register.html)
|
|
596
812
|
|
|
597
813
|
## Licence
|
|
598
814
|
|