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:
+``. 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
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..82f8332d23
--- /dev/null
+++ b/.agents/tasks/3593-tx-og-title-description/spec.md
@@ -0,0 +1,249 @@
+# Generate transaction OG title and description from transaction details
+
+| | |
+| --- | --- |
+| Issue | https://github.com/blockscout/frontend/issues/3593 |
+| PR | https://github.com/blockscout/frontend/pull/3596 (draft) |
+| Status | `in progress` |
+| 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 ``.
+
+## 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 `` value.
+- **The SEO tags are unchanged.** `` keeps the full hash; `` 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, 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 |
+
+- **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` / `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 ``; see subtask 1's spec.
+
+### Verification
+
+- `curl -A Twitterbot http://localhost:3000/tx/` 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, except the
+ `opengraph.description` fallback introduced in subtask 1.
+- 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
+
+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:
+
+
+
+ ```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` **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
+(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`),
+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 `` 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 `` or `` for `/tx/[hash]`.
+- New env vars, Mixpanel events, design work.
+
+## Task breakdown
+
+- [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/`
+- [x] 4 `[agent]` Wire the bot-gated fetch and add the `/tx/[hash]` OG templates → `subtasks/04-gssp-wiring-and-templates/`
+- [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 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.
+- [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
+
+### 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: `resolved`
+- Slack: https://blockscout.slack.com/archives/C03MMUTQDNU/p1785255479554469 (sent 2026-07-28)
+- 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?
+
+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: `resolved`
+- Slack: https://blockscout.slack.com/archives/C03MMUTQDNU/p1785325326478759 (sent 2026-07-29)
+- 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/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..d45fb1228a
--- /dev/null
+++ b/.agents/tasks/3593-tx-og-title-description/subtasks/01-og-template-layer/spec.md
@@ -0,0 +1,103 @@
+# Turn the `og` block into a `default`/`enhanced` template layer
+
+| | |
+| --- | --- |
+| Parent spec | [../../spec.md](../../spec.md) — step 1 of #3593 |
+| Status | `done` |
+| 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`. 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
+ the route has no `hash` query param, so `compileValue`'s truthiness check behaves.
+- 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
+ `` — 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
+
+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
+
+- [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?: ; description?: ; 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.
+- [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.
+- [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.
+- [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 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
+
+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..0021dfddaf
--- /dev/null
+++ b/.agents/tasks/3593-tx-og-title-description/subtasks/02-interpretation-plain-text/spec.md
@@ -0,0 +1,123 @@
+# Share the currency rounding and render interpretation summaries as plain text
+
+| | |
+| --- | --- |
+| Parent spec | [../../spec.md](../../spec.md) — step 2 of #3593 |
+| Status | `done` |
+| 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` |
+
+- `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`.
+
+
+
+## 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.
+- `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
+
+- 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
+
+- [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 `{ formatCurrencyValue(value) + ' ' }` —
+ the trailing space is the component's spacing concern and stays there, out of the shared function.
+- [x] 2 `[agent]` Add `addressToPlainText`
+ — `common/utils/address-to-plain-text.ts` over the extracted `slices/address/utils/get-address-name.ts`.
+ - inputs:
+ - 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.
+- [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
+ `../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.
+- [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 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
+
+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..a8a7ae36c1
--- /dev/null
+++ b/.agents/tasks/3593-tx-og-title-description/subtasks/03-tx-og-description-params/spec.md
@@ -0,0 +1,97 @@
+# Derive the three OG description params for a transaction
+
+| | |
+| --- | --- |
+| Parent spec | [../../spec.md](../../spec.md) — step 3 of #3593 |
+| Status | `done` |
+| 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
+
+- [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).
+- [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`); 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..c9639b4c61
--- /dev/null
+++ b/.agents/tasks/3593-tx-og-title-description/subtasks/04-gssp-wiring-and-templates/spec.md
@@ -0,0 +1,118 @@
+# Wire the bot-gated fetch and add the `/tx/[hash]` OG templates
+
+| | |
+| --- | --- |
+| Parent spec | [../../spec.md](../../spec.md) — step 4 of #3593 |
+| Status | `done` |
+| 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 `` and ``, 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: {
+ enhanced: '%tx_status% · %tx_action% · %tx_timestamp%',
+ },
+ }
+ ```
+
+ 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, **2 s** timeout each (see the parent spec's fetch plan for the
+ 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
+ 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
+
+- [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.
+- [x] 2 `[agent]` Add the OG templates to the `/tx/[hash]` entry
+ - inputs:
+ - Templates exactly as above.
+ - Check `cspell.jsonc` doesn't trip on the middle dot; add nothing to the dictionary unless it does.
+- [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(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: 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` generic the way the token page does,
+ and pass `apiData={ props.apiData }` to `PageNextJs`.
+- [x] 4 `[agent]` Verify locally
+ — all four cases confirmed on the `staging` preset; see the note below.
+ - inputs:
+ - `pnpm dev:preset staging`, then `curl -A Twitterbot 'http://localhost:3000/tx/'` 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, `` and
+ `` 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.
+- [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 ``
+keeps the full hash and `` 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
+
+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..223911b7d2
--- /dev/null
+++ b/.agents/tasks/3593-tx-og-title-description/subtasks/05-demo-deploy/spec.md
@@ -0,0 +1,157 @@
+# Deploy a demo, then verify the preview manually
+
+| | |
+| --- | --- |
+| Parent spec | [../../spec.md](../../spec.md) — step 5 of #3593 |
+| Status | `done` |
+| 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 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 top bucket or returning `504`, the timeout
+ needs revisiting 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
+
+- [x] 1 `[agent]` Deploy the demo — skill: `deploy-demo`
+ — 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`.
+- [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.
+ - Also hit it with no special UA and confirm the SEO tags are unchanged.
+- [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.
+- [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
+ 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)
+
+
+
+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, `` keeps the full one, and `` 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 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
+`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
+
+None.
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..e09db89e3f
--- /dev/null
+++ b/.agents/tasks/3593-tx-og-title-description/subtasks/07-noves-instances/spec.md
@@ -0,0 +1,86 @@
+# Leave the preview alone on Noves-provider instances
+
+| | |
+| --- | --- |
+| Parent spec | [../../spec.md](../../spec.md) — step 7 of #3593 |
+| Status | `done` |
+| 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 `` 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`.
+- 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
+
+`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
+
+- [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
+ 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".
+- [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
+ `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.
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/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
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 { value + ' ' };
}
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 { numberString + ' ' };
+ return { formatCurrencyValue(value) + ' ' };
}
case 'timestamp': {
return { dayjs(Number(value) * SECOND).format('MMM DD YYYY') };
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/pages/tx/[hash].tsx b/src/pages/tx/[hash].tsx
index 6915289a7c..21ab640a10 100644
--- a/src/pages/tx/[hash].tsx
+++ b/src/pages/tx/[hash].tsx
@@ -1,19 +1,38 @@
// 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 { getFeaturePayload } from 'src/config/utils/features';
+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) => {
+const Page: NextPage> = (props: Props) => {
return (
-
+
);
@@ -21,4 +40,25 @@ const Page: NextPage = (props: Props) => {
export default Page;
-export { tx as getServerSideProps } from 'src/server/getServerSideProps/main';
+export const getServerSideProps: GetServerSideProps> = async(ctx) => {
+ const baseResponse = await gSSP.tx(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';
+
+ 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 }),
+ fetchApi({ resource: 'core:tx_interpretation', pathParams: { hash }, timeout: API_TIMEOUT }),
+ ]);
+
+ (await baseResponse.props).apiData = getOgDescriptionParams(txData, interpretationData);
+ }
+
+ return baseResponse;
+};
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' };
}
diff --git a/src/shell/metadata/__snapshots__/generate.spec.ts.snap b/src/shell/metadata/__snapshots__/generate.spec.ts.snap
index 9a7996bd42..180e579542 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,9 +34,9 @@ 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",
+ "title": "Blockscout transaction 0x62...3193 | Blockscout",
},
"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 | undefined>) {
+import type { TemplateValue } from './types';
+
+export default function compileValue(template: TemplateValue, params: Record | 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..8d036988d4 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';
@@ -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' });
@@ -41,3 +58,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..287d2c31de 100644
--- a/src/shell/metadata/generate.ts
+++ b/src/shell/metadata/generate.ts
@@ -3,12 +3,13 @@
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';
import config from 'src/config';
+import shortenString from 'src/shared/texts/shorten-string';
import { castToString } from 'src/toolkit/utils/guards';
@@ -18,9 +19,21 @@ 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;
+
+function withInheritedDefault(template: OgTemplateValue, metadataTemplate: TemplateValue): TemplateValue {
+ return {
+ 'default': template['default'] ?? metadataTemplate['default'],
+ enhanced: template.enhanced,
+ };
+}
+
export default function generate(route: RouteParams, apiData: ApiData = 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,12 +42,16 @@ export default function generate(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' : '';
- 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 });
@@ -42,9 +59,9 @@ export default function generate(route: Rout
title: title,
description,
opengraph: {
- title: title,
- description: TEMPLATE_MAP[route.pathname].og?.description,
- imageUrl: TEMPLATE_MAP[route.pathname].og?.image,
+ 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),
jsonLd,
diff --git a/src/shell/metadata/templates/index.ts b/src/shell/metadata/templates/index.ts
index 6456610f8a..5e2c11070c 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 { OgTemplateValue, 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?: OgTemplateValue;
+ description?: OgTemplateValue;
+ image?: string;
};
}
const OG_ROOT_PAGE = {
- description: config.metadata.og.description,
+ description: { 'default': config.metadata.og.description },
image: config.metadata.og.imageUrl,
};
@@ -88,6 +85,14 @@ export const TEMPLATE_MAP: Record = {
'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 ad21099a7d..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 '/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 :
@@ -22,6 +25,15 @@ export type ApiData =
export type StructuredData = WithContext | WithContext;
+// The `enhanced` variant is used only when every placeholder in it resolves to a truthy param.
+export interface TemplateValue {
+ 'default': string;
+ enhanced?: string;
+}
+
+// An OG template may omit its `default` and inherit the route's metadata one.
+export type OgTemplateValue = Partial;
+
export interface Metadata {
title: string;
description: string;
diff --git a/src/slices/address/components/entity/AddressEntity.tsx b/src/slices/address/components/entity/AddressEntity.tsx
index 20314a3631..08dc69f55a 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';
@@ -146,15 +145,7 @@ export type ContentProps = Omit & Pick {
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> & { 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;
+}
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..76d8193f32
--- /dev/null
+++ b/src/slices/tx/utils/get-og-description-params.spec.ts
@@ -0,0 +1,88 @@
+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();
+ });
+
+ 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', () => {
+ 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..1481048f50
--- /dev/null
+++ b/src/slices/tx/utils/get-og-description-params.ts
@@ -0,0 +1,80 @@
+// 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 { 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.
+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 (getFeaturePayload(config.features.txInterpretation)?.provider !== 'blockscout') {
+ 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,
+ };
+}