From a3492f6671427e260ae0a309f4e804fc24b404a3 Mon Sep 17 00:00:00 2001 From: Tony D'Addeo Date: Thu, 4 Jun 2026 13:14:35 +0200 Subject: [PATCH] feat(tempo): add wallet sync actions --- .changeset/tempo-wallet-rpc-actions.md | 7 + site/pages/docs/actions/wallet/connect.md | 165 +++++ site/pages/experimental/erc7846/client.md | 34 +- site/pages/experimental/erc7846/connect.md | 11 +- site/vocs.config.ts | 4 + src/actions/index.test.ts | 1 + src/actions/index.ts | 6 + src/actions/wallet/connect.ts | 244 +++++++ src/actions/wallet/getCallsStatus.ts | 113 ++- src/actions/wallet/sendCalls.test.ts | 25 + src/actions/wallet/sendCalls.ts | 76 +- src/actions/wallet/sendCallsSync.test.ts | 25 + src/actions/wallet/sendTransaction.ts | 91 +-- .../wallet/sendTransactionRequest.test.ts | 194 +++++ src/actions/wallet/sendTransactionSync.ts | 91 +-- .../utils/prepareSendCallsRequest.test.ts | 103 +++ .../wallet/utils/prepareSendCallsRequest.ts | 91 +++ .../prepareSendTransactionRequest.test.ts | 120 ++++ .../utils/prepareSendTransactionRequest.ts | 147 ++++ .../utils/prepareWriteContractRequest.ts | 83 +++ src/actions/wallet/writeContract.ts | 50 +- src/clients/decorators/wallet.test.ts | 5 + src/clients/decorators/wallet.ts | 26 + src/experimental/erc7846/actions/connect.ts | 186 +---- src/index.ts | 5 + src/tempo/Capabilities.ts | 92 ++- src/tempo/Decorator.test.ts | 5 + src/tempo/Decorator.ts | 124 +++- src/tempo/actions/wallet.test.ts | 660 +++++++++++++++++- src/tempo/actions/wallet.ts | 510 +++++++++++++- src/tempo/chainConfig.test-d.ts | 211 +++++- src/tempo/chainConfig.ts | 43 +- src/tempo/internal/walletAccessKey.ts | 167 +++++ src/types/chain.ts | 45 ++ 34 files changed, 3272 insertions(+), 488 deletions(-) create mode 100644 .changeset/tempo-wallet-rpc-actions.md create mode 100644 site/pages/docs/actions/wallet/connect.md create mode 100644 src/actions/wallet/connect.ts create mode 100644 src/actions/wallet/sendTransactionRequest.test.ts create mode 100644 src/actions/wallet/utils/prepareSendCallsRequest.test.ts create mode 100644 src/actions/wallet/utils/prepareSendCallsRequest.ts create mode 100644 src/actions/wallet/utils/prepareSendTransactionRequest.test.ts create mode 100644 src/actions/wallet/utils/prepareSendTransactionRequest.ts create mode 100644 src/actions/wallet/utils/prepareWriteContractRequest.ts create mode 100644 src/tempo/internal/walletAccessKey.ts diff --git a/.changeset/tempo-wallet-rpc-actions.md b/.changeset/tempo-wallet-rpc-actions.md new file mode 100644 index 0000000000..2704aad904 --- /dev/null +++ b/.changeset/tempo-wallet-rpc-actions.md @@ -0,0 +1,7 @@ +--- +"viem": patch +--- + +Added wallet `connect`, available as `client.connect` on Wallet Clients and as `connect` from `viem/actions`. + +Added Tempo wallet actions under `tempoActions()` and `Actions.wallet`: `sendTransactionSync`, `writeContractSync`, `sendCallsSync`, `authorizeAccessKey`, and `revokeAccessKey`. Tempo chains also support typed `authorizeAccessKey` capabilities for `client.connect`. diff --git a/site/pages/docs/actions/wallet/connect.md b/site/pages/docs/actions/wallet/connect.md new file mode 100644 index 0000000000..e6f06a8420 --- /dev/null +++ b/site/pages/docs/actions/wallet/connect.md @@ -0,0 +1,165 @@ +--- +description: Requests to connect account(s) with optional capabilities. +--- + +# connect + +Requests to connect account(s) with optional [capabilities](#capabilities). + +`connect` uses [`wallet_connect`](https://github.com/ethereum/ERCs/blob/abd1c9f4eda2d6ad06ade0e3af314637a27d1ee7/ERCS/erc-7846.md). If the wallet does not support `wallet_connect` and no capabilities are requested, Viem falls back to [`eth_requestAccounts`](https://eips.ethereum.org/EIPS/eip-1102). + +## Usage + +:::code-group + +```ts [example.ts] +import 'viem/window' +import { createWalletClient, custom } from 'viem' +import { mainnet } from 'viem/chains' + +const walletClient = createWalletClient({ + chain: mainnet, + transport: custom(window.ethereum!), +}) + +const { accounts } = await walletClient.connect() +``` + +```ts [standalone.ts] +import 'viem/window' +import { createWalletClient, custom } from 'viem' +import { connect } from 'viem/actions' +import { mainnet } from 'viem/chains' + +const walletClient = createWalletClient({ + chain: mainnet, + transport: custom(window.ethereum!), +}) + +const { accounts } = await connect(walletClient) +``` + +::: + +## Returns + +`ConnectReturnType` + +Connected accounts and any returned capabilities. + +```ts +type ConnectReturnType = { + accounts: readonly { + address: Address + capabilities?: Record | undefined + }[] +} +``` + +## Parameters + +### capabilities (optional) + +- **Type:** `Record` + +Key-value pairs of [capabilities](#capabilities). + +```ts +const { accounts } = await walletClient.connect({ + capabilities: { + unstable_signInWithEthereum: { + chainId: 1, + nonce: 'abcd1234', + }, + }, +}) +``` + +### chain (optional) + +- **Type:** `Chain` +- **Default:** `client.chain` + +Chain to connect on. Chains can use this to format `wallet_connect` request fields and capabilities. + +```ts +const { accounts } = await walletClient.connect({ + chain: mainnet, +}) +``` + +## Capabilities + +### `unstable_addSubAccount` + +Adds a Sub Account to the connected Account. [See more](https://github.com/ethereum/ERCs/blob/4d3d641ee3c84750baf461b8dd71d27c424417a9/ERCS/erc-7895.md). + +```ts +const { accounts } = await walletClient.connect({ + capabilities: { + unstable_addSubAccount: { + account: { + keys: [ + { + key: '0xFBA3912Ca04dd458c843e2EE08967fC04f3579c2', + type: 'address', + }, + ], + type: 'create', + }, + }, + }, +}) +``` + +### `unstable_signInWithEthereum` + +Authenticate offchain using Sign-In with Ethereum. [See more](https://github.com/ethereum/ERCs/blob/abd1c9f4eda2d6ad06ade0e3af314637a27d1ee7/ERCS/erc-7846.md#signinwithethereum). + +```ts +const { accounts } = await walletClient.connect({ + capabilities: { + unstable_signInWithEthereum: { + chainId: 1, + nonce: 'abcd1234', + }, + }, +}) +``` + +## Chain-specific Capabilities + +Chains can add `wallet_connect` capability formatters through their chain definition. For example, Tempo chains format the `authorizeAccessKey` capability before sending the RPC request. + +```ts +import 'viem/window' +import { createWalletClient, custom } from 'viem' +import { tempo } from 'viem/chains' +import type { Capabilities } from 'viem/tempo' + +declare module 'viem' { + interface Register { + CapabilitiesSchema: Capabilities.Schema + } +} + +const walletClient = createWalletClient({ + chain: tempo, + transport: custom(window.ethereum!), +}) + +const { accounts } = await walletClient.connect({ + capabilities: { + authorizeAccessKey: { + expiry: Math.floor(Date.now() / 1000) + 86_400, + keyType: 'p256', + }, + method: 'login', + }, +}) +``` + +## JSON-RPC Methods + +- [`wallet_connect`](https://github.com/ethereum/ERCs/blob/abd1c9f4eda2d6ad06ade0e3af314637a27d1ee7/ERCS/erc-7846.md) +- Falls back to [`eth_requestAccounts`](https://eips.ethereum.org/EIPS/eip-1102) when no capabilities are requested and the wallet does not support `wallet_connect`. diff --git a/site/pages/experimental/erc7846/client.md b/site/pages/experimental/erc7846/client.md index cb05e53057..a2df940fed 100644 --- a/site/pages/experimental/erc7846/client.md +++ b/site/pages/experimental/erc7846/client.md @@ -1,16 +1,36 @@ # Extending Client with ERC-7846 Actions [Setting up your Viem Client] -To use the experimental functionality of [ERC-7846](https://eips.ethereum.org/EIPS/eip-7846), you can extend your existing (or new) Viem Client with experimental [ERC-7846](https://eips.ethereum.org/EIPS/eip-7846) Actions. +`connect` is now available as a [Wallet Action](/docs/actions/wallet/connect) on Wallet Clients and as a standalone import from `viem/actions`. + +Use the experimental [ERC-7846](https://eips.ethereum.org/EIPS/eip-7846) decorator for `disconnect` or for compatibility with the experimental entrypoint. + +## connect + +```ts +import 'viem/window' +import { createWalletClient, custom } from 'viem' +import { mainnet } from 'viem/chains' + +const client = createWalletClient({ + chain: mainnet, + transport: custom(window.ethereum!), +}) + +const { accounts } = await client.connect() +``` + +## disconnect ```ts -import { createClient, http } from 'viem' +import 'viem/window' +import { createWalletClient, custom } from 'viem' import { mainnet } from 'viem/chains' -import { erc7846Actions } from 'viem/experimental' // [!code focus] +import { erc7846Actions } from 'viem/experimental' -const client = createClient({ +const client = createWalletClient({ chain: mainnet, - transport: http(), -}).extend(erc7846Actions()) // [!code focus] + transport: custom(window.ethereum!), +}).extend(erc7846Actions()) -const hash = await client.connect() +await client.disconnect() ``` diff --git a/site/pages/experimental/erc7846/connect.md b/site/pages/experimental/erc7846/connect.md index b2fe306b08..150818632a 100644 --- a/site/pages/experimental/erc7846/connect.md +++ b/site/pages/experimental/erc7846/connect.md @@ -6,6 +6,12 @@ description: Requests to connect Account(s). Requests to connect Account(s) with optional [capabilities](#capabilities). +:::note + +`connect` is now a [Wallet Action](/docs/actions/wallet/connect). This experimental export re-exports the same implementation for compatibility. + +::: + ## Usage :::code-group @@ -23,12 +29,11 @@ import 'viem/window' // ---cut--- import { createWalletClient, custom } from 'viem' import { mainnet } from 'viem/chains' -import { erc7846Actions } from 'viem/experimental' export const walletClient = createWalletClient({ chain: mainnet, transport: custom(window.ethereum!), -}).extend(erc7846Actions()) +}) ``` ::: @@ -41,7 +46,7 @@ List of connected accounts. type ReturnType = { accounts: readonly { address: Address - capabilities: Record + capabilities?: Record | undefined }[] } ``` diff --git a/site/vocs.config.ts b/site/vocs.config.ts index 6297581508..38cd818023 100644 --- a/site/vocs.config.ts +++ b/site/vocs.config.ts @@ -461,6 +461,10 @@ export default defineConfig({ { text: 'Account', items: [ + { + text: 'connect', + link: '/docs/actions/wallet/connect', + }, { text: 'getAddresses', link: '/docs/actions/wallet/getAddresses', diff --git a/src/actions/index.test.ts b/src/actions/index.test.ts index 708251720f..ad7fe9d78d 100644 --- a/src/actions/index.test.ts +++ b/src/actions/index.test.ts @@ -7,6 +7,7 @@ test('exports actions', () => { { "addChain": [Function], "call": [Function], + "connect": [Function], "createAccessList": [Function], "createBlockFilter": [Function], "createContractEventFilter": [Function], diff --git a/src/actions/index.ts b/src/actions/index.ts index 3611f704fc..b87434ac94 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -479,6 +479,12 @@ export { type AddChainParameters, addChain, } from './wallet/addChain.js' +export { + type ConnectErrorType, + type ConnectParameters, + type ConnectReturnType, + connect, +} from './wallet/connect.js' export { type DeployContractErrorType, type DeployContractParameters, diff --git a/src/actions/wallet/connect.ts b/src/actions/wallet/connect.ts new file mode 100644 index 0000000000..05ca42302a --- /dev/null +++ b/src/actions/wallet/connect.ts @@ -0,0 +1,244 @@ +import type { Address } from 'abitype' +import type { Client } from '../../clients/createClient.js' +import type { Transport } from '../../clients/transports/createTransport.js' +import type { BaseError } from '../../errors/base.js' +import type { ExtractCapabilities } from '../../types/capabilities.js' +import type { + Chain, + ChainWalletConnectCapabilityFormatter, + ChainWalletConnectRequest, +} from '../../types/chain.js' +import type { Prettify } from '../../types/utils.js' +import type { RequestErrorType } from '../../utils/buildRequest.js' +import { numberToHex } from '../../utils/encoding/toHex.js' +import { + type RequestAddressesErrorType, + requestAddresses, +} from './requestAddresses.js' + +export type ConnectParameters< + chain extends Chain | undefined = Chain | undefined, +> = Prettify<{ + /** Chain to connect on. Defaults to the active chain. */ + chain?: chain | Chain | undefined + capabilities?: ExtractCapabilities<'connect', 'Request'> | undefined +}> + +export type ConnectReturnType = Prettify<{ + accounts: readonly { + address: Address + capabilities?: ExtractCapabilities<'connect', 'ReturnType'> | undefined + }[] +}> + +export type ConnectErrorType = RequestErrorType | RequestAddressesErrorType + +/** + * Requests to connect account(s) with optional capabilities. + * + * - Docs: https://viem.sh/docs/actions/wallet/connect + * - JSON-RPC Methods: [`wallet_connect`](https://github.com/ethereum/ERCs/blob/abd1c9f4eda2d6ad06ade0e3af314637a27d1ee7/ERCS/erc-7846.md) + * + * @param client - Client to use + * @param parameters - {@link ConnectParameters} + * @returns List of accounts managed by a wallet {@link ConnectReturnType} + * + * @example + * import { createWalletClient, custom } from 'viem' + * import { connect } from 'viem/actions' + * import { mainnet } from 'viem/chains' + * + * const client = createWalletClient({ + * chain: mainnet, + * transport: custom(window.ethereum), + * }) + * const response = await connect(client) + */ +export async function connect( + client: Client, + parameters: ConnectParameters = {}, +): Promise { + const chain = parameters.chain ?? client.chain + const walletConnect = chain?.walletConnect + const capabilities = formatRequestCapabilities(parameters.capabilities, { + chain, + client, + }) + const request = walletConnect?.formatRequest?.( + { + ...(capabilities ? { capabilities } : {}), + version: '1', + }, + { chain, client }, + ) ?? { + ...(capabilities ? { capabilities } : {}), + version: '1', + } + + const response = await (async () => { + try { + return await client.request<{ + Method: 'wallet_connect' + Parameters: [ChainWalletConnectRequest] + ReturnType: { + accounts?: readonly { + address: Address + capabilities?: Record | undefined + }[] + } + }>( + { method: 'wallet_connect', params: [request] }, + { dedupe: true, retryCount: 0 }, + ) + } catch (e) { + const error = e as BaseError + + // If the wallet does not support `wallet_connect`, and has no + // capabilities, attempt to use `eth_requestAccounts` instead. + if ( + !parameters.capabilities && + walletConnect?.fallback !== false && + (error.name === 'InvalidInputRpcError' || + error.name === 'InvalidParamsRpcError' || + error.name === 'MethodNotFoundRpcError' || + error.name === 'MethodNotSupportedRpcError' || + error.name === 'UnsupportedProviderMethodError') + ) { + const addresses = await requestAddresses(client) + return { + accounts: addresses.map((address) => ({ + address, + capabilities: {}, + })), + } + } + + throw error + } + })() + + return { + ...response, + accounts: (response.accounts ?? []).map((account) => ({ + ...account, + capabilities: formatResponseCapabilities(account.capabilities, { + chain, + client, + }), + })), + } +} + +const requestCapabilityFormatters = { + unstable_addSubAccount: { + key: 'addSubAccount', + format(capability) { + const { account, ...rest } = capability as { + account: { chainId?: number | undefined } + } + return { + ...rest, + account: { + ...account, + ...(account.chainId + ? { + chainId: numberToHex(account.chainId), + } + : {}), + }, + } + }, + }, + unstable_getSubAccounts: { + key: 'getSubAccounts', + format: (capability) => capability || undefined, + }, + unstable_signInWithEthereum: { + key: 'signInWithEthereum', + format(capability) { + const { chainId, expirationTime, issuedAt, notBefore, ...rest } = + capability as { + chainId: number + expirationTime?: Date | undefined + issuedAt?: Date | undefined + notBefore?: Date | undefined + } + return { + ...rest, + chainId: numberToHex(chainId), + ...(expirationTime + ? { + expirationTime: expirationTime.toISOString(), + } + : {}), + ...(issuedAt + ? { + issuedAt: issuedAt.toISOString(), + } + : {}), + ...(notBefore + ? { + notBefore: notBefore.toISOString(), + } + : {}), + } + }, + }, +} satisfies Record + +const responseCapabilityFormatters = { + signInWithEthereum: { + key: 'unstable_signInWithEthereum', + }, + subAccounts: { + key: 'unstable_subAccounts', + }, +} satisfies Record + +function formatRequestCapabilities( + capabilities: ExtractCapabilities<'connect', 'Request'> | undefined, + context: { chain?: Chain | undefined; client: Client }, +) { + if (!capabilities) return undefined + return formatCapabilities(capabilities, { + context, + formatters: { + ...requestCapabilityFormatters, + ...context.chain?.walletConnect?.capabilities?.request, + }, + }) +} + +function formatResponseCapabilities( + capabilities: Record | undefined, + context: { chain?: Chain | undefined; client: Client }, +) { + return formatCapabilities(capabilities ?? {}, { + context, + formatters: { + ...responseCapabilityFormatters, + ...context.chain?.walletConnect?.capabilities?.response, + }, + }) as ExtractCapabilities<'connect', 'ReturnType'> +} + +function formatCapabilities( + capabilities: Record, + options: { + context: { chain?: Chain | undefined; client: Client } + formatters: Record + }, +) { + return Object.entries(capabilities).reduce( + (capabilities, [key, value]) => { + const formatter = options.formatters[key] + const value_ = formatter?.format + ? formatter.format(value, { ...options.context, key }) + : value + if (typeof value_ === 'undefined') return capabilities + capabilities[formatter?.key ?? key] = value_ + return capabilities + }, + {} as Record, + ) +} diff --git a/src/actions/wallet/getCallsStatus.ts b/src/actions/wallet/getCallsStatus.ts index 249c9698bb..d45812fd04 100644 --- a/src/actions/wallet/getCallsStatus.ts +++ b/src/actions/wallet/getCallsStatus.ts @@ -37,6 +37,81 @@ export type GetCallsStatusReturnType = Prettify< export type GetCallsStatusErrorType = RequestErrorType | ErrorType +type RawCallsStatus = + | (Omit< + WalletGetCallsStatusReturnType< + any, + Hex | number, + Hex | bigint, + Hex | 'success' | 'reverted' + >, + 'status' + > & { + status: number | 'CONFIRMED' | 'PENDING' + }) + | { + atomic: boolean + chainId: number + receipts: RpcTransactionReceipt[] + status: number + version: string + } + +export function formatCallsStatus( + status_: RawCallsStatus, +): GetCallsStatusReturnType { + const { + atomic = false, + chainId, + receipts, + version = '2.0.0', + ...response + } = status_ + const [status, statusCode] = (() => { + const statusCode = response.status + if (typeof statusCode === 'number') { + if (statusCode >= 100 && statusCode < 200) + return ['pending', statusCode] as const + if (statusCode >= 200 && statusCode < 300) + return ['success', statusCode] as const + if (statusCode >= 300 && statusCode < 700) + return ['failure', statusCode] as const + } + if (statusCode === 'CONFIRMED') return ['success', 200] as const + if (statusCode === 'PENDING') return ['pending', 100] as const + return [undefined, statusCode] + })() + return { + ...response, + atomic, + chainId: + typeof chainId === 'number' + ? chainId + : chainId + ? hexToNumber(chainId) + : undefined, + receipts: + receipts?.map((receipt) => ({ + ...receipt, + blockNumber: + typeof receipt.blockNumber === 'bigint' + ? receipt.blockNumber + : hexToBigInt(receipt.blockNumber), + gasUsed: + typeof receipt.gasUsed === 'bigint' + ? receipt.gasUsed + : hexToBigInt(receipt.gasUsed), + status: + receipt.status === 'success' || receipt.status === 'reverted' + ? receipt.status + : receiptStatuses[receipt.status as '0x0' | '0x1'], + })) ?? [], + statusCode, + status, + version, + } as GetCallsStatusReturnType +} + /** * Returns the status of a call batch that was sent via `sendCalls`. * @@ -107,41 +182,5 @@ export async function getCallsStatus< }) } - const { - atomic = false, - chainId, - receipts, - version = '2.0.0', - ...response - } = await getStatus(parameters.id as Hex) - const [status, statusCode] = (() => { - const statusCode = response.status - if (statusCode >= 100 && statusCode < 200) - return ['pending', statusCode] as const - if (statusCode >= 200 && statusCode < 300) - return ['success', statusCode] as const - if (statusCode >= 300 && statusCode < 700) - return ['failure', statusCode] as const - // @ts-expect-error: for backwards compatibility - if (statusCode === 'CONFIRMED') return ['success', 200] as const - // @ts-expect-error: for backwards compatibility - if (statusCode === 'PENDING') return ['pending', 100] as const - return [undefined, statusCode] - })() - return { - ...response, - atomic, - // @ts-expect-error: for backwards compatibility - chainId: chainId ? hexToNumber(chainId) : undefined, - receipts: - receipts?.map((receipt) => ({ - ...receipt, - blockNumber: hexToBigInt(receipt.blockNumber), - gasUsed: hexToBigInt(receipt.gasUsed), - status: receiptStatuses[receipt.status as '0x0' | '0x1'], - })) ?? [], - statusCode, - status, - version, - } + return formatCallsStatus(await getStatus(parameters.id as Hex)) } diff --git a/src/actions/wallet/sendCalls.test.ts b/src/actions/wallet/sendCalls.test.ts index 63e844320d..512f024a44 100644 --- a/src/actions/wallet/sendCalls.test.ts +++ b/src/actions/wallet/sendCalls.test.ts @@ -390,6 +390,31 @@ test('behavior: inferred account', async () => { `) }) +test('error: chain is required', async () => { + let requested = false + const client = createWalletClient({ + transport: custom({ + async request() { + requested = true + return null + }, + }), + }) + + await expect(() => + sendCalls(client, { + account: accounts[0].address, + calls: [{ to: accounts[1].address }], + } as never), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + [ChainNotFoundError: No chain was provided to the request. + Please provide a chain with the \`chain\` argument on the Action, or by supplying a \`chain\` to WalletClient. + + Version: viem@x.y.z] + `) + expect(requested).toBe(false) +}) + test('behavior: capability: paymasterService', async () => { const requests: unknown[] = [] diff --git a/src/actions/wallet/sendCalls.ts b/src/actions/wallet/sendCalls.ts index 5e0e10885a..a1eab83c51 100644 --- a/src/actions/wallet/sendCalls.ts +++ b/src/actions/wallet/sendCalls.ts @@ -1,27 +1,27 @@ import type { Address, Narrow } from 'abitype' -import { parseAccount } from '../../accounts/utils/parseAccount.js' import type { Client } from '../../clients/createClient.js' import type { Transport } from '../../clients/transports/createTransport.js' import { BaseError } from '../../errors/base.js' +import type { ChainNotFoundErrorType } from '../../errors/chain.js' import { AtomicityNotSupportedError, UnsupportedNonOptionalCapabilityError, } from '../../errors/rpc.js' import type { ErrorType } from '../../errors/utils.js' import type { Account, GetAccountParameter } from '../../types/account.js' -import type { Call, Calls } from '../../types/calls.js' +import type { Calls } from '../../types/calls.js' import type { ExtractCapabilities } from '../../types/capabilities.js' import type { Chain, DeriveChain } from '../../types/chain.js' import type { WalletSendCallsParameters } from '../../types/eip1193.js' import type { Hex } from '../../types/misc.js' import type { Prettify } from '../../types/utils.js' -import { encodeFunctionData } from '../../utils/abi/encodeFunctionData.js' import type { RequestErrorType } from '../../utils/buildRequest.js' import { concat } from '../../utils/data/concat.js' import { hexToBigInt } from '../../utils/encoding/fromHex.js' import { numberToHex } from '../../utils/encoding/toHex.js' import { getTransactionError } from '../../utils/errors/getTransactionError.js' import { sendTransaction } from './sendTransaction.js' +import { prepareSendCallsRequest } from './utils/prepareSendCallsRequest.js' export const fallbackMagicIdentifier = '0x5792579257925792579257925792579257925792579257925792579257925792' @@ -52,7 +52,10 @@ export type SendCallsReturnType = Prettify<{ id: string }> -export type SendCallsErrorType = RequestErrorType | ErrorType +export type SendCallsErrorType = + | RequestErrorType + | ChainNotFoundErrorType + | ErrorType /** * Requests the connected wallet to send a batch of calls. @@ -96,68 +99,21 @@ export async function sendCalls< parameters: SendCallsParameters, ): Promise { const { - account: account_ = client.account, - chain = client.chain, + calls, + capabilities, + chain, experimental_fallback, - experimental_fallbackDelay = 32, - forceAtomic = false, - id, - version = '2.0.0', - } = parameters - - const account = account_ ? parseAccount(account_) : null - - let capabilities = parameters.capabilities - - if (client.dataSuffix && !parameters.capabilities?.dataSuffix) { - if (typeof client.dataSuffix === 'string') - capabilities = { - ...parameters.capabilities, - dataSuffix: { value: client.dataSuffix, optional: true }, - } - else - capabilities = { - ...parameters.capabilities, - dataSuffix: { - value: client.dataSuffix.value, - ...(client.dataSuffix.required ? {} : { optional: true }), - }, - } - } - - const calls = parameters.calls.map((call_: unknown) => { - const call = call_ as Call - - const data = call.abi - ? encodeFunctionData({ - abi: call.abi, - functionName: call.functionName, - args: call.args, - }) - : call.data - - return { - data: call.dataSuffix && data ? concat([data, call.dataSuffix]) : data, - to: call.to, - value: call.value ? numberToHex(call.value) : undefined, - } - }) + experimental_fallbackDelay, + forceAtomic, + account, + request, + } = prepareSendCallsRequest(client, parameters) try { const response = await client.request( { method: 'wallet_sendCalls', - params: [ - { - atomicRequired: forceAtomic, - calls, - capabilities, - chainId: numberToHex(chain!.id), - from: account?.address, - id, - version, - }, - ], + params: [request], }, { retryCount: 0 }, ) diff --git a/src/actions/wallet/sendCallsSync.test.ts b/src/actions/wallet/sendCallsSync.test.ts index 923f87034d..8044d96333 100644 --- a/src/actions/wallet/sendCallsSync.test.ts +++ b/src/actions/wallet/sendCallsSync.test.ts @@ -176,3 +176,28 @@ test('default', async () => { expect(response.receipts).toHaveLength(5) }) + +test('error: chain is required', async () => { + let requested = false + const client = createClient({ + transport: custom({ + async request() { + requested = true + return null + }, + }), + }) + + await expect(() => + sendCalls(client, { + account: accounts[0].address, + calls: [{ to: accounts[1].address }], + } as never), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + [ChainNotFoundError: No chain was provided to the request. + Please provide a chain with the \`chain\` argument on the Action, or by supplying a \`chain\` to WalletClient. + + Version: viem@x.y.z] + `) + expect(requested).toBe(false) +}) diff --git a/src/actions/wallet/sendTransaction.ts b/src/actions/wallet/sendTransaction.ts index e699e75450..dafe5c1b86 100644 --- a/src/actions/wallet/sendTransaction.ts +++ b/src/actions/wallet/sendTransaction.ts @@ -14,7 +14,7 @@ import { AccountTypeNotSupportedError, type AccountTypeNotSupportedErrorType, } from '../../errors/account.js' -import { BaseError } from '../../errors/base.js' +import type { BaseError } from '../../errors/base.js' import type { ErrorType } from '../../errors/utils.js' import type { GetAccountParameter } from '../../types/account.js' import type { @@ -24,27 +24,16 @@ import type { } from '../../types/chain.js' import type { GetTransactionRequestKzgParameter } from '../../types/kzg.js' import type { Hash, Hex } from '../../types/misc.js' -import type { TransactionRequest } from '../../types/transaction.js' import type { UnionOmit } from '../../types/utils.js' -import { - type RecoverAuthorizationAddressErrorType, - recoverAuthorizationAddress, -} from '../../utils/authorization/recoverAuthorizationAddress.js' +import type { RecoverAuthorizationAddressErrorType } from '../../utils/authorization/recoverAuthorizationAddress.js' import type { RequestErrorType } from '../../utils/buildRequest.js' -import { - type AssertCurrentChainErrorType, - assertCurrentChain, -} from '../../utils/chain/assertCurrentChain.js' +import type { AssertCurrentChainErrorType } from '../../utils/chain/assertCurrentChain.js' import { concat } from '../../utils/data/concat.js' import { type GetTransactionErrorReturnType, getTransactionError, } from '../../utils/errors/getTransactionError.js' -import { extract } from '../../utils/formatters/extract.js' -import { - type FormattedTransactionRequest, - formatTransactionRequest, -} from '../../utils/formatters/transactionRequest.js' +import type { FormattedTransactionRequest } from '../../utils/formatters/transactionRequest.js' import { getAction } from '../../utils/getAction.js' import { LruMap } from '../../utils/lru.js' import { @@ -62,6 +51,7 @@ import { type SendRawTransactionErrorType, sendRawTransaction, } from './sendRawTransaction.js' +import { prepareSendTransactionRequest } from './utils/prepareSendTransactionRequest.js' const supportsWalletNamespace = new LruMap(128) @@ -165,7 +155,7 @@ export async function sendTransaction< ): Promise { const { account: account_ = client.account, - assertChainId = true, + assertChainId: _assertChainId = true, chain = client.chain, accessList, authorizationList, @@ -193,65 +183,11 @@ export async function sendTransaction< let nonceManagerParameters: { address: Address; chainId: number } | undefined try { - assertRequest(parameters as AssertRequestParameters) - - const to = await (async () => { - // If `to` exists on the parameters, use that. - if (parameters.to) return parameters.to - - // If `to` is null, we are sending a deployment transaction. - if (parameters.to === null) return undefined - - // If no `to` exists, and we are sending a EIP-7702 transaction, use the - // address of the first authorization in the list. - if (authorizationList && authorizationList.length > 0) - return await recoverAuthorizationAddress({ - authorization: authorizationList[0], - }).catch(() => { - throw new BaseError( - '`to` is required. Could not infer from `authorizationList`.', - ) - }) - - // Otherwise, we are sending a deployment transaction. - return undefined - })() - if (account?.type === 'json-rpc' || account === null) { - let chainId: number | undefined - if (chain !== null) { - chainId = await getAction(client, getChainId, 'getChainId')({}) - if (assertChainId) - assertCurrentChain({ - currentChainId: chainId, - chain, - }) - } - - const chainFormat = client.chain?.formatters?.transactionRequest?.format - const format = chainFormat || formatTransactionRequest - - const request = format( - { - // Pick out extra data that might exist on the chain's transaction request type. - ...extract(rest, { format: chainFormat }), - accessList, - account, - authorizationList, - blobs, - chainId, - data: dataSuffix ? concat([data ?? '0x', dataSuffix]) : data, - gas, - gasPrice, - maxFeePerBlobGas, - maxFeePerGas, - maxPriorityFeePerGas, - nonce, - to, - type, - value, - } as TransactionRequest, - 'sendTransaction', + const { request } = await prepareSendTransactionRequest( + client, + parameters as never, + { docsPath: '/docs/actions/wallet/sendTransaction' }, ) const isWalletNamespaceSupported = supportsWalletNamespace.get(client.uid) @@ -309,6 +245,13 @@ export async function sendTransaction< } } + assertRequest(parameters as AssertRequestParameters) + + const to = await prepareSendTransactionRequest.resolveTo({ + authorizationList, + to: parameters.to, + }) + if (account?.type === 'local') { if (account.nonceManager && typeof nonce === 'undefined') { const requestChainId = (rest as unknown as { chainId?: number }).chainId diff --git a/src/actions/wallet/sendTransactionRequest.test.ts b/src/actions/wallet/sendTransactionRequest.test.ts new file mode 100644 index 0000000000..461d6a5671 --- /dev/null +++ b/src/actions/wallet/sendTransactionRequest.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, test } from 'vitest' + +import { celo, localhost } from '../../chains/index.js' +import { createWalletClient } from '../../clients/createWalletClient.js' +import { custom } from '../../clients/transports/custom.js' +import type { Hex } from '../../types/misc.js' +import { defineChain } from '../../utils/chain/defineChain.js' +import { sendTransaction } from './sendTransaction.js' +import { sendTransactionSync } from './sendTransactionSync.js' + +const sourceAddress = '0x0000000000000000000000000000000000000001' +const targetAddress = '0x0000000000000000000000000000000000000002' +const feeCurrency = '0x0000000000000000000000000000000000000003' +const transactionHash = + '0x0000000000000000000000000000000000000000000000000000000000000004' + +const chain = defineChain({ + ...localhost, + id: 1, + formatters: { + transactionRequest: celo.formatters.transactionRequest, + }, + serializers: undefined, +}) + +function receipt() { + return { + blockHash: + '0x0000000000000000000000000000000000000000000000000000000000000005', + blockNumber: '0x1', + contractAddress: null, + cumulativeGasUsed: '0x5208', + effectiveGasPrice: '0x1', + from: sourceAddress, + gasUsed: '0x5208', + logs: [], + logsBloom: `0x${'0'.repeat(512)}` as Hex, + status: '0x1', + to: targetAddress, + transactionHash, + transactionIndex: '0x0', + type: '0x2', + } +} + +describe('sendTransaction request preparation', () => { + test('formats JSON-RPC request with action-level chain formatter', async () => { + const requests: { method: string; params: unknown }[] = [] + const client = createWalletClient({ + transport: custom({ + async request({ method, params }) { + requests.push({ method, params }) + if (method === 'eth_chainId') return '0x1' + if (method === 'eth_sendTransaction') return transactionHash + return null + }, + }), + }) + + await sendTransaction(client, { + account: sourceAddress, + chain, + feeCurrency, + to: targetAddress, + value: 1n, + }) + + expect(requests.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_sendTransaction', + ]) + expect(requests[1]!.params).toEqual([ + { + feeCurrency, + from: sourceAddress, + to: targetAddress, + value: '0x1', + }, + ]) + }) + + test('uses client formatter and skips chain id assertion when chain is null', async () => { + const requests: { method: string; params: unknown }[] = [] + const client = createWalletClient({ + chain, + transport: custom({ + async request({ method, params }) { + requests.push({ method, params }) + if (method === 'eth_sendTransaction') return transactionHash + return null + }, + }), + }) + + await sendTransaction(client, { + account: sourceAddress, + chain: null, + feeCurrency, + to: targetAddress, + value: 1n, + }) + + expect(requests.map(({ method }) => method)).toEqual([ + 'eth_sendTransaction', + ]) + expect(requests[0]!.params).toEqual([ + { + feeCurrency, + from: sourceAddress, + to: targetAddress, + value: '0x1', + }, + ]) + }) +}) + +describe('sendTransactionSync request preparation', () => { + test('formats JSON-RPC request with action-level chain formatter', async () => { + const requests: { method: string; params: unknown }[] = [] + const client = createWalletClient({ + transport: custom({ + async request({ method, params }) { + requests.push({ method, params }) + if (method === 'eth_chainId') return '0x1' + if (method === 'eth_sendTransaction') return transactionHash + if (method === 'eth_getTransactionReceipt') return receipt() + return null + }, + }), + }) + + const result = await sendTransactionSync(client, { + account: sourceAddress, + chain, + feeCurrency, + pollingInterval: 1, + to: targetAddress, + value: 1n, + }) + + expect(result.transactionHash).toBe(transactionHash) + expect(requests.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_sendTransaction', + 'eth_getTransactionReceipt', + ]) + expect(requests[1]!.params).toEqual([ + { + feeCurrency, + from: sourceAddress, + to: targetAddress, + value: '0x1', + }, + ]) + }) + + test('uses client formatter and skips chain id assertion when chain is null', async () => { + const requests: { method: string; params: unknown }[] = [] + const client = createWalletClient({ + chain, + transport: custom({ + async request({ method, params }) { + requests.push({ method, params }) + if (method === 'eth_sendTransaction') return transactionHash + if (method === 'eth_getTransactionReceipt') return receipt() + return null + }, + }), + }) + + const result = await sendTransactionSync(client, { + account: sourceAddress, + chain: null, + feeCurrency, + pollingInterval: 1, + to: targetAddress, + value: 1n, + }) + + expect(result.transactionHash).toBe(transactionHash) + expect(requests.map(({ method }) => method)).toEqual([ + 'eth_sendTransaction', + 'eth_getTransactionReceipt', + ]) + expect(requests[0]!.params).toEqual([ + { + feeCurrency, + from: sourceAddress, + to: targetAddress, + value: '0x1', + }, + ]) + }) +}) diff --git a/src/actions/wallet/sendTransactionSync.ts b/src/actions/wallet/sendTransactionSync.ts index f9ca4d67e1..740ff04b13 100644 --- a/src/actions/wallet/sendTransactionSync.ts +++ b/src/actions/wallet/sendTransactionSync.ts @@ -14,7 +14,7 @@ import { AccountTypeNotSupportedError, type AccountTypeNotSupportedErrorType, } from '../../errors/account.js' -import { BaseError } from '../../errors/base.js' +import type { BaseError } from '../../errors/base.js' import { TransactionReceiptRevertedError, type TransactionReceiptRevertedErrorType, @@ -28,27 +28,16 @@ import type { } from '../../types/chain.js' import type { GetTransactionRequestKzgParameter } from '../../types/kzg.js' import type { Hash, Hex } from '../../types/misc.js' -import type { TransactionRequest } from '../../types/transaction.js' import type { UnionOmit } from '../../types/utils.js' -import { - type RecoverAuthorizationAddressErrorType, - recoverAuthorizationAddress, -} from '../../utils/authorization/recoverAuthorizationAddress.js' +import type { RecoverAuthorizationAddressErrorType } from '../../utils/authorization/recoverAuthorizationAddress.js' import type { RequestErrorType } from '../../utils/buildRequest.js' -import { - type AssertCurrentChainErrorType, - assertCurrentChain, -} from '../../utils/chain/assertCurrentChain.js' +import type { AssertCurrentChainErrorType } from '../../utils/chain/assertCurrentChain.js' import { concat } from '../../utils/data/concat.js' import { type GetTransactionErrorReturnType, getTransactionError, } from '../../utils/errors/getTransactionError.js' -import { extract } from '../../utils/formatters/extract.js' -import { - type FormattedTransactionRequest, - formatTransactionRequest, -} from '../../utils/formatters/transactionRequest.js' +import type { FormattedTransactionRequest } from '../../utils/formatters/transactionRequest.js' import { getAction } from '../../utils/getAction.js' import { LruMap } from '../../utils/lru.js' import { @@ -71,6 +60,7 @@ import { type SendRawTransactionSyncReturnType, sendRawTransactionSync, } from './sendRawTransactionSync.js' +import { prepareSendTransactionRequest } from './utils/prepareSendTransactionRequest.js' const supportsWalletNamespace = new LruMap(128) @@ -184,7 +174,7 @@ export async function sendTransactionSync< ): Promise> { const { account: account_ = client.account, - assertChainId = true, + assertChainId: _assertChainId = true, chain = client.chain, accessList, authorizationList, @@ -216,65 +206,11 @@ export async function sendTransactionSync< let nonceManagerParameters: { address: Address; chainId: number } | undefined try { - assertRequest(parameters as AssertRequestParameters) - - const to = await (async () => { - // If `to` exists on the parameters, use that. - if (parameters.to) return parameters.to - - // If `to` is null, we are sending a deployment transaction. - if (parameters.to === null) return undefined - - // If no `to` exists, and we are sending a EIP-7702 transaction, use the - // address of the first authorization in the list. - if (authorizationList && authorizationList.length > 0) - return await recoverAuthorizationAddress({ - authorization: authorizationList[0], - }).catch(() => { - throw new BaseError( - '`to` is required. Could not infer from `authorizationList`.', - ) - }) - - // Otherwise, we are sending a deployment transaction. - return undefined - })() - if (account?.type === 'json-rpc' || account === null) { - let chainId: number | undefined - if (chain !== null) { - chainId = await getAction(client, getChainId, 'getChainId')({}) - if (assertChainId) - assertCurrentChain({ - currentChainId: chainId, - chain, - }) - } - - const chainFormat = client.chain?.formatters?.transactionRequest?.format - const format = chainFormat || formatTransactionRequest - - const request = format( - { - // Pick out extra data that might exist on the chain's transaction request type. - ...extract(rest, { format: chainFormat }), - accessList, - account, - authorizationList, - blobs, - chainId, - data: dataSuffix ? concat([data ?? '0x', dataSuffix]) : data, - gas, - gasPrice, - maxFeePerBlobGas, - maxFeePerGas, - maxPriorityFeePerGas, - nonce, - to, - type, - value, - } as TransactionRequest, - 'sendTransaction', + const { request } = await prepareSendTransactionRequest( + client, + parameters as never, + { docsPath: '/docs/actions/wallet/sendTransactionSync' }, ) const isWalletNamespaceSupported = supportsWalletNamespace.get(client.uid) @@ -348,6 +284,13 @@ export async function sendTransactionSync< return receipt } + assertRequest(parameters as AssertRequestParameters) + + const to = await prepareSendTransactionRequest.resolveTo({ + authorizationList, + to: parameters.to, + }) + if (account?.type === 'local') { if (account.nonceManager && typeof nonce === 'undefined') { const requestChainId = (rest as unknown as { chainId?: number }).chainId diff --git a/src/actions/wallet/utils/prepareSendCallsRequest.test.ts b/src/actions/wallet/utils/prepareSendCallsRequest.test.ts new file mode 100644 index 0000000000..df47b03a1b --- /dev/null +++ b/src/actions/wallet/utils/prepareSendCallsRequest.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from 'vitest' + +import { mainnet } from '../../../chains/index.js' +import { createWalletClient } from '../../../clients/createWalletClient.js' +import { custom } from '../../../clients/transports/custom.js' +import { prepareSendCallsRequest } from './prepareSendCallsRequest.js' + +const sourceAddress = '0x0000000000000000000000000000000000000001' +const targetAddress = '0x0000000000000000000000000000000000000002' + +describe('prepareSendCallsRequest', () => { + test('builds wallet_sendCalls request', () => { + const client = createWalletClient({ + transport: custom({ + async request() { + return null + }, + }), + }) + + const { account, request } = prepareSendCallsRequest(client, { + account: sourceAddress, + calls: [ + { + data: '0x1234', + dataSuffix: '0xabcd', + to: targetAddress, + value: 1n, + }, + ], + chain: mainnet, + forceAtomic: true, + id: '0x1', + version: '2.0.0', + }) + + expect(account?.address).toBe(sourceAddress) + expect(request).toEqual({ + atomicRequired: true, + calls: [ + { + data: '0x1234abcd', + to: targetAddress, + value: '0x1', + }, + ], + capabilities: undefined, + chainId: '0x1', + from: sourceAddress, + id: '0x1', + version: '2.0.0', + }) + }) + + test('applies client dataSuffix as optional capability', () => { + const client = createWalletClient({ + dataSuffix: '0x1234', + transport: custom({ + async request() { + return null + }, + }), + }) + + const { request } = prepareSendCallsRequest(client, { + account: sourceAddress, + calls: [{ to: targetAddress }], + chain: mainnet, + }) + + expect(request.capabilities).toEqual({ + dataSuffix: { + optional: true, + value: '0x1234', + }, + }) + }) + + test('requires a chain before sending an RPC request', () => { + let requested = false + const client = createWalletClient({ + transport: custom({ + async request() { + requested = true + return null + }, + }), + }) + + expect(() => + prepareSendCallsRequest(client, { + account: sourceAddress, + calls: [{ to: targetAddress }], + } as never), + ).toThrowErrorMatchingInlineSnapshot(` + [ChainNotFoundError: No chain was provided to the request. + Please provide a chain with the \`chain\` argument on the Action, or by supplying a \`chain\` to WalletClient. + + Version: viem@x.y.z] + `) + expect(requested).toBe(false) + }) +}) diff --git a/src/actions/wallet/utils/prepareSendCallsRequest.ts b/src/actions/wallet/utils/prepareSendCallsRequest.ts new file mode 100644 index 0000000000..344eacfa22 --- /dev/null +++ b/src/actions/wallet/utils/prepareSendCallsRequest.ts @@ -0,0 +1,91 @@ +import type { Address } from 'abitype' +import { parseAccount } from '../../../accounts/utils/parseAccount.js' +import type { Client } from '../../../clients/createClient.js' +import type { Transport } from '../../../clients/transports/createTransport.js' +import { ChainNotFoundError } from '../../../errors/chain.js' +import type { Account } from '../../../types/account.js' +import type { Call } from '../../../types/calls.js' +import type { Chain } from '../../../types/chain.js' +import { encodeFunctionData } from '../../../utils/abi/encodeFunctionData.js' +import { concat } from '../../../utils/data/concat.js' +import { numberToHex } from '../../../utils/encoding/toHex.js' +import type { SendCallsParameters } from '../sendCalls.js' + +/** @internal */ +export function prepareSendCallsRequest< + const calls extends readonly unknown[], + chain extends Chain | undefined, + account extends Account | undefined = undefined, + chainOverride extends Chain | undefined = undefined, +>( + client: Client, + parameters: SendCallsParameters, +) { + const { + account: account_ = client.account, + chain = client.chain, + experimental_fallback, + experimental_fallbackDelay = 32, + forceAtomic = false, + id, + version = '2.0.0', + } = parameters + + const account = account_ ? parseAccount(account_) : null + if (!chain) throw new ChainNotFoundError() + + let capabilities = parameters.capabilities + + if (client.dataSuffix && !parameters.capabilities?.dataSuffix) { + if (typeof client.dataSuffix === 'string') + capabilities = { + ...parameters.capabilities, + dataSuffix: { value: client.dataSuffix, optional: true }, + } + else + capabilities = { + ...parameters.capabilities, + dataSuffix: { + value: client.dataSuffix.value, + ...(client.dataSuffix.required ? {} : { optional: true }), + }, + } + } + + const preparedCalls = parameters.calls.map((call_: unknown) => { + const call = call_ as Call + + const data = call.abi + ? encodeFunctionData({ + abi: call.abi, + functionName: call.functionName, + args: call.args, + }) + : call.data + + return { + data: call.dataSuffix && data ? concat([data, call.dataSuffix]) : data, + to: call.to, + value: call.value ? numberToHex(call.value) : undefined, + } + }) + + return { + calls: preparedCalls, + capabilities, + chain, + experimental_fallback, + experimental_fallbackDelay, + forceAtomic, + account, + request: { + atomicRequired: forceAtomic, + calls: preparedCalls, + capabilities, + chainId: numberToHex(chain.id), + from: account?.address as Address | undefined, + id, + version, + }, + } +} diff --git a/src/actions/wallet/utils/prepareSendTransactionRequest.test.ts b/src/actions/wallet/utils/prepareSendTransactionRequest.test.ts new file mode 100644 index 0000000000..47813e923c --- /dev/null +++ b/src/actions/wallet/utils/prepareSendTransactionRequest.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from 'vitest' + +import { celo, localhost } from '../../../chains/index.js' +import { createWalletClient } from '../../../clients/createWalletClient.js' +import { custom } from '../../../clients/transports/custom.js' +import { defineChain } from '../../../utils/chain/defineChain.js' +import { prepareSendTransactionRequest } from './prepareSendTransactionRequest.js' + +const sourceAddress = '0x0000000000000000000000000000000000000001' +const targetAddress = '0x0000000000000000000000000000000000000002' +const feeCurrency = '0x0000000000000000000000000000000000000003' + +const chain = defineChain({ + ...localhost, + id: 1, + formatters: { + transactionRequest: celo.formatters.transactionRequest, + }, + serializers: undefined, +}) + +describe('prepareSendTransactionRequest', () => { + test('formats request with action-level chain formatter', async () => { + const requests: { method: string; params: unknown }[] = [] + const client = createWalletClient({ + transport: custom({ + async request({ method, params }) { + requests.push({ method, params }) + if (method === 'eth_chainId') return '0x1' + return null + }, + }), + }) + + const { account, request } = await prepareSendTransactionRequest( + client, + { + account: sourceAddress, + chain, + feeCurrency, + to: targetAddress, + value: 1n, + }, + { docsPath: '/docs/actions/wallet/sendTransaction' }, + ) + + expect(account?.address).toBe(sourceAddress) + expect(requests.map(({ method }) => method)).toEqual(['eth_chainId']) + expect(request).toEqual({ + feeCurrency, + from: sourceAddress, + to: targetAddress, + value: '0x1', + }) + }) + + test('allows chain mismatch when assertChainId is false', async () => { + const client = createWalletClient({ + transport: custom({ + async request({ method }) { + if (method === 'eth_chainId') return '0x2' + return null + }, + }), + }) + + const { request } = await prepareSendTransactionRequest( + client, + { + account: sourceAddress, + assertChainId: false, + chain, + feeCurrency, + to: targetAddress, + value: 1n, + }, + { docsPath: '/docs/actions/wallet/sendTransaction' }, + ) + + expect(request).toEqual({ + feeCurrency, + from: sourceAddress, + to: targetAddress, + value: '0x1', + }) + }) + + test('uses client formatter and skips chain id request when chain is null', async () => { + const requests: { method: string; params: unknown }[] = [] + const client = createWalletClient({ + chain, + transport: custom({ + async request({ method, params }) { + requests.push({ method, params }) + return null + }, + }), + }) + + const { request } = await prepareSendTransactionRequest( + client, + { + account: sourceAddress, + chain: null, + feeCurrency, + to: targetAddress, + value: 1n, + }, + { docsPath: '/docs/actions/wallet/sendTransaction' }, + ) + + expect(requests).toEqual([]) + expect(request).toEqual({ + feeCurrency, + from: sourceAddress, + to: targetAddress, + value: '0x1', + }) + }) +}) diff --git a/src/actions/wallet/utils/prepareSendTransactionRequest.ts b/src/actions/wallet/utils/prepareSendTransactionRequest.ts new file mode 100644 index 0000000000..f9c75649c9 --- /dev/null +++ b/src/actions/wallet/utils/prepareSendTransactionRequest.ts @@ -0,0 +1,147 @@ +import type { Address } from 'abitype' + +import type { Account } from '../../../accounts/types.js' +import { parseAccount } from '../../../accounts/utils/parseAccount.js' +import type { Client } from '../../../clients/createClient.js' +import type { Transport } from '../../../clients/transports/createTransport.js' +import { AccountNotFoundError } from '../../../errors/account.js' +import { BaseError } from '../../../errors/base.js' +import type { AuthorizationList } from '../../../types/authorization.js' +import type { Chain } from '../../../types/chain.js' +import type { Hex } from '../../../types/misc.js' +import type { RpcTransactionRequest } from '../../../types/rpc.js' +import type { TransactionRequest } from '../../../types/transaction.js' +import { recoverAuthorizationAddress } from '../../../utils/authorization/recoverAuthorizationAddress.js' +import { assertCurrentChain } from '../../../utils/chain/assertCurrentChain.js' +import { concat } from '../../../utils/data/concat.js' +import { extract } from '../../../utils/formatters/extract.js' +import { formatTransactionRequest } from '../../../utils/formatters/transactionRequest.js' +import { getAction } from '../../../utils/getAction.js' +import { + type AssertRequestParameters, + assertRequest, +} from '../../../utils/transaction/assertRequest.js' +import { getChainId } from '../../public/getChainId.js' + +export type PrepareSendTransactionRequestParameters = { + account?: Account | Address | null | undefined + assertChainId?: boolean | undefined + chain?: Chain | null | undefined + dataSuffix?: Hex | undefined + [key: string]: unknown +} + +export type PrepareSendTransactionRequestReturnType< + chain extends Chain | undefined, +> = { + account: Account | null + chain: chain | Chain | null | undefined + request: RpcTransactionRequest +} + +/** @internal */ +export async function prepareSendTransactionRequest< + chain extends Chain | undefined, + account extends Account | undefined, +>( + client: Client, + parameters: PrepareSendTransactionRequestParameters, + options: { docsPath: string }, +): Promise> { + const { + account: account_ = client.account, + assertChainId = true, + chain = client.chain, + accessList, + authorizationList, + blobs, + data, + dataSuffix = typeof client.dataSuffix === 'string' + ? client.dataSuffix + : client.dataSuffix?.value, + gas, + gasPrice, + maxFeePerBlobGas, + maxFeePerGas, + maxPriorityFeePerGas, + nonce, + type, + value, + ...rest + } = parameters + + if (typeof account_ === 'undefined') + throw new AccountNotFoundError({ docsPath: options.docsPath }) + const account = account_ ? parseAccount(account_) : null + + assertRequest(parameters as AssertRequestParameters) + + const to = await prepareSendTransactionRequest.resolveTo({ + authorizationList, + to: parameters.to, + }) + + let chainId: number | undefined + if (chain !== null) { + chainId = await getAction(client, getChainId, 'getChainId')({}) + if (assertChainId) + assertCurrentChain({ + currentChainId: chainId, + chain, + }) + } + + const chainFormat = (chain || client.chain)?.formatters?.transactionRequest + ?.format + const format = chainFormat || formatTransactionRequest + + const request = format( + { + ...extract(rest, { format: chainFormat }), + accessList, + account, + authorizationList, + blobs, + chainId, + data: dataSuffix + ? concat([(data as Hex | undefined) ?? '0x', dataSuffix]) + : data, + gas, + gasPrice, + maxFeePerBlobGas, + maxFeePerGas, + maxPriorityFeePerGas, + nonce, + to, + type, + value, + } as TransactionRequest, + 'sendTransaction', + ) as RpcTransactionRequest + + return { account, chain, request } as never +} + +export namespace prepareSendTransactionRequest { + export async function resolveTo(parameters: { + authorizationList?: AuthorizationList | unknown + to?: Address | null | unknown + }): Promise
{ + const { authorizationList, to } = parameters + if (typeof to === 'string') return to as Address + if (to === null) return undefined + if ( + authorizationList && + Array.isArray(authorizationList) && + authorizationList.length > 0 + ) + return await recoverAuthorizationAddress({ + authorization: authorizationList[0], + }).catch(() => { + throw new BaseError( + '`to` is required. Could not infer from `authorizationList`.', + ) + }) + return undefined + } +} diff --git a/src/actions/wallet/utils/prepareWriteContractRequest.ts b/src/actions/wallet/utils/prepareWriteContractRequest.ts new file mode 100644 index 0000000000..0d9e65fd0f --- /dev/null +++ b/src/actions/wallet/utils/prepareWriteContractRequest.ts @@ -0,0 +1,83 @@ +import type { Abi } from 'abitype' + +import type { Account } from '../../../accounts/types.js' +import { parseAccount } from '../../../accounts/utils/parseAccount.js' +import type { Client } from '../../../clients/createClient.js' +import type { Transport } from '../../../clients/transports/createTransport.js' +import { AccountNotFoundError } from '../../../errors/account.js' +import type { Chain } from '../../../types/chain.js' +import type { + ContractFunctionArgs, + ContractFunctionName, +} from '../../../types/contract.js' +import { + type EncodeFunctionDataParameters, + encodeFunctionData, +} from '../../../utils/abi/encodeFunctionData.js' +import type { WriteContractParameters } from '../writeContract.js' + +/** @internal */ +export function prepareWriteContractRequest< + chain extends Chain | undefined, + account extends Account | undefined, + const abi extends Abi | readonly unknown[], + functionName extends ContractFunctionName, + args extends ContractFunctionArgs< + abi, + 'nonpayable' | 'payable', + functionName + >, + chainOverride extends Chain | undefined, +>( + client: Client, + parameters: WriteContractParameters< + abi, + functionName, + args, + chain, + account, + chainOverride + >, + options: prepareWriteContractRequest.Options = {}, +) { + const { + abi, + account: account_ = client.account, + address, + args, + functionName, + ...request + } = parameters as WriteContractParameters + + if (typeof account_ === 'undefined') + throw new AccountNotFoundError({ + docsPath: options.docsPath ?? '/docs/contract/writeContract', + }) + const account = account_ ? parseAccount(account_) : null + + const data = encodeFunctionData({ + abi, + args, + functionName, + } as EncodeFunctionDataParameters) + + return { + abi, + account, + address, + args, + functionName, + request: { + data, + to: address, + account, + ...request, + }, + } +} + +export declare namespace prepareWriteContractRequest { + export type Options = { + docsPath?: string | undefined + } +} diff --git a/src/actions/wallet/writeContract.ts b/src/actions/wallet/writeContract.ts index 4c3a350884..023ea38f02 100644 --- a/src/actions/wallet/writeContract.ts +++ b/src/actions/wallet/writeContract.ts @@ -1,16 +1,10 @@ import type { Abi, Address } from 'abitype' import type { Account } from '../../accounts/types.js' -import { - type ParseAccountErrorType, - parseAccount, -} from '../../accounts/utils/parseAccount.js' +import type { ParseAccountErrorType } from '../../accounts/utils/parseAccount.js' import type { Client } from '../../clients/createClient.js' import type { Transport } from '../../clients/transports/createTransport.js' -import { - AccountNotFoundError, - type AccountNotFoundErrorType, -} from '../../errors/account.js' +import type { AccountNotFoundErrorType } from '../../errors/account.js' import type { BaseError } from '../../errors/base.js' import type { ErrorType } from '../../errors/utils.js' import type { GetAccountParameter } from '../../types/account.js' @@ -26,11 +20,7 @@ import type { } from '../../types/contract.js' import type { Hex } from '../../types/misc.js' import type { Prettify, UnionEvaluate, UnionOmit } from '../../types/utils.js' -import { - type EncodeFunctionDataErrorType, - type EncodeFunctionDataParameters, - encodeFunctionData, -} from '../../utils/abi/encodeFunctionData.js' +import type { EncodeFunctionDataErrorType } from '../../utils/abi/encodeFunctionData.js' import { type GetContractErrorReturnType, getContractError, @@ -44,6 +34,7 @@ import { sendTransaction, } from './sendTransaction.js' import type { sendTransactionSync } from './sendTransactionSync.js' +import { prepareWriteContractRequest } from './utils/prepareWriteContractRequest.js' export type WriteContractParameters< abi extends Abi | readonly unknown[] = Abi, @@ -205,38 +196,11 @@ export namespace writeContract { chainOverride >, ) { - const { - abi, - account: account_ = client.account, - address, - args, - functionName, - ...request - } = parameters as WriteContractParameters - - if (typeof account_ === 'undefined') - throw new AccountNotFoundError({ - docsPath: '/docs/contract/writeContract', - }) - const account = account_ ? parseAccount(account_) : null - - const data = encodeFunctionData({ - abi, - args, - functionName, - } as EncodeFunctionDataParameters) + const { abi, account, address, args, functionName, request } = + prepareWriteContractRequest(client, parameters) try { - return await getAction( - client, - actionFn as never, - name, - )({ - data, - to: address, - account, - ...request, - }) + return await getAction(client, actionFn as never, name)(request as never) } catch (error) { throw getContractError(error as BaseError, { abi, diff --git a/src/clients/decorators/wallet.test.ts b/src/clients/decorators/wallet.test.ts index 98902d8e4d..778a4accd1 100644 --- a/src/clients/decorators/wallet.test.ts +++ b/src/clients/decorators/wallet.test.ts @@ -19,6 +19,7 @@ test('default', async () => { expect(walletActions(walletClient as any)).toMatchInlineSnapshot(` { "addChain": [Function], + "connect": [Function], "deployContract": [Function], "fillTransaction": [Function], "getAddresses": [Function], @@ -55,6 +56,10 @@ describe('smoke test', () => { await walletClient.addChain({ chain: avalanche }) }) + test('connect', async () => { + expect(await walletClient.connect()).toBeDefined() + }) + test('deployContract', async () => { expect( await walletClient.deployContract({ diff --git a/src/clients/decorators/wallet.ts b/src/clients/decorators/wallet.ts index 170b06f256..829332524d 100644 --- a/src/clients/decorators/wallet.ts +++ b/src/clients/decorators/wallet.ts @@ -14,6 +14,11 @@ import { type AddChainParameters, addChain, } from '../../actions/wallet/addChain.js' +import { + type ConnectParameters, + type ConnectReturnType, + connect, +} from '../../actions/wallet/connect.js' import { type DeployContractParameters, type DeployContractReturnType, @@ -169,6 +174,26 @@ export type WalletActions< * await client.addChain({ chain: optimism }) */ addChain: (args: AddChainParameters) => Promise + /** + * Requests to connect account(s) with optional capabilities. + * + * - Docs: https://viem.sh/docs/actions/wallet/connect + * - JSON-RPC Methods: [`wallet_connect`](https://github.com/ethereum/ERCs/blob/abd1c9f4eda2d6ad06ade0e3af314637a27d1ee7/ERCS/erc-7846.md) + * + * @param args - {@link ConnectParameters} + * @returns List of accounts managed by a wallet {@link ConnectReturnType} + * + * @example + * import { createWalletClient, custom } from 'viem' + * import { mainnet } from 'viem/chains' + * + * const client = createWalletClient({ + * chain: mainnet, + * transport: custom(window.ethereum), + * }) + * const response = await client.connect() + */ + connect: (args?: ConnectParameters) => Promise /** * Deploys a contract to the network, given bytecode and constructor arguments. * @@ -1184,6 +1209,7 @@ export function walletActions< >(client: Client): WalletActions { return { addChain: (args) => addChain(client, args), + connect: (args) => connect(client, args), deployContract: (args) => deployContract(client, args), fillTransaction: (args) => fillTransaction(client, args), getAddresses: () => getAddresses(client), diff --git a/src/experimental/erc7846/actions/connect.ts b/src/experimental/erc7846/actions/connect.ts index dec2c1a116..265574ed7d 100644 --- a/src/experimental/erc7846/actions/connect.ts +++ b/src/experimental/erc7846/actions/connect.ts @@ -1,179 +1,7 @@ -import type { Address } from 'abitype' -import { - type RequestAddressesErrorType, - requestAddresses, -} from '../../../actions/wallet/requestAddresses.js' -import type { Client } from '../../../clients/createClient.js' -import type { Transport } from '../../../clients/transports/createTransport.js' -import type { BaseError } from '../../../errors/base.js' -import type { ExtractCapabilities } from '../../../types/capabilities.js' -import type { Chain } from '../../../types/chain.js' -import type { Prettify } from '../../../types/utils.js' -import type { RequestErrorType } from '../../../utils/buildRequest.js' -import { numberToHex } from '../../../utils/encoding/toHex.js' - -export type ConnectParameters = Prettify<{ - capabilities?: ExtractCapabilities<'connect', 'Request'> | undefined -}> - -export type ConnectReturnType = Prettify<{ - accounts: readonly { - address: Address - capabilities?: ExtractCapabilities<'connect', 'ReturnType'> | undefined - }[] -}> - -export type ConnectErrorType = RequestErrorType | RequestAddressesErrorType - -/** - * Requests to connect account(s) with optional capabilities. - * - * - Docs: https://viem.sh/experimental/erc7846/connect - * - JSON-RPC Methods: [`wallet_connect`](https://github.com/ethereum/ERCs/blob/abd1c9f4eda2d6ad06ade0e3af314637a27d1ee7/ERCS/erc-7846.md) - * - * @param client - Client to use - * @param parameters - {@link ConnectParameters} - * @returns List of accounts managed by a wallet {@link ConnectReturnType} - * - * @example - * import { createWalletClient, custom } from 'viem' - * import { mainnet } from 'viem/chains' - * import { connect } from 'viem/experimental/erc7846' - * - * const client = createWalletClient({ - * chain: mainnet, - * transport: custom(window.ethereum), - * }) - * const response = await connect(client) - */ -export async function connect( - client: Client, - parameters: ConnectParameters = {}, -): Promise { - const capabilities = formatRequestCapabilities(parameters.capabilities) - - const response = await (async () => { - try { - return await client.request( - { method: 'wallet_connect', params: [{ capabilities, version: '1' }] }, - { dedupe: true, retryCount: 0 }, - ) - } catch (e) { - const error = e as BaseError - - // If the wallet does not support `wallet_connect`, and has no - // capabilities, attempt to use `eth_requestAccounts` instead. - if ( - !parameters.capabilities && - (error.name === 'InvalidInputRpcError' || - error.name === 'InvalidParamsRpcError' || - error.name === 'MethodNotFoundRpcError' || - error.name === 'MethodNotSupportedRpcError' || - error.name === 'UnsupportedProviderMethodError') - ) { - const addresses = await requestAddresses(client) - return { - accounts: addresses.map((address) => ({ - address, - capabilities: {}, - })), - } - } - - throw error - } - })() - - return { - ...response, - accounts: (response.accounts ?? []).map((account) => ({ - ...account, - capabilities: formatResponseCapabilities(account.capabilities), - })), - } -} - -function formatRequestCapabilities( - capabilities: ExtractCapabilities<'connect', 'Request'> | undefined, -) { - const { - unstable_addSubAccount, - unstable_getSubAccounts: getSubAccounts, - unstable_signInWithEthereum, - ...rest - } = capabilities ?? {} - - const addSubAccount = unstable_addSubAccount - ? { - ...unstable_addSubAccount, - account: { - ...unstable_addSubAccount.account, - ...(unstable_addSubAccount.account.chainId - ? { - chainId: numberToHex(unstable_addSubAccount.account.chainId), - } - : {}), - }, - } - : undefined - - const { chainId, expirationTime, issuedAt, notBefore } = - unstable_signInWithEthereum ?? {} - const signInWithEthereum = unstable_signInWithEthereum - ? { - ...unstable_signInWithEthereum, - chainId: numberToHex(chainId!), - ...(expirationTime - ? { - expirationTime: expirationTime.toISOString(), - } - : {}), - ...(issuedAt - ? { - issuedAt: issuedAt.toISOString(), - } - : {}), - ...(notBefore - ? { - notBefore: notBefore.toISOString(), - } - : {}), - } - : undefined - - return { - ...rest, - ...(addSubAccount - ? { - addSubAccount, - } - : {}), - ...(getSubAccounts - ? { - getSubAccounts, - } - : {}), - ...(signInWithEthereum - ? { - signInWithEthereum, - } - : {}), - } -} - -function formatResponseCapabilities( - capabilities: ExtractCapabilities<'connect', 'ReturnType'> | undefined, -) { - return Object.entries(capabilities ?? {}).reduce( - (capabilities, [key, value]) => { - const k = (() => { - if (key === 'signInWithEthereum') return 'unstable_signInWithEthereum' - if (key === 'subAccounts') return 'unstable_subAccounts' - return key - })() - capabilities[k] = value - return capabilities - }, - {} as Record, - ) -} +// biome-ignore lint/performance/noBarrelFile: compatibility re-export for the experimental entrypoint +export { + type ConnectErrorType, + type ConnectParameters, + type ConnectReturnType, + connect, +} from '../../../actions/wallet/connect.js' diff --git a/src/index.ts b/src/index.ts index 37d9385cc9..d1b29ad2f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -455,6 +455,11 @@ export type { AddChainErrorType, AddChainParameters, } from './actions/wallet/addChain.js' +export type { + ConnectErrorType, + ConnectParameters, + ConnectReturnType, +} from './actions/wallet/connect.js' export type { DeployContractErrorType, DeployContractParameters, diff --git a/src/tempo/Capabilities.ts b/src/tempo/Capabilities.ts index 6064bf3bcc..cbb13d320a 100644 --- a/src/tempo/Capabilities.ts +++ b/src/tempo/Capabilities.ts @@ -1,11 +1,22 @@ import type { Address } from 'abitype' +import type { KeyAuthorization } from 'ox/tempo' import type { DefaultCapabilitiesSchema } from '../types/capabilities.js' import type { Hex } from '../types/misc.js' import type { ExactPartial, OneOf } from '../types/utils.js' import type { DecodeErrorResultReturnType } from '../utils/index.js' +import type { WalletAuthorizeAccessKeyParameters } from './internal/walletAccessKey.js' import type { TransactionRequestTempo } from './Transaction.js' -export type Schema = Omit & { +export type Schema = Omit< + DefaultCapabilitiesSchema, + 'connect' | 'sendCalls' +> & { + connect: { + Request: DefaultCapabilitiesSchema['connect']['Request'] & + ConnectCapabilities + ReturnType: DefaultCapabilitiesSchema['connect']['ReturnType'] & + ConnectCapabilitiesReturn + } fillTransaction: { Request: FillTransactionRequestCapabilities ReturnType: FillTransactionCapabilities @@ -15,6 +26,85 @@ export type Schema = Omit & { } } +export type ConnectCapabilities = + | ConnectRegisterCapabilities + | ConnectLoginCapabilities + +export type ConnectRegisterCapabilities = { + /** Access key to authorize during connect. */ + authorizeAccessKey?: ConnectAuthorizeAccessKey | undefined + /** Digest to sign during account creation. */ + digest?: Hex | undefined + /** Server-auth configuration. */ + auth?: ConnectAuth | undefined + /** Register a new account. */ + method: 'register' + /** Account label. */ + name?: string | undefined + /** Message to sign during connect. */ + personalSign?: ConnectPersonalSign | undefined + /** Optional funding prompt to show after connect. */ + showDeposit?: ConnectShowDeposit | undefined + /** User ID for account registration. */ + userId?: string | undefined +} + +export type ConnectLoginCapabilities = { + /** Access key to authorize during connect. */ + authorizeAccessKey?: ConnectAuthorizeAccessKey | undefined + /** Credential ID to load. */ + credentialId?: string | undefined + /** Digest to sign during login. */ + digest?: Hex | undefined + /** Server-auth configuration. */ + auth?: ConnectAuth | undefined + /** Login to an existing account. */ + method?: 'login' | undefined + /** Message to sign during connect. */ + personalSign?: ConnectPersonalSign | undefined + /** Whether to show account selection. */ + selectAccount?: boolean | undefined + /** Optional funding prompt to show after connect. */ + showDeposit?: ConnectShowDeposit | undefined +} + +export type ConnectAuthorizeAccessKey = Omit< + WalletAuthorizeAccessKeyParameters, + 'showDeposit' +> + +export type ConnectAuth = + | string + | { + challenge?: string | undefined + logout?: string | undefined + returnToken?: boolean | undefined + url?: string | undefined + verify?: string | undefined + } + +export type ConnectPersonalSign = { + message: string +} + +export type ConnectShowDeposit = + | boolean + | { + amount?: string | undefined + displayName?: string | undefined + on?: 'login' | 'register' | undefined + token?: Address | string | undefined + } + +export type ConnectCapabilitiesReturn = { + auth?: { token?: string | undefined } | undefined + email?: string | null | undefined + keyAuthorization?: KeyAuthorization.Signed | undefined + personalSign?: ConnectPersonalSign | undefined + signature?: Hex | undefined + username?: string | null | undefined +} + export type FillTransactionRequestCapabilities = { /** Whether to include `balanceDiffs` in the response. */ balanceDiffs?: boolean | undefined diff --git a/src/tempo/Decorator.test.ts b/src/tempo/Decorator.test.ts index a54e2f1de1..8484e811a7 100644 --- a/src/tempo/Decorator.test.ts +++ b/src/tempo/Decorator.test.ts @@ -26,6 +26,11 @@ describe('decorator', () => { "type", "uid", "extend", + "authorizeAccessKey", + "revokeAccessKey", + "sendCallsSync", + "sendTransactionSync", + "writeContractSync", "accessKey", "amm", "channel", diff --git a/src/tempo/Decorator.ts b/src/tempo/Decorator.ts index 878daa63c9..1feab3f0cb 100644 --- a/src/tempo/Decorator.ts +++ b/src/tempo/Decorator.ts @@ -1,8 +1,12 @@ -import type { Address } from 'abitype' +import type { Abi, Address } from 'abitype' import type { Account } from '../accounts/types.js' import type { Client } from '../clients/createClient.js' import type { Transport } from '../clients/transports/createTransport.js' import type { Chain } from '../types/chain.js' +import type { + ContractFunctionArgs, + ContractFunctionName, +} from '../types/contract.js' import * as accessKeyActions from './actions/accessKey.js' import * as ammActions from './actions/amm.js' import * as channelActions from './actions/channel.js' @@ -17,12 +21,120 @@ import * as simulateActions from './actions/simulate.js' import * as tokenActions from './actions/token.js' import * as validatorActions from './actions/validator.js' import * as virtualAddressActions from './actions/virtualAddress.js' +import * as walletActions from './actions/wallet.js' import * as zoneActions from './actions/zone.js' export type Decorator< chain extends Chain | undefined = Chain | undefined, account extends Account | undefined = Account | undefined, > = { + /** + * Authorizes an access key through the connected wallet. + * + * @example + * ```ts + * import { createWalletClient, custom } from 'viem' + * import { tempo } from 'viem/chains' + * import { tempoActions } from 'viem/tempo' + * + * const client = createWalletClient({ + * chain: tempo, + * transport: custom(window.ethereum), + * }).extend(tempoActions()) + * + * const { keyAuthorization } = await client.authorizeAccessKey({ + * accessKey: { address: '0x...', type: 'p256' }, + * expiry: Math.floor(Date.now() / 1000) + 86_400, + * }) + * ``` + * + * @param parameters - Parameters. + * @returns The signed key authorization and root account address. + */ + authorizeAccessKey: ( + parameters: walletActions.authorizeAccessKey.Parameters, + ) => Promise + /** + * Revokes an access key through the connected wallet. + * + * @example + * ```ts + * import { createWalletClient, custom } from 'viem' + * import { tempo } from 'viem/chains' + * import { tempoActions } from 'viem/tempo' + * + * const client = createWalletClient({ + * account: '0x...', + * chain: tempo, + * transport: custom(window.ethereum), + * }).extend(tempoActions()) + * + * await client.revokeAccessKey({ + * accessKey: '0x...', + * }) + * ``` + * + * @param parameters - Parameters. + * @returns Nothing. + */ + revokeAccessKey: ( + parameters: walletActions.revokeAccessKey.Parameters, + ) => Promise + /** + * Requests the connected wallet to send a batch of calls synchronously. + * + * @param parameters - Parameters. + * @returns Calls status. + */ + sendCallsSync: < + const calls extends readonly unknown[], + chainOverride extends Chain | undefined = undefined, + >( + parameters: walletActions.sendCallsSync.Parameters< + chain, + account, + chainOverride, + calls + >, + ) => Promise + /** + * Creates, signs, and sends a new Tempo transaction synchronously. + * + * @param parameters - Parameters. + * @returns The transaction receipt. + */ + sendTransactionSync: ( + parameters: walletActions.sendTransactionSync.Parameters< + chain, + account, + chainOverride + >, + ) => Promise> + /** + * Executes a write function on a contract using Tempo's sync transaction RPC. + * + * @param parameters - Parameters. + * @returns The transaction receipt. + */ + writeContractSync: < + const abi extends Abi | readonly unknown[], + functionName extends ContractFunctionName, + args extends ContractFunctionArgs< + abi, + 'nonpayable' | 'payable', + functionName + >, + chainOverride extends Chain | undefined = undefined, + >( + parameters: walletActions.writeContractSync.Parameters< + abi, + functionName, + args, + chain, + account, + chainOverride + >, + ) => Promise> accessKey: { /** * Authorizes an access key by signing a key authorization and sending a transaction. @@ -5083,6 +5195,16 @@ export function decorator() { client: Client, ): Decorator => { return { + authorizeAccessKey: (parameters) => + walletActions.authorizeAccessKey(client, parameters), + revokeAccessKey: (parameters) => + walletActions.revokeAccessKey(client, parameters), + sendCallsSync: (parameters) => + walletActions.sendCallsSync(client, parameters), + sendTransactionSync: (parameters) => + walletActions.sendTransactionSync(client, parameters), + writeContractSync: (parameters) => + walletActions.writeContractSync(client, parameters as never), accessKey: { authorize: (parameters) => accessKeyActions.authorize(client, parameters), diff --git a/src/tempo/actions/wallet.test.ts b/src/tempo/actions/wallet.test.ts index 14131f5872..9e67fd654f 100644 --- a/src/tempo/actions/wallet.test.ts +++ b/src/tempo/actions/wallet.test.ts @@ -1,9 +1,35 @@ -import { expect, test } from 'vitest' +import { parseAbi } from 'abitype' +import * as Address from 'ox/Address' +import * as P256 from 'ox/P256' +import * as PublicKey from 'ox/PublicKey' +import { KeyAuthorization } from 'ox/tempo' +import { expect, test, vi } from 'vitest' +import { privateKeyToAccount } from '../../accounts/privateKeyToAccount.js' +import { connect } from '../../actions/wallet/connect.js' +import * as coreSendTransactionSync from '../../actions/wallet/sendTransactionSync.js' +import { tempoLocalnet } from '../../chains/index.js' import { createClient } from '../../clients/createClient.js' +import { createWalletClient } from '../../clients/createWalletClient.js' import { custom } from '../../clients/transports/custom.js' +import { UnsupportedProviderMethodError } from '../../errors/rpc.js' +import type { WalletGetCallsStatusReturnType } from '../../types/eip1193.js' +import type { RpcTransactionReceipt } from '../../types/rpc.js' +import { numberToHex } from '../../utils/encoding/toHex.js' import type { TransactionReceipt } from '../Transaction.js' import * as wallet from './wallet.js' +const account = '0x0000000000000000000000000000000000000001' +const accessKey = '0x0000000000000000000000000000000000000002' +const recipient = '0x0000000000000000000000000000000000000003' +const feeToken = '0x20c0000000000000000000000000000000000001' +const privateKey_p256 = + '0x5c878151adef73f88b1c360d33e9bf9dd1b6e2e0e07bc555fc33cb8cf6bc9b28' +const publicKey_p256 = P256.getPublicKey({ privateKey: privateKey_p256 }) +const publicKey_p256_hex = PublicKey.toHex(publicKey_p256, { + includePrefix: false, +}) +const publicKey_p256_address = Address.fromPublicKey(publicKey_p256) + const receipt = { blockHash: '0x0000000000000000000000000000000000000000000000000000000000000001', @@ -23,12 +49,105 @@ const receipt = { type: 'tempo', } satisfies TransactionReceipt -const client = (requests: unknown[]) => +const rpcReceipt = ( + overrides: Partial = {}, +): RpcTransactionReceipt => + ({ + blockHash: + '0x0000000000000000000000000000000000000000000000000000000000000001', + blockNumber: '0x1', + contractAddress: null, + cumulativeGasUsed: '0x1', + effectiveGasPrice: '0x1', + from: account, + gasUsed: '0x1', + logs: [], + logsBloom: '0x0', + status: '0x1', + to: null, + transactionHash: + '0x0000000000000000000000000000000000000000000000000000000000000002', + transactionIndex: '0x0', + type: '0x76', + ...overrides, + }) as RpcTransactionReceipt + +const keyAuthorizationRpc = { + chainId: numberToHex(tempoLocalnet.id), + expiry: numberToHex(123), + keyId: accessKey, + keyType: 'secp256k1', + limits: [{ token: feeToken, limit: '0x7b', period: '0x3c' }], + signature: { + type: 'secp256k1', + r: '0x635dc2033e60185bb36709c29c75d64ea51dfbd91c32ef4be198e4ceb169fb4d', + s: '0x50c2667ac4c771072746acfdcf1f1483336dcca8bd2df47cd83175dbe60f0540', + yParity: '0x0', + }, +} satisfies KeyAuthorization.Rpc + +const keyAuthorization = KeyAuthorization.fromRpc(keyAuthorizationRpc) + +const client = ( + requests: unknown[], + handlers: Record unknown> = {}, + chain: typeof tempoLocalnet | null = tempoLocalnet, +) => createClient({ + account, + ...(chain ? { chain } : {}), transport: custom({ async request(request) { requests.push(request) + if (request.method in handlers) return handlers[request.method](request) + + if (request.method === 'eth_chainId') + return numberToHex(tempoLocalnet.id) + if (request.method === 'eth_sendTransactionSync') + return rpcReceipt() as never + if (request.method === 'wallet_sendCallsSync') + return { + atomic: false, + chainId: numberToHex(tempoLocalnet.id), + id: '0x1', + receipts: [ + { + blockHash: + '0x0000000000000000000000000000000000000000000000000000000000000001', + blockNumber: '0x1', + gasUsed: '0x1', + logs: [], + status: '0x1', + transactionHash: + '0x0000000000000000000000000000000000000000000000000000000000000002', + }, + ], + status: 200, + version: '2.0.0', + } satisfies WalletGetCallsStatusReturnType + if (request.method === 'wallet_authorizeAccessKey') + return { + keyAuthorization: keyAuthorizationRpc, + rootAddress: account, + } + if (request.method === 'wallet_connect') + return { + accounts: [ + { + address: account, + capabilities: { + auth: { token: 'token' }, + email: null, + keyAuthorization: keyAuthorizationRpc, + personalSign: { message: 'hello' }, + signature: '0x1234', + username: 'tony', + }, + }, + ], + } + if (request.method === 'wallet_revokeAccessKey') return undefined if (request.method === 'wallet_transfer') return { chainId: 4321, receipt } if (request.method === 'wallet_swap') return { receipt } @@ -40,6 +159,543 @@ const client = (requests: unknown[]) => }), }) +test('connect sends wallet_connect and formats capabilities', async () => { + const requests: any[] = [] + const result = await connect(client(requests, {}, null), { + chain: tempoLocalnet, + capabilities: { + authorizeAccessKey: { + accessKey: { address: accessKey, type: 'secp256k1' }, + expiry: 123, + limits: [{ token: feeToken, limit: 123n, period: 60 }], + scopes: [ + { + address: recipient, + selector: 'transfer(address,uint256)', + recipients: [accessKey], + }, + ], + }, + auth: { returnToken: true, url: '/api/auth' }, + method: 'register', + name: 'default', + personalSign: { message: 'hello' }, + showDeposit: { amount: '50', on: 'register', token: 'USDC' }, + userId: 'user', + }, + }) + + expect(requests.map(({ method }) => method)).toEqual(['wallet_connect']) + expect(requests[0].params[0]).toEqual({ + capabilities: { + authorizeAccessKey: { + address: accessKey, + chainId: numberToHex(tempoLocalnet.id), + expiry: 123, + keyType: 'secp256k1', + limits: [{ limit: '0x7b', period: 60, token: feeToken }], + scopes: [ + { + address: recipient, + recipients: [accessKey], + selector: '0xa9059cbb', + }, + ], + }, + auth: { returnToken: true, url: '/api/auth' }, + method: 'register', + name: 'default', + personalSign: { message: 'hello' }, + showDeposit: { amount: '50', on: 'register', token: 'USDC' }, + userId: 'user', + }, + chainId: numberToHex(tempoLocalnet.id), + version: '1', + }) + expect(result).toEqual({ + accounts: [ + { + address: account, + capabilities: { + auth: { token: 'token' }, + email: null, + keyAuthorization, + personalSign: { message: 'hello' }, + signature: '0x1234', + username: 'tony', + }, + }, + ], + }) +}) + +test('connect defaults chainId from client chain', async () => { + const requests: any[] = [] + await connect(client(requests), { + capabilities: { + authorizeAccessKey: { + expiry: 123, + keyType: 'p256', + }, + method: 'login', + }, + }) + + expect(requests.map(({ method }) => method)).toEqual(['wallet_connect']) + expect(requests[0].params[0]).toEqual({ + capabilities: { + authorizeAccessKey: { + chainId: numberToHex(tempoLocalnet.id), + expiry: 123, + keyType: 'p256', + }, + method: 'login', + }, + chainId: numberToHex(tempoLocalnet.id), + version: '1', + }) +}) + +test('connect normalizes accounts without capabilities', async () => { + const requests: any[] = [] + const result = await connect( + client(requests, { + wallet_connect: () => ({ accounts: [{ address: account }] }), + }), + ) + + expect(requests.map(({ method }) => method)).toEqual(['wallet_connect']) + expect(result.accounts).toEqual([{ address: account, capabilities: {} }]) +}) + +test('connect does not fallback to eth_requestAccounts', async () => { + const requests: any[] = [] + await expect( + connect( + client(requests, { + wallet_connect: () => { + throw new UnsupportedProviderMethodError(new Error()) + }, + eth_requestAccounts: () => [account], + }), + ), + ).rejects.toThrow(UnsupportedProviderMethodError) + + expect(requests.map(({ method }) => method)).toEqual(['wallet_connect']) +}) + +test('sendTransactionSync sends eth_sendTransactionSync and formats Tempo fields', async () => { + const requests: any[] = [] + const result = await wallet.sendTransactionSync(client(requests), { + account, + data: '0xabcd', + dataSuffix: '0x1234', + feeToken, + to: recipient, + type: 'tempo', + value: 1n, + }) + + expect(requests.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_sendTransactionSync', + ]) + expect(requests[1].params[0]).toMatchObject({ + calls: [{ data: '0xabcd1234', to: recipient, value: '0x1' }], + chainId: numberToHex(tempoLocalnet.id), + feeToken, + from: account, + type: '0x76', + }) + expect(result.blockNumber).toBe(1n) + expect(result.status).toBe('success') +}) + +test('sendTransactionSync uses action-level chain formatter', async () => { + const requests: any[] = [] + await wallet.sendTransactionSync(client(requests, {}, null), { + account, + chain: tempoLocalnet, + feeToken, + to: recipient, + type: 'tempo', + value: 1n, + }) + + expect(requests.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_sendTransactionSync', + ]) + expect(requests[1].params[0]).toMatchObject({ + calls: [{ to: recipient, value: '0x1' }], + chainId: numberToHex(tempoLocalnet.id), + feeToken, + from: account, + type: '0x76', + }) +}) + +test('sendTransactionSync preserves explicit null account', async () => { + const requests: any[] = [] + await wallet.sendTransactionSync(client(requests), { + account: null, + to: recipient, + type: 'tempo', + value: 1n, + }) + + expect(requests.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_sendTransactionSync', + ]) + expect(requests[1].params[0]).toMatchObject({ + calls: [{ to: recipient, value: '0x1' }], + chainId: numberToHex(tempoLocalnet.id), + type: '0x76', + }) + expect(requests[1].params[0]).not.toHaveProperty('from') +}) + +test('sendTransactionSync falls back to hoisted account for undefined account', async () => { + const requests: any[] = [] + await wallet.sendTransactionSync(client(requests), { + account: undefined, + to: recipient, + type: 'tempo', + value: 1n, + }) + + expect(requests[1].params[0]).toMatchObject({ + from: account, + }) +}) + +test('sendTransactionSync passes timeout to eth_sendTransactionSync', async () => { + const requests: any[] = [] + await wallet.sendTransactionSync(client(requests), { + account, + timeout: 0, + to: recipient, + type: 'tempo', + }) + + expect(requests.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_sendTransactionSync', + ]) + expect(requests[1].params).toHaveLength(2) + expect(requests[1].params[1]).toBe(0) +}) + +test('sendTransactionSync throws on reverted receipt by default', async () => { + const requests: any[] = [] + await expect( + wallet.sendTransactionSync( + client(requests, { + eth_sendTransactionSync: () => rpcReceipt({ status: '0x0' }), + }), + { + account, + to: recipient, + type: 'tempo', + }, + ), + ).rejects.toThrow('reverted') +}) + +test('sendTransactionSync delegates local accounts to core raw sync path', async () => { + const spy = vi + .spyOn(coreSendTransactionSync, 'sendTransactionSync') + .mockResolvedValue(receipt as never) + const localAccount = privateKeyToAccount( + '0x0000000000000000000000000000000000000000000000000000000000000001', + ) + const requests: unknown[] = [] + + const result = await wallet.sendTransactionSync(client(requests), { + account: localAccount, + to: recipient, + value: 1n, + }) + + expect(result).toBe(receipt) + expect(spy).toHaveBeenCalledOnce() + expect(requests).toEqual([]) + spy.mockRestore() +}) + +test('writeContractSync routes through Tempo sendTransactionSync', async () => { + const requests: any[] = [] + await wallet.writeContractSync(client(requests), { + account, + address: recipient, + abi: parseAbi(['function mint(uint256 tokenId)']), + args: [1n], + functionName: 'mint', + type: 'tempo', + }) + + expect(requests.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_sendTransactionSync', + ]) + expect(requests[1].params[0].calls[0]).toMatchObject({ + to: recipient, + }) + expect(requests[1].params[0].calls[0].data).toMatch(/^0xa0712d68/) +}) + +test('writeContractSync routes through Tempo sendTransactionSync on wallet clients', async () => { + const requests: any[] = [] + const walletClient = createWalletClient({ + account, + chain: tempoLocalnet, + transport: custom({ + async request(request) { + requests.push(request) + if (request.method === 'eth_chainId') + return numberToHex(tempoLocalnet.id) + if (request.method === 'eth_sendTransactionSync') + return rpcReceipt() as never + if (request.method === 'eth_sendTransaction') + throw new Error('Unexpected core sendTransactionSync path.') + return null + }, + }), + }) + + await wallet.writeContractSync(walletClient, { + account, + address: recipient, + abi: parseAbi(['function mint(uint256 tokenId)']), + args: [1n], + functionName: 'mint', + type: 'tempo', + }) + + expect(requests.map(({ method }) => method)).toEqual([ + 'eth_chainId', + 'eth_sendTransactionSync', + ]) +}) + +test('sendCallsSync sends wallet_sendCallsSync and formats status', async () => { + const requests: any[] = [] + const result = await wallet.sendCallsSync(client(requests), { + account, + calls: [{ data: '0x1234', to: recipient, value: 1n }], + throwOnFailure: true, + }) + + expect(requests.map(({ method }) => method)).toEqual(['wallet_sendCallsSync']) + expect(requests[0].params[0].calls).toEqual([ + { data: '0x1234', to: recipient, value: '0x1' }, + ]) + expect(result.status).toBe('success') + expect(result.receipts![0].blockNumber).toBe(1n) + expect(result.receipts![0].status).toBe('success') +}) + +test('sendCallsSync passes timeout to wallet_sendCallsSync', async () => { + const requests: any[] = [] + await wallet.sendCallsSync(client(requests), { + account, + calls: [{ to: recipient }], + timeout: 1234, + }) + + expect(requests.map(({ method }) => method)).toEqual(['wallet_sendCallsSync']) + expect(requests[0].params).toEqual([ + expect.objectContaining({ + calls: [{ to: recipient, value: undefined, data: undefined }], + }), + 1234, + ]) +}) + +test('authorizeAccessKey sends wallet_authorizeAccessKey and returns authorization', async () => { + const requests: any[] = [] + const result = await wallet.authorizeAccessKey(client(requests), { + accessKey: { address: accessKey, type: 'secp256k1' }, + expiry: 123, + limits: [{ token: feeToken, limit: 123n, period: 60 }], + scopes: [ + { + address: recipient, + selector: 'transfer(address,uint256)', + recipients: [accessKey], + }, + ], + showDeposit: { amount: '50', token: 'USDC' }, + }) + + expect(requests.map(({ method }) => method)).toEqual([ + 'wallet_authorizeAccessKey', + ]) + expect(requests[0].params[0]).toEqual({ + address: accessKey, + chainId: numberToHex(tempoLocalnet.id), + expiry: 123, + keyType: 'secp256k1', + limits: [{ limit: '0x7b', period: 60, token: feeToken }], + scopes: [ + { + address: recipient, + recipients: [accessKey], + selector: '0xa9059cbb', + }, + ], + showDeposit: { amount: '50', token: 'USDC' }, + }) + expect(result).toEqual({ + keyAuthorization, + rootAddress: account, + }) +}) + +test('authorizeAccessKey omits chainId without a client chain', async () => { + const requests: any[] = [] + await wallet.authorizeAccessKey(client(requests, {}, null), { + accessKey: { address: accessKey, type: 'secp256k1' }, + expiry: 123, + }) + + expect(requests.map(({ method }) => method)).toEqual([ + 'wallet_authorizeAccessKey', + ]) + expect(requests[0].params[0]).toEqual({ + address: accessKey, + expiry: 123, + keyType: 'secp256k1', + }) +}) + +test('authorizeAccessKey normalizes public key access keys', async () => { + const requests: any[] = [] + await wallet.authorizeAccessKey(client(requests), { + accessKey: { + publicKey: publicKey_p256_hex, + type: 'p256', + }, + expiry: 123, + }) + + expect(requests.map(({ method }) => method)).toEqual([ + 'wallet_authorizeAccessKey', + ]) + expect(requests[0].params[0]).toEqual({ + address: publicKey_p256_address, + chainId: numberToHex(tempoLocalnet.id), + expiry: 123, + keyType: 'p256', + }) +}) + +test('authorizeAccessKey supports wallet-generated access keys', async () => { + const requests: any[] = [] + await wallet.authorizeAccessKey(client(requests), { + expiry: 123, + keyType: 'p256', + scopes: [ + { + address: recipient, + selector: 'transfer(address,uint256)', + recipients: [accessKey], + }, + ], + }) + + expect(requests.map(({ method }) => method)).toEqual([ + 'wallet_authorizeAccessKey', + ]) + expect(requests[0].params[0]).toEqual({ + chainId: numberToHex(tempoLocalnet.id), + expiry: 123, + keyType: 'p256', + scopes: [ + { + address: recipient, + recipients: [accessKey], + selector: '0xa9059cbb', + }, + ], + }) +}) + +test('revokeAccessKey sends wallet_revokeAccessKey and returns undefined', async () => { + const requests: any[] = [] + const result = await wallet.revokeAccessKey(client(requests), { + accessKey, + }) + + expect(requests.map(({ method }) => method)).toEqual([ + 'wallet_revokeAccessKey', + ]) + expect(requests[0].params[0]).toEqual({ + accessKeyAddress: accessKey, + address: account, + }) + expect(result).toBeUndefined() +}) + +test('revokeAccessKey normalizes object access keys', async () => { + const requests: any[] = [] + await wallet.revokeAccessKey(client(requests), { + accessKey: { address: accessKey, type: 'p256' }, + }) + + expect(requests[0].params[0]).toEqual({ + accessKeyAddress: accessKey, + address: account, + }) +}) + +test('revokeAccessKey normalizes public key access keys', async () => { + const requests: any[] = [] + await wallet.revokeAccessKey(client(requests), { + accessKey: { + publicKey: publicKey_p256_hex, + type: 'p256', + }, + }) + + expect(requests[0].params[0]).toEqual({ + accessKeyAddress: publicKey_p256_address, + address: account, + }) +}) + +test('sendCallsSync requires a chain', async () => { + const requests: any[] = [] + await expect( + wallet.sendCallsSync(client(requests, {}, null), { + account, + calls: [{ to: recipient }], + }), + ).rejects.toThrow('No chain was provided to the request.') + expect(requests).toEqual([]) +}) + +test('revokeAccessKey requires an owner address', async () => { + const requests: any[] = [] + const client_ = createClient({ + chain: tempoLocalnet, + transport: custom({ + async request(request) { + requests.push(request) + return undefined + }, + }), + }) + + await expect( + wallet.revokeAccessKey(client_, { + accessKey, + }), + ).rejects.toThrow('Could not find an Account to execute with this Action.') + expect(requests).toEqual([]) +}) + test('transfer', async () => { const requests: unknown[] = [] const result = await wallet.transfer(client(requests), { diff --git a/src/tempo/actions/wallet.ts b/src/tempo/actions/wallet.ts index 90bdf54a19..9c6cb9e951 100644 --- a/src/tempo/actions/wallet.ts +++ b/src/tempo/actions/wallet.ts @@ -1,12 +1,518 @@ -import type { Address } from 'abitype' +import type { Abi, Address } from 'abitype' +import type { KeyAuthorization } from 'ox/tempo' +import type { Account } from '../../accounts/types.js' +import { parseAccount } from '../../accounts/utils/parseAccount.js' +import { formatCallsStatus } from '../../actions/wallet/getCallsStatus.js' +import type { + SendCallsErrorType, + SendCallsParameters, +} from '../../actions/wallet/sendCalls.js' +import type { SendCallsSyncReturnType } from '../../actions/wallet/sendCallsSync.js' +import { + type SendTransactionSyncErrorType, + type SendTransactionSyncParameters, + type SendTransactionSyncRequest, + type SendTransactionSyncReturnType, + sendTransactionSync as sendTransactionSync_core, +} from '../../actions/wallet/sendTransactionSync.js' +import { prepareSendCallsRequest } from '../../actions/wallet/utils/prepareSendCallsRequest.js' +import { prepareSendTransactionRequest } from '../../actions/wallet/utils/prepareSendTransactionRequest.js' +import { prepareWriteContractRequest } from '../../actions/wallet/utils/prepareWriteContractRequest.js' +import type { + WriteContractSyncErrorType, + WriteContractSyncParameters, + WriteContractSyncReturnType, +} from '../../actions/wallet/writeContractSync.js' import type { Client } from '../../clients/createClient.js' import type { Transport } from '../../clients/transports/createTransport.js' +import { AccountNotFoundError } from '../../errors/account.js' +import type { BaseError } from '../../errors/base.js' +import { BundleFailedError } from '../../errors/calls.js' +import { TransactionReceiptRevertedError } from '../../errors/transaction.js' import type { ErrorType as CoreErrorType } from '../../errors/utils.js' import type { Chain } from '../../types/chain.js' -import type { OneOf } from '../../types/utils.js' +import type { + ContractFunctionArgs, + ContractFunctionName, +} from '../../types/contract.js' +import type { WalletGetCallsStatusReturnType } from '../../types/eip1193.js' +import type { RpcTransactionReceipt } from '../../types/rpc.js' +import type { OneOf, UnionOmit } from '../../types/utils.js' import type { RequestErrorType } from '../../utils/buildRequest.js' +import { getContractError } from '../../utils/errors/getContractError.js' +import { getTransactionError } from '../../utils/errors/getTransactionError.js' +import type { FormattedTransactionReceipt } from '../../utils/formatters/transactionReceipt.js' +import { formatTransactionReceipt } from '../../utils/formatters/transactionReceipt.js' +import { resolveAccessKey } from '../Account.js' +import { + formatWalletAuthorizeAccessKeyParameters, + formatWalletKeyAuthorizationResponse, + type WalletAuthorizeAccessKeyParameters, + type WalletAuthorizeAccessKeyRpcParameters, +} from '../internal/walletAccessKey.js' import type { TransactionReceipt } from '../Transaction.js' +function formatReceipt( + client: Client, + receipt: RpcTransactionReceipt | FormattedTransactionReceipt, + chain_?: Chain | null | undefined, +): FormattedTransactionReceipt { + if ( + typeof (receipt as FormattedTransactionReceipt).blockNumber === + 'bigint' + ) + return receipt as FormattedTransactionReceipt + const format = + (chain_ || client.chain)?.formatters?.transactionReceipt?.format || + formatTransactionReceipt + return format( + receipt as RpcTransactionReceipt, + ) as FormattedTransactionReceipt +} + +function resolveWalletAddress( + client: Client, + address?: Address | undefined, +) { + if (address) return address + if (!client.account) + throw new AccountNotFoundError({ + docsPath: '/docs/tempo/actions/wallet/revokeAccessKey', + }) + return parseAccount(client.account).address +} + +function resolveAccessKeyAddress( + accessKey: Address | resolveAccessKey.Parameters, +) { + if (typeof accessKey === 'string') return accessKey + return resolveAccessKey(accessKey).accessKeyAddress +} + +/** + * Creates, signs, and sends a new Tempo transaction synchronously. + * + * @example + * ```ts + * import { createWalletClient, custom } from 'viem' + * import { tempo } from 'viem/chains' + * import { Actions } from 'viem/tempo' + * + * const client = createWalletClient({ + * account: '0x...', + * chain: tempo, + * transport: custom(window.ethereum), + * }) + * + * const receipt = await Actions.wallet.sendTransactionSync(client, { + * to: '0x...', + * feeToken: '0x20c0000000000000000000000000000000000001', + * type: 'tempo', + * }) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns The transaction receipt. + */ +export async function sendTransactionSync< + chain extends Chain | undefined, + account extends Account | undefined, + const request extends SendTransactionSyncRequest, + chainOverride extends Chain | undefined = undefined, +>( + client: Client, + parameters: sendTransactionSync.Parameters< + chain, + account, + chainOverride, + request + >, +): Promise> { + const throwOnReceiptRevert = parameters.throwOnReceiptRevert ?? true + const parameters_ = { ...parameters, throwOnReceiptRevert } + const account_ = + parameters.account === null ? null : (parameters.account ?? client.account) + if (typeof account_ === 'undefined') + return sendTransactionSync_core(client, parameters_ as never) as never + + const account = account_ ? parseAccount(account_) : null + if (account && account.type !== 'json-rpc') + return sendTransactionSync_core(client, parameters_ as never) as never + + try { + const { request } = await prepareSendTransactionRequest( + client, + parameters_ as never, + { docsPath: '/docs/tempo/actions/wallet/sendTransactionSync' }, + ) + const receipt = await client.request<{ + Method: 'eth_sendTransactionSync' + Parameters: + | [transaction: typeof request] + | [transaction: typeof request, timeout: number] + ReturnType: RpcTransactionReceipt + }>( + { + method: 'eth_sendTransactionSync', + params: + typeof parameters_.timeout === 'number' + ? [request, parameters_.timeout] + : [request], + }, + { retryCount: 0 }, + ) + const formatted = formatReceipt(client, receipt, parameters_.chain) + if (formatted.status === 'reverted' && throwOnReceiptRevert) + throw new TransactionReceiptRevertedError({ receipt: formatted }) + return formatted as never + } catch (err) { + throw getTransactionError(err as BaseError, { + ...parameters_, + account, + chain: parameters_.chain || undefined, + }) + } +} + +export declare namespace sendTransactionSync { + export type Parameters< + chain extends Chain | undefined = Chain | undefined, + account extends Account | undefined = Account | undefined, + chainOverride extends Chain | undefined = Chain | undefined, + request extends SendTransactionSyncRequest< + chain, + chainOverride + > = SendTransactionSyncRequest, + > = SendTransactionSyncParameters + + export type ReturnValue = + SendTransactionSyncReturnType + + export type ErrorType = SendTransactionSyncErrorType +} + +/** + * Executes a write function on a contract using Tempo's sync transaction RPC. + * + * @example + * ```ts + * import { parseAbi, createWalletClient, custom } from 'viem' + * import { tempo } from 'viem/chains' + * import { Actions } from 'viem/tempo' + * + * const client = createWalletClient({ + * account: '0x...', + * chain: tempo, + * transport: custom(window.ethereum), + * }) + * + * const receipt = await Actions.wallet.writeContractSync(client, { + * address: '0x...', + * abi: parseAbi(['function mint(uint256 tokenId)']), + * args: [1n], + * functionName: 'mint', + * feeToken: '0x20c0000000000000000000000000000000000001', + * type: 'tempo', + * }) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns The transaction receipt. + */ +export async function writeContractSync< + chain extends Chain | undefined, + account extends Account | undefined, + const abi extends Abi | readonly unknown[], + functionName extends ContractFunctionName, + args extends ContractFunctionArgs< + abi, + 'nonpayable' | 'payable', + functionName + >, + chainOverride extends Chain | undefined, +>( + client: Client, + parameters: writeContractSync.Parameters< + abi, + functionName, + args, + chain, + account, + chainOverride + >, +): Promise> { + const { abi, account, address, args, functionName, request } = + prepareWriteContractRequest(client, parameters as never, { + docsPath: '/docs/tempo/actions/wallet/writeContractSync', + }) + + try { + return (await sendTransactionSync(client, request as never)) as never + } catch (error) { + throw getContractError(error as BaseError, { + abi, + address, + args, + docsPath: '/docs/tempo/actions/wallet/writeContractSync', + functionName, + sender: account?.address, + }) + } +} + +export declare namespace writeContractSync { + export type Parameters< + abi extends Abi | readonly unknown[] = Abi, + functionName extends ContractFunctionName< + abi, + 'nonpayable' | 'payable' + > = ContractFunctionName, + args extends ContractFunctionArgs< + abi, + 'nonpayable' | 'payable', + functionName + > = ContractFunctionArgs, + chain extends Chain | undefined = Chain | undefined, + account extends Account | undefined = Account | undefined, + chainOverride extends Chain | undefined = Chain | undefined, + > = WriteContractSyncParameters< + abi, + functionName, + args, + chain, + account, + chainOverride + > + + export type ReturnValue = + WriteContractSyncReturnType + + export type ErrorType = WriteContractSyncErrorType +} + +/** + * Requests the connected wallet to send a batch of calls synchronously. + * + * @example + * ```ts + * import { createWalletClient, custom } from 'viem' + * import { tempo } from 'viem/chains' + * import { Actions } from 'viem/tempo' + * + * const client = createWalletClient({ + * account: '0x...', + * chain: tempo, + * transport: custom(window.ethereum), + * }) + * + * const status = await Actions.wallet.sendCallsSync(client, { + * calls: [{ to: '0x...', value: 1n }], + * }) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns Calls status. + */ +export async function sendCallsSync< + const calls extends readonly unknown[], + chain extends Chain | undefined, + account extends Account | undefined = undefined, + chainOverride extends Chain | undefined = undefined, +>( + client: Client, + parameters: sendCallsSync.Parameters, +): Promise { + const { request } = prepareSendCallsRequest(client, parameters) + const response = await client.request<{ + Method: 'wallet_sendCallsSync' + Parameters: + | [request: typeof request] + | [request: typeof request, timeout: number] + ReturnType: WalletGetCallsStatusReturnType + }>( + { + method: 'wallet_sendCallsSync', + params: + typeof parameters.timeout === 'number' + ? [request, parameters.timeout] + : [request], + }, + { retryCount: 0 }, + ) + const status = formatCallsStatus(response) + if (parameters.throwOnFailure && status.status === 'failure') + throw new BundleFailedError(status) + return status +} + +export declare namespace sendCallsSync { + export type Parameters< + chain extends Chain | undefined = Chain | undefined, + account extends Account | undefined = Account | undefined, + chainOverride extends Chain | undefined = Chain | undefined, + calls extends readonly unknown[] = readonly unknown[], + > = UnionOmit< + SendCallsParameters, + 'experimental_fallback' | 'experimental_fallbackDelay' + > & { + /** Whether to throw an error if the call bundle failed. */ + throwOnFailure?: boolean | undefined + /** Timeout (ms) to pass to `wallet_sendCallsSync`. */ + timeout?: number | undefined + } + + export type ReturnValue = SendCallsSyncReturnType + + export type ErrorType = SendCallsErrorType +} + +/** + * Authorizes an access key through the connected wallet. + * + * @example + * ```ts + * import { createWalletClient, custom } from 'viem' + * import { tempo } from 'viem/chains' + * import { Actions } from 'viem/tempo' + * + * const client = createWalletClient({ + * chain: tempo, + * transport: custom(window.ethereum), + * }) + * + * const { keyAuthorization, rootAddress } = + * await Actions.wallet.authorizeAccessKey(client, { + * accessKey: { address: '0x...', type: 'p256' }, + * expiry: Math.floor(Date.now() / 1000) + 86_400, + * limits: [ + * { + * token: '0x20c0000000000000000000000000000000000001', + * limit: 100_000_000n, + * }, + * ], + * scopes: [{ address: '0x20c0000000000000000000000000000000000001' }], + * }) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns The signed key authorization and root account address. + */ +export async function authorizeAccessKey( + client: Client, + parameters: authorizeAccessKey.Parameters, +): Promise { + const rpcParameters = formatWalletAuthorizeAccessKeyParameters(parameters, { + defaultChainId: client.chain?.id, + includeShowDeposit: true, + }) as authorizeAccessKey.RpcParameters + const response = await client.request<{ + Method: 'wallet_authorizeAccessKey' + Parameters: [authorizeAccessKey.RpcParameters] + ReturnType: authorizeAccessKey.RpcReturnType + }>( + { + method: 'wallet_authorizeAccessKey', + params: [rpcParameters], + }, + { retryCount: 0 }, + ) + return { + ...response, + keyAuthorization: formatWalletKeyAuthorizationResponse( + response.keyAuthorization, + ), + } +} + +export declare namespace authorizeAccessKey { + export type Parameters = WalletAuthorizeAccessKeyParameters + + export type RpcParameters = WalletAuthorizeAccessKeyRpcParameters + + export type RpcReturnType<_chain extends Chain | undefined> = { + keyAuthorization: KeyAuthorization.Rpc + rootAddress: Address + } + + export type ReturnValue = { + keyAuthorization: KeyAuthorization.Signed + rootAddress: Address + } + + export type ErrorType = RequestErrorType | CoreErrorType +} + +/** + * Revokes an access key through the connected wallet. + * + * @example + * ```ts + * import { createWalletClient, custom } from 'viem' + * import { tempo } from 'viem/chains' + * import { Actions } from 'viem/tempo' + * + * const client = createWalletClient({ + * account: '0x...', + * chain: tempo, + * transport: custom(window.ethereum), + * }) + * + * await Actions.wallet.revokeAccessKey(client, { + * accessKey: '0x...', + * }) + * ``` + * + * @param client - Client. + * @param parameters - Parameters. + * @returns Nothing. + */ +export async function revokeAccessKey( + client: Client, + parameters: revokeAccessKey.Parameters, +): Promise { + const { accessKey, address, ...rest } = parameters + const rpcParameters: revokeAccessKey.RpcParameters = { + ...rest, + accessKeyAddress: resolveAccessKeyAddress(accessKey), + address: resolveWalletAddress(client, address), + } + return await client.request<{ + Method: 'wallet_revokeAccessKey' + Parameters: [revokeAccessKey.RpcParameters] + ReturnType: revokeAccessKey.RpcReturnType + }>( + { + method: 'wallet_revokeAccessKey', + params: [rpcParameters], + }, + { retryCount: 0 }, + ) +} + +export declare namespace revokeAccessKey { + export type Parameters = { + /** Address of the account that owns the access key. Defaults to the client account. */ + address?: Address | undefined + /** Access key to revoke. */ + accessKey: Address | resolveAccessKey.Parameters + } + + export type RpcParameters = { + address: Address + accessKeyAddress: Address + } + + export type RpcReturnType<_chain extends Chain | undefined> = ReturnValue + + export type ReturnValue = undefined + + export type ErrorType = RequestErrorType | CoreErrorType +} + /** * Transfers a TIP-20 token. Discriminated on `editable`: * diff --git a/src/tempo/chainConfig.test-d.ts b/src/tempo/chainConfig.test-d.ts index 8fadc30cc9..f239354e15 100644 --- a/src/tempo/chainConfig.test-d.ts +++ b/src/tempo/chainConfig.test-d.ts @@ -1,9 +1,16 @@ +import { parseAbi } from 'abitype' import { MultisigConfig } from 'ox/tempo' +import { createWalletClient, http } from 'viem' +import { connect, prepareTransactionRequest } from 'viem/actions' +import { tempoLocalnet } from 'viem/chains' +import { Actions, type Capabilities, tempoActions } from 'viem/tempo' import { expectTypeOf, test } from 'vitest' -import { prepareTransactionRequest } from '../actions/wallet/prepareTransactionRequest.js' -import { tempoLocalnet } from '../chains/index.js' -import { createWalletClient } from '../clients/createWalletClient.js' -import { http } from '../clients/transports/http.js' + +declare module 'viem' { + interface Register { + CapabilitiesSchema: Capabilities.Schema + } +} test('prepareTransactionRequest preserves tempo transaction type', async () => { const client = createWalletClient({ @@ -71,3 +78,199 @@ test('prepareTransactionRequest stays a union when ambiguous', async () => { 'legacy' | 'eip2930' | 'eip1559' | 'eip4844' | 'eip7702' | 'tempo' >() }) + +test('tempoActions exposes Tempo wallet sync actions', async () => { + const client = createWalletClient({ + account: '0x0000000000000000000000000000000000000001', + chain: tempoLocalnet, + transport: http(), + }).extend(tempoActions()) + + await client.connect({ + capabilities: { + authorizeAccessKey: { + expiry: Math.floor(Date.now() / 1000) + 86_400, + keyType: 'p256', + }, + method: 'login', + personalSign: { message: 'hello' }, + showDeposit: { amount: '50', on: 'login', token: 'USDC' }, + }, + }) + type ConnectReturnValue = Awaited> + expectTypeOf< + ConnectReturnValue['accounts'][number]['capabilities'] + >().toMatchTypeOf() + + type ConnectParameters = NonNullable[0]> + + const _connectAuthorizeWithPrivateKey = { + capabilities: { + authorizeAccessKey: { + expiry: Math.floor(Date.now() / 1000) + 86_400, + keyType: 'p256', + // @ts-expect-error `wallet_connect` access key authorization does not accept caller-provided private key material. + privateKey: + '0x0000000000000000000000000000000000000000000000000000000000000001', + }, + }, + } satisfies ConnectParameters + const _connectAuthorizeWithShowDeposit = { + capabilities: { + authorizeAccessKey: { + expiry: Math.floor(Date.now() / 1000) + 86_400, + keyType: 'p256', + // @ts-expect-error Nested `wallet_connect` access key authorization does not accept showDeposit. + showDeposit: true, + }, + }, + } satisfies ConnectParameters + const _connectVersion = { + // @ts-expect-error `connect` sets the `wallet_connect` protocol version internally. + version: '1', + } satisfies ConnectParameters + const _connectChainId = { + // @ts-expect-error `connect` gets the `wallet_connect` chain ID from the chain. + chainId: tempoLocalnet.id, + } satisfies ConnectParameters + void _connectAuthorizeWithPrivateKey + void _connectAuthorizeWithShowDeposit + void _connectVersion + void _connectChainId + + await client.sendTransactionSync({ + calls: [], + feeToken: '0x20c0000000000000000000000000000000000001', + type: 'tempo', + }) + + await client.writeContractSync({ + address: '0x0000000000000000000000000000000000000002', + abi: parseAbi(['function mint(uint256 tokenId)']), + args: [1n], + feeToken: '0x20c0000000000000000000000000000000000001', + functionName: 'mint', + type: 'tempo', + }) + + await client.sendCallsSync({ + chain: tempoLocalnet, + calls: [{ to: '0x0000000000000000000000000000000000000002' }], + timeout: 1_000, + }) + + type SendCallsSyncParameters = Actions.wallet.sendCallsSync.Parameters< + typeof tempoLocalnet, + undefined, + typeof tempoLocalnet + > + + const _pollingInterval = { + chain: tempoLocalnet, + calls: [{ to: '0x0000000000000000000000000000000000000002' }], + // @ts-expect-error Tempo `wallet_sendCallsSync` does not poll. + pollingInterval: 1_000, + } satisfies SendCallsSyncParameters + + const _status = { + chain: tempoLocalnet, + calls: [{ to: '0x0000000000000000000000000000000000000002' }], + // @ts-expect-error Tempo `wallet_sendCallsSync` does not wait for a target status. + status: 'success', + } satisfies SendCallsSyncParameters + const _experimentalFallback = { + chain: tempoLocalnet, + calls: [{ to: '0x0000000000000000000000000000000000000002' }], + // @ts-expect-error Tempo `wallet_sendCallsSync` does not use fallback transactions. + experimental_fallback: true, + } satisfies SendCallsSyncParameters + const _experimentalFallbackDelay = { + chain: tempoLocalnet, + calls: [{ to: '0x0000000000000000000000000000000000000002' }], + // @ts-expect-error Tempo `wallet_sendCallsSync` does not use fallback transactions. + experimental_fallbackDelay: 100, + } satisfies SendCallsSyncParameters + void _pollingInterval + void _status + void _experimentalFallback + void _experimentalFallbackDelay + + await client.authorizeAccessKey({ + accessKey: { + address: '0x0000000000000000000000000000000000000003', + type: 'p256', + }, + expiry: Math.floor(Date.now() / 1000) + 86_400, + }) + + await client.authorizeAccessKey({ + expiry: Math.floor(Date.now() / 1000) + 86_400, + keyType: 'p256', + }) + + const _authorizeWithPrivateKey = { + expiry: Math.floor(Date.now() / 1000) + 86_400, + keyType: 'p256', + // @ts-expect-error The wallet action does not accept caller-provided private key material. + privateKey: + '0x0000000000000000000000000000000000000000000000000000000000000001', + } satisfies Actions.wallet.authorizeAccessKey.Parameters + void _authorizeWithPrivateKey + + await client.revokeAccessKey({ + accessKey: '0x0000000000000000000000000000000000000003', + }) + + await connect(client, { + capabilities: { + authorizeAccessKey: { + accessKey: { + publicKey: + '0x20fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c812240', + type: 'p256', + }, + expiry: Math.floor(Date.now() / 1000) + 86_400, + }, + method: 'register', + name: 'default', + }, + }) + + await Actions.wallet.sendTransactionSync(client, { + calls: [], + feeToken: '0x20c0000000000000000000000000000000000001', + type: 'tempo', + }) + + await Actions.wallet.writeContractSync(client, { + address: '0x0000000000000000000000000000000000000002', + abi: parseAbi(['function mint(uint256 tokenId)']), + args: [1n], + feeToken: '0x20c0000000000000000000000000000000000001', + functionName: 'mint', + type: 'tempo', + }) + + await Actions.wallet.sendCallsSync(client, { + chain: tempoLocalnet, + calls: [{ to: '0x0000000000000000000000000000000000000002' }], + timeout: 1_000, + }) + + await Actions.wallet.authorizeAccessKey(client, { + accessKey: { + publicKey: + '0x20fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c812240', + type: 'p256', + }, + expiry: Math.floor(Date.now() / 1000) + 86_400, + }) + + await Actions.wallet.revokeAccessKey(client, { + accessKey: { + publicKey: + '0x20fe09fa1af47a6b3b4e973040f0588a1c2c96df1ce78b10e50903566ad9b7d87ffe0b281b616196c2ccdb64cd51230d8dc1f1d258ca7e8cb33a63cf8c812240', + type: 'p256', + }, + }) +}) diff --git a/src/tempo/chainConfig.ts b/src/tempo/chainConfig.ts index 06570dd315..23f6543bf7 100644 --- a/src/tempo/chainConfig.ts +++ b/src/tempo/chainConfig.ts @@ -1,6 +1,11 @@ import type { Address } from 'abitype' import * as Hex from 'ox/Hex' -import { MultisigConfig, SignatureEnvelope, type TokenId } from 'ox/tempo' +import { + type KeyAuthorization, + MultisigConfig, + SignatureEnvelope, + type TokenId, +} from 'ox/tempo' import { getCode } from '../actions/public/getCode.js' import { verifyHash } from '../actions/public/verifyHash.js' import { maxUint256 } from '../constants/number.js' @@ -18,6 +23,11 @@ import { getMetadata } from './actions/accessKey.js' import * as Formatters from './Formatters.js' import type { Hardfork } from './Hardfork.js' import * as Concurrent from './internal/concurrent.js' +import { + formatWalletAuthorizeAccessKeyParameters, + formatWalletKeyAuthorizationResponse, + type WalletAuthorizeAccessKeyParameters, +} from './internal/walletAccessKey.js' import * as Transaction from './Transaction.js' const maxExpirySecs = 25 @@ -40,6 +50,37 @@ export const chainConfig = { format: Formatters.formatTransactionRequest, }), }, + walletConnect: { + fallback: false, + formatRequest(request, { chain }) { + if (!chain) return request + return { + ...request, + chainId: Hex.fromNumber(chain.id), + } + }, + capabilities: { + request: { + authorizeAccessKey: { + format(capability, { chain }) { + return formatWalletAuthorizeAccessKeyParameters( + capability as WalletAuthorizeAccessKeyParameters, + { defaultChainId: chain?.id }, + ) + }, + }, + }, + response: { + keyAuthorization: { + format(capability) { + return formatWalletKeyAuthorizationResponse( + capability as KeyAuthorization.Rpc | KeyAuthorization.Signed, + ) + }, + }, + }, + }, + }, prepareTransactionRequest: [ async (r, { client, phase }) => { const request = r as Transaction.TransactionRequest & { diff --git a/src/tempo/internal/walletAccessKey.ts b/src/tempo/internal/walletAccessKey.ts new file mode 100644 index 0000000000..dbb140ef06 --- /dev/null +++ b/src/tempo/internal/walletAccessKey.ts @@ -0,0 +1,167 @@ +import type { Address } from 'abitype' +import * as AbiItem from 'ox/AbiItem' +import { KeyAuthorization } from 'ox/tempo' +import type { Hex } from '../../types/misc.js' +import { numberToHex } from '../../utils/encoding/toHex.js' +import { resolveAccessKey } from '../Account.js' + +export type WalletAuthorizeAccessKeyParameters = { + /** Access key to authorize. Omit to let the wallet generate and store one. */ + accessKey?: resolveAccessKey.Parameters | undefined + /** Chain ID. Defaults to the active chain. */ + chainId?: number | bigint | undefined + /** Unix timestamp when the key expires. */ + expiry: number + /** Key type to request when the wallet generates the access key. */ + keyType?: ReturnType['keyType'] | undefined + /** Spending limits per token. */ + limits?: + | { token: Address; limit: bigint; period?: number | undefined }[] + | undefined + /** Call scopes restricting which contracts/selectors this key can call. */ + scopes?: KeyAuthorization.Scope[] | undefined + /** Optional funding prompt to show after approval. */ + showDeposit?: + | boolean + | { + amount?: string | undefined + displayName?: string | undefined + token?: Address | string | undefined + } + | undefined +} + +export type WalletAuthorizeAccessKeyRpcParameters = { + address?: Address | undefined + chainId?: Hex | undefined + expiry: number + keyType?: ReturnType['keyType'] | undefined + limits?: + | { token: Address; limit: Hex; period?: number | undefined }[] + | undefined + scopes?: + | { + address: Address + selector?: Hex | undefined + recipients?: readonly Address[] | undefined + }[] + | undefined + showDeposit?: WalletAuthorizeAccessKeyParameters['showDeposit'] +} + +function formatWalletKeyAuthorization( + parameters: Pick< + WalletAuthorizeAccessKeyParameters, + 'accessKey' | 'chainId' | 'expiry' | 'limits' | 'scopes' + >, +) { + const { accessKey, chainId, expiry, limits, scopes } = parameters + if (!accessKey) return undefined + const { accessKeyAddress, keyType } = resolveAccessKey(accessKey) + return KeyAuthorization.from({ + address: accessKeyAddress, + chainId: + typeof chainId !== 'undefined' + ? typeof chainId === 'bigint' + ? chainId + : BigInt(chainId) + : 0n, + expiry, + limits, + scopes, + type: keyType, + }) +} + +function formatWalletAuthorizeAccessKeyChainId( + chainId: number | bigint | undefined, +) { + if (typeof chainId === 'undefined') return undefined + return typeof chainId === 'bigint' ? chainId : BigInt(chainId) +} + +function formatWalletKeyAuthorizationScopes( + scopes: readonly KeyAuthorization.Scope[] | undefined, +) { + return scopes?.map((scope) => ({ + address: scope.address, + ...(scope.selector + ? { + selector: scope.selector.startsWith('0x') + ? (scope.selector as Hex) + : AbiItem.getSelector(scope.selector), + } + : {}), + ...(scope.recipients && scope.recipients.length > 0 + ? { recipients: scope.recipients } + : {}), + })) +} + +export function formatWalletAuthorizeAccessKeyParameters( + parameters: WalletAuthorizeAccessKeyParameters, + options: { + defaultChainId?: number | bigint | undefined + includeShowDeposit?: boolean | undefined + } = {}, +): WalletAuthorizeAccessKeyRpcParameters { + const { + accessKey, + chainId = options.defaultChainId, + expiry, + keyType, + limits, + scopes, + showDeposit, + } = parameters + const keyAuthorization = formatWalletKeyAuthorization({ + accessKey, + chainId, + expiry, + limits, + scopes, + }) + const chainId_ = + typeof chainId !== 'undefined' + ? (keyAuthorization?.chainId ?? + formatWalletAuthorizeAccessKeyChainId(chainId)) + : undefined + const limits_ = keyAuthorization?.limits ?? limits + const scopes_ = keyAuthorization?.scopes ?? scopes + + return { + expiry, + ...(keyAuthorization + ? { + address: keyAuthorization.address, + keyType: keyAuthorization.type, + } + : keyType + ? { keyType } + : {}), + ...(typeof chainId_ !== 'undefined' + ? { chainId: numberToHex(chainId_) } + : {}), + ...(limits_ + ? { + limits: limits_.map(({ token, limit, period }) => ({ + token, + limit: numberToHex(limit), + ...(typeof period !== 'undefined' ? { period } : {}), + })), + } + : {}), + ...(scopes_ ? { scopes: formatWalletKeyAuthorizationScopes(scopes_) } : {}), + ...(options.includeShowDeposit && showDeposit !== undefined + ? { showDeposit } + : {}), + } +} + +export function formatWalletKeyAuthorizationResponse( + keyAuthorization: KeyAuthorization.Rpc | KeyAuthorization.Signed, +) { + if ('keyId' in keyAuthorization) + return KeyAuthorization.fromRpc(keyAuthorization) as KeyAuthorization.Signed + return keyAuthorization +} diff --git a/src/types/chain.ts b/src/types/chain.ts index cfa9775383..2caf30278b 100644 --- a/src/types/chain.ts +++ b/src/types/chain.ts @@ -90,6 +90,49 @@ type ChainVerifyHashFn = ( parameters: VerifyHashParameters, ) => Promise +export type ChainWalletConnectRequest = { + capabilities?: Record | undefined + version: '1' + [key: string]: unknown +} + +export type ChainWalletConnectContext = { + chain?: Chain | undefined + client: Client +} + +export type ChainWalletConnectCapabilityFormatter = { + key?: string | undefined + format?: + | (( + capability: unknown, + context: ChainWalletConnectContext & { key: string }, + ) => unknown) + | undefined +} + +export type ChainWalletConnect = { + /** + * Whether `connect` should fallback to `eth_requestAccounts` when + * `wallet_connect` is not supported and no capabilities were requested. + */ + fallback?: boolean | undefined + /** Formats the `wallet_connect` request object. */ + formatRequest?: + | (( + request: ChainWalletConnectRequest, + context: ChainWalletConnectContext, + ) => ChainWalletConnectRequest) + | undefined + /** Formats chain-specific `wallet_connect` capabilities. */ + capabilities?: + | { + request?: Record + response?: Record + } + | undefined +} + export type ChainConfig< formatters extends ChainFormatters | undefined = ChainFormatters | undefined, extendSchema extends Record | undefined = @@ -125,6 +168,8 @@ export type ChainConfig< serializers?: ChainSerializers | undefined /** Chain-specific signature verification. */ verifyHash?: ChainVerifyHashFn | undefined + /** Chain-specific `wallet_connect` formatting. */ + walletConnect?: ChainWalletConnect | undefined } /////////////////////////////////////////////////////////////////////