Documentation
zkLicensing Developer Guide
zkLicensing lets you add privacy-preserving license verification to any software using zero-knowledge proofs on Mina Protocol. No per-app license backend, no email addresses, no off-chain user accounts.
Quick Start
Get a working license gate wired into your app in an afternoon*. The verifier-side call is a one-liner (verifyLicense()); the time budget goes into hosting your own copies of the buy / renew / refund pages, wiring the licensed/unlicensed toggle into your UI, and persisting the buyer's proof.json.
- Register your app on zklicensing.com/register — connect your Mina wallet, set your price, deploy the zkApp. Takes ~5 minutes.
- Copy your verification key from the post-deploy screen.
- Fork the example flow pages — buy.html, renew.html, refund.html — into your own site. In production these must be hosted under your origin (or an origin you control), not from
zklicensing.com. Swap the constants (appId,zkAppAddress, verifier URL, pinned pubkeys) and serve the pages underCross-Origin-Opener-Policy: same-origin+Cross-Origin-Embedder-Policy: require-corpso the in-browser prover's SharedArrayBuffer can run. - Install the SDK in your project.
- Call
verifyLicense()at app startup with the buyer'sproof.json.
* The buy / renew / refund pages hosted on zklicensing.com are working examples, not a production endpoint — they exist so you can read the source, run them locally against devnet, and see the exact wallet, prover, and callback wiring your fork needs. Real vendors host their own copies (rebranded, on their own origin, with their own constants) and drive buyers there. Once you have a Mina wallet installed, an app registered, and your verification key in hand, the mechanical work — forking three HTML files, editing the constants, deploying under COOP+COEP, and adding one verifyLicense() call — is comfortably an afternoon for a competent web dev on their first Mina integration. The SDK call itself is one line; the surrounding host-your-own-flow work isn't.
Install zklicensing from npm and call verifyLicense() at app startup — see the SDK README on npm for install, imports, and a minimal usage snippet.
The SDK calls the verifier's /verify endpoint for the first ~14 days after purchase (the on-chain refund window). Once the verifier confirms the window has closed, the SDK writes a one-shot anchor into the available storage (localStorage in browsers, an injected storage adapter in Node) and from that point on verifies fully offline against the user's local clock. result.source tells you which path ran ('chain' vs 'offline'). A renewal changes the license's expiry, which invalidates the anchor and forces one more chain check.
How It Works
The system has three layers:
- zkApp (Mina smart contract) — holds the authoritative license state on-chain. Exposes methods
buyLicense(),renewLicense(),requestRefund(), andreleaseFunds(). - Proof JSON — a compact JSON file the buyer downloads. Contains a zk proof attesting "a valid license exists for this app and tier" without revealing the buyer's wallet address.
- Verification key — a public key you embed in your app. Used to verify the proof offline in milliseconds.
verifyLicense call goes to the verifier.Mobile Apps (Android / iOS)
The recommended pattern is buy in the browser, activate in the app with the passphrase. The buyer purchases on your web flow (Auro on mobile connects via WalletConnect so the whole thing works on a phone) and remembers the passphrase they chose at purchase. On first launch of the native app, they enter that passphrase into the app's own UI — no proof.json file transfer, no import step.
The native app derives licenseHash locally from the entered passphrase (Poseidon.hash([pk.x, pk.isOdd]) of the passphrase-derived Pallas pubkey) and calls GET /verify?licenseHash=…&zkAppAddress=… to confirm the license exists and is unexpired on-chain. The SDK's anchor-then-offline pattern handles the rest: during the 14-day refund window every launch hits the verifier; after the window closes the SDK anchors locally and runs fully offline.
Key Concepts
ZK Proof
A zero-knowledge proof is a cryptographic object that proves a statement is true without revealing the witness that makes it true. In our case: "a valid purchase transaction exists on Mina" is proven, and the proof file itself reveals no wallet address. Note that the underlying purchase transaction is visible on the Mina chain — pseudonymity comes from your wallet address not being tied to your real identity, not from the proof hiding the chain record.
Verification Key
The verification key is derived from your zkApp's circuit. It's a ~500-byte string you hard-code into your application binary. It's safe to distribute publicly — it can only verify proofs, not create them.
zkApp Address
The on-chain address of your deployed licensing contract. Optionally used for chain-based verification as a fallback when the buyer has lost their proof file.
Passphrase & Secret Hash
When buying, the buyer chooses a passphrase (a random UUID by default). This passphrase is never stored anywhere — its Poseidon hash (licenseHash = Poseidon.hash([secretHash])) is what gets recorded in the on-chain Merkle map. The passphrase is required to verify ownership — to prove the license belongs to you when unlocking the app — so buyers must keep it safe.
Refunds and renewals do not require the passphrase. Both are authorised by a signature from the license's wallet: refund is bound to the original buying wallet in-circuit; renew is paid by whichever wallet signs the transaction, extending expiry for the on-chain licenseHash. Buyers who lose their passphrase can still refund within 14 days and can still renew — they just can't prove ownership at the app.
Grace Period
After a license expires, buyers get a 7-day grace window to renew. During this window verifyLicense() still returns valid: true and sets inGracePeriod: true on the result so your app can show a renewal reminder. Renewal within the grace period extends the expiry from the original expiry date, not from today — so no days are lost. After the grace window closes, the license is fully expired and renewal resets the clock from the current date.
No Vendor Revocation
By design, vendors cannot revoke or modify a license once it has been purchased. The on-chain contract has no method to alter or remove a license entry — only the buyer can clear their own entry by calling requestRefund() within the 14-day refund window. This guarantee is what makes the proof file the buyer holds permanently valid for the licensed period: there is no kill switch a vendor can pull. Disputes must be handled off-chain, and access cannot be cut.
Device binding — cumulative cap with cooldown, no issuer
Vendors can bake a device cap into every license their app mints (registration form: "Device cap (non-custodial, no issuer)", 0–5). When deviceCap = 0 the license is unbound — any device holding the passphrase unlocks it. When deviceCap > 0, each activation appends a small commitment to the on-chain LicenseLeaf: Poseidon.hash([devicePubKey.x, devicePubKey.isOdd]) derived from a per-device signing key the buyer generates locally at first launch (stored in the OS keychain — Keychain on macOS/iOS, DPAPI on Windows, libsecret on Linux, Android Keystore).
The circuit enforces two rules on every bind():
- Cumulative cap. Once
deviceCapdistinct commitments have ever landed for a license, no new commitment can be added — even if the buyer has sinceunbind()'d some. This is not a "concurrent seats" model; it's a lifetime counter over the license's issued term. - Cooldown per slot. When the vendor sets
cap = N, the circuit refuses to rebind a slot beforeCOOLDOWN_SLOTS = ceil(termSlots / cap)have elapsed since it was last freed (≈90 s per slot; the register UI shows the derived cooldown in days per tier next to the input). This prevents cycling a small cap into an effectively-unlimited license by rapidly unbinding and rebinding.
What device binding does not do: it doesn't enforce concurrent use. Two devices bound to the same license can both run the app simultaneously; the circuit only caps how many distinct devices ever activate, and how frequently a slot can turn over.
Cached-root property. Because the LicenseLeaf hash includes the device-slots state, every bind()/unbind() writes a fresh licensesRoot on-chain. The SDK's verifyOffline module (see zklicensing/verify-offline) reconstructs the leaf locally from the cached {devices[], deviceCap, expirySlot, ownerPubKeyHash} snapshot, computes its Poseidon hash, verifies the Merkle witness against the last-seen root, and confirms the current device's commitment appears in the slot array. All of that is pure client-side computation — no HTTP once the snapshot is cached.
Remote log-off via passphrase. The buyer can revoke a lost or stolen device from any browser without contacting the vendor: enter the passphrase on the vendor's unbind page (or the platform fallback), the client produces an ownership signature over the target device commitment, and the circuit clears that slot (subject to the cooldown before it can be reused). No backend account, no support ticket, no vendor involvement — the passphrase is the sole authority.
Vendor tradeoffs. Small caps (1–2) tighten piracy control but frustrate buyers with multiple machines; the cooldown means "I got a new laptop" is a real wait. Larger caps (5) feel generous but weaken the deterrent. Setting cap = 0 disables device binding entirely — the license is fully bearer-style and offline verification skips the device check. There's no "issuer" service to run either way: the circuit is the sole authority.
Registering Your App
Go to register.html and complete the 4-step form:
- Step 1 — Connect wallet. Your Auro wallet becomes the zkApp's admin key. Keep it safe — it's the only way to update pricing or release escrowed funds.
- Step 2 — App info. Name, App ID (used in proof files and the buyer URL), description, category.
- Step 3 — Pricing. Your Mina payment address and license tiers. You can add multiple tiers (Pro, Enterprise).
- Step 4 — Deploy. Signs a Mina transaction that publishes your zkApp contract. The 7-day grace period and 14-day refund window are built into the shared circuit and apply to every deployment — no per-app settings are required.
Deploy phases — what your wallet is being asked to do
Clicking Deploy zkApp on Mina kicks off five steps. Two of them open Auro, and they are not the same kind of action — knowing which is which makes the prompts easier to read.
- Proving deploy — the server compiles your tier's zkApp circuit and generates the zero-knowledge proof that authorises the deployment. No wallet interaction. This is the slow step (~30 seconds) because real Mina proving runs here.
- Confirming transaction — Auro pops up a transaction prompt. You are paying the network fee and the 10 MINA registration fee, and broadcasting the zkApp creation transaction to Mina. This deducts MINA from your wallet and is recorded on-chain. Approving it sends the transaction to the network; it then needs a block to land.
- Signing App ID — Auro pops up a signature prompt (no fee, no on-chain effect). You are signing your App ID string with the same wallet, producing a Poseidon-Schnorr signature over the App ID fields. zkLicensing verifies this signature to prove that the wallet that deployed the contract is the same wallet claiming the marketplace listing. Nothing is broadcast; nothing leaves your wallet but the signature itself.
- Registering app — the signature plus your listing metadata are POSTed to zkLicensing. The signature is verified against your wallet's public key, the metadata validated, and the listing written to the marketplace queue for moderation.
- Deploying zkApp contract — Mina settles the transaction broadcast in step 2 and the zkApp account becomes active. From this point the contract can receive
buyLicense()calls.
If you deploy multiple tiers, this whole sequence runs once per tier — one transaction and one signature per tier.
Listing Requirements
Deploying the zkApp puts your licensing contract on Mina Devnet immediately. The public marketplace listing is moderated separately — we review every submission and every subsequent edit before it goes live. Reviews land within 7 business days in most cases, and up to 15 business days at the latest.
To pass review, your listing must meet the following:
- Real product. The app must be a working piece of software a user can actually run, not a placeholder, demo, or test deployment.
- Honest metadata. Name, description, category, and tier names must match what the software actually does. Tier names cannot imply features the app does not deliver.
- Reachable vendor. A working support contact (URL or email) where licensees can reach you for help or refund issues outside the 14-day automated window.
- Working logo URL. If you provide a logo, it must be a public HTTPS URL that loads — a square image, at least 128×128, under 500 KB.
- Plausible pricing. Pricing must be set in good faith. Listings with obvious wash-pricing (e.g. 0.0001 MINA "premium" tier) will be held.
- Non-malicious. The app must not harm or deceive the people who run it, and must not infringe third-party rights.
- Compliance with the vendor terms. See Legal & Privacy.
Edits to an approved listing (name, description, logo, pricing display, tier names) go back through the same queue. Your live listing keeps showing the previously approved version until the edit is reviewed — so users never see in-flight changes.
.well-known manifest. At approval time the keeper fetches https://<website-host>/.well-known/zklicensing/<appId>.json, verifies the platform signature, and checks that the payload's appId and generation list match the record. This proves the vendor controls the website they list. On mainnet a failed check blocks approval; on devnet the outcome is recorded (it feeds the trust-anchor badge) but does not block, so vendors can iterate without owning DNS.Spotted a listing that doesn't meet these requirements? Buyers can report it: email us.
Buying and renewing by zkApp address
The marketplace listing at zklicensing.com/apps is a discovery layer, not a payment gate. Anyone can buy or renew a license against any deployed LicensingApp contract by pointing the frontend directly at the zkApp address — no marketplace listing required.
To send a buyer straight to a specific contract (from your own hosted copies of the example pages):
https://yourdomain.com/buy.html?app=B62q…<- your zkApp address
https://yourdomain.com/renew.html <- reads app from the buyer's proof.json
The buy.html / renew.html / refund.html pages served from zklicensing.com are working examples — fork them into your own origin for production. The prover endpoints they call (POST /prove/buy, POST /prove/renew) remain platform-hosted.
What happens under the hood:
- The buy / renew pages first look the address up in the marketplace listing (
GET /apps) to fetch the human-readable name and prices. - If the address isn't listed — private beta, inactive listing, or an app that was never registered — the frontend falls back to reading
priceMonthly,priceYearly, andpriceFiveYeardirectly from the contract's on-chain state (appState[4..6]). The quoted price is always what the on-chain contract will charge. - The
POST /prove/buyandPOST /prove/renewendpoints accept any zkApp address that resolves to a valid on-chain LicensingApp — they're prover-and-cache endpoints, never a moderator. Whether a transaction is allowed is decided by the on-chain contract's own logic and the buyer's wallet signature, not by the marketplace.
inactive from the vendor dashboard, or an admin rejecting an active listing via POST /apps/:appId/reject) hides the app from the storefront but does not stop existing customers from buying or renewing against the contract. To halt new licenses at the contract level, the vendor (or the platform, via the platform-side sales-freeze mechanism) must act on-chain.Practical uses:
- Private / unlisted apps. Deploy a LicensingApp, skip the marketplace submission, and hand your customers a
buy.html?app=…URL. The purchase flow works identically. - Pre-review beta. Sell to early users while your marketplace listing is still in moderation — the on-chain zkApp is already live.
- Existing customer renewals continue to work even if a listing is later set inactive or the vendor takes their listing down for maintenance.
SDK Reference
TypeScript SDK published as zklicensing on npm. Works in both browsers and Node. From other languages, call the verifier's GET /verify HTTP endpoint directly and replicate the verifier-then-anchor pattern in idiomatic platform storage.
verifyLicense(proof, options?)
proof: ProofInput — fields read from the buyer's proof file.
| Field | Type | Description |
|---|---|---|
| licenseHash | string | Poseidon hash that identifies the license on-chain |
| zkAppAddress | string | Your deployed zkApp's Mina address (B62q…) |
| expirySlot | number | On-chain expiry slot from the proof. Used to invalidate stale anchors on renewal |
| verifierUrl | string? | Base URL of the verifier — the hosted /api/verify endpoint. May also be passed via options.verifierUrl |
options: VerifyOptions — all optional. Defaults are suitable for browser use.
| Option | Type | Description |
|---|---|---|
| verifierUrl | string? | Override the verifier URL from the proof |
| storage | Storage | null | Where the one-shot anchor is persisted. Defaults to globalThis.localStorage; pass null to disable the offline path; pass a { getItem, setItem } adapter in Node |
| fetcher | typeof fetch | Custom fetch implementation. Defaults to the global fetch |
| now | () => number | Clock source for the offline branch. Defaults to Date.now |
Return value — VerifyResult
| Field | Type | Description |
|---|---|---|
| valid | boolean | Whether the license is currently valid |
| expirySlot | number | On-chain expiry slot |
| expiresAt | string | null | ISO-8601 timestamp of estimated expiry. null when the license is unknown / refunded / unreachable. In the offline branch, estimated from the anchor + local clock |
| currentSlot | number | Estimated current Mina slot. From the verifier in the chain branch; estimated from the anchor + elapsed local time in the offline branch |
| inGracePeriod | boolean | True if expired but within the 7-day grace window |
| remainingDays | number | Whole days until expiry. Negative through the grace period, and further negative once fully expired. 0 when expirySlot is unknown |
| reason | string | null | Human-readable reason when valid is false or grace applies |
| source | string | 'chain' when the result came from the verifier; 'offline' when it came from the local anchor |
Anchor behaviour
- The anchor is keyed by
(zkAppAddress, licenseHash)and stores{ anchoredSlot, anchoredAtMs, expirySlot }. Keying onzkAppAddressis what makes anchors generation-scoped: a post-hardfork redeploy issues a fresh address, so pre-migration anchors are orphaned on next verify and re-created cleanly against the new address. - The anchor is set only when the verifier's response carries
refundWindowClosed: true— a server-authoritative flag the keeper computes from its ownLicenseRecord.purchaseSlotagainst the livecurrentSlot(the boundary iscurrentSlot > purchaseSlot + REFUND_WINDOW_N, whereREFUND_WINDOW_N = 13 440slots ≈ 14 days). The SDK never trusts apurchaseSlotsupplied by the local proof file, so a user winding their local clock forward — or forging the proof metadata — cannot graduate the anchor early. - When the proof's
expirySlotstops matching the anchor's (i.e. the user renewed), the anchor is invalidated and the SDK calls the verifier once more. - In the offline branch the SDK estimates the current slot from
(now − anchoredAtMs) / MS_PER_SLOT, whereMS_PER_SLOT = 90 000 msis Mina's post-Mesa slot time (one block every 90 seconds). The same 7-day grace window applies as in the chain check.
zkApp Circuit
The zkApp is written in o1js (TypeScript framework for Mina zkApps). The circuit enforces three rules:
- The purchase transaction was signed by a valid Mina private key.
- The payment amount equals the tier price at time of purchase.
- The proof's public output —
{ tier, expiresAt }— is correctly derived from the on-chain state.
Full method signatures, on-chain state layout, and the o1js source live in the SDK package — see contractInterface.ts for the public constants and LicensingApp.ts for the circuit itself.
Self-Host the Verifier
The verifier serves on-chain license state: given { licenseHash, zkAppAddress }, it returns validity, expiry, grace-period flag, and purchase slot from the Mina archive. Ownership is proved locally by the SDK — the passphrase never leaves the buyer's device. By default every proof file routes to the platform-hosted verifier at https://zklicensing.com/api/verify. Vendors who want end-to-end control over the on-chain query path can point their app at their own verifier instead.
Why you might want this. The verifier sees every verify request for your app. Running your own means:
- You control uptime — no dependency on platform infra for buyer verifies.
- You get the raw verify log (which license hashes are being queried, when) without asking the platform for it.
- You can enforce your own rate-limits or geofencing policies beyond what the platform verifier does.
What you do not get: control over the on-chain verdict itself. Whether a license is valid, expired, or refunded is a fact of the Mina chain — your verifier reads it and reports it, the same way ours does.
Step 1 — deploy verifyService.ts
The verify service is a standalone Node service in the repo (packages/sdk/src/verifyService.ts). It reads on-chain state via any public Mina RPC and exposes GET /?licenseHash=…&zkAppAddress=…, GET /witness/…, GET /license/…, GET /apps, and POST /bootstrap. Run it behind HTTPS on a hostname you control (e.g. https://verify.yourdomain.com).
Step 2 — register the URL on your app
In your vendor dashboard, edit the app and set Verifier URL to your service's base URL. The keeper stamps this into every proof file it mints for your app going forward — buyers pinning the proof file automatically route their verifyLicense calls to your verifier. Leaving the field blank falls back to the platform default.
What isn't affected
- Deploy, buy, renew, refund, migrate — all still go through the platform keeper. The standalone verifier only reads on-chain state.
- SDK offline gate — past the refund window, the SDK verifies against a locally cached anchor and doesn't call any verifier at all.
- The web verifier UI at
zklicensing.com/verify— readsverifierUrlfrom the uploaded proof file and routes the on-chain state query to your service. Ownership is proved locally by deriving the passphrase-bound keypair and comparing itslicenseHashagainst the proof file — no round-trip. Your URL is shown in the result card so buyers can confirm which verifier answered.
Proof JSON Format
The proof file downloaded by buyers is a JSON document containing the license hash, zkApp address, expiry slot, network, purchase tx hash, tier, verification-key hash, and the o1js proof blob. The exact schema and TypeScript types are documented in the SDK README.
Developer FAQ
Integration-specific questions. For end-user questions (refunds, what a ZK proof is, what happens if Mina is down), see the user FAQ.
How does the escrow accounting work?
Buyer purchases go to the zkApp contract — not the vendor — and sit in escrow for 14 days. After that, funds auto-release to the vendor's payment address (the vendor can also trigger release manually from their dashboard). The 2% platform fee is deducted at release, so the vendor receives 98% of the purchase amount.
Refunds are bound to the buyer's wallet address in-circuit: only a signature from the original purchase wallet can claim a refund — no passphrase entry required. Renewals bypass escrow entirely — funds split 98% / 2% at the transaction itself, with no 14-day hold.
Can I change my license price after listing?
Yes — call the zkApp's setPrice() method from your admin wallet. Changes apply to new purchases only. Existing licenses keep their original terms; the on-chain verification key cannot be rewritten.
What's the 2% platform fee?
zkLicensing deducts 2% of each payment on-chain before sending the remainder to your payment address. This is enforced by the zkApp circuit — it cannot be raised or waived after deployment.
Can I run zkLicensing under my own brand — no zklicensing.com presence?
Yes for the buyer-facing surface. The marketplace listing at zklicensing.com/apps is a discovery layer, not a requirement. Skip the marketplace submission, fork the buy / renew / refund example pages (bundled with the SDK under packages/sdk/examples/) onto your own origin, and buyers reach your app entirely under your domain — no zklicensing.com branding, no marketplace fallback. The on-chain zkApp is identical whether or not it's listed; see Buying and renewing by zkApp address.
What still depends on the platform backend. Deploy, buy, renew, refund, and migrate all round-trip through the platform backend. Self-hosting the backend is not supported today. It's callable from any origin (CORS is open), so a vendor-branded frontend on your own domain calling the platform backend is fine — but the backend itself is a platform dependency.
The one exception is the verifier: GET /verify is platform-hosted by default, but the SDK does support pointing at your own verify service, or skipping it entirely with the embedded-vk path — see the next entry.
Can I run the verifier myself without zkLicensing servers?
Yes — point the SDK at your own copy of the verify service. verifyLicense accepts an optional verifierUrl; run your own verifier (reads on-chain state via any public Mina RPC) and pass its URL. The response contract a self-hosted endpoint must return is documented in the SDK README under "Verifier response contract" (currentSlot, refundWindowClosed, and the standard VerifyResult fields). The SDK's offline anchor logic behaves identically against your endpoint.
Once the on-chain refund window has closed, the SDK writes a local anchor and stops calling the verifier at all — so for the steady-state offline gate, neither your verify service nor the platform's is on the hot path.
How do I verify a license in a native mobile app?
The pattern is buy on the web, unlock in the app. After purchase, the user transfers their proof.json into the native app. From there, hit the verifier's GET /verify endpoint while the 14-day refund window is open, cache a one-shot anchor in SharedPreferences / UserDefaults (mirroring the JS SDK's behaviour), and verify fully offline against the embedded verification key after the window closes.
What's a "generation" and when does it change?
A generation is the version number the zkApp writes to its own on-chain state during initialize(). Every deployed LicensingApp starts at generation 1. When the platform coordinates a redeploy — either because Mina executes a hardfork that unfreezes the contract's impossibleDuringCurrentVersion permission, or because a security issue in the current generation needs to be cut off — the version constant is bumped and every app is redeployed to a fresh address with the new generation number stamped in.
What it means for your app. The SDK's SUPPORTED_LICENSING_APP_VERSIONS list gates which generations the keeper will admit. During a migration window it contains both the old and the new generation, so buyer installs shipping either SDK version keep working. Once the platform prunes the list to just the current generation, verifies against a retired generation start failing on the buyer's next SDK call — this is the enforcement mechanism for a security-driven bump, and it's why no revocation list or CRL fetch is ever needed.
What buyers see. Nothing, in the common case. The mechanism is keeper-side address routing: whichever zkApp address the buyer's app pinned at build time (typically the original generation-1 address) stays valid forever. The keeper resolves the pinned address to the app's deploymentHistory and verifies license state against the current live contract. A buyer whose license record still lives on a retired contract (purchased pre-bump, never migrated) also keeps working: the keeper walks retired addresses newest-first and honors the license against whichever contract's on-chain map it lives on.
What still verifies against the retired generation. Historical attestation, refund claims, and cross-generation migration all continue to work forever against retired addresses — the archive retains the events, and the verify service walks each app's deploymentHistory to serve proofs from whichever generation issued the license. Renewals do not work against retired generations: the SDK's renew flow detects the retirement from the platform-signed GET /apps/:appId payload and swaps in a two-step Migrate & Renew sequence that ports the paid credit forward before renewing on the current generation.