Skip to content

Commit 10ba83a

Browse files
committed
feat(firefly): add firefly session bind and restore with farcaster integration
1 parent 21cd336 commit 10ba83a

File tree

25 files changed

+458
-37
lines changed

25 files changed

+458
-37
lines changed

cspell.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@
269269
"linkedin",
270270
"luma",
271271
"muln",
272+
"pathnames",
272273
"reposted",
273274
"reposts",
274275
"sepolia",
@@ -278,6 +279,7 @@
278279
"txid",
279280
"waitlist",
280281
"WARPCAST",
282+
"webm",
281283
"youtube"
282284
]
283285
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import {
2+
FarcasterSession,
3+
FIREFLY_ROOT_URL,
4+
fireflySessionHolder,
5+
patchFarcasterSessionRequired,
6+
resolveFireflyResponseData,
7+
} from '@masknet/web3-providers'
8+
import { SessionType, type FireflyConfigAPI, type Session } from '@masknet/web3-providers/types'
9+
import urlcat from 'urlcat'
10+
11+
async function bindFarcasterSessionToFirefly(session: FarcasterSession, signal?: AbortSignal) {
12+
const isGrantByPermission = FarcasterSession.isGrantByPermission(session, true)
13+
const isRelayService = FarcasterSession.isRelayService(session)
14+
15+
if (!isGrantByPermission && !isRelayService)
16+
throw new Error(
17+
'[bindFarcasterSessionToFirefly] Only grant-by-permission or relay service sessions are allowed.',
18+
)
19+
20+
const response = await fireflySessionHolder.fetch<FireflyConfigAPI.BindResponse>(
21+
urlcat(FIREFLY_ROOT_URL, '/v3/user/bindFarcaster'),
22+
{
23+
method: 'POST',
24+
body: JSON.stringify({
25+
token: isGrantByPermission ? session.signerRequestToken : undefined,
26+
channelToken: isRelayService ? session.channelToken : undefined,
27+
isForce: false,
28+
}),
29+
signal,
30+
},
31+
)
32+
33+
if (response.error?.some((x) => x.includes('Farcaster binding timed out'))) {
34+
throw new Error('Bind Farcaster account to Firefly timeout.')
35+
}
36+
37+
// If the farcaster is already bound to another account, throw an error.
38+
if (
39+
isRelayService &&
40+
response.error?.some((x) => x.includes('This farcaster already bound to the other account'))
41+
) {
42+
throw new Error('This Farcaster account has already bound to another Firefly account.')
43+
}
44+
45+
const data = resolveFireflyResponseData(response)
46+
patchFarcasterSessionRequired(session, data.fid, data.farcaster_signer_private_key)
47+
return data
48+
}
49+
50+
/**
51+
* Bind a lens or farcaster session to the currently logged-in Firefly session.
52+
* @param session
53+
* @param signal
54+
* @returns
55+
*/
56+
export async function bindFireflySession(session: Session, signal?: AbortSignal) {
57+
// Ensure that the Firefly session is resumed before calling this function.
58+
fireflySessionHolder.assertSession()
59+
if (session.type === SessionType.Farcaster) {
60+
return bindFarcasterSessionToFirefly(session as FarcasterSession, signal)
61+
} else if (session.type === SessionType.Firefly) {
62+
throw new Error('Not allowed')
63+
}
64+
throw new Error(`Unknown session type: ${session.type}`)
65+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { fireflySessionHolder } from '@masknet/web3-providers'
2+
import type { Session } from '@masknet/web3-providers/types'
3+
import { bindFireflySession } from './bindFireflySession'
4+
import { restoreFireflySession } from './restoreFireflySession'
5+
6+
export async function bindOrRestoreFireflySession(session: Session, signal?: AbortSignal) {
7+
try {
8+
if (fireflySessionHolder.session) {
9+
await bindFireflySession(session, signal)
10+
11+
// this will return the existing session
12+
return fireflySessionHolder.assertSession(
13+
'[bindOrRestoreFireflySession] Failed to bind farcaster session with firefly.',
14+
)
15+
} else {
16+
throw new Error('[bindOrRestoreFireflySession] Firefly session is not available.')
17+
}
18+
} catch (error) {
19+
// this will create a new session
20+
return restoreFireflySession(session, signal)
21+
}
22+
}

packages/mask/popups/pages/Personas/ConnectFirefly/createAccountByRelayService.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import type { Account } from '@masknet/shared-base'
21
import { fetchJSON } from '@masknet/web3-providers/helpers'
3-
import { FarcasterSession } from '@masknet/web3-providers'
2+
import { FarcasterSession, getFarcasterProfileById, type FireflyAccount } from '@masknet/web3-providers'
43
import urlcat from 'urlcat'
4+
import { bindOrRestoreFireflySession } from './bindOrRestoreFireflySession'
55

66
const FARCASTER_REPLY_URL = 'https://relay.farcaster.xyz'
77
const NOT_DEPEND_SECRET = '[TO_BE_REPLACED_LATER]'
@@ -20,9 +20,12 @@ async function createSession(signal?: AbortSignal) {
2020
const url = urlcat(FARCASTER_REPLY_URL, '/v1/channel')
2121
const response = await fetchJSON<FarcasterReplyResponse>(url, {
2222
method: 'POST',
23+
headers: {
24+
'Content-Type': 'application/json',
25+
},
2326
body: JSON.stringify({
24-
siteUri: 'https://www.mask.io',
25-
domain: 'www.mask.io',
27+
siweUri: 'https://firefly.social',
28+
domain: 'firefly.social',
2629
}),
2730
signal,
2831
})
@@ -51,6 +54,7 @@ export async function createAccountByRelayService(callback?: (url: string) => vo
5154

5255
// polling for the session to be ready
5356
const fireflySession = await bindOrRestoreFireflySession(session, signal)
57+
console.log('fireflySession', fireflySession)
5458

5559
// profile id is available after the session is ready
5660
const profile = await getFarcasterProfileById(session.profileId)
@@ -60,5 +64,5 @@ export async function createAccountByRelayService(callback?: (url: string) => vo
6064
session,
6165
profile,
6266
fireflySession,
63-
} satisfies Account
67+
} satisfies FireflyAccount
6468
}
Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,96 @@
1-
import { useLingui } from '@lingui/react/macro'
1+
import { Trans, useLingui } from '@lingui/react/macro'
22
import { PopupHomeTabType } from '@masknet/shared'
3-
import { QRCode } from 'react-qrcode-logo'
4-
import { PopupRoutes } from '@masknet/shared-base'
5-
import { makeStyles } from '@masknet/theme'
3+
import {
4+
AbortError,
5+
FarcasterPatchSignerError,
6+
FireflyAlreadyBoundError,
7+
FireflyBindTimeoutError,
8+
PopupRoutes,
9+
TimeoutError,
10+
} from '@masknet/shared-base'
11+
import { makeStyles, usePopupCustomSnackbar } from '@masknet/theme'
12+
import { addAccount, type AccountOptions, type FireflyAccount } from '@masknet/web3-providers'
13+
import { Social } from '@masknet/web3-providers/types'
614
import { Box } from '@mui/material'
7-
import { memo, useCallback } from 'react'
15+
import { memo, useCallback, useState } from 'react'
16+
import { QRCode } from 'react-qrcode-logo'
817
import { useNavigate } from 'react-router-dom'
918
import urlcat from 'urlcat'
1019
import { useTitle } from '../../../hooks/index.js'
20+
import { useMount } from 'react-use'
21+
import { createAccountByRelayService } from './createAccountByRelayService.js'
1122

1223
const useStyles = makeStyles()({
13-
container: {},
24+
container: {
25+
display: 'flex',
26+
justifyContent: 'center',
27+
alignItems: 'center',
28+
},
1429
})
1530

31+
function useLogin() {
32+
const { showSnackbar } = usePopupCustomSnackbar()
33+
return useCallback(
34+
async function login(createAccount: () => Promise<FireflyAccount>, options?: Omit<AccountOptions, 'source'>) {
35+
try {
36+
const account = await createAccount()
37+
38+
const done = await addAccount(account, options)
39+
console.log('created account', account)
40+
if (done) showSnackbar(<Trans>Your {Social.Source.Farcaster} account is now connected.</Trans>)
41+
} catch (error) {
42+
// skip if the error is abort error
43+
if (AbortError.is(error)) return
44+
45+
// if login timed out, let the user refresh the QR code
46+
if (error instanceof TimeoutError || error instanceof FireflyBindTimeoutError) {
47+
showSnackbar(<Trans>This QR code is longer valid. Please scan a new one to continue.</Trans>)
48+
return
49+
}
50+
51+
// failed to patch the signer
52+
if (error instanceof FarcasterPatchSignerError) throw error
53+
54+
// if any error occurs, close the modal
55+
// by this we don't need to do error handling in UI part.
56+
// if the account is already bound to another account, show a warning message
57+
if (error instanceof FireflyAlreadyBoundError) {
58+
showSnackbar(
59+
<Trans>
60+
The account you are trying to log in with is already linked to a different Firefly account.
61+
</Trans>,
62+
)
63+
return
64+
}
65+
66+
throw error
67+
}
68+
},
69+
[showSnackbar],
70+
)
71+
}
72+
1673
export const Component = memo(function ConnectFireflyPage() {
1774
const { t } = useLingui()
1875
const { classes } = useStyles()
76+
const [url, setUrl] = useState('')
1977

2078
const navigate = useNavigate()
79+
const login = useLogin()
80+
81+
useMount(async () => {
82+
login(async () => {
83+
try {
84+
const account = await createAccountByRelayService((url) => {
85+
setUrl(url)
86+
})
87+
return account
88+
} catch (err) {
89+
console.log('error', err)
90+
throw err
91+
}
92+
})
93+
})
2194

2295
const handleBack = useCallback(() => {
2396
navigate(urlcat(PopupRoutes.Personas, { tab: PopupHomeTabType.ConnectedWallets }), {
@@ -29,7 +102,7 @@ export const Component = memo(function ConnectFireflyPage() {
29102

30103
return (
31104
<Box className={classes.container}>
32-
<QRCode value="hello" ecLevel="L" size={220} quietZone={16} eyeRadius={100} qrStyle="dots" />
105+
<QRCode value={url} ecLevel="L" size={220} quietZone={16} eyeRadius={100} qrStyle="dots" />
33106
</Box>
34107
)
35108
})
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import {
2+
FarcasterSession,
3+
FIREFLY_ROOT_URL,
4+
FireflySession,
5+
patchFarcasterSessionRequired,
6+
resolveFireflyResponseData,
7+
} from '@masknet/web3-providers'
8+
import type { FireflyConfigAPI, Session } from '@masknet/web3-providers/types'
9+
import urlcat from 'urlcat'
10+
11+
export async function restoreFireflySessionFromFarcaster(session: FarcasterSession, signal?: AbortSignal) {
12+
const isGrantByPermission = FarcasterSession.isGrantByPermission(session, true)
13+
const isRelayService = FarcasterSession.isRelayService(session)
14+
if (!isGrantByPermission && !isRelayService)
15+
throw new Error('[restoreFireflySession] Only grant-by-permission or relay service sessions are allowed.')
16+
17+
const url = urlcat(FIREFLY_ROOT_URL, '/v3/auth/farcaster/login')
18+
const response = await fetch(url, {
19+
method: 'POST',
20+
headers: {
21+
'Content-Type': 'application/json',
22+
},
23+
body: JSON.stringify({
24+
token: isGrantByPermission ? session.signerRequestToken : undefined,
25+
channelToken: isRelayService ? session.channelToken : undefined,
26+
}),
27+
signal,
28+
})
29+
30+
const json: FireflyConfigAPI.LoginResponse = await response.json()
31+
if (!response.ok && json.error?.includes('Farcaster login timed out'))
32+
throw new Error('[restoreFireflySession] Farcaster login timed out.')
33+
34+
const data = resolveFireflyResponseData(json)
35+
if (data.fid && data.accountId && data.accessToken) {
36+
patchFarcasterSessionRequired(session as FarcasterSession, data.fid, data.farcaster_signer_private_key)
37+
return new FireflySession(data.uid ?? data.accountId, data.accessToken, session, null, false, data)
38+
}
39+
throw new Error('[restoreFireflySession] Failed to restore firefly session.')
40+
}
41+
42+
/**
43+
* Restore firefly session from a lens or farcaster session.
44+
* @param session
45+
* @param signal
46+
* @returns
47+
*/
48+
export function restoreFireflySession(session: Session, signal?: AbortSignal) {
49+
if (session.type === 'Farcaster') {
50+
return restoreFireflySessionFromFarcaster(session as FarcasterSession, signal)
51+
} else if (session.type === 'Firefly') {
52+
throw new Error('[restoreFireflySession] Firefly session is not allowed.')
53+
}
54+
throw new Error(`[restoreFireflySession] Unknown session type: ${session.type}`)
55+
}

packages/plugins/RSS3/src/SiteAdaptor/SocialFeeds/SocialFeed.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ const useStyles = makeStyles<void, 'image' | 'markdown' | 'failedImage' | 'body'
183183
const PlatformIconMap = {
184184
[Social.Source.Farcaster]: Icons.Farcaster,
185185
[Social.Source.Lens]: Icons.DarkLens,
186+
[Social.Source.Firefly]: null,
186187
}
187188

188189
export interface SocialFeedProps extends HTMLProps<HTMLDivElement> {

packages/shared-base/src/errors.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
export class AbortError extends Error {
2+
override name = 'AbortError'
3+
4+
constructor(message = 'Aborted') {
5+
super(message)
6+
}
7+
8+
static is(error: unknown) {
9+
return error instanceof AbortError || (error instanceof DOMException && error.name === 'AbortError')
10+
}
11+
}
12+
13+
export class FarcasterPatchSignerError extends Error {
14+
override name = 'FarcasterPatchSignerError'
15+
16+
constructor(public fid: number) {
17+
super(`Failed to patch signer key to Farcaster session: ${fid}`)
18+
}
19+
}
20+
21+
export class TimeoutError extends Error {
22+
override name = 'TimeoutError'
23+
24+
constructor(message?: string) {
25+
super(message ?? 'Timeout.')
26+
}
27+
}
28+
29+
export class FireflyBindTimeoutError extends Error {
30+
override name = 'FireflyBindTimeoutError'
31+
constructor(public source: string) {
32+
super(`Bind ${source} account to Firefly timeout.`)
33+
}
34+
}
35+
export class FireflyAlreadyBoundError extends Error {
36+
override name = 'FireflyAlreadyBoundError'
37+
38+
constructor(public source: string) {
39+
super(`This ${source} account has already bound to another Firefly account.`)
40+
}
41+
}

packages/shared-base/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export * from './constants.js'
44
export * from './types.js'
55
export * from './types/index.js'
66
export * from './helpers/index.js'
7+
export * from './errors.js'
78

89
export * from './Messages/Events.js'
910
export * from './Messages/CrossIsolationEvents.js'
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import urlcat from 'urlcat'
2+
import { FIREFLY_ROOT_URL } from '../Firefly/constants'
3+
import { resolveFireflyResponseData } from '../Firefly/helpers'
4+
import { fetchJSON } from '../helpers/fetchJSON'
5+
import type { FireflyConfigAPI } from '../types/Firefly'
6+
7+
export async function getFarcasterFriendship(sourceFid: string, destFid: string) {
8+
const response = await fetchJSON<FireflyConfigAPI.FriendshipResponse>(
9+
urlcat(FIREFLY_ROOT_URL, '/v2/farcaster-hub/user/friendship', {
10+
sourceFid,
11+
destFid,
12+
}),
13+
{
14+
method: 'GET',
15+
},
16+
)
17+
return resolveFireflyResponseData<FireflyConfigAPI.FriendshipResponse['data']>(response)
18+
}

0 commit comments

Comments
 (0)