Overview
src/lib/analytics.ts's trackEvent claims to sanitize wallet addresses before they reach third-party analytics (Google Analytics gtag/GTM dataLayer, both directly wired at the bottom of the function):
function hashPublicKey(publicKey: string): string {
if (typeof window === "undefined" || !publicKey) return "";
const encoder = new TextEncoder();
const data = encoder.encode(publicKey);
return Array.from(new Uint8Array(data.slice(0, 4)))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
.slice(0, 8);
}
export function trackEvent(name: string, props: AnalyticsProps = {}): void {
...
const sanitized = { ...props };
if ("publicKey" in sanitized && typeof sanitized.publicKey === "string") {
sanitized.publicKey = hashPublicKey(sanitized.publicKey as string);
}
...
if (typeof w.gtag === "function") { w.gtag("event", name, payload); }
else if (Array.isArray(w.dataLayer)) { w.dataLayer.push({ event: name, ...payload }); }
...
Despite its name, hashPublicKey performs no hashing at all — no SubtleCrypto.digest, no HMAC, nothing cryptographic. It takes the raw UTF-8 bytes of the first four characters of the address string and hex-encodes them. Since every Stellar public key (G...) is a base32-encoded string, encoder.encode(publicKey).slice(0, 4) is simply the ASCII byte values of the literal first four characters typed on screen — the first byte is always 0x47 ('G'), and the remaining three come from a 32-character base32 alphabet, giving on the order of 2^15 possible 4-character prefixes. This is a deterministic, unsalted, trivially low-entropy, reversible-by-lookup-table transform, not a hash: the same address always produces the same 8-hex-character output (so it functions as a stable tracking identifier across sessions, defeating any anonymization intent), and an analytics vendor or anyone with access to the dataLayer/GA export could recover the real address's 4-character prefix directly from the "sanitized" value with a small precomputed table (32^3 = 32,768 possible non-'G' suffixes), narrowing any full address search space by roughly 15 bits for free. A field named hashPublicKey, called specifically to "sanitize" a value before it's sent to a third party, is exactly the kind of code a reviewer or future engineer would trust at face value without re-deriving what it actually does.
Requirements
- Replace
hashPublicKey with an actual one-way, salted or at least full-input cryptographic hash (e.g. crypto.subtle.digest('SHA-256', ...), truncated for display if a short identifier is still desired) so the third-party-analytics payload cannot be correlated back to specific wallet addresses.
- If a stable per-user pseudonymous identifier is genuinely desired (for cross-session analytics correlation), derive it explicitly as that — a documented, intentional pseudonymous ID — rather than implying "hashed for privacy" via a misleading function name over a broken implementation.
- Since
trackEvent is exported, general-purpose infrastructure (not scoped to any one caller), fix it at the source so every current and future caller that passes a publicKey field automatically gets correct behavior, without each call site needing to know about this gap.
Acceptance Criteria
Additional Notes
More precise references
src/lib/analytics.ts:10-18 (hashPublicKey) — confirmed no cryptographic primitive is used anywhere in the function; confirmed data.slice(0, 4) operates on the TextEncoder-encoded bytes of the original string, i.e. literally the first 4 characters' ASCII codes, hex-joined.
src/lib/analytics.ts:20-43 (trackEvent) — confirmed the sanitized.publicKey = hashPublicKey(...) call is the only sanitization applied before the payload reaches w.gtag/w.dataLayer, both real, external, third-party-visible sinks (Google Analytics / Google Tag Manager), not internal logging.
- Confirmed via
grep -rn "trackEvent(" src --include="*.ts*" that current call sites (UnlockModal.tsx, useLockFlow.ts) do not pass a publicKey field today — this specific sanitization branch is not actively exercised by any live caller as of this writing. It is, however, live, exported, general-purpose infrastructure specifically built and named for this purpose, and a wallet-address-tracking event (a natural, likely near-term addition given this app's whole purpose is wallet-driven) would immediately hit this broken path the moment any future caller includes publicKey in a trackEvent payload, trusting the function's name to mean what it says.
Additional edge cases
- Even setting aside cryptographic correctness, the practical anonymity benefit of hashing only a 4-character,
'G'-prefixed substring is inherently weak compared to hashing the full ~56-character address — any fix should hash the complete publicKey input, not just adopt a real hash function while preserving the "only look at the first few characters" truncation-before-hashing pattern.
- If GA4/GTM's own IP-anonymization or user-ID features are already relied on elsewhere in this app's analytics setup, note that in the PR, since it affects how much this specific fix actually needs to accomplish versus what's already handled at the platform level — this repository doesn't contain that configuration, so it can't be verified from here.
Implementation sketch
async function hashPublicKey(publicKey: string): Promise<string> {
if (typeof window === "undefined" || !publicKey || !window.crypto?.subtle) return "";
const data = new TextEncoder().encode(publicKey);
const digest = await window.crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
.slice(0, 16); // still short for a friendly identifier, but derived from the full, real digest
}
Note trackEvent itself is currently synchronous — switching to SubtleCrypto.digest (async) requires either making trackEvent async (auditing all call sites for void trackEvent(...)-style fire-and-forget usage, which is how it's called today) or pre-computing/caching the digest once per connected publicKey (e.g. in StellarWalletContext, alongside where publicKey is first set) and passing the already-hashed value into trackEvent instead of the raw address.
Test/reproduction plan
hashPublicKey("GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWX") and hashPublicKey("GABCZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ") (same first 4 chars, "GABC", wildly different remainder) → assert the outputs differ (fails today, since both currently hash-encode only "GABC"'s bytes and produce an identical result).
- Assert the new function's output length/format doesn't trivially map back to a small enumerable space (e.g. assert output entropy/uniqueness across a batch of random valid-looking addresses sharing a prefix).
Cross-references
- No existing issue in the repo's 75-issue history covers
analytics.ts.
Overview
src/lib/analytics.ts'strackEventclaims to sanitize wallet addresses before they reach third-party analytics (Google Analyticsgtag/GTMdataLayer, both directly wired at the bottom of the function):Despite its name,
hashPublicKeyperforms no hashing at all — noSubtleCrypto.digest, no HMAC, nothing cryptographic. It takes the raw UTF-8 bytes of the first four characters of the address string and hex-encodes them. Since every Stellar public key (G...) is a base32-encoded string,encoder.encode(publicKey).slice(0, 4)is simply the ASCII byte values of the literal first four characters typed on screen — the first byte is always0x47('G'), and the remaining three come from a 32-character base32 alphabet, giving on the order of 2^15 possible 4-character prefixes. This is a deterministic, unsalted, trivially low-entropy, reversible-by-lookup-table transform, not a hash: the same address always produces the same 8-hex-character output (so it functions as a stable tracking identifier across sessions, defeating any anonymization intent), and an analytics vendor or anyone with access to thedataLayer/GA export could recover the real address's 4-character prefix directly from the "sanitized" value with a small precomputed table (32^3 = 32,768 possible non-'G' suffixes), narrowing any full address search space by roughly 15 bits for free. A field namedhashPublicKey, called specifically to "sanitize" a value before it's sent to a third party, is exactly the kind of code a reviewer or future engineer would trust at face value without re-deriving what it actually does.Requirements
hashPublicKeywith an actual one-way, salted or at least full-input cryptographic hash (e.g.crypto.subtle.digest('SHA-256', ...), truncated for display if a short identifier is still desired) so the third-party-analytics payload cannot be correlated back to specific wallet addresses.trackEventis exported, general-purpose infrastructure (not scoped to any one caller), fix it at the source so every current and future caller that passes apublicKeyfield automatically gets correct behavior, without each call site needing to know about this gap.Acceptance Criteria
hashPublicKey(or its replacement) uses a real cryptographic digest over the full input, not a truncated raw-byte slice.Additional Notes
More precise references
src/lib/analytics.ts:10-18(hashPublicKey) — confirmed no cryptographic primitive is used anywhere in the function; confirmeddata.slice(0, 4)operates on theTextEncoder-encoded bytes of the original string, i.e. literally the first 4 characters' ASCII codes, hex-joined.src/lib/analytics.ts:20-43(trackEvent) — confirmed thesanitized.publicKey = hashPublicKey(...)call is the only sanitization applied before the payload reachesw.gtag/w.dataLayer, both real, external, third-party-visible sinks (Google Analytics / Google Tag Manager), not internal logging.grep -rn "trackEvent(" src --include="*.ts*"that current call sites (UnlockModal.tsx,useLockFlow.ts) do not pass apublicKeyfield today — this specific sanitization branch is not actively exercised by any live caller as of this writing. It is, however, live, exported, general-purpose infrastructure specifically built and named for this purpose, and a wallet-address-tracking event (a natural, likely near-term addition given this app's whole purpose is wallet-driven) would immediately hit this broken path the moment any future caller includespublicKeyin atrackEventpayload, trusting the function's name to mean what it says.Additional edge cases
'G'-prefixed substring is inherently weak compared to hashing the full ~56-character address — any fix should hash the completepublicKeyinput, not just adopt a real hash function while preserving the "only look at the first few characters" truncation-before-hashing pattern.Implementation sketch
Note
trackEventitself is currently synchronous — switching toSubtleCrypto.digest(async) requires either makingtrackEventasync (auditing all call sites forvoid trackEvent(...)-style fire-and-forget usage, which is how it's called today) or pre-computing/caching the digest once per connectedpublicKey(e.g. inStellarWalletContext, alongside wherepublicKeyis first set) and passing the already-hashed value intotrackEventinstead of the raw address.Test/reproduction plan
hashPublicKey("GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWX")andhashPublicKey("GABCZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ")(same first 4 chars,"GABC", wildly different remainder) → assert the outputs differ (fails today, since both currently hash-encode only"GABC"'s bytes and produce an identical result).Cross-references
analytics.ts.