diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c1d32aa..8e765cb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,6 +31,9 @@ jobs:
- name: Typecheck SDK
run: pnpm --filter @lucent/sdk typecheck
+ - name: Analytics privacy and integration tests
+ run: pnpm test:analytics
+
- name: Typecheck app
run: pnpm --filter @lucent/app typecheck
diff --git a/ANALYTICS.md b/ANALYTICS.md
new file mode 100644
index 0000000..238f3d2
--- /dev/null
+++ b/ANALYTICS.md
@@ -0,0 +1,137 @@
+# Tester analytics and launch guide
+
+Status: integration prepared on `feat/tester-analytics`; collection starts only after configuration, deployment and live verification. No historical visits are backfilled. This work does not activate an analytics account, deploy the application, or establish user traction.
+
+Public application: **https://www.lucent-pay.xyz/**. Its public response reported Vercel hosting when checked November 9, 2026. The repository also has Cloudflare deployment tooling; do not assume that deploying that Worker updates this public Vercel site.
+
+## What this measures
+
+Use Umami for aggregate traffic and explicit product events. The public tracker website identifier is not an API key. No paid subscription, API credential, server database, wallet identifier or new npm dependency is needed by this integration.
+
+| Question | Dashboard signal | Interpretation |
+| --- | --- | --- |
+| How much interest is there? | Visitors, visits, pageviews and known routes | Estimated browsers/sessions, not verified people |
+| Where do recruited testers come from? | `source` and `campaign` event properties | Only supported, explicitly tagged invitation links |
+| Can people connect? | `wallet_connect_started` → `wallet_connected` → `wallet_ready`, filter `mode=manual` | Connection and initial balance refresh are separate stages |
+| Are people testing payments? | `registration_confirmed`, `deposit_confirmed`, `transfer_confirmed`, `withdrawal_confirmed` | Browser-reported chain success, not independently audited transactions |
+| Is payroll being tried? | `payroll_confirmed` | A confirmed run, not employees/customers acquired |
+| Is verification useful? | `disclosure_created`, `disclosure_verified` | Generated/verified disclosure, no amount or proof collected |
+| Where is onboarding failing? | `action_failed` filtered by `action`, and `wallet_connect_failed` | Counts failures without raw messages or private context |
+
+Events can be blocked, lost, repeated, forged or suppressed by privacy controls. Umami visitor identification can group people on a shared network or split one person across devices/identifier rotation. Event totals are not unique testers, unique wallets, durable returning-user counts, or customer counts. New registration events also exclude accounts registered before instrumentation.
+
+## Account setup
+
+1. Create an account at [Umami Cloud](https://cloud.umami.is/). The [pricing page](https://umami.is/pricing), checked November 9, 2026, lists a Hobby plan with one website, 100,000 monthly events and six-month retention. Recheck limits when signing up; a paid trial is not required for the initial integration. Pageviews/custom events/properties contribute to usage.
+2. Add a website named **Lucent Testnet**, using the domain **www.lucent-pay.xyz**. Copy its Website ID and the script URL supplied in its tracking snippet. Do not paste an API key into any `NEXT_PUBLIC_` variable.
+3. Put these **build-time** variables in the hosting project's Production environment:
+
+ ```dotenv
+ NEXT_PUBLIC_ANALYTICS_ENABLED=true
+ NEXT_PUBLIC_ANALYTICS_HOSTNAME=www.lucent-pay.xyz
+ NEXT_PUBLIC_UMAMI_WEBSITE_ID=c8ecbbeb-dea0-41e0-adc3-2a95b80a0f1e
+ NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://cloud.umami.is/script.js
+ ```
+
+ Use your snippet's trusted script URL if different. The Website ID above was supplied from the Lucent Umami tracking snippet; it is public configuration. See [the environment example](packages/app/.env.example). Keep Preview and Development collection disabled. The hostname guard additionally rejects preview domains, localhost and non-HTTPS pages.
+4. In the Vercel project serving the public domain, save the variables, deploy the reviewed code and trigger a **new production build**. Setting runtime variables without rebuilding does not update browser-bundled `NEXT_PUBLIC_` values. Use the existing project's deployment process; do not create a second production site just for analytics.
+5. Confirm the apex domain either redirects to `www.lucent-pay.xyz` or choose one canonical hostname consistently. The current configuration accepts only the exact configured hostname. Unmatched aliases do not collect.
+
+The Lucent Website ID was supplied on November 9, 2026 and is now recorded in the environment example and local build configuration. Hosting Production environment configuration, deployment and live dashboard verification are still required. Do not install an extra default tracking snippet: automatic tracking is intentionally disabled in this integration to prevent duplicate pageviews and URL leakage.
+
+## Privacy and controls
+
+The code builds a new allowlisted payload for every request. Only known route names, event names, the `testnet` label and selected enum properties can leave this module. Unknown routes become `/other`. Query strings, fragments, referrer URLs, dynamic document titles and arbitrary event properties are dropped.
+
+**Never add** wallet addresses (including hashed addresses), balances, amounts, salaries, names/emails, transaction hashes, contract IDs, auditor secrets, proof bundles, form contents or raw exception messages to analytics. There is no `identify` call, session replay, automatic error capture, performance capture, click/form autocapture or wallet-to-visitor mapping.
+
+A third-party tracker still runs on the page when enabled; trust the configured provider and review any future changes. Umami receives normal network request information and processes IP/User-Agent for rotating visitor identification. This integration does not promise anonymity from the provider. See its [metric definitions](https://docs.umami.is/docs/metric-definitions) and [privacy policy](https://umami.is/privacy). Hosting, RPC and blockchain services have separate data visibility.
+
+The public `/privacy` page explains tracking and provides two per-browser controls:
+
+- **Disable analytics** for a visitor who opts out.
+- **Exclude as team/internal testing** before the owner, collaborators or automation test the public deployment.
+
+Both stop collection, as do available DNT/GPC browser signals and Umami's `umami.disabled` flag. Storage failure disables collection. Preferences survive reloads without touching wallet state. Do not clear wallet storage to reset analytics. Tracking is also off in development, when configuration is missing/invalid, or on the wrong hostname.
+
+The app buffers at most 32 allowlisted events while the script loads and discards them on opt-out. Delivery is best effort. Transport errors never fail or delay a financial operation. A script request already made cannot be undone by later opting out.
+
+## Event dictionary
+
+| Event | Trigger | Allowed contextual properties |
+| --- | --- | --- |
+| Pageview (no event name) | Initial page and client-side route changes | `network`, optional `source`/`campaign` |
+| `wallet_connect_started` | Explicit connect or automatic reconnect starts | `mode=manual` or `auto` |
+| `wallet_connected` | Wallet connection/key setup returns | `mode` |
+| `wallet_ready` | Initial wallet state refresh completes | `mode` |
+| `wallet_connect_failed` | Connection or initial refresh fails | `mode` |
+| `action_started`, `action_failed` | Recognized payment action begins or its UI flow rejects | `action=registration/deposit/merge/transfer/withdrawal/payroll` |
+| `registration_confirmed` | A new registration chain invocation succeeds | No account data |
+| `deposit_confirmed`, `merge_confirmed` | Respective chain invocation succeeds | No financial data |
+| `transfer_confirmed`, `withdrawal_confirmed` | Respective chain invocation succeeds | No financial data |
+| `payroll_confirmed` | `execute_run` chain invocation succeeds | No run ID, employee count or salaries |
+| `disclosure_created` | Holder generates a disclosure bundle | `direction=sent/received` |
+| `disclosure_verified` | Verification returns successfully | No amount, event reference or verifier key |
+
+Confirmed events are emitted after successful chain submission/polling, before local state persistence. A later local persistence failure can therefore generate both a confirmed event and an action-failure event. These measure different stages; do not subtract every failure from confirmed transactions. A timed-out client may miss a landed transaction. No success is inferred merely from a button click. Existing-account registration is not counted as a new registration.
+
+## Tagged invitations
+
+Only these sources are accepted: `stellar`, `scf`, `twitter`, `telegram`, `discord`, `github`, `newsletter`, `community`. Campaigns: `testnet-pilot`, `scf46`. All other values are discarded, including arbitrary referral identifiers. Add a new enum deliberately if needed.
+
+Example invitation:
+
+```text
+https://www.lucent-pay.xyz/?utm_source=stellar&utm_campaign=testnet-pilot
+```
+
+Use these as aggregate campaign tags, not per-person links. The tags are recorded only on events whose current URL has those parameters; there is no cross-page campaign persistence. Analyze tagged landing visits separately from later aggregate actions. The properties are custom `source` and `campaign` fields, not automatically populated native UTM reports. Full referrers are deliberately omitted.
+
+## Production smoke check before recruitment
+
+1. Set your normal browser to internal exclusion via `/privacy`. Use a **separate non-excluded browser/profile** for a one-time smoke check and record its date so you can exclude that QA window from reporting. Privacy controls or ad blockers may legitimately prevent collection.
+2. Open the tagged invitation. In browser Network tools, check that the configured script loads and the collector accepts a **single** pageview. Inspect the outgoing JSON: only allowlisted route/name/properties, no URL query, referrer or sensitive fields. Confirm it appears in Umami.
+3. Navigate `/` → `/home` → `/shield` without reloads. Confirm exactly one pageview per navigation. Reload once and verify one additional view. Duplicate snippets must be removed.
+4. With disposable Testnet accounts, connect, register if new, obtain the correct test USDC/trustline, deposit, merge, send and withdraw a tiny amount. Verify the relevant events appear **after** on-chain success. Cancel one wallet request and check the failure stage without a confirmed event. No need to exercise unsafe escrow for the initial pilot.
+5. Toggle opt-out on `/privacy`, navigate and attempt an action. No subsequent collector events should be sent; the app must still work. Check DNT/GPC and internal exclusion similarly. A preview deployment should never collect into the production property.
+6. Repeat one flow from another device/browser with Freighter support. Document failures rather than treating a successful build as browser acceptance. Confirm the deployed `/privacy` notice and control links are available.
+
+The public site already responds successfully and exposes the proving isolation headers. That is a read-only deployment observation, not a fresh payment acceptance test. Analytics cannot start before the real Website ID is configured and this smoke check passes.
+
+## Recruitment plan and SCF evidence
+
+Start with **5–10 invited Testnet testers after the smoke check**, then expand after fixing the first onboarding failures. This is a proposed cohort size, not existing traction. Ask testers to use disposable accounts and no real funds. Explain the public demo auditor and recovery limitations. Start with confidential payments/disclosure; escrow remains a demonstration with its known custody caveat. Preserve local wallet state; reliable full-history recovery is not supplied by analytics.
+
+For each reporting week, save a dated dashboard export/screenshot with timezone and date range, release commit, campaign label and team/QA exclusions. Confirm export/report features available on your plan before relying on them; take sanitized screenshots if necessary. Keep evidence outside provider retention windows. Do not make a shared dashboard public without checking the displayed properties.
+
+A useful weekly table:
+
+| Period (UTC) | Estimated visitors | Manual wallet-ready visitors | Transfer event count / distinct visitors | Payroll event count | Feedback participants | Caveats |
+| --- | --- | --- | --- | --- | --- | --- |
+| Fill from observed data | — | — | — | — | — | QA exclusions, blockers, Testnet only |
+
+Where a funnel report is available, use the same date range, visitor unit, filters and order. Otherwise show the counts separately; unrelated daily totals do not establish an ordered conversion funnel. Do not claim retention across identifier rotation or equate connections/reconnects with new people. Keep developer testing and collaborator traffic out of external traction counts.
+
+Suggested application wording, **only after real data exists**: “During [period], Lucent's Testnet site recorded [N] estimated visitors and [M] visitors with a browser-reported confirmed transfer, excluding [documented internal/QA activity]. [K] people separately provided tester feedback.” Distinguish observations from goals and describe the measurement limits.
+
+Pair aggregate charts with reproducible Testnet transaction evidence from consenting pilot participants, issue-resolution notes and anonymized usability findings. Record consented feedback separately from telemetry and do not silently join browser identities to public wallet addresses. Public transaction evidence is not proof of unique people, paid customers, revenue, Mainnet use or security review.
+
+## Developer checks
+
+```bash
+pnpm build:sdk
+pnpm test:analytics
+pnpm --filter @lucent/app typecheck
+pnpm --filter @lucent/app build
+```
+
+The automated analytics test uses a fake tracker and mocked chain; it sends no live telemetry or transactions. It covers payload sanitization, environment/hostname gates, DNT/GPC, opt-out/internal exclusion, route deduplication, bounded queue, failure isolation, and rejected-versus-confirmed submission handling. No tests authenticate to the analytics dashboard. Production collection remains unverified until the smoke check above.
+
+Implementation references: `packages/app/lib/analytics.ts`, `components/analytics/Analytics.tsx`, `lib/wallet.ts`, `lib/wallet-context.tsx`, and `/privacy`. Official setup references: [tracker configuration](https://docs.umami.is/docs/tracker-configuration), [custom payloads](https://docs.umami.is/docs/tracker-functions), [installation](https://docs.umami.is/docs/collect-data).
+
+## Local verification record — November 9, 2026
+
+- Analytics privacy/confirmation tests passed with an in-memory fake tracker and rejected/pending/successful submission promises.
+- App TypeScript check and production build passed with a dummy public Website ID.
+- An isolated headless Chrome test of that production build passed initial and client-side pageviews, sensitive query exclusion, opt-out/re-enable, team exclusion across reload, DNT/GPC and preview-domain exclusion. The test routed the site to a local server and replaced the analytics script with a fake collector; RPC and other external services were blocked. No real analytics or payment transactions were sent.
+- No live Umami dashboard or production payment smoke test was performed. The production activation checklist above remains required.
diff --git a/README.md b/README.md
index 82d0a83..11a911c 100644
--- a/README.md
+++ b/README.md
@@ -58,6 +58,10 @@ wallet integration.
- [Acknowledgments](#acknowledgments)
- [License](#license)
+## Tester analytics
+
+Traffic and explicit Testnet action tracking are prepared through Umami, disabled until production configuration is supplied. See [ANALYTICS.md](ANALYTICS.md) for account setup, privacy controls, launch checks and honest SCF evidence reporting.
+
## Quickstart
Requires **Node ≥ 20**, **pnpm 10**. For contract work you'll also need Rust
diff --git a/package.json b/package.json
index 7766315..c787709 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,8 @@
"deploy:contracts": "pnpm --filter @lucent/sdk exec tsx ../../scripts/deploy.ts",
"deploy:app": "pnpm --filter @lucent/app run deploy:cf",
"e2e": "pnpm --filter @lucent/sdk exec tsx ../../scripts/e2e.ts",
- "e2e:disclosure": "pnpm --filter @lucent/sdk exec tsx ../../scripts/e2e-disclosure.ts"
+ "e2e:disclosure": "pnpm --filter @lucent/sdk exec tsx ../../scripts/e2e-disclosure.ts",
+ "test:analytics": "pnpm --filter @lucent/sdk exec tsx --tsconfig ../../packages/app/tsconfig.json ../../packages/app/test/analytics.mjs"
},
"license": "MIT",
"pnpm": {
diff --git a/packages/app/.env.example b/packages/app/.env.example
new file mode 100644
index 0000000..3d6bca0
--- /dev/null
+++ b/packages/app/.env.example
@@ -0,0 +1,8 @@
+# Analytics is off unless explicitly enabled. These values are public, NOT secrets.
+NEXT_PUBLIC_ANALYTICS_ENABLED=false
+# Exact production hostname, no https://, port or path. Preview/local hosts are excluded.
+NEXT_PUBLIC_ANALYTICS_HOSTNAME=www.lucent-pay.xyz
+# Website ID from your Umami tracking snippet (not an API key).
+NEXT_PUBLIC_UMAMI_WEBSITE_ID=c8ecbbeb-dea0-41e0-adc3-2a95b80a0f1e
+# Copy the script URL supplied by Umami. Change only for a trusted instance.
+NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://cloud.umami.is/script.js
diff --git a/packages/app/README.md b/packages/app/README.md
index 1d807de..026159f 100644
--- a/packages/app/README.md
+++ b/packages/app/README.md
@@ -69,3 +69,7 @@ The fix is already in place:
1. `scripts/vendor-bb.mjs` (run by `predev`/`prebuild`) copies bb.js's `dest/browser/` into `public/vendor/bb/` (git-ignored, regenerated each build).
2. The webpack client config aliases the bare `@aztec/bb.js` specifier to `false`.
3. `lib/bb-loader.ts` overrides the SDK's `setUltraHonkBackendLoader` to import `/vendor/bb/index.js` as **native ESM** from that stable path at runtime. The loader's `new Function` must stay lazy — Cloudflare Workers forbid eval at the top level.
+
+## Tester analytics
+
+See [the analytics setup and recruitment guide](../../ANALYTICS.md). Collection is off by default, enabled only for the configured HTTPS production hostname. Public settings and the notice live at `/privacy`. Never add a second automatic tracker snippet or send wallet/payment data to analytics.
diff --git a/packages/app/app/home/disclose-flow.tsx b/packages/app/app/home/disclose-flow.tsx
index 0ba4f01..8546961 100644
--- a/packages/app/app/home/disclose-flow.tsx
+++ b/packages/app/app/home/disclose-flow.tsx
@@ -12,6 +12,7 @@
import { useCallback, useState } from "react";
import type { TransferEvent, DisclosureRequest } from "@lucent/sdk";
import type { ConfidentialWallet } from "@/lib/wallet";
+import { trackEvent } from "@/lib/analytics";
import { errMsg } from "@/lib/err";
import { Button } from "@/components/ui/Button";
import { Textarea } from "@/components/ui/Textarea";
@@ -52,6 +53,7 @@ export function DiscloseFlow({
const request = parseDisclosureRequest(requestJson);
const bundle =
direction === "received" ? await wallet.discloseReceived(ev, request) : await wallet.discloseSent(ev, request);
+ trackEvent("disclosure_created", { direction });
setBundleJson(JSON.stringify(bundle, null, 2));
} catch (e) {
setError(errMsg(e));
diff --git a/packages/app/app/home/home-client.tsx b/packages/app/app/home/home-client.tsx
index 33580bb..773e0f6 100644
--- a/packages/app/app/home/home-client.tsx
+++ b/packages/app/app/home/home-client.tsx
@@ -2,6 +2,7 @@
import { useEffect, useState, type ComponentType } from "react";
import Link from "next/link";
+import { trackEvent } from "@/lib/analytics";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import {
@@ -359,6 +360,7 @@ export function HomeClient() {
}
async function handleRegister() {
+ trackEvent("action_started", { action: "registration" });
setError(null);
setRegisterSteps([{ id: "register", label: "Prove key ownership", status: "active", estSeconds: 4 }]);
setRegistering(true);
@@ -367,6 +369,7 @@ export function HomeClient() {
setRegisterSteps((s) => s.map((x) => ({ ...x, status: "done" })));
await refresh();
} catch (e) {
+ trackEvent("action_failed", { action: "registration" });
setError(errMsg(e));
setRegisterSteps((s) => s.map((x) => ({ ...x, status: "error" })));
} finally {
diff --git a/packages/app/app/layout.tsx b/packages/app/app/layout.tsx
index aaf8a1c..51eefae 100644
--- a/packages/app/app/layout.tsx
+++ b/packages/app/app/layout.tsx
@@ -1,5 +1,6 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
+import { Analytics } from "@/components/analytics/Analytics";
import { WalletProvider } from "@/lib/wallet-context";
export const metadata: Metadata = {
@@ -20,6 +21,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
Lucent is an unaudited Stellar Testnet application. Our demo auditor key is public. Use disposable test accounts and test assets.
+
What we measure
+
When configured for the public deployment, Umami measures page visits and named actions such as connecting a wallet, completing a Testnet transfer, or verifying a disclosure. This helps us improve onboarding and report early testing results. Visitor estimates are not verified people or customer counts.
+
Our analytics payload contains a known page name, event name, Testnet label, and optional allowlisted campaign source. We do not send wallet addresses, amounts, balances, transaction hashes, auditor keys, proofs, form input, raw errors, query strings, URL fragments, or referrer URLs. We do not enable session recording, autocapture or wallet-based identification.
+
Umami receives the network request and processes IP/browser information to estimate visitors using rotating identifiers. We do not add a persistent user ID. This is aggregate product analytics, not anonymity from the analytics service. See Umami's privacy policy. Retention depends on the selected plan; the initial rollout is intended to use the six-month Hobby plan.
+
Your controls
+
Opting out affects this browser only. Do Not Track and Global Privacy Control are also respected when exposed by your browser. Clearing browser storage resets these preferences and can also destroy confidential wallet recovery state—do not clear it just to change analytics settings.
+
+
{analyticsConfig() ? "Analytics is configured for the production hostname." : "Analytics is not configured in this build."}
+ {prefs && <>
+ {prefs.browserOptOut &&
Your browser privacy signal currently disables analytics.
}
+
+
+ >}
+ {error &&
This browser could not save the preference. Analytics stays disabled when storage is unavailable.
}
+
+
Separate network services
+
Your wallet, Stellar RPC, hosting provider and public blockchain process their own requests. Public transactions still expose participants and metadata. Turning off product analytics does not hide blockchain activity or remove infrastructure logs.
+ ;
+}
diff --git a/packages/app/app/profile/page.tsx b/packages/app/app/profile/page.tsx
index bf89132..1b051f4 100644
--- a/packages/app/app/profile/page.tsx
+++ b/packages/app/app/profile/page.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
+import Link from "next/link";
import { useRouter } from "next/navigation";
import { Copy, Check, LogOut, Activity, BookOpen, Info, RefreshCw } from "lucide-react";
import { AppShell } from "@/components/layout/AppShell";
@@ -104,6 +105,8 @@ export default function ProfilePage() {
+ Privacy & analytics settings
+
Protocol
diff --git a/packages/app/app/verify/verify-panel.tsx b/packages/app/app/verify/verify-panel.tsx
index 0762735..9ce02e0 100644
--- a/packages/app/app/verify/verify-panel.tsx
+++ b/packages/app/app/verify/verify-panel.tsx
@@ -7,6 +7,8 @@
* binding are all re-read from the chain, never trusted from the bundle.
*/
+import { trackEvent } from "@/lib/analytics";
+
import { useCallback, useEffect, useState } from "react";
import {
ChainClient,
@@ -112,6 +114,7 @@ export function VerifyPanel() {
pinnedVk: vkBytes(artifacts.vk.vkBase64),
}),
);
+ trackEvent("disclosure_verified");
} finally {
await prover.destroy();
}
diff --git a/packages/app/components/analytics/Analytics.tsx b/packages/app/components/analytics/Analytics.tsx
new file mode 100644
index 0000000..221d3bb
--- /dev/null
+++ b/packages/app/components/analytics/Analytics.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Script from "next/script";
+import { usePathname } from "next/navigation";
+import { ANALYTICS_CHANGE_EVENT, analyticsAllowed, analyticsConfig, flushAnalytics, trackPageview } from "@/lib/analytics";
+
+export function Analytics() {
+ const pathname = usePathname();
+ const [enabled, setEnabled] = useState(false);
+ useEffect(() => {
+ const update = () => setEnabled(analyticsAllowed());
+ update();
+ window.addEventListener(ANALYTICS_CHANGE_EVENT, update);
+ window.addEventListener("storage", update);
+ return () => { window.removeEventListener(ANALYTICS_CHANGE_EVENT, update); window.removeEventListener("storage", update); };
+ }, []);
+ useEffect(() => {
+ if (enabled) trackPageview(pathname);
+ }, [pathname, enabled]);
+ const cfg = analyticsConfig();
+ if (!enabled || !cfg) return null;
+ return ;
+}
diff --git a/packages/app/lib/analytics.ts b/packages/app/lib/analytics.ts
new file mode 100644
index 0000000..2781a22
--- /dev/null
+++ b/packages/app/lib/analytics.ts
@@ -0,0 +1,149 @@
+/** Explicit, allowlisted analytics. Never pass wallet data or raw errors here. */
+export const ROUTES = ["/", "/home", "/shield", "/send", "/payroll", "/escrow", "/auditor", "/verify", "/profile", "/docs", "/about", "/stats", "/privacy"] as const;
+export const EVENTS = [
+ "wallet_connect_started", "wallet_connected", "wallet_ready", "wallet_connect_failed",
+ "action_started", "action_failed", "registration_confirmed", "deposit_confirmed",
+ "merge_confirmed", "transfer_confirmed", "withdrawal_confirmed", "payroll_confirmed",
+ "disclosure_created", "disclosure_verified",
+] as const;
+export type AnalyticsEvent = typeof EVENTS[number];
+export const ACTIONS = ["registration", "deposit", "merge", "transfer", "withdrawal", "payroll"] as const;
+export type AnalyticsAction = typeof ACTIONS[number];
+const SOURCES = ["stellar", "scf", "twitter", "telegram", "discord", "github", "newsletter", "community"] as const;
+const CAMPAIGNS = ["testnet-pilot", "scf46"] as const;
+const OPT_OUT = "lucent:analytics:disabled";
+const INTERNAL = "lucent:analytics:internal";
+const CHANGE_EVENT = "lucent:analytics:preferences";
+export const ANALYTICS_CHANGE_EVENT = CHANGE_EVENT;
+
+export interface AnalyticsProperties {
+ action?: AnalyticsAction;
+ mode?: "manual" | "auto";
+ direction?: "sent" | "received";
+}
+export interface AnalyticsConfig { website: string; hostname: string; script: string }
+export interface AnalyticsPayload {
+ website: string;
+ hostname: string;
+ url: string;
+ title: string;
+ referrer: string;
+ name?: AnalyticsEvent;
+ data: Record;
+}
+declare global {
+ interface Window { umami?: { track: (payload: AnalyticsPayload) => unknown } }
+}
+
+export function analyticsConfig(): AnalyticsConfig | null {
+ if (process.env.NEXT_PUBLIC_ANALYTICS_ENABLED !== "true") return null;
+ const website = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID ?? "";
+ const hostname = process.env.NEXT_PUBLIC_ANALYTICS_HOSTNAME ?? "";
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(website)) return null;
+ if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i.test(hostname) || !hostname.includes(".") || /^(localhost|127\.)/.test(hostname)) return null;
+ try {
+ const script = new URL(process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL || "https://cloud.umami.is/script.js");
+ if (script.protocol !== "https:" || script.username || script.password || script.search || script.hash) return null;
+ return { website, hostname: hostname.toLowerCase(), script: script.href };
+ } catch { return null; }
+}
+
+export function analyticsPreferences() {
+ if (typeof window === "undefined") return { disabled: true, internal: false, browserOptOut: false };
+ const browserOptOut = navigator.doNotTrack === "1" || (navigator as Navigator & { globalPrivacyControl?: boolean }).globalPrivacyControl === true;
+ try {
+ return { disabled: localStorage.getItem(OPT_OUT) === "1" || localStorage.getItem("umami.disabled") === "1", internal: localStorage.getItem(INTERNAL) === "1", browserOptOut };
+ } catch { return { disabled: true, internal: false, browserOptOut }; }
+}
+export function analyticsAllowed(): boolean {
+ if (typeof window === "undefined" || process.env.NODE_ENV !== "production") return false;
+ const cfg = analyticsConfig();
+ const prefs = analyticsPreferences();
+ return !!cfg && location.protocol === "https:" && location.hostname === cfg.hostname && !prefs.disabled && !prefs.internal && !prefs.browserOptOut;
+}
+
+let queue: AnalyticsPayload[] = [];
+let lastPage = "";
+export function setAnalyticsPreference(kind: "disabled" | "internal", value: boolean): boolean {
+ if (typeof window === "undefined") return false;
+ try {
+ const key = kind === "disabled" ? OPT_OUT : INTERNAL;
+ if (value) localStorage.setItem(key, "1"); else localStorage.removeItem(key);
+ queue = []; lastPage = "";
+ window.dispatchEvent(new Event(CHANGE_EVENT));
+ return true;
+ } catch { return false; }
+}
+
+/** Unknown paths, hashes, query strings, referrers and titles cannot leak data. */
+export function safePath(pathname: string): string {
+ const path = pathname.split(/[?#]/)[0];
+ return (ROUTES as readonly string[]).includes(path) ? path : "/other";
+}
+export function safeAttribution(search: string): Record {
+ const query = new URLSearchParams(search);
+ const data: Record = {};
+ const source = query.get("utm_source") ?? "";
+ const campaign = query.get("utm_campaign") ?? "";
+ if ((SOURCES as readonly string[]).includes(source)) data.source = source;
+ if ((CAMPAIGNS as readonly string[]).includes(campaign)) data.campaign = campaign;
+ return data;
+}
+export function buildAnalyticsPayload(cfg: AnalyticsConfig, pathname: string, search: string, name?: AnalyticsEvent, properties: AnalyticsProperties = {}): AnalyticsPayload | null {
+ if (name !== undefined && !(EVENTS as readonly string[]).includes(name)) return null;
+ const url = safePath(pathname);
+ const data: Record = { network: "testnet", ...safeAttribution(search) };
+ if (properties.action && (ACTIONS as readonly string[]).includes(properties.action)) data.action = properties.action;
+ if (properties.mode === "manual" || properties.mode === "auto") data.mode = properties.mode;
+ if (properties.direction === "sent" || properties.direction === "received") data.direction = properties.direction;
+ return { website: cfg.website, hostname: cfg.hostname, url, title: url === "/" ? "Lucent" : `Lucent ${url}`, referrer: "", ...(name ? { name } : {}), data };
+}
+
+function deliver(payload: AnalyticsPayload) {
+ try {
+ // Object form sends only these fields, rather than merging browser defaults.
+ void Promise.resolve(window.umami?.track(payload)).catch(() => {});
+ } catch { /* Analytics must never interrupt a wallet action. */ }
+}
+export function flushAnalytics() {
+ if (!analyticsAllowed()) { queue = []; return; }
+ if (!window.umami) return;
+ const pending = queue; queue = [];
+ for (const payload of pending) deliver(payload);
+}
+function enqueue(name?: AnalyticsEvent, properties?: AnalyticsProperties) {
+ if (!analyticsAllowed()) return;
+ const cfg = analyticsConfig()!;
+ const payload = buildAnalyticsPayload(cfg, location.pathname, location.search, name, properties);
+ if (!payload) return;
+ if (window.umami) deliver(payload);
+ else if (queue.length < 32) queue.push(payload);
+}
+export function trackEvent(name: AnalyticsEvent, properties?: AnalyticsProperties) { enqueue(name, properties); }
+export function trackPageview(pathname: string) {
+ if (!analyticsAllowed()) return;
+ // Strict Mode / Script onReady must not double-count the same navigation.
+ const key = safePath(pathname);
+ if (key === lastPage) return;
+ lastPage = key;
+ enqueue();
+}
+
+/** Only known payment actions; no dynamic IDs, admin actions or arbitrary labels. */
+export function trackedAction(pathname: string, key: string): AnalyticsAction | null {
+ if (pathname === "/send" && key === "send") return "transfer";
+ if (pathname === "/shield") {
+ if (key === "deposit") return "deposit";
+ if (key === "withdraw") return "withdrawal";
+ if (key === "merge") return "merge";
+ }
+ if (pathname === "/payroll" && key === "execute") return "payroll";
+ return null;
+}
+
+/** Emit only after the SDK reports success, independent of later UI persistence. */
+export async function trackConfirmed(name: Extract, submit: () => Promise): Promise {
+ const result = await submit();
+ trackEvent(name);
+ return result;
+}
diff --git a/packages/app/lib/use-action.ts b/packages/app/lib/use-action.ts
index 75f111e..7556bab 100644
--- a/packages/app/lib/use-action.ts
+++ b/packages/app/lib/use-action.ts
@@ -2,6 +2,8 @@
import { useCallback, useState } from "react";
+import { trackEvent, trackedAction } from "./analytics";
+
import { useWallet } from "./wallet-context";
import { errMsg } from "./err";
import type { TxPhase } from "./wallet";
@@ -18,6 +20,8 @@ export function useAction() {
const run = useCallback(
async (key: string, fn: (setPhase: (p: TxPhase) => void) => Promise, opts?: { refresh?: boolean }) => {
+ const action = trackedAction(window.location.pathname, key);
+ if (action) trackEvent("action_started", { action });
setError(null);
setBusy(key);
setPhase(null);
@@ -25,6 +29,7 @@ export function useAction() {
await fn(setPhase);
if (opts?.refresh !== false) await refresh();
} catch (e) {
+ if (action) trackEvent("action_failed", { action });
setError(errMsg(e));
} finally {
setBusy(null);
diff --git a/packages/app/lib/wallet-context.tsx b/packages/app/lib/wallet-context.tsx
index 3e46227..65a307d 100644
--- a/packages/app/lib/wallet-context.tsx
+++ b/packages/app/lib/wallet-context.tsx
@@ -28,6 +28,7 @@ import { useRouter } from "next/navigation";
import { ConfidentialWallet, type WalletView } from "./wallet";
import { freighterIsAllowed } from "./freighter";
import { errMsg } from "./err";
+import { trackEvent } from "./analytics";
interface WalletCtx {
wallet: ConfidentialWallet | null;
@@ -77,7 +78,8 @@ export function WalletProvider({ children }: { children: ReactNode }) {
setLogs((prev) => [`${new Date().toLocaleTimeString()} ${msg}`, ...prev].slice(0, 80));
}, []);
- const connect = useCallback(async () => {
+ const connectWithMode = useCallback(async (mode: "manual" | "auto") => {
+ trackEvent("wallet_connect_started", { mode });
setError(null);
setConnecting(true);
try {
@@ -85,12 +87,15 @@ export function WalletProvider({ children }: { children: ReactNode }) {
setView(null);
setLastSync(null);
setWallet(w);
+ trackEvent("wallet_connected", { mode });
const v = await w.refresh();
setView(v);
setLastSync(new Date());
localStorage.removeItem(MANUALLY_DISCONNECTED_KEY);
+ trackEvent("wallet_ready", { mode });
return w;
} catch (e) {
+ trackEvent("wallet_connect_failed", { mode });
setError(errMsg(e));
return null;
} finally {
@@ -98,6 +103,8 @@ export function WalletProvider({ children }: { children: ReactNode }) {
}
}, [log]);
+ const connect = useCallback(() => connectWithMode("manual"), [connectWithMode]);
+
// Reconnect silently on mount (page load, hard refresh) if this site was
// already authorized in a prior session — otherwise every reload drops back
// to a disconnected state even though nothing the user did asked for that.
@@ -113,11 +120,11 @@ export function WalletProvider({ children }: { children: ReactNode }) {
if (localStorage.getItem(MANUALLY_DISCONNECTED_KEY)) return;
freighterIsAllowed().then(async (allowed) => {
if (!allowed) return;
- await connect();
+ await connectWithMode("auto");
// Preserve account-read/recovery errors so a connected wallet never
// silently falls into the registration screen after a failed refresh.
});
- }, [connect]);
+ }, [connectWithMode]);
const refresh = useCallback(async () => {
if (!wallet) return;
diff --git a/packages/app/lib/wallet.ts b/packages/app/lib/wallet.ts
index 58b24ab..98a56f5 100644
--- a/packages/app/lib/wallet.ts
+++ b/packages/app/lib/wallet.ts
@@ -9,6 +9,8 @@
* the signature prompt only appears once per account + deployment.
*/
+import { trackConfirmed } from "./analytics";
+
import {
ChainClient,
type Signer,
@@ -251,13 +253,13 @@ export class ConfidentialWallet {
const { proof } = await this.prover("register").prove(w.inputs);
onPhase?.("submitting");
this.log("submitting register…");
- const r = await submitRegister(this.client, this.signer, this.address, DEPLOYMENT.auditorId, w, proof);
+ const r = await trackConfirmed("registration_confirmed", () => submitRegister(this.client, this.signer, this.address, DEPLOYMENT.auditorId, w, proof));
this.log(`registered (tx ${r.hash.slice(0, 10)}…)`);
}
async deposit(amount: bigint): Promise {
this.log(`depositing ${amount}…`);
- const r = await submitDeposit(this.client, this.signer, this.address, this.address, amount);
+ const r = await trackConfirmed("deposit_confirmed", () => submitDeposit(this.client, this.signer, this.address, this.address, amount));
this.log(`deposited (tx ${r.hash.slice(0, 10)}…) → receiving balance`);
}
@@ -271,7 +273,7 @@ export class ConfidentialWallet {
// mismatch. Same reason transfer()/withdraw() sync before proving.
await this.engine.sync();
this.log("merging receiving → spendable…");
- const r = await submitMerge(this.client, this.signer, this.address);
+ const r = await trackConfirmed("merge_confirmed", () => submitMerge(this.client, this.signer, this.address));
await this.engine.applyMerge();
this.log(`merged (tx ${r.hash.slice(0, 10)}…)`);
}
@@ -363,7 +365,7 @@ export class ConfidentialWallet {
const { proof } = await this.prover("transfer").prove(w.inputs);
onPhase?.("submitting");
this.log("submitting transfer…");
- const r = await submitTransfer(this.client, this.signer, this.address, to, w, proof);
+ const r = await trackConfirmed("transfer_confirmed", () => submitTransfer(this.client, this.signer, this.address, to, w, proof));
await this.engine.setSpendable(w.next);
// No r_e bookkeeping (§15.2): the witness derives it from (vk, sigma), so
// discloseSent() re-derives it from the emitted event whenever needed.
@@ -382,7 +384,7 @@ export class ConfidentialWallet {
const { proof } = await this.prover("withdraw").prove(w.inputs);
onPhase?.("submitting");
this.log("submitting withdraw…");
- const r = await submitWithdraw(this.client, this.signer, this.address, this.address, amount, w, proof);
+ const r = await trackConfirmed("withdrawal_confirmed", () => submitWithdraw(this.client, this.signer, this.address, this.address, amount, w, proof));
await this.engine.setSpendable(w.next);
this.log(`withdrew ${amount} → public (tx ${r.hash.slice(0, 10)}…)`);
}
@@ -449,7 +451,7 @@ export class ConfidentialWallet {
onPhase?.("submitting");
this.log("submitting execute_run…");
- const r = await submitExecuteRun(this.client, this.signer, runId, blobs);
+ const r = await trackConfirmed("payroll_confirmed", () => submitExecuteRun(this.client, this.signer, runId, blobs));
await this.engine.setSpendable(next);
this.log(`run #${runId} executed (tx ${r.hash.slice(0, 10)}…)`);
}
diff --git a/packages/app/next.config.mjs b/packages/app/next.config.mjs
index 00cb6c0..240c563 100644
--- a/packages/app/next.config.mjs
+++ b/packages/app/next.config.mjs
@@ -8,6 +8,7 @@
* headers.
*/
const crossOriginIsolation = [
+ { key: "Referrer-Policy", value: "no-referrer" },
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
{ key: "Cross-Origin-Embedder-Policy", value: "credentialless" },
];
diff --git a/packages/app/test/analytics.mjs b/packages/app/test/analytics.mjs
new file mode 100644
index 0000000..d6c2a8d
--- /dev/null
+++ b/packages/app/test/analytics.mjs
@@ -0,0 +1,108 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import {
+ analyticsAllowed, analyticsConfig, buildAnalyticsPayload, flushAnalytics,
+ safeAttribution, safePath, setAnalyticsPreference, trackEvent, trackPageview, trackedAction, trackConfirmed,
+} from '../lib/analytics.ts';
+
+const store = new Map();
+const storage = { getItem: key => store.get(key) ?? null, setItem: (k,v) => store.set(k,String(v)), removeItem: k => store.delete(k) };
+const loc = { protocol: 'https:', hostname: 'www.lucent-pay.xyz', pathname: '/send', search: '?address=PRIVATE&amount=123&utm_source=scf&utm_campaign=scf46&secret=PRIVATE' };
+const browser = new EventTarget();
+globalThis.window = browser;
+globalThis.location = loc;
+globalThis.localStorage = storage;
+Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { doNotTrack: '0', globalPrivacyControl: false } });
+process.env.NODE_ENV = 'production';
+process.env.NEXT_PUBLIC_ANALYTICS_ENABLED = 'true';
+process.env.NEXT_PUBLIC_ANALYTICS_HOSTNAME = loc.hostname;
+process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID = '00000000-0000-4000-8000-000000000001';
+const cfg = analyticsConfig();
+assert.ok(cfg);
+assert.equal(analyticsAllowed(), true);
+assert.equal(safePath('/send?address=PRIVATE#secret'), '/send');
+assert.equal(safePath('/user/PRIVATE'), '/other');
+assert.deepEqual(safeAttribution('?utm_source=secret@example.com&utm_campaign=PRIVATE'), {});
+assert.deepEqual(safeAttribution('?utm_source=scf&utm_campaign=scf46'), { source:'scf', campaign:'scf46' });
+const payload = buildAnalyticsPayload(cfg, '/send?amount=123#secret', loc.search, 'transfer_confirmed', {
+ action:'transfer', amount:'123', address:'PRIVATE', error:'PRIVATE', proof:'PRIVATE', txHash:'PRIVATE', mode:'manual', arbitrary:'PRIVATE',
+});
+assert.deepEqual(payload, {
+ website:cfg.website, hostname:cfg.hostname, url:'/send', title:'Lucent /send', referrer:'', name:'transfer_confirmed',
+ data:{ network:'testnet', source:'scf', campaign:'scf46', action:'transfer', mode:'manual' },
+});
+assert.equal(buildAnalyticsPayload(cfg, '/', '', 'PRIVATE'), null);
+assert.equal(trackedAction('/auditor', 'execute'), null);
+assert.equal(trackedAction('/escrow', 'fund-123'), null);
+assert.equal(trackedAction('/payroll', 'cancel-PRIVATE'), null);
+assert.equal(trackedAction('/send', 'send'), 'transfer');
+
+let received = [];
+window.umami = { track: p => { received.push(p); } };
+trackPageview('/send'); trackPageview('/send');
+assert.equal(received.length, 1, 'No Strict Mode / onReady duplicate pageview');
+loc.pathname = '/home'; trackPageview('/home');
+loc.pathname = '/send'; trackPageview('/send');
+assert.equal(received.length, 3, 'Returning to a previous route is another visit');
+
+for (const block of ['disabled','internal']) {
+ received=[];
+ assert.equal(setAnalyticsPreference(block,true),true);
+ trackEvent('transfer_confirmed'); flushAnalytics();
+ assert.equal(received.length,0);
+ assert.equal(analyticsAllowed(),false);
+ setAnalyticsPreference(block,false);
+}
+for (const signal of ['doNotTrack','globalPrivacyControl']) {
+ navigator[signal] = signal === 'doNotTrack' ? '1' : true;
+ assert.equal(analyticsAllowed(),false);
+ navigator[signal] = signal === 'doNotTrack' ? '0' : false;
+}
+store.set('umami.disabled','1'); assert.equal(analyticsAllowed(),false); store.clear();
+loc.hostname='preview.example.com'; assert.equal(analyticsAllowed(),false); loc.hostname=cfg.hostname;
+process.env.NODE_ENV='development'; assert.equal(analyticsAllowed(),false); process.env.NODE_ENV='production';
+process.env.NEXT_PUBLIC_ANALYTICS_ENABLED='false'; assert.equal(analyticsAllowed(),false); process.env.NEXT_PUBLIC_ANALYTICS_ENABLED='true';
+process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL='https://user:SECRET@stats.example.com/script.js'; assert.equal(analyticsConfig(),null);
+process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL='http://stats.example.com/script.js'; assert.equal(analyticsConfig(),null);
+delete process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL;
+
+// Queued events are bounded, drained once, and discarded on opt-out.
+received=[]; delete window.umami;
+for(let i=0;i<50;i++) trackEvent('deposit_confirmed');
+window.umami={track:p=>received.push(p)}; flushAnalytics(); flushAnalytics();
+assert.equal(received.length,32);
+received=[]; delete window.umami; trackEvent('deposit_confirmed');
+setAnalyticsPreference('disabled',true); setAnalyticsPreference('disabled',false);
+window.umami={track:p=>received.push(p)}; flushAnalytics();
+assert.equal(received.length,0);
+window.umami={track:()=>{throw new Error('transport down');}};
+assert.doesNotThrow(()=>trackEvent('transfer_confirmed'));
+window.umami={track:()=>Promise.reject(new Error('transport rejected'))};
+trackEvent('transfer_confirmed'); await new Promise(r=>setTimeout(r,0));
+globalThis.localStorage={getItem:()=>{throw Error('storage blocked');}};
+assert.equal(analyticsAllowed(),false);
+globalThis.localStorage=storage;
+
+// Exercise the shared confirmation boundary used by all instrumented chain calls.
+received=[]; window.umami={track:p=>received.push(p)};
+await assert.rejects(()=>trackConfirmed('deposit_confirmed',async()=>{throw Error('chain rejected');}));
+assert.equal(received.length,0);
+let resolveSubmission;
+const pending=trackConfirmed('deposit_confirmed',()=>new Promise(resolve=>{resolveSubmission=resolve;}));
+assert.equal(received.length,0,'No success event before chain success');
+const result={hash:'public-test-hash',status:'SUCCESS'};
+resolveSubmission(result);
+assert.equal(await pending,result,'Original SDK result is preserved');
+assert.equal(received.length,1);
+assert.equal(received[0].name,'deposit_confirmed');
+assert.ok(!JSON.stringify(received).includes('public-test-hash'));
+window.umami={track:()=>{throw Error('analytics down');}};
+assert.equal(await trackConfirmed('deposit_confirmed',async()=>result),result);
+
+// Privacy-critical loader contract: all initialization/autocapture stays off.
+const component=readFileSync(new URL('../components/analytics/Analytics.tsx',import.meta.url),'utf8');
+assert.ok(component.includes('data-auto-track="false"'));
+assert.ok(component.includes('referrerPolicy="no-referrer"'));
+assert.ok(!component.includes('data-performance='));
+assert.ok(!component.includes('data-errors='));
+console.log('analytics: payload privacy, production/host gates, opt-out, DNT/GPC, dedupe, queue, transport failure and confirmed-chain integration passed');