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,297 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* bindClient.ts
|
|
6
|
+
*
|
|
7
|
+
* Browser-safe client for the non-custodial device-binding flow.
|
|
8
|
+
* Mirrors `buyClient.ts` in shape: the caller drives the wallet step
|
|
9
|
+
* between `proveBind` and `confirmBind`, so the SDK stays
|
|
10
|
+
* wallet-agnostic.
|
|
11
|
+
*
|
|
12
|
+
* Binding is a root-mutating tx on the same per-app in-flight slot
|
|
13
|
+
* that buy / renew / refund use — a concurrent request
|
|
14
|
+
* against the same app returns 409 `ProveQueueBusyError`, which the
|
|
15
|
+
* caller must catch and retry (see the retry loop in
|
|
16
|
+
* examples/src/bind.ts).
|
|
17
|
+
*
|
|
18
|
+
* The signature scheme is Pallas Schnorr and is re-verified INSIDE
|
|
19
|
+
* the on-chain circuit via `Signature.verify()` — a compromised
|
|
20
|
+
* backend cannot forge a bind because it can't produce a signature
|
|
21
|
+
* that satisfies the in-circuit check.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { Field, PrivateKey, PublicKey, Signature } from 'o1js';
|
|
25
|
+
import { readErrorBody, tryQueueBusyFromBody, keeperError } from './proveErrors.js';
|
|
26
|
+
import { encodePublicKey, encodeSignature, signBinding, slotStateOf } from './ownershipSignature.js';
|
|
27
|
+
|
|
28
|
+
// The bind message shape reuses `bindingMessageFields` from
|
|
29
|
+
// ownershipSignature.ts, which prefixes every preimage with
|
|
30
|
+
// TAG_BINDING so a captured bind signature can't be replayed as an
|
|
31
|
+
// unbind (or any other flow) even at identical field values.
|
|
32
|
+
|
|
33
|
+
export type ProveBindRequest = {
|
|
34
|
+
keeperUrl: string;
|
|
35
|
+
// Mina network the vendor's zkApp is deployed to. Must match the
|
|
36
|
+
// network the buy was proved against.
|
|
37
|
+
network: 'devnet' | 'mainnet';
|
|
38
|
+
// Address of the vendor's LicensingApp — combined with `licenseHash`
|
|
39
|
+
// to route to the right on-chain state.
|
|
40
|
+
zkAppAddress: string;
|
|
41
|
+
// Poseidon([ownerPub.x, ownerPub.isOdd.toField()]) — the same value
|
|
42
|
+
// computed at buy time via `licenseHashFromPubkey`. The circuit
|
|
43
|
+
// asserts `Poseidon([pk.x, pk.isOdd.toField()]) === leaf.licenseHash`,
|
|
44
|
+
// which is the on-chain owner-pubkey commitment.
|
|
45
|
+
licenseHash: string;
|
|
46
|
+
// Passphrase-derived Pallas Schnorr private key. The pubkey is
|
|
47
|
+
// derived from it locally and both are sent on the wire — the
|
|
48
|
+
// backend cross-checks the pubkey against the leaf.
|
|
49
|
+
ownerPrivateKey: PrivateKey;
|
|
50
|
+
// Poseidon([devicePub.x, devicePub.isOdd.toField()]) — get this
|
|
51
|
+
// from `getOrCreateDeviceIdentity(storageKey).deviceCommitment`.
|
|
52
|
+
// The raw device pubkey never crosses the wire; only its
|
|
53
|
+
// commitment does.
|
|
54
|
+
deviceCommitment: string;
|
|
55
|
+
// Which slot (0..cap-1) to write into.
|
|
56
|
+
slotIndex: number;
|
|
57
|
+
// Buyer's Mina wallet address — pays fees + signs the tx.
|
|
58
|
+
buyerAddress: string;
|
|
59
|
+
// Optional nonce override. When omitted the SDK samples a fresh
|
|
60
|
+
// 128-bit random nonce per call. Callers that need a deterministic
|
|
61
|
+
// nonce for tests can pass one explicitly. Pass as a decimal Field
|
|
62
|
+
// string.
|
|
63
|
+
nonce?: string;
|
|
64
|
+
// Optional overrides for the on-chain state the signature commits to: the
|
|
65
|
+
// target slot's packed (boundAt, freedAt) (see slotStateOf) and the
|
|
66
|
+
// license's expirySlot. When either is omitted the SDK reads both from
|
|
67
|
+
// GET /license/:zkApp/:hash. A stale value only makes the keeper refuse
|
|
68
|
+
// the bind (409); it can't authorize anything other than the transition
|
|
69
|
+
// from that exact state.
|
|
70
|
+
slotState?: string;
|
|
71
|
+
expirySlot?: number;
|
|
72
|
+
fetcher?: typeof fetch;
|
|
73
|
+
signal?: AbortSignal;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type ProveBindResponse = {
|
|
77
|
+
provenTxJson: string;
|
|
78
|
+
licenseHash: string;
|
|
79
|
+
slotIndex: number;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type ConfirmBindRequest = {
|
|
83
|
+
keeperUrl: string;
|
|
84
|
+
zkAppAddress: string;
|
|
85
|
+
licenseHash: string;
|
|
86
|
+
txHash: string;
|
|
87
|
+
fetcher?: typeof fetch;
|
|
88
|
+
signal?: AbortSignal;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
function baseUrl(url: string): string {
|
|
92
|
+
return url.replace(/\/+$/, '');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function readJsonError(resp: Response): Promise<string> {
|
|
96
|
+
try {
|
|
97
|
+
const body = await resp.json() as Record<string, unknown>;
|
|
98
|
+
if (typeof body.error === 'string') return body.error;
|
|
99
|
+
} catch { /* body wasn't JSON */ }
|
|
100
|
+
return `HTTP ${resp.status}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Sample a fresh replay-defense nonce. WebCrypto is required for
|
|
104
|
+
// `crypto.getRandomValues` — same runtime floor as the rest of the
|
|
105
|
+
// signing surface (Node ≥ 20, modern browsers).
|
|
106
|
+
function freshNonce(): string {
|
|
107
|
+
const crypto = (globalThis as any).crypto as Crypto | undefined;
|
|
108
|
+
if (!crypto?.getRandomValues) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
'crypto.getRandomValues unavailable — bindClient requires Node ≥ 20 or a modern browser',
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
const bytes = new Uint8Array(16); // 128 bits
|
|
114
|
+
crypto.getRandomValues(bytes);
|
|
115
|
+
let n = 0n;
|
|
116
|
+
for (const b of bytes) n = (n << 8n) | BigInt(b);
|
|
117
|
+
// Field(n) is safe: 128 bits << the Pallas base field size, so no
|
|
118
|
+
// modular reduction ever fires here.
|
|
119
|
+
return Field(n).toString();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Read the target slot's current packed state and the license's expiry from
|
|
123
|
+
// the keeper's license mirror. Shared with unbindClient.
|
|
124
|
+
export async function fetchBindingState(opts: {
|
|
125
|
+
keeperUrl: string;
|
|
126
|
+
network: 'devnet' | 'mainnet';
|
|
127
|
+
zkAppAddress: string;
|
|
128
|
+
licenseHash: string;
|
|
129
|
+
slotIndex: number;
|
|
130
|
+
fetcher?: typeof fetch;
|
|
131
|
+
signal?: AbortSignal;
|
|
132
|
+
}): Promise<{ slotState: string; expirySlot: number }> {
|
|
133
|
+
const fetcher = opts.fetcher ?? fetch;
|
|
134
|
+
const url = `${baseUrl(opts.keeperUrl)}/license/${encodeURIComponent(opts.zkAppAddress)}/`
|
|
135
|
+
+ `${encodeURIComponent(opts.licenseHash)}?network=${encodeURIComponent(opts.network)}`;
|
|
136
|
+
const resp = await fetcher(url, { signal: opts.signal });
|
|
137
|
+
if (!resp.ok) throw new Error(`License lookup failed: ${await readJsonError(resp)}`);
|
|
138
|
+
const body = await resp.json() as {
|
|
139
|
+
found?: boolean;
|
|
140
|
+
expirySlot?: number;
|
|
141
|
+
devices?: Array<{ boundAtSlot: number; freedAtSlot: number } | null>;
|
|
142
|
+
};
|
|
143
|
+
if (!body.found || !body.expirySlot) throw new Error('License not found on the keeper.');
|
|
144
|
+
return {
|
|
145
|
+
slotState: slotStateOf((body.devices ?? [])[opts.slotIndex]),
|
|
146
|
+
expirySlot: body.expirySlot,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// POST /prove/bind — backend compiles + proves the bind tx.
|
|
151
|
+
//
|
|
152
|
+
// Retry contract:
|
|
153
|
+
// - 409 `proving queue busy` → `ProveQueueBusyError`; caller catches
|
|
154
|
+
// and retries with `retryAfterMs` cadence (see examples/src/bind.ts).
|
|
155
|
+
// - Any other non-2xx surfaces the body's `error` field verbatim.
|
|
156
|
+
export async function proveBind(req: ProveBindRequest): Promise<ProveBindResponse> {
|
|
157
|
+
const fetcher = req.fetcher ?? fetch;
|
|
158
|
+
const nonce = req.nonce ?? freshNonce();
|
|
159
|
+
const { slotState, expirySlot } = (req.slotState !== undefined && req.expirySlot !== undefined)
|
|
160
|
+
? { slotState: req.slotState, expirySlot: req.expirySlot }
|
|
161
|
+
: await fetchBindingState({ ...req, fetcher });
|
|
162
|
+
|
|
163
|
+
const ownerPubKey: PublicKey = req.ownerPrivateKey.toPublicKey();
|
|
164
|
+
const ownerSig: Signature = signBinding(req.ownerPrivateKey, {
|
|
165
|
+
licenseHash: req.licenseHash,
|
|
166
|
+
deviceCommitment: req.deviceCommitment,
|
|
167
|
+
slotIndex: req.slotIndex,
|
|
168
|
+
nonce,
|
|
169
|
+
slotState,
|
|
170
|
+
expirySlot,
|
|
171
|
+
zkAppAddress: req.zkAppAddress,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const resp = await fetcher(`${baseUrl(req.keeperUrl)}/prove/bind`, {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: { 'Content-Type': 'application/json' },
|
|
177
|
+
signal: req.signal,
|
|
178
|
+
body: JSON.stringify({
|
|
179
|
+
licenseHash: req.licenseHash,
|
|
180
|
+
buyerAddress: req.buyerAddress,
|
|
181
|
+
zkAppAddress: req.zkAppAddress,
|
|
182
|
+
network: req.network,
|
|
183
|
+
ownerPubKey: encodePublicKey(ownerPubKey),
|
|
184
|
+
ownerSig: encodeSignature(ownerSig),
|
|
185
|
+
deviceCommitment: req.deviceCommitment,
|
|
186
|
+
slotIndex: req.slotIndex,
|
|
187
|
+
nonce,
|
|
188
|
+
}),
|
|
189
|
+
});
|
|
190
|
+
if (!resp.ok) {
|
|
191
|
+
const parsed = await readErrorBody(resp);
|
|
192
|
+
const queueBusy = tryQueueBusyFromBody(parsed);
|
|
193
|
+
if (queueBusy) throw queueBusy;
|
|
194
|
+
throw keeperError(parsed);
|
|
195
|
+
}
|
|
196
|
+
return await resp.json() as ProveBindResponse;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// POST /confirm/bind — pass-through no-op that clears the staged
|
|
200
|
+
// intent on the backend. Call this only after the wallet has returned
|
|
201
|
+
// a real txHash; a cancelled or dropped signature leaves the intent
|
|
202
|
+
// to expire on its own.
|
|
203
|
+
export async function confirmBind(req: ConfirmBindRequest): Promise<{ ok: true }> {
|
|
204
|
+
const fetcher = req.fetcher ?? fetch;
|
|
205
|
+
const resp = await fetcher(`${baseUrl(req.keeperUrl)}/confirm/bind`, {
|
|
206
|
+
method: 'POST',
|
|
207
|
+
headers: { 'Content-Type': 'application/json' },
|
|
208
|
+
signal: req.signal,
|
|
209
|
+
body: JSON.stringify({
|
|
210
|
+
zkAppAddress: req.zkAppAddress,
|
|
211
|
+
licenseHash: req.licenseHash,
|
|
212
|
+
txHash: req.txHash,
|
|
213
|
+
}),
|
|
214
|
+
});
|
|
215
|
+
if (!resp.ok) throw new Error(await readJsonError(resp));
|
|
216
|
+
return { ok: true };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export type BindStatus = {
|
|
220
|
+
// 'confirmed' once eventSync has observed the DeviceBound event on-chain
|
|
221
|
+
// and the license record's devices[slotIndex] reflects the new commitment.
|
|
222
|
+
// 'pending' while the tx is in flight. 'unknown' if the license record
|
|
223
|
+
// hasn't been persisted yet.
|
|
224
|
+
status: 'confirmed' | 'pending' | 'unknown';
|
|
225
|
+
slotIndex?: number;
|
|
226
|
+
deviceCommitment?: string;
|
|
227
|
+
elapsedMs?: number;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
export type PollBindStatusOptions = {
|
|
231
|
+
keeperUrl: string;
|
|
232
|
+
network: 'devnet' | 'mainnet';
|
|
233
|
+
zkAppAddress: string;
|
|
234
|
+
licenseHash: string;
|
|
235
|
+
slotIndex: number;
|
|
236
|
+
// The commitment the slot should carry once the tx has landed —
|
|
237
|
+
// typically `device.deviceCommitment` from ProveBindResponse or the
|
|
238
|
+
// value fed into proveBind. Confirmation requires the on-chain record
|
|
239
|
+
// to reflect exactly this value at `slotIndex`.
|
|
240
|
+
deviceCommitment: string;
|
|
241
|
+
fetcher?: typeof fetch;
|
|
242
|
+
// Mina slots are ~90 s and a tx usually lands within a few slots, so
|
|
243
|
+
// 10 s ticks keep the UI live without hammering the backend.
|
|
244
|
+
intervalMs?: number; // default 10 000
|
|
245
|
+
timeoutMs?: number; // default 20 min
|
|
246
|
+
signal?: AbortSignal;
|
|
247
|
+
onTick?: (status: BindStatus) => void;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// Polls GET /license/:zkAppAddress/:licenseHash until the bind has
|
|
251
|
+
// landed on-chain (the target slot's commitment matches the expected
|
|
252
|
+
// value) or the timeout fires. Mirrors pollRenewStatus in shape.
|
|
253
|
+
export async function pollBindStatus(opts: PollBindStatusOptions): Promise<BindStatus> {
|
|
254
|
+
const fetcher = opts.fetcher ?? fetch;
|
|
255
|
+
const interval = opts.intervalMs ?? 10_000;
|
|
256
|
+
const timeout = opts.timeoutMs ?? 20 * 60 * 1000;
|
|
257
|
+
const startedAt = Date.now();
|
|
258
|
+
const deadline = startedAt + timeout;
|
|
259
|
+
const url = `${baseUrl(opts.keeperUrl)}/license/${encodeURIComponent(opts.zkAppAddress)}/${encodeURIComponent(opts.licenseHash)}?network=${opts.network}`;
|
|
260
|
+
|
|
261
|
+
while (true) {
|
|
262
|
+
if (opts.signal?.aborted) throw new Error('pollBindStatus aborted');
|
|
263
|
+
let status: BindStatus = { status: 'unknown', elapsedMs: Date.now() - startedAt };
|
|
264
|
+
try {
|
|
265
|
+
const resp = await fetcher(url, { signal: opts.signal });
|
|
266
|
+
if (resp.ok) {
|
|
267
|
+
const body = await resp.json() as {
|
|
268
|
+
found?: boolean;
|
|
269
|
+
devices?: Array<{ slotIndex: number; deviceCommitment: string }>;
|
|
270
|
+
};
|
|
271
|
+
if (body.found) {
|
|
272
|
+
const slot = (body.devices ?? []).find(d => d.slotIndex === opts.slotIndex);
|
|
273
|
+
const commitment = slot?.deviceCommitment ?? '0';
|
|
274
|
+
if (commitment === opts.deviceCommitment) {
|
|
275
|
+
status = {
|
|
276
|
+
status: 'confirmed',
|
|
277
|
+
slotIndex: opts.slotIndex,
|
|
278
|
+
deviceCommitment: commitment,
|
|
279
|
+
elapsedMs: Date.now() - startedAt,
|
|
280
|
+
};
|
|
281
|
+
} else {
|
|
282
|
+
status = {
|
|
283
|
+
status: 'pending',
|
|
284
|
+
slotIndex: opts.slotIndex,
|
|
285
|
+
deviceCommitment: commitment,
|
|
286
|
+
elapsedMs: Date.now() - startedAt,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
} catch { /* transient network — try again on next tick */ }
|
|
292
|
+
opts.onTick?.(status);
|
|
293
|
+
if (status.status === 'confirmed') return status;
|
|
294
|
+
if (Date.now() >= deadline) return status;
|
|
295
|
+
await new Promise<void>((r) => setTimeout(r, interval));
|
|
296
|
+
}
|
|
297
|
+
}
|
package/src/buyClient.ts
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* buyClient.ts
|
|
6
|
+
*
|
|
7
|
+
* Browser-safe client for the buy flow. Exposes the three backend roundtrips
|
|
8
|
+
* (derive identity → prove → confirm) as small, individually testable
|
|
9
|
+
* functions plus a `pollBuyStatus` helper. Deliberately does NOT talk to a
|
|
10
|
+
* wallet — the caller drives the wallet step between `proveBuy` and
|
|
11
|
+
* `confirmBuy`, so the SDK stays wallet-agnostic and can be reused by CLI
|
|
12
|
+
* tools, alternate UIs, and third-party integrators.
|
|
13
|
+
*
|
|
14
|
+
* Peer dep: o1js (for Poseidon + Pallas PrivateKey/PublicKey/Signature).
|
|
15
|
+
* The ownership-signing surface (ownershipSignature.ts) uses Mina-native
|
|
16
|
+
* Schnorr so the passphrase-derived keypair also authorizes /prove/bind
|
|
17
|
+
* and /prove/unbind — verified IN-CIRCUIT there via Signature.verify(),
|
|
18
|
+
* which requires o1js. Every buy-flow consumer already ships o1js for the
|
|
19
|
+
* buy-witness path, so this is not new cost.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { resolveFreezeState } from './freezeClient.js';
|
|
23
|
+
import type { PurchaseReceipt } from './receipt.js';
|
|
24
|
+
import { REFUND_WINDOW_N, TOLERANCE_WINDOW_N } from './contractInterface.js';
|
|
25
|
+
import { readErrorBody, tryQueueBusyFromBody, keeperError } from './proveErrors.js';
|
|
26
|
+
import { deriveOwnershipKeypair } from './ownershipSignature.js';
|
|
27
|
+
import { licenseHashFromPubkey } from './ownershipLicenseHash.js';
|
|
28
|
+
import type { PrivateKey, PublicKey } from 'o1js';
|
|
29
|
+
|
|
30
|
+
export type BuyIdentity = {
|
|
31
|
+
// Pallas Schnorr pubkey. The buyer keeps it locally; on-chain the leaf's
|
|
32
|
+
// `licenseHash` IS `Poseidon([pk.x, pk.isOdd.toField()])` (the circuit
|
|
33
|
+
// asserts this at buy time), so no separate owner-pubkey commitment is
|
|
34
|
+
// needed — the pubkey is re-derived from the passphrase whenever a
|
|
35
|
+
// bind/unbind signature is needed.
|
|
36
|
+
publicKey: PublicKey;
|
|
37
|
+
// Mina canonical base58 encoding of publicKey. Convenience field for
|
|
38
|
+
// callers that need to persist or transmit the pubkey shape.
|
|
39
|
+
publicKeyBase58: string;
|
|
40
|
+
// Passphrase-derived Pallas private key. Callers should scope this to
|
|
41
|
+
// the activation call and let it be GC'd rather than persisting it —
|
|
42
|
+
// the passphrase is the real secret, and the keypair is re-derivable
|
|
43
|
+
// from it deterministically at any point.
|
|
44
|
+
privateKey: PrivateKey;
|
|
45
|
+
// Poseidon([pk.x, pk.isOdd.toField()]) as a decimal Field string.
|
|
46
|
+
// Sent to /prove/buy exactly like the old Poseidon(Poseidon(passphrase))
|
|
47
|
+
// licenseHash was. On-chain LicensingApp treats licenseHash as opaque,
|
|
48
|
+
// so no contract change is needed for the preimage swap.
|
|
49
|
+
licenseHash: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Derive the ownership keypair + licenseHash the buy flow keys off.
|
|
53
|
+
// Deterministic in (passphrase, zkAppAddress) — same inputs always map
|
|
54
|
+
// to the same keypair (via HKDF-SHA256) and therefore the same
|
|
55
|
+
// licenseHash on every device, so a buyer can re-derive their license
|
|
56
|
+
// identity anywhere from the passphrase and the app address. Binding
|
|
57
|
+
// the derivation to the app address means a leaked passphrase for app A
|
|
58
|
+
// cannot be replayed as a valid activation for app B.
|
|
59
|
+
//
|
|
60
|
+
// HKDF still needs WebCrypto (Node ≥20, Chromium 137+, Safari 17+,
|
|
61
|
+
// Firefox 130+); the scalar reduction and Pallas group operations are
|
|
62
|
+
// pure JS through o1js. Async because subtle.deriveBits is async.
|
|
63
|
+
export async function deriveBuyIdentity(
|
|
64
|
+
passphrase: string,
|
|
65
|
+
zkAppAddress: string,
|
|
66
|
+
): Promise<BuyIdentity> {
|
|
67
|
+
const { privateKey, publicKey, publicKeyBase58 } = await deriveOwnershipKeypair(passphrase, zkAppAddress);
|
|
68
|
+
const licenseHash = licenseHashFromPubkey(publicKey);
|
|
69
|
+
return { publicKey, publicKeyBase58, privateKey, licenseHash };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type ProveBuyRequest = {
|
|
73
|
+
// Standing consent (zklicensing/agent/actions) when an AI agent gives the
|
|
74
|
+
// withdrawal waiver for its owner; only sent with the waiver.
|
|
75
|
+
agentConsent?: unknown;
|
|
76
|
+
keeperUrl: string;
|
|
77
|
+
// Poseidon([pallasPubkey.x, pallasPubkey.isOdd.toField()]) as a decimal
|
|
78
|
+
// Field string — computed client-side via deriveBuyIdentity(). This IS
|
|
79
|
+
// the on-chain owner-pubkey commitment (the circuit asserts equality at
|
|
80
|
+
// buy time), so anyone with the passphrase (and hence the pubkey) can
|
|
81
|
+
// prove ownership at bind / unbind time without the passphrase ever
|
|
82
|
+
// leaving the device.
|
|
83
|
+
licenseHash: string;
|
|
84
|
+
buyerAddress: string;
|
|
85
|
+
zkAppAddress: string;
|
|
86
|
+
// Base58-encoded Pallas Schnorr pubkey derived from the passphrase — use
|
|
87
|
+
// `deriveBuyIdentity(passphrase, zkAppAddress).publicKeyBase58`. Bind /
|
|
88
|
+
// unbind proofs Signature.verify() against this key without the passphrase
|
|
89
|
+
// ever leaving the buyer's device. Circuit-side the licensing service
|
|
90
|
+
// asserts `licenseHash === Poseidon([pk.x, pk.isOdd.toField()])`, so a
|
|
91
|
+
// mismatch is rejected front-door.
|
|
92
|
+
ownerPubKey: string;
|
|
93
|
+
// Mina network this buy targets. Must match the network the vendor deployed
|
|
94
|
+
// the zkApp on — the licensing service uses this to pick the matching
|
|
95
|
+
// GraphQL endpoint and signer. Persist in your ProofFile so renew/refund
|
|
96
|
+
// can pass the same value.
|
|
97
|
+
network: 'devnet' | 'mainnet';
|
|
98
|
+
duration: 0 | 1 | 2 | 3; // 0 = Monthly, 1 = Yearly, 2 = 5 Years, 3 = Perpetual
|
|
99
|
+
buyerCountry: string; // ISO 3166-1 alpha-2
|
|
100
|
+
// Buyer's postal code — MANDATORY when buyerCountry === 'US' (state-level
|
|
101
|
+
// sales-tax attribution). Ignored server-side for non-US buyers. Format is
|
|
102
|
+
// enforced by the backend: 5 digits or ZIP+4 ("12345" or "12345-6789").
|
|
103
|
+
buyerZip?: string;
|
|
104
|
+
// Off by default. Mandatory only when this buy's 14-day refund window
|
|
105
|
+
// would overlap the scheduled hardfork — i.e. currentSlot + REFUND_WINDOW_N
|
|
106
|
+
// > freezeAfterSlot. Buyers whose full window ends before the freeze get
|
|
107
|
+
// no warning (nothing to warn about). When the current slot cannot be
|
|
108
|
+
// resolved client-side (caller-supplied freezeAfterSlot or env override),
|
|
109
|
+
// the SDK falls back to requiring the waiver as a safe default; the
|
|
110
|
+
// backend's own guard runs the same overlap check against its authoritative
|
|
111
|
+
// slot. Persisted on the LicenseRecord as `refundWaiverAcceptedAt`.
|
|
112
|
+
refundWaiverAccepted?: boolean;
|
|
113
|
+
// Optional freeze-slot override. When omitted the SDK reads
|
|
114
|
+
// `process.env.FREEZE_AFTER_SLOT` (Node only), else GET /health.
|
|
115
|
+
freezeAfterSlot?: number | null;
|
|
116
|
+
fetcher?: typeof fetch;
|
|
117
|
+
signal?: AbortSignal;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export type ProveBuyResponse = {
|
|
121
|
+
provenTxJson: string;
|
|
122
|
+
expirySlot: number;
|
|
123
|
+
licenseHash: string;
|
|
124
|
+
verificationKeyHash: string;
|
|
125
|
+
purchaseSlot: number;
|
|
126
|
+
purchaseAmount: string; // nanomina
|
|
127
|
+
duration: number;
|
|
128
|
+
durationLabel: string;
|
|
129
|
+
// Deployment generation this buy is proved against. Threaded through into
|
|
130
|
+
// ProofFile so a later `renew.ts` can detect generation drift against
|
|
131
|
+
// GET /apps/:appId and renew on the current deployment.
|
|
132
|
+
generation: number;
|
|
133
|
+
// Key the backend stored the per-app in-flight slot under. Callers pass
|
|
134
|
+
// this back to POST /tx-broadcast after the wallet returns a hash so the
|
|
135
|
+
// slot is extended from signing-TTL to landing-TTL. For registered apps
|
|
136
|
+
// this is the appId; for direct-paste buys it falls back to zkAppAddress.
|
|
137
|
+
appKey: string;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export type ConfirmBuyRequest = {
|
|
141
|
+
keeperUrl: string;
|
|
142
|
+
zkAppAddress: string;
|
|
143
|
+
licenseHash: string;
|
|
144
|
+
txHash: string;
|
|
145
|
+
fetcher?: typeof fetch;
|
|
146
|
+
signal?: AbortSignal;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export type BuyStatus = {
|
|
150
|
+
// Mirrors the backend's txLandWatch machine (kind: 'buy'):
|
|
151
|
+
// 'pending' → 'landed' (tx included on-chain, terminal, success)
|
|
152
|
+
// → 'timeout' (watch deadline hit without inclusion, terminal, failure)
|
|
153
|
+
// → 'failed' (included but failed on chain, e.g. the wallet could
|
|
154
|
+
// pay the fee but not the price: no license, only the network
|
|
155
|
+
// fee was paid; `failureReason` says why; terminal)
|
|
156
|
+
// 'unknown' if the backend has no watch record for this licenseHash yet —
|
|
157
|
+
// e.g. confirmBuy hasn't run, or the watch's retention window expired.
|
|
158
|
+
status: 'unknown' | 'pending' | 'landed' | 'timeout' | 'failed';
|
|
159
|
+
failureReason?: string;
|
|
160
|
+
txHash?: string;
|
|
161
|
+
landedAtBlockHeight?: number;
|
|
162
|
+
landedAtSlot?: number;
|
|
163
|
+
elapsedMs?: number;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
function baseUrl(url: string): string {
|
|
167
|
+
return url.replace(/\/+$/, '');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function readJsonError(resp: Response): Promise<string> {
|
|
171
|
+
try {
|
|
172
|
+
const body = await resp.json() as Record<string, unknown>;
|
|
173
|
+
if (typeof body.error === 'string') return body.error;
|
|
174
|
+
} catch { /* body wasn't JSON */ }
|
|
175
|
+
return `HTTP ${resp.status}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// POST /prove/buy — backend compiles + proves the buy tx.
|
|
179
|
+
// The buyer's wallet then signs the returned provenTxJson.
|
|
180
|
+
//
|
|
181
|
+
// Pre-network guards:
|
|
182
|
+
// - salesFrozen === true → refuse immediately (backend would 423 anyway).
|
|
183
|
+
// - freezeAfterSlot announced AND this buy's 14-day refund window would
|
|
184
|
+
// extend past the freeze slot AND refundWaiverAccepted !== true →
|
|
185
|
+
// refuse (buyer must explicitly accept that the scheduled hardfork
|
|
186
|
+
// can truncate their refund window). Buys whose full window ends
|
|
187
|
+
// before the freeze proceed without a waiver.
|
|
188
|
+
//
|
|
189
|
+
// Endpoint routing:
|
|
190
|
+
// - refundWaiverAccepted === true → POST /prove/buy-no-escrow. The no-escrow
|
|
191
|
+
// circuit method pays vendor+platform directly (98/2 split, no escrow
|
|
192
|
+
// tree write). This is chosen either voluntarily by the buyer (opting out
|
|
193
|
+
// of the 14-day statutory right of withdrawal in exchange for immediate
|
|
194
|
+
// delivery) or forced by a freeze overlap.
|
|
195
|
+
// - refundWaiverAccepted !== true → POST /prove/buy (standard escrow path
|
|
196
|
+
// with a 14-day refund window).
|
|
197
|
+
/**
|
|
198
|
+
* The buyer's country guessed from their IP address (keeper `GET /geo/country`),
|
|
199
|
+
* to pre-fill the country field. Null when the keeper has no geolocation
|
|
200
|
+
* database, the address has no country, or the keeper is unreachable. The
|
|
201
|
+
* buyer's own choice is what `proveBuy` sends; the keeper separately records
|
|
202
|
+
* the country it sees as VAT evidence, and never the address.
|
|
203
|
+
*/
|
|
204
|
+
export async function detectBuyerCountry(keeperUrl: string, fetcher: typeof fetch = fetch): Promise<string | null> {
|
|
205
|
+
try {
|
|
206
|
+
const res = await fetcher(`${keeperUrl.replace(/\/$/, '')}/geo/country`, { cache: 'no-store' });
|
|
207
|
+
if (!res.ok) return null;
|
|
208
|
+
const body = await res.json() as { country?: unknown };
|
|
209
|
+
return typeof body.country === 'string' && /^[A-Z]{2}$/.test(body.country) ? body.country : null;
|
|
210
|
+
} catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function proveBuy(req: ProveBuyRequest): Promise<ProveBuyResponse> {
|
|
216
|
+
const fetcher = req.fetcher ?? fetch;
|
|
217
|
+
const freeze = await resolveFreezeState({
|
|
218
|
+
keeperUrl: req.keeperUrl,
|
|
219
|
+
network: req.network,
|
|
220
|
+
freezeAfterSlot: req.freezeAfterSlot,
|
|
221
|
+
fetcher,
|
|
222
|
+
signal: req.signal,
|
|
223
|
+
});
|
|
224
|
+
if (freeze.salesFrozen) {
|
|
225
|
+
throw new Error('Sales frozen — new buys refused ahead of a scheduled hardfork');
|
|
226
|
+
}
|
|
227
|
+
let windowOverlapsFreeze = false;
|
|
228
|
+
if (freeze.freezeAfterSlot !== null) {
|
|
229
|
+
// Waiver required when the refund window would overlap the freeze, or
|
|
230
|
+
// when the current slot is unknown client-side (safe default). The
|
|
231
|
+
// +TOLERANCE_WINDOW_N margin accounts for slot-precondition slop
|
|
232
|
+
// between prove and inclusion: the real inclusion slot can be up to
|
|
233
|
+
// TOLERANCE_WINDOW_N slots past `currentSlot`, so we include that in
|
|
234
|
+
// the overlap comparison rather than let a late inclusion silently
|
|
235
|
+
// push the refund deadline past the freeze without a waiver.
|
|
236
|
+
windowOverlapsFreeze =
|
|
237
|
+
freeze.currentSlot === null ||
|
|
238
|
+
freeze.currentSlot + REFUND_WINDOW_N + TOLERANCE_WINDOW_N > freeze.freezeAfterSlot;
|
|
239
|
+
if (windowOverlapsFreeze && req.refundWaiverAccepted !== true) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
`refundWaiverAccepted must be true — the scheduled hardfork at slot ${freeze.freezeAfterSlot} can truncate the 14-day refund window`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const useNoEscrow = req.refundWaiverAccepted === true;
|
|
246
|
+
const endpoint = useNoEscrow ? '/prove/buy-no-escrow' : '/prove/buy';
|
|
247
|
+
const resp = await fetcher(`${baseUrl(req.keeperUrl)}${endpoint}`, {
|
|
248
|
+
method: 'POST',
|
|
249
|
+
headers: { 'Content-Type': 'application/json' },
|
|
250
|
+
signal: req.signal,
|
|
251
|
+
body: JSON.stringify({
|
|
252
|
+
licenseHash: req.licenseHash,
|
|
253
|
+
buyerAddress: req.buyerAddress,
|
|
254
|
+
zkAppAddress: req.zkAppAddress,
|
|
255
|
+
ownerPubKey: req.ownerPubKey,
|
|
256
|
+
network: req.network,
|
|
257
|
+
duration: req.duration,
|
|
258
|
+
buyerCountry: req.buyerCountry,
|
|
259
|
+
...(req.buyerZip ? { buyerZip: req.buyerZip } : {}),
|
|
260
|
+
...(req.refundWaiverAccepted === true ? { refundWaiverAccepted: true } : {}),
|
|
261
|
+
...(req.refundWaiverAccepted === true && req.agentConsent ? { agentConsent: req.agentConsent } : {}),
|
|
262
|
+
}),
|
|
263
|
+
});
|
|
264
|
+
if (!resp.ok) {
|
|
265
|
+
// Distinct guidance for the no-escrow path: a 404 here means the
|
|
266
|
+
// vendor's zkApp was deployed before the no-escrow method existed. Fix
|
|
267
|
+
// is a redeploy (§12.13); until
|
|
268
|
+
// then the escrowless (waiver) path is unavailable on this vendor.
|
|
269
|
+
if (useNoEscrow && resp.status === 404) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
'Backend does not offer /prove/buy-no-escrow — this vendor\'s zkApp does not support the escrowless (waiver) path; vendor must redeploy',
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
const parsed = await readErrorBody(resp);
|
|
275
|
+
const queueBusy = tryQueueBusyFromBody(parsed);
|
|
276
|
+
if (queueBusy) throw queueBusy;
|
|
277
|
+
throw keeperError(parsed);
|
|
278
|
+
}
|
|
279
|
+
return await resp.json() as ProveBuyResponse;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// POST /confirm/buy — promotes the in-memory intent staged by /prove/buy
|
|
283
|
+
// into a persisted (pending: true) LicenseRecord. Call this only after the
|
|
284
|
+
// wallet has actually returned a txHash — a cancelled or dropped signature
|
|
285
|
+
// leaves the intent to expire on its own.
|
|
286
|
+
/**
|
|
287
|
+
* Returns the buyer's receipt (zklicensing/receipt) when the keeper issued
|
|
288
|
+
* one: put it in the license file as `receipt` (Vendor Terms §7.1).
|
|
289
|
+
*/
|
|
290
|
+
export async function confirmBuy(req: ConfirmBuyRequest): Promise<{ ok: true; receipt?: PurchaseReceipt }> {
|
|
291
|
+
const fetcher = req.fetcher ?? fetch;
|
|
292
|
+
const resp = await fetcher(`${baseUrl(req.keeperUrl)}/confirm/buy`, {
|
|
293
|
+
method: 'POST',
|
|
294
|
+
headers: { 'Content-Type': 'application/json' },
|
|
295
|
+
signal: req.signal,
|
|
296
|
+
body: JSON.stringify({
|
|
297
|
+
zkAppAddress: req.zkAppAddress,
|
|
298
|
+
licenseHash: req.licenseHash,
|
|
299
|
+
txHash: req.txHash,
|
|
300
|
+
}),
|
|
301
|
+
});
|
|
302
|
+
if (!resp.ok) throw new Error(await readJsonError(resp));
|
|
303
|
+
const body = await resp.json().catch(() => ({})) as { receipt?: PurchaseReceipt };
|
|
304
|
+
return { ok: true, ...(body?.receipt ? { receipt: body.receipt } : {}) };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export type PollBuyStatusOptions = {
|
|
308
|
+
keeperUrl: string;
|
|
309
|
+
licenseHash: string;
|
|
310
|
+
fetcher?: typeof fetch;
|
|
311
|
+
// How often to poll and how long to wait total. Buy-watch flips 'pending'
|
|
312
|
+
// → 'landed' within seconds of tx inclusion, so a snappy interval keeps
|
|
313
|
+
// the UI live without hammering the backend.
|
|
314
|
+
intervalMs?: number; // default 3 000
|
|
315
|
+
timeoutMs?: number; // default 10 min
|
|
316
|
+
signal?: AbortSignal;
|
|
317
|
+
onTick?: (status: BuyStatus) => void;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// Polls GET /buy/status until it returns a terminal state ('landed' — success,
|
|
321
|
+
// tx included on-chain; 'failed' — included but failed on chain; or 'timeout'
|
|
322
|
+
// — watch deadline passed without inclusion)
|
|
323
|
+
// or the local poll timeout fires. 'unknown' just means the backend hasn't seen
|
|
324
|
+
// a watch record yet (confirmBuy still landing, or retention window expired).
|
|
325
|
+
export async function pollBuyStatus(opts: PollBuyStatusOptions): Promise<BuyStatus> {
|
|
326
|
+
const fetcher = opts.fetcher ?? fetch;
|
|
327
|
+
const interval = opts.intervalMs ?? 3_000;
|
|
328
|
+
const timeout = opts.timeoutMs ?? 10 * 60 * 1000;
|
|
329
|
+
const deadline = Date.now() + timeout;
|
|
330
|
+
const url = `${baseUrl(opts.keeperUrl)}/buy/status?licenseHash=${encodeURIComponent(opts.licenseHash)}`;
|
|
331
|
+
|
|
332
|
+
while (true) {
|
|
333
|
+
if (opts.signal?.aborted) throw new Error('pollBuyStatus aborted');
|
|
334
|
+
let status: BuyStatus = { status: 'unknown' };
|
|
335
|
+
try {
|
|
336
|
+
const resp = await fetcher(url, { signal: opts.signal });
|
|
337
|
+
if (resp.ok) status = await resp.json() as BuyStatus;
|
|
338
|
+
} catch { /* transient network — try again on next tick */ }
|
|
339
|
+
opts.onTick?.(status);
|
|
340
|
+
if (status.status === 'landed' || status.status === 'timeout' || status.status === 'failed') return status;
|
|
341
|
+
if (Date.now() >= deadline) return status;
|
|
342
|
+
await new Promise<void>((r) => setTimeout(r, interval));
|
|
343
|
+
}
|
|
344
|
+
}
|