From a0cf7982cdd8bb075af5c695c5e18148bc425af1 Mon Sep 17 00:00:00 2001 From: tom Date: Tue, 28 Jul 2026 18:22:39 +0200 Subject: [PATCH 01/13] Spec the transaction OG title and description task (#3593) Medium task, five subtasks: make the `og` block a default/enhanced template layer, share the interpretation currency rounding and render summaries as plain text, derive the status/action/timestamp params, wire the bot-gated server-side fetch plus the /tx/[hash] templates, and verify on a demo (agent deploys, human confirms the real card). Co-Authored-By: Claude Opus 5 --- .../3593-tx-og-title-description/spec.md | 182 ++++++++++++++++++ .../subtasks/01-og-template-layer/spec.md | 93 +++++++++ .../02-interpretation-plain-text/spec.md | 115 +++++++++++ .../03-tx-og-description-params/spec.md | 94 +++++++++ .../04-gssp-wiring-and-templates/spec.md | 106 ++++++++++ .../subtasks/05-demo-deploy/spec.md | 84 ++++++++ 6 files changed, 674 insertions(+) create mode 100644 .agents/tasks/3593-tx-og-title-description/spec.md create mode 100644 .agents/tasks/3593-tx-og-title-description/subtasks/01-og-template-layer/spec.md create mode 100644 .agents/tasks/3593-tx-og-title-description/subtasks/02-interpretation-plain-text/spec.md create mode 100644 .agents/tasks/3593-tx-og-title-description/subtasks/03-tx-og-description-params/spec.md create mode 100644 .agents/tasks/3593-tx-og-title-description/subtasks/04-gssp-wiring-and-templates/spec.md create mode 100644 .agents/tasks/3593-tx-og-title-description/subtasks/05-demo-deploy/spec.md diff --git a/.agents/tasks/3593-tx-og-title-description/spec.md b/.agents/tasks/3593-tx-og-title-description/spec.md new file mode 100644 index 0000000000..c3a5b104e9 --- /dev/null +++ b/.agents/tasks/3593-tx-og-title-description/spec.md @@ -0,0 +1,182 @@ +# Generate transaction OG title and description from transaction details + +| | | +| --- | --- | +| Issue | https://github.com/blockscout/frontend/issues/3593 | +| Status | `ready` | +| Size | `medium` | +| Feature branch | `issue-3593` | +| PM | Ulyana (task author) | +| Designer | — (no mockups; the deliverable is two text templates) | +| Backend | — (both endpoints already deployed to production) | +| Slack channel | — (default routing per `to-spec`) | + +## Context & goal + +A shared link to a transaction page currently produces a near-useless social preview: the OG title carries +the **full** 66-character hash and there is no `og:description` at all, so Telegram/X fall back to the +generic page description ("… detailed transaction info. View transaction status, block confirmation, gas +fee …"). Verified live with a `Twitterbot` user agent against a production instance — the `/tx/[hash]` +entry in `src/shell/metadata/templates/index.ts` has no `og` key, so `generate()` emits `og:title` = the +page title and nothing else. + +The task makes the preview describe the actual transaction: + +```text +{chain_name} transaction {tx_hash_short} | Blockscout +{status} · {tx_action} · {timestamp} +``` + +The action must read the same as the transaction details page subheading, including its fallback chain and +its amount rounding. Reaching that requires two structural changes: the `og` block in the template map +becomes a real template layer with `default`/`enhanced` variants (today `og.description` is passed through +raw, never compiled), and `/tx/[hash]`'s `getServerSideProps` gains a bot-gated server-side fetch, since +crawlers don't run JS and `metadata.update()` only ever touches `` and `<meta description>`. + +## Functional requirements + +- **OG title** — `%chain_name% transaction %hash_short%` plus the ` | Blockscout` postfix (still gated by + `promoteBlockscoutInTitle`). Needs no API data, so it is served to every crawler including search engines. + `hash_short` is `shortenString(hash, 8)` → `0xda...671a`. +- **OG description** — `%tx_status% · %tx_action% · %tx_timestamp%`, populated only for social-preview bots. + When any part is missing it falls back to the page's `<meta description>` value. +- **The SEO tags are unchanged.** `<title>` keeps the full hash; `<meta name="description">` keeps the + generic copy. OG title and OG description resolve independently of each other, and `generate()` receives + no notion of bot type — the presence of `apiData` is the only signal, and `apiData` is only populated for + social-preview bots. +- **Status** — `ok` → `Success`, `error` → `Failed`, `null` → `Pending`, `undefined` → nothing. + `Failed` is the bare word; no revert reason is appended (the UI keeps it in a tooltip and a collapsible). +- **Timestamp** — `MMM D, YYYY H:mm UTC`, always UTC (a server-rendered tag has no user timezone). +- **Action** — mirrors `TxSubHeading`'s chain exactly: + + | condition | action text | + | --- | --- | + | interpretation feature off | none → fallback OG values (and `/summary` is not requested at all) | + | feature on, summary passes `checkSummary` | the summary rendered as plain text | + | feature on, no usable summary, has `method` + `from` + `to` | `0xab...cd called\|failed to call M on 0xef...12` | + | feature on, no usable summary, missing any of those | none → fallback OG values | + +- **All-or-nothing**, per the existing `compileValue` contract: the `enhanced` template is used only when + every placeholder in it is truthy. Accepted loss cases — a **pending** transaction (its `timestamp` is + `null`), an **unresolvable action**, and any **failed, timed-out, or 404** request. +- No behavior change for any other route: after the template-layer refactor, every existing route's + `title` / `description` / `opengraph` output is byte-identical. + +### Verification + +- `curl -A Twitterbot http://localhost:3000/tx/<hash>` shows the new `og:title` / `og:description`; the same + URL without a bot UA shows the unchanged SEO tags. +- `src/shell/metadata/__snapshots__/generate.spec.ts.snap` — existing entries unchanged. +- Metrics need no work and are checked, not built: `social_preview_bot_requests_total{route="/tx/[hash]"}` + is already incremented globally from `_document.tsx` via `logRequestFromBot` using `ctx.pathname`, and + `api_request_duration_seconds{route,code}` is recorded inside `fetchApi` itself (labelled by resource + name, with `504` on abort) — so the new server-side calls are instrumented for free. +- On the demo: paste the link into Telegram and see the card; confirm from + `api_request_duration_seconds` that the 1 s timeouts are actually sufficient against the core API. + +## Data & API + +Both resources already exist in the registry and are deployed to production (sampled with `curl` during +grilling — no backend release to wait on, nothing to add via `add-api-resource`). + +- **`core:tx`** → `/api/v2/transactions/:hash` — supplies `status` (`"ok" | "error" | null`), `timestamp` + (`TimestampNullable` — **null on pending transactions**), and, for the fallback action branch, `method` + (`MethodNameNullable`), `from`, `to` (`Address | null`). One request covers both needs. +- **`core:tx_interpretation`** → `/api/v2/transactions/:hash/summary` — supplies the action summary. + Sampled shape for the issue's example transaction: + + <!-- cspell:ignore SPERPS --> + + ```json + { "data": { "summaries": [ { + "summary_template": "{action_type} {outgoing_amount} {outgoing_token} for {incoming_amount} {incoming_token}", + "summary_template_variables": { + "action_type": { "type": "string", "value": "Swap" }, + "outgoing_amount": { "type": "currency", "value": "2918443.532640630294962772" }, + "outgoing_token": { "type": "token", "value": { "symbol": "SPERPS", "…": "…" } }, + "incoming_amount": { "type": "currency", "value": "0.015575428823202624" }, + "incoming_token": { "type": "token", "value": { "symbol": "WETH", "…": "…" } } + } } ] }, "success": true } + ``` + + Replaying the UI's rounding over that gives `Swap 2.92M SPERPS for 0.016 WETH` — matching the issue. +- Note that a plain native transfer (`method: null`) still gets a usable summary from `/summary`, so on + instances with the interpretation feature on the action resolves nearly always. + +**Fetch plan** — in `/tx/[hash]`'s `getServerSideProps`, gated on +`config.metadata.og.enhancedDataEnabled && detectBotRequest(req)?.type === 'social_preview'` **and** +`!config.features.multichain.isEnabled`. The two requests run in parallel with a **1 s** timeout each +(social-bot traffic is low per Grafana history); `/summary` is skipped entirely when +`config.features.txInterpretation.isEnabled` is false. + +**Trap to code against:** `fetchApi` returns the parsed body **regardless of HTTP status** — it logs +non-200s but still `return await response.json()`. So a bad hash yields `{ message: "Not found" }` typed as +a `TransactionResponse`. Requiring `tx_timestamp` handles that for free (an error body has no `timestamp`), +but the status mapping must distinguish `undefined` from `null`, or a 404 reads as `Pending`. + +**Env var** — reuses `NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED` unchanged. It defaults to **on** +(`!== 'false'`), so this ships enabled on every instance. No new env var, so `add-env-var` is not part of +this task. + +## UI inventory + +No visual output — this task produces `<meta>` tags only, so the usual scaffold → style split does not +apply. Subtasks 1–4 are fully `[agent]`; subtask 5 is mixed — the agent deploys the demo, and the human +verifies that the preview genuinely works in a real social client. + +- Route in scope: `/tx/[hash]` — `src/pages/tx/[hash].tsx`, template at + `src/shell/metadata/templates/index.ts:82`. +- The action's source of truth for text and fallback order is + `src/slices/tx/pages/details/TxSubHeading.tsx` and + `src/features/tx-interpretation/common/components/TxInterpretation.tsx`. + +## Out of scope + +- **`og:image`** — text only. Its current absence is deliberate: `OG_ROOT_PAGE` is attached to list/root + pages, and every entity detail route (address, token, block, tx) has no `og` entry. On a + `summary_large_image` card a generic banner would push the new description below itself. +- **Multichain** `/chain/[chain_slug_or_id]/tx/[hash]` — excluded by the same `!multichain.isEnabled` guard + the token page already uses; multichain routes resolve their API base per-chain through + `factoryMultichain`, and the server-side `fetchApi`/`buildUrl` path isn't wired for that. +- **`/cc/tx/[hash]` and `/cross-chain-tx/[id]`** — different entities with different statuses and no + interpretation summary; they'd need their own product decision. +- Changing the SEO `<title>` or `<meta description>` for `/tx/[hash]`. +- New env vars, Mixpanel events, design work. + +## Task breakdown + +- [ ] 1 `[agent]` Turn the `og` block into a `default`/`enhanced` template layer → `subtasks/01-og-template-layer/` +- [ ] 2 `[agent]` Share the currency rounding and render interpretation summaries as plain text → `subtasks/02-interpretation-plain-text/` +- [ ] 3 `[agent]` Derive the three OG description params for a transaction → `subtasks/03-tx-og-description-params/` +- [ ] 4 `[agent]` Wire the bot-gated fetch and add the `/tx/[hash]` OG templates → `subtasks/04-gssp-wiring-and-templates/` +- [ ] 5 `[agent]` + `[human]` Deploy a demo, then verify the preview manually → `subtasks/05-demo-deploy/` + — the agent deploys and checks the tags over `curl`; the human confirms the real card in Telegram and + rules on whether the 1 s timeouts hold (Grafana isn't agent-reachable). + +## Open questions + +### Q1 — What should the OG description show on Noves-provider instances? + +`TxSubHeading` branches on `config.features.txInterpretation.provider === 'noves'` and renders +`core:noves_transaction`'s prose instead of the Blockscout summary. Three options: + +1. **Fetch the Noves text** — matches the page. `classificationData.description` is already a finished + sentence, so it's one extra `fetchApi` plus a trailing-dot strip; none of `createNovesSummaryObject`'s + template machinery is needed, since that exists only so the UI can linkify tokens and addresses. +2. **Always use the `called {method} on {to}` fallback** on these instances — cheap, but the preview then + disagrees with the page it links to. +3. **Emit no enhanced description at all** on these instances — the preview keeps the generic metadata + description. + +Weighing against option 1 in practice: **the Noves API is usually slow**, so a large share of requests would +hit the 1 s timeout anyway and land on whichever fallback we pick — meaning option 1 mostly buys option 2's +or 3's behavior at the cost of an extra request per bot hit. + +- Owner: PM (Ulyana) +- Status: `pending` +- Slack: https://blockscout.slack.com/archives/C03MMUTQDNU/p1785255479554469 (sent 2026-07-28) +- Answer: <decision + date, once resolved> +- **Does not block any subtask.** The answer adds one branch at the front of the action chain and changes + nothing about its structure, the status word, the timestamp, the template shape, or the gSSP gate. + Subtask 3 is built for the Blockscout provider; Noves folds in as an additive commit whenever the + answer arrives. diff --git a/.agents/tasks/3593-tx-og-title-description/subtasks/01-og-template-layer/spec.md b/.agents/tasks/3593-tx-og-title-description/subtasks/01-og-template-layer/spec.md new file mode 100644 index 0000000000..27cc4563d2 --- /dev/null +++ b/.agents/tasks/3593-tx-og-title-description/subtasks/01-og-template-layer/spec.md @@ -0,0 +1,93 @@ +# Turn the `og` block into a `default`/`enhanced` template layer + +| | | +| --- | --- | +| Parent spec | [../../spec.md](../../spec.md) — step 1 of #3593 | +| Status | `ready` | +| Size | `medium` | +| Sub-branch | — (single commit on `issue-3593`) | +| PM | Ulyana (task author) | +| Designer | — | +| Backend | — | + +## Context & goal + +`generate()` today treats the `og` block as static data: `opengraph.title` is just the compiled page title, +and `opengraph.description` is `TEMPLATE_MAP[pathname].og?.description` **passed through raw** — never run +through `compileValue`, so it can hold no placeholders. That makes a per-route dynamic OG description +impossible, and `RouteTemplateRecord.og` additionally requires `description` and `image` *together*. + +This subtask makes `og` a first-class template layer with the same `default`/`enhanced` mechanics as the +metadata templates, with OG title and OG description resolved independently and falling back to the +metadata values when a route declares no OG template. It is **behavior-preserving**: no route's output +changes. `/tx/[hash]`'s own templates land in subtask 4. + +## Functional requirements + +- `og.title` and `og.description` each accept `{ 'default': string; enhanced?: string }` and are compiled + with `compileValue` against the same `params` object as the metadata templates. +- `og.image` becomes independent of the other two — a route may declare an image with no templates, or + templates with no image. +- The ` | Blockscout` postfix is appended to the compiled OG title in **both** the `default` and `enhanced` + cases, still gated by `config.metadata.promoteBlockscoutInTitle`. +- A route with no `og.title` gets `opengraph.title = title`; with no `og.description`, it gets + `opengraph.description = description`. The description fallback is written **explicitly** even though + crawlers already do it implicitly when the tag is absent — it documents the intent and makes the + resolution rule uniform with the title's. +- New param `hash_short` — `shortenString(hash, 8)`, i.e. `0xda...671a`. Set to `undefined` (not `''`) when + the route has no `hash` query param, so `compileValue`'s truthiness check behaves. +- Every existing route's `title`, `description`, and `opengraph` output is byte-identical afterwards. + +## Data & API + +None. + +## UI inventory + +No visual surface. Files: + +- `src/shell/metadata/templates/index.ts` — the `RouteTemplateRecord` interface and `OG_ROOT_PAGE`. +- `src/shell/metadata/generate.ts` — resolution and the new param. +- `src/shell/metadata/generate.spec.ts` + `__snapshots__/generate.spec.ts.snap`. + +## Out of scope + +- Adding OG templates to `/tx/[hash]` — subtask 4. +- Touching `og:image` for any route. +- `metadata.update()` — client-side updates deliberately don't touch OG tags (bots don't run JS). + +## Task breakdown + +- [ ] 1 `[agent]` Extend `RouteTemplateRecord` in `src/shell/metadata/templates/index.ts` + - inputs: + - Extract the repeated `{ 'default': string; enhanced?: string }` shape into a named interface and + reuse it for `metadata.title`, `metadata.description`, `og.title`, `og.description`. + - New shape: `og?: { title?: <that shape>; description?: <that shape>; image?: string }`. + - Migrate `OG_ROOT_PAGE` to `{ description: { 'default': config.metadata.og.description }, image: config.metadata.og.imageUrl }`. + It is referenced by many routes; the value must stay identical. +- [ ] 2 `[agent]` Resolve OG title and description independently in `src/shell/metadata/generate.ts` + - inputs: + - `const ogTemplates = TEMPLATE_MAP[route.pathname].og;` + - `opengraph.title = ogTemplates?.title ? compileValue(ogTemplates.title, params) + titlePostfix : title` + - `opengraph.description = ogTemplates?.description ? compileValue(ogTemplates.description, params) : description` + - `opengraph.imageUrl = ogTemplates?.image` + - Do **not** thread bot type into `generate()`; `apiData` presence is the only enhancement signal. +- [ ] 3 `[agent]` Add the `hash_short` param in `generate.ts` + - inputs: + - Derive from `castToString(route.query?.hash)` the same way `idParam` / `idFormatted` are derived above it. + - `const hashParam = castToString(route.query?.hash); … hash_short: hashParam ? shortenString(hashParam, 8) : undefined` + - Import `shortenString` from `src/shared/texts/shorten-string`. `charNumber: 8` is what + `truncation="constant"` resolves to in the entity components, so titles and interpretation text + shorten identically. +- [ ] 4 `[agent]` Cover the new layer in `src/shell/metadata/generate.spec.ts` + - inputs: + - Test what's actually new, per `.agents/rules/tests-unit.mdc`: a route with `og` templates only + (title falls back to metadata), a route with both, `enhanced` chosen when all its params are present + and `default` when one is missing, and `hash_short` compilation. + - The existing snapshot entries must come out unchanged — if `pnpm test` rewrites any of them, the + refactor changed behavior and is wrong. New `it` blocks appending new entries is expected. + - `OG_ROOT_PAGE` routes keep emitting the same `og:description` and `og:image` as before. + +## Open questions + +None. (Parent Q1 does not affect this subtask.) diff --git a/.agents/tasks/3593-tx-og-title-description/subtasks/02-interpretation-plain-text/spec.md b/.agents/tasks/3593-tx-og-title-description/subtasks/02-interpretation-plain-text/spec.md new file mode 100644 index 0000000000..620b497961 --- /dev/null +++ b/.agents/tasks/3593-tx-og-title-description/subtasks/02-interpretation-plain-text/spec.md @@ -0,0 +1,115 @@ +# Share the currency rounding and render interpretation summaries as plain text + +| | | +| --- | --- | +| Parent spec | [../../spec.md](../../spec.md) — step 2 of #3593 | +| Status | `ready` | +| Size | `medium` | +| Sub-branch | — (single commit on `issue-3593`) | +| PM | Ulyana (task author) | +| Designer | — | +| Backend | — | + +## Context & goal + +The transaction interpretation summary is a template with typed variables that only `TxInterpretation` +knows how to render — as React elements. The OG description needs the same content as a plain string. + +The issue pins the amounts to the UI ("including … amount rounding"), and that rounding is four magic +thresholds living inline in `TxInterpretationElementByType`'s `currency` case. Copy-pasting them would +guarantee drift the first time someone tunes a threshold, so this subtask extracts them into a shared +function that the component then calls, and adds a plain-text renderer beside it. + +## Functional requirements + +- `formatCurrencyValue(value)` produces exactly what the UI shows today — the ladder from + `src/features/tx-interpretation/common/components/TxInterpretation.tsx:128`: + + | range | output | + | --- | --- | + | `< 0.1` | `BigNumber(value).toPrecision(2)` | + | `< 10000` | `BigNumber(value).dp(2).toFormat()` | + | `< 1000000` | `BigNumber(value).dividedBy(1000).toFormat(2) + 'K'` | + | otherwise | `BigNumber(value).dividedBy(1000000).toFormat(2) + 'M'` | + + `TxInterpretation` calls it instead of holding its own copy; its rendered output must not change. +- `summaryToPlainText(summary)` returns the summary as a single-line string, or `undefined` when + `checkSummary` rejects it — the same gate the UI uses to render nothing. +- Each variable type maps to the text its UI counterpart displays: + + | type | text | + | --- | --- | + | `string` | verbatim (already inlined by `fillStringVariables`) | + | `currency` | `formatCurrencyValue(value)` | + | `token` | `symbol ?? name ?? 'Unnamed token'` — matches `TokenEntity onlySymbol` | + | `address` | `addressToPlainText(value)` (below) | + | `domain` | verbatim | + | `method` | verbatim (the badge's text) | + | `dexTag` | `value.name` | + | `link` / `external_link` | `value.name`; the URL is dropped | + | `timestamp` | `dayjs(Number(value) * SECOND).format('MMM DD YYYY')` — the UI's variable format, **not** the OG description's own timestamp format | + | `native` / `wei` | `currencyUnits.ether` / `currencyUnits.wei` | + +- `addressToPlainText(address)` mirrors `AddressEntity`'s `Content` with `truncation="constant"`: + metadata `name`-type tag (via `getTagName`) ?? `ens_domain_name` ?? `name` ?? `shortenString(hash, 8)`. + It is exported because subtask 3's fallback action branch needs it for `from` / `to`. +- Whitespace comes out clean: single spaces between parts, no leading or trailing space. Against the + production sample in the parent spec the result is exactly `Swap 2.92M SPERPS for 0.016 WETH`. + +<!-- cspell:ignore SPERPS --> + +## Data & API + +None — operates on `TxInterpretationSummary` (`src/features/tx-interpretation/common/types/api.ts`), which +covers all ten variable types. + +## UI inventory + +- `src/features/tx-interpretation/common/components/TxInterpretation.tsx` — its `currency` case now + delegates. No other change; do not restructure the component to be render-agnostic. +- New files in `src/features/tx-interpretation/common/utils/` (kebab-case, matching siblings elsewhere in + the repo): the currency formatter, the plain-text renderer, the address-to-text helper. + +## Out of scope + +- Noves (`createNovesSummaryObject`) — parent Q1, and it wouldn't reuse this anyway: Noves prose is already + a finished sentence. +- Making `TxInterpretationElementByType` itself render-agnostic — the one-line mappings above are cheaper + inlined in the new util than abstracted out of a working component. + +## Task breakdown + +- [ ] 1 `[agent]` Extract the currency ladder into a shared function and call it from `TxInterpretation` + - inputs: + - Signature `(value: string) => string`. Keep `BigNumber` as the implementation — same import, same + thresholds, same order of comparisons. + - The component's `currency` case becomes `<chakra.span>{ formatCurrencyValue(value) + ' ' }</chakra.span>` — + the trailing space is the component's spacing concern and stays there, out of the shared function. +- [ ] 2 `[agent]` Add `addressToPlainText` + - inputs: + - Reproduce `AddressEntity`'s `Content` chain (`src/slices/address/components/entity/AddressEntity.tsx:142`): + the `metadata.tags` entry with `tagType === 'name'` through `getTagName`, then `ens_domain_name`, + then `name`, then `shortenString(hash, 8)`. + - Ignore the proxy-implementation branch (`AddressEntityContentProxy`) and the bech32/Filecoin alt-hash + handling — both are display concerns driven by client-side user settings, unavailable server-side. +- [ ] 3 `[agent]` Add `summaryToPlainText` + - inputs: + - Return `undefined` when `!checkSummary(template, variables)`. + - Reuse the existing `fillStringVariables` → `extractVariables` → `getStringChunks` pipeline from + `../utils/utils` so the parsing stays identical to the component's. + - Assemble parts (trimmed chunk, then that index's variable text), drop empties, join with a single + space, then collapse runs of whitespace and trim. Don't try to replicate the component's per-element + trailing spaces. + - Handle `native` / `wei` by name before the type switch, exactly as the component does. +- [ ] 4 `[agent]` Unit tests + - inputs: + - Reuse `txInterpretation` from `src/features/tx-interpretation/blockscout/mocks.ts` (it exercises + `string`, `currency`, `token`, `address`, `timestamp` in one template) and `TX_INTERPRETATION` from + `blockscout/stubs.ts`. + - Cover the four rounding branches at their boundaries, the `address` fallback chain (name tag / ENS / + name / short hash), `checkSummary` rejection returning `undefined`, and the whitespace result. + - Skip tests that only assert the mock or `BigNumber` itself. + +## Open questions + +None. (Parent Q1 does not affect this subtask.) diff --git a/.agents/tasks/3593-tx-og-title-description/subtasks/03-tx-og-description-params/spec.md b/.agents/tasks/3593-tx-og-title-description/subtasks/03-tx-og-description-params/spec.md new file mode 100644 index 0000000000..9e1da63d82 --- /dev/null +++ b/.agents/tasks/3593-tx-og-title-description/subtasks/03-tx-og-description-params/spec.md @@ -0,0 +1,94 @@ +# Derive the three OG description params for a transaction + +| | | +| --- | --- | +| Parent spec | [../../spec.md](../../spec.md) — step 3 of #3593 | +| Status | `ready` | +| Size | `medium` | +| Sub-branch | — (single commit on `issue-3593`) | +| PM | Ulyana (task author) | +| Designer | — | +| Backend | — | +| Depends on | subtask 2 (`summaryToPlainText`, `addressToPlainText`) | + +## Context & goal + +The unit that turns the two API responses into the three strings the OG description template compiles. + +It lives in the tx slice rather than in `src/shell/metadata/` for two concrete reasons: `generate()` is +called from `update.ts` on every client-side route change, so anything it reaches for is bundled for all +users — and `apiData` is serialized into `__NEXT_DATA__`, so passing raw responses through would ship +`token_transfers`, `decoded_input`, `raw_input` hex and whole `Token`/`Address` objects into the HTML of +every bot request. Three short strings instead. This also matches precedent: `ApiData<'/address/[hash]'>` is +already `{ domain_name: string }` — a derived value, not an API response. + +## Functional requirements + +- One exported function in `src/slices/tx/utils/get-og-description-params.ts` taking the (possibly + `undefined`) `core:tx` and `core:tx_interpretation` responses. +- It returns `{ tx_status: string; tx_action: string; tx_timestamp: string }` **or `null`** — never an + object with `undefined` members. This enforces the parent spec's all-or-nothing rule at the source, and + it is also required: Next.js refuses to serialize `undefined` in props. +- **Status** — `ok` → `Success`, `error` → `Failed`, `null` → `Pending`. `undefined` → no status, mirroring + `TxStatus` returning `null` for `undefined` (`src/slices/tx/components/TxStatus.tsx:19`). This distinction + is load-bearing: `fetchApi` returns non-200 bodies as data, so a 404's `{ message: … }` must not read as + `Pending`. +- **Timestamp** — `dayjs(tx.timestamp).utc().format('lll') + ' UTC'`. The `lll` locale format is already + `MMM D, YYYY H:mm` (`src/shared/date-and-time/dayjs.ts:47`) and the `utc` plugin is already loaded, so no + new format string is introduced. Absent when `timestamp` is `null` (pending transactions). +- **Action** — the chain from the parent spec, in order: + 1. `config.features.txInterpretation.isEnabled` false → no action. + 2. `summaryToPlainText(interpretation.data.summaries[0])` if it returns a string → that. + 3. else, if `method` **and** `from` **and** `to` are all present → + `` `${ addressToPlainText(from) } ${ status === 'error' ? 'failed to call' : 'called' } ${ method } on ${ addressToPlainText(to) }` `` + 4. else → no action. + + Branch 3's wording, and its use of `addressToPlainText` rather than a bare hash, come from + `TxSubHeading.tsx:105` — the UI feeds `from`/`to` through `AddressEntity`, so names and ENS domains show + when present. + +## Data & API + +Consumes the two responses described in the parent spec; issues no requests of its own (subtask 4 owns the +fetching). Types: `schemas['TransactionResponse']` from `@blockscout/api-types` and `TxInterpretationResponse` +from `src/features/tx-interpretation/common/types/api`. + +Guard against the `fetchApi` non-200 trap by checking the fields, not the object: an error body has no +`timestamp` and no `status`, so it produces `null` without any special-casing. + +## UI inventory + +No visual surface. New file `src/slices/tx/utils/get-og-description-params.ts` (kebab-case, matching +`get-revert-reason-text.ts` and its siblings) plus its `.spec.ts`. + +## Out of scope + +- The Noves branch — parent Q1, explicitly **not blocking**. Build the four-branch chain above; if the + answer is yes, Noves slots in ahead of branch 2 as an additive commit. +- Fetching, gating, and the `ApiData` type — subtask 4. + +## Task breakdown + +- [ ] 1 `[agent]` Write `get-og-description-params.ts` + - inputs: + - Return `null` unless all three strings resolve; assemble them independently first, then check. + - Read the feature flag as `config.features.txInterpretation.isEnabled` at call time (not module load), + so tests can vary it. + - Import `dayjs` from `src/shared/date-and-time/dayjs` (never the package directly — the locale + overrides live in that module). +- [ ] 2 `[agent]` Unit tests + - inputs: + - Cover: the happy path against the parent spec's production sample (expect + `Success · Swap 2.92M SPERPS for 0.016 WETH · Jul 27, 2026 22:39 UTC`); <!-- cspell:ignore SPERPS --> each status word; `undefined` + response → `null`; pending (`status: null`, `timestamp: null`) → `null`; feature off → `null`; no + summary + `method`/`from`/`to` → the `called` line; the same with `status: 'error'` → `failed to call`; + no summary and `method: null` → `null`; a 404-shaped body (`{ message: 'Not found' }`) → `null`. + - Reuse `src/slices/tx/mocks/details.ts` (`base`) for the transaction and + `src/features/tx-interpretation/blockscout/mocks.ts` for the summary. + - Timestamp assertions must be timezone-independent — the whole point is that output is UTC regardless + of where the test runs. + +## Open questions + +Parent [Q1](../../spec.md#q1--on-noves-provider-instances-must-the-og-description-match-the-noves-prose) +touches this subtask's action chain but does **not** block it. diff --git a/.agents/tasks/3593-tx-og-title-description/subtasks/04-gssp-wiring-and-templates/spec.md b/.agents/tasks/3593-tx-og-title-description/subtasks/04-gssp-wiring-and-templates/spec.md new file mode 100644 index 0000000000..ed74838ade --- /dev/null +++ b/.agents/tasks/3593-tx-og-title-description/subtasks/04-gssp-wiring-and-templates/spec.md @@ -0,0 +1,106 @@ +# Wire the bot-gated fetch and add the `/tx/[hash]` OG templates + +| | | +| --- | --- | +| Parent spec | [../../spec.md](../../spec.md) — step 4 of #3593 | +| Status | `ready` | +| Size | `medium` | +| Sub-branch | — (single commit on `issue-3593`) | +| PM | Ulyana (task author) | +| Designer | — | +| Backend | — | +| Depends on | subtasks 1 and 3 | + +## Context & goal + +The step that makes the feature visible: `/tx/[hash]` gets a `getServerSideProps` that fetches transaction +data for social-preview bots, and the route gets its two OG templates. Crawlers don't run JS and +`metadata.update()` only touches `<title>` and `<meta description>`, so this data has to arrive at SSR time +or not at all. + +## Functional requirements + +- `/tx/[hash]` gains OG templates: + + ```ts + og: { + title: { 'default': '%chain_name% transaction %hash_short%' }, + description: { + 'default': <the route's own metadata description>, + enhanced: '%tx_status% · %tx_action% · %tx_timestamp%', + }, + } + ``` + + The separator is a middle dot `·` (U+00B7) with a space either side. The `default` description is the + explicit fallback the parent spec calls for; the ` | Blockscout` postfix is added by `generate()`, not + written into the template. +- `ApiData<'/tx/[hash]'>` is `{ tx_status: string; tx_action: string; tx_timestamp: string }`. +- `getServerSideProps` populates `apiData` only when **all** of these hold: + `config.metadata.og.enhancedDataEnabled`, `detectBotRequest(ctx.req)?.type === 'social_preview'`, + `!config.features.multichain.isEnabled`, and `'props' in baseResponse`. +- The two requests run in parallel, **1 s** timeout each; `core:tx_interpretation` is not requested at all + when `config.features.txInterpretation.isEnabled` is false. +- The page passes `apiData` through `PageNextJs` so `PageMetadata` can reach it. +- Unchanged for everyone who isn't a social-preview bot: same SEO tags, no extra requests, no added latency. +- `og:title` carries the short hash for **all** requests including search engines — it needs no API data, so + it is a `default` template. + +## Data & API + +Per the parent spec. Nothing new to add to the resource registry. + +Note `baseResponse.props` is a promise in this pattern — the existing routes write +`(await baseResponse.props).apiData = …`. + +## UI inventory + +- `src/pages/tx/[hash].tsx` — currently re-exports `tx as getServerSideProps` from + `src/server/getServerSideProps/main`; becomes a local `getServerSideProps` that calls `gSSP.tx` first, + exactly as `src/pages/token/[hash]/index.tsx:30` does. +- `src/shell/metadata/templates/index.ts:82` — the `/tx/[hash]` entry. +- `src/shell/metadata/types.ts` — the `ApiData` conditional type. + +## Out of scope + +- Multichain, `/cc/tx/[hash]`, `/cross-chain-tx/[id]`, `og:image` — see the parent spec. +- Changing the route's `metadata.title` / `metadata.description`. + +## Task breakdown + +- [ ] 1 `[agent]` Add the `ApiData<'/tx/[hash]'>` branch in `src/shell/metadata/types.ts` + - inputs: + - Insert into the existing conditional chain, keeping its `/* eslint-disable @stylistic/indent */` style. +- [ ] 2 `[agent]` Add the OG templates to the `/tx/[hash]` entry + - inputs: + - Templates exactly as above. The `default` description duplicates the route's existing + `metadata.description.default` string — reference the same constant rather than retyping it if that + reads cleanly, otherwise duplicate it verbatim. + - Check `cspell.jsonc` doesn't trip on the middle dot; add nothing to the dictionary unless it does. +- [ ] 3 `[agent]` Add `getServerSideProps` to `src/pages/tx/[hash].tsx` + - inputs: + - Model it on `src/pages/token/[hash]/index.tsx:30` — `const baseResponse = await gSSP.tx<typeof pathname>(ctx)`, + then the guard, then `(await baseResponse.props).apiData = …`. + - Gate is `config.metadata.og.enhancedDataEnabled && detectBotRequest(ctx.req)?.type === 'social_preview'`. + Do **not** add a `config.metadata.seo.enhancedDataEnabled` arm — the SEO tags don't use API data here, + so fetching for search-engine bots would buy nothing. + - `Promise.all` over the two `fetchApi` calls; hash via `getQueryParamString(ctx.query.hash)`; + `timeout: SECOND` from `src/toolkit/utils/consts`. + - Pass the results to `getOgDescriptionParams` and assign its result (object or `null`) straight to + `apiData`. + - Introduce the `pathname` const and the `Props<typeof pathname>` generic the way the token page does, + and pass `apiData={ props.apiData }` to `PageNextJs`. +- [ ] 4 `[agent]` Verify locally + - inputs: + - `pnpm dev:preset robinhood`, then `curl -A Twitterbot 'http://localhost:3000/tx/<hash>'` and grep the + `og:` / `twitter:` / `description` meta tags. First load takes ~45 s while Turbopack compiles. + - Confirm: `og:title` has the short hash, `og:description` has the three-part line, `<title>` and + `<meta name="description">` are unchanged, and a request **without** a bot UA shows no + `og:description` beyond the fallback. + - Also check a pending transaction and a bad hash both yield the fallback description rather than a + malformed line. +- [ ] 5 `[agent]` Run `pnpm lint` and `pnpm test` (see `.agents/rules/code-quality.mdc` for the exact commands) + +## Open questions + +None. (Parent Q1 affects subtask 3's internals only.) diff --git a/.agents/tasks/3593-tx-og-title-description/subtasks/05-demo-deploy/spec.md b/.agents/tasks/3593-tx-og-title-description/subtasks/05-demo-deploy/spec.md new file mode 100644 index 0000000000..618b43353f --- /dev/null +++ b/.agents/tasks/3593-tx-og-title-description/subtasks/05-demo-deploy/spec.md @@ -0,0 +1,84 @@ +# Deploy a demo, then verify the preview manually + +| | | +| --- | --- | +| Parent spec | [../../spec.md](../../spec.md) — step 5 of #3593 | +| Status | `ready` | +| Size | `medium` | +| Sub-branch | — (no code; runs the `deploy-demo` skill) | +| PM | Ulyana (task author) | +| Designer | — | +| Backend | — | +| Depends on | subtask 4 | + +## Context & goal + +This feature's acceptance criterion is what the card looks like *inside a social app*, and Telegram and X +fetch the URL themselves — they can't reach localhost. A hand-forged `Twitterbot` UA against a dev server +proves the tags render; only a public URL proves the real bot-detection path, the real core-API latency, and +the real card. + +**This subtask is deliberately split between agent and human.** The agent deploys and can assert the tags +exist over `curl`, but the thing being accepted is that the preview *actually works* — a judgement made in a +third-party client, on a real card, by a person. The timeout ruling is likewise human: Grafana isn't +agent-reachable. So the agent finishes at "deployed, tags look right", and the human closes the subtask. + +## Functional requirements + +- A demo is deployed from the feature branch on the **`robinhood`** preset + (`tools/dev-server/registry.json` → `https://robinhoodchain.blockscout.com`). It's the instance from the + issue's own example, it has the interpretation feature on with real traffic, so summaries are non-empty. +- Pasting a transaction URL into Telegram shows `{status} · {action} · {timestamp}`, and the title carries + the short hash. +- The 1 s timeouts are confirmed sufficient against that instance's core API — this is the open + question the demo exists to answer, not a formality. If `api_request_duration_seconds` shows the + `core:tx` or `core:tx_interpretation` calls landing in the 1 s bucket or returning `504`, the timeout + needs raising and subtask 4 needs a follow-up commit. + +## Data & API + +Nothing new. The metrics to read are already wired and need no code: + +- `social_preview_bot_requests_total{route="/tx/[hash]", bot}` — incremented from `_document.tsx` via + `logRequestFromBot`, so it confirms the bot was actually detected as a social-preview crawler. +- `api_request_duration_seconds{route="core:tx"|"core:tx_interpretation", code}` — recorded inside + `fetchApi`, with `code=504` on abort. This is the timeout evidence. + +Both require `PROMETHEUS_METRICS_ENABLED=true` on the deployment (`src/server/monitoring/metrics.ts` +returns `undefined` otherwise) — check that before concluding the metrics are empty for any other reason. + +## UI inventory + +None. + +## Out of scope + +Any code change. Findings that need one become a follow-up commit against the subtask that owns the code. + +## Task breakdown + +- [ ] 1 `[agent]` Deploy the demo — skill: `deploy-demo` + - inputs: + - Preset: `robinhood`. + - Deploy from the feature branch `issue-3593`. +- [ ] 2 `[agent]` Verify the tags on the public URL + - inputs: + - `curl -A Twitterbot` and `curl -A TelegramBot` against a settled transaction on that chain; confirm + the title and the three-part description. + - Also hit it with no special UA and confirm the SEO tags are unchanged. +- [ ] 3 `[human]` Paste the link in Telegram and confirm the card really works + - inputs: + - The acceptance check: a real card in a real client, not a `curl` assertion. Also the check Ulyana and + QA will run themselves. + - Worth trying a transaction with a long action string to see where Telegram truncates, and a pending + one to see the fallback in the wild. +- [ ] 4 `[human]` Read the metrics and rule on the timeouts + - inputs: + - Human because Grafana isn't agent-reachable. + - Look at the `api_request_duration_seconds` distribution for `core:tx` / `core:tx_interpretation` and + any `code="504"`, and decide whether 1 s holds. If it doesn't, subtask 4 gets a follow-up commit + raising it. + +## Open questions + +None. From 7d391355afc4e413329e4639ef120e0cfc8f9aee Mon Sep 17 00:00:00 2001 From: tom <tom@ohhhh.me> Date: Tue, 28 Jul 2026 18:39:04 +0200 Subject: [PATCH 02/13] Record the draft PR link in the spec header Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .agents/tasks/3593-tx-og-title-description/spec.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.agents/tasks/3593-tx-og-title-description/spec.md b/.agents/tasks/3593-tx-og-title-description/spec.md index c3a5b104e9..204ed07103 100644 --- a/.agents/tasks/3593-tx-og-title-description/spec.md +++ b/.agents/tasks/3593-tx-og-title-description/spec.md @@ -3,6 +3,7 @@ | | | | --- | --- | | Issue | https://github.com/blockscout/frontend/issues/3593 | +| PR | https://github.com/blockscout/frontend/pull/3596 (draft) | | Status | `ready` | | Size | `medium` | | Feature branch | `issue-3593` | From b2a042ea8020524397b53147e4eeceb032f56219 Mon Sep 17 00:00:00 2001 From: tom <tom@ohhhh.me> Date: Tue, 28 Jul 2026 19:29:26 +0200 Subject: [PATCH 03/13] Make the OG block a default/enhanced template layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `og` entry in the route template map was static data: `og.description` was passed through to the meta tag without ever reaching `compileValue`, so it could hold no placeholders, and the record required a description and an image together. That ruled out a per-route dynamic OG description. OG title and OG description now each accept the same `default`/`enhanced` shape as the metadata templates and are compiled against the same params; the image is independent of both. A route that declares no OG template falls back to the page title and description — written out explicitly so the resolution rule is uniform, which is why routes without an OG description now emit an `og:description` carrying the text crawlers previously inferred from `<meta name="description">`. The new `hash_short` param shortens a route's hash the way `truncation="constant"` does on the page. No route declares OG templates yet — `/tx/[hash]`'s land in the next steps. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../3593-tx-og-title-description/spec.md | 11 ++- .../subtasks/01-og-template-layer/spec.md | 24 ++++-- .../__snapshots__/generate.spec.ts.snap | 10 +-- src/shell/metadata/compile-value.ts | 4 +- src/shell/metadata/generate.spec.ts | 78 ++++++++++++++++++- src/shell/metadata/generate.ts | 15 +++- src/shell/metadata/templates/index.ts | 19 ++--- src/shell/metadata/types.ts | 6 ++ 8 files changed, 134 insertions(+), 33 deletions(-) diff --git a/.agents/tasks/3593-tx-og-title-description/spec.md b/.agents/tasks/3593-tx-og-title-description/spec.md index 204ed07103..5f2ceba66d 100644 --- a/.agents/tasks/3593-tx-og-title-description/spec.md +++ b/.agents/tasks/3593-tx-og-title-description/spec.md @@ -4,7 +4,7 @@ | --- | --- | | Issue | https://github.com/blockscout/frontend/issues/3593 | | PR | https://github.com/blockscout/frontend/pull/3596 (draft) | -| Status | `ready` | +| Status | `in progress` | | Size | `medium` | | Feature branch | `issue-3593` | | PM | Ulyana (task author) | @@ -61,13 +61,16 @@ crawlers don't run JS and `metadata.update()` only ever touches `<title>` and `< every placeholder in it is truthy. Accepted loss cases — a **pending** transaction (its `timestamp` is `null`), an **unresolvable action**, and any **failed, timed-out, or 404** request. - No behavior change for any other route: after the template-layer refactor, every existing route's - `title` / `description` / `opengraph` output is byte-identical. + `title` / `description` / `og:title` / `og:image` output is byte-identical. One deliberate exception — + routes with no OG description template now emit `og:description` explicitly with the same text crawlers + already inferred from `<meta name="description">`; see subtask 1's spec. ### Verification - `curl -A Twitterbot http://localhost:3000/tx/<hash>` shows the new `og:title` / `og:description`; the same URL without a bot UA shows the unchanged SEO tags. -- `src/shell/metadata/__snapshots__/generate.spec.ts.snap` — existing entries unchanged. +- `src/shell/metadata/__snapshots__/generate.spec.ts.snap` — existing entries unchanged, except the + `opengraph.description` fallback introduced in subtask 1. - Metrics need no work and are checked, not built: `social_preview_bot_requests_total{route="/tx/[hash]"}` is already incremented globally from `_document.tsx` via `logRequestFromBot` using `ctx.pathname`, and `api_request_duration_seconds{route,code}` is recorded inside `fetchApi` itself (labelled by resource @@ -146,7 +149,7 @@ verifies that the preview genuinely works in a real social client. ## Task breakdown -- [ ] 1 `[agent]` Turn the `og` block into a `default`/`enhanced` template layer → `subtasks/01-og-template-layer/` +- [x] 1 `[agent]` Turn the `og` block into a `default`/`enhanced` template layer → `subtasks/01-og-template-layer/` - [ ] 2 `[agent]` Share the currency rounding and render interpretation summaries as plain text → `subtasks/02-interpretation-plain-text/` - [ ] 3 `[agent]` Derive the three OG description params for a transaction → `subtasks/03-tx-og-description-params/` - [ ] 4 `[agent]` Wire the bot-gated fetch and add the `/tx/[hash]` OG templates → `subtasks/04-gssp-wiring-and-templates/` diff --git a/.agents/tasks/3593-tx-og-title-description/subtasks/01-og-template-layer/spec.md b/.agents/tasks/3593-tx-og-title-description/subtasks/01-og-template-layer/spec.md index 27cc4563d2..48c143feb4 100644 --- a/.agents/tasks/3593-tx-og-title-description/subtasks/01-og-template-layer/spec.md +++ b/.agents/tasks/3593-tx-og-title-description/subtasks/01-og-template-layer/spec.md @@ -3,7 +3,7 @@ | | | | --- | --- | | Parent spec | [../../spec.md](../../spec.md) — step 1 of #3593 | -| Status | `ready` | +| Status | `done` | | Size | `medium` | | Sub-branch | — (single commit on `issue-3593`) | | PM | Ulyana (task author) | @@ -36,7 +36,12 @@ changes. `/tx/[hash]`'s own templates land in subtask 4. resolution rule uniform with the title's. - New param `hash_short` — `shortenString(hash, 8)`, i.e. `0xda...671a`. Set to `undefined` (not `''`) when the route has no `hash` query param, so `compileValue`'s truthiness check behaves. -- Every existing route's `title`, `description`, and `opengraph` output is byte-identical afterwards. +- Every existing route's `title`, `description`, and `og:image` output is byte-identical afterwards. The + explicit description fallback is the one intended exception: routes that declare no `og.description` now + emit an `og:description` tag holding the same text crawlers previously inferred from + `<meta name="description">` — the preview a social client renders is unchanged, but five entries in + `generate.spec.ts.snap` move from `undefined` to that text. `OG_ROOT_PAGE` routes are untouched (their + compiled description stays `''`, so the tag is still omitted). ## Data & API @@ -58,34 +63,37 @@ No visual surface. Files: ## Task breakdown -- [ ] 1 `[agent]` Extend `RouteTemplateRecord` in `src/shell/metadata/templates/index.ts` +- [x] 1 `[agent]` Extend `RouteTemplateRecord` in `src/shell/metadata/templates/index.ts` + — `TemplateValue` lives in `src/shell/metadata/types.ts` and is reused by `compile-value.ts`. - inputs: - Extract the repeated `{ 'default': string; enhanced?: string }` shape into a named interface and reuse it for `metadata.title`, `metadata.description`, `og.title`, `og.description`. - New shape: `og?: { title?: <that shape>; description?: <that shape>; image?: string }`. - Migrate `OG_ROOT_PAGE` to `{ description: { 'default': config.metadata.og.description }, image: config.metadata.og.imageUrl }`. It is referenced by many routes; the value must stay identical. -- [ ] 2 `[agent]` Resolve OG title and description independently in `src/shell/metadata/generate.ts` +- [x] 2 `[agent]` Resolve OG title and description independently in `src/shell/metadata/generate.ts` - inputs: - `const ogTemplates = TEMPLATE_MAP[route.pathname].og;` - `opengraph.title = ogTemplates?.title ? compileValue(ogTemplates.title, params) + titlePostfix : title` - `opengraph.description = ogTemplates?.description ? compileValue(ogTemplates.description, params) : description` - `opengraph.imageUrl = ogTemplates?.image` - Do **not** thread bot type into `generate()`; `apiData` presence is the only enhancement signal. -- [ ] 3 `[agent]` Add the `hash_short` param in `generate.ts` +- [x] 3 `[agent]` Add the `hash_short` param in `generate.ts` - inputs: - Derive from `castToString(route.query?.hash)` the same way `idParam` / `idFormatted` are derived above it. - `const hashParam = castToString(route.query?.hash); … hash_short: hashParam ? shortenString(hashParam, 8) : undefined` - Import `shortenString` from `src/shared/texts/shorten-string`. `charNumber: 8` is what `truncation="constant"` resolves to in the entity components, so titles and interpretation text shorten identically. -- [ ] 4 `[agent]` Cover the new layer in `src/shell/metadata/generate.spec.ts` +- [x] 4 `[agent]` Cover the new layer in `src/shell/metadata/generate.spec.ts` + — new `og template layer` describe, driven by a stand-in `TEMPLATE_MAP` (no route declares OG templates yet). - inputs: - Test what's actually new, per `.agents/rules/tests-unit.mdc`: a route with `og` templates only (title falls back to metadata), a route with both, `enhanced` chosen when all its params are present and `default` when one is missing, and `hash_short` compilation. - - The existing snapshot entries must come out unchanged — if `pnpm test` rewrites any of them, the - refactor changed behavior and is wrong. New `it` blocks appending new entries is expected. + - The existing snapshot entries must come out unchanged apart from the `opengraph.description` fallback + noted in the functional requirements — any rewrite of a `title` or `imageUrl` means the refactor + changed behavior and is wrong. - `OG_ROOT_PAGE` routes keep emitting the same `og:description` and `og:image` as before. ## Open questions diff --git a/src/shell/metadata/__snapshots__/generate.spec.ts.snap b/src/shell/metadata/__snapshots__/generate.spec.ts.snap index 9a7996bd42..0800a40510 100644 --- a/src/shell/metadata/__snapshots__/generate.spec.ts.snap +++ b/src/shell/metadata/__snapshots__/generate.spec.ts.snap @@ -6,7 +6,7 @@ exports[`address route > enhanced data 1`] = ` "description": "View the account balance, transactions, and other data for duck.eth on the Blockscout (Blockscout) Explorer", "jsonLd": undefined, "opengraph": { - "description": undefined, + "description": "View the account balance, transactions, and other data for duck.eth on the Blockscout (Blockscout) Explorer", "imageUrl": undefined, "title": "Blockscout address details for duck.eth | Blockscout", }, @@ -20,7 +20,7 @@ exports[`address route > no enhanced data 1`] = ` "description": "View the account balance, transactions, and more for 0xd789a607CEac2f0E14867de4EB15b15C9FFB5859 on Blockscout.", "jsonLd": undefined, "opengraph": { - "description": undefined, + "description": "View the account balance, transactions, and more for 0xd789a607CEac2f0E14867de4EB15b15C9FFB5859 on Blockscout.", "imageUrl": undefined, "title": "Blockscout address details for 0xd789a607CEac2f0E14867de4EB15b15C9FFB5859 | Blockscout", }, @@ -34,7 +34,7 @@ exports[`dynamic route 1`] = ` "description": "Blockscout detailed transaction info. View transaction status, block confirmation, gas fee, native coin and token transfers.", "jsonLd": undefined, "opengraph": { - "description": undefined, + "description": "Blockscout detailed transaction info. View transaction status, block confirmation, gas fee, native coin and token transfers.", "imageUrl": undefined, "title": "Blockscout transaction 0x62d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193 | Blockscout", }, @@ -62,7 +62,7 @@ exports[`stats details route > enhanced data 1`] = ` "description": "Cumulative account growth over time", "jsonLd": undefined, "opengraph": { - "description": undefined, + "description": "Cumulative account growth over time", "imageUrl": undefined, "title": "Number of accounts chart on Blockscout | Blockscout", }, @@ -76,7 +76,7 @@ exports[`stats details route > no enhanced data 1`] = ` "description": "Explore the accountsGrowth chart on Blockscout.", "jsonLd": undefined, "opengraph": { - "description": undefined, + "description": "Explore the accountsGrowth chart on Blockscout.", "imageUrl": undefined, "title": "Blockscout stats - Accounts growth chart | Blockscout", }, diff --git a/src/shell/metadata/compile-value.ts b/src/shell/metadata/compile-value.ts index 9d0218fb3d..0d6613cb96 100644 --- a/src/shell/metadata/compile-value.ts +++ b/src/shell/metadata/compile-value.ts @@ -1,6 +1,8 @@ // SPDX-License-Identifier: LicenseRef-Blockscout -export default function compileValue(template: { 'default': string; enhanced?: string }, params: Record<string, string | Array<string> | undefined>) { +import type { TemplateValue } from './types'; + +export default function compileValue(template: TemplateValue, params: Record<string, string | Array<string> | undefined>) { const PLACEHOLDER_REGEX = /%(\w+)%/g; const enhancedPlaceholders = (() => { diff --git a/src/shell/metadata/generate.spec.ts b/src/shell/metadata/generate.spec.ts index d563fddd98..b5c0c0d230 100644 --- a/src/shell/metadata/generate.spec.ts +++ b/src/shell/metadata/generate.spec.ts @@ -1,7 +1,7 @@ import { hash as addressHash } from 'src/slices/address/mocks/address-param'; import { base as transaction } from 'src/slices/tx/mocks/details'; -import { it, describe, expect } from 'vitest'; +import { it, describe, expect, vi, afterEach } from 'vitest'; import generate from './generate'; @@ -41,3 +41,79 @@ describe('stats details route', () => { expect(result).toMatchSnapshot(); }); }); + +describe('og template layer', () => { + // No route declares OG templates yet, so the layer is exercised against a stand-in template map. + const TEMPLATE_MAP_MOCK = { + '/txs': { + metadata: { + title: { 'default': '%chain_name% transactions' }, + description: { 'default': 'Browse %chain_name% transactions.' }, + }, + og: { image: 'https://example.com/og_image.png' }, + }, + '/tx/[hash]': { + metadata: { + title: { 'default': '%chain_name% transaction %hash%' }, + description: { 'default': 'Detailed transaction info.' }, + }, + og: { + title: { 'default': '%chain_name% transaction %hash_short%' }, + description: { 'default': 'Success · Swap · Jul 28, 2026 10:00 UTC' }, + }, + }, + '/address/[hash]': { + metadata: { + title: { 'default': '%chain_name% address %hash%' }, + description: { 'default': 'Address details.' }, + }, + og: { + description: { + 'default': 'Address details on %chain_name%.', + enhanced: '%domain_name% on %chain_name%.', + }, + }, + }, + }; + + async function importGenerateWithMockedTemplates() { + vi.resetModules(); + vi.doMock('./templates', () => ({ TEMPLATE_MAP: TEMPLATE_MAP_MOCK })); + return (await import('./generate')).default; + } + + afterEach(() => { + vi.doUnmock('./templates'); + vi.resetModules(); + }); + + it('falls back to the page title and description when the route declares no og templates', async() => { + const generateMocked = await importGenerateWithMockedTemplates(); + const result = generateMocked({ pathname: '/txs' }); + + expect(result.opengraph.title).toBe(result.title); + expect(result.opengraph.description).toBe(result.description); + expect(result.opengraph.imageUrl).toBe('https://example.com/og_image.png'); + }); + + it('compiles the og title with the title postfix and the og description independently', async() => { + const generateMocked = await importGenerateWithMockedTemplates(); + const result = generateMocked({ pathname: '/tx/[hash]', query: { hash: transaction.hash } }); + + expect(result.opengraph.title).toBe('Blockscout transaction 0x62...3193 | Blockscout'); + expect(result.opengraph.description).toBe('Success · Swap · Jul 28, 2026 10:00 UTC'); + expect(result.opengraph.imageUrl).toBeUndefined(); + expect(result.title).toBe(`Blockscout transaction ${ transaction.hash } | Blockscout`); + expect(result.description).toBe('Detailed transaction info.'); + }); + + it('picks the enhanced og description only when all its params are present', async() => { + const generateMocked = await importGenerateWithMockedTemplates(); + + const withData = generateMocked({ pathname: '/address/[hash]', query: { hash: addressHash } }, { domain_name: 'duck.eth' }); + expect(withData.opengraph.description).toBe('duck.eth on Blockscout.'); + + const withoutData = generateMocked({ pathname: '/address/[hash]', query: { hash: addressHash } }); + expect(withoutData.opengraph.description).toBe('Address details on Blockscout.'); + }); +}); diff --git a/src/shell/metadata/generate.ts b/src/shell/metadata/generate.ts index c3930caa78..16d773e098 100644 --- a/src/shell/metadata/generate.ts +++ b/src/shell/metadata/generate.ts @@ -9,6 +9,7 @@ import type { RouteParams } from 'src/server/types'; import { currencyUnits } from 'src/slices/chain/units'; import config from 'src/config'; +import shortenString from 'src/shared/texts/shorten-string'; import { castToString } from 'src/toolkit/utils/guards'; @@ -18,9 +19,14 @@ import getChainExplorerTitle from './get-chain-explorer-title'; import { generateStructuredData } from './structured-data'; import { TEMPLATE_MAP } from './templates'; +// What `truncation="constant"` resolves to in the entity components, so a shortened hash in a title reads +// the same as the one on the page. +const HASH_SHORT_CHAR_NUMBER = 8; + export default function generate<Pathname extends Route['pathname']>(route: RouteParams<Pathname>, apiData: ApiData<Pathname> = null): Metadata { const idParam = castToString(route.query?.id); const idFormatted = idParam ? upperFirst(kebabCase(idParam).replaceAll('-', ' ')) : undefined; + const hashParam = castToString(route.query?.hash); const params = { ...route.query, @@ -29,6 +35,7 @@ export default function generate<Pathname extends Route['pathname']>(route: Rout chain_explorer_title: getChainExplorerTitle(), gwei_name: currencyUnits.gwei, id_formatted: idFormatted, + hash_short: hashParam ? shortenString(hashParam, HASH_SHORT_CHAR_NUMBER) : undefined, }; const titlePostfix = config.metadata.promoteBlockscoutInTitle ? ' | Blockscout' : ''; @@ -36,15 +43,17 @@ export default function generate<Pathname extends Route['pathname']>(route: Rout const title = compileValue(TEMPLATE_MAP[route.pathname].metadata.title, params) + titlePostfix; const description = compileValue(TEMPLATE_MAP[route.pathname].metadata.description, params); + const ogTemplates = TEMPLATE_MAP[route.pathname].og; + const jsonLd = generateStructuredData({ route, apiData }); return { title: title, description, opengraph: { - title: title, - description: TEMPLATE_MAP[route.pathname].og?.description, - imageUrl: TEMPLATE_MAP[route.pathname].og?.image, + title: ogTemplates?.title ? compileValue(ogTemplates.title, params) + titlePostfix : title, + description: ogTemplates?.description ? compileValue(ogTemplates.description, params) : description, + imageUrl: ogTemplates?.image, }, canonical: getCanonicalUrl(route.pathname), jsonLd, diff --git a/src/shell/metadata/templates/index.ts b/src/shell/metadata/templates/index.ts index 6456610f8a..971cc44d8f 100644 --- a/src/shell/metadata/templates/index.ts +++ b/src/shell/metadata/templates/index.ts @@ -4,6 +4,8 @@ import type { Route } from 'nextjs-routes'; +import type { TemplateValue } from '../types'; + import { layerLabels } from 'src/features/rollup/common/utils/layer'; import config from 'src/config'; @@ -13,23 +15,18 @@ const dappEntityName = (getFeaturePayload(config.features.marketplace)?.titles.e interface RouteTemplateRecord { metadata: { - title: { - 'default': string; - enhanced?: string; - }; - description: { - 'default': string; - enhanced?: string; - }; + title: TemplateValue; + description: TemplateValue; }; og?: { - description: string; - image: string; + title?: TemplateValue; + description?: TemplateValue; + image?: string; }; } const OG_ROOT_PAGE = { - description: config.metadata.og.description, + description: { 'default': config.metadata.og.description }, image: config.metadata.og.imageUrl, }; diff --git a/src/shell/metadata/types.ts b/src/shell/metadata/types.ts index ad21099a7d..847f60cce7 100644 --- a/src/shell/metadata/types.ts +++ b/src/shell/metadata/types.ts @@ -22,6 +22,12 @@ export type ApiData<Pathname extends Route['pathname']> = export type StructuredData = WithContext<Product> | WithContext<WebApplication>; +// The `enhanced` variant is used only when every placeholder in it resolves to a truthy param. +export interface TemplateValue { + 'default': string; + enhanced?: string; +} + export interface Metadata { title: string; description: string; From 7ccec1cb7a0e38cac9fd42cf1b5e0e310590dcad Mon Sep 17 00:00:00 2001 From: tom <tom@ohhhh.me> Date: Tue, 28 Jul 2026 19:45:02 +0200 Subject: [PATCH 04/13] Render transaction interpretation summaries as plain text The OG description needs the transaction action to read exactly as the subheading on the page does, but the summary is a template of typed variables that only `TxInterpretation` knows how to turn into React elements. A plain-text renderer walks the same parsing pipeline and maps each variable type to the text its UI counterpart displays. Two pieces of that mapping were inline in components and are now shared, so the preview cannot drift from the page: the currency rounding ladder, which the interpretation component calls instead of holding its own copy, and the address name chain, extracted into the address slice and called by `AddressEntity` itself. Neither component's output changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../3593-tx-og-title-description/spec.md | 2 +- .../02-interpretation-plain-text/spec.md | 34 ++++--- .../common/components/TxInterpretation.tsx | 14 +-- .../common/utils/address-to-plain-text.ts | 16 ++++ .../utils/format-currency-value.spec.ts | 21 +++++ .../common/utils/format-currency-value.ts | 32 +++++++ .../utils/summary-to-plain-text.spec.ts | 57 ++++++++++++ .../common/utils/summary-to-plain-text.ts | 91 +++++++++++++++++++ .../components/entity/AddressEntity.tsx | 13 +-- .../address/utils/get-address-name.spec.ts | 21 +++++ src/slices/address/utils/get-address-name.ts | 23 +++++ 11 files changed, 287 insertions(+), 37 deletions(-) create mode 100644 src/features/tx-interpretation/common/utils/address-to-plain-text.ts create mode 100644 src/features/tx-interpretation/common/utils/format-currency-value.spec.ts create mode 100644 src/features/tx-interpretation/common/utils/format-currency-value.ts create mode 100644 src/features/tx-interpretation/common/utils/summary-to-plain-text.spec.ts create mode 100644 src/features/tx-interpretation/common/utils/summary-to-plain-text.ts create mode 100644 src/slices/address/utils/get-address-name.spec.ts create mode 100644 src/slices/address/utils/get-address-name.ts diff --git a/.agents/tasks/3593-tx-og-title-description/spec.md b/.agents/tasks/3593-tx-og-title-description/spec.md index 5f2ceba66d..da66f37c86 100644 --- a/.agents/tasks/3593-tx-og-title-description/spec.md +++ b/.agents/tasks/3593-tx-og-title-description/spec.md @@ -150,7 +150,7 @@ verifies that the preview genuinely works in a real social client. ## Task breakdown - [x] 1 `[agent]` Turn the `og` block into a `default`/`enhanced` template layer → `subtasks/01-og-template-layer/` -- [ ] 2 `[agent]` Share the currency rounding and render interpretation summaries as plain text → `subtasks/02-interpretation-plain-text/` +- [x] 2 `[agent]` Share the currency rounding and render interpretation summaries as plain text → `subtasks/02-interpretation-plain-text/` - [ ] 3 `[agent]` Derive the three OG description params for a transaction → `subtasks/03-tx-og-description-params/` - [ ] 4 `[agent]` Wire the bot-gated fetch and add the `/tx/[hash]` OG templates → `subtasks/04-gssp-wiring-and-templates/` - [ ] 5 `[agent]` + `[human]` Deploy a demo, then verify the preview manually → `subtasks/05-demo-deploy/` diff --git a/.agents/tasks/3593-tx-og-title-description/subtasks/02-interpretation-plain-text/spec.md b/.agents/tasks/3593-tx-og-title-description/subtasks/02-interpretation-plain-text/spec.md index 620b497961..0021dfddaf 100644 --- a/.agents/tasks/3593-tx-og-title-description/subtasks/02-interpretation-plain-text/spec.md +++ b/.agents/tasks/3593-tx-og-title-description/subtasks/02-interpretation-plain-text/spec.md @@ -3,7 +3,7 @@ | | | | --- | --- | | Parent spec | [../../spec.md](../../spec.md) — step 2 of #3593 | -| Status | `ready` | +| Status | `done` | | Size | `medium` | | Sub-branch | — (single commit on `issue-3593`) | | PM | Ulyana (task author) | @@ -50,9 +50,12 @@ function that the component then calls, and adds a plain-text renderer beside it | `timestamp` | `dayjs(Number(value) * SECOND).format('MMM DD YYYY')` — the UI's variable format, **not** the OG description's own timestamp format | | `native` / `wei` | `currencyUnits.ether` / `currencyUnits.wei` | -- `addressToPlainText(address)` mirrors `AddressEntity`'s `Content` with `truncation="constant"`: - metadata `name`-type tag (via `getTagName`) ?? `ens_domain_name` ?? `name` ?? `shortenString(hash, 8)`. - It is exported because subtask 3's fallback action branch needs it for `from` / `to`. +- `getAddressName(address)` owns the name chain `AddressEntity`'s `Content` resolves — metadata `name`-type + tag (via `getTagName`) ?? `ens_domain_name` ?? `name`, `undefined` when the address has no name. It lives + in the address slice and the entity component calls it, so the two cannot drift. +- `addressToPlainText(address)` composes that with the hash fallback (`shortenString(hash, 8)`, what + `truncation="constant"` resolves to). It is exported because subtask 3's fallback action branch needs it + twice in one template string. - Whitespace comes out clean: single spaces between parts, no leading or trailing space. Against the production sample in the parent spec the result is exactly `Swap 2.92M SPERPS for 0.016 WETH`. @@ -69,6 +72,8 @@ covers all ten variable types. delegates. No other change; do not restructure the component to be render-agnostic. - New files in `src/features/tx-interpretation/common/utils/` (kebab-case, matching siblings elsewhere in the repo): the currency formatter, the plain-text renderer, the address-to-text helper. +- `src/slices/address/utils/get-address-name.ts` — the address name chain, extracted from `AddressEntity` + so both the entity and the plain-text renderer read from one place. ## Out of scope @@ -79,20 +84,22 @@ covers all ten variable types. ## Task breakdown -- [ ] 1 `[agent]` Extract the currency ladder into a shared function and call it from `TxInterpretation` +- [x] 1 `[agent]` Extract the currency ladder into a shared function and call it from `TxInterpretation` + — `common/utils/format-currency-value.ts`. - inputs: - Signature `(value: string) => string`. Keep `BigNumber` as the implementation — same import, same thresholds, same order of comparisons. - The component's `currency` case becomes `<chakra.span>{ formatCurrencyValue(value) + ' ' }</chakra.span>` — the trailing space is the component's spacing concern and stays there, out of the shared function. -- [ ] 2 `[agent]` Add `addressToPlainText` +- [x] 2 `[agent]` Add `addressToPlainText` + — `common/utils/address-to-plain-text.ts` over the extracted `slices/address/utils/get-address-name.ts`. - inputs: - - Reproduce `AddressEntity`'s `Content` chain (`src/slices/address/components/entity/AddressEntity.tsx:142`): - the `metadata.tags` entry with `tagType === 'name'` through `getTagName`, then `ens_domain_name`, - then `name`, then `shortenString(hash, 8)`. + - Extract `AddressEntity`'s `Content` name chain into the address slice and call it from both the + component and the new helper, which appends the `shortenString(hash, 8)` fallback. - Ignore the proxy-implementation branch (`AddressEntityContentProxy`) and the bech32/Filecoin alt-hash handling — both are display concerns driven by client-side user settings, unavailable server-side. -- [ ] 3 `[agent]` Add `summaryToPlainText` +- [x] 3 `[agent]` Add `summaryToPlainText` + — `common/utils/summary-to-plain-text.ts`. - inputs: - Return `undefined` when `!checkSummary(template, variables)`. - Reuse the existing `fillStringVariables` → `extractVariables` → `getStringChunks` pipeline from @@ -101,13 +108,14 @@ covers all ten variable types. space, then collapse runs of whitespace and trim. Don't try to replicate the component's per-element trailing spaces. - Handle `native` / `wei` by name before the type switch, exactly as the component does. -- [ ] 4 `[agent]` Unit tests +- [x] 4 `[agent]` Unit tests + — a spec per new util; the timestamp assertion pins `TZ` to UTC via `vi.stubEnv`. - inputs: - Reuse `txInterpretation` from `src/features/tx-interpretation/blockscout/mocks.ts` (it exercises `string`, `currency`, `token`, `address`, `timestamp` in one template) and `TX_INTERPRETATION` from `blockscout/stubs.ts`. - - Cover the four rounding branches at their boundaries, the `address` fallback chain (name tag / ENS / - name / short hash), `checkSummary` rejection returning `undefined`, and the whitespace result. + - Cover the four rounding branches at their boundaries, the name chain (name tag / ENS / name / none) + beside `getAddressName`, `checkSummary` rejection returning `undefined`, and the whitespace result. - Skip tests that only assert the mock or `BigNumber` itself. ## Open questions diff --git a/src/features/tx-interpretation/common/components/TxInterpretation.tsx b/src/features/tx-interpretation/common/components/TxInterpretation.tsx index 0c57528e1a..1104f344ba 100644 --- a/src/features/tx-interpretation/common/components/TxInterpretation.tsx +++ b/src/features/tx-interpretation/common/components/TxInterpretation.tsx @@ -2,7 +2,6 @@ import type { BoxProps } from '@chakra-ui/react'; import { Box, chakra } from '@chakra-ui/react'; -import BigNumber from 'bignumber.js'; import { route } from 'nextjs-routes'; import React from 'react'; @@ -35,6 +34,7 @@ import { Skeleton } from 'src/toolkit/chakra/skeleton'; import { Tooltip } from 'src/toolkit/chakra/tooltip'; import { SECOND } from 'src/toolkit/utils/consts'; +import formatCurrencyValue from '../utils/format-currency-value'; import { extractVariables, getStringChunks, @@ -125,17 +125,7 @@ const TxInterpretationElementByType = ( return <chakra.span color="text.secondary" whiteSpace="pre">{ value + ' ' }</chakra.span>; } case 'currency': { - let numberString = ''; - if (BigNumber(value).isLessThan(0.1)) { - numberString = BigNumber(value).toPrecision(2); - } else if (BigNumber(value).isLessThan(10000)) { - numberString = BigNumber(value).dp(2).toFormat(); - } else if (BigNumber(value).isLessThan(1000000)) { - numberString = BigNumber(value).dividedBy(1000).toFormat(2) + 'K'; - } else { - numberString = BigNumber(value).dividedBy(1000000).toFormat(2) + 'M'; - } - return <chakra.span>{ numberString + ' ' }</chakra.span>; + return <chakra.span>{ formatCurrencyValue(value) + ' ' }</chakra.span>; } case 'timestamp': { return <chakra.span color="text.secondary" whiteSpace="pre">{ dayjs(Number(value) * SECOND).format('MMM DD YYYY') }</chakra.span>; diff --git a/src/features/tx-interpretation/common/utils/address-to-plain-text.ts b/src/features/tx-interpretation/common/utils/address-to-plain-text.ts new file mode 100644 index 0000000000..3162408d7b --- /dev/null +++ b/src/features/tx-interpretation/common/utils/address-to-plain-text.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import type { schemas } from '@blockscout/api-types'; + +import getAddressName from 'src/slices/address/utils/get-address-name'; + +import shortenString from 'src/shared/texts/shorten-string'; + +// What `truncation="constant"` resolves to in `AddressEntity`. +const HASH_CHAR_NUMBER = 8; + +// How an address reads on the page, minus the display concerns that need a client: the proxy-implementation +// tooltip and the bech32/Filecoin alt-hash, both driven by user settings unavailable server-side. +export default function addressToPlainText(address: schemas['Address']) { + return getAddressName(address) ?? shortenString(address.hash, HASH_CHAR_NUMBER); +} diff --git a/src/features/tx-interpretation/common/utils/format-currency-value.spec.ts b/src/features/tx-interpretation/common/utils/format-currency-value.spec.ts new file mode 100644 index 0000000000..bfa1f69bd8 --- /dev/null +++ b/src/features/tx-interpretation/common/utils/format-currency-value.spec.ts @@ -0,0 +1,21 @@ +import { it, expect, describe } from 'vitest'; + +import formatCurrencyValue from './format-currency-value'; + +describe('picks the notation by magnitude', () => { + it.each([ + // significant digits below 0.1, where two decimal places would collapse to 0.00 + [ '0.015575428823202624', '0.016' ], + [ '0.09999', '0.10' ], + // two decimal places up to 10K, so a value rounding up to the threshold still gets them + [ '0.1', '0.1' ], + [ '9999.999', '10,000' ], + // thousands and millions + [ '10000', '10.00K' ], + [ '999999', '1,000.00K' ], + [ '1000000', '1.00M' ], + [ '2918443.532640630294962772', '2.92M' ], + ])('%s → %s', (value, expected) => { + expect(formatCurrencyValue(value)).toBe(expected); + }); +}); diff --git a/src/features/tx-interpretation/common/utils/format-currency-value.ts b/src/features/tx-interpretation/common/utils/format-currency-value.ts new file mode 100644 index 0000000000..1851f8a238 --- /dev/null +++ b/src/features/tx-interpretation/common/utils/format-currency-value.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import BigNumber from 'bignumber.js'; + +const THOUSAND = 1_000; +const MILLION = 1_000 * THOUSAND; + +// Below this a two-decimal format would collapse to `0.00`, so significant digits are used instead. +const SMALL_VALUE_THRESHOLD = 0.1; +const SIGNIFICANT_DIGITS = 2; +const DECIMAL_PLACES = 2; +const THOUSANDS_THRESHOLD = 10 * THOUSAND; + +// Shared by the interpretation component and its plain-text renderer so an amount in a social preview +// reads exactly as it does on the page. +export default function formatCurrencyValue(value: string) { + const amount = BigNumber(value); + + if (amount.isLessThan(SMALL_VALUE_THRESHOLD)) { + return amount.toPrecision(SIGNIFICANT_DIGITS); + } + + if (amount.isLessThan(THOUSANDS_THRESHOLD)) { + return amount.dp(DECIMAL_PLACES).toFormat(); + } + + if (amount.isLessThan(MILLION)) { + return amount.dividedBy(THOUSAND).toFormat(DECIMAL_PLACES) + 'K'; + } + + return amount.dividedBy(MILLION).toFormat(DECIMAL_PLACES) + 'M'; +} diff --git a/src/features/tx-interpretation/common/utils/summary-to-plain-text.spec.ts b/src/features/tx-interpretation/common/utils/summary-to-plain-text.spec.ts new file mode 100644 index 0000000000..925901c593 --- /dev/null +++ b/src/features/tx-interpretation/common/utils/summary-to-plain-text.spec.ts @@ -0,0 +1,57 @@ +import type { TxInterpretationSummary } from 'src/features/tx-interpretation/common/types/api'; + +import { currencyUnits } from 'src/slices/chain/units'; + +import { txInterpretation } from 'src/features/tx-interpretation/blockscout/mocks'; +import { TX_INTERPRETATION } from 'src/features/tx-interpretation/blockscout/stubs'; + +import { it, expect, beforeAll, afterAll, vi } from 'vitest'; + +import summaryToPlainText from './summary-to-plain-text'; + +// A timestamp variable is rendered in local time, as on the page — pin the zone so the assertion holds +// wherever the suite runs. +beforeAll(() => { + vi.stubEnv('TZ', 'UTC'); +}); + +afterAll(() => { + vi.unstubAllEnvs(); +}); + +it('renders every variable type the way the page does', () => { + expect(summaryToPlainText(txInterpretation.data.summaries[0])).toBe('Transfer 100 DUCK to 0xd7...5859 on Jun 17 2023'); +}); + +it('renders the native coin symbol variable', () => { + const summary: TxInterpretationSummary = { + summary_template: '{action_type} {amount} {native}', + summary_template_variables: { + action_type: { type: 'string', value: 'Send' }, + amount: { type: 'currency', value: '1.5' }, + }, + }; + + expect(summaryToPlainText(summary)).toBe(`Send 1.5 ${ currencyUnits.ether }`); +}); + +it('collapses the template whitespace into single spaces', () => { + const summary: TxInterpretationSummary = { + ...TX_INTERPRETATION.data.summaries[0], + summary_template: ' {action_type} {source_amount} Ether into {destination_amount} {destination_token} ', + }; + + expect(summaryToPlainText(summary)).toBe('Wrap 0.7 Ether into 0.7 STUB'); +}); + +it('returns nothing when a template variable has no value', () => { + const summary: TxInterpretationSummary = { + summary_template: '{action_type} {amount} {token}', + summary_template_variables: { + action_type: { type: 'string', value: 'Transfer' }, + amount: { type: 'currency', value: '100' }, + }, + }; + + expect(summaryToPlainText(summary)).toBeUndefined(); +}); diff --git a/src/features/tx-interpretation/common/utils/summary-to-plain-text.ts b/src/features/tx-interpretation/common/utils/summary-to-plain-text.ts new file mode 100644 index 0000000000..c469f1d341 --- /dev/null +++ b/src/features/tx-interpretation/common/utils/summary-to-plain-text.ts @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import type { TxInterpretationSummary, TxInterpretationVariable } from 'src/features/tx-interpretation/common/types/api'; + +import { currencyUnits } from 'src/slices/chain/units'; + +import dayjs from 'src/shared/date-and-time/dayjs'; + +import { SECOND } from 'src/toolkit/utils/consts'; + +import addressToPlainText from './address-to-plain-text'; +import formatCurrencyValue from './format-currency-value'; +import { + extractVariables, + getStringChunks, + fillStringVariables, + checkSummary, + NATIVE_COIN_SYMBOL_VAR_NAME, + WEI_VAR_NAME, +} from './utils'; + +const UNNAMED_TOKEN = 'Unnamed token'; +// The format `TxInterpretation` uses for a timestamp variable, which is not the OG description's own +// timestamp format. +const TIMESTAMP_FORMAT = 'MMM DD YYYY'; + +const WHITESPACE_RUN_REGEX = /\s+/g; + +function variableToPlainText(variable: TxInterpretationVariable | undefined): string { + if (!variable) { + return ''; + } + + const { type, value } = variable; + + switch (type) { + case 'string': + case 'domain': + case 'method': + return value; + case 'currency': + return formatCurrencyValue(value); + case 'token': + return value.symbol ?? value.name ?? UNNAMED_TOKEN; + case 'address': + return addressToPlainText(value); + case 'dexTag': + case 'link': + case 'external_link': + return value.name; + case 'timestamp': + return dayjs(Number(value) * SECOND).format(TIMESTAMP_FORMAT); + } +} + +// Renders what `TxInterpretation` renders, as a single line of text. +export default function summaryToPlainText(summary: TxInterpretationSummary) { + const template = summary.summary_template; + const variables = summary.summary_template_variables; + + if (!checkSummary(template, variables)) { + return; + } + + const intermediateResult = fillStringVariables(template, variables); + const variablesNames = extractVariables(intermediateResult); + const chunks = getStringChunks(intermediateResult); + + return chunks + .flatMap((chunk, index) => { + const name = variablesNames[index]; + const variableText = (() => { + switch (name) { + case undefined: + return ''; + case NATIVE_COIN_SYMBOL_VAR_NAME: + return currencyUnits.ether; + case WEI_VAR_NAME: + return currencyUnits.wei; + default: + return variableToPlainText(variables[name]); + } + })(); + + return [ chunk.trim(), variableText ]; + }) + .filter(Boolean) + .join(' ') + .replaceAll(WHITESPACE_RUN_REGEX, ' ') + .trim(); +} diff --git a/src/slices/address/components/entity/AddressEntity.tsx b/src/slices/address/components/entity/AddressEntity.tsx index 0c0be648e5..dadbd6a19c 100644 --- a/src/slices/address/components/entity/AddressEntity.tsx +++ b/src/slices/address/components/entity/AddressEntity.tsx @@ -9,8 +9,7 @@ import { useSettingsContext } from 'src/shell/top-bar/settings/context'; import { useAddressHighlightContext } from 'src/slices/address/contexts/address-highlight'; import { toBech32Address } from 'src/slices/address/utils/bech32'; - -import { getTagName } from 'src/features/address-metadata/components/tag/utils'; +import getAddressName from 'src/slices/address/utils/get-address-name'; import * as EntityBase from 'src/shared/entities/components'; import { distributeEntityProps, getContentProps, getIconProps } from 'src/shared/entities/utils'; @@ -140,15 +139,7 @@ export type ContentProps = Omit<EntityBase.ContentBaseProps, 'text'> & Pick<Enti const Content = chakra((props: ContentProps) => { const displayedAddress = getDisplayedAddress(props.address, props.altHash); - const nameTag = (() => { - const tagData = props.address.metadata?.tags.find(tag => tag.tagType === 'name'); - if (!tagData || !tagData.name) { - return; - } - - return getTagName(tagData, props.address.hash); - })(); - const nameText = nameTag || props.address.ens_domain_name || props.address.name; + const nameText = getAddressName(props.address); const isProxy = props.address.implementations && props.address.implementations.length > 0 && props.address.proxy_type !== 'eip7702'; diff --git a/src/slices/address/utils/get-address-name.spec.ts b/src/slices/address/utils/get-address-name.spec.ts new file mode 100644 index 0000000000..72d88fd09b --- /dev/null +++ b/src/slices/address/utils/get-address-name.spec.ts @@ -0,0 +1,21 @@ +import { withName, withEns, withNameTag, withoutName } from 'src/slices/address/mocks/address-param'; + +import { it, expect } from 'vitest'; + +import getAddressName from './get-address-name'; + +it('prefers the name tag over the ENS domain and the name', () => { + expect(getAddressName(withNameTag)).toBe('Mrs. Duckie'); +}); + +it('falls back to the ENS domain', () => { + expect(getAddressName(withEns)).toBe('kitty.kitty.kitty.cat.eth'); +}); + +it('falls back to the name', () => { + expect(getAddressName(withName)).toBe('ArianeeStore'); +}); + +it('returns nothing for an address with no name of any kind', () => { + expect(getAddressName(withoutName)).toBeUndefined(); +}); diff --git a/src/slices/address/utils/get-address-name.ts b/src/slices/address/utils/get-address-name.ts new file mode 100644 index 0000000000..cb0e0c9dd1 --- /dev/null +++ b/src/slices/address/utils/get-address-name.ts @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import type { schemas } from '@blockscout/api-types'; + +import { getTagName } from 'src/features/address-metadata/components/tag/utils'; + +type AddressNameSource = Partial<Pick<schemas['Address'], 'metadata' | 'ens_domain_name' | 'name'>> & { hash: string }; + +// The name an address is displayed by, in `AddressEntity`'s order of preference. An address with no name +// of any kind gets `undefined` — the caller decides how to render the bare hash. +export default function getAddressName(address: AddressNameSource): string | undefined { + const nameTag = (() => { + const tagData = address.metadata?.tags.find(tag => tag.tagType === 'name'); + + if (!tagData || !tagData.name) { + return; + } + + return getTagName(tagData, address.hash); + })(); + + return nameTag || address.ens_domain_name || address.name || undefined; +} From 844afa1662e65c3d2545efdb30575a2a5cc1b54f Mon Sep 17 00:00:00 2001 From: tom <tom@ohhhh.me> Date: Tue, 28 Jul 2026 20:06:39 +0200 Subject: [PATCH 05/13] Derive the OG description params for a transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the transaction and its interpretation summary into the three strings the OG description template compiles — status word, action, UTC timestamp — or nothing at all when any of them is missing, which is what keeps the template's all-or-nothing rule true at the source. The unit lives in the tx slice rather than beside `generate()`: metadata is regenerated on every client-side route change, so anything reachable from there ships to all users, and the responses would otherwise be serialized into `__NEXT_DATA__` whole. Three short strings instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../3593-tx-og-title-description/spec.md | 2 +- .../03-tx-og-description-params/spec.md | 9 ++- .../utils/get-og-description-params.spec.ts | 79 +++++++++++++++++++ .../tx/utils/get-og-description-params.ts | 79 +++++++++++++++++++ 4 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 src/slices/tx/utils/get-og-description-params.spec.ts create mode 100644 src/slices/tx/utils/get-og-description-params.ts diff --git a/.agents/tasks/3593-tx-og-title-description/spec.md b/.agents/tasks/3593-tx-og-title-description/spec.md index da66f37c86..0e2c909d29 100644 --- a/.agents/tasks/3593-tx-og-title-description/spec.md +++ b/.agents/tasks/3593-tx-og-title-description/spec.md @@ -151,7 +151,7 @@ verifies that the preview genuinely works in a real social client. - [x] 1 `[agent]` Turn the `og` block into a `default`/`enhanced` template layer → `subtasks/01-og-template-layer/` - [x] 2 `[agent]` Share the currency rounding and render interpretation summaries as plain text → `subtasks/02-interpretation-plain-text/` -- [ ] 3 `[agent]` Derive the three OG description params for a transaction → `subtasks/03-tx-og-description-params/` +- [x] 3 `[agent]` Derive the three OG description params for a transaction → `subtasks/03-tx-og-description-params/` - [ ] 4 `[agent]` Wire the bot-gated fetch and add the `/tx/[hash]` OG templates → `subtasks/04-gssp-wiring-and-templates/` - [ ] 5 `[agent]` + `[human]` Deploy a demo, then verify the preview manually → `subtasks/05-demo-deploy/` — the agent deploys and checks the tags over `curl`; the human confirms the real card in Telegram and diff --git a/.agents/tasks/3593-tx-og-title-description/subtasks/03-tx-og-description-params/spec.md b/.agents/tasks/3593-tx-og-title-description/subtasks/03-tx-og-description-params/spec.md index 9e1da63d82..a8a7ae36c1 100644 --- a/.agents/tasks/3593-tx-og-title-description/subtasks/03-tx-og-description-params/spec.md +++ b/.agents/tasks/3593-tx-og-title-description/subtasks/03-tx-og-description-params/spec.md @@ -3,7 +3,7 @@ | | | | --- | --- | | Parent spec | [../../spec.md](../../spec.md) — step 3 of #3593 | -| Status | `ready` | +| Status | `done` | | Size | `medium` | | Sub-branch | — (single commit on `issue-3593`) | | PM | Ulyana (task author) | @@ -69,14 +69,17 @@ No visual surface. New file `src/slices/tx/utils/get-og-description-params.ts` ( ## Task breakdown -- [ ] 1 `[agent]` Write `get-og-description-params.ts` +- [x] 1 `[agent]` Write `get-og-description-params.ts` + — exports `TxOgDescriptionParams` alongside the function, for subtask 4's `ApiData` entry. - inputs: - Return `null` unless all three strings resolve; assemble them independently first, then check. - Read the feature flag as `config.features.txInterpretation.isEnabled` at call time (not module load), so tests can vary it. - Import `dayjs` from `src/shared/date-and-time/dayjs` (never the package directly — the locale overrides live in that module). -- [ ] 2 `[agent]` Unit tests +- [x] 2 `[agent]` Unit tests + — the happy path uses the `TX_INTERPRETATION` stub, whose summary has no timestamp variable, so the only + date in the assertions is the util's own UTC one. - inputs: - Cover: the happy path against the parent spec's production sample (expect `Success · Swap 2.92M SPERPS for 0.016 WETH · Jul 27, 2026 22:39 UTC`); <!-- cspell:ignore SPERPS --> each status word; `undefined` diff --git a/src/slices/tx/utils/get-og-description-params.spec.ts b/src/slices/tx/utils/get-og-description-params.spec.ts new file mode 100644 index 0000000000..98cf2ef886 --- /dev/null +++ b/src/slices/tx/utils/get-og-description-params.spec.ts @@ -0,0 +1,79 @@ +import type { schemas } from '@blockscout/api-types'; +import type { TxInterpretationResponse } from 'src/features/tx-interpretation/common/types/api'; + +import { base } from 'src/slices/tx/mocks/details'; + +import { TX_INTERPRETATION } from 'src/features/tx-interpretation/blockscout/stubs'; + +import { ENVS_MAP } from 'src/config/test-utils/env-presets'; + +import { it, expect, describe } from 'vitest'; +import withEnvs from 'vitest/utils/mockEnvs'; + +// The interpretation feature is off in the test env, so every case that needs an action runs with it on. +function getParamsWithInterpretation(tx: schemas['TransactionResponse'] | undefined, interpretation?: TxInterpretationResponse) { + return withEnvs(ENVS_MAP.txInterpretation, async() => { + const { 'default': getOgDescriptionParams } = await import('./get-og-description-params'); + return getOgDescriptionParams(tx, interpretation); + }); +} + +it('derives the three params from the summary and the transaction', async() => { + expect(await getParamsWithInterpretation(base, TX_INTERPRETATION)).toEqual({ + tx_status: 'Success', + tx_action: 'Wrap 0.7 Ether into 0.7 STUB', + tx_timestamp: 'Oct 10, 2022 14:34 UTC', + }); +}); + +describe('status', () => { + it.each([ + [ 'ok' as const, 'Success' ], + [ 'error' as const, 'Failed' ], + [ null, 'Pending' ], + ])('%s → %s', async(status, expected) => { + const result = await getParamsWithInterpretation({ ...base, status }, TX_INTERPRETATION); + expect(result?.tx_status).toBe(expected); + }); + + it('gives up when the field never arrived', async() => { + const { status, ...txWithoutStatus } = base; + expect(await getParamsWithInterpretation(txWithoutStatus as schemas['TransactionResponse'], TX_INTERPRETATION)).toBeNull(); + }); +}); + +describe('gives up when a part is missing', () => { + it('no transaction at all', async() => { + expect(await getParamsWithInterpretation(undefined, TX_INTERPRETATION)).toBeNull(); + }); + + it('a pending transaction, which has no timestamp', async() => { + expect(await getParamsWithInterpretation({ ...base, status: null, timestamp: null }, TX_INTERPRETATION)).toBeNull(); + }); + + it('an error body, which `fetchApi` hands back as data', async() => { + const notFound = { message: 'Not found' } as unknown as schemas['TransactionResponse']; + expect(await getParamsWithInterpretation(notFound, TX_INTERPRETATION)).toBeNull(); + }); + + it('no usable summary and no method to fall back on', async() => { + expect(await getParamsWithInterpretation({ ...base, method: null })).toBeNull(); + }); + + it('the interpretation feature is off', async() => { + const { 'default': getOgDescriptionParams } = await import('./get-og-description-params'); + expect(getOgDescriptionParams(base, TX_INTERPRETATION)).toBeNull(); + }); +}); + +describe('falls back to the called-method line', () => { + it('names the addresses the way the page does', async() => { + const result = await getParamsWithInterpretation(base); + expect(result?.tx_action).toBe('kitty.kitty.cat.eth called updateSmartAsset on 0xd7...5859'); + }); + + it('reads as a failed call for a failed transaction', async() => { + const result = await getParamsWithInterpretation({ ...base, status: 'error' }); + expect(result?.tx_action).toBe('kitty.kitty.cat.eth failed to call updateSmartAsset on 0xd7...5859'); + }); +}); diff --git a/src/slices/tx/utils/get-og-description-params.ts b/src/slices/tx/utils/get-og-description-params.ts new file mode 100644 index 0000000000..f9515673cd --- /dev/null +++ b/src/slices/tx/utils/get-og-description-params.ts @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import type { schemas } from '@blockscout/api-types'; +import type { TxInterpretationResponse } from 'src/features/tx-interpretation/common/types/api'; + +import addressToPlainText from 'src/features/tx-interpretation/common/utils/address-to-plain-text'; +import summaryToPlainText from 'src/features/tx-interpretation/common/utils/summary-to-plain-text'; + +import config from 'src/config'; +import dayjs from 'src/shared/date-and-time/dayjs'; + +// Already `MMM D, YYYY H:mm` through the locale overrides in the dayjs module. +const TIMESTAMP_FORMAT = 'lll'; + +export interface TxOgDescriptionParams { + tx_status: string; + tx_action: string; + tx_timestamp: string; +} + +// `undefined` — as opposed to `null`, which is a pending transaction — means the field never arrived, so +// there is no status to show. `fetchApi` hands back non-200 bodies as data, and this is what keeps a 404 +// from reading as `Pending`. +function getStatusText(status: schemas['Transaction']['status'] | undefined) { + if (status === undefined) { + return; + } + + switch (status) { + case 'ok': + return 'Success'; + case 'error': + return 'Failed'; + case null: + return 'Pending'; + } +} + +function getActionText(tx: schemas['TransactionResponse'] | undefined, interpretation: TxInterpretationResponse | undefined) { + if (!config.features.txInterpretation.isEnabled) { + return; + } + + const summary = interpretation?.data?.summaries?.[0]; + const summaryText = summary ? summaryToPlainText(summary) : undefined; + + if (summaryText) { + return summaryText; + } + + if (!tx?.method || !tx.from || !tx.to) { + return; + } + + const verb = tx.status === 'error' ? 'failed to call' : 'called'; + + return `${ addressToPlainText(tx.from) } ${ verb } ${ tx.method } on ${ addressToPlainText(tx.to) }`; +} + +// All or nothing: the OG description template needs every placeholder, and `undefined` members cannot be +// serialized into the page props anyway. +export default function getOgDescriptionParams( + tx: schemas['TransactionResponse'] | undefined, + interpretation: TxInterpretationResponse | undefined, +): TxOgDescriptionParams | null { + const status = getStatusText(tx?.status); + const action = getActionText(tx, interpretation); + const timestamp = tx?.timestamp ? dayjs(tx.timestamp).utc().format(TIMESTAMP_FORMAT) + ' UTC' : undefined; + + if (!status || !action || !timestamp) { + return null; + } + + return { + tx_status: status, + tx_action: action, + tx_timestamp: timestamp, + }; +} From 8889432d27a9f0a2fc4c8b28f5528b62c98a0bcc Mon Sep 17 00:00:00 2001 From: tom <tom@ohhhh.me> Date: Wed, 29 Jul 2026 10:24:51 +0200 Subject: [PATCH 06/13] Exclude agent worktrees from the linters and test runners A worktree under `.claude/worktrees/` is a full checkout of the repo, so every tool that walks the tree from the root sees each file twice: cspell and the two test runners duplicate their work, tsc type-checks a copy with no dependencies installed, and eslint exhausted the Node heap outright. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- cspell.jsonc | 2 ++ eslint.config.mjs | 2 ++ playwright-ct.config.ts | 3 +++ tsconfig.json | 2 ++ vitest.config.ts | 3 ++- 5 files changed, 11 insertions(+), 1 deletion(-) diff --git a/cspell.jsonc b/cspell.jsonc index b53e15b934..cef792f90e 100644 --- a/cspell.jsonc +++ b/cspell.jsonc @@ -8,6 +8,8 @@ "**/*.svg", "**/*.sfd", ".git", + // agent worktrees are full checkouts of the repo — checking them duplicates every file + ".claude/worktrees", "pnpm-lock.yaml", "tools/dev-server/.env.localhost", "vitest/.env.vitest", diff --git a/eslint.config.mjs b/eslint.config.mjs index 72bf3b48f6..892d39b8a6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -105,6 +105,8 @@ export default tseslint.config( 'deploy/tools/', 'public/', '.git/', + // agent worktrees are full checkouts of the repo; linting them doubles the work and can exhaust the heap + '.claude/worktrees/', 'next.config.js', ] }, diff --git a/playwright-ct.config.ts b/playwright-ct.config.ts index a0d80864ae..378eb852d0 100644 --- a/playwright-ct.config.ts +++ b/playwright-ct.config.ts @@ -15,6 +15,9 @@ const config: PlaywrightTestConfig = defineConfig({ testMatch: /.*\.pw\.tsx/, + // agent worktrees are full checkouts of the repo; their tests would run a second time + testIgnore: '.claude/worktrees/**', + snapshotPathTemplate: '{testDir}/{testFileDir}/__screenshots__/{testFileName}_{projectName}_{arg}{ext}', /* Maximum time one test can run for. */ diff --git a/tsconfig.json b/tsconfig.json index 2f6d8ee48f..debd99855d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -41,6 +41,8 @@ "exclude": [ "node_modules", "node_modules_linux", + // agent worktrees are full checkouts of the repo, with no dependencies installed of their own + ".claude/worktrees", "./deploy/tools/envs-validator", "./deploy/tools/favicon-generator", "./deploy/tools/multichain-config-generator", diff --git a/vitest.config.ts b/vitest.config.ts index fe74aa0924..fa56fc1c96 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ globalSetup: [ './vitest/global-setup.ts' ], setupFiles: [ './vitest/setup.ts' ], include: [ '**/*.spec.ts', '**/*.spec.tsx' ], - exclude: [ '**/node_modules/**', '**/node_modules_linux/**' ], + // agent worktrees are full checkouts of the repo; their specs would run a second time + exclude: [ '**/node_modules/**', '**/node_modules_linux/**', '.claude/worktrees/**' ], }, }); From 3cac2ef1d7131121d378a550bde7cd9714723f8b Mon Sep 17 00:00:00 2001 From: tom <tom@ohhhh.me> Date: Wed, 29 Jul 2026 10:38:04 +0200 Subject: [PATCH 07/13] Serve social previews of a transaction with real data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shared transaction link produced a card titled with the full 66-character hash and described by the generic page copy, since the route declared no OG templates and crawlers never run the client-side metadata update. `/tx/[hash]` now carries a short-hash OG title for every crawler, and its `getServerSideProps` fetches the transaction and its interpretation summary for social-preview bots specifically, turning them into a status · action · timestamp description. Anything less than all three parts falls back to the generic description, so a pending transaction or a failed request degrades to exactly today's card. Both requests run in parallel with a 2 s timeout. That is longer than the other routes allow because both endpoints compute their response on the first request for a transaction and cache it afterwards, and a crawler is always that first request: on eth mainnet a cold summary averages 0.95 s against 0.30 s warm. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../3593-tx-og-title-description/spec.md | 16 +++++-- .../subtasks/01-og-template-layer/spec.md | 6 ++- .../04-gssp-wiring-and-templates/spec.md | 46 +++++++++++------- src/pages/tx/[hash].tsx | 47 +++++++++++++++++-- .../__snapshots__/generate.spec.ts.snap | 2 +- src/shell/metadata/generate.spec.ts | 17 +++++++ src/shell/metadata/generate.ts | 20 +++++--- src/shell/metadata/templates/index.ts | 14 ++++-- src/shell/metadata/types.ts | 6 +++ 9 files changed, 138 insertions(+), 36 deletions(-) diff --git a/.agents/tasks/3593-tx-og-title-description/spec.md b/.agents/tasks/3593-tx-og-title-description/spec.md index 0e2c909d29..28127c7c1c 100644 --- a/.agents/tasks/3593-tx-og-title-description/spec.md +++ b/.agents/tasks/3593-tx-og-title-description/spec.md @@ -76,7 +76,8 @@ crawlers don't run JS and `metadata.update()` only ever touches `<title>` and `< `api_request_duration_seconds{route,code}` is recorded inside `fetchApi` itself (labelled by resource name, with `504` on abort) — so the new server-side calls are instrumented for free. - On the demo: paste the link into Telegram and see the card; confirm from - `api_request_duration_seconds` that the 1 s timeouts are actually sufficient against the core API. + `api_request_duration_seconds` that the 2 s timeouts are actually sufficient against the core API — the + `504`-labelled samples are the ones that gave up. ## Data & API @@ -109,10 +110,19 @@ grilling — no backend release to wait on, nothing to add via `add-api-resource **Fetch plan** — in `/tx/[hash]`'s `getServerSideProps`, gated on `config.metadata.og.enhancedDataEnabled && detectBotRequest(req)?.type === 'social_preview'` **and** -`!config.features.multichain.isEnabled`. The two requests run in parallel with a **1 s** timeout each +`!config.features.multichain.isEnabled`. The two requests run in parallel with a **2 s** timeout each (social-bot traffic is low per Grafana history); `/summary` is skipped entirely when `config.features.txInterpretation.isEnabled` is false. +Why 2 s rather than the 500 ms–1 s the other routes use: both endpoints compute on the first request for a +given transaction and cache the result, and a crawler is always that first request. Measured on eth mainnet +(10 transactions × 5 calls), the cold `/summary` call averages 0.95 s and exceeds 1 s in 4 of 10 cases, +against 0.30 s for every warm repeat; `/transactions/:hash` shows the same shape with a fatter tail. The +ceiling is the crawler's own fetch timeout — unpublished, but practically single-digit seconds — and since +the two calls are parallel the worst case adds ~2 s, well inside it. Overshooting the crawler would lose the +whole card, whereas aborting only loses the enhanced description, so the budget stays deliberately short of +what the envelope allows. + **Trap to code against:** `fetchApi` returns the parsed body **regardless of HTTP status** — it logs non-200s but still `return await response.json()`. So a bad hash yields `{ message: "Not found" }` typed as a `TransactionResponse`. Requiring `tx_timestamp` handles that for free (an error body has no `timestamp`), @@ -152,7 +162,7 @@ verifies that the preview genuinely works in a real social client. - [x] 1 `[agent]` Turn the `og` block into a `default`/`enhanced` template layer → `subtasks/01-og-template-layer/` - [x] 2 `[agent]` Share the currency rounding and render interpretation summaries as plain text → `subtasks/02-interpretation-plain-text/` - [x] 3 `[agent]` Derive the three OG description params for a transaction → `subtasks/03-tx-og-description-params/` -- [ ] 4 `[agent]` Wire the bot-gated fetch and add the `/tx/[hash]` OG templates → `subtasks/04-gssp-wiring-and-templates/` +- [x] 4 `[agent]` Wire the bot-gated fetch and add the `/tx/[hash]` OG templates → `subtasks/04-gssp-wiring-and-templates/` - [ ] 5 `[agent]` + `[human]` Deploy a demo, then verify the preview manually → `subtasks/05-demo-deploy/` — the agent deploys and checks the tags over `curl`; the human confirms the real card in Telegram and rules on whether the 1 s timeouts hold (Grafana isn't agent-reachable). diff --git a/.agents/tasks/3593-tx-og-title-description/subtasks/01-og-template-layer/spec.md b/.agents/tasks/3593-tx-og-title-description/subtasks/01-og-template-layer/spec.md index 48c143feb4..d45fb1228a 100644 --- a/.agents/tasks/3593-tx-og-title-description/subtasks/01-og-template-layer/spec.md +++ b/.agents/tasks/3593-tx-og-title-description/subtasks/01-og-template-layer/spec.md @@ -24,14 +24,16 @@ changes. `/tx/[hash]`'s own templates land in subtask 4. ## Functional requirements -- `og.title` and `og.description` each accept `{ 'default': string; enhanced?: string }` and are compiled +- `og.title` and `og.description` each accept `{ 'default'?: string; enhanced?: string }` and are compiled with `compileValue` against the same `params` object as the metadata templates. - `og.image` becomes independent of the other two — a route may declare an image with no templates, or templates with no image. - The ` | Blockscout` postfix is appended to the compiled OG title in **both** the `default` and `enhanced` cases, still gated by `config.metadata.promoteBlockscoutInTitle`. - A route with no `og.title` gets `opengraph.title = title`; with no `og.description`, it gets - `opengraph.description = description`. The description fallback is written **explicitly** even though + `opengraph.description = description`. An OG template that declares only an `enhanced` variant inherits + the metadata template's `default`, so a route needing just a richer bot description doesn't restate the + base text. The description fallback is written **explicitly** even though crawlers already do it implicitly when the tag is absent — it documents the intent and makes the resolution rule uniform with the title's. - New param `hash_short` — `shortenString(hash, 8)`, i.e. `0xda...671a`. Set to `undefined` (not `''`) when diff --git a/.agents/tasks/3593-tx-og-title-description/subtasks/04-gssp-wiring-and-templates/spec.md b/.agents/tasks/3593-tx-og-title-description/subtasks/04-gssp-wiring-and-templates/spec.md index ed74838ade..f1f01b39c3 100644 --- a/.agents/tasks/3593-tx-og-title-description/subtasks/04-gssp-wiring-and-templates/spec.md +++ b/.agents/tasks/3593-tx-og-title-description/subtasks/04-gssp-wiring-and-templates/spec.md @@ -3,7 +3,7 @@ | | | | --- | --- | | Parent spec | [../../spec.md](../../spec.md) — step 4 of #3593 | -| Status | `ready` | +| Status | `done` | | Size | `medium` | | Sub-branch | — (single commit on `issue-3593`) | | PM | Ulyana (task author) | @@ -26,21 +26,21 @@ or not at all. og: { title: { 'default': '%chain_name% transaction %hash_short%' }, description: { - 'default': <the route's own metadata description>, enhanced: '%tx_status% · %tx_action% · %tx_timestamp%', }, } ``` - The separator is a middle dot `·` (U+00B7) with a space either side. The `default` description is the - explicit fallback the parent spec calls for; the ` | Blockscout` postfix is added by `generate()`, not - written into the template. + The separator is a middle dot `·` (U+00B7) with a space either side. The OG description declares no + `default` — it inherits the route's metadata one, which is the explicit fallback the parent spec calls + for. The ` | Blockscout` postfix is added by `generate()`, not written into the template. - `ApiData<'/tx/[hash]'>` is `{ tx_status: string; tx_action: string; tx_timestamp: string }`. - `getServerSideProps` populates `apiData` only when **all** of these hold: `config.metadata.og.enhancedDataEnabled`, `detectBotRequest(ctx.req)?.type === 'social_preview'`, `!config.features.multichain.isEnabled`, and `'props' in baseResponse`. -- The two requests run in parallel, **1 s** timeout each; `core:tx_interpretation` is not requested at all - when `config.features.txInterpretation.isEnabled` is false. +- The two requests run in parallel, **2 s** timeout each (see the parent spec's fetch plan for the + measurements behind the number); `core:tx_interpretation` is not requested at all when + `config.features.txInterpretation.isEnabled` is false. - The page passes `apiData` through `PageNextJs` so `PageMetadata` can reach it. - Unchanged for everyone who isn't a social-preview bot: same SEO tags, no extra requests, no added latency. - `og:title` carries the short hash for **all** requests including search engines — it needs no API data, so @@ -68,16 +68,14 @@ Note `baseResponse.props` is a promise in this pattern — the existing routes w ## Task breakdown -- [ ] 1 `[agent]` Add the `ApiData<'/tx/[hash]'>` branch in `src/shell/metadata/types.ts` +- [x] 1 `[agent]` Add the `ApiData<'/tx/[hash]'>` branch in `src/shell/metadata/types.ts` - inputs: - Insert into the existing conditional chain, keeping its `/* eslint-disable @stylistic/indent */` style. -- [ ] 2 `[agent]` Add the OG templates to the `/tx/[hash]` entry +- [x] 2 `[agent]` Add the OG templates to the `/tx/[hash]` entry - inputs: - - Templates exactly as above. The `default` description duplicates the route's existing - `metadata.description.default` string — reference the same constant rather than retyping it if that - reads cleanly, otherwise duplicate it verbatim. + - Templates exactly as above. - Check `cspell.jsonc` doesn't trip on the middle dot; add nothing to the dictionary unless it does. -- [ ] 3 `[agent]` Add `getServerSideProps` to `src/pages/tx/[hash].tsx` +- [x] 3 `[agent]` Add `getServerSideProps` to `src/pages/tx/[hash].tsx` - inputs: - Model it on `src/pages/token/[hash]/index.tsx:30` — `const baseResponse = await gSSP.tx<typeof pathname>(ctx)`, then the guard, then `(await baseResponse.props).apiData = …`. @@ -85,21 +83,35 @@ Note `baseResponse.props` is a promise in this pattern — the existing routes w Do **not** add a `config.metadata.seo.enhancedDataEnabled` arm — the SEO tags don't use API data here, so fetching for search-engine bots would buy nothing. - `Promise.all` over the two `fetchApi` calls; hash via `getQueryParamString(ctx.query.hash)`; - `timeout: SECOND` from `src/toolkit/utils/consts`. + `timeout: 2 * SECOND` composed from `src/toolkit/utils/consts`. - Pass the results to `getOgDescriptionParams` and assign its result (object or `null`) straight to `apiData`. - Introduce the `pathname` const and the `Props<typeof pathname>` generic the way the token page does, and pass `apiData={ props.apiData }` to `PageNextJs`. -- [ ] 4 `[agent]` Verify locally +- [x] 4 `[agent]` Verify locally + — all four cases confirmed on the `staging` preset; see the note below. - inputs: - - `pnpm dev:preset robinhood`, then `curl -A Twitterbot 'http://localhost:3000/tx/<hash>'` and grep the + - `pnpm dev:preset staging`, then `curl -A Twitterbot 'http://localhost:3000/tx/<hash>'` and grep the `og:` / `twitter:` / `description` meta tags. First load takes ~45 s while Turbopack compiles. - Confirm: `og:title` has the short hash, `og:description` has the three-part line, `<title>` and `<meta name="description">` are unchanged, and a request **without** a bot UA shows no `og:description` beyond the fallback. - Also check a pending transaction and a bad hash both yield the fallback description rather than a malformed line. -- [ ] 5 `[agent]` Run `pnpm lint` and `pnpm test` (see `.agents/rules/code-quality.mdc` for the exact commands) +- [x] 5 `[agent]` Run `pnpm lint` and `pnpm test` (see `.agents/rules/code-quality.mdc` for the exact commands) + +## Verification result + +Confirmed against the `staging` preset with a `Twitterbot` user agent: `og:title` carries the short hash, +`og:description` reads `Success · Swap 0.21 UNI for 0.000015 NLP · Jul 28, 2026 18:10 UTC`, while `<title>` +keeps the full hash and `<meta name="description">` is untouched. Without a bot UA the server logs no API +request at all and the description falls back. A pending transaction and a bad hash both fall back cleanly. + +On the very first bot request the `/summary` call aborted at what was then a 1 s timeout (logged as `504`) +while the tx call succeeded, so the preview fell back to the `called` line; every later request resolved the +summary in ~400 ms. Measuring that properly on eth mainnet showed it is the backend's cold-response cost, +not the network, which is why both timeouts are now 2 s — the reasoning is in the parent spec's fetch plan. +What remains for subtask 5 is confirming from `api_request_duration_seconds` that 2 s holds in production. ## Open questions diff --git a/src/pages/tx/[hash].tsx b/src/pages/tx/[hash].tsx index 6915289a7c..b6ece6de54 100644 --- a/src/pages/tx/[hash].tsx +++ b/src/pages/tx/[hash].tsx @@ -1,19 +1,37 @@ // SPDX-License-Identifier: LicenseRef-Blockscout -import type { NextPage } from 'next'; +import type { GetServerSideProps, NextPage } from 'next'; import dynamic from 'next/dynamic'; +import type { Route } from 'nextjs-routes'; import React from 'react'; import type { Props } from 'src/server/getServerSideProps/handlers'; +import * as gSSP from 'src/server/getServerSideProps/main'; import PageNextJs from 'src/server/PageNextJs'; +import detectBotRequest from 'src/server/utils/detectBotRequest'; +import fetchApi from 'src/server/utils/fetchApi'; + +import getOgDescriptionParams from 'src/slices/tx/utils/get-og-description-params'; + +import config from 'src/config'; +import getQueryParamString from 'src/shared/router/get-query-param-string'; + +import { SECOND } from 'src/toolkit/utils/consts'; + +const pathname: Route['pathname'] = '/tx/[hash]'; + +// Both endpoints compute their response on the first request for a transaction and cache it afterwards, +// and a crawler is always that first request — measured on eth mainnet, 40% of cold `/summary` calls need +// more than a second. A crawler waiting is still cheaper than a preview with no description. +const API_TIMEOUT = 2 * SECOND; const Transaction = dynamic(() => { return import('src/slices/tx/pages/details/Transaction'); }, { ssr: false }); -const Page: NextPage<Props> = (props: Props) => { +const Page: NextPage<Props<typeof pathname>> = (props: Props<typeof pathname>) => { return ( - <PageNextJs pathname="/tx/[hash]" query={ props.query }> + <PageNextJs pathname={ pathname } query={ props.query } apiData={ props.apiData }> <Transaction/> </PageNextJs> ); @@ -21,4 +39,25 @@ const Page: NextPage<Props> = (props: Props) => { export default Page; -export { tx as getServerSideProps } from 'src/server/getServerSideProps/main'; +export const getServerSideProps: GetServerSideProps<Props<typeof pathname>> = async(ctx) => { + const baseResponse = await gSSP.tx<typeof pathname>(ctx); + + // Only social-preview bots get the enhanced description, and only server-side: crawlers don't run JS, + // and the SEO tags this route emits need no API data. + const isSocialPreviewBot = config.metadata.og.enhancedDataEnabled && detectBotRequest(ctx.req)?.type === 'social_preview'; + + if ('props' in baseResponse && !config.features.multichain.isEnabled && isSocialPreviewBot) { + const hash = getQueryParamString(ctx.query.hash); + + const [ txData, interpretationData ] = await Promise.all([ + fetchApi({ resource: 'core:tx', pathParams: { hash }, timeout: API_TIMEOUT }), + config.features.txInterpretation.isEnabled ? + fetchApi({ resource: 'core:tx_interpretation', pathParams: { hash }, timeout: API_TIMEOUT }) : + undefined, + ]); + + (await baseResponse.props).apiData = getOgDescriptionParams(txData, interpretationData); + } + + return baseResponse; +}; diff --git a/src/shell/metadata/__snapshots__/generate.spec.ts.snap b/src/shell/metadata/__snapshots__/generate.spec.ts.snap index 0800a40510..180e579542 100644 --- a/src/shell/metadata/__snapshots__/generate.spec.ts.snap +++ b/src/shell/metadata/__snapshots__/generate.spec.ts.snap @@ -36,7 +36,7 @@ exports[`dynamic route 1`] = ` "opengraph": { "description": "Blockscout detailed transaction info. View transaction status, block confirmation, gas fee, native coin and token transfers.", "imageUrl": undefined, - "title": "Blockscout transaction 0x62d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193 | Blockscout", + "title": "Blockscout transaction 0x62...3193 | Blockscout", }, "title": "Blockscout transaction 0x62d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193 | Blockscout", } diff --git a/src/shell/metadata/generate.spec.ts b/src/shell/metadata/generate.spec.ts index b5c0c0d230..8d036988d4 100644 --- a/src/shell/metadata/generate.spec.ts +++ b/src/shell/metadata/generate.spec.ts @@ -15,6 +15,23 @@ it('dynamic route', () => { expect(result).toMatchSnapshot(); }); +it('transaction route with enhanced og data', () => { + const result = generate({ pathname: '/tx/[hash]', query: { hash: transaction.hash } }, { + tx_status: 'Success', + tx_action: 'Transfer 100 DUCK to 0xd7...5859', + tx_timestamp: 'Oct 10, 2022 14:34 UTC', + }); + + expect(result.opengraph.title).toBe('Blockscout transaction 0x62...3193 | Blockscout'); + expect(result.opengraph.description).toBe('Success · Transfer 100 DUCK to 0xd7...5859 · Oct 10, 2022 14:34 UTC'); + + // the SEO tags keep the full hash and the generic copy + expect(result.title).toBe(`Blockscout transaction ${ transaction.hash } | Blockscout`); + expect(result.description).toBe( + 'Blockscout detailed transaction info. View transaction status, block confirmation, gas fee, native coin and token transfers.', + ); +}); + describe('address route', () => { it('enhanced data', () => { const result = generate({ pathname: '/address/[hash]', query: { hash: addressHash } }, { domain_name: 'duck.eth' }); diff --git a/src/shell/metadata/generate.ts b/src/shell/metadata/generate.ts index 16d773e098..287d2c31de 100644 --- a/src/shell/metadata/generate.ts +++ b/src/shell/metadata/generate.ts @@ -3,7 +3,7 @@ import { kebabCase, upperFirst } from 'es-toolkit'; import type { Route } from 'nextjs-routes'; -import type { ApiData, Metadata } from './types'; +import type { ApiData, Metadata, OgTemplateValue, TemplateValue } from './types'; import type { RouteParams } from 'src/server/types'; import { currencyUnits } from 'src/slices/chain/units'; @@ -23,6 +23,13 @@ import { TEMPLATE_MAP } from './templates'; // the same as the one on the page. const HASH_SHORT_CHAR_NUMBER = 8; +function withInheritedDefault(template: OgTemplateValue, metadataTemplate: TemplateValue): TemplateValue { + return { + 'default': template['default'] ?? metadataTemplate['default'], + enhanced: template.enhanced, + }; +} + export default function generate<Pathname extends Route['pathname']>(route: RouteParams<Pathname>, apiData: ApiData<Pathname> = null): Metadata { const idParam = castToString(route.query?.id); const idFormatted = idParam ? upperFirst(kebabCase(idParam).replaceAll('-', ' ')) : undefined; @@ -40,19 +47,20 @@ export default function generate<Pathname extends Route['pathname']>(route: Rout const titlePostfix = config.metadata.promoteBlockscoutInTitle ? ' | Blockscout' : ''; - const title = compileValue(TEMPLATE_MAP[route.pathname].metadata.title, params) + titlePostfix; - const description = compileValue(TEMPLATE_MAP[route.pathname].metadata.description, params); - + const metadataTemplates = TEMPLATE_MAP[route.pathname].metadata; const ogTemplates = TEMPLATE_MAP[route.pathname].og; + const title = compileValue(metadataTemplates.title, params) + titlePostfix; + const description = compileValue(metadataTemplates.description, params); + const jsonLd = generateStructuredData({ route, apiData }); return { title: title, description, opengraph: { - title: ogTemplates?.title ? compileValue(ogTemplates.title, params) + titlePostfix : title, - description: ogTemplates?.description ? compileValue(ogTemplates.description, params) : description, + title: ogTemplates?.title ? compileValue(withInheritedDefault(ogTemplates.title, metadataTemplates.title), params) + titlePostfix : title, + description: ogTemplates?.description ? compileValue(withInheritedDefault(ogTemplates.description, metadataTemplates.description), params) : description, imageUrl: ogTemplates?.image, }, canonical: getCanonicalUrl(route.pathname), diff --git a/src/shell/metadata/templates/index.ts b/src/shell/metadata/templates/index.ts index 971cc44d8f..5e2c11070c 100644 --- a/src/shell/metadata/templates/index.ts +++ b/src/shell/metadata/templates/index.ts @@ -4,7 +4,7 @@ import type { Route } from 'nextjs-routes'; -import type { TemplateValue } from '../types'; +import type { OgTemplateValue, TemplateValue } from '../types'; import { layerLabels } from 'src/features/rollup/common/utils/layer'; @@ -19,8 +19,8 @@ interface RouteTemplateRecord { description: TemplateValue; }; og?: { - title?: TemplateValue; - description?: TemplateValue; + title?: OgTemplateValue; + description?: OgTemplateValue; image?: string; }; } @@ -85,6 +85,14 @@ export const TEMPLATE_MAP: Record<Route['pathname'], RouteTemplateRecord> = { 'default': '%chain_name% detailed transaction info. View transaction status, block confirmation, gas fee, native coin and token transfers.', }, }, + og: { + title: { + 'default': '%chain_name% transaction %hash_short%', + }, + description: { + enhanced: '%tx_status% · %tx_action% · %tx_timestamp%', + }, + }, }, '/blocks': { metadata: { diff --git a/src/shell/metadata/types.ts b/src/shell/metadata/types.ts index 847f60cce7..b54cdfcd65 100644 --- a/src/shell/metadata/types.ts +++ b/src/shell/metadata/types.ts @@ -7,10 +7,13 @@ import type { MarketplaceDapp } from '@blockscout/admin-rs-types'; import type { schemas } from '@blockscout/api-types'; import type { LineChart } from '@blockscout/stats-types'; +import type { TxOgDescriptionParams } from 'src/slices/tx/utils/get-og-description-params'; + /* eslint-disable @stylistic/indent */ export type ApiData<Pathname extends Route['pathname']> = ( Pathname extends '/address/[hash]' ? { domain_name: string } : + Pathname extends '/tx/[hash]' ? TxOgDescriptionParams : Pathname extends '/token/[hash]' ? schemas['Token'] & { symbol_or_name: string; description?: string; projectName?: string } : Pathname extends '/token/[hash]/instance/[id]' ? { symbol_or_name: string } : Pathname extends '/apps/[id]' ? MarketplaceDapp : @@ -28,6 +31,9 @@ export interface TemplateValue { enhanced?: string; } +// An OG template may omit its `default` and inherit the route's metadata one. +export type OgTemplateValue = Partial<TemplateValue>; + export interface Metadata { title: string; description: string; From 29d8e6429cec178403d9fac3dc499c7ff404238f Mon Sep 17 00:00:00 2001 From: tom <tom@ohhhh.me> Date: Wed, 29 Jul 2026 13:31:33 +0200 Subject: [PATCH 08/13] Keep the team roster a registry and the routing rules in the skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TEAM.md mixed the roster with the rules for using it: who picks a task's contacts, that product questions go to a channel rather than a DM, which channel, and how to mention the addressee. Those already lived in `grill-the-task` and `to-spec`, so the file was a second, drifting copy — the skills are what an agent actually reads when routing a question. What stays in TEAM.md is the data plus what makes it readable: the meaning of the `default` marker, where IDs come from, and each team's ownership. One rule was only implied in the skill and is now explicit there: the roster default is what to record when the developer has no task-specific pick. The backend section also gains Slack group IDs beside its members, so a question can be addressed to a whole team once a skill has a rule for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .agents/TEAM.md | 27 ++++++++++++++++---------- .agents/skills/grill-the-task/SKILL.md | 4 +++- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/.agents/TEAM.md b/.agents/TEAM.md index 7977ea23cd..f695d59466 100644 --- a/.agents/TEAM.md +++ b/.agents/TEAM.md @@ -1,9 +1,8 @@ # Team roster for product tasks -The teams involved in product tasks, with the members an agent may need to reach. Teams have several -people — during a grilling session (`grill-the-task`) the developer picks **one contact per relevant team -for the task**; the picks are recorded in the spec header and open questions are routed to those contacts. -The member marked **default** is the fallback when the developer has no task-specific pick. +The teams involved in product tasks, the members an agent may need to reach, and their addresses in Slack. +A registry only — who gets asked what, and where, belongs to the skills that read it (`grill-the-task`, +`to-spec`). The `default` marker in a team's table flags that team's fallback member. Slack **member IDs** are stored deliberately so routing is deterministic (no runtime name lookup). They are workspace-scoped identifiers, not credentials — knowing one grants no access. To find yours in Slack: @@ -30,12 +29,25 @@ Own: mockups, missing screens/states, visual decisions. Own: API endpoints, response models, field propagation across services, backend release schedule. +### People + | Name | GitHub | Slack member ID | Focus | | | --- | --- | --- | --- | --- | | Victor | @vbaranov | U8L403FEG | Core API | default | | Nikita P. | @nikitosing | U0218K3MTC5 | Core API | | | Leonid | @lok52 | U01KDJWBCV7 | Microservices API | default | -| Evgenii | @EvgenKor | U026N2LB01E | Microservices API: Intercahin Indexer, TAC | | +| Evgenii | @EvgenKor | U026N2LB01E | Microservices API: Interchain Indexer, TAC | | + +### Groups + +Slack **group IDs** start with `S`, and a group is addressed by ID rather than by handle: +`<!subteam^SXXXXXXXX>`. To find one: the group's page in the workspace's user-group settings — its URL ends +with the ID. + +| Team | Slack group ID | +| --- | --- | +| Core API | S064H6TD6MA | +| Microservices API | S064073HASK | ## Frontend @@ -48,11 +60,6 @@ Own: architecture, the delegation boundary. ## Slack channels -Product questions are asked **in a channel, not a DM**, so other teams (QA in particular) see the answers. -The default is the frontend channel below; a large feature may have its own dedicated channel — recorded in -the task's spec header — and then **all** of that task's questions go there. Channel posts always mention -the addressee by member ID. - | Purpose | Channel | Channel ID | | --- | --- | --- | | Default for product questions | blockscout-frontend | C03MMUTQDNU | diff --git a/.agents/skills/grill-the-task/SKILL.md b/.agents/skills/grill-the-task/SKILL.md index 82fc38e83d..2f9ec54e53 100644 --- a/.agents/skills/grill-the-task/SKILL.md +++ b/.agents/skills/grill-the-task/SKILL.md @@ -81,7 +81,9 @@ recommended answer, decisions put to the developer while facts are looked up, an until shared understanding is confirmed. Skip anything the research already answered. **Start by picking the task's contacts**: for each relevant team in `.agents/TEAM.md`, ask which member -owns this task (recommending the roster's default) — these go into the spec header. Don't ask what can be +owns this task, recommending the roster's `default` — and record that default whenever the developer has no +task-specific pick. These go into the spec header, and `to-spec` routes each open question to the contact +that owns it. Don't ask what can be inferred: when the issue's author maps to a roster member of the relevant team (match the GitHub handle in `.agents/TEAM.md`), record them as that team's contact without asking — the PM slot in particular is usually just the task's author. Ask about a **dedicated Slack channel** only for **large** tasks — big From 9096ce6da4fd6c6305dd0ebc119f11a2fa72c172 Mon Sep 17 00:00:00 2001 From: tom <tom@ohhhh.me> Date: Wed, 29 Jul 2026 13:45:06 +0200 Subject: [PATCH 09/13] Record the demo findings and the endpoint latency question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo proved the wiring: every branch of the description was observed on a public URL, including the interpretation summary and the called-method fallback with a named address. It also showed the feature barely enhances on that instance, so the subtask now carries the sampling behind that — both transaction endpoints measured against a network control, and the ruling not to raise the timeout, since no crawler waits as long as the API takes. The latency goes to the backend team as Q2; it gates the release decision rather than any remaining work, because the code degrades to today's card wherever the API is slow. Also corrects a claim the spec made from the start: `logRequestFromBot` and `fetchApi` do record their metrics, but the SSR bundle keeps a registry of its own, so `/api/metrics` never exports them and the timeout evidence had to come from sampling the API directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../3593-tx-og-title-description/spec.md | 33 +++++++-- .../subtasks/05-demo-deploy/spec.md | 73 +++++++++++++++++-- 2 files changed, 91 insertions(+), 15 deletions(-) diff --git a/.agents/tasks/3593-tx-og-title-description/spec.md b/.agents/tasks/3593-tx-og-title-description/spec.md index 28127c7c1c..671bbb2ae1 100644 --- a/.agents/tasks/3593-tx-og-title-description/spec.md +++ b/.agents/tasks/3593-tx-og-title-description/spec.md @@ -71,13 +71,14 @@ crawlers don't run JS and `metadata.update()` only ever touches `<title>` and `< URL without a bot UA shows the unchanged SEO tags. - `src/shell/metadata/__snapshots__/generate.spec.ts.snap` — existing entries unchanged, except the `opengraph.description` fallback introduced in subtask 1. -- Metrics need no work and are checked, not built: `social_preview_bot_requests_total{route="/tx/[hash]"}` - is already incremented globally from `_document.tsx` via `logRequestFromBot` using `ctx.pathname`, and - `api_request_duration_seconds{route,code}` is recorded inside `fetchApi` itself (labelled by resource - name, with `504` on abort) — so the new server-side calls are instrumented for free. -- On the demo: paste the link into Telegram and see the card; confirm from - `api_request_duration_seconds` that the 2 s timeouts are actually sufficient against the core API — the - `504`-labelled samples are the ones that gave up. +- Metrics need no work **in this task**, but they also don't currently work: `logRequestFromBot` and + `fetchApi` do increment `social_preview_bot_requests_total` and `api_request_duration_seconds`, yet those + writes happen in the SSR bundle while `/api/metrics` serves the API-route bundle's registry, so nothing is + ever exported. Proven on the demo — see subtask 5's findings. Fixing that is its own task; this task's + verification falls back to external sampling. +- On the demo: paste the link into Telegram and see the card. The 2 s timeouts were checked by sampling the + instance's API directly, since `api_request_duration_seconds` never leaves the process (above) — see + subtask 5's findings for the numbers and the ruling. ## Data & API @@ -194,3 +195,21 @@ or 3's behavior at the cost of an extra request per bot hit. nothing about its structure, the status word, the timestamp, the template shape, or the gSSP gate. Subtask 3 is built for the Blockscout provider; Noves folds in as an additive commit whenever the answer arrives. + +### Q2 — Why do the transaction endpoints take seconds on some instances, and can that change? + +Sampling one instance's API (numbers and method in subtask 5's findings) puts `/api/v2/transactions/:hash` +at a p50 of 2.84 s with every single call over a second, and `/api/v2/transactions/:hash/summary` at a p50 +of 0.84 s with a tail to 10 s — against 0.56 s and 0.95 s for the same endpoints on eth mainnet. Since the +status and the timestamp both come from the transaction endpoint, the enhanced description resolves on +roughly one bot request in three there. Raising the timeout is not a fix: crawlers wait single-digit +seconds, and a card that fails to render is worse than one with the generic description. + +- Owner: Backend (Core API) +- Status: `pending` +- Slack: https://blockscout.slack.com/archives/C03MMUTQDNU/p1785325326478759 (sent 2026-07-29) +- Answer: <decision + date, once resolved> +- **Blocks nothing to build, but gates the release decision.** The code is complete and degrades correctly: + where the API is fast the preview enhances, where it isn't the card keeps today's generic description. The + question is whether shipping in that state is acceptable or whether the endpoint latency is fixed first — + a call for the PM once the backend team answers. diff --git a/.agents/tasks/3593-tx-og-title-description/subtasks/05-demo-deploy/spec.md b/.agents/tasks/3593-tx-og-title-description/subtasks/05-demo-deploy/spec.md index 618b43353f..75fe77b913 100644 --- a/.agents/tasks/3593-tx-og-title-description/subtasks/05-demo-deploy/spec.md +++ b/.agents/tasks/3593-tx-og-title-description/subtasks/05-demo-deploy/spec.md @@ -3,7 +3,7 @@ | | | | --- | --- | | Parent spec | [../../spec.md](../../spec.md) — step 5 of #3593 | -| Status | `ready` | +| Status | `in progress` | | Size | `medium` | | Sub-branch | — (no code; runs the `deploy-demo` skill) | | PM | Ulyana (task author) | @@ -30,10 +30,10 @@ agent-reachable. So the agent finishes at "deployed, tags look right", and the h issue's own example, it has the interpretation feature on with real traffic, so summaries are non-empty. - Pasting a transaction URL into Telegram shows `{status} · {action} · {timestamp}`, and the title carries the short hash. -- The 1 s timeouts are confirmed sufficient against that instance's core API — this is the open +- The 2 s timeouts are confirmed sufficient against that instance's core API — this is the open question the demo exists to answer, not a formality. If `api_request_duration_seconds` shows the - `core:tx` or `core:tx_interpretation` calls landing in the 1 s bucket or returning `504`, the timeout - needs raising and subtask 4 needs a follow-up commit. + `core:tx` or `core:tx_interpretation` calls landing in the top bucket or returning `504`, the timeout + needs revisiting and subtask 4 needs a follow-up commit. ## Data & API @@ -57,11 +57,14 @@ Any code change. Findings that need one become a follow-up commit against the su ## Task breakdown -- [ ] 1 `[agent]` Deploy the demo — skill: `deploy-demo` +- [x] 1 `[agent]` Deploy the demo — skill: `deploy-demo` + — https://review-issue-3593.k8s-dev.blockscout.com (the image build needed two retries; the runner's + outbound network was failing on npm and Alpine mirrors, unrelated to the branch). - inputs: - Preset: `robinhood`. - Deploy from the feature branch `issue-3593`. -- [ ] 2 `[agent]` Verify the tags on the public URL +- [x] 2 `[agent]` Verify the tags on the public URL + — all three description outcomes observed live; see the findings below. - inputs: - `curl -A Twitterbot` and `curl -A TelegramBot` against a settled transaction on that chain; confirm the title and the three-part description. @@ -76,8 +79,62 @@ Any code change. Findings that need one become a follow-up commit against the su - inputs: - Human because Grafana isn't agent-reachable. - Look at the `api_request_duration_seconds` distribution for `core:tx` / `core:tx_interpretation` and - any `code="504"`, and decide whether 1 s holds. If it doesn't, subtask 4 gets a follow-up commit - raising it. + any `code="504"`, and decide whether 2 s holds. The agent's findings below say it does not on this + instance; the ruling is whether that's acceptable degradation or backend work. + +## Findings from the demo (agent steps) + +<!-- cspell:ignore SWOGE --> + +Every branch was observed on the public URL, so the wiring is proven end to end: + +- summary branch — `Success · Swap 0.4 ETH for 2.65M SWOGE · Jul 29, 2026 8:49 UTC` +- fallback branch — `Success · 0x07...e311 called dagSwapTo on OKX Labs: DexRouter · Jul 29, 2026 8:49 UTC` + (note the `to` address resolving through its metadata name tag, as on the page) +- generic fallback when a request doesn't land in time + +`og:title` carries the short hash, `<title>` keeps the full one, and `<meta name="description">` is +untouched — for bots and for a plain UA alike. + +**Reliability is the open issue, and it is the backend's latency.** Over 10 identical `Twitterbot` +requests to one transaction, only 3 produced an enhanced description and only 1 of those used the summary; +a human pasting the link into Telegram saw the generic description every time. + +Paced sampling of that instance from outside the cluster (25 transactions, one request every 3 s over a +10-minute window, 105 requests, all `200`, no rate limiting) — `/api/v2/stats` included as a control for +network and edge overhead: + +| endpoint | phase | p50 | p90 | p95 | max | over 2 s | +| --- | --- | --- | --- | --- | --- | --- | +| `/api/v2/stats` (control) | — | 0.27 s | 0.50 s | 0.50 s | 0.50 s | 0/5 | +| `core:tx` | cold | 2.84 s | 4.20 s | 4.34 s | 4.36 s | 21/25 | +| `core:tx` | warm | 3.08 s | 4.26 s | 4.45 s | 4.65 s | 24/25 | +| `core:tx_interpretation` | cold | 0.84 s | 8.33 s | 9.67 s | 10.21 s | 11/25 | +| `core:tx_interpretation` | warm | 0.75 s | 2.01 s | 2.62 s | 2.73 s | 3/25 | + +The control says ~0.3 s of that is network, so the rest is backend time. Two distinct problems: `core:tx` +is *uniformly* slow — every single call took over a second, and warm is no faster than cold, so nothing is +cached — while `core:tx_interpretation` is bimodal, fast when cached and up to 10 s when not. Since the +status and timestamp both come from `core:tx`, its p50 of 2.8 s alone defeats the 2 s timeout on most +requests, which is why the preview almost never enhances on this instance. For comparison, eth mainnet +answers the same two endpoints in 0.56 s and 0.95 s cold. + +(An earlier run without pacing showed ~12 s summary times; the paced numbers above supersede it — part of that was +contention from our own burst.) + +**Conclusion: don't raise the timeout further.** 2 s already exceeds what the other routes allow, and no +crawler waits for 4 s. The finding goes to the backend team as an endpoint-latency issue on this instance; +until then the preview degrades to the generic description there, which is the designed behavior. + +**The metrics this subtask planned to read do not work.** `PROMETHEUS_METRICS_ENABLED` *is* set for review +instances (`deploy/values/review/values.yaml.gotmpl`) and `/api/metrics` answers `200` — it returns `404` +when disabled — but the registry it exposes only ever contains what **API routes** record. Posting to +`/api/monitoring/invalid-api-schema` makes `invalid_api_schema` appear immediately, while +`api_request_duration_seconds` and `social_preview_bot_requests_total` stay sample-less through any amount +of bot traffic, because `fetchApi` and `_document.tsx` run in the SSR bundle, which gets its own +`prom-client` module instance and therefore its own registry. The `frontend_*` default metrics are missing +too, cleared by `promClient.register.clear()` in `metrics.ts`. So the parent spec's claim that these calls +are "instrumented for free" is wrong, on every deployment and not just review — tracked as its own task. ## Open questions From b706a9ac690e84f2e71c75cb9f77352ebfb03e3b Mon Sep 17 00:00:00 2001 From: tom <tom@ohhhh.me> Date: Wed, 29 Jul 2026 15:53:00 +0200 Subject: [PATCH 10/13] Enable Prometheus metrics on the review-2 demo variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `review` variant exports them, `review-2` did not, so `/api/metrics` answered 404 there — and the metrics are how a demo shows what the app did server-side, which is the point of deploying one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- deploy/values/review-2/values.yaml.gotmpl | 1 + 1 file changed, 1 insertion(+) diff --git a/deploy/values/review-2/values.yaml.gotmpl b/deploy/values/review-2/values.yaml.gotmpl index 4da42b81c5..bb68860c07 100644 --- a/deploy/values/review-2/values.yaml.gotmpl +++ b/deploy/values/review-2/values.yaml.gotmpl @@ -53,6 +53,7 @@ frontend: ENVS_PRESET: {{ .Values.envsPreset }} NEXT_PUBLIC_APP_ENV: review NEXT_PUBLIC_USE_NEXT_JS_PROXY: true + PROMETHEUS_METRICS_ENABLED: true SKIP_ENVS_VALIDATION: true envFromSecret: NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID: ref+vault://deployment-values/blockscout/dev/review?token_env=VAULT_TOKEN&address=https://vault.k8s.blockscout.com#/NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID From 1ff106180e1af6ccb3fa4103c5f6ebdb3ee98463 Mon Sep 17 00:00:00 2001 From: tom <tom@ohhhh.me> Date: Wed, 29 Jul 2026 15:53:11 +0200 Subject: [PATCH 11/13] Close the demo subtask and split what the answers turned up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three open questions came back. The demo verification is done: the card was confirmed in Telegram and X on an eth-mainnet demo, and the timeout question was settled by the metrics once they worked — on the loaded instance the mandatory call aborted on every crawler request, so the answer is to keep 2 s and change where the data comes from. That leaves two pieces of work rather than one. The backend is building an endpoint shaped for this preview, with switchable ens/metadata/summary preloads, going to staging to be measured — subtask 6, a brief until its shape is agreed. And the Noves decision needs code: the product call is to emit no enhanced description there, but an instance running Noves currently still requests the Blockscout summary and lands on the called-method fallback, which is the option that was rejected — subtask 7, scoped and ready. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../3593-tx-og-title-description/spec.md | 58 ++++++++++---- .../subtasks/05-demo-deploy/spec.md | 28 +++++-- .../subtasks/06-preview-endpoint/brief.md | 58 ++++++++++++++ .../subtasks/07-noves-instances/spec.md | 78 +++++++++++++++++++ 4 files changed, 202 insertions(+), 20 deletions(-) create mode 100644 .agents/tasks/3593-tx-og-title-description/subtasks/06-preview-endpoint/brief.md create mode 100644 .agents/tasks/3593-tx-og-title-description/subtasks/07-noves-instances/spec.md diff --git a/.agents/tasks/3593-tx-og-title-description/spec.md b/.agents/tasks/3593-tx-og-title-description/spec.md index 671bbb2ae1..07676a2d19 100644 --- a/.agents/tasks/3593-tx-og-title-description/spec.md +++ b/.agents/tasks/3593-tx-og-title-description/spec.md @@ -164,9 +164,15 @@ verifies that the preview genuinely works in a real social client. - [x] 2 `[agent]` Share the currency rounding and render interpretation summaries as plain text → `subtasks/02-interpretation-plain-text/` - [x] 3 `[agent]` Derive the three OG description params for a transaction → `subtasks/03-tx-og-description-params/` - [x] 4 `[agent]` Wire the bot-gated fetch and add the `/tx/[hash]` OG templates → `subtasks/04-gssp-wiring-and-templates/` -- [ ] 5 `[agent]` + `[human]` Deploy a demo, then verify the preview manually → `subtasks/05-demo-deploy/` +- [x] 5 `[agent]` + `[human]` Deploy a demo, then verify the preview manually → `subtasks/05-demo-deploy/` — the agent deploys and checks the tags over `curl`; the human confirms the real card in Telegram and - rules on whether the 1 s timeouts hold (Grafana isn't agent-reachable). + rules on the timeouts. Card confirmed in Telegram and X on an eth-mainnet demo; the timeout ruling is + "keep 2 s and move to the endpoint below". +- [ ] 6 `[agent]` Fetch the preview data from the endpoint the backend is building for it → `subtasks/06-preview-endpoint/` + — not scoped yet (`brief.md` only): being built with switchable ens / metadata / summary preloads, going to + staging first. Blocked on the backend. +- [ ] 7 `[agent]` Leave the preview alone on Noves-provider instances → `subtasks/07-noves-instances/` + — Q1's decision; independent of the endpoint, so it can land first. ## Open questions @@ -188,13 +194,15 @@ hit the 1 s timeout anyway and land on whichever fallback we pick — meaning op or 3's behavior at the cost of an extra request per bot hit. - Owner: PM (Ulyana) -- Status: `pending` +- Status: `resolved` - Slack: https://blockscout.slack.com/archives/C03MMUTQDNU/p1785255479554469 (sent 2026-07-28) -- Answer: <decision + date, once resolved> -- **Does not block any subtask.** The answer adds one branch at the front of the action chain and changes - nothing about its structure, the status word, the timestamp, the template shape, or the gSSP gate. - Subtask 3 is built for the Blockscout provider; Noves folds in as an additive commit whenever the - answer arrives. +- Answer (2026-07-29, Ulyana): **option 3** — on Noves-provider instances emit no enhanced description at + all; the preview keeps the generic metadata description. The two providers are mutually exclusive on an + instance, and the deciding argument was that quietly adding social-bot traffic to a third party's slow API + is not ours to do: if Noves wants the richer preview on their instances, they can ask for it, conditional + on their API's performance. +- Implemented as subtask 7 — without it a Noves instance lands on the `called … on …` fallback, which is + option 2, not the decision. ### Q2 — Why do the transaction endpoints take seconds on some instances, and can that change? @@ -206,10 +214,32 @@ roughly one bot request in three there. Raising the timeout is not a fix: crawle seconds, and a card that fails to render is worse than one with the generic description. - Owner: Backend (Core API) -- Status: `pending` +- Status: `resolved` - Slack: https://blockscout.slack.com/archives/C03MMUTQDNU/p1785325326478759 (sent 2026-07-29) -- Answer: <decision + date, once resolved> -- **Blocks nothing to build, but gates the release decision.** The code is complete and degrades correctly: - where the API is fast the preview enhances, where it isn't the card keeps today's generic description. The - question is whether shipping in that state is acceptable or whether the endpoint latency is fixed first — - a call for the PM once the backend team answers. +- Answer (2026-07-29, Victor): known problem on that instance, which is under constant high load — not a + property of the endpoints. Disabling the BENS and metadata preloads would help a little, not enough. + Agreed instead to add an endpoint built for this feature, carrying only the fields the preview needs; + adopting it is subtask 6. Confirmed by sampling two quiet instances with the same method: eth-sepolia + answers `/transactions/:hash` in 0.54 s (p50, 0/25 over 2 s) and gnosis in 0.49 s, against 2.84 s on the + loaded one — and the eth-mainnet demo enhances the card on the first request. +- The **release decision** it was gating is now a straight choice for the PM: ship as is (the preview + enhances where the API is fast and keeps today's card where it isn't) or wait for subtask 6. + +### Q3 — May the preview lose name tags and ENS names? + +The new endpoint is fast partly by skipping the BENS and metadata preloads. Those are what turn an address +into the label the page shows, so without them the fallback action line degrades: the curated name tag gives +way to the plain contract name where there is one (`OKX Labs: DexRouter` → `DexRouter`), and to a shortened +hash where there isn't — an ENS domain always becomes a shortened hash. Only the social-preview text is +affected; the transaction page itself keeps using the full endpoint. + +- Owner: PM (Ulyana) +- Status: `resolved` +- Slack: https://blockscout.slack.com/archives/C03MMUTQDNU/p1785327005926429 (sent 2026-07-29) +- Answer (2026-07-29): dropping ENS and the tags from the OG interpretation is allowed, though Ulyana called + it a degradation. It may not be necessary: per Nikita P. the ENS and metadata preloads are what cost the + second, they can be parallelised, and without third-party calls the endpoint should fit in ~1 s. He is + building it with the preloads **individually switchable** (ens / metadata / summary) and will put it on + staging to measure. +- So the trade-off is now a dial rather than a decision: subtask 6 measures the endpoint with the preloads on + and only turns them off if the numbers demand it. diff --git a/.agents/tasks/3593-tx-og-title-description/subtasks/05-demo-deploy/spec.md b/.agents/tasks/3593-tx-og-title-description/subtasks/05-demo-deploy/spec.md index 75fe77b913..223911b7d2 100644 --- a/.agents/tasks/3593-tx-og-title-description/subtasks/05-demo-deploy/spec.md +++ b/.agents/tasks/3593-tx-og-title-description/subtasks/05-demo-deploy/spec.md @@ -3,7 +3,7 @@ | | | | --- | --- | | Parent spec | [../../spec.md](../../spec.md) — step 5 of #3593 | -| Status | `in progress` | +| Status | `done` | | Size | `medium` | | Sub-branch | — (no code; runs the `deploy-demo` skill) | | PM | Ulyana (task author) | @@ -58,8 +58,10 @@ Any code change. Findings that need one become a follow-up commit against the su ## Task breakdown - [x] 1 `[agent]` Deploy the demo — skill: `deploy-demo` - — https://review-issue-3593.k8s-dev.blockscout.com (the image build needed two retries; the runner's - outbound network was failing on npm and Alpine mirrors, unrelated to the branch). + — two demos, since one instance can't show both sides: `review-issue-3593` on the busy instance (where the + preview degrades, and whose metrics gave the `504` evidence) and `review-2-issue-3593` on eth mainnet + (where it works). Each image build needed a retry — the runner's outbound network keeps failing on the npm + and Alpine mirrors, unrelated to the branch. - inputs: - Preset: `robinhood`. - Deploy from the feature branch `issue-3593`. @@ -69,13 +71,20 @@ Any code change. Findings that need one become a follow-up commit against the su - `curl -A Twitterbot` and `curl -A TelegramBot` against a settled transaction on that chain; confirm the title and the three-part description. - Also hit it with no special UA and confirm the SEO tags are unchanged. -- [ ] 3 `[human]` Paste the link in Telegram and confirm the card really works +- [x] 3 `[human]` Paste the link in Telegram and confirm the card really works + — confirmed in Telegram **and** X on a second demo pointed at eth mainnet + (`review-2-issue-3593.k8s-dev.blockscout.com`), where the endpoints answer fast enough for the enhanced + description to resolve on the first request. - inputs: - The acceptance check: a real card in a real client, not a `curl` assertion. Also the check Ulyana and QA will run themselves. - Worth trying a transaction with a long action string to see where Telegram truncates, and a pending one to see the fallback in the wild. -- [ ] 4 `[human]` Read the metrics and rule on the timeouts +- [x] 4 `[human]` Read the metrics and rule on the timeouts + — ruled: keep 2 s. The metrics did get read in the end, once they worked (see below), and they said the + quiet part out loud — on the busy instance `core:tx` aborted on 6 of 6 bot requests. Raising the timeout + is not the answer, so the backend team is adding an endpoint built for this feature instead; adopting it + is subtask 6. - inputs: - Human because Grafana isn't agent-reachable. - Look at the `api_request_duration_seconds` distribution for `core:tx` / `core:tx_interpretation` and @@ -126,7 +135,14 @@ contention from our own burst.) crawler waits for 4 s. The finding goes to the backend team as an endpoint-latency issue on this instance; until then the preview degrades to the generic description there, which is the designed behavior. -**The metrics this subtask planned to read do not work.** `PROMETHEUS_METRICS_ENABLED` *is* set for review +**The metrics this subtask planned to read did not work, and now do.** The cause was found and fixed on +`main` in #3600 (registry cached on `globalThis`, since Next.js instantiates the module once per server +bundle and the second `register.clear()` unregistered the first's metrics). After merging it, the busy +instance's demo answered the timeout question directly: 6 bot requests produced +`api_request_duration_seconds_count{route="core:tx",code="504"} 6` — every mandatory call aborted at 2 s — +against 3 of 6 succeeding for `core:tx_interpretation` at ~1.3 s each. The original diagnosis follows. + +**The bug as found.** `PROMETHEUS_METRICS_ENABLED` *is* set for review instances (`deploy/values/review/values.yaml.gotmpl`) and `/api/metrics` answers `200` — it returns `404` when disabled — but the registry it exposes only ever contains what **API routes** record. Posting to `/api/monitoring/invalid-api-schema` makes `invalid_api_schema` appear immediately, while diff --git a/.agents/tasks/3593-tx-og-title-description/subtasks/06-preview-endpoint/brief.md b/.agents/tasks/3593-tx-og-title-description/subtasks/06-preview-endpoint/brief.md new file mode 100644 index 0000000000..ffef2e7e37 --- /dev/null +++ b/.agents/tasks/3593-tx-og-title-description/subtasks/06-preview-endpoint/brief.md @@ -0,0 +1,58 @@ +# Fetch the preview data from the endpoint the backend is building for it + +| | | +| --- | --- | +| Parent spec | [../../spec.md](../../spec.md) — step 6 of #3593 | +| Status | `not scoped` (this is a brief; the sub-spec is written just-in-time via `grill-the-task` in subtask mode) | +| Depends on | subtask 4 and the backend endpoint | + +## Why this subtask exists + +Subtask 4 fetches `core:tx` and `core:tx_interpretation` in parallel to build the OG description. That works +where the API is fast and fails where it isn't: on a heavily loaded instance the mandatory `core:tx` call +aborted at the 2 s timeout on 6 of 6 crawler requests (in-cluster +`api_request_duration_seconds{route="core:tx",code="504"} 6`), so the card kept the generic description. +Raising the timeout is not available — crawlers wait single-digit seconds, and a card that fails to render +is worse than a plain one. + +Rather than trade the timeout against the failure rate, the backend team agreed to add an endpoint shaped for +this feature: only the fields the preview needs, so it can answer fast. This subtask switches the page to it. + +## What the frontend asked for + +Sent in the thread below, so the endpoint can be checked against it when it lands: + +- `status` (`ok` / `error` / `null`), `timestamp` — the two mandatory fields. +- `method`, `from`, `to` — only for the fallback action branch, and from the addresses only what the page + labels them with (metadata name tag, `ens_domain_name`, `name`, else the shortened hash). +- The interpretation summary **in its current shape** (`summary_template` + `summary_template_variables`) — + the text is rendered on the frontend so it matches the page's subheading exactly. +- Ideally the summary in the *same* response, so the page makes one request with one timeout instead of two. +- Target: comfortably under the 2 s timeout on a first request, including on a loaded instance. + +## What this subtask will have to do + +Rough shape, to be confirmed when the endpoint's contract is known: + +- Add the resource via the `add-api-resource` skill, with its response type. +- Replace the two `fetchApi` calls in `src/pages/tx/[hash].tsx` with the one call; keep the bot gate, the + multichain guard, and the timeout constant. +- Adapt `getOgDescriptionParams` (`src/slices/tx/utils/`) to the new payload. Its logic is unchanged — status + map, UTC timestamp, action chain — only the input shape moves, and its spec file covers the branches. +- Decide what happens on instances whose backend is older than the endpoint: keep the two-request path as a + fallback, or gate the feature on the endpoint's presence. This is the main open design question and needs + the backend's release plan. + +## Where it stands (2026-07-29) + +Nikita P. is building the endpoint with the **ens / metadata / summary preloads individually switchable**, and +will put it on staging to measure. His read: the ENS and metadata preloads are what cost the second, they can +be parallelised, and without third-party calls the response should fit in ~1 s. + +That turns the name-vs-latency trade-off (parent Q3, resolved) into a dial: Ulyana allowed dropping ENS and +tags from the OG text but called it a degradation, so this subtask should **measure with the preloads on +first** and only switch them off if the numbers demand it. The same paced sampling method as before applies — +the script from the earlier measurements takes a host as input. + +Blocked on the endpoint reaching staging. Thread: +https://blockscout.slack.com/archives/C03MMUTQDNU/p1785325326478759 diff --git a/.agents/tasks/3593-tx-og-title-description/subtasks/07-noves-instances/spec.md b/.agents/tasks/3593-tx-og-title-description/subtasks/07-noves-instances/spec.md new file mode 100644 index 0000000000..e2a4906fd1 --- /dev/null +++ b/.agents/tasks/3593-tx-og-title-description/subtasks/07-noves-instances/spec.md @@ -0,0 +1,78 @@ +# Leave the preview alone on Noves-provider instances + +| | | +| --- | --- | +| Parent spec | [../../spec.md](../../spec.md) — step 7 of #3593 | +| Status | `ready` | +| Size | `small` | +| Sub-branch | — (single commit on `issue-3593`) | +| PM | Ulyana (task author) | +| Designer | — | +| Backend | — | +| Depends on | subtasks 3 and 4 | + +## Context & goal + +An instance runs **one** interpretation provider: with `provider === 'noves'` the transaction page renders +Noves' prose and Blockscout's own summary is not used at all. Parent [Q1](../../spec.md#q1--what-should-the-og-description-show-on-noves-provider-instances) +settled what the preview should do there: **nothing** — keep the generic metadata description, and don't call +the Noves API for it. The reasoning was that quietly pointing social-bot traffic at a third party's slow API +isn't ours to decide; if Noves wants the richer card, they can ask, conditional on their API's performance. + +Today the code does something else. `config.features.txInterpretation.isEnabled` is `true` on a Noves +instance, so `getServerSideProps` still requests `core:tx_interpretation` — the *Blockscout* summary endpoint, +which has nothing to serve there — gets an empty `summaries` array, and falls through to the +`called … on …` branch. That is Q1's option 2, not the decision. + +## Functional requirements + +- On an instance whose interpretation provider is `noves`, `/tx/[hash]` emits **no** enhanced OG description: + `apiData` stays `null` and the card keeps the generic `<meta name="description">` text. +- Neither `core:tx` nor `core:tx_interpretation` is requested on those instances — the whole point is to add + no crawler-driven load, and with no action available the other two params are useless anyway. +- The OG **title** is unaffected: it carries the short hash on every instance, since it needs no API data. +- No behavior change where the provider is `blockscout`, and none where the feature is off entirely. + +### Verification + +`curl -A Twitterbot` against a dev server on a Noves-provider preset shows the generic `og:description` and +the short-hash `og:title`, and the server logs show **no** API request for the transaction. + +## Data & API + +None — this only removes requests. + +## UI inventory + +- `src/pages/tx/[hash].tsx` — the gSSP gate. +- `src/slices/tx/utils/get-og-description-params.ts` + its spec — the action chain's front door. + +## Out of scope + +- Fetching and rendering Noves prose (`core:noves_transaction`, `createNovesSummaryObject`) — that is the + option Q1 rejected. Should Noves later ask for it, it slots in as one more branch here. +- The transaction page's own rendering, which keeps using Noves as it does today. + +## Task breakdown + +- [ ] 1 `[agent]` Skip the enhanced description when the provider is `noves` + - inputs: + - Read the provider the way the rest of the code does: `getFeaturePayload(config.features.txInterpretation)?.provider` + (`src/config/utils/features`), which is `undefined` when the feature is off. `TxSubHeading.tsx:39` is + the reference for the same check on the client. + - Gate it in **both** places, because they answer different questions: the gSSP gate decides whether to + spend requests, and `getOgDescriptionParams` decides whether an action exists. The util already returns + `null` without an action, so the second guard is what makes the first one's absence harmless. + - Extend the feature check already in `getActionText` rather than adding a second branch — the condition + becomes "interpretation on **and** provider is Blockscout". +- [ ] 2 `[agent]` Cover it in the existing specs + - inputs: + - `get-og-description-params.spec.ts` — a case with the Noves provider returning `null`, via + `withEnvs` with `NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER` set to `noves` (the existing + `ENVS_MAP.txInterpretation` preset sets it to `blockscout`, so this one needs its own override). + - Nothing to add for the gSSP gate; it has no unit test today and testing Next.js plumbing would only + assert the mock. + +## Open questions + +None. From fcaa713c75f8630a34f9115e1e8296c9c65d261b Mon Sep 17 00:00:00 2001 From: tom <tom@ohhhh.me> Date: Wed, 29 Jul 2026 18:22:03 +0200 Subject: [PATCH 12/13] Emit the enhanced OG description only where a Blockscout summary exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An instance runs a single interpretation provider. On a Noves one the transaction page renders Noves' prose and the Blockscout summary endpoint has nothing to serve, so the preview fell through to the `called … on …` line — the option the PM ruled against, since pointing crawler traffic at a third party's slow API is not ours to decide. Both gates now require the provider to be `blockscout`: the util has no action without it, and the page makes no request at all. That also drops the `core:tx` request on instances with the feature off entirely, where it could never produce a description — and `none` is the provider's default. --- .../tasks/3593-tx-og-title-description/spec.md | 16 ++++++++++------ .../04-gssp-wiring-and-templates/spec.md | 4 ++-- .../subtasks/07-noves-instances/spec.md | 16 ++++++++++++---- src/pages/tx/[hash].tsx | 9 +++++---- .../tx/utils/get-og-description-params.spec.ts | 9 +++++++++ src/slices/tx/utils/get-og-description-params.ts | 3 ++- 6 files changed, 40 insertions(+), 17 deletions(-) diff --git a/.agents/tasks/3593-tx-og-title-description/spec.md b/.agents/tasks/3593-tx-og-title-description/spec.md index 07676a2d19..82f8332d23 100644 --- a/.agents/tasks/3593-tx-og-title-description/spec.md +++ b/.agents/tasks/3593-tx-og-title-description/spec.md @@ -52,7 +52,7 @@ crawlers don't run JS and `metadata.update()` only ever touches `<title>` and `< | condition | action text | | --- | --- | - | interpretation feature off | none → fallback OG values (and `/summary` is not requested at all) | + | interpretation feature off, or its provider is Noves | none → fallback OG values, and neither request is made — see the fetch plan below and Q1 | | feature on, summary passes `checkSummary` | the summary rendered as plain text | | feature on, no usable summary, has `method` + `from` + `to` | `0xab...cd called\|failed to call M on 0xef...12` | | feature on, no usable summary, missing any of those | none → fallback OG values | @@ -111,9 +111,13 @@ grilling — no backend release to wait on, nothing to add via `add-api-resource **Fetch plan** — in `/tx/[hash]`'s `getServerSideProps`, gated on `config.metadata.og.enhancedDataEnabled && detectBotRequest(req)?.type === 'social_preview'` **and** -`!config.features.multichain.isEnabled`. The two requests run in parallel with a **2 s** timeout each -(social-bot traffic is low per Grafana history); `/summary` is skipped entirely when -`config.features.txInterpretation.isEnabled` is false. +`!config.features.multichain.isEnabled` **and** the interpretation provider being `blockscout`. The two +requests run in parallel with a **2 s** timeout each (social-bot traffic is low per Grafana history). + +The provider gate covers both requests, not just `/summary`: the description always needs an action, and +without the Blockscout summary there is no action to be had — with the feature off there is none at all, and +on a Noves instance its prose was ruled out (Q1). Fetching only to discard the result would spend a crawler's +seconds for nothing. Why 2 s rather than the 500 ms–1 s the other routes use: both endpoints compute on the first request for a given transaction and cache the result, and a crawler is always that first request. Measured on eth mainnet @@ -171,8 +175,8 @@ verifies that the preview genuinely works in a real social client. - [ ] 6 `[agent]` Fetch the preview data from the endpoint the backend is building for it → `subtasks/06-preview-endpoint/` — not scoped yet (`brief.md` only): being built with switchable ens / metadata / summary preloads, going to staging first. Blocked on the backend. -- [ ] 7 `[agent]` Leave the preview alone on Noves-provider instances → `subtasks/07-noves-instances/` - — Q1's decision; independent of the endpoint, so it can land first. +- [x] 7 `[agent]` Leave the preview alone on Noves-provider instances → `subtasks/07-noves-instances/` + — Q1's decision; landed ahead of the endpoint, since it is independent of it. ## Open questions diff --git a/.agents/tasks/3593-tx-og-title-description/subtasks/04-gssp-wiring-and-templates/spec.md b/.agents/tasks/3593-tx-og-title-description/subtasks/04-gssp-wiring-and-templates/spec.md index f1f01b39c3..c9639b4c61 100644 --- a/.agents/tasks/3593-tx-og-title-description/subtasks/04-gssp-wiring-and-templates/spec.md +++ b/.agents/tasks/3593-tx-og-title-description/subtasks/04-gssp-wiring-and-templates/spec.md @@ -39,8 +39,8 @@ or not at all. `config.metadata.og.enhancedDataEnabled`, `detectBotRequest(ctx.req)?.type === 'social_preview'`, `!config.features.multichain.isEnabled`, and `'props' in baseResponse`. - The two requests run in parallel, **2 s** timeout each (see the parent spec's fetch plan for the - measurements behind the number); `core:tx_interpretation` is not requested at all when - `config.features.txInterpretation.isEnabled` is false. + measurements behind the number), and neither is made unless the interpretation provider is `blockscout` + (tightened in subtask 7, which is where the reasoning lives). - The page passes `apiData` through `PageNextJs` so `PageMetadata` can reach it. - Unchanged for everyone who isn't a social-preview bot: same SEO tags, no extra requests, no added latency. - `og:title` carries the short hash for **all** requests including search engines — it needs no API data, so diff --git a/.agents/tasks/3593-tx-og-title-description/subtasks/07-noves-instances/spec.md b/.agents/tasks/3593-tx-og-title-description/subtasks/07-noves-instances/spec.md index e2a4906fd1..e09db89e3f 100644 --- a/.agents/tasks/3593-tx-og-title-description/subtasks/07-noves-instances/spec.md +++ b/.agents/tasks/3593-tx-og-title-description/subtasks/07-noves-instances/spec.md @@ -3,7 +3,7 @@ | | | | --- | --- | | Parent spec | [../../spec.md](../../spec.md) — step 7 of #3593 | -| Status | `ready` | +| Status | `done` | | Size | `small` | | Sub-branch | — (single commit on `issue-3593`) | | PM | Ulyana (task author) | @@ -31,7 +31,10 @@ which has nothing to serve there — gets an empty `summaries` array, and falls - Neither `core:tx` nor `core:tx_interpretation` is requested on those instances — the whole point is to add no crawler-driven load, and with no action available the other two params are useless anyway. - The OG **title** is unaffected: it carries the short hash on every instance, since it needs no API data. -- No behavior change where the provider is `blockscout`, and none where the feature is off entirely. +- No behavior change where the provider is `blockscout`. +- Where the feature is off entirely the output is unchanged (it already had no action, so no enhanced + description), but the gate stops requesting `core:tx` there too — it could never produce a description, and + the provider defaults to `none`, so that request was pure waste on the majority of instances. ### Verification @@ -55,7 +58,11 @@ None — this only removes requests. ## Task breakdown -- [ ] 1 `[agent]` Skip the enhanced description when the provider is `noves` +- [x] 1 `[agent]` Skip the enhanced description when the provider is `noves` + — `getActionText` and `[hash].tsx`'s gate both now require `provider === 'blockscout'`, so a Noves instance + (and an instance with the feature off) makes no request at all. Verified with `curl -A Twitterbot` on the eth preset with the provider overridden: generic + `og:description`, short-hash title, no transaction request in the server log (103 ms of application code), + against `Success · Transfer 0.013 ETH to … · Jul 29, 2026 14:04 UTC` on the same hash with `blockscout`. - inputs: - Read the provider the way the rest of the code does: `getFeaturePayload(config.features.txInterpretation)?.provider` (`src/config/utils/features`), which is `undefined` when the feature is off. `TxSubHeading.tsx:39` is @@ -65,7 +72,8 @@ None — this only removes requests. `null` without an action, so the second guard is what makes the first one's absence harmless. - Extend the feature check already in `getActionText` rather than adding a second branch — the condition becomes "interpretation on **and** provider is Blockscout". -- [ ] 2 `[agent]` Cover it in the existing specs +- [x] 2 `[agent]` Cover it in the existing specs + — one case in `get-og-description-params.spec.ts` under "gives up when a part is missing". - inputs: - `get-og-description-params.spec.ts` — a case with the Noves provider returning `null`, via `withEnvs` with `NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER` set to `noves` (the existing diff --git a/src/pages/tx/[hash].tsx b/src/pages/tx/[hash].tsx index b6ece6de54..21ab640a10 100644 --- a/src/pages/tx/[hash].tsx +++ b/src/pages/tx/[hash].tsx @@ -14,6 +14,7 @@ import fetchApi from 'src/server/utils/fetchApi'; import getOgDescriptionParams from 'src/slices/tx/utils/get-og-description-params'; import config from 'src/config'; +import { getFeaturePayload } from 'src/config/utils/features'; import getQueryParamString from 'src/shared/router/get-query-param-string'; import { SECOND } from 'src/toolkit/utils/consts'; @@ -46,14 +47,14 @@ export const getServerSideProps: GetServerSideProps<Props<typeof pathname>> = as // and the SEO tags this route emits need no API data. const isSocialPreviewBot = config.metadata.og.enhancedDataEnabled && detectBotRequest(ctx.req)?.type === 'social_preview'; - if ('props' in baseResponse && !config.features.multichain.isEnabled && isSocialPreviewBot) { + const hasBlockscoutInterpretation = getFeaturePayload(config.features.txInterpretation)?.provider === 'blockscout'; + + if ('props' in baseResponse && !config.features.multichain.isEnabled && isSocialPreviewBot && hasBlockscoutInterpretation) { const hash = getQueryParamString(ctx.query.hash); const [ txData, interpretationData ] = await Promise.all([ fetchApi({ resource: 'core:tx', pathParams: { hash }, timeout: API_TIMEOUT }), - config.features.txInterpretation.isEnabled ? - fetchApi({ resource: 'core:tx_interpretation', pathParams: { hash }, timeout: API_TIMEOUT }) : - undefined, + fetchApi({ resource: 'core:tx_interpretation', pathParams: { hash }, timeout: API_TIMEOUT }), ]); (await baseResponse.props).apiData = getOgDescriptionParams(txData, interpretationData); diff --git a/src/slices/tx/utils/get-og-description-params.spec.ts b/src/slices/tx/utils/get-og-description-params.spec.ts index 98cf2ef886..76d8193f32 100644 --- a/src/slices/tx/utils/get-og-description-params.spec.ts +++ b/src/slices/tx/utils/get-og-description-params.spec.ts @@ -64,6 +64,15 @@ describe('gives up when a part is missing', () => { const { 'default': getOgDescriptionParams } = await import('./get-og-description-params'); expect(getOgDescriptionParams(base, TX_INTERPRETATION)).toBeNull(); }); + + it('the provider is Noves, whose page text this summary is not', async() => { + const params = await withEnvs([ [ 'NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER', 'noves' ] ], async() => { + const { 'default': getOgDescriptionParams } = await import('./get-og-description-params'); + return getOgDescriptionParams(base, TX_INTERPRETATION); + }); + + expect(params).toBeNull(); + }); }); describe('falls back to the called-method line', () => { diff --git a/src/slices/tx/utils/get-og-description-params.ts b/src/slices/tx/utils/get-og-description-params.ts index f9515673cd..1481048f50 100644 --- a/src/slices/tx/utils/get-og-description-params.ts +++ b/src/slices/tx/utils/get-og-description-params.ts @@ -7,6 +7,7 @@ import addressToPlainText from 'src/features/tx-interpretation/common/utils/addr import summaryToPlainText from 'src/features/tx-interpretation/common/utils/summary-to-plain-text'; import config from 'src/config'; +import { getFeaturePayload } from 'src/config/utils/features'; import dayjs from 'src/shared/date-and-time/dayjs'; // Already `MMM D, YYYY H:mm` through the locale overrides in the dayjs module. @@ -37,7 +38,7 @@ function getStatusText(status: schemas['Transaction']['status'] | undefined) { } function getActionText(tx: schemas['TransactionResponse'] | undefined, interpretation: TxInterpretationResponse | undefined) { - if (!config.features.txInterpretation.isEnabled) { + if (getFeaturePayload(config.features.txInterpretation)?.provider !== 'blockscout') { return; } From c248b585b65ad6c4a47dc0b5f97f73aa1eefa882 Mon Sep 17 00:00:00 2001 From: tom <tom@ohhhh.me> Date: Wed, 29 Jul 2026 18:29:37 +0200 Subject: [PATCH 13/13] Recognize the WhatsApp, Discord and LinkedIn preview crawlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three read OG tags, none of them was in `detectBotRequest`, so their crawlers were treated as ordinary visitors and every route that enhances its preview for social bots — transaction, address, token, NFT instance, stats — served them the generic description instead. Reported for WhatsApp from a real card. Discord and LinkedIn match the `…bot` suffix rather than the bare product name, because both also ship an in-app browser whose user agent carries that name and whose requests are real visitors. --- cspell.jsonc | 1 + src/server/utils/detectBotRequest.ts | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/cspell.jsonc b/cspell.jsonc index cef792f90e..bfdbb5de47 100644 --- a/cspell.jsonc +++ b/cspell.jsonc @@ -159,6 +159,7 @@ "LCIA", "libc", "libp", + "linkedinbot", "Liquality", "llms", "lokijs", diff --git a/src/server/utils/detectBotRequest.ts b/src/server/utils/detectBotRequest.ts index 2b205a8a57..d8152539e3 100644 --- a/src/server/utils/detectBotRequest.ts +++ b/src/server/utils/detectBotRequest.ts @@ -2,7 +2,7 @@ import type { IncomingMessage } from 'http'; -type SocialPreviewBot = 'twitter' | 'facebook' | 'telegram' | 'slack'; +type SocialPreviewBot = 'twitter' | 'facebook' | 'telegram' | 'slack' | 'whatsapp' | 'discord' | 'linkedin'; type SearchEngineBot = 'google' | 'bing' | 'yahoo' | 'duckduckgo'; type ReturnType = { @@ -36,6 +36,20 @@ export default function detectBotRequest(req: IncomingMessage): ReturnType { return { type: 'social_preview', bot: 'slack' }; } + if (userAgent.toLowerCase().includes('whatsapp')) { + return { type: 'social_preview', bot: 'whatsapp' }; + } + + // These two match the `…bot` suffix rather than the bare product name: both ship an in-app browser whose + // user agent carries the same name, and those are real visitors, not crawlers. + if (userAgent.toLowerCase().includes('discordbot')) { + return { type: 'social_preview', bot: 'discord' }; + } + + if (userAgent.toLowerCase().includes('linkedinbot')) { + return { type: 'social_preview', bot: 'linkedin' }; + } + if (userAgent.toLowerCase().includes('googlebot')) { return { type: 'search_engine', bot: 'google' }; }