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.
Files changed (111) hide show
  1. package/README.md +132 -23
  2. package/build/archiveBootstrap.d.ts.map +1 -0
  3. package/build/archiveBootstrap.js +1 -1
  4. package/build/archiveBootstrap.js.map +1 -0
  5. package/build/buyClient.d.ts +2 -0
  6. package/build/buyClient.d.ts.map +1 -0
  7. package/build/buyClient.js +8 -2
  8. package/build/buyClient.js.map +1 -0
  9. package/build/contractInterface.d.ts.map +1 -0
  10. package/build/contractInterface.js +1 -1
  11. package/build/contractInterface.js.map +1 -0
  12. package/build/countryStats.d.ts +6 -1
  13. package/build/countryStats.d.ts.map +1 -0
  14. package/build/countryStats.js +33 -9
  15. package/build/countryStats.js.map +1 -0
  16. package/build/deployClient.d.ts.map +1 -0
  17. package/build/deployClient.js +1 -1
  18. package/build/deployClient.js.map +1 -0
  19. package/build/freezeClient.d.ts.map +1 -0
  20. package/build/freezeClient.js +1 -1
  21. package/build/freezeClient.js.map +1 -0
  22. package/build/generationClient.d.ts.map +1 -0
  23. package/build/generationClient.js +1 -1
  24. package/build/generationClient.js.map +1 -0
  25. package/build/index.d.ts.map +1 -0
  26. package/build/index.js +1 -1
  27. package/build/index.js.map +1 -0
  28. package/build/licenseProof.d.ts.map +1 -0
  29. package/build/licenseProof.js +1 -1
  30. package/build/licenseProof.js.map +1 -0
  31. package/build/licenseRecord.d.ts +10 -0
  32. package/build/licenseRecord.d.ts.map +1 -0
  33. package/build/licenseRecord.js +1 -1
  34. package/build/licenseRecord.js.map +1 -0
  35. package/build/licenseStore.d.ts +1 -1
  36. package/build/licenseStore.d.ts.map +1 -0
  37. package/build/licenseStore.js +4 -2
  38. package/build/licenseStore.js.map +1 -0
  39. package/build/manifestVerify.d.ts.map +1 -0
  40. package/build/manifestVerify.js +1 -1
  41. package/build/manifestVerify.js.map +1 -0
  42. package/build/migrateClient.d.ts +5 -21
  43. package/build/migrateClient.d.ts.map +1 -0
  44. package/build/migrateClient.js +29 -17
  45. package/build/migrateClient.js.map +1 -0
  46. package/build/ownership.d.ts.map +1 -0
  47. package/build/ownership.js +1 -1
  48. package/build/ownership.js.map +1 -0
  49. package/build/proveErrors.d.ts +35 -0
  50. package/build/proveErrors.d.ts.map +1 -0
  51. package/build/proveErrors.js +49 -0
  52. package/build/proveErrors.js.map +1 -0
  53. package/build/proveRetry.d.ts +38 -0
  54. package/build/proveRetry.d.ts.map +1 -0
  55. package/build/proveRetry.js +89 -0
  56. package/build/proveRetry.js.map +1 -0
  57. package/build/refundClient.d.ts.map +1 -0
  58. package/build/refundClient.js +1 -1
  59. package/build/refundClient.js.map +1 -0
  60. package/build/renewClient.d.ts +1 -0
  61. package/build/renewClient.d.ts.map +1 -0
  62. package/build/renewClient.js +9 -3
  63. package/build/renewClient.js.map +1 -0
  64. package/build/statsClient.d.ts +28 -0
  65. package/build/statsClient.d.ts.map +1 -0
  66. package/build/statsClient.js +34 -5
  67. package/build/statsClient.js.map +1 -0
  68. package/build/txBroadcastClient.d.ts +26 -0
  69. package/build/txBroadcastClient.d.ts.map +1 -0
  70. package/build/txBroadcastClient.js +20 -0
  71. package/build/txBroadcastClient.js.map +1 -0
  72. package/build/vendorAccountClient.d.ts +55 -0
  73. package/build/vendorAccountClient.d.ts.map +1 -0
  74. package/build/vendorAccountClient.js +44 -0
  75. package/build/vendorAccountClient.js.map +1 -0
  76. package/build/verifyCore.d.ts.map +1 -0
  77. package/build/verifyCore.js +1 -1
  78. package/build/verifyCore.js.map +1 -0
  79. package/build/verifyService.d.ts.map +1 -0
  80. package/build/verifyService.js +1 -1
  81. package/build/verifyService.js.map +1 -0
  82. package/build/zipToState.d.ts +2 -0
  83. package/build/zipToState.d.ts.map +1 -0
  84. package/build/zipToState.js +94 -0
  85. package/build/zipToState.js.map +1 -0
  86. package/package.json +19 -4
  87. package/src/archiveBootstrap.ts +285 -0
  88. package/src/buyClient.ts +259 -0
  89. package/src/contractInterface.d.ts +55 -0
  90. package/src/contractInterface.ts +242 -0
  91. package/src/countryStats.ts +171 -0
  92. package/src/deployClient.ts +388 -0
  93. package/src/freezeClient.ts +117 -0
  94. package/src/generationClient.ts +164 -0
  95. package/src/index.ts +392 -0
  96. package/src/licenseProof.d.ts +26 -0
  97. package/src/licenseProof.ts +48 -0
  98. package/src/licenseRecord.ts +91 -0
  99. package/src/licenseStore.ts +358 -0
  100. package/src/manifestVerify.ts +106 -0
  101. package/src/migrateClient.ts +82 -0
  102. package/src/ownership.ts +198 -0
  103. package/src/proveErrors.ts +70 -0
  104. package/src/refundClient.ts +144 -0
  105. package/src/renewClient.ts +209 -0
  106. package/src/statsClient.ts +190 -0
  107. package/src/txBroadcastClient.ts +48 -0
  108. package/src/vendorAccountClient.ts +106 -0
  109. package/src/verifyCore.ts +84 -0
  110. package/src/verifyService.ts +799 -0
  111. 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
+ }