Skip to content
Merged
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
33 changes: 33 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,39 @@ 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`.

## Measuring realtime feeds

Use separate terminals:

```bash
node scripts/realtime-burst.mjs --manual --port 4310
NEXT_PUBLIC_API_URL=http://localhost:4310/api/v1 npm run dev
```

Open `/markets?profile=1` or `/?profile=1` and keep that tab visible.
Development-only React Profiler logs `[realtime-profile]`, the component
name, commit count and total `actualDuration` every second. Let the page
settle, then trigger each sample from another terminal:

```bash
curl 'http://localhost:4310/burst?count=500&rate=500'
# Wait for completion before starting the next sample.
curl 'http://localhost:4310/burst?count=500&rate=50'
```

Sum logged intervals covering each burst and its final settling interval.
Use the same browser, viewport and animation preference for both revisions.
For a baseline, instrument the original feed with the same profiler wrapper;
the original feeds need no functional changes. Profiler time measures React
render work, not socket validation, paint, or end-to-end latency. Slow
baselines may coalesce events under load, so fewer commits alone do not
prove better responsiveness. Production ignores `?profile=1`.

The simulator serves an empty market API and emits synthetic public trades;
it never signs or submits transactions. `--count`, `--rate` and `--port` also
configure its automatic burst (omit `--manual` to emit after the first
connection). See [docs/STATUS.md](docs/STATUS.md) for measured results.

## When the contracts change

The contracts live in
Expand Down
20 changes: 18 additions & 2 deletions app/copy-trade/page.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
"use client";

import { TxReview, TxReviewPreferenceControl, shouldSkipTxReview } from "@/components/tx-review";
import type { ReviewModel } from "@/lib/stellar/tx-decode";
import { useSubmitQueueState } from "@/lib/stellar/submit-queue";


import React, { useState, useRef, useEffect, useCallback, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useWallet } from "@/lib/contexts/WalletContext";
import { getTrace, ReasoningTrace } from "@/lib/api/traces";
import { confirmCopyTrade, initiateCopyTrade } from "@/lib/api/trade";
import { ApiError } from "@/lib/api/client";
import { useMarket } from "@/lib/hooks/useMarkets";
import { useBuy, useQuoteBuy, useUsdcBalance, useAccountReserve, usePrepareBuy } from "@/lib/hooks/useStellar";
import { useBuy, useQuoteBuy, useUsdcBalance, useAccountReserve, usePrepareBuy, useOnChainMarket, fetchOnChainMarket, useUsdcTrustline, useEnableUsdc, useUsdcBalanceState, type PreparedWriteReview } from "@/lib/hooks/useStellar";
import { explorerTx } from "@/lib/stellar/config";
import { formatUsdc, formatXlm, minOut, shareOf } from "@/lib/stellar/units";
import { TxPhase } from "@/lib/stellar/tx-lifecycle";

/** Wait this long after the slider stops before asking for a new quote. */
import { getLedgerNow, useLedgerNow } from "@/lib/stellar/ledger-clock";
import { MIN_TX_TIMEOUT_S, SAFETY_MARGIN_S } from "@/lib/stellar/deadline";
import { PendingValue } from "@/components/pending-value";
const QUOTE_DEBOUNCE_MS = 400;

function renderTxPhase(phase: TxPhase): string | null {
Expand Down Expand Up @@ -60,6 +68,13 @@ function CopyTradeContent() {
const onChainMarket = useOnChainMarket(onChainMarketId);

const balance = useUsdcBalance(address);
const balanceState = useUsdcBalanceState(address);
const ledger = useLedgerNow();
const trustline = useUsdcTrustline(address);
const enableUsdc = useEnableUsdc();
const trustlineMissing = trustline.data?.kind === "missing";
const trustlineReady = trustline.data?.kind === "native" || (trustline.data?.kind === "present" && trustline.data.authorized);
const marketCanTrade = onChainMarket.data?.market.status.tag === "Open" && ledger.now !== null && Number(onChainMarket.data.market.close_time) - ledger.now > SAFETY_MARGIN_S + MIN_TX_TIMEOUT_S;
const reserve = useAccountReserve(address);
const buy = useBuy();
const submitQueue = useSubmitQueueState(address);
Expand Down Expand Up @@ -241,6 +256,7 @@ function CopyTradeContent() {
collateralIn: amountRaw,
slippageBps,
prepared: prepareBuy.data,
review: reviewPreparedBuy,
});

if (result.txHash) await confirmCopyTrade(copyTradeId, result.txHash).catch(() => undefined);
Expand Down Expand Up @@ -362,7 +378,7 @@ function CopyTradeContent() {
<div className="flex justify-between items-end flex-wrap gap-2">
<label className="font-label-caps text-label-caps text-on-surface-variant uppercase text-[10px]">Share of your USDC balance (%)</label>
<span className="font-data-mono text-data-mono text-primary font-bold text-xs sm:text-sm">
{allocation.toFixed(2)}% ({amountRaw !== null ? `${formatUsdc(amountRaw, { maxDecimals: 2, minDecimals: 2, grouping: true })} USDC` : isConnected ? "loading balance…" : "connect a wallet"})
{allocation.toFixed(2)}% ({amountRaw !== null ? `${formatUsdc(amountRaw, { maxDecimals: 2, minDecimals: 2, grouping: true })} USDC` : isConnected ? "loading balance…" : "connect a wallet"})<PendingValue state={balanceState.data} />
</span>
</div>
<div
Expand Down
5 changes: 4 additions & 1 deletion app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -195,4 +195,7 @@
.reasoning-node:last-child::before {
display: none;
}
}
}
@media (prefers-reduced-motion: reduce) {
.realtime-ticker { transform: none; white-space: normal; flex-wrap: wrap; }
}
12 changes: 9 additions & 3 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import Navbar from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { WalletProvider } from "@/lib/contexts/WalletContext";
import "./globals.css";
import { RealtimeProvider } from "@/lib/realtime/RealtimeProvider";
import { InboxProvider } from "@/lib/notifications/InboxProvider";

const geistSans = {
variable: "--font-geist-sans",
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
113 changes: 40 additions & 73 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 { motion, AnimatePresence, useReducedMotion } from "framer-motion";
import { useBurstFeed } from "@/lib/realtime/useBurst";
import { formatFeedTime } from "@/lib/realtime/format";
import { RealtimeProfiler } from "@/components/realtime-profiler";
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";

/** Where a displayed value came from, wherever backend and chain can disagree. */
const SourceTag = ({ source }: { source: "chain" | "backend" }) => (
Expand Down Expand Up @@ -78,7 +79,7 @@ const FilterBar = ({
</button>
</div>
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-secondary animate-pulse"></span>
<span className="w-2 h-2 rounded-full bg-secondary animate-pulse motion-reduce:animate-none"></span>
<span className="font-data-mono text-label-caps text-secondary uppercase">Stellar Testnet</span>
</div>
</div>
Expand Down Expand Up @@ -183,58 +184,44 @@ const MarketCard = ({ href, category, onChainMarketId, reasoningTraces, expiry,
);
};

interface FeedRow {
key: string;
time: string;
market: string;
size: string;
bet: string;
prob: string;
color: string;
}

/** 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 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, burst } = useBurstFeed({ capacity: 8, type: "TRADE_EXECUTED" });
const reduceMotion = useReducedMotion();
const feed = rows.flatMap(event => {
if (event.type !== "TRADE_EXECUTED") return [];
const data = event.payload;
return [{
key: data.eventId,
time: formatFeedTime(data.closedAt ?? event.receivedAt) + (data.closedAt ? "" : " received"),
market: data.marketQuestion || `Market #${data.onChainMarketId}`,
size: `$${formatUsdc(BigInt(data.collateralRaw), { maxDecimals: 2, grouping: true })}`,
bet: `${data.isBuy ? "BUY" : "SELL"} ${data.direction}`,
prob: `YES ${(data.priceYesBps / 100).toFixed(1)}%`,
color: data.direction === "YES" ? "text-secondary" : "text-tertiary",
}];
});
const animate = !reduceMotion && !burst;
const tableRows = feed.map(row => {
const cells = <>
<td className="py-3 text-on-surface-variant">{row.time}</td>
<td className="py-3">{row.market}</td>
<td className="py-3 text-right">{row.size}</td>
<td className={`py-3 text-right ${row.color} font-bold`}>{row.bet}</td>
<td className="py-3 text-right">{row.prob}</td>
</>;
const className = "hover:bg-surface-container transition-colors border-b border-outline-variant/30";
return animate
? <motion.tr key={row.key} initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 20 }} className={className}>{cells}</motion.tr>
: <tr key={row.key} className={className}>{cells}</tr>;
});

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>
{burst && <p className="text-xs text-on-surface-variant mb-2">{burst.count} trades in the last second{burst.overflow ? ` · ${burst.overflow} more events while this tab was hidden` : ""}.</p>}
<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,29 +234,9 @@ 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">
<AnimatePresence initial={false}>
{feed.length === 0 && (
<tr>
<td colSpan={5} className="py-6 text-center text-on-surface-variant">Waiting for on-chain trades…</td>
</tr>
)}
{feed.map((row) => (
<motion.tr
key={row.key}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ 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>
<td className="py-3">{row.market}</td>
<td className="py-3 text-right">{row.size}</td>
<td className={`py-3 text-right ${row.color} font-bold`}>{row.bet}</td>
<td className="py-3 text-right">{row.prob}</td>
</motion.tr>
))}
</AnimatePresence>
<tbody key={animate ? "animated" : "static"} className="font-data-mono text-body-md">
{feed.length === 0 && <tr><td colSpan={5} className="py-6 text-center text-on-surface-variant">Waiting for on-chain trades…</td></tr>}
{animate ? <AnimatePresence initial={false}>{tableRows}</AnimatePresence> : tableRows}
</tbody>
</table>
</div>
Expand Down Expand Up @@ -332,7 +299,7 @@ export default function Markets() {
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-64 bg-surface-container-low animate-pulse rounded-xl border border-outline-variant"></div>
<div key={i} className="h-64 bg-surface-container-low animate-pulse motion-reduce:animate-none rounded-xl border border-outline-variant"></div>
))}
</div>
) : error ? (
Expand Down Expand Up @@ -376,7 +343,7 @@ export default function Markets() {

<OnChainOnlyMarkets knownIds={knownIds} />

<TradingFeed />
<RealtimeProfiler id="TradingFeed"><TradingFeed /></RealtimeProfiler>
</main>
</div>
);
Expand Down
Loading