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 `<meta description>`. + +## Functional requirements + +- **OG title** — `%chain_name% transaction %hash_short%` plus the ` | Blockscout` postfix (still gated by + `promoteBlockscoutInTitle`). Needs no API data, so it is served to every crawler including search engines. + `hash_short` is `shortenString(hash, 8)` → `0xda...671a`. +- **OG description** — `%tx_status% · %tx_action% · %tx_timestamp%`, populated only for social-preview bots. + When any part is missing it falls back to the page's `<meta description>` value. +- **The SEO tags are unchanged.** `<title>` keeps the full hash; `<meta name="description">` keeps the + generic copy. OG title and OG description resolve independently of each other, and `generate()` receives + no notion of bot type — the presence of `apiData` is the only signal, and `apiData` is only populated for + social-preview bots. +- **Status** — `ok` → `Success`, `error` → `Failed`, `null` → `Pending`, `undefined` → nothing. + `Failed` is the bare word; no revert reason is appended (the UI keeps it in a tooltip and a collapsible). +- **Timestamp** — `MMM D, YYYY H:mm UTC`, always UTC (a server-rendered tag has no user timezone). +- **Action** — mirrors `TxSubHeading`'s chain exactly: + + | condition | action text | + | --- | --- | + | interpretation feature off, 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 `<meta name="description">`; see subtask 1's spec. + +### Verification + +- `curl -A Twitterbot http://localhost:3000/tx/<hash>` shows the new `og:title` / `og:description`; the same + URL without a bot UA shows the unchanged SEO tags. +- `src/shell/metadata/__snapshots__/generate.spec.ts.snap` — existing entries unchanged, 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: + + <!-- cspell:ignore SPERPS --> + + ```json + { "data": { "summaries": [ { + "summary_template": "{action_type} {outgoing_amount} {outgoing_token} for {incoming_amount} {incoming_token}", + "summary_template_variables": { + "action_type": { "type": "string", "value": "Swap" }, + "outgoing_amount": { "type": "currency", "value": "2918443.532640630294962772" }, + "outgoing_token": { "type": "token", "value": { "symbol": "SPERPS", "…": "…" } }, + "incoming_amount": { "type": "currency", "value": "0.015575428823202624" }, + "incoming_token": { "type": "token", "value": { "symbol": "WETH", "…": "…" } } + } } ] }, "success": true } + ``` + + Replaying the UI's rounding over that gives `Swap 2.92M SPERPS for 0.016 WETH` — matching the issue. +- Note that a plain native transfer (`method: null`) still gets a usable summary from `/summary`, so on + instances with the interpretation feature on the action resolves nearly always. + +**Fetch plan** — in `/tx/[hash]`'s `getServerSideProps`, gated on +`config.metadata.og.enhancedDataEnabled && detectBotRequest(req)?.type === 'social_preview'` **and** +`!config.features.multichain.isEnabled` **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 `<meta>` tags only, so the usual scaffold → style split does not +apply. Subtasks 1–4 are fully `[agent]`; subtask 5 is mixed — the agent deploys the demo, and the human +verifies that the preview genuinely works in a real social client. + +- Route in scope: `/tx/[hash]` — `src/pages/tx/[hash].tsx`, template at + `src/shell/metadata/templates/index.ts:82`. +- The action's source of truth for text and fallback order is + `src/slices/tx/pages/details/TxSubHeading.tsx` and + `src/features/tx-interpretation/common/components/TxInterpretation.tsx`. + +## Out of scope + +- **`og:image`** — text only. Its current absence is deliberate: `OG_ROOT_PAGE` is attached to list/root + pages, and every entity detail route (address, token, block, tx) has no `og` entry. On a + `summary_large_image` card a generic banner would push the new description below itself. +- **Multichain** `/chain/[chain_slug_or_id]/tx/[hash]` — excluded by the same `!multichain.isEnabled` guard + the token page already uses; multichain routes resolve their API base per-chain through + `factoryMultichain`, and the server-side `fetchApi`/`buildUrl` path isn't wired for that. +- **`/cc/tx/[hash]` and `/cross-chain-tx/[id]`** — different entities with different statuses and no + interpretation summary; they'd need their own product decision. +- Changing the SEO `<title>` or `<meta description>` for `/tx/[hash]`. +- New env vars, Mixpanel events, design work. + +## Task breakdown + +- [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 + `<meta name="description">` — the preview a social client renders is unchanged, but five entries in + `generate.spec.ts.snap` move from `undefined` to that text. `OG_ROOT_PAGE` routes are untouched (their + compiled description stays `''`, so the tag is still omitted). + +## Data & API + +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?: <that shape>; description?: <that shape>; image?: string }`. + - Migrate `OG_ROOT_PAGE` to `{ description: { 'default': config.metadata.og.description }, image: config.metadata.og.imageUrl }`. + It is referenced by many routes; the value must stay identical. +- [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`. + +<!-- cspell:ignore SPERPS --> + +## Data & API + +None — operates on `TxInterpretationSummary` (`src/features/tx-interpretation/common/types/api.ts`), which +covers all ten variable types. + +## UI inventory + +- `src/features/tx-interpretation/common/components/TxInterpretation.tsx` — its `currency` case now + delegates. No other change; do not restructure the component to be render-agnostic. +- New files in `src/features/tx-interpretation/common/utils/` (kebab-case, matching siblings elsewhere in + the repo): the currency formatter, the plain-text renderer, the address-to-text helper. +- `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 `<chakra.span>{ formatCurrencyValue(value) + ' ' }</chakra.span>` — + 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`); <!-- cspell:ignore SPERPS --> each status word; `undefined` + response → `null`; pending (`status: null`, `timestamp: null`) → `null`; feature off → `null`; no + summary + `method`/`from`/`to` → the `called` line; the same with `status: 'error'` → `failed to call`; + no summary and `method: null` → `null`; a 404-shaped body (`{ message: 'Not found' }`) → `null`. + - Reuse `src/slices/tx/mocks/details.ts` (`base`) for the transaction and + `src/features/tx-interpretation/blockscout/mocks.ts` for the summary. + - Timestamp assertions must be timezone-independent — the whole point is that output is UTC regardless + of where the test runs. + +## Open questions + +Parent [Q1](../../spec.md#q1--on-noves-provider-instances-must-the-og-description-match-the-noves-prose) +touches this subtask's action chain but does **not** block it. diff --git a/.agents/tasks/3593-tx-og-title-description/subtasks/04-gssp-wiring-and-templates/spec.md b/.agents/tasks/3593-tx-og-title-description/subtasks/04-gssp-wiring-and-templates/spec.md new file mode 100644 index 0000000000..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 `<title>` and `<meta description>`, so this data has to arrive at SSR time +or not at all. + +## Functional requirements + +- `/tx/[hash]` gains OG templates: + + ```ts + og: { + title: { 'default': '%chain_name% transaction %hash_short%' }, + description: { + 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<typeof pathname>(ctx)`, + then the guard, then `(await baseResponse.props).apiData = …`. + - Gate is `config.metadata.og.enhancedDataEnabled && detectBotRequest(ctx.req)?.type === 'social_preview'`. + Do **not** add a `config.metadata.seo.enhancedDataEnabled` arm — the SEO tags don't use API data here, + so fetching for search-engine bots would buy nothing. + - `Promise.all` over the two `fetchApi` calls; hash via `getQueryParamString(ctx.query.hash)`; + `timeout: 2 * SECOND` composed from `src/toolkit/utils/consts`. + - Pass the results to `getOgDescriptionParams` and assign its result (object or `null`) straight to + `apiData`. + - Introduce the `pathname` const and the `Props<typeof pathname>` generic the way the token page does, + and pass `apiData={ props.apiData }` to `PageNextJs`. +- [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/<hash>'` and grep the + `og:` / `twitter:` / `description` meta tags. First load takes ~45 s while Turbopack compiles. + - Confirm: `og:title` has the short hash, `og:description` has the three-part line, `<title>` and + `<meta name="description">` are unchanged, and a request **without** a bot UA shows no + `og:description` beyond the fallback. + - Also check a pending transaction and a bad hash both yield the fallback description rather than a + malformed line. +- [x] 5 `[agent]` Run `pnpm lint` and `pnpm test` (see `.agents/rules/code-quality.mdc` for the exact commands) + +## Verification result + +Confirmed against the `staging` preset with a `Twitterbot` user agent: `og:title` carries the short hash, +`og:description` reads `Success · Swap 0.21 UNI for 0.000015 NLP · Jul 28, 2026 18:10 UTC`, while `<title>` +keeps the full hash and `<meta name="description">` is untouched. Without a bot UA the server logs no API +request at all and the description falls back. A pending transaction and a bad hash both fall back cleanly. + +On the very first bot request the `/summary` call aborted at what was then a 1 s timeout (logged as `504`) +while the tx call succeeded, so the preview fell back to the `called` line; every later request resolved the +summary in ~400 ms. Measuring that properly on eth mainnet showed it is the backend's cold-response cost, +not the network, which is why both timeouts are now 2 s — the reasoning is in the parent spec's fetch plan. +What remains for subtask 5 is confirming from `api_request_duration_seconds` that 2 s holds in production. + +## Open questions + +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) + +<!-- cspell:ignore SWOGE --> + +Every branch was observed on the public URL, so the wiring is proven end to end: + +- summary branch — `Success · Swap 0.4 ETH for 2.65M SWOGE · Jul 29, 2026 8:49 UTC` +- fallback branch — `Success · 0x07...e311 called dagSwapTo on OKX Labs: DexRouter · Jul 29, 2026 8:49 UTC` + (note the `to` address resolving through its metadata name tag, as on the page) +- generic fallback when a request doesn't land in time + +`og:title` carries the short hash, `<title>` keeps the full one, and `<meta name="description">` is +untouched — for bots and for a plain UA alike. + +**Reliability is the open issue, and it is the backend's latency.** Over 10 identical `Twitterbot` +requests to one transaction, only 3 produced an enhanced description and only 1 of those used the summary; +a human pasting the link into Telegram saw the generic description every time. + +Paced sampling of that instance from outside the cluster (25 transactions, one request every 3 s over a +10-minute window, 105 requests, all `200`, no rate limiting) — `/api/v2/stats` included as a control for +network and edge overhead: + +| endpoint | phase | p50 | p90 | p95 | max | over 2 s | +| --- | --- | --- | --- | --- | --- | --- | +| `/api/v2/stats` (control) | — | 0.27 s | 0.50 s | 0.50 s | 0.50 s | 0/5 | +| `core:tx` | cold | 2.84 s | 4.20 s | 4.34 s | 4.36 s | 21/25 | +| `core:tx` | warm | 3.08 s | 4.26 s | 4.45 s | 4.65 s | 24/25 | +| `core:tx_interpretation` | cold | 0.84 s | 8.33 s | 9.67 s | 10.21 s | 11/25 | +| `core:tx_interpretation` | warm | 0.75 s | 2.01 s | 2.62 s | 2.73 s | 3/25 | + +The control says ~0.3 s of that is network, so the rest is backend time. Two distinct problems: `core:tx` +is *uniformly* slow — every single call took over a second, and warm is no faster than cold, so nothing is +cached — while `core:tx_interpretation` is bimodal, fast when cached and up to 10 s when not. Since the +status and timestamp both come from `core:tx`, its p50 of 2.8 s alone defeats the 2 s timeout on most +requests, which is why the preview almost never enhances on this instance. For comparison, eth mainnet +answers the same two endpoints in 0.56 s and 0.95 s cold. + +(An earlier run without pacing showed ~12 s summary times; the paced numbers above supersede it — part of that was +contention from our own burst.) + +**Conclusion: don't raise the timeout further.** 2 s already exceeds what the other routes allow, and no +crawler waits for 4 s. The finding goes to the backend team as an endpoint-latency issue on this instance; +until then the preview degrades to the generic description there, which is the designed behavior. + +**The metrics this subtask planned to read 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 `<meta name="description">` text. +- Neither `core:tx` nor `core:tx_interpretation` is requested on those instances — the whole point is to add + no crawler-driven load, and with no action available the other two params are useless anyway. +- The OG **title** is unaffected: it carries the short hash on every instance, since it needs no API data. +- No behavior change where the provider is `blockscout`. +- 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 <chakra.span color="text.secondary" whiteSpace="pre">{ value + ' ' }</chakra.span>; } case 'currency': { - let numberString = ''; - if (BigNumber(value).isLessThan(0.1)) { - numberString = BigNumber(value).toPrecision(2); - } else if (BigNumber(value).isLessThan(10000)) { - numberString = BigNumber(value).dp(2).toFormat(); - } else if (BigNumber(value).isLessThan(1000000)) { - numberString = BigNumber(value).dividedBy(1000).toFormat(2) + 'K'; - } else { - numberString = BigNumber(value).dividedBy(1000000).toFormat(2) + 'M'; - } - return <chakra.span>{ numberString + ' ' }</chakra.span>; + return <chakra.span>{ formatCurrencyValue(value) + ' ' }</chakra.span>; } case 'timestamp': { return <chakra.span color="text.secondary" whiteSpace="pre">{ dayjs(Number(value) * SECOND).format('MMM DD YYYY') }</chakra.span>; diff --git a/src/features/tx-interpretation/common/utils/address-to-plain-text.ts b/src/features/tx-interpretation/common/utils/address-to-plain-text.ts new file mode 100644 index 0000000000..3162408d7b --- /dev/null +++ b/src/features/tx-interpretation/common/utils/address-to-plain-text.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import type { schemas } from '@blockscout/api-types'; + +import getAddressName from 'src/slices/address/utils/get-address-name'; + +import shortenString from 'src/shared/texts/shorten-string'; + +// What `truncation="constant"` resolves to in `AddressEntity`. +const HASH_CHAR_NUMBER = 8; + +// How an address reads on the page, minus the display concerns that need a client: the proxy-implementation +// tooltip and the bech32/Filecoin alt-hash, both driven by user settings unavailable server-side. +export default function addressToPlainText(address: schemas['Address']) { + return getAddressName(address) ?? shortenString(address.hash, HASH_CHAR_NUMBER); +} diff --git a/src/features/tx-interpretation/common/utils/format-currency-value.spec.ts b/src/features/tx-interpretation/common/utils/format-currency-value.spec.ts new file mode 100644 index 0000000000..bfa1f69bd8 --- /dev/null +++ b/src/features/tx-interpretation/common/utils/format-currency-value.spec.ts @@ -0,0 +1,21 @@ +import { it, expect, describe } from 'vitest'; + +import formatCurrencyValue from './format-currency-value'; + +describe('picks the notation by magnitude', () => { + it.each([ + // significant digits below 0.1, where two decimal places would collapse to 0.00 + [ '0.015575428823202624', '0.016' ], + [ '0.09999', '0.10' ], + // two decimal places up to 10K, so a value rounding up to the threshold still gets them + [ '0.1', '0.1' ], + [ '9999.999', '10,000' ], + // thousands and millions + [ '10000', '10.00K' ], + [ '999999', '1,000.00K' ], + [ '1000000', '1.00M' ], + [ '2918443.532640630294962772', '2.92M' ], + ])('%s → %s', (value, expected) => { + expect(formatCurrencyValue(value)).toBe(expected); + }); +}); diff --git a/src/features/tx-interpretation/common/utils/format-currency-value.ts b/src/features/tx-interpretation/common/utils/format-currency-value.ts new file mode 100644 index 0000000000..1851f8a238 --- /dev/null +++ b/src/features/tx-interpretation/common/utils/format-currency-value.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import BigNumber from 'bignumber.js'; + +const THOUSAND = 1_000; +const MILLION = 1_000 * THOUSAND; + +// Below this a two-decimal format would collapse to `0.00`, so significant digits are used instead. +const SMALL_VALUE_THRESHOLD = 0.1; +const SIGNIFICANT_DIGITS = 2; +const DECIMAL_PLACES = 2; +const THOUSANDS_THRESHOLD = 10 * THOUSAND; + +// Shared by the interpretation component and its plain-text renderer so an amount in a social preview +// reads exactly as it does on the page. +export default function formatCurrencyValue(value: string) { + const amount = BigNumber(value); + + if (amount.isLessThan(SMALL_VALUE_THRESHOLD)) { + return amount.toPrecision(SIGNIFICANT_DIGITS); + } + + if (amount.isLessThan(THOUSANDS_THRESHOLD)) { + return amount.dp(DECIMAL_PLACES).toFormat(); + } + + if (amount.isLessThan(MILLION)) { + return amount.dividedBy(THOUSAND).toFormat(DECIMAL_PLACES) + 'K'; + } + + return amount.dividedBy(MILLION).toFormat(DECIMAL_PLACES) + 'M'; +} diff --git a/src/features/tx-interpretation/common/utils/summary-to-plain-text.spec.ts b/src/features/tx-interpretation/common/utils/summary-to-plain-text.spec.ts new file mode 100644 index 0000000000..925901c593 --- /dev/null +++ b/src/features/tx-interpretation/common/utils/summary-to-plain-text.spec.ts @@ -0,0 +1,57 @@ +import type { TxInterpretationSummary } from 'src/features/tx-interpretation/common/types/api'; + +import { currencyUnits } from 'src/slices/chain/units'; + +import { txInterpretation } from 'src/features/tx-interpretation/blockscout/mocks'; +import { TX_INTERPRETATION } from 'src/features/tx-interpretation/blockscout/stubs'; + +import { it, expect, beforeAll, afterAll, vi } from 'vitest'; + +import summaryToPlainText from './summary-to-plain-text'; + +// A timestamp variable is rendered in local time, as on the page — pin the zone so the assertion holds +// wherever the suite runs. +beforeAll(() => { + vi.stubEnv('TZ', 'UTC'); +}); + +afterAll(() => { + vi.unstubAllEnvs(); +}); + +it('renders every variable type the way the page does', () => { + expect(summaryToPlainText(txInterpretation.data.summaries[0])).toBe('Transfer 100 DUCK to 0xd7...5859 on Jun 17 2023'); +}); + +it('renders the native coin symbol variable', () => { + const summary: TxInterpretationSummary = { + summary_template: '{action_type} {amount} {native}', + summary_template_variables: { + action_type: { type: 'string', value: 'Send' }, + amount: { type: 'currency', value: '1.5' }, + }, + }; + + expect(summaryToPlainText(summary)).toBe(`Send 1.5 ${ currencyUnits.ether }`); +}); + +it('collapses the template whitespace into single spaces', () => { + const summary: TxInterpretationSummary = { + ...TX_INTERPRETATION.data.summaries[0], + summary_template: ' {action_type} {source_amount} Ether into {destination_amount} {destination_token} ', + }; + + expect(summaryToPlainText(summary)).toBe('Wrap 0.7 Ether into 0.7 STUB'); +}); + +it('returns nothing when a template variable has no value', () => { + const summary: TxInterpretationSummary = { + summary_template: '{action_type} {amount} {token}', + summary_template_variables: { + action_type: { type: 'string', value: 'Transfer' }, + amount: { type: 'currency', value: '100' }, + }, + }; + + expect(summaryToPlainText(summary)).toBeUndefined(); +}); diff --git a/src/features/tx-interpretation/common/utils/summary-to-plain-text.ts b/src/features/tx-interpretation/common/utils/summary-to-plain-text.ts new file mode 100644 index 0000000000..c469f1d341 --- /dev/null +++ b/src/features/tx-interpretation/common/utils/summary-to-plain-text.ts @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import type { TxInterpretationSummary, TxInterpretationVariable } from 'src/features/tx-interpretation/common/types/api'; + +import { currencyUnits } from 'src/slices/chain/units'; + +import dayjs from 'src/shared/date-and-time/dayjs'; + +import { SECOND } from 'src/toolkit/utils/consts'; + +import addressToPlainText from './address-to-plain-text'; +import formatCurrencyValue from './format-currency-value'; +import { + extractVariables, + getStringChunks, + fillStringVariables, + checkSummary, + NATIVE_COIN_SYMBOL_VAR_NAME, + WEI_VAR_NAME, +} from './utils'; + +const UNNAMED_TOKEN = 'Unnamed token'; +// The format `TxInterpretation` uses for a timestamp variable, which is not the OG description's own +// timestamp format. +const TIMESTAMP_FORMAT = 'MMM DD YYYY'; + +const WHITESPACE_RUN_REGEX = /\s+/g; + +function variableToPlainText(variable: TxInterpretationVariable | undefined): string { + if (!variable) { + return ''; + } + + const { type, value } = variable; + + switch (type) { + case 'string': + case 'domain': + case 'method': + return value; + case 'currency': + return formatCurrencyValue(value); + case 'token': + return value.symbol ?? value.name ?? UNNAMED_TOKEN; + case 'address': + return addressToPlainText(value); + case 'dexTag': + case 'link': + case 'external_link': + return value.name; + case 'timestamp': + return dayjs(Number(value) * SECOND).format(TIMESTAMP_FORMAT); + } +} + +// Renders what `TxInterpretation` renders, as a single line of text. +export default function summaryToPlainText(summary: TxInterpretationSummary) { + const template = summary.summary_template; + const variables = summary.summary_template_variables; + + if (!checkSummary(template, variables)) { + return; + } + + const intermediateResult = fillStringVariables(template, variables); + const variablesNames = extractVariables(intermediateResult); + const chunks = getStringChunks(intermediateResult); + + return chunks + .flatMap((chunk, index) => { + const name = variablesNames[index]; + const variableText = (() => { + switch (name) { + case undefined: + return ''; + case NATIVE_COIN_SYMBOL_VAR_NAME: + return currencyUnits.ether; + case WEI_VAR_NAME: + return currencyUnits.wei; + default: + return variableToPlainText(variables[name]); + } + })(); + + return [ chunk.trim(), variableText ]; + }) + .filter(Boolean) + .join(' ') + .replaceAll(WHITESPACE_RUN_REGEX, ' ') + .trim(); +} diff --git a/src/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: Props) => { +const Page: NextPage<Props<typeof pathname>> = (props: Props<typeof pathname>) => { return ( - <PageNextJs pathname="/tx/[hash]" query={ props.query }> + <PageNextJs pathname={ pathname } query={ props.query } apiData={ props.apiData }> <Transaction/> </PageNextJs> ); @@ -21,4 +40,25 @@ const Page: NextPage<Props> = (props: Props) => { export default Page; -export { tx as getServerSideProps } from 'src/server/getServerSideProps/main'; +export const getServerSideProps: GetServerSideProps<Props<typeof pathname>> = async(ctx) => { + const baseResponse = await gSSP.tx<typeof pathname>(ctx); + + // Only social-preview bots get the enhanced description, and only server-side: crawlers don't run JS, + // and the SEO tags this route emits need no API data. + const isSocialPreviewBot = config.metadata.og.enhancedDataEnabled && detectBotRequest(ctx.req)?.type === 'social_preview'; + + 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<string, string | Array<string> | undefined>) { +import type { TemplateValue } from './types'; + +export default function compileValue(template: TemplateValue, params: Record<string, string | Array<string> | undefined>) { const PLACEHOLDER_REGEX = /%(\w+)%/g; const enhancedPlaceholders = (() => { diff --git a/src/shell/metadata/generate.spec.ts b/src/shell/metadata/generate.spec.ts index d563fddd98..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<Pathname extends Route['pathname']>(route: RouteParams<Pathname>, apiData: ApiData<Pathname> = null): Metadata { const idParam = castToString(route.query?.id); const idFormatted = idParam ? upperFirst(kebabCase(idParam).replaceAll('-', ' ')) : undefined; + const hashParam = castToString(route.query?.hash); const params = { ...route.query, @@ -29,12 +42,16 @@ export default function generate<Pathname extends Route['pathname']>(route: Rout chain_explorer_title: getChainExplorerTitle(), gwei_name: currencyUnits.gwei, id_formatted: idFormatted, + hash_short: hashParam ? shortenString(hashParam, HASH_SHORT_CHAR_NUMBER) : undefined, }; const titlePostfix = config.metadata.promoteBlockscoutInTitle ? ' | Blockscout' : ''; - 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<Pathname extends Route['pathname']>(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<Route['pathname'], RouteTemplateRecord> = { 'default': '%chain_name% detailed transaction info. View transaction status, block confirmation, gas fee, native coin and token transfers.', }, }, + og: { + title: { + 'default': '%chain_name% transaction %hash_short%', + }, + description: { + enhanced: '%tx_status% · %tx_action% · %tx_timestamp%', + }, + }, }, '/blocks': { metadata: { diff --git a/src/shell/metadata/types.ts b/src/shell/metadata/types.ts index 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 Route['pathname']> = ( Pathname extends '/address/[hash]' ? { domain_name: string } : + Pathname extends '/tx/[hash]' ? TxOgDescriptionParams : Pathname extends '/token/[hash]' ? schemas['Token'] & { symbol_or_name: string; description?: string; projectName?: string } : Pathname extends '/token/[hash]/instance/[id]' ? { symbol_or_name: string } : Pathname extends '/apps/[id]' ? MarketplaceDapp : @@ -22,6 +25,15 @@ export type ApiData<Pathname extends Route['pathname']> = export type StructuredData = WithContext<Product> | WithContext<WebApplication>; +// The `enhanced` variant is used only when every placeholder in it resolves to a truthy param. +export interface TemplateValue { + 'default': string; + enhanced?: string; +} + +// An OG template may omit its `default` and inherit the route's metadata one. +export type OgTemplateValue = Partial<TemplateValue>; + export interface Metadata { title: string; description: string; 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<EntityBase.ContentBaseProps, 'text'> & Pick<Enti const Content = chakra((props: ContentProps) => { const displayedAddress = getDisplayedAddress(props.address, props.altHash); - const nameTag = (() => { - const tagData = props.address.metadata?.tags.find(tag => tag.tagType === 'name'); - if (!tagData || !tagData.name) { - return; - } - - return getTagName(tagData, props.address.hash); - })(); - const nameText = nameTag || props.address.ens_domain_name || props.address.name; + const nameText = getAddressName(props.address); const isProxy = props.address.implementations && props.address.implementations.length > 0 && props.address.proxy_type !== 'eip7702'; diff --git a/src/slices/address/utils/get-address-name.spec.ts b/src/slices/address/utils/get-address-name.spec.ts new file mode 100644 index 0000000000..72d88fd09b --- /dev/null +++ b/src/slices/address/utils/get-address-name.spec.ts @@ -0,0 +1,21 @@ +import { withName, withEns, withNameTag, withoutName } from 'src/slices/address/mocks/address-param'; + +import { it, expect } from 'vitest'; + +import getAddressName from './get-address-name'; + +it('prefers the name tag over the ENS domain and the name', () => { + expect(getAddressName(withNameTag)).toBe('Mrs. Duckie'); +}); + +it('falls back to the ENS domain', () => { + expect(getAddressName(withEns)).toBe('kitty.kitty.kitty.cat.eth'); +}); + +it('falls back to the name', () => { + expect(getAddressName(withName)).toBe('ArianeeStore'); +}); + +it('returns nothing for an address with no name of any kind', () => { + expect(getAddressName(withoutName)).toBeUndefined(); +}); diff --git a/src/slices/address/utils/get-address-name.ts b/src/slices/address/utils/get-address-name.ts new file mode 100644 index 0000000000..cb0e0c9dd1 --- /dev/null +++ b/src/slices/address/utils/get-address-name.ts @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import type { schemas } from '@blockscout/api-types'; + +import { getTagName } from 'src/features/address-metadata/components/tag/utils'; + +type AddressNameSource = Partial<Pick<schemas['Address'], 'metadata' | 'ens_domain_name' | 'name'>> & { hash: string }; + +// The name an address is displayed by, in `AddressEntity`'s order of preference. An address with no name +// of any kind gets `undefined` — the caller decides how to render the bare hash. +export default function getAddressName(address: AddressNameSource): string | undefined { + const nameTag = (() => { + const tagData = address.metadata?.tags.find(tag => tag.tagType === 'name'); + + if (!tagData || !tagData.name) { + return; + } + + return getTagName(tagData, address.hash); + })(); + + return nameTag || address.ens_domain_name || address.name || undefined; +} 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, + }; +}