diff --git a/src/domain.module.ts b/src/domain.module.ts index 3c2e72ee7b..931c9c384c 100644 --- a/src/domain.module.ts +++ b/src/domain.module.ts @@ -69,6 +69,7 @@ import { IAlertsRepository } from '@/domain/alerts/alerts.repository.interface'; import { AlertsRepository } from '@/domain/alerts/alerts.repository'; import { DelayModifierDecoder } from '@/domain/alerts/contracts/delay-modifier-decoder.helper'; import { SafeDecoder } from '@/domain/alerts/contracts/safe-decoder.helper'; +import { MultiSendDecoder } from '@/domain/alerts/contracts/multi-send-decoder.helper'; @Global() @Module({ @@ -120,6 +121,7 @@ import { SafeDecoder } from '@/domain/alerts/contracts/safe-decoder.helper'; MasterCopyValidator, MessageValidator, ModuleTransactionValidator, + MultiSendDecoder, MultisigTransactionValidator, SafeAppsValidator, SafeDecoder, diff --git a/src/domain/alerts/__tests__/multisend-transactions.encoder.ts b/src/domain/alerts/__tests__/multisend-transactions.encoder.ts new file mode 100644 index 0000000000..3f14e7d914 --- /dev/null +++ b/src/domain/alerts/__tests__/multisend-transactions.encoder.ts @@ -0,0 +1,34 @@ +import { + Hex, + concat, + encodeFunctionData, + encodePacked, + getAddress, + parseAbi, + size, +} from 'viem'; + +export function multiSendEncoder( + transactions: Array<{ + operation: number; + to: string; + value: bigint; + data: Hex; + }>, +): Hex { + const abi = parseAbi(['function multiSend(bytes memory transactions)']); + + const encodedTransactions = transactions.map( + ({ operation, to, value, data }) => + encodePacked( + ['uint8', 'address', 'uint256', 'uint256', 'bytes'], + [operation, getAddress(to), value, BigInt(size(data)), data], + ), + ); + + return encodeFunctionData({ + abi, + functionName: 'multiSend', + args: [concat(encodedTransactions)], + }); +} diff --git a/src/domain/alerts/__tests__/safe-transactions.encoder.ts b/src/domain/alerts/__tests__/safe-transactions.encoder.ts new file mode 100644 index 0000000000..0b7c47fc51 --- /dev/null +++ b/src/domain/alerts/__tests__/safe-transactions.encoder.ts @@ -0,0 +1,95 @@ +import { faker } from '@faker-js/faker'; +import { parseAbi, encodeFunctionData, getAddress, Hex } from 'viem'; + +export function addOwnerWithThresholdEncoder( + { + owner = faker.finance.ethereumAddress(), + _threshold = faker.number.bigInt(), + }: { + owner: string; + _threshold: bigint; + } = { + owner: faker.finance.ethereumAddress(), + _threshold: faker.number.bigInt(), + }, +): Hex { + const abi = parseAbi([ + 'function addOwnerWithThreshold(address owner, uint256 _threshold)', + ]); + + return encodeFunctionData({ + abi, + functionName: 'addOwnerWithThreshold', + args: [getAddress(owner), _threshold], + }); +} + +export function removeOwnerEncoder( + { + prevOwner = faker.finance.ethereumAddress(), + owner = faker.finance.ethereumAddress(), + _threshold = faker.number.bigInt(), + }: { + prevOwner: string; + owner: string; + _threshold: bigint; + } = { + prevOwner: faker.finance.ethereumAddress(), + owner: faker.finance.ethereumAddress(), + _threshold: faker.number.bigInt(), + }, +): Hex { + const ABI = parseAbi([ + 'function removeOwner(address prevOwner, address owner, uint256 _threshold)', + ]); + + return encodeFunctionData({ + abi: ABI, + functionName: 'removeOwner', + args: [getAddress(prevOwner), getAddress(owner), _threshold], + }); +} + +export function swapOwnerEncoder( + { + prevOwner = faker.finance.ethereumAddress(), + oldOwner = faker.finance.ethereumAddress(), + newOwner = faker.finance.ethereumAddress(), + }: { + prevOwner: string; + oldOwner: string; + newOwner: string; + } = { + prevOwner: faker.finance.ethereumAddress(), + oldOwner: faker.finance.ethereumAddress(), + newOwner: faker.finance.ethereumAddress(), + }, +): Hex { + const ABI = parseAbi([ + 'function swapOwner(address prevOwner, address oldOwner, address newOwner)', + ]); + + return encodeFunctionData({ + abi: ABI, + functionName: 'swapOwner', + args: [getAddress(prevOwner), getAddress(oldOwner), getAddress(newOwner)], + }); +} + +export function changeThresholdEncoder( + { + _threshold = faker.number.bigInt(), + }: { + _threshold: bigint; + } = { + _threshold: faker.number.bigInt(), + }, +): Hex { + const ABI = parseAbi(['function changeThreshold(uint256 _threshold)']); + + return encodeFunctionData({ + abi: ABI, + functionName: 'changeThreshold', + args: [_threshold], + }); +} diff --git a/src/domain/alerts/alerts.repository.ts b/src/domain/alerts/alerts.repository.ts index 4fb66cf0db..268360f260 100644 --- a/src/domain/alerts/alerts.repository.ts +++ b/src/domain/alerts/alerts.repository.ts @@ -4,12 +4,14 @@ import { IAlertsRepository } from '@/domain/alerts/alerts.repository.interface'; import { AlertLog } from '@/routes/alerts/entities/alert.dto.entity'; import { DelayModifierDecoder } from '@/domain/alerts/contracts/delay-modifier-decoder.helper'; import { SafeDecoder } from '@/domain/alerts/contracts/safe-decoder.helper'; +import { MultiSendDecoder } from '@/domain/alerts/contracts/multi-send-decoder.helper'; @Injectable() export class AlertsRepository implements IAlertsRepository { constructor( private readonly delayModifierDecoder: DelayModifierDecoder, private readonly safeDecoder: SafeDecoder, + private readonly multiSendDecoder: MultiSendDecoder, ) {} handleAlertLog(log: AlertLog): void { @@ -18,24 +20,55 @@ export class AlertsRepository implements IAlertsRepository { topics: log.topics as [Hex, Hex, Hex], }); - const decodedTransaction = this.decodeTransactionAdded( + const decodedTransactions = this.decodeTransactionAdded( decodedEvent.args.data, ); - if (decodedTransaction?.functionName !== 'addOwnerWithThreshold') { + if (!decodedTransactions) { // Transaction outside of specified ABI => notify user - } else { - // addOwnerWithThreshold transaction => notify user - // const safeAddress = decodedEvent.args.to; - // const [owner, _threshold] = decodedTransaction.args; + return; + } + + for (const decodedTransaction of decodedTransactions) { + switch (decodedTransaction.functionName) { + case 'addOwnerWithThreshold': { + // const safeAddress = decodedEvent.args.to; + // const [owner, _threshold] = decodedTransaction.args; + break; + } + case 'removeOwner': { + // const safeAddress = decodedEvent.args.to; + // const [prevOwner, owner, _threshold] = decodedTransaction.args; + break; + } + case 'swapOwner': { + // const safeAddress = decodedEvent.args.to; + // const [prevOwner, oldOwner, newOwner] = decodedTransaction.args; + break; + } + case 'changeThreshold': { + // const safeAddress = decodedEvent.args.to; + // const [_threshold] = decodedTransaction.args; + break; + } + default: { + // Transaction outside of specified ABI => notify user + } + } } } private decodeTransactionAdded(data: Hex) { try { - return this.safeDecoder.decodeFunctionData({ data }); + return this.multiSendDecoder + .mapMultiSendTransactions(data) + .map(this.safeDecoder.decodeFunctionData); } catch { - return null; + try { + return [this.safeDecoder.decodeFunctionData({ data })]; + } catch { + return null; + } } } } diff --git a/src/domain/alerts/contracts/multi-send-decoder.helper.spec.ts b/src/domain/alerts/contracts/multi-send-decoder.helper.spec.ts new file mode 100644 index 0000000000..473098a5df --- /dev/null +++ b/src/domain/alerts/contracts/multi-send-decoder.helper.spec.ts @@ -0,0 +1,40 @@ +import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; +import { MultiSendDecoder } from '@/domain/alerts/contracts/multi-send-decoder.helper'; +import { + addOwnerWithThresholdEncoder, + changeThresholdEncoder, + removeOwnerEncoder, + swapOwnerEncoder, +} from '@/domain/alerts/__tests__/safe-transactions.encoder'; +import { multiSendEncoder } from '@/domain/alerts/__tests__/multisend-transactions.encoder'; + +describe('MultiSendDecoder', () => { + let mapper: MultiSendDecoder; + + beforeEach(() => { + mapper = new MultiSendDecoder(); + }); + + describe('mapMultiSendTransactions', () => { + it('maps multiSend transactions correctly', () => { + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const transactions = [ + addOwnerWithThresholdEncoder(), + removeOwnerEncoder(), + swapOwnerEncoder(), + changeThresholdEncoder(), + ].map((data) => ({ + operation: faker.number.int({ min: 0, max: 1 }), + data, + // Normally static (0/0) but more robust if we generate random values + to: safeAddress, + value: faker.number.bigInt(), + })); + + const data = multiSendEncoder(transactions); + + expect(mapper.mapMultiSendTransactions(data)).toStrictEqual(transactions); + }); + }); +}); diff --git a/src/domain/alerts/contracts/multi-send-decoder.helper.ts b/src/domain/alerts/contracts/multi-send-decoder.helper.ts new file mode 100644 index 0000000000..2febb3b5ce --- /dev/null +++ b/src/domain/alerts/contracts/multi-send-decoder.helper.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@nestjs/common'; +import { + decodeFunctionData, + getAddress, + Hex, + hexToBigInt, + hexToNumber, + parseAbi, + size, + slice, +} from 'viem'; + +@Injectable() +export class MultiSendDecoder { + private readonly abi = parseAbi([ + 'function multiSend(bytes memory transactions)', + ]); + + // uint8 operation, address to, value uint256, dataLength uint256, bytes data + private static readonly OPERATION_SIZE = 1; + private static readonly TO_SIZE = 20; + private static readonly VALUE_SIZE = 32; + private static readonly DATA_LENGTH_SIZE = 32; + + mapMultiSendTransactions(multiSendData: Hex): Array<{ + operation: number; + to: string; + value: bigint; + data: Hex; + }> { + const mapped: Array<{ + operation: number; + to: string; + value: bigint; + data: Hex; + }> = []; + + const multiSend = decodeFunctionData({ + abi: this.abi, + data: multiSendData, + }); + + const transactions = multiSend.args[0]; + const transactionsSize = size(transactions); + + let cursor = 0; + + while (cursor < transactionsSize) { + const operation = slice( + transactions, + cursor, + (cursor += MultiSendDecoder.OPERATION_SIZE), + ); + + const to = slice( + transactions, + cursor, + (cursor += MultiSendDecoder.TO_SIZE), + ); + + const value = slice( + transactions, + cursor, + (cursor += MultiSendDecoder.VALUE_SIZE), + ); + + const dataLength = slice( + transactions, + cursor, + (cursor += MultiSendDecoder.DATA_LENGTH_SIZE), + ); + + const data = slice( + transactions, + cursor, + (cursor += hexToNumber(dataLength)), + ); + + mapped.push({ + operation: hexToNumber(operation), + to: getAddress(to), + value: hexToBigInt(value), + data, + }); + } + + return mapped; + } +} diff --git a/src/domain/alerts/contracts/safe-decoder.helper.ts b/src/domain/alerts/contracts/safe-decoder.helper.ts index c61d62dfac..7ab4741270 100644 --- a/src/domain/alerts/contracts/safe-decoder.helper.ts +++ b/src/domain/alerts/contracts/safe-decoder.helper.ts @@ -2,15 +2,16 @@ import { Injectable } from '@nestjs/common'; import { parseAbi } from 'viem'; import { AbiDecoder } from '@/domain/alerts/contracts/abi-decoder.helper'; -const ADD_OWNER_WITH_THRESHOLD_ABI = parseAbi([ +const OWNER_MANAGER_ABI = parseAbi([ 'function addOwnerWithThreshold(address owner, uint256 _threshold)', + 'function removeOwner(address prevOwner, address owner, uint256 _threshold)', + 'function swapOwner(address prevOwner, address oldOwner, address newOwner)', + 'function changeThreshold(uint256 _threshold)', ]); @Injectable() -export class SafeDecoder extends AbiDecoder< - typeof ADD_OWNER_WITH_THRESHOLD_ABI -> { +export class SafeDecoder extends AbiDecoder { constructor() { - super(ADD_OWNER_WITH_THRESHOLD_ABI); + super(OWNER_MANAGER_ABI); } }