diff --git a/cspell.json b/cspell.json
index d49cdd918982..b0bac3b05249 100644
--- a/cspell.json
+++ b/cspell.json
@@ -117,6 +117,7 @@
"juicebox",
"kdfparams",
"keccak",
+ "keybase",
"kitts",
"kred",
"labelledby",
diff --git a/packages/mask/background/database/persona/app.ts b/packages/mask/background/database/persona/app.ts
index f3b8f60a8c47..907c065f112e 100644
--- a/packages/mask/background/database/persona/app.ts
+++ b/packages/mask/background/database/persona/app.ts
@@ -29,6 +29,7 @@ import type {
AESJsonWebKey as Native_AESJsonWebKey,
} from '@masknet/public-api'
import { MaskMessages } from '../../../shared'
+import { convertPersonaHexPublicKey } from './util'
export async function consistentPersonaDBWriteAccess(action: () => Promise) {
await action()
}
@@ -392,11 +393,14 @@ function partialPersonaRecordToDB(
}
function personaRecordOutDB(x: NativePersonaRecord): PersonaRecord {
+ const identifier = Identifier.fromString(x.identifier, ECKeyIdentifier).unwrap()
+
return {
publicKey: x.publicKey as JsonWebKey as unknown as EC_Public_JsonWebKey,
+ publicHexKey: convertPersonaHexPublicKey(identifier),
privateKey: x.privateKey as JsonWebKey as unknown as EC_Private_JsonWebKey,
localKey: x.localKey as JsonWebKey as unknown as AESJsonWebKey,
- identifier: Identifier.fromString(x.identifier, ECKeyIdentifier).unwrap(),
+ identifier,
linkedProfiles: new IdentifierMap(new Map(Object.entries(x.linkedProfiles)), ProfileIdentifier),
createdAt: new Date(x.createdAt),
updatedAt: new Date(x.updatedAt),
diff --git a/packages/mask/background/database/persona/type.ts b/packages/mask/background/database/persona/type.ts
index 9f3b8a47ecce..340dd87ac826 100644
--- a/packages/mask/background/database/persona/type.ts
+++ b/packages/mask/background/database/persona/type.ts
@@ -112,6 +112,7 @@ export interface PersonaRecord {
parameter: { path: string; withPassword: boolean }
}
publicKey: EC_Public_JsonWebKey
+ publicHexKey?: string
privateKey?: EC_Private_JsonWebKey
localKey?: AESJsonWebKey
nickname?: string
diff --git a/packages/mask/background/database/persona/util.ts b/packages/mask/background/database/persona/util.ts
new file mode 100644
index 000000000000..992ad3ad8bf7
--- /dev/null
+++ b/packages/mask/background/database/persona/util.ts
@@ -0,0 +1,9 @@
+import { compressSecp256k1Point, decompressSecp256k1Key, PersonaIdentifier, toHex } from '@masknet/shared-base'
+
+export function convertPersonaHexPublicKey(persona: PersonaIdentifier) {
+ const key256 = decompressSecp256k1Key(persona.compressedPoint.replace(/\|/g, '/'))
+ if (!key256.x || !key256.y) return
+ const arr = compressSecp256k1Point(key256.x, key256.y)
+
+ return `0x${toHex(arr)}`
+}
diff --git a/packages/mask/background/database/persona/web.ts b/packages/mask/background/database/persona/web.ts
index 5117cf146805..c4f337b015cc 100644
--- a/packages/mask/background/database/persona/web.ts
+++ b/packages/mask/background/database/persona/web.ts
@@ -29,6 +29,7 @@ import type {
PersonaRecord,
} from './type'
import { isEmpty } from 'lodash-unified'
+import { convertPersonaHexPublicKey } from './util'
/**
* Database structure:
*
@@ -701,9 +702,12 @@ function personaRecordToDB(x: PersonaRecord): PersonaRecordDB {
function personaRecordOutDB(x: PersonaRecordDB): PersonaRecord {
// @ts-ignore
delete x.hasPrivateKey
+ const identifier = Identifier.fromString(x.identifier, ECKeyIdentifier).unwrap()
+
const obj: PersonaRecord = {
...x,
- identifier: Identifier.fromString(x.identifier, ECKeyIdentifier).unwrap(),
+ identifier,
+ publicHexKey: convertPersonaHexPublicKey(identifier),
linkedProfiles: new IdentifierMap(x.linkedProfiles, ProfileIdentifier),
}
return obj
diff --git a/packages/mask/background/services/helper/nextID.ts b/packages/mask/background/services/helper/nextID.ts
index 30b480793fd6..c9dba1b0f509 100644
--- a/packages/mask/background/services/helper/nextID.ts
+++ b/packages/mask/background/services/helper/nextID.ts
@@ -80,6 +80,7 @@ async function queryPersonaHexPublicKey(persona: PersonaIdentifier) {
return `0x${toHex(arr)}`
}
+/** @deprecated Use NextIDService */
export async function queryExistedBinding(persona: PersonaIdentifier) {
const publicKey = await queryPersonaHexPublicKey(persona)
if (!publicKey) return
@@ -92,6 +93,7 @@ export async function queryExistedBinding(persona: PersonaIdentifier) {
return first(result.ids)
}
+/** @deprecated Use NextIDService */
export async function createPersonaPayload(
persona: PersonaIdentifier,
action: 'create' | 'delete',
diff --git a/packages/mask/shared-ui/locales/en-US.json b/packages/mask/shared-ui/locales/en-US.json
index 5b42258a1c22..dc1dd69879f9 100644
--- a/packages/mask/shared-ui/locales/en-US.json
+++ b/packages/mask/shared-ui/locales/en-US.json
@@ -83,15 +83,22 @@
"setup_guide_find_username_text": "Mask needs the username to connect your profile to your persona.",
"setup_guide_connect_auto": "Connect",
"setup_guide_connect_failed": "Re-Connect",
+ "setup_guide_verify": "Verfiy",
+ "setup_guide_verify_dismiss": "Don't show again.",
+ "setup_guide_verify_checking": "Checking",
+ "setup_guide_verify_post_not_found": "No verification post found",
+ "setup_guide_verifying": "Verfiying",
+ "setup_guide_verifying_failed": "Re-Verfiy",
"user_guide_tip_connected": "You have connected to this account successfully.",
+ "user_guide_tip_need_verify_on_next_id": "Mask Network requires you to post an verification tweet to access Next.ID-based products.",
"setup_guide_say_hello_content": "Hello Mask world. This is my first encrypted message. Install https://mask.io to send me encrypted post. ",
"setup_guide_say_hello_follow": "Follow {{account}} to explore Web 3.0.",
- "setup_guide_pin_tip_0": "Don't forget to pin Mask Network in your browser toolbar to access web 3.0 easily.",
- "setup_guide_pin_tip_1": "Click on ",
- "setup_guide_pin_tip_1_s": " at top right of your browser.",
- "setup_guide_pin_tip_2": "Find Mask Network in the list of extensions and click the ",
- "setup_guide_pin_tip_2_s": " button.",
- "setup_guide_pin_tip_3": "Pinned successfully.",
+ "setup_guide_pin_tip": "Don't forget to pin Mask Network in your browser toolbar to access web 3.0 easily.",
+ "setup_guide_pin_tip_step_click_left": "Click on ",
+ "setup_guide_pin_tip_step_click_right": " at top right of your browser.",
+ "setup_guide_pin_tip_step_find_left": "Find Mask Network in the list of extensions and click the ",
+ "setup_guide_pin_tip_step_find_right": " button.",
+ "setup_guide_pin_tip_successfully": "Pinned successfully.",
"setup_guide_pin_dismiss": "Don't show again.",
"user_guide_tip_1": "Connect wallet to explore multi-chain dApps.",
"user_guide_tip_2": "Setup your exclusive NFT Avatar, explore the endless possibilities of Web 3.0.",
diff --git a/packages/mask/src/components/DataSource/useNextID.ts b/packages/mask/src/components/DataSource/useNextID.ts
new file mode 100644
index 000000000000..cc57b68e16ac
--- /dev/null
+++ b/packages/mask/src/components/DataSource/useNextID.ts
@@ -0,0 +1,73 @@
+import { useAsync, useAsyncRetry } from 'react-use'
+import type { NextIDPlatform } from '@masknet/shared-base'
+import Services from '../../extension/service'
+import { useMemo, useState } from 'react'
+import { activatedSocialNetworkUI } from '../../social-network'
+import { usePersonaConnectStatus } from './usePersonaConnectStatus'
+import { currentSetupGuideStatus, dismissVerifyNextID } from '../../settings/settings'
+import stringify from 'json-stable-stringify'
+import { SetupGuideStep } from '../InjectedComponents/SetupGuide/types'
+import type { SetupGuideCrossContextStatus } from '../../settings/types'
+import { useLastRecognizedIdentity } from './useActivatedUI'
+import { useValueRef } from '@masknet/shared'
+import { queryExistedBindingByPersona, queryExistedBindingByPlatform, queryIsBound } from '@masknet/web3-providers'
+
+export const usePersonaBoundPlatform = (personaPublicKey: string) => {
+ useAsyncRetry(() => {
+ return queryExistedBindingByPersona(personaPublicKey)
+ }, [personaPublicKey])
+}
+
+let isOpenedVerifyDialog = false
+
+export const useNextIDBoundByPlatform = (platform: NextIDPlatform, identity: string) => {
+ useAsyncRetry(() => {
+ return queryExistedBindingByPlatform(platform, identity)
+ }, [platform, identity])
+}
+
+export function useNextIDConnectStatus() {
+ const ui = activatedSocialNetworkUI
+ const [enableNextID] = useState(ui.configuration.nextIDConfig?.enable)
+ const personaConnectStatus = usePersonaConnectStatus()
+ const lastStateRef = currentSetupGuideStatus[ui.networkIdentifier]
+ const lastState_ = useValueRef(lastStateRef)
+ const lastState = useMemo(() => {
+ try {
+ return JSON.parse(lastState_)
+ } catch {
+ return {}
+ }
+ }, [lastState_])
+
+ const lastRecognized = useLastRecognizedIdentity()
+ const [username] = useState(
+ lastState.username || (lastRecognized.identifier.isUnknown ? '' : lastRecognized.identifier.userId),
+ )
+
+ const { value: isVerified = false } = useAsync(async () => {
+ if (isOpenedVerifyDialog) return true
+ if (!enableNextID || !username || !personaConnectStatus.connected) return true
+
+ const currentPersona = await Services.Settings.getCurrentPersona()
+ if (!currentPersona?.publicHexKey) return true
+
+ if (dismissVerifyNextID[ui.networkIdentifier].value[`${username}_${currentPersona.identifier.toText()}`])
+ return true
+
+ const platform = ui.configuration.nextIDConfig?.platform as NextIDPlatform | undefined
+ if (!platform) return true
+
+ const isBound = await queryIsBound(currentPersona.publicHexKey, platform, username)
+ if (isBound) return true
+
+ currentSetupGuideStatus[ui.networkIdentifier].value = stringify({
+ status: SetupGuideStep.VerifyOnNextID,
+ persona: currentPersona?.identifier.toText(),
+ })
+ isOpenedVerifyDialog = true
+ return false
+ }, [username, enableNextID, lastStateRef.value])
+
+ return isVerified
+}
diff --git a/packages/mask/src/components/DataSource/usePersonaConnectStatus.ts b/packages/mask/src/components/DataSource/usePersonaConnectStatus.ts
index 82f9f49f67b9..1d19ebb5e04f 100644
--- a/packages/mask/src/components/DataSource/usePersonaConnectStatus.ts
+++ b/packages/mask/src/components/DataSource/usePersonaConnectStatus.ts
@@ -4,7 +4,7 @@ import { useMemo } from 'react'
import Services from '../../extension/service'
import { currentSetupGuideStatus } from '../../settings/settings'
import { activatedSocialNetworkUI } from '../../social-network'
-import { SetupGuideStep } from '../InjectedComponents/SetupGuide'
+import { SetupGuideStep } from '../InjectedComponents/SetupGuide/types'
import { useLastRecognizedIdentity } from './useActivatedUI'
import { useMyPersonas } from './useMyPersonas'
diff --git a/packages/mask/src/components/InjectedComponents/SetupGuide.tsx b/packages/mask/src/components/InjectedComponents/SetupGuide.tsx
index b2096114bb72..8af270978eff 100644
--- a/packages/mask/src/components/InjectedComponents/SetupGuide.tsx
+++ b/packages/mask/src/components/InjectedComponents/SetupGuide.tsx
@@ -1,375 +1,31 @@
-import { useMemo, useState, useEffect } from 'react'
-import { Paper, Typography, IconButton, Box, Button, FormControlLabel, Checkbox } from '@mui/material'
-import { makeStyles, MaskColorVar } from '@masknet/theme'
-import CloseIcon from '@mui/icons-material/Close'
-import { ActionButtonPromise } from '../../extension/options-page/DashboardComponents/ActionButton'
+import { useMemo, useState, useEffect, useRef } from 'react'
+import { makeStyles } from '@masknet/theme'
import { useValueRef } from '@masknet/shared'
import { useI18N, MaskMessages } from '../../utils'
import { activatedSocialNetworkUI, SocialNetworkUI } from '../../social-network'
import { currentSetupGuideStatus, dismissPinExtensionTip, userGuideStatus } from '../../settings/settings'
import type { SetupGuideCrossContextStatus } from '../../settings/types'
-import { PersonaIdentifier, ProfileIdentifier, Identifier, ECKeyIdentifier } from '@masknet/shared-base'
import { makeTypedMessageText } from '@masknet/typed-message'
+import {
+ PersonaIdentifier,
+ ProfileIdentifier,
+ Identifier,
+ ECKeyIdentifier,
+ NextIDPlatform,
+ toBase64,
+ fromHex,
+ NextIDAction,
+} from '@masknet/shared-base'
import Services from '../../extension/service'
import { useLastRecognizedIdentity } from '../DataSource/useActivatedUI'
-import { MaskIcon } from '../../resources/MaskIcon'
import { useAsync, useCopyToClipboard } from 'react-use'
-import classNames from 'classnames'
-import ExtensionIcon from '@mui/icons-material/Extension'
import stringify from 'json-stable-stringify'
-import { VerifiedIcon, PinIcon } from '@masknet/icons'
-
-export enum SetupGuideStep {
- FindUsername = 'find-username',
- PinExtension = 'pin-extension',
- Close = 'close',
-}
-
-// #region wizard dialog
-const useWizardDialogStyles = makeStyles()((theme) => ({
- root: {
- padding: theme.spacing(3),
- position: 'relative',
- boxShadow: theme.palette.mode === 'dark' ? 'none' : theme.shadows[4],
- border: `${theme.palette.mode === 'dark' ? 'solid' : 'none'} 1px ${theme.palette.divider}`,
- borderRadius: 20,
- [theme.breakpoints.down('sm')]: {
- position: 'fixed',
- bottom: 0,
- left: 0,
- margin: 0,
- alignSelf: 'center',
- borderRadius: 0,
- boxShadow: 'none',
- border: `solid 1px ${theme.palette.divider}`,
- width: '100%',
- },
- userSelect: 'none',
- boxSizing: 'border-box',
- width: 480,
- '&.small': {
- width: 384,
- },
- overflow: 'hidden',
- },
- button: {
- width: 150,
- height: 40,
- minHeight: 40,
- marginLeft: 0,
- marginTop: 0,
- [theme.breakpoints.down('sm')]: {
- width: '100%',
- },
- fontSize: 14,
- wordBreak: 'keep-all',
- '&,&:hover': {
- color: `${MaskColorVar.twitterButtonText} !important`,
- background: `${MaskColorVar.twitterButton} !important`,
- },
- },
- close: {
- color: theme.palette.text.primary,
- position: 'absolute',
- right: 10,
- top: 10,
- },
- tip: {
- fontSize: 16,
- fontWeight: 500,
- lineHeight: '22px',
- paddingTop: 16,
- },
- label: {
- fontSize: 16,
- fontWeight: 400,
- lineHeight: '22px',
- },
- header: {
- height: 40,
- },
- content: {},
- connection: {
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'space-around',
- },
- connectItem: {
- flex: 1,
- height: 75,
- display: 'flex',
- flexDirection: 'column',
- alignItems: 'center',
- justifyContent: 'space-between',
- },
- line: {
- width: 100,
- height: 1,
- borderTop: `dashed 1px ${MaskColorVar.borderSecondary}`,
- },
- name: {
- fontSize: 16,
- fontWeight: 500,
- },
- footer: {},
-}))
-
-const useStyles = makeStyles()((theme) => ({
- content: {
- marginBottom: theme.spacing(2),
- },
- footer: {
- marginLeft: 0,
- marginTop: theme.spacing(3),
- flex: 1,
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- flexDirection: 'column',
- },
- tip: {},
-}))
-
-interface ContentUIProps {
- dialogType: SetupGuideStep
- content?: React.ReactNode
- footer?: React.ReactNode
- tip?: React.ReactNode
- dismiss?: React.ReactNode
-}
-
-function ContentUI(props: ContentUIProps) {
- const { classes } = useStyles()
- switch (props.dialogType) {
- case SetupGuideStep.FindUsername:
- case SetupGuideStep.PinExtension:
- return (
-
- {props.content}
- {props.tip}
- {props.footer ? : null}
- {props.dismiss ? {props.dismiss}
: null}
-
- )
- default:
- return null
- }
-}
-
-interface WizardDialogProps {
- small?: boolean
- title?: string
- dialogType: SetupGuideStep
- optional?: boolean
- content?: React.ReactNode
- tip?: React.ReactNode
- footer?: React.ReactNode
- dismiss?: React.ReactNode
- onClose?: () => void
-}
-
-function WizardDialog(props: WizardDialogProps) {
- const { small, title, dialogType, content, tip, footer, dismiss, onClose } = props
- const { classes } = useWizardDialogStyles()
-
- return (
-
-
-
- {onClose ? (
-
-
-
- ) : null}
-
- )
-}
-// #endregion
-
-// #region find username
-const useFindUsernameStyles = makeStyles()((theme) => ({
- avatar: {
- display: 'block',
- width: 48,
- height: 48,
- borderRadius: '50%',
- border: `solid 1px ${MaskColorVar.border}`,
- '&.connected': {
- borderColor: MaskColorVar.success,
- },
- },
- verified: {
- position: 'absolute',
- bottom: 0,
- right: 0,
- fontSize: 16,
- color: MaskColorVar.success,
- },
-}))
-
-interface FindUsernameProps extends Partial {
- username: string
- personaName?: string
- avatar?: string
- onUsernameChange?: (username: string) => void
- onConnect: () => Promise
- onDone?: () => void
-}
-
-function FindUsername({ personaName, username, avatar, onConnect, onDone, onClose }: FindUsernameProps) {
- const { t } = useI18N()
- const [connected, setConnected] = useState(false)
-
- const { classes } = useWizardDialogStyles()
- const { classes: findUsernameClasses } = useFindUsernameStyles()
-
- return (
-
-
-
-
- {personaName}
-
-
- {username ? (
- <>
-
-
-
-
- {connected ? : null}
-
-
- {username}
-
-
- >
- ) : null}
-
- }
- tip={
-
- }
- footer={
- username ? (
- setConnected(true)}
- disabled={!username || !personaName}
- completeIcon={null}
- failIcon={null}
- failedOnClick="use executor"
- data-testid="confirm_button">
- {t('confirm')}
-
- ) : null
- }
- onClose={onClose}
- />
- )
-}
-// #endregion
-
-interface PinExtensionProps {
- onDone?: () => void
-}
-function PinExtension({ onDone }: PinExtensionProps) {
- const pinImg = new URL('../../resources/extensionPinned.png', import.meta.url).toString()
- const { classes } = useWizardDialogStyles()
- const { t } = useI18N()
- const [checked, setChecked] = useState(true)
-
- return (
-
-
-
-
- Mask Network
-
-
-
-
-
-
-
- }
- tip={
-
- {t('setup_guide_pin_tip_0')}
-
- -
- {t('setup_guide_pin_tip_1')}
-
- {t('setup_guide_pin_tip_1_s')}
-
- -
- {t('setup_guide_pin_tip_2')}
-
- {t('setup_guide_pin_tip_2_s')}
-
- - {t('setup_guide_pin_tip_3')}
-
-
- }
- footer={
-
- }
- dismiss={
- {
- setChecked(e.target.checked)
- dismissPinExtensionTip.value = e.target.checked
- }}
- />
- }
- label={t('setup_guide_pin_dismiss')}
- />
- }
- onClose={onDone}
- />
- )
-}
+import type { NextIDPayload } from '@masknet/shared-base'
+import { SetupGuideStep } from './SetupGuide/types'
+import { FindUsername } from './SetupGuide/FindUsername'
+import { VerifyNextID } from './SetupGuide/VerifyNextID'
+import { PinExtension } from './SetupGuide/PinExtension'
+import { bindProof, createPersonaPayload, queryIsBound } from '@masknet/web3-providers'
// #region setup guide ui
interface SetupGuideUIProps {
@@ -377,12 +33,21 @@ interface SetupGuideUIProps {
onClose?: () => void
}
+interface SignInfo {
+ payload: NextIDPayload
+ personaSign: string
+ twitterPost: string
+}
+
function SetupGuideUI(props: SetupGuideUIProps) {
const { t } = useI18N()
const { persona } = props
const ui = activatedSocialNetworkUI
const [, copyToClipboard] = useCopyToClipboard()
const [step, setStep] = useState(SetupGuideStep.FindUsername)
+ const [enableNextID] = useState(ui.configuration.nextIDConfig?.enable)
+ const verifyPostCollectTimer = useRef(null)
+ const platform = ui.configuration.nextIDConfig?.platform as NextIDPlatform
// #region parse setup status
const lastStateRef = currentSetupGuideStatus[ui.networkIdentifier]
@@ -409,10 +74,10 @@ function SetupGuideUI(props: SetupGuideUIProps) {
const handler = (val: SocialNetworkUI.CollectingCapabilities.IdentityResolved) => {
if (username === '' && !val.identifier.isUnknown) setUsername(val.identifier.userId)
}
- activatedSocialNetworkUI.collecting.identityProvider?.recognized.addListener(handler)
+ ui.collecting.identityProvider?.recognized.addListener(handler)
return () => {
- activatedSocialNetworkUI.collecting.identityProvider?.recognized.removeListener(handler)
+ ui.collecting.identityProvider?.recognized.removeListener(handler)
}
}, [username])
@@ -449,17 +114,88 @@ function SetupGuideUI(props: SetupGuideUIProps) {
MaskMessages.events.ownPersonaChanged.sendToAll(undefined)
}
+ const onVerify = async () => {
+ if (!persona_?.publicHexKey) return
+ const collectVerificationPost = ui.configuration.nextIDConfig?.collectVerificationPost
+
+ const platform = ui.configuration.nextIDConfig?.platform as NextIDPlatform | undefined
+ if (!platform) return
+
+ const isBound = await queryIsBound(persona_.publicHexKey, platform, username)
+ if (!isBound) {
+ const payload = await createPersonaPayload(persona_.publicHexKey, NextIDAction.Create, username, platform)
+ if (!payload) throw new Error('Failed to create persona payload.')
+ const signResult = await Services.Identity.signWithPersona({
+ method: 'eth',
+ message: payload.signPayload,
+ identifier: persona_.publicHexKey,
+ })
+ if (!signResult) throw new Error('Failed to sign by persona.')
+ const signature = signResult.signature.signature
+ const postContent = payload.postContent.replace('%SIG_BASE64%', toBase64(fromHex(signature)))
+ ui.automation?.nativeCompositionDialog?.appendText?.(postContent, { recover: false })
+
+ const waitingPost = new Promise((resolve, reject) => {
+ verifyPostCollectTimer.current = setInterval(async () => {
+ const post = collectVerificationPost?.(postContent)
+ if (post && persona_.publicHexKey) {
+ clearInterval(verifyPostCollectTimer.current!)
+ await bindProof(
+ persona_.publicHexKey,
+ NextIDAction.Create,
+ platform,
+ username,
+ undefined,
+ signature,
+ post.postId,
+ )
+ resolve()
+ }
+ }, 1000)
+
+ setTimeout(() => {
+ clearInterval(verifyPostCollectTimer.current!)
+ reject({ message: t('setup_guide_verify_post_not_found') })
+ }, 1000 * 20)
+ })
+
+ await waitingPost
+ }
+ }
+
const onClose = () => {
currentSetupGuideStatus[ui.networkIdentifier].value = ''
setStep(SetupGuideStep.Close)
}
- const onDone = () => {
+ const onDone = async () => {
+ // check verify nextID id state
+ if (step === SetupGuideStep.FindUsername && enableNextID && persona_?.publicHexKey) {
+ const isBound = await queryIsBound(persona_.publicHexKey, platform, username)
+ if (!isBound) {
+ currentSetupGuideStatus[ui.networkIdentifier].value = stringify({
+ status: SetupGuideStep.VerifyOnNextID,
+ })
+ setStep(SetupGuideStep.VerifyOnNextID)
+ return
+ }
+ }
+
// check pin tip status
if (step === SetupGuideStep.FindUsername && !dismissPinExtensionTip.value) {
currentSetupGuideStatus[ui.networkIdentifier].value = stringify({ status: SetupGuideStep.PinExtension })
- return setStep(SetupGuideStep.PinExtension)
+ setStep(SetupGuideStep.PinExtension)
+ return
+ }
+
+ // check verify on next id status
+ if (step === SetupGuideStep.VerifyOnNextID && enableNextID && persona_ && persona_.publicHexKey) {
+ const isBound = await queryIsBound(persona_.publicHexKey, platform, username)
+ if (!isBound) return
+ setStep(SetupGuideStep.PinExtension)
+ return
}
+
// check user guide status
const network = ui.networkIdentifier
if (network === 'twitter.com' && userGuideStatus[network].value !== 'completed') {
@@ -493,6 +229,21 @@ function SetupGuideUI(props: SetupGuideUIProps) {
onConnect={onConnect}
onDone={onDone}
onClose={onClose}
+ enableNextID={enableNextID}
+ />
+ )
+ case SetupGuideStep.VerifyOnNextID:
+ return (
+
)
case SetupGuideStep.PinExtension:
diff --git a/packages/mask/src/components/InjectedComponents/SetupGuide/FindUsername.tsx b/packages/mask/src/components/InjectedComponents/SetupGuide/FindUsername.tsx
new file mode 100644
index 000000000000..3b43cfc9051a
--- /dev/null
+++ b/packages/mask/src/components/InjectedComponents/SetupGuide/FindUsername.tsx
@@ -0,0 +1,126 @@
+import { WizardDialog, WizardDialogProps, useWizardDialogStyles } from './WizardDialog'
+import { useI18N } from '../../../utils'
+import { useState } from 'react'
+import { SetupGuideStep } from './types'
+import { Box, Typography } from '@mui/material'
+import { MaskIcon } from '../../../resources/MaskIcon'
+import classNames from 'classnames'
+import { VerifiedIcon } from '@masknet/icons'
+import { ActionButtonPromise } from '../../../extension/options-page/DashboardComponents/ActionButton'
+import { makeStyles, MaskColorVar } from '@masknet/theme'
+import { noop } from 'lodash-unified'
+
+export const useFindUsernameStyles = makeStyles()((theme) => ({
+ avatar: {
+ display: 'block',
+ width: 48,
+ height: 48,
+ borderRadius: '50%',
+ border: `solid 1px ${MaskColorVar.border}`,
+ '&.connected': {
+ borderColor: MaskColorVar.success,
+ },
+ },
+ verified: {
+ position: 'absolute',
+ bottom: 0,
+ right: 0,
+ fontSize: 16,
+ color: MaskColorVar.success,
+ },
+}))
+
+export interface FindUsernameProps extends Partial {
+ username: string
+ personaName?: string
+ avatar?: string
+ onUsernameChange?: (username: string) => void
+ onConnect: () => Promise
+ onDone?: () => void
+ enableNextID?: boolean
+}
+
+export function FindUsername({
+ personaName,
+ username,
+ avatar,
+ onConnect,
+ onDone,
+ onClose,
+ enableNextID,
+}: FindUsernameProps) {
+ const { t } = useI18N()
+ const [connected, setConnected] = useState(false)
+
+ const { classes } = useWizardDialogStyles()
+ const { classes: findUsernameClasses } = useFindUsernameStyles()
+
+ return (
+
+
+
+
+ {personaName}
+
+
+ {username ? (
+ <>
+
+
+
+
+ {connected ? : null}
+
+
+ {username}
+
+
+ >
+ ) : null}
+
+ }
+ tip={
+
+ }
+ footer={
+ username ? (
+ setConnected(true)}
+ disabled={!username || !personaName}
+ completeIcon={null}
+ failIcon={null}
+ failedOnClick="use executor"
+ data-testid="confirm_button">
+ {t('confirm')}
+
+ ) : null
+ }
+ onClose={onClose}
+ />
+ )
+}
diff --git a/packages/mask/src/components/InjectedComponents/SetupGuide/PinExtension.tsx b/packages/mask/src/components/InjectedComponents/SetupGuide/PinExtension.tsx
new file mode 100644
index 000000000000..79d9bcbe00ab
--- /dev/null
+++ b/packages/mask/src/components/InjectedComponents/SetupGuide/PinExtension.tsx
@@ -0,0 +1,83 @@
+import { useWizardDialogStyles, WizardDialog } from './WizardDialog'
+import { useI18N } from '../../../utils'
+import { useState } from 'react'
+import { SetupGuideStep } from './types'
+import { Box, Button, Checkbox, FormControlLabel, Typography } from '@mui/material'
+import { MaskIcon } from '../../../resources/MaskIcon'
+import ExtensionIcon from '@mui/icons-material/Extension'
+import { PinIcon } from '@masknet/icons'
+import { dismissPinExtensionTip } from '../../../settings/settings'
+
+interface PinExtensionProps {
+ onDone?: () => void
+}
+
+export function PinExtension({ onDone }: PinExtensionProps) {
+ const pinImg = new URL('../../../resources/extensionPinned.png', import.meta.url).toString()
+ const { classes } = useWizardDialogStyles()
+ const { t } = useI18N()
+ const [checked, setChecked] = useState(true)
+
+ return (
+
+
+
+
+ Mask Network
+
+
+
+
+
+
+
+ }
+ tip={
+
+ {t('setup_guide_pin_tip')}
+
+ -
+ {t('setup_guide_pin_tip_step_click_left')}
+
+ {t('setup_guide_pin_tip_step_click_right')}
+
+ -
+ {t('setup_guide_pin_tip_step_find_left')}
+
+ {t('setup_guide_pin_tip_step_find_right')}
+
+ - {t('setup_guide_pin_tip_successfully')}
+
+
+ }
+ footer={
+
+ }
+ dismiss={
+ {
+ setChecked(e.target.checked)
+ dismissPinExtensionTip.value = e.target.checked
+ }}
+ />
+ }
+ label={t('setup_guide_pin_dismiss')}
+ />
+ }
+ onClose={onDone}
+ />
+ )
+}
diff --git a/packages/mask/src/components/InjectedComponents/SetupGuide/VerifyNextID.tsx b/packages/mask/src/components/InjectedComponents/SetupGuide/VerifyNextID.tsx
new file mode 100644
index 000000000000..5c7c8811db5e
--- /dev/null
+++ b/packages/mask/src/components/InjectedComponents/SetupGuide/VerifyNextID.tsx
@@ -0,0 +1,125 @@
+import { useI18N } from '../../../utils'
+import { Box, Checkbox, FormControlLabel, Typography } from '@mui/material'
+import { MaskIcon } from '../../../resources/MaskIcon'
+import classNames from 'classnames'
+import { VerifiedIcon } from '@masknet/icons'
+import { ActionButtonPromise } from '../../../extension/options-page/DashboardComponents/ActionButton'
+import { useWizardDialogStyles, WizardDialogProps, WizardDialog } from './WizardDialog'
+import { useFindUsernameStyles } from './FindUsername'
+import { SetupGuideStep } from './types'
+import { dismissVerifyNextID } from '../../../settings/settings'
+import { useState } from 'react'
+import type { PersonaIdentifier } from '@masknet/shared-base'
+
+interface VerifyNextIDProps extends Partial {
+ username: string
+ personaName?: string
+ personaIdentifier?: PersonaIdentifier
+ network: string
+ avatar?: string
+ onUsernameChange?: (username: string) => void
+ onVerify: () => Promise
+ onDone?: () => void
+}
+
+export const VerifyNextID = ({
+ personaName,
+ personaIdentifier,
+ username,
+ avatar,
+ onVerify,
+ onDone,
+ onClose,
+ network,
+}: VerifyNextIDProps) => {
+ const { t } = useI18N()
+
+ const { classes } = useWizardDialogStyles()
+ const { classes: findUsernameClasses } = useFindUsernameStyles()
+ const [checked, setChecked] = useState(false)
+
+ if (!personaIdentifier) return null
+
+ return (
+
+
+
+
+ {personaName}
+
+
+ {username ? (
+ <>
+
+
+
+
+
+
+
+ {username}
+
+
+ >
+ ) : null}
+
+ }
+ tip={
+ ${t('user_guide_tip_connected')}
${t(
+ 'user_guide_tip_need_verify_on_next_id',
+ )}
`,
+ }}
+ />
+ }
+ footer={
+ username ? (
+
+ {t('setup_guide_verify')}
+
+ ) : null
+ }
+ dismiss={
+ {
+ setChecked(e.target.checked)
+ dismissVerifyNextID[network].value = {
+ ...dismissVerifyNextID[network].value,
+ [`${username}_${personaIdentifier.toText()}`]: e.target.checked,
+ }
+ }}
+ />
+ }
+ label={t('setup_guide_verify_dismiss')}
+ />
+ }
+ onClose={onClose}
+ />
+ )
+}
diff --git a/packages/mask/src/components/InjectedComponents/SetupGuide/WizardDialog.tsx b/packages/mask/src/components/InjectedComponents/SetupGuide/WizardDialog.tsx
new file mode 100644
index 000000000000..64076e21fc26
--- /dev/null
+++ b/packages/mask/src/components/InjectedComponents/SetupGuide/WizardDialog.tsx
@@ -0,0 +1,168 @@
+import { Box, IconButton, Paper, Typography } from '@mui/material'
+import classNames from 'classnames'
+import CloseIcon from '@mui/icons-material/Close'
+import { makeStyles, MaskColorVar } from '@masknet/theme'
+import { SetupGuideStep } from './types'
+
+interface ContentUIProps {
+ dialogType: SetupGuideStep
+ content?: React.ReactNode
+ footer?: React.ReactNode
+ tip?: React.ReactNode
+ dismiss?: React.ReactNode
+}
+
+const useStyles = makeStyles()((theme) => ({
+ content: {
+ marginBottom: theme.spacing(2),
+ },
+ footer: {
+ marginLeft: 0,
+ marginTop: theme.spacing(3),
+ flex: 1,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexDirection: 'column',
+ },
+ tip: {},
+}))
+
+function ContentUI(props: ContentUIProps) {
+ const { classes } = useStyles()
+ switch (props.dialogType) {
+ case SetupGuideStep.FindUsername:
+ case SetupGuideStep.PinExtension:
+ return (
+
+ {props.content}
+ {props.tip}
+ {props.footer ? : null}
+ {props.dismiss ? {props.dismiss}
: null}
+
+ )
+ default:
+ return null
+ }
+}
+
+export const useWizardDialogStyles = makeStyles()((theme) => ({
+ root: {
+ padding: theme.spacing(3),
+ position: 'relative',
+ boxShadow: theme.palette.mode === 'dark' ? 'none' : theme.shadows[4],
+ border: `${theme.palette.mode === 'dark' ? 'solid' : 'none'} 1px ${theme.palette.divider}`,
+ borderRadius: 20,
+ [theme.breakpoints.down('sm')]: {
+ position: 'fixed',
+ bottom: 0,
+ left: 0,
+ margin: 0,
+ alignSelf: 'center',
+ borderRadius: 0,
+ boxShadow: 'none',
+ border: `solid 1px ${theme.palette.divider}`,
+ width: '100%',
+ },
+ userSelect: 'none',
+ boxSizing: 'border-box',
+ width: 480,
+ '&.small': {
+ width: 384,
+ },
+ overflow: 'hidden',
+ },
+ button: {
+ width: 150,
+ height: 40,
+ minHeight: 40,
+ marginLeft: 0,
+ marginTop: 0,
+ [theme.breakpoints.down('sm')]: {
+ width: '100%',
+ },
+ fontSize: 14,
+ wordBreak: 'keep-all',
+ '&,&:hover': {
+ color: `${MaskColorVar.twitterButtonText} !important`,
+ background: `${MaskColorVar.twitterButton} !important`,
+ },
+ },
+ close: {
+ color: theme.palette.text.primary,
+ position: 'absolute',
+ right: 10,
+ top: 10,
+ },
+ tip: {
+ fontSize: 16,
+ fontWeight: 500,
+ lineHeight: '22px',
+ paddingTop: 16,
+ },
+ label: {
+ fontSize: 16,
+ fontWeight: 400,
+ lineHeight: '22px',
+ },
+ header: {
+ height: 40,
+ },
+ content: {},
+ connection: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-around',
+ },
+ connectItem: {
+ flex: 1,
+ height: 75,
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+ line: {
+ width: 100,
+ height: 1,
+ borderTop: `dashed 1px ${MaskColorVar.borderSecondary}`,
+ },
+ name: {
+ fontSize: 16,
+ fontWeight: 500,
+ },
+ footer: {},
+}))
+
+export interface WizardDialogProps {
+ small?: boolean
+ title?: string
+ dialogType: SetupGuideStep
+ optional?: boolean
+ content?: React.ReactNode
+ tip?: React.ReactNode
+ footer?: React.ReactNode
+ dismiss?: React.ReactNode
+ onClose?: () => void
+}
+
+export function WizardDialog(props: WizardDialogProps) {
+ const { small, title, dialogType, content, tip, footer, dismiss, onClose } = props
+ const { classes } = useWizardDialogStyles()
+
+ return (
+
+
+
+ {onClose ? (
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/packages/mask/src/components/InjectedComponents/SetupGuide/types.ts b/packages/mask/src/components/InjectedComponents/SetupGuide/types.ts
new file mode 100644
index 000000000000..1079a2b05b2d
--- /dev/null
+++ b/packages/mask/src/components/InjectedComponents/SetupGuide/types.ts
@@ -0,0 +1,6 @@
+export enum SetupGuideStep {
+ FindUsername = 'find-username',
+ VerifyOnNextID = 'next-id-verify',
+ PinExtension = 'pin-extension',
+ Close = 'close',
+}
diff --git a/packages/mask/src/components/InjectedComponents/ToolboxUnstyled.tsx b/packages/mask/src/components/InjectedComponents/ToolboxUnstyled.tsx
index ad85644bf5a5..79728142ea19 100644
--- a/packages/mask/src/components/InjectedComponents/ToolboxUnstyled.tsx
+++ b/packages/mask/src/components/InjectedComponents/ToolboxUnstyled.tsx
@@ -33,6 +33,7 @@ import { MaskFilledIcon } from '../../resources/MaskIcon'
import { makeStyles } from '@masknet/theme'
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord'
import { usePersonaConnectStatus } from '../DataSource/usePersonaConnectStatus'
+import { useNextIDConnectStatus } from '../DataSource/useNextID'
const useStyles = makeStyles()((theme) => ({
title: {
@@ -83,6 +84,7 @@ export interface ToolboxHintProps {
}
export function ToolboxHintUnstyled(props: ToolboxHintProps) {
const { t } = useI18N()
+ const isNextIDVerified = useNextIDConnectStatus()
const {
ListItemButton = MuiListItemButton,
ListItemText = MuiListItemText,
@@ -109,6 +111,7 @@ export function ToolboxHintUnstyled(props: ToolboxHintProps) {
}, [personaConnectStatus, walletTitle, t])
const onClick = () => {
+ if (!isNextIDVerified) return
personaConnectStatus.action ? personaConnectStatus.action() : openWallet()
}
diff --git a/packages/mask/src/extension/background-script/SettingsService.ts b/packages/mask/src/extension/background-script/SettingsService.ts
index bceaf352f5b4..a5f6064f9e09 100644
--- a/packages/mask/src/extension/background-script/SettingsService.ts
+++ b/packages/mask/src/extension/background-script/SettingsService.ts
@@ -9,7 +9,7 @@ import {
pluginIDSettings,
} from '../../settings/settings'
import { currentDataProviderSettings } from '../../plugins/Trader/settings'
-import { queryMyPersonas } from './IdentityService'
+import { queryMyPersonas, queryPersona } from './IdentityService'
import {
currentAccountSettings,
currentNetworkSettings,
@@ -26,6 +26,7 @@ import {
} from '../../plugins/Wallet/settings'
import { Flags, MaskMessages } from '../../../shared'
import { indexedDB_KVStorageBackend, inMemory_KVStorageBackend } from '../../../background/database/kv-storage'
+import type { Persona } from '../../database'
function create(settings: InternalSettings) {
async function get() {
@@ -76,6 +77,12 @@ export async function getWalletAllowTestChain() {
return Flags.wallet_allow_testnet
}
+export async function getCurrentPersona(): Promise {
+ const currentPersonaIdentifier = await getCurrentPersonaIdentifier()
+ if (!currentPersonaIdentifier) return undefined
+ return queryPersona(currentPersonaIdentifier)
+}
+
export async function getCurrentPersonaIdentifier(): Promise {
await currentPersonaIdentifier.readyPromise
const personas = (await queryMyPersonas())
diff --git a/packages/mask/src/extension/background-script/SocialNetworkService.ts b/packages/mask/src/extension/background-script/SocialNetworkService.ts
index 7761972e6172..84be88b859fc 100644
--- a/packages/mask/src/extension/background-script/SocialNetworkService.ts
+++ b/packages/mask/src/extension/background-script/SocialNetworkService.ts
@@ -4,7 +4,7 @@ import { requestSNSAdaptorPermission } from '../../social-network/utils/permissi
import { currentSetupGuideStatus } from '../../settings/settings'
import stringify from 'json-stable-stringify'
-import { SetupGuideStep } from '../../components/InjectedComponents/SetupGuide'
+import { SetupGuideStep } from '../../components/InjectedComponents/SetupGuide/types'
import type { PersonaIdentifier } from '@masknet/shared-base'
import { delay } from '@dimensiondev/kit'
diff --git a/packages/mask/src/settings/settings.ts b/packages/mask/src/settings/settings.ts
index 690d31f7d0c9..12f283dde83a 100644
--- a/packages/mask/src/settings/settings.ts
+++ b/packages/mask/src/settings/settings.ts
@@ -61,6 +61,10 @@ export const sayHelloShowed: NetworkSettings = createNetworkSettings('s
export const dismissPinExtensionTip = createGlobalSettings('dismissPinExtensionTip', false, {
primary: () => '',
})
+export const dismissVerifyNextID: NetworkSettings<{ [key in string]: boolean }> = createNetworkSettings(
+ 'dismissVerifyNextID',
+ {},
+)
export const bioDescription: NetworkSettings = createNetworkSettings('bioDescription', '')
export const personalHomepage: NetworkSettings = createNetworkSettings('personalHomepage', '')
// This is a misuse of concept "NetworkSettings" as "namespaced settings"
diff --git a/packages/mask/src/settings/types.ts b/packages/mask/src/settings/types.ts
index 44fa3978b96c..6a887fd97ae2 100644
--- a/packages/mask/src/settings/types.ts
+++ b/packages/mask/src/settings/types.ts
@@ -1,7 +1,7 @@
// WARNING: Types with @public mark is exposed as public API to the native side,
// if you make an incompatible change in this file, it will break the API.
import type { ChainId } from '@masknet/web3-shared-evm'
-import type { SetupGuideStep } from '../components/InjectedComponents/SetupGuide'
+import type { SetupGuideStep } from '../components/InjectedComponents/SetupGuide/types'
export interface ChainBlockNumber {
chainId: ChainId
blockNumber: number
diff --git a/packages/mask/src/social-network-adaptor/twitter.com/ui-provider.ts b/packages/mask/src/social-network-adaptor/twitter.com/ui-provider.ts
index d14dee9b94c2..d69bb1e8467a 100644
--- a/packages/mask/src/social-network-adaptor/twitter.com/ui-provider.ts
+++ b/packages/mask/src/social-network-adaptor/twitter.com/ui-provider.ts
@@ -26,7 +26,7 @@ import { injectMaskUserBadgeAtTwitter } from './injection/MaskIcon'
import { pasteImageToCompositionDefault } from '../../social-network/defaults/automation/AttachImageToComposition'
import { injectPostInspectorAtTwitter } from './injection/PostInspector'
import { injectPostActionsAtTwitter } from './injection/PostActions'
-import { ProfileIdentifier } from '@masknet/shared-base'
+import { NextIDPlatform, PostIdentifier, ProfileIdentifier } from '@masknet/shared-base'
import { unreachable } from '@dimensiondev/kit'
import { makeStyles } from '@masknet/theme'
import { injectNFTAvatarInTwitter } from './injection/NFT/NFTAvatarInTwitter'
@@ -37,6 +37,9 @@ import { injectUserNFTAvatarAtTweet } from './injection/NFT/TweetNFTAvatar'
import { injectNFTContractAtTwitter } from './injection/NFT/NFTContract'
import { injectNFTAvatarClipInTwitter } from './injection/NFT/NFTAvatarClip'
import { TwitterRenderFragments } from './customization/render-fragments'
+import { timelinePostContentSelector } from './utils/selector'
+import { postContentMessageParser, postIdParser } from './utils/fetch'
+import { isTypedMessageText } from '@masknet/typed-message'
const useInjectedDialogClassesOverwriteTwitter = makeStyles()((theme) => {
const smallQuery = `@media (max-width: ${theme.breakpoints.values.sm}px)`
@@ -184,6 +187,31 @@ const twitterUI: SocialNetworkUI.Definition = {
avatarClipNFT: injectNFTAvatarClipInTwitter,
},
configuration: {
+ nextIDConfig: {
+ enable: true,
+ platform: NextIDPlatform.Twitter,
+ collectVerificationPost: (keyword: string) => {
+ const userId =
+ IdentityProviderTwitter.recognized.value.identifier || globalUIState.profiles.value[0].identifier
+ const postNodes = timelinePostContentSelector().evaluate()
+
+ for (const postNode of postNodes) {
+ const postId = postIdParser(postNode)
+ const postContent = postContentMessageParser(postNode)
+ const isVerified =
+ postId &&
+ postContent[0] &&
+ isTypedMessageText(postContent[0]) &&
+ (postContent[0]?.content ?? '').toLowerCase() === keyword.toLowerCase()
+
+ if (isVerified && userId) {
+ return new PostIdentifier(userId, postId)
+ }
+ }
+
+ return null
+ },
+ },
steganography: {
password() {
// ! Change this might be a breaking change !
@@ -197,4 +225,5 @@ const twitterUI: SocialNetworkUI.Definition = {
},
},
}
+
export default twitterUI
diff --git a/packages/mask/src/social-network-adaptor/twitter.com/utils/selector.ts b/packages/mask/src/social-network-adaptor/twitter.com/utils/selector.ts
index 87a02a576810..3cfa83227e68 100644
--- a/packages/mask/src/social-network-adaptor/twitter.com/utils/selector.ts
+++ b/packages/mask/src/social-network-adaptor/twitter.com/utils/selector.ts
@@ -143,6 +143,15 @@ export const postsImageSelector = (node: HTMLElement) =>
'[data-testid="tweet"] ~ div img[src*="media"]', // image in detail page for new twitter
].join(),
)
+
+export const timelinePostContentSelector = () =>
+ querySelectorAll(
+ [
+ '[data-testid="tweet"] div + div div[lang]', // text tweets
+ '[data-testid="tweet"] div + div div[data-testid="card.wrapper"]', // link box tweets
+ ].join(),
+ )
+
export const postsContentSelector = () =>
querySelectorAll(
[
diff --git a/packages/mask/src/social-network/types.ts b/packages/mask/src/social-network/types.ts
index 6a98ad910db8..ab4172151655 100644
--- a/packages/mask/src/social-network/types.ts
+++ b/packages/mask/src/social-network/types.ts
@@ -292,6 +292,7 @@ export namespace SocialNetworkUI {
}
export namespace Configuration {
export interface Define {
+ nextIDConfig?: NextIDConfig
steganography?: SteganographyConfig
setupWizard?: SetupWizardConfig
}
@@ -306,6 +307,11 @@ export namespace SocialNetworkUI {
export interface SetupWizardConfig {
disableSayHello?: boolean
}
+ export interface NextIDConfig {
+ enable?: boolean
+ platform: string
+ collectVerificationPost: (keyword: string) => PostIdentifier | null
+ }
}
}
diff --git a/packages/shared-base/src/NextID/type.ts b/packages/shared-base/src/NextID/type.ts
new file mode 100644
index 000000000000..c80d44f9528d
--- /dev/null
+++ b/packages/shared-base/src/NextID/type.ts
@@ -0,0 +1,29 @@
+export enum NextIDAction {
+ Create = 'create',
+ Delete = 'delete',
+}
+
+export enum NextIDPlatform {
+ NextId = 'nextid',
+ Twitter = 'twitter',
+ Keybase = 'keybase',
+ Ethereum = 'ethereum',
+ GitHub = 'github',
+}
+
+export interface NextIDPayload {
+ postContent: string
+ signPayload: string
+}
+
+export interface NextIDPersonaBindings {
+ persona: string
+ proofs: {
+ platform: string
+ identity: string
+ }[]
+}
+
+export interface NextIDBindings {
+ ids: NextIDPersonaBindings[]
+}
diff --git a/packages/shared-base/src/index.ts b/packages/shared-base/src/index.ts
index f4038d746655..23ad9dc2a590 100644
--- a/packages/shared-base/src/index.ts
+++ b/packages/shared-base/src/index.ts
@@ -15,3 +15,4 @@ export { DashboardRoutes } from './Routes/DashboardRoutes'
export * from './Messages/Mask'
export * from './Results'
export * from './convert'
+export * from './NextID/type'
diff --git a/packages/web3-providers/src/NextID/index.ts b/packages/web3-providers/src/NextID/index.ts
new file mode 100644
index 000000000000..34a890ca439d
--- /dev/null
+++ b/packages/web3-providers/src/NextID/index.ts
@@ -0,0 +1,106 @@
+import { fromHex, toBase64, NextIDBindings, NextIDPlatform, NextIDPayload, NextIDAction } from '@masknet/shared-base'
+import urlcat from 'urlcat'
+import { first } from 'lodash-unified'
+
+const BASE_URL =
+ process.env.channel === 'stable' && process.env.NODE_ENV === 'production'
+ ? 'https://proof-service.next.id/'
+ : 'https://js43x8ol17.execute-api.ap-east-1.amazonaws.com/api/'
+
+interface CreatePayloadBody {
+ action: string
+ platform: string
+ identity: string
+ public_key: string
+}
+
+export async function bindProof(
+ personaPublicKey: string,
+ action: NextIDAction,
+ platform: string,
+ identity: string,
+ walletSignature?: string,
+ signature?: string,
+ proofLocation?: string,
+) {
+ const requestBody = {
+ action,
+ platform,
+ identity,
+ public_key: personaPublicKey,
+ ...(proofLocation ? { proof_location: proofLocation } : {}),
+ extra: {
+ ...(walletSignature ? { wallet_signature: toBase64(fromHex(walletSignature)) } : {}),
+ ...(signature ? { signature: toBase64(fromHex(signature)) } : {}),
+ },
+ }
+
+ const response = await fetch(urlcat(BASE_URL, '/v1/proof'), {
+ body: JSON.stringify(requestBody),
+ method: 'POST',
+ mode: 'cors',
+ })
+
+ const result = (await response.json()) as { message: string }
+
+ if (!response.ok) throw new Error(result.message)
+ return response
+}
+
+export async function queryExistedBindingByPersona(personaPublicKey: string) {
+ const response = await fetch(urlcat(BASE_URL, '/v1/proof', { platform: 'nextid', identity: personaPublicKey }), {
+ mode: 'cors',
+ })
+
+ const result = (await response.json()) as NextIDBindings
+ // Will have only one item when query by personaPublicKey
+ return first(result.ids)
+}
+
+export async function queryExistedBindingByPlatform(platform: NextIDPlatform, identity: string) {
+ if (!platform && !identity) return []
+
+ const response = await fetch(urlcat(BASE_URL, '/v1/proof', { platform: platform, identity: identity }), {
+ mode: 'cors',
+ })
+
+ const result = (await response.json()) as NextIDBindings
+ return result.ids
+}
+
+export async function queryExistedBinding(platform: NextIDPlatform, identity: string): Promise {
+ throw new Error('To be implemented.')
+}
+
+export async function queryIsBound(personaPublicKey: string, platform: NextIDPlatform, identity: string) {
+ if (!platform && !identity) return false
+
+ const ids = await queryExistedBindingByPlatform(platform, identity)
+ return ids.some((x) => x.persona.toLowerCase() === personaPublicKey.toLowerCase())
+}
+
+export async function createPersonaPayload(
+ personaPublicKey: string,
+ action: NextIDAction,
+ identity: string,
+ platform: NextIDPlatform,
+): Promise {
+ const requestBody: CreatePayloadBody = {
+ action,
+ platform,
+ identity,
+ public_key: personaPublicKey,
+ }
+
+ const response = await fetch(urlcat(BASE_URL, '/v1/proof/payload'), {
+ body: JSON.stringify(requestBody),
+ method: 'POST',
+ mode: 'cors',
+ })
+
+ const result = await response.json()
+ return {
+ postContent: result.post_content,
+ signPayload: JSON.stringify(JSON.parse(result.sign_payload)),
+ }
+}
diff --git a/packages/web3-providers/src/index.ts b/packages/web3-providers/src/index.ts
index c4e28fac5830..ce3bbf389730 100644
--- a/packages/web3-providers/src/index.ts
+++ b/packages/web3-providers/src/index.ts
@@ -11,6 +11,7 @@ import { TokenPriceAPI } from './token-price'
export * from './types'
export * from './opensea/utils'
+export * from './NextID'
export const OpenSea = new OpenSeaAPI()
export const Rarible = new RaribleAPI()