zklicensing 0.1.0-beta.2 → 0.1.0-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +417 -201
- package/build/agent.d.ts +371 -0
- package/build/agent.d.ts.map +1 -0
- package/build/agent.js +553 -0
- package/build/agent.js.map +1 -0
- package/build/agentActions.d.ts +841 -0
- package/build/agentActions.d.ts.map +1 -0
- package/build/agentActions.js +581 -0
- package/build/agentActions.js.map +1 -0
- package/build/agentRecord.d.ts +83 -0
- package/build/agentRecord.d.ts.map +1 -0
- package/build/agentRecord.js +69 -0
- package/build/agentRecord.js.map +1 -0
- package/build/agentVendor.d.ts +220 -0
- package/build/agentVendor.d.ts.map +1 -0
- package/build/agentVendor.js +306 -0
- package/build/agentVendor.js.map +1 -0
- package/build/appsLookup.d.ts +14 -0
- package/build/appsLookup.d.ts.map +1 -0
- package/build/appsLookup.js +12 -0
- package/build/appsLookup.js.map +1 -0
- package/build/archiveBootstrap.d.ts +31 -5
- package/build/archiveBootstrap.d.ts.map +1 -0
- package/build/archiveBootstrap.js +140 -47
- package/build/archiveBootstrap.js.map +1 -0
- package/build/atomicJson.d.ts +5 -0
- package/build/atomicJson.d.ts.map +1 -0
- package/build/atomicJson.js +55 -0
- package/build/atomicJson.js.map +1 -0
- package/build/bindClient.d.ts +85 -0
- package/build/bindClient.d.ts.map +1 -0
- package/build/bindClient.js +187 -0
- package/build/bindClient.js.map +1 -0
- package/build/buyClient.d.ts +27 -4
- package/build/buyClient.d.ts.map +1 -0
- package/build/buyClient.js +72 -26
- package/build/buyClient.js.map +1 -0
- package/build/chainHead.d.ts +61 -0
- package/build/chainHead.d.ts.map +1 -0
- package/build/chainHead.js +88 -0
- package/build/chainHead.js.map +1 -0
- package/build/compactMerkleMap.d.ts +72 -0
- package/build/compactMerkleMap.d.ts.map +1 -0
- package/build/compactMerkleMap.js +224 -0
- package/build/compactMerkleMap.js.map +1 -0
- package/build/contractInterface.d.ts +191 -51
- package/build/contractInterface.d.ts.map +1 -0
- package/build/contractInterface.js +234 -43
- package/build/contractInterface.js.map +1 -0
- package/build/contractTypes.d.ts +9 -0
- package/build/contractTypes.d.ts.map +1 -0
- package/build/contractTypes.js +28 -0
- package/build/contractTypes.js.map +1 -0
- package/build/countryStats.d.ts +12 -3
- package/build/countryStats.d.ts.map +1 -0
- package/build/countryStats.js +87 -23
- package/build/countryStats.js.map +1 -0
- package/build/deployClient.d.ts +138 -24
- package/build/deployClient.d.ts.map +1 -0
- package/build/deployClient.js +91 -31
- package/build/deployClient.js.map +1 -0
- package/build/deviceIdentity.d.ts +68 -0
- package/build/deviceIdentity.d.ts.map +1 -0
- package/build/deviceIdentity.js +209 -0
- package/build/deviceIdentity.js.map +1 -0
- package/build/freezeClient.d.ts +26 -6
- package/build/freezeClient.d.ts.map +1 -0
- package/build/freezeClient.js +30 -6
- package/build/freezeClient.js.map +1 -0
- package/build/generationClient.d.ts +6 -3
- package/build/generationClient.d.ts.map +1 -0
- package/build/generationClient.js +25 -38
- package/build/generationClient.js.map +1 -0
- package/build/holderSecret.d.ts +70 -0
- package/build/holderSecret.d.ts.map +1 -0
- package/build/holderSecret.js +76 -0
- package/build/holderSecret.js.map +1 -0
- package/build/index.d.ts +38 -38
- package/build/index.d.ts.map +1 -0
- package/build/index.js +97 -121
- package/build/index.js.map +1 -0
- package/build/keysClient.d.ts +142 -0
- package/build/keysClient.d.ts.map +1 -0
- package/build/keysClient.js +109 -0
- package/build/keysClient.js.map +1 -0
- package/build/licenseLeaf.d.ts +76 -0
- package/build/licenseLeaf.d.ts.map +1 -0
- package/build/licenseLeaf.js +189 -0
- package/build/licenseLeaf.js.map +1 -0
- package/build/licenseRecord.d.ts +68 -5
- package/build/licenseRecord.d.ts.map +1 -0
- package/build/licenseRecord.js +26 -1
- package/build/licenseRecord.js.map +1 -0
- package/build/licenseStore.d.ts +81 -4
- package/build/licenseStore.d.ts.map +1 -0
- package/build/licenseStore.js +400 -30
- package/build/licenseStore.js.map +1 -0
- package/build/licenseTrustless.d.ts +43 -0
- package/build/licenseTrustless.d.ts.map +1 -0
- package/build/licenseTrustless.js +180 -0
- package/build/licenseTrustless.js.map +1 -0
- package/build/manifestVerify.d.ts +29 -1
- package/build/manifestVerify.d.ts.map +1 -0
- package/build/manifestVerify.js +46 -4
- package/build/manifestVerify.js.map +1 -0
- package/build/minaPriceHistory.d.ts +9 -0
- package/build/minaPriceHistory.d.ts.map +1 -0
- package/build/minaPriceHistory.js +152 -0
- package/build/minaPriceHistory.js.map +1 -0
- package/build/onChainRoot.d.ts +60 -0
- package/build/onChainRoot.d.ts.map +1 -0
- package/build/onChainRoot.js +74 -0
- package/build/onChainRoot.js.map +1 -0
- package/build/ownershipLicenseHash.d.ts +25 -0
- package/build/ownershipLicenseHash.d.ts.map +1 -0
- package/build/ownershipLicenseHash.js +29 -0
- package/build/ownershipLicenseHash.js.map +1 -0
- package/build/ownershipSignature.d.ts +153 -0
- package/build/ownershipSignature.d.ts.map +1 -0
- package/build/ownershipSignature.js +297 -0
- package/build/ownershipSignature.js.map +1 -0
- package/build/permitCheckFlow.d.ts +155 -0
- package/build/permitCheckFlow.d.ts.map +1 -0
- package/build/permitCheckFlow.js +196 -0
- package/build/permitCheckFlow.js.map +1 -0
- package/build/permitDescribe.d.ts +124 -0
- package/build/permitDescribe.d.ts.map +1 -0
- package/build/permitDescribe.js +324 -0
- package/build/permitDescribe.js.map +1 -0
- package/build/permitInterface.d.ts +85 -0
- package/build/permitInterface.d.ts.map +1 -0
- package/build/permitInterface.js +121 -0
- package/build/permitInterface.js.map +1 -0
- package/build/permitMessages.d.ts +313 -0
- package/build/permitMessages.d.ts.map +1 -0
- package/build/permitMessages.js +462 -0
- package/build/permitMessages.js.map +1 -0
- package/build/permitProof.d.ts +108 -0
- package/build/permitProof.d.ts.map +1 -0
- package/build/permitProof.js +202 -0
- package/build/permitProof.js.map +1 -0
- package/build/permitRegistryRebuild.d.ts +166 -0
- package/build/permitRegistryRebuild.d.ts.map +1 -0
- package/build/permitRegistryRebuild.js +347 -0
- package/build/permitRegistryRebuild.js.map +1 -0
- package/build/permitTree.d.ts +88 -0
- package/build/permitTree.d.ts.map +1 -0
- package/build/permitTree.js +230 -0
- package/build/permitTree.js.map +1 -0
- package/build/permitsClient.d.ts +680 -0
- package/build/permitsClient.d.ts.map +1 -0
- package/build/permitsClient.js +597 -0
- package/build/permitsClient.js.map +1 -0
- package/build/proveErrors.d.ts +50 -0
- package/build/proveErrors.d.ts.map +1 -0
- package/build/proveErrors.js +76 -0
- package/build/proveErrors.js.map +1 -0
- package/build/receipt.d.ts +95 -0
- package/build/receipt.d.ts.map +1 -0
- package/build/receipt.js +84 -0
- package/build/receipt.js.map +1 -0
- package/build/refundClient.d.ts +7 -12
- package/build/refundClient.d.ts.map +1 -0
- package/build/refundClient.js +52 -21
- package/build/refundClient.js.map +1 -0
- package/build/registryTrustVerify.d.ts +78 -0
- package/build/registryTrustVerify.d.ts.map +1 -0
- package/build/registryTrustVerify.js +129 -0
- package/build/registryTrustVerify.js.map +1 -0
- package/build/releaseClient.d.ts +26 -0
- package/build/releaseClient.d.ts.map +1 -0
- package/build/releaseClient.js +65 -0
- package/build/releaseClient.js.map +1 -0
- package/build/renewClient.d.ts +28 -2
- package/build/renewClient.d.ts.map +1 -0
- package/build/renewClient.js +31 -27
- package/build/renewClient.js.map +1 -0
- package/build/seatName.d.ts +8 -0
- package/build/seatName.d.ts.map +1 -0
- package/build/seatName.js +136 -0
- package/build/seatName.js.map +1 -0
- package/build/slotConstants.d.ts +15 -0
- package/build/slotConstants.d.ts.map +1 -0
- package/build/slotConstants.js +17 -0
- package/build/slotConstants.js.map +1 -0
- package/build/statsClient.d.ts +61 -7
- package/build/statsClient.d.ts.map +1 -0
- package/build/statsClient.js +44 -5
- package/build/statsClient.js.map +1 -0
- package/build/successorChain.d.ts +35 -0
- package/build/successorChain.d.ts.map +1 -0
- package/build/successorChain.js +69 -0
- package/build/successorChain.js.map +1 -0
- package/build/taxId.d.ts +44 -0
- package/build/taxId.d.ts.map +1 -0
- package/build/taxId.js +212 -0
- package/build/taxId.js.map +1 -0
- package/build/trustAnchorVerify.d.ts +49 -0
- package/build/trustAnchorVerify.d.ts.map +1 -0
- package/build/trustAnchorVerify.js +432 -0
- package/build/trustAnchorVerify.js.map +1 -0
- package/build/trustAnchorVerifyCli.d.ts +45 -0
- package/build/trustAnchorVerifyCli.d.ts.map +1 -0
- package/build/trustAnchorVerifyCli.js +235 -0
- package/build/trustAnchorVerifyCli.js.map +1 -0
- package/build/txBroadcastClient.d.ts +26 -0
- package/build/txBroadcastClient.d.ts.map +1 -0
- package/build/txBroadcastClient.js +20 -0
- package/build/txBroadcastClient.js.map +1 -0
- package/build/unbindClient.d.ts +77 -0
- package/build/unbindClient.d.ts.map +1 -0
- package/build/unbindClient.js +169 -0
- package/build/unbindClient.js.map +1 -0
- package/build/units.d.ts +28 -0
- package/build/units.d.ts.map +1 -0
- package/build/units.js +65 -0
- package/build/units.js.map +1 -0
- package/build/vatRates.d.ts +54 -0
- package/build/vatRates.d.ts.map +1 -0
- package/build/vatRates.js +82 -0
- package/build/vatRates.js.map +1 -0
- package/build/vatRatesData.d.ts +5 -0
- package/build/vatRatesData.d.ts.map +1 -0
- package/build/vatRatesData.js +42 -0
- package/build/vatRatesData.js.map +1 -0
- package/build/vendorAccountClient.d.ts +66 -0
- package/build/vendorAccountClient.d.ts.map +1 -0
- package/build/vendorAccountClient.js +47 -0
- package/build/vendorAccountClient.js.map +1 -0
- package/build/verifyCore.d.ts +4 -1
- package/build/verifyCore.d.ts.map +1 -0
- package/build/verifyCore.js +21 -10
- package/build/verifyCore.js.map +1 -0
- package/build/verifyOffline.d.ts +85 -0
- package/build/verifyOffline.d.ts.map +1 -0
- package/build/verifyOffline.js +139 -0
- package/build/verifyOffline.js.map +1 -0
- package/build/verifyService.d.ts.map +1 -0
- package/build/verifyService.js +571 -501
- package/build/verifyService.js.map +1 -0
- package/build/verifyWithAncestors.d.ts +19 -0
- package/build/verifyWithAncestors.d.ts.map +1 -0
- package/build/verifyWithAncestors.js +127 -0
- package/build/verifyWithAncestors.js.map +1 -0
- package/build/zipToState.d.ts +2 -0
- package/build/zipToState.d.ts.map +1 -0
- package/build/zipToState.js +94 -0
- package/build/zipToState.js.map +1 -0
- package/package.json +141 -16
- package/src/agent.ts +682 -0
- package/src/agentActions.ts +637 -0
- package/src/agentRecord.ts +121 -0
- package/src/agentVendor.ts +356 -0
- package/src/appsLookup.ts +48 -0
- package/src/archiveBootstrap.ts +386 -0
- package/src/atomicJson.ts +53 -0
- package/src/bindClient.ts +297 -0
- package/src/buyClient.ts +344 -0
- package/src/chainHead.ts +106 -0
- package/src/compactMerkleMap.ts +222 -0
- package/src/contractInterface.ts +465 -0
- package/src/contractTypes.ts +31 -0
- package/src/countryStats.ts +200 -0
- package/src/deployClient.ts +517 -0
- package/src/deviceIdentity.ts +249 -0
- package/src/freezeClient.ts +162 -0
- package/src/generationClient.ts +145 -0
- package/src/holderSecret.ts +91 -0
- package/src/index.ts +362 -0
- package/src/keysClient.ts +135 -0
- package/src/licenseLeaf.ts +221 -0
- package/src/licenseRecord.ts +190 -0
- package/src/licenseStore.ts +767 -0
- package/src/licenseTrustless.ts +207 -0
- package/src/manifestVerify.ts +164 -0
- package/src/minaPriceHistory.ts +166 -0
- package/src/onChainRoot.ts +90 -0
- package/src/ownershipLicenseHash.ts +31 -0
- package/src/ownershipSignature.ts +421 -0
- package/src/permitCheckFlow.ts +227 -0
- package/src/permitDescribe.ts +362 -0
- package/src/permitInterface.ts +146 -0
- package/src/permitMessages.ts +572 -0
- package/src/permitProof.ts +230 -0
- package/src/permitRegistryRebuild.ts +423 -0
- package/src/permitTree.ts +232 -0
- package/src/permitsClient.ts +746 -0
- package/src/proveErrors.ts +98 -0
- package/src/receipt.ts +156 -0
- package/src/refundClient.ts +177 -0
- package/src/registryTrustVerify.ts +192 -0
- package/src/releaseClient.ts +101 -0
- package/src/renewClient.ts +234 -0
- package/src/seatName.ts +165 -0
- package/src/slotConstants.ts +18 -0
- package/src/statsClient.ts +249 -0
- package/src/successorChain.ts +87 -0
- package/src/taxId.ts +227 -0
- package/src/trustAnchorVerify.ts +550 -0
- package/src/trustAnchorVerifyCli.ts +234 -0
- package/src/txBroadcastClient.ts +48 -0
- package/src/unbindClient.ts +233 -0
- package/src/units.ts +74 -0
- package/src/vatRates.ts +100 -0
- package/src/vatRatesData.ts +43 -0
- package/src/vendorAccountClient.ts +120 -0
- package/src/verifyCore.ts +100 -0
- package/src/verifyOffline.ts +212 -0
- package/src/verifyService.ts +982 -0
- package/src/verifyWithAncestors.ts +163 -0
- package/src/zipToState.ts +94 -0
- package/build/licenseProof.d.ts +0 -26
- package/build/licenseProof.js +0 -41
- package/build/migrateClient.d.ts +0 -43
- package/build/migrateClient.js +0 -47
- package/build/ownership.d.ts +0 -34
- package/build/ownership.js +0 -147
|
@@ -0,0 +1,982 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
3
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* verifyService.ts
|
|
7
|
+
*
|
|
8
|
+
* Lightweight, standalone license verification service.
|
|
9
|
+
*
|
|
10
|
+
* Exposes the same state endpoints as the backend but contains NO ZK prover —
|
|
11
|
+
* no circuit compilation, no o1js native backend. Starts in ~2 seconds and
|
|
12
|
+
* runs on any small VPS or container.
|
|
13
|
+
*
|
|
14
|
+
* Multi-network: one process can serve both devnet and mainnet. Every
|
|
15
|
+
* state endpoint receives a `zkAppAddress`, which routes unambiguously to
|
|
16
|
+
* one network via AppRecord.deployments (each generation has its own
|
|
17
|
+
* keypair — zkAppAddresses are globally unique across networks). The active
|
|
18
|
+
* o1js instance is switched per-request via withNetwork(), which also holds
|
|
19
|
+
* a process-global mutex so two concurrent handlers on different networks
|
|
20
|
+
* can't race the Mina.setActiveInstance swap.
|
|
21
|
+
*
|
|
22
|
+
* No write endpoints — chain events are the source of truth. License state
|
|
23
|
+
* is rebuilt from the archive node in three ways:
|
|
24
|
+
* - auto-bootstrap on startup when licenses.json is empty (loops over
|
|
25
|
+
* every configured network's apps),
|
|
26
|
+
* - lazy bootstrap inside GET / when the computed Merkle root
|
|
27
|
+
* disagrees with the on-chain root for the requested app,
|
|
28
|
+
* - POST /bootstrap (admin) for ad-hoc reasons.
|
|
29
|
+
*
|
|
30
|
+
* Deployment modes
|
|
31
|
+
* ----------------
|
|
32
|
+
* 1. Same-machine as the backend
|
|
33
|
+
* - Run from the backend's working directory so apps.json and licenses.json
|
|
34
|
+
* are shared transparently. Optionally set LICENSE_STORE_PATH to point at
|
|
35
|
+
* a shared file.
|
|
36
|
+
*
|
|
37
|
+
* 2. Separate machine (replica)
|
|
38
|
+
* - Copy the backend's apps.json into the verifyService working directory.
|
|
39
|
+
* - Set at least one of MINA_DEVNET_GRAPHQL_URL / MINA_MAINNET_GRAPHQL_URL.
|
|
40
|
+
* - Optionally set MINA_{DEVNET,MAINNET}_{GRAPHQL,ARCHIVE}_FALLBACK for a
|
|
41
|
+
* per-network fallback endpoint (used by the manual rotation on 5xx).
|
|
42
|
+
* - Re-copying apps.json on a cron (or pulling it via GET /apps from the
|
|
43
|
+
* backend) is the recommended way to keep the registry in sync.
|
|
44
|
+
*
|
|
45
|
+
* Usage:
|
|
46
|
+
* node build/src/verifyService.js
|
|
47
|
+
*
|
|
48
|
+
* Configuration is read from environment variables (see README §Configuration).
|
|
49
|
+
* Required: at least one of MINA_DEVNET_GRAPHQL_URL or MINA_MAINNET_GRAPHQL_URL
|
|
50
|
+
* (each also picks up MINA_{DEVNET,MAINNET}_ARCHIVE_URL, defaulting
|
|
51
|
+
* to Minascan public archive per-network).
|
|
52
|
+
* Optional: VERIFY_PORT (default 8081), VERIFY_ADMIN_TOKEN,
|
|
53
|
+
* MINA_{DEVNET,MAINNET}_{GRAPHQL,ARCHIVE}_FALLBACK (single fallback
|
|
54
|
+
* URL per network — manual rotation retries it on 5xx).
|
|
55
|
+
*
|
|
56
|
+
* NOTE: the pre-multi-network single-network trio (MINA_NETWORK_ID +
|
|
57
|
+
* MINA_GRAPHQL_URL + MINA_ARCHIVE_URL) is no longer accepted. Operators must
|
|
58
|
+
* migrate systemd units to the per-network variable names.
|
|
59
|
+
*
|
|
60
|
+
* Endpoints:
|
|
61
|
+
* GET /health
|
|
62
|
+
* GET /?licenseHash=...&zkAppAddress=... ← primary endpoint for apps
|
|
63
|
+
* GET /root?zkApp=...
|
|
64
|
+
* GET /witness/:zkAppAddress/:licenseHash
|
|
65
|
+
* GET /license/:zkAppAddress/:licenseHash
|
|
66
|
+
* GET /apps
|
|
67
|
+
* POST /bootstrap ← replay events from archive
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
import fs from 'fs/promises';
|
|
71
|
+
import express, { Request, Response } from 'express';
|
|
72
|
+
import {
|
|
73
|
+
Field,
|
|
74
|
+
Mina,
|
|
75
|
+
NetworkId as O1NetworkId,
|
|
76
|
+
fetchLastBlock,
|
|
77
|
+
fetchAccount,
|
|
78
|
+
PublicKey,
|
|
79
|
+
setBackend,
|
|
80
|
+
setGraphqlEndpoint,
|
|
81
|
+
setArchiveGraphqlEndpoint,
|
|
82
|
+
} from 'o1js';
|
|
83
|
+
import { createRequire } from 'node:module';
|
|
84
|
+
import { pathToFileURL } from 'node:url';
|
|
85
|
+
import { dirname, resolve as pathResolve } from 'path';
|
|
86
|
+
import {
|
|
87
|
+
buildMap,
|
|
88
|
+
getLicense,
|
|
89
|
+
licenseStatus,
|
|
90
|
+
readRecords,
|
|
91
|
+
serializeWitness,
|
|
92
|
+
type LicenseRecord,
|
|
93
|
+
} from './licenseStore.js';
|
|
94
|
+
import { type VerifyResponse } from './verifyCore.js';
|
|
95
|
+
import { verifyLicenseWithAncestors } from './verifyWithAncestors.js';
|
|
96
|
+
import { bootstrapFromArchive, applyEventsToStore } from './archiveBootstrap.js';
|
|
97
|
+
import {
|
|
98
|
+
CANONICAL_VK_HASHES,
|
|
99
|
+
LICENSING_APP_STATE,
|
|
100
|
+
SUPPORTED_LICENSING_APP_VERSIONS,
|
|
101
|
+
fetchLicensingAppVersion,
|
|
102
|
+
} from './contractInterface.js';
|
|
103
|
+
import { readJsonOr } from './atomicJson.js';
|
|
104
|
+
|
|
105
|
+
setBackend('native');
|
|
106
|
+
|
|
107
|
+
// ------------------------------------------------------------
|
|
108
|
+
// CONFIG (env vars) & NETWORK
|
|
109
|
+
// ------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
// Local NetworkId — the two Mina chains we support. Distinct from o1js's
|
|
112
|
+
// NetworkId (which is `'mainnet' | 'testnet' | { custom: string }`); we alias
|
|
113
|
+
// o1js's as O1NetworkId above and use it only where a Mina.Network()
|
|
114
|
+
// constructor demands it.
|
|
115
|
+
type NetworkId = 'devnet' | 'mainnet';
|
|
116
|
+
const NETWORKS: readonly NetworkId[] = ['devnet', 'mainnet'] as const;
|
|
117
|
+
|
|
118
|
+
const MINA_DEVNET_GRAPHQL_URL = process.env.MINA_DEVNET_GRAPHQL_URL;
|
|
119
|
+
const MINA_DEVNET_ARCHIVE_URL = process.env.MINA_DEVNET_ARCHIVE_URL;
|
|
120
|
+
const MINA_DEVNET_GRAPHQL_FALLBACK = process.env.MINA_DEVNET_GRAPHQL_FALLBACK;
|
|
121
|
+
const MINA_DEVNET_ARCHIVE_FALLBACK = process.env.MINA_DEVNET_ARCHIVE_FALLBACK;
|
|
122
|
+
const MINA_MAINNET_GRAPHQL_URL = process.env.MINA_MAINNET_GRAPHQL_URL;
|
|
123
|
+
const MINA_MAINNET_ARCHIVE_URL = process.env.MINA_MAINNET_ARCHIVE_URL;
|
|
124
|
+
const MINA_MAINNET_GRAPHQL_FALLBACK = process.env.MINA_MAINNET_GRAPHQL_FALLBACK;
|
|
125
|
+
const MINA_MAINNET_ARCHIVE_FALLBACK = process.env.MINA_MAINNET_ARCHIVE_FALLBACK;
|
|
126
|
+
const PORT = parseInt(process.env.VERIFY_PORT ?? '8081', 10);
|
|
127
|
+
|
|
128
|
+
// Default archive endpoint per network. Devnet uses o1Labs' Mesa archive
|
|
129
|
+
// (devnet-archive-node-api.gcp.o1test.net); mainnet falls back to Minascan's
|
|
130
|
+
// public archive. When the operator sets an explicit MINA_*_ARCHIVE_URL env
|
|
131
|
+
// var, that value wins.
|
|
132
|
+
const DEFAULT_ARCHIVE_URL: Record<NetworkId, string> = {
|
|
133
|
+
devnet: 'https://devnet-archive-node-api.gcp.o1test.net',
|
|
134
|
+
mainnet: 'https://api.minascan.io/archive/mainnet/v1/graphql',
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
type NetworkConfig = {
|
|
138
|
+
networkId: NetworkId;
|
|
139
|
+
graphqlUrl: string;
|
|
140
|
+
archiveUrl: string;
|
|
141
|
+
graphqlFallback: string | null;
|
|
142
|
+
archiveFallback: string | null;
|
|
143
|
+
network: ReturnType<typeof Mina.Network>;
|
|
144
|
+
// Same URLs but with fallback listed first — used by manual rotation on 5xx
|
|
145
|
+
// (see fetchAccountWithRotation). o1js's built-in fallback races both
|
|
146
|
+
// endpoints and returns whichever resolves first, so a fast 502 from primary
|
|
147
|
+
// beats a slower 200 from fallback. Manual rotation forces the second
|
|
148
|
+
// attempt onto fallback-first so we actually hit the healthy endpoint.
|
|
149
|
+
// Null when no fallback is configured.
|
|
150
|
+
fallbackNetwork: ReturnType<typeof Mina.Network> | null;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// Build per-network Mina.Network instances. Any network the operator didn't
|
|
154
|
+
// configure (no RPC URL) stays out of the map — requests targeting it fall
|
|
155
|
+
// through to `unregistered-address`. At least one must be configured.
|
|
156
|
+
const NETWORK_CONFIG: Partial<Record<NetworkId, NetworkConfig>> = {};
|
|
157
|
+
function buildNetworkConfig(
|
|
158
|
+
networkId: NetworkId,
|
|
159
|
+
graphqlUrl: string | undefined,
|
|
160
|
+
archiveEnv: string | undefined,
|
|
161
|
+
graphqlFallbackEnv: string | undefined,
|
|
162
|
+
archiveFallbackEnv: string | undefined,
|
|
163
|
+
): void {
|
|
164
|
+
if (!graphqlUrl) return;
|
|
165
|
+
const archiveUrl = archiveEnv ?? DEFAULT_ARCHIVE_URL[networkId];
|
|
166
|
+
const trim = (s: string | undefined) => (s ? s.trim() : '');
|
|
167
|
+
const graphqlFallback = trim(graphqlFallbackEnv) && trim(graphqlFallbackEnv) !== graphqlUrl
|
|
168
|
+
? trim(graphqlFallbackEnv) : null;
|
|
169
|
+
const archiveFallback = trim(archiveFallbackEnv) && trim(archiveFallbackEnv) !== archiveUrl
|
|
170
|
+
? trim(archiveFallbackEnv) : null;
|
|
171
|
+
// See keeperService.ts for the full rationale: o1js's array-based fallback
|
|
172
|
+
// form (`mina: [primary, fallback]`) is unusable — Promise.race lets a fast
|
|
173
|
+
// 5xx from primary beat a slow 200 from fallback, and the winning-URL
|
|
174
|
+
// cache-key bug causes subsequent `.get()` calls to miss. Manual rotation
|
|
175
|
+
// via a second single-URL Mina.Network instance is the sole failover path.
|
|
176
|
+
const fallbackMinaArg = graphqlFallback ? graphqlFallback : null;
|
|
177
|
+
const fallbackArchiveArg = graphqlFallback ? (archiveFallback ?? archiveUrl) : null;
|
|
178
|
+
NETWORK_CONFIG[networkId] = {
|
|
179
|
+
networkId,
|
|
180
|
+
graphqlUrl,
|
|
181
|
+
archiveUrl,
|
|
182
|
+
graphqlFallback,
|
|
183
|
+
archiveFallback,
|
|
184
|
+
network: Mina.Network({ networkId: networkId as O1NetworkId, mina: graphqlUrl, archive: archiveUrl }),
|
|
185
|
+
fallbackNetwork: fallbackMinaArg
|
|
186
|
+
? Mina.Network({ networkId: networkId as O1NetworkId, mina: fallbackMinaArg, archive: fallbackArchiveArg! })
|
|
187
|
+
: null,
|
|
188
|
+
};
|
|
189
|
+
console.log(`🔗 ${networkId.padEnd(7)} Mina node: ${graphqlUrl}`);
|
|
190
|
+
if (graphqlFallback) console.log(` ${networkId.padEnd(7)} fallback: ${graphqlFallback}`);
|
|
191
|
+
console.log(`📚 ${networkId.padEnd(7)} Archive node: ${archiveUrl}${archiveEnv ? ' (from env)' : ' (default)'}`);
|
|
192
|
+
if (archiveFallback) console.log(` ${networkId.padEnd(7)} fallback: ${archiveFallback}`);
|
|
193
|
+
}
|
|
194
|
+
buildNetworkConfig('devnet', MINA_DEVNET_GRAPHQL_URL, MINA_DEVNET_ARCHIVE_URL, MINA_DEVNET_GRAPHQL_FALLBACK, MINA_DEVNET_ARCHIVE_FALLBACK);
|
|
195
|
+
buildNetworkConfig('mainnet', MINA_MAINNET_GRAPHQL_URL, MINA_MAINNET_ARCHIVE_URL, MINA_MAINNET_GRAPHQL_FALLBACK, MINA_MAINNET_ARCHIVE_FALLBACK);
|
|
196
|
+
if (Object.keys(NETWORK_CONFIG).length === 0) {
|
|
197
|
+
console.error('❌ No Mina network configured. Set at least one of MINA_DEVNET_GRAPHQL_URL / MINA_MAINNET_GRAPHQL_URL.');
|
|
198
|
+
process.exit(1);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Leaked-transaction-context reset scaffolding. The verifier has no
|
|
202
|
+
// Mina.transaction() calls today, so the leak defense is theoretical — but
|
|
203
|
+
// it's cheap (one dynamic import at boot) and future-proofs against a code
|
|
204
|
+
// path we haven't accounted for taking the process out until systemd
|
|
205
|
+
// restarts it. Same shape as keeperService.ts.
|
|
206
|
+
const _o1jsRequire = createRequire(import.meta.url);
|
|
207
|
+
let _o1jsCurrentTransaction: {
|
|
208
|
+
has(): boolean;
|
|
209
|
+
id(): number;
|
|
210
|
+
leave(id: number): unknown;
|
|
211
|
+
} | null = null;
|
|
212
|
+
try {
|
|
213
|
+
const o1jsMainPath = _o1jsRequire.resolve('o1js');
|
|
214
|
+
const contextUrl = pathToFileURL(
|
|
215
|
+
pathResolve(dirname(o1jsMainPath), 'lib/mina/v1/transaction-context.js')
|
|
216
|
+
).href;
|
|
217
|
+
const mod = await import(contextUrl) as { currentTransaction?: NonNullable<typeof _o1jsCurrentTransaction> };
|
|
218
|
+
_o1jsCurrentTransaction = mod.currentTransaction ?? null;
|
|
219
|
+
} catch (e: any) {
|
|
220
|
+
console.warn('[withNetwork] o1js leak defense disabled — could not reach internal transaction-context:', e?.message ?? e);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function resetLeakedTransactionContext(): void {
|
|
224
|
+
if (!_o1jsCurrentTransaction) return;
|
|
225
|
+
while (_o1jsCurrentTransaction.has()) {
|
|
226
|
+
try { _o1jsCurrentTransaction.leave(_o1jsCurrentTransaction.id()); }
|
|
227
|
+
catch { break; }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let _networkMutexTail: Promise<void> = Promise.resolve();
|
|
232
|
+
|
|
233
|
+
// Which endpoint (primary vs fallback) currently succeeds for each network.
|
|
234
|
+
// Updated by the rotation helpers whenever a call succeeds on a specific
|
|
235
|
+
// endpoint. `withNetwork` restores from this map at region entry so that a
|
|
236
|
+
// successful fallback rotation in one region persists into the next region
|
|
237
|
+
// — otherwise a resetToPrimary here would stomp the fallback state and the
|
|
238
|
+
// following .get() cache lookup would miss on primary (the o1js account
|
|
239
|
+
// cache is keyed by graphql endpoint URL).
|
|
240
|
+
//
|
|
241
|
+
// Sticky-until-proven-broken semantics: we don't automatically retry primary
|
|
242
|
+
// on every region entry. If primary comes back up, the next rotation call
|
|
243
|
+
// will hit it first (attempt 1) and flip us back.
|
|
244
|
+
const _activeEndpointByNetwork: Map<NetworkId, 'primary' | 'fallback'> = new Map();
|
|
245
|
+
|
|
246
|
+
// Swap the active Mina instance AND the o1js module-global fetch endpoint
|
|
247
|
+
// atomically. These are two independent globals inside o1js:
|
|
248
|
+
// - `activeInstance` (mina-instance.js) — what zkApp.<state>.get() closes
|
|
249
|
+
// over for its cache lookups
|
|
250
|
+
// - `networkConfig.minaEndpoint` (fetch.js) — the DEFAULT endpoint for
|
|
251
|
+
// bare `fetchAccount({publicKey})` / `fetchLastBlock()` calls
|
|
252
|
+
// `Mina.setActiveInstance` only touches the first. Because we build TWO
|
|
253
|
+
// Mina.Network() instances at startup (primary + fallback) and the second
|
|
254
|
+
// call's Fetch.setGraphqlEndpoint side-effect leaves `networkConfig.minaEndpoint`
|
|
255
|
+
// pinned to the LAST-constructed URL (the fallback), a plain
|
|
256
|
+
// setActiveInstance(cfg.network) leaves fetchAccount hitting the fallback URL
|
|
257
|
+
// while zkApp.<state>.get() looks up under the primary URL — cache miss →
|
|
258
|
+
// "Could not find account". We must always keep the two endpoints in sync,
|
|
259
|
+
// which this helper does.
|
|
260
|
+
function activateNetworkEndpoints(cfg: NetworkConfig, useFallback: boolean): void {
|
|
261
|
+
if (useFallback && cfg.fallbackNetwork) {
|
|
262
|
+
Mina.setActiveInstance(cfg.fallbackNetwork);
|
|
263
|
+
setGraphqlEndpoint(cfg.graphqlFallback ?? cfg.graphqlUrl);
|
|
264
|
+
setArchiveGraphqlEndpoint(cfg.archiveFallback ?? cfg.archiveUrl);
|
|
265
|
+
_activeEndpointByNetwork.set(cfg.networkId, 'fallback');
|
|
266
|
+
} else {
|
|
267
|
+
Mina.setActiveInstance(cfg.network);
|
|
268
|
+
setGraphqlEndpoint(cfg.graphqlUrl);
|
|
269
|
+
setArchiveGraphqlEndpoint(cfg.archiveUrl);
|
|
270
|
+
_activeEndpointByNetwork.set(cfg.networkId, 'primary');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Active-instance juggling: o1js/Mina keeps a single global active network
|
|
275
|
+
// instance, so multi-network handlers must switch it before making any
|
|
276
|
+
// on-chain call. Wrap the region that needs a specific network in
|
|
277
|
+
// withNetwork(network, () => …). Handlers that don't touch chain state
|
|
278
|
+
// (local record reads, witness computation) don't need this.
|
|
279
|
+
async function withNetwork<T>(networkId: NetworkId, fn: () => Promise<T>): Promise<T> {
|
|
280
|
+
const cfg = NETWORK_CONFIG[networkId];
|
|
281
|
+
if (!cfg) throw new Error(`Network ${networkId} is not configured on this verifier`);
|
|
282
|
+
let release: () => void = () => {};
|
|
283
|
+
const prev = _networkMutexTail;
|
|
284
|
+
_networkMutexTail = new Promise<void>((r) => { release = r; });
|
|
285
|
+
await prev;
|
|
286
|
+
try {
|
|
287
|
+
const stickyFallback = _activeEndpointByNetwork.get(networkId) === 'fallback';
|
|
288
|
+
activateNetworkEndpoints(cfg, stickyFallback);
|
|
289
|
+
return await fn();
|
|
290
|
+
} catch (err) {
|
|
291
|
+
resetLeakedTransactionContext();
|
|
292
|
+
throw err;
|
|
293
|
+
} finally {
|
|
294
|
+
release();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Per-attempt timeout for fetchLastBlock. Sized for the SLOW path: the
|
|
299
|
+
// Minascan public node (often used as fallback) frequently rate-limits and
|
|
300
|
+
// can take 60–120s to return during load spikes. Cutting the timeout shorter
|
|
301
|
+
// would surface as a spurious "fetchLastBlock failed" after primary is down
|
|
302
|
+
// — the exact situation this fallback exists to cover. Primary endpoints
|
|
303
|
+
// (minataur, self-hosted) that return quickly aren't penalized because a
|
|
304
|
+
// fast success short-circuits the timer.
|
|
305
|
+
const CURRENT_SLOT_TIMEOUT_MS = 180_000;
|
|
306
|
+
|
|
307
|
+
// 5xx and 408 (o1js's own timeout stand-in) rotate to the fallback. 404 means
|
|
308
|
+
// "account genuinely doesn't exist" and short-circuits — no point rotating.
|
|
309
|
+
// Anything else (4xx GraphQL error, 400 malformed request) also short-circuits
|
|
310
|
+
// because it's not endpoint-health related.
|
|
311
|
+
function shouldRotateOnError(statusCode: number | undefined): boolean {
|
|
312
|
+
if (statusCode === undefined) return true; // network throw with no status — treat as transport
|
|
313
|
+
if (statusCode === 408) return true;
|
|
314
|
+
if (statusCode >= 500 && statusCode <= 599) return true;
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Manual endpoint rotation for fetchAccount. Must be called from inside a
|
|
319
|
+
// withNetwork() region so the mutex prevents concurrent handlers from
|
|
320
|
+
// observing a mid-flight setActiveInstance swap. On success leaves the
|
|
321
|
+
// winning instance active so the next zkApp.<state>.get() cache lookup finds
|
|
322
|
+
// the freshly-fetched account under the same graphql-endpoint cache key.
|
|
323
|
+
async function fetchAccountWithRotation(
|
|
324
|
+
networkId: NetworkId,
|
|
325
|
+
publicKey: PublicKey,
|
|
326
|
+
attempts: number,
|
|
327
|
+
backoffMs: (attempt: number) => number = (a) => 400 * a,
|
|
328
|
+
): Promise<Awaited<ReturnType<typeof fetchAccount>>> {
|
|
329
|
+
const cfg = NETWORK_CONFIG[networkId];
|
|
330
|
+
if (!cfg) throw new Error(`Network ${networkId} is not configured on this verifier`);
|
|
331
|
+
let last: Awaited<ReturnType<typeof fetchAccount>> = { account: undefined, error: { statusCode: 500, statusText: 'no attempt made' } as any };
|
|
332
|
+
let succeededOn: 'primary' | 'fallback' | null = null;
|
|
333
|
+
const trace: Array<{ url: string; outcome: string }> = [];
|
|
334
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
335
|
+
// Alternate primary / fallback across attempts when fallback exists:
|
|
336
|
+
// attempt 1 → primary, 2 → fallback, 3 → primary, …
|
|
337
|
+
const useFallback = cfg.fallbackNetwork !== null && attempt % 2 === 0;
|
|
338
|
+
const url = useFallback ? (cfg.graphqlFallback ?? cfg.graphqlUrl) : cfg.graphqlUrl;
|
|
339
|
+
activateNetworkEndpoints(cfg, useFallback);
|
|
340
|
+
last = await fetchAccount({ publicKey });
|
|
341
|
+
if (last.account) {
|
|
342
|
+
succeededOn = useFallback ? 'fallback' : 'primary';
|
|
343
|
+
trace.push({ url, outcome: 'ok' });
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
const status = last.error?.statusCode;
|
|
347
|
+
const outcome = status === 404 ? '404' : `HTTP ${status ?? '?'} ${last.error?.statusText ?? ''}`.trim();
|
|
348
|
+
trace.push({ url, outcome });
|
|
349
|
+
if (status === 404) break; // real not-found
|
|
350
|
+
if (!shouldRotateOnError(status)) break; // non-transport error
|
|
351
|
+
if (attempt < attempts) await new Promise((r) => setTimeout(r, backoffMs(attempt)));
|
|
352
|
+
}
|
|
353
|
+
if (!last.account && last.error?.statusCode !== 404) {
|
|
354
|
+
logUtc('fetchAccountWithRotation', '✗ all attempts failed', {
|
|
355
|
+
network: networkId,
|
|
356
|
+
publicKey: publicKey.toBase58(),
|
|
357
|
+
attempts,
|
|
358
|
+
trace,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
if (succeededOn === null) {
|
|
362
|
+
activateNetworkEndpoints(cfg, false);
|
|
363
|
+
}
|
|
364
|
+
return last;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Same rotation semantics as fetchAccountWithRotation but for fetchLastBlock.
|
|
368
|
+
// Wraps the o1js call in the CURRENT_SLOT_TIMEOUT_MS timeout race so a dead
|
|
369
|
+
// endpoint doesn't hang the whole attempt. Must be called from inside a
|
|
370
|
+
// withNetwork() region. Returns null on terminal failure so callers can decide
|
|
371
|
+
// (throw, retry higher up, or degrade gracefully — currentSlot returns 0).
|
|
372
|
+
async function fetchLastBlockWithRotation(
|
|
373
|
+
networkId: NetworkId,
|
|
374
|
+
attempts: number,
|
|
375
|
+
backoffMs: (attempt: number) => number = (a) => 400 * a,
|
|
376
|
+
): Promise<Awaited<ReturnType<typeof fetchLastBlock>> | null> {
|
|
377
|
+
const cfg = NETWORK_CONFIG[networkId];
|
|
378
|
+
if (!cfg) throw new Error(`Network ${networkId} is not configured on this verifier`);
|
|
379
|
+
let succeededOn: 'primary' | 'fallback' | null = null;
|
|
380
|
+
const trace: Array<{ url: string; outcome: string }> = [];
|
|
381
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
382
|
+
const useFallback = cfg.fallbackNetwork !== null && attempt % 2 === 0;
|
|
383
|
+
const url = useFallback ? (cfg.graphqlFallback ?? cfg.graphqlUrl) : cfg.graphqlUrl;
|
|
384
|
+
activateNetworkEndpoints(cfg, useFallback);
|
|
385
|
+
try {
|
|
386
|
+
const block = await Promise.race([
|
|
387
|
+
fetchLastBlock(),
|
|
388
|
+
new Promise<never>((_, reject) =>
|
|
389
|
+
setTimeout(() => reject(new Error(`fetchLastBlock timed out after ${CURRENT_SLOT_TIMEOUT_MS}ms`)),
|
|
390
|
+
CURRENT_SLOT_TIMEOUT_MS),
|
|
391
|
+
),
|
|
392
|
+
]);
|
|
393
|
+
succeededOn = useFallback ? 'fallback' : 'primary';
|
|
394
|
+
trace.push({ url, outcome: 'ok' });
|
|
395
|
+
return block;
|
|
396
|
+
} catch (err: any) {
|
|
397
|
+
trace.push({ url, outcome: err?.message ?? String(err) });
|
|
398
|
+
if (attempt < attempts) await new Promise((r) => setTimeout(r, backoffMs(attempt)));
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (succeededOn === null) {
|
|
402
|
+
activateNetworkEndpoints(cfg, false);
|
|
403
|
+
}
|
|
404
|
+
logUtc('fetchLastBlockWithRotation', '✗ all attempts failed', {
|
|
405
|
+
network: networkId,
|
|
406
|
+
attempts,
|
|
407
|
+
trace,
|
|
408
|
+
});
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// UTC-stamped structured log. Arrays/objects in `extra` are JSON-stringified
|
|
413
|
+
// so per-attempt traces don't render as [object Object].
|
|
414
|
+
function logUtc(scope: string, msg: string, extra?: Record<string, unknown>): void {
|
|
415
|
+
const ts = new Date().toISOString();
|
|
416
|
+
const tail = extra
|
|
417
|
+
? ' ' + Object.entries(extra).map(([k, v]) => {
|
|
418
|
+
const s = (v !== null && typeof v === 'object') ? JSON.stringify(v) : `${v}`;
|
|
419
|
+
return `${k}=${s}`;
|
|
420
|
+
}).join(' ')
|
|
421
|
+
: '';
|
|
422
|
+
console.log(`[${ts}] [${scope}] ${msg}${tail}`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Default the active instance to whichever network is configured (mainnet
|
|
426
|
+
// wins if both are up, so idle background code without explicit context lands
|
|
427
|
+
// on the production network). withNetwork() overrides this per-request.
|
|
428
|
+
activateNetworkEndpoints((NETWORK_CONFIG.mainnet ?? NETWORK_CONFIG.devnet!), false);
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
// ------------------------------------------------------------
|
|
432
|
+
// HELPERS
|
|
433
|
+
// ------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
// Fetch the current slot on the specified network. Rotation-aware. Callers
|
|
436
|
+
// that need the value in a broader on-chain region should pass the network
|
|
437
|
+
// they already resolved from the zkAppAddress.
|
|
438
|
+
async function currentSlot(network: NetworkId): Promise<number> {
|
|
439
|
+
try {
|
|
440
|
+
const block = await withNetwork(network, () => fetchLastBlockWithRotation(network, 3));
|
|
441
|
+
if (!block) return 0;
|
|
442
|
+
return Number(block.globalSlotSinceGenesis.toString());
|
|
443
|
+
} catch (e: any) {
|
|
444
|
+
logUtc('currentSlot', '✗ fetchLastBlock failed', { network, reason: e?.message ?? String(e) });
|
|
445
|
+
return 0;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Return an error message if `zkAppAddress` isn't a well-formed B62q address,
|
|
450
|
+
// null otherwise. Called at request-parse time so garbage input fails with a
|
|
451
|
+
// legible 400 in a couple of ms instead of ~1 s of proof-verify CPU followed
|
|
452
|
+
// by a generic 502 from `fromBase58` throwing deep inside checkOnChainLicense.
|
|
453
|
+
function validateZkAppAddress(zkAppAddress: string): string | null {
|
|
454
|
+
try {
|
|
455
|
+
PublicKey.fromBase58(zkAppAddress);
|
|
456
|
+
return null;
|
|
457
|
+
} catch (err: any) {
|
|
458
|
+
return `Invalid zkAppAddress: ${err?.message || 'not a valid Mina B62q address'}`;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Resolve a zkAppAddress to its owning AppRecord + network + deployment
|
|
463
|
+
// slice. Walks both networks' current-live address and every entry in
|
|
464
|
+
// deploymentHistory. Under the backend-routing design a buyer who pinned an
|
|
465
|
+
// original gen-1 address stays valid forever even after N generation bumps.
|
|
466
|
+
function resolveAppAndNetwork(
|
|
467
|
+
apps: AppRecord[],
|
|
468
|
+
zkAppAddress: string,
|
|
469
|
+
): { app: AppRecord; network: NetworkId; deployment: DeploymentInfo } | null {
|
|
470
|
+
for (const app of apps) {
|
|
471
|
+
for (const net of NETWORKS) {
|
|
472
|
+
const dep = app.deployments[net];
|
|
473
|
+
if (!dep) continue;
|
|
474
|
+
if (dep.zkAppAddress === zkAppAddress ||
|
|
475
|
+
(dep.deploymentHistory ?? []).some(h => h.zkAppAddress === zkAppAddress)) {
|
|
476
|
+
return { app, network: net, deployment: dep };
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Tagged verdict from checkOnChainLicense. Kept as a discriminated union
|
|
484
|
+
// (rather than a boolean + error string) so the caller — GET / — can
|
|
485
|
+
// pick its own response envelope per failure kind without leaking HTTP
|
|
486
|
+
// concerns into the helper.
|
|
487
|
+
type OnChainLicenseCheck =
|
|
488
|
+
| { kind: 'zkapp-not-found' }
|
|
489
|
+
| { kind: 'unregistered-address' }
|
|
490
|
+
| { kind: 'vk-mismatch'; generation: number }
|
|
491
|
+
| {
|
|
492
|
+
kind: 'checked';
|
|
493
|
+
onChainRoot: string;
|
|
494
|
+
currentSlot: number;
|
|
495
|
+
zkAppVersion: number;
|
|
496
|
+
generation: number;
|
|
497
|
+
resolvedAddress: string;
|
|
498
|
+
liveRecords: LicenseRecord[];
|
|
499
|
+
result: VerifyResponse;
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
// Fetch fresh on-chain state and delegate address routing + ancestor walk
|
|
503
|
+
// to verifyLicenseWithAncestors. Resolves the target network from the
|
|
504
|
+
// zkAppAddress; unknown addresses (not in any configured network's registry)
|
|
505
|
+
// surface as `unregistered-address`. Throws only on network / RPC failure.
|
|
506
|
+
//
|
|
507
|
+
// The `generation` field echoes the *request* address's own stable
|
|
508
|
+
// generation, so a client that pinned an older address at build time sees
|
|
509
|
+
// a token whose `g` still matches its local expectation across bumps.
|
|
510
|
+
async function checkOnChainLicense(
|
|
511
|
+
zkAppAddress: string,
|
|
512
|
+
licenseHash: string,
|
|
513
|
+
): Promise<OnChainLicenseCheck> {
|
|
514
|
+
const apps = await readApps();
|
|
515
|
+
const resolved = resolveAppAndNetwork(apps, zkAppAddress);
|
|
516
|
+
if (!resolved) return { kind: 'unregistered-address' };
|
|
517
|
+
const { network, deployment } = resolved;
|
|
518
|
+
if (!NETWORK_CONFIG[network]) return { kind: 'unregistered-address' };
|
|
519
|
+
|
|
520
|
+
const historyLen = deployment.deploymentHistory?.length ?? 0;
|
|
521
|
+
const currentGeneration = historyLen + 1;
|
|
522
|
+
const currentLive = deployment.zkAppAddress;
|
|
523
|
+
|
|
524
|
+
// Echo: the request address's own stable generation. Stays consistent
|
|
525
|
+
// across bumps for any client that pinned an older address.
|
|
526
|
+
let requestGeneration = currentGeneration;
|
|
527
|
+
if (zkAppAddress !== currentLive) {
|
|
528
|
+
const idx = (deployment.deploymentHistory ?? []).findIndex((d) => d.zkAppAddress === zkAppAddress);
|
|
529
|
+
if (idx >= 0) requestGeneration = idx + 1;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Sequential (was Promise.all): the rotation helpers do Mina.setActiveInstance
|
|
533
|
+
// internally, so running fetchAccount + fetchLastBlock in parallel would race
|
|
534
|
+
// the instance swap. Same pattern as the backend sweep.
|
|
535
|
+
const records = await readRecords();
|
|
536
|
+
const { account } = await withNetwork(network, () =>
|
|
537
|
+
fetchAccountWithRotation(network, PublicKey.fromBase58(currentLive), 3));
|
|
538
|
+
const lastBlock = await withNetwork(network, () =>
|
|
539
|
+
fetchLastBlockWithRotation(network, 3));
|
|
540
|
+
if (!lastBlock) {
|
|
541
|
+
throw new Error('fetchLastBlock failed');
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const currentRoot = account?.zkapp?.appState?.[0]?.toString();
|
|
545
|
+
if (!currentRoot) return { kind: 'zkapp-not-found' };
|
|
546
|
+
// `version` is state slot 5 (licensesRoot, vendor ×2, escrowRoot,
|
|
547
|
+
// pricesPacked, version, …).
|
|
548
|
+
const zkAppVersion = Number(account?.zkapp?.appState?.[5]?.toString() ?? '0');
|
|
549
|
+
// The live contract must run one of the published circuits. Pinned by
|
|
550
|
+
// circuit, not by this app's redeploy count: a redeploy doesn't imply a
|
|
551
|
+
// new circuit (and an app deployed after a circuit bump starts at app
|
|
552
|
+
// generation 1 on the newer circuit). Fail closed when the VK is missing
|
|
553
|
+
// or unknown — previously a redeployed app had no table entry for its
|
|
554
|
+
// generation and the check was skipped entirely.
|
|
555
|
+
const onChainVk = account?.zkapp?.verificationKey?.hash?.toString();
|
|
556
|
+
if (!onChainVk || !Object.values(CANONICAL_VK_HASHES).includes(onChainVk)) {
|
|
557
|
+
return { kind: 'vk-mismatch', generation: currentGeneration };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const currentSlotNum = Number(lastBlock.globalSlotSinceGenesis.toString());
|
|
561
|
+
// verifyLicenseWithAncestors uses bare fetchAccount internally on retired
|
|
562
|
+
// addresses — wrap it in withNetwork so the active instance points at the
|
|
563
|
+
// correct network for the whole walk.
|
|
564
|
+
const walked = await withNetwork(network, () => verifyLicenseWithAncestors({
|
|
565
|
+
deployment,
|
|
566
|
+
licenseHash,
|
|
567
|
+
currentSlot: currentSlotNum,
|
|
568
|
+
currentLiveRoot: currentRoot,
|
|
569
|
+
currentLiveForkSlot: Number(account?.zkapp?.appState?.[LICENSING_APP_STATE.forkActivationSlot]?.toString() ?? '0'),
|
|
570
|
+
records,
|
|
571
|
+
}));
|
|
572
|
+
return {
|
|
573
|
+
kind: 'checked',
|
|
574
|
+
onChainRoot: walked.onChainRoot,
|
|
575
|
+
currentSlot: currentSlotNum,
|
|
576
|
+
zkAppVersion,
|
|
577
|
+
generation: requestGeneration,
|
|
578
|
+
resolvedAddress: walked.resolvedAddress,
|
|
579
|
+
liveRecords: walked.liveRecords,
|
|
580
|
+
result: walked.result,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ------------------------------------------------------------
|
|
585
|
+
// APPS REGISTRY
|
|
586
|
+
// ------------------------------------------------------------
|
|
587
|
+
|
|
588
|
+
type Deployment = {
|
|
589
|
+
zkAppAddress: string;
|
|
590
|
+
txHash?: string;
|
|
591
|
+
verificationKeyHash?: string;
|
|
592
|
+
version?: number;
|
|
593
|
+
retiredAt?: string;
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
// Per-network on-chain slot on an AppRecord. Mirrors the backend-side
|
|
597
|
+
// DeploymentInfo but only carries the fields verifyService needs (address
|
|
598
|
+
// + retirement history). Each network keeps its own history — a mainnet
|
|
599
|
+
// bump doesn't touch the devnet slot and vice-versa.
|
|
600
|
+
type DeploymentInfo = {
|
|
601
|
+
zkAppAddress: string;
|
|
602
|
+
txHash?: string;
|
|
603
|
+
verificationKeyHash?: string;
|
|
604
|
+
forkActivationSlot?: number;
|
|
605
|
+
deploymentHistory?: Deployment[];
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
type AppRecord = {
|
|
609
|
+
appId: string;
|
|
610
|
+
name: string;
|
|
611
|
+
description: string;
|
|
612
|
+
category: string;
|
|
613
|
+
website: string;
|
|
614
|
+
vendorAddress: string;
|
|
615
|
+
// Four-tier prices (MINA). 0 means the tier is not offered.
|
|
616
|
+
priceMonthlyMina: number;
|
|
617
|
+
priceYearlyMina: number;
|
|
618
|
+
priceFiveYearMina: number;
|
|
619
|
+
pricePerpetualMina: number;
|
|
620
|
+
createdAt: string;
|
|
621
|
+
// Per-network on-chain state — a single AppRecord can carry both devnet
|
|
622
|
+
// and mainnet deployment slots simultaneously. Requests route to the slot
|
|
623
|
+
// matching the request's zkAppAddress (see resolveAppAndNetwork).
|
|
624
|
+
deployments: {
|
|
625
|
+
devnet?: DeploymentInfo;
|
|
626
|
+
mainnet?: DeploymentInfo;
|
|
627
|
+
};
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
const APPS_FILE = 'apps.json';
|
|
631
|
+
|
|
632
|
+
async function readApps(): Promise<AppRecord[]> {
|
|
633
|
+
return readJsonOr<AppRecord[]>(APPS_FILE, []);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ------------------------------------------------------------
|
|
637
|
+
// EXPRESS SERVER
|
|
638
|
+
// ------------------------------------------------------------
|
|
639
|
+
|
|
640
|
+
const app = express();
|
|
641
|
+
app.use(express.json());
|
|
642
|
+
app.use((_req, res, next) => {
|
|
643
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
644
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
645
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS');
|
|
646
|
+
if (_req.method === 'OPTIONS') { res.status(204).end(); return; }
|
|
647
|
+
next();
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
// Per-request access log. One line per request: method + path + status + ms.
|
|
651
|
+
// Skips CORS preflights (already short-circuited above with 204). Path is the
|
|
652
|
+
// raw req.path — query strings are dropped so the licenseHash from `GET /`
|
|
653
|
+
// doesn't leak into logs (the redacted-prefix log on the / handler below is
|
|
654
|
+
// the canonical place to see which license was queried).
|
|
655
|
+
// Toggle off with VERIFY_ACCESS_LOG=off if the verifier ever runs behind a
|
|
656
|
+
// hot-path API where per-request lines become noise.
|
|
657
|
+
const ACCESS_LOG_ENABLED = (process.env.VERIFY_ACCESS_LOG ?? '').toLowerCase() !== 'off';
|
|
658
|
+
if (ACCESS_LOG_ENABLED) {
|
|
659
|
+
app.use((req, res, next) => {
|
|
660
|
+
const started = Date.now();
|
|
661
|
+
res.on('finish', () => {
|
|
662
|
+
logUtc('access', `${req.method} ${req.path}`, {
|
|
663
|
+
status: res.statusCode,
|
|
664
|
+
elapsed_ms: Date.now() - started,
|
|
665
|
+
});
|
|
666
|
+
});
|
|
667
|
+
next();
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const VERIFY_ADMIN_TOKEN = process.env.VERIFY_ADMIN_TOKEN ?? '';
|
|
672
|
+
|
|
673
|
+
function requireAdmin(req: Request, res: Response, next: () => void): void {
|
|
674
|
+
if (!VERIFY_ADMIN_TOKEN) {
|
|
675
|
+
res.status(503).json({ error: 'Admin endpoint disabled (VERIFY_ADMIN_TOKEN env var not set)' });
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const auth = req.headers.authorization ?? '';
|
|
679
|
+
const match = auth.match(/^Bearer\s+(.+)$/);
|
|
680
|
+
if (!match || match[1] !== VERIFY_ADMIN_TOKEN) {
|
|
681
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
next();
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Health.
|
|
688
|
+
//
|
|
689
|
+
// `networkId` per entry is the authoritative "which Mina chain" answer — a
|
|
690
|
+
// stable identifier the client can compare against exactly. `network` (the
|
|
691
|
+
// raw GraphQL URL) is kept for operator-facing observability only, since it
|
|
692
|
+
// can rotate providers (Minascan ↔ Minataur ↔ self-hosted) without the chain
|
|
693
|
+
// changing. `fallback` mirrors the same for the configured fallback endpoint.
|
|
694
|
+
app.get('/health', (_req: Request, res: Response) => {
|
|
695
|
+
const networks: Record<string, { networkId: NetworkId; network: string; fallback: string | null }> = {};
|
|
696
|
+
for (const net of NETWORKS) {
|
|
697
|
+
const cfg = NETWORK_CONFIG[net];
|
|
698
|
+
if (!cfg) continue;
|
|
699
|
+
networks[net] = {
|
|
700
|
+
networkId: cfg.networkId,
|
|
701
|
+
network: cfg.graphqlUrl,
|
|
702
|
+
fallback: cfg.graphqlFallback,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
res.json({ status: 'ok', service: 'verify', networks });
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
// Current MerkleMap root for a specific zkApp. Network-agnostic — reads local
|
|
709
|
+
// records only.
|
|
710
|
+
app.get('/root', async (req: Request, res: Response) => {
|
|
711
|
+
const zkAppAddress = (req.query.zkApp as string | undefined) ?? '';
|
|
712
|
+
if (!zkAppAddress) {
|
|
713
|
+
return res.status(400).json({ error: 'Missing zkApp query param' });
|
|
714
|
+
}
|
|
715
|
+
const records = await readRecords();
|
|
716
|
+
const map = buildMap(records, zkAppAddress);
|
|
717
|
+
res.json({ root: map.getRoot().toString() });
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
// GET /?licenseHash=...&zkAppAddress=...
|
|
722
|
+
// Primary endpoint for Android, PWA, and Node SDK license verification.
|
|
723
|
+
// Describes the on-chain state of the requested license leaf — validity,
|
|
724
|
+
// expiry slot, grace-period flag, purchaseSlot. Callers that need to prove
|
|
725
|
+
// ownership of the license (e.g. before revealing app features) do so via
|
|
726
|
+
// the passphrase-derived Pallas Schnorr keypair off-band — this endpoint is
|
|
727
|
+
// state-only.
|
|
728
|
+
app.get('/', async (req: Request, res: Response) => {
|
|
729
|
+
const { licenseHash: licenseHashStr, zkAppAddress } = req.query as {
|
|
730
|
+
licenseHash?: string;
|
|
731
|
+
zkAppAddress?: string;
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
if (!licenseHashStr || !zkAppAddress) {
|
|
735
|
+
return res.status(400).json({ error: 'Missing required query params: licenseHash, zkAppAddress' });
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Per-request verdict log. Redacts licenseHash to a 12-char prefix so logs
|
|
739
|
+
// stay pseudonymous but retain enough entropy to correlate a user's report
|
|
740
|
+
// ("my activation says invalid at 03:52") to the exact decision without
|
|
741
|
+
// grepping full hashes. resolvedAddress lets you see when an older
|
|
742
|
+
// generation was the actual match (pre-bump buyer).
|
|
743
|
+
const startedAt = Date.now();
|
|
744
|
+
const hashShort = licenseHashStr.slice(0, 12) + '…';
|
|
745
|
+
const emitVerdict = (verdict: string, reason?: string | null, resolvedAddress?: string): void => {
|
|
746
|
+
logUtc('verify', 'verdict', {
|
|
747
|
+
licenseHash: hashShort,
|
|
748
|
+
zkAppAddress: zkAppAddress.slice(0, 12) + '…',
|
|
749
|
+
resolvedAddress: resolvedAddress ? resolvedAddress.slice(0, 12) + '…' : undefined,
|
|
750
|
+
verdict,
|
|
751
|
+
reason: reason ?? undefined,
|
|
752
|
+
elapsed_ms: Date.now() - startedAt,
|
|
753
|
+
});
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
// Defense-in-depth against impersonation: the helper resolves the address's
|
|
757
|
+
// generation via backend deploymentHistory and asserts its on-chain VK equals
|
|
758
|
+
// that generation's canonical hash. Two rejection modes it covers: (a) the
|
|
759
|
+
// address is not in any registered app's deployment history — stops a
|
|
760
|
+
// genuine old circuit redeployed at a fresh address; (b) the address is
|
|
761
|
+
// listed but its VK doesn't match — a loud index-corruption tripwire.
|
|
762
|
+
try {
|
|
763
|
+
const onChain = await checkOnChainLicense(zkAppAddress, licenseHashStr);
|
|
764
|
+
switch (onChain.kind) {
|
|
765
|
+
case 'zkapp-not-found':
|
|
766
|
+
emitVerdict('zkapp-not-found');
|
|
767
|
+
return res.status(404).json({ error: 'zkApp not found on chain' });
|
|
768
|
+
case 'unregistered-address':
|
|
769
|
+
emitVerdict('unregistered-address');
|
|
770
|
+
return res.json({
|
|
771
|
+
valid: false,
|
|
772
|
+
expiresAt: null,
|
|
773
|
+
expirySlot: 0,
|
|
774
|
+
inGracePeriod: false,
|
|
775
|
+
remainingDays: 0,
|
|
776
|
+
reason: 'zkAppAddress is not in any registered app deployment history',
|
|
777
|
+
});
|
|
778
|
+
case 'vk-mismatch':
|
|
779
|
+
emitVerdict('vk-mismatch', `generation=${onChain.generation}`);
|
|
780
|
+
return res.json({
|
|
781
|
+
valid: false,
|
|
782
|
+
expiresAt: null,
|
|
783
|
+
expirySlot: 0,
|
|
784
|
+
inGracePeriod: false,
|
|
785
|
+
remainingDays: 0,
|
|
786
|
+
reason: `zkApp verification key does not match generation ${onChain.generation}'s canonical LicensingApp circuit`,
|
|
787
|
+
});
|
|
788
|
+
case 'checked': {
|
|
789
|
+
// Key on resolvedAddress (the address the license was actually found
|
|
790
|
+
// under) rather than the request address — for pre-bump buyers who
|
|
791
|
+
// never migrated, these differ: request is the retired addr the
|
|
792
|
+
// client pinned, resolved is the retired addr the record lives on.
|
|
793
|
+
const record = onChain.liveRecords.find(r => r.zkAppAddress === onChain.resolvedAddress && r.licenseHash === licenseHashStr);
|
|
794
|
+
// Collapse the VerifyResponse shape into a single-word verdict for
|
|
795
|
+
// log scanning: valid | grace | invalid. `invalid` carries the
|
|
796
|
+
// core's own reason string ("License not found", "License state
|
|
797
|
+
// does not match on-chain root", …) so the log line is enough to
|
|
798
|
+
// triage without opening the JSON body.
|
|
799
|
+
const verdict = onChain.result.valid
|
|
800
|
+
? (onChain.result.inGracePeriod ? 'grace' : 'valid')
|
|
801
|
+
: 'invalid';
|
|
802
|
+
emitVerdict(verdict, onChain.result.reason, onChain.resolvedAddress);
|
|
803
|
+
return res.json({
|
|
804
|
+
...onChain.result,
|
|
805
|
+
currentSlot: onChain.currentSlot,
|
|
806
|
+
purchaseSlot: record?.purchaseSlot ?? 0,
|
|
807
|
+
zkAppVersion: onChain.zkAppVersion,
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
} catch (err: any) {
|
|
812
|
+
emitVerdict('error', err?.message ?? String(err));
|
|
813
|
+
console.error('[verify]', err?.message ?? err);
|
|
814
|
+
res.status(500).json({ error: err?.message ?? 'Verification failed' });
|
|
815
|
+
}
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
// License map witness — composite (zkAppAddress, licenseHash) key. Network-
|
|
819
|
+
// agnostic — witness comes from the local MerkleMap of records.
|
|
820
|
+
app.get('/witness/:zkAppAddress/:licenseHash', async (req: Request, res: Response) => {
|
|
821
|
+
const { zkAppAddress, licenseHash } = req.params as { zkAppAddress: string; licenseHash: string };
|
|
822
|
+
try {
|
|
823
|
+
const records = await readRecords();
|
|
824
|
+
const map = buildMap(records, zkAppAddress);
|
|
825
|
+
const key = Field(licenseHash);
|
|
826
|
+
const witness = map.getWitness(key);
|
|
827
|
+
res.json({
|
|
828
|
+
root: map.getRoot().toString(),
|
|
829
|
+
currentValue: map.get(key).toString(),
|
|
830
|
+
...serializeWitness(witness),
|
|
831
|
+
});
|
|
832
|
+
} catch (err: any) {
|
|
833
|
+
res.status(400).json({ error: err.message });
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
// License status — composite (zkAppAddress, licenseHash) key.
|
|
838
|
+
// deviceCap + devices ride along so vendor SDKs can drive their bind flow
|
|
839
|
+
// (activate() reads these to decide whether to prompt for a bind and which
|
|
840
|
+
// slot to target). Sourced from the license record, which is the on-chain
|
|
841
|
+
// mirror populated by eventSyncLoop from DeviceBound/DeviceUnbound events.
|
|
842
|
+
app.get('/license/:zkAppAddress/:licenseHash', async (req: Request, res: Response) => {
|
|
843
|
+
const { zkAppAddress, licenseHash } = req.params as { zkAppAddress: string; licenseHash: string };
|
|
844
|
+
const record = await getLicense(zkAppAddress, licenseHash);
|
|
845
|
+
// Resolve the address's network so currentSlot hits the right chain. An
|
|
846
|
+
// unregistered address (or one whose network isn't configured here) falls
|
|
847
|
+
// back to slot 0 — status computation still returns a sensible verdict from
|
|
848
|
+
// the record's own expirySlot.
|
|
849
|
+
const apps = await readApps();
|
|
850
|
+
const resolved = resolveAppAndNetwork(apps, zkAppAddress);
|
|
851
|
+
const slot = resolved && NETWORK_CONFIG[resolved.network]
|
|
852
|
+
? await currentSlot(resolved.network)
|
|
853
|
+
: 0;
|
|
854
|
+
const fork = resolved?.deployment.zkAppAddress === zkAppAddress ? resolved.deployment.forkActivationSlot ?? 0 : 0;
|
|
855
|
+
const status = licenseStatus(record, slot, fork);
|
|
856
|
+
res.json({
|
|
857
|
+
found: record !== null,
|
|
858
|
+
expirySlot: record?.expirySlot ?? 0,
|
|
859
|
+
status,
|
|
860
|
+
txHash: record?.txHash ?? null,
|
|
861
|
+
deviceCap: record?.deviceCap ?? 0,
|
|
862
|
+
devices: record?.devices ?? [],
|
|
863
|
+
});
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
// List registered apps
|
|
867
|
+
app.get('/apps', async (_req: Request, res: Response) => {
|
|
868
|
+
const apps = await readApps();
|
|
869
|
+
res.json({ apps });
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
// POST /bootstrap — admin
|
|
873
|
+
// Body: { zkAppAddress: string }
|
|
874
|
+
// Replays all canonical events from the archive node and rebuilds licenses.json.
|
|
875
|
+
// Gated because an open archive-replay endpoint is a DoS vector (each call hits
|
|
876
|
+
// the archive node and rewrites the local store).
|
|
877
|
+
app.post('/bootstrap', requireAdmin, async (req: Request, res: Response) => {
|
|
878
|
+
const { zkAppAddress } = req.body as { zkAppAddress?: string };
|
|
879
|
+
if (!zkAppAddress) {
|
|
880
|
+
return res.status(400).json({ error: 'Missing zkAppAddress' });
|
|
881
|
+
}
|
|
882
|
+
const addrErr = validateZkAppAddress(zkAppAddress);
|
|
883
|
+
if (addrErr) return res.status(400).json({ error: addrErr });
|
|
884
|
+
try {
|
|
885
|
+
const apps = await readApps();
|
|
886
|
+
const resolved = resolveAppAndNetwork(apps, zkAppAddress);
|
|
887
|
+
if (!resolved) {
|
|
888
|
+
return res.status(400).json({ error: `zkAppAddress not in any registered app deployment history` });
|
|
889
|
+
}
|
|
890
|
+
const { network } = resolved;
|
|
891
|
+
if (!NETWORK_CONFIG[network]) {
|
|
892
|
+
return res.status(400).json({ error: `zkAppAddress belongs to ${network} but that network is not configured on this verifier` });
|
|
893
|
+
}
|
|
894
|
+
// Refuse to replay a zkApp whose immutable source-version tag doesn't match
|
|
895
|
+
// one this verifier understands. Otherwise a v2-deployed contract's events
|
|
896
|
+
// could be decoded with a v1 schema and yield subtly wrong state.
|
|
897
|
+
const onChainVersion = await withNetwork(network, () => fetchLicensingAppVersion(zkAppAddress));
|
|
898
|
+
if (onChainVersion === null) {
|
|
899
|
+
return res.status(400).json({ error: `zkApp ${zkAppAddress.slice(0, 16)}… not visible on chain` });
|
|
900
|
+
}
|
|
901
|
+
if (!SUPPORTED_LICENSING_APP_VERSIONS.includes(onChainVersion)) {
|
|
902
|
+
return res.status(400).json({
|
|
903
|
+
error: `unsupported LicensingApp version — on-chain v${onChainVersion}, this verifier supports v${SUPPORTED_LICENSING_APP_VERSIONS.join(', v')}`,
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
const cfg = NETWORK_CONFIG[network]!;
|
|
907
|
+
console.log(`[bootstrap] Replaying events for ${zkAppAddress.slice(0, 16)}… (${network} v${onChainVersion}) from ${cfg.archiveUrl}`);
|
|
908
|
+
const { events } = await withNetwork(network, () => bootstrapFromArchive(zkAppAddress));
|
|
909
|
+
const { count, warnings } = await applyEventsToStore(zkAppAddress, events);
|
|
910
|
+
for (const w of warnings) console.warn('[bootstrap]', w);
|
|
911
|
+
console.log(`[bootstrap] Done — ${count} license records written`);
|
|
912
|
+
res.json({ ok: true, count, warnings, onChainVersion });
|
|
913
|
+
} catch (err: any) {
|
|
914
|
+
console.error('[bootstrap]', err?.message ?? err);
|
|
915
|
+
res.status(500).json({ error: err?.message ?? 'Bootstrap failed' });
|
|
916
|
+
}
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
// ------------------------------------------------------------
|
|
921
|
+
// START
|
|
922
|
+
// ------------------------------------------------------------
|
|
923
|
+
|
|
924
|
+
let records = await readRecords();
|
|
925
|
+
|
|
926
|
+
// Auto-bootstrap from archive if store is empty. Iterates every configured
|
|
927
|
+
// network and each of that network's registered apps. Apps whose network
|
|
928
|
+
// isn't configured on this verifier are skipped silently.
|
|
929
|
+
if (records.length === 0) {
|
|
930
|
+
const apps = await readApps();
|
|
931
|
+
const configuredNetworks = NETWORKS.filter(n => !!NETWORK_CONFIG[n]);
|
|
932
|
+
const bootstrapPlan: Array<{ network: NetworkId; app: AppRecord; dep: DeploymentInfo }> = [];
|
|
933
|
+
for (const network of configuredNetworks) {
|
|
934
|
+
for (const app of apps) {
|
|
935
|
+
const dep = app.deployments[network];
|
|
936
|
+
if (dep) bootstrapPlan.push({ network, app, dep });
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
if (bootstrapPlan.length > 0) {
|
|
940
|
+
console.log(`📦 licenses.json is empty — bootstrapping ${bootstrapPlan.length} app deployment(s) from archive node…`);
|
|
941
|
+
for (const { network, app: appRecord, dep } of bootstrapPlan) {
|
|
942
|
+
try {
|
|
943
|
+
// Same version guard as POST /bootstrap — refuse to replay any address
|
|
944
|
+
// whose on-chain source-version tag this verifier doesn't understand.
|
|
945
|
+
const onChainVersion = await withNetwork(network, () => fetchLicensingAppVersion(dep.zkAppAddress));
|
|
946
|
+
if (onChainVersion === null) {
|
|
947
|
+
console.warn(` ⚠️ ${appRecord.name} (${network}): zkApp not visible on chain — skipping`);
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
if (!SUPPORTED_LICENSING_APP_VERSIONS.includes(onChainVersion)) {
|
|
951
|
+
console.warn(` ⚠️ ${appRecord.name} (${network}): on-chain v${onChainVersion} not supported (supports v${SUPPORTED_LICENSING_APP_VERSIONS.join(', v')}) — skipping`);
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
const { events } = await withNetwork(network, () => bootstrapFromArchive(dep.zkAppAddress));
|
|
955
|
+
const { count, warnings } = await applyEventsToStore(dep.zkAppAddress, events);
|
|
956
|
+
for (const w of warnings) console.warn('⚠️', w);
|
|
957
|
+
console.log(` ✅ ${appRecord.name} (${network} v${onChainVersion}): ${count} records`);
|
|
958
|
+
} catch (err: any) {
|
|
959
|
+
console.warn(` ⚠️ ${appRecord.name} (${network}): bootstrap failed — ${err?.message ?? err}`);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
records = await readRecords();
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
console.log(`📋 Loaded ${records.length} license records across ${new Set(records.map(r => r.zkAppAddress)).size} zkApp(s)`);
|
|
967
|
+
|
|
968
|
+
app.listen(PORT, () => {
|
|
969
|
+
console.log(`🔍 Verify service listening on http://localhost:${PORT}`);
|
|
970
|
+
console.log(` GET /?licenseHash=...&zkAppAddress=...`);
|
|
971
|
+
console.log(` GET /witness/:zkAppAddress/:licenseHash`);
|
|
972
|
+
console.log(` GET /license/:zkAppAddress/:licenseHash`);
|
|
973
|
+
console.log(` GET /apps`);
|
|
974
|
+
console.log(` POST /bootstrap`);
|
|
975
|
+
if (VERIFY_ADMIN_TOKEN) {
|
|
976
|
+
console.log(`🔒 Admin endpoints (bootstrap) enabled`);
|
|
977
|
+
} else {
|
|
978
|
+
console.log(`⚠️ Admin endpoints disabled (set VERIFY_ADMIN_TOKEN to enable)`);
|
|
979
|
+
}
|
|
980
|
+
console.log(`🔁 Lazy bootstrap on root mismatch (no periodic poller)`);
|
|
981
|
+
console.log(`📝 Access log: ${ACCESS_LOG_ENABLED ? 'ON' : 'off (VERIFY_ACCESS_LOG=off)'}`);
|
|
982
|
+
});
|