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/build/verifyService.js
CHANGED
|
@@ -1,219 +1,425 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
3
|
-
//
|
|
4
|
-
/**
|
|
5
|
-
* verifyService.ts
|
|
6
|
-
*
|
|
7
|
-
* Lightweight, standalone license verification service.
|
|
8
|
-
*
|
|
9
|
-
* Exposes the same state endpoints as the keeper but contains NO ZK prover —
|
|
10
|
-
* no circuit compilation, no o1js native backend. Starts in ~2 seconds and
|
|
11
|
-
* runs on any small VPS or container.
|
|
12
|
-
*
|
|
13
|
-
* No write endpoints — chain events are the source of truth. License state
|
|
14
|
-
* is rebuilt from the archive node in three ways:
|
|
15
|
-
* - auto-bootstrap on startup when licenses.json is empty,
|
|
16
|
-
* - lazy bootstrap inside GET / when the computed Merkle root
|
|
17
|
-
* disagrees with the on-chain root for the requested app (the canonical
|
|
18
|
-
* "we're stale" signal). One replay + one retry is enough — root is
|
|
19
|
-
* consensus. This catches licenses issued, renewed, or refunded after
|
|
20
|
-
* boot on the very next verify call, with zero work done when no one is
|
|
21
|
-
* asking. Concurrent verifies for the same app share one in-flight
|
|
22
|
-
* bootstrap so a popular license under load does not stampede the
|
|
23
|
-
* archive node.
|
|
24
|
-
* - POST /bootstrap (admin) for ad-hoc reasons: a newly registered zkApp
|
|
25
|
-
* (verify won't trigger a sync for an app no client knows about yet), a
|
|
26
|
-
* corrupted licenses.json, or repopulating the offline /license/...
|
|
27
|
-
* debug endpoint without first hitting /verify.
|
|
28
|
-
*
|
|
29
|
-
* Deployment modes
|
|
30
|
-
* ----------------
|
|
31
|
-
* 1. Same-machine as the keeper
|
|
32
|
-
* - Run from the keeper's working directory so apps.json and licenses.json
|
|
33
|
-
* are shared transparently. Optionally set LICENSE_STORE_PATH to point at
|
|
34
|
-
* a shared file.
|
|
35
|
-
*
|
|
36
|
-
* 2. Separate machine (replica)
|
|
37
|
-
* - Copy the keeper's apps.json into the verifyService working directory.
|
|
38
|
-
* apps.json tells the service which zkApp addresses to replay events for.
|
|
39
|
-
* - Start the service. On boot it auto-bootstraps every app in apps.json
|
|
40
|
-
* from the archive node. MINA_ARCHIVE_URL defaults to the Minascan public
|
|
41
|
-
* archive matching MINA_NETWORK_ID:
|
|
42
|
-
* mainnet → https://api.minascan.io/archive/mainnet/v1/graphql
|
|
43
|
-
* else → https://api.minascan.io/archive/devnet/v1/graphql
|
|
44
|
-
* Override by setting MINA_ARCHIVE_URL in the environment.
|
|
45
|
-
* - To register a NEW app on the replica afterwards:
|
|
46
|
-
* a) Re-copy apps.json from the keeper (it owns the registry), then
|
|
47
|
-
* b) POST /bootstrap with the new zkAppAddress to ingest its events.
|
|
48
|
-
* - Re-copying apps.json on a cron (or pulling it via GET /apps from the
|
|
49
|
-
* keeper) is the recommended way to keep the registry in sync.
|
|
50
|
-
*
|
|
51
|
-
* Usage:
|
|
52
|
-
* node build/src/verifyService.js
|
|
53
|
-
*
|
|
54
|
-
* Configuration is read from environment variables (see README §Configuration).
|
|
55
|
-
* Required: MINA_GRAPHQL_URL, PLATFORM_ADDRESS.
|
|
56
|
-
* Optional: MINA_NETWORK_ID (default testnet), MINA_ARCHIVE_URL (defaults by
|
|
57
|
-
* network), VERIFY_PORT (default 8081), VERIFY_ADMIN_TOKEN.
|
|
58
|
-
*
|
|
59
|
-
* Endpoints:
|
|
60
|
-
* GET /health
|
|
61
|
-
* GET /?licenseHash=...&zkAppAddress=... ← primary endpoint for apps
|
|
62
|
-
* GET /challenge?licenseHash=... ← ownership nonce
|
|
63
|
-
* POST /respond ← ownership proof + token
|
|
64
|
-
* POST /refresh ← extend token TTL (no re-prove)
|
|
65
|
-
* POST /releaseSeat ← release this device's seat
|
|
66
|
-
* POST /reset-sessions ← drop all sessions (re-prove)
|
|
67
|
-
* GET /root?zkApp=...
|
|
68
|
-
* GET /witness/:zkAppAddress/:licenseHash
|
|
69
|
-
* GET /license/:zkAppAddress/:licenseHash
|
|
70
|
-
* GET /apps
|
|
71
|
-
* POST /bootstrap ← replay events from archive
|
|
72
|
-
*/
|
|
73
|
-
import fs from 'fs/promises';
|
|
74
|
-
import { mkdirSync } from 'fs';
|
|
75
|
-
import { homedir } from 'os';
|
|
76
|
-
import { join } from 'path';
|
|
3
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
77
4
|
import express from 'express';
|
|
78
|
-
import { Field, Mina, fetchLastBlock, fetchAccount, PublicKey,
|
|
5
|
+
import { Field, Mina, fetchLastBlock, fetchAccount, PublicKey, setBackend, setGraphqlEndpoint, setArchiveGraphqlEndpoint, } from 'o1js';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
import { dirname, resolve as pathResolve } from 'path';
|
|
79
9
|
import { buildMap, getLicense, licenseStatus, readRecords, serializeWitness, } from './licenseStore.js';
|
|
80
|
-
import {
|
|
81
|
-
import { bootstrapFromArchive, applyEventsToStore
|
|
82
|
-
import {
|
|
83
|
-
import {
|
|
84
|
-
import { createChallengeStore, createSessionStore, newJti, signOwnershipToken, verifyOwnershipToken, newOwnershipKey, OWNERSHIP_TOKEN_TTL_MS, } from './ownership.js';
|
|
10
|
+
import { verifyLicenseWithAncestors } from './verifyWithAncestors.js';
|
|
11
|
+
import { bootstrapFromArchive, applyEventsToStore } from './archiveBootstrap.js';
|
|
12
|
+
import { CANONICAL_VK_HASHES, LICENSING_APP_STATE, SUPPORTED_LICENSING_APP_VERSIONS, fetchLicensingAppVersion, } from './contractInterface.js';
|
|
13
|
+
import { readJsonOr } from './atomicJson.js';
|
|
85
14
|
setBackend('native');
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
process.exit(1);
|
|
96
|
-
}
|
|
97
|
-
return v;
|
|
98
|
-
}
|
|
99
|
-
const MINA_NETWORK_ID = process.env.MINA_NETWORK_ID ?? 'devnet';
|
|
100
|
-
const MINA_GRAPHQL_URL = requireEnv('MINA_GRAPHQL_URL', 'e.g. https://api.minascan.io/node/devnet/v1/graphql');
|
|
15
|
+
const NETWORKS = ['devnet', 'mainnet'];
|
|
16
|
+
const MINA_DEVNET_GRAPHQL_URL = process.env.MINA_DEVNET_GRAPHQL_URL;
|
|
17
|
+
const MINA_DEVNET_ARCHIVE_URL = process.env.MINA_DEVNET_ARCHIVE_URL;
|
|
18
|
+
const MINA_DEVNET_GRAPHQL_FALLBACK = process.env.MINA_DEVNET_GRAPHQL_FALLBACK;
|
|
19
|
+
const MINA_DEVNET_ARCHIVE_FALLBACK = process.env.MINA_DEVNET_ARCHIVE_FALLBACK;
|
|
20
|
+
const MINA_MAINNET_GRAPHQL_URL = process.env.MINA_MAINNET_GRAPHQL_URL;
|
|
21
|
+
const MINA_MAINNET_ARCHIVE_URL = process.env.MINA_MAINNET_ARCHIVE_URL;
|
|
22
|
+
const MINA_MAINNET_GRAPHQL_FALLBACK = process.env.MINA_MAINNET_GRAPHQL_FALLBACK;
|
|
23
|
+
const MINA_MAINNET_ARCHIVE_FALLBACK = process.env.MINA_MAINNET_ARCHIVE_FALLBACK;
|
|
101
24
|
const PORT = parseInt(process.env.VERIFY_PORT ?? '8081', 10);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
25
|
+
// Default archive endpoint per network. Devnet uses o1Labs' Mesa archive
|
|
26
|
+
// (devnet-archive-node-api.gcp.o1test.net); mainnet falls back to Minascan's
|
|
27
|
+
// public archive. When the operator sets an explicit MINA_*_ARCHIVE_URL env
|
|
28
|
+
// var, that value wins.
|
|
29
|
+
const DEFAULT_ARCHIVE_URL = {
|
|
30
|
+
devnet: 'https://devnet-archive-node-api.gcp.o1test.net',
|
|
31
|
+
mainnet: 'https://api.minascan.io/archive/mainnet/v1/graphql',
|
|
32
|
+
};
|
|
33
|
+
// Build per-network Mina.Network instances. Any network the operator didn't
|
|
34
|
+
// configure (no RPC URL) stays out of the map — requests targeting it fall
|
|
35
|
+
// through to `unregistered-address`. At least one must be configured.
|
|
36
|
+
const NETWORK_CONFIG = {};
|
|
37
|
+
function buildNetworkConfig(networkId, graphqlUrl, archiveEnv, graphqlFallbackEnv, archiveFallbackEnv) {
|
|
38
|
+
if (!graphqlUrl)
|
|
39
|
+
return;
|
|
40
|
+
const archiveUrl = archiveEnv ?? DEFAULT_ARCHIVE_URL[networkId];
|
|
41
|
+
const trim = (s) => (s ? s.trim() : '');
|
|
42
|
+
const graphqlFallback = trim(graphqlFallbackEnv) && trim(graphqlFallbackEnv) !== graphqlUrl
|
|
43
|
+
? trim(graphqlFallbackEnv) : null;
|
|
44
|
+
const archiveFallback = trim(archiveFallbackEnv) && trim(archiveFallbackEnv) !== archiveUrl
|
|
45
|
+
? trim(archiveFallbackEnv) : null;
|
|
46
|
+
// See keeperService.ts for the full rationale: o1js's array-based fallback
|
|
47
|
+
// form (`mina: [primary, fallback]`) is unusable — Promise.race lets a fast
|
|
48
|
+
// 5xx from primary beat a slow 200 from fallback, and the winning-URL
|
|
49
|
+
// cache-key bug causes subsequent `.get()` calls to miss. Manual rotation
|
|
50
|
+
// via a second single-URL Mina.Network instance is the sole failover path.
|
|
51
|
+
const fallbackMinaArg = graphqlFallback ? graphqlFallback : null;
|
|
52
|
+
const fallbackArchiveArg = graphqlFallback ? (archiveFallback ?? archiveUrl) : null;
|
|
53
|
+
NETWORK_CONFIG[networkId] = {
|
|
54
|
+
networkId,
|
|
55
|
+
graphqlUrl,
|
|
56
|
+
archiveUrl,
|
|
57
|
+
graphqlFallback,
|
|
58
|
+
archiveFallback,
|
|
59
|
+
network: Mina.Network({ networkId: networkId, mina: graphqlUrl, archive: archiveUrl }),
|
|
60
|
+
fallbackNetwork: fallbackMinaArg
|
|
61
|
+
? Mina.Network({ networkId: networkId, mina: fallbackMinaArg, archive: fallbackArchiveArg })
|
|
62
|
+
: null,
|
|
63
|
+
};
|
|
64
|
+
console.log(`🔗 ${networkId.padEnd(7)} Mina node: ${graphqlUrl}`);
|
|
65
|
+
if (graphqlFallback)
|
|
66
|
+
console.log(` ${networkId.padEnd(7)} fallback: ${graphqlFallback}`);
|
|
67
|
+
console.log(`📚 ${networkId.padEnd(7)} Archive node: ${archiveUrl}${archiveEnv ? ' (from env)' : ' (default)'}`);
|
|
68
|
+
if (archiveFallback)
|
|
69
|
+
console.log(` ${networkId.padEnd(7)} fallback: ${archiveFallback}`);
|
|
111
70
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
71
|
+
buildNetworkConfig('devnet', MINA_DEVNET_GRAPHQL_URL, MINA_DEVNET_ARCHIVE_URL, MINA_DEVNET_GRAPHQL_FALLBACK, MINA_DEVNET_ARCHIVE_FALLBACK);
|
|
72
|
+
buildNetworkConfig('mainnet', MINA_MAINNET_GRAPHQL_URL, MINA_MAINNET_ARCHIVE_URL, MINA_MAINNET_GRAPHQL_FALLBACK, MINA_MAINNET_ARCHIVE_FALLBACK);
|
|
73
|
+
if (Object.keys(NETWORK_CONFIG).length === 0) {
|
|
74
|
+
console.error('❌ No Mina network configured. Set at least one of MINA_DEVNET_GRAPHQL_URL / MINA_MAINNET_GRAPHQL_URL.');
|
|
116
75
|
process.exit(1);
|
|
117
76
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
77
|
+
// Leaked-transaction-context reset scaffolding. The verifier has no
|
|
78
|
+
// Mina.transaction() calls today, so the leak defense is theoretical — but
|
|
79
|
+
// it's cheap (one dynamic import at boot) and future-proofs against a code
|
|
80
|
+
// path we haven't accounted for taking the process out until systemd
|
|
81
|
+
// restarts it. Same shape as keeperService.ts.
|
|
82
|
+
const _o1jsRequire = createRequire(import.meta.url);
|
|
83
|
+
let _o1jsCurrentTransaction = null;
|
|
84
|
+
try {
|
|
85
|
+
const o1jsMainPath = _o1jsRequire.resolve('o1js');
|
|
86
|
+
const contextUrl = pathToFileURL(pathResolve(dirname(o1jsMainPath), 'lib/mina/v1/transaction-context.js')).href;
|
|
87
|
+
const mod = await import(contextUrl);
|
|
88
|
+
_o1jsCurrentTransaction = mod.currentTransaction ?? null;
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
console.warn('[withNetwork] o1js leak defense disabled — could not reach internal transaction-context:', e?.message ?? e);
|
|
92
|
+
}
|
|
93
|
+
function resetLeakedTransactionContext() {
|
|
94
|
+
if (!_o1jsCurrentTransaction)
|
|
132
95
|
return;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (CANONICAL_LICENSE_PROOF_VK_HASH && vk.hash.toString() !== CANONICAL_LICENSE_PROOF_VK_HASH) {
|
|
140
|
-
throw new Error(`LicenseProof VK hash ${vk.hash.toString()} does not match CANONICAL_LICENSE_PROOF_VK_HASH ${CANONICAL_LICENSE_PROOF_VK_HASH}`);
|
|
96
|
+
while (_o1jsCurrentTransaction.has()) {
|
|
97
|
+
try {
|
|
98
|
+
_o1jsCurrentTransaction.leave(_o1jsCurrentTransaction.id());
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
break;
|
|
141
102
|
}
|
|
142
|
-
|
|
143
|
-
console.log(`[verify-prover] Ready in ${((Date.now() - t) / 1000).toFixed(1)}s — vk ${vk.hash.toString().slice(0, 16)}…`);
|
|
144
|
-
})();
|
|
145
|
-
await licenseProofCompile;
|
|
103
|
+
}
|
|
146
104
|
}
|
|
147
|
-
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
105
|
+
let _networkMutexTail = Promise.resolve();
|
|
106
|
+
// Which endpoint (primary vs fallback) currently succeeds for each network.
|
|
107
|
+
// Updated by the rotation helpers whenever a call succeeds on a specific
|
|
108
|
+
// endpoint. `withNetwork` restores from this map at region entry so that a
|
|
109
|
+
// successful fallback rotation in one region persists into the next region
|
|
110
|
+
// — otherwise a resetToPrimary here would stomp the fallback state and the
|
|
111
|
+
// following .get() cache lookup would miss on primary (the o1js account
|
|
112
|
+
// cache is keyed by graphql endpoint URL).
|
|
113
|
+
//
|
|
114
|
+
// Sticky-until-proven-broken semantics: we don't automatically retry primary
|
|
115
|
+
// on every region entry. If primary comes back up, the next rotation call
|
|
116
|
+
// will hit it first (attempt 1) and flip us back.
|
|
117
|
+
const _activeEndpointByNetwork = new Map();
|
|
118
|
+
// Swap the active Mina instance AND the o1js module-global fetch endpoint
|
|
119
|
+
// atomically. These are two independent globals inside o1js:
|
|
120
|
+
// - `activeInstance` (mina-instance.js) — what zkApp.<state>.get() closes
|
|
121
|
+
// over for its cache lookups
|
|
122
|
+
// - `networkConfig.minaEndpoint` (fetch.js) — the DEFAULT endpoint for
|
|
123
|
+
// bare `fetchAccount({publicKey})` / `fetchLastBlock()` calls
|
|
124
|
+
// `Mina.setActiveInstance` only touches the first. Because we build TWO
|
|
125
|
+
// Mina.Network() instances at startup (primary + fallback) and the second
|
|
126
|
+
// call's Fetch.setGraphqlEndpoint side-effect leaves `networkConfig.minaEndpoint`
|
|
127
|
+
// pinned to the LAST-constructed URL (the fallback), a plain
|
|
128
|
+
// setActiveInstance(cfg.network) leaves fetchAccount hitting the fallback URL
|
|
129
|
+
// while zkApp.<state>.get() looks up under the primary URL — cache miss →
|
|
130
|
+
// "Could not find account". We must always keep the two endpoints in sync,
|
|
131
|
+
// which this helper does.
|
|
132
|
+
function activateNetworkEndpoints(cfg, useFallback) {
|
|
133
|
+
if (useFallback && cfg.fallbackNetwork) {
|
|
134
|
+
Mina.setActiveInstance(cfg.fallbackNetwork);
|
|
135
|
+
setGraphqlEndpoint(cfg.graphqlFallback ?? cfg.graphqlUrl);
|
|
136
|
+
setArchiveGraphqlEndpoint(cfg.archiveFallback ?? cfg.archiveUrl);
|
|
137
|
+
_activeEndpointByNetwork.set(cfg.networkId, 'fallback');
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
Mina.setActiveInstance(cfg.network);
|
|
141
|
+
setGraphqlEndpoint(cfg.graphqlUrl);
|
|
142
|
+
setArchiveGraphqlEndpoint(cfg.archiveUrl);
|
|
143
|
+
_activeEndpointByNetwork.set(cfg.networkId, 'primary');
|
|
144
|
+
}
|
|
163
145
|
}
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
146
|
+
// Active-instance juggling: o1js/Mina keeps a single global active network
|
|
147
|
+
// instance, so multi-network handlers must switch it before making any
|
|
148
|
+
// on-chain call. Wrap the region that needs a specific network in
|
|
149
|
+
// withNetwork(network, () => …). Handlers that don't touch chain state
|
|
150
|
+
// (local record reads, witness computation) don't need this.
|
|
151
|
+
async function withNetwork(networkId, fn) {
|
|
152
|
+
const cfg = NETWORK_CONFIG[networkId];
|
|
153
|
+
if (!cfg)
|
|
154
|
+
throw new Error(`Network ${networkId} is not configured on this verifier`);
|
|
155
|
+
let release = () => { };
|
|
156
|
+
const prev = _networkMutexTail;
|
|
157
|
+
_networkMutexTail = new Promise((r) => { release = r; });
|
|
158
|
+
await prev;
|
|
159
|
+
try {
|
|
160
|
+
const stickyFallback = _activeEndpointByNetwork.get(networkId) === 'fallback';
|
|
161
|
+
activateNetworkEndpoints(cfg, stickyFallback);
|
|
162
|
+
return await fn();
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
resetLeakedTransactionContext();
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
release();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Per-attempt timeout for fetchLastBlock. Sized for the SLOW path: the
|
|
173
|
+
// Minascan public node (often used as fallback) frequently rate-limits and
|
|
174
|
+
// can take 60–120s to return during load spikes. Cutting the timeout shorter
|
|
175
|
+
// would surface as a spurious "fetchLastBlock failed" after primary is down
|
|
176
|
+
// — the exact situation this fallback exists to cover. Primary endpoints
|
|
177
|
+
// (minataur, self-hosted) that return quickly aren't penalized because a
|
|
178
|
+
// fast success short-circuits the timer.
|
|
179
|
+
const CURRENT_SLOT_TIMEOUT_MS = 180_000;
|
|
180
|
+
// 5xx and 408 (o1js's own timeout stand-in) rotate to the fallback. 404 means
|
|
181
|
+
// "account genuinely doesn't exist" and short-circuits — no point rotating.
|
|
182
|
+
// Anything else (4xx GraphQL error, 400 malformed request) also short-circuits
|
|
183
|
+
// because it's not endpoint-health related.
|
|
184
|
+
function shouldRotateOnError(statusCode) {
|
|
185
|
+
if (statusCode === undefined)
|
|
186
|
+
return true; // network throw with no status — treat as transport
|
|
187
|
+
if (statusCode === 408)
|
|
188
|
+
return true;
|
|
189
|
+
if (statusCode >= 500 && statusCode <= 599)
|
|
190
|
+
return true;
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
// Manual endpoint rotation for fetchAccount. Must be called from inside a
|
|
194
|
+
// withNetwork() region so the mutex prevents concurrent handlers from
|
|
195
|
+
// observing a mid-flight setActiveInstance swap. On success leaves the
|
|
196
|
+
// winning instance active so the next zkApp.<state>.get() cache lookup finds
|
|
197
|
+
// the freshly-fetched account under the same graphql-endpoint cache key.
|
|
198
|
+
async function fetchAccountWithRotation(networkId, publicKey, attempts, backoffMs = (a) => 400 * a) {
|
|
199
|
+
const cfg = NETWORK_CONFIG[networkId];
|
|
200
|
+
if (!cfg)
|
|
201
|
+
throw new Error(`Network ${networkId} is not configured on this verifier`);
|
|
202
|
+
let last = { account: undefined, error: { statusCode: 500, statusText: 'no attempt made' } };
|
|
203
|
+
let succeededOn = null;
|
|
204
|
+
const trace = [];
|
|
205
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
206
|
+
// Alternate primary / fallback across attempts when fallback exists:
|
|
207
|
+
// attempt 1 → primary, 2 → fallback, 3 → primary, …
|
|
208
|
+
const useFallback = cfg.fallbackNetwork !== null && attempt % 2 === 0;
|
|
209
|
+
const url = useFallback ? (cfg.graphqlFallback ?? cfg.graphqlUrl) : cfg.graphqlUrl;
|
|
210
|
+
activateNetworkEndpoints(cfg, useFallback);
|
|
211
|
+
last = await fetchAccount({ publicKey });
|
|
212
|
+
if (last.account) {
|
|
213
|
+
succeededOn = useFallback ? 'fallback' : 'primary';
|
|
214
|
+
trace.push({ url, outcome: 'ok' });
|
|
215
|
+
break;
|
|
182
216
|
}
|
|
217
|
+
const status = last.error?.statusCode;
|
|
218
|
+
const outcome = status === 404 ? '404' : `HTTP ${status ?? '?'} ${last.error?.statusText ?? ''}`.trim();
|
|
219
|
+
trace.push({ url, outcome });
|
|
220
|
+
if (status === 404)
|
|
221
|
+
break; // real not-found
|
|
222
|
+
if (!shouldRotateOnError(status))
|
|
223
|
+
break; // non-transport error
|
|
224
|
+
if (attempt < attempts)
|
|
225
|
+
await new Promise((r) => setTimeout(r, backoffMs(attempt)));
|
|
226
|
+
}
|
|
227
|
+
if (!last.account && last.error?.statusCode !== 404) {
|
|
228
|
+
logUtc('fetchAccountWithRotation', '✗ all attempts failed', {
|
|
229
|
+
network: networkId,
|
|
230
|
+
publicKey: publicKey.toBase58(),
|
|
231
|
+
attempts,
|
|
232
|
+
trace,
|
|
233
|
+
});
|
|
183
234
|
}
|
|
184
|
-
|
|
235
|
+
if (succeededOn === null) {
|
|
236
|
+
activateNetworkEndpoints(cfg, false);
|
|
237
|
+
}
|
|
238
|
+
return last;
|
|
185
239
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
240
|
+
// Same rotation semantics as fetchAccountWithRotation but for fetchLastBlock.
|
|
241
|
+
// Wraps the o1js call in the CURRENT_SLOT_TIMEOUT_MS timeout race so a dead
|
|
242
|
+
// endpoint doesn't hang the whole attempt. Must be called from inside a
|
|
243
|
+
// withNetwork() region. Returns null on terminal failure so callers can decide
|
|
244
|
+
// (throw, retry higher up, or degrade gracefully — currentSlot returns 0).
|
|
245
|
+
async function fetchLastBlockWithRotation(networkId, attempts, backoffMs = (a) => 400 * a) {
|
|
246
|
+
const cfg = NETWORK_CONFIG[networkId];
|
|
247
|
+
if (!cfg)
|
|
248
|
+
throw new Error(`Network ${networkId} is not configured on this verifier`);
|
|
249
|
+
let succeededOn = null;
|
|
250
|
+
const trace = [];
|
|
251
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
252
|
+
const useFallback = cfg.fallbackNetwork !== null && attempt % 2 === 0;
|
|
253
|
+
const url = useFallback ? (cfg.graphqlFallback ?? cfg.graphqlUrl) : cfg.graphqlUrl;
|
|
254
|
+
activateNetworkEndpoints(cfg, useFallback);
|
|
255
|
+
try {
|
|
256
|
+
const block = await Promise.race([
|
|
257
|
+
fetchLastBlock(),
|
|
258
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`fetchLastBlock timed out after ${CURRENT_SLOT_TIMEOUT_MS}ms`)), CURRENT_SLOT_TIMEOUT_MS)),
|
|
259
|
+
]);
|
|
260
|
+
succeededOn = useFallback ? 'fallback' : 'primary';
|
|
261
|
+
trace.push({ url, outcome: 'ok' });
|
|
262
|
+
return block;
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
trace.push({ url, outcome: err?.message ?? String(err) });
|
|
266
|
+
if (attempt < attempts)
|
|
267
|
+
await new Promise((r) => setTimeout(r, backoffMs(attempt)));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (succeededOn === null) {
|
|
271
|
+
activateNetworkEndpoints(cfg, false);
|
|
272
|
+
}
|
|
273
|
+
logUtc('fetchLastBlockWithRotation', '✗ all attempts failed', {
|
|
274
|
+
network: networkId,
|
|
275
|
+
attempts,
|
|
276
|
+
trace,
|
|
277
|
+
});
|
|
278
|
+
return null;
|
|
191
279
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
280
|
+
// UTC-stamped structured log. Arrays/objects in `extra` are JSON-stringified
|
|
281
|
+
// so per-attempt traces don't render as [object Object].
|
|
282
|
+
function logUtc(scope, msg, extra) {
|
|
283
|
+
const ts = new Date().toISOString();
|
|
284
|
+
const tail = extra
|
|
285
|
+
? ' ' + Object.entries(extra).map(([k, v]) => {
|
|
286
|
+
const s = (v !== null && typeof v === 'object') ? JSON.stringify(v) : `${v}`;
|
|
287
|
+
return `${k}=${s}`;
|
|
288
|
+
}).join(' ')
|
|
289
|
+
: '';
|
|
290
|
+
console.log(`[${ts}] [${scope}] ${msg}${tail}`);
|
|
196
291
|
}
|
|
292
|
+
// Default the active instance to whichever network is configured (mainnet
|
|
293
|
+
// wins if both are up, so idle background code without explicit context lands
|
|
294
|
+
// on the production network). withNetwork() overrides this per-request.
|
|
295
|
+
activateNetworkEndpoints((NETWORK_CONFIG.mainnet ?? NETWORK_CONFIG.devnet), false);
|
|
197
296
|
// ------------------------------------------------------------
|
|
198
297
|
// HELPERS
|
|
199
298
|
// ------------------------------------------------------------
|
|
200
|
-
|
|
299
|
+
// Fetch the current slot on the specified network. Rotation-aware. Callers
|
|
300
|
+
// that need the value in a broader on-chain region should pass the network
|
|
301
|
+
// they already resolved from the zkAppAddress.
|
|
302
|
+
async function currentSlot(network) {
|
|
201
303
|
try {
|
|
202
|
-
const block = await
|
|
304
|
+
const block = await withNetwork(network, () => fetchLastBlockWithRotation(network, 3));
|
|
305
|
+
if (!block)
|
|
306
|
+
return 0;
|
|
203
307
|
return Number(block.globalSlotSinceGenesis.toString());
|
|
204
308
|
}
|
|
205
|
-
catch {
|
|
309
|
+
catch (e) {
|
|
310
|
+
logUtc('currentSlot', '✗ fetchLastBlock failed', { network, reason: e?.message ?? String(e) });
|
|
206
311
|
return 0;
|
|
207
312
|
}
|
|
208
313
|
}
|
|
209
|
-
|
|
210
|
-
|
|
314
|
+
// Return an error message if `zkAppAddress` isn't a well-formed B62q address,
|
|
315
|
+
// null otherwise. Called at request-parse time so garbage input fails with a
|
|
316
|
+
// legible 400 in a couple of ms instead of ~1 s of proof-verify CPU followed
|
|
317
|
+
// by a generic 502 from `fromBase58` throwing deep inside checkOnChainLicense.
|
|
318
|
+
function validateZkAppAddress(zkAppAddress) {
|
|
211
319
|
try {
|
|
212
|
-
|
|
320
|
+
PublicKey.fromBase58(zkAppAddress);
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
return `Invalid zkAppAddress: ${err?.message || 'not a valid Mina B62q address'}`;
|
|
213
325
|
}
|
|
214
|
-
|
|
215
|
-
|
|
326
|
+
}
|
|
327
|
+
// Resolve a zkAppAddress to its owning AppRecord + network + deployment
|
|
328
|
+
// slice. Walks both networks' current-live address and every entry in
|
|
329
|
+
// deploymentHistory. Under the backend-routing design a buyer who pinned an
|
|
330
|
+
// original gen-1 address stays valid forever even after N generation bumps.
|
|
331
|
+
function resolveAppAndNetwork(apps, zkAppAddress) {
|
|
332
|
+
for (const app of apps) {
|
|
333
|
+
for (const net of NETWORKS) {
|
|
334
|
+
const dep = app.deployments[net];
|
|
335
|
+
if (!dep)
|
|
336
|
+
continue;
|
|
337
|
+
if (dep.zkAppAddress === zkAppAddress ||
|
|
338
|
+
(dep.deploymentHistory ?? []).some(h => h.zkAppAddress === zkAppAddress)) {
|
|
339
|
+
return { app, network: net, deployment: dep };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
216
342
|
}
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
// Fetch fresh on-chain state and delegate address routing + ancestor walk
|
|
346
|
+
// to verifyLicenseWithAncestors. Resolves the target network from the
|
|
347
|
+
// zkAppAddress; unknown addresses (not in any configured network's registry)
|
|
348
|
+
// surface as `unregistered-address`. Throws only on network / RPC failure.
|
|
349
|
+
//
|
|
350
|
+
// The `generation` field echoes the *request* address's own stable
|
|
351
|
+
// generation, so a client that pinned an older address at build time sees
|
|
352
|
+
// a token whose `g` still matches its local expectation across bumps.
|
|
353
|
+
async function checkOnChainLicense(zkAppAddress, licenseHash) {
|
|
354
|
+
const apps = await readApps();
|
|
355
|
+
const resolved = resolveAppAndNetwork(apps, zkAppAddress);
|
|
356
|
+
if (!resolved)
|
|
357
|
+
return { kind: 'unregistered-address' };
|
|
358
|
+
const { network, deployment } = resolved;
|
|
359
|
+
if (!NETWORK_CONFIG[network])
|
|
360
|
+
return { kind: 'unregistered-address' };
|
|
361
|
+
const historyLen = deployment.deploymentHistory?.length ?? 0;
|
|
362
|
+
const currentGeneration = historyLen + 1;
|
|
363
|
+
const currentLive = deployment.zkAppAddress;
|
|
364
|
+
// Echo: the request address's own stable generation. Stays consistent
|
|
365
|
+
// across bumps for any client that pinned an older address.
|
|
366
|
+
let requestGeneration = currentGeneration;
|
|
367
|
+
if (zkAppAddress !== currentLive) {
|
|
368
|
+
const idx = (deployment.deploymentHistory ?? []).findIndex((d) => d.zkAppAddress === zkAppAddress);
|
|
369
|
+
if (idx >= 0)
|
|
370
|
+
requestGeneration = idx + 1;
|
|
371
|
+
}
|
|
372
|
+
// Sequential (was Promise.all): the rotation helpers do Mina.setActiveInstance
|
|
373
|
+
// internally, so running fetchAccount + fetchLastBlock in parallel would race
|
|
374
|
+
// the instance swap. Same pattern as the backend sweep.
|
|
375
|
+
const records = await readRecords();
|
|
376
|
+
const { account } = await withNetwork(network, () => fetchAccountWithRotation(network, PublicKey.fromBase58(currentLive), 3));
|
|
377
|
+
const lastBlock = await withNetwork(network, () => fetchLastBlockWithRotation(network, 3));
|
|
378
|
+
if (!lastBlock) {
|
|
379
|
+
throw new Error('fetchLastBlock failed');
|
|
380
|
+
}
|
|
381
|
+
const currentRoot = account?.zkapp?.appState?.[0]?.toString();
|
|
382
|
+
if (!currentRoot)
|
|
383
|
+
return { kind: 'zkapp-not-found' };
|
|
384
|
+
// `version` is state slot 5 (licensesRoot, vendor ×2, escrowRoot,
|
|
385
|
+
// pricesPacked, version, …).
|
|
386
|
+
const zkAppVersion = Number(account?.zkapp?.appState?.[5]?.toString() ?? '0');
|
|
387
|
+
// The live contract must run one of the published circuits. Pinned by
|
|
388
|
+
// circuit, not by this app's redeploy count: a redeploy doesn't imply a
|
|
389
|
+
// new circuit (and an app deployed after a circuit bump starts at app
|
|
390
|
+
// generation 1 on the newer circuit). Fail closed when the VK is missing
|
|
391
|
+
// or unknown — previously a redeployed app had no table entry for its
|
|
392
|
+
// generation and the check was skipped entirely.
|
|
393
|
+
const onChainVk = account?.zkapp?.verificationKey?.hash?.toString();
|
|
394
|
+
if (!onChainVk || !Object.values(CANONICAL_VK_HASHES).includes(onChainVk)) {
|
|
395
|
+
return { kind: 'vk-mismatch', generation: currentGeneration };
|
|
396
|
+
}
|
|
397
|
+
const currentSlotNum = Number(lastBlock.globalSlotSinceGenesis.toString());
|
|
398
|
+
// verifyLicenseWithAncestors uses bare fetchAccount internally on retired
|
|
399
|
+
// addresses — wrap it in withNetwork so the active instance points at the
|
|
400
|
+
// correct network for the whole walk.
|
|
401
|
+
const walked = await withNetwork(network, () => verifyLicenseWithAncestors({
|
|
402
|
+
deployment,
|
|
403
|
+
licenseHash,
|
|
404
|
+
currentSlot: currentSlotNum,
|
|
405
|
+
currentLiveRoot: currentRoot,
|
|
406
|
+
currentLiveForkSlot: Number(account?.zkapp?.appState?.[LICENSING_APP_STATE.forkActivationSlot]?.toString() ?? '0'),
|
|
407
|
+
records,
|
|
408
|
+
}));
|
|
409
|
+
return {
|
|
410
|
+
kind: 'checked',
|
|
411
|
+
onChainRoot: walked.onChainRoot,
|
|
412
|
+
currentSlot: currentSlotNum,
|
|
413
|
+
zkAppVersion,
|
|
414
|
+
generation: requestGeneration,
|
|
415
|
+
resolvedAddress: walked.resolvedAddress,
|
|
416
|
+
liveRecords: walked.liveRecords,
|
|
417
|
+
result: walked.result,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
const APPS_FILE = 'apps.json';
|
|
421
|
+
async function readApps() {
|
|
422
|
+
return readJsonOr(APPS_FILE, []);
|
|
217
423
|
}
|
|
218
424
|
// ------------------------------------------------------------
|
|
219
425
|
// EXPRESS SERVER
|
|
@@ -230,6 +436,26 @@ app.use((_req, res, next) => {
|
|
|
230
436
|
}
|
|
231
437
|
next();
|
|
232
438
|
});
|
|
439
|
+
// Per-request access log. One line per request: method + path + status + ms.
|
|
440
|
+
// Skips CORS preflights (already short-circuited above with 204). Path is the
|
|
441
|
+
// raw req.path — query strings are dropped so the licenseHash from `GET /`
|
|
442
|
+
// doesn't leak into logs (the redacted-prefix log on the / handler below is
|
|
443
|
+
// the canonical place to see which license was queried).
|
|
444
|
+
// Toggle off with VERIFY_ACCESS_LOG=off if the verifier ever runs behind a
|
|
445
|
+
// hot-path API where per-request lines become noise.
|
|
446
|
+
const ACCESS_LOG_ENABLED = (process.env.VERIFY_ACCESS_LOG ?? '').toLowerCase() !== 'off';
|
|
447
|
+
if (ACCESS_LOG_ENABLED) {
|
|
448
|
+
app.use((req, res, next) => {
|
|
449
|
+
const started = Date.now();
|
|
450
|
+
res.on('finish', () => {
|
|
451
|
+
logUtc('access', `${req.method} ${req.path}`, {
|
|
452
|
+
status: res.statusCode,
|
|
453
|
+
elapsed_ms: Date.now() - started,
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
next();
|
|
457
|
+
});
|
|
458
|
+
}
|
|
233
459
|
const VERIFY_ADMIN_TOKEN = process.env.VERIFY_ADMIN_TOKEN ?? '';
|
|
234
460
|
function requireAdmin(req, res, next) {
|
|
235
461
|
if (!VERIFY_ADMIN_TOKEN) {
|
|
@@ -244,11 +470,29 @@ function requireAdmin(req, res, next) {
|
|
|
244
470
|
}
|
|
245
471
|
next();
|
|
246
472
|
}
|
|
247
|
-
// Health
|
|
473
|
+
// Health.
|
|
474
|
+
//
|
|
475
|
+
// `networkId` per entry is the authoritative "which Mina chain" answer — a
|
|
476
|
+
// stable identifier the client can compare against exactly. `network` (the
|
|
477
|
+
// raw GraphQL URL) is kept for operator-facing observability only, since it
|
|
478
|
+
// can rotate providers (Minascan ↔ Minataur ↔ self-hosted) without the chain
|
|
479
|
+
// changing. `fallback` mirrors the same for the configured fallback endpoint.
|
|
248
480
|
app.get('/health', (_req, res) => {
|
|
249
|
-
|
|
481
|
+
const networks = {};
|
|
482
|
+
for (const net of NETWORKS) {
|
|
483
|
+
const cfg = NETWORK_CONFIG[net];
|
|
484
|
+
if (!cfg)
|
|
485
|
+
continue;
|
|
486
|
+
networks[net] = {
|
|
487
|
+
networkId: cfg.networkId,
|
|
488
|
+
network: cfg.graphqlUrl,
|
|
489
|
+
fallback: cfg.graphqlFallback,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
res.json({ status: 'ok', service: 'verify', networks });
|
|
250
493
|
});
|
|
251
|
-
// Current MerkleMap root for a specific zkApp.
|
|
494
|
+
// Current MerkleMap root for a specific zkApp. Network-agnostic — reads local
|
|
495
|
+
// records only.
|
|
252
496
|
app.get('/root', async (req, res) => {
|
|
253
497
|
const zkAppAddress = req.query.zkApp ?? '';
|
|
254
498
|
if (!zkAppAddress) {
|
|
@@ -258,308 +502,99 @@ app.get('/root', async (req, res) => {
|
|
|
258
502
|
const map = buildMap(records, zkAppAddress);
|
|
259
503
|
res.json({ root: map.getRoot().toString() });
|
|
260
504
|
});
|
|
261
|
-
// GET
|
|
262
|
-
//
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
res.json(ownershipChallenges.issue(licenseHash));
|
|
269
|
-
});
|
|
270
|
-
// POST /respond — body: { zkAppAddress, proof }
|
|
271
|
-
// Verifies a LicenseProof against the issued nonce, consumes the nonce,
|
|
272
|
-
// enforces the vendor's concurrent-device cap (if any), and returns
|
|
273
|
-
// { ownership: 'verified', token, expiresAt, jti, activeSessions, deviceLimit }
|
|
274
|
-
// on success. On cap overflow returns 429 without minting a token — clients
|
|
275
|
-
// must release a seat (POST /releaseSeat), reset all sessions (POST
|
|
276
|
-
// /reset-sessions), or wait for the oldest to hit TTL.
|
|
277
|
-
app.post('/respond', async (req, res) => {
|
|
278
|
-
await ensureCompiled();
|
|
279
|
-
if (!licenseProofVk) {
|
|
280
|
-
return res.status(503).json({ error: 'Verify service not ready — circuits compiling' });
|
|
281
|
-
}
|
|
282
|
-
const { zkAppAddress, proof: proofJson } = req.body;
|
|
283
|
-
if (!zkAppAddress || !proofJson) {
|
|
284
|
-
return res.status(400).json({ error: 'Missing zkAppAddress or proof' });
|
|
285
|
-
}
|
|
286
|
-
let proof;
|
|
287
|
-
try {
|
|
288
|
-
proof = await LicenseProofProof.fromJSON(proofJson);
|
|
289
|
-
}
|
|
290
|
-
catch {
|
|
291
|
-
return res.status(400).json({ error: 'Invalid proof format' });
|
|
292
|
-
}
|
|
293
|
-
let proofValid = false;
|
|
294
|
-
try {
|
|
295
|
-
proofValid = await verify(proof, licenseProofVk);
|
|
296
|
-
}
|
|
297
|
-
catch {
|
|
298
|
-
return res.status(400).json({ error: 'Proof verification threw' });
|
|
299
|
-
}
|
|
300
|
-
if (!proofValid) {
|
|
301
|
-
return res.status(400).json({ error: 'Proof is cryptographically invalid' });
|
|
302
|
-
}
|
|
303
|
-
const licenseHash = proof.publicInput.licenseHash.toString();
|
|
304
|
-
const nonce = proof.publicInput.nonce.toString();
|
|
305
|
-
const nonceState = ownershipChallenges.consume(licenseHash, nonce);
|
|
306
|
-
if (nonceState !== 'ok') {
|
|
307
|
-
return res.status(400).json({ error: `Nonce ${nonceState} — request a fresh /challenge` });
|
|
308
|
-
}
|
|
309
|
-
// Concurrent-device cap enforcement. Read the vendor's per-app limit
|
|
310
|
-
// (0/undefined = unlimited) and try to reserve a session slot BEFORE
|
|
311
|
-
// minting the HMAC — a token without a matching session-store entry is
|
|
312
|
-
// dead on arrival at GET / anyway, so refusing here avoids handing the
|
|
313
|
-
// caller a bearer that they can only use to be rejected.
|
|
314
|
-
const app_record = await loadAppByAddress(zkAppAddress);
|
|
315
|
-
const cap = Math.max(0, Math.floor(app_record?.maxConcurrentDevices ?? 0));
|
|
316
|
-
const jti = newJti();
|
|
317
|
-
const exp = Date.now() + OWNERSHIP_TOKEN_TTL_MS;
|
|
318
|
-
const reserved = ownershipSessions.insert(licenseHash, jti, exp, cap);
|
|
319
|
-
if (reserved.state === 'at-cap') {
|
|
320
|
-
return res.status(429).json({
|
|
321
|
-
error: 'Concurrent-device limit reached — release a seat, run POST /reset-sessions, or wait for the oldest session to expire',
|
|
322
|
-
activeSessions: reserved.active,
|
|
323
|
-
deviceLimit: cap,
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
const token = signOwnershipToken(ownershipKey, { z: zkAppAddress, l: licenseHash, e: exp, jti });
|
|
327
|
-
res.json({
|
|
328
|
-
ownership: 'verified',
|
|
329
|
-
token,
|
|
330
|
-
expiresAt: exp,
|
|
331
|
-
jti,
|
|
332
|
-
activeSessions: reserved.active,
|
|
333
|
-
deviceLimit: cap,
|
|
334
|
-
});
|
|
335
|
-
});
|
|
336
|
-
// POST /releaseSeat — body: { token }
|
|
337
|
-
// Releases the seat identified by the token's jti. Possession of the token
|
|
338
|
-
// is authorization to release it (same threat model as any bearer token);
|
|
339
|
-
// no proof-of-ownership is required. Enables an in-app "log out this device"
|
|
340
|
-
// button. Idempotent — a second call with the same token silently no-ops.
|
|
341
|
-
app.post('/releaseSeat', (req, res) => {
|
|
342
|
-
const { token } = req.body;
|
|
343
|
-
if (!token)
|
|
344
|
-
return res.status(400).json({ error: 'Missing token' });
|
|
345
|
-
const payload = verifyOwnershipToken(ownershipKey, token);
|
|
346
|
-
if (!payload)
|
|
347
|
-
return res.status(401).json({ error: 'Invalid or expired token' });
|
|
348
|
-
if (!payload.jti) {
|
|
349
|
-
// Pre-jti tokens aren't tracked in the session store, so there's no seat
|
|
350
|
-
// to release. Return 200 for idempotency — the caller's intent (log this
|
|
351
|
-
// device out) is satisfied by the local token delete.
|
|
352
|
-
return res.json({ ok: true, released: false, reason: 'token has no jti (pre-jti issue)' });
|
|
353
|
-
}
|
|
354
|
-
const released = ownershipSessions.releaseSeat(payload.l, payload.jti);
|
|
355
|
-
res.json({ ok: true, released, jti: payload.jti });
|
|
356
|
-
});
|
|
357
|
-
// POST /refresh — body: { token }
|
|
358
|
-
// Extends the lifetime of the current session without a fresh ownership proof.
|
|
359
|
-
// The caller sends the token they already hold; the server verifies the HMAC,
|
|
360
|
-
// checks the jti is still active in the session store (i.e. not released or
|
|
361
|
-
// evicted), and issues a new token with the same jti and a fresh exp. The
|
|
362
|
-
// session store's insert is idempotent per jti, so no cap check is needed —
|
|
363
|
-
// the slot was already reserved at /respond time.
|
|
364
|
-
//
|
|
365
|
-
// Intended for background renewal from a running app: the client polls this
|
|
366
|
-
// endpoint before the token expires so a user who keeps the app open never
|
|
367
|
-
// has to redo the passphrase-gated challenge/response. A stolen token can be
|
|
368
|
-
// refreshed too, which is why /releaseSeat exists — losing a device means the
|
|
369
|
-
// owner explicitly logs it out, and subsequent refreshes fail.
|
|
370
|
-
//
|
|
371
|
-
// Pre-jti tokens (legacy /respond without session tracking) can also refresh:
|
|
372
|
-
// they're valid HMAC, they carry no jti, and there's no session-store row to
|
|
373
|
-
// gate against. The reissued token is identically pre-jti. Callers that want
|
|
374
|
-
// concurrency enforcement must reissue via /respond so a jti gets minted.
|
|
375
|
-
app.post('/refresh', (req, res) => {
|
|
376
|
-
const { token } = req.body;
|
|
377
|
-
if (!token)
|
|
378
|
-
return res.status(400).json({ error: 'Missing token' });
|
|
379
|
-
const payload = verifyOwnershipToken(ownershipKey, token);
|
|
380
|
-
if (!payload)
|
|
381
|
-
return res.status(401).json({ error: 'Invalid or expired token' });
|
|
382
|
-
const newExp = Date.now() + OWNERSHIP_TOKEN_TTL_MS;
|
|
383
|
-
if (payload.jti) {
|
|
384
|
-
if (!ownershipSessions.has(payload.l, payload.jti)) {
|
|
385
|
-
return res.status(401).json({ error: 'Seat released' });
|
|
386
|
-
}
|
|
387
|
-
// Idempotent for existing jti — updates the expiry, never trips the cap.
|
|
388
|
-
ownershipSessions.insert(payload.l, payload.jti, newExp, 0);
|
|
389
|
-
}
|
|
390
|
-
const next = {
|
|
391
|
-
z: payload.z,
|
|
392
|
-
l: payload.l,
|
|
393
|
-
e: newExp,
|
|
394
|
-
...(payload.jti ? { jti: payload.jti } : {}),
|
|
395
|
-
};
|
|
396
|
-
const nextToken = signOwnershipToken(ownershipKey, next);
|
|
397
|
-
res.json({ token: nextToken, expiresAt: newExp, jti: payload.jti });
|
|
398
|
-
});
|
|
399
|
-
// POST /reset-sessions — body: { zkAppAddress, proof }
|
|
400
|
-
// Drops ALL active sessions for the licenseHash proved by the LicenseProof.
|
|
401
|
-
// Requires the same challenge/response as /respond — only the passphrase
|
|
402
|
-
// holder can invoke it. Intended for "reinstall unsticks a device that
|
|
403
|
-
// burned a slot without logging out" recovery. After this returns, the
|
|
404
|
-
// caller runs a fresh /respond to re-provision a session under a zero
|
|
405
|
-
// active count.
|
|
406
|
-
app.post('/reset-sessions', async (req, res) => {
|
|
407
|
-
await ensureCompiled();
|
|
408
|
-
if (!licenseProofVk) {
|
|
409
|
-
return res.status(503).json({ error: 'Verify service not ready — circuits compiling' });
|
|
410
|
-
}
|
|
411
|
-
const { zkAppAddress, proof: proofJson } = req.body;
|
|
412
|
-
if (!zkAppAddress || !proofJson) {
|
|
413
|
-
return res.status(400).json({ error: 'Missing zkAppAddress or proof' });
|
|
414
|
-
}
|
|
415
|
-
let proof;
|
|
416
|
-
try {
|
|
417
|
-
proof = await LicenseProofProof.fromJSON(proofJson);
|
|
418
|
-
}
|
|
419
|
-
catch {
|
|
420
|
-
return res.status(400).json({ error: 'Invalid proof format' });
|
|
421
|
-
}
|
|
422
|
-
let proofValid = false;
|
|
423
|
-
try {
|
|
424
|
-
proofValid = await verify(proof, licenseProofVk);
|
|
425
|
-
}
|
|
426
|
-
catch {
|
|
427
|
-
return res.status(400).json({ error: 'Proof verification threw' });
|
|
428
|
-
}
|
|
429
|
-
if (!proofValid) {
|
|
430
|
-
return res.status(400).json({ error: 'Proof is cryptographically invalid' });
|
|
431
|
-
}
|
|
432
|
-
const licenseHash = proof.publicInput.licenseHash.toString();
|
|
433
|
-
const nonce = proof.publicInput.nonce.toString();
|
|
434
|
-
const nonceState = ownershipChallenges.consume(licenseHash, nonce);
|
|
435
|
-
if (nonceState !== 'ok') {
|
|
436
|
-
return res.status(400).json({ error: `Nonce ${nonceState} — request a fresh /challenge` });
|
|
437
|
-
}
|
|
438
|
-
const dropped = ownershipSessions.reset(licenseHash);
|
|
439
|
-
res.json({ ok: true, dropped });
|
|
440
|
-
});
|
|
441
|
-
// GET /?licenseHash=...&zkAppAddress=...[&token=...]
|
|
442
|
-
// Primary endpoint for Android, iOS, PWA, and Node SDK license verification.
|
|
443
|
-
//
|
|
444
|
-
// Ownership semantics:
|
|
445
|
-
// - Without `token`, the response describes the state of the on-chain leaf
|
|
446
|
-
// only — it does NOT prove the caller owns the license. This is fine for
|
|
447
|
-
// status displays; gate access behind /respond or pass a fresh
|
|
448
|
-
// activation `token` in the query string.
|
|
449
|
-
// - With `token`, the service checks the HMAC and refuses if the token isn't
|
|
450
|
-
// for this (zkAppAddress, licenseHash) pair.
|
|
505
|
+
// GET /?licenseHash=...&zkAppAddress=...
|
|
506
|
+
// Primary endpoint for Android, PWA, and Node SDK license verification.
|
|
507
|
+
// Describes the on-chain state of the requested license leaf — validity,
|
|
508
|
+
// expiry slot, grace-period flag, purchaseSlot. Callers that need to prove
|
|
509
|
+
// ownership of the license (e.g. before revealing app features) do so via
|
|
510
|
+
// the passphrase-derived Pallas Schnorr keypair off-band — this endpoint is
|
|
511
|
+
// state-only.
|
|
451
512
|
app.get('/', async (req, res) => {
|
|
452
|
-
const { licenseHash: licenseHashStr, zkAppAddress
|
|
513
|
+
const { licenseHash: licenseHashStr, zkAppAddress } = req.query;
|
|
453
514
|
if (!licenseHashStr || !zkAppAddress) {
|
|
454
515
|
return res.status(400).json({ error: 'Missing required query params: licenseHash, zkAppAddress' });
|
|
455
516
|
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
ownershipVerified = true;
|
|
472
|
-
}
|
|
473
|
-
try {
|
|
474
|
-
const [records, { account }, lastBlock] = await Promise.all([
|
|
475
|
-
readRecords(),
|
|
476
|
-
fetchAccount({ publicKey: PublicKey.fromBase58(zkAppAddress) }),
|
|
477
|
-
fetchLastBlock(),
|
|
478
|
-
]);
|
|
479
|
-
const onChainRoot = account?.zkapp?.appState?.[0]?.toString();
|
|
480
|
-
if (!onChainRoot) {
|
|
481
|
-
return res.status(404).json({ error: 'zkApp not found on chain' });
|
|
482
|
-
}
|
|
483
|
-
// Source-version tag from the already-fetched account state — free byte
|
|
484
|
-
// for the SDK / dashboard to label which protocol revision a license lives
|
|
485
|
-
// on. Not a security gate (that's the boot + bootstrap check); purely
|
|
486
|
-
// informational.
|
|
487
|
-
const zkAppVersion = Number(account?.zkapp?.appState?.[7]?.toString() ?? '0');
|
|
488
|
-
// Defense in depth against impersonation. The contract locks
|
|
489
|
-
// `setVerificationKey` via `Permissions.VerificationKey
|
|
490
|
-
// .impossibleDuringCurrentVersion()` plus deploy-key disposal, so VK
|
|
491
|
-
// rotation post-deploy is impossible for any *listed* address. The check
|
|
492
|
-
// below resolves the address's generation via keeper deploymentHistory
|
|
493
|
-
// and asserts its on-chain VK equals that generation's canonical hash.
|
|
494
|
-
// Two failure modes: (a) the address is unknown (not the current live
|
|
495
|
-
// deployment of any registered app, not in any deploymentHistory[]) —
|
|
496
|
-
// reject; that stops a genuine old circuit deployed at a fresh address.
|
|
497
|
-
// (b) the address is listed but its VK doesn't equal its generation's
|
|
498
|
-
// hash — reject; that's a loud index-corruption tripwire.
|
|
499
|
-
const onChainVk = account?.zkapp?.verificationKey?.hash?.toString();
|
|
500
|
-
const generation = await resolveGenerationByAddress(zkAppAddress);
|
|
501
|
-
if (generation === null) {
|
|
502
|
-
return res.json({
|
|
503
|
-
valid: false,
|
|
504
|
-
expiresAt: null,
|
|
505
|
-
expirySlot: 0,
|
|
506
|
-
inGracePeriod: false,
|
|
507
|
-
remainingDays: 0,
|
|
508
|
-
reason: 'zkAppAddress is not in any registered app deployment history',
|
|
509
|
-
ownership: ownershipVerified ? 'verified' : 'unverified',
|
|
510
|
-
});
|
|
511
|
-
}
|
|
512
|
-
const expectedVk = CANONICAL_VK_HASHES[generation];
|
|
513
|
-
if (expectedVk && onChainVk !== expectedVk) {
|
|
514
|
-
return res.json({
|
|
515
|
-
valid: false,
|
|
516
|
-
expiresAt: null,
|
|
517
|
-
expirySlot: 0,
|
|
518
|
-
inGracePeriod: false,
|
|
519
|
-
remainingDays: 0,
|
|
520
|
-
reason: `zkApp verification key does not match generation ${generation}'s canonical LicensingApp circuit`,
|
|
521
|
-
ownership: ownershipVerified ? 'verified' : 'unverified',
|
|
522
|
-
});
|
|
523
|
-
}
|
|
524
|
-
const currentSlot = Number(lastBlock.globalSlotSinceGenesis.toString());
|
|
525
|
-
let liveRecords = records;
|
|
526
|
-
let result = verifyLicenseCore({
|
|
527
|
-
zkAppAddress,
|
|
528
|
-
licenseHash: licenseHashStr,
|
|
529
|
-
records: liveRecords,
|
|
530
|
-
onChainRoot,
|
|
531
|
-
currentSlot,
|
|
517
|
+
// Per-request verdict log. Redacts licenseHash to a 12-char prefix so logs
|
|
518
|
+
// stay pseudonymous but retain enough entropy to correlate a user's report
|
|
519
|
+
// ("my activation says invalid at 03:52") to the exact decision without
|
|
520
|
+
// grepping full hashes. resolvedAddress lets you see when an older
|
|
521
|
+
// generation was the actual match (pre-bump buyer).
|
|
522
|
+
const startedAt = Date.now();
|
|
523
|
+
const hashShort = licenseHashStr.slice(0, 12) + '…';
|
|
524
|
+
const emitVerdict = (verdict, reason, resolvedAddress) => {
|
|
525
|
+
logUtc('verify', 'verdict', {
|
|
526
|
+
licenseHash: hashShort,
|
|
527
|
+
zkAppAddress: zkAppAddress.slice(0, 12) + '…',
|
|
528
|
+
resolvedAddress: resolvedAddress ? resolvedAddress.slice(0, 12) + '…' : undefined,
|
|
529
|
+
verdict,
|
|
530
|
+
reason: reason ?? undefined,
|
|
531
|
+
elapsed_ms: Date.now() - startedAt,
|
|
532
532
|
});
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
533
|
+
};
|
|
534
|
+
// Defense-in-depth against impersonation: the helper resolves the address's
|
|
535
|
+
// generation via backend deploymentHistory and asserts its on-chain VK equals
|
|
536
|
+
// that generation's canonical hash. Two rejection modes it covers: (a) the
|
|
537
|
+
// address is not in any registered app's deployment history — stops a
|
|
538
|
+
// genuine old circuit redeployed at a fresh address; (b) the address is
|
|
539
|
+
// listed but its VK doesn't match — a loud index-corruption tripwire.
|
|
540
|
+
try {
|
|
541
|
+
const onChain = await checkOnChainLicense(zkAppAddress, licenseHashStr);
|
|
542
|
+
switch (onChain.kind) {
|
|
543
|
+
case 'zkapp-not-found':
|
|
544
|
+
emitVerdict('zkapp-not-found');
|
|
545
|
+
return res.status(404).json({ error: 'zkApp not found on chain' });
|
|
546
|
+
case 'unregistered-address':
|
|
547
|
+
emitVerdict('unregistered-address');
|
|
548
|
+
return res.json({
|
|
549
|
+
valid: false,
|
|
550
|
+
expiresAt: null,
|
|
551
|
+
expirySlot: 0,
|
|
552
|
+
inGracePeriod: false,
|
|
553
|
+
remainingDays: 0,
|
|
554
|
+
reason: 'zkAppAddress is not in any registered app deployment history',
|
|
555
|
+
});
|
|
556
|
+
case 'vk-mismatch':
|
|
557
|
+
emitVerdict('vk-mismatch', `generation=${onChain.generation}`);
|
|
558
|
+
return res.json({
|
|
559
|
+
valid: false,
|
|
560
|
+
expiresAt: null,
|
|
561
|
+
expirySlot: 0,
|
|
562
|
+
inGracePeriod: false,
|
|
563
|
+
remainingDays: 0,
|
|
564
|
+
reason: `zkApp verification key does not match generation ${onChain.generation}'s canonical LicensingApp circuit`,
|
|
565
|
+
});
|
|
566
|
+
case 'checked': {
|
|
567
|
+
// Key on resolvedAddress (the address the license was actually found
|
|
568
|
+
// under) rather than the request address — for pre-bump buyers who
|
|
569
|
+
// never migrated, these differ: request is the retired addr the
|
|
570
|
+
// client pinned, resolved is the retired addr the record lives on.
|
|
571
|
+
const record = onChain.liveRecords.find(r => r.zkAppAddress === onChain.resolvedAddress && r.licenseHash === licenseHashStr);
|
|
572
|
+
// Collapse the VerifyResponse shape into a single-word verdict for
|
|
573
|
+
// log scanning: valid | grace | invalid. `invalid` carries the
|
|
574
|
+
// core's own reason string ("License not found", "License state
|
|
575
|
+
// does not match on-chain root", …) so the log line is enough to
|
|
576
|
+
// triage without opening the JSON body.
|
|
577
|
+
const verdict = onChain.result.valid
|
|
578
|
+
? (onChain.result.inGracePeriod ? 'grace' : 'valid')
|
|
579
|
+
: 'invalid';
|
|
580
|
+
emitVerdict(verdict, onChain.result.reason, onChain.resolvedAddress);
|
|
581
|
+
return res.json({
|
|
582
|
+
...onChain.result,
|
|
583
|
+
currentSlot: onChain.currentSlot,
|
|
584
|
+
purchaseSlot: record?.purchaseSlot ?? 0,
|
|
585
|
+
zkAppVersion: onChain.zkAppVersion,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
547
588
|
}
|
|
548
|
-
const record = liveRecords.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHashStr);
|
|
549
|
-
return res.json({
|
|
550
|
-
...result,
|
|
551
|
-
currentSlot,
|
|
552
|
-
purchaseSlot: record?.purchaseSlot ?? 0,
|
|
553
|
-
ownership: ownershipVerified ? 'verified' : 'unverified',
|
|
554
|
-
zkAppVersion,
|
|
555
|
-
});
|
|
556
589
|
}
|
|
557
590
|
catch (err) {
|
|
591
|
+
emitVerdict('error', err?.message ?? String(err));
|
|
558
592
|
console.error('[verify]', err?.message ?? err);
|
|
559
593
|
res.status(500).json({ error: err?.message ?? 'Verification failed' });
|
|
560
594
|
}
|
|
561
595
|
});
|
|
562
|
-
// License map witness — composite (zkAppAddress, licenseHash) key.
|
|
596
|
+
// License map witness — composite (zkAppAddress, licenseHash) key. Network-
|
|
597
|
+
// agnostic — witness comes from the local MerkleMap of records.
|
|
563
598
|
app.get('/witness/:zkAppAddress/:licenseHash', async (req, res) => {
|
|
564
599
|
const { zkAppAddress, licenseHash } = req.params;
|
|
565
600
|
try {
|
|
@@ -578,16 +613,31 @@ app.get('/witness/:zkAppAddress/:licenseHash', async (req, res) => {
|
|
|
578
613
|
}
|
|
579
614
|
});
|
|
580
615
|
// License status — composite (zkAppAddress, licenseHash) key.
|
|
616
|
+
// deviceCap + devices ride along so vendor SDKs can drive their bind flow
|
|
617
|
+
// (activate() reads these to decide whether to prompt for a bind and which
|
|
618
|
+
// slot to target). Sourced from the license record, which is the on-chain
|
|
619
|
+
// mirror populated by eventSyncLoop from DeviceBound/DeviceUnbound events.
|
|
581
620
|
app.get('/license/:zkAppAddress/:licenseHash', async (req, res) => {
|
|
582
621
|
const { zkAppAddress, licenseHash } = req.params;
|
|
583
622
|
const record = await getLicense(zkAppAddress, licenseHash);
|
|
584
|
-
|
|
585
|
-
|
|
623
|
+
// Resolve the address's network so currentSlot hits the right chain. An
|
|
624
|
+
// unregistered address (or one whose network isn't configured here) falls
|
|
625
|
+
// back to slot 0 — status computation still returns a sensible verdict from
|
|
626
|
+
// the record's own expirySlot.
|
|
627
|
+
const apps = await readApps();
|
|
628
|
+
const resolved = resolveAppAndNetwork(apps, zkAppAddress);
|
|
629
|
+
const slot = resolved && NETWORK_CONFIG[resolved.network]
|
|
630
|
+
? await currentSlot(resolved.network)
|
|
631
|
+
: 0;
|
|
632
|
+
const fork = resolved?.deployment.zkAppAddress === zkAppAddress ? resolved.deployment.forkActivationSlot ?? 0 : 0;
|
|
633
|
+
const status = licenseStatus(record, slot, fork);
|
|
586
634
|
res.json({
|
|
587
635
|
found: record !== null,
|
|
588
636
|
expirySlot: record?.expirySlot ?? 0,
|
|
589
637
|
status,
|
|
590
638
|
txHash: record?.txHash ?? null,
|
|
639
|
+
deviceCap: record?.deviceCap ?? 0,
|
|
640
|
+
devices: record?.devices ?? [],
|
|
591
641
|
});
|
|
592
642
|
});
|
|
593
643
|
// List registered apps
|
|
@@ -605,11 +655,23 @@ app.post('/bootstrap', requireAdmin, async (req, res) => {
|
|
|
605
655
|
if (!zkAppAddress) {
|
|
606
656
|
return res.status(400).json({ error: 'Missing zkAppAddress' });
|
|
607
657
|
}
|
|
658
|
+
const addrErr = validateZkAppAddress(zkAppAddress);
|
|
659
|
+
if (addrErr)
|
|
660
|
+
return res.status(400).json({ error: addrErr });
|
|
608
661
|
try {
|
|
662
|
+
const apps = await readApps();
|
|
663
|
+
const resolved = resolveAppAndNetwork(apps, zkAppAddress);
|
|
664
|
+
if (!resolved) {
|
|
665
|
+
return res.status(400).json({ error: `zkAppAddress not in any registered app deployment history` });
|
|
666
|
+
}
|
|
667
|
+
const { network } = resolved;
|
|
668
|
+
if (!NETWORK_CONFIG[network]) {
|
|
669
|
+
return res.status(400).json({ error: `zkAppAddress belongs to ${network} but that network is not configured on this verifier` });
|
|
670
|
+
}
|
|
609
671
|
// Refuse to replay a zkApp whose immutable source-version tag doesn't match
|
|
610
672
|
// one this verifier understands. Otherwise a v2-deployed contract's events
|
|
611
673
|
// could be decoded with a v1 schema and yield subtly wrong state.
|
|
612
|
-
const onChainVersion = await fetchLicensingAppVersion(zkAppAddress);
|
|
674
|
+
const onChainVersion = await withNetwork(network, () => fetchLicensingAppVersion(zkAppAddress));
|
|
613
675
|
if (onChainVersion === null) {
|
|
614
676
|
return res.status(400).json({ error: `zkApp ${zkAppAddress.slice(0, 16)}… not visible on chain` });
|
|
615
677
|
}
|
|
@@ -618,8 +680,9 @@ app.post('/bootstrap', requireAdmin, async (req, res) => {
|
|
|
618
680
|
error: `unsupported LicensingApp version — on-chain v${onChainVersion}, this verifier supports v${SUPPORTED_LICENSING_APP_VERSIONS.join(', v')}`,
|
|
619
681
|
});
|
|
620
682
|
}
|
|
621
|
-
|
|
622
|
-
|
|
683
|
+
const cfg = NETWORK_CONFIG[network];
|
|
684
|
+
console.log(`[bootstrap] Replaying events for ${zkAppAddress.slice(0, 16)}… (${network} v${onChainVersion}) from ${cfg.archiveUrl}`);
|
|
685
|
+
const { events } = await withNetwork(network, () => bootstrapFromArchive(zkAppAddress));
|
|
623
686
|
const { count, warnings } = await applyEventsToStore(zkAppAddress, events);
|
|
624
687
|
for (const w of warnings)
|
|
625
688
|
console.warn('[bootstrap]', w);
|
|
@@ -635,32 +698,43 @@ app.post('/bootstrap', requireAdmin, async (req, res) => {
|
|
|
635
698
|
// START
|
|
636
699
|
// ------------------------------------------------------------
|
|
637
700
|
let records = await readRecords();
|
|
638
|
-
// Auto-bootstrap from archive if store is empty.
|
|
701
|
+
// Auto-bootstrap from archive if store is empty. Iterates every configured
|
|
702
|
+
// network and each of that network's registered apps. Apps whose network
|
|
703
|
+
// isn't configured on this verifier are skipped silently.
|
|
639
704
|
if (records.length === 0) {
|
|
640
705
|
const apps = await readApps();
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
706
|
+
const configuredNetworks = NETWORKS.filter(n => !!NETWORK_CONFIG[n]);
|
|
707
|
+
const bootstrapPlan = [];
|
|
708
|
+
for (const network of configuredNetworks) {
|
|
709
|
+
for (const app of apps) {
|
|
710
|
+
const dep = app.deployments[network];
|
|
711
|
+
if (dep)
|
|
712
|
+
bootstrapPlan.push({ network, app, dep });
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
if (bootstrapPlan.length > 0) {
|
|
716
|
+
console.log(`📦 licenses.json is empty — bootstrapping ${bootstrapPlan.length} app deployment(s) from archive node…`);
|
|
717
|
+
for (const { network, app: appRecord, dep } of bootstrapPlan) {
|
|
644
718
|
try {
|
|
645
719
|
// Same version guard as POST /bootstrap — refuse to replay any address
|
|
646
720
|
// whose on-chain source-version tag this verifier doesn't understand.
|
|
647
|
-
const onChainVersion = await fetchLicensingAppVersion(
|
|
721
|
+
const onChainVersion = await withNetwork(network, () => fetchLicensingAppVersion(dep.zkAppAddress));
|
|
648
722
|
if (onChainVersion === null) {
|
|
649
|
-
console.warn(` ⚠️ ${appRecord.name}: zkApp not visible on chain — skipping`);
|
|
723
|
+
console.warn(` ⚠️ ${appRecord.name} (${network}): zkApp not visible on chain — skipping`);
|
|
650
724
|
continue;
|
|
651
725
|
}
|
|
652
726
|
if (!SUPPORTED_LICENSING_APP_VERSIONS.includes(onChainVersion)) {
|
|
653
|
-
console.warn(` ⚠️ ${appRecord.name}: on-chain v${onChainVersion} not supported (supports v${SUPPORTED_LICENSING_APP_VERSIONS.join(', v')}) — skipping`);
|
|
727
|
+
console.warn(` ⚠️ ${appRecord.name} (${network}): on-chain v${onChainVersion} not supported (supports v${SUPPORTED_LICENSING_APP_VERSIONS.join(', v')}) — skipping`);
|
|
654
728
|
continue;
|
|
655
729
|
}
|
|
656
|
-
const { events } = await bootstrapFromArchive(
|
|
657
|
-
const { count, warnings } = await applyEventsToStore(
|
|
730
|
+
const { events } = await withNetwork(network, () => bootstrapFromArchive(dep.zkAppAddress));
|
|
731
|
+
const { count, warnings } = await applyEventsToStore(dep.zkAppAddress, events);
|
|
658
732
|
for (const w of warnings)
|
|
659
733
|
console.warn('⚠️', w);
|
|
660
|
-
console.log(` ✅ ${appRecord.name} (v${onChainVersion}): ${count} records`);
|
|
734
|
+
console.log(` ✅ ${appRecord.name} (${network} v${onChainVersion}): ${count} records`);
|
|
661
735
|
}
|
|
662
736
|
catch (err) {
|
|
663
|
-
console.warn(` ⚠️ ${appRecord.name}: bootstrap failed — ${err?.message ?? err}`);
|
|
737
|
+
console.warn(` ⚠️ ${appRecord.name} (${network}): bootstrap failed — ${err?.message ?? err}`);
|
|
664
738
|
}
|
|
665
739
|
}
|
|
666
740
|
records = await readRecords();
|
|
@@ -669,12 +743,7 @@ if (records.length === 0) {
|
|
|
669
743
|
console.log(`📋 Loaded ${records.length} license records across ${new Set(records.map(r => r.zkAppAddress)).size} zkApp(s)`);
|
|
670
744
|
app.listen(PORT, () => {
|
|
671
745
|
console.log(`🔍 Verify service listening on http://localhost:${PORT}`);
|
|
672
|
-
console.log(` GET /?licenseHash=...&zkAppAddress
|
|
673
|
-
console.log(` GET /challenge?licenseHash=...`);
|
|
674
|
-
console.log(` POST /respond`);
|
|
675
|
-
console.log(` POST /refresh`);
|
|
676
|
-
console.log(` POST /releaseSeat`);
|
|
677
|
-
console.log(` POST /reset-sessions`);
|
|
746
|
+
console.log(` GET /?licenseHash=...&zkAppAddress=...`);
|
|
678
747
|
console.log(` GET /witness/:zkAppAddress/:licenseHash`);
|
|
679
748
|
console.log(` GET /license/:zkAppAddress/:licenseHash`);
|
|
680
749
|
console.log(` GET /apps`);
|
|
@@ -686,5 +755,6 @@ app.listen(PORT, () => {
|
|
|
686
755
|
console.log(`⚠️ Admin endpoints disabled (set VERIFY_ADMIN_TOKEN to enable)`);
|
|
687
756
|
}
|
|
688
757
|
console.log(`🔁 Lazy bootstrap on root mismatch (no periodic poller)`);
|
|
758
|
+
console.log(`📝 Access log: ${ACCESS_LOG_ENABLED ? 'ON' : 'off (VERIFY_ACCESS_LOG=off)'}`);
|
|
689
759
|
});
|
|
690
760
|
//# sourceMappingURL=verifyService.js.map
|