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
14 changes: 14 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ npm run build
`npm run e2e:testnet` is an optional, read-only check against a running
backend and live testnet; see the header of `e2e/testnet.e2e.test.ts`.

## Realtime burst testing

`scripts/realtime-burst.mjs` emits synthetic `TRADE_EXECUTED` events so you can
check that realtime feeds stay smooth under bursts (issue #41):

```bash
npm i --no-save socket.io@4.8.1 # server package, not a project dependency
node scripts/realtime-burst.mjs --count 500 --rate 500 --port 4100
NEXT_PUBLIC_API_URL=http://localhost:4100/api/v1 npm run dev
```

Open `/markets?profile=1` or `/?profile=1`: in development the feeds log commit
counts and render time per second to the console (`[profile] …`).

## When the contracts change

The contracts live in
Expand Down
12 changes: 9 additions & 3 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { Metadata } from "next";
import Navbar from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { WalletProvider } from "@/lib/contexts/WalletContext";
import { RealtimeProvider } from "@/lib/realtime/RealtimeProvider";
import { InboxProvider } from "@/lib/notifications/InboxProvider";
import "./globals.css";

const geistSans = {
Expand Down Expand Up @@ -40,9 +42,13 @@ export default function RootLayout({
</head>
<body className="min-h-full flex flex-col font-body-md text-on-surface">
<WalletProvider>
<Navbar />
{children}
<Footer />
<RealtimeProvider>
<InboxProvider>
<Navbar />
{children}
<Footer />
</InboxProvider>
</RealtimeProvider>
</WalletProvider>
</body>
</html>
Expand Down
84 changes: 44 additions & 40 deletions app/markets/page.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
"use client";

import React, { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import React, { useState } from "react";
import { motion, AnimatePresence, MotionConfig } from "framer-motion";
import Link from "next/link";
import { io } from "socket.io-client";
import { useMarkets } from "@/lib/hooks/useMarkets";
import { useOnChainMarket, useOnChainMarkets, type OnChainMarketView } from "@/lib/hooks/useStellar";
import type { Market, MarketStatus, MarketTracePreview } from "@/lib/api/markets";
import { BACKEND_CATEGORIES, BACKEND_CATEGORY_LABELS } from "@/lib/stellar/categories";
import { formatUsdc } from "@/lib/stellar/units";
import { API_URL } from "@/lib/api/client";
import { useBurstFeed } from "@/lib/realtime/useBurst";
import { FeedProfiler } from "@/lib/realtime/FeedProfiler";
import type { TradeExecutedPayload } from "@/lib/realtime/RealtimeProvider";

/** Where a displayed value came from, wherever backend and chain can disagree. */
const SourceTag = ({ source }: { source: "chain" | "backend" }) => (
Expand Down Expand Up @@ -194,47 +195,35 @@ interface FeedRow {
}

/** Payload of the backend's TRADE_EXECUTED socket event (docs/api.md). */
interface TradeExecutedEvent {
eventId: string;
onChainMarketId: string;
marketQuestion: string | null;
direction: "YES" | "NO";
isBuy: boolean;
collateralRaw: string;
priceYesBps: number;
}
const tradeToRow = (data: TradeExecutedPayload): FeedRow => ({
key: `trade:${data.eventId}`,
// Receipt time: the socket payload has a ledger but no close time.
time: new Date().toLocaleTimeString("en-GB", { hour12: false }),
market: data.marketQuestion || `Market #${data.onChainMarketId}`,
size: `$${formatUsdc(BigInt(data.collateralRaw), { maxDecimals: 2, grouping: true })}`,
bet: `${data.isBuy ? "" : "SELL "}${data.direction}`,
prob: `YES ${(data.priceYesBps / 100).toFixed(1)}%`,
color: data.direction === "YES" ? "text-secondary" : "text-tertiary",
});
const rowKey = (row: FeedRow) => row.key;

const TradingFeed = () => {
const [feed, setFeed] = useState<FeedRow[]>([]);

useEffect(() => {
const socket = io(API_URL.replace(/\/api\/v1\/?$/, ""));

socket.on("TRADE_EXECUTED", (data: TradeExecutedEvent) => {
const time = new Date().toLocaleTimeString('en-GB', { hour12: false });
const newEntry: FeedRow = {
key: data.eventId,
time,
market: data.marketQuestion || `Market #${data.onChainMarketId}`,
size: `$${formatUsdc(BigInt(data.collateralRaw), { maxDecimals: 2, grouping: true })}`,
bet: `${data.isBuy ? "" : "SELL "}${data.direction}`,
prob: `YES ${(data.priceYesBps / 100).toFixed(1)}%`,
color: data.direction === "YES" ? "text-secondary" : "text-tertiary",
};
setFeed(prev => [newEntry, ...prev.slice(0, 7)]);
});

return () => {
socket.disconnect();
};
}, []);
const { rows: feed, burst, hiddenOverflow, instantKey } = useBurstFeed("TRADE_EXECUTED", {
capacity: 8,
toRow: tradeToRow,
key: rowKey,
});
// A burst or a catch-up batch remounts the tbody so rows appear without
// per-row enter/exit animations (initial={false} only applies on mount).
const instant = burst !== null || hiddenOverflow > 0;

return (
<section className="mt-12">
<div className="flex items-center gap-2 mb-4">
<span className="material-symbols-outlined text-primary">analytics</span>
<h2 className="font-headline-sm text-headline-sm uppercase tracking-tight">Real-time Trading Feed</h2>
</div>
<MotionConfig reducedMotion="user">
<div className="terminal-log-flow border border-outline-variant rounded-xl overflow-hidden bg-white">
<div className="p-4 overflow-x-auto">
<table className="w-full text-left">
Expand All @@ -247,7 +236,21 @@ const TradingFeed = () => {
<th className="pb-3 text-label-caps text-on-surface-variant text-right">Probability</th>
</tr>
</thead>
<tbody className="font-data-mono text-body-md">
<tbody key={instantKey} className="font-data-mono text-body-md">
{burst && (
<tr>
<td colSpan={5} className="py-2 text-on-surface-variant text-xs" role="status">
{burst.count} trades in the last second
</td>
</tr>
)}
{hiddenOverflow > 0 && (
<tr>
<td colSpan={5} className="py-2 text-on-surface-variant text-xs" role="status">
{hiddenOverflow} more events while this tab was hidden
</td>
</tr>
)}
<AnimatePresence initial={false}>
{feed.length === 0 && (
<tr>
Expand All @@ -257,9 +260,9 @@ const TradingFeed = () => {
{feed.map((row) => (
<motion.tr
key={row.key}
initial={{ opacity: 0, x: -20 }}
initial={instant ? false : { opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
exit={instant ? undefined : { opacity: 0, x: 20 }}
className="hover:bg-surface-container transition-colors border-b border-outline-variant/30"
>
<td className="py-3 text-on-surface-variant">{row.time}</td>
Expand All @@ -274,6 +277,7 @@ const TradingFeed = () => {
</table>
</div>
</div>
</MotionConfig>
</section>
);
};
Expand Down Expand Up @@ -376,7 +380,7 @@ export default function Markets() {

<OnChainOnlyMarkets knownIds={knownIds} />

<TradingFeed />
<FeedProfiler id="TradingFeed"><TradingFeed /></FeedProfiler>
</main>
</div>
);
Expand Down
40 changes: 38 additions & 2 deletions app/markets/quick-view/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ import { useQuery } from "@tanstack/react-query";
import { useMarket } from "@/lib/hooks/useMarkets";
import { getMarketByOnChainId } from "@/lib/api/markets";
import { ApiError } from "@/lib/api/client";
import { useBuy, useOnChainMarket, usePosition, useQuoteBuy, useUsdcBalance, useAccountReserve, usePrepareBuy } from "@/lib/hooks/useStellar";
import { useBuy, useOnChainMarket, usePosition, usePositionState, useQuoteBuy, useUsdcBalance, useAccountReserve, usePrepareBuy } from "@/lib/hooks/useStellar";
import { useWallet } from "@/lib/contexts/WalletContext";
import { explorerTx } from "@/lib/stellar/config";
import { formatUsdc, minOut, tryParseUsdc } from "@/lib/stellar/units";
import { buyBreakdown } from "@/lib/stellar/buy-breakdown";
import { TradeTape } from "@/components/market/trade-tape";
import { useInbox } from "@/lib/notifications/InboxProvider";
import { UserFacingError } from "@/lib/stellar/errors";

const SLIPPAGE_BPS = 100; // 1%

Expand Down Expand Up @@ -89,6 +92,9 @@ function MarketQuickViewContent() {

const { address, isConnected, isWrongNetwork, openModal } = useWallet();
const position = usePosition(onChainMarketId, address);
const positionState = usePositionState(onChainMarketId, address);
const { recordTx } = useInbox();
const provisional = positionState.data?.provisional;
const balance = useUsdcBalance(address);
const reserve = useAccountReserve(address);
const buy = useBuy();
Expand Down Expand Up @@ -201,6 +207,16 @@ function MarketQuickViewContent() {
);
} catch (err) {
setBuyStatus(err instanceof Error ? err.message : "The trade didn't go through.");
// Same deterministic id as the tracker's record when a hash exists.
if (err instanceof UserFacingError) {
recordTx({
hash: "hash" in buy.phase && buy.phase.hash ? buy.phase.hash : null,
kind: "buy",
marketId: String(onChainMarketId),
errorKind: err.described.kind,
message: err.message,
});
}
}
};

Expand Down Expand Up @@ -478,7 +494,25 @@ function MarketQuickViewContent() {
{position.data && (position.data.yes > 0n || position.data.no > 0n) && (
<p className="text-[11px] font-data-mono text-on-surface">
Your position: {formatUsdc(position.data.yes, { maxDecimals: 2 })} YES · {formatUsdc(position.data.no, { maxDecimals: 2 })} NO
<SourceTag source="chain" />
{provisional ? (
<>
<span className="ml-1 text-on-surface-variant">
{provisional.awaitingRpc ? "awaiting RPC" : "pending confirmation"}
</span>
{provisional.txHash ? (
<a
href={explorerTx(provisional.txHash)}
target="_blank"
rel="noopener noreferrer"
className="ml-1 text-[9px] uppercase tracking-wider text-primary hover:underline"
>
tx
</a>
) : null}
</>
) : (
<SourceTag source="chain" />
)}
</p>
)}

Expand Down Expand Up @@ -506,6 +540,8 @@ function MarketQuickViewContent() {
</div>
)}

{onChainMarketId !== null && <TradeTape onChainMarketId={String(onChainMarketId)} limit={20} compact />}

{/* AI Reasoning Snippet */}
<div className="bg-[#f0f9fa] border border-primary-container/20 p-4 rounded-lg space-y-2 relative overflow-hidden">
<div className="flex items-center gap-2 text-primary z-10 relative">
Expand Down
Loading