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
54 changes: 28 additions & 26 deletions backend/src/workers/soroban-event-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1131,39 +1131,41 @@ export class SorobanEventWorker {
const token = decodeAddress(body["token"]);
const timestamp = Math.floor(Date.now() / 1000);

const existingEvent = await prisma.streamEvent.findUnique({
where: {
transactionHash_eventType: {
transactionHash: event.txHash,
eventType: "FEE_COLLECTED",
},
},
select: { id: true },
});
if (existingEvent) {
logger.warn(
`[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=FEE_COLLECTED`,
);
} else {
await prisma.streamEvent.upsert({
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
const existingEvent = await tx.streamEvent.findUnique({
where: {
transactionHash_eventType: {
transactionHash: event.txHash,
eventType: "FEE_COLLECTED",
},
},
create: {
streamId,
eventType: "FEE_COLLECTED",
amount: feeAmount,
transactionHash: event.txHash,
ledgerSequence: event.ledger,
timestamp,
metadata: JSON.stringify({ treasury, token }),
},
update: {},
select: { id: true },
});
}
if (existingEvent) {
logger.warn(
`[SorobanWorker] Duplicate StreamEvent skipped: txHash=${event.txHash} type=FEE_COLLECTED`,
);
} else {
await tx.streamEvent.upsert({
where: {
transactionHash_eventType: {
transactionHash: event.txHash,
eventType: "FEE_COLLECTED",
},
},
create: {
streamId,
eventType: "FEE_COLLECTED",
amount: feeAmount,
transactionHash: event.txHash,
ledgerSequence: event.ledger,
timestamp,
metadata: JSON.stringify({ treasury, token }),
},
update: {},
});
}
});

// Broadcast to admin channel for treasury reporting
sseService.broadcastToAdmin("stream.fee_collected", {
Expand Down
51 changes: 37 additions & 14 deletions backend/tests/soroban-event-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,35 +257,58 @@ describe('SorobanEventWorker', () => {
} as any,
};

// First call: event doesn't exist
(prisma.streamEvent.findUnique as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
(prisma.streamEvent.upsert as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
const mockTx = {
streamEvent: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
};

(prisma.$transaction as ReturnType<typeof vi.fn>).mockImplementation(
(callback) => callback(mockTx),
);

// First call: event doesn't exist.
mockTx.streamEvent.findUnique.mockResolvedValueOnce(null);
mockTx.streamEvent.upsert.mockResolvedValueOnce({
id: 'fee-event-1',
transactionHash: txHash,
eventType: 'FEE_COLLECTED',
});

await (worker as any).handleFeeCollected(mockEvent, mockEvent.topic![1]);
expect(prisma.streamEvent.findUnique).toHaveBeenCalledTimes(1);
expect(prisma.streamEvent.upsert).toHaveBeenCalledTimes(1);
await (worker as any).handleFeeCollected(
mockEvent,
mockEvent.topic![1],
);

expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(mockTx.streamEvent.findUnique).toHaveBeenCalledTimes(1);
expect(mockTx.streamEvent.upsert).toHaveBeenCalledTimes(1);
expect(logger.warn).not.toHaveBeenCalled();

// Reset mocks
vi.clearAllMocks();

// Second call: event exists (duplicate)
(prisma.streamEvent.findUnique as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
// Second call: event already exists, so no second row is created.
mockTx.streamEvent.findUnique.mockResolvedValueOnce({
id: 'fee-event-1',
});

await (worker as any).handleFeeCollected(mockEvent, mockEvent.topic![1]);
expect(prisma.streamEvent.findUnique).toHaveBeenCalledTimes(1);
expect(prisma.streamEvent.upsert).not.toHaveBeenCalled();
(prisma.$transaction as ReturnType<typeof vi.fn>).mockImplementation(
(callback) => callback(mockTx),
);

await (worker as any).handleFeeCollected(
mockEvent,
mockEvent.topic![1],
);

expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(mockTx.streamEvent.findUnique).toHaveBeenCalledTimes(1);
expect(mockTx.streamEvent.upsert).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Duplicate StreamEvent skipped')
expect.stringContaining('Duplicate StreamEvent skipped'),
);
});

it('should process fee_config_updated events successfully', async () => {
const txHash = 'fee-config-tx-hash';

Expand Down
7 changes: 5 additions & 2 deletions frontend/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { NextConfig } from "next";

const configDirectory = path.dirname(fileURLToPath(import.meta.url));

const nextConfig: NextConfig = {
// The workspace root lives one level above this directory. Pinning it here
// prevents Turbopack from inferring a wrong root when stray package-lock
// files exist outside the repo (e.g. ~/package-lock.json).
// files exist outside the repo.
turbopack: {
root: path.join(path.dirname(new URL(import.meta.url).pathname), ".."),
root: path.join(configDirectory, ".."),
},
// Enable tree-shaking for icon/utility libraries to reduce per-route
// bundle sizes (Issue #1254).
Expand Down
39 changes: 15 additions & 24 deletions frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Toaster } from "react-hot-toast";
import { ThemeProvider } from "@/context/theme-provider";
import { Navbar } from "@/components/Navbar";
import { QueryProvider } from "@/components/providers/query-provider";
import { AppDataSync } from "@/components/providers/app-data-sync";
import { NetworkProvider } from "@/context/NetworkContext";
import { WalletMismatchBanner } from "@/components/wallet/WalletMismatchBanner";

Expand Down Expand Up @@ -62,17 +63,6 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
/*
`suppressHydrationWarning` is the companion to next-themes' built-in
blocking pre-paint script (rendered by <ThemeProvider> below). The
server renders <html> without a theme class; that script then reads the
persisted theme from localStorage and adds/removes the theme class
(`light`/`dark`) on <html> before the first paint, so the client DOM
class list can legitimately differ from what the server rendered. This
prop tells React to skip the hydration-difference check for this
element because the divergence is intentional and resolved before paint.
See `theme-provider.tsx` for the full strategy.
*/
<html lang="en" suppressHydrationWarning>
<head />
<body className={`${sora.variable} ${mono.variable} antialiased`}>
Expand All @@ -86,21 +76,22 @@ export default function RootLayout({
<QueryProvider>
<NetworkProvider>
<WalletProvider>
<AppDataSync />
<Navbar />
<WalletMismatchBanner />
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: "#111",
color: "#fff",
border: "1px solid #333",
borderRadius: "12px",
},
}}
/>
{children}
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: "#111",
color: "#fff",
border: "1px solid #333",
borderRadius: "12px",
},
}}
/>
{children}
</WalletProvider>
</NetworkProvider>
</QueryProvider>
Expand Down
42 changes: 41 additions & 1 deletion frontend/src/app/settings/settings-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import toast from "react-hot-toast";
import { getApiBaseUrl } from "@/lib/api/_shared";
import { copyToClipboard } from "@/lib/clipboard";
import { DisconnectConfirmModal } from "@/components/wallet/DisconnectConfirmModal";
import { ClearAppDataConfirmModal } from "@/components/settings/ClearAppDataConfirmModal";
import { clearDisposableAppData } from "@/lib/app-data";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";

type DisplayCurrency = "USD" | "EUR" | "GBP" | "XLM" | "USDC";
Expand Down Expand Up @@ -118,6 +120,7 @@ export default function SettingsContent() {
};

const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
const [showClearAppDataConfirm, setShowClearAppDataConfirm] = useState(false);

const handleDisconnect = () => {
if (isDirty) {
Expand All @@ -131,6 +134,16 @@ export default function SettingsContent() {
router.push("/");
};

const handleClearAppData = async () => {
clearDisposableAppData();
disconnect();
document.documentElement.classList.remove("light");
document.documentElement.classList.add("dark");
toast.success("App data cleared");
setShowClearAppDataConfirm(false);
router.push("/");
};

const copyAddress = async () => {
if (!session?.publicKey) return;
const success = await copyToClipboard(session.publicKey, {
Expand Down Expand Up @@ -494,7 +507,34 @@ export default function SettingsContent() {
</div>
</div>

{/* Disconnect */}
{/* Clear App Data */}
<div className="pt-6 mt-6 border-t border-white/10 dark:border-black/10 space-y-3">
<div>
<p className="font-medium text-white dark:text-black">
Clear App Data
</p>
<p className="text-sm text-white/60 dark:text-black/60 mt-1">
Remove disposable FlowFi preferences, saved templates, drafts, and wallet session data from this browser.
</p>
</div>

<button
type="button"
onClick={() => setShowClearAppDataConfirm(true)}
className="w-full flex items-center justify-center gap-2 border border-amber-500/30 bg-amber-500/10 hover:bg-amber-500/20 transition px-4 py-3 rounded-xl text-amber-400 font-medium"
>
Clear App Data
</button>

{showClearAppDataConfirm && (
<ClearAppDataConfirmModal
onClose={() => setShowClearAppDataConfirm(false)}
onConfirm={handleClearAppData}
/>
)}
</div>

{/* Disconnect */}
{session && (
<>
<button
Expand Down
34 changes: 34 additions & 0 deletions frontend/src/components/providers/app-data-sync.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"use client";

import { useEffect } from "react";
import { useWallet } from "@/context/wallet-context";
import {
clearDisposableAppData,
isAppDataClearEvent,
} from "@/lib/app-data";

export function AppDataSync() {
const { disconnect } = useWallet();

useEffect(() => {
const handleStorage = (event: StorageEvent) => {
if (!isAppDataClearEvent(event.key)) return;

clearDisposableAppData({ broadcast: false });
disconnect();

// Restore the app's default dark appearance without
// recreating the cleared flowfi-theme storage key.
document.documentElement.classList.remove("light");
document.documentElement.classList.add("dark");
};

window.addEventListener("storage", handleStorage);

return () => {
window.removeEventListener("storage", handleStorage);
};
}, [disconnect]);

return null;
}
Loading
Loading