diff --git a/frontend/libs/frontend-api-client/api-base.ts b/frontend/libs/frontend-api-client/api-base.ts new file mode 100644 index 0000000..d6ff0d1 --- /dev/null +++ b/frontend/libs/frontend-api-client/api-base.ts @@ -0,0 +1,25 @@ +const LOCAL_EXPLORER_API_FALLBACK = 'http://localhost:8080' + +function normalizeApiBase(value: string | null | undefined): string { + return (value || '').trim().replace(/\/$/, '') +} + +export function resolveExplorerApiBase(options: { + envValue?: string | null + browserOrigin?: string | null + serverFallback?: string +} = {}): string { + const explicitBase = normalizeApiBase(options.envValue ?? process.env.NEXT_PUBLIC_API_URL ?? '') + if (explicitBase) { + return explicitBase + } + + const browserOrigin = normalizeApiBase( + options.browserOrigin ?? (typeof window !== 'undefined' ? window.location.origin : '') + ) + if (browserOrigin) { + return browserOrigin + } + + return normalizeApiBase(options.serverFallback ?? LOCAL_EXPLORER_API_FALLBACK) +} diff --git a/frontend/src/components/wallet/AddToMetaMask.tsx b/frontend/src/components/wallet/AddToMetaMask.tsx index f81c162..6be823a 100644 --- a/frontend/src/components/wallet/AddToMetaMask.tsx +++ b/frontend/src/components/wallet/AddToMetaMask.tsx @@ -1,6 +1,7 @@ 'use client' import { useEffect, useMemo, useState } from 'react' +import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base' type WalletChain = { chainId: string @@ -76,8 +77,13 @@ type CapabilitiesCatalog = { } } +type FetchMetadata = { + source?: string | null + lastModified?: string | null +} + type EthereumProvider = { - request: (args: { method: string; params?: unknown[] }) => Promise + request: (args: { method: string; params?: unknown }) => Promise } const FALLBACK_CHAIN_138: WalletChain = { @@ -119,11 +125,13 @@ const FALLBACK_ALL_MAINNET: WalletChain = { const FEATURED_TOKEN_SYMBOLS = ['cUSDT', 'cUSDC', 'USDT', 'USDC', 'cXAUC', 'cXAUT'] +/** npm-published Snap using open Snap permissions only; stable MetaMask still requires MetaMask’s install allowlist. */ +const CHAIN138_OPEN_SNAP_ID = 'npm:chain138-open-snap' as const + function getApiBase() { - if (typeof window !== 'undefined') { - return process.env.NEXT_PUBLIC_API_URL || window.location.origin - } - return process.env.NEXT_PUBLIC_API_URL || 'https://explorer.d-bis.org' + return resolveExplorerApiBase({ + serverFallback: 'https://explorer.d-bis.org', + }) } export function AddToMetaMask() { @@ -132,6 +140,9 @@ export function AddToMetaMask() { const [networks, setNetworks] = useState(null) const [tokenList, setTokenList] = useState(null) const [capabilities, setCapabilities] = useState(null) + const [networksMeta, setNetworksMeta] = useState(null) + const [tokenListMeta, setTokenListMeta] = useState(null) + const [capabilitiesMeta, setCapabilitiesMeta] = useState(null) const ethereum = typeof window !== 'undefined' ? (window as unknown as { ethereum?: EthereumProvider }).ethereum @@ -144,37 +155,60 @@ export function AddToMetaMask() { useEffect(() => { let active = true + let timer: ReturnType | null = null + + async function fetchJson(url: string) { + const response = await fetch(url, { + cache: 'no-store', + headers: { + 'Cache-Control': 'no-cache', + }, + }) + const json = response.ok ? await response.json() : null + const meta: FetchMetadata = { + source: response.headers.get('X-Config-Source'), + lastModified: response.headers.get('Last-Modified'), + } + return { json, meta } + } async function loadCatalogs() { try { const [networksResponse, tokenListResponse, capabilitiesResponse] = await Promise.all([ - fetch(networksUrl), - fetch(tokenListUrl), - fetch(capabilitiesUrl), - ]) - - const [networksJson, tokenListJson, capabilitiesJson] = await Promise.all([ - networksResponse.ok ? networksResponse.json() : null, - tokenListResponse.ok ? tokenListResponse.json() : null, - capabilitiesResponse.ok ? capabilitiesResponse.json() : null, + fetchJson(networksUrl), + fetchJson(tokenListUrl), + fetchJson(capabilitiesUrl), ]) if (!active) return - setNetworks(networksJson) - setTokenList(tokenListJson) - setCapabilities(capabilitiesJson) + setNetworks(networksResponse.json) + setTokenList(tokenListResponse.json) + setCapabilities(capabilitiesResponse.json) + setNetworksMeta(networksResponse.meta) + setTokenListMeta(tokenListResponse.meta) + setCapabilitiesMeta(capabilitiesResponse.meta) } catch { if (!active) return setNetworks(null) setTokenList(null) setCapabilities(null) + setNetworksMeta(null) + setTokenListMeta(null) + setCapabilitiesMeta(null) + } finally { + if (active) { + timer = setTimeout(() => { + void loadCatalogs() + }, 60_000) + } } } - loadCatalogs() + void loadCatalogs() return () => { active = false + if (timer) clearTimeout(timer) } }, [capabilitiesUrl, networksUrl, tokenListUrl]) @@ -232,6 +266,40 @@ export function AddToMetaMask() { } } + const installOpenSnap = async () => { + setError(null) + setStatus(null) + + if (!ethereum) { + setError('MetaMask or another Web3 wallet is not installed.') + return + } + + try { + await ethereum.request({ + method: 'wallet_requestSnaps', + params: { [CHAIN138_OPEN_SNAP_ID]: {} }, + }) + setStatus( + `Installed or connected to ${CHAIN138_OPEN_SNAP_ID}. In MetaMask, open Snaps → Chain 138 Open for the home page (token list URL, network info).`, + ) + } catch (e) { + const err = e as { message?: string } + const msg = err.message || '' + const allowlistBlocked = /allowlist/i.test(msg) + if (allowlistBlocked && msg) { + setError( + `${msg} Production MetaMask only installs allowlisted Snaps from npm. Use MetaMask Flask for unrestricted installs during development, or request allowlisting via MetaMask’s Snaps documentation.`, + ) + } else { + setError( + msg || + `Could not install Snap. Enable MetaMask Snaps and ensure ${CHAIN138_OPEN_SNAP_ID} is published on npm.`, + ) + } + } + } + const watchToken = async (token: TokenListToken) => { setError(null) setStatus(null) @@ -281,12 +349,14 @@ export function AddToMetaMask() { const supportedTraceMethods = capabilities?.tracing?.supportedMethods || [] return ( -
+

Add to MetaMask

The wallet tools now read the same explorer-served network catalog and token list that MetaMask can consume. That keeps chain metadata, token metadata, and optional extensions aligned with the live explorer API instead of - relying on stale frontend-only defaults. + relying on stale frontend-only defaults. MetaMask does not run built-in token detection on custom networks such + as Chain 138: add the token list URL below under Settings → Security & privacy → Token lists so tokens and + icons load automatically when you are on this chain.

@@ -313,12 +383,38 @@ export function AddToMetaMask() {
+
+
Chain 138 Open Snap
+

+ Optional MetaMask Snap that uses{' '} + only open Snap permissions (minimal + privileged APIs in the Snap itself).{' '} + Stable MetaMask still only installs npm + Snaps that appear on MetaMask's install allowlist; if install fails with "not on the allowlist", + use MetaMask Flask for development or apply + for allowlisting. It adds in-wallet weekly reminders, Chain 138 transaction/signature hints, and the token list + URL on the Snap home page. The package on npm is{' '} + {CHAIN138_OPEN_SNAP_ID} + — publish from the repo with scripts/deployment/publish-chain138-open-snap.sh after{' '} + npm login. +

+ +
+
Explorer-served MetaMask metadata

Networks catalog: {chains.total > 0 ? `${chains.total} chains` : 'using frontend fallback values'}

Chain 138 token entries: {tokenCount138}

+

Networks source: {networksMeta?.source || 'unknown'}

+

Token list source: {tokenListMeta?.source || 'unknown'}

{metadataKeywordString ?

Keywords: {metadataKeywordString}

: null}
@@ -374,6 +470,12 @@ export function AddToMetaMask() { {capabilities?.rpcUrl || 'using published explorer fallback'}

+

+ Capabilities source:{' '} + + {capabilitiesMeta?.source || 'unknown'} + +

HTTP methods: {supportedHTTPMethods.length > 0 ? supportedHTTPMethods.join(', ') : 'metadata unavailable'}

@@ -394,6 +496,11 @@ export function AddToMetaMask() { {note}

))} + {capabilitiesMeta?.lastModified ? ( +

+ Last modified: {new Date(capabilitiesMeta.lastModified).toLocaleString()} +

+ ) : null}