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,234 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
3
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* trustAnchorVerifyCli.ts
|
|
7
|
+
*
|
|
8
|
+
* Command-line front-end for verifyTrustAnchor(). Ships as the `zklicensing-verify-trust`
|
|
9
|
+
* bin — the sibling of `zklicensing-verify` (which is the verifyService HTTP daemon).
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* zklicensing-verify-trust \
|
|
13
|
+
* --origin https://store.example.com \
|
|
14
|
+
* --address B62q... \
|
|
15
|
+
* --pinned-key <base64-ed25519-pubkey> \
|
|
16
|
+
* [--mina-graphql https://api.minascan.io/node/mainnet/v1/graphql] \
|
|
17
|
+
* [--mina-network-id mainnet|testnet|devnet] \
|
|
18
|
+
* [--skip-chain-checks] \
|
|
19
|
+
* [--json]
|
|
20
|
+
*
|
|
21
|
+
* The manifest lives at `<origin>/.well-known/zklicensing/<address>.json` —
|
|
22
|
+
* one file per generation address, all signed by the same platform key. The
|
|
23
|
+
* appId is read out of the signed payload; callers never need to know it
|
|
24
|
+
* out-of-band, which lets buyers verify from just the address their wallet
|
|
25
|
+
* shows them (including addresses for retired generations).
|
|
26
|
+
*
|
|
27
|
+
* Permit registries:
|
|
28
|
+
* zklicensing-verify-trust --registry B62q... [--origin https://authority.example]
|
|
29
|
+
* [--mina-network-id mainnet|devnet] [--keeper-url https://zklicensing.com/api/keeper]
|
|
30
|
+
* [--no-well-known] [--mina-graphql <url>] [--json]
|
|
31
|
+
* checks the registry's verification key against the published PermitRegistry
|
|
32
|
+
* hashes, its locked permissions and network binding, the authority website
|
|
33
|
+
* written into it at deploy (on the --origin host, when given) and that the
|
|
34
|
+
* website's /.well-known/zkpermits/<address>.json claims it, and (with
|
|
35
|
+
* --keeper-url) that the keeper's authority keys, payout and website match.
|
|
36
|
+
*
|
|
37
|
+
* Exit codes:
|
|
38
|
+
* 0 — every finding passed
|
|
39
|
+
* 1 — one or more findings failed
|
|
40
|
+
* 2 — invalid arguments
|
|
41
|
+
*
|
|
42
|
+
* --pinned-key is required and may be repeated; the CLI ships NO baked-in
|
|
43
|
+
* pin. Source it from https://zklicensing.com/trustanchor.html (published
|
|
44
|
+
* next to each generation's VK hash). See the SDK README for details.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { verifyTrustAnchor, type Finding, type TrustAnchorReport } from './trustAnchorVerify.js';
|
|
48
|
+
import { verifyRegistryTrust } from './registryTrustVerify.js';
|
|
49
|
+
|
|
50
|
+
type Args = {
|
|
51
|
+
origin?: string;
|
|
52
|
+
address?: string;
|
|
53
|
+
registry?: string;
|
|
54
|
+
keeperUrl?: string;
|
|
55
|
+
pinnedKeys: string[];
|
|
56
|
+
// undefined = fall back to DEFAULT_GRAPHQL[minaNetworkId] at resolve time,
|
|
57
|
+
// so `--mina-network-id devnet` alone auto-picks the devnet endpoint even
|
|
58
|
+
// when the user doesn't pass --mina-graphql.
|
|
59
|
+
minaGraphql?: string;
|
|
60
|
+
minaNetworkId: 'mainnet' | 'devnet';
|
|
61
|
+
skipChainChecks: boolean;
|
|
62
|
+
crossCheck: boolean;
|
|
63
|
+
wellKnown: boolean;
|
|
64
|
+
json: boolean;
|
|
65
|
+
help: boolean;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const DEFAULT_GRAPHQL: Record<Args['minaNetworkId'], string> = {
|
|
69
|
+
mainnet: 'https://api.minascan.io/node/mainnet/v1/graphql',
|
|
70
|
+
// O1 Labs' devnet-plain node is the reliable public endpoint for freshly
|
|
71
|
+
// deployed accounts — public indexers like Minascan lag by several slots
|
|
72
|
+
// and often return null for a zkApp minutes after landing.
|
|
73
|
+
devnet: 'https://devnet-plain-1.gcp.o1test.net/graphql',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function usage(): string {
|
|
77
|
+
return [
|
|
78
|
+
'zklicensing-verify-trust — verify a vendor page + on-chain zkApp mutually bind',
|
|
79
|
+
'',
|
|
80
|
+
'Required:',
|
|
81
|
+
' --origin <url> Vendor origin (https://...)',
|
|
82
|
+
' --address <B62q...> zkApp address (the address your wallet shows you)',
|
|
83
|
+
' --pinned-key <b64> Platform manifest pubkey (repeatable during rotation)',
|
|
84
|
+
'',
|
|
85
|
+
'Or, for a permit registry:',
|
|
86
|
+
' --registry <B62q...> Registry address; checks VK, permissions, network,',
|
|
87
|
+
' the website bound at deploy and its .well-known claim',
|
|
88
|
+
' --origin <url> The authority website you expect (compared by host)',
|
|
89
|
+
' --keeper-url <url> Also compare the keeper\'s authority keys, payout, website',
|
|
90
|
+
' --no-well-known Skip fetching <website>/.well-known/zkpermits/<registry>.json',
|
|
91
|
+
'',
|
|
92
|
+
'Optional:',
|
|
93
|
+
' --mina-graphql <url> Mina GraphQL endpoint (default depends on',
|
|
94
|
+
' --mina-network-id: mainnet →',
|
|
95
|
+
` ${DEFAULT_GRAPHQL.mainnet};`,
|
|
96
|
+
` devnet → ${DEFAULT_GRAPHQL.devnet})`,
|
|
97
|
+
' --mina-network-id <id> mainnet | devnet (default: devnet)',
|
|
98
|
+
' --skip-chain-checks Manifest-only audit — no o1js chain reads',
|
|
99
|
+
' --no-cross-check Skip the platform backend cross-check',
|
|
100
|
+
' (default: on — verifies vendor manifest matches',
|
|
101
|
+
' https://zklicensing.com/apps/by-address/<address>)',
|
|
102
|
+
' --json Emit TrustAnchorReport as JSON on stdout',
|
|
103
|
+
'',
|
|
104
|
+
'Exit: 0 all-ok · 1 any check failed · 2 bad args',
|
|
105
|
+
].join('\n');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseArgs(argv: string[]): Args {
|
|
109
|
+
const args: Args = {
|
|
110
|
+
pinnedKeys: [],
|
|
111
|
+
minaNetworkId: 'devnet',
|
|
112
|
+
skipChainChecks: false,
|
|
113
|
+
// Default on — the platform backend cross-check is cheap and catches
|
|
114
|
+
// vendors whose local manifest has drifted from the signed platform
|
|
115
|
+
// record. Opt out with --no-cross-check for a pure origin+chain audit.
|
|
116
|
+
crossCheck: true,
|
|
117
|
+
wellKnown: true,
|
|
118
|
+
json: false,
|
|
119
|
+
help: false,
|
|
120
|
+
};
|
|
121
|
+
for (let i = 0; i < argv.length; i++) {
|
|
122
|
+
const a = argv[i];
|
|
123
|
+
const next = () => {
|
|
124
|
+
const v = argv[++i];
|
|
125
|
+
if (v === undefined) throw new Error(`missing value for ${a}`);
|
|
126
|
+
return v;
|
|
127
|
+
};
|
|
128
|
+
switch (a) {
|
|
129
|
+
case '--origin': args.origin = next(); break;
|
|
130
|
+
case '--address': args.address = next(); break;
|
|
131
|
+
case '--registry': args.registry = next(); break;
|
|
132
|
+
case '--keeper-url': args.keeperUrl = next(); break;
|
|
133
|
+
case '--pinned-key': args.pinnedKeys.push(next()); break;
|
|
134
|
+
case '--mina-graphql': args.minaGraphql = next(); break;
|
|
135
|
+
case '--mina-network-id': {
|
|
136
|
+
const v = next();
|
|
137
|
+
if (v !== 'mainnet' && v !== 'devnet') {
|
|
138
|
+
throw new Error(`--mina-network-id must be mainnet|devnet, got "${v}"`);
|
|
139
|
+
}
|
|
140
|
+
args.minaNetworkId = v;
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
case '--skip-chain-checks': args.skipChainChecks = true; break;
|
|
144
|
+
case '--no-well-known': args.wellKnown = false; break;
|
|
145
|
+
// Kept as a no-op flag so any existing invocation that passed
|
|
146
|
+
// --cross-check keeps working. --no-cross-check opts out.
|
|
147
|
+
case '--cross-check': args.crossCheck = true; break;
|
|
148
|
+
case '--no-cross-check': args.crossCheck = false; break;
|
|
149
|
+
case '--json': args.json = true; break;
|
|
150
|
+
case '-h':
|
|
151
|
+
case '--help': args.help = true; break;
|
|
152
|
+
default: throw new Error(`unknown argument: ${a}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return args;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function formatHuman(report: TrustAnchorReport): string {
|
|
159
|
+
const lines: string[] = [];
|
|
160
|
+
const banner = report.ok ? 'TRUST ANCHOR VERIFIED' : 'TRUST ANCHOR FAILED';
|
|
161
|
+
lines.push(banner);
|
|
162
|
+
lines.push(` origin: ${report.origin}`);
|
|
163
|
+
lines.push(` address: ${report.address}`);
|
|
164
|
+
if (report.appId) lines.push(` appId: ${report.appId}`);
|
|
165
|
+
if (report.generation !== undefined) lines.push(` generation: ${report.generation}`);
|
|
166
|
+
lines.push('');
|
|
167
|
+
for (const f of report.findings) {
|
|
168
|
+
const mark = f.ok ? '[ok] ' : '[FAIL]';
|
|
169
|
+
lines.push(` ${mark} ${f.name} — ${f.detail}`);
|
|
170
|
+
}
|
|
171
|
+
return lines.join('\n');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function main(): Promise<number> {
|
|
175
|
+
let args: Args;
|
|
176
|
+
try {
|
|
177
|
+
args = parseArgs(process.argv.slice(2));
|
|
178
|
+
} catch (err: any) {
|
|
179
|
+
process.stderr.write(`${err.message}\n\n${usage()}\n`);
|
|
180
|
+
return 2;
|
|
181
|
+
}
|
|
182
|
+
if (args.help) {
|
|
183
|
+
process.stdout.write(`${usage()}\n`);
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
if (args.registry) {
|
|
187
|
+
const report = await verifyRegistryTrust({
|
|
188
|
+
address: args.registry, network: args.minaNetworkId, minaGraphqlUrl: args.minaGraphql, keeperUrl: args.keeperUrl,
|
|
189
|
+
website: args.origin, checkWellKnown: args.wellKnown,
|
|
190
|
+
});
|
|
191
|
+
if (args.json) process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
192
|
+
else {
|
|
193
|
+
process.stdout.write([report.ok ? 'REGISTRY VERIFIED' : 'REGISTRY FAILED', ` registry: ${report.address}`, ` network: ${report.network}`, '',
|
|
194
|
+
...(report.findings.some(f => f.name === 'website_claims_registry' && !f.ok) ? [` The authority publishes the claim at <website>/.well-known/zkpermits/${report.address}.json`, ''] : []),
|
|
195
|
+
...report.findings.map(f => ` ${f.ok ? '[ok] ' : '[FAIL]'} ${f.name} — ${f.detail}`)].join('\n') + '\n');
|
|
196
|
+
}
|
|
197
|
+
return report.ok ? 0 : 1;
|
|
198
|
+
}
|
|
199
|
+
const missing: string[] = [];
|
|
200
|
+
if (!args.origin) missing.push('--origin');
|
|
201
|
+
if (!args.address) missing.push('--address');
|
|
202
|
+
if (args.pinnedKeys.length === 0) missing.push('--pinned-key');
|
|
203
|
+
if (missing.length > 0) {
|
|
204
|
+
process.stderr.write(`missing required argument(s): ${missing.join(', ')}\n\n${usage()}\n`);
|
|
205
|
+
return 2;
|
|
206
|
+
}
|
|
207
|
+
const report = await verifyTrustAnchor({
|
|
208
|
+
origin: args.origin!,
|
|
209
|
+
address: args.address!,
|
|
210
|
+
pinnedPublicKeysBase64: args.pinnedKeys,
|
|
211
|
+
minaGraphqlUrl: args.minaGraphql ?? DEFAULT_GRAPHQL[args.minaNetworkId],
|
|
212
|
+
minaNetworkId: args.minaNetworkId,
|
|
213
|
+
skipChainChecks: args.skipChainChecks,
|
|
214
|
+
crossCheck: args.crossCheck,
|
|
215
|
+
});
|
|
216
|
+
if (args.json) {
|
|
217
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
218
|
+
} else {
|
|
219
|
+
process.stdout.write(`${formatHuman(report)}\n`);
|
|
220
|
+
}
|
|
221
|
+
return report.ok ? 0 : 1;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
main().then(
|
|
225
|
+
(code) => process.exit(code),
|
|
226
|
+
(err) => {
|
|
227
|
+
process.stderr.write(`unexpected error: ${err?.stack ?? err}\n`);
|
|
228
|
+
process.exit(1);
|
|
229
|
+
},
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
// Silence unused-import warning when someone bundles the module (Finding is
|
|
233
|
+
// re-exported here for consumers that import from the CLI module directly).
|
|
234
|
+
export type { Finding };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* txBroadcastClient.ts
|
|
6
|
+
*
|
|
7
|
+
* Small helper for POST /tx-broadcast. Called by the buy/renew/migrate
|
|
8
|
+
* flows immediately after the buyer's wallet returns a txHash — signals
|
|
9
|
+
* the backend to extend the app's in-flight slot from the short signing-TTL
|
|
10
|
+
* to the longer landing-TTL, so concurrent buyers on the same app don't
|
|
11
|
+
* think the slot has expired while an honest signed tx is still in the
|
|
12
|
+
* mempool.
|
|
13
|
+
*
|
|
14
|
+
* Fire-and-forget by design: this is a tightening signal, not a
|
|
15
|
+
* correctness dependency. Land-clear in the buy-watcher and the
|
|
16
|
+
* signing-TTL both remain live regardless of whether this call succeeds.
|
|
17
|
+
* Failures are swallowed with an optional log callback so a network blip
|
|
18
|
+
* doesn't surface as a confusing error mid-checkout.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type NotifyBroadcastRequest = {
|
|
22
|
+
keeperUrl: string;
|
|
23
|
+
appId: string;
|
|
24
|
+
txHash: string;
|
|
25
|
+
fetcher?: typeof fetch;
|
|
26
|
+
signal?: AbortSignal;
|
|
27
|
+
// Optional hook for surfacing swallowed failures to the caller's logger.
|
|
28
|
+
// Not called on the happy path.
|
|
29
|
+
onError?: (err: unknown) => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function baseUrl(url: string): string {
|
|
33
|
+
return url.replace(/\/+$/, '');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function notifyBroadcast(req: NotifyBroadcastRequest): Promise<void> {
|
|
37
|
+
const fetcher = req.fetcher ?? fetch;
|
|
38
|
+
try {
|
|
39
|
+
await fetcher(`${baseUrl(req.keeperUrl)}/tx-broadcast`, {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: { 'Content-Type': 'application/json' },
|
|
42
|
+
signal: req.signal,
|
|
43
|
+
body: JSON.stringify({ appId: req.appId, txHash: req.txHash }),
|
|
44
|
+
});
|
|
45
|
+
} catch (err) {
|
|
46
|
+
req.onError?.(err);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* unbindClient.ts
|
|
6
|
+
*
|
|
7
|
+
* Browser-safe client for the non-custodial device-unbinding flow.
|
|
8
|
+
* Mirrors `bindClient.ts` in shape: signs the unbind message with
|
|
9
|
+
* TAG_UNBINDING, POSTs to /prove/unbind, then /confirm/unbind after
|
|
10
|
+
* the wallet returns a txHash.
|
|
11
|
+
*
|
|
12
|
+
* Notable asymmetry with bind: the unbind message shape carries the
|
|
13
|
+
* deviceCommitment position too, but the backend and circuit both
|
|
14
|
+
* hard-code it to `Field(0)` (the "not about any specific new
|
|
15
|
+
* device" sentinel — the message is about freeing a slot, not
|
|
16
|
+
* introducing a new device). The SDK matches by omitting
|
|
17
|
+
* deviceCommitment from the request and using "0" when computing
|
|
18
|
+
* the signature preimage.
|
|
19
|
+
*
|
|
20
|
+
* Non-custodial invariant: the signature is re-verified in-circuit
|
|
21
|
+
* against the leaf's `licenseHash` (which IS the owner-pubkey
|
|
22
|
+
* commitment — the circuit asserts equality at buy time), so no
|
|
23
|
+
* backend / vendor can unilaterally kick a device — only the
|
|
24
|
+
* passphrase holder can.
|
|
25
|
+
* A user who lost the physical device but still has the passphrase
|
|
26
|
+
* can unbind the missing device's slot from any device that holds
|
|
27
|
+
* the ownership key (the "remote log-off" case).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { Field, PrivateKey, PublicKey, Signature } from 'o1js';
|
|
31
|
+
import { readErrorBody, tryQueueBusyFromBody, keeperError } from './proveErrors.js';
|
|
32
|
+
import { encodePublicKey, encodeSignature, signUnbinding } from './ownershipSignature.js';
|
|
33
|
+
import { fetchBindingState } from './bindClient.js';
|
|
34
|
+
|
|
35
|
+
export type ProveUnbindRequest = {
|
|
36
|
+
keeperUrl: string;
|
|
37
|
+
network: 'devnet' | 'mainnet';
|
|
38
|
+
zkAppAddress: string;
|
|
39
|
+
licenseHash: string;
|
|
40
|
+
ownerPrivateKey: PrivateKey;
|
|
41
|
+
// Which slot (0..cap-1) to free.
|
|
42
|
+
slotIndex: number;
|
|
43
|
+
buyerAddress: string;
|
|
44
|
+
// Optional nonce override. Sampled fresh (128-bit random) when
|
|
45
|
+
// omitted — same shape as bindClient.
|
|
46
|
+
nonce?: string;
|
|
47
|
+
// Optional overrides for the state the signature commits to — same
|
|
48
|
+
// semantics as ProveBindRequest.slotState / expirySlot.
|
|
49
|
+
slotState?: string;
|
|
50
|
+
expirySlot?: number;
|
|
51
|
+
fetcher?: typeof fetch;
|
|
52
|
+
signal?: AbortSignal;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type ProveUnbindResponse = {
|
|
56
|
+
provenTxJson: string;
|
|
57
|
+
licenseHash: string;
|
|
58
|
+
slotIndex: number;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type ConfirmUnbindRequest = {
|
|
62
|
+
keeperUrl: string;
|
|
63
|
+
zkAppAddress: string;
|
|
64
|
+
licenseHash: string;
|
|
65
|
+
txHash: string;
|
|
66
|
+
fetcher?: typeof fetch;
|
|
67
|
+
signal?: AbortSignal;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function baseUrl(url: string): string {
|
|
71
|
+
return url.replace(/\/+$/, '');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function readJsonError(resp: Response): Promise<string> {
|
|
75
|
+
try {
|
|
76
|
+
const body = await resp.json() as Record<string, unknown>;
|
|
77
|
+
if (typeof body.error === 'string') return body.error;
|
|
78
|
+
} catch { /* body wasn't JSON */ }
|
|
79
|
+
return `HTTP ${resp.status}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function freshNonce(): string {
|
|
83
|
+
const crypto = (globalThis as any).crypto as Crypto | undefined;
|
|
84
|
+
if (!crypto?.getRandomValues) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
'crypto.getRandomValues unavailable — unbindClient requires Node ≥ 20 or a modern browser',
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const bytes = new Uint8Array(16); // 128 bits
|
|
90
|
+
crypto.getRandomValues(bytes);
|
|
91
|
+
let n = 0n;
|
|
92
|
+
for (const b of bytes) n = (n << 8n) | BigInt(b);
|
|
93
|
+
return Field(n).toString();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// POST /prove/unbind — same retry contract as /prove/bind. Returns
|
|
97
|
+
// 409 `ProveQueueBusyError` when another root-mutating tx for the
|
|
98
|
+
// same app is in flight; the caller retries with the server-supplied
|
|
99
|
+
// `retryAfterMs` cadence.
|
|
100
|
+
export async function proveUnbind(req: ProveUnbindRequest): Promise<ProveUnbindResponse> {
|
|
101
|
+
const fetcher = req.fetcher ?? fetch;
|
|
102
|
+
const nonce = req.nonce ?? freshNonce();
|
|
103
|
+
const { slotState, expirySlot } = (req.slotState !== undefined && req.expirySlot !== undefined)
|
|
104
|
+
? { slotState: req.slotState, expirySlot: req.expirySlot }
|
|
105
|
+
: await fetchBindingState({ ...req, fetcher });
|
|
106
|
+
|
|
107
|
+
const ownerPubKey: PublicKey = req.ownerPrivateKey.toPublicKey();
|
|
108
|
+
// deviceCommitment=0 for the preimage — matches TAG_UNBINDING's
|
|
109
|
+
// circuit-side check (see verifyUnbinding / bindingLikeFields in
|
|
110
|
+
// ownershipSignature.ts).
|
|
111
|
+
const ownerSig: Signature = signUnbinding(req.ownerPrivateKey, {
|
|
112
|
+
licenseHash: req.licenseHash,
|
|
113
|
+
deviceCommitment: '0',
|
|
114
|
+
slotIndex: req.slotIndex,
|
|
115
|
+
nonce,
|
|
116
|
+
slotState,
|
|
117
|
+
expirySlot,
|
|
118
|
+
zkAppAddress: req.zkAppAddress,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const resp = await fetcher(`${baseUrl(req.keeperUrl)}/prove/unbind`, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: { 'Content-Type': 'application/json' },
|
|
124
|
+
signal: req.signal,
|
|
125
|
+
body: JSON.stringify({
|
|
126
|
+
licenseHash: req.licenseHash,
|
|
127
|
+
buyerAddress: req.buyerAddress,
|
|
128
|
+
zkAppAddress: req.zkAppAddress,
|
|
129
|
+
network: req.network,
|
|
130
|
+
ownerPubKey: encodePublicKey(ownerPubKey),
|
|
131
|
+
ownerSig: encodeSignature(ownerSig),
|
|
132
|
+
slotIndex: req.slotIndex,
|
|
133
|
+
nonce,
|
|
134
|
+
}),
|
|
135
|
+
});
|
|
136
|
+
if (!resp.ok) {
|
|
137
|
+
const parsed = await readErrorBody(resp);
|
|
138
|
+
const queueBusy = tryQueueBusyFromBody(parsed);
|
|
139
|
+
if (queueBusy) throw queueBusy;
|
|
140
|
+
throw keeperError(parsed);
|
|
141
|
+
}
|
|
142
|
+
return await resp.json() as ProveUnbindResponse;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// POST /confirm/unbind — pass-through no-op mirror of /confirm/bind.
|
|
146
|
+
export async function confirmUnbind(req: ConfirmUnbindRequest): Promise<{ ok: true }> {
|
|
147
|
+
const fetcher = req.fetcher ?? fetch;
|
|
148
|
+
const resp = await fetcher(`${baseUrl(req.keeperUrl)}/confirm/unbind`, {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: { 'Content-Type': 'application/json' },
|
|
151
|
+
signal: req.signal,
|
|
152
|
+
body: JSON.stringify({
|
|
153
|
+
zkAppAddress: req.zkAppAddress,
|
|
154
|
+
licenseHash: req.licenseHash,
|
|
155
|
+
txHash: req.txHash,
|
|
156
|
+
}),
|
|
157
|
+
});
|
|
158
|
+
if (!resp.ok) throw new Error(await readJsonError(resp));
|
|
159
|
+
return { ok: true };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export type UnbindStatus = {
|
|
163
|
+
// 'confirmed' once eventSync has observed the DeviceUnbound event and
|
|
164
|
+
// the target slot's commitment has flipped back to '0'. 'pending' while
|
|
165
|
+
// the tx is in flight. 'unknown' if the license record hasn't been
|
|
166
|
+
// persisted yet.
|
|
167
|
+
status: 'confirmed' | 'pending' | 'unknown';
|
|
168
|
+
slotIndex?: number;
|
|
169
|
+
deviceCommitment?: string;
|
|
170
|
+
elapsedMs?: number;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export type PollUnbindStatusOptions = {
|
|
174
|
+
keeperUrl: string;
|
|
175
|
+
network: 'devnet' | 'mainnet';
|
|
176
|
+
zkAppAddress: string;
|
|
177
|
+
licenseHash: string;
|
|
178
|
+
slotIndex: number;
|
|
179
|
+
fetcher?: typeof fetch;
|
|
180
|
+
intervalMs?: number; // default 10 000
|
|
181
|
+
timeoutMs?: number; // default 20 min
|
|
182
|
+
signal?: AbortSignal;
|
|
183
|
+
onTick?: (status: UnbindStatus) => void;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// Polls GET /license/:zkAppAddress/:licenseHash until the unbind has
|
|
187
|
+
// landed on-chain (the target slot's commitment is '0') or the timeout
|
|
188
|
+
// fires. Mirrors pollBindStatus in shape.
|
|
189
|
+
export async function pollUnbindStatus(opts: PollUnbindStatusOptions): Promise<UnbindStatus> {
|
|
190
|
+
const fetcher = opts.fetcher ?? fetch;
|
|
191
|
+
const interval = opts.intervalMs ?? 10_000;
|
|
192
|
+
const timeout = opts.timeoutMs ?? 20 * 60 * 1000;
|
|
193
|
+
const startedAt = Date.now();
|
|
194
|
+
const deadline = startedAt + timeout;
|
|
195
|
+
const url = `${baseUrl(opts.keeperUrl)}/license/${encodeURIComponent(opts.zkAppAddress)}/${encodeURIComponent(opts.licenseHash)}?network=${opts.network}`;
|
|
196
|
+
|
|
197
|
+
while (true) {
|
|
198
|
+
if (opts.signal?.aborted) throw new Error('pollUnbindStatus aborted');
|
|
199
|
+
let status: UnbindStatus = { status: 'unknown', elapsedMs: Date.now() - startedAt };
|
|
200
|
+
try {
|
|
201
|
+
const resp = await fetcher(url, { signal: opts.signal });
|
|
202
|
+
if (resp.ok) {
|
|
203
|
+
const body = await resp.json() as {
|
|
204
|
+
found?: boolean;
|
|
205
|
+
devices?: Array<{ slotIndex: number; deviceCommitment: string }>;
|
|
206
|
+
};
|
|
207
|
+
if (body.found) {
|
|
208
|
+
const slot = (body.devices ?? []).find(d => d.slotIndex === opts.slotIndex);
|
|
209
|
+
const commitment = slot?.deviceCommitment ?? '0';
|
|
210
|
+
if (commitment === '0') {
|
|
211
|
+
status = {
|
|
212
|
+
status: 'confirmed',
|
|
213
|
+
slotIndex: opts.slotIndex,
|
|
214
|
+
deviceCommitment: commitment,
|
|
215
|
+
elapsedMs: Date.now() - startedAt,
|
|
216
|
+
};
|
|
217
|
+
} else {
|
|
218
|
+
status = {
|
|
219
|
+
status: 'pending',
|
|
220
|
+
slotIndex: opts.slotIndex,
|
|
221
|
+
deviceCommitment: commitment,
|
|
222
|
+
elapsedMs: Date.now() - startedAt,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} catch { /* transient network — try again on next tick */ }
|
|
228
|
+
opts.onTick?.(status);
|
|
229
|
+
if (status.status === 'confirmed') return status;
|
|
230
|
+
if (Date.now() >= deadline) return status;
|
|
231
|
+
await new Promise<void>((r) => setTimeout(r, interval));
|
|
232
|
+
}
|
|
233
|
+
}
|
package/src/units.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* units.ts — the unit amounts are shown in.
|
|
6
|
+
*
|
|
7
|
+
* Devnet amounts are test MINA (tMINA), which has no value; mainnet amounts
|
|
8
|
+
* are MINA. Only what people read changes: field names (`priceYearlyMina`,
|
|
9
|
+
* nanomina amounts) stay the same on both networks. The MINA price in euros
|
|
10
|
+
* or dollars is real and still shown on devnet, but only as a reference for
|
|
11
|
+
* mainnet MINA (`fiatReference`), never as the value of tMINA.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { isEuCountry } from './taxId.js';
|
|
15
|
+
|
|
16
|
+
export type MinaUnit = 'MINA' | 'tMINA';
|
|
17
|
+
|
|
18
|
+
/** 'tMINA' on devnet (and any test network), 'MINA' on mainnet. */
|
|
19
|
+
export function minaUnit(network: string): MinaUnit {
|
|
20
|
+
return network === 'mainnet' ? 'MINA' : 'tMINA';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** "12.5 tMINA" / "12.5 MINA" for an amount in MINA (number or decimal string). */
|
|
24
|
+
export function formatMina(amountMina: number | string, network: string): string {
|
|
25
|
+
return `${amountMina} ${minaUnit(network)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** "12.5 tMINA" for an amount in nanomina. */
|
|
29
|
+
export function formatNanomina(nano: bigint | string | number, network: string): string {
|
|
30
|
+
const n = BigInt(nano);
|
|
31
|
+
const neg = n < 0n;
|
|
32
|
+
const a = neg ? -n : n;
|
|
33
|
+
const frac = (a % 1_000_000_000n).toString().padStart(9, '0').replace(/0+$/, '');
|
|
34
|
+
return `${neg ? '-' : ''}${(a / 1_000_000_000n).toString()}${frac ? '.' + frac : ''} ${minaUnit(network)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The words that go with a fiat value. Mainnet: "≈ €2.10". Devnet: the same
|
|
39
|
+
* amount of real MINA, said as such ("as MINA ≈ €2.10"), plus a note that
|
|
40
|
+
* tMINA itself has no value.
|
|
41
|
+
*/
|
|
42
|
+
export function fiatReference(network: string): { prefix: string; note: string | null } {
|
|
43
|
+
return network === 'mainnet'
|
|
44
|
+
? { prefix: '≈', note: null }
|
|
45
|
+
: { prefix: 'as MINA ≈', note: 'tMINA has no value; this is what the same amount of mainnet MINA is worth.' };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type FiatCurrency = 'eur' | 'usd' | 'gbp';
|
|
49
|
+
const SYMBOL: Record<FiatCurrency, string> = { eur: '€', usd: '$', gbp: '£' };
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Which money a person sees amounts in, by their country: euros in the EU,
|
|
53
|
+
* pounds and euros in the UK, dollars in the US, euros and dollars elsewhere
|
|
54
|
+
* (or when the country is unknown).
|
|
55
|
+
*/
|
|
56
|
+
export function fiatCurrencies(country?: string | null): FiatCurrency[] {
|
|
57
|
+
const c = String(country ?? '').trim().toUpperCase();
|
|
58
|
+
if (isEuCountry(c)) return ['eur'];
|
|
59
|
+
if (c === 'GB' || c === 'UK') return ['gbp', 'eur'];
|
|
60
|
+
if (c === 'US') return ['usd'];
|
|
61
|
+
return ['eur', 'usd'];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** "€2.10", "-€26.78" for an amount of money. */
|
|
65
|
+
export function formatFiat(amount: number | string, currency: FiatCurrency, digits = 2): string {
|
|
66
|
+
const n = typeof amount === 'string' ? Number(amount) : amount;
|
|
67
|
+
return `${n < 0 ? '-' : ''}${SYMBOL[currency]}${Math.abs(n).toFixed(digits)}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** "€2.10 · $2.30" for the given currencies (a currency without a rate is left out). */
|
|
71
|
+
export function formatFiatList(values: Partial<Record<FiatCurrency, number | string>>, currencies: FiatCurrency[], digits = 2): string {
|
|
72
|
+
return currencies.filter(c => values[c] !== undefined && values[c] !== null && values[c] !== '')
|
|
73
|
+
.map(c => formatFiat(values[c]!, c, digits)).join(' · ');
|
|
74
|
+
}
|
package/src/vatRates.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* vatRates.ts — `zklicensing/vat`
|
|
6
|
+
*
|
|
7
|
+
* Standard VAT rates (EU member states and the UK, with their history) and
|
|
8
|
+
* the VAT included in a VAT-inclusive price. Prices on zkLicensing are fixed
|
|
9
|
+
* per tier on-chain and include VAT: a buyer pays the listed price, and the
|
|
10
|
+
* VAT in it is at the buyer's country's standard rate on the sale date.
|
|
11
|
+
*
|
|
12
|
+
* standardVatRate('FI', '2026-10-13') → 25.5
|
|
13
|
+
* vatIncludedNano(10_000_000_000n, 25.5) → { netNano: 7_968_127_490n, vatNano: 2_031_872_510n }
|
|
14
|
+
*
|
|
15
|
+
* For vendors' own reporting and buyers' information — an estimate for
|
|
16
|
+
* consumer sales, not tax advice: business buyers with a VAT number are
|
|
17
|
+
* usually reverse-charged, and the EU's €10,000 cross-border threshold is
|
|
18
|
+
* not applied. Countries without VAT in the table give null.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { STANDARD_VAT_RATES, VAT_RATES_FETCHED, VAT_RATES_SOURCE } from './vatRatesData.js';
|
|
22
|
+
|
|
23
|
+
export { VAT_RATES_FETCHED, VAT_RATES_SOURCE };
|
|
24
|
+
|
|
25
|
+
function isoDay(date: Date | string | number): string {
|
|
26
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
27
|
+
if (!Number.isFinite(d.getTime())) throw new Error('invalid date');
|
|
28
|
+
return d.toISOString().slice(0, 10);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The standard VAT rate (percent) in force in `country` on `date`, or null if the country isn't in the table. */
|
|
32
|
+
export function standardVatRate(country: string, date: Date | string | number = new Date()): number | null {
|
|
33
|
+
const c = String(country ?? '').toUpperCase();
|
|
34
|
+
const periods = Object.prototype.hasOwnProperty.call(STANDARD_VAT_RATES, c) ? STANDARD_VAT_RATES[c] : undefined;
|
|
35
|
+
if (!periods) return null;
|
|
36
|
+
const day = isoDay(date);
|
|
37
|
+
let rate: number | null = null;
|
|
38
|
+
for (const [from, r] of periods) if (from <= day) rate = r;
|
|
39
|
+
return rate;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Countries in the table (ISO codes). */
|
|
43
|
+
export function vatCountries(): string[] {
|
|
44
|
+
return Object.keys(STANDARD_VAT_RATES);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const bp = (ratePct: number) => {
|
|
48
|
+
if (!Number.isFinite(ratePct) || ratePct < 0 || ratePct >= 100) throw new Error('rate must be a percentage in [0, 100)');
|
|
49
|
+
return BigInt(Math.round(ratePct * 100));
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** The VAT included in a VAT-inclusive `totalNano` at `ratePct`: vat = total × r / (100 + r), rounded to the nearest nanomina. */
|
|
53
|
+
export function vatIncludedNano(totalNano: bigint, ratePct: number): { netNano: bigint; vatNano: bigint } {
|
|
54
|
+
if (totalNano < 0n) throw new Error('total must not be negative');
|
|
55
|
+
const r = bp(ratePct);
|
|
56
|
+
const den = 10_000n + r;
|
|
57
|
+
const vatNano = (totalNano * r * 2n + den) / (den * 2n);
|
|
58
|
+
return { netNano: totalNano - vatNano, vatNano };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Same, for a MINA amount (display only). */
|
|
62
|
+
export function vatIncludedMina(totalMina: number, ratePct: number): { net: number; vat: number } {
|
|
63
|
+
const { netNano, vatNano } = vatIncludedNano(BigInt(Math.round(totalMina * 1e9)), ratePct);
|
|
64
|
+
return { net: Number(netNano) / 1e9, vat: Number(vatNano) / 1e9 };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The price shown to a buyer in `country` on `date`: the total, and the VAT it includes. Null rate outside the table. */
|
|
68
|
+
export function priceWithVat(totalNano: bigint, country: string, date: Date | string | number = new Date()):
|
|
69
|
+
{ totalNano: bigint; country: string; rate: number | null; netNano: bigint; vatNano: bigint } {
|
|
70
|
+
const rate = standardVatRate(country, date);
|
|
71
|
+
const split = rate === null ? { netNano: totalNano, vatNano: 0n } : vatIncludedNano(totalNano, rate);
|
|
72
|
+
return { totalNano, country: String(country).toUpperCase(), rate, ...split };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The VAT block of a license receipt (amounts as decimal MINA strings). */
|
|
76
|
+
export type VatReceipt = {
|
|
77
|
+
country: string;
|
|
78
|
+
/** Standard rate (percent) on the sale date; null when the country has no VAT in the table. */
|
|
79
|
+
ratePct: number | null;
|
|
80
|
+
totalMina: string;
|
|
81
|
+
vatMina: string;
|
|
82
|
+
netMina: string;
|
|
83
|
+
/** Which rates table was used. */
|
|
84
|
+
rates: string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const minaString = (nano: bigint) => {
|
|
88
|
+
const s = nano.toString().padStart(10, '0');
|
|
89
|
+
return `${s.slice(0, -9)}.${s.slice(-9)}`.replace(/\.?0+$/, '') || '0';
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** The VAT included in a purchase of `totalNano` by a buyer in `country` on `date`, for the license file. */
|
|
93
|
+
export function vatReceipt(totalNano: bigint, country: string, date: Date | string | number = new Date()): VatReceipt {
|
|
94
|
+
const p = priceWithVat(totalNano, country, date);
|
|
95
|
+
return {
|
|
96
|
+
country: p.country, ratePct: p.rate,
|
|
97
|
+
totalMina: minaString(p.totalNano), vatMina: minaString(p.vatNano), netMina: minaString(p.netNano),
|
|
98
|
+
rates: `${VAT_RATES_SOURCE}, ${VAT_RATES_FETCHED}`,
|
|
99
|
+
};
|
|
100
|
+
}
|