diff --git a/packages/accounts/src/light-account/account.ts b/packages/accounts/src/light-account/account.ts index a9cc7d54a8..7a8602926c 100644 --- a/packages/accounts/src/light-account/account.ts +++ b/packages/accounts/src/light-account/account.ts @@ -39,6 +39,7 @@ export type LightAccount< getOwnerAddress: () => Promise
; }; +//#region CreateLightAccountParams export type CreateLightAccountParams< TTransport extends Transport = Transport, TSigner extends SmartAccountSigner = SmartAccountSigner @@ -52,6 +53,7 @@ export type CreateLightAccountParams< initCode?: Hex; version?: LightAccountVersion; }; +//#endregion CreateLightAccountParams export async function createLightAccount< TTransport extends Transport = Transport, diff --git a/packages/accounts/src/msca/account/multiOwnerAccount.ts b/packages/accounts/src/msca/account/multiOwnerAccount.ts index 87fc02d407..0c05e08c68 100644 --- a/packages/accounts/src/msca/account/multiOwnerAccount.ts +++ b/packages/accounts/src/msca/account/multiOwnerAccount.ts @@ -26,6 +26,7 @@ export type MultiOwnerModularAccount< TSigner extends SmartAccountSigner = SmartAccountSigner > = SmartContractAccountWithSigner<"MultiOwnerModularAccount", TSigner>; +// #region CreateMultiOwnerModularAccountParams export type CreateMultiOwnerModularAccountParams< TTransport extends Transport = Transport, TSigner extends SmartAccountSigner = SmartAccountSigner @@ -40,6 +41,7 @@ export type CreateMultiOwnerModularAccountParams< accountAddress?: Address; initCode?: Hex; }; +// #endregion CreateMultiOwnerModularAccountParams export async function createMultiOwnerModularAccount< TTransport extends Transport = Transport, diff --git a/packages/alchemy/package.json b/packages/alchemy/package.json index 5e7c8f6849..86e90129f5 100644 --- a/packages/alchemy/package.json +++ b/packages/alchemy/package.json @@ -26,6 +26,16 @@ "import": "./dist/esm/index.js", "default": "./dist/cjs/index.js" }, + "./config": { + "types": "./dist/types/config/index.d.ts", + "import": "./dist/esm/config/index.js", + "default": "./dist/cjs/config/index.js" + }, + "./react": { + "types": "./dist/types/react/index.d.ts", + "import": "./dist/esm/react/index.js", + "default": "./dist/cjs/react/index.js" + }, "./package.json": "./package.json" }, "scripts": { @@ -41,6 +51,9 @@ }, "devDependencies": { "@alchemy/aa-accounts": "*", + "@tanstack/react-query": "^5.28.9", + "react": "^18.2.0", + "react-dom": "^18.2.0", "typescript": "^5.0.4", "typescript-template": "*", "vitest": "^0.31.0" @@ -56,8 +69,22 @@ "zustand": "^4.5.2" }, "peerDependencies": { + "@tanstack/react-query": "^5.28.9", + "react": "^18.2.0", + "react-dom": "^18.2.0", "viem": "2.8.6" }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "@tanstack/react-query": { + "optional": true + } + }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" diff --git a/packages/alchemy/src/client/smartAccountClient.ts b/packages/alchemy/src/client/smartAccountClient.ts index 2008d8ab6f..8858d89d90 100644 --- a/packages/alchemy/src/client/smartAccountClient.ts +++ b/packages/alchemy/src/client/smartAccountClient.ts @@ -16,6 +16,7 @@ import { createAlchemySmartAccountClientFromRpcClient } from "./internal/smartAc import { createAlchemyPublicRpcClient } from "./rpcClient.js"; import type { AlchemyRpcSchema } from "./types.js"; +// #region AlchemySmartAccountClientConfig export type AlchemySmartAccountClientConfig< transport extends Transport = Transport, chain extends Chain | undefined = Chain | undefined, @@ -34,6 +35,7 @@ export type AlchemySmartAccountClientConfig< SmartAccountClientConfig, "customMiddleware" | "feeEstimator" | "gasEstimator" | "signUserOperation" >; +// #endregion AlchemySmartAccountClientConfig export type BaseAlchemyActions< chain extends Chain | undefined = Chain | undefined, diff --git a/packages/alchemy/src/config/actions/createAccount.ts b/packages/alchemy/src/config/actions/createAccount.ts new file mode 100644 index 0000000000..c47a088b14 --- /dev/null +++ b/packages/alchemy/src/config/actions/createAccount.ts @@ -0,0 +1,107 @@ +import { + createLightAccount, + createMultiOwnerModularAccount, + type CreateLightAccountParams, + type CreateMultiOwnerModularAccountParams, +} from "@alchemy/aa-accounts"; +import { custom } from "viem"; +import { ClientOnlyPropertyError } from "../errors.js"; +import type { + AlchemyAccountsConfig, + SupportedAccountTypes, + SupportedAccounts, +} from "../types"; +import { getSignerStatus } from "./getSignerStatus.js"; + +export type AccountConfig = + TAccount extends "LightAccount" + ? Omit + : Omit< + CreateMultiOwnerModularAccountParams, + "signer" | "transport" | "chain" + >; + +export type CreateAccountParams = { + type: TAccount; + accountParams?: AccountConfig; +}; + +export async function createAccount( + { type, accountParams: params }: CreateAccountParams, + config: AlchemyAccountsConfig +): Promise { + const clientStore = config.clientStore; + if (!clientStore) { + throw new ClientOnlyPropertyError("account"); + } + + const transport = custom(config.bundlerClient); + const chain = config.bundlerClient.chain; + const signer = config.signer; + const signerStatus = getSignerStatus(config); + + if (!signerStatus.isConnected) { + throw new Error("Signer not connected"); + } + + const cachedAccount = clientStore.getState().accounts[type]; + if (cachedAccount?.account) { + return cachedAccount.account; + } + + const accountPromise = (() => { + switch (type) { + case "LightAccount": + return createLightAccount({ + ...params, + signer, + transport: (opts) => transport({ ...opts, retryCount: 0 }), + chain, + }); + case "MultiOwnerModularAccount": + return createMultiOwnerModularAccount({ + ...params, + signer, + transport: (opts) => transport({ ...opts, retryCount: 0 }), + chain, + }); + default: + throw new Error("Unsupported account type"); + } + })(); + + clientStore.setState((state) => ({ + accounts: { + ...state.accounts, + [type]: { + status: "INITIALIZING", + account: accountPromise, + }, + }, + })); + + try { + const account = await accountPromise; + clientStore.setState((state) => ({ + accounts: { + ...state.accounts, + [type]: { + status: "READY", + account, + }, + }, + })); + } catch (error) { + clientStore.setState((state) => ({ + accounts: { + ...state.accounts, + [type]: { + status: "ERROR", + error, + }, + }, + })); + } + + return accountPromise; +} diff --git a/packages/alchemy/src/config/actions/getAccount.ts b/packages/alchemy/src/config/actions/getAccount.ts new file mode 100644 index 0000000000..8127001dc5 --- /dev/null +++ b/packages/alchemy/src/config/actions/getAccount.ts @@ -0,0 +1,22 @@ +import { ClientOnlyPropertyError } from "../errors.js"; +import type { AccountState } from "../store/types.js"; +import type { AlchemyAccountsConfig, SupportedAccountTypes } from "../types"; +import { type CreateAccountParams } from "./createAccount.js"; + +export type GetAccountResult = + AccountState; + +export type GetAccountParams = + CreateAccountParams; + +export const getAccount = ( + { type }: GetAccountParams, + config: AlchemyAccountsConfig +): GetAccountResult => { + const clientStore = config.clientStore; + if (!clientStore) { + throw new ClientOnlyPropertyError("account"); + } + + return clientStore.getState().accounts[type]; +}; diff --git a/packages/alchemy/src/config/actions/getBundlerClient.ts b/packages/alchemy/src/config/actions/getBundlerClient.ts new file mode 100644 index 0000000000..f4928877bb --- /dev/null +++ b/packages/alchemy/src/config/actions/getBundlerClient.ts @@ -0,0 +1,8 @@ +import type { ClientWithAlchemyMethods } from "../../client/types"; +import type { AlchemyAccountsConfig } from "../types"; + +export const getBundlerClient = ( + config: AlchemyAccountsConfig +): ClientWithAlchemyMethods => { + return config.coreStore.getState().bundlerClient; +}; diff --git a/packages/alchemy/src/config/actions/getSigner.ts b/packages/alchemy/src/config/actions/getSigner.ts new file mode 100644 index 0000000000..fe1146a08c --- /dev/null +++ b/packages/alchemy/src/config/actions/getSigner.ts @@ -0,0 +1,11 @@ +import type { AlchemySigner } from "../../signer/signer.js"; +import { ClientOnlyPropertyError } from "../errors.js"; +import type { AlchemyAccountsConfig } from "../types"; + +export const getSigner = (config: AlchemyAccountsConfig): AlchemySigner => { + if (config.clientStore == null) { + throw new ClientOnlyPropertyError("signer"); + } + + return config.clientStore.getState().signer; +}; diff --git a/packages/alchemy/src/config/actions/getSignerStatus.ts b/packages/alchemy/src/config/actions/getSignerStatus.ts new file mode 100644 index 0000000000..f1b9fb30e7 --- /dev/null +++ b/packages/alchemy/src/config/actions/getSignerStatus.ts @@ -0,0 +1,10 @@ +import { ClientOnlyPropertyError } from "../errors.js"; +import type { AlchemyAccountsConfig } from "../types"; + +export const getSignerStatus = (config: AlchemyAccountsConfig) => { + if (config.clientStore == null) { + throw new ClientOnlyPropertyError("signerStatus"); + } + + return config.clientStore.getState().signerStatus; +}; diff --git a/packages/alchemy/src/config/actions/getUser.ts b/packages/alchemy/src/config/actions/getUser.ts new file mode 100644 index 0000000000..e937d1869b --- /dev/null +++ b/packages/alchemy/src/config/actions/getUser.ts @@ -0,0 +1,10 @@ +import { ClientOnlyPropertyError } from "../errors.js"; +import type { AlchemyAccountsConfig } from "../types"; + +export const getUser = (config: AlchemyAccountsConfig) => { + if (config.clientStore == null) { + throw new ClientOnlyPropertyError("user"); + } + + return config.clientStore.getState().user; +}; diff --git a/packages/alchemy/src/config/actions/watchAccount.ts b/packages/alchemy/src/config/actions/watchAccount.ts new file mode 100644 index 0000000000..08c809bb59 --- /dev/null +++ b/packages/alchemy/src/config/actions/watchAccount.ts @@ -0,0 +1,27 @@ +import { ClientOnlyPropertyError } from "../errors.js"; +import type { AlchemyAccountsConfig, SupportedAccountTypes } from "../types"; +import type { GetAccountResult } from "./getAccount"; + +export const watchAccount = + ( + type: TAccount, + config: AlchemyAccountsConfig + ) => + (onChange: (account: GetAccountResult) => void) => { + if (config.clientStore == null) { + throw new ClientOnlyPropertyError("account"); + } + + return config.clientStore.subscribe( + ({ accounts }) => accounts[type], + onChange, + { + fireImmediately: true, + equalityFn(a, b) { + return a?.status === "READY" && b?.status === "READY" + ? a.account.address === b.account.address + : a?.status === b?.status; + }, + } + ); + }; diff --git a/packages/alchemy/src/config/actions/watchBundlerClient.ts b/packages/alchemy/src/config/actions/watchBundlerClient.ts new file mode 100644 index 0000000000..715c29e6f5 --- /dev/null +++ b/packages/alchemy/src/config/actions/watchBundlerClient.ts @@ -0,0 +1,12 @@ +import type { ClientWithAlchemyMethods } from "../../client/types"; +import type { AlchemyAccountsConfig } from "../types"; + +export const watchBundlerClient = + (config: AlchemyAccountsConfig) => + (onChange: (bundlerClient: ClientWithAlchemyMethods) => void) => { + return config.coreStore.subscribe( + ({ bundlerClient }) => bundlerClient, + onChange, + { fireImmediately: true } + ); + }; diff --git a/packages/alchemy/src/config/actions/watchSigner.ts b/packages/alchemy/src/config/actions/watchSigner.ts new file mode 100644 index 0000000000..b61fcdc79c --- /dev/null +++ b/packages/alchemy/src/config/actions/watchSigner.ts @@ -0,0 +1,15 @@ +import type { AlchemySigner } from "../../signer"; +import { ClientOnlyPropertyError } from "../errors.js"; +import type { AlchemyAccountsConfig } from "../types"; + +export const watchSigner = + (config: AlchemyAccountsConfig) => + (onChange: (signer: AlchemySigner) => void) => { + if (config.clientStore == null) { + throw new ClientOnlyPropertyError("signer"); + } + + return config.clientStore.subscribe(({ signer }) => signer, onChange, { + fireImmediately: true, + }); + }; diff --git a/packages/alchemy/src/config/actions/watchSignerStatus.ts b/packages/alchemy/src/config/actions/watchSignerStatus.ts new file mode 100644 index 0000000000..931145f87b --- /dev/null +++ b/packages/alchemy/src/config/actions/watchSignerStatus.ts @@ -0,0 +1,17 @@ +import { ClientOnlyPropertyError } from "../errors.js"; +import type { SignerStatus } from "../store/types.js"; +import type { AlchemyAccountsConfig } from "../types"; + +export const watchSignerStatus = + (config: AlchemyAccountsConfig) => + (onChange: (status: SignerStatus) => void) => { + if (config.clientStore == null) { + throw new ClientOnlyPropertyError("signerStatus"); + } + + return config.clientStore.subscribe( + ({ signerStatus }) => signerStatus, + onChange, + { fireImmediately: true } + ); + }; diff --git a/packages/alchemy/src/config/actions/watchUser.ts b/packages/alchemy/src/config/actions/watchUser.ts new file mode 100644 index 0000000000..5f85a61ac9 --- /dev/null +++ b/packages/alchemy/src/config/actions/watchUser.ts @@ -0,0 +1,14 @@ +import type { User } from "../../signer"; +import { ClientOnlyPropertyError } from "../errors.js"; +import type { AlchemyAccountsConfig } from "../types"; + +export const watchUser = + (config: AlchemyAccountsConfig) => (onChange: (user?: User) => void) => { + if (config.clientStore == null) { + throw new ClientOnlyPropertyError("user"); + } + + return config.clientStore.subscribe(({ user }) => user, onChange, { + fireImmediately: true, + }); + }; diff --git a/packages/alchemy/src/config/createConfig.ts b/packages/alchemy/src/config/createConfig.ts new file mode 100644 index 0000000000..f4ef83fc3b --- /dev/null +++ b/packages/alchemy/src/config/createConfig.ts @@ -0,0 +1,44 @@ +"use client"; + +import { ConnectionConfigSchema } from "@alchemy/aa-core"; +import { getBundlerClient } from "./actions/getBundlerClient.js"; +import { getSigner } from "./actions/getSigner.js"; +import { createClientStore } from "./store/client.js"; +import { createCoreStore } from "./store/core.js"; +import type { AlchemyAccountsConfig, CreateConfigProps } from "./types"; + +export const DEFAULT_IFRAME_CONTAINER_ID = "alchemy-signer-iframe-container"; + +export const createConfig = ({ + chain, + iframeConfig, + rootOrgId, + rpId, + sessionConfig, + signerConnection, + ...connectionConfig +}: CreateConfigProps): AlchemyAccountsConfig => { + const connection = ConnectionConfigSchema.parse(connectionConfig); + + const config: AlchemyAccountsConfig = { + coreStore: createCoreStore({ connection, chain }), + clientStore: createClientStore({ + client: { + connection: signerConnection ?? connection, + iframeConfig, + rootOrgId, + rpId, + }, + sessionConfig, + }), + // these are just here for convenience right now, but you can do all of this with actions on the stores as well + get bundlerClient() { + return getBundlerClient(config); + }, + get signer() { + return getSigner(config); + }, + }; + + return config; +}; diff --git a/packages/alchemy/src/config/errors.ts b/packages/alchemy/src/config/errors.ts new file mode 100644 index 0000000000..d3576d8479 --- /dev/null +++ b/packages/alchemy/src/config/errors.ts @@ -0,0 +1,9 @@ +import { BaseError } from "../errors/base.js"; + +export class ClientOnlyPropertyError extends BaseError { + name: string = "ClientOnlyPropertyError"; + + constructor(property: string) { + super(`${property} is only available on the client`); + } +} diff --git a/packages/alchemy/src/config/index.ts b/packages/alchemy/src/config/index.ts new file mode 100644 index 0000000000..1fdc05fce6 --- /dev/null +++ b/packages/alchemy/src/config/index.ts @@ -0,0 +1,14 @@ +export { createAccount } from "./actions/createAccount.js"; +export { getAccount } from "./actions/getAccount.js"; +export { getBundlerClient } from "./actions/getBundlerClient.js"; +export { getSigner } from "./actions/getSigner.js"; +export { getSignerStatus } from "./actions/getSignerStatus.js"; +export { getUser } from "./actions/getUser.js"; +export { watchAccount } from "./actions/watchAccount.js"; +export { watchBundlerClient } from "./actions/watchBundlerClient.js"; +export { watchSigner } from "./actions/watchSigner.js"; +export { watchSignerStatus } from "./actions/watchSignerStatus.js"; +export { watchUser } from "./actions/watchUser.js"; +export { DEFAULT_IFRAME_CONTAINER_ID, createConfig } from "./createConfig.js"; +export { defaultAccountState } from "./store/client.js"; +export type * from "./types.js"; diff --git a/packages/alchemy/src/config/store/client.ts b/packages/alchemy/src/config/store/client.ts new file mode 100644 index 0000000000..c5324baf92 --- /dev/null +++ b/packages/alchemy/src/config/store/client.ts @@ -0,0 +1,141 @@ +import type { PartialBy } from "viem/chains"; +import { subscribeWithSelector } from "zustand/middleware"; +import { createStore } from "zustand/vanilla"; +import type { AlchemySignerClient } from "../../signer/index.js"; +import { + AlchemySigner, + type AlchemySignerParams, +} from "../../signer/signer.js"; +import { AlchemySignerStatus } from "../../signer/types.js"; +import { DEFAULT_IFRAME_CONTAINER_ID } from "../createConfig.js"; +import type { SupportedAccountTypes } from "../types.js"; +import type { + AccountState, + ClientState, + ClientStore, + SignerStatus, +} from "./types.js"; + +export type CreateClientStoreParams = { + client: PartialBy< + Exclude, + "iframeConfig" + >; + sessionConfig?: AlchemySignerParams["sessionConfig"]; +}; + +export const createClientStore = (config: CreateClientStoreParams) => { + const clientStore = + typeof window === "undefined" + ? undefined + : createStore( + subscribeWithSelector(() => createInitialClientState(config)) + ); + + addClientSideStoreListeners(clientStore); + + return clientStore; +}; + +const createSigner = (params: CreateClientStoreParams) => { + const { client, sessionConfig } = params; + const { iframeContainerId } = client.iframeConfig ?? { + iframeContainerId: DEFAULT_IFRAME_CONTAINER_ID, + }; + + let iframeContainer = document.getElementById(iframeContainerId); + if (iframeContainer !== null) { + iframeContainer.innerHTML = ""; + iframeContainer.style.display = "none"; + } else { + iframeContainer = document.createElement("div"); + iframeContainer.id = iframeContainerId; + iframeContainer.style.display = "none"; + document.body.appendChild(iframeContainer); + } + + const signer = new AlchemySigner({ + client: { + ...client, + iframeConfig: { + ...client.iframeConfig, + iframeContainerId, + }, + }, + sessionConfig, + }); + + const search = new URLSearchParams(window.location.search); + if (search.has("bundle")) { + signer.authenticate({ type: "email", bundle: search.get("bundle")! }); + } + + return signer; +}; + +const getSignerStatus = ( + alchemySignerStatus: AlchemySignerStatus +): SignerStatus => ({ + status: alchemySignerStatus, + isInitializing: alchemySignerStatus === AlchemySignerStatus.INITIALIZING, + isAuthenticating: + alchemySignerStatus === AlchemySignerStatus.AUTHENTICATING || + alchemySignerStatus === AlchemySignerStatus.AWAITING_EMAIL_AUTH, + isConnected: alchemySignerStatus === AlchemySignerStatus.CONNECTED, + isDisconnected: alchemySignerStatus === AlchemySignerStatus.DISCONNECTED, +}); + +// This is done this way to avoid issues with React requiring static state +const staticState: AccountState = { + status: "DISCONNECTED", + account: undefined, +}; + +export const defaultAccountState = < + T extends SupportedAccountTypes +>(): AccountState => staticState; + +const createInitialClientState = ( + params: CreateClientStoreParams +): ClientState => { + const signer = createSigner(params); + + return { + signer, + signerStatus: getSignerStatus(AlchemySignerStatus.INITIALIZING), + accounts: { + LightAccount: defaultAccountState<"LightAccount">(), + MultiOwnerModularAccount: + defaultAccountState<"MultiOwnerModularAccount">(), + }, + }; +}; + +const addClientSideStoreListeners = (store?: ClientStore) => { + if (store == null) { + return; + } + + store.subscribe( + ({ signer }) => signer, + (signer) => { + signer.on("statusChanged", (status) => { + store.setState({ signerStatus: getSignerStatus(status) }); + }); + signer.on("connected", (user) => store.setState({ user })); + signer.on("disconnected", () => + store.setState({ + user: undefined, + accounts: { + LightAccount: { status: "DISCONNECTED", account: undefined }, + MultiOwnerModularAccount: { + status: "DISCONNECTED", + account: undefined, + }, + }, + }) + ); + }, + { fireImmediately: true } + ); +}; diff --git a/packages/alchemy/src/config/store/core.ts b/packages/alchemy/src/config/store/core.ts new file mode 100644 index 0000000000..71a78bdfd6 --- /dev/null +++ b/packages/alchemy/src/config/store/core.ts @@ -0,0 +1,30 @@ +import { type ConnectionConfig } from "@alchemy/aa-core"; +import type { Chain } from "viem"; +import { subscribeWithSelector } from "zustand/middleware"; +import { createStore } from "zustand/vanilla"; +import { createAlchemyPublicRpcClient } from "../../client/rpcClient.js"; + +export type CreateCoreStoreParams = { + connection: ConnectionConfig; + chain: Chain; +}; + +export const createCoreStore = ({ + connection, + chain, +}: CreateCoreStoreParams) => { + const bundlerClient = createAlchemyPublicRpcClient({ + chain, + connectionConfig: connection, + }); + + // State defined in here should work either on the server or on the client + // bundler client for example can be used in either setting to make RPC calls + const coreStore = createStore( + subscribeWithSelector(() => ({ + bundlerClient, + })) + ); + + return coreStore; +}; diff --git a/packages/alchemy/src/config/store/types.ts b/packages/alchemy/src/config/store/types.ts new file mode 100644 index 0000000000..7dd2c0f3d0 --- /dev/null +++ b/packages/alchemy/src/config/store/types.ts @@ -0,0 +1,44 @@ +import type { Mutate, StoreApi } from "zustand"; +import type { ClientWithAlchemyMethods } from "../../client/types"; +import type { AlchemySigner, AlchemySignerStatus, User } from "../../signer"; +import type { SupportedAccount, SupportedAccountTypes } from "../types"; + +export type AccountState = + | { + status: "INITIALIZING"; + account: Promise>; + } + | { + status: "READY"; + account: SupportedAccount; + } + | { status: "DISCONNECTED"; account: undefined } + | { status: "ERROR"; account: undefined; error: Error }; + +export type SignerStatus = { + status: AlchemySignerStatus; + isInitializing: boolean; + isAuthenticating: boolean; + isConnected: boolean; + isDisconnected: boolean; +}; + +export type ClientState = { + signer: AlchemySigner; + user?: User; + signerStatus: SignerStatus; + accounts: { + [key in SupportedAccountTypes]: AccountState; + }; +}; + +export type ClientStore = + | Mutate, [["zustand/subscribeWithSelector", never]]> + | undefined; + +export type CoreState = { bundlerClient: ClientWithAlchemyMethods }; + +export type CoreStore = Mutate< + StoreApi, + [["zustand/subscribeWithSelector", never]] +>; diff --git a/packages/alchemy/src/config/types.ts b/packages/alchemy/src/config/types.ts new file mode 100644 index 0000000000..4e1598bfec --- /dev/null +++ b/packages/alchemy/src/config/types.ts @@ -0,0 +1,53 @@ +import type { + LightAccount, + MultiOwnerModularAccount, +} from "@alchemy/aa-accounts"; +import type { ConnectionConfig } from "@alchemy/aa-core"; +import type { Chain } from "viem"; +import type { PartialBy } from "viem/chains"; +import type { ClientWithAlchemyMethods } from "../client/types"; +import type { + AlchemySigner, + AlchemySignerClient, + AlchemySignerParams, +} from "../signer"; +import type { ClientStore, CoreStore } from "./store/types"; + +export type SupportedAccountTypes = "LightAccount" | "MultiOwnerModularAccount"; + +export type SupportedAccounts = + | LightAccount + | MultiOwnerModularAccount; + +export type SupportedAccount = + T extends "LightAccount" + ? LightAccount + : T extends "MultiOwnerModularAccount" + ? MultiOwnerModularAccount + : never; + +export type AlchemyAccountsConfig = { + bundlerClient: ClientWithAlchemyMethods; + signer: AlchemySigner; + coreStore: CoreStore; + clientStore: ClientStore; +}; + +// #region CreateConfigProps +export type CreateConfigProps = ConnectionConfig & { + chain: Chain; + sessionConfig?: AlchemySignerParams["sessionConfig"]; + /** Optional parameter that allows you to specify a different RPC Url + * or connection to be used specifically by the signer. + * This is useful if you have a different backend proxy for the signer + * than for your Bundler or Node RPC calls. + */ + signerConnection?: ConnectionConfig; +} & Omit< + PartialBy< + Exclude, + "iframeConfig" + >, + "connection" + >; +// #endregion CreateConfigProps diff --git a/packages/alchemy/src/react/context.ts b/packages/alchemy/src/react/context.ts new file mode 100644 index 0000000000..90d78558d6 --- /dev/null +++ b/packages/alchemy/src/react/context.ts @@ -0,0 +1,46 @@ +"use client"; + +import type { QueryClient } from "@tanstack/react-query"; +import { createContext, createElement, useContext } from "react"; +import type { AlchemyAccountsConfig } from "../config"; +import { NoAlchemyAccountContextError } from "./errors.js"; + +export type AlchemyAccountContextProps = + | { + config: AlchemyAccountsConfig; + queryClient: QueryClient; + } + | undefined; + +export const AlchemyAccountContext = createContext< + AlchemyAccountContextProps | undefined +>(undefined); + +export type AlchemyAccountsProviderProps = { + config: AlchemyAccountsConfig; + queryClient: QueryClient; +}; + +export const useAlchemyAccountContext = () => { + const context = useContext(AlchemyAccountContext); + + if (context === undefined) { + throw new NoAlchemyAccountContextError("useAlchemyAccountContext"); + } + + return context; +}; + +export const AlchemyAccountProvider = ({ + config, + queryClient, + children, +}: React.PropsWithChildren) => { + // Note: we don't use .tsx because we don't wanna use rollup or similar to bundle this package. + // This lets us continue to use TSC for building the packages which preserves the "use client" above + return createElement( + AlchemyAccountContext.Provider, + { value: { config, queryClient } }, + children + ); +}; diff --git a/packages/alchemy/src/react/errors.ts b/packages/alchemy/src/react/errors.ts new file mode 100644 index 0000000000..0a825f94a2 --- /dev/null +++ b/packages/alchemy/src/react/errors.ts @@ -0,0 +1,7 @@ +import { BaseError } from "../errors/base.js"; + +export class NoAlchemyAccountContextError extends BaseError { + constructor(hookName: string) { + super(`${hookName} must be used within a AlchemyAccountProvider`); + } +} diff --git a/packages/alchemy/src/react/hooks/useAccount.ts b/packages/alchemy/src/react/hooks/useAccount.ts new file mode 100644 index 0000000000..3cb4ef8c2c --- /dev/null +++ b/packages/alchemy/src/react/hooks/useAccount.ts @@ -0,0 +1,67 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { useEffect, useSyncExternalStore } from "react"; +import { createAccount } from "../../config/actions/createAccount.js"; +import { + getAccount, + type GetAccountParams, +} from "../../config/actions/getAccount.js"; +import { watchAccount } from "../../config/actions/watchAccount.js"; +import { + defaultAccountState, + type SupportedAccount, + type SupportedAccountTypes, +} from "../../config/index.js"; +import { useAlchemyAccountContext } from "../context.js"; +import { useSignerStatus } from "./useSignerStatus.js"; + +export type UseAccountResult = { + account?: SupportedAccount; + isLoadingAccount: boolean; +}; + +export type UseAccountProps = + GetAccountParams & { + skipCreate?: boolean; + }; + +export function useAccount( + params: UseAccountProps +): UseAccountResult { + const { config, queryClient } = useAlchemyAccountContext(); + const status = useSignerStatus(); + const account = useSyncExternalStore( + watchAccount(params.type, config), + () => getAccount(params, config), + defaultAccountState + ); + + const { mutate, isPending } = useMutation( + { + mutationFn: async () => account?.account ?? createAccount(params, config), + mutationKey: ["createAccount", params.type], + }, + queryClient + ); + + useEffect(() => { + if ( + !params.skipCreate && + status.isConnected && + !account?.account && + !isPending + ) { + mutate(); + } + }, [account, isPending, mutate, params.skipCreate, status]); + + return { + account: account.status === "READY" ? account?.account : undefined, + isLoadingAccount: + isPending || + account?.status === "INITIALIZING" || + status.isAuthenticating || + status.isInitializing, + }; +} diff --git a/packages/alchemy/src/react/hooks/useAuthenticate.ts b/packages/alchemy/src/react/hooks/useAuthenticate.ts new file mode 100644 index 0000000000..b2a4b37ad1 --- /dev/null +++ b/packages/alchemy/src/react/hooks/useAuthenticate.ts @@ -0,0 +1,42 @@ +"use client"; + +import { useMutation, type UseMutateFunction } from "@tanstack/react-query"; +import { ClientOnlyPropertyError } from "../../config/errors.js"; +import type { User } from "../../signer/index.js"; +import type { AuthParams } from "../../signer/signer.js"; +import { useAlchemyAccountContext } from "../context.js"; +import { useSigner } from "./useSigner.js"; + +export type UseAuthenticateResult = { + authenticate: UseMutateFunction; + isPending: boolean; + error: Error | null; +}; + +export function useAuthenticate(): UseAuthenticateResult { + const { queryClient } = useAlchemyAccountContext(); + const signer = useSigner(); + + const { + mutate: authenticate, + isPending, + error, + } = useMutation( + { + mutationFn: async (authParams: AuthParams) => { + if (!signer) { + throw new ClientOnlyPropertyError("signer"); + } + + return signer.authenticate(authParams); + }, + }, + queryClient + ); + + return { + authenticate, + isPending, + error, + }; +} diff --git a/packages/alchemy/src/react/hooks/useBundlerClient.ts b/packages/alchemy/src/react/hooks/useBundlerClient.ts new file mode 100644 index 0000000000..07abb6dc67 --- /dev/null +++ b/packages/alchemy/src/react/hooks/useBundlerClient.ts @@ -0,0 +1,18 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import type { ClientWithAlchemyMethods } from "../../client/types.js"; +import { watchBundlerClient } from "../../config/actions/watchBundlerClient.js"; +import { useAlchemyAccountContext } from "../context.js"; + +export type UseBundlerClientResult = ClientWithAlchemyMethods; + +export const useBundlerClient = () => { + const { config } = useAlchemyAccountContext(); + + return useSyncExternalStore( + watchBundlerClient(config), + () => config.bundlerClient, + () => config.bundlerClient + ); +}; diff --git a/packages/alchemy/src/react/hooks/useSigner.ts b/packages/alchemy/src/react/hooks/useSigner.ts new file mode 100644 index 0000000000..78e6146d54 --- /dev/null +++ b/packages/alchemy/src/react/hooks/useSigner.ts @@ -0,0 +1,20 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { watchSigner } from "../../config/actions/watchSigner.js"; +import type { AlchemySigner } from "../../signer/index.js"; +import { useAlchemyAccountContext } from "../context.js"; + +export const useSigner = (): AlchemySigner | null => { + const { config } = useAlchemyAccountContext(); + + // TODO: figure out how to handle this on the server + // I think we need a version of the signer that can be run on the server that essentially no-ops or errors + // for all calls + return useSyncExternalStore( + watchSigner(config), + () => config.signer, + // We don't want to return null here, should return something of type AlchemySigner + () => null + ); +}; diff --git a/packages/alchemy/src/react/hooks/useSignerStatus.ts b/packages/alchemy/src/react/hooks/useSignerStatus.ts new file mode 100644 index 0000000000..4d305e72dc --- /dev/null +++ b/packages/alchemy/src/react/hooks/useSignerStatus.ts @@ -0,0 +1,28 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { getSignerStatus } from "../../config/actions/getSignerStatus.js"; +import { watchSignerStatus } from "../../config/actions/watchSignerStatus.js"; +import type { SignerStatus } from "../../config/store/types.js"; +import { AlchemySignerStatus } from "../../signer/types.js"; +import { useAlchemyAccountContext } from "../context.js"; + +export type UseSignerStatusResult = SignerStatus; + +const serverStatus = { + status: AlchemySignerStatus.INITIALIZING, + isInitializing: true, + isAuthenticating: false, + isConnected: false, + isDisconnected: false, +}; + +export const useSignerStatus = (): UseSignerStatusResult => { + const { config } = useAlchemyAccountContext(); + + return useSyncExternalStore( + watchSignerStatus(config), + () => getSignerStatus(config), + () => serverStatus + ); +}; diff --git a/packages/alchemy/src/react/hooks/useSmartAccountClient.ts b/packages/alchemy/src/react/hooks/useSmartAccountClient.ts new file mode 100644 index 0000000000..eeecdbd8b6 --- /dev/null +++ b/packages/alchemy/src/react/hooks/useSmartAccountClient.ts @@ -0,0 +1,117 @@ +"use client"; + +import { + accountLoupeActions, + lightAccountClientActions, + multiOwnerPluginActions, + pluginManagerActions, + type AccountLoupeActions, + type LightAccount, + type LightAccountClientActions, + type MultiOwnerModularAccount, + type MultiOwnerPluginActions, + type PluginManagerActions, +} from "@alchemy/aa-accounts"; +import type { Chain, Transport } from "viem"; +import { createAlchemySmartAccountClientFromRpcClient } from "../../client/internal/smartAccountClientFromRpc.js"; +import type { + AlchemySmartAccountClient, + AlchemySmartAccountClientConfig, +} from "../../client/smartAccountClient"; +import type { + SupportedAccount, + SupportedAccountTypes, + SupportedAccounts, +} from "../../config"; +import type { GetAccountParams } from "../../config/actions/getAccount.js"; +import type { AlchemySigner } from "../../signer"; +import { useAccount } from "./useAccount.js"; +import { useBundlerClient } from "./useBundlerClient.js"; + +export type UseSmartAccountClientProps< + TTransport extends Transport = Transport, + TChain extends Chain | undefined = Chain | undefined, + TAccount extends SupportedAccountTypes = SupportedAccountTypes +> = Omit< + AlchemySmartAccountClientConfig< + TTransport, + TChain, + SupportedAccount + >, + "rpcUrl" | "chain" | "apiKey" | "jwt" | "account" +> & + GetAccountParams; + +export type ClientActions< + TAccount extends SupportedAccounts = SupportedAccounts +> = TAccount extends LightAccount + ? LightAccountClientActions + : TAccount extends MultiOwnerModularAccount + ? MultiOwnerPluginActions> & + PluginManagerActions> & + AccountLoupeActions> + : never; + +export type UseSmartAccountClientResult< + TTransport extends Transport = Transport, + TChain extends Chain | undefined = Chain | undefined, + TAccount extends SupportedAccounts = SupportedAccounts +> = { + client?: AlchemySmartAccountClient< + TTransport, + TChain, + TAccount, + ClientActions + >; + isLoadingClient: boolean; +}; + +export function useSmartAccountClient< + TTransport extends Transport = Transport, + TChain extends Chain | undefined = Chain | undefined, + TAccount extends SupportedAccountTypes = SupportedAccountTypes +>( + args: UseSmartAccountClientProps +): UseSmartAccountClientResult>; + +export function useSmartAccountClient({ + accountParams, + type, + ...clientParams +}: UseSmartAccountClientProps): UseSmartAccountClientResult { + const bundlerClient = useBundlerClient(); + const { account, isLoadingAccount } = useAccount({ + type, + accountParams, + }); + + if (!account || isLoadingAccount) { + return { client: undefined, isLoadingClient: true }; + } + + switch (account.source) { + case "LightAccount": + return { + client: createAlchemySmartAccountClientFromRpcClient({ + client: bundlerClient, + account, + ...clientParams, + }).extend(lightAccountClientActions), + isLoadingClient: false, + }; + case "MultiOwnerModularAccount": + return { + client: createAlchemySmartAccountClientFromRpcClient({ + client: bundlerClient, + account, + ...clientParams, + }) + .extend(multiOwnerPluginActions) + .extend(pluginManagerActions) + .extend(accountLoupeActions), + isLoadingClient: false, + }; + default: + throw new Error("Unsupported account type"); + } +} diff --git a/packages/alchemy/src/react/hooks/useUser.ts b/packages/alchemy/src/react/hooks/useUser.ts new file mode 100644 index 0000000000..169be0922c --- /dev/null +++ b/packages/alchemy/src/react/hooks/useUser.ts @@ -0,0 +1,19 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { getUser } from "../../config/actions/getUser.js"; +import { watchUser } from "../../config/actions/watchUser.js"; +import type { User } from "../../signer/index.js"; +import { useAlchemyAccountContext } from "../context.js"; + +export type UseUserResult = User | null; + +export const useUser = (): UseUserResult => { + const { config } = useAlchemyAccountContext(); + + return useSyncExternalStore( + watchUser(config), + () => getUser(config) ?? null, + () => null + ); +}; diff --git a/packages/alchemy/src/react/index.ts b/packages/alchemy/src/react/index.ts new file mode 100644 index 0000000000..7ac6bd1866 --- /dev/null +++ b/packages/alchemy/src/react/index.ts @@ -0,0 +1,21 @@ +export type * from "./context.js"; +export { + AlchemyAccountContext, + AlchemyAccountProvider, + useAlchemyAccountContext, +} from "./context.js"; +export { NoAlchemyAccountContextError } from "./errors.js"; +export type * from "./hooks/useAccount.js"; +export { useAccount } from "./hooks/useAccount.js"; +export type * from "./hooks/useAuthenticate.js"; +export { useAuthenticate } from "./hooks/useAuthenticate.js"; +export type * from "./hooks/useBundlerClient.js"; +export { useBundlerClient } from "./hooks/useBundlerClient.js"; +export type * from "./hooks/useSigner.js"; +export { useSigner } from "./hooks/useSigner.js"; +export type * from "./hooks/useSignerStatus.js"; +export { useSignerStatus } from "./hooks/useSignerStatus.js"; +export type * from "./hooks/useSmartAccountClient.js"; +export { useSmartAccountClient } from "./hooks/useSmartAccountClient.js"; +export type * from "./hooks/useUser.js"; +export { useUser } from "./hooks/useUser.js"; diff --git a/packages/alchemy/src/signer/session/manager.ts b/packages/alchemy/src/signer/session/manager.ts index c154939d00..cf99c367be 100644 --- a/packages/alchemy/src/signer/session/manager.ts +++ b/packages/alchemy/src/signer/session/manager.ts @@ -232,5 +232,14 @@ export class SessionManager { this.setSession({ type: "passkey", user }); }); + + window.addEventListener("storage", (e: StorageEvent) => { + if (e.key === this.sessionKey) { + // @ts-expect-error - the typing isn't working on this but this is correct + // https://docs.pmnd.rs/zustand/integrations/persisting-store-data#how-can-i-rehydrate-on-storage-event + this.store.persist.rehydrate(); + this.initialize(); + } + }); }; } diff --git a/packages/alchemy/src/signer/signer.ts b/packages/alchemy/src/signer/signer.ts index 1f2259bd0e..acda3d7471 100644 --- a/packages/alchemy/src/signer/signer.ts +++ b/packages/alchemy/src/signer/signer.ts @@ -276,6 +276,7 @@ export class AlchemySigner }); this.sessionManager.setTemporarySession({ orgId }); + this.store.setState({ status: AlchemySignerStatus.AWAITING_EMAIL_AUTH }); // We wait for the session manager to emit a connected event if // cross tab sessions are permitted diff --git a/packages/alchemy/src/signer/types.ts b/packages/alchemy/src/signer/types.ts index 8a85e85c97..3600d11a9b 100644 --- a/packages/alchemy/src/signer/types.ts +++ b/packages/alchemy/src/signer/types.ts @@ -13,4 +13,5 @@ export enum AlchemySignerStatus { CONNECTED = "CONNECTED", DISCONNECTED = "DISCONNECTED", AUTHENTICATING = "AUTHENTICATING", + AWAITING_EMAIL_AUTH = "AWAITING_EMAIL_AUTH", } diff --git a/packages/core/src/account/smartContractAccount.ts b/packages/core/src/account/smartContractAccount.ts index 941603688d..ef368ee73d 100644 --- a/packages/core/src/account/smartContractAccount.ts +++ b/packages/core/src/account/smartContractAccount.ts @@ -189,15 +189,16 @@ export async function toSmartContractAccount< SmartContractAccount > { const client = createBundlerClient({ - transport, + // we set the retry count to 0 so that viem doesn't retry during + // getting the address. That call always reverts and without this + // viem will retry 3 times, making this call very slow + transport: (opts) => transport({ ...opts, chain, retryCount: 0 }), chain, }); const entryPointContract = getContract({ address: entryPoint.address, abi: EntryPointAbi, - // Need to cast this as PublicClient or else it breaks ABI typing. - // This is valid because our PublicClient is a subclass of PublicClient - client: client as PublicClient, + client, }); const accountAddress_ = await getAccountAddress({ diff --git a/packages/core/src/client/schema.ts b/packages/core/src/client/schema.ts index add73d46ca..80bd4e3f12 100644 --- a/packages/core/src/client/schema.ts +++ b/packages/core/src/client/schema.ts @@ -18,6 +18,7 @@ export const createPublicErc4337ClientSchema = < ); }); +// #region ConnectionConfigSchema export const ConnectionConfigSchema = z.union([ z.object({ rpcUrl: z.never().optional(), @@ -40,6 +41,7 @@ export const ConnectionConfigSchema = z.union([ jwt: z.string(), }), ]); +// #endregion ConnectionConfigSchema export const UserOperationFeeOptionsFieldSchema = BigNumberishRangeSchema.merge(MultiplierSchema).partial(); diff --git a/site/.vitepress/sidebar/index.ts b/site/.vitepress/sidebar/index.ts index 1f924e88e7..25439893f9 100644 --- a/site/.vitepress/sidebar/index.ts +++ b/site/.vitepress/sidebar/index.ts @@ -123,6 +123,21 @@ export const sidebar: DefaultTheme.Sidebar = [ }, ], }, + { + text: "React Hooks", + base: "/react", + items: [ + { text: "Overview", link: "/overview" }, + { text: "createConfig", link: "/createConfig" }, + { text: "useAuthenticate", link: "/useAuthenticate" }, + { text: "useSmartAccountClient", link: "/useSmartAccountClient" }, + { text: "useAccount", link: "/useAccount" }, + { text: "useSigner", link: "/useSigner" }, + { text: "useSignerStatus", link: "/useSignerStatus" }, + { text: "useUser", link: "/useUser" }, + { text: "useBundlerClient", link: "/useBundlerClient" }, + ], + }, { text: "Choosing a smart account", items: [ diff --git a/site/react/createConfig.md b/site/react/createConfig.md new file mode 100644 index 0000000000..8cf971d72e --- /dev/null +++ b/site/react/createConfig.md @@ -0,0 +1,79 @@ +--- +outline: deep +head: + - - meta + - property: og:title + content: createConfig + - - meta + - name: description + content: An overview of the createConfig function + - - meta + - property: og:description + content: An overview of the createConfig function + - - meta + - name: twitter:title + content: createConfig + - - meta + - name: twitter:description + content: An overview of the createConfig function +--- + +# createConfig + +The `createConfig` method is used to create a configuration object that is used to initialize the `AlchemyAccountProvider`. The output of this function contains all of the state that will be used by the various hooks exported by `@alchemy/aa-alchemy/react`. + +::: warning +It's not recommended to use the resulting config directly. However, if you are not using `React` it is possible to build your own custom hooks using the state contained in the config object. +::: + +## Import + +```ts +import { createConfig } from "@alchemy/aa-alchemy/config"; +``` + +## Usage + +<<< @/snippets/react/config.ts + +## Parameters + +```ts +import { type CreateConfigProps } from "@alchemy/aa-alchemy/config"; +``` + +::: details CreateConfigProps +<<< @/../packages/alchemy/src/config/types.ts#CreateConfigProps +::: + +::: details ConnectionConfig +<<< @/../packages/core/src/client/schema.ts#ConnectionConfigSchema +::: + +## Return Type + +```ts +import { type AlchemyAccountsConfig } from "@alchemy/aa-alchemy/config"; +``` + +Returns an object containing the Alchemy Accounts state. + +### bundlerClient + +`ClientWithAlchemyMethods` +A JSON RPC client used to make requests to Alchemy's Nodes and Bundler. + +### signer + +`AlchemySigner` +The underlying signer instance used by Embedded Accounts. This property is only available on the client. + +### coreStore + +`CoreStore` +This store contains all of the state that can be used on either the client or the server. + +### clientStore + +`ClientStore` +This store contains only the state available on the client. diff --git a/site/react/overview.md b/site/react/overview.md new file mode 100644 index 0000000000..b023ad5cc8 --- /dev/null +++ b/site/react/overview.md @@ -0,0 +1,70 @@ +--- +outline: deep +head: + - - meta + - property: og:title + content: React Hooks Overview + - - meta + - name: description + content: An overview of using React Hooks exported by Account Kit + - - meta + - property: og:description + content: An overview of using React Hooks exported by Account Kit + - - meta + - name: twitter:title + content: React Hooks Overview + - - meta + - name: twitter:description + content: An overview of using React Hooks exported by Account Kit +--- + +# React Hooks Overview + +If you are using Alchemy's RPC and Smart Contract Accounts and building a React application, you can use the React Hooks exported by Account Kit to interact with your Smart Contract Accounts. You're not required to use these hooks to leverage all of the power of Account Kit. The hooks are exported from `@alchemy/aa-alchemy` and can be found within the `@alchemy/aa-alchemy/react` namespace. + +::: warning +React hooks are still being developed and the interfaces could change in the future! +::: + +## Install the package + +To use the React Hooks, you need to install the `@alchemy/aa-alchemy` and `@tanstack/react-query` packages. We use [`react-query`](https://tanstack.com/query/latest/docs/framework/react/overview) to manage async data fetching and mutations in our hooks. + +::: code-group + +```bash[npm] +npm install @alchemy/aa-alchemy @tanstack/react-query +``` + +```bash[yarn] +yarn add @alchemy/aa-alchemy @tanstack/react-query +``` + +```bash[pnpm] +pnpm add @alchemy/aa-alchemy @tanstack/react-query +``` + +::: + +## Create a config + +In order to get started, you'll first have to define a config object that can be used to create an `AlchemyAccountContext` that will be used by all of the hooks exported by the library. + +::: code-group +<<< @/snippets/react/config.ts +::: + +## Wrap app in Context Provider + +Next, you'll need to add the `AlchemyAccountProvider` to your application and pass in the config object and an instance of the `react-query` `QueryClient`. + +::: code-group + +<<< @/snippets/react/app.tsx + +<<< @/snippets/react/config.ts +::: + +## Use the hooks + +Explore the remaining hooks docs and use them in your application! diff --git a/site/react/useAccount.md b/site/react/useAccount.md new file mode 100644 index 0000000000..fe172ea9fb --- /dev/null +++ b/site/react/useAccount.md @@ -0,0 +1,85 @@ +--- +outline: deep +head: + - - meta + - property: og:title + content: useAccount + - - meta + - name: description + content: An overview of the useAccount hook + - - meta + - property: og:description + content: An overview of the useAccount hook + - - meta + - name: twitter:title + content: useAccount + - - meta + - name: twitter:description + content: An overview of the useAccount hook +--- + +# useAccount + +The `useAccount` hook is used to create a new `LightAccount` or `MultiOwnerModularAccount` contract using the `AlchemySigner` provided by the Accounts Context. This hook is mainly useful if you just want to use information from the account. In most cases, however, the [`useSmartAccountClient`](/react/useSmartAccountClient) hook is more useful since the resulting client contains the account for you to use as well. + +## Import + +```ts +import { useAccount } from "@alchemy/aa-alchemy/react"; +``` + +## Usage + +<<< @/snippets/react/useAccount.tsx + +## Parameters + +```ts +import { type UseAccountProps } from "@alchemy/aa-alchemy/react"; +``` + +### type + +`"LightAccount" | "MultiOwnerModularAccount"` + +The underlying account type you want to use + +### accountParams + +```ts + | Omit + | Omit + | undefined +``` + +An optional param object based on the `type` property passed in above. It allows for overriding the default account parameters. + +::: details CreateLightAccountParams +<<< @/../packages/accounts/src/light-account/account.ts#CreateLightAccountParams +::: + +::: details CreateMultiOwnerModularAccountParams +<<< @/../packages/accounts/src/msca/account/multiOwnerAccount.ts#CreateMultiOwnerModularAccountParams +::: + +### skipCreate + +An optional param that allows you to avoid creating a new instance of the account. This is useful if you know your account has already been created and cached locally. + +## Return Type + +```ts +import { type UseAccountResult } from "@alchemy/aa-alchemy/react"; +``` + +### account + +`LightAccount | MultiOwnerModularAccount` + +An instance of the account specified by the `type` parameter. + +### isLoadingAccount + +`boolean` + +Indicates whether or not the account is still being created. diff --git a/site/react/useAuthenticate.md b/site/react/useAuthenticate.md new file mode 100644 index 0000000000..12d7b022c3 --- /dev/null +++ b/site/react/useAuthenticate.md @@ -0,0 +1,51 @@ +--- +outline: deep +head: + - - meta + - property: og:title + content: useAuthenticate + - - meta + - name: description + content: An overview of the useAuthenticate hook + - - meta + - property: og:description + content: An overview of the useAuthenticate hook + - - meta + - name: twitter:title + content: useAuthenticate + - - meta + - name: twitter:description + content: An overview of the useAuthenticate hook +--- + +# useAuthenticate + +The `useAuthenticate` hook is used to authenticate your user and initialize the AlchemySigner provided by the `AlchemyAccountContext`. If your user is not already logged in, then you must use this hook before any other hook will work. + +## Import + +```ts +import { useAuthenticate } from "@alchemy/aa-alchemy/react"; +``` + +## Usage + +<<< @/snippets/react/login.tsx + +## Return Type + +```ts +import { type UseAuthenticateResult } from "@alchemy/aa-alchemy/react"; +``` + +### authenticate + +A function that you can call to authenticate your user. + +### isPending + +A boolean that is true when the authentication is in progress. + +### error + +an error object that is populated when the authentication fails. diff --git a/site/react/useBundlerClient.md b/site/react/useBundlerClient.md new file mode 100644 index 0000000000..f50865a064 --- /dev/null +++ b/site/react/useBundlerClient.md @@ -0,0 +1,41 @@ +--- +outline: deep +head: + - - meta + - property: og:title + content: useBundlerClient + - - meta + - name: description + content: An overview of the useBundlerClient hook + - - meta + - property: og:description + content: An overview of the useBundlerClient hook + - - meta + - name: twitter:title + content: useBundlerClient + - - meta + - name: twitter:description + content: An overview of the useBundlerClient hook +--- + +# useBundlerClient + +The `useBundlerClient` hook returns the underlying Bundler RPC client instance. + +## Import + +```ts +import { useBundlerClient } from "@alchemy/aa-alchemy/react"; +``` + +## Usage + +<<< @/snippets/react/useBundlerClient.tsx + +## Return Type + +```ts +import { type UseBundlerClientResult } from "@alchemy/aa-alchemy/react"; +``` + +Returns an instance of `ClientWithAlchemyMethods` which is the JSON RPC client connected to Alchemy services. diff --git a/site/react/useSigner.md b/site/react/useSigner.md new file mode 100644 index 0000000000..14dd7020ae --- /dev/null +++ b/site/react/useSigner.md @@ -0,0 +1,41 @@ +--- +outline: deep +head: + - - meta + - property: og:title + content: useSigner + - - meta + - name: description + content: An overview of the useSigner hook + - - meta + - property: og:description + content: An overview of the useSigner hook + - - meta + - name: twitter:title + content: useSigner + - - meta + - name: twitter:description + content: An overview of the useSigner hook +--- + +# useSigner + +The `useSigner` hook returns the `AlchemySigner` instance created within the Accounts Context. This method is provided as a convenience for accessing the `AlchemySigner` instance directly. However, most operations involving the signer are exported via other hooks. + +## Import + +```ts +import { useSigner } from "@alchemy/aa-alchemy/react"; +``` + +## Usage + +<<< @/snippets/react/useSigner.tsx + +## Return Type + +```ts +import { type AlchemySigner } from "@alchemy/aa-alchemy"; +``` + +Returns an instance of the `AlchemySigner` on the client and `null` on the server. diff --git a/site/react/useSignerStatus.md b/site/react/useSignerStatus.md new file mode 100644 index 0000000000..00a0ed7e23 --- /dev/null +++ b/site/react/useSignerStatus.md @@ -0,0 +1,67 @@ +--- +outline: deep +head: + - - meta + - property: og:title + content: useSignerStatus + - - meta + - name: description + content: An overview of the useSignerStatus hook + - - meta + - property: og:description + content: An overview of the useSignerStatus hook + - - meta + - name: twitter:title + content: useSignerStatus + - - meta + - name: twitter:description + content: An overview of the useSignerStatus hook +--- + +# useSignerStatus + +The `useSignerStatus` hook returns an enum of the current status of the `AlchemySigner`. + +## Import + +```ts +import { useSignerStatus } from "@alchemy/aa-alchemy/react"; +``` + +## Usage + +<<< @/snippets/react/useSignerStatus.tsx + +## Return Type + +```ts +import { type UseSignerStatusResult } from "@alchemy/aa-alchemy/react"; +``` + +### status + +`"INITIALIZING" | "CONNECTED" | "DISCONNECTED" | "AUTHENTICATING" | "AWAITING_EMAIL_AUTH"` + +The string representation of the current signer status. + +### isInitializing + +`boolean` + +Returns `true` if the signer is initializing. + +### isAuthenticating + +`boolean` + +Returns `true` if the signer is currently waiting for the user to complete the authentication process. + +### isConnected + +`boolean` +Returns `true` if the signer is authenticated. + +### isDisconnected + +`boolean` +Returns `true` if the signer is disconnected and unauthenticated. diff --git a/site/react/useSmartAccountClient.md b/site/react/useSmartAccountClient.md new file mode 100644 index 0000000000..8ec59c2d4f --- /dev/null +++ b/site/react/useSmartAccountClient.md @@ -0,0 +1,98 @@ +--- +outline: deep +head: + - - meta + - property: og:title + content: useSmartAccountClient + - - meta + - name: description + content: An overview of the useSmartAccountClient hook + - - meta + - property: og:description + content: An overview of the useSmartAccountClient hook + - - meta + - name: twitter:title + content: useSmartAccountClient + - - meta + - name: twitter:description + content: An overview of the useSmartAccountClient hook +--- + +# useSmartAccountClient + +The `useSmartAccountClient` hook is used to create a new [`AlchemySmartAccountClient`](/packages/aa-alchemy/smart-account-client/index) attached to either a `LightAccount` or `MultiOwnerModularAccount` contract using the `AlchemySigner`. + +## Import + +```ts +import { useSmartAccountClient } from "@alchemy/aa-alchemy/react"; +``` + +## Usage + +<<< @/snippets/react/useSmartAccountClient.tsx + +## Parameters + +```ts +import { type UseSmartAccountClientProps } from "@alchemy/aa-alchemy/react"; +``` + +### type + +`"LightAccount" | "MultiOwnerModularAccount"` + +The underlying account type you want to use + +### accountParams + +```ts + | Omit + | Omit + | undefined +``` + +An optional param object based on the `type` property passed in above. It allows for overriding the default account parameters. + +::: details CreateLightAccountParams +<<< @/../packages/accounts/src/light-account/account.ts#CreateLightAccountParams +::: + +::: details CreateMultiOwnerModularAccountParams +<<< @/../packages/accounts/src/msca/account/multiOwnerAccount.ts#CreateMultiOwnerModularAccountParams +::: + +### ...rest + +```ts +Omit< + AlchemySmartAccountClientConfig< + TTransport, + TChain, + SupportedAccount + >, + "rpcUrl" | "chain" | "apiKey" | "jwt" | "account" +>; +``` + +The remaining parameters that are accepted allow for overriding certain properties of the `AlchemySmartAccountClient` + +::: details AlchemySmartAccountClientConfig +<<< @/../packages/alchemy/src/client/smartAccountClient.ts#AlchemySmartAccountClientConfig +::: + +## Return Type + +```ts +import { type UseSmartAccountClientResult } from "@alchemy/aa-alchemy/react"; +``` + +### client + +`AlchemySmartAccountClient | undefined` +Once the underlying account is created, this will be an instance of an [`AlchemySmartAccountClient`](/packages/aa-alchemy/smart-account-client/index.html) connected to an instance of the account type specified. + +### isLoadingClient + +`boolean` +Indicates whether the client is still being created. diff --git a/site/react/useUser.md b/site/react/useUser.md new file mode 100644 index 0000000000..7df01be519 --- /dev/null +++ b/site/react/useUser.md @@ -0,0 +1,41 @@ +--- +outline: deep +head: + - - meta + - property: og:title + content: useUser + - - meta + - name: description + content: An overview of the useUser hook + - - meta + - property: og:description + content: An overview of the useUser hook + - - meta + - name: twitter:title + content: useUser + - - meta + - name: twitter:description + content: An overview of the useUser hook +--- + +# useUser + +The `useUser` hook returns the authenticated `User` if the signer is authenticated. + +## Import + +```ts +import { useUser } from "@alchemy/aa-alchemy/react"; +``` + +## Usage + +<<< @/snippets/react/useUser.tsx + +## Return Type + +```ts +import { type UseUserResult } from "@alchemy/aa-alchemy/react"; +``` + +Returns a `User` object if the user has been authenticated, othwerise `null`. diff --git a/site/snippets/react/app.tsx b/site/snippets/react/app.tsx new file mode 100644 index 0000000000..8fe5ddca9b --- /dev/null +++ b/site/snippets/react/app.tsx @@ -0,0 +1,15 @@ +import { AlchemyAccountProvider } from "@alchemy/aa-alchemy/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { config } from "./config"; + +const queryClient = new QueryClient(); + +export function App() { + return ( + + + {/** ... */} + + + ); +} diff --git a/site/snippets/react/config.ts b/site/snippets/react/config.ts new file mode 100644 index 0000000000..7165570bd6 --- /dev/null +++ b/site/snippets/react/config.ts @@ -0,0 +1,8 @@ +import { createConfig } from "@alchemy/aa-alchemy/config"; +import { sepolia } from "@alchemy/aa-core"; + +export const config = createConfig({ + // required + rpcUrl: "/api/rpc", + chain: sepolia, +}); diff --git a/site/snippets/react/login.tsx b/site/snippets/react/login.tsx new file mode 100644 index 0000000000..7c80eff326 --- /dev/null +++ b/site/snippets/react/login.tsx @@ -0,0 +1,28 @@ +import { useAuthenticate } from "@alchemy/aa-alchemy/react"; +import { useState } from "react"; + +export function Login() { + const [email, setEmail] = useState(""); + const { authenticate, isPending } = useAuthenticate(); + + return ( +
+ setEmail(e.target.value)} + > + +
+ ); +} diff --git a/site/snippets/react/useAccount.tsx b/site/snippets/react/useAccount.tsx new file mode 100644 index 0000000000..384ae8b205 --- /dev/null +++ b/site/snippets/react/useAccount.tsx @@ -0,0 +1,20 @@ +import { useAccount } from "@alchemy/aa-alchemy/react"; + +export function ComponentUsingAccount() { + // If this is the first time the hook is called, then the client will be undefined until the underlying account is connected to the client + const { isLoadingAccount, account } = useAccount({ + type: "LightAccount", // alternatively pass in "MultiOwnerModularAccount", + accountParams: {}, // optional param for overriding any account specific properties + }); + + if (isLoadingAccount || !account) { + return
Loading...
; + } + + return ( +
+

Account is ready!

+
{account.address}
+
+ ); +} diff --git a/site/snippets/react/useBundlerClient.tsx b/site/snippets/react/useBundlerClient.tsx new file mode 100644 index 0000000000..ad7d7cca08 --- /dev/null +++ b/site/snippets/react/useBundlerClient.tsx @@ -0,0 +1,21 @@ +import { useBundlerClient } from "@alchemy/aa-alchemy/react"; +import { useQuery } from "@tanstack/react-query"; + +export function ComponentWithBundlerClient() { + const client = useBundlerClient(); + + const { data, isLoading } = useQuery({ + queryKey: ["supportedEntryPoints"], + queryFn: () => client.getSupportedEntryPoints(), + }); + + if (isLoading) { + return
Loading...
; + } + + return ( +
+

{JSON.stringify(data)}

+
+ ); +} diff --git a/site/snippets/react/useSigner.tsx b/site/snippets/react/useSigner.tsx new file mode 100644 index 0000000000..6f73f66447 --- /dev/null +++ b/site/snippets/react/useSigner.tsx @@ -0,0 +1,16 @@ +import { useSigner } from "@alchemy/aa-alchemy/react"; + +export function ComponentWithSigner() { + const signer = useSigner(); + + if (!signer) { + return
Loading...
; + } + + return ( +
+

Signer is ready!

+
{signer.inner.getUser()}
+
+ ); +} diff --git a/site/snippets/react/useSignerStatus.tsx b/site/snippets/react/useSignerStatus.tsx new file mode 100644 index 0000000000..0b823343a4 --- /dev/null +++ b/site/snippets/react/useSignerStatus.tsx @@ -0,0 +1,11 @@ +import { useSignerStatus } from "@alchemy/aa-alchemy/react"; + +export function ComponentWithSignerStatus() { + const { status } = useSignerStatus(); + + return ( +
+

Signer Status: {status}

+
+ ); +} diff --git a/site/snippets/react/useSmartAccountClient.tsx b/site/snippets/react/useSmartAccountClient.tsx new file mode 100644 index 0000000000..896547efa7 --- /dev/null +++ b/site/snippets/react/useSmartAccountClient.tsx @@ -0,0 +1,20 @@ +import { useSmartAccountClient } from "@alchemy/aa-alchemy/react"; + +export function ComponentUsingClient() { + // If this is the first time the hook is called, then the client will be undefined until the underlying account is connected to the client + const { isLoadingClient, client } = useSmartAccountClient({ + type: "MultiOwnerModularAccount", // alternatively pass in "LightAccount", + accountParams: {}, // optional param for overriding any account specific properties + }); + + if (isLoadingClient || !client) { + return
Loading...
; + } + + return ( +
+

Client is ready!

+
{client.account.address}
+
+ ); +} diff --git a/site/snippets/react/useUser.tsx b/site/snippets/react/useUser.tsx new file mode 100644 index 0000000000..e5a23e7b35 --- /dev/null +++ b/site/snippets/react/useUser.tsx @@ -0,0 +1,15 @@ +import { useUser } from "@alchemy/aa-alchemy/react"; + +export function ComponentWithUser() { + const user = useUser(); + + return ( +
+

+ {user + ? `User connected with signer address: ${user.address}` + : "No user connected"} +

+
+ ); +} diff --git a/site/tsconfig.json b/site/tsconfig.json index 52b48e99b7..8afc1666fd 100644 --- a/site/tsconfig.json +++ b/site/tsconfig.json @@ -1,14 +1,14 @@ { "compilerOptions": { "baseUrl": ".", - "module": "esnext", + "module": "nodenext", "target": "esnext", "lib": ["DOM", "ESNext"], "strict": true, "jsx": "preserve", "esModuleInterop": true, "skipLibCheck": true, - "moduleResolution": "node", + "moduleResolution": "nodenext", "resolveJsonModule": true, "noUnusedLocals": true, "strictNullChecks": true, diff --git a/yarn.lock b/yarn.lock index 6ac7e5d4e6..3b0f9268dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6134,6 +6134,11 @@ resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-4.36.1.tgz#79f8c1a539d47c83104210be2388813a7af2e524" integrity sha512-DJSilV5+ytBP1FbFcEJovv4rnnm/CokuVvrBEtW/Va9DvuJ3HksbXUJEpI0aV1KtuL4ZoO9AVE6PyNLzF7tLeA== +"@tanstack/query-core@5.28.9": + version "5.28.9" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.28.9.tgz#170a7a8794ab73aeffbaf711ac62126479a5d026" + integrity sha512-hNlfCiqZevr3GRVPXS3MhaGW5hjcxvCsIQ4q6ff7EPlvFwYZaS+0d9EIIgofnegDaU2BbCDlyURoYfRl5rmzow== + "@tanstack/query-persist-client-core@4.36.1": version "4.36.1" resolved "https://registry.yarnpkg.com/@tanstack/query-persist-client-core/-/query-persist-client-core-4.36.1.tgz#4d7284994bdc2a15fe6cbe7161be21e03033fe12" @@ -6163,6 +6168,13 @@ "@tanstack/query-core" "4.36.1" use-sync-external-store "^1.2.0" +"@tanstack/react-query@^5.28.9": + version "5.28.9" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.28.9.tgz#13c2049daa5db6c3137473e279b209f76d39708e" + integrity sha512-vwifBkGXsydsLxFOBMe3+f8kvtDoqDRDwUNjPHVDDt+FoBetCbOWAUHgZn4k+CVeZgLmy7bx6aKeDbe3e8koOQ== + dependencies: + "@tanstack/query-core" "5.28.9" + "@tootallnate/once@2": version "2.0.0" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" @@ -15974,7 +15986,7 @@ react-clientside-effect@^1.2.5, react-clientside-effect@^1.2.6: dependencies: "@babel/runtime" "^7.12.13" -react-dom@18.2.0: +react-dom@18.2.0, react-dom@^18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g== @@ -16137,7 +16149,7 @@ react-use@17.4.0: ts-easing "^0.2.0" tslib "^2.1.0" -react@18.2.0: +react@18.2.0, react@^18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==