Skip to content

Show transaction status, action, and timestamp in social link previews - #3596

Open
tom2drum wants to merge 25 commits into
mainfrom
issue-3593
Open

Show transaction status, action, and timestamp in social link previews#3596
tom2drum wants to merge 25 commits into
mainfrom
issue-3593

Conversation

@tom2drum

@tom2drum tom2drum commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Description

Resolves #3593.

A shared transaction link produced a near-useless card: the OG title carried the full 66-character hash and
there was no og:description at all, so Telegram and X fell back to the generic page description. The card now
describes the transaction:

Ethereum transaction 0xc1...cc2e | Blockscout
Success · Transfer 0.013 ETH to Fee Recipient: 0xadfb...e35 · Jul 29, 2026 14:04 UTC

The action reads exactly as the transaction page's subheading, including its fallback chain and amount
rounding, because both now render from the same code.

Reaching that needed two structural changes. The og block in the metadata template map became a real
template layer with default/enhanced variants (previously og.description was passed through raw and never
compiled), and /tx/[hash]'s getServerSideProps gained a bot-gated server-side fetch — crawlers don't run JS,
and metadata.update() only ever touches <title> and <meta name="description">.

Decisions worth knowing, all recorded in the spec:

  • SEO tags are untouched. <title> keeps the full hash and <meta name="description"> keeps the generic
    copy; only social-preview crawlers get the enhanced description. generate() has no notion of bot type — the
    presence of apiData is the only signal.
  • Enhanced data comes from a purpose-built endpoint. core:tx supplies the status and timestamp the
    description cannot do without, and on a loaded instance it answered too slowly for a crawler (aborted on 6 of
    6 requests at a 2 s timeout). The Core API team added
    /api/v2/transactions/:hash/preview for this feature; against that same instance it answers in 0.51 s at p50
    and the card now enhances on 6 of 6. Raising the timeout instead was rejected: crawlers wait single-digit
    seconds, and a card that fails to render is worse than a plain one.
  • The summary stays a separate request. Folding it into the preview response was offered and declined —
    /summary is by far the slowest call, so coupling them would turn every slow summary into a lost
    description instead of one that degrades to 0x9a...71a3 called transfer on FiatTokenProxy.
  • All-or-nothing, by the existing compileValue contract. Accepted losses: a pending transaction (no
    timestamp), an unresolvable action, and any failed, timed-out or 404 request — each falls back to today's card.
  • Nothing is requested where nothing can come of it. An instance with the interpretation feature off, or
    running the Noves provider, makes no request at all: there is no action to be had, and the PM ruled that
    quietly pointing crawler traffic at a third party's slow API isn't ours to do.
  • The X card type now follows what the page has. Every page claimed summary_large_image, but entity
    pages carry no image on purpose, and X answers a promised-but-missing image with the compact card and a grey
    placeholder where the image would be. The type is now summary when there is no image, so the card renders
    as title and description alone. Applies to every imageless route, not just transactions.
  • WhatsApp, Discord and LinkedIn were missing from bot detection — a pre-existing gap that made them fall
    back to the generic description on every OG-enhanced route (address, token, NFT, stats), not just this one.
    Fixed here since WhatsApp is where it was noticed.

Also included: the currency rounding and summary-to-text rendering extracted from TxInterpretation so the
preview and the page cannot drift, and getAddressName extracted from AddressEntity so an address is labelled
the same way in both.

Environment variables

None. The feature reuses the existing NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED, which defaults to on.

Minimum API version

The enhanced description needs GET /api/v2/transactions/:hash/preview, added in
blockscout/blockscout#14638 with a follow-up fix in #14703. Both are merged but not in a released version
yet
(latest release is v11.2.6), so the requirement is the first Core API release that contains them.

This is not a hard requirement: on an instance whose backend predates the endpoint the request fails and the
card keeps the generic description, exactly as it does today when a request times out. Deliberately no
fallback to core:tx — chaining a second attempt would spend a crawler's patience precisely on the instances
that are already slow.

Breaking or incompatible changes

None for deployments.

One deliberate output change: routes with no OG description template now emit og:description explicitly,
carrying the same text crawlers already inferred from <meta name="description">. This is visible in
src/shell/metadata/__snapshots__/generate.spec.ts.snap and was chosen over an implicit fallback so the tag is
always present. Every other route's title / description / og:title / og:image output is byte-identical.

Additional information

  • Verified end to end on two demos built from this branch, plus real cards in Telegram and X.
  • @blockscout/api-types is pinned to a beta (0.0.1-beta.eeda17a) because the endpoint's schema is not in a
    stable release yet; it needs re-pinning to the stable version once the Core API release lands.
  • The schemas were named Preview / PreviewAddress upstream, which said nothing about what they previewed
    once generated into a spec whose component names are global; renamed to TransactionPreview /
    TransactionPreviewAddress in chore: Name the transaction preview schemas TransactionPreview blockscout#14711 before anything depended on them.

🤖 Generated with Claude Code

Medium task, five subtasks: make the `og` block a default/enhanced
template layer, share the interpretation currency rounding and render
summaries as plain text, derive the status/action/timestamp params, wire
the bot-gated server-side fetch plus the /tx/[hash] templates, and verify
on a demo (agent deploys, human confirms the real card).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tom2drum tom2drum added the enhancement New feature or request label Jul 28, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. To trigger a review, include @coderabbitai review in the PR description. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3312c304-09f8-44c5-8602-439d2af14bf4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

tom2drum and others added 5 commits July 28, 2026 19:29
The `og` entry in the route template map was static data: `og.description`
was passed through to the meta tag without ever reaching `compileValue`, so
it could hold no placeholders, and the record required a description and an
image together. That ruled out a per-route dynamic OG description.

OG title and OG description now each accept the same `default`/`enhanced`
shape as the metadata templates and are compiled against the same params;
the image is independent of both. A route that declares no OG template falls
back to the page title and description — written out explicitly so the
resolution rule is uniform, which is why routes without an OG description now
emit an `og:description` carrying the text crawlers previously inferred from
`<meta name="description">`. The new `hash_short` param shortens a route's
hash the way `truncation="constant"` does on the page.

No route declares OG templates yet — `/tx/[hash]`'s land in the next steps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The OG description needs the transaction action to read exactly as the
subheading on the page does, but the summary is a template of typed
variables that only `TxInterpretation` knows how to turn into React
elements. A plain-text renderer walks the same parsing pipeline and maps
each variable type to the text its UI counterpart displays.

Two pieces of that mapping were inline in components and are now shared, so
the preview cannot drift from the page: the currency rounding ladder, which
the interpretation component calls instead of holding its own copy, and the
address name chain, extracted into the address slice and called by
`AddressEntity` itself. Neither component's output changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the transaction and its interpretation summary into the three strings
the OG description template compiles — status word, action, UTC timestamp —
or nothing at all when any of them is missing, which is what keeps the
template's all-or-nothing rule true at the source.

The unit lives in the tx slice rather than beside `generate()`: metadata is
regenerated on every client-side route change, so anything reachable from
there ships to all users, and the responses would otherwise be serialized
into `__NEXT_DATA__` whole. Three short strings instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A worktree under `.claude/worktrees/` is a full checkout of the repo, so
every tool that walks the tree from the root sees each file twice: cspell
and the two test runners duplicate their work, tsc type-checks a copy with
no dependencies installed, and eslint exhausted the Node heap outright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A shared transaction link produced a card titled with the full 66-character
hash and described by the generic page copy, since the route declared no OG
templates and crawlers never run the client-side metadata update.

`/tx/[hash]` now carries a short-hash OG title for every crawler, and its
`getServerSideProps` fetches the transaction and its interpretation summary
for social-preview bots specifically, turning them into a status · action ·
timestamp description. Anything less than all three parts falls back to the
generic description, so a pending transaction or a failed request degrades
to exactly today's card.

Both requests run in parallel with a 2 s timeout. That is longer than the
other routes allow because both endpoints compute their response on the
first request for a transaction and cache it afterwards, and a crawler is
always that first request: on eth mainnet a cold summary averages 0.95 s
against 0.30 s warm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tom2drum and others added 3 commits July 29, 2026 13:31
TEAM.md mixed the roster with the rules for using it: who picks a task's
contacts, that product questions go to a channel rather than a DM, which
channel, and how to mention the addressee. Those already lived in
`grill-the-task` and `to-spec`, so the file was a second, drifting copy —
the skills are what an agent actually reads when routing a question.

What stays in TEAM.md is the data plus what makes it readable: the meaning
of the `default` marker, where IDs come from, and each team's ownership. One
rule was only implied in the skill and is now explicit there: the roster
default is what to record when the developer has no task-specific pick.

The backend section also gains Slack group IDs beside its members, so a
question can be addressed to a whole team once a skill has a rule for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The demo proved the wiring: every branch of the description was observed on
a public URL, including the interpretation summary and the called-method
fallback with a named address. It also showed the feature barely enhances on
that instance, so the subtask now carries the sampling behind that — both
transaction endpoints measured against a network control, and the ruling not
to raise the timeout, since no crawler waits as long as the API takes.

The latency goes to the backend team as Q2; it gates the release decision
rather than any remaining work, because the code degrades to today's card
wherever the API is slow.

Also corrects a claim the spec made from the start: `logRequestFromBot` and
`fetchApi` do record their metrics, but the SSR bundle keeps a registry of
its own, so `/api/metrics` never exports them and the timeout evidence had
to come from sampling the API directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tom2drum and others added 7 commits July 29, 2026 15:53
The `review` variant exports them, `review-2` did not, so `/api/metrics`
answered 404 there — and the metrics are how a demo shows what the app did
server-side, which is the point of deploying one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All three open questions came back. The demo verification is done: the card
was confirmed in Telegram and X on an eth-mainnet demo, and the timeout
question was settled by the metrics once they worked — on the loaded instance
the mandatory call aborted on every crawler request, so the answer is to keep
2 s and change where the data comes from.

That leaves two pieces of work rather than one. The backend is building an
endpoint shaped for this preview, with switchable ens/metadata/summary
preloads, going to staging to be measured — subtask 6, a brief until its shape
is agreed. And the Noves decision needs code: the product call is to emit no
enhanced description there, but an instance running Noves currently still
requests the Blockscout summary and lands on the called-method fallback, which
is the option that was rejected — subtask 7, scoped and ready.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An instance runs a single interpretation provider. On a Noves one the transaction page renders Noves' prose
and the Blockscout summary endpoint has nothing to serve, so the preview fell through to the
`called … on …` line — the option the PM ruled against, since pointing crawler traffic at a third party's
slow API is not ours to decide.

Both gates now require the provider to be `blockscout`: the util has no action without it, and the page makes
no request at all. That also drops the `core:tx` request on instances with the feature off entirely, where it
could never produce a description — and `none` is the provider's default.
All three read OG tags, none of them was in `detectBotRequest`, so their crawlers were treated as ordinary
visitors and every route that enhances its preview for social bots — transaction, address, token, NFT
instance, stats — served them the generic description instead. Reported for WhatsApp from a real card.

Discord and LinkedIn match the `…bot` suffix rather than the bare product name, because both also ship an
in-app browser whose user agent carries that name and whose requests are real visitors.
Measured on the loaded instance: with `preload_ens`, `preload_metadata` and `decode_input` all on it answers
in 0.66 s at p50 and never crossed 2 s, so status and timestamp now arrive inside the budget where they never
did, and the preloads cost nothing worth trading away.

The summary keeps its own request and its own timeout — it is still the slow one, and coupling it to the
preview would trade a degraded description for no description.
The endpoint and the metadata fix both reached `dev`, so the response type can be published and the address
tags survive the switch — the two things that were holding this subtask open.

Latency was re-measured after the fix went live, since the earlier numbers were taken while the metadata
preload silently did nothing: with all three preloads on the request costs the same as with none, so the spec
asks for all of them.
The mandatory half of the description — status and timestamp — came from `core:tx`, which on a loaded
instance answers too slowly for a crawler, so the card fell back to the generic text there. The new endpoint
returns those fields alone and answers in half a second on that same instance.

Its three preloads are all requested: measured together they cost what none of them cost, and without
`decode_input` the fallback action line would name the method by its selector. The summary keeps its own
request, so when it is slow the description degrades to that line instead of disappearing.

`addressToPlainText` now takes the same name-source type `getAddressName` defines, since the preview's
address carries only the fields needed to name it.
Both demos ran on this branch's image: the production instance the endpoint was built for enhances every
crawler request, a dev instance enhances none. The spec records why — that instance answers in ~1.7 s against
a 2 s budget, warm or cold — so the next person does not read a slow demo as a broken feature.
# Conflicts:
#	.agents/TEAM.md
#	.agents/skills/grill-the-task/SKILL.md
#	package.json
#	pnpm-lock.yaml
`Preview` and `PreviewAddress` said nothing about what they previewed once generated into a spec whose
component names are global, so they were renamed upstream (blockscout/blockscout#14711) before anything
depended on them.
@tom2drum tom2drum added need API For issues that wait for API changes dependencies Pull requests that update a dependency file labels Aug 17, 2026
@tom2drum
tom2drum marked this pull request as ready for review August 17, 2026 10:44
@tom2drum tom2drum removed the need API For issues that wait for API changes label Aug 17, 2026
Every page claimed `summary_large_image`, but the entity pages carry no image on purpose — a generic banner
would push the description, which is the point of the card, below itself. X answers a promised-but-missing
image with the compact card and a grey placeholder in the image's place, so the type now follows what the
page has.
X keeps the slot whichever card type it is, and puts a grey placeholder in it when the page offers nothing,
so an entity page gets the instance's generated icon — square, which is what a summary card's slot wants.

Only the Twitter tag: `og:image` stays unset there, since Telegram and WhatsApp drop the image entirely
rather than padding it, and their cards read better that way.

@tom2drum tom2drum left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 73171c99c382c3ff3e173f4784b2cd5d811f856c9507cc0eba8f549e74a730865f9d07a8d805d559
Round 1 of 3
Findings 0 blocker · 5 major · 3 nit
By axis Spec 3 · Standards 4 · Correctness 1
Outcome needs-human

Whole-task review of #3593 (PR #3596).

Comment thread src/server/PageMetadata.tsx
Comment thread src/server/utils/detectBotRequest.ts
Comment thread src/shell/metadata/types.ts Outdated
Comment thread src/slices/tx/utils/get-og-description-params.ts
Comment thread src/slices/tx/utils/get-og-description-params.ts Outdated
Comment thread src/slices/address/utils/get-address-name.ts
Three of the eight findings asked for code or record changes:

`TxOgDescriptionParams` moves to the slice's `types/api.ts`, the path a
cross-domain type import is required to go through.

The status mapping's comment described a contract that no longer holds:
`fetchApi` stopped parsing error bodies in #3623, which reached this branch
through a `main` merge, so a miss now arrives as `undefined` rather than as
`{ message }`. The distinction the code draws is still load-bearing — every
404, failure, and timeout would otherwise read as `Pending` — but its reason
changed, and the test asserting the old contract is gone.

The spec gains the two site-wide changes the PM asked for once real cards were
in front of her, neither of which was an original AC: the wider crawler set,
and the X card image.
`FAVICON_MASTER_URL` is read by the container start-up script rather than by
app code, so it carries no `NEXT_PUBLIC_` prefix and `/node-api/config` never
offered it. A preset could not copy it, and the favicon generator fell through
to its documented default, `NEXT_PUBLIC_NETWORK_ICON` — which is a bare
transparent glyph meant to sit on the app's own background, not the filled
square an operator supplies. The difference is invisible in a browser tab and
obvious in an X card, where the icon lands on the reader's theme background.

Exposing it is safe: the image it points at is already public, and the envs
validator filters to `NEXT_PUBLIC_` before its unknown-key check, so nothing
downstream has to learn about the key. The allowlist is deliberately narrow —
it may only ever name a value that is public regardless.

@tom2drum tom2drum left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 73171c99c382c3ff3e173f4784b2cd5d811f856cb485abf0df8a55a4561d9ead87f91d0d4b9309cf
Round 2 of 3
Findings 0 blocker · 0 major open · 3 nit deferred
By axis Spec 0 open · Standards 0 open (1 reject accepted) · Correctness 0 open
Outcome clear

Arbitration

Finding Ruling
F1 PageMetadata twitter card / favicon image fixed-verified — spec follow-ups + out-of-scope now own the behaviour
F2 WhatsApp / Discord / LinkedIn crawlers fixed-verified — recorded in Follow-ups
F3 TxOgDescriptionParams cross-domain import fixed-verified — now via slices/tx/types/api.ts
F4 slice → feature plain-text imports rejected-accepted — compose rule is UI composition, not pure helpers
F5 fetchApi error-body comment/test fixed-verified — comment + trap updated; dead test removed
F6–F8 nits deferred (unchanged)

No regressions from the fix commits. b485abf0d (expose FAVICON_MASTER_URL to presets) is adjacent tooling for the X card icon, not a fix regression.

— Reviewed by Cursor Grok 4.5

End-to-end proof that a preset carries `FAVICON_MASTER_URL` needs a source
instance that already serves it, and none does yet — the variable only starts
appearing once an instance runs a build with the config endpoint change. Setting
it here stands in for that operator value so the demo can be checked now.

Temporary, and it cannot escape the review environments: the entrypoint reads
`.env.extra` only inside `load_envs_from_preset`, which returns early when no
`ENVS_PRESET` is set — which is every non-preset deployment.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Generate transaction OG title and description from transaction details

1 participant