From b03e3c4e814eed7422d6b2825078ada35f996a79 Mon Sep 17 00:00:00 2001 From: Milton Tulli Date: Tue, 3 Mar 2026 23:12:22 -0300 Subject: [PATCH 01/54] feat(network): add Avalanche C-Chain (43114) network support Add support for Avalanche C-Chain, a standard EVM-compatible chain with high throughput. Implements network configuration, RPC client initialization (via direct EthereumClient instantiation since not in ClientFactory), and adapter routing. Closes #113. - Add Avalanche C-Chain entry to networks.json with CAIP-2 identifier and metadata - Update DataService to create EthereumClient for unsupported chains - Update AdapterFactory and NetworkAdapter to handle chain 43114 - Route to EVMAdapter (standard EVM implementation) --- bun.lock | 1 - src/config/networks.json | 29 +++++++++++++++++++ src/services/DataService.ts | 25 ++++++++++++---- .../adapters/EVMAdapter/EVMAdapter.ts | 2 +- src/services/adapters/NetworkAdapter.ts | 2 +- src/services/adapters/adaptersFactory.ts | 3 +- 6 files changed, 52 insertions(+), 10 deletions(-) diff --git a/bun.lock b/bun.lock index 780aa3d3..dd9e82c3 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "openscan", diff --git a/src/config/networks.json b/src/config/networks.json index 3bec09b4..a7b49e61 100644 --- a/src/config/networks.json +++ b/src/config/networks.json @@ -185,6 +185,35 @@ } ] }, + { + "type": "evm", + "networkId": "eip155:43114", + "slug": "avax", + "name": "Avalanche C-Chain", + "shortName": "Avalanche", + "description": "EVM-compatible smart contract platform with high throughput", + "currency": "AVAX", + "color": "#E84142", + "isTestnet": false, + "logo": "assets/networks/43114.svg", + "links": [ + { + "name": "Website", + "url": "https://www.avax.network", + "description": "Official Avalanche website" + }, + { + "name": "Docs", + "url": "https://docs.avax.network", + "description": "Developer documentation" + }, + { + "name": "Bridge", + "url": "https://core.app/bridge", + "description": "Bridge assets to Avalanche" + } + ] + }, { "type": "evm", "networkId": "eip155:11155111", diff --git a/src/services/DataService.ts b/src/services/DataService.ts index 2d8ad997..b45fd42f 100644 --- a/src/services/DataService.ts +++ b/src/services/DataService.ts @@ -1,4 +1,9 @@ -import { type SupportedChainId, ClientFactory, BitcoinClient } from "@openscan/network-connectors"; +import { + type SupportedChainId, + ClientFactory, + BitcoinClient, + EthereumClient, +} from "@openscan/network-connectors"; import { AdapterFactory } from "./adapters/adaptersFactory"; import type { NetworkAdapter } from "./adapters/NetworkAdapter"; @@ -42,11 +47,19 @@ export class DataService { } else { // Create EVM client and adapter const chainId = getChainIdFromNetwork(network) as SupportedChainId; - const networkClient = ClientFactory.createTypedClient(chainId, { - rpcUrls, - type: strategy, - }); - this.networkAdapter = AdapterFactory.createAdapter(chainId, networkClient); + + // Chains not registered in ClientFactory need a direct EthereumClient + const unsupportedByFactory = [43114]; + if (unsupportedByFactory.includes(chainId)) { + const networkClient = new EthereumClient({ rpcUrls, type: strategy }); + this.networkAdapter = AdapterFactory.createAdapter(chainId, networkClient); + } else { + const networkClient = ClientFactory.createTypedClient(chainId, { + rpcUrls, + type: strategy, + }); + this.networkAdapter = AdapterFactory.createAdapter(chainId, networkClient); + } } } diff --git a/src/services/adapters/EVMAdapter/EVMAdapter.ts b/src/services/adapters/EVMAdapter/EVMAdapter.ts index d4e4cded..667274cd 100644 --- a/src/services/adapters/EVMAdapter/EVMAdapter.ts +++ b/src/services/adapters/EVMAdapter/EVMAdapter.ts @@ -22,7 +22,7 @@ import { NonceLookupService } from "../../NonceLookupService"; export class EVMAdapter extends NetworkAdapter { private client: EthereumClient; - constructor(networkId: SupportedChainId | 11155111 | 97 | 31337, client: EthereumClient) { + constructor(networkId: SupportedChainId | 11155111 | 97 | 31337 | 43114, client: EthereumClient) { super(networkId); this.client = client; diff --git a/src/services/adapters/NetworkAdapter.ts b/src/services/adapters/NetworkAdapter.ts index 1dcb2f3b..9cb08abc 100644 --- a/src/services/adapters/NetworkAdapter.ts +++ b/src/services/adapters/NetworkAdapter.ts @@ -51,7 +51,7 @@ export abstract class NetworkAdapter { isLocalHost: boolean; protected txSearch: AddressTransactionSearch | null = null; - constructor(networkId: SupportedChainId | 31337 | 11155111 | 97) { + constructor(networkId: SupportedChainId | 31337 | 11155111 | 97 | 43114) { this.networkId = networkId; this.isLocalHost = networkId === 31337; } diff --git a/src/services/adapters/adaptersFactory.ts b/src/services/adapters/adaptersFactory.ts index dd98c2f7..364ac322 100644 --- a/src/services/adapters/adaptersFactory.ts +++ b/src/services/adapters/adaptersFactory.ts @@ -24,7 +24,7 @@ export class AdapterFactory { * Create an EVM network adapter */ static createAdapter( - networkId: SupportedChainId | 11155111 | 97 | 31337, + networkId: SupportedChainId | 11155111 | 97 | 31337 | 43114, client: | EthereumClient | OptimismClient @@ -38,6 +38,7 @@ export class AdapterFactory { case 1: case 11155111: case 31337: + case 43114: return new EVMAdapter(networkId, client as EthereumClient); case 10: return new OptimismAdapter(networkId, client as OptimismClient); From c448dad6485132fda16a8378a1f96600161e4ac7 Mon Sep 17 00:00:00 2001 From: Milton Tulli Date: Tue, 3 Mar 2026 23:28:42 -0300 Subject: [PATCH 02/54] refactor(types): introduce AppChainId type for unified chain ID handling Consolidate scattered union types (SupportedChainId | 11155111 | 97 | 31337 | 43114) into a single AppChainId type, making it easier to add new networks. --- src/services/DataService.ts | 15 ++++++++------- src/services/adapters/EVMAdapter/EVMAdapter.ts | 5 +++-- src/services/adapters/NetworkAdapter.ts | 5 +++-- src/services/adapters/adaptersFactory.ts | 4 ++-- src/types/index.ts | 9 ++++++++- 5 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/services/DataService.ts b/src/services/DataService.ts index b45fd42f..07fcca1b 100644 --- a/src/services/DataService.ts +++ b/src/services/DataService.ts @@ -8,7 +8,7 @@ import { import { AdapterFactory } from "./adapters/adaptersFactory"; import type { NetworkAdapter } from "./adapters/NetworkAdapter"; import type { BitcoinAdapter } from "./adapters/BitcoinAdapter/BitcoinAdapter"; -import type { NetworkConfig, RpcUrlsContextType } from "../types"; +import type { AppChainId, NetworkConfig, RpcUrlsContextType } from "../types"; import { getRPCUrls } from "../config/rpcConfig"; import { getNetworkRpcKey, getChainIdFromNetwork } from "../utils/networkResolver"; @@ -46,18 +46,19 @@ export class DataService { this.networkAdapter = null as unknown as NetworkAdapter; } else { // Create EVM client and adapter - const chainId = getChainIdFromNetwork(network) as SupportedChainId; + const chainId = getChainIdFromNetwork(network) as AppChainId; // Chains not registered in ClientFactory need a direct EthereumClient - const unsupportedByFactory = [43114]; + const unsupportedByFactory: number[] = [43114]; if (unsupportedByFactory.includes(chainId)) { const networkClient = new EthereumClient({ rpcUrls, type: strategy }); this.networkAdapter = AdapterFactory.createAdapter(chainId, networkClient); } else { - const networkClient = ClientFactory.createTypedClient(chainId, { - rpcUrls, - type: strategy, - }); + const factoryChainId = chainId as SupportedChainId; + const networkClient = ClientFactory.createTypedClient( + factoryChainId, + { rpcUrls, type: strategy }, + ); this.networkAdapter = AdapterFactory.createAdapter(chainId, networkClient); } } diff --git a/src/services/adapters/EVMAdapter/EVMAdapter.ts b/src/services/adapters/EVMAdapter/EVMAdapter.ts index 667274cd..a0d472dc 100644 --- a/src/services/adapters/EVMAdapter/EVMAdapter.ts +++ b/src/services/adapters/EVMAdapter/EVMAdapter.ts @@ -11,7 +11,8 @@ import { import { normalizeBlockNumber } from "../shared/normalizeBlockNumber"; import { mergeMetadata } from "../shared/mergeMetadata"; -import type { EthereumClient, SupportedChainId } from "@openscan/network-connectors"; +import type { EthereumClient } from "@openscan/network-connectors"; +import type { AppChainId } from "../../../types"; import { getRethClient, NONCE_LOOKUP_CHAIN_ID } from "../../../config/rethProviders"; import { NonceLookupService } from "../../NonceLookupService"; @@ -22,7 +23,7 @@ import { NonceLookupService } from "../../NonceLookupService"; export class EVMAdapter extends NetworkAdapter { private client: EthereumClient; - constructor(networkId: SupportedChainId | 11155111 | 97 | 31337 | 43114, client: EthereumClient) { + constructor(networkId: AppChainId, client: EthereumClient) { super(networkId); this.client = client; diff --git a/src/services/adapters/NetworkAdapter.ts b/src/services/adapters/NetworkAdapter.ts index 9cb08abc..b022d8b5 100644 --- a/src/services/adapters/NetworkAdapter.ts +++ b/src/services/adapters/NetworkAdapter.ts @@ -1,5 +1,6 @@ -import type { SupportedChainId, EthereumClient } from "@openscan/network-connectors"; +import type { EthereumClient } from "@openscan/network-connectors"; import type { + AppChainId, Block, Transaction, Address, @@ -51,7 +52,7 @@ export abstract class NetworkAdapter { isLocalHost: boolean; protected txSearch: AddressTransactionSearch | null = null; - constructor(networkId: SupportedChainId | 31337 | 11155111 | 97 | 43114) { + constructor(networkId: AppChainId) { this.networkId = networkId; this.isLocalHost = networkId === 31337; } diff --git a/src/services/adapters/adaptersFactory.ts b/src/services/adapters/adaptersFactory.ts index 364ac322..7dfac01f 100644 --- a/src/services/adapters/adaptersFactory.ts +++ b/src/services/adapters/adaptersFactory.ts @@ -15,8 +15,8 @@ import type { EthereumClient, OptimismClient, PolygonClient, - SupportedChainId, } from "@openscan/network-connectors"; +import type { AppChainId } from "../../types"; // biome-ignore lint/complexity/noStaticOnlyClass: export class AdapterFactory { @@ -24,7 +24,7 @@ export class AdapterFactory { * Create an EVM network adapter */ static createAdapter( - networkId: SupportedChainId | 11155111 | 97 | 31337 | 43114, + networkId: AppChainId, client: | EthereumClient | OptimismClient diff --git a/src/types/index.ts b/src/types/index.ts index 2abb8c9c..77a7751f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,4 @@ -import type { EthLog } from "@openscan/network-connectors"; +import type { EthLog, SupportedChainId } from "@openscan/network-connectors"; import type React from "react"; // ==================== NETWORK TYPES ==================== @@ -8,6 +8,13 @@ import type React from "react"; */ export type NetworkType = "evm" | "bitcoin"; +/** + * All EVM chain IDs supported by the app. + * Extends the connector library's SupportedChainId with additional chains. + * When adding a new EVM network, add its chain ID here. + */ +export type AppChainId = SupportedChainId | 43114; + // ==================== CORE DOMAIN TYPES ==================== export interface NetworkStats { From 13f2742a1a4624c8b0bf6f782af460ce2b6bfd96 Mon Sep 17 00:00:00 2001 From: Milton Tulli Date: Mon, 16 Mar 2026 11:26:33 -0300 Subject: [PATCH 03/54] refactor(network): use network-connectors v1.4.0 native Avalanche support Update @openscan/network-connectors to v1.4.0 which includes native AvalancheClient (43114). Remove the EthereumClient workaround for unsupported chains and use ClientFactory directly. Simplify AppChainId type since 43114 is now part of SupportedChainId. --- bun.lock | 4 ++-- package.json | 2 +- src/services/DataService.ts | 30 +++++++----------------- src/services/adapters/adaptersFactory.ts | 8 ++++--- src/types/index.ts | 6 ++--- 5 files changed, 19 insertions(+), 31 deletions(-) diff --git a/bun.lock b/bun.lock index dd9e82c3..df7592c8 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "name": "openscan", "dependencies": { "@erc7730/sdk": "^0.1.3", - "@openscan/network-connectors": "1.3.2", + "@openscan/network-connectors": "1.4.0", "@rainbow-me/rainbowkit": "^2.2.8", "@react-native-async-storage/async-storage": "^1.24.0", "@tanstack/react-query": "^5.90.21", @@ -279,7 +279,7 @@ "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], - "@openscan/network-connectors": ["@openscan/network-connectors@1.3.2", "", {}, "sha512-OdH+PqP/VNYkPrXBCaMhjNF2FQ5N5WH9wd9uGelgkCvbXXS0xXRC4PlPqWSSXqjZJUud0HAGDF5pbZfxIPFQnQ=="], + "@openscan/network-connectors": ["@openscan/network-connectors@1.4.0", "", {}, "sha512-a27b86OBZCXtCI5iKYgttXiEyG9I7NQ5QpRp2PQGydbBMrTF1U4XI3VcJJFm14FD9z8L5WW+VEZ8f7cglh3HPA=="], "@paulmillr/qr": ["@paulmillr/qr@0.2.1", "", {}, "sha512-IHnV6A+zxU7XwmKFinmYjUcwlyK9+xkG3/s9KcQhI9BjQKycrJ1JRO+FbNYPwZiPKW3je/DR0k7w8/gLa5eaxQ=="], diff --git a/package.json b/package.json index b8ca7382..d8af2777 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "dependencies": { "@erc7730/sdk": "^0.1.3", - "@openscan/network-connectors": "1.3.2", + "@openscan/network-connectors": "1.4.0", "@rainbow-me/rainbowkit": "^2.2.8", "@react-native-async-storage/async-storage": "^1.24.0", "@tanstack/react-query": "^5.90.21", diff --git a/src/services/DataService.ts b/src/services/DataService.ts index 07fcca1b..2d8ad997 100644 --- a/src/services/DataService.ts +++ b/src/services/DataService.ts @@ -1,14 +1,9 @@ -import { - type SupportedChainId, - ClientFactory, - BitcoinClient, - EthereumClient, -} from "@openscan/network-connectors"; +import { type SupportedChainId, ClientFactory, BitcoinClient } from "@openscan/network-connectors"; import { AdapterFactory } from "./adapters/adaptersFactory"; import type { NetworkAdapter } from "./adapters/NetworkAdapter"; import type { BitcoinAdapter } from "./adapters/BitcoinAdapter/BitcoinAdapter"; -import type { AppChainId, NetworkConfig, RpcUrlsContextType } from "../types"; +import type { NetworkConfig, RpcUrlsContextType } from "../types"; import { getRPCUrls } from "../config/rpcConfig"; import { getNetworkRpcKey, getChainIdFromNetwork } from "../utils/networkResolver"; @@ -46,21 +41,12 @@ export class DataService { this.networkAdapter = null as unknown as NetworkAdapter; } else { // Create EVM client and adapter - const chainId = getChainIdFromNetwork(network) as AppChainId; - - // Chains not registered in ClientFactory need a direct EthereumClient - const unsupportedByFactory: number[] = [43114]; - if (unsupportedByFactory.includes(chainId)) { - const networkClient = new EthereumClient({ rpcUrls, type: strategy }); - this.networkAdapter = AdapterFactory.createAdapter(chainId, networkClient); - } else { - const factoryChainId = chainId as SupportedChainId; - const networkClient = ClientFactory.createTypedClient( - factoryChainId, - { rpcUrls, type: strategy }, - ); - this.networkAdapter = AdapterFactory.createAdapter(chainId, networkClient); - } + const chainId = getChainIdFromNetwork(network) as SupportedChainId; + const networkClient = ClientFactory.createTypedClient(chainId, { + rpcUrls, + type: strategy, + }); + this.networkAdapter = AdapterFactory.createAdapter(chainId, networkClient); } } diff --git a/src/services/adapters/adaptersFactory.ts b/src/services/adapters/adaptersFactory.ts index 7dfac01f..7379a3ed 100644 --- a/src/services/adapters/adaptersFactory.ts +++ b/src/services/adapters/adaptersFactory.ts @@ -8,6 +8,7 @@ import { ArbitrumAdapter } from "./ArbitrumAdapter/ArbitrumAdapter"; import { BitcoinAdapter } from "./BitcoinAdapter/BitcoinAdapter"; import type { ArbitrumClient, + AvalancheClient, AztecClient, BaseClient, BitcoinClient, @@ -15,8 +16,8 @@ import type { EthereumClient, OptimismClient, PolygonClient, + SupportedChainId, } from "@openscan/network-connectors"; -import type { AppChainId } from "../../types"; // biome-ignore lint/complexity/noStaticOnlyClass: export class AdapterFactory { @@ -24,7 +25,7 @@ export class AdapterFactory { * Create an EVM network adapter */ static createAdapter( - networkId: AppChainId, + networkId: SupportedChainId, client: | EthereumClient | OptimismClient @@ -32,6 +33,7 @@ export class AdapterFactory { | PolygonClient | BaseClient | ArbitrumClient + | AvalancheClient | AztecClient, ): NetworkAdapter { switch (networkId) { @@ -39,7 +41,7 @@ export class AdapterFactory { case 11155111: case 31337: case 43114: - return new EVMAdapter(networkId, client as EthereumClient); + return new EVMAdapter(networkId, client as unknown as EthereumClient); case 10: return new OptimismAdapter(networkId, client as OptimismClient); case 56: diff --git a/src/types/index.ts b/src/types/index.ts index 77a7751f..8daf8c5e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -10,10 +10,10 @@ export type NetworkType = "evm" | "bitcoin"; /** * All EVM chain IDs supported by the app. - * Extends the connector library's SupportedChainId with additional chains. - * When adding a new EVM network, add its chain ID here. + * Maps directly to the connector library's SupportedChainId. + * When adding a new EVM network, add its chain ID to network-connectors first. */ -export type AppChainId = SupportedChainId | 43114; +export type AppChainId = SupportedChainId; // ==================== CORE DOMAIN TYPES ==================== From cda55a3e54fb615d6d663641de2debfb1754bc86 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Wed, 18 Mar 2026 16:43:42 -0300 Subject: [PATCH 04/54] feat(tooltips): add helper tooltip system with knowledge-level support Add a reusable helper tooltip infrastructure that provides contextual explanations for blockchain data fields across all explorer pages. Infrastructure: - HelperTooltip component: hover/focus/tap with a11y support - FieldLabel wrapper: knowledge-level-aware tooltip visibility - Settings UI: enable/disable toggle + beginner/intermediate/advanced selector - Navbar quick-switcher: cycle through levels with colored border indicator - New `tooltips` i18n namespace with content in all 5 languages Coverage: - EVM transaction page: all fields including L2 (Arbitrum, OP Stack) and blob fields - EVM block page: all fields including More Details (advanced-only) and Arbitrum fields - Address/contract pages: verification, proxy, read/write, balance, nonce, ENS, EIP-7702 - Token/NFT pages: standard, decimals, supply, token ID, owner, approved, metadata URI - Bitcoin transaction/block/address pages: all fields with BTC-specific content - Settings page: knowledge-level selector tooltip --- src/App.tsx | 1 + src/components/common/FieldLabel.tsx | 35 ++++ src/components/common/HelperTooltip.tsx | 168 ++++++++++++++++ src/components/navbar/index.tsx | 85 +++++++- .../pages/bitcoin/BitcoinAddressDisplay.tsx | 27 ++- .../pages/bitcoin/BitcoinBlockDisplay.tsx | 124 ++++++++++-- .../bitcoin/BitcoinTransactionDisplay.tsx | 109 ++++++++-- .../address/shared/AccountMoreInfoCard.tsx | 15 +- .../address/shared/AccountOverviewCard.tsx | 29 ++- .../evm/address/shared/ContractInfoCard.tsx | 22 +- .../address/shared/ContractInteraction.tsx | 10 + .../address/shared/ContractMoreInfoCard.tsx | 15 +- .../evm/address/shared/ERC20TokenInfoCard.tsx | 15 +- .../address/shared/NFTCollectionInfoCard.tsx | 22 +- .../pages/evm/block/BlockDisplay.tsx | 189 +++++++++++++++--- .../evm/tokenDetails/ERC1155TokenDisplay.tsx | 13 +- .../evm/tokenDetails/ERC721TokenDisplay.tsx | 31 ++- .../pages/evm/tx/TransactionDisplay.tsx | 151 +++++++++++--- src/components/pages/evm/tx/TxAnalyser.tsx | 5 + src/components/pages/settings/index.tsx | 72 +++++++ src/i18n.ts | 11 + src/i18next.d.ts | 2 + src/locales/en/common.json | 9 +- src/locales/en/settings.json | 15 ++ src/locales/en/tooltips.json | 128 ++++++++++++ src/locales/es/common.json | 9 +- src/locales/es/settings.json | 15 ++ src/locales/es/tooltips.json | 128 ++++++++++++ src/locales/ja/common.json | 9 +- src/locales/ja/settings.json | 15 ++ src/locales/ja/tooltips.json | 128 ++++++++++++ src/locales/pt-BR/common.json | 9 +- src/locales/pt-BR/settings.json | 15 ++ src/locales/pt-BR/tooltips.json | 128 ++++++++++++ src/locales/zh/common.json | 9 +- src/locales/zh/settings.json | 15 ++ src/locales/zh/tooltips.json | 128 ++++++++++++ src/styles/helper-tooltip.css | 128 ++++++++++++ src/styles/styles.css | 3 +- src/types/index.ts | 9 + 40 files changed, 1925 insertions(+), 126 deletions(-) create mode 100644 src/components/common/FieldLabel.tsx create mode 100644 src/components/common/HelperTooltip.tsx create mode 100644 src/locales/en/tooltips.json create mode 100644 src/locales/es/tooltips.json create mode 100644 src/locales/ja/tooltips.json create mode 100644 src/locales/pt-BR/tooltips.json create mode 100644 src/locales/zh/tooltips.json create mode 100644 src/styles/helper-tooltip.css diff --git a/src/App.tsx b/src/App.tsx index d09f0a50..3a70f196 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import "./styles/rainbowkit.css"; import "./styles/responsive.css"; import "./styles/ai-analysis.css"; import "./styles/rpcs.css"; +import "./styles/helper-tooltip.css"; import Loading from "./components/common/Loading"; import { diff --git a/src/components/common/FieldLabel.tsx b/src/components/common/FieldLabel.tsx new file mode 100644 index 00000000..1f01b48d --- /dev/null +++ b/src/components/common/FieldLabel.tsx @@ -0,0 +1,35 @@ +import { useTranslation } from "react-i18next"; +import type { KnowledgeLevel } from "../../types"; +import { useSettings } from "../../context/SettingsContext"; +import HelperTooltip from "./HelperTooltip"; + +interface FieldLabelProps { + label: string; + tooltipKey?: string; + visibleFor?: KnowledgeLevel[]; + className?: string; +} + +const FieldLabel: React.FC = ({ + label, + tooltipKey, + visibleFor, + className = "tx-label", +}) => { + const { settings } = useSettings(); + const { t } = useTranslation("tooltips"); + + const level = settings.knowledgeLevel ?? "beginner"; + const tooltipsEnabled = settings.showHelperTooltips !== false; + + const shouldShow = tooltipsEnabled && tooltipKey && (!visibleFor || visibleFor.includes(level)); + + return ( + + {label} + {shouldShow && } + + ); +}; + +export default FieldLabel; diff --git a/src/components/common/HelperTooltip.tsx b/src/components/common/HelperTooltip.tsx new file mode 100644 index 00000000..35a52174 --- /dev/null +++ b/src/components/common/HelperTooltip.tsx @@ -0,0 +1,168 @@ +import { useCallback, useEffect, useId, useRef, useState } from "react"; + +interface HelperTooltipProps { + content: string; + placement?: "top" | "bottom"; + className?: string; +} + +const HOVER_DELAY_MS = 350; + +const HelperTooltip: React.FC = ({ content, placement = "top", className }) => { + const [isVisible, setIsVisible] = useState(false); + const [actualPlacement, setActualPlacement] = useState(placement); + const tooltipId = useId(); + const triggerRef = useRef(null); + const bubbleRef = useRef(null); + const hoverTimeoutRef = useRef | null>(null); + const isPointerInsideRef = useRef(false); + + const show = useCallback(() => { + if (triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect(); + setActualPlacement(rect.top < 80 ? "bottom" : placement); + } + setIsVisible(true); + }, [placement]); + + const hide = useCallback(() => { + setIsVisible(false); + }, []); + + const clearHoverTimeout = useCallback(() => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + }, []); + + const handlePointerEnter = useCallback(() => { + isPointerInsideRef.current = true; + clearHoverTimeout(); + hoverTimeoutRef.current = setTimeout(show, HOVER_DELAY_MS); + }, [show, clearHoverTimeout]); + + const handlePointerLeave = useCallback(() => { + isPointerInsideRef.current = false; + clearHoverTimeout(); + // Small delay to allow moving from trigger to bubble + hoverTimeoutRef.current = setTimeout(() => { + if (!isPointerInsideRef.current) { + hide(); + } + }, 100); + }, [hide, clearHoverTimeout]); + + const handleFocus = useCallback(() => { + show(); + }, [show]); + + const handleBlur = useCallback(() => { + hide(); + }, [hide]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + hide(); + } + }, + [hide], + ); + + // Touch: tap to toggle + const handleClick = useCallback(() => { + if (isVisible) { + hide(); + } else { + show(); + } + }, [isVisible, show, hide]); + + // Close on outside click (touch devices) + useEffect(() => { + if (!isVisible) return; + + const handleOutsideClick = (e: MouseEvent | TouchEvent) => { + const target = e.target as Node; + if ( + triggerRef.current && + !triggerRef.current.contains(target) && + bubbleRef.current && + !bubbleRef.current.contains(target) + ) { + hide(); + } + }; + + document.addEventListener("mousedown", handleOutsideClick); + document.addEventListener("touchstart", handleOutsideClick); + return () => { + document.removeEventListener("mousedown", handleOutsideClick); + document.removeEventListener("touchstart", handleOutsideClick); + }; + }, [isVisible, hide]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + } + }; + }, []); + + return ( + + + {isVisible && ( + + )} + + ); +}; + +export default HelperTooltip; diff --git a/src/components/navbar/index.tsx b/src/components/navbar/index.tsx index aa6b0240..61c3d709 100644 --- a/src/components/navbar/index.tsx +++ b/src/components/navbar/index.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; import { useSettings } from "../../context/SettingsContext"; +import { useNotify } from "../../hooks/useNotify"; import { useSearch } from "../../hooks/useSearch"; import NavbarLogo from "./NavbarLogo"; import { NetworkBlockIndicator } from "./NetworkBlockIndicator"; @@ -13,7 +14,9 @@ const Navbar = () => { const location = useLocation(); const { searchTerm, setSearchTerm, isResolving, error, clearError, handleSearch, networkId } = useSearch(); - const { isDarkMode, toggleTheme, isSuperUser, toggleSuperUserMode } = useSettings(); + const { isDarkMode, toggleTheme, isSuperUser, toggleSuperUserMode, settings, updateSettings } = + useSettings(); + const notify = useNotify(); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); // Check if we should show the search box (on any network page including home) @@ -42,6 +45,30 @@ const Navbar = () => { }; }, [isMobileMenuOpen]); + const knowledgeLevel = settings.knowledgeLevel ?? "beginner"; + const tooltipsEnabled = settings.showHelperTooltips !== false; + + const cycleKnowledgeLevel = () => { + const levels = ["beginner", "intermediate", "advanced"] as const; + const currentIndex = levels.indexOf(knowledgeLevel); + const nextLevel = levels[(currentIndex + 1) % levels.length] ?? "beginner"; + updateSettings({ knowledgeLevel: nextLevel }); + const levelKey = + nextLevel === "beginner" + ? "nav.tooltipsLevelBeginner" + : nextLevel === "intermediate" + ? "nav.tooltipsLevelIntermediate" + : "nav.tooltipsLevelAdvanced"; + notify.success(t("nav.tooltipsSwitched", { level: t(levelKey) }), 2000); + }; + + const knowledgeLevelLabel = + knowledgeLevel === "beginner" + ? t("nav.tooltipsBeginner") + : knowledgeLevel === "intermediate" + ? t("nav.tooltipsIntermediate") + : t("nav.tooltipsAdvanced"); + const goToSettings = () => { navigate("/settings"); }; @@ -137,6 +164,40 @@ const Navbar = () => { )} + {tooltipsEnabled && ( +
  • + +
  • + )}
  • + {/* Tooltip Level toggle */} + {tooltipsEnabled && ( + + )} + {/* Super User Mode toggle */} )} {isSuperUser && ( diff --git a/src/components/pages/settings/index.tsx b/src/components/pages/settings/index.tsx index 5bf1f322..9b2bc7ed 100644 --- a/src/components/pages/settings/index.tsx +++ b/src/components/pages/settings/index.tsx @@ -2,6 +2,7 @@ import type React from "react"; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { Link, useLocation, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; +import HelperTooltip from "../../common/HelperTooltip"; import { MetaMaskIcon } from "../../common/MetaMaskIcon"; import { getEnabledNetworks } from "../../../config/networks"; import { AppContext, useNetworks } from "../../../context/AppContext"; @@ -87,6 +88,7 @@ const isAlchemyUrl = (url: string): boolean => url.includes("alchemy.com"); const Settings: React.FC = () => { const { t, i18n } = useTranslation("settings"); + const { t: tTooltips } = useTranslation("tooltips"); const location = useLocation(); const navigate = useNavigate(); const { rpcUrls, setRpcUrls } = useContext(AppContext); @@ -913,6 +915,76 @@ const Settings: React.FC = () => { + +
    +

    💡 {t("helperTooltips.title")}

    +

    + {t("helperTooltips.description")} +

    + +
    +
    +
    + {t("helperTooltips.enabled.label")} +
    +
    + {t("helperTooltips.enabled.description")} +
    +
    + +
    + +
    +
    +
    + {t("helperTooltips.knowledgeLevel.label")} + {settings.showHelperTooltips !== false && ( + + )} +
    +
    + {t("helperTooltips.knowledgeLevel.description")} +
    +
    + +
    +
    )} diff --git a/src/i18n.ts b/src/i18n.ts index f31fe572..021b03b8 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -12,6 +12,7 @@ import enSettings from "./locales/en/settings.json"; import enTransaction from "./locales/en/transaction.json"; import enTokenDetails from "./locales/en/tokenDetails.json"; import enRpcs from "./locales/en/rpcs.json"; +import enTooltips from "./locales/en/tooltips.json"; import esAddress from "./locales/es/address.json"; import esBlock from "./locales/es/block.json"; @@ -23,6 +24,7 @@ import esSettings from "./locales/es/settings.json"; import esTransaction from "./locales/es/transaction.json"; import esTokenDetails from "./locales/es/tokenDetails.json"; import esRpcs from "./locales/es/rpcs.json"; +import esTooltips from "./locales/es/tooltips.json"; import zhAddress from "./locales/zh/address.json"; import zhBlock from "./locales/zh/block.json"; @@ -33,6 +35,7 @@ import zhNetwork from "./locales/zh/network.json"; import zhSettings from "./locales/zh/settings.json"; import zhTransaction from "./locales/zh/transaction.json"; import zhTokenDetails from "./locales/zh/tokenDetails.json"; +import zhTooltips from "./locales/zh/tooltips.json"; import jaAddress from "./locales/ja/address.json"; import jaBlock from "./locales/ja/block.json"; @@ -43,6 +46,7 @@ import jaNetwork from "./locales/ja/network.json"; import jaSettings from "./locales/ja/settings.json"; import jaTransaction from "./locales/ja/transaction.json"; import jaTokenDetails from "./locales/ja/tokenDetails.json"; +import jaTooltips from "./locales/ja/tooltips.json"; import ptBRAddress from "./locales/pt-BR/address.json"; import ptBRBlock from "./locales/pt-BR/block.json"; @@ -53,6 +57,7 @@ import ptBRNetwork from "./locales/pt-BR/network.json"; import ptBRSettings from "./locales/pt-BR/settings.json"; import ptBRTransaction from "./locales/pt-BR/transaction.json"; import ptBRTokenDetails from "./locales/pt-BR/tokenDetails.json"; +import ptBRTooltips from "./locales/pt-BR/tooltips.json"; export const SUPPORTED_LANGUAGES = [ { code: "en", name: "English" }, @@ -78,6 +83,7 @@ i18n network: enNetwork, tokenDetails: enTokenDetails, rpcs: enRpcs, + tooltips: enTooltips, }, es: { common: esCommon, @@ -90,6 +96,7 @@ i18n network: esNetwork, tokenDetails: esTokenDetails, rpcs: esRpcs, + tooltips: esTooltips, }, zh: { common: zhCommon, @@ -101,6 +108,7 @@ i18n devtools: zhDevtools, network: zhNetwork, tokenDetails: zhTokenDetails, + tooltips: zhTooltips, }, ja: { common: jaCommon, @@ -112,6 +120,7 @@ i18n devtools: jaDevtools, network: jaNetwork, tokenDetails: jaTokenDetails, + tooltips: jaTooltips, }, "pt-BR": { common: ptBRCommon, @@ -123,6 +132,7 @@ i18n devtools: ptBRDevtools, network: ptBRNetwork, tokenDetails: ptBRTokenDetails, + tooltips: ptBRTooltips, }, }, fallbackLng: "en", @@ -137,6 +147,7 @@ i18n "devtools", "network", "rpcs", + "tooltips", ], interpolation: { escapeValue: false, diff --git a/src/i18next.d.ts b/src/i18next.d.ts index 19b600e2..d1122d44 100644 --- a/src/i18next.d.ts +++ b/src/i18next.d.ts @@ -8,6 +8,7 @@ import type settings from "./locales/en/settings.json"; import type transaction from "./locales/en/transaction.json"; import type tokenDetails from "./locales/en/tokenDetails.json"; import type rpcs from "./locales/en/rpcs.json"; +import type tooltips from "./locales/en/tooltips.json"; declare module "i18next" { interface CustomTypeOptions { defaultNS: "common"; @@ -22,6 +23,7 @@ declare module "i18next" { network: typeof network; tokenDetails: typeof tokenDetails; rpcs: typeof rpcs; + tooltips: typeof tooltips; }; } } diff --git a/src/locales/en/common.json b/src/locales/en/common.json index 1313d994..b3b3757c 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -27,7 +27,14 @@ "enableSuperUser": "Enable Super User Mode", "mempool": "Mempool", "txs": "Transactions", - "superUserBadge": "Super User" + "superUserBadge": "Super User", + "tooltipsBeginner": "Tooltips: Beginner", + "tooltipsIntermediate": "Tooltips: Intermediate", + "tooltipsAdvanced": "Tooltips: Advanced", + "tooltipsSwitched": "Tooltip helper level switched to {{level}}", + "tooltipsLevelBeginner": "Beginner", + "tooltipsLevelIntermediate": "Intermediate", + "tooltipsLevelAdvanced": "Advanced" }, "footer": { "about": "About", diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 7f9b9898..02e02876 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -149,6 +149,21 @@ } } }, + "helperTooltips": { + "title": "Helper Tooltips", + "description": "Show contextual help for blockchain data fields.", + "enabled": { + "label": "Show Helper Tooltips", + "description": "Display helper icons next to field labels with brief explanations." + }, + "knowledgeLevel": { + "label": "Knowledge Level", + "description": "Controls how many helper tooltips are shown. Beginner shows the most, Advanced shows the fewest.", + "beginner": "Beginner", + "intermediate": "Intermediate", + "advanced": "Advanced" + } + }, "rpcEndpoints": { "title": "RPC Endpoints", "description": "Configure RPC URLs for each network. Click on a network to expand and configure its endpoints.", diff --git a/src/locales/en/tooltips.json b/src/locales/en/tooltips.json new file mode 100644 index 00000000..e99ee5e7 --- /dev/null +++ b/src/locales/en/tooltips.json @@ -0,0 +1,128 @@ +{ + "transaction": { + "hash": "A unique identifier for this transaction, generated from its contents.", + "from": "The address that initiated and signed this transaction.", + "status": "Whether the transaction executed successfully or reverted.", + "confirmations": "Blocks mined after this transaction. More confirmations means higher finality.", + "interactedWith": "The contract this transaction called, or the recipient address for simple transfers.", + "transactionFee": "Total fee paid to process this transaction (gas used × gas price).", + "gasPrice": "Price per unit of gas, in Gwei. Higher gas prices incentivize faster inclusion.", + "gasLimitUsage": "Gas limit is the maximum gas authorized. Gas used is the actual amount consumed.", + "nonce": "Sender's transaction counter. Ensures transactions are processed in order.", + "position": "The index of this transaction within its block. Lower positions were processed first.", + "type": "The transaction format (e.g. Legacy, EIP-1559, EIP-4844). Determines which fee fields apply.", + "decodedInput": "The function call and parameters sent to the contract, decoded into human-readable form.", + "maxFeePerBlobGas": "Maximum price per unit of blob gas the sender is willing to pay for data availability.", + "blobGasPrice": "Actual price per unit of blob gas paid in this block.", + "blobGasUsed": "Amount of blob gas consumed by this transaction's data blobs.", + "blobCount": "Number of data blobs attached to this transaction for rollup data availability.", + "blobVersionedHashes": "Versioned hashes that uniquely identify each data blob committed by this transaction.", + "contractCreated": "The address of the new contract deployed by this transaction.", + "value": "Amount of native currency (e.g. ETH) transferred in this transaction.", + "effectiveGasPrice": "The actual gas price paid after EIP-1559 fee calculation, which may differ from the requested price.", + "l1BlockNumber": "The Ethereum L1 block that anchors this L2 transaction for security.", + "gasUsedForL1": "Gas consumed for posting this transaction's data to Ethereum L1.", + "l1Fee": "Fee paid for data availability on Ethereum L1. Part of the total transaction cost on this L2.", + "l1GasPrice": "The L1 gas price used to calculate the data availability fee component.", + "l1GasUsed": "Estimated L1 gas required to post this transaction's calldata.", + "l1FeeScalar": "A multiplier applied to the L1 fee calculation by the L2 sequencer." + }, + "block": { + "hash": "A unique fingerprint identifying this block, derived from its contents.", + "transactions": "The number of transactions included and executed in this block.", + "withdrawals": "Validator withdrawals processed in this block, returning staked ETH.", + "feeRecipient": "The address that receives priority fees from transactions in this block.", + "difficulty": "Mining difficulty for this block. Always zero on proof-of-stake networks after the Merge.", + "totalDifficulty": "Cumulative difficulty of the chain up to this block. Legacy field from proof-of-work.", + "size": "The total size of the block data in bytes.", + "extraData": "Arbitrary data set by the block producer. Often contains client or pool identifiers.", + "gasUsed": "Total gas consumed by all transactions in this block.", + "gasLimit": "Maximum gas allowed in this block, defining its capacity.", + "baseFeePerGas": "Minimum gas price for this block, set by the network based on demand (EIP-1559).", + "burntFees": "Transaction fees permanently removed from circulation via EIP-1559.", + "blobGasUsed": "Total blob gas consumed by blob transactions in this block.", + "excessBlobGas": "Blob gas above the target, used to calculate the next block's blob base fee.", + "blobCount": "Number of data blobs included in this block for rollup data availability.", + "l1BlockNumber": "The Ethereum L1 block number associated with this Arbitrum block.", + "sendCount": "Number of outgoing L2-to-L1 messages sent in this Arbitrum block.", + "sendRoot": "Merkle root of all outgoing L2-to-L1 messages, used for cross-layer verification.", + "parentHash": "Hash of the previous block in the chain, linking blocks together.", + "stateRoot": "Merkle root of the entire blockchain state after processing this block.", + "transactionsRoot": "Merkle root of all transactions included in this block.", + "receiptsRoot": "Merkle root of all transaction receipts from this block.", + "withdrawalsRoot": "Merkle root of all validator withdrawals processed in this block.", + "logsBloom": "A bloom filter for efficiently searching event logs emitted in this block.", + "nonce": "A value used in proof-of-work mining. Always zero after the Merge.", + "mixHash": "A hash used in the proof-of-work algorithm. Replaced by RANDAO value after the Merge.", + "sha3Uncles": "Hash of the uncle blocks list. Always the empty list hash after the Merge.", + "validator": "The validator index that initiated this withdrawal from the beacon chain." + }, + "address": { + "verification": "A verified contract has its source code publicly confirmed to match the deployed bytecode.", + "proxyType": "This contract uses an upgradeable proxy pattern. The logic lives in a separate implementation contract.", + "readContract": "Query contract data without spending gas or connecting a wallet. Read-only calls.", + "writeContract": "Send a transaction that changes contract state. Requires a connected wallet and gas.", + "balance": "Native currency balance held by this address.", + "usdValue": "Estimated fiat value based on current market price.", + "nonce": "Number of transactions sent from this address. Used for transaction ordering.", + "eip7702Delegate": "This account delegates its code execution to another address via EIP-7702.", + "implementationAddress": "The contract that contains the actual logic for this proxy.", + "ensName": "Ethereum Name Service name associated with this address.", + "ensApp": "Application or resolver linked to this ENS name." + }, + "token": { + "tokenStandard": "The token interface standard (e.g. ERC-20, ERC-721, ERC-1155) that defines how this token behaves.", + "decimals": "Number of decimal places used to display token amounts. Most ERC-20 tokens use 18.", + "totalSupply": "Total number of tokens that have been created for this contract.", + "tokenId": "A unique numeric identifier for this specific token within its collection.", + "owner": "The address that currently holds this NFT.", + "approved": "An address authorized to transfer this specific NFT on behalf of the owner.", + "metadataUri": "The URI where this token's metadata (name, image, attributes) is stored." + }, + "bitcoin": { + "txid": "A unique identifier for this transaction, computed from its serialized data.", + "witnessHash": "Transaction hash including witness (SegWit) data. Differs from TXID for SegWit transactions.", + "block": "The block that includes this transaction.", + "status": "Whether this transaction has been confirmed in a block or is still waiting in the mempool.", + "inputs": "The number of previous outputs being spent. Each input references a UTXO.", + "outputs": "The number of new outputs created. Each output locks BTC to an address.", + "fee": "The difference between total inputs and total outputs, paid to the miner.", + "feePerByte": "Fee rate in satoshis per byte of raw transaction data.", + "feePerVByte": "Fee rate in satoshis per virtual byte. The standard fee metric for SegWit transactions.", + "feePerWU": "Fee rate in satoshis per weight unit. The most granular fee metric.", + "size": "Raw transaction size in bytes, including witness data.", + "virtualSize": "Adjusted transaction size that discounts witness data. Used for fee calculation.", + "weight": "Transaction weight in weight units (WU). 1 vByte = 4 WU.", + "coinbase": "Whether this is a coinbase transaction that creates new BTC as a block reward.", + "witness": "Whether this transaction uses Segregated Witness (SegWit) format.", + "rbf": "Replace-By-Fee. If enabled, this transaction can be replaced by one with a higher fee before confirmation.", + "version": "Transaction format version number. Determines which features are available.", + "locktime": "Earliest block height or time when this transaction can be included in a block.", + "blockHash": "The unique hash identifying the block that contains this transaction.", + "minedBy": "The mining pool or entity that produced this block.", + "blockReward": "New BTC created as a reward for mining this block (subsidy + fees).", + "totalFees": "Sum of all transaction fees collected by the miner in this block.", + "feeRate": "Average and median fee rates of transactions in this block.", + "transactions": "Number of transactions included in this block.", + "totalOutput": "Total BTC value of all outputs created in this block.", + "difficulty": "How hard it is to find a valid block hash. Adjusts every 2016 blocks.", + "blockSize": "Total size of the serialized block data.", + "blockWeight": "Block weight in weight units. Maximum is 4,000,000 WU.", + "previousBlock": "Hash of the preceding block, linking this block to the chain.", + "nextBlock": "Hash of the following block, if one exists.", + "coinbaseMessage": "Arbitrary text embedded in the coinbase transaction by the miner.", + "merkleRoot": "Root hash of the Merkle tree of all transactions in this block.", + "blockVersion": "Block format version. Encodes which consensus rules and soft forks are signaled.", + "bits": "Compact encoding of the target threshold for a valid block hash.", + "blockNonce": "A value miners iterate to find a block hash below the target.", + "coinbaseHex": "Raw hexadecimal data of the coinbase transaction input.", + "address": "A Bitcoin address derived from a public key, used to receive BTC.", + "balance": "Total BTC held by this address across all unspent outputs.", + "totalReceived": "Cumulative BTC received by this address across all transactions.", + "utxos": "Unspent Transaction Outputs. Individual coins available to be spent by this address.", + "txCount": "Total number of transactions involving this address." + }, + "settings": { + "knowledgeLevel": "Controls how much explanatory help is shown throughout the explorer." + } +} diff --git a/src/locales/es/common.json b/src/locales/es/common.json index c37a511a..87fadd57 100644 --- a/src/locales/es/common.json +++ b/src/locales/es/common.json @@ -27,7 +27,14 @@ "enableSuperUser": "Activar modo Super Usuario", "mempool": "Mempool", "txs": "Transacciones", - "superUserBadge": "Super Usuario" + "superUserBadge": "Super Usuario", + "tooltipsBeginner": "Tooltips: Principiante", + "tooltipsIntermediate": "Tooltips: Intermedio", + "tooltipsAdvanced": "Tooltips: Avanzado", + "tooltipsSwitched": "Nivel de tooltips cambiado a {{level}}", + "tooltipsLevelBeginner": "Principiante", + "tooltipsLevelIntermediate": "Intermedio", + "tooltipsLevelAdvanced": "Avanzado" }, "footer": { "about": "Acerca de", diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index 620b9f04..b4cf270a 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -149,6 +149,21 @@ } } }, + "helperTooltips": { + "title": "Tooltips de Ayuda", + "description": "Mostrar ayuda contextual para campos de datos de blockchain.", + "enabled": { + "label": "Mostrar Tooltips de Ayuda", + "description": "Mostrar íconos de ayuda junto a las etiquetas de campos con explicaciones breves." + }, + "knowledgeLevel": { + "label": "Nivel de Conocimiento", + "description": "Controla cuántos tooltips de ayuda se muestran. Principiante muestra más, Avanzado muestra menos.", + "beginner": "Principiante", + "intermediate": "Intermedio", + "advanced": "Avanzado" + } + }, "rpcEndpoints": { "title": "Endpoints RPC", "description": "Configurá las URLs RPC para cada red. Hacé click en una red para desplegar y configurar sus endpoints.", diff --git a/src/locales/es/tooltips.json b/src/locales/es/tooltips.json new file mode 100644 index 00000000..079e9877 --- /dev/null +++ b/src/locales/es/tooltips.json @@ -0,0 +1,128 @@ +{ + "transaction": { + "hash": "Un identificador único para esta transacción, generado a partir de su contenido.", + "from": "La dirección que inició y firmó esta transacción.", + "status": "Si la transacción se ejecutó correctamente o fue revertida.", + "confirmations": "Bloques minados después de esta transacción. Más confirmaciones significa mayor finalidad.", + "interactedWith": "El contrato con el que interactuó esta transacción, o la dirección del destinatario en transferencias simples.", + "transactionFee": "Tarifa total pagada para procesar esta transacción (gas usado × precio del gas).", + "gasPrice": "Precio por unidad de gas, en Gwei. Precios más altos incentivan una inclusión más rápida.", + "gasLimitUsage": "El límite de gas es el máximo autorizado. El gas usado es la cantidad real consumida.", + "nonce": "Contador de transacciones del remitente. Asegura que las transacciones se procesen en orden.", + "position": "El índice de esta transacción dentro de su bloque. Posiciones más bajas se procesaron primero.", + "type": "El formato de la transacción (ej. Legacy, EIP-1559, EIP-4844). Determina qué campos de tarifa aplican.", + "decodedInput": "La llamada a función y parámetros enviados al contrato, decodificados en formato legible.", + "maxFeePerBlobGas": "Precio máximo por unidad de blob gas que el remitente está dispuesto a pagar por disponibilidad de datos.", + "blobGasPrice": "Precio real por unidad de blob gas pagado en este bloque.", + "blobGasUsed": "Cantidad de blob gas consumido por los blobs de datos de esta transacción.", + "blobCount": "Cantidad de blobs de datos adjuntos a esta transacción para disponibilidad de datos de rollup.", + "blobVersionedHashes": "Hashes versionados que identifican de forma única cada blob de datos comprometido por esta transacción.", + "contractCreated": "La dirección del nuevo contrato desplegado por esta transacción.", + "value": "Cantidad de moneda nativa (ej. ETH) transferida en esta transacción.", + "effectiveGasPrice": "El precio de gas real pagado después del cálculo de tarifa EIP-1559, que puede diferir del precio solicitado.", + "l1BlockNumber": "El bloque de Ethereum L1 que ancla esta transacción L2 para seguridad.", + "gasUsedForL1": "Gas consumido para publicar los datos de esta transacción en Ethereum L1.", + "l1Fee": "Tarifa pagada por disponibilidad de datos en Ethereum L1. Parte del costo total de la transacción en esta L2.", + "l1GasPrice": "El precio de gas L1 usado para calcular el componente de tarifa de disponibilidad de datos.", + "l1GasUsed": "Gas L1 estimado requerido para publicar los calldata de esta transacción.", + "l1FeeScalar": "Un multiplicador aplicado al cálculo de tarifa L1 por el secuenciador L2." + }, + "block": { + "hash": "Una huella digital única que identifica este bloque, derivada de su contenido.", + "transactions": "La cantidad de transacciones incluidas y ejecutadas en este bloque.", + "withdrawals": "Retiros de validadores procesados en este bloque, devolviendo ETH stakeado.", + "feeRecipient": "La dirección que recibe las tarifas prioritarias de las transacciones en este bloque.", + "difficulty": "Dificultad de minado para este bloque. Siempre cero en redes proof-of-stake después del Merge.", + "totalDifficulty": "Dificultad acumulada de la cadena hasta este bloque. Campo heredado de proof-of-work.", + "size": "El tamaño total de los datos del bloque en bytes.", + "extraData": "Datos arbitrarios establecidos por el productor del bloque. A menudo contiene identificadores de cliente o pool.", + "gasUsed": "Gas total consumido por todas las transacciones en este bloque.", + "gasLimit": "Gas máximo permitido en este bloque, definiendo su capacidad.", + "baseFeePerGas": "Precio mínimo de gas para este bloque, establecido por la red según la demanda (EIP-1559).", + "burntFees": "Tarifas de transacción eliminadas permanentemente de la circulación mediante EIP-1559.", + "blobGasUsed": "Blob gas total consumido por transacciones blob en este bloque.", + "excessBlobGas": "Blob gas por encima del objetivo, usado para calcular la tarifa base blob del siguiente bloque.", + "blobCount": "Cantidad de blobs de datos incluidos en este bloque para disponibilidad de datos de rollup.", + "l1BlockNumber": "El número de bloque de Ethereum L1 asociado con este bloque de Arbitrum.", + "sendCount": "Cantidad de mensajes salientes L2-a-L1 enviados en este bloque de Arbitrum.", + "sendRoot": "Raíz de Merkle de todos los mensajes salientes L2-a-L1, usada para verificación entre capas.", + "parentHash": "Hash del bloque anterior en la cadena, enlazando los bloques entre sí.", + "stateRoot": "Raíz de Merkle del estado completo de la blockchain después de procesar este bloque.", + "transactionsRoot": "Raíz de Merkle de todas las transacciones incluidas en este bloque.", + "receiptsRoot": "Raíz de Merkle de todos los recibos de transacciones de este bloque.", + "withdrawalsRoot": "Raíz de Merkle de todos los retiros de validadores procesados en este bloque.", + "logsBloom": "Un filtro bloom para buscar eficientemente logs de eventos emitidos en este bloque.", + "nonce": "Un valor usado en minería proof-of-work. Siempre cero después del Merge.", + "mixHash": "Un hash usado en el algoritmo proof-of-work. Reemplazado por el valor RANDAO después del Merge.", + "sha3Uncles": "Hash de la lista de bloques uncle. Siempre el hash de lista vacía después del Merge.", + "validator": "El índice del validador que inició este retiro desde la beacon chain." + }, + "address": { + "verification": "Un contrato verificado tiene su código fuente confirmado públicamente como coincidente con el bytecode desplegado.", + "proxyType": "Este contrato usa un patrón de proxy actualizable. La lógica está en un contrato de implementación separado.", + "readContract": "Consultar datos del contrato sin gastar gas ni conectar una wallet. Llamadas de solo lectura.", + "writeContract": "Enviar una transacción que cambia el estado del contrato. Requiere una wallet conectada y gas.", + "balance": "Saldo de moneda nativa que posee esta dirección.", + "usdValue": "Valor estimado en moneda fiat basado en el precio de mercado actual.", + "nonce": "Cantidad de transacciones enviadas desde esta dirección. Usado para ordenar transacciones.", + "eip7702Delegate": "Esta cuenta delega la ejecución de su código a otra dirección vía EIP-7702.", + "implementationAddress": "El contrato que contiene la lógica real para este proxy.", + "ensName": "Nombre del Ethereum Name Service asociado a esta dirección.", + "ensApp": "Aplicación o resolver vinculado a este nombre ENS." + }, + "token": { + "tokenStandard": "El estándar de interfaz del token (ej. ERC-20, ERC-721, ERC-1155) que define cómo se comporta.", + "decimals": "Cantidad de decimales usados para mostrar montos del token. La mayoría de tokens ERC-20 usan 18.", + "totalSupply": "Cantidad total de tokens creados para este contrato.", + "tokenId": "Un identificador numérico único para este token específico dentro de su colección.", + "owner": "La dirección que actualmente posee este NFT.", + "approved": "Una dirección autorizada para transferir este NFT específico en nombre del propietario.", + "metadataUri": "La URI donde se almacenan los metadatos de este token (nombre, imagen, atributos)." + }, + "bitcoin": { + "txid": "Un identificador único para esta transacción, calculado a partir de sus datos serializados.", + "witnessHash": "Hash de transacción incluyendo datos witness (SegWit). Difiere del TXID para transacciones SegWit.", + "block": "El bloque que incluye esta transacción.", + "status": "Si esta transacción fue confirmada en un bloque o sigue esperando en el mempool.", + "inputs": "La cantidad de salidas previas que se gastan. Cada entrada referencia un UTXO.", + "outputs": "La cantidad de nuevas salidas creadas. Cada salida bloquea BTC a una dirección.", + "fee": "La diferencia entre entradas y salidas totales, pagada al minero.", + "feePerByte": "Tasa de tarifa en satoshis por byte de datos crudos de transacción.", + "feePerVByte": "Tasa de tarifa en satoshis por byte virtual. La métrica estándar para transacciones SegWit.", + "feePerWU": "Tasa de tarifa en satoshis por unidad de peso. La métrica de tarifa más granular.", + "size": "Tamaño crudo de la transacción en bytes, incluyendo datos witness.", + "virtualSize": "Tamaño ajustado que descuenta datos witness. Usado para calcular tarifas.", + "weight": "Peso de la transacción en unidades de peso (WU). 1 vByte = 4 WU.", + "coinbase": "Si esta es una transacción coinbase que crea nuevos BTC como recompensa de bloque.", + "witness": "Si esta transacción usa el formato Segregated Witness (SegWit).", + "rbf": "Replace-By-Fee. Si está habilitado, esta transacción puede ser reemplazada por una con mayor tarifa antes de la confirmación.", + "version": "Número de versión del formato de transacción. Determina qué funciones están disponibles.", + "locktime": "La altura de bloque o momento más temprano en que esta transacción puede incluirse en un bloque.", + "blockHash": "El hash único que identifica el bloque que contiene esta transacción.", + "minedBy": "El pool de minería o entidad que produjo este bloque.", + "blockReward": "Nuevos BTC creados como recompensa por minar este bloque (subsidio + tarifas).", + "totalFees": "Suma de todas las tarifas de transacción cobradas por el minero en este bloque.", + "feeRate": "Tasas de tarifa promedio y mediana de las transacciones en este bloque.", + "transactions": "Cantidad de transacciones incluidas en este bloque.", + "totalOutput": "Valor total de BTC de todas las salidas creadas en este bloque.", + "difficulty": "Qué tan difícil es encontrar un hash de bloque válido. Se ajusta cada 2016 bloques.", + "blockSize": "Tamaño total de los datos serializados del bloque.", + "blockWeight": "Peso del bloque en unidades de peso. Máximo: 4.000.000 WU.", + "previousBlock": "Hash del bloque precedente, enlazando este bloque a la cadena.", + "nextBlock": "Hash del bloque siguiente, si existe.", + "coinbaseMessage": "Texto arbitrario incrustado en la transacción coinbase por el minero.", + "merkleRoot": "Hash raíz del árbol de Merkle de todas las transacciones en este bloque.", + "blockVersion": "Versión del formato de bloque. Codifica qué reglas de consenso y soft forks se señalizan.", + "bits": "Codificación compacta del umbral objetivo para un hash de bloque válido.", + "blockNonce": "Un valor que los mineros iteran para encontrar un hash de bloque por debajo del objetivo.", + "coinbaseHex": "Datos hexadecimales crudos de la entrada de la transacción coinbase.", + "address": "Una dirección Bitcoin derivada de una clave pública, usada para recibir BTC.", + "balance": "BTC total que posee esta dirección en todas las salidas sin gastar.", + "totalReceived": "BTC acumulados recibidos por esta dirección en todas las transacciones.", + "utxos": "Salidas de Transacción No Gastadas. Monedas individuales disponibles para gastar por esta dirección.", + "txCount": "Cantidad total de transacciones que involucran esta dirección." + }, + "settings": { + "knowledgeLevel": "Controla cuánta ayuda explicativa se muestra en el explorador." + } +} diff --git a/src/locales/ja/common.json b/src/locales/ja/common.json index d444404e..b11fc25c 100644 --- a/src/locales/ja/common.json +++ b/src/locales/ja/common.json @@ -26,7 +26,14 @@ "disableSuperUser": "スーパーユーザーモードを無効化", "enableSuperUser": "スーパーユーザーモードを有効化", "mempool": "メモリープール", - "txs": "トランザクション" + "txs": "トランザクション", + "tooltipsBeginner": "ツールチップ:初心者", + "tooltipsIntermediate": "ツールチップ:中級者", + "tooltipsAdvanced": "ツールチップ:上級者", + "tooltipsSwitched": "ツールチップヘルパーレベルが{{level}}に切り替わりました", + "tooltipsLevelBeginner": "初心者", + "tooltipsLevelIntermediate": "中級者", + "tooltipsLevelAdvanced": "上級者" }, "footer": { "about": "概要", diff --git a/src/locales/ja/settings.json b/src/locales/ja/settings.json index 3ded7c54..a50abd00 100644 --- a/src/locales/ja/settings.json +++ b/src/locales/ja/settings.json @@ -149,6 +149,21 @@ } } }, + "helperTooltips": { + "title": "ヘルパーツールチップ", + "description": "ブロックチェーンデータフィールドのコンテキストヘルプを表示します。", + "enabled": { + "label": "ヘルパーツールチップを表示", + "description": "フィールドラベルの横に簡単な説明付きのヘルプアイコンを表示します。" + }, + "knowledgeLevel": { + "label": "知識レベル", + "description": "ヘルパーツールチップの表示数を制御します。初心者は最も多く、上級者は最も少なく表示されます。", + "beginner": "初心者", + "intermediate": "中級者", + "advanced": "上級者" + } + }, "rpcEndpoints": { "title": "RPCエンドポイント", "description": "各ネットワークのRPC URLを設定します。ネットワークをクリックして展開し、エンドポイントを設定してください。", diff --git a/src/locales/ja/tooltips.json b/src/locales/ja/tooltips.json new file mode 100644 index 00000000..65264a25 --- /dev/null +++ b/src/locales/ja/tooltips.json @@ -0,0 +1,128 @@ +{ + "transaction": { + "hash": "このトランザクションの一意の識別子。内容から生成されます。", + "from": "このトランザクションを開始し署名したアドレス。", + "status": "トランザクションが正常に実行されたか、リバートされたか。", + "confirmations": "このトランザクション後にマイニングされたブロック数。確認数が多いほどファイナリティが高い。", + "interactedWith": "このトランザクションが呼び出したコントラクト、または単純な送金の受取アドレス。", + "transactionFee": "このトランザクションの処理に支払われた合計手数料(使用ガス × ガス価格)。", + "gasPrice": "ガス1単位あたりの価格(Gwei単位)。高いガス価格はより早い取り込みを促進します。", + "gasLimitUsage": "ガスリミットは承認された最大ガス量。使用ガスは実際に消費された量。", + "nonce": "送信者のトランザクションカウンター。トランザクションが順番に処理されることを保証します。", + "position": "ブロック内のこのトランザクションのインデックス。低い位置が先に処理されます。", + "type": "トランザクション形式(例:Legacy、EIP-1559、EIP-4844)。適用される手数料フィールドを決定します。", + "decodedInput": "コントラクトに送信された関数呼び出しとパラメータを人間が読める形式にデコードしたもの。", + "maxFeePerBlobGas": "送信者がデータ可用性のために支払うBlobガス1単位あたりの最高価格。", + "blobGasPrice": "このブロックで支払われたBlobガス1単位あたりの実際の価格。", + "blobGasUsed": "このトランザクションのデータBlobが消費したBlobガスの量。", + "blobCount": "ロールアップデータ可用性のためにこのトランザクションに添付されたデータBlobの数。", + "blobVersionedHashes": "このトランザクションがコミットした各データBlobを一意に識別するバージョン付きハッシュ。", + "contractCreated": "このトランザクションによってデプロイされた新しいコントラクトのアドレス。", + "value": "このトランザクションで送金されたネイティブ通貨(例:ETH)の金額。", + "effectiveGasPrice": "EIP-1559の手数料計算後に実際に支払われたガス価格。リクエストした価格と異なる場合があります。", + "l1BlockNumber": "このL2トランザクションのセキュリティを担保するEthereum L1ブロック。", + "gasUsedForL1": "このトランザクションのデータをEthereum L1に投稿するために消費されたガス。", + "l1Fee": "Ethereum L1でのデータ可用性に支払われた手数料。このL2の合計トランザクションコストの一部。", + "l1GasPrice": "データ可用性手数料の計算に使用されたL1ガス価格。", + "l1GasUsed": "このトランザクションのcalldataを投稿するために必要な推定L1ガス。", + "l1FeeScalar": "L2シーケンサーがL1手数料計算に適用する乗数。" + }, + "block": { + "hash": "このブロックを識別する一意のフィンガープリント。内容から導出されます。", + "transactions": "このブロックに含まれ実行されたトランザクションの数。", + "withdrawals": "このブロックで処理されたバリデーターの引き出し。ステークされたETHを返却します。", + "feeRecipient": "このブロック内のトランザクションから優先手数料を受け取るアドレス。", + "difficulty": "このブロックのマイニング難易度。マージ後のProof of Stakeネットワークでは常にゼロ。", + "totalDifficulty": "このブロックまでのチェーンの累積難易度。Proof of Workのレガシーフィールド。", + "size": "ブロックデータの合計サイズ(バイト)。", + "extraData": "ブロック生成者が設定した任意のデータ。クライアントやプールの識別子が含まれることが多い。", + "gasUsed": "このブロック内の全トランザクションで消費された合計ガス。", + "gasLimit": "このブロックで許可される最大ガス量。ブロックの容量を定義します。", + "baseFeePerGas": "このブロックの最低ガス価格。ネットワークの需要に基づいて設定されます(EIP-1559)。", + "burntFees": "EIP-1559により流通から永久に除去されたトランザクション手数料。", + "blobGasUsed": "このブロック内のBlobトランザクションが消費した合計Blobガス。", + "excessBlobGas": "目標を超えたBlobガス。次のブロックのBlob基本手数料の計算に使用されます。", + "blobCount": "ロールアップデータ可用性のためにこのブロックに含まれるデータBlobの数。", + "l1BlockNumber": "このArbitrumブロックに関連するEthereum L1ブロック番号。", + "sendCount": "このArbitrumブロックで送信されたL2からL1への送信メッセージの数。", + "sendRoot": "すべてのL2からL1への送信メッセージのMerkleルート。クロスレイヤー検証に使用されます。", + "parentHash": "チェーン内の前のブロックのハッシュ。ブロック同士をリンクします。", + "stateRoot": "このブロックの処理後のブロックチェーン全体の状態のMerkleルート。", + "transactionsRoot": "このブロックに含まれるすべてのトランザクションのMerkleルート。", + "receiptsRoot": "このブロックのすべてのトランザクションレシートのMerkleルート。", + "withdrawalsRoot": "このブロックで処理されたすべてのバリデーター引き出しのMerkleルート。", + "logsBloom": "このブロックで発行されたイベントログを効率的に検索するためのブルームフィルター。", + "nonce": "Proof of Workマイニングで使用される値。マージ後は常にゼロ。", + "mixHash": "Proof of Workアルゴリズムで使用されるハッシュ。マージ後はRANDAO値に置き換えられました。", + "sha3Uncles": "アンクルブロックリストのハッシュ。マージ後は常に空リストのハッシュ。", + "validator": "ビーコンチェーンからこの引き出しを開始したバリデーターインデックス。" + }, + "address": { + "verification": "検証済みコントラクトは、ソースコードがデプロイされたバイトコードと一致することが公開確認されています。", + "proxyType": "このコントラクトはアップグレード可能なプロキシパターンを使用しています。ロジックは別の実装コントラクトにあります。", + "readContract": "ガスを消費せず、ウォレットを接続せずにコントラクトデータを照会。読み取り専用の呼び出し。", + "writeContract": "コントラクトの状態を変更するトランザクションを送信。接続されたウォレットとガスが必要です。", + "balance": "このアドレスが保有するネイティブ通貨の残高。", + "usdValue": "現在の市場価格に基づく推定法定通貨価値。", + "nonce": "このアドレスから送信されたトランザクション数。トランザクションの順序付けに使用されます。", + "eip7702Delegate": "このアカウントはEIP-7702を介してコード実行を別のアドレスに委任しています。", + "implementationAddress": "このプロキシの実際のロジックを含むコントラクト。", + "ensName": "このアドレスに関連付けられたEthereum Name Serviceの名前。", + "ensApp": "このENS名前にリンクされたアプリケーションまたはリゾルバー。" + }, + "token": { + "tokenStandard": "トークンのインターフェース標準(ERC-20、ERC-721、ERC-1155など)。トークンの動作を定義します。", + "decimals": "トークン量の表示に使用される小数点以下の桁数。ほとんどのERC-20トークンは18桁を使用します。", + "totalSupply": "このコントラクトで作成されたトークンの総数。", + "tokenId": "コレクション内のこの特定のトークンを識別する一意の数値。", + "owner": "現在このNFTを保有しているアドレス。", + "approved": "所有者に代わってこの特定のNFTを転送する権限を持つアドレス。", + "metadataUri": "このトークンのメタデータ(名前、画像、属性)が保存されているURI。" + }, + "bitcoin": { + "txid": "このトランザクションの一意の識別子。シリアライズされたデータから計算されます。", + "witnessHash": "ウィットネス(SegWit)データを含むトランザクションハッシュ。SegWitトランザクションではTXIDと異なります。", + "block": "このトランザクションを含むブロック。", + "status": "このトランザクションがブロックで確認されたか、まだメモリプールで待機中か。", + "inputs": "使用される以前の出力の数。各入力はUTXOを参照します。", + "outputs": "作成される新しい出力の数。各出力はBTCをアドレスにロックします。", + "fee": "入力合計と出力合計の差額。マイナーに支払われます。", + "feePerByte": "生のトランザクションデータ1バイトあたりの手数料率(satoshi)。", + "feePerVByte": "仮想バイトあたりの手数料率(satoshi)。SegWitトランザクションの標準的な手数料指標。", + "feePerWU": "重量単位あたりの手数料率(satoshi)。最も細かい手数料指標。", + "size": "ウィットネスデータを含む生のトランザクションサイズ(バイト)。", + "virtualSize": "ウィットネスデータを割引した調整済みトランザクションサイズ。手数料計算に使用。", + "weight": "トランザクションの重量(重量単位WU)。1 vByte = 4 WU。", + "coinbase": "これがブロック報酬として新しいBTCを作成するコインベーストランザクションかどうか。", + "witness": "このトランザクションがSegregated Witness(SegWit)フォーマットを使用しているかどうか。", + "rbf": "Replace-By-Fee。有効な場合、確認前により高い手数料のトランザクションで置き換え可能。", + "version": "トランザクションフォーマットのバージョン番号。利用可能な機能を決定します。", + "locktime": "このトランザクションがブロックに含められる最も早いブロック高さまたは時刻。", + "blockHash": "このトランザクションを含むブロックを識別する一意のハッシュ。", + "minedBy": "このブロックを生成したマイニングプールまたはエンティティ。", + "blockReward": "このブロックのマイニング報酬として作成された新しいBTC(補助金+手数料)。", + "totalFees": "このブロックでマイナーが徴収した全トランザクション手数料の合計。", + "feeRate": "このブロック内のトランザクションの平均および中央値の手数料率。", + "transactions": "このブロックに含まれるトランザクションの数。", + "totalOutput": "このブロックで作成されたすべての出力のBTC総額。", + "difficulty": "有効なブロックハッシュを見つける難しさ。2016ブロックごとに調整。", + "blockSize": "シリアライズされたブロックデータの合計サイズ。", + "blockWeight": "ブロックの重量(重量単位)。最大4,000,000 WU。", + "previousBlock": "前のブロックのハッシュ。このブロックをチェーンにリンクします。", + "nextBlock": "次のブロックのハッシュ(存在する場合)。", + "coinbaseMessage": "マイナーがコインベーストランザクションに埋め込んだ任意のテキスト。", + "merkleRoot": "このブロック内の全トランザクションのMerkleツリーのルートハッシュ。", + "blockVersion": "ブロックフォーマットのバージョン。どのコンセンサスルールとソフトフォークがシグナルされるかをエンコード。", + "bits": "有効なブロックハッシュのターゲット閾値のコンパクトエンコーディング。", + "blockNonce": "マイナーがターゲット以下のブロックハッシュを見つけるために反復する値。", + "coinbaseHex": "コインベーストランザクション入力の生の16進数データ。", + "address": "公開鍵から導出されたBitcoinアドレス。BTCの受け取りに使用。", + "balance": "このアドレスが全未使用出力にわたって保有するBTC合計。", + "totalReceived": "このアドレスが全トランザクションにわたって受け取った累計BTC。", + "utxos": "未使用トランザクション出力。このアドレスが使用可能な個別のコイン。", + "txCount": "このアドレスに関連するトランザクションの総数。" + }, + "settings": { + "knowledgeLevel": "エクスプローラー全体で表示される説明ヘルプの量を制御します。" + } +} diff --git a/src/locales/pt-BR/common.json b/src/locales/pt-BR/common.json index 63f1eae8..e73d46f5 100644 --- a/src/locales/pt-BR/common.json +++ b/src/locales/pt-BR/common.json @@ -26,7 +26,14 @@ "disableSuperUser": "Desativar Modo Super Usuário", "enableSuperUser": "Ativar Modo Super Usuário", "mempool": "Mempool", - "txs": "Transações" + "txs": "Transações", + "tooltipsBeginner": "Dicas: Iniciante", + "tooltipsIntermediate": "Dicas: Intermediário", + "tooltipsAdvanced": "Dicas: Avançado", + "tooltipsSwitched": "Nível de dicas alterado para {{level}}", + "tooltipsLevelBeginner": "Iniciante", + "tooltipsLevelIntermediate": "Intermediário", + "tooltipsLevelAdvanced": "Avançado" }, "footer": { "about": "Sobre", diff --git a/src/locales/pt-BR/settings.json b/src/locales/pt-BR/settings.json index eac0b08d..88ec23e9 100644 --- a/src/locales/pt-BR/settings.json +++ b/src/locales/pt-BR/settings.json @@ -149,6 +149,21 @@ } } }, + "helperTooltips": { + "title": "Dicas de Ajuda", + "description": "Mostrar ajuda contextual para campos de dados blockchain.", + "enabled": { + "label": "Mostrar Dicas de Ajuda", + "description": "Exibir ícones de ajuda ao lado dos rótulos com explicações breves." + }, + "knowledgeLevel": { + "label": "Nível de Conhecimento", + "description": "Controla quantas dicas de ajuda são exibidas. Iniciante mostra mais, Avançado mostra menos.", + "beginner": "Iniciante", + "intermediate": "Intermediário", + "advanced": "Avançado" + } + }, "rpcEndpoints": { "title": "Endpoints RPC", "description": "Configure URLs RPC para cada rede. Clique em uma rede para expandir e configurar seus endpoints.", diff --git a/src/locales/pt-BR/tooltips.json b/src/locales/pt-BR/tooltips.json new file mode 100644 index 00000000..4fe7de03 --- /dev/null +++ b/src/locales/pt-BR/tooltips.json @@ -0,0 +1,128 @@ +{ + "transaction": { + "hash": "Um identificador único para esta transação, gerado a partir de seu conteúdo.", + "from": "O endereço que iniciou e assinou esta transação.", + "status": "Se a transação foi executada com sucesso ou revertida.", + "confirmations": "Blocos minerados após esta transação. Mais confirmações significa maior finalidade.", + "interactedWith": "O contrato com o qual esta transação interagiu, ou o endereço do destinatário em transferências simples.", + "transactionFee": "Taxa total paga para processar esta transação (gas usado × preço do gas).", + "gasPrice": "Preço por unidade de gas, em Gwei. Preços mais altos incentivam inclusão mais rápida.", + "gasLimitUsage": "O limite de gas é o máximo autorizado. O gas usado é a quantidade real consumida.", + "nonce": "Contador de transações do remetente. Garante que as transações sejam processadas em ordem.", + "position": "O índice desta transação dentro do seu bloco. Posições mais baixas foram processadas primeiro.", + "type": "O formato da transação (ex: Legacy, EIP-1559, EIP-4844). Determina quais campos de taxa se aplicam.", + "decodedInput": "A chamada de função e parâmetros enviados ao contrato, decodificados em formato legível.", + "maxFeePerBlobGas": "Preço máximo por unidade de blob gas que o remetente está disposto a pagar pela disponibilidade de dados.", + "blobGasPrice": "Preço real por unidade de blob gas pago neste bloco.", + "blobGasUsed": "Quantidade de blob gas consumido pelos blobs de dados desta transação.", + "blobCount": "Número de blobs de dados anexados a esta transação para disponibilidade de dados de rollup.", + "blobVersionedHashes": "Hashes versionados que identificam exclusivamente cada blob de dados comprometido por esta transação.", + "contractCreated": "O endereço do novo contrato implantado por esta transação.", + "value": "Quantidade de moeda nativa (ex: ETH) transferida nesta transação.", + "effectiveGasPrice": "O preço de gas realmente pago após o cálculo de taxa EIP-1559, que pode diferir do preço solicitado.", + "l1BlockNumber": "O bloco Ethereum L1 que ancora esta transação L2 para segurança.", + "gasUsedForL1": "Gas consumido para publicar os dados desta transação no Ethereum L1.", + "l1Fee": "Taxa paga pela disponibilidade de dados no Ethereum L1. Parte do custo total da transação nesta L2.", + "l1GasPrice": "O preço de gas L1 usado para calcular o componente de taxa de disponibilidade de dados.", + "l1GasUsed": "Gas L1 estimado necessário para publicar o calldata desta transação.", + "l1FeeScalar": "Um multiplicador aplicado ao cálculo da taxa L1 pelo sequenciador L2." + }, + "block": { + "hash": "Uma impressão digital única que identifica este bloco, derivada de seu conteúdo.", + "transactions": "O número de transações incluídas e executadas neste bloco.", + "withdrawals": "Saques de validadores processados neste bloco, retornando ETH em stake.", + "feeRecipient": "O endereço que recebe as taxas prioritárias das transações neste bloco.", + "difficulty": "Dificuldade de mineração para este bloco. Sempre zero em redes proof-of-stake após o Merge.", + "totalDifficulty": "Dificuldade acumulada da cadeia até este bloco. Campo legado de proof-of-work.", + "size": "O tamanho total dos dados do bloco em bytes.", + "extraData": "Dados arbitrários definidos pelo produtor do bloco. Frequentemente contém identificadores de cliente ou pool.", + "gasUsed": "Gas total consumido por todas as transações neste bloco.", + "gasLimit": "Gas máximo permitido neste bloco, definindo sua capacidade.", + "baseFeePerGas": "Preço mínimo de gas para este bloco, definido pela rede com base na demanda (EIP-1559).", + "burntFees": "Taxas de transação permanentemente removidas de circulação via EIP-1559.", + "blobGasUsed": "Blob gas total consumido por transações blob neste bloco.", + "excessBlobGas": "Blob gas acima do alvo, usado para calcular a taxa base blob do próximo bloco.", + "blobCount": "Número de blobs de dados incluídos neste bloco para disponibilidade de dados de rollup.", + "l1BlockNumber": "O número do bloco Ethereum L1 associado a este bloco Arbitrum.", + "sendCount": "Número de mensagens de saída L2-para-L1 enviadas neste bloco Arbitrum.", + "sendRoot": "Raiz de Merkle de todas as mensagens de saída L2-para-L1, usada para verificação entre camadas.", + "parentHash": "Hash do bloco anterior na cadeia, ligando os blocos entre si.", + "stateRoot": "Raiz de Merkle de todo o estado da blockchain após processar este bloco.", + "transactionsRoot": "Raiz de Merkle de todas as transações incluídas neste bloco.", + "receiptsRoot": "Raiz de Merkle de todos os recibos de transações deste bloco.", + "withdrawalsRoot": "Raiz de Merkle de todos os saques de validadores processados neste bloco.", + "logsBloom": "Um filtro bloom para buscar eficientemente logs de eventos emitidos neste bloco.", + "nonce": "Um valor usado na mineração proof-of-work. Sempre zero após o Merge.", + "mixHash": "Um hash usado no algoritmo proof-of-work. Substituído pelo valor RANDAO após o Merge.", + "sha3Uncles": "Hash da lista de blocos uncle. Sempre o hash de lista vazia após o Merge.", + "validator": "O índice do validador que iniciou esta retirada da beacon chain." + }, + "address": { + "verification": "Um contrato verificado tem seu código-fonte publicamente confirmado como correspondente ao bytecode implantado.", + "proxyType": "Este contrato usa um padrão de proxy atualizável. A lógica está em um contrato de implementação separado.", + "readContract": "Consultar dados do contrato sem gastar gas ou conectar uma carteira. Chamadas somente leitura.", + "writeContract": "Enviar uma transação que altera o estado do contrato. Requer uma carteira conectada e gas.", + "balance": "Saldo de moeda nativa mantido por este endereço.", + "usdValue": "Valor fiduciário estimado com base no preço de mercado atual.", + "nonce": "Número de transações enviadas a partir deste endereço. Usado para ordenação de transações.", + "eip7702Delegate": "Esta conta delega a execução de seu código a outro endereço via EIP-7702.", + "implementationAddress": "O contrato que contém a lógica real deste proxy.", + "ensName": "Nome do Ethereum Name Service associado a este endereço.", + "ensApp": "Aplicação ou resolvedor vinculado a este nome ENS." + }, + "token": { + "tokenStandard": "O padrão de interface do token (ex: ERC-20, ERC-721, ERC-1155) que define como este token se comporta.", + "decimals": "Número de casas decimais usadas para exibir quantidades do token. A maioria dos tokens ERC-20 usa 18.", + "totalSupply": "Número total de tokens criados para este contrato.", + "tokenId": "Um identificador numérico único para este token específico dentro de sua coleção.", + "owner": "O endereço que atualmente possui este NFT.", + "approved": "Um endereço autorizado a transferir este NFT específico em nome do proprietário.", + "metadataUri": "A URI onde os metadados deste token (nome, imagem, atributos) estão armazenados." + }, + "bitcoin": { + "txid": "Um identificador único para esta transação, calculado a partir de seus dados serializados.", + "witnessHash": "Hash da transação incluindo dados witness (SegWit). Difere do TXID para transações SegWit.", + "block": "O bloco que inclui esta transação.", + "status": "Se esta transação foi confirmada em um bloco ou ainda está esperando no mempool.", + "inputs": "O número de saídas anteriores sendo gastas. Cada entrada referencia um UTXO.", + "outputs": "O número de novas saídas criadas. Cada saída bloqueia BTC para um endereço.", + "fee": "A diferença entre entradas e saídas totais, paga ao minerador.", + "feePerByte": "Taxa de tarifa em satoshis por byte de dados brutos da transação.", + "feePerVByte": "Taxa de tarifa em satoshis por byte virtual. A métrica padrão para transações SegWit.", + "feePerWU": "Taxa de tarifa em satoshis por unidade de peso. A métrica de tarifa mais granular.", + "size": "Tamanho bruto da transação em bytes, incluindo dados witness.", + "virtualSize": "Tamanho ajustado que desconta dados witness. Usado para cálculo de taxas.", + "weight": "Peso da transação em unidades de peso (WU). 1 vByte = 4 WU.", + "coinbase": "Se esta é uma transação coinbase que cria novos BTC como recompensa de bloco.", + "witness": "Se esta transação usa o formato Segregated Witness (SegWit).", + "rbf": "Replace-By-Fee. Se habilitado, esta transação pode ser substituída por uma com taxa maior antes da confirmação.", + "version": "Número de versão do formato da transação. Determina quais recursos estão disponíveis.", + "locktime": "Altura de bloco ou momento mais cedo em que esta transação pode ser incluída em um bloco.", + "blockHash": "O hash único que identifica o bloco que contém esta transação.", + "minedBy": "O pool de mineração ou entidade que produziu este bloco.", + "blockReward": "Novos BTC criados como recompensa por minerar este bloco (subsídio + taxas).", + "totalFees": "Soma de todas as taxas de transação coletadas pelo minerador neste bloco.", + "feeRate": "Taxas de tarifa média e mediana das transações neste bloco.", + "transactions": "Número de transações incluídas neste bloco.", + "totalOutput": "Valor total de BTC de todas as saídas criadas neste bloco.", + "difficulty": "Quão difícil é encontrar um hash de bloco válido. Ajusta a cada 2016 blocos.", + "blockSize": "Tamanho total dos dados serializados do bloco.", + "blockWeight": "Peso do bloco em unidades de peso. Máximo: 4.000.000 WU.", + "previousBlock": "Hash do bloco anterior, ligando este bloco à cadeia.", + "nextBlock": "Hash do bloco seguinte, se existir.", + "coinbaseMessage": "Texto arbitrário incorporado na transação coinbase pelo minerador.", + "merkleRoot": "Hash raiz da árvore de Merkle de todas as transações neste bloco.", + "blockVersion": "Versão do formato do bloco. Codifica quais regras de consenso e soft forks são sinalizados.", + "bits": "Codificação compacta do limiar alvo para um hash de bloco válido.", + "blockNonce": "Um valor que mineradores iteram para encontrar um hash de bloco abaixo do alvo.", + "coinbaseHex": "Dados hexadecimais brutos da entrada da transação coinbase.", + "address": "Um endereço Bitcoin derivado de uma chave pública, usado para receber BTC.", + "balance": "BTC total mantido por este endereço em todas as saídas não gastas.", + "totalReceived": "BTC cumulativo recebido por este endereço em todas as transações.", + "utxos": "Saídas de Transação Não Gastas. Moedas individuais disponíveis para gasto por este endereço.", + "txCount": "Número total de transações envolvendo este endereço." + }, + "settings": { + "knowledgeLevel": "Controla quanta ajuda explicativa é exibida no explorador." + } +} diff --git a/src/locales/zh/common.json b/src/locales/zh/common.json index 188bbfae..201b44af 100644 --- a/src/locales/zh/common.json +++ b/src/locales/zh/common.json @@ -26,7 +26,14 @@ "disableSuperUser": "禁用超级用户模式", "enableSuperUser": "启用超级用户模式", "mempool": "内存池", - "txs": "交易" + "txs": "交易", + "tooltipsBeginner": "提示:初学者", + "tooltipsIntermediate": "提示:中级", + "tooltipsAdvanced": "提示:高级", + "tooltipsSwitched": "提示帮助级别已切换为{{level}}", + "tooltipsLevelBeginner": "初学者", + "tooltipsLevelIntermediate": "中级", + "tooltipsLevelAdvanced": "高级" }, "footer": { "about": "关于", diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index 91216afd..c880864f 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -149,6 +149,21 @@ } } }, + "helperTooltips": { + "title": "帮助提示", + "description": "为区块链数据字段显示上下文帮助。", + "enabled": { + "label": "显示帮助提示", + "description": "在字段标签旁显示帮助图标和简要说明。" + }, + "knowledgeLevel": { + "label": "知识水平", + "description": "控制显示多少帮助提示。初学者显示最多,高级显示最少。", + "beginner": "初学者", + "intermediate": "中级", + "advanced": "高级" + } + }, "rpcEndpoints": { "title": "RPC 端点", "description": "为每个网络配置 RPC URL。点击网络展开并配置其端点。", diff --git a/src/locales/zh/tooltips.json b/src/locales/zh/tooltips.json new file mode 100644 index 00000000..9136700b --- /dev/null +++ b/src/locales/zh/tooltips.json @@ -0,0 +1,128 @@ +{ + "transaction": { + "hash": "此交易的唯一标识符,由其内容生成。", + "from": "发起并签署此交易的地址。", + "status": "交易是否成功执行或已回滚。", + "confirmations": "此交易之后挖出的区块数。更多确认意味着更高的最终性。", + "interactedWith": "此交易调用的合约,或简单转账的接收地址。", + "transactionFee": "处理此交易支付的总费用(使用的Gas × Gas价格)。", + "gasPrice": "每单位Gas的价格,以Gwei为单位。更高的Gas价格可以更快被打包。", + "gasLimitUsage": "Gas限制是授权的最大Gas量。已用Gas是实际消耗的量。", + "nonce": "发送者的交易计数器。确保交易按顺序处理。", + "position": "此交易在区块中的索引。较低的位置优先处理。", + "type": "交易格式(如Legacy、EIP-1559、EIP-4844)。决定适用哪些费用字段。", + "decodedInput": "发送给合约的函数调用和参数,解码为可读格式。", + "maxFeePerBlobGas": "发送者愿意为数据可用性支付的每单位Blob Gas最高价格。", + "blobGasPrice": "此区块中实际支付的每单位Blob Gas价格。", + "blobGasUsed": "此交易的数据Blob消耗的Blob Gas量。", + "blobCount": "附加到此交易的数据Blob数量,用于Rollup数据可用性。", + "blobVersionedHashes": "唯一标识此交易提交的每个数据Blob的版本化哈希。", + "contractCreated": "此交易部署的新合约地址。", + "value": "此交易中转移的原生货币(如ETH)金额。", + "effectiveGasPrice": "经EIP-1559费用计算后实际支付的Gas价格,可能与请求的价格不同。", + "l1BlockNumber": "锚定此L2交易安全性的以太坊L1区块。", + "gasUsedForL1": "将此交易数据发布到以太坊L1所消耗的Gas。", + "l1Fee": "在以太坊L1上为数据可用性支付的费用。是此L2上总交易成本的一部分。", + "l1GasPrice": "用于计算数据可用性费用的L1 Gas价格。", + "l1GasUsed": "发布此交易calldata所需的预估L1 Gas。", + "l1FeeScalar": "L2排序器应用于L1费用计算的乘数。" + }, + "block": { + "hash": "标识此区块的唯一指纹,由其内容派生。", + "transactions": "此区块中包含和执行的交易数量。", + "withdrawals": "此区块中处理的验证者提款,返回质押的ETH。", + "feeRecipient": "接收此区块中交易优先费用的地址。", + "difficulty": "此区块的挖矿难度。在合并后的权益证明网络上始终为零。", + "totalDifficulty": "到此区块为止的链累积难度。工作量证明的遗留字段。", + "size": "区块数据的总大小(字节)。", + "extraData": "区块生产者设置的任意数据。通常包含客户端或矿池标识符。", + "gasUsed": "此区块中所有交易消耗的总Gas。", + "gasLimit": "此区块允许的最大Gas量,定义其容量。", + "baseFeePerGas": "此区块的最低Gas价格,由网络根据需求设定(EIP-1559)。", + "burntFees": "通过EIP-1559永久从流通中移除的交易费用。", + "blobGasUsed": "此区块中Blob交易消耗的总Blob Gas。", + "excessBlobGas": "超出目标的Blob Gas,用于计算下一个区块的Blob基础费用。", + "blobCount": "此区块中包含的数据Blob数量,用于Rollup数据可用性。", + "l1BlockNumber": "与此Arbitrum区块关联的以太坊L1区块号。", + "sendCount": "此Arbitrum区块中发送的L2到L1出站消息数量。", + "sendRoot": "所有L2到L1出站消息的Merkle根,用于跨层验证。", + "parentHash": "链中前一个区块的哈希,将区块链接在一起。", + "stateRoot": "处理此区块后整个区块链状态的Merkle根。", + "transactionsRoot": "此区块中包含的所有交易的Merkle根。", + "receiptsRoot": "此区块中所有交易收据的Merkle根。", + "withdrawalsRoot": "此区块中处理的所有验证者提款的Merkle根。", + "logsBloom": "用于高效搜索此区块中发出的事件日志的布隆过滤器。", + "nonce": "工作量证明挖矿中使用的值。合并后始终为零。", + "mixHash": "工作量证明算法中使用的哈希。合并后被RANDAO值替代。", + "sha3Uncles": "叔块列表的哈希。合并后始终为空列表哈希。", + "validator": "从信标链发起此提款的验证者索引。" + }, + "address": { + "verification": "已验证的合约其源代码已被公开确认与部署的字节码匹配。", + "proxyType": "此合约使用可升级代理模式。逻辑位于单独的实现合约中。", + "readContract": "查询合约数据,无需消耗Gas或连接钱包。只读调用。", + "writeContract": "发送改变合约状态的交易。需要连接钱包并消耗Gas。", + "balance": "此地址持有的原生货币余额。", + "usdValue": "基于当前市场价格的预估法币价值。", + "nonce": "从此地址发送的交易数量。用于交易排序。", + "eip7702Delegate": "此账户通过EIP-7702将其代码执行委托给另一个地址。", + "implementationAddress": "包含此代理实际逻辑的合约。", + "ensName": "与此地址关联的以太坊名称服务名称。", + "ensApp": "与此ENS名称关联的应用或解析器。" + }, + "token": { + "tokenStandard": "代币接口标准(如ERC-20、ERC-721、ERC-1155),定义了代币的行为方式。", + "decimals": "用于显示代币数量的小数位数。大多数ERC-20代币使用18位小数。", + "totalSupply": "为此合约创建的代币总数。", + "tokenId": "此特定代币在其集合中的唯一数字标识符。", + "owner": "当前持有此NFT的地址。", + "approved": "被授权代表所有者转移此特定NFT的地址。", + "metadataUri": "存储此代币元数据(名称、图片、属性)的URI。" + }, + "bitcoin": { + "txid": "此交易的唯一标识符,由其序列化数据计算得出。", + "witnessHash": "包含见证(SegWit)数据的交易哈希。对于SegWit交易与TXID不同。", + "block": "包含此交易的区块。", + "status": "此交易是否已在区块中确认,或仍在内存池中等待。", + "inputs": "正在花费的先前输出数量。每个输入引用一个UTXO。", + "outputs": "创建的新输出数量。每个输出将BTC锁定到一个地址。", + "fee": "总输入与总输出之间的差额,支付给矿工。", + "feePerByte": "每字节原始交易数据的费率(聪)。", + "feePerVByte": "每虚拟字节的费率(聪)。SegWit交易的标准费率指标。", + "feePerWU": "每重量单位的费率(聪)。最精细的费率指标。", + "size": "原始交易大小(字节),包括见证数据。", + "virtualSize": "折扣见证数据后的调整交易大小。用于费用计算。", + "weight": "交易重量(重量单位WU)。1 vByte = 4 WU。", + "coinbase": "这是否是创建新BTC作为区块奖励的coinbase交易。", + "witness": "此交易是否使用隔离见证(SegWit)格式。", + "rbf": "费用替换。如果启用,此交易可在确认前被更高费用的交易替换。", + "version": "交易格式版本号。决定可用的功能。", + "locktime": "此交易可被包含在区块中的最早区块高度或时间。", + "blockHash": "标识包含此交易的区块的唯一哈希。", + "minedBy": "产生此区块的矿池或实体。", + "blockReward": "挖出此区块的奖励新BTC(补贴+费用)。", + "totalFees": "矿工在此区块中收取的所有交易费用总和。", + "feeRate": "此区块中交易的平均和中位费率。", + "transactions": "此区块中包含的交易数量。", + "totalOutput": "此区块中创建的所有输出的BTC总值。", + "difficulty": "找到有效区块哈希的难度。每2016个区块调整一次。", + "blockSize": "序列化区块数据的总大小。", + "blockWeight": "区块重量(重量单位)。最大4,000,000 WU。", + "previousBlock": "前一个区块的哈希,将此区块链接到链上。", + "nextBlock": "下一个区块的哈希(如果存在)。", + "coinbaseMessage": "矿工在coinbase交易中嵌入的任意文本。", + "merkleRoot": "此区块中所有交易的Merkle树根哈希。", + "blockVersion": "区块格式版本。编码信号哪些共识规则和软分叉。", + "bits": "有效区块哈希目标阈值的紧凑编码。", + "blockNonce": "矿工迭代以找到低于目标的区块哈希的值。", + "coinbaseHex": "coinbase交易输入的原始十六进制数据。", + "address": "从公钥派生的比特币地址,用于接收BTC。", + "balance": "此地址在所有未花费输出中持有的BTC总量。", + "totalReceived": "此地址在所有交易中累计收到的BTC。", + "utxos": "未花费交易输出。此地址可用于花费的单个币。", + "txCount": "涉及此地址的交易总数。" + }, + "settings": { + "knowledgeLevel": "控制整个浏览器中显示的帮助说明数量。" + } +} diff --git a/src/styles/helper-tooltip.css b/src/styles/helper-tooltip.css new file mode 100644 index 00000000..1a6d6305 --- /dev/null +++ b/src/styles/helper-tooltip.css @@ -0,0 +1,128 @@ +/* ===== Helper Tooltip ===== */ + +.helper-tooltip { + position: relative; + display: inline-flex; + align-items: center; +} + +/* Trigger button */ +.helper-tooltip-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + margin-left: 5px; + background: none; + border: none; + cursor: pointer; + color: var(--text-tertiary); + opacity: 0.6; + transition: opacity 0.15s ease, color 0.15s ease; + vertical-align: middle; + line-height: 1; + flex-shrink: 0; +} + +.helper-tooltip-trigger:hover, +.helper-tooltip-trigger:focus-visible { + opacity: 1; + color: var(--color-info); + outline: none; +} + +.helper-tooltip-trigger:focus-visible { + outline: 2px solid var(--color-info); + outline-offset: 2px; + border-radius: 50%; +} + +/* Tooltip bubble */ +.helper-tooltip-bubble { + position: absolute; + left: 50%; + transform: translateX(-50%); + max-width: 260px; + min-width: 140px; + padding: 8px 12px; + background: var(--bg-tooltip); + color: #fff; + font-size: 0.8rem; + font-weight: 400; + line-height: 1.45; + border-radius: 6px; + box-shadow: var(--shadow-md); + z-index: 1000; + pointer-events: auto; + white-space: normal; + word-wrap: break-word; +} + +/* Top placement (default) */ +.helper-tooltip-top { + bottom: calc(100% + 6px); +} + +.helper-tooltip-top::after { + content: ""; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border-width: 5px; + border-style: solid; + border-color: var(--bg-tooltip) transparent transparent transparent; +} + +/* Bottom placement */ +.helper-tooltip-bottom { + top: calc(100% + 6px); +} + +.helper-tooltip-bottom::after { + content: ""; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + border-width: 5px; + border-style: solid; + border-color: transparent transparent var(--bg-tooltip) transparent; +} + +/* Light theme text: keep tooltip text white since bg-tooltip is dark in both themes */ +.light-theme .helper-tooltip-bubble { + color: #fff; +} + +/* ===== Navbar Tooltip Level Button ===== */ + +.navbar-toggle-btn.navbar-tooltip-level-beginner { + border-color: var(--color-primary-muted); +} + +.navbar-toggle-btn.navbar-tooltip-level-intermediate { + border-color: var(--color-info); +} + +.navbar-toggle-btn.navbar-tooltip-level-advanced { + border-color: var(--color-accent); +} + +/* Mobile adjustments */ +@media (max-width: 767px) { + .helper-tooltip-bubble { + max-width: 220px; + font-size: 0.78rem; + left: auto; + right: -8px; + transform: none; + } + + .helper-tooltip-top::after, + .helper-tooltip-bottom::after { + left: auto; + right: 12px; + transform: none; + } +} diff --git a/src/styles/styles.css b/src/styles/styles.css index 44b02a46..84c804e8 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -2478,7 +2478,8 @@ a.navbar-logo-dropdown-item:hover { } .notification { - width: clamp(240px, 90vw, 520px); + width: fit-content; + max-width: clamp(240px, 90vw, 520px); padding: 14px 18px; border-radius: 12px; box-shadow: 0 4px 14px -2px rgba(0, 0, 0, 0.2); diff --git a/src/types/index.ts b/src/types/index.ts index 169da421..c7b1be33 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -483,6 +483,11 @@ export type AIAnalysisType = */ export type PromptVersion = "stable" | "latest"; +/** + * Knowledge level for helper tooltip visibility + */ +export type KnowledgeLevel = "beginner" | "intermediate" | "advanced"; + /** * User settings for the application */ @@ -495,6 +500,8 @@ export interface UserSettings { isSuperUser?: boolean; promptVersion?: PromptVersion; persistentCacheSizeMB?: number; + knowledgeLevel?: KnowledgeLevel; + showHelperTooltips?: boolean; } /** @@ -509,6 +516,8 @@ export const DEFAULT_SETTINGS: UserSettings = { isSuperUser: false, promptVersion: "stable", persistentCacheSizeMB: 10, + knowledgeLevel: "beginner", + showHelperTooltips: true, }; /** From 0e7060259cff33aca994e12a1b2dfc8e59eea968 Mon Sep 17 00:00:00 2001 From: Mati OS Date: Wed, 18 Mar 2026 20:42:29 -0300 Subject: [PATCH 05/54] chore(@openscan/network-connectors): Update dependency to 1.6.0 --- bun.lock | 5 +++-- package.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index df7592c8..b39d6261 100644 --- a/bun.lock +++ b/bun.lock @@ -1,11 +1,12 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "openscan", "dependencies": { "@erc7730/sdk": "^0.1.3", - "@openscan/network-connectors": "1.4.0", + "@openscan/network-connectors": "1.6.0", "@rainbow-me/rainbowkit": "^2.2.8", "@react-native-async-storage/async-storage": "^1.24.0", "@tanstack/react-query": "^5.90.21", @@ -279,7 +280,7 @@ "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], - "@openscan/network-connectors": ["@openscan/network-connectors@1.4.0", "", {}, "sha512-a27b86OBZCXtCI5iKYgttXiEyG9I7NQ5QpRp2PQGydbBMrTF1U4XI3VcJJFm14FD9z8L5WW+VEZ8f7cglh3HPA=="], + "@openscan/network-connectors": ["@openscan/network-connectors@1.6.0", "", {}, "sha512-f7Ox20+NIJ4DXzcbj0OyyuVjiHB0zjm1xv+yhzrYhh6TfW6bfHpq62+++oF6/5ud5VLptRXSkdaMrLcAOJL0vQ=="], "@paulmillr/qr": ["@paulmillr/qr@0.2.1", "", {}, "sha512-IHnV6A+zxU7XwmKFinmYjUcwlyK9+xkG3/s9KcQhI9BjQKycrJ1JRO+FbNYPwZiPKW3je/DR0k7w8/gLa5eaxQ=="], diff --git a/package.json b/package.json index a16a2c5f..7441e9db 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "dependencies": { "@erc7730/sdk": "^0.1.3", - "@openscan/network-connectors": "1.4.0", + "@openscan/network-connectors": "1.6.0", "@rainbow-me/rainbowkit": "^2.2.8", "@react-native-async-storage/async-storage": "^1.24.0", "@tanstack/react-query": "^5.90.21", From caab167795a9dcefefa9f78fc52da3312805adb3 Mon Sep 17 00:00:00 2001 From: Mati OS Date: Wed, 18 Mar 2026 21:22:21 -0300 Subject: [PATCH 06/54] feat(Hardhat): Add network support --- .../adapters/HardhatAdapter/HardhatAdapter.ts | 307 ++++++++++++++++++ src/services/adapters/adaptersFactory.ts | 8 +- 2 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 src/services/adapters/HardhatAdapter/HardhatAdapter.ts diff --git a/src/services/adapters/HardhatAdapter/HardhatAdapter.ts b/src/services/adapters/HardhatAdapter/HardhatAdapter.ts new file mode 100644 index 00000000..9afe787e --- /dev/null +++ b/src/services/adapters/HardhatAdapter/HardhatAdapter.ts @@ -0,0 +1,307 @@ +import { type BlockNumberOrTag, NetworkAdapter, type TraceResult } from "../NetworkAdapter"; +import type { Block, Transaction, Address, NetworkStats, DataWithMetadata } from "../../../types"; +import type { CallNode, PrestateTrace } from "../NetworkAdapter"; +import { normalizeGethCallTrace } from "../../../utils/callTreeUtils"; +import { logger } from "../../../utils/logger"; +import { + transformRPCBlockToBlock, + transformRPCTransactionToTransaction, + createAddressFromBalance, + hexToNumber, +} from "../EVMAdapter/utils"; + +import { normalizeBlockNumber } from "../shared/normalizeBlockNumber"; +import { mergeMetadata } from "../shared/mergeMetadata"; +import type { EthereumClient, HardhatClient } from "@openscan/network-connectors"; + +/** + * Hardhat local development network adapter + * Chain ID: 31337 + * Uses HardhatClient which supports standard Ethereum methods + * plus Hardhat-specific methods (hardhat_*, evm_*, debug_*, trace_*) + */ +export class HardhatAdapter extends NetworkAdapter { + private client: HardhatClient; + + constructor(client: HardhatClient) { + super(31337); + this.client = client; + this.initTxSearch(client as unknown as EthereumClient); + } + + protected getClient(): EthereumClient { + return this.client as unknown as EthereumClient; + } + + /** + * Get the typed HardhatClient for Hardhat-specific operations + */ + getHardhatClient(): HardhatClient { + return this.client; + } + + async getBlock(blockNumber: BlockNumberOrTag): Promise> { + const normalizedBlockNumber = normalizeBlockNumber(blockNumber); + const result = await this.client.getBlockByNumber(normalizedBlockNumber); + + const blockData = result.data; + if (!blockData) { + throw new Error(`Block ${blockNumber} not found`); + } + + const block = transformRPCBlockToBlock(blockData); + + return { + data: block, + metadata: result.metadata as DataWithMetadata["metadata"], + }; + } + + async getBlockWithTransactions( + blockNumber: BlockNumberOrTag, + ): Promise { + const normalizedBlockNumber = normalizeBlockNumber(blockNumber); + const result = await this.client.getBlockByNumber(normalizedBlockNumber, true); + + const blockData = result.data; + if (!blockData) { + throw new Error(`Block ${blockNumber} not found`); + } + + const block = transformRPCBlockToBlock(blockData); + + const transactionDetails: Transaction[] = []; + if (Array.isArray(blockData.transactions)) { + for (const tx of blockData.transactions) { + if (typeof tx !== "string") { + transactionDetails.push(transformRPCTransactionToTransaction(tx)); + } + } + } + + return { + ...block, + transactionDetails, + }; + } + + async getTransaction(txHash: string): Promise> { + const [txResult, receiptResult] = await Promise.all([ + this.client.getTransactionByHash(txHash), + this.client.getTransactionReceipt(txHash), + ]); + + const txData = txResult.data; + if (!txData) { + throw new Error(`Transaction ${txHash} not found`); + } + + const receiptData = receiptResult.data; + const transaction = transformRPCTransactionToTransaction(txData, receiptData); + + if (txData.blockNumber) { + try { + const blockResult = await this.getBlock(txData.blockNumber); + + if (blockResult.data) { + transaction.timestamp = blockResult.data.timestamp; + transaction.blockBaseFeePerGas = blockResult.data.baseFeePerGas; + } + } catch (error) { + logger.warn("Failed to fetch block for transaction timestamp:", error); + } + } + + return { + data: transaction, + metadata: txResult.metadata as DataWithMetadata["metadata"], + }; + } + + async getAddress(address: string): Promise> { + const [balanceResult, codeResult, txCountResult] = await Promise.all([ + this.client.getBalance(address, "latest"), + this.client.getCode(address, "latest"), + this.client.getTransactionCount(address, "latest"), + ]); + + const balance = balanceResult.data || "0x0"; + const code = codeResult.data || "0x"; + const txCount = txCountResult.data || "0x0"; + + const addressData = createAddressFromBalance(address, balance, code, txCount); + + return { + data: addressData, + metadata: balanceResult.metadata as DataWithMetadata
    ["metadata"], + }; + } + + async getLatestBlockNumber(): Promise { + const result = await this.client.blockNumber(); + const blockNumber = result.data || "0x0"; + return hexToNumber(blockNumber); + } + + async getNetworkStats(): Promise> { + const [gasPriceResult, syncingResult, blockNumberResult, versionResult] = await Promise.all([ + this.client.gasPrice(), + this.client.syncing(), + this.client.blockNumber(), + this.client.clientVersion(), + ]); + + const gasPrice = gasPriceResult.data || "0x0"; + const syncing = syncingResult.data; + const blockNumber = blockNumberResult.data || "0x0"; + const clientVersion = versionResult.data || "unknown"; + + const stats: NetworkStats = { + currentGasPrice: gasPrice, + isSyncing: typeof syncing === "boolean" ? syncing : true, + currentBlockNumber: blockNumber, + clientVersion: clientVersion, + metadata: {}, + }; + + return { + data: stats, + metadata: gasPriceResult.metadata as DataWithMetadata["metadata"], + }; + } + + async getLatestBlocks(count = 10): Promise { + const latestBlockNumber = await this.getLatestBlockNumber(); + const blocks: Block[] = []; + + const promises = []; + for (let i = 0; i < count; i++) { + const blockNum = latestBlockNumber - i; + if (blockNum >= 0) { + promises.push(this.getBlock(blockNum)); + } + } + + const results = await Promise.all(promises); + for (const result of results) { + blocks.push(result.data); + } + + return blocks; + } + + async getTransactionsFromLatestBlocks( + blockCount = 10, + ): Promise> { + const latestBlockNumber = await this.getLatestBlockNumber(); + const result = await this.getTransactionsFromBlockRange(latestBlockNumber, blockCount); + return result.data; + } + + async getTransactionsFromBlockRange( + fromBlock: number, + blockCount = 10, + maxTransactions?: number, + ): Promise>> { + const transactions: Array = []; + const metadataList: Array["metadata"]> = []; + + for (let i = 0; i < blockCount; i++) { + if (maxTransactions && transactions.length >= maxTransactions) break; + + const blockNum = fromBlock - i; + if (blockNum < 0) break; + + try { + const blockResult = await this.getBlock(blockNum); + const blockWithTxs = await this.getBlockWithTransactions(blockNum); + + metadataList.push(blockResult.metadata); + + for (const tx of blockWithTxs.transactionDetails) { + transactions.push({ + ...tx, + blockNumber: blockWithTxs.number, + }); + } + } catch (error) { + logger.error(`Error fetching block ${blockNum}:`, error); + } + } + + const mergedMetadata = + mergeMetadata>(metadataList); + + return { + data: transactions, + metadata: mergedMetadata, + }; + } + + getChainId(): number { + return this.networkId; + } + + isTraceAvailable(): boolean { + return true; + } + + async getTransactionTrace(txHash: string): Promise { + try { + const result = await this.client.debugTraceTransaction(txHash, {}); + return result.data; + } catch (error) { + logger.error("Error getting transaction trace:", error); + return null; + } + } + + // biome-ignore lint/suspicious/noExplicitAny: Generic trace result + async getCallTrace(txHash: string): Promise { + try { + const result = await this.client.traceTransaction(txHash); + return result.data; + } catch (error) { + logger.error("Error getting call trace:", error); + return null; + } + } + + async getBlockTrace(blockHash: string): Promise { + try { + const blockResult = await this.client.getBlockByHash(blockHash, false); + const blockData = blockResult.data; + if (!blockData) return null; + + const blockNumber = hexToNumber(blockData.number); + const result = await this.client.traceBlock(blockNumber); + return result.data; + } catch (error) { + logger.error("Error getting block trace:", error); + return null; + } + } + + async getAnalyserCallTrace(txHash: string): Promise { + try { + const result = await this.client.debugTraceTransaction(txHash, { tracer: "callTracer" }); + return result.data ? normalizeGethCallTrace(result.data) : null; + } catch (error) { + logger.error("Error getting analyser call trace:", error); + return null; + } + } + + async getAnalyserPrestateTrace(txHash: string): Promise { + try { + const result = await this.client.debugTraceTransaction(txHash, { + tracer: "prestateTracer", + tracerConfig: { diffMode: true }, + }); + return (result.data as PrestateTrace) ?? null; + } catch (error) { + logger.error("Error getting analyser prestate trace:", error); + return null; + } + } +} diff --git a/src/services/adapters/adaptersFactory.ts b/src/services/adapters/adaptersFactory.ts index 7379a3ed..956d85a1 100644 --- a/src/services/adapters/adaptersFactory.ts +++ b/src/services/adapters/adaptersFactory.ts @@ -5,6 +5,7 @@ import { BaseAdapter } from "./BaseAdapter/BaseAdapter"; import { BNBAdapter } from "./BNBAdapter/BNBAdapter"; import { PolygonAdapter } from "./PolygonAdapter/PolygonAdapter"; import { ArbitrumAdapter } from "./ArbitrumAdapter/ArbitrumAdapter"; +import { HardhatAdapter } from "./HardhatAdapter/HardhatAdapter"; import { BitcoinAdapter } from "./BitcoinAdapter/BitcoinAdapter"; import type { ArbitrumClient, @@ -14,6 +15,7 @@ import type { BitcoinClient, BNBClient, EthereumClient, + HardhatClient, OptimismClient, PolygonClient, SupportedChainId, @@ -34,14 +36,16 @@ export class AdapterFactory { | BaseClient | ArbitrumClient | AvalancheClient - | AztecClient, + | AztecClient + | HardhatClient, ): NetworkAdapter { switch (networkId) { case 1: case 11155111: - case 31337: case 43114: return new EVMAdapter(networkId, client as unknown as EthereumClient); + case 31337: + return new HardhatAdapter(client as HardhatClient); case 10: return new OptimismAdapter(networkId, client as OptimismClient); case 56: From 138396683cfae4d2f6496092ea75022cae5dace9 Mon Sep 17 00:00:00 2001 From: Mati OS Date: Wed, 18 Mar 2026 23:34:03 -0300 Subject: [PATCH 07/54] test(hardhat): Enable only hardhat on run dev --- scripts/run-test-env.sh | 2 +- src/config/networks.json | 23 +++++++++++++++++++++++ src/context/AppContext.tsx | 35 +---------------------------------- 3 files changed, 25 insertions(+), 35 deletions(-) diff --git a/scripts/run-test-env.sh b/scripts/run-test-env.sh index b5310575..46ebe8a9 100755 --- a/scripts/run-test-env.sh +++ b/scripts/run-test-env.sh @@ -86,7 +86,7 @@ echo "🔍 Starting OpenScan (Ethereum Mainnet + hardhat only)..." cd "$OPENSCAN_DIR" # Start OpenScan - it will read .env.local on start -REACT_APP_OPENSCAN_NETWORKS="1,31337" npm start & +REACT_APP_OPENSCAN_NETWORKS="31337" npm start & OPENSCAN_PID=$! # Wait for OpenScan to start diff --git a/src/config/networks.json b/src/config/networks.json index a7b49e61..248a8c19 100644 --- a/src/config/networks.json +++ b/src/config/networks.json @@ -262,6 +262,29 @@ } ] }, + { + "type": "evm", + "networkId": "eip155:31337", + "slug": "localhost", + "name": "Hardhat", + "shortName": "Hardhat", + "description": "Local development network (Hardhat/Anvil)", + "currency": "ETH", + "color": "#FFF100", + "isTestnet": true, + "links": [ + { + "name": "Docs", + "url": "https://hardhat.org/docs", + "description": "Hardhat documentation" + }, + { + "name": "GitHub", + "url": "https://github.com/NomicFoundation/hardhat", + "description": "Hardhat GitHub repository" + } + ] + }, { "type": "bitcoin", "networkId": "bip122:000000000019d6689c085ae165831e93", diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx index c1f1fab3..32d8ab9c 100644 --- a/src/context/AppContext.tsx +++ b/src/context/AppContext.tsx @@ -19,7 +19,6 @@ import type { IAppContext, NetworkConfig, RpcUrlsContextType } from "../types"; import { fetchAllRpcs } from "../services/MetadataService"; import { loadJsonFilesFromStorage, saveJsonFilesToStorage } from "../utils/artifactsStorage"; import { logger } from "../utils/logger"; -import { getChainIdFromNetwork } from "../utils/networkResolver"; import { getEffectiveRpcUrls, isMetadataRpcCacheFresh, @@ -84,23 +83,6 @@ export const AppContextProvider = ({ children }: { children: ReactNode }) => { } }, []); - // Hardhat network config for local development - const hardhatNetwork: NetworkConfig = useMemo( - () => ({ - type: "evm" as const, - networkId: "eip155:31337", - chainId: 31337, - slug: "localhost", - name: "Hardhat", - shortName: "hardhat", - description: "Local development network", - color: "#FFF100", - currency: "ETH", - isTestnet: true, - }), - [], - ); - // Load networks from metadata const loadNetworkData = useCallback(async () => { setNetworksLoading(true); @@ -108,21 +90,6 @@ export const AppContextProvider = ({ children }: { children: ReactNode }) => { try { const loadedNetworks = await loadNetworks(); - - // Check if Hardhat should be included (only when both conditions are met) - const envNetworks = process.env.REACT_APP_OPENSCAN_NETWORKS; - const isDevelopment = import.meta.env.VITE_ENVIRONMENT === "development"; - const hardhatInEnv = envNetworks?.split(",").some((id) => id.trim() === "31337"); - - // Add Hardhat network if in development AND explicitly enabled - if ( - isDevelopment && - hardhatInEnv && - !loadedNetworks.some((n) => getChainIdFromNetwork(n) === 31337) - ) { - loadedNetworks.push(hardhatNetwork); - } - setNetworks(loadedNetworks); // Fetch metadata RPCs if cache is stale or missing, then update RPC state @@ -142,7 +109,7 @@ export const AppContextProvider = ({ children }: { children: ReactNode }) => { } finally { setNetworksLoading(false); } - }, [hardhatNetwork]); + }, []); const _account = useAccount(); const { isFullyConnected, address } = useWagmiConnection(); From deeb4d9fa96db7e731bf46b128ab375a6ab2748d Mon Sep 17 00:00:00 2001 From: Mati OS Date: Wed, 18 Mar 2026 23:50:18 -0300 Subject: [PATCH 08/54] feat(Hardhat): Support trace methods using struct log conversion Hardhat v3 does not support Geth's callTracer or prestateTracer options for debug_traceTransaction. Add struct log conversion utilities that reconstruct call trees and state diffs from opcode-level traces. --- .../adapters/HardhatAdapter/HardhatAdapter.ts | 46 ++- src/utils/structLogConverter.ts | 314 ++++++++++++++++++ 2 files changed, 353 insertions(+), 7 deletions(-) create mode 100644 src/utils/structLogConverter.ts diff --git a/src/services/adapters/HardhatAdapter/HardhatAdapter.ts b/src/services/adapters/HardhatAdapter/HardhatAdapter.ts index 9afe787e..680d6c3b 100644 --- a/src/services/adapters/HardhatAdapter/HardhatAdapter.ts +++ b/src/services/adapters/HardhatAdapter/HardhatAdapter.ts @@ -1,7 +1,10 @@ import { type BlockNumberOrTag, NetworkAdapter, type TraceResult } from "../NetworkAdapter"; import type { Block, Transaction, Address, NetworkStats, DataWithMetadata } from "../../../types"; import type { CallNode, PrestateTrace } from "../NetworkAdapter"; -import { normalizeGethCallTrace } from "../../../utils/callTreeUtils"; +import { + buildCallTreeFromStructLogs, + buildPrestateFromStructLogs, +} from "../../../utils/structLogConverter"; import { logger } from "../../../utils/logger"; import { transformRPCBlockToBlock, @@ -284,8 +287,24 @@ export class HardhatAdapter extends NetworkAdapter { async getAnalyserCallTrace(txHash: string): Promise { try { - const result = await this.client.debugTraceTransaction(txHash, { tracer: "callTracer" }); - return result.data ? normalizeGethCallTrace(result.data) : null; + // Hardhat v3 does not support callTracer — use default struct log tracer + // and convert the opcode trace into a call tree. + const [traceResult, txResult] = await Promise.all([ + this.client.debugTraceTransaction(txHash, {}), + this.client.getTransactionByHash(txHash), + ]); + + const trace = traceResult.data as TraceResult | undefined; + const txData = txResult.data; + if (!trace?.structLogs || !txData) return null; + + return buildCallTreeFromStructLogs(trace, { + from: txData.from ?? "", + to: txData.to ?? "", + value: txData.value ?? "0x0", + gas: txData.gas ?? "0x0", + input: txData.input ?? "0x", + }); } catch (error) { logger.error("Error getting analyser call trace:", error); return null; @@ -294,11 +313,24 @@ export class HardhatAdapter extends NetworkAdapter { async getAnalyserPrestateTrace(txHash: string): Promise { try { - const result = await this.client.debugTraceTransaction(txHash, { - tracer: "prestateTracer", - tracerConfig: { diffMode: true }, + // Hardhat v3 does not support prestateTracer — use default struct log tracer + // and extract storage changes from SLOAD/SSTORE operations. + const [traceResult, txResult] = await Promise.all([ + this.client.debugTraceTransaction(txHash, {}), + this.client.getTransactionByHash(txHash), + ]); + + const trace = traceResult.data as TraceResult | undefined; + const txData = txResult.data; + if (!trace?.structLogs || !txData) return null; + + return buildPrestateFromStructLogs(trace, { + from: txData.from ?? "", + to: txData.to ?? "", + value: txData.value ?? "0x0", + gas: txData.gas ?? "0x0", + input: txData.input ?? "0x", }); - return (result.data as PrestateTrace) ?? null; } catch (error) { logger.error("Error getting analyser prestate trace:", error); return null; diff --git a/src/utils/structLogConverter.ts b/src/utils/structLogConverter.ts new file mode 100644 index 00000000..35f5702b --- /dev/null +++ b/src/utils/structLogConverter.ts @@ -0,0 +1,314 @@ +import type { + CallNode, + PrestateAccountState, + PrestateTrace, + TraceLog, + TraceResult, +} from "../services/adapters/NetworkAdapter"; + +/** + * Call opcodes that create a new frame in the EVM execution stack. + */ +const CALL_OPS = new Set(["CALL", "STATICCALL", "DELEGATECALL", "CALLCODE", "CREATE", "CREATE2"]); + +/** + * Opcodes that terminate the current frame. + */ +const RETURN_OPS = new Set(["RETURN", "REVERT", "STOP", "SELFDESTRUCT", "INVALID"]); + +/** Read a 256-bit stack word as a hex address (last 20 bytes). */ +function stackAddr(word: string): string { + const raw = word.replace(/^0x/, "").padStart(40, "0"); + return `0x${raw.slice(-40)}`; +} + +/** Ensure a value has 0x prefix. */ +function ensureHex(word: string): string { + return word.startsWith("0x") ? word : `0x${word}`; +} + +/** Convert a number to a hex string. */ +function toHex(n: number): string { + return `0x${n.toString(16)}`; +} + +interface TxContext { + from: string; + to: string; + value: string; + gas: string; + input: string; +} + +interface FrameInfo { + node: CallNode; + startGas: number; +} + +/** + * Build a CallNode tree from EVM struct logs (opcode-level trace). + * + * This is used for Hardhat which only supports the default struct log tracer + * and does not support Geth's `callTracer`. + * + * The algorithm tracks CALL/STATICCALL/DELEGATECALL/CREATE opcodes and their + * corresponding RETURN/REVERT/STOP to reconstruct the call hierarchy. + */ +export function buildCallTreeFromStructLogs(trace: TraceResult, tx: TxContext): CallNode { + const root: CallNode = { + type: "CALL", + from: tx.from, + to: tx.to, + value: tx.value, + gas: tx.gas, + gasUsed: toHex(trace.gas), + input: tx.input, + output: trace.returnValue ? ensureHex(trace.returnValue) : undefined, + error: trace.failed ? "execution reverted" : undefined, + calls: [], + }; + + const stack: FrameInfo[] = [{ node: root, startGas: Number.parseInt(tx.gas, 16) || 0 }]; + const logs = trace.structLogs; + + for (let i = 0; i < logs.length; i++) { + const log = logs[i] as TraceLog; + + if (CALL_OPS.has(log.op) && log.stack) { + const child = extractCallFromStack(log, stack); + if (child) { + const parent = stack[stack.length - 1] as FrameInfo; + if (!parent.node.calls) parent.node.calls = []; + parent.node.calls.push(child.node); + stack.push(child); + } + } else if (RETURN_OPS.has(log.op)) { + if (stack.length > 1) { + const frame = stack.pop(); + if (frame) { + if (log.op === "REVERT") { + frame.node.error = "execution reverted"; + } + frame.node.gasUsed = toHex(Math.max(0, frame.startGas - log.gas)); + } + } + } + } + + cleanEmptyCalls(root); + return root; +} + +/** + * Extract call information from the EVM stack at a CALL-type opcode. + * + * Stack layout (top to bottom) for each opcode: + * CALL: gas, to, value, inOffset, inSize, outOffset, outSize + * CALLCODE: gas, to, value, inOffset, inSize, outOffset, outSize + * STATICCALL: gas, to, inOffset, inSize, outOffset, outSize + * DELEGATECALL: gas, to, inOffset, inSize, outOffset, outSize + * CREATE: value, offset, size + * CREATE2: value, offset, size, salt + */ +function extractCallFromStack(log: TraceLog, callStack: FrameInfo[]): FrameInfo | null { + const s = log.stack; + if (!s || s.length === 0) return null; + + const parent = callStack[callStack.length - 1] as FrameInfo; + let type = log.op; + let to: string | undefined; + let value: string | undefined; + let gas: string | undefined; + + // Stack is bottom-to-top in the array, so top of stack = last element + const len = s.length; + + switch (log.op) { + case "CALL": + case "CALLCODE": { + if (len < 7) return null; + const gasWord = s[len - 1] as string; + const toWord = s[len - 2] as string; + const valWord = s[len - 3] as string; + gas = ensureHex(gasWord); + to = stackAddr(toWord); + value = ensureHex(valWord); + break; + } + case "STATICCALL": + case "DELEGATECALL": { + if (len < 6) return null; + const gasWord = s[len - 1] as string; + const toWord = s[len - 2] as string; + gas = ensureHex(gasWord); + to = stackAddr(toWord); + value = "0x0"; + break; + } + case "CREATE": { + if (len < 3) return null; + const valWord = s[len - 1] as string; + value = ensureHex(valWord); + gas = toHex(log.gas); + type = "CREATE"; + to = undefined; + break; + } + case "CREATE2": { + if (len < 4) return null; + const valWord = s[len - 1] as string; + value = ensureHex(valWord); + gas = toHex(log.gas); + type = "CREATE2"; + to = undefined; + break; + } + default: + return null; + } + + // For DELEGATECALL, msg.sender stays the same as the parent's from + const from = + log.op === "DELEGATECALL" + ? (parent.node.from ?? "") + : (parent.node.to ?? parent.node.from ?? ""); + + const node: CallNode = { + type: type.toUpperCase(), + from, + to, + value, + gas, + gasUsed: undefined, + input: undefined, + output: undefined, + calls: [], + }; + + const startGas = gas ? Number.parseInt(gas, 16) || 0 : log.gas; + return { node, startGas }; +} + +/** Remove empty calls arrays to match callTracer output format. */ +function cleanEmptyCalls(node: CallNode): void { + if (node.calls && node.calls.length === 0) { + node.calls = undefined; + } else if (node.calls) { + for (const child of node.calls) { + cleanEmptyCalls(child); + } + } +} + +/** + * Build a PrestateTrace (pre/post state diff) from EVM struct logs. + * + * Tracks SLOAD/SSTORE operations to identify storage changes. + * + * Note: This produces a best-effort approximation since struct logs + * don't contain the full pre/post state like a native prestateTracer. + */ +export function buildPrestateFromStructLogs( + trace: TraceResult, + tx: TxContext, +): PrestateTrace | null { + const pre: Record = {}; + const post: Record = {}; + + const storageReads: Record> = {}; + const storageWrites: Record> = {}; + + // Track which contract is executing at each depth + const addressByDepth: Record = {}; + addressByDepth[1] = tx.to.toLowerCase(); + + for (const log of trace.structLogs) { + const currentAddr = addressByDepth[log.depth] ?? tx.to.toLowerCase(); + + if (log.op === "SLOAD" && log.stack && log.storage) { + const slot = log.stack[log.stack.length - 1]; + if (slot !== undefined) { + const addr = currentAddr; + if (!storageReads[addr]) storageReads[addr] = {}; + const hexSlot = ensureHex(slot); + const rawSlot = slot.replace(/^0x/, ""); + const storageVal = log.storage[rawSlot]; + if (storageVal) { + (storageReads[addr] as Record)[hexSlot] = ensureHex(storageVal); + } + } + } + + if (log.op === "SSTORE" && log.stack) { + const len = log.stack.length; + if (len >= 2) { + const slot = log.stack[len - 1]; + const val = log.stack[len - 2]; + if (slot !== undefined && val !== undefined) { + const addr = currentAddr; + if (!storageWrites[addr]) storageWrites[addr] = {}; + const hexSlot = ensureHex(slot); + (storageWrites[addr] as Record)[hexSlot] = ensureHex(val); + + // If we haven't seen a read for this slot, record pre-state from storage map + const addrReads = storageReads[addr]; + if ((!addrReads || !addrReads[hexSlot]) && log.storage) { + if (!storageReads[addr]) storageReads[addr] = {}; + const rawSlot = slot.replace(/^0x/, ""); + const storageVal = log.storage[rawSlot]; + if (storageVal) { + (storageReads[addr] as Record)[hexSlot] = ensureHex(storageVal); + } + } + } + } + } + + // Track address context changes from CALL opcodes + if (CALL_OPS.has(log.op) && log.stack) { + const len = log.stack.length; + if (log.op === "CALL" || log.op === "CALLCODE" || log.op === "STATICCALL") { + const toWord = len >= 2 ? log.stack[len - 2] : undefined; + if (toWord) { + addressByDepth[log.depth + 1] = stackAddr(toWord); + } + } else if (log.op === "DELEGATECALL") { + addressByDepth[log.depth + 1] = currentAddr; + } + } + } + + // Build pre state from reads + for (const [addr, slots] of Object.entries(storageReads)) { + if (!pre[addr]) pre[addr] = {}; + (pre[addr] as PrestateAccountState).storage = slots; + } + + // Build post state from writes (merged with reads for unchanged slots) + for (const [addr, slots] of Object.entries(storageWrites)) { + if (!post[addr]) post[addr] = {}; + const preStorage = storageReads[addr] ?? {}; + (post[addr] as PrestateAccountState).storage = { ...preStorage, ...slots }; + } + + // Include pre-state for addresses that had writes + for (const addr of Object.keys(storageWrites)) { + if (!pre[addr]) pre[addr] = {}; + const preEntry = pre[addr] as PrestateAccountState; + if (!preEntry.storage) preEntry.storage = storageReads[addr] ?? {}; + } + + // Add sender and receiver + const sender = tx.from.toLowerCase(); + const receiver = tx.to.toLowerCase(); + if (!pre[sender]) pre[sender] = {}; + if (!post[sender]) post[sender] = {}; + if (!pre[receiver]) pre[receiver] = {}; + if (!post[receiver]) post[receiver] = {}; + + if (Object.keys(pre).length === 0 && Object.keys(post).length === 0) { + return null; + } + + return { pre, post }; +} From f20e97684d7bef07f4660ea448b102e94577c0b7 Mon Sep 17 00:00:00 2001 From: Mati OS Date: Thu, 19 Mar 2026 00:01:19 -0300 Subject: [PATCH 09/54] docs: Add hardhat updates to .claude/ and README.md --- .claude/rules/architecture.md | 10 +++++----- .claude/rules/commands.md | 2 +- .claude/rules/patterns.md | 11 ++++++----- README.md | 27 +++++++++++++++++++-------- 4 files changed, 31 insertions(+), 19 deletions(-) diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index cf1ade9f..706f0eef 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -25,7 +25,7 @@ Orchestrates data fetching with caching and metadata: - Instantiates network-specific fetchers/adapters based on chain ID - Returns `DataWithMetadata` when using parallel strategy - 30-second in-memory cache keyed by `networkId:type:identifier` -- Supports trace operations for localhost networks only +- Supports trace operations for Hardhat (31337) and localhost networks ### 5. Hook Layer (`hooks/`) React integration: @@ -42,10 +42,10 @@ Global state management: Chain ID detection in `DataService` constructor determines which adapters/fetchers to use: -- **Arbitrum** (42161): `BlockFetcherArbitrum`, `BlockArbitrumAdapter` - adds `l1BlockNumber`, `sendCount`, `requestId` -- **OP Stack** (10, 8453): Optimism (10), Base (8453) - adds L1 fee breakdown (`l1Fee`, `l1GasPrice`, `l1GasUsed`) -- **Localhost** (31337): All networks + trace support (`debug_traceTransaction`, `trace_block`, etc.) -- **Default**: L1 fetchers/adapters for Ethereum (1), BSC (56, 97), Polygon (137), Sepolia (11155111) +- **Arbitrum** (42161): `ArbitrumAdapter` - adds `l1BlockNumber`, `sendCount`, `requestId` +- **OP Stack** (10, 8453): `OptimismAdapter` (10), `BaseAdapter` (8453) - adds L1 fee breakdown (`l1Fee`, `l1GasPrice`, `l1GasUsed`) +- **Hardhat** (31337): `HardhatAdapter` - uses `HardhatClient` from `@openscan/network-connectors`; trace support via struct log conversion (`buildCallTreeFromStructLogs`, `buildPrestateFromStructLogs` in `src/utils/structLogConverter.ts`) since Hardhat v3 does not support `callTracer`/`prestateTracer` +- **Default**: `EVMAdapter` for Ethereum (1), BSC (56, 97), Polygon (137), Sepolia (11155111), Avalanche (43114) ## Key Type Definitions diff --git a/.claude/rules/commands.md b/.claude/rules/commands.md index b746d64d..d65e9750 100644 --- a/.claude/rules/commands.md +++ b/.claude/rules/commands.md @@ -93,4 +93,4 @@ REACT_APP_OPENSCAN_NETWORKS="1,31337" npm start npm start ``` -Supported chain IDs: 1 (Ethereum), 42161 (Arbitrum), 10 (Optimism), 8453 (Base), 56 (BSC), 137 (Polygon), 31337 (Localhost), 97 (BSC Testnet), 11155111 (Sepolia) +Supported chain IDs: 1 (Ethereum), 42161 (Arbitrum), 10 (Optimism), 8453 (Base), 56 (BSC), 137 (Polygon), 31337 (Hardhat), 97 (BSC Testnet), 11155111 (Sepolia), 43114 (Avalanche) diff --git a/.claude/rules/patterns.md b/.claude/rules/patterns.md index 8d9e0d11..cd5aec9d 100644 --- a/.claude/rules/patterns.md +++ b/.claude/rules/patterns.md @@ -32,10 +32,10 @@ 1. Add chain ID to `src/types/index.ts` if creating new domain types 2. Add default RPC endpoints to `src/config/rpcConfig.ts` -3. Determine if network needs custom fetchers/adapters (L1, Arbitrum-like, OP Stack-like) -4. If custom: create `src/services/EVM/[Network]/fetchers/` and `adapters/` -5. Update `DataService` constructor to detect chain ID and instantiate correct fetchers/adapters -6. Add network config to `ALL_NETWORKS` in `src/config/networks.ts` +3. Determine if network needs a custom adapter (L1, Arbitrum-like, OP Stack-like, Hardhat-like) +4. If custom: create `src/services/adapters/[Network]Adapter/[Network]Adapter.ts` +5. Register the adapter in `src/services/adapters/adaptersFactory.ts` with its chain ID +6. Add network config to `src/config/networks.json` 7. Add network logo to `public/` and update `logoType` in network config ## Testing with Local Networks @@ -43,7 +43,8 @@ OpenScan includes special support for localhost development: - **Hardhat 3 Ignition**: Import deployment artifacts via Settings → Import Ignition Deployment -- **Trace Support**: `debug_traceTransaction`, `debug_traceBlockByHash`, `debug_traceCall` available on localhost (31337) +- **Trace Support**: `debug_traceTransaction`, `debug_traceBlockByHash`, `debug_traceCall` available on Hardhat (31337) and localhost networks +- **Hardhat Trace Conversion**: Hardhat v3 only supports the default struct log tracer (not `callTracer`/`prestateTracer`). The `HardhatAdapter` uses `buildCallTreeFromStructLogs()` and `buildPrestateFromStructLogs()` from `src/utils/structLogConverter.ts` to convert opcode traces into call trees and state diffs - **Auto-detection**: Port 8545 automatically recognized as localhost network ## Component Patterns diff --git a/README.md b/README.md index db2dab28..b29d95e2 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,8 @@ A trustless, open-source blockchain explorer for Bitcoin, Ethereum, and Layer 2 - **BSC (BNB Chain)** - Binance Smart Chain mainnet - **BSC Testnet** - Binance Smart Chain testnet - **Polygon POS** - Polygon proof-of-stake mainnet -- **Localhost** - Local development networks (Hardhat/Anvil) +- **Hardhat** - Local development network (Chain ID 31337) with trace support +- **Localhost** - Local development networks (Anvil/other) ### 🔍 Core Functionality @@ -59,6 +60,13 @@ A trustless, open-source blockchain explorer for Bitcoin, Ethereum, and Layer 2 - **Multiple Fallback URLs** - Automatic failover to backup RPC providers - **Read/Write Operations** - Execute smart contract calls on verified smart contracts. +### 🔬 Hardhat Development Support + +- **Dedicated Adapter** - Full `HardhatAdapter` for chain ID 31337 with typed `HardhatClient` +- **Trace Methods** - Call Tree, Gas Profiler, and State Changes via struct log conversion (Hardhat does not support Geth's `callTracer`/`prestateTracer`, so opcode-level traces are converted) +- **HH3 Ignition** - Import Hardhat 3 Ignition deployment artifacts to inspect and interact with contracts +- **Auto-detection** - Port 8545 automatically recognized as Hardhat network + ### ⚡ Layer 2 Support - **Arbitrum-Specific Fields** - Display L1 block numbers, send counts, and request IDs @@ -276,13 +284,16 @@ src/ ├── context/ # React context providers ├── hooks/ # Custom React hooks ├── services/ # Blockchain data services -│ ├── adapters/ # General reusable adapters -│ │ └── BitcoinAdapter/ # Bitcoin network adapter -│ └── EVM/ # EVM-compatible chain adapters -│ ├── Arbitrum/ # Arbitrum-specific adapters -│ ├── common/ # EVM common resources -│ ├── L1/ # EVM L1 resources -│ └── Optimism/ # Optimism-specific adapters +│ ├── adapters/ # Network adapters +│ │ ├── BitcoinAdapter/ # Bitcoin network adapter +│ │ ├── HardhatAdapter/ # Hardhat local dev adapter (31337) +│ │ ├── EVMAdapter/ # Default EVM adapter (Ethereum, Sepolia, etc.) +│ │ ├── ArbitrumAdapter/ # Arbitrum-specific adapter +│ │ ├── OptimismAdapter/ # Optimism-specific adapter +│ │ ├── BaseAdapter/ # Base-specific adapter +│ │ ├── BNBAdapter/ # BNB Chain adapter +│ │ └── PolygonAdapter/ # Polygon adapter +│ └── EVM/ # EVM-compatible chain adapters (legacy) ├── types/ # TypeScript type definitions ├── utils/ # Utility functions └── styles/ # CSS stylesheets From e4e1238a10315c6850a6ceb1b21e7ef64b7b1a84 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 19 Mar 2026 12:06:21 -0300 Subject: [PATCH 10/54] feat(tooltips): address PR #326 review feedback - Fix tooltip z-index clipping by rendering bubbles via React Portal to document.body, escaping all ancestor overflow containers - Enrich tooltip descriptions with EIP-1559, EIP-4844, UTXO model, Merkle tree, and proxy pattern explanations across all 5 locales - Fix Arbitrum difficulty tooltip and circular blob definition - Add missing tooltips: tx history table headers, account type, analyser tabs, finalized badge, contract details, Bitcoin confirmations, inputs/outputs columns, network stats, state changes - Reclassify knowledge levels: beginner=all tooltips, intermediate=hides basic fields, advanced=complex/chain-specific only - Update knowledge level descriptions in all locale settings files --- src/components/common/HelperTooltip.tsx | 74 +++++++++++--- .../pages/bitcoin/BitcoinAddressDisplay.tsx | 2 +- .../pages/bitcoin/BitcoinBlockDisplay.tsx | 40 ++++---- .../bitcoin/BitcoinTransactionDisplay.tsx | 35 +++++-- .../address/shared/AccountMoreInfoCard.tsx | 4 +- .../address/shared/AccountOverviewCard.tsx | 2 +- .../evm/address/shared/AddressHeader.tsx | 11 ++- .../evm/address/shared/ContractDetails.tsx | 19 +++- .../evm/address/shared/ContractInfoCard.tsx | 4 +- .../address/shared/ContractInteraction.tsx | 7 +- .../address/shared/ContractMoreInfoCard.tsx | 4 +- .../address/shared/NFTCollectionInfoCard.tsx | 4 +- .../evm/address/shared/TransactionHistory.tsx | 55 +++++++++-- .../pages/evm/block/BlockDisplay.tsx | 97 +++++++++---------- .../pages/evm/network/DashboardStats.tsx | 18 +++- .../pages/evm/network/NetworkStatsDisplay.tsx | 31 +++++- .../evm/tokenDetails/ERC1155TokenDisplay.tsx | 2 +- .../evm/tokenDetails/ERC721TokenDisplay.tsx | 2 +- .../pages/evm/tx/TransactionDisplay.tsx | 26 ++--- src/components/pages/evm/tx/TxAnalyser.tsx | 11 ++- .../pages/evm/tx/analyser/StateChangesTab.tsx | 28 +++++- src/locales/en/settings.json | 2 +- src/locales/en/tooltips.json | 69 +++++++++---- src/locales/es/settings.json | 2 +- src/locales/es/tooltips.json | 69 +++++++++---- src/locales/ja/settings.json | 2 +- src/locales/ja/tooltips.json | 69 +++++++++---- src/locales/pt-BR/settings.json | 2 +- src/locales/pt-BR/tooltips.json | 69 +++++++++---- src/locales/zh/settings.json | 2 +- src/locales/zh/tooltips.json | 69 +++++++++---- src/styles/helper-tooltip.css | 49 +--------- 32 files changed, 594 insertions(+), 286 deletions(-) diff --git a/src/components/common/HelperTooltip.tsx b/src/components/common/HelperTooltip.tsx index 35a52174..53e1a55a 100644 --- a/src/components/common/HelperTooltip.tsx +++ b/src/components/common/HelperTooltip.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; interface HelperTooltipProps { content: string; @@ -11,6 +12,7 @@ const HOVER_DELAY_MS = 350; const HelperTooltip: React.FC = ({ content, placement = "top", className }) => { const [isVisible, setIsVisible] = useState(false); const [actualPlacement, setActualPlacement] = useState(placement); + const [triggerRect, setTriggerRect] = useState(null); const tooltipId = useId(); const triggerRef = useRef(null); const bubbleRef = useRef(null); @@ -21,6 +23,7 @@ const HelperTooltip: React.FC = ({ content, placement = "top if (triggerRef.current) { const rect = triggerRef.current.getBoundingClientRect(); setActualPlacement(rect.top < 80 ? "bottom" : placement); + setTriggerRect(rect); } setIsVisible(true); }, [placement]); @@ -45,7 +48,6 @@ const HelperTooltip: React.FC = ({ content, placement = "top const handlePointerLeave = useCallback(() => { isPointerInsideRef.current = false; clearHoverTimeout(); - // Small delay to allow moving from trigger to bubble hoverTimeoutRef.current = setTimeout(() => { if (!isPointerInsideRef.current) { hide(); @@ -70,7 +72,6 @@ const HelperTooltip: React.FC = ({ content, placement = "top [hide], ); - // Touch: tap to toggle const handleClick = useCallback(() => { if (isVisible) { hide(); @@ -79,7 +80,7 @@ const HelperTooltip: React.FC = ({ content, placement = "top } }, [isVisible, show, hide]); - // Close on outside click (touch devices) + // Close on outside click useEffect(() => { if (!isVisible) return; @@ -112,6 +113,58 @@ const HelperTooltip: React.FC = ({ content, placement = "top }; }, []); + // Clamp bubble within viewport after render + useLayoutEffect(() => { + if (!isVisible || !bubbleRef.current) return; + const bubble = bubbleRef.current; + const rect = bubble.getBoundingClientRect(); + const margin = 8; + + if (rect.right > window.innerWidth - margin) { + bubble.style.left = `${window.innerWidth - margin - rect.width}px`; + bubble.style.transform = "none"; + } + if (rect.left < margin) { + bubble.style.left = `${margin}px`; + bubble.style.transform = "none"; + } + }, [isVisible, triggerRect]); + + const getBubbleStyle = (): React.CSSProperties => { + if (!triggerRect) return {}; + const gap = 6; + const centerX = triggerRect.left + triggerRect.width / 2; + + if (actualPlacement === "bottom") { + return { + position: "fixed", + top: triggerRect.bottom + gap, + left: centerX, + transform: "translateX(-50%)", + }; + } + return { + position: "fixed", + top: triggerRect.top - gap, + left: centerX, + transform: "translate(-50%, -100%)", + }; + }; + + const bubble = isVisible ? ( + + ) : null; + return ( - {isVisible && ( - - )} + {bubble && createPortal(bubble, document.body)} ); }; diff --git a/src/components/pages/bitcoin/BitcoinAddressDisplay.tsx b/src/components/pages/bitcoin/BitcoinAddressDisplay.tsx index bd4e8481..e9367f22 100644 --- a/src/components/pages/bitcoin/BitcoinAddressDisplay.tsx +++ b/src/components/pages/bitcoin/BitcoinAddressDisplay.tsx @@ -115,7 +115,7 @@ const BitcoinAddressDisplay: React.FC = React.memo( {address.utxoCount.toLocaleString()} unspent outputs diff --git a/src/components/pages/bitcoin/BitcoinBlockDisplay.tsx b/src/components/pages/bitcoin/BitcoinBlockDisplay.tsx index 51e9dae0..c8a9f861 100644 --- a/src/components/pages/bitcoin/BitcoinBlockDisplay.tsx +++ b/src/components/pages/bitcoin/BitcoinBlockDisplay.tsx @@ -92,6 +92,9 @@ const BitcoinBlockDisplay: React.FC = React.memo( {block.confirmations !== undefined && ( {block.confirmations.toLocaleString()} Confirmations + {settings.showHelperTooltips !== false && ( + + )} )} @@ -230,7 +233,7 @@ const BitcoinBlockDisplay: React.FC = React.memo( {block.weight.toLocaleString()} WU @@ -312,40 +315,36 @@ const BitcoinBlockDisplay: React.FC = React.memo(
    Merkle Root: - {settings.showHelperTooltips !== false && - settings.knowledgeLevel === "advanced" && ( - - )} + {settings.showHelperTooltips !== false && ( + + )} {block.merkleRoot}
    Version: - {settings.showHelperTooltips !== false && - settings.knowledgeLevel === "advanced" && ( - - )} + {settings.showHelperTooltips !== false && ( + + )} 0x{block.version.toString(16)}
    Bits: - {settings.showHelperTooltips !== false && - settings.knowledgeLevel === "advanced" && ( - - )} + {settings.showHelperTooltips !== false && ( + + )} {block.bits}
    Nonce: - {settings.showHelperTooltips !== false && - settings.knowledgeLevel === "advanced" && ( - - )} + {settings.showHelperTooltips !== false && ( + + )} {block.nonce.toLocaleString()}
    @@ -353,10 +352,9 @@ const BitcoinBlockDisplay: React.FC = React.memo(
    Coinbase (hex): - {settings.showHelperTooltips !== false && - settings.knowledgeLevel === "advanced" && ( - - )} + {settings.showHelperTooltips !== false && ( + + )} {block.coinbaseHex} diff --git a/src/components/pages/bitcoin/BitcoinTransactionDisplay.tsx b/src/components/pages/bitcoin/BitcoinTransactionDisplay.tsx index eee11c43..5d7bd5ed 100644 --- a/src/components/pages/bitcoin/BitcoinTransactionDisplay.tsx +++ b/src/components/pages/bitcoin/BitcoinTransactionDisplay.tsx @@ -1,7 +1,10 @@ import React, { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; +import { useSettings } from "../../../context/SettingsContext"; import CopyButton from "../../common/CopyButton"; import FieldLabel from "../../common/FieldLabel"; +import HelperTooltip from "../../common/HelperTooltip"; import { SATOSHIS_PER_BTC } from "../../../config/bitcoinConstants"; import { getNetworkById } from "../../../config/networks"; import type { BitcoinTransaction } from "../../../types"; @@ -46,6 +49,9 @@ interface BitcoinTransactionDisplayProps { const BitcoinTransactionDisplay: React.FC = React.memo( ({ transaction, networkId, btcPrice }) => { + const { t: tTooltips } = useTranslation("tooltips"); + const { settings } = useSettings(); + // Calculate totals const totalInput = calculateTotalInput(transaction); const totalOutput = calculateTotalOutput(transaction); @@ -129,6 +135,9 @@ const BitcoinTransactionDisplay: React.FC = Reac transaction.confirmations > 0 && ( {transaction.confirmations.toLocaleString()} Confirmations + {settings.showHelperTooltips !== false && ( + + )} ) )} @@ -156,7 +165,7 @@ const BitcoinTransactionDisplay: React.FC = Reac = Reac {feePerByte.toFixed(3)} sat/B
    @@ -285,7 +294,7 @@ const BitcoinTransactionDisplay: React.FC = Reac {feePerWU.toFixed(3)} sat/WU @@ -317,7 +326,7 @@ const BitcoinTransactionDisplay: React.FC = Reac {transaction.weight.toLocaleString()} WU @@ -369,7 +378,7 @@ const BitcoinTransactionDisplay: React.FC = Reac {transaction.version} @@ -377,7 +386,7 @@ const BitcoinTransactionDisplay: React.FC = Reac {transaction.locktime.toLocaleString()} @@ -389,7 +398,12 @@ const BitcoinTransactionDisplay: React.FC = Reac {/* Inputs Column */}
    - Inputs ({transaction.vin.length}) + + Inputs ({transaction.vin.length}) + {settings.showHelperTooltips !== false && ( + + )} + {totalInput > 0 && (
    {formatBTC(totalInput)} @@ -479,7 +493,12 @@ const BitcoinTransactionDisplay: React.FC = Reac {/* Outputs Column */}
    - Outputs ({transaction.vout.length}) + + Outputs ({transaction.vout.length}) + {settings.showHelperTooltips !== false && ( + + )} +
    {formatBTC(totalOutput)} {formatUSD(totalOutput, btcPrice) && ( diff --git a/src/components/pages/evm/address/shared/AccountMoreInfoCard.tsx b/src/components/pages/evm/address/shared/AccountMoreInfoCard.tsx index 3398aa5c..f041a67e 100644 --- a/src/components/pages/evm/address/shared/AccountMoreInfoCard.tsx +++ b/src/components/pages/evm/address/shared/AccountMoreInfoCard.tsx @@ -32,7 +32,7 @@ const AccountMoreInfoCard: React.FC = ({ @@ -60,7 +60,7 @@ const AccountMoreInfoCard: React.FC = ({ diff --git a/src/components/pages/evm/address/shared/AccountOverviewCard.tsx b/src/components/pages/evm/address/shared/AccountOverviewCard.tsx index e7e3c1e5..4b6fbb7b 100644 --- a/src/components/pages/evm/address/shared/AccountOverviewCard.tsx +++ b/src/components/pages/evm/address/shared/AccountOverviewCard.tsx @@ -138,7 +138,7 @@ const AccountOverviewCard: React.FC = ({ diff --git a/src/components/pages/evm/address/shared/AddressHeader.tsx b/src/components/pages/evm/address/shared/AddressHeader.tsx index 7abc45e4..9eb8248b 100644 --- a/src/components/pages/evm/address/shared/AddressHeader.tsx +++ b/src/components/pages/evm/address/shared/AddressHeader.tsx @@ -5,6 +5,8 @@ import type { AddressType, RPCMetadata } from "../../../../../types"; import { getAddressTypeIcon, getAddressTypeLabel } from "../../../../../utils/addressTypeDetection"; import { RPCIndicator } from "../../../../common/RPCIndicator"; import CopyButton from "../../../../common/CopyButton"; +import HelperTooltip from "../../../../common/HelperTooltip"; +import { useSettings } from "../../../../../context/SettingsContext"; interface AddressHeaderProps { addressHash: string; @@ -38,6 +40,8 @@ const AddressHeader: React.FC = ({ klerosTag, }) => { const { t } = useTranslation("address"); + const { settings } = useSettings(); + const { t: tTooltips } = useTranslation("tooltips"); const truncatedHash = truncateHash(addressHash, 4); return ( @@ -45,7 +49,12 @@ const AddressHeader: React.FC = ({
    {getAddressTypeIcon(addressType)} - {getAddressTypeLabel(addressType)} + + {getAddressTypeLabel(addressType)} + {settings.showHelperTooltips !== false && ( + + )} + {tokenSymbol && {tokenSymbol}} {klerosTag && ( = ({ } }} > - {t("contractBytecode")} + @@ -183,7 +188,11 @@ const ContractDetails: React.FC = ({ } }} > - {t("sourceCode")} + @@ -215,7 +224,11 @@ const ContractDetails: React.FC = ({ } }} > - {t("rawAbi")} + diff --git a/src/components/pages/evm/address/shared/ContractInfoCard.tsx b/src/components/pages/evm/address/shared/ContractInfoCard.tsx index 2a6072d1..ccf55b6c 100644 --- a/src/components/pages/evm/address/shared/ContractInfoCard.tsx +++ b/src/components/pages/evm/address/shared/ContractInfoCard.tsx @@ -194,7 +194,7 @@ const ContractInfoCard: React.FC = ({ {proxyInfo.type} @@ -207,7 +207,7 @@ const ContractInfoCard: React.FC = ({ diff --git a/src/components/pages/evm/address/shared/ContractInteraction.tsx b/src/components/pages/evm/address/shared/ContractInteraction.tsx index 606429c6..26f8bee6 100644 --- a/src/components/pages/evm/address/shared/ContractInteraction.tsx +++ b/src/components/pages/evm/address/shared/ContractInteraction.tsx @@ -260,7 +260,12 @@ const ContractInteraction: React.FC = ({ return (
    - {t("functions")} + + {t("functions")} + {settings.showHelperTooltips !== false && ( + + )} + {({ account, diff --git a/src/components/pages/evm/address/shared/ContractMoreInfoCard.tsx b/src/components/pages/evm/address/shared/ContractMoreInfoCard.tsx index fe996759..d81675bb 100644 --- a/src/components/pages/evm/address/shared/ContractMoreInfoCard.tsx +++ b/src/components/pages/evm/address/shared/ContractMoreInfoCard.tsx @@ -32,7 +32,7 @@ const ContractMoreInfoCard: React.FC = ({ @@ -60,7 +60,7 @@ const ContractMoreInfoCard: React.FC = ({ diff --git a/src/components/pages/evm/address/shared/NFTCollectionInfoCard.tsx b/src/components/pages/evm/address/shared/NFTCollectionInfoCard.tsx index e2b95ade..b8bc6b9a 100644 --- a/src/components/pages/evm/address/shared/NFTCollectionInfoCard.tsx +++ b/src/components/pages/evm/address/shared/NFTCollectionInfoCard.tsx @@ -67,7 +67,7 @@ const NFTCollectionInfoCard: React.FC = ({ @@ -94,7 +94,7 @@ const NFTCollectionInfoCard: React.FC = ({ diff --git a/src/components/pages/evm/address/shared/TransactionHistory.tsx b/src/components/pages/evm/address/shared/TransactionHistory.tsx index db621c0a..62023e4e 100644 --- a/src/components/pages/evm/address/shared/TransactionHistory.tsx +++ b/src/components/pages/evm/address/shared/TransactionHistory.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router-dom"; import { toFunctionSelector } from "viem"; import { useTranslation } from "react-i18next"; +import { useSettings } from "../../../../../context/SettingsContext"; import { useDataService } from "../../../../../hooks/useDataService"; import type { ABI, @@ -10,6 +11,7 @@ import type { FunctionABI, Transaction, } from "../../../../../types"; +import HelperTooltip from "../../../../common/HelperTooltip"; // Transaction cache utilities const TX_CACHE_PREFIX = "tx_cache_"; @@ -127,6 +129,8 @@ const TransactionHistory: React.FC = ({ const loadMoreDropdownRef = useRef(null); const { t } = useTranslation("address"); + const { t: tTooltips } = useTranslation("tooltips"); + const { settings } = useSettings(); // Notify parent when transactions change useEffect(() => { @@ -522,13 +526,50 @@ const TransactionHistory: React.FC = ({ - - - {hasContractAbi && } - - - - + + + {hasContractAbi && ( + + )} + + + + diff --git a/src/components/pages/evm/block/BlockDisplay.tsx b/src/components/pages/evm/block/BlockDisplay.tsx index 3d489f15..9d37f770 100644 --- a/src/components/pages/evm/block/BlockDisplay.tsx +++ b/src/components/pages/evm/block/BlockDisplay.tsx @@ -161,7 +161,12 @@ const BlockDisplay: React.FC = React.memo( ({timestampFormatted}) - {t("finalized")} + + {t("finalized")} + {settings.showHelperTooltips !== false && ( + + )} + {metadata && selectedProvider !== undefined && onProviderSelect && ( = React.memo( {Number(block.difficulty).toLocaleString()} @@ -252,7 +257,7 @@ const BlockDisplay: React.FC = React.memo( {Number(block.totalDifficulty).toLocaleString()} @@ -276,7 +281,7 @@ const BlockDisplay: React.FC = React.memo( @@ -304,7 +309,7 @@ const BlockDisplay: React.FC = React.memo( {Number(block.gasUsed).toLocaleString()} @@ -317,7 +322,7 @@ const BlockDisplay: React.FC = React.memo( {Number(block.gasLimit).toLocaleString()} @@ -328,7 +333,7 @@ const BlockDisplay: React.FC = React.memo( {formatGwei(block.baseFeePerGas)} @@ -355,7 +360,7 @@ const BlockDisplay: React.FC = React.memo( {Number(block.excessBlobGas).toLocaleString()} @@ -381,7 +386,7 @@ const BlockDisplay: React.FC = React.memo( {Number(block.l1BlockNumber).toLocaleString()} @@ -391,7 +396,7 @@ const BlockDisplay: React.FC = React.memo( {block.sendCount} @@ -399,7 +404,7 @@ const BlockDisplay: React.FC = React.memo( {block.sendRoot} @@ -423,10 +428,9 @@ const BlockDisplay: React.FC = React.memo(
    Parent Hash: - {settings.showHelperTooltips !== false && - settings.knowledgeLevel === "advanced" && ( - - )} + {settings.showHelperTooltips !== false && ( + + )} {networkId && @@ -443,30 +447,27 @@ const BlockDisplay: React.FC = React.memo(
    State Root: - {settings.showHelperTooltips !== false && - settings.knowledgeLevel === "advanced" && ( - - )} + {settings.showHelperTooltips !== false && ( + + )} {block.stateRoot}
    Transactions Root: - {settings.showHelperTooltips !== false && - settings.knowledgeLevel === "advanced" && ( - - )} + {settings.showHelperTooltips !== false && ( + + )} {block.transactionsRoot}
    Receipts Root: - {settings.showHelperTooltips !== false && - settings.knowledgeLevel === "advanced" && ( - - )} + {settings.showHelperTooltips !== false && ( + + )} {block.receiptsRoot}
    @@ -474,10 +475,9 @@ const BlockDisplay: React.FC = React.memo(
    Withdrawals Root: - {settings.showHelperTooltips !== false && - settings.knowledgeLevel === "advanced" && ( - - )} + {settings.showHelperTooltips !== false && ( + + )} {block.withdrawalsRoot}
    @@ -485,10 +485,9 @@ const BlockDisplay: React.FC = React.memo(
    Logs Bloom: - {settings.showHelperTooltips !== false && - settings.knowledgeLevel === "advanced" && ( - - )} + {settings.showHelperTooltips !== false && ( + + )}
    {block.logsBloom} @@ -497,30 +496,27 @@ const BlockDisplay: React.FC = React.memo(
    Nonce: - {settings.showHelperTooltips !== false && - settings.knowledgeLevel === "advanced" && ( - - )} + {settings.showHelperTooltips !== false && ( + + )} {block.nonce}
    Mix Hash: - {settings.showHelperTooltips !== false && - settings.knowledgeLevel === "advanced" && ( - - )} + {settings.showHelperTooltips !== false && ( + + )} {block.mixHash}
    Sha3 Uncles: - {settings.showHelperTooltips !== false && - settings.knowledgeLevel === "advanced" && ( - - )} + {settings.showHelperTooltips !== false && ( + + )} {block.sha3Uncles}
    @@ -594,10 +590,9 @@ const BlockDisplay: React.FC = React.memo(
    {t("validator")} - {settings.showHelperTooltips !== false && - settings.knowledgeLevel === "beginner" && ( - - )} + {settings.showHelperTooltips !== false && ( + + )} {Number(withdrawal.validatorIndex).toLocaleString()} diff --git a/src/components/pages/evm/network/DashboardStats.tsx b/src/components/pages/evm/network/DashboardStats.tsx index 43443ebe..80940f23 100644 --- a/src/components/pages/evm/network/DashboardStats.tsx +++ b/src/components/pages/evm/network/DashboardStats.tsx @@ -2,8 +2,10 @@ import type React from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import type { GasPrices } from "../../../../types"; +import { useSettings } from "../../../../context/SettingsContext"; import { formatPrice } from "../../../../services/PriceService"; import { formatGasPriceWithUnit } from "../../../../utils/formatUtils"; +import HelperTooltip from "../../../common/HelperTooltip"; interface DashboardStatsProps { price: number | null; @@ -35,13 +37,20 @@ const DashboardStats: React.FC = ({ networkId, }) => { const { t } = useTranslation("network"); + const { t: tTooltips } = useTranslation("tooltips"); + const { settings } = useSettings(); // Use gas tiers if available, otherwise fall back to single gas price const hasGasTiers = gasPrices !== null; return (
    -
    {t("currencyPrice", { currency })}
    +
    + {t("currencyPrice", { currency })} + {settings.showHelperTooltips !== false && ( + + )} +
    {loading ? ( = ({
    -
    {t("latestBlock")}
    +
    + {t("latestBlock")} + {settings.showHelperTooltips !== false && ( + + )} +
    {loading ? ( = React.memo(
    - {t("currentGasPrice")} + {formatGasPrice(networkStats.currentGasPrice)}
    - {t("currentBlockNumber")} + {formatBlockNumber(networkStats.currentBlockNumber)}
    - {t("syncStatus")} + = React.memo( {networkStats.clientVersion && (
    - {t("clientVersion")} + {networkStats.clientVersion}
    )} {protocolVersion && (
    - {t("protocolVersion")} + {protocolVersion}
    )} diff --git a/src/components/pages/evm/tokenDetails/ERC1155TokenDisplay.tsx b/src/components/pages/evm/tokenDetails/ERC1155TokenDisplay.tsx index a8bf293d..8ba4a669 100644 --- a/src/components/pages/evm/tokenDetails/ERC1155TokenDisplay.tsx +++ b/src/components/pages/evm/tokenDetails/ERC1155TokenDisplay.tsx @@ -243,7 +243,7 @@ const ERC1155TokenDetails: React.FC = () => { ERC-1155 diff --git a/src/components/pages/evm/tokenDetails/ERC721TokenDisplay.tsx b/src/components/pages/evm/tokenDetails/ERC721TokenDisplay.tsx index 6dc82c22..e99275c4 100644 --- a/src/components/pages/evm/tokenDetails/ERC721TokenDisplay.tsx +++ b/src/components/pages/evm/tokenDetails/ERC721TokenDisplay.tsx @@ -214,7 +214,7 @@ const ERC721TokenDisplay: React.FC = () => { ERC-721 diff --git a/src/components/pages/evm/tx/TransactionDisplay.tsx b/src/components/pages/evm/tx/TransactionDisplay.tsx index c730fc43..eec50fef 100644 --- a/src/components/pages/evm/tx/TransactionDisplay.tsx +++ b/src/components/pages/evm/tx/TransactionDisplay.tsx @@ -559,7 +559,7 @@ const TransactionDisplay: React.FC = React.memo( {transaction.to ? ( @@ -612,7 +612,7 @@ const TransactionDisplay: React.FC = React.memo( {getStatusText(transaction.receipt?.status)}
    @@ -622,7 +622,7 @@ const TransactionDisplay: React.FC = React.memo( {networkId ? ( @@ -732,7 +732,7 @@ const TransactionDisplay: React.FC = React.memo( {formatGwei(transaction.receipt.effectiveGasPrice)} @@ -745,7 +745,7 @@ const TransactionDisplay: React.FC = React.memo( {transaction.nonce}
    @@ -755,7 +755,7 @@ const TransactionDisplay: React.FC = React.memo( {transaction.transactionIndex}
    @@ -782,7 +782,7 @@ const TransactionDisplay: React.FC = React.memo( {Number(transaction.receipt.l1BlockNumber).toLocaleString()} @@ -794,7 +794,7 @@ const TransactionDisplay: React.FC = React.memo( {Number(transaction.receipt.gasUsedForL1).toLocaleString()} @@ -814,7 +814,7 @@ const TransactionDisplay: React.FC = React.memo( {formatValue(transaction.receipt.l1Fee)}
    @@ -824,7 +824,7 @@ const TransactionDisplay: React.FC = React.memo( {formatGwei(transaction.receipt.l1GasPrice)}
    @@ -836,7 +836,7 @@ const TransactionDisplay: React.FC = React.memo( {Number(transaction.receipt.l1GasUsed).toLocaleString()} @@ -848,7 +848,7 @@ const TransactionDisplay: React.FC = React.memo( {transaction.receipt.l1FeeScalar}
    @@ -919,7 +919,7 @@ const TransactionDisplay: React.FC = React.memo( {transaction.blobVersionedHashes.map((hash) => ( diff --git a/src/components/pages/evm/tx/TxAnalyser.tsx b/src/components/pages/evm/tx/TxAnalyser.tsx index d5503292..349104f1 100644 --- a/src/components/pages/evm/tx/TxAnalyser.tsx +++ b/src/components/pages/evm/tx/TxAnalyser.tsx @@ -205,7 +205,7 @@ const TxAnalyser: React.FC = ({ onClick={() => handleTabClick("inputData")} > {t("analyser.inputDataTab")} - {settings.showHelperTooltips !== false && settings.knowledgeLevel === "beginner" && ( + {settings.showHelperTooltips !== false && ( )} @@ -218,6 +218,9 @@ const TxAnalyser: React.FC = ({ onClick={() => handleTabClick("callTree")} > {t("analyser.callTree")} + {settings.showHelperTooltips !== false && ( + + )} )} diff --git a/src/components/pages/evm/tx/analyser/StateChangesTab.tsx b/src/components/pages/evm/tx/analyser/StateChangesTab.tsx index d6985c56..4369ba3e 100644 --- a/src/components/pages/evm/tx/analyser/StateChangesTab.tsx +++ b/src/components/pages/evm/tx/analyser/StateChangesTab.tsx @@ -7,6 +7,8 @@ import type { PrestateTrace, } from "../../../../../services/adapters/NetworkAdapter"; import type { ContractInfo } from "../../../../../utils/contractLookup"; +import HelperTooltip from "../../../../common/HelperTooltip"; +import { useSettings } from "../../../../../context/SettingsContext"; import LongString from "../../../../common/LongString"; function formatHexBalance(hex: string | undefined): string { @@ -42,6 +44,8 @@ const StateChangesTab: React.FC<{ contracts: Record; }> = ({ trace, networkId, networkCurrency, contracts }) => { const { t } = useTranslation("transaction"); + const { t: tTooltips } = useTranslation("tooltips"); + const { settings } = useSettings(); const [expandedSet, setExpandedSet] = useState>(new Set()); const allAddresses = Array.from(new Set([...Object.keys(trace.pre), ...Object.keys(trace.post)])); @@ -139,6 +143,9 @@ const StateChangesTab: React.FC<{
    {t("analyser.balanceChange")} ({networkCurrency}) + {settings.showHelperTooltips !== false && ( + + )} {formatHexBalance(pre.balance)} @@ -153,7 +160,12 @@ const StateChangesTab: React.FC<{ {nonceDiff && (
    - {t("analyser.nonceChange")} + + {t("analyser.nonceChange")} + {settings.showHelperTooltips !== false && ( + + )} + {pre.nonce ?? "—"} {post.nonce ?? "—"} @@ -165,7 +177,12 @@ const StateChangesTab: React.FC<{ {codeChanged && (
    - {t("analyser.codeDeployed")} + + {t("analyser.codeDeployed")} + {settings.showHelperTooltips !== false && ( + + )} + {post.code ? `${post.code.slice(0, 20)}…` : "—"} @@ -174,7 +191,12 @@ const StateChangesTab: React.FC<{ {storageKeys.map((slot) => (
    - {t("analyser.storageChange")} + + {t("analyser.storageChange")} + {settings.showHelperTooltips !== false && ( + + )} + diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 02e02876..8ef767eb 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -158,7 +158,7 @@ }, "knowledgeLevel": { "label": "Knowledge Level", - "description": "Controls how many helper tooltips are shown. Beginner shows the most, Advanced shows the fewest.", + "description": "Controls how many helper tooltips are shown. Beginner shows all tooltips. Intermediate hides basic fields. Advanced shows only complex and chain-specific tooltips.", "beginner": "Beginner", "intermediate": "Intermediate", "advanced": "Advanced" diff --git a/src/locales/en/tooltips.json b/src/locales/en/tooltips.json index e99ee5e7..10be0d52 100644 --- a/src/locales/en/tooltips.json +++ b/src/locales/en/tooltips.json @@ -5,61 +5,76 @@ "status": "Whether the transaction executed successfully or reverted.", "confirmations": "Blocks mined after this transaction. More confirmations means higher finality.", "interactedWith": "The contract this transaction called, or the recipient address for simple transfers.", - "transactionFee": "Total fee paid to process this transaction (gas used × gas price).", + "transactionFee": "Total fee paid to process this transaction (gas used × gas price). Gas measures computational work on the network, and under EIP-1559 fees include a base fee (burned) plus a priority fee (tip).", "gasPrice": "Price per unit of gas, in Gwei. Higher gas prices incentivize faster inclusion.", "gasLimitUsage": "Gas limit is the maximum gas authorized. Gas used is the actual amount consumed.", - "nonce": "Sender's transaction counter. Ensures transactions are processed in order.", + "nonce": "Sender's transaction counter. Ensures transactions are processed in order and prevents replay attacks.", "position": "The index of this transaction within its block. Lower positions were processed first.", "type": "The transaction format (e.g. Legacy, EIP-1559, EIP-4844). Determines which fee fields apply.", "decodedInput": "The function call and parameters sent to the contract, decoded into human-readable form.", "maxFeePerBlobGas": "Maximum price per unit of blob gas the sender is willing to pay for data availability.", "blobGasPrice": "Actual price per unit of blob gas paid in this block.", - "blobGasUsed": "Amount of blob gas consumed by this transaction's data blobs.", - "blobCount": "Number of data blobs attached to this transaction for rollup data availability.", + "blobGasUsed": "Amount of blob gas consumed by this transaction's data blobs. Blobs are data containers introduced in EIP-4844 for rollup data availability, priced separately from execution gas.", + "blobCount": "Number of data containers attached to this transaction for rollup data availability.", "blobVersionedHashes": "Versioned hashes that uniquely identify each data blob committed by this transaction.", "contractCreated": "The address of the new contract deployed by this transaction.", "value": "Amount of native currency (e.g. ETH) transferred in this transaction.", "effectiveGasPrice": "The actual gas price paid after EIP-1559 fee calculation, which may differ from the requested price.", "l1BlockNumber": "The Ethereum L1 block that anchors this L2 transaction for security.", "gasUsedForL1": "Gas consumed for posting this transaction's data to Ethereum L1.", - "l1Fee": "Fee paid for data availability on Ethereum L1. Part of the total transaction cost on this L2.", + "l1Fee": "Fee paid for data availability on Ethereum L1. L2 networks rely on L1 to publish transaction data for security and verification.", "l1GasPrice": "The L1 gas price used to calculate the data availability fee component.", "l1GasUsed": "Estimated L1 gas required to post this transaction's calldata.", - "l1FeeScalar": "A multiplier applied to the L1 fee calculation by the L2 sequencer." + "l1FeeScalar": "A multiplier applied to the L1 fee calculation by the L2 sequencer.", + "txHashColumn": "The unique hash identifying this transaction.", + "blockColumn": "The block number this transaction was included in.", + "methodColumn": "The smart contract function called by this transaction.", + "fromColumn": "The address that sent this transaction.", + "toColumn": "The destination address of this transaction.", + "valueColumn": "The amount of native currency transferred.", + "statusColumn": "Whether the transaction succeeded or failed.", + "callTree": "Trace of all internal calls made during this transaction's execution.", + "gasProfiler": "Breakdown of gas consumption across each internal call.", + "stateChanges": "Blockchain state modifications (balances, nonces, storage) caused by this transaction.", + "balanceChange": "Change in native currency balance for this address caused by the transaction.", + "nonceChange": "Change in transaction count (nonce) for this address.", + "codeDeployed": "New contract bytecode deployed at this address by the transaction.", + "storageChange": "A storage slot in the contract's state that was modified by the transaction." }, "block": { "hash": "A unique fingerprint identifying this block, derived from its contents.", "transactions": "The number of transactions included and executed in this block.", "withdrawals": "Validator withdrawals processed in this block, returning staked ETH.", "feeRecipient": "The address that receives priority fees from transactions in this block.", - "difficulty": "Mining difficulty for this block. Always zero on proof-of-stake networks after the Merge.", + "difficulty": "Mining difficulty for this block. Zero on proof-of-stake networks after the Merge. On some L2 networks, this may be a non-zero constant.", "totalDifficulty": "Cumulative difficulty of the chain up to this block. Legacy field from proof-of-work.", "size": "The total size of the block data in bytes.", "extraData": "Arbitrary data set by the block producer. Often contains client or pool identifiers.", "gasUsed": "Total gas consumed by all transactions in this block.", "gasLimit": "Maximum gas allowed in this block, defining its capacity.", - "baseFeePerGas": "Minimum gas price for this block, set by the network based on demand (EIP-1559).", + "baseFeePerGas": "Minimum gas price for this block, set by the network based on demand (EIP-1559). This base fee is burned.", "burntFees": "Transaction fees permanently removed from circulation via EIP-1559.", "blobGasUsed": "Total blob gas consumed by blob transactions in this block.", "excessBlobGas": "Blob gas above the target, used to calculate the next block's blob base fee.", - "blobCount": "Number of data blobs included in this block for rollup data availability.", + "blobCount": "Number of data containers included in this block for rollup data availability.", "l1BlockNumber": "The Ethereum L1 block number associated with this Arbitrum block.", "sendCount": "Number of outgoing L2-to-L1 messages sent in this Arbitrum block.", "sendRoot": "Merkle root of all outgoing L2-to-L1 messages, used for cross-layer verification.", "parentHash": "Hash of the previous block in the chain, linking blocks together.", - "stateRoot": "Merkle root of the entire blockchain state after processing this block.", + "stateRoot": "Merkle root of the entire blockchain state (a cryptographic summary of all account data) after processing this block.", "transactionsRoot": "Merkle root of all transactions included in this block.", "receiptsRoot": "Merkle root of all transaction receipts from this block.", "withdrawalsRoot": "Merkle root of all validator withdrawals processed in this block.", - "logsBloom": "A bloom filter for efficiently searching event logs emitted in this block.", + "logsBloom": "A bloom filter (probabilistic data structure) for efficiently searching event logs emitted in this block.", "nonce": "A value used in proof-of-work mining. Always zero after the Merge.", "mixHash": "A hash used in the proof-of-work algorithm. Replaced by RANDAO value after the Merge.", "sha3Uncles": "Hash of the uncle blocks list. Always the empty list hash after the Merge.", - "validator": "The validator index that initiated this withdrawal from the beacon chain." + "validator": "The validator index that initiated this withdrawal from the beacon chain.", + "finalized": "This block has been finalized by the network's consensus mechanism and cannot be reverted." }, "address": { "verification": "A verified contract has its source code publicly confirmed to match the deployed bytecode.", - "proxyType": "This contract uses an upgradeable proxy pattern. The logic lives in a separate implementation contract.", + "proxyType": "This contract uses an upgradeable proxy pattern, where execution is delegated to a separate implementation contract, allowing upgrades without changing the address.", "readContract": "Query contract data without spending gas or connecting a wallet. Read-only calls.", "writeContract": "Send a transaction that changes contract state. Requires a connected wallet and gas.", "balance": "Native currency balance held by this address.", @@ -67,8 +82,13 @@ "nonce": "Number of transactions sent from this address. Used for transaction ordering.", "eip7702Delegate": "This account delegates its code execution to another address via EIP-7702.", "implementationAddress": "The contract that contains the actual logic for this proxy.", - "ensName": "Ethereum Name Service name associated with this address.", - "ensApp": "Application or resolver linked to this ENS name." + "ensName": "Ethereum Name Service (maps human-readable names to addresses) name associated with this address.", + "ensApp": "Application or resolver linked to this ENS name.", + "accountType": "The type of this address. EOA (Externally Owned Account) is a regular wallet controlled by a private key. Contract is code deployed on-chain.", + "contractBytecode": "The compiled EVM bytecode deployed at this address.", + "sourceCode": "The original Solidity/Vyper source code, verified to match the deployed bytecode.", + "rawAbi": "The Application Binary Interface (ABI) defines how to interact with this contract's functions.", + "functions": "Available contract functions that can be called to read data or modify state." }, "token": { "tokenStandard": "The token interface standard (e.g. ERC-20, ERC-721, ERC-1155) that defines how this token behaves.", @@ -84,7 +104,7 @@ "witnessHash": "Transaction hash including witness (SegWit) data. Differs from TXID for SegWit transactions.", "block": "The block that includes this transaction.", "status": "Whether this transaction has been confirmed in a block or is still waiting in the mempool.", - "inputs": "The number of previous outputs being spent. Each input references a UTXO.", + "inputs": "The number of previous outputs being spent. Each input references a UTXO. Bitcoin uses a UTXO model where transactions consume and create discrete outputs.", "outputs": "The number of new outputs created. Each output locks BTC to an address.", "fee": "The difference between total inputs and total outputs, paid to the miner.", "feePerByte": "Fee rate in satoshis per byte of raw transaction data.", @@ -111,7 +131,7 @@ "previousBlock": "Hash of the preceding block, linking this block to the chain.", "nextBlock": "Hash of the following block, if one exists.", "coinbaseMessage": "Arbitrary text embedded in the coinbase transaction by the miner.", - "merkleRoot": "Root hash of the Merkle tree of all transactions in this block.", + "merkleRoot": "Root hash of the Merkle tree (a structure that allows efficient verification) of all transactions in this block.", "blockVersion": "Block format version. Encodes which consensus rules and soft forks are signaled.", "bits": "Compact encoding of the target threshold for a valid block hash.", "blockNonce": "A value miners iterate to find a block hash below the target.", @@ -120,7 +140,20 @@ "balance": "Total BTC held by this address across all unspent outputs.", "totalReceived": "Cumulative BTC received by this address across all transactions.", "utxos": "Unspent Transaction Outputs. Individual coins available to be spent by this address.", - "txCount": "Total number of transactions involving this address." + "txCount": "Total number of transactions involving this address.", + "blockConfirmations": "Blocks mined after this block. More confirmations means the block is more deeply embedded in the chain.", + "confirmations": "Blocks mined after the block containing this transaction. More confirmations means higher finality.", + "inputsColumn": "Previous transaction outputs being spent. Each input references a UTXO (Unspent Transaction Output).", + "outputsColumn": "New outputs created by this transaction. Each output locks BTC to a recipient address." + }, + "network": { + "gasPrice": "Current cost per unit of gas on this network.", + "blockNumber": "The most recently mined block number on this network.", + "syncStatus": "Whether the connected node has fully synced with the network.", + "clientVersion": "The software client and version running on the connected node.", + "protocolVersion": "The Ethereum protocol version supported by the connected node.", + "currencyPrice": "Current market price of this network's native currency.", + "latestBlock": "The most recently produced block on this network." }, "settings": { "knowledgeLevel": "Controls how much explanatory help is shown throughout the explorer." diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index b4cf270a..0e2936e9 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -158,7 +158,7 @@ }, "knowledgeLevel": { "label": "Nivel de Conocimiento", - "description": "Controla cuántos tooltips de ayuda se muestran. Principiante muestra más, Avanzado muestra menos.", + "description": "Controla cuántos tooltips de ayuda se muestran. Principiante muestra todos. Intermedio oculta campos básicos. Avanzado muestra solo tooltips complejos y específicos de la cadena.", "beginner": "Principiante", "intermediate": "Intermedio", "advanced": "Avanzado" diff --git a/src/locales/es/tooltips.json b/src/locales/es/tooltips.json index 079e9877..68feb29c 100644 --- a/src/locales/es/tooltips.json +++ b/src/locales/es/tooltips.json @@ -5,61 +5,76 @@ "status": "Si la transacción se ejecutó correctamente o fue revertida.", "confirmations": "Bloques minados después de esta transacción. Más confirmaciones significa mayor finalidad.", "interactedWith": "El contrato con el que interactuó esta transacción, o la dirección del destinatario en transferencias simples.", - "transactionFee": "Tarifa total pagada para procesar esta transacción (gas usado × precio del gas).", + "transactionFee": "Tarifa total pagada para procesar esta transacción (gas usado × precio del gas). El gas mide el trabajo computacional en la red, y bajo EIP-1559 las tarifas incluyen una tarifa base (quemada) más una tarifa de prioridad (propina).", "gasPrice": "Precio por unidad de gas, en Gwei. Precios más altos incentivan una inclusión más rápida.", "gasLimitUsage": "El límite de gas es el máximo autorizado. El gas usado es la cantidad real consumida.", - "nonce": "Contador de transacciones del remitente. Asegura que las transacciones se procesen en orden.", + "nonce": "Contador de transacciones del remitente. Asegura que las transacciones se procesen en orden y previene ataques de repetición.", "position": "El índice de esta transacción dentro de su bloque. Posiciones más bajas se procesaron primero.", "type": "El formato de la transacción (ej. Legacy, EIP-1559, EIP-4844). Determina qué campos de tarifa aplican.", "decodedInput": "La llamada a función y parámetros enviados al contrato, decodificados en formato legible.", "maxFeePerBlobGas": "Precio máximo por unidad de blob gas que el remitente está dispuesto a pagar por disponibilidad de datos.", "blobGasPrice": "Precio real por unidad de blob gas pagado en este bloque.", - "blobGasUsed": "Cantidad de blob gas consumido por los blobs de datos de esta transacción.", - "blobCount": "Cantidad de blobs de datos adjuntos a esta transacción para disponibilidad de datos de rollup.", + "blobGasUsed": "Cantidad de blob gas consumido por los blobs de datos de esta transacción. Los blobs son contenedores de datos introducidos en EIP-4844 para disponibilidad de datos de rollup, con precio separado del gas de ejecución.", + "blobCount": "Cantidad de contenedores de datos adjuntos a esta transacción para disponibilidad de datos de rollup.", "blobVersionedHashes": "Hashes versionados que identifican de forma única cada blob de datos comprometido por esta transacción.", "contractCreated": "La dirección del nuevo contrato desplegado por esta transacción.", "value": "Cantidad de moneda nativa (ej. ETH) transferida en esta transacción.", "effectiveGasPrice": "El precio de gas real pagado después del cálculo de tarifa EIP-1559, que puede diferir del precio solicitado.", "l1BlockNumber": "El bloque de Ethereum L1 que ancla esta transacción L2 para seguridad.", "gasUsedForL1": "Gas consumido para publicar los datos de esta transacción en Ethereum L1.", - "l1Fee": "Tarifa pagada por disponibilidad de datos en Ethereum L1. Parte del costo total de la transacción en esta L2.", + "l1Fee": "Tarifa pagada por disponibilidad de datos en Ethereum L1. Las redes L2 dependen de L1 para publicar datos de transacciones por seguridad y verificación.", "l1GasPrice": "El precio de gas L1 usado para calcular el componente de tarifa de disponibilidad de datos.", "l1GasUsed": "Gas L1 estimado requerido para publicar los calldata de esta transacción.", - "l1FeeScalar": "Un multiplicador aplicado al cálculo de tarifa L1 por el secuenciador L2." + "l1FeeScalar": "Un multiplicador aplicado al cálculo de tarifa L1 por el secuenciador L2.", + "txHashColumn": "El hash único que identifica esta transacción.", + "blockColumn": "El número de bloque en el que se incluyó esta transacción.", + "methodColumn": "La función del contrato inteligente llamada por esta transacción.", + "fromColumn": "La dirección que envió esta transacción.", + "toColumn": "La dirección de destino de esta transacción.", + "valueColumn": "La cantidad de moneda nativa transferida.", + "statusColumn": "Si la transacción tuvo éxito o falló.", + "callTree": "Rastreo de todas las llamadas internas realizadas durante la ejecución de esta transacción.", + "gasProfiler": "Desglose del consumo de gas en cada llamada interna.", + "stateChanges": "Modificaciones del estado de la blockchain (saldos, nonces, almacenamiento) causadas por esta transacción.", + "balanceChange": "Cambio en el saldo de moneda nativa de esta dirección causado por la transacción.", + "nonceChange": "Cambio en el contador de transacciones (nonce) de esta dirección.", + "codeDeployed": "Nuevo bytecode de contrato desplegado en esta dirección por la transacción.", + "storageChange": "Un slot de almacenamiento en el estado del contrato que fue modificado por la transacción." }, "block": { "hash": "Una huella digital única que identifica este bloque, derivada de su contenido.", "transactions": "La cantidad de transacciones incluidas y ejecutadas en este bloque.", "withdrawals": "Retiros de validadores procesados en este bloque, devolviendo ETH stakeado.", "feeRecipient": "La dirección que recibe las tarifas prioritarias de las transacciones en este bloque.", - "difficulty": "Dificultad de minado para este bloque. Siempre cero en redes proof-of-stake después del Merge.", + "difficulty": "Dificultad de minado para este bloque. Cero en redes proof-of-stake después del Merge. En algunas redes L2, puede ser una constante distinta de cero.", "totalDifficulty": "Dificultad acumulada de la cadena hasta este bloque. Campo heredado de proof-of-work.", "size": "El tamaño total de los datos del bloque en bytes.", "extraData": "Datos arbitrarios establecidos por el productor del bloque. A menudo contiene identificadores de cliente o pool.", "gasUsed": "Gas total consumido por todas las transacciones en este bloque.", "gasLimit": "Gas máximo permitido en este bloque, definiendo su capacidad.", - "baseFeePerGas": "Precio mínimo de gas para este bloque, establecido por la red según la demanda (EIP-1559).", + "baseFeePerGas": "Precio mínimo de gas para este bloque, establecido por la red según la demanda (EIP-1559). Esta tarifa base se quema.", "burntFees": "Tarifas de transacción eliminadas permanentemente de la circulación mediante EIP-1559.", "blobGasUsed": "Blob gas total consumido por transacciones blob en este bloque.", "excessBlobGas": "Blob gas por encima del objetivo, usado para calcular la tarifa base blob del siguiente bloque.", - "blobCount": "Cantidad de blobs de datos incluidos en este bloque para disponibilidad de datos de rollup.", + "blobCount": "Cantidad de contenedores de datos incluidos en este bloque para disponibilidad de datos de rollup.", "l1BlockNumber": "El número de bloque de Ethereum L1 asociado con este bloque de Arbitrum.", "sendCount": "Cantidad de mensajes salientes L2-a-L1 enviados en este bloque de Arbitrum.", "sendRoot": "Raíz de Merkle de todos los mensajes salientes L2-a-L1, usada para verificación entre capas.", "parentHash": "Hash del bloque anterior en la cadena, enlazando los bloques entre sí.", - "stateRoot": "Raíz de Merkle del estado completo de la blockchain después de procesar este bloque.", + "stateRoot": "Raíz de Merkle del estado completo de la blockchain (un resumen criptográfico de todos los datos de cuentas) después de procesar este bloque.", "transactionsRoot": "Raíz de Merkle de todas las transacciones incluidas en este bloque.", "receiptsRoot": "Raíz de Merkle de todos los recibos de transacciones de este bloque.", "withdrawalsRoot": "Raíz de Merkle de todos los retiros de validadores procesados en este bloque.", - "logsBloom": "Un filtro bloom para buscar eficientemente logs de eventos emitidos en este bloque.", + "logsBloom": "Un filtro bloom (estructura de datos probabilística) para buscar eficientemente logs de eventos emitidos en este bloque.", "nonce": "Un valor usado en minería proof-of-work. Siempre cero después del Merge.", "mixHash": "Un hash usado en el algoritmo proof-of-work. Reemplazado por el valor RANDAO después del Merge.", "sha3Uncles": "Hash de la lista de bloques uncle. Siempre el hash de lista vacía después del Merge.", - "validator": "El índice del validador que inició este retiro desde la beacon chain." + "validator": "El índice del validador que inició este retiro desde la beacon chain.", + "finalized": "Este bloque ha sido finalizado por el mecanismo de consenso de la red y no puede ser revertido." }, "address": { "verification": "Un contrato verificado tiene su código fuente confirmado públicamente como coincidente con el bytecode desplegado.", - "proxyType": "Este contrato usa un patrón de proxy actualizable. La lógica está en un contrato de implementación separado.", + "proxyType": "Este contrato usa un patrón de proxy actualizable, donde la ejecución se delega a un contrato de implementación separado, permitiendo actualizaciones sin cambiar la dirección.", "readContract": "Consultar datos del contrato sin gastar gas ni conectar una wallet. Llamadas de solo lectura.", "writeContract": "Enviar una transacción que cambia el estado del contrato. Requiere una wallet conectada y gas.", "balance": "Saldo de moneda nativa que posee esta dirección.", @@ -67,8 +82,13 @@ "nonce": "Cantidad de transacciones enviadas desde esta dirección. Usado para ordenar transacciones.", "eip7702Delegate": "Esta cuenta delega la ejecución de su código a otra dirección vía EIP-7702.", "implementationAddress": "El contrato que contiene la lógica real para este proxy.", - "ensName": "Nombre del Ethereum Name Service asociado a esta dirección.", - "ensApp": "Aplicación o resolver vinculado a este nombre ENS." + "ensName": "Nombre del Ethereum Name Service (mapea nombres legibles a direcciones) asociado a esta dirección.", + "ensApp": "Aplicación o resolver vinculado a este nombre ENS.", + "accountType": "El tipo de esta dirección. EOA (Cuenta de Propiedad Externa) es una wallet regular controlada por una clave privada. Contrato es código desplegado en la cadena.", + "contractBytecode": "El bytecode EVM compilado desplegado en esta dirección.", + "sourceCode": "El código fuente original en Solidity/Vyper, verificado para coincidir con el bytecode desplegado.", + "rawAbi": "La Interfaz Binaria de Aplicación (ABI) define cómo interactuar con las funciones de este contrato.", + "functions": "Funciones de contrato disponibles que pueden ser llamadas para leer datos o modificar el estado." }, "token": { "tokenStandard": "El estándar de interfaz del token (ej. ERC-20, ERC-721, ERC-1155) que define cómo se comporta.", @@ -84,7 +104,7 @@ "witnessHash": "Hash de transacción incluyendo datos witness (SegWit). Difiere del TXID para transacciones SegWit.", "block": "El bloque que incluye esta transacción.", "status": "Si esta transacción fue confirmada en un bloque o sigue esperando en el mempool.", - "inputs": "La cantidad de salidas previas que se gastan. Cada entrada referencia un UTXO.", + "inputs": "La cantidad de salidas previas que se gastan. Cada entrada referencia un UTXO. Bitcoin usa un modelo UTXO donde las transacciones consumen y crean salidas discretas.", "outputs": "La cantidad de nuevas salidas creadas. Cada salida bloquea BTC a una dirección.", "fee": "La diferencia entre entradas y salidas totales, pagada al minero.", "feePerByte": "Tasa de tarifa en satoshis por byte de datos crudos de transacción.", @@ -111,7 +131,7 @@ "previousBlock": "Hash del bloque precedente, enlazando este bloque a la cadena.", "nextBlock": "Hash del bloque siguiente, si existe.", "coinbaseMessage": "Texto arbitrario incrustado en la transacción coinbase por el minero.", - "merkleRoot": "Hash raíz del árbol de Merkle de todas las transacciones en este bloque.", + "merkleRoot": "Hash raíz del árbol de Merkle (una estructura que permite verificación eficiente) de todas las transacciones en este bloque.", "blockVersion": "Versión del formato de bloque. Codifica qué reglas de consenso y soft forks se señalizan.", "bits": "Codificación compacta del umbral objetivo para un hash de bloque válido.", "blockNonce": "Un valor que los mineros iteran para encontrar un hash de bloque por debajo del objetivo.", @@ -120,7 +140,20 @@ "balance": "BTC total que posee esta dirección en todas las salidas sin gastar.", "totalReceived": "BTC acumulados recibidos por esta dirección en todas las transacciones.", "utxos": "Salidas de Transacción No Gastadas. Monedas individuales disponibles para gastar por esta dirección.", - "txCount": "Cantidad total de transacciones que involucran esta dirección." + "txCount": "Cantidad total de transacciones que involucran esta dirección.", + "blockConfirmations": "Bloques minados después de este bloque. Más confirmaciones significa que el bloque está más profundamente integrado en la cadena.", + "confirmations": "Bloques minados después del bloque que contiene esta transacción. Más confirmaciones significa mayor finalidad.", + "inputsColumn": "Salidas de transacciones previas siendo gastadas. Cada entrada referencia un UTXO (Salida de Transacción No Gastada).", + "outputsColumn": "Nuevas salidas creadas por esta transacción. Cada salida bloquea BTC a una dirección receptora." + }, + "network": { + "gasPrice": "Costo actual por unidad de gas en esta red.", + "blockNumber": "El número de bloque más reciente minado en esta red.", + "syncStatus": "Si el nodo conectado se ha sincronizado completamente con la red.", + "clientVersion": "El software cliente y versión que ejecuta el nodo conectado.", + "protocolVersion": "La versión del protocolo Ethereum soportada por el nodo conectado.", + "currencyPrice": "Precio de mercado actual de la moneda nativa de esta red.", + "latestBlock": "El bloque más reciente producido en esta red." }, "settings": { "knowledgeLevel": "Controla cuánta ayuda explicativa se muestra en el explorador." diff --git a/src/locales/ja/settings.json b/src/locales/ja/settings.json index a50abd00..788ac69a 100644 --- a/src/locales/ja/settings.json +++ b/src/locales/ja/settings.json @@ -158,7 +158,7 @@ }, "knowledgeLevel": { "label": "知識レベル", - "description": "ヘルパーツールチップの表示数を制御します。初心者は最も多く、上級者は最も少なく表示されます。", + "description": "ヘルパーツールチップの表示数を制御します。初心者はすべて表示。中級者は基本フィールドを非表示。上級者は複雑なチェーン固有のツールチップのみ表示。", "beginner": "初心者", "intermediate": "中級者", "advanced": "上級者" diff --git a/src/locales/ja/tooltips.json b/src/locales/ja/tooltips.json index 65264a25..47efb91f 100644 --- a/src/locales/ja/tooltips.json +++ b/src/locales/ja/tooltips.json @@ -5,61 +5,76 @@ "status": "トランザクションが正常に実行されたか、リバートされたか。", "confirmations": "このトランザクション後にマイニングされたブロック数。確認数が多いほどファイナリティが高い。", "interactedWith": "このトランザクションが呼び出したコントラクト、または単純な送金の受取アドレス。", - "transactionFee": "このトランザクションの処理に支払われた合計手数料(使用ガス × ガス価格)。", + "transactionFee": "このトランザクションの処理に支払われた合計手数料(使用ガス × ガス価格)。ガスはネットワーク上の計算作業を測定し、EIP-1559ではベース手数料(バーン)とプライオリティ手数料(チップ)が含まれます。", "gasPrice": "ガス1単位あたりの価格(Gwei単位)。高いガス価格はより早い取り込みを促進します。", "gasLimitUsage": "ガスリミットは承認された最大ガス量。使用ガスは実際に消費された量。", - "nonce": "送信者のトランザクションカウンター。トランザクションが順番に処理されることを保証します。", + "nonce": "送信者のトランザクションカウンター。トランザクションが順番に処理されることを保証し、リプレイ攻撃を防止します。", "position": "ブロック内のこのトランザクションのインデックス。低い位置が先に処理されます。", "type": "トランザクション形式(例:Legacy、EIP-1559、EIP-4844)。適用される手数料フィールドを決定します。", "decodedInput": "コントラクトに送信された関数呼び出しとパラメータを人間が読める形式にデコードしたもの。", "maxFeePerBlobGas": "送信者がデータ可用性のために支払うBlobガス1単位あたりの最高価格。", "blobGasPrice": "このブロックで支払われたBlobガス1単位あたりの実際の価格。", - "blobGasUsed": "このトランザクションのデータBlobが消費したBlobガスの量。", - "blobCount": "ロールアップデータ可用性のためにこのトランザクションに添付されたデータBlobの数。", + "blobGasUsed": "このトランザクションのデータBlobが消費したBlobガスの量。Blobはロールアップデータ可用性のためにEIP-4844で導入されたデータコンテナで、実行ガスとは別に価格設定されます。", + "blobCount": "ロールアップデータ可用性のためにこのトランザクションに添付されたデータコンテナの数。", "blobVersionedHashes": "このトランザクションがコミットした各データBlobを一意に識別するバージョン付きハッシュ。", "contractCreated": "このトランザクションによってデプロイされた新しいコントラクトのアドレス。", "value": "このトランザクションで送金されたネイティブ通貨(例:ETH)の金額。", "effectiveGasPrice": "EIP-1559の手数料計算後に実際に支払われたガス価格。リクエストした価格と異なる場合があります。", "l1BlockNumber": "このL2トランザクションのセキュリティを担保するEthereum L1ブロック。", "gasUsedForL1": "このトランザクションのデータをEthereum L1に投稿するために消費されたガス。", - "l1Fee": "Ethereum L1でのデータ可用性に支払われた手数料。このL2の合計トランザクションコストの一部。", + "l1Fee": "Ethereum L1でのデータ可用性に支払われた手数料。L2ネットワークはセキュリティと検証のためにL1にトランザクションデータを公開することに依存しています。", "l1GasPrice": "データ可用性手数料の計算に使用されたL1ガス価格。", "l1GasUsed": "このトランザクションのcalldataを投稿するために必要な推定L1ガス。", - "l1FeeScalar": "L2シーケンサーがL1手数料計算に適用する乗数。" + "l1FeeScalar": "L2シーケンサーがL1手数料計算に適用する乗数。", + "txHashColumn": "このトランザクションを識別する一意のハッシュ。", + "blockColumn": "このトランザクションが含まれたブロック番号。", + "methodColumn": "このトランザクションが呼び出したスマートコントラクト関数。", + "fromColumn": "このトランザクションを送信したアドレス。", + "toColumn": "このトランザクションの宛先アドレス。", + "valueColumn": "転送されたネイティブ通貨の量。", + "statusColumn": "トランザクションが成功したか失敗したか。", + "callTree": "このトランザクション実行中に行われたすべての内部呼び出しのトレース。", + "gasProfiler": "各内部呼び出しにおけるガス消費の内訳。", + "stateChanges": "このトランザクションによって引き起こされたブロックチェーン状態の変更(残高、ノンス、ストレージ)。", + "balanceChange": "トランザクションによるこのアドレスのネイティブ通貨残高の変化。", + "nonceChange": "このアドレスのトランザクションカウント(ノンス)の変化。", + "codeDeployed": "トランザクションによってこのアドレスにデプロイされた新しいコントラクトバイトコード。", + "storageChange": "トランザクションによって変更されたコントラクト状態のストレージスロット。" }, "block": { "hash": "このブロックを識別する一意のフィンガープリント。内容から導出されます。", "transactions": "このブロックに含まれ実行されたトランザクションの数。", "withdrawals": "このブロックで処理されたバリデーターの引き出し。ステークされたETHを返却します。", "feeRecipient": "このブロック内のトランザクションから優先手数料を受け取るアドレス。", - "difficulty": "このブロックのマイニング難易度。マージ後のProof of Stakeネットワークでは常にゼロ。", + "difficulty": "このブロックのマイニング難易度。マージ後のProof of Stakeネットワークではゼロ。一部のL2ネットワークでは、ゼロでない定数の場合があります。", "totalDifficulty": "このブロックまでのチェーンの累積難易度。Proof of Workのレガシーフィールド。", "size": "ブロックデータの合計サイズ(バイト)。", "extraData": "ブロック生成者が設定した任意のデータ。クライアントやプールの識別子が含まれることが多い。", "gasUsed": "このブロック内の全トランザクションで消費された合計ガス。", "gasLimit": "このブロックで許可される最大ガス量。ブロックの容量を定義します。", - "baseFeePerGas": "このブロックの最低ガス価格。ネットワークの需要に基づいて設定されます(EIP-1559)。", + "baseFeePerGas": "このブロックの最低ガス価格。ネットワークの需要に基づいて設定されます(EIP-1559)。このベース手数料はバーンされます。", "burntFees": "EIP-1559により流通から永久に除去されたトランザクション手数料。", "blobGasUsed": "このブロック内のBlobトランザクションが消費した合計Blobガス。", "excessBlobGas": "目標を超えたBlobガス。次のブロックのBlob基本手数料の計算に使用されます。", - "blobCount": "ロールアップデータ可用性のためにこのブロックに含まれるデータBlobの数。", + "blobCount": "ロールアップデータ可用性のためにこのブロックに含まれるデータコンテナの数。", "l1BlockNumber": "このArbitrumブロックに関連するEthereum L1ブロック番号。", "sendCount": "このArbitrumブロックで送信されたL2からL1への送信メッセージの数。", "sendRoot": "すべてのL2からL1への送信メッセージのMerkleルート。クロスレイヤー検証に使用されます。", "parentHash": "チェーン内の前のブロックのハッシュ。ブロック同士をリンクします。", - "stateRoot": "このブロックの処理後のブロックチェーン全体の状態のMerkleルート。", + "stateRoot": "このブロックの処理後のブロックチェーン全体の状態(すべてのアカウントデータの暗号化サマリー)のMerkleルート。", "transactionsRoot": "このブロックに含まれるすべてのトランザクションのMerkleルート。", "receiptsRoot": "このブロックのすべてのトランザクションレシートのMerkleルート。", "withdrawalsRoot": "このブロックで処理されたすべてのバリデーター引き出しのMerkleルート。", - "logsBloom": "このブロックで発行されたイベントログを効率的に検索するためのブルームフィルター。", + "logsBloom": "このブロックで発行されたイベントログを効率的に検索するためのブルームフィルター(確率的データ構造)。", "nonce": "Proof of Workマイニングで使用される値。マージ後は常にゼロ。", "mixHash": "Proof of Workアルゴリズムで使用されるハッシュ。マージ後はRANDAO値に置き換えられました。", "sha3Uncles": "アンクルブロックリストのハッシュ。マージ後は常に空リストのハッシュ。", - "validator": "ビーコンチェーンからこの引き出しを開始したバリデーターインデックス。" + "validator": "ビーコンチェーンからこの引き出しを開始したバリデーターインデックス。", + "finalized": "このブロックはネットワークのコンセンサスメカニズムによってファイナライズされ、取り消すことはできません。" }, "address": { "verification": "検証済みコントラクトは、ソースコードがデプロイされたバイトコードと一致することが公開確認されています。", - "proxyType": "このコントラクトはアップグレード可能なプロキシパターンを使用しています。ロジックは別の実装コントラクトにあります。", + "proxyType": "このコントラクトはアップグレード可能なプロキシパターンを使用しており、実行は別の実装コントラクトに委任され、アドレスを変更せずにアップグレードが可能です。", "readContract": "ガスを消費せず、ウォレットを接続せずにコントラクトデータを照会。読み取り専用の呼び出し。", "writeContract": "コントラクトの状態を変更するトランザクションを送信。接続されたウォレットとガスが必要です。", "balance": "このアドレスが保有するネイティブ通貨の残高。", @@ -67,8 +82,13 @@ "nonce": "このアドレスから送信されたトランザクション数。トランザクションの順序付けに使用されます。", "eip7702Delegate": "このアカウントはEIP-7702を介してコード実行を別のアドレスに委任しています。", "implementationAddress": "このプロキシの実際のロジックを含むコントラクト。", - "ensName": "このアドレスに関連付けられたEthereum Name Serviceの名前。", - "ensApp": "このENS名前にリンクされたアプリケーションまたはリゾルバー。" + "ensName": "このアドレスに関連付けられたEthereum Name Service(人間が読める名前をアドレスにマッピング)の名前。", + "ensApp": "このENS名前にリンクされたアプリケーションまたはリゾルバー。", + "accountType": "このアドレスの種類。EOA(外部所有アカウント)は秘密鍵で制御される通常のウォレット。コントラクトはチェーン上にデプロイされたコードです。", + "contractBytecode": "このアドレスにデプロイされたコンパイル済みEVMバイトコード。", + "sourceCode": "デプロイされたバイトコードと一致することが検証された、元のSolidity/Vyperソースコード。", + "rawAbi": "アプリケーションバイナリインターフェース(ABI)は、このコントラクトの関数との対話方法を定義します。", + "functions": "データの読み取りまたは状態の変更のために呼び出し可能なコントラクト関数。" }, "token": { "tokenStandard": "トークンのインターフェース標準(ERC-20、ERC-721、ERC-1155など)。トークンの動作を定義します。", @@ -84,7 +104,7 @@ "witnessHash": "ウィットネス(SegWit)データを含むトランザクションハッシュ。SegWitトランザクションではTXIDと異なります。", "block": "このトランザクションを含むブロック。", "status": "このトランザクションがブロックで確認されたか、まだメモリプールで待機中か。", - "inputs": "使用される以前の出力の数。各入力はUTXOを参照します。", + "inputs": "使用される以前の出力の数。各入力はUTXOを参照します。BitcoinはUTXOモデルを使用し、トランザクションは個別の出力を消費・作成します。", "outputs": "作成される新しい出力の数。各出力はBTCをアドレスにロックします。", "fee": "入力合計と出力合計の差額。マイナーに支払われます。", "feePerByte": "生のトランザクションデータ1バイトあたりの手数料率(satoshi)。", @@ -111,7 +131,7 @@ "previousBlock": "前のブロックのハッシュ。このブロックをチェーンにリンクします。", "nextBlock": "次のブロックのハッシュ(存在する場合)。", "coinbaseMessage": "マイナーがコインベーストランザクションに埋め込んだ任意のテキスト。", - "merkleRoot": "このブロック内の全トランザクションのMerkleツリーのルートハッシュ。", + "merkleRoot": "このブロック内の全トランザクションのMerkleツリー(効率的な検証を可能にする構造)のルートハッシュ。", "blockVersion": "ブロックフォーマットのバージョン。どのコンセンサスルールとソフトフォークがシグナルされるかをエンコード。", "bits": "有効なブロックハッシュのターゲット閾値のコンパクトエンコーディング。", "blockNonce": "マイナーがターゲット以下のブロックハッシュを見つけるために反復する値。", @@ -120,7 +140,20 @@ "balance": "このアドレスが全未使用出力にわたって保有するBTC合計。", "totalReceived": "このアドレスが全トランザクションにわたって受け取った累計BTC。", "utxos": "未使用トランザクション出力。このアドレスが使用可能な個別のコイン。", - "txCount": "このアドレスに関連するトランザクションの総数。" + "txCount": "このアドレスに関連するトランザクションの総数。", + "blockConfirmations": "このブロック後にマイニングされたブロック数。確認数が多いほどブロックがチェーンに深く埋め込まれています。", + "confirmations": "このトランザクションを含むブロック後にマイニングされたブロック数。確認数が多いほどファイナリティが高い。", + "inputsColumn": "使用される以前のトランザクション出力。各入力はUTXO(未使用トランザクション出力)を参照します。", + "outputsColumn": "このトランザクションによって作成された新しい出力。各出力は受取アドレスにBTCをロックします。" + }, + "network": { + "gasPrice": "このネットワークのガス1単位あたりの現在のコスト。", + "blockNumber": "このネットワークで最も最近マイニングされたブロック番号。", + "syncStatus": "接続されたノードがネットワークと完全に同期しているかどうか。", + "clientVersion": "接続されたノードで実行されているソフトウェアクライアントとバージョン。", + "protocolVersion": "接続されたノードがサポートするEthereumプロトコルバージョン。", + "currencyPrice": "このネットワークのネイティブ通貨の現在の市場価格。", + "latestBlock": "このネットワークで最も最近生成されたブロック。" }, "settings": { "knowledgeLevel": "エクスプローラー全体で表示される説明ヘルプの量を制御します。" diff --git a/src/locales/pt-BR/settings.json b/src/locales/pt-BR/settings.json index 88ec23e9..f72c290f 100644 --- a/src/locales/pt-BR/settings.json +++ b/src/locales/pt-BR/settings.json @@ -158,7 +158,7 @@ }, "knowledgeLevel": { "label": "Nível de Conhecimento", - "description": "Controla quantas dicas de ajuda são exibidas. Iniciante mostra mais, Avançado mostra menos.", + "description": "Controla quantas dicas de ajuda são exibidas. Iniciante mostra todas. Intermediário oculta campos básicos. Avançado mostra apenas dicas complexas e específicas da cadeia.", "beginner": "Iniciante", "intermediate": "Intermediário", "advanced": "Avançado" diff --git a/src/locales/pt-BR/tooltips.json b/src/locales/pt-BR/tooltips.json index 4fe7de03..757fc5b7 100644 --- a/src/locales/pt-BR/tooltips.json +++ b/src/locales/pt-BR/tooltips.json @@ -5,61 +5,76 @@ "status": "Se a transação foi executada com sucesso ou revertida.", "confirmations": "Blocos minerados após esta transação. Mais confirmações significa maior finalidade.", "interactedWith": "O contrato com o qual esta transação interagiu, ou o endereço do destinatário em transferências simples.", - "transactionFee": "Taxa total paga para processar esta transação (gas usado × preço do gas).", + "transactionFee": "Taxa total paga para processar esta transação (gas usado × preço do gas). O gas mede o trabalho computacional na rede, e sob o EIP-1559 as taxas incluem uma taxa base (queimada) mais uma taxa de prioridade (gorjeta).", "gasPrice": "Preço por unidade de gas, em Gwei. Preços mais altos incentivam inclusão mais rápida.", "gasLimitUsage": "O limite de gas é o máximo autorizado. O gas usado é a quantidade real consumida.", - "nonce": "Contador de transações do remetente. Garante que as transações sejam processadas em ordem.", + "nonce": "Contador de transações do remetente. Garante que as transações sejam processadas em ordem e previne ataques de repetição.", "position": "O índice desta transação dentro do seu bloco. Posições mais baixas foram processadas primeiro.", "type": "O formato da transação (ex: Legacy, EIP-1559, EIP-4844). Determina quais campos de taxa se aplicam.", "decodedInput": "A chamada de função e parâmetros enviados ao contrato, decodificados em formato legível.", "maxFeePerBlobGas": "Preço máximo por unidade de blob gas que o remetente está disposto a pagar pela disponibilidade de dados.", "blobGasPrice": "Preço real por unidade de blob gas pago neste bloco.", - "blobGasUsed": "Quantidade de blob gas consumido pelos blobs de dados desta transação.", - "blobCount": "Número de blobs de dados anexados a esta transação para disponibilidade de dados de rollup.", + "blobGasUsed": "Quantidade de blob gas consumido pelos blobs de dados desta transação. Blobs são contêineres de dados introduzidos no EIP-4844 para disponibilidade de dados de rollup, com preço separado do gas de execução.", + "blobCount": "Número de contêineres de dados anexados a esta transação para disponibilidade de dados de rollup.", "blobVersionedHashes": "Hashes versionados que identificam exclusivamente cada blob de dados comprometido por esta transação.", "contractCreated": "O endereço do novo contrato implantado por esta transação.", "value": "Quantidade de moeda nativa (ex: ETH) transferida nesta transação.", "effectiveGasPrice": "O preço de gas realmente pago após o cálculo de taxa EIP-1559, que pode diferir do preço solicitado.", "l1BlockNumber": "O bloco Ethereum L1 que ancora esta transação L2 para segurança.", "gasUsedForL1": "Gas consumido para publicar os dados desta transação no Ethereum L1.", - "l1Fee": "Taxa paga pela disponibilidade de dados no Ethereum L1. Parte do custo total da transação nesta L2.", + "l1Fee": "Taxa paga pela disponibilidade de dados no Ethereum L1. Redes L2 dependem da L1 para publicar dados de transações por segurança e verificação.", "l1GasPrice": "O preço de gas L1 usado para calcular o componente de taxa de disponibilidade de dados.", "l1GasUsed": "Gas L1 estimado necessário para publicar o calldata desta transação.", - "l1FeeScalar": "Um multiplicador aplicado ao cálculo da taxa L1 pelo sequenciador L2." + "l1FeeScalar": "Um multiplicador aplicado ao cálculo da taxa L1 pelo sequenciador L2.", + "txHashColumn": "O hash único que identifica esta transação.", + "blockColumn": "O número do bloco em que esta transação foi incluída.", + "methodColumn": "A função do contrato inteligente chamada por esta transação.", + "fromColumn": "O endereço que enviou esta transação.", + "toColumn": "O endereço de destino desta transação.", + "valueColumn": "A quantidade de moeda nativa transferida.", + "statusColumn": "Se a transação teve sucesso ou falhou.", + "callTree": "Rastreamento de todas as chamadas internas feitas durante a execução desta transação.", + "gasProfiler": "Detalhamento do consumo de gas em cada chamada interna.", + "stateChanges": "Modificações no estado da blockchain (saldos, nonces, armazenamento) causadas por esta transação.", + "balanceChange": "Mudança no saldo de moeda nativa deste endereço causada pela transação.", + "nonceChange": "Mudança na contagem de transações (nonce) deste endereço.", + "codeDeployed": "Novo bytecode de contrato implantado neste endereço pela transação.", + "storageChange": "Um slot de armazenamento no estado do contrato que foi modificado pela transação." }, "block": { "hash": "Uma impressão digital única que identifica este bloco, derivada de seu conteúdo.", "transactions": "O número de transações incluídas e executadas neste bloco.", "withdrawals": "Saques de validadores processados neste bloco, retornando ETH em stake.", "feeRecipient": "O endereço que recebe as taxas prioritárias das transações neste bloco.", - "difficulty": "Dificuldade de mineração para este bloco. Sempre zero em redes proof-of-stake após o Merge.", + "difficulty": "Dificuldade de mineração para este bloco. Zero em redes proof-of-stake após o Merge. Em algumas redes L2, pode ser uma constante diferente de zero.", "totalDifficulty": "Dificuldade acumulada da cadeia até este bloco. Campo legado de proof-of-work.", "size": "O tamanho total dos dados do bloco em bytes.", "extraData": "Dados arbitrários definidos pelo produtor do bloco. Frequentemente contém identificadores de cliente ou pool.", "gasUsed": "Gas total consumido por todas as transações neste bloco.", "gasLimit": "Gas máximo permitido neste bloco, definindo sua capacidade.", - "baseFeePerGas": "Preço mínimo de gas para este bloco, definido pela rede com base na demanda (EIP-1559).", + "baseFeePerGas": "Preço mínimo de gas para este bloco, definido pela rede com base na demanda (EIP-1559). Esta taxa base é queimada.", "burntFees": "Taxas de transação permanentemente removidas de circulação via EIP-1559.", "blobGasUsed": "Blob gas total consumido por transações blob neste bloco.", "excessBlobGas": "Blob gas acima do alvo, usado para calcular a taxa base blob do próximo bloco.", - "blobCount": "Número de blobs de dados incluídos neste bloco para disponibilidade de dados de rollup.", + "blobCount": "Número de contêineres de dados incluídos neste bloco para disponibilidade de dados de rollup.", "l1BlockNumber": "O número do bloco Ethereum L1 associado a este bloco Arbitrum.", "sendCount": "Número de mensagens de saída L2-para-L1 enviadas neste bloco Arbitrum.", "sendRoot": "Raiz de Merkle de todas as mensagens de saída L2-para-L1, usada para verificação entre camadas.", "parentHash": "Hash do bloco anterior na cadeia, ligando os blocos entre si.", - "stateRoot": "Raiz de Merkle de todo o estado da blockchain após processar este bloco.", + "stateRoot": "Raiz de Merkle de todo o estado da blockchain (um resumo criptográfico de todos os dados de contas) após processar este bloco.", "transactionsRoot": "Raiz de Merkle de todas as transações incluídas neste bloco.", "receiptsRoot": "Raiz de Merkle de todos os recibos de transações deste bloco.", "withdrawalsRoot": "Raiz de Merkle de todos os saques de validadores processados neste bloco.", - "logsBloom": "Um filtro bloom para buscar eficientemente logs de eventos emitidos neste bloco.", + "logsBloom": "Um filtro bloom (estrutura de dados probabilística) para buscar eficientemente logs de eventos emitidos neste bloco.", "nonce": "Um valor usado na mineração proof-of-work. Sempre zero após o Merge.", "mixHash": "Um hash usado no algoritmo proof-of-work. Substituído pelo valor RANDAO após o Merge.", "sha3Uncles": "Hash da lista de blocos uncle. Sempre o hash de lista vazia após o Merge.", - "validator": "O índice do validador que iniciou esta retirada da beacon chain." + "validator": "O índice do validador que iniciou esta retirada da beacon chain.", + "finalized": "Este bloco foi finalizado pelo mecanismo de consenso da rede e não pode ser revertido." }, "address": { "verification": "Um contrato verificado tem seu código-fonte publicamente confirmado como correspondente ao bytecode implantado.", - "proxyType": "Este contrato usa um padrão de proxy atualizável. A lógica está em um contrato de implementação separado.", + "proxyType": "Este contrato usa um padrão de proxy atualizável, onde a execução é delegada a um contrato de implementação separado, permitindo atualizações sem alterar o endereço.", "readContract": "Consultar dados do contrato sem gastar gas ou conectar uma carteira. Chamadas somente leitura.", "writeContract": "Enviar uma transação que altera o estado do contrato. Requer uma carteira conectada e gas.", "balance": "Saldo de moeda nativa mantido por este endereço.", @@ -67,8 +82,13 @@ "nonce": "Número de transações enviadas a partir deste endereço. Usado para ordenação de transações.", "eip7702Delegate": "Esta conta delega a execução de seu código a outro endereço via EIP-7702.", "implementationAddress": "O contrato que contém a lógica real deste proxy.", - "ensName": "Nome do Ethereum Name Service associado a este endereço.", - "ensApp": "Aplicação ou resolvedor vinculado a este nome ENS." + "ensName": "Nome do Ethereum Name Service (mapeia nomes legíveis para endereços) associado a este endereço.", + "ensApp": "Aplicação ou resolvedor vinculado a este nome ENS.", + "accountType": "O tipo deste endereço. EOA (Conta de Propriedade Externa) é uma carteira regular controlada por uma chave privada. Contrato é código implantado na cadeia.", + "contractBytecode": "O bytecode EVM compilado implantado neste endereço.", + "sourceCode": "O código-fonte original em Solidity/Vyper, verificado para corresponder ao bytecode implantado.", + "rawAbi": "A Interface Binária de Aplicação (ABI) define como interagir com as funções deste contrato.", + "functions": "Funções de contrato disponíveis que podem ser chamadas para ler dados ou modificar o estado." }, "token": { "tokenStandard": "O padrão de interface do token (ex: ERC-20, ERC-721, ERC-1155) que define como este token se comporta.", @@ -84,7 +104,7 @@ "witnessHash": "Hash da transação incluindo dados witness (SegWit). Difere do TXID para transações SegWit.", "block": "O bloco que inclui esta transação.", "status": "Se esta transação foi confirmada em um bloco ou ainda está esperando no mempool.", - "inputs": "O número de saídas anteriores sendo gastas. Cada entrada referencia um UTXO.", + "inputs": "O número de saídas anteriores sendo gastas. Cada entrada referencia um UTXO. Bitcoin usa um modelo UTXO onde transações consomem e criam saídas discretas.", "outputs": "O número de novas saídas criadas. Cada saída bloqueia BTC para um endereço.", "fee": "A diferença entre entradas e saídas totais, paga ao minerador.", "feePerByte": "Taxa de tarifa em satoshis por byte de dados brutos da transação.", @@ -111,7 +131,7 @@ "previousBlock": "Hash do bloco anterior, ligando este bloco à cadeia.", "nextBlock": "Hash do bloco seguinte, se existir.", "coinbaseMessage": "Texto arbitrário incorporado na transação coinbase pelo minerador.", - "merkleRoot": "Hash raiz da árvore de Merkle de todas as transações neste bloco.", + "merkleRoot": "Hash raiz da árvore de Merkle (uma estrutura que permite verificação eficiente) de todas as transações neste bloco.", "blockVersion": "Versão do formato do bloco. Codifica quais regras de consenso e soft forks são sinalizados.", "bits": "Codificação compacta do limiar alvo para um hash de bloco válido.", "blockNonce": "Um valor que mineradores iteram para encontrar um hash de bloco abaixo do alvo.", @@ -120,7 +140,20 @@ "balance": "BTC total mantido por este endereço em todas as saídas não gastas.", "totalReceived": "BTC cumulativo recebido por este endereço em todas as transações.", "utxos": "Saídas de Transação Não Gastas. Moedas individuais disponíveis para gasto por este endereço.", - "txCount": "Número total de transações envolvendo este endereço." + "txCount": "Número total de transações envolvendo este endereço.", + "blockConfirmations": "Blocos minerados após este bloco. Mais confirmações significa que o bloco está mais profundamente incorporado na cadeia.", + "confirmations": "Blocos minerados após o bloco que contém esta transação. Mais confirmações significa maior finalidade.", + "inputsColumn": "Saídas de transações anteriores sendo gastas. Cada entrada referencia um UTXO (Saída de Transação Não Gasta).", + "outputsColumn": "Novas saídas criadas por esta transação. Cada saída bloqueia BTC para um endereço receptor." + }, + "network": { + "gasPrice": "Custo atual por unidade de gas nesta rede.", + "blockNumber": "O número do bloco mais recentemente minerado nesta rede.", + "syncStatus": "Se o nó conectado sincronizou completamente com a rede.", + "clientVersion": "O software cliente e versão executando no nó conectado.", + "protocolVersion": "A versão do protocolo Ethereum suportada pelo nó conectado.", + "currencyPrice": "Preço de mercado atual da moeda nativa desta rede.", + "latestBlock": "O bloco mais recentemente produzido nesta rede." }, "settings": { "knowledgeLevel": "Controla quanta ajuda explicativa é exibida no explorador." diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index c880864f..b436ffdc 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -158,7 +158,7 @@ }, "knowledgeLevel": { "label": "知识水平", - "description": "控制显示多少帮助提示。初学者显示最多,高级显示最少。", + "description": "控制显示多少帮助提示。初学者显示所有提示。中级隐藏基本字段。高级仅显示复杂和链特定的提示。", "beginner": "初学者", "intermediate": "中级", "advanced": "高级" diff --git a/src/locales/zh/tooltips.json b/src/locales/zh/tooltips.json index 9136700b..7a0deba2 100644 --- a/src/locales/zh/tooltips.json +++ b/src/locales/zh/tooltips.json @@ -5,61 +5,76 @@ "status": "交易是否成功执行或已回滚。", "confirmations": "此交易之后挖出的区块数。更多确认意味着更高的最终性。", "interactedWith": "此交易调用的合约,或简单转账的接收地址。", - "transactionFee": "处理此交易支付的总费用(使用的Gas × Gas价格)。", + "transactionFee": "处理此交易支付的总费用(使用的Gas × Gas价格)。Gas衡量网络上的计算工作,在EIP-1559下费用包括基础费用(销毁)加上优先费用(小费)。", "gasPrice": "每单位Gas的价格,以Gwei为单位。更高的Gas价格可以更快被打包。", "gasLimitUsage": "Gas限制是授权的最大Gas量。已用Gas是实际消耗的量。", - "nonce": "发送者的交易计数器。确保交易按顺序处理。", + "nonce": "发送者的交易计数器。确保交易按顺序处理并防止重放攻击。", "position": "此交易在区块中的索引。较低的位置优先处理。", "type": "交易格式(如Legacy、EIP-1559、EIP-4844)。决定适用哪些费用字段。", "decodedInput": "发送给合约的函数调用和参数,解码为可读格式。", "maxFeePerBlobGas": "发送者愿意为数据可用性支付的每单位Blob Gas最高价格。", "blobGasPrice": "此区块中实际支付的每单位Blob Gas价格。", - "blobGasUsed": "此交易的数据Blob消耗的Blob Gas量。", - "blobCount": "附加到此交易的数据Blob数量,用于Rollup数据可用性。", + "blobGasUsed": "此交易的数据Blob消耗的Blob Gas量。Blob是EIP-4844中引入的用于Rollup数据可用性的数据容器,与执行Gas分开定价。", + "blobCount": "附加到此交易的数据容器数量,用于Rollup数据可用性。", "blobVersionedHashes": "唯一标识此交易提交的每个数据Blob的版本化哈希。", "contractCreated": "此交易部署的新合约地址。", "value": "此交易中转移的原生货币(如ETH)金额。", "effectiveGasPrice": "经EIP-1559费用计算后实际支付的Gas价格,可能与请求的价格不同。", "l1BlockNumber": "锚定此L2交易安全性的以太坊L1区块。", "gasUsedForL1": "将此交易数据发布到以太坊L1所消耗的Gas。", - "l1Fee": "在以太坊L1上为数据可用性支付的费用。是此L2上总交易成本的一部分。", + "l1Fee": "在以太坊L1上为数据可用性支付的费用。L2网络依赖L1发布交易数据以确保安全性和可验证性。", "l1GasPrice": "用于计算数据可用性费用的L1 Gas价格。", "l1GasUsed": "发布此交易calldata所需的预估L1 Gas。", - "l1FeeScalar": "L2排序器应用于L1费用计算的乘数。" + "l1FeeScalar": "L2排序器应用于L1费用计算的乘数。", + "txHashColumn": "标识此交易的唯一哈希。", + "blockColumn": "此交易被包含在的区块号。", + "methodColumn": "此交易调用的智能合约函数。", + "fromColumn": "发送此交易的地址。", + "toColumn": "此交易的目标地址。", + "valueColumn": "转移的原生货币数量。", + "statusColumn": "交易是否成功或失败。", + "callTree": "此交易执行期间所有内部调用的跟踪。", + "gasProfiler": "每个内部调用的Gas消耗详细分解。", + "stateChanges": "此交易导致的区块链状态修改(余额、nonce、存储)。", + "balanceChange": "交易导致此地址原生货币余额的变化。", + "nonceChange": "此地址的交易计数(nonce)变化。", + "codeDeployed": "交易在此地址部署的新合约字节码。", + "storageChange": "合约状态中被交易修改的存储槽。" }, "block": { "hash": "标识此区块的唯一指纹,由其内容派生。", "transactions": "此区块中包含和执行的交易数量。", "withdrawals": "此区块中处理的验证者提款,返回质押的ETH。", "feeRecipient": "接收此区块中交易优先费用的地址。", - "difficulty": "此区块的挖矿难度。在合并后的权益证明网络上始终为零。", + "difficulty": "此区块的挖矿难度。在合并后的权益证明网络上为零。在某些L2网络上,可能是非零常数。", "totalDifficulty": "到此区块为止的链累积难度。工作量证明的遗留字段。", "size": "区块数据的总大小(字节)。", "extraData": "区块生产者设置的任意数据。通常包含客户端或矿池标识符。", "gasUsed": "此区块中所有交易消耗的总Gas。", "gasLimit": "此区块允许的最大Gas量,定义其容量。", - "baseFeePerGas": "此区块的最低Gas价格,由网络根据需求设定(EIP-1559)。", + "baseFeePerGas": "此区块的最低Gas价格,由网络根据需求设定(EIP-1559)。此基础费用被销毁。", "burntFees": "通过EIP-1559永久从流通中移除的交易费用。", "blobGasUsed": "此区块中Blob交易消耗的总Blob Gas。", "excessBlobGas": "超出目标的Blob Gas,用于计算下一个区块的Blob基础费用。", - "blobCount": "此区块中包含的数据Blob数量,用于Rollup数据可用性。", + "blobCount": "此区块中包含的数据容器数量,用于Rollup数据可用性。", "l1BlockNumber": "与此Arbitrum区块关联的以太坊L1区块号。", "sendCount": "此Arbitrum区块中发送的L2到L1出站消息数量。", "sendRoot": "所有L2到L1出站消息的Merkle根,用于跨层验证。", "parentHash": "链中前一个区块的哈希,将区块链接在一起。", - "stateRoot": "处理此区块后整个区块链状态的Merkle根。", + "stateRoot": "处理此区块后整个区块链状态(所有账户数据的加密摘要)的Merkle根。", "transactionsRoot": "此区块中包含的所有交易的Merkle根。", "receiptsRoot": "此区块中所有交易收据的Merkle根。", "withdrawalsRoot": "此区块中处理的所有验证者提款的Merkle根。", - "logsBloom": "用于高效搜索此区块中发出的事件日志的布隆过滤器。", + "logsBloom": "用于高效搜索此区块中发出的事件日志的布隆过滤器(概率数据结构)。", "nonce": "工作量证明挖矿中使用的值。合并后始终为零。", "mixHash": "工作量证明算法中使用的哈希。合并后被RANDAO值替代。", "sha3Uncles": "叔块列表的哈希。合并后始终为空列表哈希。", - "validator": "从信标链发起此提款的验证者索引。" + "validator": "从信标链发起此提款的验证者索引。", + "finalized": "此区块已被网络的共识机制最终确认,无法被撤销。" }, "address": { "verification": "已验证的合约其源代码已被公开确认与部署的字节码匹配。", - "proxyType": "此合约使用可升级代理模式。逻辑位于单独的实现合约中。", + "proxyType": "此合约使用可升级代理模式,执行被委托给单独的实现合约,允许在不更改地址的情况下进行升级。", "readContract": "查询合约数据,无需消耗Gas或连接钱包。只读调用。", "writeContract": "发送改变合约状态的交易。需要连接钱包并消耗Gas。", "balance": "此地址持有的原生货币余额。", @@ -67,8 +82,13 @@ "nonce": "从此地址发送的交易数量。用于交易排序。", "eip7702Delegate": "此账户通过EIP-7702将其代码执行委托给另一个地址。", "implementationAddress": "包含此代理实际逻辑的合约。", - "ensName": "与此地址关联的以太坊名称服务名称。", - "ensApp": "与此ENS名称关联的应用或解析器。" + "ensName": "与此地址关联的以太坊名称服务(将人类可读名称映射到地址)名称。", + "ensApp": "与此ENS名称关联的应用或解析器。", + "accountType": "此地址的类型。EOA(外部拥有账户)是由私钥控制的普通钱包。合约是部署在链上的代码。", + "contractBytecode": "部署在此地址的编译后EVM字节码。", + "sourceCode": "经验证与部署字节码匹配的原始Solidity/Vyper源代码。", + "rawAbi": "应用程序二进制接口(ABI)定义了如何与此合约的函数进行交互。", + "functions": "可调用的合约函数,用于读取数据或修改状态。" }, "token": { "tokenStandard": "代币接口标准(如ERC-20、ERC-721、ERC-1155),定义了代币的行为方式。", @@ -84,7 +104,7 @@ "witnessHash": "包含见证(SegWit)数据的交易哈希。对于SegWit交易与TXID不同。", "block": "包含此交易的区块。", "status": "此交易是否已在区块中确认,或仍在内存池中等待。", - "inputs": "正在花费的先前输出数量。每个输入引用一个UTXO。", + "inputs": "正在花费的先前输出数量。每个输入引用一个UTXO。比特币使用UTXO模型,交易消耗和创建离散输出。", "outputs": "创建的新输出数量。每个输出将BTC锁定到一个地址。", "fee": "总输入与总输出之间的差额,支付给矿工。", "feePerByte": "每字节原始交易数据的费率(聪)。", @@ -111,7 +131,7 @@ "previousBlock": "前一个区块的哈希,将此区块链接到链上。", "nextBlock": "下一个区块的哈希(如果存在)。", "coinbaseMessage": "矿工在coinbase交易中嵌入的任意文本。", - "merkleRoot": "此区块中所有交易的Merkle树根哈希。", + "merkleRoot": "此区块中所有交易的Merkle树(允许高效验证的结构)根哈希。", "blockVersion": "区块格式版本。编码信号哪些共识规则和软分叉。", "bits": "有效区块哈希目标阈值的紧凑编码。", "blockNonce": "矿工迭代以找到低于目标的区块哈希的值。", @@ -120,7 +140,20 @@ "balance": "此地址在所有未花费输出中持有的BTC总量。", "totalReceived": "此地址在所有交易中累计收到的BTC。", "utxos": "未花费交易输出。此地址可用于花费的单个币。", - "txCount": "涉及此地址的交易总数。" + "txCount": "涉及此地址的交易总数。", + "blockConfirmations": "此区块后挖出的区块数。更多确认意味着区块在链中嵌入更深。", + "confirmations": "包含此交易的区块后挖出的区块数。更多确认意味着更高的最终性。", + "inputsColumn": "正在花费的先前交易输出。每个输入引用一个UTXO(未花费交易输出)。", + "outputsColumn": "此交易创建的新输出。每个输出将BTC锁定到接收地址。" + }, + "network": { + "gasPrice": "此网络每单位Gas的当前成本。", + "blockNumber": "此网络上最近挖出的区块号。", + "syncStatus": "连接的节点是否已与网络完全同步。", + "clientVersion": "连接节点上运行的软件客户端和版本。", + "protocolVersion": "连接节点支持的以太坊协议版本。", + "currencyPrice": "此网络原生货币的当前市场价格。", + "latestBlock": "此网络上最近产生的区块。" }, "settings": { "knowledgeLevel": "控制整个浏览器中显示的帮助说明数量。" diff --git a/src/styles/helper-tooltip.css b/src/styles/helper-tooltip.css index 1a6d6305..def820ea 100644 --- a/src/styles/helper-tooltip.css +++ b/src/styles/helper-tooltip.css @@ -37,11 +37,8 @@ border-radius: 50%; } -/* Tooltip bubble */ +/* Tooltip bubble — portaled to document.body, positioned via inline style */ .helper-tooltip-bubble { - position: absolute; - left: 50%; - transform: translateX(-50%); max-width: 260px; min-width: 140px; padding: 8px 12px; @@ -52,44 +49,12 @@ line-height: 1.45; border-radius: 6px; box-shadow: var(--shadow-md); - z-index: 1000; + z-index: 10000; pointer-events: auto; white-space: normal; word-wrap: break-word; } -/* Top placement (default) */ -.helper-tooltip-top { - bottom: calc(100% + 6px); -} - -.helper-tooltip-top::after { - content: ""; - position: absolute; - top: 100%; - left: 50%; - transform: translateX(-50%); - border-width: 5px; - border-style: solid; - border-color: var(--bg-tooltip) transparent transparent transparent; -} - -/* Bottom placement */ -.helper-tooltip-bottom { - top: calc(100% + 6px); -} - -.helper-tooltip-bottom::after { - content: ""; - position: absolute; - bottom: 100%; - left: 50%; - transform: translateX(-50%); - border-width: 5px; - border-style: solid; - border-color: transparent transparent var(--bg-tooltip) transparent; -} - /* Light theme text: keep tooltip text white since bg-tooltip is dark in both themes */ .light-theme .helper-tooltip-bubble { color: #fff; @@ -114,15 +79,5 @@ .helper-tooltip-bubble { max-width: 220px; font-size: 0.78rem; - left: auto; - right: -8px; - transform: none; - } - - .helper-tooltip-top::after, - .helper-tooltip-bottom::after { - left: auto; - right: 12px; - transform: none; } } From 234cfbd7e2a6cef4f80037c4ac96b3b7982d8c18 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 19 Mar 2026 12:10:59 -0300 Subject: [PATCH 11/54] refactor(bitcoin): rearrange tx detail columns layout Move coinbase and witness to left column, RBF to right column for better visual grouping of related fields. --- .../bitcoin/BitcoinTransactionDisplay.tsx | 57 ++++++++++--------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/src/components/pages/bitcoin/BitcoinTransactionDisplay.tsx b/src/components/pages/bitcoin/BitcoinTransactionDisplay.tsx index 5d7bd5ed..9a7c66e6 100644 --- a/src/components/pages/bitcoin/BitcoinTransactionDisplay.tsx +++ b/src/components/pages/bitcoin/BitcoinTransactionDisplay.tsx @@ -300,6 +300,35 @@ const BitcoinTransactionDisplay: React.FC = Reac
    )} + +
    + + + {isCoinbase ? ( + Yes + ) : ( + No + )} + +
    +
    + + + {witnessEnabled ? ( + Yes + ) : ( + No + )} + +
    {/* Right Column */} @@ -331,34 +360,6 @@ const BitcoinTransactionDisplay: React.FC = Reac {transaction.weight.toLocaleString()} WU
    -
    - - - {isCoinbase ? ( - Yes - ) : ( - No - )} - -
    -
    - - - {witnessEnabled ? ( - Yes - ) : ( - No - )} - -
    Date: Thu, 19 Mar 2026 12:19:51 -0300 Subject: [PATCH 12/54] feat(bitcoin): show block number alongside hash on tx page Fetch block height from block header when displaying confirmed transactions. Block number is shown as plain text before the clickable block hash link. --- .../pages/bitcoin/BitcoinTransactionDisplay.tsx | 9 ++++++--- .../adapters/BitcoinAdapter/BitcoinAdapter.ts | 16 +++++++++++++++- src/types/index.ts | 1 + 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/components/pages/bitcoin/BitcoinTransactionDisplay.tsx b/src/components/pages/bitcoin/BitcoinTransactionDisplay.tsx index 9a7c66e6..b9cdbc12 100644 --- a/src/components/pages/bitcoin/BitcoinTransactionDisplay.tsx +++ b/src/components/pages/bitcoin/BitcoinTransactionDisplay.tsx @@ -180,16 +180,19 @@ const BitcoinTransactionDisplay: React.FC = Reac {transaction.blockhash && (
    - + + {transaction.blockheight !== undefined && ( + {transaction.blockheight.toLocaleString()} - + )} {networkId ? ( {truncateBlockHash(transaction.blockhash)} ) : ( - truncateBlockHash(transaction.blockhash) + {truncateBlockHash(transaction.blockhash)} )}
    diff --git a/src/services/adapters/BitcoinAdapter/BitcoinAdapter.ts b/src/services/adapters/BitcoinAdapter/BitcoinAdapter.ts index e44b86ca..3eb59478 100644 --- a/src/services/adapters/BitcoinAdapter/BitcoinAdapter.ts +++ b/src/services/adapters/BitcoinAdapter/BitcoinAdapter.ts @@ -593,8 +593,22 @@ export class BitcoinAdapter { } } + const tx = this.transformTransaction(txData); + + // Fetch block height from block header when we have a blockhash + if (tx.blockhash) { + try { + const headerResult = await this.client.getBlockHeader(tx.blockhash, true); + if (headerResult.data?.height !== undefined) { + tx.blockheight = headerResult.data.height; + } + } catch { + // Block header not available, continue without height + } + } + return { - data: this.transformTransaction(txData), + data: tx, metadata: metadata as DataWithMetadata["metadata"], }; } diff --git a/src/types/index.ts b/src/types/index.ts index c7b1be33..50f45a27 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -228,6 +228,7 @@ export interface BitcoinTransaction { vin: BitcoinTransactionInput[]; vout: BitcoinTransactionOutput[]; blockhash?: string; + blockheight?: number; confirmations?: number; blocktime?: number; time?: number; From 1a787a8d01a943495544ef79b530fe6646fa26cb Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 19 Mar 2026 12:26:26 -0300 Subject: [PATCH 13/54] feat(tooltips): add left and right placement options Support all four directions (top, bottom, left, right) for tooltip bubbles. Use left placement for confirmation and finalized badges in the top-right corner. Add vertical viewport clamping. --- src/components/common/HelperTooltip.tsx | 65 ++++++++++++++----- .../pages/bitcoin/BitcoinBlockDisplay.tsx | 2 +- .../bitcoin/BitcoinTransactionDisplay.tsx | 2 +- .../pages/evm/block/BlockDisplay.tsx | 2 +- 4 files changed, 50 insertions(+), 21 deletions(-) diff --git a/src/components/common/HelperTooltip.tsx b/src/components/common/HelperTooltip.tsx index 53e1a55a..07ab9f9d 100644 --- a/src/components/common/HelperTooltip.tsx +++ b/src/components/common/HelperTooltip.tsx @@ -3,7 +3,7 @@ import { createPortal } from "react-dom"; interface HelperTooltipProps { content: string; - placement?: "top" | "bottom"; + placement?: "top" | "bottom" | "left" | "right"; className?: string; } @@ -22,7 +22,12 @@ const HelperTooltip: React.FC = ({ content, placement = "top const show = useCallback(() => { if (triggerRef.current) { const rect = triggerRef.current.getBoundingClientRect(); - setActualPlacement(rect.top < 80 ? "bottom" : placement); + // Only auto-flip top↔bottom; left/right stay as requested + if (placement === "top" || placement === "bottom") { + setActualPlacement(rect.top < 80 ? "bottom" : placement); + } else { + setActualPlacement(placement); + } setTriggerRect(rect); } setIsVisible(true); @@ -120,35 +125,59 @@ const HelperTooltip: React.FC = ({ content, placement = "top const rect = bubble.getBoundingClientRect(); const margin = 8; + // Horizontal clamping if (rect.right > window.innerWidth - margin) { bubble.style.left = `${window.innerWidth - margin - rect.width}px`; bubble.style.transform = "none"; - } - if (rect.left < margin) { + } else if (rect.left < margin) { bubble.style.left = `${margin}px`; bubble.style.transform = "none"; } + + // Vertical clamping + if (rect.bottom > window.innerHeight - margin) { + bubble.style.top = `${window.innerHeight - margin - rect.height}px`; + } else if (rect.top < margin) { + bubble.style.top = `${margin}px`; + } }, [isVisible, triggerRect]); const getBubbleStyle = (): React.CSSProperties => { if (!triggerRect) return {}; const gap = 6; const centerX = triggerRect.left + triggerRect.width / 2; - - if (actualPlacement === "bottom") { - return { - position: "fixed", - top: triggerRect.bottom + gap, - left: centerX, - transform: "translateX(-50%)", - }; + const centerY = triggerRect.top + triggerRect.height / 2; + + switch (actualPlacement) { + case "bottom": + return { + position: "fixed", + top: triggerRect.bottom + gap, + left: centerX, + transform: "translateX(-50%)", + }; + case "left": + return { + position: "fixed", + top: centerY, + left: triggerRect.left - gap, + transform: "translate(-100%, -50%)", + }; + case "right": + return { + position: "fixed", + top: centerY, + left: triggerRect.right + gap, + transform: "translateY(-50%)", + }; + default: + return { + position: "fixed", + top: triggerRect.top - gap, + left: centerX, + transform: "translate(-50%, -100%)", + }; } - return { - position: "fixed", - top: triggerRect.top - gap, - left: centerX, - transform: "translate(-50%, -100%)", - }; }; const bubble = isVisible ? ( diff --git a/src/components/pages/bitcoin/BitcoinBlockDisplay.tsx b/src/components/pages/bitcoin/BitcoinBlockDisplay.tsx index c8a9f861..4bfde466 100644 --- a/src/components/pages/bitcoin/BitcoinBlockDisplay.tsx +++ b/src/components/pages/bitcoin/BitcoinBlockDisplay.tsx @@ -93,7 +93,7 @@ const BitcoinBlockDisplay: React.FC = React.memo( {block.confirmations.toLocaleString()} Confirmations {settings.showHelperTooltips !== false && ( - + )} )} diff --git a/src/components/pages/bitcoin/BitcoinTransactionDisplay.tsx b/src/components/pages/bitcoin/BitcoinTransactionDisplay.tsx index b9cdbc12..94cc849a 100644 --- a/src/components/pages/bitcoin/BitcoinTransactionDisplay.tsx +++ b/src/components/pages/bitcoin/BitcoinTransactionDisplay.tsx @@ -136,7 +136,7 @@ const BitcoinTransactionDisplay: React.FC = Reac {transaction.confirmations.toLocaleString()} Confirmations {settings.showHelperTooltips !== false && ( - + )} ) diff --git a/src/components/pages/evm/block/BlockDisplay.tsx b/src/components/pages/evm/block/BlockDisplay.tsx index 9d37f770..4cb77c7c 100644 --- a/src/components/pages/evm/block/BlockDisplay.tsx +++ b/src/components/pages/evm/block/BlockDisplay.tsx @@ -164,7 +164,7 @@ const BlockDisplay: React.FC = React.memo( {t("finalized")} {settings.showHelperTooltips !== false && ( - + )}
    From 443b300a082b49a5aa862501bf0caed7f7412800 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 19 Mar 2026 12:30:52 -0300 Subject: [PATCH 14/54] fix(tooltips): prevent bubble from overlapping trigger on viewport clamp Compute final pixel position for both axes before removing transform, so vertical translation is preserved when only horizontal clamping is needed and vice versa. --- src/components/common/HelperTooltip.tsx | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/components/common/HelperTooltip.tsx b/src/components/common/HelperTooltip.tsx index 07ab9f9d..e4d4c254 100644 --- a/src/components/common/HelperTooltip.tsx +++ b/src/components/common/HelperTooltip.tsx @@ -124,21 +124,32 @@ const HelperTooltip: React.FC = ({ content, placement = "top const bubble = bubbleRef.current; const rect = bubble.getBoundingClientRect(); const margin = 8; + let needsClamp = false; + let left = rect.left; + let top = rect.top; // Horizontal clamping if (rect.right > window.innerWidth - margin) { - bubble.style.left = `${window.innerWidth - margin - rect.width}px`; - bubble.style.transform = "none"; + left = window.innerWidth - margin - rect.width; + needsClamp = true; } else if (rect.left < margin) { - bubble.style.left = `${margin}px`; - bubble.style.transform = "none"; + left = margin; + needsClamp = true; } // Vertical clamping if (rect.bottom > window.innerHeight - margin) { - bubble.style.top = `${window.innerHeight - margin - rect.height}px`; + top = window.innerHeight - margin - rect.height; + needsClamp = true; } else if (rect.top < margin) { - bubble.style.top = `${margin}px`; + top = margin; + needsClamp = true; + } + + if (needsClamp) { + bubble.style.left = `${left}px`; + bubble.style.top = `${top}px`; + bubble.style.transform = "none"; } }, [isVisible, triggerRect]); From cfc9998625960647955005b805f53d65072bc1b9 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 19 Mar 2026 12:33:38 -0300 Subject: [PATCH 15/54] feat(tooltips): add arrow pointer from bubble to trigger Render a CSS triangle arrow inside the tooltip bubble that points toward the trigger. Arrow position is dynamically calculated after viewport clamping to always aim at the trigger center. --- src/components/common/HelperTooltip.tsx | 35 ++++++++++++++++-- .../pages/bitcoin/BitcoinBlockDisplay.tsx | 5 ++- src/styles/helper-tooltip.css | 37 +++++++++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/components/common/HelperTooltip.tsx b/src/components/common/HelperTooltip.tsx index e4d4c254..58dc1f7e 100644 --- a/src/components/common/HelperTooltip.tsx +++ b/src/components/common/HelperTooltip.tsx @@ -16,6 +16,7 @@ const HelperTooltip: React.FC = ({ content, placement = "top const tooltipId = useId(); const triggerRef = useRef(null); const bubbleRef = useRef(null); + const arrowRef = useRef(null); const hoverTimeoutRef = useRef | null>(null); const isPointerInsideRef = useRef(false); @@ -118,10 +119,11 @@ const HelperTooltip: React.FC = ({ content, placement = "top }; }, []); - // Clamp bubble within viewport after render + // Clamp bubble within viewport and position arrow after render useLayoutEffect(() => { - if (!isVisible || !bubbleRef.current) return; + if (!isVisible || !bubbleRef.current || !triggerRect) return; const bubble = bubbleRef.current; + const arrow = arrowRef.current; const rect = bubble.getBoundingClientRect(); const margin = 8; let needsClamp = false; @@ -151,7 +153,29 @@ const HelperTooltip: React.FC = ({ content, placement = "top bubble.style.top = `${top}px`; bubble.style.transform = "none"; } - }, [isVisible, triggerRect]); + + // Position arrow to point at trigger center + if (arrow) { + const bubbleRect = bubble.getBoundingClientRect(); + const triggerCenterX = triggerRect.left + triggerRect.width / 2; + const triggerCenterY = triggerRect.top + triggerRect.height / 2; + const arrowSize = 5; + + if (actualPlacement === "top" || actualPlacement === "bottom") { + const arrowLeft = Math.max( + arrowSize, + Math.min(triggerCenterX - bubbleRect.left, bubbleRect.width - arrowSize), + ); + arrow.style.left = `${arrowLeft}px`; + } else { + const arrowTop = Math.max( + arrowSize, + Math.min(triggerCenterY - bubbleRect.top, bubbleRect.height - arrowSize), + ); + arrow.style.top = `${arrowTop}px`; + } + } + }, [isVisible, triggerRect, actualPlacement]); const getBubbleStyle = (): React.CSSProperties => { if (!triggerRect) return {}; @@ -202,6 +226,11 @@ const HelperTooltip: React.FC = ({ content, placement = "top onMouseLeave={handlePointerLeave} > {content} +
    ) : null; diff --git a/src/components/pages/bitcoin/BitcoinBlockDisplay.tsx b/src/components/pages/bitcoin/BitcoinBlockDisplay.tsx index 4bfde466..a7ab57aa 100644 --- a/src/components/pages/bitcoin/BitcoinBlockDisplay.tsx +++ b/src/components/pages/bitcoin/BitcoinBlockDisplay.tsx @@ -93,7 +93,10 @@ const BitcoinBlockDisplay: React.FC = React.memo( {block.confirmations.toLocaleString()} Confirmations {settings.showHelperTooltips !== false && ( - + )} )} diff --git a/src/styles/helper-tooltip.css b/src/styles/helper-tooltip.css index def820ea..448d2a87 100644 --- a/src/styles/helper-tooltip.css +++ b/src/styles/helper-tooltip.css @@ -55,6 +55,43 @@ word-wrap: break-word; } +/* Arrow pointing from bubble to trigger */ +.helper-tooltip-arrow { + position: absolute; + width: 0; + height: 0; + border: 5px solid transparent; + pointer-events: none; +} + +/* Top placement: arrow at bottom, pointing down */ +.helper-tooltip-arrow-top { + bottom: -10px; + transform: translateX(-50%); + border-top-color: var(--bg-tooltip); +} + +/* Bottom placement: arrow at top, pointing up */ +.helper-tooltip-arrow-bottom { + top: -10px; + transform: translateX(-50%); + border-bottom-color: var(--bg-tooltip); +} + +/* Left placement: arrow at right, pointing right */ +.helper-tooltip-arrow-left { + right: -10px; + transform: translateY(-50%); + border-left-color: var(--bg-tooltip); +} + +/* Right placement: arrow at left, pointing left */ +.helper-tooltip-arrow-right { + left: -10px; + transform: translateY(-50%); + border-right-color: var(--bg-tooltip); +} + /* Light theme text: keep tooltip text white since bg-tooltip is dark in both themes */ .light-theme .helper-tooltip-bubble { color: #fff; From 85f44ec67c7cd577feaaeff831a2a39a117e5e45 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 19 Mar 2026 15:38:28 -0300 Subject: [PATCH 16/54] feat(analyser): add Raw Trace tab and remove old debug trace section Move raw opcode trace to a new tab in TX Analyser with paginated view, expandable stack/storage per step, and opcode color coding. Remove the old standalone debug trace section from TransactionDisplay. Hide TX Analyser entirely when no tabs have content to show. --- .../pages/evm/tx/TransactionDisplay.tsx | 144 -------------- src/components/pages/evm/tx/TxAnalyser.tsx | 72 ++++++- .../pages/evm/tx/analyser/RawTraceTab.tsx | 167 ++++++++++++++++ src/components/pages/evm/tx/analyser/types.ts | 8 +- src/locales/en/transaction.json | 7 +- src/locales/es/transaction.json | 7 +- src/locales/ja/transaction.json | 7 +- src/locales/pt-BR/transaction.json | 7 +- src/locales/zh/transaction.json | 7 +- src/styles/components.css | 182 ++++++++++++++++++ 10 files changed, 456 insertions(+), 152 deletions(-) create mode 100644 src/components/pages/evm/tx/analyser/RawTraceTab.tsx diff --git a/src/components/pages/evm/tx/TransactionDisplay.tsx b/src/components/pages/evm/tx/TransactionDisplay.tsx index 2ac7a1d0..cfcfa80b 100644 --- a/src/components/pages/evm/tx/TransactionDisplay.tsx +++ b/src/components/pages/evm/tx/TransactionDisplay.tsx @@ -18,7 +18,6 @@ import { type TokenListItem, type TokenMetadata, } from "../../../../services/MetadataService"; -import type { TraceResult } from "../../../../services/adapters/NetworkAdapter"; import { logger } from "../../../../utils/logger"; import { formatGweiFromWei, @@ -71,11 +70,6 @@ const TransactionDisplay: React.FC = React.memo( const [callTargetTokenListMatch, setCallTargetTokenListMatch] = useState( null, ); - const [showTrace, setShowTrace] = useState(false); - const [traceData, setTraceData] = useState(null); - // biome-ignore lint/suspicious/noExplicitAny: - const [callTrace, setCallTrace] = useState(null); - const [loadingTrace, setLoadingTrace] = useState(false); const [_copiedField, _setCopiedField] = useState(null); const copyTimeoutRef = useRef | null>(null); @@ -362,26 +356,6 @@ const TransactionDisplay: React.FC = React.memo( callTargetTokenListMatch, ]); - // Check if trace is available (localhost only) - const isTraceAvailable = dataService?.networkAdapter.isTraceAvailable() || false; - - // Load trace data when trace section is expanded - useEffect(() => { - if (showTrace && isTraceAvailable && dataService && !traceData && !callTrace) { - setLoadingTrace(true); - Promise.all([ - dataService.networkAdapter.getTransactionTrace(transaction.hash), - dataService.networkAdapter.getCallTrace(transaction.hash), - ]) - .then(([trace, call]) => { - setTraceData(trace); - setCallTrace(call); - }) - .catch((err) => logger.error("Error loading trace:", err)) - .finally(() => setLoadingTrace(false)); - } - }, [showTrace, isTraceAvailable, dataService, transaction.hash, traceData, callTrace]); - useEffect(() => { return () => { if (copyTimeoutRef.current) { @@ -849,124 +823,6 @@ const TransactionDisplay: React.FC = React.memo( isSuperUser={isSuperUser} /> )} - - {/* Debug Trace Section (Localhost Only) */} - {isTraceAvailable && ( -
    - {/** biome-ignore lint/a11y/useButtonType: */} - - - {showTrace && ( -
    - {loadingTrace &&
    {t("loadingTrace")}
    } - - {/* Call Trace */} - {callTrace && ( -
    -
    {t("callTrace")}
    -
    -
    - {t("traceType")} {callTrace.type} -
    -
    - {t("traceFrom")}{" "} - -
    -
    - {t("traceTo")}{" "} - -
    -
    - {t("traceValue")} {callTrace.value} -
    -
    - {t("traceGas")} {callTrace.gas} -
    -
    - {t("traceGasUsed")} {callTrace.gasUsed} -
    - {callTrace.error && ( -
    - {t("traceError")} {callTrace.error} -
    - )} - {callTrace.calls && callTrace.calls.length > 0 && ( -
    -
    - {t("internalCalls")} ({callTrace.calls.length}): -
    -
    - {JSON.stringify(callTrace.calls, null, 2)} -
    -
    - )} -
    -
    - )} - - {/* Opcode Trace */} - {traceData && ( -
    -
    {t("executionTrace")}
    -
    -
    - {t("opcodeTrace.totalGasUsed")}:{" "} - {traceData.gas} -
    -
    - {t("opcodeTrace.failed")}:{" "} - {traceData.failed ? t("opcodeTrace.yes") : t("opcodeTrace.no")} -
    -
    - {t("opcodeTrace.returnValue")}:{" "} - -
    -
    - {t("opcodeTrace.executed")}{" "} - {traceData.structLogs.length} -
    -
    - -
    {t("opcodeTrace.executionLog")}
    -
    - {traceData.structLogs.slice(0, 100).map((log, index) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: -
    -
    - {t("opcodeTrace.step")} {index}: {log.op} -
    -
    - {t("opcodeTrace.PC")}: {log.pc} | {t("opcodeTrace.gas")}: {log.gas} |{" "} - {t("opcodeTrace.cost")}: {log.gasCost} | {t("opcodeTrace.depth")}:{" "} - {log.depth} -
    - {log.stack && log.stack.length > 0 && ( -
    - {t("opcodeTrace.stack")}: [{log.stack.slice(0, 3).join(", ")} - {log.stack.length > 3 ? "..." : ""}] -
    - )} -
    - ))} - {traceData.structLogs.length > 100 && ( -
    - {t("opcodeTrace.showingFirst100", { - total: traceData.structLogs.length, - })} -
    - )} -
    -
    - )} -
    - )} -
    - )}
    = ({ if (isSuperUser) { setCollapsed(false); } else { - const superTabs: AnalyserTab[] = ["callTree", "gasProfiler", "stateChanges"]; + const superTabs: AnalyserTab[] = ["callTree", "gasProfiler", "stateChanges", "rawTrace"]; setActiveTab((prev) => (superTabs.includes(prev) ? defaultTab : prev)); } }, [isSuperUser]); const [callTree, setCallTree] = useState(null); const [prestateTrace, setPrestateTrace] = useState(null); + const [rawTrace, setRawTrace] = useState(null); const [loadingCallTree, setLoadingCallTree] = useState(false); const [loadingPrestate, setLoadingPrestate] = useState(false); + const [loadingRawTrace, setLoadingRawTrace] = useState(false); const [callTreeError, setCallTreeError] = useState(null); const [prestateError, setPrestateError] = useState(null); + const [rawTraceError, setRawTraceError] = useState(null); // Contract name + ABI enrichment for the call tree const { contracts: treeContracts, enrichmentLoading } = useCallTreeEnrichment( @@ -169,6 +177,41 @@ const TxAnalyser: React.FC = ({ isUnsupported, ]); + // Load raw trace when switching to that tab (super user only) + useEffect(() => { + if (!isSuperUser) return; + if (activeTab !== "rawTrace") return; + if (rawTrace || rawTraceError || loadingRawTrace) return; + setLoadingRawTrace(true); + dataService.networkAdapter + .getTransactionTrace(txHash) + .then((data) => { + if (data) { + setRawTrace(data); + } else { + setRawTraceError(t("analyser.notSupported")); + } + }) + .catch((err) => { + const msg = err instanceof Error ? err.message : String(err); + logger.warn("TX Analyser raw trace error:", msg); + setRawTraceError( + isUnsupported(msg) ? t("analyser.notSupported") : `${t("analyser.error")}: ${msg}`, + ); + }) + .finally(() => setLoadingRawTrace(false)); + }, [ + isSuperUser, + activeTab, + txHash, + dataService, + rawTrace, + rawTraceError, + loadingRawTrace, + t, + isUnsupported, + ]); + const [collapsed, setCollapsed] = useState(!isSuperUser); const handleTabClick = useCallback( @@ -183,6 +226,9 @@ const TxAnalyser: React.FC = ({ [activeTab], ); + // Hide entirely if there's nothing to show + if (!isSuperUser && !hasEvents && !hasInputData) return null; + return (
    {/* Tab bar */} @@ -228,6 +274,13 @@ const TxAnalyser: React.FC = ({ > {t("analyser.stateChanges")} + )} + + {t("analyser.rawTracePage", { + current: page + 1, + total: totalPages, + from: (startIndex + 1).toLocaleString(), + to: Math.min(startIndex + OPCODES_PER_PAGE, totalSteps).toLocaleString(), + totalSteps: totalSteps.toLocaleString(), + })} + + +
    + )} +
    + ); +}; + +export default RawTraceTab; diff --git a/src/components/pages/evm/tx/analyser/types.ts b/src/components/pages/evm/tx/analyser/types.ts index 74f1315a..87bf110f 100644 --- a/src/components/pages/evm/tx/analyser/types.ts +++ b/src/components/pages/evm/tx/analyser/types.ts @@ -1,7 +1,13 @@ import type { DecodedInput } from "../../../../../utils/inputDecoder"; import type { DataService } from "../../../../../services/DataService"; -export type AnalyserTab = "callTree" | "gasProfiler" | "stateChanges" | "events" | "inputData"; +export type AnalyserTab = + | "callTree" + | "gasProfiler" + | "stateChanges" + | "rawTrace" + | "events" + | "inputData"; export interface TxAnalyserProps { txHash: string; diff --git a/src/locales/en/transaction.json b/src/locales/en/transaction.json index 61369bed..e5b275cb 100644 --- a/src/locales/en/transaction.json +++ b/src/locales/en/transaction.json @@ -173,6 +173,11 @@ "expandAll": "Expand All", "collapseAll": "Collapse All", "expand": "Expand", - "collapse": "Collapse" + "collapse": "Collapse", + "rawTrace": "Raw Trace", + "rawTraceSteps": "{{count}} opcode steps", + "rawTracePrev": "Prev", + "rawTraceNext": "Next", + "rawTracePage": "Page {{current}}/{{total}} ({{from}}–{{to}} of {{totalSteps}})" } } diff --git a/src/locales/es/transaction.json b/src/locales/es/transaction.json index 8961b03c..6c487ede 100644 --- a/src/locales/es/transaction.json +++ b/src/locales/es/transaction.json @@ -173,6 +173,11 @@ "expandAll": "Expandir Todo", "collapseAll": "Colapsar Todo", "expand": "Expandir", - "collapse": "Colapsar" + "collapse": "Colapsar", + "rawTrace": "Traza Cruda", + "rawTraceSteps": "{{count}} pasos de opcode", + "rawTracePrev": "Anterior", + "rawTraceNext": "Siguiente", + "rawTracePage": "Página {{current}}/{{total}} ({{from}}–{{to}} de {{totalSteps}})" } } diff --git a/src/locales/ja/transaction.json b/src/locales/ja/transaction.json index 38f39e56..e8c768ae 100644 --- a/src/locales/ja/transaction.json +++ b/src/locales/ja/transaction.json @@ -173,6 +173,11 @@ "expandAll": "すべて展開", "collapseAll": "すべて折りたたむ", "expand": "展開", - "collapse": "折りたたむ" + "collapse": "折りたたむ", + "rawTrace": "生トレース", + "rawTraceSteps": "{{count}} オペコードステップ", + "rawTracePrev": "前へ", + "rawTraceNext": "次へ", + "rawTracePage": "ページ {{current}}/{{total}} ({{from}}–{{to}} / {{totalSteps}})" } } diff --git a/src/locales/pt-BR/transaction.json b/src/locales/pt-BR/transaction.json index 8225b8a2..6509b84a 100644 --- a/src/locales/pt-BR/transaction.json +++ b/src/locales/pt-BR/transaction.json @@ -173,6 +173,11 @@ "expandAll": "Expandir Tudo", "collapseAll": "Recolher Tudo", "expand": "Expandir", - "collapse": "Recolher" + "collapse": "Recolher", + "rawTrace": "Trace Bruto", + "rawTraceSteps": "{{count}} passos de opcode", + "rawTracePrev": "Anterior", + "rawTraceNext": "Próximo", + "rawTracePage": "Página {{current}}/{{total}} ({{from}}–{{to}} de {{totalSteps}})" } } diff --git a/src/locales/zh/transaction.json b/src/locales/zh/transaction.json index 71985afb..ab93ba07 100644 --- a/src/locales/zh/transaction.json +++ b/src/locales/zh/transaction.json @@ -173,6 +173,11 @@ "expandAll": "全部展开", "collapseAll": "全部折叠", "expand": "展开", - "collapse": "折叠" + "collapse": "折叠", + "rawTrace": "原始追踪", + "rawTraceSteps": "{{count}} 操作码步骤", + "rawTracePrev": "上一页", + "rawTraceNext": "下一页", + "rawTracePage": "第 {{current}}/{{total}} 页 ({{from}}–{{to}} / {{totalSteps}})" } } diff --git a/src/styles/components.css b/src/styles/components.css index 01058c03..7eff048d 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -2429,6 +2429,188 @@ button.tx-section-header-toggle { } } +/* ── Raw Trace ──────────────────────────────────────────────────────────── */ + +.raw-trace-header, +.raw-trace-row-summary { + display: grid; + grid-template-columns: 60px 50px 120px 80px 60px 50px 24px; + gap: 4px; + padding: 4px 12px; + font-size: 0.78rem; + font-family: "JetBrains Mono", monospace; + align-items: center; +} + +.raw-trace-header { + color: var(--text-tertiary); + font-weight: 600; + border-bottom: 1px solid var(--border-primary); + padding-bottom: 6px; + margin-bottom: 4px; +} + +.raw-trace-row-summary { + cursor: pointer; + border-radius: 4px; + color: var(--text-secondary); +} + +.raw-trace-row-summary:hover { + background: var(--overlay-light-5); +} + +.raw-trace-step { + color: var(--text-tertiary); + font-size: 0.72rem; +} + +.raw-trace-pc { + color: var(--text-tertiary); +} + +.raw-trace-op { + font-weight: 600; + color: var(--text-primary); +} + +.raw-trace-gas, +.raw-trace-gas-cost { + text-align: right; + color: var(--text-tertiary); +} + +.raw-trace-depth { + text-align: center; + color: var(--text-tertiary); +} + +.raw-trace-expand { + text-align: center; + color: var(--text-tertiary); + font-size: 0.7rem; +} + +.raw-trace-row-detail { + padding: 6px 12px 10px 72px; + font-size: 0.75rem; +} + +.raw-trace-section { + margin-bottom: 8px; +} + +.raw-trace-section-title { + color: var(--text-tertiary); + font-size: 0.72rem; + font-weight: 600; + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.raw-trace-stack { + display: flex; + flex-direction: column; + gap: 1px; + max-height: 200px; + overflow-y: auto; +} + +.raw-trace-stack-item { + display: flex; + gap: 8px; + padding: 1px 0; +} + +.raw-trace-stack-index { + color: var(--text-tertiary); + min-width: 20px; + text-align: right; + font-size: 0.7rem; +} + +.raw-trace-stack-value { + color: var(--text-primary); + font-size: 0.72rem; + word-break: break-all; +} + +.raw-trace-storage { + display: flex; + flex-direction: column; + gap: 2px; +} + +.raw-trace-storage-item { + display: flex; + gap: 6px; + align-items: center; + font-size: 0.72rem; +} + +.raw-trace-storage-slot { + color: var(--color-info); +} + +.raw-trace-storage-arrow { + color: var(--text-tertiary); +} + +.raw-trace-storage-value { + color: var(--text-primary); +} + +.raw-trace-failed { + color: var(--color-error); + font-weight: 600; +} + +.raw-trace-pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + padding: 10px 0 4px; +} + +.raw-trace-page-btn { + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 4px; + padding: 4px 12px; + font-size: 0.78rem; + color: var(--text-secondary); + cursor: pointer; +} + +.raw-trace-page-btn:hover:not(:disabled) { + background: var(--overlay-light-10); +} + +.raw-trace-page-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.raw-trace-page-info { + font-size: 0.75rem; + color: var(--text-tertiary); +} + +@media (max-width: 640px) { + .raw-trace-header, + .raw-trace-row-summary { + grid-template-columns: 40px 40px 90px 60px 50px 30px 20px; + font-size: 0.7rem; + padding: 4px 8px; + } + + .raw-trace-row-detail { + padding-left: 48px; + } +} + /* Input Data Display */ .input-data-display { background: var(--bg-tertiary); From 090a20fcd183d68d8292265f4f3e5798cba5b222 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 19 Mar 2026 15:52:22 -0300 Subject: [PATCH 17/54] docs(claude): update rules to reflect current architecture and workflow - workflow: add dual strategy (patch PRs to dev, release branches for minor/major) - architecture: update to NetworkAdapter/AdapterFactory pattern, add Bitcoin and Hardhat - patterns: fix paths, update "add network" steps to new adapter structure - i18n: add 5 supported languages, tooltips namespace, update checklists - code-style: reference all 5 locales in i18n verification --- .claude/rules/architecture.md | 55 ++++++++--------- .claude/rules/code-style.md | 2 +- .claude/rules/i18n.md | 13 +++-- .claude/rules/patterns.md | 17 +++--- .claude/rules/workflow.md | 107 ++++++++++++++++++++++------------ 5 files changed, 114 insertions(+), 80 deletions(-) diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index cf1ade9f..ec5e538a 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -4,48 +4,49 @@ OpenScan follows a layered architecture with clear separation between data fetching, transformation, and presentation: -### 1. RPC Layer (`RPCClient.ts`) -Handles JSON-RPC communication with blockchain nodes: -- Supports two strategies: `fallback` (sequential with automatic failover) and `parallel` (query all providers simultaneously) -- Strategy is configurable via user settings (`useDataService` hook applies strategy) -- Parallel mode enables provider comparison and inconsistency detection - -### 2. Fetcher Layer (`services/EVM/*/fetchers/`) -Makes raw RPC calls for specific data types: -- Network-specific implementations: `L1/`, `Arbitrum/`, `Optimism/` -- Each fetcher handles one domain (blocks, transactions, addresses, network stats) - -### 3. Adapter Layer (`services/EVM/*/adapters/`) -Transforms raw RPC responses into typed domain objects: -- Normalizes network-specific fields (e.g., Arbitrum's `l1BlockNumber`, Optimism's L1 fee data) -- Ensures consistent type structure across networks - -### 4. Service Layer (`DataService.ts`) +### 1. Client Layer (`@openscan/network-connectors`) +Typed RPC clients for blockchain communication: +- `EthereumClient` - Standard JSON-RPC for EVM chains +- `HardhatClient` - Extended client with Hardhat-specific methods (`hardhat_*`, `evm_*`, `debug_*`) +- `BitcoinClient` - Bitcoin JSON-RPC (`getblock`, `getrawtransaction`, etc.) +- Supports `fallback`, `parallel`, and `race` strategies + +### 2. Adapter Layer (`services/adapters/`) +Abstract `NetworkAdapter` base class with chain-specific implementations: +- `EVMAdapter` - Default EVM adapter (Ethereum, BSC, Polygon, Sepolia) +- `ArbitrumAdapter` - Adds `l1BlockNumber`, `sendCount`, `sendRoot` +- `OptimismAdapter` / `BaseAdapter` - Adds L1 fee breakdown (`l1Fee`, `l1GasPrice`, `l1GasUsed`) +- `HardhatAdapter` - Localhost (31337) with trace support via struct log conversion +- `BitcoinAdapter` - Bitcoin networks with UTXO model, mempool, and block explorer +- Each adapter implements: `getBlock`, `getTransaction`, `getAddress`, `getNetworkStats`, trace methods +- `AdapterFactory` routes chain ID to the correct adapter + +### 3. Service Layer (`DataService.ts`) Orchestrates data fetching with caching and metadata: -- Instantiates network-specific fetchers/adapters based on chain ID +- Instantiates the correct adapter via `AdapterFactory` based on chain ID - Returns `DataWithMetadata` when using parallel strategy - 30-second in-memory cache keyed by `networkId:type:identifier` -- Supports trace operations for localhost networks only -### 5. Hook Layer (`hooks/`) +### 4. Hook Layer (`hooks/`) React integration: - `useDataService(networkId)`: Creates DataService instance with strategy from settings - `useProviderSelection`: Manages user's selected RPC provider in parallel mode - `useSelectedData`: Extracts data from specific provider based on user selection -### 6. Context Layer (`context/`) +### 5. Context Layer (`context/`) Global state management: - `AppContext`: RPC URLs configuration -- `SettingsContext`: User settings including `rpcStrategy` ('fallback' | 'parallel') +- `SettingsContext`: User settings including `rpcStrategy` ('fallback' | 'parallel' | 'race') ## Network-Specific Handling -Chain ID detection in `DataService` constructor determines which adapters/fetchers to use: +Chain ID detection in `AdapterFactory` determines which adapter to instantiate: -- **Arbitrum** (42161): `BlockFetcherArbitrum`, `BlockArbitrumAdapter` - adds `l1BlockNumber`, `sendCount`, `requestId` -- **OP Stack** (10, 8453): Optimism (10), Base (8453) - adds L1 fee breakdown (`l1Fee`, `l1GasPrice`, `l1GasUsed`) -- **Localhost** (31337): All networks + trace support (`debug_traceTransaction`, `trace_block`, etc.) -- **Default**: L1 fetchers/adapters for Ethereum (1), BSC (56, 97), Polygon (137), Sepolia (11155111) +- **Arbitrum** (42161): `ArbitrumAdapter` - adds `l1BlockNumber`, `sendCount`, `sendRoot` +- **OP Stack** (10, 8453): `OptimismAdapter`, `BaseAdapter` - adds L1 fee breakdown +- **Hardhat** (31337): `HardhatAdapter` - trace support via struct log conversion (`buildCallTreeFromStructLogs`, `buildPrestateFromStructLogs`) +- **Bitcoin** (bip122:*): `BitcoinAdapter` - UTXO model, mempool transactions, block rewards +- **Default**: `EVMAdapter` for Ethereum (1), BSC (56, 97), Polygon (137), Sepolia (11155111), BNB (56) ## Key Type Definitions diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md index 33dfdabb..70a5a412 100644 --- a/.claude/rules/code-style.md +++ b/.claude/rules/code-style.md @@ -61,7 +61,7 @@ npm run typecheck # 4. Verify i18n compliance # - Ensure no hardcoded user-facing strings -# - Test in both English and Spanish if you added translations +# - Test in multiple languages if you added translations (en, es, ja, pt-BR, zh) # 5. Run tests (if applicable) npm run test:run diff --git a/.claude/rules/i18n.md b/.claude/rules/i18n.md index 66948ff5..99a68268 100644 --- a/.claude/rules/i18n.md +++ b/.claude/rules/i18n.md @@ -9,7 +9,7 @@ - **Library**: react-i18next (v16.5.4) with i18next (v25.8.0) - **Configuration**: `src/i18n.ts` - **Type Definitions**: `src/i18next.d.ts` (provides TypeScript autocomplete) -- **Supported Languages**: English (en), Spanish (es) +- **Supported Languages**: English (en), Spanish (es), Japanese (ja), Portuguese-BR (pt-BR), Chinese (zh) - **Language Detection**: Auto-detects from browser or localStorage (`openScan_language`) ## When to Use i18n @@ -49,6 +49,7 @@ Choose the appropriate namespace based on the component location: 8. **devtools** - Developer tools page 9. **errors** - Error messages (if not in common) 10. **tokenDetails** - Token detail pages (ERC20, ERC721, ERC1155) +11. **tooltips** - Helper tooltip content for blockchain data fields (used by FieldLabel/HelperTooltip) ## Basic Usage @@ -153,9 +154,9 @@ const currentLang = i18n.language; // "en" or "es" 1. **Identify the appropriate namespace** based on component location 2. **Add key to English file** (`src/locales/en/{namespace}.json`) -3. **Add same key to Spanish file** (`src/locales/es/{namespace}.json`) +3. **Add same key to all other locale files** (`src/locales/{es,ja,pt-BR,zh}/{namespace}.json`) 4. **Use TypeScript autocomplete** to verify the key exists -5. **Test in both languages** by switching in Settings +5. **Test in multiple languages** by switching in Settings ### Example @@ -325,9 +326,9 @@ When adding or modifying components, ensure: - [ ] No hardcoded user-facing strings remain - [ ] All text uses `t()` function from useTranslation -- [ ] Translation keys exist in **both** en/ and es/ directories +- [ ] Translation keys exist in **all** locale directories (en, es, ja, pt-BR, zh) - [ ] TypeScript compilation passes (`npm run typecheck`) -- [ ] Tested in both English and Spanish (switch in Settings) +- [ ] Tested in multiple languages (switch in Settings) ## Checklist for Code Review @@ -337,5 +338,5 @@ When reviewing code, verify: - [ ] Appropriate namespace is selected - [ ] Translation keys follow existing naming patterns - [ ] Interpolation variables are properly typed -- [ ] Translations exist in all supported languages +- [ ] Translations exist in all 5 supported languages (en, es, ja, pt-BR, zh) - [ ] No typos in translation keys (TypeScript should catch these) diff --git a/.claude/rules/patterns.md b/.claude/rules/patterns.md index 8d9e0d11..8b71e764 100644 --- a/.claude/rules/patterns.md +++ b/.claude/rules/patterns.md @@ -2,17 +2,18 @@ ## When Modifying Data Fetching -- Always maintain the adapter pattern: Fetcher → Adapter → Service +- Always maintain the adapter pattern: Client → Adapter → Service +- All adapters extend the abstract `NetworkAdapter` class (`services/adapters/NetworkAdapter.ts`) - If adding parallel strategy support, ensure complete objects are built for each provider -- Test both `fallback` and `parallel` strategies +- Test `fallback`, `parallel`, and `race` strategies - Update TypeScript types in `src/types/index.ts` if adding new fields ## When Adding L2-Specific Features - Check if network is OP Stack-based (Optimism, Base) or Arbitrum - Add network-specific types (e.g., `TransactionOptimism extends Transaction`) -- Create adapters that inherit base behavior and add L2 fields -- Update `DataService` conditional logic in constructor and relevant methods +- Create a new adapter extending `NetworkAdapter` in `services/adapters/[Network]/` +- Register the adapter in `AdapterFactory` (`services/adapters/adaptersFactory.ts`) ## When Working with Cache @@ -32,9 +33,9 @@ 1. Add chain ID to `src/types/index.ts` if creating new domain types 2. Add default RPC endpoints to `src/config/rpcConfig.ts` -3. Determine if network needs custom fetchers/adapters (L1, Arbitrum-like, OP Stack-like) -4. If custom: create `src/services/EVM/[Network]/fetchers/` and `adapters/` -5. Update `DataService` constructor to detect chain ID and instantiate correct fetchers/adapters +3. Determine if network needs a custom adapter (L1, Arbitrum-like, OP Stack-like, Bitcoin) +4. If custom: create `src/services/adapters/[Network]/[Network]Adapter.ts` extending `NetworkAdapter` +5. Register the adapter in `AdapterFactory` (`src/services/adapters/adaptersFactory.ts`) 6. Add network config to `ALL_NETWORKS` in `src/config/networks.ts` 7. Add network logo to `public/` and update `logoType` in network config @@ -50,7 +51,7 @@ OpenScan includes special support for localhost development: ### Address Page Components - Use display components for different address types: `AccountDisplay`, `ContractDisplay`, `ERC20Display`, `ERC721Display`, `ERC1155Display` -- Shared components in `src/components/pages/address/shared/` +- Shared components in `src/components/pages/evm/address/shared/` - Card-based layout with Overview and More Info sections ### Theming diff --git a/.claude/rules/workflow.md b/.claude/rules/workflow.md index 579f2a94..1ccfe130 100644 --- a/.claude/rules/workflow.md +++ b/.claude/rules/workflow.md @@ -2,76 +2,107 @@ ## Branch Strategy -The project follows a structured branching workflow: +The project uses two workflows depending on the scope of changes: + +### Patch Releases (default workflow) + +For incremental work (bug fixes, small features, improvements) that goes into the next patch version: ``` -feature/fix/refactor branches → release branch (vX.Y.Z) → dev (staging/QA) → main (production) +feature/fix/refactor branches → dev (release candidate) → main (production) ``` -### Branch Types +1. **Dev Branch**: Always holds the next release candidate (patch increment) + - Feature/fix PRs are created directly against `dev` + - Used for QA/staging before production + - Always represents the next patch version (e.g., if main is v1.2.0, dev is the v1.2.1 candidate) -1. **Feature/Fix/Refactor Branches**: Created from the **release branch** for specific changes +2. **Feature/Fix/Refactor Branches**: Created from `dev` - Naming: `feat/`, `fix/`, `refactor/` - - Example: `feat/token-holdings`, `fix/light-theme-colors`, `refactor/address-layout` - - PRs are created against the release branch - -2. **Release Branches**: Created for each release cycle - - Naming: `release/vX.Y.Z` (e.g., `release/v1.1.1`) - - All feature branches are merged here - - When features are complete, merged to `dev` for QA/staging + - PRs are created against `dev` -3. **Dev Branch**: Staging/QA environment - - Receives merges from release branches - - Used for QA testing before production - - If fixes are needed during QA, PRs can be created directly against `dev` - -4. **Main Branch**: Production-ready code +3. **Main Branch**: Production-ready code - Only receives merges from `dev` after QA approval - - Always stable and deployable + - Tagged with the version on merge -### Workflow Steps +#### Patch Workflow Steps -1. Create or checkout the release branch: +1. Create feature branch from `dev`: ```bash - git checkout release/v1.1.1 - git pull origin release/v1.1.1 + git checkout dev + git pull origin dev + git checkout -b feat/my-feature ``` -2. Create feature branch from the release branch: +2. Work on feature, commit changes following conventional commits + +3. Push and create PR to `dev`: ```bash - git checkout -b feat/my-feature + git push -u origin feat/my-feature + gh pr create --base dev ``` -3. Work on feature, commit changes following conventional commits +4. After PR approval and merge to `dev`, delete feature branch -4. Push and create PR to the **release branch**: +5. After QA approval, merge `dev` to `main`: ```bash - git push -u origin feat/my-feature - gh pr create --base release/v1.1.1 + git checkout main + git merge dev + git tag v1.2.1 + git push origin main --tags ``` -5. After PR approval and merge to release branch, delete feature branch +### Minor/Major Releases (release branch workflow) + +For larger milestones with multiple coordinated changes (new features, breaking changes): -6. When release is ready for QA, merge release branch to `dev`: +``` +feature/fix/refactor branches → release branch (vX.Y.Z) → dev → main +``` + +1. **Release Branches**: Created from `dev` for each release cycle + - Naming: `release/vX.Y.Z` (e.g., `release/v1.3.0`) + - Feature branches are created from and merged into the release branch + - When features are complete, merged to `dev` for QA/staging + +2. **Feature Branches**: Created from the **release branch** + - PRs are created against the release branch + +#### Release Branch Workflow Steps + +1. Create the release branch from `dev`: ```bash git checkout dev - git merge release/v1.1.1 - git push origin dev + git checkout -b release/v1.3.0 + git push -u origin release/v1.3.0 + ``` + +2. Create feature branches from the release branch: + ```bash + git checkout release/v1.3.0 + git checkout -b feat/my-feature ``` -7. **QA/Staging fixes**: If issues are found during QA, create PRs directly against `dev`: +3. Push and create PR to the **release branch**: + ```bash + git push -u origin feat/my-feature + gh pr create --base release/v1.3.0 + ``` + +4. When all features are merged, merge release branch to `dev` for QA: ```bash git checkout dev - git checkout -b fix/qa-issue - # ... fix the issue ... - gh pr create --base dev + git merge release/v1.3.0 + git push origin dev ``` -8. After QA approval, merge `dev` to `main`: +5. QA fixes go directly against `dev` + +6. After QA approval, merge `dev` to `main`: ```bash git checkout main git merge dev - git tag v1.1.1 + git tag v1.3.0 git push origin main --tags ``` From 3ae04193c11d3d11a2c6c043accfa45e507dd8e8 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 19 Mar 2026 16:05:21 -0300 Subject: [PATCH 18/54] feat(worker): add Etherscan API proxy for free contract verification Add /etherscan/verify route to the Cloudflare Worker that proxies contract verification requests to Etherscan V2 API using a shared API key. Frontend now uses the worker proxy as default fallback when no user key is configured, making verification free for all users. Closes #322 --- src/hooks/useEtherscan.ts | 38 +++++++--- src/utils/contractLookup.ts | 82 +++++++++++++++------ worker/src/index.ts | 11 +++ worker/src/middleware/rateLimitEtherscan.ts | 49 ++++++++++++ worker/src/middleware/validateEtherscan.ts | 24 ++++++ worker/src/routes/etherscanVerify.ts | 35 +++++++++ worker/src/types.ts | 6 ++ worker/wrangler.toml | 6 +- 8 files changed, 217 insertions(+), 34 deletions(-) create mode 100644 worker/src/middleware/rateLimitEtherscan.ts create mode 100644 worker/src/middleware/validateEtherscan.ts create mode 100644 worker/src/routes/etherscanVerify.ts diff --git a/src/hooks/useEtherscan.ts b/src/hooks/useEtherscan.ts index fc8a51c3..151ea62d 100644 --- a/src/hooks/useEtherscan.ts +++ b/src/hooks/useEtherscan.ts @@ -4,6 +4,10 @@ import { logger } from "../utils/logger"; import type { SourcifyContractDetails } from "./useSourcify"; const ETHERSCAN_V2_API = "https://api.etherscan.io/v2/api"; +const OPENSCAN_WORKER_URL = + // biome-ignore lint/complexity/useLiteralKeys: env var access + process.env["REACT_APP_OPENSCAN_WORKER_URL"] ?? + "https://openscan-groq-ai-proxy.openscan.workers.dev"; interface EtherscanSourceResult { SourceCode: string; @@ -81,7 +85,7 @@ export function useEtherscan( const [isVerified, setIsVerified] = useState(false); useEffect(() => { - if (!enabled || !address || !networkId || !apiKey) { + if (!enabled || !address || !networkId) { setData(null); setIsVerified(false); setLoading(false); @@ -93,15 +97,29 @@ export function useEtherscan( const fetchData = async () => { setLoading(true); try { - const params = new URLSearchParams({ - chainid: String(networkId), - module: "contract", - action: "getsourcecode", - address, - apikey: apiKey, - }); - const url = `${ETHERSCAN_V2_API}?${params.toString()}`; - const response = await fetch(url, { signal: controller.signal }); + let response: Response; + + if (apiKey) { + // Direct Etherscan call with user's key + const params = new URLSearchParams({ + chainid: String(networkId), + module: "contract", + action: "getsourcecode", + address, + apikey: apiKey, + }); + response = await fetch(`${ETHERSCAN_V2_API}?${params.toString()}`, { + signal: controller.signal, + }); + } else { + // Proxy through OpenScan Worker (free, no key needed) + response = await fetch(`${OPENSCAN_WORKER_URL}/etherscan/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chainId: networkId, address }), + signal: controller.signal, + }); + } if (!response.ok) { setData(null); diff --git a/src/utils/contractLookup.ts b/src/utils/contractLookup.ts index 9b20a155..e9ab5675 100644 --- a/src/utils/contractLookup.ts +++ b/src/utils/contractLookup.ts @@ -9,9 +9,51 @@ export interface ContractInfo { // Session-level cache keyed by "chainId:address" const cache = new Map(); +const OPENSCAN_WORKER_URL = + // biome-ignore lint/complexity/useLiteralKeys: env var access + process.env["REACT_APP_OPENSCAN_WORKER_URL"] ?? + "https://openscan-groq-ai-proxy.openscan.workers.dev"; + +/** + * Fetch contract verification from Etherscan V2 API. + * Uses user-provided key directly, or proxies through the OpenScan Worker. + */ +async function fetchEtherscanVerification( + address: string, + chainId: number, + signal?: AbortSignal, + etherscanKey?: string, + // biome-ignore lint/suspicious/noExplicitAny: Etherscan response shape varies +): Promise { + try { + let res: Response; + + if (etherscanKey) { + // Direct Etherscan call with user's key + const url = `https://api.etherscan.io/v2/api?chainid=${chainId}&module=contract&action=getsourcecode&address=${address}&apikey=${etherscanKey}`; + res = await fetch(url, { signal }); + } else { + // Proxy through OpenScan Worker (free, no key needed) + res = await fetch(`${OPENSCAN_WORKER_URL}/etherscan/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chainId, address }), + signal, + }); + } + + if (!res.ok) return null; + return await res.json(); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") throw err; + logger.debug("Etherscan lookup failed for", address, err); + return null; + } +} + /** * Fetch contract name + ABI for a single address. - * Tries Sourcify first; falls back to Etherscan V2 API if a key is provided. + * Tries Sourcify first; falls back to Etherscan V2 API (via worker proxy or user key). * Results are cached in memory for the session. */ export async function fetchContractInfo( @@ -54,28 +96,24 @@ export async function fetchContractInfo( logger.debug("Sourcify lookup failed for", address, err); } - // ── Etherscan fallback ──────────────────────────────────────────────────── - if (etherscanKey) { - try { - const url = `https://api.etherscan.io/v2/api?chainid=${chainId}&module=contract&action=getsourcecode&address=${address}&apikey=${etherscanKey}`; - const res = await fetch(url, { signal }); - const json = await res.json(); - if ( - json.status === "1" && - Array.isArray(json.result) && - json.result[0]?.ABI && - json.result[0].ABI !== "Contract source code not verified" - ) { - const r = json.result[0]; - const abi = JSON.parse(r.ABI); - const info: ContractInfo = { name: r.ContractName || undefined, abi }; - cache.set(cacheKey, info); - return info; - } - } catch (err) { - if (err instanceof Error && err.name === "AbortError") return null; - logger.debug("Etherscan lookup failed for", address, err); + // ── Etherscan fallback (worker proxy or user key) ───────────────────────── + try { + const json = await fetchEtherscanVerification(address, chainId, signal, etherscanKey); + if ( + json?.status === "1" && + Array.isArray(json.result) && + json.result[0]?.ABI && + json.result[0].ABI !== "Contract source code not verified" + ) { + const r = json.result[0]; + const abi = JSON.parse(r.ABI); + const info: ContractInfo = { name: r.ContractName || undefined, abi }; + cache.set(cacheKey, info); + return info; } + } catch (err) { + if (err instanceof Error && err.name === "AbortError") return null; + logger.debug("Etherscan lookup failed for", address, err); } cache.set(cacheKey, null); diff --git a/worker/src/index.ts b/worker/src/index.ts index c180ba61..42a2e46b 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -2,8 +2,11 @@ import { Hono } from "hono"; import type { Env } from "./types"; import { corsMiddleware } from "./middleware/cors"; import { rateLimitMiddleware } from "./middleware/rateLimit"; +import { rateLimitEtherscanMiddleware } from "./middleware/rateLimitEtherscan"; import { validateMiddleware } from "./middleware/validate"; +import { validateEtherscanMiddleware } from "./middleware/validateEtherscan"; import { analyzeHandler } from "./routes/analyze"; +import { etherscanVerifyHandler } from "./routes/etherscanVerify"; const app = new Hono<{ Bindings: Env }>(); @@ -13,6 +16,14 @@ app.use("*", corsMiddleware); // POST /ai/analyze — rate limit, validate, then handle app.post("/ai/analyze", rateLimitMiddleware, validateMiddleware, analyzeHandler); +// POST /etherscan/verify — rate limit, validate, then proxy to Etherscan V2 API +app.post( + "/etherscan/verify", + rateLimitEtherscanMiddleware, + validateEtherscanMiddleware, + etherscanVerifyHandler, +); + // Health check app.get("/health", (c) => c.json({ status: "ok" })); diff --git a/worker/src/middleware/rateLimitEtherscan.ts b/worker/src/middleware/rateLimitEtherscan.ts new file mode 100644 index 00000000..8fd2fe16 --- /dev/null +++ b/worker/src/middleware/rateLimitEtherscan.ts @@ -0,0 +1,49 @@ +import type { Context, Next } from "hono"; +import type { Env } from "../types"; + +const WINDOW_MS = 60_000; // 1 minute +const MAX_REQUESTS = 30; // More generous than AI (10) + +interface RateLimitEntry { + timestamps: number[]; +} + +const store = new Map(); + +let lastCleanup = Date.now(); +const CLEANUP_INTERVAL_MS = 300_000; // 5 minutes + +function cleanup(now: number) { + if (now - lastCleanup < CLEANUP_INTERVAL_MS) return; + lastCleanup = now; + for (const [key, entry] of store) { + if (entry.timestamps.every((ts) => now - ts > WINDOW_MS)) { + store.delete(key); + } + } +} + +export async function rateLimitEtherscanMiddleware(c: Context<{ Bindings: Env }>, next: Next) { + const ip = c.req.header("CF-Connecting-IP") ?? c.req.header("X-Forwarded-For") ?? "unknown"; + const now = Date.now(); + + cleanup(now); + + let entry = store.get(ip); + if (!entry) { + entry = { timestamps: [] }; + store.set(ip, entry); + } + + entry.timestamps = entry.timestamps.filter((ts) => now - ts < WINDOW_MS); + + if (entry.timestamps.length >= MAX_REQUESTS) { + const oldestInWindow = entry.timestamps[0]!; + const retryAfterSec = Math.ceil((WINDOW_MS - (now - oldestInWindow)) / 1000); + c.header("Retry-After", String(retryAfterSec)); + return c.json({ error: "Rate limit exceeded. Try again later." }, 429); + } + + entry.timestamps.push(now); + await next(); +} diff --git a/worker/src/middleware/validateEtherscan.ts b/worker/src/middleware/validateEtherscan.ts new file mode 100644 index 00000000..4bfa6601 --- /dev/null +++ b/worker/src/middleware/validateEtherscan.ts @@ -0,0 +1,24 @@ +import type { Context, Next } from "hono"; +import type { EtherscanVerifyRequestBody, Env } from "../types"; + +const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/; + +export async function validateEtherscanMiddleware(c: Context<{ Bindings: Env }>, next: Next) { + let body: EtherscanVerifyRequestBody; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid JSON" }, 400); + } + + if (typeof body.chainId !== "number" || !Number.isInteger(body.chainId) || body.chainId <= 0) { + return c.json({ error: "chainId must be a positive integer" }, 400); + } + + if (typeof body.address !== "string" || !ADDRESS_RE.test(body.address)) { + return c.json({ error: "address must be a valid 0x-prefixed Ethereum address" }, 400); + } + + c.set("validatedBody" as never, body as never); + await next(); +} diff --git a/worker/src/routes/etherscanVerify.ts b/worker/src/routes/etherscanVerify.ts new file mode 100644 index 00000000..23e4111b --- /dev/null +++ b/worker/src/routes/etherscanVerify.ts @@ -0,0 +1,35 @@ +import type { Context } from "hono"; +import type { EtherscanVerifyRequestBody, Env } from "../types"; + +const ETHERSCAN_V2_API = "https://api.etherscan.io/v2/api"; + +export async function etherscanVerifyHandler(c: Context<{ Bindings: Env }>) { + const body = c.get("validatedBody" as never) as unknown as EtherscanVerifyRequestBody; + + const params = new URLSearchParams({ + chainid: String(body.chainId), + module: "contract", + action: "getsourcecode", + address: body.address, + apikey: c.env.ETHERSCAN_API_KEY, + }); + + try { + const response = await fetch(`${ETHERSCAN_V2_API}?${params.toString()}`); + + if (!response.ok) { + const status = response.status; + if (status === 429) { + const retryAfter = response.headers.get("Retry-After"); + if (retryAfter) c.header("Retry-After", retryAfter); + return c.json({ error: "Etherscan rate limit exceeded" }, 429); + } + return c.json({ error: `Etherscan API error (HTTP ${status})` }, 502); + } + + const data = await response.json(); + return c.json(data); + } catch { + return c.json({ error: "Failed to connect to Etherscan API" }, 502); + } +} diff --git a/worker/src/types.ts b/worker/src/types.ts index 4ac5db72..bcd82948 100644 --- a/worker/src/types.ts +++ b/worker/src/types.ts @@ -15,8 +15,14 @@ export interface AnalyzeRequestBody { messages: Array<{ role: "system" | "user"; content: string }>; } +export interface EtherscanVerifyRequestBody { + chainId: number; + address: string; +} + export interface Env { GROQ_API_KEY: string; + ETHERSCAN_API_KEY: string; ALLOWED_ORIGINS: string; GROQ_MODEL: string; } diff --git a/worker/wrangler.toml b/worker/wrangler.toml index 70cf84c7..076fb635 100644 --- a/worker/wrangler.toml +++ b/worker/wrangler.toml @@ -3,8 +3,10 @@ main = "src/index.ts" compatibility_date = "2024-12-01" [vars] -ALLOWED_ORIGINS = "http://openscan.eth.link,https://openscan-explorer.github.io,https://openscan.netlify.app,*--openscan.netlify.app" +ALLOWED_ORIGINS = "https://openscan.eth.link,https://openscan.eth.limo,https://openscan-explorer.github.io,https://openscan.netlify.app,*--openscan.netlify.app" GROQ_MODEL = "groq/compound" -# Secret: GROQ_API_KEY — set via `wrangler secret put GROQ_API_KEY` +# Secrets — set via `wrangler secret put ` +# GROQ_API_KEY — Groq AI API key for /ai/analyze +# ETHERSCAN_API_KEY — Etherscan V2 API key for /etherscan/verify From a27369ee7d9fa51ed2e33976b789dc596f6915fa Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 19 Mar 2026 16:11:21 -0300 Subject: [PATCH 19/54] fix(verification): always run Etherscan lookup via worker proxy Remove hasEtherscanKey gate from useContractVerification so that Etherscan verification runs for all users via the worker proxy, not just those with a configured API key. --- src/hooks/useContractVerification.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/hooks/useContractVerification.ts b/src/hooks/useContractVerification.ts index 3a4936b1..ad88d51e 100644 --- a/src/hooks/useContractVerification.ts +++ b/src/hooks/useContractVerification.ts @@ -1,4 +1,3 @@ -import { useSettings } from "../context/SettingsContext"; import { useEtherscan } from "./useEtherscan"; import type { SourcifyContractDetails } from "./useSourcify"; import { useSourcify } from "./useSourcify"; @@ -23,21 +22,18 @@ export function useContractVerification( address: string | undefined, enabled: boolean = true, ): ContractVerificationResult { - const { settings } = useSettings(); - const hasEtherscanKey = !!settings.apiKeys?.etherscan; - const { data: sourcifyData, loading: sourcifyLoading, isVerified: sourcifyVerified, } = useSourcify(networkId, address, enabled); - // Run Etherscan in parallel whenever a key is configured + // Run Etherscan in parallel (uses worker proxy when no user key is configured) const { data: etherscanData, loading: etherscanLoading, isVerified: etherscanVerified, - } = useEtherscan(networkId, address, enabled && hasEtherscanKey); + } = useEtherscan(networkId, address, enabled); const loading = sourcifyLoading || etherscanLoading; const source: VerificationSource = [ From 9f840888fbf284a8a512b1baf3ba5268cac6ac48 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 19 Mar 2026 16:28:13 -0300 Subject: [PATCH 20/54] feat(address): add Vyper smart contract support and syntax highlighting Detect Vyper contracts from Etherscan's CompilerVersion (vyper: prefix) and Sourcify's compilation.language field. Use correct .vy file extension instead of hardcoded .sol. Add CodeBlock component with Prism.js for syntax highlighting of Solidity (.sol) and Vyper (.vy) source files. Test with CRV token (Vyper, verified on Sourcify): 0xd533a949740bb3306d119cc777fa900ba034cd52 --- bun.lock | 6 ++ package.json | 10 ++-- src/App.tsx | 1 + src/components/common/CodeBlock.tsx | 46 +++++++++++++++ .../evm/address/shared/ContractDetails.tsx | 3 +- .../evm/address/shared/ContractInfoCard.tsx | 3 +- src/hooks/useEtherscan.ts | 14 ++++- src/styles/code-highlight.css | 59 +++++++++++++++++++ 8 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 src/components/common/CodeBlock.tsx create mode 100644 src/styles/code-highlight.css diff --git a/bun.lock b/bun.lock index 780aa3d3..a597effb 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "i18next": "^25.8.0", "i18next-browser-languagedetector": "^8.2.0", "jszip": "^3.10.1", + "prismjs": "^1.30.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-i18next": "^16.5.4", @@ -29,6 +30,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^13.5.0", + "@types/prismjs": "^1.26.6", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^5.1.2", @@ -542,6 +544,8 @@ "@types/node": ["@types/node@20.19.30", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g=="], + "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], + "@types/react": ["@types/react@19.2.8", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -1404,6 +1408,8 @@ "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], "process-warning": ["process-warning@1.0.0", "", {}, "sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q=="], diff --git a/package.json b/package.json index c68207de..2610f7ab 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "i18next": "^25.8.0", "i18next-browser-languagedetector": "^8.2.0", "jszip": "^3.10.1", + "prismjs": "^1.30.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-i18next": "^16.5.4", @@ -68,16 +69,17 @@ "devDependencies": { "@biomejs/biome": "^2.3.11", "@playwright/test": "^1.57.0", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", + "@testing-library/user-event": "^13.5.0", + "@types/prismjs": "^1.26.6", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^5.1.2", "dotenv": "^17.2.3", "happy-dom": "^20.1.0", "vite": "^7.3.1", - "@testing-library/dom": "^10.4.0", - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^16.2.0", - "@testing-library/user-event": "^13.5.0", "vitest": "^4.0.14" } } diff --git a/src/App.tsx b/src/App.tsx index d09f0a50..955266da 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import "./styles/rainbowkit.css"; import "./styles/responsive.css"; import "./styles/ai-analysis.css"; import "./styles/rpcs.css"; +import "./styles/code-highlight.css"; import Loading from "./components/common/Loading"; import { diff --git a/src/components/common/CodeBlock.tsx b/src/components/common/CodeBlock.tsx new file mode 100644 index 00000000..4af973a0 --- /dev/null +++ b/src/components/common/CodeBlock.tsx @@ -0,0 +1,46 @@ +import { useMemo } from "react"; +import Prism from "prismjs"; +import "prismjs/components/prism-python"; +import "prismjs/components/prism-solidity"; + +function detectLanguage(fileName?: string): string | undefined { + if (!fileName) return undefined; + const ext = fileName.split(".").pop()?.toLowerCase(); + if (ext === "sol") return "solidity"; + if (ext === "vy") return "python"; + if (ext === "json") return "json"; + return undefined; +} + +interface CodeBlockProps { + code: string; + fileName?: string; + language?: string; +} + +const CodeBlock: React.FC = ({ code, fileName, language }) => { + const lang = language ?? detectLanguage(fileName); + const grammar = lang ? Prism.languages[lang] : undefined; + + const highlighted = useMemo(() => { + if (!grammar || !lang) return null; + return Prism.highlight(code, grammar, lang); + }, [code, grammar, lang]); + + if (highlighted) { + return ( +
    +        {/* biome-ignore lint/security/noDangerouslySetInnerHtml: Prism.highlight output is safe — it only tokenizes source code we control */}
    +        
    +      
    + ); + } + + return ( +
    +      {code}
    +    
    + ); +}; + +export default CodeBlock; diff --git a/src/components/pages/evm/address/shared/ContractDetails.tsx b/src/components/pages/evm/address/shared/ContractDetails.tsx index c82de6f8..40d9dc6e 100644 --- a/src/components/pages/evm/address/shared/ContractDetails.tsx +++ b/src/components/pages/evm/address/shared/ContractDetails.tsx @@ -2,6 +2,7 @@ import type React from "react"; import { useState } from "react"; import type { ABI } from "../../../../../types"; import { useTranslation } from "react-i18next"; +import CodeBlock from "../../../../common/CodeBlock"; import ContractInteraction from "./ContractInteraction"; interface ContractData { @@ -192,7 +193,7 @@ const ContractDetails: React.FC = ({ {sourceFiles.map((file) => (
    📄 {file.name || file.path}
    -
    {file.content}
    +
    ))}
    diff --git a/src/components/pages/evm/address/shared/ContractInfoCard.tsx b/src/components/pages/evm/address/shared/ContractInfoCard.tsx index d131e920..7eb53433 100644 --- a/src/components/pages/evm/address/shared/ContractInfoCard.tsx +++ b/src/components/pages/evm/address/shared/ContractInfoCard.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { Link } from "react-router-dom"; import type { Address, ABI } from "../../../../../types"; import { useTranslation } from "react-i18next"; +import CodeBlock from "../../../../common/CodeBlock"; import ContractInteraction from "./ContractInteraction"; import type { VerificationSource } from "../../../../../hooks/useContractVerification"; import type { SourcifyContractDetails } from "../../../../../hooks/useSourcify"; @@ -341,7 +342,7 @@ const ContractInfoCard: React.FC = ({ {sourceFiles.map((file) => (
    📄 {file.name || file.path}
    -
    {file.content}
    +
    ))}
    diff --git a/src/hooks/useEtherscan.ts b/src/hooks/useEtherscan.ts index fc8a51c3..09d2d967 100644 --- a/src/hooks/useEtherscan.ts +++ b/src/hooks/useEtherscan.ts @@ -35,6 +35,7 @@ interface StandardJsonSources { function parseSourceFiles( sourceCode: string, contractName: string, + compilerVersion?: string, ): { name: string; path: string; content: string }[] { if (!sourceCode) return []; @@ -58,8 +59,9 @@ function parseSourceFiles( } } - // Plain Solidity source - const fileName = `${contractName || "Contract"}.sol`; + // Detect language from compiler version — Vyper compilers start with "vyper:" + const ext = compilerVersion?.toLowerCase().startsWith("vyper:") ? ".vy" : ".sol"; + const fileName = `${contractName || "Contract"}${ext}`; return [{ name: fileName, path: fileName, content: sourceCode }]; } @@ -141,14 +143,20 @@ export function useEtherscan( abi = undefined; } - const files = parseSourceFiles(result.SourceCode, result.ContractName); + const files = parseSourceFiles( + result.SourceCode, + result.ContractName, + result.CompilerVersion, + ); const evmVersion = result.EVMVersion && result.EVMVersion !== "Default" ? result.EVMVersion : undefined; + const isVyper = result.CompilerVersion?.toLowerCase().startsWith("vyper:"); const contractDetails: SourcifyContractDetails = { name: result.ContractName || undefined, compilerVersion: result.CompilerVersion || undefined, evmVersion, + language: isVyper ? "Vyper" : "Solidity", abi, files, match: "perfect", diff --git a/src/styles/code-highlight.css b/src/styles/code-highlight.css new file mode 100644 index 00000000..09b11cc0 --- /dev/null +++ b/src/styles/code-highlight.css @@ -0,0 +1,59 @@ +/* Prism.js syntax highlighting theme for OpenScan */ +.source-file-code code[class*="language-"] { + text-shadow: none; +} + +.source-file-code .token.comment, +.source-file-code .token.prolog, +.source-file-code .token.doctype, +.source-file-code .token.cdata { + color: #6a9955; +} + +.source-file-code .token.punctuation { + color: #d4d4d4; +} + +.source-file-code .token.property, +.source-file-code .token.tag, +.source-file-code .token.boolean, +.source-file-code .token.number, +.source-file-code .token.constant, +.source-file-code .token.symbol { + color: #b5cea8; +} + +.source-file-code .token.selector, +.source-file-code .token.attr-name, +.source-file-code .token.string, +.source-file-code .token.char, +.source-file-code .token.builtin { + color: #ce9178; +} + +.source-file-code .token.operator, +.source-file-code .token.entity, +.source-file-code .token.url { + color: #d4d4d4; +} + +.source-file-code .token.atrule, +.source-file-code .token.attr-value, +.source-file-code .token.keyword { + color: #569cd6; +} + +.source-file-code .token.function, +.source-file-code .token.class-name { + color: #dcdcaa; +} + +.source-file-code .token.regex, +.source-file-code .token.important, +.source-file-code .token.variable { + color: #d16969; +} + +.source-file-code .token.decorator { + color: #dcdcaa; +} From a1ce7818bf0b5fc3af3a9f890d778c7862e6f9d1 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 19 Mar 2026 17:14:11 -0300 Subject: [PATCH 21/54] feat(blocks/tx): display raw blob data content via Beacon API Add EIP-4844 blob data display by fetching from the Beacon Chain API. Super user mode only. Includes BeaconService for blob sidecar fetching, KZG commitment to versioned hash matching via Web Crypto SHA-256, configurable Beacon API endpoints in Settings, and blob viewer with hex/UTF-8 toggle. Block page shows all blobs in a collapsible section, transaction page adds a Blob Data tab in the TX Analyser. Default public Beacon API endpoints provided for Ethereum and Sepolia. Blob pruning (~18 days) handled gracefully with clear user messaging. i18n translations added for all 5 supported languages. Closes #301 --- src/components/common/BlobDataDisplay.tsx | 105 ++++++++++++++++++ .../pages/evm/block/BlockDisplay.tsx | 45 ++++++++ .../pages/evm/tx/TransactionDisplay.tsx | 2 + src/components/pages/evm/tx/TxAnalyser.tsx | 39 ++++++- src/components/pages/evm/tx/analyser/types.ts | 10 +- src/components/pages/settings/index.tsx | 31 ++++++ src/config/beaconConfig.ts | 44 ++++++++ src/hooks/useBeaconBlobs.ts | 93 ++++++++++++++++ src/locales/en/block.json | 6 + src/locales/en/settings.json | 6 + src/locales/en/transaction.json | 14 +++ src/locales/es/block.json | 6 + src/locales/es/settings.json | 6 + src/locales/es/transaction.json | 14 +++ src/locales/ja/block.json | 6 + src/locales/ja/settings.json | 6 + src/locales/ja/transaction.json | 14 +++ src/locales/pt-BR/block.json | 6 + src/locales/pt-BR/settings.json | 6 + src/locales/pt-BR/transaction.json | 14 +++ src/locales/zh/block.json | 6 + src/locales/zh/settings.json | 6 + src/locales/zh/transaction.json | 14 +++ src/services/BeaconService.ts | 92 +++++++++++++++ src/styles/components.css | 92 +++++++++++++++ src/types/index.ts | 14 +++ 26 files changed, 695 insertions(+), 2 deletions(-) create mode 100644 src/components/common/BlobDataDisplay.tsx create mode 100644 src/config/beaconConfig.ts create mode 100644 src/hooks/useBeaconBlobs.ts create mode 100644 src/services/BeaconService.ts diff --git a/src/components/common/BlobDataDisplay.tsx b/src/components/common/BlobDataDisplay.tsx new file mode 100644 index 00000000..fe7a09b5 --- /dev/null +++ b/src/components/common/BlobDataDisplay.tsx @@ -0,0 +1,105 @@ +import React, { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { BlobSidecar } from "../../types"; +import CopyButton from "./CopyButton"; +import LongString from "./LongString"; + +interface BlobDataDisplayProps { + blob: BlobSidecar; + index: number; +} + +const BlobDataDisplay: React.FC = React.memo(({ blob, index }) => { + const { t } = useTranslation("transaction"); + const [showUtf8, setShowUtf8] = useState(false); + const [expanded, setExpanded] = useState(false); + + const effectiveSize = useMemo(() => { + const hex = blob.blob.startsWith("0x") ? blob.blob.slice(2) : blob.blob; + let end = hex.length; + while (end > 0 && hex[end - 1] === "0" && hex[end - 2] === "0") { + end -= 2; + } + return end / 2; + }, [blob.blob]); + + const utf8Content = useMemo(() => { + if (!showUtf8) return null; + try { + const hex = blob.blob.startsWith("0x") ? blob.blob.slice(2) : blob.blob; + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + let end = bytes.length; + while (end > 0 && bytes[end - 1] === 0) end--; + return new TextDecoder("utf-8", { fatal: false }).decode(bytes.slice(0, end)); + } catch { + return null; + } + }, [blob.blob, showUtf8]); + + const displayHex = expanded + ? blob.blob + : `${blob.blob.slice(0, 200)}${blob.blob.length > 200 ? "..." : ""}`; + + return ( +
    +
    + {t("blobData.blobIndex", { index })} + + {t("blobData.effectiveSize", { bytes: effectiveSize.toLocaleString() })} + +
    + +
    +
    + {t("blobData.kzgCommitment")} + + + + +
    +
    + {t("blobData.kzgProof")} + + + + +
    +
    + +
    +
    + + + + +
    + + {showUtf8 && utf8Content ? ( +
    {utf8Content}
    + ) : ( +
    {displayHex}
    + )} +
    +
    + ); +}); + +BlobDataDisplay.displayName = "BlobDataDisplay"; +export default BlobDataDisplay; diff --git a/src/components/pages/evm/block/BlockDisplay.tsx b/src/components/pages/evm/block/BlockDisplay.tsx index c8de7882..be6e09c6 100644 --- a/src/components/pages/evm/block/BlockDisplay.tsx +++ b/src/components/pages/evm/block/BlockDisplay.tsx @@ -2,8 +2,11 @@ import React, { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { getNetworkById } from "../../../../config/networks"; +import { useSettings } from "../../../../context/SettingsContext"; +import { useBeaconBlobs } from "../../../../hooks/useBeaconBlobs"; import type { Block, BlockArbitrum, RPCMetadata } from "../../../../types"; import AIAnalysisPanel from "../../../common/AIAnalysis/AIAnalysisPanel"; +import BlobDataDisplay from "../../../common/BlobDataDisplay"; import ExtraDataDisplay from "../../../common/ExtraDataDisplay"; import LongString from "../../../common/LongString"; import { RPCIndicator } from "../../../common/RPCIndicator"; @@ -20,12 +23,26 @@ interface BlockDisplayProps { const BlockDisplay: React.FC = React.memo( ({ block, networkId, metadata, selectedProvider, onProviderSelect }) => { const { t } = useTranslation("block"); + const { isSuperUser } = useSettings(); const network = networkId ? getNetworkById(networkId) : undefined; const networkName = network?.name ?? "Unknown Network"; const networkCurrency = network?.currency ?? "ETH"; const [showWithdrawals, setShowWithdrawals] = useState(false); const [showTransactions, setShowTransactions] = useState(false); const [showMoreDetails, setShowMoreDetails] = useState(false); + const [showBlobData, setShowBlobData] = useState(false); + + const hasBlobGas = block.blobGasUsed && Number(block.blobGasUsed) > 0; + const caip2NetworkId = networkId ? `eip155:${networkId}` : undefined; + const { + blobs: blobSidecars, + loading: blobsLoading, + isPruned: blobsPruned, + isAvailable: beaconAvailable, + } = useBeaconBlobs( + isSuperUser && hasBlobGas ? caip2NetworkId : undefined, + isSuperUser && hasBlobGas && showBlobData ? Number(block.timestamp) : undefined, + ); // Check if this is an Arbitrum block const isArbitrumBlock = (block: Block | BlockArbitrum): block is BlockArbitrum => { @@ -498,6 +515,34 @@ const BlockDisplay: React.FC = React.memo( )} )} + {/* Blob Data (Super User only) */} + {isSuperUser && hasBlobGas && beaconAvailable && ( +
    +
    + {/** biome-ignore lint/a11y/useButtonType: */} + +
    + {showBlobData && ( +
    + {blobsLoading &&

    {t("blobData.loading")}

    } + {blobsPruned &&

    {t("blobData.pruned")}

    } + {blobSidecars?.map((blob) => ( + + ))} +
    + )} +
    + )} = React.memo( inputData={transaction.data} decodedInputData={decodedInput} isSuperUser={isSuperUser} + blobVersionedHashes={transaction.blobVersionedHashes} + blockTimestamp={transaction.timestamp ? Number(transaction.timestamp) : undefined} /> )} diff --git a/src/components/pages/evm/tx/TxAnalyser.tsx b/src/components/pages/evm/tx/TxAnalyser.tsx index ff570233..f8c9f587 100644 --- a/src/components/pages/evm/tx/TxAnalyser.tsx +++ b/src/components/pages/evm/tx/TxAnalyser.tsx @@ -2,10 +2,12 @@ import type React from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import type { CallNode, PrestateTrace } from "../../../../services/adapters/NetworkAdapter"; +import { useBeaconBlobs } from "../../../../hooks/useBeaconBlobs"; import { useCallTreeEnrichment } from "../../../../hooks/useCallTreeEnrichment"; import { type ContractInfo, fetchContractInfoBatch } from "../../../../utils/contractLookup"; import { useSettings } from "../../../../context/SettingsContext"; import { logger } from "../../../../utils/logger"; +import BlobDataDisplay from "../../../common/BlobDataDisplay"; import type { AnalyserTab, TxAnalyserProps } from "./analyser/types"; import CallTreeTab from "./analyser/CallTreeTab"; import StateChangesTab from "./analyser/StateChangesTab"; @@ -24,20 +26,36 @@ const TxAnalyser: React.FC = ({ inputData, decodedInputData, isSuperUser, + blobVersionedHashes, + blockTimestamp, }) => { const { t } = useTranslation("transaction"); const hasEvents = logs && logs.length > 0; const hasInputData = inputData && inputData !== "0x"; + const hasBlobData = blobVersionedHashes && blobVersionedHashes.length > 0; const defaultTab: AnalyserTab = hasEvents ? "events" : hasInputData ? "inputData" : "callTree"; const [activeTab, setActiveTab] = useState(defaultTab); + // Beacon blob data + const caip2NetworkId = networkId ? `eip155:${networkId}` : undefined; + const { + blobs: blobSidecars, + loading: blobsLoading, + isPruned: blobsPruned, + isAvailable: beaconAvailable, + } = useBeaconBlobs( + isSuperUser && hasBlobData ? caip2NetworkId : undefined, + isSuperUser && hasBlobData && activeTab === "blobData" ? blockTimestamp : undefined, + blobVersionedHashes, + ); + // Reset to a base tab when leaving super user mode // biome-ignore lint/correctness/useExhaustiveDependencies: only react to isSuperUser changes useEffect(() => { if (isSuperUser) { setCollapsed(false); } else { - const superTabs: AnalyserTab[] = ["callTree", "gasProfiler", "stateChanges"]; + const superTabs: AnalyserTab[] = ["callTree", "gasProfiler", "stateChanges", "blobData"]; setActiveTab((prev) => (superTabs.includes(prev) ? defaultTab : prev)); } }, [isSuperUser]); @@ -228,6 +246,15 @@ const TxAnalyser: React.FC = ({ > {t("analyser.stateChanges")} + {hasBlobData && beaconAvailable && ( + + )} )} + {hasTxs && ( + + )} + {hasWithdrawals && ( + + )} + {showBlobTab && ( + + )} + + + + {/* Tab content */} + {!collapsed && ( +
    + {activeTab === "moreDetails" && ( +
    +
    + {t("analyser.parentHash")} + + {networkId && + block.parentHash !== + "0x0000000000000000000000000000000000000000000000000000000000000000" ? ( + + {block.parentHash} + + ) : ( + block.parentHash + )} + +
    +
    + {t("analyser.stateRoot")} + {block.stateRoot} +
    +
    + {t("analyser.transactionsRoot")} + {block.transactionsRoot} +
    +
    + {t("analyser.receiptsRoot")} + {block.receiptsRoot} +
    + {block.withdrawalsRoot && ( +
    + {t("analyser.withdrawalsRoot")} + {block.withdrawalsRoot} +
    + )} +
    + {t("analyser.logsBloom")} +
    + {block.logsBloom} +
    +
    +
    + {t("analyser.nonce")} + {block.nonce} +
    +
    + {t("analyser.mixHash")} + {block.mixHash} +
    +
    + {t("analyser.sha3Uncles")} + {block.sha3Uncles} +
    +
    + )} + + {activeTab === "transactions" && hasTxs && ( +
    + {block.transactions.map((txHash, index) => ( +
    + {index} + + {networkId ? ( + + {txHash} + + ) : ( + txHash + )} + +
    + ))} +
    + )} + + {activeTab === "withdrawals" && hasWithdrawals && ( +
    + {block.withdrawals.map((withdrawal, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: withdrawal index is stable +
    +
    {index}
    +
    + {t("index")} + {Number(withdrawal.index).toLocaleString()} +
    +
    + {t("validator")} + {Number(withdrawal.validatorIndex).toLocaleString()} +
    +
    + {networkId ? ( + + {withdrawal.address} + + ) : ( + withdrawal.address + )} +
    +
    + {(Number(withdrawal.amount) / 1e9).toFixed(9)} ETH +
    +
    + ))} +
    + )} + + {activeTab === "blobData" && showBlobTab && ( +
    + {blobsLoading &&
    {t("blobData.loading")}
    } + {blobsPruned &&
    {t("blobData.pruned")}
    } + {blobSidecars?.map((blob) => ( + + ))} +
    + )} +
    + )} + + ); +}; + +export default BlockAnalyser; diff --git a/src/components/pages/evm/block/BlockDisplay.tsx b/src/components/pages/evm/block/BlockDisplay.tsx index be6e09c6..61ae6fd2 100644 --- a/src/components/pages/evm/block/BlockDisplay.tsx +++ b/src/components/pages/evm/block/BlockDisplay.tsx @@ -1,16 +1,15 @@ -import React, { useMemo, useState } from "react"; +import React, { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { getNetworkById } from "../../../../config/networks"; import { useSettings } from "../../../../context/SettingsContext"; -import { useBeaconBlobs } from "../../../../hooks/useBeaconBlobs"; import type { Block, BlockArbitrum, RPCMetadata } from "../../../../types"; import AIAnalysisPanel from "../../../common/AIAnalysis/AIAnalysisPanel"; -import BlobDataDisplay from "../../../common/BlobDataDisplay"; import ExtraDataDisplay from "../../../common/ExtraDataDisplay"; import LongString from "../../../common/LongString"; import { RPCIndicator } from "../../../common/RPCIndicator"; import { formatGweiFromWei, formatNativeFromWei } from "../../../../utils/unitFormatters"; +import BlockAnalyser from "./BlockAnalyser"; interface BlockDisplayProps { block: Block | BlockArbitrum; @@ -27,22 +26,6 @@ const BlockDisplay: React.FC = React.memo( const network = networkId ? getNetworkById(networkId) : undefined; const networkName = network?.name ?? "Unknown Network"; const networkCurrency = network?.currency ?? "ETH"; - const [showWithdrawals, setShowWithdrawals] = useState(false); - const [showTransactions, setShowTransactions] = useState(false); - const [showMoreDetails, setShowMoreDetails] = useState(false); - const [showBlobData, setShowBlobData] = useState(false); - - const hasBlobGas = block.blobGasUsed && Number(block.blobGasUsed) > 0; - const caip2NetworkId = networkId ? `eip155:${networkId}` : undefined; - const { - blobs: blobSidecars, - loading: blobsLoading, - isPruned: blobsPruned, - isAvailable: beaconAvailable, - } = useBeaconBlobs( - isSuperUser && hasBlobGas ? caip2NetworkId : undefined, - isSuperUser && hasBlobGas && showBlobData ? Number(block.timestamp) : undefined, - ); // Check if this is an Arbitrum block const isArbitrumBlock = (block: Block | BlockArbitrum): block is BlockArbitrum => { @@ -351,198 +334,9 @@ const BlockDisplay: React.FC = React.memo( )} - - {/* Full-width: More Details (collapsible) */} -
    - {/** biome-ignore lint/a11y/useButtonType: */} - - - {showMoreDetails && ( -
    -
    - Parent Hash: - - {networkId && - block.parentHash !== - "0x0000000000000000000000000000000000000000000000000000000000000000" ? ( - - {block.parentHash} - - ) : ( - block.parentHash - )} - -
    -
    - State Root: - {block.stateRoot} -
    -
    - Transactions Root: - {block.transactionsRoot} -
    -
    - Receipts Root: - {block.receiptsRoot} -
    - {block.withdrawalsRoot && ( -
    - Withdrawals Root: - {block.withdrawalsRoot} -
    - )} -
    - Logs Bloom: -
    - {block.logsBloom} -
    -
    -
    - Nonce: - {block.nonce} -
    -
    - Mix Hash: - {block.mixHash} -
    -
    - Sha3 Uncles: - {block.sha3Uncles} -
    -
    - )} -
    - {/* Transactions List */} - {block.transactions && block.transactions.length > 0 && ( -
    -
    - {/** biome-ignore lint/a11y/useButtonType: */} - -
    - {showTransactions && ( -
    - {block.transactions.map((txHash, index) => ( -
    - {index} - - {networkId ? ( - - {txHash} - - ) : ( - txHash - )} - -
    - ))} -
    - )} -
    - )} - - {/* Withdrawals List */} - {block.withdrawals && block.withdrawals.length > 0 && ( -
    -
    - {/** biome-ignore lint/a11y/useButtonType: */} - -
    - {showWithdrawals && ( -
    - {block.withdrawals.map((withdrawal, index) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: -
    -
    {index}
    -
    -
    - {t("index")} - - {Number(withdrawal.index).toLocaleString()} - -
    -
    - {t("validator")} - - {Number(withdrawal.validatorIndex).toLocaleString()} - -
    -
    - {t("address")} - - {networkId ? ( - - {withdrawal.address} - - ) : ( - withdrawal.address - )} - -
    -
    - {t("amount")} - - {(Number(withdrawal.amount) / 1e9).toFixed(9)} ETH - -
    -
    -
    - ))} -
    - )} -
    - )} - {/* Blob Data (Super User only) */} - {isSuperUser && hasBlobGas && beaconAvailable && ( -
    -
    - {/** biome-ignore lint/a11y/useButtonType: */} - -
    - {showBlobData && ( -
    - {blobsLoading &&

    {t("blobData.loading")}

    } - {blobsPruned &&

    {t("blobData.pruned")}

    } - {blobSidecars?.map((blob) => ( - - ))} -
    - )} -
    - )} + = ({ ); return ( -
    +
    {/* Tab bar */} -
    +
    {hasEvents && ( - diff --git a/src/components/pages/evm/tx/analyser/GasProfilerTab.tsx b/src/components/pages/evm/tx/analyser/GasProfilerTab.tsx index 73db3e88..252b0762 100644 --- a/src/components/pages/evm/tx/analyser/GasProfilerTab.tsx +++ b/src/components/pages/evm/tx/analyser/GasProfilerTab.tsx @@ -154,8 +154,8 @@ const GasProfilerTab: React.FC<{ : []; return ( -
    -
    +
    +
    {t("analyser.summaryGas", { gas: totalGas.toLocaleString() })} {isZoomed && ( - diff --git a/src/locales/en/block.json b/src/locales/en/block.json index e70450aa..bd5bcba6 100644 --- a/src/locales/en/block.json +++ b/src/locales/en/block.json @@ -66,6 +66,23 @@ "minute": "minute", "minute_other": "minutes" }, + "analyser": { + "moreDetails": "More Details", + "transactions": "Transactions", + "withdrawals": "Withdrawals", + "blobData": "Blob Data", + "expand": "Expand", + "collapse": "Collapse", + "parentHash": "Parent Hash:", + "stateRoot": "State Root:", + "transactionsRoot": "Transactions Root:", + "receiptsRoot": "Receipts Root:", + "withdrawalsRoot": "Withdrawals Root:", + "logsBloom": "Logs Bloom:", + "nonce": "Nonce:", + "mixHash": "Mix Hash:", + "sha3Uncles": "Sha3 Uncles:" + }, "aiAnalysis": { "sectionTitle": "AI Analysis" }, diff --git a/src/locales/es/block.json b/src/locales/es/block.json index b4de2273..4a1fd4cf 100644 --- a/src/locales/es/block.json +++ b/src/locales/es/block.json @@ -66,6 +66,23 @@ "minute": "minuto", "minute_other": "minutos" }, + "analyser": { + "moreDetails": "Más detalles", + "transactions": "Transacciones", + "withdrawals": "Retiros", + "blobData": "Datos de Blobs", + "expand": "Expandir", + "collapse": "Colapsar", + "parentHash": "Hash padre:", + "stateRoot": "Raíz de estado:", + "transactionsRoot": "Raíz de transacciones:", + "receiptsRoot": "Raíz de recibos:", + "withdrawalsRoot": "Raíz de retiros:", + "logsBloom": "Logs Bloom:", + "nonce": "Nonce:", + "mixHash": "Mix Hash:", + "sha3Uncles": "Sha3 Uncles:" + }, "aiAnalysis": { "sectionTitle": "Análisis IA" }, diff --git a/src/styles/components.css b/src/styles/components.css index 0273a4c5..a4f979b0 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -435,6 +435,75 @@ } } +/* Withdrawal list (block analyser) */ +.withdrawal-list { + display: flex; + flex-direction: column; +} + +.withdrawal-row { + display: flex; + align-items: center; + gap: 16px; + padding: 10px 16px; + border-bottom: 1px solid var(--color-primary-alpha-10); + font-size: 0.85rem; +} + +.withdrawal-row:last-child { + border-bottom: none; +} + +.withdrawal-index { + flex: 0 0 40px; + height: 28px; + background: var(--color-primary-alpha-10); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; + color: var(--text-secondary); +} + +.withdrawal-field { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.withdrawal-field-label { + font-size: 0.75rem; + color: var(--text-tertiary); + white-space: nowrap; +} + +.withdrawal-address { + flex: 1; + min-width: 0; + word-break: break-all; +} + +.withdrawal-amount { + flex: 0 0 auto; + font-weight: 600; + white-space: nowrap; +} + +@media (max-width: 768px) { + .withdrawal-row { + flex-wrap: wrap; + gap: 8px; + } + + .withdrawal-address { + flex-basis: 100%; + order: 10; + } +} + .contract-creation-badge { display: inline-block; padding: 2px 8px; @@ -1947,9 +2016,9 @@ button.tx-section-header-toggle { padding: 10px; } -/* ── TX Analyser ─────────────────────────────────────────────────────────── */ +/* ── Detail Panel (shared: TX Analyser, Block Analyser) ─────────────────── */ -.tx-analyser { +.detail-panel { margin-top: 16px; border: 1px solid var(--border-primary); border-radius: 10px; @@ -1957,14 +2026,14 @@ button.tx-section-header-toggle { background: var(--bg-secondary); } -.tx-analyser-tabs { +.detail-panel-tabs { display: flex; align-items: center; border-bottom: 1px solid var(--border-primary); background: var(--bg-tertiary); } -.tx-analyser-collapse-btn { +.detail-panel-collapse-btn { margin-left: auto; padding: 6px 12px; background: none; @@ -1974,11 +2043,11 @@ button.tx-section-header-toggle { font-size: 0.85rem; } -.tx-analyser-collapse-btn:hover { +.detail-panel-collapse-btn:hover { color: var(--text-primary); } -.tx-analyser-tab { +.detail-panel-tab { padding: 10px 18px; font-size: 0.875rem; font-weight: 500; @@ -1991,38 +2060,38 @@ button.tx-section-header-toggle { margin-bottom: -1px; } -.tx-analyser-tab:hover { +.detail-panel-tab:hover { color: var(--text-primary); } -.tx-analyser-tab--active { +.detail-panel-tab--active { color: var(--color-accent); border-bottom-color: var(--color-accent); } -.tx-analyser-tab--active-base { +.detail-panel-tab--active-base { color: var(--text-primary); border-bottom-color: var(--text-primary); } -.tx-analyser-body { +.detail-panel-body { padding: 16px; min-height: 80px; } -.analyser-tab-content { +.detail-panel-tab-content { display: flex; flex-direction: column; gap: 12px; } -.analyser-loading { +.detail-panel-loading { text-align: center; padding: 32px; color: var(--text-secondary); } -.analyser-error { +.detail-panel-error { padding: 16px; border-radius: 8px; background: rgba(239, 68, 68, 0.08); @@ -2031,20 +2100,20 @@ button.tx-section-header-toggle { font-size: 0.875rem; } -.analyser-hint { +.detail-panel-hint { margin-top: 8px; color: var(--text-secondary); font-size: 0.8rem; } -.analyser-empty { +.detail-panel-empty { text-align: center; padding: 32px; color: var(--text-tertiary); font-size: 0.875rem; } -.analyser-summary { +.detail-panel-summary { display: flex; gap: 16px; flex-wrap: wrap; @@ -2056,7 +2125,7 @@ button.tx-section-header-toggle { border: 1px solid var(--border-primary); } -.analyser-summary-reverts { +.detail-panel-summary-reverts { color: var(--color-error); } @@ -2190,13 +2259,13 @@ button.tx-section-header-toggle { background: var(--overlay-light-2); } -.analyser-expand-controls { +.detail-panel-expand-controls { margin-left: auto; display: flex; gap: 8px; } -.analyser-expand-btn { +.detail-panel-expand-btn { background: none; border: 1px solid var(--border-primary); border-radius: 4px; @@ -2206,7 +2275,7 @@ button.tx-section-header-toggle { cursor: pointer; } -.analyser-expand-btn:hover { +.detail-panel-expand-btn:hover { color: var(--text-primary); border-color: var(--text-tertiary); } @@ -2319,12 +2388,12 @@ button.tx-section-header-toggle { color: var(--text-secondary); } -.analyser-summary-type { +.detail-panel-summary-type { font-size: 0.8rem; font-weight: 500; } -.analyser-summary-loading { +.detail-panel-summary-loading { color: var(--text-tertiary); font-size: 0.8rem; font-style: italic; From 78436d19aa1da3d44e6c83eba069e0ae42e58dae Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 19 Mar 2026 17:39:28 -0300 Subject: [PATCH 23/54] merge: resolve conflicts with upstream dev Merge openscan-explorer/dev into feat/beacon-blob-data, incorporating helper tooltips, knowledge levels, raw trace tab, FieldLabel component, Hardhat adapter, and Etherscan worker proxy alongside our beacon blob and block analyser changes. --- .claude/rules/architecture.md | 10 +- .claude/rules/commands.md | 2 +- .claude/rules/patterns.md | 11 +- README.md | 27 +- bun.lock | 6 +- package.json | 2 +- scripts/run-test-env.sh | 2 +- src/App.tsx | 1 + src/components/common/FieldLabel.tsx | 35 ++ src/components/common/HelperTooltip.tsx | 279 ++++++++++++++ src/components/navbar/index.tsx | 85 ++++- .../pages/bitcoin/BitcoinAddressDisplay.tsx | 27 +- .../pages/bitcoin/BitcoinBlockDisplay.tsx | 125 ++++++- .../bitcoin/BitcoinTransactionDisplay.tsx | 172 ++++++--- .../address/shared/AccountMoreInfoCard.tsx | 15 +- .../address/shared/AccountOverviewCard.tsx | 29 +- .../evm/address/shared/AddressHeader.tsx | 11 +- .../evm/address/shared/ContractDetails.tsx | 19 +- .../evm/address/shared/ContractInfoCard.tsx | 22 +- .../address/shared/ContractInteraction.tsx | 17 +- .../address/shared/ContractMoreInfoCard.tsx | 15 +- .../evm/address/shared/ERC20TokenInfoCard.tsx | 15 +- .../address/shared/NFTCollectionInfoCard.tsx | 22 +- .../evm/address/shared/TransactionHistory.tsx | 55 ++- .../pages/evm/block/BlockDisplay.tsx | 116 ++++-- .../pages/evm/network/DashboardStats.tsx | 18 +- .../pages/evm/network/NetworkStatsDisplay.tsx | 31 +- .../evm/tokenDetails/ERC1155TokenDisplay.tsx | 13 +- .../evm/tokenDetails/ERC721TokenDisplay.tsx | 31 +- .../pages/evm/tx/TransactionDisplay.tsx | 295 +++++++-------- src/components/pages/evm/tx/TxAnalyser.tsx | 94 ++++- .../pages/evm/tx/analyser/RawTraceTab.tsx | 167 +++++++++ .../pages/evm/tx/analyser/StateChangesTab.tsx | 28 +- src/components/pages/evm/tx/analyser/types.ts | 1 + src/components/pages/settings/index.tsx | 72 ++++ src/config/networks.json | 52 +++ src/context/AppContext.tsx | 35 +- src/hooks/useContractVerification.ts | 8 +- src/hooks/useEtherscan.ts | 38 +- src/i18n.ts | 11 + src/i18next.d.ts | 2 + src/locales/en/common.json | 9 +- src/locales/en/settings.json | 15 + src/locales/en/tooltips.json | 161 +++++++++ src/locales/en/transaction.json | 7 +- src/locales/es/common.json | 9 +- src/locales/es/settings.json | 15 + src/locales/es/tooltips.json | 161 +++++++++ src/locales/es/transaction.json | 7 +- src/locales/ja/common.json | 9 +- src/locales/ja/settings.json | 15 + src/locales/ja/tooltips.json | 161 +++++++++ src/locales/ja/transaction.json | 7 +- src/locales/pt-BR/common.json | 9 +- src/locales/pt-BR/settings.json | 15 + src/locales/pt-BR/tooltips.json | 161 +++++++++ src/locales/pt-BR/transaction.json | 7 +- src/locales/zh/common.json | 9 +- src/locales/zh/settings.json | 15 + src/locales/zh/tooltips.json | 161 +++++++++ src/locales/zh/transaction.json | 7 +- .../adapters/BitcoinAdapter/BitcoinAdapter.ts | 16 +- .../adapters/EVMAdapter/EVMAdapter.ts | 5 +- .../adapters/HardhatAdapter/HardhatAdapter.ts | 339 ++++++++++++++++++ src/services/adapters/NetworkAdapter.ts | 5 +- src/services/adapters/adaptersFactory.ts | 13 +- src/styles/components.css | 182 ++++++++++ src/styles/helper-tooltip.css | 120 +++++++ src/styles/styles.css | 3 +- src/types/index.ts | 19 +- src/utils/contractLookup.ts | 82 +++-- src/utils/structLogConverter.ts | 314 ++++++++++++++++ worker/src/index.ts | 11 + worker/src/middleware/rateLimitEtherscan.ts | 49 +++ worker/src/middleware/validateEtherscan.ts | 24 ++ worker/src/routes/etherscanVerify.ts | 35 ++ worker/src/types.ts | 6 + worker/wrangler.toml | 6 +- 78 files changed, 3787 insertions(+), 418 deletions(-) create mode 100644 src/components/common/FieldLabel.tsx create mode 100644 src/components/common/HelperTooltip.tsx create mode 100644 src/components/pages/evm/tx/analyser/RawTraceTab.tsx create mode 100644 src/locales/en/tooltips.json create mode 100644 src/locales/es/tooltips.json create mode 100644 src/locales/ja/tooltips.json create mode 100644 src/locales/pt-BR/tooltips.json create mode 100644 src/locales/zh/tooltips.json create mode 100644 src/services/adapters/HardhatAdapter/HardhatAdapter.ts create mode 100644 src/styles/helper-tooltip.css create mode 100644 src/utils/structLogConverter.ts create mode 100644 worker/src/middleware/rateLimitEtherscan.ts create mode 100644 worker/src/middleware/validateEtherscan.ts create mode 100644 worker/src/routes/etherscanVerify.ts diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index cf1ade9f..706f0eef 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -25,7 +25,7 @@ Orchestrates data fetching with caching and metadata: - Instantiates network-specific fetchers/adapters based on chain ID - Returns `DataWithMetadata` when using parallel strategy - 30-second in-memory cache keyed by `networkId:type:identifier` -- Supports trace operations for localhost networks only +- Supports trace operations for Hardhat (31337) and localhost networks ### 5. Hook Layer (`hooks/`) React integration: @@ -42,10 +42,10 @@ Global state management: Chain ID detection in `DataService` constructor determines which adapters/fetchers to use: -- **Arbitrum** (42161): `BlockFetcherArbitrum`, `BlockArbitrumAdapter` - adds `l1BlockNumber`, `sendCount`, `requestId` -- **OP Stack** (10, 8453): Optimism (10), Base (8453) - adds L1 fee breakdown (`l1Fee`, `l1GasPrice`, `l1GasUsed`) -- **Localhost** (31337): All networks + trace support (`debug_traceTransaction`, `trace_block`, etc.) -- **Default**: L1 fetchers/adapters for Ethereum (1), BSC (56, 97), Polygon (137), Sepolia (11155111) +- **Arbitrum** (42161): `ArbitrumAdapter` - adds `l1BlockNumber`, `sendCount`, `requestId` +- **OP Stack** (10, 8453): `OptimismAdapter` (10), `BaseAdapter` (8453) - adds L1 fee breakdown (`l1Fee`, `l1GasPrice`, `l1GasUsed`) +- **Hardhat** (31337): `HardhatAdapter` - uses `HardhatClient` from `@openscan/network-connectors`; trace support via struct log conversion (`buildCallTreeFromStructLogs`, `buildPrestateFromStructLogs` in `src/utils/structLogConverter.ts`) since Hardhat v3 does not support `callTracer`/`prestateTracer` +- **Default**: `EVMAdapter` for Ethereum (1), BSC (56, 97), Polygon (137), Sepolia (11155111), Avalanche (43114) ## Key Type Definitions diff --git a/.claude/rules/commands.md b/.claude/rules/commands.md index b746d64d..d65e9750 100644 --- a/.claude/rules/commands.md +++ b/.claude/rules/commands.md @@ -93,4 +93,4 @@ REACT_APP_OPENSCAN_NETWORKS="1,31337" npm start npm start ``` -Supported chain IDs: 1 (Ethereum), 42161 (Arbitrum), 10 (Optimism), 8453 (Base), 56 (BSC), 137 (Polygon), 31337 (Localhost), 97 (BSC Testnet), 11155111 (Sepolia) +Supported chain IDs: 1 (Ethereum), 42161 (Arbitrum), 10 (Optimism), 8453 (Base), 56 (BSC), 137 (Polygon), 31337 (Hardhat), 97 (BSC Testnet), 11155111 (Sepolia), 43114 (Avalanche) diff --git a/.claude/rules/patterns.md b/.claude/rules/patterns.md index 8d9e0d11..cd5aec9d 100644 --- a/.claude/rules/patterns.md +++ b/.claude/rules/patterns.md @@ -32,10 +32,10 @@ 1. Add chain ID to `src/types/index.ts` if creating new domain types 2. Add default RPC endpoints to `src/config/rpcConfig.ts` -3. Determine if network needs custom fetchers/adapters (L1, Arbitrum-like, OP Stack-like) -4. If custom: create `src/services/EVM/[Network]/fetchers/` and `adapters/` -5. Update `DataService` constructor to detect chain ID and instantiate correct fetchers/adapters -6. Add network config to `ALL_NETWORKS` in `src/config/networks.ts` +3. Determine if network needs a custom adapter (L1, Arbitrum-like, OP Stack-like, Hardhat-like) +4. If custom: create `src/services/adapters/[Network]Adapter/[Network]Adapter.ts` +5. Register the adapter in `src/services/adapters/adaptersFactory.ts` with its chain ID +6. Add network config to `src/config/networks.json` 7. Add network logo to `public/` and update `logoType` in network config ## Testing with Local Networks @@ -43,7 +43,8 @@ OpenScan includes special support for localhost development: - **Hardhat 3 Ignition**: Import deployment artifacts via Settings → Import Ignition Deployment -- **Trace Support**: `debug_traceTransaction`, `debug_traceBlockByHash`, `debug_traceCall` available on localhost (31337) +- **Trace Support**: `debug_traceTransaction`, `debug_traceBlockByHash`, `debug_traceCall` available on Hardhat (31337) and localhost networks +- **Hardhat Trace Conversion**: Hardhat v3 only supports the default struct log tracer (not `callTracer`/`prestateTracer`). The `HardhatAdapter` uses `buildCallTreeFromStructLogs()` and `buildPrestateFromStructLogs()` from `src/utils/structLogConverter.ts` to convert opcode traces into call trees and state diffs - **Auto-detection**: Port 8545 automatically recognized as localhost network ## Component Patterns diff --git a/README.md b/README.md index db2dab28..b29d95e2 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,8 @@ A trustless, open-source blockchain explorer for Bitcoin, Ethereum, and Layer 2 - **BSC (BNB Chain)** - Binance Smart Chain mainnet - **BSC Testnet** - Binance Smart Chain testnet - **Polygon POS** - Polygon proof-of-stake mainnet -- **Localhost** - Local development networks (Hardhat/Anvil) +- **Hardhat** - Local development network (Chain ID 31337) with trace support +- **Localhost** - Local development networks (Anvil/other) ### 🔍 Core Functionality @@ -59,6 +60,13 @@ A trustless, open-source blockchain explorer for Bitcoin, Ethereum, and Layer 2 - **Multiple Fallback URLs** - Automatic failover to backup RPC providers - **Read/Write Operations** - Execute smart contract calls on verified smart contracts. +### 🔬 Hardhat Development Support + +- **Dedicated Adapter** - Full `HardhatAdapter` for chain ID 31337 with typed `HardhatClient` +- **Trace Methods** - Call Tree, Gas Profiler, and State Changes via struct log conversion (Hardhat does not support Geth's `callTracer`/`prestateTracer`, so opcode-level traces are converted) +- **HH3 Ignition** - Import Hardhat 3 Ignition deployment artifacts to inspect and interact with contracts +- **Auto-detection** - Port 8545 automatically recognized as Hardhat network + ### ⚡ Layer 2 Support - **Arbitrum-Specific Fields** - Display L1 block numbers, send counts, and request IDs @@ -276,13 +284,16 @@ src/ ├── context/ # React context providers ├── hooks/ # Custom React hooks ├── services/ # Blockchain data services -│ ├── adapters/ # General reusable adapters -│ │ └── BitcoinAdapter/ # Bitcoin network adapter -│ └── EVM/ # EVM-compatible chain adapters -│ ├── Arbitrum/ # Arbitrum-specific adapters -│ ├── common/ # EVM common resources -│ ├── L1/ # EVM L1 resources -│ └── Optimism/ # Optimism-specific adapters +│ ├── adapters/ # Network adapters +│ │ ├── BitcoinAdapter/ # Bitcoin network adapter +│ │ ├── HardhatAdapter/ # Hardhat local dev adapter (31337) +│ │ ├── EVMAdapter/ # Default EVM adapter (Ethereum, Sepolia, etc.) +│ │ ├── ArbitrumAdapter/ # Arbitrum-specific adapter +│ │ ├── OptimismAdapter/ # Optimism-specific adapter +│ │ ├── BaseAdapter/ # Base-specific adapter +│ │ ├── BNBAdapter/ # BNB Chain adapter +│ │ └── PolygonAdapter/ # Polygon adapter +│ └── EVM/ # EVM-compatible chain adapters (legacy) ├── types/ # TypeScript type definitions ├── utils/ # Utility functions └── styles/ # CSS stylesheets diff --git a/bun.lock b/bun.lock index 780aa3d3..b39d6261 100644 --- a/bun.lock +++ b/bun.lock @@ -1,12 +1,12 @@ { "lockfileVersion": 1, - "configVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "openscan", "dependencies": { "@erc7730/sdk": "^0.1.3", - "@openscan/network-connectors": "1.3.2", + "@openscan/network-connectors": "1.6.0", "@rainbow-me/rainbowkit": "^2.2.8", "@react-native-async-storage/async-storage": "^1.24.0", "@tanstack/react-query": "^5.90.21", @@ -280,7 +280,7 @@ "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], - "@openscan/network-connectors": ["@openscan/network-connectors@1.3.2", "", {}, "sha512-OdH+PqP/VNYkPrXBCaMhjNF2FQ5N5WH9wd9uGelgkCvbXXS0xXRC4PlPqWSSXqjZJUud0HAGDF5pbZfxIPFQnQ=="], + "@openscan/network-connectors": ["@openscan/network-connectors@1.6.0", "", {}, "sha512-f7Ox20+NIJ4DXzcbj0OyyuVjiHB0zjm1xv+yhzrYhh6TfW6bfHpq62+++oF6/5ud5VLptRXSkdaMrLcAOJL0vQ=="], "@paulmillr/qr": ["@paulmillr/qr@0.2.1", "", {}, "sha512-IHnV6A+zxU7XwmKFinmYjUcwlyK9+xkG3/s9KcQhI9BjQKycrJ1JRO+FbNYPwZiPKW3je/DR0k7w8/gLa5eaxQ=="], diff --git a/package.json b/package.json index c68207de..7441e9db 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ }, "dependencies": { "@erc7730/sdk": "^0.1.3", - "@openscan/network-connectors": "1.3.2", + "@openscan/network-connectors": "1.6.0", "@rainbow-me/rainbowkit": "^2.2.8", "@react-native-async-storage/async-storage": "^1.24.0", "@tanstack/react-query": "^5.90.21", diff --git a/scripts/run-test-env.sh b/scripts/run-test-env.sh index b5310575..46ebe8a9 100755 --- a/scripts/run-test-env.sh +++ b/scripts/run-test-env.sh @@ -86,7 +86,7 @@ echo "🔍 Starting OpenScan (Ethereum Mainnet + hardhat only)..." cd "$OPENSCAN_DIR" # Start OpenScan - it will read .env.local on start -REACT_APP_OPENSCAN_NETWORKS="1,31337" npm start & +REACT_APP_OPENSCAN_NETWORKS="31337" npm start & OPENSCAN_PID=$! # Wait for OpenScan to start diff --git a/src/App.tsx b/src/App.tsx index d09f0a50..3a70f196 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import "./styles/rainbowkit.css"; import "./styles/responsive.css"; import "./styles/ai-analysis.css"; import "./styles/rpcs.css"; +import "./styles/helper-tooltip.css"; import Loading from "./components/common/Loading"; import { diff --git a/src/components/common/FieldLabel.tsx b/src/components/common/FieldLabel.tsx new file mode 100644 index 00000000..1f01b48d --- /dev/null +++ b/src/components/common/FieldLabel.tsx @@ -0,0 +1,35 @@ +import { useTranslation } from "react-i18next"; +import type { KnowledgeLevel } from "../../types"; +import { useSettings } from "../../context/SettingsContext"; +import HelperTooltip from "./HelperTooltip"; + +interface FieldLabelProps { + label: string; + tooltipKey?: string; + visibleFor?: KnowledgeLevel[]; + className?: string; +} + +const FieldLabel: React.FC = ({ + label, + tooltipKey, + visibleFor, + className = "tx-label", +}) => { + const { settings } = useSettings(); + const { t } = useTranslation("tooltips"); + + const level = settings.knowledgeLevel ?? "beginner"; + const tooltipsEnabled = settings.showHelperTooltips !== false; + + const shouldShow = tooltipsEnabled && tooltipKey && (!visibleFor || visibleFor.includes(level)); + + return ( + + {label} + {shouldShow && } + + ); +}; + +export default FieldLabel; diff --git a/src/components/common/HelperTooltip.tsx b/src/components/common/HelperTooltip.tsx new file mode 100644 index 00000000..58dc1f7e --- /dev/null +++ b/src/components/common/HelperTooltip.tsx @@ -0,0 +1,279 @@ +import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +interface HelperTooltipProps { + content: string; + placement?: "top" | "bottom" | "left" | "right"; + className?: string; +} + +const HOVER_DELAY_MS = 350; + +const HelperTooltip: React.FC = ({ content, placement = "top", className }) => { + const [isVisible, setIsVisible] = useState(false); + const [actualPlacement, setActualPlacement] = useState(placement); + const [triggerRect, setTriggerRect] = useState(null); + const tooltipId = useId(); + const triggerRef = useRef(null); + const bubbleRef = useRef(null); + const arrowRef = useRef(null); + const hoverTimeoutRef = useRef | null>(null); + const isPointerInsideRef = useRef(false); + + const show = useCallback(() => { + if (triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect(); + // Only auto-flip top↔bottom; left/right stay as requested + if (placement === "top" || placement === "bottom") { + setActualPlacement(rect.top < 80 ? "bottom" : placement); + } else { + setActualPlacement(placement); + } + setTriggerRect(rect); + } + setIsVisible(true); + }, [placement]); + + const hide = useCallback(() => { + setIsVisible(false); + }, []); + + const clearHoverTimeout = useCallback(() => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + }, []); + + const handlePointerEnter = useCallback(() => { + isPointerInsideRef.current = true; + clearHoverTimeout(); + hoverTimeoutRef.current = setTimeout(show, HOVER_DELAY_MS); + }, [show, clearHoverTimeout]); + + const handlePointerLeave = useCallback(() => { + isPointerInsideRef.current = false; + clearHoverTimeout(); + hoverTimeoutRef.current = setTimeout(() => { + if (!isPointerInsideRef.current) { + hide(); + } + }, 100); + }, [hide, clearHoverTimeout]); + + const handleFocus = useCallback(() => { + show(); + }, [show]); + + const handleBlur = useCallback(() => { + hide(); + }, [hide]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + hide(); + } + }, + [hide], + ); + + const handleClick = useCallback(() => { + if (isVisible) { + hide(); + } else { + show(); + } + }, [isVisible, show, hide]); + + // Close on outside click + useEffect(() => { + if (!isVisible) return; + + const handleOutsideClick = (e: MouseEvent | TouchEvent) => { + const target = e.target as Node; + if ( + triggerRef.current && + !triggerRef.current.contains(target) && + bubbleRef.current && + !bubbleRef.current.contains(target) + ) { + hide(); + } + }; + + document.addEventListener("mousedown", handleOutsideClick); + document.addEventListener("touchstart", handleOutsideClick); + return () => { + document.removeEventListener("mousedown", handleOutsideClick); + document.removeEventListener("touchstart", handleOutsideClick); + }; + }, [isVisible, hide]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + } + }; + }, []); + + // Clamp bubble within viewport and position arrow after render + useLayoutEffect(() => { + if (!isVisible || !bubbleRef.current || !triggerRect) return; + const bubble = bubbleRef.current; + const arrow = arrowRef.current; + const rect = bubble.getBoundingClientRect(); + const margin = 8; + let needsClamp = false; + let left = rect.left; + let top = rect.top; + + // Horizontal clamping + if (rect.right > window.innerWidth - margin) { + left = window.innerWidth - margin - rect.width; + needsClamp = true; + } else if (rect.left < margin) { + left = margin; + needsClamp = true; + } + + // Vertical clamping + if (rect.bottom > window.innerHeight - margin) { + top = window.innerHeight - margin - rect.height; + needsClamp = true; + } else if (rect.top < margin) { + top = margin; + needsClamp = true; + } + + if (needsClamp) { + bubble.style.left = `${left}px`; + bubble.style.top = `${top}px`; + bubble.style.transform = "none"; + } + + // Position arrow to point at trigger center + if (arrow) { + const bubbleRect = bubble.getBoundingClientRect(); + const triggerCenterX = triggerRect.left + triggerRect.width / 2; + const triggerCenterY = triggerRect.top + triggerRect.height / 2; + const arrowSize = 5; + + if (actualPlacement === "top" || actualPlacement === "bottom") { + const arrowLeft = Math.max( + arrowSize, + Math.min(triggerCenterX - bubbleRect.left, bubbleRect.width - arrowSize), + ); + arrow.style.left = `${arrowLeft}px`; + } else { + const arrowTop = Math.max( + arrowSize, + Math.min(triggerCenterY - bubbleRect.top, bubbleRect.height - arrowSize), + ); + arrow.style.top = `${arrowTop}px`; + } + } + }, [isVisible, triggerRect, actualPlacement]); + + const getBubbleStyle = (): React.CSSProperties => { + if (!triggerRect) return {}; + const gap = 6; + const centerX = triggerRect.left + triggerRect.width / 2; + const centerY = triggerRect.top + triggerRect.height / 2; + + switch (actualPlacement) { + case "bottom": + return { + position: "fixed", + top: triggerRect.bottom + gap, + left: centerX, + transform: "translateX(-50%)", + }; + case "left": + return { + position: "fixed", + top: centerY, + left: triggerRect.left - gap, + transform: "translate(-100%, -50%)", + }; + case "right": + return { + position: "fixed", + top: centerY, + left: triggerRect.right + gap, + transform: "translateY(-50%)", + }; + default: + return { + position: "fixed", + top: triggerRect.top - gap, + left: centerX, + transform: "translate(-50%, -100%)", + }; + } + }; + + const bubble = isVisible ? ( + + ) : null; + + return ( + + + {bubble && createPortal(bubble, document.body)} + + ); +}; + +export default HelperTooltip; diff --git a/src/components/navbar/index.tsx b/src/components/navbar/index.tsx index aa6b0240..61c3d709 100644 --- a/src/components/navbar/index.tsx +++ b/src/components/navbar/index.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; import { useSettings } from "../../context/SettingsContext"; +import { useNotify } from "../../hooks/useNotify"; import { useSearch } from "../../hooks/useSearch"; import NavbarLogo from "./NavbarLogo"; import { NetworkBlockIndicator } from "./NetworkBlockIndicator"; @@ -13,7 +14,9 @@ const Navbar = () => { const location = useLocation(); const { searchTerm, setSearchTerm, isResolving, error, clearError, handleSearch, networkId } = useSearch(); - const { isDarkMode, toggleTheme, isSuperUser, toggleSuperUserMode } = useSettings(); + const { isDarkMode, toggleTheme, isSuperUser, toggleSuperUserMode, settings, updateSettings } = + useSettings(); + const notify = useNotify(); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); // Check if we should show the search box (on any network page including home) @@ -42,6 +45,30 @@ const Navbar = () => { }; }, [isMobileMenuOpen]); + const knowledgeLevel = settings.knowledgeLevel ?? "beginner"; + const tooltipsEnabled = settings.showHelperTooltips !== false; + + const cycleKnowledgeLevel = () => { + const levels = ["beginner", "intermediate", "advanced"] as const; + const currentIndex = levels.indexOf(knowledgeLevel); + const nextLevel = levels[(currentIndex + 1) % levels.length] ?? "beginner"; + updateSettings({ knowledgeLevel: nextLevel }); + const levelKey = + nextLevel === "beginner" + ? "nav.tooltipsLevelBeginner" + : nextLevel === "intermediate" + ? "nav.tooltipsLevelIntermediate" + : "nav.tooltipsLevelAdvanced"; + notify.success(t("nav.tooltipsSwitched", { level: t(levelKey) }), 2000); + }; + + const knowledgeLevelLabel = + knowledgeLevel === "beginner" + ? t("nav.tooltipsBeginner") + : knowledgeLevel === "intermediate" + ? t("nav.tooltipsIntermediate") + : t("nav.tooltipsAdvanced"); + const goToSettings = () => { navigate("/settings"); }; @@ -137,6 +164,40 @@ const Navbar = () => { )} + {tooltipsEnabled && ( +
  • + +
  • + )}
  • + {/* Tooltip Level toggle */} + {tooltipsEnabled && ( + + )} + {/* Super User Mode toggle */}
  • TX HashBlockMethodFromToValueStatus + TX Hash + {settings.showHelperTooltips !== false && ( + + )} + + Block + {settings.showHelperTooltips !== false && ( + + )} + + Method + {settings.showHelperTooltips !== false && ( + + )} + + From + {settings.showHelperTooltips !== false && ( + + )} + + To + {settings.showHelperTooltips !== false && ( + + )} + + Value + {settings.showHelperTooltips !== false && ( + + )} + + Status + {settings.showHelperTooltips !== false && ( + + )} +
    - - - {hasContractAbi && } - - - - + + + {hasContractAbi && ( + + )} + + + + diff --git a/src/components/pages/evm/block/BlockDisplay.tsx b/src/components/pages/evm/block/BlockDisplay.tsx index 61ae6fd2..3c9df763 100644 --- a/src/components/pages/evm/block/BlockDisplay.tsx +++ b/src/components/pages/evm/block/BlockDisplay.tsx @@ -6,6 +6,8 @@ import { useSettings } from "../../../../context/SettingsContext"; import type { Block, BlockArbitrum, RPCMetadata } from "../../../../types"; import AIAnalysisPanel from "../../../common/AIAnalysis/AIAnalysisPanel"; import ExtraDataDisplay from "../../../common/ExtraDataDisplay"; +import FieldLabel from "../../../common/FieldLabel"; +import HelperTooltip from "../../../common/HelperTooltip"; import LongString from "../../../common/LongString"; import { RPCIndicator } from "../../../common/RPCIndicator"; import { formatGweiFromWei, formatNativeFromWei } from "../../../../utils/unitFormatters"; @@ -22,7 +24,8 @@ interface BlockDisplayProps { const BlockDisplay: React.FC = React.memo( ({ block, networkId, metadata, selectedProvider, onProviderSelect }) => { const { t } = useTranslation("block"); - const { isSuperUser } = useSettings(); + const { t: tTooltips } = useTranslation("tooltips"); + const { settings, isSuperUser } = useSettings(); const network = networkId ? getNetworkById(networkId) : undefined; const networkName = network?.name ?? "Unknown Network"; const networkCurrency = network?.currency ?? "ETH"; @@ -156,7 +159,12 @@ const BlockDisplay: React.FC = React.memo( ({timestampFormatted}) - {t("finalized")} + + {t("finalized")} + {settings.showHelperTooltips !== false && ( + + )} + {metadata && selectedProvider !== undefined && onProviderSelect && ( = React.memo(
    {/* Full-width: Block Hash */}
    - Hash: + @@ -182,7 +190,11 @@ const BlockDisplay: React.FC = React.memo(
    {/* Transactions */}
    - {t("transactions")} + {block.transactions ? block.transactions.length : 0} {t("transactions")} @@ -194,7 +206,11 @@ const BlockDisplay: React.FC = React.memo( {/* Withdrawals count */} {block.withdrawals && block.withdrawals.length > 0 && (
    - {t("withdrawals")} + {block.withdrawals.length}{" "} {block.withdrawals.length !== 1 ? t("withdrawalsPlural") : t("withdrawal")}{" "} @@ -205,7 +221,11 @@ const BlockDisplay: React.FC = React.memo( {/* Fee Recipient (Miner) */}
    - {t("feeRecipient")} + {networkId ? ( @@ -220,7 +240,11 @@ const BlockDisplay: React.FC = React.memo( {/* Difficulty */} {Number(block.difficulty) > 0 && (
    - {t("difficulty")}: + {Number(block.difficulty).toLocaleString()}
    )} @@ -228,7 +252,11 @@ const BlockDisplay: React.FC = React.memo( {/* Total Difficulty */} {Number(block.totalDifficulty) > 0 && (
    - {t("totalDifficulty")}: + {Number(block.totalDifficulty).toLocaleString()} @@ -237,14 +265,22 @@ const BlockDisplay: React.FC = React.memo( {/* Size */}
    - {t("size")}: + {Number(block.size).toLocaleString()} bytes
    {/* Extra Data */} {block.extraData && block.extraData !== "0x" && (
    - {t("extraData")}: + @@ -254,7 +290,11 @@ const BlockDisplay: React.FC = React.memo( {/* Blob Gas Used (EIP-4844) */} {block.blobGasUsed && Number(block.blobGasUsed) > 0 && (
    - {t("blobGasUsed")} + {Number(block.blobGasUsed).toLocaleString()}
    )} @@ -264,7 +304,11 @@ const BlockDisplay: React.FC = React.memo(
    {/* Gas Used */}
    - {t("gasUsed")} + {Number(block.gasUsed).toLocaleString()} ({gasUsedPct}%) @@ -273,14 +317,22 @@ const BlockDisplay: React.FC = React.memo( {/* Gas Limit */}
    - {t("gasLimit")} + {Number(block.gasLimit).toLocaleString()}
    {/* Base Fee Per Gas */} {block.baseFeePerGas && (
    - {t("baseFeePerGas")} + {formatGwei(block.baseFeePerGas)}
    )} @@ -288,7 +340,11 @@ const BlockDisplay: React.FC = React.memo( {/* Burnt Fees */} {burntFees && (
    - {t("burntFees")}: + 🔥 {formatNative(burntFees)} @@ -299,13 +355,21 @@ const BlockDisplay: React.FC = React.memo( {block.blobGasUsed && Number(block.blobGasUsed) > 0 && ( <>
    - {t("excessBlobGas")} + {Number(block.excessBlobGas).toLocaleString()}
    - {t("blobCount")} + {Math.floor(Number(block.blobGasUsed) / 131072)} @@ -317,17 +381,29 @@ const BlockDisplay: React.FC = React.memo( {isArbitrumBlock(block) && ( <>
    - {t("l1BlockNumber")}: + {Number(block.l1BlockNumber).toLocaleString()}
    - {t("sendCount")}: + {block.sendCount}
    - {t("sendRoot")}: + {block.sendRoot}
    diff --git a/src/components/pages/evm/network/DashboardStats.tsx b/src/components/pages/evm/network/DashboardStats.tsx index 43443ebe..80940f23 100644 --- a/src/components/pages/evm/network/DashboardStats.tsx +++ b/src/components/pages/evm/network/DashboardStats.tsx @@ -2,8 +2,10 @@ import type React from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import type { GasPrices } from "../../../../types"; +import { useSettings } from "../../../../context/SettingsContext"; import { formatPrice } from "../../../../services/PriceService"; import { formatGasPriceWithUnit } from "../../../../utils/formatUtils"; +import HelperTooltip from "../../../common/HelperTooltip"; interface DashboardStatsProps { price: number | null; @@ -35,13 +37,20 @@ const DashboardStats: React.FC = ({ networkId, }) => { const { t } = useTranslation("network"); + const { t: tTooltips } = useTranslation("tooltips"); + const { settings } = useSettings(); // Use gas tiers if available, otherwise fall back to single gas price const hasGasTiers = gasPrices !== null; return (
    -
    {t("currencyPrice", { currency })}
    +
    + {t("currencyPrice", { currency })} + {settings.showHelperTooltips !== false && ( + + )} +
    {loading ? ( = ({
    -
    {t("latestBlock")}
    +
    + {t("latestBlock")} + {settings.showHelperTooltips !== false && ( + + )} +
    {loading ? ( = React.memo(
    - {t("currentGasPrice")} + {formatGasPrice(networkStats.currentGasPrice)}
    - {t("currentBlockNumber")} + {formatBlockNumber(networkStats.currentBlockNumber)}
    - {t("syncStatus")} + = React.memo( {networkStats.clientVersion && (
    - {t("clientVersion")} + {networkStats.clientVersion}
    )} {protocolVersion && (
    - {t("protocolVersion")} + {protocolVersion}
    )} diff --git a/src/components/pages/evm/tokenDetails/ERC1155TokenDisplay.tsx b/src/components/pages/evm/tokenDetails/ERC1155TokenDisplay.tsx index e44f15d6..8ba4a669 100644 --- a/src/components/pages/evm/tokenDetails/ERC1155TokenDisplay.tsx +++ b/src/components/pages/evm/tokenDetails/ERC1155TokenDisplay.tsx @@ -12,6 +12,7 @@ import { getImageUrl, } from "../../../../utils/erc1155Metadata"; import { logger } from "../../../../utils/logger"; +import FieldLabel from "../../../common/FieldLabel"; import LoaderWithTimeout from "../../../common/LoaderWithTimeout"; const ERC1155TokenDetails: React.FC = () => { @@ -229,13 +230,21 @@ const ERC1155TokenDetails: React.FC = () => { {/* Token ID */}
    - {t("tokenID")}: + {tokenId}
    {/* Token Standard */}
    - {t("tokenStandard")}: + ERC-1155 diff --git a/src/components/pages/evm/tokenDetails/ERC721TokenDisplay.tsx b/src/components/pages/evm/tokenDetails/ERC721TokenDisplay.tsx index 335d3afc..e99275c4 100644 --- a/src/components/pages/evm/tokenDetails/ERC721TokenDisplay.tsx +++ b/src/components/pages/evm/tokenDetails/ERC721TokenDisplay.tsx @@ -13,6 +13,7 @@ import { getImageUrl, } from "../../../../utils/erc721Metadata"; import { logger } from "../../../../utils/logger"; +import FieldLabel from "../../../common/FieldLabel"; import LoaderWithTimeout from "../../../common/LoaderWithTimeout"; const ERC721TokenDisplay: React.FC = () => { @@ -200,13 +201,21 @@ const ERC721TokenDisplay: React.FC = () => { {/* Token ID */}
    - {t("tokenID")}: + {tokenId}
    {/* Token Standard */}
    - {t("tokenStandard")}: + ERC-721 @@ -215,7 +224,11 @@ const ERC721TokenDisplay: React.FC = () => { {/* Total Supply */} {collectionInfo?.totalSupply && (
    - {t("size")} + {Number(collectionInfo.totalSupply).toLocaleString()} NFTs @@ -225,7 +238,11 @@ const ERC721TokenDisplay: React.FC = () => { {/* Owner */} {owner && (
    - {t("owner")} + {owner} @@ -237,7 +254,11 @@ const ERC721TokenDisplay: React.FC = () => { {/* Approved Address */} {approval && (
    - {t("approved")}: + {approval} diff --git a/src/components/pages/evm/tx/TransactionDisplay.tsx b/src/components/pages/evm/tx/TransactionDisplay.tsx index f54e3213..e990b8e9 100644 --- a/src/components/pages/evm/tx/TransactionDisplay.tsx +++ b/src/components/pages/evm/tx/TransactionDisplay.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import AIAnalysisPanel from "../../../common/AIAnalysis/AIAnalysisPanel"; import CopyButton from "../../../../components/common/CopyButton"; +import FieldLabel from "../../../../components/common/FieldLabel"; import LongString from "../../../../components/common/LongString"; import { RPCIndicator } from "../../../../components/common/RPCIndicator"; import { getNetworkById } from "../../../../config/networks"; @@ -18,7 +19,6 @@ import { type TokenListItem, type TokenMetadata, } from "../../../../services/MetadataService"; -import type { TraceResult } from "../../../../services/adapters/NetworkAdapter"; import { logger } from "../../../../utils/logger"; import { formatGweiFromWei, @@ -71,11 +71,6 @@ const TransactionDisplay: React.FC = React.memo( const [callTargetTokenListMatch, setCallTargetTokenListMatch] = useState( null, ); - const [showTrace, setShowTrace] = useState(false); - const [traceData, setTraceData] = useState(null); - // biome-ignore lint/suspicious/noExplicitAny: - const [callTrace, setCallTrace] = useState(null); - const [loadingTrace, setLoadingTrace] = useState(false); const [_copiedField, _setCopiedField] = useState(null); const copyTimeoutRef = useRef | null>(null); @@ -362,26 +357,6 @@ const TransactionDisplay: React.FC = React.memo( callTargetTokenListMatch, ]); - // Check if trace is available (localhost only) - const isTraceAvailable = dataService?.networkAdapter.isTraceAvailable() || false; - - // Load trace data when trace section is expanded - useEffect(() => { - if (showTrace && isTraceAvailable && dataService && !traceData && !callTrace) { - setLoadingTrace(true); - Promise.all([ - dataService.networkAdapter.getTransactionTrace(transaction.hash), - dataService.networkAdapter.getCallTrace(transaction.hash), - ]) - .then(([trace, call]) => { - setTraceData(trace); - setCallTrace(call); - }) - .catch((err) => logger.error("Error loading trace:", err)) - .finally(() => setLoadingTrace(false)); - } - }, [showTrace, isTraceAvailable, dataService, transaction.hash, traceData, callTrace]); - useEffect(() => { return () => { if (copyTimeoutRef.current) { @@ -524,7 +499,11 @@ const TransactionDisplay: React.FC = React.memo( {/* Full-width rows: long hex values */} {/* Transaction Hash */}
    - {t("transactionHash")} + @@ -532,7 +511,11 @@ const TransactionDisplay: React.FC = React.memo( {/* From */}
    - {t("from")} + {networkId ? ( @@ -547,7 +530,11 @@ const TransactionDisplay: React.FC = React.memo( {/* To */}
    - {transaction.to ? t("to") : t("interactedWith")} + {transaction.to ? ( <> @@ -569,7 +556,11 @@ const TransactionDisplay: React.FC = React.memo( {/* Contract Address (if created) */} {transaction.receipt?.contractAddress && (
    - {t("contractCreated")} + {networkId ? ( = React.memo(
    {/* Status */}
    - {t("status")} + {getStatusText(transaction.receipt?.status)}
    {/* Block */}
    - {t("block")} + {networkId ? ( = React.memo( {/* Value */}
    - {t("value")} + {formatValue(transaction.value)} @@ -640,7 +643,11 @@ const TransactionDisplay: React.FC = React.memo( {/* Transaction Fee */}
    - {t("transactionFee")} + {transaction.receipt ? formatValue( @@ -658,13 +665,21 @@ const TransactionDisplay: React.FC = React.memo(
    {/* Gas Price */}
    - {t("gasPrice")} + {formatGwei(transaction.gasPrice)}
    {/* Gas Limit & Usage */}
    - {t("gasLimitUsage")} + {Number(transaction.gas).toLocaleString()} {transaction.receipt && ( @@ -688,7 +703,11 @@ const TransactionDisplay: React.FC = React.memo( {transaction.receipt && transaction.receipt.effectiveGasPrice !== transaction.gasPrice && (
    - {t("effectiveGasPrice")} + {formatGwei(transaction.receipt.effectiveGasPrice)} @@ -697,19 +716,31 @@ const TransactionDisplay: React.FC = React.memo( {/* Nonce */}
    - {t("nonce")} + {transaction.nonce}
    {/* Position */}
    - {t("position")} + {transaction.transactionIndex}
    {/* Type */}
    - {t("type")} + {transaction.type}
    @@ -722,7 +753,11 @@ const TransactionDisplay: React.FC = React.memo(
    - {t("l1BlockNumber")} + {Number(transaction.receipt.l1BlockNumber).toLocaleString()} @@ -730,7 +765,11 @@ const TransactionDisplay: React.FC = React.memo(
    - {t("gasUsedForL1")} + {Number(transaction.receipt.gasUsedForL1).toLocaleString()} @@ -746,13 +785,21 @@ const TransactionDisplay: React.FC = React.memo(
    - {t("l1Fee")} + {formatValue(transaction.receipt.l1Fee)}
    - {t("l1GasPrice")} + {formatGwei(transaction.receipt.l1GasPrice)}
    @@ -760,7 +807,11 @@ const TransactionDisplay: React.FC = React.memo(
    - {t("l1GasUsed")} + {Number(transaction.receipt.l1GasUsed).toLocaleString()} @@ -768,7 +819,11 @@ const TransactionDisplay: React.FC = React.memo(
    - {t("l1FeeScalar")} + {transaction.receipt.l1FeeScalar}
    @@ -781,13 +836,21 @@ const TransactionDisplay: React.FC = React.memo(
    {transaction.maxFeePerBlobGas && (
    - {t("maxFeePerBlobGas")} + {formatGwei(transaction.maxFeePerBlobGas)}
    )} {transaction.receipt?.blobGasPrice && (
    - {t("blobGasPrice")} + {formatGwei(transaction.receipt.blobGasPrice)} @@ -797,7 +860,11 @@ const TransactionDisplay: React.FC = React.memo(
    {transaction.receipt?.blobGasUsed && (
    - {t("blobGasUsed")} + {Number(transaction.receipt.blobGasUsed).toLocaleString()} @@ -806,7 +873,11 @@ const TransactionDisplay: React.FC = React.memo( {transaction.blobVersionedHashes && transaction.blobVersionedHashes.length > 0 && (
    - {t("blobCount")} + {transaction.blobVersionedHashes.length}
    )} @@ -819,7 +890,11 @@ const TransactionDisplay: React.FC = React.memo( transaction.blobVersionedHashes && transaction.blobVersionedHashes.length > 0 && (
    - {t("blobVersionedHashes")} + {transaction.blobVersionedHashes.map((hash) => (
    @@ -851,124 +926,6 @@ const TransactionDisplay: React.FC = React.memo( blockTimestamp={transaction.timestamp ? Number(transaction.timestamp) : undefined} /> )} - - {/* Debug Trace Section (Localhost Only) */} - {isTraceAvailable && ( -
    - {/** biome-ignore lint/a11y/useButtonType: */} - - - {showTrace && ( -
    - {loadingTrace &&
    {t("loadingTrace")}
    } - - {/* Call Trace */} - {callTrace && ( -
    -
    {t("callTrace")}
    -
    -
    - {t("traceType")} {callTrace.type} -
    -
    - {t("traceFrom")}{" "} - -
    -
    - {t("traceTo")}{" "} - -
    -
    - {t("traceValue")} {callTrace.value} -
    -
    - {t("traceGas")} {callTrace.gas} -
    -
    - {t("traceGasUsed")} {callTrace.gasUsed} -
    - {callTrace.error && ( -
    - {t("traceError")} {callTrace.error} -
    - )} - {callTrace.calls && callTrace.calls.length > 0 && ( -
    -
    - {t("internalCalls")} ({callTrace.calls.length}): -
    -
    - {JSON.stringify(callTrace.calls, null, 2)} -
    -
    - )} -
    -
    - )} - - {/* Opcode Trace */} - {traceData && ( -
    -
    {t("executionTrace")}
    -
    -
    - {t("opcodeTrace.totalGasUsed")}:{" "} - {traceData.gas} -
    -
    - {t("opcodeTrace.failed")}:{" "} - {traceData.failed ? t("opcodeTrace.yes") : t("opcodeTrace.no")} -
    -
    - {t("opcodeTrace.returnValue")}:{" "} - -
    -
    - {t("opcodeTrace.executed")}{" "} - {traceData.structLogs.length} -
    -
    - -
    {t("opcodeTrace.executionLog")}
    -
    - {traceData.structLogs.slice(0, 100).map((log, index) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: -
    -
    - {t("opcodeTrace.step")} {index}: {log.op} -
    -
    - {t("opcodeTrace.PC")}: {log.pc} | {t("opcodeTrace.gas")}: {log.gas} |{" "} - {t("opcodeTrace.cost")}: {log.gasCost} | {t("opcodeTrace.depth")}:{" "} - {log.depth} -
    - {log.stack && log.stack.length > 0 && ( -
    - {t("opcodeTrace.stack")}: [{log.stack.slice(0, 3).join(", ")} - {log.stack.length > 3 ? "..." : ""}] -
    - )} -
    - ))} - {traceData.structLogs.length > 100 && ( -
    - {t("opcodeTrace.showingFirst100", { - total: traceData.structLogs.length, - })} -
    - )} -
    -
    - )} -
    - )} -
    - )}
    = ({ blockTimestamp, }) => { const { t } = useTranslation("transaction"); + const { t: tTooltips } = useTranslation("tooltips"); const hasEvents = logs && logs.length > 0; const hasInputData = inputData && inputData !== "0x"; const hasBlobData = blobVersionedHashes && blobVersionedHashes.length > 0; @@ -55,17 +62,26 @@ const TxAnalyser: React.FC = ({ if (isSuperUser) { setCollapsed(false); } else { - const superTabs: AnalyserTab[] = ["callTree", "gasProfiler", "stateChanges", "blobData"]; + const superTabs: AnalyserTab[] = [ + "callTree", + "gasProfiler", + "stateChanges", + "rawTrace", + "blobData", + ]; setActiveTab((prev) => (superTabs.includes(prev) ? defaultTab : prev)); } }, [isSuperUser]); const [callTree, setCallTree] = useState(null); const [prestateTrace, setPrestateTrace] = useState(null); + const [rawTrace, setRawTrace] = useState(null); const [loadingCallTree, setLoadingCallTree] = useState(false); const [loadingPrestate, setLoadingPrestate] = useState(false); + const [loadingRawTrace, setLoadingRawTrace] = useState(false); const [callTreeError, setCallTreeError] = useState(null); const [prestateError, setPrestateError] = useState(null); + const [rawTraceError, setRawTraceError] = useState(null); // Contract name + ABI enrichment for the call tree const { contracts: treeContracts, enrichmentLoading } = useCallTreeEnrichment( @@ -187,6 +203,41 @@ const TxAnalyser: React.FC = ({ isUnsupported, ]); + // Load raw trace when switching to that tab (super user only) + useEffect(() => { + if (!isSuperUser) return; + if (activeTab !== "rawTrace") return; + if (rawTrace || rawTraceError || loadingRawTrace) return; + setLoadingRawTrace(true); + dataService.networkAdapter + .getTransactionTrace(txHash) + .then((data) => { + if (data) { + setRawTrace(data); + } else { + setRawTraceError(t("analyser.notSupported")); + } + }) + .catch((err) => { + const msg = err instanceof Error ? err.message : String(err); + logger.warn("TX Analyser raw trace error:", msg); + setRawTraceError( + isUnsupported(msg) ? t("analyser.notSupported") : `${t("analyser.error")}: ${msg}`, + ); + }) + .finally(() => setLoadingRawTrace(false)); + }, [ + isSuperUser, + activeTab, + txHash, + dataService, + rawTrace, + rawTraceError, + loadingRawTrace, + t, + isUnsupported, + ]); + const [collapsed, setCollapsed] = useState(!isSuperUser); const handleTabClick = useCallback( @@ -201,6 +252,9 @@ const TxAnalyser: React.FC = ({ [activeTab], ); + // Hide entirely if there's nothing to show + if (!isSuperUser && !hasEvents && !hasInputData) return null; + return (
    {/* Tab bar */} @@ -221,6 +275,9 @@ const TxAnalyser: React.FC = ({ onClick={() => handleTabClick("inputData")} > {t("analyser.inputDataTab")} + {settings.showHelperTooltips !== false && ( + + )} )} {isSuperUser && ( @@ -231,6 +288,9 @@ const TxAnalyser: React.FC = ({ onClick={() => handleTabClick("callTree")} > {t("analyser.callTree")} + {settings.showHelperTooltips !== false && ( + + )} + {hasBlobData && beaconAvailable && ( + + {t("analyser.rawTracePage", { + current: page + 1, + total: totalPages, + from: (startIndex + 1).toLocaleString(), + to: Math.min(startIndex + OPCODES_PER_PAGE, totalSteps).toLocaleString(), + totalSteps: totalSteps.toLocaleString(), + })} + + +
    + )} +
    + ); +}; + +export default RawTraceTab; diff --git a/src/components/pages/evm/tx/analyser/StateChangesTab.tsx b/src/components/pages/evm/tx/analyser/StateChangesTab.tsx index d137b667..6362c0a7 100644 --- a/src/components/pages/evm/tx/analyser/StateChangesTab.tsx +++ b/src/components/pages/evm/tx/analyser/StateChangesTab.tsx @@ -7,6 +7,8 @@ import type { PrestateTrace, } from "../../../../../services/adapters/NetworkAdapter"; import type { ContractInfo } from "../../../../../utils/contractLookup"; +import HelperTooltip from "../../../../common/HelperTooltip"; +import { useSettings } from "../../../../../context/SettingsContext"; import LongString from "../../../../common/LongString"; function formatHexBalance(hex: string | undefined): string { @@ -42,6 +44,8 @@ const StateChangesTab: React.FC<{ contracts: Record; }> = ({ trace, networkId, networkCurrency, contracts }) => { const { t } = useTranslation("transaction"); + const { t: tTooltips } = useTranslation("tooltips"); + const { settings } = useSettings(); const [expandedSet, setExpandedSet] = useState>(new Set()); const allAddresses = Array.from(new Set([...Object.keys(trace.pre), ...Object.keys(trace.post)])); @@ -139,6 +143,9 @@ const StateChangesTab: React.FC<{
    {t("analyser.balanceChange")} ({networkCurrency}) + {settings.showHelperTooltips !== false && ( + + )} {formatHexBalance(pre.balance)} @@ -153,7 +160,12 @@ const StateChangesTab: React.FC<{ {nonceDiff && (
    - {t("analyser.nonceChange")} + + {t("analyser.nonceChange")} + {settings.showHelperTooltips !== false && ( + + )} + {pre.nonce ?? "—"} {post.nonce ?? "—"} @@ -165,7 +177,12 @@ const StateChangesTab: React.FC<{ {codeChanged && (
    - {t("analyser.codeDeployed")} + + {t("analyser.codeDeployed")} + {settings.showHelperTooltips !== false && ( + + )} + {post.code ? `${post.code.slice(0, 20)}…` : "—"} @@ -174,7 +191,12 @@ const StateChangesTab: React.FC<{ {storageKeys.map((slot) => (
    - {t("analyser.storageChange")} + + {t("analyser.storageChange")} + {settings.showHelperTooltips !== false && ( + + )} + diff --git a/src/components/pages/evm/tx/analyser/types.ts b/src/components/pages/evm/tx/analyser/types.ts index 2cca0c6e..0dad7b92 100644 --- a/src/components/pages/evm/tx/analyser/types.ts +++ b/src/components/pages/evm/tx/analyser/types.ts @@ -5,6 +5,7 @@ export type AnalyserTab = | "callTree" | "gasProfiler" | "stateChanges" + | "rawTrace" | "events" | "inputData" | "blobData"; diff --git a/src/components/pages/settings/index.tsx b/src/components/pages/settings/index.tsx index 0d9e6301..01cd4c2a 100644 --- a/src/components/pages/settings/index.tsx +++ b/src/components/pages/settings/index.tsx @@ -2,6 +2,7 @@ import type React from "react"; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { Link, useLocation, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; +import HelperTooltip from "../../common/HelperTooltip"; import { MetaMaskIcon } from "../../common/MetaMaskIcon"; import { BEACON_SUPPORTED_NETWORKS, DEFAULT_BEACON_URLS } from "../../../config/beaconConfig"; import { getEnabledNetworks } from "../../../config/networks"; @@ -88,6 +89,7 @@ const isAlchemyUrl = (url: string): boolean => url.includes("alchemy.com"); const Settings: React.FC = () => { const { t, i18n } = useTranslation("settings"); + const { t: tTooltips } = useTranslation("tooltips"); const location = useLocation(); const navigate = useNavigate(); const { rpcUrls, setRpcUrls } = useContext(AppContext); @@ -914,6 +916,76 @@ const Settings: React.FC = () => {
    + +
    +

    💡 {t("helperTooltips.title")}

    +

    + {t("helperTooltips.description")} +

    + +
    +
    +
    + {t("helperTooltips.enabled.label")} +
    +
    + {t("helperTooltips.enabled.description")} +
    +
    + +
    + +
    +
    +
    + {t("helperTooltips.knowledgeLevel.label")} + {settings.showHelperTooltips !== false && ( + + )} +
    +
    + {t("helperTooltips.knowledgeLevel.description")} +
    +
    + +
    +
    )} diff --git a/src/config/networks.json b/src/config/networks.json index 3bec09b4..248a8c19 100644 --- a/src/config/networks.json +++ b/src/config/networks.json @@ -185,6 +185,35 @@ } ] }, + { + "type": "evm", + "networkId": "eip155:43114", + "slug": "avax", + "name": "Avalanche C-Chain", + "shortName": "Avalanche", + "description": "EVM-compatible smart contract platform with high throughput", + "currency": "AVAX", + "color": "#E84142", + "isTestnet": false, + "logo": "assets/networks/43114.svg", + "links": [ + { + "name": "Website", + "url": "https://www.avax.network", + "description": "Official Avalanche website" + }, + { + "name": "Docs", + "url": "https://docs.avax.network", + "description": "Developer documentation" + }, + { + "name": "Bridge", + "url": "https://core.app/bridge", + "description": "Bridge assets to Avalanche" + } + ] + }, { "type": "evm", "networkId": "eip155:11155111", @@ -233,6 +262,29 @@ } ] }, + { + "type": "evm", + "networkId": "eip155:31337", + "slug": "localhost", + "name": "Hardhat", + "shortName": "Hardhat", + "description": "Local development network (Hardhat/Anvil)", + "currency": "ETH", + "color": "#FFF100", + "isTestnet": true, + "links": [ + { + "name": "Docs", + "url": "https://hardhat.org/docs", + "description": "Hardhat documentation" + }, + { + "name": "GitHub", + "url": "https://github.com/NomicFoundation/hardhat", + "description": "Hardhat GitHub repository" + } + ] + }, { "type": "bitcoin", "networkId": "bip122:000000000019d6689c085ae165831e93", diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx index c1f1fab3..32d8ab9c 100644 --- a/src/context/AppContext.tsx +++ b/src/context/AppContext.tsx @@ -19,7 +19,6 @@ import type { IAppContext, NetworkConfig, RpcUrlsContextType } from "../types"; import { fetchAllRpcs } from "../services/MetadataService"; import { loadJsonFilesFromStorage, saveJsonFilesToStorage } from "../utils/artifactsStorage"; import { logger } from "../utils/logger"; -import { getChainIdFromNetwork } from "../utils/networkResolver"; import { getEffectiveRpcUrls, isMetadataRpcCacheFresh, @@ -84,23 +83,6 @@ export const AppContextProvider = ({ children }: { children: ReactNode }) => { } }, []); - // Hardhat network config for local development - const hardhatNetwork: NetworkConfig = useMemo( - () => ({ - type: "evm" as const, - networkId: "eip155:31337", - chainId: 31337, - slug: "localhost", - name: "Hardhat", - shortName: "hardhat", - description: "Local development network", - color: "#FFF100", - currency: "ETH", - isTestnet: true, - }), - [], - ); - // Load networks from metadata const loadNetworkData = useCallback(async () => { setNetworksLoading(true); @@ -108,21 +90,6 @@ export const AppContextProvider = ({ children }: { children: ReactNode }) => { try { const loadedNetworks = await loadNetworks(); - - // Check if Hardhat should be included (only when both conditions are met) - const envNetworks = process.env.REACT_APP_OPENSCAN_NETWORKS; - const isDevelopment = import.meta.env.VITE_ENVIRONMENT === "development"; - const hardhatInEnv = envNetworks?.split(",").some((id) => id.trim() === "31337"); - - // Add Hardhat network if in development AND explicitly enabled - if ( - isDevelopment && - hardhatInEnv && - !loadedNetworks.some((n) => getChainIdFromNetwork(n) === 31337) - ) { - loadedNetworks.push(hardhatNetwork); - } - setNetworks(loadedNetworks); // Fetch metadata RPCs if cache is stale or missing, then update RPC state @@ -142,7 +109,7 @@ export const AppContextProvider = ({ children }: { children: ReactNode }) => { } finally { setNetworksLoading(false); } - }, [hardhatNetwork]); + }, []); const _account = useAccount(); const { isFullyConnected, address } = useWagmiConnection(); diff --git a/src/hooks/useContractVerification.ts b/src/hooks/useContractVerification.ts index 3a4936b1..ad88d51e 100644 --- a/src/hooks/useContractVerification.ts +++ b/src/hooks/useContractVerification.ts @@ -1,4 +1,3 @@ -import { useSettings } from "../context/SettingsContext"; import { useEtherscan } from "./useEtherscan"; import type { SourcifyContractDetails } from "./useSourcify"; import { useSourcify } from "./useSourcify"; @@ -23,21 +22,18 @@ export function useContractVerification( address: string | undefined, enabled: boolean = true, ): ContractVerificationResult { - const { settings } = useSettings(); - const hasEtherscanKey = !!settings.apiKeys?.etherscan; - const { data: sourcifyData, loading: sourcifyLoading, isVerified: sourcifyVerified, } = useSourcify(networkId, address, enabled); - // Run Etherscan in parallel whenever a key is configured + // Run Etherscan in parallel (uses worker proxy when no user key is configured) const { data: etherscanData, loading: etherscanLoading, isVerified: etherscanVerified, - } = useEtherscan(networkId, address, enabled && hasEtherscanKey); + } = useEtherscan(networkId, address, enabled); const loading = sourcifyLoading || etherscanLoading; const source: VerificationSource = [ diff --git a/src/hooks/useEtherscan.ts b/src/hooks/useEtherscan.ts index fc8a51c3..151ea62d 100644 --- a/src/hooks/useEtherscan.ts +++ b/src/hooks/useEtherscan.ts @@ -4,6 +4,10 @@ import { logger } from "../utils/logger"; import type { SourcifyContractDetails } from "./useSourcify"; const ETHERSCAN_V2_API = "https://api.etherscan.io/v2/api"; +const OPENSCAN_WORKER_URL = + // biome-ignore lint/complexity/useLiteralKeys: env var access + process.env["REACT_APP_OPENSCAN_WORKER_URL"] ?? + "https://openscan-groq-ai-proxy.openscan.workers.dev"; interface EtherscanSourceResult { SourceCode: string; @@ -81,7 +85,7 @@ export function useEtherscan( const [isVerified, setIsVerified] = useState(false); useEffect(() => { - if (!enabled || !address || !networkId || !apiKey) { + if (!enabled || !address || !networkId) { setData(null); setIsVerified(false); setLoading(false); @@ -93,15 +97,29 @@ export function useEtherscan( const fetchData = async () => { setLoading(true); try { - const params = new URLSearchParams({ - chainid: String(networkId), - module: "contract", - action: "getsourcecode", - address, - apikey: apiKey, - }); - const url = `${ETHERSCAN_V2_API}?${params.toString()}`; - const response = await fetch(url, { signal: controller.signal }); + let response: Response; + + if (apiKey) { + // Direct Etherscan call with user's key + const params = new URLSearchParams({ + chainid: String(networkId), + module: "contract", + action: "getsourcecode", + address, + apikey: apiKey, + }); + response = await fetch(`${ETHERSCAN_V2_API}?${params.toString()}`, { + signal: controller.signal, + }); + } else { + // Proxy through OpenScan Worker (free, no key needed) + response = await fetch(`${OPENSCAN_WORKER_URL}/etherscan/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chainId: networkId, address }), + signal: controller.signal, + }); + } if (!response.ok) { setData(null); diff --git a/src/i18n.ts b/src/i18n.ts index f31fe572..021b03b8 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -12,6 +12,7 @@ import enSettings from "./locales/en/settings.json"; import enTransaction from "./locales/en/transaction.json"; import enTokenDetails from "./locales/en/tokenDetails.json"; import enRpcs from "./locales/en/rpcs.json"; +import enTooltips from "./locales/en/tooltips.json"; import esAddress from "./locales/es/address.json"; import esBlock from "./locales/es/block.json"; @@ -23,6 +24,7 @@ import esSettings from "./locales/es/settings.json"; import esTransaction from "./locales/es/transaction.json"; import esTokenDetails from "./locales/es/tokenDetails.json"; import esRpcs from "./locales/es/rpcs.json"; +import esTooltips from "./locales/es/tooltips.json"; import zhAddress from "./locales/zh/address.json"; import zhBlock from "./locales/zh/block.json"; @@ -33,6 +35,7 @@ import zhNetwork from "./locales/zh/network.json"; import zhSettings from "./locales/zh/settings.json"; import zhTransaction from "./locales/zh/transaction.json"; import zhTokenDetails from "./locales/zh/tokenDetails.json"; +import zhTooltips from "./locales/zh/tooltips.json"; import jaAddress from "./locales/ja/address.json"; import jaBlock from "./locales/ja/block.json"; @@ -43,6 +46,7 @@ import jaNetwork from "./locales/ja/network.json"; import jaSettings from "./locales/ja/settings.json"; import jaTransaction from "./locales/ja/transaction.json"; import jaTokenDetails from "./locales/ja/tokenDetails.json"; +import jaTooltips from "./locales/ja/tooltips.json"; import ptBRAddress from "./locales/pt-BR/address.json"; import ptBRBlock from "./locales/pt-BR/block.json"; @@ -53,6 +57,7 @@ import ptBRNetwork from "./locales/pt-BR/network.json"; import ptBRSettings from "./locales/pt-BR/settings.json"; import ptBRTransaction from "./locales/pt-BR/transaction.json"; import ptBRTokenDetails from "./locales/pt-BR/tokenDetails.json"; +import ptBRTooltips from "./locales/pt-BR/tooltips.json"; export const SUPPORTED_LANGUAGES = [ { code: "en", name: "English" }, @@ -78,6 +83,7 @@ i18n network: enNetwork, tokenDetails: enTokenDetails, rpcs: enRpcs, + tooltips: enTooltips, }, es: { common: esCommon, @@ -90,6 +96,7 @@ i18n network: esNetwork, tokenDetails: esTokenDetails, rpcs: esRpcs, + tooltips: esTooltips, }, zh: { common: zhCommon, @@ -101,6 +108,7 @@ i18n devtools: zhDevtools, network: zhNetwork, tokenDetails: zhTokenDetails, + tooltips: zhTooltips, }, ja: { common: jaCommon, @@ -112,6 +120,7 @@ i18n devtools: jaDevtools, network: jaNetwork, tokenDetails: jaTokenDetails, + tooltips: jaTooltips, }, "pt-BR": { common: ptBRCommon, @@ -123,6 +132,7 @@ i18n devtools: ptBRDevtools, network: ptBRNetwork, tokenDetails: ptBRTokenDetails, + tooltips: ptBRTooltips, }, }, fallbackLng: "en", @@ -137,6 +147,7 @@ i18n "devtools", "network", "rpcs", + "tooltips", ], interpolation: { escapeValue: false, diff --git a/src/i18next.d.ts b/src/i18next.d.ts index 19b600e2..d1122d44 100644 --- a/src/i18next.d.ts +++ b/src/i18next.d.ts @@ -8,6 +8,7 @@ import type settings from "./locales/en/settings.json"; import type transaction from "./locales/en/transaction.json"; import type tokenDetails from "./locales/en/tokenDetails.json"; import type rpcs from "./locales/en/rpcs.json"; +import type tooltips from "./locales/en/tooltips.json"; declare module "i18next" { interface CustomTypeOptions { defaultNS: "common"; @@ -22,6 +23,7 @@ declare module "i18next" { network: typeof network; tokenDetails: typeof tokenDetails; rpcs: typeof rpcs; + tooltips: typeof tooltips; }; } } diff --git a/src/locales/en/common.json b/src/locales/en/common.json index 1313d994..b3b3757c 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -27,7 +27,14 @@ "enableSuperUser": "Enable Super User Mode", "mempool": "Mempool", "txs": "Transactions", - "superUserBadge": "Super User" + "superUserBadge": "Super User", + "tooltipsBeginner": "Tooltips: Beginner", + "tooltipsIntermediate": "Tooltips: Intermediate", + "tooltipsAdvanced": "Tooltips: Advanced", + "tooltipsSwitched": "Tooltip helper level switched to {{level}}", + "tooltipsLevelBeginner": "Beginner", + "tooltipsLevelIntermediate": "Intermediate", + "tooltipsLevelAdvanced": "Advanced" }, "footer": { "about": "About", diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 76fe5df9..d73321aa 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -155,6 +155,21 @@ "endpointLabel": "{{network}} Beacon API", "endpointPlaceholder": "https://your-beacon-node.example.com" }, + "helperTooltips": { + "title": "Helper Tooltips", + "description": "Show contextual help for blockchain data fields.", + "enabled": { + "label": "Show Helper Tooltips", + "description": "Display helper icons next to field labels with brief explanations." + }, + "knowledgeLevel": { + "label": "Knowledge Level", + "description": "Controls how many helper tooltips are shown. Beginner shows all tooltips. Intermediate hides basic fields. Advanced shows only complex and chain-specific tooltips.", + "beginner": "Beginner", + "intermediate": "Intermediate", + "advanced": "Advanced" + } + }, "rpcEndpoints": { "title": "RPC Endpoints", "description": "Configure RPC URLs for each network. Click on a network to expand and configure its endpoints.", diff --git a/src/locales/en/tooltips.json b/src/locales/en/tooltips.json new file mode 100644 index 00000000..10be0d52 --- /dev/null +++ b/src/locales/en/tooltips.json @@ -0,0 +1,161 @@ +{ + "transaction": { + "hash": "A unique identifier for this transaction, generated from its contents.", + "from": "The address that initiated and signed this transaction.", + "status": "Whether the transaction executed successfully or reverted.", + "confirmations": "Blocks mined after this transaction. More confirmations means higher finality.", + "interactedWith": "The contract this transaction called, or the recipient address for simple transfers.", + "transactionFee": "Total fee paid to process this transaction (gas used × gas price). Gas measures computational work on the network, and under EIP-1559 fees include a base fee (burned) plus a priority fee (tip).", + "gasPrice": "Price per unit of gas, in Gwei. Higher gas prices incentivize faster inclusion.", + "gasLimitUsage": "Gas limit is the maximum gas authorized. Gas used is the actual amount consumed.", + "nonce": "Sender's transaction counter. Ensures transactions are processed in order and prevents replay attacks.", + "position": "The index of this transaction within its block. Lower positions were processed first.", + "type": "The transaction format (e.g. Legacy, EIP-1559, EIP-4844). Determines which fee fields apply.", + "decodedInput": "The function call and parameters sent to the contract, decoded into human-readable form.", + "maxFeePerBlobGas": "Maximum price per unit of blob gas the sender is willing to pay for data availability.", + "blobGasPrice": "Actual price per unit of blob gas paid in this block.", + "blobGasUsed": "Amount of blob gas consumed by this transaction's data blobs. Blobs are data containers introduced in EIP-4844 for rollup data availability, priced separately from execution gas.", + "blobCount": "Number of data containers attached to this transaction for rollup data availability.", + "blobVersionedHashes": "Versioned hashes that uniquely identify each data blob committed by this transaction.", + "contractCreated": "The address of the new contract deployed by this transaction.", + "value": "Amount of native currency (e.g. ETH) transferred in this transaction.", + "effectiveGasPrice": "The actual gas price paid after EIP-1559 fee calculation, which may differ from the requested price.", + "l1BlockNumber": "The Ethereum L1 block that anchors this L2 transaction for security.", + "gasUsedForL1": "Gas consumed for posting this transaction's data to Ethereum L1.", + "l1Fee": "Fee paid for data availability on Ethereum L1. L2 networks rely on L1 to publish transaction data for security and verification.", + "l1GasPrice": "The L1 gas price used to calculate the data availability fee component.", + "l1GasUsed": "Estimated L1 gas required to post this transaction's calldata.", + "l1FeeScalar": "A multiplier applied to the L1 fee calculation by the L2 sequencer.", + "txHashColumn": "The unique hash identifying this transaction.", + "blockColumn": "The block number this transaction was included in.", + "methodColumn": "The smart contract function called by this transaction.", + "fromColumn": "The address that sent this transaction.", + "toColumn": "The destination address of this transaction.", + "valueColumn": "The amount of native currency transferred.", + "statusColumn": "Whether the transaction succeeded or failed.", + "callTree": "Trace of all internal calls made during this transaction's execution.", + "gasProfiler": "Breakdown of gas consumption across each internal call.", + "stateChanges": "Blockchain state modifications (balances, nonces, storage) caused by this transaction.", + "balanceChange": "Change in native currency balance for this address caused by the transaction.", + "nonceChange": "Change in transaction count (nonce) for this address.", + "codeDeployed": "New contract bytecode deployed at this address by the transaction.", + "storageChange": "A storage slot in the contract's state that was modified by the transaction." + }, + "block": { + "hash": "A unique fingerprint identifying this block, derived from its contents.", + "transactions": "The number of transactions included and executed in this block.", + "withdrawals": "Validator withdrawals processed in this block, returning staked ETH.", + "feeRecipient": "The address that receives priority fees from transactions in this block.", + "difficulty": "Mining difficulty for this block. Zero on proof-of-stake networks after the Merge. On some L2 networks, this may be a non-zero constant.", + "totalDifficulty": "Cumulative difficulty of the chain up to this block. Legacy field from proof-of-work.", + "size": "The total size of the block data in bytes.", + "extraData": "Arbitrary data set by the block producer. Often contains client or pool identifiers.", + "gasUsed": "Total gas consumed by all transactions in this block.", + "gasLimit": "Maximum gas allowed in this block, defining its capacity.", + "baseFeePerGas": "Minimum gas price for this block, set by the network based on demand (EIP-1559). This base fee is burned.", + "burntFees": "Transaction fees permanently removed from circulation via EIP-1559.", + "blobGasUsed": "Total blob gas consumed by blob transactions in this block.", + "excessBlobGas": "Blob gas above the target, used to calculate the next block's blob base fee.", + "blobCount": "Number of data containers included in this block for rollup data availability.", + "l1BlockNumber": "The Ethereum L1 block number associated with this Arbitrum block.", + "sendCount": "Number of outgoing L2-to-L1 messages sent in this Arbitrum block.", + "sendRoot": "Merkle root of all outgoing L2-to-L1 messages, used for cross-layer verification.", + "parentHash": "Hash of the previous block in the chain, linking blocks together.", + "stateRoot": "Merkle root of the entire blockchain state (a cryptographic summary of all account data) after processing this block.", + "transactionsRoot": "Merkle root of all transactions included in this block.", + "receiptsRoot": "Merkle root of all transaction receipts from this block.", + "withdrawalsRoot": "Merkle root of all validator withdrawals processed in this block.", + "logsBloom": "A bloom filter (probabilistic data structure) for efficiently searching event logs emitted in this block.", + "nonce": "A value used in proof-of-work mining. Always zero after the Merge.", + "mixHash": "A hash used in the proof-of-work algorithm. Replaced by RANDAO value after the Merge.", + "sha3Uncles": "Hash of the uncle blocks list. Always the empty list hash after the Merge.", + "validator": "The validator index that initiated this withdrawal from the beacon chain.", + "finalized": "This block has been finalized by the network's consensus mechanism and cannot be reverted." + }, + "address": { + "verification": "A verified contract has its source code publicly confirmed to match the deployed bytecode.", + "proxyType": "This contract uses an upgradeable proxy pattern, where execution is delegated to a separate implementation contract, allowing upgrades without changing the address.", + "readContract": "Query contract data without spending gas or connecting a wallet. Read-only calls.", + "writeContract": "Send a transaction that changes contract state. Requires a connected wallet and gas.", + "balance": "Native currency balance held by this address.", + "usdValue": "Estimated fiat value based on current market price.", + "nonce": "Number of transactions sent from this address. Used for transaction ordering.", + "eip7702Delegate": "This account delegates its code execution to another address via EIP-7702.", + "implementationAddress": "The contract that contains the actual logic for this proxy.", + "ensName": "Ethereum Name Service (maps human-readable names to addresses) name associated with this address.", + "ensApp": "Application or resolver linked to this ENS name.", + "accountType": "The type of this address. EOA (Externally Owned Account) is a regular wallet controlled by a private key. Contract is code deployed on-chain.", + "contractBytecode": "The compiled EVM bytecode deployed at this address.", + "sourceCode": "The original Solidity/Vyper source code, verified to match the deployed bytecode.", + "rawAbi": "The Application Binary Interface (ABI) defines how to interact with this contract's functions.", + "functions": "Available contract functions that can be called to read data or modify state." + }, + "token": { + "tokenStandard": "The token interface standard (e.g. ERC-20, ERC-721, ERC-1155) that defines how this token behaves.", + "decimals": "Number of decimal places used to display token amounts. Most ERC-20 tokens use 18.", + "totalSupply": "Total number of tokens that have been created for this contract.", + "tokenId": "A unique numeric identifier for this specific token within its collection.", + "owner": "The address that currently holds this NFT.", + "approved": "An address authorized to transfer this specific NFT on behalf of the owner.", + "metadataUri": "The URI where this token's metadata (name, image, attributes) is stored." + }, + "bitcoin": { + "txid": "A unique identifier for this transaction, computed from its serialized data.", + "witnessHash": "Transaction hash including witness (SegWit) data. Differs from TXID for SegWit transactions.", + "block": "The block that includes this transaction.", + "status": "Whether this transaction has been confirmed in a block or is still waiting in the mempool.", + "inputs": "The number of previous outputs being spent. Each input references a UTXO. Bitcoin uses a UTXO model where transactions consume and create discrete outputs.", + "outputs": "The number of new outputs created. Each output locks BTC to an address.", + "fee": "The difference between total inputs and total outputs, paid to the miner.", + "feePerByte": "Fee rate in satoshis per byte of raw transaction data.", + "feePerVByte": "Fee rate in satoshis per virtual byte. The standard fee metric for SegWit transactions.", + "feePerWU": "Fee rate in satoshis per weight unit. The most granular fee metric.", + "size": "Raw transaction size in bytes, including witness data.", + "virtualSize": "Adjusted transaction size that discounts witness data. Used for fee calculation.", + "weight": "Transaction weight in weight units (WU). 1 vByte = 4 WU.", + "coinbase": "Whether this is a coinbase transaction that creates new BTC as a block reward.", + "witness": "Whether this transaction uses Segregated Witness (SegWit) format.", + "rbf": "Replace-By-Fee. If enabled, this transaction can be replaced by one with a higher fee before confirmation.", + "version": "Transaction format version number. Determines which features are available.", + "locktime": "Earliest block height or time when this transaction can be included in a block.", + "blockHash": "The unique hash identifying the block that contains this transaction.", + "minedBy": "The mining pool or entity that produced this block.", + "blockReward": "New BTC created as a reward for mining this block (subsidy + fees).", + "totalFees": "Sum of all transaction fees collected by the miner in this block.", + "feeRate": "Average and median fee rates of transactions in this block.", + "transactions": "Number of transactions included in this block.", + "totalOutput": "Total BTC value of all outputs created in this block.", + "difficulty": "How hard it is to find a valid block hash. Adjusts every 2016 blocks.", + "blockSize": "Total size of the serialized block data.", + "blockWeight": "Block weight in weight units. Maximum is 4,000,000 WU.", + "previousBlock": "Hash of the preceding block, linking this block to the chain.", + "nextBlock": "Hash of the following block, if one exists.", + "coinbaseMessage": "Arbitrary text embedded in the coinbase transaction by the miner.", + "merkleRoot": "Root hash of the Merkle tree (a structure that allows efficient verification) of all transactions in this block.", + "blockVersion": "Block format version. Encodes which consensus rules and soft forks are signaled.", + "bits": "Compact encoding of the target threshold for a valid block hash.", + "blockNonce": "A value miners iterate to find a block hash below the target.", + "coinbaseHex": "Raw hexadecimal data of the coinbase transaction input.", + "address": "A Bitcoin address derived from a public key, used to receive BTC.", + "balance": "Total BTC held by this address across all unspent outputs.", + "totalReceived": "Cumulative BTC received by this address across all transactions.", + "utxos": "Unspent Transaction Outputs. Individual coins available to be spent by this address.", + "txCount": "Total number of transactions involving this address.", + "blockConfirmations": "Blocks mined after this block. More confirmations means the block is more deeply embedded in the chain.", + "confirmations": "Blocks mined after the block containing this transaction. More confirmations means higher finality.", + "inputsColumn": "Previous transaction outputs being spent. Each input references a UTXO (Unspent Transaction Output).", + "outputsColumn": "New outputs created by this transaction. Each output locks BTC to a recipient address." + }, + "network": { + "gasPrice": "Current cost per unit of gas on this network.", + "blockNumber": "The most recently mined block number on this network.", + "syncStatus": "Whether the connected node has fully synced with the network.", + "clientVersion": "The software client and version running on the connected node.", + "protocolVersion": "The Ethereum protocol version supported by the connected node.", + "currencyPrice": "Current market price of this network's native currency.", + "latestBlock": "The most recently produced block on this network." + }, + "settings": { + "knowledgeLevel": "Controls how much explanatory help is shown throughout the explorer." + } +} diff --git a/src/locales/en/transaction.json b/src/locales/en/transaction.json index 900a0197..1a450b5d 100644 --- a/src/locales/en/transaction.json +++ b/src/locales/en/transaction.json @@ -187,6 +187,11 @@ "expandAll": "Expand All", "collapseAll": "Collapse All", "expand": "Expand", - "collapse": "Collapse" + "collapse": "Collapse", + "rawTrace": "Raw Trace", + "rawTraceSteps": "{{count}} opcode steps", + "rawTracePrev": "Prev", + "rawTraceNext": "Next", + "rawTracePage": "Page {{current}}/{{total}} ({{from}}–{{to}} of {{totalSteps}})" } } diff --git a/src/locales/es/common.json b/src/locales/es/common.json index c37a511a..87fadd57 100644 --- a/src/locales/es/common.json +++ b/src/locales/es/common.json @@ -27,7 +27,14 @@ "enableSuperUser": "Activar modo Super Usuario", "mempool": "Mempool", "txs": "Transacciones", - "superUserBadge": "Super Usuario" + "superUserBadge": "Super Usuario", + "tooltipsBeginner": "Tooltips: Principiante", + "tooltipsIntermediate": "Tooltips: Intermedio", + "tooltipsAdvanced": "Tooltips: Avanzado", + "tooltipsSwitched": "Nivel de tooltips cambiado a {{level}}", + "tooltipsLevelBeginner": "Principiante", + "tooltipsLevelIntermediate": "Intermedio", + "tooltipsLevelAdvanced": "Avanzado" }, "footer": { "about": "Acerca de", diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index e3d8d855..d731268a 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -155,6 +155,21 @@ "endpointLabel": "Beacon API de {{network}}", "endpointPlaceholder": "https://tu-nodo-beacon.ejemplo.com" }, + "helperTooltips": { + "title": "Tooltips de Ayuda", + "description": "Mostrar ayuda contextual para campos de datos de blockchain.", + "enabled": { + "label": "Mostrar Tooltips de Ayuda", + "description": "Mostrar íconos de ayuda junto a las etiquetas de campos con explicaciones breves." + }, + "knowledgeLevel": { + "label": "Nivel de Conocimiento", + "description": "Controla cuántos tooltips de ayuda se muestran. Principiante muestra todos. Intermedio oculta campos básicos. Avanzado muestra solo tooltips complejos y específicos de la cadena.", + "beginner": "Principiante", + "intermediate": "Intermedio", + "advanced": "Avanzado" + } + }, "rpcEndpoints": { "title": "Endpoints RPC", "description": "Configurá las URLs RPC para cada red. Hacé click en una red para desplegar y configurar sus endpoints.", diff --git a/src/locales/es/tooltips.json b/src/locales/es/tooltips.json new file mode 100644 index 00000000..68feb29c --- /dev/null +++ b/src/locales/es/tooltips.json @@ -0,0 +1,161 @@ +{ + "transaction": { + "hash": "Un identificador único para esta transacción, generado a partir de su contenido.", + "from": "La dirección que inició y firmó esta transacción.", + "status": "Si la transacción se ejecutó correctamente o fue revertida.", + "confirmations": "Bloques minados después de esta transacción. Más confirmaciones significa mayor finalidad.", + "interactedWith": "El contrato con el que interactuó esta transacción, o la dirección del destinatario en transferencias simples.", + "transactionFee": "Tarifa total pagada para procesar esta transacción (gas usado × precio del gas). El gas mide el trabajo computacional en la red, y bajo EIP-1559 las tarifas incluyen una tarifa base (quemada) más una tarifa de prioridad (propina).", + "gasPrice": "Precio por unidad de gas, en Gwei. Precios más altos incentivan una inclusión más rápida.", + "gasLimitUsage": "El límite de gas es el máximo autorizado. El gas usado es la cantidad real consumida.", + "nonce": "Contador de transacciones del remitente. Asegura que las transacciones se procesen en orden y previene ataques de repetición.", + "position": "El índice de esta transacción dentro de su bloque. Posiciones más bajas se procesaron primero.", + "type": "El formato de la transacción (ej. Legacy, EIP-1559, EIP-4844). Determina qué campos de tarifa aplican.", + "decodedInput": "La llamada a función y parámetros enviados al contrato, decodificados en formato legible.", + "maxFeePerBlobGas": "Precio máximo por unidad de blob gas que el remitente está dispuesto a pagar por disponibilidad de datos.", + "blobGasPrice": "Precio real por unidad de blob gas pagado en este bloque.", + "blobGasUsed": "Cantidad de blob gas consumido por los blobs de datos de esta transacción. Los blobs son contenedores de datos introducidos en EIP-4844 para disponibilidad de datos de rollup, con precio separado del gas de ejecución.", + "blobCount": "Cantidad de contenedores de datos adjuntos a esta transacción para disponibilidad de datos de rollup.", + "blobVersionedHashes": "Hashes versionados que identifican de forma única cada blob de datos comprometido por esta transacción.", + "contractCreated": "La dirección del nuevo contrato desplegado por esta transacción.", + "value": "Cantidad de moneda nativa (ej. ETH) transferida en esta transacción.", + "effectiveGasPrice": "El precio de gas real pagado después del cálculo de tarifa EIP-1559, que puede diferir del precio solicitado.", + "l1BlockNumber": "El bloque de Ethereum L1 que ancla esta transacción L2 para seguridad.", + "gasUsedForL1": "Gas consumido para publicar los datos de esta transacción en Ethereum L1.", + "l1Fee": "Tarifa pagada por disponibilidad de datos en Ethereum L1. Las redes L2 dependen de L1 para publicar datos de transacciones por seguridad y verificación.", + "l1GasPrice": "El precio de gas L1 usado para calcular el componente de tarifa de disponibilidad de datos.", + "l1GasUsed": "Gas L1 estimado requerido para publicar los calldata de esta transacción.", + "l1FeeScalar": "Un multiplicador aplicado al cálculo de tarifa L1 por el secuenciador L2.", + "txHashColumn": "El hash único que identifica esta transacción.", + "blockColumn": "El número de bloque en el que se incluyó esta transacción.", + "methodColumn": "La función del contrato inteligente llamada por esta transacción.", + "fromColumn": "La dirección que envió esta transacción.", + "toColumn": "La dirección de destino de esta transacción.", + "valueColumn": "La cantidad de moneda nativa transferida.", + "statusColumn": "Si la transacción tuvo éxito o falló.", + "callTree": "Rastreo de todas las llamadas internas realizadas durante la ejecución de esta transacción.", + "gasProfiler": "Desglose del consumo de gas en cada llamada interna.", + "stateChanges": "Modificaciones del estado de la blockchain (saldos, nonces, almacenamiento) causadas por esta transacción.", + "balanceChange": "Cambio en el saldo de moneda nativa de esta dirección causado por la transacción.", + "nonceChange": "Cambio en el contador de transacciones (nonce) de esta dirección.", + "codeDeployed": "Nuevo bytecode de contrato desplegado en esta dirección por la transacción.", + "storageChange": "Un slot de almacenamiento en el estado del contrato que fue modificado por la transacción." + }, + "block": { + "hash": "Una huella digital única que identifica este bloque, derivada de su contenido.", + "transactions": "La cantidad de transacciones incluidas y ejecutadas en este bloque.", + "withdrawals": "Retiros de validadores procesados en este bloque, devolviendo ETH stakeado.", + "feeRecipient": "La dirección que recibe las tarifas prioritarias de las transacciones en este bloque.", + "difficulty": "Dificultad de minado para este bloque. Cero en redes proof-of-stake después del Merge. En algunas redes L2, puede ser una constante distinta de cero.", + "totalDifficulty": "Dificultad acumulada de la cadena hasta este bloque. Campo heredado de proof-of-work.", + "size": "El tamaño total de los datos del bloque en bytes.", + "extraData": "Datos arbitrarios establecidos por el productor del bloque. A menudo contiene identificadores de cliente o pool.", + "gasUsed": "Gas total consumido por todas las transacciones en este bloque.", + "gasLimit": "Gas máximo permitido en este bloque, definiendo su capacidad.", + "baseFeePerGas": "Precio mínimo de gas para este bloque, establecido por la red según la demanda (EIP-1559). Esta tarifa base se quema.", + "burntFees": "Tarifas de transacción eliminadas permanentemente de la circulación mediante EIP-1559.", + "blobGasUsed": "Blob gas total consumido por transacciones blob en este bloque.", + "excessBlobGas": "Blob gas por encima del objetivo, usado para calcular la tarifa base blob del siguiente bloque.", + "blobCount": "Cantidad de contenedores de datos incluidos en este bloque para disponibilidad de datos de rollup.", + "l1BlockNumber": "El número de bloque de Ethereum L1 asociado con este bloque de Arbitrum.", + "sendCount": "Cantidad de mensajes salientes L2-a-L1 enviados en este bloque de Arbitrum.", + "sendRoot": "Raíz de Merkle de todos los mensajes salientes L2-a-L1, usada para verificación entre capas.", + "parentHash": "Hash del bloque anterior en la cadena, enlazando los bloques entre sí.", + "stateRoot": "Raíz de Merkle del estado completo de la blockchain (un resumen criptográfico de todos los datos de cuentas) después de procesar este bloque.", + "transactionsRoot": "Raíz de Merkle de todas las transacciones incluidas en este bloque.", + "receiptsRoot": "Raíz de Merkle de todos los recibos de transacciones de este bloque.", + "withdrawalsRoot": "Raíz de Merkle de todos los retiros de validadores procesados en este bloque.", + "logsBloom": "Un filtro bloom (estructura de datos probabilística) para buscar eficientemente logs de eventos emitidos en este bloque.", + "nonce": "Un valor usado en minería proof-of-work. Siempre cero después del Merge.", + "mixHash": "Un hash usado en el algoritmo proof-of-work. Reemplazado por el valor RANDAO después del Merge.", + "sha3Uncles": "Hash de la lista de bloques uncle. Siempre el hash de lista vacía después del Merge.", + "validator": "El índice del validador que inició este retiro desde la beacon chain.", + "finalized": "Este bloque ha sido finalizado por el mecanismo de consenso de la red y no puede ser revertido." + }, + "address": { + "verification": "Un contrato verificado tiene su código fuente confirmado públicamente como coincidente con el bytecode desplegado.", + "proxyType": "Este contrato usa un patrón de proxy actualizable, donde la ejecución se delega a un contrato de implementación separado, permitiendo actualizaciones sin cambiar la dirección.", + "readContract": "Consultar datos del contrato sin gastar gas ni conectar una wallet. Llamadas de solo lectura.", + "writeContract": "Enviar una transacción que cambia el estado del contrato. Requiere una wallet conectada y gas.", + "balance": "Saldo de moneda nativa que posee esta dirección.", + "usdValue": "Valor estimado en moneda fiat basado en el precio de mercado actual.", + "nonce": "Cantidad de transacciones enviadas desde esta dirección. Usado para ordenar transacciones.", + "eip7702Delegate": "Esta cuenta delega la ejecución de su código a otra dirección vía EIP-7702.", + "implementationAddress": "El contrato que contiene la lógica real para este proxy.", + "ensName": "Nombre del Ethereum Name Service (mapea nombres legibles a direcciones) asociado a esta dirección.", + "ensApp": "Aplicación o resolver vinculado a este nombre ENS.", + "accountType": "El tipo de esta dirección. EOA (Cuenta de Propiedad Externa) es una wallet regular controlada por una clave privada. Contrato es código desplegado en la cadena.", + "contractBytecode": "El bytecode EVM compilado desplegado en esta dirección.", + "sourceCode": "El código fuente original en Solidity/Vyper, verificado para coincidir con el bytecode desplegado.", + "rawAbi": "La Interfaz Binaria de Aplicación (ABI) define cómo interactuar con las funciones de este contrato.", + "functions": "Funciones de contrato disponibles que pueden ser llamadas para leer datos o modificar el estado." + }, + "token": { + "tokenStandard": "El estándar de interfaz del token (ej. ERC-20, ERC-721, ERC-1155) que define cómo se comporta.", + "decimals": "Cantidad de decimales usados para mostrar montos del token. La mayoría de tokens ERC-20 usan 18.", + "totalSupply": "Cantidad total de tokens creados para este contrato.", + "tokenId": "Un identificador numérico único para este token específico dentro de su colección.", + "owner": "La dirección que actualmente posee este NFT.", + "approved": "Una dirección autorizada para transferir este NFT específico en nombre del propietario.", + "metadataUri": "La URI donde se almacenan los metadatos de este token (nombre, imagen, atributos)." + }, + "bitcoin": { + "txid": "Un identificador único para esta transacción, calculado a partir de sus datos serializados.", + "witnessHash": "Hash de transacción incluyendo datos witness (SegWit). Difiere del TXID para transacciones SegWit.", + "block": "El bloque que incluye esta transacción.", + "status": "Si esta transacción fue confirmada en un bloque o sigue esperando en el mempool.", + "inputs": "La cantidad de salidas previas que se gastan. Cada entrada referencia un UTXO. Bitcoin usa un modelo UTXO donde las transacciones consumen y crean salidas discretas.", + "outputs": "La cantidad de nuevas salidas creadas. Cada salida bloquea BTC a una dirección.", + "fee": "La diferencia entre entradas y salidas totales, pagada al minero.", + "feePerByte": "Tasa de tarifa en satoshis por byte de datos crudos de transacción.", + "feePerVByte": "Tasa de tarifa en satoshis por byte virtual. La métrica estándar para transacciones SegWit.", + "feePerWU": "Tasa de tarifa en satoshis por unidad de peso. La métrica de tarifa más granular.", + "size": "Tamaño crudo de la transacción en bytes, incluyendo datos witness.", + "virtualSize": "Tamaño ajustado que descuenta datos witness. Usado para calcular tarifas.", + "weight": "Peso de la transacción en unidades de peso (WU). 1 vByte = 4 WU.", + "coinbase": "Si esta es una transacción coinbase que crea nuevos BTC como recompensa de bloque.", + "witness": "Si esta transacción usa el formato Segregated Witness (SegWit).", + "rbf": "Replace-By-Fee. Si está habilitado, esta transacción puede ser reemplazada por una con mayor tarifa antes de la confirmación.", + "version": "Número de versión del formato de transacción. Determina qué funciones están disponibles.", + "locktime": "La altura de bloque o momento más temprano en que esta transacción puede incluirse en un bloque.", + "blockHash": "El hash único que identifica el bloque que contiene esta transacción.", + "minedBy": "El pool de minería o entidad que produjo este bloque.", + "blockReward": "Nuevos BTC creados como recompensa por minar este bloque (subsidio + tarifas).", + "totalFees": "Suma de todas las tarifas de transacción cobradas por el minero en este bloque.", + "feeRate": "Tasas de tarifa promedio y mediana de las transacciones en este bloque.", + "transactions": "Cantidad de transacciones incluidas en este bloque.", + "totalOutput": "Valor total de BTC de todas las salidas creadas en este bloque.", + "difficulty": "Qué tan difícil es encontrar un hash de bloque válido. Se ajusta cada 2016 bloques.", + "blockSize": "Tamaño total de los datos serializados del bloque.", + "blockWeight": "Peso del bloque en unidades de peso. Máximo: 4.000.000 WU.", + "previousBlock": "Hash del bloque precedente, enlazando este bloque a la cadena.", + "nextBlock": "Hash del bloque siguiente, si existe.", + "coinbaseMessage": "Texto arbitrario incrustado en la transacción coinbase por el minero.", + "merkleRoot": "Hash raíz del árbol de Merkle (una estructura que permite verificación eficiente) de todas las transacciones en este bloque.", + "blockVersion": "Versión del formato de bloque. Codifica qué reglas de consenso y soft forks se señalizan.", + "bits": "Codificación compacta del umbral objetivo para un hash de bloque válido.", + "blockNonce": "Un valor que los mineros iteran para encontrar un hash de bloque por debajo del objetivo.", + "coinbaseHex": "Datos hexadecimales crudos de la entrada de la transacción coinbase.", + "address": "Una dirección Bitcoin derivada de una clave pública, usada para recibir BTC.", + "balance": "BTC total que posee esta dirección en todas las salidas sin gastar.", + "totalReceived": "BTC acumulados recibidos por esta dirección en todas las transacciones.", + "utxos": "Salidas de Transacción No Gastadas. Monedas individuales disponibles para gastar por esta dirección.", + "txCount": "Cantidad total de transacciones que involucran esta dirección.", + "blockConfirmations": "Bloques minados después de este bloque. Más confirmaciones significa que el bloque está más profundamente integrado en la cadena.", + "confirmations": "Bloques minados después del bloque que contiene esta transacción. Más confirmaciones significa mayor finalidad.", + "inputsColumn": "Salidas de transacciones previas siendo gastadas. Cada entrada referencia un UTXO (Salida de Transacción No Gastada).", + "outputsColumn": "Nuevas salidas creadas por esta transacción. Cada salida bloquea BTC a una dirección receptora." + }, + "network": { + "gasPrice": "Costo actual por unidad de gas en esta red.", + "blockNumber": "El número de bloque más reciente minado en esta red.", + "syncStatus": "Si el nodo conectado se ha sincronizado completamente con la red.", + "clientVersion": "El software cliente y versión que ejecuta el nodo conectado.", + "protocolVersion": "La versión del protocolo Ethereum soportada por el nodo conectado.", + "currencyPrice": "Precio de mercado actual de la moneda nativa de esta red.", + "latestBlock": "El bloque más reciente producido en esta red." + }, + "settings": { + "knowledgeLevel": "Controla cuánta ayuda explicativa se muestra en el explorador." + } +} diff --git a/src/locales/es/transaction.json b/src/locales/es/transaction.json index e6b13eb2..35c891c1 100644 --- a/src/locales/es/transaction.json +++ b/src/locales/es/transaction.json @@ -187,6 +187,11 @@ "expandAll": "Expandir Todo", "collapseAll": "Colapsar Todo", "expand": "Expandir", - "collapse": "Colapsar" + "collapse": "Colapsar", + "rawTrace": "Traza Cruda", + "rawTraceSteps": "{{count}} pasos de opcode", + "rawTracePrev": "Anterior", + "rawTraceNext": "Siguiente", + "rawTracePage": "Página {{current}}/{{total}} ({{from}}–{{to}} de {{totalSteps}})" } } diff --git a/src/locales/ja/common.json b/src/locales/ja/common.json index d444404e..b11fc25c 100644 --- a/src/locales/ja/common.json +++ b/src/locales/ja/common.json @@ -26,7 +26,14 @@ "disableSuperUser": "スーパーユーザーモードを無効化", "enableSuperUser": "スーパーユーザーモードを有効化", "mempool": "メモリープール", - "txs": "トランザクション" + "txs": "トランザクション", + "tooltipsBeginner": "ツールチップ:初心者", + "tooltipsIntermediate": "ツールチップ:中級者", + "tooltipsAdvanced": "ツールチップ:上級者", + "tooltipsSwitched": "ツールチップヘルパーレベルが{{level}}に切り替わりました", + "tooltipsLevelBeginner": "初心者", + "tooltipsLevelIntermediate": "中級者", + "tooltipsLevelAdvanced": "上級者" }, "footer": { "about": "概要", diff --git a/src/locales/ja/settings.json b/src/locales/ja/settings.json index 6118e6a2..6a1f1c0e 100644 --- a/src/locales/ja/settings.json +++ b/src/locales/ja/settings.json @@ -155,6 +155,21 @@ "endpointLabel": "{{network}} Beacon API", "endpointPlaceholder": "https://your-beacon-node.example.com" }, + "helperTooltips": { + "title": "ヘルパーツールチップ", + "description": "ブロックチェーンデータフィールドのコンテキストヘルプを表示します。", + "enabled": { + "label": "ヘルパーツールチップを表示", + "description": "フィールドラベルの横に簡単な説明付きのヘルプアイコンを表示します。" + }, + "knowledgeLevel": { + "label": "知識レベル", + "description": "ヘルパーツールチップの表示数を制御します。初心者はすべて表示。中級者は基本フィールドを非表示。上級者は複雑なチェーン固有のツールチップのみ表示。", + "beginner": "初心者", + "intermediate": "中級者", + "advanced": "上級者" + } + }, "rpcEndpoints": { "title": "RPCエンドポイント", "description": "各ネットワークのRPC URLを設定します。ネットワークをクリックして展開し、エンドポイントを設定してください。", diff --git a/src/locales/ja/tooltips.json b/src/locales/ja/tooltips.json new file mode 100644 index 00000000..47efb91f --- /dev/null +++ b/src/locales/ja/tooltips.json @@ -0,0 +1,161 @@ +{ + "transaction": { + "hash": "このトランザクションの一意の識別子。内容から生成されます。", + "from": "このトランザクションを開始し署名したアドレス。", + "status": "トランザクションが正常に実行されたか、リバートされたか。", + "confirmations": "このトランザクション後にマイニングされたブロック数。確認数が多いほどファイナリティが高い。", + "interactedWith": "このトランザクションが呼び出したコントラクト、または単純な送金の受取アドレス。", + "transactionFee": "このトランザクションの処理に支払われた合計手数料(使用ガス × ガス価格)。ガスはネットワーク上の計算作業を測定し、EIP-1559ではベース手数料(バーン)とプライオリティ手数料(チップ)が含まれます。", + "gasPrice": "ガス1単位あたりの価格(Gwei単位)。高いガス価格はより早い取り込みを促進します。", + "gasLimitUsage": "ガスリミットは承認された最大ガス量。使用ガスは実際に消費された量。", + "nonce": "送信者のトランザクションカウンター。トランザクションが順番に処理されることを保証し、リプレイ攻撃を防止します。", + "position": "ブロック内のこのトランザクションのインデックス。低い位置が先に処理されます。", + "type": "トランザクション形式(例:Legacy、EIP-1559、EIP-4844)。適用される手数料フィールドを決定します。", + "decodedInput": "コントラクトに送信された関数呼び出しとパラメータを人間が読める形式にデコードしたもの。", + "maxFeePerBlobGas": "送信者がデータ可用性のために支払うBlobガス1単位あたりの最高価格。", + "blobGasPrice": "このブロックで支払われたBlobガス1単位あたりの実際の価格。", + "blobGasUsed": "このトランザクションのデータBlobが消費したBlobガスの量。Blobはロールアップデータ可用性のためにEIP-4844で導入されたデータコンテナで、実行ガスとは別に価格設定されます。", + "blobCount": "ロールアップデータ可用性のためにこのトランザクションに添付されたデータコンテナの数。", + "blobVersionedHashes": "このトランザクションがコミットした各データBlobを一意に識別するバージョン付きハッシュ。", + "contractCreated": "このトランザクションによってデプロイされた新しいコントラクトのアドレス。", + "value": "このトランザクションで送金されたネイティブ通貨(例:ETH)の金額。", + "effectiveGasPrice": "EIP-1559の手数料計算後に実際に支払われたガス価格。リクエストした価格と異なる場合があります。", + "l1BlockNumber": "このL2トランザクションのセキュリティを担保するEthereum L1ブロック。", + "gasUsedForL1": "このトランザクションのデータをEthereum L1に投稿するために消費されたガス。", + "l1Fee": "Ethereum L1でのデータ可用性に支払われた手数料。L2ネットワークはセキュリティと検証のためにL1にトランザクションデータを公開することに依存しています。", + "l1GasPrice": "データ可用性手数料の計算に使用されたL1ガス価格。", + "l1GasUsed": "このトランザクションのcalldataを投稿するために必要な推定L1ガス。", + "l1FeeScalar": "L2シーケンサーがL1手数料計算に適用する乗数。", + "txHashColumn": "このトランザクションを識別する一意のハッシュ。", + "blockColumn": "このトランザクションが含まれたブロック番号。", + "methodColumn": "このトランザクションが呼び出したスマートコントラクト関数。", + "fromColumn": "このトランザクションを送信したアドレス。", + "toColumn": "このトランザクションの宛先アドレス。", + "valueColumn": "転送されたネイティブ通貨の量。", + "statusColumn": "トランザクションが成功したか失敗したか。", + "callTree": "このトランザクション実行中に行われたすべての内部呼び出しのトレース。", + "gasProfiler": "各内部呼び出しにおけるガス消費の内訳。", + "stateChanges": "このトランザクションによって引き起こされたブロックチェーン状態の変更(残高、ノンス、ストレージ)。", + "balanceChange": "トランザクションによるこのアドレスのネイティブ通貨残高の変化。", + "nonceChange": "このアドレスのトランザクションカウント(ノンス)の変化。", + "codeDeployed": "トランザクションによってこのアドレスにデプロイされた新しいコントラクトバイトコード。", + "storageChange": "トランザクションによって変更されたコントラクト状態のストレージスロット。" + }, + "block": { + "hash": "このブロックを識別する一意のフィンガープリント。内容から導出されます。", + "transactions": "このブロックに含まれ実行されたトランザクションの数。", + "withdrawals": "このブロックで処理されたバリデーターの引き出し。ステークされたETHを返却します。", + "feeRecipient": "このブロック内のトランザクションから優先手数料を受け取るアドレス。", + "difficulty": "このブロックのマイニング難易度。マージ後のProof of Stakeネットワークではゼロ。一部のL2ネットワークでは、ゼロでない定数の場合があります。", + "totalDifficulty": "このブロックまでのチェーンの累積難易度。Proof of Workのレガシーフィールド。", + "size": "ブロックデータの合計サイズ(バイト)。", + "extraData": "ブロック生成者が設定した任意のデータ。クライアントやプールの識別子が含まれることが多い。", + "gasUsed": "このブロック内の全トランザクションで消費された合計ガス。", + "gasLimit": "このブロックで許可される最大ガス量。ブロックの容量を定義します。", + "baseFeePerGas": "このブロックの最低ガス価格。ネットワークの需要に基づいて設定されます(EIP-1559)。このベース手数料はバーンされます。", + "burntFees": "EIP-1559により流通から永久に除去されたトランザクション手数料。", + "blobGasUsed": "このブロック内のBlobトランザクションが消費した合計Blobガス。", + "excessBlobGas": "目標を超えたBlobガス。次のブロックのBlob基本手数料の計算に使用されます。", + "blobCount": "ロールアップデータ可用性のためにこのブロックに含まれるデータコンテナの数。", + "l1BlockNumber": "このArbitrumブロックに関連するEthereum L1ブロック番号。", + "sendCount": "このArbitrumブロックで送信されたL2からL1への送信メッセージの数。", + "sendRoot": "すべてのL2からL1への送信メッセージのMerkleルート。クロスレイヤー検証に使用されます。", + "parentHash": "チェーン内の前のブロックのハッシュ。ブロック同士をリンクします。", + "stateRoot": "このブロックの処理後のブロックチェーン全体の状態(すべてのアカウントデータの暗号化サマリー)のMerkleルート。", + "transactionsRoot": "このブロックに含まれるすべてのトランザクションのMerkleルート。", + "receiptsRoot": "このブロックのすべてのトランザクションレシートのMerkleルート。", + "withdrawalsRoot": "このブロックで処理されたすべてのバリデーター引き出しのMerkleルート。", + "logsBloom": "このブロックで発行されたイベントログを効率的に検索するためのブルームフィルター(確率的データ構造)。", + "nonce": "Proof of Workマイニングで使用される値。マージ後は常にゼロ。", + "mixHash": "Proof of Workアルゴリズムで使用されるハッシュ。マージ後はRANDAO値に置き換えられました。", + "sha3Uncles": "アンクルブロックリストのハッシュ。マージ後は常に空リストのハッシュ。", + "validator": "ビーコンチェーンからこの引き出しを開始したバリデーターインデックス。", + "finalized": "このブロックはネットワークのコンセンサスメカニズムによってファイナライズされ、取り消すことはできません。" + }, + "address": { + "verification": "検証済みコントラクトは、ソースコードがデプロイされたバイトコードと一致することが公開確認されています。", + "proxyType": "このコントラクトはアップグレード可能なプロキシパターンを使用しており、実行は別の実装コントラクトに委任され、アドレスを変更せずにアップグレードが可能です。", + "readContract": "ガスを消費せず、ウォレットを接続せずにコントラクトデータを照会。読み取り専用の呼び出し。", + "writeContract": "コントラクトの状態を変更するトランザクションを送信。接続されたウォレットとガスが必要です。", + "balance": "このアドレスが保有するネイティブ通貨の残高。", + "usdValue": "現在の市場価格に基づく推定法定通貨価値。", + "nonce": "このアドレスから送信されたトランザクション数。トランザクションの順序付けに使用されます。", + "eip7702Delegate": "このアカウントはEIP-7702を介してコード実行を別のアドレスに委任しています。", + "implementationAddress": "このプロキシの実際のロジックを含むコントラクト。", + "ensName": "このアドレスに関連付けられたEthereum Name Service(人間が読める名前をアドレスにマッピング)の名前。", + "ensApp": "このENS名前にリンクされたアプリケーションまたはリゾルバー。", + "accountType": "このアドレスの種類。EOA(外部所有アカウント)は秘密鍵で制御される通常のウォレット。コントラクトはチェーン上にデプロイされたコードです。", + "contractBytecode": "このアドレスにデプロイされたコンパイル済みEVMバイトコード。", + "sourceCode": "デプロイされたバイトコードと一致することが検証された、元のSolidity/Vyperソースコード。", + "rawAbi": "アプリケーションバイナリインターフェース(ABI)は、このコントラクトの関数との対話方法を定義します。", + "functions": "データの読み取りまたは状態の変更のために呼び出し可能なコントラクト関数。" + }, + "token": { + "tokenStandard": "トークンのインターフェース標準(ERC-20、ERC-721、ERC-1155など)。トークンの動作を定義します。", + "decimals": "トークン量の表示に使用される小数点以下の桁数。ほとんどのERC-20トークンは18桁を使用します。", + "totalSupply": "このコントラクトで作成されたトークンの総数。", + "tokenId": "コレクション内のこの特定のトークンを識別する一意の数値。", + "owner": "現在このNFTを保有しているアドレス。", + "approved": "所有者に代わってこの特定のNFTを転送する権限を持つアドレス。", + "metadataUri": "このトークンのメタデータ(名前、画像、属性)が保存されているURI。" + }, + "bitcoin": { + "txid": "このトランザクションの一意の識別子。シリアライズされたデータから計算されます。", + "witnessHash": "ウィットネス(SegWit)データを含むトランザクションハッシュ。SegWitトランザクションではTXIDと異なります。", + "block": "このトランザクションを含むブロック。", + "status": "このトランザクションがブロックで確認されたか、まだメモリプールで待機中か。", + "inputs": "使用される以前の出力の数。各入力はUTXOを参照します。BitcoinはUTXOモデルを使用し、トランザクションは個別の出力を消費・作成します。", + "outputs": "作成される新しい出力の数。各出力はBTCをアドレスにロックします。", + "fee": "入力合計と出力合計の差額。マイナーに支払われます。", + "feePerByte": "生のトランザクションデータ1バイトあたりの手数料率(satoshi)。", + "feePerVByte": "仮想バイトあたりの手数料率(satoshi)。SegWitトランザクションの標準的な手数料指標。", + "feePerWU": "重量単位あたりの手数料率(satoshi)。最も細かい手数料指標。", + "size": "ウィットネスデータを含む生のトランザクションサイズ(バイト)。", + "virtualSize": "ウィットネスデータを割引した調整済みトランザクションサイズ。手数料計算に使用。", + "weight": "トランザクションの重量(重量単位WU)。1 vByte = 4 WU。", + "coinbase": "これがブロック報酬として新しいBTCを作成するコインベーストランザクションかどうか。", + "witness": "このトランザクションがSegregated Witness(SegWit)フォーマットを使用しているかどうか。", + "rbf": "Replace-By-Fee。有効な場合、確認前により高い手数料のトランザクションで置き換え可能。", + "version": "トランザクションフォーマットのバージョン番号。利用可能な機能を決定します。", + "locktime": "このトランザクションがブロックに含められる最も早いブロック高さまたは時刻。", + "blockHash": "このトランザクションを含むブロックを識別する一意のハッシュ。", + "minedBy": "このブロックを生成したマイニングプールまたはエンティティ。", + "blockReward": "このブロックのマイニング報酬として作成された新しいBTC(補助金+手数料)。", + "totalFees": "このブロックでマイナーが徴収した全トランザクション手数料の合計。", + "feeRate": "このブロック内のトランザクションの平均および中央値の手数料率。", + "transactions": "このブロックに含まれるトランザクションの数。", + "totalOutput": "このブロックで作成されたすべての出力のBTC総額。", + "difficulty": "有効なブロックハッシュを見つける難しさ。2016ブロックごとに調整。", + "blockSize": "シリアライズされたブロックデータの合計サイズ。", + "blockWeight": "ブロックの重量(重量単位)。最大4,000,000 WU。", + "previousBlock": "前のブロックのハッシュ。このブロックをチェーンにリンクします。", + "nextBlock": "次のブロックのハッシュ(存在する場合)。", + "coinbaseMessage": "マイナーがコインベーストランザクションに埋め込んだ任意のテキスト。", + "merkleRoot": "このブロック内の全トランザクションのMerkleツリー(効率的な検証を可能にする構造)のルートハッシュ。", + "blockVersion": "ブロックフォーマットのバージョン。どのコンセンサスルールとソフトフォークがシグナルされるかをエンコード。", + "bits": "有効なブロックハッシュのターゲット閾値のコンパクトエンコーディング。", + "blockNonce": "マイナーがターゲット以下のブロックハッシュを見つけるために反復する値。", + "coinbaseHex": "コインベーストランザクション入力の生の16進数データ。", + "address": "公開鍵から導出されたBitcoinアドレス。BTCの受け取りに使用。", + "balance": "このアドレスが全未使用出力にわたって保有するBTC合計。", + "totalReceived": "このアドレスが全トランザクションにわたって受け取った累計BTC。", + "utxos": "未使用トランザクション出力。このアドレスが使用可能な個別のコイン。", + "txCount": "このアドレスに関連するトランザクションの総数。", + "blockConfirmations": "このブロック後にマイニングされたブロック数。確認数が多いほどブロックがチェーンに深く埋め込まれています。", + "confirmations": "このトランザクションを含むブロック後にマイニングされたブロック数。確認数が多いほどファイナリティが高い。", + "inputsColumn": "使用される以前のトランザクション出力。各入力はUTXO(未使用トランザクション出力)を参照します。", + "outputsColumn": "このトランザクションによって作成された新しい出力。各出力は受取アドレスにBTCをロックします。" + }, + "network": { + "gasPrice": "このネットワークのガス1単位あたりの現在のコスト。", + "blockNumber": "このネットワークで最も最近マイニングされたブロック番号。", + "syncStatus": "接続されたノードがネットワークと完全に同期しているかどうか。", + "clientVersion": "接続されたノードで実行されているソフトウェアクライアントとバージョン。", + "protocolVersion": "接続されたノードがサポートするEthereumプロトコルバージョン。", + "currencyPrice": "このネットワークのネイティブ通貨の現在の市場価格。", + "latestBlock": "このネットワークで最も最近生成されたブロック。" + }, + "settings": { + "knowledgeLevel": "エクスプローラー全体で表示される説明ヘルプの量を制御します。" + } +} diff --git a/src/locales/ja/transaction.json b/src/locales/ja/transaction.json index c8f9e3da..56efee86 100644 --- a/src/locales/ja/transaction.json +++ b/src/locales/ja/transaction.json @@ -187,6 +187,11 @@ "expandAll": "すべて展開", "collapseAll": "すべて折りたたむ", "expand": "展開", - "collapse": "折りたたむ" + "collapse": "折りたたむ", + "rawTrace": "生トレース", + "rawTraceSteps": "{{count}} オペコードステップ", + "rawTracePrev": "前へ", + "rawTraceNext": "次へ", + "rawTracePage": "ページ {{current}}/{{total}} ({{from}}–{{to}} / {{totalSteps}})" } } diff --git a/src/locales/pt-BR/common.json b/src/locales/pt-BR/common.json index 63f1eae8..e73d46f5 100644 --- a/src/locales/pt-BR/common.json +++ b/src/locales/pt-BR/common.json @@ -26,7 +26,14 @@ "disableSuperUser": "Desativar Modo Super Usuário", "enableSuperUser": "Ativar Modo Super Usuário", "mempool": "Mempool", - "txs": "Transações" + "txs": "Transações", + "tooltipsBeginner": "Dicas: Iniciante", + "tooltipsIntermediate": "Dicas: Intermediário", + "tooltipsAdvanced": "Dicas: Avançado", + "tooltipsSwitched": "Nível de dicas alterado para {{level}}", + "tooltipsLevelBeginner": "Iniciante", + "tooltipsLevelIntermediate": "Intermediário", + "tooltipsLevelAdvanced": "Avançado" }, "footer": { "about": "Sobre", diff --git a/src/locales/pt-BR/settings.json b/src/locales/pt-BR/settings.json index a9dc48ae..8571ef2e 100644 --- a/src/locales/pt-BR/settings.json +++ b/src/locales/pt-BR/settings.json @@ -155,6 +155,21 @@ "endpointLabel": "Beacon API {{network}}", "endpointPlaceholder": "https://seu-no-beacon.exemplo.com" }, + "helperTooltips": { + "title": "Dicas de Ajuda", + "description": "Mostrar ajuda contextual para campos de dados blockchain.", + "enabled": { + "label": "Mostrar Dicas de Ajuda", + "description": "Exibir ícones de ajuda ao lado dos rótulos com explicações breves." + }, + "knowledgeLevel": { + "label": "Nível de Conhecimento", + "description": "Controla quantas dicas de ajuda são exibidas. Iniciante mostra todas. Intermediário oculta campos básicos. Avançado mostra apenas dicas complexas e específicas da cadeia.", + "beginner": "Iniciante", + "intermediate": "Intermediário", + "advanced": "Avançado" + } + }, "rpcEndpoints": { "title": "Endpoints RPC", "description": "Configure URLs RPC para cada rede. Clique em uma rede para expandir e configurar seus endpoints.", diff --git a/src/locales/pt-BR/tooltips.json b/src/locales/pt-BR/tooltips.json new file mode 100644 index 00000000..757fc5b7 --- /dev/null +++ b/src/locales/pt-BR/tooltips.json @@ -0,0 +1,161 @@ +{ + "transaction": { + "hash": "Um identificador único para esta transação, gerado a partir de seu conteúdo.", + "from": "O endereço que iniciou e assinou esta transação.", + "status": "Se a transação foi executada com sucesso ou revertida.", + "confirmations": "Blocos minerados após esta transação. Mais confirmações significa maior finalidade.", + "interactedWith": "O contrato com o qual esta transação interagiu, ou o endereço do destinatário em transferências simples.", + "transactionFee": "Taxa total paga para processar esta transação (gas usado × preço do gas). O gas mede o trabalho computacional na rede, e sob o EIP-1559 as taxas incluem uma taxa base (queimada) mais uma taxa de prioridade (gorjeta).", + "gasPrice": "Preço por unidade de gas, em Gwei. Preços mais altos incentivam inclusão mais rápida.", + "gasLimitUsage": "O limite de gas é o máximo autorizado. O gas usado é a quantidade real consumida.", + "nonce": "Contador de transações do remetente. Garante que as transações sejam processadas em ordem e previne ataques de repetição.", + "position": "O índice desta transação dentro do seu bloco. Posições mais baixas foram processadas primeiro.", + "type": "O formato da transação (ex: Legacy, EIP-1559, EIP-4844). Determina quais campos de taxa se aplicam.", + "decodedInput": "A chamada de função e parâmetros enviados ao contrato, decodificados em formato legível.", + "maxFeePerBlobGas": "Preço máximo por unidade de blob gas que o remetente está disposto a pagar pela disponibilidade de dados.", + "blobGasPrice": "Preço real por unidade de blob gas pago neste bloco.", + "blobGasUsed": "Quantidade de blob gas consumido pelos blobs de dados desta transação. Blobs são contêineres de dados introduzidos no EIP-4844 para disponibilidade de dados de rollup, com preço separado do gas de execução.", + "blobCount": "Número de contêineres de dados anexados a esta transação para disponibilidade de dados de rollup.", + "blobVersionedHashes": "Hashes versionados que identificam exclusivamente cada blob de dados comprometido por esta transação.", + "contractCreated": "O endereço do novo contrato implantado por esta transação.", + "value": "Quantidade de moeda nativa (ex: ETH) transferida nesta transação.", + "effectiveGasPrice": "O preço de gas realmente pago após o cálculo de taxa EIP-1559, que pode diferir do preço solicitado.", + "l1BlockNumber": "O bloco Ethereum L1 que ancora esta transação L2 para segurança.", + "gasUsedForL1": "Gas consumido para publicar os dados desta transação no Ethereum L1.", + "l1Fee": "Taxa paga pela disponibilidade de dados no Ethereum L1. Redes L2 dependem da L1 para publicar dados de transações por segurança e verificação.", + "l1GasPrice": "O preço de gas L1 usado para calcular o componente de taxa de disponibilidade de dados.", + "l1GasUsed": "Gas L1 estimado necessário para publicar o calldata desta transação.", + "l1FeeScalar": "Um multiplicador aplicado ao cálculo da taxa L1 pelo sequenciador L2.", + "txHashColumn": "O hash único que identifica esta transação.", + "blockColumn": "O número do bloco em que esta transação foi incluída.", + "methodColumn": "A função do contrato inteligente chamada por esta transação.", + "fromColumn": "O endereço que enviou esta transação.", + "toColumn": "O endereço de destino desta transação.", + "valueColumn": "A quantidade de moeda nativa transferida.", + "statusColumn": "Se a transação teve sucesso ou falhou.", + "callTree": "Rastreamento de todas as chamadas internas feitas durante a execução desta transação.", + "gasProfiler": "Detalhamento do consumo de gas em cada chamada interna.", + "stateChanges": "Modificações no estado da blockchain (saldos, nonces, armazenamento) causadas por esta transação.", + "balanceChange": "Mudança no saldo de moeda nativa deste endereço causada pela transação.", + "nonceChange": "Mudança na contagem de transações (nonce) deste endereço.", + "codeDeployed": "Novo bytecode de contrato implantado neste endereço pela transação.", + "storageChange": "Um slot de armazenamento no estado do contrato que foi modificado pela transação." + }, + "block": { + "hash": "Uma impressão digital única que identifica este bloco, derivada de seu conteúdo.", + "transactions": "O número de transações incluídas e executadas neste bloco.", + "withdrawals": "Saques de validadores processados neste bloco, retornando ETH em stake.", + "feeRecipient": "O endereço que recebe as taxas prioritárias das transações neste bloco.", + "difficulty": "Dificuldade de mineração para este bloco. Zero em redes proof-of-stake após o Merge. Em algumas redes L2, pode ser uma constante diferente de zero.", + "totalDifficulty": "Dificuldade acumulada da cadeia até este bloco. Campo legado de proof-of-work.", + "size": "O tamanho total dos dados do bloco em bytes.", + "extraData": "Dados arbitrários definidos pelo produtor do bloco. Frequentemente contém identificadores de cliente ou pool.", + "gasUsed": "Gas total consumido por todas as transações neste bloco.", + "gasLimit": "Gas máximo permitido neste bloco, definindo sua capacidade.", + "baseFeePerGas": "Preço mínimo de gas para este bloco, definido pela rede com base na demanda (EIP-1559). Esta taxa base é queimada.", + "burntFees": "Taxas de transação permanentemente removidas de circulação via EIP-1559.", + "blobGasUsed": "Blob gas total consumido por transações blob neste bloco.", + "excessBlobGas": "Blob gas acima do alvo, usado para calcular a taxa base blob do próximo bloco.", + "blobCount": "Número de contêineres de dados incluídos neste bloco para disponibilidade de dados de rollup.", + "l1BlockNumber": "O número do bloco Ethereum L1 associado a este bloco Arbitrum.", + "sendCount": "Número de mensagens de saída L2-para-L1 enviadas neste bloco Arbitrum.", + "sendRoot": "Raiz de Merkle de todas as mensagens de saída L2-para-L1, usada para verificação entre camadas.", + "parentHash": "Hash do bloco anterior na cadeia, ligando os blocos entre si.", + "stateRoot": "Raiz de Merkle de todo o estado da blockchain (um resumo criptográfico de todos os dados de contas) após processar este bloco.", + "transactionsRoot": "Raiz de Merkle de todas as transações incluídas neste bloco.", + "receiptsRoot": "Raiz de Merkle de todos os recibos de transações deste bloco.", + "withdrawalsRoot": "Raiz de Merkle de todos os saques de validadores processados neste bloco.", + "logsBloom": "Um filtro bloom (estrutura de dados probabilística) para buscar eficientemente logs de eventos emitidos neste bloco.", + "nonce": "Um valor usado na mineração proof-of-work. Sempre zero após o Merge.", + "mixHash": "Um hash usado no algoritmo proof-of-work. Substituído pelo valor RANDAO após o Merge.", + "sha3Uncles": "Hash da lista de blocos uncle. Sempre o hash de lista vazia após o Merge.", + "validator": "O índice do validador que iniciou esta retirada da beacon chain.", + "finalized": "Este bloco foi finalizado pelo mecanismo de consenso da rede e não pode ser revertido." + }, + "address": { + "verification": "Um contrato verificado tem seu código-fonte publicamente confirmado como correspondente ao bytecode implantado.", + "proxyType": "Este contrato usa um padrão de proxy atualizável, onde a execução é delegada a um contrato de implementação separado, permitindo atualizações sem alterar o endereço.", + "readContract": "Consultar dados do contrato sem gastar gas ou conectar uma carteira. Chamadas somente leitura.", + "writeContract": "Enviar uma transação que altera o estado do contrato. Requer uma carteira conectada e gas.", + "balance": "Saldo de moeda nativa mantido por este endereço.", + "usdValue": "Valor fiduciário estimado com base no preço de mercado atual.", + "nonce": "Número de transações enviadas a partir deste endereço. Usado para ordenação de transações.", + "eip7702Delegate": "Esta conta delega a execução de seu código a outro endereço via EIP-7702.", + "implementationAddress": "O contrato que contém a lógica real deste proxy.", + "ensName": "Nome do Ethereum Name Service (mapeia nomes legíveis para endereços) associado a este endereço.", + "ensApp": "Aplicação ou resolvedor vinculado a este nome ENS.", + "accountType": "O tipo deste endereço. EOA (Conta de Propriedade Externa) é uma carteira regular controlada por uma chave privada. Contrato é código implantado na cadeia.", + "contractBytecode": "O bytecode EVM compilado implantado neste endereço.", + "sourceCode": "O código-fonte original em Solidity/Vyper, verificado para corresponder ao bytecode implantado.", + "rawAbi": "A Interface Binária de Aplicação (ABI) define como interagir com as funções deste contrato.", + "functions": "Funções de contrato disponíveis que podem ser chamadas para ler dados ou modificar o estado." + }, + "token": { + "tokenStandard": "O padrão de interface do token (ex: ERC-20, ERC-721, ERC-1155) que define como este token se comporta.", + "decimals": "Número de casas decimais usadas para exibir quantidades do token. A maioria dos tokens ERC-20 usa 18.", + "totalSupply": "Número total de tokens criados para este contrato.", + "tokenId": "Um identificador numérico único para este token específico dentro de sua coleção.", + "owner": "O endereço que atualmente possui este NFT.", + "approved": "Um endereço autorizado a transferir este NFT específico em nome do proprietário.", + "metadataUri": "A URI onde os metadados deste token (nome, imagem, atributos) estão armazenados." + }, + "bitcoin": { + "txid": "Um identificador único para esta transação, calculado a partir de seus dados serializados.", + "witnessHash": "Hash da transação incluindo dados witness (SegWit). Difere do TXID para transações SegWit.", + "block": "O bloco que inclui esta transação.", + "status": "Se esta transação foi confirmada em um bloco ou ainda está esperando no mempool.", + "inputs": "O número de saídas anteriores sendo gastas. Cada entrada referencia um UTXO. Bitcoin usa um modelo UTXO onde transações consomem e criam saídas discretas.", + "outputs": "O número de novas saídas criadas. Cada saída bloqueia BTC para um endereço.", + "fee": "A diferença entre entradas e saídas totais, paga ao minerador.", + "feePerByte": "Taxa de tarifa em satoshis por byte de dados brutos da transação.", + "feePerVByte": "Taxa de tarifa em satoshis por byte virtual. A métrica padrão para transações SegWit.", + "feePerWU": "Taxa de tarifa em satoshis por unidade de peso. A métrica de tarifa mais granular.", + "size": "Tamanho bruto da transação em bytes, incluindo dados witness.", + "virtualSize": "Tamanho ajustado que desconta dados witness. Usado para cálculo de taxas.", + "weight": "Peso da transação em unidades de peso (WU). 1 vByte = 4 WU.", + "coinbase": "Se esta é uma transação coinbase que cria novos BTC como recompensa de bloco.", + "witness": "Se esta transação usa o formato Segregated Witness (SegWit).", + "rbf": "Replace-By-Fee. Se habilitado, esta transação pode ser substituída por uma com taxa maior antes da confirmação.", + "version": "Número de versão do formato da transação. Determina quais recursos estão disponíveis.", + "locktime": "Altura de bloco ou momento mais cedo em que esta transação pode ser incluída em um bloco.", + "blockHash": "O hash único que identifica o bloco que contém esta transação.", + "minedBy": "O pool de mineração ou entidade que produziu este bloco.", + "blockReward": "Novos BTC criados como recompensa por minerar este bloco (subsídio + taxas).", + "totalFees": "Soma de todas as taxas de transação coletadas pelo minerador neste bloco.", + "feeRate": "Taxas de tarifa média e mediana das transações neste bloco.", + "transactions": "Número de transações incluídas neste bloco.", + "totalOutput": "Valor total de BTC de todas as saídas criadas neste bloco.", + "difficulty": "Quão difícil é encontrar um hash de bloco válido. Ajusta a cada 2016 blocos.", + "blockSize": "Tamanho total dos dados serializados do bloco.", + "blockWeight": "Peso do bloco em unidades de peso. Máximo: 4.000.000 WU.", + "previousBlock": "Hash do bloco anterior, ligando este bloco à cadeia.", + "nextBlock": "Hash do bloco seguinte, se existir.", + "coinbaseMessage": "Texto arbitrário incorporado na transação coinbase pelo minerador.", + "merkleRoot": "Hash raiz da árvore de Merkle (uma estrutura que permite verificação eficiente) de todas as transações neste bloco.", + "blockVersion": "Versão do formato do bloco. Codifica quais regras de consenso e soft forks são sinalizados.", + "bits": "Codificação compacta do limiar alvo para um hash de bloco válido.", + "blockNonce": "Um valor que mineradores iteram para encontrar um hash de bloco abaixo do alvo.", + "coinbaseHex": "Dados hexadecimais brutos da entrada da transação coinbase.", + "address": "Um endereço Bitcoin derivado de uma chave pública, usado para receber BTC.", + "balance": "BTC total mantido por este endereço em todas as saídas não gastas.", + "totalReceived": "BTC cumulativo recebido por este endereço em todas as transações.", + "utxos": "Saídas de Transação Não Gastas. Moedas individuais disponíveis para gasto por este endereço.", + "txCount": "Número total de transações envolvendo este endereço.", + "blockConfirmations": "Blocos minerados após este bloco. Mais confirmações significa que o bloco está mais profundamente incorporado na cadeia.", + "confirmations": "Blocos minerados após o bloco que contém esta transação. Mais confirmações significa maior finalidade.", + "inputsColumn": "Saídas de transações anteriores sendo gastas. Cada entrada referencia um UTXO (Saída de Transação Não Gasta).", + "outputsColumn": "Novas saídas criadas por esta transação. Cada saída bloqueia BTC para um endereço receptor." + }, + "network": { + "gasPrice": "Custo atual por unidade de gas nesta rede.", + "blockNumber": "O número do bloco mais recentemente minerado nesta rede.", + "syncStatus": "Se o nó conectado sincronizou completamente com a rede.", + "clientVersion": "O software cliente e versão executando no nó conectado.", + "protocolVersion": "A versão do protocolo Ethereum suportada pelo nó conectado.", + "currencyPrice": "Preço de mercado atual da moeda nativa desta rede.", + "latestBlock": "O bloco mais recentemente produzido nesta rede." + }, + "settings": { + "knowledgeLevel": "Controla quanta ajuda explicativa é exibida no explorador." + } +} diff --git a/src/locales/pt-BR/transaction.json b/src/locales/pt-BR/transaction.json index c977c2a2..c2a9e0c0 100644 --- a/src/locales/pt-BR/transaction.json +++ b/src/locales/pt-BR/transaction.json @@ -187,6 +187,11 @@ "expandAll": "Expandir Tudo", "collapseAll": "Recolher Tudo", "expand": "Expandir", - "collapse": "Recolher" + "collapse": "Recolher", + "rawTrace": "Trace Bruto", + "rawTraceSteps": "{{count}} passos de opcode", + "rawTracePrev": "Anterior", + "rawTraceNext": "Próximo", + "rawTracePage": "Página {{current}}/{{total}} ({{from}}–{{to}} de {{totalSteps}})" } } diff --git a/src/locales/zh/common.json b/src/locales/zh/common.json index 188bbfae..201b44af 100644 --- a/src/locales/zh/common.json +++ b/src/locales/zh/common.json @@ -26,7 +26,14 @@ "disableSuperUser": "禁用超级用户模式", "enableSuperUser": "启用超级用户模式", "mempool": "内存池", - "txs": "交易" + "txs": "交易", + "tooltipsBeginner": "提示:初学者", + "tooltipsIntermediate": "提示:中级", + "tooltipsAdvanced": "提示:高级", + "tooltipsSwitched": "提示帮助级别已切换为{{level}}", + "tooltipsLevelBeginner": "初学者", + "tooltipsLevelIntermediate": "中级", + "tooltipsLevelAdvanced": "高级" }, "footer": { "about": "关于", diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index ac1e8f75..be6e0353 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -155,6 +155,21 @@ "endpointLabel": "{{network}} Beacon API", "endpointPlaceholder": "https://your-beacon-node.example.com" }, + "helperTooltips": { + "title": "帮助提示", + "description": "为区块链数据字段显示上下文帮助。", + "enabled": { + "label": "显示帮助提示", + "description": "在字段标签旁显示帮助图标和简要说明。" + }, + "knowledgeLevel": { + "label": "知识水平", + "description": "控制显示多少帮助提示。初学者显示所有提示。中级隐藏基本字段。高级仅显示复杂和链特定的提示。", + "beginner": "初学者", + "intermediate": "中级", + "advanced": "高级" + } + }, "rpcEndpoints": { "title": "RPC 端点", "description": "为每个网络配置 RPC URL。点击网络展开并配置其端点。", diff --git a/src/locales/zh/tooltips.json b/src/locales/zh/tooltips.json new file mode 100644 index 00000000..7a0deba2 --- /dev/null +++ b/src/locales/zh/tooltips.json @@ -0,0 +1,161 @@ +{ + "transaction": { + "hash": "此交易的唯一标识符,由其内容生成。", + "from": "发起并签署此交易的地址。", + "status": "交易是否成功执行或已回滚。", + "confirmations": "此交易之后挖出的区块数。更多确认意味着更高的最终性。", + "interactedWith": "此交易调用的合约,或简单转账的接收地址。", + "transactionFee": "处理此交易支付的总费用(使用的Gas × Gas价格)。Gas衡量网络上的计算工作,在EIP-1559下费用包括基础费用(销毁)加上优先费用(小费)。", + "gasPrice": "每单位Gas的价格,以Gwei为单位。更高的Gas价格可以更快被打包。", + "gasLimitUsage": "Gas限制是授权的最大Gas量。已用Gas是实际消耗的量。", + "nonce": "发送者的交易计数器。确保交易按顺序处理并防止重放攻击。", + "position": "此交易在区块中的索引。较低的位置优先处理。", + "type": "交易格式(如Legacy、EIP-1559、EIP-4844)。决定适用哪些费用字段。", + "decodedInput": "发送给合约的函数调用和参数,解码为可读格式。", + "maxFeePerBlobGas": "发送者愿意为数据可用性支付的每单位Blob Gas最高价格。", + "blobGasPrice": "此区块中实际支付的每单位Blob Gas价格。", + "blobGasUsed": "此交易的数据Blob消耗的Blob Gas量。Blob是EIP-4844中引入的用于Rollup数据可用性的数据容器,与执行Gas分开定价。", + "blobCount": "附加到此交易的数据容器数量,用于Rollup数据可用性。", + "blobVersionedHashes": "唯一标识此交易提交的每个数据Blob的版本化哈希。", + "contractCreated": "此交易部署的新合约地址。", + "value": "此交易中转移的原生货币(如ETH)金额。", + "effectiveGasPrice": "经EIP-1559费用计算后实际支付的Gas价格,可能与请求的价格不同。", + "l1BlockNumber": "锚定此L2交易安全性的以太坊L1区块。", + "gasUsedForL1": "将此交易数据发布到以太坊L1所消耗的Gas。", + "l1Fee": "在以太坊L1上为数据可用性支付的费用。L2网络依赖L1发布交易数据以确保安全性和可验证性。", + "l1GasPrice": "用于计算数据可用性费用的L1 Gas价格。", + "l1GasUsed": "发布此交易calldata所需的预估L1 Gas。", + "l1FeeScalar": "L2排序器应用于L1费用计算的乘数。", + "txHashColumn": "标识此交易的唯一哈希。", + "blockColumn": "此交易被包含在的区块号。", + "methodColumn": "此交易调用的智能合约函数。", + "fromColumn": "发送此交易的地址。", + "toColumn": "此交易的目标地址。", + "valueColumn": "转移的原生货币数量。", + "statusColumn": "交易是否成功或失败。", + "callTree": "此交易执行期间所有内部调用的跟踪。", + "gasProfiler": "每个内部调用的Gas消耗详细分解。", + "stateChanges": "此交易导致的区块链状态修改(余额、nonce、存储)。", + "balanceChange": "交易导致此地址原生货币余额的变化。", + "nonceChange": "此地址的交易计数(nonce)变化。", + "codeDeployed": "交易在此地址部署的新合约字节码。", + "storageChange": "合约状态中被交易修改的存储槽。" + }, + "block": { + "hash": "标识此区块的唯一指纹,由其内容派生。", + "transactions": "此区块中包含和执行的交易数量。", + "withdrawals": "此区块中处理的验证者提款,返回质押的ETH。", + "feeRecipient": "接收此区块中交易优先费用的地址。", + "difficulty": "此区块的挖矿难度。在合并后的权益证明网络上为零。在某些L2网络上,可能是非零常数。", + "totalDifficulty": "到此区块为止的链累积难度。工作量证明的遗留字段。", + "size": "区块数据的总大小(字节)。", + "extraData": "区块生产者设置的任意数据。通常包含客户端或矿池标识符。", + "gasUsed": "此区块中所有交易消耗的总Gas。", + "gasLimit": "此区块允许的最大Gas量,定义其容量。", + "baseFeePerGas": "此区块的最低Gas价格,由网络根据需求设定(EIP-1559)。此基础费用被销毁。", + "burntFees": "通过EIP-1559永久从流通中移除的交易费用。", + "blobGasUsed": "此区块中Blob交易消耗的总Blob Gas。", + "excessBlobGas": "超出目标的Blob Gas,用于计算下一个区块的Blob基础费用。", + "blobCount": "此区块中包含的数据容器数量,用于Rollup数据可用性。", + "l1BlockNumber": "与此Arbitrum区块关联的以太坊L1区块号。", + "sendCount": "此Arbitrum区块中发送的L2到L1出站消息数量。", + "sendRoot": "所有L2到L1出站消息的Merkle根,用于跨层验证。", + "parentHash": "链中前一个区块的哈希,将区块链接在一起。", + "stateRoot": "处理此区块后整个区块链状态(所有账户数据的加密摘要)的Merkle根。", + "transactionsRoot": "此区块中包含的所有交易的Merkle根。", + "receiptsRoot": "此区块中所有交易收据的Merkle根。", + "withdrawalsRoot": "此区块中处理的所有验证者提款的Merkle根。", + "logsBloom": "用于高效搜索此区块中发出的事件日志的布隆过滤器(概率数据结构)。", + "nonce": "工作量证明挖矿中使用的值。合并后始终为零。", + "mixHash": "工作量证明算法中使用的哈希。合并后被RANDAO值替代。", + "sha3Uncles": "叔块列表的哈希。合并后始终为空列表哈希。", + "validator": "从信标链发起此提款的验证者索引。", + "finalized": "此区块已被网络的共识机制最终确认,无法被撤销。" + }, + "address": { + "verification": "已验证的合约其源代码已被公开确认与部署的字节码匹配。", + "proxyType": "此合约使用可升级代理模式,执行被委托给单独的实现合约,允许在不更改地址的情况下进行升级。", + "readContract": "查询合约数据,无需消耗Gas或连接钱包。只读调用。", + "writeContract": "发送改变合约状态的交易。需要连接钱包并消耗Gas。", + "balance": "此地址持有的原生货币余额。", + "usdValue": "基于当前市场价格的预估法币价值。", + "nonce": "从此地址发送的交易数量。用于交易排序。", + "eip7702Delegate": "此账户通过EIP-7702将其代码执行委托给另一个地址。", + "implementationAddress": "包含此代理实际逻辑的合约。", + "ensName": "与此地址关联的以太坊名称服务(将人类可读名称映射到地址)名称。", + "ensApp": "与此ENS名称关联的应用或解析器。", + "accountType": "此地址的类型。EOA(外部拥有账户)是由私钥控制的普通钱包。合约是部署在链上的代码。", + "contractBytecode": "部署在此地址的编译后EVM字节码。", + "sourceCode": "经验证与部署字节码匹配的原始Solidity/Vyper源代码。", + "rawAbi": "应用程序二进制接口(ABI)定义了如何与此合约的函数进行交互。", + "functions": "可调用的合约函数,用于读取数据或修改状态。" + }, + "token": { + "tokenStandard": "代币接口标准(如ERC-20、ERC-721、ERC-1155),定义了代币的行为方式。", + "decimals": "用于显示代币数量的小数位数。大多数ERC-20代币使用18位小数。", + "totalSupply": "为此合约创建的代币总数。", + "tokenId": "此特定代币在其集合中的唯一数字标识符。", + "owner": "当前持有此NFT的地址。", + "approved": "被授权代表所有者转移此特定NFT的地址。", + "metadataUri": "存储此代币元数据(名称、图片、属性)的URI。" + }, + "bitcoin": { + "txid": "此交易的唯一标识符,由其序列化数据计算得出。", + "witnessHash": "包含见证(SegWit)数据的交易哈希。对于SegWit交易与TXID不同。", + "block": "包含此交易的区块。", + "status": "此交易是否已在区块中确认,或仍在内存池中等待。", + "inputs": "正在花费的先前输出数量。每个输入引用一个UTXO。比特币使用UTXO模型,交易消耗和创建离散输出。", + "outputs": "创建的新输出数量。每个输出将BTC锁定到一个地址。", + "fee": "总输入与总输出之间的差额,支付给矿工。", + "feePerByte": "每字节原始交易数据的费率(聪)。", + "feePerVByte": "每虚拟字节的费率(聪)。SegWit交易的标准费率指标。", + "feePerWU": "每重量单位的费率(聪)。最精细的费率指标。", + "size": "原始交易大小(字节),包括见证数据。", + "virtualSize": "折扣见证数据后的调整交易大小。用于费用计算。", + "weight": "交易重量(重量单位WU)。1 vByte = 4 WU。", + "coinbase": "这是否是创建新BTC作为区块奖励的coinbase交易。", + "witness": "此交易是否使用隔离见证(SegWit)格式。", + "rbf": "费用替换。如果启用,此交易可在确认前被更高费用的交易替换。", + "version": "交易格式版本号。决定可用的功能。", + "locktime": "此交易可被包含在区块中的最早区块高度或时间。", + "blockHash": "标识包含此交易的区块的唯一哈希。", + "minedBy": "产生此区块的矿池或实体。", + "blockReward": "挖出此区块的奖励新BTC(补贴+费用)。", + "totalFees": "矿工在此区块中收取的所有交易费用总和。", + "feeRate": "此区块中交易的平均和中位费率。", + "transactions": "此区块中包含的交易数量。", + "totalOutput": "此区块中创建的所有输出的BTC总值。", + "difficulty": "找到有效区块哈希的难度。每2016个区块调整一次。", + "blockSize": "序列化区块数据的总大小。", + "blockWeight": "区块重量(重量单位)。最大4,000,000 WU。", + "previousBlock": "前一个区块的哈希,将此区块链接到链上。", + "nextBlock": "下一个区块的哈希(如果存在)。", + "coinbaseMessage": "矿工在coinbase交易中嵌入的任意文本。", + "merkleRoot": "此区块中所有交易的Merkle树(允许高效验证的结构)根哈希。", + "blockVersion": "区块格式版本。编码信号哪些共识规则和软分叉。", + "bits": "有效区块哈希目标阈值的紧凑编码。", + "blockNonce": "矿工迭代以找到低于目标的区块哈希的值。", + "coinbaseHex": "coinbase交易输入的原始十六进制数据。", + "address": "从公钥派生的比特币地址,用于接收BTC。", + "balance": "此地址在所有未花费输出中持有的BTC总量。", + "totalReceived": "此地址在所有交易中累计收到的BTC。", + "utxos": "未花费交易输出。此地址可用于花费的单个币。", + "txCount": "涉及此地址的交易总数。", + "blockConfirmations": "此区块后挖出的区块数。更多确认意味着区块在链中嵌入更深。", + "confirmations": "包含此交易的区块后挖出的区块数。更多确认意味着更高的最终性。", + "inputsColumn": "正在花费的先前交易输出。每个输入引用一个UTXO(未花费交易输出)。", + "outputsColumn": "此交易创建的新输出。每个输出将BTC锁定到接收地址。" + }, + "network": { + "gasPrice": "此网络每单位Gas的当前成本。", + "blockNumber": "此网络上最近挖出的区块号。", + "syncStatus": "连接的节点是否已与网络完全同步。", + "clientVersion": "连接节点上运行的软件客户端和版本。", + "protocolVersion": "连接节点支持的以太坊协议版本。", + "currencyPrice": "此网络原生货币的当前市场价格。", + "latestBlock": "此网络上最近产生的区块。" + }, + "settings": { + "knowledgeLevel": "控制整个浏览器中显示的帮助说明数量。" + } +} diff --git a/src/locales/zh/transaction.json b/src/locales/zh/transaction.json index 24170fab..36722021 100644 --- a/src/locales/zh/transaction.json +++ b/src/locales/zh/transaction.json @@ -187,6 +187,11 @@ "expandAll": "全部展开", "collapseAll": "全部折叠", "expand": "展开", - "collapse": "折叠" + "collapse": "折叠", + "rawTrace": "原始追踪", + "rawTraceSteps": "{{count}} 操作码步骤", + "rawTracePrev": "上一页", + "rawTraceNext": "下一页", + "rawTracePage": "第 {{current}}/{{total}} 页 ({{from}}–{{to}} / {{totalSteps}})" } } diff --git a/src/services/adapters/BitcoinAdapter/BitcoinAdapter.ts b/src/services/adapters/BitcoinAdapter/BitcoinAdapter.ts index e44b86ca..3eb59478 100644 --- a/src/services/adapters/BitcoinAdapter/BitcoinAdapter.ts +++ b/src/services/adapters/BitcoinAdapter/BitcoinAdapter.ts @@ -593,8 +593,22 @@ export class BitcoinAdapter { } } + const tx = this.transformTransaction(txData); + + // Fetch block height from block header when we have a blockhash + if (tx.blockhash) { + try { + const headerResult = await this.client.getBlockHeader(tx.blockhash, true); + if (headerResult.data?.height !== undefined) { + tx.blockheight = headerResult.data.height; + } + } catch { + // Block header not available, continue without height + } + } + return { - data: this.transformTransaction(txData), + data: tx, metadata: metadata as DataWithMetadata["metadata"], }; } diff --git a/src/services/adapters/EVMAdapter/EVMAdapter.ts b/src/services/adapters/EVMAdapter/EVMAdapter.ts index 905571a6..f42f8b0c 100644 --- a/src/services/adapters/EVMAdapter/EVMAdapter.ts +++ b/src/services/adapters/EVMAdapter/EVMAdapter.ts @@ -12,7 +12,8 @@ import { import { normalizeBlockNumber } from "../shared/normalizeBlockNumber"; import { mergeMetadata } from "../shared/mergeMetadata"; -import type { EthereumClient, SupportedChainId } from "@openscan/network-connectors"; +import type { EthereumClient } from "@openscan/network-connectors"; +import type { AppChainId } from "../../../types"; import { getRethClient, NONCE_LOOKUP_CHAIN_ID } from "../../../config/rethProviders"; import { NonceLookupService } from "../../NonceLookupService"; @@ -23,7 +24,7 @@ import { NonceLookupService } from "../../NonceLookupService"; export class EVMAdapter extends NetworkAdapter { private client: EthereumClient; - constructor(networkId: SupportedChainId | 11155111 | 97 | 31337, client: EthereumClient) { + constructor(networkId: AppChainId, client: EthereumClient) { super(networkId); this.client = client; diff --git a/src/services/adapters/HardhatAdapter/HardhatAdapter.ts b/src/services/adapters/HardhatAdapter/HardhatAdapter.ts new file mode 100644 index 00000000..680d6c3b --- /dev/null +++ b/src/services/adapters/HardhatAdapter/HardhatAdapter.ts @@ -0,0 +1,339 @@ +import { type BlockNumberOrTag, NetworkAdapter, type TraceResult } from "../NetworkAdapter"; +import type { Block, Transaction, Address, NetworkStats, DataWithMetadata } from "../../../types"; +import type { CallNode, PrestateTrace } from "../NetworkAdapter"; +import { + buildCallTreeFromStructLogs, + buildPrestateFromStructLogs, +} from "../../../utils/structLogConverter"; +import { logger } from "../../../utils/logger"; +import { + transformRPCBlockToBlock, + transformRPCTransactionToTransaction, + createAddressFromBalance, + hexToNumber, +} from "../EVMAdapter/utils"; + +import { normalizeBlockNumber } from "../shared/normalizeBlockNumber"; +import { mergeMetadata } from "../shared/mergeMetadata"; +import type { EthereumClient, HardhatClient } from "@openscan/network-connectors"; + +/** + * Hardhat local development network adapter + * Chain ID: 31337 + * Uses HardhatClient which supports standard Ethereum methods + * plus Hardhat-specific methods (hardhat_*, evm_*, debug_*, trace_*) + */ +export class HardhatAdapter extends NetworkAdapter { + private client: HardhatClient; + + constructor(client: HardhatClient) { + super(31337); + this.client = client; + this.initTxSearch(client as unknown as EthereumClient); + } + + protected getClient(): EthereumClient { + return this.client as unknown as EthereumClient; + } + + /** + * Get the typed HardhatClient for Hardhat-specific operations + */ + getHardhatClient(): HardhatClient { + return this.client; + } + + async getBlock(blockNumber: BlockNumberOrTag): Promise> { + const normalizedBlockNumber = normalizeBlockNumber(blockNumber); + const result = await this.client.getBlockByNumber(normalizedBlockNumber); + + const blockData = result.data; + if (!blockData) { + throw new Error(`Block ${blockNumber} not found`); + } + + const block = transformRPCBlockToBlock(blockData); + + return { + data: block, + metadata: result.metadata as DataWithMetadata["metadata"], + }; + } + + async getBlockWithTransactions( + blockNumber: BlockNumberOrTag, + ): Promise { + const normalizedBlockNumber = normalizeBlockNumber(blockNumber); + const result = await this.client.getBlockByNumber(normalizedBlockNumber, true); + + const blockData = result.data; + if (!blockData) { + throw new Error(`Block ${blockNumber} not found`); + } + + const block = transformRPCBlockToBlock(blockData); + + const transactionDetails: Transaction[] = []; + if (Array.isArray(blockData.transactions)) { + for (const tx of blockData.transactions) { + if (typeof tx !== "string") { + transactionDetails.push(transformRPCTransactionToTransaction(tx)); + } + } + } + + return { + ...block, + transactionDetails, + }; + } + + async getTransaction(txHash: string): Promise> { + const [txResult, receiptResult] = await Promise.all([ + this.client.getTransactionByHash(txHash), + this.client.getTransactionReceipt(txHash), + ]); + + const txData = txResult.data; + if (!txData) { + throw new Error(`Transaction ${txHash} not found`); + } + + const receiptData = receiptResult.data; + const transaction = transformRPCTransactionToTransaction(txData, receiptData); + + if (txData.blockNumber) { + try { + const blockResult = await this.getBlock(txData.blockNumber); + + if (blockResult.data) { + transaction.timestamp = blockResult.data.timestamp; + transaction.blockBaseFeePerGas = blockResult.data.baseFeePerGas; + } + } catch (error) { + logger.warn("Failed to fetch block for transaction timestamp:", error); + } + } + + return { + data: transaction, + metadata: txResult.metadata as DataWithMetadata["metadata"], + }; + } + + async getAddress(address: string): Promise> { + const [balanceResult, codeResult, txCountResult] = await Promise.all([ + this.client.getBalance(address, "latest"), + this.client.getCode(address, "latest"), + this.client.getTransactionCount(address, "latest"), + ]); + + const balance = balanceResult.data || "0x0"; + const code = codeResult.data || "0x"; + const txCount = txCountResult.data || "0x0"; + + const addressData = createAddressFromBalance(address, balance, code, txCount); + + return { + data: addressData, + metadata: balanceResult.metadata as DataWithMetadata
    ["metadata"], + }; + } + + async getLatestBlockNumber(): Promise { + const result = await this.client.blockNumber(); + const blockNumber = result.data || "0x0"; + return hexToNumber(blockNumber); + } + + async getNetworkStats(): Promise> { + const [gasPriceResult, syncingResult, blockNumberResult, versionResult] = await Promise.all([ + this.client.gasPrice(), + this.client.syncing(), + this.client.blockNumber(), + this.client.clientVersion(), + ]); + + const gasPrice = gasPriceResult.data || "0x0"; + const syncing = syncingResult.data; + const blockNumber = blockNumberResult.data || "0x0"; + const clientVersion = versionResult.data || "unknown"; + + const stats: NetworkStats = { + currentGasPrice: gasPrice, + isSyncing: typeof syncing === "boolean" ? syncing : true, + currentBlockNumber: blockNumber, + clientVersion: clientVersion, + metadata: {}, + }; + + return { + data: stats, + metadata: gasPriceResult.metadata as DataWithMetadata["metadata"], + }; + } + + async getLatestBlocks(count = 10): Promise { + const latestBlockNumber = await this.getLatestBlockNumber(); + const blocks: Block[] = []; + + const promises = []; + for (let i = 0; i < count; i++) { + const blockNum = latestBlockNumber - i; + if (blockNum >= 0) { + promises.push(this.getBlock(blockNum)); + } + } + + const results = await Promise.all(promises); + for (const result of results) { + blocks.push(result.data); + } + + return blocks; + } + + async getTransactionsFromLatestBlocks( + blockCount = 10, + ): Promise> { + const latestBlockNumber = await this.getLatestBlockNumber(); + const result = await this.getTransactionsFromBlockRange(latestBlockNumber, blockCount); + return result.data; + } + + async getTransactionsFromBlockRange( + fromBlock: number, + blockCount = 10, + maxTransactions?: number, + ): Promise>> { + const transactions: Array = []; + const metadataList: Array["metadata"]> = []; + + for (let i = 0; i < blockCount; i++) { + if (maxTransactions && transactions.length >= maxTransactions) break; + + const blockNum = fromBlock - i; + if (blockNum < 0) break; + + try { + const blockResult = await this.getBlock(blockNum); + const blockWithTxs = await this.getBlockWithTransactions(blockNum); + + metadataList.push(blockResult.metadata); + + for (const tx of blockWithTxs.transactionDetails) { + transactions.push({ + ...tx, + blockNumber: blockWithTxs.number, + }); + } + } catch (error) { + logger.error(`Error fetching block ${blockNum}:`, error); + } + } + + const mergedMetadata = + mergeMetadata>(metadataList); + + return { + data: transactions, + metadata: mergedMetadata, + }; + } + + getChainId(): number { + return this.networkId; + } + + isTraceAvailable(): boolean { + return true; + } + + async getTransactionTrace(txHash: string): Promise { + try { + const result = await this.client.debugTraceTransaction(txHash, {}); + return result.data; + } catch (error) { + logger.error("Error getting transaction trace:", error); + return null; + } + } + + // biome-ignore lint/suspicious/noExplicitAny: Generic trace result + async getCallTrace(txHash: string): Promise { + try { + const result = await this.client.traceTransaction(txHash); + return result.data; + } catch (error) { + logger.error("Error getting call trace:", error); + return null; + } + } + + async getBlockTrace(blockHash: string): Promise { + try { + const blockResult = await this.client.getBlockByHash(blockHash, false); + const blockData = blockResult.data; + if (!blockData) return null; + + const blockNumber = hexToNumber(blockData.number); + const result = await this.client.traceBlock(blockNumber); + return result.data; + } catch (error) { + logger.error("Error getting block trace:", error); + return null; + } + } + + async getAnalyserCallTrace(txHash: string): Promise { + try { + // Hardhat v3 does not support callTracer — use default struct log tracer + // and convert the opcode trace into a call tree. + const [traceResult, txResult] = await Promise.all([ + this.client.debugTraceTransaction(txHash, {}), + this.client.getTransactionByHash(txHash), + ]); + + const trace = traceResult.data as TraceResult | undefined; + const txData = txResult.data; + if (!trace?.structLogs || !txData) return null; + + return buildCallTreeFromStructLogs(trace, { + from: txData.from ?? "", + to: txData.to ?? "", + value: txData.value ?? "0x0", + gas: txData.gas ?? "0x0", + input: txData.input ?? "0x", + }); + } catch (error) { + logger.error("Error getting analyser call trace:", error); + return null; + } + } + + async getAnalyserPrestateTrace(txHash: string): Promise { + try { + // Hardhat v3 does not support prestateTracer — use default struct log tracer + // and extract storage changes from SLOAD/SSTORE operations. + const [traceResult, txResult] = await Promise.all([ + this.client.debugTraceTransaction(txHash, {}), + this.client.getTransactionByHash(txHash), + ]); + + const trace = traceResult.data as TraceResult | undefined; + const txData = txResult.data; + if (!trace?.structLogs || !txData) return null; + + return buildPrestateFromStructLogs(trace, { + from: txData.from ?? "", + to: txData.to ?? "", + value: txData.value ?? "0x0", + gas: txData.gas ?? "0x0", + input: txData.input ?? "0x", + }); + } catch (error) { + logger.error("Error getting analyser prestate trace:", error); + return null; + } + } +} diff --git a/src/services/adapters/NetworkAdapter.ts b/src/services/adapters/NetworkAdapter.ts index 5d2f5211..007a3743 100644 --- a/src/services/adapters/NetworkAdapter.ts +++ b/src/services/adapters/NetworkAdapter.ts @@ -1,5 +1,6 @@ -import type { SupportedChainId, EthereumClient } from "@openscan/network-connectors"; +import type { EthereumClient } from "@openscan/network-connectors"; import type { + AppChainId, Block, Transaction, Address, @@ -77,7 +78,7 @@ export abstract class NetworkAdapter { isLocalHost: boolean; protected txSearch: AddressTransactionSearch | null = null; - constructor(networkId: SupportedChainId | 31337 | 11155111 | 97) { + constructor(networkId: AppChainId) { this.networkId = networkId; this.isLocalHost = networkId === 31337; } diff --git a/src/services/adapters/adaptersFactory.ts b/src/services/adapters/adaptersFactory.ts index dd98c2f7..956d85a1 100644 --- a/src/services/adapters/adaptersFactory.ts +++ b/src/services/adapters/adaptersFactory.ts @@ -5,14 +5,17 @@ import { BaseAdapter } from "./BaseAdapter/BaseAdapter"; import { BNBAdapter } from "./BNBAdapter/BNBAdapter"; import { PolygonAdapter } from "./PolygonAdapter/PolygonAdapter"; import { ArbitrumAdapter } from "./ArbitrumAdapter/ArbitrumAdapter"; +import { HardhatAdapter } from "./HardhatAdapter/HardhatAdapter"; import { BitcoinAdapter } from "./BitcoinAdapter/BitcoinAdapter"; import type { ArbitrumClient, + AvalancheClient, AztecClient, BaseClient, BitcoinClient, BNBClient, EthereumClient, + HardhatClient, OptimismClient, PolygonClient, SupportedChainId, @@ -24,7 +27,7 @@ export class AdapterFactory { * Create an EVM network adapter */ static createAdapter( - networkId: SupportedChainId | 11155111 | 97 | 31337, + networkId: SupportedChainId, client: | EthereumClient | OptimismClient @@ -32,13 +35,17 @@ export class AdapterFactory { | PolygonClient | BaseClient | ArbitrumClient - | AztecClient, + | AvalancheClient + | AztecClient + | HardhatClient, ): NetworkAdapter { switch (networkId) { case 1: case 11155111: + case 43114: + return new EVMAdapter(networkId, client as unknown as EthereumClient); case 31337: - return new EVMAdapter(networkId, client as EthereumClient); + return new HardhatAdapter(client as HardhatClient); case 10: return new OptimismAdapter(networkId, client as OptimismClient); case 56: diff --git a/src/styles/components.css b/src/styles/components.css index a4f979b0..77af954a 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -2590,6 +2590,188 @@ button.tx-section-header-toggle { } } +/* ── Raw Trace ──────────────────────────────────────────────────────────── */ + +.raw-trace-header, +.raw-trace-row-summary { + display: grid; + grid-template-columns: 60px 50px 120px 80px 60px 50px 24px; + gap: 4px; + padding: 4px 12px; + font-size: 0.78rem; + font-family: "JetBrains Mono", monospace; + align-items: center; +} + +.raw-trace-header { + color: var(--text-tertiary); + font-weight: 600; + border-bottom: 1px solid var(--border-primary); + padding-bottom: 6px; + margin-bottom: 4px; +} + +.raw-trace-row-summary { + cursor: pointer; + border-radius: 4px; + color: var(--text-secondary); +} + +.raw-trace-row-summary:hover { + background: var(--overlay-light-5); +} + +.raw-trace-step { + color: var(--text-tertiary); + font-size: 0.72rem; +} + +.raw-trace-pc { + color: var(--text-tertiary); +} + +.raw-trace-op { + font-weight: 600; + color: var(--text-primary); +} + +.raw-trace-gas, +.raw-trace-gas-cost { + text-align: right; + color: var(--text-tertiary); +} + +.raw-trace-depth { + text-align: center; + color: var(--text-tertiary); +} + +.raw-trace-expand { + text-align: center; + color: var(--text-tertiary); + font-size: 0.7rem; +} + +.raw-trace-row-detail { + padding: 6px 12px 10px 72px; + font-size: 0.75rem; +} + +.raw-trace-section { + margin-bottom: 8px; +} + +.raw-trace-section-title { + color: var(--text-tertiary); + font-size: 0.72rem; + font-weight: 600; + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.raw-trace-stack { + display: flex; + flex-direction: column; + gap: 1px; + max-height: 200px; + overflow-y: auto; +} + +.raw-trace-stack-item { + display: flex; + gap: 8px; + padding: 1px 0; +} + +.raw-trace-stack-index { + color: var(--text-tertiary); + min-width: 20px; + text-align: right; + font-size: 0.7rem; +} + +.raw-trace-stack-value { + color: var(--text-primary); + font-size: 0.72rem; + word-break: break-all; +} + +.raw-trace-storage { + display: flex; + flex-direction: column; + gap: 2px; +} + +.raw-trace-storage-item { + display: flex; + gap: 6px; + align-items: center; + font-size: 0.72rem; +} + +.raw-trace-storage-slot { + color: var(--color-info); +} + +.raw-trace-storage-arrow { + color: var(--text-tertiary); +} + +.raw-trace-storage-value { + color: var(--text-primary); +} + +.raw-trace-failed { + color: var(--color-error); + font-weight: 600; +} + +.raw-trace-pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + padding: 10px 0 4px; +} + +.raw-trace-page-btn { + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 4px; + padding: 4px 12px; + font-size: 0.78rem; + color: var(--text-secondary); + cursor: pointer; +} + +.raw-trace-page-btn:hover:not(:disabled) { + background: var(--overlay-light-10); +} + +.raw-trace-page-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.raw-trace-page-info { + font-size: 0.75rem; + color: var(--text-tertiary); +} + +@media (max-width: 640px) { + .raw-trace-header, + .raw-trace-row-summary { + grid-template-columns: 40px 40px 90px 60px 50px 30px 20px; + font-size: 0.7rem; + padding: 4px 8px; + } + + .raw-trace-row-detail { + padding-left: 48px; + } +} + /* Input Data Display */ .input-data-display { background: var(--bg-tertiary); diff --git a/src/styles/helper-tooltip.css b/src/styles/helper-tooltip.css new file mode 100644 index 00000000..448d2a87 --- /dev/null +++ b/src/styles/helper-tooltip.css @@ -0,0 +1,120 @@ +/* ===== Helper Tooltip ===== */ + +.helper-tooltip { + position: relative; + display: inline-flex; + align-items: center; +} + +/* Trigger button */ +.helper-tooltip-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + margin-left: 5px; + background: none; + border: none; + cursor: pointer; + color: var(--text-tertiary); + opacity: 0.6; + transition: opacity 0.15s ease, color 0.15s ease; + vertical-align: middle; + line-height: 1; + flex-shrink: 0; +} + +.helper-tooltip-trigger:hover, +.helper-tooltip-trigger:focus-visible { + opacity: 1; + color: var(--color-info); + outline: none; +} + +.helper-tooltip-trigger:focus-visible { + outline: 2px solid var(--color-info); + outline-offset: 2px; + border-radius: 50%; +} + +/* Tooltip bubble — portaled to document.body, positioned via inline style */ +.helper-tooltip-bubble { + max-width: 260px; + min-width: 140px; + padding: 8px 12px; + background: var(--bg-tooltip); + color: #fff; + font-size: 0.8rem; + font-weight: 400; + line-height: 1.45; + border-radius: 6px; + box-shadow: var(--shadow-md); + z-index: 10000; + pointer-events: auto; + white-space: normal; + word-wrap: break-word; +} + +/* Arrow pointing from bubble to trigger */ +.helper-tooltip-arrow { + position: absolute; + width: 0; + height: 0; + border: 5px solid transparent; + pointer-events: none; +} + +/* Top placement: arrow at bottom, pointing down */ +.helper-tooltip-arrow-top { + bottom: -10px; + transform: translateX(-50%); + border-top-color: var(--bg-tooltip); +} + +/* Bottom placement: arrow at top, pointing up */ +.helper-tooltip-arrow-bottom { + top: -10px; + transform: translateX(-50%); + border-bottom-color: var(--bg-tooltip); +} + +/* Left placement: arrow at right, pointing right */ +.helper-tooltip-arrow-left { + right: -10px; + transform: translateY(-50%); + border-left-color: var(--bg-tooltip); +} + +/* Right placement: arrow at left, pointing left */ +.helper-tooltip-arrow-right { + left: -10px; + transform: translateY(-50%); + border-right-color: var(--bg-tooltip); +} + +/* Light theme text: keep tooltip text white since bg-tooltip is dark in both themes */ +.light-theme .helper-tooltip-bubble { + color: #fff; +} + +/* ===== Navbar Tooltip Level Button ===== */ + +.navbar-toggle-btn.navbar-tooltip-level-beginner { + border-color: var(--color-primary-muted); +} + +.navbar-toggle-btn.navbar-tooltip-level-intermediate { + border-color: var(--color-info); +} + +.navbar-toggle-btn.navbar-tooltip-level-advanced { + border-color: var(--color-accent); +} + +/* Mobile adjustments */ +@media (max-width: 767px) { + .helper-tooltip-bubble { + max-width: 220px; + font-size: 0.78rem; + } +} diff --git a/src/styles/styles.css b/src/styles/styles.css index 44b02a46..84c804e8 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -2478,7 +2478,8 @@ a.navbar-logo-dropdown-item:hover { } .notification { - width: clamp(240px, 90vw, 520px); + width: fit-content; + max-width: clamp(240px, 90vw, 520px); padding: 14px 18px; border-radius: 12px; box-shadow: 0 4px 14px -2px rgba(0, 0, 0, 0.2); diff --git a/src/types/index.ts b/src/types/index.ts index 525955cc..73ecc2a6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,4 @@ -import type { EthLog } from "@openscan/network-connectors"; +import type { EthLog, SupportedChainId } from "@openscan/network-connectors"; import type React from "react"; // ==================== NETWORK TYPES ==================== @@ -8,6 +8,13 @@ import type React from "react"; */ export type NetworkType = "evm" | "bitcoin"; +/** + * All EVM chain IDs supported by the app. + * Maps directly to the connector library's SupportedChainId. + * When adding a new EVM network, add its chain ID to network-connectors first. + */ +export type AppChainId = SupportedChainId; + // ==================== CORE DOMAIN TYPES ==================== export interface NetworkStats { @@ -228,6 +235,7 @@ export interface BitcoinTransaction { vin: BitcoinTransactionInput[]; vout: BitcoinTransactionOutput[]; blockhash?: string; + blockheight?: number; confirmations?: number; blocktime?: number; time?: number; @@ -483,6 +491,11 @@ export type AIAnalysisType = */ export type PromptVersion = "stable" | "latest"; +/** + * Knowledge level for helper tooltip visibility + */ +export type KnowledgeLevel = "beginner" | "intermediate" | "advanced"; + /** * User settings for the application */ @@ -496,6 +509,8 @@ export interface UserSettings { promptVersion?: PromptVersion; persistentCacheSizeMB?: number; beaconUrls?: Record; + knowledgeLevel?: KnowledgeLevel; + showHelperTooltips?: boolean; } // ==================== BEACON/BLOB TYPES ==================== @@ -523,6 +538,8 @@ export const DEFAULT_SETTINGS: UserSettings = { isSuperUser: false, promptVersion: "stable", persistentCacheSizeMB: 10, + knowledgeLevel: "beginner", + showHelperTooltips: true, }; /** diff --git a/src/utils/contractLookup.ts b/src/utils/contractLookup.ts index 9b20a155..e9ab5675 100644 --- a/src/utils/contractLookup.ts +++ b/src/utils/contractLookup.ts @@ -9,9 +9,51 @@ export interface ContractInfo { // Session-level cache keyed by "chainId:address" const cache = new Map(); +const OPENSCAN_WORKER_URL = + // biome-ignore lint/complexity/useLiteralKeys: env var access + process.env["REACT_APP_OPENSCAN_WORKER_URL"] ?? + "https://openscan-groq-ai-proxy.openscan.workers.dev"; + +/** + * Fetch contract verification from Etherscan V2 API. + * Uses user-provided key directly, or proxies through the OpenScan Worker. + */ +async function fetchEtherscanVerification( + address: string, + chainId: number, + signal?: AbortSignal, + etherscanKey?: string, + // biome-ignore lint/suspicious/noExplicitAny: Etherscan response shape varies +): Promise { + try { + let res: Response; + + if (etherscanKey) { + // Direct Etherscan call with user's key + const url = `https://api.etherscan.io/v2/api?chainid=${chainId}&module=contract&action=getsourcecode&address=${address}&apikey=${etherscanKey}`; + res = await fetch(url, { signal }); + } else { + // Proxy through OpenScan Worker (free, no key needed) + res = await fetch(`${OPENSCAN_WORKER_URL}/etherscan/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chainId, address }), + signal, + }); + } + + if (!res.ok) return null; + return await res.json(); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") throw err; + logger.debug("Etherscan lookup failed for", address, err); + return null; + } +} + /** * Fetch contract name + ABI for a single address. - * Tries Sourcify first; falls back to Etherscan V2 API if a key is provided. + * Tries Sourcify first; falls back to Etherscan V2 API (via worker proxy or user key). * Results are cached in memory for the session. */ export async function fetchContractInfo( @@ -54,28 +96,24 @@ export async function fetchContractInfo( logger.debug("Sourcify lookup failed for", address, err); } - // ── Etherscan fallback ──────────────────────────────────────────────────── - if (etherscanKey) { - try { - const url = `https://api.etherscan.io/v2/api?chainid=${chainId}&module=contract&action=getsourcecode&address=${address}&apikey=${etherscanKey}`; - const res = await fetch(url, { signal }); - const json = await res.json(); - if ( - json.status === "1" && - Array.isArray(json.result) && - json.result[0]?.ABI && - json.result[0].ABI !== "Contract source code not verified" - ) { - const r = json.result[0]; - const abi = JSON.parse(r.ABI); - const info: ContractInfo = { name: r.ContractName || undefined, abi }; - cache.set(cacheKey, info); - return info; - } - } catch (err) { - if (err instanceof Error && err.name === "AbortError") return null; - logger.debug("Etherscan lookup failed for", address, err); + // ── Etherscan fallback (worker proxy or user key) ───────────────────────── + try { + const json = await fetchEtherscanVerification(address, chainId, signal, etherscanKey); + if ( + json?.status === "1" && + Array.isArray(json.result) && + json.result[0]?.ABI && + json.result[0].ABI !== "Contract source code not verified" + ) { + const r = json.result[0]; + const abi = JSON.parse(r.ABI); + const info: ContractInfo = { name: r.ContractName || undefined, abi }; + cache.set(cacheKey, info); + return info; } + } catch (err) { + if (err instanceof Error && err.name === "AbortError") return null; + logger.debug("Etherscan lookup failed for", address, err); } cache.set(cacheKey, null); diff --git a/src/utils/structLogConverter.ts b/src/utils/structLogConverter.ts new file mode 100644 index 00000000..35f5702b --- /dev/null +++ b/src/utils/structLogConverter.ts @@ -0,0 +1,314 @@ +import type { + CallNode, + PrestateAccountState, + PrestateTrace, + TraceLog, + TraceResult, +} from "../services/adapters/NetworkAdapter"; + +/** + * Call opcodes that create a new frame in the EVM execution stack. + */ +const CALL_OPS = new Set(["CALL", "STATICCALL", "DELEGATECALL", "CALLCODE", "CREATE", "CREATE2"]); + +/** + * Opcodes that terminate the current frame. + */ +const RETURN_OPS = new Set(["RETURN", "REVERT", "STOP", "SELFDESTRUCT", "INVALID"]); + +/** Read a 256-bit stack word as a hex address (last 20 bytes). */ +function stackAddr(word: string): string { + const raw = word.replace(/^0x/, "").padStart(40, "0"); + return `0x${raw.slice(-40)}`; +} + +/** Ensure a value has 0x prefix. */ +function ensureHex(word: string): string { + return word.startsWith("0x") ? word : `0x${word}`; +} + +/** Convert a number to a hex string. */ +function toHex(n: number): string { + return `0x${n.toString(16)}`; +} + +interface TxContext { + from: string; + to: string; + value: string; + gas: string; + input: string; +} + +interface FrameInfo { + node: CallNode; + startGas: number; +} + +/** + * Build a CallNode tree from EVM struct logs (opcode-level trace). + * + * This is used for Hardhat which only supports the default struct log tracer + * and does not support Geth's `callTracer`. + * + * The algorithm tracks CALL/STATICCALL/DELEGATECALL/CREATE opcodes and their + * corresponding RETURN/REVERT/STOP to reconstruct the call hierarchy. + */ +export function buildCallTreeFromStructLogs(trace: TraceResult, tx: TxContext): CallNode { + const root: CallNode = { + type: "CALL", + from: tx.from, + to: tx.to, + value: tx.value, + gas: tx.gas, + gasUsed: toHex(trace.gas), + input: tx.input, + output: trace.returnValue ? ensureHex(trace.returnValue) : undefined, + error: trace.failed ? "execution reverted" : undefined, + calls: [], + }; + + const stack: FrameInfo[] = [{ node: root, startGas: Number.parseInt(tx.gas, 16) || 0 }]; + const logs = trace.structLogs; + + for (let i = 0; i < logs.length; i++) { + const log = logs[i] as TraceLog; + + if (CALL_OPS.has(log.op) && log.stack) { + const child = extractCallFromStack(log, stack); + if (child) { + const parent = stack[stack.length - 1] as FrameInfo; + if (!parent.node.calls) parent.node.calls = []; + parent.node.calls.push(child.node); + stack.push(child); + } + } else if (RETURN_OPS.has(log.op)) { + if (stack.length > 1) { + const frame = stack.pop(); + if (frame) { + if (log.op === "REVERT") { + frame.node.error = "execution reverted"; + } + frame.node.gasUsed = toHex(Math.max(0, frame.startGas - log.gas)); + } + } + } + } + + cleanEmptyCalls(root); + return root; +} + +/** + * Extract call information from the EVM stack at a CALL-type opcode. + * + * Stack layout (top to bottom) for each opcode: + * CALL: gas, to, value, inOffset, inSize, outOffset, outSize + * CALLCODE: gas, to, value, inOffset, inSize, outOffset, outSize + * STATICCALL: gas, to, inOffset, inSize, outOffset, outSize + * DELEGATECALL: gas, to, inOffset, inSize, outOffset, outSize + * CREATE: value, offset, size + * CREATE2: value, offset, size, salt + */ +function extractCallFromStack(log: TraceLog, callStack: FrameInfo[]): FrameInfo | null { + const s = log.stack; + if (!s || s.length === 0) return null; + + const parent = callStack[callStack.length - 1] as FrameInfo; + let type = log.op; + let to: string | undefined; + let value: string | undefined; + let gas: string | undefined; + + // Stack is bottom-to-top in the array, so top of stack = last element + const len = s.length; + + switch (log.op) { + case "CALL": + case "CALLCODE": { + if (len < 7) return null; + const gasWord = s[len - 1] as string; + const toWord = s[len - 2] as string; + const valWord = s[len - 3] as string; + gas = ensureHex(gasWord); + to = stackAddr(toWord); + value = ensureHex(valWord); + break; + } + case "STATICCALL": + case "DELEGATECALL": { + if (len < 6) return null; + const gasWord = s[len - 1] as string; + const toWord = s[len - 2] as string; + gas = ensureHex(gasWord); + to = stackAddr(toWord); + value = "0x0"; + break; + } + case "CREATE": { + if (len < 3) return null; + const valWord = s[len - 1] as string; + value = ensureHex(valWord); + gas = toHex(log.gas); + type = "CREATE"; + to = undefined; + break; + } + case "CREATE2": { + if (len < 4) return null; + const valWord = s[len - 1] as string; + value = ensureHex(valWord); + gas = toHex(log.gas); + type = "CREATE2"; + to = undefined; + break; + } + default: + return null; + } + + // For DELEGATECALL, msg.sender stays the same as the parent's from + const from = + log.op === "DELEGATECALL" + ? (parent.node.from ?? "") + : (parent.node.to ?? parent.node.from ?? ""); + + const node: CallNode = { + type: type.toUpperCase(), + from, + to, + value, + gas, + gasUsed: undefined, + input: undefined, + output: undefined, + calls: [], + }; + + const startGas = gas ? Number.parseInt(gas, 16) || 0 : log.gas; + return { node, startGas }; +} + +/** Remove empty calls arrays to match callTracer output format. */ +function cleanEmptyCalls(node: CallNode): void { + if (node.calls && node.calls.length === 0) { + node.calls = undefined; + } else if (node.calls) { + for (const child of node.calls) { + cleanEmptyCalls(child); + } + } +} + +/** + * Build a PrestateTrace (pre/post state diff) from EVM struct logs. + * + * Tracks SLOAD/SSTORE operations to identify storage changes. + * + * Note: This produces a best-effort approximation since struct logs + * don't contain the full pre/post state like a native prestateTracer. + */ +export function buildPrestateFromStructLogs( + trace: TraceResult, + tx: TxContext, +): PrestateTrace | null { + const pre: Record = {}; + const post: Record = {}; + + const storageReads: Record> = {}; + const storageWrites: Record> = {}; + + // Track which contract is executing at each depth + const addressByDepth: Record = {}; + addressByDepth[1] = tx.to.toLowerCase(); + + for (const log of trace.structLogs) { + const currentAddr = addressByDepth[log.depth] ?? tx.to.toLowerCase(); + + if (log.op === "SLOAD" && log.stack && log.storage) { + const slot = log.stack[log.stack.length - 1]; + if (slot !== undefined) { + const addr = currentAddr; + if (!storageReads[addr]) storageReads[addr] = {}; + const hexSlot = ensureHex(slot); + const rawSlot = slot.replace(/^0x/, ""); + const storageVal = log.storage[rawSlot]; + if (storageVal) { + (storageReads[addr] as Record)[hexSlot] = ensureHex(storageVal); + } + } + } + + if (log.op === "SSTORE" && log.stack) { + const len = log.stack.length; + if (len >= 2) { + const slot = log.stack[len - 1]; + const val = log.stack[len - 2]; + if (slot !== undefined && val !== undefined) { + const addr = currentAddr; + if (!storageWrites[addr]) storageWrites[addr] = {}; + const hexSlot = ensureHex(slot); + (storageWrites[addr] as Record)[hexSlot] = ensureHex(val); + + // If we haven't seen a read for this slot, record pre-state from storage map + const addrReads = storageReads[addr]; + if ((!addrReads || !addrReads[hexSlot]) && log.storage) { + if (!storageReads[addr]) storageReads[addr] = {}; + const rawSlot = slot.replace(/^0x/, ""); + const storageVal = log.storage[rawSlot]; + if (storageVal) { + (storageReads[addr] as Record)[hexSlot] = ensureHex(storageVal); + } + } + } + } + } + + // Track address context changes from CALL opcodes + if (CALL_OPS.has(log.op) && log.stack) { + const len = log.stack.length; + if (log.op === "CALL" || log.op === "CALLCODE" || log.op === "STATICCALL") { + const toWord = len >= 2 ? log.stack[len - 2] : undefined; + if (toWord) { + addressByDepth[log.depth + 1] = stackAddr(toWord); + } + } else if (log.op === "DELEGATECALL") { + addressByDepth[log.depth + 1] = currentAddr; + } + } + } + + // Build pre state from reads + for (const [addr, slots] of Object.entries(storageReads)) { + if (!pre[addr]) pre[addr] = {}; + (pre[addr] as PrestateAccountState).storage = slots; + } + + // Build post state from writes (merged with reads for unchanged slots) + for (const [addr, slots] of Object.entries(storageWrites)) { + if (!post[addr]) post[addr] = {}; + const preStorage = storageReads[addr] ?? {}; + (post[addr] as PrestateAccountState).storage = { ...preStorage, ...slots }; + } + + // Include pre-state for addresses that had writes + for (const addr of Object.keys(storageWrites)) { + if (!pre[addr]) pre[addr] = {}; + const preEntry = pre[addr] as PrestateAccountState; + if (!preEntry.storage) preEntry.storage = storageReads[addr] ?? {}; + } + + // Add sender and receiver + const sender = tx.from.toLowerCase(); + const receiver = tx.to.toLowerCase(); + if (!pre[sender]) pre[sender] = {}; + if (!post[sender]) post[sender] = {}; + if (!pre[receiver]) pre[receiver] = {}; + if (!post[receiver]) post[receiver] = {}; + + if (Object.keys(pre).length === 0 && Object.keys(post).length === 0) { + return null; + } + + return { pre, post }; +} diff --git a/worker/src/index.ts b/worker/src/index.ts index c180ba61..42a2e46b 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -2,8 +2,11 @@ import { Hono } from "hono"; import type { Env } from "./types"; import { corsMiddleware } from "./middleware/cors"; import { rateLimitMiddleware } from "./middleware/rateLimit"; +import { rateLimitEtherscanMiddleware } from "./middleware/rateLimitEtherscan"; import { validateMiddleware } from "./middleware/validate"; +import { validateEtherscanMiddleware } from "./middleware/validateEtherscan"; import { analyzeHandler } from "./routes/analyze"; +import { etherscanVerifyHandler } from "./routes/etherscanVerify"; const app = new Hono<{ Bindings: Env }>(); @@ -13,6 +16,14 @@ app.use("*", corsMiddleware); // POST /ai/analyze — rate limit, validate, then handle app.post("/ai/analyze", rateLimitMiddleware, validateMiddleware, analyzeHandler); +// POST /etherscan/verify — rate limit, validate, then proxy to Etherscan V2 API +app.post( + "/etherscan/verify", + rateLimitEtherscanMiddleware, + validateEtherscanMiddleware, + etherscanVerifyHandler, +); + // Health check app.get("/health", (c) => c.json({ status: "ok" })); diff --git a/worker/src/middleware/rateLimitEtherscan.ts b/worker/src/middleware/rateLimitEtherscan.ts new file mode 100644 index 00000000..8fd2fe16 --- /dev/null +++ b/worker/src/middleware/rateLimitEtherscan.ts @@ -0,0 +1,49 @@ +import type { Context, Next } from "hono"; +import type { Env } from "../types"; + +const WINDOW_MS = 60_000; // 1 minute +const MAX_REQUESTS = 30; // More generous than AI (10) + +interface RateLimitEntry { + timestamps: number[]; +} + +const store = new Map(); + +let lastCleanup = Date.now(); +const CLEANUP_INTERVAL_MS = 300_000; // 5 minutes + +function cleanup(now: number) { + if (now - lastCleanup < CLEANUP_INTERVAL_MS) return; + lastCleanup = now; + for (const [key, entry] of store) { + if (entry.timestamps.every((ts) => now - ts > WINDOW_MS)) { + store.delete(key); + } + } +} + +export async function rateLimitEtherscanMiddleware(c: Context<{ Bindings: Env }>, next: Next) { + const ip = c.req.header("CF-Connecting-IP") ?? c.req.header("X-Forwarded-For") ?? "unknown"; + const now = Date.now(); + + cleanup(now); + + let entry = store.get(ip); + if (!entry) { + entry = { timestamps: [] }; + store.set(ip, entry); + } + + entry.timestamps = entry.timestamps.filter((ts) => now - ts < WINDOW_MS); + + if (entry.timestamps.length >= MAX_REQUESTS) { + const oldestInWindow = entry.timestamps[0]!; + const retryAfterSec = Math.ceil((WINDOW_MS - (now - oldestInWindow)) / 1000); + c.header("Retry-After", String(retryAfterSec)); + return c.json({ error: "Rate limit exceeded. Try again later." }, 429); + } + + entry.timestamps.push(now); + await next(); +} diff --git a/worker/src/middleware/validateEtherscan.ts b/worker/src/middleware/validateEtherscan.ts new file mode 100644 index 00000000..4bfa6601 --- /dev/null +++ b/worker/src/middleware/validateEtherscan.ts @@ -0,0 +1,24 @@ +import type { Context, Next } from "hono"; +import type { EtherscanVerifyRequestBody, Env } from "../types"; + +const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/; + +export async function validateEtherscanMiddleware(c: Context<{ Bindings: Env }>, next: Next) { + let body: EtherscanVerifyRequestBody; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid JSON" }, 400); + } + + if (typeof body.chainId !== "number" || !Number.isInteger(body.chainId) || body.chainId <= 0) { + return c.json({ error: "chainId must be a positive integer" }, 400); + } + + if (typeof body.address !== "string" || !ADDRESS_RE.test(body.address)) { + return c.json({ error: "address must be a valid 0x-prefixed Ethereum address" }, 400); + } + + c.set("validatedBody" as never, body as never); + await next(); +} diff --git a/worker/src/routes/etherscanVerify.ts b/worker/src/routes/etherscanVerify.ts new file mode 100644 index 00000000..23e4111b --- /dev/null +++ b/worker/src/routes/etherscanVerify.ts @@ -0,0 +1,35 @@ +import type { Context } from "hono"; +import type { EtherscanVerifyRequestBody, Env } from "../types"; + +const ETHERSCAN_V2_API = "https://api.etherscan.io/v2/api"; + +export async function etherscanVerifyHandler(c: Context<{ Bindings: Env }>) { + const body = c.get("validatedBody" as never) as unknown as EtherscanVerifyRequestBody; + + const params = new URLSearchParams({ + chainid: String(body.chainId), + module: "contract", + action: "getsourcecode", + address: body.address, + apikey: c.env.ETHERSCAN_API_KEY, + }); + + try { + const response = await fetch(`${ETHERSCAN_V2_API}?${params.toString()}`); + + if (!response.ok) { + const status = response.status; + if (status === 429) { + const retryAfter = response.headers.get("Retry-After"); + if (retryAfter) c.header("Retry-After", retryAfter); + return c.json({ error: "Etherscan rate limit exceeded" }, 429); + } + return c.json({ error: `Etherscan API error (HTTP ${status})` }, 502); + } + + const data = await response.json(); + return c.json(data); + } catch { + return c.json({ error: "Failed to connect to Etherscan API" }, 502); + } +} diff --git a/worker/src/types.ts b/worker/src/types.ts index 4ac5db72..bcd82948 100644 --- a/worker/src/types.ts +++ b/worker/src/types.ts @@ -15,8 +15,14 @@ export interface AnalyzeRequestBody { messages: Array<{ role: "system" | "user"; content: string }>; } +export interface EtherscanVerifyRequestBody { + chainId: number; + address: string; +} + export interface Env { GROQ_API_KEY: string; + ETHERSCAN_API_KEY: string; ALLOWED_ORIGINS: string; GROQ_MODEL: string; } diff --git a/worker/wrangler.toml b/worker/wrangler.toml index 70cf84c7..076fb635 100644 --- a/worker/wrangler.toml +++ b/worker/wrangler.toml @@ -3,8 +3,10 @@ main = "src/index.ts" compatibility_date = "2024-12-01" [vars] -ALLOWED_ORIGINS = "http://openscan.eth.link,https://openscan-explorer.github.io,https://openscan.netlify.app,*--openscan.netlify.app" +ALLOWED_ORIGINS = "https://openscan.eth.link,https://openscan.eth.limo,https://openscan-explorer.github.io,https://openscan.netlify.app,*--openscan.netlify.app" GROQ_MODEL = "groq/compound" -# Secret: GROQ_API_KEY — set via `wrangler secret put GROQ_API_KEY` +# Secrets — set via `wrangler secret put ` +# GROQ_API_KEY — Groq AI API key for /ai/analyze +# ETHERSCAN_API_KEY — Etherscan V2 API key for /etherscan/verify From df1878ccb662976a75a166ca5b1501390348a854 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 19 Mar 2026 19:25:55 -0300 Subject: [PATCH 24/54] fix: restore claude rules to match upstream dev --- .claude/rules/architecture.md | 48 ++++++++++++++++++----------------- .claude/rules/patterns.md | 19 +++++++------- 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 706f0eef..252e8a56 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -4,45 +4,47 @@ OpenScan follows a layered architecture with clear separation between data fetching, transformation, and presentation: -### 1. RPC Layer (`RPCClient.ts`) -Handles JSON-RPC communication with blockchain nodes: -- Supports two strategies: `fallback` (sequential with automatic failover) and `parallel` (query all providers simultaneously) -- Strategy is configurable via user settings (`useDataService` hook applies strategy) -- Parallel mode enables provider comparison and inconsistency detection - -### 2. Fetcher Layer (`services/EVM/*/fetchers/`) -Makes raw RPC calls for specific data types: -- Network-specific implementations: `L1/`, `Arbitrum/`, `Optimism/` -- Each fetcher handles one domain (blocks, transactions, addresses, network stats) - -### 3. Adapter Layer (`services/EVM/*/adapters/`) -Transforms raw RPC responses into typed domain objects: -- Normalizes network-specific fields (e.g., Arbitrum's `l1BlockNumber`, Optimism's L1 fee data) -- Ensures consistent type structure across networks - -### 4. Service Layer (`DataService.ts`) +### 1. Client Layer (`@openscan/network-connectors`) +Typed RPC clients for blockchain communication: +- `EthereumClient` - Standard JSON-RPC for EVM chains +- `HardhatClient` - Extended client with Hardhat-specific methods (`hardhat_*`, `evm_*`, `debug_*`) +- `BitcoinClient` - Bitcoin JSON-RPC (`getblock`, `getrawtransaction`, etc.) +- Supports `fallback`, `parallel`, and `race` strategies + +### 2. Adapter Layer (`services/adapters/`) +Abstract `NetworkAdapter` base class with chain-specific implementations: +- `EVMAdapter` - Default EVM adapter (Ethereum, BSC, Polygon, Sepolia) +- `ArbitrumAdapter` - Adds `l1BlockNumber`, `sendCount`, `sendRoot` +- `OptimismAdapter` / `BaseAdapter` - Adds L1 fee breakdown (`l1Fee`, `l1GasPrice`, `l1GasUsed`) +- `HardhatAdapter` - Localhost (31337) with trace support via struct log conversion +- `BitcoinAdapter` - Bitcoin networks with UTXO model, mempool, and block explorer +- Each adapter implements: `getBlock`, `getTransaction`, `getAddress`, `getNetworkStats`, trace methods +- `AdapterFactory` routes chain ID to the correct adapter + +### 3. Service Layer (`DataService.ts`) Orchestrates data fetching with caching and metadata: -- Instantiates network-specific fetchers/adapters based on chain ID +- Instantiates the correct adapter via `AdapterFactory` based on chain ID - Returns `DataWithMetadata` when using parallel strategy - 30-second in-memory cache keyed by `networkId:type:identifier` - Supports trace operations for Hardhat (31337) and localhost networks -### 5. Hook Layer (`hooks/`) +### 4. Hook Layer (`hooks/`) React integration: - `useDataService(networkId)`: Creates DataService instance with strategy from settings - `useProviderSelection`: Manages user's selected RPC provider in parallel mode - `useSelectedData`: Extracts data from specific provider based on user selection -### 6. Context Layer (`context/`) +### 5. Context Layer (`context/`) Global state management: - `AppContext`: RPC URLs configuration -- `SettingsContext`: User settings including `rpcStrategy` ('fallback' | 'parallel') +- `SettingsContext`: User settings including `rpcStrategy` ('fallback' | 'parallel' | 'race') ## Network-Specific Handling -Chain ID detection in `DataService` constructor determines which adapters/fetchers to use: +Chain ID detection in `AdapterFactory` determines which adapter to instantiate: -- **Arbitrum** (42161): `ArbitrumAdapter` - adds `l1BlockNumber`, `sendCount`, `requestId` +- **Arbitrum** (42161): `ArbitrumAdapter` - adds `l1BlockNumber`, `sendCount`, `sendRoot` +- **Bitcoin** (bip122:*): `BitcoinAdapter` - UTXO model, mempool transactions, block rewards - **OP Stack** (10, 8453): `OptimismAdapter` (10), `BaseAdapter` (8453) - adds L1 fee breakdown (`l1Fee`, `l1GasPrice`, `l1GasUsed`) - **Hardhat** (31337): `HardhatAdapter` - uses `HardhatClient` from `@openscan/network-connectors`; trace support via struct log conversion (`buildCallTreeFromStructLogs`, `buildPrestateFromStructLogs` in `src/utils/structLogConverter.ts`) since Hardhat v3 does not support `callTracer`/`prestateTracer` - **Default**: `EVMAdapter` for Ethereum (1), BSC (56, 97), Polygon (137), Sepolia (11155111), Avalanche (43114) diff --git a/.claude/rules/patterns.md b/.claude/rules/patterns.md index cd5aec9d..ca8a37ba 100644 --- a/.claude/rules/patterns.md +++ b/.claude/rules/patterns.md @@ -2,17 +2,18 @@ ## When Modifying Data Fetching -- Always maintain the adapter pattern: Fetcher → Adapter → Service +- Always maintain the adapter pattern: Client → Adapter → Service +- All adapters extend the abstract `NetworkAdapter` class (`services/adapters/NetworkAdapter.ts`) - If adding parallel strategy support, ensure complete objects are built for each provider -- Test both `fallback` and `parallel` strategies +- Test `fallback`, `parallel`, and `race` strategies - Update TypeScript types in `src/types/index.ts` if adding new fields ## When Adding L2-Specific Features - Check if network is OP Stack-based (Optimism, Base) or Arbitrum - Add network-specific types (e.g., `TransactionOptimism extends Transaction`) -- Create adapters that inherit base behavior and add L2 fields -- Update `DataService` conditional logic in constructor and relevant methods +- Create a new adapter extending `NetworkAdapter` in `services/adapters/[Network]/` +- Register the adapter in `AdapterFactory` (`services/adapters/adaptersFactory.ts`) ## When Working with Cache @@ -32,10 +33,10 @@ 1. Add chain ID to `src/types/index.ts` if creating new domain types 2. Add default RPC endpoints to `src/config/rpcConfig.ts` -3. Determine if network needs a custom adapter (L1, Arbitrum-like, OP Stack-like, Hardhat-like) -4. If custom: create `src/services/adapters/[Network]Adapter/[Network]Adapter.ts` -5. Register the adapter in `src/services/adapters/adaptersFactory.ts` with its chain ID -6. Add network config to `src/config/networks.json` +3. Determine if network needs a custom adapter (L1, Arbitrum-like, OP Stack-like, Bitcoin, Hardhat-like) +4. If custom: create `src/services/adapters/[Network]/[Network]Adapter.ts` extending `NetworkAdapter` +5. Register the adapter in `AdapterFactory` (`src/services/adapters/adaptersFactory.ts`) +6. Add network config to `ALL_NETWORKS` in `src/config/networks.ts` 7. Add network logo to `public/` and update `logoType` in network config ## Testing with Local Networks @@ -51,7 +52,7 @@ OpenScan includes special support for localhost development: ### Address Page Components - Use display components for different address types: `AccountDisplay`, `ContractDisplay`, `ERC20Display`, `ERC721Display`, `ERC1155Display` -- Shared components in `src/components/pages/address/shared/` +- Shared components in `src/components/pages/evm/address/shared/` - Card-based layout with Overview and More Info sections ### Theming From 3893c120c1070f5c4bdf10533210b47f14fc3425 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 19 Mar 2026 19:49:08 -0300 Subject: [PATCH 25/54] chore: bump @openscan/metadata to 1.1.2-alpha.0 Adds Avalanche C-Chain RPC endpoints to the metadata service. --- src/services/MetadataService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/MetadataService.ts b/src/services/MetadataService.ts index 79fa55dd..058aec6f 100644 --- a/src/services/MetadataService.ts +++ b/src/services/MetadataService.ts @@ -7,7 +7,7 @@ import networksData from "../config/networks.json"; import { logger } from "../utils/logger"; import { extractChainIdFromNetworkId } from "../utils/networkResolver"; -const METADATA_BASE_URL = "https://cdn.jsdelivr.net/npm/@openscan/metadata@1.1.1-alpha.0/dist"; +const METADATA_BASE_URL = "https://cdn.jsdelivr.net/npm/@openscan/metadata@1.1.2-alpha.0/dist"; export interface NetworkLink { name: string; From 120c8c7e760a2da30bd1c54b73f6a05f718dfd6b Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Thu, 19 Mar 2026 19:55:38 -0300 Subject: [PATCH 26/54] fix: invalidate metadata cache on version bump Store the metadata package version in the RPC cache. On startup, if the cached version doesn't match the current METADATA_VERSION, treat the cache as stale and refetch immediately. This ensures users get new RPC endpoints (e.g. Avalanche C-Chain) without waiting for the 24h TTL. --- src/services/MetadataService.ts | 3 ++- src/utils/rpcStorage.ts | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/services/MetadataService.ts b/src/services/MetadataService.ts index 058aec6f..a4439fdc 100644 --- a/src/services/MetadataService.ts +++ b/src/services/MetadataService.ts @@ -7,7 +7,8 @@ import networksData from "../config/networks.json"; import { logger } from "../utils/logger"; import { extractChainIdFromNetworkId } from "../utils/networkResolver"; -const METADATA_BASE_URL = "https://cdn.jsdelivr.net/npm/@openscan/metadata@1.1.2-alpha.0/dist"; +export const METADATA_VERSION = "1.1.2-alpha.0"; +const METADATA_BASE_URL = `https://cdn.jsdelivr.net/npm/@openscan/metadata@${METADATA_VERSION}/dist`; export interface NetworkLink { name: string; diff --git a/src/utils/rpcStorage.ts b/src/utils/rpcStorage.ts index b9d74ef6..3668f7e0 100644 --- a/src/utils/rpcStorage.ts +++ b/src/utils/rpcStorage.ts @@ -1,4 +1,4 @@ -import type { MetadataRpcEndpoint } from "../services/MetadataService"; +import { type MetadataRpcEndpoint, METADATA_VERSION } from "../services/MetadataService"; import type { RpcUrlsContextType } from "../types"; import { logger } from "./logger"; @@ -16,6 +16,7 @@ const BUILTIN_RPC_DEFAULTS: RpcUrlsContextType = { interface MetadataRpcCache { timestamp: number; + version?: string; rpcs: Record; } @@ -40,7 +41,7 @@ export function loadMetadataRpcsFromStorage(): MetadataRpcCache | null { */ export function saveMetadataRpcsToStorage(rpcs: Record): void { try { - const cache: MetadataRpcCache = { timestamp: Date.now(), rpcs }; + const cache: MetadataRpcCache = { timestamp: Date.now(), version: METADATA_VERSION, rpcs }; localStorage.setItem(METADATA_RPC_STORAGE_KEY, JSON.stringify(cache)); } catch (err) { logger.warn("Failed to save metadata RPCs to storage", err); @@ -53,6 +54,7 @@ export function saveMetadataRpcsToStorage(rpcs: Record Date: Fri, 20 Mar 2026 07:56:20 -0300 Subject: [PATCH 27/54] feat(worker): add proxy routes for Beacon API, Bitcoin RPC and EVM RPC Add per-provider proxy routes to the OpenScan worker to hide API keys for Alchemy (Beacon, BTC, EVM) and Infura (EVM). Includes rate limiting, request validation with method allowlists, and CORS support for GET. Routes: - GET /beacon/alchemy/:networkId/blob_sidecars/:slot - POST /btc/alchemy - POST /evm/alchemy/:networkId - POST /evm/infura/:networkId Frontend changes: - Extract shared OPENSCAN_WORKER_URL into src/config/workerConfig.ts - Add BeaconService and useBeaconBlobs hook for blob sidecar fetching - Add worker BTC and EVM URLs to BUILTIN_RPC_DEFAULTS as fallbacks - Recognize worker-proxied URLs in settings RPC tag labels Closes #334 --- src/components/pages/settings/index.tsx | 7 +++ src/config/workerConfig.ts | 5 ++ src/hooks/useBeaconBlobs.ts | 50 ++++++++++++++++ src/hooks/useEtherscan.ts | 5 +- src/services/BeaconService.ts | 60 ++++++++++++++++++++ src/utils/contractLookup.ts | 6 +- src/utils/rpcStorage.ts | 30 +++++++++- worker/src/index.ts | 36 ++++++++++++ worker/src/middleware/cors.ts | 2 +- worker/src/middleware/rateLimitBeacon.ts | 49 ++++++++++++++++ worker/src/middleware/rateLimitBtc.ts | 49 ++++++++++++++++ worker/src/middleware/rateLimitEvm.ts | 49 ++++++++++++++++ worker/src/middleware/validateBeacon.ts | 18 ++++++ worker/src/middleware/validateBtc.ts | 26 +++++++++ worker/src/middleware/validateEvm.ts | 33 +++++++++++ worker/src/routes/beaconBlobSidecars.ts | 47 ++++++++++++++++ worker/src/routes/btcRpc.ts | 32 +++++++++++ worker/src/routes/evmRpc.ts | 72 ++++++++++++++++++++++++ worker/src/types.ts | 59 +++++++++++++++++++ worker/wrangler.toml | 2 + 20 files changed, 626 insertions(+), 11 deletions(-) create mode 100644 src/config/workerConfig.ts create mode 100644 src/hooks/useBeaconBlobs.ts create mode 100644 src/services/BeaconService.ts create mode 100644 worker/src/middleware/rateLimitBeacon.ts create mode 100644 worker/src/middleware/rateLimitBtc.ts create mode 100644 worker/src/middleware/rateLimitEvm.ts create mode 100644 worker/src/middleware/validateBeacon.ts create mode 100644 worker/src/middleware/validateBtc.ts create mode 100644 worker/src/middleware/validateEvm.ts create mode 100644 worker/src/routes/beaconBlobSidecars.ts create mode 100644 worker/src/routes/btcRpc.ts create mode 100644 worker/src/routes/evmRpc.ts diff --git a/src/components/pages/settings/index.tsx b/src/components/pages/settings/index.tsx index 9b2bc7ed..e3e588d4 100644 --- a/src/components/pages/settings/index.tsx +++ b/src/components/pages/settings/index.tsx @@ -86,6 +86,10 @@ const getAlchemyBtcUrl = (networkId: string, apiKey: string): string | null => { const isInfuraUrl = (url: string): boolean => url.includes("infura.io"); const isAlchemyUrl = (url: string): boolean => url.includes("alchemy.com"); +const isWorkerAlchemyUrl = (url: string): boolean => + url.includes("/evm/alchemy/") || url.includes("/btc/alchemy") || url.includes("/beacon/alchemy/"); +const isWorkerInfuraUrl = (url: string): boolean => url.includes("/evm/infura/"); + const Settings: React.FC = () => { const { t, i18n } = useTranslation("settings"); const { t: tTooltips } = useTranslation("tooltips"); @@ -605,6 +609,7 @@ const Settings: React.FC = () => { const getRpcTagClass = useCallback( (url: string): string => { + if (isWorkerAlchemyUrl(url) || isWorkerInfuraUrl(url)) return "rpc-opensource"; if (isInfuraUrl(url) || isAlchemyUrl(url)) return "rpc-tracking"; const ep = metadataUrlMap.get(url); if (!ep) return ""; @@ -617,6 +622,8 @@ const Settings: React.FC = () => { const getRpcTagLabel = useCallback( (url: string): string => { + if (isWorkerAlchemyUrl(url)) return "OpenScan Alchemy"; + if (isWorkerInfuraUrl(url)) return "OpenScan Infura"; if (isInfuraUrl(url)) return "Infura Personal"; if (isAlchemyUrl(url)) return "Alchemy Personal"; const ep = metadataUrlMap.get(url); diff --git a/src/config/workerConfig.ts b/src/config/workerConfig.ts new file mode 100644 index 00000000..d97c0c56 --- /dev/null +++ b/src/config/workerConfig.ts @@ -0,0 +1,5 @@ +/** Base URL for the OpenScan Cloudflare Worker proxy */ +export const OPENSCAN_WORKER_URL = + // biome-ignore lint/complexity/useLiteralKeys: env var access + process.env["REACT_APP_OPENSCAN_WORKER_URL"] ?? + "https://openscan-groq-ai-proxy.openscan.workers.dev"; diff --git a/src/hooks/useBeaconBlobs.ts b/src/hooks/useBeaconBlobs.ts new file mode 100644 index 00000000..a9f18ff6 --- /dev/null +++ b/src/hooks/useBeaconBlobs.ts @@ -0,0 +1,50 @@ +import { useEffect, useState } from "react"; +import { getBlobSidecarsViaWorker, type BlobSidecarsResponse } from "../services/BeaconService"; +import { logger } from "../utils/logger"; + +/** + * Hook to fetch blob sidecars for a given slot via the OpenScan worker proxy. + * Tries Alchemy as the beacon provider. + */ +export function useBeaconBlobs( + networkId: string | undefined, + slot: string | number | undefined, + enabled: boolean, +): { data: BlobSidecarsResponse | null; loading: boolean } { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!enabled || !networkId || slot === undefined) { + setData(null); + setLoading(false); + return; + } + + const controller = new AbortController(); + + const fetchBlobs = async () => { + setLoading(true); + try { + const result = await getBlobSidecarsViaWorker( + "alchemy", + networkId, + slot, + controller.signal, + ); + setData(result); + } catch (err) { + if ((err as Error)?.name === "AbortError") return; + logger.error("Error fetching beacon blobs:", err); + setData(null); + } finally { + setLoading(false); + } + }; + + fetchBlobs(); + return () => controller.abort(); + }, [networkId, slot, enabled]); + + return { data, loading }; +} diff --git a/src/hooks/useEtherscan.ts b/src/hooks/useEtherscan.ts index a8c50ec8..6e4b86de 100644 --- a/src/hooks/useEtherscan.ts +++ b/src/hooks/useEtherscan.ts @@ -1,13 +1,10 @@ import { useEffect, useState } from "react"; +import { OPENSCAN_WORKER_URL } from "../config/workerConfig"; import { useSettings } from "../context/SettingsContext"; import { logger } from "../utils/logger"; import type { SourcifyContractDetails } from "./useSourcify"; const ETHERSCAN_V2_API = "https://api.etherscan.io/v2/api"; -const OPENSCAN_WORKER_URL = - // biome-ignore lint/complexity/useLiteralKeys: env var access - process.env["REACT_APP_OPENSCAN_WORKER_URL"] ?? - "https://openscan-groq-ai-proxy.openscan.workers.dev"; interface EtherscanSourceResult { SourceCode: string; diff --git a/src/services/BeaconService.ts b/src/services/BeaconService.ts new file mode 100644 index 00000000..989bf5f3 --- /dev/null +++ b/src/services/BeaconService.ts @@ -0,0 +1,60 @@ +import { OPENSCAN_WORKER_URL } from "../config/workerConfig"; +import { logger } from "../utils/logger"; + +export interface BlobSidecar { + index: string; + blob: string; + kzg_commitment: string; + kzg_proof: string; + signed_block_header: { + message: { + slot: string; + proposer_index: string; + parent_root: string; + state_root: string; + body_root: string; + }; + signature: string; + }; + kzg_commitment_inclusion_proof: string[]; +} + +export interface BlobSidecarsResponse { + data: BlobSidecar[]; +} + +type BeaconProvider = "alchemy"; + +/** + * Fetch blob sidecars for a given slot via the OpenScan worker proxy. + * Returns null if the slot is not found (pruned) or the request fails. + */ +export async function getBlobSidecarsViaWorker( + provider: BeaconProvider, + networkId: string, + slot: string | number, + signal?: AbortSignal, +): Promise { + const url = `${OPENSCAN_WORKER_URL}/beacon/${provider}/${networkId}/blob_sidecars/${slot}`; + + try { + const response = await fetch(url, { signal }); + + if (response.status === 404) { + logger.debug(`Beacon blob sidecars not found for slot ${slot} (pruned or unavailable)`); + return null; + } + + if (!response.ok) { + logger.warn(`Beacon worker responded with HTTP ${response.status} for slot ${slot}`); + return null; + } + + const data = (await response.json()) as BlobSidecarsResponse; + return data; + } catch (err) { + if ((err as Error)?.name === "AbortError") return null; + logger.warn("Failed to fetch blob sidecars via worker", err); + return null; + } +} diff --git a/src/utils/contractLookup.ts b/src/utils/contractLookup.ts index e9ab5675..1dc49757 100644 --- a/src/utils/contractLookup.ts +++ b/src/utils/contractLookup.ts @@ -1,3 +1,4 @@ +import { OPENSCAN_WORKER_URL } from "../config/workerConfig"; import { logger } from "./logger"; export interface ContractInfo { @@ -9,11 +10,6 @@ export interface ContractInfo { // Session-level cache keyed by "chainId:address" const cache = new Map(); -const OPENSCAN_WORKER_URL = - // biome-ignore lint/complexity/useLiteralKeys: env var access - process.env["REACT_APP_OPENSCAN_WORKER_URL"] ?? - "https://openscan-groq-ai-proxy.openscan.workers.dev"; - /** * Fetch contract verification from Etherscan V2 API. * Uses user-provided key directly, or proxies through the OpenScan Worker. diff --git a/src/utils/rpcStorage.ts b/src/utils/rpcStorage.ts index b9d74ef6..3bdbca87 100644 --- a/src/utils/rpcStorage.ts +++ b/src/utils/rpcStorage.ts @@ -1,3 +1,4 @@ +import { OPENSCAN_WORKER_URL } from "../config/workerConfig"; import type { MetadataRpcEndpoint } from "../services/MetadataService"; import type { RpcUrlsContextType } from "../types"; import { logger } from "./logger"; @@ -8,10 +9,37 @@ const METADATA_RPC_TTL = 24 * 60 * 60 * 1000; // 24 hours /** * Hardcoded fallback defaults for networks that are never in the metadata service. - * Localhost (eip155:31337) always points to the default Hardhat/Anvil port. + * Includes localhost, worker-proxied BTC, and worker-proxied EVM endpoints. + * Metadata service RPCs and user-configured RPCs take priority via getEffectiveRpcUrls(). */ const BUILTIN_RPC_DEFAULTS: RpcUrlsContextType = { "eip155:31337": ["http://localhost:8545"], + "bip122:000000000019d6689c085ae165831e93": [`${OPENSCAN_WORKER_URL}/btc/alchemy`], + "eip155:1": [ + `${OPENSCAN_WORKER_URL}/evm/alchemy/eip155:1`, + `${OPENSCAN_WORKER_URL}/evm/infura/eip155:1`, + ], + "eip155:42161": [ + `${OPENSCAN_WORKER_URL}/evm/alchemy/eip155:42161`, + `${OPENSCAN_WORKER_URL}/evm/infura/eip155:42161`, + ], + "eip155:10": [ + `${OPENSCAN_WORKER_URL}/evm/alchemy/eip155:10`, + `${OPENSCAN_WORKER_URL}/evm/infura/eip155:10`, + ], + "eip155:8453": [ + `${OPENSCAN_WORKER_URL}/evm/alchemy/eip155:8453`, + `${OPENSCAN_WORKER_URL}/evm/infura/eip155:8453`, + ], + "eip155:137": [ + `${OPENSCAN_WORKER_URL}/evm/alchemy/eip155:137`, + `${OPENSCAN_WORKER_URL}/evm/infura/eip155:137`, + ], + "eip155:56": [`${OPENSCAN_WORKER_URL}/evm/alchemy/eip155:56`], + "eip155:43114": [ + `${OPENSCAN_WORKER_URL}/evm/alchemy/eip155:43114`, + `${OPENSCAN_WORKER_URL}/evm/infura/eip155:43114`, + ], }; interface MetadataRpcCache { diff --git a/worker/src/index.ts b/worker/src/index.ts index 42a2e46b..66e3d511 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -2,11 +2,20 @@ import { Hono } from "hono"; import type { Env } from "./types"; import { corsMiddleware } from "./middleware/cors"; import { rateLimitMiddleware } from "./middleware/rateLimit"; +import { rateLimitBeaconMiddleware } from "./middleware/rateLimitBeacon"; +import { rateLimitBtcMiddleware } from "./middleware/rateLimitBtc"; import { rateLimitEtherscanMiddleware } from "./middleware/rateLimitEtherscan"; +import { rateLimitEvmMiddleware } from "./middleware/rateLimitEvm"; import { validateMiddleware } from "./middleware/validate"; +import { validateBeaconMiddleware } from "./middleware/validateBeacon"; +import { validateBtcMiddleware } from "./middleware/validateBtc"; import { validateEtherscanMiddleware } from "./middleware/validateEtherscan"; +import { validateEvmMiddleware } from "./middleware/validateEvm"; import { analyzeHandler } from "./routes/analyze"; +import { beaconAlchemyHandler } from "./routes/beaconBlobSidecars"; +import { btcAlchemyHandler } from "./routes/btcRpc"; import { etherscanVerifyHandler } from "./routes/etherscanVerify"; +import { evmAlchemyHandler, evmInfuraHandler } from "./routes/evmRpc"; const app = new Hono<{ Bindings: Env }>(); @@ -24,6 +33,33 @@ app.post( etherscanVerifyHandler, ); +// GET /beacon/alchemy/:networkId/blob_sidecars/:slot — Beacon API proxy +app.get( + "/beacon/alchemy/:networkId/blob_sidecars/:slot", + rateLimitBeaconMiddleware, + validateBeaconMiddleware, + beaconAlchemyHandler, +); + +// POST /btc/alchemy — Bitcoin JSON-RPC proxy +app.post("/btc/alchemy", rateLimitBtcMiddleware, validateBtcMiddleware, btcAlchemyHandler); + +// POST /evm/alchemy/:networkId — EVM JSON-RPC proxy via Alchemy +app.post( + "/evm/alchemy/:networkId", + rateLimitEvmMiddleware, + validateEvmMiddleware, + evmAlchemyHandler, +); + +// POST /evm/infura/:networkId — EVM JSON-RPC proxy via Infura +app.post( + "/evm/infura/:networkId", + rateLimitEvmMiddleware, + validateEvmMiddleware, + evmInfuraHandler, +); + // Health check app.get("/health", (c) => c.json({ status: "ok" })); diff --git a/worker/src/middleware/cors.ts b/worker/src/middleware/cors.ts index 3f908012..72345366 100644 --- a/worker/src/middleware/cors.ts +++ b/worker/src/middleware/cors.ts @@ -38,7 +38,7 @@ export async function corsMiddleware(c: Context<{ Bindings: Env }>, next: Next) status: 204, headers: { "Access-Control-Allow-Origin": origin, - "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", "Access-Control-Max-Age": "86400", }, diff --git a/worker/src/middleware/rateLimitBeacon.ts b/worker/src/middleware/rateLimitBeacon.ts new file mode 100644 index 00000000..3a2a8bd6 --- /dev/null +++ b/worker/src/middleware/rateLimitBeacon.ts @@ -0,0 +1,49 @@ +import type { Context, Next } from "hono"; +import type { Env } from "../types"; + +const WINDOW_MS = 60_000; // 1 minute +const MAX_REQUESTS = 60; + +interface RateLimitEntry { + timestamps: number[]; +} + +const store = new Map(); + +let lastCleanup = Date.now(); +const CLEANUP_INTERVAL_MS = 300_000; // 5 minutes + +function cleanup(now: number) { + if (now - lastCleanup < CLEANUP_INTERVAL_MS) return; + lastCleanup = now; + for (const [key, entry] of store) { + if (entry.timestamps.every((ts) => now - ts > WINDOW_MS)) { + store.delete(key); + } + } +} + +export async function rateLimitBeaconMiddleware(c: Context<{ Bindings: Env }>, next: Next) { + const ip = c.req.header("CF-Connecting-IP") ?? c.req.header("X-Forwarded-For") ?? "unknown"; + const now = Date.now(); + + cleanup(now); + + let entry = store.get(ip); + if (!entry) { + entry = { timestamps: [] }; + store.set(ip, entry); + } + + entry.timestamps = entry.timestamps.filter((ts) => now - ts < WINDOW_MS); + + if (entry.timestamps.length >= MAX_REQUESTS) { + const oldestInWindow = entry.timestamps[0]!; + const retryAfterSec = Math.ceil((WINDOW_MS - (now - oldestInWindow)) / 1000); + c.header("Retry-After", String(retryAfterSec)); + return c.json({ error: "Rate limit exceeded. Try again later." }, 429); + } + + entry.timestamps.push(now); + await next(); +} diff --git a/worker/src/middleware/rateLimitBtc.ts b/worker/src/middleware/rateLimitBtc.ts new file mode 100644 index 00000000..a27eb044 --- /dev/null +++ b/worker/src/middleware/rateLimitBtc.ts @@ -0,0 +1,49 @@ +import type { Context, Next } from "hono"; +import type { Env } from "../types"; + +const WINDOW_MS = 60_000; // 1 minute +const MAX_REQUESTS = 30; + +interface RateLimitEntry { + timestamps: number[]; +} + +const store = new Map(); + +let lastCleanup = Date.now(); +const CLEANUP_INTERVAL_MS = 300_000; // 5 minutes + +function cleanup(now: number) { + if (now - lastCleanup < CLEANUP_INTERVAL_MS) return; + lastCleanup = now; + for (const [key, entry] of store) { + if (entry.timestamps.every((ts) => now - ts > WINDOW_MS)) { + store.delete(key); + } + } +} + +export async function rateLimitBtcMiddleware(c: Context<{ Bindings: Env }>, next: Next) { + const ip = c.req.header("CF-Connecting-IP") ?? c.req.header("X-Forwarded-For") ?? "unknown"; + const now = Date.now(); + + cleanup(now); + + let entry = store.get(ip); + if (!entry) { + entry = { timestamps: [] }; + store.set(ip, entry); + } + + entry.timestamps = entry.timestamps.filter((ts) => now - ts < WINDOW_MS); + + if (entry.timestamps.length >= MAX_REQUESTS) { + const oldestInWindow = entry.timestamps[0]!; + const retryAfterSec = Math.ceil((WINDOW_MS - (now - oldestInWindow)) / 1000); + c.header("Retry-After", String(retryAfterSec)); + return c.json({ error: "Rate limit exceeded. Try again later." }, 429); + } + + entry.timestamps.push(now); + await next(); +} diff --git a/worker/src/middleware/rateLimitEvm.ts b/worker/src/middleware/rateLimitEvm.ts new file mode 100644 index 00000000..b90c0afa --- /dev/null +++ b/worker/src/middleware/rateLimitEvm.ts @@ -0,0 +1,49 @@ +import type { Context, Next } from "hono"; +import type { Env } from "../types"; + +const WINDOW_MS = 60_000; // 1 minute +const MAX_REQUESTS = 60; + +interface RateLimitEntry { + timestamps: number[]; +} + +const store = new Map(); + +let lastCleanup = Date.now(); +const CLEANUP_INTERVAL_MS = 300_000; // 5 minutes + +function cleanup(now: number) { + if (now - lastCleanup < CLEANUP_INTERVAL_MS) return; + lastCleanup = now; + for (const [key, entry] of store) { + if (entry.timestamps.every((ts) => now - ts > WINDOW_MS)) { + store.delete(key); + } + } +} + +export async function rateLimitEvmMiddleware(c: Context<{ Bindings: Env }>, next: Next) { + const ip = c.req.header("CF-Connecting-IP") ?? c.req.header("X-Forwarded-For") ?? "unknown"; + const now = Date.now(); + + cleanup(now); + + let entry = store.get(ip); + if (!entry) { + entry = { timestamps: [] }; + store.set(ip, entry); + } + + entry.timestamps = entry.timestamps.filter((ts) => now - ts < WINDOW_MS); + + if (entry.timestamps.length >= MAX_REQUESTS) { + const oldestInWindow = entry.timestamps[0]!; + const retryAfterSec = Math.ceil((WINDOW_MS - (now - oldestInWindow)) / 1000); + c.header("Retry-After", String(retryAfterSec)); + return c.json({ error: "Rate limit exceeded. Try again later." }, 429); + } + + entry.timestamps.push(now); + await next(); +} diff --git a/worker/src/middleware/validateBeacon.ts b/worker/src/middleware/validateBeacon.ts new file mode 100644 index 00000000..378f26ae --- /dev/null +++ b/worker/src/middleware/validateBeacon.ts @@ -0,0 +1,18 @@ +import type { Context, Next } from "hono"; +import { ALLOWED_BEACON_NETWORKS, type Env } from "../types"; + +export async function validateBeaconMiddleware(c: Context<{ Bindings: Env }>, next: Next) { + const networkId = c.req.param("networkId"); + const slot = c.req.param("slot"); + + if (!networkId || !ALLOWED_BEACON_NETWORKS[networkId]) { + const allowed = Object.keys(ALLOWED_BEACON_NETWORKS).join(", "); + return c.json({ error: `Invalid networkId. Allowed: ${allowed}` }, 400); + } + + if (!slot || !/^\d+$/.test(slot) || Number(slot) <= 0) { + return c.json({ error: "slot must be a positive integer" }, 400); + } + + await next(); +} diff --git a/worker/src/middleware/validateBtc.ts b/worker/src/middleware/validateBtc.ts new file mode 100644 index 00000000..c3e2b31b --- /dev/null +++ b/worker/src/middleware/validateBtc.ts @@ -0,0 +1,26 @@ +import type { Context, Next } from "hono"; +import { ALLOWED_BTC_METHODS, type BtcRpcRequestBody, type Env } from "../types"; + +export async function validateBtcMiddleware(c: Context<{ Bindings: Env }>, next: Next) { + let body: BtcRpcRequestBody; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid JSON" }, 400); + } + + if (body.jsonrpc !== "2.0") { + return c.json({ error: 'jsonrpc must be "2.0"' }, 400); + } + + if (typeof body.method !== "string" || !ALLOWED_BTC_METHODS.includes(body.method as never)) { + return c.json({ error: `Method not allowed. Allowed: ${ALLOWED_BTC_METHODS.join(", ")}` }, 400); + } + + if (!Array.isArray(body.params)) { + return c.json({ error: "params must be an array" }, 400); + } + + c.set("validatedBody" as never, body as never); + await next(); +} diff --git a/worker/src/middleware/validateEvm.ts b/worker/src/middleware/validateEvm.ts new file mode 100644 index 00000000..ede81053 --- /dev/null +++ b/worker/src/middleware/validateEvm.ts @@ -0,0 +1,33 @@ +import type { Context, Next } from "hono"; +import { ALLOWED_EVM_NETWORKS, type EvmRpcRequestBody, type Env } from "../types"; + +export async function validateEvmMiddleware(c: Context<{ Bindings: Env }>, next: Next) { + const networkId = c.req.param("networkId"); + + if (!networkId || !ALLOWED_EVM_NETWORKS[networkId]) { + const allowed = Object.keys(ALLOWED_EVM_NETWORKS).join(", "); + return c.json({ error: `Invalid networkId. Allowed: ${allowed}` }, 400); + } + + let body: EvmRpcRequestBody; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "Invalid JSON" }, 400); + } + + if (body.jsonrpc !== "2.0") { + return c.json({ error: 'jsonrpc must be "2.0"' }, 400); + } + + if (typeof body.method !== "string" || body.method.length === 0) { + return c.json({ error: "method must be a non-empty string" }, 400); + } + + if (!Array.isArray(body.params)) { + return c.json({ error: "params must be an array" }, 400); + } + + c.set("validatedBody" as never, body as never); + await next(); +} diff --git a/worker/src/routes/beaconBlobSidecars.ts b/worker/src/routes/beaconBlobSidecars.ts new file mode 100644 index 00000000..31fd5d2e --- /dev/null +++ b/worker/src/routes/beaconBlobSidecars.ts @@ -0,0 +1,47 @@ +import type { Context } from "hono"; +import { ALLOWED_BEACON_NETWORKS, type Env } from "../types"; + +const ALCHEMY_BEACON_HOSTS: Record = { + "eth-mainnet": "eth-mainnetbeacon.g.alchemy.com", + "eth-sepolia": "eth-sepoliabeacon.g.alchemy.com", +}; + +export async function beaconAlchemyHandler(c: Context<{ Bindings: Env }>) { + const networkId = c.req.param("networkId")!; + const slot = c.req.param("slot")!; + + const networkSlug = ALLOWED_BEACON_NETWORKS[networkId]; + if (!networkSlug) { + return c.json({ error: "Unsupported network" }, 400); + } + + const host = ALCHEMY_BEACON_HOSTS[networkSlug]; + if (!host) { + return c.json({ error: "Beacon not available for this network" }, 400); + } + + const url = `https://${host}/v2/${c.env.ALCHEMY_API_KEY}/eth/v1/beacon/blob_sidecars/${slot}`; + + try { + const response = await fetch(url); + + if (!response.ok) { + const status = response.status; + if (status === 429) { + const retryAfter = response.headers.get("Retry-After"); + if (retryAfter) c.header("Retry-After", retryAfter); + return c.json({ error: "Beacon API rate limit exceeded" }, 429); + } + if (status === 404) { + const data = await response.json(); + return c.json(data, 404); + } + return c.json({ error: `Beacon API error (HTTP ${status})` }, 502); + } + + const data = await response.json(); + return c.json(data); + } catch { + return c.json({ error: "Failed to connect to Beacon API" }, 502); + } +} diff --git a/worker/src/routes/btcRpc.ts b/worker/src/routes/btcRpc.ts new file mode 100644 index 00000000..566842ea --- /dev/null +++ b/worker/src/routes/btcRpc.ts @@ -0,0 +1,32 @@ +import type { Context } from "hono"; +import type { BtcRpcRequestBody, Env } from "../types"; + +const ALCHEMY_BTC_URL = "https://bitcoin-mainnet.g.alchemy.com/v2"; + +export async function btcAlchemyHandler(c: Context<{ Bindings: Env }>) { + const body = c.get("validatedBody" as never) as unknown as BtcRpcRequestBody; + const url = `${ALCHEMY_BTC_URL}/${c.env.ALCHEMY_API_KEY}`; + + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const status = response.status; + if (status === 429) { + const retryAfter = response.headers.get("Retry-After"); + if (retryAfter) c.header("Retry-After", retryAfter); + return c.json({ error: "Bitcoin RPC rate limit exceeded" }, 429); + } + return c.json({ error: `Bitcoin RPC error (HTTP ${status})` }, 502); + } + + const data = await response.json(); + return c.json(data); + } catch { + return c.json({ error: "Failed to connect to Bitcoin RPC" }, 502); + } +} diff --git a/worker/src/routes/evmRpc.ts b/worker/src/routes/evmRpc.ts new file mode 100644 index 00000000..dff32296 --- /dev/null +++ b/worker/src/routes/evmRpc.ts @@ -0,0 +1,72 @@ +import type { Context } from "hono"; +import { ALLOWED_EVM_NETWORKS, type EvmRpcRequestBody, type Env } from "../types"; + +export async function evmAlchemyHandler(c: Context<{ Bindings: Env }>) { + const networkId = c.req.param("networkId")!; + const body = c.get("validatedBody" as never) as unknown as EvmRpcRequestBody; + + const network = ALLOWED_EVM_NETWORKS[networkId]; + if (!network) { + return c.json({ error: "Unsupported network" }, 400); + } + + const url = `https://${network.alchemy}.g.alchemy.com/v2/${c.env.ALCHEMY_API_KEY}`; + + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const status = response.status; + if (status === 429) { + const retryAfter = response.headers.get("Retry-After"); + if (retryAfter) c.header("Retry-After", retryAfter); + return c.json({ error: "EVM RPC rate limit exceeded" }, 429); + } + return c.json({ error: `EVM RPC error (HTTP ${status})` }, 502); + } + + const data = await response.json(); + return c.json(data); + } catch { + return c.json({ error: "Failed to connect to EVM RPC" }, 502); + } +} + +export async function evmInfuraHandler(c: Context<{ Bindings: Env }>) { + const networkId = c.req.param("networkId")!; + const body = c.get("validatedBody" as never) as unknown as EvmRpcRequestBody; + + const network = ALLOWED_EVM_NETWORKS[networkId]; + if (!network?.infura) { + return c.json({ error: "Infura not available for this network" }, 400); + } + + const url = `https://${network.infura}.infura.io/v3/${c.env.INFURA_API_KEY}`; + + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const status = response.status; + if (status === 429) { + const retryAfter = response.headers.get("Retry-After"); + if (retryAfter) c.header("Retry-After", retryAfter); + return c.json({ error: "EVM RPC rate limit exceeded" }, 429); + } + return c.json({ error: `EVM RPC error (HTTP ${status})` }, 502); + } + + const data = await response.json(); + return c.json(data); + } catch { + return c.json({ error: "Failed to connect to EVM RPC" }, 502); + } +} diff --git a/worker/src/types.ts b/worker/src/types.ts index bcd82948..c8851296 100644 --- a/worker/src/types.ts +++ b/worker/src/types.ts @@ -23,6 +23,65 @@ export interface EtherscanVerifyRequestBody { export interface Env { GROQ_API_KEY: string; ETHERSCAN_API_KEY: string; + ALCHEMY_API_KEY: string; + INFURA_API_KEY: string; ALLOWED_ORIGINS: string; GROQ_MODEL: string; } + +// ── Beacon types ────────────────────────────────────────────────────────────── + +/** Beacon API is only supported on these networks */ +export const ALLOWED_BEACON_NETWORKS: Record = { + "eip155:1": "eth-mainnet", + "eip155:11155111": "eth-sepolia", +}; + +// ── Bitcoin types ───────────────────────────────────────────────────────────── + +export const ALLOWED_BTC_METHODS = [ + "getblock", + "getrawtransaction", + "getblockchaininfo", + "getblockcount", + "getblockhash", + "getrawmempool", + "getmempoolinfo", + "getmempoolentry", + "estimatesmartfee", + "gettxout", + "scantxoutset", + "getblockheader", + "decoderawtransaction", + "listunspent", + "validateaddress", + "getblockstats", +] as const; + +export interface BtcRpcRequestBody { + jsonrpc: string; + method: string; + params: unknown[]; + id: unknown; +} + +// ── EVM types ───────────────────────────────────────────────────────────────── + +/** Maps CAIP-2 networkId → { alchemy slug, infura slug } */ +export const ALLOWED_EVM_NETWORKS: Record = { + "eip155:1": { alchemy: "eth-mainnet", infura: "mainnet" }, + "eip155:11155111": { alchemy: "eth-sepolia", infura: "sepolia" }, + "eip155:42161": { alchemy: "arb-mainnet", infura: "arbitrum-mainnet" }, + "eip155:10": { alchemy: "opt-mainnet", infura: "optimism-mainnet" }, + "eip155:8453": { alchemy: "base-mainnet", infura: "base-mainnet" }, + "eip155:137": { alchemy: "polygon-mainnet", infura: "polygon-mainnet" }, + "eip155:56": { alchemy: "bnb-mainnet" }, + "eip155:43114": { alchemy: "avax-mainnet", infura: "avalanche-mainnet" }, +}; + +export interface EvmRpcRequestBody { + jsonrpc: string; + method: string; + params: unknown[]; + id: unknown; +} diff --git a/worker/wrangler.toml b/worker/wrangler.toml index 076fb635..a8528565 100644 --- a/worker/wrangler.toml +++ b/worker/wrangler.toml @@ -9,4 +9,6 @@ GROQ_MODEL = "groq/compound" # Secrets — set via `wrangler secret put ` # GROQ_API_KEY — Groq AI API key for /ai/analyze # ETHERSCAN_API_KEY — Etherscan V2 API key for /etherscan/verify +# ALCHEMY_API_KEY — Alchemy API key for /beacon/*, /btc/alchemy, /evm/alchemy/* +# INFURA_API_KEY — Infura API key for /evm/infura/* From afd14d8ebdd917fcc325088abd45776162e00fe4 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Fri, 20 Mar 2026 08:20:26 -0300 Subject: [PATCH 28/54] feat(worker): add dRPC as third provider for EVM and Bitcoin RPC proxy Add dRPC routes reusing existing rate limiting and validation middleware. dRPC supports all EVM networks plus Bitcoin via authenticated query params. Routes: - POST /evm/drpc/:networkId - POST /btc/drpc --- src/components/pages/settings/index.tsx | 6 ++- src/utils/rpcStorage.ts | 16 +++++- worker/src/index.ts | 7 +++ worker/src/routes/drpcRpc.ts | 67 +++++++++++++++++++++++++ worker/src/types.ts | 24 +++++---- worker/wrangler.toml | 1 + 6 files changed, 108 insertions(+), 13 deletions(-) create mode 100644 worker/src/routes/drpcRpc.ts diff --git a/src/components/pages/settings/index.tsx b/src/components/pages/settings/index.tsx index e3e588d4..cd2b2751 100644 --- a/src/components/pages/settings/index.tsx +++ b/src/components/pages/settings/index.tsx @@ -89,6 +89,8 @@ const isAlchemyUrl = (url: string): boolean => url.includes("alchemy.com"); const isWorkerAlchemyUrl = (url: string): boolean => url.includes("/evm/alchemy/") || url.includes("/btc/alchemy") || url.includes("/beacon/alchemy/"); const isWorkerInfuraUrl = (url: string): boolean => url.includes("/evm/infura/"); +const isWorkerDrpcUrl = (url: string): boolean => + url.includes("/evm/drpc/") || url.includes("/btc/drpc"); const Settings: React.FC = () => { const { t, i18n } = useTranslation("settings"); @@ -609,7 +611,8 @@ const Settings: React.FC = () => { const getRpcTagClass = useCallback( (url: string): string => { - if (isWorkerAlchemyUrl(url) || isWorkerInfuraUrl(url)) return "rpc-opensource"; + if (isWorkerAlchemyUrl(url) || isWorkerInfuraUrl(url) || isWorkerDrpcUrl(url)) + return "rpc-opensource"; if (isInfuraUrl(url) || isAlchemyUrl(url)) return "rpc-tracking"; const ep = metadataUrlMap.get(url); if (!ep) return ""; @@ -624,6 +627,7 @@ const Settings: React.FC = () => { (url: string): string => { if (isWorkerAlchemyUrl(url)) return "OpenScan Alchemy"; if (isWorkerInfuraUrl(url)) return "OpenScan Infura"; + if (isWorkerDrpcUrl(url)) return "OpenScan dRPC"; if (isInfuraUrl(url)) return "Infura Personal"; if (isAlchemyUrl(url)) return "Alchemy Personal"; const ep = metadataUrlMap.get(url); diff --git a/src/utils/rpcStorage.ts b/src/utils/rpcStorage.ts index 3bdbca87..e5dfb625 100644 --- a/src/utils/rpcStorage.ts +++ b/src/utils/rpcStorage.ts @@ -14,31 +14,43 @@ const METADATA_RPC_TTL = 24 * 60 * 60 * 1000; // 24 hours */ const BUILTIN_RPC_DEFAULTS: RpcUrlsContextType = { "eip155:31337": ["http://localhost:8545"], - "bip122:000000000019d6689c085ae165831e93": [`${OPENSCAN_WORKER_URL}/btc/alchemy`], + "bip122:000000000019d6689c085ae165831e93": [ + `${OPENSCAN_WORKER_URL}/btc/alchemy`, + `${OPENSCAN_WORKER_URL}/btc/drpc`, + ], "eip155:1": [ `${OPENSCAN_WORKER_URL}/evm/alchemy/eip155:1`, `${OPENSCAN_WORKER_URL}/evm/infura/eip155:1`, + `${OPENSCAN_WORKER_URL}/evm/drpc/eip155:1`, ], "eip155:42161": [ `${OPENSCAN_WORKER_URL}/evm/alchemy/eip155:42161`, `${OPENSCAN_WORKER_URL}/evm/infura/eip155:42161`, + `${OPENSCAN_WORKER_URL}/evm/drpc/eip155:42161`, ], "eip155:10": [ `${OPENSCAN_WORKER_URL}/evm/alchemy/eip155:10`, `${OPENSCAN_WORKER_URL}/evm/infura/eip155:10`, + `${OPENSCAN_WORKER_URL}/evm/drpc/eip155:10`, ], "eip155:8453": [ `${OPENSCAN_WORKER_URL}/evm/alchemy/eip155:8453`, `${OPENSCAN_WORKER_URL}/evm/infura/eip155:8453`, + `${OPENSCAN_WORKER_URL}/evm/drpc/eip155:8453`, ], "eip155:137": [ `${OPENSCAN_WORKER_URL}/evm/alchemy/eip155:137`, `${OPENSCAN_WORKER_URL}/evm/infura/eip155:137`, + `${OPENSCAN_WORKER_URL}/evm/drpc/eip155:137`, + ], + "eip155:56": [ + `${OPENSCAN_WORKER_URL}/evm/alchemy/eip155:56`, + `${OPENSCAN_WORKER_URL}/evm/drpc/eip155:56`, ], - "eip155:56": [`${OPENSCAN_WORKER_URL}/evm/alchemy/eip155:56`], "eip155:43114": [ `${OPENSCAN_WORKER_URL}/evm/alchemy/eip155:43114`, `${OPENSCAN_WORKER_URL}/evm/infura/eip155:43114`, + `${OPENSCAN_WORKER_URL}/evm/drpc/eip155:43114`, ], }; diff --git a/worker/src/index.ts b/worker/src/index.ts index 66e3d511..e34cc4e9 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -15,6 +15,7 @@ import { analyzeHandler } from "./routes/analyze"; import { beaconAlchemyHandler } from "./routes/beaconBlobSidecars"; import { btcAlchemyHandler } from "./routes/btcRpc"; import { etherscanVerifyHandler } from "./routes/etherscanVerify"; +import { btcDrpcHandler, evmDrpcHandler } from "./routes/drpcRpc"; import { evmAlchemyHandler, evmInfuraHandler } from "./routes/evmRpc"; const app = new Hono<{ Bindings: Env }>(); @@ -60,6 +61,12 @@ app.post( evmInfuraHandler, ); +// POST /evm/drpc/:networkId — EVM JSON-RPC proxy via dRPC +app.post("/evm/drpc/:networkId", rateLimitEvmMiddleware, validateEvmMiddleware, evmDrpcHandler); + +// POST /btc/drpc — Bitcoin JSON-RPC proxy via dRPC +app.post("/btc/drpc", rateLimitBtcMiddleware, validateBtcMiddleware, btcDrpcHandler); + // Health check app.get("/health", (c) => c.json({ status: "ok" })); diff --git a/worker/src/routes/drpcRpc.ts b/worker/src/routes/drpcRpc.ts new file mode 100644 index 00000000..8ab5e7c3 --- /dev/null +++ b/worker/src/routes/drpcRpc.ts @@ -0,0 +1,67 @@ +import type { Context } from "hono"; +import { ALLOWED_EVM_NETWORKS, type BtcRpcRequestBody, type EvmRpcRequestBody, type Env } from "../types"; + +const DRPC_BASE = "https://lb.drpc.org/ogrpc"; + +export async function evmDrpcHandler(c: Context<{ Bindings: Env }>) { + const networkId = c.req.param("networkId")!; + const body = c.get("validatedBody" as never) as unknown as EvmRpcRequestBody; + + const network = ALLOWED_EVM_NETWORKS[networkId]; + if (!network) { + return c.json({ error: "Unsupported network" }, 400); + } + + const url = `${DRPC_BASE}?network=${network.drpc}&dkey=${c.env.DRPC_API_KEY}`; + + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const status = response.status; + if (status === 429) { + const retryAfter = response.headers.get("Retry-After"); + if (retryAfter) c.header("Retry-After", retryAfter); + return c.json({ error: "EVM RPC rate limit exceeded" }, 429); + } + return c.json({ error: `EVM RPC error (HTTP ${status})` }, 502); + } + + const data = await response.json(); + return c.json(data); + } catch { + return c.json({ error: "Failed to connect to EVM RPC" }, 502); + } +} + +export async function btcDrpcHandler(c: Context<{ Bindings: Env }>) { + const body = c.get("validatedBody" as never) as unknown as BtcRpcRequestBody; + const url = `${DRPC_BASE}?network=bitcoin&dkey=${c.env.DRPC_API_KEY}`; + + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const status = response.status; + if (status === 429) { + const retryAfter = response.headers.get("Retry-After"); + if (retryAfter) c.header("Retry-After", retryAfter); + return c.json({ error: "Bitcoin RPC rate limit exceeded" }, 429); + } + return c.json({ error: `Bitcoin RPC error (HTTP ${status})` }, 502); + } + + const data = await response.json(); + return c.json(data); + } catch { + return c.json({ error: "Failed to connect to Bitcoin RPC" }, 502); + } +} diff --git a/worker/src/types.ts b/worker/src/types.ts index c8851296..18498673 100644 --- a/worker/src/types.ts +++ b/worker/src/types.ts @@ -25,6 +25,7 @@ export interface Env { ETHERSCAN_API_KEY: string; ALCHEMY_API_KEY: string; INFURA_API_KEY: string; + DRPC_API_KEY: string; ALLOWED_ORIGINS: string; GROQ_MODEL: string; } @@ -67,16 +68,19 @@ export interface BtcRpcRequestBody { // ── EVM types ───────────────────────────────────────────────────────────────── -/** Maps CAIP-2 networkId → { alchemy slug, infura slug } */ -export const ALLOWED_EVM_NETWORKS: Record = { - "eip155:1": { alchemy: "eth-mainnet", infura: "mainnet" }, - "eip155:11155111": { alchemy: "eth-sepolia", infura: "sepolia" }, - "eip155:42161": { alchemy: "arb-mainnet", infura: "arbitrum-mainnet" }, - "eip155:10": { alchemy: "opt-mainnet", infura: "optimism-mainnet" }, - "eip155:8453": { alchemy: "base-mainnet", infura: "base-mainnet" }, - "eip155:137": { alchemy: "polygon-mainnet", infura: "polygon-mainnet" }, - "eip155:56": { alchemy: "bnb-mainnet" }, - "eip155:43114": { alchemy: "avax-mainnet", infura: "avalanche-mainnet" }, +/** Maps CAIP-2 networkId → { alchemy slug, infura slug, drpc slug } */ +export const ALLOWED_EVM_NETWORKS: Record< + string, + { alchemy: string; infura?: string; drpc: string } +> = { + "eip155:1": { alchemy: "eth-mainnet", infura: "mainnet", drpc: "ethereum" }, + "eip155:11155111": { alchemy: "eth-sepolia", infura: "sepolia", drpc: "sepolia" }, + "eip155:42161": { alchemy: "arb-mainnet", infura: "arbitrum-mainnet", drpc: "arbitrum" }, + "eip155:10": { alchemy: "opt-mainnet", infura: "optimism-mainnet", drpc: "optimism" }, + "eip155:8453": { alchemy: "base-mainnet", infura: "base-mainnet", drpc: "base" }, + "eip155:137": { alchemy: "polygon-mainnet", infura: "polygon-mainnet", drpc: "polygon" }, + "eip155:56": { alchemy: "bnb-mainnet", drpc: "bsc" }, + "eip155:43114": { alchemy: "avax-mainnet", infura: "avalanche-mainnet", drpc: "avalanche" }, }; export interface EvmRpcRequestBody { diff --git a/worker/wrangler.toml b/worker/wrangler.toml index a8528565..be6ce252 100644 --- a/worker/wrangler.toml +++ b/worker/wrangler.toml @@ -11,4 +11,5 @@ GROQ_MODEL = "groq/compound" # ETHERSCAN_API_KEY — Etherscan V2 API key for /etherscan/verify # ALCHEMY_API_KEY — Alchemy API key for /beacon/*, /btc/alchemy, /evm/alchemy/* # INFURA_API_KEY — Infura API key for /evm/infura/* +# DRPC_API_KEY — dRPC API key for /evm/drpc/*, /btc/drpc From 1a1b7a143da01b187f24820af247b5d86dda1b8c Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Fri, 20 Mar 2026 08:32:05 -0300 Subject: [PATCH 29/54] chore(worker): rename worker to openscan-worker-proxy Rename from openscan-groq-ai-proxy to reflect broader scope. Old worker remains live until all frontend builds use the new URL. --- src/config/workerConfig.ts | 2 +- worker/wrangler.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/workerConfig.ts b/src/config/workerConfig.ts index d97c0c56..4ac4653f 100644 --- a/src/config/workerConfig.ts +++ b/src/config/workerConfig.ts @@ -2,4 +2,4 @@ export const OPENSCAN_WORKER_URL = // biome-ignore lint/complexity/useLiteralKeys: env var access process.env["REACT_APP_OPENSCAN_WORKER_URL"] ?? - "https://openscan-groq-ai-proxy.openscan.workers.dev"; + "https://openscan-worker-proxy.openscan.workers.dev"; diff --git a/worker/wrangler.toml b/worker/wrangler.toml index be6ce252..6d48b73c 100644 --- a/worker/wrangler.toml +++ b/worker/wrangler.toml @@ -1,4 +1,4 @@ -name = "openscan-groq-ai-proxy" +name = "openscan-worker-proxy" main = "src/index.ts" compatibility_date = "2024-12-01" From c6f5f59c442d56bd4845f300c147c88a78a21e1d Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Fri, 20 Mar 2026 08:38:33 -0300 Subject: [PATCH 30/54] feat(worker): add OnFinality as Bitcoin RPC provider (mainnet + testnet) Route: POST /btc/onfinality/:networkId Supports bip122:000000000019d6689c085ae165831e93 (mainnet) and bip122:000000000933ea01ad0ee984209779ba (testnet). --- src/components/pages/settings/index.tsx | 9 +++++- src/utils/rpcStorage.ts | 4 +++ worker/src/index.ts | 9 ++++++ worker/src/routes/onfinalityRpc.ts | 43 +++++++++++++++++++++++++ worker/src/types.ts | 1 + worker/wrangler.toml | 1 + 6 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 worker/src/routes/onfinalityRpc.ts diff --git a/src/components/pages/settings/index.tsx b/src/components/pages/settings/index.tsx index cd2b2751..b7308ae5 100644 --- a/src/components/pages/settings/index.tsx +++ b/src/components/pages/settings/index.tsx @@ -91,6 +91,7 @@ const isWorkerAlchemyUrl = (url: string): boolean => const isWorkerInfuraUrl = (url: string): boolean => url.includes("/evm/infura/"); const isWorkerDrpcUrl = (url: string): boolean => url.includes("/evm/drpc/") || url.includes("/btc/drpc"); +const isWorkerOnfinalityUrl = (url: string): boolean => url.includes("/btc/onfinality/"); const Settings: React.FC = () => { const { t, i18n } = useTranslation("settings"); @@ -611,7 +612,12 @@ const Settings: React.FC = () => { const getRpcTagClass = useCallback( (url: string): string => { - if (isWorkerAlchemyUrl(url) || isWorkerInfuraUrl(url) || isWorkerDrpcUrl(url)) + if ( + isWorkerAlchemyUrl(url) || + isWorkerInfuraUrl(url) || + isWorkerDrpcUrl(url) || + isWorkerOnfinalityUrl(url) + ) return "rpc-opensource"; if (isInfuraUrl(url) || isAlchemyUrl(url)) return "rpc-tracking"; const ep = metadataUrlMap.get(url); @@ -628,6 +634,7 @@ const Settings: React.FC = () => { if (isWorkerAlchemyUrl(url)) return "OpenScan Alchemy"; if (isWorkerInfuraUrl(url)) return "OpenScan Infura"; if (isWorkerDrpcUrl(url)) return "OpenScan dRPC"; + if (isWorkerOnfinalityUrl(url)) return "OpenScan OnFinality"; if (isInfuraUrl(url)) return "Infura Personal"; if (isAlchemyUrl(url)) return "Alchemy Personal"; const ep = metadataUrlMap.get(url); diff --git a/src/utils/rpcStorage.ts b/src/utils/rpcStorage.ts index e5dfb625..02739021 100644 --- a/src/utils/rpcStorage.ts +++ b/src/utils/rpcStorage.ts @@ -17,6 +17,10 @@ const BUILTIN_RPC_DEFAULTS: RpcUrlsContextType = { "bip122:000000000019d6689c085ae165831e93": [ `${OPENSCAN_WORKER_URL}/btc/alchemy`, `${OPENSCAN_WORKER_URL}/btc/drpc`, + `${OPENSCAN_WORKER_URL}/btc/onfinality/bip122:000000000019d6689c085ae165831e93`, + ], + "bip122:000000000933ea01ad0ee984209779ba": [ + `${OPENSCAN_WORKER_URL}/btc/onfinality/bip122:000000000933ea01ad0ee984209779ba`, ], "eip155:1": [ `${OPENSCAN_WORKER_URL}/evm/alchemy/eip155:1`, diff --git a/worker/src/index.ts b/worker/src/index.ts index e34cc4e9..6cf9063d 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -17,6 +17,7 @@ import { btcAlchemyHandler } from "./routes/btcRpc"; import { etherscanVerifyHandler } from "./routes/etherscanVerify"; import { btcDrpcHandler, evmDrpcHandler } from "./routes/drpcRpc"; import { evmAlchemyHandler, evmInfuraHandler } from "./routes/evmRpc"; +import { btcOnfinalityHandler } from "./routes/onfinalityRpc"; const app = new Hono<{ Bindings: Env }>(); @@ -67,6 +68,14 @@ app.post("/evm/drpc/:networkId", rateLimitEvmMiddleware, validateEvmMiddleware, // POST /btc/drpc — Bitcoin JSON-RPC proxy via dRPC app.post("/btc/drpc", rateLimitBtcMiddleware, validateBtcMiddleware, btcDrpcHandler); +// POST /btc/onfinality/:networkId — Bitcoin JSON-RPC proxy via OnFinality +app.post( + "/btc/onfinality/:networkId", + rateLimitBtcMiddleware, + validateBtcMiddleware, + btcOnfinalityHandler, +); + // Health check app.get("/health", (c) => c.json({ status: "ok" })); diff --git a/worker/src/routes/onfinalityRpc.ts b/worker/src/routes/onfinalityRpc.ts new file mode 100644 index 00000000..5564a5f3 --- /dev/null +++ b/worker/src/routes/onfinalityRpc.ts @@ -0,0 +1,43 @@ +import type { Context } from "hono"; +import type { BtcRpcRequestBody, Env } from "../types"; + +const ONFINALITY_BTC_HOSTS: Record = { + "bip122:000000000019d6689c085ae165831e93": "bitcoin.api.onfinality.io", + "bip122:000000000933ea01ad0ee984209779ba": "bitcoin-testnet.api.onfinality.io", +}; + +export async function btcOnfinalityHandler(c: Context<{ Bindings: Env }>) { + const networkId = c.req.param("networkId")!; + const body = c.get("validatedBody" as never) as unknown as BtcRpcRequestBody; + + const host = ONFINALITY_BTC_HOSTS[networkId]; + if (!host) { + const allowed = Object.keys(ONFINALITY_BTC_HOSTS).join(", "); + return c.json({ error: `Invalid networkId. Allowed: ${allowed}` }, 400); + } + + const url = `https://${host}/rpc?apikey=${c.env.ONFINALITY_BTC_API_KEY}`; + + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const status = response.status; + if (status === 429) { + const retryAfter = response.headers.get("Retry-After"); + if (retryAfter) c.header("Retry-After", retryAfter); + return c.json({ error: "Bitcoin RPC rate limit exceeded" }, 429); + } + return c.json({ error: `Bitcoin RPC error (HTTP ${status})` }, 502); + } + + const data = await response.json(); + return c.json(data); + } catch { + return c.json({ error: "Failed to connect to Bitcoin RPC" }, 502); + } +} diff --git a/worker/src/types.ts b/worker/src/types.ts index 18498673..63473c21 100644 --- a/worker/src/types.ts +++ b/worker/src/types.ts @@ -26,6 +26,7 @@ export interface Env { ALCHEMY_API_KEY: string; INFURA_API_KEY: string; DRPC_API_KEY: string; + ONFINALITY_BTC_API_KEY: string; ALLOWED_ORIGINS: string; GROQ_MODEL: string; } diff --git a/worker/wrangler.toml b/worker/wrangler.toml index 6d48b73c..73f6b80d 100644 --- a/worker/wrangler.toml +++ b/worker/wrangler.toml @@ -12,4 +12,5 @@ GROQ_MODEL = "groq/compound" # ALCHEMY_API_KEY — Alchemy API key for /beacon/*, /btc/alchemy, /evm/alchemy/* # INFURA_API_KEY — Infura API key for /evm/infura/* # DRPC_API_KEY — dRPC API key for /evm/drpc/*, /btc/drpc +# ONFINALITY_BTC_API_KEY — OnFinality API key for /btc/onfinality/* From b28d34d01fc3b8f5b94173e090c1d4c824179b3f Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Fri, 20 Mar 2026 08:58:48 -0300 Subject: [PATCH 31/54] feat(worker): add Ankr as EVM and Bitcoin RPC provider Routes: - POST /evm/ankr/:networkId (all 8 EVM networks) - POST /btc/ankr --- src/components/pages/settings/index.tsx | 4 ++ src/utils/rpcStorage.ts | 8 +++ worker/src/index.ts | 7 +++ worker/src/routes/ankrRpc.ts | 67 +++++++++++++++++++++++++ worker/src/types.ts | 46 +++++++++++++---- worker/wrangler.toml | 1 + 6 files changed, 123 insertions(+), 10 deletions(-) create mode 100644 worker/src/routes/ankrRpc.ts diff --git a/src/components/pages/settings/index.tsx b/src/components/pages/settings/index.tsx index b7308ae5..8f10800e 100644 --- a/src/components/pages/settings/index.tsx +++ b/src/components/pages/settings/index.tsx @@ -91,6 +91,8 @@ const isWorkerAlchemyUrl = (url: string): boolean => const isWorkerInfuraUrl = (url: string): boolean => url.includes("/evm/infura/"); const isWorkerDrpcUrl = (url: string): boolean => url.includes("/evm/drpc/") || url.includes("/btc/drpc"); +const isWorkerAnkrUrl = (url: string): boolean => + url.includes("/evm/ankr/") || url.includes("/btc/ankr"); const isWorkerOnfinalityUrl = (url: string): boolean => url.includes("/btc/onfinality/"); const Settings: React.FC = () => { @@ -616,6 +618,7 @@ const Settings: React.FC = () => { isWorkerAlchemyUrl(url) || isWorkerInfuraUrl(url) || isWorkerDrpcUrl(url) || + isWorkerAnkrUrl(url) || isWorkerOnfinalityUrl(url) ) return "rpc-opensource"; @@ -634,6 +637,7 @@ const Settings: React.FC = () => { if (isWorkerAlchemyUrl(url)) return "OpenScan Alchemy"; if (isWorkerInfuraUrl(url)) return "OpenScan Infura"; if (isWorkerDrpcUrl(url)) return "OpenScan dRPC"; + if (isWorkerAnkrUrl(url)) return "OpenScan Ankr"; if (isWorkerOnfinalityUrl(url)) return "OpenScan OnFinality"; if (isInfuraUrl(url)) return "Infura Personal"; if (isAlchemyUrl(url)) return "Alchemy Personal"; diff --git a/src/utils/rpcStorage.ts b/src/utils/rpcStorage.ts index 02739021..a823eca4 100644 --- a/src/utils/rpcStorage.ts +++ b/src/utils/rpcStorage.ts @@ -17,6 +17,7 @@ const BUILTIN_RPC_DEFAULTS: RpcUrlsContextType = { "bip122:000000000019d6689c085ae165831e93": [ `${OPENSCAN_WORKER_URL}/btc/alchemy`, `${OPENSCAN_WORKER_URL}/btc/drpc`, + `${OPENSCAN_WORKER_URL}/btc/ankr`, `${OPENSCAN_WORKER_URL}/btc/onfinality/bip122:000000000019d6689c085ae165831e93`, ], "bip122:000000000933ea01ad0ee984209779ba": [ @@ -26,35 +27,42 @@ const BUILTIN_RPC_DEFAULTS: RpcUrlsContextType = { `${OPENSCAN_WORKER_URL}/evm/alchemy/eip155:1`, `${OPENSCAN_WORKER_URL}/evm/infura/eip155:1`, `${OPENSCAN_WORKER_URL}/evm/drpc/eip155:1`, + `${OPENSCAN_WORKER_URL}/evm/ankr/eip155:1`, ], "eip155:42161": [ `${OPENSCAN_WORKER_URL}/evm/alchemy/eip155:42161`, `${OPENSCAN_WORKER_URL}/evm/infura/eip155:42161`, `${OPENSCAN_WORKER_URL}/evm/drpc/eip155:42161`, + `${OPENSCAN_WORKER_URL}/evm/ankr/eip155:42161`, ], "eip155:10": [ `${OPENSCAN_WORKER_URL}/evm/alchemy/eip155:10`, `${OPENSCAN_WORKER_URL}/evm/infura/eip155:10`, `${OPENSCAN_WORKER_URL}/evm/drpc/eip155:10`, + `${OPENSCAN_WORKER_URL}/evm/ankr/eip155:10`, ], "eip155:8453": [ `${OPENSCAN_WORKER_URL}/evm/alchemy/eip155:8453`, `${OPENSCAN_WORKER_URL}/evm/infura/eip155:8453`, `${OPENSCAN_WORKER_URL}/evm/drpc/eip155:8453`, + `${OPENSCAN_WORKER_URL}/evm/ankr/eip155:8453`, ], "eip155:137": [ `${OPENSCAN_WORKER_URL}/evm/alchemy/eip155:137`, `${OPENSCAN_WORKER_URL}/evm/infura/eip155:137`, `${OPENSCAN_WORKER_URL}/evm/drpc/eip155:137`, + `${OPENSCAN_WORKER_URL}/evm/ankr/eip155:137`, ], "eip155:56": [ `${OPENSCAN_WORKER_URL}/evm/alchemy/eip155:56`, `${OPENSCAN_WORKER_URL}/evm/drpc/eip155:56`, + `${OPENSCAN_WORKER_URL}/evm/ankr/eip155:56`, ], "eip155:43114": [ `${OPENSCAN_WORKER_URL}/evm/alchemy/eip155:43114`, `${OPENSCAN_WORKER_URL}/evm/infura/eip155:43114`, `${OPENSCAN_WORKER_URL}/evm/drpc/eip155:43114`, + `${OPENSCAN_WORKER_URL}/evm/ankr/eip155:43114`, ], }; diff --git a/worker/src/index.ts b/worker/src/index.ts index 6cf9063d..cc18aac2 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -12,6 +12,7 @@ import { validateBtcMiddleware } from "./middleware/validateBtc"; import { validateEtherscanMiddleware } from "./middleware/validateEtherscan"; import { validateEvmMiddleware } from "./middleware/validateEvm"; import { analyzeHandler } from "./routes/analyze"; +import { btcAnkrHandler, evmAnkrHandler } from "./routes/ankrRpc"; import { beaconAlchemyHandler } from "./routes/beaconBlobSidecars"; import { btcAlchemyHandler } from "./routes/btcRpc"; import { etherscanVerifyHandler } from "./routes/etherscanVerify"; @@ -68,6 +69,12 @@ app.post("/evm/drpc/:networkId", rateLimitEvmMiddleware, validateEvmMiddleware, // POST /btc/drpc — Bitcoin JSON-RPC proxy via dRPC app.post("/btc/drpc", rateLimitBtcMiddleware, validateBtcMiddleware, btcDrpcHandler); +// POST /evm/ankr/:networkId — EVM JSON-RPC proxy via Ankr +app.post("/evm/ankr/:networkId", rateLimitEvmMiddleware, validateEvmMiddleware, evmAnkrHandler); + +// POST /btc/ankr — Bitcoin JSON-RPC proxy via Ankr +app.post("/btc/ankr", rateLimitBtcMiddleware, validateBtcMiddleware, btcAnkrHandler); + // POST /btc/onfinality/:networkId — Bitcoin JSON-RPC proxy via OnFinality app.post( "/btc/onfinality/:networkId", diff --git a/worker/src/routes/ankrRpc.ts b/worker/src/routes/ankrRpc.ts new file mode 100644 index 00000000..354a5c6b --- /dev/null +++ b/worker/src/routes/ankrRpc.ts @@ -0,0 +1,67 @@ +import type { Context } from "hono"; +import { ALLOWED_EVM_NETWORKS, type BtcRpcRequestBody, type EvmRpcRequestBody, type Env } from "../types"; + +const ANKR_BASE = "https://rpc.ankr.com"; + +export async function evmAnkrHandler(c: Context<{ Bindings: Env }>) { + const networkId = c.req.param("networkId")!; + const body = c.get("validatedBody" as never) as unknown as EvmRpcRequestBody; + + const network = ALLOWED_EVM_NETWORKS[networkId]; + if (!network) { + return c.json({ error: "Unsupported network" }, 400); + } + + const url = `${ANKR_BASE}/${network.ankr}/${c.env.ANKR_API_KEY}`; + + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const status = response.status; + if (status === 429) { + const retryAfter = response.headers.get("Retry-After"); + if (retryAfter) c.header("Retry-After", retryAfter); + return c.json({ error: "EVM RPC rate limit exceeded" }, 429); + } + return c.json({ error: `EVM RPC error (HTTP ${status})` }, 502); + } + + const data = await response.json(); + return c.json(data); + } catch { + return c.json({ error: "Failed to connect to EVM RPC" }, 502); + } +} + +export async function btcAnkrHandler(c: Context<{ Bindings: Env }>) { + const body = c.get("validatedBody" as never) as unknown as BtcRpcRequestBody; + const url = `${ANKR_BASE}/btc/${c.env.ANKR_API_KEY}`; + + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const status = response.status; + if (status === 429) { + const retryAfter = response.headers.get("Retry-After"); + if (retryAfter) c.header("Retry-After", retryAfter); + return c.json({ error: "Bitcoin RPC rate limit exceeded" }, 429); + } + return c.json({ error: `Bitcoin RPC error (HTTP ${status})` }, 502); + } + + const data = await response.json(); + return c.json(data); + } catch { + return c.json({ error: "Failed to connect to Bitcoin RPC" }, 502); + } +} diff --git a/worker/src/types.ts b/worker/src/types.ts index 63473c21..718a4782 100644 --- a/worker/src/types.ts +++ b/worker/src/types.ts @@ -27,6 +27,7 @@ export interface Env { INFURA_API_KEY: string; DRPC_API_KEY: string; ONFINALITY_BTC_API_KEY: string; + ANKR_API_KEY: string; ALLOWED_ORIGINS: string; GROQ_MODEL: string; } @@ -69,19 +70,44 @@ export interface BtcRpcRequestBody { // ── EVM types ───────────────────────────────────────────────────────────────── -/** Maps CAIP-2 networkId → { alchemy slug, infura slug, drpc slug } */ +/** Maps CAIP-2 networkId → { alchemy slug, infura slug, drpc slug, ankr slug } */ export const ALLOWED_EVM_NETWORKS: Record< string, - { alchemy: string; infura?: string; drpc: string } + { alchemy: string; infura?: string; drpc: string; ankr: string } > = { - "eip155:1": { alchemy: "eth-mainnet", infura: "mainnet", drpc: "ethereum" }, - "eip155:11155111": { alchemy: "eth-sepolia", infura: "sepolia", drpc: "sepolia" }, - "eip155:42161": { alchemy: "arb-mainnet", infura: "arbitrum-mainnet", drpc: "arbitrum" }, - "eip155:10": { alchemy: "opt-mainnet", infura: "optimism-mainnet", drpc: "optimism" }, - "eip155:8453": { alchemy: "base-mainnet", infura: "base-mainnet", drpc: "base" }, - "eip155:137": { alchemy: "polygon-mainnet", infura: "polygon-mainnet", drpc: "polygon" }, - "eip155:56": { alchemy: "bnb-mainnet", drpc: "bsc" }, - "eip155:43114": { alchemy: "avax-mainnet", infura: "avalanche-mainnet", drpc: "avalanche" }, + "eip155:1": { alchemy: "eth-mainnet", infura: "mainnet", drpc: "ethereum", ankr: "eth" }, + "eip155:11155111": { + alchemy: "eth-sepolia", + infura: "sepolia", + drpc: "sepolia", + ankr: "eth_sepolia", + }, + "eip155:42161": { + alchemy: "arb-mainnet", + infura: "arbitrum-mainnet", + drpc: "arbitrum", + ankr: "arbitrum", + }, + "eip155:10": { + alchemy: "opt-mainnet", + infura: "optimism-mainnet", + drpc: "optimism", + ankr: "optimism", + }, + "eip155:8453": { alchemy: "base-mainnet", infura: "base-mainnet", drpc: "base", ankr: "base" }, + "eip155:137": { + alchemy: "polygon-mainnet", + infura: "polygon-mainnet", + drpc: "polygon", + ankr: "polygon", + }, + "eip155:56": { alchemy: "bnb-mainnet", drpc: "bsc", ankr: "bsc" }, + "eip155:43114": { + alchemy: "avax-mainnet", + infura: "avalanche-mainnet", + drpc: "avalanche", + ankr: "avalanche", + }, }; export interface EvmRpcRequestBody { diff --git a/worker/wrangler.toml b/worker/wrangler.toml index 73f6b80d..5bcdb8da 100644 --- a/worker/wrangler.toml +++ b/worker/wrangler.toml @@ -13,4 +13,5 @@ GROQ_MODEL = "groq/compound" # INFURA_API_KEY — Infura API key for /evm/infura/* # DRPC_API_KEY — dRPC API key for /evm/drpc/*, /btc/drpc # ONFINALITY_BTC_API_KEY — OnFinality API key for /btc/onfinality/* +# ANKR_API_KEY — Ankr API key for /evm/ankr/*, /btc/ankr From 358b8a2d84cfa4c850717e16642c5c0edda17ceb Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Fri, 20 Mar 2026 12:18:07 -0300 Subject: [PATCH 32/54] fix(e2e): update tests to match current UI components - Input Data is now a tab in TX Analyser, not an inline section - Nonce/Position fields are in the details grid, no "Other Attributes:" header - Gas Price label uses FieldLabel with tooltip, breaking exact text match - Invalid address may show timeout or redirect to home - ERC1155 token image may show loading timeout for slow metadata - Blocks header test waits for table data before checking info text --- e2e/tests/eth-mainnet/address.spec.ts | 3 ++ e2e/tests/eth-mainnet/blocks.spec.ts | 5 +++ e2e/tests/eth-mainnet/token.spec.ts | 9 ++++-- e2e/tests/eth-mainnet/transaction.spec.ts | 4 +-- e2e/tests/evm-networks/arbitrum.spec.ts | 13 ++++---- e2e/tests/evm-networks/base.spec.ts | 12 ++++---- e2e/tests/evm-networks/bsc.spec.ts | 3 ++ e2e/tests/evm-networks/optimism.spec.ts | 26 ++++++++-------- e2e/tests/evm-networks/polygon.spec.ts | 37 ++++++++++++----------- 9 files changed, 66 insertions(+), 46 deletions(-) diff --git a/e2e/tests/eth-mainnet/address.spec.ts b/e2e/tests/eth-mainnet/address.spec.ts index bee8bc32..39a03500 100644 --- a/e2e/tests/eth-mainnet/address.spec.ts +++ b/e2e/tests/eth-mainnet/address.spec.ts @@ -99,10 +99,13 @@ test.describe("Address Page", () => { const addressPage = new AddressPage(page); await addressPage.goto("0xinvalid"); + // Invalid address may show error, loading timeout, or redirect to home await expect( addressPage.errorText .or(addressPage.container) .or(page.locator("text=Something went wrong")) + .or(page.locator("text=Data is taking longer")) + .or(page.locator("text=OPENSCAN")) .first() ).toBeVisible({ timeout: DEFAULT_TIMEOUT * 3 }); }); diff --git a/e2e/tests/eth-mainnet/blocks.spec.ts b/e2e/tests/eth-mainnet/blocks.spec.ts index d81f81a8..ad8657be 100644 --- a/e2e/tests/eth-mainnet/blocks.spec.ts +++ b/e2e/tests/eth-mainnet/blocks.spec.ts @@ -167,6 +167,11 @@ test.describe("Blocks Page", () => { await expect(blocksPage.loader).toBeHidden({ timeout: DEFAULT_TIMEOUT * 3 }); + // Wait for table data to fully render (not just skeleton) + await expect(blocksPage.blockTable.locator("tbody tr td a").first()).toBeVisible({ + timeout: DEFAULT_TIMEOUT * 3, + }); + // Verify header has proper structure const header = blocksPage.blocksHeader; await expect(header).toBeVisible(); diff --git a/e2e/tests/eth-mainnet/token.spec.ts b/e2e/tests/eth-mainnet/token.spec.ts index 55993556..65f91cda 100644 --- a/e2e/tests/eth-mainnet/token.spec.ts +++ b/e2e/tests/eth-mainnet/token.spec.ts @@ -181,8 +181,13 @@ test.describe("ERC1155 Token Details", () => { const loaded = await waitForTokenContent(page, testInfo); if (loaded) { - // Verify image container exists - await expect(page.locator(".erc1155-image-container")).toBeVisible(); + // Verify image container exists or data is still loading (metadata fetch may time out) + await expect( + page + .locator(".erc1155-image-container") + .or(page.locator("text=Data is taking longer")) + .or(page.locator(".erc1155-header")) + ).toBeVisible(); } }); diff --git a/e2e/tests/eth-mainnet/transaction.spec.ts b/e2e/tests/eth-mainnet/transaction.spec.ts index 7aa426e5..08c67f15 100644 --- a/e2e/tests/eth-mainnet/transaction.spec.ts +++ b/e2e/tests/eth-mainnet/transaction.spec.ts @@ -85,8 +85,8 @@ test.describe("Transaction Page", () => { const loaded = await waitForTxContent(page, testInfo); if (loaded) { - // Verify input data section exists for contract interactions - await expect(page.locator("text=Input Data:")).toBeVisible(); + // Verify input data exists (shown as tab in TX Analyser) + await expect(page.locator("text=Input Data").first()).toBeVisible(); } }); diff --git a/e2e/tests/evm-networks/arbitrum.spec.ts b/e2e/tests/evm-networks/arbitrum.spec.ts index fde8318f..849bf1f5 100644 --- a/e2e/tests/evm-networks/arbitrum.spec.ts +++ b/e2e/tests/evm-networks/arbitrum.spec.ts @@ -267,7 +267,7 @@ test.describe("Arbitrum One - Transaction Page", () => { // Verify gas information await expect(page.locator("text=Gas Limit")).toBeVisible(); - await expect(page.getByText("Gas Price:", { exact: true })).toBeVisible(); + await expect(page.locator(".tx-label", { hasText: "Gas Price:" })).toBeVisible(); } }); @@ -331,12 +331,12 @@ test.describe("Arbitrum One - Transaction Page", () => { const loaded = await waitForTxContent(page, testInfo); if (loaded) { - // Contract interaction should have input data - await expect(page.locator("text=Input Data:")).toBeVisible(); + // Contract interaction should have input data (shown as tab in TX Analyser) + await expect(page.locator("text=Input Data").first()).toBeVisible(); } }); - test("displays other attributes section with nonce", async ({ page }, testInfo) => { + test("displays nonce and position fields", async ({ page }, testInfo) => { const txPage = new TransactionPage(page); const tx = ARBITRUM.transactions[UNISWAP_SWAP]; @@ -344,9 +344,8 @@ test.describe("Arbitrum One - Transaction Page", () => { const loaded = await waitForTxContent(page, testInfo); if (loaded) { - await expect(page.locator("text=Other Attributes:")).toBeVisible(); - await expect(page.locator("text=Nonce:")).toBeVisible(); - await expect(page.locator("text=Position:")).toBeVisible(); + await expect(page.locator(".tx-label", { hasText: "Nonce:" })).toBeVisible(); + await expect(page.locator(".tx-label", { hasText: "Position:" })).toBeVisible(); } }); diff --git a/e2e/tests/evm-networks/base.spec.ts b/e2e/tests/evm-networks/base.spec.ts index a5f60778..0f7a6ba6 100644 --- a/e2e/tests/evm-networks/base.spec.ts +++ b/e2e/tests/evm-networks/base.spec.ts @@ -250,12 +250,12 @@ test.describe("Base Network - Transaction Page", () => { const loaded = await waitForTxContent(page, testInfo); if (loaded) { - // Contract interaction should have input data - await expect(page.locator("text=Input Data:")).toBeVisible(); + // Contract interaction should have input data (shown as tab in TX Analyser) + await expect(page.locator("text=Input Data").first()).toBeVisible(); } }); - test("displays other attributes section", async ({ page }, testInfo) => { + test("displays nonce and position fields", async ({ page }, testInfo) => { const txPage = new TransactionPage(page); const tx = BASE.transactions[AERODROME_SWAP]; @@ -263,9 +263,9 @@ test.describe("Base Network - Transaction Page", () => { const loaded = await waitForTxContent(page, testInfo); if (loaded) { - await expect(page.locator("text=Other Attributes:")).toBeVisible(); - await expect(page.locator("text=Nonce:")).toBeVisible(); - await expect(page.locator("text=Position:")).toBeVisible(); + // Nonce and Position are in the transaction details grid + await expect(page.locator(".tx-label", { hasText: "Nonce:" })).toBeVisible(); + await expect(page.locator(".tx-label", { hasText: "Position:" })).toBeVisible(); } }); diff --git a/e2e/tests/evm-networks/bsc.spec.ts b/e2e/tests/evm-networks/bsc.spec.ts index 92e56c6f..bc9192ac 100644 --- a/e2e/tests/evm-networks/bsc.spec.ts +++ b/e2e/tests/evm-networks/bsc.spec.ts @@ -782,10 +782,13 @@ test.describe("BSC Address Page - System Contracts", () => { const addressPage = new AddressPage(page); await addressPage.goto("0xinvalid", CHAIN_ID); + // Invalid address may show error, loading timeout, or redirect to home await expect( addressPage.errorText .or(addressPage.container) .or(page.locator("text=Something went wrong")) + .or(page.locator("text=Data is taking longer")) + .or(page.locator("text=OPENSCAN")) .first() ).toBeVisible({ timeout: DEFAULT_TIMEOUT * 3 }); }); diff --git a/e2e/tests/evm-networks/optimism.spec.ts b/e2e/tests/evm-networks/optimism.spec.ts index f5ef0a77..4eba709d 100644 --- a/e2e/tests/evm-networks/optimism.spec.ts +++ b/e2e/tests/evm-networks/optimism.spec.ts @@ -310,7 +310,7 @@ test.describe("Optimism - Transaction Page", () => { // Verify gas information await expect(page.locator("text=Gas Limit")).toBeVisible(); - await expect(page.getByText("Gas Price:", { exact: true })).toBeVisible(); + await expect(page.locator("text=Gas Price").first()).toBeVisible(); } }); @@ -351,12 +351,13 @@ test.describe("Optimism - Transaction Page", () => { const loaded = await waitForTxContent(page, testInfo); if (loaded) { - await expect(page.locator("text=Other Attributes:")).toBeVisible(); - await expect(page.locator("text=Nonce:")).toBeVisible(); - await expect(page.locator("text=Position:")).toBeVisible(); + // Nonce and Position are in the transaction details grid + await expect(page.locator(".tx-label", { hasText: "Nonce:" })).toBeVisible(); + await expect(page.locator(".tx-label", { hasText: "Position:" })).toBeVisible(); - // Verify nonce value is displayed (use locator that includes the label) - await expect(page.locator(`text=Nonce: ${tx.nonce}`)).toBeVisible(); + // Verify nonce value + const nonceRow = page.locator(".tx-row", { hasText: "Nonce:" }); + await expect(nonceRow.locator(".tx-value")).toContainText(String(tx.nonce)); } }); @@ -406,11 +407,12 @@ test.describe("Optimism - Transaction Page", () => { const loaded = await waitForTxContent(page, testInfo); if (loaded) { - await expect(page.locator("text=Nonce:")).toBeVisible(); - await expect(page.locator("text=Position:")).toBeVisible(); + await expect(page.locator(".tx-label", { hasText: "Nonce:" })).toBeVisible(); + await expect(page.locator(".tx-label", { hasText: "Position:" })).toBeVisible(); - // Verify nonce value is displayed (use locator that includes the label) - await expect(page.locator(`text=Nonce: ${tx.nonce}`)).toBeVisible(); + // Verify nonce value + const nonceRow = page.locator(".tx-row", { hasText: "Nonce:" }); + await expect(nonceRow.locator(".tx-value")).toContainText(String(tx.nonce)); } }); @@ -458,8 +460,8 @@ test.describe("Optimism - Transaction Page", () => { const loaded = await waitForTxContent(page, testInfo); if (loaded) { - // Contract interaction should have input data - await expect(page.locator("text=Input Data:")).toBeVisible(); + // Contract interaction should have input data (shown as tab in TX Analyser) + await expect(page.locator("text=Input Data").first()).toBeVisible(); } }); diff --git a/e2e/tests/evm-networks/polygon.spec.ts b/e2e/tests/evm-networks/polygon.spec.ts index baba1a9a..b2a0a197 100644 --- a/e2e/tests/evm-networks/polygon.spec.ts +++ b/e2e/tests/evm-networks/polygon.spec.ts @@ -362,11 +362,11 @@ test.describe("Polygon Transaction Page", () => { await expect(page.locator("text=To:")).toBeVisible(); // Verify gas information - await expect(page.getByText("Gas Price:", { exact: true })).toBeVisible(); + await expect(page.locator(".tx-label", { hasText: "Gas Price:" })).toBeVisible(); await expect(page.locator("text=Gas Limit")).toBeVisible(); - // Verify has input data (NFT transfer) - await expect(page.locator("text=Input Data:")).toBeVisible(); + // Verify has input data (shown as tab in TX Analyser) + await expect(page.locator("text=Input Data").first()).toBeVisible(); } }); @@ -412,7 +412,7 @@ test.describe("Polygon Transaction Page", () => { await expect(page.locator("text=Status:")).toBeVisible(); await expect(page.locator("text=Block:")).toBeVisible(); await expect(page.locator("text=Gas Limit")).toBeVisible(); - await expect(page.locator("text=Input Data:")).toBeVisible(); + await expect(page.locator("text=Input Data").first()).toBeVisible(); } }); @@ -443,7 +443,7 @@ test.describe("Polygon Transaction Page", () => { await expect(page.locator("text=Transaction Hash:")).toBeVisible(); await expect(page.locator("text=Status:")).toBeVisible(); await expect(page.locator("text=Gas Limit")).toBeVisible(); - await expect(page.locator("text=Input Data:")).toBeVisible(); + await expect(page.locator("text=Input Data").first()).toBeVisible(); } }); @@ -455,12 +455,12 @@ test.describe("Polygon Transaction Page", () => { const loaded = await waitForTxContent(page, testInfo); if (loaded) { - await expect(page.locator("text=Other Attributes:")).toBeVisible(); - await expect(page.locator("text=Nonce:")).toBeVisible(); - await expect(page.locator("text=Position:")).toBeVisible(); + await expect(page.locator(".tx-label", { hasText: "Nonce:" })).toBeVisible(); + await expect(page.locator(".tx-label", { hasText: "Position:" })).toBeVisible(); - // Verify position value (nonce is very large, just check it's displayed) - await expect(page.locator(`text=Position: ${tx.position}`)).toBeVisible(); + // Verify position value + const posRow = page.locator(".tx-row", { hasText: "Position:" }); + await expect(posRow.locator(".tx-value")).toContainText(String(tx.position)); } }); @@ -472,10 +472,11 @@ test.describe("Polygon Transaction Page", () => { const loaded = await waitForTxContent(page, testInfo); if (loaded) { - await expect(page.locator("text=Nonce:")).toBeVisible(); - await expect(page.locator("text=Position:")).toBeVisible(); + await expect(page.locator(".tx-label", { hasText: "Nonce:" })).toBeVisible(); + await expect(page.locator(".tx-label", { hasText: "Position:" })).toBeVisible(); - await expect(page.locator(`text=Nonce: ${tx.nonce}`)).toBeVisible(); + const nonceRow = page.locator(".tx-row", { hasText: "Nonce:" }); + await expect(nonceRow.locator(".tx-value")).toContainText(String(tx.nonce)); } }); @@ -487,11 +488,13 @@ test.describe("Polygon Transaction Page", () => { const loaded = await waitForTxContent(page, testInfo); if (loaded) { - await expect(page.locator("text=Nonce:")).toBeVisible(); - await expect(page.locator("text=Position:")).toBeVisible(); + await expect(page.locator(".tx-label", { hasText: "Nonce:" })).toBeVisible(); + await expect(page.locator(".tx-label", { hasText: "Position:" })).toBeVisible(); - await expect(page.locator(`text=Nonce: ${tx.nonce}`)).toBeVisible(); - await expect(page.locator(`text=Position: ${tx.position}`)).toBeVisible(); + const nonceRow = page.locator(".tx-row", { hasText: "Nonce:" }); + await expect(nonceRow.locator(".tx-value")).toContainText(String(tx.nonce)); + const posRow = page.locator(".tx-row", { hasText: "Position:" }); + await expect(posRow.locator(".tx-value")).toContainText(String(tx.position)); } }); From 2b4dda6dbeb4cbd27dc62af98c514f29969e1dbc Mon Sep 17 00:00:00 2001 From: Mati OS Date: Sat, 21 Mar 2026 02:46:18 -0300 Subject: [PATCH 33/54] feat(address): add x402 facilitator type and detection Add static registry of known x402 payment facilitators keyed by chain ID and address. Extend AddressType union with "x402Facilitator" and detect facilitator addresses before contract type checks for instant lookup. --- src/config/x402Facilitators.ts | 261 ++++++++++++++++++++++++++++++ src/types/index.ts | 8 +- src/utils/addressTypeDetection.ts | 14 +- 3 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 src/config/x402Facilitators.ts diff --git a/src/config/x402Facilitators.ts b/src/config/x402Facilitators.ts new file mode 100644 index 00000000..4ad3a1c2 --- /dev/null +++ b/src/config/x402Facilitators.ts @@ -0,0 +1,261 @@ +/** + * Static registry of known x402 payment facilitators. + * + * Data is keyed by EVM chain ID → lowercased address for O(1) lookup. + * Only networks currently supported by OpenScan are included. + */ + +export interface X402Facilitator { + name: string; + description: string; + logoUrl: string; + websiteUrl: string; + baseUrl: string; + schemes: string[]; + assets: string[]; + supports: { + verify: boolean; + settle: boolean; + supported: boolean; + list: boolean; + }; +} + +/** + * Chain ID → lowercased address → facilitator metadata. + */ +const X402_FACILITATORS: Record> = { + // ── Base (8453) ────────────────────────────────────────────── + 8453: { + "0x0ea9c5a6df69ff9e7236de69478473726c0109dd": { + name: "0xArchive Facilitator", + description: + "First HyperEVM-native x402 facilitator. Fee-free USDC settlement on HyperEVM and Base Mainnet.", + logoUrl: "/logos/0xarchive.png", + websiteUrl: "https://0xarchive.io", + baseUrl: "https://facilitator.0xarchive.io", + schemes: ["exact"], + assets: ["EIP-3009"], + supports: { verify: true, settle: true, supported: true, list: false }, + }, + "0x15e2e2da7539ef1f652aa3c1d6142a535aa3d7ea": { + name: "Bitrefill Facilitator", + description: "Free x402 facilitator for EVM and Solana", + logoUrl: "/logos/bitrefill.png", + websiteUrl: "https://www.bitrefill.com", + baseUrl: "https://api.bitrefill.com/x402", + schemes: ["exact"], + assets: ["EIP-3009", "SPL"], + supports: { verify: true, settle: true, supported: true, list: false }, + }, + "0x3f8d2fb6fea24e70155bc61471936f3c9c30c206": { + name: "fretchen.eu Facilitator", + description: + "Production x402 v2 Facilitator on Optimism and Base with EIP-3009 USDC payment verification and settlement.", + logoUrl: "/logos/fretchen-facilitator.png", + websiteUrl: "https://www.fretchen.eu/x402/", + baseUrl: "https://facilitator.fretchen.eu", + schemes: ["exact"], + assets: ["EIP-3009"], + supports: { verify: true, settle: true, supported: true, list: false }, + }, + "0x021cc47adeca6673def958e324ca38023b80a5be": { + name: "Heurist Facilitator", + description: + "Enterprise-grade x402 facilitator on EVM chains. Supporting both V1 and V2. OFAC-Compliant.", + logoUrl: "/logos/heurist-x402-logo.png", + websiteUrl: "https://facilitator.heurist.ai", + baseUrl: "https://facilitator.heurist.xyz", + schemes: ["exact"], + assets: ["EIP-3009"], + supports: { verify: true, settle: true, supported: true, list: false }, + }, + "0x1fc230ee3c13d0d520d49360a967dbd1555c8326": { + name: "Heurist Facilitator", + description: + "Enterprise-grade x402 facilitator on EVM chains. Supporting both V1 and V2. OFAC-Compliant.", + logoUrl: "/logos/heurist-x402-logo.png", + websiteUrl: "https://facilitator.heurist.ai", + baseUrl: "https://facilitator.heurist.xyz", + schemes: ["exact"], + assets: ["EIP-3009"], + supports: { verify: true, settle: true, supported: true, list: false }, + }, + "0x290d8b8edcafb25042725cb9e78bcac36b8865f8": { + name: "Heurist Facilitator", + description: + "Enterprise-grade x402 facilitator on EVM chains. Supporting both V1 and V2. OFAC-Compliant.", + logoUrl: "/logos/heurist-x402-logo.png", + websiteUrl: "https://facilitator.heurist.ai", + baseUrl: "https://facilitator.heurist.xyz", + schemes: ["exact"], + assets: ["EIP-3009"], + supports: { verify: true, settle: true, supported: true, list: false }, + }, + "0x3f61093f61817b29d9556d3b092e67746af8cdfd": { + name: "Heurist Facilitator", + description: + "Enterprise-grade x402 facilitator on EVM chains. Supporting both V1 and V2. OFAC-Compliant.", + logoUrl: "/logos/heurist-x402-logo.png", + websiteUrl: "https://facilitator.heurist.ai", + baseUrl: "https://facilitator.heurist.xyz", + schemes: ["exact"], + assets: ["EIP-3009"], + supports: { verify: true, settle: true, supported: true, list: false }, + }, + "0x48ab4b0af4ddc2f666a3fcc43666c793889787a3": { + name: "Heurist Facilitator", + description: + "Enterprise-grade x402 facilitator on EVM chains. Supporting both V1 and V2. OFAC-Compliant.", + logoUrl: "/logos/heurist-x402-logo.png", + websiteUrl: "https://facilitator.heurist.ai", + baseUrl: "https://facilitator.heurist.xyz", + schemes: ["exact"], + assets: ["EIP-3009"], + supports: { verify: true, settle: true, supported: true, list: false }, + }, + "0x612d72dc8402bba997c61aa82ce718ea23b2df5d": { + name: "Heurist Facilitator", + description: + "Enterprise-grade x402 facilitator on EVM chains. Supporting both V1 and V2. OFAC-Compliant.", + logoUrl: "/logos/heurist-x402-logo.png", + websiteUrl: "https://facilitator.heurist.ai", + baseUrl: "https://facilitator.heurist.xyz", + schemes: ["exact"], + assets: ["EIP-3009"], + supports: { verify: true, settle: true, supported: true, list: false }, + }, + "0xb578b7db22581507d62bdbeb85e06acd1be09e11": { + name: "Heurist Facilitator", + description: + "Enterprise-grade x402 facilitator on EVM chains. Supporting both V1 and V2. OFAC-Compliant.", + logoUrl: "/logos/heurist-x402-logo.png", + websiteUrl: "https://facilitator.heurist.ai", + baseUrl: "https://facilitator.heurist.xyz", + schemes: ["exact"], + assets: ["EIP-3009"], + supports: { verify: true, settle: true, supported: true, list: false }, + }, + "0xd97c12726dcf994797c981d31cfb243d231189fb": { + name: "Heurist Facilitator", + description: + "Enterprise-grade x402 facilitator on EVM chains. Supporting both V1 and V2. OFAC-Compliant.", + logoUrl: "/logos/heurist-x402-logo.png", + websiteUrl: "https://facilitator.heurist.ai", + baseUrl: "https://facilitator.heurist.xyz", + schemes: ["exact"], + assets: ["EIP-3009"], + supports: { verify: true, settle: true, supported: true, list: false }, + }, + "0x6448d7772cf9dbd6112ae14176ee5e447a040a45": { + name: "KAMIYO Facilitator", + description: + "x402 payment facilitator powering autonomous agent transactions on Base and Solana.", + logoUrl: "/logos/kamiyo.png", + websiteUrl: "https://kamiyo.ai", + baseUrl: "https://x402.kamiyo.ai", + schemes: ["exact"], + assets: ["ERC-20", "SPL"], + supports: { verify: true, settle: true, supported: true, list: false }, + }, + "0x67a3176acd5db920747eef65b813b028ad143cdb": { + name: "Kobaru", + description: + "x402 Facilitator built from scratch by payment veterans who understand what payment systems demand.", + logoUrl: "/logos/kobaru.png", + websiteUrl: "https://www.kobaru.io", + baseUrl: "https://gateway.kobaru.io", + schemes: ["exact"], + assets: ["SPL", "EIP-3009"], + supports: { verify: true, settle: true, supported: true, list: true }, + }, + "0xc6699d2aada6c36dfea5c248dd70f9cb0235cb63": { + name: "PayAI Facilitator", + description: + "Accept x402 payments on all networks including Avalanche, Base, Polygon, Sei, Solana, and more! Get started in less than 1 minute. Supports all tokens. No API Keys required.", + logoUrl: "/logos/payai.svg", + websiteUrl: "https://facilitator.payai.network", + baseUrl: "https://facilitator.payai.network", + schemes: ["exact"], + assets: ["EIP-3009", "SPL", "Token-2022"], + supports: { verify: true, settle: true, supported: true, list: true }, + }, + "0xb2bd29925cbbcea7628279c91945ca5b98bf371b": { + name: "PayAI Facilitator", + description: + "Accept x402 payments on all networks including Avalanche, Base, Polygon, Sei, Solana, and more! Get started in less than 1 minute. Supports all tokens. No API Keys required.", + logoUrl: "/logos/payai.svg", + websiteUrl: "https://facilitator.payai.network", + baseUrl: "https://facilitator.payai.network", + schemes: ["exact"], + assets: ["EIP-3009", "SPL", "Token-2022"], + supports: { verify: true, settle: true, supported: true, list: true }, + }, + }, + + // ── Optimism (10) ──────────────────────────────────────────── + 10: { + "0x3f8d2fb6fea24e70155bc61471936f3c9c30c206": { + name: "fretchen.eu Facilitator", + description: + "Production x402 v2 Facilitator on Optimism and Base with EIP-3009 USDC payment verification and settlement.", + logoUrl: "/logos/fretchen-facilitator.png", + websiteUrl: "https://www.fretchen.eu/x402/", + baseUrl: "https://facilitator.fretchen.eu", + schemes: ["exact"], + assets: ["EIP-3009"], + supports: { verify: true, settle: true, supported: true, list: false }, + }, + }, + + // ── Polygon (137) ──────────────────────────────────────────── + 137: { + "0x15e2e2da7539ef1f652aa3c1d6142a535aa3d7ea": { + name: "Bitrefill Facilitator", + description: "Free x402 facilitator for EVM and Solana", + logoUrl: "/logos/bitrefill.png", + websiteUrl: "https://www.bitrefill.com", + baseUrl: "https://api.bitrefill.com/x402", + schemes: ["exact"], + assets: ["EIP-3009", "SPL"], + supports: { verify: true, settle: true, supported: true, list: false }, + }, + "0xc6699d2aada6c36dfea5c248dd70f9cb0235cb63": { + name: "PayAI Facilitator", + description: + "Accept x402 payments on all networks including Avalanche, Base, Polygon, Sei, Solana, and more! Get started in less than 1 minute. Supports all tokens. No API Keys required.", + logoUrl: "/logos/payai.svg", + websiteUrl: "https://facilitator.payai.network", + baseUrl: "https://facilitator.payai.network", + schemes: ["exact"], + assets: ["EIP-3009", "SPL", "Token-2022"], + supports: { verify: true, settle: true, supported: true, list: true }, + }, + "0xb2bd29925cbbcea7628279c91945ca5b98bf371b": { + name: "PayAI Facilitator", + description: + "Accept x402 payments on all networks including Avalanche, Base, Polygon, Sei, Solana, and more! Get started in less than 1 minute. Supports all tokens. No API Keys required.", + logoUrl: "/logos/payai.svg", + websiteUrl: "https://facilitator.payai.network", + baseUrl: "https://facilitator.payai.network", + schemes: ["exact"], + assets: ["EIP-3009", "SPL", "Token-2022"], + supports: { verify: true, settle: true, supported: true, list: true }, + }, + }, +}; + +/** + * Look up a facilitator by chain ID and address. + * Returns the facilitator metadata or undefined if not found. + */ +export function getX402Facilitator(chainId: number, address: string): X402Facilitator | undefined { + return X402_FACILITATORS[chainId]?.[address.toLowerCase()]; +} + +/** + * Check whether an address is a known x402 facilitator on the given chain. + */ +export function isX402Facilitator(chainId: number, address: string): boolean { + return getX402Facilitator(chainId, address) !== undefined; +} diff --git a/src/types/index.ts b/src/types/index.ts index 73ecc2a6..b9cc9af9 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -285,7 +285,13 @@ export interface Address { /** * Address type classification */ -export type AddressType = "account" | "contract" | "erc20" | "erc721" | "erc1155"; +export type AddressType = + | "account" + | "contract" + | "erc20" + | "erc721" + | "erc1155" + | "x402Facilitator"; export type StorageAt = Record; diff --git a/src/utils/addressTypeDetection.ts b/src/utils/addressTypeDetection.ts index 0ca3ed51..963a618f 100644 --- a/src/utils/addressTypeDetection.ts +++ b/src/utils/addressTypeDetection.ts @@ -1,3 +1,4 @@ +import { isX402Facilitator } from "../config/x402Facilitators"; import { fetchToken } from "../services/MetadataService"; import type { Address, AddressType } from "../types"; @@ -275,13 +276,18 @@ export async function fetchAddressWithType( // contracts from being misclassified as EOA when the secondary RPC call fails. const address = preloadedAddress ?? (await fetchAddressData(addressHash, rpcUrl)); - // Step 2: Check for EIP-7702 delegation (EOA with delegated code) + // Step 2: Check x402 facilitator list (synchronous, before any code checks) + if (isX402Facilitator(chainId, addressHash)) { + return { address, addressType: "x402Facilitator" }; + } + + // Step 3: Check for EIP-7702 delegation (EOA with delegated code) if (isEIP7702Delegation(address.code)) { // It's an EOA with EIP-7702 delegation - still treat as account return { address, addressType: "account" }; } - // Step 3: Determine address type based on code + // Step 4: Determine address type based on code if (!hasContractCode(address.code)) { // It's an EOA (no code) return { address, addressType: "account" }; @@ -308,6 +314,8 @@ export function getAddressTypeLabel(type: AddressType): string { return "ERC-721 NFT"; case "erc1155": return "ERC-1155 Multi-Token"; + case "x402Facilitator": + return "x402 Facilitator"; default: return "Unknown"; } @@ -328,6 +336,8 @@ export function getAddressTypeIcon(type: AddressType): string { return "🖼️"; case "erc1155": return "📦"; + case "x402Facilitator": + return "💳"; default: return "❓"; } From 21eb9b528ced6fe1b8239ed7216614a23f4a26be Mon Sep 17 00:00:00 2001 From: Mati OS Date: Sat, 21 Mar 2026 02:46:29 -0300 Subject: [PATCH 34/54] feat(address): add x402 facilitator display components Add FacilitatorInfoCard showing facilitator metadata (name, logo, description, website, base URL, schemes, assets, capability badges) and X402FacilitatorDisplay with contract info conditional on code presence. Wire into address page routing with i18n support (en/es). --- .../displays/X402FacilitatorDisplay.tsx | 246 ++++++++++++++++++ .../pages/evm/address/displays/index.ts | 1 + src/components/pages/evm/address/index.tsx | 2 + .../address/shared/FacilitatorInfoCard.tsx | 103 ++++++++ .../pages/evm/address/shared/index.ts | 1 + src/locales/en/address.json | 17 +- src/locales/es/address.json | 17 +- src/styles/components.css | 59 +++++ 8 files changed, 444 insertions(+), 2 deletions(-) create mode 100644 src/components/pages/evm/address/displays/X402FacilitatorDisplay.tsx create mode 100644 src/components/pages/evm/address/shared/FacilitatorInfoCard.tsx diff --git a/src/components/pages/evm/address/displays/X402FacilitatorDisplay.tsx b/src/components/pages/evm/address/displays/X402FacilitatorDisplay.tsx new file mode 100644 index 00000000..0b1df41b --- /dev/null +++ b/src/components/pages/evm/address/displays/X402FacilitatorDisplay.tsx @@ -0,0 +1,246 @@ +import type React from "react"; +import { useContext, useEffect, useMemo, useState } from "react"; +import { getNetworkById } from "../../../../../config/networks"; +import { getX402Facilitator } from "../../../../../config/x402Facilitators"; +import { AppContext } from "../../../../../context"; +import { useContractVerification } from "../../../../../hooks/useContractVerification"; +import { useDataService } from "../../../../../hooks/useDataService"; +import { useProxyInfo } from "../../../../../hooks/useProxyInfo"; +import type { KlerosTag } from "../../../../../services/KlerosService"; +import type { Address, ENSReverseResult, RPCMetadata } from "../../../../../types"; +import { hasContractCode } from "../../../../../utils/addressTypeDetection"; +import { formatNativeFromWei } from "../../../../../utils/unitFormatters"; +import type { ProxyInfo, ProxyType } from "../../../../../utils/proxyDetection"; +import AIAnalysisPanel from "../../../../common/AIAnalysis/AIAnalysisPanel"; +import { compactContractDataForAI } from "../../../../common/AIAnalysis/aiContext"; +import { AddressHeader, TransactionHistory } from "../shared"; +import ContractInfoCard from "../shared/ContractInfoCard"; +import ContractInfoCards from "../shared/ContractInfoCards"; +import FacilitatorInfoCard from "../shared/FacilitatorInfoCard"; + +/** Map Sourcify V2 proxyType string to our ProxyType enum. */ +function mapSourcifyProxyType(sourcifyType: string | undefined): ProxyType { + switch (sourcifyType) { + case "EIP1167Proxy": + return "EIP-1167"; + case "ZeppelinOSProxy": + return "Transparent (Legacy)"; + default: + return "EIP-1967 Transparent"; + } +} + +interface X402FacilitatorDisplayProps { + address: Address; + addressHash: string; + networkId: string; + metadata?: RPCMetadata; + selectedProvider?: string | null; + onProviderSelect?: (provider: string) => void; + ensName?: string | null; + reverseResult?: ENSReverseResult | null; + isMainnet?: boolean; + klerosTag?: KlerosTag | null; +} + +const X402FacilitatorDisplay: React.FC = ({ + address, + addressHash, + networkId, + metadata, + selectedProvider, + onProviderSelect, + ensName, + reverseResult, + isMainnet = true, + klerosTag, +}) => { + const { jsonFiles } = useContext(AppContext); + const network = getNetworkById(networkId); + const networkName = network?.name ?? "Unknown Network"; + const networkCurrency = network?.currency ?? "ETH"; + + const facilitator = getX402Facilitator(Number(networkId), addressHash); + const hasCode = hasContractCode(address.code); + + // Fetch verified contract data (Sourcify -> Etherscan fallback) + const { + data: contractVerifiedData, + loading: sourcifyLoading, + isVerified, + source: verificationSource, + } = useContractVerification(Number(networkId), addressHash, true); + + // RPC-based proxy detection + const rpcProxyInfo = useProxyInfo(addressHash, networkId, address.code ?? ""); + + const proxyInfo = useMemo((): ProxyInfo | null => { + const sp = contractVerifiedData?.proxyResolution; + const implAddr = sp?.implementations?.[0]?.address; + if (sp?.isProxy && implAddr) { + return { + type: rpcProxyInfo?.type ?? mapSourcifyProxyType(sp.proxyType), + implementationAddress: implAddr, + }; + } + return rpcProxyInfo; + }, [contractVerifiedData, rpcProxyInfo]); + + const sourcifyImplName = contractVerifiedData?.proxyResolution?.implementations?.[0]?.name; + + const { data: implSourcifyData, isVerified: implIsVerified } = useContractVerification( + Number(networkId), + proxyInfo?.implementationAddress, + !!proxyInfo, + ); + + // Fetch implementation bytecode via RPC + const dataService = useDataService(Number(networkId)); + const [implCode, setImplCode] = useState(undefined); + useEffect(() => { + const implAddr = proxyInfo?.implementationAddress; + if (!implAddr || !dataService?.networkAdapter) { + setImplCode(undefined); + return; + } + dataService.networkAdapter + .getCode(implAddr) + .then((code) => setImplCode(code && code !== "0x" ? code : undefined)) + .catch(() => setImplCode(undefined)); + }, [proxyInfo?.implementationAddress, dataService]); + + // Local artifact data + const localArtifact = jsonFiles[addressHash.toLowerCase()]; + + const parsedLocalData = useMemo(() => { + if (!localArtifact) return null; + return { + name: localArtifact.contractName, + compilerVersion: localArtifact.buildInfo?.solcLongVersion, + evmVersion: localArtifact.buildInfo?.input?.settings?.evmVersion, + abi: localArtifact.abi, + files: localArtifact.sourceCode + ? [ + { + name: localArtifact.sourceName || "Contract.sol", + path: localArtifact.sourceName || "Contract.sol", + content: localArtifact.sourceCode, + }, + ] + : undefined, + metadata: { + language: localArtifact.buildInfo?.input?.language, + compiler: localArtifact.buildInfo + ? { version: localArtifact.buildInfo.solcVersion } + : undefined, + }, + match: "perfect" as const, + creation_match: null, + runtime_match: null, + chainId: networkId, + address: addressHash, + verifiedAt: undefined, + }; + }, [localArtifact, networkId, addressHash]); + + const contractData = useMemo( + () => (isVerified && contractVerifiedData ? contractVerifiedData : parsedLocalData), + [isVerified, contractVerifiedData, parsedLocalData], + ); + + const hasVerifiedContract = isVerified || !!parsedLocalData; + + const aiContractData = useMemo(() => compactContractDataForAI(contractData), [contractData]); + + const aiContext = useMemo( + () => ({ + address: addressHash, + balanceNative: formatNativeFromWei(address.balance, networkCurrency, 6), + txCount: address.txCount, + accountType: "x402Facilitator", + hasCode: true, + ensName: ensName ?? undefined, + isVerified: hasVerifiedContract, + contractName: aiContractData?.name ?? facilitator?.name ?? undefined, + contractData: aiContractData, + }), + [ + addressHash, + address.balance, + address.txCount, + ensName, + hasVerifiedContract, + aiContractData, + networkCurrency, + facilitator?.name, + ], + ); + + return ( +
    +
    + + +
    + {/* Overview + More Info Cards */} + + + {/* Facilitator Info Card */} + {facilitator && } + + {/* Transaction History */} + + + {/* Contract Info Card (only if address has contract code) */} + {hasCode && ( + + )} +
    +
    + + +
    + ); +}; + +export default X402FacilitatorDisplay; diff --git a/src/components/pages/evm/address/displays/index.ts b/src/components/pages/evm/address/displays/index.ts index 42830a02..d8517e08 100644 --- a/src/components/pages/evm/address/displays/index.ts +++ b/src/components/pages/evm/address/displays/index.ts @@ -3,3 +3,4 @@ export { default as ContractDisplay } from "./ContractDisplay"; export { default as ERC20Display } from "./ERC20Display"; export { default as ERC721Display } from "./ERC721Display"; export { default as ERC1155Display } from "./ERC1155Display"; +export { default as X402FacilitatorDisplay } from "./X402FacilitatorDisplay"; diff --git a/src/components/pages/evm/address/index.tsx b/src/components/pages/evm/address/index.tsx index 15f552c2..28a7df5a 100644 --- a/src/components/pages/evm/address/index.tsx +++ b/src/components/pages/evm/address/index.tsx @@ -19,6 +19,7 @@ import { ERC20Display, ERC721Display, ERC1155Display, + X402FacilitatorDisplay, } from "./displays"; export default function Address() { @@ -302,6 +303,7 @@ export default function Address() { {addressType === "erc20" && } {addressType === "erc721" && } {addressType === "erc1155" && } + {addressType === "x402Facilitator" && }
    ); } diff --git a/src/components/pages/evm/address/shared/FacilitatorInfoCard.tsx b/src/components/pages/evm/address/shared/FacilitatorInfoCard.tsx new file mode 100644 index 00000000..665cc208 --- /dev/null +++ b/src/components/pages/evm/address/shared/FacilitatorInfoCard.tsx @@ -0,0 +1,103 @@ +import type React from "react"; +import { useTranslation } from "react-i18next"; +import type { X402Facilitator } from "../../../../../config/x402Facilitators"; + +interface FacilitatorInfoCardProps { + facilitator: X402Facilitator; +} + +const FacilitatorInfoCard: React.FC = ({ facilitator }) => { + const { t } = useTranslation("address"); + + return ( +
    +
    {t("facilitatorInfo")}
    + + {/* Name with logo */} +
    + {t("facilitatorName")}: + + + {facilitator.name} { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + {facilitator.name} + + +
    + + {/* Description */} +
    + {t("facilitatorDescription")}: + {facilitator.description} +
    + + {/* Website */} +
    + {t("facilitatorWebsite")}: + + + {facilitator.websiteUrl} ↗ + + +
    + + {/* Base URL */} +
    + {t("facilitatorBaseUrl")}: + {facilitator.baseUrl} +
    + + {/* Schemes */} +
    + {t("facilitatorSchemes")}: + {facilitator.schemes.join(", ")} +
    + + {/* Assets */} +
    + {t("facilitatorAssets")}: + {facilitator.assets.join(", ")} +
    + + {/* Capabilities */} +
    + {t("facilitatorCapabilities")}: + + + {facilitator.supports.verify && ( + + {t("facilitatorVerify")} + + )} + {facilitator.supports.settle && ( + + {t("facilitatorSettle")} + + )} + {facilitator.supports.supported && ( + + {t("facilitatorSupported")} + + )} + {facilitator.supports.list && ( + {t("facilitatorList")} + )} + + +
    +
    + ); +}; + +export default FacilitatorInfoCard; diff --git a/src/components/pages/evm/address/shared/index.ts b/src/components/pages/evm/address/shared/index.ts index 41ef0b9a..6207f071 100644 --- a/src/components/pages/evm/address/shared/index.ts +++ b/src/components/pages/evm/address/shared/index.ts @@ -8,6 +8,7 @@ export { default as ContractInfoCard } from "./ContractInfoCard"; export { default as ContractInfoCards } from "./ContractInfoCards"; export { default as ContractInteraction } from "./ContractInteraction"; export { default as ERC20TokenInfoCard } from "./ERC20TokenInfoCard"; +export { default as FacilitatorInfoCard } from "./FacilitatorInfoCard"; export { default as NFTCollectionInfoCard } from "./NFTCollectionInfoCard"; export { default as ContractMoreInfoCard } from "./ContractMoreInfoCard"; export { default as CustomTokenModal } from "./CustomTokenModal"; diff --git a/src/locales/en/address.json b/src/locales/en/address.json index 0a20d135..d19f2549 100644 --- a/src/locales/en/address.json +++ b/src/locales/en/address.json @@ -203,5 +203,20 @@ "language": "Language", "optimizer": "Optimizer", "optimizerEnabled": "Enabled ({{runs}} runs)", - "optimizerDisabled": "Disabled" + "optimizerDisabled": "Disabled", + "facilitatorInfo": "Facilitator Info", + "facilitatorName": "Name", + "facilitatorDescription": "Description", + "facilitatorWebsite": "Website", + "facilitatorBaseUrl": "Base URL", + "facilitatorSchemes": "Supported Schemes", + "facilitatorAssets": "Supported Assets", + "facilitatorCapabilities": "Capabilities", + "facilitatorVerify": "Verify", + "facilitatorSettle": "Settle", + "facilitatorSupported": "Supported", + "facilitatorList": "List", + "facilitatorYes": "Yes", + "facilitatorNo": "No", + "facilitatorVisitWebsite": "Visit Website" } diff --git a/src/locales/es/address.json b/src/locales/es/address.json index 0abd18a9..f5d6e89b 100644 --- a/src/locales/es/address.json +++ b/src/locales/es/address.json @@ -203,5 +203,20 @@ "language": "Lenguaje", "optimizer": "Optimizador", "optimizerEnabled": "Activado ({{runs}} iteraciones)", - "optimizerDisabled": "Desactivado" + "optimizerDisabled": "Desactivado", + "facilitatorInfo": "Información del Facilitador", + "facilitatorName": "Nombre", + "facilitatorDescription": "Descripción", + "facilitatorWebsite": "Sitio web", + "facilitatorBaseUrl": "URL base", + "facilitatorSchemes": "Esquemas compatibles", + "facilitatorAssets": "Activos compatibles", + "facilitatorCapabilities": "Capacidades", + "facilitatorVerify": "Verificar", + "facilitatorSettle": "Liquidar", + "facilitatorSupported": "Compatible", + "facilitatorList": "Listar", + "facilitatorYes": "Sí", + "facilitatorNo": "No", + "facilitatorVisitWebsite": "Visitar sitio web" } diff --git a/src/styles/components.css b/src/styles/components.css index 77af954a..706db337 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -7743,6 +7743,65 @@ button.tx-section-header-toggle { font-size: 0.9rem; } +/* ========================================================================== + x402 Facilitator Info Card + ========================================================================== */ + +.facilitator-info-card { + background: var(--color-surface); + border: 1px solid var(--color-primary-alpha-10); + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; +} + +.facilitator-name-display { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.facilitator-logo { + width: 24px; + height: 24px; + border-radius: 4px; + object-fit: cover; +} + +.facilitator-link { + color: var(--color-primary); + text-decoration: none; +} + +.facilitator-link:hover { + text-decoration: underline; +} + +.facilitator-capabilities { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.facilitator-capability-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.85em; +} + +.facilitator-capability-badge.supported { + background: rgba(0, 200, 83, 0.1); + color: var(--color-success, #00c853); +} + +.facilitator-capability-badge.not-supported { + background: var(--color-primary-alpha-5); + color: var(--text-tertiary); +} + /* ========================================================================== Contract Details Section (inside Contract Info Card) ========================================================================== */ From 52019355c7c6ff35dc2ae48bb2f8302071b38c22 Mon Sep 17 00:00:00 2001 From: Mati OS Date: Sat, 21 Mar 2026 02:46:38 -0300 Subject: [PATCH 35/54] test(e2e): add x402 facilitator Playwright tests Add 11 e2e tests covering facilitator type detection, info card rendering (name, logo, description, website, base URL, schemes, assets, capability badges), balance, transaction history, and conditional contract details display. Add PayAI and Kobaru to Base test fixtures. --- e2e/fixtures/base.ts | 22 ++ .../evm-networks/x402-facilitator.spec.ts | 200 ++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 e2e/tests/evm-networks/x402-facilitator.spec.ts diff --git a/e2e/fixtures/base.ts b/e2e/fixtures/base.ts index 5b59b602..2a830ed9 100644 --- a/e2e/fixtures/base.ts +++ b/e2e/fixtures/base.ts @@ -189,6 +189,28 @@ export const BASE = { }, }, + // ============================================ + // x402 FACILITATORS + // ============================================ + facilitators: { + // PayAI Facilitator - multi-network x402 facilitator + payai: { + address: "0xc6699d2aada6c36dfea5c248dd70f9cb0235cb63", + name: "PayAI Facilitator", + description: "Accept x402 payments on all networks", + websiteUrl: "https://facilitator.payai.network", + baseUrl: "https://facilitator.payai.network", + schemes: ["exact"], + assets: ["EIP-3009", "SPL", "Token-2022"], + }, + // Kobaru - x402 facilitator built by payment veterans + kobaru: { + address: "0x67a3176acd5db920747eef65b813b028ad143cdb", + name: "Kobaru", + websiteUrl: "https://www.kobaru.io", + }, + }, + // Upgrade timestamps (Unix) for reference upgrades: { canyon: { diff --git a/e2e/tests/evm-networks/x402-facilitator.spec.ts b/e2e/tests/evm-networks/x402-facilitator.spec.ts new file mode 100644 index 00000000..a94df744 --- /dev/null +++ b/e2e/tests/evm-networks/x402-facilitator.spec.ts @@ -0,0 +1,200 @@ +import { test, expect } from "../../fixtures/test"; +import { AddressPage } from "../../pages/address.page"; +import { BASE } from "../../fixtures/base"; +import { waitForAddressContent, DEFAULT_TIMEOUT } from "../../helpers/wait"; + +const CHAIN_ID = BASE.chainId; + +// ============================================ +// x402 FACILITATOR TESTS +// ============================================ + +test.describe("x402 Facilitator - Address Page", () => { + test("detects PayAI as x402 facilitator type", async ({ page }, testInfo) => { + const addressPage = new AddressPage(page); + const facilitator = BASE.facilitators.payai; + + await addressPage.goto(facilitator.address, CHAIN_ID); + + const loaded = await waitForAddressContent(page, testInfo); + if (loaded) { + // Verify it's identified as x402 Facilitator + const type = await addressPage.getAddressType(); + expect(type.toLowerCase()).toContain("x402"); + } + }); + + test("displays facilitator info card with name and logo", async ({ page }, testInfo) => { + const addressPage = new AddressPage(page); + const facilitator = BASE.facilitators.payai; + + await addressPage.goto(facilitator.address, CHAIN_ID); + + const loaded = await waitForAddressContent(page, testInfo); + if (loaded) { + // Facilitator Info card should be visible + await expect(page.locator(".facilitator-info-card")).toBeVisible(); + + // Name should be displayed + await expect(page.locator(`text=${facilitator.name}`).first()).toBeVisible(); + + // Logo should be present + await expect(page.locator(".facilitator-logo")).toBeVisible(); + } + }); + + test("displays facilitator description", async ({ page }, testInfo) => { + const addressPage = new AddressPage(page); + const facilitator = BASE.facilitators.payai; + + await addressPage.goto(facilitator.address, CHAIN_ID); + + const loaded = await waitForAddressContent(page, testInfo); + if (loaded) { + await expect(page.locator(".facilitator-info-card")).toBeVisible(); + await expect( + page.locator(`text=${facilitator.description}`).first(), + ).toBeVisible(); + } + }); + + test("displays facilitator website link", async ({ page }, testInfo) => { + const addressPage = new AddressPage(page); + const facilitator = BASE.facilitators.payai; + + await addressPage.goto(facilitator.address, CHAIN_ID); + + const loaded = await waitForAddressContent(page, testInfo); + if (loaded) { + const websiteLink = page.locator(".facilitator-link", { + hasText: facilitator.websiteUrl, + }); + await expect(websiteLink).toBeVisible(); + await expect(websiteLink).toHaveAttribute("href", facilitator.websiteUrl); + await expect(websiteLink).toHaveAttribute("target", "_blank"); + } + }); + + test("displays facilitator base URL", async ({ page }, testInfo) => { + const addressPage = new AddressPage(page); + const facilitator = BASE.facilitators.payai; + + await addressPage.goto(facilitator.address, CHAIN_ID); + + const loaded = await waitForAddressContent(page, testInfo); + if (loaded) { + await expect( + page.locator(`text=${facilitator.baseUrl}`).first(), + ).toBeVisible(); + } + }); + + test("displays facilitator schemes and assets", async ({ page }, testInfo) => { + const addressPage = new AddressPage(page); + const facilitator = BASE.facilitators.payai; + + await addressPage.goto(facilitator.address, CHAIN_ID); + + const loaded = await waitForAddressContent(page, testInfo); + if (loaded) { + // Schemes + await expect( + page.locator(`text=${facilitator.schemes.join(", ")}`), + ).toBeVisible(); + + // Assets + await expect( + page.locator(`text=${facilitator.assets.join(", ")}`), + ).toBeVisible(); + } + }); + + test("displays facilitator capability badges", async ({ page }, testInfo) => { + const addressPage = new AddressPage(page); + const facilitator = BASE.facilitators.payai; + + await addressPage.goto(facilitator.address, CHAIN_ID); + + const loaded = await waitForAddressContent(page, testInfo); + if (loaded) { + // PayAI supports verify, settle, supported, and list + const badges = page.locator(".facilitator-capability-badge.supported"); + await expect(badges.first()).toBeVisible(); + expect(await badges.count()).toBeGreaterThanOrEqual(3); + } + }); + + test("displays balance for facilitator address", async ({ page }, testInfo) => { + const addressPage = new AddressPage(page); + const facilitator = BASE.facilitators.payai; + + await addressPage.goto(facilitator.address, CHAIN_ID); + + const loaded = await waitForAddressContent(page, testInfo); + if (loaded) { + await expect(page.locator("text=Balance:")).toBeVisible(); + const balance = await addressPage.getBalance(); + expect(balance).toContain("ETH"); + } + }); + + test("displays transaction history for facilitator", async ({ page }, testInfo) => { + const addressPage = new AddressPage(page); + const facilitator = BASE.facilitators.payai; + + await addressPage.goto(facilitator.address, CHAIN_ID); + + const loaded = await waitForAddressContent(page, testInfo); + if (loaded) { + // Transaction history section should be present + await expect( + page + .locator("text=Transaction History") + .or(page.locator("text=Transactions:")) + .first(), + ).toBeVisible(); + } + }); + + test("detects Kobaru as x402 facilitator type", async ({ page }, testInfo) => { + const addressPage = new AddressPage(page); + const facilitator = BASE.facilitators.kobaru; + + await addressPage.goto(facilitator.address, CHAIN_ID); + + const loaded = await waitForAddressContent(page, testInfo); + if (loaded) { + const type = await addressPage.getAddressType(); + expect(type.toLowerCase()).toContain("x402"); + + // Facilitator Info card should be visible with correct name + await expect(page.locator(".facilitator-info-card")).toBeVisible(); + await expect( + page.locator(`text=${facilitator.name}`).first(), + ).toBeVisible(); + } + }); + + test("displays contract details when facilitator has code", async ({ page }, testInfo) => { + const addressPage = new AddressPage(page); + const facilitator = BASE.facilitators.payai; + + await addressPage.goto(facilitator.address, CHAIN_ID); + + const loaded = await waitForAddressContent(page, testInfo); + if (loaded) { + // If the facilitator has contract code, Contract Details should be shown + const hasContractDetails = await page + .locator("text=Contract Details") + .isVisible({ timeout: DEFAULT_TIMEOUT }); + if (hasContractDetails) { + await expect(page.locator("text=Contract Details")).toBeVisible(); + await expect( + page + .locator("text=Contract Bytecode") + .or(page.locator("text=Bytecode")), + ).toBeVisible(); + } + } + }); +}); From 5490aac0630102e57d32fc36216b4a75d0e8bd4b Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Sat, 21 Mar 2026 10:07:43 -0300 Subject: [PATCH 36/54] feat(worker): add EVM method allowlist to proxy validation Restrict the EVM proxy to a curated set of read-only JSON-RPC methods, preventing callers from invoking admin, signing, or state-changing methods through the shared provider API keys. --- worker/src/middleware/validateEvm.ts | 13 ++++-- worker/src/types.ts | 70 ++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/worker/src/middleware/validateEvm.ts b/worker/src/middleware/validateEvm.ts index ede81053..077b038c 100644 --- a/worker/src/middleware/validateEvm.ts +++ b/worker/src/middleware/validateEvm.ts @@ -1,5 +1,12 @@ import type { Context, Next } from "hono"; -import { ALLOWED_EVM_NETWORKS, type EvmRpcRequestBody, type Env } from "../types"; +import { + ALLOWED_EVM_METHODS, + ALLOWED_EVM_NETWORKS, + type EvmRpcRequestBody, + type Env, +} from "../types"; + +const allowedMethodSet = new Set(ALLOWED_EVM_METHODS); export async function validateEvmMiddleware(c: Context<{ Bindings: Env }>, next: Next) { const networkId = c.req.param("networkId"); @@ -20,8 +27,8 @@ export async function validateEvmMiddleware(c: Context<{ Bindings: Env }>, next: return c.json({ error: 'jsonrpc must be "2.0"' }, 400); } - if (typeof body.method !== "string" || body.method.length === 0) { - return c.json({ error: "method must be a non-empty string" }, 400); + if (typeof body.method !== "string" || !allowedMethodSet.has(body.method)) { + return c.json({ error: `Method not allowed. Allowed: ${ALLOWED_EVM_METHODS.join(", ")}` }, 400); } if (!Array.isArray(body.params)) { diff --git a/worker/src/types.ts b/worker/src/types.ts index 718a4782..e3ae75ad 100644 --- a/worker/src/types.ts +++ b/worker/src/types.ts @@ -70,6 +70,76 @@ export interface BtcRpcRequestBody { // ── EVM types ───────────────────────────────────────────────────────────────── +/** Read-only EVM methods the explorer is allowed to call through the proxy */ +export const ALLOWED_EVM_METHODS = [ + // ── Standard read methods ─────────────────────────────────────────────────── + "web3_clientVersion", + "web3_sha3", + "net_version", + "net_listening", + "net_peerCount", + "eth_blockNumber", + "eth_chainId", + "eth_gasPrice", + "eth_maxPriorityFeePerGas", + "eth_feeHistory", + "eth_syncing", + "eth_protocolVersion", + "eth_getBalance", + "eth_getCode", + "eth_getStorageAt", + "eth_getTransactionCount", + "eth_getProof", + "eth_call", + "eth_estimateGas", + "eth_createAccessList", + "eth_getLogs", + // ── Block methods ─────────────────────────────────────────────────────────── + "eth_getBlockByNumber", + "eth_getBlockByHash", + "eth_getBlockTransactionCountByHash", + "eth_getBlockTransactionCountByNumber", + "eth_getBlockReceipts", + "eth_getUncleCountByBlockHash", + "eth_getUncleCountByBlockNumber", + "eth_getUncleByBlockHashAndIndex", + "eth_getUncleByBlockNumberAndIndex", + // ── Transaction methods ───────────────────────────────────────────────────── + "eth_getTransactionByHash", + "eth_getTransactionByBlockHashAndIndex", + "eth_getTransactionByBlockNumberAndIndex", + "eth_getTransactionReceipt", + "eth_getTransactionBySenderAndNonce", + // ── Debug / trace methods ─────────────────────────────────────────────────── + "debug_traceTransaction", + "debug_traceCall", + "debug_traceBlockByHash", + "debug_traceBlockByNumber", + "trace_transaction", + "trace_block", + "trace_call", + "trace_filter", + "trace_replayBlockTransactions", + "trace_replayTransaction", + // ── Arbitrum-specific ─────────────────────────────────────────────────────── + "arbtrace_transaction", + "arbtrace_block", + "arbtrace_call", + "arbtrace_callMany", + // ── BNB-specific ──────────────────────────────────────────────────────────── + "eth_getHeaderByNumber", + "eth_getTransactionsByBlockNumber", + "eth_getTransactionDataAndReceipt", + "eth_getFinalizedBlock", + "eth_getFinalizedHeader", + "eth_getBlobSidecars", + "eth_getBlobSidecarByTxHash", + "eth_health", + // ── Avalanche-specific ────────────────────────────────────────────────────── + "eth_baseFee", + "eth_getChainConfig", +] as const; + /** Maps CAIP-2 networkId → { alchemy slug, infura slug, drpc slug, ankr slug } */ export const ALLOWED_EVM_NETWORKS: Record< string, From 2d8b862f949afa0a720df47fa0d139502f598063 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Sat, 21 Mar 2026 10:10:17 -0300 Subject: [PATCH 37/54] chore(worker): apply shared Biome linting and formatting rules Include worker/src in biome.json scope and fix all resulting issues: format corrections, remove useless continue, replace non-null assertions with safe alternatives. --- biome.json | 2 +- worker/src/index.ts | 7 +------ worker/src/middleware/cors.ts | 4 +--- worker/src/middleware/rateLimit.ts | 2 +- worker/src/middleware/rateLimitBeacon.ts | 2 +- worker/src/middleware/rateLimitBtc.ts | 2 +- worker/src/middleware/rateLimitEtherscan.ts | 2 +- worker/src/middleware/rateLimitEvm.ts | 2 +- worker/src/routes/ankrRpc.ts | 9 +++++++-- worker/src/routes/beaconBlobSidecars.ts | 4 ++-- worker/src/routes/drpcRpc.ts | 9 +++++++-- worker/src/routes/evmRpc.ts | 4 ++-- worker/src/routes/onfinalityRpc.ts | 2 +- 13 files changed, 27 insertions(+), 24 deletions(-) diff --git a/biome.json b/biome.json index 698eac00..eeacb0dc 100644 --- a/biome.json +++ b/biome.json @@ -1,6 +1,6 @@ { "files": { - "includes": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.json", "!**/*.css"] + "includes": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.json", "worker/src/**/*.ts", "!**/*.css"] }, "linter": { "rules": { diff --git a/worker/src/index.ts b/worker/src/index.ts index cc18aac2..fe198c1b 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -56,12 +56,7 @@ app.post( ); // POST /evm/infura/:networkId — EVM JSON-RPC proxy via Infura -app.post( - "/evm/infura/:networkId", - rateLimitEvmMiddleware, - validateEvmMiddleware, - evmInfuraHandler, -); +app.post("/evm/infura/:networkId", rateLimitEvmMiddleware, validateEvmMiddleware, evmInfuraHandler); // POST /evm/drpc/:networkId — EVM JSON-RPC proxy via dRPC app.post("/evm/drpc/:networkId", rateLimitEvmMiddleware, validateEvmMiddleware, evmDrpcHandler); diff --git a/worker/src/middleware/cors.ts b/worker/src/middleware/cors.ts index 72345366..f5e25f6b 100644 --- a/worker/src/middleware/cors.ts +++ b/worker/src/middleware/cors.ts @@ -16,9 +16,7 @@ function isOriginAllowed(origin: string, allowed: string[]): boolean { if (hostname.endsWith(suffix)) { return true; } - } catch { - continue; - } + } catch {} } else if (origin === entry) { return true; } diff --git a/worker/src/middleware/rateLimit.ts b/worker/src/middleware/rateLimit.ts index 859c1889..04adcdf4 100644 --- a/worker/src/middleware/rateLimit.ts +++ b/worker/src/middleware/rateLimit.ts @@ -42,7 +42,7 @@ export async function rateLimitMiddleware(c: Context<{ Bindings: Env }>, next: N entry.timestamps = entry.timestamps.filter((ts) => now - ts < WINDOW_MS); if (entry.timestamps.length >= MAX_REQUESTS) { - const oldestInWindow = entry.timestamps[0]!; + const oldestInWindow = entry.timestamps[0] as number; const retryAfterSec = Math.ceil((WINDOW_MS - (now - oldestInWindow)) / 1000); c.header("Retry-After", String(retryAfterSec)); return c.json({ error: "Rate limit exceeded. Try again later." }, 429); diff --git a/worker/src/middleware/rateLimitBeacon.ts b/worker/src/middleware/rateLimitBeacon.ts index 3a2a8bd6..7a7fbf10 100644 --- a/worker/src/middleware/rateLimitBeacon.ts +++ b/worker/src/middleware/rateLimitBeacon.ts @@ -38,7 +38,7 @@ export async function rateLimitBeaconMiddleware(c: Context<{ Bindings: Env }>, n entry.timestamps = entry.timestamps.filter((ts) => now - ts < WINDOW_MS); if (entry.timestamps.length >= MAX_REQUESTS) { - const oldestInWindow = entry.timestamps[0]!; + const oldestInWindow = entry.timestamps[0] as number; const retryAfterSec = Math.ceil((WINDOW_MS - (now - oldestInWindow)) / 1000); c.header("Retry-After", String(retryAfterSec)); return c.json({ error: "Rate limit exceeded. Try again later." }, 429); diff --git a/worker/src/middleware/rateLimitBtc.ts b/worker/src/middleware/rateLimitBtc.ts index a27eb044..4cf01176 100644 --- a/worker/src/middleware/rateLimitBtc.ts +++ b/worker/src/middleware/rateLimitBtc.ts @@ -38,7 +38,7 @@ export async function rateLimitBtcMiddleware(c: Context<{ Bindings: Env }>, next entry.timestamps = entry.timestamps.filter((ts) => now - ts < WINDOW_MS); if (entry.timestamps.length >= MAX_REQUESTS) { - const oldestInWindow = entry.timestamps[0]!; + const oldestInWindow = entry.timestamps[0] as number; const retryAfterSec = Math.ceil((WINDOW_MS - (now - oldestInWindow)) / 1000); c.header("Retry-After", String(retryAfterSec)); return c.json({ error: "Rate limit exceeded. Try again later." }, 429); diff --git a/worker/src/middleware/rateLimitEtherscan.ts b/worker/src/middleware/rateLimitEtherscan.ts index 8fd2fe16..d1ec3cd5 100644 --- a/worker/src/middleware/rateLimitEtherscan.ts +++ b/worker/src/middleware/rateLimitEtherscan.ts @@ -38,7 +38,7 @@ export async function rateLimitEtherscanMiddleware(c: Context<{ Bindings: Env }> entry.timestamps = entry.timestamps.filter((ts) => now - ts < WINDOW_MS); if (entry.timestamps.length >= MAX_REQUESTS) { - const oldestInWindow = entry.timestamps[0]!; + const oldestInWindow = entry.timestamps[0] as number; const retryAfterSec = Math.ceil((WINDOW_MS - (now - oldestInWindow)) / 1000); c.header("Retry-After", String(retryAfterSec)); return c.json({ error: "Rate limit exceeded. Try again later." }, 429); diff --git a/worker/src/middleware/rateLimitEvm.ts b/worker/src/middleware/rateLimitEvm.ts index b90c0afa..8f17a591 100644 --- a/worker/src/middleware/rateLimitEvm.ts +++ b/worker/src/middleware/rateLimitEvm.ts @@ -38,7 +38,7 @@ export async function rateLimitEvmMiddleware(c: Context<{ Bindings: Env }>, next entry.timestamps = entry.timestamps.filter((ts) => now - ts < WINDOW_MS); if (entry.timestamps.length >= MAX_REQUESTS) { - const oldestInWindow = entry.timestamps[0]!; + const oldestInWindow = entry.timestamps[0] as number; const retryAfterSec = Math.ceil((WINDOW_MS - (now - oldestInWindow)) / 1000); c.header("Retry-After", String(retryAfterSec)); return c.json({ error: "Rate limit exceeded. Try again later." }, 429); diff --git a/worker/src/routes/ankrRpc.ts b/worker/src/routes/ankrRpc.ts index 354a5c6b..4611d5f2 100644 --- a/worker/src/routes/ankrRpc.ts +++ b/worker/src/routes/ankrRpc.ts @@ -1,10 +1,15 @@ import type { Context } from "hono"; -import { ALLOWED_EVM_NETWORKS, type BtcRpcRequestBody, type EvmRpcRequestBody, type Env } from "../types"; +import { + ALLOWED_EVM_NETWORKS, + type BtcRpcRequestBody, + type EvmRpcRequestBody, + type Env, +} from "../types"; const ANKR_BASE = "https://rpc.ankr.com"; export async function evmAnkrHandler(c: Context<{ Bindings: Env }>) { - const networkId = c.req.param("networkId")!; + const networkId = c.req.param("networkId") ?? ""; const body = c.get("validatedBody" as never) as unknown as EvmRpcRequestBody; const network = ALLOWED_EVM_NETWORKS[networkId]; diff --git a/worker/src/routes/beaconBlobSidecars.ts b/worker/src/routes/beaconBlobSidecars.ts index 31fd5d2e..c866ec7c 100644 --- a/worker/src/routes/beaconBlobSidecars.ts +++ b/worker/src/routes/beaconBlobSidecars.ts @@ -7,8 +7,8 @@ const ALCHEMY_BEACON_HOSTS: Record = { }; export async function beaconAlchemyHandler(c: Context<{ Bindings: Env }>) { - const networkId = c.req.param("networkId")!; - const slot = c.req.param("slot")!; + const networkId = c.req.param("networkId") ?? ""; + const slot = c.req.param("slot") ?? ""; const networkSlug = ALLOWED_BEACON_NETWORKS[networkId]; if (!networkSlug) { diff --git a/worker/src/routes/drpcRpc.ts b/worker/src/routes/drpcRpc.ts index 8ab5e7c3..e0a7e858 100644 --- a/worker/src/routes/drpcRpc.ts +++ b/worker/src/routes/drpcRpc.ts @@ -1,10 +1,15 @@ import type { Context } from "hono"; -import { ALLOWED_EVM_NETWORKS, type BtcRpcRequestBody, type EvmRpcRequestBody, type Env } from "../types"; +import { + ALLOWED_EVM_NETWORKS, + type BtcRpcRequestBody, + type EvmRpcRequestBody, + type Env, +} from "../types"; const DRPC_BASE = "https://lb.drpc.org/ogrpc"; export async function evmDrpcHandler(c: Context<{ Bindings: Env }>) { - const networkId = c.req.param("networkId")!; + const networkId = c.req.param("networkId") ?? ""; const body = c.get("validatedBody" as never) as unknown as EvmRpcRequestBody; const network = ALLOWED_EVM_NETWORKS[networkId]; diff --git a/worker/src/routes/evmRpc.ts b/worker/src/routes/evmRpc.ts index dff32296..d76db333 100644 --- a/worker/src/routes/evmRpc.ts +++ b/worker/src/routes/evmRpc.ts @@ -2,7 +2,7 @@ import type { Context } from "hono"; import { ALLOWED_EVM_NETWORKS, type EvmRpcRequestBody, type Env } from "../types"; export async function evmAlchemyHandler(c: Context<{ Bindings: Env }>) { - const networkId = c.req.param("networkId")!; + const networkId = c.req.param("networkId") ?? ""; const body = c.get("validatedBody" as never) as unknown as EvmRpcRequestBody; const network = ALLOWED_EVM_NETWORKS[networkId]; @@ -37,7 +37,7 @@ export async function evmAlchemyHandler(c: Context<{ Bindings: Env }>) { } export async function evmInfuraHandler(c: Context<{ Bindings: Env }>) { - const networkId = c.req.param("networkId")!; + const networkId = c.req.param("networkId") ?? ""; const body = c.get("validatedBody" as never) as unknown as EvmRpcRequestBody; const network = ALLOWED_EVM_NETWORKS[networkId]; diff --git a/worker/src/routes/onfinalityRpc.ts b/worker/src/routes/onfinalityRpc.ts index 5564a5f3..c67d375c 100644 --- a/worker/src/routes/onfinalityRpc.ts +++ b/worker/src/routes/onfinalityRpc.ts @@ -7,7 +7,7 @@ const ONFINALITY_BTC_HOSTS: Record = { }; export async function btcOnfinalityHandler(c: Context<{ Bindings: Env }>) { - const networkId = c.req.param("networkId")!; + const networkId = c.req.param("networkId") ?? ""; const body = c.get("validatedBody" as never) as unknown as BtcRpcRequestBody; const host = ONFINALITY_BTC_HOSTS[networkId]; From 856d76bf9fef659488e7a636a9b8f71eb5603a2e Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 23 Mar 2026 08:25:15 -0300 Subject: [PATCH 38/54] feat(address): add helper tooltips and fix type indicator for x402 facilitators Add FieldLabel tooltips to FacilitatorInfoCard for Name, Base URL, Schemes, Assets, and Capabilities (visible for beginner/intermediate). Show x402 facilitators as "Account (EOA)" with an "x402 Facilitator" tag since they are primarily EOA accounts. --- .../evm/address/shared/AddressHeader.tsx | 3 ++ .../address/shared/FacilitatorInfoCard.tsx | 36 ++++++++++++++++--- src/locales/en/tooltips.json | 7 +++- src/locales/es/tooltips.json | 7 +++- src/locales/ja/tooltips.json | 7 +++- src/locales/pt-BR/tooltips.json | 7 +++- src/locales/zh/tooltips.json | 7 +++- src/utils/addressTypeDetection.ts | 4 +-- 8 files changed, 66 insertions(+), 12 deletions(-) diff --git a/src/components/pages/evm/address/shared/AddressHeader.tsx b/src/components/pages/evm/address/shared/AddressHeader.tsx index 9eb8248b..982e18e2 100644 --- a/src/components/pages/evm/address/shared/AddressHeader.tsx +++ b/src/components/pages/evm/address/shared/AddressHeader.tsx @@ -55,6 +55,9 @@ const AddressHeader: React.FC = ({ )} + {addressType === "x402Facilitator" && ( + x402 Facilitator + )} {tokenSymbol && {tokenSymbol}} {klerosTag && ( = ({ facilitator } {/* Name with logo */}
    - {t("facilitatorName")}: + = ({ facilitator } {/* Base URL */}
    - {t("facilitatorBaseUrl")}: + {facilitator.baseUrl}
    {/* Schemes */}
    - {t("facilitatorSchemes")}: + {facilitator.schemes.join(", ")}
    {/* Assets */}
    - {t("facilitatorAssets")}: + {facilitator.assets.join(", ")}
    {/* Capabilities */}
    - {t("facilitatorCapabilities")}: + {facilitator.supports.verify && ( diff --git a/src/locales/en/tooltips.json b/src/locales/en/tooltips.json index 10be0d52..eab9fe8b 100644 --- a/src/locales/en/tooltips.json +++ b/src/locales/en/tooltips.json @@ -88,7 +88,12 @@ "contractBytecode": "The compiled EVM bytecode deployed at this address.", "sourceCode": "The original Solidity/Vyper source code, verified to match the deployed bytecode.", "rawAbi": "The Application Binary Interface (ABI) defines how to interact with this contract's functions.", - "functions": "Available contract functions that can be called to read data or modify state." + "functions": "Available contract functions that can be called to read data or modify state.", + "facilitatorName": "The registered name of this x402 payment facilitator — a service that verifies and settles HTTP 402 crypto payments on behalf of API providers.", + "facilitatorBaseUrl": "The API endpoint where this facilitator exposes its x402 protocol operations (verify, settle, etc.).", + "facilitatorSchemes": "The payment scheme this facilitator supports. 'exact' means the payer sends the precise amount requested by the server.", + "facilitatorAssets": "The token standards accepted for payment. EIP-3009 = gasless USDC/EURC transfers, SPL = Solana tokens, Token-2022 = Solana token extensions.", + "facilitatorCapabilities": "The x402 protocol operations this facilitator supports: Verify (validate payment), Settle (execute on-chain transfer), Supported (query accepted tokens/networks), List (discover available services)." }, "token": { "tokenStandard": "The token interface standard (e.g. ERC-20, ERC-721, ERC-1155) that defines how this token behaves.", diff --git a/src/locales/es/tooltips.json b/src/locales/es/tooltips.json index 68feb29c..fbbe035e 100644 --- a/src/locales/es/tooltips.json +++ b/src/locales/es/tooltips.json @@ -88,7 +88,12 @@ "contractBytecode": "El bytecode EVM compilado desplegado en esta dirección.", "sourceCode": "El código fuente original en Solidity/Vyper, verificado para coincidir con el bytecode desplegado.", "rawAbi": "La Interfaz Binaria de Aplicación (ABI) define cómo interactuar con las funciones de este contrato.", - "functions": "Funciones de contrato disponibles que pueden ser llamadas para leer datos o modificar el estado." + "functions": "Funciones de contrato disponibles que pueden ser llamadas para leer datos o modificar el estado.", + "facilitatorName": "El nombre registrado de este facilitador de pagos x402 — un servicio que verifica y liquida pagos cripto HTTP 402 en nombre de proveedores de API.", + "facilitatorBaseUrl": "El endpoint de API donde este facilitador expone sus operaciones del protocolo x402 (verificar, liquidar, etc.).", + "facilitatorSchemes": "El esquema de pago que soporta este facilitador. 'exact' significa que el pagador envía el monto exacto solicitado por el servidor.", + "facilitatorAssets": "Los estándares de tokens aceptados para el pago. EIP-3009 = transferencias USDC/EURC sin gas, SPL = tokens de Solana, Token-2022 = extensiones de tokens de Solana.", + "facilitatorCapabilities": "Las operaciones del protocolo x402 que este facilitador soporta: Verify (validar pago), Settle (ejecutar transferencia on-chain), Supported (consultar tokens/redes aceptados), List (descubrir servicios disponibles)." }, "token": { "tokenStandard": "El estándar de interfaz del token (ej. ERC-20, ERC-721, ERC-1155) que define cómo se comporta.", diff --git a/src/locales/ja/tooltips.json b/src/locales/ja/tooltips.json index 47efb91f..db806742 100644 --- a/src/locales/ja/tooltips.json +++ b/src/locales/ja/tooltips.json @@ -88,7 +88,12 @@ "contractBytecode": "このアドレスにデプロイされたコンパイル済みEVMバイトコード。", "sourceCode": "デプロイされたバイトコードと一致することが検証された、元のSolidity/Vyperソースコード。", "rawAbi": "アプリケーションバイナリインターフェース(ABI)は、このコントラクトの関数との対話方法を定義します。", - "functions": "データの読み取りまたは状態の変更のために呼び出し可能なコントラクト関数。" + "functions": "データの読み取りまたは状態の変更のために呼び出し可能なコントラクト関数。", + "facilitatorName": "このx402決済ファシリテーターの登録名 — APIプロバイダーに代わってHTTP 402暗号決済を検証・決済するサービスです。", + "facilitatorBaseUrl": "このファシリテーターがx402プロトコル操作(検証、決済など)を公開するAPIエンドポイント。", + "facilitatorSchemes": "このファシリテーターがサポートする決済スキーム。「exact」は支払者がサーバーの要求する正確な金額を送信することを意味します。", + "facilitatorAssets": "決済に受け入れられるトークン規格。EIP-3009 = ガスレスUSDC/EURC転送、SPL = Solanaトークン、Token-2022 = Solanaトークン拡張。", + "facilitatorCapabilities": "このファシリテーターがサポートするx402プロトコル操作:Verify(決済検証)、Settle(オンチェーン転送実行)、Supported(対応トークン/ネットワーク照会)、List(利用可能なサービスの発見)。" }, "token": { "tokenStandard": "トークンのインターフェース標準(ERC-20、ERC-721、ERC-1155など)。トークンの動作を定義します。", diff --git a/src/locales/pt-BR/tooltips.json b/src/locales/pt-BR/tooltips.json index 757fc5b7..dfc399c5 100644 --- a/src/locales/pt-BR/tooltips.json +++ b/src/locales/pt-BR/tooltips.json @@ -88,7 +88,12 @@ "contractBytecode": "O bytecode EVM compilado implantado neste endereço.", "sourceCode": "O código-fonte original em Solidity/Vyper, verificado para corresponder ao bytecode implantado.", "rawAbi": "A Interface Binária de Aplicação (ABI) define como interagir com as funções deste contrato.", - "functions": "Funções de contrato disponíveis que podem ser chamadas para ler dados ou modificar o estado." + "functions": "Funções de contrato disponíveis que podem ser chamadas para ler dados ou modificar o estado.", + "facilitatorName": "O nome registrado deste facilitador de pagamentos x402 — um serviço que verifica e liquida pagamentos cripto HTTP 402 em nome de provedores de API.", + "facilitatorBaseUrl": "O endpoint de API onde este facilitador expõe suas operações do protocolo x402 (verificar, liquidar, etc.).", + "facilitatorSchemes": "O esquema de pagamento que este facilitador suporta. 'exact' significa que o pagador envia o valor exato solicitado pelo servidor.", + "facilitatorAssets": "Os padrões de tokens aceitos para pagamento. EIP-3009 = transferências USDC/EURC sem gas, SPL = tokens Solana, Token-2022 = extensões de tokens Solana.", + "facilitatorCapabilities": "As operações do protocolo x402 que este facilitador suporta: Verify (validar pagamento), Settle (executar transferência on-chain), Supported (consultar tokens/redes aceitos), List (descobrir serviços disponíveis)." }, "token": { "tokenStandard": "O padrão de interface do token (ex: ERC-20, ERC-721, ERC-1155) que define como este token se comporta.", diff --git a/src/locales/zh/tooltips.json b/src/locales/zh/tooltips.json index 7a0deba2..8ceaee51 100644 --- a/src/locales/zh/tooltips.json +++ b/src/locales/zh/tooltips.json @@ -88,7 +88,12 @@ "contractBytecode": "部署在此地址的编译后EVM字节码。", "sourceCode": "经验证与部署字节码匹配的原始Solidity/Vyper源代码。", "rawAbi": "应用程序二进制接口(ABI)定义了如何与此合约的函数进行交互。", - "functions": "可调用的合约函数,用于读取数据或修改状态。" + "functions": "可调用的合约函数,用于读取数据或修改状态。", + "facilitatorName": "此x402支付协调器的注册名称 — 一种代表API提供商验证和结算HTTP 402加密支付的服务。", + "facilitatorBaseUrl": "此协调器公开其x402协议操作(验证、结算等)的API端点。", + "facilitatorSchemes": "此协调器支持的支付方案。'exact'表示付款方发送服务器请求的精确金额。", + "facilitatorAssets": "接受的代币标准。EIP-3009 = 无Gas USDC/EURC转账,SPL = Solana代币,Token-2022 = Solana代币扩展。", + "facilitatorCapabilities": "此协调器支持的x402协议操作:Verify(验证支付)、Settle(执行链上转账)、Supported(查询支持的代币/网络)、List(发现可用服务)。" }, "token": { "tokenStandard": "代币接口标准(如ERC-20、ERC-721、ERC-1155),定义了代币的行为方式。", diff --git a/src/utils/addressTypeDetection.ts b/src/utils/addressTypeDetection.ts index 963a618f..cc664ba7 100644 --- a/src/utils/addressTypeDetection.ts +++ b/src/utils/addressTypeDetection.ts @@ -315,7 +315,7 @@ export function getAddressTypeLabel(type: AddressType): string { case "erc1155": return "ERC-1155 Multi-Token"; case "x402Facilitator": - return "x402 Facilitator"; + return "Account (EOA)"; default: return "Unknown"; } @@ -337,7 +337,7 @@ export function getAddressTypeIcon(type: AddressType): string { case "erc1155": return "📦"; case "x402Facilitator": - return "💳"; + return "👤"; default: return "❓"; } From 5428f97d29522d865303985e5f9bb42e7e42424b Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 23 Mar 2026 08:38:28 -0300 Subject: [PATCH 39/54] fix(block): auto-select More Details on expand and add missing tooltips When expanding via the collapse button with no tab selected, auto-select "moreDetails" so the panel shows content. Replace plain labels with FieldLabel components for all 9 fields with tooltips visible for all knowledge levels except nonce (beginner/intermediate only). Closes #336 Closes #337 --- .../pages/evm/block/BlockAnalyser.tsx | 71 ++++++++++++++++--- 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/src/components/pages/evm/block/BlockAnalyser.tsx b/src/components/pages/evm/block/BlockAnalyser.tsx index acd7ca0b..62902795 100644 --- a/src/components/pages/evm/block/BlockAnalyser.tsx +++ b/src/components/pages/evm/block/BlockAnalyser.tsx @@ -5,6 +5,7 @@ import { Link } from "react-router-dom"; import { useBeaconBlobs } from "../../../../hooks/useBeaconBlobs"; import type { Block, BlockArbitrum } from "../../../../types"; import BlobDataDisplay from "../../../common/BlobDataDisplay"; +import FieldLabel from "../../../common/FieldLabel"; type BlockAnalyserTab = "moreDetails" | "transactions" | "withdrawals" | "blobData"; @@ -92,7 +93,12 @@ const BlockAnalyser: React.FC = ({ block, networkId, isSuper
    +
    +

    ☁️ {t("workerProxy.title")}

    +

    {t("workerProxy.description")}

    + +
    +
    +
    {t("workerProxy.aiAnalysis.label")}
    +
    + {t("workerProxy.aiAnalysis.description")} +
    +
    + +
    + +
    +
    +
    {t("workerProxy.rpcProxy.label")}
    +
    + {t("workerProxy.rpcProxy.description")} +
    +
    + +
    +
    +

    ⚡ {t("rpcStrategy.title")}

    {t("rpcStrategy.description")}

    diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx index 32d8ab9c..f5159bd6 100644 --- a/src/context/AppContext.tsx +++ b/src/context/AppContext.tsx @@ -29,6 +29,16 @@ import { // Alias exported for use across the app where a shorter/consistent name is preferred export type tRpcUrlsContextType = RpcUrlsContextType; +function readWorkerProxyRpcSetting(): boolean { + try { + const raw = localStorage.getItem("openScan_user_settings"); + if (raw) return (JSON.parse(raw) as { workerProxyRpc?: boolean }).workerProxyRpc !== false; + } catch { + /* ignore */ + } + return true; +} + export const AppContext = createContext({ appReady: false, resourcesLoaded: false, @@ -50,7 +60,9 @@ export const AppContextProvider = ({ children }: { children: ReactNode }) => { const [appReady, setAppReady] = useState(false); const [resourcesLoaded, setResourcesLoaded] = useState(false); const [isHydrated, setIsHydrated] = useState(false); - const [rpcUrls, setRpcUrlsState] = useState(() => getEffectiveRpcUrls()); + const [rpcUrls, setRpcUrlsState] = useState(() => + getEffectiveRpcUrls({ excludeWorkerProxy: !readWorkerProxyRpcSetting() }), + ); // biome-ignore lint/suspicious/noExplicitAny: const [jsonFiles, setJsonFilesState] = useState>(() => loadJsonFilesFromStorage(), @@ -102,7 +114,7 @@ export const AppContextProvider = ({ children }: { children: ReactNode }) => { } } - setRpcUrlsState(getEffectiveRpcUrls()); + setRpcUrlsState(getEffectiveRpcUrls({ excludeWorkerProxy: !readWorkerProxyRpcSetting() })); } catch (err) { setNetworksError(err instanceof Error ? err.message : "Failed to load networks"); setNetworks(getAllNetworks()); diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index d73321aa..24edd808 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -149,6 +149,18 @@ } } }, + "workerProxy": { + "title": "Worker Proxy", + "description": "Control the OpenScan worker proxy used for AI analysis and RPC routing. Disabling these will require your own API keys or endpoints.", + "aiAnalysis": { + "label": "AI Analysis Proxy", + "description": "Enable the OpenScan AI analysis proxy. When disabled, the AI analysis panel will be hidden across all pages." + }, + "rpcProxy": { + "label": "RPC Proxy", + "description": "Enable OpenScan worker proxy RPC endpoints. When disabled, only metadata and your personal RPC endpoints will be used." + } + }, "beaconApi": { "title": "Beacon API", "description": "Configure Beacon Chain API endpoints to view EIP-4844 blob data. Public endpoints are provided by default.", diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json index d731268a..1936a346 100644 --- a/src/locales/es/settings.json +++ b/src/locales/es/settings.json @@ -149,6 +149,18 @@ } } }, + "workerProxy": { + "title": "Proxy Worker", + "description": "Controlá el proxy worker de OpenScan usado para análisis de IA y enrutamiento RPC. Desactivarlos requiere tus propias claves de API o endpoints.", + "aiAnalysis": { + "label": "Proxy de Análisis IA", + "description": "Habilitar el proxy de análisis IA de OpenScan. Cuando está desactivado, el panel de análisis IA se oculta en todas las páginas." + }, + "rpcProxy": { + "label": "Proxy RPC", + "description": "Habilitar los endpoints RPC del proxy worker de OpenScan. Cuando está desactivado, solo se usarán los endpoints de metadatos y personales." + } + }, "beaconApi": { "title": "Beacon API", "description": "Configurá endpoints de Beacon Chain API para ver datos de blobs EIP-4844. Se proporcionan endpoints públicos por defecto.", diff --git a/src/locales/ja/settings.json b/src/locales/ja/settings.json index 6a1f1c0e..17c4d4ab 100644 --- a/src/locales/ja/settings.json +++ b/src/locales/ja/settings.json @@ -149,6 +149,18 @@ } } }, + "workerProxy": { + "title": "ワーカープロキシ", + "description": "AI分析とRPCルーティングに使用されるOpenScanワーカープロキシを制御します。無効にすると、独自のAPIキーまたはエンドポイントが必要です。", + "aiAnalysis": { + "label": "AI分析プロキシ", + "description": "OpenScan AI分析プロキシを有効にします。無効にすると、すべてのページでAI分析パネルが非表示になります。" + }, + "rpcProxy": { + "label": "RPCプロキシ", + "description": "OpenScanワーカープロキシRPCエンドポイントを有効にします。無効にすると、メタデータと個人のRPCエンドポイントのみが使用されます。" + } + }, "beaconApi": { "title": "Beacon API", "description": "EIP-4844 Blob データを表示するための Beacon Chain API エンドポイントを設定します。デフォルトで公開エンドポイントが提供されます。", diff --git a/src/locales/pt-BR/settings.json b/src/locales/pt-BR/settings.json index 8571ef2e..eba67fd1 100644 --- a/src/locales/pt-BR/settings.json +++ b/src/locales/pt-BR/settings.json @@ -149,6 +149,18 @@ } } }, + "workerProxy": { + "title": "Proxy Worker", + "description": "Controle o proxy worker do OpenScan usado para análise de IA e roteamento RPC. Desativar requer suas próprias chaves de API ou endpoints.", + "aiAnalysis": { + "label": "Proxy de Análise IA", + "description": "Ativar o proxy de análise IA do OpenScan. Quando desativado, o painel de análise IA será ocultado em todas as páginas." + }, + "rpcProxy": { + "label": "Proxy RPC", + "description": "Ativar os endpoints RPC do proxy worker do OpenScan. Quando desativado, apenas endpoints de metadados e pessoais serão usados." + } + }, "beaconApi": { "title": "Beacon API", "description": "Configure endpoints de Beacon Chain API para visualizar dados de blobs EIP-4844. Endpoints públicos são fornecidos por padrão.", diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json index be6e0353..67908294 100644 --- a/src/locales/zh/settings.json +++ b/src/locales/zh/settings.json @@ -149,6 +149,18 @@ } } }, + "workerProxy": { + "title": "工作代理", + "description": "控制用于AI分析和RPC路由的OpenScan工作代理。禁用后需要您自己的API密钥或端点。", + "aiAnalysis": { + "label": "AI分析代理", + "description": "启用OpenScan AI分析代理。禁用后,所有页面上的AI分析面板将被隐藏。" + }, + "rpcProxy": { + "label": "RPC代理", + "description": "启用OpenScan工作代理RPC端点。禁用后,仅使用元数据和您个人的RPC端点。" + } + }, "beaconApi": { "title": "Beacon API", "description": "配置 Beacon Chain API 端点以查看 EIP-4844 Blob 数据。默认提供公共端点。", diff --git a/src/types/index.ts b/src/types/index.ts index 73ecc2a6..edc8e45b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -511,6 +511,8 @@ export interface UserSettings { beaconUrls?: Record; knowledgeLevel?: KnowledgeLevel; showHelperTooltips?: boolean; + workerProxyAi?: boolean; + workerProxyRpc?: boolean; } // ==================== BEACON/BLOB TYPES ==================== @@ -540,6 +542,8 @@ export const DEFAULT_SETTINGS: UserSettings = { persistentCacheSizeMB: 10, knowledgeLevel: "beginner", showHelperTooltips: true, + workerProxyAi: true, + workerProxyRpc: true, }; /** diff --git a/src/utils/rpcStorage.ts b/src/utils/rpcStorage.ts index e3836b82..49a5ea7e 100644 --- a/src/utils/rpcStorage.ts +++ b/src/utils/rpcStorage.ts @@ -189,19 +189,38 @@ export function saveRpcUrlsToStorage(map: RpcUrlsContextType): void { * Stored values override default for a network; missing networks fall back to defaults. * Keys are networkId strings (CAIP-2 format) */ -export function getEffectiveRpcUrls(): RpcUrlsContextType { +/** + * Check whether a URL points to the OpenScan worker proxy. + */ +export function isWorkerProxyUrl(url: string): boolean { + return url.startsWith(OPENSCAN_WORKER_URL); +} + +export function getEffectiveRpcUrls(options?: { + excludeWorkerProxy?: boolean; +}): RpcUrlsContextType { // Merge metadata defaults first, then builtin worker defaults take priority const defaults = { ...getDefaultRpcEndpoints(), ...BUILTIN_RPC_DEFAULTS }; const stored = loadRpcUrlsFromStorage(); - if (!stored) return defaults; - // Merge: stored values override defaults const merged: RpcUrlsContextType = { ...defaults }; - for (const k of Object.keys(stored)) { - const val = stored[k]; - if (!val || !Array.isArray(val) || val.length === 0) continue; - merged[k] = val; + if (stored) { + for (const k of Object.keys(stored)) { + const val = stored[k]; + if (!val || !Array.isArray(val) || val.length === 0) continue; + merged[k] = val; + } } + + if (options?.excludeWorkerProxy) { + const filtered: RpcUrlsContextType = {}; + for (const [networkId, urls] of Object.entries(merged)) { + const nonWorkerUrls = urls.filter((url) => !isWorkerProxyUrl(url)); + if (nonWorkerUrls.length > 0) filtered[networkId] = nonWorkerUrls; + } + return filtered; + } + return merged; } From 728a898a2ff19394345e463bf24dcb0beea0fd45 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 23 Mar 2026 12:56:56 -0300 Subject: [PATCH 42/54] refactor(address): use tokenSymbol prop for x402 tag instead of hardcoding Remove x402-specific conditional from AddressHeader and pass "x402 Facilitator" via the existing tokenSymbol prop from X402FacilitatorDisplay. Keeps AddressHeader generic. --- .../pages/evm/address/displays/X402FacilitatorDisplay.tsx | 1 + src/components/pages/evm/address/shared/AddressHeader.tsx | 3 --- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/pages/evm/address/displays/X402FacilitatorDisplay.tsx b/src/components/pages/evm/address/displays/X402FacilitatorDisplay.tsx index 0b1df41b..6323e232 100644 --- a/src/components/pages/evm/address/displays/X402FacilitatorDisplay.tsx +++ b/src/components/pages/evm/address/displays/X402FacilitatorDisplay.tsx @@ -186,6 +186,7 @@ const X402FacilitatorDisplay: React.FC = ({ metadata={metadata} selectedProvider={selectedProvider} onProviderSelect={onProviderSelect} + tokenSymbol="x402 Facilitator" tokenName={facilitator?.name} klerosTag={klerosTag} /> diff --git a/src/components/pages/evm/address/shared/AddressHeader.tsx b/src/components/pages/evm/address/shared/AddressHeader.tsx index 982e18e2..9eb8248b 100644 --- a/src/components/pages/evm/address/shared/AddressHeader.tsx +++ b/src/components/pages/evm/address/shared/AddressHeader.tsx @@ -55,9 +55,6 @@ const AddressHeader: React.FC = ({ )} - {addressType === "x402Facilitator" && ( - x402 Facilitator - )} {tokenSymbol && {tokenSymbol}} {klerosTag && (
    Date: Mon, 23 Mar 2026 13:01:09 -0300 Subject: [PATCH 43/54] ci(e2e): split test jobs to avoid 15-minute timeout cancellations Shard eth-mainnet into 2 parallel jobs (3 tests each). Split EVM networks into 1 job per network (5 parallel jobs) instead of 2 shards. --- .github/workflows/e2e-eth-mainnet.yml | 14 +++++++++++--- .github/workflows/e2e-evm-networks.yml | 14 ++++++++++---- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.github/workflows/e2e-eth-mainnet.yml b/.github/workflows/e2e-eth-mainnet.yml index 0f23f2eb..92d1e130 100644 --- a/.github/workflows/e2e-eth-mainnet.yml +++ b/.github/workflows/e2e-eth-mainnet.yml @@ -9,6 +9,14 @@ jobs: e2e-mainnet: runs-on: ubuntu-latest timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + shard: + - name: shard-a + tests: "e2e/tests/eth-mainnet/block.spec.ts e2e/tests/eth-mainnet/blocks.spec.ts e2e/tests/eth-mainnet/transaction.spec.ts" + - name: shard-b + tests: "e2e/tests/eth-mainnet/txs.spec.ts e2e/tests/eth-mainnet/address.spec.ts e2e/tests/eth-mainnet/token.spec.ts" steps: - uses: actions/checkout@v4 @@ -27,8 +35,8 @@ jobs: - name: Build application run: ./scripts/build-production.sh - - name: Run Ethereum Mainnet E2E tests - run: bun run test:e2e:eth-mainnet + - name: Run Ethereum Mainnet E2E tests (${{ matrix.shard.name }}) + run: bunx playwright test ${{ matrix.shard.tests }} env: CI: true INFURA_API_KEY: ${{ secrets.INFURA_API_KEY }} @@ -38,6 +46,6 @@ jobs: uses: actions/upload-artifact@v4 if: failure() with: - name: playwright-report-mainnet + name: playwright-report-mainnet-${{ matrix.shard.name }} path: playwright-report/ retention-days: 7 diff --git a/.github/workflows/e2e-evm-networks.yml b/.github/workflows/e2e-evm-networks.yml index c2bd05b0..d87084d5 100644 --- a/.github/workflows/e2e-evm-networks.yml +++ b/.github/workflows/e2e-evm-networks.yml @@ -13,10 +13,16 @@ jobs: fail-fast: false matrix: shard: - - name: shard-a - tests: "e2e/tests/evm-networks/arbitrum.spec.ts e2e/tests/evm-networks/base.spec.ts e2e/tests/evm-networks/optimism.spec.ts" - - name: shard-b - tests: "e2e/tests/evm-networks/bsc.spec.ts e2e/tests/evm-networks/polygon.spec.ts" + - name: arbitrum + tests: "e2e/tests/evm-networks/arbitrum.spec.ts" + - name: base + tests: "e2e/tests/evm-networks/base.spec.ts" + - name: optimism + tests: "e2e/tests/evm-networks/optimism.spec.ts" + - name: bsc + tests: "e2e/tests/evm-networks/bsc.spec.ts" + - name: polygon + tests: "e2e/tests/evm-networks/polygon.spec.ts" steps: - uses: actions/checkout@v4 From f80785ca540ce300825f3e2150f668901c6a6e9d Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 23 Mar 2026 13:03:54 -0300 Subject: [PATCH 44/54] ci(e2e): run automatically on PRs to main, optionally on PRs to dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change e2e workflow triggers from pull_request on dev to main, so they run automatically for release PRs (dev→main). Keep workflow_dispatch so they can be triggered manually on any branch/PR against dev. --- .github/workflows/e2e-bitcoin.yml | 2 +- .github/workflows/e2e-eth-mainnet.yml | 2 +- .github/workflows/e2e-evm-networks.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e-bitcoin.yml b/.github/workflows/e2e-bitcoin.yml index 8100456e..49b86cb8 100644 --- a/.github/workflows/e2e-bitcoin.yml +++ b/.github/workflows/e2e-bitcoin.yml @@ -2,7 +2,7 @@ name: E2E Tests - Bitcoin on: pull_request: - branches: [dev] + branches: [main] workflow_dispatch: jobs: diff --git a/.github/workflows/e2e-eth-mainnet.yml b/.github/workflows/e2e-eth-mainnet.yml index 92d1e130..64fe70ec 100644 --- a/.github/workflows/e2e-eth-mainnet.yml +++ b/.github/workflows/e2e-eth-mainnet.yml @@ -2,7 +2,7 @@ name: E2E Tests - Ethereum Mainnet on: pull_request: - branches: [dev] + branches: [main] workflow_dispatch: jobs: diff --git a/.github/workflows/e2e-evm-networks.yml b/.github/workflows/e2e-evm-networks.yml index d87084d5..3c28c365 100644 --- a/.github/workflows/e2e-evm-networks.yml +++ b/.github/workflows/e2e-evm-networks.yml @@ -2,7 +2,7 @@ name: E2E Tests - EVM Networks on: pull_request: - branches: [dev] + branches: [main] workflow_dispatch: jobs: From 9eb9e9a131035e8e9c992416408b482105019fb4 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Mon, 23 Mar 2026 13:05:10 -0300 Subject: [PATCH 45/54] ci(e2e): add workflow to run all e2e suites in parallel Add e2e-all.yml that dispatches eth-mainnet, evm-networks, and bitcoin workflows concurrently via workflow_call. Add workflow_call trigger to each child workflow to support reusable composition. --- .github/workflows/e2e-all.yml | 17 +++++++++++++++++ .github/workflows/e2e-bitcoin.yml | 1 + .github/workflows/e2e-eth-mainnet.yml | 1 + .github/workflows/e2e-evm-networks.yml | 1 + 4 files changed, 20 insertions(+) create mode 100644 .github/workflows/e2e-all.yml diff --git a/.github/workflows/e2e-all.yml b/.github/workflows/e2e-all.yml new file mode 100644 index 00000000..ddad65dd --- /dev/null +++ b/.github/workflows/e2e-all.yml @@ -0,0 +1,17 @@ +name: E2E Tests - All + +on: + workflow_dispatch: + +jobs: + e2e-eth-mainnet: + uses: ./.github/workflows/e2e-eth-mainnet.yml + secrets: inherit + + e2e-evm-networks: + uses: ./.github/workflows/e2e-evm-networks.yml + secrets: inherit + + e2e-bitcoin: + uses: ./.github/workflows/e2e-bitcoin.yml + secrets: inherit diff --git a/.github/workflows/e2e-bitcoin.yml b/.github/workflows/e2e-bitcoin.yml index 49b86cb8..6290c347 100644 --- a/.github/workflows/e2e-bitcoin.yml +++ b/.github/workflows/e2e-bitcoin.yml @@ -3,6 +3,7 @@ name: E2E Tests - Bitcoin on: pull_request: branches: [main] + workflow_call: workflow_dispatch: jobs: diff --git a/.github/workflows/e2e-eth-mainnet.yml b/.github/workflows/e2e-eth-mainnet.yml index 64fe70ec..98eb0e88 100644 --- a/.github/workflows/e2e-eth-mainnet.yml +++ b/.github/workflows/e2e-eth-mainnet.yml @@ -3,6 +3,7 @@ name: E2E Tests - Ethereum Mainnet on: pull_request: branches: [main] + workflow_call: workflow_dispatch: jobs: diff --git a/.github/workflows/e2e-evm-networks.yml b/.github/workflows/e2e-evm-networks.yml index 3c28c365..c309fca8 100644 --- a/.github/workflows/e2e-evm-networks.yml +++ b/.github/workflows/e2e-evm-networks.yml @@ -3,6 +3,7 @@ name: E2E Tests - EVM Networks on: pull_request: branches: [main] + workflow_call: workflow_dispatch: jobs: From 7f2b82acda6d10785b8650e64844c64efbca0cb3 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Wed, 25 Mar 2026 08:11:36 -0300 Subject: [PATCH 46/54] fix(rpc): prevent crash when worker proxy toggle is disabled Return empty array from getRPCUrls instead of throwing when a network has no configured endpoints. Keep networks with zero URLs in the filtered map so they remain visible but gracefully show error states instead of crashing the app. --- src/config/rpcConfig.ts | 5 +---- src/utils/rpcStorage.ts | 3 +-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/config/rpcConfig.ts b/src/config/rpcConfig.ts index f120055c..6aa20b7c 100644 --- a/src/config/rpcConfig.ts +++ b/src/config/rpcConfig.ts @@ -9,8 +9,5 @@ import type { RpcUrlsContextType } from "../types"; */ export function getRPCUrls(networkId: string, rpcUrlsMap: RpcUrlsContextType): string[] { const urls = rpcUrlsMap[networkId]; - if (!urls || urls.length === 0) { - throw new Error(`No RPC endpoint configured for network ${networkId}`); - } - return urls; + return urls ?? []; } diff --git a/src/utils/rpcStorage.ts b/src/utils/rpcStorage.ts index 49a5ea7e..6939f5e9 100644 --- a/src/utils/rpcStorage.ts +++ b/src/utils/rpcStorage.ts @@ -215,8 +215,7 @@ export function getEffectiveRpcUrls(options?: { if (options?.excludeWorkerProxy) { const filtered: RpcUrlsContextType = {}; for (const [networkId, urls] of Object.entries(merged)) { - const nonWorkerUrls = urls.filter((url) => !isWorkerProxyUrl(url)); - if (nonWorkerUrls.length > 0) filtered[networkId] = nonWorkerUrls; + filtered[networkId] = urls.filter((url) => !isWorkerProxyUrl(url)); } return filtered; } From 01237207082be25f1f7d0a4587632a457ceeb6bf Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Wed, 25 Mar 2026 08:22:50 -0300 Subject: [PATCH 47/54] fix(rpc): prevent worker proxy toggle from removing all RPC URLs Add REACT_APP_OPENSCAN_WORKER_URL to Vite define map so it is properly replaced at build time. Switch workerConfig.ts to dot notation for Vite compatibility. Guard isWorkerProxyUrl against empty string matching all URLs. --- src/config/workerConfig.ts | 4 +--- src/utils/rpcStorage.ts | 2 +- vite.config.ts | 3 +++ 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/config/workerConfig.ts b/src/config/workerConfig.ts index 4ac4653f..41558d4c 100644 --- a/src/config/workerConfig.ts +++ b/src/config/workerConfig.ts @@ -1,5 +1,3 @@ /** Base URL for the OpenScan Cloudflare Worker proxy */ export const OPENSCAN_WORKER_URL = - // biome-ignore lint/complexity/useLiteralKeys: env var access - process.env["REACT_APP_OPENSCAN_WORKER_URL"] ?? - "https://openscan-worker-proxy.openscan.workers.dev"; + process.env.REACT_APP_OPENSCAN_WORKER_URL || "https://openscan-worker-proxy.openscan.workers.dev"; diff --git a/src/utils/rpcStorage.ts b/src/utils/rpcStorage.ts index 6939f5e9..275b314e 100644 --- a/src/utils/rpcStorage.ts +++ b/src/utils/rpcStorage.ts @@ -193,7 +193,7 @@ export function saveRpcUrlsToStorage(map: RpcUrlsContextType): void { * Check whether a URL points to the OpenScan worker proxy. */ export function isWorkerProxyUrl(url: string): boolean { - return url.startsWith(OPENSCAN_WORKER_URL); + return OPENSCAN_WORKER_URL.length > 0 && url.startsWith(OPENSCAN_WORKER_URL); } export function getEffectiveRpcUrls(options?: { diff --git a/vite.config.ts b/vite.config.ts index 7ec37b31..139907c1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -61,6 +61,9 @@ export default defineConfig({ "process.env.REACT_APP_OPENSCAN_GROQ_AI_URL": JSON.stringify( process.env.REACT_APP_OPENSCAN_GROQ_AI_URL || "https://openscan-groq-ai-proxy.openscan.workers.dev" ), + "process.env.REACT_APP_OPENSCAN_WORKER_URL": JSON.stringify( + process.env.REACT_APP_OPENSCAN_WORKER_URL || "https://openscan-worker-proxy.openscan.workers.dev" + ), "import.meta.env.VITE_ENVIRONMENT": JSON.stringify( process.env.NODE_ENV || "development" ), From 827fcfd8dcdd8cfe10640e4320458e30c727c76b Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Wed, 25 Mar 2026 08:26:20 -0300 Subject: [PATCH 48/54] fix(settings): fix worker proxy toggle crash and alignment Add REACT_APP_OPENSCAN_WORKER_URL to Vite define map and switch workerConfig.ts to dot notation for proper build-time replacement. Guard isWorkerProxyUrl against empty string matching all URLs. Fix toggle knob alignment by adding flex-shrink: 0 and min-width to settings-toggle class. --- src/styles/styles.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/styles/styles.css b/src/styles/styles.css index 84c804e8..1af9f89c 100644 --- a/src/styles/styles.css +++ b/src/styles/styles.css @@ -5627,6 +5627,8 @@ code { position: relative; display: inline-block; width: 50px; + min-width: 50px; + flex-shrink: 0; height: 26px; cursor: pointer; } From c1f94bbd41f63585d0430a06a41a2645c9f37c17 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Wed, 25 Mar 2026 08:33:14 -0300 Subject: [PATCH 49/54] fix(rpc): merge worker proxy URLs with metadata RPCs instead of replacing Concatenate metadata and builtin worker URLs per-network with deduplication instead of using object spread which replaced entire arrays. Worker proxy endpoints now supplement metadata RPCs as intended. --- src/utils/rpcStorage.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/utils/rpcStorage.ts b/src/utils/rpcStorage.ts index 275b314e..72b8313f 100644 --- a/src/utils/rpcStorage.ts +++ b/src/utils/rpcStorage.ts @@ -199,8 +199,18 @@ export function isWorkerProxyUrl(url: string): boolean { export function getEffectiveRpcUrls(options?: { excludeWorkerProxy?: boolean; }): RpcUrlsContextType { - // Merge metadata defaults first, then builtin worker defaults take priority - const defaults = { ...getDefaultRpcEndpoints(), ...BUILTIN_RPC_DEFAULTS }; + // Merge metadata and builtin worker URLs per-network (concatenate arrays, deduplicate) + const metadataDefaults = getDefaultRpcEndpoints(); + const allNetworkIds = new Set([ + ...Object.keys(metadataDefaults), + ...Object.keys(BUILTIN_RPC_DEFAULTS), + ]); + const defaults: RpcUrlsContextType = {}; + for (const networkId of allNetworkIds) { + const metadataUrls = metadataDefaults[networkId] ?? []; + const builtinUrls = BUILTIN_RPC_DEFAULTS[networkId] ?? []; + defaults[networkId] = [...new Set([...metadataUrls, ...builtinUrls])]; + } const stored = loadRpcUrlsFromStorage(); const merged: RpcUrlsContextType = { ...defaults }; From c29b0dbca0ebaebbbae145d39715b8ba3f552fb2 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Wed, 25 Mar 2026 08:37:44 -0300 Subject: [PATCH 50/54] fix(rpc): merge stored user RPCs with defaults to preserve worker URLs Stored user overrides were replacing default URLs entirely, causing worker proxy URLs to disappear after page reload. Now stored URLs are merged with defaults so builtin worker endpoints persist across sessions and are only removed by the excludeWorkerProxy filter. --- src/utils/rpcStorage.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/utils/rpcStorage.ts b/src/utils/rpcStorage.ts index 72b8313f..647498ff 100644 --- a/src/utils/rpcStorage.ts +++ b/src/utils/rpcStorage.ts @@ -218,7 +218,9 @@ export function getEffectiveRpcUrls(options?: { for (const k of Object.keys(stored)) { const val = stored[k]; if (!val || !Array.isArray(val) || val.length === 0) continue; - merged[k] = val; + // Merge stored URLs with defaults so builtin worker URLs are always present + const defaultUrls = defaults[k] ?? []; + merged[k] = [...new Set([...val, ...defaultUrls])]; } } From d3036fb658e089bf7e2641b16ac6d645a2e764ff Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Wed, 25 Mar 2026 08:46:52 -0300 Subject: [PATCH 51/54] fix(ai): use unified worker proxy URL for AI analysis endpoint Point the openscan-groq AI provider at OPENSCAN_WORKER_URL instead of the legacy openscan-groq-ai-proxy URL. This ensures AI requests go through the same worker proxy that already has the Netlify preview origin in its CORS allowlist. Remove unused REACT_APP_OPENSCAN_GROQ_AI_URL from Vite define map. --- src/config/aiProviders.ts | 5 ++--- vite.config.ts | 3 --- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/config/aiProviders.ts b/src/config/aiProviders.ts index 00b5aa1e..c398f7d6 100644 --- a/src/config/aiProviders.ts +++ b/src/config/aiProviders.ts @@ -1,4 +1,5 @@ import type { AIProvider, AIProviderConfig } from "../types"; +import { OPENSCAN_WORKER_URL } from "./workerConfig"; /** * Static configuration for supported AI providers. @@ -9,9 +10,7 @@ export const AI_PROVIDERS: Record = { "openscan-groq": { id: "openscan-groq", name: "OpenScan Groq", - baseUrl: - process.env.REACT_APP_OPENSCAN_GROQ_AI_URL ?? - "https://openscan-groq-ai-proxy.openscan.workers.dev", + baseUrl: OPENSCAN_WORKER_URL, defaultModel: "groq/compound", keyUrl: "", }, diff --git a/vite.config.ts b/vite.config.ts index 139907c1..7fc220a4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -58,9 +58,6 @@ export default defineConfig({ "process.env.REACT_APP_OPENSCAN_NETWORKS": JSON.stringify( process.env.REACT_APP_OPENSCAN_NETWORKS || "" ), - "process.env.REACT_APP_OPENSCAN_GROQ_AI_URL": JSON.stringify( - process.env.REACT_APP_OPENSCAN_GROQ_AI_URL || "https://openscan-groq-ai-proxy.openscan.workers.dev" - ), "process.env.REACT_APP_OPENSCAN_WORKER_URL": JSON.stringify( process.env.REACT_APP_OPENSCAN_WORKER_URL || "https://openscan-worker-proxy.openscan.workers.dev" ), From 5bc9a4c413e8273fe391da3adc8f71b4c31d87c3 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Wed, 25 Mar 2026 08:54:40 -0300 Subject: [PATCH 52/54] fix(ai): show warning instead of hiding panel when proxy is disabled Always show the Analyze with AI button. When the AI proxy is disabled and no personal API keys are configured, show a warning message guiding the user to either enable the proxy or add their own keys. Users with their own API keys can still use AI analysis regardless of the proxy toggle. --- .../common/AIAnalysis/AIAnalysisPanel.tsx | 21 ++++++++++++++++++- src/locales/en/common.json | 3 ++- src/locales/es/common.json | 3 ++- src/locales/ja/common.json | 3 ++- src/locales/pt-BR/common.json | 3 ++- src/locales/zh/common.json | 3 ++- 6 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/components/common/AIAnalysis/AIAnalysisPanel.tsx b/src/components/common/AIAnalysis/AIAnalysisPanel.tsx index 0d7bb460..68d1e993 100644 --- a/src/components/common/AIAnalysis/AIAnalysisPanel.tsx +++ b/src/components/common/AIAnalysis/AIAnalysisPanel.tsx @@ -42,10 +42,18 @@ const AIAnalysisPanel: React.FC = ({ } }, [result, error]); - if (settings.workerProxyAi === false) return null; + const hasAiApiKeys = !!( + settings.apiKeys?.groq || + settings.apiKeys?.openai || + settings.apiKeys?.anthropic || + settings.apiKeys?.perplexity || + settings.apiKeys?.gemini + ); + const aiProxyDisabledNoKeys = settings.workerProxyAi === false && !hasAiApiKeys; const handleAnalyze = () => { setIsOpen(true); + if (aiProxyDisabledNoKeys) return; void analyze(); }; @@ -95,6 +103,17 @@ const AIAnalysisPanel: React.FC = ({ aria-hidden={!isOpen} style={{ display: isOpen ? "flex" : "none" }} > + {aiProxyDisabledNoKeys && ( +
    +
    {t("aiAnalysis.errors.proxyDisabled")}
    +
    + + {t("aiAnalysis.errors.goToSettings")} + +
    +
    + )} + {loading && !result && (
    diff --git a/src/locales/en/common.json b/src/locales/en/common.json index b3b3757c..42ee1dd9 100644 --- a/src/locales/en/common.json +++ b/src/locales/en/common.json @@ -299,7 +299,8 @@ "parseError": "Failed to parse AI response. Try again.", "generic": "Analysis failed. Please try again.", "tryAgain": "Try Again", - "goToSettings": "Go to Settings" + "goToSettings": "Go to Settings", + "proxyDisabled": "The free OpenScan AI proxy is disabled. Enable it in Settings → Network → Worker Proxy, or configure your own AI provider API keys in Settings → Providers." } }, "search": { diff --git a/src/locales/es/common.json b/src/locales/es/common.json index 87fadd57..4067115f 100644 --- a/src/locales/es/common.json +++ b/src/locales/es/common.json @@ -291,7 +291,8 @@ "parseError": "No se pudo procesar la respuesta de IA. Intentá de nuevo.", "generic": "El análisis falló. Intentá de nuevo.", "tryAgain": "Intentar de nuevo", - "goToSettings": "Ir a Configuración" + "goToSettings": "Ir a Configuración", + "proxyDisabled": "El proxy de IA gratuito de OpenScan está desactivado. Activalo en Configuración → Red → Proxy Worker, o configurá tus propias claves de API en Configuración → Proveedores." } }, "search": { diff --git a/src/locales/ja/common.json b/src/locales/ja/common.json index b11fc25c..843ccf18 100644 --- a/src/locales/ja/common.json +++ b/src/locales/ja/common.json @@ -289,7 +289,8 @@ "parseError": "AIレスポンスの解析に失敗しました。再試行してください。", "generic": "分析に失敗しました。もう一度お試しください。", "tryAgain": "再試行", - "goToSettings": "設定へ" + "goToSettings": "設定へ", + "proxyDisabled": "OpenScanの無料AIプロキシが無効です。設定 → ネットワーク → ワーカープロキシで有効にするか、設定 → プロバイダーで独自のAIプロバイダーAPIキーを設定してください。" } }, "search": { diff --git a/src/locales/pt-BR/common.json b/src/locales/pt-BR/common.json index e73d46f5..c441c67f 100644 --- a/src/locales/pt-BR/common.json +++ b/src/locales/pt-BR/common.json @@ -292,7 +292,8 @@ "parseError": "Falha ao analisar resposta da IA. Tente novamente.", "generic": "Análise falhou. Por favor, tente novamente.", "tryAgain": "Tentar Novamente", - "goToSettings": "Ir para Configurações" + "goToSettings": "Ir para Configurações", + "proxyDisabled": "O proxy de IA gratuito do OpenScan está desativado. Ative em Configurações → Rede → Proxy Worker, ou configure suas próprias chaves de API em Configurações → Provedores." } }, "search": { diff --git a/src/locales/zh/common.json b/src/locales/zh/common.json index 201b44af..47e84319 100644 --- a/src/locales/zh/common.json +++ b/src/locales/zh/common.json @@ -274,7 +274,8 @@ "parseError": "解析 AI 响应失败。请重试。", "generic": "分析失败。请重试。", "tryAgain": "重试", - "goToSettings": "前往设置" + "goToSettings": "前往设置", + "proxyDisabled": "OpenScan免费AI代理已禁用。请在设置 → 网络 → 工作代理中启用,或在设置 → 提供商中配置您自己的AI提供商API密钥。" } }, "search": { From 0666dfd659c598ac3acad5b2ce14485137ae2e3d Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Wed, 25 Mar 2026 09:08:12 -0300 Subject: [PATCH 53/54] fix(ai): skip openscan-groq proxy when AI worker proxy is disabled When workerProxyAi is false, skip the openscan-groq provider in resolveProvider so it falls through to the user's configured API keys. Previously it always used the free proxy first regardless of the toggle setting. --- src/hooks/useAIAnalysis.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/hooks/useAIAnalysis.ts b/src/hooks/useAIAnalysis.ts index a37447b7..d282777e 100644 --- a/src/hooks/useAIAnalysis.ts +++ b/src/hooks/useAIAnalysis.ts @@ -48,8 +48,9 @@ export function useAIAnalysis( const apiKeys = settings.apiKeys ?? {}; for (const id of AI_PROVIDER_ORDER) { - // openscan-groq is a free proxy — no API key needed + // openscan-groq is a free proxy — skip when AI worker proxy is disabled if (id === "openscan-groq") { + if (settings.workerProxyAi === false) continue; return { provider: AI_PROVIDERS[id], apiKey: "" }; } const key = apiKeys[id]; @@ -58,7 +59,7 @@ export function useAIAnalysis( } } return null; - }, [settings.apiKeys]); + }, [settings.apiKeys, settings.workerProxyAi]); // Augment cache key with version and mode so switching invalidates cache const augmentedCacheKey = `${cacheKey}_v${promptVersion}_${userMode}`; From 1a7d36cec9a07aa8aaa030b53200ab690d7d0566 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Wed, 25 Mar 2026 13:52:03 -0300 Subject: [PATCH 54/54] chore: bump version to 1.2.5-alpha --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a1a3c4b1..75eb1cc1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openscan", - "version": "1.2.4-alpha", + "version": "1.2.5-alpha", "private": true, "type": "module", "packageManager": "bun@1.1.0",
    TX HashBlockMethodFromToValueStatus + TX Hash + {settings.showHelperTooltips !== false && ( + + )} + + Block + {settings.showHelperTooltips !== false && ( + + )} + + Method + {settings.showHelperTooltips !== false && ( + + )} + + From + {settings.showHelperTooltips !== false && ( + + )} + + To + {settings.showHelperTooltips !== false && ( + + )} + + Value + {settings.showHelperTooltips !== false && ( + + )} + + Status + {settings.showHelperTooltips !== false && ( + + )} +