Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Map domainHash/messageHash to MULTISIG transactions and messages #2180

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
632 changes: 632 additions & 0 deletions src/domain/common/mappers/typed-data.mapper.spec.ts

Large diffs are not rendered by default.

238 changes: 238 additions & 0 deletions src/domain/common/mappers/typed-data.mapper.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
5 changes: 5 additions & 0 deletions src/routes/messages/entities/message.entity.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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}`,
Expand All @@ -49,6 +52,7 @@ export class Message {
confirmations: MessageConfirmation[],
preparedSignature: `0x${string}` | null,
origin: string | null,
typedData: TypedData,
) {
this.messageHash = messageHash;
this.status = status;
Expand All @@ -63,5 +67,6 @@ export class Message {
this.confirmations = confirmations;
this.preparedSignature = preparedSignature;
this.origin = origin;
this.typedData = typedData;
}
}
11 changes: 11 additions & 0 deletions src/routes/messages/mappers/message-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,17 @@ 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 {
constructor(
@Inject(ISafeAppsRepository)
private readonly safeAppsRepository: SafeAppsRepository,
private readonly addressInfoHelper: AddressInfoHelper,
@Inject(TypedDataMapper)
private readonly typedDataMapper: TypedDataMapper,
) {}

async mapMessageItems(
Expand All @@ -42,6 +46,7 @@ export class MessageMapper {
message.confirmations,
message.preparedSignature,
message.origin,
message.typedData,
);
}),
);
Expand Down Expand Up @@ -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,
Expand All @@ -87,6 +97,7 @@ export class MessageMapper {
confirmations,
preparedSignature,
message.origin,
typedData,
);
}

Expand Down
Loading
Loading