diff --git a/src/domain/common/mappers/typed-data.mapper.spec.ts b/src/domain/common/mappers/typed-data.mapper.spec.ts new file mode 100644 index 0000000000..a13259b032 --- /dev/null +++ b/src/domain/common/mappers/typed-data.mapper.spec.ts @@ -0,0 +1,632 @@ +import { faker } from '@faker-js/faker'; +import { + encodeAbiParameters, + getAddress, + hashMessage, + hashTypedData, + keccak256, + parseAbiParameters, +} from 'viem'; +import { TypedDataMapper } from '@/domain/common/mappers/typed-data.mapper'; +import { multisigTransactionBuilder } from '@/domain/safe/entities/__tests__/multisig-transaction.builder'; +import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; +import { fakeJson } from '@/__tests__/faker'; +import type { ILoggingService } from '@/logging/logging.interface'; + +// eslint-disable-next-line no-restricted-imports -- required for testing +import { getSafeL2SingletonDeployment } from '@safe-global/safe-deployments'; + +// <=1.2.0 +// @see https://github.com/safe-global/safe-smart-account/blob/v1.2.0/contracts/GnosisSafe.sol#L23-L26 +const DOMAIN_TYPEHASH_OLD = + '0x035aff83d86937d35b32e04f0ddc6ff469290eef2f1b692d8a815c89404d4749'; +const DOMAIN_TYPEHASH_OLD_VERSIONS = [ + '0.1.0', + '1.0.0', + '1.1.0', + '1.1.1', + '1.2.0', +]; + +// >=1.3.0 +// @see https://github.com/safe-global/safe-smart-account/blob/v1.3.0/contracts/GnosisSafe.sol#L35-L38 +const DOMAIN_TYPEHASH_NEW = + '0x47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a79469218'; +const DOMAIN_TYPEHASH_NEW_VERSIONS = ['1.3.0', '1.4.0', '1.4.1']; + +// <1.0.0 +// @see https://github.com/safe-global/safe-smart-account/blob/v0.1.0/contracts/GnosisSafe.sol#L25-L28 +const SAFE_TX_TYPEHASH_OLD = + '0x14d461bc7412367e924637b363c7bf29b8f47e2f84869f4426e5633d8af47b20'; +const SAFE_TX_TYPEHASH_OLD_VERSIONS = ['0.1.0']; + +// >=1.0.0 +// @see https://github.com/safe-global/safe-smart-account/blob/v1.0.0/contracts/GnosisSafe.sol#L25-L28 +const SAFE_TX_TYPEHASH_NEW = + '0xbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d8'; +const SAFE_TX_TYPEHASH_NEW_VERSIONS = [ + '1.0.0', + '1.1.0', + '1.1.1', + '1.2.0', + '1.3.0', + '1.4.0', + '1.4.1', +]; + +// <=1.4.1 +// @see https://github.com/safe-global/safe-smart-account/blob/v0.1.0/contracts/GnosisSafe.sol#L30-L33 +const SAFE_MESSAGE_TYPEHASH = + '0x60b3cbf8b4a223d68d641b3b6ddf9a298e7f33710cf3d3a9d1146b5a6150fbca'; +const SAFE_MESSAGE_TYPEHASH_VERSIONS = [ + '0.1.0', + '1.0.0', + '1.1.0', + '1.1.1', + '1.2.0', + '1.3.0', + '1.4.0', + '1.4.1', +]; + +const mockLoggingService = { + error: jest.fn(), +} as jest.MockedObjectDeep; + +describe('SafeTypedDataMapper', () => { + let target: TypedDataMapper; + + beforeEach(() => { + jest.resetAllMocks(); + + target = new TypedDataMapper(mockLoggingService); + }); + + describe('mapSafeTxTypedData', () => { + describe('domainHash', () => { + it.each(DOMAIN_TYPEHASH_OLD_VERSIONS)( + 'should return domainHash for version %s', + (version) => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().with('version', version).build(); + const transaction = multisigTransactionBuilder() + .with('safe', safe.address) + .build(); + + const typedData = target.mapSafeTxTypedData({ + chainId, + safe, + transaction, + }); + + expect(typedData).toEqual( + expect.objectContaining({ + domainHash: keccak256( + encodeAbiParameters(parseAbiParameters('bytes32, address'), [ + DOMAIN_TYPEHASH_OLD, + getAddress(safe.address), + ]), + ), + }), + ); + }, + ); + + it.each(DOMAIN_TYPEHASH_NEW_VERSIONS)( + 'should return chainId-based domainHash for version %s', + (version) => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().with('version', version).build(); + const transaction = multisigTransactionBuilder() + .with('safe', safe.address) + .build(); + + const typedData = target.mapSafeTxTypedData({ + chainId, + safe, + transaction, + }); + + expect(typedData).toEqual( + expect.objectContaining({ + domainHash: keccak256( + encodeAbiParameters( + parseAbiParameters('bytes32, uint256, address'), + [ + DOMAIN_TYPEHASH_NEW, + BigInt(chainId), + getAddress(safe.address), + ], + ), + ), + }), + ); + }, + ); + + it('should return null domainHash if version is null', () => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().with('version', null).build(); + const transaction = multisigTransactionBuilder() + .with('safe', safe.address) + .build(); + + const typedData = target.mapSafeTxTypedData({ + chainId, + safe, + transaction, + }); + + expect(typedData).toEqual( + expect.objectContaining({ + domainHash: null, + }), + ); + }); + + it('should return null if domain is invalid', () => { + const chainId = faker.string.numeric(); + const safe = safeBuilder() + // Not a valid verifyingContract + .with('address', 'invalid' as `0x${string}`) + .build(); + const transaction = multisigTransactionBuilder() + .with('safe', safe.address) + .build(); + + const typedData = target.mapSafeTxTypedData({ + chainId, + safe, + transaction, + }); + + expect(mockLoggingService.error).toHaveBeenCalledTimes(1); + expect(mockLoggingService.error).toHaveBeenNthCalledWith( + 1, + `Failed to hash domain for ${safe.address}`, + ); + expect(typedData).toEqual( + expect.objectContaining({ + domainHash: null, + }), + ); + }); + }); + + describe('messageHash', () => { + it.each(SAFE_TX_TYPEHASH_OLD_VERSIONS)( + 'should return dataGas-based messageHash for version %s', + (version) => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().with('version', version).build(); + const transaction = multisigTransactionBuilder() + .with('safe', safe.address) + .build(); + + const typedData = target.mapSafeTxTypedData({ + chainId, + safe, + transaction, + }); + + if ( + !transaction.data || + !transaction.safeTxGas || + !transaction.baseGas || + !transaction.gasPrice || + !transaction.gasToken || + !transaction.refundReceiver + ) { + // Appease TypeScript + throw new Error('Missing transaction data'); + } + + expect(typedData).toEqual( + expect.objectContaining({ + messageHash: keccak256( + encodeAbiParameters( + parseAbiParameters( + 'bytes32, address, uint256, bytes32, uint8, uint256, uint256, uint256, address, address, uint256', + ), + [ + SAFE_TX_TYPEHASH_OLD, + getAddress(transaction.to), + BigInt(transaction.value), + // EIP-712 expects bytes to be hashed + keccak256(transaction.data), + transaction.operation, + BigInt(transaction.safeTxGas), + BigInt(transaction.baseGas), + BigInt(transaction.gasPrice), + getAddress(transaction.gasToken), + getAddress(transaction.refundReceiver), + BigInt(transaction.nonce), + ], + ), + ), + }), + ); + }, + ); + + it.each(SAFE_TX_TYPEHASH_NEW_VERSIONS)( + 'should return baseGas-based messageHash for version %s', + (version) => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().with('version', version).build(); + const transaction = multisigTransactionBuilder() + .with('safe', safe.address) + .build(); + + const typedData = target.mapSafeTxTypedData({ + chainId, + safe, + transaction, + }); + + if ( + !transaction.data || + !transaction.safeTxGas || + !transaction.baseGas || + !transaction.gasPrice || + !transaction.gasToken || + !transaction.refundReceiver + ) { + // Appease TypeScript + throw new Error('Missing transaction data'); + } + + expect(typedData).toEqual( + expect.objectContaining({ + messageHash: keccak256( + encodeAbiParameters( + parseAbiParameters( + 'bytes32, address, uint256, bytes32, uint8, uint256, uint256, uint256, address, address, uint256', + ), + [ + SAFE_TX_TYPEHASH_NEW, + getAddress(transaction.to), + BigInt(transaction.value), + // EIP-712 expects bytes to be hashed + keccak256(transaction.data), + transaction.operation, + BigInt(transaction.safeTxGas), + BigInt(transaction.baseGas), + BigInt(transaction.gasPrice), + getAddress(transaction.gasToken), + getAddress(transaction.refundReceiver), + BigInt(transaction.nonce), + ], + ), + ), + }), + ); + }, + ); + + it.each(['safeTxGas' as const, 'baseGas' as const, 'gasPrice' as const])( + 'should return messageHash if transaction %s is 0', + (field) => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().build(); + const transaction = multisigTransactionBuilder() + .with('safe', safe.address) + .with(field, 0) + .build(); + + const typedData = target.mapSafeTxTypedData({ + chainId, + safe, + transaction, + }); + + expect(typedData).toEqual( + expect.objectContaining({ + messageHash: expect.any(String), + }), + ); + }, + ); + + it('should return null messageHash if version is null', () => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().with('version', null).build(); + const transaction = multisigTransactionBuilder() + .with('safe', safe.address) + .build(); + + const typedData = target.mapSafeTxTypedData({ + chainId, + safe, + transaction, + }); + + expect(typedData).toEqual( + expect.objectContaining({ + messageHash: null, + }), + ); + }); + + it.each([ + 'data' as const, + 'safeTxGas' as const, + 'baseGas' as const, + 'gasPrice' as const, + 'gasToken' as const, + 'refundReceiver' as const, + ])( + 'should return null messageHash if transaction %s is null', + (field) => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().with('version', null).build(); + const transaction = multisigTransactionBuilder() + .with('safe', safe.address) + .with(field, null) + .build(); + + const typedData = target.mapSafeTxTypedData({ + chainId, + safe, + transaction, + }); + + expect(typedData).toEqual( + expect.objectContaining({ + messageHash: null, + }), + ); + }, + ); + + it('should return null if message is invalid', () => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().build(); + const transaction = multisigTransactionBuilder() + .with('safe', safe.address) + // Invalid nonce + .with('nonce', 'invalid' as unknown as number) + .build(); + + const typedData = target.mapSafeTxTypedData({ + chainId, + safe, + transaction, + }); + + expect(mockLoggingService.error).toHaveBeenCalledTimes(1); + expect(mockLoggingService.error).toHaveBeenNthCalledWith( + 1, + `Failed to hash SafeTx for ${safe.address}`, + ); + expect(typedData).toEqual( + expect.objectContaining({ + messageHash: null, + }), + ); + }); + }); + }); + + describe('mapSafeMessageTypedData', () => { + describe('domainHash', () => { + it.each(DOMAIN_TYPEHASH_OLD_VERSIONS)( + 'should return domainHash for version %s', + (version) => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().with('version', version).build(); + const message = faker.lorem.words(); + + const typedData = target.mapSafeMessageTypedData({ + chainId, + safe, + message, + }); + + expect(typedData).toEqual( + expect.objectContaining({ + domainHash: keccak256( + encodeAbiParameters(parseAbiParameters('bytes32, address'), [ + DOMAIN_TYPEHASH_OLD, + getAddress(safe.address), + ]), + ), + }), + ); + }, + ); + + it.each(DOMAIN_TYPEHASH_NEW_VERSIONS)( + 'should return chainId-based domainHash for version %s', + (version) => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().with('version', version).build(); + const message = faker.lorem.words(); + + const typedData = target.mapSafeMessageTypedData({ + chainId, + safe, + message, + }); + + expect(typedData).toEqual( + expect.objectContaining({ + domainHash: keccak256( + encodeAbiParameters( + parseAbiParameters('bytes32, uint256, address'), + [ + DOMAIN_TYPEHASH_NEW, + BigInt(chainId), + getAddress(safe.address), + ], + ), + ), + }), + ); + }, + ); + + it('should return null domainHash if version is null', () => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().with('version', null).build(); + const message = faker.lorem.words(); + + const typedData = target.mapSafeMessageTypedData({ + chainId, + safe, + message, + }); + + expect(typedData).toEqual( + expect.objectContaining({ + domainHash: null, + }), + ); + }); + + it('should return null if domain is invalid', () => { + const chainId = faker.string.numeric(); + const safe = safeBuilder() + // Not a valid verifyingContract + .with('address', 'invalid' as `0x${string}`) + .build(); + const message = faker.lorem.words(); + + const typedData = target.mapSafeMessageTypedData({ + chainId, + safe, + message, + }); + + expect(mockLoggingService.error).toHaveBeenCalledTimes(1); + expect(mockLoggingService.error).toHaveBeenNthCalledWith( + 1, + `Failed to hash domain for ${safe.address}`, + ); + expect(typedData).toEqual( + expect.objectContaining({ + domainHash: null, + }), + ); + }); + }); + + describe('messageHash', () => { + it.each(SAFE_MESSAGE_TYPEHASH_VERSIONS)( + 'should return EIP-191 message-based messageHash for version %s', + (version) => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().with('version', version).build(); + const message = faker.lorem.words(); + + const typedData = target.mapSafeMessageTypedData({ + chainId, + safe, + message, + }); + + expect(typedData).toEqual( + expect.objectContaining({ + messageHash: keccak256( + encodeAbiParameters(parseAbiParameters('bytes32, bytes32'), [ + SAFE_MESSAGE_TYPEHASH, + // EIP-712 expects bytes to be hashed + keccak256(hashMessage(message)), + ]), + ), + }), + ); + }, + ); + + it.each(SAFE_MESSAGE_TYPEHASH_VERSIONS)( + 'should return EIP-712 message-based messageHash for version %s', + (version) => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().with('version', version).build(); + const message = { + domain: { + name: faker.company.name(), + version: faker.string.numeric(), + chainId: faker.number.int(), + verifyingContract: getAddress(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: getAddress(faker.finance.ethereumAddress()), + }, + to: { + name: faker.person.firstName(), + wallet: getAddress(faker.finance.ethereumAddress()), + }, + contents: faker.lorem.words(), + }, + } as const; + + const typedData = target.mapSafeMessageTypedData({ + chainId, + safe, + message, + }); + + expect(typedData).toEqual( + expect.objectContaining({ + messageHash: keccak256( + encodeAbiParameters(parseAbiParameters('bytes32, bytes32'), [ + SAFE_MESSAGE_TYPEHASH, + // EIP-712 expects bytes to be hashed + keccak256(hashTypedData(message)), + ]), + ), + }), + ); + }, + ); + + it.each([ + // Invalid message + faker.number.int(), // Primitive (EIP-191) (will call hashTypedData as not a string) + JSON.parse(fakeJson()), // EIP-712 + ])('should return null if message is invalid', (message) => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().build(); + + const typedData = target.mapSafeMessageTypedData({ + chainId, + safe, + message, + }); + + expect(mockLoggingService.error).toHaveBeenCalledTimes(1); + expect(mockLoggingService.error).toHaveBeenNthCalledWith( + 1, + `Failed to hash SafeMessage for ${safe.address}`, + ); + expect(typedData).toEqual( + expect.objectContaining({ messageHash: null }), + ); + }); + }); + }); + + it('these tests should cover up to the current version', () => { + const deployment = getSafeL2SingletonDeployment(); + + expect(DOMAIN_TYPEHASH_NEW_VERSIONS.at(-1)).toBe(deployment?.version); + expect(SAFE_TX_TYPEHASH_NEW_VERSIONS.at(-1)).toBe(deployment?.version); + expect(SAFE_MESSAGE_TYPEHASH_VERSIONS.at(-1)).toBe(deployment?.version); + }); +}); diff --git a/src/domain/common/mappers/typed-data.mapper.ts b/src/domain/common/mappers/typed-data.mapper.ts new file mode 100644 index 0000000000..7383ff1c3f --- /dev/null +++ b/src/domain/common/mappers/typed-data.mapper.ts @@ -0,0 +1,238 @@ +import { Inject, Injectable } from '@nestjs/common'; +import semverSatisfies from 'semver/functions/satisfies'; +import { + getTypesForEIP712Domain, + hashDomain, + hashMessage, + hashStruct, + hashTypedData, +} from 'viem'; +import type { TypedDataDefinition } from 'viem'; + +import { ILoggingService, LoggingService } from '@/logging/logging.interface'; +import { TypedData } from '@/routes/transactions/entities/typed-data/typed-data.entity'; +import type { MultisigTransaction } from '@/domain/safe/entities/multisig-transaction.entity'; +import type { Safe } from '@/domain/safe/entities/safe.entity'; + +@Injectable() +export class TypedDataMapper { + // Domain + private static readonly CHAIN_ID_DOMAIN_HASH_VERSION = '>=1.3.0'; + + // Message + private static readonly TRANSACTION_PRIMARY_TYPE = 'SafeTx'; + private static readonly MESSAGE_PRIMARY_TYPE = 'SafeMessage'; + private static readonly BASE_GAS_SAFETX_HASH_VERSION = '>=1.0.0'; + + constructor( + @Inject(LoggingService) private readonly loggingService: ILoggingService, + ) {} + + /** + * Calculates and maps hashes of domain and `SafeTx` for Safe transaction + * @param args.chainId - Chain ID + * @param args.safe - {@link Safe} entity + * @param args.transaction - {@link MultisigTransaction} entity + * @returns - {@link TypedData} containing hashes of domain/message + */ + public mapSafeTxTypedData(args: { + chainId: string; + safe: Safe; + transaction: MultisigTransaction; + }): TypedData { + return new TypedData({ + domainHash: this.getDomainHash(args), + messageHash: this.getSafeTxMessageHash(args), + }); + } + + /** + * Calculates and maps hashes of domain and `SafeMessage` of Safe message + * @param args.chainId - Chain ID + * @param args.safe - {@link Safe} entity + * @param args.message - Message string or {@link TypedDataDefinition} entity + * @returns - {@link TypedData} containing hashes of domain/message + */ + public mapSafeMessageTypedData(args: { + chainId: string; + safe: Safe; + message: string | TypedDataDefinition; + }): TypedData { + return new TypedData({ + domainHash: this.getDomainHash(args), + messageHash: this.getSafeMessageMessageHash(args), + }); + } + + /** + * Calculates domain hash for Safe: + * + * Note: if Safe version is available: + * - If Safe version <1.3.0, domain separator contains no `chainId` + * @see https://github.com/safe-global/safe-smart-account/blob/v1.2.0/contracts/GnosisSafe.sol#L23-L26 + * - If Safe version >=1.3.0, domain separator contains `chainId` + * @see https://github.com/safe-global/safe-smart-account/blob/v1.3.0/contracts/GnosisSafe.sol#L35-L38 + * + * @param args.chainId - Chain ID + * @param args.safe - {@link Safe} entity + * @returns - Domain hash or `null` if no version or hashing failed + */ + private getDomainHash(args: { + chainId: string; + safe: Safe; + }): `0x${string}` | null { + if (!args.safe.version) { + return null; + } + + // >=1.3.0 Safe contracts include the `chainId` in domain separator + const includesChainId = semverSatisfies( + args.safe.version, + TypedDataMapper.CHAIN_ID_DOMAIN_HASH_VERSION, + ); + const domain = { + ...(includesChainId && { chainId: Number(args.chainId) }), + verifyingContract: args.safe.address, + }; + + try { + return hashDomain({ + domain: { + chainId: Number(args.chainId), + verifyingContract: args.safe.address, + }, + types: { + EIP712Domain: getTypesForEIP712Domain({ domain }), + }, + }); + } catch { + this.loggingService.error( + `Failed to hash domain for ${args.safe.address}`, + ); + return null; + } + } + + /** + * Calculates and maps hash of `SafeTx` for Safe transaction + * + * Note: if Safe version is available: + * - If Safe version <1.0.0, `dataGas` is used in `SafeTx` hash + * @see https://github.com/safe-global/safe-smart-account/blob/v0.1.0/contracts/GnosisSafe.sol#L25-L28 + * - If Safe version >=1.0.0, `baseGas` is used in `SafeTx` hash + * @see https://github.com/safe-global/safe-smart-account/blob/v1.0.0/contracts/GnosisSafe.sol#L25-L28 + * + * @param args.chainId - Chain ID + * @param args.safe - {@link Safe} entity + * @param args.transaction - {@link MultisigTransaction} entity + * @returns - Hash of `SafeTx` or `null` if no version, missing transaction data or hashing failed + */ + private getSafeTxMessageHash(args: { + chainId: string; + safe: Safe; + transaction: MultisigTransaction; + }): `0x${string}` | null { + if (!args.safe.version) { + return null; + } + + const { + to, + value, + data, + operation, + safeTxGas, + baseGas, + gasPrice, + gasToken, + refundReceiver, + nonce, + } = args.transaction; + + if ( + data === null || + safeTxGas === null || + baseGas === null || + gasPrice === null || + gasToken === null || + refundReceiver === null + ) { + return null; + } + + // >=1.0.0 Safe contracts use `baseGas` instead of `dataGas` + const usesBaseGas = semverSatisfies( + args.safe.version, + TypedDataMapper.BASE_GAS_SAFETX_HASH_VERSION, + ); + const dataGasOrBaseGas = usesBaseGas ? 'baseGas' : 'dataGas'; + + try { + return hashStruct({ + primaryType: TypedDataMapper.TRANSACTION_PRIMARY_TYPE, + data: { + to, + value, + data, + operation, + safeTxGas, + [dataGasOrBaseGas]: baseGas, + gasPrice, + gasToken, + refundReceiver, + nonce, + }, + types: { + SafeTx: [ + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'data', type: 'bytes' }, + { name: 'operation', type: 'uint8' }, + { name: 'safeTxGas', type: 'uint256' }, + { name: dataGasOrBaseGas, type: 'uint256' }, + { name: 'gasPrice', type: 'uint256' }, + { name: 'gasToken', type: 'address' }, + { name: 'refundReceiver', type: 'address' }, + { name: 'nonce', type: 'uint256' }, + ], + }, + }); + } catch { + this.loggingService.error( + `Failed to hash SafeTx for ${args.safe.address}`, + ); + return null; + } + } + + /** + * Calculates and maps hash of `SafeMessage` for Safe message + * @param args.safe - {@link Safe} entity + * @param args.message - Message string or {@link TypedDataDefinition} entity + * @returns - Hash of `SafeMessage` or `null` if hashing failed + */ + private getSafeMessageMessageHash(args: { + safe: Safe; + message: string | TypedDataDefinition; + }): `0x${string}` | null { + try { + return hashStruct({ + primaryType: TypedDataMapper.MESSAGE_PRIMARY_TYPE, + data: { + message: + typeof args.message === 'string' + ? hashMessage(args.message) + : hashTypedData(args.message), + }, + types: { + SafeMessage: [{ name: 'message', type: 'bytes' }], + }, + }); + } catch { + this.loggingService.error( + `Failed to hash SafeMessage for ${args.safe.address}`, + ); + return null; + } + } +} diff --git a/src/routes/messages/entities/message.entity.ts b/src/routes/messages/entities/message.entity.ts index 647b6a6678..ba237169e0 100644 --- a/src/routes/messages/entities/message.entity.ts +++ b/src/routes/messages/entities/message.entity.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { AddressInfo } from '@/routes/common/entities/address-info.entity'; import { MessageConfirmation } from '@/routes/messages/entities/message-confirmation.entity'; +import { TypedData } from '@/routes/transactions/entities/typed-data/typed-data.entity'; export enum MessageStatus { NeedsConfirmation = 'NEEDS_CONFIRMATION', @@ -34,6 +35,8 @@ export class Message { preparedSignature: `0x${string}` | null; @ApiPropertyOptional({ type: String, nullable: true }) origin: string | null; + @ApiProperty({ type: TypedData }) + typedData: TypedData; constructor( messageHash: `0x${string}`, @@ -49,6 +52,7 @@ export class Message { confirmations: MessageConfirmation[], preparedSignature: `0x${string}` | null, origin: string | null, + typedData: TypedData, ) { this.messageHash = messageHash; this.status = status; @@ -63,5 +67,6 @@ export class Message { this.confirmations = confirmations; this.preparedSignature = preparedSignature; this.origin = origin; + this.typedData = typedData; } } diff --git a/src/routes/messages/mappers/message-mapper.ts b/src/routes/messages/mappers/message-mapper.ts index 1342411e07..8de44be96f 100644 --- a/src/routes/messages/mappers/message-mapper.ts +++ b/src/routes/messages/mappers/message-mapper.ts @@ -11,6 +11,8 @@ import { MessageStatus, Message, } from '@/routes/messages/entities/message.entity'; +import { TypedDataMapper } from '@/domain/common/mappers/typed-data.mapper'; +import type { TypedDataDefinition } from 'viem'; @Injectable() export class MessageMapper { @@ -18,6 +20,8 @@ export class MessageMapper { @Inject(ISafeAppsRepository) private readonly safeAppsRepository: SafeAppsRepository, private readonly addressInfoHelper: AddressInfoHelper, + @Inject(TypedDataMapper) + private readonly typedDataMapper: TypedDataMapper, ) {} async mapMessageItems( @@ -42,6 +46,7 @@ export class MessageMapper { message.confirmations, message.preparedSignature, message.origin, + message.typedData, ); }), ); @@ -72,6 +77,11 @@ export class MessageMapper { message.preparedSignature && status === MessageStatus.Confirmed ? message.preparedSignature : null; + const typedData = this.typedDataMapper.mapSafeMessageTypedData({ + chainId, + safe, + message: message.message as string | TypedDataDefinition, + }); return new Message( message.messageHash, @@ -87,6 +97,7 @@ export class MessageMapper { confirmations, preparedSignature, message.origin, + typedData, ); } diff --git a/src/routes/messages/messages.controller.spec.ts b/src/routes/messages/messages.controller.spec.ts index d571ffccd6..f5b34d3ed1 100644 --- a/src/routes/messages/messages.controller.spec.ts +++ b/src/routes/messages/messages.controller.spec.ts @@ -115,31 +115,37 @@ describe('Messages controller', () => { await request(app.getHttpServer()) .get(`/v1/chains/${chain.chainId}/messages/${message.messageHash}`) .expect(200) - .expect({ - messageHash: message.messageHash, - status: MessageStatus.Confirmed, - logoUri: null, - name: null, - message: message.message, - creationTimestamp: message.created.getTime(), - modifiedTimestamp: message.modified.getTime(), - confirmationsSubmitted: messageConfirmations.length, - confirmationsRequired: safe.threshold, - proposedBy: { - value: message.proposedBy, - name: null, + .expect(({ body }) => { + expect(body).toEqual({ + messageHash: message.messageHash, + status: MessageStatus.Confirmed, logoUri: null, - }, - confirmations: messageConfirmations.map((confirmation) => ({ - owner: { - value: confirmation.owner, + name: null, + message: message.message, + creationTimestamp: message.created.getTime(), + modifiedTimestamp: message.modified.getTime(), + confirmationsSubmitted: messageConfirmations.length, + confirmationsRequired: safe.threshold, + proposedBy: { + value: message.proposedBy, name: null, logoUri: null, }, - signature: confirmation.signature, - })), - preparedSignature: message.preparedSignature, - origin: message.origin, + confirmations: messageConfirmations.map((confirmation) => ({ + owner: { + value: confirmation.owner, + name: null, + logoUri: null, + }, + signature: confirmation.signature, + })), + preparedSignature: message.preparedSignature, + origin: message.origin, + typedData: { + domainHash: expect.any(String), + messageHash: expect.any(String), + }, + }); }); }); @@ -183,31 +189,37 @@ describe('Messages controller', () => { await request(app.getHttpServer()) .get(`/v1/chains/${chain.chainId}/messages/${message.messageHash}`) .expect(200) - .expect({ - messageHash: message.messageHash, - status: MessageStatus.Confirmed, - logoUri: safeApps[1].iconUrl, - name: safeApps[1].name, - message: message.message, - creationTimestamp: message.created.getTime(), - modifiedTimestamp: message.modified.getTime(), - confirmationsSubmitted: messageConfirmations.length, - confirmationsRequired: safe.threshold, - proposedBy: { - value: message.proposedBy, - name: null, - logoUri: null, - }, - confirmations: messageConfirmations.map((confirmation) => ({ - owner: { - value: confirmation.owner, + .expect(({ body }) => { + expect(body).toEqual({ + messageHash: message.messageHash, + status: MessageStatus.Confirmed, + logoUri: safeApps[1].iconUrl, + name: safeApps[1].name, + message: message.message, + creationTimestamp: message.created.getTime(), + modifiedTimestamp: message.modified.getTime(), + confirmationsSubmitted: messageConfirmations.length, + confirmationsRequired: safe.threshold, + proposedBy: { + value: message.proposedBy, name: null, logoUri: null, }, - signature: confirmation.signature, - })), - preparedSignature: message.preparedSignature, - origin: message.origin, + confirmations: messageConfirmations.map((confirmation) => ({ + owner: { + value: confirmation.owner, + name: null, + logoUri: null, + }, + signature: confirmation.signature, + })), + preparedSignature: message.preparedSignature, + origin: message.origin, + typedData: { + domainHash: expect.any(String), + messageHash: expect.any(String), + }, + }); }); }); @@ -248,31 +260,37 @@ describe('Messages controller', () => { await request(app.getHttpServer()) .get(`/v1/chains/${chain.chainId}/messages/${message.messageHash}`) .expect(200) - .expect({ - messageHash: message.messageHash, - status: MessageStatus.NeedsConfirmation, - logoUri: null, - name: null, - message: message.message, - creationTimestamp: message.created.getTime(), - modifiedTimestamp: message.modified.getTime(), - confirmationsSubmitted: messageConfirmations.length, - confirmationsRequired: safe.threshold, - proposedBy: { - value: message.proposedBy, - name: null, + .expect(({ body }) => { + expect(body).toEqual({ + messageHash: message.messageHash, + status: MessageStatus.NeedsConfirmation, logoUri: null, - }, - confirmations: messageConfirmations.map((confirmation) => ({ - owner: { - value: confirmation.owner, + name: null, + message: message.message, + creationTimestamp: message.created.getTime(), + modifiedTimestamp: message.modified.getTime(), + confirmationsSubmitted: messageConfirmations.length, + confirmationsRequired: safe.threshold, + proposedBy: { + value: message.proposedBy, name: null, logoUri: null, }, - signature: confirmation.signature, - })), - preparedSignature: null, - origin: message.origin, + confirmations: messageConfirmations.map((confirmation) => ({ + owner: { + value: confirmation.owner, + name: null, + logoUri: null, + }, + signature: confirmation.signature, + })), + preparedSignature: null, + origin: message.origin, + typedData: { + domainHash: expect.any(String), + messageHash: expect.any(String), + }, + }); }); }); @@ -316,31 +334,37 @@ describe('Messages controller', () => { await request(app.getHttpServer()) .get(`/v1/chains/${chain.chainId}/messages/${message.messageHash}`) .expect(200) - .expect({ - messageHash: message.messageHash, - status: MessageStatus.NeedsConfirmation, - logoUri: safeApps[2].iconUrl, - name: safeApps[2].name, - message: message.message, - creationTimestamp: message.created.getTime(), - modifiedTimestamp: message.modified.getTime(), - confirmationsSubmitted: messageConfirmations.length, - confirmationsRequired: safe.threshold, - proposedBy: { - value: message.proposedBy, - name: null, - logoUri: null, - }, - confirmations: messageConfirmations.map((confirmation) => ({ - owner: { - value: confirmation.owner, + .expect(({ body }) => { + expect(body).toEqual({ + messageHash: message.messageHash, + status: MessageStatus.NeedsConfirmation, + logoUri: safeApps[2].iconUrl, + name: safeApps[2].name, + message: message.message, + creationTimestamp: message.created.getTime(), + modifiedTimestamp: message.modified.getTime(), + confirmationsSubmitted: messageConfirmations.length, + confirmationsRequired: safe.threshold, + proposedBy: { + value: message.proposedBy, name: null, logoUri: null, }, - signature: confirmation.signature, - })), - preparedSignature: null, - origin: message.origin, + confirmations: messageConfirmations.map((confirmation) => ({ + owner: { + value: confirmation.owner, + name: null, + logoUri: null, + }, + signature: confirmation.signature, + })), + preparedSignature: null, + origin: message.origin, + typedData: { + domainHash: expect.any(String), + messageHash: expect.any(String), + }, + }); }); }); @@ -381,31 +405,37 @@ describe('Messages controller', () => { await request(app.getHttpServer()) .get(`/v1/chains/${chain.chainId}/messages/${message.messageHash}`) .expect(200) - .expect({ - messageHash: message.messageHash, - status: MessageStatus.NeedsConfirmation, - logoUri: null, - name: null, - message: message.message, - creationTimestamp: message.created.getTime(), - modifiedTimestamp: message.modified.getTime(), - confirmationsSubmitted: messageConfirmations.length, - confirmationsRequired: safe.threshold, - proposedBy: { - value: message.proposedBy, - name: null, + .expect(({ body }) => { + expect(body).toEqual({ + messageHash: message.messageHash, + status: MessageStatus.NeedsConfirmation, logoUri: null, - }, - confirmations: messageConfirmations.map((confirmation) => ({ - owner: { - value: confirmation.owner, + name: null, + message: message.message, + creationTimestamp: message.created.getTime(), + modifiedTimestamp: message.modified.getTime(), + confirmationsSubmitted: messageConfirmations.length, + confirmationsRequired: safe.threshold, + proposedBy: { + value: message.proposedBy, name: null, logoUri: null, }, - signature: confirmation.signature, - })), - preparedSignature: null, - origin: message.origin, + confirmations: messageConfirmations.map((confirmation) => ({ + owner: { + value: confirmation.owner, + name: null, + logoUri: null, + }, + signature: confirmation.signature, + })), + preparedSignature: null, + origin: message.origin, + typedData: { + domainHash: expect.any(String), + messageHash: expect.any(String), + }, + }); }); }); @@ -444,31 +474,37 @@ describe('Messages controller', () => { await request(app.getHttpServer()) .get(`/v1/chains/${chain.chainId}/messages/${message.messageHash}`) .expect(200) - .expect({ - messageHash: message.messageHash, - status: MessageStatus.NeedsConfirmation, - logoUri: null, - name: null, - message: message.message, - creationTimestamp: message.created.getTime(), - modifiedTimestamp: message.modified.getTime(), - confirmationsSubmitted: messageConfirmations.length, - confirmationsRequired: safe.threshold, - proposedBy: { - value: message.proposedBy, - name: null, + .expect(({ body }) => { + expect(body).toEqual({ + messageHash: message.messageHash, + status: MessageStatus.NeedsConfirmation, logoUri: null, - }, - confirmations: messageConfirmations.map((confirmation) => ({ - owner: { - value: confirmation.owner, + name: null, + message: message.message, + creationTimestamp: message.created.getTime(), + modifiedTimestamp: message.modified.getTime(), + confirmationsSubmitted: messageConfirmations.length, + confirmationsRequired: safe.threshold, + proposedBy: { + value: message.proposedBy, name: null, logoUri: null, }, - signature: confirmation.signature, - })), - preparedSignature: null, - origin: message.origin, + confirmations: messageConfirmations.map((confirmation) => ({ + owner: { + value: confirmation.owner, + name: null, + logoUri: null, + }, + signature: confirmation.signature, + })), + preparedSignature: null, + origin: message.origin, + typedData: { + domainHash: expect.any(String), + messageHash: expect.any(String), + }, + }); }); }); }); @@ -582,6 +618,10 @@ describe('Messages controller', () => { })), preparedSignature: null, origin: message.origin, + typedData: { + domainHash: expect.any(String), + messageHash: expect.any(String), + }, }, ]) .build(), diff --git a/src/routes/messages/messages.module.ts b/src/routes/messages/messages.module.ts index ed11994765..0c957b9bd4 100644 --- a/src/routes/messages/messages.module.ts +++ b/src/routes/messages/messages.module.ts @@ -6,6 +6,7 @@ import { MessagesService } from '@/routes/messages/messages.service'; import { SafeRepositoryModule } from '@/domain/safe/safe.repository.interface'; import { MessagesRepositoryModule } from '@/domain/messages/messages.repository.interface'; import { SafeAppsRepositoryModule } from '@/domain/safe-apps/safe-apps.repository.interface'; +import { TypedDataMapper } from '@/domain/common/mappers/typed-data.mapper'; @Module({ imports: [ @@ -15,6 +16,6 @@ import { SafeAppsRepositoryModule } from '@/domain/safe-apps/safe-apps.repositor SafeRepositoryModule, ], controllers: [MessagesController], - providers: [MessagesService, MessageMapper], + providers: [MessagesService, MessageMapper, TypedDataMapper], }) export class MessagesModule {} diff --git a/src/routes/transactions/entities/transaction-details/multisig-execution-details.entity.ts b/src/routes/transactions/entities/transaction-details/multisig-execution-details.entity.ts index a4eb808e5e..af2b88726e 100644 --- a/src/routes/transactions/entities/transaction-details/multisig-execution-details.entity.ts +++ b/src/routes/transactions/entities/transaction-details/multisig-execution-details.entity.ts @@ -5,6 +5,7 @@ import { ExecutionDetails, ExecutionDetailsType, } from '@/routes/transactions/entities/transaction-details/execution-details.entity'; +import { TypedData } from '@/routes/transactions/entities/typed-data/typed-data.entity'; export class MultisigConfirmationDetails { @ApiProperty() @@ -62,6 +63,8 @@ export class MultisigExecutionDetails extends ExecutionDetails { proposer!: AddressInfo | null; @ApiPropertyOptional({ type: AddressInfo, nullable: true }) proposedByDelegate!: AddressInfo | null; + @ApiProperty({ type: TypedData }) + typedData: TypedData; constructor( submittedAt: number, @@ -81,6 +84,7 @@ export class MultisigExecutionDetails extends ExecutionDetails { trusted: boolean, proposer: AddressInfo | null, proposedByDelegate: AddressInfo | null, + typedData: TypedData, ) { super(ExecutionDetailsType.Multisig); this.submittedAt = submittedAt; @@ -100,5 +104,6 @@ export class MultisigExecutionDetails extends ExecutionDetails { this.trusted = trusted; this.proposer = proposer; this.proposedByDelegate = proposedByDelegate; + this.typedData = typedData; } } diff --git a/src/routes/transactions/entities/typed-data/typed-data.entity.ts b/src/routes/transactions/entities/typed-data/typed-data.entity.ts new file mode 100644 index 0000000000..ae8ae12ba2 --- /dev/null +++ b/src/routes/transactions/entities/typed-data/typed-data.entity.ts @@ -0,0 +1,17 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class TypedData { + @ApiPropertyOptional({ nullable: true }) + domainHash: `0x${string}` | null; + + @ApiPropertyOptional({ nullable: true }) + messageHash: `0x${string}` | null; + + constructor(args: { + domainHash: `0x${string}` | null; + messageHash: `0x${string}` | null; + }) { + this.domainHash = args.domainHash; + this.messageHash = args.messageHash; + } +} diff --git a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-details.mapper.spec.ts b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-details.mapper.spec.ts index ba998e776f..e505a42cf0 100644 --- a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-details.mapper.spec.ts +++ b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-details.mapper.spec.ts @@ -15,6 +15,7 @@ import { AddressInfo } from '@/routes/common/entities/address-info.entity'; import { MultisigConfirmationDetails } from '@/routes/transactions/entities/transaction-details/multisig-execution-details.entity'; import { MultisigTransactionExecutionDetailsMapper } from '@/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-details.mapper'; import { getAddress } from 'viem'; +import { TypedDataMapper } from '@/domain/common/mappers/typed-data.mapper'; const addressInfoHelper = jest.mocked({ getOrDefault: jest.fn(), @@ -29,6 +30,7 @@ const safeRepository = jest.mocked({ } as jest.MockedObjectDeep); const loggingService = jest.mocked({ + error: jest.fn(), debug: jest.fn(), } as jest.MockedObjectDeep); @@ -37,11 +39,13 @@ describe('MultisigTransactionExecutionDetails mapper (Unit)', () => { beforeEach(() => { jest.resetAllMocks(); + const safeTypedDataHelper = new TypedDataMapper(loggingService); mapper = new MultisigTransactionExecutionDetailsMapper( addressInfoHelper, tokenRepository, safeRepository, loggingService, + safeTypedDataHelper, ); }); @@ -86,6 +90,10 @@ describe('MultisigTransactionExecutionDetails mapper (Unit)', () => { trusted: transaction.trusted, proposer: new AddressInfo(transaction.proposer!), proposedByDelegate: null, + typedData: { + domainHash: expect.any(String), + messageHash: expect.any(String), + }, }), ); }); @@ -151,6 +159,10 @@ describe('MultisigTransactionExecutionDetails mapper (Unit)', () => { trusted: transaction.trusted, proposer: new AddressInfo(transaction.proposer!), proposedByDelegate: null, + typedData: { + domainHash: expect.any(String), + messageHash: expect.any(String), + }, }), ); }); @@ -218,6 +230,10 @@ describe('MultisigTransactionExecutionDetails mapper (Unit)', () => { trusted: transaction.trusted, proposer: new AddressInfo(transaction.proposer!), proposedByDelegate: null, + typedData: { + domainHash: expect.any(String), + messageHash: expect.any(String), + }, }), ); }); diff --git a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-details.mapper.ts b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-details.mapper.ts index eaf2b43023..e0b6bff87b 100644 --- a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-details.mapper.ts +++ b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-details.mapper.ts @@ -13,6 +13,7 @@ import { MultisigConfirmationDetails, MultisigExecutionDetails, } from '@/routes/transactions/entities/transaction-details/multisig-execution-details.entity'; +import { TypedDataMapper } from '@/domain/common/mappers/typed-data.mapper'; @Injectable() export class MultisigTransactionExecutionDetailsMapper { @@ -21,6 +22,8 @@ export class MultisigTransactionExecutionDetailsMapper { @Inject(ITokenRepository) private readonly tokenRepository: TokenRepository, @Inject(ISafeRepository) private readonly safeRepository: SafeRepository, @Inject(LoggingService) private readonly loggingService: ILoggingService, + @Inject(TypedDataMapper) + private readonly typedDataMapper: TypedDataMapper, ) {} async mapMultisigExecutionDetails( @@ -46,6 +49,11 @@ export class MultisigTransactionExecutionDetailsMapper { const proposedByDelegate = transaction.proposedByDelegate ? new AddressInfo(transaction.proposedByDelegate) : null; + const typedData = this.typedDataMapper.mapSafeTxTypedData({ + chainId, + safe, + transaction, + }); const [gasTokenInfo, executor, refundReceiver, rejectors] = await Promise.all([ @@ -83,6 +91,7 @@ export class MultisigTransactionExecutionDetailsMapper { transaction.trusted, proposer, proposedByDelegate, + typedData, ); } diff --git a/src/routes/transactions/transactions.module.ts b/src/routes/transactions/transactions.module.ts index c99ec524d7..95f77cb5b7 100644 --- a/src/routes/transactions/transactions.module.ts +++ b/src/routes/transactions/transactions.module.ts @@ -1,5 +1,6 @@ import { ChainsRepositoryModule } from '@/domain/chains/chains.repository.interface'; import { ContractsRepositoryModule } from '@/domain/contracts/contracts.repository.interface'; +import { TypedDataMapper } from '@/domain/common/mappers/typed-data.mapper'; import { DataDecodedRepositoryModule } from '@/domain/data-decoder/data-decoded.repository.interface'; import { HumanDescriptionRepositoryModule } from '@/domain/human-description/human-description.repository.interface'; import { SafeAppsRepositoryModule } from '@/domain/safe-apps/safe-apps.repository.interface'; @@ -92,6 +93,7 @@ import { Module } from '@nestjs/common'; SafeAppInfoMapper, SettingsChangeMapper, SwapTransferInfoMapper, + TypedDataMapper, TransactionDataMapper, TransactionPreviewMapper, TransactionsHistoryMapper,