diff --git a/cspell.json b/cspell.json
index 0d984fa15b20..b0c44f24f3af 100644
--- a/cspell.json
+++ b/cspell.json
@@ -76,7 +76,9 @@
"dompurify",
"dotbit",
"dsearch",
+ "efprpc",
"enhanceable",
+ "ethfollow",
"evmos",
"farcaster",
"favourites",
diff --git a/packages/icons/icon-generated-as-jsx.js b/packages/icons/icon-generated-as-jsx.js
index ad0d0e08f086..4a144057cdd2 100644
--- a/packages/icons/icon-generated-as-jsx.js
+++ b/packages/icons/icon-generated-as-jsx.js
@@ -4196,6 +4196,11 @@ export const DecentralizedSearch = /*#__PURE__*/ __createIcon('DecentralizedSear
u: () => new URL('./plugins/DecentralizedSearch.svg', import.meta.url).href,
},
])
+export const EFP = /*#__PURE__*/ __createIcon('EFP', [
+ {
+ u: () => new URL('./plugins/EFP.svg', import.meta.url).href,
+ },
+])
export const ENS = /*#__PURE__*/ __createIcon('ENS', [
{
u: () => new URL('./plugins/ENS.png', import.meta.url).href,
diff --git a/packages/icons/icon-generated-as-url.js b/packages/icons/icon-generated-as-url.js
index babae9c49108..0b4ffd35c81f 100644
--- a/packages/icons/icon-generated-as-url.js
+++ b/packages/icons/icon-generated-as-url.js
@@ -374,6 +374,7 @@ export function cross_bridge_url() { return new URL("./plugins/CrossBridge.png",
export function cyber_connect_dark_url() { return new URL("./plugins/CyberConnect.dark.svg", import.meta.url).href }
export function cyber_connect_light_url() { return new URL("./plugins/CyberConnect.light.svg", import.meta.url).href }
export function decentralized_search_url() { return new URL("./plugins/DecentralizedSearch.svg", import.meta.url).href }
+export function efp_url() { return new URL("./plugins/EFP.svg", import.meta.url).href }
export function ens_url() { return new URL("./plugins/ENS.png", import.meta.url).href }
export function ens_cover_url() { return new URL("./plugins/ENSCover.svg", import.meta.url).href }
export function file_service_url() { return new URL("./plugins/FileService.svg", import.meta.url).href }
diff --git a/packages/icons/plugins/EFP.svg b/packages/icons/plugins/EFP.svg
new file mode 100644
index 000000000000..33fb22532c5f
--- /dev/null
+++ b/packages/icons/plugins/EFP.svg
@@ -0,0 +1,11 @@
+
diff --git a/packages/mask/package.json b/packages/mask/package.json
index dcfe23ed59c3..b305a6cf1854 100644
--- a/packages/mask/package.json
+++ b/packages/mask/package.json
@@ -48,6 +48,7 @@
"@masknet/plugin-collectible": "workspace:^",
"@masknet/plugin-cross-chain-bridge": "workspace:^",
"@masknet/plugin-debugger": "workspace:^",
+ "@masknet/plugin-efp": "workspace:^",
"@masknet/plugin-file-service": "workspace:^",
"@masknet/plugin-gitcoin": "workspace:^",
"@masknet/plugin-go-plus-security": "workspace:^",
diff --git a/packages/mask/shared/plugin-infra/register.js b/packages/mask/shared/plugin-infra/register.js
index 4124911dfd1c..c1169381ab5a 100644
--- a/packages/mask/shared/plugin-infra/register.js
+++ b/packages/mask/shared/plugin-infra/register.js
@@ -7,6 +7,7 @@ import '@masknet/plugin-go-plus-security/register'
import '@masknet/plugin-cross-chain-bridge/register'
import '@masknet/plugin-web3-profile/register'
import '@masknet/plugin-handle/register'
+import '@masknet/plugin-efp/register'
import '@masknet/plugin-approval/register'
import '@masknet/plugin-gitcoin/register'
import '@masknet/plugin-scam-warning/register'
diff --git a/packages/plugins/EFP/README.md b/packages/plugins/EFP/README.md
new file mode 100644
index 000000000000..45bfa183bd26
--- /dev/null
+++ b/packages/plugins/EFP/README.md
@@ -0,0 +1,15 @@
+# Ethereum Follow Protocol plugin
+
+## Referenced resources
+
+- https://efp.app
+- https://docs.efp.app
+- https://data.ethfollow.xyz/api/v1
+- https://github.com/ethereumfollowprotocol/app
+- https://github.com/ethereumfollowprotocol/api-v2
+
+## Known issues / Caveats
+
+- The card intentionally accepts only direct one-segment profile/list URLs with an optional `topEight=true` query.
+- Reserved EFP app routes such as `/api`, `/og`, `/assets`, `/leaderboard`, `/integrations`, `/team`, and `/swipe` are ignored.
+- The embed uses EFP-generated preview images instead of arbitrary ENS avatar or header records to keep CSP changes narrow.
diff --git a/packages/plugins/EFP/package.json b/packages/plugins/EFP/package.json
new file mode 100644
index 000000000000..ec4d05eaeaf0
--- /dev/null
+++ b/packages/plugins/EFP/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@masknet/plugin-efp",
+ "private": true,
+ "sideEffects": [
+ "./src/register.ts"
+ ],
+ "type": "module",
+ "exports": {
+ ".": {
+ "mask-src": "./src/index.ts",
+ "default": "./dist/index.js"
+ },
+ "./register": {
+ "mask-src": "./src/register.ts",
+ "default": "./dist/register.js"
+ }
+ },
+ "dependencies": {
+ "@masknet/icons": "workspace:^",
+ "@masknet/plugin-infra": "workspace:^",
+ "@masknet/shared-base": "workspace:^",
+ "@masknet/theme": "workspace:^",
+ "@masknet/typed-message": "workspace:^"
+ }
+}
diff --git a/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx b/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx
new file mode 100644
index 000000000000..b8cb9fd4078b
--- /dev/null
+++ b/packages/plugins/EFP/src/SiteAdaptor/ProfileCard.tsx
@@ -0,0 +1,232 @@
+import { Icons } from '@masknet/icons'
+import { Trans } from '@lingui/react/macro'
+import { makeStyles } from '@masknet/theme'
+import { Box, Link, Stack, Typography } from '@mui/material'
+import { useQuery } from '@tanstack/react-query'
+import { useMemo, useState, type ReactNode } from 'react'
+import { EFP_FALLBACK_IMAGE_URL } from '../constants.js'
+import type { EFPProfileLink } from '../helpers/url.js'
+import { PluginEFPRPC } from '../messages.js'
+import type { EFPProfileResponse } from '../Worker/apis/index.js'
+
+interface ProfileCardProps {
+ profileLink: EFPProfileLink
+}
+
+const formatter = new Intl.NumberFormat('en', {
+ notation: 'compact',
+ maximumFractionDigits: 1,
+})
+
+const useStyles = makeStyles()((theme) => ({
+ root: {
+ padding: theme.spacing(1.5),
+ paddingTop: 0,
+ },
+ card: {
+ overflow: 'hidden',
+ borderRadius: 8,
+ border: `1px solid ${theme.palette.maskColor.line}`,
+ color: theme.palette.maskColor.main,
+ background: theme.palette.maskColor.bottom,
+ },
+ image: {
+ display: 'block',
+ width: '100%',
+ aspectRatio: '1.91 / 1',
+ objectFit: 'cover',
+ background: theme.palette.maskColor.bg,
+ },
+ imageFallback: {
+ display: 'flex',
+ width: '100%',
+ aspectRatio: '1.91 / 1',
+ alignItems: 'center',
+ justifyContent: 'center',
+ background: 'linear-gradient(135deg, #f1f3fe 0%, #dff2fb 45%, #ecfffd 100%)',
+ color: '#333333',
+ },
+ body: {
+ padding: theme.spacing(1.5),
+ gap: theme.spacing(1),
+ },
+ eyebrow: {
+ color: theme.palette.maskColor.second,
+ fontWeight: 700,
+ lineHeight: 1,
+ },
+ title: {
+ fontWeight: 700,
+ wordBreak: 'break-word',
+ lineHeight: 1.25,
+ },
+ description: {
+ color: theme.palette.maskColor.second,
+ display: '-webkit-box',
+ overflow: 'hidden',
+ WebkitBoxOrient: 'vertical',
+ WebkitLineClamp: 2,
+ },
+ metrics: {
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: theme.spacing(1),
+ },
+ metric: {
+ minWidth: 86,
+ borderRadius: 8,
+ padding: theme.spacing(0.75, 1),
+ background: theme.palette.maskColor.bg,
+ },
+ metricValue: {
+ fontWeight: 700,
+ lineHeight: 1.2,
+ },
+ metricLabel: {
+ color: theme.palette.maskColor.second,
+ lineHeight: 1.2,
+ },
+ footer: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: theme.spacing(1),
+ },
+ link: {
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: theme.spacing(0.5),
+ fontWeight: 700,
+ textDecoration: 'none',
+ },
+}))
+
+export function ProfileCard({ profileLink }: ProfileCardProps) {
+ const { classes } = useStyles()
+ const { data, isPending: loading } = useQuery({
+ queryKey: ['efp', 'profile', profileLink.apiPath],
+ queryFn: () => PluginEFPRPC.fetchEFPProfile(profileLink.apiPath),
+ select: (raw) => (isProfileResponse(raw) ? raw : null),
+ })
+ const displayName = useMemo(() => getDisplayName(profileLink, data), [profileLink, data])
+ const description = data?.ens?.records?.description
+ const primaryList = data?.primary_list
+
+ return (
+
+
+
+
+
+ {profileLink.topEight ?
+ EFP Top 8
+ : Ethereum Follow Protocol}
+
+
+ {displayName}
+
+ {description ?
+
+ {description}
+
+ : null}
+
+ Followers}
+ value={loading ? '--' : formatCount(data?.followers_count)}
+ />
+ Following}
+ value={loading ? '--' : formatCount(data?.following_count)}
+ />
+ {primaryList ?
+ Primary List} value={`#${primaryList}`} />
+ : profileLink.type === 'list' ?
+ List} value={`#${profileLink.user}`} />
+ : null}
+
+
+
+ {profileLink.type === 'list' ?
+ EFP list
+ : EFP profile}
+
+
+ View on EFP
+
+
+
+
+
+
+ )
+}
+
+function ProfileImage({ profileLink }: ProfileCardProps) {
+ const { classes } = useStyles()
+ const [imageUrl, setImageUrl] = useState(profileLink.imageUrl)
+ const [failed, setFailed] = useState(false)
+
+ if (failed) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
{
+ if (imageUrl === EFP_FALLBACK_IMAGE_URL) {
+ setFailed(true)
+ return
+ }
+ setImageUrl(EFP_FALLBACK_IMAGE_URL)
+ }}
+ />
+ )
+}
+
+function Metric({ label, value }: { label: ReactNode; value: string }) {
+ const { classes } = useStyles()
+ return (
+
+
+ {value}
+
+
+ {label}
+
+
+ )
+}
+
+function isProfileResponse(value: EFPProfileResponse | null): value is EFPProfileResponse {
+ return !!value && (typeof value.address === 'string' || typeof value.primary_list === 'string')
+}
+
+function getDisplayName(profileLink: EFPProfileLink, data: EFPProfileResponse | null | undefined) {
+ const ensName = data?.ens?.name
+ if (ensName) return ensName
+ if (profileLink.type === 'list') return `List #${profileLink.user}`
+ return truncateAddress(profileLink.user)
+}
+
+function truncateAddress(value: string) {
+ if (!/^0x[\dA-Fa-f]{40}$/u.test(value)) return value
+ return `${value.slice(0, 6)}...${value.slice(-4)}`
+}
+
+function formatCount(value: string | number | undefined) {
+ const count = Number(value)
+ if (!Number.isFinite(count)) return '--'
+ return formatter.format(count)
+}
diff --git a/packages/plugins/EFP/src/SiteAdaptor/index.tsx b/packages/plugins/EFP/src/SiteAdaptor/index.tsx
new file mode 100644
index 000000000000..a792dd43f3ce
--- /dev/null
+++ b/packages/plugins/EFP/src/SiteAdaptor/index.tsx
@@ -0,0 +1,157 @@
+import { Icons } from '@masknet/icons'
+import { Trans } from '@lingui/react/macro'
+import {
+ type Plugin,
+ PostInfoContext,
+ usePluginWrapper,
+ usePostInfoDetails,
+} from '@masknet/plugin-infra/content-script'
+import { parseURLs } from '@masknet/shared-base'
+import { extractTextFromTypedMessage } from '@masknet/typed-message'
+import { useContext, useEffect, useMemo, type JSX } from 'react'
+import { base } from '../base.js'
+import { PLUGIN_NAME } from '../constants.js'
+import { parseEFPProfileLink, type EFPProfileLink } from '../helpers/url.js'
+import { ProfileCard } from './ProfileCard.js'
+
+function Renderer({ profileLink }: { profileLink: EFPProfileLink }) {
+ // Read rootNode/isFocusing through the context directly. The usePostInfoDetails proxy
+ // also works, but trips react-compiler's hook-as-value rule at the call site for fields
+ // (like rootNode) that the proxy returns as plain values rather than via a real hook.
+ const postInfo = useContext(PostInfoContext)
+ const rootNode = postInfo?.rootNode ?? null
+ const isFocusing = postInfo?.isFocusing ?? false
+ usePluginWrapper(true, { name: PLUGIN_NAME })
+ useHideNativeTwitterCard(rootNode, isFocusing)
+
+ return
+}
+
+function useHideNativeTwitterCard(rootNode: HTMLElement | null, isFocusing: boolean) {
+ useEffect(() => {
+ if (!rootNode) return
+
+ const article = rootNode.closest('article')
+ // Timeline: scope to the article so we don't hide cards in sibling tweets whose Twitter
+ // preview happens to mention efp.app/ethfollow.xyz in its title or description. Detail
+ // view: the post's card can live in a sibling subtree of the article (per Twitter's
+ // postsContentSelector), so widen one level.
+ const searchRoot = isFocusing ? article?.parentElement : article
+ if (!searchRoot) return
+
+ // Track every element we modify so the cleanup can restore the Twitter card if the plugin
+ // unmounts (navigation, post leaves the viewport, plugin disabled). Without this, hidden
+ // cards stay hidden forever even after the React tree is gone.
+ const modified = new Map()
+
+ const hide = () => {
+ for (const card of searchRoot.querySelectorAll('[data-testid="card.wrapper"]')) {
+ if (!isEFPCard(card)) continue
+ const container = getCardContainer(card)
+ // For link-only tweets the rootNode IS the card.wrapper, and our React tree mounts
+ // in rootElement.afterShadow — a sibling of the card. Hiding the card itself is
+ // fine, but we must not hide any ancestor of rootNode or we'd take our own
+ // injection down with it.
+ const target = container.contains(rootNode) ? card : container
+ if (modified.has(target)) continue
+ modified.set(target, {
+ display: target.style.display,
+ ariaHidden: target.getAttribute('aria-hidden'),
+ })
+ target.style.display = 'none'
+ target.setAttribute('aria-hidden', 'true')
+ }
+ }
+
+ hide()
+ const observer = new MutationObserver(hide)
+ observer.observe(searchRoot, { childList: true, subtree: true })
+ return () => {
+ observer.disconnect()
+ for (const [target, prev] of modified) {
+ target.style.display = prev.display
+ if (prev.ariaHidden === null) target.removeAttribute('aria-hidden')
+ else target.setAttribute('aria-hidden', prev.ariaHidden)
+ }
+ modified.clear()
+ }
+ }, [rootNode, isFocusing])
+}
+
+// Twitter renders the visible card as a parent that's `aria-labelledby` the card.wrapper id and
+// also holds the "From " footer as a sibling of the wrapper. Hide the parent so the footer
+// goes away with the card; fall back to the wrapper when the structure doesn't match.
+function getCardContainer(card: HTMLElement): HTMLElement {
+ const parent = card.parentElement
+ if (!parent || !card.id) return card
+ const labelledBy = parent.getAttribute('aria-labelledby')
+ if (labelledBy?.split(/\s+/u).includes(card.id)) return parent
+ return card
+}
+
+// Twitter wraps external links in t.co redirects, so the anchor href is usually opaque. The
+// real EFP URL surfaces as the anchor's visible text or as its aria-label. Run all three
+// through parseEFPProfileLink so we only match valid EFP profile/list URLs — substring checks
+// would false-positive on cards whose description merely mentions efp.app, or on hostnames
+// that happen to contain it as a substring.
+function isEFPCard(card: HTMLElement) {
+ for (const anchor of card.querySelectorAll('a[href]')) {
+ if (parseEFPProfileLink(anchor.href)) return true
+ const text = anchor.textContent?.trim()
+ if (text && parseEFPProfileLink(text)) return true
+ const label = anchor.getAttribute('aria-label')?.trim()
+ if (label && parseEFPProfileLink(label)) return true
+ }
+ return false
+}
+
+const site: Plugin.SiteAdaptor.Definition = {
+ ...base,
+ DecryptedInspector(props): JSX.Element | null {
+ const profileLink = useMemo(() => {
+ const text = extractTextFromTypedMessage(props.message)
+ if (text.isNone()) return null
+ for (const url of parseURLs(text.value, false)) {
+ const link = parseEFPProfileLink(url)
+ if (link) return link
+ }
+ return null
+ }, [props.message])
+
+ if (!profileLink) return null
+ return
+ },
+ PostInspector(): JSX.Element | null {
+ const message = usePostInfoDetails.rawMessage()
+ const profileLink = useMemo(() => {
+ const text = extractTextFromTypedMessage(message)
+ if (text.isNone()) return null
+ for (const url of parseURLs(text.value, false)) {
+ const link = parseEFPProfileLink(url)
+ if (link) return link
+ }
+ return null
+ }, [message])
+
+ if (!profileLink) return null
+ return
+ },
+ ApplicationEntries: [
+ {
+ ApplicationEntryID: base.ID,
+ category: 'dapp',
+ marketListSortingPriority: 18,
+ description: A native Ethereum protocol for following and tagging Ethereum accounts.,
+ name: Ethereum Follow Protocol,
+ icon: ,
+ tutorialLink: 'https://docs.efp.app/intro',
+ },
+ ],
+ wrapperProps: {
+ icon: ,
+ backgroundGradient:
+ 'linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.8) 100%), linear-gradient(90deg, rgba(255, 224, 103, 0.2) 0%, rgba(211, 234, 244, 0.2) 100%), #FFFFFF',
+ },
+}
+
+export default site
diff --git a/packages/plugins/EFP/src/Worker/apis/index.ts b/packages/plugins/EFP/src/Worker/apis/index.ts
new file mode 100644
index 000000000000..55766fcb18d1
--- /dev/null
+++ b/packages/plugins/EFP/src/Worker/apis/index.ts
@@ -0,0 +1,25 @@
+import { EFP_API_URL } from '../../constants.js'
+
+export interface EFPProfileResponse {
+ address?: string
+ ens?: {
+ name?: string | null
+ records?: Record | null
+ } | null
+ followers_count?: number | string
+ following_count?: number | string
+ primary_list?: string | null
+}
+
+export async function fetchEFPProfile(apiPath: string): Promise {
+ const url = `${EFP_API_URL}${apiPath}`
+ const response = await fetch(url, {
+ headers: {
+ Accept: 'application/json',
+ },
+ })
+ if (!response.ok) {
+ throw new Error(`Failed to fetch EFP profile from ${apiPath} (status: ${response.status})`)
+ }
+ return response.json() as Promise
+}
diff --git a/packages/plugins/EFP/src/Worker/index.ts b/packages/plugins/EFP/src/Worker/index.ts
new file mode 100644
index 000000000000..2c2b03ab7fd5
--- /dev/null
+++ b/packages/plugins/EFP/src/Worker/index.ts
@@ -0,0 +1,10 @@
+import type { Plugin } from '@masknet/plugin-infra'
+import { base } from '../base.js'
+
+const worker: Plugin.Worker.Definition = {
+ ...base,
+ init(signal, context) {
+ context.startService(import('./apis/index.js'))
+ },
+}
+export default worker
diff --git a/packages/plugins/EFP/src/base.ts b/packages/plugins/EFP/src/base.ts
new file mode 100644
index 000000000000..4f9a47cdab0b
--- /dev/null
+++ b/packages/plugins/EFP/src/base.ts
@@ -0,0 +1,22 @@
+import type { Plugin } from '@masknet/plugin-infra'
+import { DEFAULT_PLUGIN_PUBLISHER, EnhanceableSite } from '@masknet/shared-base'
+import { EFP_PROFILE_URL_PATTERN, PLUGIN_DESCRIPTION, PLUGIN_ID, PLUGIN_NAME } from './constants.js'
+
+export const base: Plugin.Shared.Definition = {
+ ID: PLUGIN_ID,
+ name: { fallback: PLUGIN_NAME },
+ description: { fallback: PLUGIN_DESCRIPTION },
+ publisher: DEFAULT_PLUGIN_PUBLISHER,
+ enableRequirement: {
+ supports: {
+ type: 'opt-out',
+ sites: {
+ [EnhanceableSite.Localhost]: true,
+ },
+ },
+ target: 'stable',
+ },
+ contribution: {
+ postContent: new Set([EFP_PROFILE_URL_PATTERN]),
+ },
+}
diff --git a/packages/plugins/EFP/src/constants.ts b/packages/plugins/EFP/src/constants.ts
new file mode 100644
index 000000000000..dca95fc7f084
--- /dev/null
+++ b/packages/plugins/EFP/src/constants.ts
@@ -0,0 +1,20 @@
+import { PluginID } from '@masknet/shared-base'
+
+export const PLUGIN_ID = PluginID.EFP
+export const PLUGIN_NAME = 'Ethereum Follow Protocol'
+export const PLUGIN_DESCRIPTION = 'A native Ethereum protocol for following and tagging Ethereum accounts.'
+export const EFP_APP_URL = 'https://efp.app'
+export const EFP_API_URL = 'https://data.ethfollow.xyz/api/v1'
+export const EFP_FALLBACK_IMAGE_URL = `${EFP_APP_URL}/assets/art/default-header.svg`
+
+export const EFP_HOSTS = ['efp.app', 'www.efp.app', 'ethfollow.xyz', 'www.ethfollow.xyz'] as const
+export const RESERVED_EFP_PATHS = ['api', 'og', 'assets', 'leaderboard', 'integrations', 'team', 'swipe'] as const
+
+const ENS_LABEL_PATTERN = '[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?'
+const EFP_USER_PATTERN = `(?:0x[\\dA-Fa-f]{40}|[1-9]\\d*|(?:${ENS_LABEL_PATTERN}\\.)+${ENS_LABEL_PATTERN})`
+const RESERVED_ROUTE_PATTERN = RESERVED_EFP_PATHS.map((path) => `${path}(?:[/?#]|$)`).join('|')
+
+export const EFP_PROFILE_URL_PATTERN = new RegExp(
+ `^(?:https:\\/\\/)?(?:www\\.)?(?:ethfollow\\.xyz|efp\\.app)\\/(?!${RESERVED_ROUTE_PATTERN})${EFP_USER_PATTERN}(?:\\?topEight=true)?$`,
+ 'u',
+)
diff --git a/packages/plugins/EFP/src/env.d.ts b/packages/plugins/EFP/src/env.d.ts
new file mode 100644
index 000000000000..868322d5ff30
--- /dev/null
+++ b/packages/plugins/EFP/src/env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/packages/plugins/EFP/src/helpers/url.ts b/packages/plugins/EFP/src/helpers/url.ts
new file mode 100644
index 000000000000..a765a577e17a
--- /dev/null
+++ b/packages/plugins/EFP/src/helpers/url.ts
@@ -0,0 +1,78 @@
+import { EFP_APP_URL, EFP_HOSTS, RESERVED_EFP_PATHS } from '../constants.js'
+
+const EFP_HOST_SET: ReadonlySet = new Set(EFP_HOSTS)
+const RESERVED_PATH_SET: ReadonlySet = new Set(RESERVED_EFP_PATHS)
+const ADDRESS_PATTERN = /^0x[\dA-Fa-f]{40}$/u
+const LIST_PATTERN = /^[1-9]\d*$/u
+const ENS_LABEL_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/u
+
+export interface EFPProfileLink {
+ user: string
+ type: 'list' | 'user'
+ topEight: boolean
+ profileUrl: string
+ imageUrl: string
+ apiPath: string
+}
+
+export function parseEFPProfileLink(link: string): EFPProfileLink | null {
+ const url = parseURL(link)
+ if (!url) return null
+ if (url.protocol !== 'https:') return null
+ if (!EFP_HOST_SET.has(url.hostname)) return null
+ if (url.hash) return null
+
+ const segments = url.pathname.split('/').filter(Boolean)
+ if (segments.length !== 1) return null
+
+ const user = safeDecodeURIComponent(segments[0])
+ if (!user) return null
+ if (RESERVED_PATH_SET.has(user.toLowerCase())) return null
+ if (!isSupportedUser(user)) return null
+
+ const searchParams = Array.from(url.searchParams.entries())
+ const topEight = searchParams.length === 1 && searchParams[0][0] === 'topEight' && searchParams[0][1] === 'true'
+ if (url.search && !topEight) return null
+
+ const type = LIST_PATTERN.test(user) ? 'list' : 'user'
+ const encodedUser = encodeURIComponent(user)
+ const query = topEight ? '?topEight=true' : ''
+
+ return {
+ user,
+ type,
+ topEight,
+ profileUrl: `${EFP_APP_URL}/${encodedUser}${query}`,
+ imageUrl:
+ topEight ? `${EFP_APP_URL}/api/top-eight?user=${encodedUser}` : `${EFP_APP_URL}/og?user=${encodedUser}`,
+ apiPath: `/${type === 'list' ? 'lists' : 'users'}/${encodedUser}/details`,
+ }
+}
+
+export function isEFPProfileLink(link: string): boolean {
+ return parseEFPProfileLink(link) !== null
+}
+
+function parseURL(link: string) {
+ try {
+ return new URL(/^https?:\/\//u.test(link) ? link : `https://${link}`)
+ } catch {
+ return null
+ }
+}
+
+function safeDecodeURIComponent(value: string) {
+ try {
+ return decodeURIComponent(value)
+ } catch {
+ return ''
+ }
+}
+
+function isSupportedUser(user: string) {
+ if (ADDRESS_PATTERN.test(user)) return true
+ if (LIST_PATTERN.test(user)) return true
+ const labels = user.split('.')
+ if (labels.length < 2) return false
+ return labels.every((label) => ENS_LABEL_PATTERN.test(label))
+}
diff --git a/packages/plugins/EFP/src/index.ts b/packages/plugins/EFP/src/index.ts
new file mode 100644
index 000000000000..5ba94dda1450
--- /dev/null
+++ b/packages/plugins/EFP/src/index.ts
@@ -0,0 +1,2 @@
+export * from './constants.js'
+export * from './helpers/url.js'
diff --git a/packages/plugins/EFP/src/messages.ts b/packages/plugins/EFP/src/messages.ts
new file mode 100644
index 000000000000..f76bc272a9a3
--- /dev/null
+++ b/packages/plugins/EFP/src/messages.ts
@@ -0,0 +1,5 @@
+import { getPluginRPC } from '@masknet/plugin-infra'
+import { PLUGIN_ID } from './constants.js'
+
+import.meta.webpackHot?.accept()
+export const PluginEFPRPC = getPluginRPC(PLUGIN_ID)
diff --git a/packages/plugins/EFP/src/register.ts b/packages/plugins/EFP/src/register.ts
new file mode 100644
index 000000000000..aa1f8a4cd9d5
--- /dev/null
+++ b/packages/plugins/EFP/src/register.ts
@@ -0,0 +1,15 @@
+import { registerPlugin } from '@masknet/plugin-infra'
+import { base } from './base.js'
+
+registerPlugin({
+ ...base,
+ SiteAdaptor: {
+ load: () => import('./SiteAdaptor/index.js'),
+ hotModuleReload: (hot) =>
+ import.meta.webpackHot?.accept('./SiteAdaptor', () => hot(import('./SiteAdaptor/index.js'))),
+ },
+ Worker: {
+ load: () => import('./Worker/index.js'),
+ hotModuleReload: (hot) => import.meta.webpackHot?.accept('./Worker', () => hot(import('./Worker/index.js'))),
+ },
+})
diff --git a/packages/plugins/EFP/src/tests/url.ts b/packages/plugins/EFP/src/tests/url.ts
new file mode 100644
index 000000000000..ba3b9db6cb71
--- /dev/null
+++ b/packages/plugins/EFP/src/tests/url.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest'
+import { EFP_PROFILE_URL_PATTERN } from '../constants.js'
+import { parseEFPProfileLink } from '../helpers/url.js'
+
+describe('EFP profile links', () => {
+ it.each([
+ ['ethfollow.xyz/vitalik.eth', { user: 'vitalik.eth', type: 'user', topEight: false }],
+ [
+ 'https://efp.app/0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
+ { user: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', type: 'user', topEight: false },
+ ],
+ ['https://ethfollow.xyz/6509', { user: '6509', type: 'list', topEight: false }],
+ ['https://efp.app/vitalik.eth?topEight=true', { user: 'vitalik.eth', type: 'user', topEight: true }],
+ ] as const)('parses %s', (link, expected) => {
+ const result = parseEFPProfileLink(link)
+
+ expect(result).toMatchObject(expected)
+ expect(result?.profileUrl).toContain(`/${expected.user}`)
+ })
+
+ it.each([
+ 'https://ethfollow.xyz/api',
+ 'https://efp.app/og?user=vitalik.eth',
+ 'https://efp.app/assets/logo.svg',
+ 'https://efp.app/leaderboard',
+ 'https://efp.app/integrations',
+ 'https://efp.app/team',
+ 'https://efp.app/swipe',
+ 'https://ethfollow.xyz/vitalik.eth/followers',
+ 'https://example.com/vitalik.eth',
+ 'https://efp.app/not a valid name',
+ 'https://efp.app/not..valid.eth',
+ 'https://efp.app/vitalik.eth?topEight=false',
+ 'https://efp.app/vitalik.eth?foo=bar',
+ 'https://efp.app/vitalik.eth#profile',
+ 'http://efp.app/vitalik.eth',
+ ])('rejects %s', (link) => {
+ expect(parseEFPProfileLink(link)).toBeNull()
+ })
+
+ it.each([
+ 'https://ethfollow.xyz/vitalik.eth',
+ 'https://efp.app/0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
+ 'https://ethfollow.xyz/6509',
+ 'https://efp.app/vitalik.eth?topEight=true',
+ ])('contribution pattern matches %s', (link) => {
+ expect(EFP_PROFILE_URL_PATTERN.test(link)).toBe(true)
+ })
+
+ it.each([
+ 'https://efp.app/api',
+ 'https://efp.app/og?user=vitalik.eth',
+ 'https://efp.app/vitalik.eth/followers',
+ 'https://efp.app/not a valid name',
+ 'https://efp.app/not..valid.eth',
+ 'https://efp.app/vitalik.eth?topEight=false',
+ 'https://efp.app/vitalik.eth#profile',
+ ])('contribution pattern rejects %s', (link) => {
+ expect(EFP_PROFILE_URL_PATTERN.test(link)).toBe(false)
+ })
+})
diff --git a/packages/plugins/EFP/tsconfig.json b/packages/plugins/EFP/tsconfig.json
new file mode 100644
index 000000000000..909554b29dc5
--- /dev/null
+++ b/packages/plugins/EFP/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "tsBuildInfoFile": "dist/.tsbuildinfo"
+ },
+ "include": ["src", "src/**/*.json"],
+ "references": [
+ { "path": "../../plugin-infra/tsconfig.json" },
+ { "path": "../../shared-base/tsconfig.json" },
+ { "path": "../../theme/tsconfig.json" },
+ { "path": "../../typed-message/base/tsconfig.json" }
+ ]
+}
diff --git a/packages/plugins/tsconfig.json b/packages/plugins/tsconfig.json
index 66e0be2b565f..20c78b1a2d95 100644
--- a/packages/plugins/tsconfig.json
+++ b/packages/plugins/tsconfig.json
@@ -10,6 +10,7 @@
{ "path": "./CrossChainBridge/tsconfig.json" },
{ "path": "./CyberConnect/tsconfig.json" },
{ "path": "./Debugger/tsconfig.json" },
+ { "path": "./EFP/tsconfig.json" },
{ "path": "./FileService/tsconfig.json" },
{ "path": "./Gitcoin/tsconfig.json" },
{ "path": "./GoPlusSecurity/tsconfig.json" },
diff --git a/packages/shared-base/src/types/PluginID.ts b/packages/shared-base/src/types/PluginID.ts
index 208592b5b478..27688951522c 100644
--- a/packages/shared-base/src/types/PluginID.ts
+++ b/packages/shared-base/src/types/PluginID.ts
@@ -31,6 +31,7 @@ export enum PluginID {
Wallet = 'com.maskbook.wallet',
FileService = 'com.maskbook.fileservice',
CyberConnect = 'me.cyberconnect.app',
+ EFP = 'xyz.ethfollow',
GoPlusSecurity = 'io.gopluslabs.security',
CrossChainBridge = 'io.mask.cross-chain-bridge',
Web3Profile = 'io.mask.web3-profile',
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 86e8a1c85b3f..c83dcf2b74d9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -422,6 +422,9 @@ importers:
'@masknet/plugin-debugger':
specifier: workspace:^
version: link:../plugins/Debugger
+ '@masknet/plugin-efp':
+ specifier: workspace:^
+ version: link:../plugins/EFP
'@masknet/plugin-file-service':
specifier: workspace:^
version: link:../plugins/FileService
@@ -1231,6 +1234,24 @@ importers:
specifier: workspace:^
version: link:../../web3-telemetry
+ packages/plugins/EFP:
+ dependencies:
+ '@masknet/icons':
+ specifier: workspace:^
+ version: link:../../icons
+ '@masknet/plugin-infra':
+ specifier: workspace:^
+ version: link:../../plugin-infra
+ '@masknet/shared-base':
+ specifier: workspace:^
+ version: link:../../shared-base
+ '@masknet/theme':
+ specifier: workspace:^
+ version: link:../../theme
+ '@masknet/typed-message':
+ specifier: workspace:^
+ version: link:../../typed-message/base
+
packages/plugins/FileService:
dependencies:
'@dimensiondev/common-protocols':
diff --git a/security/content-security-policy.json b/security/content-security-policy.json
index b73645506589..307820632bef 100644
--- a/security/content-security-policy.json
+++ b/security/content-security-policy.json
@@ -83,6 +83,7 @@
"https://prod-api.kosetto.com",
"https://grants-stack-indexer-v2.gitcoin.co/",
+ "https://data.ethfollow.xyz",
"https://t.co",
@@ -104,6 +105,7 @@
"https://imagedelivery.net",
"https://bridge.metis.io",
"https://static.okx.com",
+ "https://efp.app",
"https://dzyb4dm7r8k8w.cloudfront.net",
"https://purecatamphetamine.github.io/country-flag-icons/"
],