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
51 changes: 51 additions & 0 deletions .github/workflows/leaderboard-stats.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: leaderboard-stats

# Precomputes per-wallet win-rate + recent realized P&L for the leaderboard set
# and commits ui/public/leaderboard-stats.json. Polymarket's public leaderboard
# API only exposes all-time profit/volume per wallet; win-rate + sample size
# need each wallet's /trades history — an ~80-wallet fan-out that doesn't belong
# in a per-render client fetch, so it's batched here every 6h and served static.
# The script skips the write (no commit, no redeploy) when stats are unchanged.

on:
schedule:
- cron: "0 */6 * * *"
workflow_dispatch: {} # manual trigger from the Actions tab

permissions:
contents: write

concurrency:
group: leaderboard-stats
cancel-in-progress: false

jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 1

- uses: actions/setup-node@v4
with:
node-version: "20"

- name: Compute leaderboard stats
working-directory: ui
run: npx -y tsx@4 scripts/build-leaderboard-stats.ts

- name: Commit if changed
run: |
git config user.name "auspex-bot"
# GitHub-attributable noreply so Vercel accepts the auto-deploy this
# commit triggers (same as data-refresh.yml).
git config user.email "96061296+gorillachimps@users.noreply.github.com"
if git diff --quiet ui/public/leaderboard-stats.json; then
echo "No changes — exiting clean."
exit 0
fi
git add ui/public/leaderboard-stats.json
git commit -m "data: refresh leaderboard stats ($(date -u +%H:%MZ))"
git push
103 changes: 103 additions & 0 deletions ui/app/api/holders/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { NextResponse } from "next/server";

export const revalidate = 120;

const GAMMA = "https://gamma-api.polymarket.com";
const DATA = "https://data-api.polymarket.com";
const COND_RE = /^0x[0-9a-fA-F]{64}$/;
const SLUG_RE = /^[a-z0-9-]{1,120}$/i;
const ADDR_RE = /^0x[0-9a-fA-F]{40}$/;
const HOLDERS_LIMIT = 12;

type Holder = { proxyWallet: string; name: string; amount: number };
type Group = { token: string; outcomeIndex: number; holders: Holder[] };

/**
* Server-side Top Holders resolver. The Polymarket /holders endpoint keys on a
* CTF conditionId, which our snapshot doesn't carry — so we resolve it from the
* market slug via gamma-api, then fetch holders from data-api. Composed server-
* side (one round-trip for the client, cacheable, no CORS hop).
*
* gamma-api occasionally returns unescaped control chars in description fields,
* which breaks JSON.parse — so we regex-extract the conditionId from the raw
* text rather than parsing the whole payload.
*/
async function resolveConditionId(slug: string): Promise<string | null> {
const r = await fetch(`${GAMMA}/markets?slug=${encodeURIComponent(slug)}`, {
cache: "no-store",
});
if (!r.ok) return null;
const text = await r.text();
const m = text.match(/"conditionId"\s*:\s*"(0x[0-9a-fA-F]{64})"/);
return m ? m[1] : null;
}

function cleanGroups(value: unknown): Group[] {
if (!Array.isArray(value)) return [];
const groups: Group[] = [];
for (const item of value) {
if (!item || typeof item !== "object") continue;
const rec = item as Record<string, unknown>;
const token = typeof rec.token === "string" ? rec.token : "";
const outcomeIndex =
typeof rec.outcomeIndex === "number" ? rec.outcomeIndex : 0;
const rawHolders = Array.isArray(rec.holders) ? rec.holders : [];
const holders: Holder[] = [];
for (const h of rawHolders) {
if (!h || typeof h !== "object") continue;
const hr = h as Record<string, unknown>;
const proxyWallet =
typeof hr.proxyWallet === "string" ? hr.proxyWallet : "";
if (!ADDR_RE.test(proxyWallet)) continue;
const amount =
typeof hr.amount === "number" ? hr.amount : Number(hr.amount);
if (!Number.isFinite(amount) || amount <= 0) continue;
const pseudonym = typeof hr.pseudonym === "string" ? hr.pseudonym : "";
const name = typeof hr.name === "string" ? hr.name : "";
holders.push({ proxyWallet, name: pseudonym || name || "", amount });
}
if (holders.length > 0) groups.push({ token, outcomeIndex, holders });
}
return groups;
}

export async function GET(request: Request) {
const url = new URL(request.url);
const slug = url.searchParams.get("slug");
const marketParam = url.searchParams.get("market");

let conditionId: string | null = null;
if (marketParam && COND_RE.test(marketParam)) {
conditionId = marketParam;
} else if (slug && SLUG_RE.test(slug)) {
conditionId = await resolveConditionId(slug);
} else {
return NextResponse.json(
{ error: "expected ?slug=<market-slug> or ?market=<conditionId>" },
{ status: 400 },
);
}

if (!conditionId) {
return NextResponse.json(
{ conditionId: null, groups: [] },
{ headers: { "Cache-Control": "public, s-maxage=120, stale-while-revalidate=300" } },
);
}

let groups: Group[] = [];
try {
const hr = await fetch(
`${DATA}/holders?market=${conditionId}&limit=${HOLDERS_LIMIT}`,
{ cache: "no-store" },
);
if (hr.ok) groups = cleanGroups(JSON.parse(await hr.text()));
} catch {
// upstream hiccup → empty result; the client shows an empty state
}

return NextResponse.json(
{ conditionId, groups },
{ headers: { "Cache-Control": "public, s-maxage=120, stale-while-revalidate=300" } },
);
}
2 changes: 1 addition & 1 deletion ui/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
--border-strong: #2a3142;
--foreground: #e6e8ee;
--muted: #8a91a3;
--muted-2: #5d6478;
--muted-2: #7a8195;
--accent: #a78bfa;
--accent-strong: #8b5cf6;
--positive: #34d399;
Expand Down
52 changes: 51 additions & 1 deletion ui/app/markets/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { TradeSizeDistribution } from "@/components/TradeSizeDistribution";
import { ShareButtons } from "@/components/ShareButtons";
import { TriggerAlertButton } from "@/components/TriggerAlertButton";
import { DisqusComments } from "@/components/DisqusComments";
import { TopHolders } from "@/components/TopHolders";
import { cn } from "@/lib/cn";
import { getMarketBySlug } from "@/lib/data";
import {
Expand Down Expand Up @@ -223,13 +224,44 @@ export default async function MarketDetailPage({ params, searchParams }: Props)
/>
</div>

<Card className="mt-6" title="Rule">
<div className="mt-6">
<TopHolders
slug={row.slug}
tokenYes={row.tokenYes ?? null}
tokenNo={row.tokenNo ?? null}
/>
</div>

<Card className="mt-6" title="How this resolves">
<p className="text-[13px] leading-relaxed text-foreground/90">
{summarizeRules(row)}
{row.liveState === "deferred" && row.liveReason ? (
<span className="ml-2 text-muted">({row.liveReason})</span>
) : null}
</p>
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-3">
<ResolveFact label="Settles by" value={fmtSourceLabel(row.source, row.pair)} />
<ResolveFact
label="Resolution deadline"
value={row.endDate ? new Date(row.endDate).toUTCString() : "—"}
/>
<ResolveFact label="Oracle" value="UMA optimistic oracle" />
</dl>
<p className="mt-3 text-[12px] leading-relaxed text-muted">
Outcomes are finalized by Polymarket via UMA&apos;s optimistic
oracle: a proposed result can be challenged during a dispute window
before it settles. {resolutionStateNote(row)} Auspex doesn&apos;t
control resolution — confirm the live oracle status on{" "}
<a
href={`https://polymarket.com/event/${row.slug}`}
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
Polymarket
</a>
.
</p>
</Card>

<div className="mt-6">
Expand Down Expand Up @@ -321,6 +353,24 @@ function Card({
);
}

function ResolveFact({ label, value }: { label: string; value: string }) {
return (
<div className="flex flex-col">
<dt className="text-[10px] uppercase tracking-wider text-muted-2">{label}</dt>
<dd className="tabular text-[12px] text-foreground/90">{value}</dd>
</div>
);
}

/** Honest, data-derived resolution-state note. We surface the UMA *mechanism*
* and the lifecycle phase inferred from the close date — we deliberately do
* NOT invent a specific dispute status we can't verify on-chain here. */
function resolutionStateNote(row: { endDate: string | null }): string {
return urgencyForEnd(row.endDate) === "ended"
? "Trading has closed; it's now in the resolution / arbitration window."
: "It's still open for trading until the deadline above.";
}

/** Build the pre-filled text for share-to-X / Farcaster. Includes the market
* question and a short factoid line so a casual viewer can grasp the bet
* without clicking through. */
Expand Down
36 changes: 35 additions & 1 deletion ui/app/wallets/[address]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,21 @@ import { WalletTradesView } from "@/components/WalletTradesView";
import { WalletPnLChart } from "@/components/WalletPnLChart";
import { useUserPositions } from "@/lib/useUserPositions";
import { useWalletTrades } from "@/lib/useWalletTrades";
import { computeWalletPnl } from "@/lib/walletPnl";
import { computeWalletPnl, deriveTraderTags, type TraderTag } from "@/lib/walletPnl";
import { shortAddress } from "@/lib/resolveWallet";
import { cn } from "@/lib/cn";

type Props = { params: Promise<{ address: string }> };

const TAG_TONE: Record<TraderTag["tone"], string> = {
sharp: "bg-accent/15 text-accent ring-accent/40",
whale: "bg-fuchsia-500/15 text-fuchsia-200 ring-fuchsia-400/40",
good: "bg-emerald-500/15 text-emerald-200 ring-emerald-400/40",
active: "bg-sky-500/15 text-sky-200 ring-sky-400/40",
caution: "bg-amber-500/15 text-amber-200 ring-amber-400/40",
bad: "bg-rose-500/15 text-rose-200 ring-rose-400/40",
};

export default function WalletDetailPage({ params }: Props) {
const { address } = use(params);
const normalised = address.toLowerCase();
Expand All @@ -31,6 +40,11 @@ export default function WalletDetailPage({ params }: Props) {
return computeWalletPnl(tradesState.trades, positionsState.positions);
}, [tradesState.trades, positionsState.positions]);

const tags = useMemo(
() => (pnl && tradesState.trades ? deriveTraderTags(pnl, tradesState.trades) : []),
[pnl, tradesState.trades],
);

const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return;
Expand Down Expand Up @@ -101,6 +115,26 @@ export default function WalletDetailPage({ params }: Props) {
<FollowButton address={proxy} />
</div>

{tags.length > 0 ? (
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-2">
Trader profile
</span>
{tags.map((t) => (
<span
key={t.label}
title={t.hint}
className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold ring-1",
TAG_TONE[t.tone],
)}
>
{t.label}
</span>
))}
</div>
) : null}

{/* P&L summary tiles */}
<section className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat
Expand Down
Loading