zklicensing 0.1.0-beta.3 → 0.1.0-beta.6
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 +225 -96
- 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 +15 -1
- package/build/archiveBootstrap.d.ts.map +1 -1
- package/build/archiveBootstrap.js +61 -8
- package/build/archiveBootstrap.js.map +1 -1
- package/build/bindClient.d.ts +51 -0
- package/build/bindClient.d.ts.map +1 -0
- package/build/bindClient.js +115 -0
- package/build/bindClient.js.map +1 -0
- package/build/buyClient.d.ts +8 -3
- package/build/buyClient.d.ts.map +1 -1
- package/build/buyClient.js +25 -13
- package/build/buyClient.js.map +1 -1
- package/build/contractInterface.d.ts +11 -5
- package/build/contractInterface.d.ts.map +1 -1
- package/build/contractInterface.js +81 -16
- package/build/contractInterface.js.map +1 -1
- package/build/deployClient.d.ts +1 -0
- package/build/deployClient.d.ts.map +1 -1
- package/build/deployClient.js +1 -0
- package/build/deployClient.js.map +1 -1
- 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/generationClient.d.ts +1 -0
- package/build/generationClient.d.ts.map +1 -1
- package/build/generationClient.js +1 -1
- package/build/generationClient.js.map +1 -1
- package/build/index.d.ts +27 -37
- package/build/index.d.ts.map +1 -1
- package/build/index.js +84 -115
- package/build/index.js.map +1 -1
- 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 +10 -1
- package/build/licenseRecord.d.ts.map +1 -1
- package/build/licenseRecord.js.map +1 -1
- package/build/licenseStore.d.ts +16 -1
- package/build/licenseStore.d.ts.map +1 -1
- package/build/licenseStore.js +163 -5
- package/build/licenseStore.js.map +1 -1
- package/build/manifestVerify.d.ts.map +1 -1
- package/build/manifestVerify.js +7 -2
- package/build/manifestVerify.js.map +1 -1
- package/build/migrateClient.d.ts +1 -0
- package/build/migrateClient.d.ts.map +1 -1
- package/build/migrateClient.js +1 -0
- package/build/migrateClient.js.map +1 -1
- package/build/onChainRoot.d.ts +67 -0
- package/build/onChainRoot.d.ts.map +1 -0
- package/build/onChainRoot.js +87 -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 +122 -0
- package/build/ownershipSignature.d.ts.map +1 -0
- package/build/ownershipSignature.js +244 -0
- package/build/ownershipSignature.js.map +1 -0
- package/build/proveErrors.d.ts +7 -7
- package/build/proveErrors.js +7 -7
- package/build/refundClient.d.ts +2 -11
- package/build/refundClient.d.ts.map +1 -1
- package/build/refundClient.js +34 -16
- package/build/refundClient.js.map +1 -1
- package/build/renewClient.d.ts +3 -1
- package/build/renewClient.d.ts.map +1 -1
- package/build/renewClient.js +2 -1
- package/build/renewClient.js.map +1 -1
- 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/statsClient.d.ts +19 -4
- package/build/statsClient.d.ts.map +1 -1
- package/build/statsClient.js +14 -4
- package/build/statsClient.js.map +1 -1
- package/build/trustAnchorVerify.d.ts +48 -0
- package/build/trustAnchorVerify.d.ts.map +1 -0
- package/build/trustAnchorVerify.js +431 -0
- package/build/trustAnchorVerify.js.map +1 -0
- package/build/trustAnchorVerifyCli.d.ts +35 -0
- package/build/trustAnchorVerifyCli.d.ts.map +1 -0
- package/build/trustAnchorVerifyCli.js +177 -0
- package/build/trustAnchorVerifyCli.js.map +1 -0
- package/build/unbindClient.d.ts +56 -0
- package/build/unbindClient.d.ts.map +1 -0
- package/build/unbindClient.js +113 -0
- package/build/unbindClient.js.map +1 -0
- package/build/vendorAccountClient.d.ts +1 -0
- package/build/vendorAccountClient.d.ts.map +1 -1
- package/build/vendorAccountClient.js +4 -1
- package/build/vendorAccountClient.js.map +1 -1
- package/build/verifyCore.d.ts +1 -1
- package/build/verifyCore.d.ts.map +1 -1
- package/build/verifyCore.js +10 -3
- package/build/verifyCore.js.map +1 -1
- 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.js +581 -454
- package/build/verifyService.js.map +1 -1
- package/build/verifyWithAncestors.d.ts +18 -0
- package/build/verifyWithAncestors.d.ts.map +1 -0
- package/build/verifyWithAncestors.js +119 -0
- package/build/verifyWithAncestors.js.map +1 -0
- package/package.json +40 -11
- package/src/appsLookup.ts +48 -0
- package/src/archiveBootstrap.ts +71 -14
- package/src/bindClient.ts +175 -0
- package/src/buyClient.ts +67 -22
- package/src/contractInterface.d.ts +11 -5
- package/src/contractInterface.ts +105 -34
- package/src/deployClient.ts +7 -0
- package/src/deviceIdentity.ts +249 -0
- package/src/generationClient.ts +2 -1
- package/src/index.ts +128 -191
- package/src/licenseLeaf.ts +221 -0
- package/src/licenseRecord.ts +19 -2
- package/src/licenseStore.ts +186 -6
- package/src/manifestVerify.ts +7 -2
- package/src/migrateClient.ts +2 -0
- package/src/onChainRoot.ts +107 -0
- package/src/ownershipLicenseHash.ts +31 -0
- package/src/ownershipSignature.ts +354 -0
- package/src/proveErrors.ts +7 -7
- package/src/refundClient.ts +27 -13
- package/src/renewClient.ts +7 -2
- package/src/seatName.ts +165 -0
- package/src/statsClient.ts +54 -9
- package/src/trustAnchorVerify.ts +548 -0
- package/src/trustAnchorVerifyCli.ts +179 -0
- package/src/unbindClient.ts +149 -0
- package/src/vendorAccountClient.ts +9 -5
- package/src/verifyCore.ts +11 -3
- package/src/verifyOffline.ts +212 -0
- package/src/verifyService.ts +658 -488
- package/src/verifyWithAncestors.ts +153 -0
- package/build/licenseProof.d.ts +0 -26
- package/build/licenseProof.d.ts.map +0 -1
- package/build/licenseProof.js +0 -41
- package/build/licenseProof.js.map +0 -1
- package/build/ownership.d.ts +0 -34
- package/build/ownership.d.ts.map +0 -1
- package/build/ownership.js +0 -147
- package/build/ownership.js.map +0 -1
- package/build/proveRetry.d.ts +0 -38
- package/build/proveRetry.d.ts.map +0 -1
- package/build/proveRetry.js +0 -89
- package/build/proveRetry.js.map +0 -1
- package/src/licenseProof.d.ts +0 -26
- package/src/licenseProof.ts +0 -48
- package/src/ownership.ts +0 -198
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 proof 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 cross-generation migration: `migrateLicense` copies the ancestor leaf verbatim (including the device slot array) into the successor tree.
|
|
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 proof.json → 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
|
|
|
@@ -246,9 +262,9 @@ The main entry point (`zklicensing`) covers licence verification. Additional ent
|
|
|
246
262
|
|
|
247
263
|
### Per-app in-flight proving guard
|
|
248
264
|
|
|
249
|
-
`/prove/buy`, `/prove/buy-no-escrow`, `/prove/renew`, and `/prove/
|
|
265
|
+
`/prove/buy`, `/prove/buy-no-escrow`, `/prove/renew`, `/prove/migrate`, 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.
|
|
250
266
|
|
|
251
|
-
The keeper defends against this with a **per-app in-flight slot** shared across all
|
|
267
|
+
The keeper defends against this with a **per-app in-flight slot** shared across all five 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`, `proveMigrate`, and `proveRefund`. Callers wait `retryAfterMs`, then retry — no popup, no fee, no signed-tx-doomed-to-fail.
|
|
252
268
|
|
|
253
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.
|
|
254
270
|
|
|
@@ -262,12 +278,13 @@ Wraps the backend's `/prove/deploy` endpoint. The backend generates a throwaway
|
|
|
262
278
|
import { proveDeploy, pollDeployStatus } from 'zklicensing/deploy';
|
|
263
279
|
|
|
264
280
|
const { provenTxJson, zkAppAddress, verificationKeyHash } = await proveDeploy({
|
|
265
|
-
keeperUrl: 'https://zklicensing.com/api/keeper',
|
|
281
|
+
keeperUrl: 'https://dev.zklicensing.com/api/keeper',
|
|
266
282
|
senderPublicKey: 'B62q…', // vendor's fee-payer wallet
|
|
267
283
|
vendorAddress: 'B62q…',
|
|
268
|
-
priceMonthlyMina:
|
|
269
|
-
priceYearlyMina:
|
|
270
|
-
priceFiveYearMina:
|
|
284
|
+
priceMonthlyMina: 1,
|
|
285
|
+
priceYearlyMina: 10,
|
|
286
|
+
priceFiveYearMina: 40,
|
|
287
|
+
pricePerpetualMina: 10000,
|
|
271
288
|
});
|
|
272
289
|
|
|
273
290
|
// Send `provenTxJson` to the wallet for signing, obtain txHash, register the
|
|
@@ -374,6 +391,22 @@ Guards are identical to the admin path: freeze slot configured, refund window cl
|
|
|
374
391
|
|
|
375
392
|
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.
|
|
376
393
|
|
|
394
|
+
**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:
|
|
395
|
+
|
|
396
|
+
```ts
|
|
397
|
+
{
|
|
398
|
+
...signedGenerationList, // same shape as GET /apps/:appId
|
|
399
|
+
app: AppRecord // full record; usable for name / prices / vendor legal
|
|
400
|
+
}
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
Two properties matter for integrators:
|
|
404
|
+
|
|
405
|
+
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.
|
|
406
|
+
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.
|
|
407
|
+
|
|
408
|
+
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.
|
|
409
|
+
|
|
377
410
|
On renew, `renew.ts` fetches this endpoint and, if the licence's `zkAppAddress` is retired, presents a **blocking** "Migrate & Renew" flow (the keeper 409s `/prove/renew` and `/prove/buy*` on retired addresses — buyer-held renewals against a retired tree would silently strand). Migration is a single call — no hop-walking — and copies the current expiry verbatim into the live map; see `zklicensing/migrate` below.
|
|
378
411
|
|
|
379
412
|
**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.
|
|
@@ -384,15 +417,19 @@ Never trusts `current` blindly: renew always continues against the licence's own
|
|
|
384
417
|
|
|
385
418
|
### `zklicensing/buy` — buy flow client
|
|
386
419
|
|
|
387
|
-
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 `
|
|
420
|
+
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).
|
|
388
421
|
|
|
389
422
|
```ts
|
|
390
423
|
import { deriveBuyIdentity, proveBuy, confirmBuy, pollBuyStatus } from 'zklicensing/buy';
|
|
391
424
|
import { ProveQueueBusyError } from 'zklicensing/prove-errors';
|
|
392
425
|
import { notifyBroadcast } from 'zklicensing/tx-broadcast';
|
|
393
426
|
|
|
394
|
-
// 1. Derive the identity from the buyer's passphrase
|
|
395
|
-
|
|
427
|
+
// 1. Derive the identity from the buyer's passphrase and the app's zkApp
|
|
428
|
+
// address (deterministic — the resulting ownership pubkey is hashed into
|
|
429
|
+
// the on-chain licenseHash). Binding to zkAppAddress means the same
|
|
430
|
+
// passphrase yields different keypairs across apps, so a leaked
|
|
431
|
+
// passphrase for one app cannot be replayed against any other.
|
|
432
|
+
const { licenseHash } = await deriveBuyIdentity(passphrase, zkAppAddress);
|
|
396
433
|
|
|
397
434
|
// 2. Ask the backend to compile + prove the buy tx. Retry on shared per-app
|
|
398
435
|
// queue-busy — server drives cadence via `retryAfterMs`, which caps at
|
|
@@ -404,11 +441,12 @@ async function proveBuyWithRetry(): Promise<Awaited<ReturnType<typeof proveBuy>>
|
|
|
404
441
|
for (let n = 1; n <= MAX_ATTEMPTS; n++) {
|
|
405
442
|
try {
|
|
406
443
|
return await proveBuy({
|
|
407
|
-
keeperUrl: 'https://zklicensing.com/api/keeper',
|
|
408
|
-
|
|
444
|
+
keeperUrl: 'https://dev.zklicensing.com/api/keeper',
|
|
445
|
+
licenseHash,
|
|
409
446
|
buyerAddress: 'B62q…',
|
|
410
447
|
zkAppAddress: 'B62q…',
|
|
411
|
-
|
|
448
|
+
network: 'mainnet', // 'devnet' or 'mainnet' — must match your ProofFile
|
|
449
|
+
duration: 0, // 0 = Monthly, 1 = Yearly, 2 = 5 Years, 3 = Perpetual
|
|
412
450
|
buyerCountry: 'FI', // ISO 3166-1 alpha-2, required
|
|
413
451
|
// Required only when a sales freeze is scheduled — see `zklicensing/freeze`
|
|
414
452
|
// below. Ignored (safe to pass anyway) when no freeze is announced.
|
|
@@ -455,11 +493,12 @@ import { ProveQueueBusyError } from 'zklicensing/prove-errors';
|
|
|
455
493
|
import { notifyBroadcast } from 'zklicensing/tx-broadcast';
|
|
456
494
|
|
|
457
495
|
const proveArgs = {
|
|
458
|
-
keeperUrl: 'https://zklicensing.com/api/keeper',
|
|
496
|
+
keeperUrl: 'https://dev.zklicensing.com/api/keeper',
|
|
459
497
|
licenseHash: '…',
|
|
460
498
|
buyerAddress: 'B62q…',
|
|
461
499
|
zkAppAddress: 'B62q…',
|
|
462
|
-
|
|
500
|
+
network: 'mainnet' as 'devnet' | 'mainnet', // must match your ProofFile
|
|
501
|
+
duration: 0 as 0 | 1 | 2 | 3, // 0 = Monthly, 1 = Yearly, 2 = 5 Years, 3 = Perpetual
|
|
463
502
|
waiverAccepted: true, // required — throws before the network call if false
|
|
464
503
|
};
|
|
465
504
|
|
|
@@ -502,7 +541,7 @@ import { ProveQueueBusyError } from 'zklicensing/prove-errors';
|
|
|
502
541
|
|
|
503
542
|
try {
|
|
504
543
|
const { provenTxJson, ancestorGen, expirySlot, zkAppAddress, appKey } = await proveMigrate({
|
|
505
|
-
keeperUrl: 'https://zklicensing.com/api/keeper',
|
|
544
|
+
keeperUrl: 'https://dev.zklicensing.com/api/keeper',
|
|
506
545
|
licenseHash: '…',
|
|
507
546
|
appId: 'app_…',
|
|
508
547
|
buyerAddress: 'B62q…', // fee payer (buyer-signed mode)
|
|
@@ -532,7 +571,7 @@ Wraps `GET /apps/:appId` — every past `zkAppAddress` plus the currently live o
|
|
|
532
571
|
import { fetchGenerationInfo } from 'zklicensing/generation';
|
|
533
572
|
|
|
534
573
|
const info = await fetchGenerationInfo({
|
|
535
|
-
keeperUrl: 'https://zklicensing.com/api/keeper',
|
|
574
|
+
keeperUrl: 'https://dev.zklicensing.com/api/keeper',
|
|
536
575
|
appId: 'app_…',
|
|
537
576
|
pinnedPublicKeysBase64: [/* platform manifest pubkeys, base64 Ed25519 */],
|
|
538
577
|
});
|
|
@@ -547,31 +586,69 @@ Trust model: the keeper is not the trust anchor for this data — it can be re-h
|
|
|
547
586
|
|
|
548
587
|
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.
|
|
549
588
|
|
|
550
|
-
Optional out-of-band corroboration: an operator that publishes `https://<vendor-host>/.well-known/zklicensing/<
|
|
589
|
+
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.
|
|
551
590
|
|
|
552
591
|
#### Publishing the manifest (vendor side)
|
|
553
592
|
|
|
554
593
|
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:
|
|
555
594
|
|
|
556
|
-
1. Open the vendor dashboard, find the app card, and click **Download manifest**. This downloads the exact bytes served by `GET /apps/:appId`
|
|
557
|
-
2. Serve
|
|
558
|
-
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
|
|
595
|
+
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.
|
|
596
|
+
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.
|
|
597
|
+
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.
|
|
598
|
+
|
|
599
|
+
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.
|
|
559
600
|
|
|
560
|
-
|
|
601
|
+
#### `zklicensing-verify-trust` — trust-anchor CLI
|
|
602
|
+
|
|
603
|
+
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.
|
|
604
|
+
|
|
605
|
+
```sh
|
|
606
|
+
zklicensing-verify-trust \
|
|
607
|
+
--origin https://store.example.com \
|
|
608
|
+
--address B62q… \
|
|
609
|
+
--pinned-key O5BBGskSzE+BOxgf3rukMiV2e4t0I1oTwi0ERtvbUfA= \
|
|
610
|
+
--mina-graphql https://api.minascan.io/node/mainnet/v1/graphql \
|
|
611
|
+
--cross-check \
|
|
612
|
+
--json
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
`--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.
|
|
616
|
+
|
|
617
|
+
`--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).
|
|
618
|
+
|
|
619
|
+
> **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.
|
|
620
|
+
|
|
621
|
+
**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.
|
|
561
622
|
|
|
562
623
|
### `zklicensing/refund` — refund flow client
|
|
563
624
|
|
|
564
|
-
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).
|
|
625
|
+
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/migrate, so `proveRefund` throws `ProveQueueBusyError` on concurrent-prove collisions.
|
|
565
626
|
|
|
566
627
|
```ts
|
|
567
628
|
import { proveRefund, confirmRefund } from 'zklicensing/refund';
|
|
629
|
+
import { ProveQueueBusyError } from 'zklicensing/prove-errors';
|
|
568
630
|
|
|
569
|
-
const
|
|
570
|
-
keeperUrl: 'https://zklicensing.com/api/keeper',
|
|
631
|
+
const proveArgs = {
|
|
632
|
+
keeperUrl: 'https://dev.zklicensing.com/api/keeper',
|
|
571
633
|
licenseHash: '…',
|
|
572
634
|
buyerAddress: 'B62q…',
|
|
573
635
|
zkAppAddress: 'B62q…',
|
|
574
|
-
|
|
636
|
+
network: 'mainnet' as 'devnet' | 'mainnet', // must match your ProofFile
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
const MAX_ATTEMPTS = 10; // ~15 min slot-by-slot coverage at Mesa/0.4
|
|
640
|
+
let proved;
|
|
641
|
+
for (let n = 1; n <= MAX_ATTEMPTS; n++) {
|
|
642
|
+
try { proved = await proveRefund(proveArgs); break; }
|
|
643
|
+
catch (err) {
|
|
644
|
+
if (err instanceof ProveQueueBusyError && n < MAX_ATTEMPTS) {
|
|
645
|
+
await new Promise(r => setTimeout(r, err.retryAfterMs));
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
throw err;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
const { provenTxJson } = proved!;
|
|
575
652
|
|
|
576
653
|
// Send `provenTxJson` to the wallet, obtain txHash, then:
|
|
577
654
|
await confirmRefund({ keeperUrl, zkAppAddress, licenseHash, txHash });
|
|
@@ -630,7 +707,7 @@ Purchase revenue attributes to `createdAt` (falling back to slot-derived time fo
|
|
|
630
707
|
|
|
631
708
|
### `zklicensing/prove-errors` — shared typed errors
|
|
632
709
|
|
|
633
|
-
Typed error surface for the per-app in-flight guard shared across `proveBuy`, `proveRenew`, and `
|
|
710
|
+
Typed error surface for the per-app in-flight guard shared across `proveBuy`, `proveRenew`, `proveMigrate`, and `proveRefund`. See [Per-app in-flight proving guard](#per-app-in-flight-proving-guard) for the wire contract.
|
|
634
711
|
|
|
635
712
|
```ts
|
|
636
713
|
import { ProveQueueBusyError } from 'zklicensing/prove-errors';
|
|
@@ -679,29 +756,81 @@ The off-chain record shape written by the backend. Import if you're building too
|
|
|
679
756
|
import type { LicenseRecord, LicenseStatus } from 'zklicensing/record';
|
|
680
757
|
```
|
|
681
758
|
|
|
682
|
-
### `zklicensing/
|
|
759
|
+
### `zklicensing/internal/ownershipSignature` — ownership challenge signer
|
|
760
|
+
|
|
761
|
+
`deriveOwnershipKeypair(passphrase, zkAppAddress)` + `signBinding(privateKey, { licenseHash, deviceCommitment, slotIndex, nonce })` — the Pallas Schnorr signer used by the bind/unbind clients to authorize on-chain slot writes. 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.
|
|
683
762
|
|
|
684
|
-
`
|
|
763
|
+
### `zklicensing/verify-offline` — non-custodial offline verify
|
|
764
|
+
|
|
765
|
+
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?".
|
|
766
|
+
|
|
767
|
+
```ts
|
|
768
|
+
import { verifyOffline } from 'zklicensing/verify-offline';
|
|
769
|
+
|
|
770
|
+
const result = verifyOffline({
|
|
771
|
+
licensesRoot, // see below — trust anchor
|
|
772
|
+
currentSlot, // Mina slot to check expiry against
|
|
773
|
+
licenseHash,
|
|
774
|
+
expirySlot,
|
|
775
|
+
deviceCap,
|
|
776
|
+
devices, // DeviceSlotSnapshot[]
|
|
777
|
+
witness, // { root, isLefts[], siblings[] } — from GET /witness
|
|
778
|
+
devicePubKey, // optional; required when deviceCap > 0
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
if (!result.valid) throw new Error(result.reason ?? 'invalid');
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
### `zklicensing/on-chain-root` — non-custodial trust anchor
|
|
785
|
+
|
|
786
|
+
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.
|
|
787
|
+
|
|
788
|
+
```ts
|
|
789
|
+
import { fetchOnChainLicensesRoot } from 'zklicensing/on-chain-root';
|
|
790
|
+
import { verifyOffline } from 'zklicensing/verify-offline';
|
|
791
|
+
|
|
792
|
+
// 1. Fetch witness + license record from the licensing service.
|
|
793
|
+
const [w, rec] = await Promise.all([
|
|
794
|
+
fetch(`${service}/witness/${zkApp}/${licenseHash}`).then(r => r.json()),
|
|
795
|
+
fetch(`${service}/license/${zkApp}/${licenseHash}?network=mainnet`).then(r => r.json()),
|
|
796
|
+
]);
|
|
797
|
+
|
|
798
|
+
// 2. Fetch the on-chain root INDEPENDENTLY.
|
|
799
|
+
const onChainRoot = await fetchOnChainLicensesRoot({ zkAppAddress: zkApp, network: 'mainnet' });
|
|
800
|
+
|
|
801
|
+
// 3. Cross-check. If they disagree the service is stale/wrong; bail.
|
|
802
|
+
if (w.root !== onChainRoot) throw new Error('service root disagrees with chain');
|
|
803
|
+
|
|
804
|
+
// 4. Fold-verify.
|
|
805
|
+
const res = verifyOffline({
|
|
806
|
+
licensesRoot: onChainRoot,
|
|
807
|
+
currentSlot,
|
|
808
|
+
licenseHash,
|
|
809
|
+
expirySlot: rec.expirySlot,
|
|
810
|
+
deviceCap: rec.deviceCap ?? 0,
|
|
811
|
+
devices: rec.devices ?? [],
|
|
812
|
+
witness: w,
|
|
813
|
+
});
|
|
814
|
+
```
|
|
685
815
|
|
|
686
|
-
|
|
816
|
+
A worked end-to-end example — proof.json paste box, three-way fetch, cross-check, verdict — lives at `packages/sdk/examples/verify.html` alongside the buy / renew / refund / bind / unbind pages.
|
|
687
817
|
|
|
688
|
-
|
|
818
|
+
Two sourcing modes for `licensesRoot`:
|
|
689
819
|
|
|
690
|
-
|
|
820
|
+
- **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.
|
|
821
|
+
- **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.
|
|
691
822
|
|
|
692
|
-
|
|
823
|
+
Default public Mina GraphQL endpoints are wired in (`DEFAULT_MINA_GRAPHQL_URLS.mainnet` / `.devnet`); pass `graphqlUrl` to point at your own node.
|
|
693
824
|
|
|
694
|
-
|
|
695
|
-
- **`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.
|
|
696
|
-
- **`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.
|
|
825
|
+
### Verify service — on-chain state only
|
|
697
826
|
|
|
698
|
-
|
|
827
|
+
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.
|
|
699
828
|
|
|
700
829
|
## Documentation
|
|
701
830
|
|
|
702
|
-
- Integration walkthrough — [zklicensing.com/docs](https://zklicensing.com/docs)
|
|
703
|
-
- FAQ — [zklicensing.com/faq](https://zklicensing.com/faq)
|
|
704
|
-
- Register an app — [zklicensing.com/register](https://zklicensing.com/register)
|
|
831
|
+
- Integration walkthrough — [dev.zklicensing.com/docs.html](https://dev.zklicensing.com/docs.html)
|
|
832
|
+
- FAQ — [dev.zklicensing.com/faq.html](https://dev.zklicensing.com/faq.html)
|
|
833
|
+
- Register an app — [dev.zklicensing.com/register.html](https://dev.zklicensing.com/register.html)
|
|
705
834
|
|
|
706
835
|
## Licence
|
|
707
836
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type LookupableDeployment = {
|
|
2
|
+
zkAppAddress: string;
|
|
3
|
+
deploymentHistory?: Array<{
|
|
4
|
+
zkAppAddress: string;
|
|
5
|
+
}>;
|
|
6
|
+
};
|
|
7
|
+
export type LookupableApp = {
|
|
8
|
+
deployments: {
|
|
9
|
+
devnet?: LookupableDeployment;
|
|
10
|
+
mainnet?: LookupableDeployment;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
export declare function findAppByAnyAddress<T extends LookupableApp>(apps: readonly T[], zkAppAddress: string): T | undefined;
|
|
14
|
+
//# sourceMappingURL=appsLookup.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"appsLookup.d.ts","sourceRoot":"","sources":["../src/appsLookup.ts"],"names":[],"mappings":"AAkBA,MAAM,MAAM,oBAAoB,GAAG;IACjC,YAAY,EAAE,MAAM,CAAC;IACrB,iBAAiB,CAAC,EAAE,KAAK,CAAC;QAAE,YAAY,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACrD,CAAC;AAKF,MAAM,MAAM,aAAa,GAAG;IAC1B,WAAW,EAAE;QACX,MAAM,CAAC,EAAG,oBAAoB,CAAC;QAC/B,OAAO,CAAC,EAAE,oBAAoB,CAAC;KAChC,CAAC;CACH,CAAC;AAMF,wBAAgB,mBAAmB,CAAC,CAAC,SAAS,aAAa,EACzD,IAAI,EAAE,SAAS,CAAC,EAAE,EAClB,YAAY,EAAE,MAAM,GACnB,CAAC,GAAG,SAAS,CAOf"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
3
|
+
// Return the first app whose devnet OR mainnet deployment matches (current
|
|
4
|
+
// live address or any retired generation in deploymentHistory). Under the
|
|
5
|
+
// keeper-routing design a buyer who pinned an original devnet address stays
|
|
6
|
+
// resolvable across N mainnet promotions and any number of retired gens.
|
|
7
|
+
export function findAppByAnyAddress(apps, zkAppAddress) {
|
|
8
|
+
const matches = (d) => !!d && (d.zkAppAddress === zkAppAddress ||
|
|
9
|
+
(d.deploymentHistory ?? []).some(h => h.zkAppAddress === zkAppAddress));
|
|
10
|
+
return apps.find(a => matches(a.deployments.devnet) || matches(a.deployments.mainnet));
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=appsLookup.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"appsLookup.js","sourceRoot":"","sources":["../src/appsLookup.ts"],"names":[],"mappings":"AAAA,yDAAyD;AACzD,oCAAoC;AAgCpC,2EAA2E;AAC3E,0EAA0E;AAC1E,4EAA4E;AAC5E,yEAAyE;AACzE,MAAM,UAAU,mBAAmB,CACjC,IAAkB,EAClB,YAAoB;IAEpB,MAAM,OAAO,GAAG,CAAC,CAAwB,EAAW,EAAE,CACpD,CAAC,CAAC,CAAC,IAAI,CACL,CAAC,CAAC,YAAY,KAAK,YAAY;QAC/B,CAAC,CAAC,CAAC,iBAAiB,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,YAAY,KAAK,YAAY,CAAC,CACvE,CAAC;IACJ,OAAO,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC,WAAW,CAAC,MAAM,CAAC,IAAI,OAAO,CAAC,CAAC,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC,CAAC;AACzF,CAAC"}
|