Skip to content

Commit

Permalink
feat: decode multiSend alerts (safe-global#821)
Browse files Browse the repository at this point in the history
* feat: decode `multiSend` alerts

* fix: use helpers

* fix: import mapper

* fix: rename + adjust vars.
  • Loading branch information
iamacook authored Nov 3, 2023
1 parent 513e8a5 commit af4cc9c
Show file tree
Hide file tree
Showing 7 changed files with 307 additions and 13 deletions.
2 changes: 2 additions & 0 deletions src/domain.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -120,6 +121,7 @@ import { SafeDecoder } from '@/domain/alerts/contracts/safe-decoder.helper';
MasterCopyValidator,
MessageValidator,
ModuleTransactionValidator,
MultiSendDecoder,
MultisigTransactionValidator,
SafeAppsValidator,
SafeDecoder,
Expand Down
34 changes: 34 additions & 0 deletions src/domain/alerts/__tests__/multisend-transactions.encoder.ts
Original file line number Diff line number Diff line change
@@ -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)],
});
}
95 changes: 95 additions & 0 deletions src/domain/alerts/__tests__/safe-transactions.encoder.ts
Original file line number Diff line number Diff line change
@@ -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],
});
}
49 changes: 41 additions & 8 deletions src/domain/alerts/alerts.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}
}
}
}
40 changes: 40 additions & 0 deletions src/domain/alerts/contracts/multi-send-decoder.helper.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
89 changes: 89 additions & 0 deletions src/domain/alerts/contracts/multi-send-decoder.helper.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
11 changes: 6 additions & 5 deletions src/domain/alerts/contracts/safe-decoder.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof OWNER_MANAGER_ABI> {
constructor() {
super(ADD_OWNER_WITH_THRESHOLD_ABI);
super(OWNER_MANAGER_ABI);
}
}

0 comments on commit af4cc9c

Please sign in to comment.