From 5fd7990794bfd64d62b3822212eb835991ec82a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Coffee=E2=98=95=EF=B8=8F?= Date: Tue, 1 Oct 2024 14:10:37 -0400 Subject: [PATCH] add verify actions which extends public client for onchain verification of counterfactual signatures --- packages/agw-client/package.json | 5 + .../src/abis/UniversalSigValidator.ts | 28 +++++ .../agw-client/src/abstractPublicActions.ts | 27 +++++ packages/agw-client/src/actions/verifyHash.ts | 114 ++++++++++++++++++ .../agw-client/src/actions/verifyMessage.ts | 42 +++++++ .../src/actions/verifySiweMessage.ts | 57 +++++++++ .../agw-client/src/actions/verifyTypedData.ts | 50 ++++++++ packages/agw-client/src/constants.ts | 4 + .../src/createAbstractPublicClient.ts | 50 ++++++++ packages/agw-client/src/exports/actions.ts | 5 + packages/agw-client/src/exports/index.ts | 1 + .../src/utils/siwe/parseSiweMessage.ts | 54 +++++++++ .../src/utils/siwe/validateSiweMessage.ts | 39 ++++++ 13 files changed, 476 insertions(+) create mode 100644 packages/agw-client/src/abis/UniversalSigValidator.ts create mode 100644 packages/agw-client/src/abstractPublicActions.ts create mode 100644 packages/agw-client/src/actions/verifyHash.ts create mode 100644 packages/agw-client/src/actions/verifyMessage.ts create mode 100644 packages/agw-client/src/actions/verifySiweMessage.ts create mode 100644 packages/agw-client/src/actions/verifyTypedData.ts create mode 100644 packages/agw-client/src/createAbstractPublicClient.ts create mode 100644 packages/agw-client/src/exports/actions.ts create mode 100644 packages/agw-client/src/utils/siwe/parseSiweMessage.ts create mode 100644 packages/agw-client/src/utils/siwe/validateSiweMessage.ts diff --git a/packages/agw-client/package.json b/packages/agw-client/package.json index c9bccbb..3cda633 100644 --- a/packages/agw-client/package.json +++ b/packages/agw-client/package.json @@ -27,6 +27,11 @@ "types": "./dist/types/exports/index.d.ts", "import": "./dist/esm/exports/index.js", "require": "./dist/cjs/exports/index.js" + }, + "./actions": { + "types": "./dist/types/exports/actions.d.ts", + "import": "./dist/esm/exports/actions.js", + "require": "./dist/cjs/exports/actions.js" } }, "files": [ diff --git a/packages/agw-client/src/abis/UniversalSigValidator.ts b/packages/agw-client/src/abis/UniversalSigValidator.ts new file mode 100644 index 0000000..2e1516e --- /dev/null +++ b/packages/agw-client/src/abis/UniversalSigValidator.ts @@ -0,0 +1,28 @@ +const UniversalSignatureValidatorAbi = [ + { + inputs: [ + { + name: '_signer', + type: 'address', + }, + { + name: '_hash', + type: 'bytes32', + }, + { + name: '_signature', + type: 'bytes', + }, + ], + outputs: [ + { + type: 'boolean', + }, + ], + name: 'isValidUniversalSig', + stateMutability: 'nonpayable', + type: 'function', + }, +] as const; + +export default UniversalSignatureValidatorAbi; diff --git a/packages/agw-client/src/abstractPublicActions.ts b/packages/agw-client/src/abstractPublicActions.ts new file mode 100644 index 0000000..d136f29 --- /dev/null +++ b/packages/agw-client/src/abstractPublicActions.ts @@ -0,0 +1,27 @@ +import { + type Account, + type Chain, + type Client, + type PublicActions, + publicActions, + type Transport, +} from 'viem'; + +import { verifyMessage } from './actions/verifyMessage'; +import { verifySiweMessage } from './actions/verifySiweMessage'; +import { verifyTypedData } from './actions/verifyTypedData'; + +export function abstractPublicActions< + transport extends Transport = Transport, + chain extends Chain = Chain, + account extends Account | undefined = Account | undefined, +>( + client: Client, +): PublicActions { + return { + ...publicActions(client), + verifyMessage: (args) => verifyMessage(client, args), + verifySiweMessage: (args) => verifySiweMessage(client, args), + verifyTypedData: (args) => verifyTypedData(client, args), + }; +} diff --git a/packages/agw-client/src/actions/verifyHash.ts b/packages/agw-client/src/actions/verifyHash.ts new file mode 100644 index 0000000..15c128f --- /dev/null +++ b/packages/agw-client/src/actions/verifyHash.ts @@ -0,0 +1,114 @@ +import { + bytesToHex, + CallExecutionError, + type CallParameters, + type Chain, + type Client, + encodeFunctionData, + getAddress, + hexToBool, + isAddressEqual, + isErc6492Signature, + isHex, + recoverAddress, + serializeErc6492Signature, + serializeSignature, + type Transport, +} from 'viem'; +import { + call, + verifyHash as viemVerifyHash, + type VerifyHashParameters, + type VerifyHashReturnType, +} from 'viem/actions'; +import { abstractTestnet } from 'viem/chains'; +import { getAction } from 'viem/utils'; + +import UniversalSignatureValidatorAbi from '../abis/UniversalSigValidator.js'; +import { UNIVERSAL_SIGNATURE_VALIDATOR_ADDRESS } from '../constants.js'; + +const supportedChains: (number | undefined)[] = [abstractTestnet.id]; + +/** + * Verifies a message hash onchain using ERC-6492. + * + * @param client - Client to use. + * @param parameters - {@link VerifyHashParameters} + * @returns Whether or not the signature is valid. {@link VerifyHashReturnType} + */ +export async function verifyHash( + client: Client, + parameters: VerifyHashParameters, +): Promise { + const { address, factory, factoryData, hash, signature, ...rest } = + parameters; + + if (!supportedChains.includes(client.chain.id)) { + return await viemVerifyHash(client, parameters); + } + + const signatureHex = (() => { + if (isHex(signature)) return signature; + if (typeof signature === 'object' && 'r' in signature && 's' in signature) + return serializeSignature(signature); + 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; + + // If the Smart Account is not deployed, wrap the signature with a 6492 wrapper + // to perform counterfactual validation. + return serializeErc6492Signature({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + address: factory!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + data: factoryData!, + signature: signatureHex, + }); + })(); + + try { + const { data } = await getAction( + client, + call, + 'call', + )({ + to: UNIVERSAL_SIGNATURE_VALIDATOR_ADDRESS, + data: encodeFunctionData({ + abi: UniversalSignatureValidatorAbi, + functionName: 'isValidUniversalSig', + args: [address, hash, wrappedSignature], + }), + ...rest, + } as unknown as CallParameters); + + const isValid = hexToBool(data ?? '0x0'); + + return isValid; + } catch (error) { + // Fallback attempt to verify the signature via ECDSA recovery. + try { + const verified = isAddressEqual( + getAddress(address), + await recoverAddress({ hash, signature }), + ); + if (verified) return true; + // eslint-disable-next-line no-empty + } catch {} + + if (error instanceof CallExecutionError) { + // if the execution fails, the signature was not valid and an internal method inside of the validator reverted + // this can happen for many reasons, for example if signer can not be recovered from the signature + // or if the signature has no valid format + return false; + } + + throw error; + } +} diff --git a/packages/agw-client/src/actions/verifyMessage.ts b/packages/agw-client/src/actions/verifyMessage.ts new file mode 100644 index 0000000..73371f3 --- /dev/null +++ b/packages/agw-client/src/actions/verifyMessage.ts @@ -0,0 +1,42 @@ +import { type Chain, type Client, hashMessage, type Transport } from 'viem'; +import type { + VerifyMessageParameters, + VerifyMessageReturnType, +} from 'viem/actions'; + +import { verifyHash } from './verifyHash'; + +/** + * Verify that a message was signed by the provided address. + * + * Compatible with Smart Contract Accounts & Externally Owned Accounts via [ERC-6492](https://eips.ethereum.org/EIPS/eip-6492). + * + * - Docs {@link https://viem.sh/docs/actions/public/verifyMessage} + * + * @param client - Client to use. + * @param parameters - {@link VerifyMessageParameters} + * @returns Whether or not the signature is valid. {@link VerifyMessageReturnType} + */ +export async function verifyMessage( + client: Client, + { + address, + message, + factory, + factoryData, + signature, + ...callRequest + }: VerifyMessageParameters, +): Promise { + const hash = hashMessage(message); + return verifyHash(client, { + address, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + factory: factory!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + factoryData: factoryData!, + hash, + signature, + ...callRequest, + }); +} diff --git a/packages/agw-client/src/actions/verifySiweMessage.ts b/packages/agw-client/src/actions/verifySiweMessage.ts new file mode 100644 index 0000000..89bed52 --- /dev/null +++ b/packages/agw-client/src/actions/verifySiweMessage.ts @@ -0,0 +1,57 @@ +import { type Chain, type Client, hashMessage, type Transport } from 'viem'; +import type { + VerifySiweMessageParameters, + VerifySiweMessageReturnType, +} from 'viem/_types/actions/siwe/verifySiweMessage'; + +import { parseSiweMessage } from '../utils/siwe/parseSiweMessage'; +import { validateSiweMessage } from '../utils/siwe/validateSiweMessage'; +import { verifyHash } from './verifyHash'; + +/** + * Verifies [EIP-4361](https://eips.ethereum.org/EIPS/eip-4361) formatted message was signed. + * + * Compatible with Smart Contract Accounts & Externally Owned Accounts via [ERC-6492](https://eips.ethereum.org/EIPS/eip-6492). + * + * - Docs {@link https://viem.sh/docs/siwe/actions/verifySiweMessage} + * + * @param client - Client to use. + * @param parameters - {@link VerifySiweMessageParameters} + * @returns Whether or not the signature is valid. {@link VerifySiweMessageReturnType} + */ +export async function verifySiweMessage( + client: Client, + parameters: VerifySiweMessageParameters, +): Promise { + const { + address, + domain, + message, + nonce, + scheme, + signature, + time = new Date(), + ...callRequest + } = parameters; + + const parsed = parseSiweMessage(message); + if (!parsed.address) return false; + + const isValid = validateSiweMessage({ + address, + domain, + message: parsed, + nonce, + scheme, + time, + }); + if (!isValid) return false; + + const hash = hashMessage(message); + return verifyHash(client, { + address: parsed.address, + hash, + signature, + ...callRequest, + }); +} diff --git a/packages/agw-client/src/actions/verifyTypedData.ts b/packages/agw-client/src/actions/verifyTypedData.ts new file mode 100644 index 0000000..75d379a --- /dev/null +++ b/packages/agw-client/src/actions/verifyTypedData.ts @@ -0,0 +1,50 @@ +import { + type Chain, + type Client, + hashTypedData, + type Transport, + type TypedData, + type VerifyTypedDataReturnType, +} from 'viem'; +import type { VerifyTypedDataParameters } from 'viem/actions'; + +import { verifyHash } from './verifyHash'; + +/** + * Verify that typed data was signed by the provided address. + * + * - Docs {@link https://viem.sh/docs/actions/public/verifyTypedData} + * + * @param client - Client to use. + * @param parameters - {@link VerifyTypedDataParameters} + * @returns Whether or not the signature is valid. {@link VerifyTypedDataReturnType} + */ +export async function verifyTypedData< + const typedData extends TypedData | Record, + primaryType extends keyof typedData | 'EIP712Domain', + chain extends Chain, +>( + client: Client, + parameters: VerifyTypedDataParameters, +): Promise { + const { + address, + factory, + factoryData, + signature, + message, + primaryType, + types, + domain, + ...callRequest + } = parameters as VerifyTypedDataParameters; + const hash = hashTypedData({ message, primaryType, types, domain }); + return verifyHash(client, { + address, + factory: factory!, + factoryData: factoryData!, + hash, + signature, + ...callRequest, + }); +} diff --git a/packages/agw-client/src/constants.ts b/packages/agw-client/src/constants.ts index 27a9780..3c943ea 100644 --- a/packages/agw-client/src/constants.ts +++ b/packages/agw-client/src/constants.ts @@ -11,6 +11,9 @@ const VALIDATOR_ADDRESS = '0xC894DE2894e2F84C0C2944FDcce9490eC22A92b6'; const CONTRACT_DEPLOYER_ADDRESS = '0x0000000000000000000000000000000000008006' as const; +const UNIVERSAL_SIGNATURE_VALIDATOR_ADDRESS = + '0x4d98aa5724ef4f638d326Eac4Ab032C67B08ac65' as const; + const INSUFFICIENT_BALANCE_SELECTOR = '0xe7931438' as const; export { @@ -18,5 +21,6 @@ export { CONTRACT_DEPLOYER_ADDRESS, INSUFFICIENT_BALANCE_SELECTOR, SMART_ACCOUNT_FACTORY_ADDRESS, + UNIVERSAL_SIGNATURE_VALIDATOR_ADDRESS, VALIDATOR_ADDRESS, }; diff --git a/packages/agw-client/src/createAbstractPublicClient.ts b/packages/agw-client/src/createAbstractPublicClient.ts new file mode 100644 index 0000000..08af056 --- /dev/null +++ b/packages/agw-client/src/createAbstractPublicClient.ts @@ -0,0 +1,50 @@ +import { + type Account, + type Address, + type Chain, + createClient, + type ParseAccount, + type PublicClient, + type PublicClientConfig, + type RpcSchema, + type Transport, +} from 'viem'; + +import { abstractPublicActions } from './abstractPublicActions'; + +/** + * Creates a Public Client with a given [Transport](https://viem.sh/docs/clients/intro) configured for a [Chain](https://viem.sh/docs/clients/chains). + * + * - Docs: https://viem.sh/docs/clients/public + * + * A Public Client is an interface to "public" [JSON-RPC API](https://ethereum.org/en/developers/docs/apis/json-rpc/) methods such as retrieving block numbers, transactions, reading from smart contracts, etc through [Public Actions](/docs/actions/public/introduction). + * + * @param config - {@link PublicClientConfig} + * @returns A Public Client. {@link PublicClient} + * + * @example + * import { createPublicClient, http } from 'viem' + * import { mainnet } from 'viem/chains' + * + * const client = createPublicClient({ + * chain: mainnet, + * transport: http(), + * }) + */ +export function createAbstractPublicClient< + transport extends Transport, + chain extends Chain, + accountOrAddress extends Account | Address | undefined = undefined, + rpcSchema extends RpcSchema | undefined = undefined, +>( + parameters: PublicClientConfig, +): PublicClient, rpcSchema> { + const { key = 'abs-public', name = 'Abstract Public Client' } = parameters; + const client = createClient({ + ...parameters, + key, + name, + type: 'abstractPublicClient', + }); + return client.extend(abstractPublicActions) as any; +} diff --git a/packages/agw-client/src/exports/actions.ts b/packages/agw-client/src/exports/actions.ts new file mode 100644 index 0000000..4fa69ad --- /dev/null +++ b/packages/agw-client/src/exports/actions.ts @@ -0,0 +1,5 @@ +export { abstractPublicActions } from '../abstractPublicActions.js'; +export { verifyHash } from '../actions/verifyHash.js'; +export { verifyMessage } from '../actions/verifyMessage.js'; +export { verifySiweMessage } from '../actions/verifySiweMessage.js'; +export { verifyTypedData } from '../actions/verifyTypedData.js'; diff --git a/packages/agw-client/src/exports/index.ts b/packages/agw-client/src/exports/index.ts index 9d4f58e..80cb129 100644 --- a/packages/agw-client/src/exports/index.ts +++ b/packages/agw-client/src/exports/index.ts @@ -2,5 +2,6 @@ export { type AbstractClient, createAbstractClient, } from '../abstractClient.js'; +export { createAbstractPublicClient } from '../createAbstractPublicClient.js'; export { transformEIP1193Provider } from '../transformEIP1193Provider.js'; export { getSmartAccountAddressFromInitialSigner } from '../utils.js'; diff --git a/packages/agw-client/src/utils/siwe/parseSiweMessage.ts b/packages/agw-client/src/utils/siwe/parseSiweMessage.ts new file mode 100644 index 0000000..9da47a0 --- /dev/null +++ b/packages/agw-client/src/utils/siwe/parseSiweMessage.ts @@ -0,0 +1,54 @@ +import type { Address } from 'abitype'; +import type { ExactPartial, Prettify } from 'viem'; +import type { SiweMessage } from 'viem/_types/utils/siwe/types'; + +/** + * @description Parses EIP-4361 formatted message into message fields object. + * + * @see https://eips.ethereum.org/EIPS/eip-4361 + * + * @returns EIP-4361 fields object + */ +export function parseSiweMessage( + message: string, +): Prettify> { + const { scheme, statement, ...prefix } = (message.match(prefixRegex) + ?.groups ?? {}) as { + address: Address; + domain: string; + scheme?: string; + statement?: string; + }; + const { chainId, expirationTime, issuedAt, notBefore, requestId, ...suffix } = + (message.match(suffixRegex)?.groups ?? {}) as { + chainId: string; + expirationTime?: string; + issuedAt?: string; + nonce: string; + notBefore?: string; + requestId?: string; + uri: string; + version: '1'; + }; + const resources = message.split('Resources:')[1]?.split('\n- ').slice(1); + return { + ...prefix, + ...suffix, + ...(chainId ? { chainId: Number(chainId) } : {}), + ...(expirationTime ? { expirationTime: new Date(expirationTime) } : {}), + ...(issuedAt ? { issuedAt: new Date(issuedAt) } : {}), + ...(notBefore ? { notBefore: new Date(notBefore) } : {}), + ...(requestId ? { requestId } : {}), + ...(resources ? { resources } : {}), + ...(scheme ? { scheme } : {}), + ...(statement ? { statement } : {}), + }; +} + +// https://regexr.com/80gdj +const prefixRegex = + /^(?:(?[a-zA-Z][a-zA-Z0-9+-.]*):\/\/)?(?[a-zA-Z0-9+-.]*(?::[0-9]{1,5})?) (?:wants you to sign in with your Ethereum account:\n)(?
0x[a-fA-F0-9]{40})\n\n(?:(?.*)\n\n)?/; + +// https://regexr.com/80gf9 +const suffixRegex = + /(?:URI: (?.+))\n(?:Version: (?.+))\n(?:Chain ID: (?\d+))\n(?:Nonce: (?[a-zA-Z0-9]+))\n(?:Issued At: (?.+))(?:\nExpiration Time: (?.+))?(?:\nNot Before: (?.+))?(?:\nRequest ID: (?.+))?/; diff --git a/packages/agw-client/src/utils/siwe/validateSiweMessage.ts b/packages/agw-client/src/utils/siwe/validateSiweMessage.ts new file mode 100644 index 0000000..ba03f36 --- /dev/null +++ b/packages/agw-client/src/utils/siwe/validateSiweMessage.ts @@ -0,0 +1,39 @@ +import { isAddressEqual } from 'viem'; +import type { + ValidateSiweMessageParameters, + ValidateSiweMessageReturnType, +} from 'viem/_types/utils/siwe/validateSiweMessage'; + +/** + * @description Validates EIP-4361 message. + * + * @see https://eips.ethereum.org/EIPS/eip-4361 + */ +export function validateSiweMessage( + parameters: ValidateSiweMessageParameters, +): ValidateSiweMessageReturnType { + const { + address, + domain, + message, + nonce, + scheme, + time = new Date(), + } = parameters; + + if (domain && message.domain !== domain) return false; + if (nonce && message.nonce !== nonce) return false; + if (scheme && message.scheme !== scheme) return false; + + if (message.expirationTime && time >= message.expirationTime) return false; + if (message.notBefore && time < message.notBefore) return false; + + try { + if (!message.address) return false; + if (address && !isAddressEqual(message.address, address)) return false; + } catch { + return false; + } + + return true; +}