diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index f7edbbb31b..e510a0107f 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -164,9 +164,10 @@ export default (): ReturnType => ({ mappings: { imitation: { lookupDistance: faker.number.int(), - valueTolerance: faker.number.bigInt(), prefixLength: faker.number.int(), suffixLength: faker.number.int(), + valueTolerance: faker.number.bigInt(), + echoLimit: faker.number.bigInt(), }, history: { maxNestedTransfers: faker.number.int({ min: 1, max: 5 }), diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 95d0261f7d..cfdf369c5c 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -259,11 +259,12 @@ 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}`), // Note: due to high value formatted token values, we use bigint // This means the value tolerance can only be an integer valueTolerance: BigInt(process.env.IMITATION_VALUE_TOLERANCE ?? 1), - prefixLength: parseInt(process.env.IMITATION_PREFIX_LENGTH ?? `${3}`), - suffixLength: parseInt(process.env.IMITATION_SUFFIX_LENGTH ?? `${4}`), + echoLimit: BigInt(process.env.IMITATION_ECHO_LIMIT ?? `${10}`), }, history: { maxNestedTransfers: parseInt( diff --git a/src/routes/transactions/mappers/transfers/transfer-imitation.mapper.ts b/src/routes/transactions/mappers/transfers/transfer-imitation.mapper.ts index cdd988f766..bd4caf576d 100644 --- a/src/routes/transactions/mappers/transfers/transfer-imitation.mapper.ts +++ b/src/routes/transactions/mappers/transfers/transfer-imitation.mapper.ts @@ -27,6 +27,7 @@ export class TransferImitationMapper { private readonly prefixLength: number; private readonly suffixLength: number; private readonly valueTolerance: bigint; + private readonly echoLimit: bigint; constructor( @Inject(IConfigurationService) @@ -44,6 +45,9 @@ export class TransferImitationMapper { this.valueTolerance = configurationService.getOrThrow( 'mappings.imitation.valueTolerance', ); + this.echoLimit = configurationService.getOrThrow( + 'mappings.imitation.echoLimit', + ); } /** @@ -113,7 +117,10 @@ export class TransferImitationMapper { return false; } - return this.isSpoofedEvent(txInfo, prevTxInfo); + return ( + this.isSpoofedEvent(txInfo, prevTxInfo) || + this.isEchoImitation(txInfo, prevTxInfo) + ); }); txInfo.transferInfo.imitation = isImitation; @@ -155,6 +162,38 @@ export class TransferImitationMapper { return this.isImitatorAddress(refAddress, prevRefAddress); } + /** + * Returns whether {@link txInfo} is incoming transfer imitating {@link prevTxInfo} + * + * A low-value (below a defined threshold) incoming transfer of the same token + * previously sent is deemed an imitation. + * + * @param {Erc20TransferTransactionInfo} txInfo - transaction info to compare + * @param {Erc20TransferTransactionInfo} prevTxInfo - previous transaction info + * @returns {boolean} - whether the transaction is an imitation + */ + isEchoImitation( + txInfo: Erc20TransferTransactionInfo, + prevTxInfo: Erc20TransferTransactionInfo, + ): boolean { + // Incoming transfer imitations must be of the same token + const isSameToken = + txInfo.transferInfo.tokenAddress === txInfo.transferInfo.tokenAddress; + const isIncoming = txInfo.direction === TransferDirection.Incoming; + const isPrevOutgoing = prevTxInfo.direction === TransferDirection.Outgoing; + if (!isSameToken || !isIncoming || !isPrevOutgoing) { + return false; + } + + // Is imitation if value is lower than the specified threshold + const value = this.formatValue(txInfo.transferInfo); + const prevValue = this.formatValue(prevTxInfo.transferInfo); + return ( + // Imitations generally follow high transfer values + prevValue >= this.echoLimit && value <= this.echoLimit + ); + } + /** * Returns whether the transaction info is an ERC-20 transfer * @param {Erc20TransferTransactionInfo} txInfo - transaction info diff --git a/src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts b/src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts index 0c7ce774eb..004b4b2840 100644 --- a/src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts +++ b/src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts @@ -41,7 +41,7 @@ import { erc20TransferBuilder, toJson as erc20TransferToJson, } from '@/domain/safe/entities/__tests__/erc20-transfer.builder'; -import { getAddress, zeroAddress } from 'viem'; +import { getAddress, parseUnits, zeroAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { erc20TransferEncoder } from '@/domain/relay/contracts/__tests__/encoders/erc20-encoder.builder'; @@ -59,6 +59,9 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = const prefixLength = 3; const suffixLength = 4; const valueTolerance = BigInt(1); + const echoLimit = BigInt(10); + const chain = chainBuilder().build(); + const safe = safeBuilder().build(); beforeEach(async () => { jest.resetAllMocks(); @@ -72,6 +75,7 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = valueTolerance, prefixLength, suffixLength, + echoLimit, }, }, features: { @@ -121,8 +125,6 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = const imitator = `${prefix}${faker.finance.ethereumAddress().slice(prefixLength + 2, -suffixLength)}${suffix}`; return getAddress(imitator); } - const chain = chainBuilder().build(); - const safe = safeBuilder().build(); const multisigExecutionDate = new Date('2024-03-20T09:41:25Z'); const multisigToken = tokenBuilder().with('type', TokenType.Erc20).build(); @@ -2691,4 +2693,1379 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = }); }); }); + + describe('Echo transfers', () => { + const multisigExecutionDate = new Date('2024-03-20T09:42:58Z'); + const multisigToken = tokenBuilder().with('type', TokenType.Erc20).build(); + const multisigTransfer = { + ...erc20TransferBuilder() + .with('executionDate', multisigExecutionDate) + .with('from', safe.address) + .with('tokenAddress', multisigToken.address) + .with( + 'value', + parseUnits( + // Value vastly above echo limit for testing flagging + (echoLimit * faker.number.bigInt({ min: 3, max: 9 })).toString(), + multisigToken.decimals!, + ).toString(), + ) + .build(), + tokenInfo: multisigToken, + }; + const multisigTransaction = { + ...(multisigTransactionToJson( + multisigTransactionBuilder() + .with('executionDate', multisigExecutionDate) + .with('safe', safe.address) + .with('to', multisigToken.address) + .with('value', '0') + .with('operation', 0) + .with('gasToken', zeroAddress) + .with('safeTxGas', 0) + .with('baseGas', 0) + .with('gasPrice', '0') + .with('refundReceiver', zeroAddress) + .with('proposer', safe.owners[0]) + .with('executor', safe.owners[0]) + .with('isExecuted', true) + .with('isSuccessful', true) + .with('origin', null) + .with( + 'dataDecoded', + dataDecodedBuilder() + .with('method', 'transfer') + .with('parameters', [ + dataDecodedParameterBuilder() + .with('name', 'to') + .with('type', 'address') + .with('value', multisigTransfer.to) + .build(), + dataDecodedParameterBuilder() + .with('name', 'value') + .with('type', 'uint256') + .with('value', multisigTransfer.value) + .build(), + ]) + .build(), + ) + .with('confirmationsRequired', 1) + .with('confirmations', [ + confirmationBuilder().with('owner', safe.owners[0]).build(), + ]) + .with('trusted', true) + .build(), + ) as MultisigTransaction), + // TODO: Update type to include transfers + transfers: [erc20TransferToJson(multisigTransfer) as Transfer], + } as MultisigTransaction; + + const notImitatedMultisigToken = tokenBuilder() + .with('type', TokenType.Erc20) + .with('decimals', multisigToken.decimals) + .build(); + const notImitatedMultisigTransfer = { + ...erc20TransferBuilder() + .with('executionDate', multisigExecutionDate) + .with('from', safe.address) + .with('tokenAddress', notImitatedMultisigToken.address) + .with('value', faker.string.numeric({ exclude: ['0'] })) + .build(), + tokenInfo: multisigToken, + }; + const notImitatedMultisigTransaction = { + ...(multisigTransactionToJson( + multisigTransactionBuilder() + .with('executionDate', multisigExecutionDate) + .with('safe', safe.address) + .with('to', notImitatedMultisigToken.address) + .with('value', '0') + .with('operation', 0) + .with('gasToken', zeroAddress) + .with('safeTxGas', 0) + .with('baseGas', 0) + .with('gasPrice', '0') + .with('refundReceiver', zeroAddress) + .with('proposer', safe.owners[0]) + .with('executor', safe.owners[0]) + .with('isExecuted', true) + .with('isSuccessful', true) + .with('origin', null) + .with( + 'dataDecoded', + dataDecodedBuilder() + .with('method', 'transfer') + .with('parameters', [ + dataDecodedParameterBuilder() + .with('name', 'to') + .with('type', 'address') + .with('value', notImitatedMultisigTransfer.to) + .build(), + dataDecodedParameterBuilder() + .with('name', 'value') + .with('type', 'uint256') + .with('value', notImitatedMultisigTransfer.value) + .build(), + ]) + .build(), + ) + .with('confirmationsRequired', 1) + .with('confirmations', [ + confirmationBuilder().with('owner', safe.owners[0]).build(), + ]) + .with('trusted', true) + .build(), + ) as MultisigTransaction), + // TODO: Update type to include transfers + transfers: [erc20TransferToJson(notImitatedMultisigTransfer) as Transfer], + } as MultisigTransaction; + + const getAllTransactionsUrl = `${chain.transactionService}/api/v1/safes/${safe.address}/all-transactions/`; + const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safe.address}`; + const getTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${multisigToken.address}`; + const getNotImitatedTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${notImitatedMultisigToken.address}`; + + describe('Below limit', () => { + const imitationExecutionDate = new Date('2024-03-20T09:42:58Z'); + const imitationIncomingTransfer = { + ...erc20TransferBuilder() + .with('to', safe.address) + .with('tokenAddress', multisigToken.address) + .with( + 'value', + parseUnits( + faker.number.bigInt({ min: 1, max: echoLimit }).toString(), + multisigToken.decimals!, + ).toString(), + ) + .with('executionDate', imitationExecutionDate) + .build(), + // TODO: Update type to include tokenInfo + tokenInfo: multisigToken, + }; + const imitationIncomingErc20Transfer = erc20TransferEncoder() + .with('to', safe.address) + .with('value', BigInt(imitationIncomingTransfer.value)); + const imitationIncomingTransaction = ethereumTransactionToJson( + ethereumTransactionBuilder() + .with('executionDate', imitationIncomingTransfer.executionDate) + .with('data', imitationIncomingErc20Transfer.encode()) + .with('transfers', [ + erc20TransferToJson(imitationIncomingTransfer) as Transfer, + ]) + .build(), + ) as EthereumTransaction; + + it('should flag imitation incoming transfers with a below-limit value within the lookup distance', async () => { + const results = [imitationIncomingTransaction, multisigTransaction]; + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { + return Promise.resolve({ data: chain, status: 200 }); + } + if (url === getAllTransactionsUrl) { + return Promise.resolve({ + data: pageBuilder().with('results', results).build(), + status: 200, + }); + } + if (url === getSafeUrl) { + return Promise.resolve({ data: safe, status: 200 }); + } + if (url === getTokenAddressUrl) { + return Promise.resolve({ + data: multisigToken, + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .get( + `/v1/chains/${chain.chainId}/safes/${safe.address}/transactions/history?trusted=false`, + ) + .expect(200) + .then(({ body }) => { + expect(body.results).toStrictEqual([ + { + timestamp: 1710927778000, + type: 'DATE_LABEL', + }, + { + conflictType: 'None', + transaction: { + executionInfo: null, + // @ts-expect-error - Type does not contain transfers + id: `transfer_${safe.address}_${results[0].transfers[0].transferId}`, + safeAppInfo: null, + timestamp: 1710927778000, + txInfo: { + direction: 'INCOMING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: safe.address, + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: imitationIncomingTransaction.transfers![0].from, + }, + transferInfo: { + decimals: multisigToken.decimals, + imitation: true, + logoUri: multisigToken.logoUri, + tokenAddress: multisigToken.address, + tokenName: multisigToken.name, + tokenSymbol: multisigToken.symbol, + trusted: multisigToken.trusted, + type: 'ERC20', + value: imitationIncomingTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + txHash: + imitationIncomingTransaction.transfers![0].transactionHash, + }, + type: 'TRANSACTION', + }, + { + conflictType: 'None', + transaction: { + executionInfo: { + confirmationsRequired: 1, + confirmationsSubmitted: 1, + missingSigners: null, + nonce: multisigTransaction.nonce, + type: 'MULTISIG', + }, + id: `multisig_${safe.address}_${multisigTransaction.safeTxHash}`, + safeAppInfo: null, + timestamp: 1710927778000, + txInfo: { + direction: 'OUTGOING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: multisigTransfer.to, + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: safe.address, + }, + transferInfo: { + decimals: multisigToken.decimals, + imitation: false, + logoUri: multisigToken.logoUri, + tokenAddress: multisigToken.address, + tokenName: multisigToken.name, + tokenSymbol: multisigToken.symbol, + trusted: null, + type: 'ERC20', + value: multisigTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + txHash: multisigTransaction.transactionHash, + }, + type: 'TRANSACTION', + }, + ]); + }); + }); + + it('should not flag imitation incoming transfers with a below-limit value outside the lookup distance', async () => { + const results = [ + imitationIncomingTransaction, + notImitatedMultisigTransaction, + notImitatedMultisigTransaction, + notImitatedMultisigTransaction, + multisigTransaction, + ]; + + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { + return Promise.resolve({ data: chain, status: 200 }); + } + if (url === getAllTransactionsUrl) { + return Promise.resolve({ + data: pageBuilder().with('results', results).build(), + status: 200, + }); + } + if (url === getSafeUrl) { + return Promise.resolve({ data: safe, status: 200 }); + } + if (url === getTokenAddressUrl) { + return Promise.resolve({ + data: multisigToken, + status: 200, + }); + } + if (url === getNotImitatedTokenAddressUrl) { + return Promise.resolve({ + data: notImitatedMultisigToken, + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .get( + `/v1/chains/${chain.chainId}/safes/${safe.address}/transactions/history?trusted=false`, + ) + .expect(200) + .then(({ body }) => { + expect(body.results).toStrictEqual([ + { + timestamp: 1710927778000, + type: 'DATE_LABEL', + }, + { + conflictType: 'None', + transaction: { + executionInfo: null, + // @ts-expect-error - Type does not contain transfers + id: `transfer_${safe.address}_${results[0].transfers[0].transferId}`, + safeAppInfo: null, + timestamp: 1710927778000, + txInfo: { + direction: 'INCOMING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: safe.address, + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: imitationIncomingTransaction.transfers![0].from, + }, + transferInfo: { + decimals: multisigToken.decimals, + imitation: false, + logoUri: multisigToken.logoUri, + tokenAddress: multisigToken.address, + tokenName: multisigToken.name, + tokenSymbol: multisigToken.symbol, + trusted: multisigToken.trusted, + type: 'ERC20', + value: imitationIncomingTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + txHash: + imitationIncomingTransaction.transfers![0].transactionHash, + }, + type: 'TRANSACTION', + }, + { + conflictType: 'None', + transaction: { + executionInfo: { + confirmationsRequired: 1, + confirmationsSubmitted: 1, + missingSigners: null, + nonce: notImitatedMultisigTransaction.nonce, + type: 'MULTISIG', + }, + id: `multisig_${safe.address}_${notImitatedMultisigTransaction.safeTxHash}`, + safeAppInfo: null, + timestamp: + notImitatedMultisigTransfer.executionDate.getTime(), + txInfo: { + direction: 'OUTGOING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: notImitatedMultisigTransfer.to, + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: safe.address, + }, + transferInfo: { + decimals: notImitatedMultisigToken.decimals, + imitation: false, + logoUri: notImitatedMultisigToken.logoUri, + tokenAddress: notImitatedMultisigToken.address, + tokenName: notImitatedMultisigToken.name, + tokenSymbol: notImitatedMultisigToken.symbol, + trusted: null, + type: 'ERC20', + value: notImitatedMultisigTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + txHash: notImitatedMultisigTransaction.transactionHash, + }, + type: 'TRANSACTION', + }, + { + conflictType: 'None', + transaction: { + executionInfo: { + confirmationsRequired: 1, + confirmationsSubmitted: 1, + missingSigners: null, + nonce: notImitatedMultisigTransaction.nonce, + type: 'MULTISIG', + }, + id: `multisig_${safe.address}_${notImitatedMultisigTransaction.safeTxHash}`, + safeAppInfo: null, + timestamp: + notImitatedMultisigTransfer.executionDate.getTime(), + txInfo: { + direction: 'OUTGOING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: notImitatedMultisigTransfer.to, + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: safe.address, + }, + transferInfo: { + decimals: notImitatedMultisigToken.decimals, + imitation: false, + logoUri: notImitatedMultisigToken.logoUri, + tokenAddress: notImitatedMultisigToken.address, + tokenName: notImitatedMultisigToken.name, + tokenSymbol: notImitatedMultisigToken.symbol, + trusted: null, + type: 'ERC20', + value: notImitatedMultisigTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + txHash: notImitatedMultisigTransaction.transactionHash, + }, + type: 'TRANSACTION', + }, + { + conflictType: 'None', + transaction: { + executionInfo: { + confirmationsRequired: 1, + confirmationsSubmitted: 1, + missingSigners: null, + nonce: notImitatedMultisigTransaction.nonce, + type: 'MULTISIG', + }, + id: `multisig_${safe.address}_${notImitatedMultisigTransaction.safeTxHash}`, + safeAppInfo: null, + timestamp: + notImitatedMultisigTransfer.executionDate.getTime(), + txInfo: { + direction: 'OUTGOING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: notImitatedMultisigTransfer.to, + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: safe.address, + }, + transferInfo: { + decimals: notImitatedMultisigToken.decimals, + imitation: false, + logoUri: notImitatedMultisigToken.logoUri, + tokenAddress: notImitatedMultisigToken.address, + tokenName: notImitatedMultisigToken.name, + tokenSymbol: notImitatedMultisigToken.symbol, + trusted: null, + type: 'ERC20', + value: notImitatedMultisigTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + txHash: notImitatedMultisigTransaction.transactionHash, + }, + type: 'TRANSACTION', + }, + { + conflictType: 'None', + transaction: { + executionInfo: { + confirmationsRequired: 1, + confirmationsSubmitted: 1, + missingSigners: null, + nonce: multisigTransaction.nonce, + type: 'MULTISIG', + }, + id: `multisig_${safe.address}_${multisigTransaction.safeTxHash}`, + safeAppInfo: null, + timestamp: 1710927778000, + txInfo: { + direction: 'OUTGOING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: multisigTransfer.to, + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: safe.address, + }, + transferInfo: { + decimals: multisigToken.decimals, + imitation: false, + logoUri: multisigToken.logoUri, + tokenAddress: multisigToken.address, + tokenName: multisigToken.name, + tokenSymbol: multisigToken.symbol, + trusted: null, + type: 'ERC20', + value: multisigTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + txHash: multisigTransaction.transactionHash, + }, + type: 'TRANSACTION', + }, + ]); + }); + }); + + it('should filter out imitation incoming transfers with a below-limit value within the lookup distance', async () => { + const results = [imitationIncomingTransaction, multisigTransaction]; + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { + return Promise.resolve({ data: chain, status: 200 }); + } + if (url === getAllTransactionsUrl) { + return Promise.resolve({ + data: pageBuilder().with('results', results).build(), + status: 200, + }); + } + if (url === getSafeUrl) { + return Promise.resolve({ data: safe, status: 200 }); + } + if (url === getTokenAddressUrl) { + return Promise.resolve({ + data: multisigToken, + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .get( + `/v1/chains/${chain.chainId}/safes/${safe.address}/transactions/history?trusted=false&imitation=false`, + ) + .expect(200) + .then(({ body }) => { + expect(body.results).toStrictEqual([ + { + timestamp: 1710927778000, + type: 'DATE_LABEL', + }, + { + conflictType: 'None', + transaction: { + executionInfo: { + confirmationsRequired: 1, + confirmationsSubmitted: 1, + missingSigners: null, + nonce: multisigTransaction.nonce, + type: 'MULTISIG', + }, + id: `multisig_${safe.address}_${multisigTransaction.safeTxHash}`, + safeAppInfo: null, + timestamp: 1710927778000, + txInfo: { + direction: 'OUTGOING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: multisigTransfer.to, + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: safe.address, + }, + transferInfo: { + decimals: multisigToken.decimals, + imitation: false, + logoUri: multisigToken.logoUri, + tokenAddress: multisigToken.address, + tokenName: multisigToken.name, + tokenSymbol: multisigToken.symbol, + trusted: null, + type: 'ERC20', + value: multisigTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + txHash: multisigTransaction.transactionHash, + }, + type: 'TRANSACTION', + }, + ]); + }); + }); + + it('should not filter out imitation incoming transfers with a below-limit value outside the lookup distance', async () => { + const results = [ + imitationIncomingTransaction, + notImitatedMultisigTransaction, + notImitatedMultisigTransaction, + notImitatedMultisigTransaction, + multisigTransaction, + ]; + + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { + return Promise.resolve({ data: chain, status: 200 }); + } + if (url === getAllTransactionsUrl) { + return Promise.resolve({ + data: pageBuilder().with('results', results).build(), + status: 200, + }); + } + if (url === getSafeUrl) { + return Promise.resolve({ data: safe, status: 200 }); + } + if (url === getTokenAddressUrl) { + return Promise.resolve({ + data: multisigToken, + status: 200, + }); + } + if (url === getNotImitatedTokenAddressUrl) { + return Promise.resolve({ + data: notImitatedMultisigToken, + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .get( + `/v1/chains/${chain.chainId}/safes/${safe.address}/transactions/history?trusted=false&imitation=false`, + ) + .expect(200) + .then(({ body }) => { + expect(body.results).toStrictEqual([ + { + timestamp: 1710927778000, + type: 'DATE_LABEL', + }, + { + conflictType: 'None', + transaction: { + executionInfo: null, + // @ts-expect-error - Type does not contain transfers + id: `transfer_${safe.address}_${results[0].transfers[0].transferId}`, + safeAppInfo: null, + timestamp: 1710927778000, + txInfo: { + direction: 'INCOMING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: safe.address, + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: imitationIncomingTransaction.transfers![0].from, + }, + transferInfo: { + decimals: multisigToken.decimals, + imitation: false, + logoUri: multisigToken.logoUri, + tokenAddress: multisigToken.address, + tokenName: multisigToken.name, + tokenSymbol: multisigToken.symbol, + trusted: multisigToken.trusted, + type: 'ERC20', + value: imitationIncomingTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + txHash: + imitationIncomingTransaction.transfers![0].transactionHash, + }, + type: 'TRANSACTION', + }, + { + conflictType: 'None', + transaction: { + executionInfo: { + confirmationsRequired: 1, + confirmationsSubmitted: 1, + missingSigners: null, + nonce: notImitatedMultisigTransaction.nonce, + type: 'MULTISIG', + }, + id: `multisig_${safe.address}_${notImitatedMultisigTransaction.safeTxHash}`, + safeAppInfo: null, + timestamp: + notImitatedMultisigTransfer.executionDate.getTime(), + txInfo: { + direction: 'OUTGOING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: notImitatedMultisigTransfer.to, + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: safe.address, + }, + transferInfo: { + decimals: notImitatedMultisigToken.decimals, + imitation: false, + logoUri: notImitatedMultisigToken.logoUri, + tokenAddress: notImitatedMultisigToken.address, + tokenName: notImitatedMultisigToken.name, + tokenSymbol: notImitatedMultisigToken.symbol, + trusted: null, + type: 'ERC20', + value: notImitatedMultisigTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + txHash: notImitatedMultisigTransaction.transactionHash, + }, + type: 'TRANSACTION', + }, + { + conflictType: 'None', + transaction: { + executionInfo: { + confirmationsRequired: 1, + confirmationsSubmitted: 1, + missingSigners: null, + nonce: notImitatedMultisigTransaction.nonce, + type: 'MULTISIG', + }, + id: `multisig_${safe.address}_${notImitatedMultisigTransaction.safeTxHash}`, + safeAppInfo: null, + timestamp: + notImitatedMultisigTransfer.executionDate.getTime(), + txInfo: { + direction: 'OUTGOING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: notImitatedMultisigTransfer.to, + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: safe.address, + }, + transferInfo: { + decimals: notImitatedMultisigToken.decimals, + imitation: false, + logoUri: notImitatedMultisigToken.logoUri, + tokenAddress: notImitatedMultisigToken.address, + tokenName: notImitatedMultisigToken.name, + tokenSymbol: notImitatedMultisigToken.symbol, + trusted: null, + type: 'ERC20', + value: notImitatedMultisigTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + txHash: notImitatedMultisigTransaction.transactionHash, + }, + type: 'TRANSACTION', + }, + { + conflictType: 'None', + transaction: { + executionInfo: { + confirmationsRequired: 1, + confirmationsSubmitted: 1, + missingSigners: null, + nonce: notImitatedMultisigTransaction.nonce, + type: 'MULTISIG', + }, + id: `multisig_${safe.address}_${notImitatedMultisigTransaction.safeTxHash}`, + safeAppInfo: null, + timestamp: + notImitatedMultisigTransfer.executionDate.getTime(), + txInfo: { + direction: 'OUTGOING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: notImitatedMultisigTransfer.to, + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: safe.address, + }, + transferInfo: { + decimals: notImitatedMultisigToken.decimals, + imitation: false, + logoUri: notImitatedMultisigToken.logoUri, + tokenAddress: notImitatedMultisigToken.address, + tokenName: notImitatedMultisigToken.name, + tokenSymbol: notImitatedMultisigToken.symbol, + trusted: null, + type: 'ERC20', + value: notImitatedMultisigTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + txHash: notImitatedMultisigTransaction.transactionHash, + }, + type: 'TRANSACTION', + }, + { + conflictType: 'None', + transaction: { + executionInfo: { + confirmationsRequired: 1, + confirmationsSubmitted: 1, + missingSigners: null, + nonce: multisigTransaction.nonce, + type: 'MULTISIG', + }, + id: `multisig_${safe.address}_${multisigTransaction.safeTxHash}`, + safeAppInfo: null, + timestamp: 1710927778000, + txInfo: { + direction: 'OUTGOING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: multisigTransfer.to, + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: safe.address, + }, + transferInfo: { + decimals: multisigToken.decimals, + imitation: false, + logoUri: multisigToken.logoUri, + tokenAddress: multisigToken.address, + tokenName: multisigToken.name, + tokenSymbol: multisigToken.symbol, + trusted: null, + type: 'ERC20', + value: multisigTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + txHash: multisigTransaction.transactionHash, + }, + type: 'TRANSACTION', + }, + ]); + }); + }); + }); + + describe('Above limit', () => { + const aboveLimitExecutionDate = new Date('2024-03-20T09:42:58Z'); + const aboveLimitIncomingTransfer = { + ...erc20TransferBuilder() + .with('to', safe.address) + .with('tokenAddress', multisigToken.address) + .with( + 'value', + parseUnits( + faker.number.bigInt({ min: echoLimit }).toString(), + multisigToken.decimals!, + ).toString(), + ) + .with('executionDate', aboveLimitExecutionDate) + .build(), + // TODO: Update type to include tokenInfo + tokenInfo: multisigToken, + }; + const aboveLimitErc20Transfer = erc20TransferEncoder() + .with('to', safe.address) + .with('value', BigInt(aboveLimitIncomingTransfer.value)); + const aboveLimitIncomingTransaction = ethereumTransactionToJson( + ethereumTransactionBuilder() + .with('executionDate', aboveLimitIncomingTransfer.executionDate) + .with('data', aboveLimitErc20Transfer.encode()) + .with('transfers', [ + erc20TransferToJson(aboveLimitIncomingTransfer) as Transfer, + ]) + .build(), + ) as EthereumTransaction; + + it.each([ + [ + 'should not flag imitation incoming transfers with an above-limit value within the lookup distance', + true, + ], + [ + 'should not filter out imitation incoming transfers with an above-limit value within the lookup distance', + false, + ], + ])(`%s`, async (_, filter) => { + const results = [aboveLimitIncomingTransaction, multisigTransaction]; + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { + return Promise.resolve({ data: chain, status: 200 }); + } + if (url === getAllTransactionsUrl) { + return Promise.resolve({ + data: pageBuilder().with('results', results).build(), + status: 200, + }); + } + if (url === getSafeUrl) { + return Promise.resolve({ data: safe, status: 200 }); + } + if (url === getTokenAddressUrl) { + return Promise.resolve({ + data: multisigToken, + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .get( + `/v1/chains/${chain.chainId}/safes/${safe.address}/transactions/history?trusted=false&imitation=${filter}`, + ) + .expect(200) + .then(({ body }) => { + expect(body.results).toStrictEqual([ + { + timestamp: 1710927778000, + type: 'DATE_LABEL', + }, + { + conflictType: 'None', + transaction: { + executionInfo: null, + // @ts-expect-error - Type does not contain transfers + id: `transfer_${safe.address}_${results[0].transfers[0].transferId}`, + safeAppInfo: null, + timestamp: 1710927778000, + txInfo: { + direction: 'INCOMING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: safe.address, + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: aboveLimitIncomingTransaction.transfers![0].from, + }, + transferInfo: { + decimals: multisigToken.decimals, + imitation: false, + logoUri: multisigToken.logoUri, + tokenAddress: multisigToken.address, + tokenName: multisigToken.name, + tokenSymbol: multisigToken.symbol, + trusted: multisigToken.trusted, + type: 'ERC20', + value: aboveLimitIncomingTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + txHash: + aboveLimitIncomingTransaction.transfers![0].transactionHash, + }, + type: 'TRANSACTION', + }, + { + conflictType: 'None', + transaction: { + executionInfo: { + confirmationsRequired: 1, + confirmationsSubmitted: 1, + missingSigners: null, + nonce: multisigTransaction.nonce, + type: 'MULTISIG', + }, + id: `multisig_${safe.address}_${multisigTransaction.safeTxHash}`, + safeAppInfo: null, + timestamp: 1710927778000, + txInfo: { + direction: 'OUTGOING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: multisigTransfer.to, + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: safe.address, + }, + transferInfo: { + decimals: multisigToken.decimals, + imitation: false, + logoUri: multisigToken.logoUri, + tokenAddress: multisigToken.address, + tokenName: multisigToken.name, + tokenSymbol: multisigToken.symbol, + trusted: null, + type: 'ERC20', + value: multisigTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + txHash: multisigTransaction.transactionHash, + }, + type: 'TRANSACTION', + }, + ]); + }); + }); + + it.each([ + [ + 'should not flag imitation incoming transfers with an above-limit value outside the lookup distance', + true, + ], + [ + 'should not filter out imitation incoming transfers with an above-limit value outside the lookup distance', + false, + ], + ])(`%s`, async (_, filter) => { + const results = [ + aboveLimitIncomingTransaction, + notImitatedMultisigTransaction, + notImitatedMultisigTransaction, + notImitatedMultisigTransaction, + multisigTransaction, + ]; + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { + return Promise.resolve({ data: chain, status: 200 }); + } + if (url === getAllTransactionsUrl) { + return Promise.resolve({ + data: pageBuilder().with('results', results).build(), + status: 200, + }); + } + if (url === getSafeUrl) { + return Promise.resolve({ data: safe, status: 200 }); + } + if (url === getTokenAddressUrl) { + return Promise.resolve({ + data: multisigToken, + status: 200, + }); + } + if (url === getNotImitatedTokenAddressUrl) { + return Promise.resolve({ + data: notImitatedMultisigToken, + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .get( + `/v1/chains/${chain.chainId}/safes/${safe.address}/transactions/history?trusted=false&imitation=${filter}`, + ) + .expect(200) + .then(({ body }) => { + expect(body.results).toStrictEqual([ + { + timestamp: 1710927778000, + type: 'DATE_LABEL', + }, + { + conflictType: 'None', + transaction: { + executionInfo: null, + // @ts-expect-error - Type does not contain transfers + id: `transfer_${safe.address}_${results[0].transfers[0].transferId}`, + safeAppInfo: null, + timestamp: 1710927778000, + txInfo: { + direction: 'INCOMING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: safe.address, + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: aboveLimitIncomingTransaction.transfers![0].from, + }, + transferInfo: { + decimals: multisigToken.decimals, + imitation: false, + logoUri: multisigToken.logoUri, + tokenAddress: multisigToken.address, + tokenName: multisigToken.name, + tokenSymbol: multisigToken.symbol, + trusted: multisigToken.trusted, + type: 'ERC20', + value: aboveLimitIncomingTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + txHash: + aboveLimitIncomingTransaction.transfers![0].transactionHash, + }, + type: 'TRANSACTION', + }, + { + conflictType: 'None', + transaction: { + executionInfo: { + confirmationsRequired: 1, + confirmationsSubmitted: 1, + missingSigners: null, + nonce: notImitatedMultisigTransaction.nonce, + type: 'MULTISIG', + }, + id: `multisig_${safe.address}_${notImitatedMultisigTransaction.safeTxHash}`, + safeAppInfo: null, + timestamp: + notImitatedMultisigTransfer.executionDate.getTime(), + txInfo: { + direction: 'OUTGOING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: notImitatedMultisigTransfer.to, + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: safe.address, + }, + transferInfo: { + decimals: notImitatedMultisigToken.decimals, + imitation: false, + logoUri: notImitatedMultisigToken.logoUri, + tokenAddress: notImitatedMultisigToken.address, + tokenName: notImitatedMultisigToken.name, + tokenSymbol: notImitatedMultisigToken.symbol, + trusted: null, + type: 'ERC20', + value: notImitatedMultisigTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + txHash: notImitatedMultisigTransaction.transactionHash, + }, + type: 'TRANSACTION', + }, + { + conflictType: 'None', + transaction: { + executionInfo: { + confirmationsRequired: 1, + confirmationsSubmitted: 1, + missingSigners: null, + nonce: notImitatedMultisigTransaction.nonce, + type: 'MULTISIG', + }, + id: `multisig_${safe.address}_${notImitatedMultisigTransaction.safeTxHash}`, + safeAppInfo: null, + timestamp: + notImitatedMultisigTransfer.executionDate.getTime(), + txInfo: { + direction: 'OUTGOING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: notImitatedMultisigTransfer.to, + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: safe.address, + }, + transferInfo: { + decimals: notImitatedMultisigToken.decimals, + imitation: false, + logoUri: notImitatedMultisigToken.logoUri, + tokenAddress: notImitatedMultisigToken.address, + tokenName: notImitatedMultisigToken.name, + tokenSymbol: notImitatedMultisigToken.symbol, + trusted: null, + type: 'ERC20', + value: notImitatedMultisigTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + txHash: notImitatedMultisigTransaction.transactionHash, + }, + type: 'TRANSACTION', + }, + { + conflictType: 'None', + transaction: { + executionInfo: { + confirmationsRequired: 1, + confirmationsSubmitted: 1, + missingSigners: null, + nonce: notImitatedMultisigTransaction.nonce, + type: 'MULTISIG', + }, + id: `multisig_${safe.address}_${notImitatedMultisigTransaction.safeTxHash}`, + safeAppInfo: null, + timestamp: + notImitatedMultisigTransfer.executionDate.getTime(), + txInfo: { + direction: 'OUTGOING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: notImitatedMultisigTransfer.to, + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: safe.address, + }, + transferInfo: { + decimals: notImitatedMultisigToken.decimals, + imitation: false, + logoUri: notImitatedMultisigToken.logoUri, + tokenAddress: notImitatedMultisigToken.address, + tokenName: notImitatedMultisigToken.name, + tokenSymbol: notImitatedMultisigToken.symbol, + trusted: null, + type: 'ERC20', + value: notImitatedMultisigTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + txHash: notImitatedMultisigTransaction.transactionHash, + }, + type: 'TRANSACTION', + }, + { + conflictType: 'None', + transaction: { + executionInfo: { + confirmationsRequired: 1, + confirmationsSubmitted: 1, + missingSigners: null, + nonce: multisigTransaction.nonce, + type: 'MULTISIG', + }, + id: `multisig_${safe.address}_${multisigTransaction.safeTxHash}`, + safeAppInfo: null, + timestamp: 1710927778000, + txInfo: { + direction: 'OUTGOING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: multisigTransfer.to, + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: safe.address, + }, + transferInfo: { + decimals: multisigToken.decimals, + imitation: false, + logoUri: multisigToken.logoUri, + tokenAddress: multisigToken.address, + tokenName: multisigToken.name, + tokenSymbol: multisigToken.symbol, + trusted: null, + type: 'ERC20', + value: multisigTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + txHash: multisigTransaction.transactionHash, + }, + type: 'TRANSACTION', + }, + ]); + }); + }); + }); + }); });