From 99ed745ba28d912cdad86d6a1718968c8da4c7c0 Mon Sep 17 00:00:00 2001 From: jxom Date: Wed, 19 Jun 2024 11:09:37 +1000 Subject: [PATCH] feat: experimental solady erc1271 sign actions (#2413) * wip: solady * Delete test/contracts/src/CounterfactualContractCall.sol * wip: add factory + factoryData to verifyHash * tweak * tweak * snaps * wip: checkpoint * docs * docs * size * rebase * size --- .changeset/twenty-gorillas-hug.md | 5 + package.json | 6 +- site/pages/docs/utilities/verifyTypedData.md | 2 +- site/pages/experimental/solady/signMessage.md | 196 +++++++ .../experimental/solady/signTypedData.md | 485 ++++++++++++++++++ site/sidebar.ts | 18 + src/actions/public/verifyHash.test.ts | 359 ++++++++++--- src/actions/public/verifyHash.ts | 35 +- src/actions/public/verifyMessage.ts | 11 +- src/actions/public/verifyTypedData.ts | 4 + src/experimental/index.ts | 18 + .../solady/actions/signMessage.test.ts | 195 +++++++ .../solady/actions/signMessage.ts | 151 ++++++ .../solady/actions/signTypedData.test.ts | 377 ++++++++++++++ .../solady/actions/signTypedData.ts | 280 ++++++++++ .../solady/decorators/solady.test.ts | 84 +++ src/experimental/solady/decorators/solady.ts | 232 +++++++++ src/experimental/solady/types.ts | 7 + src/index.test.ts | 1 + src/index.ts | 4 + src/utils/signature/hashMessage.ts | 26 +- src/utils/signature/hashTypedData.ts | 4 +- src/utils/signature/toPrefixedMessage.test.ts | 20 + src/utils/signature/toPrefixedMessage.ts | 27 + 24 files changed, 2430 insertions(+), 117 deletions(-) create mode 100644 .changeset/twenty-gorillas-hug.md create mode 100644 site/pages/experimental/solady/signMessage.md create mode 100644 site/pages/experimental/solady/signTypedData.md create mode 100644 src/experimental/solady/actions/signMessage.test.ts create mode 100644 src/experimental/solady/actions/signMessage.ts create mode 100644 src/experimental/solady/actions/signTypedData.test.ts create mode 100644 src/experimental/solady/actions/signTypedData.ts create mode 100644 src/experimental/solady/decorators/solady.test.ts create mode 100644 src/experimental/solady/decorators/solady.ts create mode 100644 src/experimental/solady/types.ts create mode 100644 src/utils/signature/toPrefixedMessage.test.ts create mode 100644 src/utils/signature/toPrefixedMessage.ts diff --git a/.changeset/twenty-gorillas-hug.md b/.changeset/twenty-gorillas-hug.md new file mode 100644 index 0000000000..b76ebfd901 --- /dev/null +++ b/.changeset/twenty-gorillas-hug.md @@ -0,0 +1,5 @@ +--- +"viem": patch +--- + +**Experimental:** Added [Solady flavoured ERC-1271](https://github.com/Vectorized/solady/blob/678c9163550810b08f0ffb09624c9f7532392303/src/accounts/ERC1271.sol) `signMessage` & `signTypedData` for Smart Accounts that implement (or conform to) [Solady's `ERC1271.sol`](https://github.com/Vectorized/solady/blob/678c9163550810b08f0ffb09624c9f7532392303/src/accounts/ERC1271.sol#L110-L180). diff --git a/package.json b/package.json index 9967fe68b0..5a3ebe6df0 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ { "name": "viem (esm)", "path": "./src/_esm/index.js", - "limit": "60.2 kB", + "limit": "60.5 kB", "import": "*" }, { @@ -179,13 +179,13 @@ { "name": "viem/siwe", "path": "./src/_esm/siwe/index.js", - "limit": "30.5 kB", + "limit": "30.7 kB", "import": "*" }, { "name": "viem/siwe (tree-shaking)", "path": "./src/_esm/siwe/index.js", - "limit": "29.5 kB", + "limit": "29.6 kB", "import": "{ verifySiweMessage }" } ] diff --git a/site/pages/docs/utilities/verifyTypedData.md b/site/pages/docs/utilities/verifyTypedData.md index 0a3cb89fcd..431fbf0752 100644 --- a/site/pages/docs/utilities/verifyTypedData.md +++ b/site/pages/docs/utilities/verifyTypedData.md @@ -8,7 +8,7 @@ Verify that typed data was signed by the provided address. :::warning[Warning] This utility can only verify typed data that was signed by an Externally Owned Account (EOA). -To verify messages from Contract Accounts (& EOA), use the [`publicClient.verifyMessage` Action](/docs/actions/public/verifyMessage) instead. +To verify messages from Contract Accounts (& EOA), use the [`publicClient.verifyTypedData` Action](/docs/actions/public/verifyTypedData) instead. ::: ## Usage diff --git a/site/pages/experimental/solady/signMessage.md b/site/pages/experimental/solady/signMessage.md new file mode 100644 index 0000000000..d28bb2c78a --- /dev/null +++ b/site/pages/experimental/solady/signMessage.md @@ -0,0 +1,196 @@ +--- +description: Signs a personal sign message via Solady's ERC-1271 format. +--- + +# signMessage + +Signs an [EIP-191](https://eips.ethereum.org/EIPS/eip-191) personal sign message via Solady's [ERC-1271 `PersonalSign` format](https://github.com/Vectorized/solady/blob/678c9163550810b08f0ffb09624c9f7532392303/src/accounts/ERC1271.sol#L154-L166). + +This Action is suitable to sign messages for contracts (e.g. ERC-4337 Smart Accounts) that implement (or conform to) Solady's [ERC1271.sol](https://github.com/Vectorized/solady/blob/main/src/accounts/ERC1271.sol). + +With the calculated signature, you can use [`verifyMessage`](/docs/actions/public/verifyMessage) to verify the signature + +## Usage + +:::code-group + +```ts twoslash [example.ts] +import { account, walletClient } from './config' + +const signature_1 = await walletClient.signMessage({ // [!code focus:99] + // Account used for signing. + account, + message: 'hello world', + // Verifying contract address (e.g. ERC-4337 Smart Account). + verifier: '0xCB9fA1eA9b8A3bf422a8639f23Df77ea66020eC2' +}) + +const signature_2 = await walletClient.signMessage({ + // Account used for signing. + account, + // Hex data representation of message. + message: { raw: '0x68656c6c6f20776f726c64' }, + // Verifying contract address (e.g. ERC-4337 Smart Account) + verifier: '0xCB9fA1eA9b8A3bf422a8639f23Df77ea66020eC2' +}) +``` + +```ts twoslash [config.ts] filename="config.ts" +import { createWalletClient, http } from 'viem' +import { mainnet } from 'viem/chains' +import { soladyActions } from 'viem/experimental' + +export const walletClient = createWalletClient({ + chain: mainnet, + transport: http(), +}).extend(soladyActions()) + +export const [account] = await walletClient.getAddresses() +// @log: ↑ JSON-RPC Account + +// export const account = privateKeyToAccount(...) +// @log: ↑ Local Account +``` + +::: + +## Account and/or Verifier Hoisting + +If you do not wish to pass an `account` and/or `verifier` to every `signMessage`, you can also hoist the Account and/or Verifier on the Wallet Client (see `config.ts`). + +[Learn more](/docs/clients/wallet#withaccount). + +:::code-group + +```ts twoslash [example.ts] +import { walletClient } from './config' + +const signature = await walletClient.signMessage({ // [!code focus:99] + message: 'hello world', +}) +``` + +```ts [config.ts (JSON-RPC Account)] +import { createWalletClient, custom } from 'viem' +import { soladyActions } from 'viem/experimental' + +// Retrieve Account from an EIP-1193 Provider. +const [account] = await window.ethereum.request({ + method: 'eth_requestAccounts' +}) + +export const walletClient = createWalletClient({ + account, + transport: custom(window.ethereum!) +}).extend(soladyActions({ + verifier: '0xCB9fA1eA9b8A3bf422a8639f23Df77ea66020eC2' +})) +``` + +```ts twoslash [config.ts (Local Account)] filename="config.ts" +import { createWalletClient, http } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { soladyActions } from 'viem/experimental' + +export const walletClient = createWalletClient({ + account: privateKeyToAccount('0x...'), + transport: http() +}).extend(soladyActions({ + verifier: '0xCB9fA1eA9b8A3bf422a8639f23Df77ea66020eC2' +})) +``` + +::: + +## Returns + +[`Hex`](/docs/glossary/types#hex) + +The signed message. + +## Parameters + +### account + +- **Type:** `Account | Address` + +Account to used to sign the message. + +Accepts a [JSON-RPC Account](/docs/clients/wallet#json-rpc-accounts) or [Local Account (Private Key, etc)](/docs/clients/wallet#local-accounts-private-key-mnemonic-etc). + +```ts twoslash +import { walletClient } from './config' + +const signature = await walletClient.signMessage({ + account: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', // [!code focus:1] + message: 'hello world', + verifier: '0xCB9fA1eA9b8A3bf422a8639f23Df77ea66020eC2' +}) +``` + +### message + +- **Type:** `string | { raw: Hex | ByteArray }` + +Message to sign. + +By default, viem signs the UTF-8 representation of the message. + +```ts twoslash +import { walletClient } from './config' + +const signature = await walletClient.signMessage({ + account: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + message: 'hello world', // [!code focus:1] + verifier: '0xCB9fA1eA9b8A3bf422a8639f23Df77ea66020eC2', +}) +``` + +To sign the data representation of the message, you can use the `raw` attribute. + +```ts twoslash +import { walletClient } from './config' + +const signature = await walletClient.signMessage({ + account: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + message: { raw: '0x68656c6c6f20776f726c64' }, // [!code focus:1] + verifier: '0xCB9fA1eA9b8A3bf422a8639f23Df77ea66020eC2', +}) +``` + +### verifier + +- **Type:** `Address` + +The address of the verifying contract (e.g. a ERC-4337 Smart Account). Required if `verifierDomain` is not passed. + +```ts twoslash +import { walletClient } from './config' + +const signature = await walletClient.signMessage({ + account: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + message: 'hello world', + verifier: '0xCB9fA1eA9b8A3bf422a8639f23Df77ea66020eC2', // [!code focus:1] +}) +``` + +### verifierDomain + +- **Type:** `TypedDataDomain` + +Account domain separator. Required if `verifier` is not passed. + +```ts twoslash +import { walletClient } from './config' + +const signature = await walletClient.signMessage({ + account: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + message: 'hello world', + verifierDomain: { // [!code focus] + name: 'Mock4337Account', // [!code focus] + version: '1', // [!code focus] + chainId: 1, // [!code focus] + verifyingContract: '0xCB9fA1eA9b8A3bf422a8639f23Df77ea66020eC2' // [!code focus] + }, // [!code focus] +}) +``` \ No newline at end of file diff --git a/site/pages/experimental/solady/signTypedData.md b/site/pages/experimental/solady/signTypedData.md new file mode 100644 index 0000000000..d8113b46a8 --- /dev/null +++ b/site/pages/experimental/solady/signTypedData.md @@ -0,0 +1,485 @@ +--- +description: Signs typed data via Solady's ERC-1271 format. +--- + +# signMessage + +Signs [EIP-712](https://eips.ethereum.org/EIPS/eip-712) typed data via Solady's [ERC-1271 `TypedDataSign` format](https://github.com/Vectorized/solady/blob/678c9163550810b08f0ffb09624c9f7532392303/src/accounts/ERC1271.sol#L130-L151). + +This Action is suitable to sign messages for contracts (e.g. ERC-4337 Smart Accounts) that implement (or conform to) Solady's [ERC1271.sol](https://github.com/Vectorized/solady/blob/main/src/accounts/ERC1271.sol). + +With the calculated signature, you can use [`verifyTypedData`](/docs/actions/public/verifyTypedData) to verify the signature + +## Usage + +:::code-group + +```ts twoslash [example.ts] +import { account, walletClient } from './config' +import { domain, types } from './data' + +const signature = await walletClient.signTypedData({ // [!code focus:99] + // Account used for signing. + account, + domain, + types, + primaryType: 'Mail', + message: { + from: { + name: 'Cow', + wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', + }, + to: { + name: 'Bob', + wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + }, + contents: 'Hello, Bob!', + }, + // Verifying contract address (e.g. ERC-4337 Smart Account). + verifier: '0xCB9fA1eA9b8A3bf422a8639f23Df77ea66020eC2' +}) +``` + +```ts twoslash [data.ts] +// All properties on a domain are optional +export const domain = { + name: 'Ether Mail', + version: '1', + chainId: 1, + verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', +} as const + +// The named list of all type definitions +export const types = { + Person: [ + { name: 'name', type: 'string' }, + { name: 'wallet', type: 'address' }, + ], + Mail: [ + { name: 'from', type: 'Person' }, + { name: 'to', type: 'Person' }, + { name: 'contents', type: 'string' }, + ], +} as const +``` + +```ts twoslash [config.ts] filename="config.ts" +import { createWalletClient, http } from 'viem' +import { mainnet } from 'viem/chains' +import { soladyActions } from 'viem/experimental' + +export const walletClient = createWalletClient({ + chain: mainnet, + transport: http(), +}).extend(soladyActions()) + +export const [account] = await walletClient.getAddresses() +// @log: ↑ JSON-RPC Account + +// export const account = privateKeyToAccount(...) +// @log: ↑ Local Account +``` + +::: + +## Account and/or Verifier Hoisting + +If you do not wish to pass an `account` and/or `verifier` to every `signTypedData`, you can also hoist the Account and/or Verifier on the Wallet Client (see `config.ts`). + +[Learn more](/docs/clients/wallet#withaccount). + +:::code-group + +```ts twoslash [example.ts] +import { walletClient } from './config' +import { domain, types } from './data' + +const signature = await walletClient.signTypedData({ // [!code focus:99] + domain, + types, + primaryType: 'Mail', + message: { + from: { + name: 'Cow', + wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', + }, + to: { + name: 'Bob', + wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + }, + contents: 'Hello, Bob!', + }, +}) +``` + +```ts twoslash [data.ts] +// All properties on a domain are optional +export const domain = { + name: 'Ether Mail', + version: '1', + chainId: 1, + verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', +} as const + +// The named list of all type definitions +export const types = { + Person: [ + { name: 'name', type: 'string' }, + { name: 'wallet', type: 'address' }, + ], + Mail: [ + { name: 'from', type: 'Person' }, + { name: 'to', type: 'Person' }, + { name: 'contents', type: 'string' }, + ], +} as const +``` + +```ts [config.ts (JSON-RPC Account)] +import { createWalletClient, custom } from 'viem' +import { soladyActions } from 'viem/experimental' + +// Retrieve Account from an EIP-1193 Provider. +const [account] = await window.ethereum.request({ + method: 'eth_requestAccounts' +}) + +export const walletClient = createWalletClient({ + account, + transport: custom(window.ethereum!) +}).extend(soladyActions({ + verifier: '0xCB9fA1eA9b8A3bf422a8639f23Df77ea66020eC2' +})) +``` + +```ts twoslash [config.ts (Local Account)] filename="config.ts" +import { createWalletClient, http } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { soladyActions } from 'viem/experimental' + +export const walletClient = createWalletClient({ + account: privateKeyToAccount('0x...'), + transport: http() +}).extend(soladyActions({ + verifier: '0xCB9fA1eA9b8A3bf422a8639f23Df77ea66020eC2' +})) +``` + +::: + +## Returns + +[`Hex`](/docs/glossary/types#hex) + +The signed data. + +## Parameters + +### account + +- **Type:** `Account | Address` + +Account to used to sign the typed data. + +Accepts a [JSON-RPC Account](/docs/clients/wallet#json-rpc-accounts) or [Local Account (Private Key, etc)](/docs/clients/wallet#local-accounts-private-key-mnemonic-etc). + +```ts twoslash +import { walletClient } from './config' + +const signature = await walletClient.signTypedData({ + account: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', // [!code focus:1] + domain: { + name: 'Ether Mail', + version: '1', + chainId: 1, + verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', + }, + types: { + Person: [ + { name: 'name', type: 'string' }, + { name: 'wallet', type: 'address' }, + ], + Mail: [ + { name: 'from', type: 'Person' }, + { name: 'to', type: 'Person' }, + { name: 'contents', type: 'string' }, + ], + }, + primaryType: 'Mail', + message: { + from: { + name: 'Cow', + wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', + }, + to: { + name: 'Bob', + wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + }, + contents: 'Hello, Bob!', + }, + verifier: '0xCB9fA1eA9b8A3bf422a8639f23Df77ea66020eC2' +}) +``` + +### domain + +**Type:** `TypedDataDomain` + +The typed data domain. + +```ts twoslash +import { walletClient } from './config' + +const signature = await walletClient.signTypedData({ + account: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + domain: { // [!code focus:6] + name: 'Ether Mail', + version: '1', + chainId: 1, + verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', + }, + types: { + Person: [ + { name: 'name', type: 'string' }, + { name: 'wallet', type: 'address' }, + ], + Mail: [ + { name: 'from', type: 'Person' }, + { name: 'to', type: 'Person' }, + { name: 'contents', type: 'string' }, + ], + }, + primaryType: 'Mail', + message: { + from: { + name: 'Cow', + wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', + }, + to: { + name: 'Bob', + wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + }, + contents: 'Hello, Bob!', + }, + verifier: '0xCB9fA1eA9b8A3bf422a8639f23Df77ea66020eC2' +}) +``` + +### types + +The type definitions for the typed data. + +```ts twoslash +import { walletClient } from './config' + +const signature = await walletClient.signTypedData({ + account: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + domain: { + name: 'Ether Mail', + version: '1', + chainId: 1, + verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', + }, + types: { // [!code focus:11] + Person: [ + { name: 'name', type: 'string' }, + { name: 'wallet', type: 'address' }, + ], + Mail: [ + { name: 'from', type: 'Person' }, + { name: 'to', type: 'Person' }, + { name: 'contents', type: 'string' }, + ], + }, + primaryType: 'Mail', + message: { + from: { + name: 'Cow', + wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', + }, + to: { + name: 'Bob', + wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + }, + contents: 'Hello, Bob!', + }, +}) +``` + +### primaryType + +**Type:** Inferred `string`. + +The primary type to extract from `types` and use in `value`. + +```ts twoslash +import { walletClient } from './config' + +const signature = await walletClient.signTypedData({ + account: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + domain: { + name: 'Ether Mail', + version: '1', + chainId: 1, + verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', + }, + types: { + Person: [ + { name: 'name', type: 'string' }, + { name: 'wallet', type: 'address' }, + ], + Mail: [ // [!code focus:5] + { name: 'from', type: 'Person' }, + { name: 'to', type: 'Person' }, + { name: 'contents', type: 'string' }, + ], + }, + primaryType: 'Mail', // [!code focus] + message: { + from: { + name: 'Cow', + wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', + }, + to: { + name: 'Bob', + wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + }, + contents: 'Hello, Bob!', + }, +}) +``` + +### message + +**Type:** Inferred from `types` & `primaryType`. + +```ts twoslash +import { walletClient } from './config' + +const signature = await walletClient.signTypedData({ + account: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + domain: { + name: 'Ether Mail', + version: '1', + chainId: 1, + verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', + }, + types: { + Person: [ + { name: 'name', type: 'string' }, + { name: 'wallet', type: 'address' }, + ], + Mail: [ + { name: 'from', type: 'Person' }, + { name: 'to', type: 'Person' }, + { name: 'contents', type: 'string' }, + ], + }, + primaryType: 'Mail', + message: { // [!code focus:11] + from: { + name: 'Cow', + wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', + }, + to: { + name: 'Bob', + wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + }, + contents: 'Hello, Bob!', + }, + verifier: '0xCB9fA1eA9b8A3bf422a8639f23Df77ea66020eC2' +}) +``` + +### verifier + +- **Type:** `Address` + +The address of the verifying contract (e.g. a ERC-4337 Smart Account). Required if `verifierDomain` is not passed. + +```ts twoslash +import { walletClient } from './config' + +const signature = await walletClient.signTypedData({ + account: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + domain: { + name: 'Ether Mail', + version: '1', + chainId: 1, + verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', + }, + types: { + Person: [ + { name: 'name', type: 'string' }, + { name: 'wallet', type: 'address' }, + ], + Mail: [ + { name: 'from', type: 'Person' }, + { name: 'to', type: 'Person' }, + { name: 'contents', type: 'string' }, + ], + }, + primaryType: 'Mail', + message: { + from: { + name: 'Cow', + wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', + }, + to: { + name: 'Bob', + wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + }, + contents: 'Hello, Bob!', + }, + verifier: '0xCB9fA1eA9b8A3bf422a8639f23Df77ea66020eC2' // [!code focus] +}) +``` + +### verifierDomain + +- **Type:** `TypedDataDomain` + +Account domain separator. Required if `verifier` is not passed. + +```ts twoslash +import { walletClient } from './config' + +const signature = await walletClient.signTypedData({ + account: '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', + domain: { + name: 'Ether Mail', + version: '1', + chainId: 1, + verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', + }, + types: { + Person: [ + { name: 'name', type: 'string' }, + { name: 'wallet', type: 'address' }, + ], + Mail: [ + { name: 'from', type: 'Person' }, + { name: 'to', type: 'Person' }, + { name: 'contents', type: 'string' }, + ], + }, + primaryType: 'Mail', + message: { + from: { + name: 'Cow', + wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', + }, + to: { + name: 'Bob', + wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + }, + contents: 'Hello, Bob!', + }, + verifierDomain: { // [!code focus] + name: 'Mock4337Account', // [!code focus] + version: '1', // [!code focus] + chainId: 1, // [!code focus] + verifyingContract: '0xCB9fA1eA9b8A3bf422a8639f23Df77ea66020eC2' // [!code focus] + }, // [!code focus] +}) +``` diff --git a/site/sidebar.ts b/site/sidebar.ts index 81f12b457f..efd29c28e5 100644 --- a/site/sidebar.ts +++ b/site/sidebar.ts @@ -1123,6 +1123,24 @@ export const sidebar = { }, ], }, + { + text: 'Solady', + items: [ + { + text: 'Actions', + items: [ + { + text: 'signMessage', + link: '/experimental/solady/signMessage', + }, + { + text: 'signTypedData', + link: '/experimental/solady/signTypedData', + }, + ], + }, + ], + }, ], }, '/op-stack': { diff --git a/src/actions/public/verifyHash.test.ts b/src/actions/public/verifyHash.test.ts index 1a9b26ebcd..49603eb49e 100644 --- a/src/actions/public/verifyHash.test.ts +++ b/src/actions/public/verifyHash.test.ts @@ -4,105 +4,298 @@ import { ensPublicResolverConfig, smartAccountConfig } from '~test/src/abis.js' import { accounts, address } from '~test/src/constants.js' import { anvilMainnet } from '../../../test/src/anvil.js' +import { Mock4337AccountFactory } from '../../../test/contracts/generated.js' +import { deployMock4337Account } from '../../../test/src/utils.js' +import { privateKeyToAccount } from '../../accounts/privateKeyToAccount.js' +import { serializeErc6492Signature } from '../../experimental/index.js' +import { signMessage as signMessageErc1271 } from '../../experimental/solady/actions/signMessage.js' import type { Hex } from '../../types/misc.js' -import { hashMessage, toBytes } from '../../utils/index.js' +import { + encodeFunctionData, + hashMessage, + pad, + toBytes, +} from '../../utils/index.js' import { parseSignature } from '../../utils/signature/parseSignature.js' +import { mine } from '../test/mine.js' +import { signMessage } from '../wallet/signMessage.js' +import { writeContract } from '../wallet/writeContract.js' +import { simulateContract } from './simulateContract.js' import { verifyHash } from './verifyHash.js' const client = anvilMainnet.getClient() -describe('verifyHash', async () => { - test.each([ - { - _name: 'deployed, supports ERC1271, valid signature, plaintext', - address: smartAccountConfig.address, - hash: hashMessage('This is a test message for viem!'), - signature: - '0xefd5fb29a274ea6682673d8b3caa9263e936d48d486e5df68893003e0a76496439594d12245008c6fba1c8e3ef28241cffe1bef27ff6bca487b167f261f329251c', - expectedResult: true, - }, - { - _name: 'deployed, supports ERC1271, valid signature, plaintext', - address: smartAccountConfig.address, - hash: hashMessage('This is a test message for viem!'), - signature: parseSignature( - '0xefd5fb29a274ea6682673d8b3caa9263e936d48d486e5df68893003e0a76496439594d12245008c6fba1c8e3ef28241cffe1bef27ff6bca487b167f261f329251c', - ), - expectedResult: true, - }, - { - _name: 'deployed, supports ERC1271, invalid signature', - address: smartAccountConfig.address, - hash: hashMessage('This is a test message for viem!'), - signature: '0xdead', - expectedResult: false, - }, - { - _name: 'deployed, does not support ERC1271', - address: ensPublicResolverConfig.address, - hash: hashMessage('0xdead'), - signature: '0xdead', - expectedResult: false, - }, - { - _name: 'undeployed, with correct signature', - address: accounts[0].address, - hash: hashMessage('hello world'), - signature: - '0xa461f509887bd19e312c0c58467ce8ff8e300d3c1a90b608a760c5b80318eaf15fe57c96f9175d6cd4daad4663763baa7e78836e067d0163e9a2ccf2ff753f5b1b', - expectedResult: true, - }, - { - _name: 'undeployed, with correct signature', - address: accounts[0].address, - hash: hashMessage('hello world'), - signature: parseSignature( - '0xa461f509887bd19e312c0c58467ce8ff8e300d3c1a90b608a760c5b80318eaf15fe57c96f9175d6cd4daad4663763baa7e78836e067d0163e9a2ccf2ff753f5b1b', - ), - expectedResult: true, - }, - { - _name: 'undeployed, with wrong signature', - address: address.notDeployed, - hash: hashMessage('0xdead'), - signature: '0xdead', - expectedResult: false, - }, - ] as { - _name: string - address: Hex - hash: Hex - signature: Hex - expectedResult: boolean - }[])('$_name', async ({ address, hash, signature, expectedResult }) => { +const localAccount = privateKeyToAccount(accounts[0].privateKey) + +describe('local account', async () => { + test('hex', async () => { + const signature = await signMessage(client, { + account: localAccount, + message: 'hello world', + }) + + expect( + verifyHash(client, { + address: localAccount.address, + hash: hashMessage('hello world'), + signature, + }), + ).resolves.toBe(true) + }) + + test('bytes', async () => { + const signature = await signMessage(client, { + account: localAccount, + message: 'hello world', + }) + + expect( + verifyHash(client, { + address: localAccount.address, + hash: hashMessage('hello world'), + signature: toBytes(signature), + }), + ).resolves.toBe(true) + }) + + test('object', async () => { + const signature = await signMessage(client, { + account: localAccount, + message: 'hello world', + }) + + expect( + verifyHash(client, { + address: localAccount.address, + hash: hashMessage('hello world'), + signature: parseSignature(signature), + }), + ).resolves.toBe(true) + }) +}) + +describe('smart account', async () => { + test('deployed', async () => { + const { factoryAddress } = await deployMock4337Account() + + const { request, result: verifier } = await simulateContract(client, { + account: localAccount, + abi: Mock4337AccountFactory.abi, + address: factoryAddress, + functionName: 'createAccount', + args: [localAccount.address, pad('0x0')], + }) + await writeContract(client, request) + await mine(client, { blocks: 1 }) + + const signature = await signMessageErc1271(client, { + account: localAccount, + message: 'hello world', + verifier, + }) + expect( - await verifyHash(client, { - address, - hash, + verifyHash(client, { + address: verifier, + hash: hashMessage('hello world'), signature, }), - ).toBe(expectedResult) + ).resolves.toBe(true) }) - test('unexpected errors still get thrown', async () => { - await expect( + test('undeployed', async () => { + const { factoryAddress } = await deployMock4337Account() + + const { result: verifier } = await simulateContract(client, { + account: localAccount, + abi: Mock4337AccountFactory.abi, + address: factoryAddress, + functionName: 'createAccount', + args: [localAccount.address, pad('0x0')], + }) + + const factoryData = encodeFunctionData({ + abi: Mock4337AccountFactory.abi, + functionName: 'createAccount', + args: [localAccount.address, pad('0x0')], + }) + + const signature = await signMessageErc1271(client, { + account: localAccount, + factory: factoryAddress, + factoryData, + message: 'hello world', + verifier, + }) + + expect( verifyHash(client, { - address: '0x0', // invalid address - hash: hashMessage('0xdead'), - signature: '0xdead', + address: verifier, + factory: factoryAddress, + factoryData, + hash: hashMessage('hello world'), + signature, }), - ).rejects.toThrow() + ).resolves.toBe(true) }) - test('accept signature as byte array', async () => { + test('deployed w/ factory + factoryData', async () => { + const { factoryAddress } = await deployMock4337Account() + + const { request, result: verifier } = await simulateContract(client, { + account: localAccount, + abi: Mock4337AccountFactory.abi, + address: factoryAddress, + functionName: 'createAccount', + args: [localAccount.address, pad('0x0')], + }) + await writeContract(client, request) + await mine(client, { blocks: 1 }) + + const factoryData = encodeFunctionData({ + abi: Mock4337AccountFactory.abi, + functionName: 'createAccount', + args: [localAccount.address, pad('0x0')], + }) + + const signature = await signMessageErc1271(client, { + account: localAccount, + factory: factoryAddress, + factoryData, + message: 'hello world', + verifier, + }) + expect( - await verifyHash(client, { - address: smartAccountConfig.address, - hash: hashMessage('This is a test message for viem!'), - signature: toBytes( - '0xefd5fb29a274ea6682673d8b3caa9263e936d48d486e5df68893003e0a76496439594d12245008c6fba1c8e3ef28241cffe1bef27ff6bca487b167f261f329251c', - ), + verifyHash(client, { + address: verifier, + factory: factoryAddress, + factoryData, + hash: hashMessage('hello world'), + signature, }), - ).toBe(true) + ).resolves.toBe(true) }) }) + +test('signature already contains wrapper', async () => { + const { factoryAddress } = await deployMock4337Account() + + const { result: verifier } = await simulateContract(client, { + account: localAccount, + abi: Mock4337AccountFactory.abi, + address: factoryAddress, + functionName: 'createAccount', + args: [localAccount.address, pad('0x0')], + }) + + const factoryData = encodeFunctionData({ + abi: Mock4337AccountFactory.abi, + functionName: 'createAccount', + args: [localAccount.address, pad('0x0')], + }) + + const signature = await signMessageErc1271(client, { + account: localAccount, + factory: factoryAddress, + factoryData, + message: 'hello world', + verifier, + }) + + expect( + verifyHash(client, { + address: verifier, + factory: factoryAddress, + factoryData, + hash: hashMessage('hello world'), + signature: serializeErc6492Signature({ + address: factoryAddress, + data: factoryData, + signature, + }), + }), + ).resolves.toBe(true) +}) + +test.each([ + { + _name: 'deployed, supports ERC1271, valid signature, plaintext', + address: smartAccountConfig.address, + hash: hashMessage('This is a test message for viem!'), + signature: + '0xefd5fb29a274ea6682673d8b3caa9263e936d48d486e5df68893003e0a76496439594d12245008c6fba1c8e3ef28241cffe1bef27ff6bca487b167f261f329251c', + expectedResult: true, + }, + { + _name: 'deployed, supports ERC1271, valid signature, plaintext', + address: smartAccountConfig.address, + hash: hashMessage('This is a test message for viem!'), + signature: parseSignature( + '0xefd5fb29a274ea6682673d8b3caa9263e936d48d486e5df68893003e0a76496439594d12245008c6fba1c8e3ef28241cffe1bef27ff6bca487b167f261f329251c', + ), + expectedResult: true, + }, + { + _name: 'deployed, supports ERC1271, invalid signature', + address: smartAccountConfig.address, + hash: hashMessage('This is a test message for viem!'), + signature: '0xdead', + expectedResult: false, + }, + { + _name: 'deployed, does not support ERC1271', + address: ensPublicResolverConfig.address, + hash: hashMessage('0xdead'), + signature: '0xdead', + expectedResult: false, + }, + { + _name: 'undeployed, with correct signature', + address: accounts[0].address, + hash: hashMessage('hello world'), + signature: + '0xa461f509887bd19e312c0c58467ce8ff8e300d3c1a90b608a760c5b80318eaf15fe57c96f9175d6cd4daad4663763baa7e78836e067d0163e9a2ccf2ff753f5b1b', + expectedResult: true, + }, + { + _name: 'undeployed, with correct signature', + address: accounts[0].address, + hash: hashMessage('hello world'), + signature: parseSignature( + '0xa461f509887bd19e312c0c58467ce8ff8e300d3c1a90b608a760c5b80318eaf15fe57c96f9175d6cd4daad4663763baa7e78836e067d0163e9a2ccf2ff753f5b1b', + ), + expectedResult: true, + }, + { + _name: 'undeployed, with wrong signature', + address: address.notDeployed, + hash: hashMessage('0xdead'), + signature: '0xdead', + expectedResult: false, + }, +] as { + _name: string + address: Hex + hash: Hex + signature: Hex + expectedResult: boolean +}[])('$_name', async ({ address, hash, signature, expectedResult }) => { + expect( + await verifyHash(client, { + address, + hash, + signature, + }), + ).toBe(expectedResult) +}) + +test('unexpected errors still get thrown', async () => { + await expect( + verifyHash(client, { + address: '0x0', // invalid address + hash: hashMessage('0xdead'), + signature: '0xdead', + }), + ).rejects.toThrow() +}) diff --git a/src/actions/public/verifyHash.ts b/src/actions/public/verifyHash.ts index ff2e866df0..e5d7124269 100644 --- a/src/actions/public/verifyHash.ts +++ b/src/actions/public/verifyHash.ts @@ -8,6 +8,7 @@ import { CallExecutionError } from '../../errors/contract.js' import type { ErrorType } from '../../errors/utils.js' import type { Chain } from '../../types/chain.js' import type { ByteArray, Hex, Signature } from '../../types/misc.js' +import type { OneOf } from '../../types/utils.js' import { type EncodeDeployDataErrorType, encodeDeployData, @@ -19,8 +20,11 @@ import { import { type IsHexErrorType, isHex } from '../../utils/data/isHex.js' import { type ToHexErrorType, bytesToHex } from '../../utils/encoding/toHex.js' import { getAction } from '../../utils/getAction.js' +import { isErc6492Signature } from '../../utils/signature/isErc6492Signature.js' +import { serializeErc6492Signature } from '../../utils/signature/serializeErc6492Signature.js' import { serializeSignature } from '../../utils/signature/serializeSignature.js' import { type CallErrorType, type CallParameters, call } from './call.js' +import { getCode } from './getCode.js' export type VerifyHashParameters = Pick< CallParameters, @@ -32,7 +36,7 @@ export type VerifyHashParameters = Pick< hash: Hex /** The signature that was generated by signing the message with the address's private key. */ signature: Hex | ByteArray | Signature -} +} & OneOf<{ factory: Address; factoryData: Hex } | {}> export type VerifyHashReturnType = boolean @@ -53,8 +57,10 @@ export type VerifyHashErrorType = */ export async function verifyHash( client: Client, - { address, hash, signature, ...callRequest }: VerifyHashParameters, + parameters: VerifyHashParameters, ): Promise { + const { address, factory, factoryData, hash, signature, ...rest } = parameters + const signatureHex = (() => { if (isHex(signature)) return signature if (typeof signature === 'object' && 'r' in signature && 's' in signature) @@ -62,6 +68,27 @@ export async function verifyHash( return bytesToHex(signature) })() + const wrappedSignature = await (async () => { + // If no `factory` or `factoryData` is provided, it is assumed that the + // address is not a Smart Account, or the Smart Account is already deployed. + if (!factory && !factoryData) return signatureHex + + // If the signature is already wrapped, return the signature. + if (isErc6492Signature(signatureHex)) return signatureHex + + const bytecode = await getAction(client, getCode, 'getCode')({ address }) + // If the Smart Account is deployed, return the plain signature. + if (bytecode) return signatureHex + + // If the Smart Account is not deployed, wrap the signature with a 6492 wrapper + // to perform counterfactual validation. + return serializeErc6492Signature({ + address: factory!, + data: factoryData!, + signature: signatureHex, + }) + })() + try { const { data } = await getAction( client, @@ -70,10 +97,10 @@ export async function verifyHash( )({ data: encodeDeployData({ abi: universalSignatureValidatorAbi, - args: [address, hash, signatureHex], + args: [address, hash, wrappedSignature], bytecode: universalSignatureValidatorByteCode, }), - ...callRequest, + ...rest, } as unknown as CallParameters) return isBytesEqual(data ?? '0x0', '0x1') diff --git a/src/actions/public/verifyMessage.ts b/src/actions/public/verifyMessage.ts index ed2c4e1ebc..6fc8fc5ff8 100644 --- a/src/actions/public/verifyMessage.ts +++ b/src/actions/public/verifyMessage.ts @@ -50,11 +50,20 @@ export type VerifyMessageErrorType = */ export async function verifyMessage( client: Client, - { address, message, signature, ...callRequest }: VerifyMessageParameters, + { + address, + message, + factory, + factoryData, + signature, + ...callRequest + }: VerifyMessageParameters, ): Promise { const hash = hashMessage(message) return verifyHash(client, { address, + factory: factory!, + factoryData: factoryData!, hash, signature, ...callRequest, diff --git a/src/actions/public/verifyTypedData.ts b/src/actions/public/verifyTypedData.ts index 3eff94bcf0..6a1e2e28a1 100644 --- a/src/actions/public/verifyTypedData.ts +++ b/src/actions/public/verifyTypedData.ts @@ -53,6 +53,8 @@ export async function verifyTypedData< ): Promise { const { address, + factory, + factoryData, signature, message, primaryType, @@ -63,6 +65,8 @@ export async function verifyTypedData< const hash = hashTypedData({ message, primaryType, types, domain }) return verifyHash(client, { address, + factory: factory!, + factoryData: factoryData!, hash, signature, ...callRequest, diff --git a/src/experimental/index.ts b/src/experimental/index.ts index b3d1401e4d..ab26e6c471 100644 --- a/src/experimental/index.ts +++ b/src/experimental/index.ts @@ -75,3 +75,21 @@ export { type WalletActionsErc7715, walletActionsErc7715, } from './erc7715/decorators/erc7715.js' + +export { + type SignMessageErrorType, + type SignMessageParameters, + type SignMessageReturnType, + signMessage, +} from './solady/actions/signMessage.js' +export { + type SignTypedDataErrorType, + type SignTypedDataParameters, + type SignTypedDataReturnType, + signTypedData, +} from './solady/actions/signTypedData.js' +export { + type SoladyActions, + type SoladyActionsParameters, + soladyActions, +} from './solady/decorators/solady.js' diff --git a/src/experimental/solady/actions/signMessage.test.ts b/src/experimental/solady/actions/signMessage.test.ts new file mode 100644 index 0000000000..14473b1fc7 --- /dev/null +++ b/src/experimental/solady/actions/signMessage.test.ts @@ -0,0 +1,195 @@ +import type { Address } from 'abitype' +import { beforeAll, expect, test } from 'vitest' + +import { accounts } from '~test/src/constants.js' +import { Mock4337AccountFactory } from '../../../../test/contracts/generated.js' +import { anvilMainnet } from '../../../../test/src/anvil.js' +import { deployMock4337Account } from '../../../../test/src/utils.js' +import { privateKeyToAccount } from '../../../accounts/privateKeyToAccount.js' +import { + mine, + readContract, + simulateContract, + verifyMessage, + writeContract, +} from '../../../actions/index.js' +import { encodeFunctionData, pad } from '../../../utils/index.js' +import { signMessage } from './signMessage.js' + +let verifier: Address +beforeAll(async () => { + const { factoryAddress } = await deployMock4337Account() + const { result, request } = await simulateContract(client, { + account: accounts[0].address, + abi: Mock4337AccountFactory.abi, + address: factoryAddress, + functionName: 'createAccount', + args: [accounts[0].address, pad('0x0')], + }) + verifier = result + await writeContract(client, request) + await mine(client, { blocks: 1 }) +}) + +const client = anvilMainnet.getClient() + +test('default', async () => { + const message = 'hello world' + const signature = await signMessage(client!, { + account: accounts[0].address, + message, + verifier, + }) + expect( + await verifyMessage(client!, { + address: verifier, + message, + signature, + }), + ).toBeTruthy() +}) + +test('args: domain', async () => { + const message = 'hello world' + const signature = await signMessage(client!, { + account: accounts[0].address, + verifierDomain: { + name: 'Mock4337Account', + version: '1', + chainId: 1, + verifyingContract: verifier, + }, + message, + }) + expect( + await verifyMessage(client!, { + address: verifier, + message, + signature, + }), + ).toBeTruthy() +}) + +test('raw message (hex)', async () => { + const message = { raw: '0x68656c6c6f20776f726c64' } as const + const signature = await signMessage(client!, { + account: accounts[0].address, + message, + verifier, + }) + expect( + await verifyMessage(client!, { + address: verifier, + message, + signature, + }), + ).toBeTruthy() +}) + +test('raw message (bytes)', async () => { + const message = { + raw: Uint8Array.from([ + 104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100, + ]), + } as const + const signature = await signMessage(client!, { + account: accounts[0].address, + message, + verifier, + }) + expect( + await verifyMessage(client!, { + address: verifier, + message, + signature, + }), + ).toBeTruthy() +}) + +test('inferred account', async () => { + const clientWithAccount = anvilMainnet.getClient({ + account: accounts[0].address, + }) + + const message = 'hello world' + const signature = await signMessage(clientWithAccount!, { + message, + verifier, + }) + expect( + await verifyMessage(client!, { + address: verifier, + message, + signature, + }), + ).toBeTruthy() +}) + +test('counterfactual smart account', async () => { + const { factoryAddress } = await deployMock4337Account() + + const factoryData = encodeFunctionData({ + abi: Mock4337AccountFactory.abi, + functionName: 'createAccount', + args: [accounts[0].address, pad('0x1')], + }) + const verifier = await readContract(client, { + account: accounts[0].address, + abi: Mock4337AccountFactory.abi, + address: factoryAddress, + functionName: 'getAddress', + args: [pad('0x1')], + }) + + const message = 'hello world' + const signature = await signMessage(client, { + account: accounts[0].address, + message, + factory: factoryAddress, + factoryData, + verifier, + }) + expect( + await verifyMessage(client!, { + address: verifier, + factory: factoryAddress, + factoryData, + message, + signature, + }), + ).toBeTruthy() +}) + +test('local account', async () => { + const account = privateKeyToAccount(accounts[0].privateKey) + const message = 'hello world' + const signature = await signMessage(client!, { + account, + message, + verifier, + }) + expect( + await verifyMessage(client!, { + address: verifier, + message, + signature, + }), + ).toBeTruthy() +}) + +test('no account', async () => { + await expect( + // @ts-expect-error + signMessage(client!, { + message: 'hello world', + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + ` + [AccountNotFoundError: Could not find an Account to execute with this Action. + Please provide an Account with the \`account\` argument on the Action, or by supplying an \`account\` to the WalletClient. + + Docs: https://viem.sh/experimental/solady/signMessage#account + Version: viem@x.y.z] + `, + ) +}) diff --git a/src/experimental/solady/actions/signMessage.ts b/src/experimental/solady/actions/signMessage.ts new file mode 100644 index 0000000000..0e8228f07d --- /dev/null +++ b/src/experimental/solady/actions/signMessage.ts @@ -0,0 +1,151 @@ +import type { Address, TypedDataDomain } from 'abitype' +import type { Account } from '../../../accounts/types.js' +import { parseAccount } from '../../../accounts/utils/parseAccount.js' +import { + type GetEip712DomainParameters, + getEip712Domain, +} from '../../../actions/public/getEip712Domain.js' +import { signTypedData } from '../../../actions/wallet/signTypedData.js' +import type { Client } from '../../../clients/createClient.js' +import type { Transport } from '../../../clients/transports/createTransport.js' +import { AccountNotFoundError } from '../../../errors/account.js' +import type { ErrorType } from '../../../errors/utils.js' +import type { GetAccountParameter } from '../../../types/account.js' +import type { Chain } from '../../../types/chain.js' +import type { Hex, SignableMessage } from '../../../types/misc.js' +import type { OneOf, RequiredBy } from '../../../types/utils.js' +import { getAction } from '../../../utils/getAction.js' +import { toPrefixedMessage } from '../../../utils/signature/toPrefixedMessage.js' +import type { GetVerifierParameter } from '../types.js' + +export type SignMessageParameters< + account extends Account | undefined = Account | undefined, + accountOverride extends Account | undefined = Account | undefined, + verifier extends Address | undefined = Address | undefined, +> = Pick & + GetAccountParameter & { + message: SignableMessage + } & OneOf< + | { + verifierDomain: RequiredBy< + TypedDataDomain, + 'chainId' | 'name' | 'verifyingContract' | 'version' + > + verifier?: undefined + } + | (GetVerifierParameter & { + verifierDomain?: + | RequiredBy< + TypedDataDomain, + 'chainId' | 'name' | 'verifyingContract' | 'version' + > + | undefined + }) + > + +export type SignMessageReturnType = Hex + +export type SignMessageErrorType = ErrorType + +/** + * Signs a [EIP-191](https://eips.ethereum.org/EIPS/eip-191) personal sign message via Solady's [ERC1271 `PersonalSign` format](https://github.com/Vectorized/solady/blob/678c9163550810b08f0ffb09624c9f7532392303/src/accounts/ERC1271.sol#L154-L166). + * + * This Action is suitable to sign messages for Smart Accounts that implement (or conform to) Solady's [ERC1271.sol](https://github.com/Vectorized/solady/blob/main/src/accounts/ERC1271.sol). + * + * - Docs: https://viem.sh/experimental/solady/signMessage + * + * With the calculated signature, you can: + * - use [`verifyMessage`](https://viem.sh/docs/utilities/verifyMessage) to verify the signature, + * + * @param client - Client to use + * @param parameters - {@link SignMessageParameters} + * @returns The signed message. {@link SignMessageReturnType} + * + * @example + * import { createWalletClient, custom } from 'viem' + * import { mainnet } from 'viem/chains' + * import { signMessage } from 'viem/experimental/solady' + * + * const client = createWalletClient({ + * chain: mainnet, + * transport: custom(window.ethereum), + * }) + * + * const signature = await signMessage(client, { + * account: '0xE8Df82fA4E10e6A12a9Dab552bceA2acd26De9bb', + * message: 'hello world', + * verifier: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e', + * }) + * + * @example + * // Account Hoisting + * import { createWalletClient, custom } from 'viem' + * import { privateKeyToAccount } from 'viem/accounts' + * import { mainnet } from 'viem/chains' + * import { signMessage } from 'viem/experimental/solady' + * + * const client = createWalletClient({ + * account: '0xE8Df82fA4E10e6A12a9Dab552bceA2acd26De9bb', + * chain: mainnet, + * transport: custom(window.ethereum), + * }) + * + * const signature = await signMessage(client, { + * message: 'hello world', + * verifier: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e', + * }) + */ +export async function signMessage< + chain extends Chain | undefined, + account extends Account | undefined, + accountOverride extends Account | undefined = undefined, +>( + client: Client, + parameters: SignMessageParameters, +): Promise { + const { + account: account_ = client.account, + factory, + factoryData, + message, + verifier, + } = parameters + + if (!account_) + throw new AccountNotFoundError({ + docsPath: '/experimental/solady/signMessage', + }) + const account = parseAccount(account_) + + const domain = await (async () => { + if (parameters.verifierDomain) return parameters.verifierDomain + const { + domain: { salt, ...domain }, + } = await getAction( + client, + getEip712Domain, + 'getEip712Domain', + )({ + address: verifier!, + factory, + factoryData, + }) + return domain + })() + + return getAction( + client, + signTypedData, + 'signTypedData', + )({ + account, + domain, + types: { + PersonalSign: [{ name: 'prefixed', type: 'bytes' }], + }, + primaryType: 'PersonalSign', + message: { + prefixed: toPrefixedMessage(message), + }, + }) +} diff --git a/src/experimental/solady/actions/signTypedData.test.ts b/src/experimental/solady/actions/signTypedData.test.ts new file mode 100644 index 0000000000..72ba8a7370 --- /dev/null +++ b/src/experimental/solady/actions/signTypedData.test.ts @@ -0,0 +1,377 @@ +import type { Address } from 'abitype' +import { beforeAll, describe, expect, test } from 'vitest' + +import { accounts, typedData } from '~test/src/constants.js' +import { Mock4337AccountFactory } from '../../../../test/contracts/generated.js' +import { anvilMainnet } from '../../../../test/src/anvil.js' +import { deployMock4337Account } from '../../../../test/src/utils.js' +import { privateKeyToAccount } from '../../../accounts/privateKeyToAccount.js' +import { + mine, + readContract, + simulateContract, + verifyTypedData, + writeContract, +} from '../../../actions/index.js' +import { encodeFunctionData, pad } from '../../../utils/index.js' +import { signTypedData } from './signTypedData.js' + +const client = anvilMainnet.getClient() + +let verifier: Address +beforeAll(async () => { + const { factoryAddress } = await deployMock4337Account() + const { result, request } = await simulateContract(client, { + account: accounts[0].address, + abi: Mock4337AccountFactory.abi, + address: factoryAddress, + functionName: 'createAccount', + args: [accounts[0].address, pad('0x0')], + }) + verifier = result + await writeContract(client, request) + await mine(client, { blocks: 1 }) +}) + +describe('default', () => { + test('json-rpc account', async () => { + const signature = await signTypedData(client, { + ...typedData.complex, + account: accounts[0].address, + primaryType: 'Mail', + verifier, + }) + + const result = await verifyTypedData(client, { + ...typedData.complex, + address: verifier, + signature, + primaryType: 'Mail', + }) + expect(result).toBeTruthy() + }) + + test('local account', async () => { + const signature = await signTypedData(client, { + ...typedData.complex, + account: privateKeyToAccount(accounts[0].privateKey), + primaryType: 'Mail', + verifier, + }) + + const result = await verifyTypedData(client, { + ...typedData.complex, + address: verifier, + signature, + primaryType: 'Mail', + }) + expect(result).toBeTruthy() + }) +}) + +test('inferred account', async () => { + const clientWithAccount = anvilMainnet.getClient({ account: true }) + + const signature = await signTypedData(clientWithAccount, { + ...typedData.complex, + primaryType: 'Mail', + verifier, + }) + + const result = await verifyTypedData(client, { + ...typedData.complex, + address: verifier, + signature, + primaryType: 'Mail', + }) + expect(result).toBeTruthy() +}) + +describe('args: verifierDomain', () => { + test('json-rpc account', async () => { + const signature = await signTypedData(client, { + ...typedData.complex, + account: accounts[0].address, + verifierDomain: { + name: 'Mock4337Account', + version: '1', + chainId: 1, + salt: '0x0000000000000000000000000000000000000000000000000000000000000000', + verifyingContract: verifier, + }, + fields: '0x0f', + extensions: [], + primaryType: 'Mail', + }) + + const result = await verifyTypedData(client, { + ...typedData.complex, + address: verifier, + signature, + primaryType: 'Mail', + }) + expect(result).toBeTruthy() + }) + + test('local account', async () => { + const signature = await signTypedData(client, { + ...typedData.complex, + account: privateKeyToAccount(accounts[0].privateKey), + verifierDomain: { + name: 'Mock4337Account', + version: '1', + chainId: 1, + salt: '0x0000000000000000000000000000000000000000000000000000000000000000', + verifyingContract: verifier, + }, + fields: '0x0f', + extensions: [], + primaryType: 'Mail', + }) + + const result = await verifyTypedData(client, { + ...typedData.complex, + address: verifier, + signature, + primaryType: 'Mail', + }) + expect(result).toBeTruthy() + }) +}) + +describe('args: domain empty', () => { + test('json-rpc account', async () => { + const signature = await signTypedData(client, { + ...typedData.complex, + account: accounts[0].address, + domain: undefined, + primaryType: 'Mail', + verifier, + }) + + const result = await verifyTypedData(client, { + ...typedData.complex, + domain: undefined, + address: verifier, + signature, + primaryType: 'Mail', + }) + expect(result).toBeTruthy() + }) + + test('local account', async () => { + const signature = await signTypedData(client, { + ...typedData.complex, + domain: undefined, + account: privateKeyToAccount(accounts[0].privateKey), + primaryType: 'Mail', + verifier, + }) + + const result = await verifyTypedData(client, { + ...typedData.complex, + domain: undefined, + address: verifier, + signature, + primaryType: 'Mail', + }) + expect(result).toBeTruthy() + }) +}) + +describe('args: domain chainId', () => { + test('json-rpc account', async () => { + const signature = await signTypedData(client, { + ...typedData.complex, + account: accounts[0].address, + domain: { + chainId: 1, + }, + primaryType: 'Mail', + verifier, + }) + + const result = await verifyTypedData(client, { + ...typedData.complex, + domain: { + chainId: 1, + }, + address: verifier, + signature, + primaryType: 'Mail', + }) + expect(result).toBeTruthy() + }) + + test('local account', async () => { + const signature = await signTypedData(client, { + ...typedData.complex, + account: privateKeyToAccount(accounts[0].privateKey), + domain: { + chainId: 1, + }, + primaryType: 'Mail', + verifier, + }) + + const result = await verifyTypedData(client, { + ...typedData.complex, + domain: { + chainId: 1, + }, + address: verifier, + signature, + primaryType: 'Mail', + }) + expect(result).toBeTruthy() + }) +}) + +describe('args: domain name', () => { + test('json-rpc account', async () => { + const signature = await signTypedData(client, { + ...typedData.complex, + account: accounts[0].address, + domain: { + name: 'lol', + }, + primaryType: 'Mail', + verifier, + }) + + const result = await verifyTypedData(client, { + ...typedData.complex, + domain: { + name: 'lol', + }, + address: verifier, + signature, + primaryType: 'Mail', + }) + expect(result).toBeTruthy() + }) + + test('local account', async () => { + const signature = await signTypedData(client, { + ...typedData.complex, + account: privateKeyToAccount(accounts[0].privateKey), + domain: { + name: 'lol', + }, + primaryType: 'Mail', + verifier, + }) + + const result = await verifyTypedData(client, { + ...typedData.complex, + domain: { + name: 'lol', + }, + address: verifier, + signature, + primaryType: 'Mail', + }) + expect(result).toBeTruthy() + }) +}) + +describe('args: domain verifyingContract', () => { + test('json-rpc account', async () => { + const signature = await signTypedData(client, { + ...typedData.complex, + account: accounts[0].address, + domain: { + verifyingContract: '0x0000000000000000000000000000000000000000', + }, + primaryType: 'Mail', + verifier, + }) + + const result = await verifyTypedData(client, { + ...typedData.complex, + domain: { + verifyingContract: '0x0000000000000000000000000000000000000000', + }, + address: verifier, + signature, + primaryType: 'Mail', + }) + expect(result).toBeTruthy() + }) + + test('local account', async () => { + const signature = await signTypedData(client, { + ...typedData.complex, + account: privateKeyToAccount(accounts[0].privateKey), + domain: { + verifyingContract: '0x0000000000000000000000000000000000000000', + }, + primaryType: 'Mail', + verifier, + }) + + const result = await verifyTypedData(client, { + ...typedData.complex, + domain: { + verifyingContract: '0x0000000000000000000000000000000000000000', + }, + address: verifier, + signature, + primaryType: 'Mail', + }) + expect(result).toBeTruthy() + }) +}) + +test('counterfactual smart account', async () => { + const { factoryAddress } = await deployMock4337Account() + + const factoryData = encodeFunctionData({ + abi: Mock4337AccountFactory.abi, + functionName: 'createAccount', + args: [accounts[0].address, pad('0x1')], + }) + const verifier = await readContract(client, { + account: accounts[0].address, + abi: Mock4337AccountFactory.abi, + address: factoryAddress, + functionName: 'getAddress', + args: [pad('0x1')], + }) + + const signature = await signTypedData(client, { + ...typedData.complex, + account: accounts[0].address, + factory: factoryAddress, + factoryData, + primaryType: 'Mail', + verifier, + }) + + const result = await verifyTypedData(client, { + ...typedData.complex, + address: verifier, + factory: factoryAddress, + factoryData, + signature, + primaryType: 'Mail', + }) + expect(result).toBeTruthy() +}) + +test('no account', async () => { + await expect(() => + // @ts-expect-error + signTypedData(client, { + ...typedData.complex, + primaryType: 'Mail', + verifier, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + [AccountNotFoundError: Could not find an Account to execute with this Action. + Please provide an Account with the \`account\` argument on the Action, or by supplying an \`account\` to the WalletClient. + + Docs: https://viem.sh/experimental/solady/signTypedData#account + Version: viem@x.y.z] + `) +}) diff --git a/src/experimental/solady/actions/signTypedData.ts b/src/experimental/solady/actions/signTypedData.ts new file mode 100644 index 0000000000..3c570c5252 --- /dev/null +++ b/src/experimental/solady/actions/signTypedData.ts @@ -0,0 +1,280 @@ +import type { Address, TypedData, TypedDataDomain } from 'abitype' + +import type { Account } from '../../../accounts/types.js' +import { parseAccount } from '../../../accounts/utils/parseAccount.js' +import { + type GetEip712DomainParameters, + getEip712Domain, +} from '../../../actions/public/getEip712Domain.js' +import { signTypedData as signTypedData_ } from '../../../actions/wallet/signTypedData.js' +import type { Client } from '../../../clients/createClient.js' +import type { Transport } from '../../../clients/transports/createTransport.js' +import { AccountNotFoundError } from '../../../errors/account.js' +import type { ErrorType } from '../../../errors/utils.js' +import type { GetAccountParameter } from '../../../types/account.js' +import type { Chain } from '../../../types/chain.js' +import type { Hex } from '../../../types/misc.js' +import type { TypedDataDefinition } from '../../../types/typedData.js' +import type { OneOf, RequiredBy } from '../../../types/utils.js' +import { encodePacked } from '../../../utils/abi/encodePacked.js' +import { size } from '../../../utils/data/size.js' +import { stringToHex } from '../../../utils/encoding/toHex.js' +import { getAction } from '../../../utils/getAction.js' +import { + encodeType, + hashStruct, +} from '../../../utils/signature/hashTypedData.js' +import { getTypesForEIP712Domain } from '../../../utils/typedData.js' +import type { GetVerifierParameter } from '../types.js' + +export type SignTypedDataParameters< + typedData extends TypedData | Record = TypedData, + primaryType extends keyof typedData | 'EIP712Domain' = keyof typedData, + account extends Account | undefined = undefined, + accountOverride extends Account | undefined = undefined, + verifier extends Address | undefined = Address | undefined, + /// + primaryTypes = typedData extends TypedData ? keyof typedData : string, +> = TypedDataDefinition & + Pick & + GetAccountParameter & + OneOf< + | { + verifierDomain: RequiredBy< + TypedDataDomain, + 'chainId' | 'name' | 'verifyingContract' | 'salt' | 'version' + > + fields: Hex + extensions: readonly bigint[] + verifier?: undefined + } + | (GetVerifierParameter & { + verifierDomain?: + | RequiredBy< + TypedDataDomain, + 'chainId' | 'name' | 'verifyingContract' | 'salt' | 'version' + > + | undefined + fields?: Hex | undefined + extensions?: readonly bigint[] | undefined + }) + > + +export type SignTypedDataReturnType = Hex + +export type SignTypedDataErrorType = ErrorType + +/** + * Signs an [EIP-712](https://eips.ethereum.org/EIPS/eip-712) typed data message via Solady's [ERC1271 `TypedDataSign` format](https://github.com/Vectorized/solady/blob/678c9163550810b08f0ffb09624c9f7532392303/src/accounts/ERC1271.sol#L130-L151). + * + * This Action is suitable to sign messages for Smart Accounts that implement (or conform to) Solady's [ERC1271.sol](https://github.com/Vectorized/solady/blob/main/src/accounts/ERC1271.sol). + * + * - Docs: https://viem.sh/experimental/solady/signTypedData + * + * @param client - Client to use + * @param parameters - {@link SignTypedDataParameters} + * @returns The signed data. {@link SignTypedDataReturnType} + * + * @example + * import { createWalletClient, custom } from 'viem' + * import { mainnet } from 'viem/chains' + * import { signTypedData } from 'viem/experimental/solady' + * + * const client = createWalletClient({ + * chain: mainnet, + * transport: custom(window.ethereum), + * }) + * const signature = await signTypedData(client, { + * account: '0xE8Df82fA4E10e6A12a9Dab552bceA2acd26De9bb', + * domain: { + * name: 'Ether Mail', + * version: '1', + * chainId: 1, + * verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', + * }, + * types: { + * Person: [ + * { name: 'name', type: 'string' }, + * { name: 'wallet', type: 'address' }, + * ], + * Mail: [ + * { name: 'from', type: 'Person' }, + * { name: 'to', type: 'Person' }, + * { name: 'contents', type: 'string' }, + * ], + * }, + * primaryType: 'Mail', + * message: { + * from: { + * name: 'Cow', + * wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', + * }, + * to: { + * name: 'Bob', + * wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + * }, + * contents: 'Hello, Bob!', + * }, + * verifier: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e', + * }) + * + * @example + * // Account Hoisting + * import { createWalletClient, http } from 'viem' + * import { privateKeyToAccount } from 'viem/accounts' + * import { mainnet } from 'viem/chains' + * import { signTypedData } from 'viem/experimental/solady' + * + * const client = createWalletClient({ + * account: '0xE8Df82fA4E10e6A12a9Dab552bceA2acd26De9bb' + * chain: mainnet, + * transport: http(), + * }) + * const signature = await signTypedData(client, { + * domain: { + * name: 'Ether Mail', + * version: '1', + * chainId: 1, + * verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', + * }, + * types: { + * Person: [ + * { name: 'name', type: 'string' }, + * { name: 'wallet', type: 'address' }, + * ], + * Mail: [ + * { name: 'from', type: 'Person' }, + * { name: 'to', type: 'Person' }, + * { name: 'contents', type: 'string' }, + * ], + * }, + * primaryType: 'Mail', + * message: { + * from: { + * name: 'Cow', + * wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', + * }, + * to: { + * name: 'Bob', + * wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + * }, + * contents: 'Hello, Bob!', + * }, + * verifier: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e', + * }) + */ +export async function signTypedData< + const typedData extends TypedData | Record, + primaryType extends keyof typedData | 'EIP712Domain', + chain extends Chain | undefined, + account extends Account | undefined, + accountOverride extends Account | undefined = undefined, +>( + client: Client, + parameters: SignTypedDataParameters< + typedData, + primaryType, + account, + accountOverride + >, +): Promise { + const { + account: account_ = client.account, + domain, + factory, + factoryData, + message, + primaryType, + types, + verifier, + } = parameters as unknown as SignTypedDataParameters + + if (!account_) + throw new AccountNotFoundError({ + docsPath: '/experimental/solady/signTypedData', + }) + const account = parseAccount(account_!) + + // Retrieve account EIP712 domain. + const { + domain: verifierDomain, + fields, + extensions, + } = await (async () => { + if (parameters.verifierDomain && parameters.fields && parameters.extensions) + return { + domain: parameters.verifierDomain, + fields: parameters.fields, + extensions: parameters.extensions, + } + return getAction( + client, + getEip712Domain, + 'getEip712Domain', + )({ + address: verifier!, + factory, + factoryData, + }) + })() + + // Sign with typed data wrapper. + const signature = await getAction( + client, + signTypedData_, + 'signTypedData', + )({ + account, + domain, + types: { + ...types, + TypedDataSign: [ + { name: 'contents', type: primaryType }, + { name: 'fields', type: 'bytes1' }, + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + { name: 'salt', type: 'bytes32' }, + { name: 'extensions', type: 'uint256[]' }, + ], + }, + primaryType: 'TypedDataSign', + message: { + contents: message as any, + fields, + extensions, + ...(verifierDomain as any), + }, + }) + + // Compute dependencies for wrapped signature. + const hashedDomain = hashStruct({ + data: domain ?? {}, + types: { + EIP712Domain: getTypesForEIP712Domain({ domain }), + }, + primaryType: 'EIP712Domain', + }) + const hashedContents = hashStruct({ + data: message, + types: types as any, + primaryType, + }) + const encodedType = encodeType({ + primaryType, + types: types as any, + }) + + // Construct wrapped signature. + return encodePacked( + ['bytes', 'bytes32', 'bytes32', 'bytes', 'uint16'], + [ + signature, + hashedDomain, + hashedContents, + stringToHex(encodedType), + size(stringToHex(encodedType)), + ], + ) +} diff --git a/src/experimental/solady/decorators/solady.test.ts b/src/experimental/solady/decorators/solady.test.ts new file mode 100644 index 0000000000..c5d82d12e9 --- /dev/null +++ b/src/experimental/solady/decorators/solady.test.ts @@ -0,0 +1,84 @@ +import type { Address } from 'abitype' +import { beforeAll, expect, test } from 'vitest' + +import { Mock4337AccountFactory } from '../../../../test/contracts/generated.js' +import { anvilMainnet } from '../../../../test/src/anvil.js' +import { accounts, typedData } from '../../../../test/src/constants.js' +import { deployMock4337Account } from '../../../../test/src/utils.js' +import { + mine, + simulateContract, + writeContract, +} from '../../../actions/index.js' +import { pad } from '../../../utils/index.js' +import { soladyActions } from './solady.js' + +const client = anvilMainnet.getClient().extend(soladyActions()) + +let verifier: Address +beforeAll(async () => { + const { factoryAddress } = await deployMock4337Account() + const { result, request } = await simulateContract(client, { + account: accounts[0].address, + abi: Mock4337AccountFactory.abi, + address: factoryAddress, + functionName: 'createAccount', + args: [accounts[0].address, pad('0x0')], + }) + verifier = result + await writeContract(client, request) + await mine(client, { blocks: 1 }) +}) + +test('default', () => { + expect(soladyActions()(client)).toMatchInlineSnapshot(` + { + "signMessage": [Function], + "signTypedData": [Function], + } + `) +}) + +test('signMessage', async () => { + const result = await client.signMessage({ + account: accounts[0].address, + message: 'Hello, world!', + verifier, + }) + expect(result).toMatchInlineSnapshot( + `"0x2fd7a8fe17e1e364f3527263a1e0963f2bf1c1e12c0889347f7633351cb7d8060a1a87099c3ed804bd31405705946dc9266744574b1153f2905948a6967d67a11b"`, + ) + + const clientWithAccount = anvilMainnet + .getClient({ account: true }) + .extend(soladyActions({ verifier })) + const result_2 = await clientWithAccount.signMessage({ + message: 'Hello, world!', + }) + expect(result_2).toMatchInlineSnapshot( + `"0x2fd7a8fe17e1e364f3527263a1e0963f2bf1c1e12c0889347f7633351cb7d8060a1a87099c3ed804bd31405705946dc9266744574b1153f2905948a6967d67a11b"`, + ) +}) + +test('signTypedData', async () => { + const result = await client.signTypedData({ + ...typedData.complex, + account: accounts[0].address, + primaryType: 'Mail', + verifier, + }) + expect(result).toMatchInlineSnapshot( + `"0x17f7e62a3921a11100b1f9631ed17c176767719fcc6e6178bf05e44fe0def85274e45a4f27940e9bc53f55d8c6934524d461f031059ba41bc0991e52093e53a71c1788ede5301fb0c4b95dda42eabe811ba83dc3cde96087b00c9b72a4d26a379ac2972c4c4323c6d7ee73e319350f290c6549b6eb516b5e5354128413342332154d61696c2875696e743235362074696d657374616d702c506572736f6e2066726f6d2c506572736f6e20746f2c737472696e6720636f6e74656e74732c62797465732068617368294e616d6528737472696e672066697273742c737472696e67206c61737429506572736f6e284e616d65206e616d652c616464726573732077616c6c65742c737472696e675b335d206661766f72697465436f6c6f72732c75696e7432353620666f6f2c75696e7438206167652c626f6f6c206973436f6f6c2900c1"`, + ) + + const clientWithAccount = anvilMainnet + .getClient({ account: true }) + .extend(soladyActions({ verifier })) + const result_2 = await clientWithAccount.signTypedData({ + ...typedData.complex, + primaryType: 'Mail', + }) + expect(result_2).toMatchInlineSnapshot( + `"0x17f7e62a3921a11100b1f9631ed17c176767719fcc6e6178bf05e44fe0def85274e45a4f27940e9bc53f55d8c6934524d461f031059ba41bc0991e52093e53a71c1788ede5301fb0c4b95dda42eabe811ba83dc3cde96087b00c9b72a4d26a379ac2972c4c4323c6d7ee73e319350f290c6549b6eb516b5e5354128413342332154d61696c2875696e743235362074696d657374616d702c506572736f6e2066726f6d2c506572736f6e20746f2c737472696e6720636f6e74656e74732c62797465732068617368294e616d6528737472696e672066697273742c737472696e67206c61737429506572736f6e284e616d65206e616d652c616464726573732077616c6c65742c737472696e675b335d206661766f72697465436f6c6f72732c75696e7432353620666f6f2c75696e7438206167652c626f6f6c206973436f6f6c2900c1"`, + ) +}) diff --git a/src/experimental/solady/decorators/solady.ts b/src/experimental/solady/decorators/solady.ts new file mode 100644 index 0000000000..a972ce41cd --- /dev/null +++ b/src/experimental/solady/decorators/solady.ts @@ -0,0 +1,232 @@ +import type { Address, TypedData } from 'abitype' +import type { Client } from '../../../clients/createClient.js' +import type { Transport } from '../../../clients/transports/createTransport.js' +import type { Account } from '../../../types/account.js' +import type { Chain } from '../../../types/chain.js' +import { + type SignMessageParameters, + type SignMessageReturnType, + signMessage, +} from '../actions/signMessage.js' +import { + type SignTypedDataParameters, + type SignTypedDataReturnType, + signTypedData, +} from '../actions/signTypedData.js' + +export type SoladyActions< + account extends Account | undefined = Account | undefined, + verifier extends Address | undefined = Address | undefined, +> = { + /** + * Signs a [EIP-191](https://eips.ethereum.org/EIPS/eip-191) personal sign message via Solady's [ERC1271 `PersonalSign` format](https://github.com/Vectorized/solady/blob/678c9163550810b08f0ffb09624c9f7532392303/src/accounts/ERC1271.sol#L154-L166). + * + * This Action is suitable to sign messages for Smart Accounts that implement (or conform to) Solady's [ERC1271.sol](https://github.com/Vectorized/solady/blob/main/src/accounts/ERC1271.sol). + * + * - Docs: https://viem.sh/experimental/solady/signMessage + * + * With the calculated signature, you can: + * - use [`verifyMessage`](https://viem.sh/docs/utilities/verifyMessage) to verify the signature, + * + * @param client - Client to use + * @param parameters - {@link SignMessageParameters} + * @returns The signed message. {@link SignMessageReturnType} + * + * @example + * ```ts + * import { createWalletClient, custom } from 'viem' + * import { mainnet } from 'viem/chains' + * import { soladyActions } from 'viem/experimental/solady' + * + * const client = createWalletClient({ + * chain: mainnet, + * transport: custom(window.ethereum), + * }).extend(soladyActions()) + * + * const signature = await client.signMessage({ + * account: '0xE8Df82fA4E10e6A12a9Dab552bceA2acd26De9bb', + * message: 'hello world', + * verifier: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e', + * }) + * ``` + * + * @example Account & Signer Hoisting + * ```ts + * import { createWalletClient, custom } from 'viem' + * import { mainnet } from 'viem/chains' + * import { soladyActions } from 'viem/experimental/solady' + * + * const client = createWalletClient({ + * account: '0xE8Df82fA4E10e6A12a9Dab552bceA2acd26De9bb', + * chain: mainnet, + * transport: custom(window.ethereum), + * }).extend(soladyActions({ verifier: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e' })) + * + * const signature = await client.signMessage({ + * message: 'hello world', + * }) + * ``` + */ + signMessage: ( + parameters: SignMessageParameters, + ) => Promise + /** + * Signs an [EIP-712](https://eips.ethereum.org/EIPS/eip-712) typed data message via Solady's [ERC1271 `TypedDataSign` format](https://github.com/Vectorized/solady/blob/678c9163550810b08f0ffb09624c9f7532392303/src/accounts/ERC1271.sol#L130-L151). + * + * This Action is suitable to sign messages for Smart Accounts that implement (or conform to) Solady's [ERC1271.sol](https://github.com/Vectorized/solady/blob/main/src/accounts/ERC1271.sol). + * + * - Docs: https://viem.sh/experimental/solady/signTypedData + * + * @param client - Client to use + * @param parameters - {@link SignTypedDataParameters} + * @returns The signed data. {@link SignTypedDataReturnType} + * + * @example + * ```ts + * import { createWalletClient, custom } from 'viem' + * import { mainnet } from 'viem/chains' + * import { soladyActions } from 'viem/experimental/solady' + * + * const client = createWalletClient({ + * chain: mainnet, + * transport: custom(window.ethereum), + * }).extend(soladyActions()) + * + * const signature = await client.signTypedData({ + * account: '0xE8Df82fA4E10e6A12a9Dab552bceA2acd26De9bb', + * domain: { + * name: 'Ether Mail', + * version: '1', + * chainId: 1, + * verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', + * }, + * types: { + * Person: [ + * { name: 'name', type: 'string' }, + * { name: 'wallet', type: 'address' }, + * ], + * Mail: [ + * { name: 'from', type: 'Person' }, + * { name: 'to', type: 'Person' }, + * { name: 'contents', type: 'string' }, + * ], + * }, + * primaryType: 'Mail', + * message: { + * from: { + * name: 'Cow', + * wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', + * }, + * to: { + * name: 'Bob', + * wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + * }, + * contents: 'Hello, Bob!', + * }, + * verifier: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e', + * }) + * ``` + * + * @example Account & Signer Hoisting + * ```ts + * import { createWalletClient, http } from 'viem' + * import { privateKeyToAccount } from 'viem/accounts' + * import { mainnet } from 'viem/chains' + * import { soladyActions } from 'viem/experimental/solady' + * + * const client = createWalletClient({ + * account: '0xE8Df82fA4E10e6A12a9Dab552bceA2acd26De9bb', + * chain: mainnet, + * transport: http(), + * }).extend(soladyActions({ verifier: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e' })) + * + * const signature = await client.signTypedData({ + * domain: { + * name: 'Ether Mail', + * version: '1', + * chainId: 1, + * verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', + * }, + * types: { + * Person: [ + * { name: 'name', type: 'string' }, + * { name: 'wallet', type: 'address' }, + * ], + * Mail: [ + * { name: 'from', type: 'Person' }, + * { name: 'to', type: 'Person' }, + * { name: 'contents', type: 'string' }, + * ], + * }, + * primaryType: 'Mail', + * message: { + * from: { + * name: 'Cow', + * wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826', + * }, + * to: { + * name: 'Bob', + * wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB', + * }, + * contents: 'Hello, Bob!', + * }, + * }) + * ``` + */ + signTypedData: < + const typedData extends TypedData | Record, + primaryType extends keyof typedData | 'EIP712Domain', + accountOverride extends Account | undefined = undefined, + >( + parameters: SignTypedDataParameters< + typedData, + primaryType, + account, + accountOverride + >, + ) => Promise +} + +export type SoladyActionsParameters< + verifier extends Account | Address | undefined = + | Account + | Address + | undefined, +> = { + verifier?: verifier | undefined +} + +/** + * A suite of Actions based on [Solady contracts](https://github.com/Vectorized/solady). + * + * @example + * import { createPublicClient, createWalletClient, http } from 'viem' + * import { mainnet } from 'viem/chains' + * import { soladyActions } from 'viem/experimental' + * + * const walletClient = createWalletClient({ + * chain: mainnet, + * transport: http(), + * }).extend(soladyActions()) + * + * const result = await walletClient.signMessage({...}) + */ +export function soladyActions( + parameters: SoladyActionsParameters = {}, +) { + const { verifier } = parameters + return < + transport extends Transport, + chain extends Chain | undefined = Chain | undefined, + account extends Account | undefined = Account | undefined, + >( + client: Client, + ): SoladyActions => { + return { + signMessage: (parameters) => + signMessage(client, { verifier, ...parameters }), + signTypedData: (parameters) => + signTypedData(client, { verifier, ...(parameters as any) }), + } + } +} diff --git a/src/experimental/solady/types.ts b/src/experimental/solady/types.ts new file mode 100644 index 0000000000..ffd80ed287 --- /dev/null +++ b/src/experimental/solady/types.ts @@ -0,0 +1,7 @@ +import type { Address } from 'abitype' + +export type GetVerifierParameter< + verifier extends Address | undefined = Address, +> = verifier extends Address + ? { verifier?: Address | undefined } + : { verifier: Address } diff --git a/src/index.test.ts b/src/index.test.ts index 9d8f61896c..f30b1ad7fa 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -374,6 +374,7 @@ test('exports', () => { "toEventHash", "toFunctionHash", "hashMessage", + "toPrefixedMessage", "isAddress", "isAddressEqual", "isBytes", diff --git a/src/index.ts b/src/index.ts index ecd2f68892..6681637f4c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1587,6 +1587,10 @@ export { type HashMessageErrorType, hashMessage, } from './utils/signature/hashMessage.js' +export { + type ToPrefixedMessageErrorType, + toPrefixedMessage, +} from './utils/signature/toPrefixedMessage.js' export { type IsAddressOptions, type IsAddressErrorType, diff --git a/src/utils/signature/hashMessage.ts b/src/utils/signature/hashMessage.ts index c5179e9e30..acff88d33a 100644 --- a/src/utils/signature/hashMessage.ts +++ b/src/utils/signature/hashMessage.ts @@ -1,14 +1,7 @@ -import { presignMessagePrefix } from '../../constants/strings.js' import type { ErrorType } from '../../errors/utils.js' import type { ByteArray, Hex, SignableMessage } from '../../types/misc.js' -import { type ConcatErrorType, concat } from '../data/concat.js' -import { - type StringToBytesErrorType, - type ToBytesErrorType, - stringToBytes, - toBytes, -} from '../encoding/toBytes.js' import { type Keccak256ErrorType, keccak256 } from '../hash/keccak256.js' +import { toPrefixedMessage } from './toPrefixedMessage.js' type To = 'hex' | 'bytes' @@ -16,24 +9,11 @@ export type HashMessage = | (TTo extends 'bytes' ? ByteArray : never) | (TTo extends 'hex' ? Hex : never) -export type HashMessageErrorType = - | ConcatErrorType - | Keccak256ErrorType - | StringToBytesErrorType - | ToBytesErrorType - | ErrorType +export type HashMessageErrorType = Keccak256ErrorType | ErrorType export function hashMessage( message: SignableMessage, to_?: TTo | undefined, ): HashMessage { - const messageBytes = (() => { - if (typeof message === 'string') return stringToBytes(message) - if (message.raw instanceof Uint8Array) return message.raw - return toBytes(message.raw) - })() - const prefixBytes = stringToBytes( - `${presignMessagePrefix}${messageBytes.length}`, - ) - return keccak256(concat([prefixBytes, messageBytes]), to_) + return keccak256(toPrefixedMessage(message), to_) } diff --git a/src/utils/signature/hashTypedData.ts b/src/utils/signature/hashTypedData.ts index 928f770a5e..0277d41622 100644 --- a/src/utils/signature/hashTypedData.ts +++ b/src/utils/signature/hashTypedData.ts @@ -102,7 +102,7 @@ export function hashDomain({ type HashStructErrorType = EncodeDataErrorType | Keccak256ErrorType | ErrorType -function hashStruct({ +export function hashStruct({ data, primaryType, types, @@ -170,7 +170,7 @@ function hashType({ type EncodeTypeErrorType = FindTypeDependenciesErrorType -function encodeType({ +export function encodeType({ primaryType, types, }: { diff --git a/src/utils/signature/toPrefixedMessage.test.ts b/src/utils/signature/toPrefixedMessage.test.ts new file mode 100644 index 0000000000..39ace5110f --- /dev/null +++ b/src/utils/signature/toPrefixedMessage.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from 'vitest' +import { stringToBytes } from '../encoding/toBytes.js' +import { stringToHex } from '../encoding/toHex.js' +import { toPrefixedMessage } from './toPrefixedMessage.js' + +test('default', () => { + expect(toPrefixedMessage('hello world')).toMatchInlineSnapshot( + `"0x19457468657265756d205369676e6564204d6573736167653a0a313168656c6c6f20776f726c64"`, + ) + expect( + toPrefixedMessage({ raw: stringToHex('hello world') }), + ).toMatchInlineSnapshot( + `"0x19457468657265756d205369676e6564204d6573736167653a0a313168656c6c6f20776f726c64"`, + ) + expect( + toPrefixedMessage({ raw: stringToBytes('hello world') }), + ).toMatchInlineSnapshot( + `"0x19457468657265756d205369676e6564204d6573736167653a0a313168656c6c6f20776f726c64"`, + ) +}) diff --git a/src/utils/signature/toPrefixedMessage.ts b/src/utils/signature/toPrefixedMessage.ts new file mode 100644 index 0000000000..c1e2805bdf --- /dev/null +++ b/src/utils/signature/toPrefixedMessage.ts @@ -0,0 +1,27 @@ +import { presignMessagePrefix } from '../../constants/strings.js' +import type { ErrorType } from '../../errors/utils.js' +import type { Hex, SignableMessage } from '../../types/misc.js' +import { type ConcatErrorType, concat } from '../data/concat.js' +import { size } from '../data/size.js' +import { + type BytesToHexErrorType, + type StringToHexErrorType, + bytesToHex, + stringToHex, +} from '../encoding/toHex.js' + +export type ToPrefixedMessageErrorType = + | ConcatErrorType + | StringToHexErrorType + | BytesToHexErrorType + | ErrorType + +export function toPrefixedMessage(message_: SignableMessage): Hex { + const message = (() => { + if (typeof message_ === 'string') return stringToHex(message_) + if (typeof message_.raw === 'string') return message_.raw + return bytesToHex(message_.raw) + })() + const prefix = stringToHex(`${presignMessagePrefix}${size(message)}`) + return concat([prefix, message]) +}