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,98 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* proveErrors.ts
|
|
6
|
+
*
|
|
7
|
+
* Shared typed error for the "proving queue busy" 409 surface returned by
|
|
8
|
+
* /prove/buy, /prove/buy-no-escrow, /prove/renew, and
|
|
9
|
+
* /prove/refund. The backend serializes all five endpoints on a per-app
|
|
10
|
+
* in-flight slot: while a proof is out with a buyer waiting to be signed
|
|
11
|
+
* (or a signed tx is waiting to land), further requests against the same
|
|
12
|
+
* app get an immediate 409 with a `retryAfterMs` hint. Callers should catch
|
|
13
|
+
* this and wait+retry rather than surfacing the failure — the wallet popup
|
|
14
|
+
* never opens for a proof with no live chance of landing.
|
|
15
|
+
*
|
|
16
|
+
* `queueDepth` is 1 in v1 (single slot per app); the field exists so that a
|
|
17
|
+
* future Tier-1 optimistic-chaining pass can widen it without breaking
|
|
18
|
+
* clients written today.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export class ProveQueueBusyError extends Error {
|
|
22
|
+
readonly status = 409;
|
|
23
|
+
readonly retryAfterMs: number;
|
|
24
|
+
readonly queueDepth: number;
|
|
25
|
+
constructor(message: string, retryAfterMs: number, queueDepth: number) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = 'ProveQueueBusyError';
|
|
28
|
+
this.retryAfterMs = retryAfterMs;
|
|
29
|
+
this.queueDepth = queueDepth;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Shared 409-body shape used by all four prove endpoints. Matches backend.
|
|
34
|
+
export type ProveQueueBusyBody = {
|
|
35
|
+
error: 'proving queue busy';
|
|
36
|
+
retryAfterMs: number;
|
|
37
|
+
queueDepth?: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Reads the response body as JSON exactly once and returns a normalized
|
|
41
|
+
// view. Callers use this in the non-2xx branch to decide the specific
|
|
42
|
+
// error surface (queue-busy, endpoint-specific, or generic).
|
|
43
|
+
export type ParsedErrorBody = {
|
|
44
|
+
status: number;
|
|
45
|
+
body: Record<string, unknown>;
|
|
46
|
+
// Convenience: extracted `body.error` when it's a string, else the HTTP fallback.
|
|
47
|
+
message: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export async function readErrorBody(resp: Response): Promise<ParsedErrorBody> {
|
|
51
|
+
let body: Record<string, unknown> = {};
|
|
52
|
+
try {
|
|
53
|
+
body = await resp.json() as Record<string, unknown>;
|
|
54
|
+
} catch { /* body wasn't JSON */ }
|
|
55
|
+
const message = typeof body.error === 'string' ? body.error : `HTTP ${resp.status}`;
|
|
56
|
+
return { status: resp.status, body, message };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A keeper error with its stable `code` and `retryable` (e.g.
|
|
61
|
+
* FEE_PAYER_MISSING, REFUND_WINDOW_CLOSED), so callers and the MCP server can
|
|
62
|
+
* act on the code rather than the wording.
|
|
63
|
+
*/
|
|
64
|
+
export class KeeperError extends Error {
|
|
65
|
+
readonly code: string;
|
|
66
|
+
readonly retryable: boolean;
|
|
67
|
+
readonly status: number;
|
|
68
|
+
/** How long to wait before retrying, when the keeper says. */
|
|
69
|
+
readonly retryAfterMs?: number;
|
|
70
|
+
constructor(message: string, code: string, retryable: boolean, status: number, retryAfterMs?: number) {
|
|
71
|
+
super(message);
|
|
72
|
+
this.name = 'KeeperError';
|
|
73
|
+
this.code = code;
|
|
74
|
+
this.retryable = retryable;
|
|
75
|
+
this.status = status;
|
|
76
|
+
if (retryAfterMs !== undefined) this.retryAfterMs = retryAfterMs;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The error a failed keeper response stands for. */
|
|
81
|
+
export function keeperError(parsed: ParsedErrorBody): KeeperError {
|
|
82
|
+
const { body } = parsed;
|
|
83
|
+
const code = typeof body.code === 'string' && body.code ? body.code : parsed.status >= 500 ? 'INTERNAL' : 'INVALID_REQUEST';
|
|
84
|
+
return new KeeperError(parsed.message, code, body.retryable === true, parsed.status, typeof body.retryAfterMs === 'number' ? body.retryAfterMs : undefined);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Returns a ProveQueueBusyError if the parsed body matches the queue-busy
|
|
88
|
+
// shape; otherwise null. Cheap to call — no I/O.
|
|
89
|
+
export function tryQueueBusyFromBody(parsed: ParsedErrorBody): ProveQueueBusyError | null {
|
|
90
|
+
if (parsed.status !== 409) return null;
|
|
91
|
+
const { body } = parsed;
|
|
92
|
+
if (body.error !== 'proving queue busy' || typeof body.retryAfterMs !== 'number') return null;
|
|
93
|
+
return new ProveQueueBusyError(
|
|
94
|
+
body.error,
|
|
95
|
+
body.retryAfterMs,
|
|
96
|
+
typeof body.queueDepth === 'number' ? body.queueDepth : 1,
|
|
97
|
+
);
|
|
98
|
+
}
|
package/src/receipt.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* receipt.ts — `zklicensing/receipt`
|
|
6
|
+
*
|
|
7
|
+
* The purchase receipt: the durable confirmation a buyer keeps (Vendor Terms
|
|
8
|
+
* §7.1). The keeper issues it when a purchase, renewal or permit payment is
|
|
9
|
+
* confirmed, with a number in the seller's own series; a refund gets a
|
|
10
|
+
* credit note (kind 'license-refund', negative amounts, `creditFor` naming
|
|
11
|
+
* the receipt it cancels) in the seller's credit-note series. It is a
|
|
12
|
+
* document of its own, next to the license or permit file:
|
|
13
|
+
* `receiptHtml` renders it as a self-contained page the buyer downloads
|
|
14
|
+
* (receiptFileName), prints or saves as PDF.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { fiatCurrencies, formatFiatList } from './units.js';
|
|
18
|
+
|
|
19
|
+
export type ReceiptKind = 'license-purchase' | 'license-renewal' | 'permit-grant' | 'permit-renewal' | 'permit-processing' | 'license-refund';
|
|
20
|
+
|
|
21
|
+
export type PurchaseReceipt = {
|
|
22
|
+
type: 'zklicensing-receipt';
|
|
23
|
+
version: 1;
|
|
24
|
+
/** The seller's series: <SERIES>-<YYYY>-<NNNNNN>, sequential per seller and year. */
|
|
25
|
+
number: string;
|
|
26
|
+
issuedAt: string;
|
|
27
|
+
network: 'devnet' | 'mainnet';
|
|
28
|
+
kind: ReceiptKind;
|
|
29
|
+
/** The seller of record: the vendor (licenses) or the permit authority. */
|
|
30
|
+
seller: {
|
|
31
|
+
legalName: string;
|
|
32
|
+
address: string;
|
|
33
|
+
country: string;
|
|
34
|
+
/** VAT number; empty when the seller has none on record. */
|
|
35
|
+
vatId: string;
|
|
36
|
+
/** Organisation number (permit authorities). */
|
|
37
|
+
registrationNumber?: string;
|
|
38
|
+
website?: string;
|
|
39
|
+
wallet: string;
|
|
40
|
+
};
|
|
41
|
+
/** Who issued the document for the seller. */
|
|
42
|
+
issuedBy: { name: string; site: string };
|
|
43
|
+
buyer: { wallet: string; country?: string };
|
|
44
|
+
item: {
|
|
45
|
+
description: string;
|
|
46
|
+
/** License: the app; permit: the registry. */
|
|
47
|
+
productId: string;
|
|
48
|
+
productName: string;
|
|
49
|
+
/** License tier or permit term, in words. */
|
|
50
|
+
term?: string;
|
|
51
|
+
/** License hash or permit subject: what the payment bought. */
|
|
52
|
+
reference: string;
|
|
53
|
+
};
|
|
54
|
+
amount: {
|
|
55
|
+
/** 'tMINA' on devnet (test MINA, no value); receipts from before carry 'MINA'. */
|
|
56
|
+
currency: 'MINA' | 'tMINA';
|
|
57
|
+
total: string;
|
|
58
|
+
/** null: VAT not itemised (see `vatNote`). */
|
|
59
|
+
vatRatePct: number | null;
|
|
60
|
+
vat: string | null;
|
|
61
|
+
net: string | null;
|
|
62
|
+
vatCountry?: string;
|
|
63
|
+
vatNote?: string;
|
|
64
|
+
/** For reference: MINA→EUR on the day, and (mainnet only) the total in euros. */
|
|
65
|
+
eurRate?: number;
|
|
66
|
+
totalEur?: string;
|
|
67
|
+
/**
|
|
68
|
+
* The total in euros, dollars and pounds at the day's MINA rates (and the
|
|
69
|
+
* rates). On devnet (`basis: 'mainnet-reference'`) it is what the same
|
|
70
|
+
* amount of mainnet MINA would have been worth: tMINA has no value.
|
|
71
|
+
*/
|
|
72
|
+
fiat?: {
|
|
73
|
+
basis: 'paid' | 'mainnet-reference';
|
|
74
|
+
rates: { eur: number; usd: number; gbp: number };
|
|
75
|
+
totals: { eur: string; usd: string; gbp: string };
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
payment: { txHash: string; method: 'Mina blockchain' };
|
|
79
|
+
/** Set when the transaction failed on chain or never landed: nothing was paid, and the document is void. */
|
|
80
|
+
voided?: { at: string; reason: string };
|
|
81
|
+
/** Withdrawal waiver, refund window and similar terms that apply to this purchase. */
|
|
82
|
+
terms: string[];
|
|
83
|
+
/** Credit notes: the receipt this one cancels. */
|
|
84
|
+
creditFor?: { number: string; txHash: string };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const isCreditNote = (r: PurchaseReceipt) => r.kind === 'license-refund';
|
|
88
|
+
|
|
89
|
+
/** The download name for one receipt or credit note (or several: `receipts-<first number>.html`). */
|
|
90
|
+
export function receiptFileName(receipts: PurchaseReceipt | PurchaseReceipt[]): string {
|
|
91
|
+
const list = Array.isArray(receipts) ? receipts : [receipts];
|
|
92
|
+
const safe = (s: string) => s.replace(/[^A-Za-z0-9-]+/g, '-');
|
|
93
|
+
if (list.length === 1) return `${isCreditNote(list[0]) ? 'credit-note' : 'receipt'}-${safe(list[0].number)}.html`;
|
|
94
|
+
return `receipts-${safe(list[0]?.number ?? 'none')}.html`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const esc = (s: unknown) => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!));
|
|
98
|
+
|
|
99
|
+
/** A self-contained printable page (no scripts) for one or more receipts. */
|
|
100
|
+
export function receiptHtml(receipts: PurchaseReceipt | PurchaseReceipt[]): string {
|
|
101
|
+
const list = Array.isArray(receipts) ? receipts : [receipts];
|
|
102
|
+
const one = (r: PurchaseReceipt) => {
|
|
103
|
+
const a = r.amount;
|
|
104
|
+
// Devnet: test MINA (tMINA), whatever an older receipt's currency says.
|
|
105
|
+
const unit = r.network === 'mainnet' ? a.currency : 'tMINA';
|
|
106
|
+
const test = r.network !== 'mainnet' ? `<div class="banner">TEST (${esc(r.network)}): amounts in tMINA, test MINA with no value; not a tax document</div>` : '';
|
|
107
|
+
const rows = [
|
|
108
|
+
['Net', a.net !== null ? `${esc(a.net)} ${unit}` : '—'],
|
|
109
|
+
[a.vatRatePct !== null ? `VAT ${esc(a.vatCountry ?? '')} ${esc(a.vatRatePct)}%` : 'VAT', a.vat !== null ? `${esc(a.vat)} ${unit}` : esc(a.vatNote ?? 'not itemised')],
|
|
110
|
+
];
|
|
111
|
+
// Money in the buyer's currencies: euros in the EU, pounds and euros in
|
|
112
|
+
// the UK, dollars in the US, euros and dollars elsewhere (fiatCurrencies).
|
|
113
|
+
const f = a.fiat;
|
|
114
|
+
const cur = fiatCurrencies(r.buyer.country);
|
|
115
|
+
const money = esc(f ? formatFiatList(f.totals, cur) : '');
|
|
116
|
+
const rates = f ? esc(`1 MINA = ${formatFiatList(f.rates, cur, 4)}`) : '';
|
|
117
|
+
const paidWord = isCreditNote(r) ? 'refunded' : 'paid';
|
|
118
|
+
const eur = unit === 'tMINA'
|
|
119
|
+
? (f
|
|
120
|
+
? `In real MINA this would be ≈ ${money} (${rates} on mainnet that day). tMINA has no value: nothing was ${paidWord} in money.`
|
|
121
|
+
: a.eurRate ? `tMINA has no value. For reference, 1 MINA on mainnet ≈ €${esc(a.eurRate)}.` : '')
|
|
122
|
+
: (f
|
|
123
|
+
? `≈ ${money} at the day's rate${cur.length > 1 ? 's' : ''} (${rates}).`
|
|
124
|
+
: a.totalEur ? `≈ €${esc(a.totalEur)} at ${esc(a.eurRate)} €/MINA.` : '');
|
|
125
|
+
return `<section class="receipt">${test}
|
|
126
|
+
<h1>${isCreditNote(r) ? 'Credit note' : 'Receipt'} ${esc(r.number)}</h1>
|
|
127
|
+
<div class="dim">${esc(r.issuedAt.slice(0, 10))} · ${esc(r.kind.replace('-', ' '))} · ${esc(r.network)}</div>
|
|
128
|
+
${r.voided ? `<p><strong>VOID</strong>: ${esc(r.voided.reason)} Nothing was paid.</p>` : ''}
|
|
129
|
+
${r.creditFor ? `<p>Cancels receipt <strong>${esc(r.creditFor.number)}</strong> (transaction <span class="mono">${esc(r.creditFor.txHash)}</span>).</p>` : ''}
|
|
130
|
+
<div class="cols">
|
|
131
|
+
<div><strong>Seller</strong><br>${esc(r.seller.legalName)}<br>${esc(r.seller.address)}<br>${r.seller.vatId ? `VAT ${esc(r.seller.vatId)}` : 'Not VAT-registered'}${r.seller.registrationNumber ? `<br>Organisation number ${esc(r.seller.registrationNumber)}` : ''}${r.seller.website ? `<br>${esc(r.seller.website)}` : ''}<br><span class="dim">wallet ${esc(r.seller.wallet)}</span></div>
|
|
132
|
+
<div><strong>Buyer</strong><br><span class="dim">wallet</span> ${esc(r.buyer.wallet)}${r.buyer.country ? `<br>Country ${esc(r.buyer.country)}` : ''}</div>
|
|
133
|
+
</div>
|
|
134
|
+
<table>
|
|
135
|
+
<tr><th>Item</th><th class="r">Total</th></tr>
|
|
136
|
+
<tr><td>${esc(r.item.description)}<br><span class="dim">${esc(r.item.productName)} (${esc(r.item.productId)})${r.item.term ? ` · ${esc(r.item.term)}` : ''}<br>ref ${esc(r.item.reference)}</span></td><td class="r">${esc(a.total)} ${unit}</td></tr>
|
|
137
|
+
${rows.map(([k, v]) => `<tr class="sub"><td>${k}</td><td class="r">${v}</td></tr>`).join('')}
|
|
138
|
+
<tr class="tot"><td>${isCreditNote(r) ? 'Total refunded' : 'Total paid'}</td><td class="r">${esc(a.total)} ${unit}</td></tr>
|
|
139
|
+
</table>
|
|
140
|
+
${eur ? `<p class="dim fiat">${eur}</p>` : ''}
|
|
141
|
+
<p>${isCreditNote(r) ? 'Refunded' : 'Paid'} on the ${esc(r.payment.method)}, transaction <span class="mono">${esc(r.payment.txHash)}</span>.</p>
|
|
142
|
+
${r.terms.length ? `<ul>${r.terms.map(t => `<li>${esc(t)}</li>`).join('')}</ul>` : ''}
|
|
143
|
+
<p class="dim">Issued by ${esc(r.issuedBy.name)} (${esc(r.issuedBy.site)}) on behalf of the seller.</p>
|
|
144
|
+
</section>`;
|
|
145
|
+
};
|
|
146
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${esc(list.length === 1 ? `${isCreditNote(list[0]) ? 'Credit note' : 'Receipt'} ${list[0].number}` : 'Receipts')}</title>
|
|
147
|
+
<style>
|
|
148
|
+
body { font: 13px/1.45 system-ui, sans-serif; color: #111; margin: 32px; max-width: 760px; }
|
|
149
|
+
h1 { font-size: 20px; margin: 0 0 4px; } .dim { color: #666; font-size: 11px; } .mono { font-family: ui-monospace, monospace; font-size: 11px; word-break: break-all; }
|
|
150
|
+
.cols { display: flex; gap: 48px; margin: 20px 0; } .cols div { flex: 1; word-break: break-word; }
|
|
151
|
+
table { width: 100%; border-collapse: collapse; margin-top: 12px; } th, td { padding: 6px 8px; border-bottom: 1px solid #ddd; vertical-align: top; text-align: left; }
|
|
152
|
+
th { font-size: 11px; color: #555; } .r { text-align: right; white-space: nowrap; } .sub td { color: #444; } .tot td { font-weight: 700; border-top: 2px solid #111; }
|
|
153
|
+
.banner { padding: 8px 12px; background: #fff3cd; border: 1px solid #e0c060; margin-bottom: 16px; }
|
|
154
|
+
.receipt + .receipt { page-break-before: always; margin-top: 48px; }
|
|
155
|
+
</style></head><body>${list.map(one).join('\n')}</body></html>`;
|
|
156
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* refundClient.ts
|
|
6
|
+
*
|
|
7
|
+
* Browser-safe client for the refund flow. Mirrors `buyClient.ts` in shape:
|
|
8
|
+
* the caller drives the wallet step between `proveRefund` and
|
|
9
|
+
* `confirmRefund` so the SDK stays wallet-agnostic.
|
|
10
|
+
*
|
|
11
|
+
* The 14-day refund window is enforced by the backend (returns HTTP 409
|
|
12
|
+
* when closed) and cross-checked in the circuit; the SDK doesn't second-guess
|
|
13
|
+
* it locally so a single source of truth stays authoritative.
|
|
14
|
+
*
|
|
15
|
+
* `/prove/refund` shares the per-app in-flight slot with buy/renew:
|
|
16
|
+
* a concurrent request for the same app receives 409 { error: "proving queue
|
|
17
|
+
* busy", retryAfterMs, queueDepth }, which this client classifies into a
|
|
18
|
+
* `ProveQueueBusyError` so callers can wait+retry without opening a wallet.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readErrorBody, tryQueueBusyFromBody, keeperError } from './proveErrors.js';
|
|
22
|
+
import type { PurchaseReceipt } from './receipt.js';
|
|
23
|
+
|
|
24
|
+
export type ProveRefundRequest = {
|
|
25
|
+
keeperUrl: string;
|
|
26
|
+
licenseHash: string;
|
|
27
|
+
buyerAddress: string;
|
|
28
|
+
zkAppAddress: string;
|
|
29
|
+
// Mina network this refund targets. Pull from your persisted ProofFile
|
|
30
|
+
// so it always matches the network the original buy was proved against.
|
|
31
|
+
network: 'devnet' | 'mainnet';
|
|
32
|
+
fetcher?: typeof fetch;
|
|
33
|
+
signal?: AbortSignal;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type ProveRefundResponse = {
|
|
37
|
+
provenTxJson: string;
|
|
38
|
+
licenseHash: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type ConfirmRefundRequest = {
|
|
42
|
+
keeperUrl: string;
|
|
43
|
+
zkAppAddress: string;
|
|
44
|
+
licenseHash: string;
|
|
45
|
+
txHash: string;
|
|
46
|
+
fetcher?: typeof fetch;
|
|
47
|
+
signal?: AbortSignal;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function baseUrl(url: string): string {
|
|
51
|
+
return url.replace(/\/+$/, '');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// POST /prove/refund — backend compiles + proves the refund tx.
|
|
55
|
+
// The backend 409s when the 14-day window has closed (surfaces through the
|
|
56
|
+
// thrown `Error` message) and also when the shared per-app in-flight slot
|
|
57
|
+
// is held by a concurrent buy/renew/refund (surfaces as a typed
|
|
58
|
+
// `ProveQueueBusyError` — caller waits `retryAfterMs` and retries).
|
|
59
|
+
export async function proveRefund(req: ProveRefundRequest): Promise<ProveRefundResponse> {
|
|
60
|
+
const fetcher = req.fetcher ?? fetch;
|
|
61
|
+
const resp = await fetcher(`${baseUrl(req.keeperUrl)}/prove/refund`, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { 'Content-Type': 'application/json' },
|
|
64
|
+
signal: req.signal,
|
|
65
|
+
body: JSON.stringify({
|
|
66
|
+
licenseHash: req.licenseHash,
|
|
67
|
+
buyerAddress: req.buyerAddress,
|
|
68
|
+
zkAppAddress: req.zkAppAddress,
|
|
69
|
+
network: req.network,
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
72
|
+
if (!resp.ok) {
|
|
73
|
+
const parsed = await readErrorBody(resp);
|
|
74
|
+
const queueBusy = tryQueueBusyFromBody(parsed);
|
|
75
|
+
if (queueBusy) throw queueBusy;
|
|
76
|
+
throw keeperError(parsed);
|
|
77
|
+
}
|
|
78
|
+
return await resp.json() as ProveRefundResponse;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// POST /confirm/refund — promotes the in-memory refund intent to a
|
|
82
|
+
// pending (refunded: true) LicenseRecord. Best-effort: if the caller
|
|
83
|
+
// swallows the failure, eventSyncLoop will still pick up the on-chain
|
|
84
|
+
// refund event.
|
|
85
|
+
/** Returns the credit note cancelling the purchase receipt (zklicensing/receipt), when the keeper issued one: offer it as a download. */
|
|
86
|
+
export async function confirmRefund(req: ConfirmRefundRequest): Promise<{ ok: true; creditNote?: PurchaseReceipt }> {
|
|
87
|
+
const fetcher = req.fetcher ?? fetch;
|
|
88
|
+
const resp = await fetcher(`${baseUrl(req.keeperUrl)}/confirm/refund`, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'Content-Type': 'application/json' },
|
|
91
|
+
signal: req.signal,
|
|
92
|
+
body: JSON.stringify({
|
|
93
|
+
zkAppAddress: req.zkAppAddress,
|
|
94
|
+
licenseHash: req.licenseHash,
|
|
95
|
+
txHash: req.txHash,
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
if (!resp.ok) {
|
|
99
|
+
const parsed = await readErrorBody(resp);
|
|
100
|
+
throw keeperError(parsed);
|
|
101
|
+
}
|
|
102
|
+
const body = await resp.json().catch(() => ({})) as { creditNote?: PurchaseReceipt };
|
|
103
|
+
return { ok: true, ...(body?.creditNote ? { creditNote: body.creditNote } : {}) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type RefundStatus = {
|
|
107
|
+
// 'confirmed' once the eventSync sweep has observed the refundIssued event
|
|
108
|
+
// on-chain AND cleared the pending flag. 'pending' while the tx is in
|
|
109
|
+
// flight (confirmRefund flips record.status to 'refunded' immediately but
|
|
110
|
+
// leaves pending: true until the on-chain sweep lands, so both signals
|
|
111
|
+
// are needed to distinguish an intent from an actual on-chain refund).
|
|
112
|
+
// 'unknown' if no record has been persisted yet. 'failed' if the keeper
|
|
113
|
+
// saw the transaction included but failed on chain (failureReason says
|
|
114
|
+
// why): the license is as it was, not refunded; terminal.
|
|
115
|
+
status: 'confirmed' | 'pending' | 'unknown' | 'failed';
|
|
116
|
+
failureReason?: string;
|
|
117
|
+
txHash?: string | null;
|
|
118
|
+
elapsedMs?: number;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export type PollRefundStatusOptions = {
|
|
122
|
+
keeperUrl: string;
|
|
123
|
+
network: 'devnet' | 'mainnet';
|
|
124
|
+
zkAppAddress: string;
|
|
125
|
+
licenseHash: string;
|
|
126
|
+
fetcher?: typeof fetch;
|
|
127
|
+
intervalMs?: number; // default 10 000
|
|
128
|
+
timeoutMs?: number; // default 20 min
|
|
129
|
+
signal?: AbortSignal;
|
|
130
|
+
onTick?: (status: RefundStatus) => void;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Polls GET /license/:zkAppAddress/:licenseHash until the refund has landed
|
|
134
|
+
// on-chain (record.status === 'refunded') or the timeout fires. Mirrors
|
|
135
|
+
// pollBuyStatus / pollRenewStatus.
|
|
136
|
+
export async function pollRefundStatus(opts: PollRefundStatusOptions): Promise<RefundStatus> {
|
|
137
|
+
const fetcher = opts.fetcher ?? fetch;
|
|
138
|
+
const interval = opts.intervalMs ?? 10_000;
|
|
139
|
+
const timeout = opts.timeoutMs ?? 20 * 60 * 1000;
|
|
140
|
+
const startedAt = Date.now();
|
|
141
|
+
const deadline = startedAt + timeout;
|
|
142
|
+
const url = `${baseUrl(opts.keeperUrl)}/license/${encodeURIComponent(opts.zkAppAddress)}/${encodeURIComponent(opts.licenseHash)}?network=${opts.network}`;
|
|
143
|
+
|
|
144
|
+
while (true) {
|
|
145
|
+
if (opts.signal?.aborted) throw new Error('pollRefundStatus aborted');
|
|
146
|
+
let status: RefundStatus = { status: 'unknown', elapsedMs: Date.now() - startedAt };
|
|
147
|
+
try {
|
|
148
|
+
const resp = await fetcher(url, { signal: opts.signal });
|
|
149
|
+
if (resp.ok) {
|
|
150
|
+
const body = await resp.json() as {
|
|
151
|
+
found?: boolean; status?: 'active' | 'refunded' | 'expired' | 'unknown';
|
|
152
|
+
pending?: boolean; txHash?: string | null;
|
|
153
|
+
};
|
|
154
|
+
if (!body.found) {
|
|
155
|
+
status = { status: 'unknown', elapsedMs: Date.now() - startedAt };
|
|
156
|
+
} else if (body.status === 'refunded' && body.pending === false) {
|
|
157
|
+
status = { status: 'confirmed', txHash: body.txHash ?? null, elapsedMs: Date.now() - startedAt };
|
|
158
|
+
} else {
|
|
159
|
+
status = { status: 'pending', txHash: body.txHash ?? null, elapsedMs: Date.now() - startedAt };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} catch { /* transient network — try again on next tick */ }
|
|
163
|
+
// Not confirmed yet: the keeper's landing watch says whether the
|
|
164
|
+
// transaction failed on chain (a keeper without the route: 404, ignored).
|
|
165
|
+
if (status.status !== 'confirmed') {
|
|
166
|
+
try {
|
|
167
|
+
const w = await fetcher(`${baseUrl(opts.keeperUrl)}/refund/status?licenseHash=${encodeURIComponent(opts.licenseHash)}`, { signal: opts.signal });
|
|
168
|
+
const watch = w.ok ? await w.json() as { status?: string; failureReason?: string } : null;
|
|
169
|
+
if (watch?.status === 'failed') status = { ...status, status: 'failed', ...(watch.failureReason ? { failureReason: watch.failureReason } : {}) };
|
|
170
|
+
} catch { /* transient — try again on next tick */ }
|
|
171
|
+
}
|
|
172
|
+
opts.onTick?.(status);
|
|
173
|
+
if (status.status === 'confirmed' || status.status === 'failed') return status;
|
|
174
|
+
if (Date.now() >= deadline) return status;
|
|
175
|
+
await new Promise<void>((r) => setTimeout(r, interval));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* registryTrustVerify.ts — `zklicensing/permits/trust`
|
|
6
|
+
*
|
|
7
|
+
* Is this address a genuine, unmodifiable zkPermits registry? The permits
|
|
8
|
+
* counterpart of verifyTrustAnchor, used by `zklicensing-verify-trust
|
|
9
|
+
* --registry`. Each check is a discrete finding:
|
|
10
|
+
*
|
|
11
|
+
* chain_account_exists the account exists and is a zkApp
|
|
12
|
+
* chain_vk_hash_pinned its verification key is a published PermitRegistry release
|
|
13
|
+
* chain_initialized authority keys are set (initialize ran)
|
|
14
|
+
* chain_network_bound the registry is bound to this network
|
|
15
|
+
* chain_set_verification_key_locked the circuit can't be replaced
|
|
16
|
+
* chain_set_permissions_impossible permissions are frozen
|
|
17
|
+
* chain_send_proof_only funds move only through the circuit
|
|
18
|
+
* chain_edit_state_proof_only state changes only through the circuit
|
|
19
|
+
* chain_set_zkapp_uri_impossible, chain_set_delegate_impossible, chain_set_timing_impossible
|
|
20
|
+
* chain_website_bound the authority's https website is written into zkappUri (set at deploy, frozen)
|
|
21
|
+
* website_expected (with website) that website is on the host you expected
|
|
22
|
+
* website_claims_registry (with checkWellKnown) the website serves
|
|
23
|
+
* /.well-known/zkpermits/<address>.json naming this registry and network
|
|
24
|
+
* keeper_keys_match (with keeperUrl) the keeper's authority keys hash to the on-chain value
|
|
25
|
+
* keeper_payout_match (with keeperUrl) the keeper's payout address is the on-chain one
|
|
26
|
+
* keeper_website_match (with keeperUrl) the keeper lists the same website
|
|
27
|
+
*
|
|
28
|
+
* The website pair is the registry's identity: zkappUri says which site the
|
|
29
|
+
* registry belongs to, and the .well-known file, which only that site's
|
|
30
|
+
* owner can publish, says the site claims the registry. A copy of a real
|
|
31
|
+
* authority's registry under another address can't have both.
|
|
32
|
+
*
|
|
33
|
+
* Until the circuit is released after its audit, no verification-key hash
|
|
34
|
+
* is published, and chain_vk_hash_pinned fails by design.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import type { Finding } from './trustAnchorVerify.js';
|
|
38
|
+
export { permToString } from './trustAnchorVerify.js';
|
|
39
|
+
|
|
40
|
+
export type RegistryChainFacts = {
|
|
41
|
+
accountFound: boolean;
|
|
42
|
+
fetchError?: string;
|
|
43
|
+
vkHash?: string;
|
|
44
|
+
/** The website written at deploy. */
|
|
45
|
+
zkappUri?: string;
|
|
46
|
+
/** appState as decimal strings. */
|
|
47
|
+
appState?: string[];
|
|
48
|
+
/** Permission names as permToString gives them ('Impossible', 'Proof', …). */
|
|
49
|
+
permissions?: Record<'setVerificationKey' | 'setPermissions' | 'send' | 'editState' | 'setZkappUri' | 'setDelegate' | 'setTiming', string | undefined>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type RegistryTrustReport = { ok: boolean; address: string; network: 'devnet' | 'mainnet'; findings: Finding[] };
|
|
53
|
+
|
|
54
|
+
export type VerifyRegistryTrustOpts = {
|
|
55
|
+
address: string;
|
|
56
|
+
network: 'devnet' | 'mainnet';
|
|
57
|
+
/** Mina node GraphQL; default the public node for the network. */
|
|
58
|
+
minaGraphqlUrl?: string;
|
|
59
|
+
/** Also compare the keeper's record with the chain. */
|
|
60
|
+
keeperUrl?: string;
|
|
61
|
+
fetcher?: typeof fetch;
|
|
62
|
+
/** Override the chain read (tests, custom nodes). */
|
|
63
|
+
readChain?: (address: string) => Promise<RegistryChainFacts>;
|
|
64
|
+
/** The authority's website you expect (from its letters, signage or your own bookmarks); compared by host. */
|
|
65
|
+
website?: string;
|
|
66
|
+
/** Fetch the website's /.well-known/zkpermits/<address>.json and check it claims this registry. */
|
|
67
|
+
checkWellKnown?: boolean;
|
|
68
|
+
/** Override the published hashes (tests). */
|
|
69
|
+
canonicalVkHashes?: Readonly<Record<number, string>>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Where an authority's website claims a registry: one file per registry address. */
|
|
73
|
+
export function wellKnownPath(address: string): string {
|
|
74
|
+
return `/.well-known/zkpermits/${address}.json`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The file an authority publishes at wellKnownPath(address) on its website. */
|
|
78
|
+
export function wellKnownFile(address: string, network: 'devnet' | 'mainnet', name?: string): { registry: string; network: 'devnet' | 'mainnet'; name?: string } {
|
|
79
|
+
return { registry: address, network, ...(name ? { name } : {}) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function httpsUrl(v: string | undefined): URL | null {
|
|
83
|
+
if (!v) return null;
|
|
84
|
+
try { const u = new URL(v); return u.protocol === 'https:' && u.hostname && !u.username && !u.password ? u : null; } catch { return null; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function readRegistryChain(address: string, graphqlUrl: string, fetcher?: typeof fetch): Promise<RegistryChainFacts> {
|
|
88
|
+
try {
|
|
89
|
+
const { readZkappAccount } = await import('./chainHead.js');
|
|
90
|
+
const a = await readZkappAccount({ address, graphqlUrl, fetcher });
|
|
91
|
+
if (!a.found) return { accountFound: false, fetchError: 'no account' };
|
|
92
|
+
const p = a.permissions ?? {};
|
|
93
|
+
return {
|
|
94
|
+
accountFound: true,
|
|
95
|
+
vkHash: a.vkHash,
|
|
96
|
+
zkappUri: a.zkappUri,
|
|
97
|
+
appState: a.appState,
|
|
98
|
+
permissions: {
|
|
99
|
+
setVerificationKey: p.setVerificationKey?.auth,
|
|
100
|
+
setPermissions: p.setPermissions,
|
|
101
|
+
send: p.send,
|
|
102
|
+
editState: p.editState,
|
|
103
|
+
setZkappUri: p.setZkappUri,
|
|
104
|
+
setDelegate: p.setDelegate,
|
|
105
|
+
setTiming: p.setTiming,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
} catch (e: any) {
|
|
109
|
+
return { accountFound: false, fetchError: e?.message ?? String(e) };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function verifyRegistryTrust(opts: VerifyRegistryTrustOpts): Promise<RegistryTrustReport> {
|
|
114
|
+
const { DEFAULT_MINA_GRAPHQL_URLS } = await import('./onChainRoot.js');
|
|
115
|
+
const { PERMIT_REGISTRY_STATE, CANONICAL_PERMIT_VK_HASHES } = await import('./permitInterface.js');
|
|
116
|
+
const findings: Finding[] = [];
|
|
117
|
+
const add = (name: string, ok: boolean, detail: string) => findings.push({ name, ok, detail });
|
|
118
|
+
const done = (): RegistryTrustReport => ({ ok: findings.every(f => f.ok), address: opts.address, network: opts.network, findings });
|
|
119
|
+
|
|
120
|
+
const read = opts.readChain ?? ((a: string) => readRegistryChain(a, opts.minaGraphqlUrl ?? DEFAULT_MINA_GRAPHQL_URLS[opts.network], opts.fetcher));
|
|
121
|
+
const chain = await read(opts.address);
|
|
122
|
+
add('chain_account_exists', chain.accountFound && !!chain.vkHash, chain.accountFound ? (chain.vkHash ? 'zkApp account found' : 'account has no verification key (not a zkApp)') : `not found: ${chain.fetchError ?? ''}`);
|
|
123
|
+
if (!chain.accountFound || !chain.vkHash) return done();
|
|
124
|
+
|
|
125
|
+
const pinned = opts.canonicalVkHashes ?? CANONICAL_PERMIT_VK_HASHES;
|
|
126
|
+
const gen = Object.entries(pinned).find(([, h]) => h === chain.vkHash)?.[0];
|
|
127
|
+
add('chain_vk_hash_pinned', gen !== undefined,
|
|
128
|
+
gen !== undefined ? `verification key is PermitRegistry release generation ${gen}`
|
|
129
|
+
: Object.keys(pinned).length === 0 ? 'no PermitRegistry verification-key hash is published yet (pre-release)'
|
|
130
|
+
: `verification key ${chain.vkHash} is not a published PermitRegistry release`);
|
|
131
|
+
|
|
132
|
+
const st = (k: keyof typeof PERMIT_REGISTRY_STATE) => chain.appState?.[PERMIT_REGISTRY_STATE[k]] ?? '0';
|
|
133
|
+
add('chain_initialized', st('authorityKeysHash') !== '0', st('authorityKeysHash') !== '0' ? 'authority keys set' : 'authority keys not set: initialize has not run');
|
|
134
|
+
const { PERMIT_NETWORK } = await import('./permitMessages.js');
|
|
135
|
+
const want = PERMIT_NETWORK[opts.network].toString();
|
|
136
|
+
add('chain_network_bound', st('networkId') === want, st('networkId') === want ? `bound to ${opts.network}` : `bound to network id ${st('networkId')}, expected ${want} (${opts.network})`);
|
|
137
|
+
|
|
138
|
+
const perm = (name: string, key: keyof NonNullable<RegistryChainFacts['permissions']>, expected: string) => {
|
|
139
|
+
const got = chain.permissions?.[key];
|
|
140
|
+
add(name, got === expected, `${key} is ${got ?? 'unknown'}${got === expected ? '' : `, expected ${expected}`}`);
|
|
141
|
+
};
|
|
142
|
+
perm('chain_set_verification_key_locked', 'setVerificationKey', 'Impossible');
|
|
143
|
+
perm('chain_set_permissions_impossible', 'setPermissions', 'Impossible');
|
|
144
|
+
perm('chain_send_proof_only', 'send', 'Proof');
|
|
145
|
+
perm('chain_edit_state_proof_only', 'editState', 'Proof');
|
|
146
|
+
perm('chain_set_zkapp_uri_impossible', 'setZkappUri', 'Impossible');
|
|
147
|
+
perm('chain_set_delegate_impossible', 'setDelegate', 'Impossible');
|
|
148
|
+
perm('chain_set_timing_impossible', 'setTiming', 'Impossible');
|
|
149
|
+
|
|
150
|
+
const site = httpsUrl(chain.zkappUri);
|
|
151
|
+
add('chain_website_bound', !!site, site ? `belongs to ${chain.zkappUri}` : chain.zkappUri ? `zkappUri "${chain.zkappUri}" is not an https website` : 'no website written at deploy (zkappUri is empty)');
|
|
152
|
+
if (opts.website !== undefined) {
|
|
153
|
+
const want = httpsUrl(opts.website) ?? httpsUrl(`https://${opts.website}`);
|
|
154
|
+
const same = !!site && !!want && site.hostname.toLowerCase() === want.hostname.toLowerCase();
|
|
155
|
+
add('website_expected', same, same ? `on ${want!.hostname}, as expected` : `the registry belongs to ${site?.hostname ?? 'no website'}, not ${want?.hostname ?? opts.website}`);
|
|
156
|
+
}
|
|
157
|
+
if (opts.checkWellKnown) {
|
|
158
|
+
const url = site ? new URL(wellKnownPath(opts.address), site.origin).href : null;
|
|
159
|
+
if (!url) add('website_claims_registry', false, 'no website to ask');
|
|
160
|
+
else {
|
|
161
|
+
try {
|
|
162
|
+
const r = await (opts.fetcher ?? fetch)(url, { redirect: 'error', headers: { Accept: 'application/json' } });
|
|
163
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
164
|
+
const j: any = await r.json();
|
|
165
|
+
const claims = j?.registry === opts.address && j?.network === opts.network;
|
|
166
|
+
add('website_claims_registry', claims, claims ? `${url} claims this registry on ${opts.network}`
|
|
167
|
+
: `${url} names ${j?.registry ?? 'no registry'} on ${j?.network ?? 'no network'}`);
|
|
168
|
+
} catch (e: any) {
|
|
169
|
+
add('website_claims_registry', false, `${url}: ${e?.message ?? e}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (opts.keeperUrl) {
|
|
175
|
+
try {
|
|
176
|
+
const r = await (opts.fetcher ?? fetch)(`${opts.keeperUrl.replace(/\/+$/, '')}/permits/registries/${opts.address}`);
|
|
177
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
178
|
+
const { registry } = await r.json() as { registry: { keys: string[]; threshold: number; payout: string; website?: string } };
|
|
179
|
+
const { PublicKey, Field } = await import('o1js');
|
|
180
|
+
const { authorityKeys, authorityKeysHash } = await import('./permitMessages.js');
|
|
181
|
+
const h = authorityKeysHash(authorityKeys(registry.keys.map(k => PublicKey.fromBase58(k)), registry.threshold)).toString();
|
|
182
|
+
add('keeper_keys_match', h === st('authorityKeysHash'), h === st('authorityKeysHash')
|
|
183
|
+
? `keeper lists ${registry.keys.length} keys, threshold ${registry.threshold}, matching the chain` : 'the keeper\'s authority keys do not match the chain (stale or wrong record)');
|
|
184
|
+
const payout = PublicKey.fromFields([Field(st('payoutX')), Field(st('payoutIsOdd'))]).toBase58();
|
|
185
|
+
add('keeper_payout_match', payout === registry.payout, payout === registry.payout ? `payout ${payout}` : `chain pays ${payout}, keeper lists ${registry.payout}`);
|
|
186
|
+
add('keeper_website_match', registry.website === chain.zkappUri, registry.website === chain.zkappUri ? `website ${registry.website}` : `chain says ${chain.zkappUri ?? 'none'}, keeper lists ${registry.website ?? 'none'}`);
|
|
187
|
+
} catch (e: any) {
|
|
188
|
+
add('keeper_keys_match', false, `keeper record unavailable: ${e?.message ?? e}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return done();
|
|
192
|
+
}
|