accounts
Version:
Tempo Accounts SDK
416 lines (382 loc) • 13.5 kB
text/typescript
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
}