From cb19058a3df30e0a1319659f0e9a63fe75beaaf1 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 19 Dec 2024 15:27:49 +0000 Subject: [PATCH] feat: display `SafeMessage` message hash when signing off-chain (#4687) * fix: don't use `chainId` when calculating domain hash of <=1.2.0 * Extract check to variable * fix: use `dataGas` when calculating message hash of <1.0.0 * feat: display `SafeMessage` message hash when signing off-chain --- .../Summary/SafeTxHashDataRow/index.test.ts | 117 ---------- .../Summary/SafeTxHashDataRow/index.tsx | 49 +--- .../tx-flow/flows/SignMessage/SignMessage.tsx | 10 + src/utils/__tests__/safe-hashes.test.ts | 211 ++++++++++++++++++ src/utils/safe-hashes.ts | 59 +++++ 5 files changed, 282 insertions(+), 164 deletions(-) delete mode 100644 src/components/transactions/TxDetails/Summary/SafeTxHashDataRow/index.test.ts create mode 100644 src/utils/__tests__/safe-hashes.test.ts create mode 100644 src/utils/safe-hashes.ts diff --git a/src/components/transactions/TxDetails/Summary/SafeTxHashDataRow/index.test.ts b/src/components/transactions/TxDetails/Summary/SafeTxHashDataRow/index.test.ts deleted file mode 100644 index f650abe798..0000000000 --- a/src/components/transactions/TxDetails/Summary/SafeTxHashDataRow/index.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { faker } from '@faker-js/faker' -import { getDomainHash, getMessageHash } from '.' -import { AbiCoder, keccak256 } from 'ethers' -import type { SafeTransactionData, SafeVersion } from '@safe-global/safe-core-sdk-types' - -// <= 1.2.0 -// keccak256("EIP712Domain(address verifyingContract)"); -const OLD_DOMAIN_TYPEHASH = '0x035aff83d86937d35b32e04f0ddc6ff469290eef2f1b692d8a815c89404d4749' - -// >= 1.3.0 -// keccak256("EIP712Domain(uint256 chainId,address verifyingContract)"); -const NEW_DOMAIN_TYPEHASH = '0x47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a79469218' - -// < 1.0.0 -// keccak256("SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 dataGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)"); -const OLD_SAFE_TX_TYPEHASH = '0x14d461bc7412367e924637b363c7bf29b8f47e2f84869f4426e5633d8af47b20' - -// >= 1.0.0 -// keccak256("SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)"); -const NEW_SAFE_TX_TYPEHASH = '0xbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d8' - -describe('SafeTxHashDataRow', () => { - describe('getDomainHash', () => { - it.each(['1.0.0' as const, '1.1.1' as const, '1.2.0' as const])( - 'should return the domain hash without chain ID for version %s', - (version) => { - const chainId = faker.string.numeric() - const safeAddress = faker.finance.ethereumAddress() - - const result = getDomainHash({ chainId, safeAddress, safeVersion: version }) - - expect(result).toEqual( - keccak256(AbiCoder.defaultAbiCoder().encode(['bytes32', 'address'], [OLD_DOMAIN_TYPEHASH, safeAddress])), - ) - }, - ) - - it.each(['1.3.0' as const, '1.4.1' as const])( - 'should return the domain hash with chain ID for version %s', - (version) => { - const chainId = faker.string.numeric() - const safeAddress = faker.finance.ethereumAddress() - - const result = getDomainHash({ chainId, safeAddress, safeVersion: version }) - - expect(result).toEqual( - keccak256( - AbiCoder.defaultAbiCoder().encode( - ['bytes32', 'uint256', 'address'], - [NEW_DOMAIN_TYPEHASH, chainId, safeAddress], - ), - ), - ) - }, - ) - }) - - describe('getMessageHash', () => { - it.each([ - ['0.1.0' as SafeVersion, OLD_SAFE_TX_TYPEHASH], - ['1.0.0' as const, NEW_SAFE_TX_TYPEHASH], - ['1.1.1' as const, NEW_SAFE_TX_TYPEHASH], - ['1.2.0' as const, NEW_SAFE_TX_TYPEHASH], - ['1.3.0' as const, NEW_SAFE_TX_TYPEHASH], - ['1.4.1' as const, NEW_SAFE_TX_TYPEHASH], - ])('should return the message hash for version %s', (version, typehash) => { - const SafeTx: SafeTransactionData = { - to: faker.finance.ethereumAddress(), - value: faker.string.numeric(), - data: faker.string.hexadecimal({ length: 30 }), - operation: faker.number.int({ min: 0, max: 1 }), - safeTxGas: faker.string.numeric(), - baseGas: faker.string.numeric(), // <1.0.0 is dataGas - gasPrice: faker.string.numeric(), - gasToken: faker.finance.ethereumAddress(), - refundReceiver: faker.finance.ethereumAddress(), - nonce: faker.number.int({ min: 0, max: 69 }), - } - - const result = getMessageHash({ safeVersion: version, safeTxData: SafeTx }) - - expect(result).toEqual( - keccak256( - AbiCoder.defaultAbiCoder().encode( - [ - 'bytes32', - 'address', // to - 'uint256', // value - 'bytes32', // data - 'uint8', // operation - 'uint256', // safeTxGas - 'uint256', // dataGas/baseGas - 'uint256', // gasPrice - 'address', // gasToken - 'address', // refundReceiver - 'uint256', // nonce - ], - [ - typehash, - SafeTx.to, - SafeTx.value, - // EIP-712 expects data to be hashed - keccak256(SafeTx.data), - SafeTx.operation, - SafeTx.safeTxGas, - SafeTx.baseGas, - SafeTx.gasPrice, - SafeTx.gasToken, - SafeTx.refundReceiver, - SafeTx.nonce, - ], - ), - ), - ) - }) - }) -}) diff --git a/src/components/transactions/TxDetails/Summary/SafeTxHashDataRow/index.tsx b/src/components/transactions/TxDetails/Summary/SafeTxHashDataRow/index.tsx index 0fc21a99c6..be21c59cd8 100644 --- a/src/components/transactions/TxDetails/Summary/SafeTxHashDataRow/index.tsx +++ b/src/components/transactions/TxDetails/Summary/SafeTxHashDataRow/index.tsx @@ -1,53 +1,8 @@ -import { TypedDataEncoder } from 'ethers' import { TxDataRow, generateDataRowValue } from '../TxDataRow' import { type SafeTransactionData, type SafeVersion } from '@safe-global/safe-core-sdk-types' -import { getEip712TxTypes } from '@safe-global/protocol-kit/dist/src/utils' import useSafeAddress from '@/hooks/useSafeAddress' import useChainId from '@/hooks/useChainId' -import semverSatisfies from 'semver/functions/satisfies' - -const NEW_DOMAIN_TYPE_HASH_VERSION = '>=1.3.0' - -export function getDomainHash({ - chainId, - safeAddress, - safeVersion, -}: { - chainId: string - safeAddress: string - safeVersion: SafeVersion -}): string { - const includeChainId = semverSatisfies(safeVersion, NEW_DOMAIN_TYPE_HASH_VERSION) - return TypedDataEncoder.hashDomain({ - ...(includeChainId && { chainId }), - verifyingContract: safeAddress, - }) -} - -const NEW_SAFE_TX_TYPE_HASH_VERSION = '>=1.0.0' - -export function getMessageHash({ - safeVersion, - safeTxData, -}: { - safeVersion: SafeVersion - safeTxData: SafeTransactionData -}): string { - const usesBaseGas = semverSatisfies(safeVersion, NEW_SAFE_TX_TYPE_HASH_VERSION) - const SafeTx = getEip712TxTypes(safeVersion).SafeTx - - // Clone to not modify the original - const tx: any = { ...safeTxData } - - if (!usesBaseGas) { - tx.dataGas = tx.baseGas - delete tx.baseGas - - SafeTx[5].name = 'dataGas' - } - - return TypedDataEncoder.hashStruct('SafeTx', { SafeTx }, tx) -} +import { getDomainHash, getSafeTxMessageHash } from '@/utils/safe-hashes' export const SafeTxHashDataRow = ({ safeTxHash, @@ -62,7 +17,7 @@ export const SafeTxHashDataRow = ({ const safeAddress = useSafeAddress() const domainHash = getDomainHash({ chainId, safeAddress, safeVersion }) - const messageHash = safeTxData ? getMessageHash({ safeVersion, safeTxData }) : undefined + const messageHash = safeTxData ? getSafeTxMessageHash({ safeVersion, safeTxData }) : undefined return ( <> diff --git a/src/components/tx-flow/flows/SignMessage/SignMessage.tsx b/src/components/tx-flow/flows/SignMessage/SignMessage.tsx index d411e3d23a..13879c8e1d 100644 --- a/src/components/tx-flow/flows/SignMessage/SignMessage.tsx +++ b/src/components/tx-flow/flows/SignMessage/SignMessage.tsx @@ -57,6 +57,8 @@ import LinkIcon from '@/public/images/messages/link.svg' import { Blockaid } from '@/components/tx/security/blockaid' import CheckWallet from '@/components/common/CheckWallet' import NetworkWarning from '@/components/new-safe/create/NetworkWarning' +import { getDomainHash, getSafeMessageMessageHash } from '@/utils/safe-hashes' +import type { SafeVersion } from '@safe-global/safe-core-sdk-types' const createSkeletonMessage = (confirmationsRequired: number): SafeMessage => { return { @@ -260,6 +262,12 @@ const SignMessage = ({ message, origin, requestId }: SignMessageProps): ReactEle const { decodedMessage, safeMessageMessage, safeMessageHash } = useDecodedSafeMessage(message, safe) const [safeMessage, setSafeMessage] = useSafeMessage(safeMessageHash) + const domainHash = getDomainHash({ + chainId: safe.chainId, + safeAddress: safe.address.value, + safeVersion: safe.version as SafeVersion, + }) + const messageHash = getSafeMessageMessageHash({ message: decodedMessage, safeVersion: safe.version as SafeVersion }) const isPlainTextMessage = typeof decodedMessage === 'string' const decodedMessageAsString = isPlainTextMessage ? decodedMessage : JSON.stringify(decodedMessage, null, 2) const signedByCurrentSafe = !!safeMessage?.confirmations.some(({ owner }) => owner.value === wallet?.address) @@ -346,6 +354,8 @@ const SignMessage = ({ message, origin, requestId }: SignMessageProps): ReactEle + + diff --git a/src/utils/__tests__/safe-hashes.test.ts b/src/utils/__tests__/safe-hashes.test.ts new file mode 100644 index 0000000000..562748ef60 --- /dev/null +++ b/src/utils/__tests__/safe-hashes.test.ts @@ -0,0 +1,211 @@ +import { faker } from '@faker-js/faker' +import { getDomainHash, getSafeMessageMessageHash, getSafeTxMessageHash } from '../safe-hashes' +import { AbiCoder, hashMessage, keccak256, TypedDataEncoder } from 'ethers' +import type { SafeTransactionData, SafeVersion } from '@safe-global/safe-core-sdk-types' + +// <= 1.2.0 +// keccak256("EIP712Domain(address verifyingContract)"); +const OLD_DOMAIN_TYPEHASH = '0x035aff83d86937d35b32e04f0ddc6ff469290eef2f1b692d8a815c89404d4749' + +// >= 1.3.0 +// keccak256("EIP712Domain(uint256 chainId,address verifyingContract)"); +const NEW_DOMAIN_TYPEHASH = '0x47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a79469218' + +// < 1.0.0 +// keccak256("SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 dataGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)"); +const OLD_SAFE_TX_TYPEHASH = '0x14d461bc7412367e924637b363c7bf29b8f47e2f84869f4426e5633d8af47b20' + +// >= 1.0.0 +// keccak256("SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)"); +const NEW_SAFE_TX_TYPEHASH = '0xbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d8' + +// Not versioned (>= 0.1.0) +// keccak256("SafeMessage(bytes message)"); +const SAFE_MESSAGE_TYPEHASH = '0x60b3cbf8b4a223d68d641b3b6ddf9a298e7f33710cf3d3a9d1146b5a6150fbca' + +describe('getDomainHash', () => { + it.each(['1.0.0' as const, '1.1.1' as const, '1.2.0' as const])( + 'should return the domain hash without chain ID for version %s', + (version) => { + const chainId = faker.string.numeric() + const safeAddress = faker.finance.ethereumAddress() + + const result = getDomainHash({ chainId, safeAddress, safeVersion: version }) + + expect(result).toEqual( + keccak256(AbiCoder.defaultAbiCoder().encode(['bytes32', 'address'], [OLD_DOMAIN_TYPEHASH, safeAddress])), + ) + }, + ) + + it.each(['1.3.0' as const, '1.4.1' as const])( + 'should return the domain hash with chain ID for version %s', + (version) => { + const chainId = faker.string.numeric() + const safeAddress = faker.finance.ethereumAddress() + + const result = getDomainHash({ chainId, safeAddress, safeVersion: version }) + + expect(result).toEqual( + keccak256( + AbiCoder.defaultAbiCoder().encode( + ['bytes32', 'uint256', 'address'], + [NEW_DOMAIN_TYPEHASH, chainId, safeAddress], + ), + ), + ) + }, + ) +}) + +describe('getSafeTxMessageHash', () => { + it.each([ + ['0.1.0' as SafeVersion, OLD_SAFE_TX_TYPEHASH], + ['1.0.0' as const, NEW_SAFE_TX_TYPEHASH], + ['1.1.1' as const, NEW_SAFE_TX_TYPEHASH], + ['1.2.0' as const, NEW_SAFE_TX_TYPEHASH], + ['1.3.0' as const, NEW_SAFE_TX_TYPEHASH], + ['1.4.1' as const, NEW_SAFE_TX_TYPEHASH], + ])('should return the message hash for version %s', (version, typehash) => { + const SafeTx: SafeTransactionData = { + to: faker.finance.ethereumAddress(), + value: faker.string.numeric(), + data: faker.string.hexadecimal({ length: 30 }), + operation: faker.number.int({ min: 0, max: 1 }), + safeTxGas: faker.string.numeric(), + baseGas: faker.string.numeric(), // <1.0.0 is dataGas + gasPrice: faker.string.numeric(), + gasToken: faker.finance.ethereumAddress(), + refundReceiver: faker.finance.ethereumAddress(), + nonce: faker.number.int({ min: 0, max: 69 }), + } + + const result = getSafeTxMessageHash({ safeVersion: version, safeTxData: SafeTx }) + + expect(result).toEqual( + keccak256( + AbiCoder.defaultAbiCoder().encode( + [ + 'bytes32', + 'address', // to + 'uint256', // value + 'bytes32', // data + 'uint8', // operation + 'uint256', // safeTxGas + 'uint256', // dataGas/baseGas + 'uint256', // gasPrice + 'address', // gasToken + 'address', // refundReceiver + 'uint256', // nonce + ], + [ + typehash, + SafeTx.to, + SafeTx.value, + // EIP-712 expects data to be hashed + keccak256(SafeTx.data), + SafeTx.operation, + SafeTx.safeTxGas, + SafeTx.baseGas, + SafeTx.gasPrice, + SafeTx.gasToken, + SafeTx.refundReceiver, + SafeTx.nonce, + ], + ), + ), + ) + }) +}) + +describe('getSafeMessageMessageHash', () => { + describe('string messages', () => { + it.each([ + '0.1.0' as SafeVersion, + '1.0.0' as const, + '1.1.1' as const, + '1.2.0' as const, + '1.3.0' as const, + '1.4.1' as const, + ])(`should return the message hash for version %s`, (version) => { + // const message = faker.lorem.sentence() + + const message = 'test23' + + const result = getSafeMessageMessageHash({ message, safeVersion: version }) + + expect(result.slice(0, 5)).toBe('0x995') + + expect(result).toEqual( + keccak256( + AbiCoder.defaultAbiCoder().encode( + ['bytes32', 'bytes32'], + [ + SAFE_MESSAGE_TYPEHASH, + // EIP-712 expects bytes to be hashed + keccak256(hashMessage(message)), + ], + ), + ), + ) + }) + }) + + describe('typed data messages', () => { + it.each([ + '0.1.0' as SafeVersion, + '1.0.0' as const, + '1.1.1' as const, + '1.2.0' as const, + '1.3.0' as const, + '1.4.1' as const, + ])(`should return the message hash for version %s`, (version) => { + const message = { + domain: { + name: faker.company.name(), + version: faker.string.numeric(), + chainId: faker.number.int(), + verifyingContract: faker.finance.ethereumAddress(), + }, + 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: faker.person.firstName(), + wallet: faker.finance.ethereumAddress(), + }, + to: { + name: faker.person.firstName(), + wallet: faker.finance.ethereumAddress(), + }, + contents: faker.lorem.words(), + }, + } + + const result = getSafeMessageMessageHash({ message, safeVersion: version }) + + expect(result).toEqual( + keccak256( + AbiCoder.defaultAbiCoder().encode( + ['bytes32', 'bytes32'], + [ + SAFE_MESSAGE_TYPEHASH, + // EIP-712 expects bytes to be hashed + keccak256(TypedDataEncoder.hash(message.domain, message.types, message.message)), + ], + ), + ), + ) + }) + }) +}) diff --git a/src/utils/safe-hashes.ts b/src/utils/safe-hashes.ts new file mode 100644 index 0000000000..7f87932069 --- /dev/null +++ b/src/utils/safe-hashes.ts @@ -0,0 +1,59 @@ +import { TypedDataEncoder } from 'ethers' +import semverSatisfies from 'semver/functions/satisfies' +import { getEip712MessageTypes, getEip712TxTypes } from '@safe-global/protocol-kit/dist/src/utils' +import type { SafeMessage, SafeTransactionData, SafeVersion } from '@safe-global/safe-core-sdk-types' + +import { generateSafeMessageMessage } from './safe-messages' + +const NEW_DOMAIN_TYPE_HASH_VERSION = '>=1.3.0' +const NEW_SAFE_TX_TYPE_HASH_VERSION = '>=1.0.0' + +export function getDomainHash({ + chainId, + safeAddress, + safeVersion, +}: { + chainId: string + safeAddress: string + safeVersion: SafeVersion +}): string { + const includeChainId = semverSatisfies(safeVersion, NEW_DOMAIN_TYPE_HASH_VERSION) + return TypedDataEncoder.hashDomain({ + ...(includeChainId && { chainId }), + verifyingContract: safeAddress, + }) +} + +export function getSafeTxMessageHash({ + safeVersion, + safeTxData, +}: { + safeVersion: SafeVersion + safeTxData: SafeTransactionData +}): string { + const usesBaseGas = semverSatisfies(safeVersion, NEW_SAFE_TX_TYPE_HASH_VERSION) + const SafeTx = getEip712TxTypes(safeVersion).SafeTx + + // Clone to not modify the original + const tx: any = { ...safeTxData } + + if (!usesBaseGas) { + tx.dataGas = tx.baseGas + delete tx.baseGas + + SafeTx[5].name = 'dataGas' + } + + return TypedDataEncoder.hashStruct('SafeTx', { SafeTx }, tx) +} + +export function getSafeMessageMessageHash({ + message, + safeVersion, +}: { + message: SafeMessage['data'] + safeVersion: SafeVersion +}): string { + const SafeMessage = getEip712MessageTypes(safeVersion).SafeMessage + return TypedDataEncoder.hashStruct('SafeMessage', { SafeMessage }, { message: generateSafeMessageMessage(message) }) +}