Skip to content

Commit da8af63

Browse files
committed
feat(web,api): a market link unfurled as one line, and a stalled market never said so
TWO ENDPOINTS THAT NOTHING WAS READING Both already existed, fully built and documented, and both named the reader they were missing. `/markets/:token/preview.svg` renders a 1200x630 card per market — the thing a link to a token becomes when it is pasted into X, Telegram or Discord. `generateMetadata` on the market page returned a bare `title` and nothing else, so the card had never been referenced and never been seen. `/graduations/pending` carries `waitingBlocks`, and its own note says who wants it: "the keeper reads it, the operator alerts on it, and a UI can offer the finalise to whoever is looking at a stalled market. All three want the same list." Two of the three were using it. `MarketDetail` has no graduation timestamp, so without this the finalise panel could not tell a market that entered GRADUATING a minute ago from one that entered a week ago — which is the entire decision it asks someone to make. The panel now says which. Under the threshold it reads as context, in the same grey as the rest of the prose: a few hundred blocks is waiting for the large lane, produced about once in 120. Past it, the sentence changes to a reason to act and takes the warning colour §42 reserves for exactly that. The read fails silently — the finalise works without knowing the age, so a reconnecting endpoint takes the sentence away and not the button. WHO DECIDES "STALLED" The API, now per row. The client had been importing `STALLED_AFTER_BLOCKS` from the handlers module to compare for itself, which is either a server module in a browser bundle or a copied threshold that drifts (§1064). Each pending row carries its own `stalled` and the panel renders the judgement rather than repeating it. I expected removing that import to shrink the bundle and it did not — /t/[token] is 13.7 kB either way. The growth is `lib/chain.ts` pulling viem's ABI encode/decode into the client, which the wrap flow genuinely needs. The change stands on the one-definition argument, not the size one. THE IMAGE IS SVG, AND THAT IS THE OPEN HALF X, Discord and Telegram do not render SVG in an unfurl. Slack does, and the rest omit the image and show the text card — strictly better than the bare title they showed before. `og:image:type` is declared so a crawler skips it cleanly instead of guessing at the bytes. Rasterising to PNG needs a native dependency and a runtime §434 has not fixed. That is a deployment decision; picking one here would be choosing for someone else. When a PNG endpoint lands, only the URL and the type change. Verified in Chrome against a market held in GRADUATING: og:title, og:description, og:image and the twitter card all carry the market's own values; at 240 blocks the panel shows the calm line, at 4820 it shows the amber one and the intent's review gains a "4820 blocks ago" row. 181 API checks green.
1 parent d3be58f commit da8af63

5 files changed

Lines changed: 180 additions & 5 deletions

File tree

apps/web/src/app/t/[token]/page.tsx

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
import { notFound } from "next/navigation";
2626

27-
import { getMarket, getTape, isOk } from "../../../lib/api.ts";
27+
import { getMarket, getTape, isOk, API_BASE } from "../../../lib/api.ts";
2828
import {
2929
formatCompact,
3030
formatQuoteCompact,
@@ -41,19 +41,76 @@ import { WrapPanel } from "../../../components/WrapPanel.tsx";
4141

4242
import styles from "./terminal.module.css";
4343
import type { JSX } from "react";
44+
import type { Metadata } from "next";
4445

4546
export const revalidate = 0;
4647

48+
/**
49+
* WHAT A LINK TO THIS MARKET LOOKS LIKE WHEN SOMEBODY PASTES IT (§117)
50+
* ---------------------------------------------------------------------
51+
* This returned a bare `title` and nothing else, so a market link pasted into
52+
* X, Telegram or Discord unfurled as one line of text. The API has generated a
53+
* 1200×630 card per market this whole time and served it at
54+
* `/markets/:token/preview.svg`; nothing referenced it, so nothing ever saw it.
55+
*
56+
* The description is built from what the market IS rather than from marketing
57+
* copy: the pair, the supply and the fee are the three facts someone deciding
58+
* whether to open the link actually wants, and they are LOCKED values so they
59+
* cannot go stale.
60+
*
61+
* THE IMAGE IS SVG, AND THAT IS THE OPEN HALF
62+
* -------------------------------------------
63+
* X, Discord and Telegram do not render SVG in an unfurl. Slack does, and every
64+
* other crawler that cannot simply omits the image and shows the text card —
65+
* which is strictly better than the bare title it showed before. So this is
66+
* pointed at the SVG, with `og:image:type` declared so a crawler skips it
67+
* cleanly instead of guessing at the bytes.
68+
*
69+
* Rasterising to PNG needs a native dependency — resvg, sharp, a headless
70+
* browser — and which one depends on a runtime §434 has not fixed. That is a
71+
* deployment decision, not a code one, and inventing it here would be picking
72+
* for someone else. When a PNG endpoint lands, the only change is the URL and
73+
* the type on the two lines below.
74+
*/
4775
export async function generateMetadata({
4876
params,
4977
}: {
5078
params: Promise<{ token: string }>;
51-
}): Promise<{ title: string }> {
79+
}): Promise<Metadata> {
5280
const { token } = await params;
5381
const result = await getMarket(token).catch(() => null);
5482

5583
if (result === null || !isOk(result)) return { title: "Market" };
56-
return { title: `${result.data.symbol}${result.data.name}` };
84+
85+
const market = result.data;
86+
const title = `${market.symbol}${market.name}`;
87+
const description =
88+
`${market.symbol} is quoted against ${market.quoteSymbol} on SENT. ` +
89+
`Fixed supply of one billion, no creator allocation, and liquidity that ` +
90+
`locks permanently when the market graduates.`;
91+
92+
const image = `${API_BASE}/markets/${market.token}/preview.svg`;
93+
94+
return {
95+
title,
96+
description,
97+
openGraph: {
98+
title,
99+
description,
100+
siteName: "SENT",
101+
type: "website",
102+
images: [{ url: image, width: 1200, height: 630, type: "image/svg+xml", alt: title }],
103+
},
104+
twitter: {
105+
// `summary_large_image` even though the image is SVG: the card type also
106+
// decides the text layout, and a crawler that drops the image still lays
107+
// the rest out correctly.
108+
card: "summary_large_image",
109+
title,
110+
description,
111+
images: [image],
112+
},
113+
};
57114
}
58115

59116
export default async function TerminalPage({

apps/web/src/components/FinalizePanel.module.css

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,28 @@
5252
line-height: 1.5;
5353
}
5454

55+
/*
56+
* How long it has been waiting.
57+
*
58+
* Two weights for the same fact. Under the threshold it is context and reads
59+
* like the rest of the prose; past it, it is the reason the reader is being
60+
* asked to act, and it takes the warning colour §42 reserves for exactly that.
61+
*/
62+
.waiting {
63+
color: var(--text-dim);
64+
font-size: var(--text-sm);
65+
line-height: 1.5;
66+
}
67+
68+
.stalled {
69+
padding: var(--s-3);
70+
border-radius: var(--radius-sm);
71+
border: 1px solid var(--warn);
72+
color: var(--warn);
73+
font-size: var(--text-sm);
74+
line-height: 1.5;
75+
}
76+
5577
.review {
5678
min-height: 3rem;
5779
}

apps/web/src/components/FinalizePanel.tsx

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,12 @@
4343
* would overstate the pool by a factor of a trillion.
4444
*/
4545

46-
import { useMemo, useState, type JSX } from "react";
46+
import { useEffect, useMemo, useState, type JSX } from "react";
4747

4848
import { buildFinalizeGraduationIntent, toRawForPayout } from "@sent/sdk";
4949
import { TOTAL_SUPPLY } from "@sent/economics";
5050

51+
import { getPendingGraduations, isOk } from "../lib/api.ts";
5152
import { useWallet, CHAIN_ID } from "../lib/wallet.ts";
5253
import { IntentReview } from "./IntentReview.tsx";
5354

@@ -75,6 +76,50 @@ export function FinalizePanel({
7576
const [submitting, setSubmitting] = useState(false);
7677
const [sent, setSent] = useState<string | null>(null);
7778
const [error, setError] = useState<string | null>(null);
79+
const [waiting, setWaiting] = useState<{ blocks: bigint; stalled: boolean } | null>(null);
80+
81+
/*
82+
* HOW LONG HAS THIS BEEN WAITING (§16, V-20)
83+
* ------------------------------------------
84+
* `MarketDetail` has no graduation timestamp, so the page cannot tell a
85+
* market that entered GRADUATING a minute ago from one that entered a week
86+
* ago. That difference is the entire decision this panel asks someone to
87+
* make: a few hundred blocks is waiting for the large block lane, which is
88+
* produced about once in 120 blocks; thousands means nobody finalised.
89+
*
90+
* `/graduations/pending` already carries it, and its own documentation names
91+
* this exact reader — "a UI can offer the finalise to whoever is looking at a
92+
* stalled market". It was serving the keeper and the operator and nothing
93+
* else.
94+
*
95+
* Failure here is silent on purpose. The finalise works without knowing the
96+
* age, so a reconnecting endpoint must not take the button away — it just
97+
* takes the sentence away.
98+
*/
99+
useEffect(() => {
100+
const controller = new AbortController();
101+
102+
void (async () => {
103+
const result = await getPendingGraduations({ signal: controller.signal }).catch(() => null);
104+
if (controller.signal.aborted || result === null || !isOk(result)) return;
105+
106+
const row = result.data.pending.find(
107+
(entry) => entry.market.toLowerCase() === market.toLowerCase(),
108+
);
109+
110+
if (row === undefined) {
111+
setWaiting(null);
112+
return;
113+
}
114+
115+
const blocks = parseDecimal(row.waitingBlocks);
116+
// Whether this counts as stalled is the API's judgement, not a comparison
117+
// repeated here against a copy of its threshold.
118+
setWaiting(blocks === null ? null : { blocks, stalled: row.stalled });
119+
})();
120+
121+
return () => controller.abort();
122+
}, [market]);
78123

79124
const intent = useMemo(() => {
80125
const dist = parseDecimal(distributed);
@@ -99,8 +144,10 @@ export function FinalizePanel({
99144
quoteAmount: toRawForPayout(collateral, quoteDecimals),
100145
quoteDecimals,
101146
quoteSymbol,
147+
// Review only, and omitted rather than guessed when the read failed.
148+
...(waiting !== null ? { waitingBlocks: waiting.blocks } : {}),
102149
});
103-
}, [curveCollateral, distributed, market, quoteDecimals, quoteSymbol, symbol]);
150+
}, [curveCollateral, distributed, market, quoteDecimals, quoteSymbol, symbol, waiting]);
104151

105152
const wire = useMemo(
106153
() =>
@@ -168,6 +215,22 @@ export function FinalizePanel({
168215
This costs real money and returns nothing. A user who finds that out
169216
from a gas estimate they cannot interpret has been surprised by us.
170217
*/}
218+
{/*
219+
Said only when it is true.
220+
221+
A market a few hundred blocks in is waiting for a lane, and telling that
222+
user something is wrong would be raising an alarm about a system working
223+
as designed (§42). Past the threshold the API itself calls a fault, the
224+
sentence changes from context to a reason to act.
225+
*/}
226+
{waiting !== null && (
227+
<p className={waiting.stalled ? styles.stalled : styles.waiting}>
228+
{waiting.stalled
229+
? `Waiting ${waiting.blocks.toString()} blocks — long enough that nobody has finalised it. This is the case this button exists for.`
230+
: `Waiting ${waiting.blocks.toString()} blocks. The large block lane is produced about once in 120, so a few hundred is normal.`}
231+
</p>
232+
)}
233+
171234
<p className={styles.cost}>
172235
It costs about 5.4M gas and pays the sender nothing. That is over HyperEVM&apos;s
173236
default 3M block limit, so the transaction only gets included if your address is

apps/web/src/lib/api.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import type {
3535
AccountResponse,
3636
PlatformStatsResponse,
3737
HealthResponse,
38+
PendingGraduationsResponse,
3839
} from "@sent/api/handlers";
3940
import type { IntentKind, IntentRow } from "@sent/sdk";
4041
import type { FreshnessEnvelope } from "@sent/realtime";
@@ -50,6 +51,7 @@ export type {
5051
AccountResponse,
5152
PlatformStatsResponse,
5253
HealthResponse,
54+
PendingGraduationsResponse,
5355
IntentKind,
5456
IntentRow,
5557
};
@@ -496,6 +498,26 @@ export function getCreator(
496498
return request<CreatorResponse>(`/creators/${address}`, options, anyObject);
497499
}
498500

501+
/**
502+
* Markets whose curve has closed and whose pool has not been minted (§16, V-20).
503+
*
504+
* The API's own note on this endpoint says who wants it: "the keeper reads it,
505+
* the operator alerts on it, and a UI can offer the finalise to whoever is
506+
* looking at a stalled market. All three want the same list." Two of the three
507+
* were using it.
508+
*
509+
* It carries `waitingBlocks`, which is the only place that number exists —
510+
* `MarketDetail` has no graduation timestamp, so without this a finalise panel
511+
* cannot say whether the market has been waiting a minute or a week. That
512+
* difference is the whole decision: one is waiting for a block lane, the other
513+
* has been forgotten.
514+
*/
515+
export function getPendingGraduations(
516+
options?: RequestOptions,
517+
): Promise<ApiResult<PendingGraduationsResponse>> {
518+
return request<PendingGraduationsResponse>("/graduations/pending", options, anyObject);
519+
}
520+
499521
export function getHealth(options?: RequestOptions): Promise<ApiResult<HealthResponse>> {
500522
return request<HealthResponse>("/health", options, anyObject);
501523
}

services/api/src/handlers.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1642,6 +1642,16 @@ export interface PendingGraduationsResponse {
16421642
readonly symbol: string;
16431643
readonly graduatingAtBlock: string;
16441644
readonly waitingBlocks: string;
1645+
/**
1646+
* Whether THIS market has waited past the threshold.
1647+
*
1648+
* Served rather than left for the caller to derive. A client that compared
1649+
* `waitingBlocks` itself would need `STALLED_AFTER_BLOCKS`, and the only
1650+
* ways to get it are to import this module into a browser bundle or to
1651+
* copy the number — one drags a server module into the client, the other
1652+
* is a second definition of a threshold that must have one (§1064).
1653+
*/
1654+
readonly stalled: boolean;
16451655
}[];
16461656
/** True when at least one market has been waiting long enough to be a fault. */
16471657
readonly stalled: boolean;
@@ -1668,6 +1678,7 @@ export function handlePendingGraduations(
16681678
symbol: r.symbol,
16691679
graduatingAtBlock: r.graduatingAtBlock.toString(),
16701680
waitingBlocks: r.waitingBlocks.toString(),
1681+
stalled: r.waitingBlocks > STALLED_AFTER_BLOCKS,
16711682
})),
16721683
stalled: rows.some((r) => r.waitingBlocks > STALLED_AFTER_BLOCKS),
16731684
});

0 commit comments

Comments
 (0)