UNPKG

accounts

Version:

Tempo Accounts SDK

416 lines (382 loc) 13.5 kB
import { Hex } from 'ox' import * as Provider from 'ox/Provider' import * as RpcResponse from 'ox/RpcResponse' import type { StoreApi } from 'zustand/vanilla' import { createStore } from 'zustand/vanilla' import type * as Messenger from '../core/Messenger.js' import type * as CoreProvider from '../core/Provider.js' import * as Schema from '../core/Schema.js' import type * as Store from '../core/Store.js' import * as Rpc from '../core/zod/rpc.js' /** State managed by the remote (dialog) side. */ export type State = { /** Whether the dialog is rendered in an iframe or popup. */ mode: 'iframe' | 'popup' | undefined /** Trusted host origin from MessageEvent. */ origin: string | undefined /** Whether the dialog is ready to display content. */ ready: boolean /** Queued RPC requests received from the host. */ requests: readonly Store.QueuedRequest[] } /** Remote context — bundles messenger, provider, and remote store. */ export type Remote = { /** * Messenger for remote communication. */ messenger: Messenger.Bridge /** * Provider instance for executing RPC methods. */ provider: CoreProvider.Provider /** * Remote context store. */ store: StoreApi<State> /** * Hostnames trusted to render the embed in an iframe. */ trustedHosts: readonly string[] /** * Subscribes to user-facing RPC requests from the parent context. * * Syncs the host's active chain, updates the remote store, and invokes * the callback with the first pending request (or `null` when the queue * is cleared, signalling the UI should close). * * @param cb - Callback receiving the request payload. * @returns Unsubscribe function. */ onUserRequest: (cb: (payload: onUserRequest.Payload) => void | Promise<void>) => () => void /** * Subscribes to incoming RPC requests from the parent context. * Updates the remote store with the received requests and syncs the * host's active chain to the remote provider. * * @param cb - Callback receiving the full queued request list. * @returns Unsubscribe function. */ onRequests: ( cb: ( requests: readonly Store.QueuedRequest[], event: MessageEvent, extra: { account: { address: string } | undefined }, ) => void, ) => () => void /** * Signals readiness to the host and begins accepting requests. * Call this after the remote context is fully initialized. */ ready: (options?: ready.Options | undefined) => void /** * Reject an RPC request. */ reject: ( request: Store.QueuedRequest['request'], error?: Provider.ProviderRpcError | RpcResponse.BaseError | undefined, ) => void /** Reject all pending RPC requests. */ rejectAll: (error?: Provider.ProviderRpcError | RpcResponse.BaseError | undefined) => void /** * Respond to an RPC request. * * When `options.result` is provided, sends it directly. * When `options.error` is provided, sends an error response. * Otherwise, executes `provider.request(request)` and sends the result. */ respond: (request: Store.QueuedRequest['request'], options?: respond.Options) => Promise<unknown> } export declare namespace onUserRequest { type Payload = { /** Active account on the host side. */ account: { address: string } | undefined /** Origin of the host that opened this dialog. */ origin: string /** The pending request to display, or `null` when the dialog should close. */ request: Store.QueuedRequest['request'] | null } } export declare namespace ready { type Options = Messenger.ReadyOptions & { /** Authenticated account addresses. When provided, the wallet responds to SDK sync requests. */ accounts?: readonly string[] | undefined } } export declare namespace respond { type Options = { /** Error to respond with (takes precedence over result). */ error?: { code: number; message: string } | undefined /** * Called when `provider.request()` throws. Return `true` to suppress the * error response to the parent — the dialog stays open and can show a * recovery UI. The error is still re-thrown to the caller. */ onError?: ((error: Error) => boolean | void) | undefined /** Explicit result — if omitted, calls `provider.request(request)`. */ result?: unknown | undefined /** Transform the result before sending. */ selector?: ((result: any) => unknown) | undefined } } /** Creates a remote context for the dialog app. */ export function create(options: create.Options): Remote { const { messenger, provider, trustedHosts } = options const ready = typeof window !== 'undefined' && !new URLSearchParams(window.location.search).get('mode') const store = createStore<State>(() => ({ mode: undefined, origin: undefined, ready, requests: [], })) return { messenger, provider, store, trustedHosts: trustedHosts ?? [], onUserRequest(cb) { return this.onRequests(async (requests, event, { account }) => { // Sync the active account with the host. if (account) { const state = provider.store.getState() const index = state.accounts.findIndex( (a) => a.address.toLowerCase() === account.address.toLowerCase(), ) if (index < 0) { messenger.send('sync', { valid: false }) for (const r of requests) if (r.status === 'pending') this.reject(r.request) return } if (index !== state.activeAccount) provider.store.setState({ activeAccount: index }) } const pending = requests.find((r) => r.status === 'pending') store.setState({ origin: event.origin, ready: false, }) await cb({ account, origin: event.origin, request: pending?.request ?? null, }) if (pending) store.setState({ ready: true }) }) }, onRequests(cb) { return messenger.on('rpc-requests', async (payload, event) => { const { account, chainId, requests } = payload // Rehydrate persisted state so the iframe picks up accounts // created in a popup (e.g. Safari WebAuthn fallback). await provider.store.persist?.rehydrate() store.setState({ requests }) if (provider.store.getState().chainId !== chainId) provider.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: Hex.fromNumber(chainId) }], }) cb(requests, event, { account }) }) }, ready(options) { const { accounts, ...readyOptions } = options ?? {} messenger.ready({ ...readyOptions, trustedHosts }) // Respond to account sync requests from the SDK. if (accounts) messenger.on('sync', ({ addresses }) => { if (!addresses) return const valid = addresses.some((a) => accounts.some((b) => a.toLowerCase() === b.toLowerCase()), ) messenger.send('sync', { valid }) }) if (typeof window !== 'undefined') { const params = new URLSearchParams(window.location.search) const mode = params.get('mode') as State['mode'] if (mode) store.setState({ mode }) } }, reject(request, error) { const error_ = error ?? new Provider.UserRejectedRequestError() messenger.send( 'rpc-response', Object.assign( RpcResponse.from({ error: { code: error_.code, message: error_.message }, id: request.id, jsonrpc: '2.0', }), { _request: request }, ), ) }, rejectAll(error) { store.setState({ ready: false }) const requests = store.getState().requests for (const queued of requests) this.reject(queued.request, error) }, async respond(request, options = {}) { const { error, onError, selector } = options const shared = { id: request.id, jsonrpc: '2.0' } as const if (error) { messenger.send( 'rpc-response', Object.assign(RpcResponse.from({ ...shared, error, status: 'error' }), { _request: request, }), ) return } try { let result = 'result' in options ? options.result : await provider?.request(request as never) if (selector) result = selector(result) messenger.send( 'rpc-response', Object.assign(RpcResponse.from({ ...shared, result }), { _request: request }), ) return result } catch (e) { // Browser extensions (e.g. Bitwarden) monkey-patch navigator.credentials // and reject WebAuthn calls in cross-origin iframes. Fall back to popup // so the credential ceremony runs in a top-level browsing context. if (e instanceof Error && e.message.includes('sameOriginWithAncestors')) { messenger.send('switch-mode', { mode: 'popup' }) return } if (e instanceof Error && onError?.(e)) throw e const err = e as RpcResponse.BaseError messenger.send( 'rpc-response', Object.assign(RpcResponse.from({ ...shared, error: err, status: 'error' }), { _request: request, }), ) throw err } }, } } export declare namespace create { type Options = { /** Bridge messenger for cross-frame communication. */ messenger: Messenger.Bridge /** Provider to execute RPC requests against. */ provider: CoreProvider.Provider /** Hostnames trusted to render the embed in an iframe. */ trustedHosts?: readonly string[] | undefined } } /** Returns an inert remote context for SSR environments. */ export function noop(): Remote { const store = createStore<State>(() => ({ mode: undefined, origin: undefined, ready: false, requests: [], })) const off = () => () => {} return { messenger: { destroy: () => {}, on: () => () => {}, ready: () => {}, send: () => {}, waitForReady: () => Promise.resolve({}), } as unknown as Messenger.Bridge, provider: {} as CoreProvider.Provider, store, trustedHosts: [], onUserRequest: off, onRequests: off, ready: () => {}, reject: () => {}, rejectAll: () => {}, respond: async () => {}, } } /** * Validates an RPC request from search params. * * Parses against the `Schema.Request` discriminated union, checks the * method matches, and enforces strict parameter schemas (e.g. required * `limits`). On failure, rejects all pending requests via the messenger * and re-throws so the router can handle the error boundary. */ export function validateSearch<const method extends Schema.Request['method']>( remote: Remote, search: Record<string, unknown>, parameters: { method: method }, ): validateSearch.ReturnType<method> { const { method } = parameters try { const result = Schema.Request.safeParse(search) if (!result.success) throw new RpcResponse.InvalidParamsError({ message: formatZodErrors(method, result.error), }) if (result.data.method !== method) throw new RpcResponse.InvalidParamsError({ message: `Method mismatch: expected "${method}" but got "${result.data.method}".`, }) const strict = Rpc.strictParameters[method as keyof typeof Rpc.strictParameters] const params = (search.params as readonly unknown[] | undefined)?.[0] if (strict && params !== undefined) { const strictResult = strict.safeParse(params) if (!strictResult.success) throw new RpcResponse.InvalidParamsError({ message: formatZodErrors(method, strictResult.error), }) } return { ...search, _decoded: result.data, id: Number(search.id), jsonrpc: '2.0', } as never } catch (error) { if (error instanceof RpcResponse.BaseError) void remote.rejectAll(error) throw error } } export declare namespace validateSearch { type ReturnType<method extends Schema.Request['method']> = Extract< Schema.Request, { method: method } > & { id: number jsonrpc: '2.0' _decoded: Extract<Schema.Request, { method: method }> _returnType: unknown } } type ZodIssue = { path: readonly PropertyKey[] code: string message: string expected?: string | undefined errors?: readonly (readonly ZodIssue[])[] | undefined } function formatZodErrors(method: string, error: { issues: readonly ZodIssue[] }) { const issues = flattenIssues(error.issues) .map((i) => ` - ${i.path.map(String).join('.')}: ${i.message}`) .join('\n') return `Invalid params for "${method}":\n${issues}` } function flattenIssues( issues: readonly ZodIssue[], ): { path: readonly PropertyKey[]; message: string }[] { const result: { path: readonly PropertyKey[]; message: string }[] = [] for (const issue of issues) { if (issue.errors?.length) { const best = issue.errors.reduce((a, b) => (a.length <= b.length ? a : b)) for (const nested of flattenIssues(best)) result.push({ path: [...issue.path, ...nested.path], message: nested.message }) } else { let message = issue.message if (issue.code === 'invalid_type' && issue.expected) message = `Expected ${issue.expected}` else if (issue.code === 'invalid_value') message = 'Invalid value' result.push({ path: issue.path, message }) } } return result }