diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index 81cea00d456..1dcbe3ed5e7 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -57,13 +57,36 @@ Some directories have a `CONTEXT.md` documenting non-obvious patterns specific t - `src/sprite/` — SVG sprite build pipeline and which outputs are tracked vs. generated. - `src/toolkit/` — the `@blockscout/ui-toolkit` workspace package structure. - `tools/dev-server/` — how the dev server and demo deploy get their env vars from a running instance config. -- `tools/profiling/` — React render profiling: production profiling build (`profile:preset`) and DevTools trace aggregation (`profile:analyze`). +- `tools/profiling/` — React render profiling: production profiling build and DevTools trace aggregation. If you encounter a `CONTEXT.md` not listed here, read it too (and consider adding it to this list). +## Architecture decision records + +Decisions with repo-wide consequences are recorded in `.agents/adr/`, named +`<0000>-.md`. Read the relevant one before changing what it decided — an ADR carries the +evidence and the trade-off, so it answers "why is it like this?" without a git archaeology session. + +- `0002-layer-shaped-ticket-leaves.md` — why a product task's tickets cut vertically while the leaves inside them run along layers. +- `0003-turbopack-for-production-builds.md` — why production builds moved back to Turbopack. + +Add a new record (next free number, and a line here) whenever a decision is expensive to rediscover: +it constrains future work, was reached by measurement or an investigation worth not repeating, or +looks wrong without its context. Supersede rather than rewrite — flip the old record's `Status` to +`superseded by ` and leave its reasoning intact. + ## Product task workflow -Product tasks (GitHub issues) are worked through a spec-driven workflow, run by the `grill-the-task`, -`to-spec` and `implement-task` skills. Specs live in `.agents/tasks/`; read `.agents/tasks/README.md` for -the lifecycle before touching one. `.agents/delegation.md` draws the agent/human boundary, and -`.agents/TEAM.md` says who answers open questions. +Product tasks (GitHub issues) are worked through a spec-driven workflow — interview, spec, agent +implementation, code review. Specs accumulate in `.agents/tasks/` as a permanent record. See +`./tasks/README.md` for the lifecycle, the skills that run it, and the spec conventions. + +## Editing this instruction set + +Before changing anything under `.agents/`, read `./README.md` — it owns the layout, the dual-frontmatter +rules contract, the per-file symlink Cursor needs, and how references here are checked. + +## Reaching people & channels on Slack + +To reach anyone or any channel on Slack, resolve the Slack IDs from `./TEAM.md`. Draft and get approval +before sending anything — unless the skill you are running names an explicit exception. diff --git a/.agents/GLOSSARY.md b/.agents/GLOSSARY.md index 12a88091119..b6f92ffeb79 100644 --- a/.agents/GLOSSARY.md +++ b/.agents/GLOSSARY.md @@ -36,9 +36,10 @@ vars are documented in `docs/ENVS.md`. Architectural concepts like | **Connect Wallet** | feature | Lets users write to contracts, sign transactions, and connect a wallet to the explorer. Previously named `blockchain-interaction`; the current config key is `connectWallet`. Distinct from **Web3 Wallet**. | | **Dispute Games** | entity | Part of the Optimism **Fault Proof System**. On-chain games used to challenge and resolve disputed L2 output roots. | | **Easter Eggs** | feature | Hidden mini-games wired to claim links for badge rewards. | +| **Eden** | chain | A rollup built on `ev-reth` / evstack. Introduces the **Sponsored Transaction** type. | | **Epoch** | entity | A consensus time period specific to **Celo**. Has its own index and detail pages. Always refers to a Celo epoch in this codebase — not a generic blockchain concept. | | **Fault Proof System** | feature | Optimism's mechanism for proving the correctness of L2 state transitions on L1 via **Dispute Games**. | -| **Flashblocks** | feature | MegaETH's sub-second block streaming mechanism. | +| **Flashblocks** | feature | Code name for the sub-second pre-confirmation block streaming feature. Surfaced to users as **Subblocks** on OP Stack chains (OP Labs renamed it from "Flashblocks") and as **mini-blocks** on MegaETH. The `flashblocks` code name and `FLASHBLOCKS` env vars are retained. | | **Hot Contracts** | feature | Ranked list of the most recently and frequently interacted-with smart contracts on the network. | | **Interchain Indexer** | service | Microservice that indexes cross-chain messages and token transfers across heterogeneous chains. General-purpose interop indexer, not ZetaChain-specific. Provides "Cross chain txs" feature. Distinct from **CCTX**. | | **Interop Messages** | entity | **Deprecated** Cross-rollup messages passed between OP Stack chains using the native interoperability protocol. Distinct from **Interchain Indexer** messages. | @@ -52,6 +53,7 @@ vars are documented in `docs/ENVS.md`. Architectural concepts like | **Rewards** | feature | The Blockscout Merits program — a token rewards and incentives system operated by Blockscout. Entirely distinct from **Block Reward** (on-chain block-producer payouts). | | **Rollup** | concept | A chain that settles transactions on a parent (L1) chain. Introduces specific entities: deposits, withdrawals, transaction batches, output roots. Contrast with **Chain Variant**. | | **SolidityScan** | service | Third-party smart contract security vulnerability scanner integrated into contract detail pages. | +| **Sponsored Transaction** | entity | **Eden**-specific transaction type (EIP-2718 type `0x76`): an executor submits an ordered batch of calls, while a separate sponsor signs for and pays the fee. No equivalent on standard EVM chains. | | **SUAVE** | chain | MEV-focused chain developed by Flashbots, built around a trusted execution environment (TEE) architecture. Introduces the **Kettle** entity. | | **TAC (Ton Application Chain)** | chain | A chain that bridges the TON blockchain and EVM ecosystems. Introduces the **Operation** entity. | | **Tx Actions** | feature | Structured per-transaction action breakdown rendered on the tx details page — a first-party Blockscout interpretation of what a tx did. Distinct from **Tx Interpretation** (natural-language summary) and from raw calldata. | diff --git a/.agents/README.md b/.agents/README.md index 75c2b0cdb0a..45bf5e38ee2 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -8,9 +8,11 @@ here; each tool reads it through its own directory via symlinks. | `AGENTS.md` | Always-loaded project context | `.claude/CLAUDE.md` → here; Cursor reads `AGENTS.md` natively | | `rules/*.md` | Coding rules, scoped to file patterns | `.claude/rules/` and `.cursor/rules/` — see below | | `skills/*/SKILL.md` | Workflows loaded on invocation | `.claude/skills/` → here | -| `delegation.md`, `GLOSSARY.md`, `TEAM.md` | Read on demand, by pointer from `AGENTS.md` or a skill | — | +| `delegation.md`, `GLOSSARY.md`, `TEAM.md`, `slack-thread.md` | Read on demand, by pointer from `AGENTS.md` or a skill | — | | `tasks/` | Product-task specs — see `tasks/README.md` | — | +Cross-references between all of these are machine-checked: `pnpm lint:doc-links`. + ## The rules contract Claude Code and Cursor discover rules differently and neither reads the other's format, so a rule file diff --git a/.agents/TEAM.md b/.agents/TEAM.md index 7977ea23cd9..7dd933ca0df 100644 --- a/.agents/TEAM.md +++ b/.agents/TEAM.md @@ -1,60 +1,169 @@ -# Team roster for product tasks +# Team & Slack directory -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 people, teams, and channels the team reaches on Slack, and the IDs needed to address them. This file is +the **single source of truth** for turning a name into a Slack ID: refer to people, teams, and channels **by +name** in specs, skills, and requests, and resolve the ID here whenever you actually need to send a message +or build a mention or link. It is a registry only — *who* gets asked *what* is routing policy that lives in +the skills that read it. -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: +A **✓** in a team's *Default* column flags that team's fallback member — the one to contact when a request +doesn't name a specific person. + +Slack IDs are stored so addressing is deterministic (no runtime name lookup). They are workspace-scoped +identifiers, **not credentials** — knowing one grants no access. To find your own member ID in Slack: your profile → **⋯ More** → **Copy member ID**. Do not add emails to this file. +## How to address + +- **Person** — the *Slack member ID* (`U…`) from the People table. Mention as `<@U…>`; to DM, pass the ID as + the channel to the send tool. +- **Team / group** — the *Slack group ID* (`S…`) from the Groups table. Mention as ``. +- **Channel** — the *Channel ID* (`C…`). Pass it as the channel target when sending; refer to it in prose by + its `#name`. +- **Permalink** — `https://blockscout.slack.com/archives//p` (the message timestamp + with the dot removed). + ## Product managers Own: product intent, scope, priorities, user stories, acceptance. -| Name | GitHub | Slack member ID | Focus | | -| --- | --- | --- | --- | --- | -| Ulyana | @ulyanas | U024DUPJG3A | | default | -| Nikita S. | @NikitaSavik | U05BR9QEYKB | | | +### People + +| Name | GitHub | Slack member ID | Default | +| --- | --- | --- | --- | +| Ulyana | @ulyanas | U024DUPJG3A | ✓ | +| Nikita S. | @NikitaSavik | U05BR9QEYKB | | ## Designers Own: mockups, missing screens/states, visual decisions. -| Name | GitHub | Slack member ID | Focus | | -| --- | --- | --- | --- | --- | -| Tatyana | @tgladilina | U039P3QLP0A | | default | +### People + +| Name | GitHub | Slack member ID | Default | +| --- | --- | --- | --- | +| Tatyana | @tgladilina | U039P3QLP0A | ✓ | + +## Core API + +Own: core API endpoints and response models, field propagation across services, backend release schedule. + +### People + +| Name | GitHub | Slack member ID | Default | +| --- | --- | --- | --- | +| Victor | @vbaranov | U8L403FEG | ✓ | +| Nikita P. | @nikitosing | U0218K3MTC5 | | + +### Groups -## Backend engineers +| Team | Slack group ID | +| --- | --- | +| Core API | S064H6TD6MA | -Own: API endpoints, response models, field propagation across services, backend release schedule. +## Microservices API -| Name | GitHub | Slack member ID | Focus | | +Own: microservice API endpoints and their response models (metadata, stats, admin, interchain, etc.). + +### People + +| Name | GitHub | Slack member ID | Focus | Default | | --- | --- | --- | --- | --- | -| 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 | | +| Leonid | @lok52 | U01KDJWBCV7 | | ✓ | +| Evgenii | @EvgenKor | U026N2LB01E | Interchain Indexer, TAC | | + +### Groups + +| Team | Slack group ID | +| --- | --- | +| Microservices API | S064073HASK | + +### Channels + +| Purpose | Channel | Channel ID | +| --- | --- | --- | +| Default for microservices questions | blockscout-rs | C03G1QASRJ8 | +| Metadata microservice | blockscout-metadata-service | C067RACJ99B | +| Admin RS microservice | blockscout-admin | C04TC4W81QV | +| Stats microservice | blockscout-stats-rs | C089CJF6P0X | ## Frontend Own: architecture, the delegation boundary. -| Name | GitHub | Slack member ID | Focus | | -| --- | --- | --- | --- | --- | -| tom | @tom2drum | U03MN1588AU | | default | -| Max | @maxaleks | UKP0RR9K9 | | default | +### People + +| Name | GitHub | Slack member ID | Default | +| --- | --- | --- | --- | +| tom | @tom2drum | U03MN1588AU | ✓ | +| Max | @maxaleks | UKP0RR9K9 | | + +### Channels + +| Purpose | Channel | Channel ID | +| --- | --- | --- | +| Default for frontend questions | blockscout-frontend | C03MMUTQDNU | +| Ask a frontend engineer to prepare an instance's config | front-config-requests | C08D60ZL1QB | + +### Groups + +| Team | Slack group ID | +| --- | --- | +| Frontend team | S0601760KT9 | + +## QA + +Own: test plans, acceptance-criteria verification, regression coverage, release sign-off. + +### People + +| Name | GitHub | Slack member ID | Default | +| --- | --- | --- | --- | +| Yan | @yvaskov | U05Q4R111PB | ✓ | +| Alyona | @alyonakostina | U08NCHV535X | | + +### Channels + +| Purpose | Channel | Channel ID | +| --- | --- | --- | +| General questions | blockscout-qa | C059WER5EB1 | + +### Groups + +| Team | Slack group ID | +| --- | --- | +| QA team | S06015J7WVD | + +## DevOps + +Own: deployment and running-instance changes (env vars, image versions, restarts), CI/CD, and infrastructure. + +### People + +| Name | GitHub | Slack member ID | Default | +| --- | --- | --- | --- | +| Nick | @nzenchik | U04RVGGEW4Q | ✓ | +| Alik | @alik-agaev | U06287SP35W | | + +### Channels + +| Purpose | Channel | Channel ID | +| --- | --- | --- | +| General questions | blockscout-devops | C03K1932X1N | +| Requests to change a running instance (env vars, image versions, restarts, etc.) | blockscout-devops-requests | C050U1F2E9M | + +### Groups + +| Team | Slack group ID | +| --- | --- | +| DevOps team | S061MTPLJHK | -## Slack channels +## Product 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. +Dedicated channels for specific large tasks — a single place to gather a feature's requirements and related +discussion. | Purpose | Channel | Channel ID | | --- | --- | --- | -| Default for product questions | blockscout-frontend | C03MMUTQDNU | | Multichain explorer | blockscout-multichain-explorer | C08R0UNBE3A | | Cross-chain transactions | dev-interchain | C0A7SALNLPL | diff --git a/.agents/adr/0001-webpack-for-production-builds.md b/.agents/adr/0001-webpack-for-production-builds.md new file mode 100644 index 00000000000..3e8278bba31 --- /dev/null +++ b/.agents/adr/0001-webpack-for-production-builds.md @@ -0,0 +1,108 @@ +# 0001 — webpack for production builds, Turbopack for dev + +| | | +| --- | --- | +| Status | superseded by 0003 | +| Date | 2026-08-04 | +| Deciders | @tom2drum | +| Supersedes | — | + +## Decision + +**Production builds use webpack (`next build --webpack`). Dev keeps Turbopack (the Next 16 default).** + +Applies to every entry point that emits a production bundle: + +| Entry point | Bundler | +| --- | --- | +| `pnpm build` — what the `Dockerfile` runs for the shipped image | webpack | +| `pnpm build:next` | webpack | +| `pnpm prod:preset ` — local production build, incl. perf measurements | webpack | +| `pnpm build:analyze`, `pnpm prod:preset --profile` | webpack (already were) | +| `pnpm dev`, `pnpm dev:preset`, `pnpm dev:local` | Turbopack | + +Dev stays on Turbopack because it is roughly 3× faster to compile and the crash class below only +manifests in a minified production build. Production-build regressions are caught in QA rather than +by making every local dev start slower. + +## Why + +### Turbopack miscompiles the Dynamic-labs SDK + +Turbopack's scope hoisting emits code that reads the SDK's `UserFieldEditorContext` through the +wrong binding. `useContext` therefore receives a non-context value, returns `undefined`, and the SDK +throws from its own `useUpdateUserWithModal`: + +``` +useUserUpdateRequest can only be used inside the context of DynamicContextProvider +``` + +The throwing component is the SDK's internal `SyncAuthFlow`, which the SDK itself renders *inside* +`UserFieldEditorContextProvider` — so in a correct build the context cannot be missing. It is a +bundler defect, not a provider-tree bug in our code. + +Impact: **a hard crash on the initial load of every page**, for any instance configured with +`NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER=dynamic`. It is invisible in dev (unminified, no hoisting) and +was found only by running the `v2.10.0` image locally. The v2.10.0 release would have broken every +dynamic-auth instance on rollout; deployed instances were still on v2.9.4 and unaffected. + +Bisected to [#3574](https://github.com/blockscout/frontend/pull/3574) (wallet-stack deferral, +subtask 4 of [#3566](https://github.com/blockscout/frontend/issues/3566)) — parent commit good, that +commit bad. The trigger could **not** be reduced to a single import: reverting the lazy `import()` +wrappers, the `_app.tsx` provider restructure, and the `@wagmi/core` dependency each left it broken. +That fits the mechanism — scope hoisting groups modules across the whole graph, so the trigger is an +emergent property of how #3574 reshaped it, and any future graph change could re-trigger it +somewhere else. Next 16.3.0 does not fix it. + +### webpack is also the faster bundle + +Two options fixed the crash: `--webpack`, or `experimental.turbopackScopeHoisting: false`. The flag +turned out to be the expensive one. Production builds of `main`, medians of 3 +automated traces: + +| Metric | Turbopack | Turbopack, hoisting off | **webpack** | +| --- | --- | --- | --- | +| M1 FCP | 432 ms | 790 ms | **501 ms** | +| M2 first API request | 60 ms | 142 ms | **57 ms** | +| M5 blocking time | 133 ms | 408 ms | **155 ms** | +| M6 JS before FCP | 1038 KB | 1064 KB | **697 KB** | +| Emitted chunk bytes | 49.2 MB | 53.4 MB | **21.4 MB** | +| Build time | 48 s | 41 s | 2.4 min | + +Disabling scope hoisting nearly doubles FCP and triples blocking time while barely moving M6 (+2.5%) +— the cost lands in execution, not transfer, so M6 alone would not have caught it. webpack instead +*improves* pre-FCP JS by 341 KB (−33%) over the Turbopack build, more than any single lever in #3566 +delivered on its own. + +The measurement harness lives in +`.agents/tasks/3566-main-page-loading-perf/tools/` (see its README). Absolute values come from +headless Chromium on a local server and are not comparable to the numbers in that task's spec table; +the within-comparison deltas are what the decision rests on. + +## Consequences + +- **CI and image builds get slower** — webpack's compile step measured 84 s to 2.4 min across + machines and cache states, against 41–48 s for Turbopack, so budget roughly 2–3×. Accepted: + correctness plus a materially smaller bundle outweigh build latency. +- **Dev and production now use different bundlers.** A bug in either pipeline can only be caught on + that pipeline; production-only breakage will not appear in dev. QA runs against a real image. +- `next.config.js` must keep **both** the `webpack()` and `turbopack` sections in sync — it already + does, and this decision makes that non-optional. +- webpack surfaces one unresolvable import Turbopack silently tolerates: + `@react-native-async-storage/async-storage` inside `@metamask/sdk`, reached via + `@wagmi/connectors` → `@reown/appkit-adapter-wagmi` → `wagmi-config.ts`. It is an optional peer + dependency of a React Native code path a browser bundle never takes, so `next.config.js` maps it + to `false` in `resolve.fallback` (an empty module) and the build is warning-free. If a future + dependency bump introduces a similar optional import, extend that map rather than silencing + warnings wholesale. +- `next build --webpack` is a compatibility path in Next 16 and may eventually be removed. If that + happens before Turbopack is fixed, the fallback is `experimental.turbopackScopeHoisting: false` + and its performance cost. + +## Follow-ups + +- Report the miscompilation upstream to `vercel/next.js` with a minimal reproduction; the bisect + boundary and the flag that toggles it are the material. +- Re-test Turbopack on each Next upgrade. If a release fixes it, revisit — Turbopack's build speed + is worth reclaiming, but only with the M1/M5/M6 numbers above re-measured, not on the release + notes alone. diff --git a/.agents/adr/0002-layer-shaped-ticket-leaves.md b/.agents/adr/0002-layer-shaped-ticket-leaves.md new file mode 100644 index 00000000000..f436cbe869a --- /dev/null +++ b/.agents/adr/0002-layer-shaped-ticket-leaves.md @@ -0,0 +1,53 @@ +# 0002 — tickets cut vertically, leaves run along layers + +| | | +| --- | --- | +| Status | accepted | +| Date | 2026-08-11 | +| Deciders | @tom2drum | +| Supersedes | — | + +## Decision + +**A ticket is a vertical slice; the leaves inside it are layer-shaped.** + +``` +tickets/01-cross-chain-list/ ← vertical: demoable, one context window, one commit + leaf 1 [agent] add-api-resource — declare the resource + leaf 2 [agent] add-new-page — tab route + scaffold + leaf 3 [agent] wire the resource into the table + leaf 4 [human] style to mockup +``` + +"The ticket model" in `.agents/tasks/concepts.md` defines both levels and every rule that follows from them; +this record holds only the reasoning, which that file should not have to carry. + +## Why + +The tracer-bullet norm says every unit of work should be a vertical slice, all the way down. Ours stops one +level short, deliberately. + +**Layer-shaped leaves are what make execution mechanical.** The project skills are layer-shaped by +construction — `add-api-resource` declares a resource, `add-new-page` scaffolds a route. A leaf that maps +one skill to one step lets `implement-ticket` execute it without deciding anything: open the skill, read the +Skill inputs `to-tickets` already collected, run. Force a leaf to be vertical and it spans three skills, so +the executor has to compose them itself — the interesting decisions move from the ticket, where a human +reviewed them, into an unattended run. + +**Vertical tickets are what make review and verification meaningful.** An API resource reviewed alone cannot +be judged against the thing that consumes it, and a scaffold with no data cannot be verified by looking at +the running product. Grouping the leaves into a slice that renders gives both a real target: the review +reads one coherent diff, and a `(human)` acceptance criterion has something to be true about. + +So the two levels answer two different questions. *What can an agent execute without judgement?* — a leaf. +*What can a human judge?* — a ticket. Aligning both to the same axis would sacrifice one of them. + +## Consequences + +- The review unit is the ticket, so a leaf's code can be wrong for as long as it takes the slice to + finish. Accepted deliberately: reviewing every leaf spent a subagent per axis on every step, and most of + what it caught was churn the next leaf rewrote anyway. +- Leaves stop being run boundaries, which buys the review unit above at the cost of needing a resumption + mechanism inside a ticket that has no commit yet. The workflow layer owns how that works. +- Nesting is unnecessary. Work too big for one ticket becomes more tickets with blocking edges between + them, never tickets inside tickets — which is what let the sub-branch and sub-PR machinery go. diff --git a/.agents/adr/0003-turbopack-for-production-builds.md b/.agents/adr/0003-turbopack-for-production-builds.md new file mode 100644 index 00000000000..7b42a2dd9f1 --- /dev/null +++ b/.agents/adr/0003-turbopack-for-production-builds.md @@ -0,0 +1,98 @@ +# 0003 — Turbopack for production builds + +| | | +| --- | --- | +| Status | accepted | +| Date | 2026-08-18 | +| Deciders | @tom2drum | +| Supersedes | 0001 | + +## Decision + +**Production builds use Turbopack (the Next.js default — `next build`, no flag). Dev already uses +Turbopack.** This reverses [0001](0001-webpack-for-production-builds.md), which forced +`next build --webpack` because Turbopack miscompiles the Dynamic-labs SDK. + +Two things stay on webpack, because they depend on the webpack pipeline specifically: + +| Entry point | Bundler | Why | +| --- | --- | --- | +| `pnpm build`, `pnpm build:next` — the shipped image | Turbopack | default | +| `pnpm prod:preset ` | Turbopack | matches the image | +| `pnpm dev`, `pnpm dev:preset`, `pnpm dev:local` | Turbopack | default | +| `pnpm build:analyze` | webpack | `@next/bundle-analyzer` is a webpack plugin | +| `pnpm prod:preset --profile` | webpack | `--profile`'s `react-dom/profiling` alias is guaranteed on webpack, undocumented on Turbopack (see `tools/profiling/CONTEXT.md`) | + +`next.config.js` keeps both the `turbopack` and `webpack()` sections — the webpack section is still +live for the two exceptions above, and the two must stay in sync. + +## Why + +### The crash that forced webpack is fixed + +0001's whole case was a Turbopack scope-hoisting bug that mis-bound `UserFieldEditorContext` inside +the Dynamic-labs SDK and hard-crashed every page of any `NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER=dynamic` +instance — in production only, invisible in dev. It was reported upstream and fixed in +**Next 16.3.1**. + +Verified on this version: a Turbopack production build served against the `rootstock` preset (which +uses the dynamic provider) loads clean — home page renders, the Dynamic login modal opens, and the +console shows zero `DynamicContextProvider` errors. The exact failure 0001 documented no longer +reproduces. + +### Turbopack is now faster to build *and* faster at runtime + +0001 kept webpack partly because, back then, webpack also produced the lighter, faster-executing +bundle. That is no longer true. Re-measured on Next 16.3.1 with the `rootstock` preset — medians of +3 headless runs via `.agents/tasks/3566-main-page-loading-perf/tools/`, same machine and method, so +only the within-comparison deltas are meaningful: + +| Metric | webpack | **Turbopack** | Δ | +| --- | --- | --- | --- | +| M1 FCP | 2047 ms | **1049 ms** | −49% | +| M2 first API request | 123 ms | **55 ms** | −55% | +| M5 blocking time | 1444 ms | **453 ms** | −69% | +| M6 JS before FCP | 2235 KB gz | 2575 KB gz | +15% | +| Bundling time (`next build`) | 186 s | **87 s** | −53% | + +Turbopack ships ~15% more JS before FCP (M6) yet halves FCP and cuts blocking time by roughly +two-thirds — the cost that used to live in execution is gone, so the extra bytes do not hurt load +here. This is the inverse of 0001's table, where disabling scope hoisting (its stand-in for the fix) +*tripled* blocking time. Whatever changed in the hoisting fix flipped the runtime result, not just +the correctness one. M6 alone would have called this a regression; M1 and M5 are why it is not. + +Build time is the motivation: webpack's bundling step had grown slow enough to drag out CI. Turbopack +bundles in less than half the wall-clock (the type-check phase is bundler-agnostic and unchanged). + +## Consequences + +- **`experimental.useTypeScriptCli: false` is now required** in `next.config.js`. Next 16.3 defaults + build-time type-checking to the `tsc` CLI, which needs a `typescript/bin/tsc` binary; this repo + runs the native TypeScript compiler via the `@typescript/typescript6` alias, which ships `bin/tsc6` + only. The compiler-API path (this flag `false`) checks against `lib/typescript.js`, which the alias + does provide, so type-checking runs normally. Without it the build aborts claiming `typescript` is + missing — on **both** bundlers, since the check runs before bundling. +- **Dev and production are on the same bundler again.** 0001's "a production-only bug can't be caught + in dev" caveat is lifted for the default path — though the two webpack exceptions (`build:analyze`, + `--profile`) still exercise a second pipeline. +- **The `@react-native-async-storage/async-storage` fallback** that 0001 added to `resolve.fallback` + only applies to the webpack section. Turbopack silently tolerates that optional import, so no + Turbopack-side equivalent is needed. +- **CI and image builds get faster** — the reason for the change. +- **`outputFileTracingIncludes` now force-includes `@swc/helpers`.** Turbopack's standalone tracer + copies only `@swc/helpers/cjs` and drops the `esm/` entry points that Next's `require-hook` loads + at runtime, so the shipped image's `node server.js` crashed on boot (`Cannot find module + '@swc/helpers/esm/_interop_require_default.js'`). webpack traced the package fully, so this only + surfaced after the switch — and only on the standalone path, not under `next start`, so it was + invisible until a demo deploy. The `next.config.js` include is a workaround; drop it once the + Turbopack tracer is fixed upstream. + +## Follow-ups + +- Watch for a regression of the scope-hoisting class on future Next upgrades — the trigger was never + reducible to a single import, so any large change to the dynamic-mode provider graph could + re-surface a similar defect. `src/features/connect-wallet/CONTEXT.md` carries the standing rule to + verify graph changes against a dynamic-provider production build. +- The `useTypeScriptCli: false` workaround exists because Next's CLI type-check path does not + recognize the `@typescript/typescript6` alias's `bin/tsc6`. If a later Next release accepts the + native compiler's binary (or the alias ships a `bin/tsc`), the flag can be dropped. diff --git a/.agents/delegation.md b/.agents/delegation.md index 24e5f8491f6..cde1cdcad77 100644 --- a/.agents/delegation.md +++ b/.agents/delegation.md @@ -1,8 +1,8 @@ # Delegation boundary This is a **living document**. It records what work agents are trusted to do in this repo *today*, and what -stays with a human developer for now. The `grill-the-task` / `to-spec` skills consult it when tagging spec -subtasks `[agent]` or `[human]`; the `implement-task` skill obeys it when executing. As the repo becomes more +stays with a human developer for now. The `to-tickets` skill consults it when tagging a ticket's leaves +`[agent]` or `[human]`; the `implement-ticket` skill obeys it when executing. As the repo becomes more agent-friendly, loosen the boundary here via a normal PR — don't renegotiate it per task. ## Agents may do today @@ -12,7 +12,10 @@ agent-friendly, loosen the boundary here via a normal PR — don't renegotiate i - Page scaffolding and route plumbing — navigation, metadata, guards, route types, sitemap, page-type analytics (`add-new-page` skill). - Data wiring: hooks, query integration, rendering fetched data plainly. -- Component **scaffolds** (see the UI split below). +- Component **scaffolds** — file placement, props and types, data fetching, behavioral states + (loading / empty / error / pagination), semantic structure built from existing toolkit components, and + placeholder presentation. Every deferred visual decision is marked `TODO (design):` (the convention the + `add-new-page` templates use), which is what makes the handover to a human explicit. - Unit tests (`*.spec.ts` / `*.spec.tsx`) and Playwright test **scaffolds** (`*.pw.tsx` files, fixtures, mock data). - Glossary and docs updates, demo deploys (`deploy-demo` skill). @@ -20,26 +23,6 @@ agent-friendly, loosen the boundary here via a normal PR — don't renegotiate i - Final markup and styling that must match the designer's Figma mockups; visual polish of any kind. - Choosing nav/sprite icons and other visual assets. -- Generating and eyeballing Playwright screenshot baselines. +- Generating and eyeballing Playwright screenshot baselines — and only **once the component matches the + mockup**, because a baseline of placeholder presentation is worse than no baseline at all. - Design sign-off. - -## Default UI split: scaffold → style - -Every UI subtask in a spec is split into two linked subtasks by default: - -1. **`[agent]` scaffold** — file placement, props and types, data fetching, behavioral states - (loading / empty / error / pagination), semantic structure built from existing toolkit components, - placeholder presentation. Deferred visual decisions are marked `TODO (design):` (same convention as the - `add-new-page` templates). -2. **`[human]` style** — take the scaffold to the mockup: layout, spacing, styling, icons. The spec links the - exact Figma node for this step. - -## Testing policy (standing — not asked per task) - -- Unit tests are written by the agent as part of whichever `[agent]` subtask they cover. -- Playwright visual test files are scaffolded by the agent in the scaffold subtask. -- Screenshot baselines are generated and reviewed by the human **after** the style subtask — a baseline is - only meaningful once the component matches the mockup. -- Test the behavior that matters, not the obvious — follow the "What to test (and what not)" guidance in - `.agents/rules/tests-unit.md`. More tests are not better; a test that only asserts the framework or the - mock is noise. diff --git a/.agents/rules/design-system.md b/.agents/rules/design-system.md index 242a9eb5fb8..4a082337573 100644 --- a/.agents/rules/design-system.md +++ b/.agents/rules/design-system.md @@ -15,23 +15,23 @@ The app uses **Chakra UI v3** as its component and styling foundation. ## Project configuration -The design system is layered on top of Chakra UI inside `toolkit/`: +The design system is layered on top of Chakra UI inside `src/toolkit/`: | Path | Purpose | |---|---| -| `toolkit/chakra/` | Custom wrappers for Chakra components — always prefer these over bare Chakra imports | -| `toolkit/theme/theme.ts` | Theme entry point; uses Chakra v3's `createSystem` API to merge defaults with project config | -| `toolkit/theme/foundations/semanticTokens.ts` | Full list of semantic color tokens (text, bg, border, icon, component-level tokens) | -| `toolkit/theme/foundations/colors.ts` | Raw color palette referenced by semantic tokens | -| `toolkit/theme/recipes/` | Component style recipes (slot recipes and simple recipes) | -| `toolkit/components/` | Custom business components (forms, charts, tabs, etc.) built on top of Chakra | -| `toolkit/hooks/` | Shared React hooks (useDisclosure, useClipboard, etc.) | +| `src/toolkit/chakra/` | Custom wrappers for Chakra components — always prefer these over bare Chakra imports | +| `src/toolkit/theme/theme.ts` | Theme entry point; uses Chakra v3's `createSystem` API to merge defaults with project config | +| `src/toolkit/theme/foundations/semanticTokens.ts` | Full list of semantic color tokens (text, bg, border, icon, component-level tokens) | +| `src/toolkit/theme/foundations/colors.ts` | Raw color palette referenced by semantic tokens | +| `src/toolkit/theme/recipes/` | Component style recipes (slot recipes and simple recipes) | +| `src/toolkit/components/` | Custom business components (forms, charts, tabs, etc.) built on top of Chakra | +| `src/toolkit/hooks/` | Shared React hooks (useDisclosure, useClipboard, etc.) | -The `Provider` component at `toolkit/chakra/provider.tsx` wraps `ChakraProvider` with the custom theme and color mode support. It must be mounted at the app root. +The `Provider` component at `src/toolkit/chakra/provider.tsx` wraps `ChakraProvider` with the custom theme and color mode support. It must be mounted at the app root. ## Component import priority -Always check `toolkit/chakra/` before importing from Chakra UI directly; if a wrapper exists there, use it. +Always check `src/toolkit/chakra/` before importing from Chakra UI directly; if a wrapper exists there, use it. ESLint blocks the wrapped components by name, but the list is not exhaustive — the rule applies to any component that has a wrapper, caught or not. @@ -39,7 +39,7 @@ component that has a wrapper, caught or not. Never use raw color values (hex, RGB, HSL). Always reference a token. Three sources are valid: -1. **Semantic tokens** — context-aware, light/dark aware. Full list in `toolkit/theme/foundations/semanticTokens.ts`. Prefer these whenever a semantic meaning exists. +1. **Semantic tokens** — context-aware, light/dark aware. Full list in `src/toolkit/theme/foundations/semanticTokens.ts`. Prefer these whenever a semantic meaning exists. ```tsx @@ -48,13 +48,13 @@ Never use raw color values (hex, RGB, HSL). Always reference a token. Three sour Common groups: `text.*`, `bg.*`, `border.*`, `icon.*`, `link.*`, `button.*`, `badge.*`. -2. **Project color palette** — scale and alpha colors defined in `toolkit/theme/foundations/colors.ts`: `gray`, `blue`, `red`, `orange`, `yellow`, `green`, `teal`, `cyan`, `purple`, `pink` (steps 50–900), `black`, `white`, `whiteAlpha.*`, `blackAlpha.*`. +2. **Project color palette** — scale and alpha colors defined in `src/toolkit/theme/foundations/colors.ts`: `gray`, `blue`, `red`, `orange`, `yellow`, `green`, `teal`, `cyan`, `purple`, `pink` (steps 50–900), `black`, `white`, `whiteAlpha.*`, `blackAlpha.*`. ```tsx ``` -3. **Brand colors** — also defined in `toolkit/theme/foundations/colors.ts`: `github`, `telegram`, `linkedin`, `discord`, `slack`, `twitter`, `opensea`, `facebook`, `medium`, `reddit`, `celo`, `clusters`. +3. **Brand colors** — also defined in `src/toolkit/theme/foundations/colors.ts`: `github`, `telegram`, `linkedin`, `discord`, `slack`, `twitter`, `opensea`, `facebook`, `medium`, `reddit`, `celo`, `clusters`. ```tsx @@ -64,16 +64,16 @@ If a raw color value is truly unavoidable (e.g. a third-party embed), leave a co ## Design tokens -The project customizes the following Chakra token categories in `toolkit/theme/`. Always use these tokens instead of raw CSS values: +The project customizes the following Chakra token categories in `src/toolkit/theme/`. Always use these tokens instead of raw CSS values: | Token type | File | Example | |---|---|---| -| Border radius | `foundations/borders.ts` | `borderRadius="md"` instead of `borderRadius="12px"` | -| Shadows | `foundations/shadows.ts` | `boxShadow="size.md"` instead of a custom `box-shadow` | -| Z-index | `foundations/zIndex.ts` | `zIndex="modal"` instead of a raw number | -| Font weights | `theme.ts` (inline) | `fontWeight="semibold"` instead of `fontWeight={600}` | -| Durations | `foundations/durations.ts` | Use duration tokens for CSS transitions | -| Keyframes | `foundations/animations.ts` | Reference named keyframes for custom animations | +| Border radius | `src/toolkit/theme/foundations/borders.ts` | `borderRadius="md"` instead of `borderRadius="12px"` | +| Shadows | `src/toolkit/theme/foundations/shadows.ts` | `boxShadow="size.md"` instead of a custom `box-shadow` | +| Z-index | `src/toolkit/theme/foundations/zIndex.ts` | `zIndex="modal"` instead of a raw number | +| Font weights | `src/toolkit/theme/theme.ts` (inline) | `fontWeight="semibold"` instead of `fontWeight={600}` | +| Durations | `src/toolkit/theme/foundations/durations.ts` | Use duration tokens for CSS transitions | +| Keyframes | `src/toolkit/theme/foundations/animations.ts` | Reference named keyframes for custom animations | Available `radii` tokens: `none`, `sm` (4px), `base` (8px), `md` (12px), `lg` (16px), `xl` (24px), `full`. @@ -91,7 +91,7 @@ Do not set `fontSize` or `lineHeight` directly. Apply the appropriate `textStyle Label ``` -Available text styles (defined in `toolkit/theme/foundations/typography.ts`): +Available text styles (defined in `src/toolkit/theme/foundations/typography.ts`): | Token | fontSize / lineHeight | |---|---| @@ -111,7 +111,7 @@ For a regular text block, the `text.` prefix can be omitted. Do not override the default spacing of **internal parts** of compound components (e.g. adding custom padding to `DialogHeader` inside a `Dialog`, or to a `MenuList` item). The root component itself may be spaced freely; its sub-parts may not. -This rule applies to all components from `toolkit/chakra/`. +This rule applies to all components from `src/toolkit/chakra/`. ## Duplicated style props diff --git a/.agents/rules/tests-unit.md b/.agents/rules/tests-unit.md index c460f90a7be..4c5cec72f83 100644 --- a/.agents/rules/tests-unit.md +++ b/.agents/rules/tests-unit.md @@ -11,27 +11,100 @@ alwaysApply: false Unit tests cover logic that is independent of visual presentation: utility functions, custom hooks, and component behavior (state transitions, conditional rendering, event handling). If a change has no visual output to verify, prefer a Vitest test over a Playwright one — it is faster and cheaper. -## What to test (and what not) +## What a good test is -More tests are not better. A test earns its place by pinning behavior that could plausibly break; a test -that restates the implementation or the framework only adds maintenance cost and false confidence. +Tests verify behavior through public interfaces, not implementation details. Code can change entirely; tests shouldn't. +A good test reads like a specification — "user can checkout with valid cart" tells you exactly what capability exists — +and survives refactors because it doesn't care about internal structure. -**Worth testing:** -- Branching and conditional logic — the non-obvious paths through a function. -- State transitions, event handling, and the loading / empty / error / success states of a component. -- Parsing, formatting, calculation, and data-shaping logic — especially edge cases (empty, zero, null, - boundary values, malformed input). -- Regressions — a test that reproduces a fixed bug so it stays fixed. +### Good Tests -**Not worth testing:** -- Framework or library behavior (that a Chakra prop renders, that `react-query` caches) — trust the deps. -- Trivial pass-throughs: a getter that returns a field, a component that only forwards props with no logic. -- Types — `tsc` already proves them; don't add a runtime test to check a type. -- Tests whose assertions only echo the mock you set up, exercising no real code of your own. -- Snapshotting large trees "for coverage" — a snapshot must capture something a human decision depends on. +**Integration-style**: Test through real interfaces, not mocks of internal parts. -When in doubt, ask: *if I delete this test, what real defect could now ship unnoticed?* If the honest -answer is "none", don't write it. +```typescript +// GOOD: Tests observable behavior +test("user can checkout with valid cart", async () => { + const cart = createCart(); + cart.add(product); + const result = await checkout(cart, paymentMethod); + expect(result.status).toBe("confirmed"); +}); +``` + +**Characteristics:** + +- Tests behavior users/callers care about +- Uses public API only +- Survives internal refactors +- Describes WHAT, not HOW + +### Bad Tests + +**Implementation-detail tests**: Coupled to internal structure. + +```typescript +// BAD: Tests implementation details +test("checkout calls paymentService.process", async () => { + const mockPayment = jest.mock(paymentService); + await checkout(cart, payment); + expect(mockPayment.process).toHaveBeenCalledWith(cart.total); +}); +``` + +**Red flags:** + +- Mocking internal collaborators +- Testing private methods +- Asserting on call counts/order +- Test breaks when refactoring without behavior change +- Test name describes HOW not WHAT +- Verifying through external means instead of interface + +```typescript +// BAD: Bypasses interface to verify +test("createUser saves to database", async () => { + await createUser({ name: "Alice" }); + const row = await db.query("SELECT * FROM users WHERE name = ?", ["Alice"]); + expect(row).toBeDefined(); +}); + +// GOOD: Verifies through interface +test("createUser makes user retrievable", async () => { + const user = await createUser({ name: "Alice" }); + const retrieved = await getUser(user.id); + expect(retrieved.name).toBe("Alice"); +}); +``` + +**Tautological tests**: Expected value restates the implementation, so the test passes by construction. + +```typescript +// BAD: Expected value is recomputed the way the code computes it +test("calculateTotal sums line items", () => { + const items = [{ price: 10 }, { price: 5 }]; + const expected = items.reduce((sum, i) => sum + i.price, 0); + expect(calculateTotal(items)).toBe(expected); +}); + +// GOOD: Expected value is an independent, known literal +test("calculateTotal sums line items", () => { + expect(calculateTotal([{ price: 10 }, { price: 5 }])).toBe(15); +}); +``` + +## Mocking guidelines + +Mock at **system boundaries** only: + +- External APIs (payment, email, etc.) +- Time/randomness +- File system (sometimes) + +Don't mock: + +- Your own classes/modules +- Internal collaborators +- Anything you control ## File naming and location @@ -63,7 +136,7 @@ pnpm test:vitest path/to/file.spec.ts import { render, screen } from 'vitest/lib'; ``` -`vitest/lib.tsx` re-exports everything from `@testing-library/react` and replaces `render` with a custom version that wraps the component in the full app provider stack (mirroring `playwright/TestApp.tsx`): Chakra, `QueryClientProvider` (no retry, no window-focus refetch), Socket (inert), `AppContextProvider`, Marketplace, Settings, GrowthBook, Wagmi (mock connector), Rewards, and CsvExport — heavy enough to mount whole page slices in jsdom. +`vitest/lib.tsx` re-exports everything from `@testing-library/react` and replaces `render` with a custom version that wraps the component in the full app provider stack (mirroring `playwright/TestApp.tsx`): Chakra, `QueryClientProvider` (no retry, no window-focus refetch), Socket (inert by default — see below), `AppContextProvider`, Marketplace, Settings, GrowthBook, Wagmi (mock connector), Rewards, and CsvExport — heavy enough to mount whole page slices in jsdom. The `wrapper` export is also available if you need to pass it separately to RTL hooks: @@ -84,6 +157,19 @@ await flushPromises(); expect(screen.getByText('Loaded')).toBeInTheDocument(); ``` +**`vitest/utils/mockSocket.ts`** — a fake Phoenix transport whose channels join immediately, for components that keep a query disabled until `useSocketChannel` reports a join. Only the `phoenix` `Socket` is replaced; `SocketProvider`, `useSocketChannel` and `useSocketMessage` run for real: + +```tsx +import { mockSocket, MOCK_SOCKET_URL } from 'vitest/utils/mockSocket'; + +mockSocket(); +const { default: Token } = await import('./Token'); +render(); +await flushPromises(); +``` + +It uses `vi.doMock`, so it only affects modules imported **after** the call — pair it with `vi.resetModules()` and dynamic imports. Pass `socketUrl` explicitly too: without a url the provider creates no socket at all. Server-sent events are not simulated yet. + ## Mocking fetch responses `vitest-fetch-mock` is active globally. Mock responses before the code under test runs: diff --git a/.agents/skills/add-env-var/SKILL.md b/.agents/skills/add-env-var/SKILL.md index 40e686d17dd..c59f0f419c7 100644 --- a/.agents/skills/add-env-var/SKILL.md +++ b/.agents/skills/add-env-var/SKILL.md @@ -152,8 +152,8 @@ Most URL variables need a CSP allowance under `src/server/csp/policies/`. Gate the addition on the relevant config option being enabled — don't widen the CSP unconditionally. -**Exceptions** — these are already auto-included by `policies/app.ts` and -need no manual CSP work: +**Exceptions** — these are already auto-included by `src/server/csp/policies/app.ts` +and need no manual CSP work: - new API `endpoint` and `socketEndpoint` values that flow into `config.apis.*`. diff --git a/.agents/skills/create-issue-from-slack-thread/SKILL.md b/.agents/skills/create-issue-from-slack-thread/SKILL.md deleted file mode 100644 index 072bab42792..00000000000 --- a/.agents/skills/create-issue-from-slack-thread/SKILL.md +++ /dev/null @@ -1,169 +0,0 @@ ---- -name: create-issue-from-slack-thread -description: >- - Create a GitHub issue from a Slack thread conversation. Use when the user - wants to turn a Slack thread into a GitHub issue, create an issue from a Slack - conversation, or mentions creating issues from Slack links/URLs. -disable-model-invocation: true ---- - -# Create Issue from Slack Thread - -Turn a Slack thread into a well-structured GitHub issue in a Blockscout repository. - -## Prerequisites - -### 1. GitHub CLI - -This workflow uses `gh` to create issues and manage labels. **Follow the check-github-cli skill** first (ensure `gh auth status` succeeds; if not, guide the user to install/configure `gh` and do not proceed). - -### 2. Slack MCP Plugin - -This workflow uses the Slack MCP plugin (`plugin-slack-slack`) to read thread content. Before proceeding: - -- Try calling the `slack_read_thread` MCP tool with a test request to verify connectivity. -- If the Slack MCP server is not available or returns an error, tell the user: - - The Slack plugin must be enabled in Cursor. Go to **Cursor Settings > MCP** and ensure the Slack server is connected and running. - - They may need to re-authenticate the Slack plugin if the session has expired. -- Do not proceed until both `gh` and the Slack plugin are confirmed working. - -## Workflow - -### Step 1: Parse the Slack Thread URL - -The user provides a Slack thread URL. Extract `channel_id` and `message_ts` from it. - -Slack thread URLs follow these patterns: -- `https://.slack.com/archives//p` -- `https://app.slack.com/client///thread/-` - -Parsing rules: -- **channel_id**: The segment starting with `C` (e.g., `C04XXXX5DAT`). -- **message_ts**: Take the `p`-prefixed number, remove the `p`, and insert a dot before the last 6 digits. Example: `p1709834567890123` becomes `1709834567.890123`. - -If the URL cannot be parsed, ask the user for the `channel_id` and `message_ts` directly. - -### Step 2: Read the Slack Thread - -Use the `slack_read_thread` MCP tool: - -``` -Tool: slack_read_thread -Server: plugin-slack-slack -Arguments: - channel_id: "" - message_ts: "" - limit: 200 -``` - -If the thread has more than 200 messages, use the `cursor` parameter to paginate and read the full conversation. - -### Step 3: Summarize the Conversation - -Analyze the full thread and produce a technical summary that captures: - -- The core problem or request being discussed -- Relevant technical details (error messages, stack traces, affected components, versions) -- Steps to reproduce if applicable -- Any proposed solutions or workarounds mentioned -- Acceptance criteria or expected behavior if discussed - -**Mandatory rules for the summary:** -- **Never** include a link to the original Slack thread -- **Never** include names of team members or attribute statements to specific people -- Write in neutral, third-person technical language - -### Step 4: Determine the Target Repository - -Ask the user which Blockscout repository the issue should be created in. To help them decide, fetch the list of public repositories: - -```bash -gh repo list blockscout --source --no-archived --limit 100 --json name,description --jq '.[] | "\(.name): \(.description)"' -``` - -Present the most relevant repositories based on the conversation topic and ask the user to confirm the target repository. Always wait for explicit confirmation before proceeding. - -### Step 5: Fetch Available Labels - -Retrieve labels from the chosen repository: - -```bash -gh label list --repo blockscout/ --json name,description --limit 100 -``` - -Based on the issue content, select labels that correspond to the problem described. **Do not add a label if none of the available labels match the issue topic.** It is acceptable to have zero labels. - -### Step 6: Compose the Issue - -Draft the issue with: - -- **Title**: A clear, concise summary of the problem or request (imperative mood preferred, e.g., "Fix X" or "Add support for Y"). -- **Description**: A well-structured body using this template: - -```markdown -## Description - -[Core problem or request in 2-3 sentences] - -## Details - -[Technical details, error messages, affected components] - -## Steps to Reproduce - -[If applicable — numbered steps] - -## Expected Behavior - -[What should happen instead, or acceptance criteria] - -## Additional Context - -[Any other relevant technical information from the discussion] -``` - -Omit any section that has no content rather than leaving it empty. - -**Mandatory rules for the issue:** -- **Never** include a link to the original Slack thread -- **Never** include names of team members or attribute statements to specific people - -### Step 7: User Confirmation - -Before creating the issue, present the following to the user and ask for confirmation: - -1. **Repository**: `blockscout/` -2. **Title**: the proposed title -3. **Description**: the full issue body -4. **Label(s)**: the selected labels (or "None" if no labels match) - -Wait for the user to confirm or request changes. Apply any requested changes and re-confirm if needed. - -### Step 8: Create the Issue - -Once confirmed, create the issue: - -```bash -gh issue create \ - --repo blockscout/ \ - --title "" \ - --body "<body>" -``` - -If labels were selected, add them: - -```bash -gh issue edit <issue_number> --repo blockscout/<repo_name> --add-label "<label1>" --add-label "<label2>" -``` - -Alternatively, pass labels at creation time if supported: - -```bash -gh issue create \ - --repo blockscout/<repo_name> \ - --title "<title>" \ - --body "<body>" \ - --label "<label1>" --label "<label2>" -``` - -After creation, display a clickable link to the new issue. diff --git a/.agents/skills/create-issue/SKILL.md b/.agents/skills/create-issue/SKILL.md new file mode 100644 index 00000000000..476f5d2ae0a --- /dev/null +++ b/.agents/skills/create-issue/SKILL.md @@ -0,0 +1,101 @@ +--- +name: create-issue +description: >- + Create a GitHub issue from the available context. Use when the user asks + to file an issue from a source they name (Slack thread, notes, document) + or from this conversation. +--- + +# Create issue + +Turn the provided material into a thin, public-safe GitHub issue. Then stop. + +## Step 1 — Resolve the source + +Done when one body of material is in hand, or the run has stopped. + +- The user named or linked a source → that is the source. Fetch it below. +- They named nothing → this conversation is the source. +- Use only that pointer, or this conversation. + +**Fetch a pointer.** Content already in the chat (paste, attachment) is the source. For a URL or an id a connected tool can read, try those tools. To read a Slack thread, follow `.agents/slack-thread.md`. If fetch fails (no tool, auth error, unknown host), ask the user to paste the relevant notes. If they decline or paste nothing useful, stop and tell them the issue cannot be created. + +## Step 2 — Pick the topic + +Done when exactly one topic is selected, or the run has stopped. + +- Several issue-worthy topics → list them in one short round and wait. "All of them" means a separate issue per topic: confirm and create each before starting the next. +- File when the problem or request can be stated in a couple of sentences. Missing repro, acceptance criteria, or technical detail is fine — omit those sections later. +- No request or problem in the source, or the subject itself cannot be named → stop and tell them the issue cannot be created. + +## Step 3 — Draft + +Done when repository, type, labels, title and body are all decided. + +Follow the `check-github-cli` skill before any `gh` command below. Do not proceed with `gh` until `gh auth status` succeeds. + +**Repository.** Recommend one: this workspace's `origin` when it is a `blockscout/*` repo and the topic fits; otherwise a best-guess `blockscout/*` repo from the topic. Skip fork remotes. If they say it is the wrong place, list `blockscout` source repos (`gh repo list blockscout --source --no-archived --limit 100 --json name,description`) or take an `owner/name` they type. + +**Type.** Infer one of `Bug`, `Task`, or `Feature` from the topic: unexpected broken behavior → `Bug`; new user-facing capability → `Feature`; otherwise `Task`. + +**Labels.** `gh label list --repo <owner>/<name> --json name,description --limit 100`. Pick labels that match the topic; zero is fine. + +**Title.** Imperative mood ("Fix X", "Add support for Y"). + +**Body.** Neutral third-person technical language that can stand on a public tracker. From the source, take the ask plus concrete technical facts already established (errors, affected surface) — not the debugging transcript or references to the existing code. Omit empty sections: + +```markdown +## Description + +[Core problem or request in 2-3 sentences] + +## Details + +[Technical details, error messages, affected components] + +## Steps to Reproduce + +[Numbered steps] + +## Expected Behavior + +[What should happen instead, or acceptance criteria] + +## Additional Context + +[Any other relevant technical information] +``` + +**Public-safe.** People unnamed and unattributed; no links to the private source (Slack, Notion, Fireflies, internal docs); no client names; no unreleased dates or roadmap. + +## Step 4 — Confirm, create, stop + +Present repository, type, title, body, labels (or "None"), and project board (or "None") and wait. Apply requested edits and re-confirm. + +Then create: + +```bash +gh issue create \ + --repo <owner>/<name> \ + --title "<title>" \ + --body-file <path> \ + --type "<Bug|Task|Feature>" \ + --label "<label>" +``` + +Omit `--label` when there are none. Repeat `--label` for each label. Pass `--type` for `blockscout/*` repos; on a type-resolution error, retry the same create without `--type`. Write the body to a temp file so shell escaping cannot mangle it. + +If the repo is in the table, add the new issue to the specified project board. Before `item-add`, check that `gh auth status` lists a `project` scope; if it does not, tell the user to run `gh auth refresh -s project` and wait until it does: + +| Repo | Owner | Project number | +|---|---|---| +| `blockscout/frontend` | `blockscout` | 6 | +| `blockscout/blockscout` | `blockscout` | 8 | + +```bash +gh project item-add <number> --owner <owner> --url <issue-url> +``` + +Unlisted repos skip the board. A board failure does not undo the issue — report it and still show the issue link. + +Show a clickable Markdown link to the new issue. The skill is done — no grilling, speccing, or implementing unless they ask in a follow-up. diff --git a/.agents/skills/create-pr/SKILL.md b/.agents/skills/create-pr/SKILL.md index 21c2a81c22d..f17d6453e1f 100644 --- a/.agents/skills/create-pr/SKILL.md +++ b/.agents/skills/create-pr/SKILL.md @@ -19,8 +19,9 @@ Check the current branch (`git branch --show-current`) and its open PR (`gh pr l - **A. Draft placeholder** — no PR exists and the work is *not done yet*: the branch holds a freshly written spec from the task workflow (typically invoked from the `to-spec` skill, or the branch's only changes are under `.agents/tasks/`). The PR is a placeholder for work to come. -- **B. Finalize draft** — a **draft** PR already exists for the branch and the work is done (spec's last - box checked, or the user asks to make it ready for review). +- **B. Finalize draft** — a **draft** PR already exists for the branch and the work is done: reached via the + `finalize-task` handoff (which has just pruned the task folder down to `spec.md`), or the user asks to + make it ready for review. - **C. Regular PR** — no PR exists and the work is already done (a task executed without the spec workflow). This is the classic flow. - A non-draft PR already exists → don't create or update anything; write the description content as a @@ -33,30 +34,30 @@ If the signals conflict or are ambiguous, ask the user which mode they mean. The title must stand on its own — a reader who has never heard of the parent task should understand what the PR does from the title alone: -- Describe the change in plain language, scoped **accurately** — derive it from the spec's (or subtask - spec's) **Context & goal**, not from a breakdown shorthand (e.g. a primer that covers several pages is - not a "main page" change). +- Describe the change in plain language, scoped **accurately** — derive it from the spec's **Context & + goal**, not from a breakdown shorthand. - **No** issue numbers, "step N", or internal codenames/jargon (`lever 3`) in the title — those are abstract to an outside reader. The parent-task relationship lives in the **description** (`Resolves #N` + the spec link). ## Mode A — Draft placeholder (spec time) +**Reached from `to-spec`** after the developer approved the spec content: that approval already covers this +whole mode, so run steps 1–5 without re-confirming. A direct invocation keeps the confirmation in step 3. + At this stage nothing is implemented, so **do not** describe changes, env vars, or checklists — the description is a placeholder pointing at the plan: -1. **Prepare the branch** — commit the spec if needed (with the user's approval), push with `-u`. +1. **Prepare the branch** — commit `spec.md` and `questions.md` if needed (with the user's approval), push + with `-u`. 2. **Compose the placeholder body** (skip the PR template — it describes finished work): - - `Resolves #<ISSUE_NUMBER>` when the branch matches `issue-\d+` (ad-hoc spec branches have no issue — - omit). + - `Resolves #<ISSUE_NUMBER>` — the branch is `issue-<number>`, so extract the number from it. - One short paragraph: the task's goal, taken from the spec's **Context & goal**. - - A link to the spec file on this branch — the main `.agents/tasks/<dir>/spec.md`, or the subtask's - `.agents/tasks/<dir>/subtasks/<NN>-<slug>/spec.md` when this is a step sub-branch (`issue-N-step-M`). - - A note that this is a **spec-first draft**: the branch will receive the task's work subtask by - subtask, and the final description will be written when the PR is marked ready for review. + - A link to the spec file on this branch: `.agents/tasks/<dir>/spec.md`. + - A note that this is a **spec-first draft**: the branch will receive the task's work ticket by ticket, + and the final description will be written when the PR is marked ready for review. 3. **Confirm with the user**, then create as draft: `gh pr create --draft --title "..." --body-file ...`. - Title per "PR title" above (not "spec for..."; the PR becomes the task's/subtask's PR) — a feature - branch's PR describes the whole task, a step sub-branch's PR describes just that subtask. + Title per "PR title" above (not "spec for..."; the PR becomes the task's PR, describing the whole task). 4. **Labels** — copy the issue's labels (`gh issue view <N> --json labels`). Skip ENVs/dependencies labels — nothing is implemented yet; Mode B adds them from the real diff. 5. Link the created PR in the output. @@ -79,7 +80,8 @@ description is a placeholder pointing at the plan: ## Mode C — Regular PR (work already done) 1. **Prepare the branch** — as Mode B step 1, plus commit any outstanding changes (with the user's - approval, clear message). + approval, clear message). When the work sits on `main`, create the branch first: `issue-<number>` when + it came from an issue, otherwise a kebab-case slug naming the change. 2. **Write the description** — see "Writing the description" below. 3. **Confirm with the user**, then create: `gh pr create --title "..." --body-file ...` (add `--draft` only if the user asked for it). @@ -90,17 +92,27 @@ description is a placeholder pointing at the plan: - Use the template from `./docs/PULL_REQUEST_TEMPLATE.md` as the base. Read it and fill in each section. - **Issue number from branch name:** If the branch name matches `issue-\d+`, extract the number, fetch the - issue (`gh issue view <N>`), and start the **"Description and Related Issue(s)"** section with - `Resolves #<ISSUE_NUMBER>`. + issue (`gh issue view <N>`), and start the **Description** section with `Resolves #<ISSUE_NUMBER>`. - **Summary of changes:** clear and concise, at most two paragraphs; bullet points if needed. Be precise; - keep it short. -- **Environment variable changes:** if any env vars were added, changed, or documented, compare or read - `./docs/ENVS.md` (and the validator/ENVS docs if relevant) and add a separate section listing each - variable change and its **purpose**: + keep it short. This is the **Description** section. +- **The why, whenever there is no spec to hold it.** A diff shows *what* changed; the Description is the + only place the reasoning survives, and most PRs through this skill have no spec behind them — work done + by hand, and tasks small enough to finish inside their own grilling session. Add the problem the change + solves and any decision a reader would otherwise have to reverse-engineer, sourced from wherever it + actually is: + - **This conversation**, when the work happened here — the decisions and the alternatives ruled out are + already in context; use them. + - **The issue**, when the branch names one — its body states the problem the diff only implies. + - **The diff and the surrounding code**, otherwise. Infer the intent and write it plainly, then let the + user correct it at the confirmation step — that is what the confirmation is for. Where the reasoning + genuinely cannot be recovered, ask the user for it rather than inventing a rationale. +- **Environment variables:** if any env vars were added, changed, or removed, compare or read + `./docs/ENVS.md` (and the validator/ENVS docs if relevant) and fill the **Environment variables** section + with each variable change and its **purpose** (write "None" if there are none): - **Bad:** "Added `NEXT_PUBLIC_VIEWS_TX_GROUPED_FEES` environment variable to the documentation." - **Good:** "Added `NEXT_PUBLIC_VIEWS_TX_GROUPED_FEES` to group transaction fees into one section on the transaction page." - **Good:** "Extended possible values for `NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS` with set_max_gas_limit to display the maximum gas price set by the transaction sender." - **Good:** "Introduced a new option, `"fee reception"`, for the `NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE` variable." -- **Checklist:** keep the "Checklist for PR Author" section from the template and check the items that - apply (e.g. tested locally, tests added, ENVS/docs/validator updated if env vars changed). -- Always **ask the user for confirmation or changes** before creating/updating the PR. +- **Minimum API version:** fill the **Minimum API version** section from the spec header's **Minimum API + version** row — it may list several services for a multi-service raise (e.g. "Core API v11.2.4+, Admin RS + microservice v2.1+"). Mode C or an empty row → infer from the diff or write "None". diff --git a/.agents/skills/finalize-task/SKILL.md b/.agents/skills/finalize-task/SKILL.md new file mode 100644 index 00000000000..95df70c4877 --- /dev/null +++ b/.agents/skills/finalize-task/SKILL.md @@ -0,0 +1,32 @@ +--- +name: finalize-task +description: >- + Land a finished product task — prune the working files (keep spec.md), then finalize the draft PR into + ready-for-review. +disable-model-invocation: true +--- + +# Finalize task + +Land a task whose every `progress.md` box is checked. Two steps: prune the disposable working files, then +finalize the PR. Why only `spec.md` survives — the decomposition is preserved in git history, one commit per +ticket — is in [`../../tasks/concepts.md`](../../tasks/concepts.md). + +## Step 1 — Prune + +Confirm the task is done: every box in `progress.md` is checked. If any is unchecked, stop and report which +ticket is unfinished — there is nothing to land yet. + +Then delete the disposable files, keeping only `spec.md` in the task folder: + +- `tickets/` — the whole directory. +- `progress.md`. +- `questions.md`. + +Commit the deletions on the feature branch as the task's final commit — a plain descriptive subject, the +repo's `Co-Authored-By` trailer, no `#issue` reference. Pruning **before** the PR is finalized is what lets +the whole-task review read the spec and the diff without any ticket files. + +## Step 2 — Finalize the PR + +Hand off to the `create-pr` skill in **finalize-draft mode** (Mode B). diff --git a/.agents/skills/grill-the-task/SKILL.md b/.agents/skills/grill-the-task/SKILL.md index 131400fe4d3..3883daac7e8 100644 --- a/.agents/skills/grill-the-task/SKILL.md +++ b/.agents/skills/grill-the-task/SKILL.md @@ -1,8 +1,7 @@ --- name: grill-the-task description: >- - Grill a product task (GitHub issue) into an implementable spec — research first, then a - one-question-at-a-time interview; also elaborates sub-specs for deferred subtasks of large tasks. + Grill a product task (GitHub issue) into implementable work. disable-model-invocation: true --- @@ -10,17 +9,8 @@ disable-model-invocation: true Product task issues arrive thin — a title and a couple of links. This skill closes the gap: research everything researchable, then grill the developer about everything that is a *decision*, tracking what they -can't answer as open questions for the responsible people. The output is a spec, written by the `to-spec` -skill. +can't answer as open questions for the responsible people. Input is a GitHub issue URL. -**Two modes.** - -- **Task mode** (default): input is a GitHub issue URL; output is the task's main spec. -- **Subtask mode**: input is an existing spec plus a subtask number (one that has only a `brief.md`, no - `spec.md` yet); the session scopes research and questions to that subtask, reads its folder's `brief.md` - (plus any `research.md` / prototype notes gathered since) as the starting point, and writes its sub-spec - (`subtasks/<NN>-<slug>/spec.md`). Run it just-in-time, right before the subtask starts, against the - by-then-current code. ## Step 1 — Research @@ -41,7 +31,7 @@ Gather, in roughly this order: is production-deployed or staging-only. 4. **Figma mockups** — via the Figma MCP tools, **enumerate-only**: list screens/frames, their elements, columns, states, and record a node link per screen. Do **not** extract visual/styling details — appearance - stays with the mockups and the `[human]` style subtasks (see `.agents/delegation.md`). If the Figma + stays with the mockups and the `[human]` style leaves (see `.agents/delegation.md`). If the Figma MCP is not connected, have the developer describe the mockups instead. Then run two mechanical cross-checks; every mismatch becomes an open question for the backend owner or PM: @@ -56,40 +46,20 @@ Then run two mechanical cross-checks; every mismatch becomes an open question fo Research is complete when every linked source is read or flagged inaccessible, every named endpoint has a real sample response, and both cross-checks have run with each mismatch recorded as an open question. -## Step 2 — Classify the size - -Propose a size to the developer and confirm it: - -- **small** — one step; a single `spec.md`, no `subtasks/` folder. Implementable by an agent or a user - right after this session. -- **medium** — a breakdown of subtasks, each in its own folder `subtasks/<NN>-<slug>/`. The main spec is a - slim index; every subtask is scoped now (its `spec.md` written up front). -- **large** — same folder-per-subtask layout, but some subtasks are too big to specify up front. For - those, this session writes only a `brief.md` (the context it gathered + what still needs research, - prototyping, or decisions) into the folder — no `spec.md` — and each gets its own just-in-time - subtask-mode session later that writes the sub-spec. - -As the breakdown takes shape, decide each subtask's readiness with the developer — scoped now (write its -`spec.md`) or deferred (write a `brief.md`, no `spec.md`). **A task with any deferred subtask is `large`; if -every subtask is scoped now, it's `medium`.** The presence of a `spec.md` is the signal that a subtask is -scoped; the main spec's index carries only the done checkbox. - -## Step 3 — The interview +## Step 2 — The interview -**Invoke the `grilling` skill** and run the interview under its discipline: one question at a time with a -recommended answer, decisions put to the developer while facts are looked up, and no enactment (Step 4) -until shared understanding is confirmed. Skip anything the research already answered. +**Invoke the `grilling` skill** and run the interview under its discipline. 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 -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 -features often get one, and it changes where open questions are sent (see the `to-spec` skill); small and -medium tasks always use the default routing (frontend channel or DMs), so record "—" without asking. When -the developer doesn't know an answer, don't press — record the question with the owning contact and move on. +owns this task, recommending the member marked ✓ in that team's Default column — and record that ✓ member +whenever the developer has no task-specific pick. These go into the spec header, and Step 3 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. When the +developer doesn't know an answer, don't press — record the question with the owning contact and move on. -Cover these domains: +Cover these domains, each only where the task actually reaches it — a one-line bug fix touches almost none +of them, and marching through all six regardless is how a five-minute task turns into a twenty-minute one: 1. **Goal & users** — what problem, for whom. 2. **Env gating** — does the feature sit behind a new `NEXT_PUBLIC_*` env var or not. (Just the decision — @@ -102,25 +72,45 @@ Cover these domains: 5. **Analytics & links** — custom Mixpanel events **only** if there's a new interactive element worth tracking (page views are auto-wired); UTM query params on any hardcoded links to Blockscout or partner products. -6. **Delivery** — one question: deploy a demo after completion or not (executed via the `deploy-demo` skill - as a final subtask if yes). -Testing is **not** an interview domain — the standing policy in `.agents/delegation.md` applies. +Testing is **not** an interview domain — the capability boundary in `.agents/delegation.md` settles it. +Neither is human verification: which acceptance criteria are `(human)` follows the standing rule in "The +ticket model" in `.agents/tasks/concepts.md`. Only ask when one sits genuinely on the line. + +The interview is complete when every domain the task reaches is covered or explicitly skipped as +research-answered, the contacts are settled, and every unanswered question has an owner. + +## Step 3 — Send open questions + +Route every question the session couldn't answer to the person who owns it. + +1. Group them by owner. +2. Pick each group's destination. Ask whether the task has a **dedicated Slack channel** only when the task + is large enough to warrant a spec — big features often get one, and it changes the routing; otherwise + assume there is none. + - Task has a **dedicated feature channel** → **all** questions go there, API ones included. + - Otherwise, **product questions go to the frontend channel** (see `.agents/TEAM.md`) — never a DM — so + colleagues from other teams (QA in particular) build the same understanding of the feature. + - Other questions (API, design) default to a DM with the owner. + - When posting to a channel, **always mention the addressee** — `<@member ID>` from `.agents/TEAM.md` + (people missing from the roster: resolve by name via `slack_search_users` and suggest adding them). +3. Draft one message per owner: brief task context (issue link), the questions, and why they block progress. + Write all Slack messages in **Russian** — the team's internal language (the spec itself stays in English). +4. **Show every draft (with its destination) to the user and wait for explicit approval** — never send + unreviewed outreach. +5. Send (`slack_send_message`), then keep each thread's permalink for the question's `questions.md` entry. + +If the Slack MCP tools are unavailable, record the questions with owners anyway and tell the user to route +them manually. -**Front-load the executor skills' inputs.** Once the task breakdown has taken shape, go through every -`[agent]` subtask that will run a project skill (`add-new-page`, `add-api-resource`, `add-env-var`, …): -**open that skill and run its user-facing interview now** (e.g. `add-new-page` Step 0), from the skill's -current text — don't work from memory of its questions. The answers are recorded with the subtask in its -own `spec.md`, so `implement-task` can later execute without stopping to ask. Do this in whichever session -scopes the subtask: here for a subtask specced now, in the just-in-time subtask session for a deferred one -(the one that starts from a `brief.md`). +Outreach is complete when every question has a recorded permalink — or an explicit note that the developer +routes it manually. -The interview is complete when every domain is covered or explicitly skipped as research-answered, the -contacts and channel are settled, every unanswered question has an owner, and every fully-specified -`[agent]` subtask has its executor skill's inputs collected. +## Step 4 — Size and hand off -## Step 4 — Hand off to `to-spec` +Decide, by a rough sizing judgment, whether the task needs a spec — one session of work or not. No formal +breakdown is needed for this call ("Not every task needs a spec" in `.agents/tasks/README.md`). -Invoke the **`to-spec`** skill. It writes the spec (or sub-spec, in subtask mode), tags subtasks -`[agent]`/`[human]` per the delegation boundary, and runs the open-question outreach (grouping by owner, -drafting Slack messages for the developer's approval, recording thread permalinks). +- **Small task** — implement it in this same session, then hand off to the `create-pr` skill. If a `pending` question blocks the work, wait for the + reply and pick the implementation back up here. +- **Larger task** — suggest proceeding with the `to-spec` skill **in this same session**. diff --git a/.agents/skills/grilling/SKILL.md b/.agents/skills/grilling/SKILL.md index 219930f78b2..87605b4fd44 100644 --- a/.agents/skills/grilling/SKILL.md +++ b/.agents/skills/grilling/SKILL.md @@ -1,12 +1,26 @@ --- name: grilling -description: Grill the user relentlessly about a plan or design. Use when the user wants to stress-test a plan before building, or uses any 'grill' trigger phrases. +description: Grill the user relentlessly about a plan, decision, or idea. Use when the user wants to stress-test their thinking, or uses any 'grill' trigger phrases. --- -Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer. +Interview the user relentlessly until you reach a shared understanding. Map this as a **design tree**: every decision branches into the decisions that hang off it. -Ask the questions one at a time, waiting for feedback on each question before continuing. Asking multiple questions at once is bewildering. +Work the tree in **rounds**. The **frontier** is every decision whose prerequisites are already settled — the questions you can ask _now_ without guessing at answers you haven't heard yet. Ask the whole frontier in one round: number each question and give your recommended answer. Then wait for the user's answers before the next round. -If a *fact* can be found by exploring the codebase, look it up rather than asking me. The *decisions*, though, are mine — put each one to me and wait for my answer. +Each question should be formatted like so: -Do not enact the plan until I confirm we have reached a shared understanding. +``` +❓ **Q1** - **<question title>**: <question body, might be multiple paragraphs, including multiple choices> + +➡️ <your recommended answer> +``` + +Each round the user answers reshapes the tree — settled decisions push the frontier outward and unblock questions that depended on them. Recompute the frontier and ask the next round. A question whose answer depends on another question still open in this round belongs to a _later_ round, not this one. Exactly one round is open at a time. + +A source of _prior decisions_ — a meeting transcript, a Slack thread, an existing spec, the issue's own comments — is upstream of nearly the whole tree, because it doesn't add branches to it, it deletes them. Resolve every one of those in full before you ask the first question. + +Finding _facts_ is your job, never the user's. When a frontier question needs a fact from the environment (filesystem, tools, etc.), dispatch a sub-agent to find it — don't ask the user for anything you could look up yourself. Don't block on it: a running exploration is an unsettled prerequisite, so only the questions downstream of it wait for the sub-agent to report — ask the rest of the frontier now. + +The _future decisions_ are the user's — put each to them and wait. + +The session is done when the frontier is empty: every branch of the design tree visited, nothing left silently assumed. Do not act on it until the user confirms you have reached a shared understanding. diff --git a/.agents/skills/implement-task/SKILL.md b/.agents/skills/implement-task/SKILL.md deleted file mode 100644 index 87a393e6911..00000000000 --- a/.agents/skills/implement-task/SKILL.md +++ /dev/null @@ -1,119 +0,0 @@ ---- -name: implement-task -description: >- - Execute a product-task spec one leaf subtask per run — [agent] subtasks via the project skills, - [human] subtasks handed off to the developer. -disable-model-invocation: true ---- - -# Implement task - -Work through a spec produced by `grill-the-task` / `to-spec`, **one subtask per run**. The spec is the state -machine: each run starts a fresh session, picks up where the spec says work stopped, executes a single -subtask, updates the spec, and stops so the developer can verify and commit. Any colleague can resume the -task from the branch alone. - -## Invocation - -- `/implement-task` — no arguments, the usual case: infer the spec (and the current subtask, from a - `-step-<N>` branch) from the branch name, per the Branch model below. Execute the next eligible subtask - (Step 3). -- `/implement-task 4` — a **specific** subtask, out of order. Pending questions still refuse the run; - unchecked dependencies are pointed out and need the developer's explicit confirmation to proceed. -- `/implement-task 2.3` — when a subtask's own spec has a multi-step breakdown, address its **leaf steps** - with dotted numbers: step 3 inside subtask 2's sub-spec (`subtasks/02-<slug>/spec.md`). The sub-spec's own - breakdown is the checklist for that subtask; its header status is maintained like a spec's. -- An explicit task dir as the first argument (e.g. `/implement-task 3219-cross-chain-views 4`) overrides - branch inference — needed when not on the feature branch yet. - -The unit of one run is always a **leaf**: "next eligible subtask" descends — if the next subtask has its -own multi-step sub-spec, execute the next eligible leaf step *inside* it (one step, then stop). - -## Branch model - -One **feature branch** holds the whole product task; it lands in `main` as one PR when the task is done. -Within it: a big subtask (several commits) gets its own sub-branch and a PR into the feature branch; a simple -subtask is a single commit directly on the feature branch. Remind the developer of this when a subtask -starts, but **never commit, push, or open PRs yourself** — the developer reviews the diff and commits between -runs. - -**PR timing (developer's action — prompt, don't do):** a draft PR opens as soon as the spec is the branch's -first commit (feature branch → `main`; a big subtask's sub-branch → feature branch, with its sub-spec as -the first commit) and flips to ready for review when its breakdown's last box is checked. Nudge accordingly: -on a first run with no PR yet, suggest opening the draft; when checking off the final subtask (or a big -subtask's final leaf step), suggest finalizing it via the `create-pr` skill (finalize-draft mode: real -description from the diff, labels, then ready for review). - -**Branch names carry the addressing.** The feature branch is `issue-<number>` (e.g. `issue-3219`); a big -subtask's sub-branch adds a `-step-<N>` postfix (e.g. `issue-3219-step-2`). An **ad-hoc** spec's branch is its -task-dir slug (`.agents/tasks/<slug>/` → branch `<slug>`). Dash postfixes, **not** slashes — git forbids -`X` and `X/…` coexisting. The names are fully mechanical, which is what lets the skill construct branches -itself and infer the spec (and the current subtask) with no arguments: `issue-<n>` matches the task dir by -issue number, any other branch matches by exact dir name. - -## Workflow - -### Step 1 — Load state - -Resolve the spec per the Invocation section (branch inference by default, explicit task dir wins); if -neither yields a match in `.agents/tasks/`, ask. Read the main spec — and the target subtask's -`subtasks/<NN>-<slug>/spec.md`, when a `-step-<N>` branch or a dotted target selects one — plus -`.agents/delegation.md`. If the header has no feature branch yet, construct it from the convention -above (`issue-<number>`), confirm with the developer, create it, and record it in the header. - -### Step 2 — Reconcile the previous handoff - -If the previous subtask in the breakdown is `[human]` and still unchecked, ask the developer whether it's -done before doing anything else — check it off if so, stop if it's still in progress (the order exists for a -reason; don't leapfrog a pending style step unless the developer explicitly says the next subtask is -independent). - -### Step 3 — Pick the next subtask - -If the invocation named a subtask or leaf step, that's the pick (with the guardrails from the Invocation -section). Otherwise: the first unchecked subtask whose dependencies are all checked **and** whose listed -questions are all `resolved` or `waived`, descending into sub-specs to a leaf step. Then: - -- **All remaining subtasks blocked by `pending` questions** → tell the developer which questions block what, - and suggest running `to-spec` to harvest Slack answers. Stop. -- **Next subtask is `[human]`** → hand off: state what needs doing, link the Figma node, note that the - scaffold's `TODO (design):` markers are the worklist. Stop. -- **Next subtask has only a `brief.md`, no `spec.md` yet** → it isn't scoped; tell the developer to run - `grill-the-task` in subtask mode for it (it writes the folder's `spec.md` from its `brief.md`). Stop. -- **Next subtask is `[agent]`** → proceed. - -### Step 4 — Execute (one subtask only) - -Do the work, composing the project skills wherever one applies (`add-api-resource`, `add-env-var`, -`add-new-page`, `deploy-demo`, …) and staying inside the delegation boundary — scaffolds get placeholder -presentation and `TODO (design):` markers, never final styling. Follow the sub-spec if the subtask has one. - -The spec should already contain the executing skill's inputs — the grilling session runs each skill's -interview up front, so **skip any of the skill's questions the spec answers** and run uninterrupted. If an -input is genuinely missing, ask the developer and **backfill the answer into the spec** before proceeding. -Write the unit tests and Playwright scaffolds the standing testing policy assigns to this subtask (test the -behavior that matters, not the obvious — per `.agents/rules/tests-unit.md`). - -### Step 5 — Verify - -Run every code-quality check the repo defines (per `.agents/rules/code-quality.md` — run all of them, not -only the ones you remember) plus the relevant unit tests. Intentional scaffold `TODO`s may keep ESLint red -in the same way the `add-new-page` skill documents — say so explicitly rather than chasing green. - -### Step 6 — Update the spec and stop - -Check the box for what you did, with a **one-line** note (files/skills involved) — never a multi-line -changelog; git and the PR carry the detail, and any durable decision (a new dependency, an architectural -choice) is folded into the relevant spec section instead. - -Keep both checklist levels in sync when the subtask has its own sub-spec: - -- Check the **leaf step** in the sub-spec's breakdown; set the sub-spec's header `Status` to `in progress` - on its first step and `done` when its last box is checked. -- When a sub-spec goes `done`, check its **subtask line in the main index** too. -- Set the **main** header `Status` to `in progress` on the first executed subtask and `done` when the - index's last box is checked. - -The main index is what "done" and draft-PR finalization key off, so never leave it trailing a completed -sub-spec. Then summarize the result and end the run — verification of the diff, the commit, and the next -`implement-task` invocation belong to the developer. diff --git a/.agents/skills/implement-ticket/SKILL.md b/.agents/skills/implement-ticket/SKILL.md new file mode 100644 index 00000000000..93595de2203 --- /dev/null +++ b/.agents/skills/implement-ticket/SKILL.md @@ -0,0 +1,61 @@ +--- +name: implement-ticket +description: >- + Execute one ticket of a product-task spec. +disable-model-invocation: true +--- + +# Implement ticket + +Execute a single **ticket** of a task, on the already-checked-out feature branch. It runs **once per +session, for one ticket**: a fresh session picks up the ticket, does it, and either commits it or hands off +at a human touchpoint. The ticket is the unit of a run and of a commit — the ticket model, and the +branch/status conventions this skill relies on, are in [`../../tasks/concepts.md`](../../tasks/concepts.md) +and [`../../tasks/structure.md`](../../tasks/structure.md). + +## Invocation + +`/implement-ticket <NN>` — the ticket number is **always required**, and the feature branch is assumed +checked out. + +## Step 1 — Load + +Resolve the task folder from the branch (`issue-<number>`) and read the main `spec.md`, the target ticket's +`tickets/NN-<slug>/spec.md`, `progress.md`, `questions.md`, and `.agents/delegation.md`. If ticket `NN` has +only a `brief.md`, it isn't scoped — tell the developer to scope it with a `to-tickets` run, and stop. + +## Step 2 — Check runnable + +Read the ticket's `Blocked by` list — the whole runnable test lives there, so check nothing else. Resolve +each entry by kind: a `T<NN>` entry must be checked in `progress.md`; a `Q<NN>` entry must be `resolved` or +`waived` in `questions.md`. If every entry clears, proceed; otherwise report exactly which blockers are open +and stop. + +## Step 3 — Execute + +Start at the **first unchecked** leaf and work in order, checking each box as it completes — resuming +after a human touchpoint skips the leaves already checked. Stop at the first **unchecked** `[human]` leaf +and hand off (Step 5). + +## Step 4 — Verify + +Run every code-quality check the repo defines (per `.agents/rules/code-quality.md`) plus the relevant unit +tests. Intentional scaffold `TODO`s may keep ESLint red the way the `add-new-page` skill documents — +say so explicitly rather than chasing green. Then walk the ticket's **acceptance criteria** and confirm each + agent-checkable one holds. A `(human)` criterion is not yours to judge — it is a touchpoint Step 5 stops for. + +## Step 5 — Close + +Key the close to whether the ticket has any **human touchpoint** — a `[human]` leaf, or a `(human)` +acceptance criterion: + +- **No touchpoint** → fully autonomous. Check the ticket's box in `progress.md`, then **commit** — the box + tick folded into the commit so the worktree is clean after. A plain descriptive subject, the repo's + `Co-Authored-By` trailer, and **no** `#issue` reference (the PR's `Resolves #N` already links it; a commit + that names the issue spams its timeline). Done. +- **Any touchpoint** → stop and hand off. Stop at the first **unchecked** `[human]` leaf; or, when the + leaves are all `[agent]` but a `(human)` criterion remains, after implementing them and **before** + committing. Say what the developer must do, link the Figma node, and point at the scaffold's + `TODO (design):` markers. The developer does the work; for a `[human]` leaf they check its box and ask + the session to continue — it resumes from the next unchecked leaf (Step 3), never by re-invoking the + skill. Once every leaf is done, the developer commits and checks the `progress.md` box at their commit. diff --git a/.agents/skills/prepare-release/SKILL.md b/.agents/skills/prepare-release/SKILL.md index 15730294c82..94f168814fc 100644 --- a/.agents/skills/prepare-release/SKILL.md +++ b/.agents/skills/prepare-release/SKILL.md @@ -68,10 +68,19 @@ If a PR has no matching labels, try to categorize it based on its description (t - #2968 - Added `NEXT_PUBLIC_MEGA_ETH_SOCKET_URL_METRICS` to display information on the uptime dashboard page. +- **"Compatibility" section:** + - List only the API versions this release *raises* — the diff, not the full set of services the app needs. + A service whose required version is unchanged from before is not mentioned. + - From `release-prs-data.json`, read each PR body's **Minimum API version** section. For every service a PR + names, add a row at the **highest** version among all PRs that name it. Ignore PRs whose section says + "None" or that have no such section (PRs opened before it existed). + - Map the PR's service to a row: "Core API" → the **Blockscout API** row; a microservice → its + **`<name>` microservice API** row. + - If no PR names an API version, the section lists no services — leave the table empty. + - **Final edits in the release notes file:** - Update "Full list of the ENV variables" and "Full Changelog" with the correct version tags/links. - Update the "New Contributors" section if the "Get release notes draft" response included new contributors. - - Leave the "Compatibility" section content unchanged. ### 4. Verify with the user (stop and wait) diff --git a/.agents/skills/prepare-release/slack-message-template.md b/.agents/skills/prepare-release/slack-message-template.md index ff4af477ed4..69ad65bb9c3 100644 --- a/.agents/skills/prepare-release/slack-message-template.md +++ b/.agents/skills/prepare-release/slack-message-template.md @@ -3,10 +3,15 @@ Used in the final step of the `prepare-release` skill to ask the DevOps team to roll up a freshly published **frontend** pre-release on the staging instances. -- **Channel:** `#blockscout-devops-requests` (ID `C050U1F2E9M`) -- **QA cc:** the QA team user group, mention token `<!subteam^S06015J7WVD>` +- **Channel:** the DevOps *requests* channel — resolve its ID from `.agents/TEAM.md` (DevOps → Channels, + `blockscout-devops-requests`). +- **QA cc:** the QA team user group — resolve its group ID from `.agents/TEAM.md` (QA → Groups) and build the + mention token `<!subteam^<group-id>>`. - **Always draft first** and get the user's approval before sending. +Slack IDs are **not** duplicated here on purpose — `.agents/TEAM.md` is the single source of truth for every +channel and group ID, so a moved channel or renamed group is fixed in one place. + ## Placeholders | Placeholder | Meaning | @@ -14,6 +19,7 @@ a freshly published **frontend** pre-release on the staging instances. | `<alpha-tag>` | The pre-release tag, e.g. `v1.3.0-alpha`. | | `<breaking-env-changes>` | Bulleted list of breaking ENV changes, or the single line `None.` | | `<release-url>` | Link to the published GitHub pre-release. | +| `<qa-group-mention>` | QA team group mention token `<!subteam^<group-id>>`, built from `.agents/TEAM.md`. | A change is **breaking** if a deployment must change its config to keep working: a **removed** variable, a **renamed** variable, or a change to a **required**/default value @@ -32,7 +38,7 @@ Could you please roll up this pre-release tag on the staging instances? Release notes: <release-url> -cc <!subteam^S06015J7WVD> +cc <qa-group-mention> ``` ### Example — with breaking changes @@ -49,7 +55,7 @@ Could you please roll up this pre-release tag on the staging instances? Release notes: https://github.com/blockscout/frontend/releases/tag/v1.3.0-alpha -cc <!subteam^S06015J7WVD> +cc <qa-group-mention> ``` ### Example — no breaking changes @@ -63,5 +69,5 @@ Could you please roll up this pre-release tag on the staging instances? Release notes: https://github.com/blockscout/frontend/releases/tag/v1.3.0-alpha -cc <!subteam^S06015J7WVD> +cc <qa-group-mention> ``` diff --git a/.agents/skills/resolve-config-request/SKILL.md b/.agents/skills/resolve-config-request/SKILL.md new file mode 100644 index 00000000000..857e42012ed --- /dev/null +++ b/.agents/skills/resolve-config-request/SKILL.md @@ -0,0 +1,151 @@ +--- +name: resolve-config-request +description: Turn a Slack request into the exact env vars to set on a live instance, and hand them to DevOps. +disable-model-invocation: true +--- + +# Resolve a config request + +Convert **intent → variables**. The request already carries the intent; this skill never originates it. + +## Sure + +Only send a DevOps message we are **sure** of. Every other rule serves that. + +- **Stop** on ambiguity. If the variable cannot be pinned, or the value is not in the documented set of allowed values, stop and tell the user. +- **The user is the face.** Uncertainty and clarifying questions go to the user. Requester-facing posts are only the handover reply after an approved send, and the demo link on the skin branch. +- **Intent is given.** Any question that turns on what someone *wants* is a stop, not a judgement call. +- **Operator.** `docs/ENVS.md` is the manual. The docs pin legal values, not the consumers. A value the docs cannot pin is a documentation gap, fixed as documentation. +- **Off.** Prefer a documented off-value over unsetting. Unset only when the docs show the default *is* the desired state. +- **Mirror** the requester's targeting language. Unstated is not undeterminable — the DevOps bot resolves instances. +- **Drift.** `NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG` and `NEXT_PUBLIC_COLOR_THEME_OVERRIDES` ship with a `frontend-configs` file change in the same run; take the skin branch. + +No commit without the user's explicit confirmation in this conversation — the one exception is the skin branch's phase 1, which runs unattended up to the demo link. + +## Scope + +**In:** variables in `docs/ENVS.md`, including start-time ones such as `FAVICON_MASTER_URL`. + +**Out, with the exit:** + +- **Not configurable** — needs a code change. Stop and offer the `create-issue` skill. The channel carries these; they look like ordinary requests. +- **Retired variable named** — read `docs/DEPRECATED_ENVS.md` and propose the replacement. +- **Outside the frontend, or mixed with a non-configurable ask** — do the configurable frontend part only, and tell the user what was left out. +- **CDN-only trees** in `frontend-configs` with no `docs/ENVS.md` variable (`multisearch/`, `token-icons/`, `nft-images/`, `explorer-logos/`, `meta-suites-logos/`) — stop and tell the user. + +## Steps + +### 1. Read the thread + +Follow `.agents/slack-thread.md`. Threads are typically in Russian. + +**Done when:** every message and attachment in the thread is in hand. + +### 2. Map the ask to variables + +Using `docs/ENVS.md` as the operator manual, pin each change to a variable and a documented value. **Stop** if it cannot be pinned. + +**Skin.** Read [`SKIN-REQUESTS.md`](SKIN-REQUESTS.md) when a designer owns the change: this instance's logo, icon, favicon, OG image, colour theme, hero banner, homepage highlights, or navigation promo banner. That branch runs to its own stop; it returns here at step 3. A chains-menu or footer icon that merely *lives* in `network-icons/` / `footer-icons/` is not that. + +**Fetched config.** A chains menu, footer, marketplace, widgets, or cross-chain JSON — and icons those files reference — is a `frontend-configs` PR without a demo. See **Fetched configs** below; do that PR before step 3. Skip when the skin branch is already running — those files ride in that PR. + +**Done when:** every asked change is a `(variable, value)` pair pinned by the docs (and any non-skin configs PR is merged to `main`), or the run has stopped. + +### 3. Read current state (nameable instance) + +When the target is a nameable instance, fetch its live config, diff against the proposed change, drop no-ops, and identify anything that needs removing. Sending a variable already at that value is how these requests lose credibility. + +When the target is a vague class ("everywhere", "all testnets"), there is no single current state — go set-only. + +**Done when:** the message contains only real changes, or the target is a class and the message is set-only. + +### 4. Validate + +Run the startup validator against the live env plus our change, before drafting. See **Validation** below. + +**Done when:** the validator accepts the overlay (or the only remaining vars are start-time ones it cannot see). + +### 5. Draft the DevOps message + +Compose it per **The DevOps message** below. Show it to the user and wait. + +**Done when:** the user has approved the exact text. + +### 6. Send, then hand over + +Post the approved message to `blockscout-devops-requests` — resolve the channel ID from `.agents/TEAM.md`. Then reply in the original thread — same language as the thread — with a link to that message, saying the request has been handed over and is waiting on DevOps. + +**Done when:** both posts exist and the original-thread reply carries the DevOps link. + +## Reading an instance's env + +Every deployed instance exposes `{ envs: { …all NEXT_PUBLIC_* } }` at `<url>/node-api/config`. The CLI: + +```bash +tools/dev-server/fetch.sh <alias> --omit-local-envs --out=/tmp/instance.env +``` + +Step 3 diffs this file. Aliases live in `tools/dev-server/registry.json`. How fetch and env layering work: `tools/dev-server/CONTEXT.md`. + +Limits: registry alias only (otherwise GET `/node-api/config` yourself); `/node-api/config` is `NEXT_PUBLIC_*` only, so start-time variables like `FAVICON_MASTER_URL` cannot be checked this way; `fetch.sh` drops `ignoredEnvs` and `deprecatedEnvs` — for those, GET `/node-api/config`. + +## Validation + +**Default alias: `eth`.** One representative instance is enough, including when the target is a vague class. + +`--omit-local-envs` drops `localEnvs` (`tools/dev-server/envs-rules.json`); the schema requires `NEXT_PUBLIC_APP_HOST`. Copy the fetched file and add those keys, then overlay our change on the copy. dotenv-cli: the **first** `-e` file wins (`tools/dev-server/CONTEXT.md`). For a key to drop, strip it from the copy before validating. + +```bash +cp /tmp/instance.env /tmp/instance.validate.env +jq -r '.localEnvs | to_entries[] | "\(.key)=\(.value)"' tools/dev-server/envs-rules.json >> /tmp/instance.validate.env +``` + +From `deploy/tools/envs-validator/`, the preamble in `test.sh` (collect placeholders, copy `test/assets`, build), then: + +```bash +pnpm exec dotenv -e /tmp/change.env -e /tmp/instance.validate.env -- pnpm run validate +``` + +Schema organisation: `deploy/tools/envs-validator/CONTEXT.md`. This is what catches missing companion variables (`.when(...)`), forbidden combinations, and malformed nested JSON — a documentation check cannot. + +## Fetched configs + +JSON the instance **fetches** at startup — not instance chrome, no demo. + +Checkout: a workspace folder named `frontend-configs` or `blockscout_frontend_configs`. If none, stop and ask the user to add it. + +Follow the `check-github-cli` skill. Confirm with the user before the commit and the PR — this PR is merged to `main`, where live instances fetch from. (Skin phase 1 is the exception: its PR stays unmerged for review, so it needs no confirmation.) The `create-pr` skill's frontend template, ENVs label, and issue-from-branch steps do not apply. + +| Directory | Variable | +| --- | --- | +| `configs/featured-networks/` | `NEXT_PUBLIC_FEATURED_NETWORKS` | +| `configs/footer-links/` (+ `configs/footer-icons/`) | `NEXT_PUBLIC_FOOTER_LINKS` | +| `configs/marketplace/` and siblings (`marketplace-categories/`, `marketplace-subgraph-links/`, `marketplace-logos/`, `marketplace-security-reports/`) | `NEXT_PUBLIC_MARKETPLACE_CONFIG_URL`, `NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL`, `NEXT_PUBLIC_MARKETPLACE_GRAPH_LINKS_URL` | +| `configs/widgets/` | `NEXT_PUBLIC_ADDRESS_3RD_PARTY_WIDGETS_CONFIG_URL` | +| `configs/cross-chain/` | `NEXT_PUBLIC_ZETACHAIN_SERVICE_CHAINS_CONFIG_URL` | + +Hosted icons for **inlined** lists (the env holds the JSON; the file is the URL inside it): `configs/ide-icons/` → `NEXT_PUBLIC_CONTRACT_CODE_IDES`; `configs/nft-marketplace-logos/` → `NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES`; `configs/multichain-balance/` → `NEXT_PUBLIC_MULTICHAIN_BALANCE_PROVIDER_CONFIG`. + +After merge to `main`, confirm each raw URL returns 200. If the instance already has that URL, the DevOps ask is a restart to re-fetch — no new `KEY=value`. If the URL is new, or the value is inlined, it goes in the block as usual. + +## The DevOps message + +Russian, informal. Target in the requester's own words — not registry aliases. Sets in one fenced `KEY=value` block; keys to drop as names only. Mention the requester with `<@U…>` from the thread author's `user_id` (`.agents/TEAM.md` → How to address). + +```` +Привет! 🐈 +Для eth.blockscout.com нужно поставить: +``` +NEXT_PUBLIC_NAVIGATION_HIGHLIGHTED_ROUTES=['/accounts'] +``` +cc <@U024DUPJG3A> +```` + +Unset-only: `нужно убрать:` + names in the fence, no `=value`. Mixed: both sentences, set first. Restart-only (fetched file, URL already on the instance): `нужно перезапустить фронт, чтобы подтянуть обновлённый конфиг.` — no fence. + +Omit: that we validated; a backlink to the source thread. A set or drop fence already implies the restart. Caveats as bullets after the fence, only when there is a real one (don't-remove-X, companion variables). + +## Value formatting + +- **`rgba()`, never hex** — a `#` in an env var breaks bash. +- **JSON values use single quotes** (`'{"a":1}'`) so they paste into a shell or `.env` file. The validator's `replaceQuotes` converts them. diff --git a/.agents/skills/resolve-config-request/SKIN-REQUESTS.md b/.agents/skills/resolve-config-request/SKIN-REQUESTS.md new file mode 100644 index 00000000000..5f45f1c26f2 --- /dev/null +++ b/.agents/skills/resolve-config-request/SKIN-REQUESTS.md @@ -0,0 +1,81 @@ +# Skin requests + +A live **demo** is the review surface — screenshots miss dark-mode variants and viewBox cropping. The demo is unconditional — even a plain logo swap earns one. + +Variables: `NEXT_PUBLIC_NETWORK_LOGO` / `_DARK`, `NEXT_PUBLIC_NETWORK_ICON` / `_DARK`, `FAVICON_MASTER_URL`, `NEXT_PUBLIC_OG_IMAGE_URL`, `NEXT_PUBLIC_COLOR_THEME_OVERRIDES`, `NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG`, `NEXT_PUBLIC_HOMEPAGE_HIGHLIGHTS_CONFIG`, `NEXT_PUBLIC_NAVIGATION_PROMO_BANNER_CONFIG`. + +Checkout and commit/PR rules: parent **Fetched configs**, except its confirmation — see **Phase 1**. Follow the `check-github-cli` skill before any `gh` step. + +Non-skin variables on the same request wait and ride in the phase-2 DevOps message. Non-skin `frontend-configs` files on the same request go in this PR. + +## Drift + +The `configs/hero-banner/` and `configs/color-themes/` JSON files are the editable source of truth for variables whose values are **inlined** into the env var. They are not fetched at runtime. To change one colour, read the current file, patch it, and regenerate the string. + +Emitting `NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG` or `NEXT_PUBLIC_COLOR_THEME_OVERRIDES` without updating the file in the same change lets the two drift: the next colour tweak regenerates from a stale base and silently reverts everything since. Those variables always take this branch, even for a one-colour change. + +Regenerate with the configs repo's converter: + +```bash +node ./tools/json-converter/index.js <path-to-file> +``` + +## Colour sources + +Two configs, overlapping vocabulary; the request usually covers only one of them in text. Reconcile both before writing anything. + +- **Message text → hero banner.** background, text colour, button default/hover, light and dark. +- **Figma sheet → colour theme overrides.** These tokens typically exist *only* in Figma. + +A `background` in the message text is not the theme's `bg.primary`. + +Read fills off a rendered frame (`get_screenshot`). Bound-variable lookup (`get_variable_defs`) returns design-system defaults for per-instance raw fills. Both need the Figma plugin in Claude Code or Cursor. To tell which tokens are actually part of the request, diff each swatch against `DEFAULT_THEME_COLORS` in `src/toolkit/theme/foundations/colors.ts`: a token sitting at the default is not part of the request; one that differs is. + +**If those tools are absent or fail, or Figma is unreachable, stop.** A half-applied skin reaching DevOps is worse than a blocked request. + +Hero banner values are `Array<string | undefined>` with index `[0]` = light, `[1]` = dark (`[1] || [0]` fallback). + +## Assets + +Optimise SVGs with the configs repo's `svgo.config.cjs` — it strips dimensions, keeps `viewBox`, and applies `prefixIds` per file so light and dark variants do not collide. Favicon: square PNG, ≥180×180. OG image: 1200×600. + +Directories: `configs/network-logos/`, `configs/network-icons/`, `configs/favicons/`, `configs/og-images/`, `configs/hero-banner/`, `configs/network-skins/` (hero backgrounds), `configs/color-themes/`, `configs/homepage-highlights/`. Navigation promo banner is inlined and has no file in this repo. + +While the configs PR is open, asset URLs are the **PR branch's** raw GitHub URLs, not `main`. + +## Demo mechanics + +`.env.extra` is committed and is the **only** channel that carries env overrides into a review deploy. An instance not in the registry: add it to `tools/dev-server/registry.json`, run `pnpm presets:sync` (writes `.github/workflows/deploy-review.yml` and `.vscode/tasks.json`), commit all four **on the demo branch**. They die with that branch. Procedure: `tools/dev-server/CONTEXT.md`. + +Hostname: `review-<branch-slug>.k8s-dev.blockscout.com`. Follow the `deploy-demo` skill. + +Verify the theme by **computed values** — read the resolved CSS custom properties in both light and dark. The first click after opening the theme settings pop-over is swallowed; click again. A demo also confirms two things a local run cannot: that the favicon bundle is generated at container start from `FAVICON_MASTER_URL`, and that the envs-validator accepts the inline JSON blobs. + +## Phase 1 — demo + +**Phase 1 runs to the demo link without stopping.** No gate — designer's or user's — on the branch, the commits, the configs PR, the demo deploy, or the demo-link post. The PR is open but unmerged and the demo dies with its branch, so nothing here can touch a production instance; the parent's commit confirmation does not apply. The demo *is* the review surface, and a confirmation asked before it exists is asked of someone who cannot yet see what they are confirming. + +The requester (designer) is the gatekeeper for phase 2; the user relays that approval. There is no automated watch on the thread — the user monitors and continues this session. + +1. Produce the assets and JSON configs from the thread and Figma. +2. Open a PR on `frontend-configs` (target `main` branch). No confirmation, per above; `create-pr` exceptions: parent **Fetched configs**. +3. Point the demo at the PR-branch raw URLs (see **Demo mechanics**). Deploy it. +4. Verify on the live demo, post the demo link to the requester, and **stop**. Post it without asking — the named exception to `AGENTS.md`'s approve-before-sending rule. This is the run's only stop; what it waits for is the designer's reply. + +**Done when:** the configs PR is open, the demo is live, the requester has the link, and this run has stopped. + +## Phase 2 — ship + +Starts only when the user relays the designer's approval. + +1. Merge the configs PR. Swap every branch URL to `main` and confirm each returns 200 — that is when instances can fetch them. +2. For inlined vars (hero banner, colour theme), regenerate the strings from the merged files (the converter above). URL vars (homepage highlights, asset URLs): the env value is the `main` raw URL. Navigation promo: the inlined string produced in phase 1. +3. Run the parent skill from **Read current state** through **Send, then hand over**. +4. **Teardown** — checkable, they fail silently when skipped: + - demo destroyed: `gh workflow run cleanup.yml --ref <branch>`; hostname returns 404 + - temporary frontend branch deleted, local and remote + - `git status` clean + +The review-image cleanup is broken ([frontend#3638](https://github.com/blockscout/frontend/issues/3638)); hostname 404 is the bar, not image deletion. + +**Done when:** the DevOps message is sent, the handover reply is posted, and every teardown box above is true. diff --git a/.agents/skills/resolve-review/SKILL.md b/.agents/skills/resolve-review/SKILL.md new file mode 100644 index 00000000000..6a4576fb572 --- /dev/null +++ b/.agents/skills/resolve-review/SKILL.md @@ -0,0 +1,135 @@ +--- +name: resolve-review +description: >- + Close out review findings on a PR — adjudicate each one, fix what deserves fixing, then reply and resolve + the threads. +disable-model-invocation: true +--- + +# Resolve review + +Work through a PR's review findings and close them out. The hard part is not fixing — it is deciding *which* +findings deserve a fix. So the centre of this skill is **adjudication**: every finding gets a **verdict**, +reached skeptically, by checking the claim against the real code and the project's intent rather than by +trusting how confidently it was worded. + +In the product-task workflow this runs at **land**, against the whole-task PR — it adjudicates the inline +comments `review-changes` posted, alongside any human or bot comments on the same PR. + +## Verdicts, by source + +Which verdicts are even available depends on who raised the finding, so establish the source first. + +| Source | How you know it | Verdicts | +| --- | --- | --- | +| This workflow's review | a PR comment ending in a `— Reviewed by …` footer | `fix` · `reject` | +| A bot | `user.type == "Bot"` | `fix` · `reject` | +| A human | neither of the above | `fix` · `answered` | + +Test in that order. The footer comes first because an agent posts through a human's account — `user.login` +is the repo owner's in every case, so nothing but the footer distinguishes this workflow's own review. +Bots are then caught by GitHub's own `user.type`, **not** by a list of logins: this repo alone sees +`Copilot` (no `[bot]` suffix, capitalised), `coderabbitai[bot]`, `cursor[bot]` (Cursor Bugbot, which +`.cursor/BUGBOT.md` aims at our smell baseline) and `github-advanced-security[bot]`, and a name list gets +three of those four wrong — silently promoting them to human, whose comments may never be rejected. + +- **fix** — the concern is real *and* the fix belongs in this change. +- **reject** — invalid premise, contradicts design intent, already addressed, or out of scope. Closes with + an explanation. +- **answered** — *only* for a human's comment, and the only alternative to fixing one. Reply with the + reasoning: why it was done this way, what alternatives were considered, why this path won. Then **leave + the thread unresolved** and let the human decide whether they still insist. A human comment is never + rejected — they may be wrong, but that call is theirs, not yours. + +Two further rules on verdicts: + +- **No repeat rejection.** A finding you rejected once, where the reviewer came back and disagreed, may not + be rejected again on the same grounds. Fix it, reject it on genuinely **new** grounds (once), or mark it + `needs-human`. +- **Nits are `deferred`**, not fixed. The developer may promote one at Gate 1. + +## Invocation + +- `/resolve-review` — the usual case: resolve the open findings on the current branch's PR. +- `/resolve-review <PR url | comment url>` — that PR, or that single comment, scoping the whole run to it. + +## 1. Scope + +Establish what you are resolving. Derive `owner/repo` and the PR number, and confirm `gh auth status` +succeeds (commands: [`../review-changes/gh-commands.md`](../review-changes/gh-commands.md)). + +**Done when**: you know the unit of work and whether the scope is every open finding or one specific comment. + +## 2. Gather + +Collect every **actionable** finding — inline review comments, PR-level reviews, and issue comments +([`../review-changes/gh-commands.md`](../review-changes/gh-commands.md)). Keep only unresolved, actionable +threads. Drop already-resolved threads, your own prior replies, and bot status noise (CodeRabbit "review +skipped", Copilot's PR overview). + +Tag each with its **source** per the table above, then open the code it points at — `path` + `line`, or the +`diff_hunk` — so the next step judges against reality rather than against the comment text. + +**Done when**: every actionable finding is listed with its source, its location, and the current code it +refers to. Exhaustive, not a sample. + +## 3. Adjudicate + +The heart of the skill. Reason hard here; do not rush toward the gate. + +- **Investigate before judging.** Verify the claim against the actual code. Check whether it still applies — + it may be stale or already fixed. Weigh it against the spec and the conventions in `.agents/rules/`. +- **Decompose multi-point findings.** One comment can be part-`fix`, part-`reject`. Adjudicate each point. +- **Give bots no deference.** A plausible-sounding suggestion is not automatically correct; a bot can + contradict the author's intent or argue from the wrong docs. +- **When a verdict turns on design intent you cannot settle from the code and the spec, mark it + `needs-human`.** Do not guess. + +For each finding record the verdict, the reasoning, and the proposed action — the fix sketch, or the reply +text for a `reject` or an `answered`. + +**Done when**: every gathered finding has a verdict, reasoning, and a proposed action, or is `needs-human`. + +## 4. Gate 1 — confirm + +**A hard stop.** Present a table — finding (`file:line` + short quote), source, verdict, reasoning, proposed +action — and list the `needs-human` items as questions. Then **stop and wait**. Edit no code until the +developer confirms; they may re-categorise anything or answer the open questions. This is the cheapest +steering point in the whole process, which is why it comes before any edit. + +**Done when**: the developer has confirmed. + +## 5. Fix + +Implement the confirmed `fix` items only, following the conventions in `.agents/rules/`. Run the checks +those files define for the code you touched. Leave `reject`, `answered`, `deferred` and `needs-human` +findings untouched. + +**Done when**: every confirmed fix is applied and locally verified. + +## 6. Gate 2 — review the diff + +**A hard stop.** Show `git diff` plus a per-finding summary of what changed, and wait for approval before +anything is pushed or replied to. The developer commits and pushes the fixes — the reviewer's next +arbitration round reads them from the PR. + +## 7. Close out + +Reply to every thread; **who resolves depends on the source.** + +- `fix` → what changed, plus the commit sha once it exists. +- `reject` → the explanation. +- `answered` → the reasoning, the alternatives, why this path won. + +**This workflow's own findings — reply, never resolve.** The reviewer raised them and owns their close: it +verifies the fix (or agrees the reject) and resolves in its next arbitration round, which is what lets it +confirm the work landed and post the final all-clear. Resolving here would close the loop before the +reviewer ever checked it. + +**Bot findings** — resolve on `fix` or `reject`; a bot has no arbitration round, so its verdict stands on +posting. **Human findings** — resolve on `fix`, and leave `answered` open for the human. Leave every +`needs-human` thread open — those must stay visible. + +**Done when**: every adjudicated finding has been replied to and (where settled) resolved on the PR. Then +report: counts per verdict, every `reject`/`answered` with its one-line reason, and anything left for a +human. diff --git a/.agents/skills/review-changes/SKILL.md b/.agents/skills/review-changes/SKILL.md new file mode 100644 index 00000000000..a332c39ee0a --- /dev/null +++ b/.agents/skills/review-changes/SKILL.md @@ -0,0 +1,229 @@ +--- +name: review-changes +description: >- + Review a change on three axes — spec compliance, repo standards, correctness — each in a fresh subagent + context, then post the findings as inline PR comments (or report them in chat when there is no PR). +disable-model-invocation: true +--- + +# Review changes + +Review a change the way a lead reviewer would: the **axes** that apply, in parallel, each in its own fresh +subagent context, then one normalized report. + +You produce **findings** and nothing else. Fixing them is `resolve-review`'s job, so this skill never edits +a file — which is also why it runs `lint:eslint` and never `lint:eslint:fix`. + +## Scope and mode + +Mode follows from where the code is. + +| Working tree | Mode | Base | +| --- | --- | --- | +| Clean, PR open, `HEAD` = the PR's head sha | inline PR comments | merge-base with the PR's base branch | +| Clean, PR open, `HEAD` ≠ the PR's head sha | **stop — the branch must be synced first** | — | +| Clean, no PR | chat only, no file | merge-base with `main` | + +**An open PR plus an out-of-sync branch stops the run.** Say which way it diverged and what to run — `git +push` when `HEAD` is ahead, `git pull` when behind — then stop rather than review. Otherwise lines that were +never pushed are absent from the PR diff, every anchor fails, and the all-or-nothing POST discards the whole +review. + +The skill takes no invocation arguments — every case above is inferred. + +**Round detection.** Prior review comments on the PR make this run **arbitration** (step 3) rather than a +fresh review — keyed on the presence of prior comments, not on whether findings are still open, so a second +round never silently becomes another three-axis pass. + +## 1. Pin the ground + +Resolve the mode with exactly these three probes, then **fail fast before spawning anything**: a wrong mode, +a base that does not resolve (`git rev-parse`), or an empty diff stops the run here, not inside three +subagents. + +```bash +git status --porcelain # any output at all → dirty, not ready to review +gh pr list --head "$(git branch --show-current)" --state open \ + --json number,headRefOid,baseRefName +git rev-parse HEAD # compare against headRefOid +``` + +**The `gh` exit code answers "could I ask?"; its output answers "is there one?"** Exit ≠ 0 is a tooling +failure that **aborts the run**; exit 0 with `[]` is genuinely no PR. Use `gh pr list --json` and never `gh pr +view`, which exits 1 *both* when no PR exists and when it cannot reach GitHub — so nothing downstream can tell +a missing PR from a sandbox with no network. Never wrap either command in `||`. + +`--state open` matters: a merged or closed PR must not select PR mode. `headRefOid` and `baseRefName` come +back from that same call, so the base is read rather than guessed, and a plain string comparison against +`HEAD` decides PR mode — unequal in *either* direction means out of sync. + +Collect, in the review's own context: + +- `git diff --stat <base>` and `git diff --name-only <base>`, plus untracked files + (`git ls-files --others --exclude-standard`) — a new file is the most review-worthy thing in a change + and `git diff` alone misses it. +- The task's `spec.md` (if this is a task PR) and `.agents/delegation.md`. + +**In chat mode**, run the repo's checks yourself — do not take "checks pass" on trust, because verifying that +claim is most of what a review is worth: + +```bash +pnpm lint:eslint +pnpm lint:tsc +pnpm lint:cspell +pnpm test:vitest --changed <base> +``` + +`vitest --changed` selects only the test files the change affects, and exits 0 when it affects none. + +**In PR mode, checks are not your business at all** — neither run them nor read them. The Checks workflow +reports to the PR where the developer already sees it, and it may still be running alongside this review. + +**Done when**: mode, base, the touched-file list, and (in chat mode) the check results are all in hand, and +the diff is known non-empty. + +## 2. Round 1 — spawn the axes + +Send **one** message with the `general-purpose` subagents the change actually has axes for. Each gets: the +base ref, the touched-file list plus untracked files, the check output as established fact, and the paths it +must read. + +**The spec axis is gated on a spec existing.** Plenty of changes have none — work done outside the task +workflow, and any task finished inside its own grilling session (see "Not every task needs a spec" in +[`../../tasks/README.md`](../../tasks/README.md)). Confirm `spec.md` is there before dispatching. With no +spec, run **two** axes and say so in the report — the spec axis with nothing to read invents a standard to +judge against, which is worse than the gap it papers over. Standards and correctness carry the review on +their own. + +Every axis returns findings in this shape, and nothing else — no preamble, no summary: + +``` +severity: blocker | major | nit +needs-human: yes | no +location: <path>:<line> +claim: <what is wrong — quote the code> +fix: <one or two lines> +``` + +Each report is capped at **400 words**, which forces ranking instead of dumping. + +**Spec axis brief.** Read the spec (path given) and the diff. Its **Functional Requirements** are the +contract: take each one in turn and report whether the diff actually satisfies it, quoting the requirement +behind each finding. Then report what the requirements don't cover: behaviour in the diff the spec never +asked for, and requirements that look implemented but are implemented wrongly. Anything the spec's **Out of +scope** section names is not a finding. + +**Standards axis brief.** Read `.agents/rules/*.md` matching the touched file types, every `CONTEXT.md` for +directories the diff touches, `.agents/delegation.md`, and **one** smell baseline, picked by what the diff +touches: + +| The diff touches | Baseline | +| --- | --- | +| code | [`smells.md`](smells.md) | +| the instruction surface — `.agents/**`, `AGENTS.md`, any `CONTEXT.md`, `.cursor/**`, `.github/*instructions*` | [`prose-smells.md`](prose-smells.md) | +| both | both, each applied only to the files it governs | + +Task specs under `.agents/tasks/**` are not the instruction surface — the Spec axis reads those as the +source of truth rather than reviewing them as instructions. + +Report: places the diff breaks a documented rule — **cite the rule file and the rule** — and smells from the +baseline, each named and quoted. A documented rule can be a hard breach; a smell is always a judgement call. +Skip anything the checks in step 1 already cover. + +**Correctness axis brief.** Read the touched files **in full**, plus the files the change depends on — a +hunk-only read produces exactly the shallow findings that make developers stop trusting review. Report: +logic errors; mishandled loading / empty / error / pagination paths; places where the types claim something +the runtime does not; and tests that assert the framework or the mock rather than real behaviour (per the +"What to test (and what not)" section of `.agents/rules/tests-unit.md`). + +**Done when**: every dispatched axis has returned, or one has failed and you have noted which. + +## 3. Round 2+ — arbitration + +An arbitration round is **not** a fresh three-axis review. Re-reviewing everything keeps surfacing unrelated +findings, so the round never converges. + +Spawn **one** fresh `general-purpose` agent — fresh, so it rules on the evidence rather than defending a +claim it made itself. Give it the prior review comments (which carry the full exchange history) and the +current diff. It does exactly three things: + +1. Verify each claimed fix actually addresses its finding, rather than cosmetically silencing it. +2. Rule on each `reject`: **agree** — the finding closes as `rejected-accepted` — or **disagree**, with a + counter-argument. +3. Flag regressions introduced **by the fixes only**. New findings elsewhere are out of scope for this round. + +The reviewer posts these rulings and resolves the settled threads itself (step 5). + +**Done when**: every open finding is either closed or carries a ruling. + +## 4. Normalize + +Only this context sees every axis, so only it can calibrate. Left alone, each axis inflates its own findings +to `blocker` because that axis is all it can see. + +- **Severity.** `blocker` — a requirement missing or wrong, a correctness bug, or a rule breach with a real + consequence. `major` — real cost, not shipping-critical. `nit` — taste. Set `needs-human` on any finding + that turns on design intent the code cannot settle; it is orthogonal to severity and it is the loop's + escape hatch. +- **One defect, one label.** The same defect seen through two axes is one finding: pick the sharper label + and drop the other. Never report it twice. +- **Anchor check.** For every surviving finding, open its `file:line`. Confirm the quoted code is actually + there, the claim still holds, and the line is in the PR diff. Drop what fails. Hallucinated line numbers + and stale claims are the two things that end a reviewer's credibility. + +**Done when**: every finding has a normalized severity, a verified anchor, and exactly one axis label. + +## 5. Report + +Zero findings still produces a report with `Outcome: clear` and zeroed counts — a missing report is +indistinguishable from a review that never ran. + +**PR mode.** The PR is the record. `event` is always `COMMENT`: never `REQUEST_CHANGES`, which blocks the +author's own PR, and never `APPROVE`, which claims accountability an agent does not have. Name the reviewer +from the running model — any provider can run this skill — in a footer `— Reviewed by <agent or model +name>`, falling back to `— Reviewed by agent`. Commands and the all-or-nothing 422 hazard: +[`gh-commands.md`](gh-commands.md). + +*A fresh review (round 1)* posts one batched review event — never N separate comments. Each inline comment +opens with its finding id and severity — `**F1 · blocker** — <claim>` — then the suggested fix and the +footer. The review body is the header table, listing each finding by id, plus a `## Not anchorable` section +for findings with no diff line to sit on: approach-level questions, or lines outside the diff. With zero +findings the body is `Review clear` and the zeroed counts. The id is the handle a human greps for and the +coder cites back, so it is stable: an arbitration round recovers the highest `F<n>` from the existing +comments and numbers any regression from there. + +*An arbitration round (round 2+)* acts on each ruling on its own thread, because the reviewer owns the +threads it raised and is the one that closes them: + +| Ruling | Reply | Thread | +| --- | --- | --- | +| fix verified | `F<n> — verified` | resolve | +| reject agreed | `F<n> — accepted, <reason>` | resolve | +| reject disputed | the counter-argument | leave open | +| regression from a fix | a new inline comment, next free id | leave open | + +When no `blocker` or `major` thread is left open — only `deferred` nits remain — post a final review whose +body is `Review clear` and the counts. That terminal comment is the PR's `Outcome: clear`. + +**Chat mode.** Same content, no file, no PR — report the findings in the conversation. + +Close by reporting counts per severity, counts per axis (an axis that came back empty is worth a second +look), and the `Outcome`. + +**Done when**: the review is posted (or reported in chat) and the counts have been reported. + +## Out of bounds + +Not findings, no matter how they look. Each of these otherwise fills a report with noise that trains the +reader to skim past real problems. + +- **Any visual or styling judgement.** Presentation belongs to the `[human]` style leaf. +- **Anything the spec's Out of scope section names.** +- **Missing Playwright screenshot baselines** — human-generated, per `.agents/delegation.md`. +- **Style preferences with no basis** in `.agents/rules/`, a `CONTEXT.md`, or the surrounding code. No + citable rule or precedent, no finding. +- **"Add a comment explaining what this does"** — `code-quality.md` forbids *what* comments. +- **A primitive or shortcut with a why-comment on it.** The comment is an override signal: read it and back + off rather than arguing with it. +- **A `TODO (design):` marker that has been consumed.** An *unconsumed* marker, though, is a finding here: + by land the `[human]` style leaves have had their turn, so a leftover marker is real dead scaffolding. diff --git a/.agents/skills/review-changes/gh-commands.md b/.agents/skills/review-changes/gh-commands.md new file mode 100644 index 00000000000..21c72821076 --- /dev/null +++ b/.agents/skills/review-changes/gh-commands.md @@ -0,0 +1,151 @@ +# gh / GraphQL command reference + +The whole PR surface both review skills need: `review-changes` posts findings and, in arbitration rounds, +replies and resolves the threads it raised; `resolve-review` gathers, replies, and resolves bot and human +threads. Substitute `{owner}`, `{repo}`, `{N}` (PR number), `{commentId}`. Derive `{owner}/{repo}` once +and reuse. + +Confirm `gh auth status` succeeds before anything else — follow the `check-github-cli` skill if it does +not. Never authenticate on the developer's behalf. + +## Repo & PR discovery + +```bash +# owner / repo for the current checkout +gh repo view --json nameWithOwner,owner,name + +# PR for the current branch — its absence is what selects chat mode. Use `gh pr list`, never `gh pr view`, +# which exits 1 both when no PR exists and when it can't reach GitHub: exit ≠ 0 is a tooling failure that +# aborts, exit 0 with `[]` is genuinely no PR. +gh pr list --head "$(git branch --show-current)" --state open \ + --json number,title,url,headRefName,baseRefName,state,isDraft + +# head sha, needed as commit_id when posting a review +git rev-parse HEAD +``` + +## Posting a review (review-changes) + +One batched review event per round. Build the payload as a file, then: + +```bash +gh api -X POST repos/{owner}/{repo}/pulls/{N}/reviews --input review.json +``` + +```json +{ + "commit_id": "<head sha>", + "event": "COMMENT", + "body": "<header table, plus a '## Not anchorable' section if any>", + "comments": [ + { + "path": "src/slices/token/pages/Holders.tsx", + "line": 41, + "side": "RIGHT", + "body": "**F1 · blocker** — <claim>\n\n<suggested fix>\n\n— Reviewed by <agent or model name>" + } + ] +} +``` + +`event` is always `COMMENT`. For a multi-line anchor add `start_line` (and `start_side`) alongside +`line`. + +### Validate every anchor first — the POST is all-or-nothing + +A single comment whose `line` is not in the diff returns 422 and **the entire review is discarded**, +silently losing every other comment. So compute the anchorable lines before posting: + +```bash +gh api repos/{owner}/{repo}/pulls/{N}/files --paginate \ + --jq '.[] | {path: .filename, patch: .patch}' +``` + +For each file, walk its `patch`: every `@@ -a,b +c,d @@` header starts a hunk whose RIGHT-side line +numbers run from `c`; added (`+`) and context (` `) lines each advance that counter and are anchorable, +removed (`-`) lines do not advance it and are not. A finding whose line is outside that set moves into the +review body under `## Not anchorable`. + +If a POST still 422s, retry once with the offending comments demoted into the body. Never drop them. + +## Gather (resolve-review) + +```bash +# inline review comments — the usual review threads +gh api repos/{owner}/{repo}/pulls/{N}/comments --paginate + +# PR-level reviews (summary body + state per reviewer) +gh api repos/{owner}/{repo}/pulls/{N}/reviews --paginate + +# issue-level comments (the conversation tab, incl. most bot posts) +gh api repos/{owner}/{repo}/issues/{N}/comments --paginate +``` + +Fields worth reading per inline comment: + +| Field | Use | +| --- | --- | +| `id` | the comment's databaseId — needed to reply | +| `in_reply_to_id` | `null` = top-level; otherwise a reply within a thread | +| `path`, `line` / `original_line` | where it sits — open this code | +| `diff_hunk` | the snippet the reviewer saw | +| `user.login` | author — this is how you tell a human from a bot from this workflow's own review | +| `body` | the comment text | +| `html_url` | link back to the comment | + +**Telling the sources apart matters**, because they are adjudicated differently: a comment whose body ends +in a `— Reviewed by …` footer is this workflow's own review, whichever provider produced it, and may be +rejected; a bot's gets no deference at all; a human's may never be rejected. Test the footer **first** — +`user.login` is the repo owner's account for every agent, so nothing else separates this workflow's review +from a human's — then `user.type == "Bot"` for the bots. Never match bot logins by name; see the source +table in `../resolve-review/SKILL.md` for why. + +### Parse a comment / PR link + +- **PR number**: `pull/(\d+)` +- **Inline review comment id**: `#discussion_r(\d+)` +- **Issue comment id**: `#issuecomment-(\d+)` + +The captured id equals the REST `id` (databaseId), which maps to a GraphQL thread via the query below. + +## Reply, then resolve (both skills) + +```bash +gh api -X POST repos/{owner}/{repo}/pulls/{N}/comments/{commentId}/replies \ + -f body="…your reply…" +``` + +`{commentId}` is the thread's top-level comment (the one with `in_reply_to_id: null`). + +List unresolved threads with their node id and first comment's databaseId: + +```bash +gh api graphql -f query=' +query { + repository(owner:"{owner}", name:"{repo}") { + pullRequest(number:{N}) { + reviewThreads(first:50) { + nodes { id isResolved comments(first:1){ nodes { databaseId } } } + } + } + } +}' -q '.data.repository.pullRequest.reviewThreads.nodes[] + | select(.isResolved==false) + | "\(.id) \(.comments.nodes[0].databaseId)"' +``` + +```bash +gh api graphql -f query='mutation { + resolveReviewThread(input:{threadId:"PRRT_…"}) { thread { id isResolved } } +}' +``` + +Notes: + +- Reply *before* resolving — a resolved thread still accepts replies, but replying first keeps the + explanation visible. +- Skip threads already `isResolved`. +- Leave `disputed`, `needs-human` and `answered` threads **unresolved** — they are exactly the ones that + must stay visible. +- Bot status posts (CodeRabbit "review skipped", Copilot's PR overview) arrive as issue comments, have no + thread to resolve, and are not actionable. diff --git a/.agents/skills/review-changes/prose-smells.md b/.agents/skills/review-changes/prose-smells.md new file mode 100644 index 00000000000..e5e1c1ee32b --- /dev/null +++ b/.agents/skills/review-changes/prose-smells.md @@ -0,0 +1,75 @@ +# Prose smell baseline + +Nine smells for the instruction surface — `.agents/`, `AGENTS.md`, every `CONTEXT.md`, `.cursor/`, +`.github/*instructions*`. The Standards axis carries **this** baseline instead of +[`smells.md`](smells.md) when the diff touches those files, because a markdown diff has no God Components +and thirteen React/TS smells on it are pure noise. + +An instruction file is not documentation — it is **code that runs on an agent**. A rule nobody can execute +deterministically is as broken as a function that returns the wrong value, so review it that way. + +Three rules bind the baseline, mirroring `smells.md`: + +- **The repo overrides.** A convention already documented in `.agents/` wins. Where a file states its own + rule, suppress the smell that would contradict it. +- **Always a judgement call.** Report a smell as a labelled possibility ("possible Sediment"), never as a + violation. +- **Skip what tooling enforces.** `lint:doc-links` already resolved every link, heading anchor and path + reference, and flagged every path written short; cspell already ran. Their findings are facts, not smells — + neither a dead reference nor a shorthand path is ever a finding here. + +One defect, one label. Duplication and Shotgun Surgery describe the same mess from two directions — pick +the sharper one. + +## The smells + +- **Duplication** — the same meaning stated in more than one place. Two copies that *agree* look fine + until one is edited, which is why this is invisible to a read-and-compare pass: find it by grepping a + distinctive phrase from the diff across the instruction surface. → keep one authoritative statement, + point at it from the other site. *The highest-yield smell on this surface.* +- **Divergent Change** — one document edited for unrelated reasons, so a fact sits in a file whose + *reason to change* differs from the fact's own. The test: **what change would force an edit to this + line?** If the answer is not the same as for the rest of the file, it is misfiled. → move it to the + document whose reason-to-change matches. *See "Picking the file" below.* +- **Shotgun Surgery** — one decision forces edits in several files, usually because a pointer *summarises* + its target instead of pointing at it. A pointer that restates drifts, and the restatement is the copy + that goes stale. → point, don't summarise. +- **Sediment** — a line true of a previous design, left because adding feels safe and removing feels + risky. Especially: an absolute that a later change made conditional. → delete it, or reconcile it with + what the change just established. +- **Ambiguous absolute** — "always X" / "never Y" where an exception exists elsewhere in the repo. A reader + arriving directly at the absolute will not know the exception exists. → state the exception at the + absolute, or drop the word "always". +- **Rule without a mechanism** — states what must be true but not how to establish it, leaving the agent + to invent a procedure. It reads as guidance and executes as a coin flip. → give the command, the file, + or the ordered probe. *`gh pr list --json` over "check whether a PR exists" is the standing example.* +- **Incomplete case split** — an enum, status list, or branch set where a case is unhandled: a value the + writer defined and then no rule consumes. → handle every case, or say explicitly that one is ignored. +- **Uncheckable completion criterion** — a step whose "done when" cannot be told apart from not-done + ("produce a change list" rather than "every modified model accounted for"). Invites stopping early on + the easy half. → make it checkable, and exhaustive where it matters. +- **No-op** — a line the agent already obeys by default, so the file pays context to say nothing. Test it + in isolation: does behaviour differ without it? → delete the whole sentence rather than trimming words + from it. + +## Picking the file + +Divergent Change is the smell this repo gets wrong most, because two files can each plausibly hold a fact. +Resolve it by matching **invalidation triggers** — what would make someone edit this file at all: + +| File | Edited when | Answers | +| --- | --- | --- | +| `.agents/delegation.md` | agent capability or trust changes | *may an agent do this?* | +| `.agents/tasks/README.md` | the product-task workflow changes | *how does a task reach a merged PR?* | +| `.agents/AGENTS.md` | the repo gains something worth knowing about | *what exists, where do I read it?* | +| `.agents/rules/*.md` | a coding convention changes | *how do I write the code?* | +| a skill's `SKILL.md` | that workflow's steps change | *what do I do, in what order?* | + +Two consequences worth flagging as findings: + +- **`AGENTS.md` carries pointers, never mechanics.** It is always-loaded, so every session pays for it — + including the majority that never touch the thing being explained. A *how* there is misfiled by + construction. +- **A statement naming a spec artefact** — ticket, leaf, tag, checkbox, breakdown — belongs to the + workflow layer, not to `delegation.md`, which must read correctly in a repo that never adopted the + workflow. diff --git a/.agents/skills/review-changes/smells.md b/.agents/skills/review-changes/smells.md new file mode 100644 index 00000000000..c8af44d87ce --- /dev/null +++ b/.agents/skills/review-changes/smells.md @@ -0,0 +1,46 @@ +# Smell baseline + +Thirteen code smells the Standards axis carries on top of whatever `.agents/rules/` documents. Each reads +*what it is* → *how to fix*; match them against the diff. + +Three rules bind the baseline: + +- **The repo overrides.** A documented rule always wins. Where `.agents/rules/` endorses something a smell + would flag, suppress the smell — `typescript.md` endorsing `switch` on discriminated unions is the + standing example. +- **Always a judgement call.** Report a smell as a labelled possibility ("possible Data Clumps"), never as + a violation. +- **Skip what tooling enforces.** ESLint, `tsc` and cspell already ran; their findings are facts, not + smells. + +One defect, one label. Several smells often describe the same hunk (Primitive Obsession and Data Clumps +usually arrive together) — pick the sharper one and drop the rest, or the report inflates. + +## The smells + +- **Duplicated Code** — the same logic shape appears in more than one hunk or file in the change. + → extract the shape, call it from both. +- **Data Clumps** — the same few props or params keep travelling together; a type wanting to be born. + → bundle them into one type, pass that. +- **Primitive Obsession** — a `string` or `number` standing in for a domain concept that already has a + type here. → use the existing type. +- **Shotgun Surgery** — one logical change forced scattered edits across many files in the diff. + → gather what changes together into one module. +- **Divergent Change** — one file is edited for several unrelated reasons. → split it so each module + changes for one reason. +- **Speculative Generality** — props, params, or abstraction added for needs the spec does not state. + → delete it; inline back until a real need shows. *The highest-yield smell in agent-written code.* +- **Middle Man** — a component or hook that only forwards to another. → cut it, call the real target. +- **Feature Envy** — a module that mostly manipulates another area's data or internals. → move it to the + data it envies; see `src/slices/CONTEXT.md` on slice ownership. +- **God Component** — one component fetching, shaping, rendering *and* laying out. → extract a hook, or + split container from presentation. +- **Effect Escape Hatch** — `useEffect` doing what derived state, an event handler, or a react-query + option already does. → compute at render, handle in the handler, or configure the query. +- **Prop Drilling** — a prop threaded through three or more levels purely to pass along. → context, or + compose the children where the data already is. +- **Cloned-Sibling Leftovers** — a new file copied from a neighbour, carrying imports, props, labels or + comments that do not apply to it. → delete what the new file does not use. *Near-invisible in a + hunk-only read; found by reading the new file whole.* +- **Dead Scaffolding** — exports, props, types or fixtures the change introduces and never uses. + → delete them. ESLint catches unused locals, not unused exports. diff --git a/.agents/skills/slack-file/SKILL.md b/.agents/skills/slack-file/SKILL.md new file mode 100644 index 00000000000..ec8d43391e8 --- /dev/null +++ b/.agents/skills/slack-file/SKILL.md @@ -0,0 +1,60 @@ +--- +name: slack-file +description: >- + Download Slack attachments by file ID, or upload local files into a Slack thread. + Use when a Slack message lists Files: whose contents matter, or when posting a + file to Slack; the MCP connector reports metadata only. Guides Keychain token + setup if missing. +--- + +# Slack file + +The Slack MCP connector reports attachment metadata only. This skill's script is the download and upload path. Other skills that need Slack file bytes follow this skill. + +macOS. The token lives in the Keychain (service `slack-files-token`); the script reads it. Do not print, echo, or pass the token — including via `security … -w`. + +## 1. Ready + +From **this skill's directory** (the folder that contains this `SKILL.md`): + +1. `scripts/slack-file` is executable. +2. Keychain has the service, metadata only: `security find-generic-password -s slack-files-token` (no `-w`). +3. `jq` and `curl` are on `PATH`. + +If 2 fails: give the user **Setup** below and stop. Do not store the token yourself. + +The sandbox cannot read the Keychain or reach `slack.com` — run the script with those allowed. + +**Done when:** every check passes, or the run has stopped for setup. + +## 2. Run + +File IDs come from `slack_read_thread` as `Files: name.png (ID: F012SSD0KK8, image/png, 393.6 KB)`. + +Download — prints local paths; then Read them: + +```bash +scripts/slack-file -d <dir> FILE_ID [FILE_ID ...] +``` + +Upload — `slack_send_message` is text-only; this posts the files. Prints the new file IDs: + +```bash +scripts/slack-file up -c CHANNEL_ID [-t THREAD_TS] [-m COMMENT] FILE [FILE ...] +``` + +If the command fails: ask the user to paste or upload each file. Stop if they decline. + +**Done when:** every requested file is a local path (download) or a printed file ID (upload), or the run has stopped. + +## Setup + +A Slack app with `files:read` (and `files:write` to upload). The Blockscout workspace: [Files Reader](https://blockscout.slack.com/marketplace/A0BMY22GBQR-files-reader). + +Then the user stores the token themselves: + +```bash +security add-generic-password -a "$USER" -s slack-files-token +``` + +It prompts. Already exists: add `-U`. `jq`: `brew install jq`. diff --git a/.agents/skills/slack-file/scripts/slack-file b/.agents/skills/slack-file/scripts/slack-file new file mode 100755 index 00000000000..a0bd6395d98 --- /dev/null +++ b/.agents/skills/slack-file/scripts/slack-file @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# Move files in and out of Slack, so agents can read attachments the MCP connector +# only reports as metadata, and post images back into a thread. +# +# The token lives in the macOS Keychain rather than a dotfile so it never lands +# in a repo, an env dump, or a session transcript. +# cspell:ignore esac +set -euo pipefail + +KEYCHAIN_SERVICE=slack-files-token + +usage() { + cat >&2 <<'EOF' +usage: slack-file [-d DIR] FILE_ID [FILE_ID ...] + slack-file up -c CHANNEL_ID [-t THREAD_TS] [-m COMMENT] FILE [FILE ...] + +Download mode: fetches each Slack file into DIR (default: cwd) and prints the +local paths. File IDs look like F012SSD0KK8 and appear in slack_read_thread +output as "Files: name.png (ID: F012SSD0KK8, image/png, 393.6 KB)". + +Upload mode ("up"): uploads local files to a channel, optionally as a reply to +THREAD_TS, and posts COMMENT as the message text alongside them. Needs the +files:write scope. Prints the resulting file IDs. +EOF + exit 64 +} + +if ! token=$(security find-generic-password -s "$KEYCHAIN_SERVICE" -w 2>/dev/null); then + echo "slack-file: no token in Keychain under service '$KEYCHAIN_SERVICE'" >&2 + echo "slack-file: follow the slack-file skill (Setup)" >&2 + exit 1 +fi + +# The auth header is piped in as a curl config file: passing it via -H would put +# the token in argv, where `ps` can read it. +slack_curl() { + printf 'header = "Authorization: Bearer %s"\n' "$token" | curl -sS -K - "$@" +} + +api_ok() { + [ "$(jq -r '.ok' <<<"$1")" = true ] +} + +api_err() { + jq -r '.error // "unknown error"' <<<"$1" +} + +upload() { + local channel='' thread='' comment='' opt + while getopts ':c:t:m:' opt; do + case $opt in + c) channel=$OPTARG ;; + t) thread=$OPTARG ;; + m) comment=$OPTARG ;; + *) usage ;; + esac + done + shift $((OPTIND - 1)) + [ -n "$channel" ] && [ $# -gt 0 ] || usage + + local uploaded='[]' path name size meta upload_url file_id + for path in "$@"; do + if [ ! -f "$path" ]; then + echo "slack-file: $path: no such file" >&2 + exit 1 + fi + name=$(basename "$path") + size=$(wc -c <"$path" | tr -d ' ') + + meta=$(slack_curl --get 'https://slack.com/api/files.getUploadURLExternal' \ + --data-urlencode "filename=$name" --data-urlencode "length=$size") + api_ok "$meta" || { echo "slack-file: $name: $(api_err "$meta")" >&2; exit 1; } + + upload_url=$(jq -r '.upload_url' <<<"$meta") + file_id=$(jq -r '.file_id' <<<"$meta") + + # The upload URL is pre-signed, so this leg carries no Authorization header. + curl -sS -f -F "file=@$path" "$upload_url" >/dev/null + + uploaded=$(jq -c --arg id "$file_id" --arg title "$name" \ + '. + [{id: $id, title: $title}]' <<<"$uploaded") + done + + # completeUploadExternal posts the files as one message, so `comment` becomes + # that message's text — no separate chat.postMessage needed. + local payload + payload=$(jq -n -c \ + --argjson files "$uploaded" \ + --arg channel "$channel" \ + --arg thread "$thread" \ + --arg comment "$comment" \ + '{files: $files, channel_id: $channel} + + (if $thread == "" then {} else {thread_ts: $thread} end) + + (if $comment == "" then {} else {initial_comment: $comment} end)') + + local result + result=$(slack_curl -X POST 'https://slack.com/api/files.completeUploadExternal' \ + -H 'Content-Type: application/json; charset=utf-8' --data-binary "$payload") + api_ok "$result" || { echo "slack-file: upload failed: $(api_err "$result")" >&2; exit 1; } + + jq -r '.files[] | "\(.id) \(.title)"' <<<"$result" +} + +if [ "${1:-}" = up ]; then + shift + upload "$@" + exit 0 +fi + +dir=$PWD +while getopts ':d:h' opt; do + case $opt in + d) dir=$OPTARG ;; + *) usage ;; + esac +done +shift $((OPTIND - 1)) +[ $# -gt 0 ] || usage + +mkdir -p "$dir" +status=0 +for id in "$@"; do + meta=$(slack_curl "https://slack.com/api/files.info?file=$id") + if ! api_ok "$meta"; then + echo "slack-file: $id: $(api_err "$meta")" >&2 + status=1 + continue + fi + name=$(jq -r '.file.name' <<<"$meta") + url=$(jq -r '.file.url_private_download // .file.url_private' <<<"$meta") + out="$dir/${id}-${name//[^A-Za-z0-9._-]/_}" + if ! slack_curl -fL -o "$out" "$url"; then + echo "slack-file: $id: download failed" >&2 + rm -f "$out" + status=1 + continue + fi + echo "$out" +done +exit "$status" diff --git a/.agents/skills/to-spec/SKILL.md b/.agents/skills/to-spec/SKILL.md index 23c8344a620..41d6615fde3 100644 --- a/.agents/skills/to-spec/SKILL.md +++ b/.agents/skills/to-spec/SKILL.md @@ -1,137 +1,42 @@ --- name: to-spec description: >- - Convert the current conversation into a product-task spec in .agents/tasks/, or update an existing - spec — folding in new decisions, harvesting colleague replies from Slack threads, and sending open - questions to their owners. Use at the end of a grilling session, when the user wants to capture any - conversation as a spec, or to sync a spec's open questions with Slack. + Turn the current conversation into a product-task spec and publish it as a draft PR: no interview, just synthesis of what you've already discussed. +disable-model-invocation: true --- # To spec -Turn the current conversation into a spec file — or merge it into one that already exists. The spec is the -single source of truth for a product task: `implement-task` executes from it, humans work from it, and its -open questions drive the Slack round-trip with PMs, designers, and backend engineers. +Turn the finished conversation into a task's `spec.md` and `questions.md`. **Synthesize what the +conversation already settled; do not interview** — the decisions and the open questions were made in the +session that hands off here. This skill's whole job is writing them down and bootstrapping +the draft PR. -This skill is **conversation-agnostic**: it is normally invoked at the end of a `grill-the-task` session, -but works from any conversation that contains decisions worth capturing — including an **empty** one. A -fresh session invoking it on an existing spec (e.g. `/to-spec 3219-cross-chain-txs`) is the normal way to -sync Slack replies: there is nothing to convert, so the run is just harvest (Step 2) plus outreach (Step 4). +This skill runs **once** per task. The spec is write-once: there is no update or merge mode. What that +means, and why answers never rewrite it, is in [`../../tasks/concepts.md`](../../tasks/concepts.md). Not +every task gets a spec — see "Not every task needs a spec" in +[`../../tasks/README.md`](../../tasks/README.md). -## Spec location and structure +## Step 1 — Write the spec and questions -- With a GitHub issue: `.agents/tasks/<issue-number>-<slug>/spec.md` (e.g. `.agents/tasks/3219-cross-chain-txs/spec.md`). -- Ad-hoc (no issue): `.agents/tasks/<slug>/spec.md`. -- Every subtask of a medium/large task gets its own folder `subtasks/<NN>-<slug>/`, holding: - - `brief.md` — the handoff from the initial grilling session for a subtask that isn't scoped yet. Its - presence (with no `spec.md`) marks the subtask as not-yet-scoped. It carries: the subtask's goal in a - sentence or two; the context already gathered (relevant code, endpoints, mockups); the specific unknowns - to resolve (what to research, prototype, or decide) and who owns each; and links (issue, Figma, related - specs) — enough for a `grill-the-task` subtask session to start without re-deriving it. - - `spec.md` — the subtask spec (same template), Status `draft | ready | in progress | done`. Written up - front for a scoped subtask, or by the just-in-time subtask session for a deferred one — filled from the - folder's `brief.md`. - - `research.md` — optional; real research findings or prototype notes produced before the subtask - session, feeding it alongside the brief. +Derive the task folder from the issue: `.agents/tasks/<issue-number>-<slug>/` (layout in +[`../../tasks/structure.md`](../../tasks/structure.md)). Write two files: -Use `spec-template.md` (next to this file) for every spec — main and subtask alike. Structure by size: +- **`spec.md`** from [`spec-template.md`](spec-template.md). +- **`questions.md`** from [`questions-template.md`](questions-template.md). -- **small** — one `spec.md`, no `subtasks/`; the whole task is a single leaf worklist. -- **medium** — the main spec is a slim index; each subtask is a folder with a fully-specified `spec.md`. -- **large** — same layout; big subtasks are deferred (a `brief.md` now, no `spec.md`; the sub-spec is - written just-in-time later). +Keep the spec an **index of decisions**, not a worklog — what to build and why, pointing at detail (its +Slack thread, the code, the PR) rather than copying it. **Show the user the two files and get their +confirmation of the content — this is the run's one gate.** -**The main spec is an index, not a container.** Its Task breakdown is one line per subtask (checkbox + -title + folder link) — never inline inputs, requirements, or changelogs; that detail belongs in the -subtask's own `spec.md`. Tag every subtask `[agent]` or `[human]` per `.agents/delegation.md` (UI -work defaults to the scaffold → style split). Specs merge with the task's PR and accumulate in -`.agents/tasks/` as precedent. +## Step 2 — Branch and draft PR -## Workflow +The content confirmation in Step 1 is the only prompt. Once it's given, run this sequence **without asking +again** — it authorizes the branch, commit, push, and draft PR together: -### Step 1 — Locate the spec +1. **Branch** — `issue-<number>` off `main`. Create/switch if needed and record it in the spec header. +2. **Commit** — commit `spec.md` and `questions.md` as the branch's first commit. +3. **Draft PR** — hand off to the `create-pr` skill (draft-placeholder mode, feature branch → `main`); it + pushes and opens the draft with no further confirmation. Why the draft opens this early is in + [`../../tasks/README.md`](../../tasks/README.md). -Derive the path from the issue (or ask for a slug). If the file already exists, this is an **update** run: -read the spec first and treat it as hand-editable — developers edit specs directly between runs. - -### Step 2 — Harvest Slack answers (update runs only) - -Open questions live in the main `spec.md` **and** in any subtask `spec.md` under `subtasks/*/` — gather -them from all of these files. For every open question with status `pending` and a recorded Slack permalink: - -1. Read the thread with the Slack MCP tools (`slack_read_thread`; parse `channel_id`/`message_ts` from the - permalink as in the `create-issue-from-slack-thread` skill). -2. If there are replies, summarize them and propose a resolution to the user. -3. On acceptance: fold the decision into the affected spec section(s), set the question's status to - `resolved`, and record the answer and date in its entry. -4. If a reply raises a follow-up: draft it (in Russian, like all outreach), get the user's approval, send it - **into the same thread**, and keep the question `pending`. - -The harvest is complete when every `pending` question with a permalink — across the main spec and every -subtask spec — has had its thread read and is now resolved, followed up, or confirmed still unanswered. - -### Step 3 — Write or merge the spec - -Extract from the conversation: decisions, requirements, data/API facts, UI inventory, size classification, -task breakdown, and unanswered questions with their owners — the per-team contacts picked during the -session (defaults from `.agents/TEAM.md`), recorded in the header. - -**Write to the right file.** Task-level facts (context, shared data/API, overall UI inventory, out-of-scope, -the index breakdown) go in the main `spec.md`; a subtask's own requirements, data, UI, executor-skill -`inputs:`, and leaf worklist go in `subtasks/<NN>-<slug>/spec.md`. A subtask that isn't scoped yet gets a -`brief.md` instead of a `spec.md`. - -**Merge surgically.** On update runs, never regenerate the file: preserve checked boxes, statuses, hand -edits, and resolved-question records; only add or amend what the conversation actually changed. Show the -user a summary of the changes and confirm before moving on. - -**No changelogs.** Record a subtask's completion as a one-line note on its checkbox, nothing more — the -commit and the PR are the record of what changed. Durable decisions taken during work (a new dependency, an -architectural choice) are folded into the relevant spec section, not appended as a "done: …" block. - -Status field: a new spec starts as `draft`; set it to `ready` once no `pending` question blocks the first -subtask (per-subtask blocking — unblocked subtasks may proceed while unrelated questions are pending). - -### Step 4 — Send open questions (outreach) - -For `pending` questions that have **no** Slack permalink yet: - -1. Group them by owner. -2. Pick each group's destination: - - Task has a **dedicated feature channel** (spec header) → **all** questions go there, API ones included. - - Otherwise, **product questions go to the frontend channel** (see `.agents/TEAM.md`) — never a DM — so - colleagues from other teams (QA in particular) build the same understanding of the feature. - - Other questions (API, design) default to a DM with the owner. - - When posting to a channel, **always mention the addressee** — `<@member ID>` from `.agents/TEAM.md` - (people missing from the roster: resolve by name via `slack_search_users` and suggest adding them). -3. Draft one message per owner: brief task context (issue link), the questions, and why they block progress. - Write all Slack messages in **Russian** — the team's internal language (the spec itself stays in English). -4. **Show every draft (with its destination) to the user and wait for explicit approval** — never send - unreviewed outreach. -5. Send (`slack_send_message`), then record each thread's permalink in the question's entry. - -If the Slack MCP tools are unavailable, record the questions with owners anyway and tell the user to route -them manually. - -Outreach is complete when every `pending` question has a recorded permalink — or an explicit note that the -developer routes it manually. - -### Step 5 — Branch and draft PR (first creation only) - -When this run **created** the spec (or sub-spec), bootstrap the workflow's draft-PR-first policy — each -action only with the developer's explicit approval, never unprompted: - -1. **Branch** — main spec: `issue-<number>` off `main`; sub-spec (subtask mode): `issue-<number>-step-<N>` - off the feature branch; **ad-hoc spec** (no issue): the task-dir slug itself (spec in - `.agents/tasks/<slug>/` → branch `<slug>`). Create/switch if needed and record the branch in the spec - header. -2. **Commit** — propose committing the spec as the branch's first commit; show what will be committed and - wait for confirmation. -3. **Draft PR** — suggest opening it right away via the `create-pr` skill (draft-placeholder mode; feature - branch → `main`, sub-branch → feature branch). Why drafts open this early is documented in - `.agents/tasks/README.md`; the PR flips to ready when the breakdown's last box is checked (the - `implement-task` skill nudges at that moment). - -For ad-hoc specs the draft PR doubles as a **parking spot**: an idea captured as a spec today can sit in -its draft PR and be picked up, refined, or implemented days later — visible on GitHub instead of only in a -local working tree. diff --git a/.agents/skills/to-spec/questions-template.md b/.agents/skills/to-spec/questions-template.md new file mode 100644 index 00000000000..d8c4b557152 --- /dev/null +++ b/.agents/skills/to-spec/questions-template.md @@ -0,0 +1,13 @@ +# Open questions — <task title> + +<!-- One entry per question, with a stable id (`Q01`, `Q02`, …). The id is what a ticket names in its +`Blocked by` to declare the question gates it, so ids never change once assigned. `to-spec` creates this +file and records each Slack permalink when the question is sent; answers are folded in later by a plain +edit — the decision as a phrase plus its date, not the deliberation. --> + +### Q01 — <question> + +- Owner: <role> (<name>) +- Status: `pending` \| `resolved` \| `waived` +- Slack: <permalink, once sent> +- Answer: <the decision as a phrase, + date> diff --git a/.agents/skills/to-spec/spec-template.md b/.agents/skills/to-spec/spec-template.md index 6ba60ea670e..ebabc08cb25 100644 --- a/.agents/skills/to-spec/spec-template.md +++ b/.agents/skills/to-spec/spec-template.md @@ -2,82 +2,55 @@ | | | | --- | --- | -| Issue | <GitHub issue URL, or "—" for ad-hoc specs> | -| Status | `draft` \| `ready` \| `in progress` \| `done` | -| Size | `small` \| `medium` \| `large` | -| Feature branch | `<branch name>` (set on first `implement-task` run) | +| Issue | <GitHub issue URL> | +| Feature branch | `<branch name>` | | PM | <name> | | Designer | <name> | | Backend | <name> | -| Slack channel | <#feature-channel if the task has one; otherwise "—" (default routing per `to-spec`)> | +| Minimum API version | <API version(s) this task requires, e.g. "Core API v11.2.4+"; list several for a multi-service raise; "—" if none> | +| Slack channel | <#feature-channel if the task has one; otherwise "—" (default routing per `grill-the-task`)> | -<!-- People default from `.agents/TEAM.md`; override here per task. --> - -<!-- SUBTASK SPECS reuse this same template, at `subtasks/<NN>-<slug>/spec.md`, with two header changes: -swap the Issue row for `Parent spec | [../../spec.md](../../spec.md) — step <N> of #<issue>`, and add a -`Sub-branch | issue-<N>-step-<N>` row. The Status vocabulary is the same as a main spec's -(`draft | ready | in progress | done`). A subtask that hasn't been scoped yet has NO `spec.md` at all — -only a `brief.md` in its folder (the handoff from the initial grilling session); the just-in-time subtask -session reads that brief and writes this `spec.md`. People rows inherit from the parent unless a subtask -overrides one. --> +<!-- Header is static identity — no status row: task status is derived from `progress.md` (see +`.agents/tasks/structure.md`). People default from `.agents/TEAM.md`; override here per task. The spec body +below is immutable once written; only `progress.md` and `questions.md` change as work proceeds. --> ## Context & goal -<!-- The "why" and the user-facing outcome. A couple of paragraphs, no more. --> +<!-- The problem that the user is facing and solution to it, from the user's perspective. --> ## Functional requirements -<!-- User stories / testable statements. What the feature must do, not how it looks. --> +<!-- What the feature must do, as verifiable feature-level statements. THIS is the whole-task review +contract. Write each one so its truth can be judged from the shipped behaviour. --> ## Data & API -<!-- Endpoints with sample responses (curl-verified), which `service:name` resources exist vs. must be +<!-- Endpoints, which `service:name` resources exist vs. must be added, pagination/sorting/filtering params, env vars / feature flags, API readiness (deployed vs. staging-only) and the backend release version that ships the changes (for release-notes reference). --> ## UI inventory <!-- Affected pages/tabs/components: routes, navigation entry points, cross-links to existing entity -pages. One Figma node link per screen. Behavioral facts only — never visual/styling prose; appearance -belongs to the mockups and the [human] style subtasks. --> - -## Out of scope - -<!-- Explicit non-goals, so agents don't wander. --> - -## Task breakdown +pages. One Figma node link per screen. State behavioral facts and placement; leave appearance to the +mockups and the [human] style leaves. When you must lean on existing code, point to it by component or +symbol name ("match `LogDecodedInputDataTable`") — never transcribe its values, class names, or line +numbers; those rot and the code owns them. Capture only a deliberate deviation and its reason. See "What +the spec holds" in `.agents/tasks/concepts.md`. --> -<!-- Ordered checklist. The checkbox is the ONLY per-subtask state the spec tracks — done or not. -Readiness is NOT recorded here; it lives in each subtask spec's Status and is inferred from there. +## Implementation decisions -MAIN spec of a medium/large task = a slim INDEX. One line per subtask, no inputs, no changelog — the -detail lives in the subtask's own `subtasks/<NN>-<slug>/spec.md`: +<!-- A list of implementation decisions that were made. This can include: +- The modules that will be built/modified +- The interfaces of those modules that will be modified +- Technical clarifications from the developer +- Architectural decisions +- Specific interactions - - [ ] 1 `[agent]` <plain-language subtask title> → `subtasks/01-<slug>/` - - [ ] 2 `[human]` <plain-language subtask title> → `subtasks/02-<slug>/` +Do NOT include specific file paths or code snippets. They may end up being outdated very quickly. -LEAF worklist (a small task's single spec.md, or the breakdown inside a subtask spec) = the actual -steps. Tag each `[agent]`/`[human]` per `.agents/delegation.md`; reference the executing skill; -list blocking question ids (the step may not start while any is `pending`); and record the executor -skill's interview answers as an indented `inputs:` list, so `implement-task` never stops to ask. A UI -component is two linked leaves (scaffold → style). Keep each completion note to ONE line — no changelog -blocks; fold durable decisions into the sections above and let git and the PR be the record of what -changed. --> +Exception: if a prototype produced a snippet that encodes a decision more precisely than prose can (state machine, reducer, schema, type shape), inline it within the relevant decision and note briefly that it came from a prototype. Trim to the decision-rich parts, not a working demo, just the important bits. --> -- [ ] 1 `[agent]` <title> — skill: `add-api-resource` — questions: Q2 - - inputs: - - <executor-skill answer> -- [ ] 2 `[human]` Style <component> to mockup — [Figma](<node URL>) - -## Open questions - -<!-- One entry per question. Status is the gate `implement-task` checks. The Slack permalink is recorded -when the question is sent, so answers can be harvested later. When resolved, fold the decision into the -section above that it affects AND record it here. --> - -### Q1 — <question> +## Out of scope -- Owner: <role> (<name>) -- Status: `pending` \| `resolved` \| `waived` -- Slack: <permalink, once sent> -- Answer: <decision + date, once resolved> +<!-- Explicit non-goals, so agents don't wander. --> diff --git a/.agents/skills/to-tickets/SKILL.md b/.agents/skills/to-tickets/SKILL.md new file mode 100644 index 00000000000..ce2e91dd651 --- /dev/null +++ b/.agents/skills/to-tickets/SKILL.md @@ -0,0 +1,78 @@ +--- +name: to-tickets +description: >- + Break a product-task spec into a set of tracer-bullet tickets, each declaring its blocking edges. +disable-model-invocation: true +--- + +# To tickets + +Break a spec into a set of tickets: tracer-bullet vertical slices, each declaring the tickets that block it. +Its input is the **spec** and **open questions**. + +The ticket model is defined in [`../../tasks/concepts.md`](../../tasks/concepts.md). Layout and file ownership +are in [`../../tasks/structure.md`](../../tasks/structure.md). This skill carries the steps only. + +**Feasibility is the forcing function.** Every gap you hit here — a requirement the spec never pinned, an +executor question the spec can't answer — is a gap that spec should have closed. Ask the developer to fill it +and note it, rather than guessing. + +## Step 1 — Load + +Resolve the task folder from the issue number or the branch (`issue-<number>`), and read its `spec.md`, +`questions.md` and `.agents/delegation.md`. + +## Step 2 — Explore the codebase (optional) + +If you have not already explored the codebase, do so to understand the current state of the code. Ticket titles and descriptions should use the project's domain glossary vocabulary, and respect CONTEXT.md in the area you're touching. + +Look for opportunities to prefactor the code to make the implementation easier. "Make the change easy, then make the easy change." + +## Step 3 — Draft slices + +Break the work into tracer bullet tickets. +- Each slice cuts a narrow but COMPLETE path through every layer (schema, API, UI, tests) — a vertical slice. +- A completed slice is demoable or verifiable on its own +- Each slice is sized to fit in a single fresh context window +- Any prefactoring should be done first + +Give each ticket its `Blocked by` edges — the blockers that must clear before it can start, each prefixed by +kind: `T<NN>` for a ticket that must complete first, `Q<NN>` for an open question that must be answered +first. A ticket with no blockers can start immediately. + +**Defer what can't be scoped.** A ticket blocked on a prototype, a spike, or an answer nobody has yet gets a `brief.md` and no `spec.md`; a later `to-tickets` run scopes it — writing its `spec.md`, or a fresh `brief.md` if it still can't be scoped. + +## Step 4 — Quiz the user + +Present the proposed breakdown as a numbered list. For each ticket, show: +- Title: short descriptive name +- Blocked by: which other tickets (if any) must complete first +- What it delivers: the end-to-end behaviour this ticket makes work + +Ask the user: +- Does the granularity feel right? (too coarse / too fine) +- Are the blocking edges correct: does each ticket only depend on the tickets and questions that genuinely gate it? +- Should any tickets be merged or split further? + +Iterate until the user approves the breakdown. + +## Step 5 — Front-load the executor inputs + +For every `[agent]` leaf that runs a project skill (`add-new-page`, `add-api-resource`, `add-env-var`, …), +**open that skill and run its user-facing interview now**, against the spec — from the skill's current text, +not from memory of its questions (e.g. `add-new-page` Step 0). Record the answers in the ticket's **Skill +inputs** section, grouped by skill. Where the spec answers a question, take the answer from it and move on; +where it has a genuine gap, ask the developer. This is what lets the later `implement-ticket` run go +uninterrupted. + +## Step 6 — Write the files + +Per approved ticket, write `tickets/NN-<slug>/spec.md` from [`ticket-template.md`](ticket-template.md) — What +to build, Acceptance criteria (tagging `(human)` per the ticket model), the Skill inputs from Step 5, the +Leaf worklist with each leaf tagged `[agent]` / `[human]` per `.agents/delegation.md`, and the header's +`Blocked by` edges (`T<NN>` / `Q<NN>`). A ticket that can't be scoped gets a `brief.md` instead — its goal, +the known context, and the blocking unknowns with their owners. + +Create (or append to) `progress.md` from [`progress-template.md`](progress-template.md) — one checkbox line +per ticket. When scoping a deferred ticket, **append** its siblings and retarget the `Blocked by` edges that +pointed at the deferred one; never renumber, never nest. diff --git a/.agents/skills/to-tickets/progress-template.md b/.agents/skills/to-tickets/progress-template.md new file mode 100644 index 00000000000..722703ca73b --- /dev/null +++ b/.agents/skills/to-tickets/progress-template.md @@ -0,0 +1,10 @@ +# Progress — <task title> + +<!-- One checkbox per ticket, nothing else — no titles, edges, or content (those live in each ticket's +`spec.md`; edges and status are read from there and from this file's checks). A checked box means the ticket +LANDED: its commit exists, `Blocked by` edges read it to release dependents, and the last box checked is +what `finalize-task` acts on. `to-tickets` appends a line per ticket; `implement-ticket` checks the box at +commit time. Task status is derived from these boxes — see `.agents/tasks/structure.md`. --> + +- [ ] 01 → `tickets/01-<slug>/` +- [ ] 02 → `tickets/02-<slug>/` diff --git a/.agents/skills/to-tickets/ticket-template.md b/.agents/skills/to-tickets/ticket-template.md new file mode 100644 index 00000000000..7fec9875b30 --- /dev/null +++ b/.agents/skills/to-tickets/ticket-template.md @@ -0,0 +1,66 @@ +# <NN> — <Ticket title> + +| | | +| --- | --- | +| Parent spec | the task's `spec.md` → linked as `../../spec.md`, ticket <NN> of #<issue> | +| Blocked by | <blockers that must clear first, or "none": `T<NN>` for a ticket, `Q<NN>` for a question> | + +<!-- `Blocked by` is the ticket's whole runnable test — `implement-ticket` reads it and nothing else. A +`T<NN>` clears when its box is checked in `progress.md`; a `Q<NN>` clears when it is `resolved`/`waived` in +`questions.md`. People rows are inherited from the parent spec; add one here only to override it. A ticket +that hasn't been scoped yet has NO `spec.md` — only a `brief.md` in its folder, which a just-in-time +`to-tickets` run turns into this file. Open questions themselves live in the task's +`questions.md`; list here only the ids of the ones that gate this ticket. --> + +## What to build + +<!-- The end-to-end behaviour this ticket makes work, from the user's perspective — a paragraph, not a +layer-by-layer plan. What makes a well-formed ticket, and the bounds it satisfies: "The ticket model" in +`.agents/tasks/concepts.md`. --> + +## Acceptance criteria + +<!-- What must be true when this ticket is done — the gate for `implement-ticket`, not the whole-task review +(that reads the spec's Functional Requirements). A `(human)` criterion is one only a person looking at the +running product can judge, and having one is what makes `implement-ticket` pause before it commits. Which +criteria earn `(human)` is defined in "The ticket model" in `.agents/tasks/concepts.md`. Drop the "How to +verify" line when nothing is `(human)`. --> + +How to verify: `pnpm dev:preset <alias>`, open <route> + +- [ ] <criterion the review can check from the diff> +- [ ] `(human)` <criterion only a person judging the running product can check> + +## Details + +<!-- OPTIONAL — only what this ticket needs beyond the main spec's Data & API and UI inventory: the +endpoint and `service:name` resource it touches, the Figma node for its screen, a deliberate deviation and +its reason. Point at existing code by symbol name, never by transcribing its values or line numbers. Delete +the section when the main spec already carries everything. --> + +## Skill inputs + +<!-- OPTIONAL — The answers each executor skill's interview needs, grouped by skill, collected by `to-tickets` against +the spec so `implement-ticket` runs uninterrupted. One sub-heading per skill a leaf below invokes; under it, +that skill's user-facing questions with their answers. Where the spec has a genuine gap, `to-tickets` asks +the developer and records the answer here. --> + +### `<skill-name>` + +- <question>: <answer> + +## Leaf worklist + +<!-- The actual steps, each one project skill's worth of work. Leaves run along layers (resource, then page, +then styling) while the ticket cuts across them. + +Tag every leaf `[agent]` or `[human]` — explicitly, never implied; `implement-ticket` reads the tags as its +state machine. A UI component is two linked leaves (scaffold → style). A leaf names the skill it runs; its +answers live in Skill inputs above, not inline here. + +A leaf's checkbox is PROGRESS STATE — how far the ticket has got, since it has no commit yet. It is never +a changelog: one line at most, and durable decisions get folded into the sections above instead. --> + +- [ ] 1 `[agent]` <title> — skill: `add-api-resource` +- [ ] 2 `[agent]` <title> — skill: `add-new-page` +- [ ] 3 `[human]` Style <component> to mockup — [Figma](<node URL>) diff --git a/.agents/slack-thread.md b/.agents/slack-thread.md new file mode 100644 index 00000000000..464752db88f --- /dev/null +++ b/.agents/slack-thread.md @@ -0,0 +1,31 @@ +# Read a Slack thread + +Parse a Slack thread URL and read the full conversation, including replies. If the tool is missing, unauthenticated, or errors, ask the user to paste the thread. Stop if they decline. + +## Parse the URL + +- `https://<workspace>.slack.com/archives/<channel_id>/p<timestamp_without_dot>` +- `https://app.slack.com/client/<workspace_id>/<channel_id>/thread/<channel_id>-<timestamp_without_dot>` + +- **channel_id** — the segment starting with `C` (e.g. `C04XXXX5DAT`). +- **message_ts** — take the `p`-prefixed number, drop the `p`, insert a dot before the last 6 digits. `p1709834567890123` → `1709834567.890123`. + +If the URL cannot be parsed, ask for `channel_id` and `message_ts`. + +## Read the thread + +``` +Tool: slack_read_thread +Arguments: + channel_id: "<channel_id>" + message_ts: "<message_ts>" + limit: 200 +``` + +If the thread has more than 200 messages, paginate with `cursor` until the full conversation is read. + +Attachments often carry the actual content. The Slack connector reports metadata only — `Files: name.png (ID: F012SSD0KK8, image/png, 393.6 KB)`. + +Follow the `slack-file` skill to download each file. If that skill stops (no token, user declined setup), ask the user to paste or upload each file. Stop if they decline. + +**Done when:** every message and attachment in the thread is in hand. diff --git a/.agents/tasks/3566-main-page-loading-perf/tools/README.md b/.agents/tasks/3566-main-page-loading-perf/tools/README.md index 9b0196913b9..ed8adb34411 100644 --- a/.agents/tasks/3566-main-page-loading-perf/tools/README.md +++ b/.agents/tasks/3566-main-page-loading-perf/tools/README.md @@ -17,13 +17,25 @@ inflate everything 2–3×). Keep the **same preset** for every measurement — metrics M3/M4 depend on the instance's backend latency, so numbers from different presets are not comparable. -2. Open `http://localhost:3000/` in a **clean browser profile** (incognito, no extensions — - React DevTools alone adds ~150 ms of scripting). +2. Record the trace, either by hand or scripted. -3. DevTools → Performance → "Record and reload". Stop a couple of seconds after the - transactions/blocks lists show real data. Export the trace as JSON. + **By hand** — open `http://localhost:3000/` in a **clean browser profile** (incognito, no + extensions — React DevTools alone adds ~150 ms of scripting), then DevTools → Performance → + "Record and reload". Stop a couple of seconds after the transactions/blocks lists show real + data. Export the trace as JSON. -4. Extract the metrics: + **Scripted** — `trace.mjs` drives headless Chromium over CDP and writes the same JSON: + + ```bash + node trace.mjs http://localhost:3000/ /tmp/traces/before 3 # 3 runs -> before-1..3.json + ``` + + It records the same event categories the Performance panel does and uses a fresh browser + context per run (no extensions, cold cache), so it is the scripted equivalent of the clean + profile above. Prefer it whenever you need several runs per variant or a repeatable A/B; a + single exploratory trace is easier by hand, where you can also read the flame chart. + +3. Extract the metrics: ```bash python3 trace-metrics.py baseline.json # one trace @@ -40,3 +52,13 @@ inflate everything 2–3×). - The app under `prod:preset` proxies API calls through `localhost:3000/node-api/proxy` (the fetched config keeps `APP_ENV=development`). Both variants of an A/B pair share this hop, so deltas are valid — but do not compare absolute values against traces of a deployed instance. +- **Headless (`trace.mjs`) and headed absolute values are not comparable either** — same rule, + compare within one capture method. Do not mix them in a single row of the spec's table. +- **M6 is not a sufficient gate on its own.** A bundler change can leave the bytes-before-FCP + almost untouched while doubling FCP and tripling blocking time, because the cost is in executing + the code rather than transferring it. Always read M1 and M5 alongside M6 before concluding a + change is cheap — see `.agents/adr/0001-webpack-for-production-builds.md` for the case that + taught us this. +- `prod:preset` builds with the same bundler as the shipped image (webpack, per that ADR), so its + traces represent what users get. If you ever measure a build made another way, say so next to + the numbers. diff --git a/.agents/tasks/3566-main-page-loading-perf/tools/trace.mjs b/.agents/tasks/3566-main-page-loading-perf/tools/trace.mjs new file mode 100644 index 00000000000..387166e1949 --- /dev/null +++ b/.agents/tasks/3566-main-page-loading-perf/tools/trace.mjs @@ -0,0 +1,80 @@ +// Record page-load performance traces without the DevTools UI. +// +// Produces the same JSON the Performance panel's "Record and reload" export produces, so the output +// feeds straight into trace-metrics.py. Use it when you want several runs per variant (M3/M4 need a +// median) or a repeatable A/B — the manual protocol in README.md is still fine for a one-off. +// +// Usage, against an already-running production server (`pnpm prod:preset <alias>`): +// +// node .agents/tasks/3566-main-page-loading-perf/tools/trace.mjs http://localhost:3000/ ./traces/after 3 +// python3 .agents/tasks/3566-main-page-loading-perf/tools/trace-metrics.py ./traces/before-2.json ./traces/after-2.json +// +// Writes <out-prefix>-<n>.json for n in 1..runs. + +/* eslint-disable no-console -- a CLI tool: stdout is its interface, for the usage hint and for + reporting each trace it wrote. */ + +import { chromium } from '@playwright/test'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; + +// Long enough for the transactions/blocks lists to fill with real data, which M3/M4 measure. +const SETTLE_MS = 10_000; + +// The capture set the Performance panel uses: devtools.timeline for resources/tasks/render commits, +// loading for navigationStart, blink.user_timing for paint marks, __metadata for thread names +// (trace-metrics.py needs those to tell CrRendererMain apart from other threads). +const CATEGORIES = [ + '-*', + 'devtools.timeline', + 'disabled-by-default-devtools.timeline', + 'disabled-by-default-devtools.timeline.frame', + 'blink.user_timing', + 'loading', + 'latencyInfo', + 'v8.execute', + '__metadata', +]; + +const [ url, outPrefix, runsArg ] = process.argv.slice(2); +if (!url || !outPrefix) { + console.error('Usage: node trace.mjs <url> <out-prefix> [runs=1]'); + process.exit(2); +} +const runs = Number(runsArg ?? 1); + +mkdirSync(dirname(outPrefix), { recursive: true }); + +// A fresh context per run is the scripted equivalent of the protocol's "clean browser profile": +// no extensions, no warm HTTP cache, no carried-over service worker. +const browser = await chromium.launch(); + +for (let run = 1; run <= runs; run++) { + const context = await browser.newContext(); + const page = await context.newPage(); + const client = await context.newCDPSession(page); + + const events = []; + client.on('Tracing.dataCollected', ({ value }) => events.push(...value)); + const complete = new Promise((resolve) => client.once('Tracing.tracingComplete', resolve)); + + // Tracing has to start before the navigation — that is what "Record and reload" does, and + // navigationStart is the zero point every metric is relative to. + await client.send('Tracing.start', { + transferMode: 'ReportEvents', + traceConfig: { includedCategories: CATEGORIES, recordMode: 'recordAsMuchAsPossible' }, + }); + + await page.goto(url, { waitUntil: 'load', timeout: 60_000 }); + await page.waitForTimeout(SETTLE_MS); + + await client.send('Tracing.end'); + await complete; + await context.close(); + + const out = `${ outPrefix }-${ run }.json`; + writeFileSync(out, JSON.stringify({ traceEvents: events })); + console.log(`${ out }: ${ events.length } events`); +} + +await browser.close(); diff --git a/.agents/tasks/3583-block-countdown-api-v2/spec.md b/.agents/tasks/3583-block-countdown-api-v2/spec.md new file mode 100644 index 00000000000..4069a4864a9 --- /dev/null +++ b/.agents/tasks/3583-block-countdown-api-v2/spec.md @@ -0,0 +1,201 @@ +# Migrate block countdown from API v1 `getblockcountdown` to API v2 + +| | | +| --- | --- | +| Issue | https://github.com/blockscout/frontend/issues/3583 | +| Status | `done` | +| Size | `medium` | +| Feature branch | `issue-3583` | +| PM | — | +| Designer | — | +| Backend | Nikita P. | +| Minimum backend version | **v11.2.4** — v11.2.3 has the endpoint, but only v11.2.4 has the response contract this task targets | +| Slack channel | — (default routing per `to-spec`) | + +## Context & goal + +The block countdown page is the last consumer of the API v1 RPC endpoint in this app. It calls +`/api?module=block&action=getblockcountdown&blockno=N` through a resource whose `path` is just `/api`, +with the module/action/blockno passed as query params — the shape of a legacy RPC call rather than a REST +resource. An API v2 route has existed since +[blockscout#12704](https://github.com/blockscout/blockscout/pull/12704) (2025-07-03) but returned 422 for +valid input, so the migration was never done. + +[blockscout#14612](https://github.com/blockscout/blockscout/pull/14612) fixed that on 2026-07-23 and shipped +in **backend v11.2.3** (2026-07-24), renaming every response field and setting `additionalProperties: false`. +[blockscout#14646](https://github.com/blockscout/blockscout/pull/14646) then settled the rest of the contract +— real status codes for the non-success cases and string-typed block numbers (the answers to **Q1** and +**Q2**) — and shipped in **v11.2.4** (2026-08-04), which production instances run. So the target shape is +final and verifiable against a live instance. + +Goal: the countdown page reads `/api/v2/blocks/:height/countdown`, the v1 resource is gone, and the payload +is typed from the generated `@blockscout/api-types` package rather than by hand. + +## Functional requirements + +1. Block countdown data comes from `core:block_countdown` → `/api/v2/blocks/:height/countdown`. No API v1 + RPC call remains anywhere in the app. +2. The payload type is the generated one from `@blockscout/api-types`, not a hand-written interface. +3. User-visible behavior is unchanged for the case users actually hit — an already-mined block still lands on + the block page: + + | Case | API response | Page behavior | + | --- | --- | --- | + | Countdown available | 200 with all four fields | Renders the countdown (as today) | + | Block already mined | **404** `{"message":"Block number already mined"}` | Redirects to `/block/[height_or_hash]` (as today) | + | Chain still indexing | **422** `{"message":"Chain is indexing now, try again later"}` | Throws → error page (**changed**: v1 redirected) | + | Non-numeric or negative height | **422** `{"errors":[…]}` | Throws → error page (**changed**: v1 redirected) | + | Average block time disabled | **501** `{"message":…}` | Throws → error page (**changed**: v1 redirected) | + + The rule is: **404 means "the block exists, there is nothing to count down to" → redirect; every other + non-200 throws.** The changed rows are deliberate — under v1 an invalid height bounced the user to a block + page that cannot exist, which is a worse outcome than an error page. +4. The countdown page requires backend **v11.2.4 or newer**. On v11.2.3 the non-success cases answer 200 + instead of 404/422 and the block numbers are JSON integers, so the page would neither render nor redirect + correctly; no compatibility shim reads both contracts (see *Out of scope*). The PR carries the + `breaking changes` label and states the minimum version, per + [docs/CONTRIBUTING.md](../../../docs/CONTRIBUTING.md). + +## Data & API + +**Endpoint** — `GET /api/v2/blocks/:height/countdown`, Core API, production-deployed (not staging-only). +Unpaginated, no filters or sorting. `:height` must be a non-negative integer; a hash returns 422. + +Success body, curl-verified against the `staging` preset (v11.2.4): + +```json +{ + "countdown_block_number": "99999999", + "current_block_number": "11424472", + "estimated_time_in_seconds": "1105865454.6", + "remaining_blocks_count": "88575527" +} +``` + +All four fields are required, string-typed and `additionalProperties: false` +([countdown.ex](https://github.com/blockscout/blockscout/blob/master/apps/block_scout_web/lib/block_scout_web/schemas/api/v2/block/countdown.ex)). +Field mapping from v1 — every value the UI displays is present and every type is unchanged, so there is no +data gap and no conversion to write: + +| v1 (`result.*`, all strings) | v2 (all strings) | +| --- | --- | +| `CountdownBlock` | `countdown_block_number` | +| `CurrentBlock` | `current_block_number` | +| `RemainingBlock` | `remaining_blocks_count` | +| `EstimateTimeInSec` | `estimated_time_in_seconds` | + +**Non-success responses**, every one of them declared in +[`operation :block_countdown`](https://github.com/blockscout/blockscout/blob/master/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/block_controller.ex) +and produced by the +[fallback controller](https://github.com/blockscout/blockscout/blob/master/apps/block_scout_web/lib/block_scout_web/controllers/api/v2/fallback_controller.ex): + +| Case | Status | Body | +| --- | --- | --- | +| Target block already mined | 404 | `{"message":"Block number already mined"}` | +| Chain still indexing | 422 | `{"message":"Chain is indexing now, try again later"}` | +| Non-integer or negative height | 422 | `{"errors":[{"title":"Invalid value",…}]}` | +| Average block time disabled | 501 | `{"message":"Average block time calculation is disabled, so block countdown is not available"}` | + +The 200, 404 and both 422 shapes were sampled live on v11.2.4; the 422-while-indexing and the 501 are +source-verified (neither is reproducible on a healthy instance). Note that 422 covers *both* an invalid height +and an indexing chain, so the status code alone does not separate them — nothing in this task needs to. + +**Numeric precision** is no longer a concern: all four fields are strings, so a 30-digit height round-trips +intact (`"remaining_blocks_count":"123456789012345678901223143418"`). One quirk survives — +`estimated_time_in_seconds` is a stringified Elixir float, so an absurd height yields +`"1.5413580108191357e30"`. The UI only feeds it to `Number()`, which parses that correctly. + +**Types package.** The repo pins `@blockscout/api-types@0.0.1-beta.8e1692a`, published from `dev` after +[#14646](https://github.com/blockscout/blockscout/pull/14646), so all four fields are string-typed in the +generated schema. Subtask 1 published `0.0.1-beta.50eadc8`, but `main` landed `8e1692a` first and it carries +the same countdown contract, so the merge kept `main`'s pin rather than adding an unrelated bump here. `dev` remains the only publishable ref: the +`paths` / `operations` helpers this repo imports in 469 files came from +[blockscout#14515](https://github.com/blockscout/blockscout/pull/14515) and are on neither `master` nor the +`v11.2.4` tag (verified — a build from either exports only `schemas`). + +The payload type is `paths['/api/v2/blocks/{block_number_param}/countdown']['get']`, backed by the +`BlockCountdown` schema — all four fields required and string-typed. + +**No env vars, no feature flags.** The endpoint is unconditional, exactly as the v1 call is today. + +## UI inventory + +No route, navigation, metadata, sitemap or visual change. Routes `/block/countdown`, +`/block/countdown/[height]` and their `/chain/[chain_slug_or_id]/…` multichain twins are untouched. + +- [`src/slices/block/pages/countdown-details/BlockCountdown.tsx`](../../../src/slices/block/pages/countdown-details/BlockCountdown.tsx) + — the only consumer. Reads the four fields, renders `RemainingBlock` / `CurrentBlock` through + `StatsWidget`, and redirects via `window.location.assign` when there is no countdown. +- [`src/slices/block/pages/countdown-details/BlockCountdown.pw.tsx`](../../../src/slices/block/pages/countdown-details/BlockCountdown.pw.tsx) + — two screenshot cases, "short period" and "long period until the block". The long-period case is built on + a 30-digit height; the mocks need the v2 field names but stay strings, so the rendered digits are unchanged + (subtask 3 confirms that). +- Multichain needs no change: `useApiQuery` already resolves the chain from `useMultichainContext` + ([useApiQuery.ts:42](../../../src/api/hooks/useApiQuery.ts)), so the countdown request follows the + cluster's chain automatically. +- [`SearchBarSuggestBlockCountdown.tsx`](../../../src/slices/search/components/search-bar/SearchBarSuggest/SearchBarSuggestBlockCountdown.tsx) + and [`BlockCountdownIndex.tsx`](../../../src/slices/block/pages/countdown-index/BlockCountdownIndex.tsx) + only link to and navigate into the countdown route — neither fetches, so neither changes. + +## Out of scope + +- **A compatibility shim** reading both the pre- and post-v11.2.4 contracts. It would be permanent cruft for + a transitional problem, and it would need a comment explaining a historical rename, which the + [comment rules](../../../.claude/CLAUDE.md) push against. The minimum backend version is declared instead. +- **Backend changes to the endpoint** — **Q1** and **Q2** were `blockscout/blockscout` work, delivered in + [#14646](https://github.com/blockscout/blockscout/pull/14646). +- **Renaming or retiring `src/api/resources/services/core/v1.ts`.** After this task it holds only `graphql` + (whose path `/api/v1/graphql` is unrelated to the RPC API), so the file stays. +- New env vars, custom Mixpanel events (no new interactive element; page views are auto-wired), demo deploy. + +## Task breakdown + +- [x] 1 `[agent]` Unblock `@blockscout/api-types` publishing, publish a beta from `dev`, and pin it → + [`subtasks/01-publish-api-types/`](subtasks/01-publish-api-types/spec.md) +- [x] 2 `[agent]` Migrate the resource and the countdown page to API v2 → + [`subtasks/02-migrate-countdown-resource/`](subtasks/02-migrate-countdown-resource/spec.md) +- [x] 3 `[human]` Confirm the countdown screenshot baselines are unchanged → + [`subtasks/03-countdown-baselines/`](subtasks/03-countdown-baselines/spec.md) + +## Open questions + +### Q1 — Should the non-success countdown responses use real status codes? + +`/api/v2/blocks/:n/countdown` returns **HTTP 200** with `{"message":"Error! Block number already pass"}` when +the block is already mined, and **200** with `{"message":"Chain is indexing now, try again later"}` while the +chain indexes — bodies that violate the endpoint's own schema (`additionalProperties: false`, all four fields +required). Meanwhile the spec declares a **404** the controller never returns, and the **501** for disabled +average block time is not in the spec at all. + +Asking whether already-passed should become a real 404 (and the other two documented), so clients can branch +on the status code instead of sniffing for a missing field. The frontend can ship either way — requirement 3 +above works against today's behavior — but if this changes, subtask 2's narrowing logic changes with it. + +- Owner: Backend (Nikita P.) +- Status: `resolved` +- Slack: https://blockscout.slack.com/archives/D03UYHZTLTB/p1785434045879309 +- Answer: 2026-07-31 — agreed; shipped in **v11.2.4** via + [#14646](https://github.com/blockscout/blockscout/pull/14646) (issue + [#14644](https://github.com/blockscout/blockscout/issues/14644)). Already-mined is now **404**, indexing and + invalid heights **422**, disabled average block time **501** and declared as `not_implemented`. Live-verified + 2026-08-05. Requirement 3's table reflects the delivered behavior. + +### Q2 — Make the countdown's block numbers strings? + +`countdown_block_number`, `current_block_number` and `remaining_blocks_count` are declared `integer` and +unbounded, so JavaScript loses precision above 2^53. Live example — `/api/v2/blocks/123456789012345678901234567890/countdown` +returns `"remaining_blocks_count": 123456789012345678901208920841`, which `JSON.parse` turns into +`1.2345678901234568e+29`, and the page renders that. API v1 returned all of these as strings, and +[#14612](https://github.com/blockscout/blockscout/pull/14612) already made `estimated_time_in_seconds` a +string for the same reason. + +Asking for the three integers to become strings too. This decides subtask 2's payload type and the +mock values in subtask 3's baseline. + +- Owner: Backend (Nikita P.) +- Status: `resolved` +- Slack: https://blockscout.slack.com/archives/D03UYHZTLTB/p1785434045879309 (same thread as Q1) +- Answer: 2026-07-31 — agreed; shipped in **v11.2.4** alongside Q1 + ([#14646](https://github.com/blockscout/blockscout/pull/14646)). All four fields are strings now, so the + precision regression never reaches users and the long-period baseline keeps rendering the full digit string. + Live-verified 2026-08-05. diff --git a/.agents/tasks/3583-block-countdown-api-v2/subtasks/01-publish-api-types/spec.md b/.agents/tasks/3583-block-countdown-api-v2/subtasks/01-publish-api-types/spec.md new file mode 100644 index 00000000000..dbdd1c4ae29 --- /dev/null +++ b/.agents/tasks/3583-block-countdown-api-v2/subtasks/01-publish-api-types/spec.md @@ -0,0 +1,94 @@ +# Unblock `@blockscout/api-types` publishing and pin a beta from `dev` + +| | | +| --- | --- | +| Parent spec | [../../spec.md](../../spec.md) — step 1 of #3583 | +| Status | `done` | +| Sub-branch | `issue-3583-step-1` | +| Backend | Nikita P. | + +## Context & goal + +Subtask 2 needs the generated type for the countdown endpoint, and no published `@blockscout/api-types` +version has it. Getting one out requires a fix in `blockscout/blockscout` first, so this step is a +prerequisite for the rest of the task. + +Three facts pin the approach down: + +- The post-14612 countdown schema is on `master`, `dev` and the `v11.2.3` tag alike. +- The `paths` / `operations` / `schemas` type helpers that this repo imports in 469 files exist **only on + `dev`** — added by [blockscout#14515](https://github.com/blockscout/blockscout/pull/14515) (merged to `dev` + 2026-07-02, not yet on `master`). A package built from `master` or the `v11.2.3` tag has no `paths` export + and is unusable here. +- `dev`'s `types-package/package-lock.json` is internally inconsistent, so `npm ci` refuses to run and the + publish workflow dies before building: + + ``` + Invalid: lock file's js-yaml@4.1.1 does not satisfy js-yaml@4.2.0 + ``` + + `dev` carries `@redocly/openapi-core@1.34.16`, which declares `js-yaml: "4.2.0"`, while the nested + `node_modules/@redocly/openapi-core/node_modules/js-yaml` entry resolves `4.1.1`. `master` is consistent + because it still carries `1.34.15`, which declares `4.1.1`, so merging `master` into `dev` does not fix it + — the inconsistent pair is dev-only. Verified by a real dispatch: + [run 30566146869](https://github.com/blockscout/blockscout/actions/runs/30566146869) and reproduced + locally. + +Stable publishing is switched off separately: the `release: [published]` trigger in +`.github/workflows/publish-api-types-npm.yml` is commented out with +`# todo: re-enable once all fixes to OpenApi schemas will be made` — on **`master`**. +[#14515](https://github.com/blockscout/blockscout/pull/14515) already re-enabled it on `dev`, but GitHub +runs a `release` event against the *default* branch's workflow file, so auto-publishing on release stays off +until `master` gets the same change. That is the backend team's call and does not block this task, whose +publish path is the manual `workflow_dispatch` one. + +## Requirements + +1. `npm ci` succeeds in `types-package/` on `dev`. +2. `publish-api-types-npm-dev.yml` completes from `dev` and publishes a `0.0.1-beta.<sha>` version. +3. This repo pins that **exact** version — never the `beta` dist-tag, which in-progress branches share + (rationale in [src/api/CONTEXT.md](../../../../../src/api/CONTEXT.md)). +4. `pnpm run lint:tsc` passes with the new pin. + +## Steps + +- [x] 1 `[agent]` Open a PR against `blockscout/blockscout` **`dev`** regenerating + `types-package/package-lock.json`. Merging is the backend team's call — Nikita reviews. + → [blockscout#14639](https://github.com/blockscout/blockscout/pull/14639), lock-only (nested `js-yaml` + `4.1.1` → `4.2.0`); the `release` trigger needed no change on `dev` (see *Context & goal*). +- [x] 2 `[agent]` Once merged, publish — skill: `publish-beta-types` + → `@blockscout/api-types@0.0.1-beta.bb45bf1` from + [run 30609760755](https://github.com/blockscout/blockscout/actions/runs/30609760755). + - inputs: + - API service: `core` → package `@blockscout/api-types` + - Source repo: `blockscout/blockscout` + - Workflow: `.github/workflows/publish-api-types-npm-dev.yml` (no dispatch inputs; derives + `v0.0.1-beta.${GITHUB_SHA::7}` itself) + - Branch to publish from: **`dev`** + - Note: the skill says never to publish from the default branch. `dev` is not the default branch + (`master` is), and it is where the previous pinned beta came from, so this is the normal path here. +- [x] 3 `[agent]` Pin the exact published version in `package.json`, run `pnpm install`, then + `pnpm run lint:tsc`. → `package.json` + `pnpm-lock.yaml`. +- [x] 4 `[agent]` If the typecheck surfaces breakage unrelated to the countdown endpoint, stop and report it + rather than fixing it inside this task. `dev` carries roughly four weeks of schema changes beyond the + previously pinned 2026-07-02 beta, so unrelated churn was plausible and would have needed its own scope. + → none surfaced; `lint:tsc`, `lint:eslint` and `lint:cspell` are all clean on the new pin. +- [x] 5 `[agent]` Re-publish and re-pin once — the published `0.0.1-beta.50eadc8` was later dropped in favour + of `main`'s `0.0.1-beta.8e1692a`, which carries the same countdown contract (see the parent spec's + *Types package*). + [blockscout#14646](https://github.com/blockscout/blockscout/pull/14646) is on `dev`. The + `0.0.1-beta.bb45bf1` pin predates it and still types the three block numbers as `number`, so subtask 2 + cannot be written against it. Repeat steps 2–4; `dev` is still the only ref exporting `paths`, so the + `v11.2.4` tag is not an option. Expect more unrelated churn than last time — `dev` now carries v12.0.0-era + changes that no release includes, and step 4's stop-and-report rule applies to them. + → `0.0.1-beta.50eadc8` from + [run 31010579751](https://github.com/blockscout/blockscout/actions/runs/31010579751); all four countdown + fields string-typed, and the v12-era churn broke nothing (`lint:tsc`, `lint:eslint`, `lint:cspell` clean). + +## Out of scope + +- Publishing a **stable** version (`11.2.3` on the `latest` dist-tag). It was attempted and cancelled during + the grilling session: built from the `v11.2.3` tag it has no `paths` export, so it would be unusable here + while permanently occupying `latest`. Whether to cut a stable release is the backend team's call once the + `release` trigger is live again. +- Anything in the countdown migration itself — that is subtask 2. diff --git a/.agents/tasks/3583-block-countdown-api-v2/subtasks/02-migrate-countdown-resource/spec.md b/.agents/tasks/3583-block-countdown-api-v2/subtasks/02-migrate-countdown-resource/spec.md new file mode 100644 index 00000000000..483218c742e --- /dev/null +++ b/.agents/tasks/3583-block-countdown-api-v2/subtasks/02-migrate-countdown-resource/spec.md @@ -0,0 +1,120 @@ +# Migrate the countdown resource and page to API v2 + +| | | +| --- | --- | +| Parent spec | [../../spec.md](../../spec.md) — step 2 of #3583 | +| Status | `done` | +| Sub-branch | — (single commit on the feature branch) | +| Backend | Nikita P. | + +## Context & goal + +The actual migration: move `core:block_countdown` from the API v1 registry to the block registry, retype it +from the generated package, and rework the single consumer. + +Nothing is blocked. Both open questions were delivered by +[blockscout#14646](https://github.com/blockscout/blockscout/pull/14646) in backend **v11.2.4**, so the +contract is final: real status codes for every non-success case, and all four response fields string-typed. +The one prerequisite is subtask 1's re-pin — the currently pinned beta predates #14646 and still types the +block numbers as `number`. + +## Requirements + +Parent spec requirements 1–4. In particular the branching rule: **404** means the block exists and there is +nothing to count down to, so the page redirects to `/block/[height_or_hash]`; every other non-200 throws +through `throwOnResourceLoadError`. No retry tuning is needed — `useQueryClientConfig`'s `retry` already +fails 4xx immediately ([useQueryClientConfig.ts:14](../../../../../src/api/hooks/useQueryClientConfig.ts)). + +## Data & API + +Resource — added to `CORE_API_BLOCK_RESOURCES` in +[src/api/resources/services/core/block.ts](../../../../../src/api/resources/services/core/block.ts): + +```ts +block_countdown: { + path: '/api/v2/blocks/:height/countdown', + pathParams: [ 'height' as const ], +}, +``` + +`:height` rather than the siblings' `:height_or_hash` — this endpoint takes only a non-negative integer (a +hash returns 422), and `height` matches the existing route param in `/block/countdown/[height]`, so the call +site reads `pathParams: { height }`. + +Payload type — the generated 200 body, referenced inline in the payload branch like every other generated +sibling in that file. `paths[…][method]` already resolves to the 200 `application/json` body, so there is no +manual response indexing and no local consolidation: with #14646 a 200 always carries all four fields, and +the message bodies live under their own status codes. + +```ts +R extends 'core:block_countdown' ? paths['/api/v2/blocks/{block_number_param}/countdown']['get'] : +``` + +`BlockCountdownResponse` in `src/slices/block/types/api.ts` is deleted rather than re-aliased to the +generated type: local types exist for payloads a schema cannot express (see *Where a resource's response +types come from* in [src/api/CONTEXT.md](../../../../../src/api/CONTEXT.md)), and nothing outside the registry +ever imported it. + +## Steps + +Steps 1–4 land together: with the key present in both registries the later spread in +[core/index.ts](../../../../../src/api/resources/services/core/index.ts) would win and keep routing to `/api`, +and the consumer plus its mocks stop typechecking the moment the payload type changes. One commit. + +- [x] 1 `[agent]` Declare the resource — skill: `add-api-resource` + - inputs: + - Service + endpoint path: `core`, `/api/v2/blocks/:height/countdown`; key `core:block_countdown` + (the key already exists — it moves from `v1.ts` into `block.ts`) + - Live instance with the endpoint deployed: the `staging` preset (backend v11.2.4). Samples for every + status are in the parent spec's *Data & API*; the endpoint is production-deployed, not staging-only, so + the `eth` preset works too. + - Types-package state: available after subtask 1's re-pin. Type name + `paths['/api/v2/blocks/{block_number_param}/countdown']['get']`. + **No temporary local type** — this step waits for the pin rather than hand-typing a stopgap. + - Filters / sorting: none. Unpaginated — the sample body has no `next_page_params`. +- [x] 2 `[agent]` Remove the v1 resource: drop the `block_countdown` entry and its + `CoreApiV1ResourcePayload` branch from + [src/api/resources/services/core/v1.ts](../../../../../src/api/resources/services/core/v1.ts), leaving only + `graphql`. Also delete the pre-existing dead `core:block_countdown` branch in + [block.ts](../../../../../src/api/resources/services/core/block.ts) if it is still the v1 shape — it has no + matching entry in `CORE_API_BLOCK_RESOURCES` today, so it resolves to nothing and must not be left + duplicated once the real entry lands. + → `CoreApiV1ResourcePayload` went with it: `graphql` never had a payload branch (it is only used through + `buildUrl`), so the type was left vacuous. Its branch in `core/index.ts` is gone too. +- [x] 3 `[agent]` Rework + [BlockCountdown.tsx](../../../../../src/slices/block/pages/countdown-details/BlockCountdown.tsx): + - call the resource with `pathParams: { height }` instead of the `module`/`action`/`blockno` query params; + - read the four renamed fields — all strings, so `Number(estimated_time_in_seconds)` and the + `StatsWidget` values carry over unchanged; + - replace the `!data.result` redirect effect with one keyed off `error?.status === 404`, reusing the + existing `handleTimerFinish` redirect; + - keep `throwOnResourceLoadError` for every other error — it must not fire on the 404. + → the guard reads `isError && error.status === 404`, a bare literal like the repo's six other 404 checks + (e.g. [Block.tsx:170](../../../../../src/slices/block/pages/details/Block.tsx)). The + `estimated_time_in_seconds &&` guard around the timer is gone — the schema makes the field required. +- [x] 4 `[agent]` Update the mocks in + [BlockCountdown.pw.tsx](../../../../../src/slices/block/pages/countdown-details/BlockCountdown.pw.tsx) to + the v2 field names and drop the `queryParams` matcher in favour of `pathParams`. Values stay strings, so + keep them identical to today's — both cases ("short period", "long period until the block") should render + the same text they do now. **Do not** regenerate baselines — that is subtask 3. +- [x] 5 `[agent]` Unit-test the 404-means-redirect branch if step 3 extracts it into a helper — deciding + "countdown available vs. redirect" is the one piece of real logic this migration introduces. Skip if it + stays an inline `error?.status === 404` guard in the component; per + [.agents/rules/tests-unit.md](../../../../../.agents/rules/tests-unit.md) a test that only re-asserts an + inline conditional is noise. + → skipped; no helper was extracted, so the branch is covered by the dev verification in step 6. +- [x] 6 `[agent]` Verify: `pnpm run lint:tsc`, `pnpm run lint:eslint`, and a manual check on the `staging` + preset — a future block renders a countdown, an already-mined block redirects to the block page, a + non-numeric height shows the error page. + → all three confirmed on the `staging` preset (backend v11.2.4); `lint:tsc`, `lint:eslint`, `lint:cspell` + and 430 vitest tests pass. The payload type was probed against a temporary + `ResourcePayload<'core:block_countdown'>` assertion (with a negative control) to rule out a silent `never`. +- [x] 7 `[agent]` PR paperwork: `breaking changes` label, and a description plus release-note line stating + the countdown page now requires backend **v11.2.4+**. Runs with the `create-pr` finalize-draft pass on + [#3605](https://github.com/blockscout/frontend/pull/3605) once subtask 3 is checked, not as its own commit. + +## Out of scope + +- Regenerating screenshot baselines (subtask 3). +- Any change to `/block/countdown` (the index page) or the search suggestion that links into the countdown + route — neither fetches this resource. diff --git a/.agents/tasks/3583-block-countdown-api-v2/subtasks/03-countdown-baselines/spec.md b/.agents/tasks/3583-block-countdown-api-v2/subtasks/03-countdown-baselines/spec.md new file mode 100644 index 00000000000..b1d4105aa81 --- /dev/null +++ b/.agents/tasks/3583-block-countdown-api-v2/subtasks/03-countdown-baselines/spec.md @@ -0,0 +1,36 @@ +# Confirm the block countdown screenshot baselines + +| | | +| --- | --- | +| Parent spec | [../../spec.md](../../spec.md) — step 3 of #3583 | +| Status | `done` | +| Sub-branch | — (commit on the feature branch) | +| Designer | — | + +## Context & goal + +Nothing about the countdown page changes visually by intent, and with +[blockscout#14646](https://github.com/blockscout/blockscout/pull/14646) the v2 fields are strings just as the +v1 ones were — so both cases should render exactly the text they render today, including the 30-digit height +in "long period until the block". Only the mock field names change. + +That makes this subtask a **confirmation** step rather than a re-take: if either baseline moves, something in +subtask 2 changed rendering unintentionally. Per +[.agents/delegation.md](../../../../../.agents/delegation.md), looking at a screenshot diff and deciding it is +acceptable is a human step. + +## Steps + +- [x] 1 `[human]` Run the countdown component tests and inspect the diff for + [BlockCountdown.pw.tsx](../../../../../src/slices/block/pages/countdown-details/BlockCountdown.pw.tsx). + Both cases are expected to pass untouched. +- [x] 2 `[human]` If either case did move, decide whether the new rendering is acceptable before accepting it. + Scientific notation in the long-period case means the pinned package predates + [#14646](https://github.com/blockscout/blockscout/pull/14646) and the block numbers are still `number` — + that is a subtask 1 problem, not a baseline to accept. +- [x] 3 `[human]` Regenerate and commit baselines only if step 2 accepted a change. + +## Out of scope + +- Any styling or layout change. If the long-period case looks wrong, that is a finding about the pinned types + package or the migration, not something to paper over with CSS. 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 00000000000..24e53d0446f --- /dev/null +++ b/.agents/tasks/3593-tx-og-title-description/spec.md @@ -0,0 +1,266 @@ +# 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 | `done` | +| 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 `<title>` 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:** a missed request yields no body at all — `fetchApi` logs the non-200 and returns +`undefined` (it stopped parsing error bodies in #3623, which reached this branch through a `main` merge +mid-task). The status mapping must therefore keep `undefined` distinct from `null`, or every 404, failure, +and timeout 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`. + +## Follow-ups from the manual verification + +Two changes the PM asked for once real cards were in front of her. Both are outside the original ACs and +both apply site-wide rather than to `/tx/[hash]`, so they are recorded here rather than as subtasks. + +- **The crawler set grew** to WhatsApp, Discord, and LinkedIn (`detectBotRequest`). The ACs named Telegram + and X because those were what the demo was checked in; WhatsApp then showed no preview at all, which is + the bug that prompted this. Discord and LinkedIn match on the `…bot` suffix rather than the bare product + name, because both also ship an in-app browser whose user agent carries that name — those are real + visitors. Every OG-enhanced route now fetches for these three as well. +- **X gets a card image** even where no `og:image` exists — see the `og:image` note below. + +## Out of scope + +- **`og:image`** — text only on every platform but X. Its 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. + + X is the exception because it does not honour the omission: it reserves the image slot regardless and + fills it with a grey placeholder. So on routes with no image the card drops to `summary` — the small + square variant — and `twitter:image` points at the instance's generated icon. `og:image` stays unset, so + Telegram and WhatsApp keep the clean text-only card. +- **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". +- [x] 6 `[agent]` Fetch the preview data from the endpoint built for it → `subtasks/06-preview-endpoint/` + — the description's mandatory fields now come from `core:tx_preview`, which resolves inside the timeout on + the instance where `core:tx` never did. +- [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 00000000000..d45fb1228ac --- /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 00000000000..0021dfddaf5 --- /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 00000000000..a8a7ae36c1d --- /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 00000000000..c9639b4c614 --- /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 00000000000..223911b7d22 --- /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 00000000000..666791d1974 --- /dev/null +++ b/.agents/tasks/3593-tx-og-title-description/subtasks/06-preview-endpoint/brief.md @@ -0,0 +1,111 @@ +# 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. + +## What the endpoint actually returns (sampled 2026-08-14) + +`GET /api/v2/transactions/:hash/preview`, 404 + `{"message":"Not found"}` for an unknown hash — so the +`fetchApi` trap the parent spec describes still applies. Payload: + +```json +{ "status": "ok", "timestamp": "2026-08-14T08:46:24.000000Z", "method": "exactInputSingle", + "from": { "hash": "0x…", "name": null, "ens_domain_name": null }, + "to": { "hash": "0x…", "name": "SwapRouter02", "ens_domain_name": null } } +``` + +Three boolean query parameters, all defaulting to **false**: `preload_ens`, `preload_metadata`, +`decode_input`. The endpoint rejects an unknown field with a 400 (`Unexpected field: <name>`), so the accepted +set is exactly those three. + +- `decode_input=true` is what turns `method` from the raw selector into the name — `0x04e45aaf` becomes + `exactInputSingle`, matching `/transactions/:hash`. **Without it the fallback action line would show the + selector**, so this task must send it. +- `preload_ens=true` works: an ENS name on `from` appears only with the parameter. +- `preload_metadata=true` is accepted but **serializes nothing** — no `metadata` key reaches the address + objects with or without it, so where `/transactions/:hash` returns a curated name tag the preview response + carries only the plain contract name (Q3's `OKX Labs: DexRouter` → `DexRouter` case). Reported and + **confirmed as a bug by the backend on 2026-08-14, fix coming**, so the spec should assume the tags arrive + and Q3's dial stays where the PM left it. + +### Latency + +Paced sampling on the loaded instance the parent spec's Q2 is about — the hard case — with disjoint +transaction sets per variant so nothing is warmed by a sibling call, and `/stats` as the network control at +0.37 s: + +| request | p50 | p90 | max | over 2 s | +| --- | --- | --- | --- | --- | +| `/preview` bare | 0.76 s | 1.11 s | 1.31 s | 0 / 8 | +| `/preview?decode_input=true` | 0.91 s | 1.17 s | 1.96 s | 0 / 8 | +| `/preview` + all three parameters | 0.66 s | 0.92 s | 1.66 s | 0 / 8 | +| `/transactions/:hash` | 1.90 s | 2.64 s | 3.34 s | 4 / 8 | +| `/transactions/:hash/summary` | 4.31 s | 5.56 s | 11.16 s | 7 / 8 | + +The preloads cost nothing measurable — the spread between the three preview rows is smaller than the run-to-run +noise — so **request all of them**. The endpoint solves the mandatory half of the description outright: status +and timestamp now arrive inside the budget on the instance where they never did. + +The summary does not, and that is the shape of the change: the two requests stay **separate**, each with its +own timeout, so a `/summary` that misses the budget degrades to the preview's own fields (status, timestamp, +and the `called … on …` line) instead of taking the whole description down with it. Folding the summary into +the preview response would have coupled them, which is why it was rejected. + +## 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 + +**Update (2026-08-14):** deployed and measured (tables above). The endpoint delivers what it was asked for — +status and timestamp inside the budget on the loaded instance, with the preloads on. Ready to scope; the one +loose thread is `preload_metadata` serializing no tags, which changes the action text but not the shape of +the change. diff --git a/.agents/tasks/3593-tx-og-title-description/subtasks/06-preview-endpoint/spec.md b/.agents/tasks/3593-tx-og-title-description/subtasks/06-preview-endpoint/spec.md new file mode 100644 index 00000000000..f1af03213e6 --- /dev/null +++ b/.agents/tasks/3593-tx-og-title-description/subtasks/06-preview-endpoint/spec.md @@ -0,0 +1,188 @@ +# Fetch the preview data from the endpoint built for it + +| | | +| --- | --- | +| Parent spec | [../../spec.md](../../spec.md) — step 6 of #3593 | +| Status | `done` | +| Size | `small` | +| Sub-branch | — (commits land directly on `issue-3593`, as in subtasks 1–5) | +| PM | Ulyana (task author) | +| Designer | — | +| Backend | Nikita P. (built the endpoint) | +| Depends on | subtask 4 | + +## Context & goal + +Subtask 4 built the description from `core:tx` + `core:tx_interpretation`. That works where the API is fast +and fails where it isn't: `core:tx` supplies the two mandatory fields, so when it aborts at the 2 s timeout +there is no description at all. On the loaded instance the parent spec's Q2 is about, it aborted on 6 of 6 +crawler requests. Raising the timeout was ruled out — crawlers wait single-digit seconds, and a card that +fails to render is worse than a plain one. + +The backend built `/api/v2/transactions/:hash/preview` for this feature: the same fields, none of the rest. +Measured on that same instance it answers in 0.51 s at p50 and never crossed 1 s (table below), so the +mandatory half of the description now arrives inside the budget where it never did. This subtask switches the +page to it. + +The summary stays a **separate** request with its own timeout. Folding it into the preview response was +offered and declined: `/summary` is by far the slowest of the three calls, and coupling them would turn every +slow summary into a lost description instead of a degraded one. + +## Functional requirements + +- `/tx/[hash]`'s `getServerSideProps` requests **`core:tx_preview`** in place of `core:tx`, with + `preload_ens`, `preload_metadata` and `decode_input` all `true`. They measure as free, so all three are + always sent; none is made conditional. +- `core:tx_interpretation` keeps its own parallel request and its own **2 s** timeout, unchanged. When it + misses the budget the description still resolves from the preview payload alone, via the existing + `called … on …` branch. +- Both timeouts stay at **2 s**. The preview has headroom to spare, but the summary beside it does not, and + a shared shorter budget would only cut off successes the crawler was willing to wait for. +- **No fallback to `core:tx`.** Where the endpoint is absent — an instance whose backend predates it — the + request fails and the card keeps the generic description, exactly as it does today when a request times + out. Chaining a second attempt would spend a crawler's patience precisely on the instances that are already + slow. The frontend release notes name the backend version that ships the endpoint. +- The address labelling chain is unchanged — name tag → `ens_domain_name` → `name` → shortened hash — and + `getAddressName` already accepts a partial address, so the narrower payload needs no change to it. +- The action text is unchanged in every branch: summary when there is one, otherwise + `<from> called|failed to call <method> on <to>` with the **decoded** method name, which is what + `decode_input=true` buys. +- No behavior change where the interpretation provider is not `blockscout` — subtask 7's gate sits in front + of this code and still decides whether any request is made at all. + +### Verification + +`curl -A Twitterbot` against the demo on the loaded instance shows the enhanced description on a first +request for a transaction, and the server log shows one preview request inside the timeout. A real card in +Telegram confirms it end to end. + +## Data & API + +`GET /api/v2/transactions/:hash/preview` — merged in +[blockscout#14638](https://github.com/blockscout/blockscout/pull/14638) (2026-08-11), deployed to the loaded +instance and to staging. Sampled response: + +```json +{ "status": "ok", "timestamp": "2026-08-14T08:46:24.000000Z", "method": "exactInputSingle", + "from": { "hash": "0x…", "name": null, "ens_domain_name": null }, + "to": { "hash": "0x…", "name": "SwapRouter02", "ens_domain_name": null } } +``` + +Three boolean query parameters, all defaulting to `false`; an unknown field is rejected with a 400 +(`Unexpected field: <name>`), so this is the whole set: + +| parameter | effect | +| --- | --- | +| `decode_input` | `method` becomes the decoded name (`0x04e45aaf` → `exactInputSingle`) instead of the selector | +| `preload_ens` | fills `ens_domain_name` | +| `preload_metadata` | fills the address `metadata` tags (fixed and deployed 2026-08-14 — see Q1) | + +Latency on the loaded instance, paced sampling, disjoint transaction sets per variant, measured after the +metadata fix went live (`/stats` as the network control at 0.17 s): + +| request | p50 | p90 | max | over 2 s | +| --- | --- | --- | --- | --- | +| `/preview` + all three parameters | 0.51 s | 0.59 s | 0.95 s | 0 / 10 | +| `/preview` without `preload_metadata` | 0.49 s | 0.97 s | 1.04 s | 0 / 10 | +| `/preview` bare | 0.21 s | 0.46 s | 0.70 s | 0 / 10 | +| `/transactions/:hash` | 1.90 s | 2.64 s | 3.34 s | 4 / 8 | +| `/transactions/:hash/summary` | 4.31 s | 5.56 s | 11.16 s | 7 / 8 | + +The preloads are worth their cost: turning all three on is indistinguishable from turning only `decode_input` +on, and the whole request still fits inside a third of the budget. The two bottom rows were measured earlier +in the day, when the control read 0.37 s — the instance's own load moves these numbers more than any +parameter does, which is the point of keeping a control in every run. + +**The budget is per instance, not per endpoint.** A `k8s-dev` instance serves the same request in ~1.7 s as +measured from inside the cluster, which loses to the 2 s timeout often enough that the description never +resolves there — warming the response first makes no difference, so it is the instance's floor rather than a +cold-start cost. Nothing to fix in the page: the same build enhances every request against a production +instance. It does mean a demo pointed at a dev instance is not evidence about this feature either way. + +- **Resource:** `core:tx_preview` — new, added to `src/api/resources/services/core/tx.ts`. Not paginated, no + filters. The three parameters are passed per-call through `fetchApi`'s `queryParams`, which already + supports them, so nothing in the registry needs to carry them. +- **Response type:** no published `@blockscout/api-types` version has this path (checked `0.1.0` and every + beta), so a beta must be published first — see the breakdown and Q1. +- **Error shape:** an unknown hash is a 404 with `{"message":"Not found"}`. `fetchApi` returns non-200 bodies + as data, so the parent spec's trap still applies and `getOgDescriptionParams` still guards it by requiring + a timestamp and by distinguishing `undefined` from `null` status. + +## UI inventory + +No visual output — `<meta>` tags only. + +- `src/pages/tx/[hash].tsx` — the gSSP fetch. +- `src/slices/tx/utils/get-og-description-params.ts` + its spec — adapted to the narrower payload; its logic + (status map, UTC timestamp, action chain) is unchanged. +- `src/api/resources/services/core/tx.ts` — the new resource and its type branch. + +## Out of scope + +- Using the endpoint anywhere but the social-preview path — the transaction page keeps `core:tx`. +- Multichain `/chain/[chain_slug_or_id]/tx/[hash]`, and the other OG-enhanced routes (address, token, NFT, + stats), which have their own resources. +- Folding the summary into the preview response — declined above. +- Any change to the timeouts, the bot gate, or the Noves gate. + +## Task breakdown + +- [x] 1 `[agent]` Publish the beta types and pin the exact version — skill: `publish-beta-types` + — `0.0.1-beta.e709d22`, published from `dev` ([run](https://github.com/blockscout/blockscout/actions/runs/32009994992)) + and pinned in `package.json`; the schema types the three query parameters and a `PreviewAddress` with + `metadata`. + - inputs: + - Service `core` → package `@blockscout/api-types`, repo `blockscout/blockscout`, workflow + `publish-api-types-npm-dev.yml` (no dispatch inputs). + - Branch: **`dev`** — it carries both the endpoint and the metadata fix (Q1). `master` has only partial OpenAPI + schema support, so a package built from it would not be correct. + - Pin the exact published version in `package.json` — never the `@beta` tag. +- [x] 2 `[agent]` Declare the `core:tx_preview` resource — skill: `add-api-resource` + — entry and payload branch in `src/api/resources/services/core/tx.ts`, typed from the package's + `paths[…/preview]['get']`; verified with a throwaway `ResourcePayload` probe (positive and negative). + - inputs: + - Service `core`, key `core:tx_preview`, path `/api/v2/transactions/:hash/preview`, path param `hash`. + - Live instance for the sample response: the loaded instance from the parent spec's Q2 (the developer + names the registry alias at run time); the sample above was taken from it. + - Types-package state: published by step 1; type comes from + `paths['/api/v2/transactions/{transaction_hash_param}/preview']['get']`. + - No filters, no sorting, not paginated. +- [x] 3 `[agent]` Switch the page to the preview resource + — `[hash].tsx` calls `core:tx_preview` with the three parameters; `getOgDescriptionParams` and the mocks + take `schemas['Preview']`, and `addressToPlainText` now takes the same name-source type `getAddressName` + defines, since the preview's address carries only those fields. + - inputs: + - In `src/pages/tx/[hash].tsx`, replace the `core:tx` call with `core:tx_preview` plus + `queryParams: { preload_ens: true, preload_metadata: true, decode_input: true }`; leave the + `core:tx_interpretation` call, both timeouts, and the surrounding gates alone. + - Adapt `getOgDescriptionParams`'s first parameter to the preview payload type. Its branches are + unchanged, and its spec file covers them — update the fixtures, not the assertions. +- [x] 4 `[agent]` Redeploy the demo and check a real card — skill: `deploy-demo` + — both variants on this branch's image: the loaded instance enhances **6 of 6** crawler requests where the + old path managed 0 of 6, and a `k8s-dev` instance enhances none of 6 for the reason recorded above. + - inputs: + - Variant `review-2`, branch `issue-3593`, no image rebuild unless the branch moved. + - Preset: the loaded instance from the parent spec's Q2 — the developer names the alias at deploy time. + - Then `curl -A Twitterbot` for the tags, and post the link for the PM to confirm the card. + +## Open questions + +### Q1 — Can the metadata fix reach `dev`, so the types can be published? + +The types package can only be built from `dev`; `master` has partial OpenAPI schema support. But `dev` +currently has neither the endpoint ([#14638](https://github.com/blockscout/blockscout/pull/14638), merged to +`master`) nor the metadata fix ([#14703](https://github.com/blockscout/blockscout/pull/14703), open against +`master`). So the ask is: merge the fix, then merge `master` into `dev`. + +The fix matters beyond the types. `preload_metadata=true` was accepted but serialized nothing — where +`/transactions/:hash` returned a curated name tag, the preview response carried only the plain contract name +(Q3's `OKX Labs: DexRouter` → `DexRouter` case). Without it the parent spec's Q3 would have landed on "ENS +kept, name tags lost" by omission rather than by the PM's choice. + +- Owner: Backend (Nikita P.) +- Status: `resolved` +- Slack: https://blockscout.slack.com/archives/C03MMUTQDNU/p1786701659121719 (sent 2026-08-14) +- Answer (2026-08-14): both merges done — the fix landed in `master` at 10:09 and `master` was merged into + `dev` at 11:24, so `dev` carries the endpoint and the fix and is 0 behind. Verified on the deployed + instance: the address objects now include `metadata`, and a tagged address returns its curated tag, so the + preview text matches the page. Q3's dial stays where the PM left it — nothing is lost. 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 00000000000..e09db89e3fe --- /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/.agents/tasks/3607-tx-details-fee-payer-calls/spec.md b/.agents/tasks/3607-tx-details-fee-payer-calls/spec.md new file mode 100644 index 00000000000..3c3cbd72564 --- /dev/null +++ b/.agents/tasks/3607-tx-details-fee-payer-calls/spec.md @@ -0,0 +1,275 @@ +# Display fee payer and calls on the transaction details page + +| | | +| --- | --- | +| Issue | https://github.com/blockscout/frontend/issues/3607 | +| Status | `done` | +| Size | `small` | +| Feature branch | `issue-3607` | +| PM | Ulyana | +| Designer | — | +| Backend | Victor (issue author); v11.2.4+ | +| Slack channel | — (default routing per `to-spec`) | + +## Context & goal + +Eden is an `ev-reth` / evstack rollup that adds a custom EIP-2718 transaction type `0x76` (decimal `118`): +a **sponsored batch transaction**. An *executor* submits an ordered batch of calls, and a separate *sponsor* +signs for and pays the fee. The backend now indexes those transactions and exposes two new optional fields +on the transaction model ([blockscout#14590](https://github.com/blockscout/blockscout/issues/14590), +implemented in [blockscout#14643](https://github.com/blockscout/blockscout/pull/14643)): `fee_payer` and +`calls`. + +Neither field is rendered today, so an Eden sponsored transaction page silently omits the two things that +distinguish it — who actually paid, and what the batch executed. The goal is to display both on `/tx/:hash`, +and to omit them cleanly on every other chain (where they are absent from the response entirely). + +## Functional requirements + +1. When `fee_payer` is present, the transaction details page shows a **Fee payer** row with the address. + Hint copy: `Address that paid the transaction fee on behalf of the sender`. +2. When `calls` is present and non-empty, the page shows a **Calls** row with a table of the batched calls + in API order. Hint copy: `Ordered list of calls batched into this sponsored transaction`. +3. The Calls table has three columns — `To`, `Value`, `Input`: + - `To` — `AddressEntity` with `truncation="dynamic"`. When `to` is `null` the cell reads + `[ Contract creation ]` (the same string [`TxDetails.tsx:395`](../../../src/slices/tx/pages/details/info/TxDetails.tsx) + already uses). `to` being null is a real case, confirmed by the backend owner (**Q3**). + - `Value` — `NativeCoinValue` with symbol, no exchange-rate toggle. + - `Input` — `TruncatedText` + `CopyToClipboard`, matching the `Data` cell of + [`LogDecodedInputDataTable`](../../../src/slices/log/components/LogDecodedInputDataTable.tsx). +4. Both rows render inside the collapsible *View details* section, immediately after **Other** and before + **Raw input** — so the batched calls sit next to the raw/decoded input data that shares their visual + language. No `DetailedInfo.ItemDivider` around the block (`Other`, `Raw input` and `Decoded input data` + have none between them either). +5. Neither field is gated by an env var or a feature config. They are rendered on **field presence**, + the established pattern for chain-variant transaction fields: `execution_node` / `allowed_peekers` at + [`TxDetails.tsx:308`](../../../src/slices/tx/pages/details/info/TxDetails.tsx) do the same, even though + SUAVE has an `NEXT_PUBLIC_IS_SUAVE_CHAIN` flag for its nav and pages. `do_with_chain_type_fields` only + extends the response for `:eden`, so presence is a sufficient and self-maintaining gate. +6. A **Sponsored** tag appears in the transaction details page header when `transaction_types` includes + `sponsored_transaction`, alongside the other header tags. Transaction **lists** get no badge for it + (**Q1**) — there is no room. `sponsored_transaction` is still added to `TYPES_ORDER`, last and with no + label of its own: absent from that list, `indexOf` returns `-1` and sorts it ahead of every real type, + making a sponsored contract call read as the generic "Transaction" instead of "Contract call". + +## Data & API + +**Endpoint** — `GET /api/v2/transactions/{hash}` (existing `core:tx` resource; no new API resource needed). + +**Readiness** — merged to backend `master` on 2026-07-31 and already deployed on +`eden-testnet.blockscout.com` (`backend_version: v11.2.4.+commit.ac947295`). Ships in backend tag **11.2.4** +(Q3) — worth naming in the frontend release notes. + +**Field shapes** — read from +[`schemas/api/v2/transaction.ex`](https://github.com/blockscout/blockscout/pull/14643/files) and verified +against a live response: + +- `fee_payer` — a full `Address` object, `nullable: true`. +- `calls` — `Array<{ to: AddressHashNullable; value: IntegerString; input: HexString }>`, `nullable: true`. +- `required: [:fee_payer, :calls]` means the keys are always present on an Eden response, not that the + values are non-null. + +Sample (`0x35310fd76c45f1441226c102f4dc1070b41ac66cb1e6ed3354da78aa69824a67` on `eden-testnet`): + +```json +{ + "type": 118, + "transaction_types": [ "sponsored_transaction" ], + "fee_payer": { "hash": "0x32648e6529BfCacE20422a7AA1E7fB7Bd8F408d7", "is_contract": false, "…": "…" }, + "calls": [ { "to": "0xf97cDCF1e5C0955Ed5c2EA0afb2c4Bb4eD506505", "value": "0", "input": "0x" } ] +} +``` + +The call's `to` is `null` on a contract creation — confirmed by the backend owner (**Q3**), not defensive +typing. + +**Scope of each field across endpoints** — `calls` is rendered for single-transaction responses only +(`prepare_calls` returns `nil` otherwise, the same policy the backend applies to token transfers). +`fee_payer` *is* returned on list endpoints too, but showing it there is out of scope. + +**Types package** — pinned at `@blockscout/api-types@0.0.1-beta.8e1692a`, published from backend `dev` once +`master` had been merged into it (**Q4**). It carries `eden.schema`, both fields on the transaction, and the +`operations` / `paths` shorthands the app depends on. + +Because `merged.schema` marks chain-specific properties **optional**, the fields type as +`Address | null | undefined` and `Array<Call> | null | undefined`. Guards must handle `undefined` as well as +`null`. + +**Env vars / feature flags** — none added. + +## UI inventory + +- **Single surface**: `/tx/:hash` details tab — + [`src/slices/tx/pages/details/info/TxDetails.tsx`](../../../src/slices/tx/pages/details/info/TxDetails.tsx), + inside the `CollapsibleDetails` block, between `<TxDetailsOther/>` and the `Raw input` label. +- **New component**: `src/features/chain-variants/eden/pages/tx/TxDetailsEden.tsx` — renders both label/value + pairs and returns `null` when both fields are absent, so `TxDetails.tsx` composes it unconditionally + (matching `TxDetailsSetMaxGasLimit` and `TxDetailsWithdrawalStatusArbitrum`, already there doing the same). + Eden-specific UI is a **feature**, not a slice — it cannot exist on a vanilla EVM chain — and + [`TxDetails.tsx:83`](../../../src/slices/tx/pages/details/info/TxDetails.tsx) carries a standing + `// REFACTOR: Put feature related parts under the feature folder` note. No `config.ts` in the feature + folder: only chain variants needing an env flag have one (stability and zilliqa have none). +- **No Figma mockups** — none linked on the issue, and none needed. The Calls table reuses the styles of + [`LogDecodedInputDataTable`](../../../src/slices/log/components/LogDecodedInputDataTable.tsx): background + `{ _light: 'blackAlpha.50', _dark: 'whiteAlpha.50' }`, `p={4}`, `mt={2}`, `columnGap`/`rowGap={5}`, + `textStyle="sm"`, and header cells at `fontWeight={600} pb={1}`. One deliberate difference: **all four + corners are rounded** (`borderRadius="md"`), where the reference rounds only the bottom two because + `LogDecodedInputDataHeader` sits above it. Column template is `repeat(3, minmax(0, 1fr))` — equal widths + to start, tuned during verification (leaf 4). +- **Also affected by leaf 5**: the page header tags in + [`Transaction.tsx`](../../../src/slices/tx/pages/details/Transaction.tsx), and + [`TxType`](../../../src/slices/tx/components/TxType.tsx), which renders in the txs list, the home page + latest-transactions widget, and the address transactions tab. +- No new routes, navigation entries, or cross-links. +- No custom Mixpanel events: the only interactive elements are `AddressEntity` links and `CopyToClipboard`, + neither tracked elsewhere; there is no new page (view tracking is auto-wired) and no hardcoded external + link needing UTM params. + +## Out of scope + +- **Adapting the standard fields whose Eden semantics differ.** On a sponsored transaction the backend + derives `to` and `raw_input` from **call 0 only**, `value` from the **sum** of all calls, and `from` is + the *executor* rather than the fee payer. So on a multi-call transaction the "To" and "Raw input" rows + show one call while the Calls table shows all of them. The backend issue's UI requirements ask to "hide or + adapt standard fields whose Eden semantics differ" and to "present gas fields only where they are + meaningful"; #3607 asks for none of it. Raised as **Q2**, not blocking. +- A **Fee payer column in the transactions list**, even though the field is available there. +- The **Eden mainnet** dev preset (`eden.blockscout.com`) — it has no sponsored transactions to look at. +- A **Playwright visual scenario and transaction mock**. Dropped deliberately: the two rows use generic + building blocks already covered elsewhere, and a mock in `src/slices/tx/mocks/details.ts` exists only to + feed a `*.pw.tsx` scenario, so without one it would be dead code. Verification is against live + `eden-testnet` data. +- Backend work of any kind — already shipped. + +## Task breakdown + +- [x] 1 `[agent]` Add `eden` and `sponsored transaction` to `.agents/GLOSSARY.md` — skill: `update-glossary` + - done: `Eden` (chain) and `Sponsored Transaction` (entity) rows, cross-referencing each other + - inputs: + - `eden` — the chain type (`CHAIN_TYPE=eden`): an `ev-reth` / evstack rollup, explorers at + `eden.blockscout.com` and `eden-testnet.blockscout.com` + - `sponsored transaction` — scoped to Eden: EIP-2718 type `0x76` (decimal `118`); an executor submits an + ordered batch of calls and a separate sponsor signs for and pays the fee + - Also gets `eden` past cSpell, which has no entry for it today +- [x] 2 `[agent]` Add the `eden_testnet` dev-server preset + - done: `tools/dev-server/registry.json` + `pnpm presets:sync` (`deploy-review.yml`, `.vscode/tasks.json`) + - inputs: + - `"eden_testnet": "https://eden-testnet.blockscout.com"` in `tools/dev-server/registry.json` + - then `pnpm presets:sync` — regenerates the marker-bracketed alias lists in + `.github/workflows/deploy-review.yml` and `.vscode/tasks.json`; CI fails on drift + - Ordered before the UI leaves so their verification has a preset to run against +- [x] 3 `[agent]` Get `fee_payer` / `calls` into the pinned API types + - inputs: + - First check whether `@blockscout/api-types@0.0.1-beta.bb45bf1` already contains them; if so just bump + the pin in `package.json` + - Otherwise publish a beta from backend `master` via the `publish-beta-types` skill and pin that + - Verify afterwards that `schemas['TransactionResponse']` exposes `fee_payer` and `calls`, and that + `transaction_types` includes `sponsored_transaction` + - done: pinned `0.0.1-beta.8e1692a`, published from `dev` after `master` was merged into it (**Q4**). + `schemas['TransactionResponse']` exposes `fee_payer` and `calls`, and `transaction_types` includes + `sponsored_transaction`; `pnpm lint:tsc` is clean, so the merge cost the app no type churn. The interim + `eden/types/api.ts` shim is gone — the component reads both fields off the pinned schema. +- [x] 4 `[agent]` `[verify]` Build `TxDetailsEden.tsx` and wire it into the details page — requirements 1–4 + - inputs: + - New file `src/features/chain-variants/eden/pages/tx/TxDetailsEden.tsx`; no `config.ts` + - Composed unconditionally in `TxDetails.tsx` after `<TxDetailsOther/>`, before the `Raw input` label + - Fully styled from the `LogDecodedInputDataTable` reference (see **UI inventory**) — this is a code + reference, not a mockup, so there is no separate `[human]` style leaf; width and spacing tweaks happen + during verification + - verify: `pnpm dev:preset eden_testnet`, open + `/tx/0x35310fd76c45f1441226c102f4dc1070b41ac66cb1e6ed3354da78aa69824a67`, expand *View details*, confirm + the Fee payer and Calls rows render correctly between Other and Raw input; adjust styles if needed. Also + open any non-Eden preset (e.g. `eth`) and confirm neither row appears. + - implemented: `TxDetailsEden.tsx` in the new `eden` feature folder, composed in + `TxDetails.tsx`; `dev-eden-testnet` added to `.claude/launch.json`. Functional check done on + `eden_testnet`: both rows render between Other and Raw input on the sponsored transaction, and both are + absent on a type-2 one; the table's computed styles match the reference (16px padding, 20px gaps, 12px + radius, `whiteAlpha.50`, 14px text, three equal columns). Styles reviewed and accepted by the developer + on 2026-08-04, with the column template tuned to `minmax(140px, 1fr) minmax(50px, 1fr) 1fr`; the designer + signed them off on the interim demo the same day. +- [x] 5 `[agent]` `[verify]` Show the **Sponsored** tag in the page header — requirement 6 + - inputs: + - Push a `{ slug: 'sponsored', name: 'Sponsored', tagType: 'custom' }` tag in `Transaction.tsx`, next to + the `relay_tx` / `init_tx` pushes that already feed `MetadataTags` + - Add `sponsored_transaction` to `TYPES_ORDER` last, with no `switch` case, so lists keep showing no + badge for it while the type stops masking more useful labels + - verify: on `eden_testnet`, open a sponsored transaction and confirm the header tag; check `/txs` still + labels a sponsored contract call as "Contract call" + - implemented: the header tag keys off `transaction_types`, so it carries no Eden-specific coupling. + `TxType.spec.tsx` pins both ordering outcomes. The dev server would not hydrate in the agent's browser + pane (Next dev's `_clientMiddlewareManifest.js` is served as JSON), so the header tag is verified by + types and tests only — confirm it visually on the next demo. +- [x] 6 `[agent]` Deploy a demo — skill: `deploy-demo` + - inputs: + - Run last, once every other box is checked + - done: deployed on 2026-08-04 from `2442fb48c` with the `eden_testnet` preset — + https://review-issue-3607.k8s-dev.blockscout.com — and shared in the Q1/Q2 thread, where the designer + signed off the styles. It covers leaves 1–4; the developer waived a redeploy for leaf 5, so the + **Sponsored** header tag is not on the demo. + +## Open questions + +### Q1 — Should a sponsored transaction get its own badge in the transactions list? + +The backend added `sponsored_transaction` to `transaction_types`, and the backend issue's UI requirements +ask for "a tag/badge such as `Sponsored`". #3607 does not mention it. Today the value falls through +`TxType`'s `default` branch to a generic purple "Transaction". If a dedicated badge is wanted: what label, +what colour (`purple` is the fallback's; `green` is unused), and what priority relative to "Contract call" / +"Token transfer" when a transaction is both? + +- Owner: PM (Ulyana) +- Status: `resolved` +- Slack: https://blockscout.slack.com/archives/C03MMUTQDNU/p1785778160250149 (reminder with the interim demo + at https://blockscout.slack.com/archives/C03MMUTQDNU/p1785851465775779) +- Answer: 2026-08-04 — answered by Nikita S. rather than Ulyana: a **Sponsored** tag in the details page + header, skipped in lists where it would not fit. Tags are in the SoW, but how to render them was left to + the team. +- Blocks: leaf 5 + +### Q2 — Should the compatibility fields be adapted on a multi-call sponsored transaction? + +`to` and `raw_input` reflect **call 0 only**, `value` is the **sum** across calls, and `from` is the executor +rather than the payer — so those rows can be misread on a batch of more than one call. Should they be +hidden, relabelled, or annotated for Eden, as the backend issue's UI requirements suggest? Shipping narrow +for now. + +- Owner: PM (Ulyana) +- Status: `waived` +- Slack: https://blockscout.slack.com/archives/C03MMUTQDNU/p1785778160250149 +- Answer: 2026-08-04 — deferred out of this task. Shipping the narrow scope; the team waits for client + feedback on whether the compatibility fields mislead in practice, and adapts them only if it does. + +### Q3 — Which backend release ships the Eden transaction fields? + +Needed for the frontend release notes. The PR merged to `master` on 2026-07-31 and `eden-testnet` already +runs it, but no tagged release is identified. Bundled with this: confirmation that the call's address is +nullable for a contract-creation call (read from the backend source, worth hearing from the owner before the +UI relies on it), and — if so — a request to correct #3607, which names the field `address_hash` where the +API and the OpenAPI schema both use **`to`**. + +- Owner: Backend (Victor) +- Status: `resolved` +- Slack: https://blockscout.slack.com/archives/D040DB9J5QQ/p1785778264416959 +- Answer: 2026-08-03 — backend tag **11.2.4**, planned for release that week. A call's `to` is confirmed + `null` on a contract creation, and #3607's description was corrected to name the field `to`. + +### Q4 — Which backend ref can publish api-types with both the Eden fields and the response shorthands? + +`@blockscout/api-types` betas are published from `dev`, which has no `eden` chain type. A beta published from +`master` (`0.0.1-beta.cf4c6f5`) has `eden.schema` plus `fee_payer` / `calls`, but its `index.ts` lacks the +`operations` and `paths` shorthands added by +[blockscout#14515](https://github.com/blockscout/blockscout/pull/14515) — the app imports those in 60+ +modules, and pinning that build yields 384 type errors across 227 files. So neither ref serves the frontend. +Can `master` be merged into `dev` (or #14515 forward-ported to `master`) so one ref carries both? Until then +the two fields are declared locally in the `eden` feature. + +- Owner: Backend (Victor) +- Status: `resolved` +- Slack: https://blockscout.slack.com/archives/D040DB9J5QQ/p1785780964199699 (compile failure reported at + https://blockscout.slack.com/archives/D040DB9J5QQ/p1785781999313979, the `HexString` rename at + https://blockscout.slack.com/archives/D040DB9J5QQ/p1785838420460469) +- Answer: 2026-08-04 — `dev` is the ref, once `master` was merged into it. Two follow-up fixes were needed: + a compile break the merge left in `read_system_config/2` (`7b60189`), then the Eden call schema still + naming `General.HexString`, which `dev` had renamed to `General.HexData` + ([#14656](https://github.com/blockscout/blockscout/pull/14656)). The publish from `8e1692a` then succeeded. +- Blocks: leaf 3 diff --git a/.agents/tasks/3627-tac-operations-api-v2/spec.md b/.agents/tasks/3627-tac-operations-api-v2/spec.md new file mode 100644 index 00000000000..2a56d8419b7 --- /dev/null +++ b/.agents/tasks/3627-tac-operations-api-v2/spec.md @@ -0,0 +1,270 @@ +# Migrate the TAC operations UI to Read API v2 + +| | | +| --- | --- | +| Issue | https://github.com/blockscout/frontend/issues/3627 | +| Status | `done` | +| Feature branch | `issue-3627` | +| PM | Ulyana | +| Designer | Tatyana | +| Backend | Evgenii | +| Minimum API version | `tac-operation-lifecycle` **v1.2.0** — the service release that serves Read API v2; core **v11.2.8** — the release whose `/api/v2/search` returns the v2 operation shape | +| Slack channel | — (default routing per `grill-the-task`) | + +## Context & goal + +The `tac-operation-lifecycle` service described an operation's whole lifecycle through one overloaded +`type` field that mixed three unrelated things: the transfer **route** (`TON_TAC_TON`, `TAC_TON`, +`TON_TAC`), the **state** (`PENDING`, `ROLLBACK`) and a locally derived **failure reason** +(`INSUFFICIENT_FEE`), plus two catch-alls (`UNKNOWN`, `ERROR`). Two defects follow from that shape and +cannot be worked around in the UI: the route is unknowable until an operation completes, so the direction +cannot be shown while it matters most; and a finalized failure without a rollback is indistinguishable +from a success, so `type: "TON_TAC_TON"` has never actually meant "succeeded" despite being read that way. + +The upstream Stage Profiler moved to a v2 contract that separates route, business outcome, finality and +rollback. The backend consumes it, stores the facts separately, and exposes them through a new Read API v2 +([blockscout-rs#1720](https://github.com/blockscout/blockscout-rs/pull/1720)). + +Goal: the operations list, the operation details page and the by-transaction block read `/api/v2/tac/...` +and render the newly available facts — status independent of route, route while pending, the rollback flag, +and the failure reason. + +## Functional requirements + +1. The operations list, the operation details page and the by-transaction block read from + `/api/v2/tac/...`. Paths, query params, pagination and search are identical to v1; only the operation + object changes. +2. The status indicator is driven by `status` alone. No code path derives an outcome from `type`. +3. The route is driven by `type` and is rendered for **pending** operations too, not only completed ones. +4. Status and route stay in **one combined tag** — a status icon and colour wrapping the route text. This + is a standing product decision: an earlier design split them into two fields and was rejected. Do not + reintroduce the split. +5. `rollback: true` renders as a separate tag beside the status tag, never as a fourth status value. A + `failed` operation may have `rollback` either `true` or `false` and both must render. +6. On the details page the title carries **one** badge: `Rollback` when `rollback: true`, otherwise the + route. Never both — this preserves the current behaviour, where the single badge was driven by the + v1 `type` and read `Rollback` for a rollback. +7. `error_reason` is rendered inside the status tag's tooltip when present, appended to the failure text. + When absent the tooltip carries the plain failure text — the field is optional and legitimately missing + in many failed states. +8. `type: "UNKNOWN"` renders the status word with a spinner in the neutral pending presentation, and no + route. It means the operation id is indexed but its data has not loaded yet, which lasts a second or + two; the API reports `status: pending` for it, so this is the pending presentation minus the route + text, not a new state. +9. The per-stage failure text (`status_history[].note`) is reachable on the details page. +10. A `failed` operation may later become `success`, and the UI shows `failed` with no hedging in the + meantime — no spinner, no "may still resolve" wording. Deliberate: the user cannot act on it and does + not know what they would be waiting for. +11. Legacy values `PENDING`, `ROLLBACK`, `INSUFFICIENT_FEE` and `ERROR` are gone from every code path in + `src/`. The search surfaces are the last to migrate, because their payload comes from core rather than + from the tac service — see subtask 06. +12. No regression in search (`q`), pagination, sender rendering or the stage timeline. + +## Data & API + +**Endpoints** — same host as v1, no env or config change. All three exist on `tac-operation-lifecycle` +v1.2.0: + +- `GET /api/v2/tac/operations?q=&page_token=&page_items=` +- `GET /api/v2/tac/operations/{operation_id}` +- `GET /api/v2/tac/operations:byTx/{tx_hash}` + +The v1 resources are declared in +[`src/api/resources/services/tac-operation-lifecycle.ts`](../../../src/api/resources/services/tac-operation-lifecycle.ts) +and must be repointed. `stat_operations` in that file has never been consumed and is removed rather than +migrated. + +**Operation object.** Four fields replace the single `type`: + +| Field | Type | Required | Semantics | +| --- | --- | --- | --- | +| `operation_id` | `string` | yes | | +| `type` | `UNKNOWN \| TON_TAC_TON \| TAC_TON \| TON_TAC` | yes | **Route only.** Carries no outcome | +| `status` | `pending \| success \| failed` | yes | **Business outcome.** Lower-case values | +| `rollback` | `boolean` | yes | Whether a rollback occurred. Independent of `status` in the contract, though every rollback is expected to be `failed`. Do not hardcode that relationship | +| `timestamp` | `string` (RFC 3339, ms, UTC) | yes | | +| `sender` | `{ address, blockchain: TAC \| TON \| UNKNOWN_BLOCKCHAIN }` | no | | +| `error_reason` | `string` | no | Short failure label, e.g. `Insufficient Fee` | +| `status_history` | `V2OperationStage[]` | details endpoints only | Stage timeline, unchanged from v1 | + +Sample responses for every status/rollback combination are in the +[issue](https://github.com/blockscout/frontend/issues/3627). + +**Migration mapping** from the v1 `type` values: + +| v1 `type` | v2 equivalent | +| --- | --- | +| `PENDING` | `status: pending`; `type` may already carry a concrete route | +| `INSUFFICIENT_FEE` | `status: failed`, `error_reason: "Insufficient Fee"` | +| `ROLLBACK` | `status: failed`, `rollback: true`; the route is preserved in `type` | +| `TON_TAC_TON` / `TAC_TON` / `TON_TAC` | `type` = route; the outcome is in `status` and **may be `failed`** | +| `UNKNOWN` | `type: UNKNOWN` | +| `ERROR` | Not produced in v2; such an operation reads as `pending` / `UNKNOWN` | + +**Contract gotchas** — deliberate backend decisions, not bugs: + +1. `failed` does not wait for finality; `success` does. An operation reads `failed` as soon as the outcome + is known, while the indexer still polls it upstream, so `failed → success` is possible (requirement 10). + A successful but not-yet-final operation reads `pending`. `success` is terminal. +2. `error_reason` is published only when the stored value is at most 16 characters — longer values are raw + upstream payloads (serialized revert data, whole message bodies) rather than labels, and truncating them + was rejected. Treat it as optional in every failed state; the full text stays per stage in `note`. +3. `type: UNKNOWN` has two causes — not profiled yet, or a route this API version does not know. They are + indistinguishable and need not be distinguished. +4. Legacy rows may report `success` for an operation that actually failed: rows indexed under the v1 + upstream contract are mapped rather than hidden, and that contract could not express "finalized failure + without rollback". This reproduces exactly what the UI shows today, so it is not a regression, and a + background re-profiling worker converts them over time. No frontend handling. +5. A stage `timestamp` is `null` when the stage has no transactions. Pre-existing in v1. + +**Types package.** `@blockscout/tac-operation-lifecycle-types@1.2.0` shipped **no v2 types at all**: the +package's `compile:proto` script listed only the v1 protos, so the v2 protos that landed in +`proto/v2/` were never generated. Note the version collision that made this confusing — the npm package and +the service Docker image both sit at `1.2.0` and are unrelated numbers. +[blockscout-rs#1725](https://github.com/blockscout/blockscout-rs/pull/1725) fixed the build; subtask 01 +pins the beta published from `main` afterwards. All v2 messages and enums are `V2`-prefixed, so they +coexist with the v1 exports — which is what lets subtask 06 stay deferred without blocking anything. + +**Deployment.** No feature flag and no coordinated cutover: Read API v1 is byte-for-byte unchanged +(additive-only Swagger diff), so v1 and v2 can be served in parallel indefinitely. There are three +service instances — mainnet, testnet and a staging of testnet — and at spec time v2 is deployed only to +the staging one, which no frontend points at. The service must be rolled out to the instances the +frontends do use **before this task merges**; the backends can be updated early since they stay +v1-compatible. That rollout is tracked on the PR, owned by Backend, and is not a code dependency. + +**The search surfaces are the one exception**, and they do need a coordinated release. Their payload comes +from core, and core replaces `/api/v1/tac/operations` with the v2 call rather than serving both, so an +instance whose core predates **v11.2.8** would hand this frontend a v1 operation object it no longer parses. +Core cuts v11.2.8 when this task is ready, and the two ship together — see Q4. + +Development and the demo deploy both run against the staging service host published in the issue, by +overriding `NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST`. + +## UI inventory + +One Figma frame covers every screen: +[TAC TON-TAC operations](https://www.figma.com/design/1UWWsK0bg6ifzS9O1NLlo4/TAC-TON-TAC-operations?node-id=4001-37444). +The issue carries screenshots of the same frames. + +Everything lives under `src/features/chain-variants/tac/`, and the feature is gated on +`config.features.tac.isEnabled` (API host plus TON explorer URL) — unchanged by this task. Two routes +exist, `/operations` and `/operation/[id]`, plus a block on the transaction page and the search surfaces. + +**The combined status tag** — [`TacOperationStatus`](../../../src/features/chain-variants/tac/components/TacOperationStatus.tsx) +today takes a single `tac.OperationType` and switches it into an error / pending / ok presentation. It +becomes the component that reads `status` for presentation and `type` for the label, and it is shared by +the list, the details page, the by-tx block and (eventually) search. Route labels come from +[`getTacOperationStatus`](../../../src/features/chain-variants/tac/utils/tac-operation.ts) — a name that +stops being accurate once it returns a route rather than a status, and should be renamed accordingly. +`STATUS_SEQUENCE` and `STATUS_LABELS` in the same file are keyed by the v1 stage enum and move to the v2 one. + +**The rollback tag** is its own component, rendered as a sibling — the details page title uses it without +the status tag, so it cannot be baked in. + +**List** — [`TacOperationsTable`](../../../src/features/chain-variants/tac/pages/operations/TacOperationsTable.tsx) +/ `TacOperationsTableItem` and the mobile `TacOperationsList` / `TacOperationsListItem`. No new column: +status, route and the optional rollback tag are one cell. `TacOperationEntity` renders a spinner for +pending operations and keys that off `type` today. + +**Details** — [`TacOperation`](../../../src/features/chain-variants/tac/pages/operation-details/TacOperation.tsx) +(title badge via `TacOperationTag`), `TacOperationDetails` (the Status row), and the lifecycle accordion, +whose item content already renders a `note` row and whose trailing synthetic "Pending" item keys off `type` +today. + +**By transaction** — [`TxDetailsTacOperation`](../../../src/features/chain-variants/tac/pages/tx/TxDetailsTacOperation.tsx), +composed into the transaction details page. It already renders the current-stage tags beside each +operation via `getTacOperationStage`, and that stays as-is. + +**Search** — the search bar suggestion and the search results row/list item render `TacOperationStatus` +from a payload that arrives on the **core** `/api/v2/search` response, not from this service, typed in +[`src/features/chain-variants/tac/types/api.ts`](../../../src/features/chain-variants/tac/types/api.ts). +Core still returns the v1 shape, which is why subtask 06 is deferred. + +**Tests** — `TacOperationStatus.pw.tsx` covers five v1 states and never covered `ROLLBACK` or +`INSUFFICIENT_FEE`; `TacOperation.pw.tsx` covers the details page. Screenshot cases stay minimal — one per +visual variant — and the text/branching matrix is covered by Vitest instead, since the tag is built from +standard toolkit components and screenshots are expensive. No Vitest spec exists for this feature yet. +Mocks live in `mocks/operations.ts` and `mocks/search.ts`, and placeholder data in `stubs.ts`. + +## Out of scope + +- **The "application" column** TAC asked for on the operations table. Their API is not ready and they do + not yet know where the data comes from; it was explicitly deferred to its own task. +- **The by-transaction block rendering nothing** where the status block should be — a separate known issue, + reported by Backend, cause not yet established. Not folded in here. +- **Live updates.** There is no socket on these pages and none is added; a pending operation repaints on + refresh. No mockup exists for the transition, by design. +- **The core `/api/v2/search` migration itself** — backend work in `blockscout/blockscout`. Subtask 06 + consumes it once it lands. +- **Retiring Read API v1 on the backend.** No sunset date; to be agreed separately. +- New env vars or feature flags. + +## Task breakdown + +- [x] 01 Pin the v2 types package → [`subtasks/01-pin-v2-types/`](subtasks/01-pin-v2-types/spec.md) — blocked by: none +- [x] 02 Repoint the resources and rebuild the status tag on the operations list → [`subtasks/02-list-and-status-tag/`](subtasks/02-list-and-status-tag/spec.md) — blocked by: 01 +- [x] 03 Operation details page → [`subtasks/03-operation-details/`](subtasks/03-operation-details/spec.md) — blocked by: 02 +- [x] 04 By-transaction operations block → [`subtasks/04-by-tx-block/`](subtasks/04-by-tx-block/spec.md) — blocked by: 02 +- [x] 05 Remove the v1 client and refresh the generated API docs → [`subtasks/05-remove-v1/`](subtasks/05-remove-v1/spec.md) — blocked by: 02, 03, 04 +- [x] 06 Point the search surfaces at the v2 shape → [`subtasks/06-search-surfaces/`](subtasks/06-search-surfaces/spec.md) — blocked by: 02 + +## Open questions + +### Q1 — Is `failed` a terminal status? + +An insufficient-fee operation was described as staying nominally pending, since the fee can be topped up +and the operation can still succeed, while the issue reports it as `status: failed` with +`error_reason: "Insufficient Fee"` — and the v2 proto notes that `failed` is published while the indexer +still re-requests the operation upstream. If `failed` can flip to `success`, a red cross with a reason is +misleading, and no mockup covers that case. + +- Owner: Backend (Evgenii) +- Status: `resolved` +- Slack: https://blockscout.slack.com/archives/D085WMQ2BC5/p1786700647766519 +- Answer: 2026-08-14 — `failed` is terminal for the frontend but not for the indexer; the status can still + change later. Showing a spinner on such operations was considered and rejected as more confusing, since + the user can neither act on it nor know what they are waiting for. The UI shows `failed`, and shows + `success` if it later becomes so. Agreed with Design and raised with TAC without objection. Captured as + requirement 10. + +### Q2 — Which instances serve Read API v2? + +The frontend ships as one build to every instance and carries no feature flag, so an instance still on the +old service would 404 on the v2 paths. + +- Owner: Backend (Evgenii) +- Status: `resolved` +- Slack: https://blockscout.slack.com/archives/D085WMQ2BC5/p1786700647766519 +- Answer: 2026-08-14 — three instances exist (mainnet, testnet, staging of testnet); v2 is deployed only to + the staging one, which no frontend points at. Rolling out to the rest is safe to do early because the + service stays v1-compatible, and it is a release gate on the PR rather than a code dependency. Captured + under *Deployment*. + +### Q3 — Does the details page title keep its route badge? + +The mockup shows only a `Rollback` tag beside the title, while the page renders a route badge there today — +raising whether the route badge is deliberately dropped. + +- Owner: PM (Ulyana), Designer (Tatyana) +- Status: `resolved` +- Slack: https://blockscout.slack.com/archives/C03MMUTQDNU/p1786700651871919 +- Answer: 2026-08-14 — nothing changes. The badge is mutually exclusive today because the v1 `type` was a + single field: a rollback rendered `Rollback`, everything else rendered the route. The mockup draws the + rollback case, so the route badge is absent there rather than removed. Captured as requirement 6. + +### Q4 — Has the v2 operation shape reached the core `/api/v2/search` response? + +The search surfaces render TAC operations from a payload embedded in the **core** search response rather than +from `tac-operation-lifecycle`, and core returned the v1 shape when this task was written. A v1 `type` can be +`PENDING`, `ROLLBACK` or `INSUFFICIENT_FEE`, so it cannot be reinterpreted as a pure route — which is why +subtask 06 was deferred instead of riding along with subtask 02. + +- Owner: Backend (Evgenii → core backend team, Victor) +- Status: `resolved` +- Slack: https://blockscout.slack.com/archives/C04NCPZGRAR/p1786715688895299 +- Answer: 2026-08-19 — yes. Core's `dev` branch carries + [#14719](https://github.com/blockscout/blockscout/pull/14719), which switches the search result to Read + API v2 and describes the operation object in core's own OpenAPI spec, so the shape no longer has to be + owned by the feature. Two consequences: core **replaces** `/api/v1/tac/operations` rather than serving both + (a dual-endpoint period was considered and rejected as not worth the complexity for three instances), so a + minimum core version applies; and the change ships in core **v11.2.8**, cut when the frontend is ready. diff --git a/.agents/tasks/3627-tac-operations-api-v2/subtasks/01-pin-v2-types/notes.md b/.agents/tasks/3627-tac-operations-api-v2/subtasks/01-pin-v2-types/notes.md new file mode 100644 index 00000000000..adaea68fba8 --- /dev/null +++ b/.agents/tasks/3627-tac-operations-api-v2/subtasks/01-pin-v2-types/notes.md @@ -0,0 +1,19 @@ +# 01 — Notes + +## The lockfile carries one hunk unrelated to the pin + +`pnpm install` also wrote `deprecated: Active development of CryptoJS has been discontinued.` under +`crypto-js@4.2.0` in `pnpm-lock.yaml`. That is registry metadata pnpm refreshes on any install, not a +consequence of this pin — hand-reverting it only means the next install writes it back. Worth a line in the +PR body so a reviewer does not read it as scope creep. + +## The pin needed no `pnpm-workspace.yaml` change + +`@blockscout/tac-operation-lifecycle-types` is already listed under `minimumReleaseAgeExclude`, so a +freshly published beta installs without tripping the release-age hold that would otherwise reject it. + +## `1.2.0` exists but is not the version to use + +npm reports `1.2.0` as available and `pnpm install` says so too. It was published after the v2 protos +merged but before the `compile:proto` fix, so it ships no v2 module — the reason this subtask pins a beta +that sorts *below* the previous `1.1.0` pin rather than upgrading forward. diff --git a/.agents/tasks/3627-tac-operations-api-v2/subtasks/01-pin-v2-types/review.md b/.agents/tasks/3627-tac-operations-api-v2/subtasks/01-pin-v2-types/review.md new file mode 100644 index 00000000000..25f723027df --- /dev/null +++ b/.agents/tasks/3627-tac-operations-api-v2/subtasks/01-pin-v2-types/review.md @@ -0,0 +1,48 @@ +# Review — 01 Pin the v2 types package + +## Subtask 01 — Pin the v2 types package + +| | | +| --- | --- | +| Reviewed | `4e05cbd867ebf2e39ac7ca8fa3f54adf002449de` → working tree | +| Round | 1 of 3 | +| Findings | 0 blocker · 0 major · 1 nit | +| By axis | Spec 1 · Standards 0 · Correctness 0 | +| Outcome | `clear` | + +Checks run in this review's own context: `pnpm lint:tsc` clean, `pnpm lint:cspell` 0 issues, +`pnpm test:vitest --changed` selected no test files (exit 0). `pnpm lint:eslint` 0 errors with 7 +pre-existing `playwright/no-skipped-test` warnings in files this diff does not touch — declared +intentional by the dispatch, so out of bounds. + +Acceptance criteria all satisfied. Independently verified beyond the checks: the installed package on +disk is `0.0.1-beta.71a05d5`; `dist/tac-operation-lifecycle-proto/proto/` carries both `v1` and `v2`; +`index.d.ts` re-exports the v2 module alongside both v1 modules; all five types named in criterion 2 +(`V2OperationBriefDetails`, `V2OperationDetails`, `V2OperationStatus`, `V2OperationType`, +`V2OperationStage`) resolve from the package root under an isolated `tsc` probe; `status`, `rollback` +and `error_reason` are present on both detail interfaces and `V2OperationStatus` is a string enum of +`pending` / `success` / `failed`; every v1 exported type name survives, and no stale `1.1.0` reference +remains in the lockfile. + +The two empty axes are expected rather than suspicious: the diff is a two-line dependency pin with no +source code, so the standards axis has almost no documented rule to breach and the correctness axis +has no logic to get wrong. The pin also needed no `pnpm-workspace.yaml` change — the package is +already listed under `minimumReleaseAgeExclude`, so the repo's `minimumReleaseAge` does not block a +fresh beta. + +### F1 · nit · Spec — `pnpm-lock.yaml:8103` + +**Claim.** The diff carries one hunk unrelated to the pin — `deprecated: Active development of CryptoJS +has been discontinued. This library is no longer maintained.` added under `crypto-js@4.2.0`. The spec +scopes the subtask to "only the pin and the typecheck that proves the pin is sound", and nothing asked +for this. It is registry metadata a real `pnpm install` picked up, so it corroborates the "updated by a +real `pnpm install`" half of criterion 1 rather than contradicting it, and hand-reverting it would +return on the next install. + +**Suggested fix.** Leave it as is; mention it in the PR body so a reader does not take it for scope creep. + +**Status.** `deferred` + +## Out of scope — for the final review + +- None. diff --git a/.agents/tasks/3627-tac-operations-api-v2/subtasks/01-pin-v2-types/spec.md b/.agents/tasks/3627-tac-operations-api-v2/subtasks/01-pin-v2-types/spec.md new file mode 100644 index 00000000000..8976a048c83 --- /dev/null +++ b/.agents/tasks/3627-tac-operations-api-v2/subtasks/01-pin-v2-types/spec.md @@ -0,0 +1,47 @@ +# 01 — Pin the v2 types package + +| | | +| --- | --- | +| Parent spec | [../../spec.md](../../spec.md) — subtask 01 of #3627 | +| Status | `done` | +| Blocked by | none | + +## What to build + +Nothing user-facing. The rest of the task needs the generated v2 types on disk, and until +[blockscout-rs#1725](https://github.com/blockscout/blockscout-rs/pull/1725) landed no published version of +`@blockscout/tac-operation-lifecycle-types` contained them — the package's `compile:proto` compiled only +the v1 protos, so `1.2.0` shipped without the v2 module even though it was published after the v2 protos +merged. That fix is merged and the beta is already published, so this subtask is only the pin and the +typecheck that proves the pin is sound. + +The exact version to pin is **`0.0.1-beta.71a05d5`**, published from `main` by +[run 31796957575](https://github.com/blockscout/blockscout-rs/actions/runs/31796957575) and verified to +contain `dist/tac-operation-lifecycle-proto/proto/v2/` with `status`, `rollback` and `error_reason` on both +`V2OperationBriefDetails` and `V2OperationDetails`, and `V2OperationStatus` as a string enum of exactly +`pending` / `success` / `failed`. Pin that string, never the `beta` dist-tag — rationale in +[`src/api/CONTEXT.md`](../../../../../src/api/CONTEXT.md). + +## Acceptance criteria + +- [x] `package.json` pins `@blockscout/tac-operation-lifecycle-types` to the exact version above, with + `pnpm-lock.yaml` updated by a real `pnpm install` +- [x] `tac.V2OperationBriefDetails`, `tac.V2OperationDetails`, `tac.V2OperationStatus`, `tac.V2OperationType` + and `tac.V2OperationStage` all resolve from the existing `@blockscout/tac-operation-lifecycle-types` + import +- [x] `pnpm lint:tsc` passes — the v1 exports are untouched, so nothing that reads them should break +- [x] Any typecheck breakage unrelated to the tac feature is reported rather than fixed here — none surfaced + +## Leaf worklist + +- [x] 1 `[agent]` Pin the exact version, `pnpm install`, then `pnpm lint:tsc` — skill: `publish-beta-types` (steps 3–4 only; the publish itself is done) + - inputs: + - API service: `tac` → package `@blockscout/tac-operation-lifecycle-types` + - Version to pin: `0.0.1-beta.71a05d5` + - Publish is already done — do not re-run the workflow; `main` is the ref it came from, which is + acceptable here because the v2 protos are merged and the skill's "never the default branch" rule + exists to protect the stable channel, not this pin + +## Work log + +- `package.json` + `pnpm-lock.yaml` only — pin bumped from `1.1.0`; skill: `publish-beta-types` (steps 3–4). diff --git a/.agents/tasks/3627-tac-operations-api-v2/subtasks/02-list-and-status-tag/spec.md b/.agents/tasks/3627-tac-operations-api-v2/subtasks/02-list-and-status-tag/spec.md new file mode 100644 index 00000000000..e65677723d9 --- /dev/null +++ b/.agents/tasks/3627-tac-operations-api-v2/subtasks/02-list-and-status-tag/spec.md @@ -0,0 +1,69 @@ +# 02 — Repoint the resources and rebuild the status tag on the operations list + +| | | +| --- | --- | +| Parent spec | [../../spec.md](../../spec.md) — subtask 02 of #3627 | +| Status | `draft` | +| Blocked by | 01 | + +## What to build + +`/operations` reads the v2 endpoint and every row shows the truth the new contract carries: the status icon +and colour come from `status`, the route text from `type` — including for pending operations, which +previously showed no route at all — and a separate `Rollback` tag appears beside the tag when +`rollback: true`. Hovering the status tag explains the outcome, and for a failed operation with an +`error_reason` the tooltip names the reason. An operation whose `type` is still `UNKNOWN` shows the pending +presentation with the status word and a spinner instead of a route, rather than the blank cell it renders +today. + +This is the subtask that creates the two shared components the details page and the by-tx block then reuse, +so it carries the v2 resource declarations and the route-label helper as well. Search is deliberately left +on the v1 shape here — it is fed by a different API and is handled in subtask 06. + +## Acceptance criteria + +How to verify: `pnpm dev:preset tac_spb` with `NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST` pointed at the +staging service host from the issue, open `/operations` + +- [x] `tac:operations` resolves to `/api/v2/tac/operations`, with `q`, `page_token` and `page_items` + unchanged, and its payload typed from the v2 package types +- [x] The status presentation is derived from `status` only; no code path in the list reads `type` to decide + an outcome +- [x] The route label is derived from `type` and renders for `pending` operations +- [x] `rollback: true` renders as a tag beside the status tag, for both rollback and non-rollback failures +- [x] `error_reason` appears in the status tooltip when present; the tooltip falls back to the plain failure + text when absent +- [x] `type: UNKNOWN` renders the pending presentation with no route and no layout breakage +- [x] A Vitest spec covers the label and tooltip branching across every `status` × `rollback` × + `error_reason` × `UNKNOWN` combination +- [x] `(human)` The list matches the mockup — tag colours, icons, spacing, and the rollback tag's placement + and tooltip copy +- [x] `(human)` Search (`q`), pagination and sender rendering behave exactly as before on the same data + +## Details + +Endpoint and object shape are in the parent spec's *Data & API*; the components involved are listed in its +*UI inventory*. Two naming consequences worth doing here rather than leaving behind: `getTacOperationStatus` +returns a route once it stops returning a status and should be renamed, and `STATUS_SEQUENCE` / +`STATUS_LABELS` are keyed by the v1 stage enum and move to the v2 one. + +`TacOperationEntity` decides its spinner from `type === PENDING` today; that becomes `status`. + +Keep the screenshot matrix minimal — one case per visual variant, not per text permutation. The tag is +assembled from standard toolkit components, so the branching belongs in the Vitest spec. + +## Leaf worklist + +- [x] 1 `[agent]` Repoint the three tac resources to `/api/v2/tac/...` and retype their payloads — skill: `add-api-resource` + - inputs: + - Service: `tac` (`src/api/resources/services/tac-operation-lifecycle.ts`), existing resources + `operations`, `operation`, `operation_by_tx_hash` + - Paths: `/api/v2/tac/operations`, `/api/v2/tac/operations/:id`, `/api/v2/tac/operations\\:byTx/:tx_hash` + — pagination and the `q` filter field unchanged + - Payload types: `V2OperationsResponse`, `V2OperationDetails`, `V2OperationsFullResponse` + - Leave `stat_operations` alone here; subtask 05 removes it +- [x] 2 `[agent]` Rebuild `TacOperationStatus` around `status` + `type` + `error_reason`, and extract the rollback tag as its own component +- [x] 3 `[agent]` Wire the table and mobile-list rows to the new props, including the pending spinner and the `UNKNOWN` case +- [x] 4 `[agent]` Vitest spec for the route-label and tooltip-text branching +- [x] 5 `[agent]` Extend the `TacOperationStatus.pw.tsx` cases to one per visual variant and update the tac mocks and stubs to the v2 shape +- [x] 6 `[human]` Style the status tag and the rollback tag to the mockup, then regenerate the screenshot baselines — [Figma](https://www.figma.com/design/1UWWsK0bg6ifzS9O1NLlo4/TAC-TON-TAC-operations?node-id=4001-37444) diff --git a/.agents/tasks/3627-tac-operations-api-v2/subtasks/03-operation-details/spec.md b/.agents/tasks/3627-tac-operations-api-v2/subtasks/03-operation-details/spec.md new file mode 100644 index 00000000000..565ac7e8a6e --- /dev/null +++ b/.agents/tasks/3627-tac-operations-api-v2/subtasks/03-operation-details/spec.md @@ -0,0 +1,48 @@ +# 03 — Operation details page + +| | | +| --- | --- | +| Parent spec | [../../spec.md](../../spec.md) — subtask 03 of #3627 | +| Status | `draft` | +| Blocked by | 02 | + +## What to build + +`/operation/[id]` reads the v2 details endpoint and renders the same facts as the list, plus the stage +timeline. The Status row carries the combined status tag and, when `rollback: true`, the rollback tag beside +it. The title keeps exactly one badge: `Rollback` when the operation rolled back, otherwise the route — the +behaviour the page already has, now driven by two fields instead of one. The lifecycle accordion is +unchanged in shape; what changes is that its per-stage failure `note` is reachable for a failed stage, and +that its trailing synthetic "Pending" item is decided by `status` rather than by `type`. + +## Acceptance criteria + +How to verify: `pnpm dev:preset tac_spb` with `NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST` pointed at the +staging service host from the issue, open `/operation/[id]` for a success, a failure, a rollback and a +pending operation + +- [x] `tac:operation` resolves to `/api/v2/tac/operations/:id` and its payload is typed from the v2 types +- [x] The Status row renders the shared status tag from subtask 02, with the rollback tag beside it when + `rollback: true` +- [x] The title renders one badge: `Rollback` when `rollback: true`, otherwise the route — never both +- [x] The trailing synthetic pending lifecycle item is driven by `status`, not `type` +- [x] The per-stage `note` is reachable for a failed stage +- [x] `(human)` The page matches the mockup for success, failure, failure with a reason, rollback and + pending — including the title badge and the expanded stage card +- [x] `(human)` The stage timeline is unchanged versus the v1-backed page on the same operation + +## Details + +The lifecycle accordion's item content already renders a `note` row, so requirement 9 of the parent spec may +already be satisfied by construction — confirm against a real failed operation before adding anything, and +only extend if a failed stage's note is actually unreachable. + +`TacOperationTag` is the title badge component; it takes the v1 `type` today. + +## Leaf worklist + +- [x] 1 `[agent]` Wire the details page and its Status row to the v2 fields, reusing the components from subtask 02 +- [x] 2 `[agent]` Drive the title badge from `rollback` / `type`, and the accordion's synthetic pending item from `status` +- [x] 3 `[agent]` Confirm the failed-stage `note` is reachable; extend the accordion only if it is not +- [x] 4 `[agent]` Update `TacOperation.pw.tsx` cases and the details mocks to the v2 shape +- [x] 5 `[human]` Style the details page to the mockup and regenerate its screenshot baselines — [Figma](https://www.figma.com/design/1UWWsK0bg6ifzS9O1NLlo4/TAC-TON-TAC-operations?node-id=4001-37444) diff --git a/.agents/tasks/3627-tac-operations-api-v2/subtasks/04-by-tx-block/spec.md b/.agents/tasks/3627-tac-operations-api-v2/subtasks/04-by-tx-block/spec.md new file mode 100644 index 00000000000..61b4188597e --- /dev/null +++ b/.agents/tasks/3627-tac-operations-api-v2/subtasks/04-by-tx-block/spec.md @@ -0,0 +1,42 @@ +# 04 — By-transaction operations block + +| | | +| --- | --- | +| Parent spec | [../../spec.md](../../spec.md) — subtask 04 of #3627 | +| Status | `draft` | +| Blocked by | 02 | + +## What to build + +The operations block on the transaction details page reads the v2 by-tx endpoint, and each operation row +shows the combined status tag, the rollback tag when the operation rolled back, and the current-stage tags +it already renders. One transaction can produce many operations, so every row is independent. + +This is the smallest of the three view subtasks: the components come from subtask 02 and the stage tags are +existing behaviour that stays exactly as-is. + +## Acceptance criteria + +How to verify: `pnpm dev:preset tac_spb` with `NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST` pointed at the +staging service host from the issue, open a transaction that produced at least one operation + +- [x] `tac:operation_by_tx_hash` resolves to `/api/v2/tac/operations:byTx/:tx_hash` and its payload is typed + from the v2 types +- [x] Each row renders the shared status tag, plus the rollback tag when `rollback: true` +- [x] The existing current-stage tags still render, unchanged, from `status_history` +- [x] The block stays gated on `config.features.tac.isEnabled` +- [x] `(human)` The rows match the mockup, including the order of status tag, rollback tag and stage tags + +## Details + +`TxDetailsTacOperation` is composed into the transaction details page by the tx slice, not the other way +around; that composition does not change. The stage tags come from `getTacOperationStage`, which reads +`status_history` — returned by this endpoint. + +The known issue where this block renders nothing is explicitly **out of scope** (see the parent spec). If it +reproduces while working here, report it rather than fixing it. + +## Leaf worklist + +- [x] 1 `[agent]` Wire the by-tx block to the v2 payload, reusing the components from subtask 02 +- [x] 2 `[human]` Style the operation rows to the mockup — [Figma](https://www.figma.com/design/1UWWsK0bg6ifzS9O1NLlo4/TAC-TON-TAC-operations?node-id=4001-37444) diff --git a/.agents/tasks/3627-tac-operations-api-v2/subtasks/05-remove-v1/spec.md b/.agents/tasks/3627-tac-operations-api-v2/subtasks/05-remove-v1/spec.md new file mode 100644 index 00000000000..2a36b9050a7 --- /dev/null +++ b/.agents/tasks/3627-tac-operations-api-v2/subtasks/05-remove-v1/spec.md @@ -0,0 +1,43 @@ +# 05 — Remove the v1 client and refresh the generated API docs + +| | | +| --- | --- | +| Parent spec | [../../spec.md](../../spec.md) — subtask 05 of #3627 | +| Status | `draft` | +| Blocked by | 02, 03, 04 | + +## What to build + +No user-facing change. Once all three views read v2, the v1 client has no consumers left inside the tac +feature and comes out: the legacy `type` values (`PENDING`, `ROLLBACK`, `INSUFFICIENT_FEE`, `ERROR`) stop +appearing in any code path that talks to the tac service, the unused `stat_operations` resource is deleted, +and the two generated-docs files stop advertising `/api/v1/tac/...` in their curl samples. + +The search surfaces still consume the v1 operation shape, because that payload comes from the core search +API rather than from this service. Their v1 usage stays and is removed by subtask 06 once core migrates — +which is what keeps this subtask honest rather than blocked. + +## Acceptance criteria + +- [x] No tac-service code path references `OperationType.PENDING`, `ROLLBACK`, `INSUFFICIENT_FEE` or `ERROR` +- [x] The `stat_operations` resource is gone from the tac service registry, with its payload-map and + pagination entries +- [x] `/api/v1/tac/operations` and `/api/v1/tac/operations/{operation_id}` no longer appear in the + llms-txt generators +- [x] The remaining v1 type usage is confined to the search payload and its rendering, and is the only such + usage left +- [x] `pnpm lint:tsc`, `pnpm lint:eslint` and `pnpm lint:cspell` all pass + +## Details + +The two generated-docs files are `deploy/tools/llms-txt-generator/generate-standard.ts` and +`generate-pro-api.ts`; both hardcode the v1 paths in curl samples. + +The npm package keeps exporting the v1 types alongside the v2 ones, so nothing has to be deleted upstream +and the search payload keeps typechecking. + +## Leaf worklist + +- [x] 1 `[agent]` Delete `stat_operations` and any now-unused v1 label mapping from the tac feature +- [x] 2 `[agent]` Update both llms-txt generators to the v2 paths +- [x] 3 `[agent]` Grep the repo for remaining legacy `type` value usage and confirm only the search payload remains diff --git a/.agents/tasks/3627-tac-operations-api-v2/subtasks/06-search-surfaces/notes.md b/.agents/tasks/3627-tac-operations-api-v2/subtasks/06-search-surfaces/notes.md new file mode 100644 index 00000000000..c1505160fa3 --- /dev/null +++ b/.agents/tasks/3627-tac-operations-api-v2/subtasks/06-search-surfaces/notes.md @@ -0,0 +1,35 @@ +# 06 — Notes + +## The generated core types cannot express the search result's TAC operation + +Core's `/api/v2/search` result union discriminates on `type`, and the proxied TAC operation object has its +own `type` field carrying the transfer route. `openapi-typescript` collapsed the two, producing a +`SearchResultTacOperation` that + +- overwrites the route enum with the discriminator literal `"tac_operation"` — its own doc comment reads + *"(enum property replaced by openapi-typescript)"*, so the route is unrecoverable from the type; and +- flattens the operation's fields to the top level, losing both the `tac_operation` wrapper and `priority`. + +The wire format is nested and does carry the route — asserted by core's own `search_controller_test.exs` in +[#14719](https://github.com/blockscout/blockscout/pull/14719): + +```json +{ "type": "tac_operation", "priority": 0, + "tac_operation": { "operation_id": "…", "type": "TON_TAC_TON", "status": "success", + "rollback": false, "timestamp": "…", "sender": null, "error_reason": null } } +``` + +So the frontend keeps its feature-owned `SearchResultTacOperation`, retyped to v2. Worth raising with the +core team: either the operation's field is renamed in the spec's search context, or the union gets an +explicit discriminator mapping that leaves member properties alone. + +## Core publishes absent fields as `null`, the service's proto omits them + +`error_reason` and `sender` are `nullable: true` in core's schema and arrive as `null`, whereas the +`tac-operation-lifecycle` proto types them as optional-and-absent. `getTacOperationStatusTooltip` and +`TacOperationStatus` therefore accept `string | null | undefined` rather than `string | undefined`. + +## The search payload is the brief object + +No `status_history` — core proxies the brief shape — so the search rows need no reduced variant of the +status tag, and the timeline is only reachable from the operation details page. diff --git a/.agents/tasks/3627-tac-operations-api-v2/subtasks/06-search-surfaces/review.md b/.agents/tasks/3627-tac-operations-api-v2/subtasks/06-search-surfaces/review.md new file mode 100644 index 00000000000..c939d6b1c0c --- /dev/null +++ b/.agents/tasks/3627-tac-operations-api-v2/subtasks/06-search-surfaces/review.md @@ -0,0 +1,205 @@ +# Review — 06 Point the search surfaces at the v2 shape + +## Subtask 06 — Point the search surfaces at the v2 shape + +| | | +| --- | --- | +| Reviewed | `61438c9e4` → working tree | +| Round | 2 of 3 | +| Findings | 0 blocker · 6 major · 2 nit — 7 fixed, 1 deferred, 0 open | +| By axis | Spec 2 · Standards 4 · Correctness 2 | +| Outcome | `clear` | + +Checks run in this context, all clean: `pnpm lint:tsc`, `pnpm lint:eslint` (0 errors; 7 pre-existing +`playwright/no-skipped-test` warnings in files this diff does not touch), `pnpm lint:cspell`, +`pnpm test:vitest --changed` (10 files / 44 tests). + +Two decisions were checked against their evidence and hold, so they are **not** findings: + +- Keeping the feature-owned `SearchResultTacOperation` / retyping it. The generated + `SearchResultTacOperation` in `node_modules/@blockscout/api-types/dist/public.schema.ts:3296` is indeed + flattened (no `tac_operation` wrapper, no `priority`) with `type: "tac_operation"` and the doc comment + *"(enum property replaced by openapi-typescript)"*, so the route is unrecoverable from it. Keeping the + feature-owned type and the `Exclude` in `src/slices/search/types/api.ts` is exactly what + `src/api/CONTEXT.md`'s "Feature-owned sub-types for data the Core API only proxies" prescribes. +- The nullability widening. `getTacOperationStatusTooltip` already guards with `errorReason ? … : + FAILURE_TOOLTIP`, so `null` cannot leak into the tooltip; `sender?: … | null` and `error_reason?: string + | null` match the generated schema's `nullable: true` fields field-for-field, and `timestamp: string` + matches `schemas['Timestamp'] = string`. + +### F1 · major · Spec — `src/features/chain-variants/tac/components/SearchBarSuggestTacOperation.tsx:23` + +**Claim.** The suggest row passes `isRollback={ data.tac_operation.rollback }` into the tag but renders no +sibling rollback badge, while both search-results rows now do +(`{ data.tac_operation.rollback && <Badge>Rollback</Badge> }`) and so do all four sites committed in 03–05. +The tag's rollback tooltip is the only remaining carrier here, and +`getTacOperationStatusTooltip` returns `null` unless `status === failed` — so a rollback whose status is +`success` or `pending` shows **no** rollback signal at all in the suggestion row. Under v1 this row rendered +a tag reading `Rollback`, so it is a regression against parent-spec requirement 5 ("`rollback: true` renders +as a separate tag beside the status tag, never as a fourth status value") and requirement 12 ("No regression +in search (`q`), …"). + +**Suggested fix.** Render the rollback badge beside `status` in both the mobile and desktop branches, as the +two results rows do (and see F3 — it should come from a feature-owned component). + +**Status.** `fixed` + +- R2 reviewer: verified — the badge sits in the shared `status` fragment (`SearchBarSuggestTacOperation.tsx:31`), which both the mobile (`:41`) and desktop (`:53`) branches render, so a `success`/`pending` rollback is visible in both. + +### F2 · major · Correctness — `src/slices/search/pages/search-results/SearchResultListItem.tsx:263` + +**Claim.** Neither the tag nor the badge shows a loading skeleton: `<TacOperationStatus status={…} type={…} +errorReason={…} isRollback={…}/>` omits `isLoading`, and `{ data.tac_operation.rollback && +<Badge>Rollback</Badge> }` (line 269) omits `loading`. `isLoading` is in scope and is threaded to +`TacOperationEntity.Link` two lines above, and every committed sibling threads both — e.g. +`src/features/chain-variants/tac/pages/operations/TacOperationsTableItem.tsx:32` (`isLoading={ isLoading }`) +and `:34` (`<Badge loading={ isLoading }>`). Reachable in practice: +`src/slices/search/pages/search-results/SearchResults.tsx:124` sets `isLoading = marketplaceApps.isPlaceholderData || isPlaceholderData`, +so real tac rows render solid tags among skeletons whenever the marketplace query is still on placeholder +data. Same defect at `src/slices/search/pages/search-results/SearchResultTableItem.tsx:381` and `:387`. + +**Suggested fix.** Pass `isLoading={ isLoading }` to `TacOperationStatus` and `loading={ isLoading }` to the +badge in both files. + +**Status.** `fixed` + +- R2 reviewer: verified — `isLoading` on the icon and the tag, `loading` on the badge, in both rows (`SearchResultListItem.tsx:249,268,270`; `SearchResultTableItem.tsx:367,386,388`). + +### F3 · major · Standards — `src/slices/search/pages/search-results/SearchResultListItem.tsx:269` + +**Claim.** `{ data.tac_operation.rollback && <Badge>Rollback</Badge> }` — the search **slice** now +hand-renders a tac **feature** entity's badge, duplicated verbatim at `SearchResultTableItem.tsx:387`. +`src/slices/CONTEXT.md` ("Child-slice ownership"): "The slice that owns an entity owns its **rendering** — +tables, lists, detail panels, types. Other slices or features that surface the entity … import those views; +they never reimplement them." The four sites that inline this badge today all live *inside* the feature, so +this diff is the first place the rule actually bites — and it lands in the same change that deleted +`TacOperationRollbackTag`, the component that owned this rendering. + +**Suggested fix.** Render the rollback badge from a tac-feature component in both slice rows (reuse +`TacOperationTag` with `isRollback`, or export a small feature-owned badge) rather than inlining `Badge`. + +**Status.** `fixed` + +- R2 reviewer: verified (accepted half) — both rows render the feature-owned `TacOperationRollbackTag`; no slice hand-renders tac markup. The reinstated component is equivalent to the inline `<Badge loading>Rollback</Badge>` it replaced (default palette, `BadgeProps` spread), and its dropped tooltip is deliberate — the rollback wording travels via `isRollback`, which every call site passes. + +### F4 · major · Standards — `src/features/chain-variants/tac/types/api.ts:10` + +**Claim.** `TacOperationSearchPayload` re-declares field-for-field what +`@blockscout/tac-operation-lifecycle-types` already exports as `V2OperationBriefDetails` +(`operation_id`/`type`/`status`/`rollback`/`timestamp`/`sender`/`error_reason`, dist `…/v2/tac-operation-lifecycle.d.ts:50`), +differing only in `null` vs absent — and `sender?: { address: string; blockchain: tac.V2BlockchainType }` +re-inlines the exported `tac.V2BlockchainAddress` (`:69`). The type's own comment says core "proxies the +`tac-operation-lifecycle` Read API v2 brief object verbatim", which is the argument for deriving it. The +why-comment justifies not using the *core-generated* type; it does not justify retyping the *service* type, +which will silently drift when the proto adds a brief field. `smells.md` — Duplicated Code / Primitive +Obsession ("a domain concept that already has a type here → use the existing type"). + +**Suggested fix.** `export interface TacOperationSearchPayload extends Omit<tac.V2OperationBriefDetails, 'sender' | 'error_reason'> { sender?: tac.V2BlockchainAddress | null; error_reason?: string | null; }` + +**Status.** `fixed` + +- R2 reviewer: verified — `Omit<tac.V2OperationBriefDetails, 'sender' | 'error_reason'>` keeps the real route enum in `type`, the two overrides restore nullability exactly as `public.schema.ts:3296` declares it, and the wrapper plus `priority` survive. + +### F5 · major · Correctness — `src/features/chain-variants/tac/components/TacOperationStatus.spec.tsx:93` + +**Claim.** `it('renders a null error reason as a plain failure')` asserts only +`expect(screen.getByText('TAC → TON')).toBeTruthy()`. That text comes from +`getTacOperationStatusText(status, type)`, which never reads `errorReason`, so the assertion holds for any +value and merely repeats the case above it — the behaviour the `null` path actually risks (a +`"Failed operation. null"` tooltip) is never asserted, and the tooltip `describe` block covers `undefined` +but not `null`. Related: dropping the `Rollback` assertion left the `it.each` at `:78` with two cases +(`true`/`false`) that now assert identical outcomes, so the parametrisation proves nothing. +`tests-unit.md`: "A good test reads like a specification." + +**Suggested fix.** Assert the contract instead — e.g. +`expect(getTacOperationStatusTooltip(tac.V2OperationStatus.failed, null, undefined)).toBe(FAILURE_TOOLTIP)` — +and either drop the degenerate parametrisation or make it assert the differing tooltip +(`ROLLBACK_TOOLTIP` vs `FAILURE_TOOLTIP`). + +**Status.** `fixed` + +- R2 reviewer: verified — the `null` branch is asserted where it lives (`:50-55`), rollback precedence added (`:57`), and the degenerate parametrisation replaced by a contract assertion (`:85`). + +### F6 · major · Spec — `.agents/tasks/3627-tac-operations-api-v2/spec.md:209` + +**Claim.** The subtask index row still points at the file this diff deletes: +``- [ ] 06 Point the search surfaces at the v2 shape → [`subtasks/06-search-surfaces/`](subtasks/06-search-surfaces/brief.md)``. +Rows 01–05 all link `spec.md`, and `.agents/tasks/README.md:156` makes the rule explicit — a folder holds "the +subtask's `spec.md` (once scoped) or a `brief.md`". The permanent record now carries a dead reference. + +**Suggested fix.** Repoint the link to `subtasks/06-search-surfaces/spec.md`. + +**Status.** `fixed` + +- R2 reviewer: verified — the index row now links `subtasks/06-search-surfaces/spec.md`. + +### F7 · nit · Standards — `src/api/CONTEXT.md:89` + +**Claim.** The stated *reason* for feature-owned proxied sub-types is now false of its own example: core +"doesn't fully describe [it] in its own OpenAPI spec — it doesn't know those shapes (e.g. the +`tac_operation` field in the search-result variant …)". Per the subtask notes, core now **does** describe the +object; the type stays feature-owned because `openapi-typescript` collided the discriminator with the route. +Leaf 3 dismissed this as "the example is still true" — the `Exclude` snippet is, the rationale beside it is +not. + +**Suggested fix.** Add a clause naming the codegen collision as the reason for the `tac_operation` case, or +move the example to `ens_domain`. + +**Status.** `fixed` + +- R2 reviewer: verified — `src/api/CONTEXT.md:91-94` names the discriminator collision as a second ground for the exception, so the `tac_operation` example matches its stated reason again. + +### F8 · nit · Standards — `src/features/chain-variants/tac/mocks/search.ts:7` + +**Claim.** `operation_id`, `timestamp` and the `sender` address literals are now copied from +`mocks/operations.ts` (`tacOperation`), which this fixture previously reused — Duplicated Code in +`smells.md`. The shapes genuinely differ (brief vs details), so this is a judgement call. + +**Suggested fix.** Derive the brief payload from `tacOperation` by picking the brief fields rather than +re-typing the literals. + +**Status.** `deferred` + +- R2 reviewer: agree with the deferral — the two fixtures describe different payloads and are read independently; nothing was cosmetically changed. + +## Out of scope — for the final review + +- `src/features/chain-variants/tac/pages/operation-details/TacOperationDetails.tsx:64`, + `.../pages/operations/TacOperationsTableItem.tsx:34`, `.../pages/operations/TacOperationsListItem.tsx:50`, + `.../pages/tx/TxDetailsTacOperation.tsx:79` — four identical inline `<Badge loading={…}>Rollback</Badge>` + that now duplicate the feature-owned `TacOperationRollbackTag`. Landed in subtasks 03–05 and inside the + feature that owns the markup, so `src/slices/CONTEXT.md`'s child-slice ownership rule does not reach them + and this subtask did not make them wrong. Unifying them is a whole-task call. + +--- + +## Round 1 — resolution + +| Finding | Verdict | What changed | +| --- | --- | --- | +| F1 | `accepted` | `SearchBarSuggestTacOperation.tsx` now renders `TacOperationRollbackTag` beside the status tag, so a `success`/`pending` rollback is visible there too. | +| F2 | `accepted` | Both slice rows pass `isLoading` to `TacOperationStatus` and `TacOperationEntity.Icon`, and `loading` to the rollback badge. | +| F3 | `accepted, partially` | The two slice rows and the suggestion row use a reinstated feature-owned `TacOperationRollbackTag` instead of an inline `Badge`, so no slice hand-renders tac markup. **Left alone:** the four in-feature inline `<Badge>Rollback</Badge>` sites in `TacOperationDetails`, `TacOperationsTableItem`, `TacOperationsListItem` and `TxDetailsTacOperation`, written by the developer in subtasks 03–05. They are inside the feature that owns the markup, so they breach no boundary, but the duplication is real — unifying them is the developer's call, not this subtask's. Flagged in the handoff. | +| F4 | `accepted` | `TacOperationSearchPayload` now derives from `tac.V2OperationBriefDetails` via `Omit`, overriding only the two fields whose nullability differs. | +| F5 | `accepted` | The null error-reason case is covered where the branching lives (the util), the degenerate render `it.each` is replaced by one rollback test asserting the tag adds no wording of its own, and the null render test now asserts `null` never reaches the text. Also added rollback-beats-error-reason precedence. | +| F6 | `accepted` | Parent spec index entry points at `spec.md`. | +| F7 | `accepted` | `src/api/CONTEXT.md` now states that the exception also covers a shape Core describes but codegen cannot express, which is why the `tac_operation` example still stands. | +| F8 | `deferred` | Mock literal duplication between `mocks/operations.ts` and `mocks/search.ts`. The two fixtures describe different endpoints' payloads (details vs. the search brief object) and are read independently by the tests; sharing them would couple unrelated surfaces to save six lines. | + +## Round 2 — arbitration + +Every claimed fix was checked against its anchor by a fresh reviewer and independently in the orchestrating +context; all seven hold, and F8's deferral stands. **No regressions introduced by the fixes.** + +**Ruling on F3's partial rejection: agree.** `src/slices/CONTEXT.md` binds "other slices or features that +surface the entity", so it does not reach call sites inside the owning feature. Leaving the four in-feature +inline badges is right for this subtask — reopening three landed subtasks for no behavioural gain is worse +than the duplication. Recorded under *Out of scope* so it resurfaces at the whole-task review. + +Checks re-run in this context after the fixes, all clean: `pnpm lint:tsc`, `pnpm lint:eslint` (0 errors; +7 pre-existing `playwright/no-skipped-test` warnings in untouched files), `pnpm lint:cspell` (0 issues), +`pnpm test:vitest --changed` (10 files / 45 tests). + +The two `(human)` acceptance criteria in the subtask spec remain unticked — presentation parity with the +operations list, and a live search by operation id, sender and tx hash. They are the developer's to sign off +and are not part of this Outcome. diff --git a/.agents/tasks/3627-tac-operations-api-v2/subtasks/06-search-surfaces/spec.md b/.agents/tasks/3627-tac-operations-api-v2/subtasks/06-search-surfaces/spec.md new file mode 100644 index 00000000000..067c1e3c04c --- /dev/null +++ b/.agents/tasks/3627-tac-operations-api-v2/subtasks/06-search-surfaces/spec.md @@ -0,0 +1,78 @@ +# 06 — Point the search surfaces at the v2 shape + +| | | +| --- | --- | +| Parent spec | [../../spec.md](../../spec.md) — subtask 06 of #3627 | +| Status | `done` | +| Blocked by | 02 | + +## What to build + +TAC operations appear in the search bar suggestions and on the search results page, and those rows render +the same status component as the operations list. Their payload does **not** come from +`tac-operation-lifecycle` — it arrives embedded in the **core** `/api/v2/search` response, which is why it +was the one surface left on the v1 shape while the rest of the task moved to v2. + +Core now returns the v2 shape, so the three search renderers switch to the shared status tag from subtask 02 +and the deliberate v1 island — the legacy status component, the legacy label helper and the v1 mock — leaves +the codebase. That completes requirement 11 of the parent spec: no code path anywhere still reads a v1 +operation value. + +The **feature-owned result type stays**, retyped to the v2 shape. Core does describe the object in its own +spec now, but the generated TypeScript cannot express it — see *Details*. + +## Acceptance criteria + +How to verify: `pnpm dev:preset tac_spb` with `NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST` pointed at the +staging service host from the issue, search for an operation id and check both the suggestion row and the +results page + +- [x] `@blockscout/api-types` is pinned to a version whose `SearchResultItem` describes the v2 + `tac_operation` shape, with `pnpm-lock.yaml` updated by a real `pnpm install` +- [x] The three search renderers use the shared `TacOperationStatus` from subtask 02 +- [x] `SearchResultTacOperationStatus`, `utils/tac-operation-legacy.ts` and the v1 mock are gone; + `SearchResultTacOperation` stays but is retyped to the v2 shape, and the `Exclude` in + `src/slices/search/types/api.ts` stays with it +- [x] No `tac.Operation*` (v1) identifier remains anywhere in `src/` +- [x] `error_reason` and `sender` are handled as **nullable** — the core schema declares them + `nullable: true`, so they arrive as `null` rather than absent +- [x] `(human)` The suggestion row and the results row match the operations list's tag presentation +- [x] `(human)` Searching by operation id, sender and tx hash returns the same results as before + +## Details + +The search payload carries no `status_history` — core proxies the brief object, not the details one — so the +rows have the same fields the list rows do and need no reduced variant of the tag. + +**The generated types cannot describe this variant, so the feature-owned type stays.** Core's search result +union discriminates on `type`, and the TAC operation object has its own `type` field carrying the transfer +route. `openapi-typescript` collided the two: in the generated `SearchResultTacOperation` it overwrote the +route enum with the discriminator literal `"tac_operation"` (its own doc comment says *"enum property +replaced by openapi-typescript"*) and flattened the operation's fields to the top level, losing the +`tac_operation` wrapper and `priority`. The runtime response is nested and does carry the route — core's own +controller test asserts it — so the generated type is simply wrong here, and `src/api/CONTEXT.md`'s +`tac_operation` example remains accurate rather than becoming stale. Reported upstream; when core's spec and +codegen agree, the feature-owned type can go. + +The one thing the pin still buys is honesty about the dependency: it is the build of core that serves this +shape. + +A **minimum core version** has to be declared on the parent spec: core drops `/api/v1/tac/operations` rather +than serving both, so an instance running an older core with this frontend would return the v1 shape to a UI +that no longer parses it. See Q4 on the parent spec. + +## Leaf worklist + +- [x] 1 `[agent]` Publish `@blockscout/api-types` from the core `dev` branch and pin the exact version — skill: `publish-beta-types` + - inputs: + - API service: `core` → package `@blockscout/api-types`, workflow `publish-api-types-npm-dev.yml` + - Branch to publish from: `dev` — it carries blockscout/blockscout#14719 (`d8baca6e9`), the commit that + switched the search result to Read API v2 +- [x] 2 `[agent]` Point the three search renderers at the shared status tag and delete the v1 island +- [x] 3 `[agent]` ~~Narrow the `src/api/CONTEXT.md` example to `ens_domain`~~ — not needed; the example is still true (see *Details*) +- [x] 4 `[agent]` Declare the minimum core version on the parent spec and record Q4 + +## Work log + +- Published `@blockscout/api-types@0.0.1-beta.089aef5` from core `dev`; retyped the search payload, pointed + the three renderers at `TacOperationStatus`, deleted the v1 island. diff --git a/.agents/tasks/3661-tx-details-to-value-links/spec.md b/.agents/tasks/3661-tx-details-to-value-links/spec.md new file mode 100644 index 00000000000..bbb5fbbb13d --- /dev/null +++ b/.agents/tasks/3661-tx-details-to-value-links/spec.md @@ -0,0 +1,93 @@ +# UI/UX changes: tx details page ("to" & "value") + +| | | +| --- | --- | +| Issue | https://github.com/blockscout/frontend/issues/3661 | +| Feature branch | `issue-3661` | +| PM | Nikita S. | +| Designer | Tatyana | +| Backend | — (no backend changes) | +| Minimum API version | — (required fields already live in production) | +| Slack channel | — (default routing per `grill-the-task`) | + +## Context & goal + +On the transaction details page, **Eden sponsored (batch) transactions** carry a batch of `calls`, each to +its own recipient. Today the "To" field shows only the single top-level `to` address, hiding the other +recipients, and the "Value" field shows only the summed amount with no pointer to the per-recipient +breakdown that already exists lower on the page (the "Calls" section). This task surfaces the recipients in +"To" and "Value", and — separately — cleans up the "View all" link style used by the token-transfer +detail rows. + +## Functional requirements + +1. For a sponsored (batch) tx, the **"To"** field renders the list of **unique** recipients taken from + `calls[]` (de-duplicated by address), **capped at 5 rows**; when there are more, a grey + **"View all (N)"** link follows the list. +2. Both the "To" **"View all (N)"** link and the "Value" **"N recipients"** link **expand the Details + section** on the same tab, revealing the "Calls" breakdown (no navigation). +3. When the batch resolves to a **single unique recipient** (every call hits the same address), the "To" + field keeps its existing single-address rendering — the recipient list appears only when there is more + than one distinct recipient. +4. In the recipient list, the **first row** (the top-level `to`, which carries metadata) keeps the existing + rich "To" rendering; **rows 2+** render as bare hash only (identicon + hash + copy), with no + badges/tags/name/contract flags. (See Q02 — resolved.) +5. The **"Value"** field appends a grey `to` word followed by a blue **"N recipients"** link, e.g. + `0.002395904453623692 TIA ($2.55) to 2 recipients`. +6. In both places **N = number of unique recipient addresses** — calls are de-duplicated by `to` (keeping + the first call for each), so repeated addresses count once. +7. The token-transfer detail rows (**Tokens transferred / minted / burnt / created**) drop the leading icon + before **"View all"** and adopt the updated link style. The link's **show-condition and target are + unchanged** (shown on `token_transfers_overflow`, links to the Token transfers tab). + +## Data & API + +- Endpoint: `GET /api/v2/transactions/{hash}` → `TransactionResponse` (resource already declared). +- Fields consumed: `to`, `value`, `calls[]` (`{ to, value, input }`), `token_transfers[]`, + `token_transfers_overflow`, `transaction_types` (includes `sponsored_transaction`). All verified live on + `eden-testnet.blockscout.com` — **production-deployed, no backend work**. +- The embedded `token_transfers` array is capped globally (~10 across all types) with a single global + `token_transfers_overflow` flag — **not per-section**. This is why the mockup's "max 5 per section" cap is + out of scope (Q01). +- No new `service:name` resource, no new `NEXT_PUBLIC_*` env var. Eden behaviour is gated on the presence of + `calls` in the response (chain-variant detection), not a feature flag. + +## UI inventory + +- **Page**: transaction details, **Details tab** — `/tx/[hash]?tab=index`. + Figma screen `txn_details` node `5940:4556`: + https://www.figma.com/design/CEgxqWOzVulwfTUHhs0gUC/?node-id=5940-4556 + - "To" recipient list + "View all" — Figma node `5940:8800` / `5940:8801`. + - "Value" row — Figma node `5940:7643`. +- **Components touched**: + - `TxDetails.tsx` (core `tx` slice) — the "Value" row; owns the Details `isExpanded` state. + - `TxDetailsTo.tsx` (core `tx` slice, `parts/`) — the extracted "To" field and recipient list. + - `TxDetailsEden.tsx` (`features/chain-variants/eden`) — owns the "Calls" section (unchanged). + - `TxDetailsTokenTransfers.tsx` (core `tx` slice) — the "View all" restyle. +- **Link mechanism**: the links expand the Details collapsible (`setIsExpanded(true)`), which reveals the + "Calls" section that always sits below — no in-page anchor or scroll target needed. +- Recipient rows mirror the plain `AddressEntity` used inside the existing Calls grid. + +## Implementation decisions + +- Changes #1–#2 (To / Value) live in the Eden feature and are composed into `TxDetails` at the slice page + level; they activate only when `calls` is present and holds more than one unique recipient (per FR-3). +- Recipient count and both link labels derive from the distinct-recipient count (`calls` de-duplicated by + `to`), computed by a shared `getBatchRecipients` helper in the Eden feature. +- The "To" and "Value" links call an `expandDetailsSection` handler lifted from `TxDetails` + (`setIsExpanded(true)`), which reveals the "Calls" section rather than navigating or scrolling to it. +- First recipient row reuses the existing rich `to` rendering; subsequent rows are `AddressEntity` fed only + `{ hash }` — because `calls[].to` is a bare hash string with no metadata in the response. +- Token-transfer restyle: remove the `SpriteIcon name="navigation/tokens"` (the existing + `FIXME use non-navigation icon`) and apply the mockup's link style; leave the `isOverflow`-driven + visibility and Token-transfers-tab target intact. +- Max visible recipients is a named constant (5). + +## Out of scope + +- Any **per-section max-5 cap** on token transfers — needs a backend change to the embedded list/overflow + contract (Q01). This task only restyles the existing "View all". +- Any **backend / API** change. +- Fetching richer metadata (name, tags, contract/verified/scam flags) for recipient rows 2+ — bare hash per + Q02 (resolved); revisit only if requirements change. +- The transaction **list** page "To" rendering — this task is the details page only. diff --git a/.agents/tasks/README.md b/.agents/tasks/README.md index 933d3a16746..86b9e0cb629 100644 --- a/.agents/tasks/README.md +++ b/.agents/tasks/README.md @@ -1,9 +1,11 @@ # Product task specs -This directory holds one folder per product task, each with a `spec.md`. A medium/large task also has a -`subtasks/` folder with one sub-folder per subtask (`subtasks/NN-<slug>/`). Specs merge with their task's -PR and **accumulate here as a permanent record** — consult past specs as precedent for how similar tasks -were scoped and split. +This directory holds one folder per specced product task. A task is worked through a spec-driven workflow — +grill, spec, break into tickets, implement, land — and its `spec.md` survives here as a permanent record. + +Two companion docs carry the detail this spine points at: [`concepts.md`](concepts.md) for what the words +mean and the rules that hold the workflow together (the ticket model, write-once, prune-on-land), and +[`structure.md`](structure.md) for the task-folder layout and which skill owns which file. ## Why @@ -12,54 +14,37 @@ that a developer fills the gaps with guesswork. The spec workflow fixes the inpu gaps, unanswerable questions get routed to the people who own the answers, and the resulting spec explicitly says which steps an agent does and which a developer does by hand. -## Lifecycle +## Not every task needs a spec -1. **Grill** — run the `grill-the-task` skill with the issue URL. It researches first (issue, codebase, live - API samples, Figma mockups — enumerate-only), then interviews you one question at a time. What you can't - answer becomes an open question with an owner. -2. **Spec** — the session ends in the `to-spec` skill: it writes a slim index `spec.md` here plus one - `subtasks/NN-<slug>/` folder per subtask (a `spec.md` if it's scoped now, or a `brief.md` if it's - deferred to its own later session), sizes the task (small / medium / large), tags every subtask - `[agent]` or `[human]` per the delegation boundary, then - drafts the open questions as Slack messages grouped by owner — you approve, it sends, and each thread's - permalink lands in the spec. (`to-spec` also works standalone, from any conversation worth capturing.) - Commit the spec to the feature branch and **open a draft PR right away** (`to-spec` walks you through - branch, commit, and draft PR at the end of the run) — a spec-only draft is the cheap moment to catch a - wrong split or a missed requirement, it links the issue to the work, and CI and demo deploys hang off it - for the rest of the task. -3. **Answers** — when colleagues reply, run `to-spec` on the spec again: it harvests the Slack threads, - proposes resolutions, folds accepted decisions into the spec, and sends approved follow-ups. -4. **Implement** — run the `implement-task` skill repeatedly, one subtask per run: it executes `[agent]` - subtasks (composing `add-api-resource`, `add-new-page`, `add-env-var`, …) and verifies them, or hands - `[human]` subtasks (styling to Figma mockups) over to you. You review the diff and commit between runs. - A subtask can't start while a question blocking it is `pending` — unrelated subtasks can. -5. **Land** — flip the draft PR to **ready for review** when the spec's last box is checked; the feature - branch merges to `main` as one PR, spec included. Big subtasks may have had their own sub-branch + PR - into the feature branch along the way (same pattern: draft when the step starts with its sub-spec as the - first commit, ready when the step's boxes are checked); simple ones are single commits on it. Branch - names carry the addressing — feature branch is `issue-<number>` (`issue-3219`), a big subtask's sub-branch - adds `-step-<N>` (`issue-3219-step-2`) — so `implement-task` needs no arguments on a task branch. +A spec exists to **hand work to a session that wasn't in the room**. A task small enough to grill, +implement, and open as a PR inside one session never leaves the room, so it gets no folder here and its +reasoning goes into the PR description instead. The fork is a rough sizing judgment made at the end of +grilling — one session of work or not — no formal breakdown required to make the call. -## Task sizes +## Lifecycle -- **small** — one step; a single `spec.md`, no `subtasks/` folder. An agent or a user can implement it - right after the grilling session. -- **medium** — the main `spec.md` is a slim index; each subtask lives in its own - `subtasks/NN-<slug>/spec.md`, fully specified up front (`ready`). -- **large** — same layout, but big subtasks are deferred: the grilling session drops a `brief.md` in the - folder now (no `spec.md`), and each gets its sub-spec written **just-in-time** via a `grill-the-task` - subtask session right before it starts. +Each step names the skill that runs it; the session model — what runs where, and why — is in +[`concepts.md`](concepts.md). -A subtask is "scoped" once its folder has a `spec.md`; until then it holds only a `brief.md`. The main -spec's breakdown carries only the done checkbox and a link to each subtask folder. +1. **Grill** — run `grill-the-task` with the issue URL. +2. **Spec** — `to-spec` writes `spec.md` and `questions.md`, then opens the draft PR. +3. **Break into tickets** — `to-tickets` runs with the spec and open questions as its input. It writes the + ticket files and `progress.md`. +4. **Answers** — when colleagues reply, read the threads and fold each decision into `questions.md`. When an + answer changes the work, realise it through `to-tickets` — a new sibling ticket, or an edit to the + affected ticket if it isn't implemented yet, retargeting `Blocked by` edges. +5. **Implement** — run `implement-ticket <NN>` repeatedly, **one ticket per run**. +6. **Land** — `finalize-task` prunes `tickets/`, `progress.md`, and `questions.md` (only `spec.md` survives), + then hands off to `create-pr` to push, write the real description, and flip the draft to ready for review. ## Supporting files -- `.agents/delegation.md` — the living agent/human boundary (incl. the scaffold → style split for UI - work and the standing testing policy). Loosen it via PR as the repo gets more agent-friendly. -- `.agents/TEAM.md` — the team roster (members + Slack IDs); the grilling session picks one contact per - team for the task and records the picks in the spec header. -- `.agents/skills/to-spec/spec-template.md` — the spec template (used for both main and subtask specs). -- Each `subtasks/NN-<slug>/` folder holds the subtask's `spec.md` (once scoped) or a `brief.md` (the - handoff for a not-yet-scoped subtask), plus optional `research.md` (real research / prototype notes) and - `review.md` (a drop point for local review findings; the workflow that acts on them is a planned follow-up). +- [`concepts.md`](concepts.md) — the vocabulary, the ticket model, and the write-once / freeze / prune rules. +- [`structure.md`](structure.md) — the task-folder layout and the file-ownership table. +- [`../delegation.md`](../delegation.md) — the living capability boundary: what agents are trusted to do in + this repo today, and what stays with a developer. It decides every `[agent]` / `[human]` tag. Loosen it + via PR as the repo gets more agent-friendly, never per task. +- [`../TEAM.md`](../TEAM.md) — the team roster (members + Slack IDs); grilling picks one contact per team for + the task and records the picks in the spec header. +- [`../adr/0002-layer-shaped-ticket-leaves.md`](../adr/0002-layer-shaped-ticket-leaves.md) — why a ticket + cuts vertically while its leaves run along layers. diff --git a/.agents/tasks/concepts.md b/.agents/tasks/concepts.md new file mode 100644 index 00000000000..852777d4c69 --- /dev/null +++ b/.agents/tasks/concepts.md @@ -0,0 +1,141 @@ +# Product task concepts + +The semantics of the workflow: what the words mean, what the ticket model is, and the three rules that make +the whole thing hold together — write-once, freeze-on-land, prune-on-land. The skills carry the steps; +this file carries the meaning they act on. Layout and file ownership live in +[`structure.md`](structure.md). + +## Vocabulary + +- **Spec** — the task's durable statement of intent. It is a handoff artifact describing a multi-session piece + of work — what's being built, not how each session does its share. +- **Ticket** — one vertical slice of the task, scoped to a single fresh context window (the ticket model, + below). Tickets are the unit of implementation and of a commit. +- **Leaf** — one step inside a ticket, worth one project skill (`add-api-resource`, `add-new-page`, …). The + hierarchy is **spec → ticket → leaf**. +- **Progress** — the task's checkbox list, one box per ticket. The only place ticket-completion state is + stored; `progress.md`. +- **Questions** — every open question the task raised, each with a stable id (`Q01`), its owner, Slack + permalink, status, and answer once it lands; `questions.md`. A ticket that a question gates names that id + in its `Blocked by`. +- **Brief** — the marker of a **deferred** ticket: a ticket folder with a `brief.md` and no `spec.md`, + because the ticket can't be scoped until something happens first (a prototype, a spike, an answer nobody + has yet). + +## The ticket model + +A **ticket is a vertical slice**: a narrow but complete path through every layer it touches, verifiable on +its own once it lands. Two hard bounds make it the unit everything keys off: + +- It **fits in one fresh context window** — the sizing test the breakdown is quizzed against in `to-tickets`. +- It is **one commit**, made when the ticket is fully implemented. + +Inside a ticket, the **leaves** are the actual steps, and they run *along* layers — one project skill each +(`add-api-resource`, then `add-new-page`, then the styling). The two levels cut in different directions on +purpose; [`../adr/0002-layer-shaped-ticket-leaves.md`](../adr/0002-layer-shaped-ticket-leaves.md) holds the +reasoning. + +Tickets can block or be blocked by sibling tickets, so the order of work falls out of their dependency graph +rather than a linear plan. + +### Leaves + +Each leaf carries `[agent]` or `[human]` per the capability boundary in +[`../delegation.md`](../delegation.md) — exactly one, written explicitly, because `implement-ticket` reads +the tag as its state machine. UI work is **two linked leaves** by default: an `[agent]` scaffold, then a +`[human]` style leaf that takes it to the mockup — layout, spacing, styling, icons — with the exact Figma +node linked on that leaf and the scaffold's `TODO (design):` markers as its worklist. + +A leaf's checkbox is **progress state**: a ticket has no commit until it finishes, so the boxes are the +only durable record of how far it has got inside the ticket. They mark which leaves are done, never what +each one did. + +### Acceptance criteria + +Every ticket carries a checklist of what must be true when it is done — the gate for `implement-ticket`. A +criterion marked `(human)` is one only a person looking at the running product can judge; that is what makes +`implement-ticket` pause for verification before it commits. These criteria gate the ticket **only**: the +whole-task review contract is the spec's Functional Requirements (below), so acceptance criteria are not +needed once the ticket lands. + +The test for a `(human)` criterion: *does this change what a user sees or does?* + +- **Earns one** — component scaffolds (placeholder ones included), data wiring that renders, page + behaviour, perf-sensitive changes, anything touching CSP or security, and new dependencies. +- **Does not** — env vars, API resources and response types, route plumbing, metadata, sitemap, analytics, + unit tests, glossary and docs, behaviour-preserving refactors. + +Getting it wrong costs in both directions: a needless `(human)` criterion stalls an unattended chain, and a +missing one lets an autonomous run commit something nobody looked at. + +### Order + +Every ticket declares `Blocked by` — the blockers that must clear before it can start, or `none`. Each entry +is prefixed by kind: a **ticket** blocker `T<NN>` (cleared when its box is checked in `progress.md`) or a +**question** blocker `Q<NN>` (cleared when it is `resolved` or `waived` in `questions.md`). This one list is +the whole runnable test — the ticket spec states its own blocked status, so `implement-ticket` reads +`Blocked by` and nothing else to decide whether it can start. **Numbers are identity, not order**: the edges +carry the order, which is what lets a ticket be appended without renumbering anything. `implement-ticket` +works the **frontier** — any ticket whose `Blocked by` entries have all cleared. + +### Deferred tickets + +A ticket that can't be scoped until something happens first gets a `brief.md` in its folder and **no** +`spec.md`. That absence is the only marker; nothing labels the task as a whole. A just-in-time `to-tickets` +run scopes it later, against the by-then-current code — writing its `spec.md` (or a fresh `brief.md` if it +still can't be scoped), and whatever else the spike revealed is **appended as new sibling tickets**, with +`Blocked by` edges retargeted to match. The structure stays flat — a ticket never contains tickets. + +## Write-once, freeze-on-land, prune-on-land + +Three rules keep the artifacts a *final statement of intent*, never a log of how they got there. + +### The spec is write-once + +`to-spec` runs **once** per task; there is no update or merge mode. The spec body — Functional Requirements, +Data & API, UI inventory — is **immutable**. The mutable artifacts are `progress.md` checkboxes, +`questions.md`, and — each until it freezes — the ticket specs, their leaf checkboxes included. A rare +genuine requirement change is a plain in-place edit: no superseding history, no +changelog. The spec is always the *final* statement, never a record of iterations. + +A colleague's answer never mutates the spec into a history file. Fold the answer into `questions.md`, and +realise the change as a **new ticket** — or, if the affected ticket is not implemented yet, an edit to that +ticket. Not a rewrite of the spec. + +### A ticket freezes when fully implemented + +A ticket is editable until its **last leaf lands**; then it freezes, mirroring the spec one level down. +Before it freezes, a change is a plain edit to the ticket. After, a late answer that changes shipped +behaviour spawns a **new ticket**, never a rewrite. + +### Functional Requirements are the acceptance contract + +The spec's **Functional Requirements** are written as verifiable, feature-level statements, and they are +what the **whole-task review** checks at land time (`review-changes` reads the spec plus the diff). This is +why per-ticket acceptance criteria don't need to survive the ticket: the FR carry the contract for the task +as a whole. + +### Disposability and pruning + +At land, `finalize-task` prunes `tickets/`, `progress.md`, and `questions.md` — **only `spec.md` survives** +in the tree. The decomposition is preserved in git history (one commit per ticket), so nothing is lost: +precedent to browse is the accumulated specs; decomposition precedent is git history. Pruning runs +**before** the whole-task `review-changes` pass, which reads the spec and the diff via inline PR comments +and so needs no ticket files. + +## What the spec holds, and what it doesn't + +A spec is an **index of decisions**, not a worklog: *what* to build and *why*, pointing at detail instead of +copying it — so it stays legible and never drifts out of sync with its sources. + +- **Rationale lives in its Slack thread.** A resolved question records the **decision, not the + deliberation** — the outcome as a phrase, never who proposed what or the iterations that reached it; the + recorded permalink holds all of that. And the repo is **public**: client specifics, roadmap, and dates + stay in the thread too. Keep only what executing the task needs — a shipped backend version for the + release notes, yes; the date it's planned to ship, no. +- **Values live in the code.** Reference existing code by pointer ("match `LogDecodedInputDataTable`"), not + by copying its values, class names, or line numbers — those rot, and the code already owns them. Capture + only a deliberate deviation and its reason. +- **What-was-done lives in the PR.** Completion is the checked box; the diff is the record. A finding worth + keeping goes to the ticket folder's `notes.md` (task-scoped evidence the PR can quote), or graduates to a + `CONTEXT.md`, a rule, or the glossary if it's durable repo knowledge — never into the spec as a report. diff --git a/.agents/tasks/structure.md b/.agents/tasks/structure.md new file mode 100644 index 00000000000..70a2ef384cd --- /dev/null +++ b/.agents/tasks/structure.md @@ -0,0 +1,47 @@ +# Product task layout + +Where every file of a task lives, who writes it, and how mutable it is. Follow the naming convention and any +file's location is derivable — which is why the skills carry no location instructions of their own. What the +files *mean* is in [`concepts.md`](concepts.md). + +## The tree + +One folder per specced task, under `.agents/tasks/`: + +``` +.agents/tasks/<issue>-<slug>/ + spec.md task spec — the durable statement of intent + progress.md one checkbox per ticket + questions.md open questions, each with a stable id (Q01) + tickets/ + NN-<slug>/ + spec.md the ticket (once scoped) + brief.md OR this, for a deferred ticket (no spec.md) + research.md optional — research / prototype notes + notes.md optional — implementation findings, PR evidence +``` + +**Naming is mechanical.** The task folder is `<issue>-<slug>/` — the bare GitHub issue number, then a +kebab-case slug. Each ticket folder is `tickets/NN-<slug>/`, where `NN` is a zero-padded identity number, +not a position. The feature branch is `issue-<number>` (e.g. `issue-3219`); that mechanical match is what +lets a skill infer the task from the branch with no arguments. + +## File ownership + +| File | Mutability | Holds | Written / mutated by | +| --- | --- | --- | --- | +| `spec.md` | **immutable** once created | Context & goal, Functional Requirements (the whole-task review contract), Data & API, UI inventory, Out of scope. Header is static identity. | `to-spec` creates it. Edited in place only for a rare genuine requirement change. | +| `progress.md` | mutable | One checkbox per ticket: `- [ ] NN → tickets/NN-<slug>/`. No titles, edges, or content. | `to-tickets` creates it and appends a line per ticket; `implement-ticket` checks the box. | +| `questions.md` | mutable | Every open question, each with a stable id (`Q01`): owner, Slack permalink, status, answer. | `to-spec` creates it; answers folded in later by a plain edit. | +| `tickets/NN-<slug>/spec.md` | mutable until fully implemented, then **frozen** | What to build, Acceptance criteria (with `(human)` tags), Skill inputs (grouped by skill), Leaf worklist. Header: `Blocked by` (`T<NN>` ticket + `Q<NN>` question blockers). | `to-tickets` creates it; `implement-ticket` checks its leaf boxes as it works them. | +| `tickets/NN-<slug>/brief.md` | informal | Deferred-ticket marker. Goal, known context, the blocking unknowns and who owns each. | `to-tickets` when it can't scope the ticket; or dropped in by the developer. | +| `tickets/NN-<slug>/{research,notes}.md` | optional | Research / prototype notes; implementation findings kept as PR evidence. | session / developer. | + +## Status is derived, never stored + +There is no task-level status field. Task state is read off `progress.md`: **any box checked → in progress; +all boxes checked → done**. + +`progress.md` is the machine-readable spine: a checked box means the ticket **landed** (its commit exists), +which is what `Blocked by` edges read to release dependents, and the last box checked is what +`finalize-task` acts on. diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md new file mode 100644 index 00000000000..8dc515be15e --- /dev/null +++ b/.claude/agents/code-reviewer.md @@ -0,0 +1,20 @@ +--- +name: code-reviewer +description: Reviews a change on three axes (spec, standards, correctness) by running the repo's review-changes skill, and returns the outcome. Invoked manually (e.g. to review a task at land, or ad hoc); not a general-purpose reviewer. +model: inherit +effort: high +color: cyan +tools: Read, Glob, Grep, Bash, Write, Edit, Agent +--- + +Read `.agents/skills/review-changes/SKILL.md` and follow it. That file is the procedure; this definition +only launches it. + +You are the orchestrator described there: you spawn the axis agents, normalize what they return, and post +the findings as inline PR comments (or report them in chat when there is no PR). You never edit source code, +and you write no review record — the findings live on the PR. + +Your final text is a **return value**, not a message to a person. Return exactly: the PR review URL (or, in +chat mode, that the findings were reported there), counts per severity, counts per axis, and the `Outcome`. +Whoever dispatched you gates on that `Outcome`: `clear` when no `blocker` or `major` finding is open, +otherwise the open counts. diff --git a/.claude/hooks/worktree-deps.sh b/.claude/hooks/worktree-deps.sh new file mode 100755 index 00000000000..cc9689b1b1d --- /dev/null +++ b/.claude/hooks/worktree-deps.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Install node_modules on demand inside linked git worktrees. +# +# A worktree is a bare `git checkout`: gitignored paths like node_modules are never +# carried over, so anything that shells out to the local toolchain fails there. Copying +# node_modules is not an option — pnpm's layout is mostly symlinks into .pnpm, and Claude +# Code's .worktreeinclude copier skips symlinks, so the result would be a broken tree. A +# real `pnpm install` is cheap instead: the global store is content-addressed and linked +# into place (cloned on APFS), so a second worktree shares blocks rather than duplicating +# them. +# +# Installing is deferred to the first command that actually needs deps, so read-only +# sessions (review, explore, research) never pay for it. +# +# Modes: +# session-start announce missing deps, install nothing +# pre-bash install before a command that needs deps, block if the install fails + +set -uo pipefail + +mode="${1:-}" +payload="$(cat)" + +field() { printf '%s' "$payload" | jq -r "$1" 2>/dev/null; } + +dir="$(field '.cwd // empty')" +[ -n "$dir" ] && [ -d "$dir" ] || dir="$PWD" + +root="$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null)" || exit 0 +[ -n "$root" ] || exit 0 + +# Linked worktrees have their own gitdir under the shared common dir; the main checkout +# has the two paths identical. Detecting it this way rather than by matching +# .claude/worktrees/ also covers worktrees created by hand elsewhere. +gitdir="$(git -C "$root" rev-parse --absolute-git-dir 2>/dev/null)" || exit 0 +common="$(git -C "$root" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)" || exit 0 +[ "$gitdir" != "$common" ] || exit 0 + +[ -f "$root/pnpm-lock.yaml" ] || exit 0 +# .modules.yaml rather than the directory, so a half-written install still gets repaired. +[ -f "$root/node_modules/.modules.yaml" ] && exit 0 + +if [ "$mode" = "session-start" ]; then + jq -n '{ + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: "This worktree has no node_modules. A PreToolUse hook runs `pnpm install --frozen-lockfile` automatically before the first Bash command that needs the local toolchain (pnpm/npx/eslint/tsc/vitest/playwright/next), so do not install by hand. Expect that one command to take an extra minute." + } + }' + exit 0 +fi + +[ "$mode" = "pre-bash" ] || exit 0 + +cmd="$(field '.tool_input.command // empty')" +[ -n "$cmd" ] || exit 0 + +needs='(^|[;&|(]|&&|\|\|)[[:space:]]*(pnpm|npx|next|eslint|tsc|vitest|playwright)[[:space:]]|node_modules/\.bin/' +# Dependency-management subcommands run fine without node_modules, and skipping them +# keeps the hook from recursing into the install it is about to perform. +selfmanaged='pnpm[[:space:]]+(install|i|add|remove|rm|up|update|dlx|store|why|licenses|approve-builds|rebuild)([[:space:]]|$)' + +printf '%s' "$cmd" | grep -Eq "$needs" || exit 0 +printf '%s' "$cmd" | grep -Eq "$selfmanaged" && exit 0 + +pnpm_bin="$(command -v pnpm 2>/dev/null)" +if [ -z "$pnpm_bin" ]; then + # Hooks do not always inherit a login shell's PATH, so try the usual install roots. + for candidate in "$HOME/Library/pnpm/pnpm" "$HOME/.local/share/pnpm/pnpm" \ + $(ls -t "$HOME"/.nvm/versions/node/*/bin/pnpm 2>/dev/null); do + [ -x "$candidate" ] && pnpm_bin="$candidate" && break + done +fi +if [ -z "$pnpm_bin" ]; then + echo "Cannot install worktree deps: pnpm is not on PATH for hooks. Install manually in $root." >&2 + exit 2 +fi + +log="$(mktemp)" +if (cd "$root" && "$pnpm_bin" install --frozen-lockfile --prefer-offline >"$log" 2>&1); then + rm -f "$log" + jq -n --arg root "$root" '{ systemMessage: ("Installed node_modules in worktree " + $root) }' + exit 0 +fi + +tail -20 "$log" >&2 +rm -f "$log" +echo "pnpm install --frozen-lockfile failed in $root, so this command would fail on missing deps. Fix the install first." >&2 +exit 2 diff --git a/.claude/hooks/worktree-prune.sh b/.claude/hooks/worktree-prune.sh new file mode 100755 index 00000000000..814ff640223 --- /dev/null +++ b/.claude/hooks/worktree-prune.sh @@ -0,0 +1,262 @@ +#!/usr/bin/env bash +# Retire agent worktrees and their branches once the work has landed. +# +# Claude Code only auto-removes a worktree it can prove is untouched: anything with a +# local commit, a changed file, or a lock it did not write is kept on purpose, so nothing +# is ever destroyed silently. That is the right default and also why worktrees accumulate +# — a branch that has since been merged still looks like unfinished work to it. +# +# This closes that gap from the other side, with two independent ways to prove the work +# has landed: +# +# 1. HEAD is an ancestor of the base branch — everything local is already in history. +# 2. A merged PR exists whose head SHA is exactly this branch's tip. Because PRs here are +# squash-merged, the branch's own commits never become ancestors of the base, so (1) +# can never fire and this is the test that actually retires things. Matching the tip +# SHA is what makes it safe: a commit added after the merge moves the tip, the SHAs +# stop matching, and the branch is kept. +# +# An open PR on the same head keeps the branch regardless — a branch can be merged once and +# then reopened for follow-up work. Anything unproven is reported for a human, never +# deleted, and every GitHub failure (no gh, offline, rate limit) resolves to "keep". +# +# The idle window exists because another session may be working in a clean worktree right +# now; a worktree untouched for days is not one somebody is sitting in. It does not apply +# to branches whose worktree is already gone — there is nobody to interrupt. +# +# Env: +# CLAUDE_WORKTREE_IDLE_DAYS how long a worktree must be untouched (default 3) +# CLAUDE_WORKTREE_NO_GH set to 1 to skip PR lookups entirely (offline / no network) +# Args: +# --report list candidates without removing anything + +set -uo pipefail + +report_only=false +[ "${1:-}" = "--report" ] && report_only=true + +idle_days="${CLAUDE_WORKTREE_IDLE_DAYS:-3}" +payload="$(cat 2>/dev/null || true)" + +dir="$(printf '%s' "$payload" | jq -r '.cwd // empty' 2>/dev/null)" +[ -n "$dir" ] && [ -d "$dir" ] || dir="$PWD" + +root="$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null)" || exit 0 +gitdir="$(git -C "$root" rev-parse --absolute-git-dir 2>/dev/null)" || exit 0 +common="$(git -C "$root" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)" || exit 0 + +# Only ever prune from the main checkout — a worktree must not delete the directory it is +# running in, or its siblings out from under a session that owns them. +[ "$gitdir" = "$common" ] || exit 0 + +base="" +for ref in refs/remotes/origin/HEAD refs/remotes/origin/main refs/heads/main refs/heads/master; do + if git -C "$root" show-ref --verify --quiet "$ref"; then base="$ref"; break; fi +done +[ -n "$base" ] || exit 0 + +mtime() { stat -f %m "$1" 2>/dev/null || stat -c %Y "$1" 2>/dev/null; } +now="$(date +%s)" +idle_cutoff=$(( now - idle_days * 86400 )) + +removed=() +kept=() +orphans=() + +join() { local sep="$1"; shift; local out=""; for item in "$@"; do out="${out:+$out$sep}$item"; done; printf '%s' "$out"; } + +# Resolved on first use, not up front: `gh auth status` hits the keyring and the network, +# and the common session has nothing to prune and no reason to pay for it. +gh_ready="" +gh_available() { + if [ -z "$gh_ready" ]; then + if [ "${CLAUDE_WORKTREE_NO_GH:-}" != "1" ] && + command -v gh >/dev/null 2>&1 && + gh auth status >/dev/null 2>&1; then + gh_ready=true + else + gh_ready=false + fi + fi + $gh_ready +} +gh_calls=0 +gh_budget=10 + +# One bulk pair of queries covers every candidate, so the cost of the PR checks is flat +# instead of one round trip per branch. Fetched on first use, so a session with nothing to +# prune touches the network not at all. +gh_prefetched=false +open_heads="" +merged_prs="" +pr_prefetch() { + $gh_prefetched && return 0 + gh_prefetched=true + open_heads="$(cd "$root" && gh pr list --state open --limit 100 \ + --json headRefName --jq '.[].headRefName' 2>/dev/null || true)" + merged_prs="$(cd "$root" && gh pr list --state merged --limit 100 \ + --json headRefName,headRefOid,url --jq '.[] | "\(.headRefOid) \(.headRefName) \(.url)"' 2>/dev/null || true)" +} + +# Sets pr_state to merged|open|unproven, and pr_url on merged. Results go to globals rather +# than stdout because a command substitution would run this in a subshell and lose the +# prefetch and the call budget. +pr_state="" +pr_url="" +pr_check() { + local branch="$1" tip="$2" prs + pr_state="unproven" + pr_url="" + gh_available || return 0 + pr_prefetch + + if printf '%s\n' "$open_heads" | grep -Fqx "$branch"; then + pr_state="open" + return 0 + fi + + pr_url="$(printf '%s\n' "$merged_prs" | + awk -v tip="$tip" -v branch="$branch" '$1 == tip && $2 == branch { print $3; exit }')" + if [ -n "$pr_url" ]; then + pr_state="merged" + return 0 + fi + + # Older than the prefetch window — ask about this branch specifically. Budgeted, because + # a repo with a long tail of dead branches would otherwise stall session startup. + [ "$gh_calls" -ge "$gh_budget" ] && return 0 + gh_calls=$(( gh_calls + 1 )) + + prs="$(cd "$root" && gh pr list --head "$branch" --state all --limit 10 \ + --json state,headRefOid,url 2>/dev/null)" || return 0 + [ -n "$prs" ] || return 0 + + if printf '%s' "$prs" | jq -e 'any(.[]; .state == "OPEN")' >/dev/null 2>&1; then + pr_state="open" + return 0 + fi + + pr_url="$(printf '%s' "$prs" | jq -r --arg tip "$tip" \ + 'map(select(.state == "MERGED" and .headRefOid == $tip)) | first | .url // empty' 2>/dev/null)" + [ -n "$pr_url" ] && pr_state="merged" + return 0 +} + +# Worktree paths, plus whether git holds a lock on each (a live session's own lock). +locked_paths="" +while IFS= read -r line; do + case "$line" in + "worktree "*) current="${line#worktree }" ;; + "locked"*) locked_paths="$locked_paths$current"$'\n' ;; + esac +done < <(git -C "$root" worktree list --porcelain) + +while IFS= read -r wt; do + [ -n "$wt" ] || continue + [ "$wt" = "$root" ] && continue + + name="$(basename "$wt")" + branch="$(git -C "$wt" symbolic-ref --short -q HEAD 2>/dev/null || true)" + head="$(git -C "$wt" rev-parse HEAD 2>/dev/null || true)" + [ -n "$head" ] || continue + + if printf '%s' "$locked_paths" | grep -Fqx "$wt"; then + kept+=("$name: locked by a live session") + continue + fi + + changed="$(git -C "$wt" status --porcelain 2>/dev/null | wc -l | tr -d ' ')" + if [ "$changed" != "0" ]; then + kept+=("$name: $changed uncommitted change(s)") + continue + fi + + landed="" + if git -C "$root" merge-base --is-ancestor "$head" "$base" 2>/dev/null; then + landed="in ${base#refs/}" + elif [ -n "$branch" ]; then + pr_check "$branch" "$head" + case "$pr_state" in + merged) landed="$pr_url" ;; + open) kept+=("$name: open PR on $branch"); continue ;; + esac + fi + if [ -z "$landed" ]; then + ahead="$(git -C "$root" rev-list --count "$base..$head" 2>/dev/null || echo '?')" + kept+=("$name: $ahead commit(s) not landed${branch:+ (branch $branch)}") + continue + fi + + # The private index is rewritten by any git read in the worktree, which is the closest + # proxy available for "somebody was working here". + last="$(mtime "$common/worktrees/$name/index")" + [ -n "${last:-}" ] || last="$(mtime "$wt")" + if [ -n "${last:-}" ] && [ "$last" -gt "$idle_cutoff" ]; then + kept+=("$name: merged but active within ${idle_days}d") + continue + fi + + if $report_only; then + kept+=("$name: prunable — clean, idle, landed ($landed)") + continue + fi + + if git -C "$root" worktree remove --force "$wt" 2>/dev/null; then + removed+=("worktree $name") + # $landed already proved this branch's tip is accounted for, so the ancestor test that + # git branch -d would apply is the wrong gate under squash merges. + if [ -n "$branch" ]; then + git -C "$root" branch -D "$branch" >/dev/null 2>&1 && removed+=("branch $branch") + fi + else + kept+=("$name: git refused to remove it") + fi +done < <(git -C "$root" worktree list --porcelain | sed -n 's/^worktree //p') + +# Branches outlive their worktree: removing a worktree never deletes the ref, so merged +# agent branches pile up long after their directory is gone. +while IFS= read -r branch; do + [ -n "$branch" ] || continue + git -C "$root" worktree list --porcelain | grep -Fqx "branch refs/heads/$branch" && continue + + landed="" + if git -C "$root" merge-base --is-ancestor "$branch" "$base" 2>/dev/null; then + landed="in ${base#refs/}" + else + pr_check "$branch" "$(git -C "$root" rev-parse "$branch")" + case "$pr_state" in + merged) landed="$pr_url" ;; + open) kept+=("branch $branch: open PR, no worktree"); continue ;; + *) orphans+=("$branch"); continue ;; + esac + fi + + if $report_only; then + kept+=("branch $branch: prunable — landed ($landed), no worktree") + else + git -C "$root" branch -D "$branch" >/dev/null 2>&1 && removed+=("branch $branch") + fi +done < <(git -C "$root" for-each-ref --format='%(refname:short)' 'refs/heads/claude/*') + +git -C "$root" worktree prune 2>/dev/null || true + +# Orphan branches are collapsed into one clause: they are the same list every session and +# only worth a nudge, not a per-branch verdict. +if [ ${#orphans[@]} -gt 0 ]; then + kept+=("${#orphans[@]} claude/* branch(es) with no worktree and no landed PR: $(join ', ' "${orphans[@]}")") +fi + +[ ${#removed[@]} -eq 0 ] && [ ${#kept[@]} -eq 0 ] && exit 0 + +summary="" +[ ${#removed[@]} -gt 0 ] && summary="removed $(join ', ' "${removed[@]}")" +[ ${#kept[@]} -gt 0 ] && summary="${summary:+$summary; }kept — $(join '; ' "${kept[@]}")" + +jq -n --arg summary "$summary" --argjson notify "$([ ${#removed[@]} -gt 0 ] && echo true || echo false)" ' + { + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: ("Agent worktree/branch cleanup: " + $summary) + } + } + (if $notify then { systemMessage: ("Worktree cleanup — " + $summary) } else {} end) +' diff --git a/.claude/launch.json b/.claude/launch.json index b58d7bb4722..3d219601685 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -7,6 +7,12 @@ "runtimeArgs": ["dev:preset", "staging"], "port": 3000 }, + { + "name": "dev-eden-testnet", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["dev:preset", "eden_testnet"], + "port": 3000 + }, { "name": "dev-eth", "runtimeExecutable": "pnpm", diff --git a/.claude/settings.json b/.claude/settings.json index ce98915038d..ce1c24730f0 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,36 @@ { "enabledPlugins": { - "figma@claude-plugins-official": true + "figma@claude-plugins-official": false + }, + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/worktree-deps.sh session-start", + "timeout": 15 + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/worktree-prune.sh", + "timeout": 60 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/worktree-deps.sh pre-bash", + "timeout": 900, + "statusMessage": "Installing worktree dependencies…" + } + ] + } + ] } } diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index 0b25a08ff0d..2c684ec0fe8 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -22,3 +22,18 @@ See `.agents/rules/env-vars.md`. - Unit tests: See `.agents/rules/tests-unit.md`. - Visual component tests: See `.agents/rules/tests-visual.md`. + +## 6. Code smells + +See `.agents/skills/review-changes/smells.md` — thirteen smells, each a judgement call, each overridden by +anything the rules files above document. + +When the change touches the agent instruction surface instead of code — `.agents/`, `AGENTS.md`, any +`CONTEXT.md`, `.cursor/` — use `.agents/skills/review-changes/prose-smells.md` in its place. Those files are +code that runs on an agent, and none of the thirteen above apply to them. + +## 7. Not findings + +See the **Out of bounds** section of `.agents/skills/review-changes/SKILL.md`. Most importantly: styling and +visual judgements belong to a human, a `TODO (design):` marker is a scaffold working as designed, and a style +preference with no rule or precedent behind it is not a finding. diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 25c64f49b30..3f5c22acd12 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -67,6 +67,9 @@ jobs: - name: Check spelling run: pnpm lint:cspell --no-progress + - name: Check agent doc references resolve + run: pnpm lint:doc-links + - name: Check preset lists are in sync with registry run: pnpm presets:lint diff --git a/.github/workflows/deploy-review.yml b/.github/workflows/deploy-review.yml new file mode 100644 index 00000000000..2858ceb9654 --- /dev/null +++ b/.github/workflows/deploy-review.yml @@ -0,0 +1,115 @@ +name: Deploy review environment + +on: + workflow_dispatch: + inputs: + variant: + description: 'Demo variant — "review-2" disables ENVs validation (e.g. for multichain)' + required: false + default: review + type: choice + options: + - review + - review-2 + build_image: + description: 'Build & publish a new image. Disable to redeploy an existing demo with a different preset (no rebuild).' + required: false + default: true + type: boolean + envs_preset: + description: ENVs preset + required: false + default: staging + type: choice + options: + - none + # presets:start — generated from tools/dev-server/registry.json (run `pnpm presets:sync`) + - arbitrum + - arbitrum_sepolia + - base + - blackfort_testnet + - celo + - celo_sepolia + - eden_testnet + - eth + - eth_sepolia + - filecoin + - garnet + - gnosis + - gnosis_chiado + - hpp + - immutable + - mega_eth + - multichain + - neon_devnet + - numine + - optimism + - optimism_sepolia + - polygon + - rootstock + - rootstock_testnet + - robinhood + - scroll_sepolia + - shibarium + - stability_testnet + - staging + - staging_multichain + - tac + - tac_spb + - zetachain + - zetachain_testnet + - zilliqa + - zksync + - zora + # presets:end + +permissions: + contents: read + packages: write + pull-requests: write + +jobs: + make_slug: + name: Make GitHub reference slug + runs-on: ubuntu-latest + outputs: + REF_SLUG: ${{ steps.output.outputs.REF_SLUG }} + steps: + - name: Inject slug/short variables + uses: rlespinasse/github-slug-action@v4.4.1 + + - name: Set output + id: output + run: echo "REF_SLUG=${{ env.GITHUB_REF_NAME_SLUG }}" >> $GITHUB_OUTPUT + + publish_image: + name: Publish Docker image + needs: make_slug + # Skipped when redeploying an existing demo with a different preset (the image is preset-agnostic). + if: ${{ inputs.build_image }} + uses: './.github/workflows/publish-image.yml' + with: + # Variant-independent tag: the image is preset- AND variant-agnostic, + # so one image serves both the `review` and `review-2` demos. This lets a + # deploy for one variant reuse (build_image=false) an image built under the other. + tags: | + type=raw,value=review-${{ needs.make_slug.outputs.REF_SLUG }} + platforms: linux/amd64 + secrets: inherit + + deploy_review: + name: Deploy frontend + needs: [ make_slug, publish_image ] + # Run after a successful build, or directly when the build was skipped (redeploy-only). + if: ${{ always() && needs.make_slug.result == 'success' && (needs.publish_image.result == 'success' || needs.publish_image.result == 'skipped') }} + uses: blockscout/actions/.github/workflows/deploy_helmfile.yaml@main + with: + appName: ${{ inputs.variant }}-${{ needs.make_slug.outputs.REF_SLUG }} + globalEnv: review + helmfileDir: deploy + # Inject the chosen preset as a runtime env (ENVS_PRESET) instead of baking it into the image. + helmfileParameters: --suppress-diff --state-values-set envsPreset=${{ inputs.envs_preset }} + kubeConfigSecret: ci/data/dev/kubeconfig/k8s-dev + vaultRole: ci-dev + secrets: inherit + permissions: write-all diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml new file mode 100644 index 00000000000..4fd1cf54502 --- /dev/null +++ b/.github/workflows/publish-image.yml @@ -0,0 +1,141 @@ +name: Publish Docker image + +on: + workflow_dispatch: + inputs: + tags: + description: Image tags (e.g. "type=raw,value=foo") + required: false + type: string + build_args: + description: Build-time variables + required: false + type: string + platforms: + description: Image platforms (you can specify multiple platforms separated by comma) + required: false + type: string + default: linux/amd64,linux/arm64/v8 + private: + description: Make image private (true) or public (false) + required: false + type: boolean + default: true + workflow_call: + inputs: + tags: + description: Image tags (e.g. "type=raw,value=foo") + required: false + type: string + build_args: + description: Build-time variables + required: false + type: string + platforms: + description: Image platforms (you can specify multiple platforms separated by comma) + required: false + type: string + default: linux/amd64,linux/arm64/v8 + private: + description: Make image private (true) or public (false) + required: false + type: boolean + default: true + +permissions: + contents: read + packages: write + +jobs: + run: + name: Run + runs-on: build + steps: + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Set image name based on private flag + id: image-name + run: | + if [ "${{ inputs.private }}" = "true" ]; then + echo "image-name=ghcr.io/blockscout/frontend-private" >> $GITHUB_OUTPUT + else + echo "image-name=ghcr.io/blockscout/frontend" >> $GITHUB_OUTPUT + fi + + # Will automatically make nice tags, see the table here https://github.com/docker/metadata-action#basic + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ steps.image-name.outputs.image-name }} + flavor: | + latest=false + tags: | + type=ref,event=tag + ${{ inputs.tags }} + + - name: Add SHORT_SHA env property with commit short sha + run: echo "SHORT_SHA=`echo ${GITHUB_SHA} | cut -c1-8`" >> $GITHUB_ENV + + - name: Debug + env: + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + IMAGE_NAME: ${{ steps.image-name.outputs.image-name }} + IS_PRIVATE: ${{ inputs.private }} + run: | + echo "ref_type: $REF_TYPE" + echo "ref_name: $REF_NAME" + echo "image_name: $IMAGE_NAME" + echo "is_private: $IS_PRIVATE" + + - name: Setup repo + uses: blockscout/actions/.github/actions/setup-multiarch-buildx@no-metadata + id: setup + with: + docker-image: ${{ steps.image-name.outputs.image-name }} + docker-username: ${{ github.actor }} + docker-password: ${{ secrets.GITHUB_TOKEN }} + docker-remote-multi-platform: true + docker-arm-host: ${{ secrets.ARM_RUNNER_HOSTNAME }} + docker-arm-host-key: ${{ secrets.ARM_RUNNER_KEY }} + + # The registry cache exporter REPLACES the manifest at its tag rather than + # merging into it, so a single shared tag would let any of the amd64-only + # callers wipe the arm64 records written by a release build (only releases + # build arm64). Scope the tag by platform set. Sorted + whitespace-stripped + # so the tag does not depend on how the input happens to be spelled. + # Fails hard rather than falling back to a fixed tag: a shared fallback would + # silently reintroduce the very clobbering this step prevents, and + # `ignore-error=true` on the exporter would hide the symptom. + - name: Compute build cache tag + id: cache-tag + env: + PLATFORMS: ${{ inputs.platforms }} + run: | + set -euo pipefail + slug=$(printf '%s' "$PLATFORMS" | tr -d '[:space:]' | tr ',' '\n' | awk 'NF' | LC_ALL=C sort | tr '/' '-' | paste -sd '_' -) + [ -n "$slug" ] || { echo "platforms input resolved to no platforms" >&2; exit 1; } + echo "tag=buildcache-$slug" >> "$GITHUB_OUTPUT" + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + # Also read the amd64-only cache, kept warm by the four amd64 + # callers, so a multi-arch release gets its amd64 half for free. + # Redundant (same ref twice) for those amd64-only callers. + cache-from: | + type=registry,ref=${{ steps.image-name.outputs.image-name }}:${{ steps.cache-tag.outputs.tag }} + type=registry,ref=${{ steps.image-name.outputs.image-name }}:buildcache-linux-amd64 + cache-to: type=registry,ref=${{ steps.image-name.outputs.image-name }}:${{ steps.cache-tag.outputs.tag }},mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true + tags: ${{ steps.meta.outputs.tags }} + platforms: ${{ inputs.platforms }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + GIT_COMMIT_SHA=${{ env.SHORT_SHA }} + GIT_TAG=${{ github.ref_type == 'tag' && github.ref_name || '' }} + ${{ inputs.build_args }} diff --git a/.gitignore b/.gitignore index 66e765d9875..21bf70ead54 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ # next.js /.next/ /out/ +# regenerated by every `next dev` / `next build`, with a distDir-dependent body — see next-types.d.ts +/next-env.d.ts /public/assets/envs.js /public/assets/configs /public/assets/multichain diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 7a85e0f9850..51423577e65 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -328,6 +328,7 @@ "blackfort_testnet", "celo", "celo_sepolia", + "eden_testnet", "eth", "eth_sepolia", "filecoin", diff --git a/Dockerfile b/Dockerfile index 26b2780e07a..1bd5d0ee8c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,27 @@ +# ***************************** +# *** STAGE 0: Shared base **** +# ***************************** +FROM node:22.14.0-alpine AS base +# corepack prepare makes a single network request to the npm registry and has no +# built-in retry. Transient registry failures (HTTP 429, timeout, DNS) are not +# uncommon, so retry with a linear backoff. +RUN set -eu; \ + corepack enable; \ + n=0; \ + until corepack prepare pnpm@11.5.1 --activate; do \ + n=$((n + 1)); \ + if [ "$n" -ge 5 ]; then echo "corepack prepare failed after $n attempts" >&2; exit 1; fi; \ + echo "corepack prepare attempt $n failed, retrying in $((n * 10))s..." >&2; \ + sleep $((n * 10)); \ + done + # ***************************** # *** STAGE 1: Dependencies *** # ***************************** -FROM node:22.14.0-alpine AS deps +FROM base AS deps # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. RUN apk add --no-cache libc6-compat python3 make g++ RUN ln -sf /usr/bin/python3 /usr/bin/python -RUN corepack enable && corepack prepare pnpm@11.5.1 --activate ### Install all workspace dependencies in one place WORKDIR /app @@ -17,9 +33,8 @@ RUN pnpm install --frozen-lockfile # ***************************** # ****** STAGE 2: Build ******* # ***************************** -FROM node:22.14.0-alpine AS builder +FROM base AS builder RUN apk add --no-cache --upgrade libc6-compat bash jq -RUN corepack enable && corepack prepare pnpm@11.5.1 --activate # pass build args to env variables ARG GIT_COMMIT_SHA diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ec6c24aae6e..72bc8378b4f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -33,7 +33,7 @@ **Full list of the ENV variables**: [v1.2.3](https://github.com/blockscout/frontend/blob/v1.2.3/docs/ENVS.md) ## 💑 Compatibility -From this version onward, the app is compatible only with the following API versions: +This release raises the minimum required version of the following API services: | Service | Version | | --- | --- | diff --git a/cspell.jsonc b/cspell.jsonc index 835750c0925..9d6eaf1e4a5 100644 --- a/cspell.jsonc +++ b/cspell.jsonc @@ -8,6 +8,7 @@ "**/*.svg", "**/*.sfd", ".git", + ".env.*", // agent worktrees are full checkouts of the repo — checking them duplicates every file ".claude/worktrees", "pnpm-lock.yaml", @@ -35,7 +36,9 @@ // Posthog project key "phc_\\w+", // GitHub account names - "@(\\w|-)+" + "@(\\w|-)+", + // Russian copy in agent skills (DevOps / Slack templates) + "[А-Яа-яЁё]+" ], // words - list of words to be always considered correct "words": [ @@ -48,6 +51,7 @@ "addrs", "adsbyslise", "aeiou", + "APFS", "Ahrefs", "airtable", "Alexa", @@ -116,6 +120,8 @@ "Emelyanov", "Enkrypt", "esbuild", + "Evgenii", + "evstack", "explorable", "facebookexternalhit", "favicons", @@ -160,6 +166,7 @@ "LCIA", "libc", "libp", + "linkedinbot", "Liquality", "llms", "lokijs", @@ -168,6 +175,9 @@ "megaeth", "merkle", "metasuites", + "miscompilation", + "miscompiles", + "mipd", "mgas", "mload", "mmss", @@ -200,6 +210,7 @@ "pjpeg", "posthog", "protobufjs", + "PRRT", "PWDEBUG", "pwstory", "pyftsubset", @@ -256,6 +267,7 @@ "superchain", "svgr", "tabler", + "Tatyana", "TBXN", "testnetv", "thinsp", @@ -272,6 +284,7 @@ "uidotdev", "Ulyana", "unfinalized", + "unminified", "UNKN", "unparse", "unrs", @@ -304,6 +317,7 @@ "xname", "xstar", "yatki", + "zagjs", "zerion", "zerossl", "zetachain", diff --git a/deploy/scripts/CONTEXT.md b/deploy/scripts/CONTEXT.md index d19a8cf2d20..1f8a59d7601 100644 --- a/deploy/scripts/CONTEXT.md +++ b/deploy/scripts/CONTEXT.md @@ -45,7 +45,7 @@ time. `.env.extra` is then layered on top. 2. **`download_assets.sh`** — fetches the external assets referenced by env vars (network logo, marketplace config JSON, featured-networks list, - etc.) into `public/assets/configs/` so the app serves them same-origin + etc.) into `public/assets/configs/<downloaded>/` so the app serves them same-origin and not depend on 3rd-party insfrastructure. 3. **`validate_envs.sh`** → `envs-validator` — fail-fast check that the container's env vars conform to the schema. Skippable via diff --git a/deploy/tools/envs-validator/schemas/features/account.ts b/deploy/tools/envs-validator/schemas/features/account.ts index 7642de67318..8b2ef05acd2 100644 --- a/deploy/tools/envs-validator/schemas/features/account.ts +++ b/deploy/tools/envs-validator/schemas/features/account.ts @@ -19,4 +19,11 @@ export const accountSchema = yup then: (schema) => schema.required(), otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_ACCOUNT_DYNAMIC_ENVIRONMENT_ID can only be used if NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER is set to \'dynamic\' '), }), + NEXT_PUBLIC_TOKEN_INFO_EXPEDITED_REVIEW_HTML: yup + .string() + .when('NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED', { + is: (value: boolean) => value === true, + then: (schema) => schema, + otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_TOKEN_INFO_EXPEDITED_REVIEW_HTML can only be used if NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED is set to \'true\''), + }), }); \ No newline at end of file diff --git a/deploy/tools/llms-txt-generator/generate-pro-api.ts b/deploy/tools/llms-txt-generator/generate-pro-api.ts index fc929a9ff46..ce039b090ff 100644 --- a/deploy/tools/llms-txt-generator/generate-pro-api.ts +++ b/deploy/tools/llms-txt-generator/generate-pro-api.ts @@ -203,13 +203,13 @@ export function generateProApi(): string { ### TAC Operations: \`\`\`bash - curl --request GET --url '${config.apis.tac.endpoint}/api/v1/tac/operations' + curl --request GET --url '${config.apis.tac.endpoint}/api/v2/tac/operations' \`\`\` ### TAC Operation Info: \`\`\`bash - curl --request GET --url '${config.apis.tac.endpoint}/api/v1/tac/operations/{operation_id}' + curl --request GET --url '${config.apis.tac.endpoint}/api/v2/tac/operations/{operation_id}' \`\`\` ` : undefined; diff --git a/deploy/tools/llms-txt-generator/generate-standard.ts b/deploy/tools/llms-txt-generator/generate-standard.ts index d4e6659b766..400e8eadc96 100644 --- a/deploy/tools/llms-txt-generator/generate-standard.ts +++ b/deploy/tools/llms-txt-generator/generate-standard.ts @@ -203,13 +203,13 @@ export function generateStandard(): string { ### TAC Operations: \`\`\`bash - curl --request GET --url '${config.apis.tac.endpoint}/api/v1/tac/operations' + curl --request GET --url '${config.apis.tac.endpoint}/api/v2/tac/operations' \`\`\` ### TAC Operation Info: \`\`\`bash - curl --request GET --url '${config.apis.tac.endpoint}/api/v1/tac/operations/{operation_id}' + curl --request GET --url '${config.apis.tac.endpoint}/api/v2/tac/operations/{operation_id}' \`\`\` ` : undefined; diff --git a/deploy/values/review-2/values.yaml.gotmpl b/deploy/values/review-2/values.yaml.gotmpl index 4da42b81c59..bb68860c07a 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/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 47714f45619..fe7a838f9b3 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -183,6 +183,8 @@ We have 3 pre-configured projects. You can run your test with the desired projec *Note*, if you Pull Request contains any changes that are not backwards compatible with the previous versions of the app, please specify them in PR description and add label ["breaking changes"](https://github.com/blockscout/frontend/labels/breaking%20changes) to it. +*Note*, if your Pull Request adds a feature that is not privacy-compliant (e.g. tracking, analytics, or a third-party service), make sure it is disabled in private mode, so a privacy-focused deployment can turn it off. +   ## Commands list diff --git a/docs/ENVS.md b/docs/ENVS.md index 94d992ab4ca..71d38f91f4a 100644 --- a/docs/ENVS.md +++ b/docs/ENVS.md @@ -610,6 +610,7 @@ _Note_ Some properties can hold an array of up to two strings. The first string | NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER | `auth0 \| dynamic` | Auth provider that enables basic user authentication. | - | `auth0` | `dynamic` | v2.7.0+ | | NEXT_PUBLIC_ACCOUNT_DYNAMIC_ENVIRONMENT_ID | `string` | Environment ID of the Dynamic project. | Required, if provider is `dynamic` | - | `<your-secret>` | v2.7.0+ | | NEXT_PUBLIC_API_KEYS_ALERT_MESSAGE | `string` | Used for displaying custom alerts on the API keys page. Could be a regular string or HTML code. On chains supported by the Blockscout Pro API the page shows a built-in deprecation notice by default; this variable overrides it, and an empty value hides it. | - | - | `Hello world! 🤪` | v2.7.0+ | +| NEXT_PUBLIC_TOKEN_INFO_EXPEDITED_REVIEW_HTML | `string` | Payment instructions shown in the "Need a faster review?" block of the token info application form, as a regular string or HTML code. Setting it enables the block; leaving it empty hides it. Only applies when the address verification workflow is enabled. | - | - | `Send <b>99 USDC</b> to <code>0x123…</code>` | v2.11.0+ | **Dependencies** @@ -962,11 +963,11 @@ If the feature is enabled, a single button or a dropdown (if more than 1 item is ### Flashblocks -This feature allows users to view [Flashblocks](https://docs.base.org/base-chain/flashblocks/apps)-related content in the explorer, including the Flashblocks real-time feed. It currently supports only Base chains. +Real-time feed of sub-second pre-confirmation blocks. The same feature backs two stacks: on OP Stack chains these blocks are called **Subblocks** (OP Labs' current name; formerly "Flashblocks") and are streamed from the endpoint below; on **MegaETH** they are called **mini-blocks** and are streamed from the MegaETH RPC endpoint (see [MegaETH](#megaeth)). The `FLASHBLOCKS` variable name is retained across both. | Variable | Type | Description | Compulsoriness | Default value | Example value | Version | | --- | --- | --- | --- | --- | --- | --- | -| NEXT_PUBLIC_FLASHBLOCKS_SOCKET_URL | `string` | Public WebSocket endpoint to stream Flashblocks data | Required | - | `wss://mainnet.flashblocks.base.org/ws` | v2.3.0+ | +| NEXT_PUBLIC_FLASHBLOCKS_SOCKET_URL | `string` | Public WebSocket endpoint to stream Subblocks data on OP Stack chains | Required | - | `wss://mainnet.flashblocks.base.org/ws` | v2.3.0+ |   diff --git a/docs/PULL_REQUEST_TEMPLATE.md b/docs/PULL_REQUEST_TEMPLATE.md index 669ae93ed4b..d6c4c284975 100644 --- a/docs/PULL_REQUEST_TEMPLATE.md +++ b/docs/PULL_REQUEST_TEMPLATE.md @@ -1,22 +1,19 @@ -## Description and Related Issue(s) +## Description -*[Provide a brief description of the changes or enhancements introduced by this pull request and explain motivation behind them. Cite any related issue(s) or bug(s) that it addresses using the [format](https://blog.github.com/2013-05-14-closing-issues-via-pull-requests/) `Fixes #123` or `Resolves #456`.]* +*[What this pull request changes and why. Cite any related issue(s) using the [format](https://blog.github.com/2013-05-14-closing-issues-via-pull-requests/) `Fixes #123` or `Resolves #456`.]* -### Proposed Changes -*[Specify the changes or additions made in this pull request. Please mention if any changes were made to the ENV variables]* +## Environment variables -### Breaking or Incompatible Changes -*[Describe any breaking or incompatible changes introduced by this pull request. Specify how users might need to modify their code or configurations to accommodate these changes.]* +*[List each environment variable added, changed, or removed and its purpose, or "None".]* -### Additional Information -*[Include any additional information, context, or screenshots that may be helpful for reviewers.]* +## Minimum API version -## Checklist for PR author -- [ ] I have tested these changes locally. -- [ ] I added tests to cover any new functionality, following this [guide](./CONTRIBUTING.md#writing--running-tests) -- [ ] Whenever I fix a bug, I include a regression test to ensure that the bug does not reappear silently. -- [ ] If I have added a feature or functionality that is not privacy-compliant (e.g., tracking, analytics, third-party services), I have disabled it for private mode. -- [ ] If I have added, changed, renamed, or removed an environment variable - - I updated the list of environment variables in the [documentation](ENVS.md) - - I made the necessary changes to the validator script according to the [guide](./CONTRIBUTING.md#adding-new-env-variable) - - I added "ENVs" label to this pull request +*[The lowest Core API or microservice API version this change requires when it depends on new backend fields or endpoints (e.g. "Core API v11.2.4+"), or "None".]* + +## Breaking or incompatible changes + +*[Anything that forces a deployment to change its config or setup to keep working, and how to accommodate it, or "None".]* + +## Additional information + +*[Any additional context or screenshots that help reviewers.]* diff --git a/next-env.d.ts b/next-env.d.ts deleted file mode 100644 index 7996d352f43..00000000000 --- a/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// <reference types="next" /> -/// <reference types="next/image-types/global" /> -import "./.next/dev/types/routes.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/next-types.d.ts b/next-types.d.ts new file mode 100644 index 00000000000..aab0863f284 --- /dev/null +++ b/next-types.d.ts @@ -0,0 +1,11 @@ +// Next.js emits these two references into the generated `next-env.d.ts`, but that file also gets a +// `distDir`-dependent import (`.next/dev/types/routes.d.ts` under `next dev`, `.next/types/routes.d.ts` +// under `next build`), so no committed copy of it can survive both commands — every production build +// rewrote it and dirtied the working tree. `next-env.d.ts` is therefore gitignored and the references +// are kept here, where they are stable. +// +// They cannot simply be dropped: `compilerOptions.types` is pinned to `[ "node" ]`, so Next's ambient +// types are not picked up automatically. Without the second line the `*.svg` module declarations from +// `next/image-types/global` go missing and `pnpm lint:tsc` fails on every SVG import. +/// <reference types="next" /> +/// <reference types="next/image-types/global" /> diff --git a/next.config.js b/next.config.js index c3d10dbc88a..f9b4d3b6bba 100644 --- a/next.config.js +++ b/next.config.js @@ -2,8 +2,13 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.BUNDLE_ANALYZER === 'true', }); +// Destination of the generated nextjs-routes.d.ts. It has to be passed to nextjs-routes twice: +// the webpack plugin below takes it as an option, while the CLI (pnpm routes:generate) only reads +// it off the resolved Next.js config, hence the `outDir` key at the bottom of moduleExports. +const ROUTES_OUT_DIR = 'src/shared/router'; + const withRoutes = require('nextjs-routes/config')({ - outDir: 'src/server', + outDir: ROUTES_OUT_DIR, }); const headers = require('./src/server/headers'); @@ -39,7 +44,16 @@ const moduleExports = { use: [ '@svgr/webpack' ], }, ); - config.resolve.fallback = { fs: false, net: false, tls: false }; + config.resolve.fallback = { + fs: false, + net: false, + tls: false, + // @metamask/sdk (reached via @wagmi/connectors -> @reown/appkit-adapter-wagmi) imports the + // React Native storage adapter unconditionally. It is an optional peer dep of a code path a + // browser bundle never takes, so resolve it to an empty module instead of letting webpack + // warn about it on every production build. + '@react-native-async-storage/async-storage': false, + }; config.externals.push('pino-pretty', 'lokijs', 'encoding'); config.experiments = { ...config.experiments, topLevelAwait: true }; @@ -60,6 +74,12 @@ const moduleExports = { redirects, headers, output: 'standalone', + // Turbopack's standalone tracer copies only @swc/helpers/cjs and drops the esm/ entry points that + // Next's require-hook loads at runtime, so `node server.js` crashes on boot. Force the whole + // package into the standalone bundle until the tracer is fixed upstream. + outputFileTracingIncludes: { + '/**': [ './node_modules/@swc/helpers/**' ], + }, productionBrowserSourceMaps: false, serverExternalPackages: [ '@opentelemetry/sdk-node', @@ -73,10 +93,18 @@ const moduleExports = { dynamic: 30, 'static': 180, }, + // Next 16.3 defaults build-time type-checking to the `tsc` CLI, which requires a + // `typescript/bin/tsc` binary. This repo runs the native TypeScript compiler via the + // `@typescript/typescript6` alias, which ships `bin/tsc6` only — so the CLI path reports + // `typescript` as missing and aborts the build. The compiler-API path checks against + // `lib/typescript.js`, which the alias does provide, so type-checking runs normally. + useTypeScriptCli: false, }, - // workaround for passing outDir to nextjs-routes CLI - outDir: 'src/shared/router', + + // workaround for passing outDir to nextjs-routes CLI, see ROUTES_OUT_DIR above. + // Next.js warns about this unrecognized key on startup; the warning is harmless. + outDir: ROUTES_OUT_DIR, }; module.exports = withBundleAnalyzer(withRoutes(moduleExports)); diff --git a/package.json b/package.json index cd7826b4e1c..4bbac1331de 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,6 @@ "dev:preset": "./tools/dev-server/dev.preset.sh", "dev:local": "./tools/dev-server/dev.local.sh", "prod:preset": "./tools/dev-server/prod.preset.sh", - "profile:preset": "./tools/profiling/profile.preset.sh", "profile:analyze": "node ./tools/profiling/aggregate-react-profile.mjs", "presets:sync": "node ./tools/dev-server/sync-preset-lists.mjs --write", "presets:lint": "node ./tools/dev-server/sync-preset-lists.mjs", @@ -31,6 +30,7 @@ "lint:eslint:fix": "eslint . --fix", "lint:tsc": "tsc -p ./tsconfig.json", "lint:cspell": "cspell . --no-must-find-files", + "lint:doc-links": "node ./tools/scripts/check-doc-links.mjs", "lint:license:check": "license-report > ./license.json && license-report-check --source=./license.json --output=table --forbidden=n/a", "lint:envs-validator:test": "cd ./deploy/tools/envs-validator && ./test.sh", "prepare": "husky install", @@ -39,7 +39,7 @@ "test:pw": "./tools/scripts/pw.sh", "test:pw:local": "export NODE_PATH=$(pwd)/node_modules && pnpm test:pw", "test:pw:docker": "docker run --rm --ipc=host -v $(pwd):/work/ -v $(pwd)/node_modules_linux:/work/node_modules -w /work/ -it mcr.microsoft.com/playwright:v1.57.0-noble ./tools/scripts/pw.docker.sh", - "test:pw:docker:deps": "docker run --rm --ipc=host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.57.0-noble ./tools/scripts/pw.docker.deps.sh", + "test:pw:docker:deps": "docker run --rm --ipc=host -v $(pwd):/work/ -v blockscout-pnpm-linux:/pnpm-store -w /work/ -it mcr.microsoft.com/playwright:v1.57.0-noble ./tools/scripts/pw.docker.deps.sh", "test:pw:ci": "pnpm test:pw --project=$PW_PROJECT", "test:pw:detect-affected": "node ./deploy/tools/affected-tests/index.js", "test:vitest": "vitest run", @@ -52,15 +52,15 @@ "postinstall": "chakra typegen ./src/toolkit/theme/theme.ts" }, "dependencies": { - "@blockscout/admin-rs-types": "1.5.0", - "@blockscout/api-types": "0.0.1-beta.82839e44ce", + "@blockscout/admin-rs-types": "1.5.1", + "@blockscout/api-types": "0.0.1-beta.089aef5", "@blockscout/bens-types": "1.7.1", - "@blockscout/contracts-info-types": "1.5.2", + "@blockscout/contracts-info-types": "1.5.3", "@blockscout/interchain-indexer-types": "1.6.0", "@blockscout/multichain-aggregator-types": "2.1.6", "@blockscout/points-types": "1.4.1", "@blockscout/stats-types": "2.11.1", - "@blockscout/tac-operation-lifecycle-types": "1.1.0", + "@blockscout/tac-operation-lifecycle-types": "0.0.1-beta.71a05d5", "@blockscout/visualizer-types": "0.2.0", "@blockscout/zetachain-cctx-types": "1.0.0-rc.10", "@chakra-ui/react": "3.36.1", @@ -72,6 +72,7 @@ "@graphiql/toolkit": "0.11.3", "@growthbook/growthbook-react": "0.21.0", "@helia/verified-fetch": "2.6.12", + "@internationalized/date": "3.12.2", "@metamask/post-message-stream": "^7.0.0", "@metamask/providers": "^10.2.1", "@monaco-editor/react": "4.7.0", @@ -128,7 +129,7 @@ "magic-bytes.js": "1.8.0", "mixpanel-browser": "2.67.0", "monaco-editor": "0.52.2", - "next": "16.2.6", + "next": "16.3.1", "next-themes": "0.4.4", "nextjs-routes": "2.2.5", "node-fetch": "^3.3.2", @@ -188,7 +189,7 @@ "@typescript/native": "npm:typescript@7.0.2", "@vitejs/plugin-react": "^4.3.4", "css-loader": "^6.11.0", - "dotenv-cli": "^6.0.0", + "dotenv-cli": "10.0.0", "eslint": "9.39.2", "eslint-config-next": "16.2.6", "eslint-plugin-boundaries": "6.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8264ab44eff..4a057243d8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,17 +22,17 @@ importers: .: dependencies: '@blockscout/admin-rs-types': - specifier: 1.5.0 - version: 1.5.0 + specifier: 1.5.1 + version: 1.5.1 '@blockscout/api-types': - specifier: 0.0.1-beta.82839e44ce - version: 0.0.1-beta.82839e44ce + specifier: 0.0.1-beta.089aef5 + version: 0.0.1-beta.089aef5 '@blockscout/bens-types': specifier: 1.7.1 version: 1.7.1 '@blockscout/contracts-info-types': - specifier: 1.5.2 - version: 1.5.2 + specifier: 1.5.3 + version: 1.5.3 '@blockscout/interchain-indexer-types': specifier: 1.6.0 version: 1.6.0 @@ -46,8 +46,8 @@ importers: specifier: 2.11.1 version: 2.11.1 '@blockscout/tac-operation-lifecycle-types': - specifier: 1.1.0 - version: 1.1.0 + specifier: 0.0.1-beta.71a05d5 + version: 0.0.1-beta.71a05d5 '@blockscout/visualizer-types': specifier: 0.2.0 version: 0.2.0 @@ -81,6 +81,9 @@ importers: '@helia/verified-fetch': specifier: 2.6.12 version: 2.6.12(bufferutil@4.1.0)(react-native@0.85.3(@babel/core@7.29.7)(@types/react@19.1.7)(bufferutil@4.1.0)(react@19.1.4)(utf-8-validate@6.0.6))(utf-8-validate@6.0.6) + '@internationalized/date': + specifier: 3.12.2 + version: 3.12.2 '@metamask/post-message-stream': specifier: ^7.0.0 version: 7.0.0 @@ -250,14 +253,14 @@ importers: specifier: 0.52.2 version: 0.52.2 next: - specifier: 16.2.6 - version: 16.2.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.57.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + specifier: 16.3.1 + version: 16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.57.0)(@types/node@20.16.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) next-themes: specifier: 0.4.4 version: 0.4.4(react-dom@19.1.4(react@19.1.4))(react@19.1.4) nextjs-routes: specifier: 2.2.5 - version: 2.2.5(next@16.2.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.57.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)) + version: 2.2.5(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.57.0)(@types/node@20.16.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)) node-fetch: specifier: ^3.3.2 version: 3.3.2 @@ -425,8 +428,8 @@ importers: specifier: ^6.11.0 version: 6.11.0(webpack@5.107.2(esbuild@0.25.12)(postcss@8.5.15)) dotenv-cli: - specifier: ^6.0.0 - version: 6.0.0 + specifier: 10.0.0 + version: 10.0.0 eslint: specifier: 9.39.2 version: 9.39.2 @@ -623,7 +626,7 @@ importers: dependencies: next-sitemap: specifier: 4.2.3 - version: 4.2.3(next@16.2.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.57.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)) + version: 4.2.3(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.57.0)(@types/node@20.16.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)) src/toolkit/package: dependencies: @@ -633,6 +636,24 @@ importers: '@emotion/react': specifier: '>=11.14.0' version: 11.14.0(@types/react@18.3.12)(react@19.1.4) + '@internationalized/date': + specifier: '>=3.12.2' + version: 3.12.2 + '@uidotdev/usehooks': + specifier: '>=2.4.1' + version: 2.4.1(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + d3: + specifier: '>=7.9.0' + version: 7.9.0 + dayjs: + specifier: '>=1.11.21' + version: 1.11.21 + dom-to-image: + specifier: '>=2.6.0' + version: 2.6.0 + es-toolkit: + specifier: '>=1.39.10' + version: 1.39.10 next: specifier: '>=16.2.6' version: 16.2.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.57.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) @@ -1318,17 +1339,17 @@ packages: '@base-org/account@2.4.0': resolution: {integrity: sha512-A4Umpi8B9/pqR78D1Yoze4xHyQaujioVRqqO3d6xuDFw9VRtjg6tK3bPlwE0aW+nVH/ntllCpPa2PbI8Rnjcug==} - '@blockscout/admin-rs-types@1.5.0': - resolution: {integrity: sha512-QE+dpUaQDvOAb/wUJ98J3CBqfohbiBW/+AEqEmSdOOZ0XxPRUrke08krBnflq3GMACRwB+nwzQrsfl7KCrC1Eg==} + '@blockscout/admin-rs-types@1.5.1': + resolution: {integrity: sha512-+wGwULmzzfwkASJKwPB1X09iqmNUeIPu7Pc5INQxL2XunllTs32bhyvji1XqyA+VmF5yOxxYhQuLhNqCeN4SfQ==} - '@blockscout/api-types@0.0.1-beta.82839e44ce': - resolution: {integrity: sha512-DIyMKLqHqXmKCbf1eYCKbWsNY3VQ2NMXAlLyAgEkM7UYffEasUfAL0othyHfGyxvM4FMwU5tm9hlKHRFPmKYew==} + '@blockscout/api-types@0.0.1-beta.089aef5': + resolution: {integrity: sha512-+GXHstSDeOdRWVyecid0mfN4gV+lqIkXhrBsEj8zMdTBS4NSRMNhbEvd6pwKxtHZRl9ndbghX2yqrlRRufN4kw==} '@blockscout/bens-types@1.7.1': resolution: {integrity: sha512-MNIvYbj1I2vcU2rb6otlRnVMIpFG2B1WDqC7HicNrt10DbM5yPZNfBcsNM+Mhk/965Aeg529Fd+BkV9zhINqjA==} - '@blockscout/contracts-info-types@1.5.2': - resolution: {integrity: sha512-hlkCzYKAsSzsNdWmmluLXsWEnt4Hnp1HsPNuXwU2uyYe9MS1jHGl3w/mxocpm8dAFg8FZC0riTQTlsxXOEHQmg==} + '@blockscout/contracts-info-types@1.5.3': + resolution: {integrity: sha512-J+n4C+H9418r2xcIJLPEpK6WJnhJVRGZVaRzpUpXmcxBkIqzsKqfxBMddaDt5uQX8OnQJyZY3fSkfoGAAfBQaw==} '@blockscout/interchain-indexer-types@1.6.0': resolution: {integrity: sha512-zWrL3tWqXqvQre9SURlheD6jxp447Ujv7tP35a94K0Ovs3bYCT/ydsUmLoZY3CrqKyRr6cTu8V/1qUql5l9Cpw==} @@ -1342,8 +1363,8 @@ packages: '@blockscout/stats-types@2.11.1': resolution: {integrity: sha512-Ti9RbekRfLR7dUnOp2dWU0VBu7uRkutnkFqKphvfxvbrY9fiXUJ/tT6Qg+OTNewK3RbuQ0O1ZPxbqMX5eRr0OQ==} - '@blockscout/tac-operation-lifecycle-types@1.1.0': - resolution: {integrity: sha512-DwbfBiOEyAqy6cR3iH/842JhVhlM5eHI341S5AwwsmZSBzg/mtGwQgoN0BTUR74XydzuwmKYKa7bcIrIuZguSA==} + '@blockscout/tac-operation-lifecycle-types@0.0.1-beta.71a05d5': + resolution: {integrity: sha512-SFHCKG+Q1c+zn91HGcPdfVoLlxmk2uD55IiILJ6VIgfg5IhHLKLeu4+eoTwByq6fMirmyy1F/ywIdRhhQBFjUw==} '@blockscout/visualizer-types@0.2.0': resolution: {integrity: sha512-gasqbEL89iH8YnH/TIEk0MBSG9SwhEJegY9tnQ1c/jFZOCYjiVkgNwm4oH0ncwCKoNX1GoKAregbkEUwDDw7FQ==} @@ -1945,6 +1966,9 @@ packages: '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} @@ -2605,6 +2629,12 @@ packages: cpu: [arm64] os: [darwin] + '@img/sharp-darwin-arm64@0.35.3': + resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + '@img/sharp-darwin-x64@0.33.5': resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2617,6 +2647,17 @@ packages: cpu: [x64] os: [darwin] + '@img/sharp-darwin-x64@0.35.3': + resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.3': + resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + engines: {node: '>=20.9.0'} + os: [freebsd] + '@img/sharp-libvips-darwin-arm64@1.0.4': resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} cpu: [arm64] @@ -2627,6 +2668,11 @@ packages: cpu: [arm64] os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.3.2': + resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + cpu: [arm64] + os: [darwin] + '@img/sharp-libvips-darwin-x64@1.0.4': resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} cpu: [x64] @@ -2637,6 +2683,11 @@ packages: cpu: [x64] os: [darwin] + '@img/sharp-libvips-darwin-x64@1.3.2': + resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-linux-arm64@1.0.4': resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} cpu: [arm64] @@ -2649,6 +2700,12 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-arm64@1.3.2': + resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-arm@1.0.5': resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} cpu: [arm] @@ -2661,18 +2718,36 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-arm@1.3.2': + resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.3.2': + resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.3.2': + resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.0.4': resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} cpu: [s390x] @@ -2685,6 +2760,12 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.3.2': + resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-x64@1.0.4': resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} cpu: [x64] @@ -2697,6 +2778,12 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-x64@1.3.2': + resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} cpu: [arm64] @@ -2709,6 +2796,12 @@ packages: os: [linux] libc: [musl] + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.0.4': resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} cpu: [x64] @@ -2721,6 +2814,12 @@ packages: os: [linux] libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-linux-arm64@0.33.5': resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2735,6 +2834,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-arm64@0.35.3': + resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-arm@0.33.5': resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2749,6 +2855,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-arm@0.35.3': + resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2756,6 +2869,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-ppc64@0.35.3': + resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2763,6 +2883,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-riscv64@0.35.3': + resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-s390x@0.33.5': resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2777,6 +2904,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-s390x@0.35.3': + resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-linux-x64@0.33.5': resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2791,6 +2925,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-x64@0.35.3': + resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-linuxmusl-arm64@0.33.5': resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2805,6 +2946,13 @@ packages: os: [linux] libc: [musl] + '@img/sharp-linuxmusl-arm64@0.35.3': + resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-linuxmusl-x64@0.33.5': resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2819,6 +2967,13 @@ packages: os: [linux] libc: [musl] + '@img/sharp-linuxmusl-x64@0.35.3': + resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-wasm32@0.33.5': resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2829,12 +2984,27 @@ packages: engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] + '@img/sharp-wasm32@0.35.3': + resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.3': + resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + '@img/sharp-win32-arm64@0.34.5': resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [win32] + '@img/sharp-win32-arm64@0.35.3': + resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + '@img/sharp-win32-ia32@0.33.5': resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2847,6 +3017,12 @@ packages: cpu: [ia32] os: [win32] + '@img/sharp-win32-ia32@0.35.3': + resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + '@img/sharp-win32-x64@0.33.5': resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2859,8 +3035,11 @@ packages: cpu: [x64] os: [win32] - '@internationalized/date@3.12.1': - resolution: {integrity: sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==} + '@img/sharp-win32-x64@0.35.3': + resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] '@internationalized/date@3.12.2': resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==} @@ -3304,6 +3483,9 @@ packages: '@next/env@16.2.6': resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==} + '@next/env@16.3.1': + resolution: {integrity: sha512-35G3xwkQUb2oETSDjFXGrVugknoayLFBh7vSE+yAcl9IP2zT9wyGwq7297AYHR11kJld807t5f8AJBs6WBzXsQ==} + '@next/eslint-plugin-next@16.2.6': resolution: {integrity: sha512-Z8l6o4JWKUl755x4R+wogD86KPeU+Ckw4K+SYG4kHeOJtRenDeK+OSbGcqZpDtbwn9DsJVdir2UxmwXuinUbUw==} @@ -3313,12 +3495,24 @@ packages: cpu: [arm64] os: [darwin] + '@next/swc-darwin-arm64@16.3.1': + resolution: {integrity: sha512-ABMIu2zQ7cnNIHm5ivKGwZwUrm0pAai3yiJ/gK/rF1c1VP9UOnj7XECbMKFdVKp9I9eMYq9NoDs1WXOoowxzJw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + '@next/swc-darwin-x64@16.2.6': resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] + '@next/swc-darwin-x64@16.3.1': + resolution: {integrity: sha512-gNG21e/UnrroeScbY/QndUEdl0mF1FRibW7BBeYUz/5ABCepjqDdEdgr592vpzMtCn/m7FTjYq3TN4TpyDnutw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + '@next/swc-linux-arm64-gnu@16.2.6': resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==} engines: {node: '>= 10'} @@ -3326,6 +3520,13 @@ packages: os: [linux] libc: [glibc] + '@next/swc-linux-arm64-gnu@16.3.1': + resolution: {integrity: sha512-6B6Lw016iwNUQuaJoraMMTLh6TwHzFUtxipSScD1F3YyymcrRWkobodRS2ftIOkF5vrs4zNlyUrTC5YZQ9Lz5w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@next/swc-linux-arm64-musl@16.2.6': resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==} engines: {node: '>= 10'} @@ -3333,6 +3534,13 @@ packages: os: [linux] libc: [musl] + '@next/swc-linux-arm64-musl@16.3.1': + resolution: {integrity: sha512-JUiPXZKK9wOhjf4MgDiH29GZLxfqOesbLtHq2pDxwH/WwscTRV2ToymnOTh1egzaZf0ueUf8T2+CeYTGHjW0Iw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@next/swc-linux-x64-gnu@16.2.6': resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==} engines: {node: '>= 10'} @@ -3340,6 +3548,13 @@ packages: os: [linux] libc: [glibc] + '@next/swc-linux-x64-gnu@16.3.1': + resolution: {integrity: sha512-Uog9jsrmIRIL/lfvIp9htmskSNC7JcQsMVucXL2V2YY1y/D9IUN3LPEafqy0zRJ2cIU1SQ0V6F6TlffQ+pLAGg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@next/swc-linux-x64-musl@16.2.6': resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==} engines: {node: '>= 10'} @@ -3347,18 +3562,37 @@ packages: os: [linux] libc: [musl] + '@next/swc-linux-x64-musl@16.3.1': + resolution: {integrity: sha512-6yy3FT13KgUFOj5H8bl8w/6nKiJwHIvbtwh1V+1acsu+7y4tJjnemSa6mhsh53BeoVrlozE+fMgZhXH46WmjMA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + '@next/swc-win32-arm64-msvc@16.2.6': resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] + '@next/swc-win32-arm64-msvc@16.3.1': + resolution: {integrity: sha512-iOoN1QecUoGNZik536U/vtK43YwgyrCsGIkth52yIkl612n+0C9MjSnJbQAikISpb+WYRooBVhaDlUW7iZoKog==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + '@next/swc-win32-x64-msvc@16.2.6': resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] + '@next/swc-win32-x64-msvc@16.3.1': + resolution: {integrity: sha512-d/k+PpAriUPaeMJJOG7HUSdqfEX46FEPWU1p3/nm2ACmXhj9hFEWdFODUBIpkuijXYkfL90qZzTqVPRp4BW/hw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@noble/ciphers@0.4.1': resolution: {integrity: sha512-QCOA9cgf3Rc33owG0AYBB9wszz+Ul2kramWN8tXG44Gyciud/tbkEqvxRF/IpqQaBpRBNi9f4jdNxqB2CQCIXg==} @@ -8083,6 +8317,7 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. cspell-config-lib@9.8.0: resolution: {integrity: sha512-gMJBAgYPvvO+uDFLUcGWaTu6/e+r8mm4GD4rQfWa/yV4F9fj+yOYLIMZqLWRvT1moHZX1FxyVvUbJcmZ1gfebg==} @@ -8633,10 +8868,6 @@ packages: resolution: {integrity: sha512-lnOnttzfrzkRx2echxJHQRB6vOAMSCzzZg79IxpC00tU42wZPuZkQxNNrrwVAxaQZIIh001l4PxVlCrBxngBzA==} hasBin: true - dotenv-cli@6.0.0: - resolution: {integrity: sha512-qXlCOi3UMDhCWFKe0yq5sg3X+pJAz+RQDiFN38AMSbUrnY3uZshSfDJUAge951OS7J9gwLZGfsBlWRSOYz/TRg==} - hasBin: true - dotenv-cli@7.4.4: resolution: {integrity: sha512-XkBYCG0tPIes+YZr4SpfFv76SQrV/LeCE8CI7JSEMi3VR9MvTihCGTOtbIexD6i2mXF+6px7trb1imVCXSNMDw==} hasBin: true @@ -8649,10 +8880,6 @@ packages: resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} engines: {node: '>=12'} - dotenv-expand@8.0.3: - resolution: {integrity: sha512-SErOMvge0ZUyWd5B0NXMQlDkN+8r+HhVUsxgOO7IoPDOdDRD2JjExpN6y3KnFR66jsJMwSn1pqIivhU5rcJiNg==} - engines: {node: '>=12'} - dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -10848,6 +11075,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + nanoid@5.1.11: resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==} engines: {node: ^18 || >=20} @@ -10916,6 +11148,27 @@ packages: sass: optional: true + next@16.3.1: + resolution: {integrity: sha512-hsAp0i7Rh+/dhe7DGIeN2YlpLM1DP4MNxti9EtDMtqcO612X81MvvEj388/oTce9U1EcEIOWDlGq0zRwrBKvuA==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + nextjs-routes@2.2.5: resolution: {integrity: sha512-F8IwFj6JRm00lz0iIo7U7B1aOEClI8flcJVZFQIvwwxIL0eQtaroTi1IAKhxJVvETNfT5pYxVnF3pF0pn16npQ==} hasBin: true @@ -11483,6 +11736,10 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} + engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} @@ -12267,6 +12524,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.2: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} @@ -12321,6 +12583,15 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.3: + resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -14844,13 +15115,13 @@ snapshots: - utf-8-validate - zod - '@blockscout/admin-rs-types@1.5.0': {} + '@blockscout/admin-rs-types@1.5.1': {} - '@blockscout/api-types@0.0.1-beta.82839e44ce': {} + '@blockscout/api-types@0.0.1-beta.089aef5': {} '@blockscout/bens-types@1.7.1': {} - '@blockscout/contracts-info-types@1.5.2': {} + '@blockscout/contracts-info-types@1.5.3': {} '@blockscout/interchain-indexer-types@1.6.0': {} @@ -14860,7 +15131,7 @@ snapshots: '@blockscout/stats-types@2.11.1': {} - '@blockscout/tac-operation-lifecycle-types@1.1.0': {} + '@blockscout/tac-operation-lifecycle-types@0.0.1-beta.71a05d5': {} '@blockscout/visualizer-types@0.2.0': {} @@ -16084,6 +16355,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 @@ -16881,6 +17157,11 @@ snapshots: '@img/sharp-libvips-darwin-arm64': 1.2.4 optional: true + '@img/sharp-darwin-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.2 + optional: true + '@img/sharp-darwin-x64@0.33.5': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.0.4 @@ -16891,60 +17172,100 @@ snapshots: '@img/sharp-libvips-darwin-x64': 1.2.4 optional: true + '@img/sharp-darwin-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.2 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + '@img/sharp-libvips-darwin-arm64@1.0.4': optional: true '@img/sharp-libvips-darwin-arm64@1.2.4': optional: true + '@img/sharp-libvips-darwin-arm64@1.3.2': + optional: true + '@img/sharp-libvips-darwin-x64@1.0.4': optional: true '@img/sharp-libvips-darwin-x64@1.2.4': optional: true + '@img/sharp-libvips-darwin-x64@1.3.2': + optional: true + '@img/sharp-libvips-linux-arm64@1.0.4': optional: true '@img/sharp-libvips-linux-arm64@1.2.4': optional: true + '@img/sharp-libvips-linux-arm64@1.3.2': + optional: true + '@img/sharp-libvips-linux-arm@1.0.5': optional: true '@img/sharp-libvips-linux-arm@1.2.4': optional: true + '@img/sharp-libvips-linux-arm@1.3.2': + optional: true + '@img/sharp-libvips-linux-ppc64@1.2.4': optional: true + '@img/sharp-libvips-linux-ppc64@1.3.2': + optional: true + '@img/sharp-libvips-linux-riscv64@1.2.4': optional: true + '@img/sharp-libvips-linux-riscv64@1.3.2': + optional: true + '@img/sharp-libvips-linux-s390x@1.0.4': optional: true '@img/sharp-libvips-linux-s390x@1.2.4': optional: true + '@img/sharp-libvips-linux-s390x@1.3.2': + optional: true + '@img/sharp-libvips-linux-x64@1.0.4': optional: true '@img/sharp-libvips-linux-x64@1.2.4': optional: true + '@img/sharp-libvips-linux-x64@1.3.2': + optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': optional: true '@img/sharp-libvips-linuxmusl-arm64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + optional: true + '@img/sharp-libvips-linuxmusl-x64@1.0.4': optional: true '@img/sharp-libvips-linuxmusl-x64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + optional: true + '@img/sharp-linux-arm64@0.33.5': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.0.4 @@ -16955,6 +17276,11 @@ snapshots: '@img/sharp-libvips-linux-arm64': 1.2.4 optional: true + '@img/sharp-linux-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.2 + optional: true + '@img/sharp-linux-arm@0.33.5': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.0.5 @@ -16965,16 +17291,31 @@ snapshots: '@img/sharp-libvips-linux-arm': 1.2.4 optional: true + '@img/sharp-linux-arm@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.2 + optional: true + '@img/sharp-linux-ppc64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-ppc64': 1.2.4 optional: true + '@img/sharp-linux-ppc64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.2 + optional: true + '@img/sharp-linux-riscv64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-riscv64': 1.2.4 optional: true + '@img/sharp-linux-riscv64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.2 + optional: true + '@img/sharp-linux-s390x@0.33.5': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.0.4 @@ -16985,6 +17326,11 @@ snapshots: '@img/sharp-libvips-linux-s390x': 1.2.4 optional: true + '@img/sharp-linux-s390x@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.2 + optional: true + '@img/sharp-linux-x64@0.33.5': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.0.4 @@ -16995,6 +17341,11 @@ snapshots: '@img/sharp-libvips-linux-x64': 1.2.4 optional: true + '@img/sharp-linux-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.2 + optional: true + '@img/sharp-linuxmusl-arm64@0.33.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 @@ -17005,6 +17356,11 @@ snapshots: '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 optional: true + '@img/sharp-linuxmusl-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + optional: true + '@img/sharp-linuxmusl-x64@0.33.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.0.4 @@ -17015,6 +17371,11 @@ snapshots: '@img/sharp-libvips-linuxmusl-x64': 1.2.4 optional: true + '@img/sharp-linuxmusl-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + optional: true + '@img/sharp-wasm32@0.33.5': dependencies: '@emnapi/runtime': 1.10.0 @@ -17025,24 +17386,39 @@ snapshots: '@emnapi/runtime': 1.10.0 optional: true + '@img/sharp-wasm32@0.35.3': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + '@img/sharp-win32-arm64@0.34.5': optional: true + '@img/sharp-win32-arm64@0.35.3': + optional: true + '@img/sharp-win32-ia32@0.33.5': optional: true '@img/sharp-win32-ia32@0.34.5': optional: true + '@img/sharp-win32-ia32@0.35.3': + optional: true + '@img/sharp-win32-x64@0.33.5': optional: true '@img/sharp-win32-x64@0.34.5': optional: true - '@internationalized/date@3.12.1': - dependencies: - '@swc/helpers': 0.5.23 + '@img/sharp-win32-x64@0.35.3': + optional: true '@internationalized/date@3.12.2': dependencies: @@ -17127,7 +17503,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 20.16.7 + '@types/node': 22.12.0 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -18127,6 +18503,8 @@ snapshots: '@next/env@16.2.6': {} + '@next/env@16.3.1': {} + '@next/eslint-plugin-next@16.2.6': dependencies: fast-glob: 3.3.1 @@ -18134,27 +18512,51 @@ snapshots: '@next/swc-darwin-arm64@16.2.6': optional: true + '@next/swc-darwin-arm64@16.3.1': + optional: true + '@next/swc-darwin-x64@16.2.6': optional: true + '@next/swc-darwin-x64@16.3.1': + optional: true + '@next/swc-linux-arm64-gnu@16.2.6': optional: true + '@next/swc-linux-arm64-gnu@16.3.1': + optional: true + '@next/swc-linux-arm64-musl@16.2.6': optional: true + '@next/swc-linux-arm64-musl@16.3.1': + optional: true + '@next/swc-linux-x64-gnu@16.2.6': optional: true + '@next/swc-linux-x64-gnu@16.3.1': + optional: true + '@next/swc-linux-x64-musl@16.2.6': optional: true + '@next/swc-linux-x64-musl@16.3.1': + optional: true + '@next/swc-win32-arm64-msvc@16.2.6': optional: true + '@next/swc-win32-arm64-msvc@16.3.1': + optional: true + '@next/swc-win32-x64-msvc@16.2.6': optional: true + '@next/swc-win32-x64-msvc@16.3.1': + optional: true + '@noble/ciphers@0.4.1': {} '@noble/ciphers@1.2.1': {} @@ -19749,7 +20151,7 @@ snapshots: metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) metro-config: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) metro-core: 0.84.4 - semver: 7.8.4 + semver: 7.8.5 transitivePeerDependencies: - bufferutil - supports-color @@ -25760,7 +26162,7 @@ snapshots: chrome-launcher@0.15.2: dependencies: - '@types/node': 20.16.7 + '@types/node': 22.12.0 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -25771,7 +26173,7 @@ snapshots: chromium-edge-launcher@0.3.0: dependencies: - '@types/node': 20.16.7 + '@types/node': 22.12.0 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -26625,13 +27027,6 @@ snapshots: dotenv-expand: 11.0.7 minimist: 1.2.8 - dotenv-cli@6.0.0: - dependencies: - cross-spawn: 7.0.6 - dotenv: 16.6.1 - dotenv-expand: 8.0.3 - minimist: 1.2.8 - dotenv-cli@7.4.4: dependencies: cross-spawn: 7.0.6 @@ -26645,8 +27040,6 @@ snapshots: dependencies: dotenv: 16.6.1 - dotenv-expand@8.0.3: {} - dotenv@16.6.1: {} dotenv@17.3.1: {} @@ -28671,7 +29064,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 20.16.7 + '@types/node': 22.12.0 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -28688,13 +29081,13 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 20.16.7 + '@types/node': 22.12.0 merge-stream: 2.0.0 supports-color: 8.1.1 jest-worker@29.7.0: dependencies: - '@types/node': 20.16.7 + '@types/node': 22.12.0 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -29380,6 +29773,8 @@ snapshots: nanoid@3.3.12: {} + nanoid@3.3.18: {} + nanoid@5.1.11: {} napi-build-utils@2.0.0: {} @@ -29398,13 +29793,13 @@ snapshots: netmask@2.1.1: {} - next-sitemap@4.2.3(next@16.2.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.57.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)): + next-sitemap@4.2.3(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.57.0)(@types/node@20.16.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)): dependencies: '@corex/deepmerge': 4.0.43 '@next/env': 13.5.11 fast-glob: 3.3.3 minimist: 1.2.8 - next: 16.2.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.57.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + next: 16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.57.0)(@types/node@20.16.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) next-themes@0.4.4(react-dom@19.1.4(react@19.1.4))(react@19.1.4): dependencies: @@ -29437,10 +29832,37 @@ snapshots: - '@babel/core' - babel-plugin-macros - nextjs-routes@2.2.5(next@16.2.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.57.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)): + next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.57.0)(@types/node@20.16.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4): + dependencies: + '@next/env': 16.3.1 + '@swc/helpers': 0.5.23 + baseline-browser-mapping: 2.10.32 + caniuse-lite: 1.0.30001793 + postcss: 8.5.23 + react: 19.1.4 + react-dom: 19.1.4(react@19.1.4) + styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.1.4) + optionalDependencies: + '@next/swc-darwin-arm64': 16.3.1 + '@next/swc-darwin-x64': 16.3.1 + '@next/swc-linux-arm64-gnu': 16.3.1 + '@next/swc-linux-arm64-musl': 16.3.1 + '@next/swc-linux-x64-gnu': 16.3.1 + '@next/swc-linux-x64-musl': 16.3.1 + '@next/swc-win32-arm64-msvc': 16.3.1 + '@next/swc-win32-x64-msvc': 16.3.1 + '@opentelemetry/api': 1.9.1 + '@playwright/test': 1.57.0 + sharp: 0.35.3(@types/node@20.16.7) + transitivePeerDependencies: + - '@babel/core' + - '@types/node' + - babel-plugin-macros + + nextjs-routes@2.2.5(next@16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.57.0)(@types/node@20.16.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4)): dependencies: chokidar: 4.0.3 - next: 16.2.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.57.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + next: 16.3.1(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.57.0)(@types/node@20.16.7)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) no-case@3.0.4: dependencies: @@ -30095,6 +30517,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.23: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postgres-array@2.0.0: {} postgres-bytea@1.0.1: {} @@ -30363,7 +30791,7 @@ snapshots: react-aria@3.48.0(react-dom@19.1.4(react@19.1.4))(react@19.1.4): dependencies: - '@internationalized/date': 3.12.1 + '@internationalized/date': 3.12.2 '@internationalized/number': 3.6.6 '@internationalized/string': 3.2.8 '@react-types/shared': 3.34.0(react@19.1.4) @@ -30535,7 +30963,7 @@ snapshots: react-refresh: 0.14.2 regenerator-runtime: 0.13.11 scheduler: 0.27.0 - semver: 7.8.4 + semver: 7.8.5 stacktrace-parser: 0.1.11 tinyglobby: 0.2.17 whatwg-fetch: 3.6.20 @@ -30605,7 +31033,7 @@ snapshots: react-stately@3.46.0(react@19.1.4): dependencies: - '@internationalized/date': 3.12.1 + '@internationalized/date': 3.12.2 '@internationalized/number': 3.6.6 '@internationalized/string': 3.2.8 '@react-types/shared': 3.34.0(react@19.1.4) @@ -30998,6 +31426,8 @@ snapshots: semver@7.8.4: {} + semver@7.8.5: {} + send@0.19.2: dependencies: debug: 2.6.9 @@ -31128,6 +31558,40 @@ snapshots: '@img/sharp-win32-x64': 0.34.5 optional: true + sharp@0.35.3(@types/node@20.16.7): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + '@types/node': 20.16.7 + optional: true + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 21f3a9738f3..8306071f981 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -64,6 +64,3 @@ minimumReleaseAgeExclude: - '@blockscout/tac-operation-lifecycle-types' - '@blockscout/visualizer-types' - '@blockscout/zetachain-cctx-types' - # remove after 27.07.2026 - - '@chakra-ui/cli@3.36.1' - - '@chakra-ui/react@3.36.1' diff --git a/public/icons/name.d.ts b/public/icons/name.d.ts index 97e717339ca..e86e0f18072 100644 --- a/public/icons/name.d.ts +++ b/public/icons/name.d.ts @@ -29,6 +29,7 @@ | "brands/tac" | "brands/ton" | "burger" + | "calendar" | "certified" | "check" | "checkered_flag" @@ -161,7 +162,6 @@ | "revoke" | "rocket_xl" | "rocket" - | "RPC" | "scam" | "scope" | "score/score-not-ok" diff --git a/src/api/CONTEXT.md b/src/api/CONTEXT.md index 81990d0203e..25999bd3bff 100644 --- a/src/api/CONTEXT.md +++ b/src/api/CONTEXT.md @@ -21,8 +21,9 @@ The frontend never hardcodes a full API URL. A request is assembled as: object per service from `NEXT_PUBLIC_*` env vars; the env-var → field mapping changes, so the file is the source of truth. - **Every deployed instance exposes its full public config** at - **`GET <host>/node-api/config`** (`{ envs: { …all NEXT_PUBLIC_* } }`; served by - `src/pages/api/config.ts`) — the canonical source for those values on a live instance. + **`GET <host>/node-api/config`** (`{ envs: { …all NEXT_PUBLIC_* } }`, plus the start-up variables + allowlisted in `src/pages/api/config.ts`, which serves it) — the canonical source for those values + on a live instance. It also carries the "secret-ish" public keys (WalletConnect, reCAPTCHA, GA) by design; treat it as a config source, not secrets. - **The `/node-api/proxy` rewrite is a browser-CORS workaround only.** In local dev / @@ -88,7 +89,10 @@ per-service package/repo/workflow mapping table and the `gh` dispatch procedure. embed data the Core API merely proxies from a micro-service and doesn't fully describe in its own OpenAPI spec — it doesn't know those shapes (e.g. the `tac_operation` field in the search-result variant with `type: 'tac_operation'`). In the generated `@blockscout/api-types` schema such data - therefore shows up only as **optional properties** or **loose members of a type union**. + therefore shows up only as **optional properties** or **loose members of a type union**. The same applies + when Core *does* describe the shape but the generated types can't express it — e.g. a proxied object whose + own `type` field collides with the discriminator of the union it sits in, which `openapi-typescript` + resolves by overwriting the object's field. The precise shape is owned by the **feature** and must live under `src/features/**/types/api.ts` (the feature owns the rendering, so it owns the type). A slice may then **consolidate** these feature types with the generated schema — swapping a diff --git a/src/api/resources/services/core/block.ts b/src/api/resources/services/core/block.ts index b4c89a7467a..32e55aa45dc 100644 --- a/src/api/resources/services/core/block.ts +++ b/src/api/resources/services/core/block.ts @@ -3,7 +3,7 @@ import type { ApiResource } from '../../types'; import type { paths } from '@blockscout/api-types'; import type { TxsWithBlobsFilters } from 'src/features/data-availability/types/api'; -import type { BlockCountdownResponse, BlockFilters } from 'src/slices/block/types/api'; +import type { BlockFilters } from 'src/slices/block/types/api'; export const CORE_API_BLOCK_RESOURCES = { blocks: { @@ -15,6 +15,10 @@ export const CORE_API_BLOCK_RESOURCES = { path: '/api/v2/blocks/:height_or_hash', pathParams: [ 'height_or_hash' as const ], }, + block_countdown: { + path: '/api/v2/blocks/:height/countdown', + pathParams: [ 'height' as const ], + }, block_txs: { path: '/api/v2/blocks/:height_or_hash/transactions', pathParams: [ 'height_or_hash' as const ], @@ -46,7 +50,7 @@ export type CoreApiBlockResourceName = `core:${ keyof typeof CORE_API_BLOCK_RESO export type CoreApiBlockResourcePayload<R extends CoreApiBlockResourceName> = R extends 'core:blocks' ? paths['/api/v2/blocks']['get'] : R extends 'core:block' ? paths['/api/v2/blocks/{block_hash_or_number_param}']['get'] : -R extends 'core:block_countdown' ? BlockCountdownResponse : +R extends 'core:block_countdown' ? paths['/api/v2/blocks/{block_number_param}/countdown']['get'] : R extends 'core:block_txs' ? paths['/api/v2/blocks/{block_hash_or_number_param}/transactions']['get'] : R extends 'core:block_internal_txs' ? paths['/api/v2/blocks/{block_hash_or_number_param}/internal-transactions']['get'] : R extends 'core:block_withdrawals' ? paths['/api/v2/blocks/{block_hash_or_number_param}/withdrawals']['get'] : diff --git a/src/api/resources/services/core/index.ts b/src/api/resources/services/core/index.ts index 9ec75bc7ac2..78c5e8a315a 100644 --- a/src/api/resources/services/core/index.ts +++ b/src/api/resources/services/core/index.ts @@ -45,7 +45,6 @@ import type { import { CORE_API_TOKEN_RESOURCES } from './token'; import type { CoreApiTxResourceName, CoreApiTxResourcePayload, CoreApiTxPaginationFilters } from './tx'; import { CORE_API_TX_RESOURCES } from './tx'; -import type { CoreApiV1ResourceName, CoreApiV1ResourcePayload } from './v1'; import { CORE_API_V1_RESOURCES } from './v1'; export const CORE_API_RESOURCES = { @@ -72,7 +71,6 @@ R extends CoreApiMiscResourceName ? CoreApiMiscResourcePayload<R> : R extends CoreApiRollupResourceName ? CoreApiRollupResourcePayload<R> : R extends CoreApiTokenResourceName ? CoreApiTokenResourcePayload<R> : R extends CoreApiTxResourceName ? CoreApiTxResourcePayload<R> : -R extends CoreApiV1ResourceName ? CoreApiV1ResourcePayload<R> : never; /* eslint-enable @stylistic/indent */ diff --git a/src/api/resources/services/core/tx.ts b/src/api/resources/services/core/tx.ts index b119083f234..b2920cd2ab8 100644 --- a/src/api/resources/services/core/tx.ts +++ b/src/api/resources/services/core/tx.ts @@ -31,6 +31,10 @@ export const CORE_API_TX_RESOURCES = { path: '/api/v2/transactions/:hash', pathParams: [ 'hash' as const ], }, + tx_preview: { + path: '/api/v2/transactions/:hash/preview', + pathParams: [ 'hash' as const ], + }, tx_internal_txs: { path: '/api/v2/transactions/:hash/internal-transactions', pathParams: [ 'hash' as const ], @@ -92,6 +96,7 @@ R extends 'core:txs_stats' ? paths['/api/v2/transactions/stats']['get'] : R extends 'core:txs_watchlist' ? paths['/api/v2/transactions/watchlist']['get'] : R extends 'core:txs_execution_node' ? paths['/api/v2/transactions/execution-node/{execution_node_hash_param}']['get'] : R extends 'core:tx' ? paths['/api/v2/transactions/{transaction_hash_param}']['get'] : +R extends 'core:tx_preview' ? paths['/api/v2/transactions/{transaction_hash_param}/preview']['get'] : R extends 'core:tx_logs' ? paths['/api/v2/transactions/{transaction_hash_param}/logs']['get'] : R extends 'core:tx_token_transfers' ? paths['/api/v2/transactions/{transaction_hash_param}/token-transfers']['get'] : R extends 'core:tx_internal_txs' ? paths['/api/v2/transactions/{transaction_hash_param}/internal-transactions']['get'] : diff --git a/src/api/resources/services/core/v1.ts b/src/api/resources/services/core/v1.ts index 43b25563a64..1ca8535fe0d 100644 --- a/src/api/resources/services/core/v1.ts +++ b/src/api/resources/services/core/v1.ts @@ -1,21 +1,11 @@ // SPDX-License-Identifier: LicenseRef-Blockscout import type { ApiResource } from '../../types'; -import type { BlockCountdownResponse } from 'src/slices/block/types/api'; export const CORE_API_V1_RESOURCES = { graphql: { path: '/api/v1/graphql', }, - block_countdown: { - path: '/api', - }, } satisfies Record<string, ApiResource>; export type CoreApiV1ResourceName = `core:${ keyof typeof CORE_API_V1_RESOURCES }`; - -/* eslint-disable @stylistic/indent */ -export type CoreApiV1ResourcePayload<R extends CoreApiV1ResourceName> = -R extends 'core:block_countdown' ? BlockCountdownResponse : -never; -/* eslint-enable @stylistic/indent */ diff --git a/src/api/resources/services/tac-operation-lifecycle.ts b/src/api/resources/services/tac-operation-lifecycle.ts index 4c5e11b63f4..1025b89a9c4 100644 --- a/src/api/resources/services/tac-operation-lifecycle.ts +++ b/src/api/resources/services/tac-operation-lifecycle.ts @@ -5,31 +5,27 @@ import type * as tac from '@blockscout/tac-operation-lifecycle-types'; export const TAC_OPERATION_LIFECYCLE_API_RESOURCES = { operations: { - path: '/api/v1/tac/operations', + path: '/api/v2/tac/operations', paginated: true, filterFields: [ 'q' ], }, operation: { - path: '/api/v1/tac/operations/:id', + path: '/api/v2/tac/operations/:id', pathParams: [ 'id' ], }, operation_by_tx_hash: { - path: '/api/v1/tac/operations\\:byTx/:tx_hash', + path: '/api/v2/tac/operations\\:byTx/:tx_hash', pathParams: [ 'tx_hash' ], }, - stat_operations: { - path: '/api/v1/stat/operations', - }, } satisfies Record<string, ApiResource>; export type TacOperationLifecycleApiResourceName = `tac:${ keyof typeof TAC_OPERATION_LIFECYCLE_API_RESOURCES }`; /* eslint-disable @stylistic/indent */ export type TacOperationLifecycleApiResourcePayload<R extends TacOperationLifecycleApiResourceName> = -R extends 'tac:operations' ? tac.OperationsResponse : -R extends 'tac:operation' ? tac.OperationDetails : -R extends 'tac:operation_by_tx_hash' ? tac.OperationsFullResponse : -R extends 'tac:stat_operations' ? tac.GetOperationStatisticsResponse : +R extends 'tac:operations' ? tac.V2OperationsResponse : +R extends 'tac:operation' ? tac.V2OperationDetails : +R extends 'tac:operation_by_tx_hash' ? tac.V2OperationsFullResponse : never; /* eslint-enable @stylistic/indent */ diff --git a/src/config/test-utils/env-presets.ts b/src/config/test-utils/env-presets.ts index 41aa73aa0fd..7fe01d9dbbc 100644 --- a/src/config/test-utils/env-presets.ts +++ b/src/config/test-utils/env-presets.ts @@ -18,7 +18,7 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = { ], arbitrumRollup: [ [ 'NEXT_PUBLIC_ROLLUP_TYPE', 'arbitrum' ], - [ 'NEXT_PUBLIC_ROLLUP_PARENT_CHAIN', '{"name":"DuckChain","baseUrl":"https://localhost:3101"}' ], + [ 'NEXT_PUBLIC_ROLLUP_PARENT_CHAIN', '{"name":"DuckChain","baseUrl":"https://localhost:3101","id":11155111,"rpcUrls":["https://localhost:3101"],"currency":{"name":"Ether","symbol":"ETH","decimals":18},"isTestnet":true}' ], [ 'NEXT_PUBLIC_ROLLUP_DA_CELESTIA_NAMESPACE', '0x1234' ], [ 'NEXT_PUBLIC_ROLLUP_DA_CELESTIA_CELENIUM_URL', 'https://mocha.celenium.io/blob' ], ], @@ -136,4 +136,8 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = { proApi: [ [ 'NEXT_PUBLIC_PRO_API_SUPPORTED', 'true' ], ], + verifiedAddresses: [ + [ 'NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED', 'true' ], + [ 'NEXT_PUBLIC_TOKEN_INFO_EXPEDITED_REVIEW_HTML', 'Send <b>99 USDC/USDT</b> to one of the addresses below:<br>Duck Chain: 0xFB74767C1ce1aadA0a0E114441173b57f8C1571b<br>Goose Chaing: 0x4675C7e5BaAFBFFbca748158bEcBA61ef3b0a263' ], + ], }; diff --git a/src/features/account/components/user-profile/auth0/UserProfileContent.tsx b/src/features/account/components/user-profile/auth0/UserProfileContent.tsx index b398c1d4862..f642e2e6e8f 100644 --- a/src/features/account/components/user-profile/auth0/UserProfileContent.tsx +++ b/src/features/account/components/user-profile/auth0/UserProfileContent.tsx @@ -49,7 +49,7 @@ const navLinks: Array<NavLink> = [ href: route({ pathname: '/account/custom-abi' }), icon: 'ABI' as const, }, - getFeaturePayload(config.features.account)?.addressVerificationEnabled && { + getFeaturePayload(config.features.account)?.verifiedAddresses?.isEnabled && { text: 'Verified addrs', href: route({ pathname: '/account/verified-addresses' }), icon: 'verified' as const, diff --git a/src/features/account/config.ts b/src/features/account/config.ts index db2983aaa20..794cfbc4332 100644 --- a/src/features/account/config.ts +++ b/src/features/account/config.ts @@ -33,7 +33,10 @@ const config: Feature<{ apiKeys: { alertMessage: string | undefined; }; - addressVerificationEnabled: boolean; + verifiedAddresses?: { + isEnabled: boolean; + expeditedReviewHtml: string | undefined; + }; }> = (() => { if ( @@ -44,6 +47,12 @@ const config: Feature<{ const dynamicEnvironmentId = getEnvValue('NEXT_PUBLIC_ACCOUNT_DYNAMIC_ENVIRONMENT_ID'); const addressVerificationEnabled = !app.isPrivateMode && verifiedTokens.isEnabled && apis.admin !== undefined; + const expeditedReviewHtml = getEnvValue('NEXT_PUBLIC_TOKEN_INFO_EXPEDITED_REVIEW_HTML'); + const verifiedAddresses = addressVerificationEnabled ? { + isEnabled: true, + expeditedReviewHtml, + } : undefined; + if (authProvider === 'dynamic' && dynamicEnvironmentId) { return Object.freeze({ title, @@ -55,7 +64,7 @@ const config: Feature<{ apiKeys: { alertMessage: apiKeysAlertMessage, }, - addressVerificationEnabled, + verifiedAddresses, }); } @@ -67,7 +76,7 @@ const config: Feature<{ apiKeys: { alertMessage: apiKeysAlertMessage, }, - addressVerificationEnabled, + verifiedAddresses, }); } } diff --git a/src/features/account/hooks/useSignInWithWallet.ts b/src/features/account/hooks/useSignInWithWallet.ts index 12f66cd56ac..5300a3d80da 100644 --- a/src/features/account/hooks/useSignInWithWallet.ts +++ b/src/features/account/hooks/useSignInWithWallet.ts @@ -158,7 +158,7 @@ function useSignInWithWallet({ onSuccess, onError, source = 'Login', isAuth, log proceedToAuth(web3Wallet.address); } else { isConnectingWalletRef.current = true; - web3Wallet.openModal(); + web3Wallet.connect(); } }, [ proceedToAuth, web3Wallet ]); diff --git a/src/features/account/mocks/verified-addresses.ts b/src/features/account/mocks/verified-addresses.ts index 6fb9a6d8d17..d807f35be5d 100644 --- a/src/features/account/mocks/verified-addresses.ts +++ b/src/features/account/mocks/verified-addresses.ts @@ -44,6 +44,7 @@ export const ADDRESS_CHECK_RESPONSE = { signingMessage: '[eth-goerli.blockscout.com] [2023-04-18 18:47:40] I, hereby verify that I am the owner/creator of the address [0xf822070d07067d1519490dbf49448a7e30ee9ea5]', contractCreator: '0xd0e3010d1ecdbd17aae178b2bf36eb413d8a7441', contractOwner: '0xa8FCe579a11E551635b9c9CB915BEcd873C51254', + contractDeployer: '0xc9f2ba039a3827386604d9338b01e6ab131e5784', }, }, SOURCE_CODE_NOT_VERIFIED_ERROR: { diff --git a/src/features/account/pages/api-keys/ApiKeys.tsx b/src/features/account/pages/api-keys/ApiKeys.tsx index 0ff4887b06c..bb732be36bc 100644 --- a/src/features/account/pages/api-keys/ApiKeys.tsx +++ b/src/features/account/pages/api-keys/ApiKeys.tsx @@ -14,9 +14,10 @@ import useRedirectForInvalidAuthToken from 'src/features/account/hooks/useRedire import { API_KEY } from 'src/features/account/stubs'; import config from 'src/config'; -import AlertWithExternalHtml from 'src/shared/alerts/AlertWithExternalHtml'; import ApiFetchAlert from 'src/shared/alerts/ApiFetchAlert'; +import { Alert } from 'src/toolkit/chakra/alert'; +import { BoxHtml } from 'src/toolkit/chakra/box'; import { Button } from 'src/toolkit/chakra/button'; import { Link } from 'src/toolkit/chakra/link'; import { Skeleton } from 'src/toolkit/chakra/skeleton'; @@ -110,9 +111,11 @@ const ApiKeysPage: React.FC = () => { const canAdd = !isPlaceholderData ? (data?.length || 0) < DATA_LIMIT : true; - const alert = feature.isEnabled && feature.apiKeys.alertMessage ? - <AlertWithExternalHtml html={ feature.apiKeys.alertMessage } status="warning" mb={ 6 }/> : - null; + const alert = feature.isEnabled && feature.apiKeys.alertMessage ? ( + <Alert status="warning" mb={ 6 }> + <BoxHtml html={ feature.apiKeys.alertMessage }/> + </Alert> + ) : null; const button = !config.chain.isProApiSupported ? ( <Button diff --git a/src/features/account/pages/verified-addresses/address-verification/steps/AddressVerificationStepSignature.tsx b/src/features/account/pages/verified-addresses/address-verification/steps/AddressVerificationStepSignature.tsx index 0ddeaeb1b71..31530fe8802 100644 --- a/src/features/account/pages/verified-addresses/address-verification/steps/AddressVerificationStepSignature.tsx +++ b/src/features/account/pages/verified-addresses/address-verification/steps/AddressVerificationStepSignature.tsx @@ -41,7 +41,9 @@ interface Props extends AddressVerificationFormFirstStepFields, contractsInfo.Pr noWeb3Provider?: boolean; } -const AddressVerificationStepSignature = ({ address, signingMessage, contractCreator, contractOwner, onContinue, noWeb3Provider }: Props) => { +const AddressVerificationStepSignature = ( + { address, signingMessage, contractCreator, contractOwner, contractDeployer, onContinue, noWeb3Provider }: Props, +) => { const [ signMethod, setSignMethod ] = React.useState<SignMethod>(noWeb3Provider ? 'manual' : 'wallet'); const { isConnected } = useAccount(); @@ -177,12 +179,12 @@ const AddressVerificationStepSignature = ({ address, signingMessage, contractCre } case contractsInfo.VerifyAddressResponse_Status.INVALID_SIGNER_ERROR: { const signer = shortenString(formState.errors.root.message || ''); - const expectedSigners = [ contractCreator, contractOwner ].filter(Boolean).map(s => shortenString(s)).join(', '); + const expectedSigners = [ contractCreator, contractOwner, contractDeployer ].filter(Boolean).map(s => shortenString(s)).join(', '); return ( <Box> <span>This address </span> <span>{ signer }</span> - <span> is not a creator/owner of the requested contract and cannot claim ownership. Only </span> + <span> is not a creator/owner/deployer of the requested contract and cannot claim ownership. Only </span> <span>{ expectedSigners }</span> <span> can verify ownership of this contract.</span> </Box> @@ -216,7 +218,7 @@ const AddressVerificationStepSignature = ({ address, signingMessage, contractCre { contactUsLink } <span> for further assistance.</span> </Box> - { (contractOwner || contractCreator) && ( + { (contractOwner || contractCreator || contractDeployer) && ( <Flex flexDir="column" rowGap={ 4 } mb={ 4 }> { contractCreator && ( <Box> @@ -230,6 +232,12 @@ const AddressVerificationStepSignature = ({ address, signingMessage, contractCre <chakra.span>{ contractOwner }</chakra.span> </Box> ) } + { contractDeployer && ( + <Box> + <chakra.span fontWeight={ 600 }>Contract deployer: </chakra.span> + <chakra.span>{ contractDeployer }</chakra.span> + </Box> + ) } </Flex> ) } <Flex rowGap={ 5 } flexDir="column"> diff --git a/src/features/account/pages/verified-addresses/address-verification/steps/__screenshots__/AddressVerificationStepSignature.pw.tsx_default_INVALID-SIGNER-ERROR-view-mobile-1.png b/src/features/account/pages/verified-addresses/address-verification/steps/__screenshots__/AddressVerificationStepSignature.pw.tsx_default_INVALID-SIGNER-ERROR-view-mobile-1.png index bb925d0c66b..15cf8ae902a 100644 Binary files a/src/features/account/pages/verified-addresses/address-verification/steps/__screenshots__/AddressVerificationStepSignature.pw.tsx_default_INVALID-SIGNER-ERROR-view-mobile-1.png and b/src/features/account/pages/verified-addresses/address-verification/steps/__screenshots__/AddressVerificationStepSignature.pw.tsx_default_INVALID-SIGNER-ERROR-view-mobile-1.png differ diff --git a/src/features/account/pages/verified-addresses/address-verification/steps/__screenshots__/AddressVerificationStepSignature.pw.tsx_mobile_INVALID-SIGNER-ERROR-view-mobile-1.png b/src/features/account/pages/verified-addresses/address-verification/steps/__screenshots__/AddressVerificationStepSignature.pw.tsx_mobile_INVALID-SIGNER-ERROR-view-mobile-1.png index f27ddd3f7ce..cab77c1f061 100644 Binary files a/src/features/account/pages/verified-addresses/address-verification/steps/__screenshots__/AddressVerificationStepSignature.pw.tsx_mobile_INVALID-SIGNER-ERROR-view-mobile-1.png and b/src/features/account/pages/verified-addresses/address-verification/steps/__screenshots__/AddressVerificationStepSignature.pw.tsx_mobile_INVALID-SIGNER-ERROR-view-mobile-1.png differ diff --git a/src/features/account/pages/verified-addresses/token-info/TokenInfoExpeditedReview.tsx b/src/features/account/pages/verified-addresses/token-info/TokenInfoExpeditedReview.tsx new file mode 100644 index 00000000000..ce7c7a47fa6 --- /dev/null +++ b/src/features/account/pages/verified-addresses/token-info/TokenInfoExpeditedReview.tsx @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import { Box, Flex, GridItem, List, chakra } from '@chakra-ui/react'; +import React from 'react'; + +import type { Fields } from './types'; + +import SpriteIcon from 'src/sprite/SpriteIcon'; + +import { BoxHtml } from 'src/toolkit/chakra/box'; +import { Heading } from 'src/toolkit/chakra/heading'; +import { Link } from 'src/toolkit/chakra/link'; +import { FormFieldText } from 'src/toolkit/components/forms/fields/FormFieldText'; +import { transactionHashValidator } from 'src/toolkit/components/forms/validators/transaction'; + +const DOCS_URL = 'https://docs.blockscout.com/using-blockscout/token-info#expedited-payment-process'; + +interface Props { + html: string; + readOnly?: boolean; +} + +const TokenInfoExpeditedReview = ({ html, readOnly }: Props) => { + + const rules = React.useMemo(() => ({ + validate: { + tx_hash: transactionHashValidator, + }, + }), []); + + return ( + <GridItem + colSpan={{ base: 1, lg: 2 }} + bgColor={{ _light: 'blackAlpha.50', _dark: 'whiteAlpha.50' }} + p={ 6 } + borderRadius="md" + > + <Box textStyle="sm" wordBreak="break-word"> + <Flex alignItems="center" justifyContent="space-between"> + <Heading level="3">Need a faster review?</Heading> + <Link href={ DOCS_URL } external noIcon gap={ 2 }> + <SpriteIcon name="docs" boxSize={ 5 }/> + How it works + </Link> + </Flex> + <List.Root as="ol" listStyleType="decimal" gap={ 6 } mt={ 6 } pl={ 5 }> + <List.Item _marker={{ fontWeight: 600 }} pl={ 3 }> + <BoxHtml html={ html }/> + </List.Item> + <List.Item _marker={{ fontWeight: 600 }} pl={ 3 }> + Once payment is completed, enter the <chakra.span fontWeight={ 600 }>transaction hash</chakra.span> + <FormFieldText<Fields, 'payment_tx'> name="payment_tx" placeholder="Payment transaction hash" readOnly={ readOnly } mt={ 2 } rules={ rules }/> + </List.Item> + </List.Root> + </Box> + </GridItem> + ); +}; + +export default React.memo(TokenInfoExpeditedReview); diff --git a/src/features/account/pages/verified-addresses/token-info/TokenInfoForm.pw.tsx b/src/features/account/pages/verified-addresses/token-info/TokenInfoForm.pw.tsx index d9273122cc0..39e58d6d6d6 100644 --- a/src/features/account/pages/verified-addresses/token-info/TokenInfoForm.pw.tsx +++ b/src/features/account/pages/verified-addresses/token-info/TokenInfoForm.pw.tsx @@ -2,6 +2,7 @@ import React from 'react'; import * as mocks from 'src/features/account/mocks/verified-addresses'; +import { ENVS_MAP } from 'playwright/fixtures/mockEnvs'; import { test, expect } from 'playwright/lib'; import TokenInfoForm from './TokenInfoForm'; @@ -11,7 +12,10 @@ test.beforeEach(async({ mockApiResponse, mockAssetResponse }) => { await mockAssetResponse(mocks.TOKEN_INFO_APPLICATION_BASE.iconUrl, './playwright/mocks/image_md.jpg'); }); -test('base view +@mobile +@dark-mode', async({ render }) => { +test('base view +@mobile +@dark-mode', async({ render, mockEnvs }) => { + await mockEnvs([ + ...ENVS_MAP.verifiedAddresses, + ]); const props = { address: mocks.VERIFIED_ADDRESS.ITEM_1.contractAddress, tokenName: 'Test Token (TT)', diff --git a/src/features/account/pages/verified-addresses/token-info/TokenInfoForm.tsx b/src/features/account/pages/verified-addresses/token-info/TokenInfoForm.tsx index 88da7dd6dd8..3ddd4e6f892 100644 --- a/src/features/account/pages/verified-addresses/token-info/TokenInfoForm.tsx +++ b/src/features/account/pages/verified-addresses/token-info/TokenInfoForm.tsx @@ -13,6 +13,7 @@ import useApiQuery from 'src/api/hooks/useApiQuery'; import type { ResourceError } from 'src/api/resources'; import config from 'src/config'; +import { getFeaturePayload } from 'src/config/utils/features'; import * as mixpanel from 'src/services/mixpanel'; import ApiFetchAlert from 'src/shared/alerts/ApiFetchAlert'; @@ -30,6 +31,7 @@ import TokenInfoFieldIconUrl from './fields/TokenInfoFieldIconUrl'; import TokenInfoFieldProjectSector from './fields/TokenInfoFieldProjectSector'; import TokenInfoFieldSocialLink from './fields/TokenInfoFieldSocialLink'; import TokenInfoFieldSupport from './fields/TokenInfoFieldSupport'; +import TokenInfoExpeditedReview from './TokenInfoExpeditedReview'; import TokenInfoFormSectionHeader from './TokenInfoFormSectionHeader'; import TokenInfoFormStatusText from './TokenInfoFormStatusText'; import { getFormDefaultValues, prepareRequestBody } from './utils'; @@ -125,6 +127,8 @@ const TokenInfoForm = ({ address, tokenName, application, onSubmit }: Props) => readOnly: application?.status === 'IN_PROCESS', }; + const expeditedReviewHtml = getFeaturePayload(config.features.account)?.verifiedAddresses?.expeditedReviewHtml; + return ( <FormProvider { ...formApi }> <form noValidate onSubmit={ handleSubmit(onFormSubmit) } autoComplete="off" ref={ containerRef }> @@ -191,6 +195,10 @@ const TokenInfoForm = ({ address, tokenName, application, onSubmit }: Props) => { ...fieldProps } /> </GridItem> + + { expeditedReviewHtml && ( + <TokenInfoExpeditedReview html={ expeditedReviewHtml } readOnly={ fieldProps.readOnly }/> + ) } </Grid> <Button type="submit" diff --git a/src/features/account/pages/verified-addresses/token-info/__screenshots__/TokenInfoForm.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png b/src/features/account/pages/verified-addresses/token-info/__screenshots__/TokenInfoForm.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png index 25fccf117d3..7fc967a5ac2 100644 Binary files a/src/features/account/pages/verified-addresses/token-info/__screenshots__/TokenInfoForm.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png and b/src/features/account/pages/verified-addresses/token-info/__screenshots__/TokenInfoForm.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png differ diff --git a/src/features/account/pages/verified-addresses/token-info/__screenshots__/TokenInfoForm.pw.tsx_default_base-view-mobile-dark-mode-1.png b/src/features/account/pages/verified-addresses/token-info/__screenshots__/TokenInfoForm.pw.tsx_default_base-view-mobile-dark-mode-1.png index 152714963d3..3eb40519bcc 100644 Binary files a/src/features/account/pages/verified-addresses/token-info/__screenshots__/TokenInfoForm.pw.tsx_default_base-view-mobile-dark-mode-1.png and b/src/features/account/pages/verified-addresses/token-info/__screenshots__/TokenInfoForm.pw.tsx_default_base-view-mobile-dark-mode-1.png differ diff --git a/src/features/account/pages/verified-addresses/token-info/__screenshots__/TokenInfoForm.pw.tsx_default_status-IN-PROCESS-1.png b/src/features/account/pages/verified-addresses/token-info/__screenshots__/TokenInfoForm.pw.tsx_default_status-IN-PROCESS-1.png index 9b7d68aa9c1..2061d4e2611 100644 Binary files a/src/features/account/pages/verified-addresses/token-info/__screenshots__/TokenInfoForm.pw.tsx_default_status-IN-PROCESS-1.png and b/src/features/account/pages/verified-addresses/token-info/__screenshots__/TokenInfoForm.pw.tsx_default_status-IN-PROCESS-1.png differ diff --git a/src/features/account/pages/verified-addresses/token-info/__screenshots__/TokenInfoForm.pw.tsx_mobile_base-view-mobile-dark-mode-1.png b/src/features/account/pages/verified-addresses/token-info/__screenshots__/TokenInfoForm.pw.tsx_mobile_base-view-mobile-dark-mode-1.png index c02f1117031..818c5c93a2a 100644 Binary files a/src/features/account/pages/verified-addresses/token-info/__screenshots__/TokenInfoForm.pw.tsx_mobile_base-view-mobile-dark-mode-1.png and b/src/features/account/pages/verified-addresses/token-info/__screenshots__/TokenInfoForm.pw.tsx_mobile_base-view-mobile-dark-mode-1.png differ diff --git a/src/features/account/pages/verified-addresses/token-info/fields/TokenInfoFieldIconUrl.tsx b/src/features/account/pages/verified-addresses/token-info/fields/TokenInfoFieldIconUrl.tsx index cd4172f133e..9a2e999ad0f 100644 --- a/src/features/account/pages/verified-addresses/token-info/fields/TokenInfoFieldIconUrl.tsx +++ b/src/features/account/pages/verified-addresses/token-info/fields/TokenInfoFieldIconUrl.tsx @@ -29,6 +29,7 @@ const TokenInfoFieldIconUrl = ({ readOnly, size }: Props) => { <FormFieldUrl<Fields> name="icon_url" placeholder={ `Link to icon URL, link to download a SVG or 48${ times }48 PNG icon logo` } + required readOnly={ readOnly } size={ size } { ...imageField.input } diff --git a/src/features/account/pages/verified-addresses/token-info/types.ts b/src/features/account/pages/verified-addresses/token-info/types.ts index 958dc0b0909..1f4123b2160 100644 --- a/src/features/account/pages/verified-addresses/token-info/types.ts +++ b/src/features/account/pages/verified-addresses/token-info/types.ts @@ -14,6 +14,7 @@ export interface Fields extends SocialLinkFields, TickerUrlFields { support?: string; icon_url: string; comment?: string; + payment_tx?: string; } export interface TickerUrlFields { diff --git a/src/features/account/pages/verified-addresses/token-info/utils.ts b/src/features/account/pages/verified-addresses/token-info/utils.ts index 15baf94b270..78e1236eb3d 100644 --- a/src/features/account/pages/verified-addresses/token-info/utils.ts +++ b/src/features/account/pages/verified-addresses/token-info/utils.ts @@ -35,6 +35,7 @@ export function getFormDefaultValues(address: string, tokenName: string, applica medium: application.medium || '', reddit: application.reddit || '', comment: application.comment || '', + payment_tx: application.paymentTx || '', }; } @@ -65,5 +66,6 @@ export function prepareRequestBody(data: Fields): Omit<adminRs.TokenInfoSubmissi tokenAddress: data.address, twitter: data.twitter, comment: data.comment, + paymentTx: data.payment_tx || undefined, }; } diff --git a/src/features/chain-variants/eden/pages/tx/TxDetailsEden.tsx b/src/features/chain-variants/eden/pages/tx/TxDetailsEden.tsx new file mode 100644 index 00000000000..0a78dbb6892 --- /dev/null +++ b/src/features/chain-variants/eden/pages/tx/TxDetailsEden.tsx @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import { Flex, Grid } from '@chakra-ui/react'; +import React from 'react'; + +import type { schemas } from '@blockscout/api-types'; + +import AddressEntity from 'src/slices/address/components/entity/AddressEntity'; + +import * as DetailedInfo from 'src/shared/detailed-info/DetailedInfo'; +import CopyToClipboard from 'src/shared/texts/CopyToClipboard'; +import NativeCoinValue from 'src/shared/values/entity/NativeCoinValue'; + +import { Skeleton } from 'src/toolkit/chakra/skeleton'; +import { TruncatedText } from 'src/toolkit/components/truncation/TruncatedText'; + +/** One call of a sponsored batch transaction. `to` is `null` for a contract-creation call. */ +type TransactionEdenCall = NonNullable<schemas['TransactionResponse']['calls']>[number]; + +interface Props { + data: schemas['TransactionResponse']; + isLoading?: boolean; +} + +const HeaderItem = ({ children, isLoading }: { children: React.ReactNode; isLoading?: boolean }) => { + return ( + <Skeleton + fontWeight="semibold" + pb={ 1 } + loading={ isLoading } + > + { children } + </Skeleton> + ); +}; + +const CallRow = ({ to, value, input, isLoading }: TransactionEdenCall & { isLoading?: boolean }) => { + return ( + <> + <div> + { to ? + <AddressEntity address={{ hash: to }} isLoading={ isLoading }/> : + <Skeleton loading={ isLoading } display="inline-block"><span>[ Contract creation ]</span></Skeleton> + } + </div> + <div> + <NativeCoinValue amount={ value } loading={ isLoading }/> + </div> + <Flex alignItems="flex-start" whiteSpace="normal" wordBreak="break-all"> + <TruncatedText text={ input } loading={ isLoading }/> + <CopyToClipboard text={ input } isLoading={ isLoading }/> + </Flex> + </> + ); +}; + +const TxDetailsEden = ({ data, isLoading }: Props) => { + const { fee_payer: feePayer, calls } = data; + + if (!feePayer && !calls?.length) { + return null; + } + + return ( + <> + { feePayer && ( + <> + <DetailedInfo.ItemLabel + hint="Address that paid the transaction fee on behalf of the sender" + isLoading={ isLoading } + > + Fee payer + </DetailedInfo.ItemLabel> + <DetailedInfo.ItemValue> + <AddressEntity address={ feePayer } isLoading={ isLoading }/> + </DetailedInfo.ItemValue> + </> + ) } + + { calls && calls.length > 0 && ( + <> + <DetailedInfo.ItemLabel + hint="Ordered list of calls batched into this sponsored transaction" + isLoading={ isLoading } + > + Calls + </DetailedInfo.ItemLabel> + <DetailedInfo.ItemValue alignItems="flex-start" flexWrap="wrap"> + <Grid + gridTemplateColumns="minmax(140px, 1fr) minmax(50px, 1fr) 1fr" + textStyle="sm" + bgColor={{ _light: 'blackAlpha.50', _dark: 'whiteAlpha.50' }} + p={ 4 } + mt={ 2 } + w="100%" + columnGap={ 5 } + rowGap={ 5 } + borderRadius="md" + > + <HeaderItem isLoading={ isLoading }>To</HeaderItem> + <HeaderItem isLoading={ isLoading }>Value</HeaderItem> + <HeaderItem isLoading={ isLoading }>Input</HeaderItem> + { calls.map((call, index) => ( + // a batch can repeat the same call, so the position in the batch is the only stable key + <CallRow key={ index } { ...call } isLoading={ isLoading }/> + )) } + </Grid> + </DetailedInfo.ItemValue> + </> + ) } + </> + ); +}; + +export default React.memo(TxDetailsEden); diff --git a/src/features/chain-variants/eden/utils/batch-recipients.spec.ts b/src/features/chain-variants/eden/utils/batch-recipients.spec.ts new file mode 100644 index 00000000000..4c80e40ed6a --- /dev/null +++ b/src/features/chain-variants/eden/utils/batch-recipients.spec.ts @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import type { schemas } from '@blockscout/api-types'; + +import { describe, it, expect } from 'vitest'; + +import { getBatchRecipients, MAX_VISIBLE_RECIPIENTS } from './batch-recipients'; + +type TransactionEdenCall = NonNullable<schemas['TransactionResponse']['calls']>[number]; + +const REPEATED_ADDRESS = '0x0000000000000000000000000000000000000001'; + +const makeCall = (to: string | null): TransactionEdenCall => ({ + to, + value: '0', + input: '0x', +}); + +const makeCalls = (count: number): Array<TransactionEdenCall> => + Array.from({ length: count }, (_, index) => makeCall(`0x${ String(index).padStart(40, '0') }`)); + +describe('getBatchRecipients', () => { + describe('count', () => { + it('equals the number of distinct recipient addresses', () => { + expect(getBatchRecipients(makeCalls(3)).count).toBe(3); + }); + + it('de-duplicates repeated recipient addresses', () => { + const calls = [ makeCall(REPEATED_ADDRESS), makeCall(REPEATED_ADDRESS), makeCall(REPEATED_ADDRESS) ]; + expect(getBatchRecipients(calls).count).toBe(1); + }); + + it('counts only the distinct addresses in a mixed batch', () => { + const calls = [ makeCall(REPEATED_ADDRESS), makeCall('0x00000000000000000000000000000000000000ab'), makeCall(REPEATED_ADDRESS) ]; + expect(getBatchRecipients(calls).count).toBe(2); + }); + + it('is 0 when calls is undefined', () => { + expect(getBatchRecipients(undefined).count).toBe(0); + }); + + it('excludes contract-creation calls (null recipient) from the count', () => { + const calls = [ makeCall(REPEATED_ADDRESS), makeCall(null), makeCall(null) ]; + expect(getBatchRecipients(calls).count).toBe(1); + }); + }); + + describe('hasMultipleRecipients', () => { + it('is false for undefined, empty, and single-call batches', () => { + expect(getBatchRecipients(undefined).hasMultipleRecipients).toBe(false); + expect(getBatchRecipients([]).hasMultipleRecipients).toBe(false); + expect(getBatchRecipients(makeCalls(1)).hasMultipleRecipients).toBe(false); + }); + + it('is true once there is more than one distinct recipient', () => { + expect(getBatchRecipients(makeCalls(2)).hasMultipleRecipients).toBe(true); + }); + + it('is false for a multi-call batch when every call hits the same address', () => { + const calls = [ makeCall(REPEATED_ADDRESS), makeCall(REPEATED_ADDRESS) ]; + expect(getBatchRecipients(calls).hasMultipleRecipients).toBe(false); + }); + }); + + describe('visibleRecipients', () => { + it('drops duplicate recipients, keeping the first occurrence of each', () => { + const other = '0x00000000000000000000000000000000000000ab'; + const calls = [ makeCall(REPEATED_ADDRESS), makeCall(other), makeCall(REPEATED_ADDRESS) ]; + expect(getBatchRecipients(calls).visibleRecipients.map((call) => call.to)).toEqual([ REPEATED_ADDRESS, other ]); + }); + + it('returns every recipient when the distinct count is at or below the cap', () => { + const calls = makeCalls(MAX_VISIBLE_RECIPIENTS); + expect(getBatchRecipients(calls).visibleRecipients).toHaveLength(MAX_VISIBLE_RECIPIENTS); + }); + + it('caps the visible list at MAX_VISIBLE_RECIPIENTS', () => { + const calls = makeCalls(MAX_VISIBLE_RECIPIENTS + 3); + expect(getBatchRecipients(calls).visibleRecipients).toHaveLength(MAX_VISIBLE_RECIPIENTS); + }); + + it('omits contract-creation calls (null recipient)', () => { + const calls = [ makeCall(REPEATED_ADDRESS), makeCall(null) ]; + expect(getBatchRecipients(calls).visibleRecipients.map((call) => call.to)).toEqual([ REPEATED_ADDRESS ]); + }); + }); + + describe('hasOverflow', () => { + it('is false when the distinct-recipient count fits within the cap', () => { + expect(getBatchRecipients(makeCalls(MAX_VISIBLE_RECIPIENTS)).hasOverflow).toBe(false); + }); + + it('is true when the distinct-recipient count exceeds the cap', () => { + expect(getBatchRecipients(makeCalls(MAX_VISIBLE_RECIPIENTS + 1)).hasOverflow).toBe(true); + }); + }); +}); diff --git a/src/features/chain-variants/eden/utils/batch-recipients.ts b/src/features/chain-variants/eden/utils/batch-recipients.ts new file mode 100644 index 00000000000..2fbb2ffa869 --- /dev/null +++ b/src/features/chain-variants/eden/utils/batch-recipients.ts @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import { uniqBy } from 'es-toolkit'; + +import type { schemas } from '@blockscout/api-types'; + +type TransactionEdenCalls = NonNullable<schemas['TransactionResponse']['calls']>; +type TransactionEdenCall = TransactionEdenCalls[number]; + +interface BatchRecipient extends TransactionEdenCall { + readonly to: string; +} + +export const MAX_VISIBLE_RECIPIENTS = 5; + +const EMPTY_CALLS: TransactionEdenCalls = []; + +export interface BatchRecipients { + readonly count: number; + readonly hasMultipleRecipients: boolean; + readonly visibleRecipients: ReadonlyArray<BatchRecipient>; + readonly hasOverflow: boolean; +} + +export function getBatchRecipients(calls: TransactionEdenCalls | null | undefined): BatchRecipients { + const addressedCalls = (calls ?? EMPTY_CALLS).filter((call): call is BatchRecipient => call.to !== null); + const uniqueRecipients = uniqBy(addressedCalls, (call) => call.to); + const count = uniqueRecipients.length; + + return { + count, + hasMultipleRecipients: count > 1, + visibleRecipients: uniqueRecipients.slice(0, MAX_VISIBLE_RECIPIENTS), + hasOverflow: count > MAX_VISIBLE_RECIPIENTS, + }; +} diff --git a/src/features/chain-variants/tac/components/AddressEntityTacTon.tsx b/src/features/chain-variants/tac/components/AddressEntityTacTon.tsx index d6ca3c52c9b..2e4be5620f5 100644 --- a/src/features/chain-variants/tac/components/AddressEntityTacTon.tsx +++ b/src/features/chain-variants/tac/components/AddressEntityTacTon.tsx @@ -13,7 +13,7 @@ import config from 'src/config'; const tacFeature = config.features.tac; interface Props extends AddressEntity.EntityProps { - chainType: tac.BlockchainType | null; + chainType: tac.V2BlockchainType | null; } const AddressEntityTacTon = (props: Props) => { @@ -23,7 +23,7 @@ const AddressEntityTacTon = (props: Props) => { const href = (() => { switch (props.chainType) { - case tac.BlockchainType.TON: + case tac.V2BlockchainType.TON: return tacFeature.tonExplorerUrl + route({ pathname: '/address/[hash]', query: { @@ -31,7 +31,7 @@ const AddressEntityTacTon = (props: Props) => { hash: encodeURIComponent(props.address.hash), }, }); - case tac.BlockchainType.TAC: + case tac.V2BlockchainType.TAC: return route({ pathname: '/address/[hash]', query: { @@ -52,8 +52,8 @@ const AddressEntityTacTon = (props: Props) => { <AddressEntity.default { ...props } href={ href } - link={{ external: props.chainType === tac.BlockchainType.TON }} - icon={ props.chainType === tac.BlockchainType.TON ? { + link={{ external: props.chainType === tac.V2BlockchainType.TON }} + icon={ props.chainType === tac.V2BlockchainType.TON ? { shield: { name: 'brands/ton' }, hint: 'Address on TON', hintPostfix: ' on TON', diff --git a/src/features/chain-variants/tac/components/SearchBarSuggestTacOperation.tsx b/src/features/chain-variants/tac/components/SearchBarSuggestTacOperation.tsx index 367f7cb459e..a36c58bb634 100644 --- a/src/features/chain-variants/tac/components/SearchBarSuggestTacOperation.tsx +++ b/src/features/chain-variants/tac/components/SearchBarSuggestTacOperation.tsx @@ -9,17 +9,29 @@ import type { ItemsProps } from 'src/slices/search/components/search-bar/SearchB import Time from 'src/shared/date-and-time/Time'; import HashStringShortenDynamic from 'src/shared/texts/HashStringShortenDynamic'; +import { Badge } from 'src/toolkit/chakra/badge'; + import * as TacOperationEntity from './TacOperationEntity'; import TacOperationStatus from './TacOperationStatus'; const SearchBarSuggestTacOperation = ({ data, isMobile }: ItemsProps<SearchResultTacOperation>) => { - const icon = <TacOperationEntity.Icon type={ data.tac_operation.type }/>; + const icon = <TacOperationEntity.Icon status={ data.tac_operation.status }/>; const hash = ( <chakra.mark overflow="hidden" whiteSpace="nowrap" fontWeight={ 700 } mr={ 2 }> <HashStringShortenDynamic hash={ data.tac_operation.operation_id } noTooltip/> </chakra.mark> ); - const status = <TacOperationStatus status={ data.tac_operation.type }/>; + const status = ( + <> + <TacOperationStatus + status={ data.tac_operation.status } + type={ data.tac_operation.type } + errorReason={ data.tac_operation.error_reason } + isRollback={ data.tac_operation.rollback } + /> + { data.tac_operation.rollback && <Badge ml={ 1 }>Rollback</Badge> } + </> + ); if (isMobile) { return ( diff --git a/src/features/chain-variants/tac/components/TacOperationEntity.tsx b/src/features/chain-variants/tac/components/TacOperationEntity.tsx index 71143e5c700..ef7509f868a 100644 --- a/src/features/chain-variants/tac/components/TacOperationEntity.tsx +++ b/src/features/chain-variants/tac/components/TacOperationEntity.tsx @@ -24,11 +24,11 @@ const Link = chakra((props: LinkProps) => { ); }); -type IconProps = EntityBase.IconBaseProps & Pick<EntityProps, 'type'>; +type IconProps = EntityBase.IconBaseProps & Pick<EntityProps, 'status'>; const Icon = (props: IconProps) => { - switch (props.type) { - case tac.OperationType.PENDING: { + switch (props.status) { + case tac.V2OperationStatus.pending: { return <Spinner size="md" marginRight={ props.marginRight ?? '8px' }/>; } default: { @@ -69,7 +69,7 @@ const Container = EntityBase.Container; export interface EntityProps extends EntityBase.EntityBaseProps { id: string; - type: tac.OperationType | undefined; + status: tac.V2OperationStatus | undefined; } const TacOperationEntity = (props: EntityProps) => { diff --git a/src/features/chain-variants/tac/components/TacOperationStatus.pw.tsx b/src/features/chain-variants/tac/components/TacOperationStatus.pw.tsx deleted file mode 100644 index eebfdaa3e05..00000000000 --- a/src/features/chain-variants/tac/components/TacOperationStatus.pw.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; - -import * as tac from '@blockscout/tac-operation-lifecycle-types'; - -import { test, expect } from 'playwright/lib'; - -import TacOperationStatus from './TacOperationStatus'; - -const STATUSES: Array<tac.OperationType> = [ - tac.OperationType.TON_TAC_TON, - tac.OperationType.TAC_TON, - tac.OperationType.TON_TAC, - tac.OperationType.ERROR, - tac.OperationType.PENDING, -]; - -test.use({ viewport: { width: 200, height: 50 } }); - -STATUSES.forEach((status) => { - test(`${ status }`, async({ render }) => { - const component = await render(<TacOperationStatus status={ status }/>); - await expect(component).toHaveScreenshot(); - }); -}); diff --git a/src/features/chain-variants/tac/components/TacOperationStatus.spec.tsx b/src/features/chain-variants/tac/components/TacOperationStatus.spec.tsx new file mode 100644 index 00000000000..8c96aae77f4 --- /dev/null +++ b/src/features/chain-variants/tac/components/TacOperationStatus.spec.tsx @@ -0,0 +1,111 @@ +// @vitest-environment jsdom + +import React from 'react'; + +import * as tac from '@blockscout/tac-operation-lifecycle-types'; + +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render, screen } from 'vitest/lib'; + +import { FAILURE_TOOLTIP, getTacOperationStatusText, getTacOperationStatusTooltip, ROLLBACK_TOOLTIP } from '../utils/tac-operation'; +import TacOperationStatus from './TacOperationStatus'; + +const ERROR_REASON = 'Insufficient Fee'; + +describe('status text', () => { + it.each([ + [ tac.V2OperationStatus.pending, tac.V2OperationType.TON_TAC_TON, 'TON → TAC → TON' ], + [ tac.V2OperationStatus.pending, tac.V2OperationType.TAC_TON, 'TAC → TON' ], + [ tac.V2OperationStatus.pending, tac.V2OperationType.TON_TAC, 'TON → TAC' ], + [ tac.V2OperationStatus.success, tac.V2OperationType.TON_TAC_TON, 'TON → TAC → TON' ], + [ tac.V2OperationStatus.failed, tac.V2OperationType.TAC_TON, 'TAC → TON' ], + ])('is the route for %s / %s', (status, type, expected) => { + expect(getTacOperationStatusText(status, type)).toBe(expected); + }); + + // `UNKNOWN` means the operation id is indexed but its route is not known yet, so there is no route to show. + it.each([ + [ tac.V2OperationStatus.pending, 'Pending' ], + [ tac.V2OperationStatus.success, 'Success' ], + [ tac.V2OperationStatus.failed, 'Failed' ], + ])('falls back to the status word for UNKNOWN / %s', (status, expected) => { + expect(getTacOperationStatusText(status, tac.V2OperationType.UNKNOWN)).toBe(expected); + }); +}); + +describe('status tooltip', () => { + it.each([ + [ tac.V2OperationStatus.pending ], + [ tac.V2OperationStatus.success ], + ])('is absent for %s', (status) => { + expect(getTacOperationStatusTooltip(status, undefined, undefined)).toBeNull(); + expect(getTacOperationStatusTooltip(status, ERROR_REASON, undefined)).toBeNull(); + }); + + it('names the reason for a failure that carries one', () => { + expect(getTacOperationStatusTooltip(tac.V2OperationStatus.failed, ERROR_REASON, undefined)) + .toBe(`${ FAILURE_TOOLTIP }. ${ ERROR_REASON }`); + }); + + it.each([ + [ 'undefined', undefined ], + [ 'null', null ], + ])('falls back to the plain failure text when the reason is %s', (_, errorReason) => { + expect(getTacOperationStatusTooltip(tac.V2OperationStatus.failed, errorReason, undefined)).toBe(FAILURE_TOOLTIP); + }); + + it('prefers the rollback text over an error reason', () => { + expect(getTacOperationStatusTooltip(tac.V2OperationStatus.failed, ERROR_REASON, true)).toBe(ROLLBACK_TOOLTIP); + }); + + it('includes the rollback text when the rollback flag is present', () => { + expect(getTacOperationStatusTooltip(tac.V2OperationStatus.failed, undefined, true)).toBe(ROLLBACK_TOOLTIP); + }); +}); + +describe('rendering', () => { + afterEach(cleanup); + + it('shows the route for a pending operation', () => { + render( + <TacOperationStatus status={ tac.V2OperationStatus.pending } type={ tac.V2OperationType.TON_TAC }/>, + ); + expect(screen.getByText('TON → TAC')).toBeTruthy(); + }); + + it('shows the status word and no route when the route is unknown', () => { + render( + <TacOperationStatus status={ tac.V2OperationStatus.pending } type={ tac.V2OperationType.UNKNOWN }/>, + ); + expect(screen.getByText('Pending')).toBeTruthy(); + expect(screen.queryByText(/→/)).toBeNull(); + }); + + // `rollback` is carried by a sibling badge and by the tooltip, never by the tag's text. + it('keeps the route in the status tag for a rollback, and adds no wording of its own', () => { + render( + <TacOperationStatus + status={ tac.V2OperationStatus.failed } + type={ tac.V2OperationType.TAC_TON } + errorReason={ ERROR_REASON } + isRollback + />, + ); + expect(screen.getByText('TAC → TON')).toBeTruthy(); + expect(screen.queryByText('Rollback')).toBeNull(); + expect(screen.queryByText(ERROR_REASON)).toBeNull(); + }); + + // Core sends `null` where the service's proto omits the field, so the tag must not print it. + it('renders a null error reason without leaking it into the text', () => { + render( + <TacOperationStatus + status={ tac.V2OperationStatus.failed } + type={ tac.V2OperationType.TAC_TON } + errorReason={ null } + />, + ); + expect(screen.getByText('TAC → TON')).toBeTruthy(); + expect(screen.queryByText(/null/)).toBeNull(); + }); +}); diff --git a/src/features/chain-variants/tac/components/TacOperationStatus.tsx b/src/features/chain-variants/tac/components/TacOperationStatus.tsx index 3d80af6892b..a3747328312 100644 --- a/src/features/chain-variants/tac/components/TacOperationStatus.tsx +++ b/src/features/chain-variants/tac/components/TacOperationStatus.tsx @@ -4,46 +4,39 @@ import React from 'react'; import * as tac from '@blockscout/tac-operation-lifecycle-types'; +import type { StatusTagType } from 'src/shared/tags/status-tag/StatusTag'; import StatusTag from 'src/shared/tags/status-tag/StatusTag'; -import { Tooltip } from 'src/toolkit/chakra/tooltip'; - -import { getTacOperationStatus } from '../utils/tac-operation'; +import { getTacOperationStatusText, getTacOperationStatusTooltip } from '../utils/tac-operation'; interface Props { - status: tac.OperationType; + status: tac.V2OperationStatus; + type: tac.V2OperationType; + errorReason?: string | null; isLoading?: boolean; - noTooltip?: boolean; + isRollback?: boolean; } -const TacOperationStatus = ({ status, isLoading, noTooltip }: Props) => { - const text = getTacOperationStatus(status); - - if (!text) { - return null; - } - - switch (status) { - case tac.OperationType.ERROR: - case tac.OperationType.INSUFFICIENT_FEE: - return <StatusTag type="error" text={ text } loading={ isLoading }/>; - case tac.OperationType.ROLLBACK: - return ( - <Tooltip - // eslint-disable-next-line max-len - content="The cross‑chain operation was reverted and the original assets and state were returned to the sender after a failure on the destination chain" - disabled={ noTooltip } - > - <StatusTag type="error" text={ text } loading={ isLoading }/> - </Tooltip> - ); - case tac.OperationType.PENDING: { - return <StatusTag type="pending" text={ text } loading={ isLoading }/>; - } - default: { - return <StatusTag type="ok" text={ text } loading={ isLoading }/>; - } - } +const STATUS_TAG_TYPES: Record<tac.V2OperationStatus, StatusTagType> = { + [tac.V2OperationStatus.pending]: 'pending', + [tac.V2OperationStatus.success]: 'ok', + [tac.V2OperationStatus.failed]: 'error', + [tac.V2OperationStatus.UNRECOGNIZED]: 'pending', +}; + +/** + * One tag carrying both facts the v2 contract separates: the icon and colour come from `status`, the text + * from `type`. Keeping them combined is a standing product decision — a split into two fields was rejected. + */ +const TacOperationStatus = ({ status, type, errorReason, isLoading, isRollback }: Props) => { + return ( + <StatusTag + type={ STATUS_TAG_TYPES[status] } + text={ getTacOperationStatusText(status, type) } + errorText={ getTacOperationStatusTooltip(status, errorReason, isRollback) } + loading={ isLoading } + /> + ); }; export default React.memo(TacOperationStatus); diff --git a/src/features/chain-variants/tac/components/TacOperationTag.tsx b/src/features/chain-variants/tac/components/TacOperationTag.tsx index 70358d6d8e8..fbd498f3980 100644 --- a/src/features/chain-variants/tac/components/TacOperationTag.tsx +++ b/src/features/chain-variants/tac/components/TacOperationTag.tsx @@ -7,15 +7,20 @@ import type * as tac from '@blockscout/tac-operation-lifecycle-types'; import type { BadgeProps } from 'src/toolkit/chakra/badge'; import { Badge } from 'src/toolkit/chakra/badge'; -import { getTacOperationStatus } from '../utils/tac-operation'; +import { getTacOperationRoute } from '../utils/tac-operation'; interface Props extends BadgeProps { - type: tac.OperationType; + type: tac.V2OperationType; + isRollback?: boolean; } -const TacOperationTag = ({ type, ...rest }: Props) => { +const TacOperationTag = ({ type, isRollback, ...rest }: Props) => { - const text = getTacOperationStatus(type); + if (isRollback) { + return <Badge { ...rest }>Rollback</Badge>; + } + + const text = getTacOperationRoute(type); if (!text) { return null; diff --git a/src/features/chain-variants/tac/components/__screenshots__/TacOperationStatus.pw.tsx_default_ERROR-1.png b/src/features/chain-variants/tac/components/__screenshots__/TacOperationStatus.pw.tsx_default_ERROR-1.png deleted file mode 100644 index 183fd5e46b3..00000000000 Binary files a/src/features/chain-variants/tac/components/__screenshots__/TacOperationStatus.pw.tsx_default_ERROR-1.png and /dev/null differ diff --git a/src/features/chain-variants/tac/components/__screenshots__/TacOperationStatus.pw.tsx_default_PENDING-1.png b/src/features/chain-variants/tac/components/__screenshots__/TacOperationStatus.pw.tsx_default_PENDING-1.png deleted file mode 100644 index aa2101e385b..00000000000 Binary files a/src/features/chain-variants/tac/components/__screenshots__/TacOperationStatus.pw.tsx_default_PENDING-1.png and /dev/null differ diff --git a/src/features/chain-variants/tac/components/__screenshots__/TacOperationStatus.pw.tsx_default_TAC-TON-1.png b/src/features/chain-variants/tac/components/__screenshots__/TacOperationStatus.pw.tsx_default_TAC-TON-1.png deleted file mode 100644 index 50f4c8f4102..00000000000 Binary files a/src/features/chain-variants/tac/components/__screenshots__/TacOperationStatus.pw.tsx_default_TAC-TON-1.png and /dev/null differ diff --git a/src/features/chain-variants/tac/components/__screenshots__/TacOperationStatus.pw.tsx_default_TON-TAC-1.png b/src/features/chain-variants/tac/components/__screenshots__/TacOperationStatus.pw.tsx_default_TON-TAC-1.png deleted file mode 100644 index e90bc4f0eb4..00000000000 Binary files a/src/features/chain-variants/tac/components/__screenshots__/TacOperationStatus.pw.tsx_default_TON-TAC-1.png and /dev/null differ diff --git a/src/features/chain-variants/tac/components/__screenshots__/TacOperationStatus.pw.tsx_default_TON-TAC-TON-1.png b/src/features/chain-variants/tac/components/__screenshots__/TacOperationStatus.pw.tsx_default_TON-TAC-TON-1.png deleted file mode 100644 index 5e7068fb2c4..00000000000 Binary files a/src/features/chain-variants/tac/components/__screenshots__/TacOperationStatus.pw.tsx_default_TON-TAC-TON-1.png and /dev/null differ diff --git a/src/features/chain-variants/tac/mocks/operations.ts b/src/features/chain-variants/tac/mocks/operations.ts index b81c2b5dbb3..b9f62c96375 100644 --- a/src/features/chain-variants/tac/mocks/operations.ts +++ b/src/features/chain-variants/tac/mocks/operations.ts @@ -1,53 +1,56 @@ import * as tac from '@blockscout/tac-operation-lifecycle-types'; -export const tacOperation: tac.OperationDetails = { +export const tacOperation: tac.V2OperationDetails = { operation_id: '0x35f5d9c2bf07477ede48935c7130945faf17a3e5f69a7d20ce3725676513095c', - type: tac.OperationType.TON_TAC_TON, + type: tac.V2OperationType.TON_TAC_TON, + status: tac.V2OperationStatus.failed, + rollback: false, + error_reason: 'Insufficient Fee', timestamp: '2025-05-08T07:20:05.000Z', sender: { address: 'EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2+Zw8yaoxnXTt', - blockchain: tac.BlockchainType.TON, + blockchain: tac.V2BlockchainType.TON, }, status_history: [ { - type: tac.OperationStage_StageType.COLLECTED_IN_TON, + type: tac.V2OperationStage_V2StageType.COLLECTED_IN_TON, is_exist: true, is_success: true, timestamp: '2025-05-08T07:20:05.000Z', transactions: [ { hash: '0x77e3c6bef84681157dda17dec60f680a1ff6caaedec2e94c23f4ec44aa62aba8', - type: tac.BlockchainType.TON, + type: tac.V2BlockchainType.TON, }, ], note: undefined, }, { - type: tac.OperationStage_StageType.INCLUDED_IN_TON_CONSENSUS, + type: tac.V2OperationStage_V2StageType.INCLUDED_IN_TON_CONSENSUS, is_exist: true, is_success: true, timestamp: '2025-05-08T07:25:35.000Z', transactions: [ { hash: '0xafc8a8e04739b4996e9b5ef6c91673fb421d00ed42be4404d6fca6a915899235', - type: tac.BlockchainType.TAC, + type: tac.V2BlockchainType.TAC, }, { hash: '0xafc8a8e04739b4996e9b5ef6c91673fb421d00ed42be4404d6fca6a915899236', - type: tac.BlockchainType.TON, + type: tac.V2BlockchainType.TON, }, ], note: undefined, }, { - type: tac.OperationStage_StageType.EXECUTED_IN_TON, + type: tac.V2OperationStage_V2StageType.EXECUTED_IN_TON, is_exist: true, is_success: false, timestamp: '2025-05-08T07:26:14.000Z', transactions: [ { hash: '0xa9c6087ee95ede3cb0bcba7119a7f7b0ee3fc91d04faa1bb1ecd94ed83ef8161', - type: tac.BlockchainType.TAC, + type: tac.V2BlockchainType.TAC, }, ], note: 'ProxyCallError: UniswapV2Router: Insufficient output amount', diff --git a/src/features/chain-variants/tac/mocks/search.ts b/src/features/chain-variants/tac/mocks/search.ts index c70f65c4169..6f86dc01892 100644 --- a/src/features/chain-variants/tac/mocks/search.ts +++ b/src/features/chain-variants/tac/mocks/search.ts @@ -1,9 +1,19 @@ +import * as tac from '@blockscout/tac-operation-lifecycle-types'; import type { SearchResultTacOperation } from 'src/features/chain-variants/tac/types/api'; -import * as tacOperationMock from './operations'; - export const tacOperation1: SearchResultTacOperation = { type: 'tac_operation', - tac_operation: tacOperationMock.tacOperation, + tac_operation: { + operation_id: '0x35f5d9c2bf07477ede48935c7130945faf17a3e5f69a7d20ce3725676513095c', + type: tac.V2OperationType.TON_TAC_TON, + status: tac.V2OperationStatus.success, + rollback: false, + timestamp: '2025-05-08T07:20:05.000Z', + sender: { + address: 'EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2+Zw8yaoxnXTt', + blockchain: tac.V2BlockchainType.TON, + }, + error_reason: null, + }, priority: 0, }; diff --git a/src/features/chain-variants/tac/pages/operation-details/TacOperation.pw.tsx b/src/features/chain-variants/tac/pages/operation-details/TacOperation.pw.tsx index 68499d15d29..70f113d0978 100644 --- a/src/features/chain-variants/tac/pages/operation-details/TacOperation.pw.tsx +++ b/src/features/chain-variants/tac/pages/operation-details/TacOperation.pw.tsx @@ -35,7 +35,8 @@ test('pending operation', async({ render, mockTextAd, mockApiResponse, mockEnvs await mockTextAd(); await mockApiResponse('tac:operation', { ... tacOperationMock.tacOperation, - type: tac.OperationType.PENDING, + status: tac.V2OperationStatus.pending, + error_reason: undefined, }, { pathParams: { id: tacOperationMock.tacOperation.operation_id }, }); diff --git a/src/features/chain-variants/tac/pages/operation-details/TacOperation.tsx b/src/features/chain-variants/tac/pages/operation-details/TacOperation.tsx index be4cbd1a52a..9cacaee5088 100644 --- a/src/features/chain-variants/tac/pages/operation-details/TacOperation.tsx +++ b/src/features/chain-variants/tac/pages/operation-details/TacOperation.tsx @@ -31,11 +31,11 @@ const TacOperation = () => { throwOnResourceLoadError(query); const titleContentAfter = query.data ? ( - <TacOperationTag type={ query.data.type } loading={ query.isPlaceholderData }/> + <TacOperationTag type={ query.data.type } isRollback={ query.data.rollback } loading={ query.isPlaceholderData }/> ) : null; const titleSecondRow = ( - <TacOperationEntity id={ id } noLink variant="subheading" type={ query.data?.type }/> + <TacOperationEntity id={ id } noLink variant="subheading" status={ query.data?.status }/> ); return ( diff --git a/src/features/chain-variants/tac/pages/operation-details/TacOperationDetails.tsx b/src/features/chain-variants/tac/pages/operation-details/TacOperationDetails.tsx index 3dc4b884def..d14ec8752b4 100644 --- a/src/features/chain-variants/tac/pages/operation-details/TacOperationDetails.tsx +++ b/src/features/chain-variants/tac/pages/operation-details/TacOperationDetails.tsx @@ -1,5 +1,6 @@ // SPDX-License-Identifier: LicenseRef-Blockscout +import { HStack } from '@chakra-ui/react'; import React from 'react'; import type * as tac from '@blockscout/tac-operation-lifecycle-types'; @@ -7,6 +8,8 @@ import type * as tac from '@blockscout/tac-operation-lifecycle-types'; import * as DetailedInfo from 'src/shared/detailed-info/DetailedInfo'; import DetailedInfoTimestamp from 'src/shared/detailed-info/DetailedInfoTimestamp'; +import { Badge } from 'src/toolkit/chakra/badge'; + import AddressEntityTacTon from '../../components/AddressEntityTacTon'; import TacOperationStatus from '../../components/TacOperationStatus'; import { sortStatusHistory } from '../../utils/tac-operation'; @@ -14,7 +17,7 @@ import TacOperationLifecycleAccordion from './TacOperationLifecycleAccordion'; interface Props { isLoading?: boolean; - data: tac.OperationDetails; + data: tac.V2OperationDetails; } const TacOperationDetails = ({ isLoading, data }: Props) => { @@ -50,7 +53,16 @@ const TacOperationDetails = ({ isLoading, data }: Props) => { Status </DetailedInfo.ItemLabel> <DetailedInfo.ItemValue> - <TacOperationStatus status={ data.type } isLoading={ isLoading }/> + <HStack gap={ 1 } flexWrap="wrap"> + <TacOperationStatus + status={ data.status } + type={ data.type } + errorReason={ data.error_reason } + isLoading={ isLoading } + isRollback={ data.rollback } + /> + { data.rollback && <Badge loading={ isLoading }>Rollback</Badge> } + </HStack> </DetailedInfo.ItemValue> { data.timestamp && ( @@ -76,7 +88,7 @@ const TacOperationDetails = ({ isLoading, data }: Props) => { Lifecycle </DetailedInfo.ItemLabel> <DetailedInfo.ItemValue mt={ 1 }> - <TacOperationLifecycleAccordion data={ statusHistory } isLoading={ isLoading } type={ data.type }/> + <TacOperationLifecycleAccordion data={ statusHistory } isLoading={ isLoading } status={ data.status }/> </DetailedInfo.ItemValue> </> ) } diff --git a/src/features/chain-variants/tac/pages/operation-details/TacOperationLifecycleAccordion.tsx b/src/features/chain-variants/tac/pages/operation-details/TacOperationLifecycleAccordion.tsx index 5244b5df201..115f4bdb020 100644 --- a/src/features/chain-variants/tac/pages/operation-details/TacOperationLifecycleAccordion.tsx +++ b/src/features/chain-variants/tac/pages/operation-details/TacOperationLifecycleAccordion.tsx @@ -10,13 +10,13 @@ import { STATUS_LABELS } from '../../utils/tac-operation'; import TacOperationLifecycleAccordionItemContent from './TacOperationLifecycleAccordionItemContent'; interface Props { - data: tac.OperationDetails['status_history']; + data: tac.V2OperationDetails['status_history']; isLoading?: boolean; - type: tac.OperationType; + status: tac.V2OperationStatus; } -const TacOperationLifecycleAccordion = ({ data, isLoading, type }: Props) => { - const isPending = type === tac.OperationType.PENDING && !isLoading; +const TacOperationLifecycleAccordion = ({ data, isLoading, status }: Props) => { + const isPending = status === tac.V2OperationStatus.pending && !isLoading; return ( <Root> diff --git a/src/features/chain-variants/tac/pages/operation-details/TacOperationLifecycleAccordionItemContent.tsx b/src/features/chain-variants/tac/pages/operation-details/TacOperationLifecycleAccordionItemContent.tsx index a135d67355d..b16739679c7 100644 --- a/src/features/chain-variants/tac/pages/operation-details/TacOperationLifecycleAccordionItemContent.tsx +++ b/src/features/chain-variants/tac/pages/operation-details/TacOperationLifecycleAccordionItemContent.tsx @@ -15,7 +15,7 @@ import StatusTag from 'src/shared/tags/status-tag/StatusTag'; interface Props { isLast: boolean; - data: tac.OperationStage; + data: tac.V2OperationStage; } const TacOperationLifecycleAccordionItemContent = ({ isLast, data }: Props) => { @@ -44,7 +44,7 @@ const TacOperationLifecycleAccordionItemContent = ({ isLast, data }: Props) => { > { data.transactions.map((tx) => { - if (tx.type === tac.BlockchainType.TON) { + if (tx.type === tac.V2BlockchainType.TON) { return <TxEntityTon key={ tx.hash } hash={ tx.hash }/>; } diff --git a/src/features/chain-variants/tac/pages/operation-details/__screenshots__/TacOperation.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png b/src/features/chain-variants/tac/pages/operation-details/__screenshots__/TacOperation.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png index 57bcf406a17..1abaea3c32e 100644 Binary files a/src/features/chain-variants/tac/pages/operation-details/__screenshots__/TacOperation.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png and b/src/features/chain-variants/tac/pages/operation-details/__screenshots__/TacOperation.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png differ diff --git a/src/features/chain-variants/tac/pages/operation-details/__screenshots__/TacOperation.pw.tsx_default_base-view-dark-mode-mobile-1.png b/src/features/chain-variants/tac/pages/operation-details/__screenshots__/TacOperation.pw.tsx_default_base-view-dark-mode-mobile-1.png index 1a8ba66fd4d..4bfd88e4dc1 100644 Binary files a/src/features/chain-variants/tac/pages/operation-details/__screenshots__/TacOperation.pw.tsx_default_base-view-dark-mode-mobile-1.png and b/src/features/chain-variants/tac/pages/operation-details/__screenshots__/TacOperation.pw.tsx_default_base-view-dark-mode-mobile-1.png differ diff --git a/src/features/chain-variants/tac/pages/operation-details/__screenshots__/TacOperation.pw.tsx_default_pending-operation-1.png b/src/features/chain-variants/tac/pages/operation-details/__screenshots__/TacOperation.pw.tsx_default_pending-operation-1.png index 00eaca36ae7..b61832a8bb0 100644 Binary files a/src/features/chain-variants/tac/pages/operation-details/__screenshots__/TacOperation.pw.tsx_default_pending-operation-1.png and b/src/features/chain-variants/tac/pages/operation-details/__screenshots__/TacOperation.pw.tsx_default_pending-operation-1.png differ diff --git a/src/features/chain-variants/tac/pages/operation-details/__screenshots__/TacOperation.pw.tsx_mobile_base-view-dark-mode-mobile-1.png b/src/features/chain-variants/tac/pages/operation-details/__screenshots__/TacOperation.pw.tsx_mobile_base-view-dark-mode-mobile-1.png index 218e4f4c893..084b9d01732 100644 Binary files a/src/features/chain-variants/tac/pages/operation-details/__screenshots__/TacOperation.pw.tsx_mobile_base-view-dark-mode-mobile-1.png and b/src/features/chain-variants/tac/pages/operation-details/__screenshots__/TacOperation.pw.tsx_mobile_base-view-dark-mode-mobile-1.png differ diff --git a/src/features/chain-variants/tac/pages/operations/TacOperationsList.tsx b/src/features/chain-variants/tac/pages/operations/TacOperationsList.tsx index 3f6968f9838..ced22f6a897 100644 --- a/src/features/chain-variants/tac/pages/operations/TacOperationsList.tsx +++ b/src/features/chain-variants/tac/pages/operations/TacOperationsList.tsx @@ -10,7 +10,7 @@ import useLazyRenderedList from 'src/shared/lists/useLazyRenderedList'; import TacOperationsListItem from './TacOperationsListItem'; type Props = { - items: Array<tac.OperationBriefDetails>; + items: Array<tac.V2OperationBriefDetails>; isLoading?: boolean; resetKey?: string; }; diff --git a/src/features/chain-variants/tac/pages/operations/TacOperationsListItem.tsx b/src/features/chain-variants/tac/pages/operations/TacOperationsListItem.tsx index 1f10371243f..de82ccfd0f5 100644 --- a/src/features/chain-variants/tac/pages/operations/TacOperationsListItem.tsx +++ b/src/features/chain-variants/tac/pages/operations/TacOperationsListItem.tsx @@ -1,5 +1,6 @@ // SPDX-License-Identifier: LicenseRef-Blockscout +import { HStack } from '@chakra-ui/react'; import React from 'react'; import type * as tac from '@blockscout/tac-operation-lifecycle-types'; @@ -7,11 +8,13 @@ import type * as tac from '@blockscout/tac-operation-lifecycle-types'; import TimeWithTooltip from 'src/shared/date-and-time/TimeWithTooltip'; import ListItemMobileGrid from 'src/shared/lists/ListItemMobileGrid'; +import { Badge } from 'src/toolkit/chakra/badge'; + import AddressEntityTacTon from '../../components/AddressEntityTacTon'; import TacOperationEntity from '../../components/TacOperationEntity'; import TacOperationStatus from '../../components/TacOperationStatus'; -type Props = { item: tac.OperationBriefDetails; isLoading?: boolean }; +type Props = { item: tac.V2OperationBriefDetails; isLoading?: boolean }; const TacOperationsListItem = ({ item, isLoading }: Props) => { return ( @@ -21,7 +24,7 @@ const TacOperationsListItem = ({ item, isLoading }: Props) => { <ListItemMobileGrid.Value> <TacOperationEntity id={ item.operation_id } - type={ item.type } + status={ item.status } isLoading={ isLoading } /> </ListItemMobileGrid.Value> @@ -36,7 +39,16 @@ const TacOperationsListItem = ({ item, isLoading }: Props) => { <ListItemMobileGrid.Label isLoading={ isLoading }>Status</ListItemMobileGrid.Label> <ListItemMobileGrid.Value> - <TacOperationStatus status={ item.type } isLoading={ isLoading }/> + <HStack gap={ 1 } flexWrap="wrap"> + <TacOperationStatus + status={ item.status } + type={ item.type } + errorReason={ item.error_reason } + isLoading={ isLoading } + isRollback={ item.rollback } + /> + { item.rollback && <Badge loading={ isLoading }>Rollback</Badge> } + </HStack> </ListItemMobileGrid.Value> { item.sender && ( diff --git a/src/features/chain-variants/tac/pages/operations/TacOperationsTable.tsx b/src/features/chain-variants/tac/pages/operations/TacOperationsTable.tsx index 296040f6f96..74419bd3fe5 100644 --- a/src/features/chain-variants/tac/pages/operations/TacOperationsTable.tsx +++ b/src/features/chain-variants/tac/pages/operations/TacOperationsTable.tsx @@ -14,7 +14,7 @@ import { TableBody, TableColumnHeader, TableHeaderSticky, TableRoot, TableRow } import TacOperationsTableItem from './TacOperationsTableItem'; type Props = { - items: Array<tac.OperationBriefDetails>; + items: Array<tac.V2OperationBriefDetails>; isLoading?: boolean; resetKey?: string; }; @@ -27,7 +27,7 @@ const TacOperationsTable = ({ items, isLoading, resetKey }: Props) => { <TableRoot minW="950px"> <TableHeaderSticky top={ 68 }> <TableRow> - <TableColumnHeader w="200px">Status</TableColumnHeader> + <TableColumnHeader w="250px">Status</TableColumnHeader> <TableColumnHeader w="100%">Operation</TableColumnHeader> <TableColumnHeader w="200px"> Timestamp diff --git a/src/features/chain-variants/tac/pages/operations/TacOperationsTableItem.tsx b/src/features/chain-variants/tac/pages/operations/TacOperationsTableItem.tsx index fae6f26730f..53f3149c052 100644 --- a/src/features/chain-variants/tac/pages/operations/TacOperationsTableItem.tsx +++ b/src/features/chain-variants/tac/pages/operations/TacOperationsTableItem.tsx @@ -1,11 +1,13 @@ // SPDX-License-Identifier: LicenseRef-Blockscout +import { HStack } from '@chakra-ui/react'; import React from 'react'; import type * as tac from '@blockscout/tac-operation-lifecycle-types'; import TimeWithTooltip from 'src/shared/date-and-time/TimeWithTooltip'; +import { Badge } from 'src/toolkit/chakra/badge'; import { TableCell, TableRow } from 'src/toolkit/chakra/table'; import AddressEntityTacTon from '../../components/AddressEntityTacTon'; @@ -13,7 +15,7 @@ import TacOperationEntity from '../../components/TacOperationEntity'; import TacOperationStatus from '../../components/TacOperationStatus'; interface Props { - item: tac.OperationBriefDetails; + item: tac.V2OperationBriefDetails; isLoading?: boolean; } @@ -21,12 +23,21 @@ const TacOperationsTableItem = ({ item, isLoading }: Props) => { return ( <TableRow> <TableCell verticalAlign="middle"> - <TacOperationStatus status={ item.type } isLoading={ isLoading }/> + <HStack gap={ 1 } flexWrap="wrap"> + <TacOperationStatus + status={ item.status } + type={ item.type } + errorReason={ item.error_reason } + isLoading={ isLoading } + isRollback={ item.rollback } + /> + { item.rollback && <Badge loading={ isLoading }>Rollback</Badge> } + </HStack> </TableCell> <TableCell verticalAlign="middle"> <TacOperationEntity id={ item.operation_id } - type={ item.type } + status={ item.status } isLoading={ isLoading } truncation="constant_long" /> diff --git a/src/features/chain-variants/tac/pages/tx/TxDetailsTacOperation.tsx b/src/features/chain-variants/tac/pages/tx/TxDetailsTacOperation.tsx index b5cb1c4c433..f4c733dbd60 100644 --- a/src/features/chain-variants/tac/pages/tx/TxDetailsTacOperation.tsx +++ b/src/features/chain-variants/tac/pages/tx/TxDetailsTacOperation.tsx @@ -8,6 +8,7 @@ import useApiQuery from 'src/api/hooks/useApiQuery'; import config from 'src/config'; import * as DetailedInfo from 'src/shared/detailed-info/DetailedInfo'; +import { Badge } from 'src/toolkit/chakra/badge'; import { Tag } from 'src/toolkit/chakra/tag'; import TacOperationEntity from '../../components/TacOperationEntity'; @@ -60,19 +61,24 @@ const TxDetailsTacOperation = ({ isLoading, txHash }: Props) => { ]; return ( - <HStack key={ tacOperation.operation_id } rowGap={ 0 } columnGap={ 3 } flexWrap={{ base: 'wrap', lg: 'nowrap' }}> + <HStack key={ tacOperation.operation_id } rowGap={ 0 } columnGap={ 3 } flexWrap={{ base: 'wrap', lg: 'nowrap' }} maxW="100%"> <TacOperationEntity id={ tacOperation.operation_id } - type={ tacOperation.type } + status={ tacOperation.status } isLoading={ isPlaceholderData } my={{ base: '5px', lg: 0 }} /> - { tags.length > 0 && ( - <HStack flexShrink={ 0 } flexWrap="wrap" my={{ base: '3px', lg: 0 }}> - <TacOperationStatus status={ tacOperation.type } isLoading={ isPlaceholderData }/> - { tags.map((tag) => <Tag key={ tag } loading={ isPlaceholderData } flexShrink={ 0 }>{ tag }</Tag>) } - </HStack> - ) } + <HStack flexShrink={ 0 } flexWrap="wrap" my={{ base: '3px', lg: 0 }} maxW="100%" gap={ 1 }> + <TacOperationStatus + status={ tacOperation.status } + type={ tacOperation.type } + errorReason={ tacOperation.error_reason } + isLoading={ isPlaceholderData } + isRollback={ tacOperation.rollback } + /> + { tacOperation.rollback && <Badge loading={ isPlaceholderData }>Rollback</Badge> } + { tags.map((tag) => <Tag key={ tag } loading={ isPlaceholderData } flexShrink={ 0 }>{ tag }</Tag>) } + </HStack> </HStack> ); }) } diff --git a/src/features/chain-variants/tac/stubs.ts b/src/features/chain-variants/tac/stubs.ts index da9600f6ad1..e820fd1a914 100644 --- a/src/features/chain-variants/tac/stubs.ts +++ b/src/features/chain-variants/tac/stubs.ts @@ -2,34 +2,38 @@ import * as tac from '@blockscout/tac-operation-lifecycle-types'; import { ADDRESS_HASH } from 'src/slices/address/stubs/address-params'; -export const TAC_OPERATION: tac.OperationBriefDetails = { +export const TAC_OPERATION: tac.V2OperationBriefDetails = { operation_id: '0x4d3d36b7fcab0a2f93f24bf313ebfe9cc0b2c7157d2aef7e7f7d5835528428c6', - type: tac.OperationType.TAC_TON, + type: tac.V2OperationType.TAC_TON, + status: tac.V2OperationStatus.success, + rollback: false, timestamp: '2025-05-05T12:32:22.000Z', sender: { address: '0x4d3d36b7fcab0a2f93f24bf313ebfe9cc0b2c7157d2aef7e7f7d5835528428c6', - blockchain: tac.BlockchainType.TAC, + blockchain: tac.V2BlockchainType.TAC, }, }; -export const TAC_OPERATION_DETAILS: tac.OperationDetails = { +export const TAC_OPERATION_DETAILS: tac.V2OperationDetails = { operation_id: '0x6e7cdeea3f39e7664597a44ddb33ce47ba061cbee2992e2c7b0e3f9294ff8b30', - type: tac.OperationType.TAC_TON, + type: tac.V2OperationType.TAC_TON, + status: tac.V2OperationStatus.success, + rollback: false, timestamp: '2025-05-05T12:32:22.000Z', sender: { address: ADDRESS_HASH, - blockchain: tac.BlockchainType.TAC, + blockchain: tac.V2BlockchainType.TAC, }, status_history: [ { - type: tac.OperationStage_StageType.COLLECTED_IN_TAC, + type: tac.V2OperationStage_V2StageType.COLLECTED_IN_TAC, is_exist: true, is_success: true, timestamp: '2025-05-05T12:32:22.000Z', transactions: [ { hash: '0x064e57a9f43d032ac0c1cb0d7883b0d783a9fa5d207a39563a6ed06c5dc17622', - type: tac.BlockchainType.TON, + type: tac.V2BlockchainType.TON, }, ], note: undefined, diff --git a/src/features/chain-variants/tac/types/api.ts b/src/features/chain-variants/tac/types/api.ts index f98c95bceed..5d8e96fcbe1 100644 --- a/src/features/chain-variants/tac/types/api.ts +++ b/src/features/chain-variants/tac/types/api.ts @@ -2,8 +2,18 @@ import type * as tac from '@blockscout/tac-operation-lifecycle-types'; +/** + * The operation object embedded in the **core** `/api/v2/search` response. Core proxies the + * `tac-operation-lifecycle` Read API v2 brief object, so the fields are the service's own — except that + * core publishes an absent field as `null` where the service's proto omits it. + */ +export interface TacOperationSearchPayload extends Omit<tac.V2OperationBriefDetails, 'sender' | 'error_reason'> { + sender?: tac.V2BlockchainAddress | null; + error_reason?: string | null; +} + export interface SearchResultTacOperation { type: 'tac_operation'; - tac_operation: tac.OperationDetails; + tac_operation: TacOperationSearchPayload; priority: number; } diff --git a/src/features/chain-variants/tac/utils/tac-operation.ts b/src/features/chain-variants/tac/utils/tac-operation.ts index 28c2a70bae3..74053191eb6 100644 --- a/src/features/chain-variants/tac/utils/tac-operation.ts +++ b/src/features/chain-variants/tac/utils/tac-operation.ts @@ -4,28 +4,62 @@ import * as tac from '@blockscout/tac-operation-lifecycle-types'; import { rightLineArrow } from 'src/toolkit/utils/htmlEntities'; -export function getTacOperationStatus(type: tac.OperationType) { +/** + * The transfer route, and nothing else — the outcome lives in `status`. Returns `null` for + * `UNKNOWN`, which means the operation id is indexed but its route is not known yet. + */ +export function getTacOperationRoute(type: tac.V2OperationType): string | null { switch (type) { - case tac.OperationType.TON_TAC_TON: + case tac.V2OperationType.TON_TAC_TON: return `TON ${ rightLineArrow } TAC ${ rightLineArrow } TON`; - case tac.OperationType.TAC_TON: + case tac.V2OperationType.TAC_TON: return `TAC ${ rightLineArrow } TON`; - case tac.OperationType.TON_TAC: + case tac.V2OperationType.TON_TAC: return `TON ${ rightLineArrow } TAC`; - case tac.OperationType.ERROR: - return 'Error'; - case tac.OperationType.ROLLBACK: - return 'Rollback'; - case tac.OperationType.INSUFFICIENT_FEE: - return 'Insufficient fee'; - case tac.OperationType.PENDING: - return 'Pending'; default: return null; } } -export function getTacOperationStage(data: tac.OperationDetails, txHash: string) { +export const TAC_OPERATION_STATUS_LABELS: Record<tac.V2OperationStatus, string> = { + [tac.V2OperationStatus.pending]: 'Pending', + [tac.V2OperationStatus.success]: 'Success', + [tac.V2OperationStatus.failed]: 'Failed', + [tac.V2OperationStatus.UNRECOGNIZED]: 'Unknown', +}; + +/** + * The route when it is known, otherwise the status word — the tag always carries the status icon and + * colour, so it must never render empty. + */ +export function getTacOperationStatusText(status: tac.V2OperationStatus, type: tac.V2OperationType): string { + return getTacOperationRoute(type) ?? TAC_OPERATION_STATUS_LABELS[status]; +} + +export const FAILURE_TOOLTIP = 'Failed operation'; + +export const ROLLBACK_TOOLTIP = 'The cross‑chain operation was reverted and the original assets and state ' + + 'were returned to the sender after a failure on the destination chain'; + +/** + * Only a failure gets a tooltip; `error_reason` is a short label the API publishes when it has one, and is + * legitimately absent in many failed states. + */ +export function getTacOperationStatusTooltip( + status: tac.V2OperationStatus, + errorReason: string | null | undefined, + isRollback: boolean | undefined, +): string | null { + if (status !== tac.V2OperationStatus.failed) { + return null; + } + if (isRollback) { + return ROLLBACK_TOOLTIP; + } + return errorReason ? `${ FAILURE_TOOLTIP }. ${ errorReason }` : FAILURE_TOOLTIP; +} + +export function getTacOperationStage(data: tac.V2OperationDetails, txHash: string) { const currentStep = data.status_history.filter((step) => step.transactions.some((tx) => tx.hash.toLowerCase() === txHash.toLowerCase())); if (currentStep.length === 0) { return; @@ -33,26 +67,26 @@ export function getTacOperationStage(data: tac.OperationDetails, txHash: string) return currentStep.map((step) => STATUS_LABELS[step.type]); } -export const STATUS_SEQUENCE: Array<tac.OperationStage_StageType> = [ - tac.OperationStage_StageType.COLLECTED_IN_TAC, - tac.OperationStage_StageType.INCLUDED_IN_TAC_CONSENSUS, - tac.OperationStage_StageType.EXECUTED_IN_TAC, - tac.OperationStage_StageType.COLLECTED_IN_TON, - tac.OperationStage_StageType.INCLUDED_IN_TON_CONSENSUS, - tac.OperationStage_StageType.EXECUTED_IN_TON, +export const STATUS_SEQUENCE: Array<tac.V2OperationStage_V2StageType> = [ + tac.V2OperationStage_V2StageType.COLLECTED_IN_TAC, + tac.V2OperationStage_V2StageType.INCLUDED_IN_TAC_CONSENSUS, + tac.V2OperationStage_V2StageType.EXECUTED_IN_TAC, + tac.V2OperationStage_V2StageType.COLLECTED_IN_TON, + tac.V2OperationStage_V2StageType.INCLUDED_IN_TON_CONSENSUS, + tac.V2OperationStage_V2StageType.EXECUTED_IN_TON, ]; -export const STATUS_LABELS: Record<tac.OperationStage_StageType, string> = { - [tac.OperationStage_StageType.COLLECTED_IN_TAC]: 'Collected in TAC', - [tac.OperationStage_StageType.INCLUDED_IN_TAC_CONSENSUS]: 'Included in TAC consensus', - [tac.OperationStage_StageType.EXECUTED_IN_TAC]: 'Executed in TAC', - [tac.OperationStage_StageType.COLLECTED_IN_TON]: 'Collected in TON', - [tac.OperationStage_StageType.INCLUDED_IN_TON_CONSENSUS]: 'Included in TON consensus', - [tac.OperationStage_StageType.EXECUTED_IN_TON]: 'Executed in TON', - [tac.OperationStage_StageType.UNRECOGNIZED]: 'Unknown', +export const STATUS_LABELS: Record<tac.V2OperationStage_V2StageType, string> = { + [tac.V2OperationStage_V2StageType.COLLECTED_IN_TAC]: 'Collected in TAC', + [tac.V2OperationStage_V2StageType.INCLUDED_IN_TAC_CONSENSUS]: 'Included in TAC consensus', + [tac.V2OperationStage_V2StageType.EXECUTED_IN_TAC]: 'Executed in TAC', + [tac.V2OperationStage_V2StageType.COLLECTED_IN_TON]: 'Collected in TON', + [tac.V2OperationStage_V2StageType.INCLUDED_IN_TON_CONSENSUS]: 'Included in TON consensus', + [tac.V2OperationStage_V2StageType.EXECUTED_IN_TON]: 'Executed in TON', + [tac.V2OperationStage_V2StageType.UNRECOGNIZED]: 'Unknown', }; -export const sortStatusHistory = (a: tac.OperationStage, b: tac.OperationStage) => { +export const sortStatusHistory = (a: tac.V2OperationStage, b: tac.V2OperationStage) => { const aIndex = STATUS_SEQUENCE.indexOf(a.type); const bIndex = STATUS_SEQUENCE.indexOf(b.type); return aIndex - bIndex; diff --git a/src/features/connect-wallet/CONTEXT.md b/src/features/connect-wallet/CONTEXT.md index cbe6e6a8a0b..e4c374e90cc 100644 --- a/src/features/connect-wallet/CONTEXT.md +++ b/src/features/connect-wallet/CONTEXT.md @@ -56,6 +56,11 @@ disabled *fallback*. would wait forever for a readiness signal only the deferred path emits. Moving dynamic mode onto the deferred model is a known follow-up. +Before changing anything in the dynamic-mode graph, verify it in a production build (`pnpm prod:preset <alias>`) +against an instance whose `NEXT_PUBLIC_ACCOUNT_AUTH_PROVIDER=dynamic`, not just in dev — reshaping it once +tripped a production-only bundler bug that dev never surfaces (see +`.agents/adr/0003-turbopack-for-production-builds.md`). + ## Persisted connection Connection state is persisted in our **own** localStorage flag, not wagmi's diff --git a/src/features/connect-wallet/hooks/wallet/useWalletReown.ts b/src/features/connect-wallet/hooks/wallet/useWalletReown.ts index 8aa33fec4c9..8ca5ada2286 100644 --- a/src/features/connect-wallet/hooks/wallet/useWalletReown.ts +++ b/src/features/connect-wallet/hooks/wallet/useWalletReown.ts @@ -50,20 +50,23 @@ export function useWalletReown({ source, onConnect }: Params): Result { modalUnsubRef.current = runtime.subscribeModalState(setIsModalOpen); }, []); - const openModal = React.useCallback(async() => { + // Loads the runtime and opens the AppKit modal, returning the runtime so `connect` can gate its analytics + // on `isReady`. The exposed `openModal` discards it (the `Result` contract is `Promise<void>`). + const loadAndOpenModal = React.useCallback(async() => { setIsOpening(true); const runtime = await ensureLoaded(); subscribeModal(runtime); await runtime.openModal(); setIsOpening(false); + return runtime; }, [ subscribeModal ]); + const openModal = React.useCallback(async() => { + await loadAndOpenModal(); + }, [ loadAndOpenModal ]); + const connect = React.useCallback(async() => { - setIsOpening(true); - const runtime = await ensureLoaded(); - subscribeModal(runtime); - await runtime.openModal(); - setIsOpening(false); + const runtime = await loadAndOpenModal(); // Record a started connection only when the modal could actually open. A failed chunk load resolves to // the disabled runtime whose `openModal` is a no-op — there is nothing for the user to complete, and no // later bridge connect to attribute to this click. @@ -71,7 +74,7 @@ export function useWalletReown({ source, onConnect }: Params): Result { mixpanel.logEvent(mixpanel.EventTypes.WALLET_CONNECT, { Source: source, Status: 'Started' }); isConnectionStarted.current = true; } - }, [ source, subscribeModal ]); + }, [ source, loadAndOpenModal ]); const disconnect = React.useCallback(async() => { const runtime = await ensureLoaded(); diff --git a/src/features/connect-wallet/utils/install-eip6963-announce-guard.spec.ts b/src/features/connect-wallet/utils/install-eip6963-announce-guard.spec.ts new file mode 100644 index 00000000000..6eb55dc9f8c --- /dev/null +++ b/src/features/connect-wallet/utils/install-eip6963-announce-guard.spec.ts @@ -0,0 +1,30 @@ +/** @vitest-environment jsdom */ + +import { describe, expect, it, vi } from 'vitest'; + +import { installEip6963AnnounceGuard } from './install-eip6963-announce-guard'; + +const ANNOUNCE_EVENT = 'eip6963:announceProvider'; + +describe('installEip6963AnnounceGuard', () => { + it('stops malformed announce events reaching downstream listeners, and lets valid ones through', () => { + // Guard must be installed first so its listener precedes the downstream one — the same ordering + // it relies on against wagmi's mipd store in production. + installEip6963AnnounceGuard(); + + const downstream = vi.fn(); + window.addEventListener(ANNOUNCE_EVENT, downstream); + + window.dispatchEvent(new CustomEvent(ANNOUNCE_EVENT, { detail: null })); + window.dispatchEvent(new CustomEvent(ANNOUNCE_EVENT, { detail: { provider: {} } })); + window.dispatchEvent(new CustomEvent(ANNOUNCE_EVENT, { detail: { info: null, provider: {} } })); + expect(downstream).not.toHaveBeenCalled(); + + window.dispatchEvent(new CustomEvent(ANNOUNCE_EVENT, { + detail: { info: { uuid: '1', name: 'MetaMask', icon: '', rdns: 'io.metamask' }, provider: {} }, + })); + expect(downstream).toHaveBeenCalledTimes(1); + + window.removeEventListener(ANNOUNCE_EVENT, downstream); + }); +}); diff --git a/src/features/connect-wallet/utils/install-eip6963-announce-guard.ts b/src/features/connect-wallet/utils/install-eip6963-announce-guard.ts new file mode 100644 index 00000000000..420492bf7ff --- /dev/null +++ b/src/features/connect-wallet/utils/install-eip6963-announce-guard.ts @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +// https://eips.ethereum.org/EIPS/eip-6963 + +let isInstalled = false; + +/** + * Drops malformed `eip6963:announceProvider` events before any consumer's listener runs. + * + * EIP-6963 requires the event's `detail` to carry a provider `info`, but a non-compliant wallet + * extension can dispatch one with a null or partial `detail`. Consumers dereference `detail.info` + * without guarding — wagmi's vendored `mipd` store (`providerDetail.info.uuid`) most of all — and + * throw an uncaught "Cannot read properties of null (reading 'info')" we cannot fix inside the + * dependency. + * + * Listeners on `window` for a window-targeted event fire in registration order, so installing this + * before wagmi's config is created (which spins up the `mipd` store) lets it run first and + * `stopImmediatePropagation()` the malformed event, neutralising it for every downstream consumer at + * once. Idempotent. + */ +export function installEip6963AnnounceGuard(): void { + if (isInstalled || typeof window === 'undefined') { + return; + } + isInstalled = true; + + window.addEventListener('eip6963:announceProvider', (event) => { + const detail = (event as CustomEvent).detail; + if (detail === null || typeof detail !== 'object' || !('info' in detail) || !detail.info) { + event.stopImmediatePropagation(); + } + }, true); +} diff --git a/src/features/connect-wallet/utils/wagmi-config.ts b/src/features/connect-wallet/utils/wagmi-config.ts index b11e7f8a6ce..ff46fe0f37b 100644 --- a/src/features/connect-wallet/utils/wagmi-config.ts +++ b/src/features/connect-wallet/utils/wagmi-config.ts @@ -7,6 +7,7 @@ import { fallback, http } from 'viem'; import { createConfig } from 'wagmi'; import { chains, parentChain } from 'src/features/connect-wallet/utils/chains'; +import { installEip6963AnnounceGuard } from 'src/features/connect-wallet/utils/install-eip6963-announce-guard'; import essentialDappsChainsConfig from 'src/features/marketplace/chains-config/essential-dapps'; import multichainConfig from 'src/features/multichain/chains-config'; @@ -48,6 +49,10 @@ const reduceExternalChainsToTransportConfig = (readOnly: boolean): Record<string }, {} as Record<string, Transport>); }; +// Installed before the config below creates wagmi's mipd store, so the guard's listener is +// registered ahead of mipd's and can drop malformed EIP-6963 announce events before it throws on them. +installEip6963AnnounceGuard(); + const wagmi = (() => { if (!feature.isEnabled || feature.connectorType === 'dynamic') { diff --git a/src/features/contract-audit-reports/components/ContractSubmitAuditForm.tsx b/src/features/contract-audit-reports/components/ContractSubmitAuditForm.tsx index 980af604a94..98c0ca6253d 100644 --- a/src/features/contract-audit-reports/components/ContractSubmitAuditForm.tsx +++ b/src/features/contract-audit-reports/components/ContractSubmitAuditForm.tsx @@ -1,6 +1,8 @@ // SPDX-License-Identifier: LicenseRef-Blockscout +import type { DateValue } from '@chakra-ui/react'; import { VStack } from '@chakra-ui/react'; +import { getLocalTimeZone, toCalendarDate, today } from '@internationalized/date'; import React from 'react'; import type { SubmitHandler } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form'; @@ -10,11 +12,10 @@ import type { operations } from '@blockscout/api-types'; import useApiFetch from 'src/api/hooks/useApiFetch'; import type { ResourceError } from 'src/api/resources'; -import dayjs from 'src/shared/date-and-time/dayjs'; - import { Button } from 'src/toolkit/chakra/button'; import { toaster } from 'src/toolkit/chakra/toaster'; import { FormFieldCheckbox } from 'src/toolkit/components/forms/fields/FormFieldCheckbox'; +import { FormFieldDate } from 'src/toolkit/components/forms/fields/FormFieldDate'; import { FormFieldEmail } from 'src/toolkit/components/forms/fields/FormFieldEmail'; import { FormFieldText } from 'src/toolkit/components/forms/fields/FormFieldText'; import { FormFieldUrl } from 'src/toolkit/components/forms/fields/FormFieldUrl'; @@ -32,7 +33,7 @@ export type Inputs = { project_url: string; audit_company_name: string; audit_report_url: string; - audit_publish_date: string; + audit_publish_date: Array<DateValue>; comment?: string; }; @@ -47,11 +48,18 @@ const ContractSubmitAuditForm = ({ address, onSuccess }: Props) => { const formApi = useForm<Inputs>({ mode: 'onTouched', - defaultValues: { is_project_owner: false }, + defaultValues: { is_project_owner: false, audit_publish_date: [] }, }); const { handleSubmit, formState, setError } = formApi; + const maxDate = React.useMemo(() => today(getLocalTimeZone()), []); + const onFormSubmit: SubmitHandler<Inputs> = React.useCallback(async(data) => { + const [ publishDate ] = data.audit_publish_date; + if (!publishDate) { + return; + } + try { await apiFetch< 'core:contract_security_audits', @@ -61,7 +69,11 @@ const ContractSubmitAuditForm = ({ address, onSuccess }: Props) => { pathParams: { hash: address }, fetchParams: { method: 'POST', - body: data, + body: { + ...data, + // the API expects a date-only string, the picker may carry a time + audit_publish_date: toCalendarDate(publishDate).toString(), + }, }, }); @@ -103,9 +115,9 @@ const ContractSubmitAuditForm = ({ address, onSuccess }: Props) => { <FormFieldUrl<Inputs> name="project_url" required placeholder="Project URL"/> <FormFieldText<Inputs> name="audit_company_name" required placeholder="Audit company name"/> <FormFieldUrl<Inputs> name="audit_report_url" required placeholder="Audit report URL"/> - <FormFieldText<Inputs> + <FormFieldDate<Inputs, 'audit_publish_date'> name="audit_publish_date" - inputProps={{ type: 'date', max: dayjs().format('YYYY-MM-DD') }} + max={ maxDate } required placeholder="Audit publish date" /> diff --git a/src/features/contract-audit-reports/components/__screenshots__/ContractSubmitAuditForm.pw.tsx_default_base-view-1.png b/src/features/contract-audit-reports/components/__screenshots__/ContractSubmitAuditForm.pw.tsx_default_base-view-1.png index 8da8a62f711..720886ca12a 100644 Binary files a/src/features/contract-audit-reports/components/__screenshots__/ContractSubmitAuditForm.pw.tsx_default_base-view-1.png and b/src/features/contract-audit-reports/components/__screenshots__/ContractSubmitAuditForm.pw.tsx_default_base-view-1.png differ diff --git a/src/features/cross-chain-txs/components/CrossChainFromToTag.tsx b/src/features/cross-chain-txs/components/CrossChainFromToTag.tsx index 51d0a5e7a74..2e6b41bb8c5 100644 --- a/src/features/cross-chain-txs/components/CrossChainFromToTag.tsx +++ b/src/features/cross-chain-txs/components/CrossChainFromToTag.tsx @@ -4,21 +4,44 @@ import React from 'react'; import { Badge, type BadgeProps } from 'src/toolkit/chakra/badge'; +const SELF_TAG = { text: 'Self', colorPalette: 'gray' as const }; +const OUT_TAG = { text: 'Out', colorPalette: 'orange' as const }; +const IN_TAG = { text: 'In', colorPalette: 'purple' as const }; + interface Props extends BadgeProps { - type: 'in' | 'out'; + currentAddress: string; + sender?: string; + recipient?: string; isLoading?: boolean; } -const CrossChainFromToTag = ({ type, isLoading, ...rest }: Props) => { +const CrossChainFromToTag = ({ currentAddress, sender, recipient, isLoading, ...rest }: Props) => { + + const { text, colorPalette } = (() => { + if (sender?.toLowerCase() === currentAddress.toLowerCase() && recipient?.toLowerCase() === currentAddress.toLowerCase()) { + return SELF_TAG; + } + + if (sender?.toLowerCase() === currentAddress.toLowerCase()) { + return OUT_TAG; + } + + if (recipient?.toLowerCase() === currentAddress.toLowerCase()) { + return IN_TAG; + } + + return SELF_TAG; + })(); + return ( <Badge loading={ isLoading } - colorPalette={ type === 'in' ? 'purple' : 'orange' } - minW={ 8 } + colorPalette={ colorPalette } + minW={ 10 } justifyContent="center" { ...rest } > - { type === 'in' ? 'In' : 'Out' } + { text } </Badge> ); }; diff --git a/src/features/cross-chain-txs/components/CrossChainFromToTagTx.tsx b/src/features/cross-chain-txs/components/CrossChainFromToTagTx.tsx new file mode 100644 index 00000000000..5048417021f --- /dev/null +++ b/src/features/cross-chain-txs/components/CrossChainFromToTagTx.tsx @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import React from 'react'; + +import type { InterchainMessage } from '@blockscout/interchain-indexer-types'; + +import CrossChainFromToTag from './CrossChainFromToTag'; + +interface Props { + data: InterchainMessage; + isLoading?: boolean; + currentAddress: string; +} + +const CrossChainFromToTagTx = ({ data, isLoading, currentAddress }: Props) => { + const transfersNum = data.transfers.length; + + if (transfersNum === 1) { + const { sender, recipient } = data.transfers[0]; + if (sender?.hash.toLowerCase() === currentAddress.toLowerCase() || recipient?.hash.toLowerCase() === currentAddress.toLowerCase()) { + return ( + <CrossChainFromToTag + currentAddress={ currentAddress } + sender={ sender?.hash } + recipient={ recipient?.hash } + isLoading={ isLoading } + /> + ); + } + } + + return ( + <CrossChainFromToTag + currentAddress={ currentAddress } + sender={ data.sender?.hash } + recipient={ data.recipient?.hash } + isLoading={ isLoading } + /> + ); +}; + +export default React.memo(CrossChainFromToTagTx); diff --git a/src/features/cross-chain-txs/components/token-transfers/TokenTransfersCrossChainListItem.tsx b/src/features/cross-chain-txs/components/token-transfers/TokenTransfersCrossChainListItem.tsx index c99f150416a..fd0820d04be 100644 --- a/src/features/cross-chain-txs/components/token-transfers/TokenTransfersCrossChainListItem.tsx +++ b/src/features/cross-chain-txs/components/token-transfers/TokenTransfersCrossChainListItem.tsx @@ -10,7 +10,6 @@ import TxEntityInterchain from 'src/slices/tx/components/entity/TxEntityIntercha import TokenValueInterchain from 'src/features/cross-chain-txs/components/TokenValueInterchain'; -import config from 'src/config'; import dayjs from 'src/shared/date-and-time/dayjs'; import Time from 'src/shared/date-and-time/Time'; import ListItemMobile from 'src/shared/lists/ListItemMobile'; @@ -42,7 +41,9 @@ const TokenTransfersCrossChainListItem = ({ data, isLoading, rowGap = 3, current <CrossChainTxsStatusTag status={ data.status } loading={ isLoading } mode="full"/> { currentAddress && ( <CrossChainFromToTag - type={ data.sender?.hash.toLowerCase() === currentAddress.toLowerCase() && config.chain.id === data.source_chain?.id ? 'out' : 'in' } + currentAddress={ currentAddress } + sender={ data.sender?.hash } + recipient={ data.recipient?.hash } isLoading={ isLoading } /> ) } diff --git a/src/features/cross-chain-txs/components/token-transfers/TokenTransfersCrossChainTable.tsx b/src/features/cross-chain-txs/components/token-transfers/TokenTransfersCrossChainTable.tsx index 03b8f6ae04e..2a715be0262 100644 --- a/src/features/cross-chain-txs/components/token-transfers/TokenTransfersCrossChainTable.tsx +++ b/src/features/cross-chain-txs/components/token-transfers/TokenTransfersCrossChainTable.tsx @@ -31,8 +31,7 @@ const TokenTransfersCrossChainTable = ({ data, isLoading, top, currentAddress, r <TableRoot tableLayout="auto"> <TableHeaderSticky top={ top }> <TableRow> - <TableColumnHeader w="42px"/> - { currentAddress && <TableColumnHeader w="44px"/> } + <TableColumnHeader w={ currentAddress ? '86px' : '42px' }/> <TableColumnHeader>Source token</TableColumnHeader> <TableColumnHeader/> <TableColumnHeader>Target token</TableColumnHeader> diff --git a/src/features/cross-chain-txs/components/token-transfers/TokenTransfersCrossChainTableItem.tsx b/src/features/cross-chain-txs/components/token-transfers/TokenTransfersCrossChainTableItem.tsx index 3de538bd296..295bff2d907 100644 --- a/src/features/cross-chain-txs/components/token-transfers/TokenTransfersCrossChainTableItem.tsx +++ b/src/features/cross-chain-txs/components/token-transfers/TokenTransfersCrossChainTableItem.tsx @@ -1,6 +1,6 @@ // SPDX-License-Identifier: LicenseRef-Blockscout -import { chakra, VStack } from '@chakra-ui/react'; +import { chakra, HStack, VStack } from '@chakra-ui/react'; import React from 'react'; import type { InterchainTransfer } from '@blockscout/interchain-indexer-types'; @@ -11,7 +11,6 @@ import TxEntityInterchain from 'src/slices/tx/components/entity/TxEntityIntercha import TokenValueInterchain from 'src/features/cross-chain-txs/components/TokenValueInterchain'; -import config from 'src/config'; import TimeWithTooltip from 'src/shared/date-and-time/TimeWithTooltip'; import ChainLabel from 'src/shared/external-chains/ChainLabel'; @@ -35,17 +34,19 @@ const TokenTransfersCrossChainTableItem = ({ data, isLoading, currentAddress }: return ( <TableRow> - <TableCell w="42px"> - <CrossChainTxsStatusTag status={ data.status } loading={ isLoading }/> + <TableCell> + <HStack gap={ 1 }> + <CrossChainTxsStatusTag status={ data.status } loading={ isLoading }/> + { currentAddress && ( + <CrossChainFromToTag + currentAddress={ currentAddress } + sender={ data.sender?.hash } + recipient={ data.recipient?.hash } + isLoading={ isLoading } + /> + ) } + </HStack> </TableCell> - { currentAddress && ( - <TableCell> - <CrossChainFromToTag - type={ data.sender?.hash.toLowerCase() === currentAddress.toLowerCase() && config.chain.id === data.source_chain?.id ? 'out' : 'in' } - isLoading={ isLoading } - /> - </TableCell> - ) } <TableCell maxW="150px"> <VStack alignItems="start"> { data.source_token && ( diff --git a/src/features/cross-chain-txs/components/txs/TransactionsCrossChainListItem.tsx b/src/features/cross-chain-txs/components/txs/TransactionsCrossChainListItem.tsx index 36825cac2de..e4de8f015b3 100644 --- a/src/features/cross-chain-txs/components/txs/TransactionsCrossChainListItem.tsx +++ b/src/features/cross-chain-txs/components/txs/TransactionsCrossChainListItem.tsx @@ -59,7 +59,9 @@ const TransactionsCrossChainListItem = ({ data, isLoading, rowGap = 3, currentAd <CrossChainTxsStatusTag status={ data.status } loading={ isLoading } mode="full"/> { currentAddress && ( <CrossChainFromToTag - type={ data.sender?.hash.toLowerCase() === currentAddress.toLowerCase() && config.chain.id === data.source_chain?.id ? 'out' : 'in' } + currentAddress={ currentAddress } + sender={ data.sender?.hash } + recipient={ data.recipient?.hash } isLoading={ isLoading } /> ) } diff --git a/src/features/cross-chain-txs/components/txs/TransactionsCrossChainTable.tsx b/src/features/cross-chain-txs/components/txs/TransactionsCrossChainTable.tsx index 47990f1a0b2..8b968ab1e26 100644 --- a/src/features/cross-chain-txs/components/txs/TransactionsCrossChainTable.tsx +++ b/src/features/cross-chain-txs/components/txs/TransactionsCrossChainTable.tsx @@ -32,8 +32,7 @@ const TransactionsCrossChainTable = ({ data, isLoading, top, stickyHeader, curre <TableRoot tableLayout="auto"> <TableHeaderComponent top={ stickyHeader ? top : undefined }> <TableRow> - <TableColumnHeader w="42px"/> - { currentAddress && <TableColumnHeader w="44px"/> } + <TableColumnHeader w={ currentAddress ? '86px' : '42px' }/> <TableColumnHeader>Message</TableColumnHeader> <TableColumnHeader> <Flex alignItems="center" flexWrap="nowrap"> diff --git a/src/features/cross-chain-txs/components/txs/TransactionsCrossChainTableItem.tsx b/src/features/cross-chain-txs/components/txs/TransactionsCrossChainTableItem.tsx index 3a2fe520168..6911708556d 100644 --- a/src/features/cross-chain-txs/components/txs/TransactionsCrossChainTableItem.tsx +++ b/src/features/cross-chain-txs/components/txs/TransactionsCrossChainTableItem.tsx @@ -1,6 +1,6 @@ // SPDX-License-Identifier: LicenseRef-Blockscout -import { chakra, VStack } from '@chakra-ui/react'; +import { chakra, HStack, VStack } from '@chakra-ui/react'; import { route } from 'nextjs-routes'; import React from 'react'; @@ -22,7 +22,7 @@ import { TableCell, TableRow } from 'src/toolkit/chakra/table'; import { mdash } from 'src/toolkit/utils/htmlEntities'; import CrossChainBridgeLink from '../CrossChainBridgeLink'; -import CrossChainFromToTag from '../CrossChainFromToTag'; +import CrossChainFromToTagTx from '../CrossChainFromToTagTx'; import CrossChainMessageEntity from '../CrossChainMessageEntity'; import CrossChainTxsStatusTag from '../CrossChainTxsStatusTag'; @@ -55,16 +55,17 @@ const TransactionsCrossChainTableItem = ({ data, isLoading, currentAddress }: Pr return ( <TableRow> <TableCell w="42px"> - <CrossChainTxsStatusTag status={ data.status } loading={ isLoading }/> + <HStack gap={ 1 }> + <CrossChainTxsStatusTag status={ data.status } loading={ isLoading }/> + { currentAddress && ( + <CrossChainFromToTagTx + data={ data } + currentAddress={ currentAddress } + isLoading={ isLoading } + /> + ) } + </HStack> </TableCell> - { currentAddress && ( - <TableCell> - <CrossChainFromToTag - type={ data.sender?.hash.toLowerCase() === currentAddress.toLowerCase() && config.chain.id === data.source_chain?.id ? 'out' : 'in' } - isLoading={ isLoading } - /> - </TableCell> - ) } <TableCell> <CrossChainMessageEntity id={ data.message_id } isLoading={ isLoading } lineHeight="24px" fontWeight={ 700 }/> </TableCell> diff --git a/src/features/csv-export/components/CsvExport.tsx b/src/features/csv-export/components/CsvExport.tsx index 2cc603cff01..c092878f295 100644 --- a/src/features/csv-export/components/CsvExport.tsx +++ b/src/features/csv-export/components/CsvExport.tsx @@ -17,12 +17,13 @@ import type { ResourceName, ResourcePathParams } from 'src/api/resources'; import buildUrl from 'src/api/utils/build-url'; import isNeedProxy from 'src/api/utils/is-need-proxy'; +import { useSettingsContext } from 'src/shell/top-bar/settings/context'; + import { useMultichainContext } from 'src/features/multichain/context'; import config from 'src/config'; import ReCaptcha from 'src/services/re-captcha/ReCaptcha'; import useReCaptcha from 'src/services/re-captcha/useReCaptcha'; -import dayjs from 'src/shared/date-and-time/dayjs'; import getErrorMessage from 'src/shared/errors/get-error-message'; import getErrorObjStatusCode from 'src/shared/errors/get-error-obj-status-code'; import useIsInitialLoading from 'src/shared/hooks/useIsInitialLoading'; @@ -37,6 +38,7 @@ import { downloadBlob } from 'src/toolkit/utils/file'; import { useCsvExportContext } from '../utils/context'; import getFileName from '../utils/get-file-name'; +import serializeFormFields from '../utils/serialize-form-fields'; import type { StorageItem } from '../utils/storage'; import CsvExportDialog from './dialog/CsvExportDialog'; import CsvExportDialogDescription from './dialog/CsvExportDialogDescription'; @@ -76,6 +78,8 @@ const CsvExport = <R extends ResourceName>({ const recaptcha = useReCaptcha(); const csvExportContext = useCsvExportContext(); const apiFetch = useApiFetch(); + const settings = useSettingsContext(); + const isLocalTime = settings?.isLocalTime ?? true; const chain = chainData || multichainContext?.chain; @@ -101,7 +105,7 @@ const CsvExport = <R extends ResourceName>({ const fetchFactorySync = React.useCallback((data?: FormFields) => { return async(recaptchaToken?: string) => { const url = buildUrl(resourceName, pathParams, { - ...mapValues(data || {}, (value) => dayjs(value).toISOString()), + ...serializeFormFields(data, isLocalTime), ...queryParams, }, undefined, chain); @@ -126,7 +130,7 @@ const CsvExport = <R extends ResourceName>({ return response; }; - }, [ resourceName, pathParams, queryParams, chain ]); + }, [ resourceName, pathParams, queryParams, chain, isLocalTime ]); const fetchFactoryAsync = React.useCallback((data?: FormFields) => { return async(recaptchaToken?: string) => { @@ -135,7 +139,7 @@ const CsvExport = <R extends ResourceName>({ return apiFetch<typeof resourceName>(resourceName, { pathParams, queryParams: { - ...mapValues(data || {}, (value) => dayjs(value).toISOString()), + ...serializeFormFields(data, isLocalTime), ...queryParams, }, chain, @@ -147,7 +151,7 @@ const CsvExport = <R extends ResourceName>({ }, }) as Promise<CsvExportDownloadResponse>; }; - }, [ apiFetch, chain, pathParams, queryParams, resourceName ]); + }, [ apiFetch, chain, pathParams, queryParams, resourceName, isLocalTime ]); const downloadFileSync = React.useCallback(async(data?: FormFields) => { try { @@ -158,8 +162,9 @@ const CsvExport = <R extends ResourceName>({ blob, getFileName({ type, - params: { ...mergedParams, ...data }, + params: { ...mergedParams, ...serializeFormFields(data, isLocalTime) }, chainConfig, + isLocalTime, }), ); return true; @@ -171,7 +176,7 @@ const CsvExport = <R extends ResourceName>({ } finally { setIsPending(false); } - }, [ chainConfig, fetchFactorySync, mergedParams, recaptcha, type ]); + }, [ chainConfig, fetchFactorySync, mergedParams, recaptcha, type, isLocalTime ]); const downloadFileAsync = React.useCallback(async(data?: FormFields) => { try { @@ -186,9 +191,10 @@ const CsvExport = <R extends ResourceName>({ status: 'pending', type, params: pickBy({ - ...mapValues(data || {}, (value) => dayjs(value).toISOString()), + ...serializeFormFields(data, isLocalTime), ...mergedParams, chain_id: chain?.id, + is_local_time: String(isLocalTime), }, (value) => value !== '' && value !== undefined && value !== null), is_highlighted: false, }; @@ -217,7 +223,7 @@ const CsvExport = <R extends ResourceName>({ } finally { setIsPending(false); } - }, [ chain, csvExportContext, fetchFactoryAsync, mergedParams, recaptcha, type ]); + }, [ chain, csvExportContext, fetchFactoryAsync, mergedParams, recaptcha, type, isLocalTime ]); const handleButtonClick = React.useCallback(() => { if (periodFilter) { diff --git a/src/features/csv-export/components/dialog/CsvExportDialog.tsx b/src/features/csv-export/components/dialog/CsvExportDialog.tsx index db6dfdbe3af..36ce9edfb9b 100644 --- a/src/features/csv-export/components/dialog/CsvExportDialog.tsx +++ b/src/features/csv-export/components/dialog/CsvExportDialog.tsx @@ -1,12 +1,14 @@ // SPDX-License-Identifier: LicenseRef-Blockscout import { chakra, Flex } from '@chakra-ui/react'; +import { getLocalTimeZone, now, toCalendarDateTime, toTimeZone, toZoned } from '@internationalized/date'; import React from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import type { FormFields } from './types'; -import dayjs from 'src/shared/date-and-time/dayjs'; +import { useSettingsContext } from 'src/shell/top-bar/settings/context'; +import SettingsLocalTime from 'src/shell/top-bar/settings/time-format/SettingsLocalTime'; import { Button } from 'src/toolkit/chakra/button'; import { DialogBody, DialogContent, DialogHeader, DialogRoot } from 'src/toolkit/chakra/dialog'; @@ -24,15 +26,47 @@ interface Props { } const CsvExportDialog = ({ open, onOpenChange, onFormSubmit, onCancel, children, isAsyncDownload }: Props) => { + const settings = useSettingsContext(); + const isLocalTime = settings?.isLocalTime ?? true; + const formApi = useForm<FormFields>({ mode: 'onBlur', - defaultValues: { - from_period: dayjs().subtract(1, 'day').format('YYYY-MM-DDTHH:mm'), - to_period: dayjs().format('YYYY-MM-DDTHH:mm'), - }, + defaultValues: (() => { + // the picker holds bare wall-clock values; seed them with "now" read in the zone the toggle + // currently selects, truncated to whole minutes to match what the picker can express + const currentWallClock = toCalendarDateTime(now(isLocalTime ? getLocalTimeZone() : 'UTC')).set({ second: 0, millisecond: 0 }); + return { + from_period: [ currentWallClock.subtract({ days: 1 }) ], + to_period: [ currentWallClock ], + }; + })(), }); - const { handleSubmit, formState } = formApi; + const { handleSubmit, formState, getValues, setValue } = formApi; + + // when the zone toggle flips, re-express the held wall-clock values in the new zone instant-preserving + // (18:40 local → 16:40 UTC), so the toggle only changes how the picked moment is shown — never which + // moment it is. Matches how the global "Local time format" setting re-expresses timestamps everywhere. + const prevIsLocalTimeRef = React.useRef(isLocalTime); + React.useEffect(() => { + const prevIsLocalTime = prevIsLocalTimeRef.current; + if (prevIsLocalTime === isLocalTime) { + return; + } + prevIsLocalTimeRef.current = isLocalTime; + + const fromZone = prevIsLocalTime ? getLocalTimeZone() : 'UTC'; + const toZone = isLocalTime ? getLocalTimeZone() : 'UTC'; + + ([ 'from_period', 'to_period' ] as const).forEach((name) => { + const [ date ] = getValues(name) ?? []; + // a date-only or already-zoned value carries no ambiguous wall-clock to re-express + if (!date || !('hour' in date) || 'timeZone' in date) { + return; + } + setValue(name, [ toCalendarDateTime(toTimeZone(toZoned(date, fromZone), toZone)) ], { shouldValidate: true }); + }); + }, [ isLocalTime, getValues, setValue ]); const handleOpenChange: OnOpenChangeHandler = React.useCallback(({ open }) => { if (formState.isSubmitting && !open) { @@ -46,7 +80,7 @@ const CsvExportDialog = ({ open, onOpenChange, onFormSubmit, onCancel, children, }, [ onOpenChange, formState.isSubmitting, onCancel ]); return ( - <DialogRoot open={ open } onOpenChange={ handleOpenChange } size={{ lgDown: 'full', lg: 'md' }}> + <DialogRoot open={ open } onOpenChange={ handleOpenChange } size={{ lgDown: 'full', lg: 'sm' }}> <DialogContent> <FormProvider { ...formApi }> <chakra.form @@ -58,16 +92,15 @@ const CsvExportDialog = ({ open, onOpenChange, onFormSubmit, onCancel, children, </DialogHeader> <DialogBody> { children } + <SettingsLocalTime id="csv-export-local-time" mt={ 6 } width="calc(100% - 1px)"/> <Flex - columnGap={ 3 } rowGap={ 3 } - mt={ 6 } - flexDir={{ base: 'column', lg: 'row' }} - alignItems={{ base: 'flex-start', lg: 'center' }} - w="100%" + mt={ 3 } + flexDir="column" + alignItems="stretch" > - <CsvExportFormDateField name="from_period" formApi={ formApi }/> - <CsvExportFormDateField name="to_period" formApi={ formApi }/> + <CsvExportFormDateField name="from_period" formApi={ formApi } isLocalTime={ isLocalTime }/> + <CsvExportFormDateField name="to_period" formApi={ formApi } isLocalTime={ isLocalTime }/> </Flex> <Button variant="solid" diff --git a/src/features/csv-export/components/dialog/CsvExportFormDateField.tsx b/src/features/csv-export/components/dialog/CsvExportFormDateField.tsx index c44d26961bf..e0d9284ce64 100644 --- a/src/features/csv-export/components/dialog/CsvExportFormDateField.tsx +++ b/src/features/csv-export/components/dialog/CsvExportFormDateField.tsx @@ -1,35 +1,41 @@ // SPDX-License-Identifier: LicenseRef-Blockscout +import type { DateValue } from '@chakra-ui/react'; +import { getLocalTimeZone, now, toCalendarDateTime } from '@internationalized/date'; import { capitalize } from 'es-toolkit'; import React from 'react'; import type { UseFormReturn } from 'react-hook-form'; import type { FormFields } from './types'; -import dayjs from 'src/shared/date-and-time/dayjs'; - -import { FormFieldText } from 'src/toolkit/components/forms/fields/FormFieldText'; +import { FormFieldDate } from 'src/toolkit/components/forms/fields/FormFieldDate'; interface Props { formApi: UseFormReturn<FormFields>; name: 'from_period' | 'to_period'; + isLocalTime: boolean; } -const CsvExportFormDateField = ({ formApi, name }: Props) => { +const CsvExportFormDateField = ({ formApi, name, isLocalTime }: Props) => { const { formState, getValues, trigger } = formApi; - const validate = React.useCallback((newValue: string) => { + const validate = React.useCallback((newValue: Array<DateValue>) => { + const [ date ] = newValue ?? []; + if (!date) { + return; + } + if (name === 'from_period') { - const toValue = getValues('to_period'); - if (toValue && dayjs(newValue) > dayjs(toValue)) { + const [ toDate ] = getValues('to_period') ?? []; + if (toDate && date.compare(toDate) > 0) { return 'Incorrect date'; } if (formState.errors.to_period) { trigger('to_period'); } } else { - const fromValue = getValues('from_period'); - if (fromValue && dayjs(fromValue) > dayjs(newValue)) { + const [ fromDate ] = getValues('from_period') ?? []; + if (fromDate && fromDate.compare(date) > 0) { return 'Incorrect date'; } if (formState.errors.from_period) { @@ -38,13 +44,27 @@ const CsvExportFormDateField = ({ formApi, name }: Props) => { } }, [ formState.errors.from_period, formState.errors.to_period, getValues, name, trigger ]); + const maxDate = React.useMemo( + () => toCalendarDateTime(now(isLocalTime ? getLocalTimeZone() : 'UTC')), + [ isLocalTime ], + ); + return ( - <FormFieldText<FormFields, typeof name> + <FormFieldDate<FormFields, typeof name> + // remount on zone change: the underlying date-picker only re-syncs its displayed text when the + // value or locale changes, not when the format (and thus the suffix) does, so a bare toggle would + // leave the old suffix on screen until the dialog is reopened + key={ isLocalTime ? 'local' : 'utc' } name={ name } - inputProps={{ type: 'datetime-local', max: dayjs().format('YYYY-MM-DDTHH:mm') }} + max={ maxDate } placeholder={ capitalize(name.replace('_period', '')) } required - rules={{ validate }} + withTime + timeZoneSuffix={ isLocalTime ? undefined : 'UTC' } + bgColor="dialog.bg" + // a bare function would be lost: FormFieldDate spreads rules.validate into an object + // in order to add its own min/max validator + rules={{ validate: { period: validate } }} /> ); }; diff --git a/src/features/csv-export/components/dialog/types.ts b/src/features/csv-export/components/dialog/types.ts index e48d0392cf6..14d6ccb7ff9 100644 --- a/src/features/csv-export/components/dialog/types.ts +++ b/src/features/csv-export/components/dialog/types.ts @@ -1,6 +1,8 @@ // SPDX-License-Identifier: LicenseRef-Blockscout +import type { DateValue } from '@chakra-ui/react'; + export interface FormFields { - from_period: string; - to_period: string; + from_period: Array<DateValue>; + to_period: Array<DateValue>; } diff --git a/src/features/csv-export/components/downloads/CsvExportDownloadsItem.tsx b/src/features/csv-export/components/downloads/CsvExportDownloadsItem.tsx index 2674715087f..cb317f7d24b 100644 --- a/src/features/csv-export/components/downloads/CsvExportDownloadsItem.tsx +++ b/src/features/csv-export/components/downloads/CsvExportDownloadsItem.tsx @@ -16,6 +16,7 @@ import shortenString from 'src/shared/texts/shorten-string'; import SpriteIcon from 'src/sprite/SpriteIcon'; import { Button } from 'src/toolkit/chakra/button'; +import { DATE_PICKER_DATE_TIME_FORMAT } from 'src/toolkit/chakra/date-picker'; import { Link } from 'src/toolkit/chakra/link'; import { Status } from 'src/toolkit/chakra/status'; import { SECOND } from 'src/toolkit/utils/consts'; @@ -136,8 +137,15 @@ const CsvExportDownloadsItem = ({ index, data }: Props) => { const exportDetailsText = (() => { const chainText = chainData ? `on ${ chainData.name }` : undefined; + // render the submitted period in the same zone (and matching format) the dialog showed — legacy + // items predating the toggle carry no flag and fall back to local time + const isLocalTime = data.params.is_local_time !== 'false'; + const formatPeriod = (value: string) => { + const formatted = isLocalTime ? dayjs(value).format(DATE_PICKER_DATE_TIME_FORMAT) : dayjs(value).utc().format(DATE_PICKER_DATE_TIME_FORMAT); + return isLocalTime ? formatted : `${ formatted } UTC`; + }; const periodText = data.params.from_period && data.params.to_period ? - `from ${ dayjs(data.params.from_period).format('lll') } to ${ dayjs(data.params.to_period).format('lll') }` : + `from ${ formatPeriod(data.params.from_period) } to ${ formatPeriod(data.params.to_period) }` : undefined; if (data.type === 'token_holders') { diff --git a/src/features/csv-export/utils/get-file-name.ts b/src/features/csv-export/utils/get-file-name.ts index 9f2c9161340..6b4d0b2fa28 100644 --- a/src/features/csv-export/utils/get-file-name.ts +++ b/src/features/csv-export/utils/get-file-name.ts @@ -7,13 +7,17 @@ import dayjs from 'src/shared/date-and-time/dayjs'; import getPrefixByFilter from './get-prefix-by-filter'; +// filename-safe: no colons or dots, unlike the absolute ISO strings stored in the params +const PERIOD_FILE_NAME_FORMAT = 'YYYY-MM-DD-HH-mm'; + interface Params { type: CsvExportType; params: Record<string, string>; chainConfig?: typeof config; + isLocalTime: boolean; } -export default function getFileName({ type, params, chainConfig }: Params): string { +export default function getFileName({ type, params, chainConfig, isLocalTime }: Params): string { const chainText = chainConfig?.chain.name ? `${ chainConfig.chain.name.replace(' ', '_').toLowerCase() }` : ''; if (type === 'token_holders') { @@ -25,7 +29,11 @@ export default function getFileName({ type, params, chainConfig }: Params): stri } if (type.startsWith('address_')) { - const dateText = params.from_period && params.to_period ? `from_${ params.from_period }_to_${ params.to_period }` : ''; + const formatPeriod = (isoString: string) => + (isLocalTime ? dayjs(isoString) : dayjs(isoString).utc()).format(PERIOD_FILE_NAME_FORMAT); + const dateText = params.from_period && params.to_period ? + `from_${ formatPeriod(params.from_period) }_to_${ formatPeriod(params.to_period) }${ isLocalTime ? '' : '_UTC' }` : + ''; const entityPrefix = getPrefixByFilter(params?.filter_type, params?.filter_value); return [ diff --git a/src/features/csv-export/utils/serialize-form-fields.spec.ts b/src/features/csv-export/utils/serialize-form-fields.spec.ts new file mode 100644 index 00000000000..10d2bf42146 --- /dev/null +++ b/src/features/csv-export/utils/serialize-form-fields.spec.ts @@ -0,0 +1,74 @@ +import { CalendarDate, CalendarDateTime, getLocalTimeZone, parseAbsolute } from '@internationalized/date'; + +import type { FormFields } from '../components/dialog/types'; + +import { describe, it, expect } from 'vitest'; + +import serializeFormFields from './serialize-form-fields'; + +// the local wall clock is what the user picked, so asserting on it keeps the +// expectations independent of the time zone the suite happens to run in +const localFieldsOf = (isoString: string) => { + const parsed = parseAbsolute(isoString, getLocalTimeZone()); + return [ parsed.year, parsed.month, parsed.day, parsed.hour, parsed.minute ]; +}; + +describe('serializeFormFields', () => { + it('returns an empty object when there is no data', () => { + expect(serializeFormFields(undefined, true)).toEqual({}); + }); + + it('omits fields whose value was cleared', () => { + const data = { + from_period: [], + to_period: [ new CalendarDateTime(2026, 7, 30, 12, 0) ], + } as FormFields; + + expect(Object.keys(serializeFormFields(data, true))).toEqual([ 'to_period' ]); + }); + + it('returns an empty object when every field was cleared', () => { + expect(serializeFormFields({ from_period: [], to_period: [] } as FormFields, true)).toEqual({}); + }); + + it('resolves a CalendarDateTime in the local zone, preserving the wall clock', () => { + const data = { from_period: [ new CalendarDateTime(2026, 7, 30, 12, 0) ] } as FormFields; + + const result = serializeFormFields(data, true).from_period; + + expect(result).toMatch(/Z$/); + expect(localFieldsOf(result)).toEqual([ 2026, 7, 30, 12, 0 ]); + }); + + it('reads the wall clock as UTC when local time is off', () => { + const data = { from_period: [ new CalendarDateTime(2026, 7, 30, 12, 0) ] } as FormFields; + + expect(serializeFormFields(data, false).from_period).toBe('2026-07-30T12:00:00.000Z'); + }); + + it('treats a date-only value as local midnight', () => { + const data = { from_period: [ new CalendarDate(2026, 7, 30) ] } as FormFields; + + expect(localFieldsOf(serializeFormFields(data, true).from_period)).toEqual([ 2026, 7, 30, 0, 0 ]); + }); + + it('keeps the instant of a zoned value regardless of the local zone', () => { + const data = { + from_period: [ parseAbsolute('2026-07-30T09:05:00Z', 'America/New_York') ], + } as FormFields; + + expect(serializeFormFields(data, true).from_period).toBe('2026-07-30T09:05:00.000Z'); + }); + + it('serializes every populated field', () => { + const data = { + from_period: [ new CalendarDateTime(2026, 7, 29, 8, 30) ], + to_period: [ new CalendarDateTime(2026, 7, 30, 17, 45) ], + } as FormFields; + + const result = serializeFormFields(data, true); + + expect(localFieldsOf(result.from_period)).toEqual([ 2026, 7, 29, 8, 30 ]); + expect(localFieldsOf(result.to_period)).toEqual([ 2026, 7, 30, 17, 45 ]); + }); +}); diff --git a/src/features/csv-export/utils/serialize-form-fields.ts b/src/features/csv-export/utils/serialize-form-fields.ts new file mode 100644 index 00000000000..49ea859f650 --- /dev/null +++ b/src/features/csv-export/utils/serialize-form-fields.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import type { DateValue } from '@chakra-ui/react'; +import { getLocalTimeZone, toZoned } from '@internationalized/date'; + +import type { FormFields } from '../components/dialog/types'; + +// the date picker yields wall-clock values carrying no time zone; the toggle in the dialog decides +// whether those wall-clock digits are read as local time or as UTC before the API receives them as +// absolute timestamps +const toAbsoluteString = (date: DateValue, isLocalTime: boolean): string => { + if ('timeZone' in date) { + return date.toAbsoluteString(); + } + return toZoned(date, isLocalTime ? getLocalTimeZone() : 'UTC').toAbsoluteString(); +}; + +export default function serializeFormFields(data: FormFields | undefined, isLocalTime: boolean): Record<string, string> { + const result: Record<string, string> = {}; + + Object.entries(data ?? {}).forEach(([ key, value ]) => { + const [ date ] = value ?? []; + if (date) { + result[key] = toAbsoluteString(date, isLocalTime); + } + }); + + return result; +} diff --git a/src/features/flashblocks/config.ts b/src/features/flashblocks/config.ts index a5fd39dfa09..46ef4155787 100644 --- a/src/features/flashblocks/config.ts +++ b/src/features/flashblocks/config.ts @@ -26,7 +26,7 @@ const config: Feature<{ socketUrl: string; type: 'optimism' | 'megaEth'; name: s isEnabled: true, socketUrl, type: 'optimism', - name: 'flashblock', + name: 'subblock', }); } diff --git a/src/features/flashblocks/hooks/useFlashblocksSocketData.ts b/src/features/flashblocks/hooks/useFlashblocksSocketData.ts index 402b1e25b18..5e095b2b4af 100644 --- a/src/features/flashblocks/hooks/useFlashblocksSocketData.ts +++ b/src/features/flashblocks/hooks/useFlashblocksSocketData.ts @@ -14,7 +14,7 @@ import { SECOND } from 'src/toolkit/utils/consts'; const flashblocksFeature = config.features.flashblocks; const MAX_FLASHBLOCKS_COUNT = 50; -const QUEUE_TIME_THRESHOLD = 200; +const QUEUE_TIME_THRESHOLD = 150; type Status = 'initial' | 'connected' | 'disconnected' | 'error'; diff --git a/src/features/marketplace/components/MarketplaceAppIframe.tsx b/src/features/marketplace/components/MarketplaceAppIframe.tsx index ebbc7349df6..6a3b156312c 100644 --- a/src/features/marketplace/components/MarketplaceAppIframe.tsx +++ b/src/features/marketplace/components/MarketplaceAppIframe.tsx @@ -80,11 +80,11 @@ const Content = chakra(({ appUrl, address, message, isEssentialDapp, className } minW="100%" className={ className } > - { (isFrameLoading) && ( + { (isFrameLoading || !appUrl) && ( <ContentLoader/> ) } - { isReady && ( + { isReady && appUrl && ( <chakra.iframe key={ iframeKey } allow={ IFRAME_ALLOW_ATTRIBUTE } diff --git a/src/features/marketplace/pages/dapp/MarketplaceApp.pw.tsx b/src/features/marketplace/pages/dapp/MarketplaceApp.pw.tsx index f2a25cdd8a0..b2076c03ba7 100644 --- a/src/features/marketplace/pages/dapp/MarketplaceApp.pw.tsx +++ b/src/features/marketplace/pages/dapp/MarketplaceApp.pw.tsx @@ -39,7 +39,7 @@ const testFn = async({ render, mockAssetResponse, mockEnvs, mockRpcResponse, moc await expect(component).toHaveScreenshot(); }; -test('base view +@dark-mode', testFn); +test('base view', testFn); test.describe('mobile', () => { test.use({ viewport: devices['iPhone 13 Pro'].viewport }); diff --git a/src/features/marketplace/pages/dapp/MarketplaceApp.tsx b/src/features/marketplace/pages/dapp/MarketplaceApp.tsx index 8cc878932fe..5a1039db73a 100644 --- a/src/features/marketplace/pages/dapp/MarketplaceApp.tsx +++ b/src/features/marketplace/pages/dapp/MarketplaceApp.tsx @@ -41,7 +41,10 @@ export default function MarketplaceApp() { const { setIsAutoConnectDisabled } = useMarketplaceContext(); - const appUrl = useMemo(() => getAppUrl(data?.url, router), [ data?.url, router ]); + const appUrl = useMemo( + () => getAppUrl(isPlaceholderData ? undefined : data?.url, router), + [ data?.url, isPlaceholderData, router ], + ); const message = useMemo(() => ({ blockscoutColorMode: colorMode, diff --git a/src/features/marketplace/pages/dapp/__screenshots__/MarketplaceApp.pw.tsx_dark-color-mode_base-view-dark-mode-1.png b/src/features/marketplace/pages/dapp/__screenshots__/MarketplaceApp.pw.tsx_dark-color-mode_base-view-dark-mode-1.png deleted file mode 100644 index 491409b4663..00000000000 Binary files a/src/features/marketplace/pages/dapp/__screenshots__/MarketplaceApp.pw.tsx_dark-color-mode_base-view-dark-mode-1.png and /dev/null differ diff --git a/src/features/marketplace/pages/dapp/__screenshots__/MarketplaceApp.pw.tsx_default_base-view-dark-mode-1.png b/src/features/marketplace/pages/dapp/__screenshots__/MarketplaceApp.pw.tsx_default_base-view-1.png similarity index 100% rename from src/features/marketplace/pages/dapp/__screenshots__/MarketplaceApp.pw.tsx_default_base-view-dark-mode-1.png rename to src/features/marketplace/pages/dapp/__screenshots__/MarketplaceApp.pw.tsx_default_base-view-1.png diff --git a/src/features/marketplace/pages/essential-dapp/multisend/Multisend.tsx b/src/features/marketplace/pages/essential-dapp/multisend/Multisend.tsx index 8d17eb96444..3c43005f0f2 100644 --- a/src/features/marketplace/pages/essential-dapp/multisend/Multisend.tsx +++ b/src/features/marketplace/pages/essential-dapp/multisend/Multisend.tsx @@ -1,16 +1,19 @@ // SPDX-License-Identifier: LicenseRef-Blockscout -import { Box } from '@chakra-ui/react'; +import { Box, Center } from '@chakra-ui/react'; import { MultisenderWidget } from '@multisender.app/multisender-react-widget'; import React from 'react'; import AdBanner from 'src/features/ads/banner/components/AdBanner'; +import Web3Boundary from 'src/features/connect-wallet/components/Web3Boundary'; import essentialDappsChainsConfig from 'src/features/marketplace/chains-config/essential-dapps'; import config from 'src/config'; import { getFeaturePayload } from 'src/config/utils/features'; import useIsMobile from 'src/shared/hooks/useIsMobile'; +import { ContentLoader } from 'src/toolkit/components/loaders/ContentLoader'; + const feature = getFeaturePayload(config.features.marketplace); const dappConfig = feature?.essentialDapps?.multisend; @@ -522,16 +525,18 @@ const Multisend = () => { return ( <> <Container> - <MultisenderWidget - config={ widgetConfig } - logoType="minified" - posthogKey={ dappConfig?.posthogKey } - posthogHost={ dappConfig?.posthogHost } - classNames={{ - theme: 'multisenderTheme', - mantineProvider: 'multisenderMantineProvider', - }} - /> + <Web3Boundary fallback={ <Center h="500px"><ContentLoader/></Center> }> + <MultisenderWidget + config={ widgetConfig } + logoType="minified" + posthogKey={ dappConfig?.posthogKey } + posthogHost={ dappConfig?.posthogHost } + classNames={{ + theme: 'multisenderTheme', + mantineProvider: 'multisenderMantineProvider', + }} + /> + </Web3Boundary> </Container> { (feature?.essentialDappsAdEnabled && !isMobile) && ( <AdBanner diff --git a/src/features/multichain/pages/search-results/__screenshots__/SearchResults.pw.tsx_default_desktop-base-view-1.png b/src/features/multichain/pages/search-results/__screenshots__/SearchResults.pw.tsx_default_desktop-base-view-1.png index 29434cae890..60ef3594f5e 100644 Binary files a/src/features/multichain/pages/search-results/__screenshots__/SearchResults.pw.tsx_default_desktop-base-view-1.png and b/src/features/multichain/pages/search-results/__screenshots__/SearchResults.pw.tsx_default_desktop-base-view-1.png differ diff --git a/src/features/multichain/pages/search-results/__screenshots__/SearchResults.pw.tsx_default_mobile-base-view-1.png b/src/features/multichain/pages/search-results/__screenshots__/SearchResults.pw.tsx_default_mobile-base-view-1.png index b1b5d680b12..083394dcba7 100644 Binary files a/src/features/multichain/pages/search-results/__screenshots__/SearchResults.pw.tsx_default_mobile-base-view-1.png and b/src/features/multichain/pages/search-results/__screenshots__/SearchResults.pw.tsx_default_mobile-base-view-1.png differ diff --git a/src/features/multichain/pages/token-transfers/MultichainTokenTransfersLocal.tsx b/src/features/multichain/pages/token-transfers/MultichainTokenTransfersLocal.tsx index e60082f30d5..59d4a9a3f9c 100644 --- a/src/features/multichain/pages/token-transfers/MultichainTokenTransfersLocal.tsx +++ b/src/features/multichain/pages/token-transfers/MultichainTokenTransfersLocal.tsx @@ -9,6 +9,7 @@ import ActionBar from 'src/shell/page/action-bar/ActionBar'; import TokenTransfersListItem from 'src/slices/token-transfer/pages/index/TokenTransfersListItem'; import TokenTransfersTable from 'src/slices/token-transfer/pages/index/TokenTransfersTable'; +import { getTokenTransferKey } from 'src/slices/token-transfer/utils/get-token-transfer-key'; import TokenTypeFilter from 'src/slices/token/components/TokenTypeFilter'; import { useMultichainContext } from 'src/features/multichain/context'; @@ -63,7 +64,7 @@ const MultichainTokenTransfersLocal = ({ query, typeFilter, onTokenTypesChange } <Box hideFrom="lg"> { query.data?.items.slice(0, renderedItemsNum).map((item, index) => ( <TokenTransfersListItem - key={ (item.transaction_hash ?? '') + item.log_index + (query.isPlaceholderData ? index : '') + (chainData ? chainData.id : '') } + key={ getTokenTransferKey(item) + (query.isPlaceholderData ? index : '') + (chainData ? chainData.id : '') } isLoading={ query.isPlaceholderData } item={ item } chainData={ chainData } diff --git a/src/features/rollup/arbitrum/pages/txn-withdrawals/ArbitrumL2TxnWithdrawalsClaimButton.tsx b/src/features/rollup/arbitrum/pages/txn-withdrawals/ArbitrumL2TxnWithdrawalsClaimButton.tsx index a7d877d53ef..a7ff8c48494 100644 --- a/src/features/rollup/arbitrum/pages/txn-withdrawals/ArbitrumL2TxnWithdrawalsClaimButton.tsx +++ b/src/features/rollup/arbitrum/pages/txn-withdrawals/ArbitrumL2TxnWithdrawalsClaimButton.tsx @@ -12,6 +12,7 @@ import type { ResourceError } from 'src/api/resources'; import Web3Boundary from 'src/features/connect-wallet/components/Web3Boundary'; import useWallet from 'src/features/connect-wallet/hooks/useWallet'; +import WithdrawalClaimButton from 'src/features/rollup/common/components/WithdrawalClaimButton'; import config from 'src/config'; import getErrorMessage from 'src/shared/errors/get-error-message'; @@ -20,7 +21,6 @@ import getErrorProp from 'src/shared/errors/get-error-prop'; import capitalizeFirstLetter from 'src/shared/texts/capitalize-first-letter'; import { Button } from 'src/toolkit/chakra/button'; -import { Skeleton } from 'src/toolkit/chakra/skeleton'; import { toaster } from 'src/toolkit/chakra/toaster'; import ArbitrumL2TxnWithdrawalsClaimTx from './ArbitrumL2TxnWithdrawalsClaimTx'; @@ -128,17 +128,13 @@ const ArbitrumL2TxnWithdrawalsClaimButtonContent = ({ messageId, txHash, complet const isLoading = isPending || web3Wallet.isOpen; return ( - <Skeleton loading={ isDataLoading }> - <Button - size="sm" - variant="outline" - onClick={ handleClaimClick } - loading={ isLoading } - loadingText="Claim" - > - Claim - </Button> - </Skeleton> + <WithdrawalClaimButton + onClick={ handleClaimClick } + loading={ isLoading } + loadingSkeleton={ isDataLoading } + > + Claim + </WithdrawalClaimButton> ); }; diff --git a/src/features/rollup/common/components/WithdrawalClaimButton.tsx b/src/features/rollup/common/components/WithdrawalClaimButton.tsx new file mode 100644 index 00000000000..95821f16679 --- /dev/null +++ b/src/features/rollup/common/components/WithdrawalClaimButton.tsx @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import React from 'react'; + +import { chains } from 'src/features/connect-wallet/utils/chains'; +import { useMultichainContext } from 'src/features/multichain/context'; + +import config from 'src/config'; +import { getFeaturePayload } from 'src/config/utils/features'; + +import type { ButtonProps } from 'src/toolkit/chakra/button'; +import { Button } from 'src/toolkit/chakra/button'; +import { Tooltip } from 'src/toolkit/chakra/tooltip'; + +const WithdrawalClaimButton = (props: ButtonProps) => { + + const multichainContext = useMultichainContext(); + const parentChain = getFeaturePayload((multichainContext?.chain.app_config ?? config).features.rollup)?.parentChain; + const isParentChainConfigured = Boolean(parentChain?.id && chains.some(chain => chain.id === parentChain.id)); + + return ( + <Tooltip + content="The direct claim flow is not available because the parent chain is not configured. Please contact the project team to report this issue." + disabled={ isParentChainConfigured } + > + <Button + variant="outline" + size="sm" + disabled={ !isParentChainConfigured } + { ...props } + > + Claim + </Button> + </Tooltip> + ); +}; + +export default React.memo(WithdrawalClaimButton); diff --git a/src/features/rollup/optimism/components/OptimisticL2ClaimButton.tsx b/src/features/rollup/optimism/components/OptimisticL2ClaimButton.tsx index 50f2b7639f0..caa057e0ffe 100644 --- a/src/features/rollup/optimism/components/OptimisticL2ClaimButton.tsx +++ b/src/features/rollup/optimism/components/OptimisticL2ClaimButton.tsx @@ -14,6 +14,8 @@ import { Button } from 'src/toolkit/chakra/button'; import { Link } from 'src/toolkit/chakra/link'; import { useDisclosure } from 'src/toolkit/hooks/useDisclosure'; +import WithdrawalClaimButton from '../../common/components/WithdrawalClaimButton'; + const rollupFeature = config.features.rollup; export const canClaimDirectlyGuard = (data: Omit<schemas['OptimismTransactionWithdrawal'], 'nonce'>) => { @@ -57,7 +59,11 @@ const OptimisticL2ClaimButton = ({ data, from, onSuccess, source }: Props) => { /> </Web3Boundary> ) } - <Button variant="outline" size="sm" onClick={ modal.onOpen }>Claim</Button> + <WithdrawalClaimButton + onClick={ modal.onOpen } + > + Claim + </WithdrawalClaimButton> </> ); } diff --git a/src/features/rollup/optimism/components/OptimisticL2ClaimModal.tsx b/src/features/rollup/optimism/components/OptimisticL2ClaimModal.tsx index ad39937e1a2..e61d5c09f81 100644 --- a/src/features/rollup/optimism/components/OptimisticL2ClaimModal.tsx +++ b/src/features/rollup/optimism/components/OptimisticL2ClaimModal.tsx @@ -5,7 +5,7 @@ import React from 'react'; import type { SubmitHandler } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form'; import type { Abi } from 'viem'; -import { useSwitchChain, useWaitForTransactionReceipt, useWalletClient } from 'wagmi'; +import { useSwitchChain, useWaitForTransactionReceipt, useWriteContract } from 'wagmi'; import type { schemas } from '@blockscout/api-types'; @@ -65,7 +65,7 @@ const OptimisticL2ClaimModal = ({ data, onOpenChange, proofSubmitterAddress, onS const { connect: connectWeb3Wallet, isConnected: isWeb3WalletConnected, isOpen: isWeb3WalletOpen } = useWeb3Wallet({ source: 'Smart contracts' }); const { switchChainAsync } = useSwitchChain(); - const { data: walletClient } = useWalletClient({ chainId: parentChain?.id ? Number(parentChain.id) : undefined }); + const { writeContractAsync } = useWriteContract(); const { status: txStatus, error: txError, isLoading: isTxPending } = useWaitForTransactionReceipt({ hash: txHash, @@ -95,11 +95,9 @@ const OptimisticL2ClaimModal = ({ data, onOpenChange, proofSubmitterAddress, onS throw new Error('Feature is not enabled'); } - await switchChainAsync({ chainId: Number(parentChain.id) }); + const chainId = Number(parentChain.id); - if (!walletClient) { - throw new Error('Wallet Client is not defined'); - } + await switchChainAsync({ chainId }); if ( data.portal_contract_address_hash === null || @@ -125,11 +123,12 @@ const OptimisticL2ClaimModal = ({ data, onOpenChange, proofSubmitterAddress, onS formData.address, ]; - const hash = await walletClient.writeContract({ + const hash = await writeContractAsync({ args, abi: [ FINALIZE_WITHDRAWAL_ABI ] as Abi, functionName: FINALIZE_WITHDRAWAL_ABI.name, address: data.portal_contract_address_hash as `0x${ string }`, + chainId, }); setTxHash(hash); @@ -137,7 +136,7 @@ const OptimisticL2ClaimModal = ({ data, onOpenChange, proofSubmitterAddress, onS } catch (error) { showErrorToast(error); } - }, [ walletClient, switchChainAsync, data, showErrorToast ]); + }, [ writeContractAsync, switchChainAsync, data, showErrorToast ]); React.useEffect(() => { if (!txHash) { diff --git a/src/features/tx-interpretation/common/components/TxInterpretation.tsx b/src/features/tx-interpretation/common/components/TxInterpretation.tsx index 0c57528e1a0..1104f344bae 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 00000000000..be5ff2758a8 --- /dev/null +++ b/src/features/tx-interpretation/common/utils/address-to-plain-text.ts @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import type { AddressNameSource } from 'src/slices/address/utils/get-address-name'; +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: AddressNameSource) { + 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 00000000000..bfa1f69bd8c --- /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 00000000000..1851f8a2386 --- /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 00000000000..925901c593d --- /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 00000000000..c469f1d341d --- /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/features/web3-wallet/hooks/useDetectWalletEip6963.ts b/src/features/web3-wallet/hooks/useDetectWalletEip6963.ts index 671cc04278a..2af1c706588 100644 --- a/src/features/web3-wallet/hooks/useDetectWalletEip6963.ts +++ b/src/features/web3-wallet/hooks/useDetectWalletEip6963.ts @@ -34,11 +34,18 @@ export default function useDetectWalletEip6963() { const detectionTimeoutRef = React.useRef<number | null>(null); const handleAnnounceProviderEvent = React.useCallback((event: CustomEvent<EIP6963ProviderDetail>) => { + // A non-compliant wallet extension can dispatch the announce event with a null or partial + // `detail`; bail instead of dereferencing `info` on it (EIP-6963 requires it, but we can't trust it). + const info = event.detail?.info; + if (!info) { + return; + } + const wallet = Object.entries(WALLET_RDNS_MAP) - .find(([ , rdns ]) => rdns === event.detail.info.rdns)?.[0] as WalletType | undefined; + .find(([ , rdns ]) => rdns === info.rdns)?.[0] as WalletType | undefined; if (wallet && !DETECTED_PROVIDERS[wallet]) { - DETECTED_PROVIDERS[wallet] = event.detail?.provider; + DETECTED_PROVIDERS[wallet] = event.detail.provider; } }, []); diff --git a/src/pages/_error.tsx b/src/pages/_error.tsx index fb043a624a3..ee2b4cb09e8 100644 --- a/src/pages/_error.tsx +++ b/src/pages/_error.tsx @@ -8,19 +8,12 @@ import Rollbar from 'rollbar'; import type { Props as ServerSidePropsCommon } from 'src/server/getServerSideProps/handlers'; import config from 'src/config'; +import { buildServerConfig } from 'src/services/rollbar/serverConfig'; import * as cookies from 'src/shared/storage/cookies'; -const rollbar = config.services.rollbar.clientToken ? new Rollbar({ - accessToken: config.services.rollbar.clientToken, - environment: config.services.rollbar.environment, - payload: { - code_version: config.services.rollbar.codeVersion, - app_instance: config.services.rollbar.instance, - }, - maxItems: 10, - captureUncaught: true, - captureUnhandledRejections: true, -}) : undefined; +const rollbar = config.services.rollbar.clientToken ? + new Rollbar(buildServerConfig(config.services.rollbar.clientToken)) : + undefined; type Props = ServerSidePropsCommon & { statusCode: number; @@ -39,10 +32,10 @@ CustomErrorComponent.getInitialProps = async(context: NextPageContext) => { const cookies = req?.headers?.cookie || ''; if (rollbar) { - rollbar.error(err?.message ?? 'Unknown error', { - cause: err?.cause, - stack: err?.stack, - }); + // Pass the Error itself, not just its message, so Rollbar builds a trace carrying `exception.class` + // — the field the shared `isIgnoredExceptionClass` check in the server config reads to drop the DOM + // / Abort noise. Falls back to a bare message when Next.js hands us no error object. + rollbar.error(err ?? 'Unknown error', { cause: err?.cause }); } return { diff --git a/src/pages/api/config.ts b/src/pages/api/config.ts index 0d3a34d0da8..7458b424802 100644 --- a/src/pages/api/config.ts +++ b/src/pages/api/config.ts @@ -4,9 +4,11 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import { collator } from 'src/shared/texts/collator'; +const EXPOSED_STARTUP_ENVS = [ 'FAVICON_MASTER_URL' ]; + export default async function configHandler(req: NextApiRequest, res: NextApiResponse) { const publicEnvs = Object.entries(process.env) - .filter(([ key ]) => key.startsWith('NEXT_PUBLIC_')) + .filter(([ key ]) => key.startsWith('NEXT_PUBLIC_') || EXPOSED_STARTUP_ENVS.includes(key)) .sort(([ keyA ], [ keyB ]) => collator.compare(keyA, keyB)) .reduce((result, [ key, value ]) => { result[key] = value || ''; diff --git a/src/pages/tx/[hash].tsx b/src/pages/tx/[hash].tsx index 6915289a7ca..c07c9dd5650 100644 --- a/src/pages/tx/[hash].tsx +++ b/src/pages/tx/[hash].tsx @@ -1,19 +1,37 @@ // SPDX-License-Identifier: LicenseRef-Blockscout -import type { NextPage } from 'next'; +import type { GetServerSideProps, NextPage } from 'next'; import dynamic from 'next/dynamic'; +import type { Route } from 'nextjs-routes'; import React from 'react'; import type { Props } from 'src/server/getServerSideProps/handlers'; +import * as gSSP from 'src/server/getServerSideProps/main'; import PageNextJs from 'src/server/PageNextJs'; +import detectBotRequest from 'src/server/utils/detectBotRequest'; +import fetchApi from 'src/server/utils/fetchApi'; + +import getOgDescriptionParams from 'src/slices/tx/utils/get-og-description-params'; + +import config from 'src/config'; +import { 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]'; + +const API_TIMEOUT = 2 * SECOND; + +const PREVIEW_QUERY_PARAMS = { decode_input: 'true', preload_ens: 'true', preload_metadata: 'true' }; const Transaction = dynamic(() => { return import('src/slices/tx/pages/details/Transaction'); }, { ssr: false }); -const Page: NextPage<Props> = (props: Props) => { +const Page: NextPage<Props<typeof pathname>> = (props: Props<typeof pathname>) => { return ( - <PageNextJs pathname="/tx/[hash]" query={ props.query }> + <PageNextJs pathname={ pathname } query={ props.query } apiData={ props.apiData }> <Transaction/> </PageNextJs> ); @@ -21,4 +39,25 @@ const Page: NextPage<Props> = (props: Props) => { export default Page; -export { tx as getServerSideProps } from 'src/server/getServerSideProps/main'; +export const getServerSideProps: GetServerSideProps<Props<typeof pathname>> = async(ctx) => { + const baseResponse = await gSSP.tx<typeof pathname>(ctx); + + // Only social-preview bots get the enhanced description, and only server-side: crawlers don't run JS, + // and the SEO tags this route emits need no API data. + const isSocialPreviewBot = config.metadata.og.enhancedDataEnabled && detectBotRequest(ctx.req)?.type === 'social_preview'; + + 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_preview', pathParams: { hash }, queryParams: PREVIEW_QUERY_PARAMS, 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/PageMetadata.tsx b/src/server/PageMetadata.tsx index 9d054379d7e..1e6687cf787 100644 --- a/src/server/PageMetadata.tsx +++ b/src/server/PageMetadata.tsx @@ -32,11 +32,20 @@ const PageMetadata = <Pathname extends Route['pathname']>(props: Props<Pathname> <meta property="og:type" content="website"/> { /* Twitter Meta Tags */ } - <meta name="twitter:card" content="summary_large_image"/> - <meta property="twitter:domain" content={ config.app.host }/> <meta name="twitter:title" content={ opengraph.title }/> { opengraph.description && <meta name="twitter:description" content={ opengraph.description }/> } - { opengraph.imageUrl && <meta property="twitter:image" content={ opengraph.imageUrl }/> } + <meta property="twitter:domain" content={ config.app.host }/> + { opengraph.imageUrl ? ( + <> + <meta name="twitter:card" content="summary_large_image"/> + <meta property="twitter:image" content={ opengraph.imageUrl }/> + </> + ) : ( + <> + <meta name="twitter:card" content="summary"/> + <meta property="twitter:image" content={ config.app.baseUrl + '/assets/favicon/android-chrome-192x192.png' }/> + </> + ) } { /* Prevent auto zoom in inputs on mobile */ } <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"/> diff --git a/src/server/getServerSideProps/guards.ts b/src/server/getServerSideProps/guards.ts index a37bbdece48..2212cccd8f0 100644 --- a/src/server/getServerSideProps/guards.ts +++ b/src/server/getServerSideProps/guards.ts @@ -39,7 +39,7 @@ export const accountAuth0: Guard = (chainConfig: typeof config) => async() => { }; export const verifiedAddresses: Guard = (chainConfig: typeof config) => async() => { - if (!getFeaturePayload(chainConfig.features.account)?.addressVerificationEnabled) { + if (!getFeaturePayload(chainConfig.features.account)?.verifiedAddresses?.isEnabled) { return { notFound: true, }; diff --git a/src/server/primedRequests/CONTEXT.md b/src/server/primedRequests/CONTEXT.md index bf55345aa03..901af978e4b 100644 --- a/src/server/primedRequests/CONTEXT.md +++ b/src/server/primedRequests/CONTEXT.md @@ -39,7 +39,8 @@ cannot alter behavior, only timing: Each registered page has a colocated `*.primed.spec.tsx` that runs the real inline script and mounts the real page (in its layout) and asserts **primed ⊆ the page's first-render requests**, -byte-identically. The subset direction is deliberate: priming is opt-in per resource, so +byte-identically. The mount uses a fake socket transport (`vitest/utils/mockSocket.ts`) whose +channels join immediately, so queries a page defers until its socket channel is up. The subset direction is deliberate: priming is opt-in per resource, so *under*-priming is fine, but priming something the page does not actually request on first render is a bug and fails the test. `index.spec.ts` additionally fails if a registered page lacks its spec. This is what lets the registry be trusted without a running backend. diff --git a/src/server/primedRequests/pages/token.ts b/src/server/primedRequests/pages/token.ts index ef3995d821f..652c00d4fcf 100644 --- a/src/server/primedRequests/pages/token.ts +++ b/src/server/primedRequests/pages/token.ts @@ -7,6 +7,7 @@ const hashFromRoute = { routeParam: 'hash' }; const getResources = (): Array<PrimedResource> => [ { resource: 'core:token', pathParams: { hash: hashFromRoute }, tabs: [ 'index' ] }, { resource: 'core:token_counters', pathParams: { hash: hashFromRoute }, tabs: [ 'index' ] }, + { resource: 'core:address', pathParams: { hash: hashFromRoute }, tabs: [ 'index' ] }, ]; export const tokenPage: PagePrimerConfig = { diff --git a/src/server/utils/detectBotRequest.ts b/src/server/utils/detectBotRequest.ts index 2b205a8a57a..d8152539e31 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/server/utils/fetchApi.ts b/src/server/utils/fetchApi.ts index ae2be099d2e..fbce44b32b2 100644 --- a/src/server/utils/fetchApi.ts +++ b/src/server/utils/fetchApi.ts @@ -36,6 +36,7 @@ export default async function fetchApi<R extends ResourceName = never, S = Resou httpLogger.logger.info({ message: 'API fetch', url, code: response.status, duration }); } else { httpLogger.logger.error({ message: 'API fetch', url, code: response.status, duration }); + return; } return await response.json() as Promise<S>; diff --git a/src/services/rollbar/clientConfig.ts b/src/services/rollbar/clientConfig.ts index ecfaafda4f3..c3f3f497011 100644 --- a/src/services/rollbar/clientConfig.ts +++ b/src/services/rollbar/clientConfig.ts @@ -6,11 +6,26 @@ import config from 'src/config'; import { ABSENT_PARAM_ERROR_MESSAGE } from 'src/shared/errors/throw-on-absent-param-error'; import { RESOURCE_LOAD_ERROR_MESSAGE } from 'src/shared/errors/throw-on-resource-load-error'; -import { isBot, isHeadlessBrowser, isNextJsChunkError, getRequestInfo, getExceptionClass, getExceptionOriginFileName } from './utils'; +import { + isBot, + isHeadlessBrowser, + isNextJsChunkError, + getRequestInfo, + isIgnoredExceptionClass, + isMonacoCdnError, +} from './utils'; /** * Rollbar client options — imported only once the SDK chunk is loaded so the ignore helpers and * message constants stay out of the critical path until then (they ride along with `rollbar`). + * + * This `checkIgnore` reads `window` (the bot / headless checks) and only ever runs in the browser. + * The server-side error-page instance has its own config in `serverConfig.ts` and must not reuse + * this predicate — see the note there. + * + * Uncaught errors are already filtered by origin in the queue's early listener (only our own + * `/_next/` bundle is forwarded), so the rules here handle what still gets through: explicit + * `rollbar` calls, and own-bundle traces that reach third-party code deeper in the stack. */ export function buildClientConfig(accessToken: string): Configuration { return { @@ -33,29 +48,11 @@ export function buildClientConfig(accessToken: string): Configuration { return true; } - const exceptionClass = getExceptionClass(item); - const IGNORED_EXCEPTION_CLASSES = [ - // these are React errors - "NotFoundError: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node." - // they could be caused by browser extensions - // one of the examples - https://github.com/facebook/react/issues/11538 - // we can ignore them for now - 'NotFoundError', - - 'AbortError', - ]; - - if (exceptionClass && IGNORED_EXCEPTION_CLASSES.includes(exceptionClass)) { + if (isIgnoredExceptionClass(item)) { return true; } - const originFileName = getExceptionOriginFileName(item); - const IGNORED_ORIGIN_FILE_NAMES_CHUNKS = [ - '/node_modules/@walletconnect', - '/node_modules/@reown', - 'chrome-extension://', - ]; - - if (originFileName && IGNORED_ORIGIN_FILE_NAMES_CHUNKS.some((chunk) => originFileName.includes(chunk))) { + if (isMonacoCdnError(item)) { return true; } @@ -76,6 +73,23 @@ export function buildClientConfig(accessToken: string): Configuration { // Filter out client-side navigation cancellations 'cancelled navigation', + + // Browser auto-translate (Google Translate et al.) and DOM-mutating extensions move nodes + // React owns, so React's commit-phase removeChild / insertBefore / replaceChild then fails + // with a NotFoundError. Environmental and unactionable — React owns that DOM, the only way a + // node "is not a child" is an outside mutation. The NotFoundError class is already dropped via + // checkIgnore, but our error boundary re-reports it as a message (no body.trace), which the + // class check can't see — so match the shared DOM-exception tail here too. Covers all three + // node ops (…removeChild/insertBefore/replaceChild… "is not a child of this node."). + 'is not a child of this node', + + // WalletConnect/AppKit rejects a pending pairing when its TTL elapses before the user + // completes the connect flow (opened the modal, walked away). Expected user behaviour, not a + // fault, surfaced from vendored SDK code. Covers the whole expiry family since the noun + // varies (proposal / pairing / session request) but "expired" is the shared, WC-specific tail. + 'Proposal expired', + 'Pairing expired', + 'Session request expired', ], maxItems: 10, // Max items per page load // uncaught / unhandledrejection coverage is owned by the early window listeners in queue.ts — diff --git a/src/services/rollbar/queue.spec.ts b/src/services/rollbar/queue.spec.ts index e308eafa37f..76bcab8f0b1 100644 --- a/src/services/rollbar/queue.spec.ts +++ b/src/services/rollbar/queue.spec.ts @@ -36,6 +36,9 @@ const ACCESS_TOKEN = 'test-token'; const CALL_TIME_MS = 1_752_600_000_000; const CALL_TIME_S = CALL_TIME_MS / 1_000; const QUEUE_CAP = 100; +// Uncaught errors are only forwarded when the browser attributes them to a script in our own bundle +// (`/_next/`); this is the filename such an event carries. +const APP_BUNDLE_FILENAME = 'https://host.blockscout.com/_next/static/chunks/main.js'; async function importQueue() { return await import('./queue'); @@ -152,7 +155,6 @@ describe('rollbar queue', () => { expect(rollbarInstance.warn).not.toHaveBeenCalled(); expect(errorHandler).toBeTypeOf('function'); expect(removeEventListenerSpy).toHaveBeenCalledWith('error', errorHandler, true); - expect(removeEventListenerSpy).toHaveBeenCalledWith('unhandledrejection', expect.any(Function)); }); }); @@ -162,7 +164,7 @@ describe('rollbar queue', () => { const remove = queue.installEarlyListeners(); const uncaught = new Error('uncaught boom'); - window.dispatchEvent(new ErrorEvent('error', { message: 'uncaught boom', error: uncaught })); + window.dispatchEvent(new ErrorEvent('error', { message: 'uncaught boom', error: uncaught, filename: APP_BUNDLE_FILENAME })); expect(rollbarInstance.error).not.toHaveBeenCalled(); await queue.init(); @@ -175,27 +177,64 @@ describe('rollbar queue', () => { remove(); }); - it('should buffer unhandledrejection events until init', async() => { + it('should report a non-Error thrown value under a fallback message with the value as custom data', async() => { const queue = await importQueue(); const remove = queue.installEarlyListeners(); - const reason = new Error('rejected promise'); + // A synchronous `throw` of a non-Error value would otherwise reach Rollbar as a bare object + // and be filed as a generic "null or missing arguments." item (issue #3566, subtask 3). + const thrown = { code: 'BOOM' }; - window.dispatchEvent(new PromiseRejectionEvent('unhandledrejection', { - promise: Promise.resolve(), - reason, - })); - expect(rollbarInstance.error).not.toHaveBeenCalled(); + window.dispatchEvent(new ErrorEvent('error', { message: 'Uncaught object', error: thrown, filename: APP_BUNDLE_FILENAME })); + await queue.init(); + + expect(rollbarInstance.error).toHaveBeenCalledWith( + 'Uncaught object', + { client_timestamp: CALL_TIME_S, error: thrown }, + ); + + remove(); + }); + + it('should fall back to the event message when there is no error object', async() => { + const queue = await importQueue(); + const remove = queue.installEarlyListeners(); + window.dispatchEvent(new ErrorEvent('error', { message: 'Boom with no error object', error: null, filename: APP_BUNDLE_FILENAME })); await queue.init(); expect(rollbarInstance.error).toHaveBeenCalledWith( - reason, + 'Boom with no error object', { client_timestamp: CALL_TIME_S }, ); remove(); }); + it('should ignore uncaught errors that did not originate in our own bundle', async() => { + const queue = await importQueue(); + const remove = queue.installEarlyListeners(); + + // Opaque cross-origin error: masked to "Script error." with no source (e.g. #338). + window.dispatchEvent(new ErrorEvent('error', { message: 'Script error.', error: null })); + // Browser extension. + window.dispatchEvent(new ErrorEvent('error', { + message: 'boom', + error: new Error('boom'), + filename: 'chrome-extension://abcdef/inject.js', + })); + // In-app-browser / userscript inline script attributed to the document URL, not `/_next/`. + window.dispatchEvent(new ErrorEvent('error', { + message: 'boom', + error: new Error('boom'), + filename: 'https://host.blockscout.com/address/0xabc', + })); + await queue.init(); + + expect(rollbarInstance.error).not.toHaveBeenCalled(); + + remove(); + }); + it('should ignore resource load error events', async() => { const queue = await importQueue(); const remove = queue.installEarlyListeners(); diff --git a/src/services/rollbar/queue.ts b/src/services/rollbar/queue.ts index 0203a28a98f..4fc1ad5db12 100644 --- a/src/services/rollbar/queue.ts +++ b/src/services/rollbar/queue.ts @@ -133,11 +133,39 @@ function withClientTimestamp(args: Array<Rollbar.LogArgument>, timestamp: number return next; } +const UNCAUGHT_ERROR_FALLBACK_MESSAGE = 'Uncaught error'; + +/** + * Coerces a thrown value into arguments Rollbar can build an occurrence from. Passed a bare + * non-Error object (or `null`) as its sole argument, Rollbar discards the payload and files a + * generic "Item sent with null or missing arguments." occurrence — so anything that is not an + * `Error` or `string` is reported under {@link UNCAUGHT_ERROR_FALLBACK_MESSAGE} with the raw value + * preserved as custom data. + */ +function toReport(value: unknown, message: string): Array<Rollbar.LogArgument> { + if (value instanceof Error || typeof value === 'string') { + return [ value ]; + } + if (value === null || value === undefined) { + return [ message || UNCAUGHT_ERROR_FALLBACK_MESSAGE ]; + } + return [ message || UNCAUGHT_ERROR_FALLBACK_MESSAGE, { error: value } ]; +} + /** - * Captures uncaught errors / unhandled rejections during (and after) the SDK deferral window. - * Kept for the page lifetime on success — removed if init fails. Rollbar's own capture flags stay - * off to avoid double-reporting. + * Captures uncaught errors during (and after) the SDK deferral window. Kept for the page lifetime + * on success — removed if init fails. Rollbar's own `captureUncaught` stays off to avoid + * double-reporting; `captureUnhandledRejections` is left off deliberately — on public instances + * unhandled rejections are dominated by wallet-extension / third-party noise with no usable + * payload (they file empty "null or missing arguments" items), and genuine page crashes surface as + * `critical` through the React error boundary, not here. */ +const APP_BUNDLE_PATH = '/_next/'; + +function isOwnBundleError(event: ErrorEvent): boolean { + return typeof event.filename === 'string' && event.filename.includes(APP_BUNDLE_PATH); +} + export function installEarlyListeners(): () => void { if (!isEnabled() || earlyListenersInstalled || typeof window === 'undefined') { return () => {}; @@ -152,19 +180,18 @@ export function installEarlyListeners(): () => void { if (event.target instanceof Element) { return; } - log('error', [ event.error ?? event.message ]); - }; - - const handleRejection = (event: PromiseRejectionEvent) => { - log('error', [ event.reason ]); + // Only report uncaught errors from our own bundle. Everything else on a public page is + // unactionable third-party noise, and Rollbar offers no positive allowlist to express this. + if (!isOwnBundleError(event)) { + return; + } + log('error', toReport(event.error, event.message)); }; window.addEventListener('error', handleError, true); - window.addEventListener('unhandledrejection', handleRejection); const uninstall = () => { window.removeEventListener('error', handleError, true); - window.removeEventListener('unhandledrejection', handleRejection); earlyListenersInstalled = false; if (uninstallEarlyListeners === uninstall) { uninstallEarlyListeners = undefined; diff --git a/src/services/rollbar/serverConfig.ts b/src/services/rollbar/serverConfig.ts new file mode 100644 index 00000000000..06a62975b4a --- /dev/null +++ b/src/services/rollbar/serverConfig.ts @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import type { Configuration } from 'rollbar'; + +import config from 'src/config'; + +import { isIgnoredExceptionClass } from './utils'; + +/** + * Rollbar options for the error-page instance in `src/pages/_error.tsx`. It reports the explicit error + * handed to `getInitialProps`, plus server-side uncaught exceptions — `captureUncaught` is on there + * because those page-crash errors surface nowhere else, and the server has none of the third-party + * `window` noise the browser instance has to filter. It is gated to the server: this module also loads + * in the browser on a client-side error-page navigation, where an uncaught handler would bypass the + * queue's own-bundle origin gate and recapture the very noise we drop. `captureUnhandledRejections` + * stays off, and the shared `isIgnoredExceptionClass` check drops the same DOM / Abort classes the + * browser instance drops. + */ +export function buildServerConfig(accessToken: string): Configuration { + return { + accessToken, + environment: config.services.rollbar.environment, + payload: { + code_version: config.services.rollbar.codeVersion, + app_instance: config.services.rollbar.instance, + }, + checkIgnore: (_isUncaught, _args, item) => isIgnoredExceptionClass(item), + captureUncaught: typeof window === 'undefined', + captureUnhandledRejections: false, + }; +} diff --git a/src/services/rollbar/utils.spec.ts b/src/services/rollbar/utils.spec.ts new file mode 100644 index 00000000000..846356b1f4d --- /dev/null +++ b/src/services/rollbar/utils.spec.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; + +import { isMonacoCdnError, isIgnoredExceptionClass } from './utils'; + +describe('isIgnoredExceptionClass', () => { + it('matches an AbortError so both Rollbar instances drop it', () => { + const item = { + body: { trace: { exception: { 'class': 'AbortError', message: 'signal is aborted without reason' } } }, + }; + + expect(isIgnoredExceptionClass(item)).toBe(true); + }); + + it('matches a NotFoundError provoked by a DOM-mutating extension', () => { + const item = { + body: { trace: { exception: { 'class': 'NotFoundError', message: 'Failed to execute \'removeChild\' on \'Node\'' } } }, + }; + + expect(isIgnoredExceptionClass(item)).toBe(true); + }); + + it('does not match a genuine application error class', () => { + const item = { + body: { trace: { exception: { 'class': 'TypeError', message: 'Cannot read properties of undefined' } } }, + }; + + expect(isIgnoredExceptionClass(item)).toBe(false); + }); + + it('does not match a message-only item with no exception class', () => { + const item = { body: { message: { body: 'Something went wrong' } } }; + + expect(isIgnoredExceptionClass(item)).toBe(false); + }); +}); + +describe('isMonacoCdnError', () => { + it('matches a worker importScripts failure reported as a message with no stack', () => { + const item = { + body: { + message: { + body: 'Uncaught NetworkError: Failed to execute \'importScripts\' on \'WorkerGlobalScope\': ' + + 'The script at \'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/worker/workerMain.js\' failed to load.', + }, + }, + }; + + expect(isMonacoCdnError(item)).toBe(true); + }); + + it('matches a trace that enters through app code but reaches the Monaco CDN bundle deeper in the stack', () => { + const item = { + body: { + trace: { + exception: { 'class': 'Error', message: 'Can only have one anonymous define call per script file' }, + frames: [ + { filename: 'https://host.blockscout.com/_next/static/chunks/64926.js', method: 'getProvider' }, + { filename: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.js', method: 'define' }, + ], + }, + }, + }; + + expect(isMonacoCdnError(item)).toBe(true); + }); + + it('does not match an unrelated app error', () => { + const item = { + body: { + trace: { + exception: { 'class': 'TypeError', message: 'Cannot read properties of undefined' }, + frames: [ + { filename: 'https://host.blockscout.com/_next/static/chunks/12345.js', method: 'render' }, + ], + }, + }, + }; + + expect(isMonacoCdnError(item)).toBe(false); + }); + + it('does not throw on a malformed item with neither message nor frames', () => { + expect(isMonacoCdnError({ body: {} })).toBe(false); + }); +}); diff --git a/src/services/rollbar/utils.ts b/src/services/rollbar/utils.ts index c77f38c063e..b7619b1bb25 100644 --- a/src/services/rollbar/utils.ts +++ b/src/services/rollbar/utils.ts @@ -68,8 +68,52 @@ export function getExceptionClass(item: Dictionary) { return castToString(exceptionClass); } -export function getExceptionOriginFileName(item: Dictionary) { - const originFileName = get(item, 'body.trace.frames[0].filename'); +const IGNORED_EXCEPTION_CLASSES = [ + // React errors — "NotFoundError: Failed to execute 'removeChild' on 'Node': The node to be removed + // is not a child of this node." — provoked by browser extensions mutating the DOM React owns. + // See https://github.com/facebook/react/issues/11538 + 'NotFoundError', - return castToString(originFileName); + 'AbortError', +]; + +/** + * Exception classes we drop wherever they surface. Window-free by design so both the browser instance + * and the server-side error-page instance can share it — the error-page `checkIgnore` reuses only this + * check, not the browser instance's bot / headless checks, which read `window` (absent on the server). + */ +export function isIgnoredExceptionClass(item: Dictionary): boolean { + const exceptionClass = getExceptionClass(item); + return exceptionClass !== undefined && IGNORED_EXCEPTION_CLASSES.includes(exceptionClass); +} + +// Versionless so it keeps matching across Monaco version bumps; if Monaco is ever bundled locally +// instead of loaded from the CDN, filenames become app-owned, this stops matching, and genuine +// editor errors surface again — which is the behaviour we'd want then. +const MONACO_CDN_PATH = 'cdn.jsdelivr.net/npm/monaco-editor'; + +/** + * Errors originating from the Monaco editor bundle, which we load from a third-party CDN. Their + * failure modes — worker `importScripts` failures, AMD `define` collisions with browser-extension + * scripts, CDN/adblock load failures — are environmental and unactionable by us. Render-time failures + * still reach users through the editor's own ErrorBoundary, so dropping them here loses no signal. + * + * Still needed after the queue's own-bundle origin gate: a trace whose origin frame is our `/_next/` + * code but which only reaches the CDN bundle deeper in the stack passes that gate, and this catches it. + * + * Checks the message (worker failures arrive message-only, with no stack) and every frame (some + * traces enter through our own code and only reach the CDN bundle deeper in the stack). + */ +export function isMonacoCdnError(item: Dictionary): boolean { + const message = castToString(get(item, 'body.message.body')); + if (message?.includes(MONACO_CDN_PATH)) { + return true; + } + + const frames = get(item, 'body.trace.frames'); + if (!Array.isArray(frames)) { + return false; + } + + return frames.some((frame) => castToString(get(frame, 'filename'))?.includes(MONACO_CDN_PATH)); } diff --git a/src/shared/alerts/AlertWithExternalHtml.tsx b/src/shared/alerts/AlertWithExternalHtml.tsx deleted file mode 100644 index df0a5fe0e8f..00000000000 --- a/src/shared/alerts/AlertWithExternalHtml.tsx +++ /dev/null @@ -1,35 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-Blockscout - -import { Box, chakra } from '@chakra-ui/react'; -import React from 'react'; - -import type { AlertProps } from 'src/toolkit/chakra/alert'; -import { Alert } from 'src/toolkit/chakra/alert'; - -interface Props { - html: string; - status: AlertProps['status']; - showIcon?: boolean; - className?: string; -}; - -const AlertWithExternalHtml = ({ html, status, showIcon, className }: Props) => { - return ( - <Alert status={ status } showIcon={ showIcon } className={ className }> - <Box - dangerouslySetInnerHTML={{ __html: html }} - css={{ - '& a': { - color: 'link.primary', - _hover: { - color: 'link.primary.hover', - }, - }, - }} - - /> - </Alert> - ); -}; - -export default React.memo(chakra(AlertWithExternalHtml)); diff --git a/src/shared/api-degradation/ApiDegradationRpcIcon.tsx b/src/shared/api-degradation/ApiDegradationRpcIcon.tsx deleted file mode 100644 index 2a7e268781b..00000000000 --- a/src/shared/api-degradation/ApiDegradationRpcIcon.tsx +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-Blockscout - -import React from 'react'; - -import SpriteIcon from 'src/sprite/SpriteIcon'; -import type { IconName, Props as SpriteIconProps } from 'src/sprite/SpriteIcon'; - -import { Tooltip } from 'src/toolkit/chakra/tooltip'; - -interface Props extends Omit<SpriteIconProps, 'name'> { - name?: IconName; -} - -const ApiDegradationRpcIcon = (props: Props) => { - return ( - <Tooltip content="Our indexer is experiencing problems, you see the data directly from RPC"> - <SpriteIcon name="RPC" color="orange.400" boxSize={ 5 } { ...props }/> - </Tooltip> - ); -}; - -export default React.memo(ApiDegradationRpcIcon); diff --git a/src/shared/code-editor/CodeEditor.tsx b/src/shared/code-editor/CodeEditor.tsx index 1dfb30def1e..8bb0f293f6a 100644 --- a/src/shared/code-editor/CodeEditor.tsx +++ b/src/shared/code-editor/CodeEditor.tsx @@ -10,6 +10,7 @@ import React from 'react'; import type { File, Monaco } from './types'; import type { SmartContractExternalLibrary } from 'src/slices/contract/types/api'; +import { useRollbar } from 'src/services/rollbar'; import ErrorBoundary from 'src/shared/errors/ErrorBoundary'; import useIsMobile from 'src/shared/hooks/useIsMobile'; import isMetaKey from 'src/shared/utils/is-meta-key'; @@ -72,6 +73,7 @@ const CodeEditor = ({ data, remappings, libraries, language, mainFile, contractN const [ borderRadius ] = useToken('radii', 'md'); const isMobile = useIsMobile(); const themeColors = useThemeColors(); + const rollbar = useRollbar(); const editorWidth = containerRect ? containerRect.width - (isMobile ? 0 : SIDE_BAR_WIDTH) : 0; @@ -285,6 +287,14 @@ const CodeEditor = ({ data, remappings, libraries, language, mainFile, contractN return <Center bgColor={ themeColors['editor.background'] } w="100%" h="100%" borderRadius="md">Oops! Something went wrong!</Center>; }, [ themeColors ]); + // Reported with the error as plain data, not the Error instance: passing the instance makes Rollbar + // build a stack trace, and an editor render crash's frames run through the Monaco CDN bundle, so + // `isMonacoCdnError` would drop the very report we want to keep. Fires only when the editor subtree + // actually crashed in render and the user saw the fallback — the signal worth keeping. + const handleEditorError = React.useCallback((error: Error) => { + rollbar?.error('Code editor failed to render', { cause: error.cause, stack: error.stack }); + }, [ rollbar ]); + if (data.length === 1) { const css = { ...containerCss, @@ -298,7 +308,7 @@ const CodeEditor = ({ data, remappings, libraries, language, mainFile, contractN return ( <Box height={ `${ EDITOR_HEIGHT }px` } width="100%" css={ css } ref={ containerNodeRef }> - <ErrorBoundary renderErrorScreen={ renderErrorScreen }> + <ErrorBoundary renderErrorScreen={ renderErrorScreen } onError={ handleEditorError }> <MonacoEditor className="editor-container" language={ editorLanguage } @@ -327,7 +337,7 @@ const CodeEditor = ({ data, remappings, libraries, language, mainFile, contractN onKeyDown={ handleKeyDown } onKeyUp={ handleKeyUp } > - <ErrorBoundary renderErrorScreen={ renderErrorScreen }> + <ErrorBoundary renderErrorScreen={ renderErrorScreen } onError={ handleEditorError }> <Box flexGrow={ 1 }> <CodeEditorTabs tabs={ tabs } diff --git a/src/shared/detailed-info/DetailedInfoNativeCoinValue.tsx b/src/shared/detailed-info/DetailedInfoNativeCoinValue.tsx index 6f50fa39de0..90d35fd691d 100644 --- a/src/shared/detailed-info/DetailedInfoNativeCoinValue.tsx +++ b/src/shared/detailed-info/DetailedInfoNativeCoinValue.tsx @@ -7,17 +7,20 @@ import NativeCoinValue from 'src/shared/values/entity/NativeCoinValue'; import { ItemValue } from './DetailedInfo'; -interface Props extends NativeCoinValueProps {} +interface Props extends NativeCoinValueProps { + endContent?: React.ReactNode; +} -const DetailedInfoNativeCoinValue = ({ ...rest }: Props) => { +const DetailedInfoNativeCoinValue = ({ endContent, ...rest }: Props) => { return ( - <ItemValue multiRow> + <ItemValue multiRow columnGap={ 1 }> <NativeCoinValue accuracy={ 0 } flexWrap="wrap" rowGap={ 0 } { ...rest } /> + { endContent } </ItemValue> ); }; diff --git a/src/shared/entities/components.tsx b/src/shared/entities/components.tsx index 746dad45d45..1080261339b 100644 --- a/src/shared/entities/components.tsx +++ b/src/shared/entities/components.tsx @@ -44,6 +44,10 @@ export interface EntityBaseProps { truncationMaxSymbols?: number; variant?: Variant; chain?: ExternalChain; + contentProps?: { + tooltipInteractive?: boolean; + tooltipContentAfter?: React.ReactNode; + }; } export interface ContainerBaseProps extends Pick<EntityBaseProps, 'className'> { @@ -204,6 +208,7 @@ export interface ContentBaseProps extends Pick< asProp?: React.ElementType; text: string; tooltipInteractive?: boolean; + tooltipContentAfter?: React.ReactNode; } const Content = chakra(({ @@ -217,17 +222,27 @@ const Content = chakra(({ variant, noTooltip, tooltipInteractive, + tooltipContentAfter, noLink, }: ContentBaseProps) => { const styles = getContentProps(variant); + const tooltipContent = tooltipContentAfter ? ( + <> + { text } + { tooltipContentAfter } + </> + ) : undefined; + if (truncation === 'tail') { return ( <TruncatedText text={ text } loading={ isLoading } className={ className } + noTooltip={ noTooltip } tooltipInteractive={ tooltipInteractive } + tooltipContent={ tooltipContent } { ...styles } /> ); @@ -243,6 +258,7 @@ const Content = chakra(({ type="long" noTooltip={ noTooltip } tooltipInteractive={ tooltipInteractive } + tooltipContent={ tooltipContent } maxSymbols={ truncationMaxSymbols } /> ); @@ -253,6 +269,7 @@ const Content = chakra(({ as={ asProp } noTooltip={ noTooltip } tooltipInteractive={ tooltipInteractive } + tooltipContent={ tooltipContent } maxSymbols={ truncationMaxSymbols } /> ); @@ -264,6 +281,7 @@ const Content = chakra(({ tailLength={ tailLength } noTooltip={ noTooltip } tooltipInteractive={ tooltipInteractive } + tooltipContent={ tooltipContent } /> ); case 'none': diff --git a/src/shared/entities/utils.ts b/src/shared/entities/utils.ts index 31b1a4745ae..d2a66765316 100644 --- a/src/shared/entities/utils.ts +++ b/src/shared/entities/utils.ts @@ -37,7 +37,7 @@ export function getContentProps(variant: EntityBaseProps['variant'] = 'content') } export function distributeEntityProps<Props extends EntityBaseProps>(props: Props, multichainContext?: TMultichainContext | null) { - const { className, onClick, icon, noIcon, link, chain, ...mainProps } = props; + const { className, onClick, icon, noIcon, link, chain, contentProps, ...mainProps } = props; const { variant, ...restProps } = mainProps; return { @@ -46,7 +46,7 @@ export function distributeEntityProps<Props extends EntityBaseProps>(props: Prop // This does not apply to the links. If the links are within the multichain views, they should lead to chain-specific pages. icon: { ...mainProps, ...icon, chain, noIcon }, link: { ...restProps, ...link, onClick, chain: chain ?? multichainContext?.chain }, - content: mainProps, + content: { ...mainProps, ...contentProps }, symbol: restProps, copy: restProps, }; diff --git a/src/shared/stats/StatsWidget.tsx b/src/shared/stats/StatsWidget.tsx index 7eded0dbd3b..440225d1348 100644 --- a/src/shared/stats/StatsWidget.tsx +++ b/src/shared/stats/StatsWidget.tsx @@ -1,5 +1,6 @@ // SPDX-License-Identifier: LicenseRef-Blockscout +import type { BoxProps } from '@chakra-ui/react'; import { Box, Flex, Text, chakra } from '@chakra-ui/react'; import type { Route } from 'nextjs-routes'; import { route } from 'nextjs-routes'; @@ -13,8 +14,25 @@ import { Skeleton } from 'src/toolkit/chakra/skeleton'; import { Hint } from 'src/toolkit/components/Hint/Hint'; import { TruncatedText } from 'src/toolkit/components/truncation/TruncatedText'; -export type Props = { - className?: string; +interface ContainerProps extends BoxProps { + href?: Route; +} + +const Container = React.forwardRef<HTMLDivElement, ContainerProps>(({ href, children, ...props }, ref) => { + const content = href ? ( + <Link href={ route(href) } variant="plain" w="full" h="full" display="flex"> + { children } + </Link> + ) : children; + + return ( + <Box ref={ ref } display="flex" h="100%" { ...props }> + { content } + </Box> + ); +}); + +export interface Props extends BoxProps { label: string; value: string | React.ReactNode; valuePrefix?: string; @@ -30,22 +48,7 @@ export type Props = { isFallback?: boolean; }; -const Container = ({ href, children, className }: { href?: Route; children: React.JSX.Element; className?: string }) => { - const content = href ? ( - <Link href={ route(href) } variant="plain" w="full" h="full" display="flex"> - { children } - </Link> - ) : children; - - return ( - <Box className={ className } display="flex" h="100%"> - { content } - </Box> - ); -}; - -const StatsWidget = ({ - className, +const StatsWidget = React.forwardRef<HTMLDivElement, Props>(({ icon, label, value, @@ -59,9 +62,10 @@ const StatsWidget = ({ period, href, isFallback, -}: Props) => { + ...containerProps +}, ref) => { return ( - <Container href={ !isLoading ? href : undefined } className={ className }> + <Container ref={ ref } href={ !isLoading ? href : undefined } { ...containerProps }> <Flex alignItems="center" bgColor={ isLoading ? { _light: 'blackAlpha.50', _dark: 'whiteAlpha.50' } : { _light: 'theme.stats.bg._light', _dark: 'theme.stats.bg._dark' } } @@ -132,6 +136,6 @@ const StatsWidget = ({ </Flex> </Container> ); -}; +}); export default chakra(StatsWidget); diff --git a/src/shared/texts/HashStringShorten.tsx b/src/shared/texts/HashStringShorten.tsx index 1e20803dfe2..96360189b41 100644 --- a/src/shared/texts/HashStringShorten.tsx +++ b/src/shared/texts/HashStringShorten.tsx @@ -11,12 +11,13 @@ interface Props { hash: string; noTooltip?: boolean; tooltipInteractive?: boolean; + tooltipContent?: React.ReactNode; type?: 'long' | 'short'; maxSymbols?: number; as?: React.ElementType; } -const HashStringShorten = ({ hash, noTooltip, as = 'span', type, tooltipInteractive, maxSymbols }: Props) => { +const HashStringShorten = ({ hash, noTooltip, as = 'span', type, tooltipInteractive, tooltipContent, maxSymbols }: Props) => { const charNumber = maxSymbols ?? (type === 'long' ? 16 : 8); if (hash.length <= charNumber) { return <chakra.span as={ as }>{ hash }</chakra.span>; @@ -29,7 +30,11 @@ const HashStringShorten = ({ hash, noTooltip, as = 'span', type, tooltipInteract } return ( - <Tooltip content={ hash } interactive={ tooltipInteractive }> + <Tooltip + contentProps={{ maxW: { base: 'calc(100vw - 8px)', lg: '400px' } }} + content={ tooltipContent ?? hash } + interactive={ tooltipInteractive } + > { content } </Tooltip> ); diff --git a/src/shared/texts/HashStringShortenDynamic.tsx b/src/shared/texts/HashStringShortenDynamic.tsx index d5cb957b74b..27c3d149284 100644 --- a/src/shared/texts/HashStringShortenDynamic.tsx +++ b/src/shared/texts/HashStringShortenDynamic.tsx @@ -28,11 +28,21 @@ interface Props extends BoxProps { fontWeight?: string | number; noTooltip?: boolean; tooltipInteractive?: boolean; + tooltipContent?: React.ReactNode; tailLength?: number; as?: React.ElementType; } -const HashStringShortenDynamic = ({ hash, fontWeight = '400', noTooltip, tailLength = TAIL_LENGTH, as = 'span', tooltipInteractive, ...props }: Props) => { +const HashStringShortenDynamic = ({ + hash, + fontWeight = '400', + noTooltip, + tailLength = TAIL_LENGTH, + as = 'span', + tooltipInteractive, + tooltipContent, + ...props +}: Props) => { const elementRef = useRef<HTMLSpanElement>(null); const [ displayedString, setDisplayedString ] = React.useState(hash); @@ -97,10 +107,10 @@ const HashStringShortenDynamic = ({ hash, fontWeight = '400', noTooltip, tailLen const content = <chakra.span ref={ elementRef } as={ as } { ...props }>{ displayedString }</chakra.span>; const isTruncated = hash.length !== displayedString.length; - if (isTruncated && !noTooltip) { + if (!noTooltip && (isTruncated || tooltipContent)) { return ( <Tooltip - content={ hash } + content={ tooltipContent ?? hash } contentProps={{ maxW: { base: 'calc(100vw - 8px)', lg: '400px' } }} interactive={ tooltipInteractive } > diff --git a/src/shell/footer/Footer.tsx b/src/shell/footer/Footer.tsx index 8e875bb7e01..1b7b76545d6 100644 --- a/src/shell/footer/Footer.tsx +++ b/src/shell/footer/Footer.tsx @@ -163,7 +163,7 @@ const Footer = () => { </Link> </Flex> <Text mt={ 3 } fontSize="xs"> - Blockscout is a tool for inspecting and analyzing EVM based blockchains. Blockchain explorer for Ethereum Networks. + Scan, inspect, and analyze EVM based blockchains with Blockscout, a blockchain explorer for Ethereum networks. </Text> <VStack mt={ 6 } alignItems="start" textStyle="xs" gap={ 1 }> <Flex flexDir={ onionDomain ? 'row' : 'column' } _empty={{ display: 'none' }} columnGap={ 6 } rowGap={ 1 }> diff --git a/src/shell/footer/__screenshots__/Footer.pw.tsx_dark-color-mode_with-custom-links-max-cols-mobile-dark-mode-1.png b/src/shell/footer/__screenshots__/Footer.pw.tsx_dark-color-mode_with-custom-links-max-cols-mobile-dark-mode-1.png index 1fc8c5b339c..2b74a459547 100644 Binary files a/src/shell/footer/__screenshots__/Footer.pw.tsx_dark-color-mode_with-custom-links-max-cols-mobile-dark-mode-1.png and b/src/shell/footer/__screenshots__/Footer.pw.tsx_dark-color-mode_with-custom-links-max-cols-mobile-dark-mode-1.png differ diff --git a/src/shell/footer/__screenshots__/Footer.pw.tsx_dark-color-mode_with-custom-links-min-cols-base-view-dark-mode-mobile-1.png b/src/shell/footer/__screenshots__/Footer.pw.tsx_dark-color-mode_with-custom-links-min-cols-base-view-dark-mode-mobile-1.png index a031fa4695c..c436476184d 100644 Binary files a/src/shell/footer/__screenshots__/Footer.pw.tsx_dark-color-mode_with-custom-links-min-cols-base-view-dark-mode-mobile-1.png and b/src/shell/footer/__screenshots__/Footer.pw.tsx_dark-color-mode_with-custom-links-min-cols-base-view-dark-mode-mobile-1.png differ diff --git a/src/shell/footer/__screenshots__/Footer.pw.tsx_dark-color-mode_without-custom-links-base-view-dark-mode-mobile-1.png b/src/shell/footer/__screenshots__/Footer.pw.tsx_dark-color-mode_without-custom-links-base-view-dark-mode-mobile-1.png index bbbaf63f93b..74ea9bf9735 100644 Binary files a/src/shell/footer/__screenshots__/Footer.pw.tsx_dark-color-mode_without-custom-links-base-view-dark-mode-mobile-1.png and b/src/shell/footer/__screenshots__/Footer.pw.tsx_dark-color-mode_without-custom-links-base-view-dark-mode-mobile-1.png differ diff --git a/src/shell/footer/__screenshots__/Footer.pw.tsx_dark-color-mode_without-custom-links-with-indexing-alert-dark-mode-mobile-1.png b/src/shell/footer/__screenshots__/Footer.pw.tsx_dark-color-mode_without-custom-links-with-indexing-alert-dark-mode-mobile-1.png index 81ad863bba2..51ea588c1e9 100644 Binary files a/src/shell/footer/__screenshots__/Footer.pw.tsx_dark-color-mode_without-custom-links-with-indexing-alert-dark-mode-mobile-1.png and b/src/shell/footer/__screenshots__/Footer.pw.tsx_dark-color-mode_without-custom-links-with-indexing-alert-dark-mode-mobile-1.png differ diff --git a/src/shell/footer/__screenshots__/Footer.pw.tsx_default_with-custom-links-max-cols-mobile-dark-mode-1.png b/src/shell/footer/__screenshots__/Footer.pw.tsx_default_with-custom-links-max-cols-mobile-dark-mode-1.png index ed1f3645010..dfc4987e154 100644 Binary files a/src/shell/footer/__screenshots__/Footer.pw.tsx_default_with-custom-links-max-cols-mobile-dark-mode-1.png and b/src/shell/footer/__screenshots__/Footer.pw.tsx_default_with-custom-links-max-cols-mobile-dark-mode-1.png differ diff --git a/src/shell/footer/__screenshots__/Footer.pw.tsx_default_with-custom-links-max-cols-screen-xl-base-view-1.png b/src/shell/footer/__screenshots__/Footer.pw.tsx_default_with-custom-links-max-cols-screen-xl-base-view-1.png index 4410fb924b3..29fc538a6ba 100644 Binary files a/src/shell/footer/__screenshots__/Footer.pw.tsx_default_with-custom-links-max-cols-screen-xl-base-view-1.png and b/src/shell/footer/__screenshots__/Footer.pw.tsx_default_with-custom-links-max-cols-screen-xl-base-view-1.png differ diff --git a/src/shell/footer/__screenshots__/Footer.pw.tsx_default_with-custom-links-min-cols-base-view-dark-mode-mobile-1.png b/src/shell/footer/__screenshots__/Footer.pw.tsx_default_with-custom-links-min-cols-base-view-dark-mode-mobile-1.png index 3890b86d591..e66adbfaf95 100644 Binary files a/src/shell/footer/__screenshots__/Footer.pw.tsx_default_with-custom-links-min-cols-base-view-dark-mode-mobile-1.png and b/src/shell/footer/__screenshots__/Footer.pw.tsx_default_with-custom-links-min-cols-base-view-dark-mode-mobile-1.png differ diff --git a/src/shell/footer/__screenshots__/Footer.pw.tsx_default_without-custom-links-base-view-dark-mode-mobile-1.png b/src/shell/footer/__screenshots__/Footer.pw.tsx_default_without-custom-links-base-view-dark-mode-mobile-1.png index ec9b1350028..ca6d5e2deb9 100644 Binary files a/src/shell/footer/__screenshots__/Footer.pw.tsx_default_without-custom-links-base-view-dark-mode-mobile-1.png and b/src/shell/footer/__screenshots__/Footer.pw.tsx_default_without-custom-links-base-view-dark-mode-mobile-1.png differ diff --git a/src/shell/footer/__screenshots__/Footer.pw.tsx_default_without-custom-links-with-full-info-mobile-1.png b/src/shell/footer/__screenshots__/Footer.pw.tsx_default_without-custom-links-with-full-info-mobile-1.png index caa6800f47b..30e318afd85 100644 Binary files a/src/shell/footer/__screenshots__/Footer.pw.tsx_default_without-custom-links-with-full-info-mobile-1.png and b/src/shell/footer/__screenshots__/Footer.pw.tsx_default_without-custom-links-with-full-info-mobile-1.png differ diff --git a/src/shell/footer/__screenshots__/Footer.pw.tsx_default_without-custom-links-with-indexing-alert-dark-mode-mobile-1.png b/src/shell/footer/__screenshots__/Footer.pw.tsx_default_without-custom-links-with-indexing-alert-dark-mode-mobile-1.png index f74d75df211..eba00108683 100644 Binary files a/src/shell/footer/__screenshots__/Footer.pw.tsx_default_without-custom-links-with-indexing-alert-dark-mode-mobile-1.png and b/src/shell/footer/__screenshots__/Footer.pw.tsx_default_without-custom-links-with-indexing-alert-dark-mode-mobile-1.png differ diff --git a/src/shell/footer/__screenshots__/Footer.pw.tsx_mobile_with-custom-links-max-cols-mobile-dark-mode-1.png b/src/shell/footer/__screenshots__/Footer.pw.tsx_mobile_with-custom-links-max-cols-mobile-dark-mode-1.png index 5415a9a6ade..aabf612c440 100644 Binary files a/src/shell/footer/__screenshots__/Footer.pw.tsx_mobile_with-custom-links-max-cols-mobile-dark-mode-1.png and b/src/shell/footer/__screenshots__/Footer.pw.tsx_mobile_with-custom-links-max-cols-mobile-dark-mode-1.png differ diff --git a/src/shell/footer/__screenshots__/Footer.pw.tsx_mobile_with-custom-links-min-cols-base-view-dark-mode-mobile-1.png b/src/shell/footer/__screenshots__/Footer.pw.tsx_mobile_with-custom-links-min-cols-base-view-dark-mode-mobile-1.png index 47c4fb3b9ae..5a823b8e670 100644 Binary files a/src/shell/footer/__screenshots__/Footer.pw.tsx_mobile_with-custom-links-min-cols-base-view-dark-mode-mobile-1.png and b/src/shell/footer/__screenshots__/Footer.pw.tsx_mobile_with-custom-links-min-cols-base-view-dark-mode-mobile-1.png differ diff --git a/src/shell/footer/__screenshots__/Footer.pw.tsx_mobile_without-custom-links-base-view-dark-mode-mobile-1.png b/src/shell/footer/__screenshots__/Footer.pw.tsx_mobile_without-custom-links-base-view-dark-mode-mobile-1.png index c61a168ce0d..0ad702cc33b 100644 Binary files a/src/shell/footer/__screenshots__/Footer.pw.tsx_mobile_without-custom-links-base-view-dark-mode-mobile-1.png and b/src/shell/footer/__screenshots__/Footer.pw.tsx_mobile_without-custom-links-base-view-dark-mode-mobile-1.png differ diff --git a/src/shell/footer/__screenshots__/Footer.pw.tsx_mobile_without-custom-links-with-full-info-mobile-1.png b/src/shell/footer/__screenshots__/Footer.pw.tsx_mobile_without-custom-links-with-full-info-mobile-1.png index 8a32fd9fee6..d47790a7fd1 100644 Binary files a/src/shell/footer/__screenshots__/Footer.pw.tsx_mobile_without-custom-links-with-full-info-mobile-1.png and b/src/shell/footer/__screenshots__/Footer.pw.tsx_mobile_without-custom-links-with-full-info-mobile-1.png differ diff --git a/src/shell/footer/__screenshots__/Footer.pw.tsx_mobile_without-custom-links-with-indexing-alert-dark-mode-mobile-1.png b/src/shell/footer/__screenshots__/Footer.pw.tsx_mobile_without-custom-links-with-indexing-alert-dark-mode-mobile-1.png index c7873b3a764..d014fd7f2be 100644 Binary files a/src/shell/footer/__screenshots__/Footer.pw.tsx_mobile_without-custom-links-with-indexing-alert-dark-mode-mobile-1.png and b/src/shell/footer/__screenshots__/Footer.pw.tsx_mobile_without-custom-links-with-indexing-alert-dark-mode-mobile-1.png differ diff --git a/src/shell/header/HeaderAlert.tsx b/src/shell/header/HeaderAlert.tsx index a5bb3d6eb96..d5e36c2efc2 100644 --- a/src/shell/header/HeaderAlert.tsx +++ b/src/shell/header/HeaderAlert.tsx @@ -7,14 +7,16 @@ import React from 'react'; import IndexingStatusBlocks from 'src/slices/chain/indexing-status/IndexingStatusBlocks'; import config from 'src/config'; -import AlertWithExternalHtml from 'src/shared/alerts/AlertWithExternalHtml'; + +import { Alert } from 'src/toolkit/chakra/alert'; +import { BoxHtml } from 'src/toolkit/chakra/box'; const maintenanceAlertHtml = config.shell.header.maintenanceAlert.message || ''; const HeaderAlert = (props: FlexProps) => { return ( <Flex flexDir="column" rowGap={ 1 } mb={{ base: 6, lg: 3 }} _empty={{ display: 'none' }} { ...props }> - { maintenanceAlertHtml && <AlertWithExternalHtml html={ maintenanceAlertHtml } status="info" showIcon/> } + { maintenanceAlertHtml && <Alert status="info" showIcon><BoxHtml html={ maintenanceAlertHtml }/></Alert> } <IndexingStatusBlocks/> </Flex> ); diff --git a/src/shell/header/__screenshots__/Burger.pw.tsx_dark-color-mode_with-promo-banner-text-dark-mode-1.png b/src/shell/header/__screenshots__/Burger.pw.tsx_dark-color-mode_with-promo-banner-text-dark-mode-1.png index 6cf3bbf9761..47a71c5af87 100644 Binary files a/src/shell/header/__screenshots__/Burger.pw.tsx_dark-color-mode_with-promo-banner-text-dark-mode-1.png and b/src/shell/header/__screenshots__/Burger.pw.tsx_dark-color-mode_with-promo-banner-text-dark-mode-1.png differ diff --git a/src/shell/header/__screenshots__/Burger.pw.tsx_default_auth-base-view-1.png b/src/shell/header/__screenshots__/Burger.pw.tsx_default_auth-base-view-1.png index a0be06b94cb..5e0c46c8277 100644 Binary files a/src/shell/header/__screenshots__/Burger.pw.tsx_default_auth-base-view-1.png and b/src/shell/header/__screenshots__/Burger.pw.tsx_default_auth-base-view-1.png differ diff --git a/src/shell/header/__screenshots__/Burger.pw.tsx_default_base-view-1.png b/src/shell/header/__screenshots__/Burger.pw.tsx_default_base-view-1.png index 151a94dc3f8..722f1cee468 100644 Binary files a/src/shell/header/__screenshots__/Burger.pw.tsx_default_base-view-1.png and b/src/shell/header/__screenshots__/Burger.pw.tsx_default_base-view-1.png differ diff --git a/src/shell/header/__screenshots__/Burger.pw.tsx_default_dark-mode-base-view-1.png b/src/shell/header/__screenshots__/Burger.pw.tsx_default_dark-mode-base-view-1.png index 640e165f137..20b1fdb8fe6 100644 Binary files a/src/shell/header/__screenshots__/Burger.pw.tsx_default_dark-mode-base-view-1.png and b/src/shell/header/__screenshots__/Burger.pw.tsx_default_dark-mode-base-view-1.png differ diff --git a/src/shell/header/__screenshots__/Burger.pw.tsx_default_submenu-1.png b/src/shell/header/__screenshots__/Burger.pw.tsx_default_submenu-1.png index c5e5843f8bd..43f8233a171 100644 Binary files a/src/shell/header/__screenshots__/Burger.pw.tsx_default_submenu-1.png and b/src/shell/header/__screenshots__/Burger.pw.tsx_default_submenu-1.png differ diff --git a/src/shell/header/__screenshots__/Burger.pw.tsx_default_with-promo-banner-image-1.png b/src/shell/header/__screenshots__/Burger.pw.tsx_default_with-promo-banner-image-1.png index 6abc0577436..6312194292d 100644 Binary files a/src/shell/header/__screenshots__/Burger.pw.tsx_default_with-promo-banner-image-1.png and b/src/shell/header/__screenshots__/Burger.pw.tsx_default_with-promo-banner-image-1.png differ diff --git a/src/shell/header/__screenshots__/Burger.pw.tsx_default_with-promo-banner-text-dark-mode-1.png b/src/shell/header/__screenshots__/Burger.pw.tsx_default_with-promo-banner-text-dark-mode-1.png index a1e1409cb42..3a94b9e0c30 100644 Binary files a/src/shell/header/__screenshots__/Burger.pw.tsx_default_with-promo-banner-text-dark-mode-1.png and b/src/shell/header/__screenshots__/Burger.pw.tsx_default_with-promo-banner-text-dark-mode-1.png differ diff --git a/src/shell/metadata/__snapshots__/generate.spec.ts.snap b/src/shell/metadata/__snapshots__/generate.spec.ts.snap index 9a7996bd427..180e5795428 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 9d0218fb3dd..0d6613cb969 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 d563fddd98b..8d036988d44 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 c3930caa788..287d2c31de4 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 6456610f8a4..7db193a61d1 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, }; @@ -42,7 +39,7 @@ export const TEMPLATE_MAP: Record<Route['pathname'], RouteTemplateRecord> = { 'default': '%chain_name% blockchain explorer - View %chain_name% stats', }, description: { - 'default': 'Explore %chain_name% blockchain data. Search transactions, addresses, tokens, blocks, and more.', + 'default': 'Explore %chain_name% blockchain data. Search and scan transactions, addresses, tokens, blocks, and more.', }, }, og: OG_ROOT_PAGE, @@ -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 ad21099a7d6..e021dfb5827 100644 --- a/src/shell/metadata/types.ts +++ b/src/shell/metadata/types.ts @@ -6,11 +6,13 @@ import type { Product, WebApplication, WithContext } from 'schema-dts'; 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/types/api'; /* 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 +24,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/shell/navigation/horizontal/NavLinkGroup.tsx b/src/shell/navigation/horizontal/NavLinkGroup.tsx index 9fee36f8135..0d18fc1e4ce 100644 --- a/src/shell/navigation/horizontal/NavLinkGroup.tsx +++ b/src/shell/navigation/horizontal/NavLinkGroup.tsx @@ -28,12 +28,12 @@ const NavLinkGroup = ({ item }: Props) => { <HStack separator={ <Separator/> } alignItems="stretch"> { item.subItems.map((subItem, index) => { if (!Array.isArray(subItem)) { - return <NavLink key={ subItem.text } item={ subItem }/>; + return <NavLink key={ subItem.text } item={ subItem } minH="48px"/>; } return ( <chakra.ul key={ index } display="flex" flexDir="column" rowGap={ 1 }> - { subItem.map((navItem) => <NavLink key={ navItem.text } item={ navItem }/>) } + { subItem.map((navItem) => <NavLink key={ navItem.text } item={ navItem } minH="48px"/>) } </chakra.ul> ); }) } @@ -44,7 +44,7 @@ const NavLinkGroup = ({ item }: Props) => { if (Array.isArray(subItem)) { return null; } - return <NavLink key={ subItem.text } item={ subItem }/>; + return <NavLink key={ subItem.text } item={ subItem } minH="48px"/>; }) } </chakra.ul> ); diff --git a/src/shell/navigation/useNavItems.tsx b/src/shell/navigation/useNavItems.tsx index 117f188db28..825af72d648 100644 --- a/src/shell/navigation/useNavItems.tsx +++ b/src/shell/navigation/useNavItems.tsx @@ -413,7 +413,7 @@ export default function useNavItems(): ReturnType { icon: 'navigation/custom_abi', isActive: pathname === '/account/custom-abi', }, - getFeaturePayload(config.features.account)?.addressVerificationEnabled && { + getFeaturePayload(config.features.account)?.verifiedAddresses?.isEnabled && { text: 'Verified addrs', nextRoute: { pathname: '/account/verified-addresses' as const }, icon: 'navigation/verified_contracts', diff --git a/src/shell/navigation/vertical/NavLink.tsx b/src/shell/navigation/vertical/NavLink.tsx index 7ddb7bcb72b..8170ba378b9 100644 --- a/src/shell/navigation/vertical/NavLink.tsx +++ b/src/shell/navigation/vertical/NavLink.tsx @@ -51,6 +51,8 @@ const NavLink = ({ item, onClick, isCollapsed, isDisabled }: Props) => { noIcon { ...styleProps.itemProps } w={{ base: '100%', lg: isExpanded ? '100%' : '60px', xl: isCollapsed ? '60px' : '100%' }} + minH="48px" + fontWeight="500" display="flex" position="relative" px={{ base: 2, lg: isExpanded ? 2 : '15px', xl: isCollapsed ? '15px' : 2 }} @@ -77,10 +79,9 @@ const NavLink = ({ item, onClick, isCollapsed, isDisabled }: Props) => { interactive > <HStack gap={ 0 } overflow="hidden"> - <NavLinkIcon item={ item }/> + <NavLinkIcon item={ item } mr={ 3 }/> <chakra.span { ...styleProps.textProps } - ml={ 3 } display={{ base: 'inline-flex', lg: isExpanded ? 'inline-flex' : 'none', xl: isCollapsed ? 'none' : 'inline-flex' }} alignItems="center" > diff --git a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_hover-xl-screen-dark-mode-1.png b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_hover-xl-screen-dark-mode-1.png index 1c3c77ef0b6..2856b952ca8 100644 Binary files a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_hover-xl-screen-dark-mode-1.png and b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_hover-xl-screen-dark-mode-1.png differ diff --git a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_no-auth-xl-screen-dark-mode-1.png b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_no-auth-xl-screen-dark-mode-1.png index 1c3c77ef0b6..2856b952ca8 100644 Binary files a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_no-auth-xl-screen-dark-mode-1.png and b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_no-auth-xl-screen-dark-mode-1.png differ diff --git a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_with-highlighted-routes-xl-screen-dark-mode-1.png b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_with-highlighted-routes-xl-screen-dark-mode-1.png index 053cde861b9..0f3b09cb01b 100644 Binary files a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_with-highlighted-routes-xl-screen-dark-mode-1.png and b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_with-highlighted-routes-xl-screen-dark-mode-1.png differ diff --git a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_with-promo-banner-text-xl-screen-dark-mode-1.png b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_with-promo-banner-text-xl-screen-dark-mode-1.png index 6c01cc51e51..28fc586ff6a 100644 Binary files a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_with-promo-banner-text-xl-screen-dark-mode-1.png and b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_dark-color-mode_with-promo-banner-text-xl-screen-dark-mode-1.png differ diff --git a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_hover-xl-screen-dark-mode-1.png b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_hover-xl-screen-dark-mode-1.png index 370a6b46e1b..63710fcd44b 100644 Binary files a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_hover-xl-screen-dark-mode-1.png and b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_hover-xl-screen-dark-mode-1.png differ diff --git a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_no-auth-xl-screen-dark-mode-1.png b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_no-auth-xl-screen-dark-mode-1.png index 370a6b46e1b..63710fcd44b 100644 Binary files a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_no-auth-xl-screen-dark-mode-1.png and b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_no-auth-xl-screen-dark-mode-1.png differ diff --git a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-highlighted-routes-with-submenu-1.png b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-highlighted-routes-with-submenu-1.png index cc92ea3b8b1..1b49e0c0086 100644 Binary files a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-highlighted-routes-with-submenu-1.png and b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-highlighted-routes-with-submenu-1.png differ diff --git a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-highlighted-routes-xl-screen-dark-mode-1.png b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-highlighted-routes-xl-screen-dark-mode-1.png index eaac6d4724c..ca70401dbdb 100644 Binary files a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-highlighted-routes-xl-screen-dark-mode-1.png and b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-highlighted-routes-xl-screen-dark-mode-1.png differ diff --git a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-promo-banner-image-xl-screen-1.png b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-promo-banner-image-xl-screen-1.png index 5e6f3f4919d..e19c8d4f75b 100644 Binary files a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-promo-banner-image-xl-screen-1.png and b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-promo-banner-image-xl-screen-1.png differ diff --git a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-promo-banner-text-xl-screen-dark-mode-1.png b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-promo-banner-text-xl-screen-dark-mode-1.png index 96977efda9d..cd480582bdf 100644 Binary files a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-promo-banner-text-xl-screen-dark-mode-1.png and b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-promo-banner-text-xl-screen-dark-mode-1.png differ diff --git a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-submenu-base-view-1.png b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-submenu-base-view-1.png index 6f3f63392c2..e3b0eb3a5fe 100644 Binary files a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-submenu-base-view-1.png and b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-submenu-base-view-1.png differ diff --git a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-submenu-xl-screen-base-view-1.png b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-submenu-xl-screen-base-view-1.png index d8a705161b7..fa13f10cfe5 100644 Binary files a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-submenu-xl-screen-base-view-1.png and b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-submenu-xl-screen-base-view-1.png differ diff --git a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-tooltips-base-view-1.png b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-tooltips-base-view-1.png index 9dcfdd3793d..3b87f08fa3e 100644 Binary files a/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-tooltips-base-view-1.png and b/src/shell/navigation/vertical/__screenshots__/NavigationDesktop.pw.tsx_default_with-tooltips-base-view-1.png differ diff --git a/src/shell/page/actions-menu/ActionsMenu.tsx b/src/shell/page/actions-menu/ActionsMenu.tsx index d94fa0b5c9a..46c32759df4 100644 --- a/src/shell/page/actions-menu/ActionsMenu.tsx +++ b/src/shell/page/actions-menu/ActionsMenu.tsx @@ -46,7 +46,7 @@ const AccountActionsMenu = ({ isLoading, className, showUpdateMetadataItem }: Pr }, { render: (props: ItemProps) => <TokenInfoMenuItem { ...props }/>, - enabled: config.features.account.isEnabled && isTokenPage && getFeaturePayload(config.features.account)?.addressVerificationEnabled, + enabled: config.features.account.isEnabled && isTokenPage && getFeaturePayload(config.features.account)?.verifiedAddresses?.isEnabled, }, { render: (props: ItemProps) => <PrivateTagMenuItem { ...props } entityType={ isTxPage ? 'tx' : 'address' }/>, diff --git a/src/shell/top-bar/settings/time-format/SettingsLocalTime.tsx b/src/shell/top-bar/settings/time-format/SettingsLocalTime.tsx index 2768c2bc1ca..4fafd5a305f 100644 --- a/src/shell/top-bar/settings/time-format/SettingsLocalTime.tsx +++ b/src/shell/top-bar/settings/time-format/SettingsLocalTime.tsx @@ -2,11 +2,12 @@ import React from 'react'; +import type { SwitchProps } from 'src/toolkit/chakra/switch'; import { Switch } from 'src/toolkit/chakra/switch'; import { useSettingsContext } from '../context'; -const SettingsLocalTime = () => { +const SettingsLocalTime = (props: SwitchProps) => { const settingsContext = useSettingsContext(); if (!settingsContext) { @@ -18,12 +19,13 @@ const SettingsLocalTime = () => { return ( <Switch id="local-time" - defaultChecked={ isLocalTime } - onChange={ toggleIsLocalTime } + checked={ isLocalTime } + onCheckedChange={ toggleIsLocalTime } direction="rtl" justifyContent="space-between" w="100%" minH="34px" + { ...props } > Local time format </Switch> diff --git a/src/slices/address/components/entity/AddressEntity.tsx b/src/slices/address/components/entity/AddressEntity.tsx index 20314a3631a..05d01791456 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'; @@ -166,12 +157,15 @@ const Content = chakra((props: ContentProps) => { const styles = getContentProps(props.variant); const label = ( - <VStack gap={ 0 } py={ 1 } color="inherit"> - <Box fontWeight={ 600 } whiteSpace="pre-wrap" wordBreak="break-word">{ nameText }</Box> - <Box whiteSpace="pre-wrap" wordBreak="break-word"> - { displayedAddress } - </Box> - </VStack> + <> + <VStack gap={ 0 } py={ 1 } color="inherit"> + <Box fontWeight={ 600 } whiteSpace="pre-wrap" wordBreak="break-word">{ nameText }</Box> + <Box whiteSpace="pre-wrap" wordBreak="break-word"> + { displayedAddress } + </Box> + </VStack> + { props.tooltipContentAfter } + </> ); return ( @@ -227,10 +221,16 @@ const AddressEntity = (props: EntityProps) => { const altHash = !props.noAltHash && settingsContext?.addressFormat === 'bech32' ? toBech32Address(props.address.hash) : undefined; - // inside highlight context all tooltips should be interactive - // because non-interactive ones will not pass 'onMouseLeave' event to the parent component - // see issue - https://github.com/chakra-ui/chakra-ui/issues/9939#issuecomment-2810567024 - const content = <Content { ...partsProps.content } altHash={ altHash } tooltipInteractive={ Boolean(highlightContext) }/>; + const content = ( + <Content + { ...partsProps.content } + altHash={ altHash } + // inside highlight context all tooltips should be interactive + // because non-interactive ones will not pass 'onMouseLeave' event to the parent component + // see issue - https://github.com/chakra-ui/chakra-ui/issues/9939#issuecomment-2810567024 + tooltipInteractive={ Boolean(highlightContext) || partsProps.content.tooltipInteractive } + /> + ); return ( <Container diff --git a/src/slices/address/components/entity/AddressEntityContentProxy.tsx b/src/slices/address/components/entity/AddressEntityContentProxy.tsx index 19629dcffd6..8fc11829b2e 100644 --- a/src/slices/address/components/entity/AddressEntityContentProxy.tsx +++ b/src/slices/address/components/entity/AddressEntityContentProxy.tsx @@ -10,7 +10,7 @@ import { Tooltip } from 'src/toolkit/chakra/tooltip'; import type { ContentProps } from './AddressEntity'; import AddressEntity from './AddressEntity'; -const AddressEntityContentProxy = (props: ContentProps) => { +const AddressEntityContentProxy = ({ tooltipContentAfter, tooltipInteractive, ...props }: ContentProps) => { const implementations = props.address.implementations; if (!implementations || implementations.length === 0) { @@ -56,6 +56,7 @@ const AddressEntityContentProxy = (props: ContentProps) => { /> )) } </Flex> + { tooltipContentAfter } </> ); diff --git a/src/slices/address/components/entity/AddressEntityInterchain.tsx b/src/slices/address/components/entity/AddressEntityInterchain.tsx index 7ded6297d7f..c0391c16dbd 100644 --- a/src/slices/address/components/entity/AddressEntityInterchain.tsx +++ b/src/slices/address/components/entity/AddressEntityInterchain.tsx @@ -21,8 +21,8 @@ interface Props extends EntityProps, JsxStyleProps { const AddressEntityInterchain = ({ chain, currentAddress, ...props }: Props) => { const isCurrentChain = chain?.id === config.chain.id; - const isCurrentAddress = isCurrentChain && currentAddress?.toLowerCase() === props.address.hash.toLowerCase(); const isMultichainAddress = multichainConfig()?.chains.some(({ id }) => id === chain?.id); + const isCurrentAddress = (isCurrentChain || isMultichainAddress) && currentAddress?.toLowerCase() === props.address.hash.toLowerCase(); if (isCurrentChain || isMultichainAddress) { return <AddressEntity { ...props } chain={ isMultichainAddress ? undefined : chain } noLink={ isCurrentAddress }/>; diff --git a/src/slices/address/components/entity/AddressEntityWithTokenFilter.tsx b/src/slices/address/components/entity/AddressEntityWithTokenFilter.tsx index 398ba72bb11..c9e296eb170 100644 --- a/src/slices/address/components/entity/AddressEntityWithTokenFilter.tsx +++ b/src/slices/address/components/entity/AddressEntityWithTokenFilter.tsx @@ -1,21 +1,30 @@ // SPDX-License-Identifier: LicenseRef-Blockscout -import { chakra } from '@chakra-ui/react'; -import { route } from 'nextjs-routes'; +import { chakra, Separator } from '@chakra-ui/react'; import React from 'react'; +import { useMultichainContext } from 'src/features/multichain/context'; + import config from 'src/config'; +import { route } from 'src/shared/router/routes'; +import SpriteIcon from 'src/sprite/SpriteIcon'; + +import { Link } from 'src/toolkit/chakra/link'; import * as AddressEntity from './AddressEntity'; interface Props extends AddressEntity.EntityProps { tokenHash: string; - tokenSymbol: string; + tokenSymbol: string | undefined; } const AddressEntityWithTokenFilter = (props: Props) => { - if (!config.features.advancedFilter.isEnabled) { + const multiChainContext = useMultichainContext(); + + const chainConfig = (multiChainContext?.chain.app_config ?? config); + + if (!chainConfig.features.advancedFilter.isEnabled) { return <AddressEntity.default { ...props }/>; } @@ -26,12 +35,28 @@ const AddressEntityWithTokenFilter = (props: Props) => { to_address_hashes_to_include: [ props.address.hash ], from_address_hashes_to_include: [ props.address.hash ], token_contract_address_hashes_to_include: [ props.tokenHash ], - token_contract_symbols_to_include: [ props.tokenSymbol ], + ...(props.tokenSymbol ? { token_contract_symbols_to_include: [ props.tokenSymbol ] } : {}), }, - }); + }, { chain: multiChainContext?.chain }); + + const tooltipContentAfter = ( + <> + <Separator my={ 1 } className="dark"/> + <Link href={ defaultHref } display="flex" alignItems="center" justifyContent="center" gap={ 2 } fontWeight={ 500 } className="dark" textStyle="xs"> + <SpriteIcon name="advanced-filter" boxSize={ 5 }/> + <span>View all token transfers for this address and token</span> + </Link> + </> + ); return ( - <AddressEntity.default { ...props } href={ props.href ?? defaultHref }/> + <AddressEntity.default + { ...props } + contentProps={{ + tooltipInteractive: true, + tooltipContentAfter, + }} + /> ); }; 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 00000000000..72d88fd09bd --- /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 00000000000..5ee45cad1a3 --- /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'; + +export 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/block/pages/countdown-details/BlockCountdown.pw.tsx b/src/slices/block/pages/countdown-details/BlockCountdown.pw.tsx index c9b67846ea7..f0a5150e6a2 100644 --- a/src/slices/block/pages/countdown-details/BlockCountdown.pw.tsx +++ b/src/slices/block/pages/countdown-details/BlockCountdown.pw.tsx @@ -15,18 +15,12 @@ test.describe('short period until the block', () => { test.beforeEach(async({ mockApiResponse }) => { await mockApiResponse('core:block_countdown', { - result: { - CountdownBlock: height, - CurrentBlock: '1234567700', - RemainingBlock: '190', - EstimateTimeInSec: String(24 * 60 * 60 + 3 * 60 * 60 + 42 * 60 + 11), - }, + countdown_block_number: height, + current_block_number: '1234567700', + remaining_blocks_count: '190', + estimated_time_in_seconds: String(24 * 60 * 60 + 3 * 60 * 60 + 42 * 60 + 11), }, { - queryParams: { - module: 'block', - action: 'getblockcountdown', - blockno: height, - }, + pathParams: { height }, }); }); @@ -54,18 +48,12 @@ test.describe('long period until the block', () => { test.beforeEach(async({ mockApiResponse }) => { await mockApiResponse('core:block_countdown', { - result: { - CountdownBlock: height, - CurrentBlock: '1234567700', - RemainingBlock: '123456789012345678900000000190', - EstimateTimeInSec: String(1234567890 * 24 * 60 * 60 + 3 * 60 * 60 + 42 * 60 + 11), - }, + countdown_block_number: height, + current_block_number: '1234567700', + remaining_blocks_count: '123456789012345678900000000190', + estimated_time_in_seconds: String(1234567890 * 24 * 60 * 60 + 3 * 60 * 60 + 42 * 60 + 11), }, { - queryParams: { - module: 'block', - action: 'getblockcountdown', - blockno: height, - }, + pathParams: { height }, }); }); diff --git a/src/slices/block/pages/countdown-details/BlockCountdown.tsx b/src/slices/block/pages/countdown-details/BlockCountdown.tsx index 7624b680d73..92b7b45d28d 100644 --- a/src/slices/block/pages/countdown-details/BlockCountdown.tsx +++ b/src/slices/block/pages/countdown-details/BlockCountdown.tsx @@ -40,36 +40,35 @@ const BlockCountdown = ({ hideCapybaraRunner }: Props) => { const height = getQueryParamString(router.query.height); const { data, isPending, isError, error } = useApiQuery('core:block_countdown', { - queryParams: { - module: 'block', - action: 'getblockcountdown', - blockno: height, - }, + pathParams: { height }, }); + // the API answers 404 when the block is already mined, which is the same outcome as the countdown running out + const isBlockMined = isError && error.status === 404; + const handleAddToAppleCalClick = React.useCallback(() => { - if (!data?.result?.EstimateTimeInSec) { + if (!data?.estimated_time_in_seconds) { return; } - const fileBlob = createIcsFileBlob({ blockHeight: height, date: dayjs().add(Number(data.result.EstimateTimeInSec), 's'), multichainContext }); + const fileBlob = createIcsFileBlob({ blockHeight: height, date: dayjs().add(Number(data.estimated_time_in_seconds), 's'), multichainContext }); downloadBlob(fileBlob, `Block #${ height } creation event.ics`); - }, [ data?.result?.EstimateTimeInSec, height, multichainContext ]); + }, [ data?.estimated_time_in_seconds, height, multichainContext ]); const handleTimerFinish = React.useCallback(() => { window.location.assign(route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: height } }, multichainContext)); }, [ height, multichainContext ]); React.useEffect(() => { - if (!isError && !isPending && !data.result) { + if (isBlockMined) { handleTimerFinish(); } - }, [ data?.result, handleTimerFinish, isError, isPending ]); + }, [ handleTimerFinish, isBlockMined ]); - if (isError) { + if (isError && !isBlockMined) { throwOnResourceLoadError({ isError, error, resource: 'core:block_countdown' }); } - if (isPending || !data?.result) { + if (isPending || isBlockMined || !data) { return <Center h="100%"><ContentLoader/></Center>; } @@ -85,7 +84,7 @@ const BlockCountdown = ({ hideCapybaraRunner }: Props) => { </Heading> <Box mt={ 2 } color="text.secondary"> <Box fontWeight={ 600 }>Estimated target date</Box> - <Time timestamp={ dayjs().add(Number(data.result.EstimateTimeInSec), 's').valueOf() }/> + <Time timestamp={ dayjs().add(Number(data.estimated_time_in_seconds), 's').valueOf() }/> </Box> <Flex columnGap={ 2 } mt={ 3 }> <Link @@ -94,7 +93,7 @@ const BlockCountdown = ({ hideCapybaraRunner }: Props) => { textStyle="sm" px={ 2 } display="inline-flex" - href={ createGoogleCalendarLink({ blockHeight: height, timeFromNow: Number(data.result.EstimateTimeInSec), multichainContext }) } + href={ createGoogleCalendarLink({ blockHeight: height, timeFromNow: Number(data.estimated_time_in_seconds), multichainContext }) } > <Image src="/static/google_calendar.svg" alt="Google calendar logo" boxSize={ 5 } mr={ 2 }/> <span>Google</span> @@ -136,15 +135,13 @@ const BlockCountdown = ({ hideCapybaraRunner }: Props) => { ) } </Box> </Flex> - { data.result.EstimateTimeInSec && ( - <BlockCountdownTimer - value={ Math.ceil(Number(data.result.EstimateTimeInSec)) } - onFinish={ handleTimerFinish } - /> - ) } + <BlockCountdownTimer + value={ Math.ceil(Number(data.estimated_time_in_seconds)) } + onFinish={ handleTimerFinish } + /> <Grid gridTemplateColumns="repeat(2, calc(50% - 4px))" columnGap={ 2 } mt={ 2 }> - <StatsWidget label="Remaining blocks" value={ data.result.RemainingBlock } icon="apps"/> - <StatsWidget label="Current block" value={ data.result.CurrentBlock } icon="block"/> + <StatsWidget label="Remaining blocks" value={ data.remaining_blocks_count } icon="apps"/> + <StatsWidget label="Current block" value={ data.current_block_number } icon="block"/> </Grid> { !hideCapybaraRunner && <CapybaraRunner/> } </Flex> diff --git a/src/slices/block/types/api.ts b/src/slices/block/types/api.ts index 3779e0724ac..f4520f8f3bf 100644 --- a/src/slices/block/types/api.ts +++ b/src/slices/block/types/api.ts @@ -15,12 +15,3 @@ export interface NewBlockCountSocketResponse { export interface BlockFilters { type?: schemas['BlockResponse']['type']; } - -export interface BlockCountdownResponse { - result: { - CountdownBlock: string; - CurrentBlock: string; - EstimateTimeInSec: string; - RemainingBlock: string; - } | null; -} diff --git a/src/slices/home/pages/index/__screenshots__/Home.pw.tsx_default_degradation-view-1.png b/src/slices/home/pages/index/__screenshots__/Home.pw.tsx_default_degradation-view-1.png index 1752f475c25..c716265b7d3 100644 Binary files a/src/slices/home/pages/index/__screenshots__/Home.pw.tsx_default_degradation-view-1.png and b/src/slices/home/pages/index/__screenshots__/Home.pw.tsx_default_degradation-view-1.png differ diff --git a/src/slices/home/pages/index/blocks/LatestBlocks.tsx b/src/slices/home/pages/index/blocks/LatestBlocks.tsx index b4144b4b91e..66972f2de32 100644 --- a/src/slices/home/pages/index/blocks/LatestBlocks.tsx +++ b/src/slices/home/pages/index/blocks/LatestBlocks.tsx @@ -1,6 +1,6 @@ // SPDX-License-Identifier: LicenseRef-Blockscout -import { chakra, Box, Flex, Text, VStack, HStack } from '@chakra-ui/react'; +import { chakra, Box, Flex, Text, VStack } from '@chakra-ui/react'; import { upperFirst } from 'es-toolkit'; import { route } from 'nextjs-routes'; import React from 'react'; @@ -10,10 +10,8 @@ import type { schemas } from '@blockscout/api-types'; import getChainUtilizationParams from 'src/slices/chain/get-chain-utilization-params'; import useStatsQuery from 'src/slices/chain/stats/useStatsQuery'; import { useHomeDataContext } from 'src/slices/home/contexts/home-data-context'; -import { useHomeRpcDataContext } from 'src/slices/home/contexts/rpc-data-context'; import config from 'src/config'; -import ApiDegradationRpcIcon from 'src/shared/api-degradation/ApiDegradationRpcIcon'; import useIsMobile from 'src/shared/hooks/useIsMobile'; import useInitialList from 'src/shared/lists/useInitialList'; @@ -44,9 +42,6 @@ const LatestBlocks = () => { const statsQueryResult = useStatsQuery(); - const rpcDataContext = useHomeRpcDataContext(); - const isRpcData = rpcDataContext.isEnabled && !rpcDataContext.isLoading && !rpcDataContext.isError && rpcDataContext.subscriptions.includes('latest-blocks'); - const content = (() => { if (blocksQuery?.isError) { return <LatestBlocksDegraded maxNum={ blocksMaxCount }/>; @@ -79,10 +74,7 @@ const LatestBlocks = () => { return ( <Box width={{ base: '100%', lg: '280px' }} flexShrink={ 0 }> - <HStack alignItems="center"> - <Heading level="3">Latest blocks</Heading> - { isRpcData && <ApiDegradationRpcIcon/> } - </HStack> + <Heading level="3">Latest blocks</Heading> { typeof statsQueryResult.data?.network_utilization_percentage === 'number' && ( <Skeleton loading={ statsQueryResult.isPlaceholderData } mt={ 2 } display="inline-block" textStyle="sm"> <Text as="span"> diff --git a/src/slices/home/pages/index/stats/LatestBatchStatsWidget.tsx b/src/slices/home/pages/index/stats/LatestBatchStatsWidget.tsx index 219e4b17e5e..8002b70b684 100644 --- a/src/slices/home/pages/index/stats/LatestBatchStatsWidget.tsx +++ b/src/slices/home/pages/index/stats/LatestBatchStatsWidget.tsx @@ -1,5 +1,6 @@ // SPDX-License-Identifier: LicenseRef-Blockscout +import type { BoxProps } from '@chakra-ui/react'; import { chakra } from '@chakra-ui/react'; import React from 'react'; @@ -7,12 +8,11 @@ import { useHomeDataContext } from 'src/slices/home/contexts/home-data-context'; import StatsWidget from 'src/shared/stats/StatsWidget'; -type Props = { - className?: string; +interface Props extends BoxProps { isLoading: boolean; }; -const LatestBatchStatsWidget = ({ className, isLoading }: Props) => { +const LatestBatchStatsWidget = ({ isLoading, ...props }: Props) => { const { latestBatchQuery } = useHomeDataContext(); if (latestBatchQuery?.data === undefined) { @@ -21,12 +21,12 @@ const LatestBatchStatsWidget = ({ className, isLoading }: Props) => { return ( <StatsWidget - className={ className } icon="txn_batches" label="Latest batch" value={ latestBatchQuery.data.toLocaleString() } href={{ pathname: '/batches' }} isLoading={ isLoading } + { ...props } /> ); }; diff --git a/src/slices/home/pages/index/stats/LatestBlockStatsWidget.tsx b/src/slices/home/pages/index/stats/LatestBlockStatsWidget.tsx index ab21a1c6fe1..6a60bc7c569 100644 --- a/src/slices/home/pages/index/stats/LatestBlockStatsWidget.tsx +++ b/src/slices/home/pages/index/stats/LatestBlockStatsWidget.tsx @@ -1,5 +1,6 @@ // SPDX-License-Identifier: LicenseRef-Blockscout +import type { BoxProps } from '@chakra-ui/react'; import { chakra } from '@chakra-ui/react'; import React from 'react'; @@ -7,13 +8,12 @@ import { useHomeDataContext } from 'src/slices/home/contexts/home-data-context'; import StatsWidget from 'src/shared/stats/StatsWidget'; -type Props = { - className?: string; +interface Props extends BoxProps { isLoading: boolean; fallbackValue: number | string | undefined; }; -const LatestBlockStatsWidget = ({ className, isLoading, fallbackValue }: Props) => { +const LatestBlockStatsWidget = ({ isLoading, fallbackValue, ...props }: Props) => { const { blocksQuery } = useHomeDataContext(); const value = blocksQuery?.data?.[0]?.height ?? fallbackValue; @@ -23,12 +23,12 @@ const LatestBlockStatsWidget = ({ className, isLoading, fallbackValue }: Props) return ( <StatsWidget - className={ className } icon="block" label="Latest block" value={ Number(value).toLocaleString() } href={{ pathname: '/blocks' }} isLoading={ isLoading } + { ...props } /> ); }; diff --git a/src/slices/home/pages/index/stats/Stats.tsx b/src/slices/home/pages/index/stats/Stats.tsx index 57b0cf4dcbe..9ce0798ec59 100644 --- a/src/slices/home/pages/index/stats/Stats.tsx +++ b/src/slices/home/pages/index/stats/Stats.tsx @@ -200,14 +200,14 @@ const Stats = () => { flexBasis="50%" flexGrow={ 1 } > - { items.map((item) => { + { items.map(({ id, ...item }) => { if ('component' in item) { - return <React.Fragment key={ item.id }>{ item.component }</React.Fragment>; + return <React.Fragment key={ id }>{ item.component }</React.Fragment>; } return ( <StatsWidget - key={ item.id } + key={ id } { ...item } { ...homeStatsWidgetCommonStyles } isLoading={ isLoading } diff --git a/src/slices/home/pages/index/stats/StatsDegraded.tsx b/src/slices/home/pages/index/stats/StatsDegraded.tsx index e016594daba..465e5327c8b 100644 --- a/src/slices/home/pages/index/stats/StatsDegraded.tsx +++ b/src/slices/home/pages/index/stats/StatsDegraded.tsx @@ -12,13 +12,16 @@ import { homeStatsWidgetCommonStyles, isHomeStatsItemEnabled, sortHomeStatsItems import { getPublicClient, isPublicClientAvailable } from 'src/features/connect-wallet/utils/public-client'; -import ApiDegradationRpcIcon from 'src/shared/api-degradation/ApiDegradationRpcIcon'; import dayjs from 'src/shared/date-and-time/dayjs'; import StatsWidget from 'src/shared/stats/StatsWidget'; import { GWEI } from 'src/shared/values/entity/utils'; +import { Tooltip } from 'src/toolkit/chakra/tooltip'; import { mdash } from 'src/toolkit/utils/htmlEntities'; +const TOOLTIP_CONTENT_VALUE = 'Our indexer is experiencing problems, you see the data directly from RPC'; +const TOOLTIP_CONTENT_NO_VALUE = 'Our indexer is experiencing problems and we couldn\'t get this data directly from RPC'; + const StatsDegraded = () => { const [ averageBlockTime, setAverageBlockTime ] = React.useState<number | undefined>(undefined); @@ -94,7 +97,6 @@ const StatsDegraded = () => { label: 'Latest block', value: blocks[0] ? blocks[0].height.toLocaleString() : mdash, isFallback: blocks[0] === undefined, - hint: blocks[0] && !isLoading ? <ApiDegradationRpcIcon/> : undefined, }, { id: 'average_block_time' as const, @@ -102,7 +104,6 @@ const StatsDegraded = () => { label: 'Average block time', value: averageBlockTime ? `${ averageBlockTime.toFixed(1) }s` : mdash, isFallback: averageBlockTime === undefined, - hint: averageBlockTime && !isLoading ? <ApiDegradationRpcIcon/> : undefined, }, { id: 'total_txs' as const, @@ -139,7 +140,6 @@ const StatsDegraded = () => { value: gasPriceQuery.data ? <GasPrice data={ gasPriceQuery.data }/> : mdash, isFallback: !gasPriceQuery.data, isLoading: gasPriceQuery.isLoading, - hint: gasPriceQuery.data && !isLoading && !gasPriceQuery.isLoading ? <ApiDegradationRpcIcon/> : undefined, }, { id: 'btc_locked' as const, @@ -172,14 +172,22 @@ const StatsDegraded = () => { flexBasis="50%" flexGrow={ 1 } > - { items.map((item) => ( - <StatsWidget - key={ item.id } - { ...item } - isLoading={ isLoading || item.isLoading } - { ...homeStatsWidgetCommonStyles }/> - ), - ) } + { items.map(({ id, ...item }) => { + const isLoadingState = item.isLoading || isLoading; + return ( + <Tooltip + key={ id } + content={ item.value !== mdash ? TOOLTIP_CONTENT_VALUE : TOOLTIP_CONTENT_NO_VALUE } + disabled={ isLoadingState } + > + <StatsWidget + { ...item } + isLoading={ isLoadingState } + { ...homeStatsWidgetCommonStyles } + /> + </Tooltip> + ); + }) } </Grid> ); diff --git a/src/slices/home/pages/index/txs/Transactions.tsx b/src/slices/home/pages/index/txs/Transactions.tsx index a99d8123706..db179ae0096 100644 --- a/src/slices/home/pages/index/txs/Transactions.tsx +++ b/src/slices/home/pages/index/txs/Transactions.tsx @@ -1,12 +1,9 @@ // SPDX-License-Identifier: LicenseRef-Blockscout -import { HStack } from '@chakra-ui/react'; import React from 'react'; import { SocketProvider } from 'src/api/socket/context'; -import { useHomeRpcDataContext } from 'src/slices/home/contexts/rpc-data-context'; - import useAuth from 'src/features/account/hooks/useIsAuth'; import LatestWatchlistTxs from 'src/features/account/pages/home/LatestWatchlistTxs'; import LatestZetaChainCCTXs from 'src/features/chain-variants/zeta-chain/pages/home/LatestZetaChainCCTXs'; @@ -16,7 +13,6 @@ import { layerLabels } from 'src/features/rollup/common/utils/layer'; import LatestOptimisticDeposits from 'src/features/rollup/optimism/pages/home/LatestOptimisticDeposits'; import config from 'src/config'; -import ApiDegradationRpcIcon from 'src/shared/api-degradation/ApiDegradationRpcIcon'; import { Heading } from 'src/toolkit/chakra/heading'; import AdaptiveTabs from 'src/toolkit/components/AdaptiveTabs/AdaptiveTabs'; @@ -30,8 +26,6 @@ const crossChainTxsFeature = config.features.crossChainTxs; const Transactions = () => { const isAuth = useAuth(); - const rpcDataContext = useHomeRpcDataContext(); - const isRpcData = rpcDataContext.isEnabled && !rpcDataContext.isLoading && !rpcDataContext.isError && rpcDataContext.subscriptions.includes('latest-txs'); const tabs = [ zetachainFeature.isEnabled && { @@ -80,10 +74,7 @@ const Transactions = () => { return ( <> - <HStack mb={ 3 }> - <Heading level="3" >Latest transactions</Heading> - { isRpcData && <ApiDegradationRpcIcon/> } - </HStack> + <Heading level="3" mb={ 3 }>Latest transactions</Heading> <AdaptiveTabs tabs={ tabs } unmountOnExit={ false } listProps={{ mb: 3 }}/> </> ); diff --git a/src/slices/search/pages/search-results/SearchResultListItem.tsx b/src/slices/search/pages/search-results/SearchResultListItem.tsx index f01de328598..058c63ee150 100644 --- a/src/slices/search/pages/search-results/SearchResultListItem.tsx +++ b/src/slices/search/pages/search-results/SearchResultListItem.tsx @@ -33,6 +33,7 @@ import HashStringShortenDynamic from 'src/shared/texts/HashStringShortenDynamic' import highlightText from 'src/shared/texts/highlight-text'; import SpriteIcon from 'src/sprite/SpriteIcon'; +import { Badge } from 'src/toolkit/chakra/badge'; import { useColorMode } from 'src/toolkit/chakra/color-mode'; import { Image } from 'src/toolkit/chakra/image'; import { Link } from 'src/toolkit/chakra/link'; @@ -245,7 +246,7 @@ const SearchResultListItem = ({ data, searchTerm, isLoading, addressFormat }: Pr case 'tac_operation': { return ( <TacOperationEntity.Container> - <TacOperationEntity.Icon type={ data.tac_operation.type }/> + <TacOperationEntity.Icon status={ data.tac_operation.status } isLoading={ isLoading }/> <TacOperationEntity.Link isLoading={ isLoading } id={ data.tac_operation.operation_id } @@ -259,7 +260,14 @@ const SearchResultListItem = ({ data, searchTerm, isLoading, addressFormat }: Pr mr={ 2 } /> </TacOperationEntity.Link> - <TacOperationStatus status={ data.tac_operation.type }/> + <TacOperationStatus + status={ data.tac_operation.status } + type={ data.tac_operation.type } + errorReason={ data.tac_operation.error_reason } + isRollback={ data.tac_operation.rollback } + isLoading={ isLoading } + /> + { data.tac_operation.rollback && <Badge loading={ isLoading }>Rollback</Badge> } </TacOperationEntity.Container> ); } diff --git a/src/slices/search/pages/search-results/SearchResultTableItem.tsx b/src/slices/search/pages/search-results/SearchResultTableItem.tsx index ce365ee7fbc..6bea88bdbf9 100644 --- a/src/slices/search/pages/search-results/SearchResultTableItem.tsx +++ b/src/slices/search/pages/search-results/SearchResultTableItem.tsx @@ -32,6 +32,7 @@ import HashStringShortenDynamic from 'src/shared/texts/HashStringShortenDynamic' import highlightText from 'src/shared/texts/highlight-text'; import SpriteIcon from 'src/sprite/SpriteIcon'; +import { Badge } from 'src/toolkit/chakra/badge'; import { useColorMode } from 'src/toolkit/chakra/color-mode'; import { Image } from 'src/toolkit/chakra/image'; import { Link } from 'src/toolkit/chakra/link'; @@ -363,7 +364,7 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading, addressFormat }: P <> <TableCell colSpan={ 2 } fontSize="sm"> <TacOperationEntity.Container> - <TacOperationEntity.Icon type={ data.tac_operation.type }/> + <TacOperationEntity.Icon status={ data.tac_operation.status } isLoading={ isLoading }/> <TacOperationEntity.Link isLoading={ isLoading } id={ data.tac_operation.operation_id } @@ -377,7 +378,14 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading, addressFormat }: P mr={ 2 } /> </TacOperationEntity.Link> - <TacOperationStatus status={ data.tac_operation.type }/> + <TacOperationStatus + status={ data.tac_operation.status } + type={ data.tac_operation.type } + errorReason={ data.tac_operation.error_reason } + isRollback={ data.tac_operation.rollback } + isLoading={ isLoading } + /> + { data.tac_operation.rollback && <Badge loading={ isLoading }>Rollback</Badge> } </TacOperationEntity.Container> </TableCell> <TableCell fontSize="sm" verticalAlign="middle" isNumeric> diff --git a/src/slices/token-transfer/components/list/TokenTransferList.tsx b/src/slices/token-transfer/components/list/TokenTransferList.tsx index 2f38a9225ce..1a9cefd064f 100644 --- a/src/slices/token-transfer/components/list/TokenTransferList.tsx +++ b/src/slices/token-transfer/components/list/TokenTransferList.tsx @@ -10,6 +10,7 @@ import { useMultichainContext } from 'src/features/multichain/context'; import useLazyRenderedList from 'src/shared/lists/useLazyRenderedList'; +import { getTokenTransferKey } from '../../utils/get-token-transfer-key'; import TokenTransferListItem from './TokenTransferListItem'; interface Props { @@ -31,7 +32,7 @@ const TokenTransferList = ({ data, baseAddress, showTxInfo, enableTimeIncrement, <Box> { data.slice(0, renderedItemsNum).map((item, index) => ( <TokenTransferListItem - key={ item.transaction_hash + item.block_hash + item.log_index + (isLoading ? index : '') } + key={ getTokenTransferKey(item) + (isLoading ? index : '') } data={ item } baseAddress={ baseAddress } showTxInfo={ showTxInfo } diff --git a/src/slices/token-transfer/components/list/TokenTransferListItem.tsx b/src/slices/token-transfer/components/list/TokenTransferListItem.tsx index a32e391fb12..c1df03ca7e4 100644 --- a/src/slices/token-transfer/components/list/TokenTransferListItem.tsx +++ b/src/slices/token-transfer/components/list/TokenTransferListItem.tsx @@ -95,6 +95,8 @@ const TokenTransferListItem = ({ from={ data.from } to={ data.to } current={ baseAddress } + tokenHash={ data.token.address_hash } + tokenSymbol={ data.token.symbol ?? undefined } isLoading={ isLoading } w="100%" /> diff --git a/src/slices/token-transfer/components/list/TokenTransferTable.tsx b/src/slices/token-transfer/components/list/TokenTransferTable.tsx index 5a14fb07bd5..c4b62779fd0 100644 --- a/src/slices/token-transfer/components/list/TokenTransferTable.tsx +++ b/src/slices/token-transfer/components/list/TokenTransferTable.tsx @@ -15,6 +15,7 @@ import useLazyRenderedList from 'src/shared/lists/useLazyRenderedList'; import { TableBody, TableColumnHeader, TableHeaderSticky, TableRoot, TableRow } from 'src/toolkit/chakra/table'; +import { getTokenTransferKey } from '../../utils/get-token-transfer-key'; import TokenTransferTableItem from './TokenTransferTableItem'; interface Props { @@ -81,7 +82,7 @@ const TokenTransferTable = ({ ) } { data.slice(0, renderedItemsNum).map((item, index) => ( <TokenTransferTableItem - key={ item.transaction_hash + item.block_hash + item.log_index + (isLoading ? index : '') } + key={ getTokenTransferKey(item) + (isLoading ? index : '') } data={ item } baseAddress={ baseAddress } showTxInfo={ showTxInfo } diff --git a/src/slices/token-transfer/components/list/TokenTransferTableItem.tsx b/src/slices/token-transfer/components/list/TokenTransferTableItem.tsx index 76df50f20b2..f4398597304 100644 --- a/src/slices/token-transfer/components/list/TokenTransferTableItem.tsx +++ b/src/slices/token-transfer/components/list/TokenTransferTableItem.tsx @@ -124,6 +124,8 @@ const TokenTransferTableItem = ({ from={ data.from } to={ data.to } current={ baseAddress } + tokenHash={ data.token.address_hash } + tokenSymbol={ data.token.symbol ?? undefined } isLoading={ isLoading } mt={ 1 } mode={{ base: 'compact', lg: 'compact', xl: 'long' }} diff --git a/src/slices/token-transfer/components/snippet/TokenTransferSnippet.tsx b/src/slices/token-transfer/components/snippet/TokenTransferSnippet.tsx index ebc3b7322b2..2391728fdbb 100644 --- a/src/slices/token-transfer/components/snippet/TokenTransferSnippet.tsx +++ b/src/slices/token-transfer/components/snippet/TokenTransferSnippet.tsx @@ -97,6 +97,19 @@ const TokenTransferSnippet = ({ data, isLoading, noAddressIcons = true }: Props) } })(); + const { tokenHash, tokenSymbol } = (() => { + if (data.token) { + return { + tokenHash: data.token.address_hash, + tokenSymbol: data.token.symbol ?? undefined, + }; + } + return { + tokenHash: undefined, + tokenSymbol: undefined, + }; + })(); + return ( <Flex alignItems="center" @@ -109,6 +122,8 @@ const TokenTransferSnippet = ({ data, isLoading, noAddressIcons = true }: Props) <AddressFromTo from={ data.from } to={ data.to } + tokenHash={ tokenHash } + tokenSymbol={ tokenSymbol } truncation="constant" noIcon={ noAddressIcons } isLoading={ isLoading } diff --git a/src/slices/token-transfer/pages/index/TokenTransfersLocal.tsx b/src/slices/token-transfer/pages/index/TokenTransfersLocal.tsx index c9ef5ef3631..013eb09ead8 100644 --- a/src/slices/token-transfer/pages/index/TokenTransfersLocal.tsx +++ b/src/slices/token-transfer/pages/index/TokenTransfersLocal.tsx @@ -15,6 +15,7 @@ import useLazyRenderedList from 'src/shared/lists/useLazyRenderedList'; import Pagination from 'src/shared/pagination/Pagination'; import useTokenTransfersQuery from '../../hooks/useTokenTransfersQuery'; +import { getTokenTransferKey } from '../../utils/get-token-transfer-key'; import TokenTransfersListItem from './TokenTransfersListItem'; import TokenTransfersTable from './TokenTransfersTable'; @@ -31,7 +32,7 @@ const TokenTransfersLocal = () => { <Box hideFrom="lg"> { query.data?.items.slice(0, renderedItemsNum).map((item, index) => ( <TokenTransfersListItem - key={ (item.transaction_hash ?? '') + item.log_index + (query.isPlaceholderData ? index : '') } + key={ getTokenTransferKey(item) + (query.isPlaceholderData ? index : '') } isLoading={ query.isPlaceholderData } item={ item } /> diff --git a/src/slices/token-transfer/pages/index/TokenTransfersTable.spec.tsx b/src/slices/token-transfer/pages/index/TokenTransfersTable.spec.tsx new file mode 100644 index 00000000000..5d021cd31f1 --- /dev/null +++ b/src/slices/token-transfer/pages/index/TokenTransfersTable.spec.tsx @@ -0,0 +1,32 @@ +// @vitest-environment jsdom +// SPDX-License-Identifier: LicenseRef-Blockscout + +import React from 'react'; + +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from 'vitest/lib'; + +import { erc1155A, erc1155B, erc1155C, erc1155D, erc20, erc721 } from '../../mocks'; +import TokenTransfersTable from './TokenTransfersTable'; + +const BATCH_PAGE = [ erc1155A, erc1155B, erc1155C, erc1155D ]; +const NEXT_PAGE = [ erc20, erc721 ]; + +describe('TokenTransfersTable', () => { + afterEach(cleanup); + + it('renders every item of a batch transfer', () => { + const { container } = render(<TokenTransfersTable items={ BATCH_PAGE } top={ 0 }/>); + + expect(container.querySelectorAll('tbody tr')).toHaveLength(BATCH_PAGE.length); + }); + + it('drops all rows of the previous page when the next page arrives', () => { + const { container, rerender } = render(<TokenTransfersTable items={ BATCH_PAGE } top={ 0 }/>); + + rerender(<TokenTransfersTable items={ NEXT_PAGE } top={ 0 }/>); + + expect(container.querySelectorAll('tbody tr')).toHaveLength(NEXT_PAGE.length); + expect(container.textContent).not.toContain(erc1155A.transaction_hash?.slice(0, 10)); + }); +}); diff --git a/src/slices/token-transfer/pages/index/TokenTransfersTable.tsx b/src/slices/token-transfer/pages/index/TokenTransfersTable.tsx index da48a51930a..afd804fd8db 100644 --- a/src/slices/token-transfer/pages/index/TokenTransfersTable.tsx +++ b/src/slices/token-transfer/pages/index/TokenTransfersTable.tsx @@ -12,6 +12,7 @@ import useLazyRenderedList from 'src/shared/lists/useLazyRenderedList'; import { TableBody, TableColumnHeader, TableHeaderSticky, TableRoot, TableRow } from 'src/toolkit/chakra/table'; +import { getTokenTransferKey } from '../../utils/get-token-transfer-key'; import TokenTransferTableItem from './TokenTransfersTableItem'; interface Props { @@ -45,7 +46,7 @@ const TokenTransferTable = ({ items, top, isLoading, chainData, resetKey }: Prop <TableBody> { items?.slice(0, renderedItemsNum).map((item, index) => ( <TokenTransferTableItem - key={ (item.transaction_hash ?? '') + item.log_index + (isLoading ? index : '') + (chainData ? chainData.id : '') } + key={ getTokenTransferKey(item) + (isLoading ? index : '') + (chainData ? chainData.id : '') } item={ item } isLoading={ isLoading } chainData={ chainData } diff --git a/src/slices/token-transfer/pages/index/TokenTransfersTableItem.tsx b/src/slices/token-transfer/pages/index/TokenTransfersTableItem.tsx index acebf2af180..bc96f260df2 100644 --- a/src/slices/token-transfer/pages/index/TokenTransfersTableItem.tsx +++ b/src/slices/token-transfer/pages/index/TokenTransfersTableItem.tsx @@ -93,6 +93,8 @@ const TokenTransferTableItem = ({ item, isLoading, chainData }: Props) => { maxW={{ lg: '220px', xl: '320px' }} from={ item.from } to={ item.to } + tokenHash={ item.token?.address_hash } + tokenSymbol={ item.token?.symbol ?? undefined } isLoading={ isLoading } mode={{ lg: 'compact', xl: 'long' }} /> diff --git a/src/slices/token-transfer/pages/token/TokenTransfer.tsx b/src/slices/token-transfer/pages/token/TokenTransfer.tsx index 3afcb79d16e..c7562ef57af 100644 --- a/src/slices/token-transfer/pages/token/TokenTransfer.tsx +++ b/src/slices/token-transfer/pages/token/TokenTransfer.tsx @@ -35,6 +35,11 @@ const TokenTransfer = ({ tokenId, token, isLoading: isLoadingProp, tokenInstance const [ newItemsCount, setNewItemsCount ] = useGradualIncrement(0); const [ showSocketErrorAlert, setShowSocketErrorAlert ] = React.useState(false); + // The backend emits `token_transfer` events for the whole token, not per instance, so on the NFT + // instance page they produce false "N more transfers" notices. + // See https://github.com/blockscout/frontend/issues/3653 + const isSocketEnabled = !tokenId; + const transfersQuery = useQueryWithPages({ resourceName: tokenId ? 'core:token_instance_transfers' : 'core:token_transfers', pathParams: { hash: token?.address_hash, id: tokenId }, @@ -60,7 +65,7 @@ const TokenTransfer = ({ tokenId, token, isLoading: isLoadingProp, tokenInstance topic: `tokens:${ token?.address_hash.toLowerCase() }`, onSocketClose: handleSocketClose, onSocketError: handleSocketError, - isDisabled: transfersQuery.isPlaceholderData || transfersQuery.isError || transfersQuery.pagination.page !== 1, + isDisabled: !isSocketEnabled || transfersQuery.isPlaceholderData || transfersQuery.isError || transfersQuery.pagination.page !== 1, }); useSocketMessage({ channel, @@ -76,7 +81,7 @@ const TokenTransfer = ({ tokenId, token, isLoading: isLoadingProp, tokenInstance <TokenTransferTable data={ transfersQuery.data?.items } top={ ACTION_BAR_HEIGHT_DESKTOP } - showSocketInfo={ transfersQuery.pagination.page === 1 } + showSocketInfo={ isSocketEnabled && transfersQuery.pagination.page === 1 } showSocketErrorAlert={ showSocketErrorAlert } socketInfoNum={ newItemsCount } tokenId={ tokenId } @@ -87,7 +92,7 @@ const TokenTransfer = ({ tokenId, token, isLoading: isLoadingProp, tokenInstance /> </Box> <Box display={{ base: 'block', lg: 'none' }}> - { transfersQuery.pagination.page === 1 && ( + { isSocketEnabled && transfersQuery.pagination.page === 1 && ( <SocketNewItemsNotice.Mobile num={ newItemsCount } showErrorAlert={ showSocketErrorAlert } diff --git a/src/slices/token-transfer/utils/get-token-transfer-key.spec.ts b/src/slices/token-transfer/utils/get-token-transfer-key.spec.ts new file mode 100644 index 00000000000..aa82605bc90 --- /dev/null +++ b/src/slices/token-transfer/utils/get-token-transfer-key.spec.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import { describe, expect, it } from 'vitest'; + +import { erc1155A, erc1155B, erc1155C, erc1155D, erc20 } from '../mocks'; +import { getTokenTransferKey } from './get-token-transfer-key'; + +describe('getTokenTransferKey', () => { + it('tells apart the items of a single ERC-1155 batch transfer', () => { + const batch = [ erc1155A, erc1155B, erc1155C, erc1155D ]; + + expect(new Set(batch.map(getTokenTransferKey)).size).toBe(4); + }); + + it('tells apart transfers from the same block that differ only in log index', () => { + const first = getTokenTransferKey(erc1155A); + const second = getTokenTransferKey({ ...erc1155A, log_index: erc1155A.log_index + 1 }); + + expect(first).not.toBe(second); + }); + + it('tells apart transfers that carry no token id', () => { + const fungible = getTokenTransferKey(erc20); + + expect(fungible).not.toBe(getTokenTransferKey({ ...erc20, transaction_hash: '0xdeadbeef' })); + expect(fungible).not.toBe(getTokenTransferKey(erc1155A)); + }); +}); diff --git a/src/slices/token-transfer/utils/get-token-transfer-key.ts b/src/slices/token-transfer/utils/get-token-transfer-key.ts new file mode 100644 index 00000000000..73538aeb85b --- /dev/null +++ b/src/slices/token-transfer/utils/get-token-transfer-key.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import type { schemas } from '@blockscout/api-types'; + +// An ERC-1155 batch transfer reaches the API as a single log, which the API flattens into one item +// per transferred token id — so those items share a (transaction_hash, block_hash, log_index) triple +// and only the token id tells them apart. +// +// Equal keys are not merely untidy here: React tracks pending removals in a key -> fiber map, where +// duplicates overwrite each other, and the shadowed fibers are then never unmounted. Their rows stay +// in the DOM through pagination, stacked above the rows of every page that follows. +// https://github.com/blockscout/frontend/issues/3628 +export function getTokenTransferKey(item: schemas['TokenTransfer']): string { + const tokenId = item.total && 'token_id' in item.total ? item.total.token_id : null; + + return [ item.transaction_hash, item.block_hash, item.log_index, tokenId ].join('_'); +} diff --git a/src/slices/token/pages/address/useFetchTokens.ts b/src/slices/token/pages/address/useFetchTokens.ts index 284a5d7712d..3c7cc6dd598 100644 --- a/src/slices/token/pages/address/useFetchTokens.ts +++ b/src/slices/token/pages/address/useFetchTokens.ts @@ -13,9 +13,12 @@ import useApiQuery, { getResourceKey } from 'src/api/hooks/useApiQuery'; import useSocketChannel from 'src/api/socket/useSocketChannel'; import useSocketMessage from 'src/api/socket/useSocketMessage'; +import { useAppContext } from 'src/shell/app/context'; + import { useMultichainContext } from 'src/features/multichain/context'; import config from 'src/config'; +import * as cookies from 'src/shared/storage/cookies'; import type { TokenEnhancedData } from './utils'; import { calculateUsdValue } from './utils'; @@ -25,6 +28,8 @@ interface Props { enabled?: boolean; } +const EMPTY_TOKEN_BALANCES: Array<schemas['TokenBalance']> = []; + const tokenBalanceItemIdentityFactory = (match: schemas['TokenBalance']) => (item: schemas['TokenBalance']) => (( match.token && item.token && match.token.address_hash === item.token.address_hash && @@ -40,6 +45,12 @@ const socketEventForTokenType = (tokenTypeId: string): string => { const additionalTypes = config.slices.token.additionalTypes; export default function useFetchTokens({ hash, enabled }: Props) { + const { cookies: appCookies } = useAppContext(); + + // the socket carries no request headers, so the backend pushes balances unfiltered — + // the scam filter it applies to the REST response has to be repeated here + const shouldHideScamTokens = config.slices.token.hideScamTokensEnabled && !(cookies.get(cookies.NAMES.SHOW_SCAM_TOKENS, appCookies) === 'true'); + const erc20query = useApiQuery('core:address_tokens', { pathParams: { hash }, queryParams: { type: [ 'ERC-20' ] }, @@ -85,19 +96,34 @@ export default function useFetchTokens({ hash, enabled }: Props) { const updateTokensData = React.useCallback((type: TokenType | Array<TokenType>, payload: AddressTokensBalancesSocketMessage) => { const queryKey = getResourceKey('core:address_tokens', { pathParams: { hash }, queryParams: { type: Array.isArray(type) ? type : [ type ] } }); + const tokenBalances = shouldHideScamTokens ? + payload.token_balances.filter((item) => item.token?.reputation !== 'scam') : + payload.token_balances; + const scamBalances = shouldHideScamTokens ? + payload.token_balances.filter((item) => item.token?.reputation === 'scam') : + EMPTY_TOKEN_BALANCES; + queryClient.setQueryData(queryKey, ( prevData: paths['/api/v2/addresses/{address_hash_param}/tokens']['get'] | undefined, ) => { - const items = prevData?.items.map((currentItem) => { - const updatedData = payload.token_balances.find(tokenBalanceItemIdentityFactory(currentItem)); - return updatedData ?? currentItem; - }) || []; + // a token can be flagged as scam while the page is open, so a cached row has to go + // when the socket reports its identity as scam + const items = prevData?.items + .filter((currentItem) => !scamBalances.some(tokenBalanceItemIdentityFactory(currentItem))) + .map((currentItem) => { + const updatedData = tokenBalances.find(tokenBalanceItemIdentityFactory(currentItem)); + return updatedData ?? currentItem; + }) || []; const extraItems = prevData?.next_page_params ? [] : - payload.token_balances.filter((socketItem) => !items.some(tokenBalanceItemIdentityFactory(socketItem))); + tokenBalances.filter((socketItem) => !items.some(tokenBalanceItemIdentityFactory(socketItem))); if (!prevData) { + if (extraItems.length === 0) { + return undefined; + } + return { items: extraItems, next_page_params: null, @@ -109,7 +135,7 @@ export default function useFetchTokens({ hash, enabled }: Props) { next_page_params: prevData.next_page_params, }; }); - }, [ hash, queryClient ]); + }, [ hash, queryClient, shouldHideScamTokens ]); const additionalTokenTypesIds = React.useMemo(() => { return additionalTypes.map((item) => item.id); diff --git a/src/slices/token/pages/details/Token.tsx b/src/slices/token/pages/details/Token.tsx index e3c1b73efdd..5570f7a7b87 100644 --- a/src/slices/token/pages/details/Token.tsx +++ b/src/slices/token/pages/details/Token.tsx @@ -41,7 +41,6 @@ import RoutedTabs from 'src/toolkit/components/RoutedTabs/RoutedTabs'; export type TokenTabs = 'token_transfers' | 'holders' | 'inventory'; const TokenPageContent = () => { - const [ isQueryEnabled, setIsQueryEnabled ] = React.useState(false); const [ totalSupplySocket, setTotalSupplySocket ] = React.useState<number>(); const router = useRouter(); @@ -51,12 +50,16 @@ const TokenPageContent = () => { useEtherscanRedirects(); const queryClient = useQueryClient(); + // Ideally, with the current API setup, we should wait until the socket connection is established before fetching the token data + // so the client does not miss events related to total supply changes. + // However, this would require moving the token request out of the primed list, which would reduce page-loading speed. + // Therefore, we decided not to do this, as we expect a very small number of affected users. const tokenQuery = useTokenQuery(hashString); const addressQuery = useApiQuery('core:address', { pathParams: { hash: hashString }, queryOptions: { - enabled: isQueryEnabled && Boolean(router.query.hash), + enabled: Boolean(router.query.hash), placeholderData: addressStubs.ADDRESS_INFO, }, }); @@ -83,13 +86,9 @@ const TokenPageContent = () => { }); }, [ queryClient, hashString ]); - const enableQuery = React.useCallback(() => setIsQueryEnabled(true), []); - const channel = useSocketChannel({ topic: `tokens:${ hashString?.toLowerCase() }`, isDisabled: !hashString, - onJoin: enableQuery, - onSocketError: enableQuery, }); useSocketMessage({ channel, @@ -123,11 +122,11 @@ const TokenPageContent = () => { queryOptions: { enabled: Boolean(hashString), placeholderData: TOKEN_COUNTERS }, }); - const address3rdPartyWidgets = useAddress3rdPartyWidgets('token', false, isQueryEnabled); + const address3rdPartyWidgets = useAddress3rdPartyWidgets('token', false); throwOnResourceLoadError(tokenQuery); - const isMainDataLoading = tokenQuery.isPlaceholderData || addressQuery.isPlaceholderData; + const isMainDataLoading = tokenQuery.isPlaceholderData; const isFullDataLoading = isMainDataLoading || (address3rdPartyWidgets.isEnabled && address3rdPartyWidgets.configQuery.isPlaceholderData); const transfersCount = !tokenCountersQuery.isPlaceholderData && tokenCountersQuery.data?.transfers_count ? @@ -154,18 +153,15 @@ const TokenPageContent = () => { addressQuery.data?.is_contract ? { id: 'contract', title: () => { - if (addressQuery.data?.is_verified) { - return ( - <> - <span>Contract</span> - <SpriteIcon name="status/success" boxSize="14px" color="green.500"/> - </> - ); - } - - return 'Contract'; + return ( + <> + <span>Contract</span> + { addressQuery.data?.is_verified && + <SpriteIcon name="status/success" boxSize="14px" color="green.500" isLoading={ addressQuery.isPlaceholderData }/> } + </> + ); }, - component: <Contract addressData={ addressQuery.data } isLoading={ isMainDataLoading }/>, + component: <Contract addressData={ addressQuery.data } isLoading={ isMainDataLoading || addressQuery.isPlaceholderData }/>, subTabs: CONTRACT_TAB_IDS, } : undefined, hasInventoryTab ? { diff --git a/src/slices/token/pages/details/TokenPageTitle.tsx b/src/slices/token/pages/details/TokenPageTitle.tsx index 4a7cbaac96b..3d60371cdda 100644 --- a/src/slices/token/pages/details/TokenPageTitle.tsx +++ b/src/slices/token/pages/details/TokenPageTitle.tsx @@ -45,15 +45,10 @@ interface Props { const TokenPageTitle = ({ tokenQuery, addressQuery, verifiedInfoQuery, hash }: Props) => { const multichainContext = useMultichainContext(); - const addressHash = !tokenQuery.isPlaceholderData ? (tokenQuery.data?.address_hash || '') : ''; const addressesForMetadataQuery = React.useMemo(() => ([ hash ].filter(Boolean)), [ hash ]); const addressMetadataQuery = useAddressMetadataInfoQuery(addressesForMetadataQuery); - const isLoading = tokenQuery.isPlaceholderData || - addressQuery.isPlaceholderData || - (config.features.verifiedTokens.isEnabled && verifiedInfoQuery.isPending); - const tokenSymbolText = tokenQuery.data?.symbol ? ` (${ tokenQuery.data.symbol })` : ''; const [ bridgedTokenTagBgColor ] = useToken('colors', 'blue.500'); @@ -94,6 +89,11 @@ const TokenPageTitle = ({ tokenQuery, addressQuery, verifiedInfoQuery, hash }: P multichainContext?.chain?.app_config, ]); + const isTagsLoading = tokenQuery.isPlaceholderData || + addressQuery.isPlaceholderData || + (config.features.addressMetadata.isEnabled && addressMetadataQuery.isPending) || + (config.features.verifiedTokens.isEnabled && verifiedInfoQuery.isPending); + const contentAfter = ( <> { tokenQuery.data && <TokenEntity.Reputation value={ tokenQuery.data.reputation } ml={ 0 }/> } @@ -113,7 +113,7 @@ const TokenPageTitle = ({ tokenQuery, addressQuery, verifiedInfoQuery, hash }: P </Tooltip> ) } <MetadataTags - isLoading={ isLoading || (config.features.addressMetadata.isEnabled && addressMetadataQuery.isPending) } + isLoading={ isTagsLoading } tags={ tags } addressHash={ addressQuery.data?.hash } flexGrow={ 1 } @@ -121,24 +121,26 @@ const TokenPageTitle = ({ tokenQuery, addressQuery, verifiedInfoQuery, hash }: P </> ); + const isMainDataLoading = tokenQuery.isPlaceholderData; + // should not be shown before the main title text is loaded + const isAddressDataLoading = tokenQuery.isPlaceholderData || addressQuery.isPlaceholderData; + const secondRow = ( <Flex alignItems="center" w="100%" minW={ 0 } columnGap={ 2 } rowGap={ 2 } flexWrap={{ base: 'wrap', lg: 'nowrap' }}> - { addressQuery.data && ( - <AddressEntity - address={{ ...addressQuery.data, name: '' }} - isLoading={ isLoading } - variant="subheading" - icon={ multichainContext?.chain ? { - shield: { name: 'pie_chart', isLoading }, - } : undefined } - /> - ) } - { !isLoading && tokenQuery.data && <TokenAddToWallet token={ tokenQuery.data } variant="button"/> } - { addressQuery.data && <AddressQrCode hash={ addressQuery.data.hash } isLoading={ isLoading }/> } - <ActionsMenu isLoading={ isLoading }/> + <AddressEntity + address={{ ...addressQuery.data, name: '', hash: addressQuery.data?.hash || hash }} + isLoading={ isAddressDataLoading } + variant="subheading" + icon={ multichainContext?.chain ? { + shield: { name: 'pie_chart', isLoading: isAddressDataLoading }, + } : undefined } + /> + { !isMainDataLoading && tokenQuery.data && <TokenAddToWallet token={ tokenQuery.data } variant="button"/> } + <AddressQrCode hash={ hash } isLoading={ isMainDataLoading }/> + <ActionsMenu isLoading={ isMainDataLoading }/> <Flex ml={{ base: 0, lg: 'auto' }} columnGap={ 2 } flexGrow={{ base: 1, lg: 0 }}> <TokenVerifiedInfo verifiedInfoQuery={ verifiedInfoQuery }/> - <AlternativeExplorers type="token" pathParam={ addressHash } ml={{ base: 'auto', lg: 0 }}/> + <AlternativeExplorers type="token" pathParam={ hash } ml={{ base: 'auto', lg: 0 }}/> </Flex> </Flex> ); @@ -147,11 +149,11 @@ const TokenPageTitle = ({ tokenQuery, addressQuery, verifiedInfoQuery, hash }: P <> <PageTitle title={ `${ tokenQuery.data?.name || 'Unnamed token' }${ tokenSymbolText }` } - isLoading={ tokenQuery.isPlaceholderData } + isLoading={ isMainDataLoading } beforeTitle={ tokenQuery.data ? ( <TokenEntity.Icon token={ tokenQuery.data } - isLoading={ tokenQuery.isPlaceholderData } + isLoading={ isMainDataLoading } variant="heading" chain={ multichainContext?.chain } /> diff --git a/src/slices/token/pages/details/holders/TokenHoldersListItem.tsx b/src/slices/token/pages/details/holders/TokenHoldersListItem.tsx index fddddd1db76..68d963bd693 100644 --- a/src/slices/token/pages/details/holders/TokenHoldersListItem.tsx +++ b/src/slices/token/pages/details/holders/TokenHoldersListItem.tsx @@ -6,7 +6,7 @@ import React from 'react'; import type { schemas } from '@blockscout/api-types'; import { hasTokenIds, isConfidentialTokenType } from 'src/slices/token/utils/token-types'; -import AddressEntity from 'src/slices/address/components/entity/AddressEntity'; +import AddressEntityWithTokenFilter from 'src/slices/address/components/entity/AddressEntityWithTokenFilter'; import ListItemMobileGrid from 'src/shared/lists/ListItemMobileGrid'; import AssetValue from 'src/shared/values/entity/AssetValue'; @@ -26,8 +26,10 @@ const TokenHoldersListItem = ({ holder, token, isLoading }: Props) => { <ListItemMobileGrid.Container> <ListItemMobileGrid.Label isLoading={ isLoading }>Address</ListItemMobileGrid.Label> <ListItemMobileGrid.Value> - <AddressEntity + <AddressEntityWithTokenFilter address={ holder.address } + tokenHash={ token.address_hash } + tokenSymbol={ token.symbol ?? undefined } isLoading={ isLoading } fontWeight="700" maxW="100%" diff --git a/src/slices/token/pages/details/holders/TokenHoldersTableItem.tsx b/src/slices/token/pages/details/holders/TokenHoldersTableItem.tsx index 15254d5d6b8..914d7fad73a 100644 --- a/src/slices/token/pages/details/holders/TokenHoldersTableItem.tsx +++ b/src/slices/token/pages/details/holders/TokenHoldersTableItem.tsx @@ -6,7 +6,7 @@ import React from 'react'; import type { schemas } from '@blockscout/api-types'; import { hasTokenIds, isConfidentialTokenType } from 'src/slices/token/utils/token-types'; -import AddressEntity from 'src/slices/address/components/entity/AddressEntity'; +import AddressEntityWithTokenFilter from 'src/slices/address/components/entity/AddressEntityWithTokenFilter'; import AssetValue from 'src/shared/values/entity/AssetValue'; import ConfidentialValue from 'src/shared/values/entity/ConfidentialValue'; @@ -25,8 +25,10 @@ const TokenTransferTableItem = ({ holder, token, isLoading }: Props) => { return ( <TableRow> <TableCell verticalAlign="middle"> - <AddressEntity + <AddressEntityWithTokenFilter address={ holder.address } + tokenHash={ token.address_hash } + tokenSymbol={ token.symbol ?? undefined } isLoading={ isLoading } flexGrow={ 1 } fontWeight="700" diff --git a/src/slices/tx/components/TxType.spec.tsx b/src/slices/tx/components/TxType.spec.tsx new file mode 100644 index 00000000000..f17169a9beb --- /dev/null +++ b/src/slices/tx/components/TxType.spec.tsx @@ -0,0 +1,25 @@ +// @vitest-environment jsdom +// SPDX-License-Identifier: LicenseRef-Blockscout + +import React from 'react'; + +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render, screen } from 'vitest/lib'; + +import TxType from './TxType'; + +describe('TxType', () => { + afterEach(cleanup); + + it('prefers a more informative type over sponsored_transaction', () => { + render(<TxType types={ [ 'sponsored_transaction', 'contract_call' ] }/>); + + expect(screen.queryByText('Contract call')).not.toBeNull(); + }); + + it('falls back to the generic label when a transaction is only sponsored', () => { + render(<TxType types={ [ 'sponsored_transaction' ] }/>); + + expect(screen.queryByText('Transaction')).not.toBeNull(); + }); +}); diff --git a/src/slices/tx/components/TxType.tsx b/src/slices/tx/components/TxType.tsx index 9a0436fd020..2ce14cc0698 100644 --- a/src/slices/tx/components/TxType.tsx +++ b/src/slices/tx/components/TxType.tsx @@ -21,6 +21,10 @@ const TYPES_ORDER: schemas['Transaction']['transaction_types'] = [ 'token_transfer', 'contract_call', 'coin_transfer', + // Listed last and deliberately given no label of its own — the details page header carries the + // "Sponsored" tag instead, and lists have no room for it. An unlisted type would score -1 here and sort + // ahead of every real one, masking labels like "Contract call". + 'sponsored_transaction', ]; const TxType = ({ types, isLoading, ...rest }: Props) => { diff --git a/src/slices/tx/mocks/details.ts b/src/slices/tx/mocks/details.ts index c81a89d57f2..af25c7cdda4 100644 --- a/src/slices/tx/mocks/details.ts +++ b/src/slices/tx/mocks/details.ts @@ -287,3 +287,18 @@ export const withRecipientContract = { ...withRecipientEns, to: addressParamMock.contract, }; + +const toPreviewAddress = (address: schemas['Address']): schemas['TransactionPreviewAddress'] => ({ + hash: address.hash, + name: address.name, + ens_domain_name: address.ens_domain_name, + metadata: address.metadata, +}); + +export const preview: schemas['TransactionPreview'] = { + status: base.status, + timestamp: base.timestamp, + method: base.method, + from: toPreviewAddress(base.from), + to: base.to ? toPreviewAddress(base.to) : null, +}; diff --git a/src/slices/tx/pages/details/Transaction.tsx b/src/slices/tx/pages/details/Transaction.tsx index bce0a3e233d..2a14a6dc8eb 100644 --- a/src/slices/tx/pages/details/Transaction.tsx +++ b/src/slices/tx/pages/details/Transaction.tsx @@ -108,6 +108,10 @@ const TransactionPageContent = () => { } } + if (data?.transaction_types?.includes('sponsored_transaction')) { + txTags.push({ slug: 'sponsored', name: 'Sponsored', tagType: 'custom' as const, ordinal: 0 }); + } + const protocolTags = data?.to?.metadata?.tags?.filter(tag => tag.tagType === 'protocol'); if (protocolTags && protocolTags.length > 0) { txTags.push(...protocolTags); diff --git a/src/slices/tx/pages/details/info/TxDetails.tsx b/src/slices/tx/pages/details/info/TxDetails.tsx index 77ff4bd0674..aa8b16882d6 100644 --- a/src/slices/tx/pages/details/info/TxDetails.tsx +++ b/src/slices/tx/pages/details/info/TxDetails.tsx @@ -24,6 +24,8 @@ import LogDecodedInputData from 'src/slices/log/components/LogDecodedInputData'; import TxSocketAlert from 'src/slices/tx/components/TxSocketAlert'; import getConfirmationDuration from 'src/slices/tx/utils/get-confirmation-duration'; +import TxDetailsEden from 'src/features/chain-variants/eden/pages/tx/TxDetailsEden'; +import { getBatchRecipients } from 'src/features/chain-variants/eden/utils/batch-recipients'; import TxAllowedPeekers from 'src/features/chain-variants/suave/pages/tx/TxAllowedPeekers'; import TxDetailsTacOperation from 'src/features/chain-variants/tac/pages/tx/TxDetailsTacOperation'; import TxDetailsCrossChainMessages from 'src/features/cross-chain-txs/pages/tx/TxDetailsCrossChainMessages'; @@ -53,12 +55,11 @@ import TextSeparator from 'src/shared/texts/TextSeparator'; import GasPriceValue from 'src/shared/values/entity/GasPriceValue'; import NativeCoinValue from 'src/shared/values/entity/NativeCoinValue'; import Utilization from 'src/shared/values/utilization/Utilization'; -import SpriteIcon from 'src/sprite/SpriteIcon'; import { Badge } from 'src/toolkit/chakra/badge'; import { CollapsibleDetails } from 'src/toolkit/chakra/collapsible'; +import { Link } from 'src/toolkit/chakra/link'; import { Skeleton } from 'src/toolkit/chakra/skeleton'; -import { Tooltip } from 'src/toolkit/chakra/tooltip'; import TxDetailsBurntFees from './parts/TxDetailsBurntFees'; import TxDetailsFeePerGas from './parts/TxDetailsFeePerGas'; @@ -67,6 +68,7 @@ import TxDetailsGasUsage from './parts/TxDetailsGasUsage'; import TxDetailsOther from './parts/TxDetailsOther'; import TxDetailsSetMaxGasLimit from './parts/TxDetailsSetMaxGasLimit'; import TxDetailsStatus from './parts/TxDetailsStatus'; +import TxDetailsTo from './parts/TxDetailsTo'; import TxDetailsTokenTransfers from './parts/TxDetailsTokenTransfers'; import TxDetailsTxFee from './parts/TxDetailsTxFee'; import TxHash from './parts/TxHash'; @@ -84,11 +86,13 @@ const rollupFeature = config.features.rollup; const TxDetails = ({ data, isLoading, socketStatus, noTxActions }: Props) => { const [ isExpanded, setIsExpanded ] = React.useState(false); + const recipients = React.useMemo(() => getBatchRecipients(data?.calls), [ data?.calls ]); + const handleCutLinkClick = React.useCallback(() => { setIsExpanded((flag) => !flag); }, []); - const showAssociatedL1Tx = React.useCallback(() => { + const expandDetailsSection = React.useCallback(() => { setIsExpanded(true); }, []); @@ -102,29 +106,6 @@ const TxDetails = ({ data, isLoading, socketStatus, noTxActions }: Props) => { ...data.from.watchlist_names || [], ].map((tag) => <Badge key={ tag.label }>{ tag.display_name }</Badge>); - const toAddress = data.to ? data.to : data.created_contract; - const addressToTags = [ - ...toAddress?.private_tags || [], - ...toAddress?.public_tags || [], - ...toAddress?.watchlist_names || [], - ].map((tag) => <Badge key={ tag.label }>{ tag.display_name }</Badge>); - - const executionSuccessBadge = toAddress?.is_contract && data.result === 'success' ? ( - <Tooltip content="Contract execution completed"> - <chakra.span display="inline-flex" ml={ 2 } mr={ 1 }> - <SpriteIcon name="status/success" boxSize={ 4 } color={{ _light: 'blackAlpha.800', _dark: 'whiteAlpha.800' }} cursor="pointer"/> - </chakra.span> - </Tooltip> - ) : null; - - const executionFailedBadge = toAddress?.is_contract && Boolean(data.status) && data.result !== 'success' ? ( - <Tooltip content="Error occurred during contract execution"> - <chakra.span display="inline-flex" ml={ 2 } mr={ 1 }> - <SpriteIcon name="status/error" boxSize={ 4 } color="text.error" cursor="pointer"/> - </chakra.span> - </Tooltip> - ) : null; - const hasInterop = rollupFeature.isEnabled && rollupFeature.interopEnabled && data.op_interop_messages && data.op_interop_messages.length > 0; return ( @@ -154,7 +135,7 @@ const TxDetails = ({ data, isLoading, socketStatus, noTxActions }: Props) => { <TxHash hash={ data.hash } isLoading={ isLoading } status={ data.status }/> - <TxDetailsStatus data={ data } isLoading={ isLoading } onShowDetailsClick={ showAssociatedL1Tx }/> + <TxDetailsStatus data={ data } isLoading={ isLoading } onShowDetailsClick={ expandDetailsSection }/> { rollupFeature.isEnabled && rollupFeature.type === 'optimistic' && data.op_withdrawals && data.op_withdrawals.length > 0 && !config.slices.tx.hiddenFields?.L1_status && ( @@ -351,50 +332,12 @@ const TxDetails = ({ data, isLoading, socketStatus, noTxActions }: Props) => { ) } </DetailedInfo.ItemValue> - <DetailedInfo.ItemLabel - hint="Address (external or contract) receiving the transaction" + <TxDetailsTo + data={ data } isLoading={ isLoading } - > - { data.to?.is_contract ? 'Interacted with contract' : 'To' } - </DetailedInfo.ItemLabel> - <DetailedInfo.ItemValue - flexWrap={{ base: 'wrap', lg: 'nowrap' }} - columnGap={ 3 } - > - { toAddress ? ( - <> - { data.to && data.to.hash ? ( - <Flex flexWrap="nowrap" alignItems="center" maxW="100%"> - <AddressEntity - address={ toAddress } - isLoading={ isLoading } - /> - { executionSuccessBadge } - { executionFailedBadge } - </Flex> - ) : ( - <Flex width="100%" whiteSpace="pre" alignItems="center" flexShrink={ 0 }> - <span>[Contract </span> - <AddressEntity - address={ toAddress } - isLoading={ isLoading } - noIcon - /> - <span>created]</span> - { executionSuccessBadge } - { executionFailedBadge } - </Flex> - ) } - { addressToTags.length > 0 && ( - <Flex columnGap={ 3 }> - { addressToTags } - </Flex> - ) } - </> - ) : ( - <span>[ Contract creation ]</span> - ) } - </DetailedInfo.ItemValue> + recipients={ recipients } + onViewDetailClick={ expandDetailsSection } + /> { data.token_transfers && ( <TxDetailsTokenTransfers @@ -488,6 +431,14 @@ const TxDetails = ({ data, isLoading, socketStatus, noTxActions }: Props) => { historicalExchangeRate={ data.historic_exchange_rate } hasExchangeRateToggle loading={ isLoading } + endContent={ recipients.hasMultipleRecipients ? ( + <Flex alignItems="center" whiteSpace="pre"> + <Text color="text.secondary">to </Text> + <Link variant="primary" onClick={ expandDetailsSection }> + { `${ recipients.count } recipients` } + </Link> + </Flex> + ) : undefined } /> </> ) } @@ -807,6 +758,8 @@ const TxDetails = ({ data, isLoading, socketStatus, noTxActions }: Props) => { <TxDetailsOther nonce={ data.nonce } type={ data.type } position={ data.position } queueIndex={ data.scroll?.queue_index }/> + <TxDetailsEden data={ data } isLoading={ isLoading }/> + <DetailedInfo.ItemLabel hint="Binary data included with the transaction. See logs tab for additional info" mb={{ base: 1, lg: 0 }} diff --git a/src/slices/tx/pages/details/info/__screenshots__/TxDetails.pw.tsx_default_with-token-transfer-mobile-1.png b/src/slices/tx/pages/details/info/__screenshots__/TxDetails.pw.tsx_default_with-token-transfer-mobile-1.png index a3c8411dfa0..a146b35af15 100644 Binary files a/src/slices/tx/pages/details/info/__screenshots__/TxDetails.pw.tsx_default_with-token-transfer-mobile-1.png and b/src/slices/tx/pages/details/info/__screenshots__/TxDetails.pw.tsx_default_with-token-transfer-mobile-1.png differ diff --git a/src/slices/tx/pages/details/info/__screenshots__/TxDetails.pw.tsx_mobile_with-token-transfer-mobile-1.png b/src/slices/tx/pages/details/info/__screenshots__/TxDetails.pw.tsx_mobile_with-token-transfer-mobile-1.png index 58e0bdeb670..ba19da94ab0 100644 Binary files a/src/slices/tx/pages/details/info/__screenshots__/TxDetails.pw.tsx_mobile_with-token-transfer-mobile-1.png and b/src/slices/tx/pages/details/info/__screenshots__/TxDetails.pw.tsx_mobile_with-token-transfer-mobile-1.png differ diff --git a/src/slices/tx/pages/details/info/parts/TxDetailsTo.tsx b/src/slices/tx/pages/details/info/parts/TxDetailsTo.tsx new file mode 100644 index 00000000000..c084f16f850 --- /dev/null +++ b/src/slices/tx/pages/details/info/parts/TxDetailsTo.tsx @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import { Flex, chakra } from '@chakra-ui/react'; +import React from 'react'; + +import type { schemas } from '@blockscout/api-types'; + +import AddressEntity from 'src/slices/address/components/entity/AddressEntity'; + +import type { BatchRecipients } from 'src/features/chain-variants/eden/utils/batch-recipients'; + +import * as DetailedInfo from 'src/shared/detailed-info/DetailedInfo'; +import SpriteIcon from 'src/sprite/SpriteIcon'; + +import { Badge } from 'src/toolkit/chakra/badge'; +import { Link } from 'src/toolkit/chakra/link'; +import { Tooltip } from 'src/toolkit/chakra/tooltip'; + +interface Props { + data: schemas['TransactionResponse']; + isLoading?: boolean; + recipients: BatchRecipients; + onViewDetailClick: () => void; +} + +const TxDetailsTo = ({ data, isLoading, recipients, onViewDetailClick }: Props) => { + const toAddress = data.to ? data.to : data.created_contract; + const addressToTags = [ + ...toAddress?.private_tags || [], + ...toAddress?.public_tags || [], + ...toAddress?.watchlist_names || [], + ].map((tag) => <Badge key={ tag.label }>{ tag.display_name }</Badge>); + + const executionSuccessBadge = toAddress?.is_contract && data.result === 'success' ? ( + <Tooltip content="Contract execution completed"> + <chakra.span display="inline-flex" ml={ 2 } mr={ 1 }> + <SpriteIcon name="status/success" boxSize={ 4 } color={{ _light: 'blackAlpha.800', _dark: 'whiteAlpha.800' }} cursor="pointer"/> + </chakra.span> + </Tooltip> + ) : null; + + const executionFailedBadge = toAddress?.is_contract && Boolean(data.status) && data.result !== 'success' ? ( + <Tooltip content="Error occurred during contract execution"> + <chakra.span display="inline-flex" ml={ 2 } mr={ 1 }> + <SpriteIcon name="status/error" boxSize={ 4 } color="text.error" cursor="pointer"/> + </chakra.span> + </Tooltip> + ) : null; + + const toFieldContent = toAddress ? ( + <> + { data.to && data.to.hash ? ( + <Flex flexWrap="nowrap" alignItems="center" maxW="100%"> + <AddressEntity + address={ toAddress } + isLoading={ isLoading } + /> + { executionSuccessBadge } + { executionFailedBadge } + </Flex> + ) : ( + <Flex width="100%" whiteSpace="pre" alignItems="center" flexShrink={ 0 }> + <span>[Contract </span> + <AddressEntity + address={ toAddress } + isLoading={ isLoading } + noIcon + /> + <span>created]</span> + { executionSuccessBadge } + { executionFailedBadge } + </Flex> + ) } + { addressToTags.length > 0 && ( + <Flex columnGap={ 3 }> + { addressToTags } + </Flex> + ) } + </> + ) : ( + <span>[ Contract creation ]</span> + ); + + return ( + <> + <DetailedInfo.ItemLabel + hint="Address (external or contract) receiving the transaction" + isLoading={ isLoading } + > + { data.to?.is_contract ? 'Interacted with contract' : 'To' } + </DetailedInfo.ItemLabel> + { recipients.hasMultipleRecipients ? ( + <DetailedInfo.ItemValue flexDir="column" alignItems="flex-start" rowGap={ 1 }> + { recipients.visibleRecipients.map((call, index) => ( + <Flex + key={ index } + flexWrap={{ base: 'wrap', lg: 'nowrap' }} + alignItems="center" + columnGap={ 3 } + maxW="100%" + minH={ DetailedInfo.ITEM_VALUE_LINE_HEIGHT } + > + { call.to === data.to?.hash ? + toFieldContent : + <AddressEntity address={{ hash: call.to }} isLoading={ isLoading }/> } + </Flex> + )) } + { recipients.hasOverflow && ( + <Link variant="secondary" textStyle="sm" onClick={ onViewDetailClick } mt={ 1.5 }> + { `View all (${ recipients.count })` } + </Link> + ) } + </DetailedInfo.ItemValue> + ) : ( + <DetailedInfo.ItemValue + flexWrap={{ base: 'wrap', lg: 'nowrap' }} + columnGap={ 3 } + > + { toFieldContent } + </DetailedInfo.ItemValue> + ) } + </> + ); +}; + +export default React.memo(TxDetailsTo); diff --git a/src/slices/tx/pages/details/info/parts/TxDetailsTokenTransfers.tsx b/src/slices/tx/pages/details/info/parts/TxDetailsTokenTransfers.tsx index bcd1a52a7fd..0ad19d6e6f8 100644 --- a/src/slices/tx/pages/details/info/parts/TxDetailsTokenTransfers.tsx +++ b/src/slices/tx/pages/details/info/parts/TxDetailsTokenTransfers.tsx @@ -11,7 +11,6 @@ import { useMultichainContext } from 'src/features/multichain/context'; import * as DetailedInfo from 'src/shared/detailed-info/DetailedInfo'; import { route } from 'src/shared/router/routes'; -import SpriteIcon from 'src/sprite/SpriteIcon'; import { Link } from 'src/toolkit/chakra/link'; @@ -64,13 +63,9 @@ const TxDetailsTokenTransfers = ({ data, txHash, isOverflow }: Props) => { { items.map((item, index) => <TokenTransferSnippet key={ index } data={ item }/>) } </Flex> { isOverflow && ( - <> - { /* FIXME use non-navigation icon */ } - <SpriteIcon name="navigation/tokens" boxSize={ 6 }/> - <Link href={ viewAllUrl }> - View all - </Link> - </> + <Link href={ viewAllUrl } textStyle="sm" variant="secondary" mt={ 1.5 }> + View all + </Link> ) } </DetailedInfo.ItemValue> </React.Fragment> diff --git a/src/slices/tx/types/api.ts b/src/slices/tx/types/api.ts index f620b30187f..4c7f96f08c0 100644 --- a/src/slices/tx/types/api.ts +++ b/src/slices/tx/types/api.ts @@ -19,3 +19,9 @@ export interface TxsFilters { filter?: TxsStatusFilter; type?: TxsTypeFilter; }; + +export interface TxOgDescriptionParams { + tx_status: string; + tx_action: string; + tx_timestamp: string; +} 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 00000000000..f8df52acff7 --- /dev/null +++ b/src/slices/tx/utils/get-og-description-params.spec.ts @@ -0,0 +1,83 @@ +import type { schemas } from '@blockscout/api-types'; +import type { TxInterpretationResponse } from 'src/features/tx-interpretation/common/types/api'; + +import { preview } 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['TransactionPreview'] | 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(preview, 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({ ...preview, status }, TX_INTERPRETATION); + expect(result?.tx_status).toBe(expected); + }); + + it('gives up when the field never arrived', async() => { + const { status, ...txWithoutStatus } = preview; + expect(await getParamsWithInterpretation(txWithoutStatus as schemas['TransactionPreview'], 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({ ...preview, status: null, timestamp: null }, TX_INTERPRETATION)).toBeNull(); + }); + + it('no usable summary and no method to fall back on', async() => { + expect(await getParamsWithInterpretation({ ...preview, method: null })).toBeNull(); + }); + + it('the interpretation feature is off', async() => { + const { 'default': getOgDescriptionParams } = await import('./get-og-description-params'); + expect(getOgDescriptionParams(preview, 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(preview, 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(preview); + 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({ ...preview, 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 00000000000..a6c8c1f1856 --- /dev/null +++ b/src/slices/tx/utils/get-og-description-params.ts @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import type { schemas } from '@blockscout/api-types'; +import type { TxInterpretationResponse } from 'src/features/tx-interpretation/common/types/api'; +import type { TxOgDescriptionParams } from 'src/slices/tx/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'; + +// `undefined` — as opposed to `null`, which is a pending transaction — means there is no transaction to +// describe: `fetchApi` returns nothing when the request 404s, fails, or runs out of its budget. Collapsing +// the two would make every such miss read as `Pending`. +function getStatusText(status: schemas['TransactionPreview']['status'] | undefined) { + if (status === undefined) { + return; + } + + switch (status) { + case 'ok': + return 'Success'; + case 'error': + return 'Failed'; + case null: + return 'Pending'; + } +} + +function getActionText(tx: schemas['TransactionPreview'] | 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['TransactionPreview'] | 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, + }; +} diff --git a/src/sprite/icons/RPC.svg b/src/sprite/icons/RPC.svg deleted file mode 100644 index b153ae2bcdd..00000000000 --- a/src/sprite/icons/RPC.svg +++ /dev/null @@ -1,3 +0,0 @@ -<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M15 13a1.93 1.93 0 0 0-1 .285l-1.545-1.55a3 3 0 0 0 0-3.47L14 6.715A1.93 1.93 0 0 0 15 7a2 2 0 1 0-2-2c.002.353.1.699.285 1l-1.55 1.545a3 3 0 0 0-3.47 0L6.715 6A1.93 1.93 0 0 0 7 5a2 2 0 1 0-2 2 1.93 1.93 0 0 0 1-.285l1.545 1.55a3 3 0 0 0 0 3.47L6 13.285A1.93 1.93 0 0 0 5 13a2 2 0 1 0 2 2 1.93 1.93 0 0 0-.285-1l1.55-1.545a3 3 0 0 0 3.47 0L13.285 14A1.93 1.93 0 0 0 13 15a2 2 0 1 0 2-2Zm0-9a1 1 0 1 1 0 2 1 1 0 0 1 0-2ZM4 5a1 1 0 1 1 2 0 1 1 0 0 1-2 0Zm1 11a1 1 0 1 1 0-2.001A1 1 0 0 1 5 16Zm5-4a2 2 0 1 1 0-4 2 2 0 0 1 0 4Zm5 4a1 1 0 1 1 0-2.002A1 1 0 0 1 15 16Z" fill="currentColor" stroke="currentColor" stroke-width=".636"/> -</svg> diff --git a/src/sprite/icons/calendar.svg b/src/sprite/icons/calendar.svg new file mode 100644 index 00000000000..9cb1b258142 --- /dev/null +++ b/src/sprite/icons/calendar.svg @@ -0,0 +1,3 @@ +<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M17 3h4a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h4V1h2v2h6V1h2v2Zm-2 2H9v2H7V5H4v4h16V5h-3v2h-2V5Zm5 6H4v8h16v-8Z" fill="currentColor"/> +</svg> diff --git a/src/toolkit/chakra/box.tsx b/src/toolkit/chakra/box.tsx new file mode 100644 index 00000000000..e9fa872a2fa --- /dev/null +++ b/src/toolkit/chakra/box.tsx @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import type { BoxProps } from '@chakra-ui/react'; +import { Box } from '@chakra-ui/react'; + +export const BoxHtml = ({ html, ...props }: { html: string } & BoxProps) => { + return ( + <Box + dangerouslySetInnerHTML={{ __html: html }} + css={{ + '& a': { + color: 'link.primary', + _hover: { + color: 'link.primary.hover', + }, + }, + }} + { ...props } + /> + ); +}; diff --git a/src/toolkit/chakra/close-button.tsx b/src/toolkit/chakra/close-button.tsx index c3613cfa16f..dde28723f4a 100644 --- a/src/toolkit/chakra/close-button.tsx +++ b/src/toolkit/chakra/close-button.tsx @@ -1,6 +1,6 @@ // SPDX-License-Identifier: LicenseRef-Blockscout -import type { ButtonProps } from '@chakra-ui/react'; +import type { ButtonProps, JsxStyleProps } from '@chakra-ui/react'; import { Icon, useRecipe } from '@chakra-ui/react'; import * as React from 'react'; @@ -11,6 +11,7 @@ import { IconButton } from './icon-button'; export interface CloseButtonProps extends Omit<ButtonProps, 'variant' | 'size'> { variant?: 'plain'; size?: 'md'; + iconProps?: JsxStyleProps; } export const CloseButton = React.forwardRef< @@ -20,10 +21,11 @@ export const CloseButton = React.forwardRef< const recipe = useRecipe({ recipe: closeButtonRecipe }); const [ recipeProps, restProps ] = recipe.splitVariantProps(props); const styles = recipe(recipeProps); + const { iconProps, ...rest } = restProps; return ( - <IconButton aria-label="Close" ref={ ref } css={ styles } { ...restProps }> - { props.children ?? <Icon boxSize={ 5 }><CloseIcon/></Icon> } + <IconButton aria-label="Close" ref={ ref } css={ styles } { ...rest }> + { props.children ?? <Icon boxSize={ 5 } { ...iconProps }><CloseIcon/></Icon> } </IconButton> ); }); diff --git a/src/toolkit/chakra/date-picker.tsx b/src/toolkit/chakra/date-picker.tsx new file mode 100644 index 00000000000..3f8d5da6795 --- /dev/null +++ b/src/toolkit/chakra/date-picker.tsx @@ -0,0 +1,305 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import type { DateValue } from '@chakra-ui/react'; +import { DatePicker as ChakraDatePicker, HStack, Icon, Portal, useControllableState } from '@chakra-ui/react'; +import { CalendarDate, CalendarDateTime, getLocalTimeZone, isSameDay, now, toCalendarDate, toCalendarDateTime, today } from '@internationalized/date'; +import dayjs from 'dayjs'; +import { padStart } from 'es-toolkit/compat'; +import React from 'react'; + +import ArrowIcon from 'src/sprite/icons/arrows/east-mini.svg'; +import CalendarIcon from 'src/sprite/icons/calendar.svg'; + +import { CloseButton } from './close-button'; +import { Field } from './field'; +import { InputGroup } from './input-group'; +import { TimePicker } from './time-picker'; + +export interface DatePickerValueChangeDetails { + value: Array<DateValue>; + valueAsString: Array<string>; + view: 'day' | 'month' | 'year'; +} + +const DATE_FORMAT = 'MMM D, YYYY'; +export const DATE_PICKER_DATE_TIME_FORMAT = 'MMM D, YYYY H:mm'; + +// a ZonedDateTime stringifies with an IANA suffix ("...+02:00[Europe/Madrid]") that dayjs cannot parse, +// so every value is narrowed to a plain calendar date-time before formatting +const toDayjs = (date: DateValue) => dayjs(toCalendarDateTime(date).toString()); + +const format = (date: DateValue) => { + return toDayjs(date).format(DATE_FORMAT); +}; + +const formatWithTime = (date: DateValue) => { + return toDayjs(date).format(DATE_PICKER_DATE_TIME_FORMAT); +}; + +const parse = (value: string): DateValue | undefined => { + const parsed = dayjs(value); + if (!parsed.isValid()) return; + + return new CalendarDate(parsed.year(), parsed.month() + 1, parsed.date()); +}; + +const parseWithTime = (value: string): DateValue | undefined => { + const parsed = dayjs(value); + if (!parsed.isValid()) return; + + return new CalendarDateTime( + parsed.year(), + parsed.month() + 1, + parsed.date(), + parsed.hour(), + parsed.minute(), + ); +}; + +const getTimeParts = (date: DateValue | undefined) => { + if (!date || !('hour' in date)) return { hour: undefined, minute: undefined }; + return { hour: date.hour, minute: date.minute }; +}; + +const isToday = (date: DateValue): boolean => { + return isSameDay(date, today(getLocalTimeZone())); +}; + +const getTime = (date: DateValue): string => { + return toDayjs(date).format('H:mm'); +}; + +// a day can be selectable while a specific time on it is not, so the carried-over +// time is pulled back inside the limits instead of producing an out-of-range value +const clampToLimits = (date: CalendarDateTime, min?: DateValue, max?: DateValue): CalendarDateTime => { + if (min && date.compare(min) < 0) { + return toCalendarDateTime(min); + } + if (max && date.compare(max) > 0) { + return toCalendarDateTime(max); + } + return date; +}; + +const getDefaultDateValue = (withCurrentTime?: boolean): CalendarDateTime => { + const current = now(getLocalTimeZone()); + return new CalendarDateTime( + current.year, + current.month, + current.day, + withCurrentTime ? current.hour : 0, + withCurrentTime ? current.minute : 0, + ); +}; + +export interface DatePickerProps extends ChakraDatePicker.RootProps { + withTime?: boolean; + errorText?: string; + timeZoneSuffix?: string; +} + +export const DatePicker = React.forwardRef<HTMLInputElement, DatePickerProps>( + function DatePicker({ + placeholder, + withTime, + timeZoneSuffix, + value: valueProp, + defaultValue, + onValueChange: onValueChangeProp, + min, + max, + disabled, + readOnly, + invalid, + errorText, + required, + bgColor, + ...rest + }, ref) { + + const formatValue = React.useCallback((date: DateValue) => { + const base = withTime ? formatWithTime(date) : format(date); + return withTime && timeZoneSuffix ? `${ base } ${ timeZoneSuffix }` : base; + }, [ withTime, timeZoneSuffix ]); + + const parseValue = React.useCallback((value: string) => { + // the suffix is display-only, so it is stripped before the date itself is parsed back + const cleaned = timeZoneSuffix && value.endsWith(timeZoneSuffix) ? + value.slice(0, -timeZoneSuffix.length).trimEnd() : + value; + return withTime ? parseWithTime(cleaned) : parse(cleaned); + }, [ withTime, timeZoneSuffix ]); + + const onValueChange = React.useCallback((value: Array<DateValue> | undefined) => { + onValueChangeProp?.({ value: value ?? [], valueAsString: value?.map(formatValue) ?? [], view: 'day' }); + }, [ onValueChangeProp, formatValue ]); + + const [ value, setValue ] = useControllableState<Array<DateValue> | undefined>({ + value: valueProp, + defaultValue: defaultValue, + onChange: onValueChange, + }); + + const handleDateChange = React.useCallback((details: DatePickerValueChangeDetails) => { + const newDate = details.value[0]; + if (!newDate) return setValue([]); + + // without a time picker there is no time to carry over, so the value stays a plain calendar date + if (!withTime) { + return setValue([ new CalendarDate(newDate.year, newDate.month, newDate.day) ]); + } + + setValue((prev) => { + const current = prev?.[0] ?? getDefaultDateValue(isToday(newDate)); + const fromNew = getTimeParts(newDate); + const fromCurrent = getTimeParts(current); + const hour = fromNew.hour ?? fromCurrent.hour; + const minute = fromNew.minute ?? fromCurrent.minute; + + return [ clampToLimits(new CalendarDateTime(newDate.year, newDate.month, newDate.day, hour, minute), min, max) ]; + }); + }, [ setValue, withTime, min, max ]); + + const handleTimeChange = React.useCallback((time: string | undefined) => { + setValue((prev) => { + const current = prev?.[0] ?? getDefaultDateValue(); + + // clearing the time keeps the selected day but drops its time component + if (time === undefined) { + return [ toCalendarDate(current) ]; + } + + const [ hours, minutes ] = time.split(':'); + // a date-only value would silently ignore the time fields, so it is widened first + return [ toCalendarDateTime(current).set({ hour: Number(hours), minute: Number(minutes) }) ]; + }); + }, [ setValue ]); + + const positioning = { + placement: 'bottom-start' as const, + overflowPadding: 4, + sameWidth: true, + ...rest.positioning, + offset: { + mainAxis: 4, + ...rest.positioning?.offset, + }, + }; + + return ( + <ChakraDatePicker.Root + ref={ ref } + openOnClick + closeOnSelect={ !withTime } + lazyMount + unmountOnExit + format={ formatValue } + parse={ parseValue } + value={ value } + onValueChange={ handleDateChange } + min={ min } + max={ max } + disabled={ disabled } + readOnly={ readOnly } + invalid={ invalid } + required={ required } + { ...rest } + positioning={ positioning } + > + <ChakraDatePicker.Control> + <ChakraDatePicker.Context> + { (context) => { + const isFilled = context.value.length > 0; + + const endElement = ( + <HStack> + { isFilled && !readOnly && ( + <ChakraDatePicker.ClearTrigger asChild disabled={ disabled }> + <CloseButton/> + </ChakraDatePicker.ClearTrigger> + ) } + <ChakraDatePicker.Trigger + disabled={ disabled } + { ...(readOnly ? { 'data-readOnly': true } : {}) } + > + <Icon boxSize={ 6 }><CalendarIcon/></Icon> + </ChakraDatePicker.Trigger> + </HStack> + ); + + return ( + <Field + label={ placeholder ?? 'Date' } + floating + size="lg" + readOnly={ readOnly } + invalid={ invalid } + errorText={ errorText } + required={ required } + focusVisible={ context.open } + bgColor={ bgColor } + > + <InputGroup endElement={ endElement } endElementProps={{ pl: 2, pr: 4 }}> + <ChakraDatePicker.Input/> + </InputGroup> + </Field> + ); + } } + </ChakraDatePicker.Context> + </ChakraDatePicker.Control> + <Portal> + <ChakraDatePicker.Positioner> + <ChakraDatePicker.Content> + { [ 'day' as const, 'month' as const, 'year' as const ].map((view) => ( + <ChakraDatePicker.View key={ view } view={ view }> + <ChakraDatePicker.ViewControl> + <ChakraDatePicker.PrevTrigger> + <Icon boxSize={ 6 }><ArrowIcon/></Icon> + </ChakraDatePicker.PrevTrigger> + <ChakraDatePicker.ViewTrigger> + <ChakraDatePicker.RangeText/> + </ChakraDatePicker.ViewTrigger> + <ChakraDatePicker.NextTrigger> + <Icon boxSize={ 6 } transform="rotate(180deg)"><ArrowIcon/></Icon> + </ChakraDatePicker.NextTrigger> + </ChakraDatePicker.ViewControl> + { view === 'day' && ( + <> + <ChakraDatePicker.DayTable/> + { withTime && (() => { + + const minTime = min && 'hour' in min ? + `${ padStart(min.hour.toString(), 2, '0') }:${ padStart(min.minute.toString(), 2, '0') }` : + undefined; + + const maxTime = max && 'hour' in max ? + `${ padStart(max.hour.toString(), 2, '0') }:${ padStart(max.minute.toString(), 2, '0') }` : + undefined; + + const currentDate = value?.[0]; + const isMinDay = currentDate && min && currentDate.year === min.year && currentDate.month === min.month && currentDate.day === min.day; + const isMaxDay = currentDate && max && currentDate.year === max.year && currentDate.month === max.month && currentDate.day === max.day; + + return ( + <TimePicker + value={ currentDate && 'hour' in currentDate ? getTime(currentDate) : undefined } + min={ isMinDay ? minTime : undefined } + max={ isMaxDay ? maxTime : undefined } + onValueChange={ handleTimeChange } + disabled={ !currentDate } + inputProps={{ bgColor: 'dialog.bg' }} + /> + ); + })() } + </> + ) } + { view === 'month' && <ChakraDatePicker.MonthTable/> } + { view === 'year' && <ChakraDatePicker.YearTable/> } + </ChakraDatePicker.View> + )) } + </ChakraDatePicker.Content> + </ChakraDatePicker.Positioner> + </Portal> + </ChakraDatePicker.Root> + ); + }); diff --git a/src/toolkit/chakra/field.tsx b/src/toolkit/chakra/field.tsx index 5264105c7a4..0d4bcd49244 100644 --- a/src/toolkit/chakra/field.tsx +++ b/src/toolkit/chakra/field.tsx @@ -15,11 +15,12 @@ export interface FieldProps extends Omit<ChakraField.RootProps, 'label' | 'child optionalText?: React.ReactNode; children: React.ReactElement<InputProps> | React.ReactElement<InputGroupProps>; size?: 'sm' | 'md' | 'lg' | '2xl'; + focusVisible?: boolean; } export const Field = React.forwardRef<HTMLDivElement, FieldProps>( function Field(props, ref) { - const { label, children, helperText, errorText, optionalText, ...rest } = props; + const { label, children, helperText, errorText, optionalText, focusVisible, ...rest } = props; // A floating field cannot be without a label. if (rest.floating && label) { @@ -31,6 +32,7 @@ export const Field = React.forwardRef<HTMLDivElement, FieldProps>( bgColor: rest.bgColor, disabled: rest.disabled, readOnly: rest.readOnly, + ...(focusVisible ? { 'data-focus-visible': true } : {}), }; const labelElement = ( diff --git a/src/toolkit/chakra/time-picker.tsx b/src/toolkit/chakra/time-picker.tsx new file mode 100644 index 00000000000..533313f2f52 --- /dev/null +++ b/src/toolkit/chakra/time-picker.tsx @@ -0,0 +1,386 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import { HStack, Icon, VStack, useControllableState } from '@chakra-ui/react'; +import { clamp, delay, range } from 'es-toolkit'; +import { padStart } from 'es-toolkit/compat'; +import React from 'react'; + +import ClockIcon from 'src/sprite/icons/clock-light.svg'; + +import { useDisclosure } from '../hooks/useDisclosure'; +import type { ButtonProps } from './button'; +import { Button } from './button'; +import { CloseButton } from './close-button'; +import type { FieldProps } from './field'; +import { Field } from './field'; +import type { InputProps } from './input'; +import { Input } from './input'; +import { InputGroup } from './input-group'; +import { PopoverBody, PopoverContent, PopoverRoot, PopoverTrigger } from './popover'; + +const BUTTON_HEIGHT = 32; +const GAP_HEIGHT = 8; + +const getLimits = (min?: string, max?: string) => { + if (!min && !max) { + return; + } + + const [ minHour, minMinute ] = min?.split(':').map(Number) ?? [ 0, 0 ]; + const [ maxHour, maxMinute ] = max?.split(':').map(Number) ?? [ 23, 59 ]; + + return { + min: { + hours: minHour, + minutes: minMinute, + }, + max: { + hours: maxHour, + minutes: maxMinute, + }, + }; +}; + +interface IsInLimitsParams { + value: number; + type: 'hours' | 'minutes'; + timeValue: { + hours: number | undefined; + minutes: number | undefined; + }; + limits?: Record<'min' | 'max', { hours: number; minutes: number }>; +} + +const isInLimits = ({ value, type, timeValue, limits }: IsInLimitsParams) => { + if (!limits) { + return true; + } + + if (type === 'hours') { + return value >= limits.min.hours && value <= limits.max.hours; + } + + if (timeValue.hours !== undefined) { + if (timeValue.hours === limits.min.hours) { + return value >= limits.min.minutes; + } + + if (timeValue.hours === limits.max.hours) { + return value <= limits.max.minutes; + } + } + + return true; +}; + +const formatValue = (hours: number, minutes: number) => { + return `${ padStart(hours.toString(), 2, '0') }:${ padStart(minutes.toString(), 2, '0') }`; +}; + +const getDefaultValue = ({ limits, type, timeValue }: Omit<IsInLimitsParams, 'value'>) => { + if (!limits) { + return 0; + } + + if (type === 'hours') { + if (timeValue.minutes !== undefined) { + if (timeValue.minutes < limits.min.minutes) { + return clamp(limits.min.hours + 1, limits.min.hours, limits.max.hours); + } + } + return limits.min.hours; + } + + if (timeValue.hours !== undefined) { + if (timeValue.hours === limits.min.hours) { + return limits.min.minutes; + } + } + + return 0; +}; + +interface TimePickerItemButtonProps extends ButtonProps { + value: number; +} + +const TimePickerItemButton = React.forwardRef<HTMLButtonElement, TimePickerItemButtonProps>(({ value, ...props }, ref) => { + return ( + <Button + ref={ ref } + size="sm" + variant="plain" + scrollSnapAlign="start" + data-value={ value } + px={ 1 } + minH={ `${ BUTTON_HEIGHT }px` } + borderWidth="0" + fontWeight={ 400 } + _disabled={{ opacity: 'control.disabled' }} + _hover={{ color: 'hover' }} + _selected={{ + bgColor: 'selected.option.bg', + color: 'whiteAlpha.900', + _hover: { + bgColor: 'selected.option.bg', + color: 'whiteAlpha.900', + }, + }} + { ...props } + > + { padStart(value.toString(), 2, '0') } + </Button> + ); +}); + +export interface TimePickerProps extends Omit<FieldProps, 'children'> { + value?: string; + defaultValue?: string; + onValueChange?: (value: string | undefined) => void; + inputProps?: InputProps; + min?: string; + max?: string; +} + +export const TimePicker = ({ + value, + defaultValue, + onValueChange, + min, + max, + disabled, + readOnly, + inputProps, + ...rest +}: TimePickerProps) => { + + const hoursContainerRef = React.useRef<HTMLDivElement>(null); + const minutesContainerRef = React.useRef<HTMLDivElement>(null); + + const { open, onOpenChange } = useDisclosure(); + const limits = React.useMemo(() => getLimits(min, max), [ min, max ]); + + const onHoursChange = React.useCallback((hours: number | undefined) => { + const [ , minutes ] = value?.split(':') ?? []; + onValueChange?.(hours !== undefined ? formatValue(hours, Number(minutes ?? 0)) : undefined); + }, [ value, onValueChange ]); + + const [ hours, setHours ] = useControllableState<number | undefined>({ + value: value?.split(':')[0] ? Number(value.split(':')[0]) : undefined, + defaultValue: defaultValue?.split(':')[0] ? Number(defaultValue.split(':')[0]) : undefined, + onChange: onHoursChange, + }); + + const onMinutesChange = React.useCallback((minutes: number | undefined) => { + const [ hours ] = value?.split(':') ?? []; + onValueChange?.(minutes !== undefined ? formatValue(Number(hours ?? 0), minutes) : undefined); + }, [ value, onValueChange ]); + + const [ minutes, setMinutes ] = useControllableState<number | undefined>({ + value: value?.split(':')[1] ? Number(value.split(':')[1]) : undefined, + defaultValue: defaultValue?.split(':')[1] ? Number(defaultValue.split(':')[1]) : undefined, + onChange: onMinutesChange, + }); + + const scrollToItem = React.useCallback((hours: number | undefined, minutes: number | undefined, behavior: ScrollBehavior = 'instant') => { + hours !== undefined && hoursContainerRef.current?.scrollTo({ + top: hours * (BUTTON_HEIGHT + GAP_HEIGHT), + behavior, + }); + minutes !== undefined && minutesContainerRef.current?.scrollTo({ + top: minutes * (BUTTON_HEIGHT + GAP_HEIGHT), + behavior, + }); + }, []); + + const handleHoursClick = React.useCallback(async(event: React.MouseEvent<HTMLButtonElement>) => { + const button = event.currentTarget as HTMLButtonElement; + const newValue = Number(button.dataset.value); + if (Number.isNaN(newValue)) { + return; + } + setHours(newValue); + + const defaultValueMinutes = getDefaultValue({ limits, type: 'minutes', timeValue: { hours: newValue, minutes } }); + + if (minutes === undefined) { + scrollToItem(undefined, defaultValueMinutes, 'smooth'); + return; + } + + if (!isInLimits({ + value: minutes, + type: 'minutes', + timeValue: { hours: newValue, minutes }, limits, + })) { + // FIXME: subsequent set state will override the previous one where we set hours + await delay(0); + setMinutes(defaultValueMinutes); + scrollToItem(undefined, defaultValueMinutes, 'smooth'); + } + }, [ limits, minutes, setHours, setMinutes, scrollToItem ]); + + const handleMinutesClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => { + const button = event.currentTarget as HTMLButtonElement; + const newValue = Number(button.dataset.value); + if (Number.isNaN(newValue)) { + return; + } + setMinutes(newValue); + setHours((prev) => { + if (prev === undefined || !isInLimits({ value: prev, type: 'hours', timeValue: { hours: prev, minutes: newValue }, limits })) { + const defaultValue = getDefaultValue({ limits, type: 'hours', timeValue: { hours: prev ?? 0, minutes: newValue } }); + scrollToItem(defaultValue, undefined, 'smooth'); + return defaultValue; + } + return prev; + }); + }, [ limits, scrollToItem, setHours, setMinutes ]); + + const handleClear = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => { + event.stopPropagation(); + setHours(undefined); + setMinutes(undefined); + scrollToItem(0, 0); + }, [ setHours, setMinutes, scrollToItem ]); + + const timeValue = React.useMemo(() => ({ hours, minutes }), [ hours, minutes ]); + + React.useEffect(() => { + if (!open) { + return; + } + + const id = window.requestAnimationFrame(() => { + scrollToItem(hours ?? 0, minutes ?? 0); + }); + + return () => window.cancelAnimationFrame(id); + // scroll to the selected time when the popover is opened + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ open ]); + + React.useEffect(() => { + if (limits) { + if ( + (timeValue.hours !== undefined && !isInLimits({ value: timeValue.hours, type: 'hours', timeValue, limits })) || + (timeValue.minutes !== undefined && !isInLimits({ value: timeValue.minutes, type: 'minutes', timeValue, limits })) + ) { + setHours(undefined); + setMinutes(undefined); + scrollToItem(0, 0); + } + } + }, [ limits, timeValue, setHours, setMinutes, scrollToItem ]); + + const closeButtonOpacity = (() => { + if (hours !== undefined || minutes !== undefined) { + if (disabled || readOnly) { + return 'control.disabled'; + } + return 1; + } + + return 0; + })(); + + const clockIconTrigger = (() => { + if (disabled) { + return 'not-allowed'; + } + + if (readOnly) { + return 'default'; + } + + return 'pointer'; + })(); + + const endElement = ( + <HStack mr={ 2 } gap={ 1 }> + { !readOnly && ( + <CloseButton + onClick={ handleClear } + opacity={ closeButtonOpacity } + color="icon.secondary" + _hover={{ color: 'hover' }} + iconProps={{ p: '1px' }} + disabled={ disabled } + /> + ) } + <Icon + boxSize={ 5 } + p="3px" + color="icon.primary" + cursor={ clockIconTrigger } + _hover={{ color: disabled || readOnly ? 'icon.primary' : 'hover' }} + opacity={ disabled || readOnly ? 'control.disabled' : 1 } + > + <ClockIcon/> + </Icon> + </HStack> + ); + + const invalid = React.useMemo(() => { + if (disabled || readOnly || (!min && !max)) { + return false; + } + + return !isInLimits({ value: hours ?? 0, type: 'hours', timeValue, limits }) || !isInLimits({ value: minutes ?? 0, type: 'minutes', timeValue, limits }); + }, [ disabled, readOnly, min, max, hours, minutes, timeValue, limits ]); + + return ( + <PopoverRoot + positioning={{ sameWidth: true }} + lazyMount={ false } + unmountOnExit={ false } + onOpenChange={ !disabled && !readOnly ? onOpenChange : undefined } + open={ !disabled && !readOnly && open } + > + <PopoverTrigger asChild> + <Field readOnly={ readOnly } disabled={ disabled } invalid={ invalid } { ...rest }> + <InputGroup endElement={ endElement } > + <Input + placeholder="Select time" + size="sm" + value={ hours !== undefined && minutes !== undefined ? formatValue(hours, minutes) : '' } + { ...inputProps } + /> + </InputGroup> + </Field> + </PopoverTrigger> + <PopoverContent borderRadius="base" w="100%" minW="160px" zIndex="tooltip"> + <PopoverBody> + <HStack gap={ 3 } alignItems="flex-start"> + <VStack ref={ hoursContainerRef } gap={ `${ GAP_HEIGHT }px` } maxH="232px" overflowY="scroll" scrollbarWidth="none" scrollSnapType="y mandatory"> + { range(0, 24).map(hour => { + return ( + <TimePickerItemButton + key={ hour } + value={ hour } + selected={ hours === hour } + disabled={ !isInLimits({ value: hour, type: 'hours', timeValue, limits }) } + onClick={ handleHoursClick } + /> + ); + }) } + </VStack> + <VStack ref={ minutesContainerRef } gap={ `${ GAP_HEIGHT }px` } maxH="232px" overflowY="scroll" scrollbarWidth="none" scrollSnapType="y mandatory"> + { range(0, 60).map(minute => { + return ( + <TimePickerItemButton + key={ minute } + value={ minute } + selected={ minutes === minute } + disabled={ !isInLimits({ value: minute, type: 'minutes', timeValue, limits }) } + onClick={ handleMinutesClick } + /> + ); + }) } + </VStack> + </HStack> + </PopoverBody> + </PopoverContent> + </PopoverRoot> + ); +}; diff --git a/src/toolkit/components/forms/fields/FormFieldDate.tsx b/src/toolkit/components/forms/fields/FormFieldDate.tsx new file mode 100644 index 00000000000..d71a3baa744 --- /dev/null +++ b/src/toolkit/components/forms/fields/FormFieldDate.tsx @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import React from 'react'; +import type { Path, FieldValues } from 'react-hook-form'; +import { useController, useFormContext } from 'react-hook-form'; + +import type { FormFieldPropsBase } from './types'; + +import type { DatePickerValueChangeDetails, DatePickerProps } from '../../../chakra/date-picker'; +import { DatePicker } from '../../../chakra/date-picker'; +import { getFormFieldErrorText } from '../utils/getFormFieldErrorText'; +import { dateValidatorFactory } from '../validators/date'; + +export type FormFieldDateProps< + FormFields extends FieldValues, + Name extends Path<FormFields>, +> = FormFieldPropsBase<FormFields, Name> & DatePickerProps; + +const FormFieldDateContent = < + FormFields extends FieldValues, + Name extends Path<FormFields>, +>(props: FormFieldDateProps<FormFields, Name>) => { + const { name, rules: rulesProp, controllerProps, value, onValueChange, ...rest } = props; + + const { control } = useFormContext<FormFields>(); + + const dateValidator = React.useMemo( + () => dateValidatorFactory(rest.min, rest.max), + [ rest.min, rest.max ], + ); + + const rules = React.useMemo( + () => ({ + ...rulesProp, + validate: { + ...rulesProp?.validate, + date: dateValidator, + }, + }), + [ rulesProp, dateValidator ], + ); + + const { field, fieldState, formState } = useController<FormFields, typeof name>({ + control, + name, + rules: { ...rules, required: rest.required }, + ...controllerProps, + }); + + const isDisabled = formState.isSubmitting; + + const handleChange = React.useCallback((details: DatePickerValueChangeDetails) => { + field.onChange(details.value); + }, [ field ]); + + return ( + <DatePicker + ref={ field.ref } + name={ field.name } + value={ field.value } + onBlur={ field.onBlur } + onValueChange={ handleChange } + disabled={ isDisabled } + invalid={ Boolean(fieldState.error) } + errorText={ getFormFieldErrorText(fieldState.error) } + { ...rest } + /> + ); +}; + +export const FormFieldDate = React.memo(FormFieldDateContent) as typeof FormFieldDateContent; diff --git a/src/toolkit/components/forms/fields/index.ts b/src/toolkit/components/forms/fields/index.ts index 9789600fc92..26656c33b28 100644 --- a/src/toolkit/components/forms/fields/index.ts +++ b/src/toolkit/components/forms/fields/index.ts @@ -6,6 +6,7 @@ export * from './FormFieldAddress'; export * from './FormFieldCheckbox'; export * from './FormFieldCheckboxGroup'; export * from './FormFieldColor'; +export * from './FormFieldDate'; export * from './FormFieldEmail'; export * from './FormFieldNumber'; export * from './FormFieldRadio'; diff --git a/src/toolkit/components/forms/validators/date.spec.ts b/src/toolkit/components/forms/validators/date.spec.ts new file mode 100644 index 00000000000..a1f2cbf65ff --- /dev/null +++ b/src/toolkit/components/forms/validators/date.spec.ts @@ -0,0 +1,66 @@ +import { CalendarDate, CalendarDateTime, getLocalTimeZone, parseAbsolute } from '@internationalized/date'; + +import { describe, it, expect } from 'vitest'; + +import { dateValidatorFactory } from './date'; + +const MIN = new CalendarDateTime(2026, 7, 20, 10, 0); +const MAX = new CalendarDateTime(2026, 7, 30, 18, 0); + +describe('dateValidatorFactory', () => { + it('passes when neither limit is set', () => { + expect(dateValidatorFactory()([ new CalendarDate(1999, 1, 1) ])).toBe(true); + }); + + it('passes for an undefined value', () => { + expect(dateValidatorFactory(MIN, MAX)(undefined)).toBe(true); + }); + + // a cleared field holds [], which used to dereference value[0] and throw + it('passes for a cleared value instead of throwing', () => { + expect(() => dateValidatorFactory(MIN, MAX)([])).not.toThrow(); + expect(dateValidatorFactory(MIN, MAX)([])).toBe(true); + }); + + it('passes for a value inside the limits', () => { + expect(dateValidatorFactory(MIN, MAX)([ new CalendarDateTime(2026, 7, 25, 9, 0) ])).toBe(true); + }); + + it('passes on both boundaries', () => { + expect(dateValidatorFactory(MIN, MAX)([ MIN ])).toBe(true); + expect(dateValidatorFactory(MIN, MAX)([ MAX ])).toBe(true); + }); + + it('rejects a value before the minimum', () => { + expect(dateValidatorFactory(MIN, MAX)([ new CalendarDateTime(2026, 7, 20, 9, 59) ])) + .toBe('Date is before the minimum date'); + }); + + it('rejects a value after the maximum', () => { + expect(dateValidatorFactory(MIN, MAX)([ new CalendarDateTime(2026, 7, 30, 18, 1) ])) + .toBe('Date is after the maximum date'); + }); + + it('applies a lone minimum and a lone maximum', () => { + expect(dateValidatorFactory(MIN)([ new CalendarDateTime(2026, 1, 1, 0, 0) ])) + .toBe('Date is before the minimum date'); + expect(dateValidatorFactory(undefined, MAX)([ new CalendarDateTime(2027, 1, 1, 0, 0) ])) + .toBe('Date is after the maximum date'); + }); + + it('compares a date-only value at day granularity', () => { + // same day as MAX, so the time on MAX must not push it out of range + expect(dateValidatorFactory(MIN, MAX)([ new CalendarDate(2026, 7, 30) ])).toBe(true); + expect(dateValidatorFactory(MIN, MAX)([ new CalendarDate(2026, 7, 31) ])) + .toBe('Date is after the maximum date'); + }); + + it('compares across value types', () => { + const zonedMax = parseAbsolute(new CalendarDateTime(2026, 7, 30, 18, 0) + .toDate(getLocalTimeZone()).toISOString(), getLocalTimeZone()); + + expect(dateValidatorFactory(undefined, zonedMax)([ new CalendarDateTime(2026, 7, 30, 17, 0) ])).toBe(true); + expect(dateValidatorFactory(undefined, zonedMax)([ new CalendarDateTime(2026, 7, 30, 19, 0) ])) + .toBe('Date is after the maximum date'); + }); +}); diff --git a/src/toolkit/components/forms/validators/date.ts b/src/toolkit/components/forms/validators/date.ts new file mode 100644 index 00000000000..7145733c193 --- /dev/null +++ b/src/toolkit/components/forms/validators/date.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import type { DateValue } from '@chakra-ui/react'; + +export const dateValidatorFactory = (min?: DateValue, max?: DateValue) => (value: Array<DateValue> | undefined) => { + if (!value || (min === undefined && max === undefined)) { + return true; + } + + // a cleared field holds an empty array, which has nothing to compare against + const date = value[0]; + if (!date) { + return true; + } + + if (min && date.compare(min) < 0) { + return 'Date is before the minimum date'; + } + + if (max && date.compare(max) > 0) { + return 'Date is after the maximum date'; + } + + return true; +}; diff --git a/src/toolkit/components/forms/validators/index.ts b/src/toolkit/components/forms/validators/index.ts index e1534f76e00..969a747177c 100644 --- a/src/toolkit/components/forms/validators/index.ts +++ b/src/toolkit/components/forms/validators/index.ts @@ -2,6 +2,7 @@ export * from './address'; export * from './color'; +export * from './date'; export * from './email'; export * from './signature'; export * from './text'; diff --git a/src/toolkit/components/forms/validators/transaction.ts b/src/toolkit/components/forms/validators/transaction.ts index 93c6edc2c19..c4b351feaee 100644 --- a/src/toolkit/components/forms/validators/transaction.ts +++ b/src/toolkit/components/forms/validators/transaction.ts @@ -5,3 +5,11 @@ export const TRANSACTION_HASH_REGEXP = /^0x[a-fA-F\d]{64}$/; export const TRANSACTION_HASH_LENGTH = 66; + +export function transactionHashValidator(value: string | undefined) { + if (!value) { + return true; + } + + return TRANSACTION_HASH_REGEXP.test(value) ? true : 'Incorrect format'; +} diff --git a/src/toolkit/components/truncation/TruncatedText.tsx b/src/toolkit/components/truncation/TruncatedText.tsx index 2adc3c52fe3..4271e92b045 100644 --- a/src/toolkit/components/truncation/TruncatedText.tsx +++ b/src/toolkit/components/truncation/TruncatedText.tsx @@ -15,13 +15,14 @@ import { TruncatedTextTooltip } from './TruncatedTextTooltip'; export interface TruncatedTextProps extends Omit<SkeletonTextProps, 'loading'> { text: string; loading?: boolean; + noTooltip?: boolean; // tooltipContent is used to display the tooltip value different from the truncated value - tooltipContent?: string; + tooltipContent?: React.ReactNode; tooltipPlacement?: ExcludeUndefined<TooltipProps['positioning']>['placement']; tooltipInteractive?: boolean; } -export const TruncatedText = ({ text, tooltipPlacement, tooltipInteractive, tooltipContent, loading, ...rest }: TruncatedTextProps) => { +export const TruncatedText = ({ text, tooltipPlacement, tooltipInteractive, tooltipContent, loading, noTooltip, ...rest }: TruncatedTextProps) => { const valueElement = ( <Skeleton loading={ loading } @@ -36,6 +37,10 @@ export const TruncatedText = ({ text, tooltipPlacement, tooltipInteractive, tool </Skeleton> ); + if (noTooltip) { + return valueElement; + } + // if tooltipContent is provided, we display the tooltip content no matter if the value is truncated or not if (tooltipContent) { return ( diff --git a/src/toolkit/package/package.json b/src/toolkit/package/package.json index 3c29efe143b..a6864a1275a 100644 --- a/src/toolkit/package/package.json +++ b/src/toolkit/package/package.json @@ -43,6 +43,12 @@ "peerDependencies": { "@chakra-ui/react": ">=3.36.1", "@emotion/react": ">=11.14.0", + "@internationalized/date": ">=3.12.2", + "@uidotdev/usehooks": ">=2.4.1", + "d3": ">=7.9.0", + "dayjs": ">=1.11.21", + "dom-to-image": ">=2.6.0", + "es-toolkit": ">=1.39.10", "next": ">=16.2.6", "next-themes": ">=0.4.4", "react": ">=18.3.1", diff --git a/src/toolkit/package/src/index.ts b/src/toolkit/package/src/index.ts index b470973f1df..b01f681db79 100644 --- a/src/toolkit/package/src/index.ts +++ b/src/toolkit/package/src/index.ts @@ -5,11 +5,13 @@ export * from '../../chakra/accordion'; export * from '../../chakra/alert'; export * from '../../chakra/avatar'; export * from '../../chakra/badge'; +export * from '../../chakra/box'; export * from '../../chakra/button'; export * from '../../chakra/checkbox'; export * from '../../chakra/close-button'; export * from '../../chakra/collapsible'; export * from '../../chakra/color-mode'; +export * from '../../chakra/date-picker'; export * from '../../chakra/dialog'; export * from '../../chakra/drawer'; export * from '../../chakra/empty-state'; @@ -37,6 +39,7 @@ export * from '../../chakra/table'; export * from '../../chakra/tabs'; export * from '../../chakra/tag'; export * from '../../chakra/textarea'; +export * from '../../chakra/time-picker'; export * from '../../chakra/toaster'; export * from '../../chakra/tooltip'; diff --git a/src/toolkit/package/vite.config.ts b/src/toolkit/package/vite.config.ts index 6b6422bfef9..6fcfc2b3691 100644 --- a/src/toolkit/package/vite.config.ts +++ b/src/toolkit/package/vite.config.ts @@ -80,8 +80,10 @@ export default defineConfig({ 'next/router', 'next-themes', 'react-hook-form', - 'es-toolkit', - 'dayjs', + // regexps so subpath imports (es-toolkit/compat, dayjs/plugin/*) stay external too + /^es-toolkit(\/.*)?$/, + /^dayjs(\/.*)?$/, + '@internationalized/date', 'd3', 'dom-to-image', '@uidotdev/usehooks', diff --git a/src/toolkit/pages/design-system/DesignSystem.tsx b/src/toolkit/pages/design-system/DesignSystem.tsx index 99736fc0e4a..39190dc6c4e 100644 --- a/src/toolkit/pages/design-system/DesignSystem.tsx +++ b/src/toolkit/pages/design-system/DesignSystem.tsx @@ -19,6 +19,7 @@ import ClipboardShowcase from './tabs/Clipboard'; import CloseButtonShowcase from './tabs/CloseButton'; import CollapsibleShowcase from './tabs/Collapsible'; import ContentLoaderShowcase from './tabs/ContentLoader'; +import DatePickerShowcase from './tabs/DatePicker'; import DialogShowcase from './tabs/Dialog'; import EmptyStateShowcase from './tabs/EmptyState'; import FieldShowcase from './tabs/Field'; @@ -42,6 +43,7 @@ import TableShowcase from './tabs/Table'; import TabsShowcase from './tabs/Tabs'; import TagShowcase from './tabs/Tag'; import TextareaShowcase from './tabs/Textarea'; +import TimePickerShowcase from './tabs/TimePicker'; import ToastShowcase from './tabs/Toast'; import TooltipShowcase from './tabs/Tooltip'; import ValuesShowcase from './tabs/Values'; @@ -56,6 +58,7 @@ const tabs = [ { label: 'Close button', value: 'close-button', component: <CloseButtonShowcase/> }, { label: 'Collapsible', value: 'collapsible', component: <CollapsibleShowcase/> }, { label: 'Content loader', value: 'content-loader', component: <ContentLoaderShowcase/> }, + { label: 'Date picker', value: 'date-picker', component: <DatePickerShowcase/> }, { label: 'Dialog', value: 'dialog', component: <DialogShowcase/> }, { label: 'Empty state', value: 'empty-state', component: <EmptyStateShowcase/> }, { label: 'Field', value: 'field', component: <FieldShowcase/> }, @@ -79,6 +82,7 @@ const tabs = [ { label: 'Tabs', value: 'tabs', component: <TabsShowcase/> }, { label: 'Tag', value: 'tag', component: <TagShowcase/> }, { label: 'Textarea', value: 'textarea', component: <TextareaShowcase/> }, + { label: 'Time picker', value: 'time-picker', component: <TimePickerShowcase/> }, { label: 'Toast', value: 'toast', component: <ToastShowcase/> }, { label: 'Tooltip', value: 'tooltip', component: <TooltipShowcase/> }, { label: 'Values', value: 'values', component: <ValuesShowcase/> }, diff --git a/src/toolkit/pages/design-system/tabs/DatePicker.pw.tsx b/src/toolkit/pages/design-system/tabs/DatePicker.pw.tsx new file mode 100644 index 00000000000..ec95d0899ed --- /dev/null +++ b/src/toolkit/pages/design-system/tabs/DatePicker.pw.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +import { TabsRoot } from 'src/toolkit/chakra/tabs'; + +import { test, expect } from 'playwright/lib'; + +import DatePicker from './DatePicker'; + +test('default +@dark-mode', async({ render }) => { + const component = await render(<TabsRoot defaultValue="date-picker"><DatePicker/></TabsRoot>); + await expect(component).toHaveScreenshot(); + await component.locator('input[name="date_of_birth"]').click(); + await expect(component).toHaveScreenshot(); +}); diff --git a/src/toolkit/pages/design-system/tabs/DatePicker.tsx b/src/toolkit/pages/design-system/tabs/DatePicker.tsx new file mode 100644 index 00000000000..da0bcd5280f --- /dev/null +++ b/src/toolkit/pages/design-system/tabs/DatePicker.tsx @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import type { DateValue } from '@chakra-ui/react'; +import { parseDate, Text } from '@chakra-ui/react'; +import { getLocalTimeZone, parseAbsolute } from '@internationalized/date'; +import { delay } from 'es-toolkit'; +import React from 'react'; +import type { SubmitHandler } from 'react-hook-form'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { Button } from 'src/toolkit/chakra/button'; +import { DatePicker } from 'src/toolkit/chakra/date-picker'; +import { toaster } from 'src/toolkit/chakra/toaster'; +import { FormFieldDate } from 'src/toolkit/components/forms/fields/FormFieldDate'; +import { DAY, HOUR, MINUTE, SECOND } from 'src/toolkit/utils/consts'; + +import { Section, Container, SectionHeader, SamplesStack, Sample } from '../parts'; + +interface FormFields { + date_of_birth: Array<DateValue>; +} + +const DatePickerShowcase = () => { + + const [ value, setValue ] = React.useState<Array<DateValue> | undefined>(undefined); + + const handleValueChange = React.useCallback((details: { value: Array<DateValue> | undefined }) => { + setValue(details.value); + }, [ setValue ]); + + const timeZone = getLocalTimeZone(); + + const formApi = useForm<FormFields>({ + defaultValues: { + date_of_birth: [], + }, + mode: 'onBlur', + }); + + const onSubmit: SubmitHandler<FormFields> = React.useCallback(async(formData) => { + await delay(SECOND); + // eslint-disable-next-line no-console + console.log(formData); + toaster.success({ + title: 'Form submitted', + description: `Selected date: ${ formData.date_of_birth.toString() }`, + }); + }, []); + + return ( + <Container value="date-picker"> + <Section> + <SectionHeader>Variants</SectionHeader> + <SamplesStack > + <Sample label="variant: outline"> + <DatePicker placeholder="Select date" w="300px"/> + <DatePicker placeholder="Select date (disabled)" w="300px" value={ [ parseDate('2022-11-11') ] } disabled/> + <DatePicker placeholder="Select date (readOnly)" w="300px" value={ [ parseDate('2022-11-11') ] } readOnly/> + <DatePicker placeholder="Select date (invalid)" w="300px" value={ [ parseDate('2022-11-11') ] } required invalid errorText="Error"/> + </Sample> + </SamplesStack> + </Section> + <Section> + <SectionHeader>Min and max date</SectionHeader> + <SamplesStack > + <Sample label="min: 10d ago; max: new Date()"> + <DatePicker + w="300px" + min={ parseAbsolute(new Date(Date.now() - 10 * DAY).toISOString(), timeZone) } + max={ parseAbsolute(new Date().toISOString(), timeZone) } + /> + </Sample> + <Sample label="min: 10d ago + 1h:42m; max: Date.now()"> + <DatePicker + w="300px" + min={ parseAbsolute(new Date(Date.now() - 10 * DAY + HOUR + 42 * MINUTE).toISOString(), timeZone) } + max={ parseAbsolute(new Date().toISOString(), timeZone) } + withTime + /> + </Sample> + </SamplesStack> + </Section> + <Section> + <SectionHeader>With time selection</SectionHeader> + <SamplesStack > + <Sample label="withTime: true"> + <DatePicker placeholder="Select date" w="300px" withTime onValueChange={ handleValueChange }/> + <Text>{ value?.toString() ?? 'No value' }</Text> + </Sample> + </SamplesStack> + </Section> + <Section> + <SectionHeader>Form field</SectionHeader> + <SamplesStack > + <Sample> + <FormProvider { ...formApi }> + <form noValidate onSubmit={ formApi.handleSubmit(onSubmit) }> + <FormFieldDate<FormFields, 'date_of_birth'> + name="date_of_birth" + placeholder="Select date of birth" + w="400px" + min={ parseAbsolute(new Date(Date.now() - 10 * DAY + HOUR + 42 * MINUTE).toISOString(), timeZone) } + max={ parseAbsolute(new Date().toISOString(), timeZone) } + withTime + required + /> + <Button + type="submit" + loading={ formApi.formState.isSubmitting } + mt={ 6 } + > + Submit + </Button> + </form> + </FormProvider> + </Sample> + </SamplesStack> + </Section> + </Container> + ); +}; + +export default React.memo(DatePickerShowcase); diff --git a/src/toolkit/pages/design-system/tabs/TimePicker.pw.tsx b/src/toolkit/pages/design-system/tabs/TimePicker.pw.tsx new file mode 100644 index 00000000000..f69f470f01e --- /dev/null +++ b/src/toolkit/pages/design-system/tabs/TimePicker.pw.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +import { TabsRoot } from 'src/toolkit/chakra/tabs'; + +import { test, expect } from 'playwright/lib'; + +import TimePicker from './TimePicker'; + +test('default +@dark-mode', async({ render }) => { + const component = await render(<TabsRoot defaultValue="time-picker"><TimePicker/></TabsRoot>); + await expect(component).toHaveScreenshot(); +}); diff --git a/src/toolkit/pages/design-system/tabs/TimePicker.tsx b/src/toolkit/pages/design-system/tabs/TimePicker.tsx new file mode 100644 index 00000000000..b4165a5d932 --- /dev/null +++ b/src/toolkit/pages/design-system/tabs/TimePicker.tsx @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import React from 'react'; + +import { TimePicker } from 'src/toolkit/chakra/time-picker'; + +import { Section, Container, SectionHeader, SamplesStack, Sample } from '../parts'; + +const TimePickerShowcase = () => { + + return ( + <Container value="time-picker"> + <Section> + <SectionHeader>Variants</SectionHeader> + <SamplesStack > + <Sample label="default"> + <TimePicker w="200px"/> + <TimePicker w="200px" value="12:00" disabled/> + <TimePicker w="200px" value="12:00" readOnly/> + <TimePicker w="200px" value="12:00" invalid/> + </Sample> + </SamplesStack> + </Section> + <Section> + <SectionHeader>Min and max time</SectionHeader> + <SamplesStack > + <Sample label="min: 03:45; max: 21:13"> + <TimePicker min="03:45" max="21:13" value="01:01"/> + </Sample> + </SamplesStack> + </Section> + </Container> + ); +}; + +export default React.memo(TimePickerShowcase); diff --git a/src/toolkit/pages/design-system/tabs/__screenshots__/DatePicker.pw.tsx_dark-color-mode_default-dark-mode-1.png b/src/toolkit/pages/design-system/tabs/__screenshots__/DatePicker.pw.tsx_dark-color-mode_default-dark-mode-1.png new file mode 100644 index 00000000000..a880ddd0102 Binary files /dev/null and b/src/toolkit/pages/design-system/tabs/__screenshots__/DatePicker.pw.tsx_dark-color-mode_default-dark-mode-1.png differ diff --git a/src/toolkit/pages/design-system/tabs/__screenshots__/DatePicker.pw.tsx_dark-color-mode_default-dark-mode-2.png b/src/toolkit/pages/design-system/tabs/__screenshots__/DatePicker.pw.tsx_dark-color-mode_default-dark-mode-2.png new file mode 100644 index 00000000000..16e7adc088c Binary files /dev/null and b/src/toolkit/pages/design-system/tabs/__screenshots__/DatePicker.pw.tsx_dark-color-mode_default-dark-mode-2.png differ diff --git a/src/toolkit/pages/design-system/tabs/__screenshots__/DatePicker.pw.tsx_default_default-dark-mode-1.png b/src/toolkit/pages/design-system/tabs/__screenshots__/DatePicker.pw.tsx_default_default-dark-mode-1.png new file mode 100644 index 00000000000..22a74cc1ff6 Binary files /dev/null and b/src/toolkit/pages/design-system/tabs/__screenshots__/DatePicker.pw.tsx_default_default-dark-mode-1.png differ diff --git a/src/toolkit/pages/design-system/tabs/__screenshots__/DatePicker.pw.tsx_default_default-dark-mode-2.png b/src/toolkit/pages/design-system/tabs/__screenshots__/DatePicker.pw.tsx_default_default-dark-mode-2.png new file mode 100644 index 00000000000..038907ff9c1 Binary files /dev/null and b/src/toolkit/pages/design-system/tabs/__screenshots__/DatePicker.pw.tsx_default_default-dark-mode-2.png differ diff --git a/src/toolkit/pages/design-system/tabs/__screenshots__/TimePicker.pw.tsx_dark-color-mode_default-dark-mode-1.png b/src/toolkit/pages/design-system/tabs/__screenshots__/TimePicker.pw.tsx_dark-color-mode_default-dark-mode-1.png new file mode 100644 index 00000000000..78327266fff Binary files /dev/null and b/src/toolkit/pages/design-system/tabs/__screenshots__/TimePicker.pw.tsx_dark-color-mode_default-dark-mode-1.png differ diff --git a/src/toolkit/pages/design-system/tabs/__screenshots__/TimePicker.pw.tsx_default_default-dark-mode-1.png b/src/toolkit/pages/design-system/tabs/__screenshots__/TimePicker.pw.tsx_default_default-dark-mode-1.png new file mode 100644 index 00000000000..fc60609d6ea Binary files /dev/null and b/src/toolkit/pages/design-system/tabs/__screenshots__/TimePicker.pw.tsx_default_default-dark-mode-1.png differ diff --git a/src/toolkit/theme/foundations/zIndex.ts b/src/toolkit/theme/foundations/zIndex.ts index 00ab6019d2b..b85a80d2461 100644 --- a/src/toolkit/theme/foundations/zIndex.ts +++ b/src/toolkit/theme/foundations/zIndex.ts @@ -12,7 +12,7 @@ export const zIndex = { banner: { value: 1200 }, overlay: { value: 1300 }, modal: { value: 1400 }, - modal2: { value: 14001 }, + modal2: { value: 1401 }, tooltip: { value: 1550 }, // otherwise tooltips will not be visible in modals tooltip2: { value: 1551 }, // for tooltips in tooltips toast: { value: 1700 }, diff --git a/src/toolkit/theme/recipes/date-picker.recipe.ts b/src/toolkit/theme/recipes/date-picker.recipe.ts new file mode 100644 index 00000000000..744b8d45639 --- /dev/null +++ b/src/toolkit/theme/recipes/date-picker.recipe.ts @@ -0,0 +1,353 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import { defineSlotRecipe, defineStyle } from '@chakra-ui/react'; + +import { recipe as inputRecipe } from './input.recipe'; + +// PrevTrigger, NextTrigger +const navTriggerStyle = defineStyle({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + boxSize: 'var(--datepicker-nav-trigger-size)', + color: 'icon.primary', + cursor: 'pointer', + _hover: { + color: 'hover', + bg: 'transparent', + }, + _disabled: { + opacity: 'control.disabled', + }, +}); + +export const recipe = defineSlotRecipe({ + className: 'date-picker', + slots: [ + 'root', + 'label', + 'indicatorGroup', + 'control', + 'input', + 'trigger', + 'content', + 'view', + 'viewControl', + 'viewTrigger', + 'prevTrigger', + 'nextTrigger', + 'rangeText', + 'table', + 'tableRow', + 'tableHeader', + 'tableCell', + 'tableCellTrigger', + 'monthSelect', + 'yearSelect', + 'clearTrigger', + ], + base: { + root: { + display: 'flex', + flexDirection: 'column', + gap: '1.5', + width: 'full', + '--datepicker-indicators-offset': 'sizes.3', + _disabled: { + opacity: 0.5, + }, + }, + + label: { + textStyle: 'sm', + fontWeight: 'medium', + }, + + indicatorGroup: { + position: 'absolute', + insetEnd: 'var(--datepicker-indicators-offset)', + top: '50%', + transform: 'translateY(-50%)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '1', + }, + + control: { + display: 'flex', + alignItems: 'center', + gap: '2', + width: 'full', + position: 'relative', + }, + + input: { + flex: '1', + minWidth: '0', + height: 'var(--datepicker-input-height)', + '--input-height': 'var(--datepicker-input-height)', + px: 'var(--datepicker-input-px)', + textStyle: 'sm', + borderRadius: 'base', + outline: '0', + appearance: 'none', + fontWeight: '500', + _readOnly: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + _disabled: { + cursor: 'not-allowed', + }, + }, + + trigger: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: '6', + height: '6', + color: 'icon.primary', + cursor: 'pointer', + outline: 'none', + _hover: { + color: 'hover', + }, + _disabled: { + cursor: 'not-allowed', + opacity: 'control.disabled', + }, + _readOnly: { + cursor: 'default', + opacity: 'control.disabled', + _hover: { + color: 'icon.primary', + pointerEvents: 'none', + }, + }, + }, + + content: { + display: 'flex', + flexDirection: 'column', + gap: '3', + p: '4', + minW: '280px', + maxW: '500px', + bg: 'popover.bg', + borderRadius: 'md', + boxShadow: 'popover', + boxShadowColor: 'colors.popover.shadow', + color: 'text.primary', + '--date-picker-z-index': 'zIndex.modal2', + zIndex: 'calc(var(--date-picker-z-index) + var(--layer-index, 0))', + outline: 'none', + _open: { + animationStyle: 'scale-fade-in', + animationDuration: 'fast', + }, + _closed: { + animationStyle: 'scale-fade-out', + animationDuration: 'faster', + }, + }, + + view: { + display: 'flex', + flexDirection: 'column', + gap: '3', + }, + + viewControl: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: '2', + height: 'var(--datepicker-nav-trigger-size)', + }, + + viewTrigger: { + display: 'inline-flex', + flex: '1', + alignItems: 'center', + justifyContent: 'center', + gap: '1', + py: '1', + px: '2', + cursor: 'pointer', + _hover: { + color: 'hover', + bg: 'transparent', + }, + }, + + prevTrigger: navTriggerStyle, + nextTrigger: navTriggerStyle, + + rangeText: { + textStyle: 'md', + fontWeight: '600', + }, + + table: { + borderCollapse: 'separate', + borderSpacing: '0 8px', + }, + + tableHeader: { + width: 'var(--table-cell-size)', + py: '1', + textStyle: 'md', + fontWeight: '600', + textAlign: 'center', + textTransform: 'uppercase', + color: 'text.primary', + }, + + tableCell: { + py: '0', + textAlign: 'center', + }, + + tableCellTrigger: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: 'var(--table-cell-size)', + minHeight: 'var(--table-cell-size)', + textStyle: 'md', + borderRadius: 'sm', + cursor: 'pointer', + position: 'relative', + _hover: { + color: 'hover', + bg: 'transparent', + }, + '[data-view=month] &, [data-view=year] &': { + width: 'calc(var(--table-cell-size) * 1.75)', + }, + _today: { + color: 'colorPalette.fg', + fontWeight: 'semibold', + textDecoration: 'underline', + textUnderlineOffset: '3px', + textDecorationThickness: '2px', + }, + '&[data-selected]': { + bg: 'selected.option.bg', + color: 'whiteAlpha.900', + _hover: { + bg: 'selected.option.bg', + color: 'whiteAlpha.900', + }, + }, + '&[data-in-range]': { + bg: 'selected.option.bg', + color: 'whiteAlpha.900', + borderRadius: '0', + _hover: { + bg: 'selected.option.bg', + color: 'whiteAlpha.900', + }, + }, + '&[data-in-range][data-selected]': { + bg: 'selected.option.bg', + color: 'whiteAlpha.900', + borderRadius: '0', + _hover: { + bg: 'selected.option.bg', + color: 'whiteAlpha.900', + }, + '&[data-range-start][data-range-end]': { + borderRadius: 'sm', + }, + '&[data-range-start]:not([data-range-end])': { + borderStartRadius: 'sm', + borderEndRadius: '0', + }, + '&[data-range-end]:not([data-range-start])': { + borderEndRadius: 'sm', + borderStartRadius: '0', + }, + }, + _disabled: { + opacity: 'control.disabled', + cursor: 'not-allowed', + }, + }, + + clearTrigger: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + textStyle: 'xs', + color: 'icon.secondary', + _hover: { + color: 'hover', + }, + _disabled: { + cursor: 'not-allowed', + }, + }, + }, + + variants: { + size: { + lg: { + root: { + '--datepicker-input-height': '60px', + '--datepicker-input-px': 'sizes.4', + }, + view: { + '--table-cell-size': 'sizes.8', + '--datepicker-nav-trigger-size': 'sizes.8', + '--datepicker-select-height': 'sizes.10', + }, + input: { + textStyle: 'md', + }, + }, + }, + + hideOutsideDays: { + 'true': { + tableCellTrigger: { + '&[data-outside-range]': { + visibility: 'hidden', + }, + }, + }, + }, + + variant: { + outline: { + input: inputRecipe.variants?.variant.outline, + }, + }, + + floating: { + 'true': {}, + }, + }, + + compoundVariants: [ + { + size: 'lg', + floating: true, + css: { + input: { + padding: '24px 10px 8px 16px', + }, + }, + }, + ], + + defaultVariants: { + size: 'lg', + variant: 'outline', + floating: true, + }, +}); diff --git a/src/toolkit/theme/recipes/index.ts b/src/toolkit/theme/recipes/index.ts index d9c6524cebe..15a652282ee 100644 --- a/src/toolkit/theme/recipes/index.ts +++ b/src/toolkit/theme/recipes/index.ts @@ -8,6 +8,7 @@ import { recipe as checkbox } from './checkbox.recipe'; import { recipe as checkmark } from './checkmark.recipe'; import { recipe as closeButton } from './close-button.recipe'; import { recipe as code } from './code.recipe'; +import { recipe as datePicker } from './date-picker.recipe'; import { recipe as dialog } from './dialog.recipe'; import { recipe as drawer } from './drawer.recipe'; import { recipe as emptyState } from './empty-state.recipe'; @@ -56,6 +57,7 @@ export const slotRecipes = { accordion, alert, checkbox, + datePicker, dialog, drawer, emptyState, diff --git a/tools/dev-server/CONTEXT.md b/tools/dev-server/CONTEXT.md index 7cae7c16ba6..ac618e89788 100644 --- a/tools/dev-server/CONTEXT.md +++ b/tools/dev-server/CONTEXT.md @@ -17,15 +17,22 @@ unnecessary. ## Files +Two naming rules hold here: **dotted names are entry points** — one per `pnpm` script, meant to +be typed by a human (`dev.preset.sh`, `prod.preset.sh`); **snake_case names are internals** — +invoked or sourced by another script, never directly (`run_steps.sh`). `fetch.sh` predates the +rules and is referenced by path from the Dockerfile and several generator scripts, so it keeps +its name. + | File | Role | |---|---| | `registry.json` | **Single source of truth**: `alias` → instance URL map. | | `envs-rules.json` | `localEnvs` (local APP_* substitutions) + `ignoredEnvs` / `deprecatedEnvs` (keys to drop - see "Dropped envs" below). | | `fetch.ts` (→ `fetch.js`) | Fetches `<url>/node-api/config`, drops `ignoredEnvs` + `deprecatedEnvs`, applies/omits `localEnvs`, writes `.env.tmp`. | | `fetch.sh` | Compile-on-run wrapper (`tsc` + `node fetch.js`). Resolves its own path, so callable from any cwd. | -| `dev.preset.sh` | `pnpm dev:preset <alias> [--port <number>]` - fetch + run `next dev`. | -| `dev.local.sh` | `pnpm dev:local [--port <number>]` - run against a local backend using `.env.localhost` (no fetch). | -| `prod.preset.sh` | `pnpm prod:preset <alias> [--skip-build]` - fetch + `next build` + `next start` (production build, e.g. for performance measurements); `--skip-build` restarts from the existing `.next` output. | +| `dev.preset.sh` | `pnpm dev:preset <alias> [--port <number>]` — fetch + run `next dev`. | +| `dev.local.sh` | `pnpm dev:local [--port <number>]` — run against a local backend using `.env.localhost` (no fetch). Skips the multichain config: a local backend serves a single chain. | +| `prod.preset.sh` | `pnpm prod:preset <alias> [--port <number>] [--skip-build]` — fetch + `next build` + `next start` (production build, e.g. for performance measurements); `--skip-build` restarts from the existing `.next` output. `--profile` builds the React-profileable variant (see `tools/profiling/CONTEXT.md`). | +| `run_steps.sh` | Sourced by all three run scripts: env layering (`build_port_args`, `build_env_args`), asset regeneration (`prepare_assets`), and the launch wrapper (`run_with_envs`). What stays in a run script is its argument parsing and the command it finally runs. | | `.env.localhost` | Committed base config for local-backend dev. | | `sync-preset-lists.mjs` | Regenerates / checks the alias dropdowns from `registry.json`. | | `fetch.js`, `tsconfig.tsbuildinfo` | Build artifacts - git-ignored, regenerated on run. | @@ -59,13 +66,17 @@ lives here): newline doesn't silently drop its last variable (this bit us with `.env.extra`). - **dotenv-cli precedence: the FIRST `-e` file wins** (not the last). The run scripts therefore list env files **highest-priority-first**. +- **`dotenv-cli` must stay on a release that bundles `dotenv-expand` ≥ 10.** Instance configs contain + values with a bare `$` (regex anchors in `NEXT_PUBLIC_ZETACHAIN_EXTERNAL_SEARCH_CONFIG`, say); + `dotenv-expand` 8 throws `Cannot read properties of undefined (reading 'split')` on them instead of + leaving the non-variable `$` alone, which kills every `dotenv` invocation in the run scripts. - **`--omit-local-envs` is the dev/container switch.** Dev mode applies `localEnvs` (so APP_HOST etc. point at `localhost`); the container passes `--omit-local-envs` so those keys are absent and the deployment's own APP_* values survive (this replaced the old entrypoint blacklist). ## Env layering (highest → lowest priority) -- `dev:preset`: `--port` flag → `.env.local` → `.env.extra` → `.env.secrets` → `.env.tmp` (fetched instance) +- `dev:preset` / `prod:preset`: `--port` flag → `.env.local` → `.env.extra` → `.env.secrets` → `.env.tmp` (fetched instance) - `dev:local`: `--port` flag → `.env.local` → `.env.extra` → `.env.secrets` → `.env.localhost` The `--port` flag sets `NEXT_PUBLIC_APP_PORT` via dotenv-cli's `-v` (applied AFTER all `-e` @@ -73,6 +84,8 @@ files, so it beats every env file). It overrides the env var rather than just `n so the generated `envs.js` / `config.app.baseUrl` stay consistent with the actual port. Without the flag, the port comes from the env files as before (default `3000` from `localEnvs` / `.env.localhost`; a persistent personal override belongs in `.env.local`). +`prod:preset` therefore regenerates `envs.js` in its **start** step, not its build step — +that's what lets a `--skip-build` restart move to a different port. | File | Committed? | Purpose | |---|---|---| diff --git a/tools/dev-server/dev.local.sh b/tools/dev-server/dev.local.sh index fd62b935eb6..dbc3027e18a 100755 --- a/tools/dev-server/dev.local.sh +++ b/tools/dev-server/dev.local.sh @@ -2,6 +2,9 @@ # Runs the dev server against a LOCAL backend using the committed tools/dev-server/.env.localhost # config (no HTTP fetch). Layer your own overrides via .env.local / .env.extra / .env.secrets. +# The shared steps live in run_steps.sh. + +source ./tools/dev-server/run_steps.sh usage="Usage: pnpm dev:local [--port <number>]" @@ -17,6 +20,9 @@ while [ "$#" -gt 0 ]; do port="$2"; shift 2 ;; --port=*) port="${1#--port=}"; shift ;; + --) + # `pnpm dev:local -- --port 3001` forwards the separator into the script; drop it + shift ;; *) echo "🚨 Unknown argument \"$1\"." echo "$usage" @@ -24,64 +30,12 @@ while [ "$#" -gt 0 ]; do esac done -if [ -n "$port" ] && ! [[ "$port" =~ ^[0-9]+$ ]]; then - echo "🚨 Invalid --port value \"$port\" — expected a number." - echo "$usage" - exit 1 -fi - -# --port overrides NEXT_PUBLIC_APP_PORT (dotenv-cli applies -v variables AFTER the -e files, -# so this beats every env file). Overriding the env var — not just `next dev -p` — keeps the -# generated envs.js and config.app.baseUrl consistent with the actual port. -port_args=() -if [ -n "$port" ]; then - port_args+=( -v NEXT_PUBLIC_APP_PORT="$port" ) -fi - -# Env files in dotenv-cli precedence order: the FIRST -e file wins, so list highest priority first. -# .env.local (git-ignored, personal local overrides) — optional -# .env.extra (committed branch/feature ENVs) -# .env.secrets (git-ignored local secrets) — optional -# tools/dev-server/.env.localhost (local-backend base config) -env_args=() -if [ -f ./.env.local ]; then - env_args+=( -e ./.env.local ) -fi -env_args+=( -e ./.env.extra ) -if [ -f ./.env.secrets ]; then - env_args+=( -e ./.env.secrets ) -fi -env_args+=( -e ./tools/dev-server/.env.localhost ) - -# remove previous assets -rm -rf ./public/assets/configs -rm -rf ./public/assets/multichain -rm -rf ./public/assets/essential-dapps -rm -rf ./public/assets/envs.js - -# download assets for the running instance -dotenv \ - "${env_args[@]}" \ - -- bash -c './deploy/scripts/download_assets.sh ./public/assets/configs' - -# generate essential dapps chains config if marketplace essential dapps enabled -dotenv \ - "${env_args[@]}" \ - -- bash -c 'cd deploy/tools/essential-dapps-chains-config-generator && pnpm build && pnpm generate' || exit 1 - -source ./deploy/scripts/build_sprite.sh -echo "" +build_port_args "$port" "$usage" +build_env_args ./tools/dev-server/.env.localhost -# generate routes -pnpm routes:generate -echo "" +# no preset name: a local backend serves a single chain, so there is no multichain config +prepare_assets # generate envs.js file and run the app -dotenv \ - -v NEXT_PUBLIC_GIT_COMMIT_SHA=$(git rev-parse --short HEAD) \ - -v NEXT_PUBLIC_GIT_TAG=$(git describe --tags --abbrev=0) \ - -v NEXT_PUBLIC_ICON_SPRITE_HASH="${NEXT_PUBLIC_ICON_SPRITE_HASH}" \ - "${port_args[@]}" \ - "${env_args[@]}" \ - -- bash -c 'source ./deploy/scripts/export_pro_api_flag.sh && ./deploy/scripts/make_envs_script.sh && next dev -p $NEXT_PUBLIC_APP_PORT' | +run_with_envs 'source ./deploy/scripts/export_pro_api_flag.sh && ./deploy/scripts/make_envs_script.sh && next dev -p $NEXT_PUBLIC_APP_PORT' | pino-pretty diff --git a/tools/dev-server/dev.preset.sh b/tools/dev-server/dev.preset.sh index 5e2aba2307d..5b08c504820 100755 --- a/tools/dev-server/dev.preset.sh +++ b/tools/dev-server/dev.preset.sh @@ -1,5 +1,10 @@ #!/bin/bash +# Runs the dev server against a live instance's config, fetched over HTTP at startup. +# The shared steps live in run_steps.sh. + +source ./tools/dev-server/run_steps.sh + usage="Usage: pnpm dev:preset <instance_alias> [--port <number>]" port="" @@ -15,6 +20,9 @@ while [ "$#" -gt 0 ]; do port="$2"; shift 2 ;; --port=*) port="${1#--port=}"; shift ;; + --) + # `pnpm dev:preset -- eth` forwards the separator into the script; drop it + shift ;; *) positional+=( "$1" ); shift ;; esac @@ -25,76 +33,16 @@ if [ "${#positional[@]}" -ne 1 ]; then exit 1 fi -if [ -n "$port" ] && ! [[ "$port" =~ ^[0-9]+$ ]]; then - echo "🚨 Invalid --port value \"$port\" — expected a number." - echo "$usage" - exit 1 -fi - preset_name="${positional[0]}" -# --port overrides NEXT_PUBLIC_APP_PORT (dotenv-cli applies -v variables AFTER the -e files, -# so this beats every env file). Overriding the env var — not just `next dev -p` — keeps the -# generated envs.js and config.app.baseUrl consistent with the actual port. -port_args=() -if [ -n "$port" ]; then - port_args+=( -v NEXT_PUBLIC_APP_PORT="$port" ) -fi +build_port_args "$port" "$usage" # Fetch the instance config into ./.env.tmp (compile-on-run) ./tools/dev-server/fetch.sh "$preset_name" || exit 1 -# Env files in dotenv-cli precedence order: the FIRST -e file wins, so list highest priority first. -# .env.local (git-ignored, personal local overrides) — optional -# .env.extra (committed branch/feature ENVs, also read by the demo deploy) -# .env.secrets (git-ignored local secrets) — optional; the fetched config already carries public keys -# .env.tmp (fetched instance config) -env_args=() -if [ -f ./.env.local ]; then - env_args+=( -e ./.env.local ) -fi -env_args+=( -e ./.env.extra ) -if [ -f ./.env.secrets ]; then - env_args+=( -e ./.env.secrets ) -fi -env_args+=( -e ./.env.tmp ) - -# remove previous assets -rm -rf ./public/assets/configs -rm -rf ./public/assets/multichain -rm -rf ./public/assets/essential-dapps -rm -rf ./public/assets/envs.js - -# download assets for the running instance -dotenv \ - "${env_args[@]}" \ - -- bash -c './deploy/scripts/download_assets.sh ./public/assets/configs' - -# generate multichain config (matches both "multichain" and "staging_multichain") -if [[ "$preset_name" =~ "multichain" ]]; then - dotenv \ - "${env_args[@]}" \ - -- bash -c 'cd deploy/tools/multichain-config-generator && pnpm build && pnpm generate' || exit 1 -fi - -# generate essential dapps chains config if marketplace essential dapps enabled -dotenv \ - "${env_args[@]}" \ - -- bash -c 'cd deploy/tools/essential-dapps-chains-config-generator && pnpm build && pnpm generate' || exit 1 - -source ./deploy/scripts/build_sprite.sh -echo "" - -# generate routes -pnpm routes:generate -echo "" +build_env_args ./.env.tmp +prepare_assets "$preset_name" # generate envs.js file and run the app -dotenv \ - -v NEXT_PUBLIC_GIT_COMMIT_SHA=$(git rev-parse --short HEAD) \ - -v NEXT_PUBLIC_GIT_TAG=$(git describe --tags --abbrev=0) \ - -v NEXT_PUBLIC_ICON_SPRITE_HASH="${NEXT_PUBLIC_ICON_SPRITE_HASH}" \ - "${port_args[@]}" \ - "${env_args[@]}" \ - -- bash -c 'source ./deploy/scripts/export_pro_api_flag.sh && ./deploy/scripts/make_envs_script.sh && next dev -p $NEXT_PUBLIC_APP_PORT' | +run_with_envs 'source ./deploy/scripts/export_pro_api_flag.sh && ./deploy/scripts/make_envs_script.sh && next dev -p $NEXT_PUBLIC_APP_PORT' | pino-pretty diff --git a/tools/dev-server/prod.preset.sh b/tools/dev-server/prod.preset.sh index 11e56693f0e..fe983094401 100755 --- a/tools/dev-server/prod.preset.sh +++ b/tools/dev-server/prod.preset.sh @@ -3,110 +3,98 @@ # Production build + start against a live instance's config (same env layering as dev.preset.sh). # Useful for performance measurements, where dev-mode overhead (React dev build, Turbopack # on-demand compile, StrictMode double-fetch) would skew the numbers. +# The shared steps live in run_steps.sh. # -# Usage: pnpm prod:preset <instance_alias> [--skip-build] +# Builds with Turbopack (the Next.js default), like the shipped image does — see +# .agents/adr/0003-turbopack-for-production-builds.md. +# +# Usage: pnpm prod:preset <instance_alias> [--port <number>] [--skip-build] [--profile] # # --skip-build Start the server from the existing build (.next) without rebuilding. # Reuses the .env.tmp and public assets produced by the previous full run. +# --profile Build a React-profileable production bundle (`next build --webpack --profile`). +# Why profiling stays on webpack, and what it's good for: tools/profiling/CONTEXT.md. -skip_build=false -preset_name="" +source ./tools/dev-server/run_steps.sh + +usage="Usage: pnpm prod:preset <instance_alias> [--port <number>] [--skip-build] [--profile]" -for arg in "$@"; do - case "$arg" in - --skip-build) skip_build=true ;; - *) preset_name="$arg" ;; +port="" +skip_build=false +profile=false +positional=() +while [ "$#" -gt 0 ]; do + case "$1" in + --port) + if [ "$#" -lt 2 ]; then + echo "🚨 --port requires a value." + echo "$usage" + exit 1 + fi + port="$2"; shift 2 ;; + --port=*) + port="${1#--port=}"; shift ;; + --skip-build) + skip_build=true; shift ;; + --profile) + profile=true; shift ;; + --) + # `pnpm prod:preset -- eth` forwards the separator into the script; drop it + shift ;; + *) + positional+=( "$1" ); shift ;; esac done -if [ -z "$preset_name" ]; then - echo "Usage: pnpm prod:preset <instance_alias> [--skip-build]" +if [ "${#positional[@]}" -ne 1 ]; then + echo "$usage" exit 1 fi -# Env files in dotenv-cli precedence order: the FIRST -e file wins, so list highest priority first. -# Same layering as dev.preset.sh. -env_args=() -if [ -f ./.env.local ]; then - env_args+=( -e ./.env.local ) -fi -env_args+=( -e ./.env.extra ) -if [ -f ./.env.secrets ]; then - env_args+=( -e ./.env.secrets ) +preset_name="${positional[0]}" + +# the command the user actually typed, for error hints +cmd="pnpm prod:preset $preset_name" +build_flags="" +if [ "$profile" = true ]; then + cmd="$cmd --profile" + build_flags=" --webpack --profile" fi -env_args+=( -e ./.env.tmp ) + +build_port_args "$port" "$usage" +build_env_args ./.env.tmp if [ "$skip_build" = false ]; then # Fetch the instance config into ./.env.tmp (compile-on-run) ./tools/dev-server/fetch.sh "$preset_name" || exit 1 - # remove previous assets - rm -rf ./public/assets/configs - rm -rf ./public/assets/multichain - rm -rf ./public/assets/essential-dapps - rm -rf ./public/assets/envs.js - - # download assets for the running instance - dotenv \ - "${env_args[@]}" \ - -- bash -c './deploy/scripts/download_assets.sh ./public/assets/configs' - - # generate multichain config (matches both "multichain" and "staging_multichain") - if [[ "$preset_name" =~ "multichain" ]]; then - dotenv \ - "${env_args[@]}" \ - -- bash -c 'cd deploy/tools/multichain-config-generator && pnpm build && pnpm generate' || exit 1 - fi - - # generate essential dapps chains config if marketplace essential dapps enabled - dotenv \ - "${env_args[@]}" \ - -- bash -c 'cd deploy/tools/essential-dapps-chains-config-generator && pnpm build && pnpm generate' || exit 1 + prepare_assets "$preset_name" - source ./deploy/scripts/build_sprite.sh - echo "" - - # generate routes - pnpm routes:generate - echo "" - - # generate envs.js and build the app - dotenv \ - -v NEXT_PUBLIC_GIT_COMMIT_SHA=$(git rev-parse --short HEAD) \ - -v NEXT_PUBLIC_GIT_TAG=$(git describe --tags --abbrev=0) \ - -v NEXT_PUBLIC_ICON_SPRITE_HASH="${NEXT_PUBLIC_ICON_SPRITE_HASH}" \ - "${env_args[@]}" \ - -- bash -c 'source ./deploy/scripts/export_pro_api_flag.sh && ./deploy/scripts/make_envs_script.sh && next build' || exit 1 + # build the app + run_with_envs "source ./deploy/scripts/export_pro_api_flag.sh && next build${build_flags}" || exit 1 echo "" else if [ ! -f ./.env.tmp ]; then - echo "Error: .env.tmp not found. Run a full build first: pnpm prod:preset $preset_name" + echo "Error: .env.tmp not found. Run a full build first: $cmd" exit 1 fi if [ ! -d ./.next ]; then - echo "Error: .next build output not found. Run a full build first: pnpm prod:preset $preset_name" - exit 1 - fi - if [ ! -f ./public/assets/envs.js ]; then - echo "Error: public/assets/envs.js not found. Run a full build first: pnpm prod:preset $preset_name" + echo "Error: .next build output not found. Run a full build first: $cmd" exit 1 fi -fi -# derive the sprite hash from the built sprite file, so the server env matches the build -# (on --skip-build the export from build_sprite.sh is not available) -if [ -z "$NEXT_PUBLIC_ICON_SPRITE_HASH" ]; then - sprite_file=$(ls ./public/icons/sprite.*.svg 2>/dev/null | head -1) - if [ -n "$sprite_file" ]; then - NEXT_PUBLIC_ICON_SPRITE_HASH=$(basename "$sprite_file" | sed -E 's/^sprite\.(.+)\.svg$/\1/') + # derive the sprite hash from the built sprite file, so the server env matches the build + # (skipping the build also skips the export from build_sprite.sh) + if [ -z "$NEXT_PUBLIC_ICON_SPRITE_HASH" ]; then + sprite_file=$(ls ./public/icons/sprite.*.svg 2>/dev/null | head -1) + if [ -n "$sprite_file" ]; then + NEXT_PUBLIC_ICON_SPRITE_HASH=$(basename "$sprite_file" | sed -E 's/^sprite\.(.+)\.svg$/\1/') + fi fi fi -# start the production server -dotenv \ - -v NEXT_PUBLIC_GIT_COMMIT_SHA=$(git rev-parse --short HEAD) \ - -v NEXT_PUBLIC_GIT_TAG=$(git describe --tags --abbrev=0) \ - -v NEXT_PUBLIC_ICON_SPRITE_HASH="${NEXT_PUBLIC_ICON_SPRITE_HASH}" \ - "${env_args[@]}" \ - -- bash -c 'next start -p $NEXT_PUBLIC_APP_PORT' | +# generate envs.js and start the production server. Both steps belong here rather than in the +# build: envs.js is read by the browser at runtime, so regenerating it now is what lets a +# --skip-build run pick up a different --port, and the server itself reads the pro-api flag. +run_with_envs 'source ./deploy/scripts/export_pro_api_flag.sh && ./deploy/scripts/make_envs_script.sh && next start -p $NEXT_PUBLIC_APP_PORT' | pino-pretty diff --git a/tools/dev-server/registry.json b/tools/dev-server/registry.json index f2d9fdb2703..b55eba215c3 100644 --- a/tools/dev-server/registry.json +++ b/tools/dev-server/registry.json @@ -5,13 +5,14 @@ "blackfort_testnet": "https://blackfort-testnet.blockscout.com", "celo": "https://celo.blockscout.com", "celo_sepolia": "https://celo-sepolia.blockscout.com", + "eden_testnet": "https://eden-testnet.blockscout.com", "eth": "https://eth.blockscout.com", "eth_sepolia": "https://eth-sepolia.blockscout.com", "filecoin": "https://filecoin.blockscout.com", "garnet": "https://explorer.garnetchain.com", "gnosis": "https://gnosis.blockscout.com", "gnosis_chiado": "https://gnosis-chiado.blockscout.com", - "hpp": "https://hpp.blockscout.com", + "hpp": "https://explorer.hpp.io", "immutable": "https://explorer.immutable.com", "mega_eth": "https://megaeth.blockscout.com", "multichain": "https://explorer.blockscout.com", diff --git a/tools/dev-server/run_steps.sh b/tools/dev-server/run_steps.sh new file mode 100644 index 00000000000..863e8e9996d --- /dev/null +++ b/tools/dev-server/run_steps.sh @@ -0,0 +1,123 @@ +#!/bin/bash + +# The steps every local run script performs, in the order they perform them: +# layer the env files, regenerate what the app reads from disk at boot, launch. +# Shared by dev.preset.sh, dev.local.sh and prod.preset.sh — what stays in those scripts is +# their argument parsing and the command they finally run. +# +# Must be SOURCED, not executed: prepare_assets sources deploy/scripts/build_sprite.sh so the +# icon sprite hash it exports reaches the launch step, and the functions report results through +# the shell variables named in each comment. All of them assume the repo root is the cwd. + +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + echo "This script must be sourced. Use: source ./tools/dev-server/run_steps.sh" >&2 + exit 1 +fi + +# The launch command is piped into pino-pretty, so without pipefail the script's exit status would +# be pino-pretty's — a failure on the left of the pipe (a broken envs.js, a server that won't boot) +# would report success. Set here because run_with_envs is what establishes that pipe. +set -o pipefail + +port_args=() +env_args=() + +# --port overrides NEXT_PUBLIC_APP_PORT (dotenv-cli applies -v variables AFTER the -e files, +# so this beats every env file). Overriding the env var — not just `next dev -p` — keeps the +# generated envs.js and config.app.baseUrl consistent with the actual port. +# +# Usage: build_port_args "$port" "$usage" — an empty port leaves the env files in charge. +# Sets: port_args +build_port_args() { + local port="$1" usage="$2" + + port_args=() + if [ -z "$port" ]; then + return + fi + + if ! [[ "$port" =~ ^[0-9]+$ ]]; then + echo "🚨 Invalid --port value \"$port\" — expected a number." + echo "$usage" + exit 1 + fi + + port_args+=( -v NEXT_PUBLIC_APP_PORT="$port" ) +} + +# Env files in dotenv-cli precedence order: the FIRST -e file wins, so list highest priority first. +# .env.local (git-ignored, personal local overrides) — optional +# .env.extra (committed branch/feature ENVs, also read by the demo deploy) +# .env.secrets (git-ignored local secrets) — optional; a fetched config already carries public keys +# $1 (the base config: the fetched .env.tmp, or .env.localhost for a local backend) +# +# Usage: build_env_args <base_env_file> +# Sets: env_args +build_env_args() { + env_args=() + if [ -f ./.env.local ]; then + env_args+=( -e ./.env.local ) + fi + env_args+=( -e ./.env.extra ) + if [ -f ./.env.secrets ]; then + env_args+=( -e ./.env.secrets ) + fi + env_args+=( -e "$1" ) +} + +# Regenerates everything the app reads off disk at boot: the downloaded instance assets, the +# generated configs, the icon sprite and the route map. +# +# Usage: prepare_assets [preset_name] — a preset_name matching "multichain" also generates the +# multichain config; omit it for a local-backend run, which has no multichain config. +# Reads: env_args +# Exports: NEXT_PUBLIC_ICON_SPRITE_HASH (via build_sprite.sh) +prepare_assets() { + local preset_name="$1" + + # remove previous assets + rm -rf ./public/assets/configs + rm -rf ./public/assets/multichain + rm -rf ./public/assets/essential-dapps + rm -rf ./public/assets/envs.js + + # download assets for the running instance + dotenv \ + "${env_args[@]}" \ + -- bash -c './deploy/scripts/download_assets.sh ./public/assets/configs' + + # generate multichain config (matches both "multichain" and "staging_multichain") + if [[ "$preset_name" =~ "multichain" ]]; then + dotenv \ + "${env_args[@]}" \ + -- bash -c 'cd deploy/tools/multichain-config-generator && pnpm build && pnpm generate' || exit 1 + fi + + # generate essential dapps chains config if marketplace essential dapps enabled + dotenv \ + "${env_args[@]}" \ + -- bash -c 'cd deploy/tools/essential-dapps-chains-config-generator && pnpm build && pnpm generate' || exit 1 + + source ./deploy/scripts/build_sprite.sh + echo "" + + # generate routes + pnpm routes:generate + echo "" +} + +# Runs a command with the full env: the build-time variables that end up in envs.js (git sha/tag, +# sprite hash), then the env files, then the --port override. +# +# Usage: run_with_envs "<shell command>" — pipe it to pino-pretty for long-running servers; +# one-shot commands (a build) should check the exit code instead. +# Reads: env_args, port_args, NEXT_PUBLIC_ICON_SPRITE_HASH +run_with_envs() { + dotenv \ + -v NEXT_PUBLIC_GIT_COMMIT_SHA=$(git rev-parse --short HEAD) \ + -v NEXT_PUBLIC_GIT_TAG=$(git describe --tags --abbrev=0) \ + -v NEXT_PUBLIC_ICON_SPRITE_HASH="${NEXT_PUBLIC_ICON_SPRITE_HASH}" \ + "${port_args[@]}" \ + "${env_args[@]}" \ + -- bash -c "$1" +} diff --git a/tools/profiling/CONTEXT.md b/tools/profiling/CONTEXT.md index 1598da009a9..4c6793f1643 100644 --- a/tools/profiling/CONTEXT.md +++ b/tools/profiling/CONTEXT.md @@ -1,22 +1,35 @@ # React render profiling — context Tooling for measuring and attributing React render cost of heavy pages (large tables, -long lists). Born out of the July 2026 `TokenTransferTable` optimization (dev 1275→646ms, -prod 554→259ms); kept generic so any page can be profiled the same way. +long lists). ## Files | File | Role | |---|---| -| `profile.preset.sh` | `pnpm profile:preset <alias>` — production build + serve with the profiling `react-dom`, env-wired to a live instance exactly like `pnpm dev:preset` (see `tools/dev-server/CONTEXT.md`). `--skip-build` re-serves the existing build. | | `aggregate-react-profile.mjs` | `pnpm profile:analyze <profile.json> [profileB.json]` — turns a React DevTools Profiler export into a per-component cost table (total self ms / instances / avg). With two files, also prints a delta table. | +## Running a profileable build + +There is no dedicated profiling script — the production run script takes a flag: + +``` +pnpm prod:preset <alias> --profile # fetch envs, build, serve +pnpm prod:preset <alias> --profile --skip-build # re-serve the existing build +``` + +That is the ordinary `pnpm prod:preset` production build + serve (env-wired to a live instance — +see `tools/dev-server/CONTEXT.md` for the env layering and the preset registry), with `--profile` +switching the build to `next build --webpack --profile` so the profiling `react-dom` is aliased in. +With `--skip-build` nothing is rebuilt, so `--profile` has no effect there: you get whatever +flavor is already in `.next` — profileable only if the last full build had the flag. + ## Workflow 1. **Attribute in dev** — `pnpm dev:preset <alias>`, record the scenario in the React DevTools Profiler, export ("Save profile...", the down-arrow button), run `pnpm profile:analyze` on it. Dev numbers are inflated (~2.5× vs prod) but component names are real. -2. **Size in prod** — `pnpm profile:preset <alias>`, record the same scenario. Numbers are close +2. **Size in prod** — `pnpm prod:preset <alias> --profile`, record the same scenario. Numbers are close to what users pay; most names are minified. 3. **Compare** — `pnpm profile:analyze a.json b.json` (e.g. baseline vs patched: `git stash` around the rebuild). Only compare traces from the **same build flavor** — minified names differ across builds. diff --git a/tools/profiling/profile.preset.sh b/tools/profiling/profile.preset.sh deleted file mode 100755 index 6318ee0ca38..00000000000 --- a/tools/profiling/profile.preset.sh +++ /dev/null @@ -1,99 +0,0 @@ -#!/bin/bash - -# Production build + serve with React profiling enabled, wired to a live instance's -# env config the same way as `pnpm dev:preset <alias>` (see tools/dev-server/CONTEXT.md). -# -# Usage: -# pnpm profile:preset <instance_alias> # full: fetch envs, build, serve -# pnpm profile:preset <instance_alias> --skip-build # re-serve the existing profiling build -# -# The build runs `next build --webpack --profile`: -# --profile aliases react-dom to react-dom/profiling, so the React DevTools Profiler -# works against an otherwise production build (minified, prod JSX runtime, no dev-only checks); -# --webpack is used because --profile is guaranteed on the webpack pipeline, while Turbopack's -# (the Next 16 default) support for production profiling is not documented; -# next.config.js maintains both pipelines. -# -# NOTE: most component names in the profiler are minified in this build — use it to SIZE costs, -# and the dev-mode profiler to ATTRIBUTE them by name. - -if [ "$#" -lt 1 ]; then - echo "Usage: pnpm profile:preset <instance_alias> [--skip-build]" - exit 1 -fi - -preset_name="$1" -skip_build=false -if [ "$2" == "--skip-build" ]; then - skip_build=true -fi - -# Fetch the instance config into ./.env.tmp (compile-on-run) -./tools/dev-server/fetch.sh "$preset_name" || exit 1 - -# Env files in dotenv-cli precedence order: the FIRST -e file wins, so list highest priority first. -# .env.local (git-ignored, personal local overrides) — optional -# .env.extra (committed branch/feature ENVs, also read by the demo deploy) -# .env.secrets (git-ignored local secrets) — optional; the fetched config already carries public keys -# .env.tmp (fetched instance config) -env_args=() -if [ -f ./.env.local ]; then - env_args+=( -e ./.env.local ) -fi -env_args+=( -e ./.env.extra ) -if [ -f ./.env.secrets ]; then - env_args+=( -e ./.env.secrets ) -fi -env_args+=( -e ./.env.tmp ) - -if [ "$skip_build" = false ]; then - # remove previous assets - rm -rf ./public/assets/configs - rm -rf ./public/assets/multichain - rm -rf ./public/assets/essential-dapps - rm -rf ./public/assets/envs.js - - # download assets for the running instance - dotenv \ - "${env_args[@]}" \ - -- bash -c './deploy/scripts/download_assets.sh ./public/assets/configs' - - # generate multichain config (matches both "multichain" and "staging_multichain") - if [[ "$preset_name" =~ "multichain" ]]; then - dotenv \ - "${env_args[@]}" \ - -- bash -c 'cd deploy/tools/multichain-config-generator && pnpm build && pnpm generate' || exit 1 - fi - - # generate essential dapps chains config if marketplace essential dapps enabled - dotenv \ - "${env_args[@]}" \ - -- bash -c 'cd deploy/tools/essential-dapps-chains-config-generator && pnpm build && pnpm generate' || exit 1 - - source ./deploy/scripts/build_sprite.sh - echo "" - - # generate routes - pnpm routes:generate - echo "" - - # production build with the profiling react-dom + envs.js for the runtime config - dotenv \ - -v NEXT_PUBLIC_GIT_COMMIT_SHA=$(git rev-parse --short HEAD) \ - -v NEXT_PUBLIC_GIT_TAG=$(git describe --tags --abbrev=0) \ - -v NEXT_PUBLIC_ICON_SPRITE_HASH="${NEXT_PUBLIC_ICON_SPRITE_HASH}" \ - "${env_args[@]}" \ - -- bash -c 'source ./deploy/scripts/export_pro_api_flag.sh && next build --webpack --profile && ./deploy/scripts/make_envs_script.sh' || exit 1 - - echo "" - echo "Profiling build ready. Starting the server..." -fi - -# serve the production build -dotenv \ - -v NEXT_PUBLIC_GIT_COMMIT_SHA=$(git rev-parse --short HEAD) \ - -v NEXT_PUBLIC_GIT_TAG=$(git describe --tags --abbrev=0) \ - -v NEXT_PUBLIC_ICON_SPRITE_HASH="${NEXT_PUBLIC_ICON_SPRITE_HASH}" \ - "${env_args[@]}" \ - -- bash -c 'source ./deploy/scripts/export_pro_api_flag.sh && next start -p $NEXT_PUBLIC_APP_PORT' | -pino-pretty diff --git a/tools/scripts/check-doc-links.mjs b/tools/scripts/check-doc-links.mjs new file mode 100644 index 00000000000..72064f5bd5f --- /dev/null +++ b/tools/scripts/check-doc-links.mjs @@ -0,0 +1,211 @@ +#!/usr/bin/env node + +// Resolves the cross-references in the agent instruction surface: markdown links, heading anchors, and the +// file and directory paths in backticks. These files instruct agents rather than humans, so a reference that +// no longer resolves does not merely read badly — it sends an agent to a file that is not there, and nothing +// else in the toolchain notices. Kept mechanical on purpose: a review agent should spend its judgement on +// what a rule says, not on whether the rule's target still exists. +// +// These documents also carry paths that are *illustrations* rather than references — a template's output +// column, a kind of file that lives in many slices. Checking those would produce noise that trains everyone +// to ignore the checker, so an illustration is exempt where it carries a mark that cannot be read as a +// reference; `ILLUSTRATION_FORMS` below is the only statement of what those marks are. Each exemption is +// scoped to the path itself rather than to its whole line, so a real reference standing beside an +// illustration is still checked, and anything unmarked is read as a reference and has to resolve. +// +// An `e.g.` is deliberately *not* a mark. Prose that introduces a real file as an example is the common +// case by far, and exempting it would leave those references unprotected against a later rename — the drift +// this script exists to catch. An example that names no real file gets a `<placeholder>` segment instead. + +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { readdir, readFile, realpath } from 'node:fs/promises'; +import path from 'node:path'; + +const ROOT = path.resolve(import.meta.dirname, '../..'); + +// The instruction surface: the agent config directories, plus every per-directory CONTEXT.md wherever it +// lives. Task specs are excluded — they describe files that do not exist yet by design. +const ROOTS = [ '.agents', '.claude', '.cursor' ]; +const EXCLUDED = [ '.agents/tasks', '.claude/worktrees' ]; +const SKIPPED_DIRS = new Set([ 'node_modules', '.git', '.next' ]); + +const PATH_EXTENSIONS = /\.(?:md|mdc|mjs|json|jsonc|ya?ml|sh|tsx?)$/; + +const tracked = execFileSync('git', [ 'ls-files' ], { cwd: ROOT, encoding: 'utf8' }).split('\n').filter(Boolean); + +// Derived rather than listed, so a new top-level directory needs no edit here. A path is only expected to +// resolve when its first segment is one of these: `types/api.ts` names a kind of file, `src/api/types.ts` a +// location. +const TOP_LEVEL = new Set(tracked.filter((f) => f.includes('/')).map((f) => f.split('/')[0])); + +async function collectMarkdown(dir, acc = []) { + let entries; + try { + entries = await readdir(path.join(ROOT, dir), { withFileTypes: true }); + } catch { + return acc; // a root absent from this checkout is not an error + } + + for (const entry of entries) { + const rel = path.join(dir, entry.name); + if (EXCLUDED.some((ex) => rel === ex || rel.startsWith(`${ ex }/`))) continue; + + // A symlinked directory (`.claude/skills` → `../.agents/skills`) is not a directory to `readdir`, so the + // walk skips it — correctly, since the walk over `.agents` reaches those files by their real path. + if (entry.isDirectory()) { + if (!SKIPPED_DIRS.has(entry.name)) await collectMarkdown(rel, acc); + } else if (/\.mdc?$/.test(entry.name)) { + acc.push(rel); + } + } + + return acc; +} + +// GitHub's heading slug: lowercase, drop all but word chars, spaces and hyphens, then spaces to hyphens. +const slugify = (heading) => heading + .toLowerCase() + .replace(/`/g, '') + .replace(/[^\w\s-]/g, '') + .trim() + .replace(/\s+/g, '-'); + +async function headingSlugs(absPath) { + const body = await readFile(absPath, 'utf8'); + return new Set( + body.split('\n') + .filter((line) => /^#{1,6}\s/.test(line)) + .map((line) => slugify(line.replace(/^#{1,6}\s+/, ''))), + ); +} + +// Fenced blocks hold example commands and JSON payloads; neither is a reference. +function withoutFences(body) { + let inFence = false; + return body.split('\n').map((line) => { + if (/^\s*```/.test(line)) { + inFence = !inFence; + return ''; + } + return inFence ? '' : line; + }); +} + +// Stated once, and printed on failure so the convention reaches an author at the moment they trip it rather +// than in a document they would have to know to read. +const ILLUSTRATION_FORMS = 'give it a <placeholder> segment, or place it after a → in a table row as the ' + + 'output of the pattern before it'; + +// Blanks the marked regions and leaves the rest of the line checkable. The arrow form is confined to table +// rows, where a cell pairs a pattern with its filled-in output; in prose an arrow is ordinary punctuation +// and the path after it is a reference like any other. The `<placeholder>` form is per-path, below. +const withoutIllustrations = (line) => (/^\s*\|/.test(line) ? line.replace(/→[^|]*/g, '') : line); + +const isPlaceholder = (target) => /[<>{}*]|__/.test(target); + +// Resolved against the file's realpath, so a symlinked entry point (`.claude/CLAUDE.md` → `.agents/AGENTS.md`) +// resolves its relative links from where the file really lives. +function resolves(realDir, target) { + const clean = target.replace(/^\.\//, ''); + return [ path.resolve(realDir, clean), path.resolve(ROOT, clean) ].find((c) => existsSync(c)); +} + +// A path that resolves nowhere may still name a real file written short — `toolkit/theme/theme.ts` for +// `src/toolkit/theme/theme.ts`. Reported only when exactly one tracked file ends with it, which makes the +// intended file certain; `types/api.ts` matches thirty of them and so asserts no single location to check. +// Files only: a bare directory such as `hooks/` names a convention every slice follows, not one location, +// and nothing distinguishes that from shorthand. +function shorthandFor(target) { + const matches = tracked.filter((f) => f.endsWith(`/${ target }`)); + return matches.length === 1 ? matches[0] : undefined; +} + +// A path whose parent directory sits beside the file was written relative to it — `components/Provider.tsx` +// in a CONTEXT.md is a reference to a neighbour, and its absence is a break. Without this the whole relative +// class goes unprotected: once broken, such a path is indistinguishable from `types/api.ts` naming a kind of +// file, so a renamed neighbour would fail silently. +function nearby(realDir, target) { + const parent = path.dirname(target); + return parent !== '.' && !target.endsWith('/') && existsSync(path.resolve(realDir, parent)); +} + +async function checkFile(fileRel, failures) { + const realDir = path.dirname(await realpath(path.join(ROOT, fileRel))); + const lines = withoutFences(await readFile(path.join(ROOT, fileRel), 'utf8')); + + for (const [ index, rawLine ] of lines.entries()) { + const report = (message) => failures.push({ file: fileRel, line: index + 1, message }); + const checkable = withoutIllustrations(rawLine); + + // Backtick paths. Collected first, then stripped, so an inline code span holding a markdown-link + // example — `[Link Text](URL)` — is not read as a link. + const spans = [ ...checkable.matchAll(/`([^`]+)`/g) ].map((m) => m[1]); + const line = checkable.replace(/`[^`]+`/g, ''); + + for (const target of spans) { + if (/\s/.test(target) || isPlaceholder(target) || !target.includes('/')) continue; + // A trailing slash marks a directory, a known extension marks a file. Anything else in backticks — a + // config key, a dotted token, a fragment of prose — is not a path. + if (!target.endsWith('/') && !PATH_EXTENSIONS.test(target)) continue; + if (resolves(realDir, target)) continue; + + const full = shorthandFor(target); + if (full) { + report(`${ target } is shorthand; write it in full: ${ full }`); + } else if (target.startsWith('./') || TOP_LEVEL.has(target.split('/')[0]) || nearby(realDir, target)) { + report(`path reference does not exist: ${ target }`); + } + } + + // Markdown links, with an optional heading anchor. + for (const [ , target ] of line.matchAll(/\[[^\]]*\]\(([^)\s]+)\)/g)) { + if (/^(?:https?:|mailto:|#)/.test(target) || isPlaceholder(target)) continue; + + const [ filePart, anchor ] = target.split('#'); + // A link with neither a slash nor a file extension is prose in brackets, not a path. + if (!filePart.includes('/') && !PATH_EXTENSIONS.test(filePart)) continue; + + const resolved = resolves(realDir, filePart); + if (!resolved) { + report(`link target does not exist: ${ target }`); + } else if (anchor && resolved.endsWith('.md')) { + const slugs = await headingSlugs(resolved); + if (!slugs.has(anchor.toLowerCase())) report(`heading anchor not found in target: ${ target }`); + } + } + } +} + +const walked = (await Promise.all(ROOTS.map((r) => collectMarkdown(r)))).flat(); +const contexts = tracked.filter((f) => path.basename(f) === 'CONTEXT.md'); + +// One entry per real file. `.cursor/rules/*.mdc` and `.claude/CLAUDE.md` are symlinks onto files the walk +// already reached under `.agents`, and checking a file twice reports each of its findings twice. +const seen = new Map(); +for (const rel of [ ...walked, ...contexts ]) { + const real = await realpath(path.join(ROOT, rel)); + if (!seen.has(real)) seen.set(real, rel); +} +const files = [ ...seen.values() ]; + +const failures = []; +for (const file of files) { + await checkFile(file, failures); +} + +if (failures.length > 0) { + for (const { file, line, message } of failures) { + // eslint-disable-next-line no-console + console.error(`${ file }:${ line } — ${ message }`); + } + // eslint-disable-next-line no-console + console.error( + `\n${ failures.length } unresolved reference(s) across ${ files.length } file(s).\n` + + `A path naming a shape rather than a file has to be marked as one, or it is read as a reference: ${ ILLUSTRATION_FORMS }.`, + ); + process.exit(1); +} + +// eslint-disable-next-line no-console +console.log(`Doc links: ${ files.length } files checked, every reference resolves.`); diff --git a/tools/scripts/pw.docker.deps.sh b/tools/scripts/pw.docker.deps.sh index e7013aa880e..d3b485c470d 100755 --- a/tools/scripts/pw.docker.deps.sh +++ b/tools/scripts/pw.docker.deps.sh @@ -19,4 +19,4 @@ export npm_config_prefer_offline=true # Non-interactive install in Docker (pnpm 11 may prompt to purge node_modules otherwise). export CI=true -pnpm install --modules-dir node_modules_linux --config.confirm-modules-purge=true \ No newline at end of file +pnpm install --modules-dir node_modules_linux --store-dir /pnpm-store --config.confirm-modules-purge=true \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index debd99855d2..dbc04cd2c37 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,7 +30,7 @@ ] }, "include": [ - "next-env.d.ts", + "next-types.d.ts", "**/*.ts", "**/*.node.ts", "**/*.tsx", diff --git a/vitest/lib.tsx b/vitest/lib.tsx index b79adc02007..e7fd2269fc1 100644 --- a/vitest/lib.tsx +++ b/vitest/lib.tsx @@ -49,9 +49,14 @@ const wagmiConfig = createConfig({ }, }); -// The full app provider stack (mirrors playwright/TestApp.tsx; socket and wallet client stay -// inert) — heavy enough to mount whole page slices in jsdom. -const TestApp = ({ children }: { children: React.ReactNode }) => { +interface TestAppProps { + children: React.ReactNode; + socketUrl?: string; +} + +// The full app provider stack (mirrors playwright/TestApp.tsx; the wallet client stays inert, and +// so does the socket unless `socketUrl` is given) — heavy enough to mount whole page slices in jsdom. +const TestApp = ({ children, socketUrl }: TestAppProps) => { const [ queryClient ] = React.useState(() => new QueryClient({ defaultOptions: { queries: { @@ -64,7 +69,7 @@ const TestApp = ({ children }: { children: React.ReactNode }) => { return ( <ChakraProvider> <QueryClientProvider client={ queryClient }> - <SocketProvider url={ undefined }> + <SocketProvider url={ socketUrl }> <AppContextProvider pageProps={ PAGE_PROPS }> <MarketplaceContext.Provider value={ marketplaceContext }> <SettingsContextProvider> diff --git a/vitest/utils/checkPrimedRequests.tsx b/vitest/utils/checkPrimedRequests.tsx index 5a563d843c3..5bcae06e480 100644 --- a/vitest/utils/checkPrimedRequests.tsx +++ b/vitest/utils/checkPrimedRequests.tsx @@ -9,6 +9,7 @@ import { expect, it, vi } from 'vitest'; import flushPromises from './flushPromises'; import withEnvs from './mockEnvs'; import { mockNextRouter } from './mockRouter'; +import { mockSocket, MOCK_SOCKET_URL } from './mockSocket'; // Drift check for the early-fetch primer (src/server/primedRequests): asserts that every // request the primer would fire for a page is also fired — with byte-identical URL and @@ -58,6 +59,9 @@ export default function checkPrimedRequests(params: CheckPrimedRequestsParams) { // register the router mock AFTER withEnvs' resetModules so the subsequent dynamic // imports of Layout / page components pick it up (doMock before resetModules races) mockNextRouter(params.page, params.url); + // pages gate some queries on their socket channel joining; the mock joins right away so + // those requests belong to the first render here, as they effectively do in a browser + mockSocket(); const { getPrimerScript } = await import('src/server/primedRequests'); @@ -86,7 +90,7 @@ export default function checkPrimedRequests(params: CheckPrimedRequestsParams) { params.loadComponent(), ]); - const { unmount } = render(<TestApp><Component/></TestApp>); + const { unmount } = render(<TestApp socketUrl={ MOCK_SOCKET_URL }><Component/></TestApp>); try { // let mount effects fire their queries and any second-wave queries subscribe @@ -120,6 +124,7 @@ export default function checkPrimedRequests(params: CheckPrimedRequestsParams) { }); } finally { vi.doUnmock('next/router'); + vi.doUnmock('phoenix'); fetchMock.resetMocks(); window.__primedFetches = undefined; window.history.replaceState(null, '', '/'); diff --git a/vitest/utils/mockSocket.ts b/vitest/utils/mockSocket.ts new file mode 100644 index 00000000000..4332b2d9ce4 --- /dev/null +++ b/vitest/utils/mockSocket.ts @@ -0,0 +1,64 @@ +import { vi } from 'vitest'; + +// Phoenix socket mocking for Vitest. +// +// Replaces the transport only — `SocketProvider`, `useSocketChannel` and `useSocketMessage` stay +// real. Channels join successfully, so the queries a page enables from an `onJoin` callback run +// here the way they do in a browser. Server-sent events are not simulated: subscriptions made via +// `channel.on` are accepted and never fire. +// +// Uses `vi.doMock`, which applies only to modules imported AFTER the call — pair it with +// `resetModules` + dynamic imports (checkPrimedRequests.tsx), and clean up with +// `vi.doUnmock('phoenix')`. Mounting under `vitest/lib`'s TestApp additionally requires passing +// `socketUrl={ MOCK_SOCKET_URL }`, since the provider skips socket creation without a url. + +/** any non-empty url works — the mocked socket never opens a connection */ +export const MOCK_SOCKET_URL = 'wss://localhost/socket'; + +interface MockPush { + receive: (status: string, callback: (response: unknown) => void) => MockPush; +} + +function createMockPush(): MockPush { + const push: MockPush = { + receive: (status, callback) => { + if (status === 'ok') { + // a real join never resolves synchronously; keep the callback off the caller's stack so + // the whole `.receive()` chain is set up before any of it runs + queueMicrotask(() => callback({})); + } + return push; + }, + }; + + return push; +} + +function createMockChannel() { + let nextHandlerRef = 0; + + return { + join: createMockPush, + leave: createMockPush, + push: createMockPush, + on: () => nextHandlerRef++, + off: () => {}, + }; +} + +export function mockSocket() { + let nextListenerRef = 0; + const createListenerRef = () => String(nextListenerRef++); + + class MockSocketClass { + connect() {} + disconnect() {} + onOpen = createListenerRef; + onClose = createListenerRef; + onError = createListenerRef; + off() {} + channel = createMockChannel; + } + + vi.doMock('phoenix', () => ({ Socket: MockSocketClass })); +}