Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/config/entities/__tests__/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ export default (): ReturnType<typeof configuration> => ({
},
mappings: {
imitation: {
lookupDistance: faker.number.int(),
prefixLength: faker.number.int(),
suffixLength: faker.number.int(),
},
Expand Down
1 change: 1 addition & 0 deletions src/config/entities/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ export default () => ({
},
mappings: {
imitation: {
lookupDistance: parseInt(process.env.IMITATION_LOOKUP_DISTANCE ?? `${3}`),
prefixLength: parseInt(process.env.IMITATION_PREFIX_LENGTH ?? `${3}`),
suffixLength: parseInt(process.env.IMITATION_SUFFIX_LENGTH ?? `${4}`),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ export class Erc20Transfer extends Transfer {
decimals: number | null;
@ApiPropertyOptional({ type: Boolean, nullable: true })
trusted: boolean | null;
@ApiPropertyOptional({ type: Boolean, nullable: true })
imitation: boolean | null;
@ApiProperty()
imitation: boolean;

constructor(
tokenAddress: `0x${string}`,
Expand All @@ -30,7 +30,7 @@ export class Erc20Transfer extends Transfer {
logoUri: string | null = null,
decimals: number | null = null,
trusted: boolean | null = null,
imitation: boolean | null = null,
imitation: boolean = false,
) {
super(TransferType.Erc20);
this.tokenAddress = tokenAddress;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,26 @@ import { TransactionItem } from '@/routes/transactions/entities/transaction-item
import {
isTransferTransactionInfo,
TransferDirection,
TransferTransactionInfo,
} from '@/routes/transactions/entities/transfer-transaction-info.entity';
import { isErc20Transfer } from '@/routes/transactions/entities/transfers/erc20-transfer.entity';
import { Inject } from '@nestjs/common';
import { formatUnits } from 'viem';

export class TransferImitationMapper {
private static ETH_DECIMALS = 18;

private readonly lookupDistance: number;
private readonly prefixLength: number;
private readonly suffixLength: number;

constructor(
@Inject(IConfigurationService) configurationService: IConfigurationService,
@Inject(IConfigurationService)
private readonly configurationService: IConfigurationService,
) {
this.lookupDistance = configurationService.getOrThrow(
'mappings.imitation.lookupDistance',
);
this.prefixLength = configurationService.getOrThrow(
'mappings.imitation.prefixLength',
);
Expand All @@ -22,99 +31,140 @@ export class TransferImitationMapper {
);
}

/**
* Flags or filters (according to {@link args.showImitations}) transactions
* that are likely imitations, based on the value and vanity of the address of
* the sender or recipient.
*
* Note: does not support batched transactions as we have no transaction data
* to decode the individual transactions from.
*
* @param args.transactions - transactions to map
* @param args.previousTransaction - first transaction of the next page
* @param args.showImitations - whether to filter out imitations
*
* @returns - mapped transactions
*/
mapImitations(args: {
transactions: Array<TransactionItem>;
previousTransaction: TransactionItem | undefined;
showImitations: boolean;
}): Array<TransactionItem> {
const transactions = this.mapTransferInfoImitation(
args.transactions,
args.previousTransaction,
);
const mappedTransactions: Array<TransactionItem> = [];

if (args.showImitations) {
return transactions;
}
// Iterate in reverse order as transactions are date descending
for (let i = args.transactions.length - 1; i >= 0; i--) {
const item = args.transactions[i];

return transactions.filter(({ transaction }) => {
const { txInfo } = transaction;
return (
!isTransferTransactionInfo(txInfo) ||
!isErc20Transfer(txInfo.transferInfo) ||
// null by default or explicitly false if not imitation
txInfo.transferInfo?.imitation !== true
);
});
}

/**
* Flags outgoing ERC20 transfers that imitate their direct predecessor in value
* and have a recipient address that is not the same but matches in vanity.
*
* @param transactions - list of transactions to map
* @param previousTransaction - transaction to compare last {@link transactions} against
*
* Note: this only handles singular imitation transfers. It does not handle multiple
* imitation transfers in a row, nor does it compare batched multiSend transactions
* as the "distance" between those batched and their imitation may not be immediate.
*/
private mapTransferInfoImitation(
transactions: Array<TransactionItem>,
previousTransaction?: TransactionItem,
): Array<TransactionItem> {
return transactions.map((item, i, arr) => {
// Executed by Safe - cannot be imitation
if (item.transaction.executionInfo) {
return item;
}

// Transaction list is in date-descending order. We compare each transaction with the next
// unless we are comparing the last transaction, in which case we compare it with the
// "previous transaction" (the first transaction of the subsequent page).
const prevItem = i === arr.length - 1 ? previousTransaction : arr[i + 1];

// No reference transaction to filter against
if (!prevItem) {
return item;
mappedTransactions.unshift(item);
continue;
}

const txInfo = item.transaction.txInfo;
// Only transfers can be imitated, of which we are only interested in ERC20s
if (
// Only consider transfers...
!isTransferTransactionInfo(item.transaction.txInfo) ||
!isTransferTransactionInfo(prevItem.transaction.txInfo) ||
// ...of ERC20s...
!isErc20Transfer(item.transaction.txInfo.transferInfo) ||
!isErc20Transfer(prevItem.transaction.txInfo.transferInfo)
!isTransferTransactionInfo(txInfo) ||
!isErc20Transfer(txInfo.transferInfo)
) {
return item;
mappedTransactions.unshift(item);
continue;
}

// ...that are outgoing
const isOutgoing =
item.transaction.txInfo.direction === TransferDirection.Outgoing;
const isPrevOutgoing =
prevItem.transaction.txInfo.direction === TransferDirection.Outgoing;
if (!isOutgoing || !isPrevOutgoing) {
return item;
/**
* Transactions to compare for imitation against, limited by a lookup distance.
*
* Concatenation takes preference of already mapped transactions over their
* original in order to prevent comparison against duplicates.
* Its length is {@link transactions} + 1 as {@link previousTransaction}
* is appended to compare {@link transactions.at(-1)} against.
*/
const prevItems = mappedTransactions
.concat(args.transactions.slice(i - 1), args.previousTransaction ?? [])
// Only compare so far back
.slice(0, this.lookupDistance);

if (prevItems.length === 0) {
txInfo.transferInfo.imitation = false;
mappedTransactions.unshift(item);
continue;
}

// Imitation transfers are of the same value...
const isSameValue =
item.transaction.txInfo.transferInfo.value ===
prevItem.transaction.txInfo.transferInfo.value;
if (!isSameValue) {
return item;
// Imitation transfers often employ differing decimals to prevent direct
// comparison of values. Here we normalize the value
const formattedValue = this.formatValue(
txInfo.transferInfo.value,
txInfo.transferInfo.decimals,
);
// Either sender or recipient according to "direction" of transaction
const refAddress = this.getReferenceAddress(txInfo);

const isImitation = prevItems.some((prevItem) => {
const prevTxInfo = prevItem.transaction.txInfo;
if (
!isTransferTransactionInfo(prevTxInfo) ||
!isErc20Transfer(prevTxInfo.transferInfo) ||
// Do not compare against previously identified imitations
prevTxInfo.transferInfo.imitation
) {
return false;
}

const prevFormattedValue = this.formatValue(
prevTxInfo.transferInfo.value,
prevTxInfo.transferInfo.decimals,
);

// Imitation transfers match in value
if (formattedValue !== prevFormattedValue) {
return false;
}

// Imitation transfers match in vanity of address
const prevRefAddress = this.getReferenceAddress(prevTxInfo);
return this.isImitatorAddress(refAddress, prevRefAddress);
});

txInfo.transferInfo.imitation = isImitation;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: if the for loop continue before reaching this point, the value of txInfo.transferInfo.imitation is null. Do you see value in setting it to false in those situations? Maybe the API of this mapper is simpler if we enforce a boolean type in the field?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In 5a945cd I changed the default value of Erc20Transfer['imitation'] to false in the and explicitly set it here as requested.


if (!isImitation || args.showImitations) {
mappedTransactions.unshift(item);
}
}

item.transaction.txInfo.transferInfo.imitation = this.isImitatorAddress(
item.transaction.txInfo.recipient.value,
prevItem.transaction.txInfo.recipient.value,
);
return mappedTransactions;
}

return item;
});
/**
* Returns a string value of the value multiplied by the given decimals
* @param value - value to format
* @param decimals - decimals to multiply value by
* @returns - formatted value
*/
private formatValue(value: string, decimals: number | null): string {
// Default to "standard" Ethereum decimals
const _decimals = decimals ?? TransferImitationMapper.ETH_DECIMALS;
return formatUnits(BigInt(value), _decimals);
}

/**
* Returns the address of the sender or recipient according to the direction
* @param txInfo - transaction info
* @returns - address of sender or recipient
*/
private getReferenceAddress(txInfo: TransferTransactionInfo): string {
return txInfo.direction === TransferDirection.Outgoing
? txInfo.recipient.value
: txInfo.sender.value;
}

/**
* Returns whether the two addresses match in vanity
* @param address1 - address to compare against
* @param address2 - second address to compare
* @returns - whether the two addresses are imitators
*/
private isImitatorAddress(address1: string, address2: string): boolean {
const a1 = address1.toLowerCase();
const a2 = address2.toLowerCase();
Expand Down
Loading