Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,12 @@ NEXT_PUBLIC_TOKEN_OUT=0xC67DCE33D7A8efA5FfEB961899C73fe01bCe9273

# FTSO feed the trigger watches. Default is FLR/USD.
NEXT_PUBLIC_FEED_ID=0x01464c522f55534400000000000000000000000000

# Sentry error tracking
# Leave empty in local dev and CI to disable.
NEXT_PUBLIC_SENTRY_DSN=

# PostHog funnel analytics
# Leave empty in local dev and CI to disable.
NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
38 changes: 31 additions & 7 deletions frontend/app/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from "@/lib/wraith";
import { Ticker } from "@/app/components/Ticker";
import { ActivityLog } from "@/app/components/ActivityLog";
import { trackEvent, setPersonProperties, trackError } from "@/lib/analytics";

const WRAITH_ADDRESS = (process.env.NEXT_PUBLIC_WRAITH_ADDRESS ?? "") as Address;
const FXRP_ADDRESS = (process.env.NEXT_PUBLIC_FXRP_ADDRESS ?? "") as Address;
Expand Down Expand Up @@ -76,6 +77,14 @@ export default function Home() {
const [minOut, setMinOut] = useState("150");
const [xrplAddress, setXrplAddress] = useState("");
const [days, setDays] = useState("7");
const [composeStarted, setComposeStarted] = useState(false);

const startCompose = useCallback(() => {
if (!composeStarted) {
setComposeStarted(true);
trackEvent("order_compose_started", { wallet_connected: Boolean(account) });
}
}, [composeStarted, account]);

const say = (message: string, kind: "info" | "error" = "info") => {
setStatus(message);
Expand Down Expand Up @@ -134,6 +143,16 @@ export default function Home() {
return () => clearInterval(interval);
}, [loadOrders]);

useEffect(() => {
setPersonProperties({ wallet_connected: Boolean(account) });
}, [account]);

useEffect(() => {
if (wrongNetwork) {
trackEvent("wrong_network_shown", { wallet_connected: Boolean(account) });
}
}, [wrongNetwork, account]);

const stats = useMemo(() => {
const escrowed = orders
.filter((o) => o.state === "sealed")
Expand Down Expand Up @@ -165,6 +184,7 @@ export default function Home() {
setWrongNetwork(chainId !== coston2.id);
say(chainId === coston2.id ? "Wallet connected." : "Wallet connected, but it is on the wrong network.");
} catch (error) {
trackError(error);
say(error instanceof Error ? error.message.split("\n")[0] : "Could not connect.", "error");
}
}
Expand Down Expand Up @@ -243,8 +263,10 @@ export default function Home() {

setLastTx(hash);
say("Sealed. Your trigger never touched the chain in the clear.");
trackEvent("order_sealed", { wallet_connected: true });
await loadOrders();
} catch (error) {
trackError(error);
const message = error instanceof Error ? error.message : String(error);
say(message.split("\n")[0], "error");
} finally {
Expand All @@ -270,8 +292,10 @@ export default function Home() {

setLastTx(hash);
say(`Order ${orderId} cancelled. Escrow refunded.`);
trackEvent("order_cancelled", { wallet_connected: true });
await loadOrders();
} catch (error) {
trackError(error);
const message = error instanceof Error ? error.message : String(error);
say(message.split("\n")[0], "error");
} finally {
Expand Down Expand Up @@ -334,19 +358,19 @@ export default function Home() {
<form className="compose" onSubmit={seal}>
<label className="field">
<span className="field-label">Escrow (FXRP)</span>
<input value={amount} onChange={(e) => setAmount(e.target.value)} inputMode="decimal" required />
<input value={amount} onChange={(e) => { setAmount(e.target.value); startCompose(); }} inputMode="decimal" required />
</label>

<div className="field field-secret">
<span className="field-label">Trigger</span>
<div className="field-row">
<select value={direction} onChange={(e) => setDirection(e.target.value as Direction)}>
<select value={direction} onChange={(e) => { setDirection(e.target.value as Direction); startCompose(); }}>
<option value="below">Falls to</option>
<option value="above">Rises to</option>
</select>
<input
value={threshold}
onChange={(e) => setThreshold(e.target.value)}
onChange={(e) => { setThreshold(e.target.value); startCompose(); }}
inputMode="decimal"
aria-label="Trigger price"
required
Expand All @@ -357,13 +381,13 @@ export default function Home() {
<div className="field field-secret">
<span className="field-label">Then</span>
<div className="field-row">
<select value={action} onChange={(e) => setAction(e.target.value as ActionKind)}>
<select value={action} onChange={(e) => { setAction(e.target.value as ActionKind); startCompose(); }}>
<option value="swap">Swap</option>
<option value="redeem">Redeem to XRP</option>
</select>
<input
value={minOut}
onChange={(e) => setMinOut(e.target.value)}
onChange={(e) => { setMinOut(e.target.value); startCompose(); }}
inputMode="decimal"
aria-label={action === "swap" ? "Minimum output" : "Lots to redeem"}
required
Expand All @@ -376,7 +400,7 @@ export default function Home() {
<span className="field-label">XRPL destination</span>
<input
value={xrplAddress}
onChange={(e) => setXrplAddress(e.target.value)}
onChange={(e) => { setXrplAddress(e.target.value); startCompose(); }}
placeholder="r…"
pattern="r[1-9A-HJ-NP-Za-km-z]{24,34}"
title="An XRPL classic address, starting with r"
Expand All @@ -389,7 +413,7 @@ export default function Home() {
<span className="field-label">Expires in (days)</span>
<input
value={days}
onChange={(e) => setDays(e.target.value)}
onChange={(e) => { setDays(e.target.value); startCompose(); }}
inputMode="numeric"
pattern="[0-9]+"
required
Expand Down
41 changes: 41 additions & 0 deletions frontend/app/components/PostHogProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"use client";

import { useEffect } from "react";
import posthog from "posthog-js";
import { usePathname, useSearchParams } from "next/navigation";

export function PostHogProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
const key = process.env.NEXT_PUBLIC_POSTHOG_KEY;
const host = process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com";

if (key) {
posthog.init(key, {
api_host: host,
person_profiles: "identified_only",
capture_pageview: false, // We'll handle this manually to ensure it's SPA friendly
});
}
}, []);

return <>{children}</>;
}

export function PostHogPageView() {
const pathname = usePathname();
const searchParams = useSearchParams();

useEffect(() => {
if (process.env.NEXT_PUBLIC_POSTHOG_KEY && typeof window !== "undefined") {
let url = window.origin + pathname;
if (searchParams.toString()) {
url = url + `?${searchParams.toString()}`;
}
posthog.capture("$pageview", {
$current_url: url,
});
}
}, [pathname, searchParams]);

return null;
}
9 changes: 8 additions & 1 deletion frontend/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { Archivo, JetBrains_Mono } from "next/font/google";
import { Nav } from "@/app/components/Nav";
import { PostHogProvider, PostHogPageView } from "@/app/components/PostHogProvider";
import "./globals.css";

const archivo = Archivo({
Expand Down Expand Up @@ -46,7 +48,12 @@ export default function RootLayout({ children }: { children: React.ReactNode })
{/* Fixed grain: breaks the digital flatness of large dark fields. */}
<div className="grain" aria-hidden="true" />
<Nav />
{children}
<Suspense fallback={null}>
<PostHogProvider>
<PostHogPageView />
{children}
</PostHogProvider>
</Suspense>
</body>
</html>
);
Expand Down
72 changes: 72 additions & 0 deletions frontend/lib/analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import posthog from "posthog-js";
import * as Sentry from "@sentry/nextjs";
import { scrub } from "./scrub";

const IS_POSTHOG_ACTIVE = typeof window !== "undefined" && Boolean(process.env.NEXT_PUBLIC_POSTHOG_KEY);
const IS_SENTRY_ACTIVE = Boolean(process.env.NEXT_PUBLIC_SENTRY_DSN);

export interface AnalyticsProperties {
wallet_connected?: boolean;
[key: string]: any;
}

/**
* Capture a PostHog named funnel event safely, scrubbing all properties.
* No trigger terms, ciphertext, or form field values are allowed here.
*/
export function trackEvent(name: string, properties: AnalyticsProperties = {}) {
if (!IS_POSTHOG_ACTIVE) return;

try {
// 1. Enforce privacy: remove any forbidden fields from the properties payload, just in case
const safeProps = { ...properties };
delete safeProps.threshold;
delete safeProps.thresholdE18;
delete safeProps.amount;
delete safeProps.minOut;
delete safeProps.minOutOrLots;
delete safeProps.xrplAddress;
delete safeProps.underlyingAddress;
delete safeProps.encrypted;
delete safeProps.ciphertext;

// 2. Recursively scrub any remaining property values of addresses/hex patterns
const scrubbedProps = scrub(safeProps);

// 3. Send to PostHog
posthog.capture(name, scrubbedProps);
} catch (error) {
trackError(error instanceof Error ? error : new Error(String(error)));
}
}

/**
* Set user/person properties in PostHog safely.
*/
export function setPersonProperties(properties: AnalyticsProperties) {
if (!IS_POSTHOG_ACTIVE) return;

try {
const scrubbedProps = scrub(properties);
posthog.register(scrubbedProps);
} catch (error) {
trackError(error instanceof Error ? error : new Error(String(error)));
}
}

/**
* Capture client-side errors via Sentry, scrubbing any details before sending.
*/
export function trackError(error: unknown, context: Record<string, any> = {}) {
if (!IS_SENTRY_ACTIVE) return;

try {
const scrubbedContext = scrub(context);
Sentry.withScope((scope) => {
scope.setExtras(scrubbedContext);
Sentry.captureException(error);
});
} catch {
// Fail silently to keep the user experience completely clean
}
}
52 changes: 52 additions & 0 deletions frontend/lib/scrub.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import test from "node:test";
import assert from "node:assert";
import { scrub } from "./scrub.ts";

test("scrub strips 0x-prefixed hex strings", () => {
const original = "The contract address is 0x3d893C53D9e8056135C26C8c638B76C8b60Df726 and the tx is 0xdeadbeef12345";
const expected = "The contract address is [SCRUBBED_HEX] and the tx is [SCRUBBED_HEX]";
assert.strictEqual(scrub(original), expected);
});

test("scrub strips XRPL classic addresses starting with r", () => {
const original = "XRPL address r9cZA1zs9m8Czzpq9A5Cgi8TzzepG8YYYY";
const expected = "XRPL address [SCRUBBED_ADDRESS]";
assert.strictEqual(scrub(original), expected);
});

test("scrub strips naked hex-like strings of length >= 8", () => {
const original = "The hash deadbeef is stripped, but short hex de or cafe is not if it is too short, unless it is part of a longer hex sequence.";
const expected = "The hash [SCRUBBED_HEX] is stripped, but short hex de or cafe is not if it is too short, unless it is part of a longer hex sequence.";
assert.strictEqual(scrub(original), expected);
});

test("scrub recursively processes nested objects and arrays", () => {
const payload = {
user: "0x1234567890123456789012345678901234567890",
order: {
id: 42,
txHash: "0xabcdef0123456789",
notes: "Destination is r9cZA1zs9m8Czzpq9A5Cgi8TzzepG8YYYY",
},
list: ["0x2345", "0xdeadbeefcafe", { embedded: "0x99999" }],
};

const expected = {
user: "[SCRUBBED_HEX]",
order: {
id: 42,
txHash: "[SCRUBBED_HEX]",
notes: "Destination is [SCRUBBED_ADDRESS]",
},
list: ["[SCRUBBED_HEX]", "[SCRUBBED_HEX]", { embedded: "[SCRUBBED_HEX]" }],
};

assert.deepStrictEqual(scrub(payload), expected);
});

test("scrub is a no-op for non-string primitives", () => {
assert.strictEqual(scrub(123), 123);
assert.strictEqual(scrub(true), true);
assert.strictEqual(scrub(null), null);
assert.strictEqual(scrub(undefined), undefined);
});
37 changes: 37 additions & 0 deletions frontend/lib/scrub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Privacy-respecting payload scrubbing utility.
* Recursively scrubs EVM addresses, XRPL addresses, and hex-like strings from any payload.
*/

export function scrubString(val: string): string {
let s = val;
// 1. Scrub 0x-prefixed hex strings (e.g., EVM addresses, transaction hashes, ciphertexts)
s = s.replace(/0x[a-fA-F0-9]+/gi, "[SCRUBBED_HEX]");
// 2. Scrub XRPL classic addresses (starts with r, then 24-34 Base58 characters)
s = s.replace(/\br[1-9A-HJ-NP-Za-km-z]{24,34}\b/g, "[SCRUBBED_ADDRESS]");
// 3. Scrub hex strings without 0x prefix that are 8 or more characters long
s = s.replace(/\b[a-fA-F0-9]{8,}\b/gi, "[SCRUBBED_HEX]");
// 4. Scrub any 40-character hex string even without word boundaries
s = s.replace(/[a-fA-F0-9]{40}/gi, "[SCRUBBED_HEX]");
return s;
}

export function scrub<T>(val: T): T {
if (val === null || val === undefined) {
return val;
}
if (typeof val === "string") {
return scrubString(val) as unknown as T;
}
if (Array.isArray(val)) {
return val.map((item) => scrub(item)) as unknown as T;
}
if (typeof val === "object") {
const res: Record<string, any> = {};
for (const [k, v] of Object.entries(val as Record<string, any>)) {
res[k] = scrub(v);
}
return res as unknown as T;
}
return val;
}
4 changes: 2 additions & 2 deletions frontend/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
import "./.next/dev/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
8 changes: 7 additions & 1 deletion frontend/next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { withSentryConfig } from "@sentry/nextjs";

/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};

export default nextConfig;
export default withSentryConfig(nextConfig, {
// Sentry-specific options here
silent: true,
disableLogger: true,
});
Loading