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:
defiQUG
2026-04-05 01:23:25 -07:00
parent 3bca5394fc
commit 4044fb07e1
2 changed files with 152 additions and 20 deletions

View 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)
}

View File

@@ -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 MetaMasks 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 MetaMasks 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&apos;s install allowlist; if install fails with &quot;not on the allowlist&quot;,
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>