Skip to content

Commit 21cd336

Browse files
committed
feat: add firefly session management and farcaster integration
1 parent 426228f commit 21cd336

File tree

13 files changed

+482
-2
lines changed

13 files changed

+482
-2
lines changed

.node-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v23.6.0
1+
v24.7.0

cspell.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@
263263
"boba",
264264
"cashtags",
265265
"celo",
266+
"deeplink",
266267
"endregion",
267268
"firelfy",
268269
"linkedin",
@@ -276,6 +277,7 @@
276277
"tweetnacl",
277278
"txid",
278279
"waitlist",
280+
"WARPCAST",
279281
"youtube"
280282
]
281283
}

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

Whitespace-only changes.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { Account } from '@masknet/shared-base'
2+
import { fetchJSON } from '@masknet/web3-providers/helpers'
3+
import { FarcasterSession } from '@masknet/web3-providers'
4+
import urlcat from 'urlcat'
5+
6+
const FARCASTER_REPLY_URL = 'https://relay.farcaster.xyz'
7+
const NOT_DEPEND_SECRET = '[TO_BE_REPLACED_LATER]'
8+
9+
interface FarcasterReplyResponse {
10+
channelToken: string
11+
url: string
12+
// the same as url
13+
connectUri: string
14+
// cspell: disable-next-line
15+
/** @example dpO7VRkrPcwyLhyFZ */
16+
nonce: string
17+
}
18+
19+
async function createSession(signal?: AbortSignal) {
20+
const url = urlcat(FARCASTER_REPLY_URL, '/v1/channel')
21+
const response = await fetchJSON<FarcasterReplyResponse>(url, {
22+
method: 'POST',
23+
body: JSON.stringify({
24+
siteUri: 'https://www.mask.io',
25+
domain: 'www.mask.io',
26+
}),
27+
signal,
28+
})
29+
30+
const now = Date.now()
31+
const farcasterSession = new FarcasterSession(
32+
NOT_DEPEND_SECRET,
33+
NOT_DEPEND_SECRET,
34+
now,
35+
now,
36+
'',
37+
response.channelToken,
38+
)
39+
40+
return {
41+
deeplink: response.connectUri,
42+
session: farcasterSession,
43+
}
44+
}
45+
46+
export async function createAccountByRelayService(callback?: (url: string) => void, signal?: AbortSignal) {
47+
const { deeplink, session } = await createSession(signal)
48+
49+
// present QR code to the user or open the link in a new tab
50+
callback?.(deeplink)
51+
52+
// polling for the session to be ready
53+
const fireflySession = await bindOrRestoreFireflySession(session, signal)
54+
55+
// profile id is available after the session is ready
56+
const profile = await getFarcasterProfileById(session.profileId)
57+
58+
return {
59+
origin: 'sync',
60+
session,
61+
profile,
62+
fireflySession,
63+
} satisfies Account
64+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import urlcat from 'urlcat'
2+
3+
import { SessionType, type Session } from '../types/Session.js'
4+
import { fetchJSON } from '../helpers/fetchJSON.js'
5+
import { BaseSession } from '../Session/Session.js'
6+
7+
export const WARPCAST_ROOT_URL_V2 = 'https://api.warpcast.com/v2'
8+
export const FAKE_SIGNER_REQUEST_TOKEN = 'fake_signer_request_token'
9+
10+
export enum FarcasterSponsorship {
11+
Firefly = 'firefly',
12+
}
13+
14+
export class FarcasterSession extends BaseSession implements Session {
15+
constructor(
16+
/**
17+
* Fid
18+
*/
19+
profileId: string,
20+
/**
21+
* the private key of the signer
22+
*/
23+
token: string,
24+
createdAt: number,
25+
expiresAt: number,
26+
public signerRequestToken?: string,
27+
public channelToken?: string,
28+
public sponsorshipSignature?: string,
29+
public walletAddress?: string,
30+
) {
31+
super(SessionType.Farcaster, profileId, token, createdAt, expiresAt)
32+
}
33+
34+
override serialize(): `${SessionType}:${string}:${string}:${string}` {
35+
return [
36+
super.serialize(),
37+
this.signerRequestToken ?? '',
38+
this.channelToken ?? '',
39+
this.sponsorshipSignature ?? '',
40+
].join(':') as `${SessionType}:${string}:${string}:${string}`
41+
}
42+
43+
refresh(): Promise<void> {
44+
throw new Error('Not allowed')
45+
}
46+
47+
async destroy(): Promise<void> {
48+
const url = urlcat(WARPCAST_ROOT_URL_V2, '/auth')
49+
const response = await fetchJSON<{
50+
result: {
51+
success: boolean
52+
}
53+
}>(url, {
54+
method: 'DELETE',
55+
headers: {
56+
Authorization: `Bearer ${this.token}`,
57+
},
58+
body: JSON.stringify({
59+
method: 'revokeToken',
60+
params: {
61+
timestamp: this.createdAt,
62+
},
63+
}),
64+
})
65+
66+
// indicate the session is destroyed
67+
this.expiresAt = 0
68+
69+
if (!response.result.success) throw new Error('Failed to destroy the session.')
70+
return
71+
}
72+
73+
static isGrantByPermission(
74+
session: Session | null,
75+
// if strict is true, the session must have a valid signer request token
76+
strict = false,
77+
): session is FarcasterSession & { signerRequestToken: string } {
78+
if (!session) return false
79+
const token = (session as FarcasterSession).signerRequestToken
80+
return (
81+
session.type === SessionType.Farcaster &&
82+
!!token &&
83+
// strict mode
84+
(strict ? token !== FAKE_SIGNER_REQUEST_TOKEN : true)
85+
)
86+
}
87+
88+
static isSponsorship(
89+
session: Session | null,
90+
strict = false,
91+
): session is FarcasterSession & { signerRequestToken: string; sponsorshipSignature: string } {
92+
if (!session) return false
93+
return (
94+
FarcasterSession.isGrantByPermission(session, strict) &&
95+
!!(session as FarcasterSession).sponsorshipSignature
96+
)
97+
}
98+
99+
static isRelayService(session: Session | null): session is FarcasterSession & { channelToken: string } {
100+
if (!session) return false
101+
return session.type === 'Farcaster' && !!(session as FarcasterSession).channelToken
102+
}
103+
104+
static isLoginByWallet(session: Session | null): session is FarcasterSession & { walletAddress: string } {
105+
if (!session) return false
106+
return session.type === 'Farcaster' && !!(session as FarcasterSession).walletAddress
107+
}
108+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { BaseSession } from '../Session/Session'
2+
import { SessionType, type Session } from '../types/Session'
3+
4+
export const WARPCAST_ROOT_URL_V2 = 'https://api.warpcast.com/v2'
5+
export const FAKE_SIGNER_REQUEST_TOKEN = 'fake_signer_request_token'
6+
7+
export enum FarcasterSponsorship {
8+
Firefly = 'firefly',
9+
}
10+
11+
export type FireflySessionSignature = {
12+
address: string
13+
message: string
14+
signature: string
15+
}
16+
17+
export type FireflySessionPayload = {
18+
/**
19+
* indicate a new firefly binding when it was created
20+
*/
21+
isNew?: boolean
22+
23+
/**
24+
* numeric user ID
25+
*/
26+
uid?: string
27+
/**
28+
* UUID of the user
29+
*/
30+
accountId?: string
31+
avatar?: string | null
32+
displayName?: string | null
33+
}
34+
35+
export class FireflySession extends BaseSession implements Session {
36+
constructor(
37+
accountId: string,
38+
accessToken: string,
39+
public parent: Session | null,
40+
public signature: FireflySessionSignature | null,
41+
/**
42+
* @deprecated
43+
* This field always false. Use `payload.isNew` instead
44+
*/
45+
public isNew?: boolean,
46+
public payload?: FireflySessionPayload,
47+
) {
48+
super(SessionType.Firefly, accountId, accessToken, 0, 0)
49+
}
50+
51+
/**
52+
* For users after this patch use accountId in UUID format for events.
53+
* For legacy users use profileId in numeric format for events.
54+
*/
55+
get accountIdForEvent() {
56+
return this.payload?.accountId ?? this.profileId
57+
}
58+
59+
override serialize(): `${SessionType}:${string}:${string}:${string}` {
60+
return [
61+
super.serialize(),
62+
// parent session
63+
this.parent ? btoa(this.parent.serialize()) : '',
64+
// signature if session created by signing a message
65+
this.signature ? encodeAsciiPayload(this.signature) : '',
66+
// isNew flag
67+
this.isNew ? '1' : '0',
68+
// extra data payload
69+
this.payload ? encodeNoAsciiPayload(this.payload) : '',
70+
].join(':') as `${SessionType}:${string}:${string}:${string}`
71+
}
72+
73+
override async refresh(): Promise<void> {
74+
// throw new NotAllowedError()
75+
throw new Error('Not allowed')
76+
}
77+
78+
override async destroy(): Promise<void> {
79+
// throw new NotAllowedError()
80+
throw new Error('Not allowed')
81+
}
82+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { NextFetchersOptions } from '@/helpers/getNextFetchers.js'
2+
import type { FireflySession } from './Session.js'
3+
import { fetchJSON } from '../helpers/fetchJSON.js'
4+
import { SessionHolder } from '../Session/SessionHolder.js'
5+
6+
class FireflySessionHolder extends SessionHolder<FireflySession> {
7+
fetchWithSessionGiven(session: FireflySession) {
8+
return <T>(url: string, init?: RequestInit) => {
9+
return fetchJSON<T>(url, {
10+
...init,
11+
headers: { ...init?.headers, Authorization: `Bearer ${session.token}` },
12+
})
13+
}
14+
}
15+
16+
override async fetchWithSession<T>(url: string, init?: RequestInit, options?: NextFetchersOptions) {
17+
const authToken = this.sessionRequired.token
18+
19+
return fetchJSON<T>(
20+
url,
21+
{
22+
...init,
23+
headers: { ...init?.headers, Authorization: `Bearer ${authToken}` },
24+
},
25+
options,
26+
)
27+
}
28+
29+
override fetchWithoutSession<T>(url: string, init?: RequestInit, options?: NextFetchersOptions) {
30+
return fetchJSON<T>(url, init, options)
31+
}
32+
}
33+
34+
export const fireflySessionHolder = new FireflySessionHolder()

packages/web3-providers/src/Firefly/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@ export * from './Config.js'
22
export * from './RedPacket.js'
33
export * from './Twitter.js'
44
export * from './Farcaster.js'
5+
export * from './Session.js'
6+
export * from './FarcasterSession.js'
57
export { FIREFLY_SITE_URL } from './constants.js'
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { type SessionType, type Session } from '../types/Session.js'
2+
3+
export abstract class BaseSession implements Session {
4+
constructor(
5+
public type: SessionType,
6+
public profileId: string,
7+
public token: string,
8+
public createdAt: number,
9+
public expiresAt: number,
10+
) {}
11+
12+
serialize(): `${SessionType}:${string}` {
13+
const body = JSON.stringify({
14+
type: this.type,
15+
token: this.token,
16+
profileId: this.profileId,
17+
createdAt: this.createdAt,
18+
expiresAt: this.expiresAt,
19+
})
20+
21+
return `${this.type}:${btoa(body)}`
22+
}
23+
24+
abstract refresh(): Promise<void>
25+
26+
abstract destroy(): Promise<void>
27+
}

0 commit comments

Comments
 (0)