fix(wallet): MetaMask Open Snap allowlist messaging + resolveExplorerApiBase
- Clarify stable MetaMask install allowlist vs open Snap permissions on /wallet - Surface Flask / allowlist-application hint when install errors mention allowlist - Add shared resolveExplorerApiBase helper for catalog URLs Made-with: Cursor
This commit is contained in:
25
frontend/libs/frontend-api-client/api-base.ts
Normal file
25
frontend/libs/frontend-api-client/api-base.ts
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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<unknown>
|
||||
request: (args: { method: string; params?: unknown }) => Promise<unknown>
|
||||
}
|
||||
|
||||
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<NetworksCatalog | null>(null)
|
||||
const [tokenList, setTokenList] = useState<TokenListCatalog | null>(null)
|
||||
const [capabilities, setCapabilities] = useState<CapabilitiesCatalog | null>(null)
|
||||
const [networksMeta, setNetworksMeta] = useState<FetchMetadata | null>(null)
|
||||
const [tokenListMeta, setTokenListMeta] = useState<FetchMetadata | null>(null)
|
||||
const [capabilitiesMeta, setCapabilitiesMeta] = useState<FetchMetadata | null>(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<typeof setTimeout> | 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 (
|
||||
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="space-y-4 rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 sm:p-5">
|
||||
<h2 className="text-lg font-semibold">Add to MetaMask</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
@@ -313,12 +383,38 @@ export function AddToMetaMask() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-primary-200 bg-primary-50/40 p-4 dark:border-primary-900 dark:bg-primary-950/20">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Chain 138 Open Snap</div>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Optional MetaMask Snap that uses{' '}
|
||||
<span className="font-medium text-gray-800 dark:text-gray-200">only open Snap permissions</span> (minimal
|
||||
privileged APIs in the Snap itself).{' '}
|
||||
<span className="font-medium text-gray-800 dark:text-gray-200">Stable MetaMask</span> still only installs npm
|
||||
Snaps that appear on MetaMask's install allowlist; if install fails with "not on the allowlist",
|
||||
use <span className="font-medium text-gray-800 dark:text-gray-200">MetaMask Flask</span> 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{' '}
|
||||
<code className="break-all rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">{CHAIN138_OPEN_SNAP_ID}</code>
|
||||
— publish from the repo with <code className="break-all rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">scripts/deployment/publish-chain138-open-snap.sh</code> after{' '}
|
||||
<code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">npm login</code>.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void installOpenSnap()}
|
||||
className="mt-3 rounded bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
|
||||
>
|
||||
Install Open Snap
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Explorer-served MetaMask metadata</div>
|
||||
<div className="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<p>Networks catalog: {chains.total > 0 ? `${chains.total} chains` : 'using frontend fallback values'}</p>
|
||||
<p>Chain 138 token entries: {tokenCount138}</p>
|
||||
<p>Networks source: {networksMeta?.source || 'unknown'}</p>
|
||||
<p>Token list source: {tokenListMeta?.source || 'unknown'}</p>
|
||||
{metadataKeywordString ? <p>Keywords: {metadataKeywordString}</p> : null}
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
@@ -374,6 +470,12 @@ export function AddToMetaMask() {
|
||||
{capabilities?.rpcUrl || 'using published explorer fallback'}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
Capabilities source:{' '}
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{capabilitiesMeta?.source || 'unknown'}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
HTTP methods: {supportedHTTPMethods.length > 0 ? supportedHTTPMethods.join(', ') : 'metadata unavailable'}
|
||||
</p>
|
||||
@@ -394,6 +496,11 @@ export function AddToMetaMask() {
|
||||
{note}
|
||||
</p>
|
||||
))}
|
||||
{capabilitiesMeta?.lastModified ? (
|
||||
<p className="text-xs">
|
||||
Last modified: {new Date(capabilitiesMeta.lastModified).toLocaleString()}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user