diff --git a/packages/thirdweb/src/wallets/embedded/core/authentication/index.ts b/packages/thirdweb/src/wallets/embedded/core/authentication/index.ts new file mode 100644 index 00000000000..1c7c53cb264 --- /dev/null +++ b/packages/thirdweb/src/wallets/embedded/core/authentication/index.ts @@ -0,0 +1,118 @@ +import type { ThirdwebClient } from "../../../../index.js"; +import type { AuthArgsType, PreAuthArgsType } from "./type.js"; +import { AuthProvider } from "../../implementations/interfaces/auth.js"; +import { UserWalletStatus } from "../../implementations/interfaces/embedded-wallets/embedded-wallets.js"; +import type { EmbeddedWalletSdk } from "../../implementations/lib/embedded-wallet.js"; + +const ewsSDKCache = new Map(); + +/** + * @internal + */ +async function getEmbeddedWalletSDK(client: ThirdwebClient) { + if (ewsSDKCache.has(client)) { + return ewsSDKCache.get(client) as EmbeddedWalletSdk; + } + const { EmbeddedWalletSdk } = await import( + "../../implementations/lib/embedded-wallet.js" + ); + // TODO (ew) cache this + const ewSDK = new EmbeddedWalletSdk({ + client: client, + }); + ewsSDKCache.set(client, ewSDK); + return ewSDK; +} + +/** + * @internal + */ +export async function getAuthenticatedUser(args: { client: ThirdwebClient }) { + const { client } = args; + const ewSDK = await getEmbeddedWalletSDK(client); + const user = await ewSDK.getUser(); + switch (user.status) { + case UserWalletStatus.LOGGED_IN_WALLET_INITIALIZED: { + return user; + } + } + return undefined; +} + +/** + * @internal + */ +export async function preAuthenticate(args: PreAuthArgsType) { + const ewSDK = await getEmbeddedWalletSDK(args.client); + const strategy = args.strategy; + switch (strategy) { + case "email": { + return ewSDK.auth.sendEmailLoginOtp({ email: args.email }); + } + default: + throw new Error( + `Provider: ${strategy} doesnt require pre-authentication`, + ); + } +} + +/** + * @internal + */ +export async function authenticate(args: AuthArgsType) { + const ewSDK = await getEmbeddedWalletSDK(args.client); + const strategy = args.strategy; + switch (strategy) { + case "email": { + return await ewSDK.auth.verifyEmailLoginOtp({ + email: args.email, + otp: args.verificationCode, + }); + } + case "apple": + case "facebook": + case "google": { + const oauthProvider = oauthStrategyToAuthProvider[strategy]; + return ewSDK.auth.loginWithOauth({ + oauthProvider, + closeOpenedWindow: args.closeOpenedWindow, + openedWindow: args.openedWindow, + }); + } + case "jwt": { + return ewSDK.auth.loginWithCustomJwt({ + jwt: args.jwt, + encryptionKey: args.encryptionKey, + }); + } + case "auth_endpoint": { + return ewSDK.auth.loginWithCustomAuthEndpoint({ + payload: args.payload, + encryptionKey: args.encryptionKey, + }); + } + case "iframe_email_verification": { + return ewSDK.auth.loginWithEmailOtp({ + email: args.email, + }); + } + case "iframe": { + return ewSDK.auth.loginWithModal(); + } + default: + assertUnreachable(strategy); + } +} + +function assertUnreachable(x: never): never { + throw new Error("Invalid param: " + x); +} + +const oauthStrategyToAuthProvider: Record< + "google" | "facebook" | "apple", + AuthProvider +> = { + google: AuthProvider.GOOGLE, + facebook: AuthProvider.FACEBOOK, + apple: AuthProvider.APPLE, +}; diff --git a/packages/thirdweb/src/wallets/embedded/core/authentication/type.ts b/packages/thirdweb/src/wallets/embedded/core/authentication/type.ts new file mode 100644 index 00000000000..73a7207cfeb --- /dev/null +++ b/packages/thirdweb/src/wallets/embedded/core/authentication/type.ts @@ -0,0 +1,37 @@ +import type { ThirdwebClient } from "../../../../client/client.js"; + +export type MultiStepAuthProviderType = { + strategy: "email"; + email: string; +}; +export type PreAuthArgsType = MultiStepAuthProviderType & { + client: ThirdwebClient; +}; + +export type MultiStepAuthArgsType = MultiStepAuthProviderType & { + verificationCode: string; +}; +export type SingleStepAuthArgsType = + | { + strategy: "google"; + openedWindow?: Window; + closeOpenedWindow?: (window: Window) => void; + } + | { + strategy: "apple"; + openedWindow?: Window; + closeOpenedWindow?: (window: Window) => void; + } + | { + strategy: "facebook"; + openedWindow?: Window; + closeOpenedWindow?: (window: Window) => void; + } + | { strategy: "jwt"; jwt: string; encryptionKey: string } + | { strategy: "auth_endpoint"; payload: string; encryptionKey: string } + | { strategy: "iframe_email_verification"; email: string } + | { strategy: "iframe" }; + +export type AuthArgsType = (MultiStepAuthArgsType | SingleStepAuthArgsType) & { + client: ThirdwebClient; +}; diff --git a/packages/thirdweb/src/wallets/embedded/core/wallet/index.ts b/packages/thirdweb/src/wallets/embedded/core/wallet/index.ts new file mode 100644 index 00000000000..44b3a1a7d14 --- /dev/null +++ b/packages/thirdweb/src/wallets/embedded/core/wallet/index.ts @@ -0,0 +1,100 @@ +import { + defineChain, + type Chain, + type ThirdwebClient, +} from "../../../../index.js"; +import type { Account, Wallet } from "../../../interfaces/wallet.js"; +import type { WalletMetadata } from "../../../types.js"; +import type { + MultiStepAuthArgsType, + PreAuthArgsType, + SingleStepAuthArgsType, +} from "../authentication/type.js"; +import { + authenticate, + getAuthenticatedUser, + preAuthenticate, +} from "../authentication/index.js"; +import type { EmbeddedWalletConfig } from "./types.js"; + +/** + * Embedded Wallet + * @param args - The args to use for the wallet + * @param args.client - The ThirdwebClient to use for the wallet + * @returns The embedded wallet + * @example + * ```ts + * import { embeddedWallet } from "thirdweb/wallets"; + * + * const wallet = embeddedWallet({ + * client, + * }); + * await wallet.connect({ + * strategy: "google", + * }); + * ``` + */ +export function embeddedWallet(args: EmbeddedWalletConfig) { + return new EmbeddedWallet(args); +} + +class EmbeddedWallet implements Wallet { + metadata: WalletMetadata = { + id: "embedded-wallet", + name: "Embedded Wallet", + iconUrl: "", // TODO (ew) + }; + client: ThirdwebClient; + account?: Account; + chain: Chain; + + constructor(args: EmbeddedWalletConfig) { + this.client = args.client; + this.chain = args.defaultChain ?? defineChain(1); + } + + async preAuthenticate(options: Omit) { + return preAuthenticate({ + client: this.client, + ...options, + }); + } + + async connect( + options: MultiStepAuthArgsType | SingleStepAuthArgsType, + ): Promise { + const authResult = await authenticate({ + client: this.client, + ...options, + }); + const authAccount = await authResult.user.wallet.getAccount(); + this.account = authAccount; + return authAccount; + } + + async autoConnect(): Promise { + const user = await getAuthenticatedUser({ client: this.client }); + if (!user) { + throw new Error("not authenticated"); + } + const authAccount = await user.wallet.getAccount(); + this.account = authAccount; + return authAccount; + } + + async disconnect(): Promise { + this.account = undefined; + } + + getAccount(): Account | undefined { + return this.account; + } + + getChain() { + return this.chain; + } + + async switchChain(newChain: Chain) { + this.chain = newChain; + } +} diff --git a/packages/thirdweb/src/wallets/embedded/core/wallet/types.ts b/packages/thirdweb/src/wallets/embedded/core/wallet/types.ts new file mode 100644 index 00000000000..d73167e1205 --- /dev/null +++ b/packages/thirdweb/src/wallets/embedded/core/wallet/types.ts @@ -0,0 +1,7 @@ +import type { Chain } from "../../../../chains/types.js"; +import type { ThirdwebClient } from "../../../../client/client.js"; + +export type EmbeddedWalletConfig = { + client: ThirdwebClient; + defaultChain?: Chain; +}; diff --git a/packages/thirdweb/src/wallets/embedded/implementations/constants/settings.ts b/packages/thirdweb/src/wallets/embedded/implementations/constants/settings.ts new file mode 100644 index 00000000000..4a547a96ced --- /dev/null +++ b/packages/thirdweb/src/wallets/embedded/implementations/constants/settings.ts @@ -0,0 +1,68 @@ +/** + * @internal + */ +export const EMBEDDED_WALLET_PATH = "/sdk/2022-08-12/embedded-wallet"; + +/** + * @internal + */ +export const HEADLESS_GOOGLE_OAUTH_ROUTE = `/auth/headless-google-login-managed`; +/** + * @internal + */ +export const GET_IFRAME_BASE_URL = () => { + if ( + !!( + typeof window !== "undefined" && + localStorage.getItem("IS_THIRDWEB_DEV") === "true" + ) + ) { + return ( + window.localStorage.getItem("THIRDWEB_DEV_URL") ?? "http://localhost:3000" + ); + } + + return `https://embedded-wallet.thirdweb.com`; +}; +/** + * @internal + */ +export const WALLET_USER_DETAILS_LOCAL_STORAGE_NAME = (clientId: string) => + `thirdwebEwsWalletUserDetails-${clientId}`; + +/** + * @internal + */ +export const WALLET_USER_ID_LOCAL_STORAGE_NAME = (clientId: string) => + `thirdwebEwsWalletUserId-${clientId}`; + +/** + * @internal + */ +const AUTH_TOKEN_LOCAL_STORAGE_PREFIX = "walletToken"; + +/** + * @internal + */ +export const AUTH_TOKEN_LOCAL_STORAGE_NAME = (clientId: string) => { + return `${AUTH_TOKEN_LOCAL_STORAGE_PREFIX}-${clientId}`; +}; + +/** + * @internal + */ +const DEVICE_SHARE_LOCAL_STORAGE_PREFIX = "a"; + +/** + * @internal + */ +export const DEVICE_SHARE_LOCAL_STORAGE_NAME = ( + clientId: string, + userId: string, +) => `${DEVICE_SHARE_LOCAL_STORAGE_PREFIX}-${clientId}-${userId}`; + +/** + * @internal + */ +export const DEVICE_SHARE_LOCAL_STORAGE_NAME_DEPRECATED = (clientId: string) => + `${DEVICE_SHARE_LOCAL_STORAGE_PREFIX}-${clientId}`; diff --git a/packages/thirdweb/src/wallets/embedded/implementations/index.ts b/packages/thirdweb/src/wallets/embedded/implementations/index.ts new file mode 100644 index 00000000000..efa78f827f0 --- /dev/null +++ b/packages/thirdweb/src/wallets/embedded/implementations/index.ts @@ -0,0 +1,34 @@ +export { + AUTH_TOKEN_LOCAL_STORAGE_NAME, + DEVICE_SHARE_LOCAL_STORAGE_NAME, + DEVICE_SHARE_LOCAL_STORAGE_NAME_DEPRECATED, + WALLET_USER_DETAILS_LOCAL_STORAGE_NAME, + WALLET_USER_ID_LOCAL_STORAGE_NAME, +} from "./constants/settings.js"; +export { AuthProvider, RecoveryShareManagement } from "./interfaces/auth.js"; +export type { + AuthAndWalletRpcReturnType, + AuthLoginReturnType, + AuthStoredTokenWithCookieReturnType, + StoredTokenType, + GetHeadlessLoginLinkReturnType, +} from "./interfaces/auth.js"; +export { UserWalletStatus } from "./interfaces/embedded-wallets/embedded-wallets.js"; +export type { + AuthDetails, + EmbeddedWalletConstructorType, + GetAuthDetailsReturnType, + GetUser, + GetUserWalletStatusRpcReturnType, + InitializedUser, + LogoutReturnType, + SendEmailOtpReturnType, + SetUpWalletRpcReturnType, +} from "./interfaces/embedded-wallets/embedded-wallets.js"; +export type { + GetAddressReturnType, + SignMessageReturnType, + SignTransactionReturnType, + SignedTypedDataReturnType, +} from "./interfaces/embedded-wallets/signer.js"; +export { EmbeddedWalletSdk } from "./lib/embedded-wallet.js"; diff --git a/packages/thirdweb/src/wallets/embedded/implementations/interfaces/auth.ts b/packages/thirdweb/src/wallets/embedded/implementations/interfaces/auth.ts new file mode 100644 index 00000000000..1580405f61b --- /dev/null +++ b/packages/thirdweb/src/wallets/embedded/implementations/interfaces/auth.ts @@ -0,0 +1,49 @@ +import type { + AuthDetails, + InitializedUser, + SetUpWalletRpcReturnType, +} from "./embedded-wallets/embedded-wallets.js"; + +export enum RecoveryShareManagement { + USER_MANAGED = "USER_MANAGED", + CLOUD_MANAGED = "AWS_MANAGED", +} + +export enum AuthProvider { + COGNITO = "Cognito", + GOOGLE = "Google", + EMAIL_OTP = "EmailOtp", + CUSTOM_JWT = "CustomJWT", + CUSTOM_AUTH_ENDPOINT = "CustomAuthEndpoint", + FACEBOOK = "Facebook", + APPLE = "Apple", +} + +/** + * @internal + */ +export type GetHeadlessLoginLinkReturnType = { + loginLink: string; +}; + +// TODO: Clean up tech debt of random type Objects +// E.g. StoredTokenType is really not used anywhere but it exists as this object for legacy reason +export type StoredTokenType = { + jwtToken: string; + authProvider: AuthProvider; + authDetails: AuthDetails; + developerClientId: string; +}; + +export type AuthStoredTokenWithCookieReturnType = { + storedToken: StoredTokenType & { + cookieString: string; + shouldStoreCookieString: boolean; + isNewUser: boolean; + }; +}; +export type AuthAndWalletRpcReturnType = AuthStoredTokenWithCookieReturnType & { + walletDetails: SetUpWalletRpcReturnType; +}; + +export type AuthLoginReturnType = { user: InitializedUser }; diff --git a/packages/thirdweb/src/wallets/embedded/implementations/interfaces/embedded-wallets/embedded-wallets.ts b/packages/thirdweb/src/wallets/embedded/implementations/interfaces/embedded-wallets/embedded-wallets.ts new file mode 100644 index 00000000000..0baa975b971 --- /dev/null +++ b/packages/thirdweb/src/wallets/embedded/implementations/interfaces/embedded-wallets/embedded-wallets.ts @@ -0,0 +1,121 @@ +import type { ThirdwebClient } from "../../../../../index.js"; +import type { EmbeddedWallet } from "../../lib/core/embedded-wallet.js"; +import type { EmbeddedWalletIframeCommunicator } from "../../utils/iFrameCommunication/EmbeddedWalletIframeCommunicator.js"; +import type { + AuthAndWalletRpcReturnType, + RecoveryShareManagement, +} from "../auth.js"; + +// Class constructor types +// types for class constructors still a little messy right now. +// Open to PRs from whoever sees this and knows of a cleaner way to handle things +export type ClientIdConstructorType = { + /** + * the clientId found on the dashboard settings {@link https://thirdweb.com/dashboard/settings} + */ + client: ThirdwebClient; +}; +export type EmbeddedWalletConstructorType = ClientIdConstructorType & { + /** + * @param authResult - The authResult returned from the EmbeddedWalletSdk auth method + * @returns + */ + onAuthSuccess?: (authResult: AuthAndWalletRpcReturnType) => void; +}; + +export type ClientIdWithQuerierType = ClientIdConstructorType & { + querier: EmbeddedWalletIframeCommunicator; +}; + +// Auth Types +export type AuthDetails = { + email?: string; + userWalletId: string; + encryptionKey?: string; + backupRecoveryCodes?: string[]; + recoveryShareManagement: RecoveryShareManagement; +}; + +export type InitializedUser = { + status: UserWalletStatus.LOGGED_IN_WALLET_INITIALIZED; + wallet: EmbeddedWallet; + walletAddress: string; + authDetails: AuthDetails; +}; + +// Embedded Wallet Types +export enum UserWalletStatus { + LOGGED_OUT = "Logged Out", + LOGGED_IN_WALLET_UNINITIALIZED = "Logged In, Wallet Uninitialized", + LOGGED_IN_NEW_DEVICE = "Logged In, New Device", + LOGGED_IN_WALLET_INITIALIZED = "Logged In, Wallet Initialized", +} + +export type WalletAddressObjectType = { + /** + * User's wallet address + */ + walletAddress: string; +}; + +export type SetUpWalletRpcReturnType = WalletAddressObjectType & { + /** + * the value that is saved for the user's device share. + * We save this into the localStorage on the site itself if we could not save it within the iframe's localStorage. + * This happens in incognito mostly + */ + deviceShareStored: string; + /** + * Tells us if we were able to store values in the localStorage in our iframe. + * We need to store it under the dev's domain localStorage if we weren't able to store things in the iframe + */ + isIframeStorageEnabled: boolean; +}; + +export type SendEmailOtpReturnType = { + isNewUser: boolean; + isNewDevice: boolean; + recoveryShareManagement: RecoveryShareManagement; +}; +export type LogoutReturnType = { success: boolean }; + +/** + * @internal + */ +export type GetAuthDetailsReturnType = { authDetails?: AuthDetails }; + +// ! Types seem repetitive, but the name should identify which goes where +// this is the return type from the EmbeddedWallet Class getUserWalletStatus method iframe call +export type GetUserWalletStatusRpcReturnType = + | { + status: UserWalletStatus.LOGGED_OUT; + user: undefined; + } + | { + status: UserWalletStatus.LOGGED_IN_WALLET_UNINITIALIZED; + user: { authDetails: AuthDetails }; + } + | { + status: UserWalletStatus.LOGGED_IN_NEW_DEVICE; + user: { authDetails: AuthDetails; walletAddress: string }; + } + | { + status: UserWalletStatus.LOGGED_IN_WALLET_INITIALIZED; + user: Omit; + }; + +// this is the return type from the EmbeddedWallet Class getUserWalletStatus method +export type GetUser = + | { + status: UserWalletStatus.LOGGED_OUT; + } + | { + status: UserWalletStatus.LOGGED_IN_WALLET_UNINITIALIZED; + authDetails: AuthDetails; + } + | { + status: UserWalletStatus.LOGGED_IN_NEW_DEVICE; + authDetails: AuthDetails; + walletAddress: string; + } + | InitializedUser; diff --git a/packages/thirdweb/src/wallets/embedded/implementations/interfaces/embedded-wallets/signer.ts b/packages/thirdweb/src/wallets/embedded/implementations/interfaces/embedded-wallets/signer.ts new file mode 100644 index 00000000000..eb5314eee4e --- /dev/null +++ b/packages/thirdweb/src/wallets/embedded/implementations/interfaces/embedded-wallets/signer.ts @@ -0,0 +1,11 @@ +/** + * @internal + */ +export type GetAddressReturnType = { address: string }; +export type SignMessageReturnType = { signedMessage: string }; +export type SignTransactionReturnType = { + signedTransaction: string; +}; +export type SignedTypedDataReturnType = { + signedTypedData: string; +}; diff --git a/packages/thirdweb/src/wallets/embedded/implementations/lib/auth/abstract-login.ts b/packages/thirdweb/src/wallets/embedded/implementations/lib/auth/abstract-login.ts new file mode 100644 index 00000000000..2749157dcaa --- /dev/null +++ b/packages/thirdweb/src/wallets/embedded/implementations/lib/auth/abstract-login.ts @@ -0,0 +1,103 @@ +import type { ThirdwebClient } from "../../../../../client/client.js"; +import type { + AuthAndWalletRpcReturnType, + AuthLoginReturnType, + AuthProvider, +} from "../../interfaces/auth.js"; +import type { + ClientIdWithQuerierType, + SendEmailOtpReturnType, +} from "../../interfaces/embedded-wallets/embedded-wallets.js"; +import type { EmbeddedWalletIframeCommunicator } from "../../utils/iFrameCommunication/EmbeddedWalletIframeCommunicator.js"; + +export type LoginQuerierTypes = { + loginWithCustomAuthEndpoint: { payload: string; encryptionKey: string }; + loginWithCustomJwt: { jwt: string; encryptionKey?: string }; + loginWithThirdwebModal: undefined | { email: string }; + sendThirdwebEmailLoginOtp: { email: string }; + verifyThirdwebEmailLoginOtp: { + email: string; + otp: string; + recoveryCode?: string; + }; + injectDeveloperClientId: void; + getHeadlessOauthLoginLink: { authProvider: AuthProvider }; +}; + +type OauthLoginType = { + openedWindow?: Window | null; + closeOpenedWindow?: (openedWindow: Window) => void; +}; + +/** + * @internal + */ +export abstract class AbstractLogin< + MODAL = void, + EMAIL_MODAL extends { email: string } = { email: string }, + EMAIL_VERIFICATION extends { email: string; otp: string } = { + email: string; + otp: string; + recoveryCode?: string; + }, +> { + protected LoginQuerier: EmbeddedWalletIframeCommunicator; + protected preLogin; + protected postLogin: ( + authResults: AuthAndWalletRpcReturnType, + ) => Promise; + protected client: ThirdwebClient; + /** + * Used to manage the user's auth states. This should not be instantiated directly. + * Call {@link EmbeddedWalletSdk.auth} instead. + * @internal + */ + constructor({ + querier, + preLogin, + postLogin, + client, + }: ClientIdWithQuerierType & { + preLogin: () => Promise; + postLogin: ( + authDetails: AuthAndWalletRpcReturnType, + ) => Promise; + }) { + this.LoginQuerier = querier; + this.preLogin = preLogin; + this.postLogin = postLogin; + this.client = client; + } + + abstract loginWithCustomJwt(args: { + jwt: string; + encryptionKey: string; + }): Promise; + abstract loginWithCustomAuthEndpoint(args: { + payload: string; + encryptionKey: string; + }): Promise; + abstract loginWithModal(args?: MODAL): Promise; + abstract loginWithEmailOtp(args: EMAIL_MODAL): Promise; + abstract loginWithOauth( + args: OauthLoginType & { oauthProvider: AuthProvider }, + ): Promise; + + /** + * @internal + */ + async sendEmailLoginOtp({ + email, + }: LoginQuerierTypes["sendThirdwebEmailLoginOtp"]): Promise { + await this.preLogin(); + const result = await this.LoginQuerier.call({ + procedureName: "sendThirdwebEmailLoginOtp", + params: { email }, + }); + return result; + } + + abstract verifyEmailLoginOtp( + args: EMAIL_VERIFICATION, + ): Promise; +} diff --git a/packages/thirdweb/src/wallets/embedded/implementations/lib/auth/base-login.ts b/packages/thirdweb/src/wallets/embedded/implementations/lib/auth/base-login.ts new file mode 100644 index 00000000000..28cf050930c --- /dev/null +++ b/packages/thirdweb/src/wallets/embedded/implementations/lib/auth/base-login.ts @@ -0,0 +1,239 @@ +import { GET_IFRAME_BASE_URL } from "../../constants/settings.js"; +import { + AuthProvider, + type AuthAndWalletRpcReturnType, + type AuthLoginReturnType, + type GetHeadlessLoginLinkReturnType, +} from "../../interfaces/auth.js"; +import { AbstractLogin, type LoginQuerierTypes } from "./abstract-login.js"; + +/** + * + */ +export class BaseLogin extends AbstractLogin< + void, + { email: string }, + { email: string; otp: string; recoveryCode?: string } +> { + private async getOauthLoginUrl( + authProvider: AuthProvider, + ): Promise { + const result = await this.LoginQuerier.call( + { + procedureName: "getHeadlessOauthLoginLink", + params: { authProvider }, + }, + ); + return result; + } + + /** + * @internal + */ + override async loginWithModal(): Promise { + await this.preLogin(); + const result = await this.LoginQuerier.call({ + procedureName: "loginWithThirdwebModal", + params: undefined, + showIframe: true, + }); + return this.postLogin(result); + } + + /** + * @internal + */ + override async loginWithEmailOtp({ + email, + }: { + email: string; + }): Promise { + await this.preLogin(); + const result = await this.LoginQuerier.call({ + procedureName: "loginWithThirdwebModal", + params: { email }, + showIframe: true, + }); + return this.postLogin(result); + } + + private closeWindow = ({ + isWindowOpenedByFn, + win, + closeOpenedWindow, + }: { + win?: Window | null; + isWindowOpenedByFn: boolean; + closeOpenedWindow?: (openedWindow: Window) => void; + }) => { + if (isWindowOpenedByFn) { + win?.close(); + } else { + if (win && closeOpenedWindow) { + closeOpenedWindow(win); + } else if (win) { + win.close(); + } + } + }; + + private getOauthPopUpSizing(authProvider: AuthProvider) { + switch (authProvider) { + case AuthProvider.FACEBOOK: + return "width=715, height=555"; + default: + return "width=350, height=500"; + } + } + + /** + * @internal + */ + override async loginWithOauth(args: { + oauthProvider: AuthProvider; + openedWindow?: Window | null | undefined; + closeOpenedWindow?: ((openedWindow: Window) => void) | undefined; + }): Promise { + let win = args?.openedWindow; + let isWindowOpenedByFn = false; + if (!win) { + win = window.open( + "", + "Login", + this.getOauthPopUpSizing(args.oauthProvider), + ); + isWindowOpenedByFn = true; + } + if (!win) { + throw new Error("Something went wrong opening pop-up"); + } + // logout the user + // fetch the url to open the login window from iframe + const [{ loginLink }] = await Promise.all([ + this.getOauthLoginUrl(args.oauthProvider), + this.preLogin(), + ]); + win.location.href = loginLink; + // listen to result from the login window + const result = await new Promise( + (resolve, reject) => { + // detect when the user closes the login window + const pollTimer = window.setInterval(async () => { + if (!win) { + return; + } + if (win.closed) { + clearInterval(pollTimer); + window.removeEventListener("message", messageListener); + reject(new Error("User closed login window")); + } + }, 1000); + + const messageListener = async ( + event: MessageEvent<{ + eventType: string; + authResult?: AuthAndWalletRpcReturnType; + error?: string; + }>, + ) => { + if (event.origin !== GET_IFRAME_BASE_URL()) { + return; + } + if (typeof event.data !== "object") { + reject(new Error("Invalid event data")); + return; + } + + switch (event.data.eventType) { + case "userLoginSuccess": { + window.removeEventListener("message", messageListener); + clearInterval(pollTimer); + this.closeWindow({ + isWindowOpenedByFn, + win, + closeOpenedWindow: args?.closeOpenedWindow, + }); + if (event.data.authResult) { + resolve(event.data.authResult); + } + break; + } + case "userLoginFailed": { + window.removeEventListener("message", messageListener); + clearInterval(pollTimer); + this.closeWindow({ + isWindowOpenedByFn, + win, + closeOpenedWindow: args?.closeOpenedWindow, + }); + reject(new Error(event.data.error)); + break; + } + case "injectDeveloperClientId": { + win?.postMessage( + { + eventType: "injectDeveloperClientIdResult", + developerClientId: this.client.clientId, + authOption: args.oauthProvider, + }, + GET_IFRAME_BASE_URL(), + ); + break; + } + } + }; + window.addEventListener("message", messageListener); + }, + ); + + return this.postLogin({ + storedToken: { ...result.storedToken, shouldStoreCookieString: true }, + walletDetails: { ...result.walletDetails, isIframeStorageEnabled: false }, + }); + } + + /** + * @internal + */ + override async loginWithCustomJwt({ + encryptionKey, + jwt, + }: LoginQuerierTypes["loginWithCustomJwt"]): Promise { + await this.preLogin(); + const result = await this.LoginQuerier.call({ + procedureName: "loginWithCustomJwt", + params: { encryptionKey, jwt }, + }); + return this.postLogin(result); + } + + /** + * @internal + */ + override async loginWithCustomAuthEndpoint({ + encryptionKey, + payload, + }: LoginQuerierTypes["loginWithCustomAuthEndpoint"]): Promise { + await this.preLogin(); + const result = await this.LoginQuerier.call({ + procedureName: "loginWithCustomAuthEndpoint", + params: { encryptionKey, payload }, + }); + return this.postLogin(result); + } + + /** + * @internal + */ + override async verifyEmailLoginOtp({ + email, + otp, + recoveryCode, + }: LoginQuerierTypes["verifyThirdwebEmailLoginOtp"]): Promise { + const result = await this.LoginQuerier.call({ + procedureName: "verifyThirdwebEmailLoginOtp", + params: { email, otp, recoveryCode }, + }); + return this.postLogin(result); + } +} diff --git a/packages/thirdweb/src/wallets/embedded/implementations/lib/auth/index.ts b/packages/thirdweb/src/wallets/embedded/implementations/lib/auth/index.ts new file mode 100644 index 00000000000..08ed03d24b1 --- /dev/null +++ b/packages/thirdweb/src/wallets/embedded/implementations/lib/auth/index.ts @@ -0,0 +1,225 @@ +import type { ThirdwebClient } from "../../../../../index.js"; +import type { + AuthAndWalletRpcReturnType, + AuthLoginReturnType, +} from "../../interfaces/auth.js"; +import type { + ClientIdWithQuerierType, + LogoutReturnType, + SendEmailOtpReturnType, +} from "../../interfaces/embedded-wallets/embedded-wallets.js"; +import { LocalStorage } from "../../utils/Storage/LocalStorage.js"; +import type { EmbeddedWalletIframeCommunicator } from "../../utils/iFrameCommunication/EmbeddedWalletIframeCommunicator.js"; +import { BaseLogin } from "./base-login.js"; + +export type AuthQuerierTypes = { + logout: void; + initIframe: { + clientId: string; + authCookie: string; + walletUserId: string; + deviceShareStored: string; + }; +}; + +/** + * + */ +export class Auth { + protected client: ThirdwebClient; + protected AuthQuerier: EmbeddedWalletIframeCommunicator; + protected localStorage: LocalStorage; + protected onAuthSuccess: ( + authResults: AuthAndWalletRpcReturnType, + ) => Promise; + private BaseLogin: BaseLogin; + + /** + * Used to manage the user's auth states. This should not be instantiated directly. + * Call {@link EmbeddedWalletSdk.auth} instead. + * @internal + */ + constructor({ + client, + querier, + onAuthSuccess, + }: ClientIdWithQuerierType & { + onAuthSuccess: ( + authDetails: AuthAndWalletRpcReturnType, + ) => Promise; + }) { + this.client = client; + + this.AuthQuerier = querier; + this.localStorage = new LocalStorage({ clientId: client.clientId }); + this.onAuthSuccess = onAuthSuccess; + this.BaseLogin = new BaseLogin({ + postLogin: async (result) => { + return this.postLogin(result); + }, + preLogin: async () => { + await this.preLogin(); + }, + querier: querier, + client, + }); + } + + private async preLogin() { + await this.logout(); + } + + private async postLogin({ + storedToken, + walletDetails, + }: AuthAndWalletRpcReturnType): Promise { + if (storedToken.shouldStoreCookieString) { + await this.localStorage.saveAuthCookie(storedToken.cookieString); + } + const initializedUser = await this.onAuthSuccess({ + storedToken, + walletDetails, + }); + return initializedUser; + } + + /** + * Used to log the user into their thirdweb wallet on your platform via a myriad of auth providers + * @example + * ```typescript + * const thirdwebEmbeddedWallet = new EmbeddedWalletSdk({clientId: "YOUR_CLIENT_ID", chain: "Polygon"}) + * try { + * const user = await thirdwebEmbeddedWallet.auth.loginWithModal(); + * // user is now logged in + * } catch (e) { + * // User closed modal or something else went wrong during the authentication process + * console.error(e) + * } + * ``` + * @returns `{{user: InitializedUser}}` An InitializedUser object. + */ + async loginWithModal(): Promise { + return this.BaseLogin.loginWithModal(); + } + + /** + * Used to log the user into their thirdweb wallet using email OTP + * @example + * ```typescript + * // Basic Flow + * const thirdwebEmbeddedWallet = new EmbeddedWalletSdk({clientId: "", chain: "Polygon"}); + * try { + * // prompts user to enter the code they received + * const user = await thirdwebEmbeddedWallet.auth.loginWithThirdwebEmailOtp({ email : "you@example.com" }); + * // user is now logged in + * } catch (e) { + * // User closed the OTP modal or something else went wrong during the authentication process + * console.error(e) + * } + * ``` + * @param args - args.email: We will send the email an OTP that needs to be entered in order for them to be logged in. + * @returns `{{user: InitializedUser}}` An InitializedUser object. See {@link EmbeddedWalletSdk.getUser} for more + */ + async loginWithEmailOtp( + args: Parameters[0], + ): Promise { + return this.BaseLogin.loginWithEmailOtp(args); + } + + /** + * @internal + */ + async loginWithCustomJwt( + args: Parameters[0], + ): Promise { + return this.BaseLogin.loginWithCustomJwt(args); + } + + /** + * @internal + */ + async loginWithCustomAuthEndpoint( + args: Parameters[0], + ): Promise { + return this.BaseLogin.loginWithCustomAuthEndpoint(args); + } + + /** + * @internal + */ + async loginWithOauth( + args: Parameters[0], + ): Promise { + return this.BaseLogin.loginWithOauth(args); + } + + /** + * A headless way to send the users at the passed email an OTP code. + * You need to then call {@link Auth.verifyEmailLoginOtp} in order to complete the login process + * @example + * @param param0.email + * ```typescript + * const thirdwebEmbeddedWallet = new EmbeddedWalletSdk({clientId: "", chain: "Polygon"}); + * // sends user an OTP code + * try { + * await thirdwebEmbeddedWallet.auth.sendEmailLoginOtp({ email : "you@example.com" }); + * } catch(e) { + * // Error Sending user's email an OTP code + * console.error(e); + * } + * + * // Then when your user is ready to verify their OTP + * try { + * const user = await thirdwebEmbeddedWallet.auth.verifyEmailLoginOtp({ email: "you@example.com", otp: "6-DIGIT_CODE_HERE" }); + * } catch(e) { + * // Error verifying the OTP code + * console.error(e) + * } + * ``` + * @param param0 - param0.email We will send the email an OTP that needs to be entered in order for them to be logged in. + * @returns `{{ isNewUser: boolean }}` IsNewUser indicates if the user is a new user to your platform + * @internal + */ + async sendEmailLoginOtp({ + email, + }: Parameters< + BaseLogin["sendEmailLoginOtp"] + >[0]): Promise { + return this.BaseLogin.sendEmailLoginOtp({ + email, + }); + } + + /** + * Used to verify the otp that the user receives from thirdweb + * + * See {@link Auth.sendEmailLoginOtp} for how the headless call flow looks like. Simply swap out the calls to `loginWithThirdwebEmailOtp` with `verifyThirdwebEmailLoginOtp` + * @param args - props.email We will send the email an OTP that needs to be entered in order for them to be logged in. + * props.otp The code that the user received in their email + * @returns `{{user: InitializedUser}}` An InitializedUser object containing the user's status, wallet, authDetails, and more + * @internal + */ + async verifyEmailLoginOtp( + args: Parameters[0], + ) { + return this.BaseLogin.verifyEmailLoginOtp(args); + } + + /** + * Logs any existing user out of their wallet. + * @returns `{{success: boolean}}` true if a user is successfully logged out. false if there's no user currently logged in. + * @internal + */ + async logout(): Promise { + const { success } = await this.AuthQuerier.call({ + procedureName: "logout", + params: undefined, + }); + const isRemoveAuthCookie = await this.localStorage.removeAuthCookie(); + const isRemoveUserId = await this.localStorage.removeWalletUserId(); + + return { + success: success || isRemoveAuthCookie || isRemoveUserId, + }; + } +} diff --git a/packages/thirdweb/src/wallets/embedded/implementations/lib/core/embedded-wallet.ts b/packages/thirdweb/src/wallets/embedded/implementations/lib/core/embedded-wallet.ts new file mode 100644 index 00000000000..ce744036d20 --- /dev/null +++ b/packages/thirdweb/src/wallets/embedded/implementations/lib/core/embedded-wallet.ts @@ -0,0 +1,266 @@ +import type { + ClientIdWithQuerierType, + GetUser, + GetUserWalletStatusRpcReturnType, + SetUpWalletRpcReturnType, + WalletAddressObjectType, +} from "../../interfaces/embedded-wallets/embedded-wallets.js"; +import { UserWalletStatus } from "../../interfaces/embedded-wallets/embedded-wallets.js"; + +import { LocalStorage } from "../../utils/Storage/LocalStorage.js"; +import type { EmbeddedWalletIframeCommunicator } from "../../utils/iFrameCommunication/EmbeddedWalletIframeCommunicator.js"; +import type { Account } from "../../../../index.js"; +import type { + GetAddressReturnType, + SignMessageReturnType, + SignTransactionReturnType, + SignedTypedDataReturnType, +} from "../../interfaces/embedded-wallets/signer.js"; +import { getRpcClient } from "../../../../../rpc/rpc.js"; +import { defineChain } from "../../../../../chains/utils.js"; +import type { SendTransactionOption } from "../../../../interfaces/wallet.js"; +import type { Hex, TypedDataDefinition } from "viem"; +import type { ThirdwebClient } from "../../../../../index.js"; +import type * as ethers5 from "ethers5"; +import { eth_sendRawTransaction } from "../../../../../rpc/actions/eth_sendRawTransaction.js"; + +export type WalletManagementTypes = { + createWallet: void; + setUpNewDevice: void; + getUserStatus: void; +}; +export type WalletManagementUiTypes = { + createWalletUi: void; + setUpNewDeviceUi: void; +}; + +export type EmbeddedWalletInternalHelperType = { showUi: boolean }; + +export type SignerProcedureTypes = { + getAddress: void; + signMessage: { + message: string | Hex; + chainId: number; + rpcEndpoint?: string; + }; + signTransaction: { + transaction: ethers5.ethers.providers.TransactionRequest; + chainId: number; + rpcEndpoint?: string; + }; + signTypedDataV4: { + domain: TypedDataDefinition["domain"]; + types: TypedDataDefinition["types"]; + message: TypedDataDefinition["message"]; + chainId: number; + rpcEndpoint?: string; + }; + //connect: { provider: Provider }; +}; + +type PostWalletSetup = SetUpWalletRpcReturnType & { + walletUserId: string; +}; + +/** + * + */ +export class EmbeddedWallet { + protected client: ThirdwebClient; + protected walletManagerQuerier: EmbeddedWalletIframeCommunicator< + WalletManagementTypes & WalletManagementUiTypes + >; + protected localStorage: LocalStorage; + + /** + * Not meant to be initialized directly. Call {@link initializeUser} to get an instance + * @internal + */ + constructor({ client, querier }: ClientIdWithQuerierType) { + this.client = client; + this.walletManagerQuerier = querier; + + this.localStorage = new LocalStorage({ clientId: client.clientId }); + } + + /** + * Used to set-up the user device in the case that they are using incognito + * @returns `{walletAddress : string }` The user's wallet details + * @internal + */ + async postWalletSetUp({ + deviceShareStored, + walletAddress, + isIframeStorageEnabled, + walletUserId, + }: PostWalletSetup): Promise { + if (!isIframeStorageEnabled) { + await this.localStorage.saveDeviceShare(deviceShareStored, walletUserId); + } + return { walletAddress }; + } + + /** + * Gets the various status states of the user + * @example + * ```typescript + * const userStatus = await Paper.getUserWalletStatus(); + * switch (userStatus.status) { + * case UserWalletStatus.LOGGED_OUT: { + * // User is logged out, call one of the auth methods on Paper.auth to authenticate the user + * break; + * } + * case UserWalletStatus.LOGGED_IN_WALLET_UNINITIALIZED: { + * // User is logged in, but does not have a wallet associated with it + * // you also have access to the user's details + * userStatus.user.authDetails; + * break; + * } + * case UserWalletStatus.LOGGED_IN_NEW_DEVICE: { + * // User is logged in and created a wallet already, but is missing the device shard + * // You have access to: + * userStatus.user.authDetails; + * userStatus.user.walletAddress; + * break; + * } + * case UserWalletStatus.LOGGED_IN_WALLET_INITIALIZED: { + * // user is logged in and wallet is all set up. + * // You have access to: + * userStatus.user.authDetails; + * userStatus.user.walletAddress; + * userStatus.user.wallet; + * break; + * } + *} + *``` + * @returns `{GetUserWalletStatusFnReturnType}` an object to containing various information on the user statuses + * @internal + */ + async getUserWalletStatus(): Promise { + const userStatus = + await this.walletManagerQuerier.call({ + procedureName: "getUserStatus", + params: undefined, + }); + if (userStatus.status === UserWalletStatus.LOGGED_IN_WALLET_INITIALIZED) { + return { + status: UserWalletStatus.LOGGED_IN_WALLET_INITIALIZED, + ...userStatus.user, + wallet: this, + }; + } else if (userStatus.status === UserWalletStatus.LOGGED_IN_NEW_DEVICE) { + return { + status: UserWalletStatus.LOGGED_IN_WALLET_UNINITIALIZED, + ...userStatus.user, + }; + } else if ( + userStatus.status === UserWalletStatus.LOGGED_IN_WALLET_UNINITIALIZED + ) { + return { + status: UserWalletStatus.LOGGED_IN_WALLET_UNINITIALIZED, + ...userStatus.user, + }; + } else { + // Logged out + return { status: userStatus.status }; + } + } + + /** + * Returns an Ethers.Js compatible signer that you can use in conjunction with the rest of dApp + * @param network.rpcEndpoint + * @example + * ```typescript + * const Paper = new ThirdwebEmbeddedWalletSdk({clientId: "", chain: "Polygon"}); + * const user = await Paper.getUser(); + * if (user.status === UserStatus.LOGGED_IN_WALLET_INITIALIZED) { + * // returns a signer on the Polygon mainnet + * const signer = await user.getEthersJsSigner(); + * // returns a signer on the specified RPC endpoints + * const signer = await user.getEthersJsSigner({rpcEndpoint: "https://eth-rpc.gateway.pokt.network"}); + * } + * ``` + * @param network - object with the rpc url where calls will be routed through + * @throws If attempting to call the function without the user wallet initialize on their current device. This should never happen if call {@link ThirdwebEmbeddedWalletSdk.initializeUser} before accessing this function + * @returns A signer that is compatible with Ether.js. Defaults to the public rpc on the chain specified when initializing the {@link ThirdwebEmbeddedWalletSdk} instance + * @internal + * + */ + async getAccount(): Promise { + const querier = this + .walletManagerQuerier as unknown as EmbeddedWalletIframeCommunicator; + const { address } = await querier.call({ + procedureName: "getAddress", + params: undefined, + }); + const signTransaction = async (tx: SendTransactionOption) => { + const { signedTransaction } = + await querier.call({ + procedureName: "signTransaction", + params: { + transaction: { + to: tx.to ?? undefined, + data: tx.data, + value: tx.value, + gasLimit: tx.gas, + gasPrice: tx.gasPrice, + nonce: tx.nonce, + chainId: tx.chainId, + accessList: tx.accessList, + maxFeePerGas: tx.maxFeePerGas, + maxPriorityFeePerGas: tx.maxPriorityFeePerGas, + type: tx.maxFeePerGas ? 2 : 0, + }, + chainId: tx.chainId, + rpcEndpoint: `https://${tx.chainId}.rpc.thirdweb.com`, // TODO (ew) shouldnt be needed + }, + }); + return signedTransaction as Hex; + }; + const client = this.client; + return { + address, + async sendTransaction(tx) { + const rpcRequest = getRpcClient({ + client, + chain: defineChain(tx.chainId), + }); + const signedTx = await signTransaction(tx); + const transactionHash = await eth_sendRawTransaction( + rpcRequest, + signedTx, + ); + return { + transactionHash, + }; + }, + async signMessage({ message }) { + const messageDecoded = + typeof message === "string" ? message : message.raw; + const { signedMessage } = await querier.call({ + procedureName: "signMessage", + params: { + message: messageDecoded as any, // wants Bytes or string + chainId: 1, // TODO check if we need this + }, + }); + return signedMessage as Hex; + }, + async signTypedData(_typedData) { + const { signedTypedData } = + await querier.call({ + procedureName: "signTypedDataV4", + params: { + domain: _typedData.domain, + types: + _typedData.types as SignerProcedureTypes["signTypedDataV4"]["types"], + message: + _typedData.message as SignerProcedureTypes["signTypedDataV4"]["message"], + chainId: 1, // TODO check if we need this + }, + }); + return signedTypedData as Hex; + }, + }; + } +} diff --git a/packages/thirdweb/src/wallets/embedded/implementations/lib/embedded-wallet.ts b/packages/thirdweb/src/wallets/embedded/implementations/lib/embedded-wallet.ts new file mode 100644 index 00000000000..cb6e26e6699 --- /dev/null +++ b/packages/thirdweb/src/wallets/embedded/implementations/lib/embedded-wallet.ts @@ -0,0 +1,108 @@ +import type { ThirdwebClient } from "../../../../index.js"; +import { + UserWalletStatus, + type EmbeddedWalletConstructorType, + type GetUser, +} from "../interfaces/embedded-wallets/embedded-wallets.js"; +import { EmbeddedWalletIframeCommunicator } from "../utils/iFrameCommunication/EmbeddedWalletIframeCommunicator.js"; +import { Auth, type AuthQuerierTypes } from "./auth/index.js"; +import { EmbeddedWallet } from "./core/embedded-wallet.js"; + +/** + * @internal + */ +export class EmbeddedWalletSdk { + protected client: ThirdwebClient; + protected querier: EmbeddedWalletIframeCommunicator; + + private wallet: EmbeddedWallet; + /** + * Used to manage the Auth state of the user. + */ + auth: Auth; + + private isClientIdLegacyPaper(clientId: string): boolean { + if (clientId.indexOf("-") > 0 && clientId.length === 36) { + return true; + } else { + return false; + } + } + + /** + * @example + * `const thirdwebEmbeddedWallet = new EmbeddedWalletSdk({ clientId: "", chain: "Goerli" });` + * @internal + */ + constructor({ client, onAuthSuccess }: EmbeddedWalletConstructorType) { + if (this.isClientIdLegacyPaper(client.clientId)) { + throw new Error( + "You are using a legacy clientId. Please use the clientId found on the thirdweb dashboard settings page", + ); + } + this.client = client; + this.querier = new EmbeddedWalletIframeCommunicator({ + clientId: client.clientId, + }); + this.wallet = new EmbeddedWallet({ + client, + querier: this.querier, + }); + + this.auth = new Auth({ + client, + querier: this.querier, + onAuthSuccess: async (authResult) => { + onAuthSuccess?.(authResult); + await this.wallet.postWalletSetUp({ + ...authResult.walletDetails, + walletUserId: authResult.storedToken.authDetails.userWalletId, + }); + await this.querier.call({ + procedureName: "initIframe", + params: { + deviceShareStored: authResult.walletDetails.deviceShareStored, + clientId: this.client.clientId, + walletUserId: authResult.storedToken.authDetails.userWalletId, + authCookie: authResult.storedToken.cookieString, + }, + }); + return { + user: { + status: UserWalletStatus.LOGGED_IN_WALLET_INITIALIZED, + authDetails: authResult.storedToken.authDetails, + wallet: this.wallet, + walletAddress: authResult.walletDetails.walletAddress, + }, + }; + }, + }); + } + + /** + * Gets the usr if they are logged in + * @example + * ```js + * const user = await thirdwebEmbeddedWallet.getUser(); + * switch (user.status) { + * case UserWalletStatus.LOGGED_OUT: { + * // User is logged out, call one of the auth methods on thirdwebEmbeddedWallet.auth to authenticate the user + * break; + * } + * case UserWalletStatus.LOGGED_IN_WALLET_INITIALIZED: { + * // user is logged in and wallet is all set up. + * // You have access to: + * user.status; + * user.authDetails; + * user.walletAddress; + * user.wallet; + * break; + * } + * } + * ``` + * @returns GetUser - an object to containing various information on the user statuses + */ + async getUser(): Promise { + return this.wallet.getUserWalletStatus(); + } +} diff --git a/packages/thirdweb/src/wallets/embedded/implementations/utils/Storage/LocalStorage.ts b/packages/thirdweb/src/wallets/embedded/implementations/utils/Storage/LocalStorage.ts new file mode 100644 index 00000000000..89561d68b65 --- /dev/null +++ b/packages/thirdweb/src/wallets/embedded/implementations/utils/Storage/LocalStorage.ts @@ -0,0 +1,123 @@ +import { + AUTH_TOKEN_LOCAL_STORAGE_NAME, + DEVICE_SHARE_LOCAL_STORAGE_NAME, + WALLET_USER_ID_LOCAL_STORAGE_NAME, +} from "../../constants/settings.js"; + +const data = new Map(); + +/** + * @internal + */ +export class LocalStorage { + protected isSupported: boolean; + protected clientId: string; + /** + * @internal + */ + constructor({ clientId }: { clientId: string }) { + this.isSupported = typeof window !== "undefined" && !!window.localStorage; + this.clientId = clientId; + } + + protected async getItem(key: string): Promise { + if (this.isSupported) { + return window.localStorage.getItem(key); + } else { + return data.get(key) ?? null; + } + } + + protected async setItem(key: string, value: string): Promise { + if (this.isSupported) { + return window.localStorage.setItem(key, value); + } else { + data.set(key, value); + } + } + + protected async removeItem(key: string): Promise { + const item = await this.getItem(key); + if (this.isSupported && item) { + window.localStorage.removeItem(key); + return true; + } + return false; + } + + /** + * @internal + */ + async saveAuthCookie(cookie: string): Promise { + await this.setItem(AUTH_TOKEN_LOCAL_STORAGE_NAME(this.clientId), cookie); + } + /** + * @internal + */ + async getAuthCookie(): Promise { + return this.getItem(AUTH_TOKEN_LOCAL_STORAGE_NAME(this.clientId)); + } + /** + * @internal + */ + async removeAuthCookie(): Promise { + return this.removeItem(AUTH_TOKEN_LOCAL_STORAGE_NAME(this.clientId)); + } + + /** + * @internal + */ + async saveDeviceShare(share: string, userId: string): Promise { + await this.saveWalletUserId(userId); + await this.setItem( + DEVICE_SHARE_LOCAL_STORAGE_NAME(this.clientId, userId), + share, + ); + } + /** + * @internal + */ + async getDeviceShare(): Promise { + const userId = await this.getWalletUserId(); + if (userId) { + return this.getItem( + DEVICE_SHARE_LOCAL_STORAGE_NAME(this.clientId, userId), + ); + } + return null; + } + /** + * @internal + */ + async removeDeviceShare(): Promise { + const userId = await this.getWalletUserId(); + if (userId) { + return this.removeItem( + DEVICE_SHARE_LOCAL_STORAGE_NAME(this.clientId, userId), + ); + } + return false; + } + + /** + * @internal + */ + async getWalletUserId(): Promise { + return this.getItem(WALLET_USER_ID_LOCAL_STORAGE_NAME(this.clientId)); + } + /** + * @internal + */ + async saveWalletUserId(userId: string): Promise { + await this.setItem( + WALLET_USER_ID_LOCAL_STORAGE_NAME(this.clientId), + userId, + ); + } + /** + * @internal + */ + async removeWalletUserId(): Promise { + return this.removeItem(WALLET_USER_ID_LOCAL_STORAGE_NAME(this.clientId)); + } +} diff --git a/packages/thirdweb/src/wallets/embedded/implementations/utils/iFrameCommunication/EmbeddedWalletIframeCommunicator.ts b/packages/thirdweb/src/wallets/embedded/implementations/utils/iFrameCommunication/EmbeddedWalletIframeCommunicator.ts new file mode 100644 index 00000000000..11fe26d58ee --- /dev/null +++ b/packages/thirdweb/src/wallets/embedded/implementations/utils/iFrameCommunication/EmbeddedWalletIframeCommunicator.ts @@ -0,0 +1,72 @@ +import { + EMBEDDED_WALLET_PATH, + GET_IFRAME_BASE_URL, +} from "../../constants/settings.js"; +import { LocalStorage } from "../Storage/LocalStorage.js"; +import { IframeCommunicator } from "./IframeCommunicator.js"; + +/** + * @internal + */ +export class EmbeddedWalletIframeCommunicator< + T extends { [key: string]: any }, +> extends IframeCommunicator { + clientId: string; + /** + * @internal + */ + constructor({ clientId }: { clientId: string }) { + super({ + iframeId: EMBEDDED_WALLET_IFRAME_ID, + link: createEmbeddedWalletIframeLink({ + clientId, + path: EMBEDDED_WALLET_PATH, + }).href, + container: document.body, + }); + this.clientId = clientId; + } + + /** + * @internal + */ + override async onIframeLoadedInitVariables() { + const localStorage = new LocalStorage({ + clientId: this.clientId, + }); + + return { + authCookie: await localStorage.getAuthCookie(), + deviceShareStored: await localStorage.getDeviceShare(), + walletUserId: await localStorage.getWalletUserId(), + clientId: this.clientId, + }; + } +} + +// This is the URL and ID tag of the iFrame that we communicate with +/** + * @internal + */ +export function createEmbeddedWalletIframeLink({ + clientId, + path, + queryParams, +}: { + clientId: string; + path: string; + queryParams?: { [key: string]: string | number }; +}) { + const embeddedWalletUrl = new URL(`${path}`, GET_IFRAME_BASE_URL()); + if (queryParams) { + for (const queryKey of Object.keys(queryParams)) { + embeddedWalletUrl.searchParams.set( + queryKey, + queryParams[queryKey]?.toString() || "", + ); + } + } + embeddedWalletUrl.searchParams.set("clientId", clientId); + return embeddedWalletUrl; +} +export const EMBEDDED_WALLET_IFRAME_ID = "thirdweb-embedded-wallet-iframe"; diff --git a/packages/thirdweb/src/wallets/embedded/implementations/utils/iFrameCommunication/IframeCommunicator.ts b/packages/thirdweb/src/wallets/embedded/implementations/utils/iFrameCommunication/IframeCommunicator.ts new file mode 100644 index 00000000000..454f8dfc6bb --- /dev/null +++ b/packages/thirdweb/src/wallets/embedded/implementations/utils/iFrameCommunication/IframeCommunicator.ts @@ -0,0 +1,187 @@ +import { GET_IFRAME_BASE_URL } from "../../constants/settings.js"; + +type IFrameCommunicatorProps = { + link: string; + iframeId: string; + container?: HTMLElement; + onIframeInitialize?: () => void; +}; + +function sleep(seconds: number) { + return new Promise((resolve) => { + setTimeout(resolve, seconds * 1000); + }); +} + +const iframeBaseStyle = { + height: "100%", + width: "100%", + border: "none", + backgroundColor: "transparent", + colorScheme: "light", + position: "fixed", + top: "0px", + right: "0px", + zIndex: "2147483646", + display: "none", +}; + +// Global var to help track iframe state +const isIframeLoaded = new Map(); + +/** + * @internal + */ +export class IframeCommunicator { + private iframe: HTMLIFrameElement; + private POLLING_INTERVAL_SECONDS = 1.4; + + private iframeBaseUrl; + /** + * @internal + */ + constructor({ + link, + iframeId, + container = document.body, + onIframeInitialize, + }: IFrameCommunicatorProps) { + this.iframeBaseUrl = GET_IFRAME_BASE_URL(); + + // Creating the IFrame element for communication + let iframe = document.getElementById(iframeId) as HTMLIFrameElement | null; + const hrefLink = new URL(link); + // TODO (ew) - bring back version tracking + // const sdkVersion = process.env.THIRDWEB_EWS_SDK_VERSION; + // if (!sdkVersion) { + // throw new Error("Missing THIRDWEB_EWS_SDK_VERSION env var"); + // } + // hrefLink.searchParams.set("sdkVersion", sdkVersion); + if (!iframe || iframe.src !== hrefLink.href) { + // ! Do not update the hrefLink here or it'll cause multiple re-renders + if (!iframe) { + iframe = document.createElement("iframe"); + const mergedIframeStyles = { + ...iframeBaseStyle, + }; + Object.assign(iframe.style, mergedIframeStyles); + iframe.setAttribute("id", iframeId); + iframe.setAttribute("fetchpriority", "high"); + container.appendChild(iframe); + } + iframe.src = hrefLink.href; + // iframe.setAttribute("data-version", sdkVersion); + + const onIframeLoaded = (event: MessageEvent) => { + if (event.data.eventType === "ewsIframeLoaded") { + window.removeEventListener("message", onIframeLoaded); + if (!iframe) { + console.warn("thirdweb Iframe not found"); + return; + } + this.onIframeLoadHandler(iframe, onIframeInitialize)(); + } + }; + window.addEventListener("message", onIframeLoaded); + } + this.iframe = iframe; + } + + protected async onIframeLoadedInitVariables(): Promise> { + return {}; + } + + /** + * @internal + */ + onIframeLoadHandler( + iframe: HTMLIFrameElement, + onIframeInitialize?: () => void, + ) { + return async () => { + const promise = new Promise(async (res, rej) => { + const channel = new MessageChannel(); + channel.port1.onmessage = (event: any) => { + const { data } = event; + channel.port1.close(); + if (!data.success) { + return rej(new Error(data.error)); + } + isIframeLoaded.set(iframe.src, true); + if (onIframeInitialize) { + onIframeInitialize(); + } + return res(true); + }; + + const INIT_IFRAME_EVENT = "initIframe"; + iframe?.contentWindow?.postMessage( + // ? We initialise the iframe with a bunch + // of useful information so that we don't have to pass it + // through in each of the future call. This would be where we do it. + { + eventType: INIT_IFRAME_EVENT, + data: await this.onIframeLoadedInitVariables(), + }, + this.iframeBaseUrl, + [channel.port2], + ); + }); + await promise; + }; + } + + /** + * @internal + */ + async call({ + procedureName, + params, + showIframe = false, + }: { + procedureName: keyof T; + params: T[keyof T]; + showIframe?: boolean; + }) { + while (!isIframeLoaded.get(this.iframe.src)) { + await sleep(this.POLLING_INTERVAL_SECONDS); + } + if (showIframe) { + this.iframe.style.display = "block"; + // magic number to let the display render before performing the animation of the modal in + await sleep(0.005); + } + const promise = new Promise((res, rej) => { + const channel = new MessageChannel(); + channel.port1.onmessage = async (event: any) => { + const { data } = event; + channel.port1.close(); + if (showIframe) { + // magic number to let modal fade out before hiding it + await sleep(0.1); + this.iframe.style.display = "none"; + } + if (!data.success) { + rej(new Error(data.error)); + } else { + res(data.data); + } + }; + this.iframe.contentWindow?.postMessage( + { eventType: procedureName, data: params }, + this.iframeBaseUrl, + [channel.port2], + ); + }); + return promise; + } + + /** + * This has to be called by any iframe that will be removed from the DOM. + * Use to make sure that we reset the global loaded state of the particular iframe.src + * @internal + */ + destroy() { + isIframeLoaded.delete(this.iframe.src); + } +} diff --git a/packages/thirdweb/src/wallets/index.ts b/packages/thirdweb/src/wallets/index.ts index d72045321c0..639c86f3ffb 100644 --- a/packages/thirdweb/src/wallets/index.ts +++ b/packages/thirdweb/src/wallets/index.ts @@ -91,3 +91,9 @@ export { type CoinbaseSDKWalletConnectionOptions, } from "./coinbase/coinbaseSDKWallet.js"; export { coinbaseMetadata } from "./coinbase/coinbaseMetadata.js"; + +export { embeddedWallet } from "./embedded/core/wallet/index.js"; +export { + type MultiStepAuthArgsType, + type SingleStepAuthArgsType, +} from "./embedded/core/authentication/type.js"; diff --git a/packages/thirdweb/src/wallets/manager/index.ts b/packages/thirdweb/src/wallets/manager/index.ts index 5b4a0cb66e2..c9b1eb8d7b0 100644 --- a/packages/thirdweb/src/wallets/manager/index.ts +++ b/packages/thirdweb/src/wallets/manager/index.ts @@ -179,6 +179,8 @@ export function createConnectionManager() { } await wallet.switchChain(chain); + // for wallets that dont implement events, just set it manually + activeWalletChain.setValue(wallet.getChain()); }; return { diff --git a/packages/thirdweb/src/wallets/private-key.ts b/packages/thirdweb/src/wallets/private-key.ts index be92a05fc6f..960c550cf7c 100644 --- a/packages/thirdweb/src/wallets/private-key.ts +++ b/packages/thirdweb/src/wallets/private-key.ts @@ -1,9 +1,9 @@ import type { Hex, TransactionSerializable } from "viem"; -import type { Account } from "./interfaces/wallet.js"; import { privateKeyToAccount } from "viem/accounts"; import type { ThirdwebClient } from "../client/client.js"; import { eth_sendRawTransaction, getRpcClient } from "../rpc/index.js"; import { defineChain } from "../chains/utils.js"; +import type { Account } from "./interfaces/wallet.js"; export type PrivateKeyAccountOptions = { client: ThirdwebClient; diff --git a/packages/thirdweb/src/wallets/smart/lib/userop.ts b/packages/thirdweb/src/wallets/smart/lib/userop.ts index d2db35ff3be..982a84f2440 100644 --- a/packages/thirdweb/src/wallets/smart/lib/userop.ts +++ b/packages/thirdweb/src/wallets/smart/lib/userop.ts @@ -1,4 +1,10 @@ -import { keccak256, concat, type Hex, encodeAbiParameters } from "viem"; +import { + keccak256, + concat, + type Hex, + encodeAbiParameters, + toBytes, +} from "viem"; import type { SmartWalletOptions, UserOperation } from "../types.js"; import { isContractDeployed } from "../../../utils/bytecode/is-contract-deployed.js"; import type { ThirdwebContract } from "../../../contract/contract.js"; @@ -146,7 +152,7 @@ export async function signUserOp(args: { if (options.personalAccount.signMessage) { const signature = await options.personalAccount.signMessage({ message: { - raw: userOpHash, + raw: toBytes(userOpHash), }, }); return { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d25528c314..f447604cd20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5150,7 +5150,7 @@ packages: '@babel/helper-validator-option': 7.23.5 browserslist: 4.22.2 lru-cache: 5.1.1 - semver: 7.6.0 + semver: 7.5.4 /@babel/helper-create-class-features-plugin@7.23.7(@babel/core@7.23.7): resolution: {integrity: sha512-xCoqR/8+BoNnXOY7RVSgv6X+o7pmT5q1d+gGcRlXYkI+9B31glE4jeejhKVpA04O1AtzOt7OSQ6VYKP5FcRl9g==} @@ -6664,7 +6664,7 @@ packages: outdent: 0.5.0 prettier: 2.8.8 resolve-from: 5.0.0 - semver: 7.6.0 + semver: 7.5.4 dev: false /@changesets/assemble-release-plan@5.2.4: @@ -6675,7 +6675,7 @@ packages: '@changesets/get-dependents-graph': 1.3.6 '@changesets/types': 5.2.1 '@manypkg/get-packages': 1.1.3 - semver: 7.6.0 + semver: 7.5.4 dev: false /@changesets/changelog-git@0.1.14: @@ -12161,7 +12161,7 @@ packages: resolution: {integrity: sha512-gqBJSmJMWomZFxlppaKea7NeAqFrDrrS0RMt24No92M3nJWcyI9YKGEQKl+EyJqZ5gh6w1s0cTklMHMzRwA1NA==} dependencies: source-map-support: 0.5.21 - tslib: 2.6.2 + tslib: 2.5.0 dev: true /@swc/cli@0.1.63(@swc/core@1.3.71): @@ -12919,8 +12919,8 @@ packages: /@types/node@18.17.1: resolution: {integrity: sha512-xlR1jahfizdplZYRU59JlUx9uzF1ARa8jbhM11ccpCJya8kvos5jwdm2ZAgxSCwOl0fq21svP18EVwPBXMQudw==} - /@types/node@20.11.16: - resolution: {integrity: sha512-gKb0enTmRCzXSSUJDq6/sPcqrfCv2mkkG6Jt/clpn5eiCbKTY+SgZUxo+p8ZKMof5dCp9vHQUAB7wOUTod22wQ==} + /@types/node@20.11.17: + resolution: {integrity: sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==} dependencies: undici-types: 5.26.5 dev: true @@ -13321,7 +13321,7 @@ packages: globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 - semver: 7.6.0 + semver: 7.5.4 ts-api-utils: 1.0.1(typescript@5.3.3) typescript: 5.3.3 transitivePeerDependencies: @@ -13402,7 +13402,7 @@ packages: '@typescript-eslint/types': 6.2.0 '@typescript-eslint/typescript-estree': 6.2.0(typescript@5.3.3) eslint: 8.56.0 - semver: 7.6.0 + semver: 7.5.4 transitivePeerDependencies: - supports-color - typescript @@ -15140,7 +15140,7 @@ packages: '@babel/compat-data': 7.23.5 '@babel/core': 7.23.7 '@babel/helper-define-polyfill-provider': 0.4.4(@babel/core@7.23.7) - semver: 7.6.0 + semver: 7.5.4 transitivePeerDependencies: - supports-color @@ -15789,6 +15789,14 @@ packages: base64-js: 1.5.1 ieee754: 1.2.1 + /bufferutil@4.0.7: + resolution: {integrity: sha512-kukuqc39WOHtdxtw4UScxF/WVnMFVSQVKhtx3AjZJzhd0RGZZldcrfSEbVsWWe6KNH253574cq5F+wpv0G9pJw==} + engines: {node: '>=6.14.2'} + requiresBuild: true + dependencies: + node-gyp-build: 4.6.0 + dev: false + /bufferutil@4.0.8: resolution: {integrity: sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==} engines: {node: '>=6.14.2'} @@ -15822,7 +15830,7 @@ packages: /bun-types@1.0.26: resolution: {integrity: sha512-VcSj+SCaWIcMb0uSGIAtr8P92zq9q+unavcQmx27fk6HulCthXHBVrdGuXxAZbFtv7bHVjizRzR2mk9r/U8Nkg==} dependencies: - '@types/node': 20.11.16 + '@types/node': 20.11.17 '@types/ws': 8.5.10 dev: true @@ -18309,7 +18317,7 @@ packages: object.values: 1.1.6 prop-types: 15.8.1 resolve: 2.0.0-next.4 - semver: 7.5.4 + semver: 7.6.0 string.prototype.matchall: 4.0.8 /eslint-plugin-svg-jsx@1.2.2: @@ -21589,7 +21597,7 @@ packages: jest-snapshot: 29.6.2 jest-util: 29.6.2 p-limit: 3.1.0 - pretty-format: 29.6.2 + pretty-format: 29.7.0 pure-rand: 6.0.1 slash: 3.0.0 stack-utils: 2.0.6 @@ -21740,7 +21748,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: jest-get-type: 29.4.3 - pretty-format: 29.6.2 + pretty-format: 29.7.0 dev: true /jest-matcher-utils@29.6.2: @@ -24842,7 +24850,7 @@ packages: got: 11.8.5 registry-auth-token: 4.2.2 registry-url: 5.1.0 - semver: 7.6.0 + semver: 7.5.4 dev: false /package-json@8.1.1: @@ -26701,7 +26709,7 @@ packages: resolution: {integrity: sha512-Ij1vCAdFgWABd7zTg50Xw1/p0JgESNxuLlneEAsmBrKishA06ulTTL/SHGmNy2Zud7+rKrHTKNI6moJsn1ppAQ==} dependencies: '@types/semver': 6.2.3 - semver: 7.6.0 + semver: 7.5.4 dev: false /semver-diff@4.0.0: @@ -27670,7 +27678,7 @@ packages: engines: {node: ^14.18.0 || >=16.0.0} dependencies: '@pkgr/utils': 2.3.1 - tslib: 2.6.2 + tslib: 2.5.0 /system-architecture@0.1.0: resolution: {integrity: sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==} @@ -29925,7 +29933,7 @@ packages: resolution: {integrity: sha512-PRDso2sGwF6kM75QykIesBijKSVceR6jL2G8NGYyq2XrItNC2P5/qL5XeR056GhA+Ly7JMFvJb9I312mJfmqnQ==} engines: {node: '>=4.0.0'} dependencies: - bufferutil: 4.0.8 + bufferutil: 4.0.7 debug: 2.6.9 es5-ext: 0.10.62 typedarray-to-buffer: 3.1.5