zklicensing 0.1.0-beta.2 → 0.1.0-beta.3
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 +132 -23
- package/build/archiveBootstrap.d.ts.map +1 -0
- package/build/archiveBootstrap.js +1 -1
- package/build/archiveBootstrap.js.map +1 -0
- package/build/buyClient.d.ts +2 -0
- package/build/buyClient.d.ts.map +1 -0
- package/build/buyClient.js +8 -2
- package/build/buyClient.js.map +1 -0
- package/build/contractInterface.d.ts.map +1 -0
- package/build/contractInterface.js +1 -1
- package/build/contractInterface.js.map +1 -0
- package/build/countryStats.d.ts +6 -1
- package/build/countryStats.d.ts.map +1 -0
- package/build/countryStats.js +33 -9
- package/build/countryStats.js.map +1 -0
- package/build/deployClient.d.ts.map +1 -0
- package/build/deployClient.js +1 -1
- package/build/deployClient.js.map +1 -0
- package/build/freezeClient.d.ts.map +1 -0
- package/build/freezeClient.js +1 -1
- package/build/freezeClient.js.map +1 -0
- package/build/generationClient.d.ts.map +1 -0
- package/build/generationClient.js +1 -1
- package/build/generationClient.js.map +1 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +1 -1
- package/build/index.js.map +1 -0
- package/build/licenseProof.d.ts.map +1 -0
- package/build/licenseProof.js +1 -1
- package/build/licenseProof.js.map +1 -0
- package/build/licenseRecord.d.ts +10 -0
- package/build/licenseRecord.d.ts.map +1 -0
- package/build/licenseRecord.js +1 -1
- package/build/licenseRecord.js.map +1 -0
- package/build/licenseStore.d.ts +1 -1
- package/build/licenseStore.d.ts.map +1 -0
- package/build/licenseStore.js +4 -2
- package/build/licenseStore.js.map +1 -0
- package/build/manifestVerify.d.ts.map +1 -0
- package/build/manifestVerify.js +1 -1
- package/build/manifestVerify.js.map +1 -0
- package/build/migrateClient.d.ts +5 -21
- package/build/migrateClient.d.ts.map +1 -0
- package/build/migrateClient.js +29 -17
- package/build/migrateClient.js.map +1 -0
- package/build/ownership.d.ts.map +1 -0
- package/build/ownership.js +1 -1
- package/build/ownership.js.map +1 -0
- package/build/proveErrors.d.ts +35 -0
- package/build/proveErrors.d.ts.map +1 -0
- package/build/proveErrors.js +49 -0
- package/build/proveErrors.js.map +1 -0
- package/build/proveRetry.d.ts +38 -0
- package/build/proveRetry.d.ts.map +1 -0
- package/build/proveRetry.js +89 -0
- package/build/proveRetry.js.map +1 -0
- package/build/refundClient.d.ts.map +1 -0
- package/build/refundClient.js +1 -1
- package/build/refundClient.js.map +1 -0
- package/build/renewClient.d.ts +1 -0
- package/build/renewClient.d.ts.map +1 -0
- package/build/renewClient.js +9 -3
- package/build/renewClient.js.map +1 -0
- package/build/statsClient.d.ts +28 -0
- package/build/statsClient.d.ts.map +1 -0
- package/build/statsClient.js +34 -5
- package/build/statsClient.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/vendorAccountClient.d.ts +55 -0
- package/build/vendorAccountClient.d.ts.map +1 -0
- package/build/vendorAccountClient.js +44 -0
- package/build/vendorAccountClient.js.map +1 -0
- package/build/verifyCore.d.ts.map +1 -0
- package/build/verifyCore.js +1 -1
- package/build/verifyCore.js.map +1 -0
- package/build/verifyService.d.ts.map +1 -0
- package/build/verifyService.js +1 -1
- package/build/verifyService.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 +19 -4
- package/src/archiveBootstrap.ts +285 -0
- package/src/buyClient.ts +259 -0
- package/src/contractInterface.d.ts +55 -0
- package/src/contractInterface.ts +242 -0
- package/src/countryStats.ts +171 -0
- package/src/deployClient.ts +388 -0
- package/src/freezeClient.ts +117 -0
- package/src/generationClient.ts +164 -0
- package/src/index.ts +392 -0
- package/src/licenseProof.d.ts +26 -0
- package/src/licenseProof.ts +48 -0
- package/src/licenseRecord.ts +91 -0
- package/src/licenseStore.ts +358 -0
- package/src/manifestVerify.ts +106 -0
- package/src/migrateClient.ts +82 -0
- package/src/ownership.ts +198 -0
- package/src/proveErrors.ts +70 -0
- package/src/refundClient.ts +144 -0
- package/src/renewClient.ts +209 -0
- package/src/statsClient.ts +190 -0
- package/src/txBroadcastClient.ts +48 -0
- package/src/vendorAccountClient.ts +106 -0
- package/src/verifyCore.ts +84 -0
- package/src/verifyService.ts +799 -0
- package/src/zipToState.ts +94 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* contractInterface.ts
|
|
6
|
+
*
|
|
7
|
+
* Public contract interface: event Structs, slot/grace constants, duration
|
|
8
|
+
* helpers, and a minimal `LicensingAppEventsContract` SmartContract subclass
|
|
9
|
+
* that has the events declaration but no @method bodies.
|
|
10
|
+
*
|
|
11
|
+
* The full `LicensingApp` (with provable method bodies) lives in the private
|
|
12
|
+
* zklicensing-app package; only the things needed to decode events and reason
|
|
13
|
+
* about durations are published here so a public verify service can replay
|
|
14
|
+
* the archive without pulling in the contract source.
|
|
15
|
+
*
|
|
16
|
+
* `CANONICAL_VK_HASH` and `CANONICAL_LICENSE_PROOF_VK_HASH` are the
|
|
17
|
+
* verification-key hashes of the two published circuits — `LicensingApp`
|
|
18
|
+
* (the on-chain zkApp) and `LicenseOwnershipProgram` (the ownership
|
|
19
|
+
* challenge/response `ZkProgram`), respectively. Both are hand-maintained
|
|
20
|
+
* constants — bump them whenever the corresponding circuit changes and
|
|
21
|
+
* rerun the keeper's boot assertion (see zklicensing-app/src/keeperService.ts)
|
|
22
|
+
* / the verify service's `ensureCompiled()` check to catch drift.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { declareState, fetchAccount, Field, PublicKey, SmartContract, Struct, UInt32, UInt64, MerkleMap } from 'o1js';
|
|
26
|
+
|
|
27
|
+
// ------------------------------------------------------------
|
|
28
|
+
// EVENT STRUCTS
|
|
29
|
+
// ------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
// Struct() returns a class whose type references o1js internals that fall
|
|
32
|
+
// outside this package's rootDir (npm hoists o1js to the workspace root).
|
|
33
|
+
// Annotating each export as `any` keeps tsc from inferring (and therefore
|
|
34
|
+
// emitting) those internal type references. Consumers get full typing through
|
|
35
|
+
// the hand-written contractInterface.d.ts shim.
|
|
36
|
+
//
|
|
37
|
+
// The clean alternative — `export class X extends Struct({...}) {}` as the
|
|
38
|
+
// mina-fungible-token repo does — only works when o1js sits inside rootDir,
|
|
39
|
+
// which a workspace member can't guarantee.
|
|
40
|
+
|
|
41
|
+
export const LicenseIssuedEvent: any = Struct({
|
|
42
|
+
licenseHash: Field,
|
|
43
|
+
buyer: PublicKey,
|
|
44
|
+
amount: UInt64,
|
|
45
|
+
slot: UInt32,
|
|
46
|
+
expirySlot: UInt32,
|
|
47
|
+
});
|
|
48
|
+
export type LicenseIssuedEvent = any;
|
|
49
|
+
|
|
50
|
+
export const ExpiryUpdateEvent: any = Struct({
|
|
51
|
+
licenseHash: Field,
|
|
52
|
+
newExpiry: UInt32,
|
|
53
|
+
});
|
|
54
|
+
export type ExpiryUpdateEvent = any;
|
|
55
|
+
|
|
56
|
+
export const PriceUpdatedEvent: any = Struct({
|
|
57
|
+
priceMonthly: UInt64,
|
|
58
|
+
priceYearly: UInt64,
|
|
59
|
+
priceFiveYear: UInt64,
|
|
60
|
+
});
|
|
61
|
+
export type PriceUpdatedEvent = any;
|
|
62
|
+
|
|
63
|
+
export const AppCreatedEvent: any = Struct({
|
|
64
|
+
vendor: PublicKey,
|
|
65
|
+
});
|
|
66
|
+
export type AppCreatedEvent = any;
|
|
67
|
+
|
|
68
|
+
// Emitted by `migrateLicense` when a license issued under an ancestor
|
|
69
|
+
// deployment is re-minted into this deployment at zero payment. `licenseHash`
|
|
70
|
+
// is stable across all deployments because the buyer's derived hash is what
|
|
71
|
+
// identifies the license; `expirySlot` is copied verbatim from the ancestor's
|
|
72
|
+
// licensesRoot entry; `ancestorGen` is the generation index the migration
|
|
73
|
+
// resolved against (needed so archive replay and auditors can trace which
|
|
74
|
+
// frozen root supplied the entry — skip-generation migrations are one-hop).
|
|
75
|
+
export const LicenseMigratedEvent: any = Struct({
|
|
76
|
+
licenseHash: Field,
|
|
77
|
+
expirySlot: UInt32,
|
|
78
|
+
ancestorGen: UInt32,
|
|
79
|
+
});
|
|
80
|
+
export type LicenseMigratedEvent = any;
|
|
81
|
+
|
|
82
|
+
// ------------------------------------------------------------
|
|
83
|
+
// CONSTANTS (slot math — 90 s/slot after Mesa upgrade)
|
|
84
|
+
// ------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
export const MONTHLY_SLOTS_N = 28_800; // 30 days × 24 × 40
|
|
87
|
+
export const ONE_YEAR_SLOTS_N = 350_640; // 365.25 days × 24 × 40 (Julian year)
|
|
88
|
+
export const FIVE_YEAR_SLOTS_N = 1_753_200; // 5 × ONE_YEAR_SLOTS_N
|
|
89
|
+
export const REFUND_WINDOW_N = 13_440;
|
|
90
|
+
export const GRACE_PERIOD_N = 6_720;
|
|
91
|
+
export const MS_PER_SLOT_N = 90_000; // Mesa slot duration in ms
|
|
92
|
+
export const SLOTS_PER_DAY_N = 960; // 86_400_000 / MS_PER_SLOT_N
|
|
93
|
+
|
|
94
|
+
// Slot tolerance for the globalSlotSinceGenesis precondition. Applied uniformly to
|
|
95
|
+
// every method that reads currentSlot. 40 slots × 90 s = 1 hour — sized to
|
|
96
|
+
// clear the honest-user prove+wallet+mempool latency envelope. The refund vs
|
|
97
|
+
// release windows stay disjoint by construction: requestRefund asserts against
|
|
98
|
+
// the lower bound (currentSlot ≤ purchaseSlot + REFUND_WINDOW), releaseFunds
|
|
99
|
+
// asserts against a threshold shifted by +TOLERANCE_WINDOW so neither can
|
|
100
|
+
// land on the same real slot as the other. See AUDIT_BRIEF.md §7.
|
|
101
|
+
export const TOLERANCE_WINDOW_N = 40;
|
|
102
|
+
|
|
103
|
+
export const EMPTY: Field = Field(0);
|
|
104
|
+
export const EMPTY_ROOT: Field = new MerkleMap().getRoot();
|
|
105
|
+
|
|
106
|
+
// ------------------------------------------------------------
|
|
107
|
+
// DURATION HELPERS
|
|
108
|
+
// ------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
export const DURATION_LABELS = ['1 Month', '1 Year', '5 Years'] as const;
|
|
111
|
+
export type DurationLabel = typeof DURATION_LABELS[number];
|
|
112
|
+
|
|
113
|
+
export const DURATION_INDEX = { '1 Month': 0, '1 Year': 1, '5 Years': 2 } as const;
|
|
114
|
+
|
|
115
|
+
export function durationSlotsFromLabel(label?: string | null): number | null {
|
|
116
|
+
if (label === '1 Month') return MONTHLY_SLOTS_N;
|
|
117
|
+
if (label === '1 Year') return ONE_YEAR_SLOTS_N;
|
|
118
|
+
if (label === '5 Years') return FIVE_YEAR_SLOTS_N;
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function durationIndexFromLabel(label?: string | null): number | null {
|
|
123
|
+
if (label === '1 Month') return 0;
|
|
124
|
+
if (label === '1 Year') return 1;
|
|
125
|
+
if (label === '5 Years') return 2;
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function durationLabelFromIndex(idx: number): DurationLabel | null {
|
|
130
|
+
return DURATION_LABELS[idx] ?? null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ------------------------------------------------------------
|
|
134
|
+
// EVENTS-ONLY CONTRACT
|
|
135
|
+
//
|
|
136
|
+
// A SmartContract subclass with the same `events: { ... }` declaration as
|
|
137
|
+
// LicensingApp but no `@method` bodies. Used by archive-replay code to call
|
|
138
|
+
// `.fetchEvents()` without depending on the private circuit source.
|
|
139
|
+
// ------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
export class LicensingAppEventsContract extends SmartContract {
|
|
142
|
+
events: any = {
|
|
143
|
+
appCreated: AppCreatedEvent,
|
|
144
|
+
licenseIssued: LicenseIssuedEvent,
|
|
145
|
+
licenseIssuedNoEscrow: LicenseIssuedEvent,
|
|
146
|
+
licenseRenewed: ExpiryUpdateEvent,
|
|
147
|
+
licenseMigrated: LicenseMigratedEvent,
|
|
148
|
+
refundIssued: Field,
|
|
149
|
+
fundsReleased: Field,
|
|
150
|
+
priceUpdated: PriceUpdatedEvent,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// State-slot layout mirrors LicensingApp's declareState(). Read-only from here
|
|
155
|
+
// — no @method bodies exist. Consumers use `.version.get()`, `.licensesRoot.get()`
|
|
156
|
+
// etc. after fetchAccount(). `pricesPacked` is a single Field with the three
|
|
157
|
+
// UInt64 tier prices packed as [pM | pY<<64 | pF<<128]; unpack with
|
|
158
|
+
// `unpackPrices` if you need the individual tiers off-chain.
|
|
159
|
+
declareState(LicensingAppEventsContract, {
|
|
160
|
+
licensesRoot: Field,
|
|
161
|
+
vendor: PublicKey,
|
|
162
|
+
escrowRoot: Field,
|
|
163
|
+
pricesPacked: Field,
|
|
164
|
+
version: UInt32,
|
|
165
|
+
ancestorsRoot: Field,
|
|
166
|
+
} as any);
|
|
167
|
+
|
|
168
|
+
// Off-chain mirror of the in-circuit price packing (see LicensingApp.ts). Kept
|
|
169
|
+
// here so the events service can decode `pricesPacked` slots without pulling
|
|
170
|
+
// the private contract source.
|
|
171
|
+
export function unpackPrices(packed: Field): { priceMonthly: UInt64; priceYearly: UInt64; priceFiveYear: UInt64 } {
|
|
172
|
+
const raw = packed.toBigInt();
|
|
173
|
+
const mask = (1n << 64n) - 1n;
|
|
174
|
+
return {
|
|
175
|
+
priceMonthly: UInt64.from(raw & mask),
|
|
176
|
+
priceYearly: UInt64.from((raw >> 64n) & mask),
|
|
177
|
+
priceFiveYear: UInt64.from((raw >> 128n) & mask),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ------------------------------------------------------------
|
|
182
|
+
// SUPPORTED SOURCE VERSIONS
|
|
183
|
+
//
|
|
184
|
+
// On-chain `version` slot is set at init() and can never change (setPermissions
|
|
185
|
+
// is impossible; setVerificationKey is impossibleDuringCurrentVersion). A
|
|
186
|
+
// keeper / verifier that only understands v1 state decodes must refuse to serve
|
|
187
|
+
// a zkApp deployed from a different source revision — otherwise it could
|
|
188
|
+
// mis-decode events or feed a stale state format to the verifier.
|
|
189
|
+
//
|
|
190
|
+
// Add new versions here once the corresponding decoder / verifier code path
|
|
191
|
+
// lands. Never remove: dropping a version is a support-removal decision that
|
|
192
|
+
// belongs in a release note, not silent breakage.
|
|
193
|
+
// ------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
export const SUPPORTED_LICENSING_APP_VERSIONS: readonly number[] = [1];
|
|
196
|
+
|
|
197
|
+
// Read the immutable `version` slot from a deployed LicensingApp. Returns null
|
|
198
|
+
// when the account is not (yet) a zkApp — caller decides whether to retry
|
|
199
|
+
// (deploy still pending) or reject (wrong address).
|
|
200
|
+
export async function fetchLicensingAppVersion(zkAppAddress: string): Promise<number | null> {
|
|
201
|
+
const pub = PublicKey.fromBase58(zkAppAddress);
|
|
202
|
+
await fetchAccount({ publicKey: pub });
|
|
203
|
+
const contract: any = new LicensingAppEventsContract(pub);
|
|
204
|
+
try {
|
|
205
|
+
const v = contract.version.get();
|
|
206
|
+
return Number(v.toString());
|
|
207
|
+
} catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ------------------------------------------------------------
|
|
213
|
+
// CANONICAL VK HASHES
|
|
214
|
+
//
|
|
215
|
+
// Per-generation, append-only. The keeper boots with `LicensingApp.compile()`
|
|
216
|
+
// and asserts the resulting VK hash matches `CANONICAL_VK_HASH` (the current
|
|
217
|
+
// generation's row); the verify service resolves an address's generation via
|
|
218
|
+
// AppRecord.deploymentHistory and asserts the on-chain VK hash matches
|
|
219
|
+
// `CANONICAL_VK_HASHES[generation]`. Retired-generation licenses (e.g. a
|
|
220
|
+
// 5-year buyer whose license lives on v1 after the platform bumped to v2/v3)
|
|
221
|
+
// continue to verify because the historical row is still present. A genuine
|
|
222
|
+
// old circuit deployed at a fresh address fails: the address is not in any
|
|
223
|
+
// `deploymentHistory[]`, so the resolver returns null and verify rejects.
|
|
224
|
+
//
|
|
225
|
+
// Rules:
|
|
226
|
+
// - Append the new generation's row at each hardfork migration; never
|
|
227
|
+
// delete historical rows.
|
|
228
|
+
// - Bump `CURRENT_GENERATION` to the new key alongside the row.
|
|
229
|
+
// - `CANONICAL_VK_HASH` is derived from the record — drift test and keeper
|
|
230
|
+
// boot-assert are unchanged.
|
|
231
|
+
// ------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
export const CANONICAL_VK_HASHES: Readonly<Record<number, string>> = {
|
|
234
|
+
1: '25510637873522061606403949338800808972643356911784243535869508122414992770753',
|
|
235
|
+
// 2: '…' — append at each hardfork migration, never delete.
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export const CURRENT_GENERATION: number = 1;
|
|
239
|
+
|
|
240
|
+
export const CANONICAL_VK_HASH: string = CANONICAL_VK_HASHES[CURRENT_GENERATION];
|
|
241
|
+
|
|
242
|
+
export const CANONICAL_LICENSE_PROOF_VK_HASH: string = '6592142093294558010952934797827640785154417146015648013584661926355710165692';
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Copyright (c) 2025-2026 zkLicensing project developers
|
|
2
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* countryStats.ts
|
|
6
|
+
*
|
|
7
|
+
* Buyer-country breakdown of a vendor's revenue, bucketed by UTC calendar year.
|
|
8
|
+
* Structured for tax reporting: one row per (year, appId, countryCode) with
|
|
9
|
+
* license count + the vendor's cut in MINA. Both the keeper (source of
|
|
10
|
+
* truth for /stats) and the dashboard UI import from here so the numbers can
|
|
11
|
+
* never drift between them.
|
|
12
|
+
*
|
|
13
|
+
* Purchases attribute to the year the buyer confirmed on-chain (LicenseRecord
|
|
14
|
+
* `createdAt` ISO timestamp). Legacy records that predate `createdAt` fall
|
|
15
|
+
* back to slot-derived time. Renewals attribute to the renewal year via
|
|
16
|
+
* `renewals[i].slot → time`. Refunded purchases contribute zero revenue but
|
|
17
|
+
* still count the license (a purchase happened even if the money was
|
|
18
|
+
* returned) — remove `count` from refunded rows client-side if you want a
|
|
19
|
+
* strictly net view.
|
|
20
|
+
*
|
|
21
|
+
* Browser-safe: pure computation, no fs, no fetch.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { LicenseRecord } from './licenseRecord.js';
|
|
25
|
+
import { zipToState } from './zipToState.js';
|
|
26
|
+
|
|
27
|
+
export { zipToState } from './zipToState.js';
|
|
28
|
+
|
|
29
|
+
// LicensingApp charges a 2 % platform fee (200 basis points); the vendor
|
|
30
|
+
// receives the remaining 9 800 bps. Keep this in sync with the on-chain
|
|
31
|
+
// constant in LicensingApp.
|
|
32
|
+
const PLATFORM_FEE_BASIS_POINTS = 200n;
|
|
33
|
+
const VENDOR_BPS = 10_000n - PLATFORM_FEE_BASIS_POINTS;
|
|
34
|
+
|
|
35
|
+
// 90 s/slot post-Mesa. Used only for slot→time fallback attribution.
|
|
36
|
+
const SLOT_DURATION_MS = 90_000;
|
|
37
|
+
|
|
38
|
+
export type CountryBucket = {
|
|
39
|
+
count: number; // licenses purchased by buyers from this country (renewals don't bump)
|
|
40
|
+
revenueMina: number; // vendor cut in MINA — purchase + renewals combined
|
|
41
|
+
// State sub-buckets — populated only for US buyers, derived from
|
|
42
|
+
// `buyerZip` via zipToState. States are the jurisdictional unit that US
|
|
43
|
+
// sales-tax filings care about; the raw ZIP stays on the LicenseRecord
|
|
44
|
+
// for audit trail but isn't rolled up here. Missing entirely on non-US
|
|
45
|
+
// rows; may be missing on US rows for legacy records that predate ZIP
|
|
46
|
+
// capture or whose ZIP prefix doesn't resolve to a state.
|
|
47
|
+
states?: Record<string, { count: number; revenueMina: number }>;
|
|
48
|
+
};
|
|
49
|
+
export type CountryYearBreakdown = {
|
|
50
|
+
aggregate: Record<string, CountryBucket>; // keyed by ISO-alpha-2 (or '??' when missing)
|
|
51
|
+
byApp: Record<string, Record<string, CountryBucket>>; // appId → country → bucket
|
|
52
|
+
};
|
|
53
|
+
export type CountryBreakdown = {
|
|
54
|
+
years: number[]; // UTC calendar years with activity, most-recent first
|
|
55
|
+
byYear: Record<number, CountryYearBreakdown>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// tzOffsetMinutes: signed minutes east of UTC (e.g. +60 for CET, -480 for PST).
|
|
59
|
+
// Purchase/renewal timestamps are shifted by this offset before extracting the
|
|
60
|
+
// calendar year, so the "tax year" cut-over aligns with the operator's local
|
|
61
|
+
// clock instead of hardcoded UTC. Defaults to 0 (UTC) for callers who don't
|
|
62
|
+
// pass it — preserves the pre-tz behavior.
|
|
63
|
+
export function computeCountryBreakdown(
|
|
64
|
+
apps: { appId: string; zkAppAddress: string }[],
|
|
65
|
+
records: LicenseRecord[],
|
|
66
|
+
currentSlot: number,
|
|
67
|
+
nowMs: number,
|
|
68
|
+
tzOffsetMinutes: number = 0,
|
|
69
|
+
): CountryBreakdown {
|
|
70
|
+
const appByAddress = new Map(apps.map(a => [a.zkAppAddress, a.appId] as const));
|
|
71
|
+
const byYear: Record<number, CountryYearBreakdown> = {};
|
|
72
|
+
const yearSet = new Set<number>();
|
|
73
|
+
const tzShiftMs = tzOffsetMinutes * 60_000;
|
|
74
|
+
|
|
75
|
+
const ensureYear = (year: number): CountryYearBreakdown => {
|
|
76
|
+
let yb = byYear[year];
|
|
77
|
+
if (!yb) {
|
|
78
|
+
yb = { aggregate: {}, byApp: {} };
|
|
79
|
+
byYear[year] = yb;
|
|
80
|
+
yearSet.add(year);
|
|
81
|
+
}
|
|
82
|
+
return yb;
|
|
83
|
+
};
|
|
84
|
+
const ensureAppBucket = (yb: CountryYearBreakdown, appId: string): Record<string, CountryBucket> => {
|
|
85
|
+
let ab = yb.byApp[appId];
|
|
86
|
+
if (!ab) {
|
|
87
|
+
ab = {};
|
|
88
|
+
yb.byApp[appId] = ab;
|
|
89
|
+
}
|
|
90
|
+
return ab;
|
|
91
|
+
};
|
|
92
|
+
const add = (
|
|
93
|
+
bucket: Record<string, CountryBucket>,
|
|
94
|
+
code: string,
|
|
95
|
+
count: number,
|
|
96
|
+
revenueNano: bigint,
|
|
97
|
+
state?: string,
|
|
98
|
+
): void => {
|
|
99
|
+
let b = bucket[code];
|
|
100
|
+
if (!b) {
|
|
101
|
+
b = { count: 0, revenueMina: 0 };
|
|
102
|
+
bucket[code] = b;
|
|
103
|
+
}
|
|
104
|
+
b.count += count;
|
|
105
|
+
b.revenueMina += Number(revenueNano) / 1e9;
|
|
106
|
+
// State sub-bucket only for US rows whose ZIP resolved to a state.
|
|
107
|
+
// Non-US or unresolvable rows just add to the country total.
|
|
108
|
+
if (code === 'US' && state) {
|
|
109
|
+
const states = b.states ?? (b.states = {});
|
|
110
|
+
let sb = states[state];
|
|
111
|
+
if (!sb) {
|
|
112
|
+
sb = { count: 0, revenueMina: 0 };
|
|
113
|
+
states[state] = sb;
|
|
114
|
+
}
|
|
115
|
+
sb.count += count;
|
|
116
|
+
sb.revenueMina += Number(revenueNano) / 1e9;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
const slotToYear = (slot: number): number | null => {
|
|
120
|
+
if (currentSlot <= 0) return null;
|
|
121
|
+
const ms = nowMs - (currentSlot - slot) * SLOT_DURATION_MS;
|
|
122
|
+
const y = new Date(ms + tzShiftMs).getUTCFullYear();
|
|
123
|
+
return Number.isFinite(y) ? y : null;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
for (const r of records) {
|
|
127
|
+
const appId = appByAddress.get(r.zkAppAddress);
|
|
128
|
+
if (!appId) continue;
|
|
129
|
+
const country = r.buyerCountry && /^[A-Z]{2}$/.test(r.buyerCountry) ? r.buyerCountry : '??';
|
|
130
|
+
// Only US rows carry a meaningful ZIP for sales-tax purposes; derive
|
|
131
|
+
// the state jurisdiction here and let `add` roll it up. Non-US ZIPs
|
|
132
|
+
// and unresolvable prefixes fall through to country-total only.
|
|
133
|
+
const state = country === 'US' && r.buyerZip ? zipToState(r.buyerZip) : undefined;
|
|
134
|
+
|
|
135
|
+
// Purchase — attribute to the year of confirm-on-chain (createdAt).
|
|
136
|
+
// Fallback: slot-derived time for legacy records that lack createdAt.
|
|
137
|
+
let purchaseYear: number | null = null;
|
|
138
|
+
if (r.createdAt) {
|
|
139
|
+
const y = new Date(new Date(r.createdAt).getTime() + tzShiftMs).getUTCFullYear();
|
|
140
|
+
if (Number.isFinite(y)) purchaseYear = y;
|
|
141
|
+
}
|
|
142
|
+
if (purchaseYear === null && r.purchaseSlot > 0) {
|
|
143
|
+
purchaseYear = slotToYear(r.purchaseSlot);
|
|
144
|
+
}
|
|
145
|
+
if (purchaseYear !== null) {
|
|
146
|
+
const yb = ensureYear(purchaseYear);
|
|
147
|
+
const ab = ensureAppBucket(yb, appId);
|
|
148
|
+
const purchaseCut = (!r.refunded && r.purchaseAmount && r.purchaseAmount !== '0')
|
|
149
|
+
? (BigInt(r.purchaseAmount) * VENDOR_BPS) / 10_000n
|
|
150
|
+
: 0n;
|
|
151
|
+
add(ab, country, 1, purchaseCut, state);
|
|
152
|
+
add(yb.aggregate, country, 1, purchaseCut, state);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Renewals — revenue only (no license count), attributed to renewal year.
|
|
156
|
+
for (const rn of r.renewals ?? []) {
|
|
157
|
+
const y = slotToYear(rn.slot);
|
|
158
|
+
if (y === null) continue;
|
|
159
|
+
const yb = ensureYear(y);
|
|
160
|
+
const ab = ensureAppBucket(yb, appId);
|
|
161
|
+
const cut = (BigInt(rn.amount) * VENDOR_BPS) / 10_000n;
|
|
162
|
+
add(ab, country, 0, cut, state);
|
|
163
|
+
add(yb.aggregate, country, 0, cut, state);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
years: Array.from(yearSet).sort((a, b) => b - a),
|
|
169
|
+
byYear,
|
|
170
|
+
};
|
|
171
|
+
}
|