From be68d6d7528335652a121c6f8aeb9cd7e1425ce9 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 16 May 2024 12:35:19 +0200 Subject: [PATCH 001/207] Add `toJson` helpers for locking controller tests (#1554) Adds and uses JSON conversion helpers for converting non-JSON datatypes (e.g. Date objects) in locking tests: - Add `Campaign`-specific `toJson` helper - Add `LockEventItem | UnlockEventItem | WithdrawEventItem`-specific `toJson` helper - Use the above in their respective tests --- .../entities/__tests__/campaign.builder.ts | 9 +++++ .../__tests__/locking-event.builder.ts | 9 +++++ src/routes/locking/locking.controller.spec.ts | 37 +++++-------------- 3 files changed, 28 insertions(+), 27 deletions(-) diff --git a/src/domain/locking/entities/__tests__/campaign.builder.ts b/src/domain/locking/entities/__tests__/campaign.builder.ts index 454c1471a1..e192d278da 100644 --- a/src/domain/locking/entities/__tests__/campaign.builder.ts +++ b/src/domain/locking/entities/__tests__/campaign.builder.ts @@ -18,3 +18,12 @@ export function campaignBuilder(): IBuilder { ), ); } + +export function toJson(campaign: Campaign): unknown { + return { + ...campaign, + startDate: campaign.startDate.toISOString(), + endDate: campaign.endDate.toISOString(), + lastUpdated: campaign.lastUpdated.toISOString(), + }; +} diff --git a/src/domain/locking/entities/__tests__/locking-event.builder.ts b/src/domain/locking/entities/__tests__/locking-event.builder.ts index 0d86235c60..d412c6d43d 100644 --- a/src/domain/locking/entities/__tests__/locking-event.builder.ts +++ b/src/domain/locking/entities/__tests__/locking-event.builder.ts @@ -45,3 +45,12 @@ export function withdrawEventItemBuilder(): IBuilder { .with('unlockIndex', faker.string.numeric()) .with('logIndex', faker.string.numeric()); } + +export function toJson( + event: LockEventItem | UnlockEventItem | WithdrawEventItem, +): unknown { + return { + ...event, + executionDate: event.executionDate.toISOString(), + }; +} diff --git a/src/routes/locking/locking.controller.spec.ts b/src/routes/locking/locking.controller.spec.ts index e28d946bbc..51b8dd7316 100644 --- a/src/routes/locking/locking.controller.spec.ts +++ b/src/routes/locking/locking.controller.spec.ts @@ -22,6 +22,7 @@ import { lockEventItemBuilder, unlockEventItemBuilder, withdrawEventItemBuilder, + toJson as lockingEventToJson, } from '@/domain/locking/entities/__tests__/locking-event.builder'; import { LockingEvent } from '@/domain/locking/entities/locking-event.entity'; import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; @@ -31,7 +32,10 @@ import { rankBuilder } from '@/domain/locking/entities/__tests__/rank.builder'; import { PaginationData } from '@/routes/common/pagination/pagination.data'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; -import { campaignBuilder } from '@/domain/locking/entities/__tests__/campaign.builder'; +import { + campaignBuilder, + toJson as campaignToJson, +} from '@/domain/locking/entities/__tests__/campaign.builder'; import { Campaign } from '@/domain/locking/entities/campaign.entity'; describe('Locking (Unit)', () => { @@ -84,12 +88,7 @@ describe('Locking (Unit)', () => { await request(app.getHttpServer()) .get(`/v1/locking/campaigns/${campaign.campaignId}`) .expect(200) - .expect({ - ...campaign, - startDate: campaign.startDate.toISOString(), - endDate: campaign.endDate.toISOString(), - lastUpdated: campaign.lastUpdated.toISOString(), - }); + .expect(campaignToJson(campaign) as Campaign); }); it('should get the list of campaigns', async () => { @@ -115,12 +114,7 @@ describe('Locking (Unit)', () => { count: 1, next: null, previous: null, - results: campaignsPage.results.map((campaign) => ({ - ...campaign, - startDate: campaign.startDate.toISOString(), - endDate: campaign.endDate.toISOString(), - lastUpdated: campaign.lastUpdated.toISOString(), - })), + results: campaignsPage.results.map(campaignToJson), }); }); @@ -177,12 +171,7 @@ describe('Locking (Unit)', () => { count: 1, next: null, previous: null, - results: campaignsPage.results.map((campaign) => ({ - ...campaign, - startDate: campaign.startDate.toISOString(), - endDate: campaign.endDate.toISOString(), - lastUpdated: campaign.lastUpdated.toISOString(), - })), + results: campaignsPage.results.map(campaignToJson), }); expect(networkService.get).toHaveBeenCalledWith({ @@ -476,10 +465,7 @@ describe('Locking (Unit)', () => { count: lockingHistoryPage.count, next: null, previous: null, - results: lockingHistoryPage.results.map((result) => ({ - ...result, - executionDate: result.executionDate.toISOString(), - })), + results: lockingHistoryPage.results.map(lockingEventToJson), }); expect(networkService.get).toHaveBeenCalledWith({ @@ -527,10 +513,7 @@ describe('Locking (Unit)', () => { count: lockingHistoryPage.count, next: null, previous: null, - results: lockingHistoryPage.results.map((result) => ({ - ...result, - executionDate: result.executionDate.toISOString(), - })), + results: lockingHistoryPage.results.map(lockingEventToJson), }); expect(networkService.get).toHaveBeenCalledWith({ From 0e679ba4ceca138188e1b44e2ab4a2025ebad3d5 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 16 May 2024 13:44:02 +0200 Subject: [PATCH 002/207] Use mock imitation transactions in tests (#1548) Migrates from static to dynamic mock data for the imitation transaction tests. --- .../transactions-history.controller.spec.ts | 1230 +++++++---------- 1 file changed, 483 insertions(+), 747 deletions(-) diff --git a/src/routes/transactions/transactions-history.controller.spec.ts b/src/routes/transactions/transactions-history.controller.spec.ts index 51c881e8f9..696663e2c1 100644 --- a/src/routes/transactions/transactions-history.controller.spec.ts +++ b/src/routes/transactions/transactions-history.controller.spec.ts @@ -59,9 +59,12 @@ import { } from '@/domain/safe/entities/__tests__/erc721-transfer.builder'; import { TransactionItem } from '@/routes/transactions/entities/transaction-item.entity'; import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; -import { getAddress } from 'viem'; +import { getAddress, zeroAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { MultisigTransaction } from '@/domain/safe/entities/multisig-transaction.entity'; +import { EthereumTransaction } from '@/domain/safe/entities/ethereum-transaction.entity'; +import { erc20TransferEncoder } from '@/domain/relay/contracts/__tests__/encoders/erc20-encoder.builder'; describe('Transactions History Controller (Unit)', () => { let app: INestApplication; @@ -1353,158 +1356,115 @@ describe('Transactions History Controller (Unit)', () => { }); }); - describe('Address poisoning', () => { - // TODO: Add tests with a mixture of (non-)trusted tokens, as well add builder-based tests + describe('Imitation transactions', () => { describe('Trusted tokens', () => { it('should flag outgoing ERC-20 transfers that imitate a direct predecessor', async () => { - // Example taken from arb1:0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4 - marked as trusted const chain = chainBuilder().build(); - const safe = safeBuilder() - .with('address', '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4') - .with('owners', [ - '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', - ]) + const safe = safeBuilder().build(); + + const multisigExecutionDate = new Date('2024-03-20T09:41:25Z'); + const multisigToken = tokenBuilder() + .with('trusted', true) + .with('type', TokenType.Erc20) .build(); + const multisigTransfer = { + ...erc20TransferBuilder() + .with('executionDate', multisigExecutionDate) + .with('from', safe.address) + .with('tokenAddress', multisigToken.address) + .build(), + tokenInfo: multisigToken, + }; + const multisigTransaction = { + ...(multisigTransactionToJson( + multisigTransactionBuilder() + .with('executionDate', multisigExecutionDate) + .with('safe', safe.address) + .with('to', multisigToken.address) + .with('value', '0') + .with('data', '0x') // TODO: Use encoder + .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 - this could remove dataDecodedParamHelper.getFromParam/getToParam? + transfers: [erc20TransferToJson(multisigTransfer) as Transfer], + } as MultisigTransaction; - const results = [ - { - executionDate: '2024-03-20T09:42:58Z', - to: '0x0e74DE9501F54610169EDB5D6CC6b559d403D4B7', - data: '0x12514bba00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000010000000000000000000000000cdb94376e0330b13f5becaece169602cbb14399c000000000000000000000000a52cd97c022e5373ee305010ff2263d29bb87a7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000009a6de84bf23ed9ba92bdb8027037975ef181b1c4000000000000000000000000345e400b58fbc0f9bc0eb176b6a125f35056ac300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fd737d98d9f6b566cc104fd40aecc449b8eaa5120000000000000000000000001b4b73713ada8a6f864b58d0dd6099ca54e59aa30000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000878678326eac90000000000000000000000000000000000000000000000000000000000000001ed02f00000000000000000000000000000000000000000000000000000000000000000', - txHash: - '0xf6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb4', - blockNumber: 192295013, - transfers: [ - { - type: 'ERC20_TRANSFER', - executionDate: '2024-03-20T09:42:58Z', - blockNumber: 192295013, - transactionHash: - '0xf6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb4', - to: '0xFd737d98d9F6b566cc104Fd40aEcC449b8EaA512', - value: '40000000000000000000000', - tokenId: null, - tokenAddress: '0xcDB94376E0330B13F5Becaece169602cbB14399c', - transferId: - 'ef6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb44', - tokenInfo: { - type: 'ERC20', - address: '0xcDB94376E0330B13F5Becaece169602cbB14399c', - name: 'Arbitrum', - symbol: 'ARB', - decimals: 18, - logoUri: - 'https://safe-transaction-assets.safe.global/tokens/logos/0xcDB94376E0330B13F5Becaece169602cbB14399c.png', - trusted: true, - }, - from: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', - }, - ], - txType: 'ETHEREUM_TRANSACTION', - from: '0xA504C7e72AD25927EbFA6ea14aD5EA56fb0aB64a', - }, - { - safe: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', - to: '0x912CE59144191C1204E64559FE8253a0e49E6548', - value: '0', - data: '0xa9059cbb000000000000000000000000fd7e78798f312a29bb03133de9d26e151d3aa512000000000000000000000000000000000000000000000878678326eac9000000', - operation: 0, - gasToken: '0x0000000000000000000000000000000000000000', - safeTxGas: 0, - baseGas: 0, - gasPrice: '0', - refundReceiver: '0x0000000000000000000000000000000000000000', - nonce: 3, - executionDate: '2024-03-20T09:41:25Z', - submissionDate: '2024-03-20T09:38:11.447366Z', - modified: '2024-03-20T09:41:25Z', - blockNumber: 192294646, - transactionHash: - '0x7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f371817813', - safeTxHash: - '0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', - proposer: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - executor: '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', - isExecuted: true, - isSuccessful: true, - ethGasPrice: '10946000', - maxFeePerGas: null, - maxPriorityFeePerGas: null, - gasUsed: 249105, - fee: '2726703330000', - origin: '{}', - dataDecoded: { - method: 'transfer', - parameters: [ - { - name: 'to', - type: 'address', - value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - }, - { - name: 'value', - type: 'uint256', - value: '40000000000000000000000', - }, - ], - }, - confirmationsRequired: 2, - confirmations: [ - { - owner: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - submissionDate: '2024-03-20T09:38:11.479197Z', - transactionHash: null, - signature: - '0x552b4bfaf92e7486785f6f922975e131f244152613486f2567112913a910047f14a5f5ce410d39192d0fbc7df1d9dc43e7c11b64510d44151dd2712be14665eb1c', - signatureType: 'EOA', - }, - { - owner: '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', - submissionDate: '2024-03-20T09:41:25Z', - transactionHash: null, - signature: - '0x000000000000000000000000be7d3f723d069a941228e44e222b37fbce0731ce000000000000000000000000000000000000000000000000000000000000000001', - signatureType: 'APPROVED_HASH', - }, - ], - trusted: true, - signatures: - '0x000000000000000000000000be7d3f723d069a941228e44e222b37fbce0731ce000000000000000000000000000000000000000000000000000000000000000001552b4bfaf92e7486785f6f922975e131f244152613486f2567112913a910047f14a5f5ce410d39192d0fbc7df1d9dc43e7c11b64510d44151dd2712be14665eb1c', - transfers: [ - { - type: 'ERC20_TRANSFER', - executionDate: '2024-03-20T09:41:25Z', - blockNumber: 192294646, - transactionHash: - '0x7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f371817813', - to: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - value: '40000000000000000000000', - tokenId: null, - tokenAddress: '0x912CE59144191C1204E64559FE8253a0e49E6548', - transferId: - 'e7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f3718178133', - tokenInfo: { - type: 'ERC20', - address: '0x912CE59144191C1204E64559FE8253a0e49E6548', - name: 'Arbitrum', - symbol: 'ARB', - decimals: 18, - logoUri: - 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', - trusted: true, - }, - from: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', - }, - ], - txType: 'MULTISIG_TRANSACTION', - }, - ]; + // TODO: Value and recipient + const imitationAddress = getAddress( + multisigTransfer.to.slice(0, 5) + + faker.finance.ethereumAddress().slice(5, -4) + + multisigTransfer.to.slice(-4), + ); + const imitationExecutionDate = new Date('2024-03-20T09:42:58Z'); + const imitationErc20Transfer = erc20TransferEncoder() + .with('to', imitationAddress) + .with('value', BigInt(multisigTransfer.value)); + const imitationToken = tokenBuilder() + .with('trusted', true) + .with('type', TokenType.Erc20) + .build(); + const imitationTransfer = { + ...erc20TransferBuilder() + .with('from', safe.address) + .with('to', imitationAddress) + .with('tokenAddress', imitationToken.address) + .with('value', multisigTransfer.value) + .with('executionDate', imitationExecutionDate) + .build(), + // TODO: Update type to include tokenInfo + tokenInfo: imitationToken, + }; + const imitationTransaction = ethereumTransactionToJson( + ethereumTransactionBuilder() + .with('executionDate', imitationTransfer.executionDate) + .with('data', imitationErc20Transfer.encode()) + .with('transfers', [ + erc20TransferToJson(imitationTransfer) as Transfer, + ]) + .build(), + ) as EthereumTransaction; + const results = [imitationTransaction, multisigTransaction]; const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chain.chainId}`; const getAllTransactionsUrl = `${chain.transactionService}/api/v1/safes/${safe.address}/all-transactions/`; const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safe.address}`; + // @ts-expect-error - Type does not contain transfers const getImitationTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[0].transfers[0].tokenAddress}`; + // @ts-expect-error - Type does not contain transfers const getTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[1].transfers[0].tokenAddress}`; networkService.get.mockImplementation(({ url }) => { if (url === getChainUrl) { @@ -1521,13 +1481,13 @@ describe('Transactions History Controller (Unit)', () => { } if (url === getImitationTokenAddressUrl) { return Promise.resolve({ - data: results[0].transfers[0].tokenInfo, + data: imitationToken, status: 200, }); } if (url === getTokenAddressUrl) { return Promise.resolve({ - data: results[1].transfers[0].tokenInfo, + data: multisigToken, status: 200, }); } @@ -1549,7 +1509,8 @@ describe('Transactions History Controller (Unit)', () => { conflictType: 'None', transaction: { executionInfo: null, - id: 'transfer_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_ef6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb44', + // @ts-expect-error - Type does not contain transfers + id: `transfer_${safe.address}_${results[0].transfers[0].transferId}`, safeAppInfo: null, timestamp: 1710927778000, txInfo: { @@ -1558,26 +1519,24 @@ describe('Transactions History Controller (Unit)', () => { recipient: { logoUri: null, name: null, - value: '0xFd737d98d9F6b566cc104Fd40aEcC449b8EaA512', + value: imitationAddress, }, richDecodedInfo: null, sender: { logoUri: null, name: null, - value: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + value: safe.address, }, transferInfo: { - decimals: 18, + decimals: imitationToken.decimals, imitation: true, - logoUri: - 'https://safe-transaction-assets.safe.global/tokens/logos/0xcDB94376E0330B13F5Becaece169602cbB14399c.png', - tokenAddress: - '0xcDB94376E0330B13F5Becaece169602cbB14399c', - tokenName: 'Arbitrum', - tokenSymbol: 'ARB', - trusted: true, + logoUri: imitationToken.logoUri, + tokenAddress: imitationToken.address, + tokenName: imitationToken.name, + tokenSymbol: imitationToken.symbol, + trusted: imitationToken.trusted, type: 'ERC20', - value: '40000000000000000000000', + value: multisigTransfer.value, }, type: 'Transfer', }, @@ -1589,63 +1548,39 @@ describe('Transactions History Controller (Unit)', () => { conflictType: 'None', transaction: { executionInfo: { - confirmationsRequired: 2, - confirmationsSubmitted: 2, + confirmationsRequired: 1, + confirmationsSubmitted: 1, missingSigners: null, - nonce: 3, + nonce: multisigTransaction.nonce, type: 'MULTISIG', }, - id: 'multisig_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', + id: `multisig_${safe.address}_${multisigTransaction.safeTxHash}`, safeAppInfo: null, timestamp: 1710927685000, txInfo: { direction: 'OUTGOING', - humanDescription: 'Send 40000 ARB to 0xFd7e...A512', + humanDescription: null, recipient: { logoUri: null, name: null, - value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - }, - richDecodedInfo: { - fragments: [ - { - type: 'text', - value: 'Send', - }, - { - logoUri: - 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', - symbol: 'ARB', - type: 'tokenValue', - value: '40000', - }, - { - type: 'text', - value: 'to', - }, - { - type: 'address', - value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - }, - ], + value: multisigTransfer.to, }, + richDecodedInfo: null, sender: { logoUri: null, name: null, - value: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + value: safe.address, }, transferInfo: { - decimals: 18, + decimals: multisigToken.decimals, imitation: null, - logoUri: - 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', - tokenAddress: - '0x912CE59144191C1204E64559FE8253a0e49E6548', - tokenName: 'Arbitrum', - tokenSymbol: 'ARB', + logoUri: multisigToken.logoUri, + tokenAddress: multisigToken.address, + tokenName: multisigToken.name, + tokenSymbol: multisigToken.symbol, trusted: null, type: 'ERC20', - value: '40000000000000000000000', + value: multisigTransfer.value, }, type: 'Transfer', }, @@ -1658,154 +1593,112 @@ describe('Transactions History Controller (Unit)', () => { }); it('should filter out outgoing ERC-20 transfers that imitate a direct predecessor', async () => { - // Example taken from arb1:0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4 - marked as trusted const chain = chainBuilder().build(); - const safe = safeBuilder() - .with('address', '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4') - .with('owners', [ - '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', - ]) + const safe = safeBuilder().build(); + + const multisigExecutionDate = new Date('2024-03-20T09:41:25Z'); + const multisigToken = tokenBuilder() + .with('trusted', true) + .with('type', TokenType.Erc20) .build(); + const multisigTransfer = { + ...erc20TransferBuilder() + .with('executionDate', multisigExecutionDate) + .with('from', safe.address) + .with('tokenAddress', multisigToken.address) + .build(), + tokenInfo: multisigToken, + }; + const multisigTransaction = { + ...(multisigTransactionToJson( + multisigTransactionBuilder() + .with('executionDate', multisigExecutionDate) + .with('safe', safe.address) + .with('to', multisigToken.address) + .with('value', '0') + .with('data', '0x') // TODO: Use encoder + .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 - this could remove dataDecodedParamHelper.getFromParam/getToParam? + transfers: [erc20TransferToJson(multisigTransfer) as Transfer], + } as MultisigTransaction; - const results = [ - { - executionDate: '2024-03-20T09:42:58Z', - to: '0x0e74DE9501F54610169EDB5D6CC6b559d403D4B7', - data: '0x12514bba00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000010000000000000000000000000cdb94376e0330b13f5becaece169602cbb14399c000000000000000000000000a52cd97c022e5373ee305010ff2263d29bb87a7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000009a6de84bf23ed9ba92bdb8027037975ef181b1c4000000000000000000000000345e400b58fbc0f9bc0eb176b6a125f35056ac300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fd737d98d9f6b566cc104fd40aecc449b8eaa5120000000000000000000000001b4b73713ada8a6f864b58d0dd6099ca54e59aa30000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000878678326eac90000000000000000000000000000000000000000000000000000000000000001ed02f00000000000000000000000000000000000000000000000000000000000000000', - txHash: - '0xf6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb4', - blockNumber: 192295013, - transfers: [ - { - type: 'ERC20_TRANSFER', - executionDate: '2024-03-20T09:42:58Z', - blockNumber: 192295013, - transactionHash: - '0xf6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb4', - to: '0xFd737d98d9F6b566cc104Fd40aEcC449b8EaA512', - value: '40000000000000000000000', - tokenId: null, - tokenAddress: '0xcDB94376E0330B13F5Becaece169602cbB14399c', - transferId: - 'ef6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb44', - tokenInfo: { - type: 'ERC20', - address: '0xcDB94376E0330B13F5Becaece169602cbB14399c', - name: 'Arbitrum', - symbol: 'ARB', - decimals: 18, - logoUri: - 'https://safe-transaction-assets.safe.global/tokens/logos/0xcDB94376E0330B13F5Becaece169602cbB14399c.png', - trusted: true, - }, - from: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', - }, - ], - txType: 'ETHEREUM_TRANSACTION', - from: '0xA504C7e72AD25927EbFA6ea14aD5EA56fb0aB64a', - }, - { - safe: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', - to: '0x912CE59144191C1204E64559FE8253a0e49E6548', - value: '0', - data: '0xa9059cbb000000000000000000000000fd7e78798f312a29bb03133de9d26e151d3aa512000000000000000000000000000000000000000000000878678326eac9000000', - operation: 0, - gasToken: '0x0000000000000000000000000000000000000000', - safeTxGas: 0, - baseGas: 0, - gasPrice: '0', - refundReceiver: '0x0000000000000000000000000000000000000000', - nonce: 3, - executionDate: '2024-03-20T09:41:25Z', - submissionDate: '2024-03-20T09:38:11.447366Z', - modified: '2024-03-20T09:41:25Z', - blockNumber: 192294646, - transactionHash: - '0x7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f371817813', - safeTxHash: - '0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', - proposer: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - executor: '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', - isExecuted: true, - isSuccessful: true, - ethGasPrice: '10946000', - maxFeePerGas: null, - maxPriorityFeePerGas: null, - gasUsed: 249105, - fee: '2726703330000', - origin: '{}', - dataDecoded: { - method: 'transfer', - parameters: [ - { - name: 'to', - type: 'address', - value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - }, - { - name: 'value', - type: 'uint256', - value: '40000000000000000000000', - }, - ], - }, - confirmationsRequired: 2, - confirmations: [ - { - owner: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - submissionDate: '2024-03-20T09:38:11.479197Z', - transactionHash: null, - signature: - '0x552b4bfaf92e7486785f6f922975e131f244152613486f2567112913a910047f14a5f5ce410d39192d0fbc7df1d9dc43e7c11b64510d44151dd2712be14665eb1c', - signatureType: 'EOA', - }, - { - owner: '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', - submissionDate: '2024-03-20T09:41:25Z', - transactionHash: null, - signature: - '0x000000000000000000000000be7d3f723d069a941228e44e222b37fbce0731ce000000000000000000000000000000000000000000000000000000000000000001', - signatureType: 'APPROVED_HASH', - }, - ], - trusted: true, - signatures: - '0x000000000000000000000000be7d3f723d069a941228e44e222b37fbce0731ce000000000000000000000000000000000000000000000000000000000000000001552b4bfaf92e7486785f6f922975e131f244152613486f2567112913a910047f14a5f5ce410d39192d0fbc7df1d9dc43e7c11b64510d44151dd2712be14665eb1c', - transfers: [ - { - type: 'ERC20_TRANSFER', - executionDate: '2024-03-20T09:41:25Z', - blockNumber: 192294646, - transactionHash: - '0x7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f371817813', - to: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - value: '40000000000000000000000', - tokenId: null, - tokenAddress: '0x912CE59144191C1204E64559FE8253a0e49E6548', - transferId: - 'e7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f3718178133', - tokenInfo: { - type: 'ERC20', - address: '0x912CE59144191C1204E64559FE8253a0e49E6548', - name: 'Arbitrum', - symbol: 'ARB', - decimals: 18, - logoUri: - 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', - trusted: true, - }, - from: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', - }, - ], - txType: 'MULTISIG_TRANSACTION', - }, - ]; + // TODO: Value and recipient + const imitationAddress = getAddress( + multisigTransfer.to.slice(0, 5) + + faker.finance.ethereumAddress().slice(5, -4) + + multisigTransfer.to.slice(-4), + ); + const imitationExecutionDate = new Date('2024-03-20T09:42:58Z'); + const imitationErc20Transfer = erc20TransferEncoder() + .with('to', imitationAddress) + .with('value', BigInt(multisigTransfer.value)); + const imitationToken = tokenBuilder() + .with('trusted', true) + .with('type', TokenType.Erc20) + .build(); + const imitationTransfer = { + ...erc20TransferBuilder() + .with('from', safe.address) + .with('to', imitationAddress) + .with('tokenAddress', imitationToken.address) + .with('value', multisigTransfer.value) + .with('executionDate', imitationExecutionDate) + .build(), + // TODO: Update type to include tokenInfo + tokenInfo: imitationToken, + }; + const imitationTransaction = ethereumTransactionToJson( + ethereumTransactionBuilder() + .with('executionDate', imitationTransfer.executionDate) + .with('data', imitationErc20Transfer.encode()) + .with('transfers', [ + erc20TransferToJson(imitationTransfer) as Transfer, + ]) + .build(), + ) as EthereumTransaction; + const results = [imitationTransaction, multisigTransaction]; const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chain.chainId}`; const getAllTransactionsUrl = `${chain.transactionService}/api/v1/safes/${safe.address}/all-transactions/`; const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safe.address}`; + // @ts-expect-error - Type does not contain transfers const getImitationTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[0].transfers[0].tokenAddress}`; + // @ts-expect-error - Type does not contain transfers const getTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[1].transfers[0].tokenAddress}`; networkService.get.mockImplementation(({ url }) => { if (url === getChainUrl) { @@ -1822,13 +1715,13 @@ describe('Transactions History Controller (Unit)', () => { } if (url === getImitationTokenAddressUrl) { return Promise.resolve({ - data: results[0].transfers[0].tokenInfo, + data: imitationToken, status: 200, }); } if (url === getTokenAddressUrl) { return Promise.resolve({ - data: results[1].transfers[0].tokenInfo, + data: multisigToken, status: 200, }); } @@ -1850,63 +1743,39 @@ describe('Transactions History Controller (Unit)', () => { conflictType: 'None', transaction: { executionInfo: { - confirmationsRequired: 2, - confirmationsSubmitted: 2, + confirmationsRequired: 1, + confirmationsSubmitted: 1, missingSigners: null, - nonce: 3, + nonce: multisigTransaction.nonce, type: 'MULTISIG', }, - id: 'multisig_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', + id: `multisig_${safe.address}_${multisigTransaction.safeTxHash}`, safeAppInfo: null, timestamp: 1710927685000, txInfo: { direction: 'OUTGOING', - humanDescription: 'Send 40000 ARB to 0xFd7e...A512', + humanDescription: null, recipient: { logoUri: null, name: null, - value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - }, - richDecodedInfo: { - fragments: [ - { - type: 'text', - value: 'Send', - }, - { - logoUri: - 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', - symbol: 'ARB', - type: 'tokenValue', - value: '40000', - }, - { - type: 'text', - value: 'to', - }, - { - type: 'address', - value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - }, - ], + value: multisigTransfer.to, }, + richDecodedInfo: null, sender: { logoUri: null, name: null, - value: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + value: safe.address, }, transferInfo: { - decimals: 18, + decimals: multisigToken.decimals, imitation: null, - logoUri: - 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', - tokenAddress: - '0x912CE59144191C1204E64559FE8253a0e49E6548', - tokenName: 'Arbitrum', - tokenSymbol: 'ARB', + logoUri: multisigToken.logoUri, + tokenAddress: multisigToken.address, + tokenName: multisigToken.name, + tokenSymbol: multisigToken.symbol, trusted: null, type: 'ERC20', - value: '40000000000000000000000', + value: multisigTransfer.value, }, type: 'Transfer', }, @@ -1921,154 +1790,112 @@ describe('Transactions History Controller (Unit)', () => { describe('Non-trusted tokens', () => { it('should flag outgoing ERC-20 transfers that imitate a direct predecessor', async () => { - // Example taken from arb1:0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4 const chain = chainBuilder().build(); - const safe = safeBuilder() - .with('address', '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4') - .with('owners', [ - '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', - ]) + const safe = safeBuilder().build(); + + const multisigExecutionDate = new Date('2024-03-20T09:41:25Z'); + const multisigToken = tokenBuilder() + .with('trusted', false) + .with('type', TokenType.Erc20) .build(); + const multisigTransfer = { + ...erc20TransferBuilder() + .with('executionDate', multisigExecutionDate) + .with('from', safe.address) + .with('tokenAddress', multisigToken.address) + .build(), + tokenInfo: multisigToken, + }; + const multisigTransaction = { + ...(multisigTransactionToJson( + multisigTransactionBuilder() + .with('executionDate', multisigExecutionDate) + .with('safe', safe.address) + .with('to', multisigToken.address) + .with('value', '0') + .with('data', '0x') // TODO: Use encoder + .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 - this could remove dataDecodedParamHelper.getFromParam/getToParam? + transfers: [erc20TransferToJson(multisigTransfer) as Transfer], + } as MultisigTransaction; - const results = [ - { - executionDate: '2024-03-20T09:42:58Z', - to: '0x0e74DE9501F54610169EDB5D6CC6b559d403D4B7', - data: '0x12514bba00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000010000000000000000000000000cdb94376e0330b13f5becaece169602cbb14399c000000000000000000000000a52cd97c022e5373ee305010ff2263d29bb87a7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000009a6de84bf23ed9ba92bdb8027037975ef181b1c4000000000000000000000000345e400b58fbc0f9bc0eb176b6a125f35056ac300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fd737d98d9f6b566cc104fd40aecc449b8eaa5120000000000000000000000001b4b73713ada8a6f864b58d0dd6099ca54e59aa30000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000878678326eac90000000000000000000000000000000000000000000000000000000000000001ed02f00000000000000000000000000000000000000000000000000000000000000000', - txHash: - '0xf6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb4', - blockNumber: 192295013, - transfers: [ - { - type: 'ERC20_TRANSFER', - executionDate: '2024-03-20T09:42:58Z', - blockNumber: 192295013, - transactionHash: - '0xf6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb4', - to: '0xFd737d98d9F6b566cc104Fd40aEcC449b8EaA512', - value: '40000000000000000000000', - tokenId: null, - tokenAddress: '0xcDB94376E0330B13F5Becaece169602cbB14399c', - transferId: - 'ef6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb44', - tokenInfo: { - type: 'ERC20', - address: '0xcDB94376E0330B13F5Becaece169602cbB14399c', - name: 'Arbitrum', - symbol: 'ARB', - decimals: 18, - logoUri: - 'https://safe-transaction-assets.safe.global/tokens/logos/0xcDB94376E0330B13F5Becaece169602cbB14399c.png', - trusted: false, - }, - from: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', - }, - ], - txType: 'ETHEREUM_TRANSACTION', - from: '0xA504C7e72AD25927EbFA6ea14aD5EA56fb0aB64a', - }, - { - safe: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', - to: '0x912CE59144191C1204E64559FE8253a0e49E6548', - value: '0', - data: '0xa9059cbb000000000000000000000000fd7e78798f312a29bb03133de9d26e151d3aa512000000000000000000000000000000000000000000000878678326eac9000000', - operation: 0, - gasToken: '0x0000000000000000000000000000000000000000', - safeTxGas: 0, - baseGas: 0, - gasPrice: '0', - refundReceiver: '0x0000000000000000000000000000000000000000', - nonce: 3, - executionDate: '2024-03-20T09:41:25Z', - submissionDate: '2024-03-20T09:38:11.447366Z', - modified: '2024-03-20T09:41:25Z', - blockNumber: 192294646, - transactionHash: - '0x7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f371817813', - safeTxHash: - '0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', - proposer: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - executor: '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', - isExecuted: true, - isSuccessful: true, - ethGasPrice: '10946000', - maxFeePerGas: null, - maxPriorityFeePerGas: null, - gasUsed: 249105, - fee: '2726703330000', - origin: '{}', - dataDecoded: { - method: 'transfer', - parameters: [ - { - name: 'to', - type: 'address', - value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - }, - { - name: 'value', - type: 'uint256', - value: '40000000000000000000000', - }, - ], - }, - confirmationsRequired: 2, - confirmations: [ - { - owner: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - submissionDate: '2024-03-20T09:38:11.479197Z', - transactionHash: null, - signature: - '0x552b4bfaf92e7486785f6f922975e131f244152613486f2567112913a910047f14a5f5ce410d39192d0fbc7df1d9dc43e7c11b64510d44151dd2712be14665eb1c', - signatureType: 'EOA', - }, - { - owner: '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', - submissionDate: '2024-03-20T09:41:25Z', - transactionHash: null, - signature: - '0x000000000000000000000000be7d3f723d069a941228e44e222b37fbce0731ce000000000000000000000000000000000000000000000000000000000000000001', - signatureType: 'APPROVED_HASH', - }, - ], - trusted: true, - signatures: - '0x000000000000000000000000be7d3f723d069a941228e44e222b37fbce0731ce000000000000000000000000000000000000000000000000000000000000000001552b4bfaf92e7486785f6f922975e131f244152613486f2567112913a910047f14a5f5ce410d39192d0fbc7df1d9dc43e7c11b64510d44151dd2712be14665eb1c', - transfers: [ - { - type: 'ERC20_TRANSFER', - executionDate: '2024-03-20T09:41:25Z', - blockNumber: 192294646, - transactionHash: - '0x7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f371817813', - to: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - value: '40000000000000000000000', - tokenId: null, - tokenAddress: '0x912CE59144191C1204E64559FE8253a0e49E6548', - transferId: - 'e7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f3718178133', - tokenInfo: { - type: 'ERC20', - address: '0x912CE59144191C1204E64559FE8253a0e49E6548', - name: 'Arbitrum', - symbol: 'ARB', - decimals: 18, - logoUri: - 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', - trusted: false, - }, - from: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', - }, - ], - txType: 'MULTISIG_TRANSACTION', - }, - ]; + // TODO: Value and recipient + const imitationAddress = getAddress( + multisigTransfer.to.slice(0, 5) + + faker.finance.ethereumAddress().slice(5, -4) + + multisigTransfer.to.slice(-4), + ); + const imitationExecutionDate = new Date('2024-03-20T09:42:58Z'); + const imitationErc20Transfer = erc20TransferEncoder() + .with('to', imitationAddress) + .with('value', BigInt(multisigTransfer.value)); + const imitationToken = tokenBuilder() + .with('trusted', false) + .with('type', TokenType.Erc20) + .build(); + const imitationTransfer = { + ...erc20TransferBuilder() + .with('from', safe.address) + .with('to', imitationAddress) + .with('tokenAddress', imitationToken.address) + .with('value', multisigTransfer.value) + .with('executionDate', imitationExecutionDate) + .build(), + // TODO: Update type to include tokenInfo + tokenInfo: imitationToken, + }; + const imitationTransaction = ethereumTransactionToJson( + ethereumTransactionBuilder() + .with('executionDate', imitationTransfer.executionDate) + .with('data', imitationErc20Transfer.encode()) + .with('transfers', [ + erc20TransferToJson(imitationTransfer) as Transfer, + ]) + .build(), + ) as EthereumTransaction; + const results = [imitationTransaction, multisigTransaction]; const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chain.chainId}`; const getAllTransactionsUrl = `${chain.transactionService}/api/v1/safes/${safe.address}/all-transactions/`; const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safe.address}`; + // @ts-expect-error - Type does not contain transfers const getImitationTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[0].transfers[0].tokenAddress}`; + // @ts-expect-error - Type does not contain transfers const getTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[1].transfers[0].tokenAddress}`; networkService.get.mockImplementation(({ url }) => { if (url === getChainUrl) { @@ -2085,13 +1912,13 @@ describe('Transactions History Controller (Unit)', () => { } if (url === getImitationTokenAddressUrl) { return Promise.resolve({ - data: results[0].transfers[0].tokenInfo, + data: imitationToken, status: 200, }); } if (url === getTokenAddressUrl) { return Promise.resolve({ - data: results[1].transfers[0].tokenInfo, + data: multisigToken, status: 200, }); } @@ -2113,7 +1940,8 @@ describe('Transactions History Controller (Unit)', () => { conflictType: 'None', transaction: { executionInfo: null, - id: 'transfer_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_ef6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb44', + // @ts-expect-error - Type does not contain transfers + id: `transfer_${safe.address}_${results[0].transfers[0].transferId}`, safeAppInfo: null, timestamp: 1710927778000, txInfo: { @@ -2122,26 +1950,24 @@ describe('Transactions History Controller (Unit)', () => { recipient: { logoUri: null, name: null, - value: '0xFd737d98d9F6b566cc104Fd40aEcC449b8EaA512', + value: imitationAddress, }, richDecodedInfo: null, sender: { logoUri: null, name: null, - value: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + value: safe.address, }, transferInfo: { - decimals: 18, + decimals: imitationToken.decimals, imitation: true, - logoUri: - 'https://safe-transaction-assets.safe.global/tokens/logos/0xcDB94376E0330B13F5Becaece169602cbB14399c.png', - tokenAddress: - '0xcDB94376E0330B13F5Becaece169602cbB14399c', - tokenName: 'Arbitrum', - tokenSymbol: 'ARB', - trusted: false, + logoUri: imitationToken.logoUri, + tokenAddress: imitationToken.address, + tokenName: imitationToken.name, + tokenSymbol: imitationToken.symbol, + trusted: imitationToken.trusted, type: 'ERC20', - value: '40000000000000000000000', + value: multisigTransfer.value, }, type: 'Transfer', }, @@ -2153,63 +1979,39 @@ describe('Transactions History Controller (Unit)', () => { conflictType: 'None', transaction: { executionInfo: { - confirmationsRequired: 2, - confirmationsSubmitted: 2, + confirmationsRequired: 1, + confirmationsSubmitted: 1, missingSigners: null, - nonce: 3, + nonce: multisigTransaction.nonce, type: 'MULTISIG', }, - id: 'multisig_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', + id: `multisig_${safe.address}_${multisigTransaction.safeTxHash}`, safeAppInfo: null, timestamp: 1710927685000, txInfo: { direction: 'OUTGOING', - humanDescription: 'Send 40000 ARB to 0xFd7e...A512', + humanDescription: null, recipient: { logoUri: null, name: null, - value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - }, - richDecodedInfo: { - fragments: [ - { - type: 'text', - value: 'Send', - }, - { - logoUri: - 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', - symbol: 'ARB', - type: 'tokenValue', - value: '40000', - }, - { - type: 'text', - value: 'to', - }, - { - type: 'address', - value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - }, - ], + value: multisigTransfer.to, }, + richDecodedInfo: null, sender: { logoUri: null, name: null, - value: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + value: safe.address, }, transferInfo: { - decimals: 18, + decimals: multisigToken.decimals, imitation: null, - logoUri: - 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', - tokenAddress: - '0x912CE59144191C1204E64559FE8253a0e49E6548', - tokenName: 'Arbitrum', - tokenSymbol: 'ARB', + logoUri: multisigToken.logoUri, + tokenAddress: multisigToken.address, + tokenName: multisigToken.name, + tokenSymbol: multisigToken.symbol, trusted: null, type: 'ERC20', - value: '40000000000000000000000', + value: multisigTransfer.value, }, type: 'Transfer', }, @@ -2222,154 +2024,112 @@ describe('Transactions History Controller (Unit)', () => { }); it('should filter out outgoing ERC-20 transfers that imitate a direct predecessor', async () => { - // Example taken from arb1:0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4 const chain = chainBuilder().build(); - const safe = safeBuilder() - .with('address', '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4') - .with('owners', [ - '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', - ]) + const safe = safeBuilder().build(); + + const multisigExecutionDate = new Date('2024-03-20T09:41:25Z'); + const multisigToken = tokenBuilder() + .with('trusted', false) + .with('type', TokenType.Erc20) .build(); + const multisigTransfer = { + ...erc20TransferBuilder() + .with('executionDate', multisigExecutionDate) + .with('from', safe.address) + .with('tokenAddress', multisigToken.address) + .build(), + tokenInfo: multisigToken, + }; + const multisigTransaction = { + ...(multisigTransactionToJson( + multisigTransactionBuilder() + .with('executionDate', multisigExecutionDate) + .with('safe', safe.address) + .with('to', multisigToken.address) + .with('value', '0') + .with('data', '0x') // TODO: Use encoder + .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 - this could remove dataDecodedParamHelper.getFromParam/getToParam? + transfers: [erc20TransferToJson(multisigTransfer) as Transfer], + } as MultisigTransaction; - const results = [ - { - executionDate: '2024-03-20T09:42:58Z', - to: '0x0e74DE9501F54610169EDB5D6CC6b559d403D4B7', - data: '0x12514bba00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000010000000000000000000000000cdb94376e0330b13f5becaece169602cbb14399c000000000000000000000000a52cd97c022e5373ee305010ff2263d29bb87a7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000009a6de84bf23ed9ba92bdb8027037975ef181b1c4000000000000000000000000345e400b58fbc0f9bc0eb176b6a125f35056ac300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000fd737d98d9f6b566cc104fd40aecc449b8eaa5120000000000000000000000001b4b73713ada8a6f864b58d0dd6099ca54e59aa30000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000878678326eac90000000000000000000000000000000000000000000000000000000000000001ed02f00000000000000000000000000000000000000000000000000000000000000000', - txHash: - '0xf6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb4', - blockNumber: 192295013, - transfers: [ - { - type: 'ERC20_TRANSFER', - executionDate: '2024-03-20T09:42:58Z', - blockNumber: 192295013, - transactionHash: - '0xf6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb4', - to: '0xFd737d98d9F6b566cc104Fd40aEcC449b8EaA512', - value: '40000000000000000000000', - tokenId: null, - tokenAddress: '0xcDB94376E0330B13F5Becaece169602cbB14399c', - transferId: - 'ef6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb44', - tokenInfo: { - type: 'ERC20', - address: '0xcDB94376E0330B13F5Becaece169602cbB14399c', - name: 'Arbitrum', - symbol: 'ARB', - decimals: 18, - logoUri: - 'https://safe-transaction-assets.safe.global/tokens/logos/0xcDB94376E0330B13F5Becaece169602cbB14399c.png', - trusted: false, - }, - from: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', - }, - ], - txType: 'ETHEREUM_TRANSACTION', - from: '0xA504C7e72AD25927EbFA6ea14aD5EA56fb0aB64a', - }, - { - safe: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', - to: '0x912CE59144191C1204E64559FE8253a0e49E6548', - value: '0', - data: '0xa9059cbb000000000000000000000000fd7e78798f312a29bb03133de9d26e151d3aa512000000000000000000000000000000000000000000000878678326eac9000000', - operation: 0, - gasToken: '0x0000000000000000000000000000000000000000', - safeTxGas: 0, - baseGas: 0, - gasPrice: '0', - refundReceiver: '0x0000000000000000000000000000000000000000', - nonce: 3, - executionDate: '2024-03-20T09:41:25Z', - submissionDate: '2024-03-20T09:38:11.447366Z', - modified: '2024-03-20T09:41:25Z', - blockNumber: 192294646, - transactionHash: - '0x7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f371817813', - safeTxHash: - '0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', - proposer: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - executor: '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', - isExecuted: true, - isSuccessful: true, - ethGasPrice: '10946000', - maxFeePerGas: null, - maxPriorityFeePerGas: null, - gasUsed: 249105, - fee: '2726703330000', - origin: '{}', - dataDecoded: { - method: 'transfer', - parameters: [ - { - name: 'to', - type: 'address', - value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - }, - { - name: 'value', - type: 'uint256', - value: '40000000000000000000000', - }, - ], - }, - confirmationsRequired: 2, - confirmations: [ - { - owner: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - submissionDate: '2024-03-20T09:38:11.479197Z', - transactionHash: null, - signature: - '0x552b4bfaf92e7486785f6f922975e131f244152613486f2567112913a910047f14a5f5ce410d39192d0fbc7df1d9dc43e7c11b64510d44151dd2712be14665eb1c', - signatureType: 'EOA', - }, - { - owner: '0xBE7d3f723d069a941228e44e222b37fBCe0731ce', - submissionDate: '2024-03-20T09:41:25Z', - transactionHash: null, - signature: - '0x000000000000000000000000be7d3f723d069a941228e44e222b37fbce0731ce000000000000000000000000000000000000000000000000000000000000000001', - signatureType: 'APPROVED_HASH', - }, - ], - trusted: true, - signatures: - '0x000000000000000000000000be7d3f723d069a941228e44e222b37fbce0731ce000000000000000000000000000000000000000000000000000000000000000001552b4bfaf92e7486785f6f922975e131f244152613486f2567112913a910047f14a5f5ce410d39192d0fbc7df1d9dc43e7c11b64510d44151dd2712be14665eb1c', - transfers: [ - { - type: 'ERC20_TRANSFER', - executionDate: '2024-03-20T09:41:25Z', - blockNumber: 192294646, - transactionHash: - '0x7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f371817813', - to: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - value: '40000000000000000000000', - tokenId: null, - tokenAddress: '0x912CE59144191C1204E64559FE8253a0e49E6548', - transferId: - 'e7e60c76bb3b350dc552f3c261faf7dcdbfe141f7a740d9495efd49f3718178133', - tokenInfo: { - type: 'ERC20', - address: '0x912CE59144191C1204E64559FE8253a0e49E6548', - name: 'Arbitrum', - symbol: 'ARB', - decimals: 18, - logoUri: - 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', - trusted: false, - }, - from: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', - }, - ], - txType: 'MULTISIG_TRANSACTION', - }, - ]; + // TODO: Value and recipient + const imitationAddress = getAddress( + multisigTransfer.to.slice(0, 5) + + faker.finance.ethereumAddress().slice(5, -4) + + multisigTransfer.to.slice(-4), + ); + const imitationExecutionDate = new Date('2024-03-20T09:42:58Z'); + const imitationErc20Transfer = erc20TransferEncoder() + .with('to', imitationAddress) + .with('value', BigInt(multisigTransfer.value)); + const imitationToken = tokenBuilder() + .with('trusted', false) + .with('type', TokenType.Erc20) + .build(); + const imitationTransfer = { + ...erc20TransferBuilder() + .with('from', safe.address) + .with('to', imitationAddress) + .with('tokenAddress', imitationToken.address) + .with('value', multisigTransfer.value) + .with('executionDate', imitationExecutionDate) + .build(), + // TODO: Update type to include tokenInfo + tokenInfo: imitationToken, + }; + const imitationTransaction = ethereumTransactionToJson( + ethereumTransactionBuilder() + .with('executionDate', imitationTransfer.executionDate) + .with('data', imitationErc20Transfer.encode()) + .with('transfers', [ + erc20TransferToJson(imitationTransfer) as Transfer, + ]) + .build(), + ) as EthereumTransaction; + const results = [imitationTransaction, multisigTransaction]; const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chain.chainId}`; const getAllTransactionsUrl = `${chain.transactionService}/api/v1/safes/${safe.address}/all-transactions/`; const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safe.address}`; + // @ts-expect-error - Type does not contain transfers const getImitationTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[0].transfers[0].tokenAddress}`; + // @ts-expect-error - Type does not contain transfers const getTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[1].transfers[0].tokenAddress}`; networkService.get.mockImplementation(({ url }) => { if (url === getChainUrl) { @@ -2386,13 +2146,13 @@ describe('Transactions History Controller (Unit)', () => { } if (url === getImitationTokenAddressUrl) { return Promise.resolve({ - data: results[0].transfers[0].tokenInfo, + data: imitationToken, status: 200, }); } if (url === getTokenAddressUrl) { return Promise.resolve({ - data: results[1].transfers[0].tokenInfo, + data: multisigToken, status: 200, }); } @@ -2414,63 +2174,39 @@ describe('Transactions History Controller (Unit)', () => { conflictType: 'None', transaction: { executionInfo: { - confirmationsRequired: 2, - confirmationsSubmitted: 2, + confirmationsRequired: 1, + confirmationsSubmitted: 1, missingSigners: null, - nonce: 3, + nonce: multisigTransaction.nonce, type: 'MULTISIG', }, - id: 'multisig_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', + id: `multisig_${safe.address}_${multisigTransaction.safeTxHash}`, safeAppInfo: null, timestamp: 1710927685000, txInfo: { direction: 'OUTGOING', - humanDescription: 'Send 40000 ARB to 0xFd7e...A512', + humanDescription: null, recipient: { logoUri: null, name: null, - value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - }, - richDecodedInfo: { - fragments: [ - { - type: 'text', - value: 'Send', - }, - { - logoUri: - 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', - symbol: 'ARB', - type: 'tokenValue', - value: '40000', - }, - { - type: 'text', - value: 'to', - }, - { - type: 'address', - value: '0xFd7e78798f312A29bb03133de9D26E151D3aA512', - }, - ], + value: multisigTransfer.to, }, + richDecodedInfo: null, sender: { logoUri: null, name: null, - value: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + value: safe.address, }, transferInfo: { - decimals: 18, + decimals: multisigToken.decimals, imitation: null, - logoUri: - 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', - tokenAddress: - '0x912CE59144191C1204E64559FE8253a0e49E6548', - tokenName: 'Arbitrum', - tokenSymbol: 'ARB', + logoUri: multisigToken.logoUri, + tokenAddress: multisigToken.address, + tokenName: multisigToken.name, + tokenSymbol: multisigToken.symbol, trusted: null, type: 'ERC20', - value: '40000000000000000000000', + value: multisigTransfer.value, }, type: 'Transfer', }, From e1084ed083a1e1026540e0b06c8d195f47c7de60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Thu, 16 May 2024 16:04:57 +0200 Subject: [PATCH 003/207] Make Campaign.lastUpdated field optional (#1555) Changes Campaign.lastUpdated to optional. --- .../entities/__tests__/campaign.builder.ts | 2 +- .../locking/entities/campaign.entity.ts | 2 +- .../schemas/__tests__/campaign.schema.spec.ts | 37 +++++++++++++++---- .../entities/schemas/campaign.schema.ts | 12 ------ .../locking/entities/campaign.entity.ts | 6 +-- 5 files changed, 35 insertions(+), 24 deletions(-) delete mode 100644 src/domain/locking/entities/schemas/campaign.schema.ts diff --git a/src/domain/locking/entities/__tests__/campaign.builder.ts b/src/domain/locking/entities/__tests__/campaign.builder.ts index e192d278da..04eb3d543e 100644 --- a/src/domain/locking/entities/__tests__/campaign.builder.ts +++ b/src/domain/locking/entities/__tests__/campaign.builder.ts @@ -24,6 +24,6 @@ export function toJson(campaign: Campaign): unknown { ...campaign, startDate: campaign.startDate.toISOString(), endDate: campaign.endDate.toISOString(), - lastUpdated: campaign.lastUpdated.toISOString(), + lastUpdated: campaign.lastUpdated?.toISOString(), }; } diff --git a/src/domain/locking/entities/campaign.entity.ts b/src/domain/locking/entities/campaign.entity.ts index 5132f31644..52a02d54b0 100644 --- a/src/domain/locking/entities/campaign.entity.ts +++ b/src/domain/locking/entities/campaign.entity.ts @@ -10,7 +10,7 @@ export const CampaignSchema = z.object({ description: z.string(), startDate: z.coerce.date(), endDate: z.coerce.date(), - lastUpdated: z.coerce.date(), + lastUpdated: z.coerce.date().nullish().default(null), activitiesMetadata: z.array(ActivityMetadataSchema).nullish().default(null), }); diff --git a/src/domain/locking/entities/schemas/__tests__/campaign.schema.spec.ts b/src/domain/locking/entities/schemas/__tests__/campaign.schema.spec.ts index 5c3024613c..a171e6c59a 100644 --- a/src/domain/locking/entities/schemas/__tests__/campaign.schema.spec.ts +++ b/src/domain/locking/entities/schemas/__tests__/campaign.schema.spec.ts @@ -1,5 +1,6 @@ import { campaignBuilder } from '@/domain/locking/entities/__tests__/campaign.builder'; import { CampaignSchema } from '@/domain/locking/entities/campaign.entity'; +import { faker } from '@faker-js/faker'; import { ZodError } from 'zod'; describe('CampaignSchema', () => { @@ -11,10 +12,12 @@ describe('CampaignSchema', () => { expect(result.success).toBe(true); }); - it.each(['startDate' as const, 'endDate' as const, 'lastUpdated' as const])( + it.each(['startDate' as const, 'endDate' as const])( `should coerce %s to a date`, (field) => { - const campaign = campaignBuilder().build(); + const campaign = campaignBuilder() + .with(field, faker.date.recent().toISOString() as unknown as Date) + .build(); const result = CampaignSchema.safeParse(campaign); @@ -24,6 +27,31 @@ describe('CampaignSchema', () => { }, ); + it('should default lastUpdated to null', () => { + const campaign = campaignBuilder() + .with('lastUpdated', faker.date.recent()) + .build(); + // @ts-expect-error - inferred types don't allow optional fields + delete campaign.lastUpdated; + + const result = CampaignSchema.safeParse(campaign); + + expect(result.success && result.data.lastUpdated).toBe(null); + }); + + it('should coerce lastUpdated to a date', () => { + const lastUpdated = faker.date.recent().toISOString(); + const campaign = campaignBuilder() + .with('lastUpdated', lastUpdated as unknown as Date) + .build(); + + const result = CampaignSchema.safeParse(campaign); + + expect(result.success && result.data.lastUpdated).toStrictEqual( + new Date(lastUpdated), + ); + }); + it('should not validate an invalid campaign', () => { const campaign = { invalid: 'campaign' }; @@ -62,11 +90,6 @@ describe('CampaignSchema', () => { path: ['endDate'], message: 'Invalid date', }, - { - code: 'invalid_date', - path: ['lastUpdated'], - message: 'Invalid date', - }, ]), ); }); diff --git a/src/domain/locking/entities/schemas/campaign.schema.ts b/src/domain/locking/entities/schemas/campaign.schema.ts deleted file mode 100644 index 8b0d81db3d..0000000000 --- a/src/domain/locking/entities/schemas/campaign.schema.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ActivityMetadataSchema } from '@/domain/locking/entities/activity-metadata.entity'; -import { z } from 'zod'; - -export const CampaignSchema = z.object({ - campaignId: z.string(), - name: z.string(), - description: z.string(), - startDate: z.coerce.date(), - endDate: z.coerce.date(), - lastUpdated: z.coerce.date(), - activitiesMetadata: z.array(ActivityMetadataSchema).nullish().default(null), -}); diff --git a/src/routes/locking/entities/campaign.entity.ts b/src/routes/locking/entities/campaign.entity.ts index aee07bbb8e..ac83ca6cb0 100644 --- a/src/routes/locking/entities/campaign.entity.ts +++ b/src/routes/locking/entities/campaign.entity.ts @@ -1,6 +1,6 @@ import { Campaign as DomainCampaign } from '@/domain/locking/entities/campaign.entity'; import { ActivityMetadata } from '@/routes/locking/entities/activity-metadata.entity'; -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class Campaign implements DomainCampaign { @ApiProperty() @@ -13,8 +13,8 @@ export class Campaign implements DomainCampaign { startDate!: Date; @ApiProperty({ type: String }) endDate!: Date; - @ApiProperty({ type: String }) - lastUpdated!: Date; + @ApiPropertyOptional({ type: String, nullable: true }) + lastUpdated!: Date | null; @ApiProperty({ type: [ActivityMetadata] }) activitiesMetadata!: ActivityMetadata[] | null; } From 582b36c0931efb29f9f9670b2aec1afa50bb466f Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 16 May 2024 16:19:47 +0200 Subject: [PATCH 004/207] Prevent mock transfers of no values (#1556) Ensures that the mocked imitation transfers _always_ have a `value` above `0`: - Set a minimum value of >0 for transfers in imitation transaction tests --- .../transactions-history.controller.spec.ts | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/src/routes/transactions/transactions-history.controller.spec.ts b/src/routes/transactions/transactions-history.controller.spec.ts index 696663e2c1..dc9fff676e 100644 --- a/src/routes/transactions/transactions-history.controller.spec.ts +++ b/src/routes/transactions/transactions-history.controller.spec.ts @@ -1372,6 +1372,7 @@ describe('Transactions History Controller (Unit)', () => { .with('executionDate', multisigExecutionDate) .with('from', safe.address) .with('tokenAddress', multisigToken.address) + .with('value', faker.string.numeric({ exclude: ['0'] })) .build(), tokenInfo: multisigToken, }; @@ -1462,10 +1463,8 @@ describe('Transactions History Controller (Unit)', () => { const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chain.chainId}`; const getAllTransactionsUrl = `${chain.transactionService}/api/v1/safes/${safe.address}/all-transactions/`; const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safe.address}`; - // @ts-expect-error - Type does not contain transfers - const getImitationTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[0].transfers[0].tokenAddress}`; - // @ts-expect-error - Type does not contain transfers - const getTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[1].transfers[0].tokenAddress}`; + const getImitationTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${imitationToken.address}`; + const getTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${multisigToken.address}`; networkService.get.mockImplementation(({ url }) => { if (url === getChainUrl) { return Promise.resolve({ data: chain, status: 200 }); @@ -1606,6 +1605,7 @@ describe('Transactions History Controller (Unit)', () => { .with('executionDate', multisigExecutionDate) .with('from', safe.address) .with('tokenAddress', multisigToken.address) + .with('value', faker.string.numeric({ exclude: ['0'] })) .build(), tokenInfo: multisigToken, }; @@ -1696,10 +1696,8 @@ describe('Transactions History Controller (Unit)', () => { const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chain.chainId}`; const getAllTransactionsUrl = `${chain.transactionService}/api/v1/safes/${safe.address}/all-transactions/`; const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safe.address}`; - // @ts-expect-error - Type does not contain transfers - const getImitationTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[0].transfers[0].tokenAddress}`; - // @ts-expect-error - Type does not contain transfers - const getTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[1].transfers[0].tokenAddress}`; + const getImitationTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${imitationToken.address}`; + const getTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${multisigToken.address}`; networkService.get.mockImplementation(({ url }) => { if (url === getChainUrl) { return Promise.resolve({ data: chain, status: 200 }); @@ -1803,6 +1801,7 @@ describe('Transactions History Controller (Unit)', () => { .with('executionDate', multisigExecutionDate) .with('from', safe.address) .with('tokenAddress', multisigToken.address) + .with('value', faker.string.numeric({ exclude: ['0'] })) .build(), tokenInfo: multisigToken, }; @@ -1893,10 +1892,8 @@ describe('Transactions History Controller (Unit)', () => { const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chain.chainId}`; const getAllTransactionsUrl = `${chain.transactionService}/api/v1/safes/${safe.address}/all-transactions/`; const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safe.address}`; - // @ts-expect-error - Type does not contain transfers - const getImitationTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[0].transfers[0].tokenAddress}`; - // @ts-expect-error - Type does not contain transfers - const getTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[1].transfers[0].tokenAddress}`; + const getImitationTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${imitationToken.address}`; + const getTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${multisigToken.address}`; networkService.get.mockImplementation(({ url }) => { if (url === getChainUrl) { return Promise.resolve({ data: chain, status: 200 }); @@ -2037,6 +2034,7 @@ describe('Transactions History Controller (Unit)', () => { .with('executionDate', multisigExecutionDate) .with('from', safe.address) .with('tokenAddress', multisigToken.address) + .with('value', faker.string.numeric({ exclude: ['0'] })) .build(), tokenInfo: multisigToken, }; @@ -2127,10 +2125,8 @@ describe('Transactions History Controller (Unit)', () => { const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chain.chainId}`; const getAllTransactionsUrl = `${chain.transactionService}/api/v1/safes/${safe.address}/all-transactions/`; const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safe.address}`; - // @ts-expect-error - Type does not contain transfers - const getImitationTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[0].transfers[0].tokenAddress}`; - // @ts-expect-error - Type does not contain transfers - const getTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[1].transfers[0].tokenAddress}`; + const getImitationTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${imitationToken.address}`; + const getTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${multisigToken.address}`; networkService.get.mockImplementation(({ url }) => { if (url === getChainUrl) { return Promise.resolve({ data: chain, status: 200 }); From 71e3ee29e66a5c10e17bf6fa8ef9f86b831dac23 Mon Sep 17 00:00:00 2001 From: Den Smalonski Date: Thu, 16 May 2024 16:20:17 +0200 Subject: [PATCH 005/207] feat: add TKO Hekla chain configuration --- src/config/entities/configuration.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 6ba0e1ca91..9456de259f 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -100,6 +100,7 @@ export default () => ({ 17069: { nativeCoin: 'ethereum', chainName: 'redstone-garnet' }, 7560: { nativeCoin: 'ethereum', chainName: 'cyber' }, 111557560: { nativeCoin: 'ethereum', chainName: 'cyber' }, + 167009: { nativeCoin: 'ethereum', chainName: 'tko-hekla' }, }, }, }, From c9e8de68fdf92d782f2c2727f0651bb56c5d9dc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Thu, 16 May 2024 18:49:25 +0200 Subject: [PATCH 006/207] Add campaign leaderboard routes (#1557) Adds GET /v1/locking/campaigns/:campaignId/leaderboard endpoint mapping. --- .../locking-api/locking-api.service.spec.ts | 43 +++--- .../locking-api/locking-api.service.ts | 10 +- .../interfaces/locking-api.interface.ts | 7 + ...er.builder.ts => campaign-rank.builder.ts} | 6 +- ...lder.entity.ts => campaign-rank.entity.ts} | 6 +- ...a.spec.ts => campaign-rank.schema.spec.ts} | 18 +-- .../locking/locking.repository.interface.ts | 7 + src/domain/locking/locking.repository.ts | 13 ++ .../locking/entities/campaign-rank.entity.ts | 15 ++ .../entities/campaign-rank.page.entity.ts | 8 + src/routes/locking/locking.controller.spec.ts | 139 ++++++++++++++++++ src/routes/locking/locking.controller.ts | 20 +++ src/routes/locking/locking.service.ts | 26 ++++ 13 files changed, 280 insertions(+), 38 deletions(-) rename src/domain/locking/entities/__tests__/{holder.builder.ts => campaign-rank.builder.ts} (67%) rename src/domain/locking/entities/{holder.entity.ts => campaign-rank.entity.ts} (70%) rename src/domain/locking/entities/schemas/__tests__/{holder.schema.spec.ts => campaign-rank.schema.spec.ts} (73%) create mode 100644 src/routes/locking/entities/campaign-rank.entity.ts create mode 100644 src/routes/locking/entities/campaign-rank.page.entity.ts diff --git a/src/datasources/locking-api/locking-api.service.spec.ts b/src/datasources/locking-api/locking-api.service.spec.ts index dc00ca2ab5..d8574bf03d 100644 --- a/src/datasources/locking-api/locking-api.service.spec.ts +++ b/src/datasources/locking-api/locking-api.service.spec.ts @@ -14,7 +14,8 @@ import { import { getAddress } from 'viem'; import { rankBuilder } from '@/domain/locking/entities/__tests__/rank.builder'; import { campaignBuilder } from '@/domain/locking/entities/__tests__/campaign.builder'; -import { holderBuilder } from '@/domain/locking/entities/__tests__/holder.builder'; +import { campaignRankBuilder } from '@/domain/locking/entities/__tests__/campaign-rank.builder'; +import { CampaignRank } from '@/domain/locking/entities/campaign-rank.entity'; const networkService = { get: jest.fn(), @@ -265,22 +266,25 @@ describe('LockingApi', () => { }); }); - describe('getLeaderboardV2', () => { - it('should get leaderboard v2', async () => { + describe('getCampaignLeaderboard', () => { + it('should get leaderboard by campaign', async () => { const campaignId = faker.string.uuid(); - const leaderboardV2Page = pageBuilder() - .with('results', [holderBuilder().build(), holderBuilder().build()]) + const campaignRankPage = pageBuilder() + .with('results', [ + campaignRankBuilder().build(), + campaignRankBuilder().build(), + ]) .build(); mockNetworkService.get.mockResolvedValueOnce({ - data: leaderboardV2Page, + data: campaignRankPage, status: 200, }); - const result = await service.getLeaderboardV2({ campaignId }); + const result = await service.getCampaignLeaderboard({ campaignId }); - expect(result).toEqual(leaderboardV2Page); + expect(result).toEqual(campaignRankPage); expect(mockNetworkService.get).toHaveBeenCalledWith({ - url: `${lockingBaseUri}/api/v2/leaderboard/${campaignId}`, + url: `${lockingBaseUri}/api/v1/campaigns/${campaignId}/leaderboard`, networkRequest: { params: { limit: undefined, @@ -294,18 +298,21 @@ describe('LockingApi', () => { const limit = faker.number.int(); const offset = faker.number.int(); const campaignId = faker.string.uuid(); - const leaderboardV2Page = pageBuilder() - .with('results', [holderBuilder().build(), holderBuilder().build()]) + const campaignRankPage = pageBuilder() + .with('results', [ + campaignRankBuilder().build(), + campaignRankBuilder().build(), + ]) .build(); mockNetworkService.get.mockResolvedValueOnce({ - data: leaderboardV2Page, + data: campaignRankPage, status: 200, }); - await service.getLeaderboardV2({ campaignId, limit, offset }); + await service.getCampaignLeaderboard({ campaignId, limit, offset }); expect(mockNetworkService.get).toHaveBeenCalledWith({ - url: `${lockingBaseUri}/api/v2/leaderboard/${campaignId}`, + url: `${lockingBaseUri}/api/v1/campaigns/${campaignId}/leaderboard`, networkRequest: { params: { limit, @@ -319,7 +326,7 @@ describe('LockingApi', () => { const status = faker.internet.httpStatusCode({ types: ['serverError'] }); const campaignId = faker.string.uuid(); const error = new NetworkResponseError( - new URL(`${lockingBaseUri}/api/v2/leaderboard/${campaignId}`), + new URL(`${lockingBaseUri}/api/v1/campaigns/${campaignId}/leaderboard`), { status, } as Response, @@ -329,9 +336,9 @@ describe('LockingApi', () => { ); mockNetworkService.get.mockRejectedValueOnce(error); - await expect(service.getLeaderboardV2({ campaignId })).rejects.toThrow( - new DataSourceError('Unexpected error', status), - ); + await expect( + service.getCampaignLeaderboard({ campaignId }), + ).rejects.toThrow(new DataSourceError('Unexpected error', status)); expect(mockNetworkService.get).toHaveBeenCalledTimes(1); }); diff --git a/src/datasources/locking-api/locking-api.service.ts b/src/datasources/locking-api/locking-api.service.ts index f6d21d0c75..6c03a8c3e8 100644 --- a/src/datasources/locking-api/locking-api.service.ts +++ b/src/datasources/locking-api/locking-api.service.ts @@ -7,7 +7,7 @@ import { import { Page } from '@/domain/entities/page.entity'; import { ILockingApi } from '@/domain/interfaces/locking-api.interface'; import { Campaign } from '@/domain/locking/entities/campaign.entity'; -import { Holder } from '@/domain/locking/entities/holder.entity'; +import { CampaignRank } from '@/domain/locking/entities/campaign-rank.entity'; import { LockingEvent } from '@/domain/locking/entities/locking-event.entity'; import { Rank } from '@/domain/locking/entities/rank.entity'; import { Inject } from '@nestjs/common'; @@ -88,14 +88,14 @@ export class LockingApi implements ILockingApi { } } - async getLeaderboardV2(args: { + async getCampaignLeaderboard(args: { campaignId: string; limit?: number; offset?: number; - }): Promise> { + }): Promise> { try { - const url = `${this.baseUri}/api/v2/leaderboard/${args.campaignId}`; - const { data } = await this.networkService.get>({ + const url = `${this.baseUri}/api/v1/campaigns/${args.campaignId}/leaderboard`; + const { data } = await this.networkService.get>({ url, networkRequest: { params: { diff --git a/src/domain/interfaces/locking-api.interface.ts b/src/domain/interfaces/locking-api.interface.ts index f790d2cf2c..fff606ef68 100644 --- a/src/domain/interfaces/locking-api.interface.ts +++ b/src/domain/interfaces/locking-api.interface.ts @@ -1,5 +1,6 @@ import { Page } from '@/domain/entities/page.entity'; import { Campaign } from '@/domain/locking/entities/campaign.entity'; +import { CampaignRank } from '@/domain/locking/entities/campaign-rank.entity'; import { LockingEvent } from '@/domain/locking/entities/locking-event.entity'; import { Rank } from '@/domain/locking/entities/rank.entity'; @@ -20,6 +21,12 @@ export interface ILockingApi { offset?: number; }): Promise>; + getCampaignLeaderboard(args: { + campaignId: string; + limit?: number; + offset?: number; + }): Promise>; + getLockingHistory(args: { safeAddress: `0x${string}`; limit?: number; diff --git a/src/domain/locking/entities/__tests__/holder.builder.ts b/src/domain/locking/entities/__tests__/campaign-rank.builder.ts similarity index 67% rename from src/domain/locking/entities/__tests__/holder.builder.ts rename to src/domain/locking/entities/__tests__/campaign-rank.builder.ts index c991559942..7b3b2cced9 100644 --- a/src/domain/locking/entities/__tests__/holder.builder.ts +++ b/src/domain/locking/entities/__tests__/campaign-rank.builder.ts @@ -1,10 +1,10 @@ import { Builder, IBuilder } from '@/__tests__/builder'; -import { Holder } from '@/domain/locking/entities/holder.entity'; +import { CampaignRank } from '@/domain/locking/entities/campaign-rank.entity'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; -export function holderBuilder(): IBuilder { - return new Builder() +export function campaignRankBuilder(): IBuilder { + return new Builder() .with('holder', getAddress(faker.finance.ethereumAddress())) .with('position', faker.number.int()) .with('boost', faker.string.numeric()) diff --git a/src/domain/locking/entities/holder.entity.ts b/src/domain/locking/entities/campaign-rank.entity.ts similarity index 70% rename from src/domain/locking/entities/holder.entity.ts rename to src/domain/locking/entities/campaign-rank.entity.ts index a34084b745..02dcf7339a 100644 --- a/src/domain/locking/entities/holder.entity.ts +++ b/src/domain/locking/entities/campaign-rank.entity.ts @@ -3,7 +3,7 @@ import { AddressSchema } from '@/validation/entities/schemas/address.schema'; import { NumericStringSchema } from '@/validation/entities/schemas/numeric-string.schema'; import { z } from 'zod'; -export const HolderSchema = z.object({ +export const CampaignRankSchema = z.object({ holder: AddressSchema, position: z.number(), boost: NumericStringSchema, @@ -11,6 +11,6 @@ export const HolderSchema = z.object({ boostedPoints: NumericStringSchema, }); -export const HolderPageSchema = buildPageSchema(HolderSchema); +export const CampaignRankPageSchema = buildPageSchema(CampaignRankSchema); -export type Holder = z.infer; +export type CampaignRank = z.infer; diff --git a/src/domain/locking/entities/schemas/__tests__/holder.schema.spec.ts b/src/domain/locking/entities/schemas/__tests__/campaign-rank.schema.spec.ts similarity index 73% rename from src/domain/locking/entities/schemas/__tests__/holder.schema.spec.ts rename to src/domain/locking/entities/schemas/__tests__/campaign-rank.schema.spec.ts index 3364e1e2e1..e6ed4f8fa8 100644 --- a/src/domain/locking/entities/schemas/__tests__/holder.schema.spec.ts +++ b/src/domain/locking/entities/schemas/__tests__/campaign-rank.schema.spec.ts @@ -1,14 +1,14 @@ -import { holderBuilder } from '@/domain/locking/entities/__tests__/holder.builder'; -import { HolderSchema } from '@/domain/locking/entities/holder.entity'; +import { campaignRankBuilder } from '@/domain/locking/entities/__tests__/campaign-rank.builder'; +import { CampaignRankSchema } from '@/domain/locking/entities/campaign-rank.entity'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; import { ZodError } from 'zod'; -describe('HolderSchema', () => { +describe('CampaignRankSchema', () => { it('should validate a valid holder', () => { - const holder = holderBuilder().build(); + const campaignRank = campaignRankBuilder().build(); - const result = HolderSchema.safeParse(holder); + const result = CampaignRankSchema.safeParse(campaignRank); expect(result.success).toBe(true); }); @@ -17,11 +17,11 @@ describe('HolderSchema', () => { const nonChecksummedAddress = faker.finance .ethereumAddress() .toLowerCase() as `0x${string}`; - const holder = holderBuilder() + const campaignRank = campaignRankBuilder() .with('holder', nonChecksummedAddress) .build(); - const result = HolderSchema.safeParse(holder); + const result = CampaignRankSchema.safeParse(campaignRank); expect(result.success && result.data.holder).toBe( getAddress(nonChecksummedAddress), @@ -29,9 +29,9 @@ describe('HolderSchema', () => { }); it('should not validate an invalid holder', () => { - const holder = { invalid: 'holder' }; + const campaignRank = { invalid: 'campaignRank' }; - const result = HolderSchema.safeParse(holder); + const result = CampaignRankSchema.safeParse(campaignRank); expect(!result.success && result.error).toStrictEqual( new ZodError([ diff --git a/src/domain/locking/locking.repository.interface.ts b/src/domain/locking/locking.repository.interface.ts index 88d1613a16..bf4290e8f6 100644 --- a/src/domain/locking/locking.repository.interface.ts +++ b/src/domain/locking/locking.repository.interface.ts @@ -1,5 +1,6 @@ import { Page } from '@/domain/entities/page.entity'; import { Campaign } from '@/domain/locking/entities/campaign.entity'; +import { CampaignRank } from '@/domain/locking/entities/campaign-rank.entity'; import { LockingEvent } from '@/domain/locking/entities/locking-event.entity'; import { Rank } from '@/domain/locking/entities/rank.entity'; @@ -20,6 +21,12 @@ export interface ILockingRepository { offset?: number; }): Promise>; + getCampaignLeaderboard(args: { + campaignId: string; + limit?: number; + offset?: number; + }): Promise>; + getLockingHistory(args: { safeAddress: `0x${string}`; offset?: number; diff --git a/src/domain/locking/locking.repository.ts b/src/domain/locking/locking.repository.ts index 67531b3c49..464c36d0a4 100644 --- a/src/domain/locking/locking.repository.ts +++ b/src/domain/locking/locking.repository.ts @@ -4,6 +4,10 @@ import { Campaign, CampaignPageSchema, } from '@/domain/locking/entities/campaign.entity'; +import { + CampaignRank, + CampaignRankPageSchema, +} from '@/domain/locking/entities/campaign-rank.entity'; import { LockingEvent } from '@/domain/locking/entities/locking-event.entity'; import { Rank } from '@/domain/locking/entities/rank.entity'; import { LockingEventPageSchema } from '@/domain/locking/entities/schemas/locking-event.schema'; @@ -46,6 +50,15 @@ export class LockingRepository implements ILockingRepository { return RankPageSchema.parse(page); } + async getCampaignLeaderboard(args: { + campaignId: string; + limit?: number; + offset?: number; + }): Promise> { + const page = await this.lockingApi.getCampaignLeaderboard(args); + return CampaignRankPageSchema.parse(page); + } + async getLockingHistory(args: { safeAddress: `0x${string}`; offset?: number; diff --git a/src/routes/locking/entities/campaign-rank.entity.ts b/src/routes/locking/entities/campaign-rank.entity.ts new file mode 100644 index 0000000000..e8f33666f3 --- /dev/null +++ b/src/routes/locking/entities/campaign-rank.entity.ts @@ -0,0 +1,15 @@ +import { CampaignRank as DomainCampaignRank } from '@/domain/locking/entities/campaign-rank.entity'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CampaignRank implements DomainCampaignRank { + @ApiProperty() + holder!: `0x${string}`; + @ApiProperty() + position!: number; + @ApiProperty() + boost!: string; + @ApiProperty() + points!: string; + @ApiProperty() + boostedPoints!: string; +} diff --git a/src/routes/locking/entities/campaign-rank.page.entity.ts b/src/routes/locking/entities/campaign-rank.page.entity.ts new file mode 100644 index 0000000000..e275b037be --- /dev/null +++ b/src/routes/locking/entities/campaign-rank.page.entity.ts @@ -0,0 +1,8 @@ +import { Page } from '@/routes/common/entities/page.entity'; +import { CampaignRank } from '@/routes/locking/entities/campaign-rank.entity'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CampaignRankPage extends Page { + @ApiProperty({ type: CampaignRank }) + results!: Array; +} diff --git a/src/routes/locking/locking.controller.spec.ts b/src/routes/locking/locking.controller.spec.ts index 51b8dd7316..2bc612210f 100644 --- a/src/routes/locking/locking.controller.spec.ts +++ b/src/routes/locking/locking.controller.spec.ts @@ -37,6 +37,8 @@ import { toJson as campaignToJson, } from '@/domain/locking/entities/__tests__/campaign.builder'; import { Campaign } from '@/domain/locking/entities/campaign.entity'; +import { CampaignRank } from '@/domain/locking/entities/campaign-rank.entity'; +import { campaignRankBuilder } from '@/domain/locking/entities/__tests__/campaign-rank.builder'; describe('Locking (Unit)', () => { let app: INestApplication; @@ -217,6 +219,143 @@ describe('Locking (Unit)', () => { }); }); + describe('GET leaderboard by campaign', () => { + it('should get the leaderboard by campaign', async () => { + const campaign = campaignBuilder().build(); + const campaignRankPage = pageBuilder() + .with('results', [ + campaignRankBuilder().build(), + campaignRankBuilder().build(), + ]) + .with('count', 2) + .with('previous', null) + .with('next', null) + .build(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}/leaderboard`: + return Promise.resolve({ data: campaignRankPage, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/locking/campaigns/${campaign.campaignId}/leaderboard`) + .expect(200) + .expect({ + count: 2, + next: null, + previous: null, + results: campaignRankPage.results, + }); + }); + + it('should validate the response', async () => { + const campaign = campaignBuilder().build(); + const invalidCampaignRanks = [{ invalid: 'campaignRank' }]; + const campaignRankPage = pageBuilder() + .with('results', invalidCampaignRanks) + .with('count', invalidCampaignRanks.length) + .with('previous', null) + .with('next', null) + .build(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}/leaderboard`: + return Promise.resolve({ data: campaignRankPage, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/locking/campaigns/${campaign.campaignId}/leaderboard`) + .expect(500) + .expect({ + statusCode: 500, + message: 'Internal server error', + }); + }); + + it('should forward the pagination parameters', async () => { + const limit = faker.number.int({ min: 1, max: 10 }); + const offset = faker.number.int({ min: 1, max: 10 }); + const campaign = campaignBuilder().build(); + const campaignRankPage = pageBuilder() + .with('results', [ + campaignRankBuilder().build(), + campaignRankBuilder().build(), + ]) + .with('count', 2) + .with('previous', null) + .with('next', null) + .build(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}/leaderboard`: + return Promise.resolve({ data: campaignRankPage, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get( + `/v1/locking/campaigns/${campaign.campaignId}/leaderboard?cursor=limit%3D${limit}%26offset%3D${offset}`, + ) + .expect(200) + .expect({ + count: 2, + next: null, + previous: null, + results: campaignRankPage.results, + }); + + expect(networkService.get).toHaveBeenCalledWith({ + url: `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}/leaderboard`, + networkRequest: { + params: { + limit, + offset, + }, + }, + }); + }); + + it('should forward errors from the service', async () => { + const campaign = campaignBuilder().build(); + const statusCode = faker.internet.httpStatusCode({ + types: ['clientError', 'serverError'], + }); + const errorMessage = faker.word.words(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}/leaderboard`: + return Promise.reject( + new NetworkResponseError( + new URL(url), + { + status: statusCode, + } as Response, + { message: errorMessage, status: statusCode }, + ), + ); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/locking/campaigns/${campaign.campaignId}/leaderboard`) + .expect(statusCode) + .expect({ + message: errorMessage, + code: statusCode, + }); + }); + }); + describe('GET rank', () => { it('should get the rank', async () => { const rank = rankBuilder().build(); diff --git a/src/routes/locking/locking.controller.ts b/src/routes/locking/locking.controller.ts index fede794622..0b0bd68055 100644 --- a/src/routes/locking/locking.controller.ts +++ b/src/routes/locking/locking.controller.ts @@ -11,6 +11,7 @@ import { Controller, Get, Param } from '@nestjs/common'; import { ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger'; import { Campaign } from '@/routes/locking/entities/campaign.entity'; import { CampaignPage } from '@/routes/locking/entities/campaign.page.entity'; +import { CampaignRankPage } from '@/routes/locking/entities/campaign-rank.page.entity'; @ApiTags('locking') @Controller({ @@ -42,6 +43,25 @@ export class LockingController { return this.lockingService.getCampaigns({ routeUrl, paginationData }); } + @ApiOkResponse({ type: CampaignRankPage }) + @ApiQuery({ + name: 'cursor', + required: false, + type: String, + }) + @Get('/campaigns/:campaignId/leaderboard') + async getCampaignLeaderboard( + @Param('campaignId') campaignId: string, + @RouteUrlDecorator() routeUrl: URL, + @PaginationDataDecorator() paginationData: PaginationData, + ): Promise { + return this.lockingService.getCampaignLeaderboard({ + campaignId, + routeUrl, + paginationData, + }); + } + @ApiOkResponse({ type: Rank }) @Get('/leaderboard/rank/:safeAddress') async getRank( diff --git a/src/routes/locking/locking.service.ts b/src/routes/locking/locking.service.ts index 625135b763..c8afbdb8db 100644 --- a/src/routes/locking/locking.service.ts +++ b/src/routes/locking/locking.service.ts @@ -1,5 +1,6 @@ import { Page } from '@/domain/entities/page.entity'; import { Campaign } from '@/domain/locking/entities/campaign.entity'; +import { CampaignRank } from '@/domain/locking/entities/campaign-rank.entity'; import { LockingEvent } from '@/domain/locking/entities/locking-event.entity'; import { Rank } from '@/domain/locking/entities/rank.entity'; import { ILockingRepository } from '@/domain/locking/locking.repository.interface'; @@ -68,6 +69,31 @@ export class LockingService { }; } + async getCampaignLeaderboard(args: { + campaignId: string; + routeUrl: URL; + paginationData: PaginationData; + }): Promise> { + const result = await this.lockingRepository.getCampaignLeaderboard({ + campaignId: args.campaignId, + limit: args.paginationData.limit, + offset: args.paginationData.offset, + }); + + const nextUrl = cursorUrlFromLimitAndOffset(args.routeUrl, result.next); + const previousUrl = cursorUrlFromLimitAndOffset( + args.routeUrl, + result.previous, + ); + + return { + count: result.count, + next: nextUrl?.toString() ?? null, + previous: previousUrl?.toString() ?? null, + results: result.results, + }; + } + async getLockingHistory(args: { safeAddress: `0x${string}`; routeUrl: URL; From cbf7de8cbb4a3a477268cbfbe96b2d7720cd5c12 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Fri, 17 May 2024 11:03:03 +0200 Subject: [PATCH 007/207] Move `/locking` and `/campaign` routes under `/community` (#1560) Moves all Locking Service-related routes under `/community`, aligning with the domain of the Activity Program: - Rename `Locking-` module, controller, service to `Community-`. - Rename the following (as of yet unused) routes: - `GET` `/v1/locking/campaigns` ->`/v1/community/campaigns` - `GET` `/v1/locking/campaigns/:campaignId` ->`/v1/community/campaigns/:campaignId` - `GET` `/v1/locking/campaigns/:campaignId/leaderboard` -> `/v1/community/campaigns/:campaignId/leaderboard` - Rename the following (and deprecate with a redirection of the currently used) routes: - `GET` `/v1/locking/leaderboard` -> `/v1/community/locking/leaderboard` - `GET` `/v1/locking/leaderboard/rank/:safeAddress` -> `/v1/community/locking/:safeAddress/rank` - `GET` `/v1/locking/:safeAddress/history` -> `/v1/community/locking/:safeAddress/history` --- src/app.module.ts | 2 + src/domain/locking/locking.domain.module.ts | 1 + .../community/community.controller.spec.ts | 801 ++++++++++++++++++ src/routes/community/community.controller.ts | 110 +++ src/routes/community/community.module.ts | 11 + .../community.service.ts} | 40 +- src/routes/locking/locking.controller.spec.ts | 702 +-------------- src/routes/locking/locking.controller.ts | 111 +-- src/routes/locking/locking.module.ts | 2 - 9 files changed, 1019 insertions(+), 761 deletions(-) create mode 100644 src/routes/community/community.controller.spec.ts create mode 100644 src/routes/community/community.controller.ts create mode 100644 src/routes/community/community.module.ts rename src/routes/{locking/locking.service.ts => community/community.service.ts} (96%) diff --git a/src/app.module.ts b/src/app.module.ts index f1514b7c34..5c9214d7ce 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -15,6 +15,7 @@ import { ConfigurationModule } from '@/config/configuration.module'; import { CacheModule } from '@/datasources/cache/cache.module'; import { CacheHooksModule } from '@/routes/cache-hooks/cache-hooks.module'; import { CollectiblesModule } from '@/routes/collectibles/collectibles.module'; +import { CommunityModule } from '@/routes/community/community.module'; import { ContractsModule } from '@/routes/contracts/contracts.module'; import { DataDecodedModule } from '@/routes/data-decode/data-decoded.module'; import { DelegatesModule } from '@/routes/delegates/delegates.module'; @@ -70,6 +71,7 @@ export class AppModule implements NestModule { CacheHooksModule, ChainsModule, CollectiblesModule, + CommunityModule, ContractsModule, DataDecodedModule, // TODO: delete/rename DelegatesModule when clients migration to v2 is completed. diff --git a/src/domain/locking/locking.domain.module.ts b/src/domain/locking/locking.domain.module.ts index 72bbe68b2a..5cea9e3d88 100644 --- a/src/domain/locking/locking.domain.module.ts +++ b/src/domain/locking/locking.domain.module.ts @@ -8,4 +8,5 @@ import { LockingRepository } from '@/domain/locking/locking.repository'; providers: [{ provide: ILockingRepository, useClass: LockingRepository }], exports: [ILockingRepository], }) +// TODO: Convert to CommunityDomainModule export class LockingDomainModule {} diff --git a/src/routes/community/community.controller.spec.ts b/src/routes/community/community.controller.spec.ts new file mode 100644 index 0000000000..a279f8cf34 --- /dev/null +++ b/src/routes/community/community.controller.spec.ts @@ -0,0 +1,801 @@ +import * as request from 'supertest'; +import { faker } from '@faker-js/faker'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { TestAppProvider } from '@/__tests__/test-app.provider'; +import { AppModule } from '@/app.module'; +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; +import { CacheModule } from '@/datasources/cache/cache.module'; +import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; +import { NetworkModule } from '@/datasources/network/network.module'; +import { + INetworkService, + NetworkService, +} from '@/datasources/network/network.service.interface'; +import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; +import { RequestScopedLoggingModule } from '@/logging/logging.module'; +import configuration from '@/config/entities/__tests__/configuration'; +import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; +import { pageBuilder } from '@/domain/entities/__tests__/page.builder'; +import { + lockEventItemBuilder, + unlockEventItemBuilder, + withdrawEventItemBuilder, + toJson as lockingEventToJson, +} from '@/domain/locking/entities/__tests__/locking-event.builder'; +import { LockingEvent } from '@/domain/locking/entities/locking-event.entity'; +import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; +import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; +import { getAddress } from 'viem'; +import { rankBuilder } from '@/domain/locking/entities/__tests__/rank.builder'; +import { PaginationData } from '@/routes/common/pagination/pagination.data'; +import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; +import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { + campaignBuilder, + toJson as campaignToJson, +} from '@/domain/locking/entities/__tests__/campaign.builder'; +import { Campaign } from '@/domain/locking/entities/campaign.entity'; +import { CampaignRank } from '@/domain/locking/entities/campaign-rank.entity'; +import { campaignRankBuilder } from '@/domain/locking/entities/__tests__/campaign-rank.builder'; + +describe('Community (Unit)', () => { + let app: INestApplication; + let lockingBaseUri: string; + let networkService: jest.MockedObjectDeep; + + beforeEach(async () => { + jest.resetAllMocks(); + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule.register(configuration)], + }) + .overrideModule(AccountDataSourceModule) + .useModule(TestAccountDataSourceModule) + .overrideModule(CacheModule) + .useModule(TestCacheModule) + .overrideModule(RequestScopedLoggingModule) + .useModule(TestLoggingModule) + .overrideModule(NetworkModule) + .useModule(TestNetworkModule) + .overrideModule(QueuesApiModule) + .useModule(TestQueuesApiModule) + .compile(); + + const configurationService = moduleFixture.get(IConfigurationService); + lockingBaseUri = configurationService.get('locking.baseUri'); + networkService = moduleFixture.get(NetworkService); + + app = await new TestAppProvider().provide(moduleFixture); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('GET /community/campaigns', () => { + it('should get the list of campaigns', async () => { + const campaignsPage = pageBuilder() + .with('results', [campaignBuilder().build()]) + .with('count', 1) + .with('previous', null) + .with('next', null) + .build(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns`: + return Promise.resolve({ data: campaignsPage, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/community/campaigns`) + .expect(200) + .expect({ + count: 1, + next: null, + previous: null, + results: campaignsPage.results.map(campaignToJson), + }); + }); + + it('should validate the list of campaigns', async () => { + const invalidCampaigns = [{ invalid: 'campaign' }]; + const campaignsPage = pageBuilder() + .with('results', invalidCampaigns) + .with('count', 1) + .with('previous', null) + .with('next', null) + .build(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns`: + return Promise.resolve({ data: campaignsPage, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/community/campaigns`) + .expect(500) + .expect({ + statusCode: 500, + message: 'Internal server error', + }); + }); + + it('should forward the pagination parameters', async () => { + const limit = faker.number.int({ min: 1, max: 10 }); + const offset = faker.number.int({ min: 1, max: 10 }); + const campaignsPage = pageBuilder() + .with('results', [campaignBuilder().build()]) + .with('count', 1) + .with('previous', null) + .with('next', null) + .build(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns`: + return Promise.resolve({ data: campaignsPage, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get( + `/v1/community/campaigns?cursor=limit%3D${limit}%26offset%3D${offset}`, + ) + .expect(200) + .expect({ + count: 1, + next: null, + previous: null, + results: campaignsPage.results.map(campaignToJson), + }); + + expect(networkService.get).toHaveBeenCalledWith({ + url: `${lockingBaseUri}/api/v1/campaigns`, + networkRequest: { + params: { + limit, + offset, + }, + }, + }); + }); + + it('should forward errors from the service', async () => { + const statusCode = faker.internet.httpStatusCode({ + types: ['clientError', 'serverError'], + }); + const errorMessage = faker.word.words(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns`: + return Promise.reject( + new NetworkResponseError( + new URL(`${lockingBaseUri}/api/v1/campaigns`), + { + status: statusCode, + } as Response, + { message: errorMessage, status: statusCode }, + ), + ); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/community/campaigns`) + .expect(statusCode) + .expect({ + message: errorMessage, + code: statusCode, + }); + }); + }); + + describe('GET /community/campaigns/:campaignId', () => { + it('should get a campaign by ID', async () => { + const campaign = campaignBuilder().build(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}`: + return Promise.resolve({ data: campaign, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/community/campaigns/${campaign.campaignId}`) + .expect(200) + .expect(campaignToJson(campaign) as Campaign); + }); + + // TODO: Enable when validation is implemented + it.skip('should validate the response', async () => { + const invalidCampaign = { + campaignId: faker.string.uuid(), + invalid: 'campaign', + }; + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns/${invalidCampaign.campaignId}`: + return Promise.resolve({ data: invalidCampaign, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/community/campaigns/${invalidCampaign.campaignId}`) + .expect(500) + .expect({ + statusCode: 500, + message: 'Internal server error', + }); + }); + + it('should forward an error from the service', async () => { + const campaignId = faker.string.uuid(); + const statusCode = faker.internet.httpStatusCode({ + types: ['clientError', 'serverError'], + }); + const errorMessage = faker.word.words(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns/${campaignId}`: + return Promise.reject( + new NetworkResponseError( + new URL(`${lockingBaseUri}/api/v1/campaigns/${campaignId}`), + { + status: statusCode, + } as Response, + { message: errorMessage, status: statusCode }, + ), + ); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/community/campaigns/${campaignId}`) + .expect(statusCode) + .expect({ + message: errorMessage, + code: statusCode, + }); + }); + }); + + describe('GET /community/campaigns/:campaignId/leaderboard', () => { + it('should get the leaderboard by campaign ID', async () => { + const campaign = campaignBuilder().build(); + const campaignRankPage = pageBuilder() + .with('results', [ + campaignRankBuilder().build(), + campaignRankBuilder().build(), + ]) + .with('count', 2) + .with('previous', null) + .with('next', null) + .build(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}/leaderboard`: + return Promise.resolve({ data: campaignRankPage, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/community/campaigns/${campaign.campaignId}/leaderboard`) + .expect(200) + .expect({ + count: 2, + next: null, + previous: null, + results: campaignRankPage.results, + }); + }); + + it('should validate the response', async () => { + const campaign = campaignBuilder().build(); + const invalidCampaignRanks = [{ invalid: 'campaignRank' }]; + const campaignRankPage = pageBuilder() + .with('results', invalidCampaignRanks) + .with('count', invalidCampaignRanks.length) + .with('previous', null) + .with('next', null) + .build(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}/leaderboard`: + return Promise.resolve({ data: campaignRankPage, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/community/campaigns/${campaign.campaignId}/leaderboard`) + .expect(500) + .expect({ + statusCode: 500, + message: 'Internal server error', + }); + }); + + it('should forward the pagination parameters', async () => { + const limit = faker.number.int({ min: 1, max: 10 }); + const offset = faker.number.int({ min: 1, max: 10 }); + const campaign = campaignBuilder().build(); + const campaignRankPage = pageBuilder() + .with('results', [ + campaignRankBuilder().build(), + campaignRankBuilder().build(), + ]) + .with('count', 2) + .with('previous', null) + .with('next', null) + .build(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}/leaderboard`: + return Promise.resolve({ data: campaignRankPage, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get( + `/v1/community/campaigns/${campaign.campaignId}/leaderboard?cursor=limit%3D${limit}%26offset%3D${offset}`, + ) + .expect(200) + .expect({ + count: 2, + next: null, + previous: null, + results: campaignRankPage.results, + }); + + expect(networkService.get).toHaveBeenCalledWith({ + url: `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}/leaderboard`, + networkRequest: { + params: { + limit, + offset, + }, + }, + }); + }); + + it('should forward errors from the service', async () => { + const campaign = campaignBuilder().build(); + const statusCode = faker.internet.httpStatusCode({ + types: ['clientError', 'serverError'], + }); + const errorMessage = faker.word.words(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}/leaderboard`: + return Promise.reject( + new NetworkResponseError( + new URL(url), + { + status: statusCode, + } as Response, + { message: errorMessage, status: statusCode }, + ), + ); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/community/campaigns/${campaign.campaignId}/leaderboard`) + .expect(statusCode) + .expect({ + message: errorMessage, + code: statusCode, + }); + }); + }); + + describe('GET /community/locking/leaderboard', () => { + it('should get the leaderboard', async () => { + const leaderboard = pageBuilder() + .with('results', [rankBuilder().build()]) + .build(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/leaderboard`: + return Promise.resolve({ data: leaderboard, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/community/locking/leaderboard`) + .expect(200) + .expect(({ body }) => { + expect(body).toEqual({ + count: leaderboard.count, + next: expect.any(String), + previous: expect.any(String), + results: leaderboard.results, + }); + }); + + expect(networkService.get).toHaveBeenCalledWith({ + url: `${lockingBaseUri}/api/v1/leaderboard`, + networkRequest: { + params: { + limit: PaginationData.DEFAULT_LIMIT, + offset: PaginationData.DEFAULT_OFFSET, + }, + }, + }); + }); + + it('should forward the pagination parameters', async () => { + const limit = faker.number.int({ min: 1, max: 10 }); + const offset = faker.number.int({ min: 1, max: 10 }); + const leaderboard = pageBuilder() + .with('results', [rankBuilder().build()]) + .build(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/leaderboard`: + return Promise.resolve({ data: leaderboard, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get( + `/v1/community/locking/leaderboard?cursor=limit%3D${limit}%26offset%3D${offset}`, + ) + .expect(200) + .expect(({ body }) => { + expect(body).toEqual({ + count: leaderboard.count, + next: expect.any(String), + previous: expect.any(String), + results: leaderboard.results, + }); + }); + + expect(networkService.get).toHaveBeenCalledWith({ + url: `${lockingBaseUri}/api/v1/leaderboard`, + networkRequest: { + params: { + limit, + offset, + }, + }, + }); + }); + + it('should validate the response', async () => { + const leaderboard = pageBuilder() + .with('results', [{ invalid: 'rank' }]) + .build(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/leaderboard`: + return Promise.resolve({ data: leaderboard, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/community/locking/leaderboard`) + .expect(500) + .expect({ + statusCode: 500, + message: 'Internal server error', + }); + }); + + it('should forward an error from the service', async () => { + const statusCode = faker.internet.httpStatusCode({ + types: ['clientError', 'serverError'], + }); + const errorMessage = faker.word.words(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/leaderboard`: + return Promise.reject( + new NetworkResponseError( + new URL(`${lockingBaseUri}/api/v1/leaderboard`), + { + status: statusCode, + } as Response, + { message: errorMessage, status: statusCode }, + ), + ); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/community/locking/leaderboard`) + .expect(statusCode) + .expect({ + message: errorMessage, + code: statusCode, + }); + }); + }); + + describe('GET /community/locking/:safeAddress/rank', () => { + it('should get the rank', async () => { + const rank = rankBuilder().build(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/leaderboard/${rank.holder}`: + return Promise.resolve({ data: rank, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/community/locking/${rank.holder}/rank`) + .expect(200) + .expect(rank); + }); + + it('should validate the Safe address in URL', async () => { + const safeAddress = faker.string.alphanumeric(); + + await request(app.getHttpServer()) + .get(`/v1/community/locking/${safeAddress}/rank`) + .expect(422) + .expect({ + statusCode: 422, + code: 'custom', + message: 'Invalid address', + path: [], + }); + }); + + it('should validate the response', async () => { + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const rank = { invalid: 'rank' }; + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/leaderboard/${safeAddress}`: + return Promise.resolve({ data: rank, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/community/locking/${safeAddress}/rank`) + .expect(500) + .expect({ + statusCode: 500, + message: 'Internal server error', + }); + }); + + it('should forward an error from the service', async () => { + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const statusCode = faker.internet.httpStatusCode({ + types: ['clientError', 'serverError'], + }); + const errorMessage = faker.word.words(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/leaderboard/${safeAddress}`: + return Promise.reject( + new NetworkResponseError( + new URL(`${lockingBaseUri}/api/v1/leaderboard/${safeAddress}`), + { + status: statusCode, + } as Response, + { message: errorMessage, status: statusCode }, + ), + ); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/community/locking/${safeAddress}/rank`) + .expect(statusCode) + .expect({ + message: errorMessage, + code: statusCode, + }); + }); + }); + + describe('GET /community/locking/:safeAddress/history', () => { + it('should get locking history', async () => { + const safeAddress = faker.finance.ethereumAddress(); + const lockingHistory = [ + lockEventItemBuilder().build(), + unlockEventItemBuilder().build(), + withdrawEventItemBuilder().build(), + ]; + const lockingHistoryPage = pageBuilder() + .with('results', lockingHistory) + .with('count', lockingHistory.length) + .with('previous', null) + .with('next', null) + .build(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + // Service will have checksummed address + case `${lockingBaseUri}/api/v1/all-events/${getAddress(safeAddress)}`: + return Promise.resolve({ data: lockingHistoryPage, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/community/locking/${safeAddress}/history`) + .expect(200) + .expect({ + count: lockingHistoryPage.count, + next: null, + previous: null, + results: lockingHistoryPage.results.map(lockingEventToJson), + }); + + expect(networkService.get).toHaveBeenCalledWith({ + url: `${lockingBaseUri}/api/v1/all-events/${getAddress(safeAddress)}`, + networkRequest: { + params: { + limit: PaginationData.DEFAULT_LIMIT, + offset: PaginationData.DEFAULT_OFFSET, + }, + }, + }); + }); + + it('should forward the pagination parameters', async () => { + const safeAddress = faker.finance.ethereumAddress(); + const limit = faker.number.int({ min: 1, max: 10 }); + const offset = faker.number.int({ min: 1, max: 10 }); + const lockingHistory = [ + lockEventItemBuilder().build(), + unlockEventItemBuilder().build(), + withdrawEventItemBuilder().build(), + ]; + const lockingHistoryPage = pageBuilder() + .with('results', lockingHistory) + .with('count', lockingHistory.length) + .with('previous', null) + .with('next', null) + .build(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + // Service will have checksummed address + case `${lockingBaseUri}/api/v1/all-events/${getAddress(safeAddress)}`: + return Promise.resolve({ data: lockingHistoryPage, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get( + `/v1/community/locking/${safeAddress}/history?cursor=limit%3D${limit}%26offset%3D${offset}`, + ) + .expect(200) + .expect({ + count: lockingHistoryPage.count, + next: null, + previous: null, + results: lockingHistoryPage.results.map(lockingEventToJson), + }); + + expect(networkService.get).toHaveBeenCalledWith({ + url: `${lockingBaseUri}/api/v1/all-events/${getAddress(safeAddress)}`, + networkRequest: { + params: { + limit, + offset, + }, + }, + }); + }); + + it('should validate the Safe address in URL', async () => { + const safeAddress = faker.string.alphanumeric(); + + await request(app.getHttpServer()) + .get(`/v1/community/locking/${safeAddress}/history`) + .expect(422) + .expect({ + statusCode: 422, + code: 'custom', + message: 'Invalid address', + path: [], + }); + }); + + it('should validate the response', async () => { + const safeAddress = faker.finance.ethereumAddress(); + const invalidLockingHistory = [{ invalid: 'value' }]; + const lockingHistoryPage = pageBuilder() + .with('results', invalidLockingHistory) + .with('count', invalidLockingHistory.length) + .with('previous', null) + .with('next', null) + .build(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + // Service will have checksummed address + case `${lockingBaseUri}/api/v1/all-events/${getAddress(safeAddress)}`: + return Promise.resolve({ data: lockingHistoryPage, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/community/locking/${safeAddress}/history`) + .expect(500) + .expect({ + statusCode: 500, + message: 'Internal server error', + }); + }); + + it('should forward an error from the service', async () => { + const safeAddress = faker.finance.ethereumAddress(); + const statusCode = faker.internet.httpStatusCode({ + types: ['clientError', 'serverError'], + }); + const errorMessage = faker.word.words(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/all-events/${getAddress(safeAddress)}`: + return Promise.reject( + new NetworkResponseError( + new URL(`${lockingBaseUri}/v1/locking/${safeAddress}/history`), + { + status: statusCode, + } as Response, + { message: errorMessage, status: statusCode }, + ), + ); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/community/locking/${safeAddress}/history`) + .expect(statusCode) + .expect({ + message: errorMessage, + code: statusCode, + }); + }); + }); +}); diff --git a/src/routes/community/community.controller.ts b/src/routes/community/community.controller.ts new file mode 100644 index 0000000000..8faaddbd62 --- /dev/null +++ b/src/routes/community/community.controller.ts @@ -0,0 +1,110 @@ +import { PaginationDataDecorator } from '@/routes/common/decorators/pagination.data.decorator'; +import { RouteUrlDecorator } from '@/routes/common/decorators/route.url.decorator'; +import { PaginationData } from '@/routes/common/pagination/pagination.data'; +import { CommunityService } from '@/routes/community/community.service'; +import { CampaignRankPage } from '@/routes/locking/entities/campaign-rank.page.entity'; +import { Campaign } from '@/routes/locking/entities/campaign.entity'; +import { CampaignPage } from '@/routes/locking/entities/campaign.page.entity'; +import { LockingEventPage } from '@/routes/locking/entities/locking-event.page.entity'; +import { Rank } from '@/routes/locking/entities/rank.entity'; +import { RankPage } from '@/routes/locking/entities/rank.page.entity'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; +import { ValidationPipe } from '@/validation/pipes/validation.pipe'; +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiTags, ApiOkResponse, ApiQuery } from '@nestjs/swagger'; + +@ApiTags('community') +@Controller({ + path: 'community', + version: '1', +}) +export class CommunityController { + constructor(private readonly communityService: CommunityService) {} + + @ApiOkResponse({ type: CampaignPage }) + @ApiQuery({ + name: 'cursor', + required: false, + type: String, + }) + @Get('/campaigns') + async getCampaigns( + @RouteUrlDecorator() routeUrl: URL, + @PaginationDataDecorator() paginationData: PaginationData, + ): Promise { + return this.communityService.getCampaigns({ routeUrl, paginationData }); + } + + @ApiOkResponse({ type: Campaign }) + @Get('/campaigns/:campaignId') + async getCampaignById( + @Param('campaignId') campaignId: string, + ): Promise { + return this.communityService.getCampaignById(campaignId); + } + + @ApiOkResponse({ type: CampaignRankPage }) + @ApiQuery({ + name: 'cursor', + required: false, + type: String, + }) + @Get('/campaigns/:campaignId/leaderboard') + async getCampaignLeaderboard( + @Param('campaignId') campaignId: string, + @RouteUrlDecorator() routeUrl: URL, + @PaginationDataDecorator() paginationData: PaginationData, + ): Promise { + return this.communityService.getCampaignLeaderboard({ + campaignId, + routeUrl, + paginationData, + }); + } + + @ApiOkResponse({ type: RankPage }) + @ApiQuery({ + name: 'cursor', + required: false, + type: String, + }) + @Get('/locking/leaderboard') + async getLeaderboard( + @RouteUrlDecorator() routeUrl: URL, + @PaginationDataDecorator() paginationData: PaginationData, + ): Promise { + return this.communityService.getLockingLeaderboard({ + routeUrl, + paginationData, + }); + } + + @ApiOkResponse({ type: Rank }) + @Get('/locking/:safeAddress/rank') + async getRank( + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, + ): Promise { + return this.communityService.getLockingRank(safeAddress); + } + + @ApiOkResponse({ type: LockingEventPage }) + @ApiQuery({ + name: 'cursor', + required: false, + type: String, + }) + @Get('/locking/:safeAddress/history') + async getLockingHistory( + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, + @RouteUrlDecorator() routeUrl: URL, + @PaginationDataDecorator() paginationData: PaginationData, + ): Promise { + return this.communityService.getLockingHistory({ + safeAddress, + routeUrl, + paginationData, + }); + } +} diff --git a/src/routes/community/community.module.ts b/src/routes/community/community.module.ts new file mode 100644 index 0000000000..6177b7e238 --- /dev/null +++ b/src/routes/community/community.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { LockingDomainModule } from '@/domain/locking/locking.domain.module'; +import { CommunityService } from '@/routes/community/community.service'; +import { CommunityController } from '@/routes/community/community.controller'; + +@Module({ + imports: [LockingDomainModule], + providers: [CommunityService], + controllers: [CommunityController], +}) +export class CommunityModule {} diff --git a/src/routes/locking/locking.service.ts b/src/routes/community/community.service.ts similarity index 96% rename from src/routes/locking/locking.service.ts rename to src/routes/community/community.service.ts index c8afbdb8db..850c73c606 100644 --- a/src/routes/locking/locking.service.ts +++ b/src/routes/community/community.service.ts @@ -11,16 +11,12 @@ import { import { Inject, Injectable } from '@nestjs/common'; @Injectable() -export class LockingService { +export class CommunityService { constructor( @Inject(ILockingRepository) private readonly lockingRepository: ILockingRepository, ) {} - async getCampaignById(campaignId: string): Promise { - return this.lockingRepository.getCampaignById(campaignId); - } - async getCampaigns(args: { routeUrl: URL; paginationData: PaginationData; @@ -43,17 +39,20 @@ export class LockingService { }; } - async getRank(safeAddress: `0x${string}`): Promise { - return this.lockingRepository.getRank(safeAddress); + async getCampaignById(campaignId: string): Promise { + return this.lockingRepository.getCampaignById(campaignId); } - async getLeaderboard(args: { + async getCampaignLeaderboard(args: { + campaignId: string; routeUrl: URL; paginationData: PaginationData; - }): Promise> { - const result = await this.lockingRepository.getLeaderboard( - args.paginationData, - ); + }): Promise> { + const result = await this.lockingRepository.getCampaignLeaderboard({ + campaignId: args.campaignId, + limit: args.paginationData.limit, + offset: args.paginationData.offset, + }); const nextUrl = cursorUrlFromLimitAndOffset(args.routeUrl, result.next); const previousUrl = cursorUrlFromLimitAndOffset( @@ -69,16 +68,13 @@ export class LockingService { }; } - async getCampaignLeaderboard(args: { - campaignId: string; + async getLockingLeaderboard(args: { routeUrl: URL; paginationData: PaginationData; - }): Promise> { - const result = await this.lockingRepository.getCampaignLeaderboard({ - campaignId: args.campaignId, - limit: args.paginationData.limit, - offset: args.paginationData.offset, - }); + }): Promise> { + const result = await this.lockingRepository.getLeaderboard( + args.paginationData, + ); const nextUrl = cursorUrlFromLimitAndOffset(args.routeUrl, result.next); const previousUrl = cursorUrlFromLimitAndOffset( @@ -94,6 +90,10 @@ export class LockingService { }; } + async getLockingRank(safeAddress: `0x${string}`): Promise { + return this.lockingRepository.getRank(safeAddress); + } + async getLockingHistory(args: { safeAddress: `0x${string}`; routeUrl: URL; diff --git a/src/routes/locking/locking.controller.spec.ts b/src/routes/locking/locking.controller.spec.ts index 2bc612210f..e87d94e68e 100644 --- a/src/routes/locking/locking.controller.spec.ts +++ b/src/routes/locking/locking.controller.spec.ts @@ -4,46 +4,20 @@ import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { AppModule } from '@/app.module'; -import { IConfigurationService } from '@/config/configuration.service.interface'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { CacheModule } from '@/datasources/cache/cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; import { NetworkModule } from '@/datasources/network/network.module'; -import { - INetworkService, - NetworkService, -} from '@/datasources/network/network.service.interface'; import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; import { RequestScopedLoggingModule } from '@/logging/logging.module'; import configuration from '@/config/entities/__tests__/configuration'; -import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; -import { pageBuilder } from '@/domain/entities/__tests__/page.builder'; -import { - lockEventItemBuilder, - unlockEventItemBuilder, - withdrawEventItemBuilder, - toJson as lockingEventToJson, -} from '@/domain/locking/entities/__tests__/locking-event.builder'; -import { LockingEvent } from '@/domain/locking/entities/locking-event.entity'; import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { getAddress } from 'viem'; -import { rankBuilder } from '@/domain/locking/entities/__tests__/rank.builder'; -import { PaginationData } from '@/routes/common/pagination/pagination.data'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; -import { - campaignBuilder, - toJson as campaignToJson, -} from '@/domain/locking/entities/__tests__/campaign.builder'; -import { Campaign } from '@/domain/locking/entities/campaign.entity'; -import { CampaignRank } from '@/domain/locking/entities/campaign-rank.entity'; -import { campaignRankBuilder } from '@/domain/locking/entities/__tests__/campaign-rank.builder'; describe('Locking (Unit)', () => { let app: INestApplication; - let lockingBaseUri: string; - let networkService: jest.MockedObjectDeep; beforeEach(async () => { jest.resetAllMocks(); @@ -63,10 +37,6 @@ describe('Locking (Unit)', () => { .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - lockingBaseUri = configurationService.get('locking.baseUri'); - networkService = moduleFixture.get(NetworkService); - app = await new TestAppProvider().provide(moduleFixture); await app.init(); }); @@ -75,668 +45,70 @@ describe('Locking (Unit)', () => { await app.close(); }); - describe('GET campaign', () => { - it('should get the campaign', async () => { - const campaign = campaignBuilder().build(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}`: - return Promise.resolve({ data: campaign, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .get(`/v1/locking/campaigns/${campaign.campaignId}`) - .expect(200) - .expect(campaignToJson(campaign) as Campaign); - }); - - it('should get the list of campaigns', async () => { - const campaignsPage = pageBuilder() - .with('results', [campaignBuilder().build()]) - .with('count', 1) - .with('previous', null) - .with('next', null) - .build(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${lockingBaseUri}/api/v1/campaigns`: - return Promise.resolve({ data: campaignsPage, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .get(`/v1/locking/campaigns`) - .expect(200) - .expect({ - count: 1, - next: null, - previous: null, - results: campaignsPage.results.map(campaignToJson), - }); - }); - - it('should validate the list of campaigns', async () => { - const invalidCampaigns = [{ invalid: 'campaign' }]; - const campaignsPage = pageBuilder() - .with('results', invalidCampaigns) - .with('count', 1) - .with('previous', null) - .with('next', null) - .build(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${lockingBaseUri}/api/v1/campaigns`: - return Promise.resolve({ data: campaignsPage, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .get(`/v1/locking/campaigns`) - .expect(500) - .expect({ - statusCode: 500, - message: 'Internal server error', - }); - }); - - it('should forward the pagination parameters', async () => { - const limit = faker.number.int({ min: 1, max: 10 }); - const offset = faker.number.int({ min: 1, max: 10 }); - const campaignsPage = pageBuilder() - .with('results', [campaignBuilder().build()]) - .with('count', 1) - .with('previous', null) - .with('next', null) - .build(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${lockingBaseUri}/api/v1/campaigns`: - return Promise.resolve({ data: campaignsPage, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .get( - `/v1/locking/campaigns?cursor=limit%3D${limit}%26offset%3D${offset}`, - ) - .expect(200) - .expect({ - count: 1, - next: null, - previous: null, - results: campaignsPage.results.map(campaignToJson), - }); - - expect(networkService.get).toHaveBeenCalledWith({ - url: `${lockingBaseUri}/api/v1/campaigns`, - networkRequest: { - params: { - limit, - offset, - }, - }, - }); - }); - - it('should forward errors from the service', async () => { - const statusCode = faker.internet.httpStatusCode({ - types: ['clientError', 'serverError'], - }); - const errorMessage = faker.word.words(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${lockingBaseUri}/api/v1/campaigns`: - return Promise.reject( - new NetworkResponseError( - new URL(`${lockingBaseUri}/api/v1/campaigns`), - { - status: statusCode, - } as Response, - { message: errorMessage, status: statusCode }, - ), - ); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .get(`/v1/locking/campaigns`) - .expect(statusCode) - .expect({ - message: errorMessage, - code: statusCode, - }); - }); - }); - - describe('GET leaderboard by campaign', () => { - it('should get the leaderboard by campaign', async () => { - const campaign = campaignBuilder().build(); - const campaignRankPage = pageBuilder() - .with('results', [ - campaignRankBuilder().build(), - campaignRankBuilder().build(), - ]) - .with('count', 2) - .with('previous', null) - .with('next', null) - .build(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}/leaderboard`: - return Promise.resolve({ data: campaignRankPage, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .get(`/v1/locking/campaigns/${campaign.campaignId}/leaderboard`) - .expect(200) - .expect({ - count: 2, - next: null, - previous: null, - results: campaignRankPage.results, - }); - }); - - it('should validate the response', async () => { - const campaign = campaignBuilder().build(); - const invalidCampaignRanks = [{ invalid: 'campaignRank' }]; - const campaignRankPage = pageBuilder() - .with('results', invalidCampaignRanks) - .with('count', invalidCampaignRanks.length) - .with('previous', null) - .with('next', null) - .build(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}/leaderboard`: - return Promise.resolve({ data: campaignRankPage, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .get(`/v1/locking/campaigns/${campaign.campaignId}/leaderboard`) - .expect(500) - .expect({ - statusCode: 500, - message: 'Internal server error', - }); - }); - - it('should forward the pagination parameters', async () => { - const limit = faker.number.int({ min: 1, max: 10 }); - const offset = faker.number.int({ min: 1, max: 10 }); - const campaign = campaignBuilder().build(); - const campaignRankPage = pageBuilder() - .with('results', [ - campaignRankBuilder().build(), - campaignRankBuilder().build(), - ]) - .with('count', 2) - .with('previous', null) - .with('next', null) - .build(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}/leaderboard`: - return Promise.resolve({ data: campaignRankPage, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .get( - `/v1/locking/campaigns/${campaign.campaignId}/leaderboard?cursor=limit%3D${limit}%26offset%3D${offset}`, - ) - .expect(200) - .expect({ - count: 2, - next: null, - previous: null, - results: campaignRankPage.results, - }); - - expect(networkService.get).toHaveBeenCalledWith({ - url: `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}/leaderboard`, - networkRequest: { - params: { - limit, - offset, - }, - }, - }); - }); - - it('should forward errors from the service', async () => { - const campaign = campaignBuilder().build(); - const statusCode = faker.internet.httpStatusCode({ - types: ['clientError', 'serverError'], - }); - const errorMessage = faker.word.words(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}/leaderboard`: - return Promise.reject( - new NetworkResponseError( - new URL(url), - { - status: statusCode, - } as Response, - { message: errorMessage, status: statusCode }, - ), - ); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .get(`/v1/locking/campaigns/${campaign.campaignId}/leaderboard`) - .expect(statusCode) - .expect({ - message: errorMessage, - code: statusCode, - }); - }); - }); - - describe('GET rank', () => { - it('should get the rank', async () => { - const rank = rankBuilder().build(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${lockingBaseUri}/api/v1/leaderboard/${rank.holder}`: - return Promise.resolve({ data: rank, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .get(`/v1/locking/leaderboard/rank/${rank.holder}`) - .expect(200) - .expect(rank); - }); - - it('should validate the Safe address in URL', async () => { - const safeAddress = faker.string.alphanumeric(); - - await request(app.getHttpServer()) - .get(`/v1/locking/leaderboard/rank/${safeAddress}`) - .expect(422) - .expect({ - statusCode: 422, - code: 'custom', - message: 'Invalid address', - path: [], - }); - }); - - it('should validate the response', async () => { - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const rank = { invalid: 'rank' }; - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${lockingBaseUri}/api/v1/leaderboard/${safeAddress}`: - return Promise.resolve({ data: rank, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .get(`/v1/locking/leaderboard/rank/${safeAddress}`) - .expect(500) - .expect({ - statusCode: 500, - message: 'Internal server error', - }); - }); - - it('should forward an error from the service', async () => { - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const statusCode = faker.internet.httpStatusCode({ - types: ['clientError', 'serverError'], - }); - const errorMessage = faker.word.words(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${lockingBaseUri}/api/v1/leaderboard/${safeAddress}`: - return Promise.reject( - new NetworkResponseError( - new URL(`${lockingBaseUri}/api/v1/leaderboard/${safeAddress}`), - { - status: statusCode, - } as Response, - { message: errorMessage, status: statusCode }, - ), - ); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); + describe('GET /locking/leaderboard/rank/:safeAddress', () => { + it('should return 302 and redirect to the new endpoint', async () => { + const safeAddress = faker.finance.ethereumAddress(); await request(app.getHttpServer()) .get(`/v1/locking/leaderboard/rank/${safeAddress}`) - .expect(statusCode) - .expect({ - message: errorMessage, - code: statusCode, + .expect(308) + .expect((res) => { + expect(res.get('location')).toBe( + `/v1/community/locking/${safeAddress}/rank`, + ); }); }); }); - describe('GET leaderboard', () => { - it('should get the leaderboard', async () => { - const leaderboard = pageBuilder() - .with('results', [rankBuilder().build()]) - .build(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${lockingBaseUri}/api/v1/leaderboard`: - return Promise.resolve({ data: leaderboard, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .get(`/v1/locking/leaderboard`) - .expect(200) - .expect(({ body }) => { - expect(body).toEqual({ - count: leaderboard.count, - next: expect.any(String), - previous: expect.any(String), - results: leaderboard.results, - }); - }); - - expect(networkService.get).toHaveBeenCalledWith({ - url: `${lockingBaseUri}/api/v1/leaderboard`, - networkRequest: { - params: { - limit: PaginationData.DEFAULT_LIMIT, - offset: PaginationData.DEFAULT_OFFSET, - }, - }, - }); - }); - - it('should forward the pagination parameters', async () => { - const limit = faker.number.int({ min: 1, max: 10 }); - const offset = faker.number.int({ min: 1, max: 10 }); - const leaderboard = pageBuilder() - .with('results', [rankBuilder().build()]) - .build(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${lockingBaseUri}/api/v1/leaderboard`: - return Promise.resolve({ data: leaderboard, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .get( - `/v1/locking/leaderboard?cursor=limit%3D${limit}%26offset%3D${offset}`, - ) - .expect(200) - .expect(({ body }) => { - expect(body).toEqual({ - count: leaderboard.count, - next: expect.any(String), - previous: expect.any(String), - results: leaderboard.results, - }); - }); - - expect(networkService.get).toHaveBeenCalledWith({ - url: `${lockingBaseUri}/api/v1/leaderboard`, - networkRequest: { - params: { - limit, - offset, - }, - }, - }); - }); - - it('should validate the response', async () => { - const leaderboard = pageBuilder() - .with('results', [{ invalid: 'rank' }]) - .build(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${lockingBaseUri}/api/v1/leaderboard`: - return Promise.resolve({ data: leaderboard, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - + describe('GET /locking/leaderboard', () => { + it('should return 302 and redirect to the new endpoint', async () => { await request(app.getHttpServer()) - .get(`/v1/locking/leaderboard`) - .expect(500) - .expect({ - statusCode: 500, - message: 'Internal server error', + .get('/v1/locking/leaderboard') + .expect(308) + .expect((res) => { + expect(res.get('location')).toBe('/v1/community/locking/leaderboard'); }); }); - it('should forward an error from the service', async () => { - const statusCode = faker.internet.httpStatusCode({ - types: ['clientError', 'serverError'], - }); - const errorMessage = faker.word.words(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${lockingBaseUri}/api/v1/leaderboard`: - return Promise.reject( - new NetworkResponseError( - new URL(`${lockingBaseUri}/api/v1/leaderboard`), - { - status: statusCode, - } as Response, - { message: errorMessage, status: statusCode }, - ), - ); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); + it('should return 302 and redirect to the new endpoint with cursor', async () => { + const cursor = 'limit%3Daa%26offset%3D2'; await request(app.getHttpServer()) - .get(`/v1/locking/leaderboard`) - .expect(statusCode) - .expect({ - message: errorMessage, - code: statusCode, + .get(`/v1/locking/leaderboard/?cursor=${cursor}`) + .expect(308) + .expect((res) => { + expect(res.get('location')).toBe( + `/v1/community/locking/leaderboard/?cursor=${cursor}`, + ); }); }); }); - describe('GET locking history', () => { - it('should get locking history', async () => { + describe('GET /locking/:safeAddress/history', () => { + it('should return 302 and redirect to the new endpoint', async () => { const safeAddress = faker.finance.ethereumAddress(); - const lockingHistory = [ - lockEventItemBuilder().build(), - unlockEventItemBuilder().build(), - withdrawEventItemBuilder().build(), - ]; - const lockingHistoryPage = pageBuilder() - .with('results', lockingHistory) - .with('count', lockingHistory.length) - .with('previous', null) - .with('next', null) - .build(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - // Service will have checksummed address - case `${lockingBaseUri}/api/v1/all-events/${getAddress(safeAddress)}`: - return Promise.resolve({ data: lockingHistoryPage, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); await request(app.getHttpServer()) .get(`/v1/locking/${safeAddress}/history`) - .expect(200) - .expect({ - count: lockingHistoryPage.count, - next: null, - previous: null, - results: lockingHistoryPage.results.map(lockingEventToJson), + .expect(308) + .expect((res) => { + expect(res.get('location')).toBe( + `/v1/community/locking/${safeAddress}/history`, + ); }); - - expect(networkService.get).toHaveBeenCalledWith({ - url: `${lockingBaseUri}/api/v1/all-events/${getAddress(safeAddress)}`, - networkRequest: { - params: { - limit: PaginationData.DEFAULT_LIMIT, - offset: PaginationData.DEFAULT_OFFSET, - }, - }, - }); }); - it('should forward the pagination parameters', async () => { + it('should return 302 and redirect to the new endpoint with cursor', async () => { const safeAddress = faker.finance.ethereumAddress(); - const limit = faker.number.int({ min: 1, max: 10 }); - const offset = faker.number.int({ min: 1, max: 10 }); - const lockingHistory = [ - lockEventItemBuilder().build(), - unlockEventItemBuilder().build(), - withdrawEventItemBuilder().build(), - ]; - const lockingHistoryPage = pageBuilder() - .with('results', lockingHistory) - .with('count', lockingHistory.length) - .with('previous', null) - .with('next', null) - .build(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - // Service will have checksummed address - case `${lockingBaseUri}/api/v1/all-events/${getAddress(safeAddress)}`: - return Promise.resolve({ data: lockingHistoryPage, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); + const cursor = 'limit%3Daa%26offset%3D2'; await request(app.getHttpServer()) - .get( - `/v1/locking/${safeAddress}/history?cursor=limit%3D${limit}%26offset%3D${offset}`, - ) - .expect(200) - .expect({ - count: lockingHistoryPage.count, - next: null, - previous: null, - results: lockingHistoryPage.results.map(lockingEventToJson), - }); - - expect(networkService.get).toHaveBeenCalledWith({ - url: `${lockingBaseUri}/api/v1/all-events/${getAddress(safeAddress)}`, - networkRequest: { - params: { - limit, - offset, - }, - }, - }); - }); - - it('should validate the Safe address in URL', async () => { - const safeAddress = faker.string.alphanumeric(); - - await request(app.getHttpServer()) - .get(`/v1/locking/${safeAddress}/history`) - .expect(422) - .expect({ - statusCode: 422, - code: 'custom', - message: 'Invalid address', - path: [], - }); - }); - - it('should validate the response', async () => { - const safeAddress = faker.finance.ethereumAddress(); - const invalidLockingHistory = [{ invalid: 'value' }]; - const lockingHistoryPage = pageBuilder() - .with('results', invalidLockingHistory) - .with('count', invalidLockingHistory.length) - .with('previous', null) - .with('next', null) - .build(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - // Service will have checksummed address - case `${lockingBaseUri}/api/v1/all-events/${getAddress(safeAddress)}`: - return Promise.resolve({ data: lockingHistoryPage, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .get(`/v1/locking/${safeAddress}/history`) - .expect(500) - .expect({ - statusCode: 500, - message: 'Internal server error', - }); - }); - - it('should forward an error from the service', async () => { - const safeAddress = faker.finance.ethereumAddress(); - const statusCode = faker.internet.httpStatusCode({ - types: ['clientError', 'serverError'], - }); - const errorMessage = faker.word.words(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${lockingBaseUri}/api/v1/all-events/${getAddress(safeAddress)}`: - return Promise.reject( - new NetworkResponseError( - new URL(`${lockingBaseUri}/v1/locking/${safeAddress}/history`), - { - status: statusCode, - } as Response, - { message: errorMessage, status: statusCode }, - ), - ); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .get(`/v1/locking/${safeAddress}/history`) - .expect(statusCode) - .expect({ - message: errorMessage, - code: statusCode, + .get(`/v1/locking/${safeAddress}/history/?cursor=${cursor}`) + .expect(308) + .expect((res) => { + expect(res.get('location')).toBe( + `/v1/community/locking/${safeAddress}/history/?cursor=${cursor}`, + ); }); }); }); diff --git a/src/routes/locking/locking.controller.ts b/src/routes/locking/locking.controller.ts index 0b0bd68055..84922046a2 100644 --- a/src/routes/locking/locking.controller.ts +++ b/src/routes/locking/locking.controller.ts @@ -1,17 +1,21 @@ import { LockingEventPage } from '@/routes/locking/entities/locking-event.page.entity'; import { Rank } from '@/routes/locking/entities/rank.entity'; -import { PaginationDataDecorator } from '@/routes/common/decorators/pagination.data.decorator'; -import { RouteUrlDecorator } from '@/routes/common/decorators/route.url.decorator'; import { RankPage } from '@/routes/locking/entities/rank.page.entity'; -import { PaginationData } from '@/routes/common/pagination/pagination.data'; -import { LockingService } from '@/routes/locking/locking.service'; -import { AddressSchema } from '@/validation/entities/schemas/address.schema'; -import { ValidationPipe } from '@/validation/pipes/validation.pipe'; -import { Controller, Get, Param } from '@nestjs/common'; -import { ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger'; -import { Campaign } from '@/routes/locking/entities/campaign.entity'; -import { CampaignPage } from '@/routes/locking/entities/campaign.page.entity'; -import { CampaignRankPage } from '@/routes/locking/entities/campaign-rank.page.entity'; +import { + Controller, + Get, + HttpStatus, + Param, + Redirect, + Req, +} from '@nestjs/common'; +import { + ApiOkResponse, + ApiOperation, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; +import { Request } from 'express'; @ApiTags('locking') @Controller({ @@ -19,89 +23,48 @@ import { CampaignRankPage } from '@/routes/locking/entities/campaign-rank.page.e version: '1', }) export class LockingController { - constructor(private readonly lockingService: LockingService) {} - - @ApiOkResponse({ type: Campaign }) - @Get('/campaigns/:campaignId') - async getCampaignById( - @Param('campaignId') campaignId: string, - ): Promise { - return this.lockingService.getCampaignById(campaignId); - } - - @ApiOkResponse({ type: CampaignPage }) - @ApiQuery({ - name: 'cursor', - required: false, - type: String, - }) - @Get('/campaigns') - async getCampaigns( - @RouteUrlDecorator() routeUrl: URL, - @PaginationDataDecorator() paginationData: PaginationData, - ): Promise { - return this.lockingService.getCampaigns({ routeUrl, paginationData }); - } - - @ApiOkResponse({ type: CampaignRankPage }) - @ApiQuery({ - name: 'cursor', - required: false, - type: String, - }) - @Get('/campaigns/:campaignId/leaderboard') - async getCampaignLeaderboard( - @Param('campaignId') campaignId: string, - @RouteUrlDecorator() routeUrl: URL, - @PaginationDataDecorator() paginationData: PaginationData, - ): Promise { - return this.lockingService.getCampaignLeaderboard({ - campaignId, - routeUrl, - paginationData, - }); - } - + @ApiOperation({ deprecated: true }) @ApiOkResponse({ type: Rank }) + @Redirect(undefined, HttpStatus.PERMANENT_REDIRECT) @Get('/leaderboard/rank/:safeAddress') - async getRank( - @Param('safeAddress', new ValidationPipe(AddressSchema)) + getRank( + @Param('safeAddress') safeAddress: `0x${string}`, - ): Promise { - return this.lockingService.getRank(safeAddress); + ): { url: string } { + return { url: `/v1/community/locking/${safeAddress}/rank` }; } + @ApiOperation({ deprecated: true }) @ApiOkResponse({ type: RankPage }) @ApiQuery({ name: 'cursor', required: false, type: String, }) + @Redirect(undefined, HttpStatus.PERMANENT_REDIRECT) @Get('/leaderboard') - async getLeaderboard( - @RouteUrlDecorator() routeUrl: URL, - @PaginationDataDecorator() paginationData: PaginationData, - ): Promise { - return this.lockingService.getLeaderboard({ routeUrl, paginationData }); + getLeaderboard(@Req() request: Request): { url: string } { + const newUrl = '/v1/community/locking/leaderboard'; + const search = request.url.split('?')[1]; + return { + url: search ? `${newUrl}/?${search}` : newUrl, + }; } + @ApiOperation({ deprecated: true }) @ApiOkResponse({ type: LockingEventPage }) @ApiQuery({ name: 'cursor', required: false, type: String, }) + @Redirect(undefined, HttpStatus.PERMANENT_REDIRECT) @Get('/:safeAddress/history') - async getLockingHistory( - @Param('safeAddress', new ValidationPipe(AddressSchema)) - safeAddress: `0x${string}`, - @RouteUrlDecorator() routeUrl: URL, - @PaginationDataDecorator() paginationData: PaginationData, - ): Promise { - return this.lockingService.getLockingHistory({ - safeAddress, - routeUrl, - paginationData, - }); + getLockingHistory(@Req() request: Request): { url: string } { + const newUrl = `/v1/community/locking/${request.params.safeAddress}/history`; + const search = request.url.split('?')[1]; + return { + url: search ? `${newUrl}/?${search}` : newUrl, + }; } } diff --git a/src/routes/locking/locking.module.ts b/src/routes/locking/locking.module.ts index ee41515480..502f896d7d 100644 --- a/src/routes/locking/locking.module.ts +++ b/src/routes/locking/locking.module.ts @@ -1,11 +1,9 @@ import { Module } from '@nestjs/common'; import { LockingController } from '@/routes/locking/locking.controller'; -import { LockingService } from '@/routes/locking/locking.service'; import { LockingDomainModule } from '@/domain/locking/locking.domain.module'; @Module({ imports: [LockingDomainModule], - providers: [LockingService], controllers: [LockingController], }) export class LockingModule {} From 9b6371b2fce867b9cb02dc2f8ff6ed9b78b60b8f Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Fri, 17 May 2024 11:22:48 +0200 Subject: [PATCH 008/207] Validate campaign retrieved by ID (#1561) Adds validation of the campaign retrieved by ID: - Add `CampaignSchema` parsing to `ILockingService['getCampaignById']` - Unskip relevant test --- src/domain/locking/locking.repository.ts | 4 +++- src/routes/community/community.controller.spec.ts | 3 +-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/domain/locking/locking.repository.ts b/src/domain/locking/locking.repository.ts index 464c36d0a4..5329192047 100644 --- a/src/domain/locking/locking.repository.ts +++ b/src/domain/locking/locking.repository.ts @@ -3,6 +3,7 @@ import { ILockingApi } from '@/domain/interfaces/locking-api.interface'; import { Campaign, CampaignPageSchema, + CampaignSchema, } from '@/domain/locking/entities/campaign.entity'; import { CampaignRank, @@ -26,7 +27,8 @@ export class LockingRepository implements ILockingRepository { ) {} async getCampaignById(campaignId: string): Promise { - return this.lockingApi.getCampaignById(campaignId); + const campaign = await this.lockingApi.getCampaignById(campaignId); + return CampaignSchema.parse(campaign); } async getCampaigns(args: { diff --git a/src/routes/community/community.controller.spec.ts b/src/routes/community/community.controller.spec.ts index a279f8cf34..93cd44c788 100644 --- a/src/routes/community/community.controller.spec.ts +++ b/src/routes/community/community.controller.spec.ts @@ -220,8 +220,7 @@ describe('Community (Unit)', () => { .expect(campaignToJson(campaign) as Campaign); }); - // TODO: Enable when validation is implemented - it.skip('should validate the response', async () => { + it('should validate the response', async () => { const invalidCampaign = { campaignId: faker.string.uuid(), invalid: 'campaign', From 114b28bb7a98a9c4c5a411163449f24dba6a8d4b Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Fri, 17 May 2024 11:38:12 +0200 Subject: [PATCH 009/207] Rename "locking" domain "community" (#1562) Renames "locking" domain to "community" - the folder and all related components within it: - `/src/domain/locking` -> `/src/domain/community` - `ILockingRepository`-> `ICommunityRepository` - `LockingRepository` -> `CommunityRepository` - `LockingDomainModule` -> `CommunityDomainModule` --- .../locking-api/locking-api.service.spec.ts | 10 +++---- .../locking-api/locking-api.service.ts | 8 +++--- .../community/community.domain.module.ts | 11 ++++++++ .../community.repository.interface.ts} | 12 ++++----- .../community.repository.ts} | 16 ++++++------ .../__tests__/activity-metadata.builder.ts | 2 +- .../__tests__/campaign-rank.builder.ts | 2 +- .../entities/__tests__/campaign.builder.ts | 4 +-- .../__tests__/locking-event.builder.ts | 4 +-- .../entities/__tests__/rank.builder.ts | 2 +- .../entities/activity-metadata.entity.ts | 0 .../entities/campaign-rank.entity.ts | 0 .../entities/campaign.entity.ts | 2 +- .../entities/locking-event.entity.ts | 2 +- src/domain/community/entities/rank.entity.ts | 4 +++ .../activity-metadata.schema.spec.ts | 4 +-- .../__tests__/campaign-rank.schema.spec.ts | 4 +-- .../schemas/__tests__/campaign.schema.spec.ts | 4 +-- .../__tests__/locking-event.schema.spec.ts | 4 +-- .../schemas/__tests__/rank.schema.spec.ts | 4 +-- .../entities/schemas/locking-event.schema.ts | 0 .../entities/schemas/rank.schema.ts | 0 .../interfaces/locking-api.interface.ts | 8 +++--- src/domain/locking/entities/rank.entity.ts | 4 --- src/domain/locking/locking.domain.module.ts | 12 --------- .../community/community.controller.spec.ts | 14 +++++----- src/routes/community/community.module.ts | 4 +-- src/routes/community/community.service.ts | 26 +++++++++---------- .../entities/activity-metadata.entity.ts | 2 +- .../locking/entities/campaign-rank.entity.ts | 2 +- .../locking/entities/campaign.entity.ts | 2 +- .../entities/locking-event.page.entity.ts | 4 +-- src/routes/locking/entities/rank.entity.ts | 2 +- src/routes/locking/locking.module.ts | 2 -- 34 files changed, 89 insertions(+), 92 deletions(-) create mode 100644 src/domain/community/community.domain.module.ts rename src/domain/{locking/locking.repository.interface.ts => community/community.repository.interface.ts} (60%) rename src/domain/{locking/locking.repository.ts => community/community.repository.ts} (75%) rename src/domain/{locking => community}/entities/__tests__/activity-metadata.builder.ts (81%) rename src/domain/{locking => community}/entities/__tests__/campaign-rank.builder.ts (85%) rename src/domain/{locking => community}/entities/__tests__/campaign.builder.ts (82%) rename src/domain/{locking => community}/entities/__tests__/locking-event.builder.ts (94%) rename src/domain/{locking => community}/entities/__tests__/rank.builder.ts (88%) rename src/domain/{locking => community}/entities/activity-metadata.entity.ts (100%) rename src/domain/{locking => community}/entities/campaign-rank.entity.ts (100%) rename src/domain/{locking => community}/entities/campaign.entity.ts (85%) rename src/domain/{locking => community}/entities/locking-event.entity.ts (85%) create mode 100644 src/domain/community/entities/rank.entity.ts rename src/domain/{locking => community}/entities/schemas/__tests__/activity-metadata.schema.spec.ts (90%) rename src/domain/{locking => community}/entities/schemas/__tests__/campaign-rank.schema.spec.ts (91%) rename src/domain/{locking => community}/entities/schemas/__tests__/campaign.schema.spec.ts (93%) rename src/domain/{locking => community}/entities/schemas/__tests__/locking-event.schema.spec.ts (99%) rename src/domain/{locking => community}/entities/schemas/__tests__/rank.schema.spec.ts (92%) rename src/domain/{locking => community}/entities/schemas/locking-event.schema.ts (100%) rename src/domain/{locking => community}/entities/schemas/rank.schema.ts (100%) delete mode 100644 src/domain/locking/entities/rank.entity.ts delete mode 100644 src/domain/locking/locking.domain.module.ts diff --git a/src/datasources/locking-api/locking-api.service.spec.ts b/src/datasources/locking-api/locking-api.service.spec.ts index d8574bf03d..04b1673a3d 100644 --- a/src/datasources/locking-api/locking-api.service.spec.ts +++ b/src/datasources/locking-api/locking-api.service.spec.ts @@ -10,12 +10,12 @@ import { lockEventItemBuilder, unlockEventItemBuilder, withdrawEventItemBuilder, -} from '@/domain/locking/entities/__tests__/locking-event.builder'; +} from '@/domain/community/entities/__tests__/locking-event.builder'; import { getAddress } from 'viem'; -import { rankBuilder } from '@/domain/locking/entities/__tests__/rank.builder'; -import { campaignBuilder } from '@/domain/locking/entities/__tests__/campaign.builder'; -import { campaignRankBuilder } from '@/domain/locking/entities/__tests__/campaign-rank.builder'; -import { CampaignRank } from '@/domain/locking/entities/campaign-rank.entity'; +import { rankBuilder } from '@/domain/community/entities/__tests__/rank.builder'; +import { campaignBuilder } from '@/domain/community/entities/__tests__/campaign.builder'; +import { campaignRankBuilder } from '@/domain/community/entities/__tests__/campaign-rank.builder'; +import { CampaignRank } from '@/domain/community/entities/campaign-rank.entity'; const networkService = { get: jest.fn(), diff --git a/src/datasources/locking-api/locking-api.service.ts b/src/datasources/locking-api/locking-api.service.ts index 6c03a8c3e8..d15e94fc42 100644 --- a/src/datasources/locking-api/locking-api.service.ts +++ b/src/datasources/locking-api/locking-api.service.ts @@ -6,10 +6,10 @@ import { } from '@/datasources/network/network.service.interface'; import { Page } from '@/domain/entities/page.entity'; import { ILockingApi } from '@/domain/interfaces/locking-api.interface'; -import { Campaign } from '@/domain/locking/entities/campaign.entity'; -import { CampaignRank } from '@/domain/locking/entities/campaign-rank.entity'; -import { LockingEvent } from '@/domain/locking/entities/locking-event.entity'; -import { Rank } from '@/domain/locking/entities/rank.entity'; +import { Campaign } from '@/domain/community/entities/campaign.entity'; +import { CampaignRank } from '@/domain/community/entities/campaign-rank.entity'; +import { LockingEvent } from '@/domain/community/entities/locking-event.entity'; +import { Rank } from '@/domain/community/entities/rank.entity'; import { Inject } from '@nestjs/common'; export class LockingApi implements ILockingApi { diff --git a/src/domain/community/community.domain.module.ts b/src/domain/community/community.domain.module.ts new file mode 100644 index 0000000000..f139e797d7 --- /dev/null +++ b/src/domain/community/community.domain.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { LockingApiModule } from '@/datasources/locking-api/locking-api.module'; +import { ICommunityRepository } from '@/domain/community/community.repository.interface'; +import { CommunityRepository } from '@/domain/community/community.repository'; + +@Module({ + imports: [LockingApiModule], + providers: [{ provide: ICommunityRepository, useClass: CommunityRepository }], + exports: [ICommunityRepository], +}) +export class CommunityDomainModule {} diff --git a/src/domain/locking/locking.repository.interface.ts b/src/domain/community/community.repository.interface.ts similarity index 60% rename from src/domain/locking/locking.repository.interface.ts rename to src/domain/community/community.repository.interface.ts index bf4290e8f6..e5726624ca 100644 --- a/src/domain/locking/locking.repository.interface.ts +++ b/src/domain/community/community.repository.interface.ts @@ -1,12 +1,12 @@ import { Page } from '@/domain/entities/page.entity'; -import { Campaign } from '@/domain/locking/entities/campaign.entity'; -import { CampaignRank } from '@/domain/locking/entities/campaign-rank.entity'; -import { LockingEvent } from '@/domain/locking/entities/locking-event.entity'; -import { Rank } from '@/domain/locking/entities/rank.entity'; +import { Campaign } from '@/domain/community/entities/campaign.entity'; +import { CampaignRank } from '@/domain/community/entities/campaign-rank.entity'; +import { LockingEvent } from '@/domain/community/entities/locking-event.entity'; +import { Rank } from '@/domain/community/entities/rank.entity'; -export const ILockingRepository = Symbol('ILockingRepository'); +export const ICommunityRepository = Symbol('ICommunityRepository'); -export interface ILockingRepository { +export interface ICommunityRepository { getCampaignById(campaignId: string): Promise; getCampaigns(args: { diff --git a/src/domain/locking/locking.repository.ts b/src/domain/community/community.repository.ts similarity index 75% rename from src/domain/locking/locking.repository.ts rename to src/domain/community/community.repository.ts index 5329192047..b04261ac96 100644 --- a/src/domain/locking/locking.repository.ts +++ b/src/domain/community/community.repository.ts @@ -4,23 +4,23 @@ import { Campaign, CampaignPageSchema, CampaignSchema, -} from '@/domain/locking/entities/campaign.entity'; +} from '@/domain/community/entities/campaign.entity'; import { CampaignRank, CampaignRankPageSchema, -} from '@/domain/locking/entities/campaign-rank.entity'; -import { LockingEvent } from '@/domain/locking/entities/locking-event.entity'; -import { Rank } from '@/domain/locking/entities/rank.entity'; -import { LockingEventPageSchema } from '@/domain/locking/entities/schemas/locking-event.schema'; +} from '@/domain/community/entities/campaign-rank.entity'; +import { LockingEvent } from '@/domain/community/entities/locking-event.entity'; +import { Rank } from '@/domain/community/entities/rank.entity'; +import { LockingEventPageSchema } from '@/domain/community/entities/schemas/locking-event.schema'; import { RankPageSchema, RankSchema, -} from '@/domain/locking/entities/schemas/rank.schema'; -import { ILockingRepository } from '@/domain/locking/locking.repository.interface'; +} from '@/domain/community/entities/schemas/rank.schema'; +import { ICommunityRepository } from '@/domain/community/community.repository.interface'; import { Inject, Injectable } from '@nestjs/common'; @Injectable() -export class LockingRepository implements ILockingRepository { +export class CommunityRepository implements ICommunityRepository { constructor( @Inject(ILockingApi) private readonly lockingApi: ILockingApi, diff --git a/src/domain/locking/entities/__tests__/activity-metadata.builder.ts b/src/domain/community/entities/__tests__/activity-metadata.builder.ts similarity index 81% rename from src/domain/locking/entities/__tests__/activity-metadata.builder.ts rename to src/domain/community/entities/__tests__/activity-metadata.builder.ts index 1b500f0e4d..04f6e8e01f 100644 --- a/src/domain/locking/entities/__tests__/activity-metadata.builder.ts +++ b/src/domain/community/entities/__tests__/activity-metadata.builder.ts @@ -1,5 +1,5 @@ import { Builder, IBuilder } from '@/__tests__/builder'; -import { ActivityMetadata } from '@/domain/locking/entities/activity-metadata.entity'; +import { ActivityMetadata } from '@/domain/community/entities/activity-metadata.entity'; import { faker } from '@faker-js/faker'; export function activityMetadataBuilder(): IBuilder { diff --git a/src/domain/locking/entities/__tests__/campaign-rank.builder.ts b/src/domain/community/entities/__tests__/campaign-rank.builder.ts similarity index 85% rename from src/domain/locking/entities/__tests__/campaign-rank.builder.ts rename to src/domain/community/entities/__tests__/campaign-rank.builder.ts index 7b3b2cced9..4bc1825a5b 100644 --- a/src/domain/locking/entities/__tests__/campaign-rank.builder.ts +++ b/src/domain/community/entities/__tests__/campaign-rank.builder.ts @@ -1,5 +1,5 @@ import { Builder, IBuilder } from '@/__tests__/builder'; -import { CampaignRank } from '@/domain/locking/entities/campaign-rank.entity'; +import { CampaignRank } from '@/domain/community/entities/campaign-rank.entity'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; diff --git a/src/domain/locking/entities/__tests__/campaign.builder.ts b/src/domain/community/entities/__tests__/campaign.builder.ts similarity index 82% rename from src/domain/locking/entities/__tests__/campaign.builder.ts rename to src/domain/community/entities/__tests__/campaign.builder.ts index 04eb3d543e..5deeca3b05 100644 --- a/src/domain/locking/entities/__tests__/campaign.builder.ts +++ b/src/domain/community/entities/__tests__/campaign.builder.ts @@ -1,6 +1,6 @@ import { IBuilder, Builder } from '@/__tests__/builder'; -import { activityMetadataBuilder } from '@/domain/locking/entities/__tests__/activity-metadata.builder'; -import { Campaign } from '@/domain/locking/entities/campaign.entity'; +import { activityMetadataBuilder } from '@/domain/community/entities/__tests__/activity-metadata.builder'; +import { Campaign } from '@/domain/community/entities/campaign.entity'; import { faker } from '@faker-js/faker'; export function campaignBuilder(): IBuilder { diff --git a/src/domain/locking/entities/__tests__/locking-event.builder.ts b/src/domain/community/entities/__tests__/locking-event.builder.ts similarity index 94% rename from src/domain/locking/entities/__tests__/locking-event.builder.ts rename to src/domain/community/entities/__tests__/locking-event.builder.ts index d412c6d43d..e39f7d4757 100644 --- a/src/domain/locking/entities/__tests__/locking-event.builder.ts +++ b/src/domain/community/entities/__tests__/locking-event.builder.ts @@ -3,13 +3,13 @@ import { LockEventItem, UnlockEventItem, WithdrawEventItem, -} from '@/domain/locking/entities/locking-event.entity'; +} from '@/domain/community/entities/locking-event.entity'; import { LockEventItemSchema, LockingEventType, UnlockEventItemSchema, WithdrawEventItemSchema, -} from '@/domain/locking/entities/schemas/locking-event.schema'; +} from '@/domain/community/entities/schemas/locking-event.schema'; import { faker } from '@faker-js/faker'; import { Hex, getAddress } from 'viem'; import { z } from 'zod'; diff --git a/src/domain/locking/entities/__tests__/rank.builder.ts b/src/domain/community/entities/__tests__/rank.builder.ts similarity index 88% rename from src/domain/locking/entities/__tests__/rank.builder.ts rename to src/domain/community/entities/__tests__/rank.builder.ts index bf766210e2..b9ad11fff7 100644 --- a/src/domain/locking/entities/__tests__/rank.builder.ts +++ b/src/domain/community/entities/__tests__/rank.builder.ts @@ -1,5 +1,5 @@ import { IBuilder, Builder } from '@/__tests__/builder'; -import { Rank } from '@/domain/locking/entities/rank.entity'; +import { Rank } from '@/domain/community/entities/rank.entity'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; diff --git a/src/domain/locking/entities/activity-metadata.entity.ts b/src/domain/community/entities/activity-metadata.entity.ts similarity index 100% rename from src/domain/locking/entities/activity-metadata.entity.ts rename to src/domain/community/entities/activity-metadata.entity.ts diff --git a/src/domain/locking/entities/campaign-rank.entity.ts b/src/domain/community/entities/campaign-rank.entity.ts similarity index 100% rename from src/domain/locking/entities/campaign-rank.entity.ts rename to src/domain/community/entities/campaign-rank.entity.ts diff --git a/src/domain/locking/entities/campaign.entity.ts b/src/domain/community/entities/campaign.entity.ts similarity index 85% rename from src/domain/locking/entities/campaign.entity.ts rename to src/domain/community/entities/campaign.entity.ts index 52a02d54b0..eddd4ed12c 100644 --- a/src/domain/locking/entities/campaign.entity.ts +++ b/src/domain/community/entities/campaign.entity.ts @@ -1,5 +1,5 @@ import { buildPageSchema } from '@/domain/entities/schemas/page.schema.factory'; -import { ActivityMetadataSchema } from '@/domain/locking/entities/activity-metadata.entity'; +import { ActivityMetadataSchema } from '@/domain/community/entities/activity-metadata.entity'; import { z } from 'zod'; export type Campaign = z.infer; diff --git a/src/domain/locking/entities/locking-event.entity.ts b/src/domain/community/entities/locking-event.entity.ts similarity index 85% rename from src/domain/locking/entities/locking-event.entity.ts rename to src/domain/community/entities/locking-event.entity.ts index af08cd6708..019e9b98d9 100644 --- a/src/domain/locking/entities/locking-event.entity.ts +++ b/src/domain/community/entities/locking-event.entity.ts @@ -3,7 +3,7 @@ import { LockingEventSchema, UnlockEventItemSchema, WithdrawEventItemSchema, -} from '@/domain/locking/entities/schemas/locking-event.schema'; +} from '@/domain/community/entities/schemas/locking-event.schema'; import { z } from 'zod'; export type LockEventItem = z.infer; diff --git a/src/domain/community/entities/rank.entity.ts b/src/domain/community/entities/rank.entity.ts new file mode 100644 index 0000000000..63cd33cbae --- /dev/null +++ b/src/domain/community/entities/rank.entity.ts @@ -0,0 +1,4 @@ +import { RankSchema } from '@/domain/community/entities/schemas/rank.schema'; +import { z } from 'zod'; + +export type Rank = z.infer; diff --git a/src/domain/locking/entities/schemas/__tests__/activity-metadata.schema.spec.ts b/src/domain/community/entities/schemas/__tests__/activity-metadata.schema.spec.ts similarity index 90% rename from src/domain/locking/entities/schemas/__tests__/activity-metadata.schema.spec.ts rename to src/domain/community/entities/schemas/__tests__/activity-metadata.schema.spec.ts index 0b1e3272e7..3c37694aea 100644 --- a/src/domain/locking/entities/schemas/__tests__/activity-metadata.schema.spec.ts +++ b/src/domain/community/entities/schemas/__tests__/activity-metadata.schema.spec.ts @@ -1,5 +1,5 @@ -import { activityMetadataBuilder } from '@/domain/locking/entities/__tests__/activity-metadata.builder'; -import { ActivityMetadataSchema } from '@/domain/locking/entities/activity-metadata.entity'; +import { activityMetadataBuilder } from '@/domain/community/entities/__tests__/activity-metadata.builder'; +import { ActivityMetadataSchema } from '@/domain/community/entities/activity-metadata.entity'; import { faker } from '@faker-js/faker'; import { ZodError } from 'zod'; diff --git a/src/domain/locking/entities/schemas/__tests__/campaign-rank.schema.spec.ts b/src/domain/community/entities/schemas/__tests__/campaign-rank.schema.spec.ts similarity index 91% rename from src/domain/locking/entities/schemas/__tests__/campaign-rank.schema.spec.ts rename to src/domain/community/entities/schemas/__tests__/campaign-rank.schema.spec.ts index e6ed4f8fa8..7c7c7b015a 100644 --- a/src/domain/locking/entities/schemas/__tests__/campaign-rank.schema.spec.ts +++ b/src/domain/community/entities/schemas/__tests__/campaign-rank.schema.spec.ts @@ -1,5 +1,5 @@ -import { campaignRankBuilder } from '@/domain/locking/entities/__tests__/campaign-rank.builder'; -import { CampaignRankSchema } from '@/domain/locking/entities/campaign-rank.entity'; +import { campaignRankBuilder } from '@/domain/community/entities/__tests__/campaign-rank.builder'; +import { CampaignRankSchema } from '@/domain/community/entities/campaign-rank.entity'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; import { ZodError } from 'zod'; diff --git a/src/domain/locking/entities/schemas/__tests__/campaign.schema.spec.ts b/src/domain/community/entities/schemas/__tests__/campaign.schema.spec.ts similarity index 93% rename from src/domain/locking/entities/schemas/__tests__/campaign.schema.spec.ts rename to src/domain/community/entities/schemas/__tests__/campaign.schema.spec.ts index a171e6c59a..5c3efb6e0e 100644 --- a/src/domain/locking/entities/schemas/__tests__/campaign.schema.spec.ts +++ b/src/domain/community/entities/schemas/__tests__/campaign.schema.spec.ts @@ -1,5 +1,5 @@ -import { campaignBuilder } from '@/domain/locking/entities/__tests__/campaign.builder'; -import { CampaignSchema } from '@/domain/locking/entities/campaign.entity'; +import { campaignBuilder } from '@/domain/community/entities/__tests__/campaign.builder'; +import { CampaignSchema } from '@/domain/community/entities/campaign.entity'; import { faker } from '@faker-js/faker'; import { ZodError } from 'zod'; diff --git a/src/domain/locking/entities/schemas/__tests__/locking-event.schema.spec.ts b/src/domain/community/entities/schemas/__tests__/locking-event.schema.spec.ts similarity index 99% rename from src/domain/locking/entities/schemas/__tests__/locking-event.schema.spec.ts rename to src/domain/community/entities/schemas/__tests__/locking-event.schema.spec.ts index ad74f0cdb4..3cf3d81447 100644 --- a/src/domain/locking/entities/schemas/__tests__/locking-event.schema.spec.ts +++ b/src/domain/community/entities/schemas/__tests__/locking-event.schema.spec.ts @@ -3,14 +3,14 @@ import { lockEventItemBuilder, unlockEventItemBuilder, withdrawEventItemBuilder, -} from '@/domain/locking/entities/__tests__/locking-event.builder'; +} from '@/domain/community/entities/__tests__/locking-event.builder'; import { LockEventItemSchema, LockingEventPageSchema, LockingEventSchema, UnlockEventItemSchema, WithdrawEventItemSchema, -} from '@/domain/locking/entities/schemas/locking-event.schema'; +} from '@/domain/community/entities/schemas/locking-event.schema'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; import { ZodError } from 'zod'; diff --git a/src/domain/locking/entities/schemas/__tests__/rank.schema.spec.ts b/src/domain/community/entities/schemas/__tests__/rank.schema.spec.ts similarity index 92% rename from src/domain/locking/entities/schemas/__tests__/rank.schema.spec.ts rename to src/domain/community/entities/schemas/__tests__/rank.schema.spec.ts index 6020b16354..67abe8fbd8 100644 --- a/src/domain/locking/entities/schemas/__tests__/rank.schema.spec.ts +++ b/src/domain/community/entities/schemas/__tests__/rank.schema.spec.ts @@ -1,5 +1,5 @@ -import { rankBuilder } from '@/domain/locking/entities/__tests__/rank.builder'; -import { RankSchema } from '@/domain/locking/entities/schemas/rank.schema'; +import { rankBuilder } from '@/domain/community/entities/__tests__/rank.builder'; +import { RankSchema } from '@/domain/community/entities/schemas/rank.schema'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; import { ZodError } from 'zod'; diff --git a/src/domain/locking/entities/schemas/locking-event.schema.ts b/src/domain/community/entities/schemas/locking-event.schema.ts similarity index 100% rename from src/domain/locking/entities/schemas/locking-event.schema.ts rename to src/domain/community/entities/schemas/locking-event.schema.ts diff --git a/src/domain/locking/entities/schemas/rank.schema.ts b/src/domain/community/entities/schemas/rank.schema.ts similarity index 100% rename from src/domain/locking/entities/schemas/rank.schema.ts rename to src/domain/community/entities/schemas/rank.schema.ts diff --git a/src/domain/interfaces/locking-api.interface.ts b/src/domain/interfaces/locking-api.interface.ts index fff606ef68..66a9c2a12e 100644 --- a/src/domain/interfaces/locking-api.interface.ts +++ b/src/domain/interfaces/locking-api.interface.ts @@ -1,8 +1,8 @@ import { Page } from '@/domain/entities/page.entity'; -import { Campaign } from '@/domain/locking/entities/campaign.entity'; -import { CampaignRank } from '@/domain/locking/entities/campaign-rank.entity'; -import { LockingEvent } from '@/domain/locking/entities/locking-event.entity'; -import { Rank } from '@/domain/locking/entities/rank.entity'; +import { Campaign } from '@/domain/community/entities/campaign.entity'; +import { CampaignRank } from '@/domain/community/entities/campaign-rank.entity'; +import { LockingEvent } from '@/domain/community/entities/locking-event.entity'; +import { Rank } from '@/domain/community/entities/rank.entity'; export const ILockingApi = Symbol('ILockingApi'); diff --git a/src/domain/locking/entities/rank.entity.ts b/src/domain/locking/entities/rank.entity.ts deleted file mode 100644 index 2610eb892b..0000000000 --- a/src/domain/locking/entities/rank.entity.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { RankSchema } from '@/domain/locking/entities/schemas/rank.schema'; -import { z } from 'zod'; - -export type Rank = z.infer; diff --git a/src/domain/locking/locking.domain.module.ts b/src/domain/locking/locking.domain.module.ts deleted file mode 100644 index 5cea9e3d88..0000000000 --- a/src/domain/locking/locking.domain.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { LockingApiModule } from '@/datasources/locking-api/locking-api.module'; -import { ILockingRepository } from '@/domain/locking/locking.repository.interface'; -import { LockingRepository } from '@/domain/locking/locking.repository'; - -@Module({ - imports: [LockingApiModule], - providers: [{ provide: ILockingRepository, useClass: LockingRepository }], - exports: [ILockingRepository], -}) -// TODO: Convert to CommunityDomainModule -export class LockingDomainModule {} diff --git a/src/routes/community/community.controller.spec.ts b/src/routes/community/community.controller.spec.ts index 93cd44c788..63932ebce7 100644 --- a/src/routes/community/community.controller.spec.ts +++ b/src/routes/community/community.controller.spec.ts @@ -23,22 +23,22 @@ import { unlockEventItemBuilder, withdrawEventItemBuilder, toJson as lockingEventToJson, -} from '@/domain/locking/entities/__tests__/locking-event.builder'; -import { LockingEvent } from '@/domain/locking/entities/locking-event.entity'; +} from '@/domain/community/entities/__tests__/locking-event.builder'; +import { LockingEvent } from '@/domain/community/entities/locking-event.entity'; import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; import { getAddress } from 'viem'; -import { rankBuilder } from '@/domain/locking/entities/__tests__/rank.builder'; +import { rankBuilder } from '@/domain/community/entities/__tests__/rank.builder'; import { PaginationData } from '@/routes/common/pagination/pagination.data'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { campaignBuilder, toJson as campaignToJson, -} from '@/domain/locking/entities/__tests__/campaign.builder'; -import { Campaign } from '@/domain/locking/entities/campaign.entity'; -import { CampaignRank } from '@/domain/locking/entities/campaign-rank.entity'; -import { campaignRankBuilder } from '@/domain/locking/entities/__tests__/campaign-rank.builder'; +} from '@/domain/community/entities/__tests__/campaign.builder'; +import { Campaign } from '@/domain/community/entities/campaign.entity'; +import { CampaignRank } from '@/domain/community/entities/campaign-rank.entity'; +import { campaignRankBuilder } from '@/domain/community/entities/__tests__/campaign-rank.builder'; describe('Community (Unit)', () => { let app: INestApplication; diff --git a/src/routes/community/community.module.ts b/src/routes/community/community.module.ts index 6177b7e238..feafd52c01 100644 --- a/src/routes/community/community.module.ts +++ b/src/routes/community/community.module.ts @@ -1,10 +1,10 @@ import { Module } from '@nestjs/common'; -import { LockingDomainModule } from '@/domain/locking/locking.domain.module'; +import { CommunityDomainModule } from '@/domain/community/community.domain.module'; import { CommunityService } from '@/routes/community/community.service'; import { CommunityController } from '@/routes/community/community.controller'; @Module({ - imports: [LockingDomainModule], + imports: [CommunityDomainModule], providers: [CommunityService], controllers: [CommunityController], }) diff --git a/src/routes/community/community.service.ts b/src/routes/community/community.service.ts index 850c73c606..b746a1457d 100644 --- a/src/routes/community/community.service.ts +++ b/src/routes/community/community.service.ts @@ -1,9 +1,9 @@ import { Page } from '@/domain/entities/page.entity'; -import { Campaign } from '@/domain/locking/entities/campaign.entity'; -import { CampaignRank } from '@/domain/locking/entities/campaign-rank.entity'; -import { LockingEvent } from '@/domain/locking/entities/locking-event.entity'; -import { Rank } from '@/domain/locking/entities/rank.entity'; -import { ILockingRepository } from '@/domain/locking/locking.repository.interface'; +import { Campaign } from '@/domain/community/entities/campaign.entity'; +import { CampaignRank } from '@/domain/community/entities/campaign-rank.entity'; +import { LockingEvent } from '@/domain/community/entities/locking-event.entity'; +import { Rank } from '@/domain/community/entities/rank.entity'; +import { ICommunityRepository } from '@/domain/community/community.repository.interface'; import { PaginationData, cursorUrlFromLimitAndOffset, @@ -13,15 +13,15 @@ import { Inject, Injectable } from '@nestjs/common'; @Injectable() export class CommunityService { constructor( - @Inject(ILockingRepository) - private readonly lockingRepository: ILockingRepository, + @Inject(ICommunityRepository) + private readonly communityRepository: ICommunityRepository, ) {} async getCampaigns(args: { routeUrl: URL; paginationData: PaginationData; }): Promise> { - const result = await this.lockingRepository.getCampaigns( + const result = await this.communityRepository.getCampaigns( args.paginationData, ); @@ -40,7 +40,7 @@ export class CommunityService { } async getCampaignById(campaignId: string): Promise { - return this.lockingRepository.getCampaignById(campaignId); + return this.communityRepository.getCampaignById(campaignId); } async getCampaignLeaderboard(args: { @@ -48,7 +48,7 @@ export class CommunityService { routeUrl: URL; paginationData: PaginationData; }): Promise> { - const result = await this.lockingRepository.getCampaignLeaderboard({ + const result = await this.communityRepository.getCampaignLeaderboard({ campaignId: args.campaignId, limit: args.paginationData.limit, offset: args.paginationData.offset, @@ -72,7 +72,7 @@ export class CommunityService { routeUrl: URL; paginationData: PaginationData; }): Promise> { - const result = await this.lockingRepository.getLeaderboard( + const result = await this.communityRepository.getLeaderboard( args.paginationData, ); @@ -91,7 +91,7 @@ export class CommunityService { } async getLockingRank(safeAddress: `0x${string}`): Promise { - return this.lockingRepository.getRank(safeAddress); + return this.communityRepository.getRank(safeAddress); } async getLockingHistory(args: { @@ -99,7 +99,7 @@ export class CommunityService { routeUrl: URL; paginationData: PaginationData; }): Promise> { - const result = await this.lockingRepository.getLockingHistory({ + const result = await this.communityRepository.getLockingHistory({ safeAddress: args.safeAddress, limit: args.paginationData.limit, offset: args.paginationData.offset, diff --git a/src/routes/locking/entities/activity-metadata.entity.ts b/src/routes/locking/entities/activity-metadata.entity.ts index cbce0edacf..84e3250118 100644 --- a/src/routes/locking/entities/activity-metadata.entity.ts +++ b/src/routes/locking/entities/activity-metadata.entity.ts @@ -1,4 +1,4 @@ -import { ActivityMetadata as DomainActivityMetadata } from '@/domain/locking/entities/activity-metadata.entity'; +import { ActivityMetadata as DomainActivityMetadata } from '@/domain/community/entities/activity-metadata.entity'; import { ApiProperty } from '@nestjs/swagger'; export class ActivityMetadata implements DomainActivityMetadata { diff --git a/src/routes/locking/entities/campaign-rank.entity.ts b/src/routes/locking/entities/campaign-rank.entity.ts index e8f33666f3..a136abd3cd 100644 --- a/src/routes/locking/entities/campaign-rank.entity.ts +++ b/src/routes/locking/entities/campaign-rank.entity.ts @@ -1,4 +1,4 @@ -import { CampaignRank as DomainCampaignRank } from '@/domain/locking/entities/campaign-rank.entity'; +import { CampaignRank as DomainCampaignRank } from '@/domain/community/entities/campaign-rank.entity'; import { ApiProperty } from '@nestjs/swagger'; export class CampaignRank implements DomainCampaignRank { diff --git a/src/routes/locking/entities/campaign.entity.ts b/src/routes/locking/entities/campaign.entity.ts index ac83ca6cb0..2ca5930c43 100644 --- a/src/routes/locking/entities/campaign.entity.ts +++ b/src/routes/locking/entities/campaign.entity.ts @@ -1,4 +1,4 @@ -import { Campaign as DomainCampaign } from '@/domain/locking/entities/campaign.entity'; +import { Campaign as DomainCampaign } from '@/domain/community/entities/campaign.entity'; import { ActivityMetadata } from '@/routes/locking/entities/activity-metadata.entity'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; diff --git a/src/routes/locking/entities/locking-event.page.entity.ts b/src/routes/locking/entities/locking-event.page.entity.ts index c6bedcf1cd..c1ecc1b062 100644 --- a/src/routes/locking/entities/locking-event.page.entity.ts +++ b/src/routes/locking/entities/locking-event.page.entity.ts @@ -2,8 +2,8 @@ import { LockEventItem as DomainLockEventItem, UnlockEventItem as DomainUnlockEventItem, WithdrawEventItem as DomainWithdrawEventItem, -} from '@/domain/locking/entities/locking-event.entity'; -import { LockingEventType } from '@/domain/locking/entities/schemas/locking-event.schema'; +} from '@/domain/community/entities/locking-event.entity'; +import { LockingEventType } from '@/domain/community/entities/schemas/locking-event.schema'; import { Page } from '@/routes/common/entities/page.entity'; import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger'; diff --git a/src/routes/locking/entities/rank.entity.ts b/src/routes/locking/entities/rank.entity.ts index 61a539e08d..62b7713601 100644 --- a/src/routes/locking/entities/rank.entity.ts +++ b/src/routes/locking/entities/rank.entity.ts @@ -1,4 +1,4 @@ -import { Rank as DomainRank } from '@/domain/locking/entities/rank.entity'; +import { Rank as DomainRank } from '@/domain/community/entities/rank.entity'; import { ApiProperty } from '@nestjs/swagger'; export class Rank implements DomainRank { diff --git a/src/routes/locking/locking.module.ts b/src/routes/locking/locking.module.ts index 502f896d7d..6b384f2c8d 100644 --- a/src/routes/locking/locking.module.ts +++ b/src/routes/locking/locking.module.ts @@ -1,9 +1,7 @@ import { Module } from '@nestjs/common'; import { LockingController } from '@/routes/locking/locking.controller'; -import { LockingDomainModule } from '@/domain/locking/locking.domain.module'; @Module({ - imports: [LockingDomainModule], controllers: [LockingController], }) export class LockingModule {} From 9384e7eab82cc9e51f801fe312d011f03d11211c Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Fri, 17 May 2024 11:59:44 +0200 Subject: [PATCH 010/207] Specify rank as lock specific (#1563) Renames all instances of "rank" to be lock-specific: - `ILockingApi['getRank']` -> `ILockingApi['getLockingRank']` - `ICommunityRepository['getRank']` -> `ICommunityRepository['getLockingRank']` - `RankSchema` -> `LockingRankSchema` - `RankPageSchema` -> `LockingRankPageSchema` - `Rank` -> `LockingRank` - `RankPage` -> `LockingRankPage` - `rankBuilder` -> `lockRankBuilder` --- .../locking-api/locking-api.service.spec.ts | 22 +++++++++---------- .../locking-api/locking-api.service.ts | 10 ++++----- .../community.repository.interface.ts | 6 ++--- src/domain/community/community.repository.ts | 18 +++++++-------- ...ank.builder.ts => locking-rank.builder.ts} | 6 ++--- .../community/entities/locking-rank.entity.ts | 4 ++++ src/domain/community/entities/rank.entity.ts | 4 ---- ...ma.spec.ts => locking-rank.schema.spec.ts} | 22 ++++++++++--------- ...{rank.schema.ts => locking-rank.schema.ts} | 4 ++-- .../interfaces/locking-api.interface.ts | 6 ++--- .../community/community.controller.spec.ts | 22 +++++++++---------- src/routes/community/community.controller.ts | 14 ++++++------ src/routes/community/community.service.ts | 8 +++---- ...{rank.entity.ts => locking-rank.entity.ts} | 4 ++-- .../entities/locking-rank.page.entity.ts | 8 +++++++ .../locking/entities/rank.page.entity.ts | 8 ------- src/routes/locking/locking.controller.ts | 8 +++---- 17 files changed, 87 insertions(+), 87 deletions(-) rename src/domain/community/entities/__tests__/{rank.builder.ts => locking-rank.builder.ts} (69%) create mode 100644 src/domain/community/entities/locking-rank.entity.ts delete mode 100644 src/domain/community/entities/rank.entity.ts rename src/domain/community/entities/schemas/__tests__/{rank.schema.spec.ts => locking-rank.schema.spec.ts} (67%) rename src/domain/community/entities/schemas/{rank.schema.ts => locking-rank.schema.ts} (79%) rename src/routes/locking/entities/{rank.entity.ts => locking-rank.entity.ts} (62%) create mode 100644 src/routes/locking/entities/locking-rank.page.entity.ts delete mode 100644 src/routes/locking/entities/rank.page.entity.ts diff --git a/src/datasources/locking-api/locking-api.service.spec.ts b/src/datasources/locking-api/locking-api.service.spec.ts index 04b1673a3d..d94cb45835 100644 --- a/src/datasources/locking-api/locking-api.service.spec.ts +++ b/src/datasources/locking-api/locking-api.service.spec.ts @@ -12,7 +12,7 @@ import { withdrawEventItemBuilder, } from '@/domain/community/entities/__tests__/locking-event.builder'; import { getAddress } from 'viem'; -import { rankBuilder } from '@/domain/community/entities/__tests__/rank.builder'; +import { lockingRankBuilder } from '@/domain/community/entities/__tests__/locking-rank.builder'; import { campaignBuilder } from '@/domain/community/entities/__tests__/campaign.builder'; import { campaignRankBuilder } from '@/domain/community/entities/__tests__/campaign-rank.builder'; import { CampaignRank } from '@/domain/community/entities/campaign-rank.entity'; @@ -156,20 +156,18 @@ describe('LockingApi', () => { }); }); - describe('getRank', () => { - it('should get rank', async () => { + describe('getLockingRank', () => { + it('should get locking rank', async () => { const safeAddress = getAddress(faker.finance.ethereumAddress()); - const rank = rankBuilder().build(); + const lockingRank = lockingRankBuilder().build(); mockNetworkService.get.mockResolvedValueOnce({ - data: { - rank, - }, + data: lockingRank, status: 200, }); - const result = await service.getRank(safeAddress); + const result = await service.getLockingRank(safeAddress); - expect(result).toEqual({ rank }); + expect(result).toEqual(lockingRank); expect(mockNetworkService.get).toHaveBeenCalledWith({ url: `${lockingBaseUri}/api/v1/leaderboard/${safeAddress}`, }); @@ -189,7 +187,7 @@ describe('LockingApi', () => { ); mockNetworkService.get.mockRejectedValueOnce(error); - await expect(service.getRank(safeAddress)).rejects.toThrow( + await expect(service.getLockingRank(safeAddress)).rejects.toThrow( new DataSourceError('Unexpected error', status), ); @@ -200,7 +198,7 @@ describe('LockingApi', () => { describe('getLeaderboard', () => { it('should get leaderboard', async () => { const leaderboardPage = pageBuilder() - .with('results', [rankBuilder().build()]) + .with('results', [lockingRankBuilder().build()]) .build(); mockNetworkService.get.mockResolvedValueOnce({ data: leaderboardPage, @@ -225,7 +223,7 @@ describe('LockingApi', () => { const limit = faker.number.int(); const offset = faker.number.int(); const leaderboardPage = pageBuilder() - .with('results', [rankBuilder().build()]) + .with('results', [lockingRankBuilder().build()]) .build(); mockNetworkService.get.mockResolvedValueOnce({ data: leaderboardPage, diff --git a/src/datasources/locking-api/locking-api.service.ts b/src/datasources/locking-api/locking-api.service.ts index d15e94fc42..4cb03a81c2 100644 --- a/src/datasources/locking-api/locking-api.service.ts +++ b/src/datasources/locking-api/locking-api.service.ts @@ -9,7 +9,7 @@ import { ILockingApi } from '@/domain/interfaces/locking-api.interface'; import { Campaign } from '@/domain/community/entities/campaign.entity'; import { CampaignRank } from '@/domain/community/entities/campaign-rank.entity'; import { LockingEvent } from '@/domain/community/entities/locking-event.entity'; -import { Rank } from '@/domain/community/entities/rank.entity'; +import { LockingRank } from '@/domain/community/entities/locking-rank.entity'; import { Inject } from '@nestjs/common'; export class LockingApi implements ILockingApi { @@ -57,10 +57,10 @@ export class LockingApi implements ILockingApi { } } - async getRank(safeAddress: `0x${string}`): Promise { + async getLockingRank(safeAddress: `0x${string}`): Promise { try { const url = `${this.baseUri}/api/v1/leaderboard/${safeAddress}`; - const { data } = await this.networkService.get({ url }); + const { data } = await this.networkService.get({ url }); return data; } catch (error) { throw this.httpErrorFactory.from(error); @@ -70,10 +70,10 @@ export class LockingApi implements ILockingApi { async getLeaderboard(args: { limit?: number; offset?: number; - }): Promise> { + }): Promise> { try { const url = `${this.baseUri}/api/v1/leaderboard`; - const { data } = await this.networkService.get>({ + const { data } = await this.networkService.get>({ url, networkRequest: { params: { diff --git a/src/domain/community/community.repository.interface.ts b/src/domain/community/community.repository.interface.ts index e5726624ca..e59c1ba4fd 100644 --- a/src/domain/community/community.repository.interface.ts +++ b/src/domain/community/community.repository.interface.ts @@ -2,7 +2,7 @@ import { Page } from '@/domain/entities/page.entity'; import { Campaign } from '@/domain/community/entities/campaign.entity'; import { CampaignRank } from '@/domain/community/entities/campaign-rank.entity'; import { LockingEvent } from '@/domain/community/entities/locking-event.entity'; -import { Rank } from '@/domain/community/entities/rank.entity'; +import { LockingRank } from '@/domain/community/entities/locking-rank.entity'; export const ICommunityRepository = Symbol('ICommunityRepository'); @@ -14,12 +14,12 @@ export interface ICommunityRepository { offset?: number; }): Promise>; - getRank(safeAddress: `0x${string}`): Promise; + getLockingRank(safeAddress: `0x${string}`): Promise; getLeaderboard(args: { limit?: number; offset?: number; - }): Promise>; + }): Promise>; getCampaignLeaderboard(args: { campaignId: string; diff --git a/src/domain/community/community.repository.ts b/src/domain/community/community.repository.ts index b04261ac96..1025faa0c7 100644 --- a/src/domain/community/community.repository.ts +++ b/src/domain/community/community.repository.ts @@ -10,12 +10,12 @@ import { CampaignRankPageSchema, } from '@/domain/community/entities/campaign-rank.entity'; import { LockingEvent } from '@/domain/community/entities/locking-event.entity'; -import { Rank } from '@/domain/community/entities/rank.entity'; +import { LockingRank } from '@/domain/community/entities/locking-rank.entity'; import { LockingEventPageSchema } from '@/domain/community/entities/schemas/locking-event.schema'; import { - RankPageSchema, - RankSchema, -} from '@/domain/community/entities/schemas/rank.schema'; + LockingRankPageSchema, + LockingRankSchema, +} from '@/domain/community/entities/schemas/locking-rank.schema'; import { ICommunityRepository } from '@/domain/community/community.repository.interface'; import { Inject, Injectable } from '@nestjs/common'; @@ -39,17 +39,17 @@ export class CommunityRepository implements ICommunityRepository { return CampaignPageSchema.parse(page); } - async getRank(safeAddress: `0x${string}`): Promise { - const rank = await this.lockingApi.getRank(safeAddress); - return RankSchema.parse(rank); + async getLockingRank(safeAddress: `0x${string}`): Promise { + const lockingRank = await this.lockingApi.getLockingRank(safeAddress); + return LockingRankSchema.parse(lockingRank); } async getLeaderboard(args: { limit?: number; offset?: number; - }): Promise> { + }): Promise> { const page = await this.lockingApi.getLeaderboard(args); - return RankPageSchema.parse(page); + return LockingRankPageSchema.parse(page); } async getCampaignLeaderboard(args: { diff --git a/src/domain/community/entities/__tests__/rank.builder.ts b/src/domain/community/entities/__tests__/locking-rank.builder.ts similarity index 69% rename from src/domain/community/entities/__tests__/rank.builder.ts rename to src/domain/community/entities/__tests__/locking-rank.builder.ts index b9ad11fff7..abd3505e65 100644 --- a/src/domain/community/entities/__tests__/rank.builder.ts +++ b/src/domain/community/entities/__tests__/locking-rank.builder.ts @@ -1,10 +1,10 @@ import { IBuilder, Builder } from '@/__tests__/builder'; -import { Rank } from '@/domain/community/entities/rank.entity'; +import { LockingRank } from '@/domain/community/entities/locking-rank.entity'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; -export function rankBuilder(): IBuilder { - return new Builder() +export function lockingRankBuilder(): IBuilder { + return new Builder() .with('holder', getAddress(faker.finance.ethereumAddress())) .with('position', faker.number.int()) .with('lockedAmount', faker.string.numeric()) diff --git a/src/domain/community/entities/locking-rank.entity.ts b/src/domain/community/entities/locking-rank.entity.ts new file mode 100644 index 0000000000..8c7c9fa98c --- /dev/null +++ b/src/domain/community/entities/locking-rank.entity.ts @@ -0,0 +1,4 @@ +import { LockingRankSchema } from '@/domain/community/entities/schemas/locking-rank.schema'; +import { z } from 'zod'; + +export type LockingRank = z.infer; diff --git a/src/domain/community/entities/rank.entity.ts b/src/domain/community/entities/rank.entity.ts deleted file mode 100644 index 63cd33cbae..0000000000 --- a/src/domain/community/entities/rank.entity.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { RankSchema } from '@/domain/community/entities/schemas/rank.schema'; -import { z } from 'zod'; - -export type Rank = z.infer; diff --git a/src/domain/community/entities/schemas/__tests__/rank.schema.spec.ts b/src/domain/community/entities/schemas/__tests__/locking-rank.schema.spec.ts similarity index 67% rename from src/domain/community/entities/schemas/__tests__/rank.schema.spec.ts rename to src/domain/community/entities/schemas/__tests__/locking-rank.schema.spec.ts index 67abe8fbd8..9310c04ba8 100644 --- a/src/domain/community/entities/schemas/__tests__/rank.schema.spec.ts +++ b/src/domain/community/entities/schemas/__tests__/locking-rank.schema.spec.ts @@ -1,14 +1,14 @@ -import { rankBuilder } from '@/domain/community/entities/__tests__/rank.builder'; -import { RankSchema } from '@/domain/community/entities/schemas/rank.schema'; +import { lockingRankBuilder } from '@/domain/community/entities/__tests__/locking-rank.builder'; +import { LockingRankSchema } from '@/domain/community/entities/schemas/locking-rank.schema'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; import { ZodError } from 'zod'; describe('RankSchema', () => { - it('should validate a valid rank', () => { - const rank = rankBuilder().build(); + it('should validate a valid locking rank', () => { + const lockingRank = lockingRankBuilder().build(); - const result = RankSchema.safeParse(rank); + const result = LockingRankSchema.safeParse(lockingRank); expect(result.success).toBe(true); }); @@ -17,19 +17,21 @@ describe('RankSchema', () => { const nonChecksummedAddress = faker.finance .ethereumAddress() .toLowerCase() as `0x${string}`; - const rank = rankBuilder().with('holder', nonChecksummedAddress).build(); + const lockingRank = lockingRankBuilder() + .with('holder', nonChecksummedAddress) + .build(); - const result = RankSchema.safeParse(rank); + const result = LockingRankSchema.safeParse(lockingRank); expect(result.success && result.data.holder).toBe( getAddress(nonChecksummedAddress), ); }); - it('should not validate an invalid rank', () => { - const rank = { invalid: 'rank' }; + it('should not validate an invalid locking rank', () => { + const lockingRank = { invalid: 'lockingRank' }; - const result = RankSchema.safeParse(rank); + const result = LockingRankSchema.safeParse(lockingRank); expect(!result.success && result.error).toStrictEqual( new ZodError([ diff --git a/src/domain/community/entities/schemas/rank.schema.ts b/src/domain/community/entities/schemas/locking-rank.schema.ts similarity index 79% rename from src/domain/community/entities/schemas/rank.schema.ts rename to src/domain/community/entities/schemas/locking-rank.schema.ts index db4248fda2..812acc688a 100644 --- a/src/domain/community/entities/schemas/rank.schema.ts +++ b/src/domain/community/entities/schemas/locking-rank.schema.ts @@ -3,7 +3,7 @@ import { buildPageSchema } from '@/domain/entities/schemas/page.schema.factory'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; import { NumericStringSchema } from '@/validation/entities/schemas/numeric-string.schema'; -export const RankSchema = z.object({ +export const LockingRankSchema = z.object({ holder: AddressSchema, position: z.number(), lockedAmount: NumericStringSchema, @@ -11,4 +11,4 @@ export const RankSchema = z.object({ withdrawnAmount: NumericStringSchema, }); -export const RankPageSchema = buildPageSchema(RankSchema); +export const LockingRankPageSchema = buildPageSchema(LockingRankSchema); diff --git a/src/domain/interfaces/locking-api.interface.ts b/src/domain/interfaces/locking-api.interface.ts index 66a9c2a12e..cd20944a86 100644 --- a/src/domain/interfaces/locking-api.interface.ts +++ b/src/domain/interfaces/locking-api.interface.ts @@ -2,7 +2,7 @@ import { Page } from '@/domain/entities/page.entity'; import { Campaign } from '@/domain/community/entities/campaign.entity'; import { CampaignRank } from '@/domain/community/entities/campaign-rank.entity'; import { LockingEvent } from '@/domain/community/entities/locking-event.entity'; -import { Rank } from '@/domain/community/entities/rank.entity'; +import { LockingRank } from '@/domain/community/entities/locking-rank.entity'; export const ILockingApi = Symbol('ILockingApi'); @@ -14,12 +14,12 @@ export interface ILockingApi { offset?: number; }): Promise>; - getRank(safeAddress: `0x${string}`): Promise; + getLockingRank(safeAddress: `0x${string}`): Promise; getLeaderboard(args: { limit?: number; offset?: number; - }): Promise>; + }): Promise>; getCampaignLeaderboard(args: { campaignId: string; diff --git a/src/routes/community/community.controller.spec.ts b/src/routes/community/community.controller.spec.ts index 63932ebce7..ba558e5195 100644 --- a/src/routes/community/community.controller.spec.ts +++ b/src/routes/community/community.controller.spec.ts @@ -28,7 +28,7 @@ import { LockingEvent } from '@/domain/community/entities/locking-event.entity'; import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; import { getAddress } from 'viem'; -import { rankBuilder } from '@/domain/community/entities/__tests__/rank.builder'; +import { lockingRankBuilder } from '@/domain/community/entities/__tests__/locking-rank.builder'; import { PaginationData } from '@/routes/common/pagination/pagination.data'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; @@ -416,7 +416,7 @@ describe('Community (Unit)', () => { describe('GET /community/locking/leaderboard', () => { it('should get the leaderboard', async () => { const leaderboard = pageBuilder() - .with('results', [rankBuilder().build()]) + .with('results', [lockingRankBuilder().build()]) .build(); networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -454,7 +454,7 @@ describe('Community (Unit)', () => { const limit = faker.number.int({ min: 1, max: 10 }); const offset = faker.number.int({ min: 1, max: 10 }); const leaderboard = pageBuilder() - .with('results', [rankBuilder().build()]) + .with('results', [lockingRankBuilder().build()]) .build(); networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -545,21 +545,21 @@ describe('Community (Unit)', () => { }); describe('GET /community/locking/:safeAddress/rank', () => { - it('should get the rank', async () => { - const rank = rankBuilder().build(); + it('should get the locking rank', async () => { + const lockingRank = lockingRankBuilder().build(); networkService.get.mockImplementation(({ url }) => { switch (url) { - case `${lockingBaseUri}/api/v1/leaderboard/${rank.holder}`: - return Promise.resolve({ data: rank, status: 200 }); + case `${lockingBaseUri}/api/v1/leaderboard/${lockingRank.holder}`: + return Promise.resolve({ data: lockingRank, status: 200 }); default: return Promise.reject(`No matching rule for url: ${url}`); } }); await request(app.getHttpServer()) - .get(`/v1/community/locking/${rank.holder}/rank`) + .get(`/v1/community/locking/${lockingRank.holder}/rank`) .expect(200) - .expect(rank); + .expect(lockingRank); }); it('should validate the Safe address in URL', async () => { @@ -578,11 +578,11 @@ describe('Community (Unit)', () => { it('should validate the response', async () => { const safeAddress = getAddress(faker.finance.ethereumAddress()); - const rank = { invalid: 'rank' }; + const lockingRank = { invalid: 'lockingRank' }; networkService.get.mockImplementation(({ url }) => { switch (url) { case `${lockingBaseUri}/api/v1/leaderboard/${safeAddress}`: - return Promise.resolve({ data: rank, status: 200 }); + return Promise.resolve({ data: lockingRank, status: 200 }); default: return Promise.reject(`No matching rule for url: ${url}`); } diff --git a/src/routes/community/community.controller.ts b/src/routes/community/community.controller.ts index 8faaddbd62..cf57bbbd63 100644 --- a/src/routes/community/community.controller.ts +++ b/src/routes/community/community.controller.ts @@ -6,8 +6,8 @@ import { CampaignRankPage } from '@/routes/locking/entities/campaign-rank.page.e import { Campaign } from '@/routes/locking/entities/campaign.entity'; import { CampaignPage } from '@/routes/locking/entities/campaign.page.entity'; import { LockingEventPage } from '@/routes/locking/entities/locking-event.page.entity'; -import { Rank } from '@/routes/locking/entities/rank.entity'; -import { RankPage } from '@/routes/locking/entities/rank.page.entity'; +import { LockingRank } from '@/routes/locking/entities/locking-rank.entity'; +import { LockingRankPage } from '@/routes/locking/entities/locking-rank.page.entity'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; import { ValidationPipe } from '@/validation/pipes/validation.pipe'; import { Controller, Get, Param } from '@nestjs/common'; @@ -62,7 +62,7 @@ export class CommunityController { }); } - @ApiOkResponse({ type: RankPage }) + @ApiOkResponse({ type: LockingRankPage }) @ApiQuery({ name: 'cursor', required: false, @@ -72,19 +72,19 @@ export class CommunityController { async getLeaderboard( @RouteUrlDecorator() routeUrl: URL, @PaginationDataDecorator() paginationData: PaginationData, - ): Promise { + ): Promise { return this.communityService.getLockingLeaderboard({ routeUrl, paginationData, }); } - @ApiOkResponse({ type: Rank }) + @ApiOkResponse({ type: LockingRank }) @Get('/locking/:safeAddress/rank') - async getRank( + async getLockingRank( @Param('safeAddress', new ValidationPipe(AddressSchema)) safeAddress: `0x${string}`, - ): Promise { + ): Promise { return this.communityService.getLockingRank(safeAddress); } diff --git a/src/routes/community/community.service.ts b/src/routes/community/community.service.ts index b746a1457d..6f6ec54f3c 100644 --- a/src/routes/community/community.service.ts +++ b/src/routes/community/community.service.ts @@ -2,7 +2,7 @@ import { Page } from '@/domain/entities/page.entity'; import { Campaign } from '@/domain/community/entities/campaign.entity'; import { CampaignRank } from '@/domain/community/entities/campaign-rank.entity'; import { LockingEvent } from '@/domain/community/entities/locking-event.entity'; -import { Rank } from '@/domain/community/entities/rank.entity'; +import { LockingRank } from '@/domain/community/entities/locking-rank.entity'; import { ICommunityRepository } from '@/domain/community/community.repository.interface'; import { PaginationData, @@ -71,7 +71,7 @@ export class CommunityService { async getLockingLeaderboard(args: { routeUrl: URL; paginationData: PaginationData; - }): Promise> { + }): Promise> { const result = await this.communityRepository.getLeaderboard( args.paginationData, ); @@ -90,8 +90,8 @@ export class CommunityService { }; } - async getLockingRank(safeAddress: `0x${string}`): Promise { - return this.communityRepository.getRank(safeAddress); + async getLockingRank(safeAddress: `0x${string}`): Promise { + return this.communityRepository.getLockingRank(safeAddress); } async getLockingHistory(args: { diff --git a/src/routes/locking/entities/rank.entity.ts b/src/routes/locking/entities/locking-rank.entity.ts similarity index 62% rename from src/routes/locking/entities/rank.entity.ts rename to src/routes/locking/entities/locking-rank.entity.ts index 62b7713601..1af049c028 100644 --- a/src/routes/locking/entities/rank.entity.ts +++ b/src/routes/locking/entities/locking-rank.entity.ts @@ -1,7 +1,7 @@ -import { Rank as DomainRank } from '@/domain/community/entities/rank.entity'; +import { LockingRank as DomainLockingRank } from '@/domain/community/entities/locking-rank.entity'; import { ApiProperty } from '@nestjs/swagger'; -export class Rank implements DomainRank { +export class LockingRank implements DomainLockingRank { @ApiProperty() holder!: `0x${string}`; @ApiProperty() diff --git a/src/routes/locking/entities/locking-rank.page.entity.ts b/src/routes/locking/entities/locking-rank.page.entity.ts new file mode 100644 index 0000000000..a8b5646f2e --- /dev/null +++ b/src/routes/locking/entities/locking-rank.page.entity.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Page } from '@/routes/common/entities/page.entity'; +import { LockingRank } from '@/routes/locking/entities/locking-rank.entity'; + +export class LockingRankPage extends Page { + @ApiProperty({ type: LockingRank }) + results!: Array; +} diff --git a/src/routes/locking/entities/rank.page.entity.ts b/src/routes/locking/entities/rank.page.entity.ts deleted file mode 100644 index f3975c5ac0..0000000000 --- a/src/routes/locking/entities/rank.page.entity.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { Page } from '@/routes/common/entities/page.entity'; -import { Rank } from '@/routes/locking/entities/rank.entity'; - -export class RankPage extends Page { - @ApiProperty({ type: Rank }) - results!: Array; -} diff --git a/src/routes/locking/locking.controller.ts b/src/routes/locking/locking.controller.ts index 84922046a2..5475311685 100644 --- a/src/routes/locking/locking.controller.ts +++ b/src/routes/locking/locking.controller.ts @@ -1,6 +1,6 @@ import { LockingEventPage } from '@/routes/locking/entities/locking-event.page.entity'; -import { Rank } from '@/routes/locking/entities/rank.entity'; -import { RankPage } from '@/routes/locking/entities/rank.page.entity'; +import { LockingRank } from '@/routes/locking/entities/locking-rank.entity'; +import { LockingRankPage } from '@/routes/locking/entities/locking-rank.page.entity'; import { Controller, Get, @@ -24,7 +24,7 @@ import { Request } from 'express'; }) export class LockingController { @ApiOperation({ deprecated: true }) - @ApiOkResponse({ type: Rank }) + @ApiOkResponse({ type: LockingRank }) @Redirect(undefined, HttpStatus.PERMANENT_REDIRECT) @Get('/leaderboard/rank/:safeAddress') getRank( @@ -35,7 +35,7 @@ export class LockingController { } @ApiOperation({ deprecated: true }) - @ApiOkResponse({ type: RankPage }) + @ApiOkResponse({ type: LockingRankPage }) @ApiQuery({ name: 'cursor', required: false, From a86911d3ddeea4bdfaafad7dba363c734dd25429 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 May 2024 09:29:45 +0200 Subject: [PATCH 011/207] --- (#1570) updated-dependencies: - dependency-name: "@types/node" dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 2f3cf8cb9a..a56bb4a08b 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "@types/jest": "29.5.12", "@types/jsonwebtoken": "^9", "@types/lodash": "^4.17.1", - "@types/node": "^20.12.11", + "@types/node": "^20.12.12", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.2", "eslint": "^9.2.0", diff --git a/yarn.lock b/yarn.lock index aa0cb319b1..e6876183e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1925,12 +1925,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^20.12.11": - version: 20.12.11 - resolution: "@types/node@npm:20.12.11" +"@types/node@npm:^20.12.12": + version: 20.12.12 + resolution: "@types/node@npm:20.12.12" dependencies: undici-types: "npm:~5.26.4" - checksum: 10/c6afe7c2c4504a4f488814d7b306ebad16bf42cbb43bf9db9fe1aed8c5fb99235593c3be5088979a64526b106cf022256688e2f002811be8273d87dc2e0d484f + checksum: 10/e3945da0a3017bdc1f88f15bdfb823f526b2a717bd58d4640082d6eb0bd2794b5c99bfb914b9e9324ec116dce36066990353ed1c777e8a7b0641f772575793c4 languageName: node linkType: hard @@ -7277,7 +7277,7 @@ __metadata: "@types/jest": "npm:29.5.12" "@types/jsonwebtoken": "npm:^9" "@types/lodash": "npm:^4.17.1" - "@types/node": "npm:^20.12.11" + "@types/node": "npm:^20.12.12" "@types/semver": "npm:^7.5.8" "@types/supertest": "npm:^6.0.2" amqp-connection-manager: "npm:^4.1.14" From ba3a16135c48c529824d991b366efa15a6cd21f5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 May 2024 09:29:59 +0200 Subject: [PATCH 012/207] --- (#1571) updated-dependencies: - dependency-name: eslint dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 40 ++++++++++++++++++++-------------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index a56bb4a08b..9fb4530d8e 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "@types/node": "^20.12.12", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.2", - "eslint": "^9.2.0", + "eslint": "^9.3.0", "eslint-config-prettier": "^9.1.0", "husky": "^9.0.11", "jest": "29.7.0", diff --git a/yarn.lock b/yarn.lock index e6876183e0..546d01241c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -682,9 +682,9 @@ __metadata: languageName: node linkType: hard -"@eslint/eslintrc@npm:^3.0.2": - version: 3.0.2 - resolution: "@eslint/eslintrc@npm:3.0.2" +"@eslint/eslintrc@npm:^3.1.0": + version: 3.1.0 + resolution: "@eslint/eslintrc@npm:3.1.0" dependencies: ajv: "npm:^6.12.4" debug: "npm:^4.3.2" @@ -695,14 +695,14 @@ __metadata: js-yaml: "npm:^4.1.0" minimatch: "npm:^3.1.2" strip-json-comments: "npm:^3.1.1" - checksum: 10/04e3d7de2b16fd59ba8985ecd6922eb488e630f94e4433858567a8a6c99b478bb7b47854b166b830b44905759547d0a03654eb1265952c812d5d1d70e3e4ccf9 + checksum: 10/02bf892d1397e1029209dea685e9f4f87baf643315df2a632b5f121ec7e8548a3b34f428a007234fa82772218fa8a3ac2d10328637b9ce63b7f8344035b74db3 languageName: node linkType: hard -"@eslint/js@npm:9.2.0": - version: 9.2.0 - resolution: "@eslint/js@npm:9.2.0" - checksum: 10/4e9fec5395a8f6797bfa57b28b67c3b1c63ebcaf665e457546a34a42b14ebbf992d3617a64ae65addf32ab89cd7448008a275a4d73f9bcb1829f4eae67301841 +"@eslint/js@npm:9.3.0": + version: 9.3.0 + resolution: "@eslint/js@npm:9.3.0" + checksum: 10/3fb4b30561c34b52e7c6c6b55ea61df1cead73a525e1ccd77b1454d893dcf06f99fe9c46bf410a044ef7d3339c455bc4f75769b40c4734343f5b46d2d76b89ef languageName: node linkType: hard @@ -745,10 +745,10 @@ __metadata: languageName: node linkType: hard -"@humanwhocodes/retry@npm:^0.2.3": - version: 0.2.4 - resolution: "@humanwhocodes/retry@npm:0.2.4" - checksum: 10/14f2f797d89e01787dcb372211788a258dfd7875caa4e051b5110d9d9da46466921a313ef2366abc167d88e4ca8422e701bca334c1259794023f3a8bb48e8d7f +"@humanwhocodes/retry@npm:^0.3.0": + version: 0.3.0 + resolution: "@humanwhocodes/retry@npm:0.3.0" + checksum: 10/e574bab58680867414e225c9002e9a97eb396f85871c180fbb1a9bcdf9ded4b4de0b327f7d0c43b775873362b7c92956d4b322e8bc4b90be56077524341f04b2 languageName: node linkType: hard @@ -3873,17 +3873,17 @@ __metadata: languageName: node linkType: hard -"eslint@npm:^9.2.0": - version: 9.2.0 - resolution: "eslint@npm:9.2.0" +"eslint@npm:^9.3.0": + version: 9.3.0 + resolution: "eslint@npm:9.3.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.2.0" "@eslint-community/regexpp": "npm:^4.6.1" - "@eslint/eslintrc": "npm:^3.0.2" - "@eslint/js": "npm:9.2.0" + "@eslint/eslintrc": "npm:^3.1.0" + "@eslint/js": "npm:9.3.0" "@humanwhocodes/config-array": "npm:^0.13.0" "@humanwhocodes/module-importer": "npm:^1.0.1" - "@humanwhocodes/retry": "npm:^0.2.3" + "@humanwhocodes/retry": "npm:^0.3.0" "@nodelib/fs.walk": "npm:^1.2.8" ajv: "npm:^6.12.4" chalk: "npm:^4.0.0" @@ -3913,7 +3913,7 @@ __metadata: text-table: "npm:^0.2.0" bin: eslint: bin/eslint.js - checksum: 10/691626f7e6059966338d00bc11d232190974e10b701048fcbd2c34031ac80b6eed0e0c5612fc4e32205b56bdbf7d0be34f5c19b8f61ff655b67ad4fd2c0515d3 + checksum: 10/c56d63bc3655ce26456cb1b6869eb16579d9b243f143374ce28e4e168ab8fd9d054700014af903b6a5445a9134108327d974ba3e75019220f62df6ce72b6f5b6 languageName: node linkType: hard @@ -7283,7 +7283,7 @@ __metadata: amqp-connection-manager: "npm:^4.1.14" amqplib: "npm:^0.10.4" cookie-parser: "npm:^1.4.6" - eslint: "npm:^9.2.0" + eslint: "npm:^9.3.0" eslint-config-prettier: "npm:^9.1.0" husky: "npm:^9.0.11" jest: "npm:29.7.0" From 1aa056c0e14bca5e1af733c754a11a50678799d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 May 2024 09:30:17 +0200 Subject: [PATCH 013/207] --- (#1573) updated-dependencies: - dependency-name: "@safe-global/safe-deployments" dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 9fb4530d8e..b9ed9cb645 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "@nestjs/platform-express": "^10.3.8", "@nestjs/serve-static": "^4.0.2", "@nestjs/swagger": "^7.3.1", - "@safe-global/safe-deployments": "^1.35.0", + "@safe-global/safe-deployments": "^1.36.0", "amqp-connection-manager": "^4.1.14", "amqplib": "^0.10.4", "cookie-parser": "^1.4.6", diff --git a/yarn.lock b/yarn.lock index 546d01241c..6c785ba8a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1569,12 +1569,12 @@ __metadata: languageName: node linkType: hard -"@safe-global/safe-deployments@npm:^1.35.0": - version: 1.35.0 - resolution: "@safe-global/safe-deployments@npm:1.35.0" +"@safe-global/safe-deployments@npm:^1.36.0": + version: 1.36.0 + resolution: "@safe-global/safe-deployments@npm:1.36.0" dependencies: semver: "npm:^7.6.0" - checksum: 10/7ff4499d5ba218db49f28d56f6fd0b97a8b73a8d00be8c4f57265bbca2e84aff18f2a396bb0b0d23134321da7f5f7f751e462b237291deaefa1658574dec03ad + checksum: 10/dfcb6dc62d3c4a2c03d8aea099ac4f6155936a660c8456b88fe68867d5b8c3aca229f2583fae06cca5d1d33262dc8acfdb504ddeeccbf40bbdf600f52a819363 languageName: node linkType: hard @@ -7270,7 +7270,7 @@ __metadata: "@nestjs/serve-static": "npm:^4.0.2" "@nestjs/swagger": "npm:^7.3.1" "@nestjs/testing": "npm:^10.3.8" - "@safe-global/safe-deployments": "npm:^1.35.0" + "@safe-global/safe-deployments": "npm:^1.36.0" "@types/amqplib": "npm:^0" "@types/cookie-parser": "npm:^1.4.7" "@types/express": "npm:^4.17.21" From d26e04a0126e4b8447797141f77e8e5cccc6aa87 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 May 2024 09:30:45 +0200 Subject: [PATCH 014/207] --- (#1572) updated-dependencies: - dependency-name: viem dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index b9ed9cb645..0fab88cd28 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "semver": "^7.6.2", - "viem": "^2.10.5", + "viem": "^2.11.1", "winston": "^3.13.0", "zod": "^3.23.8" }, diff --git a/yarn.lock b/yarn.lock index 6c785ba8a5..1122c0ee37 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5040,12 +5040,12 @@ __metadata: languageName: node linkType: hard -"isows@npm:1.0.3": - version: 1.0.3 - resolution: "isows@npm:1.0.3" +"isows@npm:1.0.4": + version: 1.0.4 + resolution: "isows@npm:1.0.4" peerDependencies: ws: "*" - checksum: 10/9cacd5cf59f67deb51e825580cd445ab1725ecb05a67c704050383fb772856f3cd5e7da8ad08f5a3bd2823680d77d099459d0c6a7037972a74d6429af61af440 + checksum: 10/a3ee62e3d6216abb3adeeb2a551fe2e7835eac87b05a6ecc3e7739259bf5f8e83290501f49e26137390c8093f207fc3378d4a7653aab76ad7bbab4b2dba9c5b9 languageName: node linkType: hard @@ -7305,7 +7305,7 @@ __metadata: tsconfig-paths: "npm:4.2.0" typescript: "npm:^5.4.5" typescript-eslint: "npm:^7.8.0" - viem: "npm:^2.10.5" + viem: "npm:^2.11.1" winston: "npm:^3.13.0" zod: "npm:^3.23.8" languageName: unknown @@ -8330,9 +8330,9 @@ __metadata: languageName: node linkType: hard -"viem@npm:^2.10.5": - version: 2.10.5 - resolution: "viem@npm:2.10.5" +"viem@npm:^2.11.1": + version: 2.11.1 + resolution: "viem@npm:2.11.1" dependencies: "@adraffy/ens-normalize": "npm:1.10.0" "@noble/curves": "npm:1.2.0" @@ -8340,14 +8340,14 @@ __metadata: "@scure/bip32": "npm:1.3.2" "@scure/bip39": "npm:1.2.1" abitype: "npm:1.0.0" - isows: "npm:1.0.3" + isows: "npm:1.0.4" ws: "npm:8.13.0" peerDependencies: typescript: ">=5.0.4" peerDependenciesMeta: typescript: optional: true - checksum: 10/8ef40085caf77a2414c6d5d8b14c49d086534f8300d0a47645722a062deede12a0b22d8d9a18597a25f8892e4c479e7b9ebb3f7387f58aff9854fb0508e30a3b + checksum: 10/1dc5d1455d006e5788e865e6e8a702e7dcf8cd2ca5cdfecc2dccf5886a579044fc8e0915f6a53e6a5db82e52f8660d6cf1d18dfcf75486fde6cea379d02cbb8b languageName: node linkType: hard From 94ac0e247a88356edd86cdda28f7a281b3089655 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 May 2024 09:31:02 +0200 Subject: [PATCH 015/207] --- (#1569) updated-dependencies: - dependency-name: redis dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 0fab88cd28..e1e4566f17 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "nestjs-cls": "^4.3.0", "postgres": "^3.4.4", "postgres-shift": "^0.1.0", - "redis": "^4.6.13", + "redis": "^4.6.14", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "semver": "^7.6.2", diff --git a/yarn.lock b/yarn.lock index 1122c0ee37..7301df15d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1522,14 +1522,14 @@ __metadata: languageName: node linkType: hard -"@redis/client@npm:1.5.14": - version: 1.5.14 - resolution: "@redis/client@npm:1.5.14" +"@redis/client@npm:1.5.16": + version: 1.5.16 + resolution: "@redis/client@npm:1.5.16" dependencies: cluster-key-slot: "npm:1.1.2" generic-pool: "npm:3.9.0" yallist: "npm:4.0.0" - checksum: 10/aab53eff9456e0a5e0ef78ce16db3eca4b837274b8285c5d66ced549573dbacf75972935806911274d6dd906a53d982ef9b1a6f11a8efe4a18efa94ec9c2a4b3 + checksum: 10/54bd45dcdb980e9682fc9aaad36607a34b6c05ebc733fc9a132db33ce77b3ff63c229d8d8b43ce2d7db115f31ff2fefcbcc7dceeaa1fc88c03e7c8012e456adf languageName: node linkType: hard @@ -7050,17 +7050,17 @@ __metadata: languageName: node linkType: hard -"redis@npm:^4.6.13": - version: 4.6.13 - resolution: "redis@npm:4.6.13" +"redis@npm:^4.6.14": + version: 4.6.14 + resolution: "redis@npm:4.6.14" dependencies: "@redis/bloom": "npm:1.2.0" - "@redis/client": "npm:1.5.14" + "@redis/client": "npm:1.5.16" "@redis/graph": "npm:1.1.1" "@redis/json": "npm:1.0.6" "@redis/search": "npm:1.1.6" "@redis/time-series": "npm:1.0.5" - checksum: 10/cc66182b8fa78c2a63b5300b15fa6fbf8908773d78bc5ca3960018f465595b51dfecaebe8c848111a3b723530f17bdaa1c186f73875cd9ba351f32d2e5e14d5f + checksum: 10/5a00d678ea39a2e2fdaa961b593873e21677922b72671b00ab0feda3469506bc89c13221e56b1c00994504538ea45dd7ed6cde5d8be8da308a26f5d2424d0f85 languageName: node linkType: hard @@ -7293,7 +7293,7 @@ __metadata: postgres: "npm:^3.4.4" postgres-shift: "npm:^0.1.0" prettier: "npm:^3.2.5" - redis: "npm:^4.6.13" + redis: "npm:^4.6.14" reflect-metadata: "npm:^0.2.2" rxjs: "npm:^7.8.1" semver: "npm:^7.6.2" From 7dd6d82bb55525b50a6fe826606d877d4737879d Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Tue, 21 May 2024 09:36:37 +0200 Subject: [PATCH 016/207] Rename `campaignId` to `resourceId` (#1574) --- .../locking-api/locking-api.service.spec.ts | 28 ++++++------- .../locking-api/locking-api.service.ts | 8 ++-- .../community.repository.interface.ts | 4 +- src/domain/community/community.repository.ts | 6 +-- .../__tests__/activity-metadata.builder.ts | 2 +- .../entities/__tests__/campaign.builder.ts | 2 +- .../entities/activity-metadata.entity.ts | 2 +- .../community/entities/campaign.entity.ts | 2 +- .../activity-metadata.schema.spec.ts | 2 +- .../schemas/__tests__/campaign.schema.spec.ts | 2 +- .../interfaces/locking-api.interface.ts | 4 +- .../community/community.controller.spec.ts | 40 +++++++++---------- src/routes/community/community.controller.ts | 12 +++--- src/routes/community/community.service.ts | 8 ++-- .../entities/activity-metadata.entity.ts | 2 +- .../locking/entities/campaign.entity.ts | 2 +- 16 files changed, 63 insertions(+), 63 deletions(-) diff --git a/src/datasources/locking-api/locking-api.service.spec.ts b/src/datasources/locking-api/locking-api.service.spec.ts index d94cb45835..a44ea375cc 100644 --- a/src/datasources/locking-api/locking-api.service.spec.ts +++ b/src/datasources/locking-api/locking-api.service.spec.ts @@ -47,7 +47,7 @@ describe('LockingApi', () => { }); describe('getCampaignById', () => { - it('should get a campaign by campaignId', async () => { + it('should get a campaign by resourceId', async () => { const campaign = campaignBuilder().build(); mockNetworkService.get.mockResolvedValueOnce({ @@ -55,11 +55,11 @@ describe('LockingApi', () => { status: 200, }); - const result = await service.getCampaignById(campaign.campaignId); + const result = await service.getCampaignById(campaign.resourceId); expect(result).toEqual(campaign); expect(mockNetworkService.get).toHaveBeenCalledWith({ - url: `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}`, + url: `${lockingBaseUri}/api/v1/campaigns/${campaign.resourceId}`, }); }); @@ -67,7 +67,7 @@ describe('LockingApi', () => { const status = faker.internet.httpStatusCode({ types: ['serverError'] }); const campaign = campaignBuilder().build(); const error = new NetworkResponseError( - new URL(`${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}`), + new URL(`${lockingBaseUri}/api/v1/campaigns/${campaign.resourceId}`), { status, } as Response, @@ -78,7 +78,7 @@ describe('LockingApi', () => { mockNetworkService.get.mockRejectedValueOnce(error); await expect( - service.getCampaignById(campaign.campaignId), + service.getCampaignById(campaign.resourceId), ).rejects.toThrow(new DataSourceError('Unexpected error', status)); expect(mockNetworkService.get).toHaveBeenCalledTimes(1); @@ -266,7 +266,7 @@ describe('LockingApi', () => { describe('getCampaignLeaderboard', () => { it('should get leaderboard by campaign', async () => { - const campaignId = faker.string.uuid(); + const resourceId = faker.string.uuid(); const campaignRankPage = pageBuilder() .with('results', [ campaignRankBuilder().build(), @@ -278,11 +278,11 @@ describe('LockingApi', () => { status: 200, }); - const result = await service.getCampaignLeaderboard({ campaignId }); + const result = await service.getCampaignLeaderboard({ resourceId }); expect(result).toEqual(campaignRankPage); expect(mockNetworkService.get).toHaveBeenCalledWith({ - url: `${lockingBaseUri}/api/v1/campaigns/${campaignId}/leaderboard`, + url: `${lockingBaseUri}/api/v1/campaigns/${resourceId}/leaderboard`, networkRequest: { params: { limit: undefined, @@ -295,7 +295,7 @@ describe('LockingApi', () => { it('should forward pagination queries', async () => { const limit = faker.number.int(); const offset = faker.number.int(); - const campaignId = faker.string.uuid(); + const resourceId = faker.string.uuid(); const campaignRankPage = pageBuilder() .with('results', [ campaignRankBuilder().build(), @@ -307,10 +307,10 @@ describe('LockingApi', () => { status: 200, }); - await service.getCampaignLeaderboard({ campaignId, limit, offset }); + await service.getCampaignLeaderboard({ resourceId, limit, offset }); expect(mockNetworkService.get).toHaveBeenCalledWith({ - url: `${lockingBaseUri}/api/v1/campaigns/${campaignId}/leaderboard`, + url: `${lockingBaseUri}/api/v1/campaigns/${resourceId}/leaderboard`, networkRequest: { params: { limit, @@ -322,9 +322,9 @@ describe('LockingApi', () => { it('should forward error', async () => { const status = faker.internet.httpStatusCode({ types: ['serverError'] }); - const campaignId = faker.string.uuid(); + const resourceId = faker.string.uuid(); const error = new NetworkResponseError( - new URL(`${lockingBaseUri}/api/v1/campaigns/${campaignId}/leaderboard`), + new URL(`${lockingBaseUri}/api/v1/campaigns/${resourceId}/leaderboard`), { status, } as Response, @@ -335,7 +335,7 @@ describe('LockingApi', () => { mockNetworkService.get.mockRejectedValueOnce(error); await expect( - service.getCampaignLeaderboard({ campaignId }), + service.getCampaignLeaderboard({ resourceId }), ).rejects.toThrow(new DataSourceError('Unexpected error', status)); expect(mockNetworkService.get).toHaveBeenCalledTimes(1); diff --git a/src/datasources/locking-api/locking-api.service.ts b/src/datasources/locking-api/locking-api.service.ts index 4cb03a81c2..1f04d4ed4c 100644 --- a/src/datasources/locking-api/locking-api.service.ts +++ b/src/datasources/locking-api/locking-api.service.ts @@ -26,9 +26,9 @@ export class LockingApi implements ILockingApi { this.configurationService.getOrThrow('locking.baseUri'); } - async getCampaignById(campaignId: string): Promise { + async getCampaignById(resourceId: string): Promise { try { - const url = `${this.baseUri}/api/v1/campaigns/${campaignId}`; + const url = `${this.baseUri}/api/v1/campaigns/${resourceId}`; const { data } = await this.networkService.get({ url }); return data; } catch (error) { @@ -89,12 +89,12 @@ export class LockingApi implements ILockingApi { } async getCampaignLeaderboard(args: { - campaignId: string; + resourceId: string; limit?: number; offset?: number; }): Promise> { try { - const url = `${this.baseUri}/api/v1/campaigns/${args.campaignId}/leaderboard`; + const url = `${this.baseUri}/api/v1/campaigns/${args.resourceId}/leaderboard`; const { data } = await this.networkService.get>({ url, networkRequest: { diff --git a/src/domain/community/community.repository.interface.ts b/src/domain/community/community.repository.interface.ts index e59c1ba4fd..464616fd82 100644 --- a/src/domain/community/community.repository.interface.ts +++ b/src/domain/community/community.repository.interface.ts @@ -7,7 +7,7 @@ import { LockingRank } from '@/domain/community/entities/locking-rank.entity'; export const ICommunityRepository = Symbol('ICommunityRepository'); export interface ICommunityRepository { - getCampaignById(campaignId: string): Promise; + getCampaignById(resourceId: string): Promise; getCampaigns(args: { limit?: number; @@ -22,7 +22,7 @@ export interface ICommunityRepository { }): Promise>; getCampaignLeaderboard(args: { - campaignId: string; + resourceId: string; limit?: number; offset?: number; }): Promise>; diff --git a/src/domain/community/community.repository.ts b/src/domain/community/community.repository.ts index 1025faa0c7..9a028ab89a 100644 --- a/src/domain/community/community.repository.ts +++ b/src/domain/community/community.repository.ts @@ -26,8 +26,8 @@ export class CommunityRepository implements ICommunityRepository { private readonly lockingApi: ILockingApi, ) {} - async getCampaignById(campaignId: string): Promise { - const campaign = await this.lockingApi.getCampaignById(campaignId); + async getCampaignById(resourceId: string): Promise { + const campaign = await this.lockingApi.getCampaignById(resourceId); return CampaignSchema.parse(campaign); } @@ -53,7 +53,7 @@ export class CommunityRepository implements ICommunityRepository { } async getCampaignLeaderboard(args: { - campaignId: string; + resourceId: string; limit?: number; offset?: number; }): Promise> { diff --git a/src/domain/community/entities/__tests__/activity-metadata.builder.ts b/src/domain/community/entities/__tests__/activity-metadata.builder.ts index 04f6e8e01f..790a428024 100644 --- a/src/domain/community/entities/__tests__/activity-metadata.builder.ts +++ b/src/domain/community/entities/__tests__/activity-metadata.builder.ts @@ -4,7 +4,7 @@ import { faker } from '@faker-js/faker'; export function activityMetadataBuilder(): IBuilder { return new Builder() - .with('campaignId', faker.string.uuid()) + .with('resourceId', faker.string.uuid()) .with('name', faker.word.words()) .with('description', faker.lorem.sentence()) .with('maxPoints', faker.string.numeric()); diff --git a/src/domain/community/entities/__tests__/campaign.builder.ts b/src/domain/community/entities/__tests__/campaign.builder.ts index 5deeca3b05..24e18ea095 100644 --- a/src/domain/community/entities/__tests__/campaign.builder.ts +++ b/src/domain/community/entities/__tests__/campaign.builder.ts @@ -5,7 +5,7 @@ import { faker } from '@faker-js/faker'; export function campaignBuilder(): IBuilder { return new Builder() - .with('campaignId', faker.string.uuid()) + .with('resourceId', faker.string.uuid()) .with('name', faker.word.words()) .with('description', faker.lorem.sentence()) .with('startDate', faker.date.recent()) diff --git a/src/domain/community/entities/activity-metadata.entity.ts b/src/domain/community/entities/activity-metadata.entity.ts index 84f80be0ef..58d1dad356 100644 --- a/src/domain/community/entities/activity-metadata.entity.ts +++ b/src/domain/community/entities/activity-metadata.entity.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; export type ActivityMetadata = z.infer; export const ActivityMetadataSchema = z.object({ - campaignId: z.string(), + resourceId: z.string(), name: z.string(), description: z.string(), maxPoints: NumericStringSchema, diff --git a/src/domain/community/entities/campaign.entity.ts b/src/domain/community/entities/campaign.entity.ts index eddd4ed12c..0639d885b2 100644 --- a/src/domain/community/entities/campaign.entity.ts +++ b/src/domain/community/entities/campaign.entity.ts @@ -5,7 +5,7 @@ import { z } from 'zod'; export type Campaign = z.infer; export const CampaignSchema = z.object({ - campaignId: z.string(), + resourceId: z.string(), name: z.string(), description: z.string(), startDate: z.coerce.date(), diff --git a/src/domain/community/entities/schemas/__tests__/activity-metadata.schema.spec.ts b/src/domain/community/entities/schemas/__tests__/activity-metadata.schema.spec.ts index 3c37694aea..eee997c450 100644 --- a/src/domain/community/entities/schemas/__tests__/activity-metadata.schema.spec.ts +++ b/src/domain/community/entities/schemas/__tests__/activity-metadata.schema.spec.ts @@ -41,7 +41,7 @@ describe('ActivityMetadataSchema', () => { code: 'invalid_type', expected: 'string', received: 'undefined', - path: ['campaignId'], + path: ['resourceId'], message: 'Required', }, { diff --git a/src/domain/community/entities/schemas/__tests__/campaign.schema.spec.ts b/src/domain/community/entities/schemas/__tests__/campaign.schema.spec.ts index 5c3efb6e0e..ca4cf84887 100644 --- a/src/domain/community/entities/schemas/__tests__/campaign.schema.spec.ts +++ b/src/domain/community/entities/schemas/__tests__/campaign.schema.spec.ts @@ -63,7 +63,7 @@ describe('CampaignSchema', () => { code: 'invalid_type', expected: 'string', received: 'undefined', - path: ['campaignId'], + path: ['resourceId'], message: 'Required', }, { diff --git a/src/domain/interfaces/locking-api.interface.ts b/src/domain/interfaces/locking-api.interface.ts index cd20944a86..a64d4d93ec 100644 --- a/src/domain/interfaces/locking-api.interface.ts +++ b/src/domain/interfaces/locking-api.interface.ts @@ -7,7 +7,7 @@ import { LockingRank } from '@/domain/community/entities/locking-rank.entity'; export const ILockingApi = Symbol('ILockingApi'); export interface ILockingApi { - getCampaignById(campaignId: string): Promise; + getCampaignById(resourceId: string): Promise; getCampaigns(args: { limit?: number; @@ -22,7 +22,7 @@ export interface ILockingApi { }): Promise>; getCampaignLeaderboard(args: { - campaignId: string; + resourceId: string; limit?: number; offset?: number; }): Promise>; diff --git a/src/routes/community/community.controller.spec.ts b/src/routes/community/community.controller.spec.ts index ba558e5195..b3593f2b52 100644 --- a/src/routes/community/community.controller.spec.ts +++ b/src/routes/community/community.controller.spec.ts @@ -202,12 +202,12 @@ describe('Community (Unit)', () => { }); }); - describe('GET /community/campaigns/:campaignId', () => { + describe('GET /community/campaigns/:resourceId', () => { it('should get a campaign by ID', async () => { const campaign = campaignBuilder().build(); networkService.get.mockImplementation(({ url }) => { switch (url) { - case `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}`: + case `${lockingBaseUri}/api/v1/campaigns/${campaign.resourceId}`: return Promise.resolve({ data: campaign, status: 200 }); default: return Promise.reject(`No matching rule for url: ${url}`); @@ -215,19 +215,19 @@ describe('Community (Unit)', () => { }); await request(app.getHttpServer()) - .get(`/v1/community/campaigns/${campaign.campaignId}`) + .get(`/v1/community/campaigns/${campaign.resourceId}`) .expect(200) .expect(campaignToJson(campaign) as Campaign); }); it('should validate the response', async () => { const invalidCampaign = { - campaignId: faker.string.uuid(), + resourceId: faker.string.uuid(), invalid: 'campaign', }; networkService.get.mockImplementation(({ url }) => { switch (url) { - case `${lockingBaseUri}/api/v1/campaigns/${invalidCampaign.campaignId}`: + case `${lockingBaseUri}/api/v1/campaigns/${invalidCampaign.resourceId}`: return Promise.resolve({ data: invalidCampaign, status: 200 }); default: return Promise.reject(`No matching rule for url: ${url}`); @@ -235,7 +235,7 @@ describe('Community (Unit)', () => { }); await request(app.getHttpServer()) - .get(`/v1/community/campaigns/${invalidCampaign.campaignId}`) + .get(`/v1/community/campaigns/${invalidCampaign.resourceId}`) .expect(500) .expect({ statusCode: 500, @@ -244,17 +244,17 @@ describe('Community (Unit)', () => { }); it('should forward an error from the service', async () => { - const campaignId = faker.string.uuid(); + const resourceId = faker.string.uuid(); const statusCode = faker.internet.httpStatusCode({ types: ['clientError', 'serverError'], }); const errorMessage = faker.word.words(); networkService.get.mockImplementation(({ url }) => { switch (url) { - case `${lockingBaseUri}/api/v1/campaigns/${campaignId}`: + case `${lockingBaseUri}/api/v1/campaigns/${resourceId}`: return Promise.reject( new NetworkResponseError( - new URL(`${lockingBaseUri}/api/v1/campaigns/${campaignId}`), + new URL(`${lockingBaseUri}/api/v1/campaigns/${resourceId}`), { status: statusCode, } as Response, @@ -267,7 +267,7 @@ describe('Community (Unit)', () => { }); await request(app.getHttpServer()) - .get(`/v1/community/campaigns/${campaignId}`) + .get(`/v1/community/campaigns/${resourceId}`) .expect(statusCode) .expect({ message: errorMessage, @@ -276,7 +276,7 @@ describe('Community (Unit)', () => { }); }); - describe('GET /community/campaigns/:campaignId/leaderboard', () => { + describe('GET /community/campaigns/:resourceId/leaderboard', () => { it('should get the leaderboard by campaign ID', async () => { const campaign = campaignBuilder().build(); const campaignRankPage = pageBuilder() @@ -290,7 +290,7 @@ describe('Community (Unit)', () => { .build(); networkService.get.mockImplementation(({ url }) => { switch (url) { - case `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}/leaderboard`: + case `${lockingBaseUri}/api/v1/campaigns/${campaign.resourceId}/leaderboard`: return Promise.resolve({ data: campaignRankPage, status: 200 }); default: return Promise.reject(`No matching rule for url: ${url}`); @@ -298,7 +298,7 @@ describe('Community (Unit)', () => { }); await request(app.getHttpServer()) - .get(`/v1/community/campaigns/${campaign.campaignId}/leaderboard`) + .get(`/v1/community/campaigns/${campaign.resourceId}/leaderboard`) .expect(200) .expect({ count: 2, @@ -319,7 +319,7 @@ describe('Community (Unit)', () => { .build(); networkService.get.mockImplementation(({ url }) => { switch (url) { - case `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}/leaderboard`: + case `${lockingBaseUri}/api/v1/campaigns/${campaign.resourceId}/leaderboard`: return Promise.resolve({ data: campaignRankPage, status: 200 }); default: return Promise.reject(`No matching rule for url: ${url}`); @@ -327,7 +327,7 @@ describe('Community (Unit)', () => { }); await request(app.getHttpServer()) - .get(`/v1/community/campaigns/${campaign.campaignId}/leaderboard`) + .get(`/v1/community/campaigns/${campaign.resourceId}/leaderboard`) .expect(500) .expect({ statusCode: 500, @@ -350,7 +350,7 @@ describe('Community (Unit)', () => { .build(); networkService.get.mockImplementation(({ url }) => { switch (url) { - case `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}/leaderboard`: + case `${lockingBaseUri}/api/v1/campaigns/${campaign.resourceId}/leaderboard`: return Promise.resolve({ data: campaignRankPage, status: 200 }); default: return Promise.reject(`No matching rule for url: ${url}`); @@ -359,7 +359,7 @@ describe('Community (Unit)', () => { await request(app.getHttpServer()) .get( - `/v1/community/campaigns/${campaign.campaignId}/leaderboard?cursor=limit%3D${limit}%26offset%3D${offset}`, + `/v1/community/campaigns/${campaign.resourceId}/leaderboard?cursor=limit%3D${limit}%26offset%3D${offset}`, ) .expect(200) .expect({ @@ -370,7 +370,7 @@ describe('Community (Unit)', () => { }); expect(networkService.get).toHaveBeenCalledWith({ - url: `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}/leaderboard`, + url: `${lockingBaseUri}/api/v1/campaigns/${campaign.resourceId}/leaderboard`, networkRequest: { params: { limit, @@ -388,7 +388,7 @@ describe('Community (Unit)', () => { const errorMessage = faker.word.words(); networkService.get.mockImplementation(({ url }) => { switch (url) { - case `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}/leaderboard`: + case `${lockingBaseUri}/api/v1/campaigns/${campaign.resourceId}/leaderboard`: return Promise.reject( new NetworkResponseError( new URL(url), @@ -404,7 +404,7 @@ describe('Community (Unit)', () => { }); await request(app.getHttpServer()) - .get(`/v1/community/campaigns/${campaign.campaignId}/leaderboard`) + .get(`/v1/community/campaigns/${campaign.resourceId}/leaderboard`) .expect(statusCode) .expect({ message: errorMessage, diff --git a/src/routes/community/community.controller.ts b/src/routes/community/community.controller.ts index cf57bbbd63..b84f85094a 100644 --- a/src/routes/community/community.controller.ts +++ b/src/routes/community/community.controller.ts @@ -36,11 +36,11 @@ export class CommunityController { } @ApiOkResponse({ type: Campaign }) - @Get('/campaigns/:campaignId') + @Get('/campaigns/:resourceId') async getCampaignById( - @Param('campaignId') campaignId: string, + @Param('resourceId') resourceId: string, ): Promise { - return this.communityService.getCampaignById(campaignId); + return this.communityService.getCampaignById(resourceId); } @ApiOkResponse({ type: CampaignRankPage }) @@ -49,14 +49,14 @@ export class CommunityController { required: false, type: String, }) - @Get('/campaigns/:campaignId/leaderboard') + @Get('/campaigns/:resourceId/leaderboard') async getCampaignLeaderboard( - @Param('campaignId') campaignId: string, + @Param('resourceId') resourceId: string, @RouteUrlDecorator() routeUrl: URL, @PaginationDataDecorator() paginationData: PaginationData, ): Promise { return this.communityService.getCampaignLeaderboard({ - campaignId, + resourceId, routeUrl, paginationData, }); diff --git a/src/routes/community/community.service.ts b/src/routes/community/community.service.ts index 6f6ec54f3c..ae7b385c1a 100644 --- a/src/routes/community/community.service.ts +++ b/src/routes/community/community.service.ts @@ -39,17 +39,17 @@ export class CommunityService { }; } - async getCampaignById(campaignId: string): Promise { - return this.communityRepository.getCampaignById(campaignId); + async getCampaignById(resourceId: string): Promise { + return this.communityRepository.getCampaignById(resourceId); } async getCampaignLeaderboard(args: { - campaignId: string; + resourceId: string; routeUrl: URL; paginationData: PaginationData; }): Promise> { const result = await this.communityRepository.getCampaignLeaderboard({ - campaignId: args.campaignId, + resourceId: args.resourceId, limit: args.paginationData.limit, offset: args.paginationData.offset, }); diff --git a/src/routes/locking/entities/activity-metadata.entity.ts b/src/routes/locking/entities/activity-metadata.entity.ts index 84e3250118..83b877d764 100644 --- a/src/routes/locking/entities/activity-metadata.entity.ts +++ b/src/routes/locking/entities/activity-metadata.entity.ts @@ -3,7 +3,7 @@ import { ApiProperty } from '@nestjs/swagger'; export class ActivityMetadata implements DomainActivityMetadata { @ApiProperty() - campaignId!: string; + resourceId!: string; @ApiProperty() name!: string; @ApiProperty() diff --git a/src/routes/locking/entities/campaign.entity.ts b/src/routes/locking/entities/campaign.entity.ts index 2ca5930c43..58582ee0cc 100644 --- a/src/routes/locking/entities/campaign.entity.ts +++ b/src/routes/locking/entities/campaign.entity.ts @@ -4,7 +4,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class Campaign implements DomainCampaign { @ApiProperty() - campaignId!: string; + resourceId!: string; @ApiProperty() name!: string; @ApiProperty() From 2f8f63888549622fc674c05c29a787dfaea65b45 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Tue, 21 May 2024 10:55:19 +0200 Subject: [PATCH 017/207] Update `ActivityMetadata` entity (#1577) Aligns `ActivityMetadata` entity by removing `resourceId` and changing `maxPoints` to a number: - Remove `resourceId` and change `maxPoints` to number in `ActivityMetadataSchema` - Update `ActivityMetadata` to reflect changes - Update tests accordingly --- .../__tests__/activity-metadata.builder.ts | 3 +- .../entities/activity-metadata.entity.ts | 4 +-- .../activity-metadata.schema.spec.ts | 28 +------------------ .../entities/activity-metadata.entity.ts | 4 +-- 4 files changed, 4 insertions(+), 35 deletions(-) diff --git a/src/domain/community/entities/__tests__/activity-metadata.builder.ts b/src/domain/community/entities/__tests__/activity-metadata.builder.ts index 790a428024..8080d51a4c 100644 --- a/src/domain/community/entities/__tests__/activity-metadata.builder.ts +++ b/src/domain/community/entities/__tests__/activity-metadata.builder.ts @@ -4,8 +4,7 @@ import { faker } from '@faker-js/faker'; export function activityMetadataBuilder(): IBuilder { return new Builder() - .with('resourceId', faker.string.uuid()) .with('name', faker.word.words()) .with('description', faker.lorem.sentence()) - .with('maxPoints', faker.string.numeric()); + .with('maxPoints', faker.number.int()); } diff --git a/src/domain/community/entities/activity-metadata.entity.ts b/src/domain/community/entities/activity-metadata.entity.ts index 58d1dad356..11f8723ac5 100644 --- a/src/domain/community/entities/activity-metadata.entity.ts +++ b/src/domain/community/entities/activity-metadata.entity.ts @@ -1,11 +1,9 @@ -import { NumericStringSchema } from '@/validation/entities/schemas/numeric-string.schema'; import { z } from 'zod'; export type ActivityMetadata = z.infer; export const ActivityMetadataSchema = z.object({ - resourceId: z.string(), name: z.string(), description: z.string(), - maxPoints: NumericStringSchema, + maxPoints: z.number(), }); diff --git a/src/domain/community/entities/schemas/__tests__/activity-metadata.schema.spec.ts b/src/domain/community/entities/schemas/__tests__/activity-metadata.schema.spec.ts index eee997c450..f80ee8169e 100644 --- a/src/domain/community/entities/schemas/__tests__/activity-metadata.schema.spec.ts +++ b/src/domain/community/entities/schemas/__tests__/activity-metadata.schema.spec.ts @@ -1,6 +1,5 @@ import { activityMetadataBuilder } from '@/domain/community/entities/__tests__/activity-metadata.builder'; import { ActivityMetadataSchema } from '@/domain/community/entities/activity-metadata.entity'; -import { faker } from '@faker-js/faker'; import { ZodError } from 'zod'; describe('ActivityMetadataSchema', () => { @@ -12,24 +11,6 @@ describe('ActivityMetadataSchema', () => { expect(result.success).toBe(true); }); - it('should not allow a non-numeric string for maxPoints', () => { - const activityMetadata = activityMetadataBuilder() - .with('maxPoints', faker.string.alpha()) - .build(); - - const result = ActivityMetadataSchema.safeParse(activityMetadata); - - expect(!result.success && result.error).toStrictEqual( - new ZodError([ - { - code: 'custom', - message: 'Invalid base-10 numeric string', - path: ['maxPoints'], - }, - ]), - ); - }); - it('should not validate an invalid activity metadata', () => { const activityMetadata = { invalid: 'activity metadata' }; @@ -37,13 +18,6 @@ describe('ActivityMetadataSchema', () => { expect(!result.success && result.error).toStrictEqual( new ZodError([ - { - code: 'invalid_type', - expected: 'string', - received: 'undefined', - path: ['resourceId'], - message: 'Required', - }, { code: 'invalid_type', expected: 'string', @@ -60,7 +34,7 @@ describe('ActivityMetadataSchema', () => { }, { code: 'invalid_type', - expected: 'string', + expected: 'number', received: 'undefined', path: ['maxPoints'], message: 'Required', diff --git a/src/routes/locking/entities/activity-metadata.entity.ts b/src/routes/locking/entities/activity-metadata.entity.ts index 83b877d764..2ff388abb7 100644 --- a/src/routes/locking/entities/activity-metadata.entity.ts +++ b/src/routes/locking/entities/activity-metadata.entity.ts @@ -2,12 +2,10 @@ import { ActivityMetadata as DomainActivityMetadata } from '@/domain/community/e import { ApiProperty } from '@nestjs/swagger'; export class ActivityMetadata implements DomainActivityMetadata { - @ApiProperty() - resourceId!: string; @ApiProperty() name!: string; @ApiProperty() description!: string; @ApiProperty() - maxPoints!: string; + maxPoints!: number; } From 1109b86ed733bd23edaa07c43769101d5ceebdca Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Tue, 21 May 2024 11:20:06 +0200 Subject: [PATCH 018/207] Add campaign rank retrieval to locking API (#1575) Add ILockingApi['getCampaignRank'] --- .../locking-api/locking-api.service.spec.ts | 43 +++++++++++++++++++ .../locking-api/locking-api.service.ts | 13 ++++++ .../interfaces/locking-api.interface.ts | 5 +++ 3 files changed, 61 insertions(+) diff --git a/src/datasources/locking-api/locking-api.service.spec.ts b/src/datasources/locking-api/locking-api.service.spec.ts index a44ea375cc..6172d93752 100644 --- a/src/datasources/locking-api/locking-api.service.spec.ts +++ b/src/datasources/locking-api/locking-api.service.spec.ts @@ -156,6 +156,49 @@ describe('LockingApi', () => { }); }); + describe('getCampaignRank', () => { + it('should get campaign rank', async () => { + const resourceId = faker.string.uuid(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const campaignRank = campaignRankBuilder().build(); + mockNetworkService.get.mockResolvedValueOnce({ + data: campaignRank, + status: 200, + }); + + const result = await service.getCampaignRank({ resourceId, safeAddress }); + + expect(result).toEqual(campaignRank); + expect(mockNetworkService.get).toHaveBeenCalledWith({ + url: `${lockingBaseUri}/api/v1/campaigns/${resourceId}/leaderboard/${safeAddress}`, + }); + }); + + it('should forward error', async () => { + const resourceId = faker.string.uuid(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const status = faker.internet.httpStatusCode({ types: ['serverError'] }); + const error = new NetworkResponseError( + new URL( + `${lockingBaseUri}/api/v1/campaigns/${resourceId}/leaderboard/${safeAddress}`, + ), + { + status, + } as Response, + { + message: 'Unexpected error', + }, + ); + mockNetworkService.get.mockRejectedValueOnce(error); + + await expect( + service.getCampaignRank({ resourceId, safeAddress }), + ).rejects.toThrow(new DataSourceError('Unexpected error', status)); + + expect(mockNetworkService.get).toHaveBeenCalledTimes(1); + }); + }); + describe('getLockingRank', () => { it('should get locking rank', async () => { const safeAddress = getAddress(faker.finance.ethereumAddress()); diff --git a/src/datasources/locking-api/locking-api.service.ts b/src/datasources/locking-api/locking-api.service.ts index 1f04d4ed4c..415a60600e 100644 --- a/src/datasources/locking-api/locking-api.service.ts +++ b/src/datasources/locking-api/locking-api.service.ts @@ -57,6 +57,19 @@ export class LockingApi implements ILockingApi { } } + async getCampaignRank(args: { + resourceId: string; + safeAddress: `0x${string}`; + }): Promise { + try { + const url = `${this.baseUri}/api/v1/campaigns/${args.resourceId}/leaderboard/${args.safeAddress}`; + const { data } = await this.networkService.get({ url }); + return data; + } catch (error) { + throw this.httpErrorFactory.from(error); + } + } + async getLockingRank(safeAddress: `0x${string}`): Promise { try { const url = `${this.baseUri}/api/v1/leaderboard/${safeAddress}`; diff --git a/src/domain/interfaces/locking-api.interface.ts b/src/domain/interfaces/locking-api.interface.ts index a64d4d93ec..9c6cfd89d6 100644 --- a/src/domain/interfaces/locking-api.interface.ts +++ b/src/domain/interfaces/locking-api.interface.ts @@ -27,6 +27,11 @@ export interface ILockingApi { offset?: number; }): Promise>; + getCampaignRank(args: { + resourceId: string; + safeAddress: `0x${string}`; + }): Promise; + getLockingHistory(args: { safeAddress: `0x${string}`; limit?: number; From b1776087c04d1b72e736a2e09905c53c980b7240 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Tue, 21 May 2024 11:20:58 +0200 Subject: [PATCH 019/207] Add campaign rank route (#1576) Add ICommunityRespository['getCampaignRank'] and implementation Add CommunityController['getCampaignRank]` under aforementioned route --- .../community.repository.interface.ts | 5 + src/domain/community/community.repository.ts | 9 ++ .../community/community.controller.spec.ts | 93 +++++++++++++++++++ src/routes/community/community.controller.ts | 11 +++ src/routes/community/community.service.ts | 7 ++ 5 files changed, 125 insertions(+) diff --git a/src/domain/community/community.repository.interface.ts b/src/domain/community/community.repository.interface.ts index 464616fd82..31b80489b2 100644 --- a/src/domain/community/community.repository.interface.ts +++ b/src/domain/community/community.repository.interface.ts @@ -27,6 +27,11 @@ export interface ICommunityRepository { offset?: number; }): Promise>; + getCampaignRank(args: { + resourceId: string; + safeAddress: `0x${string}`; + }): Promise; + getLockingHistory(args: { safeAddress: `0x${string}`; offset?: number; diff --git a/src/domain/community/community.repository.ts b/src/domain/community/community.repository.ts index 9a028ab89a..a12b61ca07 100644 --- a/src/domain/community/community.repository.ts +++ b/src/domain/community/community.repository.ts @@ -8,6 +8,7 @@ import { import { CampaignRank, CampaignRankPageSchema, + CampaignRankSchema, } from '@/domain/community/entities/campaign-rank.entity'; import { LockingEvent } from '@/domain/community/entities/locking-event.entity'; import { LockingRank } from '@/domain/community/entities/locking-rank.entity'; @@ -61,6 +62,14 @@ export class CommunityRepository implements ICommunityRepository { return CampaignRankPageSchema.parse(page); } + async getCampaignRank(args: { + resourceId: string; + safeAddress: `0x${string}`; + }): Promise { + const campaignRank = await this.lockingApi.getCampaignRank(args); + return CampaignRankSchema.parse(campaignRank); + } + async getLockingHistory(args: { safeAddress: `0x${string}`; offset?: number; diff --git a/src/routes/community/community.controller.spec.ts b/src/routes/community/community.controller.spec.ts index b3593f2b52..f8e07ee1c6 100644 --- a/src/routes/community/community.controller.spec.ts +++ b/src/routes/community/community.controller.spec.ts @@ -413,6 +413,99 @@ describe('Community (Unit)', () => { }); }); + describe('GET /community/campaigns/:resourceId/leaderboard/:safeAddress', () => { + it('should get the campaign rank', async () => { + const resourceId = faker.string.uuid(); + const campaignRank = campaignRankBuilder().build(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns/${resourceId}/leaderboard/${safeAddress}`: + return Promise.resolve({ data: campaignRank, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/community/campaigns/${resourceId}/leaderboard/${safeAddress}`) + .expect(200) + .expect(campaignRank); + }); + + it('should validate the Safe address in URL', async () => { + const resourceId = faker.string.uuid(); + const safeAddress = faker.string.alphanumeric(); + + await request(app.getHttpServer()) + .get(`/v1/community/campaigns/${resourceId}/leaderboard/${safeAddress}`) + .expect(422) + .expect({ + statusCode: 422, + code: 'custom', + message: 'Invalid address', + path: [], + }); + }); + + it('should validate the response', async () => { + const resourceId = faker.string.uuid(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const campaignRank = { invalid: 'campaignRank' }; + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns/${resourceId}/leaderboard/${safeAddress}`: + return Promise.resolve({ data: campaignRank, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/community/campaigns/${resourceId}/leaderboard/${safeAddress}`) + .expect(500) + .expect({ + statusCode: 500, + message: 'Internal server error', + }); + }); + + it('should forward an error from the service', async () => { + const resourceId = faker.string.uuid(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const statusCode = faker.internet.httpStatusCode({ + types: ['clientError', 'serverError'], + }); + const errorMessage = faker.word.words(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns/${resourceId}/leaderboard/${safeAddress}`: + return Promise.reject( + new NetworkResponseError( + new URL( + `${lockingBaseUri}/api/v1/campaigns/${resourceId}/leaderboard/${safeAddress}`, + ), + { + status: statusCode, + } as Response, + { message: errorMessage, status: statusCode }, + ), + ); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/community/campaigns/${resourceId}/leaderboard/${safeAddress}`) + .expect(statusCode) + .expect({ + message: errorMessage, + code: statusCode, + }); + }); + }); + describe('GET /community/locking/leaderboard', () => { it('should get the leaderboard', async () => { const leaderboard = pageBuilder() diff --git a/src/routes/community/community.controller.ts b/src/routes/community/community.controller.ts index b84f85094a..08b47b0b3f 100644 --- a/src/routes/community/community.controller.ts +++ b/src/routes/community/community.controller.ts @@ -2,6 +2,7 @@ import { PaginationDataDecorator } from '@/routes/common/decorators/pagination.d import { RouteUrlDecorator } from '@/routes/common/decorators/route.url.decorator'; import { PaginationData } from '@/routes/common/pagination/pagination.data'; import { CommunityService } from '@/routes/community/community.service'; +import { CampaignRank } from '@/routes/locking/entities/campaign-rank.entity'; import { CampaignRankPage } from '@/routes/locking/entities/campaign-rank.page.entity'; import { Campaign } from '@/routes/locking/entities/campaign.entity'; import { CampaignPage } from '@/routes/locking/entities/campaign.page.entity'; @@ -62,6 +63,16 @@ export class CommunityController { }); } + @ApiOkResponse({ type: CampaignRank }) + @Get('/campaigns/:resourceId/leaderboard/:safeAddress') + async getCampaignRank( + @Param('resourceId') resourceId: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, + ): Promise { + return this.communityService.getCampaignRank({ resourceId, safeAddress }); + } + @ApiOkResponse({ type: LockingRankPage }) @ApiQuery({ name: 'cursor', diff --git a/src/routes/community/community.service.ts b/src/routes/community/community.service.ts index ae7b385c1a..1d56ad402f 100644 --- a/src/routes/community/community.service.ts +++ b/src/routes/community/community.service.ts @@ -68,6 +68,13 @@ export class CommunityService { }; } + async getCampaignRank(args: { + resourceId: string; + safeAddress: `0x${string}`; + }): Promise { + return this.communityRepository.getCampaignRank(args); + } + async getLockingLeaderboard(args: { routeUrl: URL; paginationData: PaginationData; From bc1937f124b127d877f26c40a20c3ab474bae0fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Tue, 21 May 2024 12:59:42 +0200 Subject: [PATCH 020/207] Change CampaignRank schema (#1578) Change boost, points, and boostedPoints to numbers in CampaignRank. --- .../community/entities/__tests__/campaign-rank.builder.ts | 6 +++--- src/domain/community/entities/campaign-rank.entity.ts | 7 +++---- .../schemas/__tests__/campaign-rank.schema.spec.ts | 6 +++--- src/routes/locking/entities/campaign-rank.entity.ts | 6 +++--- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/domain/community/entities/__tests__/campaign-rank.builder.ts b/src/domain/community/entities/__tests__/campaign-rank.builder.ts index 4bc1825a5b..04eabdc6da 100644 --- a/src/domain/community/entities/__tests__/campaign-rank.builder.ts +++ b/src/domain/community/entities/__tests__/campaign-rank.builder.ts @@ -7,7 +7,7 @@ export function campaignRankBuilder(): IBuilder { return new Builder() .with('holder', getAddress(faker.finance.ethereumAddress())) .with('position', faker.number.int()) - .with('boost', faker.string.numeric()) - .with('points', faker.string.numeric()) - .with('boostedPoints', faker.string.numeric()); + .with('boost', faker.number.float()) + .with('points', faker.number.float()) + .with('boostedPoints', faker.number.float()); } diff --git a/src/domain/community/entities/campaign-rank.entity.ts b/src/domain/community/entities/campaign-rank.entity.ts index 02dcf7339a..1de022f85a 100644 --- a/src/domain/community/entities/campaign-rank.entity.ts +++ b/src/domain/community/entities/campaign-rank.entity.ts @@ -1,14 +1,13 @@ import { buildPageSchema } from '@/domain/entities/schemas/page.schema.factory'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; -import { NumericStringSchema } from '@/validation/entities/schemas/numeric-string.schema'; import { z } from 'zod'; export const CampaignRankSchema = z.object({ holder: AddressSchema, position: z.number(), - boost: NumericStringSchema, - points: NumericStringSchema, - boostedPoints: NumericStringSchema, + boost: z.number(), + points: z.number(), + boostedPoints: z.number(), }); export const CampaignRankPageSchema = buildPageSchema(CampaignRankSchema); diff --git a/src/domain/community/entities/schemas/__tests__/campaign-rank.schema.spec.ts b/src/domain/community/entities/schemas/__tests__/campaign-rank.schema.spec.ts index 7c7c7b015a..38ba8cdb11 100644 --- a/src/domain/community/entities/schemas/__tests__/campaign-rank.schema.spec.ts +++ b/src/domain/community/entities/schemas/__tests__/campaign-rank.schema.spec.ts @@ -51,21 +51,21 @@ describe('CampaignRankSchema', () => { }, { code: 'invalid_type', - expected: 'string', + expected: 'number', received: 'undefined', path: ['boost'], message: 'Required', }, { code: 'invalid_type', - expected: 'string', + expected: 'number', received: 'undefined', path: ['points'], message: 'Required', }, { code: 'invalid_type', - expected: 'string', + expected: 'number', received: 'undefined', path: ['boostedPoints'], message: 'Required', diff --git a/src/routes/locking/entities/campaign-rank.entity.ts b/src/routes/locking/entities/campaign-rank.entity.ts index a136abd3cd..7ff7750852 100644 --- a/src/routes/locking/entities/campaign-rank.entity.ts +++ b/src/routes/locking/entities/campaign-rank.entity.ts @@ -7,9 +7,9 @@ export class CampaignRank implements DomainCampaignRank { @ApiProperty() position!: number; @ApiProperty() - boost!: string; + boost!: number; @ApiProperty() - points!: string; + points!: number; @ApiProperty() - boostedPoints!: string; + boostedPoints!: number; } From 8a83262dfa0cddd6d3355fd28ebcb61aaef6d03b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Tue, 21 May 2024 13:18:51 +0200 Subject: [PATCH 021/207] Rename points fields (#1579) Renames points to totalPoints and boostedPoints to totalBoostedPoints in CampaignRankSchema. --- .../community/entities/__tests__/campaign-rank.builder.ts | 4 ++-- src/domain/community/entities/campaign-rank.entity.ts | 4 ++-- .../entities/schemas/__tests__/campaign-rank.schema.spec.ts | 4 ++-- src/routes/locking/entities/campaign-rank.entity.ts | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/domain/community/entities/__tests__/campaign-rank.builder.ts b/src/domain/community/entities/__tests__/campaign-rank.builder.ts index 04eabdc6da..da51bd7f63 100644 --- a/src/domain/community/entities/__tests__/campaign-rank.builder.ts +++ b/src/domain/community/entities/__tests__/campaign-rank.builder.ts @@ -8,6 +8,6 @@ export function campaignRankBuilder(): IBuilder { .with('holder', getAddress(faker.finance.ethereumAddress())) .with('position', faker.number.int()) .with('boost', faker.number.float()) - .with('points', faker.number.float()) - .with('boostedPoints', faker.number.float()); + .with('totalPoints', faker.number.float()) + .with('totalBoostedPoints', faker.number.float()); } diff --git a/src/domain/community/entities/campaign-rank.entity.ts b/src/domain/community/entities/campaign-rank.entity.ts index 1de022f85a..19ce7b534b 100644 --- a/src/domain/community/entities/campaign-rank.entity.ts +++ b/src/domain/community/entities/campaign-rank.entity.ts @@ -6,8 +6,8 @@ export const CampaignRankSchema = z.object({ holder: AddressSchema, position: z.number(), boost: z.number(), - points: z.number(), - boostedPoints: z.number(), + totalPoints: z.number(), + totalBoostedPoints: z.number(), }); export const CampaignRankPageSchema = buildPageSchema(CampaignRankSchema); diff --git a/src/domain/community/entities/schemas/__tests__/campaign-rank.schema.spec.ts b/src/domain/community/entities/schemas/__tests__/campaign-rank.schema.spec.ts index 38ba8cdb11..340530da07 100644 --- a/src/domain/community/entities/schemas/__tests__/campaign-rank.schema.spec.ts +++ b/src/domain/community/entities/schemas/__tests__/campaign-rank.schema.spec.ts @@ -60,14 +60,14 @@ describe('CampaignRankSchema', () => { code: 'invalid_type', expected: 'number', received: 'undefined', - path: ['points'], + path: ['totalPoints'], message: 'Required', }, { code: 'invalid_type', expected: 'number', received: 'undefined', - path: ['boostedPoints'], + path: ['totalBoostedPoints'], message: 'Required', }, ]), diff --git a/src/routes/locking/entities/campaign-rank.entity.ts b/src/routes/locking/entities/campaign-rank.entity.ts index 7ff7750852..656282ad44 100644 --- a/src/routes/locking/entities/campaign-rank.entity.ts +++ b/src/routes/locking/entities/campaign-rank.entity.ts @@ -9,7 +9,7 @@ export class CampaignRank implements DomainCampaignRank { @ApiProperty() boost!: number; @ApiProperty() - points!: number; + totalPoints!: number; @ApiProperty() - boostedPoints!: number; + totalBoostedPoints!: number; } From 7531f18818bd8b2f3000929aa43a990074b7226a Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Fri, 24 May 2024 12:28:25 +0200 Subject: [PATCH 022/207] Checksum `safeAddress` of balance fetching and propagate type (#1567) Checksums the incoming `safeAddress` of `BalancesController['getBalances']` and propagates the stricter (`0x${string}`) type throughout the project accordingly: - Add validation pipe checksum to incoming `safeAddress` of `BalancesController['getBalances']` - Update `safeAddress` argument of `BalancesService['getBalances | clearBalance']` - Update `safeAddress` argument type of `IBalancesRepository['getBalances | clearBalance']` and all implementor - Update `safeAddress` argument type of `IBalancesApi['getBalances']` and all implementors (Safe/Zerion) - Update `safeAddress` type of Safe/Zerion balance cache - Propagate type through getting Safe overviews, after checksumming address in `Caip10AddressesPipe` - Update types/tests accordingly --- .../balances-api/balances-api.manager.spec.ts | 3 ++- .../balances-api/safe-balances-api.service.ts | 4 ++-- .../zerion-balances-api.service.ts | 4 ++-- src/datasources/cache/cache.router.ts | 8 ++++---- .../balances/balances.repository.interface.ts | 7 +++++-- src/domain/balances/balances.repository.ts | 4 ++-- .../interfaces/balances-api.interface.ts | 7 +++++-- .../zerion-balances.controller.spec.ts | 8 ++++---- .../balances/balances.controller.spec.ts | 18 +++++++++--------- src/routes/balances/balances.controller.ts | 5 ++++- src/routes/balances/balances.service.ts | 2 +- .../safes/pipes/caip-10-addresses.pipe.spec.ts | 9 +++++---- .../safes/pipes/caip-10-addresses.pipe.ts | 3 ++- src/routes/safes/safes.controller.ts | 2 +- src/routes/safes/safes.service.ts | 2 +- 15 files changed, 49 insertions(+), 37 deletions(-) diff --git a/src/datasources/balances-api/balances-api.manager.spec.ts b/src/datasources/balances-api/balances-api.manager.spec.ts index c1b0a7d51c..dc2c728311 100644 --- a/src/datasources/balances-api/balances-api.manager.spec.ts +++ b/src/datasources/balances-api/balances-api.manager.spec.ts @@ -8,6 +8,7 @@ import { IBalancesApi } from '@/domain/interfaces/balances-api.interface'; import { IConfigApi } from '@/domain/interfaces/config-api.interface'; import { IPricesApi } from '@/datasources/balances-api/prices-api.interface'; import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; const configurationService = { getOrThrow: jest.fn(), @@ -121,7 +122,7 @@ describe('Balances API Manager Tests', () => { const safeBalancesApi = await balancesApiManager.getBalancesApi( chain.chainId, ); - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const trusted = faker.datatype.boolean(); const excludeSpam = faker.datatype.boolean(); diff --git a/src/datasources/balances-api/safe-balances-api.service.ts b/src/datasources/balances-api/safe-balances-api.service.ts index a528335d97..f497c70afd 100644 --- a/src/datasources/balances-api/safe-balances-api.service.ts +++ b/src/datasources/balances-api/safe-balances-api.service.ts @@ -38,7 +38,7 @@ export class SafeBalancesApi implements IBalancesApi { } async getBalances(args: { - safeAddress: string; + safeAddress: `0x${string}`; fiatCode: string; chain: Chain; trusted?: boolean; @@ -69,7 +69,7 @@ export class SafeBalancesApi implements IBalancesApi { } } - async clearBalances(args: { safeAddress: string }): Promise { + async clearBalances(args: { safeAddress: `0x${string}` }): Promise { const key = CacheRouter.getBalancesCacheKey({ chainId: this.chainId, safeAddress: args.safeAddress, diff --git a/src/datasources/balances-api/zerion-balances-api.service.ts b/src/datasources/balances-api/zerion-balances-api.service.ts index 1afebc97db..3fe2a8d7a2 100644 --- a/src/datasources/balances-api/zerion-balances-api.service.ts +++ b/src/datasources/balances-api/zerion-balances-api.service.ts @@ -89,7 +89,7 @@ export class ZerionBalancesApi implements IBalancesApi { async getBalances(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; fiatCode: string; }): Promise { const cacheDir = CacheRouter.getZerionBalancesCacheDir(args); @@ -254,7 +254,7 @@ export class ZerionBalancesApi implements IBalancesApi { async clearBalances(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise { const key = CacheRouter.getZerionBalancesCacheKey(args); await this.cacheService.deleteByKey(key); diff --git a/src/datasources/cache/cache.router.ts b/src/datasources/cache/cache.router.ts index 9273ee81b8..0247f03415 100644 --- a/src/datasources/cache/cache.router.ts +++ b/src/datasources/cache/cache.router.ts @@ -44,14 +44,14 @@ export class CacheRouter { static getBalancesCacheKey(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): string { return `${args.chainId}_${CacheRouter.SAFE_BALANCES_KEY}_${args.safeAddress}`; } static getBalancesCacheDir(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; trusted?: boolean; excludeSpam?: boolean; }): CacheDir { @@ -63,14 +63,14 @@ export class CacheRouter { static getZerionBalancesCacheKey(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): string { return `${args.chainId}_${CacheRouter.ZERION_BALANCES_KEY}_${args.safeAddress}`; } static getZerionBalancesCacheDir(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; fiatCode: string; }): CacheDir { return new CacheDir( diff --git a/src/domain/balances/balances.repository.interface.ts b/src/domain/balances/balances.repository.interface.ts index 471e526c42..9dabf3d294 100644 --- a/src/domain/balances/balances.repository.interface.ts +++ b/src/domain/balances/balances.repository.interface.ts @@ -13,7 +13,7 @@ export interface IBalancesRepository { */ getBalances(args: { chain: Chain; - safeAddress: string; + safeAddress: `0x${string}`; fiatCode: string; trusted?: boolean; excludeSpam?: boolean; @@ -22,7 +22,10 @@ export interface IBalancesRepository { /** * Clears any stored local balance data of {@link safeAddress} on {@link chainId} */ - clearBalances(args: { chainId: string; safeAddress: string }): Promise; + clearBalances(args: { + chainId: string; + safeAddress: `0x${string}`; + }): Promise; /** * Gets the list of supported fiat codes. diff --git a/src/domain/balances/balances.repository.ts b/src/domain/balances/balances.repository.ts index 4d2cd1e017..ba9e28f81d 100644 --- a/src/domain/balances/balances.repository.ts +++ b/src/domain/balances/balances.repository.ts @@ -14,7 +14,7 @@ export class BalancesRepository implements IBalancesRepository { async getBalances(args: { chain: Chain; - safeAddress: string; + safeAddress: `0x${string}`; fiatCode: string; trusted?: boolean; excludeSpam?: boolean; @@ -28,7 +28,7 @@ export class BalancesRepository implements IBalancesRepository { async clearBalances(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise { const api = await this.balancesApiManager.getBalancesApi(args.chainId); await api.clearBalances(args); diff --git a/src/domain/interfaces/balances-api.interface.ts b/src/domain/interfaces/balances-api.interface.ts index 529a8cc572..ea7fc1dbd6 100644 --- a/src/domain/interfaces/balances-api.interface.ts +++ b/src/domain/interfaces/balances-api.interface.ts @@ -5,14 +5,17 @@ import { Page } from '@/domain/entities/page.entity'; export interface IBalancesApi { getBalances(args: { - safeAddress: string; + safeAddress: `0x${string}`; fiatCode: string; chain?: Chain; trusted?: boolean; excludeSpam?: boolean; }): Promise; - clearBalances(args: { chainId: string; safeAddress: string }): Promise; + clearBalances(args: { + chainId: string; + safeAddress: `0x${string}`; + }): Promise; getCollectibles(args: { safeAddress: string; diff --git a/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts b/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts index 995e3cd212..07a15007c9 100644 --- a/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts +++ b/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts @@ -98,7 +98,7 @@ describe('Balances Controller (Unit)', () => { describe('GET /balances (externalized)', () => { it(`maps native coin + ERC20 token balance correctly, and sorts balances by fiatBalance`, async () => { const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const currency = faker.finance.currencyCode(); const chainName = app .get(IConfigurationService) @@ -246,7 +246,7 @@ describe('Balances Controller (Unit)', () => { it('returns large numbers as is (not in scientific notation)', async () => { const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const currency = faker.finance.currencyCode(); const chainName = app .get(IConfigurationService) @@ -464,7 +464,7 @@ describe('Balances Controller (Unit)', () => { describe('Rate Limit error', () => { it('does not trigger a rate-limit error', async () => { const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const currency = faker.finance.currencyCode(); const chainName = app .get(IConfigurationService) @@ -535,7 +535,7 @@ describe('Balances Controller (Unit)', () => { it('triggers a rate-limit error', async () => { const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainName = app .get(IConfigurationService) .getOrThrow( diff --git a/src/routes/balances/balances.controller.spec.ts b/src/routes/balances/balances.controller.spec.ts index c9a950fa75..b35451406c 100644 --- a/src/routes/balances/balances.controller.spec.ts +++ b/src/routes/balances/balances.controller.spec.ts @@ -69,7 +69,7 @@ describe('Balances Controller (Unit)', () => { describe('GET /balances', () => { it(`maps native coin + ERC20 token balance correctly, and sorts balances by fiatBalance`, async () => { const chain = chainBuilder().with('chainId', '10').build(); - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const tokenAddress = faker.finance.ethereumAddress(); const secondTokenAddress = faker.finance.ethereumAddress(); const transactionApiBalancesResponse = [ @@ -225,7 +225,7 @@ describe('Balances Controller (Unit)', () => { it(`excludeSpam and trusted params are forwarded to tx service`, async () => { const chain = chainBuilder().with('chainId', '10').build(); - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const tokenAddress = faker.finance.ethereumAddress(); const transactionApiBalancesResponse = [ balanceBuilder() @@ -277,7 +277,7 @@ describe('Balances Controller (Unit)', () => { it(`maps native token correctly`, async () => { const chain = chainBuilder().with('chainId', '10').build(); - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const transactionApiBalancesResponse = [ balanceBuilder() .with('tokenAddress', null) @@ -340,7 +340,7 @@ describe('Balances Controller (Unit)', () => { it('returns large numbers as is (not in scientific notation)', async () => { const chain = chainBuilder().with('chainId', '10').build(); - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const tokenAddress = faker.finance.ethereumAddress(); const transactionApiBalancesResponse = [ balanceBuilder() @@ -420,7 +420,7 @@ describe('Balances Controller (Unit)', () => { describe('Config API Error', () => { it(`500 error response`, async () => { const chainId = '1'; - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const error = new NetworkResponseError( new URL( `${safeConfigUrl}/v1/chains/${chainId}/safes/${safeAddress}/balances/usd`, @@ -446,7 +446,7 @@ describe('Balances Controller (Unit)', () => { describe('Prices provider API Error', () => { it(`should return a 0-balance when an error is thrown by the provider`, async () => { const chain = chainBuilder().with('chainId', '10').build(); - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const tokenAddress = faker.finance.ethereumAddress(); const transactionApiBalancesResponse = [ balanceBuilder() @@ -505,7 +505,7 @@ describe('Balances Controller (Unit)', () => { it(`should return a 0-balance when a validation error happens`, async () => { const chain = chainBuilder().with('chainId', '10').build(); - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const tokenAddress = getAddress(faker.finance.ethereumAddress()); const transactionApiBalancesResponse = [ balanceBuilder() @@ -570,7 +570,7 @@ describe('Balances Controller (Unit)', () => { describe('Transaction API Error', () => { it(`500 error response`, async () => { const chainId = '1'; - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainResponse = chainBuilder().with('chainId', chainId).build(); const transactionServiceUrl = `${chainResponse.transactionService}/api/v1/safes/${safeAddress}/balances/`; networkService.get.mockImplementation(({ url }) => { @@ -603,7 +603,7 @@ describe('Balances Controller (Unit)', () => { it(`500 error if validation fails`, async () => { const chainId = '1'; - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainResponse = chainBuilder().with('chainId', chainId).build(); networkService.get.mockImplementation(({ url }) => { if (url == `${safeConfigUrl}/api/v1/chains/${chainId}`) { diff --git a/src/routes/balances/balances.controller.ts b/src/routes/balances/balances.controller.ts index 40bd8b4a41..b227fcbc1a 100644 --- a/src/routes/balances/balances.controller.ts +++ b/src/routes/balances/balances.controller.ts @@ -9,6 +9,8 @@ import { import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { BalancesService } from '@/routes/balances/balances.service'; import { Balances } from '@/routes/balances/entities/balances.entity'; +import { ValidationPipe } from '@/validation/pipes/validation.pipe'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; @ApiTags('balances') @Controller({ @@ -22,7 +24,8 @@ export class BalancesController { @Get('chains/:chainId/safes/:safeAddress/balances/:fiatCode') async getBalances( @Param('chainId') chainId: string, - @Param('safeAddress') safeAddress: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, @Param('fiatCode') fiatCode: string, @Query('trusted', new DefaultValuePipe(false), ParseBoolPipe) trusted: boolean, diff --git a/src/routes/balances/balances.service.ts b/src/routes/balances/balances.service.ts index d019697ac7..e96300bda5 100644 --- a/src/routes/balances/balances.service.ts +++ b/src/routes/balances/balances.service.ts @@ -21,7 +21,7 @@ export class BalancesService { async getBalances(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; fiatCode: string; trusted: boolean; excludeSpam: boolean; diff --git a/src/routes/safes/pipes/caip-10-addresses.pipe.spec.ts b/src/routes/safes/pipes/caip-10-addresses.pipe.spec.ts index c7cdead2f2..a8340532e9 100644 --- a/src/routes/safes/pipes/caip-10-addresses.pipe.spec.ts +++ b/src/routes/safes/pipes/caip-10-addresses.pipe.spec.ts @@ -7,6 +7,7 @@ import { faker } from '@faker-js/faker'; import { Controller, Get, INestApplication, Query } from '@nestjs/common'; import { TestingModule, Test } from '@nestjs/testing'; import * as request from 'supertest'; +import { getAddress } from 'viem'; @Controller() class TestController { @@ -35,7 +36,7 @@ describe('Caip10AddressesPipe', () => { await app.close(); }); - it('returns parsed CAIP-10 addresses', async () => { + it('returns parsed, checksummed CAIP-10 addresses', async () => { const chainId1 = faker.string.numeric(); const chainId2 = faker.string.numeric(); const chainId3 = faker.string.numeric(); @@ -49,9 +50,9 @@ describe('Caip10AddressesPipe', () => { ) .expect(200) .expect([ - { chainId: chainId1, address: address1 }, - { chainId: chainId2, address: address2 }, - { chainId: chainId3, address: address3 }, + { chainId: chainId1, address: getAddress(address1) }, + { chainId: chainId2, address: getAddress(address2) }, + { chainId: chainId3, address: getAddress(address3) }, ]); }); diff --git a/src/routes/safes/pipes/caip-10-addresses.pipe.ts b/src/routes/safes/pipes/caip-10-addresses.pipe.ts index 51febfcca1..a8f465f540 100644 --- a/src/routes/safes/pipes/caip-10-addresses.pipe.ts +++ b/src/routes/safes/pipes/caip-10-addresses.pipe.ts @@ -1,4 +1,5 @@ import { PipeTransform, Injectable } from '@nestjs/common'; +import { getAddress } from 'viem'; @Injectable() export class Caip10AddressesPipe @@ -11,7 +12,7 @@ export class Caip10AddressesPipe const addresses = data.split(',').map((caip10Address: string) => { const [chainId, address] = caip10Address.split(':'); - return { chainId, address }; + return { chainId, address: getAddress(address) }; }); if (addresses.length === 0 || !addresses[0].address) { diff --git a/src/routes/safes/safes.controller.ts b/src/routes/safes/safes.controller.ts index fd47bad51d..88e331c1dc 100644 --- a/src/routes/safes/safes.controller.ts +++ b/src/routes/safes/safes.controller.ts @@ -45,7 +45,7 @@ export class SafesController { async getSafeOverview( @Query('currency') currency: string, @Query('safes', new Caip10AddressesPipe()) - addresses: Array<{ chainId: string; address: string }>, + addresses: Array<{ chainId: string; address: `0x${string}` }>, @Query('trusted', new DefaultValuePipe(false), ParseBoolPipe) trusted: boolean, @Query('exclude_spam', new DefaultValuePipe(true), ParseBoolPipe) diff --git a/src/routes/safes/safes.service.ts b/src/routes/safes/safes.service.ts index c4f4930e25..cd4c23d200 100644 --- a/src/routes/safes/safes.service.ts +++ b/src/routes/safes/safes.service.ts @@ -129,7 +129,7 @@ export class SafesService { async getSafeOverview(args: { currency: string; - addresses: Array<{ chainId: string; address: string }>; + addresses: Array<{ chainId: string; address: `0x${string}` }>; trusted: boolean; excludeSpam: boolean; walletAddress?: `0x${string}`; From 4dbdcc5a3a4ab43049d0b238459df7300393c8cd Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Fri, 24 May 2024 12:28:48 +0200 Subject: [PATCH 023/207] Checksum `safeAddress` of collectibles fetching and propagate type (#1568) --- .../balances-api/safe-balances-api.service.ts | 4 ++-- .../balances-api/zerion-balances-api.service.ts | 4 ++-- src/datasources/cache/cache.router.ts | 8 ++++---- .../collectibles/collectibles.repository.interface.ts | 4 ++-- src/domain/collectibles/collectibles.repository.ts | 4 ++-- src/domain/interfaces/balances-api.interface.ts | 4 ++-- .../zerion-collectibles.controller.spec.ts | 10 +++++----- .../collectibles/collectibles.controller.spec.ts | 11 ++++++----- src/routes/collectibles/collectibles.controller.ts | 5 ++++- src/routes/collectibles/collectibles.service.ts | 2 +- 10 files changed, 30 insertions(+), 26 deletions(-) diff --git a/src/datasources/balances-api/safe-balances-api.service.ts b/src/datasources/balances-api/safe-balances-api.service.ts index f497c70afd..a6c0474b21 100644 --- a/src/datasources/balances-api/safe-balances-api.service.ts +++ b/src/datasources/balances-api/safe-balances-api.service.ts @@ -78,7 +78,7 @@ export class SafeBalancesApi implements IBalancesApi { } async getCollectibles(args: { - safeAddress: string; + safeAddress: `0x${string}`; limit?: number; offset?: number; trusted?: boolean; @@ -109,7 +109,7 @@ export class SafeBalancesApi implements IBalancesApi { } } - async clearCollectibles(args: { safeAddress: string }): Promise { + async clearCollectibles(args: { safeAddress: `0x${string}` }): Promise { const key = CacheRouter.getCollectiblesKey({ chainId: this.chainId, safeAddress: args.safeAddress, diff --git a/src/datasources/balances-api/zerion-balances-api.service.ts b/src/datasources/balances-api/zerion-balances-api.service.ts index 3fe2a8d7a2..c6b271a8c3 100644 --- a/src/datasources/balances-api/zerion-balances-api.service.ts +++ b/src/datasources/balances-api/zerion-balances-api.service.ts @@ -143,7 +143,7 @@ export class ZerionBalancesApi implements IBalancesApi { */ async getCollectibles(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; limit?: number; offset?: number; }): Promise> { @@ -187,7 +187,7 @@ export class ZerionBalancesApi implements IBalancesApi { async clearCollectibles(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise { const key = CacheRouter.getZerionCollectiblesCacheKey(args); await this.cacheService.deleteByKey(key); diff --git a/src/datasources/cache/cache.router.ts b/src/datasources/cache/cache.router.ts index 0247f03415..dc83a63e4c 100644 --- a/src/datasources/cache/cache.router.ts +++ b/src/datasources/cache/cache.router.ts @@ -81,14 +81,14 @@ export class CacheRouter { static getZerionCollectiblesCacheKey(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): string { return `${args.chainId}_${CacheRouter.ZERION_COLLECTIBLES_KEY}_${args.safeAddress}`; } static getZerionCollectiblesCacheDir(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; limit?: number; offset?: number; }): CacheDir { @@ -136,7 +136,7 @@ export class CacheRouter { static getCollectiblesCacheDir(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; limit?: number; offset?: number; trusted?: boolean; @@ -150,7 +150,7 @@ export class CacheRouter { static getCollectiblesKey(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): string { return `${args.chainId}_${CacheRouter.SAFE_COLLECTIBLES_KEY}_${args.safeAddress}`; } diff --git a/src/domain/collectibles/collectibles.repository.interface.ts b/src/domain/collectibles/collectibles.repository.interface.ts index 2209a9c71b..f197fa91b1 100644 --- a/src/domain/collectibles/collectibles.repository.interface.ts +++ b/src/domain/collectibles/collectibles.repository.interface.ts @@ -9,7 +9,7 @@ export const ICollectiblesRepository = Symbol('ICollectiblesRepository'); export interface ICollectiblesRepository { getCollectibles(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; limit?: number; offset?: number; trusted?: boolean; @@ -18,7 +18,7 @@ export interface ICollectiblesRepository { clearCollectibles(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise; } diff --git a/src/domain/collectibles/collectibles.repository.ts b/src/domain/collectibles/collectibles.repository.ts index 3b501725ff..3c525a0562 100644 --- a/src/domain/collectibles/collectibles.repository.ts +++ b/src/domain/collectibles/collectibles.repository.ts @@ -14,7 +14,7 @@ export class CollectiblesRepository implements ICollectiblesRepository { async getCollectibles(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; limit?: number; offset?: number; trusted?: boolean; @@ -27,7 +27,7 @@ export class CollectiblesRepository implements ICollectiblesRepository { async clearCollectibles(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise { const api = await this.balancesApiManager.getBalancesApi(args.chainId); await api.clearCollectibles(args); diff --git a/src/domain/interfaces/balances-api.interface.ts b/src/domain/interfaces/balances-api.interface.ts index ea7fc1dbd6..d5d1fffe90 100644 --- a/src/domain/interfaces/balances-api.interface.ts +++ b/src/domain/interfaces/balances-api.interface.ts @@ -18,7 +18,7 @@ export interface IBalancesApi { }): Promise; getCollectibles(args: { - safeAddress: string; + safeAddress: `0x${string}`; chainId?: string; limit?: number; offset?: number; @@ -28,7 +28,7 @@ export interface IBalancesApi { clearCollectibles(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise; getFiatCodes(): Promise; diff --git a/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts b/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts index f440c15a57..cd23536155 100644 --- a/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts +++ b/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts @@ -74,7 +74,7 @@ describe('Zerion Collectibles Controller', () => { describe('GET /v2/collectibles', () => { it('successfully gets collectibles from Zerion', async () => { const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const aTokenAddress = faker.finance.ethereumAddress(); const aNFTName = faker.string.sample(); const aUrl = faker.internet.url({ appendSlash: false }); @@ -254,7 +254,7 @@ describe('Zerion Collectibles Controller', () => { }); it('successfully maps pagination option (no limit)', async () => { const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const inputPaginationCursor = `cursor=${encodeURIComponent(`&offset=10`)}`; const zerionNext = `${faker.internet.url({ appendSlash: false })}?page%5Bsize%5D=20&page%5Bafter%5D=IjMwIg==`; const expectedNext = `${encodeURIComponent(`limit=20&offset=30`)}`; @@ -318,7 +318,7 @@ describe('Zerion Collectibles Controller', () => { it('successfully maps pagination option (no offset)', async () => { const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const paginationLimit = 4; const inputPaginationCursor = `cursor=${encodeURIComponent(`limit=${paginationLimit}`)}`; const zerionNext = `${faker.internet.url({ appendSlash: false })}?page%5Bsize%5D=4&page%5Bafter%5D=IjQi`; @@ -382,7 +382,7 @@ describe('Zerion Collectibles Controller', () => { it('successfully maps pagination option (both limit and offset)', async () => { const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const paginationLimit = 4; const inputPaginationCursor = `cursor=${encodeURIComponent(`limit=${paginationLimit}&offset=20`)}`; const zerionNext = `${faker.internet.url({ appendSlash: false })}?page%5Bsize%5D=4&page%5Bafter%5D=IjMwIg==`; @@ -449,7 +449,7 @@ describe('Zerion Collectibles Controller', () => { describe('Zerion Balances API Error', () => { it(`500 error response`, async () => { const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); networkService.get.mockImplementation(({ url }) => { switch (url) { case `${zerionBaseUri}/v1/wallets/${safeAddress}/nft-positions`: diff --git a/src/routes/collectibles/collectibles.controller.spec.ts b/src/routes/collectibles/collectibles.controller.spec.ts index 998006e6b5..6df799914d 100644 --- a/src/routes/collectibles/collectibles.controller.spec.ts +++ b/src/routes/collectibles/collectibles.controller.spec.ts @@ -32,6 +32,7 @@ import { AccountDataSourceModule } from '@/datasources/account/account.datasourc import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { getAddress } from 'viem'; describe('Collectibles Controller (Unit)', () => { let app: INestApplication; @@ -71,7 +72,7 @@ describe('Collectibles Controller (Unit)', () => { describe('GET /v2/collectibles', () => { it('is successful', async () => { const chainId = faker.string.numeric(); - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainResponse = chainBuilder().with('chainId', chainId).build(); const pageLimit = 1; const collectiblesResponse = pageBuilder() @@ -112,7 +113,7 @@ describe('Collectibles Controller (Unit)', () => { it('pagination data is forwarded to tx service', async () => { const chainId = faker.string.numeric(); - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainResponse = chainBuilder().with('chainId', chainId).build(); const limit = 10; const offset = 20; @@ -156,7 +157,7 @@ describe('Collectibles Controller (Unit)', () => { it('excludeSpam and trusted params are forwarded to tx service', async () => { const chainId = faker.string.numeric(); - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainResponse = chainBuilder().with('chainId', chainId).build(); const excludeSpam = true; const trusted = true; @@ -200,7 +201,7 @@ describe('Collectibles Controller (Unit)', () => { it('tx service collectibles returns 400', async () => { const chainId = faker.string.numeric(); - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainResponse = chainBuilder().with('chainId', chainId).build(); const transactionServiceUrl = `${chainResponse.transactionService}/api/v2/safes/${safeAddress}/collectibles/`; const transactionServiceError = new NetworkResponseError( @@ -233,7 +234,7 @@ describe('Collectibles Controller (Unit)', () => { it('tx service collectibles does not return a response', async () => { const chainId = faker.string.numeric(); - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainResponse = chainBuilder().with('chainId', chainId).build(); const transactionServiceUrl = `${chainResponse.transactionService}/api/v2/safes/${safeAddress}/collectibles/`; const transactionServiceError = new NetworkRequestError( diff --git a/src/routes/collectibles/collectibles.controller.ts b/src/routes/collectibles/collectibles.controller.ts index 265c49d902..4acf1486f4 100644 --- a/src/routes/collectibles/collectibles.controller.ts +++ b/src/routes/collectibles/collectibles.controller.ts @@ -14,6 +14,8 @@ import { PaginationDataDecorator } from '@/routes/common/decorators/pagination.d import { RouteUrlDecorator } from '@/routes/common/decorators/route.url.decorator'; import { Page } from '@/routes/common/entities/page.entity'; import { PaginationData } from '@/routes/common/pagination/pagination.data'; +import { ValidationPipe } from '@/validation/pipes/validation.pipe'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; @ApiTags('collectibles') @Controller({ @@ -41,7 +43,8 @@ export class CollectiblesController { @Get('chains/:chainId/safes/:safeAddress/collectibles') async getCollectibles( @Param('chainId') chainId: string, - @Param('safeAddress') safeAddress: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, @RouteUrlDecorator() routeUrl: URL, @PaginationDataDecorator() paginationData: PaginationData, @Query('trusted', new DefaultValuePipe(false), ParseBoolPipe) diff --git a/src/routes/collectibles/collectibles.service.ts b/src/routes/collectibles/collectibles.service.ts index 1bd7cbcd93..b9740e047a 100644 --- a/src/routes/collectibles/collectibles.service.ts +++ b/src/routes/collectibles/collectibles.service.ts @@ -16,7 +16,7 @@ export class CollectiblesService { async getCollectibles(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; routeUrl: Readonly; paginationData: PaginationData; trusted: boolean; From 75eed3fea590a56a50cb98163dc423d4c9b1f3d5 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 27 May 2024 09:43:08 +0200 Subject: [PATCH 024/207] Move community entities from locking routes (#1581) Moves locking entities from `/routes/locking` to `/routes/community` folder for consistency: - Move `/routes/locking/entities` to `/routes/community/entities` - Update imports accordingly --- src/routes/community/community.controller.ts | 14 +++++++------- .../entities/activity-metadata.entity.ts | 0 .../entities/campaign-rank.entity.ts | 0 .../entities/campaign-rank.page.entity.ts | 2 +- .../entities/campaign.entity.ts | 2 +- .../entities/campaign.page.entity.ts | 2 +- .../entities/locking-event.page.entity.ts | 0 .../entities/locking-rank.entity.ts | 0 .../entities/locking-rank.page.entity.ts | 2 +- src/routes/locking/locking.controller.ts | 6 +++--- 10 files changed, 14 insertions(+), 14 deletions(-) rename src/routes/{locking => community}/entities/activity-metadata.entity.ts (100%) rename src/routes/{locking => community}/entities/campaign-rank.entity.ts (100%) rename src/routes/{locking => community}/entities/campaign-rank.page.entity.ts (74%) rename src/routes/{locking => community}/entities/campaign.entity.ts (87%) rename src/routes/{locking => community}/entities/campaign.page.entity.ts (76%) rename src/routes/{locking => community}/entities/locking-event.page.entity.ts (100%) rename src/routes/{locking => community}/entities/locking-rank.entity.ts (100%) rename src/routes/{locking => community}/entities/locking-rank.page.entity.ts (75%) diff --git a/src/routes/community/community.controller.ts b/src/routes/community/community.controller.ts index 08b47b0b3f..7047c5fa2c 100644 --- a/src/routes/community/community.controller.ts +++ b/src/routes/community/community.controller.ts @@ -2,13 +2,13 @@ import { PaginationDataDecorator } from '@/routes/common/decorators/pagination.d import { RouteUrlDecorator } from '@/routes/common/decorators/route.url.decorator'; import { PaginationData } from '@/routes/common/pagination/pagination.data'; import { CommunityService } from '@/routes/community/community.service'; -import { CampaignRank } from '@/routes/locking/entities/campaign-rank.entity'; -import { CampaignRankPage } from '@/routes/locking/entities/campaign-rank.page.entity'; -import { Campaign } from '@/routes/locking/entities/campaign.entity'; -import { CampaignPage } from '@/routes/locking/entities/campaign.page.entity'; -import { LockingEventPage } from '@/routes/locking/entities/locking-event.page.entity'; -import { LockingRank } from '@/routes/locking/entities/locking-rank.entity'; -import { LockingRankPage } from '@/routes/locking/entities/locking-rank.page.entity'; +import { CampaignRank } from '@/routes/community/entities/campaign-rank.entity'; +import { CampaignRankPage } from '@/routes/community/entities/campaign-rank.page.entity'; +import { Campaign } from '@/routes/community/entities/campaign.entity'; +import { CampaignPage } from '@/routes/community/entities/campaign.page.entity'; +import { LockingEventPage } from '@/routes/community/entities/locking-event.page.entity'; +import { LockingRank } from '@/routes/community/entities/locking-rank.entity'; +import { LockingRankPage } from '@/routes/community/entities/locking-rank.page.entity'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; import { ValidationPipe } from '@/validation/pipes/validation.pipe'; import { Controller, Get, Param } from '@nestjs/common'; diff --git a/src/routes/locking/entities/activity-metadata.entity.ts b/src/routes/community/entities/activity-metadata.entity.ts similarity index 100% rename from src/routes/locking/entities/activity-metadata.entity.ts rename to src/routes/community/entities/activity-metadata.entity.ts diff --git a/src/routes/locking/entities/campaign-rank.entity.ts b/src/routes/community/entities/campaign-rank.entity.ts similarity index 100% rename from src/routes/locking/entities/campaign-rank.entity.ts rename to src/routes/community/entities/campaign-rank.entity.ts diff --git a/src/routes/locking/entities/campaign-rank.page.entity.ts b/src/routes/community/entities/campaign-rank.page.entity.ts similarity index 74% rename from src/routes/locking/entities/campaign-rank.page.entity.ts rename to src/routes/community/entities/campaign-rank.page.entity.ts index e275b037be..b0131a0850 100644 --- a/src/routes/locking/entities/campaign-rank.page.entity.ts +++ b/src/routes/community/entities/campaign-rank.page.entity.ts @@ -1,5 +1,5 @@ import { Page } from '@/routes/common/entities/page.entity'; -import { CampaignRank } from '@/routes/locking/entities/campaign-rank.entity'; +import { CampaignRank } from '@/routes/community/entities/campaign-rank.entity'; import { ApiProperty } from '@nestjs/swagger'; export class CampaignRankPage extends Page { diff --git a/src/routes/locking/entities/campaign.entity.ts b/src/routes/community/entities/campaign.entity.ts similarity index 87% rename from src/routes/locking/entities/campaign.entity.ts rename to src/routes/community/entities/campaign.entity.ts index 58582ee0cc..3f43a99559 100644 --- a/src/routes/locking/entities/campaign.entity.ts +++ b/src/routes/community/entities/campaign.entity.ts @@ -1,5 +1,5 @@ import { Campaign as DomainCampaign } from '@/domain/community/entities/campaign.entity'; -import { ActivityMetadata } from '@/routes/locking/entities/activity-metadata.entity'; +import { ActivityMetadata } from '@/routes/community/entities/activity-metadata.entity'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class Campaign implements DomainCampaign { diff --git a/src/routes/locking/entities/campaign.page.entity.ts b/src/routes/community/entities/campaign.page.entity.ts similarity index 76% rename from src/routes/locking/entities/campaign.page.entity.ts rename to src/routes/community/entities/campaign.page.entity.ts index efa8cf9b50..6be950add9 100644 --- a/src/routes/locking/entities/campaign.page.entity.ts +++ b/src/routes/community/entities/campaign.page.entity.ts @@ -1,5 +1,5 @@ import { Page } from '@/routes/common/entities/page.entity'; -import { Campaign } from '@/routes/locking/entities/campaign.entity'; +import { Campaign } from '@/routes/community/entities/campaign.entity'; import { ApiProperty } from '@nestjs/swagger'; export class CampaignPage extends Page { diff --git a/src/routes/locking/entities/locking-event.page.entity.ts b/src/routes/community/entities/locking-event.page.entity.ts similarity index 100% rename from src/routes/locking/entities/locking-event.page.entity.ts rename to src/routes/community/entities/locking-event.page.entity.ts diff --git a/src/routes/locking/entities/locking-rank.entity.ts b/src/routes/community/entities/locking-rank.entity.ts similarity index 100% rename from src/routes/locking/entities/locking-rank.entity.ts rename to src/routes/community/entities/locking-rank.entity.ts diff --git a/src/routes/locking/entities/locking-rank.page.entity.ts b/src/routes/community/entities/locking-rank.page.entity.ts similarity index 75% rename from src/routes/locking/entities/locking-rank.page.entity.ts rename to src/routes/community/entities/locking-rank.page.entity.ts index a8b5646f2e..c9c709d620 100644 --- a/src/routes/locking/entities/locking-rank.page.entity.ts +++ b/src/routes/community/entities/locking-rank.page.entity.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Page } from '@/routes/common/entities/page.entity'; -import { LockingRank } from '@/routes/locking/entities/locking-rank.entity'; +import { LockingRank } from '@/routes/community/entities/locking-rank.entity'; export class LockingRankPage extends Page { @ApiProperty({ type: LockingRank }) diff --git a/src/routes/locking/locking.controller.ts b/src/routes/locking/locking.controller.ts index 5475311685..cc19ae45df 100644 --- a/src/routes/locking/locking.controller.ts +++ b/src/routes/locking/locking.controller.ts @@ -1,6 +1,6 @@ -import { LockingEventPage } from '@/routes/locking/entities/locking-event.page.entity'; -import { LockingRank } from '@/routes/locking/entities/locking-rank.entity'; -import { LockingRankPage } from '@/routes/locking/entities/locking-rank.page.entity'; +import { LockingEventPage } from '@/routes/community/entities/locking-event.page.entity'; +import { LockingRank } from '@/routes/community/entities/locking-rank.entity'; +import { LockingRankPage } from '@/routes/community/entities/locking-rank.page.entity'; import { Controller, Get, From 43b11470e09ae26320ab2b8d644afadf3202a5b0 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 27 May 2024 09:52:23 +0200 Subject: [PATCH 025/207] Remove legacy locking routes (#1582) Removes "legacy" locking routes: - Remove `LockingController` (and test coverage) - Remove `LockingModule` (and registration) --- src/app.module.ts | 2 - src/routes/locking/locking.controller.spec.ts | 115 ------------------ src/routes/locking/locking.controller.ts | 70 ----------- src/routes/locking/locking.module.ts | 7 -- 4 files changed, 194 deletions(-) delete mode 100644 src/routes/locking/locking.controller.spec.ts delete mode 100644 src/routes/locking/locking.controller.ts delete mode 100644 src/routes/locking/locking.module.ts diff --git a/src/app.module.ts b/src/app.module.ts index 5c9214d7ce..4d70724eb3 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -41,7 +41,6 @@ import { AlertsControllerModule } from '@/routes/alerts/alerts.controller.module import { RecoveryModule } from '@/routes/recovery/recovery.module'; import { RelayControllerModule } from '@/routes/relay/relay.controller.module'; import { SubscriptionControllerModule } from '@/routes/subscriptions/subscription.module'; -import { LockingModule } from '@/routes/locking/locking.module'; import { ZodErrorFilter } from '@/routes/common/filters/zod-error.filter'; import { CacheControlInterceptor } from '@/routes/common/interceptors/cache-control.interceptor'; import { AuthModule } from '@/routes/auth/auth.module'; @@ -87,7 +86,6 @@ export class AppModule implements NestModule { : []), EstimationsModule, HealthModule, - LockingModule, MessagesModule, NotificationsModule, OwnersModule, diff --git a/src/routes/locking/locking.controller.spec.ts b/src/routes/locking/locking.controller.spec.ts deleted file mode 100644 index e87d94e68e..0000000000 --- a/src/routes/locking/locking.controller.spec.ts +++ /dev/null @@ -1,115 +0,0 @@ -import * as request from 'supertest'; -import { faker } from '@faker-js/faker'; -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { TestAppProvider } from '@/__tests__/test-app.provider'; -import { AppModule } from '@/app.module'; -import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; -import { CacheModule } from '@/datasources/cache/cache.module'; -import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; -import { NetworkModule } from '@/datasources/network/network.module'; -import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; -import { RequestScopedLoggingModule } from '@/logging/logging.module'; -import configuration from '@/config/entities/__tests__/configuration'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; -import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; - -describe('Locking (Unit)', () => { - let app: INestApplication; - - beforeEach(async () => { - jest.resetAllMocks(); - - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule.register(configuration)], - }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) - .overrideModule(CacheModule) - .useModule(TestCacheModule) - .overrideModule(RequestScopedLoggingModule) - .useModule(TestLoggingModule) - .overrideModule(NetworkModule) - .useModule(TestNetworkModule) - .overrideModule(QueuesApiModule) - .useModule(TestQueuesApiModule) - .compile(); - - app = await new TestAppProvider().provide(moduleFixture); - await app.init(); - }); - - afterAll(async () => { - await app.close(); - }); - - describe('GET /locking/leaderboard/rank/:safeAddress', () => { - it('should return 302 and redirect to the new endpoint', async () => { - const safeAddress = faker.finance.ethereumAddress(); - - await request(app.getHttpServer()) - .get(`/v1/locking/leaderboard/rank/${safeAddress}`) - .expect(308) - .expect((res) => { - expect(res.get('location')).toBe( - `/v1/community/locking/${safeAddress}/rank`, - ); - }); - }); - }); - - describe('GET /locking/leaderboard', () => { - it('should return 302 and redirect to the new endpoint', async () => { - await request(app.getHttpServer()) - .get('/v1/locking/leaderboard') - .expect(308) - .expect((res) => { - expect(res.get('location')).toBe('/v1/community/locking/leaderboard'); - }); - }); - - it('should return 302 and redirect to the new endpoint with cursor', async () => { - const cursor = 'limit%3Daa%26offset%3D2'; - - await request(app.getHttpServer()) - .get(`/v1/locking/leaderboard/?cursor=${cursor}`) - .expect(308) - .expect((res) => { - expect(res.get('location')).toBe( - `/v1/community/locking/leaderboard/?cursor=${cursor}`, - ); - }); - }); - }); - - describe('GET /locking/:safeAddress/history', () => { - it('should return 302 and redirect to the new endpoint', async () => { - const safeAddress = faker.finance.ethereumAddress(); - - await request(app.getHttpServer()) - .get(`/v1/locking/${safeAddress}/history`) - .expect(308) - .expect((res) => { - expect(res.get('location')).toBe( - `/v1/community/locking/${safeAddress}/history`, - ); - }); - }); - - it('should return 302 and redirect to the new endpoint with cursor', async () => { - const safeAddress = faker.finance.ethereumAddress(); - const cursor = 'limit%3Daa%26offset%3D2'; - - await request(app.getHttpServer()) - .get(`/v1/locking/${safeAddress}/history/?cursor=${cursor}`) - .expect(308) - .expect((res) => { - expect(res.get('location')).toBe( - `/v1/community/locking/${safeAddress}/history/?cursor=${cursor}`, - ); - }); - }); - }); -}); diff --git a/src/routes/locking/locking.controller.ts b/src/routes/locking/locking.controller.ts deleted file mode 100644 index cc19ae45df..0000000000 --- a/src/routes/locking/locking.controller.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { LockingEventPage } from '@/routes/community/entities/locking-event.page.entity'; -import { LockingRank } from '@/routes/community/entities/locking-rank.entity'; -import { LockingRankPage } from '@/routes/community/entities/locking-rank.page.entity'; -import { - Controller, - Get, - HttpStatus, - Param, - Redirect, - Req, -} from '@nestjs/common'; -import { - ApiOkResponse, - ApiOperation, - ApiQuery, - ApiTags, -} from '@nestjs/swagger'; -import { Request } from 'express'; - -@ApiTags('locking') -@Controller({ - path: 'locking', - version: '1', -}) -export class LockingController { - @ApiOperation({ deprecated: true }) - @ApiOkResponse({ type: LockingRank }) - @Redirect(undefined, HttpStatus.PERMANENT_REDIRECT) - @Get('/leaderboard/rank/:safeAddress') - getRank( - @Param('safeAddress') - safeAddress: `0x${string}`, - ): { url: string } { - return { url: `/v1/community/locking/${safeAddress}/rank` }; - } - - @ApiOperation({ deprecated: true }) - @ApiOkResponse({ type: LockingRankPage }) - @ApiQuery({ - name: 'cursor', - required: false, - type: String, - }) - @Redirect(undefined, HttpStatus.PERMANENT_REDIRECT) - @Get('/leaderboard') - getLeaderboard(@Req() request: Request): { url: string } { - const newUrl = '/v1/community/locking/leaderboard'; - const search = request.url.split('?')[1]; - return { - url: search ? `${newUrl}/?${search}` : newUrl, - }; - } - - @ApiOperation({ deprecated: true }) - @ApiOkResponse({ type: LockingEventPage }) - @ApiQuery({ - name: 'cursor', - required: false, - type: String, - }) - @Redirect(undefined, HttpStatus.PERMANENT_REDIRECT) - @Get('/:safeAddress/history') - getLockingHistory(@Req() request: Request): { url: string } { - const newUrl = `/v1/community/locking/${request.params.safeAddress}/history`; - const search = request.url.split('?')[1]; - return { - url: search ? `${newUrl}/?${search}` : newUrl, - }; - } -} diff --git a/src/routes/locking/locking.module.ts b/src/routes/locking/locking.module.ts deleted file mode 100644 index 6b384f2c8d..0000000000 --- a/src/routes/locking/locking.module.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Module } from '@nestjs/common'; -import { LockingController } from '@/routes/locking/locking.controller'; - -@Module({ - controllers: [LockingController], -}) -export class LockingModule {} From eff926bd89dbf4d5666bd50b280b619e2f2764dc Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 27 May 2024 10:16:18 +0200 Subject: [PATCH 026/207] Improve CAIP-10 address parsing (#1583) Pivots from using `getAddress` to parsing against a designated schema for CAIP-10 addresses, also adding verification of the `chainId`: - Create `Caip10AddressPipeSchema` - Parse incoming CAIP-10 addresses against schema - Add test coverage --- .../safes/pipes/caip-10-addresses.pipe.spec.ts | 18 ++++++++++++++++++ .../safes/pipes/caip-10-addresses.pipe.ts | 18 +++++++++++++----- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/routes/safes/pipes/caip-10-addresses.pipe.spec.ts b/src/routes/safes/pipes/caip-10-addresses.pipe.spec.ts index a8340532e9..da3c2c5c15 100644 --- a/src/routes/safes/pipes/caip-10-addresses.pipe.spec.ts +++ b/src/routes/safes/pipes/caip-10-addresses.pipe.spec.ts @@ -56,6 +56,24 @@ describe('Caip10AddressesPipe', () => { ]); }); + it('throws for non-numerical chainIds', async () => { + const chainId = faker.string.alpha(); + const address = faker.finance.ethereumAddress(); + + await request(app.getHttpServer()) + .get(`/test?addresses=${chainId}:${address}`) + .expect(500); + }); + + it('throws for non-address addresses', async () => { + const chainId = faker.string.numeric(); + const address = faker.number.int(); + + await request(app.getHttpServer()) + .get(`/test?addresses=${chainId}:${address}`) + .expect(500); + }); + it('throws for missing params', async () => { await request(app.getHttpServer()).get('/test?addresses=').expect(500); }); diff --git a/src/routes/safes/pipes/caip-10-addresses.pipe.ts b/src/routes/safes/pipes/caip-10-addresses.pipe.ts index a8f465f540..504732416e 100644 --- a/src/routes/safes/pipes/caip-10-addresses.pipe.ts +++ b/src/routes/safes/pipes/caip-10-addresses.pipe.ts @@ -1,21 +1,29 @@ +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; +import { NumericStringSchema } from '@/validation/entities/schemas/numeric-string.schema'; import { PipeTransform, Injectable } from '@nestjs/common'; -import { getAddress } from 'viem'; +import { z } from 'zod'; + +const Caip10AddressPipeSchema = z.object({ + chainId: NumericStringSchema, + address: AddressSchema, +}); @Injectable() export class Caip10AddressesPipe - implements PipeTransform> + implements + PipeTransform> { transform(data: string): Array<{ chainId: string; - address: string; + address: `0x${string}`; }> { const addresses = data.split(',').map((caip10Address: string) => { const [chainId, address] = caip10Address.split(':'); - return { chainId, address: getAddress(address) }; + return Caip10AddressPipeSchema.parse({ chainId, address }); }); - if (addresses.length === 0 || !addresses[0].address) { + if (addresses.length === 0) { throw new Error( 'Provided addresses do not conform to the CAIP-10 standard', ); From 8ffc04bbabec0130e614a39e2dac526db7e9c810 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 27 May 2024 10:45:47 +0200 Subject: [PATCH 027/207] Fix `@typescript-eslint/no-unsafe-enum-comparison` instances (#1566) Enables `@typescript-eslint/no-unsafe-enum-comparison` lint rule and addresses instances where "unsafe" enum comparison occurs: - Enable `@typescript-eslint/no-unsafe-enum-comparison` rule in `eslint.config.mjs` - Fix all "unsafe" enum comparison --- eslint.config.mjs | 1 - .../entities/human-description-template.entity.ts | 6 +++--- .../entities/transfer-transaction-info.entity.ts | 4 ++-- .../mappers/common/custom-transaction.mapper.ts | 3 ++- .../transactions/mappers/common/transaction-info.mapper.ts | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 7aef3a0df5..bf63ed60dc 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -34,7 +34,6 @@ export default tseslint.config( '@typescript-eslint/no-unsafe-argument': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/no-unsafe-call': 'off', - '@typescript-eslint/no-unsafe-enum-comparison': 'off', '@typescript-eslint/no-unsafe-member-access': 'off', '@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/restrict-template-expressions': 'off', diff --git a/src/domain/human-description/entities/human-description-template.entity.ts b/src/domain/human-description/entities/human-description-template.entity.ts index 78a9ca580a..3d93ad1394 100644 --- a/src/domain/human-description/entities/human-description-template.entity.ts +++ b/src/domain/human-description/entities/human-description-template.entity.ts @@ -102,7 +102,7 @@ export class HumanDescriptionTemplate { const value = args[index]; switch (tokenType) { - case ValueType.TokenValue: { + case 'tokenValue': { if (typeof value !== 'bigint') { throw Error( `Invalid token type amount. tokenType=${tokenType}, amount=${value}`, @@ -114,7 +114,7 @@ export class HumanDescriptionTemplate { value: { amount: value, address: to }, }; } - case ValueType.Address: { + case 'address': { if (!isHex(value)) { throw Error( `Invalid token type value. tokenType=${tokenType}, address=${value}`, @@ -126,7 +126,7 @@ export class HumanDescriptionTemplate { value, }; } - case ValueType.Number: { + case 'number': { if (typeof value !== 'bigint') { throw Error( `Invalid token type value. tokenType=${tokenType}, address=${value}`, diff --git a/src/routes/transactions/entities/transfer-transaction-info.entity.ts b/src/routes/transactions/entities/transfer-transaction-info.entity.ts index 41a8e9e2ae..2a24230188 100644 --- a/src/routes/transactions/entities/transfer-transaction-info.entity.ts +++ b/src/routes/transactions/entities/transfer-transaction-info.entity.ts @@ -19,14 +19,14 @@ export class TransferTransactionInfo extends TransactionInfo { @ApiProperty() recipient: AddressInfo; @ApiProperty() - direction: string; + direction: TransferDirection; @ApiProperty() transferInfo: Transfer; constructor( sender: AddressInfo, recipient: AddressInfo, - direction: string, + direction: TransferDirection, transferInfo: Transfer, humanDescription: string | null, richDecodedInfo: RichDecodedInfo | null | undefined, diff --git a/src/routes/transactions/mappers/common/custom-transaction.mapper.ts b/src/routes/transactions/mappers/common/custom-transaction.mapper.ts index ab836fcbc7..03b850bf3a 100644 --- a/src/routes/transactions/mappers/common/custom-transaction.mapper.ts +++ b/src/routes/transactions/mappers/common/custom-transaction.mapper.ts @@ -10,6 +10,7 @@ import { TRANSACTIONS_PARAMETER_NAME, } from '@/routes/transactions/constants'; import { CustomTransactionInfo } from '@/routes/transactions/entities/custom-transaction.entity'; +import { Operation } from '@/domain/safe/entities/operation.entity'; @Injectable() export class CustomTransactionMapper { @@ -77,7 +78,7 @@ export class CustomTransactionMapper { to === safe && dataSize === 0 && (!value || Number(value) === 0) && - operation === 0 && + operation === Operation.CALL && (!baseGas || Number(baseGas) === 0) && (!gasPrice || Number(gasPrice) === 0) && (!gasToken || gasToken === NULL_ADDRESS) && diff --git a/src/routes/transactions/mappers/common/transaction-info.mapper.ts b/src/routes/transactions/mappers/common/transaction-info.mapper.ts index 2cff3b416c..f9fc17b01b 100644 --- a/src/routes/transactions/mappers/common/transaction-info.mapper.ts +++ b/src/routes/transactions/mappers/common/transaction-info.mapper.ts @@ -5,7 +5,7 @@ import { MultisigTransaction } from '@/domain/safe/entities/multisig-transaction import { Operation } from '@/domain/safe/entities/operation.entity'; import { TokenRepository } from '@/domain/tokens/token.repository'; import { ITokenRepository } from '@/domain/tokens/token.repository.interface'; -import { TokenType } from '@/routes/balances/entities/token-type.entity'; +import { TokenType } from '@/domain/tokens/entities/token.entity'; import { DataDecodedParameter } from '@/routes/data-decode/entities/data-decoded-parameter.entity'; import { DataDecoded } from '@/routes/data-decode/entities/data-decoded.entity'; import { SettingsChangeTransaction } from '@/routes/transactions/entities/settings-change-transaction.entity'; @@ -223,7 +223,7 @@ export class MultisigTransactionInfoMapper { dataSize: number, operation: Operation, ): boolean { - return (value > 0 && dataSize > 0) || operation !== 0; + return (value > 0 && dataSize > 0) || operation !== Operation.CALL; } private isNativeCoinTransfer(value: number, dataSize: number): boolean { From a7b049b48b25d40456b48b098f5cafd0818400b4 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 27 May 2024 10:48:15 +0200 Subject: [PATCH 028/207] Fix `@typescript-eslint/require-await` instances (#1565) Enables the `@typescript-eslint/require-await` line rule and addresses instances where unnecessary `async` is used: - Enable `@typescript-eslint/require-await` rule in `eslint.config.mjs` - Remove all unnecessary uses of `async` and associated changes --- eslint.config.mjs | 1 - src/__tests__/amqp-client.factory.ts | 4 +- .../fake.configuration.service.spec.ts | 8 +-- src/config/nest.configuration.service.spec.ts | 10 ++-- .../alerts-api/tenderly-api.service.spec.ts | 4 +- .../coingecko-api.service.spec.ts | 2 +- .../zerion-balances-api.service.ts | 3 +- .../__tests__/fake.cache.service.spec.ts | 2 +- .../redis.cache.service.key-prefix.spec.ts | 2 +- .../config-api/config-api.service.spec.ts | 4 +- .../email-api/pushwoosh-api.service.spec.ts | 4 +- .../errors/http-error-factory.spec.ts | 8 +-- .../network/fetch.network.service.spec.ts | 2 +- .../relay-api/gelato-api.service.spec.ts | 2 +- .../siwe-api/siwe-api.service.spec.ts | 2 +- src/domain/alerts/alerts.repository.ts | 6 +-- src/routes/alerts/alerts.controller.ts | 4 +- src/routes/auth/guards/auth.guard.spec.ts | 2 +- .../__tests__/event-hooks-queue.e2e-spec.ts | 2 +- .../cache-hooks/cache-hooks.controller.ts | 4 +- .../__tests__/safe-created.schema.spec.ts | 2 +- .../filters/global-error.filter.spec.ts | 4 +- .../common/filters/zod-error.filter.spec.ts | 14 ++--- .../common/pagination/pagination.data.spec.ts | 54 +++++++++---------- .../pipes/caip-10-addresses.pipe.spec.ts | 4 +- .../common/safe-app-info.mapper.spec.ts | 2 +- test/global-setup.ts | 2 +- 27 files changed, 78 insertions(+), 80 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index bf63ed60dc..799e101147 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -37,7 +37,6 @@ export default tseslint.config( '@typescript-eslint/no-unsafe-member-access': 'off', '@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/restrict-template-expressions': 'off', - '@typescript-eslint/require-await': 'off', '@typescript-eslint/unbound-method': 'off', }, }, diff --git a/src/__tests__/amqp-client.factory.ts b/src/__tests__/amqp-client.factory.ts index 4b1de7c7a9..e2fd5a8a64 100644 --- a/src/__tests__/amqp-client.factory.ts +++ b/src/__tests__/amqp-client.factory.ts @@ -1,10 +1,10 @@ import amqp, { ChannelWrapper } from 'amqp-connection-manager'; import { Channel } from 'amqplib'; -export async function amqpClientFactory(queue?: string): Promise<{ +export function amqpClientFactory(queue?: string): { channel: ChannelWrapper; queueName: string; -}> { +} { const { AMQP_URL, AMQP_EXCHANGE_NAME, AMQP_EXCHANGE_MODE, AMQP_QUEUE } = process.env; diff --git a/src/config/__tests__/fake.configuration.service.spec.ts b/src/config/__tests__/fake.configuration.service.spec.ts index 6941e33ff2..ae4ce251fc 100644 --- a/src/config/__tests__/fake.configuration.service.spec.ts +++ b/src/config/__tests__/fake.configuration.service.spec.ts @@ -3,11 +3,11 @@ import { FakeConfigurationService } from '@/config/__tests__/fake.configuration. describe('FakeConfigurationService', () => { let configurationService: FakeConfigurationService; - beforeEach(async () => { + beforeEach(() => { configurationService = new FakeConfigurationService(); }); - it(`Setting key should store its value`, async () => { + it(`Setting key should store its value`, () => { configurationService.set('aaa', 'bbb'); const result = configurationService.get('aaa'); @@ -16,7 +16,7 @@ describe('FakeConfigurationService', () => { expect(result).toBe('bbb'); }); - it(`Retrieving unknown key should return undefined`, async () => { + it(`Retrieving unknown key should return undefined`, () => { configurationService.set('aaa', 'bbb'); const result = configurationService.get('unknown_key'); @@ -24,7 +24,7 @@ describe('FakeConfigurationService', () => { expect(result).toBe(undefined); }); - it(`Retrieving unknown key should throw`, async () => { + it(`Retrieving unknown key should throw`, () => { configurationService.set('aaa', 'bbb'); const result = (): void => { diff --git a/src/config/nest.configuration.service.spec.ts b/src/config/nest.configuration.service.spec.ts index e1a9ada637..ddc881e82e 100644 --- a/src/config/nest.configuration.service.spec.ts +++ b/src/config/nest.configuration.service.spec.ts @@ -11,12 +11,12 @@ const configServiceMock = jest.mocked(configService); describe('NestConfigurationService', () => { let target: NestConfigurationService; - beforeEach(async () => { + beforeEach(() => { jest.resetAllMocks(); target = new NestConfigurationService(configServiceMock); }); - it(`get key is successful`, async () => { + it(`get key is successful`, () => { const key = faker.string.sample(); const value = { some: { value: 10 } }; configServiceMock.get.mockReturnValue(value); @@ -29,7 +29,7 @@ describe('NestConfigurationService', () => { expect(result).toBe(value); }); - it(`get key returns undefined when no key is found`, async () => { + it(`get key returns undefined when no key is found`, () => { const key = faker.string.sample(); configServiceMock.get.mockReturnValue(undefined); @@ -41,7 +41,7 @@ describe('NestConfigurationService', () => { expect(result).toBe(undefined); }); - it(`getOrThrow key is successful`, async () => { + it(`getOrThrow key is successful`, () => { const key = faker.string.sample(); const value = { some: { value: 10 } }; configServiceMock.getOrThrow.mockReturnValue(value); @@ -54,7 +54,7 @@ describe('NestConfigurationService', () => { expect(result).toBe(value); }); - it(`getOrThrow key throws error`, async () => { + it(`getOrThrow key throws error`, () => { const key = faker.string.sample(); configServiceMock.getOrThrow.mockImplementation(() => { throw new Error('some error'); diff --git a/src/datasources/alerts-api/tenderly-api.service.spec.ts b/src/datasources/alerts-api/tenderly-api.service.spec.ts index 7e708c3479..15a26066d0 100644 --- a/src/datasources/alerts-api/tenderly-api.service.spec.ts +++ b/src/datasources/alerts-api/tenderly-api.service.spec.ts @@ -25,7 +25,7 @@ describe('TenderlyApi', () => { let tenderlyAccount: string; let tenderlyProject: string; - beforeEach(async () => { + beforeEach(() => { jest.resetAllMocks(); tenderlyBaseUri = faker.internet.url({ appendSlash: false }); @@ -48,7 +48,7 @@ describe('TenderlyApi', () => { ); }); - it('should error if configuration is not defined', async () => { + it('should error if configuration is not defined', () => { const fakeConfigurationService = new FakeConfigurationService(); const httpErrorFactory = new HttpErrorFactory(); diff --git a/src/datasources/balances-api/coingecko-api.service.spec.ts b/src/datasources/balances-api/coingecko-api.service.spec.ts index a78e7c4da7..801cd650fa 100644 --- a/src/datasources/balances-api/coingecko-api.service.spec.ts +++ b/src/datasources/balances-api/coingecko-api.service.spec.ts @@ -41,7 +41,7 @@ describe('CoingeckoAPI', () => { const defaultExpirationTimeInSeconds = faker.number.int(); const notFoundExpirationTimeInSeconds = faker.number.int(); - beforeEach(async () => { + beforeEach(() => { jest.resetAllMocks(); fakeConfigurationService = new FakeConfigurationService(); fakeConfigurationService.set( diff --git a/src/datasources/balances-api/zerion-balances-api.service.ts b/src/datasources/balances-api/zerion-balances-api.service.ts index c6b271a8c3..0e0f854106 100644 --- a/src/datasources/balances-api/zerion-balances-api.service.ts +++ b/src/datasources/balances-api/zerion-balances-api.service.ts @@ -222,7 +222,8 @@ export class ZerionBalancesApi implements IBalancesApi { } async getFiatCodes(): Promise { - return this.fiatCodes; + // Resolving to conform with interface + return Promise.resolve(this.fiatCodes); } private _mapErc20Balance( diff --git a/src/datasources/cache/__tests__/fake.cache.service.spec.ts b/src/datasources/cache/__tests__/fake.cache.service.spec.ts index 2680fb454e..b675b0330c 100644 --- a/src/datasources/cache/__tests__/fake.cache.service.spec.ts +++ b/src/datasources/cache/__tests__/fake.cache.service.spec.ts @@ -5,7 +5,7 @@ import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity'; describe('FakeCacheService', () => { let target: FakeCacheService; - beforeEach(async () => { + beforeEach(() => { target = new FakeCacheService(); }); diff --git a/src/datasources/cache/redis.cache.service.key-prefix.spec.ts b/src/datasources/cache/redis.cache.service.key-prefix.spec.ts index b2032521d3..e2e7767a34 100644 --- a/src/datasources/cache/redis.cache.service.key-prefix.spec.ts +++ b/src/datasources/cache/redis.cache.service.key-prefix.spec.ts @@ -35,7 +35,7 @@ describe('RedisCacheService with a Key Prefix', () => { let defaultExpirationTimeInSeconds: number; const keyPrefix = faker.string.uuid(); - beforeEach(async () => { + beforeEach(() => { clearAllMocks(); defaultExpirationTimeInSeconds = faker.number.int(); mockConfigurationService.getOrThrow.mockImplementation((key) => { diff --git a/src/datasources/config-api/config-api.service.spec.ts b/src/datasources/config-api/config-api.service.spec.ts index 37602d389f..7020a732ec 100644 --- a/src/datasources/config-api/config-api.service.spec.ts +++ b/src/datasources/config-api/config-api.service.spec.ts @@ -32,7 +32,7 @@ describe('ConfigApi', () => { let fakeConfigurationService: FakeConfigurationService; let service: ConfigApi; - beforeAll(async () => { + beforeAll(() => { fakeConfigurationService = new FakeConfigurationService(); fakeConfigurationService.set('safeConfig.baseUri', baseUri); fakeConfigurationService.set( @@ -45,7 +45,7 @@ describe('ConfigApi', () => { ); }); - beforeEach(async () => { + beforeEach(() => { jest.resetAllMocks(); service = new ConfigApi( dataSource, diff --git a/src/datasources/email-api/pushwoosh-api.service.spec.ts b/src/datasources/email-api/pushwoosh-api.service.spec.ts index 738fed35b1..bc14f4533a 100644 --- a/src/datasources/email-api/pushwoosh-api.service.spec.ts +++ b/src/datasources/email-api/pushwoosh-api.service.spec.ts @@ -23,7 +23,7 @@ describe('PushwooshApi', () => { let pushwooshFromEmail: string; let pushwooshFromName: string; - beforeEach(async () => { + beforeEach(() => { jest.resetAllMocks(); pushwooshApplicationCode = faker.string.alphanumeric(); @@ -49,7 +49,7 @@ describe('PushwooshApi', () => { ); }); - it('should error if configuration is not defined', async () => { + it('should error if configuration is not defined', () => { const fakeConfigurationService = new FakeConfigurationService(); expect( diff --git a/src/datasources/errors/http-error-factory.spec.ts b/src/datasources/errors/http-error-factory.spec.ts index 7f7c8e784d..02a025fd08 100644 --- a/src/datasources/errors/http-error-factory.spec.ts +++ b/src/datasources/errors/http-error-factory.spec.ts @@ -8,7 +8,7 @@ import { faker } from '@faker-js/faker'; describe('HttpErrorFactory', () => { const httpErrorFactory: HttpErrorFactory = new HttpErrorFactory(); - it('should create an DataSourceError when there is an error with the response', async () => { + it('should create an DataSourceError when there is an error with the response', () => { const httpError = new NetworkResponseError( new URL(faker.internet.url()), { @@ -27,7 +27,7 @@ describe('HttpErrorFactory', () => { ); }); - it('should create an DataSourceError with 503 status when there is an error with the request URL', async () => { + it('should create an DataSourceError with 503 status when there is an error with the request URL', () => { const httpError = new NetworkRequestError(null, undefined); const actual = httpErrorFactory.from(httpError); @@ -36,7 +36,7 @@ describe('HttpErrorFactory', () => { expect(actual.message).toBe('Service unavailable'); }); - it('should create an DataSourceError with 503 status when there is an error with the request', async () => { + it('should create an DataSourceError with 503 status when there is an error with the request', () => { const httpError = new NetworkRequestError( new URL(faker.internet.url()), new Error('Failed to fetch'), @@ -48,7 +48,7 @@ describe('HttpErrorFactory', () => { expect(actual.message).toBe('Service unavailable'); }); - it('should create an DataSourceError with 503 status when an arbitrary error happens', async () => { + it('should create an DataSourceError with 503 status when an arbitrary error happens', () => { const errMessage = 'Service unavailable'; const randomError = new Error(); diff --git a/src/datasources/network/fetch.network.service.spec.ts b/src/datasources/network/fetch.network.service.spec.ts index 514a2e21b6..1770589831 100644 --- a/src/datasources/network/fetch.network.service.spec.ts +++ b/src/datasources/network/fetch.network.service.spec.ts @@ -19,7 +19,7 @@ const loggingServiceMock = jest.mocked(loggingService); describe('FetchNetworkService', () => { let target: FetchNetworkService; - beforeEach(async () => { + beforeEach(() => { jest.resetAllMocks(); target = new FetchNetworkService(fetchClientMock, loggingServiceMock); }); diff --git a/src/datasources/relay-api/gelato-api.service.spec.ts b/src/datasources/relay-api/gelato-api.service.spec.ts index eced003a25..6801fd5de1 100644 --- a/src/datasources/relay-api/gelato-api.service.spec.ts +++ b/src/datasources/relay-api/gelato-api.service.spec.ts @@ -21,7 +21,7 @@ describe('GelatoApi', () => { let ttlSeconds: number; let httpErrorFactory: HttpErrorFactory; - beforeEach(async () => { + beforeEach(() => { jest.resetAllMocks(); httpErrorFactory = new HttpErrorFactory(); diff --git a/src/datasources/siwe-api/siwe-api.service.spec.ts b/src/datasources/siwe-api/siwe-api.service.spec.ts index 424cf8e62b..adaed83953 100644 --- a/src/datasources/siwe-api/siwe-api.service.spec.ts +++ b/src/datasources/siwe-api/siwe-api.service.spec.ts @@ -18,7 +18,7 @@ describe('SiweApiService', () => { let fakeCacheService: FakeCacheService; const nonceTtlInSeconds = faker.number.int(); - beforeEach(async () => { + beforeEach(() => { jest.resetAllMocks(); fakeConfigurationService = new FakeConfigurationService(); fakeCacheService = new FakeCacheService(); diff --git a/src/domain/alerts/alerts.repository.ts b/src/domain/alerts/alerts.repository.ts index acbd487004..23bfb3126c 100644 --- a/src/domain/alerts/alerts.repository.ts +++ b/src/domain/alerts/alerts.repository.ts @@ -98,7 +98,7 @@ export class AlertsRepository implements IAlertsRepository { decodedEvent.args.data, ); - const newSafeState = await this._mapSafeSetup({ + const newSafeState = this._mapSafeSetup({ safe, decodedTransactions, }); @@ -188,10 +188,10 @@ export class AlertsRepository implements IAlertsRepository { return decoded; } - private async _mapSafeSetup(args: { + private _mapSafeSetup(args: { safe: Safe; decodedTransactions: Array>; - }): Promise { + }): Safe { return args.decodedTransactions.reduce((newSafe, decodedTransaction) => { switch (decodedTransaction.functionName) { case 'addOwnerWithThreshold': { diff --git a/src/routes/alerts/alerts.controller.ts b/src/routes/alerts/alerts.controller.ts index e7d5c79cc0..17379d82e9 100644 --- a/src/routes/alerts/alerts.controller.ts +++ b/src/routes/alerts/alerts.controller.ts @@ -37,10 +37,10 @@ export class AlertsController { @UseGuards(TenderlySignatureGuard) @Post() @HttpCode(202) - async postAlert( + postAlert( @Body(new ValidationPipe(AlertSchema)) alertPayload: Alert, - ): Promise { + ): void { // TODO: we return immediately but we should consider a pub/sub system to tackle received alerts // which were not handled correctly (e.g. due to other 3rd parties being unavailable) this.alertsService diff --git a/src/routes/auth/guards/auth.guard.spec.ts b/src/routes/auth/guards/auth.guard.spec.ts index bf64403c69..8cc297308c 100644 --- a/src/routes/auth/guards/auth.guard.spec.ts +++ b/src/routes/auth/guards/auth.guard.spec.ts @@ -23,7 +23,7 @@ import jwtConfiguration from '@/datasources/jwt/configuration/__tests__/jwt.conf class TestController { @Get('valid') @UseGuards(AuthGuard) - async validRoute(): Promise<{ secret: string }> { + validRoute(): { secret: string } { return { secret: 'This is a secret message' }; } } diff --git a/src/routes/cache-hooks/__tests__/event-hooks-queue.e2e-spec.ts b/src/routes/cache-hooks/__tests__/event-hooks-queue.e2e-spec.ts index 2ceee4f0ff..1205dea247 100644 --- a/src/routes/cache-hooks/__tests__/event-hooks-queue.e2e-spec.ts +++ b/src/routes/cache-hooks/__tests__/event-hooks-queue.e2e-spec.ts @@ -46,7 +46,7 @@ describe('Events queue processing e2e tests', () => { app = await new TestAppProvider().provide(moduleRef); await app.init(); redisClient = await redisClientFactory(); - const amqpClient = await amqpClientFactory(queue); + const amqpClient = amqpClientFactory(queue); channel = amqpClient.channel; queueName = amqpClient.queueName; }); diff --git a/src/routes/cache-hooks/cache-hooks.controller.ts b/src/routes/cache-hooks/cache-hooks.controller.ts index df077e9ef7..4d7da879f7 100644 --- a/src/routes/cache-hooks/cache-hooks.controller.ts +++ b/src/routes/cache-hooks/cache-hooks.controller.ts @@ -46,9 +46,7 @@ export class CacheHooksController { @Post('/hooks/events') @UseFilters(EventProtocolChangedFilter) @HttpCode(202) - async postEvent( - @Body(new ValidationPipe(WebHookSchema)) event: Event, - ): Promise { + postEvent(@Body(new ValidationPipe(WebHookSchema)) event: Event): void { if (!this.isEventsQueueEnabled || this.isHttpEvent(event)) { this.service.onEvent(event).catch((error) => { this.loggingService.error(error); diff --git a/src/routes/cache-hooks/entities/schemas/__tests__/safe-created.schema.spec.ts b/src/routes/cache-hooks/entities/schemas/__tests__/safe-created.schema.spec.ts index 2e48841749..eaa7f36f10 100644 --- a/src/routes/cache-hooks/entities/schemas/__tests__/safe-created.schema.spec.ts +++ b/src/routes/cache-hooks/entities/schemas/__tests__/safe-created.schema.spec.ts @@ -33,7 +33,7 @@ describe('SafeCreatedEventSchema', () => { ); }); - it('should not allow a missing chainId', async () => { + it('should not allow a missing chainId', () => { const safeCreatedEvent = safeCreatedEventBuilder().build(); // @ts-expect-error - inferred types don't allow optional fields delete safeCreatedEvent.chainId; diff --git a/src/routes/common/filters/global-error.filter.spec.ts b/src/routes/common/filters/global-error.filter.spec.ts index ab6a7912a5..253deef761 100644 --- a/src/routes/common/filters/global-error.filter.spec.ts +++ b/src/routes/common/filters/global-error.filter.spec.ts @@ -17,7 +17,7 @@ import { GlobalErrorFilter } from '@/routes/common/filters/global-error.filter'; @Controller({}) class TestController { @Get('http-exception') - async httpException(): Promise { + httpException(): void { throw new HttpException( { message: 'Some http exception' }, HttpStatus.BAD_GATEWAY, @@ -25,7 +25,7 @@ class TestController { } @Get('non-http-exception') - async nonHttpException(): Promise { + nonHttpException(): void { throw new Error('Some random error'); } } diff --git a/src/routes/common/filters/zod-error.filter.spec.ts b/src/routes/common/filters/zod-error.filter.spec.ts index a15a221f6a..6d9fbccf7c 100644 --- a/src/routes/common/filters/zod-error.filter.spec.ts +++ b/src/routes/common/filters/zod-error.filter.spec.ts @@ -32,30 +32,30 @@ const ZodNestedUnionSchema = z.union([ @Controller({}) class TestController { @Post('zod-exception') - async zodError( + zodError( @Body(new ValidationPipe(ZodSchema)) body: z.infer, - ): Promise> { + ): z.infer { return body; } @Post('zod-union-exception') - async zodUnionError( + zodUnionError( @Body(new ValidationPipe(ZodUnionSchema)) body: z.infer, - ): Promise> { + ): z.infer { return body; } @Post('zod-nested-union-exception') - async zodNestedUnionError( + zodNestedUnionError( @Body(new ValidationPipe(ZodNestedUnionSchema)) body: z.infer, - ): Promise> { + ): z.infer { return body; } @Get('non-zod-exception') - async nonZodException(): Promise { + nonZodException(): void { throw new Error('Some random error'); } } diff --git a/src/routes/common/pagination/pagination.data.spec.ts b/src/routes/common/pagination/pagination.data.spec.ts index 7aaf3ea375..3424a3a763 100644 --- a/src/routes/common/pagination/pagination.data.spec.ts +++ b/src/routes/common/pagination/pagination.data.spec.ts @@ -8,7 +8,7 @@ import { describe('PaginationData', () => { describe('fromCursor', () => { - it('url with cursor, limit and offset', async () => { + it('url with cursor, limit and offset', () => { const url = new URL('https://safe.global/?cursor=limit%3D1%26offset%3D2'); const actual = PaginationData.fromCursor(url); @@ -17,7 +17,7 @@ describe('PaginationData', () => { expect(actual.offset).toBe(2); }); - it('url with cursor, limit but no offset', async () => { + it('url with cursor, limit but no offset', () => { const url = new URL('https://safe.global/?cursor=limit%3D1%26'); const actual = PaginationData.fromCursor(url); @@ -26,7 +26,7 @@ describe('PaginationData', () => { expect(actual.offset).toBe(PaginationData.DEFAULT_OFFSET); }); - it('url with cursor, offset but no limit', async () => { + it('url with cursor, offset but no limit', () => { const url = new URL('https://safe.global/?cursor=offset%3D1%26'); const actual = PaginationData.fromCursor(url); @@ -35,7 +35,7 @@ describe('PaginationData', () => { expect(actual.offset).toBe(1); }); - it('url with no cursor', async () => { + it('url with no cursor', () => { const url = new URL('https://safe.global/?another_query=offset%3D1%26'); const actual = PaginationData.fromCursor(url); @@ -44,7 +44,7 @@ describe('PaginationData', () => { expect(actual.offset).toBe(PaginationData.DEFAULT_OFFSET); }); - it('limit is not a number', async () => { + it('limit is not a number', () => { const url = new URL( 'https://safe.global/?cursor=limit%3Daa%26offset%3D2', ); @@ -55,7 +55,7 @@ describe('PaginationData', () => { expect(actual.offset).toBe(2); }); - it('offset is not a number', async () => { + it('offset is not a number', () => { const url = new URL( 'https://safe.global/?cursor=limit%3D1%26offset%3Daa', ); @@ -68,7 +68,7 @@ describe('PaginationData', () => { }); describe('fromLimitAndOffset', () => { - it('url with limit and offset', async () => { + it('url with limit and offset', () => { const url = new URL('https://safe.global/?limit=10&offset=20'); const actual = PaginationData.fromLimitAndOffset(url); @@ -77,7 +77,7 @@ describe('PaginationData', () => { expect(actual.offset).toBe(20); }); - it('url with limit but no offset', async () => { + it('url with limit but no offset', () => { const url = new URL('https://safe.global/?limit=10'); const actual = PaginationData.fromLimitAndOffset(url); @@ -86,7 +86,7 @@ describe('PaginationData', () => { expect(actual.offset).toBe(PaginationData.DEFAULT_OFFSET); }); - it('url with offset but no limit', async () => { + it('url with offset but no limit', () => { const url = new URL('https://safe.global/?offset=20'); const actual = PaginationData.fromLimitAndOffset(url); @@ -95,7 +95,7 @@ describe('PaginationData', () => { expect(actual.offset).toBe(20); }); - it('url with neither limit no offset', async () => { + it('url with neither limit no offset', () => { const url = new URL('https://safe.global/?another_query=test'); const actual = PaginationData.fromLimitAndOffset(url); @@ -104,7 +104,7 @@ describe('PaginationData', () => { expect(actual.offset).toBe(PaginationData.DEFAULT_OFFSET); }); - it('limit is not a number', async () => { + it('limit is not a number', () => { const url = new URL('https://safe.global/?limit=aa&offset=20'); const actual = PaginationData.fromLimitAndOffset(url); @@ -113,7 +113,7 @@ describe('PaginationData', () => { expect(actual.offset).toBe(20); }); - it('offset is not a number', async () => { + it('offset is not a number', () => { const url = new URL('https://safe.global/?limit=10&offset=aa'); const actual = PaginationData.fromLimitAndOffset(url); @@ -124,7 +124,7 @@ describe('PaginationData', () => { }); describe('cursorUrlFromLimitAndOffset', () => { - it('url is generated correctly', async () => { + it('url is generated correctly', () => { const fromUrl = 'https://safe.global/?limit=10&offset=2'; const baseUrl = 'https://base.url/'; @@ -136,7 +136,7 @@ describe('PaginationData', () => { expect(actual?.href).toStrictEqual(expected.href); }); - it('cursor is updated', async () => { + it('cursor is updated', () => { const fromUrl = 'https://safe.global/?limit=10&offset=2'; // base url has limit=0 and offset=1 const baseUrl = 'https://base.url/?cursor=limit%3D0%26offset%3D1'; @@ -149,7 +149,7 @@ describe('PaginationData', () => { expect(actual?.href).toStrictEqual(expected.href); }); - it('returns null when from is null', async () => { + it('returns null when from is null', () => { const baseUrl = 'https://base.url/'; const actual = cursorUrlFromLimitAndOffset(baseUrl, null); @@ -159,7 +159,7 @@ describe('PaginationData', () => { }); describe('buildNextPageURL', () => { - it('next url is the default if no cursor is passed', async () => { + it('next url is the default if no cursor is passed', () => { const currentUrl = faker.internet.url({ appendSlash: false }); const expected = new URL( `${currentUrl}/?cursor=limit%3D${ @@ -177,7 +177,7 @@ describe('PaginationData', () => { expect(actual?.href).toStrictEqual(expected.href); }); - it('next url is the default if an invalid cursor is passed', async () => { + it('next url is the default if an invalid cursor is passed', () => { const base = faker.internet.url({ appendSlash: false }); const currentUrl = new URL(`${base}/?cursor=${faker.word.sample()}`); const expected = new URL( @@ -194,7 +194,7 @@ describe('PaginationData', () => { expect(actual?.href).toStrictEqual(expected.href); }); - it('next url is null if an invalid cursor is passed but there is no next page', async () => { + it('next url is null if an invalid cursor is passed but there is no next page', () => { const currentUrl = new URL( `${faker.internet.url({ appendSlash: false, @@ -209,7 +209,7 @@ describe('PaginationData', () => { expect(actual).toStrictEqual(null); }); - it('next url is null if items count is equal to next offset', async () => { + it('next url is null if items count is equal to next offset', () => { const limit = faker.number.int({ min: 1, max: 100 }); const offset = faker.number.int({ min: 1, max: 100 }); const itemsCount = limit + offset; @@ -224,7 +224,7 @@ describe('PaginationData', () => { expect(actual).toStrictEqual(null); }); - it('next url is null if items count is less than next offset', async () => { + it('next url is null if items count is less than next offset', () => { const limit = faker.number.int({ min: 1, max: 100 }); const offset = faker.number.int({ min: 1, max: 100 }); const itemsCount = faker.number.int({ max: limit + offset - 1 }); @@ -239,7 +239,7 @@ describe('PaginationData', () => { expect(actual).toStrictEqual(null); }); - it('next url contains a new offset and the same limit', async () => { + it('next url contains a new offset and the same limit', () => { const limit = faker.number.int({ min: 1, max: 100 }); const offset = faker.number.int({ min: 1, max: 100 }); const expectedOffset = limit + offset; @@ -259,7 +259,7 @@ describe('PaginationData', () => { }); describe('buildPreviousPageURL', () => { - it('previous url is null if no cursor is passed', async () => { + it('previous url is null if no cursor is passed', () => { const currentUrl = new URL( `${faker.internet.url({ appendSlash: false })}`, ); @@ -269,7 +269,7 @@ describe('PaginationData', () => { expect(actual).toStrictEqual(null); }); - it('previous url is null if an invalid cursor is passed', async () => { + it('previous url is null if an invalid cursor is passed', () => { const currentUrl = new URL( `${faker.internet.url({ appendSlash: false, @@ -281,7 +281,7 @@ describe('PaginationData', () => { expect(actual).toStrictEqual(null); }); - it('previous url is null if an invalid cursor is passed (2)', async () => { + it('previous url is null if an invalid cursor is passed (2)', () => { const currentUrl = new URL( `${faker.internet.url({ appendSlash: false, @@ -293,7 +293,7 @@ describe('PaginationData', () => { expect(actual).toStrictEqual(null); }); - it('previous url is null if offset is zero', async () => { + it('previous url is null if offset is zero', () => { const currentUrl = new URL( `${faker.internet.url({ appendSlash: false, @@ -305,7 +305,7 @@ describe('PaginationData', () => { expect(actual).toStrictEqual(null); }); - it('previous url contains a zero offset if limit >= offset', async () => { + it('previous url contains a zero offset if limit >= offset', () => { const limit = faker.number.int({ min: 2, max: 100 }); const offset = faker.number.int({ min: 1, max: limit }); const base = faker.internet.url({ appendSlash: false }); @@ -319,7 +319,7 @@ describe('PaginationData', () => { expect(actual?.href).toStrictEqual(expected.href); }); - it('previous url contains a new offset and the same limit', async () => { + it('previous url contains a new offset and the same limit', () => { const limit = faker.number.int({ min: 1, max: 100 }); const offset = faker.number.int({ min: limit + 1 }); const expectedOffset = offset - limit; diff --git a/src/routes/safes/pipes/caip-10-addresses.pipe.spec.ts b/src/routes/safes/pipes/caip-10-addresses.pipe.spec.ts index da3c2c5c15..352c7ced88 100644 --- a/src/routes/safes/pipes/caip-10-addresses.pipe.spec.ts +++ b/src/routes/safes/pipes/caip-10-addresses.pipe.spec.ts @@ -12,10 +12,10 @@ import { getAddress } from 'viem'; @Controller() class TestController { @Get('test') - async route( + route( @Query('addresses', new Caip10AddressesPipe()) addresses: Array<{ chainId: string; address: string }>, - ): Promise> { + ): Array<{ chainId: string; address: string }> { return addresses; } } diff --git a/src/routes/transactions/mappers/common/safe-app-info.mapper.spec.ts b/src/routes/transactions/mappers/common/safe-app-info.mapper.spec.ts index 7284970dbd..317f5d2616 100644 --- a/src/routes/transactions/mappers/common/safe-app-info.mapper.spec.ts +++ b/src/routes/transactions/mappers/common/safe-app-info.mapper.spec.ts @@ -21,7 +21,7 @@ describe('SafeAppInfo mapper (Unit)', () => { let mapper: SafeAppInfoMapper; - beforeEach(async () => { + beforeEach(() => { jest.resetAllMocks(); mapper = new SafeAppInfoMapper(safeAppsRepositoryMock, mockLoggingService); }); diff --git a/test/global-setup.ts b/test/global-setup.ts index ee290d8ed1..39b1962d4d 100644 --- a/test/global-setup.ts +++ b/test/global-setup.ts @@ -1,4 +1,4 @@ // eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export default async () => { +export default () => { process.env.TZ = 'UTC'; }; From 29fcc3ffc43b88d948ed7c0591dd09d0ff93a5a1 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 27 May 2024 11:34:14 +0200 Subject: [PATCH 029/207] Fallback to `threshold` if `confirmationsRequired` of transaction is `null` (#1584) Changes the validation of `MultisigTransaction['confirmationsRequired']` to also expect nullish values and default to `null` if no number is returned: - Change `MultisigTransactionSchema['confirmationsRequired']` to expect a number or nullish values, defaulting to `null` - Update instances where `confirmationsRequired` is references, falling back to the threshold of the Safe - Add relative test coverage --- .../entities/multisig-transaction.entity.ts | 2 +- .../safes/safes.controller.overview.spec.ts | 161 ++++++++ src/routes/safes/safes.service.ts | 5 +- ...-transaction-execution-info.mapper.spec.ts | 375 ++++++++++++------ ...tisig-transaction-execution-info.mapper.ts | 2 +- ...multisig-transaction-status.mapper.spec.ts | 19 +- .../multisig-transaction-status.mapper.ts | 2 +- 7 files changed, 442 insertions(+), 124 deletions(-) diff --git a/src/domain/safe/entities/multisig-transaction.entity.ts b/src/domain/safe/entities/multisig-transaction.entity.ts index 9e604af10a..8c88410d8a 100644 --- a/src/domain/safe/entities/multisig-transaction.entity.ts +++ b/src/domain/safe/entities/multisig-transaction.entity.ts @@ -46,7 +46,7 @@ export const MultisigTransactionSchema = z.object({ gasUsed: z.number().nullish().default(null), fee: NumericStringSchema.nullish().default(null), origin: z.string().nullish().default(null), - confirmationsRequired: z.number(), + confirmationsRequired: z.number().nullish().default(null), confirmations: z.array(ConfirmationSchema).nullish().default(null), signatures: HexSchema.nullish().default(null), trusted: z.boolean(), diff --git a/src/routes/safes/safes.controller.overview.spec.ts b/src/routes/safes/safes.controller.overview.spec.ts index 7446889cc0..18a8c84486 100644 --- a/src/routes/safes/safes.controller.overview.spec.ts +++ b/src/routes/safes/safes.controller.overview.spec.ts @@ -248,6 +248,167 @@ describe('Safes Controller Overview (Unit)', () => { ); }); + it('overview with transactions awaiting confirmation is correctly serialised from threshold if confirmationsRequired is null', async () => { + const chain = chainBuilder().with('chainId', '10').build(); + const safeInfo = safeBuilder().build(); + const tokenAddress = faker.finance.ethereumAddress(); + const secondTokenAddress = faker.finance.ethereumAddress(); + const transactionApiBalancesResponse = [ + balanceBuilder() + .with('tokenAddress', null) + .with('balance', '3000000000000000000') + .with('token', null) + .build(), + balanceBuilder() + .with('tokenAddress', getAddress(tokenAddress)) + .with('balance', '4000000000000000000') + .with('token', balanceTokenBuilder().with('decimals', 17).build()) + .build(), + balanceBuilder() + .with('tokenAddress', getAddress(secondTokenAddress)) + .with('balance', '3000000000000000000') + .with('token', balanceTokenBuilder().with('decimals', 17).build()) + .build(), + ]; + const currency = faker.finance.currencyCode(); + const nativeCoinPriceProviderResponse = { + // @ts-expect-error - TODO: remove after migration + [chain.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, + }; + const tokenPriceProviderResponse = { + [tokenAddress]: { [currency.toLowerCase()]: 12.5 }, + [secondTokenAddress]: { [currency.toLowerCase()]: 10 }, + }; + const walletAddress = getAddress(faker.finance.ethereumAddress()); + const multisigTransactions = [ + multisigTransactionToJson( + multisigTransactionBuilder() + .with('confirmationsRequired', null) + .with('confirmations', [ + // Signature provided + confirmationBuilder().with('owner', walletAddress).build(), + ]) + .build(), + ), + multisigTransactionToJson(multisigTransactionBuilder().build()), + ]; + const queuedTransactions = pageBuilder() + .with('results', multisigTransactions) + .with('count', multisigTransactions.length) + .build(); + + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: { + return Promise.resolve({ data: chain, status: 200 }); + } + case `${chain.transactionService}/api/v1/safes/${safeInfo.address}`: { + return Promise.resolve({ data: safeInfo, status: 200 }); + } + case `${chain.transactionService}/api/v1/safes/${safeInfo.address}/balances/`: { + return Promise.resolve({ + data: transactionApiBalancesResponse, + status: 200, + }); + } + case `${pricesProviderUrl}/simple/price`: { + return Promise.resolve({ + data: nativeCoinPriceProviderResponse, + status: 200, + }); + } + // @ts-expect-error - TODO: remove after migration + case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: { + return Promise.resolve({ + data: tokenPriceProviderResponse, + status: 200, + }); + } + case `${chain.transactionService}/api/v1/safes/${safeInfo.address}/multisig-transactions/`: { + return Promise.resolve({ + data: queuedTransactions, + status: 200, + }); + } + default: { + return Promise.reject(`No matching rule for url: ${url}`); + } + } + }); + + await request(app.getHttpServer()) + .get( + `/v1/safes?currency=${currency}&safes=${chain.chainId}:${safeInfo.address}&wallet_address=${walletAddress}`, + ) + .expect(200) + .expect(({ body }) => + expect(body).toMatchObject([ + { + address: { + value: safeInfo.address, + name: null, + logoUri: null, + }, + chainId: chain.chainId, + threshold: safeInfo.threshold, + owners: safeInfo.owners.map((owner) => ({ + value: owner, + name: null, + logoUri: null, + })), + fiatTotal: '5410.25', + queued: 2, + awaitingConfirmation: 1, + }, + ]), + ); + + expect(networkService.get.mock.calls.length).toBe(6); + + expect(networkService.get.mock.calls[0][0].url).toBe( + `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, + ); + expect(networkService.get.mock.calls[1][0].url).toBe( + `${chain.transactionService}/api/v1/safes/${safeInfo.address}`, + ); + expect(networkService.get.mock.calls[2][0].url).toBe( + `${chain.transactionService}/api/v1/safes/${safeInfo.address}/balances/`, + ); + expect(networkService.get.mock.calls[2][0].networkRequest).toStrictEqual({ + params: { trusted: false, exclude_spam: true }, + }); + expect(networkService.get.mock.calls[3][0].url).toBe( + // @ts-expect-error - TODO: remove after migration + `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, + ); + expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ + headers: { 'x-cg-pro-api-key': pricesApiKey }, + params: { + vs_currencies: currency.toLowerCase(), + contract_addresses: [ + tokenAddress.toLowerCase(), + secondTokenAddress.toLowerCase(), + ].join(','), + }, + }); + expect(networkService.get.mock.calls[4][0].url).toBe( + `${pricesProviderUrl}/simple/price`, + ); + expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ + headers: { 'x-cg-pro-api-key': pricesApiKey }, + params: { + // @ts-expect-error - TODO: remove after migration + ids: chain.pricesProvider.nativeCoin, + vs_currencies: currency.toLowerCase(), + }, + }); + expect(networkService.get.mock.calls[5][0].url).toBe( + `${chain.transactionService}/api/v1/safes/${safeInfo.address}/multisig-transactions/`, + ); + }); + it('should not return awaiting confirmations if no more confirmations are required', async () => { const chain = chainBuilder().with('chainId', '10').build(); const safeInfo = safeBuilder().build(); diff --git a/src/routes/safes/safes.service.ts b/src/routes/safes/safes.service.ts index cd4c23d200..68cf67d002 100644 --- a/src/routes/safes/safes.service.ts +++ b/src/routes/safes/safes.service.ts @@ -165,6 +165,7 @@ export class SafesService { ? this.computeAwaitingConfirmation({ transactions: queue.results, walletAddress: args.walletAddress, + threshold: safe.threshold, }) : null; @@ -206,11 +207,13 @@ export class SafesService { private computeAwaitingConfirmation(args: { transactions: Array; walletAddress: `0x${string}`; + threshold: number; }): number { return args.transactions.reduce( (acc, { confirmationsRequired, confirmations }) => { const isConfirmed = - !!confirmations && confirmations.length >= confirmationsRequired; + !!confirmations && + confirmations.length >= (confirmationsRequired ?? args.threshold); const isSignable = !isConfirmed && !confirmations?.some((confirmation) => { diff --git a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-info.mapper.spec.ts b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-info.mapper.spec.ts index f0bfb6a80f..8a7ca4600b 100644 --- a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-info.mapper.spec.ts +++ b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-info.mapper.spec.ts @@ -15,129 +15,266 @@ describe('Multisig Transaction execution info mapper (Unit)', () => { mapper = new MultisigTransactionExecutionInfoMapper(); }); - it('should return a MultiSigExecutionInfo with no missing signers', () => { - const safe = safeBuilder().build(); - const proposer = faker.finance.ethereumAddress(); - const transaction = multisigTransactionBuilder() - .with('proposer', getAddress(proposer)) - .build(); - - const executionInfo = mapper.mapExecutionInfo( - transaction, - safe, - TransactionStatus.Success, - ); - - expect(executionInfo).toEqual( - new MultisigExecutionInfo( - transaction.nonce, - transaction.confirmationsRequired, - Number(transaction.confirmations?.length), - null, - ), - ); - }); + describe('based on confirmationsRequired', () => { + it('should return a MultiSigExecutionInfo with no missing signers', () => { + const safe = safeBuilder().build(); + const proposer = faker.finance.ethereumAddress(); + const transaction = multisigTransactionBuilder() + .with('proposer', getAddress(proposer)) + .build(); - it('should return a MultiSigExecutionInfo with no missing signers and zero confirmations', () => { - const safe = safeBuilder().build(); - const transaction = multisigTransactionBuilder() - .with('confirmations', null) - .build(); - - const executionInfo = mapper.mapExecutionInfo( - transaction, - safe, - TransactionStatus.Success, - ); - - expect(executionInfo).toEqual( - new MultisigExecutionInfo( - transaction.nonce, - transaction.confirmationsRequired, - 0, - null, - ), - ); - }); + const executionInfo = mapper.mapExecutionInfo( + transaction, + safe, + TransactionStatus.Success, + ); - it('should return a MultiSigExecutionInfo with empty missing signers', () => { - const safe = safeBuilder().with('owners', []).build(); - const transaction = multisigTransactionBuilder().build(); - - const executionInfo = mapper.mapExecutionInfo( - transaction, - safe, - TransactionStatus.AwaitingConfirmations, - ); - - expect(executionInfo).toEqual( - new MultisigExecutionInfo( - transaction.nonce, - transaction.confirmationsRequired, - Number(transaction.confirmations?.length), - [], - ), - ); - }); + expect(executionInfo).toEqual( + new MultisigExecutionInfo( + transaction.nonce, + transaction.confirmationsRequired!, + Number(transaction.confirmations?.length), + null, + ), + ); + }); + + it('should return a MultiSigExecutionInfo with no missing signers and zero confirmations', () => { + const safe = safeBuilder().build(); + const transaction = multisigTransactionBuilder() + .with('confirmations', null) + .build(); + + const executionInfo = mapper.mapExecutionInfo( + transaction, + safe, + TransactionStatus.Success, + ); + + expect(executionInfo).toEqual( + new MultisigExecutionInfo( + transaction.nonce, + transaction.confirmationsRequired!, + 0, + null, + ), + ); + }); + + it('should return a MultiSigExecutionInfo with empty missing signers', () => { + const safe = safeBuilder().with('owners', []).build(); + const transaction = multisigTransactionBuilder().build(); + + const executionInfo = mapper.mapExecutionInfo( + transaction, + safe, + TransactionStatus.AwaitingConfirmations, + ); + + expect(executionInfo).toEqual( + new MultisigExecutionInfo( + transaction.nonce, + transaction.confirmationsRequired!, + Number(transaction.confirmations?.length), + [], + ), + ); + }); + + it('should return a MultiSigExecutionInfo with all safe owners as missing signers', () => { + const transaction = multisigTransactionBuilder().build(); + const safe = safeBuilder() + .with('owners', [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ]) + .build(); + + const executionInfo = mapper.mapExecutionInfo( + transaction, + safe, + TransactionStatus.AwaitingConfirmations, + ); + + expect(executionInfo).toEqual( + new MultisigExecutionInfo( + transaction.nonce, + transaction.confirmationsRequired!, + Number(transaction.confirmations?.length), + safe.owners.map((address) => new AddressInfo(address)), + ), + ); + }); + + it('should return a MultiSigExecutionInfo with some safe owners as missing signers', () => { + const confirmations = [ + confirmationBuilder() + .with('owner', getAddress(faker.finance.ethereumAddress())) + .build(), + confirmationBuilder() + .with('owner', getAddress(faker.finance.ethereumAddress())) + .build(), + ]; + const transaction = multisigTransactionBuilder() + .with('proposer', getAddress(confirmations[0].owner)) + .with('confirmations', confirmations) + .build(); + const safe = safeBuilder() + .with('owners', [ + getAddress(confirmations[0].owner), + getAddress(faker.finance.ethereumAddress()), + ]) + .build(); + + const executionInfo = mapper.mapExecutionInfo( + transaction, + safe, + TransactionStatus.AwaitingConfirmations, + ); - it('should return a MultiSigExecutionInfo with all safe owners as missing signers', () => { - const transaction = multisigTransactionBuilder().build(); - const safe = safeBuilder() - .with('owners', [ - getAddress(faker.finance.ethereumAddress()), - getAddress(faker.finance.ethereumAddress()), - ]) - .build(); - - const executionInfo = mapper.mapExecutionInfo( - transaction, - safe, - TransactionStatus.AwaitingConfirmations, - ); - - expect(executionInfo).toEqual( - new MultisigExecutionInfo( - transaction.nonce, - transaction.confirmationsRequired, - Number(transaction.confirmations?.length), - safe.owners.map((address) => new AddressInfo(address)), - ), - ); + expect(executionInfo).toEqual( + new MultisigExecutionInfo( + transaction.nonce, + transaction.confirmationsRequired!, + Number(transaction.confirmations?.length), + [new AddressInfo(safe.owners[1])], + ), + ); + }); }); - it('should return a MultiSigExecutionInfo with some safe owners as missing signers', () => { - const confirmations = [ - confirmationBuilder() - .with('owner', getAddress(faker.finance.ethereumAddress())) - .build(), - confirmationBuilder() - .with('owner', getAddress(faker.finance.ethereumAddress())) - .build(), - ]; - const transaction = multisigTransactionBuilder() - .with('proposer', getAddress(confirmations[0].owner)) - .with('confirmations', confirmations) - .build(); - const safe = safeBuilder() - .with('owners', [ - getAddress(confirmations[0].owner), - getAddress(faker.finance.ethereumAddress()), - ]) - .build(); - - const executionInfo = mapper.mapExecutionInfo( - transaction, - safe, - TransactionStatus.AwaitingConfirmations, - ); - - expect(executionInfo).toEqual( - new MultisigExecutionInfo( - transaction.nonce, - transaction.confirmationsRequired, - Number(transaction.confirmations?.length), - [new AddressInfo(safe.owners[1])], - ), - ); + describe('based on threshold when confirmationsRequired is null', () => { + it('should return a MultiSigExecutionInfo with no missing signers', () => { + const safe = safeBuilder().build(); + const proposer = faker.finance.ethereumAddress(); + const transaction = multisigTransactionBuilder() + .with('proposer', getAddress(proposer)) + .with('confirmationsRequired', null) + .build(); + + const executionInfo = mapper.mapExecutionInfo( + transaction, + safe, + TransactionStatus.Success, + ); + + expect(executionInfo).toEqual( + new MultisigExecutionInfo( + transaction.nonce, + safe.threshold, + Number(transaction.confirmations?.length), + null, + ), + ); + }); + + it('should return a MultiSigExecutionInfo with no missing signers and zero confirmations', () => { + const safe = safeBuilder().build(); + const transaction = multisigTransactionBuilder() + .with('confirmations', null) + .with('confirmationsRequired', null) + .build(); + + const executionInfo = mapper.mapExecutionInfo( + transaction, + safe, + TransactionStatus.Success, + ); + + expect(executionInfo).toEqual( + new MultisigExecutionInfo(transaction.nonce, safe.threshold, 0, null), + ); + }); + + it('should return a MultiSigExecutionInfo with empty missing signers', () => { + const safe = safeBuilder() + .with('owners', []) + .with('threshold', 0) + .build(); + const transaction = multisigTransactionBuilder() + .with('confirmationsRequired', null) + .build(); + + const executionInfo = mapper.mapExecutionInfo( + transaction, + safe, + TransactionStatus.AwaitingConfirmations, + ); + + expect(executionInfo).toEqual( + new MultisigExecutionInfo( + transaction.nonce, + safe.threshold, + Number(transaction.confirmations?.length), + [], + ), + ); + }); + + it('should return a MultiSigExecutionInfo with all safe owners as missing signers', () => { + const transaction = multisigTransactionBuilder() + .with('confirmationsRequired', null) + .build(); + const safe = safeBuilder() + .with('owners', [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ]) + .with('threshold', 2) + .build(); + + const executionInfo = mapper.mapExecutionInfo( + transaction, + safe, + TransactionStatus.AwaitingConfirmations, + ); + + expect(executionInfo).toEqual( + new MultisigExecutionInfo( + transaction.nonce, + safe.threshold, + Number(transaction.confirmations?.length), + safe.owners.map((address) => new AddressInfo(address)), + ), + ); + }); + + it('should return a MultiSigExecutionInfo with some safe owners as missing signers', () => { + const confirmations = [ + confirmationBuilder() + .with('owner', getAddress(faker.finance.ethereumAddress())) + .build(), + confirmationBuilder() + .with('owner', getAddress(faker.finance.ethereumAddress())) + .build(), + ]; + const transaction = multisigTransactionBuilder() + .with('proposer', getAddress(confirmations[0].owner)) + .with('confirmations', confirmations) + .with('confirmationsRequired', null) + .build(); + const safe = safeBuilder() + .with('owners', [ + getAddress(confirmations[0].owner), + getAddress(faker.finance.ethereumAddress()), + ]) + .with('threshold', 2) + .build(); + + const executionInfo = mapper.mapExecutionInfo( + transaction, + safe, + TransactionStatus.AwaitingConfirmations, + ); + + expect(executionInfo).toEqual( + new MultisigExecutionInfo( + transaction.nonce, + safe.threshold, + Number(transaction.confirmations?.length), + [new AddressInfo(safe.owners[1])], + ), + ); + }); }); }); diff --git a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-info.mapper.ts b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-info.mapper.ts index 87cce198d9..2c7dd2ddd3 100644 --- a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-info.mapper.ts +++ b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-info.mapper.ts @@ -19,7 +19,7 @@ export class MultisigTransactionExecutionInfoMapper { return new MultisigExecutionInfo( transaction.nonce, - transaction.confirmationsRequired, + transaction.confirmationsRequired ?? safe.threshold, transaction?.confirmations?.length || 0, missingSigners, ); diff --git a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-status.mapper.spec.ts b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-status.mapper.spec.ts index 20e87f8d59..da6060bc90 100644 --- a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-status.mapper.spec.ts +++ b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-status.mapper.spec.ts @@ -64,7 +64,7 @@ describe('Multisig Transaction status mapper (Unit)', () => { ); }); - it('should return an AWAITING_EXECUTION status', () => { + it('should return an AWAITING_EXECUTION status based on confirmationsRequired', () => { const transaction = multisigTransactionBuilder() .with('isExecuted', false) .with('nonce', 4) @@ -80,4 +80,21 @@ describe('Multisig Transaction status mapper (Unit)', () => { TransactionStatus.AwaitingExecution, ); }); + + it('should return an AWAITING_EXECUTION status based on threshold if confirmationsRequired is null', () => { + const transaction = multisigTransactionBuilder() + .with('isExecuted', false) + .with('nonce', 4) + .with('confirmations', [ + confirmationBuilder().build(), + confirmationBuilder().build(), + ]) + .with('confirmationsRequired', null) + .build(); + const safe = safeBuilder().with('nonce', 3).with('threshold', 1).build(); + + expect(mapper.mapTransactionStatus(transaction, safe)).toBe( + TransactionStatus.AwaitingExecution, + ); + }); }); diff --git a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-status.mapper.ts b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-status.mapper.ts index 5dabeb576d..67139dc4cd 100644 --- a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-status.mapper.ts +++ b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-status.mapper.ts @@ -19,7 +19,7 @@ export class MultisigTransactionStatusMapper { } if ( (transaction.confirmations?.length || 0) < - transaction.confirmationsRequired + (transaction.confirmationsRequired ?? safe.threshold) ) { return TransactionStatus.AwaitingConfirmations; } From 538fb8c6a261bb7a5bb45378d73b4e2ea7fd6380 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 27 May 2024 13:19:04 +0200 Subject: [PATCH 030/207] Improve flakiness of `TransactionsViewController` tests (#1585) Changes the mock `appCode` value in "Gets Generic confirmation view if swap app is restricted" test of the `TransactionsViewController` tests to ensure it isn't the same as that of mocked `verifiedApp` value. --- src/routes/transactions/transactions-view.controller.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/routes/transactions/transactions-view.controller.spec.ts b/src/routes/transactions/transactions-view.controller.spec.ts index 192a84c654..a59ff033fd 100644 --- a/src/routes/transactions/transactions-view.controller.spec.ts +++ b/src/routes/transactions/transactions-view.controller.spec.ts @@ -361,7 +361,8 @@ describe('TransactionsViewController tests', () => { const preSignature = preSignatureEncoder.build(); const order = orderBuilder() .with('uid', preSignature.orderUid) - .with('fullAppData', `{ "appCode": "${faker.company.buzzNoun()}" }`) + // We don't use buzzNoun here as it can generate the same value as verifiedApp + .with('fullAppData', `{ "appCode": "restrited app code" }`) .build(); const buyToken = tokenBuilder().with('address', order.buyToken).build(); const sellToken = tokenBuilder().with('address', order.sellToken).build(); From 0365b15705a81ab25d584614310c1a0e869dd8a8 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 27 May 2024 16:38:06 +0200 Subject: [PATCH 031/207] =?UTF-8?q?Revert=20"Fallback=20to=20`threshold`?= =?UTF-8?q?=20if=20`confirmationsRequired`=20of=20transaction=20is=20?= =?UTF-8?q?=E2=80=A6"=20(#1586)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts safe-global/safe-client-gateway#1584 This adjustment is no longer necessary as it solved what was later to be deemed indexing issues. --- .../entities/multisig-transaction.entity.ts | 2 +- .../safes/safes.controller.overview.spec.ts | 161 -------- src/routes/safes/safes.service.ts | 5 +- ...-transaction-execution-info.mapper.spec.ts | 375 ++++++------------ ...tisig-transaction-execution-info.mapper.ts | 2 +- ...multisig-transaction-status.mapper.spec.ts | 19 +- .../multisig-transaction-status.mapper.ts | 2 +- 7 files changed, 124 insertions(+), 442 deletions(-) diff --git a/src/domain/safe/entities/multisig-transaction.entity.ts b/src/domain/safe/entities/multisig-transaction.entity.ts index 8c88410d8a..9e604af10a 100644 --- a/src/domain/safe/entities/multisig-transaction.entity.ts +++ b/src/domain/safe/entities/multisig-transaction.entity.ts @@ -46,7 +46,7 @@ export const MultisigTransactionSchema = z.object({ gasUsed: z.number().nullish().default(null), fee: NumericStringSchema.nullish().default(null), origin: z.string().nullish().default(null), - confirmationsRequired: z.number().nullish().default(null), + confirmationsRequired: z.number(), confirmations: z.array(ConfirmationSchema).nullish().default(null), signatures: HexSchema.nullish().default(null), trusted: z.boolean(), diff --git a/src/routes/safes/safes.controller.overview.spec.ts b/src/routes/safes/safes.controller.overview.spec.ts index 18a8c84486..7446889cc0 100644 --- a/src/routes/safes/safes.controller.overview.spec.ts +++ b/src/routes/safes/safes.controller.overview.spec.ts @@ -248,167 +248,6 @@ describe('Safes Controller Overview (Unit)', () => { ); }); - it('overview with transactions awaiting confirmation is correctly serialised from threshold if confirmationsRequired is null', async () => { - const chain = chainBuilder().with('chainId', '10').build(); - const safeInfo = safeBuilder().build(); - const tokenAddress = faker.finance.ethereumAddress(); - const secondTokenAddress = faker.finance.ethereumAddress(); - const transactionApiBalancesResponse = [ - balanceBuilder() - .with('tokenAddress', null) - .with('balance', '3000000000000000000') - .with('token', null) - .build(), - balanceBuilder() - .with('tokenAddress', getAddress(tokenAddress)) - .with('balance', '4000000000000000000') - .with('token', balanceTokenBuilder().with('decimals', 17).build()) - .build(), - balanceBuilder() - .with('tokenAddress', getAddress(secondTokenAddress)) - .with('balance', '3000000000000000000') - .with('token', balanceTokenBuilder().with('decimals', 17).build()) - .build(), - ]; - const currency = faker.finance.currencyCode(); - const nativeCoinPriceProviderResponse = { - // @ts-expect-error - TODO: remove after migration - [chain.pricesProvider.nativeCoin!]: { - [currency.toLowerCase()]: 1536.75, - }, - }; - const tokenPriceProviderResponse = { - [tokenAddress]: { [currency.toLowerCase()]: 12.5 }, - [secondTokenAddress]: { [currency.toLowerCase()]: 10 }, - }; - const walletAddress = getAddress(faker.finance.ethereumAddress()); - const multisigTransactions = [ - multisigTransactionToJson( - multisigTransactionBuilder() - .with('confirmationsRequired', null) - .with('confirmations', [ - // Signature provided - confirmationBuilder().with('owner', walletAddress).build(), - ]) - .build(), - ), - multisigTransactionToJson(multisigTransactionBuilder().build()), - ]; - const queuedTransactions = pageBuilder() - .with('results', multisigTransactions) - .with('count', multisigTransactions.length) - .build(); - - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: { - return Promise.resolve({ data: chain, status: 200 }); - } - case `${chain.transactionService}/api/v1/safes/${safeInfo.address}`: { - return Promise.resolve({ data: safeInfo, status: 200 }); - } - case `${chain.transactionService}/api/v1/safes/${safeInfo.address}/balances/`: { - return Promise.resolve({ - data: transactionApiBalancesResponse, - status: 200, - }); - } - case `${pricesProviderUrl}/simple/price`: { - return Promise.resolve({ - data: nativeCoinPriceProviderResponse, - status: 200, - }); - } - // @ts-expect-error - TODO: remove after migration - case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: { - return Promise.resolve({ - data: tokenPriceProviderResponse, - status: 200, - }); - } - case `${chain.transactionService}/api/v1/safes/${safeInfo.address}/multisig-transactions/`: { - return Promise.resolve({ - data: queuedTransactions, - status: 200, - }); - } - default: { - return Promise.reject(`No matching rule for url: ${url}`); - } - } - }); - - await request(app.getHttpServer()) - .get( - `/v1/safes?currency=${currency}&safes=${chain.chainId}:${safeInfo.address}&wallet_address=${walletAddress}`, - ) - .expect(200) - .expect(({ body }) => - expect(body).toMatchObject([ - { - address: { - value: safeInfo.address, - name: null, - logoUri: null, - }, - chainId: chain.chainId, - threshold: safeInfo.threshold, - owners: safeInfo.owners.map((owner) => ({ - value: owner, - name: null, - logoUri: null, - })), - fiatTotal: '5410.25', - queued: 2, - awaitingConfirmation: 1, - }, - ]), - ); - - expect(networkService.get.mock.calls.length).toBe(6); - - expect(networkService.get.mock.calls[0][0].url).toBe( - `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, - ); - expect(networkService.get.mock.calls[1][0].url).toBe( - `${chain.transactionService}/api/v1/safes/${safeInfo.address}`, - ); - expect(networkService.get.mock.calls[2][0].url).toBe( - `${chain.transactionService}/api/v1/safes/${safeInfo.address}/balances/`, - ); - expect(networkService.get.mock.calls[2][0].networkRequest).toStrictEqual({ - params: { trusted: false, exclude_spam: true }, - }); - expect(networkService.get.mock.calls[3][0].url).toBe( - // @ts-expect-error - TODO: remove after migration - `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, - ); - expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ - headers: { 'x-cg-pro-api-key': pricesApiKey }, - params: { - vs_currencies: currency.toLowerCase(), - contract_addresses: [ - tokenAddress.toLowerCase(), - secondTokenAddress.toLowerCase(), - ].join(','), - }, - }); - expect(networkService.get.mock.calls[4][0].url).toBe( - `${pricesProviderUrl}/simple/price`, - ); - expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ - headers: { 'x-cg-pro-api-key': pricesApiKey }, - params: { - // @ts-expect-error - TODO: remove after migration - ids: chain.pricesProvider.nativeCoin, - vs_currencies: currency.toLowerCase(), - }, - }); - expect(networkService.get.mock.calls[5][0].url).toBe( - `${chain.transactionService}/api/v1/safes/${safeInfo.address}/multisig-transactions/`, - ); - }); - it('should not return awaiting confirmations if no more confirmations are required', async () => { const chain = chainBuilder().with('chainId', '10').build(); const safeInfo = safeBuilder().build(); diff --git a/src/routes/safes/safes.service.ts b/src/routes/safes/safes.service.ts index 68cf67d002..cd4c23d200 100644 --- a/src/routes/safes/safes.service.ts +++ b/src/routes/safes/safes.service.ts @@ -165,7 +165,6 @@ export class SafesService { ? this.computeAwaitingConfirmation({ transactions: queue.results, walletAddress: args.walletAddress, - threshold: safe.threshold, }) : null; @@ -207,13 +206,11 @@ export class SafesService { private computeAwaitingConfirmation(args: { transactions: Array; walletAddress: `0x${string}`; - threshold: number; }): number { return args.transactions.reduce( (acc, { confirmationsRequired, confirmations }) => { const isConfirmed = - !!confirmations && - confirmations.length >= (confirmationsRequired ?? args.threshold); + !!confirmations && confirmations.length >= confirmationsRequired; const isSignable = !isConfirmed && !confirmations?.some((confirmation) => { diff --git a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-info.mapper.spec.ts b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-info.mapper.spec.ts index 8a7ca4600b..f0bfb6a80f 100644 --- a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-info.mapper.spec.ts +++ b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-info.mapper.spec.ts @@ -15,266 +15,129 @@ describe('Multisig Transaction execution info mapper (Unit)', () => { mapper = new MultisigTransactionExecutionInfoMapper(); }); - describe('based on confirmationsRequired', () => { - it('should return a MultiSigExecutionInfo with no missing signers', () => { - const safe = safeBuilder().build(); - const proposer = faker.finance.ethereumAddress(); - const transaction = multisigTransactionBuilder() - .with('proposer', getAddress(proposer)) - .build(); - - const executionInfo = mapper.mapExecutionInfo( - transaction, - safe, - TransactionStatus.Success, - ); - - expect(executionInfo).toEqual( - new MultisigExecutionInfo( - transaction.nonce, - transaction.confirmationsRequired!, - Number(transaction.confirmations?.length), - null, - ), - ); - }); - - it('should return a MultiSigExecutionInfo with no missing signers and zero confirmations', () => { - const safe = safeBuilder().build(); - const transaction = multisigTransactionBuilder() - .with('confirmations', null) - .build(); - - const executionInfo = mapper.mapExecutionInfo( - transaction, - safe, - TransactionStatus.Success, - ); - - expect(executionInfo).toEqual( - new MultisigExecutionInfo( - transaction.nonce, - transaction.confirmationsRequired!, - 0, - null, - ), - ); - }); - - it('should return a MultiSigExecutionInfo with empty missing signers', () => { - const safe = safeBuilder().with('owners', []).build(); - const transaction = multisigTransactionBuilder().build(); - - const executionInfo = mapper.mapExecutionInfo( - transaction, - safe, - TransactionStatus.AwaitingConfirmations, - ); - - expect(executionInfo).toEqual( - new MultisigExecutionInfo( - transaction.nonce, - transaction.confirmationsRequired!, - Number(transaction.confirmations?.length), - [], - ), - ); - }); - - it('should return a MultiSigExecutionInfo with all safe owners as missing signers', () => { - const transaction = multisigTransactionBuilder().build(); - const safe = safeBuilder() - .with('owners', [ - getAddress(faker.finance.ethereumAddress()), - getAddress(faker.finance.ethereumAddress()), - ]) - .build(); - - const executionInfo = mapper.mapExecutionInfo( - transaction, - safe, - TransactionStatus.AwaitingConfirmations, - ); - - expect(executionInfo).toEqual( - new MultisigExecutionInfo( - transaction.nonce, - transaction.confirmationsRequired!, - Number(transaction.confirmations?.length), - safe.owners.map((address) => new AddressInfo(address)), - ), - ); - }); - - it('should return a MultiSigExecutionInfo with some safe owners as missing signers', () => { - const confirmations = [ - confirmationBuilder() - .with('owner', getAddress(faker.finance.ethereumAddress())) - .build(), - confirmationBuilder() - .with('owner', getAddress(faker.finance.ethereumAddress())) - .build(), - ]; - const transaction = multisigTransactionBuilder() - .with('proposer', getAddress(confirmations[0].owner)) - .with('confirmations', confirmations) - .build(); - const safe = safeBuilder() - .with('owners', [ - getAddress(confirmations[0].owner), - getAddress(faker.finance.ethereumAddress()), - ]) - .build(); - - const executionInfo = mapper.mapExecutionInfo( - transaction, - safe, - TransactionStatus.AwaitingConfirmations, - ); - - expect(executionInfo).toEqual( - new MultisigExecutionInfo( - transaction.nonce, - transaction.confirmationsRequired!, - Number(transaction.confirmations?.length), - [new AddressInfo(safe.owners[1])], - ), - ); - }); + it('should return a MultiSigExecutionInfo with no missing signers', () => { + const safe = safeBuilder().build(); + const proposer = faker.finance.ethereumAddress(); + const transaction = multisigTransactionBuilder() + .with('proposer', getAddress(proposer)) + .build(); + + const executionInfo = mapper.mapExecutionInfo( + transaction, + safe, + TransactionStatus.Success, + ); + + expect(executionInfo).toEqual( + new MultisigExecutionInfo( + transaction.nonce, + transaction.confirmationsRequired, + Number(transaction.confirmations?.length), + null, + ), + ); }); - describe('based on threshold when confirmationsRequired is null', () => { - it('should return a MultiSigExecutionInfo with no missing signers', () => { - const safe = safeBuilder().build(); - const proposer = faker.finance.ethereumAddress(); - const transaction = multisigTransactionBuilder() - .with('proposer', getAddress(proposer)) - .with('confirmationsRequired', null) - .build(); - - const executionInfo = mapper.mapExecutionInfo( - transaction, - safe, - TransactionStatus.Success, - ); - - expect(executionInfo).toEqual( - new MultisigExecutionInfo( - transaction.nonce, - safe.threshold, - Number(transaction.confirmations?.length), - null, - ), - ); - }); - - it('should return a MultiSigExecutionInfo with no missing signers and zero confirmations', () => { - const safe = safeBuilder().build(); - const transaction = multisigTransactionBuilder() - .with('confirmations', null) - .with('confirmationsRequired', null) - .build(); - - const executionInfo = mapper.mapExecutionInfo( - transaction, - safe, - TransactionStatus.Success, - ); - - expect(executionInfo).toEqual( - new MultisigExecutionInfo(transaction.nonce, safe.threshold, 0, null), - ); - }); - - it('should return a MultiSigExecutionInfo with empty missing signers', () => { - const safe = safeBuilder() - .with('owners', []) - .with('threshold', 0) - .build(); - const transaction = multisigTransactionBuilder() - .with('confirmationsRequired', null) - .build(); - - const executionInfo = mapper.mapExecutionInfo( - transaction, - safe, - TransactionStatus.AwaitingConfirmations, - ); - - expect(executionInfo).toEqual( - new MultisigExecutionInfo( - transaction.nonce, - safe.threshold, - Number(transaction.confirmations?.length), - [], - ), - ); - }); - - it('should return a MultiSigExecutionInfo with all safe owners as missing signers', () => { - const transaction = multisigTransactionBuilder() - .with('confirmationsRequired', null) - .build(); - const safe = safeBuilder() - .with('owners', [ - getAddress(faker.finance.ethereumAddress()), - getAddress(faker.finance.ethereumAddress()), - ]) - .with('threshold', 2) - .build(); - - const executionInfo = mapper.mapExecutionInfo( - transaction, - safe, - TransactionStatus.AwaitingConfirmations, - ); - - expect(executionInfo).toEqual( - new MultisigExecutionInfo( - transaction.nonce, - safe.threshold, - Number(transaction.confirmations?.length), - safe.owners.map((address) => new AddressInfo(address)), - ), - ); - }); + it('should return a MultiSigExecutionInfo with no missing signers and zero confirmations', () => { + const safe = safeBuilder().build(); + const transaction = multisigTransactionBuilder() + .with('confirmations', null) + .build(); + + const executionInfo = mapper.mapExecutionInfo( + transaction, + safe, + TransactionStatus.Success, + ); + + expect(executionInfo).toEqual( + new MultisigExecutionInfo( + transaction.nonce, + transaction.confirmationsRequired, + 0, + null, + ), + ); + }); - it('should return a MultiSigExecutionInfo with some safe owners as missing signers', () => { - const confirmations = [ - confirmationBuilder() - .with('owner', getAddress(faker.finance.ethereumAddress())) - .build(), - confirmationBuilder() - .with('owner', getAddress(faker.finance.ethereumAddress())) - .build(), - ]; - const transaction = multisigTransactionBuilder() - .with('proposer', getAddress(confirmations[0].owner)) - .with('confirmations', confirmations) - .with('confirmationsRequired', null) - .build(); - const safe = safeBuilder() - .with('owners', [ - getAddress(confirmations[0].owner), - getAddress(faker.finance.ethereumAddress()), - ]) - .with('threshold', 2) - .build(); + it('should return a MultiSigExecutionInfo with empty missing signers', () => { + const safe = safeBuilder().with('owners', []).build(); + const transaction = multisigTransactionBuilder().build(); + + const executionInfo = mapper.mapExecutionInfo( + transaction, + safe, + TransactionStatus.AwaitingConfirmations, + ); + + expect(executionInfo).toEqual( + new MultisigExecutionInfo( + transaction.nonce, + transaction.confirmationsRequired, + Number(transaction.confirmations?.length), + [], + ), + ); + }); - const executionInfo = mapper.mapExecutionInfo( - transaction, - safe, - TransactionStatus.AwaitingConfirmations, - ); + it('should return a MultiSigExecutionInfo with all safe owners as missing signers', () => { + const transaction = multisigTransactionBuilder().build(); + const safe = safeBuilder() + .with('owners', [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ]) + .build(); + + const executionInfo = mapper.mapExecutionInfo( + transaction, + safe, + TransactionStatus.AwaitingConfirmations, + ); + + expect(executionInfo).toEqual( + new MultisigExecutionInfo( + transaction.nonce, + transaction.confirmationsRequired, + Number(transaction.confirmations?.length), + safe.owners.map((address) => new AddressInfo(address)), + ), + ); + }); - expect(executionInfo).toEqual( - new MultisigExecutionInfo( - transaction.nonce, - safe.threshold, - Number(transaction.confirmations?.length), - [new AddressInfo(safe.owners[1])], - ), - ); - }); + it('should return a MultiSigExecutionInfo with some safe owners as missing signers', () => { + const confirmations = [ + confirmationBuilder() + .with('owner', getAddress(faker.finance.ethereumAddress())) + .build(), + confirmationBuilder() + .with('owner', getAddress(faker.finance.ethereumAddress())) + .build(), + ]; + const transaction = multisigTransactionBuilder() + .with('proposer', getAddress(confirmations[0].owner)) + .with('confirmations', confirmations) + .build(); + const safe = safeBuilder() + .with('owners', [ + getAddress(confirmations[0].owner), + getAddress(faker.finance.ethereumAddress()), + ]) + .build(); + + const executionInfo = mapper.mapExecutionInfo( + transaction, + safe, + TransactionStatus.AwaitingConfirmations, + ); + + expect(executionInfo).toEqual( + new MultisigExecutionInfo( + transaction.nonce, + transaction.confirmationsRequired, + Number(transaction.confirmations?.length), + [new AddressInfo(safe.owners[1])], + ), + ); }); }); diff --git a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-info.mapper.ts b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-info.mapper.ts index 2c7dd2ddd3..87cce198d9 100644 --- a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-info.mapper.ts +++ b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-info.mapper.ts @@ -19,7 +19,7 @@ export class MultisigTransactionExecutionInfoMapper { return new MultisigExecutionInfo( transaction.nonce, - transaction.confirmationsRequired ?? safe.threshold, + transaction.confirmationsRequired, transaction?.confirmations?.length || 0, missingSigners, ); diff --git a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-status.mapper.spec.ts b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-status.mapper.spec.ts index da6060bc90..20e87f8d59 100644 --- a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-status.mapper.spec.ts +++ b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-status.mapper.spec.ts @@ -64,7 +64,7 @@ describe('Multisig Transaction status mapper (Unit)', () => { ); }); - it('should return an AWAITING_EXECUTION status based on confirmationsRequired', () => { + it('should return an AWAITING_EXECUTION status', () => { const transaction = multisigTransactionBuilder() .with('isExecuted', false) .with('nonce', 4) @@ -80,21 +80,4 @@ describe('Multisig Transaction status mapper (Unit)', () => { TransactionStatus.AwaitingExecution, ); }); - - it('should return an AWAITING_EXECUTION status based on threshold if confirmationsRequired is null', () => { - const transaction = multisigTransactionBuilder() - .with('isExecuted', false) - .with('nonce', 4) - .with('confirmations', [ - confirmationBuilder().build(), - confirmationBuilder().build(), - ]) - .with('confirmationsRequired', null) - .build(); - const safe = safeBuilder().with('nonce', 3).with('threshold', 1).build(); - - expect(mapper.mapTransactionStatus(transaction, safe)).toBe( - TransactionStatus.AwaitingExecution, - ); - }); }); diff --git a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-status.mapper.ts b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-status.mapper.ts index 67139dc4cd..5dabeb576d 100644 --- a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-status.mapper.ts +++ b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-status.mapper.ts @@ -19,7 +19,7 @@ export class MultisigTransactionStatusMapper { } if ( (transaction.confirmations?.length || 0) < - (transaction.confirmationsRequired ?? safe.threshold) + transaction.confirmationsRequired ) { return TransactionStatus.AwaitingConfirmations; } From 3e1d13872d45d57b6e5d465a33b6fe2ab5205774 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 27 May 2024 16:50:09 +0200 Subject: [PATCH 032/207] Remove unnecessary fallback for `confirmationsRequired` (#1587) Removes unnecessary fallback value for `MultisigTransaction['confirmationsRequired']` when returning `MultisigExecutionDetails`. --- .../multisig-transaction-execution-details.mapper.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-details.mapper.ts b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-details.mapper.ts index d43b9c2b6a..6dcd230872 100644 --- a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-details.mapper.ts +++ b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-execution-details.mapper.ts @@ -30,8 +30,6 @@ export class MultisigTransactionExecutionDetailsMapper { ): Promise { const signers = safe.owners.map((owner) => new AddressInfo(owner)); const gasToken = transaction.gasToken ?? NULL_ADDRESS; - const confirmationsRequired = - transaction.confirmationsRequired ?? safe.threshold; const confirmations = !transaction.confirmations ? [] : transaction.confirmations.map( @@ -75,7 +73,7 @@ export class MultisigTransactionExecutionDetailsMapper { transaction.safeTxHash, executor, signers, - confirmationsRequired, + transaction.confirmationsRequired, confirmations, rejectors, gasTokenInfo, From d83fd4103e79c6ade23f4d2d25be1a1009f3e597 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 27 May 2024 17:10:37 +0200 Subject: [PATCH 033/207] Checksum `safeAddress` when getting relay count (#1580) Checksums the incoming `safeAddress` of `RelayController['getRelaysRemaining']` and propagates the stricter (`0x${string}`) type throughout the project accordingly: - Add checksumming and validation to `RelayController['getRelaysRemaining']` - Propagate type scrictness across: - relay-related cache keys - `IRelayApi` and its implementors (`GelatoApi`) - relay-related builders/encoders - `RelayLimitReachedError['address']` - `LimitAddressesMapper` - `RelayRepository` - `RelayDto` - `RelayController` - `RelayService --- src/datasources/cache/cache.router.ts | 7 ++- .../relay-api/gelato-api.service.ts | 23 +++------- src/domain/interfaces/relay-api.interface.ts | 9 ++-- .../encoders/erc20-encoder.builder.ts | 16 +++---- .../encoders/proxy-factory-encoder.builder.ts | 8 ++-- .../__tests__/erc20-decoder.helper.spec.ts | 3 +- .../proxy-factory-decoder.helper.spec.ts | 3 +- .../relay/errors/relay-limit-reached.error.ts | 3 +- src/domain/relay/limit-addresses.mapper.ts | 44 ++++++++++++------- src/domain/relay/relay.repository.ts | 6 +-- src/routes/relay/entities/relay.dto.entity.ts | 5 +-- src/routes/relay/relay.controller.ts | 4 +- src/routes/relay/relay.service.ts | 2 +- 13 files changed, 68 insertions(+), 65 deletions(-) diff --git a/src/datasources/cache/cache.router.ts b/src/datasources/cache/cache.router.ts index dc83a63e4c..2bac62dc01 100644 --- a/src/datasources/cache/cache.router.ts +++ b/src/datasources/cache/cache.router.ts @@ -420,13 +420,16 @@ export class CacheRouter { return new CacheDir(CacheRouter.getChainCacheKey(chainId), ''); } - static getRelayKey(args: { chainId: string; address: string }): string { + static getRelayKey(args: { + chainId: string; + address: `0x${string}`; + }): string { return `${args.chainId}_${CacheRouter.RELAY_KEY}_${args.address}`; } static getRelayCacheDir(args: { chainId: string; - address: string; + address: `0x${string}`; }): CacheDir { return new CacheDir(CacheRouter.getRelayKey(args), ''); } diff --git a/src/datasources/relay-api/gelato-api.service.ts b/src/datasources/relay-api/gelato-api.service.ts index 0bf9a88671..a66fc3cea8 100644 --- a/src/datasources/relay-api/gelato-api.service.ts +++ b/src/datasources/relay-api/gelato-api.service.ts @@ -11,8 +11,6 @@ import { CacheService, ICacheService, } from '@/datasources/cache/cache.service.interface'; -import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity'; -import { getAddress } from 'viem'; @Injectable() export class GelatoApi implements IRelayApi { @@ -42,7 +40,7 @@ export class GelatoApi implements IRelayApi { async relay(args: { chainId: string; - to: string; + to: `0x${string}`; data: string; gasLimit: bigint | null; }): Promise<{ taskId: string }> { @@ -76,34 +74,23 @@ export class GelatoApi implements IRelayApi { async getRelayCount(args: { chainId: string; - address: string; + address: `0x${string}`; }): Promise { - const cacheDir = this.getRelayCacheKey(args); + const cacheDir = CacheRouter.getRelayCacheDir(args); const count = await this.cacheService.get(cacheDir); return count ? parseInt(count) : 0; } async setRelayCount(args: { chainId: string; - address: string; + address: `0x${string}`; count: number; }): Promise { - const cacheDir = this.getRelayCacheKey(args); + const cacheDir = CacheRouter.getRelayCacheDir(args); await this.cacheService.set( cacheDir, args.count.toString(), this.ttlSeconds, ); } - - private getRelayCacheKey(args: { - chainId: string; - address: string; - }): CacheDir { - return CacheRouter.getRelayCacheDir({ - chainId: args.chainId, - // Ensure address is checksummed to always have a consistent cache key - address: getAddress(args.address), - }); - } } diff --git a/src/domain/interfaces/relay-api.interface.ts b/src/domain/interfaces/relay-api.interface.ts index 25deedbd97..8191312eb0 100644 --- a/src/domain/interfaces/relay-api.interface.ts +++ b/src/domain/interfaces/relay-api.interface.ts @@ -3,16 +3,19 @@ export const IRelayApi = Symbol('IRelayApi'); export interface IRelayApi { relay(args: { chainId: string; - to: string; + to: `0x${string}`; data: string; gasLimit: bigint | null; }): Promise<{ taskId: string }>; - getRelayCount(args: { chainId: string; address: string }): Promise; + getRelayCount(args: { + chainId: string; + address: `0x${string}`; + }): Promise; setRelayCount(args: { chainId: string; - address: string; + address: `0x${string}`; count: number; }): Promise; } diff --git a/src/domain/relay/contracts/__tests__/encoders/erc20-encoder.builder.ts b/src/domain/relay/contracts/__tests__/encoders/erc20-encoder.builder.ts index f74a61f9dd..d41898134f 100644 --- a/src/domain/relay/contracts/__tests__/encoders/erc20-encoder.builder.ts +++ b/src/domain/relay/contracts/__tests__/encoders/erc20-encoder.builder.ts @@ -1,12 +1,12 @@ import { faker } from '@faker-js/faker'; -import { Hex, encodeFunctionData, getAddress, erc20Abi } from 'viem'; +import { encodeFunctionData, getAddress, erc20Abi } from 'viem'; import { Builder } from '@/__tests__/builder'; import { IEncoder } from '@/__tests__/encoder-builder'; // transfer type Erc20TransferArgs = { - to: Hex; + to: `0x${string}`; value: bigint; }; @@ -14,7 +14,7 @@ class Erc20TransferEncoder extends Builder implements IEncoder { - encode(): Hex { + encode(): `0x${string}` { const args = this.build(); return encodeFunctionData({ @@ -34,8 +34,8 @@ export function erc20TransferEncoder(): Erc20TransferEncoder // transferFrom type Erc20TransferFromArgs = { - sender: Hex; - recipient: Hex; + sender: `0x${string}`; + recipient: `0x${string}`; amount: bigint; }; @@ -43,7 +43,7 @@ class Erc20TransferFromEncoder extends Builder implements IEncoder { - encode(): Hex { + encode(): `0x${string}` { const args = this.build(); return encodeFunctionData({ @@ -64,7 +64,7 @@ export function erc20TransferFromEncoder(): Erc20TransferFromEncoder extends Builder implements IEncoder { - encode(): Hex { + encode(): `0x${string}` { const args = this.build(); return encodeFunctionData({ diff --git a/src/domain/relay/contracts/__tests__/encoders/proxy-factory-encoder.builder.ts b/src/domain/relay/contracts/__tests__/encoders/proxy-factory-encoder.builder.ts index 19dc1ad1f6..a23e0f0f4a 100644 --- a/src/domain/relay/contracts/__tests__/encoders/proxy-factory-encoder.builder.ts +++ b/src/domain/relay/contracts/__tests__/encoders/proxy-factory-encoder.builder.ts @@ -1,5 +1,5 @@ import { faker } from '@faker-js/faker'; -import { encodeFunctionData, getAddress, Hex } from 'viem'; +import { encodeFunctionData, getAddress } from 'viem'; import ProxyFactory130 from '@/abis/safe/v1.3.0/GnosisSafeProxyFactory.abi'; import { IEncoder } from '@/__tests__/encoder-builder'; import { Builder } from '@/__tests__/builder'; @@ -8,8 +8,8 @@ import { setupEncoder } from '@/domain/contracts/__tests__/encoders/safe-encoder // createProxyWithNonce type CreateProxyWithNonceArgs = { - singleton: Hex; - initializer: Hex; + singleton: `0x${string}`; + initializer: `0x${string}`; saltNonce: bigint; }; @@ -17,7 +17,7 @@ class SetupEncoder extends Builder implements IEncoder { - encode(): Hex { + encode(): `0x${string}` { const args = this.build(); return encodeFunctionData({ diff --git a/src/domain/relay/contracts/decoders/__tests__/erc20-decoder.helper.spec.ts b/src/domain/relay/contracts/decoders/__tests__/erc20-decoder.helper.spec.ts index 73c46bef5c..9c3b2f53a4 100644 --- a/src/domain/relay/contracts/decoders/__tests__/erc20-decoder.helper.spec.ts +++ b/src/domain/relay/contracts/decoders/__tests__/erc20-decoder.helper.spec.ts @@ -1,4 +1,3 @@ -import { Hex } from 'viem'; import { faker } from '@faker-js/faker'; import { Erc20Decoder } from '@/domain/relay/contracts/decoders/erc-20-decoder.helper'; import { erc20TransferEncoder } from '@/domain/relay/contracts/__tests__/encoders/erc20-encoder.builder'; @@ -23,7 +22,7 @@ describe('Erc20Decoder', () => { }); it('throws if the function call cannot be decoded', () => { - const data = faker.string.hexadecimal({ length: 138 }) as Hex; + const data = faker.string.hexadecimal({ length: 138 }) as `0x${string}`; expect(() => target.decodeFunctionData({ data })).toThrow(); }); diff --git a/src/domain/relay/contracts/decoders/__tests__/proxy-factory-decoder.helper.spec.ts b/src/domain/relay/contracts/decoders/__tests__/proxy-factory-decoder.helper.spec.ts index f20466160c..ec51849b9d 100644 --- a/src/domain/relay/contracts/decoders/__tests__/proxy-factory-decoder.helper.spec.ts +++ b/src/domain/relay/contracts/decoders/__tests__/proxy-factory-decoder.helper.spec.ts @@ -1,4 +1,3 @@ -import { Hex } from 'viem'; import { faker } from '@faker-js/faker'; import { ProxyFactoryDecoder } from '@/domain/relay/contracts/decoders/proxy-factory-decoder.helper'; import { createProxyWithNonceEncoder } from '@/domain/relay/contracts/__tests__/encoders/proxy-factory-encoder.builder'; @@ -27,7 +26,7 @@ describe('ProxyFactoryDecoder', () => { }); it('throws if the function call cannot be decoded', () => { - const data = faker.string.hexadecimal({ length: 138 }) as Hex; + const data = faker.string.hexadecimal({ length: 138 }) as `0x${string}`; expect(() => target.decodeFunctionData({ data })).toThrow(); }); diff --git a/src/domain/relay/errors/relay-limit-reached.error.ts b/src/domain/relay/errors/relay-limit-reached.error.ts index 5b1d9f8800..05c158d3e4 100644 --- a/src/domain/relay/errors/relay-limit-reached.error.ts +++ b/src/domain/relay/errors/relay-limit-reached.error.ts @@ -1,9 +1,8 @@ import { HttpException, HttpStatus } from '@nestjs/common'; -import { Hex } from 'viem'; export class RelayLimitReachedError extends HttpException { constructor( - readonly address: Hex, + readonly address: `0x${string}`, readonly current: number, readonly limit: number, ) { diff --git a/src/domain/relay/limit-addresses.mapper.ts b/src/domain/relay/limit-addresses.mapper.ts index 8d2f343227..f6ac11067d 100644 --- a/src/domain/relay/limit-addresses.mapper.ts +++ b/src/domain/relay/limit-addresses.mapper.ts @@ -1,5 +1,4 @@ import { Inject, Injectable } from '@nestjs/common'; -import { Hex } from 'viem/types/misc'; import { Erc20Decoder } from '@/domain/relay/contracts/decoders/erc-20-decoder.helper'; import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; @@ -32,9 +31,9 @@ export class LimitAddressesMapper { async getLimitAddresses(args: { version: string; chainId: string; - to: Hex; - data: Hex; - }): Promise { + to: `0x${string}`; + data: `0x${string}`; + }): Promise { // Calldata matches that of execTransaction and meets validity requirements if ( this.isValidExecTransactionCall({ @@ -109,7 +108,10 @@ export class LimitAddressesMapper { throw new InvalidTransferError(); } - private isValidExecTransactionCall(args: { to: Hex; data: Hex }): boolean { + private isValidExecTransactionCall(args: { + to: `0x${string}`; + data: `0x${string}`; + }): boolean { const execTransactionArgs = this.getExecTransactionArgs(args.data); // Not a valid execTransaction call if (!execTransactionArgs) { @@ -149,10 +151,10 @@ export class LimitAddressesMapper { return isCancellation || this.safeDecoder.isCall(execTransactionArgs.data); } - private getExecTransactionArgs(data: Hex): { - to: Hex; + private getExecTransactionArgs(data: `0x${string}`): { + to: `0x${string}`; value: bigint; - data: Hex; + data: `0x${string}`; } | null { try { const safeDecodedData = this.safeDecoder.decodeFunctionData({ @@ -173,7 +175,10 @@ export class LimitAddressesMapper { } } - private isValidErc20Transfer(args: { to: Hex; data: Hex }): boolean { + private isValidErc20Transfer(args: { + to: `0x${string}`; + data: `0x${string}`; + }): boolean { // Can throw but called after this.erc20Decoder.helpers.isTransfer const erc20DecodedData = this.erc20Decoder.decodeFunctionData({ data: args.data, @@ -188,7 +193,10 @@ export class LimitAddressesMapper { return to !== args.to; } - private isValidErc20TransferFrom(args: { to: Hex; data: Hex }): boolean { + private isValidErc20TransferFrom(args: { + to: `0x${string}`; + data: `0x${string}`; + }): boolean { // Can throw but called after this.erc20Decoder.helpers.isTransferFrom const erc20DecodedData = this.erc20Decoder.decodeFunctionData({ data: args.data, @@ -205,7 +213,7 @@ export class LimitAddressesMapper { private async isOfficialMastercopy(args: { chainId: string; - address: string; + address: `0x${string}`; }): Promise { try { await this.safeRepository.getSafe(args); @@ -218,7 +226,7 @@ export class LimitAddressesMapper { private isOfficialMultiSendDeployment(args: { version: string; chainId: string; - address: string; + address: `0x${string}`; }): boolean { const multiSendCallOnlyDeployment = getMultiSendCallOnlyDeployment({ version: args.version, @@ -244,7 +252,9 @@ export class LimitAddressesMapper { ); } - private getSafeAddressFromMultiSend = (data: Hex): Hex => { + private getSafeAddressFromMultiSend = ( + data: `0x${string}`, + ): `0x${string}` => { // Decode transactions within MultiSend const transactions = this.multiSendDecoder.mapMultiSendTransactions(data); @@ -274,7 +284,7 @@ export class LimitAddressesMapper { private isOfficialProxyFactoryDeployment(args: { version: string; chainId: string; - address: string; + address: `0x${string}`; }): boolean { const proxyFactoryDeployment = getProxyFactoryDeployment({ version: args.version, @@ -290,7 +300,7 @@ export class LimitAddressesMapper { private isValidCreateProxyWithNonceCall(args: { version: string; chainId: string; - data: Hex; + data: `0x${string}`; }): boolean { let singleton: string | null = null; @@ -325,7 +335,9 @@ export class LimitAddressesMapper { return isL1Singleton || isL2Singleton; } - private getOwnersFromCreateProxyWithNonce(data: Hex): readonly Hex[] { + private getOwnersFromCreateProxyWithNonce( + data: `0x${string}`, + ): readonly `0x${string}`[] { const decodedProxyFactory = this.proxyFactoryDecoder.decodeFunctionData({ data, }); diff --git a/src/domain/relay/relay.repository.ts b/src/domain/relay/relay.repository.ts index f18584c6c0..2e8ba751db 100644 --- a/src/domain/relay/relay.repository.ts +++ b/src/domain/relay/relay.repository.ts @@ -64,14 +64,14 @@ export class RelayRepository { async getRelayCount(args: { chainId: string; - address: string; + address: `0x${string}`; }): Promise { return this.relayApi.getRelayCount(args); } private async canRelay(args: { chainId: string; - address: string; + address: `0x${string}`; }): Promise<{ result: boolean; currentCount: number }> { const currentCount = await this.getRelayCount(args); return { result: currentCount < this.limit, currentCount }; @@ -79,7 +79,7 @@ export class RelayRepository { private async incrementRelayCount(args: { chainId: string; - address: string; + address: `0x${string}`; }): Promise { const currentCount = await this.getRelayCount(args); const incremented = currentCount + 1; diff --git a/src/routes/relay/entities/relay.dto.entity.ts b/src/routes/relay/entities/relay.dto.entity.ts index 70d70ad822..16c162af58 100644 --- a/src/routes/relay/entities/relay.dto.entity.ts +++ b/src/routes/relay/entities/relay.dto.entity.ts @@ -1,6 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { z } from 'zod'; -import { Hex } from 'viem'; import { RelayDtoSchema } from '@/routes/relay/entities/schemas/relay.dto.schema'; export class RelayDto implements z.infer { @@ -8,10 +7,10 @@ export class RelayDto implements z.infer { version!: string; @ApiProperty() - to!: Hex; + to!: `0x${string}`; @ApiProperty() - data!: Hex; + data!: `0x${string}`; @ApiPropertyOptional({ type: String, diff --git a/src/routes/relay/relay.controller.ts b/src/routes/relay/relay.controller.ts index 6caffe4905..46a6c536ff 100644 --- a/src/routes/relay/relay.controller.ts +++ b/src/routes/relay/relay.controller.ts @@ -10,6 +10,7 @@ import { UnofficialMasterCopyExceptionFilter } from '@/domain/relay/exception-fi import { UnofficialMultiSendExceptionFilter } from '@/domain/relay/exception-filters/unofficial-multisend.error'; import { UnofficialProxyFactoryExceptionFilter } from '@/domain/relay/exception-filters/unofficial-proxy-factory.exception-filter'; import { RelayDtoSchema } from '@/routes/relay/entities/schemas/relay.dto.schema'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; @ApiTags('relay') @Controller({ @@ -39,7 +40,8 @@ export class RelayController { @Get(':safeAddress') async getRelaysRemaining( @Param('chainId') chainId: string, - @Param('safeAddress') safeAddress: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, ): Promise<{ remaining: number; limit: number; diff --git a/src/routes/relay/relay.service.ts b/src/routes/relay/relay.service.ts index a85e83641d..d1ed70d7f9 100644 --- a/src/routes/relay/relay.service.ts +++ b/src/routes/relay/relay.service.ts @@ -30,7 +30,7 @@ export class RelayService { async getRelaysRemaining(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise<{ remaining: number; limit: number }> { const currentCount = await this.relayRepository.getRelayCount({ chainId: args.chainId, From e85605da41acd189a1dba13ae02a74bd461709ce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 May 2024 10:07:11 +0200 Subject: [PATCH 034/207] Bump @types/lodash from 4.17.1 to 4.17.4 (#1589) Bumps [@types/lodash](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/lodash) from 4.17.1 to 4.17.4. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/lodash) --- updated-dependencies: - dependency-name: "@types/lodash" dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index e1e4566f17..6268c1e01b 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "@types/express": "^4.17.21", "@types/jest": "29.5.12", "@types/jsonwebtoken": "^9", - "@types/lodash": "^4.17.1", + "@types/lodash": "^4.17.4", "@types/node": "^20.12.12", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.2", diff --git a/yarn.lock b/yarn.lock index 7301df15d7..0ed2215f61 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1890,10 +1890,10 @@ __metadata: languageName: node linkType: hard -"@types/lodash@npm:^4.17.1": - version: 4.17.1 - resolution: "@types/lodash@npm:4.17.1" - checksum: 10/384bdd29348a000f8e815f94839a1a8c7f5a4ca856b016ade7f2abdc1df0b4e3e009c113b69db320a8fde51d1f38e60c19462b9bf3e82e0e2e32d3ac3e7ba2c4 +"@types/lodash@npm:^4.17.4": + version: 4.17.4 + resolution: "@types/lodash@npm:4.17.4" + checksum: 10/3ec19f9fc48200006e71733e08bcb1478b0398673657fcfb21a8643d41a80bcce09a01000077c3b23a3c6d86b9b314abe0672a8fdfc0fd66b893bd41955cfab8 languageName: node linkType: hard @@ -7276,7 +7276,7 @@ __metadata: "@types/express": "npm:^4.17.21" "@types/jest": "npm:29.5.12" "@types/jsonwebtoken": "npm:^9" - "@types/lodash": "npm:^4.17.1" + "@types/lodash": "npm:^4.17.4" "@types/node": "npm:^20.12.12" "@types/semver": "npm:^7.5.8" "@types/supertest": "npm:^6.0.2" From fd5576837a8b9df83fee61d7fd43c2dce529dbd9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 May 2024 10:08:00 +0200 Subject: [PATCH 035/207] Bump typescript-eslint from 7.8.0 to 7.11.0 (#1591) Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 7.8.0 to 7.11.0. - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.11.0/packages/typescript-eslint) --- updated-dependencies: - dependency-name: typescript-eslint dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 128 +++++++++++++++++++++++---------------------------- 2 files changed, 59 insertions(+), 71 deletions(-) diff --git a/package.json b/package.json index 6268c1e01b..96cf7e33c5 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "ts-node": "^10.9.2", "tsconfig-paths": "4.2.0", "typescript": "^5.4.5", - "typescript-eslint": "^7.8.0" + "typescript-eslint": "^7.11.0" }, "jest": { "moduleFileExtensions": [ diff --git a/yarn.lock b/yarn.lock index 0ed2215f61..d30794ba69 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1874,13 +1874,6 @@ __metadata: languageName: node linkType: hard -"@types/json-schema@npm:^7.0.15": - version: 7.0.15 - resolution: "@types/json-schema@npm:7.0.15" - checksum: 10/1a3c3e06236e4c4aab89499c428d585527ce50c24fe8259e8b3926d3df4cfbbbcf306cfc73ddfb66cbafc973116efd15967020b0f738f63e09e64c7d260519e7 - languageName: node - linkType: hard - "@types/jsonwebtoken@npm:^9": version: 9.0.6 resolution: "@types/jsonwebtoken@npm:9.0.6" @@ -2019,20 +2012,18 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:7.8.0": - version: 7.8.0 - resolution: "@typescript-eslint/eslint-plugin@npm:7.8.0" +"@typescript-eslint/eslint-plugin@npm:7.11.0": + version: 7.11.0 + resolution: "@typescript-eslint/eslint-plugin@npm:7.11.0" dependencies: "@eslint-community/regexpp": "npm:^4.10.0" - "@typescript-eslint/scope-manager": "npm:7.8.0" - "@typescript-eslint/type-utils": "npm:7.8.0" - "@typescript-eslint/utils": "npm:7.8.0" - "@typescript-eslint/visitor-keys": "npm:7.8.0" - debug: "npm:^4.3.4" + "@typescript-eslint/scope-manager": "npm:7.11.0" + "@typescript-eslint/type-utils": "npm:7.11.0" + "@typescript-eslint/utils": "npm:7.11.0" + "@typescript-eslint/visitor-keys": "npm:7.11.0" graphemer: "npm:^1.4.0" ignore: "npm:^5.3.1" natural-compare: "npm:^1.4.0" - semver: "npm:^7.6.0" ts-api-utils: "npm:^1.3.0" peerDependencies: "@typescript-eslint/parser": ^7.0.0 @@ -2040,44 +2031,44 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/0dc5f0933e1f1196bfc3d2545758d53981c9cd1b501f9795ebc82e471d88b008da3fa33712b60398c5ada7e0853805b3bcffe2ef8b94a25d0502b187663a0b6c + checksum: 10/be95ed0bbd5b34c47239677ea39d531bcd8a18717a67d70a297bed5b0050b256159856bb9c1e894ac550d011c24bb5b4abf8056c5d70d0d5895f0cc1accd14ea languageName: node linkType: hard -"@typescript-eslint/parser@npm:7.8.0": - version: 7.8.0 - resolution: "@typescript-eslint/parser@npm:7.8.0" +"@typescript-eslint/parser@npm:7.11.0": + version: 7.11.0 + resolution: "@typescript-eslint/parser@npm:7.11.0" dependencies: - "@typescript-eslint/scope-manager": "npm:7.8.0" - "@typescript-eslint/types": "npm:7.8.0" - "@typescript-eslint/typescript-estree": "npm:7.8.0" - "@typescript-eslint/visitor-keys": "npm:7.8.0" + "@typescript-eslint/scope-manager": "npm:7.11.0" + "@typescript-eslint/types": "npm:7.11.0" + "@typescript-eslint/typescript-estree": "npm:7.11.0" + "@typescript-eslint/visitor-keys": "npm:7.11.0" debug: "npm:^4.3.4" peerDependencies: eslint: ^8.56.0 peerDependenciesMeta: typescript: optional: true - checksum: 10/57b7918ec80484903e43e6877aabc37e7e1735fefc730c161777333b38d92cffb562fca9c91e622c0e58fe2fb0f7e47e5237bd0666189a70b3abc62e5c13eb7c + checksum: 10/0a32417aec62d7de04427323ab3fc8159f9f02429b24f739d8748e8b54fc65b0e3dbae8e4779c4b795f0d8e5f98a4d83a43b37ea0f50ebda51546cdcecf73caa languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:7.8.0": - version: 7.8.0 - resolution: "@typescript-eslint/scope-manager@npm:7.8.0" +"@typescript-eslint/scope-manager@npm:7.11.0": + version: 7.11.0 + resolution: "@typescript-eslint/scope-manager@npm:7.11.0" dependencies: - "@typescript-eslint/types": "npm:7.8.0" - "@typescript-eslint/visitor-keys": "npm:7.8.0" - checksum: 10/4ebb16bb2aa9b9c7c38326405b97b037849b45a241ebdd6d2b8dfdbc4dbe73b3f4ea34888b2469244303037505d2f263b8bcf260f59fa7a8527d95e8989d260e + "@typescript-eslint/types": "npm:7.11.0" + "@typescript-eslint/visitor-keys": "npm:7.11.0" + checksum: 10/79eff310405c6657ff092641e3ad51c6698c6708b915ecef945ebdd1737bd48e1458c5575836619f42dec06143ec0e3a826f3e551af590d297367da3d08f329e languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:7.8.0": - version: 7.8.0 - resolution: "@typescript-eslint/type-utils@npm:7.8.0" +"@typescript-eslint/type-utils@npm:7.11.0": + version: 7.11.0 + resolution: "@typescript-eslint/type-utils@npm:7.11.0" dependencies: - "@typescript-eslint/typescript-estree": "npm:7.8.0" - "@typescript-eslint/utils": "npm:7.8.0" + "@typescript-eslint/typescript-estree": "npm:7.11.0" + "@typescript-eslint/utils": "npm:7.11.0" debug: "npm:^4.3.4" ts-api-utils: "npm:^1.3.0" peerDependencies: @@ -2085,23 +2076,23 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/3c2df3fda8200d04101e438d490ea8025f988774a62af4858bee2764f4bf26f676b2119a83af08a5b0b928634d489d77d783c3deebfe6c48da883f86c7260c41 + checksum: 10/ab6ebeff68a60fc40d0ace88e03d6b4242b8f8fe2fa300db161780d58777b57f69fa077cd482e1b673316559459bd20b8cc89a7f9f30e644bfed8293f77f0e4b languageName: node linkType: hard -"@typescript-eslint/types@npm:7.8.0": - version: 7.8.0 - resolution: "@typescript-eslint/types@npm:7.8.0" - checksum: 10/3c7100ecd251c54126c8e4cf00f353cd421a88bf23ac3dc48ff40b1b530596467b4b4fd7e1c91e61a561fe03a6f53eb11acd043fd9f30388d995f32399f43bee +"@typescript-eslint/types@npm:7.11.0": + version: 7.11.0 + resolution: "@typescript-eslint/types@npm:7.11.0" + checksum: 10/c6a0b47ef43649a59c9d51edfc61e367b55e519376209806b1c98385a8385b529e852c7a57e081fb15ef6a5dc0fc8e90bd5a508399f5ac2137f4d462e89cdc30 languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:7.8.0": - version: 7.8.0 - resolution: "@typescript-eslint/typescript-estree@npm:7.8.0" +"@typescript-eslint/typescript-estree@npm:7.11.0": + version: 7.11.0 + resolution: "@typescript-eslint/typescript-estree@npm:7.11.0" dependencies: - "@typescript-eslint/types": "npm:7.8.0" - "@typescript-eslint/visitor-keys": "npm:7.8.0" + "@typescript-eslint/types": "npm:7.11.0" + "@typescript-eslint/visitor-keys": "npm:7.11.0" debug: "npm:^4.3.4" globby: "npm:^11.1.0" is-glob: "npm:^4.0.3" @@ -2111,34 +2102,31 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/099a0cae4f6ddf07ccfa881f4c775013f6b2ba8aa5173df6c0a7051e1aa982b82672a21b2bdedd4c35b4e62f44c7db6bac98ed3122ddb0bbe5f62134d8462842 + checksum: 10/b98b101e42d3b91003510a5c5a83f4350b6c1cf699bf2e409717660579ffa71682bc280c4f40166265c03f9546ed4faedc3723e143f1ab0ed7f5990cc3dff0ae languageName: node linkType: hard -"@typescript-eslint/utils@npm:7.8.0": - version: 7.8.0 - resolution: "@typescript-eslint/utils@npm:7.8.0" +"@typescript-eslint/utils@npm:7.11.0": + version: 7.11.0 + resolution: "@typescript-eslint/utils@npm:7.11.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.4.0" - "@types/json-schema": "npm:^7.0.15" - "@types/semver": "npm:^7.5.8" - "@typescript-eslint/scope-manager": "npm:7.8.0" - "@typescript-eslint/types": "npm:7.8.0" - "@typescript-eslint/typescript-estree": "npm:7.8.0" - semver: "npm:^7.6.0" + "@typescript-eslint/scope-manager": "npm:7.11.0" + "@typescript-eslint/types": "npm:7.11.0" + "@typescript-eslint/typescript-estree": "npm:7.11.0" peerDependencies: eslint: ^8.56.0 - checksum: 10/49b7077e22e4456d41cd8fa71126ffd37b0eb325ba49af5495a6fddf3d8529960dd3aaa8d73a7a35f0c42ee4da0849b6cbc00ebefff50f2e3cb8330bbb788d91 + checksum: 10/fbef14e166a70ccc4527c0731e0338acefa28218d1a018aa3f5b6b1ad9d75c56278d5f20bda97cf77da13e0a67c4f3e579c5b2f1c2e24d676960927921b55851 languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:7.8.0": - version: 7.8.0 - resolution: "@typescript-eslint/visitor-keys@npm:7.8.0" +"@typescript-eslint/visitor-keys@npm:7.11.0": + version: 7.11.0 + resolution: "@typescript-eslint/visitor-keys@npm:7.11.0" dependencies: - "@typescript-eslint/types": "npm:7.8.0" + "@typescript-eslint/types": "npm:7.11.0" eslint-visitor-keys: "npm:^3.4.3" - checksum: 10/1616a7d88ed91958f5fe97468b4c3d3b97119cfd8c9965dfc50140bb189d474d01b4a6dd608669db818380c05e15e4020ba55b8662ed3eda80963d74cdc70038 + checksum: 10/1f2cf1214638e9e78e052393c9e24295196ec4781b05951659a3997e33f8699a760ea3705c17d770e10eda2067435199e0136ab09e5fac63869e22f2da184d89 languageName: node linkType: hard @@ -7304,7 +7292,7 @@ __metadata: ts-node: "npm:^10.9.2" tsconfig-paths: "npm:4.2.0" typescript: "npm:^5.4.5" - typescript-eslint: "npm:^7.8.0" + typescript-eslint: "npm:^7.11.0" viem: "npm:^2.11.1" winston: "npm:^3.13.0" zod: "npm:^3.23.8" @@ -8131,19 +8119,19 @@ __metadata: languageName: node linkType: hard -"typescript-eslint@npm:^7.8.0": - version: 7.8.0 - resolution: "typescript-eslint@npm:7.8.0" +"typescript-eslint@npm:^7.11.0": + version: 7.11.0 + resolution: "typescript-eslint@npm:7.11.0" dependencies: - "@typescript-eslint/eslint-plugin": "npm:7.8.0" - "@typescript-eslint/parser": "npm:7.8.0" - "@typescript-eslint/utils": "npm:7.8.0" + "@typescript-eslint/eslint-plugin": "npm:7.11.0" + "@typescript-eslint/parser": "npm:7.11.0" + "@typescript-eslint/utils": "npm:7.11.0" peerDependencies: eslint: ^8.56.0 peerDependenciesMeta: typescript: optional: true - checksum: 10/eb17e75f412366de82fc486e09f4b98d53108c6599b16d5425dfe771473a8546a9f3667f336c2c01af71fbe03d3520e31584e19d02c150f52b8cc35b7458a89a + checksum: 10/8c82d777a6503867b1edd873276706afc158c55012dd958b22a44255e4f7fb12591435b1086571fdbc73de3ce783fe24ec87d1cc2bd5739d9edbad0e52572cf1 languageName: node linkType: hard From 1a1714007258d5bec0a3b542cfce7e4c87bd9de2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 May 2024 10:10:41 +0200 Subject: [PATCH 036/207] Bump ts-jest from 29.1.2 to 29.1.3 (#1588) Bumps [ts-jest](https://github.com/kulshekhar/ts-jest) from 29.1.2 to 29.1.3. - [Release notes](https://github.com/kulshekhar/ts-jest/releases) - [Changelog](https://github.com/kulshekhar/ts-jest/blob/main/CHANGELOG.md) - [Commits](https://github.com/kulshekhar/ts-jest/compare/v29.1.2...v29.1.3) --- updated-dependencies: - dependency-name: ts-jest dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 96cf7e33c5..5f9e68b160 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "prettier": "^3.2.5", "source-map-support": "^0.5.20", "supertest": "^7.0.0", - "ts-jest": "29.1.2", + "ts-jest": "29.1.3", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "4.2.0", diff --git a/yarn.lock b/yarn.lock index d30794ba69..5909c5c012 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7287,7 +7287,7 @@ __metadata: semver: "npm:^7.6.2" source-map-support: "npm:^0.5.20" supertest: "npm:^7.0.0" - ts-jest: "npm:29.1.2" + ts-jest: "npm:29.1.3" ts-loader: "npm:^9.5.1" ts-node: "npm:^10.9.2" tsconfig-paths: "npm:4.2.0" @@ -7956,9 +7956,9 @@ __metadata: languageName: node linkType: hard -"ts-jest@npm:29.1.2": - version: 29.1.2 - resolution: "ts-jest@npm:29.1.2" +"ts-jest@npm:29.1.3": + version: 29.1.3 + resolution: "ts-jest@npm:29.1.3" dependencies: bs-logger: "npm:0.x" fast-json-stable-stringify: "npm:2.x" @@ -7970,6 +7970,7 @@ __metadata: yargs-parser: "npm:^21.0.1" peerDependencies: "@babel/core": ">=7.0.0-beta.0 <8" + "@jest/transform": ^29.0.0 "@jest/types": ^29.0.0 babel-jest: ^29.0.0 jest: ^29.0.0 @@ -7977,6 +7978,8 @@ __metadata: peerDependenciesMeta: "@babel/core": optional: true + "@jest/transform": + optional: true "@jest/types": optional: true babel-jest: @@ -7985,7 +7988,7 @@ __metadata: optional: true bin: ts-jest: cli.js - checksum: 10/5e40e7b933a1f3aa0d304d3c53913d1a7125fc79cd44e22b332f6e25dfe13008ddc7ac647066bb4f914d76083f7e8949f0bc156d793c30f3419f4ffd8180968b + checksum: 10/cc1f608bb5859e112ffb8a6d84ddb5c20954b7ec8c89a8c7f95e373368d8946b5843594fe7779078eec2b7e825962848f1a1ba7a44c71b8a08ed4e75d3a3f8d8 languageName: node linkType: hard From 2037dd4f7703a8bb575b4506b5e11931baf2a193 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 May 2024 10:17:21 +0200 Subject: [PATCH 037/207] Bump viem from 2.11.1 to 2.13.1 (#1592) Bumps [viem](https://github.com/wevm/viem) from 2.11.1 to 2.13.1. - [Release notes](https://github.com/wevm/viem/releases) - [Commits](https://github.com/wevm/viem/compare/viem@2.11.1...viem@2.13.1) --- updated-dependencies: - dependency-name: viem dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 5f9e68b160..74215c1f93 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "semver": "^7.6.2", - "viem": "^2.11.1", + "viem": "^2.13.1", "winston": "^3.13.0", "zod": "^3.23.8" }, diff --git a/yarn.lock b/yarn.lock index 5909c5c012..3221fe1905 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7293,7 +7293,7 @@ __metadata: tsconfig-paths: "npm:4.2.0" typescript: "npm:^5.4.5" typescript-eslint: "npm:^7.11.0" - viem: "npm:^2.11.1" + viem: "npm:^2.13.1" winston: "npm:^3.13.0" zod: "npm:^3.23.8" languageName: unknown @@ -8321,9 +8321,9 @@ __metadata: languageName: node linkType: hard -"viem@npm:^2.11.1": - version: 2.11.1 - resolution: "viem@npm:2.11.1" +"viem@npm:^2.13.1": + version: 2.13.1 + resolution: "viem@npm:2.13.1" dependencies: "@adraffy/ens-normalize": "npm:1.10.0" "@noble/curves": "npm:1.2.0" @@ -8338,7 +8338,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/1dc5d1455d006e5788e865e6e8a702e7dcf8cd2ca5cdfecc2dccf5886a579044fc8e0915f6a53e6a5db82e52f8660d6cf1d18dfcf75486fde6cea379d02cbb8b + checksum: 10/bfddca57e09d30810eaaa8ea01b281b8b1bcdb6da698404bc0f2b1d0a01a57051d70e911da0f480d9cdd8ec22c04ea88de0f09e29de5b4f1459e93c16d712952 languageName: node linkType: hard From 078d1d1b42bc4d4435828a3aa215835851390093 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Tue, 28 May 2024 16:41:04 +0200 Subject: [PATCH 038/207] Validate token payload before signing it (#1594) Adds validation of the payload passed to `AuthRepository['signToken']`, ensuring that it is a `AuthPayloadDto`. --- src/domain/auth/auth.repository.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/domain/auth/auth.repository.ts b/src/domain/auth/auth.repository.ts index 2740761030..890cc350f1 100644 --- a/src/domain/auth/auth.repository.ts +++ b/src/domain/auth/auth.repository.ts @@ -24,8 +24,8 @@ export class AuthRepository implements IAuthRepository { notBefore?: number; }, ): string { - // TODO: Verify payload before signing it - return this.jwtService.sign(payload, options); + const authPayloadDto = AuthPayloadDtoSchema.parse(payload); + return this.jwtService.sign(authPayloadDto, options); } verifyToken(accessToken: string): AuthPayloadDto { From 9057e1aae07208242dfd397dc9f78de394bc808d Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Wed, 29 May 2024 14:56:08 +0200 Subject: [PATCH 039/207] Checksum incoming `address` for estimation and propagate type (#1595) Checksums the incoming `address` of `EstimationsController['getEstimation']` and propagates the stricter (`0x${string}`) type throughout the project accordingly: - Increase strictness of `IEstimationsRepository['getEstimation']` `address` argument and implementation. - Increase strictness of all related methods across domain/controllers and implementations: - Messages - Safe - Transactions - Increase strictness of all relevant cache keys and dirs. - Update tests accordingly. --- src/datasources/cache/cache.router.ts | 14 +++---- .../transaction-api.service.spec.ts | 22 +++++----- .../transaction-api.service.ts | 18 ++++---- .../estimations.repository.interface.ts | 2 +- .../estimations/estimations.repository.ts | 2 +- .../interfaces/transaction-api.interface.ts | 20 +++++---- src/domain/messages/messages.repository.ts | 4 +- src/domain/safe/safe.repository.interface.ts | 26 ++++++------ src/domain/safe/safe.repository.ts | 33 ++++++++------- .../estimations.controller.spec.ts | 25 +++++++---- .../estimations/estimations.controller.ts | 5 ++- src/routes/estimations/estimations.service.ts | 2 +- src/routes/messages/messages.controller.ts | 4 +- src/routes/messages/messages.service.ts | 2 +- src/routes/safes/safes.controller.ts | 6 ++- src/routes/safes/safes.service.ts | 10 ++--- ...rs-by-safe.transactions.controller.spec.ts | 6 ++- ...ns-by-safe.transactions.controller.spec.ts | 8 ++-- .../transactions-history.controller.spec.ts | 41 +++++++++++-------- .../transactions/transactions.controller.ts | 19 ++++++--- .../transactions/transactions.service.ts | 19 +++++---- 21 files changed, 165 insertions(+), 123 deletions(-) diff --git a/src/datasources/cache/cache.router.ts b/src/datasources/cache/cache.router.ts index 2bac62dc01..9c1825308a 100644 --- a/src/datasources/cache/cache.router.ts +++ b/src/datasources/cache/cache.router.ts @@ -104,14 +104,14 @@ export class CacheRouter { static getSafeCacheDir(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): CacheDir { return new CacheDir(CacheRouter.getSafeCacheKey(args), ''); } static getSafeCacheKey(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): string { return `${args.chainId}_${CacheRouter.SAFE_KEY}_${args.safeAddress}`; } @@ -300,7 +300,7 @@ export class CacheRouter { static getCreationTransactionCacheDir(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): CacheDir { return new CacheDir( `${args.chainId}_${CacheRouter.CREATION_TRANSACTION_KEY}_${args.safeAddress}`, @@ -310,7 +310,7 @@ export class CacheRouter { static getAllTransactionsCacheDir(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; ordering?: string; executed?: boolean; queued?: boolean; @@ -325,7 +325,7 @@ export class CacheRouter { static getAllTransactionsKey(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): string { return `${args.chainId}_${CacheRouter.ALL_TRANSACTIONS_KEY}_${args.safeAddress}`; } @@ -381,14 +381,14 @@ export class CacheRouter { static getMessagesBySafeCacheKey(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): string { return `${args.chainId}_${CacheRouter.MESSAGES_KEY}_${args.safeAddress}`; } static getMessagesBySafeCacheDir(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; limit?: number; offset?: number; }): CacheDir { diff --git a/src/datasources/transaction-api/transaction-api.service.spec.ts b/src/datasources/transaction-api/transaction-api.service.spec.ts index 7b889e5666..8de16b2cd7 100644 --- a/src/datasources/transaction-api/transaction-api.service.spec.ts +++ b/src/datasources/transaction-api/transaction-api.service.spec.ts @@ -314,7 +314,7 @@ describe('TransactionApi', () => { describe('clearSafe', () => { it('should clear the Safe cache', async () => { - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); await service.clearSafe(safeAddress); @@ -1561,7 +1561,7 @@ describe('TransactionApi', () => { describe('getCreationTransaction', () => { it('should return the creation transaction retrieved', async () => { - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const creationTransaction = creationTransactionBuilder().build(); const getCreationTransactionUrl = `${baseUrl}/api/v1/safes/${safeAddress}/creation/`; const cacheDir = new CacheDir( @@ -1587,7 +1587,7 @@ describe('TransactionApi', () => { ['Transaction Service', { nonFieldErrors: [errorMessage] }], ['standard', new Error(errorMessage)], ])(`should forward a %s error`, async (_, error) => { - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const getCreationTransactionUrl = `${baseUrl}/api/v1/safes/${safeAddress}/creation/`; const statusCode = faker.internet.httpStatusCode({ types: ['clientError', 'serverError'], @@ -1623,7 +1623,7 @@ describe('TransactionApi', () => { describe('getAllTransactions', () => { it('should return all transactions retrieved', async () => { - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const ordering = faker.word.noun(); const executed = faker.datatype.boolean(); const queued = faker.datatype.boolean(); @@ -1675,7 +1675,7 @@ describe('TransactionApi', () => { ['Transaction Service', { nonFieldErrors: [errorMessage] }], ['standard', new Error(errorMessage)], ])(`should forward a %s error`, async (_, error) => { - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const ordering = faker.word.noun(); const executed = faker.datatype.boolean(); const queued = faker.datatype.boolean(); @@ -1733,7 +1733,7 @@ describe('TransactionApi', () => { describe('clearAllTransactions', () => { it('should clear the all transactions cache', async () => { - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); await service.clearAllTransactions(safeAddress); @@ -2116,7 +2116,7 @@ describe('TransactionApi', () => { describe('getEstimation', () => { it('should return the estimation received', async () => { - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const to = getAddress(faker.finance.ethereumAddress()); const value = faker.string.numeric(); const data = faker.string.hexadecimal() as `0x${string}`; @@ -2153,7 +2153,7 @@ describe('TransactionApi', () => { ['Transaction Service', { nonFieldErrors: [errorMessage] }], ['standard', new Error(errorMessage)], ])(`should forward a %s error`, async (_, error) => { - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const to = getAddress(faker.finance.ethereumAddress()); const value = faker.string.numeric(); const data = faker.string.hexadecimal() as `0x${string}`; @@ -2251,7 +2251,7 @@ describe('TransactionApi', () => { describe('getMessagesBySafe', () => { it('should return the message hash received', async () => { - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const limit = faker.number.int(); const offset = faker.number.int(); const getMessageBySafeUrl = `${baseUrl}/api/v1/safes/${safeAddress}/messages/`; @@ -2289,7 +2289,7 @@ describe('TransactionApi', () => { ['Transaction Service', { nonFieldErrors: [errorMessage] }], ['standard', new Error(errorMessage)], ])(`should forward a %s error`, async (_, error) => { - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const limit = faker.number.int(); const offset = faker.number.int(); const getMessageBySafeUrl = `${baseUrl}/api/v1/safes/${safeAddress}/messages/`; @@ -2542,7 +2542,7 @@ describe('TransactionApi', () => { describe('clearMessagesBySafe', () => { it('should clear the messages cache by Safe address', async () => { - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); await service.clearMessagesBySafe({ safeAddress }); diff --git a/src/datasources/transaction-api/transaction-api.service.ts b/src/datasources/transaction-api/transaction-api.service.ts index 21767eb2c1..6d83167e91 100644 --- a/src/datasources/transaction-api/transaction-api.service.ts +++ b/src/datasources/transaction-api/transaction-api.service.ts @@ -121,7 +121,7 @@ export class TransactionApi implements ITransactionApi { } } - async getSafe(safeAddress: string): Promise { + async getSafe(safeAddress: `0x${string}`): Promise { try { const cacheDir = CacheRouter.getSafeCacheDir({ chainId: this.chainId, @@ -139,7 +139,7 @@ export class TransactionApi implements ITransactionApi { } } - async clearSafe(safeAddress: string): Promise { + async clearSafe(safeAddress: `0x${string}`): Promise { const key = CacheRouter.getSafeCacheKey({ chainId: this.chainId, safeAddress, @@ -642,7 +642,7 @@ export class TransactionApi implements ITransactionApi { // Important: there is no hook which invalidates this endpoint, // Therefore, this data will live in cache until [defaultExpirationTimeInSeconds] async getCreationTransaction( - safeAddress: string, + safeAddress: `0x${string}`, ): Promise { try { const cacheDir = CacheRouter.getCreationTransactionCacheDir({ @@ -662,7 +662,7 @@ export class TransactionApi implements ITransactionApi { } async getAllTransactions(args: { - safeAddress: string; + safeAddress: `0x${string}`; ordering?: string; executed?: boolean; queued?: boolean; @@ -696,7 +696,7 @@ export class TransactionApi implements ITransactionApi { } } - async clearAllTransactions(safeAddress: string): Promise { + async clearAllTransactions(safeAddress: `0x${string}`): Promise { const key = CacheRouter.getAllTransactionsKey({ chainId: this.chainId, safeAddress, @@ -821,7 +821,7 @@ export class TransactionApi implements ITransactionApi { } async getEstimation(args: { - address: string; + address: `0x${string}`; getEstimationDto: GetEstimationDto; }): Promise { try { @@ -860,7 +860,7 @@ export class TransactionApi implements ITransactionApi { } async getMessagesBySafe(args: { - safeAddress: string; + safeAddress: `0x${string}`; limit?: number | undefined; offset?: number | undefined; }): Promise> { @@ -957,7 +957,9 @@ export class TransactionApi implements ITransactionApi { } } - async clearMessagesBySafe(args: { safeAddress: string }): Promise { + async clearMessagesBySafe(args: { + safeAddress: `0x${string}`; + }): Promise { const key = CacheRouter.getMessagesBySafeCacheKey({ chainId: this.chainId, safeAddress: args.safeAddress, diff --git a/src/domain/estimations/estimations.repository.interface.ts b/src/domain/estimations/estimations.repository.interface.ts index 5e3cd0f10d..f39e32bdb3 100644 --- a/src/domain/estimations/estimations.repository.interface.ts +++ b/src/domain/estimations/estimations.repository.interface.ts @@ -9,7 +9,7 @@ export const IEstimationsRepository = Symbol('IEstimationsRepository'); export interface IEstimationsRepository { getEstimation(args: { chainId: string; - address: string; + address: `0x${string}`; getEstimationDto: GetEstimationDto; }): Promise; } diff --git a/src/domain/estimations/estimations.repository.ts b/src/domain/estimations/estimations.repository.ts index 4341e09174..b088f99947 100644 --- a/src/domain/estimations/estimations.repository.ts +++ b/src/domain/estimations/estimations.repository.ts @@ -14,7 +14,7 @@ export class EstimationsRepository implements IEstimationsRepository { async getEstimation(args: { chainId: string; - address: string; + address: `0x${string}`; getEstimationDto: GetEstimationDto; }): Promise { const api = await this.transactionApiManager.getTransactionApi( diff --git a/src/domain/interfaces/transaction-api.interface.ts b/src/domain/interfaces/transaction-api.interface.ts index 017965bf72..ce3c6d5400 100644 --- a/src/domain/interfaces/transaction-api.interface.ts +++ b/src/domain/interfaces/transaction-api.interface.ts @@ -29,9 +29,9 @@ export interface ITransactionApi { getSingletons(): Promise; - getSafe(safeAddress: string): Promise; + getSafe(safeAddress: `0x${string}`): Promise; - clearSafe(address: string): Promise; + clearSafe(address: `0x${string}`): Promise; getContract(contractAddress: string): Promise; @@ -144,7 +144,7 @@ export interface ITransactionApi { clearMultisigTransaction(safeTransactionHash: string): Promise; getMultisigTransactions(args: { - safeAddress: string; + safeAddress: `0x${string}`; ordering?: string; executed?: boolean; trusted?: boolean; @@ -160,10 +160,12 @@ export interface ITransactionApi { clearMultisigTransactions(safeAddress: string): Promise; - getCreationTransaction(safeAddress: string): Promise; + getCreationTransaction( + safeAddress: `0x${string}`, + ): Promise; getAllTransactions(args: { - safeAddress: string; + safeAddress: `0x${string}`; ordering?: string; executed?: boolean; queued?: boolean; @@ -171,7 +173,7 @@ export interface ITransactionApi { offset?: number; }): Promise>; - clearAllTransactions(safeAddress: string): Promise; + clearAllTransactions(safeAddress: `0x${string}`): Promise; getToken(address: string): Promise; @@ -193,14 +195,14 @@ export interface ITransactionApi { }): Promise; getEstimation(args: { - address: string; + address: `0x${string}`; getEstimationDto: GetEstimationDto; }): Promise; getMessageByHash(messageHash: string): Promise; getMessagesBySafe(args: { - safeAddress: string; + safeAddress: `0x${string}`; limit?: number; offset?: number; }): Promise>; @@ -222,7 +224,7 @@ export interface ITransactionApi { signature: `0x${string}`; }): Promise; - clearMessagesBySafe(args: { safeAddress: string }): Promise; + clearMessagesBySafe(args: { safeAddress: `0x${string}` }): Promise; clearMessagesByHash(args: { messageHash: string }): Promise; } diff --git a/src/domain/messages/messages.repository.ts b/src/domain/messages/messages.repository.ts index 14206ab6ee..2aa093885b 100644 --- a/src/domain/messages/messages.repository.ts +++ b/src/domain/messages/messages.repository.ts @@ -27,7 +27,7 @@ export class MessagesRepository implements IMessagesRepository { async getMessagesBySafe(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; limit?: number | undefined; offset?: number | undefined; }): Promise> { @@ -76,7 +76,7 @@ export class MessagesRepository implements IMessagesRepository { async clearMessagesBySafe(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise { const api = await this.transactionApiManager.getTransactionApi( args.chainId, diff --git a/src/domain/safe/safe.repository.interface.ts b/src/domain/safe/safe.repository.interface.ts index 1bd3a3e57b..af78205a06 100644 --- a/src/domain/safe/safe.repository.interface.ts +++ b/src/domain/safe/safe.repository.interface.ts @@ -16,19 +16,19 @@ import { TransactionApiManagerModule } from '@/domain/interfaces/transaction-api export const ISafeRepository = Symbol('ISafeRepository'); export interface ISafeRepository { - getSafe(args: { chainId: string; address: string }): Promise; + getSafe(args: { chainId: string; address: `0x${string}` }): Promise; - clearSafe(args: { chainId: string; address: string }): Promise; + clearSafe(args: { chainId: string; address: `0x${string}` }): Promise; isOwner(args: { chainId: string; - safeAddress: string; - address: string; + safeAddress: `0x${string}`; + address: `0x${string}`; }): Promise; getCollectibleTransfers(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; limit?: number; offset?: number; }): Promise>; @@ -65,7 +65,7 @@ export interface ISafeRepository { getModuleTransactions(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; to?: string; module?: string; limit?: number; @@ -104,12 +104,12 @@ export interface ISafeRepository { getCreationTransaction(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise; getTransactionHistory(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; limit?: number; offset?: number; }): Promise>; @@ -121,7 +121,7 @@ export interface ISafeRepository { clearAllExecutedTransactions(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise; clearMultisigTransaction(args: { @@ -136,7 +136,7 @@ export interface ISafeRepository { getMultisigTransactions(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; executed?: boolean; executionDateGte?: string; executionDateLte?: string; @@ -157,7 +157,7 @@ export interface ISafeRepository { getTransfers(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; limit?: number; }): Promise>; @@ -168,7 +168,7 @@ export interface ISafeRepository { getLastTransactionSortedByNonce(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise; proposeTransaction(args: { @@ -189,7 +189,7 @@ export interface ISafeRepository { */ getNonces(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise<{ currentNonce: number; recommendedNonce: number }>; getSafesByModule(args: { diff --git a/src/domain/safe/safe.repository.ts b/src/domain/safe/safe.repository.ts index 9460401033..b4fbfba934 100644 --- a/src/domain/safe/safe.repository.ts +++ b/src/domain/safe/safe.repository.ts @@ -26,7 +26,6 @@ import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; import { TransactionTypePageSchema } from '@/domain/safe/entities/schemas/transaction-type.schema'; import { AddConfirmationDto } from '@/domain/transactions/entities/add-confirmation.dto.entity'; import { ProposeTransactionDto } from '@/domain/transactions/entities/propose-transaction.dto.entity'; -import { getAddress } from 'viem'; import { ILoggingService, LoggingService } from '@/logging/logging.interface'; import { IChainsRepository } from '@/domain/chains/chains.repository.interface'; import { CreationTransactionSchema } from '@/domain/safe/entities/schemas/creation-transaction.schema'; @@ -42,14 +41,20 @@ export class SafeRepository implements ISafeRepository { private readonly chainsRepository: IChainsRepository, ) {} - async getSafe(args: { chainId: string; address: string }): Promise { + async getSafe(args: { + chainId: string; + address: `0x${string}`; + }): Promise { const transactionService = await this.transactionApiManager.getTransactionApi(args.chainId); const safe = await transactionService.getSafe(args.address); return SafeSchema.parse(safe); } - async clearSafe(args: { chainId: string; address: string }): Promise { + async clearSafe(args: { + chainId: string; + address: `0x${string}`; + }): Promise { const transactionService = await this.transactionApiManager.getTransactionApi(args.chainId); return transactionService.clearSafe(args.address); @@ -57,16 +62,14 @@ export class SafeRepository implements ISafeRepository { async isOwner(args: { chainId: string; - safeAddress: string; - address: string; + safeAddress: `0x${string}`; + address: `0x${string}`; }): Promise { const safe = await this.getSafe({ chainId: args.chainId, address: args.safeAddress, }); - const owner = getAddress(args.address); - const owners = safe.owners.map((rawAddress) => getAddress(rawAddress)); - return owners.includes(owner); + return safe.owners.includes(args.address); } async getCollectibleTransfers(args: { @@ -215,7 +218,7 @@ export class SafeRepository implements ISafeRepository { async getCreationTransaction(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise { const transactionService = await this.transactionApiManager.getTransactionApi(args.chainId); @@ -227,7 +230,7 @@ export class SafeRepository implements ISafeRepository { async getTransactionHistory(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; limit?: number; offset?: number; }): Promise> { @@ -236,7 +239,7 @@ export class SafeRepository implements ISafeRepository { private async getAllExecutedTransactions(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; ordering?: string; limit?: number; offset?: number; @@ -255,7 +258,7 @@ export class SafeRepository implements ISafeRepository { async clearAllExecutedTransactions(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise { const transactionService = await this.transactionApiManager.getTransactionApi(args.chainId); @@ -321,7 +324,7 @@ export class SafeRepository implements ISafeRepository { async getMultisigTransactions(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; executed?: boolean; executionDateGte?: string; executionDateLte?: string; @@ -406,7 +409,7 @@ export class SafeRepository implements ISafeRepository { async getLastTransactionSortedByNonce(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise { const transactionService = await this.transactionApiManager.getTransactionApi(args.chainId); @@ -439,7 +442,7 @@ export class SafeRepository implements ISafeRepository { async getNonces(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise<{ currentNonce: number; recommendedNonce: number }> { const safe = await this.getSafe({ chainId: args.chainId, diff --git a/src/routes/estimations/estimations.controller.spec.ts b/src/routes/estimations/estimations.controller.spec.ts index 2c70f1c64d..6802f84d1e 100644 --- a/src/routes/estimations/estimations.controller.spec.ts +++ b/src/routes/estimations/estimations.controller.spec.ts @@ -29,6 +29,7 @@ import { AccountDataSourceModule } from '@/datasources/account/account.datasourc import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { getAddress } from 'viem'; describe('Estimations Controller (Unit)', () => { let app: INestApplication; @@ -204,8 +205,9 @@ describe('Estimations Controller (Unit)', () => { .build(); networkService.get.mockImplementation(({ url }) => { const chainsUrl = `${safeConfigUrl}/api/v1/chains/${chain.chainId}`; - const getSafeUrl = `${chain.transactionService}/api/v1/safes/${address}`; - const multisigTransactionsUrl = `${chain.transactionService}/api/v1/safes/${address}/multisig-transactions/`; + // Param ValidationPipe checksums address + const getSafeUrl = `${chain.transactionService}/api/v1/safes/${getAddress(address)}`; + const multisigTransactionsUrl = `${chain.transactionService}/api/v1/safes/${getAddress(address)}/multisig-transactions/`; if (url === chainsUrl) { return Promise.resolve({ data: chain, status: 200 }); } @@ -224,7 +226,8 @@ describe('Estimations Controller (Unit)', () => { return Promise.reject(`No matching rule for url: ${url}`); }); networkService.post.mockImplementation(({ url }) => { - const estimationsUrl = `${chain.transactionService}/api/v1/safes/${address}/multisig-transactions/estimations/`; + // Param ValidationPipe checksums address + const estimationsUrl = `${chain.transactionService}/api/v1/safes/${getAddress(address)}/multisig-transactions/estimations/`; return url === estimationsUrl ? Promise.resolve({ data: estimation, status: 200 }) : Promise.reject(`No matching rule for url: ${url}`); @@ -255,8 +258,9 @@ describe('Estimations Controller (Unit)', () => { const estimation = estimationBuilder().build(); networkService.get.mockImplementation(({ url }) => { const chainsUrl = `${safeConfigUrl}/api/v1/chains/${chain.chainId}`; - const getSafeUrl = `${chain.transactionService}/api/v1/safes/${address}`; - const multisigTransactionsUrl = `${chain.transactionService}/api/v1/safes/${address}/multisig-transactions/`; + // Param ValidationPipe checksums address + const getSafeUrl = `${chain.transactionService}/api/v1/safes/${getAddress(address)}`; + const multisigTransactionsUrl = `${chain.transactionService}/api/v1/safes/${getAddress(address)}/multisig-transactions/`; if (url === chainsUrl) { return Promise.resolve({ data: chain, status: 200 }); } @@ -272,7 +276,8 @@ describe('Estimations Controller (Unit)', () => { return Promise.reject(`No matching rule for url: ${url}`); }); networkService.post.mockImplementation(({ url }) => { - const estimationsUrl = `${chain.transactionService}/api/v1/safes/${address}/multisig-transactions/estimations/`; + // Param ValidationPipe checksums address + const estimationsUrl = `${chain.transactionService}/api/v1/safes/${getAddress(address)}/multisig-transactions/estimations/`; return url === estimationsUrl ? Promise.resolve({ data: estimation, status: 200 }) : Promise.reject(`No matching rule for url: ${url}`); @@ -306,8 +311,9 @@ describe('Estimations Controller (Unit)', () => { .build(); networkService.get.mockImplementation(({ url }) => { const chainsUrl = `${safeConfigUrl}/api/v1/chains/${chain.chainId}`; - const getSafeUrl = `${chain.transactionService}/api/v1/safes/${address}`; - const multisigTransactionsUrl = `${chain.transactionService}/api/v1/safes/${address}/multisig-transactions/`; + // Param ValidationPipe checksums address + const getSafeUrl = `${chain.transactionService}/api/v1/safes/${getAddress(address)}`; + const multisigTransactionsUrl = `${chain.transactionService}/api/v1/safes/${getAddress(address)}/multisig-transactions/`; if (url === chainsUrl) { return Promise.resolve({ data: chain, status: 200 }); } @@ -326,7 +332,8 @@ describe('Estimations Controller (Unit)', () => { return Promise.reject(`No matching rule for url: ${url}`); }); networkService.post.mockImplementation(({ url }) => { - const estimationsUrl = `${chain.transactionService}/api/v1/safes/${address}/multisig-transactions/estimations/`; + // Param ValidationPipe checksums address + const estimationsUrl = `${chain.transactionService}/api/v1/safes/${getAddress(address)}/multisig-transactions/estimations/`; return url === estimationsUrl ? Promise.resolve({ data: estimation, status: 200 }) : Promise.reject(`No matching rule for url: ${url}`); diff --git a/src/routes/estimations/estimations.controller.ts b/src/routes/estimations/estimations.controller.ts index e8af56b7a2..9209968461 100644 --- a/src/routes/estimations/estimations.controller.ts +++ b/src/routes/estimations/estimations.controller.ts @@ -5,6 +5,7 @@ import { GetEstimationDto } from '@/routes/estimations/entities/get-estimation.d import { EstimationsService } from '@/routes/estimations/estimations.service'; import { ValidationPipe } from '@/validation/pipes/validation.pipe'; import { GetEstimationDtoSchema } from '@/routes/estimations/entities/schemas/get-estimation.dto.schema'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; @ApiTags('estimations') @Controller({ @@ -17,9 +18,9 @@ export class EstimationsController { @ApiOkResponse({ type: EstimationResponse }) @HttpCode(200) @Post('chains/:chainId/safes/:address/multisig-transactions/estimations') - async getContract( + async getEstimation( @Param('chainId') chainId: string, - @Param('address') address: string, + @Param('address', new ValidationPipe(AddressSchema)) address: `0x${string}`, @Body(new ValidationPipe(GetEstimationDtoSchema)) getEstimationDto: GetEstimationDto, ): Promise { diff --git a/src/routes/estimations/estimations.service.ts b/src/routes/estimations/estimations.service.ts index b048cd750e..fef49a14e1 100644 --- a/src/routes/estimations/estimations.service.ts +++ b/src/routes/estimations/estimations.service.ts @@ -26,7 +26,7 @@ export class EstimationsService { */ async getEstimation(args: { chainId: string; - address: string; + address: `0x${string}`; getEstimationDto: GetEstimationDto; }): Promise { const estimation = await this.estimationsRepository.getEstimation(args); diff --git a/src/routes/messages/messages.controller.ts b/src/routes/messages/messages.controller.ts index 05900be5d7..8703763871 100644 --- a/src/routes/messages/messages.controller.ts +++ b/src/routes/messages/messages.controller.ts @@ -14,6 +14,7 @@ import { MessagesService } from '@/routes/messages/messages.service'; import { ValidationPipe } from '@/validation/pipes/validation.pipe'; import { UpdateMessageSignatureDtoSchema } from '@/routes/messages/entities/schemas/update-message-signature.dto.schema'; import { CreateMessageDtoSchema } from '@/routes/messages/entities/schemas/create-message.dto.schema'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; @ApiTags('messages') @Controller({ @@ -37,7 +38,8 @@ export class MessagesController { @ApiQuery({ name: 'cursor', required: false, type: String }) async getMessagesBySafe( @Param('chainId') chainId: string, - @Param('safeAddress') safeAddress: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, @RouteUrlDecorator() routeUrl: URL, @PaginationDataDecorator() paginationData: PaginationData, ): Promise> { diff --git a/src/routes/messages/messages.service.ts b/src/routes/messages/messages.service.ts index 9a427ecf5c..79b943c901 100644 --- a/src/routes/messages/messages.service.ts +++ b/src/routes/messages/messages.service.ts @@ -41,7 +41,7 @@ export class MessagesService { async getMessagesBySafe(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; paginationData: PaginationData; routeUrl: Readonly; }): Promise> { diff --git a/src/routes/safes/safes.controller.ts b/src/routes/safes/safes.controller.ts index 88e331c1dc..99f95f5bca 100644 --- a/src/routes/safes/safes.controller.ts +++ b/src/routes/safes/safes.controller.ts @@ -26,7 +26,8 @@ export class SafesController { @Get('chains/:chainId/safes/:safeAddress') async getSafe( @Param('chainId') chainId: string, - @Param('safeAddress') safeAddress: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, ): Promise { return this.service.getSafeInfo({ chainId, safeAddress }); } @@ -35,7 +36,8 @@ export class SafesController { @Get('chains/:chainId/safes/:safeAddress/nonces') async getNonces( @Param('chainId') chainId: string, - @Param('safeAddress') safeAddress: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, ): Promise { return this.service.getNonces({ chainId, safeAddress }); } diff --git a/src/routes/safes/safes.service.ts b/src/routes/safes/safes.service.ts index cd4c23d200..aec2e285a8 100644 --- a/src/routes/safes/safes.service.ts +++ b/src/routes/safes/safes.service.ts @@ -50,7 +50,7 @@ export class SafesService { async getSafeInfo(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise { const [safe, { recommendedMasterCopyVersion }, supportedSingletons] = await Promise.all([ @@ -197,7 +197,7 @@ export class SafesService { public async getNonces(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise { const nonce = await this.safeRepository.getNonces(args); return new SafeNonces(nonce); @@ -231,7 +231,7 @@ export class SafesService { private async getCollectiblesTag( chainId: string, - safeAddress: string, + safeAddress: `0x${string}`, ): Promise { const lastCollectibleTransfer = await this.safeRepository .getCollectibleTransfers({ @@ -273,7 +273,7 @@ export class SafesService { */ private async getTxHistoryTagDate( chainId: string, - safeAddress: string, + safeAddress: `0x${string}`, ): Promise { const txPages = await Promise.allSettled([ this.safeRepository.getMultisigTransactions({ @@ -318,7 +318,7 @@ export class SafesService { private async modifiedMessageTag( chainId: string, - safeAddress: string, + safeAddress: `0x${string}`, ): Promise { const messages = await this.messagesRepository.getMessagesBySafe({ chainId, diff --git a/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts index cbec22729f..22670a30ae 100644 --- a/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts @@ -112,7 +112,8 @@ describe('List incoming transfers by Safe - Transactions Controller (Unit)', () }); const error = new NetworkResponseError( new URL( - `${chainResponse.transactionService}/v1/chains/${chainId}/safes/${safeAddress}/incoming-transfers/?cursor=limit%3D${limit}%26offset%3D${offset}`, + // Param ValidationPipe checksums address + `${chainResponse.transactionService}/v1/chains/${chainId}/safes/${getAddress(safeAddress)}/incoming-transfers/?cursor=limit%3D${limit}%26offset%3D${offset}`, ), { status: 500 } as Response, ); @@ -133,7 +134,8 @@ describe('List incoming transfers by Safe - Transactions Controller (Unit)', () url: `${safeConfigUrl}/api/v1/chains/${chainId}`, }); expect(networkService.get).toHaveBeenCalledWith({ - url: `${chainResponse.transactionService}/api/v1/safes/${safeAddress}/incoming-transfers/`, + // Param ValidationPipe checksums address + url: `${chainResponse.transactionService}/api/v1/safes/${getAddress(safeAddress)}/incoming-transfers/`, networkRequest: expect.objectContaining({ params: expect.objectContaining({ offset, limit }), }), diff --git a/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts index a9b1a6b750..b19b2c5a68 100644 --- a/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts @@ -109,7 +109,8 @@ describe('List multisig transactions by Safe - Transactions Controller (Unit)', }); const error = new NetworkResponseError( new URL( - `${safeConfigUrl}/v1/chains/${chainId}/safes/${safeAddress}/multisig-transactions`, + // Param ValidationPipe checksums address + `${safeConfigUrl}/v1/chains/${chainId}/safes/${getAddress(safeAddress)}/multisig-transactions`, ), { status: 500 } as Response, ); @@ -128,11 +129,12 @@ describe('List multisig transactions by Safe - Transactions Controller (Unit)', url: `${safeConfigUrl}/api/v1/chains/${chainId}`, }); expect(networkService.get).toHaveBeenCalledWith({ - url: `${chainResponse.transactionService}/api/v1/safes/${safeAddress}/multisig-transactions/`, + // Param ValidationPipe checksums address + url: `${chainResponse.transactionService}/api/v1/safes/${getAddress(safeAddress)}/multisig-transactions/`, networkRequest: expect.objectContaining({ params: expect.objectContaining({ ordering: '-nonce', - safe: safeAddress, + safe: getAddress(safeAddress), trusted: true, }), }), diff --git a/src/routes/transactions/transactions-history.controller.spec.ts b/src/routes/transactions/transactions-history.controller.spec.ts index dc9fff676e..6fe0b91435 100644 --- a/src/routes/transactions/transactions-history.controller.spec.ts +++ b/src/routes/transactions/transactions-history.controller.spec.ts @@ -150,7 +150,8 @@ describe('Transactions History Controller (Unit)', () => { const chainId = chainResponse.chainId; networkService.get.mockImplementation(({ url }) => { const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chainId}`; - const getAllTransactions = `${chainResponse.transactionService}/api/v1/safes/${safeAddress}/all-transactions/`; + // Param ValidationPipe checksums address + const getAllTransactions = `${chainResponse.transactionService}/api/v1/safes/${getAddress(safeAddress)}/all-transactions/`; if (url === getChainUrl) { return Promise.resolve({ data: chainResponse, status: 200 }); } @@ -177,7 +178,8 @@ describe('Transactions History Controller (Unit)', () => { const page = pageBuilder().build(); networkService.get.mockImplementation(({ url }) => { const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chain.chainId}`; - const getAllTransactions = `${chain.transactionService}/api/v1/safes/${safeAddress}/all-transactions/`; + // Param ValidationPipe checksums address + const getAllTransactions = `${chain.transactionService}/api/v1/safes/${getAddress(safeAddress)}/all-transactions/`; if (url === getChainUrl) { return Promise.resolve({ data: chain, status: 200 }); } @@ -213,9 +215,10 @@ describe('Transactions History Controller (Unit)', () => { const creationTransaction = creationTransactionBuilder().build(); networkService.get.mockImplementation(({ url }) => { const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chainId}`; - const getAllTransactions = `${chainResponse.transactionService}/api/v1/safes/${safeAddress}/all-transactions/`; - const getSafeUrl = `${chainResponse.transactionService}/api/v1/safes/${safeAddress}`; - const getSafeCreationUrl = `${chainResponse.transactionService}/api/v1/safes/${safeAddress}/creation/`; + // Param ValidationPipe checksums address + const getAllTransactions = `${chainResponse.transactionService}/api/v1/safes/${getAddress(safeAddress)}/all-transactions/`; + const getSafeUrl = `${chainResponse.transactionService}/api/v1/safes/${getAddress(safeAddress)}`; + const getSafeCreationUrl = `${chainResponse.transactionService}/api/v1/safes/${getAddress(safeAddress)}/creation/`; if (url === getChainUrl) { return Promise.resolve({ data: chainResponse, status: 200 }); } @@ -341,8 +344,9 @@ describe('Transactions History Controller (Unit)', () => { }; networkService.get.mockImplementation(({ url }) => { const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chainId}`; - const getAllTransactions = `${chainResponse.transactionService}/api/v1/safes/${safeAddress}/all-transactions/`; - const getSafeUrl = `${chainResponse.transactionService}/api/v1/safes/${safeAddress}`; + // Param ValidationPipe checksums address + const getAllTransactions = `${chainResponse.transactionService}/api/v1/safes/${getAddress(safeAddress)}/all-transactions/`; + const getSafeUrl = `${chainResponse.transactionService}/api/v1/safes/${getAddress(safeAddress)}`; if (url === getChainUrl) { return Promise.resolve({ data: chainResponse, status: 200 }); } @@ -396,8 +400,9 @@ describe('Transactions History Controller (Unit)', () => { }; networkService.get.mockImplementation(({ url }) => { const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chainId}`; - const getAllTransactions = `${chainResponse.transactionService}/api/v1/safes/${safeAddress}/all-transactions/`; - const getSafeUrl = `${chainResponse.transactionService}/api/v1/safes/${safeAddress}`; + // Param ValidationPipe checksums address + const getAllTransactions = `${chainResponse.transactionService}/api/v1/safes/${getAddress(safeAddress)}/all-transactions/`; + const getSafeUrl = `${chainResponse.transactionService}/api/v1/safes/${getAddress(safeAddress)}`; if (url === getChainUrl) { return Promise.resolve({ data: chainResponse, status: 200 }); } @@ -654,10 +659,11 @@ describe('Transactions History Controller (Unit)', () => { .build(); networkService.get.mockImplementation(({ url }) => { const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chainId}`; - const getAllTransactions = `${chainResponse.transactionService}/api/v1/safes/${safeAddress}/all-transactions/`; - const getSafeUrl = `${chainResponse.transactionService}/api/v1/safes/${safeAddress}`; + // Param ValidationPipe checksums address + const getAllTransactions = `${chainResponse.transactionService}/api/v1/safes/${getAddress(safeAddress)}/all-transactions/`; + const getSafeUrl = `${chainResponse.transactionService}/api/v1/safes/${getAddress(safeAddress)}`; const getContractUrl = `${chainResponse.transactionService}/api/v1/contracts/`; - const getSafeCreationUrl = `${chainResponse.transactionService}/api/v1/safes/${safeAddress}/creation/`; + const getSafeCreationUrl = `${chainResponse.transactionService}/api/v1/safes/${getAddress(safeAddress)}/creation/`; if (url === getChainUrl) { return Promise.resolve({ data: chainResponse, status: 200 }); } @@ -735,8 +741,9 @@ describe('Transactions History Controller (Unit)', () => { }; networkService.get.mockImplementation(({ url }) => { const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chainId}`; - const getAllTransactions = `${chainResponse.transactionService}/api/v1/safes/${safeAddress}/all-transactions/`; - const getSafeUrl = `${chainResponse.transactionService}/api/v1/safes/${safeAddress}`; + // Param ValidationPipe checksums address + const getAllTransactions = `${chainResponse.transactionService}/api/v1/safes/${getAddress(safeAddress)}/all-transactions/`; + const getSafeUrl = `${chainResponse.transactionService}/api/v1/safes/${getAddress(safeAddress)}`; if (url === getChainUrl) { return Promise.resolve({ data: chainResponse, status: 200 }); } @@ -766,7 +773,8 @@ describe('Transactions History Controller (Unit)', () => { url: `${safeConfigUrl}/api/v1/chains/${chainId}`, }); expect(networkService.get).toHaveBeenCalledWith({ - url: `${chainResponse.transactionService}/api/v1/safes/${safeAddress}/all-transactions/`, + // Param ValidationPipe checksums address + url: `${chainResponse.transactionService}/api/v1/safes/${getAddress(safeAddress)}/all-transactions/`, networkRequest: { params: { executed: true, @@ -774,7 +782,8 @@ describe('Transactions History Controller (Unit)', () => { limit: limit + 1, ordering: undefined, queued: false, - safe: safeAddress, + // Param ValidationPipe checksums address + safe: getAddress(safeAddress), }, }, }); diff --git a/src/routes/transactions/transactions.controller.ts b/src/routes/transactions/transactions.controller.ts index c8a95d0a2a..c3223515c8 100644 --- a/src/routes/transactions/transactions.controller.ts +++ b/src/routes/transactions/transactions.controller.ts @@ -38,6 +38,7 @@ import { TransactionsService } from '@/routes/transactions/transactions.service' import { DeleteTransactionDto } from '@/routes/transactions/entities/delete-transaction.dto.entity'; import { ValidationPipe } from '@/validation/pipes/validation.pipe'; import { DeleteTransactionDtoSchema } from '@/routes/transactions/entities/schemas/delete-transaction.dto.schema'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; @ApiTags('transactions') @Controller({ @@ -72,7 +73,8 @@ export class TransactionsController { @Param('chainId') chainId: string, @RouteUrlDecorator() routeUrl: URL, @PaginationDataDecorator() paginationData: PaginationData, - @Param('safeAddress') safeAddress: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, @Query('execution_date__gte') executionDateGte?: string, @Query('execution_date__lte') executionDateLte?: string, @Query('to') to?: string, @@ -159,7 +161,8 @@ export class TransactionsController { async getIncomingTransfers( @Param('chainId') chainId: string, @RouteUrlDecorator() routeUrl: URL, - @Param('safeAddress') safeAddress: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, @PaginationDataDecorator() paginationData: PaginationData, @Query('trusted', new DefaultValuePipe(true), ParseBoolPipe) trusted: boolean, @@ -188,7 +191,8 @@ export class TransactionsController { @Post('chains/:chainId/transactions/:safeAddress/preview') async previewTransaction( @Param('chainId') chainId: string, - @Param('safeAddress') safeAddress: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, @Body(new ValidationPipe(PreviewTransactionDtoSchema)) previewTransactionDto: PreviewTransactionDto, ): Promise { @@ -206,7 +210,8 @@ export class TransactionsController { async getTransactionQueue( @Param('chainId') chainId: string, @RouteUrlDecorator() routeUrl: URL, - @Param('safeAddress') safeAddress: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, @PaginationDataDecorator() paginationData: PaginationData, @Query('trusted', new DefaultValuePipe(true), ParseBoolPipe) trusted: boolean, @@ -227,7 +232,8 @@ export class TransactionsController { async getTransactionsHistory( @Param('chainId') chainId: string, @RouteUrlDecorator() routeUrl: URL, - @Param('safeAddress') safeAddress: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, @PaginationDataDecorator() paginationData: PaginationData, @Query('timezone_offset', new DefaultValuePipe(0), ParseIntPipe) timezoneOffsetMs: number, @@ -252,7 +258,8 @@ export class TransactionsController { @Post('chains/:chainId/transactions/:safeAddress/propose') async proposeTransaction( @Param('chainId') chainId: string, - @Param('safeAddress') safeAddress: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, @Body(new ValidationPipe(ProposeTransactionDtoSchema)) proposeTransactionDto: ProposeTransactionDto, ): Promise { diff --git a/src/routes/transactions/transactions.service.ts b/src/routes/transactions/transactions.service.ts index 7e7ab7c136..51e5f6352f 100644 --- a/src/routes/transactions/transactions.service.ts +++ b/src/routes/transactions/transactions.service.ts @@ -35,6 +35,7 @@ import { TransactionPreviewMapper } from '@/routes/transactions/mappers/transact import { TransactionsHistoryMapper } from '@/routes/transactions/mappers/transactions-history.mapper'; import { TransferDetailsMapper } from '@/routes/transactions/mappers/transfers/transfer-details.mapper'; import { TransferMapper } from '@/routes/transactions/mappers/transfers/transfer.mapper'; +import { getAddress } from 'viem'; @Injectable() export class TransactionsService { @@ -76,7 +77,8 @@ export class TransactionsService { }), this.safeRepository.getSafe({ chainId: args.chainId, - address: safeAddress, + // We can't checksum outside of case as some IDs don't contain addresses + address: getAddress(safeAddress), }), ]); return this.transferDetailsMapper.mapDetails( @@ -94,7 +96,8 @@ export class TransactionsService { }), this.safeRepository.getSafe({ chainId: args.chainId, - address: safeAddress, + // We can't checksum outside of case as some IDs don't contain addresses + address: getAddress(safeAddress), }), ]); return this.multisigTransactionDetailsMapper.mapDetails( @@ -127,7 +130,7 @@ export class TransactionsService { chainId: string; routeUrl: Readonly; paginationData: PaginationData; - safeAddress: string; + safeAddress: `0x${string}`; executionDateGte?: string; executionDateLte?: string; to?: string; @@ -250,7 +253,7 @@ export class TransactionsService { async getIncomingTransfers(args: { chainId: string; routeUrl: Readonly; - safeAddress: string; + safeAddress: `0x${string}`; executionDateGte?: string; executionDateLte?: string; to?: string; @@ -293,7 +296,7 @@ export class TransactionsService { async previewTransaction(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; previewTransactionDto: PreviewTransactionDto; }): Promise { const safe = await this.safeRepository.getSafe({ @@ -310,7 +313,7 @@ export class TransactionsService { async getTransactionQueue(args: { chainId: string; routeUrl: Readonly; - safeAddress: string; + safeAddress: `0x${string}`; paginationData: PaginationData; trusted?: boolean; }): Promise> { @@ -360,7 +363,7 @@ export class TransactionsService { async getTransactionHistory(args: { chainId: string; routeUrl: Readonly; - safeAddress: string; + safeAddress: `0x${string}`; paginationData: PaginationData; timezoneOffsetMs: number; onlyTrusted: boolean; @@ -409,7 +412,7 @@ export class TransactionsService { async proposeTransaction(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; proposeTransactionDto: ProposeTransactionDto; }): Promise { await this.safeRepository.proposeTransaction(args); From 1d114c8df42fd76e135176af025ef4f413198a09 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Wed, 29 May 2024 15:34:34 +0200 Subject: [PATCH 040/207] Checksum `contractAddress` of contract fetching and propagate type (#1596) Checksums the incoming `contractAddress` of `ContractsController['getContract']` and propagates the stricter (`0x${string}`) type throughout the project accordingly: - Add validation pipe checksum to incoming `contractAddress` of `ContractsController['getContract']` - Make the `contractAddress` type of `ContractsService['getContract']` stricter - Checksum the `sender` and `recipient` addresses in `Erc20TransferMapper`/`Erc721TransferMapper` - Checksum address mapping values in `Erc20TransferMapper`, `Erc721TransferMapper`, `SettingsChangeMapper`and `TransactionDataMapper` - Make the `contractAddress` type of `ITransactionApi['getContract']` (and it's implementation) stricter - Make the `address` type of `AddressInfoHelper['get' | 'getOrDefault' | 'getCollection' | '_getFromSource']` stricter - Make the `contractAddress` type of `IContractsRepository['getContract']` (and it's implementation) stricter - Make contracts cache dir `contractAddress` type stricter - Update types/tests accordingly --- src/datasources/cache/cache.router.ts | 2 +- .../transaction-api.service.spec.ts | 2 +- .../transaction-api/transaction-api.service.ts | 2 +- .../contracts/contracts.repository.interface.ts | 2 +- src/domain/contracts/contracts.repository.ts | 2 +- .../interfaces/transaction-api.interface.ts | 2 +- .../common/address-info/address-info.helper.ts | 8 ++++---- src/routes/contracts/contracts.controller.ts | 5 ++++- src/routes/contracts/contracts.service.ts | 2 +- .../mappers/common/erc20-transfer.mapper.ts | 5 +++-- .../mappers/common/erc721-transfer.mapper.ts | 5 +++-- .../mappers/common/settings-change.mapper.spec.ts | 6 ++---- .../mappers/common/settings-change.mapper.ts | 11 ++++++----- .../common/transaction-data.mapper.spec.ts | 15 ++++++++------- .../mappers/common/transaction-data.mapper.ts | 5 +++-- .../multisig-transaction-details.mapper.ts | 2 +- .../transactions-history.controller.spec.ts | 5 ++++- 17 files changed, 45 insertions(+), 36 deletions(-) diff --git a/src/datasources/cache/cache.router.ts b/src/datasources/cache/cache.router.ts index 9c1825308a..275d6f2a5f 100644 --- a/src/datasources/cache/cache.router.ts +++ b/src/datasources/cache/cache.router.ts @@ -118,7 +118,7 @@ export class CacheRouter { static getContractCacheDir(args: { chainId: string; - contractAddress: string; + contractAddress: `0x${string}`; }): CacheDir { return new CacheDir( `${args.chainId}_${CacheRouter.CONTRACT_KEY}_${args.contractAddress}`, diff --git a/src/datasources/transaction-api/transaction-api.service.spec.ts b/src/datasources/transaction-api/transaction-api.service.spec.ts index 8de16b2cd7..4195050a58 100644 --- a/src/datasources/transaction-api/transaction-api.service.spec.ts +++ b/src/datasources/transaction-api/transaction-api.service.spec.ts @@ -352,7 +352,7 @@ describe('TransactionApi', () => { ['Transaction Service', { nonFieldErrors: [errorMessage] }], ['standard', new Error(errorMessage)], ])(`should forward a %s error`, async (_, error) => { - const contract = faker.finance.ethereumAddress(); + const contract = getAddress(faker.finance.ethereumAddress()); const getContractUrl = `${baseUrl}/api/v1/contracts/${contract}`; const statusCode = faker.internet.httpStatusCode({ types: ['clientError', 'serverError'], diff --git a/src/datasources/transaction-api/transaction-api.service.ts b/src/datasources/transaction-api/transaction-api.service.ts index 6d83167e91..4d87a17a85 100644 --- a/src/datasources/transaction-api/transaction-api.service.ts +++ b/src/datasources/transaction-api/transaction-api.service.ts @@ -149,7 +149,7 @@ export class TransactionApi implements ITransactionApi { // Important: there is no hook which invalidates this endpoint, // Therefore, this data will live in cache until [defaultExpirationTimeInSeconds] - async getContract(contractAddress: string): Promise { + async getContract(contractAddress: `0x${string}`): Promise { try { const cacheDir = CacheRouter.getContractCacheDir({ chainId: this.chainId, diff --git a/src/domain/contracts/contracts.repository.interface.ts b/src/domain/contracts/contracts.repository.interface.ts index 76205b4fa2..e7c17b8b44 100644 --- a/src/domain/contracts/contracts.repository.interface.ts +++ b/src/domain/contracts/contracts.repository.interface.ts @@ -11,7 +11,7 @@ export interface IContractsRepository { */ getContract(args: { chainId: string; - contractAddress: string; + contractAddress: `0x${string}`; }): Promise; } diff --git a/src/domain/contracts/contracts.repository.ts b/src/domain/contracts/contracts.repository.ts index 7bed96013d..32e5401ac9 100644 --- a/src/domain/contracts/contracts.repository.ts +++ b/src/domain/contracts/contracts.repository.ts @@ -13,7 +13,7 @@ export class ContractsRepository implements IContractsRepository { async getContract(args: { chainId: string; - contractAddress: string; + contractAddress: `0x${string}`; }): Promise { const api = await this.transactionApiManager.getTransactionApi( args.chainId, diff --git a/src/domain/interfaces/transaction-api.interface.ts b/src/domain/interfaces/transaction-api.interface.ts index ce3c6d5400..9ef249ae99 100644 --- a/src/domain/interfaces/transaction-api.interface.ts +++ b/src/domain/interfaces/transaction-api.interface.ts @@ -33,7 +33,7 @@ export interface ITransactionApi { clearSafe(address: `0x${string}`): Promise; - getContract(contractAddress: string): Promise; + getContract(contractAddress: `0x${string}`): Promise; getDelegates(args: { safeAddress?: string; diff --git a/src/routes/common/address-info/address-info.helper.ts b/src/routes/common/address-info/address-info.helper.ts index 0b12d6b178..257053706f 100644 --- a/src/routes/common/address-info/address-info.helper.ts +++ b/src/routes/common/address-info/address-info.helper.ts @@ -32,7 +32,7 @@ export class AddressInfoHelper { async get( chainId: string, - address: string, + address: `0x${string}`, sources: Source[], ): Promise { for (const source of sources) { @@ -61,7 +61,7 @@ export class AddressInfoHelper { */ getOrDefault( chainId: string, - address: string, + address: `0x${string}`, sources: Source[], ): Promise { return this.get(chainId, address, sources).catch( @@ -78,7 +78,7 @@ export class AddressInfoHelper { */ getCollection( chainId: string, - addresses: string[], + addresses: `0x${string}`[], sources: Source[], ): Promise> { return Promise.allSettled( @@ -93,7 +93,7 @@ export class AddressInfoHelper { private _getFromSource( chainId: string, - address: string, + address: `0x${string}`, source: Source, ): Promise { switch (source) { diff --git a/src/routes/contracts/contracts.controller.ts b/src/routes/contracts/contracts.controller.ts index 4181d8cec7..6b7e71169c 100644 --- a/src/routes/contracts/contracts.controller.ts +++ b/src/routes/contracts/contracts.controller.ts @@ -3,6 +3,8 @@ import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { Contract } from '@/domain/contracts/entities/contract.entity'; import { ContractsService } from '@/routes/contracts/contracts.service'; import { Contract as ApiContract } from '@/routes/contracts/entities/contract.entity'; +import { ValidationPipe } from '@/validation/pipes/validation.pipe'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; @ApiTags('contracts') @Controller({ @@ -16,7 +18,8 @@ export class ContractsController { @Get('chains/:chainId/contracts/:contractAddress') async getContract( @Param('chainId') chainId: string, - @Param('contractAddress') contractAddress: string, + @Param('contractAddress', new ValidationPipe(AddressSchema)) + contractAddress: `0x${string}`, ): Promise { return this.contractsService.getContract({ chainId, contractAddress }); } diff --git a/src/routes/contracts/contracts.service.ts b/src/routes/contracts/contracts.service.ts index c8cf4da181..237eb007cc 100644 --- a/src/routes/contracts/contracts.service.ts +++ b/src/routes/contracts/contracts.service.ts @@ -12,7 +12,7 @@ export class ContractsService { async getContract(args: { chainId: string; - contractAddress: string; + contractAddress: `0x${string}`; }): Promise { return this.contractsRepository.getContract(args); } diff --git a/src/routes/transactions/mappers/common/erc20-transfer.mapper.ts b/src/routes/transactions/mappers/common/erc20-transfer.mapper.ts index 02d81d14e0..090d5d1b8e 100644 --- a/src/routes/transactions/mappers/common/erc20-transfer.mapper.ts +++ b/src/routes/transactions/mappers/common/erc20-transfer.mapper.ts @@ -9,6 +9,7 @@ import { TransferTransactionInfo } from '@/routes/transactions/entities/transfer import { Erc20Transfer } from '@/routes/transactions/entities/transfers/erc20-transfer.entity'; import { DataDecodedParamHelper } from '@/routes/transactions/mappers/common/data-decoded-param.helper'; import { getTransferDirection } from '@/routes/transactions/mappers/common/transfer-direction.helper'; +import { getAddress } from 'viem'; @Injectable() export class Erc20TransferMapper { @@ -36,13 +37,13 @@ export class Erc20TransferMapper { const direction = getTransferDirection(transaction.safe, sender, recipient); const senderAddressInfo = await this.addressInfoHelper.getOrDefault( chainId, - sender, + getAddress(sender), ['TOKEN', 'CONTRACT'], ); const recipientAddressInfo = await this.addressInfoHelper.getOrDefault( chainId, - recipient, + getAddress(recipient), ['TOKEN', 'CONTRACT'], ); diff --git a/src/routes/transactions/mappers/common/erc721-transfer.mapper.ts b/src/routes/transactions/mappers/common/erc721-transfer.mapper.ts index e99eaffcc5..7be3dfe4dd 100644 --- a/src/routes/transactions/mappers/common/erc721-transfer.mapper.ts +++ b/src/routes/transactions/mappers/common/erc721-transfer.mapper.ts @@ -9,6 +9,7 @@ import { TransferTransactionInfo } from '@/routes/transactions/entities/transfer import { Erc721Transfer } from '@/routes/transactions/entities/transfers/erc721-transfer.entity'; import { DataDecodedParamHelper } from '@/routes/transactions/mappers/common/data-decoded-param.helper'; import { getTransferDirection } from '@/routes/transactions/mappers/common/transfer-direction.helper'; +import { getAddress } from 'viem'; @Injectable() export class Erc721TransferMapper { @@ -36,13 +37,13 @@ export class Erc721TransferMapper { const direction = getTransferDirection(transaction.safe, sender, recipient); const senderAddressInfo = await this.addressInfoHelper.getOrDefault( chainId, - sender, + getAddress(sender), ['TOKEN', 'CONTRACT'], ); const recipientAddressInfo = await this.addressInfoHelper.getOrDefault( chainId, - recipient, + getAddress(recipient), ['TOKEN', 'CONTRACT'], ); diff --git a/src/routes/transactions/mappers/common/settings-change.mapper.spec.ts b/src/routes/transactions/mappers/common/settings-change.mapper.spec.ts index e59ae51399..2fcf8a0437 100644 --- a/src/routes/transactions/mappers/common/settings-change.mapper.spec.ts +++ b/src/routes/transactions/mappers/common/settings-change.mapper.spec.ts @@ -205,9 +205,7 @@ describe('Multisig Settings Change Transaction mapper (Unit)', () => { dataDecodedBuilder() .with('method', 'enableModule') .with('parameters', [ - dataDecodedParameterBuilder() - .with('value', faker.string.numeric()) - .build(), + dataDecodedParameterBuilder().with('value', moduleAddress).build(), ]) .build(), ) @@ -234,7 +232,7 @@ describe('Multisig Settings Change Transaction mapper (Unit)', () => { .with('method', 'disableModule') .with('parameters', [ dataDecodedParameterBuilder() - .with('value', faker.string.numeric()) + .with('value', faker.finance.ethereumAddress()) .build(), dataDecodedParameterBuilder().with('value', moduleAddress).build(), ]) diff --git a/src/routes/transactions/mappers/common/settings-change.mapper.ts b/src/routes/transactions/mappers/common/settings-change.mapper.ts index 6e1a6c9427..9ddaafb560 100644 --- a/src/routes/transactions/mappers/common/settings-change.mapper.ts +++ b/src/routes/transactions/mappers/common/settings-change.mapper.ts @@ -17,6 +17,7 @@ import { SetGuard } from '@/routes/transactions/entities/settings-changes/set-gu import { SettingsChange } from '@/routes/transactions/entities/settings-changes/settings-change.entity'; import { SwapOwner } from '@/routes/transactions/entities/settings-changes/swap-owner.entity'; import { DataDecodedParamHelper } from '@/routes/transactions/mappers/common/data-decoded-param.helper'; +import { getAddress } from 'viem'; @Injectable() export class SettingsChangeMapper { @@ -58,7 +59,7 @@ export class SettingsChangeMapper { if (typeof handler !== 'string') return null; const addressInfo = await this.addressInfoHelper.getOrDefault( chainId, - handler, + getAddress(handler), ['CONTRACT'], ); return new SetFallbackHandler(addressInfo); @@ -123,7 +124,7 @@ export class SettingsChangeMapper { const implementationInfo = await this.addressInfoHelper.getOrDefault( chainId, - implementation, + getAddress(implementation), ['CONTRACT'], ); return new ChangeMasterCopy(implementationInfo); @@ -142,7 +143,7 @@ export class SettingsChangeMapper { const moduleInfo = await this.addressInfoHelper.getOrDefault( chainId, - module, + getAddress(module), ['CONTRACT'], ); return new EnableModule(moduleInfo); @@ -161,7 +162,7 @@ export class SettingsChangeMapper { const moduleInfo = await this.addressInfoHelper.getOrDefault( chainId, - module, + getAddress(module), ['CONTRACT'], ); return new DisableModule(moduleInfo); @@ -193,7 +194,7 @@ export class SettingsChangeMapper { if (guardValue !== NULL_ADDRESS) { const guardAddressInfo = await this.addressInfoHelper.getOrDefault( chainId, - guardValue, + getAddress(guardValue), ['CONTRACT'], ); return new SetGuard(guardAddressInfo); diff --git a/src/routes/transactions/mappers/common/transaction-data.mapper.spec.ts b/src/routes/transactions/mappers/common/transaction-data.mapper.spec.ts index a413ed44cc..a981b001e9 100644 --- a/src/routes/transactions/mappers/common/transaction-data.mapper.spec.ts +++ b/src/routes/transactions/mappers/common/transaction-data.mapper.spec.ts @@ -12,6 +12,7 @@ import { AddressInfo } from '@/routes/common/entities/address-info.entity'; import { MULTI_SEND_METHOD_NAME } from '@/routes/transactions/constants'; import { DataDecodedParamHelper } from '@/routes/transactions/mappers/common/data-decoded-param.helper'; import { TransactionDataMapper } from '@/routes/transactions/mappers/common/transaction-data.mapper'; +import { getAddress } from 'viem'; const addressInfoHelper = jest.mocked({ get: jest.fn(), @@ -42,7 +43,7 @@ describe('Transaction Data Mapper (Unit)', () => { const actual = await mapper.isTrustedDelegateCall( faker.string.numeric(), 0, - faker.finance.ethereumAddress(), + getAddress(faker.finance.ethereumAddress()), dataDecodedBuilder().build(), ); expect(actual).toBeNull(); @@ -57,7 +58,7 @@ describe('Transaction Data Mapper (Unit)', () => { const actual = await mapper.isTrustedDelegateCall( faker.string.numeric(), Operation.DELEGATE, - faker.finance.ethereumAddress(), + getAddress(faker.finance.ethereumAddress()), null, ); @@ -69,7 +70,7 @@ describe('Transaction Data Mapper (Unit)', () => { const actual = await mapper.isTrustedDelegateCall( faker.string.numeric(), 1, - faker.finance.ethereumAddress(), + getAddress(faker.finance.ethereumAddress()), dataDecodedBuilder().build(), ); expect(actual).toBe(false); @@ -82,7 +83,7 @@ describe('Transaction Data Mapper (Unit)', () => { const actual = await mapper.isTrustedDelegateCall( faker.string.numeric(), 1, - faker.finance.ethereumAddress(), + getAddress(faker.finance.ethereumAddress()), dataDecodedBuilder().build(), ); expect(actual).toBe(false); @@ -96,7 +97,7 @@ describe('Transaction Data Mapper (Unit)', () => { const actual = await mapper.isTrustedDelegateCall( faker.string.numeric(), 1, - faker.finance.ethereumAddress(), + getAddress(faker.finance.ethereumAddress()), dataDecodedBuilder().build(), ); expect(actual).toBe(true); @@ -110,7 +111,7 @@ describe('Transaction Data Mapper (Unit)', () => { const actual = await mapper.isTrustedDelegateCall( faker.string.numeric(), 1, - faker.finance.ethereumAddress(), + getAddress(faker.finance.ethereumAddress()), dataDecodedBuilder().build(), ); expect(actual).toBe(false); @@ -316,7 +317,7 @@ describe('Transaction Data Mapper (Unit)', () => { }); it('should build an address info index for a nested multiSend (2)', async () => { - const contractAddress = faker.finance.ethereumAddress(); + const contractAddress = getAddress(faker.finance.ethereumAddress()); const contractAddressInfo = new AddressInfo( contractAddress, faker.word.sample(), diff --git a/src/routes/transactions/mappers/common/transaction-data.mapper.ts b/src/routes/transactions/mappers/common/transaction-data.mapper.ts index 572cfc63e8..b9f05c5b3a 100644 --- a/src/routes/transactions/mappers/common/transaction-data.mapper.ts +++ b/src/routes/transactions/mappers/common/transaction-data.mapper.ts @@ -16,6 +16,7 @@ import { PreviewTransactionDto } from '@/routes/transactions/entities/preview-tr import { TransactionData } from '@/routes/transactions/entities/transaction-data.entity'; import { DataDecodedParamHelper } from '@/routes/transactions/mappers/common/data-decoded-param.helper'; import { AddressInfo } from '@/routes/common/entities/address-info.entity'; +import { getAddress } from 'viem'; @Injectable() export class TransactionDataMapper { @@ -73,7 +74,7 @@ export class TransactionDataMapper { async isTrustedDelegateCall( chainId: string, operation: Operation, - to: string, + to: `0x${string}`, dataDecoded: DataDecoded | null, ): Promise { if (operation !== Operation.DELEGATE) return null; @@ -173,7 +174,7 @@ export class TransactionDataMapper { ): Promise { if (typeof value === 'string' && value !== NULL_ADDRESS) { const addressInfo = await this.addressInfoHelper - .get(chainId, value, ['TOKEN', 'CONTRACT']) + .get(chainId, getAddress(value), ['TOKEN', 'CONTRACT']) .catch(() => null); return addressInfo?.name ? addressInfo : null; } diff --git a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-details.mapper.ts b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-details.mapper.ts index 4bc50fbcf1..b292671433 100644 --- a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-details.mapper.ts +++ b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-details.mapper.ts @@ -93,7 +93,7 @@ export class MultisigTransactionDetailsMapper { */ private async _getRecipientAddressInfo( chainId: string, - address: string, + address: `0x${string}`, ): Promise { return await this.addressInfoHelper.getOrDefault(chainId, address, [ 'TOKEN', diff --git a/src/routes/transactions/transactions-history.controller.spec.ts b/src/routes/transactions/transactions-history.controller.spec.ts index 6fe0b91435..1ac533d9d4 100644 --- a/src/routes/transactions/transactions-history.controller.spec.ts +++ b/src/routes/transactions/transactions-history.controller.spec.ts @@ -587,7 +587,10 @@ describe('Transactions History Controller (Unit)', () => { txInfo: { type: 'Transfer', sender: { value: multisigTransaction.safe }, - recipient: { value: multisigTransactionToAddress }, + // Decoder checksums address (although Transaction Service likely returns it checksummed) + recipient: { + value: getAddress(multisigTransactionToAddress), + }, direction: 'OUTGOING', transferInfo: { type: 'ERC20', From e94497dd4de965edaddbf39eb87f82d3528529dd Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Wed, 29 May 2024 16:06:16 +0200 Subject: [PATCH 041/207] Checksum `delegateAddress` when deleting delegate and propagate type (#1597) Checksums incoming `delegateAddress` of `ContractsController['getContract']` and propagates the stricter (`0x${string}`) type throughout the project accordingly: - Checksum `delegateAddress` in `DelegatesController['deleteDelegate']` - Increase strictness of `delegateAddress` in `DelegatesService['deleteDelegate']` - Increase strictness of addresses in `IDelegateRepository`/`IDelegatesV2Repository` and their implementations - Increase strictness of delegate fetching from `ITransactionApi` and its implementation - Increase strictness of cache dir accordingly --- src/datasources/cache/cache.router.ts | 6 +++--- .../transaction-api.service.ts | 20 +++++++++---------- .../delegate/delegate.repository.interface.ts | 14 ++++++------- src/domain/delegate/delegate.repository.ts | 14 ++++++------- .../delegate/v2/delegates.v2.repository.ts | 6 +++--- .../interfaces/transaction-api.interface.ts | 20 +++++++++---------- src/routes/delegates/delegates.controller.ts | 4 +++- src/routes/delegates/delegates.service.ts | 2 +- 8 files changed, 44 insertions(+), 42 deletions(-) diff --git a/src/datasources/cache/cache.router.ts b/src/datasources/cache/cache.router.ts index 275d6f2a5f..b9d6461583 100644 --- a/src/datasources/cache/cache.router.ts +++ b/src/datasources/cache/cache.router.ts @@ -157,9 +157,9 @@ export class CacheRouter { static getDelegatesCacheDir(args: { chainId: string; - safeAddress?: string; - delegate?: string; - delegator?: string; + safeAddress?: `0x${string}`; + delegate?: `0x${string}`; + delegator?: `0x${string}`; label?: string; limit?: number; offset?: number; diff --git a/src/datasources/transaction-api/transaction-api.service.ts b/src/datasources/transaction-api/transaction-api.service.ts index 4d87a17a85..57c62d558b 100644 --- a/src/datasources/transaction-api/transaction-api.service.ts +++ b/src/datasources/transaction-api/transaction-api.service.ts @@ -168,9 +168,9 @@ export class TransactionApi implements ITransactionApi { } async getDelegates(args: { - safeAddress?: string; - delegate?: string; - delegator?: string; + safeAddress?: `0x${string}`; + delegate?: `0x${string}`; + delegator?: `0x${string}`; label?: string; limit?: number; offset?: number; @@ -202,9 +202,9 @@ export class TransactionApi implements ITransactionApi { } async getDelegatesV2(args: { - safeAddress?: string; - delegate?: string; - delegator?: string; + safeAddress?: `0x${string}`; + delegate?: `0x${string}`; + delegator?: `0x${string}`; label?: string; limit?: number; offset?: number; @@ -284,8 +284,8 @@ export class TransactionApi implements ITransactionApi { } async deleteDelegate(args: { - delegate: string; - delegator: string; + delegate: `0x${string}`; + delegator: `0x${string}`; signature: string; }): Promise { try { @@ -304,8 +304,8 @@ export class TransactionApi implements ITransactionApi { } async deleteSafeDelegate(args: { - delegate: string; - safeAddress: string; + delegate: `0x${string}`; + safeAddress: `0x${string}`; signature: string; }): Promise { try { diff --git a/src/domain/delegate/delegate.repository.interface.ts b/src/domain/delegate/delegate.repository.interface.ts index 2f9c52b5f3..2d68d70443 100644 --- a/src/domain/delegate/delegate.repository.interface.ts +++ b/src/domain/delegate/delegate.repository.interface.ts @@ -9,9 +9,9 @@ export const IDelegateRepository = Symbol('IDelegateRepository'); export interface IDelegateRepository { getDelegates(args: { chainId: string; - safeAddress?: string; - delegate?: string; - delegator?: string; + safeAddress?: `0x${string}`; + delegate?: `0x${string}`; + delegator?: `0x${string}`; label?: string; limit?: number; offset?: number; @@ -28,15 +28,15 @@ export interface IDelegateRepository { deleteDelegate(args: { chainId: string; - delegate: string; - delegator: string; + delegate: `0x${string}`; + delegator: `0x${string}`; signature: string; }): Promise; deleteSafeDelegate(args: { chainId: string; - delegate: string; - safeAddress: string; + delegate: `0x${string}`; + safeAddress: `0x${string}`; signature: string; }): Promise; } diff --git a/src/domain/delegate/delegate.repository.ts b/src/domain/delegate/delegate.repository.ts index 4f8fab71f1..2b156ce45d 100644 --- a/src/domain/delegate/delegate.repository.ts +++ b/src/domain/delegate/delegate.repository.ts @@ -14,9 +14,9 @@ export class DelegateRepository implements IDelegateRepository { async getDelegates(args: { chainId: string; - safeAddress?: string; - delegate?: string; - delegator?: string; + safeAddress?: `0x${string}`; + delegate?: `0x${string}`; + delegator?: `0x${string}`; label?: string; limit?: number; offset?: number; @@ -56,8 +56,8 @@ export class DelegateRepository implements IDelegateRepository { async deleteDelegate(args: { chainId: string; - delegate: string; - delegator: string; + delegate: `0x${string}`; + delegator: `0x${string}`; signature: string; }): Promise { const transactionService = @@ -71,8 +71,8 @@ export class DelegateRepository implements IDelegateRepository { async deleteSafeDelegate(args: { chainId: string; - delegate: string; - safeAddress: string; + delegate: `0x${string}`; + safeAddress: `0x${string}`; signature: string; }): Promise { const transactionService = diff --git a/src/domain/delegate/v2/delegates.v2.repository.ts b/src/domain/delegate/v2/delegates.v2.repository.ts index b54cb976dc..eba00b0cc8 100644 --- a/src/domain/delegate/v2/delegates.v2.repository.ts +++ b/src/domain/delegate/v2/delegates.v2.repository.ts @@ -14,9 +14,9 @@ export class DelegatesV2Repository implements IDelegatesV2Repository { async getDelegates(args: { chainId: string; - safeAddress?: string; - delegate?: string; - delegator?: string; + safeAddress?: `0x${string}`; + delegate?: `0x${string}`; + delegator?: `0x${string}`; label?: string; limit?: number; offset?: number; diff --git a/src/domain/interfaces/transaction-api.interface.ts b/src/domain/interfaces/transaction-api.interface.ts index 9ef249ae99..6fb970f004 100644 --- a/src/domain/interfaces/transaction-api.interface.ts +++ b/src/domain/interfaces/transaction-api.interface.ts @@ -36,18 +36,18 @@ export interface ITransactionApi { getContract(contractAddress: `0x${string}`): Promise; getDelegates(args: { - safeAddress?: string; - delegate?: string; - delegator?: string; + safeAddress?: `0x${string}`; + delegate?: `0x${string}`; + delegator?: `0x${string}`; label?: string; limit?: number; offset?: number; }): Promise>; getDelegatesV2(args: { - safeAddress?: string; - delegate?: string; - delegator?: string; + safeAddress?: `0x${string}`; + delegate?: `0x${string}`; + delegator?: `0x${string}`; label?: string; limit?: number; offset?: number; @@ -70,14 +70,14 @@ export interface ITransactionApi { }): Promise; deleteDelegate(args: { - delegate: string; - delegator: string; + delegate: `0x${string}`; + delegator: `0x${string}`; signature: string; }): Promise; deleteSafeDelegate(args: { - delegate: string; - safeAddress: string; + delegate: `0x${string}`; + safeAddress: `0x${string}`; signature: string; }): Promise; diff --git a/src/routes/delegates/delegates.controller.ts b/src/routes/delegates/delegates.controller.ts index 2cb2d6dce7..a7216c3958 100644 --- a/src/routes/delegates/delegates.controller.ts +++ b/src/routes/delegates/delegates.controller.ts @@ -30,6 +30,7 @@ import { GetDelegateDtoSchema } from '@/routes/delegates/entities/schemas/get-de import { CreateDelegateDtoSchema } from '@/routes/delegates/entities/schemas/create-delegate.dto.schema'; import { DeleteDelegateDtoSchema } from '@/routes/delegates/entities/schemas/delete-delegate.dto.schema'; import { DeleteSafeDelegateDtoSchema } from '@/routes/delegates/entities/schemas/delete-safe-delegate.dto.schema'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; @ApiTags('delegates') @Controller({ @@ -96,7 +97,8 @@ export class DelegatesController { @Delete('chains/:chainId/delegates/:delegateAddress') async deleteDelegate( @Param('chainId') chainId: string, - @Param('delegateAddress') delegateAddress: string, + @Param('delegateAddress', new ValidationPipe(AddressSchema)) + delegateAddress: `0x${string}`, @Body(new ValidationPipe(DeleteDelegateDtoSchema)) deleteDelegateDto: DeleteDelegateDto, ): Promise { diff --git a/src/routes/delegates/delegates.service.ts b/src/routes/delegates/delegates.service.ts index eb7ab3cbfc..0f47b8edee 100644 --- a/src/routes/delegates/delegates.service.ts +++ b/src/routes/delegates/delegates.service.ts @@ -64,7 +64,7 @@ export class DelegatesService { async deleteDelegate(args: { chainId: string; - delegateAddress: string; + delegateAddress: `0x${string}`; deleteDelegateDto: DeleteDelegateDto; }): Promise { return await this.repository.deleteDelegate({ From c105c1c526c49e8a0f55fa299b8e9c7ae97e2218 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Wed, 29 May 2024 16:24:39 +0200 Subject: [PATCH 042/207] Checksum addresses in email routes and propagate type (#1598) Checksums the incoming `signer`/`safeAddress` of `EmailController` and propagates the stricter (`0x${string}`) type throughout the project accordingly: - Checksum addresses in `EmailController` - Increase strictness of addresses in `EmailService` - Increase strictness of addresses in `IAccountRepository` and its implementation - Increase strictness of addresses in account exceptions contructors --- .../account/account.repository.interface.ts | 18 +-- src/domain/account/account.repository.ts | 133 ++++++------------ .../errors/account-does-not-exist.error.ts | 8 +- .../account/errors/account-save.error.ts | 6 +- .../errors/email-already-verified.error.ts | 6 +- .../errors/email-edit-matches.error.ts | 6 +- .../errors/invalid-verification-code.error.ts | 6 +- .../errors/verification-timeframe.error.ts | 4 +- src/domain/alerts/alerts.repository.ts | 2 +- src/routes/email/email.controller.ts | 22 +-- src/routes/email/email.service.ts | 16 +-- 11 files changed, 105 insertions(+), 122 deletions(-) diff --git a/src/domain/account/account.repository.interface.ts b/src/domain/account/account.repository.interface.ts index 15f78a4171..1c8400a37d 100644 --- a/src/domain/account/account.repository.interface.ts +++ b/src/domain/account/account.repository.interface.ts @@ -6,14 +6,14 @@ export const IAccountRepository = Symbol('IAccountRepository'); export interface IAccountRepository { getAccount(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; signer: `0x${string}`; authPayload: AuthPayload; }): Promise; getAccounts(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; onlyVerified: boolean; }): Promise; @@ -28,7 +28,7 @@ export interface IAccountRepository { */ createAccount(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; emailAddress: string; signer: `0x${string}`; authPayload: AuthPayload; @@ -48,8 +48,8 @@ export interface IAccountRepository { */ resendEmailVerification(args: { chainId: string; - safeAddress: string; - signer: string; + safeAddress: `0x${string}`; + signer: `0x${string}`; }): Promise; /** @@ -65,8 +65,8 @@ export interface IAccountRepository { */ verifyEmailAddress(args: { chainId: string; - safeAddress: string; - signer: string; + safeAddress: `0x${string}`; + signer: `0x${string}`; code: string; }): Promise; @@ -80,7 +80,7 @@ export interface IAccountRepository { */ deleteAccount(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; signer: `0x${string}`; authPayload: AuthPayload; }): Promise; @@ -98,7 +98,7 @@ export interface IAccountRepository { */ editEmail(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; emailAddress: string; signer: `0x${string}`; authPayload: AuthPayload; diff --git a/src/domain/account/account.repository.ts b/src/domain/account/account.repository.ts index 9e074696dc..ee8ca801d0 100644 --- a/src/domain/account/account.repository.ts +++ b/src/domain/account/account.repository.ts @@ -20,7 +20,6 @@ import { SubscriptionRepository } from '@/domain/subscriptions/subscription.repo import { AccountDoesNotExistError } from '@/domain/account/errors/account-does-not-exist.error'; import { ILoggingService, LoggingService } from '@/logging/logging.interface'; import { VerificationCodeDoesNotExistError } from '@/datasources/account/errors/verification-code-does-not-exist.error'; -import { getAddress } from 'viem'; import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; @@ -60,55 +59,45 @@ export class AccountRepository implements IAccountRepository { async getAccount(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; signer: `0x${string}`; authPayload: AuthPayload; }): Promise { - const safeAddress = getAddress(args.safeAddress); - const signer = getAddress(args.signer); - if ( !args.authPayload.isForChain(args.chainId) || - !args.authPayload.isForSigner(signer) + !args.authPayload.isForSigner(args.signer) ) { throw new UnauthorizedException(); } return this.accountDataSource.getAccount({ chainId: args.chainId, - safeAddress, - signer, + safeAddress: args.safeAddress, + signer: args.signer, }); } getAccounts(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; onlyVerified: boolean; }): Promise { - const safeAddress = getAddress(args.safeAddress); - return this.accountDataSource.getAccounts({ - chainId: args.chainId, - safeAddress, - onlyVerified: args.onlyVerified, - }); + return this.accountDataSource.getAccounts(args); } async createAccount(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; emailAddress: string; signer: `0x${string}`; authPayload: AuthPayload; }): Promise { const email = new EmailAddress(args.emailAddress); const verificationCode = this._generateCode(); - const safeAddress = getAddress(args.safeAddress); - const signer = getAddress(args.signer); if ( !args.authPayload.isForChain(args.chainId) || - !args.authPayload.isForSigner(signer) + !args.authPayload.isForSigner(args.signer) ) { throw new UnauthorizedException(); } @@ -116,9 +105,9 @@ export class AccountRepository implements IAccountRepository { // Check after AuthPayload check to avoid unnecessary request const isOwner = await this.safeRepository .isOwner({ - safeAddress, + safeAddress: args.safeAddress, chainId: args.chainId, - address: signer, + address: args.signer, }) // Swallow error to avoid leaking information .catch(() => false); @@ -131,22 +120,20 @@ export class AccountRepository implements IAccountRepository { chainId: args.chainId, code: verificationCode, emailAddress: email, - safeAddress, - signer, + safeAddress: args.safeAddress, + signer: args.signer, codeGenerationDate: new Date(), unsubscriptionToken: crypto.randomUUID(), }); // New account registrations should be subscribed to the Account Recovery category await this.subscriptionRepository.subscribe({ chainId: args.chainId, - signer, - safeAddress, + signer: args.signer, + safeAddress: args.safeAddress, notificationTypeKey: SubscriptionRepository.CATEGORY_ACCOUNT_RECOVERY, }); this._sendEmailVerification({ ...args, - signer, - safeAddress, code: verificationCode, }); } catch (e) { @@ -156,16 +143,10 @@ export class AccountRepository implements IAccountRepository { async resendEmailVerification(args: { chainId: string; - safeAddress: string; - signer: string; + safeAddress: `0x${string}`; + signer: `0x${string}`; }): Promise { - const safeAddress = getAddress(args.safeAddress); - const signer = getAddress(args.signer); - const account = await this.accountDataSource.getAccount({ - chainId: args.chainId, - safeAddress, - signer, - }); + const account = await this.accountDataSource.getAccount(args); // If the account was already verified, we should not send out a new // verification code @@ -175,11 +156,7 @@ export class AccountRepository implements IAccountRepository { const verificationCode: VerificationCode | null = await this.accountDataSource - .getAccountVerificationCode({ - chainId: args.chainId, - safeAddress, - signer, - }) + .getAccountVerificationCode(args) .catch((reason) => { this.loggingService.warn(reason); return null; @@ -202,24 +179,20 @@ export class AccountRepository implements IAccountRepository { // Expired or non-existent code. Generate new one await this.accountDataSource.setEmailVerificationCode({ chainId: args.chainId, - safeAddress, - signer, + safeAddress: args.safeAddress, + signer: args.signer, code: this._generateCode(), codeGenerationDate: new Date(), }); } const currentVerificationCode = - await this.accountDataSource.getAccountVerificationCode({ - chainId: args.chainId, - safeAddress, - signer, - }); + await this.accountDataSource.getAccountVerificationCode(args); this._sendEmailVerification({ chainId: args.chainId, - safeAddress, - signer, + safeAddress: args.safeAddress, + signer: args.signer, code: currentVerificationCode.code, emailAddress: account.emailAddress.value, }); @@ -227,17 +200,11 @@ export class AccountRepository implements IAccountRepository { async verifyEmailAddress(args: { chainId: string; - safeAddress: string; - signer: string; + safeAddress: `0x${string}`; + signer: `0x${string}`; code: string; }): Promise { - const safeAddress = getAddress(args.safeAddress); - const signer = getAddress(args.signer); - const account = await this.accountDataSource.getAccount({ - chainId: args.chainId, - safeAddress, - signer, - }); + const account = await this.accountDataSource.getAccount(args); if (account.isVerified) { // account is already verified, so we don't need to perform further checks @@ -247,11 +214,7 @@ export class AccountRepository implements IAccountRepository { let verificationCode: VerificationCode; try { verificationCode = - await this.accountDataSource.getAccountVerificationCode({ - chainId: args.chainId, - safeAddress, - signer, - }); + await this.accountDataSource.getAccountVerificationCode(args); } catch (e) { // If we attempt to verify an email is done without a verification code in place, // Send a new code to the client's email address for verification @@ -274,23 +237,20 @@ export class AccountRepository implements IAccountRepository { // TODO: it is possible that when verifying the email address, a new code generation was triggered await this.accountDataSource.verifyEmail({ chainId: args.chainId, - safeAddress, - signer, + safeAddress: args.safeAddress, + signer: args.signer, }); } async deleteAccount(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; signer: `0x${string}`; authPayload: AuthPayload; }): Promise { - const safeAddress = getAddress(args.safeAddress); - const signer = getAddress(args.signer); - if ( !args.authPayload.isForChain(args.chainId) || - !args.authPayload.isForSigner(signer) + !args.authPayload.isForSigner(args.signer) ) { throw new UnauthorizedException(); } @@ -298,8 +258,8 @@ export class AccountRepository implements IAccountRepository { try { const account = await this.accountDataSource.getAccount({ chainId: args.chainId, - safeAddress, - signer, + safeAddress: args.safeAddress, + signer: args.signer, }); // If there is an error deleting the email address, // do not delete the respective account as we still need to get the email @@ -309,8 +269,8 @@ export class AccountRepository implements IAccountRepository { }); await this.accountDataSource.deleteAccount({ chainId: args.chainId, - safeAddress, - signer, + safeAddress: args.safeAddress, + signer: args.signer, }); } catch (error) { this.loggingService.warn(error); @@ -323,25 +283,22 @@ export class AccountRepository implements IAccountRepository { async editEmail(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; emailAddress: string; signer: `0x${string}`; authPayload: AuthPayload; }): Promise { - const safeAddress = getAddress(args.safeAddress); - const signer = getAddress(args.signer); - if ( !args.authPayload.isForChain(args.chainId) || - !args.authPayload.isForSigner(signer) + !args.authPayload.isForSigner(args.signer) ) { throw new UnauthorizedException(); } const account = await this.accountDataSource.getAccount({ chainId: args.chainId, - safeAddress, - signer, + safeAddress: args.safeAddress, + signer: args.signer, }); const newEmail = new EmailAddress(args.emailAddress); @@ -354,21 +311,21 @@ export class AccountRepository implements IAccountRepository { await this.accountDataSource.updateAccountEmail({ chainId: args.chainId, emailAddress: newEmail, - safeAddress, - signer, + safeAddress: args.safeAddress, + signer: args.signer, unsubscriptionToken: crypto.randomUUID(), }); await this.accountDataSource.setEmailVerificationCode({ chainId: args.chainId, code: newVerificationCode, - signer, + signer: args.signer, codeGenerationDate: new Date(), - safeAddress, + safeAddress: args.safeAddress, }); this._sendEmailVerification({ chainId: args.chainId, - safeAddress, - signer, + safeAddress: args.safeAddress, + signer: args.signer, emailAddress: args.emailAddress, code: newVerificationCode, }); diff --git a/src/domain/account/errors/account-does-not-exist.error.ts b/src/domain/account/errors/account-does-not-exist.error.ts index 8db5d64b66..333b7fbf76 100644 --- a/src/domain/account/errors/account-does-not-exist.error.ts +++ b/src/domain/account/errors/account-does-not-exist.error.ts @@ -1,7 +1,11 @@ export class AccountDoesNotExistError extends Error { - readonly signer: string; + readonly signer: `0x${string}`; - constructor(chainId: string, safeAddress: string, signer: string) { + constructor( + chainId: string, + safeAddress: `0x${string}`, + signer: `0x${string}`, + ) { super( `Account for ${signer} of ${safeAddress} on chain ${chainId} does not exist.`, ); diff --git a/src/domain/account/errors/account-save.error.ts b/src/domain/account/errors/account-save.error.ts index f2bc24bd02..cae98e5e3c 100644 --- a/src/domain/account/errors/account-save.error.ts +++ b/src/domain/account/errors/account-save.error.ts @@ -1,5 +1,9 @@ export class AccountSaveError extends Error { - constructor(chainId: string, safeAddress: string, signer: string) { + constructor( + chainId: string, + safeAddress: `0x${string}`, + signer: `0x${string}`, + ) { super( `Error while creating account. Account was not created. chainId=${chainId}, safeAddress=${safeAddress}, signer=${signer}`, ); diff --git a/src/domain/account/errors/email-already-verified.error.ts b/src/domain/account/errors/email-already-verified.error.ts index b256f72409..9f21760251 100644 --- a/src/domain/account/errors/email-already-verified.error.ts +++ b/src/domain/account/errors/email-already-verified.error.ts @@ -1,7 +1,11 @@ export class EmailAlreadyVerifiedError extends Error { readonly signer: string; - constructor(args: { chainId: string; safeAddress: string; signer: string }) { + constructor(args: { + chainId: string; + safeAddress: `0x${string}`; + signer: `0x${string}`; + }) { super( `The email address is already verified. chainId=${args.chainId}, safeAddress=${args.safeAddress}, signer=${args.signer}`, ); diff --git a/src/domain/account/errors/email-edit-matches.error.ts b/src/domain/account/errors/email-edit-matches.error.ts index be2611f1b6..5ca730ad85 100644 --- a/src/domain/account/errors/email-edit-matches.error.ts +++ b/src/domain/account/errors/email-edit-matches.error.ts @@ -1,5 +1,9 @@ export class EmailEditMatchesError extends Error { - constructor(args: { chainId: string; safeAddress: string; signer: string }) { + constructor(args: { + chainId: string; + safeAddress: `0x${string}`; + signer: `0x${string}`; + }) { super( `The provided email address matches that set for the Safe owner. chainId=${args.chainId}, safeAddress=${args.safeAddress}, signer=${args.signer}`, ); diff --git a/src/domain/account/errors/invalid-verification-code.error.ts b/src/domain/account/errors/invalid-verification-code.error.ts index 535a528a58..267135bc16 100644 --- a/src/domain/account/errors/invalid-verification-code.error.ts +++ b/src/domain/account/errors/invalid-verification-code.error.ts @@ -1,5 +1,9 @@ export class InvalidVerificationCodeError extends Error { - constructor(args: { chainId: string; safeAddress: string; signer: string }) { + constructor(args: { + chainId: string; + safeAddress: `0x${string}`; + signer: `0x${string}`; + }) { super( `The verification code is invalid. chainId=${args.chainId}, safeAddress=${args.safeAddress}, signer=${args.signer} `, ); diff --git a/src/domain/account/errors/verification-timeframe.error.ts b/src/domain/account/errors/verification-timeframe.error.ts index 6f7652ca2c..7ef330977c 100644 --- a/src/domain/account/errors/verification-timeframe.error.ts +++ b/src/domain/account/errors/verification-timeframe.error.ts @@ -1,8 +1,8 @@ export class ResendVerificationTimespanError extends Error { constructor(args: { chainId: string; - safeAddress: string; - signer: string; + safeAddress: `0x${string}`; + signer: `0x${string}`; timespanMs: number; lockWindowMs: number; }) { diff --git a/src/domain/alerts/alerts.repository.ts b/src/domain/alerts/alerts.repository.ts index 23bfb3126c..d8f11f6e94 100644 --- a/src/domain/alerts/alerts.repository.ts +++ b/src/domain/alerts/alerts.repository.ts @@ -127,7 +127,7 @@ export class AlertsRepository implements IAlertsRepository { */ private async _getSubscribedAccounts(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise { const accounts = await this.accountRepository.getAccounts({ chainId: args.chainId, diff --git a/src/routes/email/email.controller.ts b/src/routes/email/email.controller.ts index 38d555a2fa..9d8b9df536 100644 --- a/src/routes/email/email.controller.ts +++ b/src/routes/email/email.controller.ts @@ -43,7 +43,8 @@ export class EmailController { @UseFilters(AccountDoesNotExistExceptionFilter) async getEmail( @Param('chainId') chainId: string, - @Param('safeAddress') safeAddress: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, @Param('signer', new ValidationPipe(AddressSchema)) signer: `0x${string}`, @Auth() authPayload: AuthPayload, ): Promise { @@ -59,7 +60,8 @@ export class EmailController { @UseGuards(AuthGuard) async saveEmail( @Param('chainId') chainId: string, - @Param('safeAddress') safeAddress: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, @Body(new ValidationPipe(SaveEmailDtoSchema)) saveEmailDto: SaveEmailDto, @Auth() authPayload: AuthPayload, ): Promise { @@ -77,8 +79,9 @@ export class EmailController { @HttpCode(HttpStatus.ACCEPTED) async resendVerification( @Param('chainId') chainId: string, - @Param('safeAddress') safeAddress: string, - @Param('signer') signer: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, + @Param('signer', new ValidationPipe(AddressSchema)) signer: `0x${string}`, ): Promise { await this.service.resendVerification({ chainId, @@ -92,8 +95,9 @@ export class EmailController { @HttpCode(HttpStatus.NO_CONTENT) async verifyEmailAddress( @Param('chainId') chainId: string, - @Param('safeAddress') safeAddress: string, - @Param('signer') signer: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, + @Param('signer', new ValidationPipe(AddressSchema)) signer: `0x${string}`, @Body() verifyEmailDto: VerifyEmailDto, ): Promise { await this.service.verifyEmailAddress({ @@ -110,7 +114,8 @@ export class EmailController { @HttpCode(HttpStatus.NO_CONTENT) async deleteEmail( @Param('chainId') chainId: string, - @Param('safeAddress') safeAddress: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, @Param('signer', new ValidationPipe(AddressSchema)) signer: `0x${string}`, @Auth() authPayload: AuthPayload, ): Promise { @@ -131,7 +136,8 @@ export class EmailController { @HttpCode(HttpStatus.ACCEPTED) async editEmail( @Param('chainId') chainId: string, - @Param('safeAddress') safeAddress: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, @Param('signer', new ValidationPipe(AddressSchema)) signer: `0x${string}`, @Body() editEmailDto: EditEmailDto, @Auth() authPayload: AuthPayload, diff --git a/src/routes/email/email.service.ts b/src/routes/email/email.service.ts index e5036be692..9e88cd4e28 100644 --- a/src/routes/email/email.service.ts +++ b/src/routes/email/email.service.ts @@ -23,7 +23,7 @@ export class EmailService { async saveEmail(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; emailAddress: string; signer: `0x${string}`; authPayload: AuthPayload; @@ -35,8 +35,8 @@ export class EmailService { async resendVerification(args: { chainId: string; - safeAddress: string; - signer: string; + safeAddress: `0x${string}`; + signer: `0x${string}`; }): Promise { return this.repository .resendEmailVerification(args) @@ -45,8 +45,8 @@ export class EmailService { async verifyEmailAddress(args: { chainId: string; - safeAddress: string; - signer: string; + safeAddress: `0x${string}`; + signer: `0x${string}`; code: string; }): Promise { return this.repository @@ -56,7 +56,7 @@ export class EmailService { async deleteEmail(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; signer: `0x${string}`; authPayload: AuthPayload; }): Promise { @@ -67,7 +67,7 @@ export class EmailService { async editEmail(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; signer: `0x${string}`; emailAddress: string; authPayload: AuthPayload; @@ -79,7 +79,7 @@ export class EmailService { async getEmail(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; signer: `0x${string}`; authPayload: AuthPayload; }): Promise { From 249979b61198e56a792a2c8b3bbca57092c14f1b Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Wed, 29 May 2024 16:48:15 +0200 Subject: [PATCH 043/207] Checksum `safeAddress` when creating a message and propagate type (#1599) Checksums the incoming `safeAddress` of `MessagesController` and propagates the stricter (`0x${string}`) type throughout the project accordingly. These were done together as emails are experimental: - Checksum addresses in `MessagesController['createMessage']` - Increase strictness of `safeAddress` in `MessageService['createMessage']` and `IMessagesRepository['createMessage']` and its implementation - Increase strictness of`safeAddress` in `ITransactionApi['postMessage']` and its implementation - Update tests accordingly --- .../transaction-api/transaction-api.service.spec.ts | 4 ++-- src/datasources/transaction-api/transaction-api.service.ts | 2 +- src/domain/interfaces/transaction-api.interface.ts | 2 +- src/domain/messages/messages.repository.interface.ts | 6 +++--- src/domain/messages/messages.repository.ts | 2 +- src/routes/messages/messages.controller.ts | 3 ++- src/routes/messages/messages.service.ts | 2 +- 7 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/datasources/transaction-api/transaction-api.service.spec.ts b/src/datasources/transaction-api/transaction-api.service.spec.ts index 4195050a58..64285c9dea 100644 --- a/src/datasources/transaction-api/transaction-api.service.spec.ts +++ b/src/datasources/transaction-api/transaction-api.service.spec.ts @@ -2404,7 +2404,7 @@ describe('TransactionApi', () => { describe('postMessage', () => { it('should post message', async () => { - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const message = faker.word.words(); const safeAppId = faker.number.int(); const signature = faker.string.hexadecimal(); @@ -2437,7 +2437,7 @@ describe('TransactionApi', () => { ['Transaction Service', { nonFieldErrors: [errorMessage] }], ['standard', new Error(errorMessage)], ])(`should forward a %s error`, async (_, error) => { - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const message = faker.word.words(); const safeAppId = faker.number.int(); const signature = faker.string.hexadecimal(); diff --git a/src/datasources/transaction-api/transaction-api.service.ts b/src/datasources/transaction-api/transaction-api.service.ts index 57c62d558b..ea27482a41 100644 --- a/src/datasources/transaction-api/transaction-api.service.ts +++ b/src/datasources/transaction-api/transaction-api.service.ts @@ -918,7 +918,7 @@ export class TransactionApi implements ITransactionApi { } async postMessage(args: { - safeAddress: string; + safeAddress: `0x${string}`; message: unknown; safeAppId: number | null; signature: string; diff --git a/src/domain/interfaces/transaction-api.interface.ts b/src/domain/interfaces/transaction-api.interface.ts index 6fb970f004..3e4ddbfcbb 100644 --- a/src/domain/interfaces/transaction-api.interface.ts +++ b/src/domain/interfaces/transaction-api.interface.ts @@ -213,7 +213,7 @@ export interface ITransactionApi { }): Promise; postMessage(args: { - safeAddress: string; + safeAddress: `0x${string}`; message: unknown; safeAppId: number | null; signature: string; diff --git a/src/domain/messages/messages.repository.interface.ts b/src/domain/messages/messages.repository.interface.ts index db977824ce..9744aff073 100644 --- a/src/domain/messages/messages.repository.interface.ts +++ b/src/domain/messages/messages.repository.interface.ts @@ -14,14 +14,14 @@ export interface IMessagesRepository { getMessagesBySafe(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; limit?: number; offset?: number; }): Promise>; createMessage(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; message: unknown; safeAppId: number; signature: string; @@ -35,7 +35,7 @@ export interface IMessagesRepository { clearMessagesBySafe(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise; clearMessagesByHash(args: { diff --git a/src/domain/messages/messages.repository.ts b/src/domain/messages/messages.repository.ts index 2aa093885b..134e42530f 100644 --- a/src/domain/messages/messages.repository.ts +++ b/src/domain/messages/messages.repository.ts @@ -44,7 +44,7 @@ export class MessagesRepository implements IMessagesRepository { async createMessage(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; message: unknown; safeAppId: number | null; signature: string; diff --git a/src/routes/messages/messages.controller.ts b/src/routes/messages/messages.controller.ts index 8703763871..5820dbc465 100644 --- a/src/routes/messages/messages.controller.ts +++ b/src/routes/messages/messages.controller.ts @@ -55,7 +55,8 @@ export class MessagesController { @Post('chains/:chainId/safes/:safeAddress/messages') async createMessage( @Param('chainId') chainId: string, - @Param('safeAddress') safeAddress: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, @Body(new ValidationPipe(CreateMessageDtoSchema)) createMessageDto: CreateMessageDto, ): Promise { diff --git a/src/routes/messages/messages.service.ts b/src/routes/messages/messages.service.ts index 79b943c901..c658e4ea1d 100644 --- a/src/routes/messages/messages.service.ts +++ b/src/routes/messages/messages.service.ts @@ -123,7 +123,7 @@ export class MessagesService { async createMessage(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; createMessageDto: CreateMessageDto; }): Promise { return await this.messagesRepository.createMessage({ From 61f967314b33cd20be5c6f813df0efd0299a7b67 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Wed, 29 May 2024 16:59:35 +0200 Subject: [PATCH 044/207] Checksum `safeAddress` when unregistering notifications and propagate type (#1600) Checksums the incoming `safeAddress` of `NotificationsController` and propagates the stricter (`0x${string}`) type throughout the project accordingly: - Checksum the `safeAddress` in `NotificationsController['unregisterSafe']` - Increase strictness of `safeAddress` in `NotificationsService['unregisterSafe']` and its implementation - Increase strictness of `safeAddress` in `ITransactionApi['deleteSafeRegistration']` and its implementation - Increase strictness of `safeAddress` in `INotificationsRepository['unregisterSafe']` and its implementation - Update tests accordingly --- .../transaction-api/transaction-api.service.spec.ts | 4 ++-- src/datasources/transaction-api/transaction-api.service.ts | 2 +- src/domain/interfaces/transaction-api.interface.ts | 2 +- src/domain/notifications/notifications.repository.ts | 2 +- src/routes/notifications/notifications.controller.spec.ts | 4 +++- src/routes/notifications/notifications.controller.ts | 5 ++++- src/routes/notifications/notifications.service.ts | 2 +- 7 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/datasources/transaction-api/transaction-api.service.spec.ts b/src/datasources/transaction-api/transaction-api.service.spec.ts index 64285c9dea..83104f7bd1 100644 --- a/src/datasources/transaction-api/transaction-api.service.spec.ts +++ b/src/datasources/transaction-api/transaction-api.service.spec.ts @@ -2066,7 +2066,7 @@ describe('TransactionApi', () => { describe('deleteSafeRegistration', () => { it('should delete Safe registration', async () => { const uuid = faker.string.uuid(); - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const deleteSafeRegistrationUrl = `${baseUrl}/api/v1/notifications/devices/${uuid}/safes/${safeAddress}`; networkService.delete.mockResolvedValueOnce({ status: 200, @@ -2087,7 +2087,7 @@ describe('TransactionApi', () => { ['standard', new Error(errorMessage)], ])(`should forward a %s error`, async (_, error) => { const uuid = faker.string.uuid(); - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const deleteSafeRegistrationUrl = `${baseUrl}/api/v1/notifications/devices/${uuid}/safes/${safeAddress}`; const statusCode = faker.internet.httpStatusCode({ types: ['clientError', 'serverError'], diff --git a/src/datasources/transaction-api/transaction-api.service.ts b/src/datasources/transaction-api/transaction-api.service.ts index ea27482a41..480e55ae76 100644 --- a/src/datasources/transaction-api/transaction-api.service.ts +++ b/src/datasources/transaction-api/transaction-api.service.ts @@ -810,7 +810,7 @@ export class TransactionApi implements ITransactionApi { async deleteSafeRegistration(args: { uuid: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise { try { const url = `${this.baseUrl}/api/v1/notifications/devices/${args.uuid}/safes/${args.safeAddress}`; diff --git a/src/domain/interfaces/transaction-api.interface.ts b/src/domain/interfaces/transaction-api.interface.ts index 3e4ddbfcbb..f3fdabdf4a 100644 --- a/src/domain/interfaces/transaction-api.interface.ts +++ b/src/domain/interfaces/transaction-api.interface.ts @@ -191,7 +191,7 @@ export interface ITransactionApi { deleteSafeRegistration(args: { uuid: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise; getEstimation(args: { diff --git a/src/domain/notifications/notifications.repository.ts b/src/domain/notifications/notifications.repository.ts index 6c7429a072..662b499608 100644 --- a/src/domain/notifications/notifications.repository.ts +++ b/src/domain/notifications/notifications.repository.ts @@ -38,7 +38,7 @@ export class NotificationsRepository implements INotificationsRepository { async unregisterSafe(args: { chainId: string; uuid: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise { const api = await this.transactionApiManager.getTransactionApi( args.chainId, diff --git a/src/routes/notifications/notifications.controller.spec.ts b/src/routes/notifications/notifications.controller.spec.ts index 2cf2d453a0..c57d4996fa 100644 --- a/src/routes/notifications/notifications.controller.spec.ts +++ b/src/routes/notifications/notifications.controller.spec.ts @@ -25,6 +25,7 @@ import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/tes import { RegisterDeviceDto } from '@/routes/notifications/entities/register-device.dto.entity'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { getAddress } from 'viem'; describe('Notifications Controller (Unit)', () => { let app: INestApplication; @@ -321,7 +322,8 @@ describe('Notifications Controller (Unit)', () => { const uuid = faker.string.uuid(); const safeAddress = faker.finance.ethereumAddress(); const chain = chainBuilder().build(); - const expectedProviderURL = `${chain.transactionService}/api/v1/notifications/devices/${uuid}/safes/${safeAddress}`; + // ValidationPipe checksums safeAddress param + const expectedProviderURL = `${chain.transactionService}/api/v1/notifications/devices/${uuid}/safes/${getAddress(safeAddress)}`; networkService.get.mockImplementation(({ url }) => url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}` ? Promise.resolve({ data: chain, status: 200 }) diff --git a/src/routes/notifications/notifications.controller.ts b/src/routes/notifications/notifications.controller.ts index 50c90dddab..2ffef9a0e9 100644 --- a/src/routes/notifications/notifications.controller.ts +++ b/src/routes/notifications/notifications.controller.ts @@ -9,6 +9,8 @@ import { import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { RegisterDeviceDto } from '@/routes/notifications/entities/register-device.dto.entity'; import { NotificationsService } from '@/routes/notifications/notifications.service'; +import { ValidationPipe } from '@/validation/pipes/validation.pipe'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; @ApiTags('notifications') @Controller({ path: '', version: '1' }) @@ -36,7 +38,8 @@ export class NotificationsController { async unregisterSafe( @Param('chainId') chainId: string, @Param('uuid') uuid: string, - @Param('safeAddress') safeAddress: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, ): Promise { return this.notificationsService.unregisterSafe({ chainId, diff --git a/src/routes/notifications/notifications.service.ts b/src/routes/notifications/notifications.service.ts index 45a468215d..aca845b6b0 100644 --- a/src/routes/notifications/notifications.service.ts +++ b/src/routes/notifications/notifications.service.ts @@ -95,7 +95,7 @@ export class NotificationsService { async unregisterSafe(args: { chainId: string; uuid: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise { return this.notificationsRepository.unregisterSafe(args); } From 1bcfa7aa4bd335461c53e2e28c5fcdf6f4499628 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Wed, 29 May 2024 17:14:42 +0200 Subject: [PATCH 045/207] Checksum `safeAddress` when fetching module transactions and propagate type (#1601) Checksums the incoming `safeAddress` of `TransactionsController['getModuleTransactions']` and propagates the stricter (`0x${string}`) type throughout the project accordingly: - Checksum the `safeAddress` in `TransactionsController['getModuleTransactions']` - Increase strictness of `safeAddress` in `TransactionsService['getModuleTransactions']` - Increase strictness of `safeAddress` in `ITransactionApi['getModuleTransactions']` and its implementation - Increase strictness of `safeAddress` in relative cache dir/key - Update tests accordingly --- src/datasources/cache/cache.router.ts | 4 ++-- .../transaction-api/transaction-api.service.spec.ts | 2 +- src/datasources/transaction-api/transaction-api.service.ts | 4 ++-- src/domain/interfaces/transaction-api.interface.ts | 4 ++-- src/domain/safe/safe.repository.interface.ts | 2 +- src/domain/safe/safe.repository.ts | 4 ++-- src/routes/transactions/transactions.controller.ts | 3 ++- src/routes/transactions/transactions.service.ts | 2 +- 8 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/datasources/cache/cache.router.ts b/src/datasources/cache/cache.router.ts index b9d6461583..818a344f09 100644 --- a/src/datasources/cache/cache.router.ts +++ b/src/datasources/cache/cache.router.ts @@ -213,7 +213,7 @@ export class CacheRouter { static getModuleTransactionsCacheDir(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; to?: string; module?: string; limit?: number; @@ -227,7 +227,7 @@ export class CacheRouter { static getModuleTransactionsCacheKey(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): string { return `${args.chainId}_${CacheRouter.MODULE_TRANSACTIONS_KEY}_${args.safeAddress}`; } diff --git a/src/datasources/transaction-api/transaction-api.service.spec.ts b/src/datasources/transaction-api/transaction-api.service.spec.ts index 83104f7bd1..a1d2ecf818 100644 --- a/src/datasources/transaction-api/transaction-api.service.spec.ts +++ b/src/datasources/transaction-api/transaction-api.service.spec.ts @@ -1264,7 +1264,7 @@ describe('TransactionApi', () => { describe('clearModuleTransactions', () => { it('should clear the module transactions cache', async () => { - const safeAddress = faker.finance.ethereumAddress(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); await service.clearModuleTransactions(safeAddress); diff --git a/src/datasources/transaction-api/transaction-api.service.ts b/src/datasources/transaction-api/transaction-api.service.ts index 480e55ae76..743b049989 100644 --- a/src/datasources/transaction-api/transaction-api.service.ts +++ b/src/datasources/transaction-api/transaction-api.service.ts @@ -500,7 +500,7 @@ export class TransactionApi implements ITransactionApi { } async getModuleTransactions(args: { - safeAddress: string; + safeAddress: `0x${string}`; to?: string; module?: string; limit?: number; @@ -531,7 +531,7 @@ export class TransactionApi implements ITransactionApi { } } - async clearModuleTransactions(safeAddress: string): Promise { + async clearModuleTransactions(safeAddress: `0x${string}`): Promise { const key = CacheRouter.getModuleTransactionsCacheKey({ chainId: this.chainId, safeAddress, diff --git a/src/domain/interfaces/transaction-api.interface.ts b/src/domain/interfaces/transaction-api.interface.ts index f3fdabdf4a..9ecd3fea80 100644 --- a/src/domain/interfaces/transaction-api.interface.ts +++ b/src/domain/interfaces/transaction-api.interface.ts @@ -123,14 +123,14 @@ export interface ITransactionApi { getModuleTransaction(moduleTransactionId: string): Promise; getModuleTransactions(args: { - safeAddress: string; + safeAddress: `0x${string}`; to?: string; module?: string; limit?: number; offset?: number; }): Promise>; - clearModuleTransactions(safeAddress: string): Promise; + clearModuleTransactions(safeAddress: `0x${string}`): Promise; getMultisigTransaction( safeTransactionHash: string, diff --git a/src/domain/safe/safe.repository.interface.ts b/src/domain/safe/safe.repository.interface.ts index af78205a06..44dee35afd 100644 --- a/src/domain/safe/safe.repository.interface.ts +++ b/src/domain/safe/safe.repository.interface.ts @@ -74,7 +74,7 @@ export interface ISafeRepository { clearModuleTransactions(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise; /** diff --git a/src/domain/safe/safe.repository.ts b/src/domain/safe/safe.repository.ts index b4fbfba934..ebaa50dbe2 100644 --- a/src/domain/safe/safe.repository.ts +++ b/src/domain/safe/safe.repository.ts @@ -149,7 +149,7 @@ export class SafeRepository implements ISafeRepository { async getModuleTransactions(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; to?: string; module?: string; limit?: number; @@ -163,7 +163,7 @@ export class SafeRepository implements ISafeRepository { async clearModuleTransactions(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; }): Promise { const transactionService = await this.transactionApiManager.getTransactionApi(args.chainId); diff --git a/src/routes/transactions/transactions.controller.ts b/src/routes/transactions/transactions.controller.ts index c3223515c8..77c651bdb8 100644 --- a/src/routes/transactions/transactions.controller.ts +++ b/src/routes/transactions/transactions.controller.ts @@ -120,7 +120,8 @@ export class TransactionsController { @Param('chainId') chainId: string, @RouteUrlDecorator() routeUrl: URL, @PaginationDataDecorator() paginationData: PaginationData, - @Param('safeAddress') safeAddress: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, @Query('to') to?: string, @Query('module') module?: string, ): Promise> { diff --git a/src/routes/transactions/transactions.service.ts b/src/routes/transactions/transactions.service.ts index 51e5f6352f..7ce8835f7f 100644 --- a/src/routes/transactions/transactions.service.ts +++ b/src/routes/transactions/transactions.service.ts @@ -211,7 +211,7 @@ export class TransactionsService { async getModuleTransactions(args: { chainId: string; routeUrl: Readonly; - safeAddress: string; + safeAddress: `0x${string}`; to?: string; module?: string; paginationData?: PaginationData; From dd4df74acea4b4d0bd316e769c98a26cfec265e6 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 30 May 2024 08:44:05 +0200 Subject: [PATCH 046/207] Checksum `ownerAddress` when fetching owned Safe(s) and propagate type (#1602) Checksums the incoming `ownerAddress` of `OwnersController` and propagates the stricter (`0x${string}`) type throughout the project accordingly: - Checksum the `ownerAddress` in `OwnersController` - Increase strictness of `ownerAddress` in `OwnersService` and `SafeRepository` - Increase strictness of `ownerAddress` in `ITransactionApi['getSafesByOwner']` and its implementation - Increase strictness of `ownerAddress` in relative cache dir/key - Update tests accordingly --- src/datasources/cache/cache.router.ts | 2 +- .../transaction-api/transaction-api.service.spec.ts | 4 ++-- .../transaction-api/transaction-api.service.ts | 2 +- src/domain/interfaces/transaction-api.interface.ts | 2 +- src/domain/safe/safe.repository.interface.ts | 6 +++++- src/domain/safe/safe.repository.ts | 4 ++-- src/routes/owners/owners.controller.spec.ts | 11 +++++++---- src/routes/owners/owners.controller.ts | 8 ++++++-- src/routes/owners/owners.service.ts | 4 ++-- 9 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/datasources/cache/cache.router.ts b/src/datasources/cache/cache.router.ts index 818a344f09..8c14ef1029 100644 --- a/src/datasources/cache/cache.router.ts +++ b/src/datasources/cache/cache.router.ts @@ -357,7 +357,7 @@ export class CacheRouter { static getSafesByOwnerCacheDir(args: { chainId: string; - ownerAddress: string; + ownerAddress: `0x${string}`; }): CacheDir { return new CacheDir( `${args.chainId}_${CacheRouter.OWNERS_SAFE_KEY}_${args.ownerAddress}`, diff --git a/src/datasources/transaction-api/transaction-api.service.spec.ts b/src/datasources/transaction-api/transaction-api.service.spec.ts index a1d2ecf818..45d12aecd1 100644 --- a/src/datasources/transaction-api/transaction-api.service.spec.ts +++ b/src/datasources/transaction-api/transaction-api.service.spec.ts @@ -1876,7 +1876,7 @@ describe('TransactionApi', () => { describe('getSafesByOwner', () => { it('should return retrieved safe', async () => { - const owner = faker.finance.ethereumAddress(); + const owner = getAddress(faker.finance.ethereumAddress()); const safeList = { safes: [ faker.finance.ethereumAddress(), @@ -1904,7 +1904,7 @@ describe('TransactionApi', () => { ['Transaction Service', { nonFieldErrors: [errorMessage] }], ['standard', new Error(errorMessage)], ])(`should forward a %s error`, async (_, error) => { - const owner = faker.finance.ethereumAddress(); + const owner = getAddress(faker.finance.ethereumAddress()); const getSafesByOwnerUrl = `${baseUrl}/api/v1/owners/${owner}/safes/`; const statusCode = faker.internet.httpStatusCode({ types: ['clientError', 'serverError'], diff --git a/src/datasources/transaction-api/transaction-api.service.ts b/src/datasources/transaction-api/transaction-api.service.ts index 743b049989..26d4a683a0 100644 --- a/src/datasources/transaction-api/transaction-api.service.ts +++ b/src/datasources/transaction-api/transaction-api.service.ts @@ -755,7 +755,7 @@ export class TransactionApi implements ITransactionApi { // Important: there is no hook which invalidates this endpoint, // Therefore, this data will live in cache until [ownersExpirationTimeSeconds] - async getSafesByOwner(ownerAddress: string): Promise { + async getSafesByOwner(ownerAddress: `0x${string}`): Promise { try { const cacheDir = CacheRouter.getSafesByOwnerCacheDir({ chainId: this.chainId, diff --git a/src/domain/interfaces/transaction-api.interface.ts b/src/domain/interfaces/transaction-api.interface.ts index 9ecd3fea80..a39bcf3651 100644 --- a/src/domain/interfaces/transaction-api.interface.ts +++ b/src/domain/interfaces/transaction-api.interface.ts @@ -179,7 +179,7 @@ export interface ITransactionApi { getTokens(args: { limit?: number; offset?: number }): Promise>; - getSafesByOwner(ownerAddress: string): Promise; + getSafesByOwner(ownerAddress: `0x${string}`): Promise; postDeviceRegistration(args: { device: Device; diff --git a/src/domain/safe/safe.repository.interface.ts b/src/domain/safe/safe.repository.interface.ts index 44dee35afd..6d5dea7b24 100644 --- a/src/domain/safe/safe.repository.interface.ts +++ b/src/domain/safe/safe.repository.interface.ts @@ -163,9 +163,13 @@ export interface ISafeRepository { getSafesByOwner(args: { chainId: string; - ownerAddress: string; + ownerAddress: `0x${string}`; }): Promise; + getAllSafesByOwner(args: { + ownerAddress: `0x${string}`; + }): Promise<{ [chainId: string]: Array }>; + getLastTransactionSortedByNonce(args: { chainId: string; safeAddress: `0x${string}`; diff --git a/src/domain/safe/safe.repository.ts b/src/domain/safe/safe.repository.ts index ebaa50dbe2..6c34516bfb 100644 --- a/src/domain/safe/safe.repository.ts +++ b/src/domain/safe/safe.repository.ts @@ -368,7 +368,7 @@ export class SafeRepository implements ISafeRepository { async getSafesByOwner(args: { chainId: string; - ownerAddress: string; + ownerAddress: `0x${string}`; }): Promise { const transactionService = await this.transactionApiManager.getTransactionApi(args.chainId); @@ -380,7 +380,7 @@ export class SafeRepository implements ISafeRepository { } async getAllSafesByOwner(args: { - ownerAddress: string; + ownerAddress: `0x${string}`; }): Promise<{ [chainId: string]: Array }> { // Note: does not take pagination into account but we do not support // enough chains for it to be an issue diff --git a/src/routes/owners/owners.controller.spec.ts b/src/routes/owners/owners.controller.spec.ts index d6fd832b20..54dc082f5e 100644 --- a/src/routes/owners/owners.controller.spec.ts +++ b/src/routes/owners/owners.controller.spec.ts @@ -129,7 +129,8 @@ describe('Owners Controller (Unit)', () => { }); const error = new NetworkResponseError( new URL( - `${chainResponse.transactionService}/v1/chains/${chainId}/owners/${ownerAddress}/safes`, + // ValidationPipe checksums ownerAddress param + `${chainResponse.transactionService}/v1/chains/${chainId}/owners/${getAddress(ownerAddress)}/safes`, ), { status: 500, @@ -150,7 +151,8 @@ describe('Owners Controller (Unit)', () => { url: `${safeConfigUrl}/api/v1/chains/${chainId}`, }); expect(networkService.get).toHaveBeenCalledWith({ - url: `${chainResponse.transactionService}/api/v1/owners/${ownerAddress}/safes/`, + // ValidationPipe checksums ownerAddress param + url: `${chainResponse.transactionService}/api/v1/owners/${getAddress(ownerAddress)}/safes/`, }); }); @@ -228,14 +230,15 @@ describe('Owners Controller (Unit)', () => { }); } - case `${chain1.transactionService}/api/v1/owners/${ownerAddress}/safes/`: { + // ValidationPipe checksums ownerAddress param + case `${chain1.transactionService}/api/v1/owners/${getAddress(ownerAddress)}/safes/`: { return Promise.resolve({ data: { safes: safesOnChain1 }, status: 200, }); } - case `${chain2.transactionService}/api/v1/owners/${ownerAddress}/safes/`: { + case `${chain2.transactionService}/api/v1/owners/${getAddress(ownerAddress)}/safes/`: { return Promise.resolve({ data: { safes: safesOnChain2 }, status: 200, diff --git a/src/routes/owners/owners.controller.ts b/src/routes/owners/owners.controller.ts index 3d8abfbe94..c57e89545f 100644 --- a/src/routes/owners/owners.controller.ts +++ b/src/routes/owners/owners.controller.ts @@ -2,6 +2,8 @@ import { Controller, Get, Param } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { SafeList } from '@/routes/owners/entities/safe-list.entity'; import { OwnersService } from '@/routes/owners/owners.service'; +import { ValidationPipe } from '@/validation/pipes/validation.pipe'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; @ApiTags('owners') @Controller({ @@ -15,7 +17,8 @@ export class OwnersController { @Get('chains/:chainId/owners/:ownerAddress/safes') async getSafesByOwner( @Param('chainId') chainId: string, - @Param('ownerAddress') ownerAddress: string, + @Param('ownerAddress', new ValidationPipe(AddressSchema)) + ownerAddress: `0x${string}`, ): Promise { return this.ownersService.getSafesByOwner({ chainId, ownerAddress }); } @@ -23,7 +26,8 @@ export class OwnersController { @ApiOkResponse({ type: SafeList }) @Get('owners/:ownerAddress/safes') async getAllSafesByOwner( - @Param('ownerAddress') ownerAddress: string, + @Param('ownerAddress', new ValidationPipe(AddressSchema)) + ownerAddress: `0x${string}`, ): Promise<{ [chainId: string]: Array }> { return this.ownersService.getAllSafesByOwner({ ownerAddress }); } diff --git a/src/routes/owners/owners.service.ts b/src/routes/owners/owners.service.ts index 4c8a7122ff..28b4fef921 100644 --- a/src/routes/owners/owners.service.ts +++ b/src/routes/owners/owners.service.ts @@ -12,13 +12,13 @@ export class OwnersService { async getSafesByOwner(args: { chainId: string; - ownerAddress: string; + ownerAddress: `0x${string}`; }): Promise { return this.safeRepository.getSafesByOwner(args); } async getAllSafesByOwner(args: { - ownerAddress: string; + ownerAddress: `0x${string}`; }): Promise<{ [chainId: string]: Array }> { return this.safeRepository.getAllSafesByOwner(args); } From b2bd0b2c87f5b5424a96145026058fe4203112e2 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 3 Jun 2024 12:27:27 +0200 Subject: [PATCH 047/207] Add swaps API for Arbitrum (#1603) Adds [Arbitrum One order book API URL](https://docs.cow.fi/cow-protocol/reference/apis/orderbook) to the swaps configuration: - Add `https://api.cow.fi/arbitrum_one` to `swaps.api[42161]` configuration --- src/config/entities/__tests__/configuration.ts | 1 + src/config/entities/configuration.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index 07c01d2265..faac9feb09 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -240,6 +240,7 @@ export default (): ReturnType => ({ api: { 1: faker.internet.url(), 100: faker.internet.url(), + 42161: faker.internet.url(), 11155111: faker.internet.url(), }, explorerBaseUri: faker.internet.url(), diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 5d551ecd39..1abface1c7 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -265,6 +265,7 @@ export default () => ({ api: { 1: 'https://api.cow.fi/mainnet', 100: 'https://api.cow.fi/xdai', + 42161: 'https://api.cow.fi/arbitrum_one', 11155111: 'https://api.cow.fi/sepolia', }, explorerBaseUri: From 95f00e4ea59f1b666d491f6b15de3111add2873d Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 3 Jun 2024 15:17:08 +0200 Subject: [PATCH 048/207] Remove chain-specific prices provider configuration (#1593) Changes`Chain['pricesProvider']` to be a required property, propegating the relevant adjustments as well as removing the local configuration: - Make `ChainSchema['pricesProvider']` (and all nested properties) required - Remove `balances.providers.safe.prices.chains` from (test) configuration - Remove fallbacks to configuration - Remove all TODO comments - Update tests accordingly --- .../entities/__tests__/configuration.ts | 74 --------- src/config/entities/configuration.ts | 20 --- .../coingecko-api.service.spec.ts | 142 ------------------ .../balances-api/coingecko-api.service.ts | 14 +- .../schemas/__tests__/chain.schema.spec.ts | 21 +-- .../chains/entities/schemas/chain.schema.ts | 7 +- .../balances/balances.controller.spec.ts | 14 +- .../safes/safes.controller.overview.spec.ts | 77 +++------- 8 files changed, 30 insertions(+), 339 deletions(-) diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index faac9feb09..dc4a607998 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -28,80 +28,6 @@ export default (): ReturnType => ({ pricesTtlSeconds: faker.number.int(), nativeCoinPricesTtlSeconds: faker.number.int(), notFoundPriceTtlSeconds: faker.number.int(), - chains: { - 1: { - nativeCoin: faker.string.sample(), - chainName: faker.string.sample(), - }, - 10: { - nativeCoin: faker.string.sample(), - chainName: faker.string.sample(), - }, - 100: { - nativeCoin: faker.string.sample(), - chainName: faker.string.sample(), - }, - 1101: { - nativeCoin: faker.string.sample(), - chainName: faker.string.sample(), - }, - 11155111: { - nativeCoin: faker.string.sample(), - chainName: faker.string.sample(), - }, - 1313161554: { - nativeCoin: faker.string.sample(), - chainName: faker.string.sample(), - }, - 137: { - nativeCoin: faker.string.sample(), - chainName: faker.string.sample(), - }, - 196: { - nativeCoin: faker.string.sample(), - chainName: faker.string.sample(), - }, - 324: { - nativeCoin: faker.string.sample(), - chainName: faker.string.sample(), - }, - 42161: { - nativeCoin: faker.string.sample(), - chainName: faker.string.sample(), - }, - 42220: { - nativeCoin: faker.string.sample(), - chainName: faker.string.sample(), - }, - 43114: { - nativeCoin: faker.string.sample(), - chainName: faker.string.sample(), - }, - 5: { - nativeCoin: faker.string.sample(), - chainName: faker.string.sample(), - }, - 534352: { - nativeCoin: faker.string.sample(), - chainName: faker.string.sample(), - }, - 56: { - nativeCoin: faker.string.sample(), - chainName: faker.string.sample(), - }, - 8453: { - nativeCoin: faker.string.sample(), - chainName: faker.string.sample(), - }, - 84531: { - nativeCoin: faker.string.sample(), - chainName: faker.string.sample(), - }, - 84532: { - nativeCoin: faker.string.sample(), - chainName: faker.string.sample(), - }, - }, highRefreshRateTokens: [], highRefreshRateTokensTtlSeconds: faker.number.int(), }, diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 1abface1c7..91a5f54724 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -48,26 +48,6 @@ export default () => ({ notFoundPriceTtlSeconds: parseInt( process.env.NOT_FOUND_PRICE_TTL_SECONDS ?? `${72 * 60 * 60}`, ), - chains: { - 1: { nativeCoin: 'ethereum', chainName: 'ethereum' }, - 10: { nativeCoin: 'ethereum', chainName: 'optimistic-ethereum' }, - 100: { nativeCoin: 'xdai', chainName: 'xdai' }, - 1101: { nativeCoin: 'ethereum', chainName: 'polygon-zkevm' }, - 11155111: { nativeCoin: 'ethereum', chainName: 'ethereum' }, - 1313161554: { nativeCoin: 'ethereum', chainName: 'aurora' }, - 137: { nativeCoin: 'matic-network', chainName: 'polygon-pos' }, - 196: { nativeCoin: 'okb', chainName: 'x1' }, - 324: { nativeCoin: 'ethereum', chainName: 'zksync' }, - 42161: { nativeCoin: 'ethereum', chainName: 'arbitrum-one' }, - 42220: { nativeCoin: 'celo', chainName: 'celo' }, - 43114: { nativeCoin: 'avalanche-2', chainName: 'avalanche' }, - 5: { nativeCoin: 'ethereum', chainName: 'ethereum' }, - 534352: { nativeCoin: 'weth', chainName: 'scroll' }, - 56: { nativeCoin: 'binancecoin', chainName: 'binance-smart-chain' }, - 8453: { nativeCoin: 'ethereum', chainName: 'base' }, - 84531: { nativeCoin: 'ethereum', chainName: 'base' }, - 84532: { nativeCoin: 'ethereum', chainName: 'base' }, - }, highRefreshRateTokens: process.env.HIGH_REFRESH_RATE_TOKENS?.split(',') ?? [], highRefreshRateTokensTtlSeconds: parseInt( diff --git a/src/datasources/balances-api/coingecko-api.service.spec.ts b/src/datasources/balances-api/coingecko-api.service.spec.ts index 801cd650fa..bb4784ebc9 100644 --- a/src/datasources/balances-api/coingecko-api.service.spec.ts +++ b/src/datasources/balances-api/coingecko-api.service.spec.ts @@ -9,7 +9,6 @@ import { INetworkService } from '@/datasources/network/network.service.interface import { sortBy } from 'lodash'; import { ILoggingService } from '@/logging/logging.interface'; import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; -import { pricesProviderBuilder } from '@/domain/chains/entities/__tests__/prices-provider.builder'; const mockCacheFirstDataSource = jest.mocked({ get: jest.fn(), @@ -168,7 +167,6 @@ describe('CoingeckoAPI', () => { }); const expectedCacheDir = new CacheDir( - // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${tokenAddress}_${lowerCaseFiatCode}`, '', ); @@ -176,7 +174,6 @@ describe('CoingeckoAPI', () => { { [tokenAddress]: { [lowerCaseFiatCode]: price } }, ]); expect(mockNetworkService.get).toHaveBeenCalledWith({ - // @ts-expect-error - TODO: remove after migration url: `${coingeckoBaseUri}/simple/token_price/${chain.pricesProvider.chainName}`, networkRequest: { headers: { @@ -228,7 +225,6 @@ describe('CoingeckoAPI', () => { }); const expectedCacheDir = new CacheDir( - // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${tokenAddress}_${lowerCaseFiatCode}`, '', ); @@ -236,7 +232,6 @@ describe('CoingeckoAPI', () => { { [tokenAddress]: { [lowerCaseFiatCode]: price } }, ]); expect(mockNetworkService.get).toHaveBeenCalledWith({ - // @ts-expect-error - TODO: remove after migration url: `${coingeckoBaseUri}/simple/token_price/${chain.pricesProvider.chainName}`, networkRequest: { params: { @@ -255,72 +250,6 @@ describe('CoingeckoAPI', () => { ); }); - // TODO: remove this after the prices provider data is migrated to the Config Service - it('should return and cache one token price (using the fallback configuration)', async () => { - fakeConfigurationService.set('balances.providers.safe.prices.apiKey', null); - const chain = chainBuilder() - .with( - 'pricesProvider', - pricesProviderBuilder().with('chainName', null).build(), - ) - .build(); - const chainName = faker.string.sample(); - const tokenAddress = faker.finance.ethereumAddress(); - const fiatCode = faker.finance.currencyCode(); - const lowerCaseFiatCode = fiatCode.toLowerCase(); - const price = faker.number.float({ min: 0.01, multipleOf: 0.01 }); - const coingeckoPrice: AssetPrice = { - [tokenAddress]: { [lowerCaseFiatCode]: price }, - }; - mockCacheService.get.mockResolvedValue(undefined); - mockNetworkService.get.mockResolvedValue({ - data: coingeckoPrice, - status: 200, - }); - fakeConfigurationService.set( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - chainName, - ); - const service = new CoingeckoApi( - fakeConfigurationService, - mockCacheFirstDataSource, - mockNetworkService, - mockCacheService, - mockLoggingService, - ); - - const assetPrice = await service.getTokenPrices({ - chain, - tokenAddresses: [tokenAddress], - fiatCode, - }); - - const expectedCacheDir = new CacheDir( - `${chainName}_token_price_${tokenAddress}_${lowerCaseFiatCode}`, - '', - ); - expect(assetPrice).toEqual([ - { [tokenAddress]: { [lowerCaseFiatCode]: price } }, - ]); - expect(mockNetworkService.get).toHaveBeenCalledWith({ - url: `${coingeckoBaseUri}/simple/token_price/${chainName}`, - networkRequest: { - params: { - contract_addresses: tokenAddress, - vs_currencies: lowerCaseFiatCode, - }, - }, - }); - expect(mockCacheService.get).toHaveBeenCalledTimes(1); - expect(mockCacheService.get).toHaveBeenCalledWith(expectedCacheDir); - expect(mockCacheService.set).toHaveBeenCalledTimes(1); - expect(mockCacheService.set).toHaveBeenCalledWith( - expectedCacheDir, - JSON.stringify({ [tokenAddress]: { [lowerCaseFiatCode]: price } }), - pricesTtlSeconds, - ); - }); - it('should return and cache multiple token prices', async () => { const chain = chainBuilder().build(); const fiatCode = faker.finance.currencyCode(); @@ -358,7 +287,6 @@ describe('CoingeckoAPI', () => { { [thirdTokenAddress]: { [lowerCaseFiatCode]: thirdPrice } }, ]); expect(mockNetworkService.get).toHaveBeenCalledWith({ - // @ts-expect-error - TODO: remove after migration url: `${coingeckoBaseUri}/simple/token_price/${chain.pricesProvider.chainName}`, networkRequest: { headers: { @@ -377,21 +305,18 @@ describe('CoingeckoAPI', () => { expect(mockCacheService.get).toHaveBeenCalledTimes(3); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${secondTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, '', ), @@ -399,7 +324,6 @@ describe('CoingeckoAPI', () => { expect(mockCacheService.set).toHaveBeenCalledTimes(3); expect(mockCacheService.set).toHaveBeenCalledWith( new CacheDir( - // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, '', ), @@ -410,7 +334,6 @@ describe('CoingeckoAPI', () => { ); expect(mockCacheService.set).toHaveBeenCalledWith( new CacheDir( - // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${secondTokenAddress}_${lowerCaseFiatCode}`, '', ), @@ -421,7 +344,6 @@ describe('CoingeckoAPI', () => { ); expect(mockCacheService.set).toHaveBeenCalledWith( new CacheDir( - // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, '', ), @@ -476,7 +398,6 @@ describe('CoingeckoAPI', () => { { [anotherTokenAddress]: { [lowerCaseFiatCode]: anotherPrice } }, ]); expect(mockNetworkService.get).toHaveBeenCalledWith({ - // @ts-expect-error - TODO: remove after migration url: `${coingeckoBaseUri}/simple/token_price/${chain.pricesProvider.chainName}`, networkRequest: { headers: { @@ -496,14 +417,12 @@ describe('CoingeckoAPI', () => { // high-refresh-rate token price is cached with highRefreshRateTokensTtlSeconds expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${highRefreshRateTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.set).toHaveBeenCalledWith( new CacheDir( - // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${highRefreshRateTokenAddress}_${lowerCaseFiatCode}`, '', ), @@ -515,14 +434,12 @@ describe('CoingeckoAPI', () => { // another token price is cached with pricesCacheTtlSeconds expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${anotherTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.set).toHaveBeenCalledWith( new CacheDir( - // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${anotherTokenAddress}_${lowerCaseFiatCode}`, '', ), @@ -580,7 +497,6 @@ describe('CoingeckoAPI', () => { ), ); expect(mockNetworkService.get).toHaveBeenCalledWith({ - // @ts-expect-error - TODO: remove after migration url: `${coingeckoBaseUri}/simple/token_price/${chain.pricesProvider.chainName}`, networkRequest: { headers: { @@ -595,21 +511,18 @@ describe('CoingeckoAPI', () => { expect(mockCacheService.get).toHaveBeenCalledTimes(3); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${secondTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, '', ), @@ -618,7 +531,6 @@ describe('CoingeckoAPI', () => { expect(mockCacheService.set).toHaveBeenNthCalledWith( 1, new CacheDir( - // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, '', ), @@ -630,7 +542,6 @@ describe('CoingeckoAPI', () => { expect(mockCacheService.set).toHaveBeenNthCalledWith( 2, new CacheDir( - // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, '', ), @@ -688,7 +599,6 @@ describe('CoingeckoAPI', () => { ), ); expect(mockNetworkService.get).toHaveBeenCalledWith({ - // @ts-expect-error - TODO: remove after migration url: `${coingeckoBaseUri}/simple/token_price/${chain.pricesProvider.chainName}`, networkRequest: { headers: { @@ -703,21 +613,18 @@ describe('CoingeckoAPI', () => { expect(mockCacheService.get).toHaveBeenCalledTimes(3); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${secondTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, '', ), @@ -749,7 +656,6 @@ describe('CoingeckoAPI', () => { expect(mockCacheFirstDataSource.get).toHaveBeenCalledWith({ cacheDir: new CacheDir( - // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.nativeCoin}_native_coin_price_${lowerCaseFiatCode}`, '', ), @@ -759,7 +665,6 @@ describe('CoingeckoAPI', () => { 'x-cg-pro-api-key': coingeckoApiKey, }, params: { - // @ts-expect-error - TODO: remove after migration ids: chain.pricesProvider.nativeCoin, vs_currencies: lowerCaseFiatCode, }, @@ -788,14 +693,12 @@ describe('CoingeckoAPI', () => { expect(mockCacheFirstDataSource.get).toHaveBeenCalledWith({ cacheDir: new CacheDir( - // @ts-expect-error - TODO: remove after migration `${chain.pricesProvider.nativeCoin}_native_coin_price_${lowerCaseFiatCode}`, '', ), url: `${coingeckoBaseUri}/simple/price`, networkRequest: { params: { - // @ts-expect-error - TODO: remove after migration ids: chain.pricesProvider.nativeCoin, vs_currencies: lowerCaseFiatCode, }, @@ -804,49 +707,4 @@ describe('CoingeckoAPI', () => { expireTimeSeconds: nativeCoinPricesTtlSeconds, }); }); - - // TODO: remove this after the prices provider data is migrated to the Config Service - it('should return the native coin price (using the fallback configuration)', async () => { - const chain = chainBuilder() - .with( - 'pricesProvider', - pricesProviderBuilder().with('nativeCoin', null).build(), - ) - .build(); - const nativeCoinId = faker.string.sample(); - const fiatCode = faker.finance.currencyCode(); - const lowerCaseFiatCode = fiatCode.toLowerCase(); - const expectedAssetPrice: AssetPrice = { gnosis: { eur: 98.86 } }; - mockCacheFirstDataSource.get.mockResolvedValue(expectedAssetPrice); - fakeConfigurationService.set('balances.providers.safe.prices.apiKey', null); - fakeConfigurationService.set( - `balances.providers.safe.prices.chains.${chain.chainId}.nativeCoin`, - nativeCoinId, - ); - const service = new CoingeckoApi( - fakeConfigurationService, - mockCacheFirstDataSource, - mockNetworkService, - mockCacheService, - mockLoggingService, - ); - - await service.getNativeCoinPrice({ chain, fiatCode }); - - expect(mockCacheFirstDataSource.get).toHaveBeenCalledWith({ - cacheDir: new CacheDir( - `${nativeCoinId}_native_coin_price_${lowerCaseFiatCode}`, - '', - ), - url: `${coingeckoBaseUri}/simple/price`, - networkRequest: { - params: { - ids: nativeCoinId, - vs_currencies: lowerCaseFiatCode, - }, - }, - notFoundExpireTimeSeconds: notFoundExpirationTimeInSeconds, - expireTimeSeconds: nativeCoinPricesTtlSeconds, - }); - }); }); diff --git a/src/datasources/balances-api/coingecko-api.service.ts b/src/datasources/balances-api/coingecko-api.service.ts index c58b467540..49c52ac66e 100644 --- a/src/datasources/balances-api/coingecko-api.service.ts +++ b/src/datasources/balances-api/coingecko-api.service.ts @@ -114,12 +114,7 @@ export class CoingeckoApi implements IPricesApi { }): Promise { try { const lowerCaseFiatCode = args.fiatCode.toLowerCase(); - // TODO: remove configurationService fallback when fully migrated. - const nativeCoinId = - args.chain.pricesProvider?.nativeCoin ?? - this.configurationService.getOrThrow( - `balances.providers.safe.prices.chains.${args.chain.chainId}.nativeCoin`, - ); + const nativeCoinId = args.chain.pricesProvider.nativeCoin; const cacheDir = CacheRouter.getNativeCoinPriceCacheDir({ nativeCoinId, fiatCode: lowerCaseFiatCode, @@ -172,12 +167,7 @@ export class CoingeckoApi implements IPricesApi { const lowerCaseTokenAddresses = args.tokenAddresses.map((address) => address.toLowerCase(), ); - // TODO: remove configurationService fallback when fully migrated. - const chainName = - args.chain.pricesProvider?.chainName ?? - this.configurationService.getOrThrow( - `balances.providers.safe.prices.chains.${args.chain.chainId}.chainName`, - ); + const chainName = args.chain.pricesProvider.chainName; const pricesFromCache = await this._getTokenPricesFromCache({ chainName, tokenAddresses: lowerCaseTokenAddresses, diff --git a/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts b/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts index eeda718f77..3f2ba4327b 100644 --- a/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts +++ b/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts @@ -314,9 +314,9 @@ describe('Chain schemas', () => { }); it('should not validate an invalid prices provider chainName', () => { - const pricesProvider = { - chainName: 1, - }; + const pricesProvider = pricesProviderBuilder() + .with('chainName', faker.number.int() as unknown as string) + .build(); const result = PricesProviderSchema.safeParse(pricesProvider); @@ -334,9 +334,9 @@ describe('Chain schemas', () => { }); it('should not validate an invalid prices provider nativeCoin', () => { - const pricesProvider = { - nativeCoin: 1, - }; + const pricesProvider = pricesProviderBuilder() + .with('nativeCoin', faker.number.int() as unknown as string) + .build(); const result = PricesProviderSchema.safeParse(pricesProvider); @@ -363,15 +363,6 @@ describe('Chain schemas', () => { expect(result.success).toBe(true); }); - // TODO: remove when fully migrated. - it('should allow optional pricesProvider', () => { - const chain = chainBuilder().with('pricesProvider', undefined).build(); - - const result = ChainSchema.safeParse(chain); - - expect(result.success).toBe(true); - }); - it.each([['chainLogoUri' as const], ['ensRegistryAddress' as const]])( 'should allow undefined %s and default to null', (field) => { diff --git a/src/domain/chains/entities/schemas/chain.schema.ts b/src/domain/chains/entities/schemas/chain.schema.ts index 9855e2505e..3c1d11d8f9 100644 --- a/src/domain/chains/entities/schemas/chain.schema.ts +++ b/src/domain/chains/entities/schemas/chain.schema.ts @@ -55,8 +55,8 @@ export const GasPriceSchema = z.array( ); export const PricesProviderSchema = z.object({ - chainName: z.string().nullish().default(null), - nativeCoin: z.string().nullish().default(null), + chainName: z.string(), + nativeCoin: z.string(), }); export const ChainSchema = z.object({ @@ -72,8 +72,7 @@ export const ChainSchema = z.object({ publicRpcUri: RpcUriSchema, blockExplorerUriTemplate: BlockExplorerUriTemplateSchema, nativeCurrency: NativeCurrencySchema, - // TODO: remove optionality when fully migrated. - pricesProvider: PricesProviderSchema.optional(), + pricesProvider: PricesProviderSchema, transactionService: z.string().url(), vpcTransactionService: z.string().url(), theme: ThemeSchema, diff --git a/src/routes/balances/balances.controller.spec.ts b/src/routes/balances/balances.controller.spec.ts index b35451406c..c629685b60 100644 --- a/src/routes/balances/balances.controller.spec.ts +++ b/src/routes/balances/balances.controller.spec.ts @@ -94,8 +94,7 @@ describe('Balances Controller (Unit)', () => { .getOrThrow('balances.providers.safe.prices.apiKey'); const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - // @ts-expect-error - TODO: remove after migration - [chain.pricesProvider.nativeCoin!]: { + [chain.pricesProvider.nativeCoin]: { [currency.toLowerCase()]: 1536.75, }, }; @@ -117,7 +116,6 @@ describe('Balances Controller (Unit)', () => { data: nativeCoinPriceProviderResponse, status: 200, }); - // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: return Promise.resolve({ data: tokenPriceProviderResponse, @@ -197,7 +195,6 @@ describe('Balances Controller (Unit)', () => { params: { trusted: false, exclude_spam: true }, }); expect(networkService.get.mock.calls[2][0].url).toBe( - // @ts-expect-error - TODO: remove after migration `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); expect(networkService.get.mock.calls[2][0].networkRequest).toStrictEqual({ @@ -216,7 +213,6 @@ describe('Balances Controller (Unit)', () => { expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': apiKey }, params: { - // @ts-expect-error - TODO: remove after migration ids: chain.pricesProvider.nativeCoin, vs_currencies: currency.toLowerCase(), }, @@ -249,7 +245,6 @@ describe('Balances Controller (Unit)', () => { data: transactionApiBalancesResponse, status: 200, }); - // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: return Promise.resolve({ data: tokenPriceProviderResponse, @@ -287,8 +282,7 @@ describe('Balances Controller (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - // @ts-expect-error - TODO: remove after migration - [chain.pricesProvider.nativeCoin!]: { + [chain.pricesProvider.nativeCoin]: { [currency.toLowerCase()]: 1536.75, }, }; @@ -362,7 +356,6 @@ describe('Balances Controller (Unit)', () => { data: transactionApiBalancesResponse, status: 200, }); - // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: return Promise.resolve({ data: tokenPriceProviderResponse, @@ -412,7 +405,6 @@ describe('Balances Controller (Unit)', () => { params: { trusted: false, exclude_spam: true }, }); expect(networkService.get.mock.calls[2][0].url).toBe( - // @ts-expect-error - TODO: remove after migration `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); }); @@ -464,7 +456,6 @@ describe('Balances Controller (Unit)', () => { data: transactionApiBalancesResponse, status: 200, }); - // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: return Promise.reject(); default: @@ -524,7 +515,6 @@ describe('Balances Controller (Unit)', () => { data: transactionApiBalancesResponse, status: 200, }); - // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: return Promise.resolve({ data: tokenPriceProviderResponse, diff --git a/src/routes/safes/safes.controller.overview.spec.ts b/src/routes/safes/safes.controller.overview.spec.ts index 7446889cc0..2e491e052a 100644 --- a/src/routes/safes/safes.controller.overview.spec.ts +++ b/src/routes/safes/safes.controller.overview.spec.ts @@ -111,8 +111,7 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - // @ts-expect-error - TODO: remove after migration - [chain.pricesProvider.nativeCoin!]: { + [chain.pricesProvider.nativeCoin]: { [currency.toLowerCase()]: 1536.75, }, }; @@ -158,7 +157,6 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, @@ -219,7 +217,6 @@ describe('Safes Controller Overview (Unit)', () => { params: { trusted: false, exclude_spam: true }, }); expect(networkService.get.mock.calls[3][0].url).toBe( - // @ts-expect-error - TODO: remove after migration `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ @@ -238,7 +235,6 @@ describe('Safes Controller Overview (Unit)', () => { expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, params: { - // @ts-expect-error - TODO: remove after migration ids: chain.pricesProvider.nativeCoin, vs_currencies: currency.toLowerCase(), }, @@ -272,8 +268,7 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - // @ts-expect-error - TODO: remove after migration - [chain.pricesProvider.nativeCoin!]: { + [chain.pricesProvider.nativeCoin]: { [currency.toLowerCase()]: 1536.75, }, }; @@ -331,7 +326,6 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, @@ -392,7 +386,6 @@ describe('Safes Controller Overview (Unit)', () => { params: { trusted: false, exclude_spam: true }, }); expect(networkService.get.mock.calls[3][0].url).toBe( - // @ts-expect-error - TODO: remove after migration `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ @@ -411,7 +404,6 @@ describe('Safes Controller Overview (Unit)', () => { expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, params: { - // @ts-expect-error - TODO: remove after migration ids: chain.pricesProvider.nativeCoin, vs_currencies: currency.toLowerCase(), }, @@ -485,12 +477,10 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - // @ts-expect-error - TODO: remove after migration - [chain1.pricesProvider.nativeCoin!]: { + [chain1.pricesProvider.nativeCoin]: { [currency.toLowerCase()]: 1536.75, }, - // @ts-expect-error - TODO: remove after migration - [chain2.pricesProvider.nativeCoin!]: { + [chain2.pricesProvider.nativeCoin]: { [currency.toLowerCase()]: 1536.75, }, }; @@ -546,14 +536,13 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - // @ts-expect-error - TODO: remove after migration + case `${pricesProviderUrl}/simple/token_price/${chain1.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, }); } - // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain2.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, @@ -712,12 +701,10 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - // @ts-expect-error - TODO: remove after migration - [chain1.pricesProvider.nativeCoin!]: { + [chain1.pricesProvider.nativeCoin]: { [currency.toLowerCase()]: 1536.75, }, - // @ts-expect-error - TODO: remove after migration - [chain2.pricesProvider.nativeCoin!]: { + [chain2.pricesProvider.nativeCoin]: { [currency.toLowerCase()]: 1536.75, }, }; @@ -773,14 +760,12 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain1.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, }); } - // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain2.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, @@ -897,8 +882,7 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - // @ts-expect-error - TODO: remove after migration - [chain.pricesProvider.nativeCoin!]: { + [chain.pricesProvider.nativeCoin]: { [currency.toLowerCase()]: 1536.75, }, }; @@ -935,7 +919,6 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, @@ -995,7 +978,6 @@ describe('Safes Controller Overview (Unit)', () => { params: { trusted: false, exclude_spam: true }, }); expect(networkService.get.mock.calls[3][0].url).toBe( - // @ts-expect-error - TODO: remove after migration `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ @@ -1014,7 +996,6 @@ describe('Safes Controller Overview (Unit)', () => { expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, params: { - // @ts-expect-error - TODO: remove after migration ids: chain.pricesProvider.nativeCoin, vs_currencies: currency.toLowerCase(), }, @@ -1048,8 +1029,7 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - // @ts-expect-error - TODO: remove after migration - [chain.pricesProvider.nativeCoin!]: { + [chain.pricesProvider.nativeCoin]: { [currency.toLowerCase()]: 1536.75, }, }; @@ -1087,7 +1067,6 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, @@ -1147,7 +1126,6 @@ describe('Safes Controller Overview (Unit)', () => { params: { trusted: true, exclude_spam: false }, }); expect(networkService.get.mock.calls[3][0].url).toBe( - // @ts-expect-error - TODO: remove after migration `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ @@ -1166,7 +1144,6 @@ describe('Safes Controller Overview (Unit)', () => { expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, params: { - // @ts-expect-error - TODO: remove after migration ids: chain.pricesProvider.nativeCoin, vs_currencies: currency.toLowerCase(), }, @@ -1242,12 +1219,10 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - // @ts-expect-error - TODO: remove after migration - [chain1.pricesProvider.nativeCoin!]: { + [chain1.pricesProvider.nativeCoin]: { [currency.toLowerCase()]: 1536.75, }, - // @ts-expect-error - TODO: remove after migration - [chain2.pricesProvider.nativeCoin!]: { + [chain2.pricesProvider.nativeCoin]: { [currency.toLowerCase()]: 1536.75, }, }; @@ -1309,14 +1284,12 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain1.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, }); } - // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain2.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, @@ -1440,12 +1413,10 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - // @ts-expect-error - TODO: remove after migration - [chain1.pricesProvider.nativeCoin!]: { + [chain1.pricesProvider.nativeCoin]: { [currency.toLowerCase()]: 1536.75, }, - // @ts-expect-error - TODO: remove after migration - [chain2.pricesProvider.nativeCoin!]: { + [chain2.pricesProvider.nativeCoin]: { [currency.toLowerCase()]: 1536.75, }, }; @@ -1502,14 +1473,12 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain1.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, }); } - // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain2.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, @@ -1644,12 +1613,10 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - // @ts-expect-error - TODO: remove after migration - [chain1.pricesProvider.nativeCoin!]: { + [chain1.pricesProvider.nativeCoin]: { [currency.toLowerCase()]: 1536.75, }, - // @ts-expect-error - TODO: remove after migration - [chain2.pricesProvider.nativeCoin!]: { + [chain2.pricesProvider.nativeCoin]: { [currency.toLowerCase()]: 1536.75, }, }; @@ -1705,14 +1672,12 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain1.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, }); } - // @ts-expect-error - TODO: remove after migration case `${pricesProviderUrl}/simple/token_price/${chain2.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, @@ -1800,16 +1765,8 @@ describe('Safes Controller Overview (Unit)', () => { .with('token', balanceTokenBuilder().with('decimals', 17).build()) .build(), ]; - const nativeCoinId = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain.chainId}.nativeCoin`, - ); - const chainName = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - ); + const nativeCoinId = chain.pricesProvider.nativeCoin; + const chainName = chain.pricesProvider.chainName; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { [nativeCoinId]: { [currency.toLowerCase()]: 1536.75 }, From e486d20f2d1c0a428857c5b81bd1878a847e0b7d Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 3 Jun 2024 16:21:01 +0200 Subject: [PATCH 049/207] Improve imitation detection to cover incoming (and multiple) transfers (#1564) This improves address poisoning detection in the following manner: - Comparison now occurs relative to a "lookup distance". This means that multiple imitation transafers within that distance, relative to the legitimate transaction are detected. - Incoming transfers are also now detected. - Imitation tokens that leverage varying decimals are also now detected. --- .../entities/__tests__/configuration.ts | 1 + src/config/entities/configuration.ts | 1 + .../transfers/erc20-transfer.entity.ts | 6 +- .../transfers/transfer-imitation.mapper.ts | 194 ++- .../transactions-history.controller.spec.ts | 875 +--------- ....imitation-transactions.controller.spec.ts | 1467 +++++++++++++++++ 6 files changed, 1595 insertions(+), 949 deletions(-) create mode 100644 src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index dc4a607998..ce325c2eae 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -127,6 +127,7 @@ export default (): ReturnType => ({ }, mappings: { imitation: { + lookupDistance: faker.number.int(), prefixLength: faker.number.int(), suffixLength: faker.number.int(), }, diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 91a5f54724..6bfeb7671a 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -203,6 +203,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}`), }, diff --git a/src/routes/transactions/entities/transfers/erc20-transfer.entity.ts b/src/routes/transactions/entities/transfers/erc20-transfer.entity.ts index d8c5c9d68b..5210475981 100644 --- a/src/routes/transactions/entities/transfers/erc20-transfer.entity.ts +++ b/src/routes/transactions/entities/transfers/erc20-transfer.entity.ts @@ -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}`, @@ -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; diff --git a/src/routes/transactions/mappers/transfers/transfer-imitation.mapper.ts b/src/routes/transactions/mappers/transfers/transfer-imitation.mapper.ts index 64312afe1f..c43bf2157a 100644 --- a/src/routes/transactions/mappers/transfers/transfer-imitation.mapper.ts +++ b/src/routes/transactions/mappers/transfers/transfer-imitation.mapper.ts @@ -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', ); @@ -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; previousTransaction: TransactionItem | undefined; showImitations: boolean; }): Array { - const transactions = this.mapTransferInfoImitation( - args.transactions, - args.previousTransaction, - ); + const mappedTransactions: Array = []; - 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, - previousTransaction?: TransactionItem, - ): Array { - 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; + + 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(); diff --git a/src/routes/transactions/transactions-history.controller.spec.ts b/src/routes/transactions/transactions-history.controller.spec.ts index 1ac533d9d4..b545ae4462 100644 --- a/src/routes/transactions/transactions-history.controller.spec.ts +++ b/src/routes/transactions/transactions-history.controller.spec.ts @@ -59,19 +59,14 @@ import { } from '@/domain/safe/entities/__tests__/erc721-transfer.builder'; import { TransactionItem } from '@/routes/transactions/entities/transaction-item.entity'; import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; -import { getAddress, zeroAddress } from 'viem'; +import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; -import { MultisigTransaction } from '@/domain/safe/entities/multisig-transaction.entity'; -import { EthereumTransaction } from '@/domain/safe/entities/ethereum-transaction.entity'; -import { erc20TransferEncoder } from '@/domain/relay/contracts/__tests__/encoders/erc20-encoder.builder'; describe('Transactions History Controller (Unit)', () => { let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; - const prefixLength = 3; - const suffixLength = 4; beforeEach(async () => { jest.resetAllMocks(); @@ -83,14 +78,6 @@ describe('Transactions History Controller (Unit)', () => { history: { maxNestedTransfers: 5, }, - imitation: { - prefixLength, - suffixLength, - }, - }, - features: { - ...configuration().features, - imitationMapping: true, }, }); @@ -1367,864 +1354,4 @@ describe('Transactions History Controller (Unit)', () => { }); }); }); - - describe('Imitation transactions', () => { - describe('Trusted tokens', () => { - it('should flag outgoing ERC-20 transfers that imitate a direct predecessor', async () => { - const chain = chainBuilder().build(); - const safe = safeBuilder().build(); - - const multisigExecutionDate = new Date('2024-03-20T09:41:25Z'); - const multisigToken = tokenBuilder() - .with('trusted', true) - .with('type', TokenType.Erc20) - .build(); - const multisigTransfer = { - ...erc20TransferBuilder() - .with('executionDate', multisigExecutionDate) - .with('from', safe.address) - .with('tokenAddress', multisigToken.address) - .with('value', faker.string.numeric({ exclude: ['0'] })) - .build(), - tokenInfo: multisigToken, - }; - const multisigTransaction = { - ...(multisigTransactionToJson( - multisigTransactionBuilder() - .with('executionDate', multisigExecutionDate) - .with('safe', safe.address) - .with('to', multisigToken.address) - .with('value', '0') - .with('data', '0x') // TODO: Use encoder - .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 - this could remove dataDecodedParamHelper.getFromParam/getToParam? - transfers: [erc20TransferToJson(multisigTransfer) as Transfer], - } as MultisigTransaction; - - // TODO: Value and recipient - const imitationAddress = getAddress( - multisigTransfer.to.slice(0, 5) + - faker.finance.ethereumAddress().slice(5, -4) + - multisigTransfer.to.slice(-4), - ); - const imitationExecutionDate = new Date('2024-03-20T09:42:58Z'); - const imitationErc20Transfer = erc20TransferEncoder() - .with('to', imitationAddress) - .with('value', BigInt(multisigTransfer.value)); - const imitationToken = tokenBuilder() - .with('trusted', true) - .with('type', TokenType.Erc20) - .build(); - const imitationTransfer = { - ...erc20TransferBuilder() - .with('from', safe.address) - .with('to', imitationAddress) - .with('tokenAddress', imitationToken.address) - .with('value', multisigTransfer.value) - .with('executionDate', imitationExecutionDate) - .build(), - // TODO: Update type to include tokenInfo - tokenInfo: imitationToken, - }; - const imitationTransaction = ethereumTransactionToJson( - ethereumTransactionBuilder() - .with('executionDate', imitationTransfer.executionDate) - .with('data', imitationErc20Transfer.encode()) - .with('transfers', [ - erc20TransferToJson(imitationTransfer) as Transfer, - ]) - .build(), - ) as EthereumTransaction; - const results = [imitationTransaction, multisigTransaction]; - - const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chain.chainId}`; - const getAllTransactionsUrl = `${chain.transactionService}/api/v1/safes/${safe.address}/all-transactions/`; - const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safe.address}`; - const getImitationTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${imitationToken.address}`; - const getTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${multisigToken.address}`; - networkService.get.mockImplementation(({ url }) => { - if (url === getChainUrl) { - 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 === getImitationTokenAddressUrl) { - return Promise.resolve({ - data: imitationToken, - 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=true`, - ) - .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: 'OUTGOING', - humanDescription: null, - recipient: { - logoUri: null, - name: null, - value: imitationAddress, - }, - richDecodedInfo: null, - sender: { - logoUri: null, - name: null, - value: safe.address, - }, - transferInfo: { - decimals: imitationToken.decimals, - imitation: true, - logoUri: imitationToken.logoUri, - tokenAddress: imitationToken.address, - tokenName: imitationToken.name, - tokenSymbol: imitationToken.symbol, - trusted: imitationToken.trusted, - type: 'ERC20', - value: multisigTransfer.value, - }, - type: 'Transfer', - }, - txStatus: 'SUCCESS', - }, - 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: 1710927685000, - 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: null, - logoUri: multisigToken.logoUri, - tokenAddress: multisigToken.address, - tokenName: multisigToken.name, - tokenSymbol: multisigToken.symbol, - trusted: null, - type: 'ERC20', - value: multisigTransfer.value, - }, - type: 'Transfer', - }, - txStatus: 'SUCCESS', - }, - type: 'TRANSACTION', - }, - ]); - }); - }); - - it('should filter out outgoing ERC-20 transfers that imitate a direct predecessor', async () => { - const chain = chainBuilder().build(); - const safe = safeBuilder().build(); - - const multisigExecutionDate = new Date('2024-03-20T09:41:25Z'); - const multisigToken = tokenBuilder() - .with('trusted', true) - .with('type', TokenType.Erc20) - .build(); - const multisigTransfer = { - ...erc20TransferBuilder() - .with('executionDate', multisigExecutionDate) - .with('from', safe.address) - .with('tokenAddress', multisigToken.address) - .with('value', faker.string.numeric({ exclude: ['0'] })) - .build(), - tokenInfo: multisigToken, - }; - const multisigTransaction = { - ...(multisigTransactionToJson( - multisigTransactionBuilder() - .with('executionDate', multisigExecutionDate) - .with('safe', safe.address) - .with('to', multisigToken.address) - .with('value', '0') - .with('data', '0x') // TODO: Use encoder - .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 - this could remove dataDecodedParamHelper.getFromParam/getToParam? - transfers: [erc20TransferToJson(multisigTransfer) as Transfer], - } as MultisigTransaction; - - // TODO: Value and recipient - const imitationAddress = getAddress( - multisigTransfer.to.slice(0, 5) + - faker.finance.ethereumAddress().slice(5, -4) + - multisigTransfer.to.slice(-4), - ); - const imitationExecutionDate = new Date('2024-03-20T09:42:58Z'); - const imitationErc20Transfer = erc20TransferEncoder() - .with('to', imitationAddress) - .with('value', BigInt(multisigTransfer.value)); - const imitationToken = tokenBuilder() - .with('trusted', true) - .with('type', TokenType.Erc20) - .build(); - const imitationTransfer = { - ...erc20TransferBuilder() - .with('from', safe.address) - .with('to', imitationAddress) - .with('tokenAddress', imitationToken.address) - .with('value', multisigTransfer.value) - .with('executionDate', imitationExecutionDate) - .build(), - // TODO: Update type to include tokenInfo - tokenInfo: imitationToken, - }; - const imitationTransaction = ethereumTransactionToJson( - ethereumTransactionBuilder() - .with('executionDate', imitationTransfer.executionDate) - .with('data', imitationErc20Transfer.encode()) - .with('transfers', [ - erc20TransferToJson(imitationTransfer) as Transfer, - ]) - .build(), - ) as EthereumTransaction; - const results = [imitationTransaction, multisigTransaction]; - - const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chain.chainId}`; - const getAllTransactionsUrl = `${chain.transactionService}/api/v1/safes/${safe.address}/all-transactions/`; - const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safe.address}`; - const getImitationTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${imitationToken.address}`; - const getTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${multisigToken.address}`; - networkService.get.mockImplementation(({ url }) => { - if (url === getChainUrl) { - 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 === getImitationTokenAddressUrl) { - return Promise.resolve({ - data: imitationToken, - 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=true&imitation=false`, - ) - .expect(200) - .then(({ body }) => { - expect(body.results).toStrictEqual([ - { - timestamp: 1710927685000, - 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: 1710927685000, - 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: null, - logoUri: multisigToken.logoUri, - tokenAddress: multisigToken.address, - tokenName: multisigToken.name, - tokenSymbol: multisigToken.symbol, - trusted: null, - type: 'ERC20', - value: multisigTransfer.value, - }, - type: 'Transfer', - }, - txStatus: 'SUCCESS', - }, - type: 'TRANSACTION', - }, - ]); - }); - }); - }); - - describe('Non-trusted tokens', () => { - it('should flag outgoing ERC-20 transfers that imitate a direct predecessor', async () => { - const chain = chainBuilder().build(); - const safe = safeBuilder().build(); - - const multisigExecutionDate = new Date('2024-03-20T09:41:25Z'); - const multisigToken = tokenBuilder() - .with('trusted', false) - .with('type', TokenType.Erc20) - .build(); - const multisigTransfer = { - ...erc20TransferBuilder() - .with('executionDate', multisigExecutionDate) - .with('from', safe.address) - .with('tokenAddress', multisigToken.address) - .with('value', faker.string.numeric({ exclude: ['0'] })) - .build(), - tokenInfo: multisigToken, - }; - const multisigTransaction = { - ...(multisigTransactionToJson( - multisigTransactionBuilder() - .with('executionDate', multisigExecutionDate) - .with('safe', safe.address) - .with('to', multisigToken.address) - .with('value', '0') - .with('data', '0x') // TODO: Use encoder - .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 - this could remove dataDecodedParamHelper.getFromParam/getToParam? - transfers: [erc20TransferToJson(multisigTransfer) as Transfer], - } as MultisigTransaction; - - // TODO: Value and recipient - const imitationAddress = getAddress( - multisigTransfer.to.slice(0, 5) + - faker.finance.ethereumAddress().slice(5, -4) + - multisigTransfer.to.slice(-4), - ); - const imitationExecutionDate = new Date('2024-03-20T09:42:58Z'); - const imitationErc20Transfer = erc20TransferEncoder() - .with('to', imitationAddress) - .with('value', BigInt(multisigTransfer.value)); - const imitationToken = tokenBuilder() - .with('trusted', false) - .with('type', TokenType.Erc20) - .build(); - const imitationTransfer = { - ...erc20TransferBuilder() - .with('from', safe.address) - .with('to', imitationAddress) - .with('tokenAddress', imitationToken.address) - .with('value', multisigTransfer.value) - .with('executionDate', imitationExecutionDate) - .build(), - // TODO: Update type to include tokenInfo - tokenInfo: imitationToken, - }; - const imitationTransaction = ethereumTransactionToJson( - ethereumTransactionBuilder() - .with('executionDate', imitationTransfer.executionDate) - .with('data', imitationErc20Transfer.encode()) - .with('transfers', [ - erc20TransferToJson(imitationTransfer) as Transfer, - ]) - .build(), - ) as EthereumTransaction; - const results = [imitationTransaction, multisigTransaction]; - - const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chain.chainId}`; - const getAllTransactionsUrl = `${chain.transactionService}/api/v1/safes/${safe.address}/all-transactions/`; - const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safe.address}`; - const getImitationTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${imitationToken.address}`; - const getTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${multisigToken.address}`; - networkService.get.mockImplementation(({ url }) => { - if (url === getChainUrl) { - 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 === getImitationTokenAddressUrl) { - return Promise.resolve({ - data: imitationToken, - 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: 'OUTGOING', - humanDescription: null, - recipient: { - logoUri: null, - name: null, - value: imitationAddress, - }, - richDecodedInfo: null, - sender: { - logoUri: null, - name: null, - value: safe.address, - }, - transferInfo: { - decimals: imitationToken.decimals, - imitation: true, - logoUri: imitationToken.logoUri, - tokenAddress: imitationToken.address, - tokenName: imitationToken.name, - tokenSymbol: imitationToken.symbol, - trusted: imitationToken.trusted, - type: 'ERC20', - value: multisigTransfer.value, - }, - type: 'Transfer', - }, - txStatus: 'SUCCESS', - }, - 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: 1710927685000, - 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: null, - logoUri: multisigToken.logoUri, - tokenAddress: multisigToken.address, - tokenName: multisigToken.name, - tokenSymbol: multisigToken.symbol, - trusted: null, - type: 'ERC20', - value: multisigTransfer.value, - }, - type: 'Transfer', - }, - txStatus: 'SUCCESS', - }, - type: 'TRANSACTION', - }, - ]); - }); - }); - - it('should filter out outgoing ERC-20 transfers that imitate a direct predecessor', async () => { - const chain = chainBuilder().build(); - const safe = safeBuilder().build(); - - const multisigExecutionDate = new Date('2024-03-20T09:41:25Z'); - const multisigToken = tokenBuilder() - .with('trusted', false) - .with('type', TokenType.Erc20) - .build(); - const multisigTransfer = { - ...erc20TransferBuilder() - .with('executionDate', multisigExecutionDate) - .with('from', safe.address) - .with('tokenAddress', multisigToken.address) - .with('value', faker.string.numeric({ exclude: ['0'] })) - .build(), - tokenInfo: multisigToken, - }; - const multisigTransaction = { - ...(multisigTransactionToJson( - multisigTransactionBuilder() - .with('executionDate', multisigExecutionDate) - .with('safe', safe.address) - .with('to', multisigToken.address) - .with('value', '0') - .with('data', '0x') // TODO: Use encoder - .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 - this could remove dataDecodedParamHelper.getFromParam/getToParam? - transfers: [erc20TransferToJson(multisigTransfer) as Transfer], - } as MultisigTransaction; - - // TODO: Value and recipient - const imitationAddress = getAddress( - multisigTransfer.to.slice(0, 5) + - faker.finance.ethereumAddress().slice(5, -4) + - multisigTransfer.to.slice(-4), - ); - const imitationExecutionDate = new Date('2024-03-20T09:42:58Z'); - const imitationErc20Transfer = erc20TransferEncoder() - .with('to', imitationAddress) - .with('value', BigInt(multisigTransfer.value)); - const imitationToken = tokenBuilder() - .with('trusted', false) - .with('type', TokenType.Erc20) - .build(); - const imitationTransfer = { - ...erc20TransferBuilder() - .with('from', safe.address) - .with('to', imitationAddress) - .with('tokenAddress', imitationToken.address) - .with('value', multisigTransfer.value) - .with('executionDate', imitationExecutionDate) - .build(), - // TODO: Update type to include tokenInfo - tokenInfo: imitationToken, - }; - const imitationTransaction = ethereumTransactionToJson( - ethereumTransactionBuilder() - .with('executionDate', imitationTransfer.executionDate) - .with('data', imitationErc20Transfer.encode()) - .with('transfers', [ - erc20TransferToJson(imitationTransfer) as Transfer, - ]) - .build(), - ) as EthereumTransaction; - const results = [imitationTransaction, multisigTransaction]; - - const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chain.chainId}`; - const getAllTransactionsUrl = `${chain.transactionService}/api/v1/safes/${safe.address}/all-transactions/`; - const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safe.address}`; - const getImitationTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${imitationToken.address}`; - const getTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${multisigToken.address}`; - networkService.get.mockImplementation(({ url }) => { - if (url === getChainUrl) { - 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 === getImitationTokenAddressUrl) { - return Promise.resolve({ - data: imitationToken, - 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: 1710927685000, - 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: 1710927685000, - 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: null, - logoUri: multisigToken.logoUri, - tokenAddress: multisigToken.address, - tokenName: multisigToken.name, - tokenSymbol: multisigToken.symbol, - trusted: null, - type: 'ERC20', - value: multisigTransfer.value, - }, - type: 'Transfer', - }, - txStatus: 'SUCCESS', - }, - type: 'TRANSACTION', - }, - ]); - }); - }); - }); - }); }); diff --git a/src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts b/src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts new file mode 100644 index 0000000000..9f64e7b998 --- /dev/null +++ b/src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts @@ -0,0 +1,1467 @@ +import { faker } from '@faker-js/faker'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import * as request from 'supertest'; +import { TestAppProvider } from '@/__tests__/test-app.provider'; +import { IConfigurationService } from '@/config/configuration.service.interface'; +import configuration from '@/config/entities/__tests__/configuration'; +import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; +import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; +import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; +import { + dataDecodedBuilder, + dataDecodedParameterBuilder, +} from '@/domain/data-decoder/entities/__tests__/data-decoded.builder'; +import { pageBuilder } from '@/domain/entities/__tests__/page.builder'; +import { + ethereumTransactionBuilder, + toJson as ethereumTransactionToJson, +} from '@/domain/safe/entities/__tests__/ethereum-transaction.builder'; +import { confirmationBuilder } from '@/domain/safe/entities/__tests__/multisig-transaction-confirmation.builder'; +import { + multisigTransactionBuilder, + toJson as multisigTransactionToJson, +} from '@/domain/safe/entities/__tests__/multisig-transaction.builder'; +import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; +import { tokenBuilder } from '@/domain/tokens/__tests__/token.builder'; +import { TokenType } from '@/domain/tokens/entities/token.entity'; +import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; +import { Transfer } from '@/domain/safe/entities/transfer.entity'; +import { + INetworkService, + NetworkService, +} from '@/datasources/network/network.service.interface'; +import { AppModule } from '@/app.module'; +import { CacheModule } from '@/datasources/cache/cache.module'; +import { RequestScopedLoggingModule } from '@/logging/logging.module'; +import { NetworkModule } from '@/datasources/network/network.module'; +import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; +import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; +import { + erc20TransferBuilder, + toJson as erc20TransferToJson, +} from '@/domain/safe/entities/__tests__/erc20-transfer.builder'; +import { getAddress, 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'; +import { EthereumTransaction } from '@/domain/safe/entities/ethereum-transaction.entity'; +import { MultisigTransaction } from '@/domain/safe/entities/multisig-transaction.entity'; + +describe('Transactions History Controller (Unit) - Imitation Transactions', () => { + let app: INestApplication; + let safeConfigUrl: string; + let networkService: jest.MockedObjectDeep; + const lookupDistance = 2; + const prefixLength = 3; + const suffixLength = 4; + + beforeEach(async () => { + jest.resetAllMocks(); + + const testConfiguration: typeof configuration = () => ({ + ...configuration(), + mappings: { + ...configuration().mappings, + imitation: { + lookupDistance, + prefixLength, + suffixLength, + }, + }, + features: { + ...configuration().features, + imitationMapping: true, + }, + }); + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule.register(testConfiguration)], + }) + .overrideModule(AccountDataSourceModule) + .useModule(TestAccountDataSourceModule) + .overrideModule(CacheModule) + .useModule(TestCacheModule) + .overrideModule(RequestScopedLoggingModule) + .useModule(TestLoggingModule) + .overrideModule(NetworkModule) + .useModule(TestNetworkModule) + .overrideModule(QueuesApiModule) + .useModule(TestQueuesApiModule) + .compile(); + + const configurationService = moduleFixture.get(IConfigurationService); + safeConfigUrl = configurationService.get('safeConfig.baseUri'); + networkService = moduleFixture.get(NetworkService); + + app = await new TestAppProvider().provide(moduleFixture); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + function getImitationAddress(address: `0x${string}`): `0x${string}` { + // + 2 is to account for the '0x' prefix + const prefix = address.slice(0, prefixLength + 2); + const suffix = address.slice(-suffixLength); + 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(); + const multisigTransfer = { + ...erc20TransferBuilder() + .with('executionDate', multisigExecutionDate) + .with('from', safe.address) + .with('tokenAddress', multisigToken.address) + .with('value', faker.string.numeric({ exclude: ['0'] })) + .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) + .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 imitationAddress = getImitationAddress(multisigTransfer.to); + const imitationExecutionDate = new Date('2024-03-20T09:42:58Z'); + const imitationToken = tokenBuilder() + .with('type', TokenType.Erc20) + .with('decimals', multisigToken.decimals) + .build(); + + const imitationIncomingTransfer = { + ...erc20TransferBuilder() + .with('from', imitationAddress) + .with('to', safe.address) + .with('tokenAddress', imitationToken.address) + .with('value', multisigTransfer.value) + .with('executionDate', imitationExecutionDate) + .build(), + // TODO: Update type to include tokenInfo + tokenInfo: imitationToken, + }; + const imitationIncomingErc20Transfer = erc20TransferEncoder() + .with('to', safe.address) + .with('value', BigInt(multisigTransfer.value)); + const imitationIncomingTransaction = ethereumTransactionToJson( + ethereumTransactionBuilder() + .with('executionDate', imitationIncomingTransfer.executionDate) + .with('data', imitationIncomingErc20Transfer.encode()) + .with('transfers', [ + erc20TransferToJson(imitationIncomingTransfer) as Transfer, + ]) + .build(), + ) as EthereumTransaction; + + const imitationOutgoingTransfer = { + ...erc20TransferBuilder() + .with('from', safe.address) + .with('to', imitationAddress) + .with('tokenAddress', imitationToken.address) + .with('value', multisigTransfer.value) + .with('executionDate', imitationExecutionDate) + .build(), + // TODO: Update type to include tokenInfo + tokenInfo: imitationToken, + }; + const imitationOutgoingErc20Transfer = erc20TransferEncoder() + .with('to', imitationAddress) + .with('value', BigInt(multisigTransfer.value)); + const imitationOutgoingTransaction = ethereumTransactionToJson( + ethereumTransactionBuilder() + .with('executionDate', imitationOutgoingTransfer.executionDate) + .with('data', imitationOutgoingErc20Transfer.encode()) + .with('transfers', [ + erc20TransferToJson(imitationOutgoingTransfer) as Transfer, + ]) + .build(), + ) as EthereumTransaction; + + 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}`; + const getImitationTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${imitationToken.address}`; + + it('should flag imitation incoming/outgoing transfers within the lookup distance', async () => { + const results = [ + imitationIncomingTransaction, + multisigTransaction, + imitationOutgoingTransaction, + 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, + }); + } + if (url === getImitationTokenAddressUrl) { + return Promise.resolve({ + data: imitationToken, + 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: imitationAddress, + }, + transferInfo: { + decimals: imitationToken.decimals, + imitation: true, + logoUri: imitationToken.logoUri, + tokenAddress: imitationToken.address, + tokenName: imitationToken.name, + tokenSymbol: imitationToken.symbol, + trusted: imitationToken.trusted, + type: 'ERC20', + value: multisigTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + }, + 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: 1710927685000, + 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', + }, + type: 'TRANSACTION', + }, + { + conflictType: 'None', + transaction: { + executionInfo: null, + // @ts-expect-error - Type does not contain transfers + id: `transfer_${safe.address}_${results[2].transfers[0].transferId}`, + safeAppInfo: null, + timestamp: 1710927778000, + txInfo: { + direction: 'OUTGOING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: imitationAddress, + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: safe.address, + }, + transferInfo: { + decimals: imitationToken.decimals, + imitation: true, + logoUri: imitationToken.logoUri, + tokenAddress: imitationToken.address, + tokenName: imitationToken.name, + tokenSymbol: imitationToken.symbol, + trusted: imitationToken.trusted, + type: 'ERC20', + value: multisigTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + }, + 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', + }, + 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: 1710927685000, + 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', + }, + type: 'TRANSACTION', + }, + ]); + }); + }); + + it('should not flag imitation incoming/outgoing transfers outside the lookup distance', async () => { + const results = [ + imitationIncomingTransaction, + imitationOutgoingTransaction, + notImitatedMultisigTransaction, + notImitatedMultisigTransaction, + multisigTransaction, + ]; + + networkService.get.mockImplementation(({ url }) => { + console.log('=>', 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, + }); + } + if (url === getImitationTokenAddressUrl) { + return Promise.resolve({ + data: imitationToken, + 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: imitationAddress, + }, + transferInfo: { + decimals: imitationToken.decimals, + imitation: false, // Not flagged + logoUri: imitationToken.logoUri, + tokenAddress: imitationToken.address, + tokenName: imitationToken.name, + tokenSymbol: imitationToken.symbol, + trusted: imitationToken.trusted, + type: 'ERC20', + value: multisigTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + }, + type: 'TRANSACTION', + }, + { + conflictType: 'None', + transaction: { + executionInfo: null, + // @ts-expect-error - Type does not contain transfers + id: `transfer_${safe.address}_${results[1].transfers[0].transferId}`, + safeAppInfo: null, + timestamp: 1710927778000, + txInfo: { + direction: 'OUTGOING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: imitationAddress, + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: safe.address, + }, + transferInfo: { + decimals: imitationToken.decimals, + imitation: false, // Not flagged + logoUri: imitationToken.logoUri, + tokenAddress: imitationToken.address, + tokenName: imitationToken.name, + tokenSymbol: imitationToken.symbol, + trusted: imitationToken.trusted, + type: 'ERC20', + value: multisigTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + }, + 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', + }, + 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', + }, + 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: 1710927685000, + 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', + }, + type: 'TRANSACTION', + }, + ]); + }); + }); + + it('should filter out imitation incoming/outgoing transfers within the lookup distance', async () => { + const results = [ + imitationIncomingTransaction, + multisigTransaction, + imitationOutgoingTransaction, + 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, + }); + } + if (url === getImitationTokenAddressUrl) { + return Promise.resolve({ + data: imitationToken, + 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: 1710927685000, + 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: 1710927685000, + 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', + }, + 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', + }, + 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: 1710927685000, + 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', + }, + type: 'TRANSACTION', + }, + ]); + }); + }); + + it('should not filter out imitation incoming/outgoing transfers within the lookup distance', async () => { + const results = [ + imitationIncomingTransaction, + imitationOutgoingTransaction, + 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, + }); + } + if (url === getImitationTokenAddressUrl) { + return Promise.resolve({ + data: imitationToken, + 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: imitationAddress, + }, + transferInfo: { + decimals: imitationToken.decimals, + imitation: false, // Not flagged + logoUri: imitationToken.logoUri, + tokenAddress: imitationToken.address, + tokenName: imitationToken.name, + tokenSymbol: imitationToken.symbol, + trusted: imitationToken.trusted, + type: 'ERC20', + value: multisigTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + }, + type: 'TRANSACTION', + }, + { + conflictType: 'None', + transaction: { + executionInfo: null, + // @ts-expect-error - Type does not contain transfers + id: `transfer_${safe.address}_${results[1].transfers[0].transferId}`, + safeAppInfo: null, + timestamp: 1710927778000, + txInfo: { + direction: 'OUTGOING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: imitationAddress, + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: safe.address, + }, + transferInfo: { + decimals: imitationToken.decimals, + imitation: false, // Not flagged + logoUri: imitationToken.logoUri, + tokenAddress: imitationToken.address, + tokenName: imitationToken.name, + tokenSymbol: imitationToken.symbol, + trusted: imitationToken.trusted, + type: 'ERC20', + value: multisigTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + }, + 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', + }, + 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', + }, + 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: 1710927685000, + 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', + }, + type: 'TRANSACTION', + }, + ]); + }); + }); + + it('should detect imitation tokens using differing decimals', async () => { + const differentDecimals = multisigToken.decimals! + 1; + const differentValue = multisigTransfer.value + '0'; + const imitationWithDifferentDecimalsAddress = getImitationAddress( + multisigTransfer.to, + ); + const imitationWithDifferentDecimalsExecutionDate = new Date( + '2024-03-20T09:42:58Z', + ); + const imitationWithDifferentDecimalsToken = tokenBuilder() + .with('type', TokenType.Erc20) + .with('decimals', differentDecimals) + .build(); + + const imitationWithDifferentDecimalsIncomingTransfer = { + ...erc20TransferBuilder() + .with('from', imitationWithDifferentDecimalsAddress) + .with('to', safe.address) + .with('tokenAddress', imitationWithDifferentDecimalsToken.address) + .with('value', differentValue) + .with('executionDate', imitationWithDifferentDecimalsExecutionDate) + .build(), + // TODO: Update type to include tokenInfo + tokenInfo: imitationWithDifferentDecimalsToken, + }; + const imitationWithDifferentDecimalsIncomingErc20Transfer = + erc20TransferEncoder() + .with('to', safe.address) + .with('value', BigInt(differentValue)); + const imitationWithDifferentDecimalsIncomingTransaction = + ethereumTransactionToJson( + ethereumTransactionBuilder() + .with( + 'executionDate', + imitationWithDifferentDecimalsIncomingTransfer.executionDate, + ) + .with( + 'data', + imitationWithDifferentDecimalsIncomingErc20Transfer.encode(), + ) + .with('transfers', [ + erc20TransferToJson( + imitationWithDifferentDecimalsIncomingTransfer, + ) as Transfer, + ]) + .build(), + ) as EthereumTransaction; + + const results = [ + imitationWithDifferentDecimalsIncomingTransaction, + 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 === + `${chain.transactionService}/api/v1/tokens/${imitationWithDifferentDecimalsToken.address}` + ) { + return Promise.resolve({ + data: imitationWithDifferentDecimalsToken, + 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: imitationWithDifferentDecimalsAddress, + }, + transferInfo: { + decimals: imitationWithDifferentDecimalsToken.decimals, + imitation: true, + logoUri: imitationWithDifferentDecimalsToken.logoUri, + tokenAddress: imitationWithDifferentDecimalsToken.address, + tokenName: imitationWithDifferentDecimalsToken.name, + tokenSymbol: imitationWithDifferentDecimalsToken.symbol, + trusted: imitationWithDifferentDecimalsToken.trusted, + type: 'ERC20', + value: imitationWithDifferentDecimalsIncomingTransfer.value, + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + }, + 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: 1710927685000, + 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', + }, + type: 'TRANSACTION', + }, + ]); + }); + }); +}); From c9598e39e0d173ae9ad82995c027f24de2f807ec Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 3 Jun 2024 16:29:16 +0200 Subject: [PATCH 050/207] Fall back to indexed name of contract if none is set (#1604) This falls back to the `name` of a contract when getting the details of an address and no `displayName` is set. --- .../__tests__/address-info.helper.spec.ts | 184 ++++++++++++++++++ .../address-info/address-info.helper.ts | 5 +- 2 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 src/routes/common/__tests__/address-info.helper.spec.ts diff --git a/src/routes/common/__tests__/address-info.helper.spec.ts b/src/routes/common/__tests__/address-info.helper.spec.ts new file mode 100644 index 0000000000..c72f1fd9b3 --- /dev/null +++ b/src/routes/common/__tests__/address-info.helper.spec.ts @@ -0,0 +1,184 @@ +import { ContractsRepository } from '@/domain/contracts/contracts.repository'; +import { contractBuilder } from '@/domain/contracts/entities/__tests__/contract.builder'; +import { tokenBuilder } from '@/domain/tokens/__tests__/token.builder'; +import { TokenRepository } from '@/domain/tokens/token.repository'; +import { ILoggingService } from '@/logging/logging.interface'; +import { AddressInfoHelper } from '@/routes/common/address-info/address-info.helper'; +import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; + +describe('AddressInfoHelper', () => { + let target: AddressInfoHelper; + + const contractsRepository = jest.mocked({ + getContract: jest.fn(), + } as jest.MockedObjectDeep); + const tokenRepository = jest.mocked({ + getToken: jest.fn(), + } as jest.MockedObjectDeep); + const loggingService = jest.mocked({ + debug: jest.fn(), + } as jest.MockedObjectDeep); + + beforeEach(() => { + jest.resetAllMocks(); + target = new AddressInfoHelper( + contractsRepository, + tokenRepository, + loggingService, + ); + }); + + describe('get', () => { + it('should return the first source if found', async () => { + const chainId = faker.string.numeric(); + const contract = contractBuilder() + .with('displayName', faker.word.sample()) + .build(); + contractsRepository.getContract.mockResolvedValue(contract); + + const result = await target.get(chainId, contract.address, [ + 'CONTRACT', + 'TOKEN', + ]); + + expect(result).toEqual({ + value: contract.address, + name: contract.displayName, + logoUri: contract.logoUri, + }); + expect(contractsRepository.getContract).toHaveBeenCalledWith({ + chainId, + contractAddress: contract.address, + }); + }); + + it('should return the next source if the first one fails', async () => { + const chainId = faker.string.numeric(); + const token = tokenBuilder().build(); + contractsRepository.getContract.mockRejectedValue(new Error('Not found')); + tokenRepository.getToken.mockResolvedValue(token); + + const result = await target.get(chainId, token.address, [ + 'CONTRACT', + 'TOKEN', + ]); + + expect(result).toEqual({ + value: token.address, + name: token.name, + logoUri: token.logoUri, + }); + expect(contractsRepository.getContract).toHaveBeenCalledWith({ + chainId, + contractAddress: token.address, + }); + expect(tokenRepository.getToken).toHaveBeenCalledWith({ + chainId, + address: token.address, + }); + }); + + it('should fall back to returning the name of contracts if displayName is not available', async () => { + const chainId = faker.string.numeric(); + const contract = contractBuilder().with('displayName', '').build(); + contractsRepository.getContract.mockResolvedValue(contract); + + const result = await target.get(chainId, contract.address, ['CONTRACT']); + + expect(result).toEqual({ + value: contract.address, + name: contract.name, + logoUri: contract.logoUri, + }); + expect(contractsRepository.getContract).toHaveBeenCalledWith({ + chainId, + contractAddress: contract.address, + }); + }); + }); + + // Note: we do not test the intricacies of `get` here as it is covered above + describe('getOrDefault', () => { + it('should return a default if no source is found', async () => { + const chainId = faker.string.numeric(); + const address = getAddress(faker.finance.ethereumAddress()); + contractsRepository.getContract.mockRejectedValue(new Error('Not found')); + + const result = await target.getOrDefault(chainId, address, ['CONTRACT']); + + expect(result).toEqual({ + value: address, + name: null, + logoUri: null, + }); + expect(contractsRepository.getContract).toHaveBeenCalledWith({ + chainId, + contractAddress: address, + }); + }); + }); + + // Note: we do not test the intricacies of `getOrDefault` here as it is covered above + describe('getCollection', () => { + it('should return a collection of addresses', async () => { + const chainId = faker.string.numeric(); + const contract = contractBuilder().build(); + const token = tokenBuilder().build(); + const address = getAddress(faker.finance.ethereumAddress()); + contractsRepository.getContract + .mockResolvedValueOnce(contract) + .mockRejectedValueOnce(new Error('Not found')) + .mockRejectedValueOnce(new Error('Not found')); + tokenRepository.getToken + .mockResolvedValueOnce(token) + .mockRejectedValueOnce(new Error('Not found')); + + const result = await target.getCollection( + chainId, + [contract.address, token.address, address], + ['CONTRACT', 'TOKEN'], + ); + + expect(result).toEqual([ + { + value: contract.address, + name: contract.displayName, + logoUri: contract.logoUri, + }, + { + value: token.address, + name: token.name, + logoUri: token.logoUri, + }, + { + value: address, + name: null, + logoUri: null, + }, + ]); + expect(contractsRepository.getContract).toHaveBeenCalledTimes(3); + expect(tokenRepository.getToken).toHaveBeenCalledTimes(2); + expect(contractsRepository.getContract).toHaveBeenNthCalledWith(1, { + chainId, + contractAddress: contract.address, + }); + expect(contractsRepository.getContract).toHaveBeenNthCalledWith(2, { + chainId, + contractAddress: token.address, + }); + expect(contractsRepository.getContract).toHaveBeenNthCalledWith(3, { + chainId, + contractAddress: address, + }); + expect(tokenRepository.getToken).toHaveBeenNthCalledWith(1, { + chainId, + address: token.address, + }); + expect(tokenRepository.getToken).toHaveBeenNthCalledWith(2, { + chainId, + address, + }); + }); + }); +}); diff --git a/src/routes/common/address-info/address-info.helper.ts b/src/routes/common/address-info/address-info.helper.ts index 257053706f..05c31f70d1 100644 --- a/src/routes/common/address-info/address-info.helper.ts +++ b/src/routes/common/address-info/address-info.helper.ts @@ -100,7 +100,10 @@ export class AddressInfoHelper { case 'CONTRACT': return this.contractsRepository .getContract({ chainId, contractAddress: address }) - .then((c) => new AddressInfo(c.address, c.displayName, c.logoUri)); + .then((c) => { + const name = c.displayName || c.name; + return new AddressInfo(c.address, name, c.logoUri); + }); case 'TOKEN': return this.tokenRepository .getToken({ chainId, address }) From b5752c38830d9db6c0190071b72766ba124aa40f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Jun 2024 23:55:53 +0200 Subject: [PATCH 051/207] Bump the nest-js-core group with 4 updates (#1605) Bumps the nest-js-core group with 4 updates: [@nestjs/common](https://github.com/nestjs/nest/tree/HEAD/packages/common), [@nestjs/core](https://github.com/nestjs/nest/tree/HEAD/packages/core), [@nestjs/platform-express](https://github.com/nestjs/nest/tree/HEAD/packages/platform-express) and [@nestjs/testing](https://github.com/nestjs/nest/tree/HEAD/packages/testing). Updates `@nestjs/common` from 10.3.8 to 10.3.9 - [Release notes](https://github.com/nestjs/nest/releases) - [Commits](https://github.com/nestjs/nest/commits/v10.3.9/packages/common) Updates `@nestjs/core` from 10.3.8 to 10.3.9 - [Release notes](https://github.com/nestjs/nest/releases) - [Commits](https://github.com/nestjs/nest/commits/v10.3.9/packages/core) Updates `@nestjs/platform-express` from 10.3.8 to 10.3.9 - [Release notes](https://github.com/nestjs/nest/releases) - [Commits](https://github.com/nestjs/nest/commits/v10.3.9/packages/platform-express) Updates `@nestjs/testing` from 10.3.8 to 10.3.9 - [Release notes](https://github.com/nestjs/nest/releases) - [Commits](https://github.com/nestjs/nest/commits/v10.3.9/packages/testing) --- updated-dependencies: - dependency-name: "@nestjs/common" dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nest-js-core - dependency-name: "@nestjs/core" dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nest-js-core - dependency-name: "@nestjs/platform-express" dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nest-js-core - dependency-name: "@nestjs/testing" dependency-type: direct:development update-type: version-update:semver-patch dependency-group: nest-js-core ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 8 ++++---- yarn.lock | 40 ++++++++++++++++++++-------------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/package.json b/package.json index 74215c1f93..c7595d9577 100644 --- a/package.json +++ b/package.json @@ -26,10 +26,10 @@ }, "dependencies": { "@nestjs/cli": "^10.3.2", - "@nestjs/common": "^10.3.8", + "@nestjs/common": "^10.3.9", "@nestjs/config": "^3.2.2", - "@nestjs/core": "^10.3.8", - "@nestjs/platform-express": "^10.3.8", + "@nestjs/core": "^10.3.9", + "@nestjs/platform-express": "^10.3.9", "@nestjs/serve-static": "^4.0.2", "@nestjs/swagger": "^7.3.1", "@safe-global/safe-deployments": "^1.36.0", @@ -52,7 +52,7 @@ "devDependencies": { "@faker-js/faker": "^8.4.1", "@nestjs/schematics": "^10.1.1", - "@nestjs/testing": "^10.3.8", + "@nestjs/testing": "^10.3.9", "@types/amqplib": "^0", "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.21", diff --git a/yarn.lock b/yarn.lock index 3221fe1905..7a6c2e0324 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1227,9 +1227,9 @@ __metadata: languageName: node linkType: hard -"@nestjs/common@npm:^10.3.8": - version: 10.3.8 - resolution: "@nestjs/common@npm:10.3.8" +"@nestjs/common@npm:^10.3.9": + version: 10.3.9 + resolution: "@nestjs/common@npm:10.3.9" dependencies: iterare: "npm:1.2.1" tslib: "npm:2.6.2" @@ -1244,7 +1244,7 @@ __metadata: optional: true class-validator: optional: true - checksum: 10/e13fcfc11d4d0fdf2bf8c7482e7b3d6da2218cfd7f1483973084e49fd5f0ab4e9b4f36ce573a8ec88c32bd04fc792ab8468b377ba1446901fec8f02f2baafa17 + checksum: 10/a4886bf1e99f0f1952731dcb3ed4fa0927c0165285683637a719c85e4ddf3a2f711d554136c60d83862fc9f917541c1bbcb75191510930e503e19da190c2de34 languageName: node linkType: hard @@ -1263,9 +1263,9 @@ __metadata: languageName: node linkType: hard -"@nestjs/core@npm:^10.3.8": - version: 10.3.8 - resolution: "@nestjs/core@npm:10.3.8" +"@nestjs/core@npm:^10.3.9": + version: 10.3.9 + resolution: "@nestjs/core@npm:10.3.9" dependencies: "@nuxtjs/opencollective": "npm:0.3.2" fast-safe-stringify: "npm:2.1.1" @@ -1287,7 +1287,7 @@ __metadata: optional: true "@nestjs/websockets": optional: true - checksum: 10/62aebc3a5f48f79137a4dc5fcb0b72120c0ef9ac8ddf9a7c18164f7fe81b4374d47ff652ac876845f982d6126db555ca3c47ec89c772e65196e93e8c32c008f4 + checksum: 10/90f52b0cf7e80f417202306d65df16a23f5aaa4e9db996524845e5b2e8738f0f175dda9ef75a90d2b5665e2868aafa6e31ff4aa2b68cbb5a5169c43cc2fbaf41 languageName: node linkType: hard @@ -1308,9 +1308,9 @@ __metadata: languageName: node linkType: hard -"@nestjs/platform-express@npm:^10.3.8": - version: 10.3.8 - resolution: "@nestjs/platform-express@npm:10.3.8" +"@nestjs/platform-express@npm:^10.3.9": + version: 10.3.9 + resolution: "@nestjs/platform-express@npm:10.3.9" dependencies: body-parser: "npm:1.20.2" cors: "npm:2.8.5" @@ -1320,7 +1320,7 @@ __metadata: peerDependencies: "@nestjs/common": ^10.0.0 "@nestjs/core": ^10.0.0 - checksum: 10/62b4da16167650e87a8823214b0a1d8195760a565aecbae77fe584b8666131979b8653c42707e55d8c1d6f50d31f374fe25ab49e4ec86488f125be4e07bbb921 + checksum: 10/3fa49827355239c99882ed83e021f334851ffd12a17ff9a705f1dbc0fcee134dfc0795841aca0dc25663de8ce1628fbbeb338e185e8fb0f1515769657d317f7e languageName: node linkType: hard @@ -1404,9 +1404,9 @@ __metadata: languageName: node linkType: hard -"@nestjs/testing@npm:^10.3.8": - version: 10.3.8 - resolution: "@nestjs/testing@npm:10.3.8" +"@nestjs/testing@npm:^10.3.9": + version: 10.3.9 + resolution: "@nestjs/testing@npm:10.3.9" dependencies: tslib: "npm:2.6.2" peerDependencies: @@ -1419,7 +1419,7 @@ __metadata: optional: true "@nestjs/platform-express": optional: true - checksum: 10/87a07be7868451a51b040e19eae82bb0c6c3f1005663b3d43756cf4a98816254bed46ba679ed2d1db4e4e6b6b143acb9e7a8a86cfa3cec412e513071b70c454d + checksum: 10/0053a4fffc0675961c3026a33e01237757752646904fb2cbad0a756601045bba7a3b642c765d47347700a6193da9538c61a8743f2ea9075f3f8fd97d0a94a58d languageName: node linkType: hard @@ -7250,14 +7250,14 @@ __metadata: dependencies: "@faker-js/faker": "npm:^8.4.1" "@nestjs/cli": "npm:^10.3.2" - "@nestjs/common": "npm:^10.3.8" + "@nestjs/common": "npm:^10.3.9" "@nestjs/config": "npm:^3.2.2" - "@nestjs/core": "npm:^10.3.8" - "@nestjs/platform-express": "npm:^10.3.8" + "@nestjs/core": "npm:^10.3.9" + "@nestjs/platform-express": "npm:^10.3.9" "@nestjs/schematics": "npm:^10.1.1" "@nestjs/serve-static": "npm:^4.0.2" "@nestjs/swagger": "npm:^7.3.1" - "@nestjs/testing": "npm:^10.3.8" + "@nestjs/testing": "npm:^10.3.9" "@safe-global/safe-deployments": "npm:^1.36.0" "@types/amqplib": "npm:^0" "@types/cookie-parser": "npm:^1.4.7" From bdbb8bcd8cbaf0a812efec38181a229d226ea26b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Jun 2024 23:56:10 +0200 Subject: [PATCH 052/207] Bump prettier from 3.2.5 to 3.3.0 (#1606) Bumps [prettier](https://github.com/prettier/prettier) from 3.2.5 to 3.3.0. - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.2.5...3.3.0) --- updated-dependencies: - dependency-name: prettier dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index c7595d9577..dc2c0c6f1b 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "eslint-config-prettier": "^9.1.0", "husky": "^9.0.11", "jest": "29.7.0", - "prettier": "^3.2.5", + "prettier": "^3.3.0", "source-map-support": "^0.5.20", "supertest": "^7.0.0", "ts-jest": "29.1.3", diff --git a/yarn.lock b/yarn.lock index 7a6c2e0324..303f76710c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6819,12 +6819,12 @@ __metadata: languageName: node linkType: hard -"prettier@npm:^3.2.5": - version: 3.2.5 - resolution: "prettier@npm:3.2.5" +"prettier@npm:^3.3.0": + version: 3.3.0 + resolution: "prettier@npm:3.3.0" bin: prettier: bin/prettier.cjs - checksum: 10/d509f9da0b70e8cacc561a1911c0d99ec75117faed27b95cc8534cb2349667dee6351b0ca83fa9d5703f14127faa52b798de40f5705f02d843da133fc3aa416a + checksum: 10/e55233f8e4b5f96f52180dbfa424ae797a98a9b8a9a7a79de5004e522c02b423e71927ed99d855dbfcd00dc3b82e5f6fb304cfe117cc4e7c8477d883df2d8984 languageName: node linkType: hard @@ -7280,7 +7280,7 @@ __metadata: nestjs-cls: "npm:^4.3.0" postgres: "npm:^3.4.4" postgres-shift: "npm:^0.1.0" - prettier: "npm:^3.2.5" + prettier: "npm:^3.3.0" redis: "npm:^4.6.14" reflect-metadata: "npm:^0.2.2" rxjs: "npm:^7.8.1" From 6fcccf3654e2aad13ac1dc839ceca401879ea96f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Jun 2024 23:56:24 +0200 Subject: [PATCH 053/207] Bump @types/node from 20.12.12 to 20.14.0 (#1607) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.12.12 to 20.14.0. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index dc2c0c6f1b..c07ea4825d 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "@types/jest": "29.5.12", "@types/jsonwebtoken": "^9", "@types/lodash": "^4.17.4", - "@types/node": "^20.12.12", + "@types/node": "^20.14.0", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.2", "eslint": "^9.3.0", diff --git a/yarn.lock b/yarn.lock index 303f76710c..912335c9dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1918,12 +1918,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^20.12.12": - version: 20.12.12 - resolution: "@types/node@npm:20.12.12" +"@types/node@npm:^20.14.0": + version: 20.14.0 + resolution: "@types/node@npm:20.14.0" dependencies: undici-types: "npm:~5.26.4" - checksum: 10/e3945da0a3017bdc1f88f15bdfb823f526b2a717bd58d4640082d6eb0bd2794b5c99bfb914b9e9324ec116dce36066990353ed1c777e8a7b0641f772575793c4 + checksum: 10/49b332fbf8aee4dc4f61cc1f1f6e130632510f795dd7b274e55894516feaf4bec8a3d13ea764e2443e340a64ce9bbeb006d14513bf6ccdd4f21161eccc7f311e languageName: node linkType: hard @@ -7265,7 +7265,7 @@ __metadata: "@types/jest": "npm:29.5.12" "@types/jsonwebtoken": "npm:^9" "@types/lodash": "npm:^4.17.4" - "@types/node": "npm:^20.12.12" + "@types/node": "npm:^20.14.0" "@types/semver": "npm:^7.5.8" "@types/supertest": "npm:^6.0.2" amqp-connection-manager: "npm:^4.1.14" From 4a3ebae9fe6796ff8e78c91ac1a025a43cdd8736 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Jun 2024 23:56:38 +0200 Subject: [PATCH 054/207] Bump ts-jest from 29.1.3 to 29.1.4 (#1608) Bumps [ts-jest](https://github.com/kulshekhar/ts-jest) from 29.1.3 to 29.1.4. - [Release notes](https://github.com/kulshekhar/ts-jest/releases) - [Changelog](https://github.com/kulshekhar/ts-jest/blob/main/CHANGELOG.md) - [Commits](https://github.com/kulshekhar/ts-jest/compare/v29.1.3...v29.1.4) --- updated-dependencies: - dependency-name: ts-jest dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index c07ea4825d..7699da490f 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "prettier": "^3.3.0", "source-map-support": "^0.5.20", "supertest": "^7.0.0", - "ts-jest": "29.1.3", + "ts-jest": "29.1.4", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "4.2.0", diff --git a/yarn.lock b/yarn.lock index 912335c9dc..db27465d4b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7287,7 +7287,7 @@ __metadata: semver: "npm:^7.6.2" source-map-support: "npm:^0.5.20" supertest: "npm:^7.0.0" - ts-jest: "npm:29.1.3" + ts-jest: "npm:29.1.4" ts-loader: "npm:^9.5.1" ts-node: "npm:^10.9.2" tsconfig-paths: "npm:4.2.0" @@ -7956,9 +7956,9 @@ __metadata: languageName: node linkType: hard -"ts-jest@npm:29.1.3": - version: 29.1.3 - resolution: "ts-jest@npm:29.1.3" +"ts-jest@npm:29.1.4": + version: 29.1.4 + resolution: "ts-jest@npm:29.1.4" dependencies: bs-logger: "npm:0.x" fast-json-stable-stringify: "npm:2.x" @@ -7988,7 +7988,7 @@ __metadata: optional: true bin: ts-jest: cli.js - checksum: 10/cc1f608bb5859e112ffb8a6d84ddb5c20954b7ec8c89a8c7f95e373368d8946b5843594fe7779078eec2b7e825962848f1a1ba7a44c71b8a08ed4e75d3a3f8d8 + checksum: 10/3103c0e2f9937ae6bb51918105883565bb2d11cae1121ae20aedd1c4374f843341463a4a1986e02a958d119be0d3a9b996d761bc4aac85152a29385e609fed3c languageName: node linkType: hard From 4ba3d75a876bd4d13b51de65e848fd6a3960578b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Jun 2024 23:56:58 +0200 Subject: [PATCH 055/207] Bump typescript-eslint from 7.11.0 to 7.12.0 (#1609) Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 7.11.0 to 7.12.0. - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.12.0/packages/typescript-eslint) --- updated-dependencies: - dependency-name: typescript-eslint dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 116 +++++++++++++++++++++++++-------------------------- 2 files changed, 59 insertions(+), 59 deletions(-) diff --git a/package.json b/package.json index 7699da490f..db9689578e 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "ts-node": "^10.9.2", "tsconfig-paths": "4.2.0", "typescript": "^5.4.5", - "typescript-eslint": "^7.11.0" + "typescript-eslint": "^7.12.0" }, "jest": { "moduleFileExtensions": [ diff --git a/yarn.lock b/yarn.lock index db27465d4b..738ac02f5a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2012,15 +2012,15 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:7.11.0": - version: 7.11.0 - resolution: "@typescript-eslint/eslint-plugin@npm:7.11.0" +"@typescript-eslint/eslint-plugin@npm:7.12.0": + version: 7.12.0 + resolution: "@typescript-eslint/eslint-plugin@npm:7.12.0" dependencies: "@eslint-community/regexpp": "npm:^4.10.0" - "@typescript-eslint/scope-manager": "npm:7.11.0" - "@typescript-eslint/type-utils": "npm:7.11.0" - "@typescript-eslint/utils": "npm:7.11.0" - "@typescript-eslint/visitor-keys": "npm:7.11.0" + "@typescript-eslint/scope-manager": "npm:7.12.0" + "@typescript-eslint/type-utils": "npm:7.12.0" + "@typescript-eslint/utils": "npm:7.12.0" + "@typescript-eslint/visitor-keys": "npm:7.12.0" graphemer: "npm:^1.4.0" ignore: "npm:^5.3.1" natural-compare: "npm:^1.4.0" @@ -2031,44 +2031,44 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/be95ed0bbd5b34c47239677ea39d531bcd8a18717a67d70a297bed5b0050b256159856bb9c1e894ac550d011c24bb5b4abf8056c5d70d0d5895f0cc1accd14ea + checksum: 10/a62a74b2a469d94d9a688c26d7dfafef111ee8d7db6b55a80147d319a39ab68036c659b62ad5d9a0138e5581b24c42372bcc543343b8a41bb8c3f96ffd32743b languageName: node linkType: hard -"@typescript-eslint/parser@npm:7.11.0": - version: 7.11.0 - resolution: "@typescript-eslint/parser@npm:7.11.0" +"@typescript-eslint/parser@npm:7.12.0": + version: 7.12.0 + resolution: "@typescript-eslint/parser@npm:7.12.0" dependencies: - "@typescript-eslint/scope-manager": "npm:7.11.0" - "@typescript-eslint/types": "npm:7.11.0" - "@typescript-eslint/typescript-estree": "npm:7.11.0" - "@typescript-eslint/visitor-keys": "npm:7.11.0" + "@typescript-eslint/scope-manager": "npm:7.12.0" + "@typescript-eslint/types": "npm:7.12.0" + "@typescript-eslint/typescript-estree": "npm:7.12.0" + "@typescript-eslint/visitor-keys": "npm:7.12.0" debug: "npm:^4.3.4" peerDependencies: eslint: ^8.56.0 peerDependenciesMeta: typescript: optional: true - checksum: 10/0a32417aec62d7de04427323ab3fc8159f9f02429b24f739d8748e8b54fc65b0e3dbae8e4779c4b795f0d8e5f98a4d83a43b37ea0f50ebda51546cdcecf73caa + checksum: 10/66b692ca1d00965b854e99784e78d8540adc49cf44a4e295e91ad2e809f236d6d1b3877eeddf3ee61f531a1313c9269ed7f16e083148a92f82c5de1337b06659 languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:7.11.0": - version: 7.11.0 - resolution: "@typescript-eslint/scope-manager@npm:7.11.0" +"@typescript-eslint/scope-manager@npm:7.12.0": + version: 7.12.0 + resolution: "@typescript-eslint/scope-manager@npm:7.12.0" dependencies: - "@typescript-eslint/types": "npm:7.11.0" - "@typescript-eslint/visitor-keys": "npm:7.11.0" - checksum: 10/79eff310405c6657ff092641e3ad51c6698c6708b915ecef945ebdd1737bd48e1458c5575836619f42dec06143ec0e3a826f3e551af590d297367da3d08f329e + "@typescript-eslint/types": "npm:7.12.0" + "@typescript-eslint/visitor-keys": "npm:7.12.0" + checksum: 10/49a1fa4c15a161258963c4ffe37d89a212138d1c09e39a73064cd3a962823b98e362546de7228698877bc7e7f515252f439c140245f9689ff59efd7b35be58a4 languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:7.11.0": - version: 7.11.0 - resolution: "@typescript-eslint/type-utils@npm:7.11.0" +"@typescript-eslint/type-utils@npm:7.12.0": + version: 7.12.0 + resolution: "@typescript-eslint/type-utils@npm:7.12.0" dependencies: - "@typescript-eslint/typescript-estree": "npm:7.11.0" - "@typescript-eslint/utils": "npm:7.11.0" + "@typescript-eslint/typescript-estree": "npm:7.12.0" + "@typescript-eslint/utils": "npm:7.12.0" debug: "npm:^4.3.4" ts-api-utils: "npm:^1.3.0" peerDependencies: @@ -2076,23 +2076,23 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/ab6ebeff68a60fc40d0ace88e03d6b4242b8f8fe2fa300db161780d58777b57f69fa077cd482e1b673316559459bd20b8cc89a7f9f30e644bfed8293f77f0e4b + checksum: 10/c42d15aa5f0483ce361910b770cb4050e69739632ddb01436e189775df2baee6f7398f9e55633f1f1955d58c2a622a4597a093c5372eb61aafdda8a43bac2d57 languageName: node linkType: hard -"@typescript-eslint/types@npm:7.11.0": - version: 7.11.0 - resolution: "@typescript-eslint/types@npm:7.11.0" - checksum: 10/c6a0b47ef43649a59c9d51edfc61e367b55e519376209806b1c98385a8385b529e852c7a57e081fb15ef6a5dc0fc8e90bd5a508399f5ac2137f4d462e89cdc30 +"@typescript-eslint/types@npm:7.12.0": + version: 7.12.0 + resolution: "@typescript-eslint/types@npm:7.12.0" + checksum: 10/17b57ccd26278312299b27f587d7e9b34076ff37780b3973f848e4ac7bdf80d1bee7356082b54e900e0d77be8a0dda1feef1feb84843b9ec253855200cd93f36 languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:7.11.0": - version: 7.11.0 - resolution: "@typescript-eslint/typescript-estree@npm:7.11.0" +"@typescript-eslint/typescript-estree@npm:7.12.0": + version: 7.12.0 + resolution: "@typescript-eslint/typescript-estree@npm:7.12.0" dependencies: - "@typescript-eslint/types": "npm:7.11.0" - "@typescript-eslint/visitor-keys": "npm:7.11.0" + "@typescript-eslint/types": "npm:7.12.0" + "@typescript-eslint/visitor-keys": "npm:7.12.0" debug: "npm:^4.3.4" globby: "npm:^11.1.0" is-glob: "npm:^4.0.3" @@ -2102,31 +2102,31 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/b98b101e42d3b91003510a5c5a83f4350b6c1cf699bf2e409717660579ffa71682bc280c4f40166265c03f9546ed4faedc3723e143f1ab0ed7f5990cc3dff0ae + checksum: 10/45e7402e2e32782a96dbca671b4ad731b643e47c172d735e749930d1560071a1a1e2a8765396443d09bff83c69dad2fff07dc30a2ed212bff492e20aa6b2b790 languageName: node linkType: hard -"@typescript-eslint/utils@npm:7.11.0": - version: 7.11.0 - resolution: "@typescript-eslint/utils@npm:7.11.0" +"@typescript-eslint/utils@npm:7.12.0": + version: 7.12.0 + resolution: "@typescript-eslint/utils@npm:7.12.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.4.0" - "@typescript-eslint/scope-manager": "npm:7.11.0" - "@typescript-eslint/types": "npm:7.11.0" - "@typescript-eslint/typescript-estree": "npm:7.11.0" + "@typescript-eslint/scope-manager": "npm:7.12.0" + "@typescript-eslint/types": "npm:7.12.0" + "@typescript-eslint/typescript-estree": "npm:7.12.0" peerDependencies: eslint: ^8.56.0 - checksum: 10/fbef14e166a70ccc4527c0731e0338acefa28218d1a018aa3f5b6b1ad9d75c56278d5f20bda97cf77da13e0a67c4f3e579c5b2f1c2e24d676960927921b55851 + checksum: 10/b66725cef2dcc4975714ea7528fa000cebd4e0b55bb6c43d7efe9ce21a6c7af5f8b2c49f1be3a5118c26666d4b0228470105741e78430e463b72f91fa62e0adf languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:7.11.0": - version: 7.11.0 - resolution: "@typescript-eslint/visitor-keys@npm:7.11.0" +"@typescript-eslint/visitor-keys@npm:7.12.0": + version: 7.12.0 + resolution: "@typescript-eslint/visitor-keys@npm:7.12.0" dependencies: - "@typescript-eslint/types": "npm:7.11.0" + "@typescript-eslint/types": "npm:7.12.0" eslint-visitor-keys: "npm:^3.4.3" - checksum: 10/1f2cf1214638e9e78e052393c9e24295196ec4781b05951659a3997e33f8699a760ea3705c17d770e10eda2067435199e0136ab09e5fac63869e22f2da184d89 + checksum: 10/5c03bbb68f6eb775005c83042da99de87513cdf9b5549c2ac30caf2c74dc9888cebec57d9eeb0dead8f63a57771288f59605c9a4d8aeec6b87b5390ac723cbd4 languageName: node linkType: hard @@ -7292,7 +7292,7 @@ __metadata: ts-node: "npm:^10.9.2" tsconfig-paths: "npm:4.2.0" typescript: "npm:^5.4.5" - typescript-eslint: "npm:^7.11.0" + typescript-eslint: "npm:^7.12.0" viem: "npm:^2.13.1" winston: "npm:^3.13.0" zod: "npm:^3.23.8" @@ -8122,19 +8122,19 @@ __metadata: languageName: node linkType: hard -"typescript-eslint@npm:^7.11.0": - version: 7.11.0 - resolution: "typescript-eslint@npm:7.11.0" +"typescript-eslint@npm:^7.12.0": + version: 7.12.0 + resolution: "typescript-eslint@npm:7.12.0" dependencies: - "@typescript-eslint/eslint-plugin": "npm:7.11.0" - "@typescript-eslint/parser": "npm:7.11.0" - "@typescript-eslint/utils": "npm:7.11.0" + "@typescript-eslint/eslint-plugin": "npm:7.12.0" + "@typescript-eslint/parser": "npm:7.12.0" + "@typescript-eslint/utils": "npm:7.12.0" peerDependencies: eslint: ^8.56.0 peerDependenciesMeta: typescript: optional: true - checksum: 10/8c82d777a6503867b1edd873276706afc158c55012dd958b22a44255e4f7fb12591435b1086571fdbc73de3ce783fe24ec87d1cc2bd5739d9edbad0e52572cf1 + checksum: 10/a508ae2e847c463cbe6f709360be3d080f621a8396bfe59f10745520a5c931b72dc882e9b94faf065c2471d00baa57ded758acf89716afe68e749373ca46928b languageName: node linkType: hard From 019ec46a20890e45645319c0de5f05644b17df12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Tue, 4 Jun 2024 12:11:58 +0200 Subject: [PATCH 056/207] Fix @typescript-eslint/no-unsafe-argument instances (#1533) Enable @typescript-eslint/no-unsafe-argument --- eslint.config.mjs | 1 - src/__tests__/deployments.helper.ts | 14 +++-- src/config/configuration.validator.spec.ts | 42 +++++++------- .../balances-api/coingecko-api.service.ts | 4 +- .../zerion-balances-api.service.ts | 7 ++- .../cache/cache.first.data.source.ts | 9 ++- .../network/network.module.spec.ts | 3 +- .../entities/__tests__/backbone.builder.ts | 2 +- .../entities/__tests__/contract.builder.ts | 2 +- .../__tests__/data-decoded.schema.spec.ts | 6 +- src/domain/swaps/swaps.repository.e2e-spec.ts | 5 +- .../about/__tests__/get-about.e2e-spec.ts | 3 +- src/routes/alerts/alerts.controller.spec.ts | 5 +- .../alerts/guards/tenderly-signature.guard.ts | 2 +- src/routes/auth/auth.controller.spec.ts | 55 +++++++------------ .../auth/decorators/auth.decorator.spec.ts | 3 +- src/routes/auth/guards/auth.guard.spec.ts | 3 +- src/routes/auth/guards/auth.guard.ts | 2 +- .../zerion-balances.controller.spec.ts | 47 +++++++--------- .../balances/balances.controller.spec.ts | 3 +- .../__tests__/event-hooks-queue.e2e-spec.ts | 3 +- .../cache-hooks.controller.spec.ts | 3 +- src/routes/chains/chains.controller.spec.ts | 3 +- .../zerion-collectibles.controller.spec.ts | 3 +- .../collectibles.controller.spec.ts | 3 +- .../pagination.data.decorator.spec.ts | 3 +- .../decorators/pagination.data.decorator.ts | 3 +- .../common/decorators/route.url.decorator.ts | 3 +- .../filters/global-error.filter.spec.ts | 3 +- .../common/filters/zod-error.filter.spec.ts | 3 +- .../cache-control.interceptor.spec.ts | 3 +- .../route-logger.interceptor.spec.ts | 3 +- .../interceptors/route-logger.interceptor.ts | 6 +- .../community/community.controller.spec.ts | 3 +- .../__tests__/get-contract.e2e-spec.ts | 3 +- .../contracts/contracts.controller.spec.ts | 3 +- .../__tests__/data-decode.e2e-spec.ts | 3 +- .../delegates/delegates.controller.spec.ts | 3 +- .../v2/delegates.v2.controller.spec.ts | 3 +- .../email.controller.delete-email.spec.ts | 3 +- .../email/email.controller.edit-email.spec.ts | 3 +- .../email/email.controller.get-email.spec.ts | 3 +- ...ail.controller.resend-verification.spec.ts | 3 +- .../email/email.controller.save-email.spec.ts | 3 +- .../email.controller.verify-email.spec.ts | 3 +- .../estimations.controller.spec.ts | 3 +- .../health/__tests__/get-health.e2e-spec.ts | 3 +- src/routes/health/health.controller.spec.ts | 3 +- .../create-message.dto.schema.spec.ts | 3 +- .../messages/messages.controller.spec.ts | 3 +- .../notifications.controller.spec.ts | 3 +- .../__tests__/get-safes-by-owner.e2e-spec.ts | 3 +- src/routes/owners/owners.controller.spec.ts | 3 +- .../recovery/recovery.controller.spec.ts | 3 +- src/routes/relay/relay.controller.spec.ts | 3 +- src/routes/root/root.controller.spec.ts | 3 +- .../__tests__/get-safe-apps.e2e-spec.ts | 3 +- .../safe-apps/safe-apps.controller.spec.ts | 3 +- .../pipes/caip-10-addresses.pipe.spec.ts | 3 +- .../safes/safes.controller.nonces.spec.ts | 3 +- .../safes/safes.controller.overview.spec.ts | 3 +- src/routes/safes/safes.controller.spec.ts | 3 +- .../subscription.controller.spec.ts | 3 +- ...firmations.transactions.controller.spec.ts | 3 +- ...ransaction.transactions.controller.spec.ts | 3 +- ...tion-by-id.transactions.controller.spec.ts | 3 +- ...rs-by-safe.transactions.controller.spec.ts | 3 +- ...ns-by-safe.transactions.controller.spec.ts | 3 +- ...ns-by-safe.transactions.controller.spec.ts | 3 +- ...ns-by-safe.transactions.controller.spec.ts | 3 +- ...ransaction.transactions.controller.spec.ts | 3 +- ...ransaction.transactions.controller.spec.ts | 3 +- .../transactions/helpers/swap-order.helper.ts | 4 +- .../transactions-history.controller.spec.ts | 14 +++-- ....imitation-transactions.controller.spec.ts | 3 +- .../transactions-view.controller.spec.ts | 5 +- 76 files changed, 226 insertions(+), 177 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 799e101147..14f933735f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -31,7 +31,6 @@ export default tseslint.config( '@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-floating-promises': 'warn', // TODO: Address these rules: (added to update to ESLint 9) - '@typescript-eslint/no-unsafe-argument': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/no-unsafe-call': 'off', '@typescript-eslint/no-unsafe-member-access': 'off', diff --git a/src/__tests__/deployments.helper.ts b/src/__tests__/deployments.helper.ts index cff17e8dc9..ee2b359042 100644 --- a/src/__tests__/deployments.helper.ts +++ b/src/__tests__/deployments.helper.ts @@ -85,9 +85,9 @@ export function getVersionsByChainIdByDeploymentMap(): VersionsByChainIdByDeploy const deployment = JSON.parse(assetJson); // Get the alias name - const name = Object.entries(deploymentAliases).find(([, aliases]) => { - return aliases.includes(deployment.contractName); - })?.[0]; + const name = Object.entries(deploymentAliases).find(([, aliases]) => + aliases.includes(deployment.contractName as string), + )?.[0]; if (!name) { throw new Error( @@ -96,9 +96,13 @@ export function getVersionsByChainIdByDeploymentMap(): VersionsByChainIdByDeploy } // Add the version to the map - for (const chainId of Object.keys(deployment.networkAddresses)) { + for (const chainId of Object.keys( + deployment.networkAddresses as Record, + )) { versionsByDeploymentByChainId[name][chainId] ??= []; - versionsByDeploymentByChainId[name][chainId]?.push(deployment.version); + versionsByDeploymentByChainId[name][chainId]?.push( + deployment.version as string, + ); } } } diff --git a/src/config/configuration.validator.spec.ts b/src/config/configuration.validator.spec.ts index fb370e0958..e907fac951 100644 --- a/src/config/configuration.validator.spec.ts +++ b/src/config/configuration.validator.spec.ts @@ -5,7 +5,7 @@ import configurationValidator from '@/config/configuration.validator'; import { RootConfigurationSchema } from '@/config/configuration.module'; describe('Configuration validator', () => { - const validConfiguration = { + const validConfiguration: Record = { ...JSON.parse(fakeJson()), AUTH_TOKEN: faker.string.uuid(), ALERTS_PROVIDER_SIGNING_KEY: faker.string.uuid(), @@ -24,7 +24,7 @@ describe('Configuration validator', () => { it('should bypass this validation on test environment', () => { process.env.NODE_ENV = 'test'; - const expected = JSON.parse(fakeJson()); + const expected: Record = JSON.parse(fakeJson()); const validated = configurationValidator(expected, RootConfigurationSchema); expect(validated).toBe(expected); }); @@ -63,27 +63,25 @@ describe('Configuration validator', () => { it('should an invalid LOG_LEVEL configuration in production environment', () => { process.env.NODE_ENV = 'production'; + const invalidConfiguration: Record = { + ...JSON.parse(fakeJson()), + AUTH_TOKEN: faker.string.uuid(), + LOG_LEVEL: faker.word.words(), + ALERTS_PROVIDER_SIGNING_KEY: faker.string.uuid(), + ALERTS_PROVIDER_API_KEY: faker.string.uuid(), + ALERTS_PROVIDER_ACCOUNT: faker.string.alphanumeric(), + ALERTS_PROVIDER_PROJECT: faker.string.alphanumeric(), + EMAIL_API_APPLICATION_CODE: faker.string.alphanumeric(), + EMAIL_API_FROM_EMAIL: faker.internet.email(), + EMAIL_API_KEY: faker.string.uuid(), + EMAIL_TEMPLATE_RECOVERY_TX: faker.string.alphanumeric(), + EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: faker.string.alphanumeric(), + EMAIL_TEMPLATE_VERIFICATION_CODE: faker.string.alphanumeric(), + RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN: faker.string.uuid(), + RELAY_PROVIDER_API_KEY_SEPOLIA: faker.string.uuid(), + }; expect(() => - configurationValidator( - { - ...JSON.parse(fakeJson()), - AUTH_TOKEN: faker.string.uuid(), - LOG_LEVEL: faker.word.words(), - ALERTS_PROVIDER_SIGNING_KEY: faker.string.uuid(), - ALERTS_PROVIDER_API_KEY: faker.string.uuid(), - ALERTS_PROVIDER_ACCOUNT: faker.string.alphanumeric(), - ALERTS_PROVIDER_PROJECT: faker.string.alphanumeric(), - EMAIL_API_APPLICATION_CODE: faker.string.alphanumeric(), - EMAIL_API_FROM_EMAIL: faker.internet.email(), - EMAIL_API_KEY: faker.string.uuid(), - EMAIL_TEMPLATE_RECOVERY_TX: faker.string.alphanumeric(), - EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: faker.string.alphanumeric(), - EMAIL_TEMPLATE_VERIFICATION_CODE: faker.string.alphanumeric(), - RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN: faker.string.uuid(), - RELAY_PROVIDER_API_KEY_SEPOLIA: faker.string.uuid(), - }, - RootConfigurationSchema, - ), + configurationValidator(invalidConfiguration, RootConfigurationSchema), ).toThrow( /LOG_LEVEL Invalid enum value. Expected 'error' | 'warn' | 'info' | 'http' | 'verbose' | 'debug' | 'silly', received/, ); diff --git a/src/datasources/balances-api/coingecko-api.service.ts b/src/datasources/balances-api/coingecko-api.service.ts index 49c52ac66e..f20cf9ec49 100644 --- a/src/datasources/balances-api/coingecko-api.service.ts +++ b/src/datasources/balances-api/coingecko-api.service.ts @@ -246,7 +246,9 @@ export class CoingeckoApi implements IPricesApi { const { key, field } = cacheDir; if (cached != null) { this.loggingService.debug({ type: 'cache_hit', key, field }); - result.push(JSON.parse(cached)); + // TODO: build an AssetPrice validator or a type guard to ensure the cache value is valid. + const cachedAssetPrice: AssetPrice = JSON.parse(cached); + result.push(cachedAssetPrice); } else { this.loggingService.debug({ type: 'cache_miss', key, field }); } diff --git a/src/datasources/balances-api/zerion-balances-api.service.ts b/src/datasources/balances-api/zerion-balances-api.service.ts index 0e0f854106..37f60bcc04 100644 --- a/src/datasources/balances-api/zerion-balances-api.service.ts +++ b/src/datasources/balances-api/zerion-balances-api.service.ts @@ -98,7 +98,9 @@ export class ZerionBalancesApi implements IBalancesApi { if (cached != null) { const { key, field } = cacheDir; this.loggingService.debug({ type: 'cache_hit', key, field }); - return this._mapBalances(chainName, JSON.parse(cached)); + // TODO: create a ZerionBalance type with guard to avoid these type assertions. + const zerionBalances: ZerionBalance[] = JSON.parse(cached); + return this._mapBalances(chainName, zerionBalances); } try { @@ -152,7 +154,8 @@ export class ZerionBalancesApi implements IBalancesApi { if (cached != null) { const { key, field } = cacheDir; this.loggingService.debug({ type: 'cache_hit', key, field }); - const data = JSON.parse(cached); + // TODO: create a ZerionCollectibles type with guard to avoid these type assertions. + const data: ZerionCollectibles = JSON.parse(cached); return this._buildCollectiblesPage(data.links.next, data.data); } else { try { diff --git a/src/datasources/cache/cache.first.data.source.ts b/src/datasources/cache/cache.first.data.source.ts index 94845214b1..7068bc0e2a 100644 --- a/src/datasources/cache/cache.first.data.source.ts +++ b/src/datasources/cache/cache.first.data.source.ts @@ -97,11 +97,10 @@ export class CacheFirstDataSource { this.loggingService.debug({ type: 'cache_hit', key, field }); const cachedData = JSON.parse(cached); if (cachedData?.response?.status === 404) { - throw new NetworkResponseError( - cachedData.url, - cachedData.response, - cachedData?.data, - ); + // TODO: create a CachedData type with guard to avoid these type assertions. + const url: URL = cachedData.url; + const response: Response = cachedData.response; + throw new NetworkResponseError(url, response, cachedData?.data); } return cachedData; } diff --git a/src/datasources/network/network.module.spec.ts b/src/datasources/network/network.module.spec.ts index 5e2aa15be1..d134a8971b 100644 --- a/src/datasources/network/network.module.spec.ts +++ b/src/datasources/network/network.module.spec.ts @@ -15,9 +15,10 @@ import { NetworkResponseError, } from '@/datasources/network/entities/network.error.entity'; import { fakeJson } from '@/__tests__/faker'; +import { Server } from 'net'; describe('NetworkModule', () => { - let app: INestApplication; + let app: INestApplication; let fetchClient: FetchClient; let httpClientTimeout: number; diff --git a/src/domain/backbone/entities/__tests__/backbone.builder.ts b/src/domain/backbone/entities/__tests__/backbone.builder.ts index 5890e9c1f3..34e713856d 100644 --- a/src/domain/backbone/entities/__tests__/backbone.builder.ts +++ b/src/domain/backbone/entities/__tests__/backbone.builder.ts @@ -16,5 +16,5 @@ export function backboneBuilder(): IBuilder { faker.word.sample(), ), ) - .with('settings', JSON.parse(fakeJson())); + .with('settings', JSON.parse(fakeJson()) as Record); } diff --git a/src/domain/contracts/entities/__tests__/contract.builder.ts b/src/domain/contracts/entities/__tests__/contract.builder.ts index b2575f2728..9ce84cd541 100644 --- a/src/domain/contracts/entities/__tests__/contract.builder.ts +++ b/src/domain/contracts/entities/__tests__/contract.builder.ts @@ -10,6 +10,6 @@ export function contractBuilder(): IBuilder { .with('name', faker.word.sample()) .with('displayName', faker.word.words()) .with('logoUri', faker.internet.url({ appendSlash: false })) - .with('contractAbi', JSON.parse(fakeJson())) + .with('contractAbi', JSON.parse(fakeJson()) as Record) .with('trustedForDelegateCall', faker.datatype.boolean()); } diff --git a/src/domain/data-decoder/entities/schemas/__tests__/data-decoded.schema.spec.ts b/src/domain/data-decoder/entities/schemas/__tests__/data-decoded.schema.spec.ts index c03b9e3869..cd81be5628 100644 --- a/src/domain/data-decoder/entities/schemas/__tests__/data-decoded.schema.spec.ts +++ b/src/domain/data-decoder/entities/schemas/__tests__/data-decoded.schema.spec.ts @@ -38,7 +38,7 @@ describe('Data decoded schema', () => { it('should allow record valueDecoded', () => { const dataDecodedParameter = dataDecodedParameterBuilder() - .with('valueDecoded', JSON.parse(fakeJson())) + .with('valueDecoded', JSON.parse(fakeJson()) as Record) .build(); const result = DataDecodedParameterSchema.safeParse(dataDecodedParameter); @@ -48,7 +48,9 @@ describe('Data decoded schema', () => { it('should allow array valueDecoded', () => { const dataDecodedParameter = dataDecodedParameterBuilder() - .with('valueDecoded', [JSON.parse(fakeJson())]) + .with('valueDecoded', [ + JSON.parse(fakeJson()) as Record, + ]) .build(); const result = DataDecodedParameterSchema.safeParse(dataDecodedParameter); diff --git a/src/domain/swaps/swaps.repository.e2e-spec.ts b/src/domain/swaps/swaps.repository.e2e-spec.ts index b04ae3b324..9e35ca69eb 100644 --- a/src/domain/swaps/swaps.repository.e2e-spec.ts +++ b/src/domain/swaps/swaps.repository.e2e-spec.ts @@ -9,6 +9,7 @@ import { NetworkModule } from '@/datasources/network/network.module'; import { ISwapsRepository } from '@/domain/swaps/swaps.repository'; import { Order } from '@/domain/swaps/entities/order.entity'; import configuration from '@/config/entities/configuration'; +import { Server } from 'net'; const orderIds = { '1': { @@ -102,7 +103,7 @@ const orderIds = { }, }; describe('CowSwap E2E tests', () => { - let app: INestApplication; + let app: INestApplication; let repository: ISwapsRepository; beforeAll(async () => { @@ -141,7 +142,7 @@ describe('CowSwap E2E tests', () => { expect(actual).toEqual({ ...expectedObject, - fullAppData: JSON.parse(expectedObject.fullAppData), + fullAppData: JSON.parse(expectedObject.fullAppData as string), }); }); }); diff --git a/src/routes/about/__tests__/get-about.e2e-spec.ts b/src/routes/about/__tests__/get-about.e2e-spec.ts index 8b5034a20b..1eef0fc1e2 100644 --- a/src/routes/about/__tests__/get-about.e2e-spec.ts +++ b/src/routes/about/__tests__/get-about.e2e-spec.ts @@ -5,9 +5,10 @@ import { AppModule } from '@/app.module'; import { expect } from '@jest/globals'; import '@/__tests__/matchers/to-be-string-or-null'; import { CacheKeyPrefix } from '@/datasources/cache/constants'; +import { Server } from 'net'; describe('Get about e2e test', () => { - let app: INestApplication; + let app: INestApplication; beforeAll(async () => { const cacheKeyPrefix = crypto.randomUUID(); diff --git a/src/routes/alerts/alerts.controller.spec.ts b/src/routes/alerts/alerts.controller.spec.ts index e565d3b634..25e6aa2315 100644 --- a/src/routes/alerts/alerts.controller.spec.ts +++ b/src/routes/alerts/alerts.controller.spec.ts @@ -65,6 +65,7 @@ import { } from '@/datasources/jwt/configuration/jwt.configuration.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; // The `x-tenderly-signature` header contains a cryptographic signature. The webhook request signature is // a HMAC SHA256 hash of concatenated signing secret, request payload, and timestamp, in this order. @@ -98,7 +99,7 @@ describe('Alerts (Unit)', () => { .build(); describe('/alerts route enabled', () => { - let app: INestApplication; + let app: INestApplication; let signingKey: string; let networkService: jest.MockedObjectDeep; let safeConfigUrl: string | undefined; @@ -1602,7 +1603,7 @@ describe('Alerts (Unit)', () => { }); describe('/alerts route disabled', () => { - let app: INestApplication; + let app: INestApplication; beforeEach(async () => { jest.resetAllMocks(); diff --git a/src/routes/alerts/guards/tenderly-signature.guard.ts b/src/routes/alerts/guards/tenderly-signature.guard.ts index ef3b14c2d0..54b991f6cf 100644 --- a/src/routes/alerts/guards/tenderly-signature.guard.ts +++ b/src/routes/alerts/guards/tenderly-signature.guard.ts @@ -25,7 +25,7 @@ export class TenderlySignatureGuard implements CanActivate { } canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest(); + const request: Request = context.switchToHttp().getRequest(); const signature = this.getSignature(request.headers); const digest = this.getDigest(request); diff --git a/src/routes/auth/auth.controller.spec.ts b/src/routes/auth/auth.controller.spec.ts index 07d70bb137..8058e635da 100644 --- a/src/routes/auth/auth.controller.spec.ts +++ b/src/routes/auth/auth.controller.spec.ts @@ -29,11 +29,12 @@ import { import jwtConfiguration from '@/datasources/jwt/configuration/__tests__/jwt.configuration'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; const MAX_VALIDITY_PERIOD_IN_MS = 15 * 60 * 1_000; // 15 minutes describe('AuthController', () => { - let app: INestApplication; + let app: INestApplication; let cacheService: FakeCacheService; beforeEach(async () => { @@ -106,17 +107,15 @@ describe('AuthController', () => { const nonceResponse = await request(app.getHttpServer()).get( '/v1/auth/nonce', ); - const cacheDir = new CacheDir( - `auth_nonce_${nonceResponse.body.nonce}`, - '', - ); + const nonce: string = nonceResponse.body.nonce; + const cacheDir = new CacheDir(`auth_nonce_${nonce}`, ''); const expirationTime = faker.date.between({ from: new Date(), to: new Date(Date.now() + MAX_VALIDITY_PERIOD_IN_MS), }); const message = siweMessageBuilder() .with('address', signer.address) - .with('nonce', nonceResponse.body.nonce) + .with('nonce', nonce) .with('expirationTime', expirationTime.toISOString()) .build(); const signature = await signer.signMessage({ @@ -156,25 +155,21 @@ describe('AuthController', () => { const nonceResponse = await request(app.getHttpServer()).get( '/v1/auth/nonce', ); - const cacheDir = new CacheDir( - `auth_nonce_${nonceResponse.body.nonce}`, - '', - ); + const nonce: string = nonceResponse.body.nonce; + const cacheDir = new CacheDir(`auth_nonce_${nonce}`, ''); const expirationTime = faker.date.future({ refDate: new Date(Date.now() + MAX_VALIDITY_PERIOD_IN_MS), }); const message = siweMessageBuilder() .with('address', signer.address) - .with('nonce', nonceResponse.body.nonce) + .with('nonce', nonce) .with('expirationTime', expirationTime.toISOString()) .build(); const signature = await signer.signMessage({ message: toSignableSiweMessage(message), }); - await expect(cacheService.get(cacheDir)).resolves.toBe( - nonceResponse.body.nonce, - ); + await expect(cacheService.get(cacheDir)).resolves.toBe(nonce); await request(app.getHttpServer()) .post('/v1/auth/verify') .send({ @@ -240,17 +235,15 @@ describe('AuthController', () => { const nonceResponse = await request(app.getHttpServer()).get( '/v1/auth/nonce', ); - const cacheDir = new CacheDir( - `auth_nonce_${nonceResponse.body.nonce}`, - '', - ); + const nonce: string = nonceResponse.body.nonce; + const cacheDir = new CacheDir(`auth_nonce_${nonce}`, ''); const expirationTime = faker.date.between({ from: new Date(), to: new Date(Date.now() + MAX_VALIDITY_PERIOD_IN_MS), }); const message = siweMessageBuilder() .with('address', signer.address) - .with('nonce', nonceResponse.body.nonce) + .with('nonce', nonce) .with('expirationTime', expirationTime.toISOString()) .build(); const signature = await signer.signMessage({ @@ -291,19 +284,15 @@ describe('AuthController', () => { from: new Date(), to: new Date(Date.now() + MAX_VALIDITY_PERIOD_IN_MS), }); + const nonce: string = nonceResponse.body.nonce; const message = siweMessageBuilder() - .with('nonce', nonceResponse.body.nonce) + .with('nonce', nonce) .with('expirationTime', expirationTime.toISOString()) .build(); - const cacheDir = new CacheDir( - `auth_nonce_${nonceResponse.body.nonce}`, - '', - ); + const cacheDir = new CacheDir(`auth_nonce_${nonce}`, ''); const signature = faker.string.hexadecimal(); - await expect(cacheService.get(cacheDir)).resolves.toBe( - nonceResponse.body.nonce, - ); + await expect(cacheService.get(cacheDir)).resolves.toBe(nonce); await request(app.getHttpServer()) .post('/v1/auth/verify') .send({ @@ -329,23 +318,19 @@ describe('AuthController', () => { const nonceResponse = await request(app.getHttpServer()).get( '/v1/auth/nonce', ); - const cacheDir = new CacheDir( - `auth_nonce_${nonceResponse.body.nonce}`, - '', - ); + const nonce: string = nonceResponse.body.nonce; + const cacheDir = new CacheDir(`auth_nonce_${nonce}`, ''); const expirationTime = faker.date.past(); const message = siweMessageBuilder() .with('address', signer.address) - .with('nonce', nonceResponse.body.nonce) + .with('nonce', nonce) .with('expirationTime', expirationTime.toISOString()) .build(); const signature = await signer.signMessage({ message: toSignableSiweMessage(message), }); - await expect(cacheService.get(cacheDir)).resolves.toBe( - nonceResponse.body.nonce, - ); + await expect(cacheService.get(cacheDir)).resolves.toBe(nonce); await request(app.getHttpServer()) .post('/v1/auth/verify') .send({ diff --git a/src/routes/auth/decorators/auth.decorator.spec.ts b/src/routes/auth/decorators/auth.decorator.spec.ts index e46e77997e..f283651f72 100644 --- a/src/routes/auth/decorators/auth.decorator.spec.ts +++ b/src/routes/auth/decorators/auth.decorator.spec.ts @@ -21,10 +21,11 @@ import { UseGuards, } from '@nestjs/common'; import { TestingModule, Test } from '@nestjs/testing'; +import { Server } from 'net'; import * as request from 'supertest'; describe('Auth decorator', () => { - let app: INestApplication; + let app: INestApplication; let jwtService: IJwtService; let authPayloadFromDecoractor: AuthPayloadDto; diff --git a/src/routes/auth/guards/auth.guard.spec.ts b/src/routes/auth/guards/auth.guard.spec.ts index 8cc297308c..17a88ffa93 100644 --- a/src/routes/auth/guards/auth.guard.spec.ts +++ b/src/routes/auth/guards/auth.guard.spec.ts @@ -18,6 +18,7 @@ import { JwtConfigurationModule, } from '@/datasources/jwt/configuration/jwt.configuration.module'; import jwtConfiguration from '@/datasources/jwt/configuration/__tests__/jwt.configuration'; +import { Server } from 'net'; @Controller() class TestController { @@ -29,7 +30,7 @@ class TestController { } describe('AuthGuard', () => { - let app: INestApplication; + let app: INestApplication; let jwtService: IJwtService; beforeEach(async () => { diff --git a/src/routes/auth/guards/auth.guard.ts b/src/routes/auth/guards/auth.guard.ts index 0d5a342956..4acf28186c 100644 --- a/src/routes/auth/guards/auth.guard.ts +++ b/src/routes/auth/guards/auth.guard.ts @@ -39,7 +39,7 @@ export class AuthGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const request: Request = context.switchToHttp().getRequest(); - const accessToken = + const accessToken: string | undefined = request.cookies[AuthController.ACCESS_TOKEN_COOKIE_NAME]; // No token in the request diff --git a/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts b/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts index 07a15007c9..c294df657f 100644 --- a/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts +++ b/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts @@ -33,9 +33,10 @@ import { import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('Balances Controller (Unit)', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; let zerionBaseUri: string; @@ -100,11 +101,9 @@ describe('Balances Controller (Unit)', () => { const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); const safeAddress = getAddress(faker.finance.ethereumAddress()); const currency = faker.finance.currencyCode(); - const chainName = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.zerion.chains.${chain.chainId}.chainName`, - ); + const chainName = configurationService.getOrThrow( + `balances.providers.zerion.chains.${chain.chainId}.chainName`, + ); const nativeCoinFungibleInfo = zerionFungibleInfoBuilder() .with('implementations', [ zerionImplementationBuilder().build(), @@ -165,9 +164,9 @@ describe('Balances Controller (Unit)', () => { .build(), ]) .build(); - const apiKey = app - .get(IConfigurationService) - .getOrThrow(`balances.providers.zerion.apiKey`); + const apiKey = configurationService.getOrThrow( + `balances.providers.zerion.apiKey`, + ); networkService.get.mockImplementation(({ url }) => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: @@ -248,11 +247,9 @@ describe('Balances Controller (Unit)', () => { const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); const safeAddress = getAddress(faker.finance.ethereumAddress()); const currency = faker.finance.currencyCode(); - const chainName = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.zerion.chains.${chain.chainId}.chainName`, - ); + const chainName = configurationService.getOrThrow( + `balances.providers.zerion.chains.${chain.chainId}.chainName`, + ); const nativeCoinFungibleInfo = zerionFungibleInfoBuilder() .with('implementations', [ zerionImplementationBuilder().build(), @@ -314,9 +311,9 @@ describe('Balances Controller (Unit)', () => { .build(), ]) .build(); - const apiKey = app - .get(IConfigurationService) - .getOrThrow(`balances.providers.zerion.apiKey`); + const apiKey = configurationService.getOrThrow( + `balances.providers.zerion.apiKey`, + ); networkService.get.mockImplementation(({ url }) => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: @@ -466,11 +463,9 @@ describe('Balances Controller (Unit)', () => { const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); const safeAddress = getAddress(faker.finance.ethereumAddress()); const currency = faker.finance.currencyCode(); - const chainName = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.zerion.chains.${chain.chainId}.chainName`, - ); + const chainName = configurationService.getOrThrow( + `balances.providers.zerion.chains.${chain.chainId}.chainName`, + ); const nativeCoinFungibleInfo = zerionFungibleInfoBuilder() .with('implementations', [ zerionImplementationBuilder() @@ -536,11 +531,9 @@ describe('Balances Controller (Unit)', () => { it('triggers a rate-limit error', async () => { const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); const safeAddress = getAddress(faker.finance.ethereumAddress()); - const chainName = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.zerion.chains.${chain.chainId}.chainName`, - ); + const chainName = configurationService.getOrThrow( + `balances.providers.zerion.chains.${chain.chainId}.chainName`, + ); const nativeCoinFungibleInfo = zerionFungibleInfoBuilder() .with('implementations', [ zerionImplementationBuilder() diff --git a/src/routes/balances/balances.controller.spec.ts b/src/routes/balances/balances.controller.spec.ts index c629685b60..abb23feda7 100644 --- a/src/routes/balances/balances.controller.spec.ts +++ b/src/routes/balances/balances.controller.spec.ts @@ -26,9 +26,10 @@ import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/tes import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('Balances Controller (Unit)', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let pricesProviderUrl: string; let networkService: jest.MockedObjectDeep; diff --git a/src/routes/cache-hooks/__tests__/event-hooks-queue.e2e-spec.ts b/src/routes/cache-hooks/__tests__/event-hooks-queue.e2e-spec.ts index 1205dea247..ad7516b23d 100644 --- a/src/routes/cache-hooks/__tests__/event-hooks-queue.e2e-spec.ts +++ b/src/routes/cache-hooks/__tests__/event-hooks-queue.e2e-spec.ts @@ -12,9 +12,10 @@ import { Test } from '@nestjs/testing'; import { ChannelWrapper } from 'amqp-connection-manager'; import { RedisClientType } from 'redis'; import { getAddress } from 'viem'; +import { Server } from 'net'; describe('Events queue processing e2e tests', () => { - let app: INestApplication; + let app: INestApplication; let redisClient: RedisClientType; let channel: ChannelWrapper; let queueName: string; diff --git a/src/routes/cache-hooks/cache-hooks.controller.spec.ts b/src/routes/cache-hooks/cache-hooks.controller.spec.ts index ba61039d75..9467befa0d 100644 --- a/src/routes/cache-hooks/cache-hooks.controller.spec.ts +++ b/src/routes/cache-hooks/cache-hooks.controller.spec.ts @@ -24,9 +24,10 @@ import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/tes import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('Post Hook Events (Unit)', () => { - let app: INestApplication; + let app: INestApplication; let authToken: string; let safeConfigUrl: string; let fakeCacheService: FakeCacheService; diff --git a/src/routes/chains/chains.controller.spec.ts b/src/routes/chains/chains.controller.spec.ts index 734605dce7..626003cbae 100644 --- a/src/routes/chains/chains.controller.spec.ts +++ b/src/routes/chains/chains.controller.spec.ts @@ -31,9 +31,10 @@ import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/tes import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('Chains Controller (Unit)', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let name: string; diff --git a/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts b/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts index cd23536155..4d1d46d0a0 100644 --- a/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts +++ b/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts @@ -28,9 +28,10 @@ import { import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('Zerion Collectibles Controller', () => { - let app: INestApplication; + let app: INestApplication; let networkService: jest.MockedObjectDeep; let zerionBaseUri: string; let zerionChainIds: string[]; diff --git a/src/routes/collectibles/collectibles.controller.spec.ts b/src/routes/collectibles/collectibles.controller.spec.ts index 6df799914d..0965388068 100644 --- a/src/routes/collectibles/collectibles.controller.spec.ts +++ b/src/routes/collectibles/collectibles.controller.spec.ts @@ -32,10 +32,11 @@ import { AccountDataSourceModule } from '@/datasources/account/account.datasourc import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; import { getAddress } from 'viem'; describe('Collectibles Controller (Unit)', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; diff --git a/src/routes/common/decorators/pagination.data.decorator.spec.ts b/src/routes/common/decorators/pagination.data.decorator.spec.ts index 2cbedbf7c5..a9da9c9caa 100644 --- a/src/routes/common/decorators/pagination.data.decorator.spec.ts +++ b/src/routes/common/decorators/pagination.data.decorator.spec.ts @@ -3,9 +3,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import * as request from 'supertest'; import { PaginationDataDecorator } from '@/routes/common/decorators/pagination.data.decorator'; import { PaginationData } from '@/routes/common/pagination/pagination.data'; +import { Server } from 'net'; describe('PaginationDataDecorator', () => { - let app: INestApplication; + let app: INestApplication; let paginationData: PaginationData; @Controller() diff --git a/src/routes/common/decorators/pagination.data.decorator.ts b/src/routes/common/decorators/pagination.data.decorator.ts index 5f0d227a99..39c5d652e5 100644 --- a/src/routes/common/decorators/pagination.data.decorator.ts +++ b/src/routes/common/decorators/pagination.data.decorator.ts @@ -1,6 +1,7 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { getRouteUrl } from '@/routes/common/decorators/utils'; import { PaginationData } from '@/routes/common/pagination/pagination.data'; +import { Request } from 'express'; /** * Route decorator which parses {@link PaginationData} from a @@ -10,7 +11,7 @@ import { PaginationData } from '@/routes/common/pagination/pagination.data'; */ export const PaginationDataDecorator = createParamDecorator( (data: unknown, ctx: ExecutionContext): PaginationData => { - const request = ctx.switchToHttp().getRequest(); + const request: Request = ctx.switchToHttp().getRequest(); return PaginationData.fromCursor(getRouteUrl(request)); }, ); diff --git a/src/routes/common/decorators/route.url.decorator.ts b/src/routes/common/decorators/route.url.decorator.ts index 70d0f09611..c8c7e186a1 100644 --- a/src/routes/common/decorators/route.url.decorator.ts +++ b/src/routes/common/decorators/route.url.decorator.ts @@ -1,5 +1,6 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { getRouteUrl } from '@/routes/common/decorators/utils'; +import { Request } from 'express'; /** * Route decorator which extracts the resulting @@ -7,7 +8,7 @@ import { getRouteUrl } from '@/routes/common/decorators/utils'; */ export const RouteUrlDecorator = createParamDecorator( (data: unknown, ctx: ExecutionContext): URL => { - const request = ctx.switchToHttp().getRequest(); + const request: Request = ctx.switchToHttp().getRequest(); return getRouteUrl(request); }, ); diff --git a/src/routes/common/filters/global-error.filter.spec.ts b/src/routes/common/filters/global-error.filter.spec.ts index 253deef761..0da6b28fb7 100644 --- a/src/routes/common/filters/global-error.filter.spec.ts +++ b/src/routes/common/filters/global-error.filter.spec.ts @@ -13,6 +13,7 @@ import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; import { ConfigurationModule } from '@/config/configuration.module'; import configuration from '@/config/entities/__tests__/configuration'; import { GlobalErrorFilter } from '@/routes/common/filters/global-error.filter'; +import { Server } from 'net'; @Controller({}) class TestController { @@ -31,7 +32,7 @@ class TestController { } describe('GlobalErrorFilter tests', () => { - let app: INestApplication; + let app: INestApplication; beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [TestLoggingModule, ConfigurationModule.register(configuration)], diff --git a/src/routes/common/filters/zod-error.filter.spec.ts b/src/routes/common/filters/zod-error.filter.spec.ts index 6d9fbccf7c..2ee83d4a4b 100644 --- a/src/routes/common/filters/zod-error.filter.spec.ts +++ b/src/routes/common/filters/zod-error.filter.spec.ts @@ -10,6 +10,7 @@ import { ZodErrorFilter } from '@/routes/common/filters/zod-error.filter'; import { z } from 'zod'; import { ValidationPipe } from '@/validation/pipes/validation.pipe'; import { faker } from '@faker-js/faker'; +import { Server } from 'net'; const ZodSchema = z.object({ value: z.string(), @@ -61,7 +62,7 @@ class TestController { } describe('ZodErrorFilter tests', () => { - let app: INestApplication; + let app: INestApplication; beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ diff --git a/src/routes/common/interceptors/cache-control.interceptor.spec.ts b/src/routes/common/interceptors/cache-control.interceptor.spec.ts index 09cdfa3ce3..c9d704fd64 100644 --- a/src/routes/common/interceptors/cache-control.interceptor.spec.ts +++ b/src/routes/common/interceptors/cache-control.interceptor.spec.ts @@ -7,6 +7,7 @@ import { import { CacheControlInterceptor } from '@/routes/common/interceptors/cache-control.interceptor'; import { Test } from '@nestjs/testing'; import * as request from 'supertest'; +import { Server } from 'net'; @Controller() @UseInterceptors(CacheControlInterceptor) @@ -18,7 +19,7 @@ class TestController { } describe('CacheControlInterceptor tests', () => { - let app: INestApplication; + let app: INestApplication; beforeEach(async () => { const module = await Test.createTestingModule({ diff --git a/src/routes/common/interceptors/route-logger.interceptor.spec.ts b/src/routes/common/interceptors/route-logger.interceptor.spec.ts index 7911b2a6f9..4d2e02bd5f 100644 --- a/src/routes/common/interceptors/route-logger.interceptor.spec.ts +++ b/src/routes/common/interceptors/route-logger.interceptor.spec.ts @@ -11,6 +11,7 @@ import * as request from 'supertest'; import { DataSourceError } from '@/domain/errors/data-source.error'; import { faker } from '@faker-js/faker'; import { RouteLoggerInterceptor } from '@/routes/common/interceptors/route-logger.interceptor'; +import { Server } from 'net'; const mockLoggingService: jest.MockedObjectDeep = { info: jest.fn(), @@ -56,7 +57,7 @@ class TestController { } describe('RouteLoggerInterceptor tests', () => { - let app: INestApplication; + let app: INestApplication; beforeEach(async () => { jest.resetAllMocks(); diff --git a/src/routes/common/interceptors/route-logger.interceptor.ts b/src/routes/common/interceptors/route-logger.interceptor.ts index 8674cfc3e2..0d3d659037 100644 --- a/src/routes/common/interceptors/route-logger.interceptor.ts +++ b/src/routes/common/interceptors/route-logger.interceptor.ts @@ -35,12 +35,12 @@ export class RouteLoggerInterceptor implements NestInterceptor { const startTimeMs: number = performance.now(); const httpContext = context.switchToHttp(); - const request = httpContext.getRequest(); - const response = httpContext.getResponse(); + const request: Request = httpContext.getRequest(); + const response: Response = httpContext.getResponse(); return next.handle().pipe( tap({ - error: (e) => this.onError(request, e, startTimeMs), + error: (e: Error) => this.onError(request, e, startTimeMs), complete: () => this.onComplete(request, response, startTimeMs), }), ); diff --git a/src/routes/community/community.controller.spec.ts b/src/routes/community/community.controller.spec.ts index f8e07ee1c6..8c171f5e55 100644 --- a/src/routes/community/community.controller.spec.ts +++ b/src/routes/community/community.controller.spec.ts @@ -39,9 +39,10 @@ import { import { Campaign } from '@/domain/community/entities/campaign.entity'; import { CampaignRank } from '@/domain/community/entities/campaign-rank.entity'; import { campaignRankBuilder } from '@/domain/community/entities/__tests__/campaign-rank.builder'; +import { Server } from 'net'; describe('Community (Unit)', () => { - let app: INestApplication; + let app: INestApplication; let lockingBaseUri: string; let networkService: jest.MockedObjectDeep; diff --git a/src/routes/contracts/__tests__/get-contract.e2e-spec.ts b/src/routes/contracts/__tests__/get-contract.e2e-spec.ts index cdc843d5ab..591b37fc9d 100644 --- a/src/routes/contracts/__tests__/get-contract.e2e-spec.ts +++ b/src/routes/contracts/__tests__/get-contract.e2e-spec.ts @@ -6,9 +6,10 @@ import { AppModule } from '@/app.module'; import { redisClientFactory } from '@/__tests__/redis-client.factory'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { CacheKeyPrefix } from '@/datasources/cache/constants'; +import { Server } from 'net'; describe('Get contract e2e test', () => { - let app: INestApplication; + let app: INestApplication; let redisClient: RedisClientType; const chainId = '1'; // Mainnet const cacheKeyPrefix = crypto.randomUUID(); diff --git a/src/routes/contracts/contracts.controller.spec.ts b/src/routes/contracts/contracts.controller.spec.ts index 3e609b3ab6..d6c8b33a99 100644 --- a/src/routes/contracts/contracts.controller.spec.ts +++ b/src/routes/contracts/contracts.controller.spec.ts @@ -22,9 +22,10 @@ import { AccountDataSourceModule } from '@/datasources/account/account.datasourc import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('Contracts controller', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; diff --git a/src/routes/data-decode/__tests__/data-decode.e2e-spec.ts b/src/routes/data-decode/__tests__/data-decode.e2e-spec.ts index efde9178e2..09c11c989d 100644 --- a/src/routes/data-decode/__tests__/data-decode.e2e-spec.ts +++ b/src/routes/data-decode/__tests__/data-decode.e2e-spec.ts @@ -7,9 +7,10 @@ import { TestAppProvider } from '@/__tests__/test-app.provider'; import { DataDecoded } from '@/domain/data-decoder/entities/data-decoded.entity'; import { transactionDataDtoBuilder } from '@/routes/data-decode/entities/__tests__/transaction-data.dto.builder'; import { CacheKeyPrefix } from '@/datasources/cache/constants'; +import { Server } from 'net'; describe('Data decode e2e tests', () => { - let app: INestApplication; + let app: INestApplication; const chainId = '1'; // Mainnet beforeAll(async () => { diff --git a/src/routes/delegates/delegates.controller.spec.ts b/src/routes/delegates/delegates.controller.spec.ts index 117ca398c5..358a43fa83 100644 --- a/src/routes/delegates/delegates.controller.spec.ts +++ b/src/routes/delegates/delegates.controller.spec.ts @@ -29,9 +29,10 @@ import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/tes import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('Delegates controller', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; diff --git a/src/routes/delegates/v2/delegates.v2.controller.spec.ts b/src/routes/delegates/v2/delegates.v2.controller.spec.ts index 19cb20e43c..beaed69941 100644 --- a/src/routes/delegates/v2/delegates.v2.controller.spec.ts +++ b/src/routes/delegates/v2/delegates.v2.controller.spec.ts @@ -26,11 +26,12 @@ import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { omit } from 'lodash'; +import { Server } from 'net'; import * as request from 'supertest'; import { getAddress } from 'viem'; describe('Delegates controller', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; diff --git a/src/routes/email/email.controller.delete-email.spec.ts b/src/routes/email/email.controller.delete-email.spec.ts index 295e24e431..ea458ebf16 100644 --- a/src/routes/email/email.controller.delete-email.spec.ts +++ b/src/routes/email/email.controller.delete-email.spec.ts @@ -38,9 +38,10 @@ import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; import { getSecondsUntil } from '@/domain/common/utils/time'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('Email controller delete email tests', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let accountDataSource: jest.MockedObjectDeep; let emailApi: jest.MockedObjectDeep; diff --git a/src/routes/email/email.controller.edit-email.spec.ts b/src/routes/email/email.controller.edit-email.spec.ts index 8515e4959e..2d6e13a4c0 100644 --- a/src/routes/email/email.controller.edit-email.spec.ts +++ b/src/routes/email/email.controller.edit-email.spec.ts @@ -39,11 +39,12 @@ import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; import { getSecondsUntil } from '@/domain/common/utils/time'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; const verificationCodeTtlMs = 100; describe('Email controller edit email tests', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let accountDataSource: jest.MockedObjectDeep; let networkService: jest.MockedObjectDeep; diff --git a/src/routes/email/email.controller.get-email.spec.ts b/src/routes/email/email.controller.get-email.spec.ts index 50391efebd..5d40f49af1 100644 --- a/src/routes/email/email.controller.get-email.spec.ts +++ b/src/routes/email/email.controller.get-email.spec.ts @@ -30,9 +30,10 @@ import { } from '@/datasources/jwt/configuration/jwt.configuration.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('Email controller get email tests', () => { - let app: INestApplication; + let app: INestApplication; let accountDataSource: jest.MockedObjectDeep; let jwtService: IJwtService; diff --git a/src/routes/email/email.controller.resend-verification.spec.ts b/src/routes/email/email.controller.resend-verification.spec.ts index 3a2d628407..f70ea23ab9 100644 --- a/src/routes/email/email.controller.resend-verification.spec.ts +++ b/src/routes/email/email.controller.resend-verification.spec.ts @@ -25,11 +25,12 @@ import { } from '@/datasources/jwt/configuration/jwt.configuration.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; const resendLockWindowMs = 100; const ttlMs = 1000; describe('Email controller resend verification tests', () => { - let app: INestApplication; + let app: INestApplication; let accountDataSource: jest.MockedObjectDeep; beforeEach(async () => { diff --git a/src/routes/email/email.controller.save-email.spec.ts b/src/routes/email/email.controller.save-email.spec.ts index 247d95bb98..c6510be17e 100644 --- a/src/routes/email/email.controller.save-email.spec.ts +++ b/src/routes/email/email.controller.save-email.spec.ts @@ -39,9 +39,10 @@ import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; import { getSecondsUntil } from '@/domain/common/utils/time'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('Email controller save email tests', () => { - let app: INestApplication; + let app: INestApplication; let configurationService: jest.MockedObjectDeep; let emailApi: jest.MockedObjectDeep; let accountDataSource: jest.MockedObjectDeep; diff --git a/src/routes/email/email.controller.verify-email.spec.ts b/src/routes/email/email.controller.verify-email.spec.ts index ec05b5d455..d80981e721 100644 --- a/src/routes/email/email.controller.verify-email.spec.ts +++ b/src/routes/email/email.controller.verify-email.spec.ts @@ -24,12 +24,13 @@ import { } from '@/datasources/jwt/configuration/jwt.configuration.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; const resendLockWindowMs = 100; const ttlMs = 1000; describe('Email controller verify email tests', () => { - let app: INestApplication; + let app: INestApplication; let accountDataSource: jest.MockedObjectDeep; beforeEach(async () => { diff --git a/src/routes/estimations/estimations.controller.spec.ts b/src/routes/estimations/estimations.controller.spec.ts index 6802f84d1e..9769db229f 100644 --- a/src/routes/estimations/estimations.controller.spec.ts +++ b/src/routes/estimations/estimations.controller.spec.ts @@ -29,10 +29,11 @@ import { AccountDataSourceModule } from '@/datasources/account/account.datasourc import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; import { getAddress } from 'viem'; describe('Estimations Controller (Unit)', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; diff --git a/src/routes/health/__tests__/get-health.e2e-spec.ts b/src/routes/health/__tests__/get-health.e2e-spec.ts index 2116b53dbc..e807332c7f 100644 --- a/src/routes/health/__tests__/get-health.e2e-spec.ts +++ b/src/routes/health/__tests__/get-health.e2e-spec.ts @@ -4,9 +4,10 @@ import * as request from 'supertest'; import { AppModule } from '@/app.module'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { CacheKeyPrefix } from '@/datasources/cache/constants'; +import { Server } from 'net'; describe('Get health e2e test', () => { - let app: INestApplication; + let app: INestApplication; beforeAll(async () => { const cacheKeyPrefix = crypto.randomUUID(); diff --git a/src/routes/health/health.controller.spec.ts b/src/routes/health/health.controller.spec.ts index 7c98a8d935..4d221ba7f2 100644 --- a/src/routes/health/health.controller.spec.ts +++ b/src/routes/health/health.controller.spec.ts @@ -20,9 +20,10 @@ import { IQueueReadiness, QueueReadiness, } from '@/domain/interfaces/queue-readiness.interface'; +import { Server } from 'net'; describe('Health Controller tests', () => { - let app: INestApplication; + let app: INestApplication; let cacheService: FakeCacheService; let queuesApi: jest.MockedObjectDeep; diff --git a/src/routes/messages/entities/schemas/__tests__/create-message.dto.schema.spec.ts b/src/routes/messages/entities/schemas/__tests__/create-message.dto.schema.spec.ts index 2a8deefd4b..70e6669000 100644 --- a/src/routes/messages/entities/schemas/__tests__/create-message.dto.schema.spec.ts +++ b/src/routes/messages/entities/schemas/__tests__/create-message.dto.schema.spec.ts @@ -7,8 +7,9 @@ import { ZodError } from 'zod'; describe('CreateMessageDtoSchema', () => { describe('message', () => { it('should validate a valid record message', () => { + const message = JSON.parse(fakeJson()) as Record; const createMessageDto = createMessageDtoBuilder() - .with('message', JSON.parse(fakeJson())) + .with('message', message) .build(); const result = CreateMessageDtoSchema.safeParse(createMessageDto); diff --git a/src/routes/messages/messages.controller.spec.ts b/src/routes/messages/messages.controller.spec.ts index 77799247b1..5641a7e126 100644 --- a/src/routes/messages/messages.controller.spec.ts +++ b/src/routes/messages/messages.controller.spec.ts @@ -34,9 +34,10 @@ import { AccountDataSourceModule } from '@/datasources/account/account.datasourc import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('Messages controller', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; diff --git a/src/routes/notifications/notifications.controller.spec.ts b/src/routes/notifications/notifications.controller.spec.ts index c57d4996fa..f242b5f917 100644 --- a/src/routes/notifications/notifications.controller.spec.ts +++ b/src/routes/notifications/notifications.controller.spec.ts @@ -25,10 +25,11 @@ import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/tes import { RegisterDeviceDto } from '@/routes/notifications/entities/register-device.dto.entity'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; import { getAddress } from 'viem'; describe('Notifications Controller (Unit)', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; diff --git a/src/routes/owners/__tests__/get-safes-by-owner.e2e-spec.ts b/src/routes/owners/__tests__/get-safes-by-owner.e2e-spec.ts index 40d901131d..3b89593474 100644 --- a/src/routes/owners/__tests__/get-safes-by-owner.e2e-spec.ts +++ b/src/routes/owners/__tests__/get-safes-by-owner.e2e-spec.ts @@ -5,9 +5,10 @@ import * as request from 'supertest'; import { AppModule } from '@/app.module'; import { redisClientFactory } from '@/__tests__/redis-client.factory'; import { CacheKeyPrefix } from '@/datasources/cache/constants'; +import { Server } from 'net'; describe('Get safes by owner e2e test', () => { - let app: INestApplication; + let app: INestApplication; let redisClient: RedisClientType; const chainId = '1'; // Mainnet const cacheKeyPrefix = crypto.randomUUID(); diff --git a/src/routes/owners/owners.controller.spec.ts b/src/routes/owners/owners.controller.spec.ts index 54dc082f5e..e4428a0ffb 100644 --- a/src/routes/owners/owners.controller.spec.ts +++ b/src/routes/owners/owners.controller.spec.ts @@ -24,9 +24,10 @@ import { getAddress } from 'viem'; import { pageBuilder } from '@/domain/entities/__tests__/page.builder'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('Owners Controller (Unit)', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; diff --git a/src/routes/recovery/recovery.controller.spec.ts b/src/routes/recovery/recovery.controller.spec.ts index 60bd648d3d..afdfc2652c 100644 --- a/src/routes/recovery/recovery.controller.spec.ts +++ b/src/routes/recovery/recovery.controller.spec.ts @@ -44,9 +44,10 @@ import { authPayloadDtoBuilder } from '@/domain/auth/entities/__tests__/auth-pay import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; import { getSecondsUntil } from '@/domain/common/utils/time'; import { getAddress } from 'viem'; +import { Server } from 'net'; describe('Recovery (Unit)', () => { - let app: INestApplication; + let app: INestApplication; let alertsUrl: string; let alertsAccount: string; let alertsProject: string; diff --git a/src/routes/relay/relay.controller.spec.ts b/src/routes/relay/relay.controller.spec.ts index 22deb8adfc..84a7e36119 100644 --- a/src/routes/relay/relay.controller.spec.ts +++ b/src/routes/relay/relay.controller.spec.ts @@ -53,6 +53,7 @@ import { createProxyWithNonceEncoder } from '@/domain/relay/contracts/__tests__/ import { getDeploymentVersionsByChainIds } from '@/__tests__/deployments.helper'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; const supportedChainIds = Object.keys(configuration().relay.apiKey); @@ -78,7 +79,7 @@ const PROXY_FACTORY_VERSIONS = getDeploymentVersionsByChainIds( ); describe('Relay controller', () => { - let app: INestApplication; + let app: INestApplication; let configurationService: jest.MockedObjectDeep; let networkService: jest.MockedObjectDeep; let safeConfigUrl: string; diff --git a/src/routes/root/root.controller.spec.ts b/src/routes/root/root.controller.spec.ts index 11d189cb03..9054efb9f4 100644 --- a/src/routes/root/root.controller.spec.ts +++ b/src/routes/root/root.controller.spec.ts @@ -10,9 +10,10 @@ import { AccountDataSourceModule } from '@/datasources/account/account.datasourc import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('Root Controller tests', () => { - let app: INestApplication; + let app: INestApplication; beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ diff --git a/src/routes/safe-apps/__tests__/get-safe-apps.e2e-spec.ts b/src/routes/safe-apps/__tests__/get-safe-apps.e2e-spec.ts index e4fa894da6..797d50c697 100644 --- a/src/routes/safe-apps/__tests__/get-safe-apps.e2e-spec.ts +++ b/src/routes/safe-apps/__tests__/get-safe-apps.e2e-spec.ts @@ -7,9 +7,10 @@ import { TestAppProvider } from '@/__tests__/test-app.provider'; import { redisClientFactory } from '@/__tests__/redis-client.factory'; import { CacheKeyPrefix } from '@/datasources/cache/constants'; import { SafeApp } from '@/routes/safe-apps/entities/safe-app.entity'; +import { Server } from 'net'; describe('Get Safe Apps e2e test', () => { - let app: INestApplication; + let app: INestApplication; let redisClient: RedisClientType; const chainId = '1'; // Mainnet const cacheKeyPrefix = crypto.randomUUID(); diff --git a/src/routes/safe-apps/safe-apps.controller.spec.ts b/src/routes/safe-apps/safe-apps.controller.spec.ts index 6d02894ca6..ca079189ed 100644 --- a/src/routes/safe-apps/safe-apps.controller.spec.ts +++ b/src/routes/safe-apps/safe-apps.controller.spec.ts @@ -24,9 +24,10 @@ import { AccountDataSourceModule } from '@/datasources/account/account.datasourc import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('Safe Apps Controller (Unit)', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; diff --git a/src/routes/safes/pipes/caip-10-addresses.pipe.spec.ts b/src/routes/safes/pipes/caip-10-addresses.pipe.spec.ts index 352c7ced88..90acdd1720 100644 --- a/src/routes/safes/pipes/caip-10-addresses.pipe.spec.ts +++ b/src/routes/safes/pipes/caip-10-addresses.pipe.spec.ts @@ -6,6 +6,7 @@ import { Caip10AddressesPipe } from '@/routes/safes/pipes/caip-10-addresses.pipe import { faker } from '@faker-js/faker'; import { Controller, Get, INestApplication, Query } from '@nestjs/common'; import { TestingModule, Test } from '@nestjs/testing'; +import { Server } from 'net'; import * as request from 'supertest'; import { getAddress } from 'viem'; @@ -21,7 +22,7 @@ class TestController { } describe('Caip10AddressesPipe', () => { - let app: INestApplication; + let app: INestApplication; beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ diff --git a/src/routes/safes/safes.controller.nonces.spec.ts b/src/routes/safes/safes.controller.nonces.spec.ts index 3064cd6726..c813fe5227 100644 --- a/src/routes/safes/safes.controller.nonces.spec.ts +++ b/src/routes/safes/safes.controller.nonces.spec.ts @@ -26,9 +26,10 @@ import { AccountDataSourceModule } from '@/datasources/account/account.datasourc import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('Safes Controller Nonces (Unit)', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string | undefined; let networkService: jest.MockedObjectDeep; let configurationService: jest.MockedObjectDeep; diff --git a/src/routes/safes/safes.controller.overview.spec.ts b/src/routes/safes/safes.controller.overview.spec.ts index 2e491e052a..72e02c65dc 100644 --- a/src/routes/safes/safes.controller.overview.spec.ts +++ b/src/routes/safes/safes.controller.overview.spec.ts @@ -32,9 +32,10 @@ import { NetworkResponseError } from '@/datasources/network/entities/network.err import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('Safes Controller Overview (Unit)', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; let pricesProviderUrl: string; diff --git a/src/routes/safes/safes.controller.spec.ts b/src/routes/safes/safes.controller.spec.ts index 34d49db538..9e4698be0b 100644 --- a/src/routes/safes/safes.controller.spec.ts +++ b/src/routes/safes/safes.controller.spec.ts @@ -43,9 +43,10 @@ import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/tes import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('Safes Controller (Unit)', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; diff --git a/src/routes/subscriptions/subscription.controller.spec.ts b/src/routes/subscriptions/subscription.controller.spec.ts index 6693a82686..b6ee059da0 100644 --- a/src/routes/subscriptions/subscription.controller.spec.ts +++ b/src/routes/subscriptions/subscription.controller.spec.ts @@ -35,9 +35,10 @@ import { } from '@/datasources/jwt/configuration/jwt.configuration.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('Subscription Controller tests', () => { - let app: INestApplication; + let app: INestApplication; let accountDataSource: jest.MockedObjectDeep; beforeEach(async () => { diff --git a/src/routes/transactions/__tests__/controllers/add-transaction-confirmations.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/add-transaction-confirmations.transactions.controller.spec.ts index d6fa18c96e..1d83f8d16f 100644 --- a/src/routes/transactions/__tests__/controllers/add-transaction-confirmations.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/add-transaction-confirmations.transactions.controller.spec.ts @@ -27,9 +27,10 @@ import { } from '@/datasources/network/network.service.interface'; import { addConfirmationDtoBuilder } from '@/routes/transactions/__tests__/entities/add-confirmation.dto.builder'; import { getAddress } from 'viem'; +import { Server } from 'net'; describe('Add transaction confirmations - Transactions Controller (Unit)', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; diff --git a/src/routes/transactions/__tests__/controllers/delete-transaction.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/delete-transaction.transactions.controller.spec.ts index cf9e0a075f..fd41b504c8 100644 --- a/src/routes/transactions/__tests__/controllers/delete-transaction.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/delete-transaction.transactions.controller.spec.ts @@ -30,9 +30,10 @@ import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity'; import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.service'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('Delete Transaction - Transactions Controller (Unit', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; let fakeCacheService: FakeCacheService; diff --git a/src/routes/transactions/__tests__/controllers/get-transaction-by-id.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/get-transaction-by-id.transactions.controller.spec.ts index aee9b3d3a3..9780a06a15 100644 --- a/src/routes/transactions/__tests__/controllers/get-transaction-by-id.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/get-transaction-by-id.transactions.controller.spec.ts @@ -42,9 +42,10 @@ import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/tes import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('Get by id - Transactions Controller (Unit)', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; diff --git a/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts index 22670a30ae..5b2711731e 100644 --- a/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts @@ -39,9 +39,10 @@ import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/tes import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('List incoming transfers by Safe - Transactions Controller (Unit)', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; diff --git a/src/routes/transactions/__tests__/controllers/list-module-transactions-by-safe.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/list-module-transactions-by-safe.transactions.controller.spec.ts index c38a6fab97..6e25ec2176 100644 --- a/src/routes/transactions/__tests__/controllers/list-module-transactions-by-safe.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/list-module-transactions-by-safe.transactions.controller.spec.ts @@ -28,9 +28,10 @@ import { AccountDataSourceModule } from '@/datasources/account/account.datasourc import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('List module transactions by Safe - Transactions Controller (Unit)', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; diff --git a/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts index b19b2c5a68..8867120974 100644 --- a/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts @@ -38,9 +38,10 @@ import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/tes import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('List multisig transactions by Safe - Transactions Controller (Unit)', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; diff --git a/src/routes/transactions/__tests__/controllers/list-queued-transactions-by-safe.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/list-queued-transactions-by-safe.transactions.controller.spec.ts index abfe839aa5..27411a686e 100644 --- a/src/routes/transactions/__tests__/controllers/list-queued-transactions-by-safe.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/list-queued-transactions-by-safe.transactions.controller.spec.ts @@ -25,9 +25,10 @@ import { } from '@/datasources/network/network.service.interface'; import { pageBuilder } from '@/domain/entities/__tests__/page.builder'; import { getAddress } from 'viem'; +import { Server } from 'net'; describe('List queued transactions by Safe - Transactions Controller (Unit)', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; diff --git a/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts index 9716d99e36..107a859664 100644 --- a/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts @@ -30,9 +30,10 @@ import { NetworkModule } from '@/datasources/network/network.module'; import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('Preview transaction - Transactions Controller (Unit)', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; diff --git a/src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts index 4fe98a823d..61cb531fbb 100644 --- a/src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts @@ -32,9 +32,10 @@ import { RequestScopedLoggingModule } from '@/logging/logging.module'; import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('Propose transaction - Transactions Controller (Unit)', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; diff --git a/src/routes/transactions/helpers/swap-order.helper.ts b/src/routes/transactions/helpers/swap-order.helper.ts index 8ada5f83f8..3a0623cf44 100644 --- a/src/routes/transactions/helpers/swap-order.helper.ts +++ b/src/routes/transactions/helpers/swap-order.helper.ts @@ -142,7 +142,9 @@ export class SwapOrderHelper { isAppAllowed(order: Order): boolean { if (!this.restrictApps) return true; const appCode = order.fullAppData?.appCode; - return !!appCode && this.allowedApps.has(appCode); + return ( + !!appCode && typeof appCode === 'string' && this.allowedApps.has(appCode) + ); } private isSwapOrder(transaction: { data?: `0x${string}` }): boolean { diff --git a/src/routes/transactions/transactions-history.controller.spec.ts b/src/routes/transactions/transactions-history.controller.spec.ts index b545ae4462..58170f0ada 100644 --- a/src/routes/transactions/transactions-history.controller.spec.ts +++ b/src/routes/transactions/transactions-history.controller.spec.ts @@ -62,11 +62,13 @@ import { NetworkResponseError } from '@/datasources/network/entities/network.err import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; describe('Transactions History Controller (Unit)', () => { - let app: INestApplication; - let safeConfigUrl: string; + let app: INestApplication; + let safeConfigUrl: string | undefined; let networkService: jest.MockedObjectDeep; + let configurationService: jest.MockedObjectDeep; beforeEach(async () => { jest.resetAllMocks(); @@ -96,7 +98,7 @@ describe('Transactions History Controller (Unit)', () => { .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); + configurationService = moduleFixture.get(IConfigurationService); safeConfigUrl = configurationService.get('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); @@ -782,9 +784,9 @@ describe('Transactions History Controller (Unit)', () => { it('Should limit the amount of nested transfers', async () => { const safe = safeBuilder().build(); const chain = chainBuilder().build(); - const maxNestedTransfers = app - .get(IConfigurationService) - .getOrThrow('mappings.history.maxNestedTransfers'); + const maxNestedTransfers = configurationService.getOrThrow( + 'mappings.history.maxNestedTransfers', + ); const date = new Date(); const transfers = faker.helpers.multiple( () => 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 9f64e7b998..dc1805319b 100644 --- a/src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts +++ b/src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts @@ -47,9 +47,10 @@ import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { erc20TransferEncoder } from '@/domain/relay/contracts/__tests__/encoders/erc20-encoder.builder'; import { EthereumTransaction } from '@/domain/safe/entities/ethereum-transaction.entity'; import { MultisigTransaction } from '@/domain/safe/entities/multisig-transaction.entity'; +import { Server } from 'net'; describe('Transactions History Controller (Unit) - Imitation Transactions', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; const lookupDistance = 2; diff --git a/src/routes/transactions/transactions-view.controller.spec.ts b/src/routes/transactions/transactions-view.controller.spec.ts index a59ff033fd..0494e6ee49 100644 --- a/src/routes/transactions/transactions-view.controller.spec.ts +++ b/src/routes/transactions/transactions-view.controller.spec.ts @@ -26,9 +26,10 @@ import { setPreSignatureEncoder } from '@/domain/swaps/contracts/__tests__/encod import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { faker } from '@faker-js/faker'; +import { Server } from 'net'; describe('TransactionsViewController tests', () => { - let app: INestApplication; + let app: INestApplication; let safeConfigUrl: string; let swapsApiUrl: string; let networkService: jest.MockedObjectDeep; @@ -194,7 +195,7 @@ describe('TransactionsViewController tests', () => { }, receiver: order.receiver, owner: order.owner, - fullAppData: JSON.parse(order.fullAppData), + fullAppData: JSON.parse(order.fullAppData as string), }), ); }); From a9aa8bf6f308c38f2055b16226bade0dcf0f2d28 Mon Sep 17 00:00:00 2001 From: Den Smalonski Date: Tue, 4 Jun 2024 13:04:02 +0200 Subject: [PATCH 057/207] feature: update env --- .env.custom | 1 + 1 file changed, 1 insertion(+) create mode 100644 .env.custom diff --git a/.env.custom b/.env.custom new file mode 100644 index 0000000000..f542a06c26 --- /dev/null +++ b/.env.custom @@ -0,0 +1 @@ +APPLICATION_VERSION=1.40.0 \ No newline at end of file From f8ac6ad07fb27f945e0378c40761a7dbfca739d1 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 6 Jun 2024 09:26:36 +0200 Subject: [PATCH 058/207] Change CAIP-10 parsing error message (#1616) Changes the error thrown in the `Caip10AddressesPipe` for missing addresses to better suit the exception thrown. --- src/routes/safes/pipes/caip-10-addresses.pipe.spec.ts | 8 ++++++++ src/routes/safes/pipes/caip-10-addresses.pipe.ts | 4 +--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/routes/safes/pipes/caip-10-addresses.pipe.spec.ts b/src/routes/safes/pipes/caip-10-addresses.pipe.spec.ts index 90acdd1720..4145cdd89c 100644 --- a/src/routes/safes/pipes/caip-10-addresses.pipe.spec.ts +++ b/src/routes/safes/pipes/caip-10-addresses.pipe.spec.ts @@ -75,6 +75,10 @@ describe('Caip10AddressesPipe', () => { .expect(500); }); + it('throws for no params', async () => { + await request(app.getHttpServer()).get('/test').expect(500); + }); + it('throws for missing params', async () => { await request(app.getHttpServer()).get('/test?addresses=').expect(500); }); @@ -90,4 +94,8 @@ describe('Caip10AddressesPipe', () => { .get(`/test?addresses=${chainId}:`) .expect(500); }); + + it('throws for splittable values', async () => { + await request(app.getHttpServer()).get('/test?addresses=,').expect(500); + }); }); diff --git a/src/routes/safes/pipes/caip-10-addresses.pipe.ts b/src/routes/safes/pipes/caip-10-addresses.pipe.ts index 504732416e..ff9897f398 100644 --- a/src/routes/safes/pipes/caip-10-addresses.pipe.ts +++ b/src/routes/safes/pipes/caip-10-addresses.pipe.ts @@ -24,9 +24,7 @@ export class Caip10AddressesPipe }); if (addresses.length === 0) { - throw new Error( - 'Provided addresses do not conform to the CAIP-10 standard', - ); + throw new Error('No addresses provided. At least one is required.'); } return addresses; From 648da8bffe8d0d152d21c322dbedec496bc6cbc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Thu, 6 Jun 2024 09:41:26 +0200 Subject: [PATCH 059/207] Add Counterfactual Safes ERC-20 balances (#1615) Make BalancesApiManager.getBalancesApi return ZerionBalancesApi when the Safe data is unavailable through the Safe Transaction Service. --- .../balances-api/balances-api.manager.spec.ts | 72 ++++++++++++++-- .../balances-api/balances-api.manager.ts | 35 ++++++-- .../balances-api/balances-api.module.ts | 7 +- .../zerion-balances-api.service.ts | 1 + src/domain/balances/balances.repository.ts | 6 +- .../collectibles/collectibles.repository.ts | 10 ++- .../balances-api.manager.interface.ts | 9 +- .../balances/balances.controller.spec.ts | 85 +++++++++++++++---- .../__tests__/event-hooks-queue.e2e-spec.ts | 12 +-- .../cache-hooks.controller.spec.ts | 21 +++-- .../collectibles.controller.spec.ts | 22 ++++- .../safes/safes.controller.overview.spec.ts | 68 +++++++++------ 12 files changed, 265 insertions(+), 83 deletions(-) diff --git a/src/datasources/balances-api/balances-api.manager.spec.ts b/src/datasources/balances-api/balances-api.manager.spec.ts index dc2c728311..5901be4729 100644 --- a/src/datasources/balances-api/balances-api.manager.spec.ts +++ b/src/datasources/balances-api/balances-api.manager.spec.ts @@ -9,6 +9,10 @@ import { IConfigApi } from '@/domain/interfaces/config-api.interface'; import { IPricesApi } from '@/datasources/balances-api/prices-api.interface'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; +import { sample } from 'lodash'; +import { ITransactionApiManager } from '@/domain/interfaces/transaction-api.manager.interface'; +import { ITransactionApi } from '@/domain/interfaces/transaction-api.interface'; +import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; const configurationService = { getOrThrow: jest.fn(), @@ -34,6 +38,14 @@ const httpErrorFactory = { from: jest.fn(), } as jest.MockedObjectDeep; +const transactionApiManagerMock = { + getTransactionApi: jest.fn(), +} as jest.MockedObjectDeep; + +const transactionApiMock = { + getSafe: jest.fn(), +} as jest.MockedObjectDeep; + const zerionBalancesApi = { getBalances: jest.fn(), clearBalances: jest.fn(), @@ -51,17 +63,43 @@ const coingeckoApi = { } as IPricesApi; const coingeckoApiMock = jest.mocked(coingeckoApi); +const ZERION_BALANCES_CHAIN_IDS: string[] = Array.from( + { length: faker.number.int({ min: 1, max: 10 }) }, + () => faker.string.numeric(), +); beforeEach(() => { jest.resetAllMocks(); configurationServiceMock.getOrThrow.mockImplementation((key) => { - if (key === 'features.zerionBalancesChainIds') return ['1', '2', '3']; + if (key === 'features.zerionBalancesChainIds') + return ZERION_BALANCES_CHAIN_IDS; }); }); describe('Balances API Manager Tests', () => { describe('getBalancesApi checks', () => { - it('should return the Zerion API', async () => { + it('should return the Zerion API if the chainId is one of zerionBalancesChainIds', async () => { + const manager = new BalancesApiManager( + configurationService, + configApiMock, + dataSourceMock, + cacheService, + httpErrorFactory, + zerionBalancesApiMock, + coingeckoApiMock, + transactionApiManagerMock, + ); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + + const result = await manager.getBalancesApi( + sample(ZERION_BALANCES_CHAIN_IDS) as string, + safeAddress, + ); + + expect(result).toEqual(zerionBalancesApi); + }); + + it('should return the Zerion API if the Safe address is not known by the Safe Transaction Service', async () => { const manager = new BalancesApiManager( configurationService, configApiMock, @@ -70,9 +108,20 @@ describe('Balances API Manager Tests', () => { httpErrorFactory, zerionBalancesApiMock, coingeckoApiMock, + transactionApiManagerMock, + ); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + transactionApiManagerMock.getTransactionApi.mockResolvedValue( + transactionApiMock, ); + transactionApiMock.getSafe.mockImplementation(() => { + throw new Error(); + }); - const result = await manager.getBalancesApi('2'); + const result = await manager.getBalancesApi( + faker.string.numeric({ exclude: ZERION_BALANCES_CHAIN_IDS }), + safeAddress, + ); expect(result).toEqual(zerionBalancesApi); }); @@ -88,10 +137,12 @@ describe('Balances API Manager Tests', () => { [true, vpcTxServiceUrl], [false, txServiceUrl], ])('vpcUrl is %s', async (useVpcUrl, expectedUrl) => { - const zerionChainIds = ['1', '2', '3']; const fiatCode = faker.finance.currencyCode(); const chain = chainBuilder() - .with('chainId', '4') + .with( + 'chainId', + faker.string.numeric({ exclude: ZERION_BALANCES_CHAIN_IDS }), + ) .with('transactionService', txServiceUrl) .with('vpcTransactionService', vpcTxServiceUrl) .build(); @@ -104,7 +155,7 @@ describe('Balances API Manager Tests', () => { else if (key === 'expirationTimeInSeconds.notFound.default') return notFoundExpireTimeSeconds; else if (key === 'features.zerionBalancesChainIds') - return zerionChainIds; + return ZERION_BALANCES_CHAIN_IDS; throw new Error(`Unexpected key: ${key}`); }); configApiMock.getChain.mockResolvedValue(chain); @@ -117,12 +168,18 @@ describe('Balances API Manager Tests', () => { httpErrorFactory, zerionBalancesApiMock, coingeckoApiMock, + transactionApiManagerMock, + ); + transactionApiManagerMock.getTransactionApi.mockResolvedValue( + transactionApiMock, ); + transactionApiMock.getSafe.mockResolvedValue(safeBuilder().build()); + const safeAddress = getAddress(faker.finance.ethereumAddress()); const safeBalancesApi = await balancesApiManager.getBalancesApi( chain.chainId, + safeAddress, ); - const safeAddress = getAddress(faker.finance.ethereumAddress()); const trusted = faker.datatype.boolean(); const excludeSpam = faker.datatype.boolean(); @@ -164,6 +221,7 @@ describe('Balances API Manager Tests', () => { httpErrorFactory, zerionBalancesApiMock, coingeckoApiMock, + transactionApiManagerMock, ); const result = await manager.getFiatCodes(); diff --git a/src/datasources/balances-api/balances-api.manager.ts b/src/datasources/balances-api/balances-api.manager.ts index 3b20aa3822..9d5927a9f3 100644 --- a/src/datasources/balances-api/balances-api.manager.ts +++ b/src/datasources/balances-api/balances-api.manager.ts @@ -13,6 +13,7 @@ import { IConfigApi } from '@/domain/interfaces/config-api.interface'; import { IPricesApi } from '@/datasources/balances-api/prices-api.interface'; import { Inject, Injectable } from '@nestjs/common'; import { intersection } from 'lodash'; +import { ITransactionApiManager } from '@/domain/interfaces/transaction-api.manager.interface'; @Injectable() export class BalancesApiManager implements IBalancesApiManager { @@ -30,6 +31,8 @@ export class BalancesApiManager implements IBalancesApiManager { private readonly httpErrorFactory: HttpErrorFactory, @Inject(IZerionBalancesApi) zerionBalancesApi: IBalancesApi, @Inject(IPricesApi) private readonly coingeckoApi: IPricesApi, + @Inject(ITransactionApiManager) + private readonly transactionApiManager: ITransactionApiManager, ) { this.zerionChainIds = this.configurationService.getOrThrow( 'features.zerionBalancesChainIds', @@ -40,11 +43,35 @@ export class BalancesApiManager implements IBalancesApiManager { this.zerionBalancesApi = zerionBalancesApi; } - async getBalancesApi(chainId: string): Promise { + async getBalancesApi( + chainId: string, + safeAddress: `0x${string}`, + ): Promise { if (this.zerionChainIds.includes(chainId)) { return this.zerionBalancesApi; } + // SafeBalancesApi will be returned only if TransactionApi returns the Safe data. + // Otherwise ZerionBalancesApi will be returned as the Safe is considered counterfactual/not deployed. + try { + const transactionApi = + await this.transactionApiManager.getTransactionApi(chainId); + await transactionApi.getSafe(safeAddress); + return this._getSafeBalancesApi(chainId); + } catch { + return this.zerionBalancesApi; + } + } + + async getFiatCodes(): Promise { + const [zerionFiatCodes, safeFiatCodes] = await Promise.all([ + this.zerionBalancesApi.getFiatCodes(), + this.coingeckoApi.getFiatCodes(), + ]); + return intersection(zerionFiatCodes, safeFiatCodes).sort(); + } + + private async _getSafeBalancesApi(chainId: string): Promise { const safeBalancesApi = this.safeBalancesApiMap[chainId]; if (safeBalancesApi !== undefined) return safeBalancesApi; @@ -60,10 +87,4 @@ export class BalancesApiManager implements IBalancesApiManager { ); return this.safeBalancesApiMap[chainId]; } - - async getFiatCodes(): Promise { - const zerionFiatCodes = await this.zerionBalancesApi.getFiatCodes(); - const safeFiatCodes = await this.coingeckoApi.getFiatCodes(); - return intersection(zerionFiatCodes, safeFiatCodes).sort(); - } } diff --git a/src/datasources/balances-api/balances-api.module.ts b/src/datasources/balances-api/balances-api.module.ts index c29c50c424..3fdd66346a 100644 --- a/src/datasources/balances-api/balances-api.module.ts +++ b/src/datasources/balances-api/balances-api.module.ts @@ -10,9 +10,14 @@ import { HttpErrorFactory } from '@/datasources/errors/http-error-factory'; import { CoingeckoApi } from '@/datasources/balances-api/coingecko-api.service'; import { IPricesApi } from '@/datasources/balances-api/prices-api.interface'; import { ConfigApiModule } from '@/datasources/config-api/config-api.module'; +import { TransactionApiManagerModule } from '@/domain/interfaces/transaction-api.manager.interface'; @Module({ - imports: [CacheFirstDataSourceModule, ConfigApiModule], + imports: [ + CacheFirstDataSourceModule, + ConfigApiModule, + TransactionApiManagerModule, + ], providers: [ HttpErrorFactory, { provide: IBalancesApiManager, useClass: BalancesApiManager }, diff --git a/src/datasources/balances-api/zerion-balances-api.service.ts b/src/datasources/balances-api/zerion-balances-api.service.ts index 37f60bcc04..55f7ae412b 100644 --- a/src/datasources/balances-api/zerion-balances-api.service.ts +++ b/src/datasources/balances-api/zerion-balances-api.service.ts @@ -92,6 +92,7 @@ export class ZerionBalancesApi implements IBalancesApi { safeAddress: `0x${string}`; fiatCode: string; }): Promise { + // TODO: check the fiatCode is supported. const cacheDir = CacheRouter.getZerionBalancesCacheDir(args); const chainName = this._getChainName(args.chainId); const cached = await this.cacheService.get(cacheDir); diff --git a/src/domain/balances/balances.repository.ts b/src/domain/balances/balances.repository.ts index ba9e28f81d..fe36b628ff 100644 --- a/src/domain/balances/balances.repository.ts +++ b/src/domain/balances/balances.repository.ts @@ -21,6 +21,7 @@ export class BalancesRepository implements IBalancesRepository { }): Promise { const api = await this.balancesApiManager.getBalancesApi( args.chain.chainId, + args.safeAddress, ); const balances = await api.getBalances(args); return balances.map((balance) => BalanceSchema.parse(balance)); @@ -30,7 +31,10 @@ export class BalancesRepository implements IBalancesRepository { chainId: string; safeAddress: `0x${string}`; }): Promise { - const api = await this.balancesApiManager.getBalancesApi(args.chainId); + const api = await this.balancesApiManager.getBalancesApi( + args.chainId, + args.safeAddress, + ); await api.clearBalances(args); } diff --git a/src/domain/collectibles/collectibles.repository.ts b/src/domain/collectibles/collectibles.repository.ts index 3c525a0562..be4569f3f5 100644 --- a/src/domain/collectibles/collectibles.repository.ts +++ b/src/domain/collectibles/collectibles.repository.ts @@ -20,7 +20,10 @@ export class CollectiblesRepository implements ICollectiblesRepository { trusted?: boolean; excludeSpam?: boolean; }): Promise> { - const api = await this.balancesApiManager.getBalancesApi(args.chainId); + const api = await this.balancesApiManager.getBalancesApi( + args.chainId, + args.safeAddress, + ); const page = await api.getCollectibles(args); return CollectiblePageSchema.parse(page); } @@ -29,7 +32,10 @@ export class CollectiblesRepository implements ICollectiblesRepository { chainId: string; safeAddress: `0x${string}`; }): Promise { - const api = await this.balancesApiManager.getBalancesApi(args.chainId); + const api = await this.balancesApiManager.getBalancesApi( + args.chainId, + args.safeAddress, + ); await api.clearCollectibles(args); } } diff --git a/src/domain/interfaces/balances-api.manager.interface.ts b/src/domain/interfaces/balances-api.manager.interface.ts index f1199b321a..010c073a85 100644 --- a/src/domain/interfaces/balances-api.manager.interface.ts +++ b/src/domain/interfaces/balances-api.manager.interface.ts @@ -8,10 +8,17 @@ export interface IBalancesApiManager { * Each chain is associated with an implementation (i.e.: to a balances * provider) via configuration. * + * Note: if the Safe entity associated with the safeAddress cannot be retrieved + * from the TransactionApi for the chainId, then the ZerionApi will be used. + * * @param chainId - the chain identifier to check. + * @param safeAddress - the Safe address to check. * @returns {@link IBalancesApi} configured for the input chain ID. */ - getBalancesApi(chainId: string): Promise; + getBalancesApi( + chainId: string, + safeAddress: `0x${string}`, + ): Promise; /** * Gets the list of supported fiat codes. diff --git a/src/routes/balances/balances.controller.spec.ts b/src/routes/balances/balances.controller.spec.ts index abb23feda7..ef44e72cbe 100644 --- a/src/routes/balances/balances.controller.spec.ts +++ b/src/routes/balances/balances.controller.spec.ts @@ -27,6 +27,7 @@ import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; +import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; describe('Balances Controller (Unit)', () => { let app: INestApplication; @@ -107,6 +108,11 @@ describe('Balances Controller (Unit)', () => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: return Promise.resolve({ data: chain, status: 200 }); + case `${chain.transactionService}/api/v1/safes/${safeAddress}`: + return Promise.resolve({ + data: safeBuilder().build(), + status: 200, + }); case `${chain.transactionService}/api/v1/safes/${safeAddress}/balances/`: return Promise.resolve({ data: transactionApiBalancesResponse, @@ -184,21 +190,24 @@ describe('Balances Controller (Unit)', () => { }); // 4 Network calls are expected - // (1. Chain data, 2. Balances, 3. Coingecko native coin, 4. Coingecko tokens) - expect(networkService.get.mock.calls.length).toBe(4); + // (1. Chain data, 2. Safe data, 3. Balances, 4. Coingecko native coin, 5. Coingecko tokens) + expect(networkService.get.mock.calls.length).toBe(5); expect(networkService.get.mock.calls[0][0].url).toBe( `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, ); expect(networkService.get.mock.calls[1][0].url).toBe( + `${chain.transactionService}/api/v1/safes/${safeAddress}`, + ); + expect(networkService.get.mock.calls[2][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeAddress}/balances/`, ); - expect(networkService.get.mock.calls[1][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[2][0].networkRequest).toStrictEqual({ params: { trusted: false, exclude_spam: true }, }); - expect(networkService.get.mock.calls[2][0].url).toBe( + expect(networkService.get.mock.calls[3][0].url).toBe( `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); - expect(networkService.get.mock.calls[2][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': apiKey }, params: { vs_currencies: currency.toLowerCase(), @@ -208,10 +217,10 @@ describe('Balances Controller (Unit)', () => { ].join(','), }, }); - expect(networkService.get.mock.calls[3][0].url).toBe( + expect(networkService.get.mock.calls[4][0].url).toBe( `${pricesProviderUrl}/simple/price`, ); - expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': apiKey }, params: { ids: chain.pricesProvider.nativeCoin, @@ -241,6 +250,11 @@ describe('Balances Controller (Unit)', () => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: return Promise.resolve({ data: chain, status: 200 }); + case `${chain.transactionService}/api/v1/safes/${safeAddress}`: + return Promise.resolve({ + data: safeBuilder().build(), + status: 200, + }); case `${chain.transactionService}/api/v1/safes/${safeAddress}/balances/`: return Promise.resolve({ data: transactionApiBalancesResponse, @@ -263,7 +277,7 @@ describe('Balances Controller (Unit)', () => { .expect(200); // trusted and exclude_spam params are passed - expect(networkService.get.mock.calls[1][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[2][0].networkRequest).toStrictEqual({ params: { trusted, exclude_spam: excludeSpam, @@ -291,6 +305,11 @@ describe('Balances Controller (Unit)', () => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: return Promise.resolve({ data: chain, status: 200 }); + case `${chain.transactionService}/api/v1/safes/${safeAddress}`: + return Promise.resolve({ + data: safeBuilder().build(), + status: 200, + }); case `${chain.transactionService}/api/v1/safes/${safeAddress}/balances/`: return Promise.resolve({ data: transactionApiBalancesResponse, @@ -352,6 +371,11 @@ describe('Balances Controller (Unit)', () => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: return Promise.resolve({ data: chain, status: 200 }); + case `${chain.transactionService}/api/v1/safes/${safeAddress}`: + return Promise.resolve({ + data: safeBuilder().build(), + status: 200, + }); case `${chain.transactionService}/api/v1/safes/${safeAddress}/balances/`: return Promise.resolve({ data: transactionApiBalancesResponse, @@ -394,18 +418,21 @@ describe('Balances Controller (Unit)', () => { }); // 3 Network calls are expected - // (1. Chain data, 2. Balances, 3. Coingecko token) - expect(networkService.get.mock.calls.length).toBe(3); + // (1. Chain data, 2. Safe data, 3. Balances, 4. Coingecko token) + expect(networkService.get.mock.calls.length).toBe(4); expect(networkService.get.mock.calls[0][0].url).toBe( `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, ); expect(networkService.get.mock.calls[1][0].url).toBe( + `${chain.transactionService}/api/v1/safes/${safeAddress}`, + ); + expect(networkService.get.mock.calls[2][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeAddress}/balances/`, ); - expect(networkService.get.mock.calls[1][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[2][0].networkRequest).toStrictEqual({ params: { trusted: false, exclude_spam: true }, }); - expect(networkService.get.mock.calls[2][0].url).toBe( + expect(networkService.get.mock.calls[3][0].url).toBe( `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); }); @@ -452,6 +479,11 @@ describe('Balances Controller (Unit)', () => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: return Promise.resolve({ data: chain, status: 200 }); + case `${chain.transactionService}/api/v1/safes/${safeAddress}`: + return Promise.resolve({ + data: safeBuilder().build(), + status: 200, + }); case `${chain.transactionService}/api/v1/safes/${safeAddress}/balances/`: return Promise.resolve({ data: transactionApiBalancesResponse, @@ -492,7 +524,7 @@ describe('Balances Controller (Unit)', () => { ], }); - expect(networkService.get.mock.calls.length).toBe(3); + expect(networkService.get.mock.calls.length).toBe(4); }); it(`should return a 0-balance when a validation error happens`, async () => { @@ -511,6 +543,11 @@ describe('Balances Controller (Unit)', () => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: return Promise.resolve({ data: chain, status: 200 }); + case `${chain.transactionService}/api/v1/safes/${safeAddress}`: + return Promise.resolve({ + data: safeBuilder().build(), + status: 200, + }); case `${chain.transactionService}/api/v1/safes/${safeAddress}/balances/`: return Promise.resolve({ data: transactionApiBalancesResponse, @@ -554,7 +591,7 @@ describe('Balances Controller (Unit)', () => { ], }); - expect(networkService.get.mock.calls.length).toBe(3); + expect(networkService.get.mock.calls.length).toBe(4); }); }); @@ -567,6 +604,14 @@ describe('Balances Controller (Unit)', () => { networkService.get.mockImplementation(({ url }) => { if (url == `${safeConfigUrl}/api/v1/chains/${chainId}`) { return Promise.resolve({ data: chainResponse, status: 200 }); + } else if ( + url == + `${chainResponse.transactionService}/api/v1/safes/${safeAddress}` + ) { + return Promise.resolve({ + data: safeBuilder().build(), + status: 200, + }); } else if (url == transactionServiceUrl) { const error = new NetworkResponseError( new URL(transactionServiceUrl), @@ -588,7 +633,7 @@ describe('Balances Controller (Unit)', () => { code: 500, }); - expect(networkService.get.mock.calls.length).toBe(2); + expect(networkService.get.mock.calls.length).toBe(3); }); }); @@ -607,6 +652,14 @@ describe('Balances Controller (Unit)', () => { data: [{ invalid: 'data' }], status: 200, }); + } else if ( + url == + `${chainResponse.transactionService}/api/v1/safes/${safeAddress}` + ) { + return Promise.resolve({ + data: safeBuilder().build(), + status: 200, + }); } else { return Promise.reject(new Error(`Could not match ${url}`)); } @@ -620,7 +673,7 @@ describe('Balances Controller (Unit)', () => { message: 'Internal server error', }); - expect(networkService.get.mock.calls.length).toBe(3); + expect(networkService.get.mock.calls.length).toBe(4); }); }); diff --git a/src/routes/cache-hooks/__tests__/event-hooks-queue.e2e-spec.ts b/src/routes/cache-hooks/__tests__/event-hooks-queue.e2e-spec.ts index ad7516b23d..dbbc1da7a8 100644 --- a/src/routes/cache-hooks/__tests__/event-hooks-queue.e2e-spec.ts +++ b/src/routes/cache-hooks/__tests__/event-hooks-queue.e2e-spec.ts @@ -22,6 +22,8 @@ describe('Events queue processing e2e tests', () => { const cacheKeyPrefix = crypto.randomUUID(); const queue = crypto.randomUUID(); const chainId = '1'; // Mainnet + // TODO: use a proper "test" safe address + const safeAddress = getAddress('0x9a8FEe232DCF73060Af348a1B62Cdb0a19852d13'); beforeAll(async () => { const defaultConfiguration = configuration(); @@ -80,7 +82,6 @@ describe('Events queue processing e2e tests', () => { txHash: faker.string.hexadecimal({ length: 32 }), }, ])('$type clears balances', async (payload) => { - const safeAddress = faker.finance.ethereumAddress(); const cacheDir = new CacheDir( `${chainId}_safe_balances_${getAddress(safeAddress)}`, faker.string.alpha(), @@ -123,7 +124,6 @@ describe('Events queue processing e2e tests', () => { safeTxHash: faker.string.hexadecimal({ length: 32 }), }, ])('$type clears multisig transactions', async (payload) => { - const safeAddress = faker.finance.ethereumAddress(); const cacheDir = new CacheDir( `${chainId}_multisig_transactions_${getAddress(safeAddress)}`, faker.string.alpha(), @@ -166,7 +166,6 @@ describe('Events queue processing e2e tests', () => { safeTxHash: faker.string.hexadecimal({ length: 32 }), }, ])('$type clears multisig transaction', async (payload) => { - const safeAddress = faker.finance.ethereumAddress(); const cacheDir = new CacheDir( `${chainId}_multisig_transaction_${payload.safeTxHash}`, faker.string.alpha(), @@ -201,7 +200,6 @@ describe('Events queue processing e2e tests', () => { txHash: faker.string.hexadecimal({ length: 32 }), }, ])('$type clears safe info', async (payload) => { - const safeAddress = faker.finance.ethereumAddress(); const cacheDir = new CacheDir( `${chainId}_safe_${getAddress(safeAddress)}`, faker.string.alpha(), @@ -241,7 +239,6 @@ describe('Events queue processing e2e tests', () => { txHash: faker.string.hexadecimal({ length: 32 }), }, ])('$type clears safe collectibles', async (payload) => { - const safeAddress = faker.finance.ethereumAddress(); const cacheDir = new CacheDir( `${chainId}_safe_collectibles_${getAddress(safeAddress)}`, faker.string.alpha(), @@ -281,7 +278,6 @@ describe('Events queue processing e2e tests', () => { txHash: faker.string.hexadecimal({ length: 32 }), }, ])('$type clears safe collectible transfers', async (payload) => { - const safeAddress = faker.finance.ethereumAddress(); const cacheDir = new CacheDir( `${chainId}_transfers_${getAddress(safeAddress)}`, faker.string.alpha(), @@ -316,7 +312,6 @@ describe('Events queue processing e2e tests', () => { value: faker.string.numeric(), }, ])('$type clears incoming transfers', async (payload) => { - const safeAddress = faker.finance.ethereumAddress(); const cacheDir = new CacheDir( `${chainId}_incoming_transfers_${getAddress(safeAddress)}`, faker.string.alpha(), @@ -346,7 +341,6 @@ describe('Events queue processing e2e tests', () => { txHash: faker.string.hexadecimal({ length: 32 }), }, ])('$type clears module transactions', async (payload) => { - const safeAddress = faker.finance.ethereumAddress(); const cacheDir = new CacheDir( `${chainId}_module_transactions_${getAddress(safeAddress)}`, faker.string.alpha(), @@ -401,7 +395,6 @@ describe('Events queue processing e2e tests', () => { txHash: faker.string.hexadecimal({ length: 32 }), }, ])('$type clears all transactions', async (payload) => { - const safeAddress = faker.finance.ethereumAddress(); const cacheDir = new CacheDir( `${chainId}_all_transactions_${getAddress(safeAddress)}`, faker.string.alpha(), @@ -434,7 +427,6 @@ describe('Events queue processing e2e tests', () => { messageHash: faker.string.hexadecimal({ length: 32 }), }, ])('$type clears messages', async (payload) => { - const safeAddress = faker.finance.ethereumAddress(); const cacheDir = new CacheDir( `${chainId}_messages_${getAddress(safeAddress)}`, faker.string.alpha(), diff --git a/src/routes/cache-hooks/cache-hooks.controller.spec.ts b/src/routes/cache-hooks/cache-hooks.controller.spec.ts index 9467befa0d..ae2e756b5b 100644 --- a/src/routes/cache-hooks/cache-hooks.controller.spec.ts +++ b/src/routes/cache-hooks/cache-hooks.controller.spec.ts @@ -25,6 +25,7 @@ import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; +import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; describe('Post Hook Events (Unit)', () => { let app: INestApplication; @@ -274,14 +275,16 @@ describe('Post Hook Events (Unit)', () => { txHash: faker.string.hexadecimal({ length: 32 }), }, ])('$type clears balances', async (payload) => { - const safeAddress = faker.finance.ethereumAddress(); const chainId = faker.string.numeric({ exclude: configurationService.getOrThrow( 'features.zerionBalancesChainIds', ), }); + const chain = chainBuilder().with('chainId', chainId).build(); + const safe = safeBuilder().build(); + const safeAddress = getAddress(safe.address); const cacheDir = new CacheDir( - `${chainId}_safe_balances_${getAddress(safeAddress)}`, + `${chainId}_safe_balances_${safeAddress}`, faker.string.alpha(), ); await fakeCacheService.set( @@ -298,9 +301,11 @@ describe('Post Hook Events (Unit)', () => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chainId}`: return Promise.resolve({ - data: chainBuilder().with('chainId', chainId).build(), + data: chain, status: 200, }); + case `${chain.transactionService}/api/v1/safes/${safeAddress}`: + return Promise.resolve({ data: safe, status: 200 }); default: return Promise.reject(new Error(`Could not match ${url}`)); } @@ -495,14 +500,16 @@ describe('Post Hook Events (Unit)', () => { txHash: faker.string.hexadecimal({ length: 32 }), }, ])('$type clears safe collectibles', async (payload) => { - const safeAddress = faker.finance.ethereumAddress(); const chainId = faker.string.numeric({ exclude: configurationService.getOrThrow( 'features.zerionBalancesChainIds', ), }); + const chain = chainBuilder().with('chainId', chainId).build(); + const safe = safeBuilder().build(); + const safeAddress = getAddress(safe.address); const cacheDir = new CacheDir( - `${chainId}_safe_collectibles_${getAddress(safeAddress)}`, + `${chainId}_safe_collectibles_${safeAddress}`, faker.string.alpha(), ); await fakeCacheService.set( @@ -519,9 +526,11 @@ describe('Post Hook Events (Unit)', () => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chainId}`: return Promise.resolve({ - data: chainBuilder().with('chainId', chainId).build(), + data: chain, status: 200, }); + case `${chain.transactionService}/api/v1/safes/${safeAddress}`: + return Promise.resolve({ data: safe, status: 200 }); default: return Promise.reject(new Error(`Could not match ${url}`)); } diff --git a/src/routes/collectibles/collectibles.controller.spec.ts b/src/routes/collectibles/collectibles.controller.spec.ts index 0965388068..cc82790c26 100644 --- a/src/routes/collectibles/collectibles.controller.spec.ts +++ b/src/routes/collectibles/collectibles.controller.spec.ts @@ -34,6 +34,7 @@ import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues- import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; import { getAddress } from 'viem'; +import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; describe('Collectibles Controller (Unit)', () => { let app: INestApplication; @@ -76,6 +77,7 @@ describe('Collectibles Controller (Unit)', () => { const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainResponse = chainBuilder().with('chainId', chainId).build(); const pageLimit = 1; + const safeResponse = safeBuilder().build(); const collectiblesResponse = pageBuilder() .with('next', limitAndOffsetUrlFactory(pageLimit, 0)) .with('previous', limitAndOffsetUrlFactory(pageLimit, 0)) @@ -90,6 +92,8 @@ describe('Collectibles Controller (Unit)', () => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chainId}`: return Promise.resolve({ data: chainResponse, status: 200 }); + case `${chainResponse.transactionService}/api/v1/safes/${safeAddress}`: + return Promise.resolve({ data: safeResponse, status: 200 }); case `${chainResponse.transactionService}/api/v2/safes/${safeAddress}/collectibles/`: return Promise.resolve({ data: collectiblesResponse, status: 200 }); default: @@ -118,7 +122,7 @@ describe('Collectibles Controller (Unit)', () => { const chainResponse = chainBuilder().with('chainId', chainId).build(); const limit = 10; const offset = 20; - + const safeResponse = safeBuilder().build(); const collectiblesResponse = pageBuilder() .with('next', null) .with('previous', null) @@ -133,6 +137,8 @@ describe('Collectibles Controller (Unit)', () => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chainId}`: return Promise.resolve({ data: chainResponse, status: 200 }); + case `${chainResponse.transactionService}/api/v1/safes/${safeAddress}`: + return Promise.resolve({ data: safeResponse, status: 200 }); case `${chainResponse.transactionService}/api/v2/safes/${safeAddress}/collectibles/`: return Promise.resolve({ data: collectiblesResponse, status: 200 }); default: @@ -146,7 +152,7 @@ describe('Collectibles Controller (Unit)', () => { ) .expect(200); - expect(networkService.get.mock.calls[1][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[2][0].networkRequest).toStrictEqual({ params: { limit: 10, offset: 20, @@ -162,7 +168,7 @@ describe('Collectibles Controller (Unit)', () => { const chainResponse = chainBuilder().with('chainId', chainId).build(); const excludeSpam = true; const trusted = true; - + const safeResponse = safeBuilder().build(); const collectiblesResponse = pageBuilder() .with('next', null) .with('previous', null) @@ -177,6 +183,8 @@ describe('Collectibles Controller (Unit)', () => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chainId}`: return Promise.resolve({ data: chainResponse, status: 200 }); + case `${chainResponse.transactionService}/api/v1/safes/${safeAddress}`: + return Promise.resolve({ data: safeResponse, status: 200 }); case `${chainResponse.transactionService}/api/v2/safes/${safeAddress}/collectibles/`: return Promise.resolve({ data: collectiblesResponse, status: 200 }); default: @@ -190,7 +198,7 @@ describe('Collectibles Controller (Unit)', () => { ) .expect(200); - expect(networkService.get.mock.calls[1][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[2][0].networkRequest).toStrictEqual({ params: { limit: PaginationData.DEFAULT_LIMIT, offset: PaginationData.DEFAULT_OFFSET, @@ -204,6 +212,7 @@ describe('Collectibles Controller (Unit)', () => { const chainId = faker.string.numeric(); const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainResponse = chainBuilder().with('chainId', chainId).build(); + const safeResponse = safeBuilder().build(); const transactionServiceUrl = `${chainResponse.transactionService}/api/v2/safes/${safeAddress}/collectibles/`; const transactionServiceError = new NetworkResponseError( new URL(transactionServiceUrl), @@ -216,6 +225,8 @@ describe('Collectibles Controller (Unit)', () => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chainId}`: return Promise.resolve({ data: chainResponse, status: 200 }); + case `${chainResponse.transactionService}/api/v1/safes/${safeAddress}`: + return Promise.resolve({ data: safeResponse, status: 200 }); case transactionServiceUrl: return Promise.reject(transactionServiceError); default: @@ -237,6 +248,7 @@ describe('Collectibles Controller (Unit)', () => { const chainId = faker.string.numeric(); const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainResponse = chainBuilder().with('chainId', chainId).build(); + const safeResponse = safeBuilder().build(); const transactionServiceUrl = `${chainResponse.transactionService}/api/v2/safes/${safeAddress}/collectibles/`; const transactionServiceError = new NetworkRequestError( new URL(transactionServiceUrl), @@ -245,6 +257,8 @@ describe('Collectibles Controller (Unit)', () => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chainId}`: return Promise.resolve({ data: chainResponse, status: 200 }); + case `${chainResponse.transactionService}/api/v1/safes/${safeAddress}`: + return Promise.resolve({ data: safeResponse, status: 200 }); case transactionServiceUrl: return Promise.reject(transactionServiceError); default: diff --git a/src/routes/safes/safes.controller.overview.spec.ts b/src/routes/safes/safes.controller.overview.spec.ts index 72e02c65dc..0b89409238 100644 --- a/src/routes/safes/safes.controller.overview.spec.ts +++ b/src/routes/safes/safes.controller.overview.spec.ts @@ -203,7 +203,7 @@ describe('Safes Controller Overview (Unit)', () => { ]), ); - expect(networkService.get.mock.calls.length).toBe(6); + expect(networkService.get.mock.calls.length).toBe(7); expect(networkService.get.mock.calls[0][0].url).toBe( `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, @@ -212,15 +212,18 @@ describe('Safes Controller Overview (Unit)', () => { `${chain.transactionService}/api/v1/safes/${safeInfo.address}`, ); expect(networkService.get.mock.calls[2][0].url).toBe( + `${chain.transactionService}/api/v1/safes/${safeInfo.address}`, + ); + expect(networkService.get.mock.calls[3][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeInfo.address}/balances/`, ); - expect(networkService.get.mock.calls[2][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ params: { trusted: false, exclude_spam: true }, }); - expect(networkService.get.mock.calls[3][0].url).toBe( + expect(networkService.get.mock.calls[4][0].url).toBe( `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); - expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, params: { vs_currencies: currency.toLowerCase(), @@ -230,17 +233,17 @@ describe('Safes Controller Overview (Unit)', () => { ].join(','), }, }); - expect(networkService.get.mock.calls[4][0].url).toBe( + expect(networkService.get.mock.calls[5][0].url).toBe( `${pricesProviderUrl}/simple/price`, ); - expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[5][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, params: { ids: chain.pricesProvider.nativeCoin, vs_currencies: currency.toLowerCase(), }, }); - expect(networkService.get.mock.calls[5][0].url).toBe( + expect(networkService.get.mock.calls[6][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeInfo.address}/multisig-transactions/`, ); }); @@ -372,7 +375,7 @@ describe('Safes Controller Overview (Unit)', () => { ]), ); - expect(networkService.get.mock.calls.length).toBe(6); + expect(networkService.get.mock.calls.length).toBe(7); expect(networkService.get.mock.calls[0][0].url).toBe( `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, @@ -381,15 +384,18 @@ describe('Safes Controller Overview (Unit)', () => { `${chain.transactionService}/api/v1/safes/${safeInfo.address}`, ); expect(networkService.get.mock.calls[2][0].url).toBe( + `${chain.transactionService}/api/v1/safes/${safeInfo.address}`, + ); + expect(networkService.get.mock.calls[3][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeInfo.address}/balances/`, ); - expect(networkService.get.mock.calls[2][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ params: { trusted: false, exclude_spam: true }, }); - expect(networkService.get.mock.calls[3][0].url).toBe( + expect(networkService.get.mock.calls[4][0].url).toBe( `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); - expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, params: { vs_currencies: currency.toLowerCase(), @@ -399,17 +405,17 @@ describe('Safes Controller Overview (Unit)', () => { ].join(','), }, }); - expect(networkService.get.mock.calls[4][0].url).toBe( + expect(networkService.get.mock.calls[5][0].url).toBe( `${pricesProviderUrl}/simple/price`, ); - expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[5][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, params: { ids: chain.pricesProvider.nativeCoin, vs_currencies: currency.toLowerCase(), }, }); - expect(networkService.get.mock.calls[5][0].url).toBe( + expect(networkService.get.mock.calls[6][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeInfo.address}/multisig-transactions/`, ); }); @@ -964,7 +970,7 @@ describe('Safes Controller Overview (Unit)', () => { }, ]); - expect(networkService.get.mock.calls.length).toBe(6); + expect(networkService.get.mock.calls.length).toBe(7); expect(networkService.get.mock.calls[0][0].url).toBe( `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, @@ -973,15 +979,18 @@ describe('Safes Controller Overview (Unit)', () => { `${chain.transactionService}/api/v1/safes/${safeInfo.address}`, ); expect(networkService.get.mock.calls[2][0].url).toBe( + `${chain.transactionService}/api/v1/safes/${safeInfo.address}`, + ); + expect(networkService.get.mock.calls[3][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeInfo.address}/balances/`, ); - expect(networkService.get.mock.calls[2][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ params: { trusted: false, exclude_spam: true }, }); - expect(networkService.get.mock.calls[3][0].url).toBe( + expect(networkService.get.mock.calls[4][0].url).toBe( `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); - expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, params: { vs_currencies: currency.toLowerCase(), @@ -991,17 +1000,17 @@ describe('Safes Controller Overview (Unit)', () => { ].join(','), }, }); - expect(networkService.get.mock.calls[4][0].url).toBe( + expect(networkService.get.mock.calls[5][0].url).toBe( `${pricesProviderUrl}/simple/price`, ); - expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[5][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, params: { ids: chain.pricesProvider.nativeCoin, vs_currencies: currency.toLowerCase(), }, }); - expect(networkService.get.mock.calls[5][0].url).toBe( + expect(networkService.get.mock.calls[6][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeInfo.address}/multisig-transactions/`, ); }); @@ -1111,7 +1120,7 @@ describe('Safes Controller Overview (Unit)', () => { }, ]); - expect(networkService.get.mock.calls.length).toBe(6); + expect(networkService.get.mock.calls.length).toBe(7); expect(networkService.get.mock.calls[0][0].url).toBe( `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, @@ -1120,16 +1129,19 @@ describe('Safes Controller Overview (Unit)', () => { `${chain.transactionService}/api/v1/safes/${safeInfo.address}`, ); expect(networkService.get.mock.calls[2][0].url).toBe( + `${chain.transactionService}/api/v1/safes/${safeInfo.address}`, + ); + expect(networkService.get.mock.calls[3][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeInfo.address}/balances/`, ); - expect(networkService.get.mock.calls[2][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ // Forwarded params params: { trusted: true, exclude_spam: false }, }); - expect(networkService.get.mock.calls[3][0].url).toBe( + expect(networkService.get.mock.calls[4][0].url).toBe( `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); - expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, params: { vs_currencies: currency.toLowerCase(), @@ -1139,17 +1151,17 @@ describe('Safes Controller Overview (Unit)', () => { ].join(','), }, }); - expect(networkService.get.mock.calls[4][0].url).toBe( + expect(networkService.get.mock.calls[5][0].url).toBe( `${pricesProviderUrl}/simple/price`, ); - expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[5][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, params: { ids: chain.pricesProvider.nativeCoin, vs_currencies: currency.toLowerCase(), }, }); - expect(networkService.get.mock.calls[5][0].url).toBe( + expect(networkService.get.mock.calls[6][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeInfo.address}/multisig-transactions/`, ); }); From 88d529a98e48dfb35cd0512e6a382866ff77239f Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 6 Jun 2024 12:00:24 +0200 Subject: [PATCH 060/207] Add transaction hash to `Transaction` entity (#1614) This adds a new `txHash` property to the `Transaction` entity: - Add `txHash` to `Transaction` entity - Populate `txHash` in creation, module, multisig and transfer mappers - Update tests accordingly --- ...rs-by-safe.transactions.controller.spec.ts | 4 +++ ...ns-by-safe.transactions.controller.spec.ts | 2 ++ ...ns-by-safe.transactions.controller.spec.ts | 3 ++ .../entities/transaction.entity.ts | 4 +++ .../creation-transaction.mapper.ts | 1 + .../module-transaction.mapper.ts | 1 + .../multisig-transaction.mapper.ts | 1 + .../mappers/transfers/transfer.mapper.spec.ts | 7 +++++ .../mappers/transfers/transfer.mapper.ts | 1 + .../transactions-history.controller.spec.ts | 3 ++ ....imitation-transactions.controller.spec.ts | 28 +++++++++++++++++++ 11 files changed, 55 insertions(+) diff --git a/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts index 5b2711731e..6f96847daa 100644 --- a/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts @@ -235,6 +235,7 @@ describe('List incoming transfers by Safe - Transactions Controller (Unit)', () type: 'TRANSACTION', transaction: { id: `transfer_${safe.address}_e1015fc6905`, + txHash: erc20Transfer.transactionHash, executionInfo: null, safeAppInfo: null, timestamp: erc20Transfer.executionDate.getTime(), @@ -319,6 +320,7 @@ describe('List incoming transfers by Safe - Transactions Controller (Unit)', () type: 'TRANSACTION', transaction: { id: `transfer_${safe.address}_e1015fc6905`, + txHash: erc20Transfer.transactionHash, executionInfo: null, safeAppInfo: null, timestamp: erc20Transfer.executionDate.getTime(), @@ -455,6 +457,7 @@ describe('List incoming transfers by Safe - Transactions Controller (Unit)', () type: 'TRANSACTION', transaction: { id: `transfer_${safe.address}_e1015fc6905`, + txHash: erc721Transfer.transactionHash, timestamp: erc721Transfer.executionDate.getTime(), txStatus: 'SUCCESS', txInfo: { @@ -525,6 +528,7 @@ describe('List incoming transfers by Safe - Transactions Controller (Unit)', () type: 'TRANSACTION', transaction: { id: `transfer_${safe.address}_e1015fc690`, + txHash: nativeTokenTransfer.transactionHash, timestamp: nativeTokenTransfer.executionDate.getTime(), txStatus: 'SUCCESS', txInfo: { diff --git a/src/routes/transactions/__tests__/controllers/list-module-transactions-by-safe.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/list-module-transactions-by-safe.transactions.controller.spec.ts index 6e25ec2176..89999be8bd 100644 --- a/src/routes/transactions/__tests__/controllers/list-module-transactions-by-safe.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/list-module-transactions-by-safe.transactions.controller.spec.ts @@ -208,6 +208,7 @@ describe('List module transactions by Safe - Transactions Controller (Unit)', () type: 'TRANSACTION', transaction: { id: `module_${moduleTransaction1.safe}_${moduleTransaction1.moduleTransactionId}`, + txHash: moduleTransaction1.transactionHash, safeAppInfo: null, timestamp: moduleTransaction1.executionDate.getTime(), txStatus: expect.any(String), @@ -225,6 +226,7 @@ describe('List module transactions by Safe - Transactions Controller (Unit)', () type: 'TRANSACTION', transaction: { id: `module_${moduleTransaction2.safe}_${moduleTransaction2.moduleTransactionId}`, + txHash: moduleTransaction2.transactionHash, safeAppInfo: null, timestamp: moduleTransaction2.executionDate.getTime(), txStatus: expect.any(String), diff --git a/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts index 8867120974..4f3ab6de92 100644 --- a/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts @@ -262,6 +262,7 @@ describe('List multisig transactions by Safe - Transactions Controller (Unit)', type: 'TRANSACTION', transaction: { id: `multisig_${safe.address}_0x31d44c6`, + txHash: multisigTransaction.transactionHash, timestamp: multisigTransaction.executionDate?.getTime(), txStatus: 'SUCCESS', txInfo: { @@ -385,6 +386,7 @@ describe('List multisig transactions by Safe - Transactions Controller (Unit)', type: 'TRANSACTION', transaction: { id: `multisig_${safe.address}_0x0f9f1b72`, + txHash: multisigTransaction.transactionHash, timestamp: 1655853152000, txStatus: 'SUCCESS', txInfo: { @@ -483,6 +485,7 @@ describe('List multisig transactions by Safe - Transactions Controller (Unit)', type: 'TRANSACTION', transaction: { id: `multisig_${domainTransaction.safe}_${domainTransaction.safeTxHash}`, + txHash: domainTransaction.transactionHash, timestamp: domainTransaction.executionDate?.getTime(), txStatus: 'SUCCESS', txInfo: { diff --git a/src/routes/transactions/entities/transaction.entity.ts b/src/routes/transactions/entities/transaction.entity.ts index 16dc5b61a0..b0fed02a94 100644 --- a/src/routes/transactions/entities/transaction.entity.ts +++ b/src/routes/transactions/entities/transaction.entity.ts @@ -28,6 +28,8 @@ export class Transaction { @ApiProperty() id: string; @ApiProperty() + txHash: `0x${string}` | null; + @ApiProperty() timestamp: number | null; @ApiProperty() txStatus: string; @@ -59,6 +61,7 @@ export class Transaction { txInfo: TransactionInfo, executionInfo: ExecutionInfo | null = null, safeAppInfo: SafeAppInfo | null = null, + txHash: `0x${string}` | null = null, ) { this.id = id; this.timestamp = timestamp; @@ -66,5 +69,6 @@ export class Transaction { this.txInfo = txInfo; this.executionInfo = executionInfo; this.safeAppInfo = safeAppInfo; + this.txHash = txHash; } } diff --git a/src/routes/transactions/mappers/creation-transaction/creation-transaction.mapper.ts b/src/routes/transactions/mappers/creation-transaction/creation-transaction.mapper.ts index 51d655b6a1..87c1b10205 100644 --- a/src/routes/transactions/mappers/creation-transaction/creation-transaction.mapper.ts +++ b/src/routes/transactions/mappers/creation-transaction/creation-transaction.mapper.ts @@ -48,6 +48,7 @@ export class CreationTransactionMapper { txInfo, null, null, + transaction.transactionHash, ); } } diff --git a/src/routes/transactions/mappers/module-transactions/module-transaction.mapper.ts b/src/routes/transactions/mappers/module-transactions/module-transaction.mapper.ts index 41a10285db..76a666ad18 100644 --- a/src/routes/transactions/mappers/module-transactions/module-transaction.mapper.ts +++ b/src/routes/transactions/mappers/module-transactions/module-transaction.mapper.ts @@ -40,6 +40,7 @@ export class ModuleTransactionMapper { txInfo, executionInfo, null, + transaction.transactionHash, ); } } diff --git a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction.mapper.ts b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction.mapper.ts index ce24c18e6d..5fd78f5b26 100644 --- a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction.mapper.ts +++ b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction.mapper.ts @@ -47,6 +47,7 @@ export class MultisigTransactionMapper { txInfo, executionInfo, safeAppInfo, + transaction.transactionHash, ); } } diff --git a/src/routes/transactions/mappers/transfers/transfer.mapper.spec.ts b/src/routes/transactions/mappers/transfers/transfer.mapper.spec.ts index 82cf0bb008..3fa326e89a 100644 --- a/src/routes/transactions/mappers/transfers/transfer.mapper.spec.ts +++ b/src/routes/transactions/mappers/transfers/transfer.mapper.spec.ts @@ -66,6 +66,7 @@ describe('Transfer mapper (Unit)', () => { txInfo: expect.any(TransferTransactionInfo), executionInfo: null, safeAppInfo: null, + txHash: transfer.transactionHash, }, ]); }); @@ -106,6 +107,7 @@ describe('Transfer mapper (Unit)', () => { txInfo: expect.any(TransferTransactionInfo), executionInfo: null, safeAppInfo: null, + txHash: transfer.transactionHash, }, ]); }); @@ -146,6 +148,7 @@ describe('Transfer mapper (Unit)', () => { txInfo: expect.any(TransferTransactionInfo), executionInfo: null, safeAppInfo: null, + txHash: transfer.transactionHash, }, ]); }); @@ -210,6 +213,7 @@ describe('Transfer mapper (Unit)', () => { txInfo: expect.any(TransferTransactionInfo), executionInfo: null, safeAppInfo: null, + txHash: transfer.transactionHash, }, ]); }); @@ -323,6 +327,7 @@ describe('Transfer mapper (Unit)', () => { txInfo: expect.any(TransferTransactionInfo), executionInfo: null, safeAppInfo: null, + txHash: nativeTransfer.transactionHash, }, expect.objectContaining({ id: `transfer_${safe.address}_${erc721Transfer.transferId}`, @@ -331,6 +336,7 @@ describe('Transfer mapper (Unit)', () => { txInfo: expect.any(TransferTransactionInfo), executionInfo: null, safeAppInfo: null, + txHash: erc721Transfer.transactionHash, }), expect.objectContaining({ id: `transfer_${safe.address}_${trustedErc20TransferWithValue.transferId}`, @@ -339,6 +345,7 @@ describe('Transfer mapper (Unit)', () => { txInfo: expect.any(TransferTransactionInfo), executionInfo: null, safeAppInfo: null, + txHash: trustedErc20TransferWithValue.transactionHash, }), ]); }); diff --git a/src/routes/transactions/mappers/transfers/transfer.mapper.ts b/src/routes/transactions/mappers/transfers/transfer.mapper.ts index 3cb688cc35..31436e5856 100644 --- a/src/routes/transactions/mappers/transfers/transfer.mapper.ts +++ b/src/routes/transactions/mappers/transfers/transfer.mapper.ts @@ -27,6 +27,7 @@ export class TransferMapper { await this.transferInfoMapper.mapTransferInfo(chainId, transfer, safe), null, null, + transfer.transactionHash, ); } diff --git a/src/routes/transactions/transactions-history.controller.spec.ts b/src/routes/transactions/transactions-history.controller.spec.ts index 58170f0ada..bf60e49915 100644 --- a/src/routes/transactions/transactions-history.controller.spec.ts +++ b/src/routes/transactions/transactions-history.controller.spec.ts @@ -543,6 +543,7 @@ describe('Transactions History Controller (Unit)', () => { type: 'TRANSACTION', transaction: { id: `module_${safe.address}_i5a6754140f0432d3b`, + txHash: moduleTransaction.transactionHash, safeAppInfo: null, timestamp: moduleTransaction.executionDate.getTime(), txStatus: 'SUCCESS', @@ -571,6 +572,7 @@ describe('Transactions History Controller (Unit)', () => { type: 'TRANSACTION', transaction: { id: `multisig_${safe.address}_0x31d44c67`, + txHash: multisigTransaction.transactionHash, timestamp: 1668583871000, txStatus: 'SUCCESS', txInfo: { @@ -610,6 +612,7 @@ describe('Transactions History Controller (Unit)', () => { type: 'TRANSACTION', transaction: { id: `transfer_${safe.address}_e1015fc6905859c69`, + txHash: nativeTokenTransfer.transactionHash, executionInfo: null, safeAppInfo: null, timestamp: nativeTokenTransfer.executionDate.getTime(), 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 dc1805319b..89192e477a 100644 --- a/src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts +++ b/src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts @@ -380,6 +380,8 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = type: 'Transfer', }, txStatus: 'SUCCESS', + txHash: + imitationIncomingTransaction.transfers![0].transactionHash, }, type: 'TRANSACTION', }, @@ -424,6 +426,7 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = type: 'Transfer', }, txStatus: 'SUCCESS', + txHash: multisigTransaction.transactionHash, }, type: 'TRANSACTION', }, @@ -463,6 +466,8 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = type: 'Transfer', }, txStatus: 'SUCCESS', + txHash: + imitationOutgoingTransaction.transfers![0].transactionHash, }, type: 'TRANSACTION', }, @@ -507,6 +512,7 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = type: 'Transfer', }, txStatus: 'SUCCESS', + txHash: notImitatedMultisigTransaction.transactionHash, }, type: 'TRANSACTION', }, @@ -551,6 +557,7 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = type: 'Transfer', }, txStatus: 'SUCCESS', + txHash: multisigTransaction.transactionHash, }, type: 'TRANSACTION', }, @@ -649,6 +656,8 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = type: 'Transfer', }, txStatus: 'SUCCESS', + txHash: + imitationIncomingTransaction.transfers![0].transactionHash, }, type: 'TRANSACTION', }, @@ -688,6 +697,8 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = type: 'Transfer', }, txStatus: 'SUCCESS', + txHash: + imitationOutgoingTransaction.transfers![0].transactionHash, }, type: 'TRANSACTION', }, @@ -732,6 +743,7 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = type: 'Transfer', }, txStatus: 'SUCCESS', + txHash: notImitatedMultisigTransaction.transactionHash, }, type: 'TRANSACTION', }, @@ -776,6 +788,7 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = type: 'Transfer', }, txStatus: 'SUCCESS', + txHash: notImitatedMultisigTransaction.transactionHash, }, type: 'TRANSACTION', }, @@ -820,6 +833,7 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = type: 'Transfer', }, txStatus: 'SUCCESS', + txHash: multisigTransaction.transactionHash, }, type: 'TRANSACTION', }, @@ -922,6 +936,7 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = type: 'Transfer', }, txStatus: 'SUCCESS', + txHash: multisigTransaction.transactionHash, }, type: 'TRANSACTION', }, @@ -965,6 +980,7 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = }, type: 'Transfer', }, + txHash: notImitatedMultisigTransaction.transactionHash, txStatus: 'SUCCESS', }, type: 'TRANSACTION', @@ -1010,6 +1026,7 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = type: 'Transfer', }, txStatus: 'SUCCESS', + txHash: multisigTransaction.transactionHash, }, type: 'TRANSACTION', }, @@ -1107,6 +1124,8 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = type: 'Transfer', }, txStatus: 'SUCCESS', + txHash: + imitationIncomingTransaction.transfers![0].transactionHash, }, type: 'TRANSACTION', }, @@ -1146,6 +1165,8 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = type: 'Transfer', }, txStatus: 'SUCCESS', + txHash: + imitationOutgoingTransaction.transfers![0].transactionHash, }, type: 'TRANSACTION', }, @@ -1190,6 +1211,7 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = type: 'Transfer', }, txStatus: 'SUCCESS', + txHash: notImitatedMultisigTransaction.transactionHash, }, type: 'TRANSACTION', }, @@ -1234,6 +1256,7 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = type: 'Transfer', }, txStatus: 'SUCCESS', + txHash: notImitatedMultisigTransaction.transactionHash, }, type: 'TRANSACTION', }, @@ -1278,6 +1301,7 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = type: 'Transfer', }, txStatus: 'SUCCESS', + txHash: multisigTransaction.transactionHash, }, type: 'TRANSACTION', }, @@ -1415,6 +1439,9 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = type: 'Transfer', }, txStatus: 'SUCCESS', + txHash: + imitationWithDifferentDecimalsIncomingTransaction.transfers![0] + .transactionHash, }, type: 'TRANSACTION', }, @@ -1459,6 +1486,7 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = type: 'Transfer', }, txStatus: 'SUCCESS', + txHash: multisigTransaction.transactionHash, }, type: 'TRANSACTION', }, From 09ef88e49beeae64f5f012834cba404748df514d Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 6 Jun 2024 12:01:23 +0200 Subject: [PATCH 061/207] Add schemas for Zerion entities (#1611) This adds the relative validation schemas for the balances/collectibles from Zeriod and parses against them to ensure validity: - Create `ZerionCollectiblesSchema` (and sub-schemas) - Create `ZerionBalancesSchema` (and sub-schemas) - Validate relevant data against the above in `ZerionBalancesApi` - Update tests accordingly --- .../zerion-collectible.entity.builder.ts | 5 +- .../entities/zerion-balance.entity.ts | 107 +++++++++------- .../entities/zerion-collectible.entity.ts | 117 ++++++++++++------ .../zerion-balances-api.service.ts | 17 ++- .../zerion-collectibles.controller.spec.ts | 2 +- 5 files changed, 156 insertions(+), 92 deletions(-) diff --git a/src/datasources/balances-api/entities/__tests__/zerion-collectible.entity.builder.ts b/src/datasources/balances-api/entities/__tests__/zerion-collectible.entity.builder.ts index 99a633bbfa..7939605528 100644 --- a/src/datasources/balances-api/entities/__tests__/zerion-collectible.entity.builder.ts +++ b/src/datasources/balances-api/entities/__tests__/zerion-collectible.entity.builder.ts @@ -7,6 +7,7 @@ import { ZerionNFTInfo, } from '@/datasources/balances-api/entities/zerion-collectible.entity'; import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; export function zerionNFTInfoBuilder(): IBuilder { return new Builder() @@ -14,7 +15,7 @@ export function zerionNFTInfoBuilder(): IBuilder { preview: { url: faker.internet.url({ appendSlash: false }) }, detail: { url: faker.internet.url({ appendSlash: false }) }, }) - .with('contract_address', faker.finance.ethereumAddress()) + .with('contract_address', getAddress(faker.finance.ethereumAddress())) .with('flags', { is_spam: faker.datatype.boolean() }) .with('interface', faker.string.alphanumeric()) .with('name', faker.string.alphanumeric()) @@ -34,7 +35,7 @@ export function zerionCollectionInfoBuilder(): IBuilder { export function zerionCollectibleAttributesBuilder(): IBuilder { return new Builder() .with('amount', faker.string.numeric()) - .with('changed_at', faker.date.recent().toString()) + .with('changed_at', faker.date.recent()) .with('collection_info', zerionCollectionInfoBuilder().build()) .with('nft_info', zerionNFTInfoBuilder().build()) .with('price', faker.number.float()) diff --git a/src/datasources/balances-api/entities/zerion-balance.entity.ts b/src/datasources/balances-api/entities/zerion-balance.entity.ts index 628ad168ff..05ccdf308e 100644 --- a/src/datasources/balances-api/entities/zerion-balance.entity.ts +++ b/src/datasources/balances-api/entities/zerion-balance.entity.ts @@ -3,46 +3,67 @@ * Reference documentation: https://developers.zerion.io/reference/listwalletpositions */ -export interface ZerionFungibleInfo { - name: string | null; - symbol: string | null; - description: string | null; - icon: { url: string | null } | null; - implementations: ZerionImplementation[]; -} - -export interface ZerionImplementation { - chain_id: string; - address: string | null; - decimals: number; -} - -export interface ZerionQuantity { - int: string; - decimals: number; - float: number; - numeric: string; -} - -export interface ZerionFlags { - displayable: boolean; -} - -export interface ZerionAttributes { - name: string; - quantity: ZerionQuantity; - value: number | null; - price: number; - fungible_info: ZerionFungibleInfo; - flags: ZerionFlags; -} - -export interface ZerionBalance { - type: 'positions'; - id: string; - attributes: ZerionAttributes; -} - -export interface ZerionBalances { - data: ZerionBalance[]; -} +import { z } from 'zod'; + +export type ZerionFungibleInfo = z.infer; + +export type ZerionImplementation = z.infer; + +export type ZerionQuantity = z.infer; + +export type ZerionFlags = z.infer; + +export type ZerionAttributes = z.infer; + +export type ZerionBalance = z.infer; + +export type ZerionBalances = z.infer; + +const ZerionImplementationSchema = z.object({ + chain_id: z.string(), + address: z.string().nullable(), + decimals: z.number(), +}); + +const ZerionFungibleInfoSchema = z.object({ + name: z.string().nullable(), + symbol: z.string().nullable(), + description: z.string().nullable(), + icon: z + .object({ + url: z.string().nullable(), + }) + .nullish() + .default(null), + implementations: z.array(ZerionImplementationSchema), +}); + +const ZerionQuantitySchema = z.object({ + int: z.string(), + decimals: z.number(), + float: z.number(), + numeric: z.string(), +}); + +const ZerionFlagsSchema = z.object({ + displayable: z.boolean(), +}); + +const ZerionAttributesSchema = z.object({ + name: z.string(), + quantity: ZerionQuantitySchema, + value: z.number().nullable(), + price: z.number(), + fungible_info: ZerionFungibleInfoSchema, + flags: ZerionFlagsSchema, +}); + +export const ZerionBalanceSchema = z.object({ + type: z.literal('positions'), + id: z.string(), + attributes: ZerionAttributesSchema, +}); + +export const ZerionBalancesSchema = z.object({ + data: z.array(ZerionBalanceSchema), +}); diff --git a/src/datasources/balances-api/entities/zerion-collectible.entity.ts b/src/datasources/balances-api/entities/zerion-collectible.entity.ts index adc9772e6e..25ec5e17fb 100644 --- a/src/datasources/balances-api/entities/zerion-collectible.entity.ts +++ b/src/datasources/balances-api/entities/zerion-collectible.entity.ts @@ -1,40 +1,77 @@ -export interface ZerionCollectionInfo { - content: { - icon: { url: string }; - banner: { url: string }; - } | null; - description: string | null; - name: string | null; -} - -export interface ZerionNFTInfo { - content: { - preview: { url: string } | null; - detail: { url: string } | null; - } | null; - contract_address: string; - flags: { is_spam: boolean } | null; - interface: string | null; - name: string | null; - token_id: string; -} - -export interface ZerionCollectibleAttributes { - amount: string; - changed_at: string; - collection_info: ZerionCollectionInfo | null; - nft_info: ZerionNFTInfo; - price: number; - value: number; -} - -export interface ZerionCollectible { - attributes: ZerionCollectibleAttributes; - id: string; - type: 'nft_positions'; -} - -export interface ZerionCollectibles { - data: ZerionCollectible[]; - links: { next: string | null }; -} +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; +import { z } from 'zod'; + +export type ZerionCollectionInfo = z.infer; + +export type ZerionNFTInfo = z.infer; + +export type ZerionCollectibleAttributes = z.infer< + typeof ZerionCollectibleAttributesSchema +>; + +export type ZerionCollectible = z.infer; + +export type ZerionCollectibles = z.infer; + +const ZerionCollectionInfoSchema = z.object({ + content: z + .object({ + icon: z.object({ + url: z.string(), + }), + banner: z.object({ + url: z.string(), + }), + }) + .nullable(), + description: z.string().nullable(), + name: z.string().nullable(), +}); + +const ZerionNFTInfoSchema = z.object({ + content: z + .object({ + preview: z + .object({ + url: z.string(), + }) + .nullable(), + detail: z + .object({ + url: z.string(), + }) + .nullable(), + }) + .nullable(), + contract_address: AddressSchema, + flags: z + .object({ + is_spam: z.boolean(), + }) + .nullable(), + interface: z.string().nullable(), + name: z.string().nullable(), + token_id: z.string(), +}); + +const ZerionCollectibleAttributesSchema = z.object({ + amount: z.string(), + changed_at: z.coerce.date(), + collection_info: ZerionCollectionInfoSchema.nullable(), + nft_info: ZerionNFTInfoSchema, + price: z.number(), + value: z.number(), +}); + +const ZerionCollectibleSchema = z.object({ + attributes: ZerionCollectibleAttributesSchema, + id: z.string(), + type: z.literal('nft_positions'), +}); + +export const ZerionCollectiblesSchema = z.object({ + data: z.array(ZerionCollectibleSchema), + links: z.object({ + next: z.string().nullable(), + }), +}); diff --git a/src/datasources/balances-api/zerion-balances-api.service.ts b/src/datasources/balances-api/zerion-balances-api.service.ts index 55f7ae412b..d6e09f6a25 100644 --- a/src/datasources/balances-api/zerion-balances-api.service.ts +++ b/src/datasources/balances-api/zerion-balances-api.service.ts @@ -3,11 +3,14 @@ import { ChainAttributes } from '@/datasources/balances-api/entities/provider-ch import { ZerionAttributes, ZerionBalance, + ZerionBalanceSchema, ZerionBalances, + ZerionBalancesSchema, } from '@/datasources/balances-api/entities/zerion-balance.entity'; import { ZerionCollectible, ZerionCollectibles, + ZerionCollectiblesSchema, } from '@/datasources/balances-api/entities/zerion-collectible.entity'; import { CacheRouter } from '@/datasources/cache/cache.router'; import { @@ -33,6 +36,7 @@ import { IBalancesApi } from '@/domain/interfaces/balances-api.interface'; import { ILoggingService, LoggingService } from '@/logging/logging.interface'; import { Inject, Injectable } from '@nestjs/common'; import { getAddress } from 'viem'; +import { z } from 'zod'; export const IZerionBalancesApi = Symbol('IZerionBalancesApi'); @@ -99,8 +103,9 @@ export class ZerionBalancesApi implements IBalancesApi { if (cached != null) { const { key, field } = cacheDir; this.loggingService.debug({ type: 'cache_hit', key, field }); - // TODO: create a ZerionBalance type with guard to avoid these type assertions. - const zerionBalances: ZerionBalance[] = JSON.parse(cached); + const zerionBalances = z + .array(ZerionBalanceSchema) + .parse(JSON.parse(cached)); return this._mapBalances(chainName, zerionBalances); } @@ -121,9 +126,10 @@ export class ZerionBalancesApi implements IBalancesApi { url, networkRequest, }); + const zerionBalances = ZerionBalancesSchema.parse(data); await this.cacheService.set( cacheDir, - JSON.stringify(data.data), + JSON.stringify(zerionBalances.data), this.defaultExpirationTimeInSeconds, ); return this._mapBalances(chainName, data.data); @@ -155,8 +161,7 @@ export class ZerionBalancesApi implements IBalancesApi { if (cached != null) { const { key, field } = cacheDir; this.loggingService.debug({ type: 'cache_hit', key, field }); - // TODO: create a ZerionCollectibles type with guard to avoid these type assertions. - const data: ZerionCollectibles = JSON.parse(cached); + const data = ZerionCollectiblesSchema.parse(JSON.parse(cached)); return this._buildCollectiblesPage(data.links.next, data.data); } else { try { @@ -293,7 +298,7 @@ export class ZerionBalancesApi implements IBalancesApi { ): Collectible[] { return zerionCollectibles.map( ({ attributes: { nft_info, collection_info } }) => ({ - address: getAddress(nft_info.contract_address), + address: nft_info.contract_address, tokenName: nft_info.name ?? '', tokenSymbol: nft_info.name ?? '', logoUri: collection_info?.content?.icon.url ?? '', diff --git a/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts b/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts index 4d1d46d0a0..495c9897f3 100644 --- a/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts +++ b/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts @@ -76,7 +76,7 @@ describe('Zerion Collectibles Controller', () => { it('successfully gets collectibles from Zerion', async () => { const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); const safeAddress = getAddress(faker.finance.ethereumAddress()); - const aTokenAddress = faker.finance.ethereumAddress(); + const aTokenAddress = getAddress(faker.finance.ethereumAddress()); const aNFTName = faker.string.sample(); const aUrl = faker.internet.url({ appendSlash: false }); const zerionApiCollectiblesResponse = zerionCollectiblesBuilder() From 7af88b309feb93cedcf8d9b43c6f1d86f0daef13 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 6 Jun 2024 12:01:43 +0200 Subject: [PATCH 062/207] Add and validate against schema for `AssetPrice` (#1610) Adds an `AssetPriceSchema` for validating against (with test coverage) and usage of it in the `CoingeckoApi`: - Create `AssetPriceSchema` (with test) - Validate cache against it --- .../balances-api/coingecko-api.service.ts | 8 ++- .../__tests__/asset-price.schema.spec.ts | 60 +++++++++++++++++++ .../entities/asset-price.entity.ts | 9 ++- 3 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 src/datasources/balances-api/entities/__tests__/asset-price.schema.spec.ts diff --git a/src/datasources/balances-api/coingecko-api.service.ts b/src/datasources/balances-api/coingecko-api.service.ts index f20cf9ec49..914830fe87 100644 --- a/src/datasources/balances-api/coingecko-api.service.ts +++ b/src/datasources/balances-api/coingecko-api.service.ts @@ -1,7 +1,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { IPricesApi } from '@/datasources/balances-api/prices-api.interface'; -import { AssetPrice } from '@/datasources/balances-api/entities/asset-price.entity'; +import { + AssetPrice, + AssetPriceSchema, +} from '@/datasources/balances-api/entities/asset-price.entity'; import { CacheFirstDataSource } from '../cache/cache.first.data.source'; import { CacheRouter } from '../cache/cache.router'; import { DataSourceError } from '@/domain/errors/data-source.error'; @@ -246,8 +249,7 @@ export class CoingeckoApi implements IPricesApi { const { key, field } = cacheDir; if (cached != null) { this.loggingService.debug({ type: 'cache_hit', key, field }); - // TODO: build an AssetPrice validator or a type guard to ensure the cache value is valid. - const cachedAssetPrice: AssetPrice = JSON.parse(cached); + const cachedAssetPrice = AssetPriceSchema.parse(JSON.parse(cached)); result.push(cachedAssetPrice); } else { this.loggingService.debug({ type: 'cache_miss', key, field }); diff --git a/src/datasources/balances-api/entities/__tests__/asset-price.schema.spec.ts b/src/datasources/balances-api/entities/__tests__/asset-price.schema.spec.ts new file mode 100644 index 0000000000..51cca0834e --- /dev/null +++ b/src/datasources/balances-api/entities/__tests__/asset-price.schema.spec.ts @@ -0,0 +1,60 @@ +import { AssetPriceSchema } from '@/datasources/balances-api/entities/asset-price.entity'; +import { faker } from '@faker-js/faker'; + +describe('AssetPriceSchema', () => { + it('should allow an object with string keys and a mixture of number and null values', () => { + const assetPrice = { + [faker.finance.ethereumAddress()]: { + [faker.finance.currencyCode()]: faker.helpers.arrayElement([ + faker.number.float(), + null, + ]), + }, + }; + + const result = AssetPriceSchema.safeParse(assetPrice); + + expect(result.success).toBe(true); + }); + + it('should not validate an object with non-object values', () => { + const address = faker.finance.ethereumAddress(); + const assetPrice = { + [address]: faker.number.int(), + }; + + const result = AssetPriceSchema.safeParse(assetPrice); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected: 'object', + message: 'Expected object, received number', + path: [address], + received: 'number', + }, + ]); + }); + + it('should not validate an object with non-number or null values in the nested object', () => { + const address = faker.finance.ethereumAddress(); + const currency = faker.finance.currencyCode(); + const assetPrice = { + [address]: { + [currency]: faker.string.alphanumeric(), + }, + }; + + const result = AssetPriceSchema.safeParse(assetPrice); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected: 'number', + message: 'Expected number, received string', + path: [address, currency], + received: 'string', + }, + ]); + }); +}); diff --git a/src/datasources/balances-api/entities/asset-price.entity.ts b/src/datasources/balances-api/entities/asset-price.entity.ts index a8f6bcacad..3be1dcdfb5 100644 --- a/src/datasources/balances-api/entities/asset-price.entity.ts +++ b/src/datasources/balances-api/entities/asset-price.entity.ts @@ -1,3 +1,6 @@ -export interface AssetPrice { - [assetName: string]: Record; -} +import { z } from 'zod'; + +export type AssetPrice = z.infer; + +// TODO: Enforce Ethereum address keys (and maybe checksum them) +export const AssetPriceSchema = z.record(z.record(z.number().nullable())); From 520ead328a7e5e68aff485e583e134b288262260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Thu, 6 Jun 2024 19:53:14 +0200 Subject: [PATCH 063/207] Check fiatCode before getting Zerion balances (#1617) Check fiatCode before calling Zerion --- .../entities/__tests__/configuration.ts | 2 +- .../zerion-balances-api.service.ts | 8 ++- .../zerion-balances.controller.spec.ts | 55 ++++++++++++++++--- 3 files changed, 54 insertions(+), 11 deletions(-) diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index ce325c2eae..c3de1ba7e9 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -61,7 +61,7 @@ export default (): ReturnType => ({ ]), ), limitPeriodSeconds: faker.number.int({ min: 1, max: 10 }), - limitCalls: faker.number.int({ min: 1, max: 10 }), + limitCalls: faker.number.int({ min: 1, max: 5 }), }, }, }, diff --git a/src/datasources/balances-api/zerion-balances-api.service.ts b/src/datasources/balances-api/zerion-balances-api.service.ts index d6e09f6a25..17fe16201c 100644 --- a/src/datasources/balances-api/zerion-balances-api.service.ts +++ b/src/datasources/balances-api/zerion-balances-api.service.ts @@ -96,7 +96,13 @@ export class ZerionBalancesApi implements IBalancesApi { safeAddress: `0x${string}`; fiatCode: string; }): Promise { - // TODO: check the fiatCode is supported. + if (!this.fiatCodes.includes(args.fiatCode.toUpperCase())) { + throw new DataSourceError( + `Unsupported currency code: ${args.fiatCode}`, + 400, + ); + } + const cacheDir = CacheRouter.getZerionBalancesCacheDir(args); const chainName = this._getChainName(args.chainId); const cached = await this.cacheService.get(cacheDir); diff --git a/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts b/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts index c294df657f..19dc2d2b4d 100644 --- a/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts +++ b/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts @@ -34,6 +34,7 @@ import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; +import { sample } from 'lodash'; describe('Balances Controller (Unit)', () => { let app: INestApplication; @@ -41,6 +42,7 @@ describe('Balances Controller (Unit)', () => { let networkService: jest.MockedObjectDeep; let zerionBaseUri: string; let zerionChainIds: string[]; + let zerionCurrencies: string[]; let configurationService: jest.MockedObjectDeep; beforeEach(async () => { @@ -85,6 +87,10 @@ describe('Balances Controller (Unit)', () => { zerionChainIds = configurationService.getOrThrow( 'features.zerionBalancesChainIds', ); + zerionCurrencies = configurationService.getOrThrow( + 'balances.providers.zerion.currencies', + ); + networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); @@ -96,11 +102,11 @@ describe('Balances Controller (Unit)', () => { }); describe('Balances provider: Zerion', () => { - describe('GET /balances (externalized)', () => { + describe('GET /balances', () => { it(`maps native coin + ERC20 token balance correctly, and sorts balances by fiatBalance`, async () => { const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); const safeAddress = getAddress(faker.finance.ethereumAddress()); - const currency = faker.finance.currencyCode(); + const currency = sample(zerionCurrencies); const chainName = configurationService.getOrThrow( `balances.providers.zerion.chains.${chain.chainId}.chainName`, ); @@ -237,7 +243,7 @@ describe('Balances Controller (Unit)', () => { headers: { Authorization: `Basic ${apiKey}` }, params: { 'filter[chain_ids]': chainName, - currency: currency.toLowerCase(), + currency: currency?.toLowerCase(), sort: 'value', }, }); @@ -246,7 +252,7 @@ describe('Balances Controller (Unit)', () => { it('returns large numbers as is (not in scientific notation)', async () => { const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); const safeAddress = getAddress(faker.finance.ethereumAddress()); - const currency = faker.finance.currencyCode(); + const currency = sample(zerionCurrencies); const chainName = configurationService.getOrThrow( `balances.providers.zerion.chains.${chain.chainId}.chainName`, ); @@ -384,11 +390,38 @@ describe('Balances Controller (Unit)', () => { headers: { Authorization: `Basic ${apiKey}` }, params: { 'filter[chain_ids]': chainName, - currency: currency.toLowerCase(), + currency: currency?.toLowerCase(), sort: 'value', }, }); }); + + it('fails when an unsupported fiatCode is provided', async () => { + const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const unsupportedCurrency = faker.string.alpha({ + length: { min: 4, max: 4 }, + exclude: zerionCurrencies, + }); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: + return Promise.resolve({ data: chain, status: 200 }); + default: + return Promise.reject(new Error(`Could not match ${url}`)); + } + }); + + await request(app.getHttpServer()) + .get( + `/v1/chains/${chain.chainId}/safes/${safeAddress}/balances/${unsupportedCurrency}`, + ) + .expect(400) + .expect({ + code: 400, + message: `Unsupported currency code: ${unsupportedCurrency}`, + }); + }); }); describe('Config API Error', () => { @@ -434,7 +467,7 @@ describe('Balances Controller (Unit)', () => { it(`500 error response`, async () => { const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); const safeAddress = faker.finance.ethereumAddress(); - const currency = faker.finance.currencyCode(); + const currency = sample(zerionCurrencies); networkService.get.mockImplementation(({ url }) => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: @@ -462,7 +495,7 @@ describe('Balances Controller (Unit)', () => { it('does not trigger a rate-limit error', async () => { const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); const safeAddress = getAddress(faker.finance.ethereumAddress()); - const currency = faker.finance.currencyCode(); + const currency = sample(zerionCurrencies); const chainName = configurationService.getOrThrow( `balances.providers.zerion.chains.${chain.chainId}.chainName`, ); @@ -584,17 +617,21 @@ describe('Balances Controller (Unit)', () => { const limitCalls = configurationService.getOrThrow( 'balances.providers.zerion.limitCalls', ); + + // Note: each request use a different currency code to avoid cache hits. + // The last request will trigger the rate limit error. + // This assumes the test configuration follows the rule: zerionCurrencies.length > limitCalls for (let i = 0; i < limitCalls; i++) { await request(app.getHttpServer()) .get( - `/v1/chains/${chain.chainId}/safes/${safeAddress}/balances/${crypto.randomUUID()}`, + `/v1/chains/${chain.chainId}/safes/${safeAddress}/balances/${zerionCurrencies[i]}`, ) .expect(200); } await request(app.getHttpServer()) .get( - `/v1/chains/${chain.chainId}/safes/${safeAddress}/balances/${crypto.randomUUID()}`, + `/v1/chains/${chain.chainId}/safes/${safeAddress}/balances/${zerionCurrencies[limitCalls]}`, ) .expect(429); From 4dbbf06e7f76b44635c3339f0accd2bf66798ffb Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Fri, 7 Jun 2024 13:35:20 +0200 Subject: [PATCH 064/207] Refactor SiWe-related logic to use viem (#1618) This migrates all SiWe-related logic to viem. It also adapts our implementation to accept a string `message` and parses it instead of relying on an object: - Modify `ISiweRepository` it to accept a string `message` and` signature` (matching the SiWe standard) - Update tests accordingly --- .../entities/__tests__/configuration.ts | 1 + src/config/entities/configuration.ts | 3 + .../siwe-api/siwe-api.service.spec.ts | 55 +- src/datasources/siwe-api/siwe-api.service.ts | 61 -- .../to-signable-siwe-message.spec.ts | 328 -------- .../utils/to-signable-siwe-message.ts | 53 -- src/domain/interfaces/siwe-api.interface.ts | 9 - .../__tests__/siwe-message.builder.ts | 8 +- .../__tests__/siwe-message.schema.spec.ts | 744 ------------------ .../siwe/entities/siwe-message.entity.ts | 141 ---- src/domain/siwe/siwe.repository.interface.ts | 6 +- src/domain/siwe/siwe.repository.ts | 89 ++- src/routes/auth/auth.controller.spec.ts | 112 +-- src/routes/auth/auth.controller.ts | 16 +- src/routes/auth/auth.service.ts | 20 +- .../__tests__/siwe.dto.entity.spec.ts | 53 ++ .../verify-auth-message.dto.schema.spec.ts | 58 -- src/routes/auth/entities/siwe.dto.entity.ts | 9 + .../verify-auth-message.dto.entity.ts | 13 - 19 files changed, 223 insertions(+), 1556 deletions(-) delete mode 100644 src/datasources/siwe-api/utils/__tests__/to-signable-siwe-message.spec.ts delete mode 100644 src/datasources/siwe-api/utils/to-signable-siwe-message.ts delete mode 100644 src/domain/siwe/entities/__tests__/siwe-message.schema.spec.ts delete mode 100644 src/domain/siwe/entities/siwe-message.entity.ts create mode 100644 src/routes/auth/entities/__tests__/siwe.dto.entity.spec.ts delete mode 100644 src/routes/auth/entities/schemas/__tests__/verify-auth-message.dto.schema.spec.ts create mode 100644 src/routes/auth/entities/siwe.dto.entity.ts delete mode 100644 src/routes/auth/entities/verify-auth-message.dto.entity.ts diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index c3de1ba7e9..95c9f8222c 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -17,6 +17,7 @@ export default (): ReturnType => ({ auth: { token: faker.string.hexadecimal({ length: 32 }), nonceTtlSeconds: faker.number.int(), + maxValidityPeriodSeconds: faker.number.int({ min: 1, max: 60 * 1_000 }), }, balances: { balancesTtlSeconds: faker.number.int(), diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 6bfeb7671a..dc14328c4d 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -29,6 +29,9 @@ export default () => ({ nonceTtlSeconds: parseInt( process.env.AUTH_NONCE_TTL_SECONDS ?? `${5 * 60}`, ), + maxValidityPeriodSeconds: parseInt( + process.env.AUTH_VALIDITY_PERIOD_SECONDS ?? `${15 * 60}`, + ), }, balances: { balancesTtlSeconds: parseInt(process.env.BALANCES_TTL_SECONDS ?? `${300}`), diff --git a/src/datasources/siwe-api/siwe-api.service.spec.ts b/src/datasources/siwe-api/siwe-api.service.spec.ts index adaed83953..d734c196b2 100644 --- a/src/datasources/siwe-api/siwe-api.service.spec.ts +++ b/src/datasources/siwe-api/siwe-api.service.spec.ts @@ -1,16 +1,8 @@ import { FakeConfigurationService } from '@/config/__tests__/fake.configuration.service'; import { SiweApi } from '@/datasources/siwe-api/siwe-api.service'; -import { toSignableSiweMessage } from '@/datasources/siwe-api/utils/to-signable-siwe-message'; import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.service'; import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity'; -import { siweMessageBuilder } from '@/domain/siwe/entities/__tests__/siwe-message.builder'; -import { ILoggingService } from '@/logging/logging.interface'; import { faker } from '@faker-js/faker'; -import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; - -const mockLoggingService = { - debug: jest.fn(), -} as jest.MockedObjectDeep; describe('SiweApiService', () => { let service: SiweApi; @@ -23,52 +15,7 @@ describe('SiweApiService', () => { fakeConfigurationService = new FakeConfigurationService(); fakeCacheService = new FakeCacheService(); fakeConfigurationService.set('auth.nonceTtlSeconds', nonceTtlInSeconds); - service = new SiweApi( - mockLoggingService, - fakeConfigurationService, - fakeCacheService, - ); - }); - - describe('generateNonce', () => { - it('should return an alphanumeric string of at least 8 characters', () => { - const nonce = service.generateNonce(); - expect(nonce).toMatch(/^[a-zA-Z0-9]{8,}$/); - }); - }); - - describe('verifyMessage', () => { - it('should return true if the message is verified', async () => { - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); - const message = siweMessageBuilder() - .with('address', signer.address) - .build(); - const signature = await signer.signMessage({ - message: toSignableSiweMessage(message), - }); - - await expect( - service.verifyMessage({ - message, - signature, - }), - ).resolves.toBe(true); - }); - - it('should return false if the message is not verified', async () => { - const message = siweMessageBuilder().build(); - const signature = faker.string.hexadecimal({ - length: 132, - }) as `0x${string}`; - - await expect( - service.verifyMessage({ - message, - signature, - }), - ).resolves.toBe(false); - }); + service = new SiweApi(fakeConfigurationService, fakeCacheService); }); describe('storeNonce', () => { diff --git a/src/datasources/siwe-api/siwe-api.service.ts b/src/datasources/siwe-api/siwe-api.service.ts index f0eeaecc6e..c8844f0bc8 100644 --- a/src/datasources/siwe-api/siwe-api.service.ts +++ b/src/datasources/siwe-api/siwe-api.service.ts @@ -1,9 +1,5 @@ -import { toSignableSiweMessage } from '@/datasources/siwe-api/utils/to-signable-siwe-message'; -import { SiweMessage } from '@/domain/siwe/entities/siwe-message.entity'; import { ISiweApi } from '@/domain/interfaces/siwe-api.interface'; -import { LoggingService, ILoggingService } from '@/logging/logging.interface'; import { Inject, Injectable } from '@nestjs/common'; -import { verifyMessage } from 'viem'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { CacheService, @@ -13,30 +9,9 @@ import { CacheRouter } from '@/datasources/cache/cache.router'; @Injectable() export class SiweApi implements ISiweApi { - /** - * The official SiWe implementation uses a nonce length of 17: - * - * > 96 bits has been chosen as a number to sufficiently balance size and security - * > considerations relative to the lifespan of it's usage. - * - * ``` - * const ALPHANUMERIC = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; - * const length = Math.ceil(96 / (Math.log(ALPHANUMERIC.length) / Math.LN2)) // 17 - * ``` - * - * @see https://github.com/spruceid/siwe/blob/0e63b05cd3c722abd282dd1128aa8878648a8620/packages/siwe/lib/utils.ts#L36-L53 - * @see https://github.com/StableLib/stablelib/blob/5243520e343c217b6a751464dec1bc980cb510d8/packages/random/random.ts#L80-L99 - * - * As we rely on typed arrays to generate random values, we must use an even number. - * We therefore use a length of 18 to be compatible and remain as similar as possible. - */ - private static readonly NONCE_LENGTH = 18; - private readonly nonceTtlInSeconds: number; constructor( - @Inject(LoggingService) - private readonly loggingService: ILoggingService, @Inject(IConfigurationService) private readonly configurationService: IConfigurationService, @Inject(CacheService) private readonly cacheService: ICacheService, @@ -45,42 +20,6 @@ export class SiweApi implements ISiweApi { 'auth.nonceTtlSeconds', ); } - - /** - * Returns a string-based nonce of at least 8 alphanumeric characters - * according to the EIP-4361 (SiWe) standard. - * - * @see https://eips.ethereum.org/EIPS/eip-4361#message-fields - */ - generateNonce(): string { - // One byte is two hex chars - const length = SiweApi.NONCE_LENGTH / 2; - const randomValues = crypto.getRandomValues(new Uint8Array(length)); - - return Array.from(randomValues, (byte) => { - return byte.toString(16).padStart(2, '0'); - }).join(''); - } - - async verifyMessage(args: { - message: SiweMessage; - signature: `0x${string}`; - }): Promise { - const message = toSignableSiweMessage(args.message); - try { - return await verifyMessage({ - address: args.message.address, - message, - signature: args.signature, - }); - } catch (e) { - this.loggingService.debug( - `Failed to verify SiWe message. message=${message}, error=${e}`, - ); - return false; - } - } - async storeNonce(nonce: string): Promise { const cacheDir = CacheRouter.getAuthNonceCacheDir(nonce); await this.cacheService.set(cacheDir, nonce, this.nonceTtlInSeconds); diff --git a/src/datasources/siwe-api/utils/__tests__/to-signable-siwe-message.spec.ts b/src/datasources/siwe-api/utils/__tests__/to-signable-siwe-message.spec.ts deleted file mode 100644 index dce4b8ddaf..0000000000 --- a/src/datasources/siwe-api/utils/__tests__/to-signable-siwe-message.spec.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { toSignableSiweMessage } from '@/datasources/siwe-api/utils/to-signable-siwe-message'; -import { siweMessageBuilder } from '@/domain/siwe/entities/__tests__/siwe-message.builder'; -import { faker } from '@faker-js/faker'; - -describe('toSignableSiweMessage', () => { - it('should return a signable message with all fields', () => { - const message = siweMessageBuilder() - .with('resources', [ - faker.internet.url(), - faker.internet.url(), - faker.internet.url(), - ]) - .build(); - - const result = toSignableSiweMessage(message); - - // Origin is built correctly from scheme and domain - expect(result) - .toBe(`${message.scheme}://${message.domain} wants you to sign in with your Ethereum account: -${message.address} - -${message.statement} - -URI: ${message.uri} -Version: ${message.version} -Chain ID: ${message.chainId} -Nonce: ${message.nonce} -Issued At: ${message.issuedAt} -Expiration Time: ${message.expirationTime} -Not Before: ${message.notBefore} -Request ID: ${message.requestId} -Resources: -- ${message.resources![0]} -- ${message.resources![1]} -- ${message.resources![2]}`); - }); - - describe('statement', () => { - it('should add a new line before and after the statement', () => { - const message = siweMessageBuilder() - .with('scheme', undefined) - .with('statement', faker.lorem.sentence()) - .with('expirationTime', undefined) - .with('notBefore', undefined) - .with('requestId', undefined) - .with('resources', undefined) - .build(); - - const result = toSignableSiweMessage(message); - - expect(result) - .toBe(`${message.domain} wants you to sign in with your Ethereum account: -${message.address} - -${message.statement} - -URI: ${message.uri} -Version: ${message.version} -Chain ID: ${message.chainId} -Nonce: ${message.nonce} -Issued At: ${message.issuedAt}`); - }); - - it('should not add an empty statement', () => { - const message = siweMessageBuilder() - .with('scheme', undefined) - .with('statement', '') - .with('expirationTime', undefined) - .with('notBefore', undefined) - .with('requestId', undefined) - .with('resources', undefined) - .build(); - - const result = toSignableSiweMessage(message); - - expect(result) - .toBe(`${message.domain} wants you to sign in with your Ethereum account: -${message.address} -URI: ${message.uri} -Version: ${message.version} -Chain ID: ${message.chainId} -Nonce: ${message.nonce} -Issued At: ${message.issuedAt}`); - }); - }); - - describe('expirationTime', () => { - it('should add the expirationTime if present', () => { - const message = siweMessageBuilder() - .with('scheme', undefined) - .with('statement', undefined) - .with('expirationTime', faker.date.recent().toISOString()) - .with('notBefore', undefined) - .with('requestId', undefined) - .with('resources', undefined) - .build(); - - const result = toSignableSiweMessage(message); - - expect(result) - .toBe(`${message.domain} wants you to sign in with your Ethereum account: -${message.address} -URI: ${message.uri} -Version: ${message.version} -Chain ID: ${message.chainId} -Nonce: ${message.nonce} -Issued At: ${message.issuedAt} -Expiration Time: ${message.expirationTime}`); - }); - - it('should not add an empty expirationTime', () => { - const message = siweMessageBuilder() - .with('scheme', undefined) - .with('statement', undefined) - .with('expirationTime', '') - .with('notBefore', undefined) - .with('requestId', undefined) - .with('resources', undefined) - .build(); - - const result = toSignableSiweMessage(message); - - expect(result) - .toBe(`${message.domain} wants you to sign in with your Ethereum account: -${message.address} -URI: ${message.uri} -Version: ${message.version} -Chain ID: ${message.chainId} -Nonce: ${message.nonce} -Issued At: ${message.issuedAt}`); - }); - }); - - describe('notBefore', () => { - it('should add the notBefore time if present', () => { - const message = siweMessageBuilder() - .with('scheme', undefined) - .with('statement', undefined) - .with('expirationTime', undefined) - .with('notBefore', faker.date.recent().toISOString()) - .with('requestId', undefined) - .with('resources', undefined) - .build(); - - const result = toSignableSiweMessage(message); - - expect(result) - .toBe(`${message.domain} wants you to sign in with your Ethereum account: -${message.address} -URI: ${message.uri} -Version: ${message.version} -Chain ID: ${message.chainId} -Nonce: ${message.nonce} -Issued At: ${message.issuedAt} -Not Before: ${message.notBefore}`); - }); - - it('should not add an empty notBefore', () => { - const message = siweMessageBuilder() - .with('scheme', undefined) - .with('statement', undefined) - .with('expirationTime', undefined) - .with('notBefore', '') - .with('requestId', undefined) - .with('resources', undefined) - .build(); - - const result = toSignableSiweMessage(message); - - expect(result) - .toBe(`${message.domain} wants you to sign in with your Ethereum account: -${message.address} -URI: ${message.uri} -Version: ${message.version} -Chain ID: ${message.chainId} -Nonce: ${message.nonce} -Issued At: ${message.issuedAt}`); - }); - }); - - describe('requestId', () => { - it('should add the requestId if present', () => { - const message = siweMessageBuilder() - .with('scheme', undefined) - .with('statement', undefined) - .with('expirationTime', undefined) - .with('notBefore', undefined) - .with('requestId', faker.string.uuid()) - .with('resources', undefined) - .build(); - - const result = toSignableSiweMessage(message); - - expect(result) - .toBe(`${message.domain} wants you to sign in with your Ethereum account: -${message.address} -URI: ${message.uri} -Version: ${message.version} -Chain ID: ${message.chainId} -Nonce: ${message.nonce} -Issued At: ${message.issuedAt} -Request ID: ${message.requestId}`); - }); - - it('should not add an empty requestId', () => { - const message = siweMessageBuilder() - .with('scheme', undefined) - .with('statement', undefined) - .with('expirationTime', undefined) - .with('notBefore', undefined) - .with('requestId', '') - .with('resources', undefined) - .build(); - - const result = toSignableSiweMessage(message); - - expect(result) - .toBe(`${message.domain} wants you to sign in with your Ethereum account: -${message.address} -URI: ${message.uri} -Version: ${message.version} -Chain ID: ${message.chainId} -Nonce: ${message.nonce} -Issued At: ${message.issuedAt}`); - }); - }); - - describe('resources', () => { - it('should add each resource on a new line if present', () => { - const message = siweMessageBuilder() - .with('scheme', undefined) - .with('statement', undefined) - .with('expirationTime', undefined) - .with('notBefore', undefined) - .with('requestId', undefined) - .with('resources', [ - faker.internet.url(), - faker.internet.url(), - faker.internet.url(), - ]) - .build(); - - const result = toSignableSiweMessage(message); - - expect(result) - .toBe(`${message.domain} wants you to sign in with your Ethereum account: -${message.address} -URI: ${message.uri} -Version: ${message.version} -Chain ID: ${message.chainId} -Nonce: ${message.nonce} -Issued At: ${message.issuedAt} -Resources: -- ${message.resources![0]} -- ${message.resources![1]} -- ${message.resources![2]}`); - }); - - it('should not add empty resources', () => { - const message = siweMessageBuilder() - .with('scheme', undefined) - .with('statement', undefined) - .with('expirationTime', undefined) - .with('notBefore', undefined) - .with('requestId', undefined) - .with('resources', []) - .build(); - - const result = toSignableSiweMessage(message); - - expect(result) - .toBe(`${message.domain} wants you to sign in with your Ethereum account: -${message.address} -URI: ${message.uri} -Version: ${message.version} -Chain ID: ${message.chainId} -Nonce: ${message.nonce} -Issued At: ${message.issuedAt}`); - }); - - it('should filter empty resources', () => { - const message = siweMessageBuilder() - .with('scheme', undefined) - .with('statement', undefined) - .with('expirationTime', undefined) - .with('notBefore', undefined) - .with('requestId', undefined) - .with('resources', ['']) - .build(); - - const result = toSignableSiweMessage(message); - - expect(result) - .toBe(`${message.domain} wants you to sign in with your Ethereum account: -${message.address} -URI: ${message.uri} -Version: ${message.version} -Chain ID: ${message.chainId} -Nonce: ${message.nonce} -Issued At: ${message.issuedAt}`); - }); - - it('should filter empty resources', () => { - const message = siweMessageBuilder() - .with('scheme', undefined) - .with('statement', undefined) - .with('expirationTime', undefined) - .with('notBefore', undefined) - .with('requestId', undefined) - .with('resources', [faker.internet.url(), '', faker.internet.url()]) - .build(); - - const result = toSignableSiweMessage(message); - - expect(result) - .toBe(`${message.domain} wants you to sign in with your Ethereum account: -${message.address} -URI: ${message.uri} -Version: ${message.version} -Chain ID: ${message.chainId} -Nonce: ${message.nonce} -Issued At: ${message.issuedAt} -Resources: -- ${message.resources![0]} -- ${message.resources![2]}`); - }); - }); -}); diff --git a/src/datasources/siwe-api/utils/to-signable-siwe-message.ts b/src/datasources/siwe-api/utils/to-signable-siwe-message.ts deleted file mode 100644 index 40d522f840..0000000000 --- a/src/datasources/siwe-api/utils/to-signable-siwe-message.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { SiweMessage } from '@/domain/siwe/entities/siwe-message.entity'; - -/** - * The following adheres to the EIP-4361 (SiWe) standard - * {@link https://eips.ethereum.org/EIPS/eip-4361} - */ -export function toSignableSiweMessage(message: SiweMessage): string { - const lines = []; - - const origin = message.scheme - ? `${message.scheme}://${message.domain}` - : message.domain; - - lines.push( - `${origin} wants you to sign in with your Ethereum account:`, - message.address, - ); - - if (message.statement) { - // Ensure new line above and below statement - lines.push('', message.statement, ''); - } - - lines.push( - `URI: ${message.uri}`, - `Version: ${message.version}`, - `Chain ID: ${message.chainId}`, - `Nonce: ${message.nonce}`, - `Issued At: ${message.issuedAt}`, - ); - - if (message.expirationTime) { - lines.push(`Expiration Time: ${message.expirationTime}`); - } - - if (message.notBefore) { - lines.push(`Not Before: ${message.notBefore}`); - } - - if (message.requestId) { - lines.push(`Request ID: ${message.requestId}`); - } - - if (Array.isArray(message.resources) && message.resources.length > 0) { - const resources = message.resources.filter(Boolean); - - if (resources.length > 0) { - lines.push('Resources:', ...resources.map((resource) => `- ${resource}`)); - } - } - - return lines.join('\n'); -} diff --git a/src/domain/interfaces/siwe-api.interface.ts b/src/domain/interfaces/siwe-api.interface.ts index e9923685d3..3d4c230ae0 100644 --- a/src/domain/interfaces/siwe-api.interface.ts +++ b/src/domain/interfaces/siwe-api.interface.ts @@ -1,15 +1,6 @@ -import { SiweMessage } from '@/domain/siwe/entities/siwe-message.entity'; - export const ISiweApi = Symbol('ISiweApi'); export interface ISiweApi { - generateNonce(): string; - - verifyMessage(args: { - message: SiweMessage; - signature: `0x${string}`; - }): Promise; - storeNonce(nonce: string): Promise; getNonce(nonce: string): Promise; diff --git a/src/domain/siwe/entities/__tests__/siwe-message.builder.ts b/src/domain/siwe/entities/__tests__/siwe-message.builder.ts index 79fb6ae588..d1d0ab6c24 100644 --- a/src/domain/siwe/entities/__tests__/siwe-message.builder.ts +++ b/src/domain/siwe/entities/__tests__/siwe-message.builder.ts @@ -1,5 +1,5 @@ import { Builder, IBuilder } from '@/__tests__/builder'; -import { SiweMessage } from '@/domain/siwe/entities/siwe-message.entity'; +import { SiweMessage } from 'viem/siwe'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; @@ -13,9 +13,9 @@ export function siweMessageBuilder(): IBuilder { .with('version', '1') .with('chainId', faker.number.int({ min: 1 })) .with('nonce', faker.string.alphanumeric({ length: 8 })) - .with('issuedAt', faker.date.recent().toISOString()) - .with('expirationTime', faker.date.future().toISOString()) - .with('notBefore', faker.date.past().toISOString()) + .with('issuedAt', faker.date.recent()) + .with('expirationTime', faker.date.future()) + .with('notBefore', faker.date.past()) .with('requestId', faker.string.uuid()) .with( 'resources', diff --git a/src/domain/siwe/entities/__tests__/siwe-message.schema.spec.ts b/src/domain/siwe/entities/__tests__/siwe-message.schema.spec.ts deleted file mode 100644 index e72b53644f..0000000000 --- a/src/domain/siwe/entities/__tests__/siwe-message.schema.spec.ts +++ /dev/null @@ -1,744 +0,0 @@ -import { getSecondsUntil } from '@/domain/common/utils/time'; -import { siweMessageBuilder } from '@/domain/siwe/entities/__tests__/siwe-message.builder'; -import { getSiweMessageSchema } from '@/domain/siwe/entities/siwe-message.entity'; -import { faker } from '@faker-js/faker'; -import { getAddress } from 'viem'; -import { ZodError } from 'zod'; - -const MAX_VALIDITY_PERIOD_IN_MS = 15 * 60 * 1_000; // 15 minutes - -describe('SiweMessageSchema', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('should validate a SiWe message', () => { - const message = siweMessageBuilder().build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(result.success).toBe(true); - }); - - describe('scheme', () => { - it('should validate with a RFC 3986 URI scheme', () => { - const message = siweMessageBuilder() - .with('scheme', faker.internet.protocol()) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(result.success).toBe(true); - }); - - it('should validate without scheme', () => { - const message = siweMessageBuilder().with('scheme', undefined).build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(result.success).toBe(true); - }); - }); - - describe('domain', () => { - it('should validate with a RFC 3986 URI domain', () => { - const message = siweMessageBuilder() - .with('scheme', undefined) - .with('domain', faker.internet.domainName()) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(result.success).toBe(true); - }); - - it('should not validate a non-RFC 3986 URI domain', () => { - const message = siweMessageBuilder() - .with('scheme', faker.internet.protocol()) - // A scheme is present in the domain - .with('domain', faker.internet.url()) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(!result.success && result.error).toStrictEqual( - new ZodError([ - { - code: 'custom', - message: 'Invalid RFC 3986 authority', - path: ['domain'], - }, - ]), - ); - }); - - it('should not validate a non-domain', () => { - const message = siweMessageBuilder() - .with('scheme', faker.internet.protocol()) - .with('domain', faker.lorem.sentence()) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(!result.success && result.error).toStrictEqual( - new ZodError([ - { - code: 'custom', - message: 'Invalid RFC 3986 authority', - path: ['domain'], - }, - ]), - ); - }); - }); - - describe('address', () => { - it('should validate a checksummed address', () => { - const message = siweMessageBuilder() - .with('address', getAddress(faker.finance.ethereumAddress())) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(result.success).toBe(true); - }); - - it('should not validate a non-checksummed address', () => { - const message = siweMessageBuilder() - .with( - 'address', - faker.finance.ethereumAddress().toLowerCase() as `0x${string}`, - ) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(!result.success && result.error).toStrictEqual( - new ZodError([ - { - code: 'custom', - message: 'Invalid checksummed address', - path: ['address'], - }, - ]), - ); - }); - - it('should not validate a non-address', () => { - const message = siweMessageBuilder() - .with('address', faker.lorem.word() as `0x${string}`) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(!result.success && result.error).toStrictEqual( - new ZodError([ - { - code: 'custom', - message: 'Invalid checksummed address', - path: ['address'], - }, - ]), - ); - }); - }); - - describe('statement', () => { - it('should validate a statement', () => { - const message = siweMessageBuilder() - .with('statement', faker.lorem.sentence()) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(result.success).toBe(true); - }); - - it('should allow an optional statement', () => { - const message = siweMessageBuilder().with('statement', undefined).build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(result.success).toBe(true); - }); - - it('should not validate a statement with a newline', () => { - const message = siweMessageBuilder() - .with('statement', `${faker.lorem.sentence()}\n`) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(!result.success && result.error).toStrictEqual( - new ZodError([ - { - code: 'custom', - message: 'Must not include newlines', - path: ['statement'], - }, - ]), - ); - }); - }); - - describe('uri', () => { - it('should validate an RFC 3986 URI', () => { - const message = siweMessageBuilder() - .with('uri', faker.internet.url({ appendSlash: false })) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(result.success).toBe(true); - }); - - it('should not validate an RFC 3986 domain', () => { - const message = siweMessageBuilder() - .with('uri', faker.internet.domainName()) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(!result.success && result.error).toStrictEqual( - new ZodError([ - { - validation: 'url', - code: 'invalid_string', - message: 'Invalid url', - path: ['uri'], - }, - ]), - ); - }); - - it('should not validate non-URI', () => { - const message = siweMessageBuilder() - .with('uri', faker.lorem.word()) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(!result.success && result.error).toStrictEqual( - new ZodError([ - { - validation: 'url', - code: 'invalid_string', - message: 'Invalid url', - path: ['uri'], - }, - ]), - ); - }); - }); - - describe('version', () => { - it('should validate version 1', () => { - const message = siweMessageBuilder().with('version', '1').build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(result.success).toBe(true); - }); - - it('should not validate a non-version 1', () => { - const message = siweMessageBuilder() - .with('version', '2' as '1') - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(!result.success && result.error).toStrictEqual( - new ZodError([ - { - received: '2', - code: 'invalid_literal', - expected: '1', - path: ['version'], - message: 'Invalid literal value, expected "1"', - }, - ]), - ); - }); - }); - - describe('chainId', () => { - it('should validate an EIP-155 Chain ID', () => { - const message = siweMessageBuilder() - .with('chainId', faker.number.int()) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(result.success).toBe(true); - }); - - it('should not validate a non-EIP-155 Chain ID', () => { - const message = siweMessageBuilder() - .with('chainId', faker.lorem.word() as unknown as number) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(!result.success && result.error).toStrictEqual( - new ZodError([ - { - code: 'invalid_type', - expected: 'number', - received: 'string', - path: ['chainId'], - message: 'Expected number, received string', - }, - ]), - ); - }); - }); - - describe('nonce', () => { - it('should validate an alphanumeric nonce of at least 8 characters', () => { - const message = siweMessageBuilder() - .with('nonce', faker.string.alphanumeric({ length: 8 })) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(result.success).toBe(true); - }); - - it('should not validate a non-alphanumeric nonce', () => { - const message = siweMessageBuilder() - .with('nonce', faker.lorem.sentence()) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(!result.success && result.error).toStrictEqual( - new ZodError([ - { - validation: 'regex', - code: 'invalid_string', - message: 'Invalid', - path: ['nonce'], - }, - ]), - ); - }); - - it('should not validate a nonce of less than 8 characters', () => { - const message = siweMessageBuilder() - .with('nonce', faker.string.alphanumeric({ length: 7 })) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(!result.success && result.error).toStrictEqual( - new ZodError([ - { - code: 'too_small', - minimum: 8, - type: 'string', - inclusive: true, - exact: false, - message: 'String must contain at least 8 character(s)', - path: ['nonce'], - }, - ]), - ); - }); - }); - - describe('issuedAt', () => { - it('should validate an ISO 8601 datetime string', () => { - const message = siweMessageBuilder() - .with('issuedAt', faker.date.recent().toISOString()) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(result.success).toBe(true); - }); - - it('should not validate a non-ISO 8601 datetime string', () => { - const message = siweMessageBuilder() - .with('issuedAt', faker.lorem.sentence()) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(!result.success && result.error).toStrictEqual( - new ZodError([ - { - code: 'invalid_string', - validation: 'datetime', - message: 'Invalid datetime', - path: ['issuedAt'], - }, - ]), - ); - }); - }); - - describe('expirationTime', () => { - it('should validate an ISO 8601 datetime string', () => { - const message = siweMessageBuilder() - .with('expirationTime', faker.date.future().toISOString()) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(result.success).toBe(true); - }); - - it('allow an optional expirationTime', () => { - const message = siweMessageBuilder() - .with('expirationTime', undefined) - .build(); - const maxValidityInSecs = faker.number.int({ min: 1 }); - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(result.success).toBe(true); - }); - - it('should not validate a non-ISO 8601 datetime string', () => { - const message = siweMessageBuilder() - .with('expirationTime', faker.lorem.sentence()) - .build(); - const maxValidityInSecs = faker.number.int({ min: 1 }); - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(!result.success && result.error).toStrictEqual( - new ZodError([ - { - code: 'invalid_string', - validation: 'datetime', - message: 'Invalid datetime', - path: ['expirationTime'], - }, - { - code: 'custom', - message: `Must be within ${maxValidityInSecs} seconds`, - path: ['expirationTime'], - }, - ]), - ); - }); - - it('should only allow a maximum validity period', () => { - const expirationTime = faker.date.future({ - refDate: new Date(Date.now() + MAX_VALIDITY_PERIOD_IN_MS), - }); - const message = siweMessageBuilder() - .with('expirationTime', expirationTime.toISOString()) - .build(); - const maxValidityInSecs = getSecondsUntil(expirationTime) - 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(!result.success && result.error).toStrictEqual( - new ZodError([ - { - code: 'custom', - message: `Must be within ${maxValidityInSecs} seconds`, - path: ['expirationTime'], - }, - ]), - ); - }); - }); - - describe('notBefore', () => { - it('should validate an ISO 8601 datetime string', () => { - const message = siweMessageBuilder() - .with('notBefore', faker.date.past().toISOString()) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(result.success).toBe(true); - }); - - it('should allow an optional notBefore', () => { - const message = siweMessageBuilder().with('notBefore', undefined).build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(result.success).toBe(true); - }); - - it('should not validate a non-ISO 8601 datetime string', () => { - const message = siweMessageBuilder() - .with('notBefore', faker.lorem.sentence()) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(!result.success && result.error).toStrictEqual( - new ZodError([ - { - code: 'invalid_string', - validation: 'datetime', - message: 'Invalid datetime', - path: ['notBefore'], - }, - ]), - ); - }); - }); - - describe('requestId', () => { - it('should validate a requestId', () => { - const message = siweMessageBuilder() - .with('requestId', faker.string.uuid()) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(result.success).toBe(true); - }); - - it('allow an optional requestId', () => { - const message = siweMessageBuilder().with('requestId', undefined).build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(result.success).toBe(true); - }); - - it('should not validate a non-string requestId', () => { - const message = siweMessageBuilder() - .with('requestId', faker.number.int() as unknown as string) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(!result.success && result.error).toStrictEqual( - new ZodError([ - { - code: 'invalid_type', - expected: 'string', - received: 'number', - path: ['requestId'], - message: 'Expected string, received number', - }, - ]), - ); - }); - }); - - describe('resources', () => { - it('should validate an array of RFC 3986 URIs', () => { - const message = siweMessageBuilder() - .with('resources', [faker.internet.url({ appendSlash: false })]) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(result.success).toBe(true); - }); - - it('should not allow an array of RFC 3986 domains', () => { - const message = siweMessageBuilder() - .with('resources', [faker.internet.domainName()]) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(!result.success && result.error).toStrictEqual( - new ZodError([ - { - validation: 'url', - code: 'invalid_string', - message: 'Invalid url', - path: ['resources', 0], - }, - ]), - ); - }); - - it('allow an optional resources', () => { - const message = siweMessageBuilder().with('resources', undefined).build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(result.success).toBe(true); - }); - - it('should not validate a non-array of RFC 3986 URIs', () => { - const message = siweMessageBuilder() - .with('resources', faker.lorem.sentence() as unknown as string[]) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(!result.success && result.error).toStrictEqual( - new ZodError([ - { - code: 'invalid_type', - expected: 'array', - received: 'string', - path: ['resources'], - message: 'Expected array, received string', - }, - ]), - ); - }); - - it('should not validate a resource with a newline', () => { - const message = siweMessageBuilder() - .with('resources', [`${faker.internet.url({ appendSlash: false })}\n`]) - .build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - - const result = schema.safeParse(message); - - expect(!result.success && result.error).toStrictEqual( - new ZodError([ - { - code: 'custom', - message: 'Must not include newlines', - path: ['resources', 0], - }, - ]), - ); - }); - }); - - it.each([ - ['domain' as const], - ['address' as const], - ['uri' as const], - ['version' as const], - ['chainId' as const], - ['nonce' as const], - ])('should not allow %s to be undefined', (key) => { - const message = siweMessageBuilder().build(); - const maxValidityInSecs = - getSecondsUntil(new Date(message.expirationTime!)) + 1; - const schema = getSiweMessageSchema(maxValidityInSecs); - delete message[key]; - - const result = schema.safeParse(message); - - expect( - !result.success && - result.error.issues.length === 1 && - result.error.issues[0].path.length === 1 && - result.error.issues[0].path[0] === key, - ).toBe(true); - }); -}); diff --git a/src/domain/siwe/entities/siwe-message.entity.ts b/src/domain/siwe/entities/siwe-message.entity.ts deleted file mode 100644 index 01d5fcd0ec..0000000000 --- a/src/domain/siwe/entities/siwe-message.entity.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { getAddress } from 'viem'; -import { z } from 'zod'; - -export type SiweMessage = z.infer>; - -/** - * The following adheres to the EIP-4361 (SiWe) standard message fields - * {@link https://eips.ethereum.org/EIPS/eip-4361#message-fields} - * - * Note: we do not coerce any values as they will have been referenced in the message signed - */ - -// Use inferred schema -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function getSiweMessageSchema(maxValidityPeriodInSeconds: number) { - return z.object({ - /** - * OPTIONAL. The URI scheme of the origin of the request. Its value MUST be an RFC 3986 URI scheme. - */ - scheme: z - // Valid RFC 3986 URI schemes that suit our needs - .enum(['http', 'https']) - .optional(), - /** - * REQUIRED. The domain that is requesting the signing. Its value MUST be an RFC 3986 authority. The authority includes - * an OPTIONAL port. If the port is not specified, the default port for the provided scheme is assumed (e.g., 443 for HTTPS). - * If scheme is not specified, HTTPS is assumed by default. - */ - domain: z - .string() - // We cannot use z.url() here as assumes scheme is present - .refine(isRfc3986Authority, { - message: 'Invalid RFC 3986 authority', - }), - /** - * REQUIRED. The Ethereum address performing the signing. Its value SHOULD be conformant to mixed-case checksum address - * encoding specified in ERC-55 where applicable. - */ - address: z - .string() - // We cannot use AddressSchema here as the given address will have been referenced in the message signed - .refine(isChecksummedAddress, { - message: 'Invalid checksummed address', - }), - /** - * OPTIONAL. A human-readable ASCII assertion that the user will sign which MUST NOT include '\n' (the byte 0x0a). - */ - statement: z - .string() - .optional() - .refine((value) => !value || isOneLine(value), { - message: 'Must not include newlines', - }), - /** - * REQUIRED. An RFC 3986 URI referring to the resource that is the subject of the signing (as in the subject of a claim). - */ - uri: z.string().url(), - /** - * REQUIRED. The current version of the SIWE Message, which MUST be 1 for this specification. - */ - version: z.literal('1'), - /** - * REQUIRED. The EIP-155 Chain ID to which the session is bound, and the network where Contract Accounts MUST be resolved. - */ - chainId: z.number().int(), - /** - * REQUIRED. A random string typically chosen by the relying party and used to prevent replay attacks, at least 8 alphanumeric - * characters. - */ - nonce: z - .string() - .min(8) - .regex(/^[a-zA-Z0-9]+$/), - /** - * REQUIRED. The time when the message was generated, typically the current time. Its value MUST be an ISO 8601 datetime string. - */ - issuedAt: z.string().datetime(), - /** - * OPTIONAL. The time when the signed authentication message is no longer valid. Its value MUST be an ISO 8601 datetime string. - */ - expirationTime: z - .string() - .datetime() - .optional() - .refine( - (expirationTime) => { - if (!expirationTime) { - return true; - } - - const maxExpirationTime = new Date( - Date.now() + maxValidityPeriodInSeconds * 1_000, - ); - - return new Date(expirationTime) <= maxExpirationTime; - }, - { - message: `Must be within ${maxValidityPeriodInSeconds} seconds`, - }, - ), - /** - * OPTIONAL. The time when the signed authentication message will become valid. Its value MUST be an ISO 8601 datetime string. - */ - notBefore: z.string().datetime().optional(), - /** - * OPTIONAL. A system-specific identifier that MAY be used to uniquely refer to the sign-in request. - */ - requestId: z.string().optional(), - /** - * OPTIONAL. A list of information or references to information the user wishes to have resolved as part of authentication by the - * relying party. Every resource MUST be an RFC 3986 URI separated by "\n- " where \n is the byte 0x0a. - */ - resources: z - .array( - z.string().url().refine(isOneLine, { - message: 'Must not include newlines', - }), - ) - .optional(), - }); -} - -function isRfc3986Authority(value: string): boolean { - return ( - !value.includes('://') && - // Duplicate schemes are otherwise valid, e.g. 'https://https://example.com' - URL.canParse(`scheme://${value}`) - ); -} - -function isChecksummedAddress(value: string): value is `0x${string}` { - try { - return value === getAddress(value); - } catch { - return false; - } -} - -function isOneLine(value: string): boolean { - return !value.includes('\n'); -} diff --git a/src/domain/siwe/siwe.repository.interface.ts b/src/domain/siwe/siwe.repository.interface.ts index dd70c432e6..1b97190c2e 100644 --- a/src/domain/siwe/siwe.repository.interface.ts +++ b/src/domain/siwe/siwe.repository.interface.ts @@ -1,6 +1,5 @@ import { SiweApiModule } from '@/datasources/siwe-api/siwe-api.module'; import { SiweRepository } from '@/domain/siwe/siwe.repository'; -import { VerifyAuthMessageDto } from '@/routes/auth/entities/verify-auth-message.dto.entity'; import { Module } from '@nestjs/common'; export const ISiweRepository = Symbol('ISiweRepository'); @@ -8,7 +7,10 @@ export const ISiweRepository = Symbol('ISiweRepository'); export interface ISiweRepository { generateNonce(): Promise<{ nonce: string }>; - isValidMessage(args: VerifyAuthMessageDto): Promise; + isValidMessage(args: { + message: string; + signature: `0x${string}`; + }): Promise; } @Module({ diff --git a/src/domain/siwe/siwe.repository.ts b/src/domain/siwe/siwe.repository.ts index a96a36117f..faa88e9ce9 100644 --- a/src/domain/siwe/siwe.repository.ts +++ b/src/domain/siwe/siwe.repository.ts @@ -1,14 +1,31 @@ import { ISiweApi } from '@/domain/interfaces/siwe-api.interface'; +import { IConfigurationService } from '@/config/configuration.service.interface'; import { ISiweRepository } from '@/domain/siwe/siwe.repository.interface'; -import { VerifyAuthMessageDto } from '@/routes/auth/entities/verify-auth-message.dto.entity'; +import { LoggingService, ILoggingService } from '@/logging/logging.interface'; import { Inject, Injectable } from '@nestjs/common'; +import { verifyMessage } from 'viem'; +import { + generateSiweNonce, + parseSiweMessage, + validateSiweMessage, +} from 'viem/siwe'; @Injectable() export class SiweRepository implements ISiweRepository { + private readonly maxValidityPeriodInSeconds: number; + constructor( @Inject(ISiweApi) private readonly siweApi: ISiweApi, - ) {} + @Inject(IConfigurationService) + private readonly configurationService: IConfigurationService, + @Inject(LoggingService) + private readonly loggingService: ILoggingService, + ) { + this.maxValidityPeriodInSeconds = this.configurationService.getOrThrow( + 'auth.maxValidityPeriodSeconds', + ); + } /** * Generates a unique nonce and stores it in cache for later verification. @@ -16,9 +33,8 @@ export class SiweRepository implements ISiweRepository { * @returns nonce - unique string to be signed */ async generateNonce(): Promise<{ nonce: string }> { - const nonce = this.siweApi.generateNonce(); + const nonce = generateSiweNonce(); - // Store nonce for reference to verify/prevent replay attacks await this.siweApi.storeNonce(nonce); return { @@ -29,36 +45,65 @@ export class SiweRepository implements ISiweRepository { /** * Verifies the validity of a signed message: * - * 1. Ensure the message itself has not expired. - * 2. Ensure the nonce was generated by us/is not a replay attack. - * 3. Verify the signature of the message. + * 1. Ensure the message itself is not before or after validity period. + * 2. Ensure the desired expiration time is within the max validity period. + * 3. Verify the signature. + * 4. Ensure the nonce was generated by us/is not a replay attack. * - * @param args - DTO containing the message and signature to verify. + * @param args.message - SiWe message + * @param args.signature - signature from signing {@link args.message} * - * @returns boolean - whether the signed message is valid. + * @returns boolean - whether the signed message is valid */ - async isValidMessage(args: VerifyAuthMessageDto): Promise { - const isExpired = - !!args.message.expirationTime && - new Date(args.message.expirationTime) < new Date(); + async isValidMessage(args: { + message: string; + signature: `0x${string}`; + }): Promise { + const message = parseSiweMessage(args.message); + + // Without nonce we can't verify whether a replay attack + if (!message.nonce) { + return false; + } try { - // Verification is not necessary, message has expired - if (isExpired) { + // Verifying message after notBefore and before expirationTime + const isValidMessage = validateSiweMessage({ + message, + }); + + if (!isValidMessage || !message.address) { + return false; + } + + // Expiration expectation does not exceed max validity period + const isExpirationValid = + !message.expirationTime || + message.expirationTime <= + new Date(Date.now() + this.maxValidityPeriodInSeconds * 1_000); + + if (!isExpirationValid) { return false; } - const [isValidSignature, storedNonce] = await Promise.all([ - this.siweApi.verifyMessage(args), - this.siweApi.getNonce(args.message.nonce), + // Verify signature and nonce is cached (not a replay attack) + const [isValidSignature, isNonceCached] = await Promise.all([ + verifyMessage({ + address: message.address, + message: args.message, + signature: args.signature, + }), + this.siweApi.getNonce(message.nonce).then(Boolean), ]); - const isValidNonce = storedNonce === args.message.nonce; - return isValidSignature && isValidNonce; - } catch { + return isValidSignature && isNonceCached; + } catch (e) { + this.loggingService.debug( + `Failed to verify SiWe message. message=${args.message}, error=${e}`, + ); return false; } finally { - await this.siweApi.clearNonce(args.message.nonce); + await this.siweApi.clearNonce(message.nonce); } } } diff --git a/src/routes/auth/auth.controller.spec.ts b/src/routes/auth/auth.controller.spec.ts index 8058e635da..ffbeb5a382 100644 --- a/src/routes/auth/auth.controller.spec.ts +++ b/src/routes/auth/auth.controller.spec.ts @@ -17,7 +17,7 @@ import { TestAppProvider } from '@/__tests__/test-app.provider'; import { siweMessageBuilder } from '@/domain/siwe/entities/__tests__/siwe-message.builder'; import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; import { faker } from '@faker-js/faker'; -import { toSignableSiweMessage } from '@/datasources/siwe-api/utils/to-signable-siwe-message'; +import { createSiweMessage } from 'viem/siwe'; import { CacheService } from '@/datasources/cache/cache.service.interface'; import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.service'; import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity'; @@ -30,12 +30,12 @@ import jwtConfiguration from '@/datasources/jwt/configuration/__tests__/jwt.conf import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; - -const MAX_VALIDITY_PERIOD_IN_MS = 15 * 60 * 1_000; // 15 minutes +import { IConfigurationService } from '@/config/configuration.service.interface'; describe('AuthController', () => { let app: INestApplication; let cacheService: FakeCacheService; + let maxValidityPeriodInMs: number; beforeEach(async () => { jest.useFakeTimers(); @@ -70,6 +70,11 @@ describe('AuthController', () => { .compile(); cacheService = moduleFixture.get(CacheService); + const configService: IConfigurationService = moduleFixture.get( + IConfigurationService, + ); + maxValidityPeriodInMs = + configService.getOrThrow('auth.maxValidityPeriodSeconds') * 1_000; app = await new TestAppProvider().provide(moduleFixture); @@ -111,15 +116,17 @@ describe('AuthController', () => { const cacheDir = new CacheDir(`auth_nonce_${nonce}`, ''); const expirationTime = faker.date.between({ from: new Date(), - to: new Date(Date.now() + MAX_VALIDITY_PERIOD_IN_MS), + to: new Date(Date.now() + maxValidityPeriodInMs), }); - const message = siweMessageBuilder() - .with('address', signer.address) - .with('nonce', nonce) - .with('expirationTime', expirationTime.toISOString()) - .build(); + const message = createSiweMessage( + siweMessageBuilder() + .with('address', signer.address) + .with('nonce', nonce) + .with('expirationTime', expirationTime) + .build(), + ); const signature = await signer.signMessage({ - message: toSignableSiweMessage(message), + message, }); const maxAge = getSecondsUntil(expirationTime); // jsonwebtoken sets expiration based on timespans, not exact dates @@ -158,15 +165,17 @@ describe('AuthController', () => { const nonce: string = nonceResponse.body.nonce; const cacheDir = new CacheDir(`auth_nonce_${nonce}`, ''); const expirationTime = faker.date.future({ - refDate: new Date(Date.now() + MAX_VALIDITY_PERIOD_IN_MS), + refDate: new Date(Date.now() + maxValidityPeriodInMs), }); - const message = siweMessageBuilder() - .with('address', signer.address) - .with('nonce', nonce) - .with('expirationTime', expirationTime.toISOString()) - .build(); + const message = createSiweMessage( + siweMessageBuilder() + .with('address', signer.address) + .with('nonce', nonce) + .with('expirationTime', expirationTime) + .build(), + ); const signature = await signer.signMessage({ - message: toSignableSiweMessage(message), + message, }); await expect(cacheService.get(cacheDir)).resolves.toBe(nonce); @@ -176,21 +185,17 @@ describe('AuthController', () => { message, signature, }) - .expect(422) + .expect(401) .expect(({ headers, body }) => { expect(headers['set-cookie']).toBe(undefined); expect(body).toStrictEqual({ - code: 'custom', - message: 'Must be within 900 seconds', - path: ['message', 'expirationTime'], - statusCode: 422, + message: 'Unauthorized', + statusCode: 401, }); }); - // Nonce not deleted - await expect(cacheService.get(cacheDir)).resolves.toBe( - nonceResponse.body.nonce, - ); + // Nonce deleted + await expect(cacheService.get(cacheDir)).resolves.toBe(undefined); }); it('should not verify a signer if using an unsigned nonce', async () => { @@ -198,15 +203,16 @@ describe('AuthController', () => { const signer = privateKeyToAccount(privateKey); const expirationTime = faker.date.between({ from: new Date(), - to: new Date(Date.now() + MAX_VALIDITY_PERIOD_IN_MS), + to: new Date(Date.now() + maxValidityPeriodInMs), }); - const message = siweMessageBuilder() + const siweMessage = siweMessageBuilder() .with('address', signer.address) - .with('expirationTime', expirationTime.toISOString()) + .with('expirationTime', expirationTime) .build(); - const cacheDir = new CacheDir(`auth_nonce_${message.nonce}`, ''); + const message = createSiweMessage(siweMessage); + const cacheDir = new CacheDir(`auth_nonce_${siweMessage.nonce}`, ''); const signature = await signer.signMessage({ - message: toSignableSiweMessage(message), + message, }); await expect(cacheService.get(cacheDir)).resolves.toBe(undefined); @@ -239,15 +245,17 @@ describe('AuthController', () => { const cacheDir = new CacheDir(`auth_nonce_${nonce}`, ''); const expirationTime = faker.date.between({ from: new Date(), - to: new Date(Date.now() + MAX_VALIDITY_PERIOD_IN_MS), + to: new Date(Date.now() + maxValidityPeriodInMs), }); - const message = siweMessageBuilder() - .with('address', signer.address) - .with('nonce', nonce) - .with('expirationTime', expirationTime.toISOString()) - .build(); + const message = createSiweMessage( + siweMessageBuilder() + .with('address', signer.address) + .with('nonce', nonce) + .with('expirationTime', expirationTime) + .build(), + ); const signature = await signer.signMessage({ - message: toSignableSiweMessage(message), + message, }); // Mimic ttl expiration await cacheService.deleteByKey(cacheDir.key); @@ -282,13 +290,15 @@ describe('AuthController', () => { ); const expirationTime = faker.date.between({ from: new Date(), - to: new Date(Date.now() + MAX_VALIDITY_PERIOD_IN_MS), + to: new Date(Date.now() + maxValidityPeriodInMs), }); const nonce: string = nonceResponse.body.nonce; - const message = siweMessageBuilder() - .with('nonce', nonce) - .with('expirationTime', expirationTime.toISOString()) - .build(); + const message = createSiweMessage( + siweMessageBuilder() + .with('nonce', nonce) + .with('expirationTime', expirationTime) + .build(), + ); const cacheDir = new CacheDir(`auth_nonce_${nonce}`, ''); const signature = faker.string.hexadecimal(); @@ -320,14 +330,16 @@ describe('AuthController', () => { ); const nonce: string = nonceResponse.body.nonce; const cacheDir = new CacheDir(`auth_nonce_${nonce}`, ''); - const expirationTime = faker.date.past(); - const message = siweMessageBuilder() - .with('address', signer.address) - .with('nonce', nonce) - .with('expirationTime', expirationTime.toISOString()) - .build(); + const expirationTime = new Date(); + const message = createSiweMessage( + siweMessageBuilder() + .with('address', signer.address) + .with('nonce', nonce) + .with('expirationTime', expirationTime) + .build(), + ); const signature = await signer.signMessage({ - message: toSignableSiweMessage(message), + message, }); await expect(cacheService.get(cacheDir)).resolves.toBe(nonce); diff --git a/src/routes/auth/auth.controller.ts b/src/routes/auth/auth.controller.ts index 2af698d04a..b0614929bf 100644 --- a/src/routes/auth/auth.controller.ts +++ b/src/routes/auth/auth.controller.ts @@ -2,10 +2,7 @@ import { Body, Controller, Get, Post, HttpCode, Res } from '@nestjs/common'; import { ApiExcludeController } from '@nestjs/swagger'; import { ValidationPipe } from '@/validation/pipes/validation.pipe'; import { AuthService } from '@/routes/auth/auth.service'; -import { - VerifyAuthMessageDto, - VerifyAuthMessageDtoSchema, -} from '@/routes/auth/entities/verify-auth-message.dto.entity'; +import { SiweDtoSchema, SiweDto } from '@/routes/auth/entities/siwe.dto.entity'; import { Response } from 'express'; import { getMillisecondsUntil } from '@/domain/common/utils/time'; @@ -13,7 +10,7 @@ import { getMillisecondsUntil } from '@/domain/common/utils/time'; * The AuthController is responsible for handling authentication: * * 1. Calling `/v1/auth/nonce` returns a unique nonce to be signed. - * 2. The client signs this nonce in a SIWE message, sending it and + * 2. The client signs this nonce in a SiWe message, sending it and * the signature to `/v1/auth/verify` for verification. * 3. If verification succeeds, JWT token is added to `access_token` * Set-Cookie. @@ -37,18 +34,17 @@ export class AuthController { async verify( @Res({ passthrough: true }) res: Response, - @Body(new ValidationPipe(VerifyAuthMessageDtoSchema)) - verifyAuthMessageDto: VerifyAuthMessageDto, + @Body(new ValidationPipe(SiweDtoSchema)) + siweDto: SiweDto, ): Promise { - const { accessToken } = - await this.authService.getAccessToken(verifyAuthMessageDto); + const { accessToken } = await this.authService.getAccessToken(siweDto); res.cookie(AuthController.ACCESS_TOKEN_COOKIE_NAME, accessToken, { httpOnly: true, secure: true, sameSite: 'lax', path: '/', - // Extract maxAge from token as it may slightly differ to SIWE message + // Extract maxAge from token as it may slightly differ to SiWe message maxAge: this.getMaxAge(accessToken), }); } diff --git a/src/routes/auth/auth.service.ts b/src/routes/auth/auth.service.ts index 0c9d2f4416..4db23bbdf9 100644 --- a/src/routes/auth/auth.service.ts +++ b/src/routes/auth/auth.service.ts @@ -1,10 +1,14 @@ import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; -import { VerifyAuthMessageDto } from '@/routes/auth/entities/verify-auth-message.dto.entity'; +import { SiweDto } from '@/routes/auth/entities/siwe.dto.entity'; import { ISiweRepository } from '@/domain/siwe/siwe.repository.interface'; import { IAuthRepository } from '@/domain/auth/auth.repository.interface'; import { getSecondsUntil } from '@/domain/common/utils/time'; -import { AuthPayloadDto } from '@/domain/auth/entities/auth-payload.entity'; +import { + AuthPayloadDto, + AuthPayloadDtoSchema, +} from '@/domain/auth/entities/auth-payload.entity'; import { JwtPayloadWithClaims } from '@/datasources/jwt/jwt-claims.entity'; +import { parseSiweMessage } from 'viem/siwe'; @Injectable() export class AuthService { @@ -21,7 +25,7 @@ export class AuthService { return await this.siweRepository.generateNonce(); } - async getAccessToken(args: VerifyAuthMessageDto): Promise<{ + async getAccessToken(args: SiweDto): Promise<{ accessToken: string; }> { const isValid = await this.siweRepository.isValidMessage(args); @@ -30,12 +34,14 @@ export class AuthService { throw new UnauthorizedException(); } - const { chainId, address, notBefore, expirationTime } = args.message; + const { chainId, address, notBefore, expirationTime } = parseSiweMessage( + args.message, + ); - const payload: AuthPayloadDto = { - chain_id: chainId.toString(), + const payload = AuthPayloadDtoSchema.parse({ + chain_id: chainId?.toString(), signer_address: address, - }; + }); const accessToken = this.authRepository.signToken(payload, { ...(notBefore && { diff --git a/src/routes/auth/entities/__tests__/siwe.dto.entity.spec.ts b/src/routes/auth/entities/__tests__/siwe.dto.entity.spec.ts new file mode 100644 index 0000000000..8ef3f4b9f0 --- /dev/null +++ b/src/routes/auth/entities/__tests__/siwe.dto.entity.spec.ts @@ -0,0 +1,53 @@ +import { siweMessageBuilder } from '@/domain/siwe/entities/__tests__/siwe-message.builder'; +import { SiweDtoSchema } from '@/routes/auth/entities/siwe.dto.entity'; +import { faker } from '@faker-js/faker'; +import { createSiweMessage } from 'viem/siwe'; + +describe('SiweDtoSchema', () => { + it('should validate a valid SiweDto', () => { + const siweDto = { + message: createSiweMessage(siweMessageBuilder().build()), + signature: faker.string.hexadecimal(), + }; + + const result = SiweDtoSchema.safeParse(siweDto); + + expect(result.success).toBe(true); + }); + + it('should not validate a non-string message', () => { + const siweDto = { + message: faker.number.int(), + signature: faker.string.hexadecimal(), + }; + + const result = SiweDtoSchema.safeParse(siweDto); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Expected string, received number', + path: ['message'], + received: 'number', + }, + ]); + }); + + it('should not validate a non-hex signature', () => { + const siweDto = { + message: createSiweMessage(siweMessageBuilder().build()), + signature: faker.string.alpha(), + }; + + const result = SiweDtoSchema.safeParse(siweDto); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'custom', + message: 'Invalid "0x" notated hex string', + path: ['signature'], + }, + ]); + }); +}); diff --git a/src/routes/auth/entities/schemas/__tests__/verify-auth-message.dto.schema.spec.ts b/src/routes/auth/entities/schemas/__tests__/verify-auth-message.dto.schema.spec.ts deleted file mode 100644 index f4b049c2a1..0000000000 --- a/src/routes/auth/entities/schemas/__tests__/verify-auth-message.dto.schema.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { siweMessageBuilder } from '@/domain/siwe/entities/__tests__/siwe-message.builder'; -import { VerifyAuthMessageDtoSchema } from '@/routes/auth/entities/verify-auth-message.dto.entity'; -import { faker } from '@faker-js/faker'; - -const MAX_VALIDITY_PERIOD_IN_MS = 15 * 60 * 1_000; // 15 minutes - -describe('VerifyAuthMessageDto', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('should validate a VerifyAuthMessageDto', () => { - const expirationTime = faker.date.between({ - from: new Date(), - to: new Date(Date.now() + MAX_VALIDITY_PERIOD_IN_MS), - }); - const verifyAuthMessageDto = { - message: siweMessageBuilder() - .with('expirationTime', expirationTime.toISOString()) - .build(), - signature: faker.string.hexadecimal(), - }; - - const result = VerifyAuthMessageDtoSchema.safeParse(verifyAuthMessageDto); - - expect(result.success).toBe(true); - }); - - it.each([['message' as const], ['signature' as const]])( - 'should not allow %s to be undefined', - (key) => { - const expirationTime = faker.date.between({ - from: new Date(), - to: new Date(Date.now() + MAX_VALIDITY_PERIOD_IN_MS), - }); - const verifyAuthMessageDto = { - message: siweMessageBuilder() - .with('expirationTime', expirationTime.toISOString()) - .build(), - signature: faker.string.hexadecimal(), - }; - delete verifyAuthMessageDto[key]; - - const result = VerifyAuthMessageDtoSchema.safeParse(verifyAuthMessageDto); - - expect( - !result.success && - result.error.issues.length === 1 && - result.error.issues[0].path.length === 1 && - result.error.issues[0].path[0] === key, - ).toBe(true); - }, - ); -}); diff --git a/src/routes/auth/entities/siwe.dto.entity.ts b/src/routes/auth/entities/siwe.dto.entity.ts new file mode 100644 index 0000000000..123e04db15 --- /dev/null +++ b/src/routes/auth/entities/siwe.dto.entity.ts @@ -0,0 +1,9 @@ +import { HexSchema } from '@/validation/entities/schemas/hex.schema'; +import { z } from 'zod'; + +export type SiweDto = z.infer; + +export const SiweDtoSchema = z.object({ + message: z.string(), + signature: HexSchema, +}); diff --git a/src/routes/auth/entities/verify-auth-message.dto.entity.ts b/src/routes/auth/entities/verify-auth-message.dto.entity.ts deleted file mode 100644 index 0d4b483362..0000000000 --- a/src/routes/auth/entities/verify-auth-message.dto.entity.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { getSiweMessageSchema } from '@/domain/siwe/entities/siwe-message.entity'; -import { HexSchema } from '@/validation/entities/schemas/hex.schema'; -import { z } from 'zod'; - -// TODO: Inject -const MAX_VALIDITY_PERIOD_IN_SECONDS = 15 * 60; // 15 minutes - -export type VerifyAuthMessageDto = z.infer; - -export const VerifyAuthMessageDtoSchema = z.object({ - message: getSiweMessageSchema(MAX_VALIDITY_PERIOD_IN_SECONDS), - signature: HexSchema, -}); From eada304de4fd1bfbeb7b6ee345a2b77704650221 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Fri, 7 Jun 2024 13:35:54 +0200 Subject: [PATCH 065/207] Enable `@typescript-eslint/no-unsafe-call` lint rule (#1619) This enables the `@typescript-eslint/no-unsafe-call` lint rule rule that was disabled: - Enable `@typescript-eslint/no-unsafe-call` lint rule - Update respective errors --- eslint.config.mjs | 1 - src/__tests__/deployments.helper.ts | 4 +-- src/app.provider.ts | 2 +- .../account/account.datasource.spec.ts | 4 +-- src/datasources/account/account.datasource.ts | 2 +- .../db/postgres-database.migration.hook.ts | 2 +- .../db/postgres-database.module.ts | 4 +-- .../human-description-api/json/index.ts | 2 +- src/datasources/jwt/jwt.module.ts | 2 +- .../network/network.module.spec.ts | 8 ++++-- src/domain/account/account.repository.ts | 2 +- src/domain/account/code-generator.ts | 2 +- src/logging/logging.module.ts | 4 +-- src/logging/logging.service.spec.ts | 2 +- .../about/__tests__/get-about.e2e-spec.ts | 2 +- src/routes/alerts/alerts.controller.spec.ts | 4 +-- .../alerts/guards/tenderly-signature.guard.ts | 2 +- src/routes/auth/auth.controller.spec.ts | 2 +- .../auth/decorators/auth.decorator.spec.ts | 2 +- src/routes/auth/guards/auth.guard.spec.ts | 2 +- .../zerion-balances.controller.spec.ts | 2 +- .../balances/balances.controller.spec.ts | 12 +++++---- .../cache-hooks.controller.spec.ts | 2 +- src/routes/chains/chains.controller.spec.ts | 14 +++++----- .../zerion-collectibles.controller.spec.ts | 26 ++++++++++--------- .../collectibles.controller.spec.ts | 8 +++--- .../pagination.data.decorator.spec.ts | 2 +- .../filters/global-error.filter.spec.ts | 2 +- .../common/filters/zod-error.filter.spec.ts | 2 +- .../cache-control.interceptor.spec.ts | 2 +- .../interceptors/cache-control.interceptor.ts | 3 ++- .../route-logger.interceptor.spec.ts | 2 +- .../community/community.controller.spec.ts | 8 +++--- .../__tests__/get-contract.e2e-spec.ts | 2 +- .../contracts/contracts.controller.spec.ts | 8 +++--- .../__tests__/data-decode.e2e-spec.ts | 2 +- .../delegates/delegates.controller.spec.ts | 8 +++--- .../v2/delegates.v2.controller.spec.ts | 8 +++--- .../email.controller.delete-email.spec.ts | 8 +++--- .../email/email.controller.edit-email.spec.ts | 8 +++--- .../email/email.controller.get-email.spec.ts | 2 +- ...ail.controller.resend-verification.spec.ts | 2 +- .../email/email.controller.save-email.spec.ts | 2 +- .../email.controller.verify-email.spec.ts | 2 +- .../estimations.controller.spec.ts | 8 +++--- .../health/__tests__/get-health.e2e-spec.ts | 2 +- src/routes/health/health.controller.spec.ts | 2 +- .../messages/messages.controller.spec.ts | 8 +++--- .../notifications.controller.spec.ts | 8 +++--- .../__tests__/get-safes-by-owner.e2e-spec.ts | 2 +- src/routes/owners/owners.controller.spec.ts | 8 +++--- .../recovery/recovery.controller.spec.ts | 14 +++++----- .../entities/schemas/relay.dto.schema.ts | 2 +- src/routes/relay/relay.controller.spec.ts | 2 +- src/routes/root/root.controller.spec.ts | 2 +- .../__tests__/get-safe-apps.e2e-spec.ts | 3 ++- .../safe-apps/safe-apps.controller.spec.ts | 8 +++--- .../pipes/caip-10-addresses.pipe.spec.ts | 2 +- .../safes/safes.controller.nonces.spec.ts | 2 +- .../safes/safes.controller.overview.spec.ts | 6 +++-- src/routes/safes/safes.controller.spec.ts | 8 +++--- src/routes/safes/safes.service.ts | 2 +- .../subscription.controller.spec.ts | 2 +- ...firmations.transactions.controller.spec.ts | 8 +++--- ...ransaction.transactions.controller.spec.ts | 8 +++--- ...tion-by-id.transactions.controller.spec.ts | 8 +++--- ...rs-by-safe.transactions.controller.spec.ts | 8 +++--- ...ns-by-safe.transactions.controller.spec.ts | 8 +++--- ...ns-by-safe.transactions.controller.spec.ts | 8 +++--- ...ns-by-safe.transactions.controller.spec.ts | 8 +++--- ...ransaction.transactions.controller.spec.ts | 8 +++--- ...ransaction.transactions.controller.spec.ts | 8 +++--- .../transactions-history.controller.spec.ts | 5 ++-- ....imitation-transactions.controller.spec.ts | 8 +++--- .../transactions-view.controller.spec.ts | 10 ++++--- src/types/postgres-shift.d.ts | 13 +++++++++- tsconfig.json | 1 + 77 files changed, 234 insertions(+), 158 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 14f933735f..ff6e323c5a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -32,7 +32,6 @@ export default tseslint.config( '@typescript-eslint/no-floating-promises': 'warn', // TODO: Address these rules: (added to update to ESLint 9) '@typescript-eslint/no-unsafe-assignment': 'off', - '@typescript-eslint/no-unsafe-call': 'off', '@typescript-eslint/no-unsafe-member-access': 'off', '@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/restrict-template-expressions': 'off', diff --git a/src/__tests__/deployments.helper.ts b/src/__tests__/deployments.helper.ts index ee2b359042..b7e68218ae 100644 --- a/src/__tests__/deployments.helper.ts +++ b/src/__tests__/deployments.helper.ts @@ -1,5 +1,5 @@ -import * as path from 'path'; -import * as fs from 'fs'; +import path from 'path'; +import fs from 'fs'; /** * This generates a map of contract names to chain IDs to a versions array diff --git a/src/app.provider.ts b/src/app.provider.ts index 14b0452367..50418dcce4 100644 --- a/src/app.provider.ts +++ b/src/app.provider.ts @@ -3,7 +3,7 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { NestFactory } from '@nestjs/core'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { json } from 'express'; -import * as cookieParser from 'cookie-parser'; +import cookieParser from 'cookie-parser'; function configureVersioning(app: INestApplication): void { app.enableVersioning({ diff --git a/src/datasources/account/account.datasource.spec.ts b/src/datasources/account/account.datasource.spec.ts index 02b095d623..c4282b90ef 100644 --- a/src/datasources/account/account.datasource.spec.ts +++ b/src/datasources/account/account.datasource.spec.ts @@ -1,9 +1,9 @@ import { AccountDataSource } from '@/datasources/account/account.datasource'; -import * as postgres from 'postgres'; +import postgres from 'postgres'; import { PostgresError } from 'postgres'; import { faker } from '@faker-js/faker'; import { AccountDoesNotExistError } from '@/domain/account/errors/account-does-not-exist.error'; -import * as shift from 'postgres-shift'; +import shift from 'postgres-shift'; import configuration from '@/config/entities/__tests__/configuration'; import { Account, diff --git a/src/datasources/account/account.datasource.ts b/src/datasources/account/account.datasource.ts index fa6c92d6b8..8abd10bedd 100644 --- a/src/datasources/account/account.datasource.ts +++ b/src/datasources/account/account.datasource.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import * as postgres from 'postgres'; +import postgres from 'postgres'; import { IAccountDataSource } from '@/domain/interfaces/account.datasource.interface'; import { AccountDoesNotExistError } from '@/domain/account/errors/account-does-not-exist.error'; import { diff --git a/src/datasources/db/postgres-database.migration.hook.ts b/src/datasources/db/postgres-database.migration.hook.ts index d90a1100cb..5cedcc2f53 100644 --- a/src/datasources/db/postgres-database.migration.hook.ts +++ b/src/datasources/db/postgres-database.migration.hook.ts @@ -1,6 +1,6 @@ import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; import { ILoggingService, LoggingService } from '@/logging/logging.interface'; -import * as shift from 'postgres-shift'; +import shift from 'postgres-shift'; import postgres from 'postgres'; /** diff --git a/src/datasources/db/postgres-database.module.ts b/src/datasources/db/postgres-database.module.ts index 9116f79897..6d28e11832 100644 --- a/src/datasources/db/postgres-database.module.ts +++ b/src/datasources/db/postgres-database.module.ts @@ -1,9 +1,9 @@ -import * as postgres from 'postgres'; +import postgres from 'postgres'; import { Module } from '@nestjs/common'; import { PostgresDatabaseShutdownHook } from '@/datasources/db/postgres-database.shutdown.hook'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { PostgresDatabaseMigrationHook } from '@/datasources/db/postgres-database.migration.hook'; -import * as fs from 'fs'; +import fs from 'fs'; function dbFactory(configurationService: IConfigurationService): postgres.Sql { const caPath = configurationService.get('db.postgres.ssl.caPath'); diff --git a/src/datasources/human-description-api/json/index.ts b/src/datasources/human-description-api/json/index.ts index 0031b56257..215a6ac470 100644 --- a/src/datasources/human-description-api/json/index.ts +++ b/src/datasources/human-description-api/json/index.ts @@ -1,3 +1,3 @@ -import * as ContractDescriptions from '@/datasources/human-description-api/json/contract-descriptions.json'; +import ContractDescriptions from '@/datasources/human-description-api/json/contract-descriptions.json'; export default ContractDescriptions; diff --git a/src/datasources/jwt/jwt.module.ts b/src/datasources/jwt/jwt.module.ts index ca91f1f20f..f656138530 100644 --- a/src/datasources/jwt/jwt.module.ts +++ b/src/datasources/jwt/jwt.module.ts @@ -1,4 +1,4 @@ -import * as jwt from 'jsonwebtoken'; +import jwt from 'jsonwebtoken'; import { Module } from '@nestjs/common'; import { JwtService } from '@/datasources/jwt/jwt.service'; import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; diff --git a/src/datasources/network/network.module.spec.ts b/src/datasources/network/network.module.spec.ts index d134a8971b..71b1ccb638 100644 --- a/src/datasources/network/network.module.spec.ts +++ b/src/datasources/network/network.module.spec.ts @@ -38,9 +38,13 @@ describe('NetworkModule', () => { ], }).compile(); - const configurationService = moduleFixture.get(IConfigurationService); + const configurationService = moduleFixture.get( + IConfigurationService, + ); fetchClient = moduleFixture.get('FetchClient'); - httpClientTimeout = configurationService.get('httpClient.requestTimeout'); + httpClientTimeout = configurationService.getOrThrow( + 'httpClient.requestTimeout', + ); app = moduleFixture.createNestApplication(); await app.init(); diff --git a/src/domain/account/account.repository.ts b/src/domain/account/account.repository.ts index ee8ca801d0..2ac5153516 100644 --- a/src/domain/account/account.repository.ts +++ b/src/domain/account/account.repository.ts @@ -14,7 +14,7 @@ import { EmailAlreadyVerifiedError } from '@/domain/account/errors/email-already import { InvalidVerificationCodeError } from '@/domain/account/errors/invalid-verification-code.error'; import { EmailEditMatchesError } from '@/domain/account/errors/email-edit-matches.error'; import { IEmailApi } from '@/domain/interfaces/email-api.interface'; -import * as crypto from 'crypto'; +import crypto from 'crypto'; import { ISubscriptionRepository } from '@/domain/subscriptions/subscription.repository.interface'; import { SubscriptionRepository } from '@/domain/subscriptions/subscription.repository'; import { AccountDoesNotExistError } from '@/domain/account/errors/account-does-not-exist.error'; diff --git a/src/domain/account/code-generator.ts b/src/domain/account/code-generator.ts index a4b995794b..fcfa88bf69 100644 --- a/src/domain/account/code-generator.ts +++ b/src/domain/account/code-generator.ts @@ -1,4 +1,4 @@ -import * as crypto from 'crypto'; +import crypto from 'crypto'; /** * Generates a random number up to six digits diff --git a/src/logging/logging.module.ts b/src/logging/logging.module.ts index 0a249e1c1e..5c2a6db3d7 100644 --- a/src/logging/logging.module.ts +++ b/src/logging/logging.module.ts @@ -1,6 +1,6 @@ import { Global, Module } from '@nestjs/common'; -import * as winston from 'winston'; -import * as Transport from 'winston-transport'; +import winston from 'winston'; +import Transport from 'winston-transport'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { LoggingService } from '@/logging/logging.interface'; import { RequestScopedLoggingService } from '@/logging/logging.service'; diff --git a/src/logging/logging.service.spec.ts b/src/logging/logging.service.spec.ts index 29b7ba65e6..335f44ae10 100644 --- a/src/logging/logging.service.spec.ts +++ b/src/logging/logging.service.spec.ts @@ -1,6 +1,6 @@ import { faker } from '@faker-js/faker'; import { ClsService } from 'nestjs-cls'; -import * as winston from 'winston'; +import winston from 'winston'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { RequestScopedLoggingService } from '@/logging/logging.service'; diff --git a/src/routes/about/__tests__/get-about.e2e-spec.ts b/src/routes/about/__tests__/get-about.e2e-spec.ts index 1eef0fc1e2..8142731b40 100644 --- a/src/routes/about/__tests__/get-about.e2e-spec.ts +++ b/src/routes/about/__tests__/get-about.e2e-spec.ts @@ -1,6 +1,6 @@ import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { AppModule } from '@/app.module'; import { expect } from '@jest/globals'; import '@/__tests__/matchers/to-be-string-or-null'; diff --git a/src/routes/alerts/alerts.controller.spec.ts b/src/routes/alerts/alerts.controller.spec.ts index 25e6aa2315..3c758e41bd 100644 --- a/src/routes/alerts/alerts.controller.spec.ts +++ b/src/routes/alerts/alerts.controller.spec.ts @@ -1,8 +1,8 @@ import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { faker } from '@faker-js/faker'; -import * as crypto from 'crypto'; +import crypto from 'crypto'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; diff --git a/src/routes/alerts/guards/tenderly-signature.guard.ts b/src/routes/alerts/guards/tenderly-signature.guard.ts index 54b991f6cf..c3aee329e2 100644 --- a/src/routes/alerts/guards/tenderly-signature.guard.ts +++ b/src/routes/alerts/guards/tenderly-signature.guard.ts @@ -5,7 +5,7 @@ import { Injectable, } from '@nestjs/common'; import { Request } from 'express'; -import * as crypto from 'crypto'; +import crypto from 'crypto'; import { IConfigurationService } from '@/config/configuration.service.interface'; @Injectable() diff --git a/src/routes/auth/auth.controller.spec.ts b/src/routes/auth/auth.controller.spec.ts index ffbeb5a382..b50ba83a04 100644 --- a/src/routes/auth/auth.controller.spec.ts +++ b/src/routes/auth/auth.controller.spec.ts @@ -1,6 +1,6 @@ import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; diff --git a/src/routes/auth/decorators/auth.decorator.spec.ts b/src/routes/auth/decorators/auth.decorator.spec.ts index f283651f72..7eac8ac679 100644 --- a/src/routes/auth/decorators/auth.decorator.spec.ts +++ b/src/routes/auth/decorators/auth.decorator.spec.ts @@ -22,7 +22,7 @@ import { } from '@nestjs/common'; import { TestingModule, Test } from '@nestjs/testing'; import { Server } from 'net'; -import * as request from 'supertest'; +import request from 'supertest'; describe('Auth decorator', () => { let app: INestApplication; diff --git a/src/routes/auth/guards/auth.guard.spec.ts b/src/routes/auth/guards/auth.guard.spec.ts index 17a88ffa93..5be74dc609 100644 --- a/src/routes/auth/guards/auth.guard.spec.ts +++ b/src/routes/auth/guards/auth.guard.spec.ts @@ -10,7 +10,7 @@ import { AuthGuard } from '@/routes/auth/guards/auth.guard'; import { faker } from '@faker-js/faker'; import { Controller, Get, INestApplication, UseGuards } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { AuthRepositoryModule } from '@/domain/auth/auth.repository.interface'; import { getSecondsUntil } from '@/domain/common/utils/time'; import { diff --git a/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts b/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts index 19dc2d2b4d..2ce03e7b5a 100644 --- a/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts +++ b/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { AppModule } from '@/app.module'; import { IConfigurationService } from '@/config/configuration.service.interface'; diff --git a/src/routes/balances/balances.controller.spec.ts b/src/routes/balances/balances.controller.spec.ts index ef44e72cbe..f6e9dce21b 100644 --- a/src/routes/balances/balances.controller.spec.ts +++ b/src/routes/balances/balances.controller.spec.ts @@ -1,6 +1,6 @@ import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; @@ -53,9 +53,11 @@ describe('Balances Controller (Unit)', () => { .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); - pricesProviderUrl = configurationService.get( + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); + pricesProviderUrl = configurationService.getOrThrow( 'balances.providers.safe.prices.baseUri', ); networkService = moduleFixture.get(NetworkService); @@ -92,7 +94,7 @@ describe('Balances Controller (Unit)', () => { .build(), ]; const apiKey = app - .get(IConfigurationService) + .get(IConfigurationService) .getOrThrow('balances.providers.safe.prices.apiKey'); const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { diff --git a/src/routes/cache-hooks/cache-hooks.controller.spec.ts b/src/routes/cache-hooks/cache-hooks.controller.spec.ts index ae2e756b5b..341c45be62 100644 --- a/src/routes/cache-hooks/cache-hooks.controller.spec.ts +++ b/src/routes/cache-hooks/cache-hooks.controller.spec.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; diff --git a/src/routes/chains/chains.controller.spec.ts b/src/routes/chains/chains.controller.spec.ts index 626003cbae..3b041b6d15 100644 --- a/src/routes/chains/chains.controller.spec.ts +++ b/src/routes/chains/chains.controller.spec.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { AppModule } from '@/app.module'; import { IConfigurationService } from '@/config/configuration.service.interface'; @@ -70,11 +70,13 @@ describe('Chains Controller (Unit)', () => { .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); - name = configurationService.get('about.name'); - version = configurationService.get('about.version'); - buildNumber = configurationService.get('about.buildNumber'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); + name = configurationService.getOrThrow('about.name'); + version = configurationService.getOrThrow('about.version'); + buildNumber = configurationService.getOrThrow('about.buildNumber'); networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); diff --git a/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts b/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts index 495c9897f3..139da356a0 100644 --- a/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts +++ b/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { AppModule } from '@/app.module'; import { IConfigurationService } from '@/config/configuration.service.interface'; @@ -54,11 +54,13 @@ describe('Zerion Collectibles Controller', () => { .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - zerionBaseUri = configurationService.get( + const configurationService = moduleFixture.get( + IConfigurationService, + ); + zerionBaseUri = configurationService.getOrThrow( 'balances.providers.zerion.baseUri', ); - zerionChainIds = configurationService.get( + zerionChainIds = configurationService.getOrThrow( 'features.zerionBalancesChainIds', ); networkService = moduleFixture.get(NetworkService); @@ -125,12 +127,12 @@ describe('Zerion Collectibles Controller', () => { ]) .build(); const chainName = app - .get(IConfigurationService) + .get(IConfigurationService) .getOrThrow( `balances.providers.zerion.chains.${chain.chainId}.chainName`, ); const apiKey = app - .get(IConfigurationService) + .get(IConfigurationService) .getOrThrow(`balances.providers.zerion.apiKey`); networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -267,12 +269,12 @@ describe('Zerion Collectibles Controller', () => { .with('links', { next: zerionNext }) .build(); const chainName = app - .get(IConfigurationService) + .get(IConfigurationService) .getOrThrow( `balances.providers.zerion.chains.${chain.chainId}.chainName`, ); const apiKey = app - .get(IConfigurationService) + .get(IConfigurationService) .getOrThrow(`balances.providers.zerion.apiKey`); networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -332,12 +334,12 @@ describe('Zerion Collectibles Controller', () => { .with('links', { next: zerionNext }) .build(); const chainName = app - .get(IConfigurationService) + .get(IConfigurationService) .getOrThrow( `balances.providers.zerion.chains.${chain.chainId}.chainName`, ); const apiKey = app - .get(IConfigurationService) + .get(IConfigurationService) .getOrThrow(`balances.providers.zerion.apiKey`); networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -396,12 +398,12 @@ describe('Zerion Collectibles Controller', () => { .with('links', { next: zerionNext }) .build(); const chainName = app - .get(IConfigurationService) + .get(IConfigurationService) .getOrThrow( `balances.providers.zerion.chains.${chain.chainId}.chainName`, ); const apiKey = app - .get(IConfigurationService) + .get(IConfigurationService) .getOrThrow(`balances.providers.zerion.apiKey`); networkService.get.mockImplementation(({ url }) => { switch (url) { diff --git a/src/routes/collectibles/collectibles.controller.spec.ts b/src/routes/collectibles/collectibles.controller.spec.ts index cc82790c26..ca4583db47 100644 --- a/src/routes/collectibles/collectibles.controller.spec.ts +++ b/src/routes/collectibles/collectibles.controller.spec.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { AppModule } from '@/app.module'; import { IConfigurationService } from '@/config/configuration.service.interface'; @@ -59,8 +59,10 @@ describe('Collectibles Controller (Unit)', () => { .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); diff --git a/src/routes/common/decorators/pagination.data.decorator.spec.ts b/src/routes/common/decorators/pagination.data.decorator.spec.ts index a9da9c9caa..d8c5c5caff 100644 --- a/src/routes/common/decorators/pagination.data.decorator.spec.ts +++ b/src/routes/common/decorators/pagination.data.decorator.spec.ts @@ -1,6 +1,6 @@ import { Controller, Get, INestApplication, Module } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { PaginationDataDecorator } from '@/routes/common/decorators/pagination.data.decorator'; import { PaginationData } from '@/routes/common/pagination/pagination.data'; import { Server } from 'net'; diff --git a/src/routes/common/filters/global-error.filter.spec.ts b/src/routes/common/filters/global-error.filter.spec.ts index 0da6b28fb7..a2885b0783 100644 --- a/src/routes/common/filters/global-error.filter.spec.ts +++ b/src/routes/common/filters/global-error.filter.spec.ts @@ -7,7 +7,7 @@ import { } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { TestAppProvider } from '@/__tests__/test-app.provider'; -import * as request from 'supertest'; +import request from 'supertest'; import { APP_FILTER } from '@nestjs/core'; import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; import { ConfigurationModule } from '@/config/configuration.module'; diff --git a/src/routes/common/filters/zod-error.filter.spec.ts b/src/routes/common/filters/zod-error.filter.spec.ts index 2ee83d4a4b..18d9862b50 100644 --- a/src/routes/common/filters/zod-error.filter.spec.ts +++ b/src/routes/common/filters/zod-error.filter.spec.ts @@ -1,7 +1,7 @@ import { Body, Controller, Get, INestApplication, Post } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { TestAppProvider } from '@/__tests__/test-app.provider'; -import * as request from 'supertest'; +import request from 'supertest'; import { APP_FILTER } from '@nestjs/core'; import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; import { ConfigurationModule } from '@/config/configuration.module'; diff --git a/src/routes/common/interceptors/cache-control.interceptor.spec.ts b/src/routes/common/interceptors/cache-control.interceptor.spec.ts index c9d704fd64..9a38a5c166 100644 --- a/src/routes/common/interceptors/cache-control.interceptor.spec.ts +++ b/src/routes/common/interceptors/cache-control.interceptor.spec.ts @@ -6,7 +6,7 @@ import { } from '@nestjs/common'; import { CacheControlInterceptor } from '@/routes/common/interceptors/cache-control.interceptor'; import { Test } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { Server } from 'net'; @Controller() diff --git a/src/routes/common/interceptors/cache-control.interceptor.ts b/src/routes/common/interceptors/cache-control.interceptor.ts index 641c4d7c7a..b2b7b47744 100644 --- a/src/routes/common/interceptors/cache-control.interceptor.ts +++ b/src/routes/common/interceptors/cache-control.interceptor.ts @@ -4,6 +4,7 @@ import { Injectable, NestInterceptor, } from '@nestjs/common'; +import { Response } from 'express'; import { Observable, tap } from 'rxjs'; /** @@ -17,7 +18,7 @@ export class CacheControlInterceptor implements NestInterceptor { ): Observable | Promise> { return next.handle().pipe( tap(() => { - const response = context.switchToHttp().getResponse(); + const response: Response = context.switchToHttp().getResponse(); response.header('Cache-Control', 'no-cache'); }), ); diff --git a/src/routes/common/interceptors/route-logger.interceptor.spec.ts b/src/routes/common/interceptors/route-logger.interceptor.spec.ts index 4d2e02bd5f..924528f774 100644 --- a/src/routes/common/interceptors/route-logger.interceptor.spec.ts +++ b/src/routes/common/interceptors/route-logger.interceptor.spec.ts @@ -7,7 +7,7 @@ import { HttpStatus, INestApplication, } from '@nestjs/common'; -import * as request from 'supertest'; +import request from 'supertest'; import { DataSourceError } from '@/domain/errors/data-source.error'; import { faker } from '@faker-js/faker'; import { RouteLoggerInterceptor } from '@/routes/common/interceptors/route-logger.interceptor'; diff --git a/src/routes/community/community.controller.spec.ts b/src/routes/community/community.controller.spec.ts index 8c171f5e55..2d7bd50b0a 100644 --- a/src/routes/community/community.controller.spec.ts +++ b/src/routes/community/community.controller.spec.ts @@ -1,4 +1,4 @@ -import * as request from 'supertest'; +import request from 'supertest'; import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; @@ -64,8 +64,10 @@ describe('Community (Unit)', () => { .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - lockingBaseUri = configurationService.get('locking.baseUri'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + lockingBaseUri = configurationService.getOrThrow('locking.baseUri'); networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); diff --git a/src/routes/contracts/__tests__/get-contract.e2e-spec.ts b/src/routes/contracts/__tests__/get-contract.e2e-spec.ts index 591b37fc9d..9d45175fd9 100644 --- a/src/routes/contracts/__tests__/get-contract.e2e-spec.ts +++ b/src/routes/contracts/__tests__/get-contract.e2e-spec.ts @@ -1,4 +1,4 @@ -import * as request from 'supertest'; +import request from 'supertest'; import { RedisClientType } from 'redis'; import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; diff --git a/src/routes/contracts/contracts.controller.spec.ts b/src/routes/contracts/contracts.controller.spec.ts index d6c8b33a99..fb869c931d 100644 --- a/src/routes/contracts/contracts.controller.spec.ts +++ b/src/routes/contracts/contracts.controller.spec.ts @@ -1,6 +1,6 @@ import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; @@ -47,8 +47,10 @@ describe('Contracts controller', () => { .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); diff --git a/src/routes/data-decode/__tests__/data-decode.e2e-spec.ts b/src/routes/data-decode/__tests__/data-decode.e2e-spec.ts index 09c11c989d..61578762b4 100644 --- a/src/routes/data-decode/__tests__/data-decode.e2e-spec.ts +++ b/src/routes/data-decode/__tests__/data-decode.e2e-spec.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { AppModule } from '@/app.module'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { DataDecoded } from '@/domain/data-decoder/entities/data-decoded.entity'; diff --git a/src/routes/delegates/delegates.controller.spec.ts b/src/routes/delegates/delegates.controller.spec.ts index 358a43fa83..a4bd0e224d 100644 --- a/src/routes/delegates/delegates.controller.spec.ts +++ b/src/routes/delegates/delegates.controller.spec.ts @@ -2,7 +2,7 @@ import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { omit } from 'lodash'; -import * as request from 'supertest'; +import request from 'supertest'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; @@ -54,8 +54,10 @@ describe('Delegates controller', () => { .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); diff --git a/src/routes/delegates/v2/delegates.v2.controller.spec.ts b/src/routes/delegates/v2/delegates.v2.controller.spec.ts index beaed69941..735eae5e98 100644 --- a/src/routes/delegates/v2/delegates.v2.controller.spec.ts +++ b/src/routes/delegates/v2/delegates.v2.controller.spec.ts @@ -27,7 +27,7 @@ import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { omit } from 'lodash'; import { Server } from 'net'; -import * as request from 'supertest'; +import request from 'supertest'; import { getAddress } from 'viem'; describe('Delegates controller', () => { @@ -62,8 +62,10 @@ describe('Delegates controller', () => { .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); diff --git a/src/routes/email/email.controller.delete-email.spec.ts b/src/routes/email/email.controller.delete-email.spec.ts index ea458ebf16..6bf6eb0952 100644 --- a/src/routes/email/email.controller.delete-email.spec.ts +++ b/src/routes/email/email.controller.delete-email.spec.ts @@ -10,7 +10,7 @@ import { TestNetworkModule } from '@/datasources/network/__tests__/test.network. import { TestAppProvider } from '@/__tests__/test-app.provider'; import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; -import * as request from 'supertest'; +import request from 'supertest'; import { faker } from '@faker-js/faker'; import { authPayloadDtoBuilder } from '@/domain/auth/entities/__tests__/auth-payload-dto.entity.builder'; import { IConfigurationService } from '@/config/configuration.service.interface'; @@ -71,8 +71,10 @@ describe('Email controller delete email tests', () => { .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); accountDataSource = moduleFixture.get(IAccountDataSource); emailApi = moduleFixture.get(IEmailApi); networkService = moduleFixture.get(NetworkService); diff --git a/src/routes/email/email.controller.edit-email.spec.ts b/src/routes/email/email.controller.edit-email.spec.ts index 2d6e13a4c0..80091cb510 100644 --- a/src/routes/email/email.controller.edit-email.spec.ts +++ b/src/routes/email/email.controller.edit-email.spec.ts @@ -10,7 +10,7 @@ import { TestNetworkModule } from '@/datasources/network/__tests__/test.network. import { TestAppProvider } from '@/__tests__/test-app.provider'; import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; -import * as request from 'supertest'; +import request from 'supertest'; import { faker } from '@faker-js/faker'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { @@ -86,8 +86,10 @@ describe('Email controller edit email tests', () => { .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); accountDataSource = moduleFixture.get(IAccountDataSource); networkService = moduleFixture.get(NetworkService); jwtService = moduleFixture.get(IJwtService); diff --git a/src/routes/email/email.controller.get-email.spec.ts b/src/routes/email/email.controller.get-email.spec.ts index 5d40f49af1..c522dfb6d8 100644 --- a/src/routes/email/email.controller.get-email.spec.ts +++ b/src/routes/email/email.controller.get-email.spec.ts @@ -12,7 +12,7 @@ import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; import { NetworkModule } from '@/datasources/network/network.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; import { TestAppProvider } from '@/__tests__/test-app.provider'; -import * as request from 'supertest'; +import request from 'supertest'; import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; import { authPayloadDtoBuilder } from '@/domain/auth/entities/__tests__/auth-payload-dto.entity.builder'; diff --git a/src/routes/email/email.controller.resend-verification.spec.ts b/src/routes/email/email.controller.resend-verification.spec.ts index f70ea23ab9..b54a5a287c 100644 --- a/src/routes/email/email.controller.resend-verification.spec.ts +++ b/src/routes/email/email.controller.resend-verification.spec.ts @@ -10,7 +10,7 @@ import { TestNetworkModule } from '@/datasources/network/__tests__/test.network. import { TestAppProvider } from '@/__tests__/test-app.provider'; import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; -import * as request from 'supertest'; +import request from 'supertest'; import { IAccountDataSource } from '@/domain/interfaces/account.datasource.interface'; import { EmailControllerModule } from '@/routes/email/email.controller.module'; import { INestApplication } from '@nestjs/common'; diff --git a/src/routes/email/email.controller.save-email.spec.ts b/src/routes/email/email.controller.save-email.spec.ts index c6510be17e..c5350d9664 100644 --- a/src/routes/email/email.controller.save-email.spec.ts +++ b/src/routes/email/email.controller.save-email.spec.ts @@ -10,7 +10,7 @@ import { TestNetworkModule } from '@/datasources/network/__tests__/test.network. import { TestAppProvider } from '@/__tests__/test-app.provider'; import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; -import * as request from 'supertest'; +import request from 'supertest'; import { faker } from '@faker-js/faker'; import { authPayloadDtoBuilder } from '@/domain/auth/entities/__tests__/auth-payload-dto.entity.builder'; import { IConfigurationService } from '@/config/configuration.service.interface'; diff --git a/src/routes/email/email.controller.verify-email.spec.ts b/src/routes/email/email.controller.verify-email.spec.ts index d80981e721..a19910947a 100644 --- a/src/routes/email/email.controller.verify-email.spec.ts +++ b/src/routes/email/email.controller.verify-email.spec.ts @@ -10,7 +10,7 @@ import { TestNetworkModule } from '@/datasources/network/__tests__/test.network. import { TestAppProvider } from '@/__tests__/test-app.provider'; import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; -import * as request from 'supertest'; +import request from 'supertest'; import { faker } from '@faker-js/faker'; import { IAccountDataSource } from '@/domain/interfaces/account.datasource.interface'; import { EmailControllerModule } from '@/routes/email/email.controller.module'; diff --git a/src/routes/estimations/estimations.controller.spec.ts b/src/routes/estimations/estimations.controller.spec.ts index 9769db229f..40d088b3ac 100644 --- a/src/routes/estimations/estimations.controller.spec.ts +++ b/src/routes/estimations/estimations.controller.spec.ts @@ -2,7 +2,7 @@ import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { omit } from 'lodash'; -import * as request from 'supertest'; +import request from 'supertest'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; @@ -55,8 +55,10 @@ describe('Estimations Controller (Unit)', () => { .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); diff --git a/src/routes/health/__tests__/get-health.e2e-spec.ts b/src/routes/health/__tests__/get-health.e2e-spec.ts index e807332c7f..7c773d1c13 100644 --- a/src/routes/health/__tests__/get-health.e2e-spec.ts +++ b/src/routes/health/__tests__/get-health.e2e-spec.ts @@ -1,6 +1,6 @@ import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { AppModule } from '@/app.module'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { CacheKeyPrefix } from '@/datasources/cache/constants'; diff --git a/src/routes/health/health.controller.spec.ts b/src/routes/health/health.controller.spec.ts index 4d221ba7f2..7217b16731 100644 --- a/src/routes/health/health.controller.spec.ts +++ b/src/routes/health/health.controller.spec.ts @@ -11,7 +11,7 @@ import { TestAppProvider } from '@/__tests__/test-app.provider'; import { INestApplication } from '@nestjs/common'; import { CacheService } from '@/datasources/cache/cache.service.interface'; import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.service'; -import * as request from 'supertest'; +import request from 'supertest'; import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; diff --git a/src/routes/messages/messages.controller.spec.ts b/src/routes/messages/messages.controller.spec.ts index 5641a7e126..177dd724e8 100644 --- a/src/routes/messages/messages.controller.spec.ts +++ b/src/routes/messages/messages.controller.spec.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; @@ -59,8 +59,10 @@ describe('Messages controller', () => { .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); diff --git a/src/routes/notifications/notifications.controller.spec.ts b/src/routes/notifications/notifications.controller.spec.ts index f242b5f917..1fd3f56e6a 100644 --- a/src/routes/notifications/notifications.controller.spec.ts +++ b/src/routes/notifications/notifications.controller.spec.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; @@ -51,8 +51,10 @@ describe('Notifications Controller (Unit)', () => { .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); diff --git a/src/routes/owners/__tests__/get-safes-by-owner.e2e-spec.ts b/src/routes/owners/__tests__/get-safes-by-owner.e2e-spec.ts index 3b89593474..75cfe9c7a7 100644 --- a/src/routes/owners/__tests__/get-safes-by-owner.e2e-spec.ts +++ b/src/routes/owners/__tests__/get-safes-by-owner.e2e-spec.ts @@ -1,7 +1,7 @@ import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { RedisClientType } from 'redis'; -import * as request from 'supertest'; +import request from 'supertest'; import { AppModule } from '@/app.module'; import { redisClientFactory } from '@/__tests__/redis-client.factory'; import { CacheKeyPrefix } from '@/datasources/cache/constants'; diff --git a/src/routes/owners/owners.controller.spec.ts b/src/routes/owners/owners.controller.spec.ts index e4428a0ffb..797d94834a 100644 --- a/src/routes/owners/owners.controller.spec.ts +++ b/src/routes/owners/owners.controller.spec.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; @@ -49,8 +49,10 @@ describe('Owners Controller (Unit)', () => { .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); diff --git a/src/routes/recovery/recovery.controller.spec.ts b/src/routes/recovery/recovery.controller.spec.ts index afdfc2652c..ab84aa64db 100644 --- a/src/routes/recovery/recovery.controller.spec.ts +++ b/src/routes/recovery/recovery.controller.spec.ts @@ -1,4 +1,4 @@ -import * as request from 'supertest'; +import request from 'supertest'; import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; @@ -89,11 +89,13 @@ describe('Recovery (Unit)', () => { .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - alertsUrl = configurationService.get('alerts-api.baseUri'); - alertsAccount = configurationService.get('alerts-api.account'); - alertsProject = configurationService.get('alerts-api.project'); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + alertsUrl = configurationService.getOrThrow('alerts-api.baseUri'); + alertsAccount = configurationService.getOrThrow('alerts-api.account'); + alertsProject = configurationService.getOrThrow('alerts-api.project'); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); jwtService = moduleFixture.get(IJwtService); diff --git a/src/routes/relay/entities/schemas/relay.dto.schema.ts b/src/routes/relay/entities/schemas/relay.dto.schema.ts index 2c4aa48649..e84f543a2b 100644 --- a/src/routes/relay/entities/schemas/relay.dto.schema.ts +++ b/src/routes/relay/entities/schemas/relay.dto.schema.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import * as semver from 'semver'; +import semver from 'semver'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; import { HexSchema } from '@/validation/entities/schemas/hex.schema'; diff --git a/src/routes/relay/relay.controller.spec.ts b/src/routes/relay/relay.controller.spec.ts index 84a7e36119..ce4d96c5b5 100644 --- a/src/routes/relay/relay.controller.spec.ts +++ b/src/routes/relay/relay.controller.spec.ts @@ -1,5 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { AppModule } from '@/app.module'; import { CacheModule } from '@/datasources/cache/cache.module'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; diff --git a/src/routes/root/root.controller.spec.ts b/src/routes/root/root.controller.spec.ts index 9054efb9f4..e1d056cc04 100644 --- a/src/routes/root/root.controller.spec.ts +++ b/src/routes/root/root.controller.spec.ts @@ -5,7 +5,7 @@ import configuration from '@/config/entities/__tests__/configuration'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { CacheModule } from '@/datasources/cache/cache.module'; -import * as request from 'supertest'; +import request from 'supertest'; import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; diff --git a/src/routes/safe-apps/__tests__/get-safe-apps.e2e-spec.ts b/src/routes/safe-apps/__tests__/get-safe-apps.e2e-spec.ts index 797d50c697..3c97e326f2 100644 --- a/src/routes/safe-apps/__tests__/get-safe-apps.e2e-spec.ts +++ b/src/routes/safe-apps/__tests__/get-safe-apps.e2e-spec.ts @@ -1,7 +1,7 @@ import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { RedisClientType } from 'redis'; -import * as request from 'supertest'; +import request from 'supertest'; import { AppModule } from '@/app.module'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { redisClientFactory } from '@/__tests__/redis-client.factory'; @@ -42,6 +42,7 @@ describe('Get Safe Apps e2e test', () => { .expect(200) .expect(({ body }) => { expect(body).toBeInstanceOf(Array); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call body.forEach((safeApp: SafeApp) => expect(safeApp).toEqual( expect.objectContaining({ diff --git a/src/routes/safe-apps/safe-apps.controller.spec.ts b/src/routes/safe-apps/safe-apps.controller.spec.ts index ca079189ed..7157b9f88b 100644 --- a/src/routes/safe-apps/safe-apps.controller.spec.ts +++ b/src/routes/safe-apps/safe-apps.controller.spec.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; @@ -49,8 +49,10 @@ describe('Safe Apps Controller (Unit)', () => { .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); diff --git a/src/routes/safes/pipes/caip-10-addresses.pipe.spec.ts b/src/routes/safes/pipes/caip-10-addresses.pipe.spec.ts index 4145cdd89c..46bf938a94 100644 --- a/src/routes/safes/pipes/caip-10-addresses.pipe.spec.ts +++ b/src/routes/safes/pipes/caip-10-addresses.pipe.spec.ts @@ -7,7 +7,7 @@ import { faker } from '@faker-js/faker'; import { Controller, Get, INestApplication, Query } from '@nestjs/common'; import { TestingModule, Test } from '@nestjs/testing'; import { Server } from 'net'; -import * as request from 'supertest'; +import request from 'supertest'; import { getAddress } from 'viem'; @Controller() diff --git a/src/routes/safes/safes.controller.nonces.spec.ts b/src/routes/safes/safes.controller.nonces.spec.ts index c813fe5227..771c6e03e9 100644 --- a/src/routes/safes/safes.controller.nonces.spec.ts +++ b/src/routes/safes/safes.controller.nonces.spec.ts @@ -13,7 +13,7 @@ import { NetworkService, } from '@/datasources/network/network.service.interface'; import { TestAppProvider } from '@/__tests__/test-app.provider'; -import * as request from 'supertest'; +import request from 'supertest'; import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; import { diff --git a/src/routes/safes/safes.controller.overview.spec.ts b/src/routes/safes/safes.controller.overview.spec.ts index 0b89409238..55f71c66ac 100644 --- a/src/routes/safes/safes.controller.overview.spec.ts +++ b/src/routes/safes/safes.controller.overview.spec.ts @@ -2,7 +2,7 @@ import { INestApplication } from '@nestjs/common'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { Test, TestingModule } from '@nestjs/testing'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; -import * as request from 'supertest'; +import request from 'supertest'; import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; import { pageBuilder } from '@/domain/entities/__tests__/page.builder'; @@ -69,7 +69,9 @@ describe('Safes Controller Overview (Unit)', () => { .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); + const configurationService = moduleFixture.get( + IConfigurationService, + ); safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); pricesProviderUrl = configurationService.getOrThrow( 'balances.providers.safe.prices.baseUri', diff --git a/src/routes/safes/safes.controller.spec.ts b/src/routes/safes/safes.controller.spec.ts index 9e4698be0b..07959bf56e 100644 --- a/src/routes/safes/safes.controller.spec.ts +++ b/src/routes/safes/safes.controller.spec.ts @@ -2,7 +2,7 @@ import { INestApplication } from '@nestjs/common'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { Test, TestingModule } from '@nestjs/testing'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; -import * as request from 'supertest'; +import request from 'supertest'; import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; import { singletonBuilder } from '@/domain/chains/entities/__tests__/singleton.builder'; @@ -68,8 +68,10 @@ describe('Safes Controller (Unit)', () => { .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); diff --git a/src/routes/safes/safes.service.ts b/src/routes/safes/safes.service.ts index aec2e285a8..57f76f0566 100644 --- a/src/routes/safes/safes.service.ts +++ b/src/routes/safes/safes.service.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { max } from 'lodash'; -import * as semver from 'semver'; +import semver from 'semver'; import { IChainsRepository } from '@/domain/chains/chains.repository.interface'; import { Singleton } from '@/domain/chains/entities/singleton.entity'; import { MessagesRepository } from '@/domain/messages/messages.repository'; diff --git a/src/routes/subscriptions/subscription.controller.spec.ts b/src/routes/subscriptions/subscription.controller.spec.ts index b6ee059da0..c6af6b448f 100644 --- a/src/routes/subscriptions/subscription.controller.spec.ts +++ b/src/routes/subscriptions/subscription.controller.spec.ts @@ -11,7 +11,7 @@ import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; import { NetworkModule } from '@/datasources/network/network.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; import { TestAppProvider } from '@/__tests__/test-app.provider'; -import * as request from 'supertest'; +import request from 'supertest'; import { faker } from '@faker-js/faker'; import { Subscription } from '@/domain/account/entities/subscription.entity'; import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; diff --git a/src/routes/transactions/__tests__/controllers/add-transaction-confirmations.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/add-transaction-confirmations.transactions.controller.spec.ts index 1d83f8d16f..82dc1806cf 100644 --- a/src/routes/transactions/__tests__/controllers/add-transaction-confirmations.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/add-transaction-confirmations.transactions.controller.spec.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; @@ -49,8 +49,10 @@ describe('Add transaction confirmations - Transactions Controller (Unit)', () => ], }).compile(); - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); diff --git a/src/routes/transactions/__tests__/controllers/delete-transaction.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/delete-transaction.transactions.controller.spec.ts index fd41b504c8..1808e683f2 100644 --- a/src/routes/transactions/__tests__/controllers/delete-transaction.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/delete-transaction.transactions.controller.spec.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; @@ -56,8 +56,10 @@ describe('Delete Transaction - Transactions Controller (Unit', () => { .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); fakeCacheService = moduleFixture.get(CacheService); diff --git a/src/routes/transactions/__tests__/controllers/get-transaction-by-id.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/get-transaction-by-id.transactions.controller.spec.ts index 9780a06a15..110e2b8052 100644 --- a/src/routes/transactions/__tests__/controllers/get-transaction-by-id.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/get-transaction-by-id.transactions.controller.spec.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; @@ -67,8 +67,10 @@ describe('Get by id - Transactions Controller (Unit)', () => { .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); diff --git a/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts index 6f96847daa..a59af8f6df 100644 --- a/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { IConfigurationService } from '@/config/configuration.service.interface'; import configuration from '@/config/entities/__tests__/configuration'; @@ -64,8 +64,10 @@ describe('List incoming transfers by Safe - Transactions Controller (Unit)', () .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); diff --git a/src/routes/transactions/__tests__/controllers/list-module-transactions-by-safe.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/list-module-transactions-by-safe.transactions.controller.spec.ts index 89999be8bd..99557d05c9 100644 --- a/src/routes/transactions/__tests__/controllers/list-module-transactions-by-safe.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/list-module-transactions-by-safe.transactions.controller.spec.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; @@ -53,8 +53,10 @@ describe('List module transactions by Safe - Transactions Controller (Unit)', () .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); diff --git a/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts index 4f3ab6de92..90b5d825b6 100644 --- a/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { IConfigurationService } from '@/config/configuration.service.interface'; import configuration from '@/config/entities/__tests__/configuration'; @@ -63,8 +63,10 @@ describe('List multisig transactions by Safe - Transactions Controller (Unit)', .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); diff --git a/src/routes/transactions/__tests__/controllers/list-queued-transactions-by-safe.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/list-queued-transactions-by-safe.transactions.controller.spec.ts index 27411a686e..09d266ebd3 100644 --- a/src/routes/transactions/__tests__/controllers/list-queued-transactions-by-safe.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/list-queued-transactions-by-safe.transactions.controller.spec.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; @@ -47,8 +47,10 @@ describe('List queued transactions by Safe - Transactions Controller (Unit)', () ], }).compile(); - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); diff --git a/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts index 107a859664..c9f8255888 100644 --- a/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; @@ -55,8 +55,10 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); diff --git a/src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts index 61cb531fbb..e55af0a23e 100644 --- a/src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; @@ -57,8 +57,10 @@ describe('Propose transaction - Transactions Controller (Unit)', () => { .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); diff --git a/src/routes/transactions/transactions-history.controller.spec.ts b/src/routes/transactions/transactions-history.controller.spec.ts index bf60e49915..cf88134f9c 100644 --- a/src/routes/transactions/transactions-history.controller.spec.ts +++ b/src/routes/transactions/transactions-history.controller.spec.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { IConfigurationService } from '@/config/configuration.service.interface'; import configuration from '@/config/entities/__tests__/configuration'; @@ -831,8 +831,9 @@ describe('Transactions History Controller (Unit)', () => { ) .expect(200) .then(({ body }) => { - // the amount of TransactionItems is limited to the max value expect( + // the amount of TransactionItems is limited to the max value + // eslint-disable-next-line @typescript-eslint/no-unsafe-call body.results.filter( (item: TransactionItem) => item.type === 'TRANSACTION', ), 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 89192e477a..2a90ab6478 100644 --- a/src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts +++ b/src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import { INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; +import request from 'supertest'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { IConfigurationService } from '@/config/configuration.service.interface'; import configuration from '@/config/entities/__tests__/configuration'; @@ -91,8 +91,10 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); diff --git a/src/routes/transactions/transactions-view.controller.spec.ts b/src/routes/transactions/transactions-view.controller.spec.ts index 0494e6ee49..b5bc04fb57 100644 --- a/src/routes/transactions/transactions-view.controller.spec.ts +++ b/src/routes/transactions/transactions-view.controller.spec.ts @@ -16,7 +16,7 @@ import { NetworkModule } from '@/datasources/network/network.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { TestAppProvider } from '@/__tests__/test-app.provider'; -import * as request from 'supertest'; +import request from 'supertest'; import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; import { dataDecodedBuilder } from '@/domain/data-decoder/entities/__tests__/data-decoded.builder'; @@ -67,9 +67,11 @@ describe('TransactionsViewController tests', () => { .useModule(TestQueuesApiModule) .compile(); - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); - swapsApiUrl = configurationService.get('swaps.api.1'); + const configurationService = moduleFixture.get( + IConfigurationService, + ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); + swapsApiUrl = configurationService.getOrThrow('swaps.api.1'); networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); diff --git a/src/types/postgres-shift.d.ts b/src/types/postgres-shift.d.ts index 60ce77dec3..0e77546f20 100644 --- a/src/types/postgres-shift.d.ts +++ b/src/types/postgres-shift.d.ts @@ -1 +1,12 @@ -declare module 'postgres-shift'; +declare module 'postgres-shift' { + import { Sql } from 'postgres'; + + // https://github.com/porsager/postgres-shift/blob/master/index.js + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export default async (options: { + sql: Sql; + path?: string; + before?: boolean | null; + after?: boolean | null; + }): Promise => {}; +} diff --git a/tsconfig.json b/tsconfig.json index 23c969f9df..1d54375025 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "module": "commonjs", + "esModuleInterop": true, "declaration": true, "removeComments": true, "emitDecoratorMetadata": true, From 213c8977d4dbd590f103a07e9dfba91fa268ff9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Fri, 7 Jun 2024 18:50:44 +0200 Subject: [PATCH 066/207] Fix getAddress unhandled error in TransactionsService (#1621) Fix viem getAddress unhandled error --- src/routes/transactions/transactions.service.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/routes/transactions/transactions.service.ts b/src/routes/transactions/transactions.service.ts index 7ce8835f7f..f82bc80920 100644 --- a/src/routes/transactions/transactions.service.ts +++ b/src/routes/transactions/transactions.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { MultisigTransaction as DomainMultisigTransaction } from '@/domain/safe/entities/multisig-transaction.entity'; import { SafeRepository } from '@/domain/safe/safe.repository'; import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; @@ -35,7 +35,7 @@ import { TransactionPreviewMapper } from '@/routes/transactions/mappers/transact import { TransactionsHistoryMapper } from '@/routes/transactions/mappers/transactions-history.mapper'; import { TransferDetailsMapper } from '@/routes/transactions/mappers/transfers/transfer-details.mapper'; import { TransferMapper } from '@/routes/transactions/mappers/transfers/transfer.mapper'; -import { getAddress } from 'viem'; +import { getAddress, isAddress } from 'viem'; @Injectable() export class TransactionsService { @@ -70,6 +70,10 @@ export class TransactionsService { } case TRANSFER_PREFIX: { + if (!isAddress(safeAddress)) { + throw new BadRequestException('Invalid transaction ID'); + } + const [transfer, safe] = await Promise.all([ this.safeRepository.getTransfer({ chainId: args.chainId, @@ -89,6 +93,10 @@ export class TransactionsService { } case MULTISIG_TRANSACTION_PREFIX: { + if (!isAddress(safeAddress)) { + throw new BadRequestException('Invalid transaction ID'); + } + const [tx, safe] = await Promise.all([ this.safeRepository.getMultiSigTransaction({ chainId: args.chainId, From 8f90c03106c1ce48f7420522e6bad906bda3a67d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Mon, 10 Jun 2024 10:04:21 +0200 Subject: [PATCH 067/207] Adjust ZerionBalanceSchema to allow undefined attributes.fungibleInfo.description (#1622) Changes the validation of ZerionBalanceSchema.attributes.fungibleInfo.description in order to allow an undefined value for the field --- .../balances-api/entities/zerion-balance.entity.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/datasources/balances-api/entities/zerion-balance.entity.ts b/src/datasources/balances-api/entities/zerion-balance.entity.ts index 05ccdf308e..c3f9c94a0c 100644 --- a/src/datasources/balances-api/entities/zerion-balance.entity.ts +++ b/src/datasources/balances-api/entities/zerion-balance.entity.ts @@ -28,7 +28,7 @@ const ZerionImplementationSchema = z.object({ const ZerionFungibleInfoSchema = z.object({ name: z.string().nullable(), symbol: z.string().nullable(), - description: z.string().nullable(), + description: z.string().nullish().default(null), icon: z .object({ url: z.string().nullable(), @@ -67,3 +67,5 @@ export const ZerionBalanceSchema = z.object({ export const ZerionBalancesSchema = z.object({ data: z.array(ZerionBalanceSchema), }); + +// TODO: add schema tests. From da063e4477bfed5a22b8cfa61a1b2d2fa0576791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Mon, 10 Jun 2024 13:31:54 +0200 Subject: [PATCH 068/207] Add FF_COUNTERFACTUAL_BALANCES (#1625) Add FF_COUNTERFACTUAL_BALANCES --- .../entities/__tests__/configuration.ts | 1 + src/config/entities/configuration.ts | 2 ++ .../balances-api/balances-api.manager.spec.ts | 2 ++ .../balances-api/balances-api.manager.ts | 27 ++++++++++++------- .../zerion-balances.controller.spec.ts | 4 +++ .../balances/balances.controller.spec.ts | 11 +++++++- .../collectibles.controller.spec.ts | 15 ++++++++--- .../safes/safes.controller.overview.spec.ts | 4 +++ 8 files changed, 53 insertions(+), 13 deletions(-) diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index 95c9f8222c..638ffa1826 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -117,6 +117,7 @@ export default (): ReturnType => ({ confirmationView: false, eventsQueue: false, delegatesV2: false, + counterfactualBalances: false, }, httpClient: { requestTimeout: faker.number.int() }, locking: { diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index dc14328c4d..d1a6cf14d3 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -183,6 +183,8 @@ export default () => ({ process.env.FF_CONFIRMATION_VIEW?.toLowerCase() === 'true', eventsQueue: process.env.FF_EVENTS_QUEUE?.toLowerCase() === 'true', delegatesV2: process.env.FF_DELEGATES_V2?.toLowerCase() === 'true', + counterfactualBalances: + process.env.FF_COUNTERFACTUAL_BALANCES?.toLowerCase() === 'true', }, httpClient: { // Timeout in milliseconds to be used for the HTTP client. diff --git a/src/datasources/balances-api/balances-api.manager.spec.ts b/src/datasources/balances-api/balances-api.manager.spec.ts index 5901be4729..f26fa06673 100644 --- a/src/datasources/balances-api/balances-api.manager.spec.ts +++ b/src/datasources/balances-api/balances-api.manager.spec.ts @@ -73,6 +73,7 @@ beforeEach(() => { configurationServiceMock.getOrThrow.mockImplementation((key) => { if (key === 'features.zerionBalancesChainIds') return ZERION_BALANCES_CHAIN_IDS; + if (key === 'features.counterfactualBalances') return true; }); }); @@ -156,6 +157,7 @@ describe('Balances API Manager Tests', () => { return notFoundExpireTimeSeconds; else if (key === 'features.zerionBalancesChainIds') return ZERION_BALANCES_CHAIN_IDS; + else if (key === 'features.counterfactualBalances') return true; throw new Error(`Unexpected key: ${key}`); }); configApiMock.getChain.mockResolvedValue(chain); diff --git a/src/datasources/balances-api/balances-api.manager.ts b/src/datasources/balances-api/balances-api.manager.ts index 9d5927a9f3..19af491642 100644 --- a/src/datasources/balances-api/balances-api.manager.ts +++ b/src/datasources/balances-api/balances-api.manager.ts @@ -18,6 +18,7 @@ import { ITransactionApiManager } from '@/domain/interfaces/transaction-api.mana @Injectable() export class BalancesApiManager implements IBalancesApiManager { private safeBalancesApiMap: Record = {}; + private readonly isCounterFactualBalancesEnabled: boolean; private readonly zerionChainIds: string[]; private readonly zerionBalancesApi: IBalancesApi; private readonly useVpcUrl: boolean; @@ -34,6 +35,10 @@ export class BalancesApiManager implements IBalancesApiManager { @Inject(ITransactionApiManager) private readonly transactionApiManager: ITransactionApiManager, ) { + this.isCounterFactualBalancesEnabled = + this.configurationService.getOrThrow( + 'features.counterfactualBalances', + ); this.zerionChainIds = this.configurationService.getOrThrow( 'features.zerionBalancesChainIds', ); @@ -51,16 +56,20 @@ export class BalancesApiManager implements IBalancesApiManager { return this.zerionBalancesApi; } - // SafeBalancesApi will be returned only if TransactionApi returns the Safe data. - // Otherwise ZerionBalancesApi will be returned as the Safe is considered counterfactual/not deployed. - try { - const transactionApi = - await this.transactionApiManager.getTransactionApi(chainId); - await transactionApi.getSafe(safeAddress); - return this._getSafeBalancesApi(chainId); - } catch { - return this.zerionBalancesApi; + if (this.isCounterFactualBalancesEnabled) { + // SafeBalancesApi will be returned only if TransactionApi returns the Safe data. + // Otherwise ZerionBalancesApi will be returned as the Safe is considered counterfactual/not deployed. + try { + const transactionApi = + await this.transactionApiManager.getTransactionApi(chainId); + await transactionApi.getSafe(safeAddress); + return this._getSafeBalancesApi(chainId); + } catch { + return this.zerionBalancesApi; + } } + + return this._getSafeBalancesApi(chainId); } async getFiatCodes(): Promise { diff --git a/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts b/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts index 2ce03e7b5a..734b33e1db 100644 --- a/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts +++ b/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts @@ -62,6 +62,10 @@ describe('Balances Controller (Unit)', () => { }, }, }, + features: { + ...defaultConfiguration.features, + counterfactualBalances: true, + }, }); const moduleFixture: TestingModule = await Test.createTestingModule({ diff --git a/src/routes/balances/balances.controller.spec.ts b/src/routes/balances/balances.controller.spec.ts index f6e9dce21b..220d09d4f9 100644 --- a/src/routes/balances/balances.controller.spec.ts +++ b/src/routes/balances/balances.controller.spec.ts @@ -38,8 +38,17 @@ describe('Balances Controller (Unit)', () => { beforeEach(async () => { jest.resetAllMocks(); + const defaultConfiguration = configuration(); + const testConfiguration = (): typeof defaultConfiguration => ({ + ...defaultConfiguration, + features: { + ...defaultConfiguration.features, + counterfactualBalances: true, + }, + }); + const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule.register(configuration)], + imports: [AppModule.register(testConfiguration)], }) .overrideModule(AccountDataSourceModule) .useModule(TestAccountDataSourceModule) diff --git a/src/routes/collectibles/collectibles.controller.spec.ts b/src/routes/collectibles/collectibles.controller.spec.ts index ca4583db47..be601be122 100644 --- a/src/routes/collectibles/collectibles.controller.spec.ts +++ b/src/routes/collectibles/collectibles.controller.spec.ts @@ -44,8 +44,17 @@ describe('Collectibles Controller (Unit)', () => { beforeEach(async () => { jest.resetAllMocks(); + const defaultConfiguration = configuration(); + const testConfiguration = (): typeof defaultConfiguration => ({ + ...defaultConfiguration, + features: { + ...defaultConfiguration.features, + counterfactualBalances: false, + }, + }); + const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule.register(configuration)], + imports: [AppModule.register(testConfiguration)], }) .overrideModule(AccountDataSourceModule) .useModule(TestAccountDataSourceModule) @@ -154,7 +163,7 @@ describe('Collectibles Controller (Unit)', () => { ) .expect(200); - expect(networkService.get.mock.calls[2][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[1][0].networkRequest).toStrictEqual({ params: { limit: 10, offset: 20, @@ -200,7 +209,7 @@ describe('Collectibles Controller (Unit)', () => { ) .expect(200); - expect(networkService.get.mock.calls[2][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[1][0].networkRequest).toStrictEqual({ params: { limit: PaginationData.DEFAULT_LIMIT, offset: PaginationData.DEFAULT_OFFSET, diff --git a/src/routes/safes/safes.controller.overview.spec.ts b/src/routes/safes/safes.controller.overview.spec.ts index 55f71c66ac..da27d1d546 100644 --- a/src/routes/safes/safes.controller.overview.spec.ts +++ b/src/routes/safes/safes.controller.overview.spec.ts @@ -52,6 +52,10 @@ describe('Safes Controller Overview (Unit)', () => { maxOverviews: 3, }, }, + features: { + ...configuration().features, + counterfactualBalances: true, + }, }); const moduleFixture: TestingModule = await Test.createTestingModule({ From f8cf9d433e4343c22f43298dd01710eca0280208 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 10 Jun 2024 17:20:15 +0200 Subject: [PATCH 069/207] Add `transaction_hash` query to `module-transactions` (#1624) Adds and forwards a `transaction_hash` query to the `module-transactions` endpoint: - Add `transaction_hash` query to `module-transactions` endpoint and propagate it to Transaction Service - Add `txHash` value to field of module transaction cache - Update tests accordingly --- src/datasources/cache/cache.router.ts | 3 ++- .../transaction-api/transaction-api.service.spec.ts | 8 ++++++-- .../transaction-api/transaction-api.service.ts | 2 ++ src/domain/interfaces/transaction-api.interface.ts | 1 + src/domain/safe/safe.repository.interface.ts | 1 + src/domain/safe/safe.repository.ts | 1 + src/routes/transactions/transactions.controller.ts | 2 ++ src/routes/transactions/transactions.service.ts | 1 + 8 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/datasources/cache/cache.router.ts b/src/datasources/cache/cache.router.ts index 8c14ef1029..0fce955b2d 100644 --- a/src/datasources/cache/cache.router.ts +++ b/src/datasources/cache/cache.router.ts @@ -215,13 +215,14 @@ export class CacheRouter { chainId: string; safeAddress: `0x${string}`; to?: string; + txHash?: string; module?: string; limit?: number; offset?: number; }): CacheDir { return new CacheDir( CacheRouter.getModuleTransactionsCacheKey(args), - `${args.to}_${args.module}_${args.limit}_${args.offset}`, + `${args.to}_${args.module}_${args.txHash}_${args.limit}_${args.offset}`, ); } diff --git a/src/datasources/transaction-api/transaction-api.service.spec.ts b/src/datasources/transaction-api/transaction-api.service.spec.ts index 45d12aecd1..45c1e9f9ae 100644 --- a/src/datasources/transaction-api/transaction-api.service.spec.ts +++ b/src/datasources/transaction-api/transaction-api.service.spec.ts @@ -1177,7 +1177,7 @@ describe('TransactionApi', () => { const getModuleTransactionsUrl = `${baseUrl}/api/v1/safes/${moduleTransaction.safe}/module-transactions/`; const cacheDir = new CacheDir( `${chainId}_module_transactions_${moduleTransaction.safe}`, - `${moduleTransaction.to}_${moduleTransaction.module}_${limit}_${offset}`, + `${moduleTransaction.to}_${moduleTransaction.module}_${moduleTransaction.transactionHash}_${limit}_${offset}`, ); mockDataSource.get.mockResolvedValueOnce(moduleTransactionsPage); @@ -1185,6 +1185,7 @@ describe('TransactionApi', () => { safeAddress: moduleTransaction.safe, to: moduleTransaction.to, module: moduleTransaction.module, + txHash: moduleTransaction.transactionHash, limit, offset, }); @@ -1200,6 +1201,7 @@ describe('TransactionApi', () => { params: { to: moduleTransaction.to, module: moduleTransaction.module, + transaction_hash: moduleTransaction.transactionHash, limit, offset, }, @@ -1222,7 +1224,7 @@ describe('TransactionApi', () => { const expected = new DataSourceError(errorMessage, statusCode); const cacheDir = new CacheDir( `${chainId}_module_transactions_${moduleTransaction.safe}`, - `${moduleTransaction.to}_${moduleTransaction.module}_${limit}_${offset}`, + `${moduleTransaction.to}_${moduleTransaction.module}_${moduleTransaction.transactionHash}_${limit}_${offset}`, ); mockDataSource.get.mockRejectedValueOnce( new NetworkResponseError( @@ -1239,6 +1241,7 @@ describe('TransactionApi', () => { safeAddress: moduleTransaction.safe, to: moduleTransaction.to, module: moduleTransaction.module, + txHash: moduleTransaction.transactionHash, limit, offset, }), @@ -1254,6 +1257,7 @@ describe('TransactionApi', () => { params: { to: moduleTransaction.to, module: moduleTransaction.module, + transaction_hash: moduleTransaction.transactionHash, limit, offset, }, diff --git a/src/datasources/transaction-api/transaction-api.service.ts b/src/datasources/transaction-api/transaction-api.service.ts index 26d4a683a0..9ad0922c66 100644 --- a/src/datasources/transaction-api/transaction-api.service.ts +++ b/src/datasources/transaction-api/transaction-api.service.ts @@ -502,6 +502,7 @@ export class TransactionApi implements ITransactionApi { async getModuleTransactions(args: { safeAddress: `0x${string}`; to?: string; + txHash?: string; module?: string; limit?: number; offset?: number; @@ -519,6 +520,7 @@ export class TransactionApi implements ITransactionApi { networkRequest: { params: { to: args.to, + transaction_hash: args.txHash, module: args.module, limit: args.limit, offset: args.offset, diff --git a/src/domain/interfaces/transaction-api.interface.ts b/src/domain/interfaces/transaction-api.interface.ts index a39bcf3651..9ba912f91e 100644 --- a/src/domain/interfaces/transaction-api.interface.ts +++ b/src/domain/interfaces/transaction-api.interface.ts @@ -125,6 +125,7 @@ export interface ITransactionApi { getModuleTransactions(args: { safeAddress: `0x${string}`; to?: string; + txHash?: string; module?: string; limit?: number; offset?: number; diff --git a/src/domain/safe/safe.repository.interface.ts b/src/domain/safe/safe.repository.interface.ts index 6d5dea7b24..a64c049362 100644 --- a/src/domain/safe/safe.repository.interface.ts +++ b/src/domain/safe/safe.repository.interface.ts @@ -67,6 +67,7 @@ export interface ISafeRepository { chainId: string; safeAddress: `0x${string}`; to?: string; + txHash?: string; module?: string; limit?: number; offset?: number; diff --git a/src/domain/safe/safe.repository.ts b/src/domain/safe/safe.repository.ts index 6c34516bfb..c2c7866cfb 100644 --- a/src/domain/safe/safe.repository.ts +++ b/src/domain/safe/safe.repository.ts @@ -151,6 +151,7 @@ export class SafeRepository implements ISafeRepository { chainId: string; safeAddress: `0x${string}`; to?: string; + txHash?: string; module?: string; limit?: number; offset?: number; diff --git a/src/routes/transactions/transactions.controller.ts b/src/routes/transactions/transactions.controller.ts index 77c651bdb8..bafe0b6741 100644 --- a/src/routes/transactions/transactions.controller.ts +++ b/src/routes/transactions/transactions.controller.ts @@ -124,12 +124,14 @@ export class TransactionsController { safeAddress: `0x${string}`, @Query('to') to?: string, @Query('module') module?: string, + @Query('transaction_hash') txHash?: string, ): Promise> { return this.transactionsService.getModuleTransactions({ chainId, routeUrl, safeAddress, to, + txHash, module, paginationData, }); diff --git a/src/routes/transactions/transactions.service.ts b/src/routes/transactions/transactions.service.ts index f82bc80920..233b5f0e92 100644 --- a/src/routes/transactions/transactions.service.ts +++ b/src/routes/transactions/transactions.service.ts @@ -222,6 +222,7 @@ export class TransactionsService { safeAddress: `0x${string}`; to?: string; module?: string; + txHash?: string; paginationData?: PaginationData; }): Promise> { const domainTransactions = await this.safeRepository.getModuleTransactions({ From cba01d6c75911c9f84b23def79e8e103192f8b6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Tue, 11 Jun 2024 00:03:03 +0200 Subject: [PATCH 070/207] Change E2E testing Safe addresses (#1620) Change the testing Safe address used in E2E tests --- .../__tests__/event-hooks-queue.e2e-spec.ts | 88 +++++++++++++------ src/routes/common/__tests__/constants.ts | 11 +++ .../__tests__/get-safes-by-owner.e2e-spec.ts | 11 +-- 3 files changed, 80 insertions(+), 30 deletions(-) create mode 100644 src/routes/common/__tests__/constants.ts diff --git a/src/routes/cache-hooks/__tests__/event-hooks-queue.e2e-spec.ts b/src/routes/cache-hooks/__tests__/event-hooks-queue.e2e-spec.ts index dbbc1da7a8..315382014c 100644 --- a/src/routes/cache-hooks/__tests__/event-hooks-queue.e2e-spec.ts +++ b/src/routes/cache-hooks/__tests__/event-hooks-queue.e2e-spec.ts @@ -13,6 +13,7 @@ import { ChannelWrapper } from 'amqp-connection-manager'; import { RedisClientType } from 'redis'; import { getAddress } from 'viem'; import { Server } from 'net'; +import { TEST_SAFE } from '@/routes/common/__tests__/constants'; describe('Events queue processing e2e tests', () => { let app: INestApplication; @@ -21,9 +22,6 @@ describe('Events queue processing e2e tests', () => { let queueName: string; const cacheKeyPrefix = crypto.randomUUID(); const queue = crypto.randomUUID(); - const chainId = '1'; // Mainnet - // TODO: use a proper "test" safe address - const safeAddress = getAddress('0x9a8FEe232DCF73060Af348a1B62Cdb0a19852d13'); beforeAll(async () => { const defaultConfiguration = configuration(); @@ -83,7 +81,7 @@ describe('Events queue processing e2e tests', () => { }, ])('$type clears balances', async (payload) => { const cacheDir = new CacheDir( - `${chainId}_safe_balances_${getAddress(safeAddress)}`, + `${TEST_SAFE.chainId}_safe_balances_${getAddress(TEST_SAFE.address)}`, faker.string.alpha(), ); await redisClient.hSet( @@ -91,7 +89,11 @@ describe('Events queue processing e2e tests', () => { cacheDir.field, faker.string.alpha(), ); - const data = { address: safeAddress, chainId, ...payload }; + const data = { + address: TEST_SAFE.address, + chainId: TEST_SAFE.chainId, + ...payload, + }; await channel.sendToQueue(queueName, data); @@ -125,7 +127,7 @@ describe('Events queue processing e2e tests', () => { }, ])('$type clears multisig transactions', async (payload) => { const cacheDir = new CacheDir( - `${chainId}_multisig_transactions_${getAddress(safeAddress)}`, + `${TEST_SAFE.chainId}_multisig_transactions_${getAddress(TEST_SAFE.address)}`, faker.string.alpha(), ); await redisClient.hSet( @@ -133,7 +135,11 @@ describe('Events queue processing e2e tests', () => { cacheDir.field, faker.string.alpha(), ); - const data = { address: safeAddress, chainId, ...payload }; + const data = { + address: TEST_SAFE.address, + chainId: TEST_SAFE.chainId, + ...payload, + }; await channel.sendToQueue(queueName, data); @@ -167,7 +173,7 @@ describe('Events queue processing e2e tests', () => { }, ])('$type clears multisig transaction', async (payload) => { const cacheDir = new CacheDir( - `${chainId}_multisig_transaction_${payload.safeTxHash}`, + `${TEST_SAFE.chainId}_multisig_transaction_${payload.safeTxHash}`, faker.string.alpha(), ); await redisClient.hSet( @@ -175,7 +181,11 @@ describe('Events queue processing e2e tests', () => { cacheDir.field, faker.string.alpha(), ); - const data = { address: safeAddress, chainId, ...payload }; + const data = { + address: TEST_SAFE.address, + chainId: TEST_SAFE.chainId, + ...payload, + }; await channel.sendToQueue(queueName, data); @@ -201,7 +211,7 @@ describe('Events queue processing e2e tests', () => { }, ])('$type clears safe info', async (payload) => { const cacheDir = new CacheDir( - `${chainId}_safe_${getAddress(safeAddress)}`, + `${TEST_SAFE.chainId}_safe_${getAddress(TEST_SAFE.address)}`, faker.string.alpha(), ); await redisClient.hSet( @@ -209,7 +219,11 @@ describe('Events queue processing e2e tests', () => { cacheDir.field, faker.string.alpha(), ); - const data = { address: safeAddress, chainId, ...payload }; + const data = { + address: TEST_SAFE.address, + chainId: TEST_SAFE.chainId, + ...payload, + }; await channel.sendToQueue(queueName, data); @@ -240,7 +254,7 @@ describe('Events queue processing e2e tests', () => { }, ])('$type clears safe collectibles', async (payload) => { const cacheDir = new CacheDir( - `${chainId}_safe_collectibles_${getAddress(safeAddress)}`, + `${TEST_SAFE.chainId}_safe_collectibles_${getAddress(TEST_SAFE.address)}`, faker.string.alpha(), ); await redisClient.hSet( @@ -248,7 +262,11 @@ describe('Events queue processing e2e tests', () => { cacheDir.field, faker.string.alpha(), ); - const data = { address: safeAddress, chainId, ...payload }; + const data = { + address: TEST_SAFE.address, + chainId: TEST_SAFE.chainId, + ...payload, + }; await channel.sendToQueue(queueName, data); @@ -279,7 +297,7 @@ describe('Events queue processing e2e tests', () => { }, ])('$type clears safe collectible transfers', async (payload) => { const cacheDir = new CacheDir( - `${chainId}_transfers_${getAddress(safeAddress)}`, + `${TEST_SAFE.chainId}_transfers_${getAddress(TEST_SAFE.address)}`, faker.string.alpha(), ); await redisClient.hSet( @@ -287,7 +305,11 @@ describe('Events queue processing e2e tests', () => { cacheDir.field, faker.string.alpha(), ); - const data = { address: safeAddress, chainId, ...payload }; + const data = { + address: TEST_SAFE.address, + chainId: TEST_SAFE.chainId, + ...payload, + }; await channel.sendToQueue(queueName, data); @@ -313,7 +335,7 @@ describe('Events queue processing e2e tests', () => { }, ])('$type clears incoming transfers', async (payload) => { const cacheDir = new CacheDir( - `${chainId}_incoming_transfers_${getAddress(safeAddress)}`, + `${TEST_SAFE.chainId}_incoming_transfers_${getAddress(TEST_SAFE.address)}`, faker.string.alpha(), ); await redisClient.hSet( @@ -321,7 +343,11 @@ describe('Events queue processing e2e tests', () => { cacheDir.field, faker.string.alpha(), ); - const data = { address: safeAddress, chainId, ...payload }; + const data = { + address: TEST_SAFE.address, + chainId: TEST_SAFE.chainId, + ...payload, + }; await channel.sendToQueue(queueName, data); @@ -342,7 +368,7 @@ describe('Events queue processing e2e tests', () => { }, ])('$type clears module transactions', async (payload) => { const cacheDir = new CacheDir( - `${chainId}_module_transactions_${getAddress(safeAddress)}`, + `${TEST_SAFE.chainId}_module_transactions_${getAddress(TEST_SAFE.address)}`, faker.string.alpha(), ); await redisClient.hSet( @@ -350,7 +376,11 @@ describe('Events queue processing e2e tests', () => { cacheDir.field, faker.string.alpha(), ); - const data = { address: safeAddress, chainId, ...payload }; + const data = { + address: TEST_SAFE.address, + chainId: TEST_SAFE.chainId, + ...payload, + }; await channel.sendToQueue(queueName, data); @@ -396,7 +426,7 @@ describe('Events queue processing e2e tests', () => { }, ])('$type clears all transactions', async (payload) => { const cacheDir = new CacheDir( - `${chainId}_all_transactions_${getAddress(safeAddress)}`, + `${TEST_SAFE.chainId}_all_transactions_${getAddress(TEST_SAFE.address)}`, faker.string.alpha(), ); await redisClient.hSet( @@ -404,7 +434,11 @@ describe('Events queue processing e2e tests', () => { cacheDir.field, faker.string.alpha(), ); - const data = { address: safeAddress, chainId, ...payload }; + const data = { + address: TEST_SAFE.address, + chainId: TEST_SAFE.chainId, + ...payload, + }; await channel.sendToQueue(queueName, data); @@ -428,7 +462,7 @@ describe('Events queue processing e2e tests', () => { }, ])('$type clears messages', async (payload) => { const cacheDir = new CacheDir( - `${chainId}_messages_${getAddress(safeAddress)}`, + `${TEST_SAFE.chainId}_messages_${getAddress(TEST_SAFE.address)}`, faker.string.alpha(), ); await redisClient.hSet( @@ -436,7 +470,11 @@ describe('Events queue processing e2e tests', () => { cacheDir.field, faker.string.alpha(), ); - const data = { address: safeAddress, chainId, ...payload }; + const data = { + address: TEST_SAFE.address, + chainId: TEST_SAFE.chainId, + ...payload, + }; await channel.sendToQueue(queueName, data); @@ -479,13 +517,13 @@ describe('Events queue processing e2e tests', () => { type: 'SAFE_APPS_UPDATE', }, ])('$type clears safe apps', async (payload) => { - const cacheDir = new CacheDir(`${chainId}_safe_apps`, ''); + const cacheDir = new CacheDir(`${TEST_SAFE.chainId}_safe_apps`, ''); await redisClient.hSet( `${cacheKeyPrefix}-${cacheDir.key}`, cacheDir.field, faker.string.alpha(), ); - const data = { chainId, ...payload }; + const data = { chainId: TEST_SAFE.chainId, ...payload }; await channel.sendToQueue(queueName, data); diff --git a/src/routes/common/__tests__/constants.ts b/src/routes/common/__tests__/constants.ts new file mode 100644 index 0000000000..0cf342b5ad --- /dev/null +++ b/src/routes/common/__tests__/constants.ts @@ -0,0 +1,11 @@ +interface TestSafe { + chainId: string; + address: `0x${string}`; + owners: `0x${string}`[]; +} + +export const TEST_SAFE: TestSafe = { + chainId: '1', + address: '0x8675B754342754A30A2AeF474D114d8460bca19b' as const, + owners: ['0x6c15f69EE76DA763e5b5DB6f7f0C29eb625bc9B7' as const], +}; diff --git a/src/routes/owners/__tests__/get-safes-by-owner.e2e-spec.ts b/src/routes/owners/__tests__/get-safes-by-owner.e2e-spec.ts index 75cfe9c7a7..d38ce1f3d8 100644 --- a/src/routes/owners/__tests__/get-safes-by-owner.e2e-spec.ts +++ b/src/routes/owners/__tests__/get-safes-by-owner.e2e-spec.ts @@ -6,11 +6,11 @@ import { AppModule } from '@/app.module'; import { redisClientFactory } from '@/__tests__/redis-client.factory'; import { CacheKeyPrefix } from '@/datasources/cache/constants'; import { Server } from 'net'; +import { TEST_SAFE } from '@/routes/common/__tests__/constants'; describe('Get safes by owner e2e test', () => { let app: INestApplication; let redisClient: RedisClientType; - const chainId = '1'; // Mainnet const cacheKeyPrefix = crypto.randomUUID(); beforeAll(async () => { @@ -32,15 +32,16 @@ describe('Get safes by owner e2e test', () => { }); it('GET /owners//safes', async () => { - const ownerAddress = '0xf10E2042ec19747401E5EA174EfB63A0058265E6'; - const ownerCacheKey = `${cacheKeyPrefix}-${chainId}_owner_safes_${ownerAddress}`; + const ownerCacheKey = `${cacheKeyPrefix}-${TEST_SAFE.chainId}_owner_safes_${TEST_SAFE.owners[0]}`; await request(app.getHttpServer()) - .get(`/chains/${chainId}/owners/${ownerAddress}/safes`) + .get(`/chains/${TEST_SAFE.chainId}/owners/${TEST_SAFE.owners[0]}/safes`) .expect(200) .then(({ body }) => { expect(body).toEqual( - expect.objectContaining({ safes: expect.any(Array) }), + expect.objectContaining({ + safes: expect.arrayContaining([TEST_SAFE.address]), + }), ); }); From 64c78440c0ddcdc7d774cffe09d5770945523238 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Tue, 11 Jun 2024 00:05:53 +0200 Subject: [PATCH 071/207] Cache Safe existence check for counterfactual balances (#1626) Introduces a new `isSafe` method on the `TransactionApi` that returns an _indefinitely_ cached `boolean` regarding deployment state. The cache is invalidated on the `SAFE_CREATED` event: - Check `isSafe` within `BalancesApiManager` accordingly and propagate - Add new `${chainId}_safe_exists_${address}` cache routing - Add/update tests accordingly --- .../balances-api/balances-api.manager.spec.ts | 9 +- .../balances-api/balances-api.manager.ts | 24 ++-- src/datasources/cache/cache.router.ts | 15 ++ .../transaction-api.manager.spec.ts | 6 + .../transaction-api.manager.ts | 3 + .../transaction-api.service.spec.ts | 131 ++++++++++++++++++ .../transaction-api.service.ts | 67 +++++++++ .../interfaces/transaction-api.interface.ts | 4 + src/domain/safe/safe.repository.interface.ts | 4 + src/domain/safe/safe.repository.ts | 20 +++ .../cache-hooks.controller.spec.ts | 39 ++++++ src/routes/cache-hooks/cache-hooks.service.ts | 2 +- .../entities/__tests__/safe-created.build.ts | 5 +- .../__tests__/safe-created.schema.spec.ts | 14 ++ .../entities/schemas/safe-created.schema.ts | 3 + 15 files changed, 326 insertions(+), 20 deletions(-) diff --git a/src/datasources/balances-api/balances-api.manager.spec.ts b/src/datasources/balances-api/balances-api.manager.spec.ts index f26fa06673..f5d4152d01 100644 --- a/src/datasources/balances-api/balances-api.manager.spec.ts +++ b/src/datasources/balances-api/balances-api.manager.spec.ts @@ -12,7 +12,6 @@ import { getAddress } from 'viem'; import { sample } from 'lodash'; import { ITransactionApiManager } from '@/domain/interfaces/transaction-api.manager.interface'; import { ITransactionApi } from '@/domain/interfaces/transaction-api.interface'; -import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; const configurationService = { getOrThrow: jest.fn(), @@ -43,7 +42,7 @@ const transactionApiManagerMock = { } as jest.MockedObjectDeep; const transactionApiMock = { - getSafe: jest.fn(), + isSafe: jest.fn(), } as jest.MockedObjectDeep; const zerionBalancesApi = { @@ -115,9 +114,7 @@ describe('Balances API Manager Tests', () => { transactionApiManagerMock.getTransactionApi.mockResolvedValue( transactionApiMock, ); - transactionApiMock.getSafe.mockImplementation(() => { - throw new Error(); - }); + transactionApiMock.isSafe.mockResolvedValue(false); const result = await manager.getBalancesApi( faker.string.numeric({ exclude: ZERION_BALANCES_CHAIN_IDS }), @@ -175,7 +172,7 @@ describe('Balances API Manager Tests', () => { transactionApiManagerMock.getTransactionApi.mockResolvedValue( transactionApiMock, ); - transactionApiMock.getSafe.mockResolvedValue(safeBuilder().build()); + transactionApiMock.isSafe.mockResolvedValue(true); const safeAddress = getAddress(faker.finance.ethereumAddress()); const safeBalancesApi = await balancesApiManager.getBalancesApi( diff --git a/src/datasources/balances-api/balances-api.manager.ts b/src/datasources/balances-api/balances-api.manager.ts index 19af491642..a6c84a86dc 100644 --- a/src/datasources/balances-api/balances-api.manager.ts +++ b/src/datasources/balances-api/balances-api.manager.ts @@ -55,21 +55,21 @@ export class BalancesApiManager implements IBalancesApiManager { if (this.zerionChainIds.includes(chainId)) { return this.zerionBalancesApi; } + const transactionApi = + await this.transactionApiManager.getTransactionApi(chainId); - if (this.isCounterFactualBalancesEnabled) { - // SafeBalancesApi will be returned only if TransactionApi returns the Safe data. - // Otherwise ZerionBalancesApi will be returned as the Safe is considered counterfactual/not deployed. - try { - const transactionApi = - await this.transactionApiManager.getTransactionApi(chainId); - await transactionApi.getSafe(safeAddress); - return this._getSafeBalancesApi(chainId); - } catch { - return this.zerionBalancesApi; - } + if (!this.isCounterFactualBalancesEnabled) { + return this._getSafeBalancesApi(chainId); } - return this._getSafeBalancesApi(chainId); + // SafeBalancesApi will be returned only if TransactionApi returns the Safe data. + // Otherwise ZerionBalancesApi will be returned as the Safe is considered counterfactual/not deployed. + const isSafe = await transactionApi.isSafe(safeAddress); + if (isSafe) { + return this._getSafeBalancesApi(chainId); + } else { + return this.zerionBalancesApi; + } } async getFiatCodes(): Promise { diff --git a/src/datasources/cache/cache.router.ts b/src/datasources/cache/cache.router.ts index 0fce955b2d..b44cae54ae 100644 --- a/src/datasources/cache/cache.router.ts +++ b/src/datasources/cache/cache.router.ts @@ -22,6 +22,7 @@ export class CacheRouter { private static readonly SAFE_APPS_KEY = 'safe_apps'; private static readonly SAFE_BALANCES_KEY = 'safe_balances'; private static readonly SAFE_COLLECTIBLES_KEY = 'safe_collectibles'; + private static readonly SAFE_EXISTS_KEY = 'safe_exists'; private static readonly SAFE_FIAT_CODES_KEY = 'safe_fiat_codes'; private static readonly SAFE_KEY = 'safe'; private static readonly SINGLETONS_KEY = 'singletons'; @@ -116,6 +117,20 @@ export class CacheRouter { return `${args.chainId}_${CacheRouter.SAFE_KEY}_${args.safeAddress}`; } + static getIsSafeCacheDir(args: { + chainId: string; + safeAddress: `0x${string}`; + }): CacheDir { + return new CacheDir(CacheRouter.getIsSafeCacheKey(args), ''); + } + + static getIsSafeCacheKey(args: { + chainId: string; + safeAddress: `0x${string}`; + }): string { + return `${args.chainId}_${CacheRouter.SAFE_EXISTS_KEY}_${args.safeAddress}`; + } + static getContractCacheDir(args: { chainId: string; contractAddress: `0x${string}`; diff --git a/src/datasources/transaction-api/transaction-api.manager.spec.ts b/src/datasources/transaction-api/transaction-api.manager.spec.ts index f04e7dd103..e8073197fc 100644 --- a/src/datasources/transaction-api/transaction-api.manager.spec.ts +++ b/src/datasources/transaction-api/transaction-api.manager.spec.ts @@ -6,6 +6,7 @@ import { INetworkService } from '@/datasources/network/network.service.interface import { TransactionApiManager } from '@/datasources/transaction-api/transaction-api.manager'; import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; import { IConfigApi } from '@/domain/interfaces/config-api.interface'; +import { ILoggingService } from '@/logging/logging.interface'; import { faker } from '@faker-js/faker'; const configurationService = { @@ -32,6 +33,10 @@ const httpErrorFactory = {} as jest.MockedObjectDeep; const networkService = {} as jest.MockedObjectDeep; +const mockLoggingService = { + debug: jest.fn(), +} as jest.MockedObjectDeep; + describe('Transaction API Manager Tests', () => { beforeEach(() => { jest.resetAllMocks(); @@ -77,6 +82,7 @@ describe('Transaction API Manager Tests', () => { cacheService, httpErrorFactory, networkService, + mockLoggingService, ); const transactionApi = await target.getTransactionApi(chain.chainId); diff --git a/src/datasources/transaction-api/transaction-api.manager.ts b/src/datasources/transaction-api/transaction-api.manager.ts index f4ea4fa2a5..b16e44f806 100644 --- a/src/datasources/transaction-api/transaction-api.manager.ts +++ b/src/datasources/transaction-api/transaction-api.manager.ts @@ -14,6 +14,7 @@ import { TransactionApi } from '@/datasources/transaction-api/transaction-api.se import { Chain } from '@/domain/chains/entities/chain.entity'; import { IConfigApi } from '@/domain/interfaces/config-api.interface'; import { ITransactionApiManager } from '@/domain/interfaces/transaction-api.manager.interface'; +import { ILoggingService, LoggingService } from '@/logging/logging.interface'; @Injectable() export class TransactionApiManager implements ITransactionApiManager { @@ -29,6 +30,7 @@ export class TransactionApiManager implements ITransactionApiManager { @Inject(CacheService) private readonly cacheService: ICacheService, private readonly httpErrorFactory: HttpErrorFactory, @Inject(NetworkService) private readonly networkService: INetworkService, + @Inject(LoggingService) private readonly loggingService: ILoggingService, ) { this.useVpcUrl = this.configurationService.getOrThrow( 'safeTransaction.useVpcUrl', @@ -48,6 +50,7 @@ export class TransactionApiManager implements ITransactionApiManager { this.configurationService, this.httpErrorFactory, this.networkService, + this.loggingService, ); return this.transactionApiMap[chainId]; } diff --git a/src/datasources/transaction-api/transaction-api.service.spec.ts b/src/datasources/transaction-api/transaction-api.service.spec.ts index 45c1e9f9ae..7c8d5aac18 100644 --- a/src/datasources/transaction-api/transaction-api.service.spec.ts +++ b/src/datasources/transaction-api/transaction-api.service.spec.ts @@ -24,6 +24,7 @@ import { proposeTransactionDtoBuilder } from '@/routes/transactions/entities/__t import { erc20TransferBuilder } from '@/domain/safe/entities/__tests__/erc20-transfer.builder'; import { DeviceType } from '@/domain/notifications/entities/device.entity'; import { getAddress } from 'viem'; +import { ILoggingService } from '@/logging/logging.interface'; const dataSource = { get: jest.fn(), @@ -33,6 +34,7 @@ const mockDataSource = jest.mocked(dataSource); const cacheService = { deleteByKey: jest.fn(), set: jest.fn(), + get: jest.fn(), } as jest.MockedObjectDeep; const mockCacheService = jest.mocked(cacheService); @@ -48,11 +50,16 @@ const networkService = jest.mocked({ } as jest.MockedObjectDeep); const mockNetworkService = jest.mocked(networkService); +const mockLoggingService = { + debug: jest.fn(), +} as jest.MockedObjectDeep; + describe('TransactionApi', () => { const chainId = '1'; const baseUrl = faker.internet.url({ appendSlash: false }); let httpErrorFactory: HttpErrorFactory; let service: TransactionApi; + const indefiniteExpirationTime = -1; let defaultExpirationTimeInSeconds: number; let notFoundExpireTimeSeconds: number; let ownersTtlSeconds: number; @@ -91,6 +98,7 @@ describe('TransactionApi', () => { mockConfigurationService, httpErrorFactory, mockNetworkService, + mockLoggingService, ); }); @@ -325,6 +333,129 @@ describe('TransactionApi', () => { }); }); + describe('isSafe', () => { + it('should return whether Safe exists', async () => { + const safe = safeBuilder().build(); + const cacheDir = new CacheDir( + `${chainId}_safe_exists_${safe.address}`, + '', + ); + cacheService.get.mockResolvedValueOnce(undefined); + networkService.get.mockResolvedValueOnce({ status: 200, data: safe }); + + const actual = await service.isSafe(safe.address); + + expect(actual).toBe(true); + expect(cacheService.get).toHaveBeenCalledTimes(1); + expect(cacheService.get).toHaveBeenCalledWith(cacheDir); + expect(networkService.get).toHaveBeenCalledTimes(1); + expect(networkService.get).toHaveBeenCalledWith({ + url: `${baseUrl}/api/v1/safes/${safe.address}`, + }); + expect(cacheService.set).toHaveBeenCalledTimes(1); + expect(cacheService.set).toHaveBeenCalledWith( + cacheDir, + 'true', + indefiniteExpirationTime, + ); + }); + + it('should return the cached value', async () => { + const safe = safeBuilder().build(); + const cacheDir = new CacheDir( + `${chainId}_safe_exists_${safe.address}`, + '', + ); + const isSafe = faker.datatype.boolean(); + cacheService.get.mockResolvedValueOnce(JSON.stringify(isSafe)); + networkService.get.mockResolvedValueOnce({ status: 200, data: safe }); + + const actual = await service.isSafe(safe.address); + + expect(actual).toBe(isSafe); + expect(cacheService.get).toHaveBeenCalledTimes(1); + expect(cacheService.get).toHaveBeenCalledWith(cacheDir); + expect(networkService.get).not.toHaveBeenCalled(); + expect(cacheService.set).not.toHaveBeenCalledTimes(1); + }); + + it('should return false if Safe does not exist', async () => { + const safe = safeBuilder().build(); + const cacheDir = new CacheDir( + `${chainId}_safe_exists_${safe.address}`, + '', + ); + cacheService.get.mockResolvedValueOnce(undefined); + networkService.get.mockResolvedValueOnce({ status: 404, data: null }); + + const actual = await service.isSafe(safe.address); + + expect(actual).toBe(false); + expect(cacheService.get).toHaveBeenCalledTimes(1); + expect(cacheService.get).toHaveBeenCalledWith(cacheDir); + expect(networkService.get).toHaveBeenCalledTimes(1); + expect(networkService.get).toHaveBeenCalledWith({ + url: `${baseUrl}/api/v1/safes/${safe.address}`, + }); + expect(cacheService.set).toHaveBeenCalledTimes(1); + expect(cacheService.set).toHaveBeenCalledWith( + cacheDir, + 'false', + defaultExpirationTimeInSeconds, + ); + }); + + const errorMessage = faker.word.words(); + it.each([ + ['Transaction Service', { nonFieldErrors: [errorMessage] }], + ['standard', new Error(errorMessage)], + ])(`should forward a %s error`, async (_, error) => { + const safe = safeBuilder().build(); + const getSafeUrl = `${baseUrl}/api/v1/safes/${safe.address}`; + const statusCode = faker.internet.httpStatusCode({ + types: ['serverError'], + }); + const expected = new DataSourceError(errorMessage, statusCode); + const cacheDir = new CacheDir( + `${chainId}_safe_exists_${safe.address}`, + '', + ); + cacheService.get.mockResolvedValueOnce(undefined); + networkService.get.mockRejectedValueOnce( + new NetworkResponseError( + new URL(getSafeUrl), + { + status: statusCode, + } as Response, + error, + ), + ); + + await expect(service.isSafe(safe.address)).rejects.toThrow(expected); + + expect(cacheService.get).toHaveBeenCalledTimes(1); + expect(cacheService.get).toHaveBeenCalledWith(cacheDir); + expect(networkService.get).toHaveBeenCalledTimes(1); + expect(networkService.get).toHaveBeenCalledWith({ + url: `${baseUrl}/api/v1/safes/${safe.address}`, + }); + expect(cacheService.set).not.toHaveBeenCalled(); + }); + }); + + describe('clearIsSafe', () => { + it('should clear the Safe existence cache', async () => { + const safeAddress = getAddress(faker.finance.ethereumAddress()); + + await service.clearIsSafe(safeAddress); + + expect(mockCacheService.deleteByKey).toHaveBeenCalledTimes(1); + expect(mockCacheService.deleteByKey).toHaveBeenCalledWith( + `${chainId}_safe_exists_${safeAddress}`, + ); + }); + }); + describe('getContract', () => { it('should return retrieved contract', async () => { const contract = contractBuilder().build(); diff --git a/src/datasources/transaction-api/transaction-api.service.ts b/src/datasources/transaction-api/transaction-api.service.ts index 9ad0922c66..34cdc46741 100644 --- a/src/datasources/transaction-api/transaction-api.service.ts +++ b/src/datasources/transaction-api/transaction-api.service.ts @@ -26,10 +26,12 @@ import { Transfer } from '@/domain/safe/entities/transfer.entity'; import { Token } from '@/domain/tokens/entities/token.entity'; import { AddConfirmationDto } from '@/domain/transactions/entities/add-confirmation.dto.entity'; import { ProposeTransactionDto } from '@/domain/transactions/entities/propose-transaction.dto.entity'; +import { ILoggingService } from '@/logging/logging.interface'; import { get } from 'lodash'; export class TransactionApi implements ITransactionApi { private static readonly ERROR_ARRAY_PATH = 'nonFieldErrors'; + private static readonly INDEFINITE_EXPIRATION_TIME = -1; private readonly defaultExpirationTimeInSeconds: number; private readonly defaultNotFoundExpirationTimeSeconds: number; @@ -45,6 +47,7 @@ export class TransactionApi implements ITransactionApi { private readonly configurationService: IConfigurationService, private readonly httpErrorFactory: HttpErrorFactory, private readonly networkService: INetworkService, + private readonly loggingService: ILoggingService, ) { this.defaultExpirationTimeInSeconds = this.configurationService.getOrThrow( @@ -147,6 +150,70 @@ export class TransactionApi implements ITransactionApi { await this.cacheService.deleteByKey(key); } + // TODO: this replicates logic from the CacheFirstDataSource.get method to avoid + // implementation of response remapping but we should refactor it to avoid duplication + async isSafe(safeAddress: `0x${string}`): Promise { + const cacheDir = CacheRouter.getIsSafeCacheDir({ + chainId: this.chainId, + safeAddress, + }); + + const cached = await this.cacheService.get(cacheDir).catch(() => null); + + if (cached != null) { + this.loggingService.debug({ + type: 'cache_hit', + ...cacheDir, + }); + + return cached === 'true'; + } else { + this.loggingService.debug({ + type: 'cache_miss', + ...cacheDir, + }); + } + + const isSafe = await (async (): Promise => { + try { + const url = `${this.baseUrl}/api/v1/safes/${safeAddress}`; + const { data } = await this.networkService.get({ + url, + }); + + return !!data; + } catch (error) { + if ( + error instanceof NetworkResponseError && + // Transaction Service returns 404 when address is not of a Safe + error.response.status === 404 + ) { + return false; + } + throw this.httpErrorFactory.from(this.mapError(error)); + } + })(); + + await this.cacheService.set( + cacheDir, + JSON.stringify(isSafe), + isSafe + ? // We can indefinitely cache this as an address cannot "un-Safe" itself + TransactionApi.INDEFINITE_EXPIRATION_TIME + : this.defaultExpirationTimeInSeconds, + ); + + return isSafe; + } + + async clearIsSafe(safeAddress: `0x${string}`): Promise { + const key = CacheRouter.getIsSafeCacheKey({ + chainId: this.chainId, + safeAddress, + }); + await this.cacheService.deleteByKey(key); + } + // Important: there is no hook which invalidates this endpoint, // Therefore, this data will live in cache until [defaultExpirationTimeInSeconds] async getContract(contractAddress: `0x${string}`): Promise { diff --git a/src/domain/interfaces/transaction-api.interface.ts b/src/domain/interfaces/transaction-api.interface.ts index 9ba912f91e..466fdd71f4 100644 --- a/src/domain/interfaces/transaction-api.interface.ts +++ b/src/domain/interfaces/transaction-api.interface.ts @@ -33,6 +33,10 @@ export interface ITransactionApi { clearSafe(address: `0x${string}`): Promise; + isSafe(address: `0x${string}`): Promise; + + clearIsSafe(address: `0x${string}`): Promise; + getContract(contractAddress: `0x${string}`): Promise; getDelegates(args: { diff --git a/src/domain/safe/safe.repository.interface.ts b/src/domain/safe/safe.repository.interface.ts index a64c049362..9f0d4f384d 100644 --- a/src/domain/safe/safe.repository.interface.ts +++ b/src/domain/safe/safe.repository.interface.ts @@ -20,6 +20,10 @@ export interface ISafeRepository { clearSafe(args: { chainId: string; address: `0x${string}` }): Promise; + isSafe(args: { chainId: string; address: `0x${string}` }): Promise; + + clearIsSafe(args: { chainId: string; address: `0x${string}` }): Promise; + isOwner(args: { chainId: string; safeAddress: `0x${string}`; diff --git a/src/domain/safe/safe.repository.ts b/src/domain/safe/safe.repository.ts index c2c7866cfb..3e44cca97b 100644 --- a/src/domain/safe/safe.repository.ts +++ b/src/domain/safe/safe.repository.ts @@ -30,6 +30,7 @@ import { ILoggingService, LoggingService } from '@/logging/logging.interface'; import { IChainsRepository } from '@/domain/chains/chains.repository.interface'; import { CreationTransactionSchema } from '@/domain/safe/entities/schemas/creation-transaction.schema'; import { SafeSchema } from '@/domain/safe/entities/schemas/safe.schema'; +import { z } from 'zod'; @Injectable() export class SafeRepository implements ISafeRepository { @@ -51,6 +52,25 @@ export class SafeRepository implements ISafeRepository { return SafeSchema.parse(safe); } + async isSafe(args: { + chainId: string; + address: `0x${string}`; + }): Promise { + const transactionService = + await this.transactionApiManager.getTransactionApi(args.chainId); + const isSafe = await transactionService.isSafe(args.address); + return z.boolean().parse(isSafe); + } + + async clearIsSafe(args: { + chainId: string; + address: `0x${string}`; + }): Promise { + const transactionService = + await this.transactionApiManager.getTransactionApi(args.chainId); + return transactionService.clearIsSafe(args.address); + } + async clearSafe(args: { chainId: string; address: `0x${string}`; diff --git a/src/routes/cache-hooks/cache-hooks.controller.spec.ts b/src/routes/cache-hooks/cache-hooks.controller.spec.ts index 341c45be62..64f783f892 100644 --- a/src/routes/cache-hooks/cache-hooks.controller.spec.ts +++ b/src/routes/cache-hooks/cache-hooks.controller.spec.ts @@ -26,6 +26,7 @@ import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues- import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; +import { safeCreatedEventBuilder } from '@/routes/cache-hooks/entities/__tests__/safe-created.build'; describe('Post Hook Events (Unit)', () => { let app: INestApplication; @@ -180,6 +181,8 @@ describe('Post Hook Events (Unit)', () => { }, { type: 'SAFE_CREATED', + address: faker.finance.ethereumAddress(), + blockNumber: faker.number.int(), }, ])('accepts $type', async (payload) => { const chainId = faker.string.numeric(); @@ -961,4 +964,40 @@ describe('Post Hook Events (Unit)', () => { await expect(fakeCacheService.get(cacheDir)).resolves.toBeUndefined(); }, ); + + it.each([ + { + type: 'SAFE_CREATED', + }, + ])('$type clears Safe existence', async () => { + const data = safeCreatedEventBuilder().build(); + const cacheDir = new CacheDir( + `${data.chainId}_safe_exists_${data.address}`, + '', + ); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${safeConfigUrl}/api/v1/chains/${data.chainId}`: + return Promise.resolve({ + data: chainBuilder().with('chainId', data.chainId).build(), + status: 200, + }); + default: + return Promise.reject(new Error(`Could not match ${url}`)); + } + }); + await fakeCacheService.set( + cacheDir, + faker.string.alpha(), + faker.number.int({ min: 1 }), + ); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(data) + .expect(202); + + await expect(fakeCacheService.get(cacheDir)).resolves.toBeUndefined(); + }); }); diff --git a/src/routes/cache-hooks/cache-hooks.service.ts b/src/routes/cache-hooks/cache-hooks.service.ts index 00aaa6f276..dd4c0de72d 100644 --- a/src/routes/cache-hooks/cache-hooks.service.ts +++ b/src/routes/cache-hooks/cache-hooks.service.ts @@ -321,8 +321,8 @@ export class CacheHooksService implements OnModuleInit { promises.push(this.safeAppsRepository.clearSafeApps(event.chainId)); this._logEvent(event); break; - // A new Safe created does not trigger any action case EventType.SAFE_CREATED: + promises.push(this.safeRepository.clearIsSafe(event)); break; } return Promise.all(promises); diff --git a/src/routes/cache-hooks/entities/__tests__/safe-created.build.ts b/src/routes/cache-hooks/entities/__tests__/safe-created.build.ts index 8f957f9d0b..d8c4a7fe32 100644 --- a/src/routes/cache-hooks/entities/__tests__/safe-created.build.ts +++ b/src/routes/cache-hooks/entities/__tests__/safe-created.build.ts @@ -2,9 +2,12 @@ import { Builder, IBuilder } from '@/__tests__/builder'; import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; import { SafeCreated } from '@/routes/cache-hooks/entities/safe-created.entity'; import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; export function safeCreatedEventBuilder(): IBuilder { return new Builder() .with('type', EventType.SAFE_CREATED) - .with('chainId', faker.string.numeric()); + .with('chainId', faker.string.numeric()) + .with('address', getAddress(faker.finance.ethereumAddress())) + .with('blockNumber', faker.number.int()); } diff --git a/src/routes/cache-hooks/entities/schemas/__tests__/safe-created.schema.spec.ts b/src/routes/cache-hooks/entities/schemas/__tests__/safe-created.schema.spec.ts index eaa7f36f10..c5d316427e 100644 --- a/src/routes/cache-hooks/entities/schemas/__tests__/safe-created.schema.spec.ts +++ b/src/routes/cache-hooks/entities/schemas/__tests__/safe-created.schema.spec.ts @@ -2,6 +2,7 @@ import { safeCreatedEventBuilder } from '@/routes/cache-hooks/entities/__tests__ import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; import { SafeCreatedEventSchema } from '@/routes/cache-hooks/entities/schemas/safe-created.schema'; import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; import { ZodError } from 'zod'; describe('SafeCreatedEventSchema', () => { @@ -13,6 +14,19 @@ describe('SafeCreatedEventSchema', () => { expect(result.success).toBe(true); }); + it('should checksum the address', () => { + const nonChecksummedAddress = faker.finance.ethereumAddress().toLowerCase(); + const safeCreatedEvent = safeCreatedEventBuilder() + .with('address', nonChecksummedAddress as `0x${string}`) + .build(); + + const result = SafeCreatedEventSchema.safeParse(safeCreatedEvent); + + expect(result.success && result.data.address).toBe( + getAddress(nonChecksummedAddress), + ); + }); + it('should not allow a non-SAFE_CREATED event', () => { const safeCreatedEvent = safeCreatedEventBuilder() .with('type', faker.word.sample() as EventType.SAFE_CREATED) diff --git a/src/routes/cache-hooks/entities/schemas/safe-created.schema.ts b/src/routes/cache-hooks/entities/schemas/safe-created.schema.ts index 5fc82d9806..74125cdc1a 100644 --- a/src/routes/cache-hooks/entities/schemas/safe-created.schema.ts +++ b/src/routes/cache-hooks/entities/schemas/safe-created.schema.ts @@ -1,7 +1,10 @@ import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; import { z } from 'zod'; export const SafeCreatedEventSchema = z.object({ type: z.literal(EventType.SAFE_CREATED), chainId: z.string(), + address: AddressSchema, + blockNumber: z.number(), }); From 245ff2e36bd24f4f24f22e7b813f853730a84b2c Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Tue, 11 Jun 2024 00:06:17 +0200 Subject: [PATCH 072/207] Remove unnecessary log from test (#1628) This removes the unnecessary log from the imitation transactions test. --- ...ransactions-history.imitation-transactions.controller.spec.ts | 1 - 1 file changed, 1 deletion(-) 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 2a90ab6478..ba7622122b 100644 --- a/src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts +++ b/src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts @@ -577,7 +577,6 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = ]; networkService.get.mockImplementation(({ url }) => { - console.log('=>', url); if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { return Promise.resolve({ data: chain, status: 200 }); } From e7c573f3c96cfe8b8754fb5d104b066da491fe65 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Jun 2024 00:07:54 +0200 Subject: [PATCH 073/207] Bump typescript-eslint from 7.12.0 to 7.13.0 (#1631) Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 7.12.0 to 7.13.0. - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.13.0/packages/typescript-eslint) --- updated-dependencies: - dependency-name: typescript-eslint dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 116 +++++++++++++++++++++++++-------------------------- 2 files changed, 59 insertions(+), 59 deletions(-) diff --git a/package.json b/package.json index db9689578e..7613b21b06 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "ts-node": "^10.9.2", "tsconfig-paths": "4.2.0", "typescript": "^5.4.5", - "typescript-eslint": "^7.12.0" + "typescript-eslint": "^7.13.0" }, "jest": { "moduleFileExtensions": [ diff --git a/yarn.lock b/yarn.lock index 738ac02f5a..c28150f148 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2012,15 +2012,15 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:7.12.0": - version: 7.12.0 - resolution: "@typescript-eslint/eslint-plugin@npm:7.12.0" +"@typescript-eslint/eslint-plugin@npm:7.13.0": + version: 7.13.0 + resolution: "@typescript-eslint/eslint-plugin@npm:7.13.0" dependencies: "@eslint-community/regexpp": "npm:^4.10.0" - "@typescript-eslint/scope-manager": "npm:7.12.0" - "@typescript-eslint/type-utils": "npm:7.12.0" - "@typescript-eslint/utils": "npm:7.12.0" - "@typescript-eslint/visitor-keys": "npm:7.12.0" + "@typescript-eslint/scope-manager": "npm:7.13.0" + "@typescript-eslint/type-utils": "npm:7.13.0" + "@typescript-eslint/utils": "npm:7.13.0" + "@typescript-eslint/visitor-keys": "npm:7.13.0" graphemer: "npm:^1.4.0" ignore: "npm:^5.3.1" natural-compare: "npm:^1.4.0" @@ -2031,44 +2031,44 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/a62a74b2a469d94d9a688c26d7dfafef111ee8d7db6b55a80147d319a39ab68036c659b62ad5d9a0138e5581b24c42372bcc543343b8a41bb8c3f96ffd32743b + checksum: 10/93c3a0d8871d8351187503152a6c5199714eb62c96991e0d3e0caaee6881839dee4ad55e5de5d1a4389ae12ed10d3a845603de1f2f581337f782f19113022a65 languageName: node linkType: hard -"@typescript-eslint/parser@npm:7.12.0": - version: 7.12.0 - resolution: "@typescript-eslint/parser@npm:7.12.0" +"@typescript-eslint/parser@npm:7.13.0": + version: 7.13.0 + resolution: "@typescript-eslint/parser@npm:7.13.0" dependencies: - "@typescript-eslint/scope-manager": "npm:7.12.0" - "@typescript-eslint/types": "npm:7.12.0" - "@typescript-eslint/typescript-estree": "npm:7.12.0" - "@typescript-eslint/visitor-keys": "npm:7.12.0" + "@typescript-eslint/scope-manager": "npm:7.13.0" + "@typescript-eslint/types": "npm:7.13.0" + "@typescript-eslint/typescript-estree": "npm:7.13.0" + "@typescript-eslint/visitor-keys": "npm:7.13.0" debug: "npm:^4.3.4" peerDependencies: eslint: ^8.56.0 peerDependenciesMeta: typescript: optional: true - checksum: 10/66b692ca1d00965b854e99784e78d8540adc49cf44a4e295e91ad2e809f236d6d1b3877eeddf3ee61f531a1313c9269ed7f16e083148a92f82c5de1337b06659 + checksum: 10/ad930d9138c3caa9e0ac2d887798318b5b06df5aa1ecc50c2d8cd912e00cf13eb007256bfb4c11709f0191fc180614a15f84c0f0f03a50f035b0b8af0eb9409c languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:7.12.0": - version: 7.12.0 - resolution: "@typescript-eslint/scope-manager@npm:7.12.0" +"@typescript-eslint/scope-manager@npm:7.13.0": + version: 7.13.0 + resolution: "@typescript-eslint/scope-manager@npm:7.13.0" dependencies: - "@typescript-eslint/types": "npm:7.12.0" - "@typescript-eslint/visitor-keys": "npm:7.12.0" - checksum: 10/49a1fa4c15a161258963c4ffe37d89a212138d1c09e39a73064cd3a962823b98e362546de7228698877bc7e7f515252f439c140245f9689ff59efd7b35be58a4 + "@typescript-eslint/types": "npm:7.13.0" + "@typescript-eslint/visitor-keys": "npm:7.13.0" + checksum: 10/2b258a06c5e747c80423b07855f052f327a4d5b0a0cf3a46221ef298653139d3b01ac1534fc0db6609fd962ba45ec87a0e12f8d3778183440923bcf4687832a5 languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:7.12.0": - version: 7.12.0 - resolution: "@typescript-eslint/type-utils@npm:7.12.0" +"@typescript-eslint/type-utils@npm:7.13.0": + version: 7.13.0 + resolution: "@typescript-eslint/type-utils@npm:7.13.0" dependencies: - "@typescript-eslint/typescript-estree": "npm:7.12.0" - "@typescript-eslint/utils": "npm:7.12.0" + "@typescript-eslint/typescript-estree": "npm:7.13.0" + "@typescript-eslint/utils": "npm:7.13.0" debug: "npm:^4.3.4" ts-api-utils: "npm:^1.3.0" peerDependencies: @@ -2076,23 +2076,23 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/c42d15aa5f0483ce361910b770cb4050e69739632ddb01436e189775df2baee6f7398f9e55633f1f1955d58c2a622a4597a093c5372eb61aafdda8a43bac2d57 + checksum: 10/f51ccb3c59963db82a504b02c8d15bc518137c176b8d39891f7bcb7b4b02ca0fa918a3754781f198f592f1047dc24c49086430bbef857d877d085e14d33f7a6c languageName: node linkType: hard -"@typescript-eslint/types@npm:7.12.0": - version: 7.12.0 - resolution: "@typescript-eslint/types@npm:7.12.0" - checksum: 10/17b57ccd26278312299b27f587d7e9b34076ff37780b3973f848e4ac7bdf80d1bee7356082b54e900e0d77be8a0dda1feef1feb84843b9ec253855200cd93f36 +"@typescript-eslint/types@npm:7.13.0": + version: 7.13.0 + resolution: "@typescript-eslint/types@npm:7.13.0" + checksum: 10/5adc39c569217ed7d09853385313f1fcf2c05385e5e0144740238e346afbc0dec576c1eb46f779368736b080e6f9f368483fff3378b0bf7e6b275f27a904f04d languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:7.12.0": - version: 7.12.0 - resolution: "@typescript-eslint/typescript-estree@npm:7.12.0" +"@typescript-eslint/typescript-estree@npm:7.13.0": + version: 7.13.0 + resolution: "@typescript-eslint/typescript-estree@npm:7.13.0" dependencies: - "@typescript-eslint/types": "npm:7.12.0" - "@typescript-eslint/visitor-keys": "npm:7.12.0" + "@typescript-eslint/types": "npm:7.13.0" + "@typescript-eslint/visitor-keys": "npm:7.13.0" debug: "npm:^4.3.4" globby: "npm:^11.1.0" is-glob: "npm:^4.0.3" @@ -2102,31 +2102,31 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/45e7402e2e32782a96dbca671b4ad731b643e47c172d735e749930d1560071a1a1e2a8765396443d09bff83c69dad2fff07dc30a2ed212bff492e20aa6b2b790 + checksum: 10/d4cc68e8aa9902c5efa820582b05bfb6c1567e21e7743250778613a045f0b6bb05128f7cfc090368ab808ad91be6193b678569ca803f917b2958c3752bc4810b languageName: node linkType: hard -"@typescript-eslint/utils@npm:7.12.0": - version: 7.12.0 - resolution: "@typescript-eslint/utils@npm:7.12.0" +"@typescript-eslint/utils@npm:7.13.0": + version: 7.13.0 + resolution: "@typescript-eslint/utils@npm:7.13.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.4.0" - "@typescript-eslint/scope-manager": "npm:7.12.0" - "@typescript-eslint/types": "npm:7.12.0" - "@typescript-eslint/typescript-estree": "npm:7.12.0" + "@typescript-eslint/scope-manager": "npm:7.13.0" + "@typescript-eslint/types": "npm:7.13.0" + "@typescript-eslint/typescript-estree": "npm:7.13.0" peerDependencies: eslint: ^8.56.0 - checksum: 10/b66725cef2dcc4975714ea7528fa000cebd4e0b55bb6c43d7efe9ce21a6c7af5f8b2c49f1be3a5118c26666d4b0228470105741e78430e463b72f91fa62e0adf + checksum: 10/c87bbb90c958ed4617f88767890af2a797adcf28060e85809a9cad2ce4ed55b5db685d3a8d062dbbf89d2a49e85759e2a9deb92ee1946a95d5de6cbd14ea42f4 languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:7.12.0": - version: 7.12.0 - resolution: "@typescript-eslint/visitor-keys@npm:7.12.0" +"@typescript-eslint/visitor-keys@npm:7.13.0": + version: 7.13.0 + resolution: "@typescript-eslint/visitor-keys@npm:7.13.0" dependencies: - "@typescript-eslint/types": "npm:7.12.0" + "@typescript-eslint/types": "npm:7.13.0" eslint-visitor-keys: "npm:^3.4.3" - checksum: 10/5c03bbb68f6eb775005c83042da99de87513cdf9b5549c2ac30caf2c74dc9888cebec57d9eeb0dead8f63a57771288f59605c9a4d8aeec6b87b5390ac723cbd4 + checksum: 10/5568dd435f22337c034da8c2dacd5be23b966c5978d25d96fca1358c59289861dfc4c39f2943c7790e947f75843d60035ad56c1f2c106f0e7d9ecf1ff6646065 languageName: node linkType: hard @@ -7292,7 +7292,7 @@ __metadata: ts-node: "npm:^10.9.2" tsconfig-paths: "npm:4.2.0" typescript: "npm:^5.4.5" - typescript-eslint: "npm:^7.12.0" + typescript-eslint: "npm:^7.13.0" viem: "npm:^2.13.1" winston: "npm:^3.13.0" zod: "npm:^3.23.8" @@ -8122,19 +8122,19 @@ __metadata: languageName: node linkType: hard -"typescript-eslint@npm:^7.12.0": - version: 7.12.0 - resolution: "typescript-eslint@npm:7.12.0" +"typescript-eslint@npm:^7.13.0": + version: 7.13.0 + resolution: "typescript-eslint@npm:7.13.0" dependencies: - "@typescript-eslint/eslint-plugin": "npm:7.12.0" - "@typescript-eslint/parser": "npm:7.12.0" - "@typescript-eslint/utils": "npm:7.12.0" + "@typescript-eslint/eslint-plugin": "npm:7.13.0" + "@typescript-eslint/parser": "npm:7.13.0" + "@typescript-eslint/utils": "npm:7.13.0" peerDependencies: eslint: ^8.56.0 peerDependenciesMeta: typescript: optional: true - checksum: 10/a508ae2e847c463cbe6f709360be3d080f621a8396bfe59f10745520a5c931b72dc882e9b94faf065c2471d00baa57ded758acf89716afe68e749373ca46928b + checksum: 10/86a4261bccc0695c33b5ea74b16ae33d9a8321edf07aac4549de970b19f962e42d7043080904cde6459629495198e4a370e8a7c9b0a2fa0c3bd77c8d85f5700e languageName: node linkType: hard From bcc5e127fe3c46ea926a18f57d488f68768e7306 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Jun 2024 00:08:11 +0200 Subject: [PATCH 074/207] Bump eslint from 9.3.0 to 9.4.0 (#1632) Bumps [eslint](https://github.com/eslint/eslint) from 9.3.0 to 9.4.0. - [Release notes](https://github.com/eslint/eslint/releases) - [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md) - [Commits](https://github.com/eslint/eslint/compare/v9.3.0...v9.4.0) --- updated-dependencies: - dependency-name: eslint dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 58 ++++++++++++++++++++++++++-------------------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/package.json b/package.json index 7613b21b06..781ef20991 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "@types/node": "^20.14.0", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.2", - "eslint": "^9.3.0", + "eslint": "^9.4.0", "eslint-config-prettier": "^9.1.0", "husky": "^9.0.11", "jest": "29.7.0", diff --git a/yarn.lock b/yarn.lock index c28150f148..724ebaab45 100644 --- a/yarn.lock +++ b/yarn.lock @@ -682,6 +682,17 @@ __metadata: languageName: node linkType: hard +"@eslint/config-array@npm:^0.15.1": + version: 0.15.1 + resolution: "@eslint/config-array@npm:0.15.1" + dependencies: + "@eslint/object-schema": "npm:^2.1.3" + debug: "npm:^4.3.1" + minimatch: "npm:^3.0.5" + checksum: 10/cf8f68a24498531180fad6846cb52dac4e852b0296d2664930bc15d6a2944ad427827bbaebfddf3f87b9c5db0e36c13974d6dc89fff8ba0d3d2b4357b8d52b4e + languageName: node + linkType: hard + "@eslint/eslintrc@npm:^3.1.0": version: 3.1.0 resolution: "@eslint/eslintrc@npm:3.1.0" @@ -699,10 +710,17 @@ __metadata: languageName: node linkType: hard -"@eslint/js@npm:9.3.0": - version: 9.3.0 - resolution: "@eslint/js@npm:9.3.0" - checksum: 10/3fb4b30561c34b52e7c6c6b55ea61df1cead73a525e1ccd77b1454d893dcf06f99fe9c46bf410a044ef7d3339c455bc4f75769b40c4734343f5b46d2d76b89ef +"@eslint/js@npm:9.4.0": + version: 9.4.0 + resolution: "@eslint/js@npm:9.4.0" + checksum: 10/f1fa9acda8bab02dad21e9b7f46c6ba8cb3949979846caf7667f0c682ed0b56d9e8db143b00aab587ef2d02603df202eb5f7017d8f3a98be94be6efa763865ab + languageName: node + linkType: hard + +"@eslint/object-schema@npm:^2.1.3": + version: 2.1.3 + resolution: "@eslint/object-schema@npm:2.1.3" + checksum: 10/832e80e91503a1e74a8d870b41c9f374064492a89002c45af17cad9766080e8770c21319a50f0004a77f36add9af6218dbeff34d3e3a16446784ea80a933c0a7 languageName: node linkType: hard @@ -720,17 +738,6 @@ __metadata: languageName: node linkType: hard -"@humanwhocodes/config-array@npm:^0.13.0": - version: 0.13.0 - resolution: "@humanwhocodes/config-array@npm:0.13.0" - dependencies: - "@humanwhocodes/object-schema": "npm:^2.0.3" - debug: "npm:^4.3.1" - minimatch: "npm:^3.0.5" - checksum: 10/524df31e61a85392a2433bf5d03164e03da26c03d009f27852e7dcfdafbc4a23f17f021dacf88e0a7a9fe04ca032017945d19b57a16e2676d9114c22a53a9d11 - languageName: node - linkType: hard - "@humanwhocodes/module-importer@npm:^1.0.1": version: 1.0.1 resolution: "@humanwhocodes/module-importer@npm:1.0.1" @@ -738,13 +745,6 @@ __metadata: languageName: node linkType: hard -"@humanwhocodes/object-schema@npm:^2.0.3": - version: 2.0.3 - resolution: "@humanwhocodes/object-schema@npm:2.0.3" - checksum: 10/05bb99ed06c16408a45a833f03a732f59bf6184795d4efadd33238ff8699190a8c871ad1121241bb6501589a9598dc83bf25b99dcbcf41e155cdf36e35e937a3 - languageName: node - linkType: hard - "@humanwhocodes/retry@npm:^0.3.0": version: 0.3.0 resolution: "@humanwhocodes/retry@npm:0.3.0" @@ -3861,15 +3861,15 @@ __metadata: languageName: node linkType: hard -"eslint@npm:^9.3.0": - version: 9.3.0 - resolution: "eslint@npm:9.3.0" +"eslint@npm:^9.4.0": + version: 9.4.0 + resolution: "eslint@npm:9.4.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.2.0" "@eslint-community/regexpp": "npm:^4.6.1" + "@eslint/config-array": "npm:^0.15.1" "@eslint/eslintrc": "npm:^3.1.0" - "@eslint/js": "npm:9.3.0" - "@humanwhocodes/config-array": "npm:^0.13.0" + "@eslint/js": "npm:9.4.0" "@humanwhocodes/module-importer": "npm:^1.0.1" "@humanwhocodes/retry": "npm:^0.3.0" "@nodelib/fs.walk": "npm:^1.2.8" @@ -3901,7 +3901,7 @@ __metadata: text-table: "npm:^0.2.0" bin: eslint: bin/eslint.js - checksum: 10/c56d63bc3655ce26456cb1b6869eb16579d9b243f143374ce28e4e168ab8fd9d054700014af903b6a5445a9134108327d974ba3e75019220f62df6ce72b6f5b6 + checksum: 10/e2eaae18eb79d543a1ca5420495ea9bf1278f9e25bfa6309ec4e4dae981cba4d731a9b857f5e2f8b5e467adaaf871a635a7eb143a749e7cdcdff4716821628d2 languageName: node linkType: hard @@ -7271,7 +7271,7 @@ __metadata: amqp-connection-manager: "npm:^4.1.14" amqplib: "npm:^0.10.4" cookie-parser: "npm:^1.4.6" - eslint: "npm:^9.3.0" + eslint: "npm:^9.4.0" eslint-config-prettier: "npm:^9.1.0" husky: "npm:^9.0.11" jest: "npm:29.7.0" From 3b807de38d11e40037f8d027d52d831cd2b1f750 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Jun 2024 00:33:02 +0200 Subject: [PATCH 075/207] Bump prettier from 3.3.0 to 3.3.1 (#1633) Bumps [prettier](https://github.com/prettier/prettier) from 3.3.0 to 3.3.1. - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.3.0...3.3.1) --- updated-dependencies: - dependency-name: prettier dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 781ef20991..d8c675d943 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "eslint-config-prettier": "^9.1.0", "husky": "^9.0.11", "jest": "29.7.0", - "prettier": "^3.3.0", + "prettier": "^3.3.1", "source-map-support": "^0.5.20", "supertest": "^7.0.0", "ts-jest": "29.1.4", diff --git a/yarn.lock b/yarn.lock index 724ebaab45..b1ffbcba46 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6819,12 +6819,12 @@ __metadata: languageName: node linkType: hard -"prettier@npm:^3.3.0": - version: 3.3.0 - resolution: "prettier@npm:3.3.0" +"prettier@npm:^3.3.1": + version: 3.3.1 + resolution: "prettier@npm:3.3.1" bin: prettier: bin/prettier.cjs - checksum: 10/e55233f8e4b5f96f52180dbfa424ae797a98a9b8a9a7a79de5004e522c02b423e71927ed99d855dbfcd00dc3b82e5f6fb304cfe117cc4e7c8477d883df2d8984 + checksum: 10/31ca48d07a163fe6bff5483feb9bdf3bd7e4305e8d976373375cddc2949180a007be3ef08c36f4d7b31e449acef1ebbf46d3b94dc32f5a276837bf48c393be69 languageName: node linkType: hard @@ -7280,7 +7280,7 @@ __metadata: nestjs-cls: "npm:^4.3.0" postgres: "npm:^3.4.4" postgres-shift: "npm:^0.1.0" - prettier: "npm:^3.3.0" + prettier: "npm:^3.3.1" redis: "npm:^4.6.14" reflect-metadata: "npm:^0.2.2" rxjs: "npm:^7.8.1" From 012ff79974e0d2633358d1469755a32f623cc04a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Jun 2024 00:33:18 +0200 Subject: [PATCH 076/207] Bump viem from 2.13.1 to 2.13.8 (#1634) Bumps [viem](https://github.com/wevm/viem) from 2.13.1 to 2.13.8. - [Release notes](https://github.com/wevm/viem/releases) - [Commits](https://github.com/wevm/viem/compare/viem@2.13.1...viem@2.13.8) --- updated-dependencies: - dependency-name: viem dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index d8c675d943..9c38859d86 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "semver": "^7.6.2", - "viem": "^2.13.1", + "viem": "^2.13.8", "winston": "^3.13.0", "zod": "^3.23.8" }, diff --git a/yarn.lock b/yarn.lock index b1ffbcba46..a4362777f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7293,7 +7293,7 @@ __metadata: tsconfig-paths: "npm:4.2.0" typescript: "npm:^5.4.5" typescript-eslint: "npm:^7.13.0" - viem: "npm:^2.13.1" + viem: "npm:^2.13.8" winston: "npm:^3.13.0" zod: "npm:^3.23.8" languageName: unknown @@ -8321,9 +8321,9 @@ __metadata: languageName: node linkType: hard -"viem@npm:^2.13.1": - version: 2.13.1 - resolution: "viem@npm:2.13.1" +"viem@npm:^2.13.8": + version: 2.13.8 + resolution: "viem@npm:2.13.8" dependencies: "@adraffy/ens-normalize": "npm:1.10.0" "@noble/curves": "npm:1.2.0" @@ -8338,7 +8338,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/bfddca57e09d30810eaaa8ea01b281b8b1bcdb6da698404bc0f2b1d0a01a57051d70e911da0f480d9cdd8ec22c04ea88de0f09e29de5b4f1459e93c16d712952 + checksum: 10/e92da3344687c233b0951069a623319616bafdc30ad43558b7bb57287aebfd6e8a12f05c80711ef285bb67906c79a96cfea319d48c2440a7919c494414fb20ff languageName: node linkType: hard From 5d66a7f69e694c7ca0fbd7215bffb82ac99b72eb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Jun 2024 00:39:27 +0200 Subject: [PATCH 077/207] Bump @types/lodash from 4.17.4 to 4.17.5 (#1629) Bumps [@types/lodash](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/lodash) from 4.17.4 to 4.17.5. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/lodash) --- updated-dependencies: - dependency-name: "@types/lodash" dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 9c38859d86..1e2e70f6a2 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "@types/express": "^4.17.21", "@types/jest": "29.5.12", "@types/jsonwebtoken": "^9", - "@types/lodash": "^4.17.4", + "@types/lodash": "^4.17.5", "@types/node": "^20.14.0", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.2", diff --git a/yarn.lock b/yarn.lock index a4362777f3..a7c8da4377 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1883,10 +1883,10 @@ __metadata: languageName: node linkType: hard -"@types/lodash@npm:^4.17.4": - version: 4.17.4 - resolution: "@types/lodash@npm:4.17.4" - checksum: 10/3ec19f9fc48200006e71733e08bcb1478b0398673657fcfb21a8643d41a80bcce09a01000077c3b23a3c6d86b9b314abe0672a8fdfc0fd66b893bd41955cfab8 +"@types/lodash@npm:^4.17.5": + version: 4.17.5 + resolution: "@types/lodash@npm:4.17.5" + checksum: 10/10e2e9cbeb16998026f4071f9f5f2a38b651eba15302f512e0b8ab904c07c197ca0282d2821f64e53c2b692d7046af0a1ce3ead190fb077cbe4036948fce1924 languageName: node linkType: hard @@ -7264,7 +7264,7 @@ __metadata: "@types/express": "npm:^4.17.21" "@types/jest": "npm:29.5.12" "@types/jsonwebtoken": "npm:^9" - "@types/lodash": "npm:^4.17.4" + "@types/lodash": "npm:^4.17.5" "@types/node": "npm:^20.14.0" "@types/semver": "npm:^7.5.8" "@types/supertest": "npm:^6.0.2" From 867e8754894642e6ff7ae7505968fce0948f17fe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Jun 2024 08:50:26 +0200 Subject: [PATCH 078/207] Bump braces from 3.0.2 to 3.0.3 (#1635) Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3. - [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md) - [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3) --- updated-dependencies: - dependency-name: braces dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/yarn.lock b/yarn.lock index a7c8da4377..bd52d28173 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2800,11 +2800,11 @@ __metadata: linkType: hard "braces@npm:^3.0.2, braces@npm:~3.0.2": - version: 3.0.2 - resolution: "braces@npm:3.0.2" + version: 3.0.3 + resolution: "braces@npm:3.0.3" dependencies: - fill-range: "npm:^7.0.1" - checksum: 10/966b1fb48d193b9d155f810e5efd1790962f2c4e0829f8440b8ad236ba009222c501f70185ef732fef17a4c490bb33a03b90dab0631feafbdf447da91e8165b1 + fill-range: "npm:^7.1.1" + checksum: 10/fad11a0d4697a27162840b02b1fad249c1683cbc510cd5bf1a471f2f8085c046d41094308c577a50a03a579dd99d5a6b3724c4b5e8b14df2c4443844cfcda2c6 languageName: node linkType: hard @@ -4173,12 +4173,12 @@ __metadata: languageName: node linkType: hard -"fill-range@npm:^7.0.1": - version: 7.0.1 - resolution: "fill-range@npm:7.0.1" +"fill-range@npm:^7.1.1": + version: 7.1.1 + resolution: "fill-range@npm:7.1.1" dependencies: to-regex-range: "npm:^5.0.1" - checksum: 10/e260f7592fd196b4421504d3597cc76f4a1ca7a9488260d533b611fc3cefd61e9a9be1417cb82d3b01ad9f9c0ff2dbf258e1026d2445e26b0cf5148ff4250429 + checksum: 10/a7095cb39e5bc32fada2aa7c7249d3f6b01bd1ce461a61b0adabacccabd9198500c6fb1f68a7c851a657e273fce2233ba869638897f3d7ed2e87a2d89b4436ea languageName: node linkType: hard From af28c7fcd0eaafdfb406526dcabe5d19565a0cb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Tue, 11 Jun 2024 18:07:56 +0200 Subject: [PATCH 079/207] Add ZerionBalancesSchema tests (#1637) Add ZerionBalancesSchema tests --- .../zerion-balance.entity.builder.ts | 3 +- .../entities/zerion-balance.entity.spec.ts | 573 ++++++++++++++++++ .../entities/zerion-balance.entity.ts | 27 +- .../zerion-balances-api.service.ts | 4 +- 4 files changed, 590 insertions(+), 17 deletions(-) create mode 100644 src/datasources/balances-api/entities/zerion-balance.entity.spec.ts diff --git a/src/datasources/balances-api/entities/__tests__/zerion-balance.entity.builder.ts b/src/datasources/balances-api/entities/__tests__/zerion-balance.entity.builder.ts index d47f23ed66..e11f034173 100644 --- a/src/datasources/balances-api/entities/__tests__/zerion-balance.entity.builder.ts +++ b/src/datasources/balances-api/entities/__tests__/zerion-balance.entity.builder.ts @@ -9,11 +9,12 @@ import { ZerionImplementation, ZerionQuantity, } from '@/datasources/balances-api/entities/zerion-balance.entity'; +import { getAddress } from 'viem'; export function zerionImplementationBuilder(): IBuilder { return new Builder() .with('chain_id', faker.string.sample()) - .with('address', faker.finance.ethereumAddress()) + .with('address', getAddress(faker.finance.ethereumAddress())) .with('decimals', faker.number.int()); } diff --git a/src/datasources/balances-api/entities/zerion-balance.entity.spec.ts b/src/datasources/balances-api/entities/zerion-balance.entity.spec.ts new file mode 100644 index 0000000000..5195bc7151 --- /dev/null +++ b/src/datasources/balances-api/entities/zerion-balance.entity.spec.ts @@ -0,0 +1,573 @@ +import { + zerionAttributesBuilder, + zerionBalanceBuilder, + zerionBalancesBuilder, + zerionFlagsBuilder, + zerionFungibleInfoBuilder, + zerionImplementationBuilder, + zerionQuantityBuilder, +} from '@/datasources/balances-api/entities/__tests__/zerion-balance.entity.builder'; +import { + ZerionAttributesSchema, + ZerionBalanceSchema, + ZerionBalancesSchema, + ZerionFlagsSchema, + ZerionFungibleInfoSchema, + ZerionImplementationSchema, + ZerionQuantitySchema, +} from '@/datasources/balances-api/entities/zerion-balance.entity'; +import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; +import { ZodError } from 'zod'; + +describe('Zerion Balance Entity schemas', () => { + describe('ZerionBalancesSchema', () => { + it('should validate a ZerionBalances object', () => { + const zerionBalances = zerionBalancesBuilder().build(); + + const result = ZerionBalancesSchema.safeParse(zerionBalances); + + expect(result.success).toBe(true); + }); + + it('should not allow invalid', () => { + const zerionBalances = { + data: 'invalid', + }; + + const result = ZerionBalancesSchema.safeParse(zerionBalances); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'array', + received: 'string', + path: ['data'], + message: 'Expected array, received string', + }, + ]), + ); + }); + }); + describe('ZerionBalanceSchema', () => { + it('should validate a ZerionBalance object', () => { + const zerionBalance = zerionBalanceBuilder().build(); + + const result = ZerionBalanceSchema.safeParse(zerionBalance); + + expect(result.success).toBe(true); + }); + + it('should not allow an invalid type value', () => { + const zerionBalance = zerionBalanceBuilder().build(); + // @ts-expect-error - type is expected to be a 'positions' literal + zerionBalance.type = 'invalid'; + + const result = ZerionBalanceSchema.safeParse(zerionBalance); + + expect(result.success && result.data.type).toBe('unknown'); + }); + + it('should fallback to "unknown" if type is not defined', () => { + const zerionBalance = zerionBalanceBuilder().build(); + // @ts-expect-error - type is expected to be a 'positions' literal + delete zerionBalance['type']; + + const result = ZerionBalanceSchema.safeParse(zerionBalance); + + expect(result.success && result.data.type).toBe('unknown'); + }); + + it('should not allow an invalid id value', () => { + const zerionBalance = zerionBalanceBuilder().build(); + // @ts-expect-error - id is expected to be a string + zerionBalance.id = faker.number.int(); + + const result = ZerionBalanceSchema.safeParse(zerionBalance); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'number', + path: ['id'], + message: 'Expected string, received number', + }, + ]), + ); + }); + + it.each(['id' as const, 'attributes' as const])( + 'should not allow %s to be undefined', + (key) => { + const zerionBalance = zerionBalanceBuilder().build(); + delete zerionBalance[key]; + + const result = ZerionBalanceSchema.safeParse(zerionBalance); + + expect( + !result.success && + result.error.issues.length === 1 && + result.error.issues[0].path.length === 1 && + result.error.issues[0].path[0] === key, + ).toBe(true); + }, + ); + }); + + describe('ZerionAttributesSchema', () => { + it('should validate a ZerionAttributes object', () => { + const zerionAttributes = zerionBalanceBuilder().build().attributes; + + const result = ZerionAttributesSchema.safeParse(zerionAttributes); + + expect(result.success).toBe(true); + }); + + it.each(['name' as const, 'quantity' as const])( + 'should not allow %s to be undefined', + (key) => { + const zerionAttributes = zerionAttributesBuilder().build(); + delete zerionAttributes[key]; + + const result = ZerionAttributesSchema.safeParse(zerionAttributes); + + expect( + !result.success && + result.error.issues.length === 1 && + result.error.issues[0].path.length === 1 && + result.error.issues[0].path[0] === key, + ).toBe(true); + }, + ); + + it('should not allow an invalid name value', () => { + const zerionAttributes = zerionAttributesBuilder().build(); + // @ts-expect-error - name is expected to be a string + zerionAttributes.name = faker.number.int(); + + const result = ZerionAttributesSchema.safeParse(zerionAttributes); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'number', + path: ['name'], + message: 'Expected string, received number', + }, + ]), + ); + }); + + it('should not allow an invalid value value', () => { + const zerionAttributes = zerionAttributesBuilder().build(); + // @ts-expect-error - value is expected to be a number + zerionAttributes.value = faker.string.sample(); + + const result = ZerionAttributesSchema.safeParse(zerionAttributes); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'number', + received: 'string', + path: ['value'], + message: 'Expected number, received string', + }, + ]), + ); + }); + + it('should not allow an invalid price value', () => { + const zerionAttributes = zerionAttributesBuilder().build(); + // @ts-expect-error - price is expected to be a number + zerionAttributes.price = faker.string.sample(); + + const result = ZerionAttributesSchema.safeParse(zerionAttributes); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'number', + received: 'string', + path: ['price'], + message: 'Expected number, received string', + }, + ]), + ); + }); + }); + + describe('ZerionQuantitySchema', () => { + it('should validate a ZerionQuantity object', () => { + const zerionQuantity = zerionQuantityBuilder().build(); + + const result = ZerionQuantitySchema.safeParse(zerionQuantity); + + expect(result.success).toBe(true); + }); + + it.each([ + 'int' as const, + 'decimals' as const, + 'float' as const, + 'numeric' as const, + ])('should not allow %s to be undefined', (key) => { + const zerionQuantity = zerionQuantityBuilder().build(); + delete zerionQuantity[key]; + + const result = ZerionQuantitySchema.safeParse(zerionQuantity); + + expect( + !result.success && + result.error.issues.length === 1 && + result.error.issues[0].path.length === 1 && + result.error.issues[0].path[0] === key, + ).toBe(true); + }); + + it('should not allow an invalid int value', () => { + const zerionQuantity = zerionQuantityBuilder().build(); + // @ts-expect-error - int is expected to be a string + zerionQuantity.int = faker.number.int(); + + const result = ZerionQuantitySchema.safeParse(zerionQuantity); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'number', + path: ['int'], + message: 'Expected string, received number', + }, + ]), + ); + }); + + it('should not allow an invalid decimals value', () => { + const zerionQuantity = zerionQuantityBuilder().build(); + // @ts-expect-error - decimals is expected to be a number + zerionQuantity.decimals = faker.string.sample(); + + const result = ZerionQuantitySchema.safeParse(zerionQuantity); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'number', + received: 'string', + path: ['decimals'], + message: 'Expected number, received string', + }, + ]), + ); + }); + + it('should not allow an invalid float value', () => { + const zerionQuantity = zerionQuantityBuilder().build(); + // @ts-expect-error - float is expected to be a number + zerionQuantity.float = faker.string.sample(); + + const result = ZerionQuantitySchema.safeParse(zerionQuantity); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'number', + received: 'string', + path: ['float'], + message: 'Expected number, received string', + }, + ]), + ); + }); + + it('should not allow an invalid numeric value', () => { + const zerionQuantity = zerionQuantityBuilder().build(); + // @ts-expect-error - numeric is expected to be a string + zerionQuantity.numeric = faker.number.float(); + + const result = ZerionQuantitySchema.safeParse(zerionQuantity); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'number', + path: ['numeric'], + message: 'Expected string, received number', + }, + ]), + ); + }); + }); + + describe('ZerionFlagsSchema', () => { + it('should validate a ZerionFlags object', () => { + const zerionFlags = zerionFlagsBuilder().build(); + + const result = ZerionFlagsSchema.safeParse(zerionFlags); + + expect(result.success).toBe(true); + }); + + it('should not allow displayable to be undefined', () => { + const zerionFlags = zerionFlagsBuilder().build(); + // @ts-expect-error - inferred types don't allow optional fields + delete zerionFlags.displayable; + + const result = ZerionFlagsSchema.safeParse(zerionFlags); + + expect( + !result.success && + result.error.issues.length === 1 && + result.error.issues[0].path.length === 1 && + result.error.issues[0].path[0] === 'displayable', + ).toBe(true); + }); + + it('should not allow an invalid ZerionFlags', () => { + const zerionFlags = zerionFlagsBuilder().build(); + // @ts-expect-error - displayable is expected to be a boolean + zerionFlags.displayable = faker.string.sample(); + + const result = ZerionFlagsSchema.safeParse(zerionFlags); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'boolean', + received: 'string', + path: ['displayable'], + message: 'Expected boolean, received string', + }, + ]), + ); + }); + }); + + describe('ZerionFungibleInfoSchema', () => { + it('should validate a ZerionFungibleInfo object', () => { + const zerionFungibleInfo = zerionFungibleInfoBuilder().build(); + + const result = ZerionFungibleInfoSchema.safeParse(zerionFungibleInfo); + + expect(result.success).toBe(true); + }); + + it('should not allow an invalid name value', () => { + const zerionFungibleInfo = zerionFungibleInfoBuilder().build(); + // @ts-expect-error - name is expected to be a string + zerionFungibleInfo.name = faker.number.int(); + + const result = ZerionFungibleInfoSchema.safeParse(zerionFungibleInfo); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'number', + path: ['name'], + message: 'Expected string, received number', + }, + ]), + ); + }); + + it('should not allow an invalid symbol value', () => { + const zerionFungibleInfo = zerionFungibleInfoBuilder().build(); + // @ts-expect-error - symbol is expected to be a string + zerionFungibleInfo.symbol = faker.number.int(); + + const result = ZerionFungibleInfoSchema.safeParse(zerionFungibleInfo); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'number', + path: ['symbol'], + message: 'Expected string, received number', + }, + ]), + ); + }); + + it('should not allow an invalid description value', () => { + const zerionFungibleInfo = zerionFungibleInfoBuilder().build(); + // @ts-expect-error - description is expected to be a string + zerionFungibleInfo.description = faker.number.int(); + + const result = ZerionFungibleInfoSchema.safeParse(zerionFungibleInfo); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'number', + path: ['description'], + message: 'Expected string, received number', + }, + ]), + ); + }); + + it('should not allow an invalid icon value', () => { + const zerionFungibleInfo = zerionFungibleInfoBuilder().build(); + // @ts-expect-error - icon is expected to be an object + zerionFungibleInfo.icon = faker.string.sample(); + + const result = ZerionFungibleInfoSchema.safeParse(zerionFungibleInfo); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'object', + received: 'string', + path: ['icon'], + message: 'Expected object, received string', + }, + ]), + ); + }); + + it('should not allow an invalid icon url value', () => { + const zerionFungibleInfo = zerionFungibleInfoBuilder().build(); + // @ts-expect-error - icon url is expected to be a string + zerionFungibleInfo.icon = { url: faker.number.int() }; + + const result = ZerionFungibleInfoSchema.safeParse(zerionFungibleInfo); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'number', + path: ['icon', 'url'], + message: 'Expected string, received number', + }, + ]), + ); + }); + }); + + describe('ZerionImplementationSchema', () => { + it('should validate a ZerionImplementation object', () => { + const zerionImplementation = zerionImplementationBuilder().build(); + + const result = ZerionImplementationSchema.safeParse(zerionImplementation); + + expect(result.success).toBe(true); + }); + + it.each(['chain_id' as const, 'decimals' as const])( + 'should not allow %s to be undefined', + (key) => { + const zerionImplementation = zerionImplementationBuilder().build(); + delete zerionImplementation[key]; + + const result = + ZerionImplementationSchema.safeParse(zerionImplementation); + + expect( + !result.success && + result.error.issues.length === 1 && + result.error.issues[0].path.length === 1 && + result.error.issues[0].path[0] === key, + ).toBe(true); + }, + ); + + it('should not allow an invalid chain_id value', () => { + const zerionImplementation = zerionImplementationBuilder().build(); + // @ts-expect-error - chain_id is expected to be a string + zerionImplementation.chain_id = faker.number.int(); + + const result = ZerionImplementationSchema.safeParse(zerionImplementation); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'number', + path: ['chain_id'], + message: 'Expected string, received number', + }, + ]), + ); + }); + + it('should not allow an invalid address value', () => { + const zerionImplementation = zerionImplementationBuilder().build(); + // @ts-expect-error - address is expected to be a string + zerionImplementation.address = faker.number.int(); + + const result = ZerionImplementationSchema.safeParse(zerionImplementation); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'number', + path: ['address'], + message: 'Expected string, received number', + }, + ]), + ); + }); + + it('should checksum a valid, non-checksummed address', () => { + const zerionImplementation = zerionImplementationBuilder().build(); + const nonChecksummedAddress = faker.finance + .ethereumAddress() + .toLowerCase(); + // @ts-expect-error - address is expected to be a checksummed address + zerionImplementation.address = nonChecksummedAddress; + + const result = ZerionImplementationSchema.safeParse(zerionImplementation); + + expect(result.success && result.data.address).toBe( + getAddress(nonChecksummedAddress), + ); + }); + + it('should not allow an invalid decimals value', () => { + const zerionImplementation = zerionImplementationBuilder().build(); + // @ts-expect-error - decimals is expected to be a number + zerionImplementation.decimals = faker.string.sample(); + + const result = ZerionImplementationSchema.safeParse(zerionImplementation); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'number', + received: 'string', + path: ['decimals'], + message: 'Expected number, received string', + }, + ]), + ); + }); + }); +}); diff --git a/src/datasources/balances-api/entities/zerion-balance.entity.ts b/src/datasources/balances-api/entities/zerion-balance.entity.ts index c3f9c94a0c..1195078dbe 100644 --- a/src/datasources/balances-api/entities/zerion-balance.entity.ts +++ b/src/datasources/balances-api/entities/zerion-balance.entity.ts @@ -3,6 +3,7 @@ * Reference documentation: https://developers.zerion.io/reference/listwalletpositions */ +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; import { z } from 'zod'; export type ZerionFungibleInfo = z.infer; @@ -19,47 +20,47 @@ export type ZerionBalance = z.infer; export type ZerionBalances = z.infer; -const ZerionImplementationSchema = z.object({ +export const ZerionImplementationSchema = z.object({ chain_id: z.string(), - address: z.string().nullable(), + address: AddressSchema.nullish().default(null), decimals: z.number(), }); -const ZerionFungibleInfoSchema = z.object({ - name: z.string().nullable(), - symbol: z.string().nullable(), +export const ZerionFungibleInfoSchema = z.object({ + name: z.string().nullish().default(null), + symbol: z.string().nullish().default(null), description: z.string().nullish().default(null), icon: z .object({ - url: z.string().nullable(), + url: z.string().nullish().default(null), }) .nullish() .default(null), implementations: z.array(ZerionImplementationSchema), }); -const ZerionQuantitySchema = z.object({ +export const ZerionQuantitySchema = z.object({ int: z.string(), decimals: z.number(), float: z.number(), numeric: z.string(), }); -const ZerionFlagsSchema = z.object({ +export const ZerionFlagsSchema = z.object({ displayable: z.boolean(), }); -const ZerionAttributesSchema = z.object({ +export const ZerionAttributesSchema = z.object({ name: z.string(), quantity: ZerionQuantitySchema, - value: z.number().nullable(), - price: z.number(), + value: z.number().nullish().default(null), + price: z.number().nullish().default(null), fungible_info: ZerionFungibleInfoSchema, flags: ZerionFlagsSchema, }); export const ZerionBalanceSchema = z.object({ - type: z.literal('positions'), + type: z.enum(['positions', 'unknown']).catch('unknown'), id: z.string(), attributes: ZerionAttributesSchema, }); @@ -67,5 +68,3 @@ export const ZerionBalanceSchema = z.object({ export const ZerionBalancesSchema = z.object({ data: z.array(ZerionBalanceSchema), }); - -// TODO: add schema tests. diff --git a/src/datasources/balances-api/zerion-balances-api.service.ts b/src/datasources/balances-api/zerion-balances-api.service.ts index 17fe16201c..eae9846a8d 100644 --- a/src/datasources/balances-api/zerion-balances-api.service.ts +++ b/src/datasources/balances-api/zerion-balances-api.service.ts @@ -222,9 +222,9 @@ export class ZerionBalancesApi implements IBalancesApi { throw Error( `Zerion error: ${chainName} implementation not found for balance ${zb.id}`, ); - const { value } = zb.attributes; + const { value, price } = zb.attributes; const fiatBalance = value ? getNumberString(value) : null; - const fiatConversion = getNumberString(zb.attributes.price); + const fiatConversion = price ? getNumberString(price) : null; return { ...(implementation.address === null From d932f709a891c3fc1e92bc628408dbb1cf43d7a0 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Wed, 12 Jun 2024 09:19:38 +0200 Subject: [PATCH 080/207] Add `IBlockchainApiManager` for on-chain interactions (#1623) Adds a new `IBlockchainManager`, returning a client per chain. It can be used as a basis for future on-chain interactions, e.g. verification of EIP-1271 signatures: - Add relative configuration for Infura API key - Add `IBlockchainApiManager` and implementation with test coverage - Clear client on `CHAIN_UPDATE` - Add relevant test coverage --- .../entities/__tests__/configuration.ts | 5 ++ src/config/entities/configuration.ts | 5 ++ .../blockchain/blockchain-api.manager.spec.ts | 49 ++++++++++++++ .../blockchain/blockchain-api.manager.ts | 67 +++++++++++++++++++ .../blockchain.repository.interface.ts | 18 +++++ .../blockchain/blockchain.repository.ts | 15 +++++ .../blockchain-api.manager.interface.ts | 21 ++++++ .../cache-hooks.controller.spec.ts | 38 +++++++++++ src/routes/cache-hooks/cache-hooks.module.ts | 2 + src/routes/cache-hooks/cache-hooks.service.ts | 10 ++- test/e2e-setup.ts | 1 + 11 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 src/datasources/blockchain/blockchain-api.manager.spec.ts create mode 100644 src/datasources/blockchain/blockchain-api.manager.ts create mode 100644 src/domain/blockchain/blockchain.repository.interface.ts create mode 100644 src/domain/blockchain/blockchain.repository.ts create mode 100644 src/domain/interfaces/blockchain-api.manager.interface.ts diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index 638ffa1826..1655812085 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -66,6 +66,11 @@ export default (): ReturnType => ({ }, }, }, + blockchain: { + infura: { + apiKey: faker.string.hexadecimal({ length: 32 }), + }, + }, db: { postgres: { host: process.env.POSTGRES_TEST_HOST || 'localhost', diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index d1a6cf14d3..6059bd8a58 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -105,6 +105,11 @@ export default () => ({ }, }, }, + blockchain: { + infura: { + apiKey: process.env.INFURA_API_KEY, + }, + }, db: { postgres: { host: process.env.POSTGRES_HOST || 'localhost', diff --git a/src/datasources/blockchain/blockchain-api.manager.spec.ts b/src/datasources/blockchain/blockchain-api.manager.spec.ts new file mode 100644 index 0000000000..b54bd9d1d6 --- /dev/null +++ b/src/datasources/blockchain/blockchain-api.manager.spec.ts @@ -0,0 +1,49 @@ +import { FakeConfigurationService } from '@/config/__tests__/fake.configuration.service'; +import { BlockchainApiManager } from '@/datasources/blockchain/blockchain-api.manager'; +import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; +import { IConfigApi } from '@/domain/interfaces/config-api.interface'; +import { faker } from '@faker-js/faker'; + +const configApiMock = jest.mocked({ + getChain: jest.fn(), +} as jest.MockedObjectDeep); + +describe('BlockchainApiManager', () => { + let target: BlockchainApiManager; + + beforeEach(() => { + jest.resetAllMocks(); + + const fakeConfigurationService = new FakeConfigurationService(); + fakeConfigurationService.set( + 'blockchain.infura.apiKey', + faker.string.hexadecimal({ length: 32 }), + ); + target = new BlockchainApiManager(fakeConfigurationService, configApiMock); + }); + + describe('getBlockchainApi', () => { + it('caches the API', async () => { + const chain = chainBuilder().build(); + configApiMock.getChain.mockResolvedValue(chain); + + const api = await target.getBlockchainApi(chain.chainId); + const cachedApi = await target.getBlockchainApi(chain.chainId); + + expect(api).toBe(cachedApi); + }); + }); + + describe('destroyBlockchainApi', () => { + it('destroys the API', async () => { + const chain = chainBuilder().build(); + configApiMock.getChain.mockResolvedValue(chain); + + const api = await target.getBlockchainApi(chain.chainId); + target.destroyBlockchainApi(chain.chainId); + const cachedApi = await target.getBlockchainApi(chain.chainId); + + expect(api).not.toBe(cachedApi); + }); + }); +}); diff --git a/src/datasources/blockchain/blockchain-api.manager.ts b/src/datasources/blockchain/blockchain-api.manager.ts new file mode 100644 index 0000000000..797b9509a8 --- /dev/null +++ b/src/datasources/blockchain/blockchain-api.manager.ts @@ -0,0 +1,67 @@ +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { Chain as DomainChain } from '@/domain/chains/entities/chain.entity'; +import { RpcUriAuthentication } from '@/domain/chains/entities/rpc-uri-authentication.entity'; +import { IBlockchainApiManager } from '@/domain/interfaces/blockchain-api.manager.interface'; +import { IConfigApi } from '@/domain/interfaces/config-api.interface'; +import { Inject, Injectable } from '@nestjs/common'; +import { Chain, PublicClient, createPublicClient, http } from 'viem'; + +@Injectable() +export class BlockchainApiManager implements IBlockchainApiManager { + private readonly blockchainApiMap: Record = {}; + private readonly infuraApiKey: string; + + constructor( + @Inject(IConfigurationService) + private readonly configurationService: IConfigurationService, + @Inject(IConfigApi) private readonly configApi: IConfigApi, + ) { + this.infuraApiKey = this.configurationService.getOrThrow( + 'blockchain.infura.apiKey', + ); + } + + async getBlockchainApi(chainId: string): Promise { + const blockchainApi = this.blockchainApiMap[chainId]; + if (blockchainApi) { + return blockchainApi; + } + + const chain = await this.configApi.getChain(chainId); + this.blockchainApiMap[chainId] = this.createClient(chain); + + return this.blockchainApiMap[chainId]; + } + + destroyBlockchainApi(chainId: string): void { + if (this.blockchainApiMap?.[chainId]) { + delete this.blockchainApiMap[chainId]; + } + } + + private createClient(chain: DomainChain): PublicClient { + return createPublicClient({ + chain: this.formatChain(chain), + transport: http(), + }); + } + + private formatChain(chain: DomainChain): Chain { + return { + id: Number(chain.chainId), + name: chain.chainName, + nativeCurrency: chain.nativeCurrency, + rpcUrls: { + default: { + http: [this.formatRpcUri(chain.rpcUri)], + }, + }, + }; + } + + private formatRpcUri(rpcUri: DomainChain['rpcUri']): string { + return rpcUri.authentication === RpcUriAuthentication.ApiKeyPath + ? rpcUri.value + this.infuraApiKey + : rpcUri.value; + } +} diff --git a/src/domain/blockchain/blockchain.repository.interface.ts b/src/domain/blockchain/blockchain.repository.interface.ts new file mode 100644 index 0000000000..c3780a3b44 --- /dev/null +++ b/src/domain/blockchain/blockchain.repository.interface.ts @@ -0,0 +1,18 @@ +import { BlockchainRepository } from '@/domain/blockchain/blockchain.repository'; +import { BlockchainApiManagerModule } from '@/domain/interfaces/blockchain-api.manager.interface'; +import { Module } from '@nestjs/common'; + +export const IBlockchainRepository = Symbol('IBlockchainRepository'); + +export interface IBlockchainRepository { + clearClient(chainId: string): void; +} + +@Module({ + imports: [BlockchainApiManagerModule], + providers: [ + { provide: IBlockchainRepository, useClass: BlockchainRepository }, + ], + exports: [IBlockchainRepository], +}) +export class BlockchainRepositoryModule {} diff --git a/src/domain/blockchain/blockchain.repository.ts b/src/domain/blockchain/blockchain.repository.ts new file mode 100644 index 0000000000..fcb274373c --- /dev/null +++ b/src/domain/blockchain/blockchain.repository.ts @@ -0,0 +1,15 @@ +import { IBlockchainRepository } from '@/domain/blockchain/blockchain.repository.interface'; +import { IBlockchainApiManager } from '@/domain/interfaces/blockchain-api.manager.interface'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class BlockchainRepository implements IBlockchainRepository { + constructor( + @Inject(IBlockchainApiManager) + private readonly blockchainApiManager: IBlockchainApiManager, + ) {} + + clearClient(chainId: string): void { + this.blockchainApiManager.destroyBlockchainApi(chainId); + } +} diff --git a/src/domain/interfaces/blockchain-api.manager.interface.ts b/src/domain/interfaces/blockchain-api.manager.interface.ts new file mode 100644 index 0000000000..542bd23153 --- /dev/null +++ b/src/domain/interfaces/blockchain-api.manager.interface.ts @@ -0,0 +1,21 @@ +import { BlockchainApiManager } from '@/datasources/blockchain/blockchain-api.manager'; +import { ConfigApiModule } from '@/datasources/config-api/config-api.module'; +import { PublicClient } from 'viem'; +import { Module } from '@nestjs/common'; + +export const IBlockchainApiManager = Symbol('IBlockchainApiManager'); + +export interface IBlockchainApiManager { + getBlockchainApi(chainId: string): Promise; + + destroyBlockchainApi(chainId: string): void; +} + +@Module({ + imports: [ConfigApiModule], + providers: [ + { provide: IBlockchainApiManager, useClass: BlockchainApiManager }, + ], + exports: [IBlockchainApiManager], +}) +export class BlockchainApiManagerModule {} diff --git a/src/routes/cache-hooks/cache-hooks.controller.spec.ts b/src/routes/cache-hooks/cache-hooks.controller.spec.ts index 64f783f892..a4214da45a 100644 --- a/src/routes/cache-hooks/cache-hooks.controller.spec.ts +++ b/src/routes/cache-hooks/cache-hooks.controller.spec.ts @@ -26,6 +26,7 @@ import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues- import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; +import { IBlockchainApiManager } from '@/domain/interfaces/blockchain-api.manager.interface'; import { safeCreatedEventBuilder } from '@/routes/cache-hooks/entities/__tests__/safe-created.build'; describe('Post Hook Events (Unit)', () => { @@ -35,6 +36,7 @@ describe('Post Hook Events (Unit)', () => { let fakeCacheService: FakeCacheService; let networkService: jest.MockedObjectDeep; let configurationService: IConfigurationService; + let blockchainApiManager: IBlockchainApiManager; async function initApp(config: typeof configuration): Promise { const moduleFixture: TestingModule = await Test.createTestingModule({ @@ -55,6 +57,9 @@ describe('Post Hook Events (Unit)', () => { fakeCacheService = moduleFixture.get(CacheService); configurationService = moduleFixture.get(IConfigurationService); + blockchainApiManager = moduleFixture.get( + IBlockchainApiManager, + ); authToken = configurationService.getOrThrow('auth.token'); safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); @@ -863,6 +868,39 @@ describe('Post Hook Events (Unit)', () => { await expect(fakeCacheService.get(cacheDir)).resolves.toBeUndefined(); }); + it.each([ + { + type: 'CHAIN_UPDATE', + }, + ])('$type clears the blockchain client', async (payload) => { + const chainId = faker.string.numeric(); + const data = { + chainId: chainId, + ...payload, + }; + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${safeConfigUrl}/api/v1/chains/${chainId}`: + return Promise.resolve({ + data: chainBuilder().with('chainId', chainId).build(), + status: 200, + }); + default: + return Promise.reject(new Error(`Could not match ${url}`)); + } + }); + const client = await blockchainApiManager.getBlockchainApi(chainId); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(data) + .expect(202); + + const newClient = await blockchainApiManager.getBlockchainApi(chainId); + expect(client).not.toBe(newClient); + }); + it.each([ { type: 'SAFE_APPS_UPDATE', diff --git a/src/routes/cache-hooks/cache-hooks.module.ts b/src/routes/cache-hooks/cache-hooks.module.ts index 387bd2516e..5bcf9a4020 100644 --- a/src/routes/cache-hooks/cache-hooks.module.ts +++ b/src/routes/cache-hooks/cache-hooks.module.ts @@ -8,10 +8,12 @@ import { SafeRepositoryModule } from '@/domain/safe/safe.repository.interface'; import { MessagesRepositoryModule } from '@/domain/messages/messages.repository.interface'; import { SafeAppsRepositoryModule } from '@/domain/safe-apps/safe-apps.repository.interface'; import { QueuesRepositoryModule } from '@/domain/queues/queues-repository.interface'; +import { BlockchainRepositoryModule } from '@/domain/blockchain/blockchain.repository.interface'; @Module({ imports: [ BalancesRepositoryModule, + BlockchainRepositoryModule, ChainsRepositoryModule, CollectiblesRepositoryModule, MessagesRepositoryModule, diff --git a/src/routes/cache-hooks/cache-hooks.service.ts b/src/routes/cache-hooks/cache-hooks.service.ts index dd4c0de72d..114fb69c40 100644 --- a/src/routes/cache-hooks/cache-hooks.service.ts +++ b/src/routes/cache-hooks/cache-hooks.service.ts @@ -12,6 +12,7 @@ import { IConfigurationService } from '@/config/configuration.service.interface' import { IQueuesRepository } from '@/domain/queues/queues-repository.interface'; import { ConsumeMessage } from 'amqplib'; import { WebHookSchema } from '@/routes/cache-hooks/entities/schemas/web-hook.schema'; +import { IBlockchainRepository } from '@/domain/blockchain/blockchain.repository.interface'; @Injectable() export class CacheHooksService implements OnModuleInit { @@ -21,6 +22,8 @@ export class CacheHooksService implements OnModuleInit { constructor( @Inject(IBalancesRepository) private readonly balancesRepository: IBalancesRepository, + @Inject(IBlockchainRepository) + private readonly blockchainRepository: IBlockchainRepository, @Inject(IChainsRepository) private readonly chainsRepository: IChainsRepository, @Inject(ICollectiblesRepository) @@ -314,7 +317,12 @@ export class CacheHooksService implements OnModuleInit { this._logMessageEvent(event); break; case EventType.CHAIN_UPDATE: - promises.push(this.chainsRepository.clearChain(event.chainId)); + promises.push( + this.chainsRepository.clearChain(event.chainId).then(() => { + // Clear after updated as RPC may have change + this.blockchainRepository.clearClient(event.chainId); + }), + ); this._logEvent(event); break; case EventType.SAFE_APPS_UPDATE: diff --git a/test/e2e-setup.ts b/test/e2e-setup.ts index aed3e636af..946aba9eb5 100644 --- a/test/e2e-setup.ts +++ b/test/e2e-setup.ts @@ -8,6 +8,7 @@ process.env.ALERTS_PROVIDER_PROJECT = 'fake-project'; process.env.EMAIL_API_APPLICATION_CODE = 'fake-application-code'; process.env.EMAIL_API_FROM_EMAIL = 'changeme@example.com'; process.env.EMAIL_API_KEY = 'fake-api-key'; +process.env.INFURA_API_KEY = 'fake-api-key'; // For E2E tests, connect to the test database process.env.POSTGRES_HOST = 'localhost'; From 7a69e4d19ade116d78e2781914b8acde24eedd9b Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Wed, 12 Jun 2024 09:42:16 +0200 Subject: [PATCH 081/207] Support authentication with ERC-6492 (#1627) Integrate `IBlockchainApiManager` with our SiWe implementation to support smart contract wallets: - Import `BlockchainApiManagerModule` in `SiweRepositoryModule` - Modify signature validation in `SiweRepository` to verifies signatures of EOAs then on-chain - Create `FakeBlockchainApiManager`/`TestBlockchainApiManager` for mocking `eth_call` responses - Add appropriate test coverage --- .../__tests__/fake.blockchain-api.manager.ts | 8 ++ .../__tests__/test.blockchain-api.manager.ts | 14 ++++ src/domain/siwe/siwe.repository.interface.ts | 3 +- src/domain/siwe/siwe.repository.ts | 40 +++++++++- src/routes/auth/auth.controller.spec.ts | 73 ++++++++++++++++++- 5 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 src/datasources/blockchain/__tests__/fake.blockchain-api.manager.ts create mode 100644 src/datasources/blockchain/__tests__/test.blockchain-api.manager.ts diff --git a/src/datasources/blockchain/__tests__/fake.blockchain-api.manager.ts b/src/datasources/blockchain/__tests__/fake.blockchain-api.manager.ts new file mode 100644 index 0000000000..eb7f17396c --- /dev/null +++ b/src/datasources/blockchain/__tests__/fake.blockchain-api.manager.ts @@ -0,0 +1,8 @@ +import { IBlockchainApiManager } from '@/domain/interfaces/blockchain-api.manager.interface'; +import { Injectable } from '@nestjs/common'; +@Injectable() +export class FakeBlockchainApiManager implements IBlockchainApiManager { + getBlockchainApi = jest.fn(); + + destroyBlockchainApi = jest.fn(); +} diff --git a/src/datasources/blockchain/__tests__/test.blockchain-api.manager.ts b/src/datasources/blockchain/__tests__/test.blockchain-api.manager.ts new file mode 100644 index 0000000000..c4375ef0e4 --- /dev/null +++ b/src/datasources/blockchain/__tests__/test.blockchain-api.manager.ts @@ -0,0 +1,14 @@ +import { FakeBlockchainApiManager } from '@/datasources/blockchain/__tests__/fake.blockchain-api.manager'; +import { IBlockchainApiManager } from '@/domain/interfaces/blockchain-api.manager.interface'; +import { Module } from '@nestjs/common'; + +@Module({ + providers: [ + { + provide: IBlockchainApiManager, + useClass: FakeBlockchainApiManager, + }, + ], + exports: [IBlockchainApiManager], +}) +export class TestBlockchainApiManagerModule {} diff --git a/src/domain/siwe/siwe.repository.interface.ts b/src/domain/siwe/siwe.repository.interface.ts index 1b97190c2e..b73f05e572 100644 --- a/src/domain/siwe/siwe.repository.interface.ts +++ b/src/domain/siwe/siwe.repository.interface.ts @@ -1,4 +1,5 @@ import { SiweApiModule } from '@/datasources/siwe-api/siwe-api.module'; +import { BlockchainApiManagerModule } from '@/domain/interfaces/blockchain-api.manager.interface'; import { SiweRepository } from '@/domain/siwe/siwe.repository'; import { Module } from '@nestjs/common'; @@ -14,7 +15,7 @@ export interface ISiweRepository { } @Module({ - imports: [SiweApiModule], + imports: [SiweApiModule, BlockchainApiManagerModule], providers: [ { provide: ISiweRepository, diff --git a/src/domain/siwe/siwe.repository.ts b/src/domain/siwe/siwe.repository.ts index faa88e9ce9..4b51cc6e56 100644 --- a/src/domain/siwe/siwe.repository.ts +++ b/src/domain/siwe/siwe.repository.ts @@ -9,6 +9,7 @@ import { parseSiweMessage, validateSiweMessage, } from 'viem/siwe'; +import { IBlockchainApiManager } from '@/domain/interfaces/blockchain-api.manager.interface'; @Injectable() export class SiweRepository implements ISiweRepository { @@ -21,6 +22,8 @@ export class SiweRepository implements ISiweRepository { private readonly configurationService: IConfigurationService, @Inject(LoggingService) private readonly loggingService: ILoggingService, + @Inject(IBlockchainApiManager) + private readonly blockchainApiManager: IBlockchainApiManager, ) { this.maxValidityPeriodInSeconds = this.configurationService.getOrThrow( 'auth.maxValidityPeriodSeconds', @@ -72,7 +75,7 @@ export class SiweRepository implements ISiweRepository { message, }); - if (!isValidMessage || !message.address) { + if (!isValidMessage || !message.chainId || !message.address) { return false; } @@ -88,7 +91,8 @@ export class SiweRepository implements ISiweRepository { // Verify signature and nonce is cached (not a replay attack) const [isValidSignature, isNonceCached] = await Promise.all([ - verifyMessage({ + this.isValidSignature({ + chainId: message.chainId.toString(), address: message.address, message: args.message, signature: args.signature, @@ -106,4 +110,36 @@ export class SiweRepository implements ISiweRepository { await this.siweApi.clearNonce(message.nonce); } } + + /** + * Verifies signature of signed SiWe message, either by EOA or smart contract + * + * @param args.message - SiWe message + * @param args.chainId - chainId of the blockchain + * @param args.address - address of the signer + * @param args.signature - signature from signing {@link args.message} + * + * @returns boolean - whether the signature is valid + */ + private async isValidSignature(args: { + message: string; + chainId: string; + address: `0x${string}`; + signature: `0x${string}`; + }): Promise { + // First check if valid signature of EOA as it can be done off chain + const isValidEoaSignature = await verifyMessage(args).catch(() => false); + if (isValidEoaSignature) { + return true; + } + + // Else, verify hash on-chain using ERC-6492 for smart contract accounts + const blockchainApi = await this.blockchainApiManager.getBlockchainApi( + args.chainId, + ); + return blockchainApi.verifySiweMessage({ + message: args.message, + signature: args.signature, + }); + } } diff --git a/src/routes/auth/auth.controller.spec.ts b/src/routes/auth/auth.controller.spec.ts index b50ba83a04..b705767724 100644 --- a/src/routes/auth/auth.controller.spec.ts +++ b/src/routes/auth/auth.controller.spec.ts @@ -31,10 +31,19 @@ import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues- import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; import { IConfigurationService } from '@/config/configuration.service.interface'; +import { FakeBlockchainApiManager } from '@/datasources/blockchain/__tests__/fake.blockchain-api.manager'; +import { + BlockchainApiManagerModule, + IBlockchainApiManager, +} from '@/domain/interfaces/blockchain-api.manager.interface'; +import { TestBlockchainApiManagerModule } from '@/datasources/blockchain/__tests__/test.blockchain-api.manager'; + +const verifySiweMessageMock = jest.fn(); describe('AuthController', () => { let app: INestApplication; let cacheService: FakeCacheService; + let blockchainApiManager: FakeBlockchainApiManager; let maxValidityPeriodInMs: number; beforeEach(async () => { @@ -55,6 +64,8 @@ describe('AuthController', () => { }) .overrideModule(JWT_CONFIGURATION_MODULE) .useModule(JwtConfigurationModule.register(jwtConfiguration)) + .overrideModule(BlockchainApiManagerModule) + .useModule(TestBlockchainApiManagerModule) .overrideModule(AccountDataSourceModule) .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) @@ -70,12 +81,17 @@ describe('AuthController', () => { .compile(); cacheService = moduleFixture.get(CacheService); + blockchainApiManager = moduleFixture.get(IBlockchainApiManager); const configService: IConfigurationService = moduleFixture.get( IConfigurationService, ); maxValidityPeriodInMs = configService.getOrThrow('auth.maxValidityPeriodSeconds') * 1_000; + blockchainApiManager.getBlockchainApi.mockImplementation(() => ({ + verifySiweMessage: verifySiweMessageMock, + })); + app = await new TestAppProvider().provide(moduleFixture); await app.init(); @@ -152,6 +168,56 @@ describe('AuthController', () => { expect(setCookie).toHaveLength; expect(setCookie[0]).toMatch(setCookieRegExp); }); + // Verified off-chain as EOA + expect(verifySiweMessageMock).not.toHaveBeenCalled(); + // Nonce deleted + await expect(cacheService.get(cacheDir)).resolves.toBe(undefined); + }); + + it('should verify a smart contract signer', async () => { + const nonceResponse = await request(app.getHttpServer()).get( + '/v1/auth/nonce', + ); + const nonce: string = nonceResponse.body.nonce; + const cacheDir = new CacheDir(`auth_nonce_${nonce}`, ''); + const expirationTime = faker.date.between({ + from: new Date(), + to: new Date(Date.now() + maxValidityPeriodInMs), + }); + const message = createSiweMessage( + siweMessageBuilder() + .with('nonce', nonce) + .with('expirationTime', expirationTime) + .build(), + ); + const signature = faker.string.hexadecimal({ length: 132 }); + verifySiweMessageMock.mockResolvedValue(true); + const maxAge = getSecondsUntil(expirationTime); + // jsonwebtoken sets expiration based on timespans, not exact dates + // meaning we cannot use expirationTime directly + const expires = new Date(Date.now() + maxAge * 1_000); + + await expect(cacheService.get(cacheDir)).resolves.toBe( + nonceResponse.body.nonce, + ); + await request(app.getHttpServer()) + .post('/v1/auth/verify') + .send({ + message, + signature, + }) + .expect(200) + .expect(({ headers }) => { + const setCookie = headers['set-cookie']; + const setCookieRegExp = new RegExp( + `access_token=([^;]*); Max-Age=${maxAge}; Path=/; Expires=${expires.toUTCString()}; HttpOnly; Secure; SameSite=Lax`, + ); + + expect(setCookie).toHaveLength; + expect(setCookie[0]).toMatch(setCookieRegExp); + }); + // Verified on-chain as could not verify EOA + expect(verifySiweMessageMock).toHaveBeenCalledTimes(1); // Nonce deleted await expect(cacheService.get(cacheDir)).resolves.toBe(undefined); }); @@ -284,7 +350,7 @@ describe('AuthController', () => { await expect(cacheService.get(cacheDir)).resolves.toBe(undefined); }); - it('should not verify a signer if the signature is invalid', async () => { + it('should not verify a (smart contract) signer if the signature is invalid', async () => { const nonceResponse = await request(app.getHttpServer()).get( '/v1/auth/nonce', ); @@ -300,7 +366,8 @@ describe('AuthController', () => { .build(), ); const cacheDir = new CacheDir(`auth_nonce_${nonce}`, ''); - const signature = faker.string.hexadecimal(); + const signature = faker.string.hexadecimal({ length: 132 }); + verifySiweMessageMock.mockResolvedValue(false); await expect(cacheService.get(cacheDir)).resolves.toBe(nonce); await request(app.getHttpServer()) @@ -318,6 +385,8 @@ describe('AuthController', () => { statusCode: 401, }); }); + // Tried to verify off-/on-chain but failed + expect(verifySiweMessageMock).toHaveBeenCalledTimes(1); // Nonce deleted await expect(cacheService.get(cacheDir)).resolves.toBe(undefined); }); From 449ab9c920927600d647da3e9053e594f20d3f16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Wed, 12 Jun 2024 11:18:40 +0200 Subject: [PATCH 082/207] Check INFURA_API_KEY value at startup (#1642) Check INFURA_API_KEY value at startup --- .env.sample | 4 ++++ docker-compose.yml | 1 + src/config/configuration.module.ts | 1 + src/config/configuration.validator.spec.ts | 3 +++ src/datasources/blockchain/blockchain-api.manager.ts | 1 + 5 files changed, 10 insertions(+) diff --git a/.env.sample b/.env.sample index 2a9bf9ab93..b5415c5a8d 100644 --- a/.env.sample +++ b/.env.sample @@ -21,6 +21,10 @@ # (default is 100) # NATIVE_COINS_PRICES_TTL_SECONDS= +# RPC Provider +# The RPC provider to be used. +# INFURA_API_KEY= + # Balances Provider - Zerion API # Chain ids configured to use this provider. (comma-separated numbers) # (default='') diff --git a/docker-compose.yml b/docker-compose.yml index 584c321b92..37106374f8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -63,6 +63,7 @@ services: EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: ${EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX-example_template_unknown_recovery_tx} EMAIL_TEMPLATE_RECOVERY_TX: ${EMAIL_TEMPLATE_RECOVERY_TX-example_template_recovery_tx} EMAIL_TEMPLATE_VERIFICATION_CODE: ${EMAIL_TEMPLATE_VERIFICATION_CODE-example_template_verification_code} + INFURA_API_KEY: ${INFURA_API_KEY-example_api_key} JWT_ISSUER: ${JWT_ISSUER-example_issuer} JWT_TOKEN: ${JWT_TOKEN-example_token} RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN: ${RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN-example_api_key} diff --git a/src/config/configuration.module.ts b/src/config/configuration.module.ts index 7782ebf096..f6a7079549 100644 --- a/src/config/configuration.module.ts +++ b/src/config/configuration.module.ts @@ -46,6 +46,7 @@ export const RootConfigurationSchema = z.object({ EMAIL_TEMPLATE_RECOVERY_TX: z.string(), EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: z.string(), EMAIL_TEMPLATE_VERIFICATION_CODE: z.string(), + INFURA_API_KEY: z.string(), RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN: z.string(), RELAY_PROVIDER_API_KEY_SEPOLIA: z.string(), }); diff --git a/src/config/configuration.validator.spec.ts b/src/config/configuration.validator.spec.ts index e907fac951..da840434e8 100644 --- a/src/config/configuration.validator.spec.ts +++ b/src/config/configuration.validator.spec.ts @@ -18,6 +18,7 @@ describe('Configuration validator', () => { EMAIL_TEMPLATE_RECOVERY_TX: faker.string.alphanumeric(), EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: faker.string.alphanumeric(), EMAIL_TEMPLATE_VERIFICATION_CODE: faker.string.alphanumeric(), + INFURA_API_KEY: faker.string.uuid(), RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN: faker.string.uuid(), RELAY_PROVIDER_API_KEY_SEPOLIA: faker.string.uuid(), }; @@ -46,6 +47,7 @@ describe('Configuration validator', () => { { key: 'EMAIL_TEMPLATE_RECOVERY_TX' }, { key: 'EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX' }, { key: 'EMAIL_TEMPLATE_VERIFICATION_CODE' }, + { key: 'INFURA_API_KEY' }, { key: 'RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN' }, { key: 'RELAY_PROVIDER_API_KEY_SEPOLIA' }, ])( @@ -77,6 +79,7 @@ describe('Configuration validator', () => { EMAIL_TEMPLATE_RECOVERY_TX: faker.string.alphanumeric(), EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: faker.string.alphanumeric(), EMAIL_TEMPLATE_VERIFICATION_CODE: faker.string.alphanumeric(), + INFURA_API_KEY: faker.string.uuid(), RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN: faker.string.uuid(), RELAY_PROVIDER_API_KEY_SEPOLIA: faker.string.uuid(), }; diff --git a/src/datasources/blockchain/blockchain-api.manager.ts b/src/datasources/blockchain/blockchain-api.manager.ts index 797b9509a8..713be238e2 100644 --- a/src/datasources/blockchain/blockchain-api.manager.ts +++ b/src/datasources/blockchain/blockchain-api.manager.ts @@ -59,6 +59,7 @@ export class BlockchainApiManager implements IBlockchainApiManager { }; } + // Note: this assumes Infura as provider when using an API key as authentication method. private formatRpcUri(rpcUri: DomainChain['rpcUri']): string { return rpcUri.authentication === RpcUriAuthentication.ApiKeyPath ? rpcUri.value + this.infuraApiKey From 6d12cfb28623c75b11ac2d276accbdf8db1414eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Wed, 12 Jun 2024 16:52:29 +0200 Subject: [PATCH 083/207] Allow non-eth addresses on ZerionBalanceSchema implementantion addresses (#1646) Allow non-eth addresses in ZerionBalanceSchema --- .../entities/zerion-balance.entity.spec.ts | 11 ++++++++++- .../balances-api/entities/zerion-balance.entity.ts | 11 +++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/datasources/balances-api/entities/zerion-balance.entity.spec.ts b/src/datasources/balances-api/entities/zerion-balance.entity.spec.ts index 5195bc7151..a230c5bd1d 100644 --- a/src/datasources/balances-api/entities/zerion-balance.entity.spec.ts +++ b/src/datasources/balances-api/entities/zerion-balance.entity.spec.ts @@ -515,6 +515,16 @@ describe('Zerion Balance Entity schemas', () => { ); }); + // Note: this is needed as the implementation address field can contain non-eth addresses. + it('should allow any string as address value', () => { + const zerionImplementation = zerionImplementationBuilder().build(); + zerionImplementation.address = faker.string.sample(); + + const result = ZerionImplementationSchema.safeParse(zerionImplementation); + + expect(result.success).toBe(true); + }); + it('should not allow an invalid address value', () => { const zerionImplementation = zerionImplementationBuilder().build(); // @ts-expect-error - address is expected to be a string @@ -540,7 +550,6 @@ describe('Zerion Balance Entity schemas', () => { const nonChecksummedAddress = faker.finance .ethereumAddress() .toLowerCase(); - // @ts-expect-error - address is expected to be a checksummed address zerionImplementation.address = nonChecksummedAddress; const result = ZerionImplementationSchema.safeParse(zerionImplementation); diff --git a/src/datasources/balances-api/entities/zerion-balance.entity.ts b/src/datasources/balances-api/entities/zerion-balance.entity.ts index 1195078dbe..ae15be07d5 100644 --- a/src/datasources/balances-api/entities/zerion-balance.entity.ts +++ b/src/datasources/balances-api/entities/zerion-balance.entity.ts @@ -3,7 +3,7 @@ * Reference documentation: https://developers.zerion.io/reference/listwalletpositions */ -import { AddressSchema } from '@/validation/entities/schemas/address.schema'; +import { getAddress, isAddress } from 'viem'; import { z } from 'zod'; export type ZerionFungibleInfo = z.infer; @@ -22,7 +22,14 @@ export type ZerionBalances = z.infer; export const ZerionImplementationSchema = z.object({ chain_id: z.string(), - address: AddressSchema.nullish().default(null), + // Note: AddressSchema can't be used here because this field can contain non-eth addresses. + address: z + .string() + .nullish() + .default(null) + .transform((value) => + value !== null && isAddress(value) ? getAddress(value) : value, + ), decimals: z.number(), }); From 1f65768cafb1b24025a1cf8cace0bc6f737ccfb6 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 13 Jun 2024 09:57:27 +0200 Subject: [PATCH 084/207] Add configuration for relaying on Arbitrum (#1647) Add configuration for relaying on Arbitrum --- .env.sample | 1 + docker-compose.yml | 1 + src/config/configuration.module.ts | 1 + src/config/configuration.validator.spec.ts | 3 +++ src/config/entities/__tests__/configuration.ts | 1 + src/config/entities/configuration.ts | 1 + 6 files changed, 8 insertions(+) diff --git a/.env.sample b/.env.sample index b5415c5a8d..c0290e3093 100644 --- a/.env.sample +++ b/.env.sample @@ -42,6 +42,7 @@ # (default=5) # RELAY_THROTTLE_LIMIT= # The API key to be used per chain. +# RELAY_PROVIDER_API_KEY_ARBITRUM_ONE= # RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN= # RELAY_PROVIDER_API_KEY_SEPOLIA= diff --git a/docker-compose.yml b/docker-compose.yml index 37106374f8..9b2e85e738 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -66,6 +66,7 @@ services: INFURA_API_KEY: ${INFURA_API_KEY-example_api_key} JWT_ISSUER: ${JWT_ISSUER-example_issuer} JWT_TOKEN: ${JWT_TOKEN-example_token} + RELAY_PROVIDER_API_KEY_ARBITRUM_ONE: ${RELAY_PROVIDER_API_KEY_ARBITRUM_ONE-example_api_key} RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN: ${RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN-example_api_key} RELAY_PROVIDER_API_KEY_SEPOLIA: ${RELAY_PROVIDER_API_KEY_SEPOLIA-example_api_key} depends_on: diff --git a/src/config/configuration.module.ts b/src/config/configuration.module.ts index f6a7079549..6fe4c29914 100644 --- a/src/config/configuration.module.ts +++ b/src/config/configuration.module.ts @@ -47,6 +47,7 @@ export const RootConfigurationSchema = z.object({ EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: z.string(), EMAIL_TEMPLATE_VERIFICATION_CODE: z.string(), INFURA_API_KEY: z.string(), + RELAY_PROVIDER_API_KEY_ARBITRUM_ONE: z.string(), RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN: z.string(), RELAY_PROVIDER_API_KEY_SEPOLIA: z.string(), }); diff --git a/src/config/configuration.validator.spec.ts b/src/config/configuration.validator.spec.ts index da840434e8..3cdeb59a19 100644 --- a/src/config/configuration.validator.spec.ts +++ b/src/config/configuration.validator.spec.ts @@ -19,6 +19,7 @@ describe('Configuration validator', () => { EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: faker.string.alphanumeric(), EMAIL_TEMPLATE_VERIFICATION_CODE: faker.string.alphanumeric(), INFURA_API_KEY: faker.string.uuid(), + RELAY_PROVIDER_API_KEY_ARBITRUM_ONE: faker.string.uuid(), RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN: faker.string.uuid(), RELAY_PROVIDER_API_KEY_SEPOLIA: faker.string.uuid(), }; @@ -48,6 +49,7 @@ describe('Configuration validator', () => { { key: 'EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX' }, { key: 'EMAIL_TEMPLATE_VERIFICATION_CODE' }, { key: 'INFURA_API_KEY' }, + { key: 'RELAY_PROVIDER_API_KEY_ARBITRUM_ONE' }, { key: 'RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN' }, { key: 'RELAY_PROVIDER_API_KEY_SEPOLIA' }, ])( @@ -80,6 +82,7 @@ describe('Configuration validator', () => { EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: faker.string.alphanumeric(), EMAIL_TEMPLATE_VERIFICATION_CODE: faker.string.alphanumeric(), INFURA_API_KEY: faker.string.uuid(), + RELAY_PROVIDER_API_KEY_ARBITRUM_ONE: faker.string.uuid(), RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN: faker.string.uuid(), RELAY_PROVIDER_API_KEY_SEPOLIA: faker.string.uuid(), }; diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index 1655812085..582a65f8a6 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -158,6 +158,7 @@ export default (): ReturnType => ({ ttlSeconds: faker.number.int(), apiKey: { 100: faker.string.hexadecimal({ length: 32 }), + 42161: faker.string.hexadecimal({ length: 32 }), 11155111: faker.string.hexadecimal({ length: 32 }), }, }, diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 6059bd8a58..b1b4ccfe13 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -239,6 +239,7 @@ export default () => ({ ), apiKey: { 100: process.env.RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN, + 42161: process.env.RELAY_PROVIDER_API_KEY_ARBITRUM_ONE, 11155111: process.env.RELAY_PROVIDER_API_KEY_SEPOLIA, }, }, From 116c6c98e460e7b50295d53787c005895299c053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Thu, 13 Jun 2024 12:31:07 +0200 Subject: [PATCH 085/207] Add Polygon ZKEVM and Scroll to Zerion API configuration (#1645) Add Polygon ZKEVM and Scroll to Zerion API configuration --- src/config/entities/__tests__/configuration.ts | 2 ++ src/config/entities/configuration.ts | 12 +++++------- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index 582a65f8a6..a170945d52 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -40,12 +40,14 @@ export default (): ReturnType => ({ 1: { chainName: faker.string.sample() }, 10: { chainName: faker.string.sample() }, 100: { chainName: faker.string.sample() }, + 1101: { chainName: faker.string.sample() }, 1313161554: { chainName: faker.string.sample() }, 137: { chainName: faker.string.sample() }, 324: { chainName: faker.string.sample() }, 42161: { chainName: faker.string.sample() }, 42220: { chainName: faker.string.sample() }, 43114: { chainName: faker.string.sample() }, + 534352: { chainName: faker.string.sample() }, 56: { chainName: faker.string.sample() }, 8453: { chainName: faker.string.sample() }, }, diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index b1b4ccfe13..72e000de17 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -64,19 +64,17 @@ export default () => ({ chains: { 1: { chainName: 'ethereum' }, 10: { chainName: 'optimism' }, - 56: { chainName: 'binance-smart-chain' }, 100: { chainName: 'xdai' }, + 1101: { chainName: 'polygon-zkevm' }, + 1313161554: { chainName: 'aurora' }, 137: { chainName: 'polygon' }, 324: { chainName: 'zksync-era' }, - // 1101 (Polygon zkEVM) is not available on Zerion - // 1101: { chainName: '' }, - 8453: { chainName: 'base' }, 42161: { chainName: 'arbitrum' }, 42220: { chainName: 'celo' }, 43114: { chainName: 'avalanche' }, - // 11155111 (Sepolia) is not available on Zerion - // 11155111: { chainName: '' }, - 1313161554: { chainName: 'aurora' }, + 534352: { chainName: 'scroll' }, + 56: { chainName: 'binance-smart-chain' }, + 8453: { chainName: 'base' }, }, currencies: [ 'usd', From 863da5272f9dbed1edc39332982c06aa5728b6f5 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Fri, 14 Jun 2024 09:31:11 +0200 Subject: [PATCH 086/207] Add endpoints to get campaign activity (#1636) Integrates fetching of Campaign activities into the Gateway under `GET /v1/community/campaigns/:resourceId/activities`, accepting a `holder` query parameter for specific addresses: - Add new Locking Service endpoint to `ILockingApi` - Add `CampaignPointsSchema`/`CampaignPointsPageSchema` with relevant entities and builders - Add `ICommunityRepository['getCampaignPointsForAddress']`, validating against `CampaignPointsPageSchema` - Add `GET /v1/community/campaigns/:resourceId/activities` to `CommunityController`/`CommunityService` - Add/update tests accordingly --- .../locking-api/locking-api.service.spec.ts | 132 +++++++++++ .../locking-api/locking-api.service.ts | 24 ++ .../community.repository.interface.ts | 8 + src/domain/community/community.repository.ts | 14 ++ .../__tests__/campaign-activity.builder.ts | 22 ++ .../entities/campaign-activity.entity.ts | 18 ++ .../campaign-activity.schema.spec.ts | 117 ++++++++++ .../interfaces/locking-api.interface.ts | 7 + .../community/community.controller.spec.ts | 211 ++++++++++++++++++ src/routes/community/community.controller.ts | 19 +- src/routes/community/community.service.ts | 28 +++ .../entities/campaign-activity.entity.ts | 17 ++ .../entities/campaign-activity.page.entity.ts | 8 + 13 files changed, 624 insertions(+), 1 deletion(-) create mode 100644 src/domain/community/entities/__tests__/campaign-activity.builder.ts create mode 100644 src/domain/community/entities/campaign-activity.entity.ts create mode 100644 src/domain/community/entities/schemas/__tests__/campaign-activity.schema.spec.ts create mode 100644 src/routes/community/entities/campaign-activity.entity.ts create mode 100644 src/routes/community/entities/campaign-activity.page.entity.ts diff --git a/src/datasources/locking-api/locking-api.service.spec.ts b/src/datasources/locking-api/locking-api.service.spec.ts index 6172d93752..5e9dad16bd 100644 --- a/src/datasources/locking-api/locking-api.service.spec.ts +++ b/src/datasources/locking-api/locking-api.service.spec.ts @@ -16,6 +16,7 @@ import { lockingRankBuilder } from '@/domain/community/entities/__tests__/lockin import { campaignBuilder } from '@/domain/community/entities/__tests__/campaign.builder'; import { campaignRankBuilder } from '@/domain/community/entities/__tests__/campaign-rank.builder'; import { CampaignRank } from '@/domain/community/entities/campaign-rank.entity'; +import { campaignActivityBuilder } from '@/domain/community/entities/__tests__/campaign-activity.builder'; const networkService = { get: jest.fn(), @@ -156,6 +157,137 @@ describe('LockingApi', () => { }); }); + describe('getCampaignActivities', () => { + it('should get campaigns activities', async () => { + const campaign = campaignBuilder().build(); + const holder = getAddress(faker.finance.ethereumAddress()); + const campaignActivityPage = pageBuilder() + .with('results', [ + campaignActivityBuilder().with('holder', holder).build(), + campaignActivityBuilder().with('holder', holder).build(), + ]) + .build(); + + mockNetworkService.get.mockResolvedValueOnce({ + data: campaignActivityPage, + status: 200, + }); + + const result = await service.getCampaignActivities({ + resourceId: campaign.resourceId, + holder, + }); + + expect(result).toEqual(campaignActivityPage); + expect(mockNetworkService.get).toHaveBeenCalledWith({ + url: `${lockingBaseUri}/api/v1/campaigns/${campaign.resourceId}/activities`, + networkRequest: { + params: { + holder: holder, + limit: undefined, + offset: undefined, + }, + }, + }); + }); + + it('should get campaigns activities for address', async () => { + const campaign = campaignBuilder().build(); + const holder = getAddress(faker.finance.ethereumAddress()); + const campaignActivityPage = pageBuilder() + .with('results', [ + campaignActivityBuilder().with('holder', holder).build(), + campaignActivityBuilder().with('holder', holder).build(), + ]) + .build(); + + mockNetworkService.get.mockResolvedValueOnce({ + data: campaignActivityPage, + status: 200, + }); + + const result = await service.getCampaignActivities({ + resourceId: campaign.resourceId, + holder, + }); + + expect(result).toEqual(campaignActivityPage); + expect(mockNetworkService.get).toHaveBeenCalledWith({ + url: `${lockingBaseUri}/api/v1/campaigns/${campaign.resourceId}/activities`, + networkRequest: { + params: { + holder, + limit: undefined, + offset: undefined, + }, + }, + }); + }); + + it('should forward pagination queries', async () => { + const limit = faker.number.int(); + const offset = faker.number.int(); + const campaign = campaignBuilder().build(); + const holder = getAddress(faker.finance.ethereumAddress()); + const campaignActivityPage = pageBuilder() + .with('results', [ + campaignActivityBuilder().with('holder', holder).build(), + campaignActivityBuilder().with('holder', holder).build(), + ]) + .build(); + + mockNetworkService.get.mockResolvedValueOnce({ + data: campaignActivityPage, + status: 200, + }); + + await service.getCampaignActivities({ + resourceId: campaign.resourceId, + holder, + limit, + offset, + }); + + expect(mockNetworkService.get).toHaveBeenCalledWith({ + url: `${lockingBaseUri}/api/v1/campaigns/${campaign.resourceId}/activities`, + networkRequest: { + params: { + holder, + limit, + offset, + }, + }, + }); + }); + + it('should forward error', async () => { + const campaign = campaignBuilder().build(); + const holder = getAddress(faker.finance.ethereumAddress()); + const status = faker.internet.httpStatusCode({ types: ['serverError'] }); + const error = new NetworkResponseError( + new URL( + `${lockingBaseUri}/api/v1/campaigns/${campaign.resourceId}/activities/${holder}`, + ), + { + status, + } as Response, + { + message: 'Unexpected error', + }, + ); + mockNetworkService.get.mockRejectedValueOnce(error); + + await expect( + service.getCampaignActivities({ + resourceId: campaign.resourceId, + holder, + }), + ).rejects.toThrow(new DataSourceError('Unexpected error', status)); + + expect(mockNetworkService.get).toHaveBeenCalledTimes(1); + }); + }); + describe('getCampaignRank', () => { it('should get campaign rank', async () => { const resourceId = faker.string.uuid(); diff --git a/src/datasources/locking-api/locking-api.service.ts b/src/datasources/locking-api/locking-api.service.ts index 415a60600e..d3fccd31a5 100644 --- a/src/datasources/locking-api/locking-api.service.ts +++ b/src/datasources/locking-api/locking-api.service.ts @@ -57,6 +57,30 @@ export class LockingApi implements ILockingApi { } } + async getCampaignActivities(args: { + resourceId: string; + holder?: `0x${string}`; + limit?: number; + offset?: number; + }): Promise { + try { + const url = `${this.baseUri}/api/v1/campaigns/${args.resourceId}/activities`; + const { data } = await this.networkService.get({ + url, + networkRequest: { + params: { + holder: args.holder, + limit: args.limit, + offset: args.offset, + }, + }, + }); + return data; + } catch (error) { + throw this.httpErrorFactory.from(error); + } + } + async getCampaignRank(args: { resourceId: string; safeAddress: `0x${string}`; diff --git a/src/domain/community/community.repository.interface.ts b/src/domain/community/community.repository.interface.ts index 31b80489b2..a08e15295e 100644 --- a/src/domain/community/community.repository.interface.ts +++ b/src/domain/community/community.repository.interface.ts @@ -3,6 +3,7 @@ import { Campaign } from '@/domain/community/entities/campaign.entity'; import { CampaignRank } from '@/domain/community/entities/campaign-rank.entity'; import { LockingEvent } from '@/domain/community/entities/locking-event.entity'; import { LockingRank } from '@/domain/community/entities/locking-rank.entity'; +import { CampaignActivity } from '@/domain/community/entities/campaign-activity.entity'; export const ICommunityRepository = Symbol('ICommunityRepository'); @@ -14,6 +15,13 @@ export interface ICommunityRepository { offset?: number; }): Promise>; + getCampaignActivities(args: { + resourceId: string; + holder?: `0x${string}`; + limit?: number; + offset?: number; + }): Promise>; + getLockingRank(safeAddress: `0x${string}`): Promise; getLeaderboard(args: { diff --git a/src/domain/community/community.repository.ts b/src/domain/community/community.repository.ts index a12b61ca07..18fd44a2d2 100644 --- a/src/domain/community/community.repository.ts +++ b/src/domain/community/community.repository.ts @@ -19,6 +19,10 @@ import { } from '@/domain/community/entities/schemas/locking-rank.schema'; import { ICommunityRepository } from '@/domain/community/community.repository.interface'; import { Inject, Injectable } from '@nestjs/common'; +import { + CampaignActivity, + CampaignActivityPageSchema, +} from '@/domain/community/entities/campaign-activity.entity'; @Injectable() export class CommunityRepository implements ICommunityRepository { @@ -40,6 +44,16 @@ export class CommunityRepository implements ICommunityRepository { return CampaignPageSchema.parse(page); } + async getCampaignActivities(args: { + resourceId: string; + holder?: `0x${string}`; + limit?: number; + offset?: number; + }): Promise> { + const page = await this.lockingApi.getCampaignActivities(args); + return CampaignActivityPageSchema.parse(page); + } + async getLockingRank(safeAddress: `0x${string}`): Promise { const lockingRank = await this.lockingApi.getLockingRank(safeAddress); return LockingRankSchema.parse(lockingRank); diff --git a/src/domain/community/entities/__tests__/campaign-activity.builder.ts b/src/domain/community/entities/__tests__/campaign-activity.builder.ts new file mode 100644 index 0000000000..71a5827ee9 --- /dev/null +++ b/src/domain/community/entities/__tests__/campaign-activity.builder.ts @@ -0,0 +1,22 @@ +import { Builder, IBuilder } from '@/__tests__/builder'; +import { CampaignActivity } from '@/domain/community/entities/campaign-activity.entity'; +import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; + +export function campaignActivityBuilder(): IBuilder { + return new Builder() + .with('holder', getAddress(faker.finance.ethereumAddress())) + .with('startDate', faker.date.recent()) + .with('endDate', faker.date.future()) + .with('boost', faker.number.float()) + .with('totalPoints', faker.number.float()) + .with('totalBoostedPoints', faker.number.float()); +} + +export function toJson(campaignActivity: CampaignActivity): unknown { + return { + ...campaignActivity, + startDate: campaignActivity.startDate.toISOString(), + endDate: campaignActivity.endDate.toISOString(), + }; +} diff --git a/src/domain/community/entities/campaign-activity.entity.ts b/src/domain/community/entities/campaign-activity.entity.ts new file mode 100644 index 0000000000..0a67360b7b --- /dev/null +++ b/src/domain/community/entities/campaign-activity.entity.ts @@ -0,0 +1,18 @@ +import { buildPageSchema } from '@/domain/entities/schemas/page.schema.factory'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; +import { z } from 'zod'; + +export const CampaignActivitySchema = z.object({ + startDate: z.coerce.date(), + endDate: z.coerce.date(), + holder: AddressSchema, + boost: z.number(), + totalPoints: z.number(), + totalBoostedPoints: z.number(), +}); + +export const CampaignActivityPageSchema = buildPageSchema( + CampaignActivitySchema, +); + +export type CampaignActivity = z.infer; diff --git a/src/domain/community/entities/schemas/__tests__/campaign-activity.schema.spec.ts b/src/domain/community/entities/schemas/__tests__/campaign-activity.schema.spec.ts new file mode 100644 index 0000000000..a4a4030f65 --- /dev/null +++ b/src/domain/community/entities/schemas/__tests__/campaign-activity.schema.spec.ts @@ -0,0 +1,117 @@ +import { campaignActivityBuilder } from '@/domain/community/entities/__tests__/campaign-activity.builder'; +import { CampaignActivitySchema } from '@/domain/community/entities/campaign-activity.entity'; +import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; +import { ZodError } from 'zod'; + +describe('CampaignActivitySchema', () => { + it('should validate a valid CampaignActivity', () => { + const campaignActivity = campaignActivityBuilder().build(); + + const result = CampaignActivitySchema.safeParse(campaignActivity); + + expect(result.success).toBe(true); + }); + + it('should checksum the holder', () => { + const campaignActivity = campaignActivityBuilder().build(); + campaignActivity.holder = + campaignActivity.holder.toLowerCase() as `0x${string}`; + + const result = CampaignActivitySchema.safeParse(campaignActivity); + + expect(result.success && result.data.holder).toBe( + getAddress(campaignActivity.holder), + ); + }); + + it.each(['startDate' as const, 'endDate' as const])( + `should coerce %s to a Date`, + (field) => { + const campaignActivity = campaignActivityBuilder() + .with(field, faker.date.recent().toISOString() as unknown as Date) + .build(); + + const result = CampaignActivitySchema.safeParse(campaignActivity); + + expect(result.success && result.data[field]).toBeInstanceOf(Date); + }, + ); + + it.each([ + 'boost' as const, + 'totalPoints' as const, + 'totalBoostedPoints' as const, + ])(`should validate a decimal %s`, (field) => { + const campaignActivity = campaignActivityBuilder() + .with(field, faker.number.float()) + .build(); + + const result = CampaignActivitySchema.safeParse(campaignActivity); + + expect(result.success).toBe(true); + }); + + it.each([ + 'boost' as const, + 'totalPoints' as const, + 'totalBoostedPoints' as const, + ])(`should validate a float %s`, (field) => { + const campaignActivity = campaignActivityBuilder() + .with(field, faker.number.float()) + .build(); + + const result = CampaignActivitySchema.safeParse(campaignActivity); + + expect(result.success).toBe(true); + }); + + it('should not validate an invalid CampaignActivity', () => { + const campaignActivity = { invalid: 'campaignActivity' }; + + const result = CampaignActivitySchema.safeParse(campaignActivity); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_date', + path: ['startDate'], + message: 'Invalid date', + }, + { + code: 'invalid_date', + path: ['endDate'], + message: 'Invalid date', + }, + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['holder'], + message: 'Required', + }, + { + code: 'invalid_type', + expected: 'number', + received: 'undefined', + path: ['boost'], + message: 'Required', + }, + { + code: 'invalid_type', + expected: 'number', + received: 'undefined', + path: ['totalPoints'], + message: 'Required', + }, + { + code: 'invalid_type', + expected: 'number', + received: 'undefined', + path: ['totalBoostedPoints'], + message: 'Required', + }, + ]), + ); + }); +}); diff --git a/src/domain/interfaces/locking-api.interface.ts b/src/domain/interfaces/locking-api.interface.ts index 9c6cfd89d6..45b0660fe0 100644 --- a/src/domain/interfaces/locking-api.interface.ts +++ b/src/domain/interfaces/locking-api.interface.ts @@ -14,6 +14,13 @@ export interface ILockingApi { offset?: number; }): Promise>; + getCampaignActivities(args: { + resourceId: string; + holder?: `0x${string}`; + limit?: number; + offset?: number; + }): Promise; + getLockingRank(safeAddress: `0x${string}`): Promise; getLeaderboard(args: { diff --git a/src/routes/community/community.controller.spec.ts b/src/routes/community/community.controller.spec.ts index 2d7bd50b0a..64c49694b2 100644 --- a/src/routes/community/community.controller.spec.ts +++ b/src/routes/community/community.controller.spec.ts @@ -40,6 +40,10 @@ import { Campaign } from '@/domain/community/entities/campaign.entity'; import { CampaignRank } from '@/domain/community/entities/campaign-rank.entity'; import { campaignRankBuilder } from '@/domain/community/entities/__tests__/campaign-rank.builder'; import { Server } from 'net'; +import { + campaignActivityBuilder, + toJson as campaignActivityToJson, +} from '@/domain/community/entities/__tests__/campaign-activity.builder'; describe('Community (Unit)', () => { let app: INestApplication; @@ -279,6 +283,213 @@ describe('Community (Unit)', () => { }); }); + describe('GET /campaigns/:resourceId/activity', () => { + it('should get the campaign activity by campaign ID', async () => { + const campaign = campaignBuilder().build(); + const campaignActivity = campaignActivityBuilder().build(); + const campaignActivityPage = pageBuilder() + .with('results', [campaignActivity]) + .with('count', 1) + .with('previous', null) + .with('next', null) + .build(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns/${campaign.resourceId}/activities`: + return Promise.resolve({ data: campaignActivityPage, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get(`/v1/community/campaigns/${campaign.resourceId}/activities`) + .expect(200) + .expect({ + count: 1, + next: null, + previous: null, + results: [campaignActivityToJson(campaignActivity)], + }); + }); + + it('should get the campaign activity by campaign ID and holder', async () => { + const campaign = campaignBuilder().build(); + const holder = getAddress(faker.finance.ethereumAddress()); + const campaignActivity = campaignActivityBuilder() + .with('holder', holder) + .build(); + const campaignActivityPage = pageBuilder() + .with('results', [campaignActivity]) + .with('count', 1) + .with('previous', null) + .with('next', null) + .build(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns/${campaign.resourceId}/activities`: + return Promise.resolve({ data: campaignActivityPage, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get( + `/v1/community/campaigns/${campaign.resourceId}/activities?holder=${holder}`, + ) + .expect(200) + .expect({ + count: 1, + next: null, + previous: null, + results: [campaignActivityToJson(campaignActivity)], + }); + + expect(networkService.get).toHaveBeenCalledWith({ + url: `${lockingBaseUri}/api/v1/campaigns/${campaign.resourceId}/activities`, + networkRequest: { + params: { + limit: 20, + offset: 0, + holder, + }, + }, + }); + }); + + it('should forward the pagination parameters', async () => { + const limit = faker.number.int({ min: 1, max: 10 }); + const offset = faker.number.int({ min: 1, max: 10 }); + const campaign = campaignBuilder().build(); + const holder = getAddress(faker.finance.ethereumAddress()); + const campaignActivity = campaignActivityBuilder() + .with('holder', holder) + .build(); + const campaignActivityPage = pageBuilder() + .with('results', [campaignActivity]) + .with('count', 1) + .with('previous', null) + .with('next', null) + .build(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns/${campaign.resourceId}/activities`: + return Promise.resolve({ data: campaignActivityPage, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get( + `/v1/community/campaigns/${campaign.resourceId}/activities?cursor=limit%3D${limit}%26offset%3D${offset}&holder=${holder}`, + ) + .expect(200) + .expect({ + count: 1, + next: null, + previous: null, + results: [campaignActivityToJson(campaignActivity)], + }); + + expect(networkService.get).toHaveBeenCalledWith({ + url: `${lockingBaseUri}/api/v1/campaigns/${campaign.resourceId}/activities`, + networkRequest: { + params: { + limit, + offset, + holder, + }, + }, + }); + }); + + it('should validate the holder query', async () => { + const campaign = campaignBuilder().build(); + const holder = faker.string.alphanumeric(); + + await request(app.getHttpServer()) + .get( + `/v1/community/campaigns/${campaign.resourceId}/activities?holder=${holder}`, + ) + .expect(422) + .expect({ + statusCode: 422, + code: 'custom', + message: 'Invalid address', + path: [], + }); + }); + + it('should validate the response', async () => { + const campaign = campaignBuilder().build(); + const holder = getAddress(faker.finance.ethereumAddress()); + const invalidCampaignActivity = [{ invalid: 'campaignActivity' }]; + const campaignActivityPage = pageBuilder() + .with('results', invalidCampaignActivity) + .with('count', invalidCampaignActivity.length) + .with('previous', null) + .with('next', null) + .build(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns/${campaign.resourceId}/activities`: + return Promise.resolve({ data: campaignActivityPage, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .get( + `/v1/community/campaigns/${campaign.resourceId}/activities?holder=${holder}`, + ) + .expect(500) + .expect({ + statusCode: 500, + message: 'Internal server error', + }); + }); + + it('should forward an error from the service', () => { + const campaign = campaignBuilder().build(); + const holder = getAddress(faker.finance.ethereumAddress()); + const statusCode = faker.internet.httpStatusCode({ + types: ['clientError', 'serverError'], + }); + const errorMessage = faker.word.words(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${lockingBaseUri}/api/v1/campaigns/${campaign.resourceId}/activities`: + return Promise.reject( + new NetworkResponseError( + new URL( + `${lockingBaseUri}/api/v1/campaigns/${campaign.resourceId}/activities`, + ), + { + status: statusCode, + } as Response, + { message: errorMessage, status: statusCode }, + ), + ); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + return request(app.getHttpServer()) + .get( + `/v1/community/campaigns/${campaign.resourceId}/activities?holder${holder}`, + ) + .expect(statusCode) + .expect({ + message: errorMessage, + code: statusCode, + }); + }); + }); + describe('GET /community/campaigns/:resourceId/leaderboard', () => { it('should get the leaderboard by campaign ID', async () => { const campaign = campaignBuilder().build(); diff --git a/src/routes/community/community.controller.ts b/src/routes/community/community.controller.ts index 7047c5fa2c..812eeb91cf 100644 --- a/src/routes/community/community.controller.ts +++ b/src/routes/community/community.controller.ts @@ -2,6 +2,7 @@ import { PaginationDataDecorator } from '@/routes/common/decorators/pagination.d import { RouteUrlDecorator } from '@/routes/common/decorators/route.url.decorator'; import { PaginationData } from '@/routes/common/pagination/pagination.data'; import { CommunityService } from '@/routes/community/community.service'; +import { CampaignActivityPage } from '@/routes/community/entities/campaign-activity.page.entity'; import { CampaignRank } from '@/routes/community/entities/campaign-rank.entity'; import { CampaignRankPage } from '@/routes/community/entities/campaign-rank.page.entity'; import { Campaign } from '@/routes/community/entities/campaign.entity'; @@ -11,7 +12,7 @@ import { LockingRank } from '@/routes/community/entities/locking-rank.entity'; import { LockingRankPage } from '@/routes/community/entities/locking-rank.page.entity'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; import { ValidationPipe } from '@/validation/pipes/validation.pipe'; -import { Controller, Get, Param } from '@nestjs/common'; +import { Controller, Get, Param, Query } from '@nestjs/common'; import { ApiTags, ApiOkResponse, ApiQuery } from '@nestjs/swagger'; @ApiTags('community') @@ -44,6 +45,22 @@ export class CommunityController { return this.communityService.getCampaignById(resourceId); } + @Get('/campaigns/:resourceId/activities') + async getCampaignActivities( + @Param('resourceId') resourceId: string, + @RouteUrlDecorator() routeUrl: URL, + @PaginationDataDecorator() paginationData: PaginationData, + @Query('holder', new ValidationPipe(AddressSchema.optional())) + holder?: `0x${string}`, + ): Promise { + return this.communityService.getCampaignActivities({ + resourceId, + holder, + routeUrl, + paginationData, + }); + } + @ApiOkResponse({ type: CampaignRankPage }) @ApiQuery({ name: 'cursor', diff --git a/src/routes/community/community.service.ts b/src/routes/community/community.service.ts index 1d56ad402f..54cb85b03c 100644 --- a/src/routes/community/community.service.ts +++ b/src/routes/community/community.service.ts @@ -9,6 +9,7 @@ import { cursorUrlFromLimitAndOffset, } from '@/routes/common/pagination/pagination.data'; import { Inject, Injectable } from '@nestjs/common'; +import { CampaignActivity } from '@/domain/community/entities/campaign-activity.entity'; @Injectable() export class CommunityService { @@ -43,6 +44,33 @@ export class CommunityService { return this.communityRepository.getCampaignById(resourceId); } + async getCampaignActivities(args: { + resourceId: string; + holder?: `0x${string}`; + routeUrl: URL; + paginationData: PaginationData; + }): Promise> { + const result = await this.communityRepository.getCampaignActivities({ + resourceId: args.resourceId, + holder: args.holder, + limit: args.paginationData.limit, + offset: args.paginationData.offset, + }); + + const nextUrl = cursorUrlFromLimitAndOffset(args.routeUrl, result.next); + const previousUrl = cursorUrlFromLimitAndOffset( + args.routeUrl, + result.previous, + ); + + return { + count: result.count, + next: nextUrl?.toString() ?? null, + previous: previousUrl?.toString() ?? null, + results: result.results, + }; + } + async getCampaignLeaderboard(args: { resourceId: string; routeUrl: URL; diff --git a/src/routes/community/entities/campaign-activity.entity.ts b/src/routes/community/entities/campaign-activity.entity.ts new file mode 100644 index 0000000000..9679702de4 --- /dev/null +++ b/src/routes/community/entities/campaign-activity.entity.ts @@ -0,0 +1,17 @@ +import { CampaignActivity as DomainCampaignActivity } from '@/domain/community/entities/campaign-activity.entity'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CampaignActivity implements DomainCampaignActivity { + @ApiProperty() + holder!: `0x${string}`; + @ApiProperty({ type: String }) + startDate!: Date; + @ApiProperty({ type: String }) + endDate!: Date; + @ApiProperty() + boost!: number; + @ApiProperty() + totalPoints!: number; + @ApiProperty() + totalBoostedPoints!: number; +} diff --git a/src/routes/community/entities/campaign-activity.page.entity.ts b/src/routes/community/entities/campaign-activity.page.entity.ts new file mode 100644 index 0000000000..f61b091d59 --- /dev/null +++ b/src/routes/community/entities/campaign-activity.page.entity.ts @@ -0,0 +1,8 @@ +import { Page } from '@/routes/common/entities/page.entity'; +import { CampaignActivity } from '@/routes/community/entities/campaign-activity.entity'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CampaignActivityPage extends Page { + @ApiProperty({ type: [CampaignActivity] }) + results!: Array; +} From bbab1cfe5036b484f4e6b69709d7c7c72dd7ee01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Fri, 14 Jun 2024 10:47:43 +0200 Subject: [PATCH 087/207] Extract Zerion configuration from Chain entity (#1648) Extract Zerion configuration from Chain entity --- .../balances-api/balances-api.manager.spec.ts | 1 + .../zerion-balances-api.service.spec.ts | 174 ++++++++++++++++++ .../zerion-balances-api.service.ts | 30 ++- .../__tests__/balances-provider.builder.ts | 9 + .../entities/__tests__/chain.builder.ts | 2 + .../entities/balances-provider.entity.ts | 4 + .../schemas/__tests__/chain.schema.spec.ts | 110 +++++++++++ .../chains/entities/schemas/chain.schema.ts | 8 + .../collectibles.repository.interface.ts | 3 +- .../collectibles/collectibles.repository.ts | 5 +- .../interfaces/balances-api.interface.ts | 4 +- .../zerion-balances.controller.spec.ts | 49 +++-- src/routes/chains/chains.controller.spec.ts | 3 + src/routes/chains/chains.service.ts | 2 + .../entities/balances-provider.entity.ts | 9 + src/routes/chains/entities/chain.entity.ts | 5 + .../zerion-collectibles.controller.spec.ts | 96 ++++++---- .../collectibles/collectibles.module.ts | 3 +- .../collectibles/collectibles.service.ts | 5 + 19 files changed, 459 insertions(+), 63 deletions(-) create mode 100644 src/datasources/balances-api/zerion-balances-api.service.spec.ts create mode 100644 src/domain/chains/entities/__tests__/balances-provider.builder.ts create mode 100644 src/domain/chains/entities/balances-provider.entity.ts create mode 100644 src/routes/chains/entities/balances-provider.entity.ts diff --git a/src/datasources/balances-api/balances-api.manager.spec.ts b/src/datasources/balances-api/balances-api.manager.spec.ts index f5d4152d01..176d3dd487 100644 --- a/src/datasources/balances-api/balances-api.manager.spec.ts +++ b/src/datasources/balances-api/balances-api.manager.spec.ts @@ -185,6 +185,7 @@ describe('Balances API Manager Tests', () => { await safeBalancesApi.getBalances({ safeAddress, fiatCode, + chain, trusted, excludeSpam, }); diff --git a/src/datasources/balances-api/zerion-balances-api.service.spec.ts b/src/datasources/balances-api/zerion-balances-api.service.spec.ts new file mode 100644 index 0000000000..6b567465b9 --- /dev/null +++ b/src/datasources/balances-api/zerion-balances-api.service.spec.ts @@ -0,0 +1,174 @@ +import { FakeConfigurationService } from '@/config/__tests__/fake.configuration.service'; +import { ZerionBalancesApi } from '@/datasources/balances-api/zerion-balances-api.service'; +import { ICacheService } from '@/datasources/cache/cache.service.interface'; +import { HttpErrorFactory } from '@/datasources/errors/http-error-factory'; +import { INetworkService } from '@/datasources/network/network.service.interface'; +import { balancesProviderBuilder } from '@/domain/chains/entities/__tests__/balances-provider.builder'; +import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; +import { ILoggingService } from '@/logging/logging.interface'; +import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; + +const mockCacheService = jest.mocked({ + increment: jest.fn(), + get: jest.fn(), + set: jest.fn(), +} as jest.MockedObjectDeep); + +const mockLoggingService = { + debug: jest.fn(), +} as jest.MockedObjectDeep; + +const mockNetworkService = jest.mocked({ + get: jest.fn(), +} as jest.MockedObjectDeep); + +const mockHttpErrorFactory = jest.mocked({ + from: jest.fn(), +} as jest.MockedObjectDeep); + +describe('ZerionBalancesApiService', () => { + let service: ZerionBalancesApi; + let fakeConfigurationService: FakeConfigurationService; + const zerionApiKey = faker.string.sample(); + const zerionBaseUri = faker.internet.url({ appendSlash: false }); + const defaultExpirationTimeInSeconds = faker.number.int(); + const notFoundExpirationTimeInSeconds = faker.number.int(); + const supportedFiatCodes = Array.from( + new Set([ + ...Array.from({ length: faker.number.int({ min: 2, max: 5 }) }, () => + faker.finance.currencyCode().toLowerCase(), + ), + ]), + ); + const fallbackChainId = faker.number.int(); + const fallbackChainName = faker.string.sample(); + const fallbackChainsConfiguration = { + [fallbackChainId]: { chainName: fallbackChainName }, + }; + + beforeEach(() => { + jest.resetAllMocks(); + fakeConfigurationService = new FakeConfigurationService(); + fakeConfigurationService.set( + 'balances.providers.zerion.apiKey', + zerionApiKey, + ); + fakeConfigurationService.set( + 'balances.providers.zerion.baseUri', + zerionBaseUri, + ); + fakeConfigurationService.set( + 'expirationTimeInSeconds.default', + defaultExpirationTimeInSeconds, + ); + fakeConfigurationService.set( + 'expirationTimeInSeconds.notFound.default', + notFoundExpirationTimeInSeconds, + ); + fakeConfigurationService.set( + 'balances.providers.zerion.chains', + fallbackChainsConfiguration, + ); + fakeConfigurationService.set( + 'balances.providers.zerion.currencies', + supportedFiatCodes, + ); + fakeConfigurationService.set( + 'balances.providers.zerion.limitPeriodSeconds', + faker.number.int(), + ); + fakeConfigurationService.set( + 'balances.providers.zerion.limitCalls', + faker.number.int(), + ); + + service = new ZerionBalancesApi( + mockCacheService, + mockLoggingService, + mockNetworkService, + fakeConfigurationService, + mockHttpErrorFactory, + ); + }); + + describe('getBalances', () => { + it('should fail for an invalid fiatCode', async () => { + const chain = chainBuilder().build(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const fiatCode = faker.string.alphanumeric({ + exclude: supportedFiatCodes, + }); + + await expect( + service.getBalances({ + chain, + safeAddress, + fiatCode, + }), + ).rejects.toThrow(`Unsupported currency code: ${fiatCode}`); + }); + + it('should get the chainName from the chain parameter', async () => { + const chain = chainBuilder().build(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const fiatCode = faker.helpers.arrayElement(supportedFiatCodes); + mockNetworkService.get.mockResolvedValue({ + data: { data: [] }, + status: 200, + }); + + await service.getBalances({ + chain, + safeAddress, + fiatCode, + }); + + expect(mockNetworkService.get).toHaveBeenCalledWith({ + url: `${zerionBaseUri}/v1/wallets/${safeAddress}/positions`, + networkRequest: { + headers: { Authorization: `Basic ${zerionApiKey}` }, + params: { + 'filter[chain_ids]': chain.balancesProvider.chainName, + currency: fiatCode.toLowerCase(), + sort: 'value', + }, + }, + }); + }); + + it('should fallback to the static configuration to get the chainName', async () => { + const chain = chainBuilder() + .with('chainId', fallbackChainId.toString()) + .with( + 'balancesProvider', + balancesProviderBuilder().with('chainName', null).build(), + ) + .build(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const fiatCode = faker.helpers.arrayElement(supportedFiatCodes); + mockNetworkService.get.mockResolvedValue({ + data: { data: [] }, + status: 200, + }); + + await service.getBalances({ + chain, + safeAddress, + fiatCode, + }); + + expect(mockNetworkService.get).toHaveBeenCalledWith({ + url: `${zerionBaseUri}/v1/wallets/${safeAddress}/positions`, + networkRequest: { + headers: { Authorization: `Basic ${zerionApiKey}` }, + params: { + 'filter[chain_ids]': fallbackChainName, + currency: fiatCode.toLowerCase(), + sort: 'value', + }, + }, + }); + }); + }); +}); diff --git a/src/datasources/balances-api/zerion-balances-api.service.ts b/src/datasources/balances-api/zerion-balances-api.service.ts index eae9846a8d..f3bdf816ce 100644 --- a/src/datasources/balances-api/zerion-balances-api.service.ts +++ b/src/datasources/balances-api/zerion-balances-api.service.ts @@ -28,6 +28,7 @@ import { Erc20Balance, NativeBalance, } from '@/domain/balances/entities/balance.entity'; +import { Chain } from '@/domain/chains/entities/chain.entity'; import { Collectible } from '@/domain/collectibles/entities/collectible.entity'; import { getNumberString } from '@/domain/common/utils/utils'; import { Page } from '@/domain/entities/page.entity'; @@ -92,7 +93,7 @@ export class ZerionBalancesApi implements IBalancesApi { } async getBalances(args: { - chainId: string; + chain: Chain; safeAddress: `0x${string}`; fiatCode: string; }): Promise { @@ -103,8 +104,12 @@ export class ZerionBalancesApi implements IBalancesApi { ); } - const cacheDir = CacheRouter.getZerionBalancesCacheDir(args); - const chainName = this._getChainName(args.chainId); + const cacheDir = CacheRouter.getZerionBalancesCacheDir({ + chainId: args.chain.chainId, + safeAddress: args.safeAddress, + fiatCode: args.fiatCode, + }); + const chainName = this._getChainName(args.chain); const cached = await this.cacheService.get(cacheDir); if (cached != null) { const { key, field } = cacheDir; @@ -157,12 +162,15 @@ export class ZerionBalancesApi implements IBalancesApi { * Since this setup does not align well with the CGW API, it is needed to encode/decode these parameters. */ async getCollectibles(args: { - chainId: string; + chain: Chain; safeAddress: `0x${string}`; limit?: number; offset?: number; }): Promise> { - const cacheDir = CacheRouter.getZerionCollectiblesCacheDir(args); + const cacheDir = CacheRouter.getZerionCollectiblesCacheDir({ + ...args, + chainId: args.chain.chainId, + }); const cached = await this.cacheService.get(cacheDir); if (cached != null) { const { key, field } = cacheDir; @@ -172,7 +180,7 @@ export class ZerionBalancesApi implements IBalancesApi { } else { try { await this._checkRateLimit(); - const chainName = this._getChainName(args.chainId); + const chainName = this._getChainName(args.chain); const url = `${this.baseUri}/v1/wallets/${args.safeAddress}/nft-positions`; const pageAfter = this._encodeZerionPageOffset(args.offset); const networkRequest = { @@ -276,12 +284,16 @@ export class ZerionBalancesApi implements IBalancesApi { await this.cacheService.deleteByKey(key); } - private _getChainName(chainId: string): string { - const chainName = this.chainsConfiguration[Number(chainId)]?.chainName; + private _getChainName(chain: Chain): string { + const chainName = + chain.balancesProvider.chainName ?? + this.chainsConfiguration[Number(chain.chainId)]?.chainName; + if (!chainName) throw Error( - `Chain ${chainId} balances retrieval via Zerion is not configured`, + `Chain ${chain.chainId} balances retrieval via Zerion is not configured`, ); + return chainName; } diff --git a/src/domain/chains/entities/__tests__/balances-provider.builder.ts b/src/domain/chains/entities/__tests__/balances-provider.builder.ts new file mode 100644 index 0000000000..90c3954753 --- /dev/null +++ b/src/domain/chains/entities/__tests__/balances-provider.builder.ts @@ -0,0 +1,9 @@ +import { Builder, IBuilder } from '@/__tests__/builder'; +import { BalancesProvider } from '@/domain/chains/entities/balances-provider.entity'; +import { faker } from '@faker-js/faker'; + +export function balancesProviderBuilder(): IBuilder { + return new Builder() + .with('chainName', faker.company.name()) + .with('enabled', faker.datatype.boolean()); +} diff --git a/src/domain/chains/entities/__tests__/chain.builder.ts b/src/domain/chains/entities/__tests__/chain.builder.ts index 26c2ab59a0..6bd6837ef1 100644 --- a/src/domain/chains/entities/__tests__/chain.builder.ts +++ b/src/domain/chains/entities/__tests__/chain.builder.ts @@ -9,6 +9,7 @@ import { rpcUriBuilder } from '@/domain/chains/entities/__tests__/rpc-uri.builde import { themeBuilder } from '@/domain/chains/entities/__tests__/theme.builder'; import { Chain } from '@/domain/chains/entities/chain.entity'; import { pricesProviderBuilder } from '@/domain/chains/entities/__tests__/prices-provider.builder'; +import { balancesProviderBuilder } from '@/domain/chains/entities/__tests__/balances-provider.builder'; export function chainBuilder(): IBuilder { return new Builder() @@ -25,6 +26,7 @@ export function chainBuilder(): IBuilder { .with('blockExplorerUriTemplate', blockExplorerUriTemplateBuilder().build()) .with('nativeCurrency', nativeCurrencyBuilder().build()) .with('pricesProvider', pricesProviderBuilder().build()) + .with('balancesProvider', balancesProviderBuilder().build()) .with('transactionService', faker.internet.url({ appendSlash: false })) .with('vpcTransactionService', faker.internet.url({ appendSlash: false })) .with('theme', themeBuilder().build()) diff --git a/src/domain/chains/entities/balances-provider.entity.ts b/src/domain/chains/entities/balances-provider.entity.ts new file mode 100644 index 0000000000..f645a71441 --- /dev/null +++ b/src/domain/chains/entities/balances-provider.entity.ts @@ -0,0 +1,4 @@ +import { BalancesProviderSchema } from '@/domain/chains/entities/schemas/chain.schema'; +import { z } from 'zod'; + +export type BalancesProvider = z.infer; diff --git a/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts b/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts index 3f2ba4327b..b1e46befd3 100644 --- a/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts +++ b/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts @@ -1,3 +1,4 @@ +import { balancesProviderBuilder } from '@/domain/chains/entities/__tests__/balances-provider.builder'; import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; import { gasPriceFixedEIP1559Builder } from '@/domain/chains/entities/__tests__/gas-price-fixed-eip-1559.builder'; import { gasPriceFixedBuilder } from '@/domain/chains/entities/__tests__/gas-price-fixed.builder'; @@ -8,6 +9,7 @@ import { rpcUriBuilder } from '@/domain/chains/entities/__tests__/rpc-uri.builde import { themeBuilder } from '@/domain/chains/entities/__tests__/theme.builder'; import { ChainSchema, + BalancesProviderSchema, GasPriceFixedEip1559Schema, GasPriceFixedSchema, GasPriceOracleSchema, @@ -354,6 +356,86 @@ describe('Chain schemas', () => { }); }); + describe('BalancesProviderSchema', () => { + it('should validate a valid BalancesProvider', () => { + const balancesProvider = balancesProviderBuilder().build(); + + const result = BalancesProviderSchema.safeParse(balancesProvider); + + expect(result.success).toBe(true); + }); + + it('should not validate an invalid balancesProvider chainName', () => { + const balancesProvider = balancesProviderBuilder().build(); + // @ts-expect-error - chainName is expected to be a string + balancesProvider.chainName = faker.number.int(); + + const result = BalancesProviderSchema.safeParse(balancesProvider); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'number', + path: ['chainName'], + message: 'Expected string, received number', + }, + ]), + ); + }); + + it('should default balancesProvider chainName to null', () => { + const balancesProvider = balancesProviderBuilder().build(); + // @ts-expect-error - inferred types don't allow optional fields + delete balancesProvider.chainName; + + const result = BalancesProviderSchema.safeParse(balancesProvider); + + expect(result.success && result.data.chainName).toStrictEqual(null); + }); + + it('should not validate an undefined balancesProvider enablement status', () => { + const balancesProvider = balancesProviderBuilder().build(); + // @ts-expect-error - inferred types don't allow optional fields + delete balancesProvider.enabled; + + const result = BalancesProviderSchema.safeParse(balancesProvider); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'boolean', + received: 'undefined', + path: ['enabled'], + message: 'Required', + }, + ]), + ); + }); + + it('should not validate an invalid balancesProvider enablement status', () => { + const balancesProvider = balancesProviderBuilder().build(); + // @ts-expect-error - enabled is expected to be a boolean + balancesProvider.enabled = 'true'; + + const result = BalancesProviderSchema.safeParse(balancesProvider); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'boolean', + received: 'string', + path: ['enabled'], + message: 'Expected boolean, received string', + }, + ]), + ); + }); + }); + describe('ChainSchema', () => { it('should validate a valid chain', () => { const chain = chainBuilder().build(); @@ -401,5 +483,33 @@ describe('Chain schemas', () => { ]), ); }); + + it.each([ + ['rpcUri' as const], + ['safeAppsRpcUri' as const], + ['publicRpcUri' as const], + ['blockExplorerUriTemplate' as const], + ['nativeCurrency' as const], + ['pricesProvider' as const], + ['balancesProvider' as const], + ['theme' as const], + ])('should not validate a chain without %s', (field) => { + const chain = chainBuilder().build(); + delete chain[field]; + + const result = ChainSchema.safeParse(chain); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'object', + received: 'undefined', + path: [field], + message: 'Required', + }, + ]), + ); + }); }); }); diff --git a/src/domain/chains/entities/schemas/chain.schema.ts b/src/domain/chains/entities/schemas/chain.schema.ts index 3c1d11d8f9..a857023909 100644 --- a/src/domain/chains/entities/schemas/chain.schema.ts +++ b/src/domain/chains/entities/schemas/chain.schema.ts @@ -59,6 +59,11 @@ export const PricesProviderSchema = z.object({ nativeCoin: z.string(), }); +export const BalancesProviderSchema = z.object({ + chainName: z.string().nullish().default(null), + enabled: z.boolean(), +}); + export const ChainSchema = z.object({ chainId: z.string(), chainName: z.string(), @@ -73,6 +78,7 @@ export const ChainSchema = z.object({ blockExplorerUriTemplate: BlockExplorerUriTemplateSchema, nativeCurrency: NativeCurrencySchema, pricesProvider: PricesProviderSchema, + balancesProvider: BalancesProviderSchema, transactionService: z.string().url(), vpcTransactionService: z.string().url(), theme: ThemeSchema, @@ -84,4 +90,6 @@ export const ChainSchema = z.object({ recommendedMasterCopyVersion: z.string(), }); +// TODO: Merge schema definitions with ChainEntity. + export const ChainPageSchema = buildPageSchema(ChainSchema); diff --git a/src/domain/collectibles/collectibles.repository.interface.ts b/src/domain/collectibles/collectibles.repository.interface.ts index f197fa91b1..dc9e7896c2 100644 --- a/src/domain/collectibles/collectibles.repository.interface.ts +++ b/src/domain/collectibles/collectibles.repository.interface.ts @@ -3,12 +3,13 @@ import { Page } from '@/domain/entities/page.entity'; import { Module } from '@nestjs/common'; import { CollectiblesRepository } from '@/domain/collectibles/collectibles.repository'; import { BalancesApiModule } from '@/datasources/balances-api/balances-api.module'; +import { Chain } from '@/domain/chains/entities/chain.entity'; export const ICollectiblesRepository = Symbol('ICollectiblesRepository'); export interface ICollectiblesRepository { getCollectibles(args: { - chainId: string; + chain: Chain; safeAddress: `0x${string}`; limit?: number; offset?: number; diff --git a/src/domain/collectibles/collectibles.repository.ts b/src/domain/collectibles/collectibles.repository.ts index be4569f3f5..6f5aeed1c2 100644 --- a/src/domain/collectibles/collectibles.repository.ts +++ b/src/domain/collectibles/collectibles.repository.ts @@ -4,6 +4,7 @@ import { CollectiblePageSchema } from '@/domain/collectibles/entities/schemas/co import { Collectible } from '@/domain/collectibles/entities/collectible.entity'; import { Page } from '@/domain/entities/page.entity'; import { IBalancesApiManager } from '@/domain/interfaces/balances-api.manager.interface'; +import { Chain } from '@/domain/chains/entities/chain.entity'; @Injectable() export class CollectiblesRepository implements ICollectiblesRepository { @@ -13,7 +14,7 @@ export class CollectiblesRepository implements ICollectiblesRepository { ) {} async getCollectibles(args: { - chainId: string; + chain: Chain; safeAddress: `0x${string}`; limit?: number; offset?: number; @@ -21,7 +22,7 @@ export class CollectiblesRepository implements ICollectiblesRepository { excludeSpam?: boolean; }): Promise> { const api = await this.balancesApiManager.getBalancesApi( - args.chainId, + args.chain.chainId, args.safeAddress, ); const page = await api.getCollectibles(args); diff --git a/src/domain/interfaces/balances-api.interface.ts b/src/domain/interfaces/balances-api.interface.ts index d5d1fffe90..bfe6910a17 100644 --- a/src/domain/interfaces/balances-api.interface.ts +++ b/src/domain/interfaces/balances-api.interface.ts @@ -7,7 +7,7 @@ export interface IBalancesApi { getBalances(args: { safeAddress: `0x${string}`; fiatCode: string; - chain?: Chain; + chain: Chain; trusted?: boolean; excludeSpam?: boolean; }): Promise; @@ -19,7 +19,7 @@ export interface IBalancesApi { getCollectibles(args: { safeAddress: `0x${string}`; - chainId?: string; + chain: Chain; limit?: number; offset?: number; trusted?: boolean; diff --git a/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts b/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts index 734b33e1db..d9601c362b 100644 --- a/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts +++ b/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts @@ -35,6 +35,7 @@ import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues- import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; import { sample } from 'lodash'; +import { balancesProviderBuilder } from '@/domain/chains/entities/__tests__/balances-provider.builder'; describe('Balances Controller (Unit)', () => { let app: INestApplication; @@ -108,12 +109,16 @@ describe('Balances Controller (Unit)', () => { describe('Balances provider: Zerion', () => { describe('GET /balances', () => { it(`maps native coin + ERC20 token balance correctly, and sorts balances by fiatBalance`, async () => { - const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); + const chainName = faker.company.name(); + const chain = chainBuilder() + .with('chainId', zerionChainIds[0]) + .with( + 'balancesProvider', + balancesProviderBuilder().with('chainName', chainName).build(), + ) + .build(); const safeAddress = getAddress(faker.finance.ethereumAddress()); const currency = sample(zerionCurrencies); - const chainName = configurationService.getOrThrow( - `balances.providers.zerion.chains.${chain.chainId}.chainName`, - ); const nativeCoinFungibleInfo = zerionFungibleInfoBuilder() .with('implementations', [ zerionImplementationBuilder().build(), @@ -254,12 +259,16 @@ describe('Balances Controller (Unit)', () => { }); it('returns large numbers as is (not in scientific notation)', async () => { - const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); + const chainName = faker.company.name(); + const chain = chainBuilder() + .with('chainId', zerionChainIds[0]) + .with( + 'balancesProvider', + balancesProviderBuilder().with('chainName', chainName).build(), + ) + .build(); const safeAddress = getAddress(faker.finance.ethereumAddress()); const currency = sample(zerionCurrencies); - const chainName = configurationService.getOrThrow( - `balances.providers.zerion.chains.${chain.chainId}.chainName`, - ); const nativeCoinFungibleInfo = zerionFungibleInfoBuilder() .with('implementations', [ zerionImplementationBuilder().build(), @@ -497,12 +506,16 @@ describe('Balances Controller (Unit)', () => { describe('Rate Limit error', () => { it('does not trigger a rate-limit error', async () => { - const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); + const chainName = faker.company.name(); + const chain = chainBuilder() + .with('chainId', zerionChainIds[0]) + .with( + 'balancesProvider', + balancesProviderBuilder().with('chainName', chainName).build(), + ) + .build(); const safeAddress = getAddress(faker.finance.ethereumAddress()); const currency = sample(zerionCurrencies); - const chainName = configurationService.getOrThrow( - `balances.providers.zerion.chains.${chain.chainId}.chainName`, - ); const nativeCoinFungibleInfo = zerionFungibleInfoBuilder() .with('implementations', [ zerionImplementationBuilder() @@ -566,11 +579,15 @@ describe('Balances Controller (Unit)', () => { }); it('triggers a rate-limit error', async () => { - const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); + const chainName = faker.company.name(); + const chain = chainBuilder() + .with('chainId', zerionChainIds[0]) + .with( + 'balancesProvider', + balancesProviderBuilder().with('chainName', chainName).build(), + ) + .build(); const safeAddress = getAddress(faker.finance.ethereumAddress()); - const chainName = configurationService.getOrThrow( - `balances.providers.zerion.chains.${chain.chainId}.chainName`, - ); const nativeCoinFungibleInfo = zerionFungibleInfoBuilder() .with('implementations', [ zerionImplementationBuilder() diff --git a/src/routes/chains/chains.controller.spec.ts b/src/routes/chains/chains.controller.spec.ts index 3b041b6d15..3d0bc4d0ec 100644 --- a/src/routes/chains/chains.controller.spec.ts +++ b/src/routes/chains/chains.controller.spec.ts @@ -120,6 +120,7 @@ describe('Chains Controller (Unit)', () => { ), disabledWallets: chainsResponse.results[0].disabledWallets, features: chainsResponse.results[0].features, + balancesProvider: chainsResponse.results[0].balancesProvider, }, { chainId: chainsResponse.results[1].chainId, @@ -143,6 +144,7 @@ describe('Chains Controller (Unit)', () => { ), disabledWallets: chainsResponse.results[1].disabledWallets, features: chainsResponse.results[1].features, + balancesProvider: chainsResponse.results[1].balancesProvider, }, ], }); @@ -238,6 +240,7 @@ describe('Chains Controller (Unit)', () => { ensRegistryAddress: chainDomain.ensRegistryAddress ? getAddress(chainDomain.ensRegistryAddress) : chainDomain.ensRegistryAddress, + balancesProvider: chainDomain.balancesProvider, }; networkService.get.mockResolvedValueOnce({ data: chainDomain, diff --git a/src/routes/chains/chains.service.ts b/src/routes/chains/chains.service.ts index 04d7541695..08a047d3f1 100644 --- a/src/routes/chains/chains.service.ts +++ b/src/routes/chains/chains.service.ts @@ -56,6 +56,7 @@ export class ChainsService { chain.ensRegistryAddress, chain.isTestnet, chain.chainLogoUri, + chain.balancesProvider, ), ); @@ -88,6 +89,7 @@ export class ChainsService { result.ensRegistryAddress, result.isTestnet, result.chainLogoUri, + result.balancesProvider, ); } diff --git a/src/routes/chains/entities/balances-provider.entity.ts b/src/routes/chains/entities/balances-provider.entity.ts new file mode 100644 index 0000000000..a189dec0c2 --- /dev/null +++ b/src/routes/chains/entities/balances-provider.entity.ts @@ -0,0 +1,9 @@ +import { BalancesProvider as DomainBalancesProvider } from '@/domain/chains/entities/balances-provider.entity'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class BalancesProvider implements DomainBalancesProvider { + @ApiPropertyOptional({ type: Number, nullable: true }) + chainName!: string | null; + @ApiProperty() + enabled!: boolean; +} diff --git a/src/routes/chains/entities/chain.entity.ts b/src/routes/chains/entities/chain.entity.ts index cda6d38396..72f1348801 100644 --- a/src/routes/chains/entities/chain.entity.ts +++ b/src/routes/chains/entities/chain.entity.ts @@ -32,6 +32,7 @@ import { Theme, Theme as ApiTheme, } from '@/routes/chains/entities/theme.entity'; +import { BalancesProvider } from '@/routes/chains/entities/balances-provider.entity'; @ApiExtraModels(ApiGasPriceOracle, ApiGasPriceFixed, ApiGasPriceFixedEIP1559) export class Chain { @@ -58,6 +59,8 @@ export class Chain { @ApiPropertyOptional({ type: String, nullable: true }) ensRegistryAddress: string | null; @ApiProperty() + balancesProvider: BalancesProvider; + @ApiProperty() features: string[]; @ApiProperty({ type: 'array', @@ -102,6 +105,7 @@ export class Chain { ensRegistryAddress: string | null, isTestnet: boolean, chainLogoUri: string | null, + balancesProvider: BalancesProvider, ) { this.chainId = chainId; this.chainName = chainName; @@ -121,5 +125,6 @@ export class Chain { this.safeAppsRpcUri = safeAppsRpcUri; this.shortName = shortName; this.theme = theme; + this.balancesProvider = balancesProvider; } } diff --git a/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts b/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts index 139da356a0..2e29ec2dcc 100644 --- a/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts +++ b/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts @@ -29,9 +29,11 @@ import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; +import { balancesProviderBuilder } from '@/domain/chains/entities/__tests__/balances-provider.builder'; describe('Zerion Collectibles Controller', () => { let app: INestApplication; + let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; let zerionBaseUri: string; let zerionChainIds: string[]; @@ -57,6 +59,7 @@ describe('Zerion Collectibles Controller', () => { const configurationService = moduleFixture.get( IConfigurationService, ); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); zerionBaseUri = configurationService.getOrThrow( 'balances.providers.zerion.baseUri', ); @@ -76,7 +79,14 @@ describe('Zerion Collectibles Controller', () => { describe('Collectibles provider: Zerion', () => { describe('GET /v2/collectibles', () => { it('successfully gets collectibles from Zerion', async () => { - const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); + const chainName = faker.company.name(); + const chain = chainBuilder() + .with('chainId', zerionChainIds[0]) + .with( + 'balancesProvider', + balancesProviderBuilder().with('chainName', chainName).build(), + ) + .build(); const safeAddress = getAddress(faker.finance.ethereumAddress()); const aTokenAddress = getAddress(faker.finance.ethereumAddress()); const aNFTName = faker.string.sample(); @@ -126,16 +136,13 @@ describe('Zerion Collectibles Controller', () => { .build(), ]) .build(); - const chainName = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.zerion.chains.${chain.chainId}.chainName`, - ); const apiKey = app .get(IConfigurationService) .getOrThrow(`balances.providers.zerion.apiKey`); networkService.get.mockImplementation(({ url }) => { switch (url) { + case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: + return Promise.resolve({ data: chain, status: 200 }); case `${zerionBaseUri}/v1/wallets/${safeAddress}/nft-positions`: return Promise.resolve({ data: zerionApiCollectiblesResponse, @@ -240,12 +247,15 @@ describe('Zerion Collectibles Controller', () => { }); }); - expect(networkService.get.mock.calls.length).toBe(1); + expect(networkService.get.mock.calls.length).toBe(2); expect(networkService.get.mock.calls[0][0].url).toBe( + `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, + ); + expect(networkService.get.mock.calls[1][0].url).toBe( `${zerionBaseUri}/v1/wallets/${safeAddress}/nft-positions`, ); expect( - networkService.get.mock.calls[0][0].networkRequest, + networkService.get.mock.calls[1][0].networkRequest, ).toStrictEqual({ headers: { Authorization: `Basic ${apiKey}` }, params: { @@ -255,8 +265,16 @@ describe('Zerion Collectibles Controller', () => { }, }); }); + it('successfully maps pagination option (no limit)', async () => { - const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); + const chainName = faker.company.name(); + const chain = chainBuilder() + .with('chainId', zerionChainIds[0]) + .with( + 'balancesProvider', + balancesProviderBuilder().with('chainName', chainName).build(), + ) + .build(); const safeAddress = getAddress(faker.finance.ethereumAddress()); const inputPaginationCursor = `cursor=${encodeURIComponent(`&offset=10`)}`; const zerionNext = `${faker.internet.url({ appendSlash: false })}?page%5Bsize%5D=20&page%5Bafter%5D=IjMwIg==`; @@ -268,16 +286,13 @@ describe('Zerion Collectibles Controller', () => { ]) .with('links', { next: zerionNext }) .build(); - const chainName = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.zerion.chains.${chain.chainId}.chainName`, - ); const apiKey = app .get(IConfigurationService) .getOrThrow(`balances.providers.zerion.apiKey`); networkService.get.mockImplementation(({ url }) => { switch (url) { + case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: + return Promise.resolve({ data: chain, status: 200 }); case `${zerionBaseUri}/v1/wallets/${safeAddress}/nft-positions`: return Promise.resolve({ data: zerionApiCollectiblesResponse, @@ -302,12 +317,15 @@ describe('Zerion Collectibles Controller', () => { }); }); - expect(networkService.get.mock.calls.length).toBe(1); + expect(networkService.get.mock.calls.length).toBe(2); expect(networkService.get.mock.calls[0][0].url).toBe( + `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, + ); + expect(networkService.get.mock.calls[1][0].url).toBe( `${zerionBaseUri}/v1/wallets/${safeAddress}/nft-positions`, ); expect( - networkService.get.mock.calls[0][0].networkRequest, + networkService.get.mock.calls[1][0].networkRequest, ).toStrictEqual({ headers: { Authorization: `Basic ${apiKey}` }, params: { @@ -320,7 +338,14 @@ describe('Zerion Collectibles Controller', () => { }); it('successfully maps pagination option (no offset)', async () => { - const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); + const chainName = faker.company.name(); + const chain = chainBuilder() + .with('chainId', zerionChainIds[0]) + .with( + 'balancesProvider', + balancesProviderBuilder().with('chainName', chainName).build(), + ) + .build(); const safeAddress = getAddress(faker.finance.ethereumAddress()); const paginationLimit = 4; const inputPaginationCursor = `cursor=${encodeURIComponent(`limit=${paginationLimit}`)}`; @@ -333,16 +358,13 @@ describe('Zerion Collectibles Controller', () => { ]) .with('links', { next: zerionNext }) .build(); - const chainName = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.zerion.chains.${chain.chainId}.chainName`, - ); const apiKey = app .get(IConfigurationService) .getOrThrow(`balances.providers.zerion.apiKey`); networkService.get.mockImplementation(({ url }) => { switch (url) { + case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: + return Promise.resolve({ data: chain, status: 200 }); case `${zerionBaseUri}/v1/wallets/${safeAddress}/nft-positions`: return Promise.resolve({ data: zerionApiCollectiblesResponse, @@ -367,12 +389,15 @@ describe('Zerion Collectibles Controller', () => { }); }); - expect(networkService.get.mock.calls.length).toBe(1); + expect(networkService.get.mock.calls.length).toBe(2); expect(networkService.get.mock.calls[0][0].url).toBe( + `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, + ); + expect(networkService.get.mock.calls[1][0].url).toBe( `${zerionBaseUri}/v1/wallets/${safeAddress}/nft-positions`, ); expect( - networkService.get.mock.calls[0][0].networkRequest, + networkService.get.mock.calls[1][0].networkRequest, ).toStrictEqual({ headers: { Authorization: `Basic ${apiKey}` }, params: { @@ -384,7 +409,14 @@ describe('Zerion Collectibles Controller', () => { }); it('successfully maps pagination option (both limit and offset)', async () => { - const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); + const chainName = faker.company.name(); + const chain = chainBuilder() + .with('chainId', zerionChainIds[0]) + .with( + 'balancesProvider', + balancesProviderBuilder().with('chainName', chainName).build(), + ) + .build(); const safeAddress = getAddress(faker.finance.ethereumAddress()); const paginationLimit = 4; const inputPaginationCursor = `cursor=${encodeURIComponent(`limit=${paginationLimit}&offset=20`)}`; @@ -397,16 +429,13 @@ describe('Zerion Collectibles Controller', () => { ]) .with('links', { next: zerionNext }) .build(); - const chainName = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.zerion.chains.${chain.chainId}.chainName`, - ); const apiKey = app .get(IConfigurationService) .getOrThrow(`balances.providers.zerion.apiKey`); networkService.get.mockImplementation(({ url }) => { switch (url) { + case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: + return Promise.resolve({ data: chain, status: 200 }); case `${zerionBaseUri}/v1/wallets/${safeAddress}/nft-positions`: return Promise.resolve({ data: zerionApiCollectiblesResponse, @@ -431,12 +460,15 @@ describe('Zerion Collectibles Controller', () => { }); }); - expect(networkService.get.mock.calls.length).toBe(1); + expect(networkService.get.mock.calls.length).toBe(2); expect(networkService.get.mock.calls[0][0].url).toBe( + `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, + ); + expect(networkService.get.mock.calls[1][0].url).toBe( `${zerionBaseUri}/v1/wallets/${safeAddress}/nft-positions`, ); expect( - networkService.get.mock.calls[0][0].networkRequest, + networkService.get.mock.calls[1][0].networkRequest, ).toStrictEqual({ headers: { Authorization: `Basic ${apiKey}` }, params: { diff --git a/src/routes/collectibles/collectibles.module.ts b/src/routes/collectibles/collectibles.module.ts index 961628b48a..8af90f6a82 100644 --- a/src/routes/collectibles/collectibles.module.ts +++ b/src/routes/collectibles/collectibles.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { CollectiblesController } from '@/routes/collectibles/collectibles.controller'; import { CollectiblesService } from '@/routes/collectibles/collectibles.service'; import { CollectiblesRepositoryModule } from '@/domain/collectibles/collectibles.repository.interface'; +import { ChainsRepositoryModule } from '@/domain/chains/chains.repository.interface'; @Module({ - imports: [CollectiblesRepositoryModule], + imports: [ChainsRepositoryModule, CollectiblesRepositoryModule], controllers: [CollectiblesController], providers: [CollectiblesService], }) diff --git a/src/routes/collectibles/collectibles.service.ts b/src/routes/collectibles/collectibles.service.ts index b9740e047a..b1ae2f30d4 100644 --- a/src/routes/collectibles/collectibles.service.ts +++ b/src/routes/collectibles/collectibles.service.ts @@ -6,12 +6,15 @@ import { PaginationData, cursorUrlFromLimitAndOffset, } from '@/routes/common/pagination/pagination.data'; +import { IChainsRepository } from '@/domain/chains/chains.repository.interface'; @Injectable() export class CollectiblesService { constructor( @Inject(ICollectiblesRepository) private readonly repository: ICollectiblesRepository, + @Inject(IChainsRepository) + private readonly chainsRepository: IChainsRepository, ) {} async getCollectibles(args: { @@ -22,8 +25,10 @@ export class CollectiblesService { trusted: boolean; excludeSpam: boolean; }): Promise> { + const chain = await this.chainsRepository.getChain(args.chainId); const collectibles = await this.repository.getCollectibles({ ...args, + chain, limit: args.paginationData.limit, offset: args.paginationData.offset, }); From cb6d45982e8d2e3a21a965e57856412ad817b04e Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Fri, 14 Jun 2024 11:32:17 +0200 Subject: [PATCH 088/207] Simplify and use `AbiDecoder` function selector helpers (#1639) Adjusts the logic of `AbiDecoder['helpers']` to instead compare the beginning of the function data against the relevant selector: - Optimise `AbiDecoder['helpers']` generation - Remove `SetPreSignatureDecoder['isSetPreSignature']` and replace all usage with `this.helpers.isSetPreSignature` --- .../contracts/decoders/abi-decoder.helper.ts | 12 +++--------- .../decoders/set-pre-signature-decoder.helper.ts | 15 ++------------- .../transactions/helpers/swap-order.helper.ts | 4 +++- 3 files changed, 8 insertions(+), 23 deletions(-) diff --git a/src/domain/contracts/decoders/abi-decoder.helper.ts b/src/domain/contracts/decoders/abi-decoder.helper.ts index a958957e7b..169cd92453 100644 --- a/src/domain/contracts/decoders/abi-decoder.helper.ts +++ b/src/domain/contracts/decoders/abi-decoder.helper.ts @@ -7,6 +7,7 @@ import { Hex, decodeEventLog as _decodeEventLog, decodeFunctionData as _decodeFunctionData, + toFunctionSelector, } from 'viem'; type Helper = `is${Capitalize>}`; @@ -30,17 +31,10 @@ export function _generateHelpers( } const helperName = `is${capitalize(item.name)}` as Helper; + const functionSelector = toFunctionSelector(item); helpers[helperName] = (data: Hex): boolean => { - try { - const { functionName } = _decodeFunctionData({ - data, - abi, - }); - return functionName === item.name; - } catch { - return false; - } + return data.startsWith(functionSelector); }; } diff --git a/src/domain/swaps/contracts/decoders/set-pre-signature-decoder.helper.ts b/src/domain/swaps/contracts/decoders/set-pre-signature-decoder.helper.ts index de44195a97..6906c2cb7a 100644 --- a/src/domain/swaps/contracts/decoders/set-pre-signature-decoder.helper.ts +++ b/src/domain/swaps/contracts/decoders/set-pre-signature-decoder.helper.ts @@ -1,6 +1,6 @@ import { Inject, Injectable, Module } from '@nestjs/common'; import { AbiDecoder } from '@/domain/contracts/decoders/abi-decoder.helper'; -import { parseAbi, toFunctionSelector } from 'viem'; +import { parseAbi } from 'viem'; import { ILoggingService, LoggingService } from '@/logging/logging.interface'; export const abi = parseAbi([ @@ -9,21 +9,10 @@ export const abi = parseAbi([ @Injectable() export class SetPreSignatureDecoder extends AbiDecoder { - private readonly setPreSignatureFunctionSelector: `0x${string}`; - constructor( @Inject(LoggingService) private readonly loggingService: ILoggingService, ) { super(abi); - this.setPreSignatureFunctionSelector = toFunctionSelector(abi[0]); - } - - /** - * Checks if the provided transaction data is a setPreSignature call. - * @param data - the transaction data - */ - isSetPreSignature(data: string): boolean { - return data.startsWith(this.setPreSignatureFunctionSelector); } /** @@ -34,7 +23,7 @@ export class SetPreSignatureDecoder extends AbiDecoder { */ getOrderUid(data: `0x${string}`): `0x${string}` | null { try { - if (!this.isSetPreSignature(data)) return null; + if (!this.helpers.isSetPreSignature(data)) return null; const { args } = this.decodeFunctionData({ data }); return args[0]; } catch (e) { diff --git a/src/routes/transactions/helpers/swap-order.helper.ts b/src/routes/transactions/helpers/swap-order.helper.ts index 3a0623cf44..ec0a5fd386 100644 --- a/src/routes/transactions/helpers/swap-order.helper.ts +++ b/src/routes/transactions/helpers/swap-order.helper.ts @@ -149,7 +149,9 @@ export class SwapOrderHelper { private isSwapOrder(transaction: { data?: `0x${string}` }): boolean { if (!transaction.data) return false; - return this.setPreSignatureDecoder.isSetPreSignature(transaction.data); + return this.setPreSignatureDecoder.helpers.isSetPreSignature( + transaction.data, + ); } /** From d323fd97b1dc10c6c79917fd3150484f47c20aa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Fri, 14 Jun 2024 11:34:38 +0200 Subject: [PATCH 089/207] Ensure RPC URL formatting for Infura PublicClients (#1644) Ensure RPC URL formatting for Infura-backed PublicClients --- .../blockchain/blockchain-api.manager.spec.ts | 47 +++++++++++++++++-- .../blockchain/blockchain-api.manager.ts | 11 ++++- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/datasources/blockchain/blockchain-api.manager.spec.ts b/src/datasources/blockchain/blockchain-api.manager.spec.ts index b54bd9d1d6..564bf71389 100644 --- a/src/datasources/blockchain/blockchain-api.manager.spec.ts +++ b/src/datasources/blockchain/blockchain-api.manager.spec.ts @@ -1,6 +1,8 @@ import { FakeConfigurationService } from '@/config/__tests__/fake.configuration.service'; import { BlockchainApiManager } from '@/datasources/blockchain/blockchain-api.manager'; import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; +import { rpcUriBuilder } from '@/domain/chains/entities/__tests__/rpc-uri.builder'; +import { RpcUriAuthentication } from '@/domain/chains/entities/rpc-uri-authentication.entity'; import { IConfigApi } from '@/domain/interfaces/config-api.interface'; import { faker } from '@faker-js/faker'; @@ -10,15 +12,13 @@ const configApiMock = jest.mocked({ describe('BlockchainApiManager', () => { let target: BlockchainApiManager; + const infuraApiKey = faker.string.hexadecimal({ length: 32 }); beforeEach(() => { jest.resetAllMocks(); const fakeConfigurationService = new FakeConfigurationService(); - fakeConfigurationService.set( - 'blockchain.infura.apiKey', - faker.string.hexadecimal({ length: 32 }), - ); + fakeConfigurationService.set('blockchain.infura.apiKey', infuraApiKey); target = new BlockchainApiManager(fakeConfigurationService, configApiMock); }); @@ -32,6 +32,45 @@ describe('BlockchainApiManager', () => { expect(api).toBe(cachedApi); }); + + it('should include the INFURA_API_KEY in the RPC URI for an Infura URI with API_KEY_PATH authentication', async () => { + const rpcUriValue = `https://${faker.string.sample()}.infura.io/v3/`; + const chain = chainBuilder() + .with( + 'rpcUri', + rpcUriBuilder() + .with('value', rpcUriValue) + .with('authentication', RpcUriAuthentication.ApiKeyPath) + .build(), + ) + .build(); + configApiMock.getChain.mockResolvedValue(chain); + + const api = await target.getBlockchainApi(chain.chainId); + + expect(api.chain?.rpcUrls.default.http[0]).toContain(infuraApiKey); + }); + + it('should not include the INFURA_API_KEY in the RPC URI for an Infura URI without API_KEY_PATH authentication', async () => { + const rpcUriValue = `https://${faker.string.sample()}.infura.io/v3/`; + const chain = chainBuilder() + .with('rpcUri', rpcUriBuilder().with('value', rpcUriValue).build()) + .build(); + configApiMock.getChain.mockResolvedValue(chain); + + const api = await target.getBlockchainApi(chain.chainId); + + expect(api.chain?.rpcUrls.default.http[0]).not.toContain(infuraApiKey); + }); + + it('should not include the INFURA_API_KEY in the RPC URI for a RPC provider different from Infura', async () => { + const chain = chainBuilder().build(); + configApiMock.getChain.mockResolvedValue(chain); + + const api = await target.getBlockchainApi(chain.chainId); + + expect(api.chain?.rpcUrls.default.http[0]).not.toContain(infuraApiKey); + }); }); describe('destroyBlockchainApi', () => { diff --git a/src/datasources/blockchain/blockchain-api.manager.ts b/src/datasources/blockchain/blockchain-api.manager.ts index 713be238e2..420eae6dc9 100644 --- a/src/datasources/blockchain/blockchain-api.manager.ts +++ b/src/datasources/blockchain/blockchain-api.manager.ts @@ -8,6 +8,7 @@ import { Chain, PublicClient, createPublicClient, http } from 'viem'; @Injectable() export class BlockchainApiManager implements IBlockchainApiManager { + private static readonly INFURA_URL_PATTERN = 'infura'; private readonly blockchainApiMap: Record = {}; private readonly infuraApiKey: string; @@ -59,9 +60,15 @@ export class BlockchainApiManager implements IBlockchainApiManager { }; } - // Note: this assumes Infura as provider when using an API key as authentication method. + /** + * Formats rpcUri to include the Infura API key if the rpcUri is an Infura URL + * and the authentication method is {@link RpcUriAuthentication.ApiKeyPath}. + * @param rpcUri rpcUri to format + * @returns Formatted rpcUri + */ private formatRpcUri(rpcUri: DomainChain['rpcUri']): string { - return rpcUri.authentication === RpcUriAuthentication.ApiKeyPath + return rpcUri.authentication === RpcUriAuthentication.ApiKeyPath && + rpcUri.value.includes(BlockchainApiManager.INFURA_URL_PATTERN) ? rpcUri.value + this.infuraApiKey : rpcUri.value; } From 73c5bac7c6b3338760fccd22aa17ba5f174b2aa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Fri, 14 Jun 2024 14:00:53 +0200 Subject: [PATCH 090/207] Increase E2E tests timeout (#1653) --- test/jest-e2e.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/jest-e2e.json b/test/jest-e2e.json index 9b21fb9645..71c3291049 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -5,7 +5,7 @@ "setupFiles": ["/test/e2e-setup.ts"], "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", - "testTimeout": 20000, + "testTimeout": 40000, "transform": { "^.+\\.(t|j)s$": "ts-jest" }, From e43f13a88f835bff4f1f2ee32b6bf07350781f43 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Fri, 14 Jun 2024 14:57:37 +0200 Subject: [PATCH 091/207] Add missing properties to `balanceBuilder` (#1641) Adds the missing `fiatBalance` and `fiatConversion` to `balanceBuilder` with a default value of `null`. --- src/domain/balances/entities/__tests__/balance.builder.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/domain/balances/entities/__tests__/balance.builder.ts b/src/domain/balances/entities/__tests__/balance.builder.ts index ccc1841afc..cb74f62c8b 100644 --- a/src/domain/balances/entities/__tests__/balance.builder.ts +++ b/src/domain/balances/entities/__tests__/balance.builder.ts @@ -8,5 +8,7 @@ export function balanceBuilder(): IBuilder { return new Builder() .with('tokenAddress', getAddress(faker.finance.ethereumAddress())) .with('token', balanceTokenBuilder().build()) - .with('balance', faker.string.numeric()); + .with('balance', faker.string.numeric()) + .with('fiatBalance', null) + .with('fiatConversion', null); } From 5855b1af99e062868cf4a0ab43a56483f8bd935a Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Fri, 14 Jun 2024 16:33:13 +0200 Subject: [PATCH 092/207] Replace `Account` implementation and integration (#1652) Replaces the current account/subscription integration, as well as their integration with alerts and emails with a new simple account basis (#1654) for fortchoming features: Note: #1654 was merged into this commit before `main` in order to prevent duplicated old tables. #1652: - Remove account datasource - Remove test datasource from tests - Remove account domain - Remove subscription domain - Decouple account/subscription integration from alerts: - Remove business logic - Remove redundant code - Remove relative test logic and skip those eventually required #1654: - Create `00001_accounts` migration to create database - Create `Account` entity with test coverage - Create `Group` entity with test coverage - Create `IAccountsDatasource` with implementation and test override --- migrations/00001_accounts/index.sql | 13 + migrations/00001_initial/index.sql | 45 - src/app.module.ts | 10 +- .../entities/__tests__/configuration.ts | 9 - src/config/entities/configuration.ts | 14 - .../test.account.datasource.module.ts | 31 - .../account/account.datasource.module.ts | 11 - .../account/account.datasource.spec.ts | 808 ---------- src/datasources/account/account.datasource.ts | 384 ----- .../verification-code-does-not-exist.error.ts | 10 - .../test.accounts.datasource.modulte.ts | 20 + .../accounts/accounts.datasource.module.ts | 11 + .../accounts/accounts.datasource.spec.ts | 106 ++ .../accounts/accounts.datasource.ts | 61 + .../entities/__tests__/account.builder.ts | 11 + .../entities/__tests__/group.builder.ts | 7 + .../accounts/entities/account.entity.spec.ts | 114 ++ .../accounts/entities/account.entity.ts | 11 + .../accounts/entities/group.entity.spec.ts | 65 + .../accounts/entities/group.entity.ts | 7 + .../email-api/pushwoosh-api.service.spec.ts | 2 +- .../email-api/pushwoosh-api.service.ts | 2 +- src/domain/account/account.domain.module.ts | 27 - .../account/account.repository.interface.ts | 106 -- src/domain/account/account.repository.ts | 384 ----- src/domain/account/code-generator.ts | 10 - .../entities/__tests__/account.builder.ts | 17 - .../__tests__/subscription.builder.ts | 9 - .../__tests__/verification-code.builder.ts | 10 - .../account/entities/account.entity.spec.ts | 25 - src/domain/account/entities/account.entity.ts | 32 - .../account/entities/subscription.entity.ts | 4 - .../errors/account-does-not-exist.error.ts | 14 - .../account/errors/account-save.error.ts | 11 - .../errors/email-already-verified.error.ts | 14 - .../errors/email-edit-matches.error.ts | 11 - .../errors/invalid-verification-code.error.ts | 11 - .../errors/verification-timeframe.error.ts | 13 - src/domain/alerts/alerts.domain.module.ts | 6 - src/domain/alerts/alerts.repository.ts | 183 +-- .../alerts/urls/url-generator.helper.spec.ts | 56 - .../alerts/urls/url-generator.helper.ts | 31 - .../alerts/urls/url-generator.module.ts | 8 - .../create-email-message.dto.entity.ts | 0 .../account.datasource.interface.ts | 205 --- .../accounts.datasource.interface.ts | 9 + src/domain/interfaces/email-api.interface.ts | 2 +- .../subscription.domain.module.ts | 14 - .../subscription.repository.interface.ts | 25 - .../subscriptions/subscription.repository.ts | 56 - src/routes/alerts/alerts.controller.spec.ts | 1307 ++++------------- src/routes/auth/auth.controller.spec.ts | 4 - .../zerion-balances.controller.spec.ts | 4 - .../balances/balances.controller.spec.ts | 4 - .../cache-hooks.controller.spec.ts | 4 - src/routes/chains/chains.controller.spec.ts | 4 - .../zerion-collectibles.controller.spec.ts | 4 - .../collectibles.controller.spec.ts | 4 - .../community/community.controller.spec.ts | 4 - .../contracts/contracts.controller.spec.ts | 4 - .../delegates/delegates.controller.spec.ts | 4 - .../v2/delegates.v2.controller.spec.ts | 4 - .../email.controller.delete-email.spec.ts | 447 ------ .../email/email.controller.edit-email.spec.ts | 629 -------- .../email/email.controller.get-email.spec.ts | 365 ----- src/routes/email/email.controller.module.ts | 13 - ...ail.controller.resend-verification.spec.ts | 185 --- .../email/email.controller.save-email.spec.ts | 419 ------ src/routes/email/email.controller.ts | 153 -- .../email.controller.verify-email.spec.ts | 171 --- src/routes/email/email.service.ts | 92 -- .../__tests__/edit-email-dto.entity.spec.ts | 34 - .../__tests__/save-email-dto.entity.spec.ts | 67 - .../email/entities/edit-email-dto.entity.ts | 11 - src/routes/email/entities/email.entity.ts | 14 - .../email/entities/save-email-dto.entity.ts | 16 - .../email/entities/verify-email-dto.entity.ts | 6 - ...account-does-not-exist.exception-filter.ts | 21 - .../email-edit-matches.exception-filter.ts | 21 - ...alid-verification-code.exception-filter.ts | 21 - .../unauthenticated.exception-filter.ts | 30 - .../estimations.controller.spec.ts | 4 - src/routes/health/health.controller.spec.ts | 4 - .../messages/messages.controller.spec.ts | 4 - .../notifications.controller.spec.ts | 4 - src/routes/owners/owners.controller.spec.ts | 4 - .../recovery/recovery.controller.spec.ts | 4 - src/routes/relay/relay.controller.spec.ts | 4 - src/routes/root/root.controller.spec.ts | 4 - .../safe-apps/safe-apps.controller.spec.ts | 4 - .../safes/safes.controller.nonces.spec.ts | 4 - .../safes/safes.controller.overview.spec.ts | 4 - src/routes/safes/safes.controller.spec.ts | 4 - .../subscription.controller.spec.ts | 203 --- .../subscriptions/subscription.controller.ts | 27 - .../subscriptions/subscription.module.ts | 11 - .../subscriptions/subscription.service.ts | 22 - ...ransaction.transactions.controller.spec.ts | 4 - ...tion-by-id.transactions.controller.spec.ts | 4 - ...rs-by-safe.transactions.controller.spec.ts | 4 - ...ns-by-safe.transactions.controller.spec.ts | 4 - ...ns-by-safe.transactions.controller.spec.ts | 4 - ...ransaction.transactions.controller.spec.ts | 4 - ...ransaction.transactions.controller.spec.ts | 4 - .../transactions-history.controller.spec.ts | 4 - ....imitation-transactions.controller.spec.ts | 4 - .../transactions-view.controller.spec.ts | 4 - 107 files changed, 724 insertions(+), 6738 deletions(-) create mode 100644 migrations/00001_accounts/index.sql delete mode 100644 migrations/00001_initial/index.sql delete mode 100644 src/datasources/account/__tests__/test.account.datasource.module.ts delete mode 100644 src/datasources/account/account.datasource.module.ts delete mode 100644 src/datasources/account/account.datasource.spec.ts delete mode 100644 src/datasources/account/account.datasource.ts delete mode 100644 src/datasources/account/errors/verification-code-does-not-exist.error.ts create mode 100644 src/datasources/accounts/__tests__/test.accounts.datasource.modulte.ts create mode 100644 src/datasources/accounts/accounts.datasource.module.ts create mode 100644 src/datasources/accounts/accounts.datasource.spec.ts create mode 100644 src/datasources/accounts/accounts.datasource.ts create mode 100644 src/datasources/accounts/entities/__tests__/account.builder.ts create mode 100644 src/datasources/accounts/entities/__tests__/group.builder.ts create mode 100644 src/datasources/accounts/entities/account.entity.spec.ts create mode 100644 src/datasources/accounts/entities/account.entity.ts create mode 100644 src/datasources/accounts/entities/group.entity.spec.ts create mode 100644 src/datasources/accounts/entities/group.entity.ts delete mode 100644 src/domain/account/account.domain.module.ts delete mode 100644 src/domain/account/account.repository.interface.ts delete mode 100644 src/domain/account/account.repository.ts delete mode 100644 src/domain/account/code-generator.ts delete mode 100644 src/domain/account/entities/__tests__/account.builder.ts delete mode 100644 src/domain/account/entities/__tests__/subscription.builder.ts delete mode 100644 src/domain/account/entities/__tests__/verification-code.builder.ts delete mode 100644 src/domain/account/entities/account.entity.spec.ts delete mode 100644 src/domain/account/entities/account.entity.ts delete mode 100644 src/domain/account/entities/subscription.entity.ts delete mode 100644 src/domain/account/errors/account-does-not-exist.error.ts delete mode 100644 src/domain/account/errors/account-save.error.ts delete mode 100644 src/domain/account/errors/email-already-verified.error.ts delete mode 100644 src/domain/account/errors/email-edit-matches.error.ts delete mode 100644 src/domain/account/errors/invalid-verification-code.error.ts delete mode 100644 src/domain/account/errors/verification-timeframe.error.ts delete mode 100644 src/domain/alerts/urls/url-generator.helper.spec.ts delete mode 100644 src/domain/alerts/urls/url-generator.helper.ts delete mode 100644 src/domain/alerts/urls/url-generator.module.ts rename src/domain/{account => email}/entities/create-email-message.dto.entity.ts (100%) delete mode 100644 src/domain/interfaces/account.datasource.interface.ts create mode 100644 src/domain/interfaces/accounts.datasource.interface.ts delete mode 100644 src/domain/subscriptions/subscription.domain.module.ts delete mode 100644 src/domain/subscriptions/subscription.repository.interface.ts delete mode 100644 src/domain/subscriptions/subscription.repository.ts delete mode 100644 src/routes/email/email.controller.delete-email.spec.ts delete mode 100644 src/routes/email/email.controller.edit-email.spec.ts delete mode 100644 src/routes/email/email.controller.get-email.spec.ts delete mode 100644 src/routes/email/email.controller.module.ts delete mode 100644 src/routes/email/email.controller.resend-verification.spec.ts delete mode 100644 src/routes/email/email.controller.save-email.spec.ts delete mode 100644 src/routes/email/email.controller.ts delete mode 100644 src/routes/email/email.controller.verify-email.spec.ts delete mode 100644 src/routes/email/email.service.ts delete mode 100644 src/routes/email/entities/__tests__/edit-email-dto.entity.spec.ts delete mode 100644 src/routes/email/entities/__tests__/save-email-dto.entity.spec.ts delete mode 100644 src/routes/email/entities/edit-email-dto.entity.ts delete mode 100644 src/routes/email/entities/email.entity.ts delete mode 100644 src/routes/email/entities/save-email-dto.entity.ts delete mode 100644 src/routes/email/entities/verify-email-dto.entity.ts delete mode 100644 src/routes/email/exception-filters/account-does-not-exist.exception-filter.ts delete mode 100644 src/routes/email/exception-filters/email-edit-matches.exception-filter.ts delete mode 100644 src/routes/email/exception-filters/invalid-verification-code.exception-filter.ts delete mode 100644 src/routes/email/exception-filters/unauthenticated.exception-filter.ts delete mode 100644 src/routes/subscriptions/subscription.controller.spec.ts delete mode 100644 src/routes/subscriptions/subscription.controller.ts delete mode 100644 src/routes/subscriptions/subscription.module.ts delete mode 100644 src/routes/subscriptions/subscription.service.ts diff --git a/migrations/00001_accounts/index.sql b/migrations/00001_accounts/index.sql new file mode 100644 index 0000000000..de08273229 --- /dev/null +++ b/migrations/00001_accounts/index.sql @@ -0,0 +1,13 @@ +DROP TABLE IF EXISTS groups, +accounts CASCADE; + +CREATE TABLE + groups (id SERIAL PRIMARY KEY); + +CREATE TABLE + accounts ( + id SERIAL PRIMARY KEY, + group_id INTEGER REFERENCES groups (id), + address CHARACTER VARYING(42) NOT NULL, + UNIQUE (address) + ); \ No newline at end of file diff --git a/migrations/00001_initial/index.sql b/migrations/00001_initial/index.sql deleted file mode 100644 index f2fdce2897..0000000000 --- a/migrations/00001_initial/index.sql +++ /dev/null @@ -1,45 +0,0 @@ -DROP TABLE IF EXISTS accounts CASCADE; -CREATE table accounts -( - id SERIAL PRIMARY KEY, - chain_id int NOT NULL, - email_address text NOT NULL, - safe_address character varying(42) NOT NULL, - signer character varying(42) NOT NULL, - verified boolean NOT NULL DEFAULT false, - unsubscription_token uuid NOT NULL, - UNIQUE (chain_id, safe_address, signer) -); - -DROP TABLE IF EXISTS verification_codes CASCADE; -CREATE TABLE verification_codes -( - account_id INT, - code text NOT NULL, - generated_on timestamp with time zone NOT NULL, - sent_on timestamp with time zone, - FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE, - UNIQUE (account_id) -); - -DROP TABLE IF EXISTS notification_types CASCADE; -CREATE TABLE notification_types -( - id SERIAL PRIMARY KEY, - key TEXT NOT NULL UNIQUE, - name TEXT NOT NULL -); - --- Add the default notification_type: account_recovery -INSERT INTO notification_types (key, name) -VALUES ('account_recovery', 'Account Recovery'); - -DROP TABLE IF EXISTS subscriptions CASCADE; -CREATE TABLE subscriptions -( - account_id INT, - notification_type INT, - FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE, - FOREIGN KEY (notification_type) REFERENCES notification_types (id) ON DELETE CASCADE, - UNIQUE (account_id, notification_type) -) diff --git a/src/app.module.ts b/src/app.module.ts index 4d70724eb3..6f33d76717 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -36,11 +36,9 @@ import { GlobalErrorFilter } from '@/routes/common/filters/global-error.filter'; import { DataSourceErrorFilter } from '@/routes/common/filters/data-source-error.filter'; import { ServeStaticModule } from '@nestjs/serve-static'; import { RootModule } from '@/routes/root/root.module'; -import { EmailControllerModule } from '@/routes/email/email.controller.module'; import { AlertsControllerModule } from '@/routes/alerts/alerts.controller.module'; import { RecoveryModule } from '@/routes/recovery/recovery.module'; import { RelayControllerModule } from '@/routes/relay/relay.controller.module'; -import { SubscriptionControllerModule } from '@/routes/subscriptions/subscription.module'; import { ZodErrorFilter } from '@/routes/common/filters/zod-error.filter'; import { CacheControlInterceptor } from '@/routes/common/interceptors/cache-control.interceptor'; import { AuthModule } from '@/routes/auth/auth.module'; @@ -76,13 +74,9 @@ export class AppModule implements NestModule { // TODO: delete/rename DelegatesModule when clients migration to v2 is completed. DelegatesModule, ...(isDelegatesV2Enabled ? [DelegatesV2Module] : []), + // Note: this feature will not work as expected until we reintegrate the email service ...(isEmailFeatureEnabled - ? [ - AlertsControllerModule, - EmailControllerModule, - RecoveryModule, - SubscriptionControllerModule, - ] + ? [AlertsControllerModule, RecoveryModule] : []), EstimationsModule, HealthModule, diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index a170945d52..22f61f21ee 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -94,15 +94,6 @@ export default (): ReturnType => ({ apiKey: faker.string.hexadecimal({ length: 32 }), fromEmail: faker.internet.email(), fromName: faker.person.fullName(), - templates: { - recoveryTx: faker.string.alphanumeric(), - unknownRecoveryTx: faker.string.alphanumeric(), - verificationCode: faker.string.alphanumeric(), - }, - verificationCode: { - resendLockWindowMs: faker.number.int(), - ttlMs: faker.number.int(), - }, }, expirationTimeInSeconds: { default: faker.number.int(), diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 72e000de17..0651c1cb9f 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -135,20 +135,6 @@ export default () => ({ apiKey: process.env.EMAIL_API_KEY, fromEmail: process.env.EMAIL_API_FROM_EMAIL, fromName: process.env.EMAIL_API_FROM_NAME || 'Safe', - templates: { - recoveryTx: process.env.EMAIL_TEMPLATE_RECOVERY_TX, - unknownRecoveryTx: process.env.EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX, - verificationCode: process.env.EMAIL_TEMPLATE_VERIFICATION_CODE, - }, - verificationCode: { - resendLockWindowMs: parseInt( - process.env.EMAIL_VERIFICATION_CODE_RESEND_LOCK_WINDOW_MS ?? - `${30 * 1000}`, - ), - ttlMs: parseInt( - process.env.EMAIL_VERIFICATION_CODE_TTL_MS ?? `${5 * 60 * 1000}`, - ), - }, }, expirationTimeInSeconds: { default: parseInt(process.env.EXPIRATION_TIME_DEFAULT_SECONDS ?? `${60}`), diff --git a/src/datasources/account/__tests__/test.account.datasource.module.ts b/src/datasources/account/__tests__/test.account.datasource.module.ts deleted file mode 100644 index 3bd2203639..0000000000 --- a/src/datasources/account/__tests__/test.account.datasource.module.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Module } from '@nestjs/common'; -import { IAccountDataSource } from '@/domain/interfaces/account.datasource.interface'; - -const accountDataSource = { - getAccount: jest.fn(), - getAccounts: jest.fn(), - getAccountVerificationCode: jest.fn(), - createAccount: jest.fn(), - setEmailVerificationCode: jest.fn(), - setEmailVerificationSentDate: jest.fn(), - verifyEmail: jest.fn(), - deleteAccount: jest.fn(), - updateAccountEmail: jest.fn(), - getSubscriptions: jest.fn(), - subscribe: jest.fn(), - unsubscribe: jest.fn(), - unsubscribeAll: jest.fn(), -} as jest.MockedObjectDeep; - -@Module({ - providers: [ - { - provide: IAccountDataSource, - useFactory: (): jest.MockedObjectDeep => { - return jest.mocked(accountDataSource); - }, - }, - ], - exports: [IAccountDataSource], -}) -export class TestAccountDataSourceModule {} diff --git a/src/datasources/account/account.datasource.module.ts b/src/datasources/account/account.datasource.module.ts deleted file mode 100644 index a2af8d3a29..0000000000 --- a/src/datasources/account/account.datasource.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from '@nestjs/common'; -import { AccountDataSource } from '@/datasources/account/account.datasource'; -import { IAccountDataSource } from '@/domain/interfaces/account.datasource.interface'; -import { PostgresDatabaseModule } from '@/datasources/db/postgres-database.module'; - -@Module({ - imports: [PostgresDatabaseModule], - providers: [{ provide: IAccountDataSource, useClass: AccountDataSource }], - exports: [IAccountDataSource], -}) -export class AccountDataSourceModule {} diff --git a/src/datasources/account/account.datasource.spec.ts b/src/datasources/account/account.datasource.spec.ts deleted file mode 100644 index c4282b90ef..0000000000 --- a/src/datasources/account/account.datasource.spec.ts +++ /dev/null @@ -1,808 +0,0 @@ -import { AccountDataSource } from '@/datasources/account/account.datasource'; -import postgres from 'postgres'; -import { PostgresError } from 'postgres'; -import { faker } from '@faker-js/faker'; -import { AccountDoesNotExistError } from '@/domain/account/errors/account-does-not-exist.error'; -import shift from 'postgres-shift'; -import configuration from '@/config/entities/__tests__/configuration'; -import { - Account, - EmailAddress, - VerificationCode, -} from '@/domain/account/entities/account.entity'; -import { accountBuilder } from '@/domain/account/entities/__tests__/account.builder'; -import { verificationCodeBuilder } from '@/domain/account/entities/__tests__/verification-code.builder'; -import { getAddress } from 'viem'; -import { readFileSync } from 'fs'; -import { join } from 'path'; - -const DB_CHAIN_ID_MAX_VALUE = 2147483647; - -describe('Account DataSource Tests', () => { - let target: AccountDataSource; - const config = configuration(); - - const isCIContext = process.env.CI?.toLowerCase() === 'true'; - - const sql = postgres({ - host: config.db.postgres.host, - port: parseInt(config.db.postgres.port), - db: config.db.postgres.database, - user: config.db.postgres.username, - password: config.db.postgres.password, - // If running on a CI context (e.g.: GitHub Actions), - // disable certificate pinning for the test execution - ssl: - isCIContext || !config.db.postgres.ssl.enabled - ? false - : { - requestCert: config.db.postgres.ssl.requestCert, - rejectUnauthorized: config.db.postgres.ssl.rejectUnauthorized, - ca: readFileSync( - join(__dirname, '../../../db_config/test/server.crt'), - 'utf8', - ), - }, - }); - - // Run any pending migration before test execution - beforeAll(async () => { - await shift({ sql }); - }); - - beforeEach(() => { - target = new AccountDataSource(sql); - }); - - afterEach(async () => { - await sql`TRUNCATE TABLE accounts, notification_types, subscriptions CASCADE`; - }); - - afterAll(async () => { - await sql.end(); - }); - - it('saves account successfully', async () => { - const chainId = faker.number.int({ max: DB_CHAIN_ID_MAX_VALUE }); - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const emailAddress = new EmailAddress(faker.internet.email()); - const signer = getAddress(faker.finance.ethereumAddress()); - const code = faker.string.numeric({ length: 6 }); - const codeGenerationDate = faker.date.recent(); - const unsubscriptionToken = faker.string.uuid(); - - const [account, verificationCode] = await target.createAccount({ - chainId: chainId.toString(), - safeAddress, - emailAddress, - signer, - code, - codeGenerationDate, - unsubscriptionToken, - }); - - expect(account).toMatchObject({ - chainId: chainId.toString(), - emailAddress: emailAddress, - isVerified: false, - safeAddress: safeAddress, - signer: signer, - }); - expect(verificationCode).toMatchObject({ - code: code, - generatedOn: codeGenerationDate, - sentOn: null, - }); - }); - - it('saving account with same email throws', async () => { - const chainId = faker.number.int({ max: DB_CHAIN_ID_MAX_VALUE }); - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const emailAddress = new EmailAddress(faker.internet.email()); - const signer = getAddress(faker.finance.ethereumAddress()); - const code = faker.string.numeric({ length: 6 }); - const codeGenerationDate = faker.date.recent(); - const unsubscriptionToken = faker.string.uuid(); - - await target.createAccount({ - chainId: chainId.toString(), - safeAddress, - emailAddress, - signer, - code, - codeGenerationDate, - unsubscriptionToken, - }); - - await expect( - target.createAccount({ - chainId: chainId.toString(), - safeAddress, - emailAddress, - signer, - code, - codeGenerationDate, - unsubscriptionToken, - }), - ).rejects.toThrow(PostgresError); - }); - - it('updates email verification successfully', async () => { - const chainId = faker.number.int({ max: DB_CHAIN_ID_MAX_VALUE }); - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const emailAddress = new EmailAddress(faker.internet.email()); - const signer = getAddress(faker.finance.ethereumAddress()); - const code = faker.string.numeric({ length: 6 }); - const codeGenerationDate = faker.date.recent(); - const newCode = code + 1; - const newCodeGenerationDate = faker.date.recent(); - const unsubscriptionToken = faker.string.uuid(); - - const [, verificationCode] = await target.createAccount({ - chainId: chainId.toString(), - safeAddress, - emailAddress, - signer, - code, - codeGenerationDate, - unsubscriptionToken, - }); - - const updatedVerificationCode = await target.setEmailVerificationCode({ - chainId: chainId.toString(), - safeAddress, - signer, - code: newCode.toString(), - codeGenerationDate: newCodeGenerationDate, - }); - - expect(updatedVerificationCode.code).not.toBe(verificationCode.code); - expect(updatedVerificationCode.sentOn).toBeNull(); - expect(updatedVerificationCode.generatedOn).toEqual(newCodeGenerationDate); - }); - - it('setting email verification code on verified emails throws', async () => { - const chainId = faker.number.int({ max: DB_CHAIN_ID_MAX_VALUE }); - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const emailAddress = new EmailAddress(faker.internet.email()); - const signer = getAddress(faker.finance.ethereumAddress()); - const code = faker.string.numeric({ length: 6 }); - const codeGenerationDate = faker.date.recent(); - const newCode = code + 1; - const newCodeGenerationDate = faker.date.recent(); - const unsubscriptionToken = faker.string.uuid(); - await target.createAccount({ - chainId: chainId.toString(), - safeAddress, - emailAddress, - signer, - code, - codeGenerationDate, - unsubscriptionToken, - }); - await target.verifyEmail({ - chainId: chainId.toString(), - safeAddress, - signer, - }); - - await expect( - target.setEmailVerificationCode({ - chainId: chainId.toString(), - safeAddress, - signer, - code: newCode.toString(), - codeGenerationDate: newCodeGenerationDate, - }), - ).rejects.toThrow(); - }); - - it('sets verification sent date successfully', async () => { - const chainId = faker.number.int({ max: DB_CHAIN_ID_MAX_VALUE }); - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const emailAddress = new EmailAddress(faker.internet.email()); - const signer = getAddress(faker.finance.ethereumAddress()); - const sentOn = faker.date.recent(); - const code = faker.string.numeric({ length: 6 }); - const codeGenerationDate = faker.date.recent(); - const unsubscriptionToken = faker.string.uuid(); - - await target.createAccount({ - chainId: chainId.toString(), - safeAddress, - emailAddress, - signer, - code, - codeGenerationDate, - unsubscriptionToken, - }); - const updatedVerificationCode = await target.setEmailVerificationSentDate({ - chainId: chainId.toString(), - safeAddress, - signer, - sentOn, - }); - - expect(updatedVerificationCode.sentOn).toEqual(sentOn); - }); - - it('setting verification code throws on unknown accounts', async () => { - const chainId = faker.number.int({ max: DB_CHAIN_ID_MAX_VALUE }); - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const signer = getAddress(faker.finance.ethereumAddress()); - const code = faker.number.int({ max: 999998 }); - const newCode = code + 1; - const newCodeGenerationDate = faker.date.recent(); - - await expect( - target.setEmailVerificationCode({ - chainId: chainId.toString(), - safeAddress, - signer, - code: newCode.toString(), - codeGenerationDate: newCodeGenerationDate, - }), - ).rejects.toThrow(); - }); - - it('updating email verification fails on unknown accounts', async () => { - const chainId = faker.number.int({ max: DB_CHAIN_ID_MAX_VALUE }); - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const signer = getAddress(faker.finance.ethereumAddress()); - - await expect( - target.verifyEmail({ - chainId: chainId.toString(), - safeAddress, - signer, - }), - ).rejects.toThrow(AccountDoesNotExistError); - }); - - it('gets only verified email addresses associated with a given safe address', async () => { - const chainId = faker.number.int({ max: DB_CHAIN_ID_MAX_VALUE }).toString(); - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const verifiedAccounts: [Account, VerificationCode][] = [ - [ - accountBuilder() - .with('chainId', chainId) - .with('safeAddress', getAddress(safeAddress)) - .with('isVerified', true) - .build(), - verificationCodeBuilder().build(), - ], - [ - accountBuilder() - .with('chainId', chainId) - .with('safeAddress', getAddress(safeAddress)) - .with('isVerified', true) - .build(), - verificationCodeBuilder().build(), - ], - ]; - const nonVerifiedAccounts: [Account, VerificationCode][] = [ - [ - accountBuilder() - .with('chainId', chainId) - .with('safeAddress', getAddress(safeAddress)) - .with('isVerified', false) - .build(), - verificationCodeBuilder().build(), - ], - [ - accountBuilder() - .with('chainId', chainId) - .with('safeAddress', getAddress(safeAddress)) - .with('isVerified', false) - .build(), - verificationCodeBuilder().build(), - ], - ]; - for (const [account, verificationCode] of verifiedAccounts) { - await target.createAccount({ - chainId, - safeAddress, - emailAddress: account.emailAddress, - signer: account.signer, - code: verificationCode.code, - codeGenerationDate: verificationCode.generatedOn, - unsubscriptionToken: account.unsubscriptionToken, - }); - await target.verifyEmail({ - chainId: chainId, - safeAddress, - signer: account.signer, - }); - } - for (const [account, verificationCode] of nonVerifiedAccounts) { - await target.createAccount({ - chainId, - safeAddress, - emailAddress: account.emailAddress, - signer: account.signer, - code: verificationCode.code, - codeGenerationDate: verificationCode.generatedOn, - unsubscriptionToken: account.unsubscriptionToken, - }); - } - - const actual = await target.getAccounts({ - chainId, - safeAddress, - onlyVerified: true, - }); - - const expected = verifiedAccounts.map(([account]) => account); - expect(actual).toEqual(expect.arrayContaining(expected)); - }); - - it('gets all email addresses associated with a given safe address', async () => { - const chainId = faker.number.int({ max: DB_CHAIN_ID_MAX_VALUE }).toString(); - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const verifiedAccounts: [Account, VerificationCode][] = [ - [ - accountBuilder() - .with('chainId', chainId) - .with('safeAddress', getAddress(safeAddress)) - .with('isVerified', true) - .build(), - verificationCodeBuilder().build(), - ], - [ - accountBuilder() - .with('chainId', chainId) - .with('safeAddress', getAddress(safeAddress)) - .with('isVerified', true) - .build(), - verificationCodeBuilder().build(), - ], - ]; - const nonVerifiedAccounts: [Account, VerificationCode][] = [ - [ - accountBuilder() - .with('chainId', chainId) - .with('safeAddress', getAddress(safeAddress)) - .with('isVerified', false) - .build(), - verificationCodeBuilder().build(), - ], - [ - accountBuilder() - .with('chainId', chainId) - .with('safeAddress', getAddress(safeAddress)) - .with('isVerified', false) - .build(), - verificationCodeBuilder().build(), - ], - ]; - for (const [account, verificationCode] of verifiedAccounts) { - await target.createAccount({ - chainId, - safeAddress, - emailAddress: account.emailAddress, - signer: account.signer, - code: verificationCode.code, - codeGenerationDate: verificationCode.generatedOn, - unsubscriptionToken: account.unsubscriptionToken, - }); - await target.verifyEmail({ - chainId: chainId, - safeAddress, - signer: account.signer, - }); - } - for (const [account, verificationCode] of nonVerifiedAccounts) { - await target.createAccount({ - chainId, - safeAddress, - emailAddress: account.emailAddress, - signer: account.signer, - code: verificationCode.code, - codeGenerationDate: verificationCode.generatedOn, - unsubscriptionToken: account.unsubscriptionToken, - }); - } - - const actual = await target.getAccounts({ - chainId, - safeAddress, - onlyVerified: false, - }); - - const expected = verifiedAccounts - .concat(nonVerifiedAccounts) - .map(([account]) => account); - expect(actual).toEqual(expect.arrayContaining(expected)); - }); - - it('deletes accounts successfully', async () => { - const chainId = faker.number.int({ max: DB_CHAIN_ID_MAX_VALUE }).toString(); - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const emailAddress = new EmailAddress(faker.internet.email()); - const signer = getAddress(faker.finance.ethereumAddress()); - const code = faker.number.int({ max: 999998 }).toString(); - const codeGenerationDate = faker.date.recent(); - const unsubscriptionToken = faker.string.uuid(); - - await target.createAccount({ - chainId, - safeAddress, - emailAddress, - signer, - code, - codeGenerationDate, - unsubscriptionToken, - }); - await target.deleteAccount({ - chainId, - safeAddress, - signer, - }); - - await expect( - target.getAccount({ - chainId, - safeAddress, - signer, - }), - ).rejects.toThrow(AccountDoesNotExistError); - }); - - it('deleting a non-existent account throws', async () => { - const chainId = faker.number.int({ max: DB_CHAIN_ID_MAX_VALUE }).toString(); - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const signer = getAddress(faker.finance.ethereumAddress()); - - await expect( - target.deleteAccount({ - chainId, - safeAddress, - signer, - }), - ).rejects.toThrow(AccountDoesNotExistError); - }); - - it('update from previously unverified emails successfully', async () => { - const chainId = faker.number.int({ max: DB_CHAIN_ID_MAX_VALUE }).toString(); - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const prevEmailAddress = new EmailAddress(faker.internet.email()); - const emailAddress = new EmailAddress(faker.internet.email()); - const signer = getAddress(faker.finance.ethereumAddress()); - const unsubscriptionToken = faker.string.uuid(); - - await target.createAccount({ - chainId, - safeAddress, - emailAddress: prevEmailAddress, - signer, - code: faker.string.numeric(), - codeGenerationDate: faker.date.recent(), - unsubscriptionToken, - }); - - const updatedAccount = await target.updateAccountEmail({ - chainId, - safeAddress, - emailAddress, - signer, - unsubscriptionToken, - }); - - expect(updatedAccount).toMatchObject({ - chainId, - emailAddress, - isVerified: false, - safeAddress, - signer, - }); - }); - - it('update from previously verified emails successfully', async () => { - const chainId = faker.number.int({ max: DB_CHAIN_ID_MAX_VALUE }).toString(); - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const prevEmailAddress = new EmailAddress(faker.internet.email()); - const emailAddress = new EmailAddress(faker.internet.email()); - const signer = getAddress(faker.finance.ethereumAddress()); - const unsubscriptionToken = faker.string.uuid(); - await target.createAccount({ - chainId, - safeAddress, - emailAddress: prevEmailAddress, - signer, - code: faker.string.numeric(), - codeGenerationDate: faker.date.recent(), - unsubscriptionToken, - }); - await target.verifyEmail({ - chainId, - safeAddress, - signer, - }); - - const updatedAccount = await target.updateAccountEmail({ - chainId, - safeAddress, - emailAddress, - signer, - unsubscriptionToken, - }); - - expect(updatedAccount).toMatchObject({ - chainId, - emailAddress, - isVerified: false, - safeAddress, - signer, - }); - }); - - it('updating a non-existent account throws', async () => { - const chainId = faker.number.int({ max: DB_CHAIN_ID_MAX_VALUE }); - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const emailAddress = new EmailAddress(faker.internet.email()); - const signer = getAddress(faker.finance.ethereumAddress()); - const unsubscriptionToken = faker.string.uuid(); - - await expect( - target.updateAccountEmail({ - chainId: chainId.toString(), - safeAddress, - emailAddress, - signer, - unsubscriptionToken, - }), - ).rejects.toThrow(AccountDoesNotExistError); - }); - - it('Has zero subscriptions when creating new account', async () => { - const chainId = faker.number.int({ max: DB_CHAIN_ID_MAX_VALUE }).toString(); - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const emailAddress = new EmailAddress(faker.internet.email()); - const signer = getAddress(faker.finance.ethereumAddress()); - const code = faker.string.numeric({ length: 6 }); - const codeGenerationDate = faker.date.recent(); - const unsubscriptionToken = faker.string.uuid(); - - await target.createAccount({ - chainId, - safeAddress, - emailAddress, - signer, - code, - codeGenerationDate, - unsubscriptionToken, - }); - const actual = await target.getSubscriptions({ - chainId, - safeAddress, - signer, - }); - - expect(actual).toHaveLength(0); - }); - - it('subscribes to category', async () => { - const chainId = faker.number.int({ max: DB_CHAIN_ID_MAX_VALUE }).toString(); - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const emailAddress = new EmailAddress(faker.internet.email()); - const signer = getAddress(faker.finance.ethereumAddress()); - const code = faker.string.numeric({ length: 6 }); - const codeGenerationDate = faker.date.recent(); - const unsubscriptionToken = faker.string.uuid(); - const subscription = { - key: faker.word.sample(), - name: faker.word.words(2), - }; - await sql`INSERT INTO notification_types (key, name) - VALUES (${subscription.key}, ${subscription.name})`; - await target.createAccount({ - chainId, - safeAddress, - emailAddress, - signer, - code, - codeGenerationDate, - unsubscriptionToken, - }); - - await target.subscribe({ - chainId, - safeAddress, - signer, - notificationTypeKey: subscription.key, - }); - - const subscriptions = await target.getSubscriptions({ - chainId, - safeAddress, - signer, - }); - expect(subscriptions).toContainEqual(subscription); - }); - - it('unsubscribes from a category successfully', async () => { - const chainId = faker.number.int({ max: DB_CHAIN_ID_MAX_VALUE }).toString(); - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const emailAddress = new EmailAddress(faker.internet.email()); - const signer = getAddress(faker.finance.ethereumAddress()); - const code = faker.string.numeric({ length: 6 }); - const codeGenerationDate = faker.date.recent(); - const unsubscriptionToken = faker.string.uuid(); - const subscriptions = [ - { - key: faker.word.sample(), - name: faker.word.words(2), - }, - { - key: faker.word.sample(), - name: faker.word.words(2), - }, - ]; - await sql`INSERT INTO notification_types (key, name) - VALUES (${subscriptions[0].key}, ${subscriptions[0].name}), - (${subscriptions[1].key}, ${subscriptions[1].name})`; - await target.createAccount({ - chainId, - safeAddress, - emailAddress, - signer, - code, - codeGenerationDate, - unsubscriptionToken, - }); - // Subscribe to two categories - await target.subscribe({ - chainId, - safeAddress, - signer, - notificationTypeKey: subscriptions[0].key, - }); - await target.subscribe({ - chainId, - safeAddress, - signer, - notificationTypeKey: subscriptions[1].key, - }); - - // Unsubscribe from one category - const result = await target.unsubscribe({ - notificationTypeKey: subscriptions[0].key, - token: unsubscriptionToken, - }); - - const currentSubscriptions = await target.getSubscriptions({ - chainId, - safeAddress, - signer, - }); - expect(result).toContainEqual(subscriptions[0]); - expect(currentSubscriptions).not.toContainEqual(subscriptions[0]); - expect(currentSubscriptions).toContainEqual(subscriptions[1]); - }); - - it('unsubscribes from a non-existent category', async () => { - const chainId = faker.number.int({ max: DB_CHAIN_ID_MAX_VALUE }).toString(); - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const emailAddress = new EmailAddress(faker.internet.email()); - const signer = getAddress(faker.finance.ethereumAddress()); - const code = faker.string.numeric({ length: 6 }); - const codeGenerationDate = faker.date.recent(); - const unsubscriptionToken = faker.string.uuid(); - const nonExistentCategory = faker.word.sample(); - await target.createAccount({ - chainId, - safeAddress, - emailAddress, - signer, - code, - codeGenerationDate, - unsubscriptionToken, - }); - - // Unsubscribe from one category - const result = await target.unsubscribe({ - notificationTypeKey: nonExistentCategory, - token: unsubscriptionToken, - }); - - expect(result).toHaveLength(0); - }); - - it('unsubscribes with wrong token', async () => { - const chainId = faker.number.int({ max: DB_CHAIN_ID_MAX_VALUE }).toString(); - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const emailAddress = new EmailAddress(faker.internet.email()); - const signer = getAddress(faker.finance.ethereumAddress()); - const code = faker.string.numeric({ length: 6 }); - const codeGenerationDate = faker.date.recent(); - const unsubscriptionToken = faker.string.uuid(); - const subscriptions = [ - { - key: faker.word.sample(), - name: faker.word.words(2), - }, - ]; - await target.createAccount({ - chainId, - safeAddress, - emailAddress, - signer, - code, - codeGenerationDate, - unsubscriptionToken, - }); - - // Unsubscribe from one category - const result = await target.unsubscribe({ - notificationTypeKey: subscriptions[0].key, - token: faker.string.uuid(), - }); - - const currentSubscriptions = await target.getSubscriptions({ - chainId, - safeAddress, - signer, - }); - expect(result).toHaveLength(0); - expect(currentSubscriptions).toHaveLength(0); - }); - - it('unsubscribes from all categories successfully', async () => { - const chainId = faker.number.int({ max: DB_CHAIN_ID_MAX_VALUE }).toString(); - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const emailAddress = new EmailAddress(faker.internet.email()); - const signer = getAddress(faker.finance.ethereumAddress()); - const code = faker.string.numeric({ length: 6 }); - const codeGenerationDate = faker.date.recent(); - const unsubscriptionToken = faker.string.uuid(); - const subscriptions = [ - { - key: faker.word.sample(), - name: faker.word.words(2), - }, - { - key: faker.word.sample(), - name: faker.word.words(2), - }, - ]; - await sql`INSERT INTO notification_types (key, name) - VALUES (${subscriptions[0].key}, ${subscriptions[0].name}), - (${subscriptions[1].key}, ${subscriptions[1].name})`; - await target.createAccount({ - chainId, - safeAddress, - emailAddress, - signer, - code, - codeGenerationDate, - unsubscriptionToken, - }); - // Subscribe to two categories - await target.subscribe({ - chainId, - safeAddress, - signer, - notificationTypeKey: subscriptions[0].key, - }); - await target.subscribe({ - chainId, - safeAddress, - signer, - notificationTypeKey: subscriptions[1].key, - }); - - const result = await target.unsubscribeAll({ - token: unsubscriptionToken, - }); - - const currentSubscriptions = await target.getSubscriptions({ - chainId, - safeAddress, - signer, - }); - expect(currentSubscriptions).toHaveLength(0); - expect(result).toHaveLength(2); - expect(result).toContainEqual(subscriptions[0]); - expect(result).toContainEqual(subscriptions[1]); - }); -}); diff --git a/src/datasources/account/account.datasource.ts b/src/datasources/account/account.datasource.ts deleted file mode 100644 index 8abd10bedd..0000000000 --- a/src/datasources/account/account.datasource.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import postgres from 'postgres'; -import { IAccountDataSource } from '@/domain/interfaces/account.datasource.interface'; -import { AccountDoesNotExistError } from '@/domain/account/errors/account-does-not-exist.error'; -import { - Account as DomainAccount, - EmailAddress, - VerificationCode as DomainVerificationCode, -} from '@/domain/account/entities/account.entity'; -import { Subscription as DomainSubscription } from '@/domain/account/entities/subscription.entity'; -import { VerificationCodeDoesNotExistError } from '@/datasources/account/errors/verification-code-does-not-exist.error'; -import { getAddress } from 'viem'; - -interface Account { - id: number; - chain_id: number; - email_address: string; - safe_address: string; - signer: string; - verified: boolean; - unsubscription_token: string; -} - -interface VerificationCode { - account_id: number; - code: string; - generated_on: Date; - sent_on: Date | null; -} - -interface Subscription { - id: number; - key: string; - name: string; -} - -@Injectable() -export class AccountDataSource implements IAccountDataSource { - constructor(@Inject('DB_INSTANCE') private readonly sql: postgres.Sql) {} - - async getAccount(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - }): Promise { - const [account] = await this.sql`SELECT * - FROM accounts - WHERE chain_id = ${args.chainId} - AND safe_address = ${args.safeAddress} - AND signer = ${args.signer}`; - if (!account) { - throw new AccountDoesNotExistError( - args.chainId, - args.safeAddress, - args.signer, - ); - } - - return { - chainId: account.chain_id.toString(), - emailAddress: new EmailAddress(account.email_address), - isVerified: account.verified, - safeAddress: getAddress(account.safe_address), - signer: getAddress(account.signer), - unsubscriptionToken: account.unsubscription_token, - }; - } - - async getAccounts(args: { - chainId: string; - safeAddress: `0x${string}`; - onlyVerified: boolean; - }): Promise { - const onlyVerifiedQuery = this.sql`AND verified = true`; - const accounts = await this.sql`SELECT * - FROM accounts - WHERE chain_id = ${args.chainId} - AND safe_address = ${args.safeAddress} ${args.onlyVerified ? onlyVerifiedQuery : this.sql``};`; - - return accounts.map((account) => { - return { - chainId: account.chain_id.toString(), - emailAddress: new EmailAddress(account.email_address), - isVerified: account.verified, - safeAddress: getAddress(account.safe_address), - signer: getAddress(account.signer), - unsubscriptionToken: account.unsubscription_token, - }; - }); - } - - async getAccountVerificationCode(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - }): Promise { - const [verificationCode] = await this.sql`SELECT * - FROM verification_codes - inner join accounts on accounts.id = verification_codes.account_id - WHERE account_id = accounts.id - AND chain_id = ${args.chainId} - AND safe_address = ${args.safeAddress} - AND signer = ${args.signer} - `; - - if (!verificationCode) { - throw new VerificationCodeDoesNotExistError( - args.chainId, - args.safeAddress, - args.signer, - ); - } - - return { - code: verificationCode.code, - generatedOn: verificationCode.generated_on, - sentOn: verificationCode.sent_on, - }; - } - - async createAccount(args: { - chainId: string; - safeAddress: `0x${string}`; - emailAddress: EmailAddress; - signer: `0x${string}`; - code: string; - codeGenerationDate: Date; - unsubscriptionToken: string; - }): Promise<[DomainAccount, DomainVerificationCode]> { - const [createdAccount, verificationCode] = await this.sql.begin( - async (sql) => { - const [account] = await sql` - INSERT INTO accounts (chain_id, email_address, safe_address, signer, unsubscription_token) - VALUES (${args.chainId}, ${args.emailAddress.value}, ${args.safeAddress}, ${args.signer}, - ${args.unsubscriptionToken}) RETURNING * - `; - - const [verificationCode] = await sql` - INSERT INTO verification_codes (account_id, code, generated_on) - VALUES (${account.id}, ${args.code}, ${args.codeGenerationDate}) RETURNING * - `; - return [account, verificationCode]; - }, - ); - - return [ - { - chainId: createdAccount.chain_id.toString(), - emailAddress: new EmailAddress(createdAccount.email_address), - isVerified: createdAccount.verified, - safeAddress: getAddress(createdAccount.safe_address), - signer: getAddress(createdAccount.signer), - unsubscriptionToken: createdAccount.unsubscription_token, - }, - { - code: verificationCode.code, - generatedOn: verificationCode.generated_on, - sentOn: verificationCode.sent_on, - }, - ]; - } - - async setEmailVerificationCode(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - code: string; - codeGenerationDate: Date; - }): Promise { - const [verificationCode] = await this.sql` - INSERT INTO verification_codes (account_id, code, generated_on) - (SELECT id, ${args.code}, ${args.codeGenerationDate} - FROM accounts - WHERE chain_id = ${args.chainId} - AND safe_address = ${args.safeAddress} - AND signer = ${args.signer} - AND verified = false) ON CONFLICT (account_id) - DO - UPDATE SET code = ${args.code}, - generated_on = ${args.codeGenerationDate} - RETURNING * - `; - - return { - code: verificationCode.code, - generatedOn: verificationCode.generated_on, - sentOn: verificationCode.sent_on, - } as DomainVerificationCode; - } - - async setEmailVerificationSentDate(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - sentOn: Date; - }): Promise { - const [verificationCode] = await this.sql< - VerificationCode[] - >`UPDATE verification_codes - SET sent_on = ${args.sentOn} FROM accounts - WHERE chain_id = ${args.chainId} - AND safe_address = ${args.safeAddress} - AND signer = ${args.signer} - AND account_id = accounts.id - RETURNING *`; - if (!verificationCode) { - throw new VerificationCodeDoesNotExistError( - args.chainId, - args.safeAddress, - args.signer, - ); - } - - return { - code: verificationCode.code, - generatedOn: verificationCode.generated_on, - sentOn: verificationCode.sent_on, - }; - } - - async verifyEmail(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - }): Promise { - await this.sql.begin(async (sql) => { - const [verifiedAccount] = await sql`UPDATE accounts - SET verified = true - WHERE chain_id = ${args.chainId} - AND safe_address = ${args.safeAddress} - AND signer = ${args.signer} RETURNING *`; - if (!verifiedAccount) { - throw new AccountDoesNotExistError( - args.chainId, - args.safeAddress, - args.signer, - ); - } - - await sql`DELETE - FROM verification_codes - WHERE account_id = ${verifiedAccount.id}`; - - return verifiedAccount; - }); - } - - async deleteAccount(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - }): Promise { - const [deletedAccount] = await this.sql`DELETE - FROM accounts - WHERE chain_id = ${args.chainId} - AND safe_address = ${args.safeAddress} - AND signer = ${args.signer} RETURNING *`; - if (!deletedAccount) { - throw new AccountDoesNotExistError( - args.chainId, - args.safeAddress, - args.signer, - ); - } - - return { - chainId: deletedAccount.chain_id.toString(), - emailAddress: new EmailAddress(deletedAccount.email_address), - isVerified: deletedAccount.verified, - safeAddress: getAddress(deletedAccount.safe_address), - signer: getAddress(deletedAccount.signer), - unsubscriptionToken: deletedAccount.unsubscription_token, - }; - } - - async updateAccountEmail(args: { - chainId: string; - safeAddress: `0x${string}`; - emailAddress: EmailAddress; - signer: `0x${string}`; - unsubscriptionToken: string; - }): Promise { - const [updatedAccount] = await this.sql`UPDATE accounts - SET email_address = ${args.emailAddress.value}, - verified = false, - unsubscription_token = ${args.unsubscriptionToken} - WHERE chain_id = ${args.chainId} - AND safe_address = ${args.safeAddress} - AND signer = ${args.signer} RETURNING *`; - if (!updatedAccount) { - throw new AccountDoesNotExistError( - args.chainId, - args.safeAddress, - args.signer, - ); - } - - return { - chainId: updatedAccount.chain_id.toString(), - emailAddress: new EmailAddress(updatedAccount.email_address), - isVerified: updatedAccount.verified, - safeAddress: getAddress(updatedAccount.safe_address), - signer: getAddress(updatedAccount.signer), - unsubscriptionToken: updatedAccount.unsubscription_token, - }; - } - - async getSubscriptions(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - }): Promise { - const subscriptions = await this.sql`SELECT key, name - FROM notification_types - INNER JOIN subscriptions subs - on notification_types.id = subs.notification_type - INNER JOIN accounts emails on emails.id = subs.account_id - WHERE chain_id = ${args.chainId} - AND safe_address = ${args.safeAddress} - AND signer = ${args.signer}`; - return subscriptions.map((subscription) => ({ - key: subscription.key, - name: subscription.name, - })); - } - - async subscribe(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - notificationTypeKey: string; - }): Promise { - const subscriptions = await this - .sql`INSERT INTO subscriptions (account_id, notification_type) - (SELECT accounts.id AS account_id, - notification_types.id AS subscription_id - FROM accounts - CROSS JOIN notification_types - WHERE accounts.chain_id = ${args.chainId} - AND accounts.safe_address = ${args.safeAddress} - AND accounts.signer = ${args.signer} - AND notification_types.key = ${args.notificationTypeKey}) RETURNING *`; - return subscriptions.map((s) => ({ - key: s.key, - name: s.name, - })); - } - - async unsubscribe(args: { - notificationTypeKey: string; - token: string; - }): Promise { - const subscriptions = await this.sql`DELETE - FROM subscriptions USING accounts, notification_types - WHERE accounts.unsubscription_token = ${args.token} - AND notification_types.key = ${args.notificationTypeKey} - AND subscriptions.account_id = accounts.id - AND subscriptions.notification_type = notification_types.id - RETURNING notification_types.key - , notification_types.name`; - return subscriptions.map((s) => ({ - key: s.key, - name: s.name, - })); - } - - async unsubscribeAll(args: { token: string }): Promise { - const subscriptions = await this.sql` - WITH deleted_subscriptions AS ( - DELETE - FROM subscriptions - WHERE account_id = (SELECT id - FROM accounts - WHERE unsubscription_token = ${args.token}) RETURNING notification_type) - SELECT subs.key, subs.name - FROM notification_types subs - JOIN deleted_subscriptions deleted_subs ON subs.id = deleted_subs.notification_type - `; - return subscriptions.map((s) => ({ - key: s.key, - name: s.name, - })); - } -} diff --git a/src/datasources/account/errors/verification-code-does-not-exist.error.ts b/src/datasources/account/errors/verification-code-does-not-exist.error.ts deleted file mode 100644 index 0820795009..0000000000 --- a/src/datasources/account/errors/verification-code-does-not-exist.error.ts +++ /dev/null @@ -1,10 +0,0 @@ -export class VerificationCodeDoesNotExistError extends Error { - readonly signer: string; - - constructor(chainId: string, safeAddress: string, signer: string) { - super( - `Verification code for account of ${signer} of ${safeAddress} on chain ${chainId} does not exist.`, - ); - this.signer = signer; - } -} diff --git a/src/datasources/accounts/__tests__/test.accounts.datasource.modulte.ts b/src/datasources/accounts/__tests__/test.accounts.datasource.modulte.ts new file mode 100644 index 0000000000..ae694ac5e4 --- /dev/null +++ b/src/datasources/accounts/__tests__/test.accounts.datasource.modulte.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { IAccountsDatasource } from '@/domain/interfaces/accounts.datasource.interface'; + +const accountsDatasource = { + getAccount: jest.fn(), + createAccount: jest.fn(), +} as jest.MockedObjectDeep; + +@Module({ + providers: [ + { + provide: IAccountsDatasource, + useFactory: (): jest.MockedObjectDeep => { + return jest.mocked(accountsDatasource); + }, + }, + ], + exports: [IAccountsDatasource], +}) +export class TestAccountsDataSourceModule {} diff --git a/src/datasources/accounts/accounts.datasource.module.ts b/src/datasources/accounts/accounts.datasource.module.ts new file mode 100644 index 0000000000..477622904b --- /dev/null +++ b/src/datasources/accounts/accounts.datasource.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PostgresDatabaseModule } from '@/datasources/db/postgres-database.module'; +import { AccountsDatasource } from '@/datasources/accounts/accounts.datasource'; +import { IAccountsDatasource } from '@/domain/interfaces/accounts.datasource.interface'; + +@Module({ + imports: [PostgresDatabaseModule], + providers: [{ provide: IAccountsDatasource, useClass: AccountsDatasource }], + exports: [IAccountsDatasource], +}) +export class AccountsDatasourceModule {} diff --git a/src/datasources/accounts/accounts.datasource.spec.ts b/src/datasources/accounts/accounts.datasource.spec.ts new file mode 100644 index 0000000000..e63c206bd7 --- /dev/null +++ b/src/datasources/accounts/accounts.datasource.spec.ts @@ -0,0 +1,106 @@ +import configuration from '@/config/entities/__tests__/configuration'; +import { AccountsDatasource } from '@/datasources/accounts/accounts.datasource'; +import { ILoggingService } from '@/logging/logging.interface'; +import { faker } from '@faker-js/faker'; +import fs from 'node:fs'; +import path from 'node:path'; +import postgres from 'postgres'; +import shift from 'postgres-shift'; +import { getAddress } from 'viem'; + +const config = configuration(); + +const isCIContext = process.env.CI?.toLowerCase() === 'true'; + +const sql = postgres({ + host: config.db.postgres.host, + port: parseInt(config.db.postgres.port), + db: config.db.postgres.database, + user: config.db.postgres.username, + password: config.db.postgres.password, + // If running on a CI context (e.g.: GitHub Actions), + // disable certificate pinning for the test execution + ssl: + isCIContext || !config.db.postgres.ssl.enabled + ? false + : { + requestCert: config.db.postgres.ssl.requestCert, + rejectUnauthorized: config.db.postgres.ssl.rejectUnauthorized, + ca: fs.readFileSync( + path.join(process.cwd(), 'db_config/test/server.crt'), + 'utf8', + ), + }, +}); + +const mockLoggingService = { + info: jest.fn(), + warn: jest.fn(), +} as jest.MockedObjectDeep; + +describe('AccountsDatasource tests', () => { + let target: AccountsDatasource; + + // Run pending migrations before tests + beforeAll(async () => { + await shift({ sql }); + }); + + beforeEach(() => { + target = new AccountsDatasource(sql, mockLoggingService); + }); + + afterEach(async () => { + await sql`TRUNCATE TABLE groups, accounts CASCADE`; + }); + + afterAll(async () => { + await sql.end(); + }); + + describe('createAccount', () => { + it('creates an account successfully', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + + const result = await target.createAccount(address); + + expect(result).toStrictEqual({ + id: expect.any(Number), + group_id: null, + address, + }); + }); + + it('throws when an account with the same address already exists', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + await target.createAccount(address); + + await expect(target.createAccount(address)).rejects.toThrow( + 'Error creating account.', + ); + }); + }); + + describe('getAccount', () => { + it('returns an account successfully', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + await target.createAccount(address); + + const result = await target.getAccount(address); + + expect(result).toStrictEqual({ + id: expect.any(Number), + group_id: null, + address, + }); + }); + + it('throws if no account is found', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + + await expect(target.getAccount(address)).rejects.toThrow( + 'Error getting account.', + ); + }); + }); +}); diff --git a/src/datasources/accounts/accounts.datasource.ts b/src/datasources/accounts/accounts.datasource.ts new file mode 100644 index 0000000000..5b6efcb298 --- /dev/null +++ b/src/datasources/accounts/accounts.datasource.ts @@ -0,0 +1,61 @@ +import { Account } from '@/datasources/accounts/entities/account.entity'; +import { IAccountsDatasource } from '@/domain/interfaces/accounts.datasource.interface'; +import { LoggingService, ILoggingService } from '@/logging/logging.interface'; +import { asError } from '@/logging/utils'; +import { + Inject, + Injectable, + NotFoundException, + UnprocessableEntityException, +} from '@nestjs/common'; +import postgres from 'postgres'; + +@Injectable() +export class AccountsDatasource implements IAccountsDatasource { + constructor( + @Inject('DB_INSTANCE') private readonly sql: postgres.Sql, + @Inject(LoggingService) private readonly loggingService: ILoggingService, + ) {} + + async createAccount(address: `0x${string}`): Promise { + const [account] = await this.sql<[Account]>`INSERT INTO + accounts (address) + VALUES + (${address}) RETURNING *`.catch( + (e) => { + this.loggingService.warn( + `Error creating account: ${asError(e).message}`, + ); + return []; + }, + ); + + if (!account) { + throw new UnprocessableEntityException('Error creating account.'); + } + + return account; + } + + async getAccount(address: `0x${string}`): Promise { + const [account] = await this.sql<[Account]>`SELECT + * + FROM + accounts + WHERE + address = ${address}`.catch( + (e) => { + this.loggingService.info( + `Error getting account: ${asError(e).message}`, + ); + return []; + }, + ); + + if (!account) { + throw new NotFoundException('Error getting account.'); + } + + return account; + } +} diff --git a/src/datasources/accounts/entities/__tests__/account.builder.ts b/src/datasources/accounts/entities/__tests__/account.builder.ts new file mode 100644 index 0000000000..5faf6b1dd4 --- /dev/null +++ b/src/datasources/accounts/entities/__tests__/account.builder.ts @@ -0,0 +1,11 @@ +import { IBuilder, Builder } from '@/__tests__/builder'; +import { Account } from '@/datasources/accounts/entities/account.entity'; +import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; + +export function accountBuilder(): IBuilder { + return new Builder() + .with('id', faker.number.int()) + .with('group_id', faker.number.int()) + .with('address', getAddress(faker.finance.ethereumAddress())); +} diff --git a/src/datasources/accounts/entities/__tests__/group.builder.ts b/src/datasources/accounts/entities/__tests__/group.builder.ts new file mode 100644 index 0000000000..aa2806160f --- /dev/null +++ b/src/datasources/accounts/entities/__tests__/group.builder.ts @@ -0,0 +1,7 @@ +import { IBuilder, Builder } from '@/__tests__/builder'; +import { Group } from '@/datasources/accounts/entities/group.entity'; +import { faker } from '@faker-js/faker'; + +export function groupBuilder(): IBuilder { + return new Builder().with('id', faker.number.int()); +} diff --git a/src/datasources/accounts/entities/account.entity.spec.ts b/src/datasources/accounts/entities/account.entity.spec.ts new file mode 100644 index 0000000000..dbfd3c7655 --- /dev/null +++ b/src/datasources/accounts/entities/account.entity.spec.ts @@ -0,0 +1,114 @@ +import { accountBuilder } from '@/datasources/accounts/entities/__tests__/account.builder'; +import { AccountSchema } from '@/datasources/accounts/entities/account.entity'; +import { getAddress } from 'viem'; +import { faker } from '@faker-js/faker'; + +describe('AccountSchema', () => { + it('should verify an Account', () => { + const account = accountBuilder().build(); + + const result = AccountSchema.safeParse(account); + + expect(result.success).toBe(true); + }); + + it.each(['id' as const, 'group_id' as const])( + 'should not verify an Account with a float %s', + (field) => { + const account = accountBuilder() + .with(field, faker.number.float()) + .build(); + + const result = AccountSchema.safeParse(account); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected: 'integer', + message: 'Expected integer, received float', + path: [field], + received: 'float', + }, + ]); + }, + ); + + it.each(['id' as const, 'group_id' as const])( + 'should not verify an Account with a string %s', + (field) => { + const account = accountBuilder().build(); + // @ts-expect-error - should be integers + account[field] = account[field].toString(); + + const result = AccountSchema.safeParse(account); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected: 'number', + message: 'Expected number, received string', + path: [field], + received: 'string', + }, + ]); + }, + ); + + it('should not verify an Account with a non-Ethereum address', () => { + const account = accountBuilder().with('address', '0x123').build(); + + const result = AccountSchema.safeParse(account); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'custom', + message: 'Invalid address', + path: ['address'], + }, + ]); + }); + + it('should checksum the address of an Account', () => { + const account = accountBuilder().build(); + // @ts-expect-error - address should be `0x${string}` + account.address = account.address.toLowerCase(); + + const result = AccountSchema.safeParse(account); + + expect(result.success && result.data.address).toBe( + getAddress(account.address), + ); + }); + + it('should not verify an invalid Account', () => { + const account = { + invalid: 'account', + }; + + const result = AccountSchema.safeParse(account); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected: 'number', + message: 'Required', + path: ['id'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'number', + message: 'Required', + path: ['group_id'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'string', + message: 'Required', + path: ['address'], + received: 'undefined', + }, + ]); + }); +}); diff --git a/src/datasources/accounts/entities/account.entity.ts b/src/datasources/accounts/entities/account.entity.ts new file mode 100644 index 0000000000..e3a428d390 --- /dev/null +++ b/src/datasources/accounts/entities/account.entity.ts @@ -0,0 +1,11 @@ +import { GroupSchema } from '@/datasources/accounts/entities/group.entity'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; +import { z } from 'zod'; + +export type Account = z.infer; + +export const AccountSchema = z.object({ + id: z.number().int(), + group_id: GroupSchema.shape.id, + address: AddressSchema, +}); diff --git a/src/datasources/accounts/entities/group.entity.spec.ts b/src/datasources/accounts/entities/group.entity.spec.ts new file mode 100644 index 0000000000..a22ad49108 --- /dev/null +++ b/src/datasources/accounts/entities/group.entity.spec.ts @@ -0,0 +1,65 @@ +import { groupBuilder } from '@/datasources/accounts/entities/__tests__/group.builder'; +import { GroupSchema } from '@/datasources/accounts/entities/group.entity'; +import { faker } from '@faker-js/faker'; + +describe('GroupSchema', () => { + it('should verify a Group', () => { + const group = groupBuilder().build(); + + const result = GroupSchema.safeParse(group); + + expect(result.success).toBe(true); + }); + + it('should not verify a Group with a float id', () => { + const group = groupBuilder().with('id', faker.number.float()).build(); + + const result = GroupSchema.safeParse(group); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected: 'integer', + message: 'Expected integer, received float', + path: ['id'], + received: 'float', + }, + ]); + }); + + it('should not verify a Group with a string id', () => { + const group = groupBuilder().build(); + // @ts-expect-error - id should be an integer + group.id = group.id.toString(); + + const result = GroupSchema.safeParse(group); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected: 'number', + message: 'Expected number, received string', + path: ['id'], + received: 'string', + }, + ]); + }); + + it('should not verify an invalid Group', () => { + const group = { + invalid: 'group', + }; + + const result = GroupSchema.safeParse(group); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected: 'number', + message: 'Required', + path: ['id'], + received: 'undefined', + }, + ]); + }); +}); diff --git a/src/datasources/accounts/entities/group.entity.ts b/src/datasources/accounts/entities/group.entity.ts new file mode 100644 index 0000000000..3e38dbc310 --- /dev/null +++ b/src/datasources/accounts/entities/group.entity.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export type Group = z.infer; + +export const GroupSchema = z.object({ + id: z.number().int(), +}); diff --git a/src/datasources/email-api/pushwoosh-api.service.spec.ts b/src/datasources/email-api/pushwoosh-api.service.spec.ts index bc14f4533a..d746c8eb2f 100644 --- a/src/datasources/email-api/pushwoosh-api.service.spec.ts +++ b/src/datasources/email-api/pushwoosh-api.service.spec.ts @@ -3,7 +3,7 @@ import { PushwooshApi } from '@/datasources/email-api/pushwoosh-api.service'; import { HttpErrorFactory } from '@/datasources/errors/http-error-factory'; import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; import { INetworkService } from '@/datasources/network/network.service.interface'; -import { CreateEmailMessageDto } from '@/domain/account/entities/create-email-message.dto.entity'; +import { CreateEmailMessageDto } from '@/domain/email/entities/create-email-message.dto.entity'; import { DataSourceError } from '@/domain/errors/data-source.error'; import { faker } from '@faker-js/faker'; diff --git a/src/datasources/email-api/pushwoosh-api.service.ts b/src/datasources/email-api/pushwoosh-api.service.ts index 192f4026a3..1e4eba70d5 100644 --- a/src/datasources/email-api/pushwoosh-api.service.ts +++ b/src/datasources/email-api/pushwoosh-api.service.ts @@ -5,7 +5,7 @@ import { INetworkService, NetworkService, } from '@/datasources/network/network.service.interface'; -import { CreateEmailMessageDto } from '@/domain/account/entities/create-email-message.dto.entity'; +import { CreateEmailMessageDto } from '@/domain/email/entities/create-email-message.dto.entity'; import { IEmailApi } from '@/domain/interfaces/email-api.interface'; @Injectable() diff --git a/src/domain/account/account.domain.module.ts b/src/domain/account/account.domain.module.ts deleted file mode 100644 index 986a339d02..0000000000 --- a/src/domain/account/account.domain.module.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Module } from '@nestjs/common'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { IAccountRepository } from '@/domain/account/account.repository.interface'; -import { AccountRepository } from '@/domain/account/account.repository'; -import { EmailApiModule } from '@/datasources/email-api/email-api.module'; -import { ISubscriptionRepository } from '@/domain/subscriptions/subscription.repository.interface'; -import { SubscriptionRepository } from '@/domain/subscriptions/subscription.repository'; -import { AuthRepositoryModule } from '@/domain/auth/auth.repository.interface'; -import { SafeRepositoryModule } from '@/domain/safe/safe.repository.interface'; - -@Module({ - imports: [ - AccountDataSourceModule, - EmailApiModule, - AuthRepositoryModule, - SafeRepositoryModule, - ], - providers: [ - { provide: IAccountRepository, useClass: AccountRepository }, - { - provide: ISubscriptionRepository, - useClass: SubscriptionRepository, - }, - ], - exports: [IAccountRepository], -}) -export class AccountDomainModule {} diff --git a/src/domain/account/account.repository.interface.ts b/src/domain/account/account.repository.interface.ts deleted file mode 100644 index 1c8400a37d..0000000000 --- a/src/domain/account/account.repository.interface.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Account } from '@/domain/account/entities/account.entity'; -import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; - -export const IAccountRepository = Symbol('IAccountRepository'); - -export interface IAccountRepository { - getAccount(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - authPayload: AuthPayload; - }): Promise; - - getAccounts(args: { - chainId: string; - safeAddress: `0x${string}`; - onlyVerified: boolean; - }): Promise; - - /** - * Creates a new account. - * - * @param args.chainId - the chain id of where the Safe is deployed - * @param args.safeAddress - the Safe address which we should create the account for - * @param args.emailAddress - the email address to store - * @param args.signer - the owner address to which we should link the account to - * @param args.authPayload - the payload to use for authorization - */ - createAccount(args: { - chainId: string; - safeAddress: `0x${string}`; - emailAddress: string; - signer: `0x${string}`; - authPayload: AuthPayload; - }): Promise; - - /** - * Resends the email verification code for an email registration - * - * @param args.chainId - the chain id of where the Safe is deployed - * @param args.safeAddress - the Safe address to which we should store the email address - * @param args.signer - the owner address to which we should link the email address to - * - * @throws {EmailAlreadyVerifiedError} - if the email is already verified - * @throws {ResendVerificationTimespanError} - - * if trying to trigger a resend within email.verificationCode.resendLockWindowMs - * @throws {InvalidVerificationCodeError} - if a verification code was not set - */ - resendEmailVerification(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - }): Promise; - - /** - * Verifies an email address with the provided code. - * - * @param args.chainId - the chain id of where the Safe is deployed - * @param args.safeAddress - the Safe address to which we should store the email address - * @param args.signer - the owner address to which we should link the email address to - * @param args.code - the user-provided code to validate the email verification - * - * @throws {InvalidVerificationCodeError} - if the verification code does not match the expected one - * @throws {InvalidVerificationCodeError} - if the verification code expired - */ - verifyEmailAddress(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - code: string; - }): Promise; - - /** - * Deletes an account. - * - * @param args.chainId - the chain id of where the Safe is deployed - * @param args.safeAddress - the Safe address of the account to be removed - * @param args.signer - the signer address of the account to be removed - * @param args.authPayload - the payload to use for authorization - */ - deleteAccount(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - authPayload: AuthPayload; - }): Promise; - - /** - * Edits an email entry. - * - * @param args.chainId - the chain id of where the Safe is deployed - * @param args.safeAddress - the Safe address to which we should store the email address - * @param args.emailAddress - the email address to store - * @param args.signer - the owner address to which we should link the email address to - * @param args.authPayload - the payload to use for authorization - * - * @throws {EmailEditMatchesError} - if trying to apply edit with same email address as current one - */ - editEmail(args: { - chainId: string; - safeAddress: `0x${string}`; - emailAddress: string; - signer: `0x${string}`; - authPayload: AuthPayload; - }): Promise; -} diff --git a/src/domain/account/account.repository.ts b/src/domain/account/account.repository.ts deleted file mode 100644 index 2ac5153516..0000000000 --- a/src/domain/account/account.repository.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { IAccountDataSource } from '@/domain/interfaces/account.datasource.interface'; -import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; -import codeGenerator from '@/domain/account/code-generator'; -import { - Account, - EmailAddress, - VerificationCode, -} from '@/domain/account/entities/account.entity'; -import { IAccountRepository } from '@/domain/account/account.repository.interface'; -import { AccountSaveError } from '@/domain/account/errors/account-save.error'; -import { ResendVerificationTimespanError } from '@/domain/account/errors/verification-timeframe.error'; -import { IConfigurationService } from '@/config/configuration.service.interface'; -import { EmailAlreadyVerifiedError } from '@/domain/account/errors/email-already-verified.error'; -import { InvalidVerificationCodeError } from '@/domain/account/errors/invalid-verification-code.error'; -import { EmailEditMatchesError } from '@/domain/account/errors/email-edit-matches.error'; -import { IEmailApi } from '@/domain/interfaces/email-api.interface'; -import crypto from 'crypto'; -import { ISubscriptionRepository } from '@/domain/subscriptions/subscription.repository.interface'; -import { SubscriptionRepository } from '@/domain/subscriptions/subscription.repository'; -import { AccountDoesNotExistError } from '@/domain/account/errors/account-does-not-exist.error'; -import { ILoggingService, LoggingService } from '@/logging/logging.interface'; -import { VerificationCodeDoesNotExistError } from '@/datasources/account/errors/verification-code-does-not-exist.error'; -import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; -import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; - -@Injectable() -export class AccountRepository implements IAccountRepository { - private readonly verificationCodeResendLockWindowMs: number; - private readonly verificationCodeTtlMs: number; - private static readonly VERIFICATION_CODE_EMAIL_SUBJECT = 'Verification code'; - - constructor( - @Inject(IAccountDataSource) - private readonly accountDataSource: IAccountDataSource, - @Inject(IConfigurationService) - private readonly configurationService: IConfigurationService, - @Inject(IEmailApi) private readonly emailApi: IEmailApi, - @Inject(ISubscriptionRepository) - private readonly subscriptionRepository: ISubscriptionRepository, - @Inject(LoggingService) private readonly loggingService: ILoggingService, - @Inject(ISafeRepository) private readonly safeRepository: ISafeRepository, - ) { - this.verificationCodeResendLockWindowMs = - this.configurationService.getOrThrow( - 'email.verificationCode.resendLockWindowMs', - ); - this.verificationCodeTtlMs = this.configurationService.getOrThrow( - 'email.verificationCode.ttlMs', - ); - } - - private _generateCode(): string { - const verificationCode = codeGenerator(); - // Pads the final verification code to 6 characters - // The generated code might have less than 6 digits so the version to be - // validated against should account with the leading zeroes - return verificationCode.toString().padStart(6, '0'); - } - - async getAccount(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - authPayload: AuthPayload; - }): Promise { - if ( - !args.authPayload.isForChain(args.chainId) || - !args.authPayload.isForSigner(args.signer) - ) { - throw new UnauthorizedException(); - } - - return this.accountDataSource.getAccount({ - chainId: args.chainId, - safeAddress: args.safeAddress, - signer: args.signer, - }); - } - - getAccounts(args: { - chainId: string; - safeAddress: `0x${string}`; - onlyVerified: boolean; - }): Promise { - return this.accountDataSource.getAccounts(args); - } - - async createAccount(args: { - chainId: string; - safeAddress: `0x${string}`; - emailAddress: string; - signer: `0x${string}`; - authPayload: AuthPayload; - }): Promise { - const email = new EmailAddress(args.emailAddress); - const verificationCode = this._generateCode(); - - if ( - !args.authPayload.isForChain(args.chainId) || - !args.authPayload.isForSigner(args.signer) - ) { - throw new UnauthorizedException(); - } - - // Check after AuthPayload check to avoid unnecessary request - const isOwner = await this.safeRepository - .isOwner({ - safeAddress: args.safeAddress, - chainId: args.chainId, - address: args.signer, - }) - // Swallow error to avoid leaking information - .catch(() => false); - if (!isOwner) { - throw new UnauthorizedException(); - } - - try { - await this.accountDataSource.createAccount({ - chainId: args.chainId, - code: verificationCode, - emailAddress: email, - safeAddress: args.safeAddress, - signer: args.signer, - codeGenerationDate: new Date(), - unsubscriptionToken: crypto.randomUUID(), - }); - // New account registrations should be subscribed to the Account Recovery category - await this.subscriptionRepository.subscribe({ - chainId: args.chainId, - signer: args.signer, - safeAddress: args.safeAddress, - notificationTypeKey: SubscriptionRepository.CATEGORY_ACCOUNT_RECOVERY, - }); - this._sendEmailVerification({ - ...args, - code: verificationCode, - }); - } catch (e) { - throw new AccountSaveError(args.chainId, args.safeAddress, args.signer); - } - } - - async resendEmailVerification(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - }): Promise { - const account = await this.accountDataSource.getAccount(args); - - // If the account was already verified, we should not send out a new - // verification code - if (account.isVerified) { - throw new EmailAlreadyVerifiedError(args); - } - - const verificationCode: VerificationCode | null = - await this.accountDataSource - .getAccountVerificationCode(args) - .catch((reason) => { - this.loggingService.warn(reason); - return null; - }); - - // If there's a date for when the verification was sent out, - // check if timespan is still valid. - if (verificationCode?.sentOn) { - const timespanMs = Date.now() - verificationCode.sentOn.getTime(); - if (timespanMs < this.verificationCodeResendLockWindowMs) { - throw new ResendVerificationTimespanError({ - ...args, - timespanMs, - lockWindowMs: this.verificationCodeResendLockWindowMs, - }); - } - } - - if (!this._isEmailVerificationCodeValid(verificationCode)) { - // Expired or non-existent code. Generate new one - await this.accountDataSource.setEmailVerificationCode({ - chainId: args.chainId, - safeAddress: args.safeAddress, - signer: args.signer, - code: this._generateCode(), - codeGenerationDate: new Date(), - }); - } - - const currentVerificationCode = - await this.accountDataSource.getAccountVerificationCode(args); - - this._sendEmailVerification({ - chainId: args.chainId, - safeAddress: args.safeAddress, - signer: args.signer, - code: currentVerificationCode.code, - emailAddress: account.emailAddress.value, - }); - } - - async verifyEmailAddress(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - code: string; - }): Promise { - const account = await this.accountDataSource.getAccount(args); - - if (account.isVerified) { - // account is already verified, so we don't need to perform further checks - throw new EmailAlreadyVerifiedError(args); - } - - let verificationCode: VerificationCode; - try { - verificationCode = - await this.accountDataSource.getAccountVerificationCode(args); - } catch (e) { - // If we attempt to verify an email is done without a verification code in place, - // Send a new code to the client's email address for verification - this.loggingService.warn(e); - if (e instanceof VerificationCodeDoesNotExistError) { - await this.resendEmailVerification(args); - } - // throw an error to the clients informing that the email address needs to be verified with a new code - throw new Error('Email needs to be verified again'); - } - - if (verificationCode.code !== args.code) { - throw new InvalidVerificationCodeError(args); - } - - if (!this._isEmailVerificationCodeValid(verificationCode)) { - throw new InvalidVerificationCodeError(args); - } - - // TODO: it is possible that when verifying the email address, a new code generation was triggered - await this.accountDataSource.verifyEmail({ - chainId: args.chainId, - safeAddress: args.safeAddress, - signer: args.signer, - }); - } - - async deleteAccount(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - authPayload: AuthPayload; - }): Promise { - if ( - !args.authPayload.isForChain(args.chainId) || - !args.authPayload.isForSigner(args.signer) - ) { - throw new UnauthorizedException(); - } - - try { - const account = await this.accountDataSource.getAccount({ - chainId: args.chainId, - safeAddress: args.safeAddress, - signer: args.signer, - }); - // If there is an error deleting the email address, - // do not delete the respective account as we still need to get the email - // for future deletions requests - await this.emailApi.deleteEmailAddress({ - emailAddress: account.emailAddress.value, - }); - await this.accountDataSource.deleteAccount({ - chainId: args.chainId, - safeAddress: args.safeAddress, - signer: args.signer, - }); - } catch (error) { - this.loggingService.warn(error); - // If there is no account, do not throw in order not to signal its existence - if (!(error instanceof AccountDoesNotExistError)) { - throw error; - } - } - } - - async editEmail(args: { - chainId: string; - safeAddress: `0x${string}`; - emailAddress: string; - signer: `0x${string}`; - authPayload: AuthPayload; - }): Promise { - if ( - !args.authPayload.isForChain(args.chainId) || - !args.authPayload.isForSigner(args.signer) - ) { - throw new UnauthorizedException(); - } - - const account = await this.accountDataSource.getAccount({ - chainId: args.chainId, - safeAddress: args.safeAddress, - signer: args.signer, - }); - const newEmail = new EmailAddress(args.emailAddress); - - if (newEmail.value === account.emailAddress.value) { - throw new EmailEditMatchesError(args); - } - - const newVerificationCode = this._generateCode(); - - await this.accountDataSource.updateAccountEmail({ - chainId: args.chainId, - emailAddress: newEmail, - safeAddress: args.safeAddress, - signer: args.signer, - unsubscriptionToken: crypto.randomUUID(), - }); - await this.accountDataSource.setEmailVerificationCode({ - chainId: args.chainId, - code: newVerificationCode, - signer: args.signer, - codeGenerationDate: new Date(), - safeAddress: args.safeAddress, - }); - this._sendEmailVerification({ - chainId: args.chainId, - safeAddress: args.safeAddress, - signer: args.signer, - emailAddress: args.emailAddress, - code: newVerificationCode, - }); - } - - private _isEmailVerificationCodeValid( - verificationCode: VerificationCode | null, - ): boolean { - if (!verificationCode || !verificationCode.generatedOn) return false; - const window = Date.now() - verificationCode.generatedOn.getTime(); - return window < this.verificationCodeTtlMs; - } - - /** - * Sends the verification email to the target {@link args.emailAddress} - * - * This function returns "immediately" so the result of this operation won't - * be known to the caller. - * - * @private - */ - private _sendEmailVerification(args: { - signer: `0x${string}`; - chainId: string; - code: string; - emailAddress: string; - safeAddress: `0x${string}`; - }): void { - this.emailApi - .createMessage({ - to: [args.emailAddress], - template: this.configurationService.getOrThrow( - 'email.templates.verificationCode', - ), - subject: AccountRepository.VERIFICATION_CODE_EMAIL_SUBJECT, - substitutions: { verificationCode: args.code }, - }) - .catch(() => { - this.loggingService.warn(`Error sending verification email.`); - }) - .then(() => - // Update verification-sent date on a successful response - this.accountDataSource.setEmailVerificationSentDate({ - chainId: args.chainId, - safeAddress: args.safeAddress, - signer: args.signer, - sentOn: new Date(), - }), - ) - .catch(() => - this.loggingService.warn( - 'Error updating email verification sent date.', - ), - ); - } -} diff --git a/src/domain/account/code-generator.ts b/src/domain/account/code-generator.ts deleted file mode 100644 index fcfa88bf69..0000000000 --- a/src/domain/account/code-generator.ts +++ /dev/null @@ -1,10 +0,0 @@ -import crypto from 'crypto'; - -/** - * Generates a random number up to six digits - */ -export default function (): number { - const array = new Uint32Array(1); - crypto.getRandomValues(array); - return array[0] % 1_000_000; -} diff --git a/src/domain/account/entities/__tests__/account.builder.ts b/src/domain/account/entities/__tests__/account.builder.ts deleted file mode 100644 index 3236291cb1..0000000000 --- a/src/domain/account/entities/__tests__/account.builder.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Builder, IBuilder } from '@/__tests__/builder'; -import { faker } from '@faker-js/faker'; -import { - Account, - EmailAddress, -} from '@/domain/account/entities/account.entity'; -import { getAddress } from 'viem'; - -export function accountBuilder(): IBuilder { - return new Builder() - .with('chainId', faker.string.numeric()) - .with('emailAddress', new EmailAddress(faker.internet.email())) - .with('isVerified', faker.datatype.boolean()) - .with('safeAddress', getAddress(faker.finance.ethereumAddress())) - .with('signer', getAddress(faker.finance.ethereumAddress())) - .with('unsubscriptionToken', faker.string.uuid()); -} diff --git a/src/domain/account/entities/__tests__/subscription.builder.ts b/src/domain/account/entities/__tests__/subscription.builder.ts deleted file mode 100644 index 0559e6458b..0000000000 --- a/src/domain/account/entities/__tests__/subscription.builder.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Builder, IBuilder } from '@/__tests__/builder'; -import { Subscription } from '@/domain/account/entities/subscription.entity'; -import { faker } from '@faker-js/faker'; - -export function subscriptionBuilder(): IBuilder { - return new Builder() - .with('key', faker.word.sample()) - .with('name', faker.word.words()); -} diff --git a/src/domain/account/entities/__tests__/verification-code.builder.ts b/src/domain/account/entities/__tests__/verification-code.builder.ts deleted file mode 100644 index 1f9ae29c9f..0000000000 --- a/src/domain/account/entities/__tests__/verification-code.builder.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Builder, IBuilder } from '@/__tests__/builder'; -import { VerificationCode } from '@/domain/account/entities/account.entity'; -import { faker } from '@faker-js/faker'; - -export function verificationCodeBuilder(): IBuilder { - return new Builder() - .with('code', faker.string.numeric({ length: 6 })) - .with('generatedOn', new Date()) - .with('sentOn', null); -} diff --git a/src/domain/account/entities/account.entity.spec.ts b/src/domain/account/entities/account.entity.spec.ts deleted file mode 100644 index c12ce2a64c..0000000000 --- a/src/domain/account/entities/account.entity.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { - EmailAddress, - InvalidEmailFormatError, -} from '@/domain/account/entities/account.entity'; -import { faker } from '@faker-js/faker'; - -describe('Email entity tests', () => { - it.each(['test@email.com', faker.internet.email()])( - '%s is a valid email', - (input) => { - const email = new EmailAddress(input); - - expect(email.value).toBe(input); - }, - ); - - it.each(['', ' ', '@', '@test.com', 'test.com', '.@.com'])( - '%s is not a valid email', - (input) => { - expect(() => { - new EmailAddress(input); - }).toThrow(InvalidEmailFormatError); - }, - ); -}); diff --git a/src/domain/account/entities/account.entity.ts b/src/domain/account/entities/account.entity.ts deleted file mode 100644 index 17b39f17c1..0000000000 --- a/src/domain/account/entities/account.entity.ts +++ /dev/null @@ -1,32 +0,0 @@ -export class InvalidEmailFormatError extends Error { - constructor() { - super('Provided email is not a recognizable email format.'); - } -} - -export interface Account { - chainId: string; - emailAddress: EmailAddress; - isVerified: boolean; - safeAddress: `0x${string}`; - signer: `0x${string}`; - unsubscriptionToken: string; -} - -export interface VerificationCode { - code: string; - generatedOn: Date; - sentOn: Date | null; -} - -export class EmailAddress { - // https://www.ietf.org/rfc/rfc5322.txt - private static EMAIL_REGEX: RegExp = - /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; - - constructor(readonly value: string) { - if (!EmailAddress.EMAIL_REGEX.test(value)) { - throw new InvalidEmailFormatError(); - } - } -} diff --git a/src/domain/account/entities/subscription.entity.ts b/src/domain/account/entities/subscription.entity.ts deleted file mode 100644 index 4cfd8b8af8..0000000000 --- a/src/domain/account/entities/subscription.entity.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface Subscription { - key: string; - name: string; -} diff --git a/src/domain/account/errors/account-does-not-exist.error.ts b/src/domain/account/errors/account-does-not-exist.error.ts deleted file mode 100644 index 333b7fbf76..0000000000 --- a/src/domain/account/errors/account-does-not-exist.error.ts +++ /dev/null @@ -1,14 +0,0 @@ -export class AccountDoesNotExistError extends Error { - readonly signer: `0x${string}`; - - constructor( - chainId: string, - safeAddress: `0x${string}`, - signer: `0x${string}`, - ) { - super( - `Account for ${signer} of ${safeAddress} on chain ${chainId} does not exist.`, - ); - this.signer = signer; - } -} diff --git a/src/domain/account/errors/account-save.error.ts b/src/domain/account/errors/account-save.error.ts deleted file mode 100644 index cae98e5e3c..0000000000 --- a/src/domain/account/errors/account-save.error.ts +++ /dev/null @@ -1,11 +0,0 @@ -export class AccountSaveError extends Error { - constructor( - chainId: string, - safeAddress: `0x${string}`, - signer: `0x${string}`, - ) { - super( - `Error while creating account. Account was not created. chainId=${chainId}, safeAddress=${safeAddress}, signer=${signer}`, - ); - } -} diff --git a/src/domain/account/errors/email-already-verified.error.ts b/src/domain/account/errors/email-already-verified.error.ts deleted file mode 100644 index 9f21760251..0000000000 --- a/src/domain/account/errors/email-already-verified.error.ts +++ /dev/null @@ -1,14 +0,0 @@ -export class EmailAlreadyVerifiedError extends Error { - readonly signer: string; - - constructor(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - }) { - super( - `The email address is already verified. chainId=${args.chainId}, safeAddress=${args.safeAddress}, signer=${args.signer}`, - ); - this.signer = args.signer; - } -} diff --git a/src/domain/account/errors/email-edit-matches.error.ts b/src/domain/account/errors/email-edit-matches.error.ts deleted file mode 100644 index 5ca730ad85..0000000000 --- a/src/domain/account/errors/email-edit-matches.error.ts +++ /dev/null @@ -1,11 +0,0 @@ -export class EmailEditMatchesError extends Error { - constructor(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - }) { - super( - `The provided email address matches that set for the Safe owner. chainId=${args.chainId}, safeAddress=${args.safeAddress}, signer=${args.signer}`, - ); - } -} diff --git a/src/domain/account/errors/invalid-verification-code.error.ts b/src/domain/account/errors/invalid-verification-code.error.ts deleted file mode 100644 index 267135bc16..0000000000 --- a/src/domain/account/errors/invalid-verification-code.error.ts +++ /dev/null @@ -1,11 +0,0 @@ -export class InvalidVerificationCodeError extends Error { - constructor(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - }) { - super( - `The verification code is invalid. chainId=${args.chainId}, safeAddress=${args.safeAddress}, signer=${args.signer} `, - ); - } -} diff --git a/src/domain/account/errors/verification-timeframe.error.ts b/src/domain/account/errors/verification-timeframe.error.ts deleted file mode 100644 index 7ef330977c..0000000000 --- a/src/domain/account/errors/verification-timeframe.error.ts +++ /dev/null @@ -1,13 +0,0 @@ -export class ResendVerificationTimespanError extends Error { - constructor(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - timespanMs: number; - lockWindowMs: number; - }) { - super( - `Verification cannot be resent at this time. ${args.timespanMs} ms have elapsed out of ${args.lockWindowMs} ms for signer=${args.signer}, safe=${args.safeAddress}, chainId=${args.chainId}`, - ); - } -} diff --git a/src/domain/alerts/alerts.domain.module.ts b/src/domain/alerts/alerts.domain.module.ts index d10d73f372..938b277648 100644 --- a/src/domain/alerts/alerts.domain.module.ts +++ b/src/domain/alerts/alerts.domain.module.ts @@ -4,22 +4,16 @@ import { AlertsRepository } from '@/domain/alerts/alerts.repository'; import { IAlertsRepository } from '@/domain/alerts/alerts.repository.interface'; import { AlertsDecodersModule } from '@/domain/alerts/alerts-decoders.module'; import { EmailApiModule } from '@/datasources/email-api/email-api.module'; -import { AccountDomainModule } from '@/domain/account/account.domain.module'; -import { UrlGeneratorModule } from '@/domain/alerts/urls/url-generator.module'; -import { SubscriptionDomainModule } from '@/domain/subscriptions/subscription.domain.module'; import { SafeRepositoryModule } from '@/domain/safe/safe.repository.interface'; import { ChainsRepositoryModule } from '@/domain/chains/chains.repository.interface'; @Module({ imports: [ - AccountDomainModule, AlertsApiModule, AlertsDecodersModule, ChainsRepositoryModule, EmailApiModule, SafeRepositoryModule, - SubscriptionDomainModule, - UrlGeneratorModule, ], providers: [{ provide: IAlertsRepository, useClass: AlertsRepository }], exports: [IAlertsRepository], diff --git a/src/domain/alerts/alerts.repository.ts b/src/domain/alerts/alerts.repository.ts index d8f11f6e94..fa7c338ab0 100644 --- a/src/domain/alerts/alerts.repository.ts +++ b/src/domain/alerts/alerts.repository.ts @@ -8,44 +8,22 @@ import { AlertLog } from '@/routes/alerts/entities/alert.dto.entity'; import { DelayModifierDecoder } from '@/domain/alerts/contracts/decoders/delay-modifier-decoder.helper'; import { SafeDecoder } from '@/domain/contracts/decoders/safe-decoder.helper'; import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; -import { IEmailApi } from '@/domain/interfaces/email-api.interface'; -import { IAccountRepository } from '@/domain/account/account.repository.interface'; import { ILoggingService, LoggingService } from '@/logging/logging.interface'; -import { IConfigurationService } from '@/config/configuration.service.interface'; import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; import { Safe } from '@/domain/safe/entities/safe.entity'; -import { UrlGeneratorHelper } from '@/domain/alerts/urls/url-generator.helper'; -import { IChainsRepository } from '@/domain/chains/chains.repository.interface'; -import { ISubscriptionRepository } from '@/domain/subscriptions/subscription.repository.interface'; -import { SubscriptionRepository } from '@/domain/subscriptions/subscription.repository'; -import { Account } from '@/domain/account/entities/account.entity'; @Injectable() export class AlertsRepository implements IAlertsRepository { - private static readonly UNKNOWN_TX_EMAIL_SUBJECT = 'Malicious transaction'; - private static readonly RECOVERY_TX_EMAIL_SUBJECT = 'Recovery attempt'; - constructor( @Inject(IAlertsApi) private readonly alertsApi: IAlertsApi, - @Inject(IEmailApi) - private readonly emailApi: IEmailApi, - @Inject(IAccountRepository) - private readonly accountRepository: IAccountRepository, - private readonly urlGenerator: UrlGeneratorHelper, private readonly delayModifierDecoder: DelayModifierDecoder, private readonly safeDecoder: SafeDecoder, private readonly multiSendDecoder: MultiSendDecoder, @Inject(LoggingService) private readonly loggingService: ILoggingService, - @Inject(IConfigurationService) - private readonly configurationService: IConfigurationService, @Inject(ISafeRepository) private readonly safeRepository: ISafeRepository, - @Inject(IChainsRepository) - private readonly chainRepository: IChainsRepository, - @Inject(ISubscriptionRepository) - private readonly subscriptionRepository: ISubscriptionRepository, ) {} async addContract(contract: AlertsRegistration): Promise { @@ -73,16 +51,6 @@ export class AlertsRepository implements IAlertsRepository { // Recovery module is deployed per Safe so we can assume that it is only enabled on one const safeAddress = safes[0]; - const subscribedAccounts = await this._getSubscribedAccounts({ - chainId, - safeAddress, - }); - if (subscribedAccounts.length === 0) { - this.loggingService.debug( - `An alert for a Safe with no associated emails was received. moduleAddress=${moduleAddress}, safeAddress=${safeAddress}`, - ); - return; - } try { const safe = await this.safeRepository.getSafe({ @@ -98,63 +66,18 @@ export class AlertsRepository implements IAlertsRepository { decodedEvent.args.data, ); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const newSafeState = this._mapSafeSetup({ safe, decodedTransactions, }); - await this._notifySafeSetup({ - chainId, - newSafeState, - accountsToNotify: subscribedAccounts, - }); + // TODO: Notify the user about the new Safe state } catch { - await this._notifyUnknownTransaction({ - chainId, - safeAddress, - accountsToNotify: subscribedAccounts, - }); + // TODO: Notify the user about the unknown transaction } } - /** - * Gets all the subscribed accounts to CATEGORY_ACCOUNT_RECOVERY for a given safe - * - * @param args.chainId - the chain id where the safe is deployed - * @param args.safeAddress - the safe address to which the accounts should be retrieved - * - * @private - */ - private async _getSubscribedAccounts(args: { - chainId: string; - safeAddress: `0x${string}`; - }): Promise { - const accounts = await this.accountRepository.getAccounts({ - chainId: args.chainId, - safeAddress: args.safeAddress, - onlyVerified: true, - }); - - const subscribedAccounts = accounts.map(async (account) => { - const accountSubscriptions = - await this.subscriptionRepository.getSubscriptions({ - chainId: account.chainId, - safeAddress: account.safeAddress, - signer: account.signer, - }); - return accountSubscriptions.some( - (subscription) => - subscription.key === SubscriptionRepository.CATEGORY_ACCOUNT_RECOVERY, - ) - ? account - : null; - }); - - return (await Promise.all(subscribedAccounts)).filter( - (account): account is Account => account !== null, - ); - } - private _decodeTransactionAdded( data: Hex, ): Array> { @@ -236,104 +159,4 @@ export class AlertsRepository implements IAlertsRepository { return newSafe; }, structuredClone(args.safe)); } - - private async _notifyUnknownTransaction(args: { - safeAddress: string; - chainId: string; - accountsToNotify: Account[]; - }): Promise { - const chain = await this.chainRepository.getChain(args.chainId); - const webAppUrl = this.urlGenerator.addressToSafeWebAppUrl({ - chain, - safeAddress: args.safeAddress, - }); - - const emailActions = args.accountsToNotify.map((account) => { - const unsubscriptionUrl = this.urlGenerator.unsubscriptionSafeWebAppUrl({ - unsubscriptionToken: account.unsubscriptionToken, - }); - return this.emailApi.createMessage({ - to: [account.emailAddress.value], - template: this.configurationService.getOrThrow( - 'email.templates.unknownRecoveryTx', - ), - subject: AlertsRepository.UNKNOWN_TX_EMAIL_SUBJECT, - substitutions: { - webAppUrl, - unsubscriptionUrl, - }, - }); - }); - - Promise.allSettled(emailActions) - .then((results) => { - results.forEach((result, index) => { - if (result.status === 'rejected') { - const signer = args.accountsToNotify.at(index)?.signer; - this.loggingService.warn( - `Error sending email to user with account ${signer}, for Safe ${args.safeAddress} on chain ${args.chainId}`, - ); - } - }); - }) - .catch((reason) => { - this.loggingService.warn(reason); - }); - } - - private async _notifySafeSetup(args: { - chainId: string; - newSafeState: Safe; - accountsToNotify: Account[]; - }): Promise { - const chain = await this.chainRepository.getChain(args.chainId); - - const webAppUrl = this.urlGenerator.addressToSafeWebAppUrl({ - chain, - safeAddress: args.newSafeState.address, - }); - const owners = args.newSafeState.owners.map((address) => { - return { - address, - explorerUrl: this.urlGenerator.addressToExplorerUrl({ - chain, - address, - }), - }; - }); - - const emailActions = args.accountsToNotify.map((account) => { - const unsubscriptionUrl = this.urlGenerator.unsubscriptionSafeWebAppUrl({ - unsubscriptionToken: account.unsubscriptionToken, - }); - return this.emailApi.createMessage({ - to: [account.emailAddress.value], - template: this.configurationService.getOrThrow( - 'email.templates.recoveryTx', - ), - subject: AlertsRepository.RECOVERY_TX_EMAIL_SUBJECT, - substitutions: { - webAppUrl, - owners, - threshold: args.newSafeState.threshold.toString(), - unsubscriptionUrl, - }, - }); - }); - - Promise.allSettled(emailActions) - .then((results) => { - results.forEach((result, index) => { - if (result.status === 'rejected') { - const signer = args.accountsToNotify.at(index)?.signer; - this.loggingService.warn( - `Error sending email to user with account ${signer}, for Safe ${args.newSafeState.address} on chain ${args.chainId}`, - ); - } - }); - }) - .catch((reason) => { - this.loggingService.warn(reason); - }); - } } diff --git a/src/domain/alerts/urls/url-generator.helper.spec.ts b/src/domain/alerts/urls/url-generator.helper.spec.ts deleted file mode 100644 index a5dca0ea8e..0000000000 --- a/src/domain/alerts/urls/url-generator.helper.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { IConfigurationService } from '@/config/configuration.service.interface'; -import { UrlGeneratorHelper } from '@/domain/alerts/urls/url-generator.helper'; -import { blockExplorerUriTemplateBuilder } from '@/domain/chains/entities/__tests__/block-explorer-uri-template.builder'; -import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; -import { faker } from '@faker-js/faker'; - -const configurationService = { - getOrThrow: jest.fn(), -} as jest.MockedObjectDeep; - -const configurationServiceMock = jest.mocked(configurationService); - -describe('UrlGeneratorHelper', () => { - const webAppBaseUri = faker.internet.url({ appendSlash: false }); - - configurationServiceMock.getOrThrow.mockImplementation((key) => { - if (key === 'safeWebApp.baseUri') { - return webAppBaseUri; - } - throw new Error(`Unexpected key: ${key}`); - }); - - const target = new UrlGeneratorHelper(configurationServiceMock); - - describe('addressToSafeWebAppUrl', () => { - it('should return a Safe web app url', () => { - const chain = chainBuilder().build(); - - const safeAddress = faker.finance.ethereumAddress(); - const expected = `${webAppBaseUri}/home?safe=${chain.shortName}:${safeAddress}`; - - expect(target.addressToSafeWebAppUrl({ chain, safeAddress })).toEqual( - expected, - ); - }); - }); - - describe('addressToExplorerUrl', () => { - it('should return a Safe web app url', () => { - const explorerUrl = faker.internet.url({ appendSlash: false }); - const chain = chainBuilder() - .with( - 'blockExplorerUriTemplate', - blockExplorerUriTemplateBuilder() - .with('address', `${explorerUrl}/{{address}}`) - .build(), - ) - .build(); - - const address = faker.finance.ethereumAddress(); - const expected = `${explorerUrl}/${address}`; - - expect(target.addressToExplorerUrl({ chain, address })).toEqual(expected); - }); - }); -}); diff --git a/src/domain/alerts/urls/url-generator.helper.ts b/src/domain/alerts/urls/url-generator.helper.ts deleted file mode 100644 index 20c9cf659d..0000000000 --- a/src/domain/alerts/urls/url-generator.helper.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Inject } from '@nestjs/common'; -import { IConfigurationService } from '@/config/configuration.service.interface'; -import { Chain } from '@/domain/chains/entities/chain.entity'; - -export class UrlGeneratorHelper { - private readonly webAppBaseUri: string; - - constructor( - @Inject(IConfigurationService) - private readonly configurationService: IConfigurationService, - ) { - this.webAppBaseUri = - this.configurationService.getOrThrow('safeWebApp.baseUri'); - } - - addressToSafeWebAppUrl(args: { chain: Chain; safeAddress: string }): string { - return `${this.webAppBaseUri}/home?safe=${args.chain.shortName}:${args.safeAddress}`; - } - - addressToExplorerUrl(args: { chain: Chain; address: string }): string { - return args.chain.blockExplorerUriTemplate.address.replace( - '{{address}}', - args.address, - ); - } - - // TODO: The final URL needs to be confirmed - unsubscriptionSafeWebAppUrl(args: { unsubscriptionToken: string }): string { - return `${this.webAppBaseUri}/unsubscribe?token=${args.unsubscriptionToken}`; - } -} diff --git a/src/domain/alerts/urls/url-generator.module.ts b/src/domain/alerts/urls/url-generator.module.ts deleted file mode 100644 index 3a422ff478..0000000000 --- a/src/domain/alerts/urls/url-generator.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { UrlGeneratorHelper } from '@/domain/alerts/urls/url-generator.helper'; - -@Module({ - providers: [UrlGeneratorHelper], - exports: [UrlGeneratorHelper], -}) -export class UrlGeneratorModule {} diff --git a/src/domain/account/entities/create-email-message.dto.entity.ts b/src/domain/email/entities/create-email-message.dto.entity.ts similarity index 100% rename from src/domain/account/entities/create-email-message.dto.entity.ts rename to src/domain/email/entities/create-email-message.dto.entity.ts diff --git a/src/domain/interfaces/account.datasource.interface.ts b/src/domain/interfaces/account.datasource.interface.ts deleted file mode 100644 index df28da2717..0000000000 --- a/src/domain/interfaces/account.datasource.interface.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { - Account, - EmailAddress, - VerificationCode, -} from '@/domain/account/entities/account.entity'; -import { Subscription } from '@/domain/account/entities/subscription.entity'; - -export const IAccountDataSource = Symbol('IAccountDataSource'); - -export interface IAccountDataSource { - /** - * Gets the account associated with a signer/owner of a Safe for a specific chain. - * - * @param args.chainId - the chain id of where the Safe is deployed - * @param args.safeAddress - the Safe address of the account - * @param args.signer - the signer/owner address of the account - * - * @throws {AccountDoesNotExistError} - */ - getAccount(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - }): Promise; - - /** - * Gets all accounts associated with a Safe address for a specific chain - * - * @param args.chainId - the chain id of where the Safe is deployed - * @param args.safeAddress - the Safe address of the account - * @param args.onlyVerified - if set to true, returns only verified emails. - * Else, returns all emails. - */ - getAccounts(args: { - chainId: string; - safeAddress: `0x${string}`; - onlyVerified: boolean; - }): Promise; - - getAccountVerificationCode(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - }): Promise; - - /** - * Creates a new account entry - * - * @param args.chainId - the chain id of where the Safe is deployed - * @param args.safeAddress - the Safe address of the signer/owner - * @param args.emailAddress - the email address of the signer/owner - * @param args.signer - the signer/owner address of the account - * @param args.code - the generated code to be used to verify this email address - * @param args.verificationGeneratedOn – the date which represents when the code was generated - */ - createAccount(args: { - chainId: string; - safeAddress: `0x${string}`; - emailAddress: EmailAddress; - signer: `0x${string}`; - code: string; - codeGenerationDate: Date; - unsubscriptionToken: string; - }): Promise<[Account, VerificationCode]>; - - /** - * Sets the verification code for an account. - * - * If the reset was successful, the new verification code is returned. - * - * @param args.chainId - the chain id of where the Safe is deployed - * @param args.safeAddress - the Safe address of the signer/owner - * @param args.signer - the signer/owner address of the account - * @param args.code - the generated code to be used to verify this email address - * @param args.verificationGeneratedOn – the date which represents when the code was generated - */ - setEmailVerificationCode(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - code: string; - codeGenerationDate: Date; - }): Promise; - - /** - * Sets the verification date for an email entry. - * - * @param args.chainId - the chain id of where the Safe is deployed - * @param args.safeAddress - the Safe address of the signer/owner - * @param args.signer - the signer/owner address of the account - * @param args.sent_on - the verification-sent date - */ - setEmailVerificationSentDate(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - sentOn: Date; - }): Promise; - - /** - * Verifies the email address for an account of a Safe. - * - * @param args.chainId - the chain id of where the Safe is deployed - * @param args.safeAddress - the Safe address of the signer/owner - * @param args.signer - the signer/owner address of the account - * - * @throws {AccountDoesNotExistError} - */ - verifyEmail(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - }): Promise; - - /** - * Deletes the given account. - * - * @param args.chainId - the chain id of where the Safe is deployed - * @param args.safeAddress - the Safe address of the signer/owner - * @param args.signer - the signer/owner address of the account - * - * @throws {AccountDoesNotExistError} - */ - deleteAccount(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - }): Promise; - - /** - * Updates the email address of an account. - * - * @param args.chainId - the chain id of where the Safe is deployed - * @param args.safeAddress - the Safe address of the signer/owner - * @param args.emailAddress - the email address to store - * @param args.signer - the signer/owner address of the account - * @param args.code - the generated code to be used to verify this email address - * @param args.verificationGeneratedOn – the date which represents when the code was generated - * - * @throws {AccountDoesNotExistError} - */ - updateAccountEmail(args: { - chainId: string; - safeAddress: `0x${string}`; - emailAddress: EmailAddress; - signer: `0x${string}`; - unsubscriptionToken: string; - }): Promise; - - /** - * Gets all the subscriptions for the account on chainId, with the specified safeAddress. - * - * @param args.chainId - the chain id of where the Safe is deployed - * @param args.safeAddress - the Safe address of the signer/owner - * @param args.signer - the signer/owner address of the account - */ - getSubscriptions(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - }): Promise; - - /** - * Subscribes the account on chainId, with the safeAddress to a type of notification. - * - * @param args.chainId - the chain id of where the Safe is deployed - * @param args.safeAddress - the Safe address to which the email address is linked to - * @param args.signer - the signer/owner address of the account - * @param args.notificationTypeKey - the category key to subscribe to - * - * @returns The Subscriptions that were successfully subscribed to - */ - subscribe(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - notificationTypeKey: string; - }): Promise; - - /** - * Unsubscribes from the notification type with the provided category key. - * - * If the category key or the token are incorrect, no subscriptions are returned from this call. - * - * @param args.notificationTypeKey - the category key to unsubscribe - * @param args.token - the unsubscription token (tied to a single account) - * - * @returns The Subscriptions that were successfully unsubscribed. - */ - unsubscribe(args: { - notificationTypeKey: string; - token: string; - }): Promise; - - /** - * Unsubscribes from all notification categories. - * - * If the provided token is incorrect, no subscriptions are returned from this call. - * - * @param args.token - the unsubscription token (tied to a single account) - * - * @returns The Subscriptions that were successfully unsubscribed. - */ - unsubscribeAll(args: { token: string }): Promise; -} diff --git a/src/domain/interfaces/accounts.datasource.interface.ts b/src/domain/interfaces/accounts.datasource.interface.ts new file mode 100644 index 0000000000..a46d598e59 --- /dev/null +++ b/src/domain/interfaces/accounts.datasource.interface.ts @@ -0,0 +1,9 @@ +import { Account } from '@/datasources/accounts/entities/account.entity'; + +export const IAccountsDatasource = Symbol('IAccountsDatasource'); + +export interface IAccountsDatasource { + createAccount(address: `0x${string}`): Promise; + + getAccount(address: `0x${string}`): Promise; +} diff --git a/src/domain/interfaces/email-api.interface.ts b/src/domain/interfaces/email-api.interface.ts index c48b7f659b..77f9320ad6 100644 --- a/src/domain/interfaces/email-api.interface.ts +++ b/src/domain/interfaces/email-api.interface.ts @@ -1,4 +1,4 @@ -import { CreateEmailMessageDto } from '@/domain/account/entities/create-email-message.dto.entity'; +import { CreateEmailMessageDto } from '@/domain/email/entities/create-email-message.dto.entity'; export const IEmailApi = Symbol('IEmailApi'); diff --git a/src/domain/subscriptions/subscription.domain.module.ts b/src/domain/subscriptions/subscription.domain.module.ts deleted file mode 100644 index 00b0654596..0000000000 --- a/src/domain/subscriptions/subscription.domain.module.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Module } from '@nestjs/common'; -import { EmailApiModule } from '@/datasources/email-api/email-api.module'; -import { ISubscriptionRepository } from '@/domain/subscriptions/subscription.repository.interface'; -import { SubscriptionRepository } from '@/domain/subscriptions/subscription.repository'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; - -@Module({ - imports: [AccountDataSourceModule, EmailApiModule], - providers: [ - { provide: ISubscriptionRepository, useClass: SubscriptionRepository }, - ], - exports: [ISubscriptionRepository], -}) -export class SubscriptionDomainModule {} diff --git a/src/domain/subscriptions/subscription.repository.interface.ts b/src/domain/subscriptions/subscription.repository.interface.ts deleted file mode 100644 index 9a22d49c98..0000000000 --- a/src/domain/subscriptions/subscription.repository.interface.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Subscription } from '@/domain/account/entities/subscription.entity'; - -export const ISubscriptionRepository = Symbol('ISubscriptionRepository'); - -export interface ISubscriptionRepository { - getSubscriptions(args: { - chainId: string; - safeAddress: string; - signer: string; - }): Promise; - - subscribe(args: { - chainId: string; - safeAddress: string; - signer: string; - notificationTypeKey: string; - }): Promise; - - unsubscribe(args: { - notificationTypeKey: string; - token: string; - }): Promise; - - unsubscribeAll(args: { token: string }): Promise; -} diff --git a/src/domain/subscriptions/subscription.repository.ts b/src/domain/subscriptions/subscription.repository.ts deleted file mode 100644 index a2aefd5e27..0000000000 --- a/src/domain/subscriptions/subscription.repository.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { IAccountDataSource } from '@/domain/interfaces/account.datasource.interface'; -import { ISubscriptionRepository } from '@/domain/subscriptions/subscription.repository.interface'; -import { Subscription } from '@/domain/account/entities/subscription.entity'; -import { getAddress } from 'viem'; - -@Injectable() -export class SubscriptionRepository implements ISubscriptionRepository { - public static CATEGORY_ACCOUNT_RECOVERY = 'account_recovery'; - - constructor( - @Inject(IAccountDataSource) - private readonly accountDataSource: IAccountDataSource, - ) {} - - getSubscriptions(args: { - chainId: string; - safeAddress: string; - signer: string; - }): Promise { - const safeAddress = getAddress(args.safeAddress); - const signer = getAddress(args.signer); - return this.accountDataSource.getSubscriptions({ - chainId: args.chainId, - safeAddress, - signer, - }); - } - - subscribe(args: { - chainId: string; - safeAddress: string; - signer: string; - notificationTypeKey: string; - }): Promise { - const safeAddress = getAddress(args.safeAddress); - const signer = getAddress(args.signer); - return this.accountDataSource.subscribe({ - chainId: args.chainId, - safeAddress, - signer, - notificationTypeKey: args.notificationTypeKey, - }); - } - - unsubscribe(args: { - notificationTypeKey: string; - token: string; - }): Promise { - return this.accountDataSource.unsubscribe(args); - } - - unsubscribeAll(args: { token: string }): Promise { - return this.accountDataSource.unsubscribeAll(args); - } -} diff --git a/src/routes/alerts/alerts.controller.spec.ts b/src/routes/alerts/alerts.controller.spec.ts index 3c758e41bd..e8c9821592 100644 --- a/src/routes/alerts/alerts.controller.spec.ts +++ b/src/routes/alerts/alerts.controller.spec.ts @@ -18,10 +18,6 @@ import { } from '@/routes/alerts/entities/__tests__/alerts.builder'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { Alert, EventType } from '@/routes/alerts/entities/alert.dto.entity'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; -import { IEmailApi } from '@/domain/interfaces/email-api.interface'; -import { IAccountDataSource } from '@/domain/interfaces/account.datasource.interface'; import { EmailApiModule } from '@/datasources/email-api/email-api.module'; import { TestEmailApiModule } from '@/datasources/email-api/__tests__/test.email-api.module'; import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; @@ -33,10 +29,6 @@ import { swapOwnerEncoder, } from '@/domain/contracts/__tests__/encoders/safe-encoder.builder'; import { transactionAddedEventBuilder } from '@/domain/alerts/contracts/__tests__/encoders/delay-modifier-encoder.builder'; -import { - INetworkService, - NetworkService, -} from '@/datasources/network/network.service.interface'; import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; import { TestAppProvider } from '@/__tests__/test-app.provider'; import { getAddress } from 'viem'; @@ -45,9 +37,6 @@ import { multiSendEncoder, multiSendTransactionsEncoder, } from '@/domain/contracts/__tests__/encoders/multi-send-encoder.builder'; -import { accountBuilder } from '@/domain/account/entities/__tests__/account.builder'; -import { EmailAddress } from '@/domain/account/entities/account.entity'; -import { subscriptionBuilder } from '@/domain/account/entities/__tests__/subscription.builder'; import { AlertsApiConfigurationModule, ALERTS_API_CONFIGURATION_MODULE, @@ -91,19 +80,10 @@ function fakeTenderlySignature(args: { describe('Alerts (Unit)', () => { let configurationService: jest.MockedObjectDeep; - let emailApi: jest.MockedObjectDeep; - let accountDataSource: jest.MockedObjectDeep; - - const accountRecoverySubscription = subscriptionBuilder() - .with('key', 'account_recovery') - .build(); describe('/alerts route enabled', () => { let app: INestApplication; let signingKey: string; - let networkService: jest.MockedObjectDeep; - let safeConfigUrl: string | undefined; - let webAppBaseUri: string | undefined; beforeEach(async () => { jest.resetAllMocks(); @@ -128,8 +108,6 @@ describe('Alerts (Unit)', () => { .useModule( AlertsApiConfigurationModule.register(alertsApiConfiguration), ) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) @@ -143,12 +121,7 @@ describe('Alerts (Unit)', () => { .compile(); configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); signingKey = configurationService.getOrThrow('alerts-route.signingKey'); - emailApi = moduleFixture.get(IEmailApi); - accountDataSource = moduleFixture.get(IAccountDataSource); - networkService = moduleFixture.get(NetworkService); - webAppBaseUri = configurationService.getOrThrow('safeWebApp.baseUri'); app = await new TestAppProvider().provide(moduleFixture); await app.init(); }); @@ -182,14 +155,17 @@ describe('Alerts (Unit)', () => { .expect({}); }); - describe('it notifies about a valid transaction attempt', () => { - it('notifies about addOwnerWithThreshold attempts', async () => { + describe.skip('it notifies about a valid transaction attempt', () => { + it('notifies about addOwnerWithThreshold attempts', () => { + // Intentional fail in case we enable this suite + // We need to first integrate the email service + expect(true).toBe(false); + const chain = chainBuilder().build(); const delayModifier = getAddress(faker.finance.ethereumAddress()); const safe = safeBuilder().with('modules', [delayModifier]).build(); const addOwnerWithThreshold = addOwnerWithThresholdEncoder(); - const { threshold, owner } = addOwnerWithThreshold.build(); const transactionAddedEvent = transactionAddedEventBuilder() .with('data', addOwnerWithThreshold.encode()) .with('to', getAddress(safe.address)) @@ -213,74 +189,22 @@ describe('Alerts (Unit)', () => { .with('event_type', EventType.ALERT) .build(); const timestamp = Date.now().toString(); + // TODO: Check value of threshold and owner in email + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { threshold, owner } = addOwnerWithThreshold.build(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const signature = fakeTenderlySignature({ signingKey, alert, timestamp, }); - const verifiedAccounts = [ - accountBuilder() - .with('emailAddress', new EmailAddress(faker.internet.email())) - .with('isVerified', true) - .build(), - ]; - accountDataSource.getAccounts.mockResolvedValue(verifiedAccounts); - accountDataSource.getSubscriptions.mockResolvedValue([ - subscriptionBuilder().with('key', 'account_recovery').build(), - ]); - - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/modules/${delayModifier}/safes/`: - return Promise.resolve({ - data: { safes: [safe.address] }, - status: 200, - }); - case `${chain.transactionService}/api/v1/safes/${safe.address}`: - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .post('/v1/alerts') - .set('x-tenderly-signature', signature) - .set('date', timestamp) - .send(alert) - .expect(202) - .expect({}); - - const expectedTargetEmailAddresses = verifiedAccounts.map( - ({ emailAddress }) => emailAddress.value, - ); - expect(emailApi.createMessage).toHaveBeenCalledTimes(1); - expect(emailApi.createMessage).toHaveBeenNthCalledWith(1, { - subject: 'Recovery attempt', - substitutions: { - webAppUrl: `${webAppBaseUri}/home?safe=${chain.shortName}:${safe.address}`, - owners: [...safe.owners, owner].map((address) => { - return { - address, - explorerUrl: chain.blockExplorerUriTemplate.address.replace( - '{{address}}', - address, - ), - }; - }), - threshold: threshold.toString(), - unsubscriptionUrl: `${webAppBaseUri}/unsubscribe?token=${verifiedAccounts[0].unsubscriptionToken}`, - }, - template: configurationService.getOrThrow( - 'email.templates.recoveryTx', - ), - to: expectedTargetEmailAddresses, - }); }); - it('notifies about removeOwner attempts', async () => { + it('notifies about removeOwner attempts', () => { + // Intentional fail in case we enable this suite + // We need to first integrate the email service + expect(true).toBe(false); + const chain = chainBuilder().build(); const delayModifier = getAddress(faker.finance.ethereumAddress()); const owners = [ @@ -297,7 +221,6 @@ describe('Alerts (Unit)', () => { 'owner', owners[1], ); - const { threshold } = removeOwner.build(); const transactionAddedEvent = transactionAddedEventBuilder() .with('data', removeOwner.encode()) .with('to', getAddress(safe.address)) @@ -321,74 +244,22 @@ describe('Alerts (Unit)', () => { .with('event_type', EventType.ALERT) .build(); const timestamp = Date.now().toString(); + // TODO: Check value of threshold in email + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { threshold } = removeOwner.build(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const signature = fakeTenderlySignature({ signingKey, alert, timestamp, }); - const verifiedAccounts = [ - accountBuilder() - .with('emailAddress', new EmailAddress(faker.internet.email())) - .with('isVerified', true) - .build(), - ]; - accountDataSource.getAccounts.mockResolvedValue(verifiedAccounts); - accountDataSource.getSubscriptions.mockResolvedValue([ - accountRecoverySubscription, - ]); - - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/modules/${delayModifier}/safes/`: - return Promise.resolve({ - data: { safes: [safe.address] }, - status: 200, - }); - case `${chain.transactionService}/api/v1/safes/${safe.address}`: - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .post('/v1/alerts') - .set('x-tenderly-signature', signature) - .set('date', timestamp) - .send(alert) - .expect(202) - .expect({}); - - const expectedTargetEmailAddresses = verifiedAccounts.map( - ({ emailAddress }) => emailAddress.value, - ); - expect(emailApi.createMessage).toHaveBeenCalledTimes(1); - expect(emailApi.createMessage).toHaveBeenNthCalledWith(1, { - subject: 'Recovery attempt', - substitutions: { - webAppUrl: `${webAppBaseUri}/home?safe=${chain.shortName}:${safe.address}`, - owners: [owners[0], owners[2]].map((address) => { - return { - address, - explorerUrl: chain.blockExplorerUriTemplate.address.replace( - '{{address}}', - address, - ), - }; - }), - threshold: threshold.toString(), - unsubscriptionUrl: `${webAppBaseUri}/unsubscribe?token=${verifiedAccounts[0].unsubscriptionToken}`, - }, - template: configurationService.getOrThrow( - 'email.templates.recoveryTx', - ), - to: expectedTargetEmailAddresses, - }); }); - it('notifies about swapOwner attempts', async () => { + it('notifies about swapOwner attempts', () => { + // Intentional fail in case we enable this suite + // We need to first integrate the email service + expect(true).toBe(false); + const chain = chainBuilder().build(); const delayModifier = getAddress(faker.finance.ethereumAddress()); const owners = [ @@ -404,7 +275,6 @@ describe('Alerts (Unit)', () => { const swapOwner = swapOwnerEncoder(owners) .with('oldOwner', getAddress(owners[1])) .with('newOwner', getAddress(faker.finance.ethereumAddress())); - const { newOwner } = swapOwner.build(); const transactionAddedEvent = transactionAddedEventBuilder() .with('data', swapOwner.encode()) .with('to', getAddress(safe.address)) @@ -428,80 +298,27 @@ describe('Alerts (Unit)', () => { .with('event_type', EventType.ALERT) .build(); const timestamp = Date.now().toString(); + // TODO: Check value of newOwner in email + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { newOwner } = swapOwner.build(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const signature = fakeTenderlySignature({ signingKey, alert, timestamp, }); - const verifiedAccounts = [ - accountBuilder() - .with('emailAddress', new EmailAddress(faker.internet.email())) - .with('isVerified', true) - .build(), - ]; - accountDataSource.getAccounts.mockResolvedValue(verifiedAccounts); - accountDataSource.getSubscriptions.mockResolvedValue([ - accountRecoverySubscription, - ]); - - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/modules/${delayModifier}/safes/`: - return Promise.resolve({ - data: { safes: [safe.address] }, - status: 200, - }); - case `${chain.transactionService}/api/v1/safes/${safe.address}`: - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .post('/v1/alerts') - .set('x-tenderly-signature', signature) - .set('date', timestamp) - .send(alert) - .expect(202) - .expect({}); - - const expectedTargetEmailAddresses = verifiedAccounts.map( - ({ emailAddress }) => emailAddress.value, - ); - expect(emailApi.createMessage).toHaveBeenCalledTimes(1); - expect(emailApi.createMessage).toHaveBeenNthCalledWith(1, { - subject: 'Recovery attempt', - substitutions: { - webAppUrl: `${webAppBaseUri}/home?safe=${chain.shortName}:${safe.address}`, - owners: [owners[0], newOwner, owners[2]].map((address) => { - return { - address, - explorerUrl: chain.blockExplorerUriTemplate.address.replace( - '{{address}}', - address, - ), - }; - }), - threshold: safe.threshold.toString(), - unsubscriptionUrl: `${webAppBaseUri}/unsubscribe?token=${verifiedAccounts[0].unsubscriptionToken}`, - }, - template: configurationService.getOrThrow( - 'email.templates.recoveryTx', - ), - to: expectedTargetEmailAddresses, - }); }); - it('notifies about changeThreshold attempts', async () => { + it('notifies about changeThreshold attempts', () => { + // Intentional fail in case we enable this suite + // We need to first integrate the email service + expect(true).toBe(false); + const chain = chainBuilder().build(); const delayModifier = getAddress(faker.finance.ethereumAddress()); const safe = safeBuilder().with('modules', [delayModifier]).build(); const changeThreshold = changeThresholdEncoder(); - const { threshold } = changeThreshold.build(); const transactionAddedEvent = transactionAddedEventBuilder() .with('data', changeThreshold.encode()) .with('to', getAddress(safe.address)) @@ -525,74 +342,22 @@ describe('Alerts (Unit)', () => { .with('event_type', EventType.ALERT) .build(); const timestamp = Date.now().toString(); + // TODO: Check value of threshold in email + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { threshold } = changeThreshold.build(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const signature = fakeTenderlySignature({ signingKey, alert, timestamp, }); - const verifiedAccounts = [ - accountBuilder() - .with('emailAddress', new EmailAddress(faker.internet.email())) - .with('isVerified', true) - .build(), - ]; - accountDataSource.getAccounts.mockResolvedValue(verifiedAccounts); - accountDataSource.getSubscriptions.mockResolvedValue([ - accountRecoverySubscription, - ]); - - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/modules/${delayModifier}/safes/`: - return Promise.resolve({ - data: { safes: [safe.address] }, - status: 200, - }); - case `${chain.transactionService}/api/v1/safes/${safe.address}`: - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .post('/v1/alerts') - .set('x-tenderly-signature', signature) - .set('date', timestamp) - .send(alert) - .expect(202) - .expect({}); - - const expectedTargetEmailAddresses = verifiedAccounts.map( - ({ emailAddress }) => emailAddress.value, - ); - expect(emailApi.createMessage).toHaveBeenCalledTimes(1); - expect(emailApi.createMessage).toHaveBeenNthCalledWith(1, { - subject: 'Recovery attempt', - substitutions: { - webAppUrl: `${webAppBaseUri}/home?safe=${chain.shortName}:${safe.address}`, - owners: safe.owners.map((address) => { - return { - address, - explorerUrl: chain.blockExplorerUriTemplate.address.replace( - '{{address}}', - address, - ), - }; - }), - threshold: threshold.toString(), - unsubscriptionUrl: `${webAppBaseUri}/unsubscribe?token=${verifiedAccounts[0].unsubscriptionToken}`, - }, - template: configurationService.getOrThrow( - 'email.templates.recoveryTx', - ), - to: expectedTargetEmailAddresses, - }); }); - it('notifies about batched owner management attempts', async () => { + it('notifies about batched owner management attempts', () => { + // Intentional fail in case we enable this suite + // We need to first integrate the email service + expect(true).toBe(false); + const chain = chainBuilder().build(); const delayModifier = getAddress(faker.finance.ethereumAddress()); const owners = [ @@ -653,84 +418,24 @@ describe('Alerts (Unit)', () => { .with('event_type', EventType.ALERT) .build(); const timestamp = Date.now().toString(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const signature = fakeTenderlySignature({ signingKey, alert, timestamp, }); - const verifiedAccounts = [ - accountBuilder() - .with('emailAddress', new EmailAddress(faker.internet.email())) - .with('isVerified', true) - .build(), - ]; - accountDataSource.getAccounts.mockResolvedValue(verifiedAccounts); - accountDataSource.getSubscriptions.mockResolvedValue([ - accountRecoverySubscription, - ]); - - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/modules/${delayModifier}/safes/`: - return Promise.resolve({ - data: { safes: [safe.address] }, - status: 200, - }); - case `${chain.transactionService}/api/v1/safes/${safe.address}`: - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .post('/v1/alerts') - .set('x-tenderly-signature', signature) - .set('date', timestamp) - .send(alert) - .expect(202) - .expect({}); - - const expectedTargetEmailAddresses = verifiedAccounts.map( - ({ emailAddress }) => emailAddress.value, - ); - expect(emailApi.createMessage).toHaveBeenCalledTimes(1); - expect(emailApi.createMessage).toHaveBeenNthCalledWith(1, { - subject: 'Recovery attempt', - substitutions: { - webAppUrl: `${webAppBaseUri}/home?safe=${chain.shortName}:${safe.address}`, - owners: [ - owners[1], - owners[2], - addOwnerWithThreshold.build().owner, - ].map((address) => { - return { - address, - explorerUrl: chain.blockExplorerUriTemplate.address.replace( - '{{address}}', - address, - ), - }; - }), - threshold: removeOwner.build().threshold.toString(), - unsubscriptionUrl: `${webAppBaseUri}/unsubscribe?token=${verifiedAccounts[0].unsubscriptionToken}`, - }, - template: configurationService.getOrThrow( - 'email.templates.recoveryTx', - ), - to: expectedTargetEmailAddresses, - }); }); - it('notifies about alerts with multiple logs', async () => { + it('notifies about alerts with multiple logs', () => { + // Intentional fail in case we enable this suite + // We need to first integrate the email service + expect(true).toBe(false); + const chain = chainBuilder().build(); const delayModifier = getAddress(faker.finance.ethereumAddress()); const safe = safeBuilder().with('modules', [delayModifier]).build(); const addOwnerWithThreshold = addOwnerWithThresholdEncoder(); - const { threshold, owner } = addOwnerWithThreshold.build(); const transactionAddedEvent = transactionAddedEventBuilder() .with('data', addOwnerWithThreshold.encode()) .with('to', getAddress(safe.address)) @@ -753,101 +458,27 @@ describe('Alerts (Unit)', () => { .with('event_type', EventType.ALERT) .build(); const timestamp = Date.now().toString(); + // TODO: Check value of threshold and owner in email + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { threshold, owner } = addOwnerWithThreshold.build(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const signature = fakeTenderlySignature({ signingKey, alert, timestamp, }); - const verifiedAccounts = [ - accountBuilder() - .with('emailAddress', new EmailAddress(faker.internet.email())) - .with('isVerified', true) - .build(), - ]; - accountDataSource.getAccounts.mockResolvedValue(verifiedAccounts); - accountDataSource.getSubscriptions.mockResolvedValue([ - accountRecoverySubscription, - ]); - - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/modules/${delayModifier}/safes/`: - return Promise.resolve({ - data: { safes: [safe.address] }, - status: 200, - }); - case `${chain.transactionService}/api/v1/safes/${safe.address}`: - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .post('/v1/alerts') - .set('x-tenderly-signature', signature) - .set('date', timestamp) - .send(alert) - .expect(202) - .expect({}); - - const expectedTargetEmailAddresses = verifiedAccounts.map( - ({ emailAddress }) => emailAddress.value, - ); - expect(emailApi.createMessage).toHaveBeenCalledTimes(2); - expect(emailApi.createMessage).toHaveBeenNthCalledWith(1, { - subject: 'Recovery attempt', - substitutions: { - webAppUrl: `${webAppBaseUri}/home?safe=${chain.shortName}:${safe.address}`, - owners: [...safe.owners, owner].map((address) => { - return { - address, - explorerUrl: chain.blockExplorerUriTemplate.address.replace( - '{{address}}', - address, - ), - }; - }), - threshold: threshold.toString(), - unsubscriptionUrl: `${webAppBaseUri}/unsubscribe?token=${verifiedAccounts[0].unsubscriptionToken}`, - }, - template: configurationService.getOrThrow( - 'email.templates.recoveryTx', - ), - to: expectedTargetEmailAddresses, - }); - expect(emailApi.createMessage).toHaveBeenNthCalledWith(2, { - subject: 'Recovery attempt', - substitutions: { - webAppUrl: `${webAppBaseUri}/home?safe=${chain.shortName}:${safe.address}`, - owners: [...safe.owners, owner].map((address) => { - return { - address, - explorerUrl: chain.blockExplorerUriTemplate.address.replace( - '{{address}}', - address, - ), - }; - }), - threshold: threshold.toString(), - unsubscriptionUrl: `${webAppBaseUri}/unsubscribe?token=${verifiedAccounts[0].unsubscriptionToken}`, - }, - template: configurationService.getOrThrow( - 'email.templates.recoveryTx', - ), - to: expectedTargetEmailAddresses, - }); }); - it('notifies multiple emails of a Safe for a single alert', async () => { + it('notifies multiple emails of a Safe for a single alert', () => { + // Intentional fail in case we enable this suite + // We need to first integrate the email service + expect(true).toBe(false); + const chain = chainBuilder().build(); const delayModifier = getAddress(faker.finance.ethereumAddress()); const safe = safeBuilder().with('modules', [delayModifier]).build(); const addOwnerWithThreshold = addOwnerWithThresholdEncoder(); - const { threshold, owner } = addOwnerWithThreshold.build(); const transactionAddedEvent = transactionAddedEventBuilder() .with('data', addOwnerWithThreshold.encode()) .with('to', getAddress(safe.address)) @@ -871,91 +502,24 @@ describe('Alerts (Unit)', () => { .with('event_type', EventType.ALERT) .build(); const timestamp = Date.now().toString(); + // TODO: Check value of threshold and owner in email + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { threshold, owner } = addOwnerWithThreshold.build(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const signature = fakeTenderlySignature({ signingKey, alert, timestamp, }); - const verifiedAccounts = [ - accountBuilder() - .with('emailAddress', new EmailAddress(faker.internet.email())) - .with('isVerified', true) - .build(), - accountBuilder() - .with('emailAddress', new EmailAddress(faker.internet.email())) - .with('isVerified', true) - .build(), - ]; - accountDataSource.getAccounts.mockResolvedValue(verifiedAccounts); - accountDataSource.getSubscriptions.mockResolvedValue([ - accountRecoverySubscription, - ]); - - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/modules/${delayModifier}/safes/`: - return Promise.resolve({ - data: { safes: [safe.address] }, - status: 200, - }); - case `${chain.transactionService}/api/v1/safes/${safe.address}`: - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .post('/v1/alerts') - .set('x-tenderly-signature', signature) - .set('date', timestamp) - .send(alert) - .expect(202) - .expect({}); - - const expectedOwners = [...safe.owners, owner].map((address) => { - return { - address, - explorerUrl: chain.blockExplorerUriTemplate.address.replace( - '{{address}}', - address, - ), - }; - }); - expect(emailApi.createMessage).toHaveBeenCalledTimes(2); - expect(emailApi.createMessage).toHaveBeenCalledWith({ - subject: 'Recovery attempt', - substitutions: { - webAppUrl: `${webAppBaseUri}/home?safe=${chain.shortName}:${safe.address}`, - owners: expectedOwners, - threshold: threshold.toString(), - unsubscriptionUrl: `${webAppBaseUri}/unsubscribe?token=${verifiedAccounts[0].unsubscriptionToken}`, - }, - template: configurationService.getOrThrow( - 'email.templates.recoveryTx', - ), - to: [verifiedAccounts[0].emailAddress.value], - }); - expect(emailApi.createMessage).toHaveBeenCalledWith({ - subject: 'Recovery attempt', - substitutions: { - webAppUrl: `${webAppBaseUri}/home?safe=${chain.shortName}:${safe.address}`, - owners: expectedOwners, - threshold: threshold.toString(), - unsubscriptionUrl: `${webAppBaseUri}/unsubscribe?token=${verifiedAccounts[1].unsubscriptionToken}`, - }, - template: configurationService.getOrThrow( - 'email.templates.recoveryTx', - ), - to: [verifiedAccounts[1].emailAddress.value], - }); }); }); - describe('it notifies about an invalid transaction attempt', () => { - it('notifies about an invalid transaction attempt', async () => { + describe.skip('it notifies about an invalid transaction attempt', () => { + it('notifies about an invalid transaction attempt', () => { + // Intentional fail in case we enable this suite + // We need to first integrate the email service + expect(true).toBe(false); + const chain = chainBuilder().build(); const delayModifier = getAddress(faker.finance.ethereumAddress()); const safe = safeBuilder().with('modules', [delayModifier]).build(); @@ -983,64 +547,19 @@ describe('Alerts (Unit)', () => { .with('event_type', EventType.ALERT) .build(); const timestamp = Date.now().toString(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const signature = fakeTenderlySignature({ signingKey, alert, timestamp, }); - const verifiedAccounts = [ - accountBuilder() - .with('emailAddress', new EmailAddress(faker.internet.email())) - .with('isVerified', true) - .build(), - ]; - accountDataSource.getAccounts.mockResolvedValue(verifiedAccounts); - accountDataSource.getSubscriptions.mockResolvedValue([ - accountRecoverySubscription, - ]); - - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/modules/${delayModifier}/safes/`: - return Promise.resolve({ - data: { safes: [safe.address] }, - status: 200, - }); - case `${chain.transactionService}/api/v1/safes/${safe.address}`: - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .post('/v1/alerts') - .set('x-tenderly-signature', signature) - .set('date', timestamp) - .send(alert) - .expect(202) - .expect({}); - - const expectedTargetEmailAddresses = verifiedAccounts.map( - ({ emailAddress }) => emailAddress.value, - ); - expect(emailApi.createMessage).toHaveBeenCalledTimes(1); - expect(emailApi.createMessage).toHaveBeenNthCalledWith(1, { - subject: 'Malicious transaction', - substitutions: { - webAppUrl: `${webAppBaseUri}/home?safe=${chain.shortName}:${safe.address}`, - unsubscriptionUrl: `${webAppBaseUri}/unsubscribe?token=${verifiedAccounts[0].unsubscriptionToken}`, - }, - template: configurationService.getOrThrow( - 'email.templates.unknownRecoveryTx', - ), - to: expectedTargetEmailAddresses, - }); }); - it('notifies about alerts with multiple logs', async () => { + it('notifies about alerts with multiple logs', () => { + // Intentional fail in case we enable this suite + // We need to first integrate the email service + expect(true).toBe(false); + const chain = chainBuilder().build(); const delayModifier = getAddress(faker.finance.ethereumAddress()); const safe = safeBuilder().with('modules', [delayModifier]).build(); @@ -1067,500 +586,238 @@ describe('Alerts (Unit)', () => { .with('event_type', EventType.ALERT) .build(); const timestamp = Date.now().toString(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const signature = fakeTenderlySignature({ signingKey, alert, timestamp, }); - const verifiedAccounts = [ - accountBuilder() - .with('emailAddress', new EmailAddress(faker.internet.email())) - .with('isVerified', true) - .build(), - ]; - accountDataSource.getAccounts.mockResolvedValue(verifiedAccounts); - accountDataSource.getSubscriptions.mockResolvedValue([ - accountRecoverySubscription, - ]); + }); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/modules/${delayModifier}/safes/`: - return Promise.resolve({ - data: { safes: [safe.address] }, - status: 200, - }); - case `${chain.transactionService}/api/v1/safes/${safe.address}`: - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); + it('notifies about a batch of a valid and an invalid transaction attempt', () => { + // Intentional fail in case we enable this suite + // We need to first integrate the email service + expect(true).toBe(false); - await request(app.getHttpServer()) - .post('/v1/alerts') - .set('x-tenderly-signature', signature) - .set('date', timestamp) - .send(alert) - .expect(202) - .expect({}); + const chain = chainBuilder().build(); + const delayModifier = getAddress(faker.finance.ethereumAddress()); + const owners = [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ]; + const safe = safeBuilder() + .with('modules', [delayModifier]) + .with('owners', owners) + .build(); - const expectedTargetEmailAddresses = verifiedAccounts.map( - ({ emailAddress }) => emailAddress.value, - ); - expect(emailApi.createMessage).toHaveBeenCalledTimes(2); - expect(emailApi.createMessage).toHaveBeenNthCalledWith(1, { - subject: 'Malicious transaction', - substitutions: { - webAppUrl: `${webAppBaseUri}/home?safe=${chain.shortName}:${safe.address}`, - unsubscriptionUrl: `${webAppBaseUri}/unsubscribe?token=${verifiedAccounts[0].unsubscriptionToken}`, + const addOwnerWithThreshold = addOwnerWithThresholdEncoder(); + const multiSendTransactions = multiSendTransactionsEncoder([ + { + operation: 0, + to: getAddress(safe.address), + value: BigInt(0), + data: addOwnerWithThreshold.encode(), }, - template: configurationService.getOrThrow( - 'email.templates.unknownRecoveryTx', - ), - to: expectedTargetEmailAddresses, - }); - expect(emailApi.createMessage).toHaveBeenNthCalledWith(2, { - subject: 'Malicious transaction', - substitutions: { - webAppUrl: `${webAppBaseUri}/home?safe=${chain.shortName}:${safe.address}`, - unsubscriptionUrl: `${webAppBaseUri}/unsubscribe?token=${verifiedAccounts[0].unsubscriptionToken}`, + { + operation: 0, + to: getAddress(safe.address), + value: BigInt(0), + data: execTransactionEncoder().encode(), // Invalid as not owner management call }, - template: configurationService.getOrThrow( - 'email.templates.unknownRecoveryTx', - ), - to: expectedTargetEmailAddresses, - }); - }); - }); + ]); + const multiSend = multiSendEncoder().with( + 'transactions', + multiSendTransactions, + ); + const transactionAddedEvent = transactionAddedEventBuilder() + .with('data', multiSend.encode()) + .with( + 'to', + getAddress(getMultiSendCallOnlyDeployment()!.defaultAddress), + ) + .encode(); - it('notifies about a batch of a valid and an invalid transaction attempt', async () => { - const chain = chainBuilder().build(); - const delayModifier = getAddress(faker.finance.ethereumAddress()); - const owners = [ - getAddress(faker.finance.ethereumAddress()), - getAddress(faker.finance.ethereumAddress()), - getAddress(faker.finance.ethereumAddress()), - ]; - const safe = safeBuilder() - .with('modules', [delayModifier]) - .with('owners', owners) - .build(); - - const addOwnerWithThreshold = addOwnerWithThresholdEncoder(); - const multiSendTransactions = multiSendTransactionsEncoder([ - { - operation: 0, - to: getAddress(safe.address), - value: BigInt(0), - data: addOwnerWithThreshold.encode(), - }, - { - operation: 0, - to: getAddress(safe.address), - value: BigInt(0), - data: execTransactionEncoder().encode(), // Invalid as not owner management call - }, - ]); - const multiSend = multiSendEncoder().with( - 'transactions', - multiSendTransactions, - ); - const transactionAddedEvent = transactionAddedEventBuilder() - .with('data', multiSend.encode()) - .with( - 'to', - getAddress(getMultiSendCallOnlyDeployment()!.defaultAddress), - ) - .encode(); - - const alert = alertBuilder() - .with( - 'transaction', - alertTransactionBuilder() - .with('to', delayModifier) - .with('logs', [ - alertLogBuilder() - .with('address', delayModifier) - .with('data', transactionAddedEvent.data) - .with('topics', transactionAddedEvent.topics) - .build(), - ]) - .with('network', chain.chainId) - .build(), - ) - .with('event_type', EventType.ALERT) - .build(); - const timestamp = Date.now().toString(); - const signature = fakeTenderlySignature({ - signingKey, - alert, - timestamp, - }); - const verifiedAccounts = [ - accountBuilder() - .with('emailAddress', new EmailAddress(faker.internet.email())) - .with('isVerified', true) - .build(), - ]; - accountDataSource.getAccounts.mockResolvedValue(verifiedAccounts); - accountDataSource.getSubscriptions.mockResolvedValue([ - accountRecoverySubscription, - ]); - - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/modules/${delayModifier}/safes/`: - return Promise.resolve({ - data: { safes: [safe.address] }, - status: 200, - }); - case `${chain.transactionService}/api/v1/safes/${safe.address}`: - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } + const alert = alertBuilder() + .with( + 'transaction', + alertTransactionBuilder() + .with('to', delayModifier) + .with('logs', [ + alertLogBuilder() + .with('address', delayModifier) + .with('data', transactionAddedEvent.data) + .with('topics', transactionAddedEvent.topics) + .build(), + ]) + .with('network', chain.chainId) + .build(), + ) + .with('event_type', EventType.ALERT) + .build(); + const timestamp = Date.now().toString(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const signature = fakeTenderlySignature({ + signingKey, + alert, + timestamp, + }); }); - await request(app.getHttpServer()) - .post('/v1/alerts') - .set('x-tenderly-signature', signature) - .set('date', timestamp) - .send(alert) - .expect(202) - .expect({}); - - const expectedTargetEmailAddresses = verifiedAccounts.map( - ({ emailAddress }) => emailAddress.value, - ); - expect(emailApi.createMessage).toHaveBeenCalledTimes(1); - expect(emailApi.createMessage).toHaveBeenNthCalledWith(1, { - subject: 'Malicious transaction', - substitutions: { - webAppUrl: `${webAppBaseUri}/home?safe=${chain.shortName}:${safe.address}`, - unsubscriptionUrl: `${webAppBaseUri}/unsubscribe?token=${verifiedAccounts[0].unsubscriptionToken}`, - }, - template: configurationService.getOrThrow( - 'email.templates.unknownRecoveryTx', - ), - to: expectedTargetEmailAddresses, - }); - }); + it('notifies about alerts with multiple logs of a valid and a log of an invalid transaction attempt', () => { + // Intentional fail in case we enable this suite + // We need to first integrate the email service + expect(true).toBe(false); - it('notifies about alerts with multiple logs of a valid and a log of an invalid transaction attempt', async () => { - const chain = chainBuilder().build(); - const delayModifier = getAddress(faker.finance.ethereumAddress()); - const owners = [ - getAddress(faker.finance.ethereumAddress()), - getAddress(faker.finance.ethereumAddress()), - getAddress(faker.finance.ethereumAddress()), - ]; - const safe = safeBuilder() - .with('modules', [delayModifier]) - .with('owners', owners) - .build(); - - const addOwnerWithThreshold = addOwnerWithThresholdEncoder(); - const multiSendTransactions = multiSendTransactionsEncoder([ - { - operation: 0, - to: getAddress(safe.address), - value: BigInt(0), - data: addOwnerWithThreshold.encode(), - }, - { - operation: 0, - to: getAddress(safe.address), - value: BigInt(0), - data: execTransactionEncoder().encode(), // Invalid as not owner management call - }, - ]); - const multiSend = multiSendEncoder().with( - 'transactions', - multiSendTransactions, - ); - const transactionAddedEvent = transactionAddedEventBuilder() - .with('data', multiSend.encode()) - .with( - 'to', - getAddress(getMultiSendCallOnlyDeployment()!.defaultAddress), - ) - .encode(); - - const log = alertLogBuilder() - .with('address', delayModifier) - .with('data', transactionAddedEvent.data) - .with('topics', transactionAddedEvent.topics) - .build(); - const alert = alertBuilder() - .with( - 'transaction', - alertTransactionBuilder() - .with('to', delayModifier) - .with('logs', [log, log]) // Multiple logs - .with('network', chain.chainId) - .build(), - ) - .with('event_type', EventType.ALERT) - .build(); - const timestamp = Date.now().toString(); - const signature = fakeTenderlySignature({ - signingKey, - alert, - timestamp, - }); - const verifiedAccounts = [ - accountBuilder() - .with('emailAddress', new EmailAddress(faker.internet.email())) - .with('isVerified', true) - .build(), - ]; - accountDataSource.getAccounts.mockResolvedValue(verifiedAccounts); - accountDataSource.getSubscriptions.mockResolvedValue([ - accountRecoverySubscription, - ]); - - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/modules/${delayModifier}/safes/`: - return Promise.resolve({ - data: { safes: [safe.address] }, - status: 200, - }); - case `${chain.transactionService}/api/v1/safes/${safe.address}`: - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); + const chain = chainBuilder().build(); + const delayModifier = getAddress(faker.finance.ethereumAddress()); + const owners = [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ]; + const safe = safeBuilder() + .with('modules', [delayModifier]) + .with('owners', owners) + .build(); - await request(app.getHttpServer()) - .post('/v1/alerts') - .set('x-tenderly-signature', signature) - .set('date', timestamp) - .send(alert) - .expect(202) - .expect({}); + const addOwnerWithThreshold = addOwnerWithThresholdEncoder(); + const multiSendTransactions = multiSendTransactionsEncoder([ + { + operation: 0, + to: getAddress(safe.address), + value: BigInt(0), + data: addOwnerWithThreshold.encode(), + }, + { + operation: 0, + to: getAddress(safe.address), + value: BigInt(0), + data: execTransactionEncoder().encode(), // Invalid as not owner management call + }, + ]); + const multiSend = multiSendEncoder().with( + 'transactions', + multiSendTransactions, + ); + const transactionAddedEvent = transactionAddedEventBuilder() + .with('data', multiSend.encode()) + .with( + 'to', + getAddress(getMultiSendCallOnlyDeployment()!.defaultAddress), + ) + .encode(); - const expectedTargetEmailAddresses = verifiedAccounts.map( - ({ emailAddress }) => emailAddress.value, - ); - expect(emailApi.createMessage).toHaveBeenCalledTimes(2); - expect(emailApi.createMessage).toHaveBeenNthCalledWith(1, { - subject: 'Malicious transaction', - substitutions: { - webAppUrl: `${webAppBaseUri}/home?safe=${chain.shortName}:${safe.address}`, - unsubscriptionUrl: `${webAppBaseUri}/unsubscribe?token=${verifiedAccounts[0].unsubscriptionToken}`, - }, - template: configurationService.getOrThrow( - 'email.templates.unknownRecoveryTx', - ), - to: expectedTargetEmailAddresses, - }); - expect(emailApi.createMessage).toHaveBeenNthCalledWith(2, { - subject: 'Malicious transaction', - substitutions: { - webAppUrl: `${webAppBaseUri}/home?safe=${chain.shortName}:${safe.address}`, - unsubscriptionUrl: `${webAppBaseUri}/unsubscribe?token=${verifiedAccounts[0].unsubscriptionToken}`, - }, - template: configurationService.getOrThrow( - 'email.templates.unknownRecoveryTx', - ), - to: expectedTargetEmailAddresses, + const log = alertLogBuilder() + .with('address', delayModifier) + .with('data', transactionAddedEvent.data) + .with('topics', transactionAddedEvent.topics) + .build(); + const alert = alertBuilder() + .with( + 'transaction', + alertTransactionBuilder() + .with('to', delayModifier) + .with('logs', [log, log]) // Multiple logs + .with('network', chain.chainId) + .build(), + ) + .with('event_type', EventType.ALERT) + .build(); + const timestamp = Date.now().toString(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const signature = fakeTenderlySignature({ + signingKey, + alert, + timestamp, + }); }); - }); - it('notifies multiple email addresses of a Safe', async () => { - const chain = chainBuilder().build(); - const delayModifier = getAddress(faker.finance.ethereumAddress()); - const safe = safeBuilder().with('modules', [delayModifier]).build(); - - const addOwnerWithThreshold = addOwnerWithThresholdEncoder(); - const { threshold, owner } = addOwnerWithThreshold.build(); - const transactionAddedEvent = transactionAddedEventBuilder() - .with('data', addOwnerWithThreshold.encode()) - .with('to', getAddress(safe.address)) - .encode(); - - const alert = alertBuilder() - .with( - 'transaction', - alertTransactionBuilder() - .with('to', delayModifier) - .with('logs', [ - alertLogBuilder() - .with('address', delayModifier) - .with('data', transactionAddedEvent.data) - .with('topics', transactionAddedEvent.topics) - .build(), - ]) - .with('network', chain.chainId) - .build(), - ) - .with('event_type', EventType.ALERT) - .build(); - const timestamp = Date.now().toString(); - const signature = fakeTenderlySignature({ - signingKey, - alert, - timestamp, - }); - // Multiple emails - const verifiedAccounts = [ - accountBuilder() - .with('emailAddress', new EmailAddress(faker.internet.email())) - .with('isVerified', true) - .build(), - accountBuilder() - .with('emailAddress', new EmailAddress(faker.internet.email())) - .with('isVerified', true) - .build(), - ]; - accountDataSource.getAccounts.mockResolvedValue(verifiedAccounts); - accountDataSource.getSubscriptions.mockResolvedValue([ - accountRecoverySubscription, - ]); - - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/modules/${delayModifier}/safes/`: - return Promise.resolve({ - data: { safes: [safe.address] }, - status: 200, - }); - case `${chain.transactionService}/api/v1/safes/${safe.address}`: - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); + it('notifies multiple accounts subscribed for recovery alerts of one Safe', () => { + // Intentional fail in case we enable this suite + // We need to first integrate the email service + expect(true).toBe(false); - await request(app.getHttpServer()) - .post('/v1/alerts') - .set('x-tenderly-signature', signature) - .set('date', timestamp) - .send(alert) - .expect(202) - .expect({}); + const chain = chainBuilder().build(); + const delayModifier = getAddress(faker.finance.ethereumAddress()); + const safe = safeBuilder().with('modules', [delayModifier]).build(); - const expectedOwners = [...safe.owners, owner].map((address) => { - return { - address, - explorerUrl: chain.blockExplorerUriTemplate.address.replace( - '{{address}}', - address, - ), - }; - }); - expect(emailApi.createMessage).toHaveBeenCalledTimes(2); - expect(emailApi.createMessage).toHaveBeenCalledWith({ - subject: 'Recovery attempt', - substitutions: { - webAppUrl: `${webAppBaseUri}/home?safe=${chain.shortName}:${safe.address}`, - owners: expectedOwners, - threshold: threshold.toString(), - unsubscriptionUrl: `${webAppBaseUri}/unsubscribe?token=${verifiedAccounts[0].unsubscriptionToken}`, - }, - template: configurationService.getOrThrow( - 'email.templates.recoveryTx', - ), - to: [verifiedAccounts[0].emailAddress.value], - }); - expect(emailApi.createMessage).toHaveBeenCalledWith({ - subject: 'Recovery attempt', - substitutions: { - webAppUrl: `${webAppBaseUri}/home?safe=${chain.shortName}:${safe.address}`, - owners: expectedOwners, - threshold: threshold.toString(), - unsubscriptionUrl: `${webAppBaseUri}/unsubscribe?token=${verifiedAccounts[1].unsubscriptionToken}`, - }, - template: configurationService.getOrThrow( - 'email.templates.recoveryTx', - ), - to: [verifiedAccounts[1].emailAddress.value], - }); - }); + const addOwnerWithThreshold = addOwnerWithThresholdEncoder(); + const transactionAddedEvent = transactionAddedEventBuilder() + .with('data', addOwnerWithThreshold.encode()) + .with('to', getAddress(safe.address)) + .encode(); - it('does not notify accounts not subscribed to CATEGORY_ACCOUNT_RECOVERY', async () => { - const chain = chainBuilder().build(); - const delayModifier = getAddress(faker.finance.ethereumAddress()); - const safe = safeBuilder().with('modules', [delayModifier]).build(); - const addOwnerWithThreshold = addOwnerWithThresholdEncoder(); - const transactionAddedEvent = transactionAddedEventBuilder() - .with('data', addOwnerWithThreshold.encode()) - .with('to', getAddress(safe.address)) - .encode(); - const alert = alertBuilder() - .with( - 'transaction', - alertTransactionBuilder() - .with('to', delayModifier) - .with('logs', [ - alertLogBuilder() - .with('address', delayModifier) - .with('data', transactionAddedEvent.data) - .with('topics', transactionAddedEvent.topics) - .build(), - ]) - .with('network', chain.chainId) - .build(), - ) - .with('event_type', EventType.ALERT) - .build(); - const timestamp = Date.now().toString(); - const signature = fakeTenderlySignature({ - signingKey, - alert, - timestamp, - }); - const accounts = [ - accountBuilder() - .with('emailAddress', new EmailAddress(faker.internet.email())) - .with('isVerified', true) - .build(), - accountBuilder() - .with('emailAddress', new EmailAddress(faker.internet.email())) - .with('isVerified', true) - .build(), - ]; - accountDataSource.getAccounts.mockResolvedValue(accounts); - accountDataSource.getSubscriptions.mockResolvedValue([ - subscriptionBuilder().build(), - ]); - - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/modules/${delayModifier}/safes/`: - return Promise.resolve({ - data: { safes: [safe.address] }, - status: 200, - }); - case `${chain.transactionService}/api/v1/safes/${safe.address}`: - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } + const alert = alertBuilder() + .with( + 'transaction', + alertTransactionBuilder() + .with('to', delayModifier) + .with('logs', [ + alertLogBuilder() + .with('address', delayModifier) + .with('data', transactionAddedEvent.data) + .with('topics', transactionAddedEvent.topics) + .build(), + ]) + .with('network', chain.chainId) + .build(), + ) + .with('event_type', EventType.ALERT) + .build(); + const timestamp = Date.now().toString(); + // TODO: Check threshold and expected owners in email + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { threshold, owner } = addOwnerWithThreshold.build(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const expectedOwners = [...safe.owners, owner]; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const signature = fakeTenderlySignature({ + signingKey, + alert, + timestamp, + }); }); - await request(app.getHttpServer()) - .post('/v1/alerts') - .set('x-tenderly-signature', signature) - .set('date', timestamp) - .send(alert) - .expect(202) - .expect({}); + it('does not notify if not subscribed for recovery alerts', () => { + // Intentional fail in case we enable this suite + // We need to first integrate the email service + expect(true).toBe(false); - expect(emailApi.createMessage).toHaveBeenCalledTimes(0); + const chain = chainBuilder().build(); + const delayModifier = getAddress(faker.finance.ethereumAddress()); + const safe = safeBuilder().with('modules', [delayModifier]).build(); + const addOwnerWithThreshold = addOwnerWithThresholdEncoder(); + const transactionAddedEvent = transactionAddedEventBuilder() + .with('data', addOwnerWithThreshold.encode()) + .with('to', getAddress(safe.address)) + .encode(); + const alert = alertBuilder() + .with( + 'transaction', + alertTransactionBuilder() + .with('to', delayModifier) + .with('logs', [ + alertLogBuilder() + .with('address', delayModifier) + .with('data', transactionAddedEvent.data) + .with('topics', transactionAddedEvent.topics) + .build(), + ]) + .with('network', chain.chainId) + .build(), + ) + .with('event_type', EventType.ALERT) + .build(); + const timestamp = Date.now().toString(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const signature = fakeTenderlySignature({ + signingKey, + alert, + timestamp, + }); + // TODO: Check no email sent + }); }); it('returns 400 (Bad Request) for valid signature/invalid payload', async () => { @@ -1620,8 +877,6 @@ describe('Alerts (Unit)', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(testConfiguration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/auth/auth.controller.spec.ts b/src/routes/auth/auth.controller.spec.ts index b705767724..3f0a43a6d8 100644 --- a/src/routes/auth/auth.controller.spec.ts +++ b/src/routes/auth/auth.controller.spec.ts @@ -9,8 +9,6 @@ import { AppModule } from '@/app.module'; import { CacheModule } from '@/datasources/cache/cache.module'; import { RequestScopedLoggingModule } from '@/logging/logging.module'; import { NetworkModule } from '@/datasources/network/network.module'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { EmailApiModule } from '@/datasources/email-api/email-api.module'; import { TestEmailApiModule } from '@/datasources/email-api/__tests__/test.email-api.module'; import { TestAppProvider } from '@/__tests__/test-app.provider'; @@ -66,8 +64,6 @@ describe('AuthController', () => { .useModule(JwtConfigurationModule.register(jwtConfiguration)) .overrideModule(BlockchainApiManagerModule) .useModule(TestBlockchainApiManagerModule) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts b/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts index d9601c362b..33cc158f46 100644 --- a/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts +++ b/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts @@ -6,8 +6,6 @@ import { TestAppProvider } from '@/__tests__/test-app.provider'; import { AppModule } from '@/app.module'; import { IConfigurationService } from '@/config/configuration.service.interface'; import configuration from '@/config/entities/__tests__/configuration'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { CacheModule } from '@/datasources/cache/cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; @@ -72,8 +70,6 @@ describe('Balances Controller (Unit)', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(testConfiguration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/balances/balances.controller.spec.ts b/src/routes/balances/balances.controller.spec.ts index 220d09d4f9..f10977a5c6 100644 --- a/src/routes/balances/balances.controller.spec.ts +++ b/src/routes/balances/balances.controller.spec.ts @@ -21,8 +21,6 @@ import { NULL_ADDRESS } from '@/routes/common/constants'; import { balanceBuilder } from '@/domain/balances/entities/__tests__/balance.builder'; import { balanceTokenBuilder } from '@/domain/balances/entities/__tests__/balance.token.builder'; import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; @@ -50,8 +48,6 @@ describe('Balances Controller (Unit)', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(testConfiguration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/cache-hooks/cache-hooks.controller.spec.ts b/src/routes/cache-hooks/cache-hooks.controller.spec.ts index a4214da45a..f9479128b1 100644 --- a/src/routes/cache-hooks/cache-hooks.controller.spec.ts +++ b/src/routes/cache-hooks/cache-hooks.controller.spec.ts @@ -19,8 +19,6 @@ import { INetworkService, NetworkService, } from '@/datasources/network/network.service.interface'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; @@ -42,8 +40,6 @@ describe('Post Hook Events (Unit)', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(config)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/chains/chains.controller.spec.ts b/src/routes/chains/chains.controller.spec.ts index 3d0bc4d0ec..d45d363714 100644 --- a/src/routes/chains/chains.controller.spec.ts +++ b/src/routes/chains/chains.controller.spec.ts @@ -26,8 +26,6 @@ import { Page } from '@/domain/entities/page.entity'; import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; import { RequestScopedLoggingModule } from '@/logging/logging.module'; import { PaginationData } from '@/routes/common/pagination/pagination.data'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; @@ -58,8 +56,6 @@ describe('Chains Controller (Unit)', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(configuration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts b/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts index 2e29ec2dcc..30bf63a5b5 100644 --- a/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts +++ b/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts @@ -6,8 +6,6 @@ import { TestAppProvider } from '@/__tests__/test-app.provider'; import { AppModule } from '@/app.module'; import { IConfigurationService } from '@/config/configuration.service.interface'; import configuration from '@/config/entities/__tests__/configuration'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { CacheModule } from '@/datasources/cache/cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; @@ -44,8 +42,6 @@ describe('Zerion Collectibles Controller', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(configuration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/collectibles/collectibles.controller.spec.ts b/src/routes/collectibles/collectibles.controller.spec.ts index be601be122..c5a27bc2a7 100644 --- a/src/routes/collectibles/collectibles.controller.spec.ts +++ b/src/routes/collectibles/collectibles.controller.spec.ts @@ -28,8 +28,6 @@ import { import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; import { RequestScopedLoggingModule } from '@/logging/logging.module'; import { PaginationData } from '@/routes/common/pagination/pagination.data'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; @@ -56,8 +54,6 @@ describe('Collectibles Controller (Unit)', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(testConfiguration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/community/community.controller.spec.ts b/src/routes/community/community.controller.spec.ts index 64c49694b2..f5769c82e3 100644 --- a/src/routes/community/community.controller.spec.ts +++ b/src/routes/community/community.controller.spec.ts @@ -25,8 +25,6 @@ import { toJson as lockingEventToJson, } from '@/domain/community/entities/__tests__/locking-event.builder'; import { LockingEvent } from '@/domain/community/entities/locking-event.entity'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; import { getAddress } from 'viem'; import { lockingRankBuilder } from '@/domain/community/entities/__tests__/locking-rank.builder'; import { PaginationData } from '@/routes/common/pagination/pagination.data'; @@ -56,8 +54,6 @@ describe('Community (Unit)', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(configuration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/contracts/contracts.controller.spec.ts b/src/routes/contracts/contracts.controller.spec.ts index fb869c931d..7df2881c47 100644 --- a/src/routes/contracts/contracts.controller.spec.ts +++ b/src/routes/contracts/contracts.controller.spec.ts @@ -18,8 +18,6 @@ import { NetworkService, } from '@/datasources/network/network.service.interface'; import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; @@ -35,8 +33,6 @@ describe('Contracts controller', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(configuration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/delegates/delegates.controller.spec.ts b/src/routes/delegates/delegates.controller.spec.ts index a4bd0e224d..6580ca3d4e 100644 --- a/src/routes/delegates/delegates.controller.spec.ts +++ b/src/routes/delegates/delegates.controller.spec.ts @@ -24,8 +24,6 @@ import { createDelegateDtoBuilder } from '@/routes/delegates/entities/__tests__/ import { deleteDelegateDtoBuilder } from '@/routes/delegates/entities/__tests__/delete-delegate.dto.builder'; import { deleteSafeDelegateDtoBuilder } from '@/routes/delegates/entities/__tests__/delete-safe-delegate.dto.builder'; import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; @@ -42,8 +40,6 @@ describe('Delegates controller', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(configuration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/delegates/v2/delegates.v2.controller.spec.ts b/src/routes/delegates/v2/delegates.v2.controller.spec.ts index 735eae5e98..4b18bd6d31 100644 --- a/src/routes/delegates/v2/delegates.v2.controller.spec.ts +++ b/src/routes/delegates/v2/delegates.v2.controller.spec.ts @@ -2,8 +2,6 @@ import { TestAppProvider } from '@/__tests__/test-app.provider'; import { AppModule } from '@/app.module'; import { IConfigurationService } from '@/config/configuration.service.interface'; import configuration from '@/config/entities/__tests__/configuration'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { CacheModule } from '@/datasources/cache/cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; @@ -50,8 +48,6 @@ describe('Delegates controller', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(testConfiguration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/email/email.controller.delete-email.spec.ts b/src/routes/email/email.controller.delete-email.spec.ts deleted file mode 100644 index 6bf6eb0952..0000000000 --- a/src/routes/email/email.controller.delete-email.spec.ts +++ /dev/null @@ -1,447 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AppModule } from '@/app.module'; -import { CacheModule } from '@/datasources/cache/cache.module'; -import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; -import configuration from '@/config/entities/__tests__/configuration'; -import { RequestScopedLoggingModule } from '@/logging/logging.module'; -import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; -import { NetworkModule } from '@/datasources/network/network.module'; -import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; -import { TestAppProvider } from '@/__tests__/test-app.provider'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; -import request from 'supertest'; -import { faker } from '@faker-js/faker'; -import { authPayloadDtoBuilder } from '@/domain/auth/entities/__tests__/auth-payload-dto.entity.builder'; -import { IConfigurationService } from '@/config/configuration.service.interface'; -import { - INetworkService, - NetworkService, -} from '@/datasources/network/network.service.interface'; -import { IAccountDataSource } from '@/domain/interfaces/account.datasource.interface'; -import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; -import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; -import { getAddress } from 'viem'; -import { EmailControllerModule } from '@/routes/email/email.controller.module'; -import { AccountDoesNotExistError } from '@/domain/account/errors/account-does-not-exist.error'; -import { EmailApiModule } from '@/datasources/email-api/email-api.module'; -import { TestEmailApiModule } from '@/datasources/email-api/__tests__/test.email-api.module'; -import { IEmailApi } from '@/domain/interfaces/email-api.interface'; -import { accountBuilder } from '@/domain/account/entities/__tests__/account.builder'; -import { INestApplication } from '@nestjs/common'; -import jwtConfiguration from '@/datasources/jwt/configuration/__tests__/jwt.configuration'; -import { - JWT_CONFIGURATION_MODULE, - JwtConfigurationModule, -} from '@/datasources/jwt/configuration/jwt.configuration.module'; -import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; -import { getSecondsUntil } from '@/domain/common/utils/time'; -import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; -import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; -import { Server } from 'net'; - -describe('Email controller delete email tests', () => { - let app: INestApplication; - let safeConfigUrl: string; - let accountDataSource: jest.MockedObjectDeep; - let emailApi: jest.MockedObjectDeep; - let networkService: jest.MockedObjectDeep; - let jwtService: IJwtService; - - beforeEach(async () => { - jest.resetAllMocks(); - jest.useFakeTimers(); - - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule.register(configuration), EmailControllerModule], - }) - .overrideModule(JWT_CONFIGURATION_MODULE) - .useModule(JwtConfigurationModule.register(jwtConfiguration)) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) - .overrideModule(EmailApiModule) - .useModule(TestEmailApiModule) - .overrideModule(CacheModule) - .useModule(TestCacheModule) - .overrideModule(RequestScopedLoggingModule) - .useModule(TestLoggingModule) - .overrideModule(NetworkModule) - .useModule(TestNetworkModule) - .overrideModule(QueuesApiModule) - .useModule(TestQueuesApiModule) - .compile(); - - const configurationService = moduleFixture.get( - IConfigurationService, - ); - safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); - accountDataSource = moduleFixture.get(IAccountDataSource); - emailApi = moduleFixture.get(IEmailApi); - networkService = moduleFixture.get(NetworkService); - jwtService = moduleFixture.get(IJwtService); - - app = await new TestAppProvider().provide(moduleFixture); - await app.init(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - afterAll(async () => { - await app.close(); - }); - - it.each([ - // non-checksummed address - { safeAddress: faker.finance.ethereumAddress().toLowerCase() }, - // checksummed address - { safeAddress: getAddress(faker.finance.ethereumAddress()) }, - ])('deletes email successfully', async ({ safeAddress }) => { - const chain = chainBuilder().build(); - const safe = safeBuilder() - // Allow test of non-checksummed address by casting - .with('address', safeAddress as `0x${string}`) - .build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', signerAddress) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - const account = accountBuilder() - .with('signer', signerAddress) - .with('chainId', chain.chainId) - .with('safeAddress', safe.address) - .build(); - accountDataSource.getAccount.mockResolvedValue(account); - accountDataSource.deleteAccount.mockResolvedValue(account); - emailApi.deleteEmailAddress.mockResolvedValue(); - - await request(app.getHttpServer()) - .delete( - `/v1/chains/${chain.chainId}/safes/${safeAddress}/emails/${account.signer}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .expect(204) - .expect({}); - - expect(emailApi.deleteEmailAddress).toHaveBeenCalledTimes(1); - expect(accountDataSource.deleteAccount).toHaveBeenCalledTimes(1); - expect(accountDataSource.deleteAccount).toHaveBeenCalledWith({ - chainId: chain.chainId, - // Should always call with the checksummed address - safeAddress: getAddress(safeAddress), - signer: signerAddress, - }); - }); - - it("returns 204 if trying to deleting an email that doesn't exist", async () => { - const chain = chainBuilder().build(); - // Signer is owner of safe - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', signerAddress) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/safes/${safe.address}`: - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(new Error(`Could not match ${url}`)); - } - }); - accountDataSource.getAccount.mockRejectedValueOnce( - new AccountDoesNotExistError(chain.chainId, safe.address, signerAddress), - ); - - await request(app.getHttpServer()) - .delete( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .expect(204) - .expect({}); - - expect(emailApi.deleteEmailAddress).toHaveBeenCalledTimes(0); - expect(accountDataSource.deleteAccount).toHaveBeenCalledTimes(0); - }); - - it('returns 403 if no token is present', async () => { - const chain = chainBuilder().build(); - // Signer is owner of safe - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - - await request(app.getHttpServer()) - .delete( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .expect(403); - - expect(accountDataSource.getAccount).toHaveBeenCalledTimes(0); - expect(emailApi.deleteEmailAddress).toHaveBeenCalledTimes(0); - expect(accountDataSource.deleteAccount).toHaveBeenCalledTimes(0); - }); - - it('returns 403 if token is not a valid JWT', async () => { - const chain = chainBuilder().build(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const accessToken = faker.string.numeric(); - - expect(() => jwtService.verify(accessToken)).toThrow('jwt malformed'); - await request(app.getHttpServer()) - .delete( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .expect(403); - - expect(accountDataSource.getAccount).toHaveBeenCalledTimes(0); - expect(emailApi.deleteEmailAddress).toHaveBeenCalledTimes(0); - expect(accountDataSource.deleteAccount).toHaveBeenCalledTimes(0); - }); - - it('returns 403 if token is not yet valid', async () => { - const chain = chainBuilder().build(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', signerAddress) - .build(); - const notBefore = faker.date.future(); - const accessToken = jwtService.sign(authPayloadDto, { - notBefore: getSecondsUntil(notBefore), - }); - - expect(() => jwtService.verify(accessToken)).toThrow('jwt not active'); - await request(app.getHttpServer()) - .delete( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .expect(403); - - expect(accountDataSource.getAccount).toHaveBeenCalledTimes(0); - expect(emailApi.deleteEmailAddress).toHaveBeenCalledTimes(0); - expect(accountDataSource.deleteAccount).toHaveBeenCalledTimes(0); - }); - - it('returns 403 if token has expired', async () => { - const chain = chainBuilder().build(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', signerAddress) - .build(); - const accessToken = jwtService.sign(authPayloadDto, { - expiresIn: 0, // Now - }); - jest.advanceTimersByTime(1_000); - - expect(() => jwtService.verify(accessToken)).toThrow('jwt expired'); - await request(app.getHttpServer()) - .delete( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .expect(403); - - expect(accountDataSource.getAccount).toHaveBeenCalledTimes(0); - expect(emailApi.deleteEmailAddress).toHaveBeenCalledTimes(0); - expect(accountDataSource.deleteAccount).toHaveBeenCalledTimes(0); - }); - - it('returns 403 if signer_address is not a valid Ethereum address', async () => { - const chain = chainBuilder().build(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', faker.string.numeric() as `0x${string}`) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - jest.advanceTimersByTime(1_000); - - expect(() => jwtService.verify(accessToken)).not.toThrow(); - await request(app.getHttpServer()) - .delete( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .expect(403); - - expect(accountDataSource.getAccount).toHaveBeenCalledTimes(0); - expect(emailApi.deleteEmailAddress).toHaveBeenCalledTimes(0); - expect(accountDataSource.deleteAccount).toHaveBeenCalledTimes(0); - }); - - it('returns 403 if chain_id is not a valid chain ID', async () => { - const chain = chainBuilder().build(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', faker.string.alpha()) - .with('signer_address', signerAddress) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - - expect(() => jwtService.verify(accessToken)).not.toThrow(); - await request(app.getHttpServer()) - .delete( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .expect(403); - - expect(accountDataSource.getAccount).toHaveBeenCalledTimes(0); - expect(emailApi.deleteEmailAddress).toHaveBeenCalledTimes(0); - expect(accountDataSource.deleteAccount).toHaveBeenCalledTimes(0); - }); - - // Note: this could be removed as we checksum the :signer but for robustness we should keep it - it.each([ - // non-checksummed address - { - signer_address: faker.finance.ethereumAddress().toLowerCase(), - }, - // checksummed address - { - signer_address: getAddress(faker.finance.ethereumAddress()), - }, - ])( - 'returns 401 if signer_address does not match a non-checksummed signer request', - async ({ signer_address }) => { - const chain = chainBuilder().build(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', signer_address as `0x${string}`) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - - expect(() => jwtService.verify(accessToken)).not.toThrow(); - await request(app.getHttpServer()) - .delete( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${ - // non-checksummed - signerAddress.toLowerCase() - }`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .expect(401); - - expect(accountDataSource.getAccount).toHaveBeenCalledTimes(0); - expect(emailApi.deleteEmailAddress).toHaveBeenCalledTimes(0); - expect(accountDataSource.deleteAccount).toHaveBeenCalledTimes(0); - }, - ); - it.each([ - // non-checksummed address - { - signer_address: faker.finance.ethereumAddress().toLowerCase(), - }, - // checksummed address - { - signer_address: getAddress(faker.finance.ethereumAddress()), - }, - ])( - 'returns 401 if signer_address does not match a checksummed signer request', - async ({ signer_address }) => { - const chain = chainBuilder().build(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', signer_address as `0x${string}`) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - - expect(() => jwtService.verify(accessToken)).not.toThrow(); - await request(app.getHttpServer()) - .delete( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${ - // checksummed - getAddress(signerAddress) - }`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .expect(401); - - expect(accountDataSource.getAccount).toHaveBeenCalledTimes(0); - expect(emailApi.deleteEmailAddress).toHaveBeenCalledTimes(0); - expect(accountDataSource.deleteAccount).toHaveBeenCalledTimes(0); - }, - ); - - it('Returns 401 if chain_id does not match the request', async () => { - const chain = chainBuilder().build(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', faker.string.numeric({ exclude: [chain.chainId] })) - .with('signer_address', signerAddress) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - - expect(() => jwtService.verify(accessToken)).not.toThrow(); - await request(app.getHttpServer()) - .delete( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .expect(401); - - expect(accountDataSource.getAccount).toHaveBeenCalledTimes(0); - expect(emailApi.deleteEmailAddress).toHaveBeenCalledTimes(0); - expect(accountDataSource.deleteAccount).toHaveBeenCalledTimes(0); - }); - - it('returns 500 if email api throws', async () => { - const chain = chainBuilder().build(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', signerAddress) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - const account = accountBuilder() - .with('signer', signerAddress) - .with('chainId', chain.chainId) - .with('safeAddress', safe.address) - .build(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/safes/${safe.address}`: - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(new Error(`Could not match ${url}`)); - } - }); - accountDataSource.getAccount.mockResolvedValueOnce(account); - emailApi.deleteEmailAddress.mockRejectedValue(new Error('Some error')); - - await request(app.getHttpServer()) - .delete( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .expect(500) - .expect({ code: 500, message: 'Internal server error' }); - - expect(emailApi.deleteEmailAddress).toHaveBeenCalledTimes(1); - expect(accountDataSource.deleteAccount).toHaveBeenCalledTimes(0); - }); -}); diff --git a/src/routes/email/email.controller.edit-email.spec.ts b/src/routes/email/email.controller.edit-email.spec.ts deleted file mode 100644 index 80091cb510..0000000000 --- a/src/routes/email/email.controller.edit-email.spec.ts +++ /dev/null @@ -1,629 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AppModule } from '@/app.module'; -import { CacheModule } from '@/datasources/cache/cache.module'; -import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; -import configuration from '@/config/entities/__tests__/configuration'; -import { RequestScopedLoggingModule } from '@/logging/logging.module'; -import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; -import { NetworkModule } from '@/datasources/network/network.module'; -import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; -import { TestAppProvider } from '@/__tests__/test-app.provider'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; -import request from 'supertest'; -import { faker } from '@faker-js/faker'; -import { IConfigurationService } from '@/config/configuration.service.interface'; -import { - INetworkService, - NetworkService, -} from '@/datasources/network/network.service.interface'; -import { IAccountDataSource } from '@/domain/interfaces/account.datasource.interface'; -import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; -import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; -import { EmailControllerModule } from '@/routes/email/email.controller.module'; -import { INestApplication } from '@nestjs/common'; -import { AccountDoesNotExistError } from '@/domain/account/errors/account-does-not-exist.error'; -import { - Account, - EmailAddress, -} from '@/domain/account/entities/account.entity'; -import { accountBuilder } from '@/domain/account/entities/__tests__/account.builder'; -import { getAddress } from 'viem'; -import jwtConfiguration from '@/datasources/jwt/configuration/__tests__/jwt.configuration'; -import { - JWT_CONFIGURATION_MODULE, - JwtConfigurationModule, -} from '@/datasources/jwt/configuration/jwt.configuration.module'; -import { authPayloadDtoBuilder } from '@/domain/auth/entities/__tests__/auth-payload-dto.entity.builder'; -import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; -import { getSecondsUntil } from '@/domain/common/utils/time'; -import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; -import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; -import { Server } from 'net'; - -const verificationCodeTtlMs = 100; - -describe('Email controller edit email tests', () => { - let app: INestApplication; - let safeConfigUrl: string; - let accountDataSource: jest.MockedObjectDeep; - let networkService: jest.MockedObjectDeep; - let jwtService: IJwtService; - - beforeEach(async () => { - jest.resetAllMocks(); - jest.useFakeTimers(); - - const defaultConfiguration = configuration(); - - const testConfiguration: typeof configuration = () => { - return { - ...defaultConfiguration, - email: { - ...defaultConfiguration.email, - verificationCode: { - ...defaultConfiguration.email.verificationCode, - ttlMs: verificationCodeTtlMs, - }, - }, - }; - }; - - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule.register(testConfiguration), EmailControllerModule], - }) - .overrideModule(JWT_CONFIGURATION_MODULE) - .useModule(JwtConfigurationModule.register(jwtConfiguration)) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) - .overrideModule(CacheModule) - .useModule(TestCacheModule) - .overrideModule(RequestScopedLoggingModule) - .useModule(TestLoggingModule) - .overrideModule(NetworkModule) - .useModule(TestNetworkModule) - .overrideModule(QueuesApiModule) - .useModule(TestQueuesApiModule) - .compile(); - - const configurationService = moduleFixture.get( - IConfigurationService, - ); - safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); - accountDataSource = moduleFixture.get(IAccountDataSource); - networkService = moduleFixture.get(NetworkService); - jwtService = moduleFixture.get(IJwtService); - - app = await new TestAppProvider().provide(moduleFixture); - await app.init(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - afterAll(async () => { - await app.close(); - }); - - it.each([ - // non-checksummed address - { safeAddress: faker.finance.ethereumAddress().toLowerCase() }, - // checksummed address - { safeAddress: getAddress(faker.finance.ethereumAddress()) }, - ])('edits email successfully', async ({ safeAddress }) => { - const chain = chainBuilder().build(); - const prevEmailAddress = faker.internet.email(); - const emailAddress = faker.internet.email(); - const safe = safeBuilder() - // Allow test of non-checksummed address by casting - .with('address', safeAddress as `0x${string}`) - .build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', signerAddress) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/safes/${safe.address}`: - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(new Error(`Could not match ${url}`)); - } - }); - accountDataSource.getAccount.mockResolvedValue( - accountBuilder() - .with('chainId', chain.chainId) - .with('signer', signerAddress) - .with('isVerified', true) - .with('safeAddress', safe.address) - .with('emailAddress', new EmailAddress(prevEmailAddress)) - .build(), - ); - accountDataSource.updateAccountEmail.mockResolvedValue( - accountBuilder() - .with('emailAddress', new EmailAddress(emailAddress)) - .build(), - ); - - await request(app.getHttpServer()) - .put( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .send({ - emailAddress, - }) - .expect(202) - .expect({}); - - expect(accountDataSource.updateAccountEmail).toHaveBeenCalledTimes(1); - expect(accountDataSource.updateAccountEmail).toHaveBeenCalledWith({ - chainId: chain.chainId, - emailAddress: new EmailAddress(emailAddress), - // Should always call with the checksummed address - safeAddress: getAddress(safe.address), - signer: signerAddress, - unsubscriptionToken: expect.any(String), - }); - expect(accountDataSource.setEmailVerificationCode).toHaveBeenCalledTimes(1); - expect(accountDataSource.setEmailVerificationCode).toHaveBeenCalledWith({ - chainId: chain.chainId, - code: expect.any(String), - signer: signerAddress, - // Should always call with the checksummed address - safeAddress: getAddress(safe.address), - codeGenerationDate: expect.any(Date), - }); - expect( - accountDataSource.setEmailVerificationSentDate, - ).toHaveBeenCalledTimes(1); - expect(accountDataSource.setEmailVerificationSentDate).toHaveBeenCalledWith( - { - chainId: chain.chainId, - // Should always call with the checksummed address - safeAddress: getAddress(safe.address), - signer: signerAddress, - sentOn: expect.any(Date), - }, - ); - // TODO: validate that `IEmailApi.createMessage` is triggered with the correct code - }); - - it('returns 403 if no token is present', async () => { - const chain = chainBuilder().build(); - const emailAddress = faker.internet.email(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - - await request(app.getHttpServer()) - .put( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .send({ - emailAddress, - }) - .expect(403); - - expect(accountDataSource.updateAccountEmail).not.toHaveBeenCalled(); - expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); - expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); - expect( - accountDataSource.setEmailVerificationSentDate, - ).not.toHaveBeenCalled(); - }); - - it('returns 403 if token is not a valid JWT', async () => { - const chain = chainBuilder().build(); - const emailAddress = faker.internet.email(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const accessToken = faker.string.alphanumeric(); - - expect(() => jwtService.verify(accessToken)).toThrow('jwt malformed'); - await request(app.getHttpServer()) - .put( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .send({ - emailAddress, - }) - .expect(403); - - expect(accountDataSource.updateAccountEmail).not.toHaveBeenCalled(); - expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); - expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); - expect( - accountDataSource.setEmailVerificationSentDate, - ).not.toHaveBeenCalled(); - }); - - it('returns 403 if token is not yet valid', async () => { - const chain = chainBuilder().build(); - const emailAddress = faker.internet.email(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', signerAddress) - .build(); - const notBefore = faker.date.future(); - const accessToken = jwtService.sign(authPayloadDto, { - notBefore: getSecondsUntil(notBefore), - }); - - expect(() => jwtService.verify(accessToken)).toThrow('jwt not active'); - await request(app.getHttpServer()) - .put( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .send({ - emailAddress, - }) - .expect(403); - - expect(accountDataSource.updateAccountEmail).not.toHaveBeenCalled(); - expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); - expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); - expect( - accountDataSource.setEmailVerificationSentDate, - ).not.toHaveBeenCalled(); - }); - - it('returns 403 if token has expired', async () => { - const chain = chainBuilder().build(); - const emailAddress = faker.internet.email(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', signerAddress) - .build(); - const accessToken = jwtService.sign(authPayloadDto, { - expiresIn: 0, // Now - }); - jest.advanceTimersByTime(1_000); - - expect(() => jwtService.verify(accessToken)).toThrow('jwt expired'); - await request(app.getHttpServer()) - .put( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .send({ - emailAddress, - }) - .expect(403); - - expect(accountDataSource.updateAccountEmail).not.toHaveBeenCalled(); - expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); - expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); - expect( - accountDataSource.setEmailVerificationSentDate, - ).not.toHaveBeenCalled(); - }); - - it('returns 403 if signer_address is not a valid Ethereum address', async () => { - const chain = chainBuilder().build(); - const emailAddress = faker.internet.email(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', faker.string.numeric() as `0x${string}`) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - - expect(() => jwtService.verify(accessToken)).not.toThrow(); - await request(app.getHttpServer()) - .put( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .send({ - emailAddress, - }) - .expect(403); - - expect(accountDataSource.updateAccountEmail).not.toHaveBeenCalled(); - expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); - expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); - expect( - accountDataSource.setEmailVerificationSentDate, - ).not.toHaveBeenCalled(); - }); - - it('returns 403 if chain_id is not a valid chain ID', async () => { - const chain = chainBuilder().build(); - const emailAddress = faker.internet.email(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', faker.string.alpha()) - .with('signer_address', signerAddress) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - - expect(() => jwtService.verify(accessToken)).not.toThrow(); - await request(app.getHttpServer()) - .put( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .send({ - emailAddress, - }) - .expect(403); - - expect(accountDataSource.updateAccountEmail).not.toHaveBeenCalled(); - expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); - expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); - expect( - accountDataSource.setEmailVerificationSentDate, - ).not.toHaveBeenCalled(); - }); - - // Note: this could be removed as we checksum the :signer but for robustness we should keep it - it.each([ - // non-checksummed address - { - signer_address: faker.finance.ethereumAddress().toLowerCase(), - }, - // checksummed address - { - signer_address: getAddress(faker.finance.ethereumAddress()), - }, - ])( - 'returns 401 if signer_address does not match a checksummed signer request', - async ({ signer_address }) => { - const chain = chainBuilder().build(); - const emailAddress = faker.internet.email(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', signer_address as `0x${string}`) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - - expect(() => jwtService.verify(accessToken)).not.toThrow(); - await request(app.getHttpServer()) - .put( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${ - // non-checksummed - signerAddress.toLowerCase() - }`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .send({ - emailAddress, - }) - .expect(401); - - expect(accountDataSource.updateAccountEmail).not.toHaveBeenCalled(); - expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); - expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); - expect( - accountDataSource.setEmailVerificationSentDate, - ).not.toHaveBeenCalled(); - }, - ); - - it.each([ - // non-checksummed address - { - signer_address: faker.finance.ethereumAddress().toLowerCase(), - }, - // checksummed address - { - signer_address: getAddress(faker.finance.ethereumAddress()), - }, - ])( - 'returns 401 if signer_address does not match a non-checksummed signer request', - async ({ signer_address }) => { - const chain = chainBuilder().build(); - const emailAddress = faker.internet.email(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', signer_address as `0x${string}`) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - - expect(() => jwtService.verify(accessToken)).not.toThrow(); - await request(app.getHttpServer()) - .put( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${ - // checksummed - getAddress(signerAddress) - }`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .send({ - emailAddress, - }) - .expect(401); - - expect(accountDataSource.updateAccountEmail).not.toHaveBeenCalled(); - expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); - expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); - expect( - accountDataSource.setEmailVerificationSentDate, - ).not.toHaveBeenCalled(); - }, - ); - - it('returns 401 if chain_id does not match the request', async () => { - const chain = chainBuilder().build(); - const emailAddress = faker.internet.email(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', faker.string.numeric({ exclude: [chain.chainId] })) - .with('signer_address', signerAddress) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - - expect(() => jwtService.verify(accessToken)).not.toThrow(); - await request(app.getHttpServer()) - .put( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .send({ - emailAddress, - }) - .expect(401); - - expect(accountDataSource.updateAccountEmail).not.toHaveBeenCalled(); - expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); - expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); - expect( - accountDataSource.setEmailVerificationSentDate, - ).not.toHaveBeenCalled(); - }); - - it('should return 409 if trying to edit with the same email', async () => { - const chain = chainBuilder().build(); - const emailAddress = faker.internet.email(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', signerAddress) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/safes/${safe.address}`: - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(new Error(`Could not match ${url}`)); - } - }); - accountDataSource.getAccount.mockResolvedValue({ - emailAddress: new EmailAddress(emailAddress), - } as Account); - - await request(app.getHttpServer()) - .put( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .send({ - emailAddress, - }) - .expect(409) - .expect({ - statusCode: 409, - message: 'Email address matches that of the Safe owner.', - }); - - expect(accountDataSource.updateAccountEmail).toHaveBeenCalledTimes(0); - expect(accountDataSource.setEmailVerificationCode).toHaveBeenCalledTimes(0); - expect( - accountDataSource.setEmailVerificationSentDate, - ).toHaveBeenCalledTimes(0); - }); - - it('should return 404 if trying to edit a non-existent email entry', async () => { - const chain = chainBuilder().build(); - const emailAddress = faker.internet.email(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', signerAddress) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/safes/${safe.address}`: - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(new Error(`Could not match ${url}`)); - } - }); - accountDataSource.getAccount.mockRejectedValue( - new AccountDoesNotExistError(chain.chainId, safe.address, signerAddress), - ); - - await request(app.getHttpServer()) - .put( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .send({ - emailAddress, - }) - .expect(404) - .expect({ - statusCode: 404, - message: `No email address was found for the provided signer ${signerAddress}.`, - }); - - expect(accountDataSource.updateAccountEmail).toHaveBeenCalledTimes(0); - expect(accountDataSource.setEmailVerificationCode).toHaveBeenCalledTimes(0); - expect( - accountDataSource.setEmailVerificationSentDate, - ).toHaveBeenCalledTimes(0); - }); - - it('return 500 if updating fails in general', async () => { - const chain = chainBuilder().build(); - const prevEmailAddress = faker.internet.email(); - const emailAddress = faker.internet.email(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', signerAddress) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/safes/${safe.address}`: - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(new Error(`Could not match ${url}`)); - } - }); - accountDataSource.getAccount.mockResolvedValue({ - emailAddress: new EmailAddress(prevEmailAddress), - } as Account); - accountDataSource.updateAccountEmail.mockRejectedValue(new Error()); - - await request(app.getHttpServer()) - .put( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .send({ - emailAddress, - }) - .expect(500) - .expect({ - code: 500, - message: 'Internal server error', - }); - - expect(accountDataSource.updateAccountEmail).toHaveBeenCalledTimes(1); - expect(accountDataSource.setEmailVerificationCode).toHaveBeenCalledTimes(0); - expect( - accountDataSource.setEmailVerificationSentDate, - ).toHaveBeenCalledTimes(0); - }); -}); diff --git a/src/routes/email/email.controller.get-email.spec.ts b/src/routes/email/email.controller.get-email.spec.ts deleted file mode 100644 index c522dfb6d8..0000000000 --- a/src/routes/email/email.controller.get-email.spec.ts +++ /dev/null @@ -1,365 +0,0 @@ -import { INestApplication } from '@nestjs/common'; -import configuration from '@/config/entities/__tests__/configuration'; -import { Test, TestingModule } from '@nestjs/testing'; -import { AppModule } from '@/app.module'; -import { EmailControllerModule } from '@/routes/email/email.controller.module'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; -import { CacheModule } from '@/datasources/cache/cache.module'; -import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; -import { RequestScopedLoggingModule } from '@/logging/logging.module'; -import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; -import { NetworkModule } from '@/datasources/network/network.module'; -import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; -import { TestAppProvider } from '@/__tests__/test-app.provider'; -import request from 'supertest'; -import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; -import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; -import { authPayloadDtoBuilder } from '@/domain/auth/entities/__tests__/auth-payload-dto.entity.builder'; -import { IAccountDataSource } from '@/domain/interfaces/account.datasource.interface'; -import { accountBuilder } from '@/domain/account/entities/__tests__/account.builder'; -import { faker } from '@faker-js/faker'; -import { AccountDoesNotExistError } from '@/domain/account/errors/account-does-not-exist.error'; -import { getAddress } from 'viem'; -import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; -import { getSecondsUntil } from '@/domain/common/utils/time'; -import jwtConfiguration from '@/datasources/jwt/configuration/__tests__/jwt.configuration'; -import { - JWT_CONFIGURATION_MODULE, - JwtConfigurationModule, -} from '@/datasources/jwt/configuration/jwt.configuration.module'; -import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; -import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; -import { Server } from 'net'; - -describe('Email controller get email tests', () => { - let app: INestApplication; - let accountDataSource: jest.MockedObjectDeep; - let jwtService: IJwtService; - - beforeEach(async () => { - jest.resetAllMocks(); - jest.useFakeTimers(); - - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule.register(configuration), EmailControllerModule], - }) - .overrideModule(JWT_CONFIGURATION_MODULE) - .useModule(JwtConfigurationModule.register(jwtConfiguration)) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) - .overrideModule(CacheModule) - .useModule(TestCacheModule) - .overrideModule(RequestScopedLoggingModule) - .useModule(TestLoggingModule) - .overrideModule(NetworkModule) - .useModule(TestNetworkModule) - .overrideModule(QueuesApiModule) - .useModule(TestQueuesApiModule) - .compile(); - - accountDataSource = moduleFixture.get(IAccountDataSource); - jwtService = moduleFixture.get(IJwtService); - - app = await new TestAppProvider().provide(moduleFixture); - await app.init(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - afterAll(async () => { - await app.close(); - }); - - it.each([ - // non-checksummed address - { - safeAddress: faker.finance.ethereumAddress().toLowerCase(), - }, - // checksummed address - { - safeAddress: getAddress(faker.finance.ethereumAddress()), - }, - ])('Retrieves email if correctly authenticated', async ({ safeAddress }) => { - const chain = chainBuilder().build(); - const safe = safeBuilder() - // Allow test of non-checksummed address by casting - .with('address', safeAddress as `0x${string}`) - .build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', signerAddress) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - const account = accountBuilder() - .with('signer', signerAddress) - .with('chainId', chain.chainId) - .with('safeAddress', getAddress(safe.address)) - .build(); - accountDataSource.getAccount.mockResolvedValue(account); - - expect(() => jwtService.verify(accessToken)).not.toThrow(); - await request(app.getHttpServer()) - .get( - `/v1/chains/${chain.chainId}/safes/${safeAddress}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .expect(200) - .expect({ - email: account.emailAddress.value, - verified: account.isVerified, - }); - - expect(accountDataSource.getAccount).toHaveBeenCalledTimes(1); - expect(accountDataSource.getAccount).toHaveBeenCalledWith({ - chainId: chain.chainId.toString(), - // Should always call with the checksummed address - safeAddress: getAddress(safeAddress), - signer: signerAddress, - }); - }); - - it('Returns 403 if no token is present', async () => { - const chain = chainBuilder().build(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - - await request(app.getHttpServer()) - .get( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .expect(403); - - expect(accountDataSource.getAccount).not.toHaveBeenCalled(); - }); - - it('returns 403 if token is not a valid JWT', async () => { - const chain = chainBuilder().build(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const accessToken = faker.string.alphanumeric(); - - expect(() => jwtService.verify(accessToken)).toThrow('jwt malformed'); - await request(app.getHttpServer()) - .get( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .expect(403); - - expect(accountDataSource.getAccount).not.toHaveBeenCalled(); - }); - - it('returns 403 is token it not yet valid', async () => { - const chain = chainBuilder().build(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', signerAddress) - .build(); - const notBefore = faker.date.future(); - const accessToken = jwtService.sign(authPayloadDto, { - notBefore: getSecondsUntil(notBefore), - }); - - expect(() => jwtService.verify(accessToken)).toThrow('jwt not active'); - await request(app.getHttpServer()) - .get( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .expect(403); - - expect(accountDataSource.getAccount).not.toHaveBeenCalled(); - }); - - it('returns 403 if token has expired', async () => { - const chain = chainBuilder().build(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', signerAddress) - .build(); - const accessToken = jwtService.sign(authPayloadDto, { - expiresIn: 0, // Now - }); - jest.advanceTimersByTime(1_000); - - expect(() => jwtService.verify(accessToken)).toThrow('jwt expired'); - await request(app.getHttpServer()) - .get( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .expect(403); - - expect(accountDataSource.getAccount).not.toHaveBeenCalled(); - }); - - it('returns 403 if signer_address is not a valid Ethereum address', async () => { - const chain = chainBuilder().build(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', faker.string.numeric() as `0x${string}`) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - - expect(() => jwtService.verify(accessToken)).not.toThrow(); - await request(app.getHttpServer()) - .get( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .expect(403); - - expect(accountDataSource.getAccount).not.toHaveBeenCalled(); - }); - - it('returns 403 if chain_id is not a valid chain ID', async () => { - const chain = chainBuilder().build(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', faker.string.alpha()) - .with('signer_address', signerAddress) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - - expect(() => jwtService.verify(accessToken)).not.toThrow(); - await request(app.getHttpServer()) - .get( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .expect(403); - - expect(accountDataSource.getAccount).not.toHaveBeenCalled(); - }); - - // Note: this could be removed as we checksum the :signer but for robustness we should keep it - it.each([ - // non-checksummed address - { - signer_address: faker.finance.ethereumAddress().toLowerCase(), - }, - // checksummed address - { - signer_address: getAddress(faker.finance.ethereumAddress()), - }, - ])( - 'returns 401 if signer_address does not match a non-checksummed signer request', - async ({ signer_address }) => { - const chain = chainBuilder().build(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', signer_address as `0x${string}`) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - - expect(() => jwtService.verify(accessToken)).not.toThrow(); - await request(app.getHttpServer()) - .get( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${ - // non-checksummed - signerAddress.toLowerCase() - }`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .expect(401); - - expect(accountDataSource.getAccount).not.toHaveBeenCalled(); - }, - ); - - it.each([ - // non-checksummed address - { - signer_address: faker.finance.ethereumAddress().toLowerCase(), - }, - // checksummed address - { - signer_address: getAddress(faker.finance.ethereumAddress()), - }, - ])( - 'returns 401 if signer_address does not match a checksummed signer request', - async ({ signer_address }) => { - const chain = chainBuilder().build(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', signer_address as `0x${string}`) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - - expect(() => jwtService.verify(accessToken)).not.toThrow(); - await request(app.getHttpServer()) - .get( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${ - // checksummed - getAddress(signerAddress) - }`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .expect(401); - - expect(accountDataSource.getAccount).not.toHaveBeenCalled(); - }, - ); - - it('Returns 401 if chain_id does not match the request', async () => { - const chain = chainBuilder().build(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', faker.string.numeric({ exclude: [chain.chainId] })) - .with('signer_address', signerAddress) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - - expect(() => jwtService.verify(accessToken)).not.toThrow(); - await request(app.getHttpServer()) - .get( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .expect(401); - - expect(accountDataSource.getAccount).not.toHaveBeenCalled(); - }); - - it('Returns 404 if signer has no emails', async () => { - const chain = chainBuilder().build(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', signerAddress) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - accountDataSource.getAccount.mockRejectedValue( - new AccountDoesNotExistError(chain.chainId, safe.address, signerAddress), - ); - - expect(() => jwtService.verify(accessToken)).not.toThrow(); - await request(app.getHttpServer()) - .get( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, - ) - .set('Cookie', [`access_token=${accessToken}`]) - .expect(404) - .expect({ - message: `No email address was found for the provided signer ${signerAddress}.`, - statusCode: 404, - }); - }); -}); diff --git a/src/routes/email/email.controller.module.ts b/src/routes/email/email.controller.module.ts deleted file mode 100644 index 606f2a4953..0000000000 --- a/src/routes/email/email.controller.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from '@nestjs/common'; -import { AccountDomainModule } from '@/domain/account/account.domain.module'; -import { EmailController } from '@/routes/email/email.controller'; -import { EmailService } from '@/routes/email/email.service'; -import { SafeRepositoryModule } from '@/domain/safe/safe.repository.interface'; -import { AuthRepositoryModule } from '@/domain/auth/auth.repository.interface'; - -@Module({ - imports: [AccountDomainModule, SafeRepositoryModule, AuthRepositoryModule], - providers: [EmailService], - controllers: [EmailController], -}) -export class EmailControllerModule {} diff --git a/src/routes/email/email.controller.resend-verification.spec.ts b/src/routes/email/email.controller.resend-verification.spec.ts deleted file mode 100644 index b54a5a287c..0000000000 --- a/src/routes/email/email.controller.resend-verification.spec.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AppModule } from '@/app.module'; -import { CacheModule } from '@/datasources/cache/cache.module'; -import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; -import configuration from '@/config/entities/__tests__/configuration'; -import { RequestScopedLoggingModule } from '@/logging/logging.module'; -import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; -import { NetworkModule } from '@/datasources/network/network.module'; -import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; -import { TestAppProvider } from '@/__tests__/test-app.provider'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; -import request from 'supertest'; -import { IAccountDataSource } from '@/domain/interfaces/account.datasource.interface'; -import { EmailControllerModule } from '@/routes/email/email.controller.module'; -import { INestApplication } from '@nestjs/common'; -import { accountBuilder } from '@/domain/account/entities/__tests__/account.builder'; -import { verificationCodeBuilder } from '@/domain/account/entities/__tests__/verification-code.builder'; -import { faker } from '@faker-js/faker'; -import { getAddress } from 'viem'; -import jwtConfiguration from '@/datasources/jwt/configuration/__tests__/jwt.configuration'; -import { - JWT_CONFIGURATION_MODULE, - JwtConfigurationModule, -} from '@/datasources/jwt/configuration/jwt.configuration.module'; -import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; -import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; -import { Server } from 'net'; - -const resendLockWindowMs = 100; -const ttlMs = 1000; -describe('Email controller resend verification tests', () => { - let app: INestApplication; - let accountDataSource: jest.MockedObjectDeep; - - beforeEach(async () => { - jest.resetAllMocks(); - jest.useFakeTimers(); - - const defaultTestConfiguration = configuration(); - const testConfiguration: typeof configuration = () => ({ - ...defaultTestConfiguration, - email: { - ...defaultTestConfiguration['email'], - verificationCode: { - resendLockWindowMs: resendLockWindowMs, - ttlMs: ttlMs, - }, - }, - }); - - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule.register(testConfiguration), EmailControllerModule], - }) - .overrideModule(JWT_CONFIGURATION_MODULE) - .useModule(JwtConfigurationModule.register(jwtConfiguration)) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) - .overrideModule(CacheModule) - .useModule(TestCacheModule) - .overrideModule(RequestScopedLoggingModule) - .useModule(TestLoggingModule) - .overrideModule(NetworkModule) - .useModule(TestNetworkModule) - .overrideModule(QueuesApiModule) - .useModule(TestQueuesApiModule) - .compile(); - - accountDataSource = moduleFixture.get(IAccountDataSource); - - app = await new TestAppProvider().provide(moduleFixture); - await app.init(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - afterAll(async () => { - await app.close(); - }); - - it.each([ - // non-checksummed address - { safeAddress: faker.finance.ethereumAddress().toLowerCase() }, - // checksummed address - { safeAddress: getAddress(faker.finance.ethereumAddress()) }, - ])('resends email verification successfully', async ({ safeAddress }) => { - const account = accountBuilder().with('isVerified', false).build(); - const verificationCode = verificationCodeBuilder() - .with('generatedOn', new Date()) - .with('sentOn', new Date()) - .build(); - accountDataSource.getAccount.mockResolvedValueOnce(account); - accountDataSource.getAccountVerificationCode.mockResolvedValue( - verificationCode, - ); - accountDataSource.setEmailVerificationSentDate.mockResolvedValueOnce( - verificationCode, - ); - - // Advance timer by the minimum amount of time required to resend email - jest.advanceTimersByTime(resendLockWindowMs); - await request(app.getHttpServer()) - .post( - `/v1/chains/${account.chainId}/safes/${safeAddress}/emails/${account.signer}/verify-resend`, - ) - .expect(202) - .expect({}); - - expect(accountDataSource.setEmailVerificationCode).toHaveBeenCalledTimes(0); - expect(accountDataSource.getAccountVerificationCode).toHaveBeenCalledTimes( - 2, - ); - expect( - accountDataSource.setEmailVerificationSentDate, - ).toHaveBeenCalledTimes(1); - expect(accountDataSource.getAccount).toHaveBeenCalledWith({ - chainId: account.chainId, - // Should always call with the checksummed address - safeAddress: getAddress(safeAddress), - signer: account.signer, - }); - }); - - it('triggering email resend within lock window returns 202', async () => { - const account = accountBuilder().with('isVerified', false).build(); - const verificationCode = verificationCodeBuilder() - .with('generatedOn', new Date()) - .with('sentOn', new Date()) - .build(); - accountDataSource.getAccount.mockResolvedValue(account); - accountDataSource.getAccountVerificationCode.mockResolvedValue( - verificationCode, - ); - - // Advance timer to a time within resendLockWindowMs - jest.advanceTimersByTime(resendLockWindowMs - 1); - await request(app.getHttpServer()) - .post( - `/v1/chains/${account.chainId}/safes/${account.safeAddress}/emails/${account.signer}/verify-resend`, - ) - .expect(202) - .expect(''); - }); - - it('triggering email resend on verified emails returns 202', async () => { - const account = accountBuilder().with('isVerified', true).build(); - accountDataSource.getAccount.mockResolvedValue(account); - - jest.advanceTimersByTime(resendLockWindowMs); - await request(app.getHttpServer()) - .post( - `/v1/chains/${account.chainId}/safes/${account.safeAddress}/emails/${account.signer}/verify-resend`, - ) - .expect(202) - .expect(''); - }); - - it('resend email with new code', async () => { - const account = accountBuilder().with('isVerified', false).build(); - const verificationCode = verificationCodeBuilder() - .with('generatedOn', new Date()) - .with('sentOn', new Date()) - .build(); - accountDataSource.getAccount.mockResolvedValueOnce(account); - accountDataSource.getAccountVerificationCode.mockResolvedValueOnce( - verificationCode, - ); - accountDataSource.getAccountVerificationCode.mockResolvedValueOnce( - verificationCodeBuilder().build(), - ); - - // Advance timer so that code is considered as expired - jest.advanceTimersByTime(ttlMs); - await request(app.getHttpServer()) - .post( - `/v1/chains/${account.chainId}/safes/${account.safeAddress}/emails/${account.signer}/verify-resend`, - ) - .expect(202) - .expect({}); - - // TODO 3rd party mock checking that the new code was sent out (and not the old one) - }); -}); diff --git a/src/routes/email/email.controller.save-email.spec.ts b/src/routes/email/email.controller.save-email.spec.ts deleted file mode 100644 index c5350d9664..0000000000 --- a/src/routes/email/email.controller.save-email.spec.ts +++ /dev/null @@ -1,419 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AppModule } from '@/app.module'; -import { CacheModule } from '@/datasources/cache/cache.module'; -import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; -import configuration from '@/config/entities/__tests__/configuration'; -import { RequestScopedLoggingModule } from '@/logging/logging.module'; -import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; -import { NetworkModule } from '@/datasources/network/network.module'; -import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; -import { TestAppProvider } from '@/__tests__/test-app.provider'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; -import request from 'supertest'; -import { faker } from '@faker-js/faker'; -import { authPayloadDtoBuilder } from '@/domain/auth/entities/__tests__/auth-payload-dto.entity.builder'; -import { IConfigurationService } from '@/config/configuration.service.interface'; -import { - INetworkService, - NetworkService, -} from '@/datasources/network/network.service.interface'; -import { IAccountDataSource } from '@/domain/interfaces/account.datasource.interface'; -import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; -import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; -import { getAddress } from 'viem'; -import { EmailControllerModule } from '@/routes/email/email.controller.module'; -import { IEmailApi } from '@/domain/interfaces/email-api.interface'; -import { TestEmailApiModule } from '@/datasources/email-api/__tests__/test.email-api.module'; -import { EmailApiModule } from '@/datasources/email-api/email-api.module'; -import { INestApplication } from '@nestjs/common'; -import { accountBuilder } from '@/domain/account/entities/__tests__/account.builder'; -import { verificationCodeBuilder } from '@/domain/account/entities/__tests__/verification-code.builder'; -import { EmailAddress } from '@/domain/account/entities/account.entity'; -import jwtConfiguration from '@/datasources/jwt/configuration/__tests__/jwt.configuration'; -import { - JWT_CONFIGURATION_MODULE, - JwtConfigurationModule, -} from '@/datasources/jwt/configuration/jwt.configuration.module'; -import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; -import { getSecondsUntil } from '@/domain/common/utils/time'; -import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; -import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; -import { Server } from 'net'; - -describe('Email controller save email tests', () => { - let app: INestApplication; - let configurationService: jest.MockedObjectDeep; - let emailApi: jest.MockedObjectDeep; - let accountDataSource: jest.MockedObjectDeep; - let networkService: jest.MockedObjectDeep; - let safeConfigUrl: string | undefined; - let jwtService: IJwtService; - - beforeEach(async () => { - jest.resetAllMocks(); - jest.useFakeTimers(); - - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule.register(configuration), EmailControllerModule], - }) - .overrideModule(JWT_CONFIGURATION_MODULE) - .useModule(JwtConfigurationModule.register(jwtConfiguration)) - .overrideModule(EmailApiModule) - .useModule(TestEmailApiModule) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) - .overrideModule(CacheModule) - .useModule(TestCacheModule) - .overrideModule(RequestScopedLoggingModule) - .useModule(TestLoggingModule) - .overrideModule(NetworkModule) - .useModule(TestNetworkModule) - .overrideModule(QueuesApiModule) - .useModule(TestQueuesApiModule) - .compile(); - - configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); - emailApi = moduleFixture.get(IEmailApi); - accountDataSource = moduleFixture.get(IAccountDataSource); - networkService = moduleFixture.get(NetworkService); - jwtService = moduleFixture.get(IJwtService); - - app = await new TestAppProvider().provide(moduleFixture); - await app.init(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - afterAll(async () => { - await app.close(); - }); - - it.each([ - // non-checksummed address - { safeAddress: faker.finance.ethereumAddress().toLowerCase() }, - // checksummed address - { safeAddress: getAddress(faker.finance.ethereumAddress()) }, - ])('stores email successfully', async ({ safeAddress }) => { - const chain = chainBuilder().build(); - const emailAddress = faker.internet.email(); - const safe = safeBuilder() - // Allow test of non-checksummed address by casting - .with('address', safeAddress as `0x${string}`) - .build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', signerAddress) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - // Schema validation checksums address of Safe - case `${chain.transactionService}/api/v1/safes/${getAddress(safe.address)}`: - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(new Error(`Could not match ${url}`)); - } - }); - accountDataSource.createAccount.mockResolvedValue([ - accountBuilder() - .with('chainId', chain.chainId) - .with('emailAddress', new EmailAddress(emailAddress)) - .with('safeAddress', safe.address) - .with('signer', signerAddress) - .with('isVerified', false) - .build(), - verificationCodeBuilder().build(), - ]); - accountDataSource.subscribe.mockResolvedValue([ - { - key: faker.word.sample(), - name: faker.word.words(2), - }, - ]); - emailApi.createMessage.mockResolvedValue(); - accountDataSource.setEmailVerificationSentDate.mockResolvedValue( - verificationCodeBuilder().build(), - ); - - expect(() => jwtService.verify(accessToken)).not.toThrow(); - await request(app.getHttpServer()) - .post(`/v1/chains/${chain.chainId}/safes/${safeAddress}/emails`) - .set('Cookie', [`access_token=${accessToken}`]) - .send({ - emailAddress: emailAddress, - signer: signerAddress, - }) - .expect(201) - .expect({}); - - expect(emailApi.createMessage).toHaveBeenCalledTimes(1); - expect(emailApi.createMessage).toHaveBeenNthCalledWith(1, { - subject: 'Verification code', - substitutions: { verificationCode: expect.any(String) }, - template: configurationService.getOrThrow( - 'email.templates.verificationCode', - ), - to: [emailAddress], - }); - expect(accountDataSource.createAccount).toHaveBeenCalledWith({ - chainId: chain.chainId, - // Should always store the checksummed address - safeAddress: getAddress(safeAddress), - emailAddress: new EmailAddress(emailAddress), - signer: signerAddress, - code: expect.any(String), - codeGenerationDate: expect.any(Date), - unsubscriptionToken: expect.any(String), - }); - expect(accountDataSource.subscribe).toHaveBeenCalledWith({ - chainId: chain.chainId, - // should be called with checksummed address - safeAddress: getAddress(safeAddress), - signer: signerAddress, - notificationTypeKey: 'account_recovery', - }); - }); - - it('returns 403 if no token is present', async () => { - const chain = chainBuilder().build(); - const emailAddress = faker.internet.email(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - - await request(app.getHttpServer()) - .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/emails`) - .send({ - emailAddress: emailAddress, - signer: signerAddress, - }) - .expect(403); - - expect(emailApi.createMessage).not.toHaveBeenCalled(); - expect( - accountDataSource.setEmailVerificationSentDate, - ).not.toHaveBeenCalled(); - expect(accountDataSource.createAccount).not.toHaveBeenCalled(); - expect(accountDataSource.subscribe).not.toHaveBeenCalled(); - }); - - it('returns 403 if token is not a valid JWT', async () => { - const chain = chainBuilder().build(); - const emailAddress = faker.internet.email(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const accessToken = faker.string.alphanumeric(); - - expect(() => jwtService.verify(accessToken)).toThrow('jwt malformed'); - await request(app.getHttpServer()) - .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/emails`) - .set('Cookie', [`access_token=${accessToken}`]) - .send({ - emailAddress: emailAddress, - signer: signerAddress, - }) - .expect(403); - - expect(emailApi.createMessage).not.toHaveBeenCalled(); - expect( - accountDataSource.setEmailVerificationSentDate, - ).not.toHaveBeenCalled(); - expect(accountDataSource.createAccount).not.toHaveBeenCalled(); - expect(accountDataSource.subscribe).not.toHaveBeenCalled(); - }); - - it('returns 403 if token is not yet valid', async () => { - const chain = chainBuilder().build(); - const emailAddress = faker.internet.email(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', signerAddress) - .build(); - const notBefore = faker.date.future(); - const accessToken = jwtService.sign(authPayloadDto, { - notBefore: getSecondsUntil(notBefore), - }); - - expect(() => jwtService.verify(accessToken)).toThrow('jwt not active'); - await request(app.getHttpServer()) - .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/emails`) - .set('Cookie', [`access_token=${accessToken}`]) - .send({ - emailAddress: emailAddress, - signer: signerAddress, - }) - .expect(403); - - expect(emailApi.createMessage).not.toHaveBeenCalled(); - expect( - accountDataSource.setEmailVerificationSentDate, - ).not.toHaveBeenCalled(); - expect(accountDataSource.createAccount).not.toHaveBeenCalled(); - expect(accountDataSource.subscribe).not.toHaveBeenCalled(); - }); - - it('returns 403 if token has expired', async () => { - const chain = chainBuilder().build(); - const emailAddress = faker.internet.email(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', signerAddress) - .build(); - const accessToken = jwtService.sign(authPayloadDto, { - expiresIn: 0, // Now - }); - jest.advanceTimersByTime(1_000); - - expect(() => jwtService.verify(accessToken)).toThrow('jwt expired'); - await request(app.getHttpServer()) - .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/emails`) - .set('Cookie', [`access_token=${accessToken}`]) - .send({ - emailAddress: emailAddress, - signer: signerAddress, - }) - .expect(403); - - expect(emailApi.createMessage).not.toHaveBeenCalled(); - expect( - accountDataSource.setEmailVerificationSentDate, - ).not.toHaveBeenCalled(); - expect(accountDataSource.createAccount).not.toHaveBeenCalled(); - expect(accountDataSource.subscribe).not.toHaveBeenCalled(); - }); - - it('returns 403 if signer_address is not a valid Ethereum address', async () => { - const chain = chainBuilder().build(); - const emailAddress = faker.internet.email(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .with('signer_address', faker.string.numeric() as `0x${string}`) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - - expect(() => jwtService.verify(accessToken)).not.toThrow(); - await request(app.getHttpServer()) - .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/emails`) - .set('Cookie', [`access_token=${accessToken}`]) - .send({ - emailAddress: emailAddress, - signer: signerAddress, - }) - .expect(403); - - expect(emailApi.createMessage).not.toHaveBeenCalled(); - expect( - accountDataSource.setEmailVerificationSentDate, - ).not.toHaveBeenCalled(); - expect(accountDataSource.createAccount).not.toHaveBeenCalled(); - expect(accountDataSource.subscribe).not.toHaveBeenCalled(); - }); - - it('returns 403 if chain_id is not a valid chain ID', async () => { - const chain = chainBuilder().build(); - const emailAddress = faker.internet.email(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', faker.string.alpha()) - .with('signer_address', signerAddress) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - - expect(() => jwtService.verify(accessToken)).not.toThrow(); - await request(app.getHttpServer()) - .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/emails`) - .set('Cookie', [`access_token=${accessToken}`]) - .send({ - emailAddress: emailAddress, - signer: signerAddress, - }) - .expect(403); - - expect(emailApi.createMessage).not.toHaveBeenCalled(); - expect( - accountDataSource.setEmailVerificationSentDate, - ).not.toHaveBeenCalled(); - expect(accountDataSource.createAccount).not.toHaveBeenCalled(); - expect(accountDataSource.subscribe).not.toHaveBeenCalled(); - }); - - it('returns 401 if chain_id does not match the request', async () => { - const chain = chainBuilder().build(); - const emailAddress = faker.internet.email(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', faker.string.numeric({ exclude: [chain.chainId] })) - .with('signer_address', signerAddress) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - - expect(() => jwtService.verify(accessToken)).not.toThrow(); - await request(app.getHttpServer()) - .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/emails`) - .set('Cookie', [`access_token=${accessToken}`]) - .send({ - emailAddress: emailAddress, - signer: signerAddress, - }) - .expect(401); - - expect(emailApi.createMessage).not.toHaveBeenCalled(); - expect( - accountDataSource.setEmailVerificationSentDate, - ).not.toHaveBeenCalled(); - expect(accountDataSource.createAccount).not.toHaveBeenCalled(); - expect(accountDataSource.subscribe).not.toHaveBeenCalled(); - }); - - it('returns 401 if not an owner of the Safe', async () => { - const chain = chainBuilder().build(); - const emailAddress = faker.internet.email(); - const safe = safeBuilder().build(); - const signerAddress = safe.owners[0]; - const authPayloadDto = authPayloadDtoBuilder() - .with('chain_id', chain.chainId) - .build(); - const accessToken = jwtService.sign(authPayloadDto); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/safes/${safe.address}`: - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(new Error(`Could not match ${url}`)); - } - }); - - expect(() => jwtService.verify(accessToken)).not.toThrow(); - await request(app.getHttpServer()) - .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/emails`) - .set('Cookie', [`access_token=${accessToken}`]) - .send({ - emailAddress: emailAddress, - signer: signerAddress, - }) - .expect(401); - - expect(emailApi.createMessage).not.toHaveBeenCalled(); - expect( - accountDataSource.setEmailVerificationSentDate, - ).not.toHaveBeenCalled(); - expect(accountDataSource.createAccount).not.toHaveBeenCalled(); - expect(accountDataSource.subscribe).not.toHaveBeenCalled(); - }); -}); diff --git a/src/routes/email/email.controller.ts b/src/routes/email/email.controller.ts deleted file mode 100644 index 9d8b9df536..0000000000 --- a/src/routes/email/email.controller.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { - Body, - Controller, - Delete, - Get, - HttpCode, - HttpStatus, - Param, - Post, - Put, - UseFilters, - UseGuards, -} from '@nestjs/common'; -import { EmailService } from '@/routes/email/email.service'; -import { - SaveEmailDto, - SaveEmailDtoSchema, -} from '@/routes/email/entities/save-email-dto.entity'; -import { ApiExcludeController, ApiTags } from '@nestjs/swagger'; -import { VerifyEmailDto } from '@/routes/email/entities/verify-email-dto.entity'; -import { AccountDoesNotExistExceptionFilter } from '@/routes/email/exception-filters/account-does-not-exist.exception-filter'; -import { EditEmailDto } from '@/routes/email/entities/edit-email-dto.entity'; -import { EmailEditMatchesExceptionFilter } from '@/routes/email/exception-filters/email-edit-matches.exception-filter'; -import { AuthGuard } from '@/routes/auth/guards/auth.guard'; -import { Email } from '@/routes/email/entities/email.entity'; -import { UnauthenticatedExceptionFilter } from '@/routes/email/exception-filters/unauthenticated.exception-filter'; -import { Auth } from '@/routes/auth/decorators/auth.decorator'; -import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; -import { ValidationPipe } from '@/validation/pipes/validation.pipe'; -import { AddressSchema } from '@/validation/entities/schemas/address.schema'; - -@ApiTags('email') -@Controller({ - path: 'chains/:chainId/safes/:safeAddress/emails', - version: '1', -}) -@ApiExcludeController() -export class EmailController { - constructor(private readonly service: EmailService) {} - - @Get(':signer') - @UseGuards(AuthGuard) - @UseFilters(AccountDoesNotExistExceptionFilter) - async getEmail( - @Param('chainId') chainId: string, - @Param('safeAddress', new ValidationPipe(AddressSchema)) - safeAddress: `0x${string}`, - @Param('signer', new ValidationPipe(AddressSchema)) signer: `0x${string}`, - @Auth() authPayload: AuthPayload, - ): Promise { - return this.service.getEmail({ - chainId, - safeAddress, - signer, - authPayload, - }); - } - - @Post('') - @UseGuards(AuthGuard) - async saveEmail( - @Param('chainId') chainId: string, - @Param('safeAddress', new ValidationPipe(AddressSchema)) - safeAddress: `0x${string}`, - @Body(new ValidationPipe(SaveEmailDtoSchema)) saveEmailDto: SaveEmailDto, - @Auth() authPayload: AuthPayload, - ): Promise { - await this.service.saveEmail({ - chainId, - emailAddress: saveEmailDto.emailAddress, - safeAddress, - signer: saveEmailDto.signer, - authPayload, - }); - } - - @Post(':signer/verify-resend') - @UseFilters(new UnauthenticatedExceptionFilter(HttpStatus.ACCEPTED)) - @HttpCode(HttpStatus.ACCEPTED) - async resendVerification( - @Param('chainId') chainId: string, - @Param('safeAddress', new ValidationPipe(AddressSchema)) - safeAddress: `0x${string}`, - @Param('signer', new ValidationPipe(AddressSchema)) signer: `0x${string}`, - ): Promise { - await this.service.resendVerification({ - chainId, - safeAddress, - signer, - }); - } - - @Put(':signer/verify') - @UseFilters(new UnauthenticatedExceptionFilter(HttpStatus.BAD_REQUEST)) - @HttpCode(HttpStatus.NO_CONTENT) - async verifyEmailAddress( - @Param('chainId') chainId: string, - @Param('safeAddress', new ValidationPipe(AddressSchema)) - safeAddress: `0x${string}`, - @Param('signer', new ValidationPipe(AddressSchema)) signer: `0x${string}`, - @Body() verifyEmailDto: VerifyEmailDto, - ): Promise { - await this.service.verifyEmailAddress({ - chainId, - safeAddress, - signer, - code: verifyEmailDto.code, - }); - } - - @Delete(':signer') - @UseGuards(AuthGuard) - @UseFilters(AccountDoesNotExistExceptionFilter) - @HttpCode(HttpStatus.NO_CONTENT) - async deleteEmail( - @Param('chainId') chainId: string, - @Param('safeAddress', new ValidationPipe(AddressSchema)) - safeAddress: `0x${string}`, - @Param('signer', new ValidationPipe(AddressSchema)) signer: `0x${string}`, - @Auth() authPayload: AuthPayload, - ): Promise { - await this.service.deleteEmail({ - chainId, - safeAddress, - signer, - authPayload, - }); - } - - @Put(':signer') - @UseGuards(AuthGuard) - @UseFilters( - EmailEditMatchesExceptionFilter, - AccountDoesNotExistExceptionFilter, - ) - @HttpCode(HttpStatus.ACCEPTED) - async editEmail( - @Param('chainId') chainId: string, - @Param('safeAddress', new ValidationPipe(AddressSchema)) - safeAddress: `0x${string}`, - @Param('signer', new ValidationPipe(AddressSchema)) signer: `0x${string}`, - @Body() editEmailDto: EditEmailDto, - @Auth() authPayload: AuthPayload, - ): Promise { - await this.service.editEmail({ - chainId, - safeAddress, - signer, - emailAddress: editEmailDto.emailAddress, - authPayload, - }); - } -} diff --git a/src/routes/email/email.controller.verify-email.spec.ts b/src/routes/email/email.controller.verify-email.spec.ts deleted file mode 100644 index a19910947a..0000000000 --- a/src/routes/email/email.controller.verify-email.spec.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AppModule } from '@/app.module'; -import { CacheModule } from '@/datasources/cache/cache.module'; -import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; -import configuration from '@/config/entities/__tests__/configuration'; -import { RequestScopedLoggingModule } from '@/logging/logging.module'; -import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; -import { NetworkModule } from '@/datasources/network/network.module'; -import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; -import { TestAppProvider } from '@/__tests__/test-app.provider'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; -import request from 'supertest'; -import { faker } from '@faker-js/faker'; -import { IAccountDataSource } from '@/domain/interfaces/account.datasource.interface'; -import { EmailControllerModule } from '@/routes/email/email.controller.module'; -import { INestApplication } from '@nestjs/common'; -import { accountBuilder } from '@/domain/account/entities/__tests__/account.builder'; -import { verificationCodeBuilder } from '@/domain/account/entities/__tests__/verification-code.builder'; -import jwtConfiguration from '@/datasources/jwt/configuration/__tests__/jwt.configuration'; -import { - JWT_CONFIGURATION_MODULE, - JwtConfigurationModule, -} from '@/datasources/jwt/configuration/jwt.configuration.module'; -import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; -import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; -import { Server } from 'net'; - -const resendLockWindowMs = 100; -const ttlMs = 1000; - -describe('Email controller verify email tests', () => { - let app: INestApplication; - let accountDataSource: jest.MockedObjectDeep; - - beforeEach(async () => { - jest.resetAllMocks(); - jest.useFakeTimers(); - - const defaultTestConfiguration = configuration(); - const testConfiguration: typeof configuration = () => ({ - ...defaultTestConfiguration, - email: { - ...defaultTestConfiguration['email'], - verificationCode: { - resendLockWindowMs: resendLockWindowMs, - ttlMs: ttlMs, - }, - }, - }); - - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule.register(testConfiguration), EmailControllerModule], - }) - .overrideModule(JWT_CONFIGURATION_MODULE) - .useModule(JwtConfigurationModule.register(jwtConfiguration)) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) - .overrideModule(CacheModule) - .useModule(TestCacheModule) - .overrideModule(RequestScopedLoggingModule) - .useModule(TestLoggingModule) - .overrideModule(NetworkModule) - .useModule(TestNetworkModule) - .overrideModule(QueuesApiModule) - .useModule(TestQueuesApiModule) - .compile(); - - accountDataSource = moduleFixture.get(IAccountDataSource); - - app = await new TestAppProvider().provide(moduleFixture); - await app.init(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - afterAll(async () => { - await app.close(); - }); - - it('verifies email successfully', async () => { - const account = accountBuilder().with('isVerified', false).build(); - const verificationCode = verificationCodeBuilder().build(); - accountDataSource.getAccount.mockResolvedValue(account); - accountDataSource.getAccountVerificationCode.mockResolvedValue( - verificationCode, - ); - - jest.advanceTimersByTime(ttlMs - 1); - await request(app.getHttpServer()) - .put( - `/v1/chains/${account.chainId}/safes/${account.safeAddress}/emails/${account.signer}/verify`, - ) - .send({ - code: verificationCode.code, - }) - .expect(204) - .expect({}); - - expect(accountDataSource.verifyEmail).toHaveBeenCalledTimes(1); - }); - - it('returns 400 on already verified emails', async () => { - const account = accountBuilder().with('isVerified', true).build(); - accountDataSource.getAccount.mockResolvedValueOnce(account); - - jest.advanceTimersByTime(ttlMs - 1); - await request(app.getHttpServer()) - .put( - `/v1/chains/${account.chainId}/safes/${account.safeAddress}/emails/${account.signer}/verify`, - ) - .send({ - code: faker.string.numeric({ length: 6 }), - }) - .expect(400) - .expect(''); - - expect(accountDataSource.verifyEmail).toHaveBeenCalledTimes(0); - expect(accountDataSource.getAccountVerificationCode).toHaveBeenCalledTimes( - 0, - ); - }); - - it('email verification with expired code returns 400', async () => { - const account = accountBuilder().with('isVerified', false).build(); - accountDataSource.getAccount.mockResolvedValueOnce(account); - const verificationCode = verificationCodeBuilder().build(); - accountDataSource.getAccountVerificationCode.mockResolvedValue( - verificationCode, - ); - - jest.advanceTimersByTime(ttlMs); - await request(app.getHttpServer()) - .put( - `/v1/chains/${account.chainId}/safes/${account.safeAddress}/emails/${account.signer}/verify`, - ) - .send({ - account: account.signer, - code: verificationCode.code, - }) - .expect(400) - .expect(''); - - expect(accountDataSource.verifyEmail).toHaveBeenCalledTimes(0); - }); - - it('email verification with wrong code returns 400', async () => { - const account = accountBuilder().with('isVerified', false).build(); - accountDataSource.getAccount.mockResolvedValueOnce(account); - const verificationCode = verificationCodeBuilder().build(); - accountDataSource.getAccountVerificationCode.mockResolvedValue( - verificationCode, - ); - - jest.advanceTimersByTime(ttlMs - 1); - await request(app.getHttpServer()) - .put( - `/v1/chains/${account.chainId}/safes/${account.safeAddress}/emails/${account.signer}/verify`, - ) - .send({ - account: account.signer, - code: faker.string.numeric({ length: 6 }), - }) - .expect(400) - .expect(''); - - expect(accountDataSource.verifyEmail).toHaveBeenCalledTimes(0); - }); -}); diff --git a/src/routes/email/email.service.ts b/src/routes/email/email.service.ts deleted file mode 100644 index 9e88cd4e28..0000000000 --- a/src/routes/email/email.service.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { - Inject, - Injectable, - UnprocessableEntityException, -} from '@nestjs/common'; -import { IAccountRepository } from '@/domain/account/account.repository.interface'; -import { Email } from '@/routes/email/entities/email.entity'; -import { InvalidAddressError } from 'viem'; -import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; - -@Injectable() -export class EmailService { - constructor( - @Inject(IAccountRepository) private readonly repository: IAccountRepository, - ) {} - - private _mapInvalidAddressError(e: unknown): never { - if (e instanceof InvalidAddressError) { - throw new UnprocessableEntityException(e.shortMessage); - } - throw e; - } - - async saveEmail(args: { - chainId: string; - safeAddress: `0x${string}`; - emailAddress: string; - signer: `0x${string}`; - authPayload: AuthPayload; - }): Promise { - return this.repository - .createAccount(args) - .catch((e) => this._mapInvalidAddressError(e)); - } - - async resendVerification(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - }): Promise { - return this.repository - .resendEmailVerification(args) - .catch((e) => this._mapInvalidAddressError(e)); - } - - async verifyEmailAddress(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - code: string; - }): Promise { - return this.repository - .verifyEmailAddress(args) - .catch((e) => this._mapInvalidAddressError(e)); - } - - async deleteEmail(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - authPayload: AuthPayload; - }): Promise { - return this.repository - .deleteAccount(args) - .catch((e) => this._mapInvalidAddressError(e)); - } - - async editEmail(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - emailAddress: string; - authPayload: AuthPayload; - }): Promise { - return this.repository - .editEmail(args) - .catch((e) => this._mapInvalidAddressError(e)); - } - - async getEmail(args: { - chainId: string; - safeAddress: `0x${string}`; - signer: `0x${string}`; - authPayload: AuthPayload; - }): Promise { - const account = await this.repository - .getAccount(args) - .catch((e) => this._mapInvalidAddressError(e)); - - return new Email(account.emailAddress.value, account.isVerified); - } -} diff --git a/src/routes/email/entities/__tests__/edit-email-dto.entity.spec.ts b/src/routes/email/entities/__tests__/edit-email-dto.entity.spec.ts deleted file mode 100644 index bf80850c0c..0000000000 --- a/src/routes/email/entities/__tests__/edit-email-dto.entity.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - EditEmailDto, - EditEmailDtoSchema, -} from '@/routes/email/entities/edit-email-dto.entity'; -import { faker } from '@faker-js/faker'; - -describe('EditEmailDtoSchema', () => { - it('should allow a valid EditEmailDto', () => { - const editEmailDto: EditEmailDto = { - emailAddress: faker.internet.email(), - }; - - const result = EditEmailDtoSchema.safeParse(editEmailDto); - - expect(result.success).toBe(true); - }); - - it('should not allow a non-email emailAddress', () => { - const editEmailDto: EditEmailDto = { - emailAddress: faker.lorem.word(), - }; - - const result = EditEmailDtoSchema.safeParse(editEmailDto); - - expect(!result.success && result.error.issues).toStrictEqual([ - { - code: 'invalid_string', - message: 'Invalid email', - path: ['emailAddress'], - validation: 'email', - }, - ]); - }); -}); diff --git a/src/routes/email/entities/__tests__/save-email-dto.entity.spec.ts b/src/routes/email/entities/__tests__/save-email-dto.entity.spec.ts deleted file mode 100644 index 5be81da2fb..0000000000 --- a/src/routes/email/entities/__tests__/save-email-dto.entity.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { - SaveEmailDto, - SaveEmailDtoSchema, -} from '@/routes/email/entities/save-email-dto.entity'; -import { faker } from '@faker-js/faker'; -import { getAddress } from 'viem'; - -describe('SaveEmailDtoSchema', () => { - it('should allow a valid SaveEmailDto', () => { - const saveEmailDto: SaveEmailDto = { - emailAddress: faker.internet.email(), - signer: getAddress(faker.finance.ethereumAddress()), - }; - - const result = SaveEmailDtoSchema.safeParse(saveEmailDto); - - expect(result.success).toBe(true); - }); - - it('should not allow a non-email emailAddress', () => { - const saveEmailDto: SaveEmailDto = { - emailAddress: faker.lorem.word(), - signer: getAddress(faker.finance.ethereumAddress()), - }; - - const result = SaveEmailDtoSchema.safeParse(saveEmailDto); - - expect(!result.success && result.error.issues).toStrictEqual([ - { - code: 'invalid_string', - message: 'Invalid email', - path: ['emailAddress'], - validation: 'email', - }, - ]); - }); - - it('should not allow a non-Ethereum address signer', () => { - const saveEmailDto: SaveEmailDto = { - emailAddress: faker.internet.email(), - signer: faker.string.alphanumeric() as `0x${string}`, - }; - - const result = SaveEmailDtoSchema.safeParse(saveEmailDto); - - expect(!result.success && result.error.issues).toStrictEqual([ - { - code: 'custom', - message: 'Invalid address', - path: ['signer'], - }, - ]); - }); - - it('should checksum the signer', () => { - const saveEmailDto: SaveEmailDto = { - emailAddress: faker.internet.email(), - signer: faker.finance.ethereumAddress().toLowerCase() as `0x${string}`, // not checksummed - }; - - const result = SaveEmailDtoSchema.safeParse(saveEmailDto); - - expect(result.success && result.data.signer).toBe( - getAddress(saveEmailDto.signer), - ); - }); -}); diff --git a/src/routes/email/entities/edit-email-dto.entity.ts b/src/routes/email/entities/edit-email-dto.entity.ts deleted file mode 100644 index 20564a4f5b..0000000000 --- a/src/routes/email/entities/edit-email-dto.entity.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { z } from 'zod'; - -export class EditEmailDto implements z.infer { - @ApiProperty() - emailAddress!: string; -} - -export const EditEmailDtoSchema = z.object({ - emailAddress: z.string().email(), -}); diff --git a/src/routes/email/entities/email.entity.ts b/src/routes/email/entities/email.entity.ts deleted file mode 100644 index 496e571970..0000000000 --- a/src/routes/email/entities/email.entity.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; - -export class Email { - @ApiProperty() - email: string; - - @ApiProperty() - verified: boolean; - - constructor(email: string, verified: boolean) { - this.email = email; - this.verified = verified; - } -} diff --git a/src/routes/email/entities/save-email-dto.entity.ts b/src/routes/email/entities/save-email-dto.entity.ts deleted file mode 100644 index 5c8f5d268b..0000000000 --- a/src/routes/email/entities/save-email-dto.entity.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { AddressSchema } from '@/validation/entities/schemas/address.schema'; -import { ApiProperty } from '@nestjs/swagger'; -import { z } from 'zod'; - -export class SaveEmailDto implements z.infer { - @ApiProperty() - emailAddress!: string; - - @ApiProperty() - signer!: `0x${string}`; -} - -export const SaveEmailDtoSchema = z.object({ - emailAddress: z.string().email(), - signer: AddressSchema, -}); diff --git a/src/routes/email/entities/verify-email-dto.entity.ts b/src/routes/email/entities/verify-email-dto.entity.ts deleted file mode 100644 index b0ec147b26..0000000000 --- a/src/routes/email/entities/verify-email-dto.entity.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; - -export class VerifyEmailDto { - @ApiProperty() - code!: string; -} diff --git a/src/routes/email/exception-filters/account-does-not-exist.exception-filter.ts b/src/routes/email/exception-filters/account-does-not-exist.exception-filter.ts deleted file mode 100644 index f193e91c1b..0000000000 --- a/src/routes/email/exception-filters/account-does-not-exist.exception-filter.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { - ArgumentsHost, - Catch, - ExceptionFilter, - HttpStatus, -} from '@nestjs/common'; -import { Response } from 'express'; -import { AccountDoesNotExistError } from '@/domain/account/errors/account-does-not-exist.error'; - -@Catch(AccountDoesNotExistError) -export class AccountDoesNotExistExceptionFilter implements ExceptionFilter { - catch(exception: AccountDoesNotExistError, host: ArgumentsHost): void { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - - response.status(HttpStatus.NOT_FOUND).json({ - message: `No email address was found for the provided signer ${exception.signer}.`, - statusCode: HttpStatus.NOT_FOUND, - }); - } -} diff --git a/src/routes/email/exception-filters/email-edit-matches.exception-filter.ts b/src/routes/email/exception-filters/email-edit-matches.exception-filter.ts deleted file mode 100644 index 44b2fb482e..0000000000 --- a/src/routes/email/exception-filters/email-edit-matches.exception-filter.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { - ArgumentsHost, - Catch, - ExceptionFilter, - HttpStatus, -} from '@nestjs/common'; -import { Response } from 'express'; -import { EmailEditMatchesError } from '@/domain/account/errors/email-edit-matches.error'; - -@Catch(EmailEditMatchesError) -export class EmailEditMatchesExceptionFilter implements ExceptionFilter { - catch(exception: EmailEditMatchesError, host: ArgumentsHost): void { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - - response.status(HttpStatus.CONFLICT).json({ - message: 'Email address matches that of the Safe owner.', - statusCode: HttpStatus.CONFLICT, - }); - } -} diff --git a/src/routes/email/exception-filters/invalid-verification-code.exception-filter.ts b/src/routes/email/exception-filters/invalid-verification-code.exception-filter.ts deleted file mode 100644 index b458636937..0000000000 --- a/src/routes/email/exception-filters/invalid-verification-code.exception-filter.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { - ArgumentsHost, - Catch, - ExceptionFilter, - HttpStatus, -} from '@nestjs/common'; -import { Response } from 'express'; -import { InvalidVerificationCodeError } from '@/domain/account/errors/invalid-verification-code.error'; - -@Catch(InvalidVerificationCodeError) -export class InvalidVerificationCodeExceptionFilter implements ExceptionFilter { - catch(exception: InvalidVerificationCodeError, host: ArgumentsHost): void { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - - response.status(HttpStatus.BAD_REQUEST).json({ - message: `The provided verification code is not valid.`, - statusCode: HttpStatus.BAD_REQUEST, - }); - } -} diff --git a/src/routes/email/exception-filters/unauthenticated.exception-filter.ts b/src/routes/email/exception-filters/unauthenticated.exception-filter.ts deleted file mode 100644 index 8589d3499c..0000000000 --- a/src/routes/email/exception-filters/unauthenticated.exception-filter.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - ArgumentsHost, - Catch, - ExceptionFilter, - HttpStatus, -} from '@nestjs/common'; -import { Response } from 'express'; - -/** - * The UnauthenticatedExceptionFilter can be used on routes which are not - * authenticated. - * - * When used, this exception filter would catch any instance of {@link Error} - * and return to the clients the provided status code with an empty body. - * - * This is specially useful for unauthenticated routes which need to provide - * feedback to the clients on a successful response while masking all errors - * under one response. - */ -@Catch(Error) -export class UnauthenticatedExceptionFilter implements ExceptionFilter { - constructor(private readonly statusCode: HttpStatus) {} - - catch(exception: unknown, host: ArgumentsHost): void { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - - response.status(this.statusCode).json(); - } -} diff --git a/src/routes/estimations/estimations.controller.spec.ts b/src/routes/estimations/estimations.controller.spec.ts index 40d088b3ac..10fd962de6 100644 --- a/src/routes/estimations/estimations.controller.spec.ts +++ b/src/routes/estimations/estimations.controller.spec.ts @@ -25,8 +25,6 @@ import { INetworkService, NetworkService, } from '@/datasources/network/network.service.interface'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; @@ -43,8 +41,6 @@ describe('Estimations Controller (Unit)', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(configuration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/health/health.controller.spec.ts b/src/routes/health/health.controller.spec.ts index 7217b16731..c9c511bdff 100644 --- a/src/routes/health/health.controller.spec.ts +++ b/src/routes/health/health.controller.spec.ts @@ -12,8 +12,6 @@ import { INestApplication } from '@nestjs/common'; import { CacheService } from '@/datasources/cache/cache.service.interface'; import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.service'; import request from 'supertest'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { @@ -33,8 +31,6 @@ describe('Health Controller tests', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(configuration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/messages/messages.controller.spec.ts b/src/routes/messages/messages.controller.spec.ts index 177dd724e8..5ec04ec022 100644 --- a/src/routes/messages/messages.controller.spec.ts +++ b/src/routes/messages/messages.controller.spec.ts @@ -30,8 +30,6 @@ import { updateMessageSignatureDtoBuilder } from '@/routes/messages/entities/__t import { MessageStatus } from '@/routes/messages/entities/message.entity'; import { SafeApp } from '@/routes/safe-apps/entities/safe-app.entity'; import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; @@ -47,8 +45,6 @@ describe('Messages controller', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(configuration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/notifications/notifications.controller.spec.ts b/src/routes/notifications/notifications.controller.spec.ts index 1fd3f56e6a..755b231a12 100644 --- a/src/routes/notifications/notifications.controller.spec.ts +++ b/src/routes/notifications/notifications.controller.spec.ts @@ -20,8 +20,6 @@ import { } from '@/datasources/network/network.service.interface'; import { registerDeviceDtoBuilder } from '@/routes/notifications/entities/__tests__/register-device.dto.builder'; import { safeRegistrationBuilder } from '@/routes/notifications/entities/__tests__/safe-registration.builder'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { RegisterDeviceDto } from '@/routes/notifications/entities/register-device.dto.entity'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; @@ -39,8 +37,6 @@ describe('Notifications Controller (Unit)', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(configuration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/owners/owners.controller.spec.ts b/src/routes/owners/owners.controller.spec.ts index 797d94834a..75484b0391 100644 --- a/src/routes/owners/owners.controller.spec.ts +++ b/src/routes/owners/owners.controller.spec.ts @@ -18,8 +18,6 @@ import { CacheModule } from '@/datasources/cache/cache.module'; import { RequestScopedLoggingModule } from '@/logging/logging.module'; import { NetworkModule } from '@/datasources/network/network.module'; import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { getAddress } from 'viem'; import { pageBuilder } from '@/domain/entities/__tests__/page.builder'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; @@ -37,8 +35,6 @@ describe('Owners Controller (Unit)', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(configuration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/recovery/recovery.controller.spec.ts b/src/routes/recovery/recovery.controller.spec.ts index ab84aa64db..65ae44a46e 100644 --- a/src/routes/recovery/recovery.controller.spec.ts +++ b/src/routes/recovery/recovery.controller.spec.ts @@ -18,8 +18,6 @@ import { RequestScopedLoggingModule } from '@/logging/logging.module'; import { addRecoveryModuleDtoBuilder } from '@/routes/recovery/entities/__tests__/add-recovery-module.dto.builder'; import configuration from '@/config/entities/__tests__/configuration'; import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; @@ -73,8 +71,6 @@ describe('Recovery (Unit)', () => { }) .overrideModule(JWT_CONFIGURATION_MODULE) .useModule(JwtConfigurationModule.register(jwtConfiguration)) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(ALERTS_CONFIGURATION_MODULE) .useModule(AlertsConfigurationModule.register(alertsConfiguration)) .overrideModule(ALERTS_API_CONFIGURATION_MODULE) diff --git a/src/routes/relay/relay.controller.spec.ts b/src/routes/relay/relay.controller.spec.ts index ce4d96c5b5..a5a7befab8 100644 --- a/src/routes/relay/relay.controller.spec.ts +++ b/src/routes/relay/relay.controller.spec.ts @@ -9,8 +9,6 @@ import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; import { NetworkModule } from '@/datasources/network/network.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; import { TestAppProvider } from '@/__tests__/test-app.provider'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { INetworkService, @@ -100,8 +98,6 @@ describe('Relay controller', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(testConfiguration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/root/root.controller.spec.ts b/src/routes/root/root.controller.spec.ts index e1d056cc04..0bd66d160d 100644 --- a/src/routes/root/root.controller.spec.ts +++ b/src/routes/root/root.controller.spec.ts @@ -6,8 +6,6 @@ import { TestAppProvider } from '@/__tests__/test-app.provider'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { CacheModule } from '@/datasources/cache/cache.module'; import request from 'supertest'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; @@ -19,8 +17,6 @@ describe('Root Controller tests', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(configuration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(QueuesApiModule) diff --git a/src/routes/safe-apps/safe-apps.controller.spec.ts b/src/routes/safe-apps/safe-apps.controller.spec.ts index 7157b9f88b..54562d0e98 100644 --- a/src/routes/safe-apps/safe-apps.controller.spec.ts +++ b/src/routes/safe-apps/safe-apps.controller.spec.ts @@ -20,8 +20,6 @@ import { INetworkService, NetworkService, } from '@/datasources/network/network.service.interface'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; @@ -37,8 +35,6 @@ describe('Safe Apps Controller (Unit)', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(configuration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/safes/safes.controller.nonces.spec.ts b/src/routes/safes/safes.controller.nonces.spec.ts index 771c6e03e9..3cbb566cd4 100644 --- a/src/routes/safes/safes.controller.nonces.spec.ts +++ b/src/routes/safes/safes.controller.nonces.spec.ts @@ -22,8 +22,6 @@ import { } from '@/domain/safe/entities/__tests__/multisig-transaction.builder'; import { pageBuilder } from '@/domain/entities/__tests__/page.builder'; import { INestApplication } from '@nestjs/common'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; @@ -40,8 +38,6 @@ describe('Safes Controller Nonces (Unit)', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(configuration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/safes/safes.controller.overview.spec.ts b/src/routes/safes/safes.controller.overview.spec.ts index da27d1d546..208e7d1592 100644 --- a/src/routes/safes/safes.controller.overview.spec.ts +++ b/src/routes/safes/safes.controller.overview.spec.ts @@ -18,8 +18,6 @@ import { INetworkService, NetworkService, } from '@/datasources/network/network.service.interface'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { faker } from '@faker-js/faker'; import { balanceBuilder } from '@/domain/balances/entities/__tests__/balance.builder'; import { balanceTokenBuilder } from '@/domain/balances/entities/__tests__/balance.token.builder'; @@ -61,8 +59,6 @@ describe('Safes Controller Overview (Unit)', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(testConfiguration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/safes/safes.controller.spec.ts b/src/routes/safes/safes.controller.spec.ts index 07959bf56e..df59375a15 100644 --- a/src/routes/safes/safes.controller.spec.ts +++ b/src/routes/safes/safes.controller.spec.ts @@ -38,8 +38,6 @@ import { NetworkService, } from '@/datasources/network/network.service.interface'; import { NULL_ADDRESS } from '@/routes/common/constants'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; @@ -56,8 +54,6 @@ describe('Safes Controller (Unit)', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(configuration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/subscriptions/subscription.controller.spec.ts b/src/routes/subscriptions/subscription.controller.spec.ts deleted file mode 100644 index c6af6b448f..0000000000 --- a/src/routes/subscriptions/subscription.controller.spec.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AppModule } from '@/app.module'; -import configuration from '@/config/entities/__tests__/configuration'; -import { EmailControllerModule } from '@/routes/email/email.controller.module'; -import { EmailApiModule } from '@/datasources/email-api/email-api.module'; -import { TestEmailApiModule } from '@/datasources/email-api/__tests__/test.email-api.module'; -import { CacheModule } from '@/datasources/cache/cache.module'; -import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; -import { RequestScopedLoggingModule } from '@/logging/logging.module'; -import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; -import { NetworkModule } from '@/datasources/network/network.module'; -import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; -import { TestAppProvider } from '@/__tests__/test-app.provider'; -import request from 'supertest'; -import { faker } from '@faker-js/faker'; -import { Subscription } from '@/domain/account/entities/subscription.entity'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { IAccountDataSource } from '@/domain/interfaces/account.datasource.interface'; -import { INestApplication } from '@nestjs/common'; -import { - AlertsConfigurationModule, - ALERTS_CONFIGURATION_MODULE, -} from '@/routes/alerts/configuration/alerts.configuration.module'; -import alertsConfiguration from '@/routes/alerts/configuration/__tests__/alerts.configuration'; -import { - AlertsApiConfigurationModule, - ALERTS_API_CONFIGURATION_MODULE, -} from '@/datasources/alerts-api/configuration/alerts-api.configuration.module'; -import alertsApiConfiguration from '@/datasources/alerts-api/configuration/__tests__/alerts-api.configuration'; -import jwtConfiguration from '@/datasources/jwt/configuration/__tests__/jwt.configuration'; -import { - JWT_CONFIGURATION_MODULE, - JwtConfigurationModule, -} from '@/datasources/jwt/configuration/jwt.configuration.module'; -import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; -import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; -import { Server } from 'net'; - -describe('Subscription Controller tests', () => { - let app: INestApplication; - let accountDataSource: jest.MockedObjectDeep; - - beforeEach(async () => { - jest.resetAllMocks(); - jest.useFakeTimers(); - - const defaultTestConfiguration = configuration(); - const testConfiguration: typeof configuration = () => ({ - ...defaultTestConfiguration, - features: { - ...defaultTestConfiguration.features, - email: true, - }, - }); - - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule.register(testConfiguration), EmailControllerModule], - }) - .overrideModule(JWT_CONFIGURATION_MODULE) - .useModule(JwtConfigurationModule.register(jwtConfiguration)) - .overrideModule(ALERTS_CONFIGURATION_MODULE) - .useModule(AlertsConfigurationModule.register(alertsConfiguration)) - .overrideModule(ALERTS_API_CONFIGURATION_MODULE) - .useModule(AlertsApiConfigurationModule.register(alertsApiConfiguration)) - .overrideModule(EmailApiModule) - .useModule(TestEmailApiModule) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) - .overrideModule(CacheModule) - .useModule(TestCacheModule) - .overrideModule(RequestScopedLoggingModule) - .useModule(TestLoggingModule) - .overrideModule(NetworkModule) - .useModule(TestNetworkModule) - .overrideModule(QueuesApiModule) - .useModule(TestQueuesApiModule) - .compile(); - - accountDataSource = moduleFixture.get(IAccountDataSource); - - app = await new TestAppProvider().provide(moduleFixture); - await app.init(); - }); - - afterAll(async () => { - await app.close(); - }); - - it('unsubscribes from a category successfully', async () => { - const subscriptionKey = faker.word.sample(); - const subscriptionName = faker.word.sample(2); - const token = faker.string.uuid(); - const subscriptions = [ - { - key: subscriptionKey, - name: subscriptionName, - }, - ] as Subscription[]; - accountDataSource.unsubscribe.mockResolvedValueOnce(subscriptions); - - await request(app.getHttpServer()) - .delete(`/v1/subscriptions/?category=${subscriptionKey}&token=${token}`) - .expect(200) - .expect({}); - - expect(accountDataSource.unsubscribe).toHaveBeenCalledWith({ - notificationTypeKey: subscriptionKey, - token: token, - }); - expect(accountDataSource.unsubscribeAll).toHaveBeenCalledTimes(0); - }); - - it('validates uuid format when deleting category', async () => { - const subscriptionKey = faker.word.sample(); - const token = faker.string.hexadecimal(); - - await request(app.getHttpServer()) - .delete(`/v1/subscriptions/?category=${subscriptionKey}&token=${token}`) - .expect(400) - .expect({ - message: 'Validation failed (uuid is expected)', - error: 'Bad Request', - statusCode: 400, - }); - - expect(accountDataSource.unsubscribe).toHaveBeenCalledTimes(0); - expect(accountDataSource.unsubscribeAll).toHaveBeenCalledTimes(0); - }); - - it('deleting category is not successful', async () => { - const subscriptionKey = faker.word.sample(); - const token = faker.string.uuid(); - accountDataSource.unsubscribe.mockRejectedValueOnce( - new Error('some error'), - ); - - await request(app.getHttpServer()) - .delete(`/v1/subscriptions/?category=${subscriptionKey}&token=${token}`) - .expect(500) - .expect({ code: 500, message: 'Internal server error' }); - }); - - it('deletes all categories successfully', async () => { - const subscriptionKey = faker.word.sample(); - const subscriptionName = faker.word.sample(2); - const token = faker.string.uuid(); - const subscriptions = [ - { - key: subscriptionKey, - name: subscriptionName, - }, - ] as Subscription[]; - accountDataSource.unsubscribeAll.mockResolvedValueOnce(subscriptions); - - await request(app.getHttpServer()) - .delete(`/v1/subscriptions/all?token=${token}`) - .expect(200) - .expect({}); - - expect(accountDataSource.unsubscribeAll).toHaveBeenCalledWith({ - token: token, - }); - expect(accountDataSource.unsubscribe).toHaveBeenCalledTimes(0); - }); - - it('validates uuid format when deleting all categories', async () => { - const subscriptionKey = faker.word.sample(); - const subscriptionName = faker.word.sample(2); - const token = faker.string.hexadecimal(); - const subscriptions = [ - { - key: subscriptionKey, - name: subscriptionName, - }, - ] as Subscription[]; - accountDataSource.unsubscribe.mockResolvedValueOnce(subscriptions); - - await request(app.getHttpServer()) - .delete(`/v1/subscriptions/all?token=${token}`) - .expect(400) - .expect({ - message: 'Validation failed (uuid is expected)', - error: 'Bad Request', - statusCode: 400, - }); - - expect(accountDataSource.unsubscribe).toHaveBeenCalledTimes(0); - expect(accountDataSource.unsubscribeAll).toHaveBeenCalledTimes(0); - }); - - it('deleting all categories is not successful', async () => { - const token = faker.string.uuid(); - accountDataSource.unsubscribeAll.mockRejectedValueOnce( - new Error('some error'), - ); - - await request(app.getHttpServer()) - .delete(`/v1/subscriptions/all?token=${token}`) - .expect(500) - .expect({ code: 500, message: 'Internal server error' }); - }); -}); diff --git a/src/routes/subscriptions/subscription.controller.ts b/src/routes/subscriptions/subscription.controller.ts deleted file mode 100644 index d43be921a2..0000000000 --- a/src/routes/subscriptions/subscription.controller.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Controller, Delete, ParseUUIDPipe, Query } from '@nestjs/common'; -import { ApiExcludeController } from '@nestjs/swagger'; -import { SubscriptionService } from '@/routes/subscriptions/subscription.service'; - -@Controller({ - path: 'subscriptions', - version: '1', -}) -@ApiExcludeController() -export class SubscriptionController { - constructor(private readonly service: SubscriptionService) {} - - @Delete() - async unsubscribe( - @Query('category') category: string, - @Query('token', new ParseUUIDPipe()) token: string, - ): Promise { - return this.service.unsubscribe({ notificationTypeKey: category, token }); - } - - @Delete('all') - async unsubscribeAll( - @Query('token', new ParseUUIDPipe()) token: string, - ): Promise { - return this.service.unsubscribeAll({ token }); - } -} diff --git a/src/routes/subscriptions/subscription.module.ts b/src/routes/subscriptions/subscription.module.ts deleted file mode 100644 index 7fcbcf60c4..0000000000 --- a/src/routes/subscriptions/subscription.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from '@nestjs/common'; -import { SubscriptionDomainModule } from '@/domain/subscriptions/subscription.domain.module'; -import { SubscriptionService } from '@/routes/subscriptions/subscription.service'; -import { SubscriptionController } from '@/routes/subscriptions/subscription.controller'; - -@Module({ - imports: [SubscriptionDomainModule], - providers: [SubscriptionService], - controllers: [SubscriptionController], -}) -export class SubscriptionControllerModule {} diff --git a/src/routes/subscriptions/subscription.service.ts b/src/routes/subscriptions/subscription.service.ts deleted file mode 100644 index 4e20274f75..0000000000 --- a/src/routes/subscriptions/subscription.service.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { SubscriptionRepository } from '@/domain/subscriptions/subscription.repository'; -import { ISubscriptionRepository } from '@/domain/subscriptions/subscription.repository.interface'; - -@Injectable() -export class SubscriptionService { - constructor( - @Inject(ISubscriptionRepository) - private readonly repository: SubscriptionRepository, - ) {} - - async unsubscribe(args: { - notificationTypeKey: string; - token: string; - }): Promise { - await this.repository.unsubscribe(args); - } - - async unsubscribeAll(args: { token: string }): Promise { - await this.repository.unsubscribeAll(args); - } -} diff --git a/src/routes/transactions/__tests__/controllers/delete-transaction.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/delete-transaction.transactions.controller.spec.ts index 1808e683f2..b25723714b 100644 --- a/src/routes/transactions/__tests__/controllers/delete-transaction.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/delete-transaction.transactions.controller.spec.ts @@ -16,8 +16,6 @@ import { import { DeleteTransactionDto } from '@/routes/transactions/entities/delete-transaction.dto.entity'; import { AppModule } from '@/app.module'; import { CacheModule } from '@/datasources/cache/cache.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; import { NetworkModule } from '@/datasources/network/network.module'; import { RequestScopedLoggingModule } from '@/logging/logging.module'; import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; @@ -44,8 +42,6 @@ describe('Delete Transaction - Transactions Controller (Unit', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(configuration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/transactions/__tests__/controllers/get-transaction-by-id.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/get-transaction-by-id.transactions.controller.spec.ts index 110e2b8052..6374cb61fa 100644 --- a/src/routes/transactions/__tests__/controllers/get-transaction-by-id.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/get-transaction-by-id.transactions.controller.spec.ts @@ -37,8 +37,6 @@ import { CacheModule } from '@/datasources/cache/cache.module'; import { RequestScopedLoggingModule } from '@/logging/logging.module'; import { NetworkModule } from '@/datasources/network/network.module'; import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; @@ -55,8 +53,6 @@ describe('Get by id - Transactions Controller (Unit)', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(configuration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts index a59af8f6df..92e0994cf1 100644 --- a/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts @@ -34,8 +34,6 @@ import { CacheModule } from '@/datasources/cache/cache.module'; import { RequestScopedLoggingModule } from '@/logging/logging.module'; import { NetworkModule } from '@/datasources/network/network.module'; import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; @@ -52,8 +50,6 @@ describe('List incoming transfers by Safe - Transactions Controller (Unit)', () const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(configuration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/transactions/__tests__/controllers/list-module-transactions-by-safe.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/list-module-transactions-by-safe.transactions.controller.spec.ts index 99557d05c9..2770f8fba1 100644 --- a/src/routes/transactions/__tests__/controllers/list-module-transactions-by-safe.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/list-module-transactions-by-safe.transactions.controller.spec.ts @@ -24,8 +24,6 @@ import { RequestScopedLoggingModule } from '@/logging/logging.module'; import { NetworkModule } from '@/datasources/network/network.module'; import { pageBuilder } from '@/domain/entities/__tests__/page.builder'; import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; @@ -41,8 +39,6 @@ describe('List module transactions by Safe - Transactions Controller (Unit)', () const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(configuration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts index 90b5d825b6..e61738bf8b 100644 --- a/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts @@ -33,8 +33,6 @@ import { CacheModule } from '@/datasources/cache/cache.module'; import { RequestScopedLoggingModule } from '@/logging/logging.module'; import { NetworkModule } from '@/datasources/network/network.module'; import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; @@ -51,8 +49,6 @@ describe('List multisig transactions by Safe - Transactions Controller (Unit)', const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(configuration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts index c9f8255888..06b30ddd0a 100644 --- a/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts @@ -15,8 +15,6 @@ import { import { Operation } from '@/domain/safe/entities/operation.entity'; import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; import configuration from '@/config/entities/__tests__/configuration'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { @@ -43,8 +41,6 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(configuration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts index e55af0a23e..0ac63abe50 100644 --- a/src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts @@ -24,8 +24,6 @@ import { } from '@/datasources/network/network.service.interface'; import { proposeTransactionDtoBuilder } from '@/routes/transactions/entities/__tests__/propose-transaction.dto.builder'; import { AppModule } from '@/app.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; import { CacheModule } from '@/datasources/cache/cache.module'; import { NetworkModule } from '@/datasources/network/network.module'; import { RequestScopedLoggingModule } from '@/logging/logging.module'; @@ -45,8 +43,6 @@ describe('Propose transaction - Transactions Controller (Unit)', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(configuration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/transactions/transactions-history.controller.spec.ts b/src/routes/transactions/transactions-history.controller.spec.ts index cf88134f9c..81ce39e055 100644 --- a/src/routes/transactions/transactions-history.controller.spec.ts +++ b/src/routes/transactions/transactions-history.controller.spec.ts @@ -47,8 +47,6 @@ import { AppModule } from '@/app.module'; import { CacheModule } from '@/datasources/cache/cache.module'; import { RequestScopedLoggingModule } from '@/logging/logging.module'; import { NetworkModule } from '@/datasources/network/network.module'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { erc20TransferBuilder, toJson as erc20TransferToJson, @@ -86,8 +84,6 @@ describe('Transactions History Controller (Unit)', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(testConfiguration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) 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 ba7622122b..1e6f9b649a 100644 --- a/src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts +++ b/src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts @@ -35,8 +35,6 @@ import { AppModule } from '@/app.module'; import { CacheModule } from '@/datasources/cache/cache.module'; import { RequestScopedLoggingModule } from '@/logging/logging.module'; import { NetworkModule } from '@/datasources/network/network.module'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { erc20TransferBuilder, toJson as erc20TransferToJson, @@ -79,8 +77,6 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(testConfiguration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) diff --git a/src/routes/transactions/transactions-view.controller.spec.ts b/src/routes/transactions/transactions-view.controller.spec.ts index b5bc04fb57..b6f90572a7 100644 --- a/src/routes/transactions/transactions-view.controller.spec.ts +++ b/src/routes/transactions/transactions-view.controller.spec.ts @@ -6,8 +6,6 @@ import { import configuration from '@/config/entities/__tests__/configuration'; import { Test, TestingModule } from '@nestjs/testing'; import { AppModule } from '@/app.module'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import { CacheModule } from '@/datasources/cache/cache.module'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { RequestScopedLoggingModule } from '@/logging/logging.module'; @@ -55,8 +53,6 @@ describe('TransactionsViewController tests', () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(testConfiguration)], }) - .overrideModule(AccountDataSourceModule) - .useModule(TestAccountDataSourceModule) .overrideModule(CacheModule) .useModule(TestCacheModule) .overrideModule(RequestScopedLoggingModule) From 8216494cc4584f407e74631a4055241268780faf Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Fri, 14 Jun 2024 16:58:00 +0200 Subject: [PATCH 093/207] Standardise API managers, clearing on `CHAIN_UPDATE` (#1640) Refactors the `IBlockchainApiManager`, `ITransactionApiManager` and `IBalancesApiManager` to implement a common `IApiManager` interface for creating and destroying API instances. The destruction of said instances now happens with the `CHAIN_UPDATE` event: - Create `IApiManager` interface - Refactor `BlockchainApiManager`/`TransactionApiManager`/`BalancesApiManager` to extend and implement `IApiManager` - Add `TransactionsRepository` with `clearApi` method - Add `clearApi` method to `BalancesRepository` - Call all `clearApi` methods after `CHAIN_UPDATE` clears the respective `Chain` entity - Update/add tests accordingly --- .../balances-api/balances-api.manager.spec.ts | 18 +-- .../balances-api/balances-api.manager.ts | 11 +- .../__tests__/fake.blockchain-api.manager.ts | 5 +- .../blockchain/blockchain-api.manager.spec.ts | 22 +-- .../blockchain/blockchain-api.manager.ts | 4 +- .../transaction-api.manager.spec.ts | 2 +- .../transaction-api.manager.ts | 8 +- src/domain/backbone/backbone.repository.ts | 2 +- .../balances/balances.repository.interface.ts | 5 + src/domain/balances/balances.repository.ts | 8 +- .../blockchain.repository.interface.ts | 2 +- .../blockchain/blockchain.repository.ts | 4 +- src/domain/chains/chains.repository.ts | 3 +- .../collectibles/collectibles.repository.ts | 4 +- src/domain/contracts/contracts.repository.ts | 4 +- .../data-decoder/data-decoded.repository.ts | 4 +- src/domain/delegate/delegate.repository.ts | 20 +-- .../delegate/v2/delegates.v2.repository.ts | 15 +- .../estimations/estimations.repository.ts | 4 +- .../interfaces/api.manager.interface.ts | 5 + .../balances-api.manager.interface.ts | 8 +- .../blockchain-api.manager.interface.ts | 7 +- .../transaction-api.manager.interface.ts | 5 +- src/domain/messages/messages.repository.ts | 28 ++-- .../notifications/notifications.repository.ts | 10 +- src/domain/safe/safe.repository.ts | 135 +++++++++++------- src/domain/siwe/siwe.repository.ts | 4 +- src/domain/tokens/token.repository.ts | 10 +- .../transactions.repository.interface.ts | 18 +++ .../transactions/transactions.repository.ts | 16 +++ src/routes/auth/auth.controller.spec.ts | 2 +- .../cache-hooks.controller.spec.ts | 81 ++++++++++- src/routes/cache-hooks/cache-hooks.module.ts | 2 + src/routes/cache-hooks/cache-hooks.service.ts | 10 +- 34 files changed, 320 insertions(+), 166 deletions(-) create mode 100644 src/domain/interfaces/api.manager.interface.ts create mode 100644 src/domain/transactions/transactions.repository.interface.ts create mode 100644 src/domain/transactions/transactions.repository.ts diff --git a/src/datasources/balances-api/balances-api.manager.spec.ts b/src/datasources/balances-api/balances-api.manager.spec.ts index 176d3dd487..ce9bb426db 100644 --- a/src/datasources/balances-api/balances-api.manager.spec.ts +++ b/src/datasources/balances-api/balances-api.manager.spec.ts @@ -38,7 +38,7 @@ const httpErrorFactory = { } as jest.MockedObjectDeep; const transactionApiManagerMock = { - getTransactionApi: jest.fn(), + getApi: jest.fn(), } as jest.MockedObjectDeep; const transactionApiMock = { @@ -77,7 +77,7 @@ beforeEach(() => { }); describe('Balances API Manager Tests', () => { - describe('getBalancesApi checks', () => { + describe('getApi checks', () => { it('should return the Zerion API if the chainId is one of zerionBalancesChainIds', async () => { const manager = new BalancesApiManager( configurationService, @@ -91,7 +91,7 @@ describe('Balances API Manager Tests', () => { ); const safeAddress = getAddress(faker.finance.ethereumAddress()); - const result = await manager.getBalancesApi( + const result = await manager.getApi( sample(ZERION_BALANCES_CHAIN_IDS) as string, safeAddress, ); @@ -111,12 +111,10 @@ describe('Balances API Manager Tests', () => { transactionApiManagerMock, ); const safeAddress = getAddress(faker.finance.ethereumAddress()); - transactionApiManagerMock.getTransactionApi.mockResolvedValue( - transactionApiMock, - ); + transactionApiManagerMock.getApi.mockResolvedValue(transactionApiMock); transactionApiMock.isSafe.mockResolvedValue(false); - const result = await manager.getBalancesApi( + const result = await manager.getApi( faker.string.numeric({ exclude: ZERION_BALANCES_CHAIN_IDS }), safeAddress, ); @@ -169,13 +167,11 @@ describe('Balances API Manager Tests', () => { coingeckoApiMock, transactionApiManagerMock, ); - transactionApiManagerMock.getTransactionApi.mockResolvedValue( - transactionApiMock, - ); + transactionApiManagerMock.getApi.mockResolvedValue(transactionApiMock); transactionApiMock.isSafe.mockResolvedValue(true); const safeAddress = getAddress(faker.finance.ethereumAddress()); - const safeBalancesApi = await balancesApiManager.getBalancesApi( + const safeBalancesApi = await balancesApiManager.getApi( chain.chainId, safeAddress, ); diff --git a/src/datasources/balances-api/balances-api.manager.ts b/src/datasources/balances-api/balances-api.manager.ts index a6c84a86dc..5aac59e375 100644 --- a/src/datasources/balances-api/balances-api.manager.ts +++ b/src/datasources/balances-api/balances-api.manager.ts @@ -48,15 +48,14 @@ export class BalancesApiManager implements IBalancesApiManager { this.zerionBalancesApi = zerionBalancesApi; } - async getBalancesApi( + async getApi( chainId: string, safeAddress: `0x${string}`, ): Promise { if (this.zerionChainIds.includes(chainId)) { return this.zerionBalancesApi; } - const transactionApi = - await this.transactionApiManager.getTransactionApi(chainId); + const transactionApi = await this.transactionApiManager.getApi(chainId); if (!this.isCounterFactualBalancesEnabled) { return this._getSafeBalancesApi(chainId); @@ -96,4 +95,10 @@ export class BalancesApiManager implements IBalancesApiManager { ); return this.safeBalancesApiMap[chainId]; } + + destroyApi(chainId: string): void { + if (this.safeBalancesApiMap[chainId] !== undefined) { + delete this.safeBalancesApiMap[chainId]; + } + } } diff --git a/src/datasources/blockchain/__tests__/fake.blockchain-api.manager.ts b/src/datasources/blockchain/__tests__/fake.blockchain-api.manager.ts index eb7f17396c..5b801499a5 100644 --- a/src/datasources/blockchain/__tests__/fake.blockchain-api.manager.ts +++ b/src/datasources/blockchain/__tests__/fake.blockchain-api.manager.ts @@ -1,8 +1,9 @@ import { IBlockchainApiManager } from '@/domain/interfaces/blockchain-api.manager.interface'; import { Injectable } from '@nestjs/common'; + @Injectable() export class FakeBlockchainApiManager implements IBlockchainApiManager { - getBlockchainApi = jest.fn(); + getApi = jest.fn(); - destroyBlockchainApi = jest.fn(); + destroyApi = jest.fn(); } diff --git a/src/datasources/blockchain/blockchain-api.manager.spec.ts b/src/datasources/blockchain/blockchain-api.manager.spec.ts index 564bf71389..f75daeffa1 100644 --- a/src/datasources/blockchain/blockchain-api.manager.spec.ts +++ b/src/datasources/blockchain/blockchain-api.manager.spec.ts @@ -22,13 +22,13 @@ describe('BlockchainApiManager', () => { target = new BlockchainApiManager(fakeConfigurationService, configApiMock); }); - describe('getBlockchainApi', () => { + describe('getApi', () => { it('caches the API', async () => { const chain = chainBuilder().build(); configApiMock.getChain.mockResolvedValue(chain); - const api = await target.getBlockchainApi(chain.chainId); - const cachedApi = await target.getBlockchainApi(chain.chainId); + const api = await target.getApi(chain.chainId); + const cachedApi = await target.getApi(chain.chainId); expect(api).toBe(cachedApi); }); @@ -46,7 +46,7 @@ describe('BlockchainApiManager', () => { .build(); configApiMock.getChain.mockResolvedValue(chain); - const api = await target.getBlockchainApi(chain.chainId); + const api = await target.getApi(chain.chainId); expect(api.chain?.rpcUrls.default.http[0]).toContain(infuraApiKey); }); @@ -58,7 +58,7 @@ describe('BlockchainApiManager', () => { .build(); configApiMock.getChain.mockResolvedValue(chain); - const api = await target.getBlockchainApi(chain.chainId); + const api = await target.getApi(chain.chainId); expect(api.chain?.rpcUrls.default.http[0]).not.toContain(infuraApiKey); }); @@ -67,20 +67,20 @@ describe('BlockchainApiManager', () => { const chain = chainBuilder().build(); configApiMock.getChain.mockResolvedValue(chain); - const api = await target.getBlockchainApi(chain.chainId); + const api = await target.getApi(chain.chainId); expect(api.chain?.rpcUrls.default.http[0]).not.toContain(infuraApiKey); }); }); - describe('destroyBlockchainApi', () => { - it('destroys the API', async () => { + describe('destroyApi', () => { + it('clears the API', async () => { const chain = chainBuilder().build(); configApiMock.getChain.mockResolvedValue(chain); - const api = await target.getBlockchainApi(chain.chainId); - target.destroyBlockchainApi(chain.chainId); - const cachedApi = await target.getBlockchainApi(chain.chainId); + const api = await target.getApi(chain.chainId); + target.destroyApi(chain.chainId); + const cachedApi = await target.getApi(chain.chainId); expect(api).not.toBe(cachedApi); }); diff --git a/src/datasources/blockchain/blockchain-api.manager.ts b/src/datasources/blockchain/blockchain-api.manager.ts index 420eae6dc9..ddb7c98ff4 100644 --- a/src/datasources/blockchain/blockchain-api.manager.ts +++ b/src/datasources/blockchain/blockchain-api.manager.ts @@ -22,7 +22,7 @@ export class BlockchainApiManager implements IBlockchainApiManager { ); } - async getBlockchainApi(chainId: string): Promise { + async getApi(chainId: string): Promise { const blockchainApi = this.blockchainApiMap[chainId]; if (blockchainApi) { return blockchainApi; @@ -34,7 +34,7 @@ export class BlockchainApiManager implements IBlockchainApiManager { return this.blockchainApiMap[chainId]; } - destroyBlockchainApi(chainId: string): void { + destroyApi(chainId: string): void { if (this.blockchainApiMap?.[chainId]) { delete this.blockchainApiMap[chainId]; } diff --git a/src/datasources/transaction-api/transaction-api.manager.spec.ts b/src/datasources/transaction-api/transaction-api.manager.spec.ts index e8073197fc..3e8ec8a050 100644 --- a/src/datasources/transaction-api/transaction-api.manager.spec.ts +++ b/src/datasources/transaction-api/transaction-api.manager.spec.ts @@ -85,7 +85,7 @@ describe('Transaction API Manager Tests', () => { mockLoggingService, ); - const transactionApi = await target.getTransactionApi(chain.chainId); + const transactionApi = await target.getApi(chain.chainId); await transactionApi.getBackbone(); expect(dataSourceMock.get).toHaveBeenCalledWith({ diff --git a/src/datasources/transaction-api/transaction-api.manager.ts b/src/datasources/transaction-api/transaction-api.manager.ts index b16e44f806..d16a705d3c 100644 --- a/src/datasources/transaction-api/transaction-api.manager.ts +++ b/src/datasources/transaction-api/transaction-api.manager.ts @@ -37,7 +37,7 @@ export class TransactionApiManager implements ITransactionApiManager { ); } - async getTransactionApi(chainId: string): Promise { + async getApi(chainId: string): Promise { const transactionApi = this.transactionApiMap[chainId]; if (transactionApi !== undefined) return transactionApi; @@ -54,4 +54,10 @@ export class TransactionApiManager implements ITransactionApiManager { ); return this.transactionApiMap[chainId]; } + + destroyApi(chainId: string): void { + if (this.transactionApiMap[chainId] !== undefined) { + delete this.transactionApiMap[chainId]; + } + } } diff --git a/src/domain/backbone/backbone.repository.ts b/src/domain/backbone/backbone.repository.ts index 8130708944..7824bdfe94 100644 --- a/src/domain/backbone/backbone.repository.ts +++ b/src/domain/backbone/backbone.repository.ts @@ -12,7 +12,7 @@ export class BackboneRepository implements IBackboneRepository { ) {} async getBackbone(chainId: string): Promise { - const api = await this.transactionApiManager.getTransactionApi(chainId); + const api = await this.transactionApiManager.getApi(chainId); const data = await api.getBackbone(); return BackboneSchema.parse(data); } diff --git a/src/domain/balances/balances.repository.interface.ts b/src/domain/balances/balances.repository.interface.ts index 9dabf3d294..08a93c0f79 100644 --- a/src/domain/balances/balances.repository.interface.ts +++ b/src/domain/balances/balances.repository.interface.ts @@ -32,6 +32,11 @@ export interface IBalancesRepository { * @returns an alphabetically ordered list of uppercase strings representing the supported fiat codes. */ getFiatCodes(): Promise; + + /** + * Clears the API associated with {@link chainId} + */ + clearApi(chainId: string): void; } @Module({ diff --git a/src/domain/balances/balances.repository.ts b/src/domain/balances/balances.repository.ts index fe36b628ff..d52f8d7c34 100644 --- a/src/domain/balances/balances.repository.ts +++ b/src/domain/balances/balances.repository.ts @@ -19,7 +19,7 @@ export class BalancesRepository implements IBalancesRepository { trusted?: boolean; excludeSpam?: boolean; }): Promise { - const api = await this.balancesApiManager.getBalancesApi( + const api = await this.balancesApiManager.getApi( args.chain.chainId, args.safeAddress, ); @@ -31,7 +31,7 @@ export class BalancesRepository implements IBalancesRepository { chainId: string; safeAddress: `0x${string}`; }): Promise { - const api = await this.balancesApiManager.getBalancesApi( + const api = await this.balancesApiManager.getApi( args.chainId, args.safeAddress, ); @@ -41,4 +41,8 @@ export class BalancesRepository implements IBalancesRepository { async getFiatCodes(): Promise { return this.balancesApiManager.getFiatCodes(); } + + clearApi(chainId: string): void { + this.balancesApiManager.destroyApi(chainId); + } } diff --git a/src/domain/blockchain/blockchain.repository.interface.ts b/src/domain/blockchain/blockchain.repository.interface.ts index c3780a3b44..2ef848d76f 100644 --- a/src/domain/blockchain/blockchain.repository.interface.ts +++ b/src/domain/blockchain/blockchain.repository.interface.ts @@ -5,7 +5,7 @@ import { Module } from '@nestjs/common'; export const IBlockchainRepository = Symbol('IBlockchainRepository'); export interface IBlockchainRepository { - clearClient(chainId: string): void; + clearApi(chainId: string): void; } @Module({ diff --git a/src/domain/blockchain/blockchain.repository.ts b/src/domain/blockchain/blockchain.repository.ts index fcb274373c..5f70daf28d 100644 --- a/src/domain/blockchain/blockchain.repository.ts +++ b/src/domain/blockchain/blockchain.repository.ts @@ -9,7 +9,7 @@ export class BlockchainRepository implements IBlockchainRepository { private readonly blockchainApiManager: IBlockchainApiManager, ) {} - clearClient(chainId: string): void { - this.blockchainApiManager.destroyBlockchainApi(chainId); + clearApi(chainId: string): void { + this.blockchainApiManager.destroyApi(chainId); } } diff --git a/src/domain/chains/chains.repository.ts b/src/domain/chains/chains.repository.ts index ac986fe9c7..79c2247a56 100644 --- a/src/domain/chains/chains.repository.ts +++ b/src/domain/chains/chains.repository.ts @@ -34,8 +34,7 @@ export class ChainsRepository implements IChainsRepository { } async getSingletons(chainId: string): Promise { - const transactionApi = - await this.transactionApiManager.getTransactionApi(chainId); + const transactionApi = await this.transactionApiManager.getApi(chainId); const singletons = await transactionApi.getSingletons(); return singletons.map((singleton) => SingletonSchema.parse(singleton)); } diff --git a/src/domain/collectibles/collectibles.repository.ts b/src/domain/collectibles/collectibles.repository.ts index 6f5aeed1c2..e2e9c77e1e 100644 --- a/src/domain/collectibles/collectibles.repository.ts +++ b/src/domain/collectibles/collectibles.repository.ts @@ -21,7 +21,7 @@ export class CollectiblesRepository implements ICollectiblesRepository { trusted?: boolean; excludeSpam?: boolean; }): Promise> { - const api = await this.balancesApiManager.getBalancesApi( + const api = await this.balancesApiManager.getApi( args.chain.chainId, args.safeAddress, ); @@ -33,7 +33,7 @@ export class CollectiblesRepository implements ICollectiblesRepository { chainId: string; safeAddress: `0x${string}`; }): Promise { - const api = await this.balancesApiManager.getBalancesApi( + const api = await this.balancesApiManager.getApi( args.chainId, args.safeAddress, ); diff --git a/src/domain/contracts/contracts.repository.ts b/src/domain/contracts/contracts.repository.ts index 32e5401ac9..698f7ec267 100644 --- a/src/domain/contracts/contracts.repository.ts +++ b/src/domain/contracts/contracts.repository.ts @@ -15,9 +15,7 @@ export class ContractsRepository implements IContractsRepository { chainId: string; contractAddress: `0x${string}`; }): Promise { - const api = await this.transactionApiManager.getTransactionApi( - args.chainId, - ); + const api = await this.transactionApiManager.getApi(args.chainId); const data = await api.getContract(args.contractAddress); return ContractSchema.parse(data); } diff --git a/src/domain/data-decoder/data-decoded.repository.ts b/src/domain/data-decoder/data-decoded.repository.ts index 5a6d16d612..7304ca9a6a 100644 --- a/src/domain/data-decoder/data-decoded.repository.ts +++ b/src/domain/data-decoder/data-decoded.repository.ts @@ -16,9 +16,7 @@ export class DataDecodedRepository implements IDataDecodedRepository { data: `0x${string}`; to?: `0x${string}`; }): Promise { - const api = await this.transactionApiManager.getTransactionApi( - args.chainId, - ); + const api = await this.transactionApiManager.getApi(args.chainId); const dataDecoded = await api.getDataDecoded({ data: args.data, to: args.to, diff --git a/src/domain/delegate/delegate.repository.ts b/src/domain/delegate/delegate.repository.ts index 2b156ce45d..9bb43957f0 100644 --- a/src/domain/delegate/delegate.repository.ts +++ b/src/domain/delegate/delegate.repository.ts @@ -21,8 +21,9 @@ export class DelegateRepository implements IDelegateRepository { limit?: number; offset?: number; }): Promise> { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); const page = await transactionService.getDelegates({ safeAddress: args.safeAddress, delegate: args.delegate, @@ -43,8 +44,9 @@ export class DelegateRepository implements IDelegateRepository { signature: string; label: string; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); await transactionService.postDelegate({ safeAddress: args.safeAddress, delegate: args.delegate, @@ -60,8 +62,9 @@ export class DelegateRepository implements IDelegateRepository { delegator: `0x${string}`; signature: string; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); return transactionService.deleteDelegate({ delegate: args.delegate, delegator: args.delegator, @@ -75,8 +78,9 @@ export class DelegateRepository implements IDelegateRepository { safeAddress: `0x${string}`; signature: string; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); return transactionService.deleteSafeDelegate({ delegate: args.delegate, safeAddress: args.safeAddress, diff --git a/src/domain/delegate/v2/delegates.v2.repository.ts b/src/domain/delegate/v2/delegates.v2.repository.ts index eba00b0cc8..ee7d0d866c 100644 --- a/src/domain/delegate/v2/delegates.v2.repository.ts +++ b/src/domain/delegate/v2/delegates.v2.repository.ts @@ -21,8 +21,9 @@ export class DelegatesV2Repository implements IDelegatesV2Repository { limit?: number; offset?: number; }): Promise> { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); const page = await transactionService.getDelegatesV2({ safeAddress: args.safeAddress, delegate: args.delegate, @@ -43,8 +44,9 @@ export class DelegatesV2Repository implements IDelegatesV2Repository { signature: string; label: string; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); await transactionService.postDelegateV2({ safeAddress: args.safeAddress, delegate: args.delegate, @@ -61,8 +63,9 @@ export class DelegatesV2Repository implements IDelegatesV2Repository { safeAddress: `0x${string}` | null; signature: string; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); return transactionService.deleteDelegateV2({ delegate: args.delegate, delegator: args.delegator, diff --git a/src/domain/estimations/estimations.repository.ts b/src/domain/estimations/estimations.repository.ts index b088f99947..de5104dbde 100644 --- a/src/domain/estimations/estimations.repository.ts +++ b/src/domain/estimations/estimations.repository.ts @@ -17,9 +17,7 @@ export class EstimationsRepository implements IEstimationsRepository { address: `0x${string}`; getEstimationDto: GetEstimationDto; }): Promise { - const api = await this.transactionApiManager.getTransactionApi( - args.chainId, - ); + const api = await this.transactionApiManager.getApi(args.chainId); const data = await api.getEstimation(args); return EstimationSchema.parse(data); } diff --git a/src/domain/interfaces/api.manager.interface.ts b/src/domain/interfaces/api.manager.interface.ts new file mode 100644 index 0000000000..ff1c563079 --- /dev/null +++ b/src/domain/interfaces/api.manager.interface.ts @@ -0,0 +1,5 @@ +export interface IApiManager { + getApi(chainId: string, ...rest: unknown[]): Promise; + + destroyApi(chainId: string): void; +} diff --git a/src/domain/interfaces/balances-api.manager.interface.ts b/src/domain/interfaces/balances-api.manager.interface.ts index 010c073a85..2cebced6bc 100644 --- a/src/domain/interfaces/balances-api.manager.interface.ts +++ b/src/domain/interfaces/balances-api.manager.interface.ts @@ -1,8 +1,9 @@ +import { IApiManager } from '@/domain/interfaces/api.manager.interface'; import { IBalancesApi } from '@/domain/interfaces/balances-api.interface'; export const IBalancesApiManager = Symbol('IBalancesApiManager'); -export interface IBalancesApiManager { +export interface IBalancesApiManager extends IApiManager { /** * Gets an {@link IBalancesApi} implementation. * Each chain is associated with an implementation (i.e.: to a balances @@ -15,10 +16,7 @@ export interface IBalancesApiManager { * @param safeAddress - the Safe address to check. * @returns {@link IBalancesApi} configured for the input chain ID. */ - getBalancesApi( - chainId: string, - safeAddress: `0x${string}`, - ): Promise; + getApi(chainId: string, safeAddress: `0x${string}`): Promise; /** * Gets the list of supported fiat codes. diff --git a/src/domain/interfaces/blockchain-api.manager.interface.ts b/src/domain/interfaces/blockchain-api.manager.interface.ts index 542bd23153..f544e708ec 100644 --- a/src/domain/interfaces/blockchain-api.manager.interface.ts +++ b/src/domain/interfaces/blockchain-api.manager.interface.ts @@ -2,14 +2,11 @@ import { BlockchainApiManager } from '@/datasources/blockchain/blockchain-api.ma import { ConfigApiModule } from '@/datasources/config-api/config-api.module'; import { PublicClient } from 'viem'; import { Module } from '@nestjs/common'; +import { IApiManager } from '@/domain/interfaces/api.manager.interface'; export const IBlockchainApiManager = Symbol('IBlockchainApiManager'); -export interface IBlockchainApiManager { - getBlockchainApi(chainId: string): Promise; - - destroyBlockchainApi(chainId: string): void; -} +export interface IBlockchainApiManager extends IApiManager {} @Module({ imports: [ConfigApiModule], diff --git a/src/domain/interfaces/transaction-api.manager.interface.ts b/src/domain/interfaces/transaction-api.manager.interface.ts index e9fb1cf261..aac6defab4 100644 --- a/src/domain/interfaces/transaction-api.manager.interface.ts +++ b/src/domain/interfaces/transaction-api.manager.interface.ts @@ -4,12 +4,11 @@ import { TransactionApiManager } from '@/datasources/transaction-api/transaction import { CacheFirstDataSourceModule } from '@/datasources/cache/cache.first.data.source.module'; import { HttpErrorFactory } from '@/datasources/errors/http-error-factory'; import { ConfigApiModule } from '@/datasources/config-api/config-api.module'; +import { IApiManager } from '@/domain/interfaces/api.manager.interface'; export const ITransactionApiManager = Symbol('ITransactionApiManager'); -export interface ITransactionApiManager { - getTransactionApi(chainId: string): Promise; -} +export interface ITransactionApiManager extends IApiManager {} @Module({ imports: [CacheFirstDataSourceModule, ConfigApiModule], diff --git a/src/domain/messages/messages.repository.ts b/src/domain/messages/messages.repository.ts index 134e42530f..146454517a 100644 --- a/src/domain/messages/messages.repository.ts +++ b/src/domain/messages/messages.repository.ts @@ -19,8 +19,9 @@ export class MessagesRepository implements IMessagesRepository { chainId: string; messageHash: string; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); const message = await transactionService.getMessageByHash(args.messageHash); return MessageSchema.parse(message); } @@ -31,8 +32,9 @@ export class MessagesRepository implements IMessagesRepository { limit?: number | undefined; offset?: number | undefined; }): Promise> { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); const page = await transactionService.getMessagesBySafe({ safeAddress: args.safeAddress, limit: args.limit, @@ -49,8 +51,9 @@ export class MessagesRepository implements IMessagesRepository { safeAppId: number | null; signature: string; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); return transactionService.postMessage({ safeAddress: args.safeAddress, @@ -65,8 +68,9 @@ export class MessagesRepository implements IMessagesRepository { messageHash: string; signature: `0x${string}`; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); return transactionService.postMessageSignature({ messageHash: args.messageHash, @@ -78,9 +82,7 @@ export class MessagesRepository implements IMessagesRepository { chainId: string; safeAddress: `0x${string}`; }): Promise { - const api = await this.transactionApiManager.getTransactionApi( - args.chainId, - ); + const api = await this.transactionApiManager.getApi(args.chainId); await api.clearMessagesBySafe(args); } @@ -88,9 +90,7 @@ export class MessagesRepository implements IMessagesRepository { chainId: string; messageHash: string; }): Promise { - const api = await this.transactionApiManager.getTransactionApi( - args.chainId, - ); + const api = await this.transactionApiManager.getApi(args.chainId); await api.clearMessagesByHash(args); } } diff --git a/src/domain/notifications/notifications.repository.ts b/src/domain/notifications/notifications.repository.ts index 662b499608..6c746e4ad1 100644 --- a/src/domain/notifications/notifications.repository.ts +++ b/src/domain/notifications/notifications.repository.ts @@ -14,7 +14,7 @@ export class NotificationsRepository implements INotificationsRepository { device: Device, safeRegistration: SafeRegistration, ): Promise { - const api = await this.transactionApiManager.getTransactionApi( + const api = await this.transactionApiManager.getApi( safeRegistration.chainId, ); await api.postDeviceRegistration({ @@ -29,9 +29,7 @@ export class NotificationsRepository implements INotificationsRepository { chainId: string; uuid: string; }): Promise { - const api = await this.transactionApiManager.getTransactionApi( - args.chainId, - ); + const api = await this.transactionApiManager.getApi(args.chainId); return api.deleteDeviceRegistration(args.uuid); } @@ -40,9 +38,7 @@ export class NotificationsRepository implements INotificationsRepository { uuid: string; safeAddress: `0x${string}`; }): Promise { - const api = await this.transactionApiManager.getTransactionApi( - args.chainId, - ); + const api = await this.transactionApiManager.getApi(args.chainId); return api.deleteSafeRegistration({ uuid: args.uuid, safeAddress: args.safeAddress, diff --git a/src/domain/safe/safe.repository.ts b/src/domain/safe/safe.repository.ts index 3e44cca97b..213d219650 100644 --- a/src/domain/safe/safe.repository.ts +++ b/src/domain/safe/safe.repository.ts @@ -46,8 +46,9 @@ export class SafeRepository implements ISafeRepository { chainId: string; address: `0x${string}`; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); const safe = await transactionService.getSafe(args.address); return SafeSchema.parse(safe); } @@ -56,8 +57,9 @@ export class SafeRepository implements ISafeRepository { chainId: string; address: `0x${string}`; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); const isSafe = await transactionService.isSafe(args.address); return z.boolean().parse(isSafe); } @@ -66,8 +68,9 @@ export class SafeRepository implements ISafeRepository { chainId: string; address: `0x${string}`; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); return transactionService.clearIsSafe(args.address); } @@ -75,8 +78,9 @@ export class SafeRepository implements ISafeRepository { chainId: string; address: `0x${string}`; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); return transactionService.clearSafe(args.address); } @@ -98,8 +102,9 @@ export class SafeRepository implements ISafeRepository { limit?: number; offset?: number; }): Promise> { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); const page = await transactionService.getTransfers({ ...args, @@ -112,8 +117,9 @@ export class SafeRepository implements ISafeRepository { chainId: string; safeAddress: string; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); return transactionService.clearTransfers(args.safeAddress); } @@ -129,8 +135,9 @@ export class SafeRepository implements ISafeRepository { limit?: number; offset?: number; }): Promise> { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); const page = await transactionService.getIncomingTransfers(args); return TransferPageSchema.parse(page); } @@ -139,8 +146,9 @@ export class SafeRepository implements ISafeRepository { chainId: string; safeAddress: string; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); return transactionService.clearIncomingTransfers(args.safeAddress); } @@ -150,8 +158,9 @@ export class SafeRepository implements ISafeRepository { safeTxHash: string; addConfirmationDto: AddConfirmationDto; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); await transactionService.postConfirmation(args); } @@ -159,8 +168,9 @@ export class SafeRepository implements ISafeRepository { chainId: string; moduleTransactionId: string; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); const moduleTransaction = await transactionService.getModuleTransaction( args.moduleTransactionId, ); @@ -176,8 +186,9 @@ export class SafeRepository implements ISafeRepository { limit?: number; offset?: number; }): Promise> { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); const page = await transactionService.getModuleTransactions(args); return ModuleTransactionPageSchema.parse(page); } @@ -186,8 +197,9 @@ export class SafeRepository implements ISafeRepository { chainId: string; safeAddress: `0x${string}`; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); return transactionService.clearModuleTransactions(args.safeAddress); } @@ -225,8 +237,9 @@ export class SafeRepository implements ISafeRepository { offset?: number; trusted?: boolean; }): Promise> { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); const page: Page = await transactionService.getMultisigTransactions({ ...args, @@ -241,8 +254,9 @@ export class SafeRepository implements ISafeRepository { chainId: string; safeAddress: `0x${string}`; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); const createTransaction = await transactionService.getCreationTransaction( args.safeAddress, ); @@ -265,8 +279,9 @@ export class SafeRepository implements ISafeRepository { limit?: number; offset?: number; }): Promise> { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); const page: Page = await transactionService.getAllTransactions( { ...args, @@ -281,8 +296,9 @@ export class SafeRepository implements ISafeRepository { chainId: string; safeAddress: `0x${string}`; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); return transactionService.clearAllTransactions(args.safeAddress); } @@ -291,8 +307,9 @@ export class SafeRepository implements ISafeRepository { chainId: string; safeTransactionHash: string; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); return transactionService.clearMultisigTransaction( args.safeTransactionHash, ); @@ -302,8 +319,9 @@ export class SafeRepository implements ISafeRepository { chainId: string; safeTransactionHash: string; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); const multiSigTransaction = await transactionService.getMultisigTransaction( args.safeTransactionHash, ); @@ -316,8 +334,9 @@ export class SafeRepository implements ISafeRepository { safeTxHash: string; signature: string; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); const { safe } = await transactionService.getMultisigTransaction( args.safeTxHash, ); @@ -338,8 +357,9 @@ export class SafeRepository implements ISafeRepository { chainId: string; safeAddress: string; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); return transactionService.clearMultisigTransactions(args.safeAddress); } @@ -356,8 +376,9 @@ export class SafeRepository implements ISafeRepository { limit?: number; offset?: number; }): Promise> { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); const page = await transactionService.getMultisigTransactions({ ...args, ordering: '-nonce', @@ -370,8 +391,9 @@ export class SafeRepository implements ISafeRepository { chainId: string; transferId: string; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); const transfer = await transactionService.getTransfer(args.transferId); return TransferSchema.parse(transfer); } @@ -381,8 +403,9 @@ export class SafeRepository implements ISafeRepository { safeAddress: string; limit?: number | undefined; }): Promise> { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); const page = await transactionService.getTransfers(args); return TransferPageSchema.parse(page); } @@ -391,8 +414,9 @@ export class SafeRepository implements ISafeRepository { chainId: string; ownerAddress: `0x${string}`; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); const safeList = await transactionService.getSafesByOwner( args.ownerAddress, ); @@ -432,8 +456,9 @@ export class SafeRepository implements ISafeRepository { chainId: string; safeAddress: `0x${string}`; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); const page: Page = await transactionService.getMultisigTransactions({ ...args, @@ -452,8 +477,9 @@ export class SafeRepository implements ISafeRepository { safeAddress: string; proposeTransactionDto: ProposeTransactionDto; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); return transactionService.postMultisigTransaction({ address: args.safeAddress, @@ -489,8 +515,9 @@ export class SafeRepository implements ISafeRepository { chainId: string; moduleAddress: string; }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); const safesByModule = await transactionService.getSafesByModule( args.moduleAddress, ); diff --git a/src/domain/siwe/siwe.repository.ts b/src/domain/siwe/siwe.repository.ts index 4b51cc6e56..6145066ce9 100644 --- a/src/domain/siwe/siwe.repository.ts +++ b/src/domain/siwe/siwe.repository.ts @@ -134,9 +134,7 @@ export class SiweRepository implements ISiweRepository { } // Else, verify hash on-chain using ERC-6492 for smart contract accounts - const blockchainApi = await this.blockchainApiManager.getBlockchainApi( - args.chainId, - ); + const blockchainApi = await this.blockchainApiManager.getApi(args.chainId); return blockchainApi.verifySiweMessage({ message: args.message, signature: args.signature, diff --git a/src/domain/tokens/token.repository.ts b/src/domain/tokens/token.repository.ts index 45115364e9..e0eddb1df0 100644 --- a/src/domain/tokens/token.repository.ts +++ b/src/domain/tokens/token.repository.ts @@ -16,8 +16,9 @@ export class TokenRepository implements ITokenRepository { ) {} async getToken(args: { chainId: string; address: string }): Promise { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); const token = await transactionService.getToken(args.address); return TokenSchema.parse(token); } @@ -27,8 +28,9 @@ export class TokenRepository implements ITokenRepository { limit?: number; offset?: number; }): Promise> { - const transactionService = - await this.transactionApiManager.getTransactionApi(args.chainId); + const transactionService = await this.transactionApiManager.getApi( + args.chainId, + ); const page = await transactionService.getTokens(args); return TokenPageSchema.parse(page); } diff --git a/src/domain/transactions/transactions.repository.interface.ts b/src/domain/transactions/transactions.repository.interface.ts new file mode 100644 index 0000000000..37ced836f5 --- /dev/null +++ b/src/domain/transactions/transactions.repository.interface.ts @@ -0,0 +1,18 @@ +import { TransactionApiManagerModule } from '@/domain/interfaces/transaction-api.manager.interface'; +import { TransactionsRepository } from '@/domain/transactions/transactions.repository'; +import { Module } from '@nestjs/common'; + +export const ITransactionsRepository = Symbol('ITransactionsRepository'); + +export interface ITransactionsRepository { + clearApi(chainId: string): void; +} + +@Module({ + imports: [TransactionApiManagerModule], + providers: [ + { provide: ITransactionsRepository, useClass: TransactionsRepository }, + ], + exports: [ITransactionsRepository], +}) +export class TransactionsRepositoryModule {} diff --git a/src/domain/transactions/transactions.repository.ts b/src/domain/transactions/transactions.repository.ts new file mode 100644 index 0000000000..a9fb66ed80 --- /dev/null +++ b/src/domain/transactions/transactions.repository.ts @@ -0,0 +1,16 @@ +import { TransactionApiManager } from '@/datasources/transaction-api/transaction-api.manager'; +import { ITransactionApiManager } from '@/domain/interfaces/transaction-api.manager.interface'; +import { ITransactionsRepository } from '@/domain/transactions/transactions.repository.interface'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class TransactionsRepository implements ITransactionsRepository { + constructor( + @Inject(ITransactionApiManager) + private readonly transactionApiManager: TransactionApiManager, + ) {} + + clearApi(chainId: string): void { + this.transactionApiManager.destroyApi(chainId); + } +} diff --git a/src/routes/auth/auth.controller.spec.ts b/src/routes/auth/auth.controller.spec.ts index 3f0a43a6d8..ae6e163a2d 100644 --- a/src/routes/auth/auth.controller.spec.ts +++ b/src/routes/auth/auth.controller.spec.ts @@ -84,7 +84,7 @@ describe('AuthController', () => { maxValidityPeriodInMs = configService.getOrThrow('auth.maxValidityPeriodSeconds') * 1_000; - blockchainApiManager.getBlockchainApi.mockImplementation(() => ({ + blockchainApiManager.getApi.mockImplementation(() => ({ verifySiweMessage: verifySiweMessageMock, })); diff --git a/src/routes/cache-hooks/cache-hooks.controller.spec.ts b/src/routes/cache-hooks/cache-hooks.controller.spec.ts index f9479128b1..fcdeb1adcf 100644 --- a/src/routes/cache-hooks/cache-hooks.controller.spec.ts +++ b/src/routes/cache-hooks/cache-hooks.controller.spec.ts @@ -26,6 +26,8 @@ import { Server } from 'net'; import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; import { IBlockchainApiManager } from '@/domain/interfaces/blockchain-api.manager.interface'; import { safeCreatedEventBuilder } from '@/routes/cache-hooks/entities/__tests__/safe-created.build'; +import { ITransactionApiManager } from '@/domain/interfaces/transaction-api.manager.interface'; +import { IBalancesApiManager } from '@/domain/interfaces/balances-api.manager.interface'; describe('Post Hook Events (Unit)', () => { let app: INestApplication; @@ -35,6 +37,8 @@ describe('Post Hook Events (Unit)', () => { let networkService: jest.MockedObjectDeep; let configurationService: IConfigurationService; let blockchainApiManager: IBlockchainApiManager; + let transactionApiManager: ITransactionApiManager; + let balancesApiManager: IBalancesApiManager; async function initApp(config: typeof configuration): Promise { const moduleFixture: TestingModule = await Test.createTestingModule({ @@ -56,6 +60,8 @@ describe('Post Hook Events (Unit)', () => { blockchainApiManager = moduleFixture.get( IBlockchainApiManager, ); + transactionApiManager = moduleFixture.get(ITransactionApiManager); + balancesApiManager = moduleFixture.get(IBalancesApiManager); authToken = configurationService.getOrThrow('auth.token'); safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); @@ -868,7 +874,7 @@ describe('Post Hook Events (Unit)', () => { { type: 'CHAIN_UPDATE', }, - ])('$type clears the blockchain client', async (payload) => { + ])('$type clears the blockchain API', async (payload) => { const chainId = faker.string.numeric(); const data = { chainId: chainId, @@ -885,7 +891,7 @@ describe('Post Hook Events (Unit)', () => { return Promise.reject(new Error(`Could not match ${url}`)); } }); - const client = await blockchainApiManager.getBlockchainApi(chainId); + const api = await blockchainApiManager.getApi(chainId); await request(app.getHttpServer()) .post(`/hooks/events`) @@ -893,8 +899,75 @@ describe('Post Hook Events (Unit)', () => { .send(data) .expect(202); - const newClient = await blockchainApiManager.getBlockchainApi(chainId); - expect(client).not.toBe(newClient); + const newApi = await blockchainApiManager.getApi(chainId); + expect(api).not.toBe(newApi); + }); + + it.each([ + { + type: 'CHAIN_UPDATE', + }, + ])('$type clears the transaction API', async (payload) => { + const chainId = faker.string.numeric(); + const data = { + chainId: chainId, + ...payload, + }; + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${safeConfigUrl}/api/v1/chains/${chainId}`: + return Promise.resolve({ + data: chainBuilder().with('chainId', chainId).build(), + status: 200, + }); + default: + return Promise.reject(new Error(`Could not match ${url}`)); + } + }); + const api = await transactionApiManager.getApi(chainId); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(data) + .expect(202); + + const newApi = await transactionApiManager.getApi(chainId); + expect(api).not.toBe(newApi); + }); + + it.each([ + { + type: 'CHAIN_UPDATE', + }, + ])('$type clears the balances API', async (payload) => { + const chainId = faker.string.numeric(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const data = { + chainId: chainId, + ...payload, + }; + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${safeConfigUrl}/api/v1/chains/${chainId}`: + return Promise.resolve({ + data: chainBuilder().with('chainId', chainId).build(), + status: 200, + }); + default: + return Promise.reject(new Error(`Could not match ${url}`)); + } + }); + const api = await balancesApiManager.getApi(chainId, safeAddress); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(data) + .expect(202); + + const newApi = await balancesApiManager.getApi(chainId, safeAddress); + expect(api).not.toBe(newApi); }); it.each([ diff --git a/src/routes/cache-hooks/cache-hooks.module.ts b/src/routes/cache-hooks/cache-hooks.module.ts index 5bcf9a4020..2c9840c7b1 100644 --- a/src/routes/cache-hooks/cache-hooks.module.ts +++ b/src/routes/cache-hooks/cache-hooks.module.ts @@ -5,6 +5,7 @@ import { BalancesRepositoryModule } from '@/domain/balances/balances.repository. import { CollectiblesRepositoryModule } from '@/domain/collectibles/collectibles.repository.interface'; import { ChainsRepositoryModule } from '@/domain/chains/chains.repository.interface'; import { SafeRepositoryModule } from '@/domain/safe/safe.repository.interface'; +import { TransactionsRepositoryModule } from '@/domain/transactions/transactions.repository.interface'; import { MessagesRepositoryModule } from '@/domain/messages/messages.repository.interface'; import { SafeAppsRepositoryModule } from '@/domain/safe-apps/safe-apps.repository.interface'; import { QueuesRepositoryModule } from '@/domain/queues/queues-repository.interface'; @@ -19,6 +20,7 @@ import { BlockchainRepositoryModule } from '@/domain/blockchain/blockchain.repos MessagesRepositoryModule, SafeAppsRepositoryModule, SafeRepositoryModule, + TransactionsRepositoryModule, QueuesRepositoryModule, ], providers: [CacheHooksService], diff --git a/src/routes/cache-hooks/cache-hooks.service.ts b/src/routes/cache-hooks/cache-hooks.service.ts index 114fb69c40..a045419661 100644 --- a/src/routes/cache-hooks/cache-hooks.service.ts +++ b/src/routes/cache-hooks/cache-hooks.service.ts @@ -5,6 +5,7 @@ import { ICollectiblesRepository } from '@/domain/collectibles/collectibles.repo import { IMessagesRepository } from '@/domain/messages/messages.repository.interface'; import { ISafeAppsRepository } from '@/domain/safe-apps/safe-apps.repository.interface'; import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; +import { ITransactionsRepository } from '@/domain/transactions/transactions.repository.interface'; import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; import { LoggingService, ILoggingService } from '@/logging/logging.interface'; import { Event } from '@/routes/cache-hooks/entities/event.entity'; @@ -34,6 +35,8 @@ export class CacheHooksService implements OnModuleInit { private readonly safeAppsRepository: ISafeAppsRepository, @Inject(ISafeRepository) private readonly safeRepository: ISafeRepository, + @Inject(ITransactionsRepository) + private readonly transactionsRepository: ITransactionsRepository, @Inject(LoggingService) private readonly loggingService: ILoggingService, @Inject(IQueuesRepository) @@ -319,8 +322,11 @@ export class CacheHooksService implements OnModuleInit { case EventType.CHAIN_UPDATE: promises.push( this.chainsRepository.clearChain(event.chainId).then(() => { - // Clear after updated as RPC may have change - this.blockchainRepository.clearClient(event.chainId); + // RPC may have changed + this.blockchainRepository.clearApi(event.chainId); + // Transaction Service may have changed + this.transactionsRepository.clearApi(event.chainId); + this.balancesRepository.clearApi(event.chainId); }), ); this._logEvent(event); From 5145868897d90ebc6593c9bd81cc9cb67e9a519a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Mon, 17 Jun 2024 08:58:29 +0200 Subject: [PATCH 094/207] Initialize AccountsModule behind FF_ACCOUNTS (#1656) Initialize AccountsModule behind FF_ACCOUNTS --- src/app.module.ts | 3 +++ .../entities/__tests__/configuration.ts | 1 + src/config/entities/configuration.ts | 1 + .../accounts/accounts.repository.interface.ts | 19 +++++++++++++++++++ src/domain/accounts/accounts.repository.ts | 5 +++++ src/routes/accounts/accounts.module.ts | 9 +++++++++ 6 files changed, 38 insertions(+) create mode 100644 src/domain/accounts/accounts.repository.interface.ts create mode 100644 src/domain/accounts/accounts.repository.ts create mode 100644 src/routes/accounts/accounts.module.ts diff --git a/src/app.module.ts b/src/app.module.ts index 6f33d76717..ebd37b5fad 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -44,6 +44,7 @@ import { CacheControlInterceptor } from '@/routes/common/interceptors/cache-cont import { AuthModule } from '@/routes/auth/auth.module'; import { TransactionsViewControllerModule } from '@/routes/transactions/transactions-view.controller'; import { DelegatesV2Module } from '@/routes/delegates/v2/delegates.v2.module'; +import { AccountsModule } from '@/routes/accounts/accounts.module'; @Module({}) export class AppModule implements NestModule { @@ -53,6 +54,7 @@ export class AppModule implements NestModule { static register(configFactory = configuration): DynamicModule { const { auth: isAuthFeatureEnabled, + accounts: isAccountsFeatureEnabled, email: isEmailFeatureEnabled, confirmationView: isConfirmationViewEnabled, delegatesV2: isDelegatesV2Enabled, @@ -63,6 +65,7 @@ export class AppModule implements NestModule { imports: [ // features AboutModule, + ...(isAccountsFeatureEnabled ? [AccountsModule] : []), ...(isAuthFeatureEnabled ? [AuthModule] : []), BalancesModule, CacheHooksModule, diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index 22f61f21ee..1055c7c4b6 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -116,6 +116,7 @@ export default (): ReturnType => ({ eventsQueue: false, delegatesV2: false, counterfactualBalances: false, + accounts: false, }, httpClient: { requestTimeout: faker.number.int() }, locking: { diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 0651c1cb9f..ee8c4b5a29 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -174,6 +174,7 @@ export default () => ({ delegatesV2: process.env.FF_DELEGATES_V2?.toLowerCase() === 'true', counterfactualBalances: process.env.FF_COUNTERFACTUAL_BALANCES?.toLowerCase() === 'true', + accounts: process.env.FF_ACCOUNTS?.toLowerCase() === 'true', }, httpClient: { // Timeout in milliseconds to be used for the HTTP client. diff --git a/src/domain/accounts/accounts.repository.interface.ts b/src/domain/accounts/accounts.repository.interface.ts new file mode 100644 index 0000000000..80d20a6519 --- /dev/null +++ b/src/domain/accounts/accounts.repository.interface.ts @@ -0,0 +1,19 @@ +import { AccountsDatasourceModule } from '@/datasources/accounts/accounts.datasource.module'; +import { AccountsRepository } from '@/domain/accounts/accounts.repository'; +import { Module } from '@nestjs/common'; + +export const IAccountsRepository = Symbol('IAccountsRepository'); + +export interface IAccountsRepository {} + +@Module({ + imports: [AccountsDatasourceModule], + providers: [ + { + provide: IAccountsRepository, + useClass: AccountsRepository, + }, + ], + exports: [IAccountsRepository], +}) +export class AccountsRepositoryModule {} diff --git a/src/domain/accounts/accounts.repository.ts b/src/domain/accounts/accounts.repository.ts new file mode 100644 index 0000000000..5fd8eb26a8 --- /dev/null +++ b/src/domain/accounts/accounts.repository.ts @@ -0,0 +1,5 @@ +import { IAccountsRepository } from '@/domain/accounts/accounts.repository.interface'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class AccountsRepository implements IAccountsRepository {} diff --git a/src/routes/accounts/accounts.module.ts b/src/routes/accounts/accounts.module.ts new file mode 100644 index 0000000000..2d32ec52e7 --- /dev/null +++ b/src/routes/accounts/accounts.module.ts @@ -0,0 +1,9 @@ +import { AccountsRepositoryModule } from '@/domain/accounts/accounts.repository.interface'; +import { Module } from '@nestjs/common'; + +@Module({ + imports: [AccountsRepositoryModule], + controllers: [], + providers: [], +}) +export class AccountsModule {} From a421be1973a18577ef681809eab5d4e524e23f48 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 17 Jun 2024 09:18:08 +0200 Subject: [PATCH 095/207] Coerce numerical strings of `CampaignActivities` to numbers (#1657) Adjusts the validation of `boost`, `totalPoints` and `totalBoostedPoints` in `CampaignAcitivitySchema` to coerce the values to numbers: - Coerce `boost`, `totalPoints` and `totalBoostedPoints` in `CampaignAcitivitySchema` to numbers - Update test coverage --- .../community/entities/campaign-activity.entity.ts | 6 +++--- .../__tests__/campaign-activity.schema.spec.ts | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/domain/community/entities/campaign-activity.entity.ts b/src/domain/community/entities/campaign-activity.entity.ts index 0a67360b7b..5604e138eb 100644 --- a/src/domain/community/entities/campaign-activity.entity.ts +++ b/src/domain/community/entities/campaign-activity.entity.ts @@ -6,9 +6,9 @@ export const CampaignActivitySchema = z.object({ startDate: z.coerce.date(), endDate: z.coerce.date(), holder: AddressSchema, - boost: z.number(), - totalPoints: z.number(), - totalBoostedPoints: z.number(), + boost: z.coerce.number(), + totalPoints: z.coerce.number(), + totalBoostedPoints: z.coerce.number(), }); export const CampaignActivityPageSchema = buildPageSchema( diff --git a/src/domain/community/entities/schemas/__tests__/campaign-activity.schema.spec.ts b/src/domain/community/entities/schemas/__tests__/campaign-activity.schema.spec.ts index a4a4030f65..f7d24ea9ff 100644 --- a/src/domain/community/entities/schemas/__tests__/campaign-activity.schema.spec.ts +++ b/src/domain/community/entities/schemas/__tests__/campaign-activity.schema.spec.ts @@ -93,23 +93,23 @@ describe('CampaignActivitySchema', () => { { code: 'invalid_type', expected: 'number', - received: 'undefined', + received: 'nan', path: ['boost'], - message: 'Required', + message: 'Expected number, received nan', }, { code: 'invalid_type', expected: 'number', - received: 'undefined', + received: 'nan', path: ['totalPoints'], - message: 'Required', + message: 'Expected number, received nan', }, { code: 'invalid_type', expected: 'number', - received: 'undefined', + received: 'nan', path: ['totalBoostedPoints'], - message: 'Required', + message: 'Expected number, received nan', }, ]), ); From 8308ec61a017e0fb9f57e595366bb34ee9e91525 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 17 Jun 2024 12:53:25 +0200 Subject: [PATCH 096/207] Don't coerce numerical strings of `CampaignActivities` (#1658) Due to large numbers the coercion of `boost`, `totalPoints` and `totalBoostedPoints` (#1657) may cause issues. This reverts the change, validating that the values are numerical string: - Ensure `boost`, `totalPoints` and `totalBoostedPoints` of `CampaignActivites` are numerical string - Propagate the type - Update tests --- .../__tests__/campaign-activity.builder.ts | 6 ++--- .../entities/campaign-activity.entity.ts | 7 +++--- .../campaign-activity.schema.spec.ts | 22 +++++++++---------- .../entities/campaign-activity.entity.ts | 6 ++--- 4 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/domain/community/entities/__tests__/campaign-activity.builder.ts b/src/domain/community/entities/__tests__/campaign-activity.builder.ts index 71a5827ee9..63af983415 100644 --- a/src/domain/community/entities/__tests__/campaign-activity.builder.ts +++ b/src/domain/community/entities/__tests__/campaign-activity.builder.ts @@ -8,9 +8,9 @@ export function campaignActivityBuilder(): IBuilder { .with('holder', getAddress(faker.finance.ethereumAddress())) .with('startDate', faker.date.recent()) .with('endDate', faker.date.future()) - .with('boost', faker.number.float()) - .with('totalPoints', faker.number.float()) - .with('totalBoostedPoints', faker.number.float()); + .with('boost', faker.string.numeric()) + .with('totalPoints', faker.string.numeric()) + .with('totalBoostedPoints', faker.string.numeric()); } export function toJson(campaignActivity: CampaignActivity): unknown { diff --git a/src/domain/community/entities/campaign-activity.entity.ts b/src/domain/community/entities/campaign-activity.entity.ts index 5604e138eb..680354e0ec 100644 --- a/src/domain/community/entities/campaign-activity.entity.ts +++ b/src/domain/community/entities/campaign-activity.entity.ts @@ -1,14 +1,15 @@ import { buildPageSchema } from '@/domain/entities/schemas/page.schema.factory'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; +import { NumericStringSchema } from '@/validation/entities/schemas/numeric-string.schema'; import { z } from 'zod'; export const CampaignActivitySchema = z.object({ startDate: z.coerce.date(), endDate: z.coerce.date(), holder: AddressSchema, - boost: z.coerce.number(), - totalPoints: z.coerce.number(), - totalBoostedPoints: z.coerce.number(), + boost: NumericStringSchema, + totalPoints: NumericStringSchema, + totalBoostedPoints: NumericStringSchema, }); export const CampaignActivityPageSchema = buildPageSchema( diff --git a/src/domain/community/entities/schemas/__tests__/campaign-activity.schema.spec.ts b/src/domain/community/entities/schemas/__tests__/campaign-activity.schema.spec.ts index f7d24ea9ff..dfcd0ec199 100644 --- a/src/domain/community/entities/schemas/__tests__/campaign-activity.schema.spec.ts +++ b/src/domain/community/entities/schemas/__tests__/campaign-activity.schema.spec.ts @@ -44,7 +44,7 @@ describe('CampaignActivitySchema', () => { 'totalBoostedPoints' as const, ])(`should validate a decimal %s`, (field) => { const campaignActivity = campaignActivityBuilder() - .with(field, faker.number.float()) + .with(field, faker.number.int().toString()) .build(); const result = CampaignActivitySchema.safeParse(campaignActivity); @@ -58,7 +58,7 @@ describe('CampaignActivitySchema', () => { 'totalBoostedPoints' as const, ])(`should validate a float %s`, (field) => { const campaignActivity = campaignActivityBuilder() - .with(field, faker.number.float()) + .with(field, faker.number.float().toString()) .build(); const result = CampaignActivitySchema.safeParse(campaignActivity); @@ -92,24 +92,24 @@ describe('CampaignActivitySchema', () => { }, { code: 'invalid_type', - expected: 'number', - received: 'nan', + expected: 'string', + received: 'undefined', path: ['boost'], - message: 'Expected number, received nan', + message: 'Required', }, { code: 'invalid_type', - expected: 'number', - received: 'nan', + expected: 'string', + received: 'undefined', path: ['totalPoints'], - message: 'Expected number, received nan', + message: 'Required', }, { code: 'invalid_type', - expected: 'number', - received: 'nan', + expected: 'string', + received: 'undefined', path: ['totalBoostedPoints'], - message: 'Expected number, received nan', + message: 'Required', }, ]), ); diff --git a/src/routes/community/entities/campaign-activity.entity.ts b/src/routes/community/entities/campaign-activity.entity.ts index 9679702de4..d3c06c31d2 100644 --- a/src/routes/community/entities/campaign-activity.entity.ts +++ b/src/routes/community/entities/campaign-activity.entity.ts @@ -9,9 +9,9 @@ export class CampaignActivity implements DomainCampaignActivity { @ApiProperty({ type: String }) endDate!: Date; @ApiProperty() - boost!: number; + boost!: string; @ApiProperty() - totalPoints!: number; + totalPoints!: string; @ApiProperty() - totalBoostedPoints!: number; + totalBoostedPoints!: string; } From 50d342ef7961fd6f7ee49f1c5b6d9b6d724dc635 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 17 Jun 2024 13:01:28 +0200 Subject: [PATCH 097/207] Add `PostgresDatabaseMigrator` for (testing) migration (#1655) Adds a new `PostgresDatabaseMigrator` that has the core logic of `postgres-shift`, as well as a testing method. The `migrate` method mirrors `postgres-shift` and the `test` method reuses part of it, executing each migration in a separate transaction. It allows us to interact with the database _before_ and _after_ a migration has executed, stopping after the desired migration: - Create `PostgresDatabaseMigrator` and inject it - Remove `postgres-shift` and associated definition/patch, replacing usage with the above - Add appropriate test coverage --- .../postgres-shift-npm-0.1.0-9342b5f6f6.patch | 38 -- migrations/__tests__/00001_accounts.spec.ts | 51 +++ migrations/__tests__/_all.spec.ts | 13 + package.json | 13 +- src/__tests__/db.factory.ts | 30 ++ .../accounts/accounts.datasource.spec.ts | 35 +- .../db/postgres-database.migration.hook.ts | 6 +- .../db/postgres-database.migrator.spec.ts | 335 ++++++++++++++++++ .../db/postgres-database.migrator.ts | 240 +++++++++++++ .../db/postgres-database.module.ts | 10 + src/types/postgres-shift.d.ts | 12 - yarn.lock | 15 - 12 files changed, 692 insertions(+), 106 deletions(-) delete mode 100644 .yarn/patches/postgres-shift-npm-0.1.0-9342b5f6f6.patch create mode 100644 migrations/__tests__/00001_accounts.spec.ts create mode 100644 migrations/__tests__/_all.spec.ts create mode 100644 src/__tests__/db.factory.ts create mode 100644 src/datasources/db/postgres-database.migrator.spec.ts create mode 100644 src/datasources/db/postgres-database.migrator.ts delete mode 100644 src/types/postgres-shift.d.ts diff --git a/.yarn/patches/postgres-shift-npm-0.1.0-9342b5f6f6.patch b/.yarn/patches/postgres-shift-npm-0.1.0-9342b5f6f6.patch deleted file mode 100644 index 78df8e54ff..0000000000 --- a/.yarn/patches/postgres-shift-npm-0.1.0-9342b5f6f6.patch +++ /dev/null @@ -1,38 +0,0 @@ -diff --git a/index.js b/index.js -index 0115c8519e8c756ee24f50418eb2d85e8fc5c617..cf5cac0df35edf705c51ef7ed0ed489b69c0777e 100644 ---- a/index.js -+++ b/index.js -@@ -1,9 +1,9 @@ --import fs from 'fs' --import path from 'path' -+const fs = require('fs') -+const path = require('path') - - const join = path.join - --export default async function({ -+module.exports = async function({ - sql, - path = join(process.cwd(), 'migrations'), - before = null, -@@ -48,7 +48,7 @@ export default async function({ - }) { - fs.existsSync(join(path, 'index.sql')) && !fs.existsSync(join(path, 'index.js')) - ? await sql.file(join(path, 'index.sql')) -- : await import(join(path, 'index.js')).then(x => x.default(sql)) // eslint-disable-line -+ : await require(join(path, 'index.js'))(sql) // eslint-disable-line - - await sql` - insert into migrations ( -diff --git a/package.json b/package.json -index fde88a1ecb0f49e7925fdd8743c552c70b30db32..5eee1e0c9728d5c38d517f3592ed5e893d9455cb 100644 ---- a/package.json -+++ b/package.json -@@ -3,7 +3,6 @@ - "version": "0.1.0", - "description": "A simple forwards only migration solution for [postgres.js](https://github.com/porsager/postgres)", - "main": "index.js", -- "type": "module", - "scripts": { - "test": "node tests/index.js" - }, diff --git a/migrations/__tests__/00001_accounts.spec.ts b/migrations/__tests__/00001_accounts.spec.ts new file mode 100644 index 0000000000..e4f685eebf --- /dev/null +++ b/migrations/__tests__/00001_accounts.spec.ts @@ -0,0 +1,51 @@ +import { dbFactory } from '@/__tests__/db.factory'; +import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; +import { Sql } from 'postgres'; + +describe('Migration 00001_accounts', () => { + const sql = dbFactory(); + const migrator = new PostgresDatabaseMigrator(sql); + + it('runs successfully', async () => { + await sql`DROP TABLE IF EXISTS groups, accounts CASCADE;`; + + const result = await migrator.test({ + migration: '00001_accounts', + after: async (sql: Sql) => { + return { + accounts: { + columns: + await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'accounts'`, + rows: await sql`SELECT * FROM accounts`, + }, + groups: { + columns: + await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'groups'`, + rows: await sql`SELECT * FROM groups`, + }, + }; + }, + }); + + expect(result.after).toStrictEqual({ + accounts: { + columns: [ + { column_name: 'id' }, + { column_name: 'group_id' }, + { column_name: 'address' }, + ], + rows: [], + }, + groups: { + columns: [ + { + column_name: 'id', + }, + ], + rows: [], + }, + }); + + await sql.end(); + }); +}); diff --git a/migrations/__tests__/_all.spec.ts b/migrations/__tests__/_all.spec.ts new file mode 100644 index 0000000000..31f8d88544 --- /dev/null +++ b/migrations/__tests__/_all.spec.ts @@ -0,0 +1,13 @@ +import { dbFactory } from '@/__tests__/db.factory'; +import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; + +describe('Migrations', () => { + const sql = dbFactory(); + const migrator = new PostgresDatabaseMigrator(sql); + + it('run successfully', async () => { + await expect(migrator.migrate()).resolves.not.toThrow(); + + await sql.end(); + }); +}); diff --git a/package.json b/package.json index 1e2e70f6a2..78d3d63ba9 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,6 @@ "lodash": "^4.17.21", "nestjs-cls": "^4.3.0", "postgres": "^3.4.4", - "postgres-shift": "^0.1.0", "redis": "^4.6.14", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", @@ -82,7 +81,6 @@ "json", "ts" ], - "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" @@ -98,15 +96,12 @@ ], "testEnvironment": "node", "moduleNameMapper": { - "^@/abis/(.*)$": "/../abis/$1", - "^@/(.*)$": "/../src/$1" + "^@/abis/(.*)$": "/abis/$1", + "^@/(.*)$": "/src/$1" }, - "globalSetup": "/../test/global-setup.ts" + "globalSetup": "/test/global-setup.ts" }, "main": "main.ts", "repository": "https://github.com/5afe/safe-client-gateway-nest.git", - "packageManager": "yarn@4.1.1", - "resolutions": { - "postgres-shift@^0.1.0": "patch:postgres-shift@npm%3A0.1.0#./.yarn/patches/postgres-shift-npm-0.1.0-9342b5f6f6.patch" - } + "packageManager": "yarn@4.1.1" } diff --git a/src/__tests__/db.factory.ts b/src/__tests__/db.factory.ts new file mode 100644 index 0000000000..78da019570 --- /dev/null +++ b/src/__tests__/db.factory.ts @@ -0,0 +1,30 @@ +import postgres from 'postgres'; +import fs from 'node:fs'; +import path from 'node:path'; +import configuration from '@/config/entities/__tests__/configuration'; + +export function dbFactory(): postgres.Sql { + const config = configuration(); + const isCIContext = process.env.CI?.toLowerCase() === 'true'; + + return postgres({ + host: config.db.postgres.host, + port: parseInt(config.db.postgres.port), + db: config.db.postgres.database, + user: config.db.postgres.username, + password: config.db.postgres.password, + // If running on a CI context (e.g.: GitHub Actions), + // disable certificate pinning for the test execution + ssl: + isCIContext || !config.db.postgres.ssl.enabled + ? false + : { + requestCert: config.db.postgres.ssl.requestCert, + rejectUnauthorized: config.db.postgres.ssl.rejectUnauthorized, + ca: fs.readFileSync( + path.join(process.cwd(), 'db_config/test/server.crt'), + 'utf8', + ), + }, + }); +} diff --git a/src/datasources/accounts/accounts.datasource.spec.ts b/src/datasources/accounts/accounts.datasource.spec.ts index e63c206bd7..4c42803b34 100644 --- a/src/datasources/accounts/accounts.datasource.spec.ts +++ b/src/datasources/accounts/accounts.datasource.spec.ts @@ -1,37 +1,12 @@ -import configuration from '@/config/entities/__tests__/configuration'; +import { dbFactory } from '@/__tests__/db.factory'; import { AccountsDatasource } from '@/datasources/accounts/accounts.datasource'; import { ILoggingService } from '@/logging/logging.interface'; import { faker } from '@faker-js/faker'; -import fs from 'node:fs'; -import path from 'node:path'; -import postgres from 'postgres'; -import shift from 'postgres-shift'; +import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; import { getAddress } from 'viem'; -const config = configuration(); - -const isCIContext = process.env.CI?.toLowerCase() === 'true'; - -const sql = postgres({ - host: config.db.postgres.host, - port: parseInt(config.db.postgres.port), - db: config.db.postgres.database, - user: config.db.postgres.username, - password: config.db.postgres.password, - // If running on a CI context (e.g.: GitHub Actions), - // disable certificate pinning for the test execution - ssl: - isCIContext || !config.db.postgres.ssl.enabled - ? false - : { - requestCert: config.db.postgres.ssl.requestCert, - rejectUnauthorized: config.db.postgres.ssl.rejectUnauthorized, - ca: fs.readFileSync( - path.join(process.cwd(), 'db_config/test/server.crt'), - 'utf8', - ), - }, -}); +const sql = dbFactory(); +const migrator = new PostgresDatabaseMigrator(sql); const mockLoggingService = { info: jest.fn(), @@ -43,7 +18,7 @@ describe('AccountsDatasource tests', () => { // Run pending migrations before tests beforeAll(async () => { - await shift({ sql }); + await migrator.migrate(); }); beforeEach(() => { diff --git a/src/datasources/db/postgres-database.migration.hook.ts b/src/datasources/db/postgres-database.migration.hook.ts index 5cedcc2f53..f11a81a725 100644 --- a/src/datasources/db/postgres-database.migration.hook.ts +++ b/src/datasources/db/postgres-database.migration.hook.ts @@ -1,7 +1,7 @@ import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; import { ILoggingService, LoggingService } from '@/logging/logging.interface'; -import shift from 'postgres-shift'; import postgres from 'postgres'; +import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; /** * The {@link PostgresDatabaseMigrationHook} is a Module Init hook meaning @@ -16,6 +16,8 @@ export class PostgresDatabaseMigrationHook implements OnModuleInit { constructor( @Inject('DB_INSTANCE') private readonly sql: postgres.Sql, + @Inject(PostgresDatabaseMigrator) + private readonly migrator: PostgresDatabaseMigrator, @Inject(LoggingService) private readonly loggingService: ILoggingService, ) {} @@ -29,7 +31,7 @@ export class PostgresDatabaseMigrationHook implements OnModuleInit { await this .sql`SELECT pg_advisory_lock(${PostgresDatabaseMigrationHook.LOCK_MAGIC_NUMBER})`; // Perform migration - await shift({ sql: this.sql }); + await this.migrator.migrate(); this.loggingService.info('Pending migrations executed'); } catch (e) { // If there's an error performing a migration, we should throw the error diff --git a/src/datasources/db/postgres-database.migrator.spec.ts b/src/datasources/db/postgres-database.migrator.spec.ts new file mode 100644 index 0000000000..92de93a09d --- /dev/null +++ b/src/datasources/db/postgres-database.migrator.spec.ts @@ -0,0 +1,335 @@ +import { dbFactory } from '@/__tests__/db.factory'; +import postgres from 'postgres'; +import path from 'node:path'; +import fs from 'node:fs'; +import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; + +const folder = path.join(__dirname, 'migrations'); +const migrations: Array<{ + name: string; + file: { name: string; contents: string }; +}> = [ + { + name: '00001_initial', + file: { + name: 'index.sql', + contents: `drop table if exists test; + create table test ( + a text, + b int + ); + + insert into test (a, b) values ('hello', 1337);`, + }, + }, + { + name: '00002_update', + file: { + name: 'index.js', + contents: `module.exports = async function(sql) { + await sql\` + alter table test add column c timestamp with time zone + \` + + await sql\` + insert into test (a, b, c) values ('world', 69420, ${'${new Date()}'}) + \` + }`, + }, + }, + { + name: '00003_delete', + file: { + name: 'index.sql', + contents: 'drop table test;', + }, + }, +]; + +describe('PostgresDatabaseMigrator tests', () => { + let sql: postgres.Sql; + let target: PostgresDatabaseMigrator; + + beforeEach(() => { + sql = dbFactory(); + target = new PostgresDatabaseMigrator(sql); + }); + + afterEach(async () => { + // Drop example table after each test + await sql`drop table if exists test`; + + // Close connection after each test + await sql.end(); + + // Remove migrations folder after each test + fs.rmSync(folder, { recursive: true, force: true }); + }); + + describe('migrate', () => { + afterEach(async () => { + // Drop migrations table after each test + await sql`drop table if exists migrations`; + }); + + it('should successfully migrate and keep track of last run migration', async () => { + // Create migration folders and files + for (const { name, file } of migrations) { + const migrationPath = path.join(folder, name); + fs.mkdirSync(migrationPath, { recursive: true }); + fs.writeFileSync(path.join(migrationPath, file.name), file.contents); + } + + // Test migration and expect migrations to be recorded + await expect(target.migrate(folder)).resolves.not.toThrow(); + await expect(sql`SELECT * FROM migrations`).resolves.toStrictEqual([ + { + id: 1, + name: 'initial', + created_at: expect.any(Date), + }, + { + id: 2, + name: 'update', + created_at: expect.any(Date), + }, + { + id: 3, + name: 'delete', + created_at: expect.any(Date), + }, + ]); + }); + + it('should migrate from the last run migration', async () => { + const [initialMigration, ...remainingMigrations] = migrations; + + // Create initial migration folder and file + const initialMigrationPath = path.join(folder, initialMigration.name); + fs.mkdirSync(initialMigrationPath, { recursive: true }); + fs.writeFileSync( + path.join(initialMigrationPath, initialMigration.file.name), + initialMigration.file.contents, + ); + + // Migrate (only initial migration should be recorded) + await target.migrate(folder); + const recordedMigrations = await sql`SELECT * FROM migrations`; + expect(recordedMigrations).toStrictEqual([ + { + id: 1, + name: 'initial', + created_at: expect.any(Date), + }, + ]); + + // Add remaining migrations + for (const { name, file } of remainingMigrations) { + const migrationPath = path.join(folder, name); + + fs.mkdirSync(migrationPath, { recursive: true }); + fs.writeFileSync(path.join(migrationPath, file.name), file.contents); + } + + // Migrate from last run migration + await target.migrate(folder); + await expect(sql`SELECT * FROM migrations`).resolves.toStrictEqual([ + { + id: 1, + name: 'initial', + // Was not run again + created_at: recordedMigrations[0].created_at, + }, + { + id: 2, + name: 'update', + created_at: expect.any(Date), + }, + { + id: 3, + name: 'delete', + created_at: expect.any(Date), + }, + ]); + }); + + it('throws if there are no migrations', async () => { + // Create empty migrations folder + fs.mkdirSync(folder, { recursive: true }); + + await expect(target.migrate(folder)).rejects.toThrow( + 'No migrations found', + ); + }); + + it('throws if there is inconsistent numbering', async () => { + // Omit second migration to create inconsistency + const [migration1, , migration3] = migrations; + + // Add inconsistent migration folders and file + for (const { name, file } of [migration1, migration3]) { + const migrationPath = path.join(folder, name); + + fs.mkdirSync(migrationPath, { recursive: true }); + fs.writeFileSync(path.join(migrationPath, file.name), file.contents); + } + + await expect(target.migrate(folder)).rejects.toThrow( + 'Migrations numbered inconsistency', + ); + }); + }); + + describe('test', () => { + it('should test migration', async () => { + const [migration1, migration2, migration3] = migrations; + + // Create migration folder with first migration + const migration1Path = path.join(folder, migration1.name); + fs.mkdirSync(migration1Path, { recursive: true }); + fs.writeFileSync( + path.join(migration1Path, migration1.file.name), + migration1.file.contents, + ); + + // Test first migration + await expect( + target.test({ + migration: migration1.name, + before: (sql) => sql`SELECT * FROM test`, + after: (sql) => sql`SELECT * FROM test`, + folder, + }), + ).resolves.toStrictEqual({ + before: undefined, + after: [ + { + a: 'hello', + b: 1337, + }, + ], + }); + + // Should not track migrations when testing + await expect(sql`SELECT * FROM migrations`).rejects.toThrow( + 'does not exist', + ); + + // Add second migration + const migration2Path = path.join(folder, migration2.name); + fs.mkdirSync(migration2Path, { recursive: true }); + fs.writeFileSync( + path.join(migration2Path, migration2.file.name), + migration2.file.contents, + ); + + // Test up to second migration + await expect( + target.test({ + migration: migration2.name, + before: (sql) => sql`SELECT * FROM test`, + after: (sql) => sql`SELECT * FROM test`, + folder, + }), + ).resolves.toStrictEqual({ + before: [ + { + a: 'hello', + b: 1337, + }, + ], + after: [ + { + a: 'hello', + b: 1337, + c: null, + }, + { + a: 'world', + b: 69420, + c: expect.any(Date), + }, + ], + }); + + // Add third migration + const migration3Path = path.join(folder, migration3.name); + fs.mkdirSync(migration3Path, { recursive: true }); + fs.writeFileSync( + path.join(migration3Path, migration3.file.name), + migration3.file.contents, + ); + + // Test all migrations + await expect( + target.test({ + migration: migration3.name, + before: (sql) => sql`SELECT * FROM test`, + after: (sql) => sql`SELECT * FROM test`, + folder, + }), + ).resolves.toStrictEqual({ + before: [ + { + a: 'hello', + b: 1337, + c: null, + }, + { + a: 'world', + b: 69420, + c: expect.any(Date), + }, + ], + // Final migration drops table + after: undefined, + }); + }); + + it('throws if there are no migrations', async () => { + // Create empty migrations folder + fs.mkdirSync(folder, { recursive: true }); + + await expect( + target.test({ migration: '', after: Promise.resolve, folder }), + ).rejects.toThrow('No migrations found'); + + // Remove migrations folder + fs.rmSync(folder, { recursive: true }); + }); + + it('throws if migration is not found', async () => { + // Create migration folders and files + for (const { name, file } of migrations) { + const migrationPath = path.join(folder, name); + + fs.mkdirSync(migrationPath, { recursive: true }); + fs.writeFileSync(path.join(migrationPath, file.name), file.contents); + } + + await expect( + target.test({ migration: '69420_hax', after: Promise.resolve, folder }), + ).rejects.toThrow('Migration 69420_hax not found'); + + // Remove migrations folder + fs.rmSync(folder, { recursive: true }); + }); + + it('throws if there is inconsistent numbering', async () => { + // Omit second migration to create inconsistency + const [migration1, , migration3] = migrations; + + // Add inconsistent migration folders and file + for (const { name, file } of [migration1, migration3]) { + const migrationPath = path.join(folder, name); + + fs.mkdirSync(migrationPath, { recursive: true }); + fs.writeFileSync(path.join(migrationPath, file.name), file.contents); + } + + await expect( + target.test({ migration: '', after: Promise.resolve, folder }), + ).rejects.toThrow('Migrations numbered inconsistency'); + }); + }); +}); diff --git a/src/datasources/db/postgres-database.migrator.ts b/src/datasources/db/postgres-database.migrator.ts new file mode 100644 index 0000000000..d2e32c8f3d --- /dev/null +++ b/src/datasources/db/postgres-database.migrator.ts @@ -0,0 +1,240 @@ +import { Inject, Injectable } from '@nestjs/common'; +import fs from 'node:fs'; +import { join } from 'node:path'; +import type { Sql, TransactionSql } from 'postgres'; + +type Migration = { + path: string; + id: number; + name: string; +}; + +/** + * Migrates a Postgres database using SQL and JavaScript files. + * + * Migrations should be in a directory, prefixed with a 5-digit number, + * and contain either an `index.sql` or `index.js` file. + * + * This is heavily inspired by `postgres-shift` + * @see https://github.com/porsager/postgres-shift/blob/master/index.js + */ +@Injectable() +export class PostgresDatabaseMigrator { + private static readonly MIGRATIONS_FOLDER = join(process.cwd(), 'migrations'); + private static readonly SQL_MIGRATION_FILE = 'index.sql'; + private static readonly JS_MIGRATION_FILE = 'index.js'; + private static readonly MIGRATIONS_TABLE = 'migrations'; + + constructor(@Inject('DB_INSTANCE') private readonly sql: Sql) {} + + /** + * Runs/records migrations not present in the {@link PostgresMigrator.MIGRATIONS_TABLE} table. + * + * Note: all migrations are run in a single transaction for optimal performance. + */ + async migrate( + path = PostgresDatabaseMigrator.MIGRATIONS_FOLDER, + ): Promise { + const migrations = this.getMigrations(path); + + await this.assertMigrationsTable(); + + const last = await this.getLastRunMigration(); + const remaining = migrations.slice(last?.id ?? 0); + + await this.sql.begin(async (transaction: TransactionSql) => { + for (const current of remaining) { + await this.run({ transaction, migration: current }); + await this.setLastRunMigration({ transaction, migration: current }); + } + }); + } + + /** + * @private migrates up to/allows for querying before/after migration to test it. + * + * Note: each migration is ran in separate transaction to allow queries in between. + * + * @param args.migration - migration to test + * @param args.folder - folder to search for migrations + * @param args.before - function to run before each migration + * @param args.after - function to run after each migration + * + * @example + * ```typescript + * const result = await migrator.test({ + * migration: '00001_initial', + * before: (sql) => sql`SELECT * FROM `, + * after: (sql) => sql`SELECT * FROM `, + * }); + * + * expect(result.before).toBeUndefined(); + * expect(result.after).toStrictEqual(expected); + * ``` + */ + async test(args: { + migration: string; + before?: (sql: Sql) => Promise; + after: (sql: Sql) => Promise; + folder?: string; + }): Promise<{ + before: unknown; + after: unknown; + }> { + const migrations = this.getMigrations( + args.folder ?? PostgresDatabaseMigrator.MIGRATIONS_FOLDER, + ); + + // Find index of migration to test + const migrationIndex = migrations.findIndex((migration) => { + return migration.path.includes(args.migration); + }); + + if (migrationIndex === -1) { + throw new Error(`Migration ${args.migration} not found`); + } + + // Get migrations up to the specified migration + const migrationsToTest = migrations.slice(0, migrationIndex + 1); + + let before: unknown; + + for await (const migration of migrationsToTest) { + const isMigrationBeingTested = migration.path.includes(args.migration); + + if (isMigrationBeingTested && args.before) { + before = await args.before(this.sql).catch(() => undefined); + } + + await this.sql.begin((transaction) => { + return this.run({ transaction, migration }); + }); + } + + const after = await args.after(this.sql).catch(() => undefined); + + return { before, after }; + } + + /** + * Retrieves all migrations found at the specified path. + * + * @param path - path to search for migrations + * + * @returns array of {@link Migration} + */ + private getMigrations(path: string): Array { + const migrations = fs + .readdirSync(path) + .filter((file) => { + const isDirectory = fs.statSync(join(path, file)).isDirectory(); + const isMigration = file.match(/^[0-9]{5}_/); + return isDirectory && isMigration; + }) + .sort() + .map((file) => { + return { + path: join(path, file), + id: parseInt(file.slice(0, 5)), + name: file.slice(6), + }; + }); + + if (migrations.length === 0) { + throw new Error('No migrations found'); + } + + const latest = migrations.at(-1); + if (latest?.id !== migrations.length) { + throw new Error('Migrations numbered inconsistency'); + } + + return migrations; + } + + /** + * Adds specified migration to the transaction if supported. + * + * @param args.transaction - {@link TransactionSql} to migration within + * @param args.migration - {@link Migration} to add + */ + private async run(args: { + transaction: TransactionSql; + migration: Migration; + }): Promise { + const isSql = fs.existsSync( + join(args.migration.path, PostgresDatabaseMigrator.SQL_MIGRATION_FILE), + ); + const isJs = fs.existsSync( + join(args.migration.path, PostgresDatabaseMigrator.JS_MIGRATION_FILE), + ); + + if (isSql) { + await args.transaction.file( + join(args.migration.path, PostgresDatabaseMigrator.SQL_MIGRATION_FILE), + ); + } else if (isJs) { + const file = (await import( + join(args.migration.path, PostgresDatabaseMigrator.JS_MIGRATION_FILE) + )) as { + default: (transaction: TransactionSql) => Promise; + }; + await file.default(args.transaction); + } else { + throw new Error(`No migration file found for ${args.migration.path}`); + } + } + /** + * Creates the {@link PostgresDatabaseMigrator.MIGRATIONS_TABLE} table if it does not exist. + */ + private async assertMigrationsTable(): Promise { + try { + await this.sql`SELECT + '${this.sql(PostgresDatabaseMigrator.MIGRATIONS_TABLE)}'::regclass`; + } catch { + await this.sql`CREATE TABLE + ${this.sql(PostgresDatabaseMigrator.MIGRATIONS_TABLE)} ( + id SERIAL PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + name TEXT + )`; + } + } + + /** + * Retrieves the last run migration from the {@link PostgresDatabaseMigrator.MIGRATIONS_TABLE} table. + * + * @returns last run {@link Migration} + */ + private async getLastRunMigration(): Promise { + const [last] = await this.sql>`SELECT + id + FROM + ${this.sql(PostgresDatabaseMigrator.MIGRATIONS_TABLE)} + ORDER BY + id DESC + LIMIT + 1`; + + return last; + } + + /** + * Adds the last run migration to the {@link PostgresDatabaseMigrator.MIGRATIONS_TABLE} table. + * + * @param args.transaction - {@link TransactionSql} to set within + * @param args.migration - {@link Migration} to set + */ + private async setLastRunMigration(args: { + transaction: TransactionSql; + migration: Migration; + }): Promise { + await args.transaction`INSERT INTO ${this.sql(PostgresDatabaseMigrator.MIGRATIONS_TABLE)} ( + id, + name + ) VALUES ( + ${args.migration.id}, + ${args.migration.name} + )`; + } +} diff --git a/src/datasources/db/postgres-database.module.ts b/src/datasources/db/postgres-database.module.ts index 6d28e11832..c8fb93e79f 100644 --- a/src/datasources/db/postgres-database.module.ts +++ b/src/datasources/db/postgres-database.module.ts @@ -4,6 +4,7 @@ import { PostgresDatabaseShutdownHook } from '@/datasources/db/postgres-database import { IConfigurationService } from '@/config/configuration.service.interface'; import { PostgresDatabaseMigrationHook } from '@/datasources/db/postgres-database.migration.hook'; import fs from 'fs'; +import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; function dbFactory(configurationService: IConfigurationService): postgres.Sql { const caPath = configurationService.get('db.postgres.ssl.caPath'); @@ -31,6 +32,10 @@ function dbFactory(configurationService: IConfigurationService): postgres.Sql { }); } +function migratorFactory(sql: postgres.Sql): PostgresDatabaseMigrator { + return new PostgresDatabaseMigrator(sql); +} + @Module({ providers: [ { @@ -38,6 +43,11 @@ function dbFactory(configurationService: IConfigurationService): postgres.Sql { useFactory: dbFactory, inject: [IConfigurationService], }, + { + provide: PostgresDatabaseMigrator, + useFactory: migratorFactory, + inject: ['DB_INSTANCE'], + }, PostgresDatabaseShutdownHook, PostgresDatabaseMigrationHook, ], diff --git a/src/types/postgres-shift.d.ts b/src/types/postgres-shift.d.ts deleted file mode 100644 index 0e77546f20..0000000000 --- a/src/types/postgres-shift.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -declare module 'postgres-shift' { - import { Sql } from 'postgres'; - - // https://github.com/porsager/postgres-shift/blob/master/index.js - // eslint-disable-next-line @typescript-eslint/no-unused-vars - export default async (options: { - sql: Sql; - path?: string; - before?: boolean | null; - after?: boolean | null; - }): Promise => {}; -} diff --git a/yarn.lock b/yarn.lock index bd52d28173..f6bc2cc811 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6791,20 +6791,6 @@ __metadata: languageName: node linkType: hard -"postgres-shift@npm:0.1.0": - version: 0.1.0 - resolution: "postgres-shift@npm:0.1.0" - checksum: 10/1bfa9ac4479df2c0e2d8b0ecbc6e7b39c4ef990235c8200254fbd347bc08989cf0d078d378880656c14f5e20f18953a020d1ba58f85fc3973f99102aa455d0a1 - languageName: node - linkType: hard - -"postgres-shift@patch:postgres-shift@npm%3A0.1.0#./.yarn/patches/postgres-shift-npm-0.1.0-9342b5f6f6.patch::locator=safe-client-gateway%40workspace%3A.": - version: 0.1.0 - resolution: "postgres-shift@patch:postgres-shift@npm%3A0.1.0#./.yarn/patches/postgres-shift-npm-0.1.0-9342b5f6f6.patch::version=0.1.0&hash=5731ac&locator=safe-client-gateway%40workspace%3A." - checksum: 10/65263993b2f45da2e20d4df651bd8ede600702e53a67bcf81102990cf8f56bb0de9e5273301245226b0ebf301508ea75d0d976670427dd10672337230d848926 - languageName: node - linkType: hard - "postgres@npm:^3.4.4": version: 3.4.4 resolution: "postgres@npm:3.4.4" @@ -7279,7 +7265,6 @@ __metadata: lodash: "npm:^4.17.21" nestjs-cls: "npm:^4.3.0" postgres: "npm:^3.4.4" - postgres-shift: "npm:^0.1.0" prettier: "npm:^3.3.1" redis: "npm:^4.6.14" reflect-metadata: "npm:^0.2.2" From c649dd729368d315fa89c0b0e5b820bfdbe39a4c Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 17 Jun 2024 13:19:19 +0200 Subject: [PATCH 098/207] Don't copy patches to build (#1659) Removes the `COPY` of `.yarn/patches` from the `Dockerfile`. --- Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index ac74ba70c1..9958e6a640 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,6 @@ ENV NODE_ENV production ENV YARN_CACHE_FOLDER /root/.yarn WORKDIR /app COPY --chown=node:node .yarn/releases ./.yarn/releases -COPY --chown=node:node .yarn/patches ./.yarn/patches COPY --chown=node:node package.json yarn.lock .yarnrc.yml tsconfig*.json ./ COPY --chown=node:node scripts/generate-abis.js ./scripts/generate-abis.js RUN --mount=type=cache,target=/root/.yarn yarn From 18c26ea30de06267436f3d20799affa6dd87cc31 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Jun 2024 23:41:20 +0200 Subject: [PATCH 099/207] Bump @types/node from 20.14.0 to 20.14.3 (#1662) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.14.0 to 20.14.3. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 78d3d63ba9..9d5072f99d 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "@types/jest": "29.5.12", "@types/jsonwebtoken": "^9", "@types/lodash": "^4.17.5", - "@types/node": "^20.14.0", + "@types/node": "^20.14.3", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.2", "eslint": "^9.4.0", diff --git a/yarn.lock b/yarn.lock index f6bc2cc811..4f4b59c8f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1918,12 +1918,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^20.14.0": - version: 20.14.0 - resolution: "@types/node@npm:20.14.0" +"@types/node@npm:^20.14.3": + version: 20.14.3 + resolution: "@types/node@npm:20.14.3" dependencies: undici-types: "npm:~5.26.4" - checksum: 10/49b332fbf8aee4dc4f61cc1f1f6e130632510f795dd7b274e55894516feaf4bec8a3d13ea764e2443e340a64ce9bbeb006d14513bf6ccdd4f21161eccc7f311e + checksum: 10/4ac40f26cde19536224c1b32c06e20c40f1163bdde617bda73561bb13070cd444c6a280f3f10a0eb621a4e341eb2d9a6f3a18193b715c17e70e5fa884b81ed1a languageName: node linkType: hard @@ -7251,7 +7251,7 @@ __metadata: "@types/jest": "npm:29.5.12" "@types/jsonwebtoken": "npm:^9" "@types/lodash": "npm:^4.17.5" - "@types/node": "npm:^20.14.0" + "@types/node": "npm:^20.14.3" "@types/semver": "npm:^7.5.8" "@types/supertest": "npm:^6.0.2" amqp-connection-manager: "npm:^4.1.14" From cfe9800f51033daf6a8f3b1b52f870b0a22b37b7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Jun 2024 23:41:38 +0200 Subject: [PATCH 100/207] Bump prettier from 3.3.1 to 3.3.2 (#1663) Bumps [prettier](https://github.com/prettier/prettier) from 3.3.1 to 3.3.2. - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.3.1...3.3.2) --- updated-dependencies: - dependency-name: prettier dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 9d5072f99d..a64bf7c339 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "eslint-config-prettier": "^9.1.0", "husky": "^9.0.11", "jest": "29.7.0", - "prettier": "^3.3.1", + "prettier": "^3.3.2", "source-map-support": "^0.5.20", "supertest": "^7.0.0", "ts-jest": "29.1.4", diff --git a/yarn.lock b/yarn.lock index 4f4b59c8f7..6a67436e48 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6805,12 +6805,12 @@ __metadata: languageName: node linkType: hard -"prettier@npm:^3.3.1": - version: 3.3.1 - resolution: "prettier@npm:3.3.1" +"prettier@npm:^3.3.2": + version: 3.3.2 + resolution: "prettier@npm:3.3.2" bin: prettier: bin/prettier.cjs - checksum: 10/31ca48d07a163fe6bff5483feb9bdf3bd7e4305e8d976373375cddc2949180a007be3ef08c36f4d7b31e449acef1ebbf46d3b94dc32f5a276837bf48c393be69 + checksum: 10/83214e154afa5aa9b664c2506640212323eb1376b13379b2413dc351b7de0687629dca3f00ff2ec895ebd7e3a2adb7d7e231b6c77606e2358137f2150807405b languageName: node linkType: hard @@ -7265,7 +7265,7 @@ __metadata: lodash: "npm:^4.17.21" nestjs-cls: "npm:^4.3.0" postgres: "npm:^3.4.4" - prettier: "npm:^3.3.1" + prettier: "npm:^3.3.2" redis: "npm:^4.6.14" reflect-metadata: "npm:^0.2.2" rxjs: "npm:^7.8.1" From 8e988d00391533d6c3edd3f03741a87c8991f2a3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Jun 2024 23:42:09 +0200 Subject: [PATCH 101/207] Bump viem from 2.13.8 to 2.14.2 (#1664) Bumps [viem](https://github.com/wevm/viem) from 2.13.8 to 2.14.2. - [Release notes](https://github.com/wevm/viem/releases) - [Commits](https://github.com/wevm/viem/compare/viem@2.13.8...viem@2.14.2) --- updated-dependencies: - dependency-name: viem dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index a64bf7c339..5ec0c88b2d 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "semver": "^7.6.2", - "viem": "^2.13.8", + "viem": "^2.14.2", "winston": "^3.13.0", "zod": "^3.23.8" }, diff --git a/yarn.lock b/yarn.lock index 6a67436e48..7075e95627 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7278,7 +7278,7 @@ __metadata: tsconfig-paths: "npm:4.2.0" typescript: "npm:^5.4.5" typescript-eslint: "npm:^7.13.0" - viem: "npm:^2.13.8" + viem: "npm:^2.14.2" winston: "npm:^3.13.0" zod: "npm:^3.23.8" languageName: unknown @@ -8306,9 +8306,9 @@ __metadata: languageName: node linkType: hard -"viem@npm:^2.13.8": - version: 2.13.8 - resolution: "viem@npm:2.13.8" +"viem@npm:^2.14.2": + version: 2.14.2 + resolution: "viem@npm:2.14.2" dependencies: "@adraffy/ens-normalize": "npm:1.10.0" "@noble/curves": "npm:1.2.0" @@ -8323,7 +8323,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/e92da3344687c233b0951069a623319616bafdc30ad43558b7bb57287aebfd6e8a12f05c80711ef285bb67906c79a96cfea319d48c2440a7919c494414fb20ff + checksum: 10/41f2c7b3c242350867a06e2e0ab75a8e1b58cfef244d62a9c3301b5ff8cad75600992d7a0145aa221b2e271c2aeb7daf4c5f193ed7b5999e976fb42609da7f83 languageName: node linkType: hard From 9ffdbc1daea8e4d428099daccbdd17e16b18a5a3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Jun 2024 23:42:30 +0200 Subject: [PATCH 102/207] Bump @safe-global/safe-deployments from 1.36.0 to 1.37.0 (#1666) Bumps [@safe-global/safe-deployments](https://github.com/safe-global/safe-deployments) from 1.36.0 to 1.37.0. - [Release notes](https://github.com/safe-global/safe-deployments/releases) - [Commits](https://github.com/safe-global/safe-deployments/commits) --- updated-dependencies: - dependency-name: "@safe-global/safe-deployments" dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 5ec0c88b2d..bec9ef6361 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "@nestjs/platform-express": "^10.3.9", "@nestjs/serve-static": "^4.0.2", "@nestjs/swagger": "^7.3.1", - "@safe-global/safe-deployments": "^1.36.0", + "@safe-global/safe-deployments": "^1.37.0", "amqp-connection-manager": "^4.1.14", "amqplib": "^0.10.4", "cookie-parser": "^1.4.6", diff --git a/yarn.lock b/yarn.lock index 7075e95627..2cc7fb1170 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1569,12 +1569,12 @@ __metadata: languageName: node linkType: hard -"@safe-global/safe-deployments@npm:^1.36.0": - version: 1.36.0 - resolution: "@safe-global/safe-deployments@npm:1.36.0" +"@safe-global/safe-deployments@npm:^1.37.0": + version: 1.37.0 + resolution: "@safe-global/safe-deployments@npm:1.37.0" dependencies: semver: "npm:^7.6.0" - checksum: 10/dfcb6dc62d3c4a2c03d8aea099ac4f6155936a660c8456b88fe68867d5b8c3aca229f2583fae06cca5d1d33262dc8acfdb504ddeeccbf40bbdf600f52a819363 + checksum: 10/99304d3b67d564ca014d8d2d2a0773319a53ccc89846c05464353699864f229f35615bcfc8e08f13acbd099b5670d54885309c02f914c7eda75b2cf93e145350 languageName: node linkType: hard @@ -7244,7 +7244,7 @@ __metadata: "@nestjs/serve-static": "npm:^4.0.2" "@nestjs/swagger": "npm:^7.3.1" "@nestjs/testing": "npm:^10.3.9" - "@safe-global/safe-deployments": "npm:^1.36.0" + "@safe-global/safe-deployments": "npm:^1.37.0" "@types/amqplib": "npm:^0" "@types/cookie-parser": "npm:^1.4.7" "@types/express": "npm:^4.17.21" From 710f1c63250475274c258c4c108c477de8c86a34 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Jun 2024 00:04:51 +0200 Subject: [PATCH 103/207] Bump eslint from 9.4.0 to 9.5.0 (#1665) Bumps [eslint](https://github.com/eslint/eslint) from 9.4.0 to 9.5.0. - [Release notes](https://github.com/eslint/eslint/releases) - [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md) - [Commits](https://github.com/eslint/eslint/compare/v9.4.0...v9.5.0) --- updated-dependencies: - dependency-name: eslint dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 44 ++++++++++++++++++++++---------------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/package.json b/package.json index bec9ef6361..d0a38d47c7 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@types/node": "^20.14.3", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.2", - "eslint": "^9.4.0", + "eslint": "^9.5.0", "eslint-config-prettier": "^9.1.0", "husky": "^9.0.11", "jest": "29.7.0", diff --git a/yarn.lock b/yarn.lock index 2cc7fb1170..fc1d90c5ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -682,14 +682,14 @@ __metadata: languageName: node linkType: hard -"@eslint/config-array@npm:^0.15.1": - version: 0.15.1 - resolution: "@eslint/config-array@npm:0.15.1" +"@eslint/config-array@npm:^0.16.0": + version: 0.16.0 + resolution: "@eslint/config-array@npm:0.16.0" dependencies: - "@eslint/object-schema": "npm:^2.1.3" + "@eslint/object-schema": "npm:^2.1.4" debug: "npm:^4.3.1" minimatch: "npm:^3.0.5" - checksum: 10/cf8f68a24498531180fad6846cb52dac4e852b0296d2664930bc15d6a2944ad427827bbaebfddf3f87b9c5db0e36c13974d6dc89fff8ba0d3d2b4357b8d52b4e + checksum: 10/6c1716f896a5bd290a2987ac28ec4fe18f052d2338ccf7822107eb0a6b974c44e6297cb7c9d6e0c5718c510e6c8e53043bea04cf4836dcb26a57e0255bfe99bc languageName: node linkType: hard @@ -710,17 +710,17 @@ __metadata: languageName: node linkType: hard -"@eslint/js@npm:9.4.0": - version: 9.4.0 - resolution: "@eslint/js@npm:9.4.0" - checksum: 10/f1fa9acda8bab02dad21e9b7f46c6ba8cb3949979846caf7667f0c682ed0b56d9e8db143b00aab587ef2d02603df202eb5f7017d8f3a98be94be6efa763865ab +"@eslint/js@npm:9.5.0": + version: 9.5.0 + resolution: "@eslint/js@npm:9.5.0" + checksum: 10/206364e3a074eaaeccc2b9e1e3f129539106a81ec634f32c51bc1699e0c4a47ab3e6480a6484a198bca6406888ba8f2917c35a87296680905d146075b5ed2738 languageName: node linkType: hard -"@eslint/object-schema@npm:^2.1.3": - version: 2.1.3 - resolution: "@eslint/object-schema@npm:2.1.3" - checksum: 10/832e80e91503a1e74a8d870b41c9f374064492a89002c45af17cad9766080e8770c21319a50f0004a77f36add9af6218dbeff34d3e3a16446784ea80a933c0a7 +"@eslint/object-schema@npm:^2.1.4": + version: 2.1.4 + resolution: "@eslint/object-schema@npm:2.1.4" + checksum: 10/221e8d9f281c605948cd6e030874aacce83fe097f8f9c1964787037bccf08e82b7aa9eff1850a30fffac43f1d76555727ec22a2af479d91e268e89d1e035131e languageName: node linkType: hard @@ -3861,15 +3861,15 @@ __metadata: languageName: node linkType: hard -"eslint@npm:^9.4.0": - version: 9.4.0 - resolution: "eslint@npm:9.4.0" +"eslint@npm:^9.5.0": + version: 9.5.0 + resolution: "eslint@npm:9.5.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.2.0" "@eslint-community/regexpp": "npm:^4.6.1" - "@eslint/config-array": "npm:^0.15.1" + "@eslint/config-array": "npm:^0.16.0" "@eslint/eslintrc": "npm:^3.1.0" - "@eslint/js": "npm:9.4.0" + "@eslint/js": "npm:9.5.0" "@humanwhocodes/module-importer": "npm:^1.0.1" "@humanwhocodes/retry": "npm:^0.3.0" "@nodelib/fs.walk": "npm:^1.2.8" @@ -3881,7 +3881,7 @@ __metadata: eslint-scope: "npm:^8.0.1" eslint-visitor-keys: "npm:^4.0.0" espree: "npm:^10.0.1" - esquery: "npm:^1.4.2" + esquery: "npm:^1.5.0" esutils: "npm:^2.0.2" fast-deep-equal: "npm:^3.1.3" file-entry-cache: "npm:^8.0.0" @@ -3901,7 +3901,7 @@ __metadata: text-table: "npm:^0.2.0" bin: eslint: bin/eslint.js - checksum: 10/e2eaae18eb79d543a1ca5420495ea9bf1278f9e25bfa6309ec4e4dae981cba4d731a9b857f5e2f8b5e467adaaf871a635a7eb143a749e7cdcdff4716821628d2 + checksum: 10/47578c242659a398638918c6f61a12c3e1e0ca71733769a54fdfd7be6d7c4ca0824694861846959829784b23cbfca5aad9599714dc0f4ae48ffdcdafbfe67bea languageName: node linkType: hard @@ -3926,7 +3926,7 @@ __metadata: languageName: node linkType: hard -"esquery@npm:^1.4.2": +"esquery@npm:^1.5.0": version: 1.5.0 resolution: "esquery@npm:1.5.0" dependencies: @@ -7257,7 +7257,7 @@ __metadata: amqp-connection-manager: "npm:^4.1.14" amqplib: "npm:^0.10.4" cookie-parser: "npm:^1.4.6" - eslint: "npm:^9.4.0" + eslint: "npm:^9.5.0" eslint-config-prettier: "npm:^9.1.0" husky: "npm:^9.0.11" jest: "npm:29.7.0" From 82eea8fe0ab8cc2d9e40c916ffc82e9da782d15e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Jun 2024 08:19:58 +0200 Subject: [PATCH 104/207] Bump docker/build-push-action from 5 to 6 (#1661) Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 6. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v5...v6) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c2f756d23..32dcc4fcb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,7 +128,7 @@ jobs: with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - - uses: docker/build-push-action@v5 + - uses: docker/build-push-action@v6 with: platforms: linux/amd64,linux/arm64 push: true @@ -157,7 +157,7 @@ jobs: with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - - uses: docker/build-push-action@v5 + - uses: docker/build-push-action@v6 with: platforms: linux/amd64,linux/arm64 push: true From 7218ecba2437608224dea7f57d11d3afb010b092 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Tue, 18 Jun 2024 12:06:40 +0200 Subject: [PATCH 105/207] Add base decoder for ComposableCoW contract (#1660) Adds a new `ComposableCowDecoder`, based on the [required elements of the CoW SDK](https://github.com/cowprotocol/cow-sdk/blob/5aa61a03d2ed9921c5f95522866b2af0ceb1c24d/src/composable/orderTypes/Twap.ts) to detect and decode `createWithContext` calls: - Add ComposableCoW ABI - Create `ComposableCowDecoder` - Add test coverage --- .gitignore | 3 +- abis/composable-cow/ComposableCoW.abi.ts | 613 ++++++++++++++++++ .../composable-cow-decoder.helper.spec.ts | 30 + .../decoders/composable-cow-decoder.helper.ts | 169 +++++ 4 files changed, 814 insertions(+), 1 deletion(-) create mode 100644 abis/composable-cow/ComposableCoW.abi.ts create mode 100644 src/domain/swaps/contracts/decoders/composable-cow-decoder.helper.spec.ts create mode 100644 src/domain/swaps/contracts/decoders/composable-cow-decoder.helper.ts diff --git a/.gitignore b/.gitignore index 38a65080da..c9fb220daf 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,5 @@ lerna-debug.log* data # ABIs -/abis +/abis/* +!/abis/composable-cow \ No newline at end of file diff --git a/abis/composable-cow/ComposableCoW.abi.ts b/abis/composable-cow/ComposableCoW.abi.ts new file mode 100644 index 0000000000..de90bc4c49 --- /dev/null +++ b/abis/composable-cow/ComposableCoW.abi.ts @@ -0,0 +1,613 @@ +/** + * Taken from CoW SDK: + * + * @see https://github.com/cowprotocol/cow-sdk/blob/5aa61a03d2ed9921c5f95522866b2af0ceb1c24d/abi/ComposableCoW.json + */ +export default [ + { + inputs: [ + { + internalType: 'address', + name: '_settlement', + type: 'address', + }, + ], + stateMutability: 'nonpayable', + type: 'constructor', + }, + { + inputs: [], + name: 'InterfaceNotSupported', + type: 'error', + }, + { + inputs: [], + name: 'InvalidHandler', + type: 'error', + }, + { + inputs: [], + name: 'ProofNotAuthed', + type: 'error', + }, + { + inputs: [], + name: 'SingleOrderNotAuthed', + type: 'error', + }, + { + inputs: [], + name: 'SwapGuardRestricted', + type: 'error', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + components: [ + { + internalType: 'contract IConditionalOrder', + name: 'handler', + type: 'address', + }, + { + internalType: 'bytes32', + name: 'salt', + type: 'bytes32', + }, + { + internalType: 'bytes', + name: 'staticInput', + type: 'bytes', + }, + ], + indexed: false, + internalType: 'struct IConditionalOrder.ConditionalOrderParams', + name: 'params', + type: 'tuple', + }, + ], + name: 'ConditionalOrderCreated', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + indexed: false, + internalType: 'bytes32', + name: 'root', + type: 'bytes32', + }, + { + components: [ + { + internalType: 'uint256', + name: 'location', + type: 'uint256', + }, + { + internalType: 'bytes', + name: 'data', + type: 'bytes', + }, + ], + indexed: false, + internalType: 'struct ComposableCoW.Proof', + name: 'proof', + type: 'tuple', + }, + ], + name: 'MerkleRootSet', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + indexed: false, + internalType: 'contract ISwapGuard', + name: 'swapGuard', + type: 'address', + }, + ], + name: 'SwapGuardSet', + type: 'event', + }, + { + inputs: [ + { + internalType: 'address', + name: '', + type: 'address', + }, + { + internalType: 'bytes32', + name: '', + type: 'bytes32', + }, + ], + name: 'cabinet', + outputs: [ + { + internalType: 'bytes32', + name: '', + type: 'bytes32', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + components: [ + { + internalType: 'contract IConditionalOrder', + name: 'handler', + type: 'address', + }, + { + internalType: 'bytes32', + name: 'salt', + type: 'bytes32', + }, + { + internalType: 'bytes', + name: 'staticInput', + type: 'bytes', + }, + ], + internalType: 'struct IConditionalOrder.ConditionalOrderParams', + name: 'params', + type: 'tuple', + }, + { + internalType: 'bool', + name: 'dispatch', + type: 'bool', + }, + ], + name: 'create', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + components: [ + { + internalType: 'contract IConditionalOrder', + name: 'handler', + type: 'address', + }, + { + internalType: 'bytes32', + name: 'salt', + type: 'bytes32', + }, + { + internalType: 'bytes', + name: 'staticInput', + type: 'bytes', + }, + ], + internalType: 'struct IConditionalOrder.ConditionalOrderParams', + name: 'params', + type: 'tuple', + }, + { + internalType: 'contract IValueFactory', + name: 'factory', + type: 'address', + }, + { + internalType: 'bytes', + name: 'data', + type: 'bytes', + }, + { + internalType: 'bool', + name: 'dispatch', + type: 'bool', + }, + ], + name: 'createWithContext', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'domainSeparator', + outputs: [ + { + internalType: 'bytes32', + name: '', + type: 'bytes32', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + components: [ + { + internalType: 'contract IConditionalOrder', + name: 'handler', + type: 'address', + }, + { + internalType: 'bytes32', + name: 'salt', + type: 'bytes32', + }, + { + internalType: 'bytes', + name: 'staticInput', + type: 'bytes', + }, + ], + internalType: 'struct IConditionalOrder.ConditionalOrderParams', + name: 'params', + type: 'tuple', + }, + { + internalType: 'bytes', + name: 'offchainInput', + type: 'bytes', + }, + { + internalType: 'bytes32[]', + name: 'proof', + type: 'bytes32[]', + }, + ], + name: 'getTradeableOrderWithSignature', + outputs: [ + { + components: [ + { + internalType: 'contract IERC20', + name: 'sellToken', + type: 'address', + }, + { + internalType: 'contract IERC20', + name: 'buyToken', + type: 'address', + }, + { + internalType: 'address', + name: 'receiver', + type: 'address', + }, + { + internalType: 'uint256', + name: 'sellAmount', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'buyAmount', + type: 'uint256', + }, + { + internalType: 'uint32', + name: 'validTo', + type: 'uint32', + }, + { + internalType: 'bytes32', + name: 'appData', + type: 'bytes32', + }, + { + internalType: 'uint256', + name: 'feeAmount', + type: 'uint256', + }, + { + internalType: 'bytes32', + name: 'kind', + type: 'bytes32', + }, + { + internalType: 'bool', + name: 'partiallyFillable', + type: 'bool', + }, + { + internalType: 'bytes32', + name: 'sellTokenBalance', + type: 'bytes32', + }, + { + internalType: 'bytes32', + name: 'buyTokenBalance', + type: 'bytes32', + }, + ], + internalType: 'struct GPv2Order.Data', + name: 'order', + type: 'tuple', + }, + { + internalType: 'bytes', + name: 'signature', + type: 'bytes', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + components: [ + { + internalType: 'contract IConditionalOrder', + name: 'handler', + type: 'address', + }, + { + internalType: 'bytes32', + name: 'salt', + type: 'bytes32', + }, + { + internalType: 'bytes', + name: 'staticInput', + type: 'bytes', + }, + ], + internalType: 'struct IConditionalOrder.ConditionalOrderParams', + name: 'params', + type: 'tuple', + }, + ], + name: 'hash', + outputs: [ + { + internalType: 'bytes32', + name: '', + type: 'bytes32', + }, + ], + stateMutability: 'pure', + type: 'function', + }, + { + inputs: [ + { + internalType: 'contract Safe', + name: 'safe', + type: 'address', + }, + { + internalType: 'address', + name: 'sender', + type: 'address', + }, + { + internalType: 'bytes32', + name: '_hash', + type: 'bytes32', + }, + { + internalType: 'bytes32', + name: '_domainSeparator', + type: 'bytes32', + }, + { + internalType: 'bytes32', + name: '', + type: 'bytes32', + }, + { + internalType: 'bytes', + name: 'encodeData', + type: 'bytes', + }, + { + internalType: 'bytes', + name: 'payload', + type: 'bytes', + }, + ], + name: 'isValidSafeSignature', + outputs: [ + { + internalType: 'bytes4', + name: 'magic', + type: 'bytes4', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'bytes32', + name: 'singleOrderHash', + type: 'bytes32', + }, + ], + name: 'remove', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: '', + type: 'address', + }, + ], + name: 'roots', + outputs: [ + { + internalType: 'bytes32', + name: '', + type: 'bytes32', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'bytes32', + name: 'root', + type: 'bytes32', + }, + { + components: [ + { + internalType: 'uint256', + name: 'location', + type: 'uint256', + }, + { + internalType: 'bytes', + name: 'data', + type: 'bytes', + }, + ], + internalType: 'struct ComposableCoW.Proof', + name: 'proof', + type: 'tuple', + }, + ], + name: 'setRoot', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'bytes32', + name: 'root', + type: 'bytes32', + }, + { + components: [ + { + internalType: 'uint256', + name: 'location', + type: 'uint256', + }, + { + internalType: 'bytes', + name: 'data', + type: 'bytes', + }, + ], + internalType: 'struct ComposableCoW.Proof', + name: 'proof', + type: 'tuple', + }, + { + internalType: 'contract IValueFactory', + name: 'factory', + type: 'address', + }, + { + internalType: 'bytes', + name: 'data', + type: 'bytes', + }, + ], + name: 'setRootWithContext', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'contract ISwapGuard', + name: 'swapGuard', + type: 'address', + }, + ], + name: 'setSwapGuard', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: '', + type: 'address', + }, + { + internalType: 'bytes32', + name: '', + type: 'bytes32', + }, + ], + name: 'singleOrders', + outputs: [ + { + internalType: 'bool', + name: '', + type: 'bool', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: '', + type: 'address', + }, + ], + name: 'swapGuards', + outputs: [ + { + internalType: 'contract ISwapGuard', + name: '', + type: 'address', + }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const; diff --git a/src/domain/swaps/contracts/decoders/composable-cow-decoder.helper.spec.ts b/src/domain/swaps/contracts/decoders/composable-cow-decoder.helper.spec.ts new file mode 100644 index 0000000000..e4aca4dbc8 --- /dev/null +++ b/src/domain/swaps/contracts/decoders/composable-cow-decoder.helper.spec.ts @@ -0,0 +1,30 @@ +import { ComposableCowDecoder } from '@/domain/swaps/contracts/decoders/composable-cow-decoder.helper'; + +describe('ComposableCowDecoder', () => { + const target = new ComposableCowDecoder(); + + describe('decodeTwapStruct', () => { + it('should decode a createWithContext call', () => { + const data = + '0x0d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011f294a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000'; + + const result = target.decodeTwapStruct(data); + + expect(result).toStrictEqual({ + appData: + '0xf7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda0', + buyToken: '0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14', + minPartLimit: BigInt('611289510998251134'), + n: BigInt('2'), + partSellAmount: BigInt('213586875483862141750'), + receiver: '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381', + sellToken: '0xbe72E441BF55620febc26715db68d3494213D8Cb', + span: BigInt('0'), + t: BigInt('1800'), + t0: BigInt('0'), + }); + }); + + it.todo('should throw if TWAP handler is invalid'); + }); +}); diff --git a/src/domain/swaps/contracts/decoders/composable-cow-decoder.helper.ts b/src/domain/swaps/contracts/decoders/composable-cow-decoder.helper.ts new file mode 100644 index 0000000000..43489409fe --- /dev/null +++ b/src/domain/swaps/contracts/decoders/composable-cow-decoder.helper.ts @@ -0,0 +1,169 @@ +import { AbiDecoder } from '@/domain/contracts/decoders/abi-decoder.helper'; +import ComposableCoW from '@/abis/composable-cow/ComposableCoW.abi'; +import { Injectable } from '@nestjs/common'; +import { decodeAbiParameters, isAddressEqual, parseAbiParameters } from 'viem'; + +/** + * Decoder for ComposableCow contract which focuses on decoding TWAP (`createWithContext`) orders + * + * The following is based on teh CoW SDK implementation: + * @see https://github.com/cowprotocol/cow-sdk/blob/5aa61a03d2ed9921c5f95522866b2af0ceb1c24d/src/composable/orderTypes/Twap.ts + */ +@Injectable() +export class ComposableCowDecoder extends AbiDecoder { + // Address of the TWAP handler contract + private static readonly TwapHandlerAddress = + '0x6cF1e9cA41f7611dEf408122793c358a3d11E5a5'; + + // Define the ABI of the TwapStruct + private static readonly TwapStructAbiParameters = parseAbiParameters( + 'address sellToken, address buyToken, address receiver, uint256 partSellAmount, uint256 minPartLimit, uint256 t0, uint256 n, uint256 t, uint256 span, bytes32 appData', + ); + + constructor() { + super(ComposableCoW); + } + + /** + * Decode {@link TwapStruct} from `createWithContext` data + * @param data - transaction data to decode + * @returns the decoded {@link TwapStruct} + */ + decodeTwapStruct(data: `0x${string}`): TwapStruct { + const decoded = this.decodeCreateWithContext(data); + + if (!decoded) { + throw new Error('Unable to decode `createWithContext` data'); + } + + const [params] = decoded; + + if ( + !isAddressEqual(params.handler, ComposableCowDecoder.TwapHandlerAddress) + ) { + throw new Error('Invalid TWAP handler'); + } + + return this.decodeConditionalOrderParams(params.staticInput); + } + + /** + * Decode the `createWithContext` data to tuple of parameters + * @param data - transaction data to decode + * @returns decoded parameters passed to `createWithContext` + */ + // Use inferred return type + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + private decodeCreateWithContext(data: `0x${string}`) { + try { + const decoded = this.decodeFunctionData({ + data, + }); + + if (decoded.functionName !== 'createWithContext') { + throw new Error('Data is not of createWithContext'); + } + + return decoded.args; + } catch { + return null; + } + } + + /** + * Decode the `ConditionalOrderParams` from `createWithContext` data + * @param staticInput - `staticInput` of `createWithContext` call + * @returns decoded `ConditionalOrderParams` as {@link TwapStruct} + */ + private decodeConditionalOrderParams( + staticInput: `0x${string}`, // IConditionalOrder.ConditionalOrderParams calldata + ): TwapStruct { + const [ + sellToken, + buyToken, + receiver, + partSellAmount, + minPartLimit, + t0, + n, + t, + span, + appData, + ] = decodeAbiParameters( + ComposableCowDecoder.TwapStructAbiParameters, + staticInput, + ); + + return { + sellToken, + buyToken, + receiver, + partSellAmount, + minPartLimit, + t0, + n, + t, + span, + appData, + }; + } +} + +/** + * Model of the contract's struct used for `staticIntput` of the `createWithContext` function + * @see https://docs.cow.fi/cow-protocol/reference/sdks/cow-sdk/interfaces/TwapStruct + */ +export type TwapStruct = { + /** + * which token to sell + */ + readonly sellToken: `0x${string}`; + + /** + * which token to buy + */ + readonly buyToken: `0x${string}`; + + /** + * who to send the tokens to + */ + readonly receiver: `0x${string}`; + + /** + * Meta-data associated with the order. Normally would be the keccak256 hash of the document generated in http://github.com/cowprotocol/app-data + * + * This hash should have been uploaded to the API https://api.cow.fi/docs/#/default/put_api_v1_app_data__app_data_hash_ and potentially to other data availability protocols like IPFS. + * + */ + readonly appData: `0x${string}`; + + /** + * amount of sellToken to sell in each part + */ + readonly partSellAmount: bigint; + + /** + * minimum amount of buyToken that must be bought in each part + */ + readonly minPartLimit: bigint; + + /** + * start time of the TWAP + */ + readonly t0: bigint; + + /** + * number of parts + */ + readonly n: bigint; + + /** + * duration of the TWAP interval + */ + readonly t: bigint; + + /** + * whether the TWAP is valid for the entire interval or not + */ + readonly span: bigint; +}; From 43ccea8306d87a086149fe885cb07e12d4bc1333 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Tue, 18 Jun 2024 12:07:01 +0200 Subject: [PATCH 106/207] Add method to get `fullAppData` from `appDataHash` (#1667) Adds new `ISwapsApi['getFullAppData']` that accepts the aforementioned hash and returns the decoded value according to the [CoW API](https://docs.cow.fi/cow-protocol/reference/apis/orderbook): - Add `ISwapsApi['getFullAppData']` and implementation - Add `FullAppDataSchema` (and use it within the `OrderSchema` as well) with test coverage - Add `ISwapsRepository['getFullAppData']`, calling the API and validating the response --- .../swaps-api/cowswap-api.service.ts | 11 ++++++++ src/domain/interfaces/swaps-api.factory.ts | 1 + src/domain/interfaces/swaps-api.interface.ts | 3 ++ .../entities/full-app-data.entity.spec.ts | 28 +++++++++++++++++++ .../swaps/entities/full-app-data.entity.ts | 22 +++++++++++++++ src/domain/swaps/entities/order.entity.ts | 18 ++---------- src/domain/swaps/swaps.repository.ts | 18 ++++++++++++ 7 files changed, 85 insertions(+), 16 deletions(-) create mode 100644 src/domain/swaps/entities/full-app-data.entity.spec.ts create mode 100644 src/domain/swaps/entities/full-app-data.entity.ts diff --git a/src/datasources/swaps-api/cowswap-api.service.ts b/src/datasources/swaps-api/cowswap-api.service.ts index c23f477733..28afc01bc5 100644 --- a/src/datasources/swaps-api/cowswap-api.service.ts +++ b/src/datasources/swaps-api/cowswap-api.service.ts @@ -2,6 +2,7 @@ import { INetworkService } from '@/datasources/network/network.service.interface import { Order } from '@/domain/swaps/entities/order.entity'; import { ISwapsApi } from '@/domain/interfaces/swaps-api.interface'; import { HttpErrorFactory } from '@/datasources/errors/http-error-factory'; +import { FullAppData } from '@/domain/swaps/entities/full-app-data.entity'; export class CowSwapApi implements ISwapsApi { constructor( @@ -19,4 +20,14 @@ export class CowSwapApi implements ISwapsApi { throw this.httpErrorFactory.from(error); } } + + async getFullAppData(appDataHash: `0x${string}`): Promise { + try { + const url = `${this.baseUrl}/api/v1/app_data/${appDataHash}`; + const { data } = await this.networkService.get({ url }); + return data; + } catch (error) { + throw this.httpErrorFactory.from(error); + } + } } diff --git a/src/domain/interfaces/swaps-api.factory.ts b/src/domain/interfaces/swaps-api.factory.ts index 098abfc1c0..22d14f292b 100644 --- a/src/domain/interfaces/swaps-api.factory.ts +++ b/src/domain/interfaces/swaps-api.factory.ts @@ -2,6 +2,7 @@ import { ISwapsApi } from '@/domain/interfaces/swaps-api.interface'; export const ISwapsApiFactory = Symbol('ISwapsApiFactory'); +// TODO: Extend IApiManager interface and clear on `CHAIN_UPDATE` export interface ISwapsApiFactory { get(chainId: string): ISwapsApi; } diff --git a/src/domain/interfaces/swaps-api.interface.ts b/src/domain/interfaces/swaps-api.interface.ts index bde6665e95..ba49a32684 100644 --- a/src/domain/interfaces/swaps-api.interface.ts +++ b/src/domain/interfaces/swaps-api.interface.ts @@ -1,5 +1,8 @@ +import { FullAppData } from '@/domain/swaps/entities/full-app-data.entity'; import { Order } from '@/domain/swaps/entities/order.entity'; export interface ISwapsApi { getOrder(uid: string): Promise; + + getFullAppData(appDataHash: `0x${string}`): Promise; } diff --git a/src/domain/swaps/entities/full-app-data.entity.spec.ts b/src/domain/swaps/entities/full-app-data.entity.spec.ts new file mode 100644 index 0000000000..d9983551b9 --- /dev/null +++ b/src/domain/swaps/entities/full-app-data.entity.spec.ts @@ -0,0 +1,28 @@ +import { FullAppDataSchema } from '@/domain/swaps/entities/full-app-data.entity'; + +describe('FullAppDataSchema', () => { + it.each([ + '[]', + '{}', + 'null', + '{\n "version": "0.1.0",\n "appCode": "Yearn",\n "metadata": {\n "referrer": {\n "version": "0.1.0",\n "address": "0xFEB4acf3df3cDEA7399794D0869ef76A6EfAff52"\n }\n }\n}\n', + ])('%s is valid', (fullAppData) => { + const data = { + fullAppData, + }; + + const result = FullAppDataSchema.safeParse(data); + + expect(result.success).toBe(true); + }); + + it.each(['a', 'a : b', '{', '['])('%s is not valid', (fullAppData) => { + const data = { + fullAppData, + }; + + const result = FullAppDataSchema.safeParse(data); + + expect(result.success).toBe(false); + }); +}); diff --git a/src/domain/swaps/entities/full-app-data.entity.ts b/src/domain/swaps/entities/full-app-data.entity.ts new file mode 100644 index 0000000000..ee2ab97a61 --- /dev/null +++ b/src/domain/swaps/entities/full-app-data.entity.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +export type FullAppData = z.infer; + +export const FullAppDataSchema = z.object({ + fullAppData: z + .string() + .nullish() + .default(null) + .transform((jsonString, ctx) => { + try { + if (!jsonString) return null; + return JSON.parse(jsonString); + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Not a valid JSON payload', + }); + return z.NEVER; + } + }), +}); diff --git a/src/domain/swaps/entities/order.entity.ts b/src/domain/swaps/entities/order.entity.ts index 11874b714f..bf450761a8 100644 --- a/src/domain/swaps/entities/order.entity.ts +++ b/src/domain/swaps/entities/order.entity.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; import { HexSchema } from '@/validation/entities/schemas/hex.schema'; +import { FullAppDataSchema } from '@/domain/swaps/entities/full-app-data.entity'; export type Order = z.infer; @@ -79,20 +80,5 @@ export const OrderSchema = z.object({ .nullish() .default(null), executedSurplusFee: z.coerce.bigint().nullish().default(null), - fullAppData: z - .string() - .nullish() - .default(null) - .transform((jsonString, ctx) => { - try { - if (!jsonString) return null; - return JSON.parse(jsonString); - } catch (error) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Not a valid JSON payload', - }); - return z.NEVER; - } - }), + fullAppData: FullAppDataSchema.shape.fullAppData, }); diff --git a/src/domain/swaps/swaps.repository.ts b/src/domain/swaps/swaps.repository.ts index ee4ef0730d..ef062975dc 100644 --- a/src/domain/swaps/swaps.repository.ts +++ b/src/domain/swaps/swaps.repository.ts @@ -1,11 +1,20 @@ import { Inject, Injectable } from '@nestjs/common'; import { ISwapsApiFactory } from '@/domain/interfaces/swaps-api.factory'; import { Order, OrderSchema } from '@/domain/swaps/entities/order.entity'; +import { + FullAppData, + FullAppDataSchema, +} from '@/domain/swaps/entities/full-app-data.entity'; export const ISwapsRepository = Symbol('ISwapsRepository'); export interface ISwapsRepository { getOrder(chainId: string, orderUid: `0x${string}`): Promise; + + getFullAppData( + chainId: string, + appDataHash: `0x${string}`, + ): Promise; } @Injectable() @@ -20,4 +29,13 @@ export class SwapsRepository implements ISwapsRepository { const order = await api.getOrder(orderUid); return OrderSchema.parse(order); } + + async getFullAppData( + chainId: string, + appDataHash: `0x${string}`, + ): Promise { + const api = this.swapsApiFactory.get(chainId); + const fullAppData = await api.getFullAppData(appDataHash); + return FullAppDataSchema.parse(fullAppData); + } } From 4d9b3a136c5fe1d15f50610281fe0cd43656c6ff Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Tue, 18 Jun 2024 12:50:13 +0200 Subject: [PATCH 107/207] Fix abi (#1668) Moves the ABI of the ComposableCoW contract from the `@/abis` folder to where it is used to unblock current tasks. It will later be moved back: - Move ABI from `@/abis/composable-cow` to `ComposableCowDecoder` - Remove rule from `.gitignore` --- .gitignore | 3 +- abis/composable-cow/ComposableCoW.abi.ts | 613 ----------------- .../decoders/composable-cow-decoder.helper.ts | 623 +++++++++++++++++- 3 files changed, 621 insertions(+), 618 deletions(-) delete mode 100644 abis/composable-cow/ComposableCoW.abi.ts diff --git a/.gitignore b/.gitignore index c9fb220daf..f4e31bb6ca 100644 --- a/.gitignore +++ b/.gitignore @@ -50,5 +50,4 @@ lerna-debug.log* data # ABIs -/abis/* -!/abis/composable-cow \ No newline at end of file +/abis/* \ No newline at end of file diff --git a/abis/composable-cow/ComposableCoW.abi.ts b/abis/composable-cow/ComposableCoW.abi.ts deleted file mode 100644 index de90bc4c49..0000000000 --- a/abis/composable-cow/ComposableCoW.abi.ts +++ /dev/null @@ -1,613 +0,0 @@ -/** - * Taken from CoW SDK: - * - * @see https://github.com/cowprotocol/cow-sdk/blob/5aa61a03d2ed9921c5f95522866b2af0ceb1c24d/abi/ComposableCoW.json - */ -export default [ - { - inputs: [ - { - internalType: 'address', - name: '_settlement', - type: 'address', - }, - ], - stateMutability: 'nonpayable', - type: 'constructor', - }, - { - inputs: [], - name: 'InterfaceNotSupported', - type: 'error', - }, - { - inputs: [], - name: 'InvalidHandler', - type: 'error', - }, - { - inputs: [], - name: 'ProofNotAuthed', - type: 'error', - }, - { - inputs: [], - name: 'SingleOrderNotAuthed', - type: 'error', - }, - { - inputs: [], - name: 'SwapGuardRestricted', - type: 'error', - }, - { - anonymous: false, - inputs: [ - { - indexed: true, - internalType: 'address', - name: 'owner', - type: 'address', - }, - { - components: [ - { - internalType: 'contract IConditionalOrder', - name: 'handler', - type: 'address', - }, - { - internalType: 'bytes32', - name: 'salt', - type: 'bytes32', - }, - { - internalType: 'bytes', - name: 'staticInput', - type: 'bytes', - }, - ], - indexed: false, - internalType: 'struct IConditionalOrder.ConditionalOrderParams', - name: 'params', - type: 'tuple', - }, - ], - name: 'ConditionalOrderCreated', - type: 'event', - }, - { - anonymous: false, - inputs: [ - { - indexed: true, - internalType: 'address', - name: 'owner', - type: 'address', - }, - { - indexed: false, - internalType: 'bytes32', - name: 'root', - type: 'bytes32', - }, - { - components: [ - { - internalType: 'uint256', - name: 'location', - type: 'uint256', - }, - { - internalType: 'bytes', - name: 'data', - type: 'bytes', - }, - ], - indexed: false, - internalType: 'struct ComposableCoW.Proof', - name: 'proof', - type: 'tuple', - }, - ], - name: 'MerkleRootSet', - type: 'event', - }, - { - anonymous: false, - inputs: [ - { - indexed: true, - internalType: 'address', - name: 'owner', - type: 'address', - }, - { - indexed: false, - internalType: 'contract ISwapGuard', - name: 'swapGuard', - type: 'address', - }, - ], - name: 'SwapGuardSet', - type: 'event', - }, - { - inputs: [ - { - internalType: 'address', - name: '', - type: 'address', - }, - { - internalType: 'bytes32', - name: '', - type: 'bytes32', - }, - ], - name: 'cabinet', - outputs: [ - { - internalType: 'bytes32', - name: '', - type: 'bytes32', - }, - ], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [ - { - components: [ - { - internalType: 'contract IConditionalOrder', - name: 'handler', - type: 'address', - }, - { - internalType: 'bytes32', - name: 'salt', - type: 'bytes32', - }, - { - internalType: 'bytes', - name: 'staticInput', - type: 'bytes', - }, - ], - internalType: 'struct IConditionalOrder.ConditionalOrderParams', - name: 'params', - type: 'tuple', - }, - { - internalType: 'bool', - name: 'dispatch', - type: 'bool', - }, - ], - name: 'create', - outputs: [], - stateMutability: 'nonpayable', - type: 'function', - }, - { - inputs: [ - { - components: [ - { - internalType: 'contract IConditionalOrder', - name: 'handler', - type: 'address', - }, - { - internalType: 'bytes32', - name: 'salt', - type: 'bytes32', - }, - { - internalType: 'bytes', - name: 'staticInput', - type: 'bytes', - }, - ], - internalType: 'struct IConditionalOrder.ConditionalOrderParams', - name: 'params', - type: 'tuple', - }, - { - internalType: 'contract IValueFactory', - name: 'factory', - type: 'address', - }, - { - internalType: 'bytes', - name: 'data', - type: 'bytes', - }, - { - internalType: 'bool', - name: 'dispatch', - type: 'bool', - }, - ], - name: 'createWithContext', - outputs: [], - stateMutability: 'nonpayable', - type: 'function', - }, - { - inputs: [], - name: 'domainSeparator', - outputs: [ - { - internalType: 'bytes32', - name: '', - type: 'bytes32', - }, - ], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [ - { - internalType: 'address', - name: 'owner', - type: 'address', - }, - { - components: [ - { - internalType: 'contract IConditionalOrder', - name: 'handler', - type: 'address', - }, - { - internalType: 'bytes32', - name: 'salt', - type: 'bytes32', - }, - { - internalType: 'bytes', - name: 'staticInput', - type: 'bytes', - }, - ], - internalType: 'struct IConditionalOrder.ConditionalOrderParams', - name: 'params', - type: 'tuple', - }, - { - internalType: 'bytes', - name: 'offchainInput', - type: 'bytes', - }, - { - internalType: 'bytes32[]', - name: 'proof', - type: 'bytes32[]', - }, - ], - name: 'getTradeableOrderWithSignature', - outputs: [ - { - components: [ - { - internalType: 'contract IERC20', - name: 'sellToken', - type: 'address', - }, - { - internalType: 'contract IERC20', - name: 'buyToken', - type: 'address', - }, - { - internalType: 'address', - name: 'receiver', - type: 'address', - }, - { - internalType: 'uint256', - name: 'sellAmount', - type: 'uint256', - }, - { - internalType: 'uint256', - name: 'buyAmount', - type: 'uint256', - }, - { - internalType: 'uint32', - name: 'validTo', - type: 'uint32', - }, - { - internalType: 'bytes32', - name: 'appData', - type: 'bytes32', - }, - { - internalType: 'uint256', - name: 'feeAmount', - type: 'uint256', - }, - { - internalType: 'bytes32', - name: 'kind', - type: 'bytes32', - }, - { - internalType: 'bool', - name: 'partiallyFillable', - type: 'bool', - }, - { - internalType: 'bytes32', - name: 'sellTokenBalance', - type: 'bytes32', - }, - { - internalType: 'bytes32', - name: 'buyTokenBalance', - type: 'bytes32', - }, - ], - internalType: 'struct GPv2Order.Data', - name: 'order', - type: 'tuple', - }, - { - internalType: 'bytes', - name: 'signature', - type: 'bytes', - }, - ], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [ - { - components: [ - { - internalType: 'contract IConditionalOrder', - name: 'handler', - type: 'address', - }, - { - internalType: 'bytes32', - name: 'salt', - type: 'bytes32', - }, - { - internalType: 'bytes', - name: 'staticInput', - type: 'bytes', - }, - ], - internalType: 'struct IConditionalOrder.ConditionalOrderParams', - name: 'params', - type: 'tuple', - }, - ], - name: 'hash', - outputs: [ - { - internalType: 'bytes32', - name: '', - type: 'bytes32', - }, - ], - stateMutability: 'pure', - type: 'function', - }, - { - inputs: [ - { - internalType: 'contract Safe', - name: 'safe', - type: 'address', - }, - { - internalType: 'address', - name: 'sender', - type: 'address', - }, - { - internalType: 'bytes32', - name: '_hash', - type: 'bytes32', - }, - { - internalType: 'bytes32', - name: '_domainSeparator', - type: 'bytes32', - }, - { - internalType: 'bytes32', - name: '', - type: 'bytes32', - }, - { - internalType: 'bytes', - name: 'encodeData', - type: 'bytes', - }, - { - internalType: 'bytes', - name: 'payload', - type: 'bytes', - }, - ], - name: 'isValidSafeSignature', - outputs: [ - { - internalType: 'bytes4', - name: 'magic', - type: 'bytes4', - }, - ], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [ - { - internalType: 'bytes32', - name: 'singleOrderHash', - type: 'bytes32', - }, - ], - name: 'remove', - outputs: [], - stateMutability: 'nonpayable', - type: 'function', - }, - { - inputs: [ - { - internalType: 'address', - name: '', - type: 'address', - }, - ], - name: 'roots', - outputs: [ - { - internalType: 'bytes32', - name: '', - type: 'bytes32', - }, - ], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [ - { - internalType: 'bytes32', - name: 'root', - type: 'bytes32', - }, - { - components: [ - { - internalType: 'uint256', - name: 'location', - type: 'uint256', - }, - { - internalType: 'bytes', - name: 'data', - type: 'bytes', - }, - ], - internalType: 'struct ComposableCoW.Proof', - name: 'proof', - type: 'tuple', - }, - ], - name: 'setRoot', - outputs: [], - stateMutability: 'nonpayable', - type: 'function', - }, - { - inputs: [ - { - internalType: 'bytes32', - name: 'root', - type: 'bytes32', - }, - { - components: [ - { - internalType: 'uint256', - name: 'location', - type: 'uint256', - }, - { - internalType: 'bytes', - name: 'data', - type: 'bytes', - }, - ], - internalType: 'struct ComposableCoW.Proof', - name: 'proof', - type: 'tuple', - }, - { - internalType: 'contract IValueFactory', - name: 'factory', - type: 'address', - }, - { - internalType: 'bytes', - name: 'data', - type: 'bytes', - }, - ], - name: 'setRootWithContext', - outputs: [], - stateMutability: 'nonpayable', - type: 'function', - }, - { - inputs: [ - { - internalType: 'contract ISwapGuard', - name: 'swapGuard', - type: 'address', - }, - ], - name: 'setSwapGuard', - outputs: [], - stateMutability: 'nonpayable', - type: 'function', - }, - { - inputs: [ - { - internalType: 'address', - name: '', - type: 'address', - }, - { - internalType: 'bytes32', - name: '', - type: 'bytes32', - }, - ], - name: 'singleOrders', - outputs: [ - { - internalType: 'bool', - name: '', - type: 'bool', - }, - ], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [ - { - internalType: 'address', - name: '', - type: 'address', - }, - ], - name: 'swapGuards', - outputs: [ - { - internalType: 'contract ISwapGuard', - name: '', - type: 'address', - }, - ], - stateMutability: 'view', - type: 'function', - }, -] as const; diff --git a/src/domain/swaps/contracts/decoders/composable-cow-decoder.helper.ts b/src/domain/swaps/contracts/decoders/composable-cow-decoder.helper.ts index 43489409fe..0bb3ca3d8f 100644 --- a/src/domain/swaps/contracts/decoders/composable-cow-decoder.helper.ts +++ b/src/domain/swaps/contracts/decoders/composable-cow-decoder.helper.ts @@ -1,8 +1,625 @@ import { AbiDecoder } from '@/domain/contracts/decoders/abi-decoder.helper'; -import ComposableCoW from '@/abis/composable-cow/ComposableCoW.abi'; import { Injectable } from '@nestjs/common'; import { decodeAbiParameters, isAddressEqual, parseAbiParameters } from 'viem'; +/** + * Taken from CoW SDK: + * + * @see https://github.com/cowprotocol/cow-sdk/blob/5aa61a03d2ed9921c5f95522866b2af0ceb1c24d/abi/ComposableCoW.json + * + * TODO: We should locate this in @/abis/... but we will need to refactor the /scripts/generate-abis.js + * to handle ABIs that are present (or alternatively install the @cowprotocol/contracts package and generate + * the ABIs from there) + */ +export const ComposableCowAbi = [ + { + inputs: [ + { + internalType: 'address', + name: '_settlement', + type: 'address', + }, + ], + stateMutability: 'nonpayable', + type: 'constructor', + }, + { + inputs: [], + name: 'InterfaceNotSupported', + type: 'error', + }, + { + inputs: [], + name: 'InvalidHandler', + type: 'error', + }, + { + inputs: [], + name: 'ProofNotAuthed', + type: 'error', + }, + { + inputs: [], + name: 'SingleOrderNotAuthed', + type: 'error', + }, + { + inputs: [], + name: 'SwapGuardRestricted', + type: 'error', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + components: [ + { + internalType: 'contract IConditionalOrder', + name: 'handler', + type: 'address', + }, + { + internalType: 'bytes32', + name: 'salt', + type: 'bytes32', + }, + { + internalType: 'bytes', + name: 'staticInput', + type: 'bytes', + }, + ], + indexed: false, + internalType: 'struct IConditionalOrder.ConditionalOrderParams', + name: 'params', + type: 'tuple', + }, + ], + name: 'ConditionalOrderCreated', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + indexed: false, + internalType: 'bytes32', + name: 'root', + type: 'bytes32', + }, + { + components: [ + { + internalType: 'uint256', + name: 'location', + type: 'uint256', + }, + { + internalType: 'bytes', + name: 'data', + type: 'bytes', + }, + ], + indexed: false, + internalType: 'struct ComposableCoW.Proof', + name: 'proof', + type: 'tuple', + }, + ], + name: 'MerkleRootSet', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + indexed: false, + internalType: 'contract ISwapGuard', + name: 'swapGuard', + type: 'address', + }, + ], + name: 'SwapGuardSet', + type: 'event', + }, + { + inputs: [ + { + internalType: 'address', + name: '', + type: 'address', + }, + { + internalType: 'bytes32', + name: '', + type: 'bytes32', + }, + ], + name: 'cabinet', + outputs: [ + { + internalType: 'bytes32', + name: '', + type: 'bytes32', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + components: [ + { + internalType: 'contract IConditionalOrder', + name: 'handler', + type: 'address', + }, + { + internalType: 'bytes32', + name: 'salt', + type: 'bytes32', + }, + { + internalType: 'bytes', + name: 'staticInput', + type: 'bytes', + }, + ], + internalType: 'struct IConditionalOrder.ConditionalOrderParams', + name: 'params', + type: 'tuple', + }, + { + internalType: 'bool', + name: 'dispatch', + type: 'bool', + }, + ], + name: 'create', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + components: [ + { + internalType: 'contract IConditionalOrder', + name: 'handler', + type: 'address', + }, + { + internalType: 'bytes32', + name: 'salt', + type: 'bytes32', + }, + { + internalType: 'bytes', + name: 'staticInput', + type: 'bytes', + }, + ], + internalType: 'struct IConditionalOrder.ConditionalOrderParams', + name: 'params', + type: 'tuple', + }, + { + internalType: 'contract IValueFactory', + name: 'factory', + type: 'address', + }, + { + internalType: 'bytes', + name: 'data', + type: 'bytes', + }, + { + internalType: 'bool', + name: 'dispatch', + type: 'bool', + }, + ], + name: 'createWithContext', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'domainSeparator', + outputs: [ + { + internalType: 'bytes32', + name: '', + type: 'bytes32', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + components: [ + { + internalType: 'contract IConditionalOrder', + name: 'handler', + type: 'address', + }, + { + internalType: 'bytes32', + name: 'salt', + type: 'bytes32', + }, + { + internalType: 'bytes', + name: 'staticInput', + type: 'bytes', + }, + ], + internalType: 'struct IConditionalOrder.ConditionalOrderParams', + name: 'params', + type: 'tuple', + }, + { + internalType: 'bytes', + name: 'offchainInput', + type: 'bytes', + }, + { + internalType: 'bytes32[]', + name: 'proof', + type: 'bytes32[]', + }, + ], + name: 'getTradeableOrderWithSignature', + outputs: [ + { + components: [ + { + internalType: 'contract IERC20', + name: 'sellToken', + type: 'address', + }, + { + internalType: 'contract IERC20', + name: 'buyToken', + type: 'address', + }, + { + internalType: 'address', + name: 'receiver', + type: 'address', + }, + { + internalType: 'uint256', + name: 'sellAmount', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'buyAmount', + type: 'uint256', + }, + { + internalType: 'uint32', + name: 'validTo', + type: 'uint32', + }, + { + internalType: 'bytes32', + name: 'appData', + type: 'bytes32', + }, + { + internalType: 'uint256', + name: 'feeAmount', + type: 'uint256', + }, + { + internalType: 'bytes32', + name: 'kind', + type: 'bytes32', + }, + { + internalType: 'bool', + name: 'partiallyFillable', + type: 'bool', + }, + { + internalType: 'bytes32', + name: 'sellTokenBalance', + type: 'bytes32', + }, + { + internalType: 'bytes32', + name: 'buyTokenBalance', + type: 'bytes32', + }, + ], + internalType: 'struct GPv2Order.Data', + name: 'order', + type: 'tuple', + }, + { + internalType: 'bytes', + name: 'signature', + type: 'bytes', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + components: [ + { + internalType: 'contract IConditionalOrder', + name: 'handler', + type: 'address', + }, + { + internalType: 'bytes32', + name: 'salt', + type: 'bytes32', + }, + { + internalType: 'bytes', + name: 'staticInput', + type: 'bytes', + }, + ], + internalType: 'struct IConditionalOrder.ConditionalOrderParams', + name: 'params', + type: 'tuple', + }, + ], + name: 'hash', + outputs: [ + { + internalType: 'bytes32', + name: '', + type: 'bytes32', + }, + ], + stateMutability: 'pure', + type: 'function', + }, + { + inputs: [ + { + internalType: 'contract Safe', + name: 'safe', + type: 'address', + }, + { + internalType: 'address', + name: 'sender', + type: 'address', + }, + { + internalType: 'bytes32', + name: '_hash', + type: 'bytes32', + }, + { + internalType: 'bytes32', + name: '_domainSeparator', + type: 'bytes32', + }, + { + internalType: 'bytes32', + name: '', + type: 'bytes32', + }, + { + internalType: 'bytes', + name: 'encodeData', + type: 'bytes', + }, + { + internalType: 'bytes', + name: 'payload', + type: 'bytes', + }, + ], + name: 'isValidSafeSignature', + outputs: [ + { + internalType: 'bytes4', + name: 'magic', + type: 'bytes4', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'bytes32', + name: 'singleOrderHash', + type: 'bytes32', + }, + ], + name: 'remove', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: '', + type: 'address', + }, + ], + name: 'roots', + outputs: [ + { + internalType: 'bytes32', + name: '', + type: 'bytes32', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'bytes32', + name: 'root', + type: 'bytes32', + }, + { + components: [ + { + internalType: 'uint256', + name: 'location', + type: 'uint256', + }, + { + internalType: 'bytes', + name: 'data', + type: 'bytes', + }, + ], + internalType: 'struct ComposableCoW.Proof', + name: 'proof', + type: 'tuple', + }, + ], + name: 'setRoot', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'bytes32', + name: 'root', + type: 'bytes32', + }, + { + components: [ + { + internalType: 'uint256', + name: 'location', + type: 'uint256', + }, + { + internalType: 'bytes', + name: 'data', + type: 'bytes', + }, + ], + internalType: 'struct ComposableCoW.Proof', + name: 'proof', + type: 'tuple', + }, + { + internalType: 'contract IValueFactory', + name: 'factory', + type: 'address', + }, + { + internalType: 'bytes', + name: 'data', + type: 'bytes', + }, + ], + name: 'setRootWithContext', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'contract ISwapGuard', + name: 'swapGuard', + type: 'address', + }, + ], + name: 'setSwapGuard', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: '', + type: 'address', + }, + { + internalType: 'bytes32', + name: '', + type: 'bytes32', + }, + ], + name: 'singleOrders', + outputs: [ + { + internalType: 'bool', + name: '', + type: 'bool', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: '', + type: 'address', + }, + ], + name: 'swapGuards', + outputs: [ + { + internalType: 'contract ISwapGuard', + name: '', + type: 'address', + }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const; + /** * Decoder for ComposableCow contract which focuses on decoding TWAP (`createWithContext`) orders * @@ -10,7 +627,7 @@ import { decodeAbiParameters, isAddressEqual, parseAbiParameters } from 'viem'; * @see https://github.com/cowprotocol/cow-sdk/blob/5aa61a03d2ed9921c5f95522866b2af0ceb1c24d/src/composable/orderTypes/Twap.ts */ @Injectable() -export class ComposableCowDecoder extends AbiDecoder { +export class ComposableCowDecoder extends AbiDecoder { // Address of the TWAP handler contract private static readonly TwapHandlerAddress = '0x6cF1e9cA41f7611dEf408122793c358a3d11E5a5'; @@ -21,7 +638,7 @@ export class ComposableCowDecoder extends AbiDecoder { ); constructor() { - super(ComposableCoW); + super(ComposableCowAbi); } /** From 5a04f38da3ac39427e3140bd22b8aa724f3d453a Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Wed, 19 Jun 2024 10:25:17 +0200 Subject: [PATCH 108/207] Map TWAP (`createWithContext` call) orders (#1669) This adds TWAP-specific decoding of `createWithContext` transactions behind a feature flag: - Add `features.twapsDecoding` feature flag - Extract re-used values from `Order` entity to relative enums - Add `TwapOrderInfo` entity - Add `TransactionInfoType.TwapOrder` type - Add test-covered `GPv2OrderHelper` for decoding order UIDs (based on CoW Protocol/SDK - linked in code) - Add test-covered `TwapOrderHelper` for finding/generating parts of TWAP orders (based on CoW Swap app - linked in code) - Add test-covered `TwapOrderMapper` for mapping TWAP orders --- .../entities/__tests__/configuration.ts | 1 + src/config/entities/configuration.ts | 1 + .../decoders/composable-cow-decoder.helper.ts | 4 + .../swaps/entities/__tests__/order.builder.ts | 16 +- src/domain/swaps/entities/order.entity.ts | 27 ++- .../entities/swaps/twap-order-info.entity.ts | 191 +++++++++++++++ .../entities/transaction-info.entity.ts | 1 + .../helpers/gp-v2-order.helper.spec.ts | 82 +++++++ .../helpers/gp-v2-order.helper.ts | 119 +++++++++ .../helpers/swap-order.helper.spec.ts | 4 +- .../transactions/helpers/swap-order.helper.ts | 12 +- .../helpers/twap-order.helper.spec.ts | 124 ++++++++++ .../transactions/helpers/twap-order.helper.ts | 128 ++++++++++ .../mappers/common/transaction-info.mapper.ts | 57 +++++ .../mappers/common/twap-order.mapper.spec.ts | 228 ++++++++++++++++++ .../mappers/common/twap-order.mapper.ts | 195 +++++++++++++++ .../transactions/transactions.module.ts | 4 + 17 files changed, 1178 insertions(+), 16 deletions(-) create mode 100644 src/routes/transactions/entities/swaps/twap-order-info.entity.ts create mode 100644 src/routes/transactions/helpers/gp-v2-order.helper.spec.ts create mode 100644 src/routes/transactions/helpers/gp-v2-order.helper.ts create mode 100644 src/routes/transactions/helpers/twap-order.helper.spec.ts create mode 100644 src/routes/transactions/helpers/twap-order.helper.ts create mode 100644 src/routes/transactions/mappers/common/twap-order.mapper.spec.ts create mode 100644 src/routes/transactions/mappers/common/twap-order.mapper.ts diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index 1055c7c4b6..8a3fdf61b8 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -109,6 +109,7 @@ export default (): ReturnType => ({ email: false, zerionBalancesChainIds: ['137'], swapsDecoding: true, + twapsDecoding: true, historyDebugLogs: false, imitationMapping: false, auth: false, diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index ee8c4b5a29..b855eaa0f9 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -163,6 +163,7 @@ export default () => ({ zerionBalancesChainIds: process.env.FF_ZERION_BALANCES_CHAIN_IDS?.split(',') ?? [], swapsDecoding: process.env.FF_SWAPS_DECODING?.toLowerCase() === 'true', + twapsDecoding: process.env.FF_TWAPS_DECODING?.toLowerCase() === 'true', historyDebugLogs: process.env.FF_HISTORY_DEBUG_LOGS?.toLowerCase() === 'true', imitationMapping: diff --git a/src/domain/swaps/contracts/decoders/composable-cow-decoder.helper.ts b/src/domain/swaps/contracts/decoders/composable-cow-decoder.helper.ts index 0bb3ca3d8f..85a2be8d36 100644 --- a/src/domain/swaps/contracts/decoders/composable-cow-decoder.helper.ts +++ b/src/domain/swaps/contracts/decoders/composable-cow-decoder.helper.ts @@ -672,6 +672,10 @@ export class ComposableCowDecoder extends AbiDecoder { // Use inferred return type // eslint-disable-next-line @typescript-eslint/explicit-function-return-type private decodeCreateWithContext(data: `0x${string}`) { + if (!this.helpers.isCreateWithContext(data)) { + return null; + } + try { const decoded = this.decodeFunctionData({ data, diff --git a/src/domain/swaps/entities/__tests__/order.builder.ts b/src/domain/swaps/entities/__tests__/order.builder.ts index 5cfa8756de..c88fcdac66 100644 --- a/src/domain/swaps/entities/__tests__/order.builder.ts +++ b/src/domain/swaps/entities/__tests__/order.builder.ts @@ -1,7 +1,10 @@ import { + BuyTokenBalance, Order, OrderClass, + OrderKind, OrderStatus, + SellTokenBalance, } from '@/domain/swaps/entities/order.entity'; import { Builder, IBuilder } from '@/__tests__/builder'; import { faker } from '@faker-js/faker'; @@ -28,15 +31,22 @@ export function orderBuilder(): IBuilder { }), ) .with('feeAmount', faker.number.bigInt({ min: 1 })) - .with('kind', faker.helpers.arrayElement(['buy', 'sell'] as const)) + .with('kind', faker.helpers.arrayElement([OrderKind.Buy, OrderKind.Buy])) .with('partiallyFillable', faker.datatype.boolean()) .with( 'sellTokenBalance', - faker.helpers.arrayElement(['erc20', 'internal', 'external'] as const), + faker.helpers.arrayElement([ + SellTokenBalance.Erc20, + SellTokenBalance.Internal, + SellTokenBalance.External, + ]), ) .with( 'buyTokenBalance', - faker.helpers.arrayElement(['erc20', 'internal'] as const), + faker.helpers.arrayElement([ + BuyTokenBalance.Erc20, + BuyTokenBalance.Internal, + ]), ) .with( 'signingScheme', diff --git a/src/domain/swaps/entities/order.entity.ts b/src/domain/swaps/entities/order.entity.ts index bf450761a8..8f5ce71f26 100644 --- a/src/domain/swaps/entities/order.entity.ts +++ b/src/domain/swaps/entities/order.entity.ts @@ -21,6 +21,25 @@ export enum OrderClass { Unknown = 'unknown', } +export enum OrderKind { + Buy = 'buy', + Sell = 'sell', + Unknown = 'unknown', +} + +export enum SellTokenBalance { + Erc20 = 'erc20', + Internal = 'internal', + External = 'external', + Unknown = 'unknown', +} + +export enum BuyTokenBalance { + Erc20 = 'erc20', + Internal = 'internal', + Unknown = 'unknown', +} + export const OrderSchema = z.object({ sellToken: AddressSchema, buyToken: AddressSchema, @@ -30,12 +49,12 @@ export const OrderSchema = z.object({ validTo: z.number(), appData: z.string(), feeAmount: z.coerce.bigint(), - kind: z.enum(['buy', 'sell', 'unknown']).catch('unknown'), + kind: z.nativeEnum(OrderKind).catch(OrderKind.Unknown), partiallyFillable: z.boolean(), sellTokenBalance: z - .enum(['erc20', 'internal', 'external', 'unknown']) - .catch('unknown'), - buyTokenBalance: z.enum(['erc20', 'internal', 'unknown']).catch('unknown'), + .nativeEnum(SellTokenBalance) + .catch(SellTokenBalance.Unknown), + buyTokenBalance: z.nativeEnum(BuyTokenBalance).catch(BuyTokenBalance.Unknown), signingScheme: z .enum(['eip712', 'ethsign', 'presign', 'eip1271', 'unknown']) .catch('unknown'), diff --git a/src/routes/transactions/entities/swaps/twap-order-info.entity.ts b/src/routes/transactions/entities/swaps/twap-order-info.entity.ts new file mode 100644 index 0000000000..6e8e30cdf6 --- /dev/null +++ b/src/routes/transactions/entities/swaps/twap-order-info.entity.ts @@ -0,0 +1,191 @@ +import { + OrderStatus, + OrderKind, + OrderClass, +} from '@/domain/swaps/entities/order.entity'; +import { TokenInfo } from '@/routes/transactions/entities/swaps/token-info.entity'; +import { + TransactionInfo, + TransactionInfoType, +} from '@/routes/transactions/entities/transaction-info.entity'; +import { + ApiExtraModels, + ApiProperty, + ApiPropertyOptional, +} from '@nestjs/swagger'; + +export enum DurationType { + Auto = 'AUTO', + LimitDuration = 'LIMIT_DURATION', +} + +export enum StartTimeValue { + AtMiningTime = 'AT_MINING_TIME', + AtEpoch = 'AT_EPOCH', +} + +type DurationOfPart = + | { durationType: DurationType.Auto } + | { durationType: DurationType.LimitDuration; duration: number }; + +type StartTime = + | { startType: StartTimeValue.AtMiningTime } + | { startType: StartTimeValue.AtEpoch; epoch: number }; + +export type TwapOrderInfo = { + orderStatus: OrderStatus; + kind: OrderKind.Sell; + class: OrderClass.Limit; + validUntil: number; + sellAmount: string; + buyAmount: string; + executedSellAmount: string; + executedBuyAmount: string; + sellToken: TokenInfo; + buyToken: TokenInfo; + receiver: `0x${string}`; + owner: `0x${string}`; + numberOfParts: number; + partSellAmount: string; + minPartLimit: string; + timeBetweenParts: string; + durationOfPart: DurationOfPart; + startTime: StartTime; +}; + +@ApiExtraModels(TokenInfo) +export class TwapOrderTransactionInfo + extends TransactionInfo + implements TwapOrderInfo +{ + @ApiProperty({ enum: [TransactionInfoType.TwapOrder] }) + override type = TransactionInfoType.TwapOrder; + + @ApiProperty({ description: 'The TWAP status' }) + orderStatus: OrderStatus; + + @ApiProperty({ enum: OrderKind }) + kind: OrderKind.Sell; + + @ApiProperty({ enum: OrderClass }) + class: OrderClass.Limit; + + @ApiProperty({ description: 'The timestamp when the TWAP expires' }) + validUntil: number; + + @ApiProperty({ + description: 'The sell token raw amount (no decimals)', + }) + sellAmount: string; + + @ApiProperty({ + description: 'The buy token raw amount (no decimals)', + }) + buyAmount: string; + + @ApiProperty({ + description: 'The executed sell token raw amount (no decimals)', + }) + executedSellAmount: string; + + @ApiProperty({ + description: 'The executed buy token raw amount (no decimals)', + }) + executedBuyAmount: string; + + @ApiProperty({ description: 'The sell token of the TWAP' }) + sellToken: TokenInfo; + + @ApiProperty({ description: 'The buy token of the TWAP' }) + buyToken: TokenInfo; + + @ApiProperty({ + description: 'The address to receive the proceeds of the trade', + }) + receiver: `0x${string}`; + + @ApiProperty({ + type: String, + }) + owner: `0x${string}`; + + @ApiPropertyOptional({ + type: Object, + nullable: true, + description: 'The App Data for this TWAP', + }) + fullAppData: Record | null; + + @ApiProperty({ + description: 'The number of parts in the TWAP', + }) + numberOfParts: number; + + @ApiProperty({ + description: 'The amount of sellToken to sell in each part', + }) + partSellAmount: string; + + @ApiProperty({ + description: 'The amount of buyToken that must be bought in each part', + }) + minPartLimit: string; + + @ApiProperty({ + description: 'The duration of the TWAP interval', + }) + timeBetweenParts: string; + + @ApiProperty({ + description: 'Whether the TWAP is valid for the entire interval or not', + }) + durationOfPart: DurationOfPart; + + @ApiProperty({ + description: 'The start time of the TWAP', + }) + startTime: StartTime; + + constructor(args: { + orderStatus: OrderStatus; + kind: OrderKind.Sell; + class: OrderClass.Limit; + validUntil: number; + sellAmount: string; + buyAmount: string; + executedSellAmount: string; + executedBuyAmount: string; + sellToken: TokenInfo; + buyToken: TokenInfo; + receiver: `0x${string}`; + owner: `0x${string}`; + fullAppData: Record | null; + numberOfParts: number; + partSellAmount: string; + minPartLimit: string; + timeBetweenParts: string; + durationOfPart: DurationOfPart; + startTime: StartTime; + }) { + super(TransactionInfoType.SwapOrder, null, null); + this.orderStatus = args.orderStatus; + this.kind = args.kind; + this.class = args.class; + this.validUntil = args.validUntil; + this.sellAmount = args.sellAmount; + this.buyAmount = args.buyAmount; + this.executedSellAmount = args.executedSellAmount; + this.executedBuyAmount = args.executedBuyAmount; + this.sellToken = args.sellToken; + this.buyToken = args.buyToken; + this.receiver = args.receiver; + this.owner = args.owner; + this.fullAppData = args.fullAppData; + this.numberOfParts = args.numberOfParts; + this.partSellAmount = args.partSellAmount; + this.minPartLimit = args.minPartLimit; + this.timeBetweenParts = args.timeBetweenParts; + this.durationOfPart = args.durationOfPart; + this.startTime = args.startTime; + } +} diff --git a/src/routes/transactions/entities/transaction-info.entity.ts b/src/routes/transactions/entities/transaction-info.entity.ts index 6172e73730..85bc3365cb 100644 --- a/src/routes/transactions/entities/transaction-info.entity.ts +++ b/src/routes/transactions/entities/transaction-info.entity.ts @@ -7,6 +7,7 @@ export enum TransactionInfoType { SettingsChange = 'SettingsChange', Transfer = 'Transfer', SwapOrder = 'SwapOrder', + TwapOrder = 'TwapOrder', } export class TransactionInfo { diff --git a/src/routes/transactions/helpers/gp-v2-order.helper.spec.ts b/src/routes/transactions/helpers/gp-v2-order.helper.spec.ts new file mode 100644 index 0000000000..89a28c18bb --- /dev/null +++ b/src/routes/transactions/helpers/gp-v2-order.helper.spec.ts @@ -0,0 +1,82 @@ +import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; +import { ComposableCowDecoder } from '@/domain/swaps/contracts/decoders/composable-cow-decoder.helper'; +import { GPv2OrderHelper } from '@/routes/transactions/helpers/gp-v2-order.helper'; +import { TwapOrderHelper } from '@/routes/transactions/helpers/twap-order.helper'; +import { zeroAddress } from 'viem'; + +describe('GPv2OrderHelper', () => { + const target = new GPv2OrderHelper(); + + const multiSendDecoder = new MultiSendDecoder(); + const composableCowDecoder = new ComposableCowDecoder(); + const twapOrderHelper = new TwapOrderHelper( + multiSendDecoder, + composableCowDecoder, + ); + + describe('computeOrderUid', () => { + it('should decode the order UIDs of a direct createWithContextCall', () => { + /** + * @see https://sepolia.etherscan.io/address/0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74 + */ + const chainId = '11155111'; + const owner = '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381'; + const data = + '0x0d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011f294a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000'; + const executionDate = new Date(1718288040000); + const twapStruct = composableCowDecoder.decodeTwapStruct(data); + const parts = twapOrderHelper.generateTwapOrderParts({ + twapStruct, + executionDate, + chainId, + }); + + const orderUids = parts.map((order) => + target.computeOrderUid({ chainId, owner, order }), + ); + + expect(orderUids).toStrictEqual([ + '0xdaabe82f86545c66074b5565962e96758979ae80124aabef05e0585149d30f7931eac7f0141837b266de30f4dc9af15629bd5381666b05af', + '0x557cb31a9dbbd23830c57d9fd3bbfc3694e942c161232b6cf696ba3bd11f9d6631eac7f0141837b266de30f4dc9af15629bd5381666b0cb7', + ]); + }); + + it('should decode the order UIDs of a createWithContextCall in multiSend', () => { + /** + * `createWithContext` call is third transaction in batch + * @see https://sepolia.etherscan.io/address/0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B + */ + const chainId = '11155111'; + const owner = '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381'; + const data = + '0x8d80ff0a000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000003cb0031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000024f08a03230000000000000000000000002f55e8b20d0b9fefa187aa7d00b6cbe563605bf50031eac7f0141837b266de30f4dc9af15629bd5381000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000443365582cdaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230b000000000000000000000000fdafc9d1902f4e0b84f65f49f244b32b31013b7400fdafc9d1902f4e0b84f65f49f244b32b31013b74000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002640d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011918e600000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb00000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000003782dace9d90000000000000000000000000000000000000000000000000003b1b5fbf83bf2f7160000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000003840000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'; + const executionDate = new Date(1718281752000); + const orderData = twapOrderHelper.findTwapOrder({ + // MultiSend decoder does not check validity of ComposableCow contract + // (it recursively decodes nested transactions so uses ComposableCow from batch) + to: zeroAddress, + data, + }); + if (!orderData) { + throw new Error('Unable to find TWAP order'); + } + const twapStruct = composableCowDecoder.decodeTwapStruct(orderData); + const parts = twapOrderHelper.generateTwapOrderParts({ + twapStruct, + executionDate, + chainId, + }); + + const orderUids = parts.map((order) => { + return target.computeOrderUid({ chainId, owner, order }); + }); + + expect(orderUids).toStrictEqual([ + '0x97ad7eb9e1dd457df8b43dfd875b69dd53bd7bdc8148d48393b21b412870d85d31eac7f0141837b266de30f4dc9af15629bd5381666ae99b', + '0xd615b06c65531e20667a7a33c7068417953cda646cc6220089f4f331c3f5029a31eac7f0141837b266de30f4dc9af15629bd5381666aed1f', + '0xc3695c9d73a4127223a0fbd96538ce6f22121f8c869259e974a519080a91651931eac7f0141837b266de30f4dc9af15629bd5381666af0a3', + '0x1a296dbc504c1b3385c1510d70e519ff95804a7da5caa8d3fc2920e05be4a52731eac7f0141837b266de30f4dc9af15629bd5381666af427', + ]); + }); + }); +}); diff --git a/src/routes/transactions/helpers/gp-v2-order.helper.ts b/src/routes/transactions/helpers/gp-v2-order.helper.ts new file mode 100644 index 0000000000..bfed845a2e --- /dev/null +++ b/src/routes/transactions/helpers/gp-v2-order.helper.ts @@ -0,0 +1,119 @@ +import { + BuyTokenBalance, + OrderKind, + SellTokenBalance, +} from '@/domain/swaps/entities/order.entity'; +import { Injectable } from '@nestjs/common'; +import { TypedDataDomain, encodePacked, hashTypedData } from 'viem'; + +/** + * We can use generics to infer this from TwapOrderHelper.GPv2OrderTypeFields + * but for readability we manually define it. + * + * Functions that infer this type (`hashOrder`) will still return type errors + * if there is a mismatch between the inferred type and this. + */ +export type GPv2OrderParameters = { + sellToken: `0x${string}`; + buyToken: `0x${string}`; + receiver: `0x${string}`; + sellAmount: bigint; + buyAmount: bigint; + validTo: number; + appData: `0x${string}`; + feeAmount: bigint; + kind: OrderKind; + partiallyFillable: boolean; + sellTokenBalance: SellTokenBalance; + buyTokenBalance: BuyTokenBalance; +}; + +@Injectable() +export class GPv2OrderHelper { + // Domain + private static readonly DomainName = 'Gnosis Protocol' as const; + private static readonly DomainVersion = 'v2' as const; + private static readonly DomainVerifyingContract = + '0x9008D19f58AAbD9eD0D60971565AA8510560ab41' as const; + + // Typed data + private static readonly TypedDataPrimaryType = 'Order' as const; + private static readonly TypedDataTypes = { + [GPv2OrderHelper.TypedDataPrimaryType]: [ + { name: 'sellToken', type: 'address' }, + { name: 'buyToken', type: 'address' }, + { name: 'receiver', type: 'address' }, + { name: 'sellAmount', type: 'uint256' }, + { name: 'buyAmount', type: 'uint256' }, + { name: 'validTo', type: 'uint32' }, + { name: 'appData', type: 'bytes32' }, + { name: 'feeAmount', type: 'uint256' }, + { name: 'kind', type: 'string' }, + { name: 'partiallyFillable', type: 'bool' }, + { name: 'sellTokenBalance', type: 'string' }, + { name: 'buyTokenBalance', type: 'string' }, + ], + } as const; + + /** + * Computes the order UID from the given order parameters + * + * @param args.chainId - chain order is on + * @param args.owner - owner of the order + * @param args.order - order parameters + * @returns order UID + * + * Implementation taken from CoW Protocol + * @see https://github.com/cowprotocol/contracts/blob/1465e69f6935b3ef9ce45d4878e44f0335ef8531/src/ts/order.ts#L311 + */ + public computeOrderUid(args: { + chainId: string; + owner: `0x${string}`; + order: GPv2OrderParameters; + }): `0x${string}` { + return encodePacked( + ['bytes32', 'address', 'uint32'], + [this.hashOrder(args), args.owner, args.order.validTo], + ); + } + + /** + * Computes the 32-byte signing hash of an order + * + * @param args.chain - chain order is on + * @param args.order - order parameters + * @returns order hash + * + * Implementation taken from CoW Protocol + * @see https://github.com/cowprotocol/contracts/blob/1465e69f6935b3ef9ce45d4878e44f0335ef8531/src/ts/order.ts#L277 + */ + private hashOrder(args: { + chainId: string; + order: GPv2OrderParameters; + }): `0x${string}` { + return hashTypedData({ + domain: this.getDomain(args.chainId), + primaryType: GPv2OrderHelper.TypedDataPrimaryType, + types: GPv2OrderHelper.TypedDataTypes, + message: args.order, + }); + } + + /** + * Returns the EIP-712 domain for the given chain + * + * @param chainId - chain ID to be used + * @returns EIP-712 typed domain data + * + * Implementation taken from CoW SDL + * @see https://github.com/cowprotocol/cow-sdk/blob/5aa61a03d2ed9921c5f95522866b2af0ceb1c24d/src/order-signing/orderSigningUtils.ts#L98 + */ + private getDomain(chainId: string): TypedDataDomain { + return { + name: GPv2OrderHelper.DomainName, + version: GPv2OrderHelper.DomainVersion, + chainId: Number(chainId), + verifyingContract: GPv2OrderHelper.DomainVerifyingContract, + }; + } +} diff --git a/src/routes/transactions/helpers/swap-order.helper.spec.ts b/src/routes/transactions/helpers/swap-order.helper.spec.ts index b5d853f9e5..6ac9d7c84d 100644 --- a/src/routes/transactions/helpers/swap-order.helper.spec.ts +++ b/src/routes/transactions/helpers/swap-order.helper.spec.ts @@ -6,7 +6,7 @@ import { orderBuilder } from '@/domain/swaps/entities/__tests__/order.builder'; import { tokenBuilder } from '@/domain/tokens/__tests__/token.builder'; import { getAddress } from 'viem'; import { IConfigurationService } from '@/config/configuration.service.interface'; -import { OrderStatus } from '@/domain/swaps/entities/order.entity'; +import { OrderKind, OrderStatus } from '@/domain/swaps/entities/order.entity'; import { SwapOrderHelper } from '@/routes/transactions/helpers/swap-order.helper'; import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; import { IChainsRepository } from '@/domain/chains/chains.repository.interface'; @@ -204,7 +204,7 @@ describe('Swap Order Helper tests', () => { .with('status', status) .with('buyToken', getAddress(buyToken.address)) .with('sellToken', getAddress(sellToken.address)) - .with('kind', 'unknown') + .with('kind', OrderKind.Unknown) .build(); swapsRepositoryMock.getOrder.mockResolvedValue(order); tokenRepositoryMock.getToken.mockImplementation(({ address }) => { diff --git a/src/routes/transactions/helpers/swap-order.helper.ts b/src/routes/transactions/helpers/swap-order.helper.ts index ec0a5fd386..774e7eedbd 100644 --- a/src/routes/transactions/helpers/swap-order.helper.ts +++ b/src/routes/transactions/helpers/swap-order.helper.ts @@ -5,12 +5,9 @@ import { ITokenRepository, TokenRepositoryModule, } from '@/domain/tokens/token.repository.interface'; -import { - ISwapsRepository, - SwapsRepository, -} from '@/domain/swaps/swaps.repository'; +import { ISwapsRepository } from '@/domain/swaps/swaps.repository'; import { Token, TokenType } from '@/domain/tokens/entities/token.entity'; -import { Order } from '@/domain/swaps/entities/order.entity'; +import { Order, OrderKind } from '@/domain/swaps/entities/order.entity'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { SwapsRepositoryModule } from '@/domain/swaps/swaps-repository.module'; import { @@ -36,7 +33,8 @@ export class SwapOrderHelper { private readonly setPreSignatureDecoder: SetPreSignatureDecoder, @Inject(ITokenRepository) private readonly tokenRepository: ITokenRepository, - @Inject(ISwapsRepository) private readonly swapsRepository: SwapsRepository, + @Inject(ISwapsRepository) + private readonly swapsRepository: ISwapsRepository, @Inject(IConfigurationService) private readonly configurationService: IConfigurationService, @Inject('SWAP_ALLOWED_APPS') private readonly allowedApps: Set, @@ -97,7 +95,7 @@ export class SwapOrderHelper { args.orderUid, ); - if (order.kind === 'unknown') throw new Error('Unknown order kind'); + if (order.kind === OrderKind.Unknown) throw new Error('Unknown order kind'); const [buyToken, sellToken] = await Promise.all([ this.getToken({ diff --git a/src/routes/transactions/helpers/twap-order.helper.spec.ts b/src/routes/transactions/helpers/twap-order.helper.spec.ts new file mode 100644 index 0000000000..08acacdfda --- /dev/null +++ b/src/routes/transactions/helpers/twap-order.helper.spec.ts @@ -0,0 +1,124 @@ +import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; +import { ComposableCowDecoder } from '@/domain/swaps/contracts/decoders/composable-cow-decoder.helper'; +import { TwapOrderHelper } from '@/routes/transactions/helpers/twap-order.helper'; +import { faker } from '@faker-js/faker'; +import { zeroAddress } from 'viem'; +describe('TwapOrderHelper', () => { + const ComposableCowAddress = '0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74'; + + /** + * @see https://sepolia.etherscan.io/address/0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74 + */ + const directCalldata = + '0x0d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011f294a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000'; + /** + * `createWithContext` call is third transaction in batch + * @see https://sepolia.etherscan.io/address/0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B + */ + const batchedCalldata = + '0x8d80ff0a000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000003cb0031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000024f08a03230000000000000000000000002f55e8b20d0b9fefa187aa7d00b6cbe563605bf50031eac7f0141837b266de30f4dc9af15629bd5381000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000443365582cdaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230b000000000000000000000000fdafc9d1902f4e0b84f65f49f244b32b31013b7400fdafc9d1902f4e0b84f65f49f244b32b31013b74000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002640d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011918e600000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb00000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000003782dace9d90000000000000000000000000000000000000000000000000003b1b5fbf83bf2f7160000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000003840000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'; + + const multiSendDecoder = new MultiSendDecoder(); + const composableCowDecoder = new ComposableCowDecoder(); + const target = new TwapOrderHelper(multiSendDecoder, composableCowDecoder); + + describe('findTwapOrder', () => { + describe('direct createWithContext call', () => { + it('should find order to the official ComposableCoW contract', () => { + const result = target.findTwapOrder({ + to: ComposableCowAddress, + data: directCalldata, + }); + + expect(result).toStrictEqual(directCalldata); + }); + + it('should not find order to an unofficial ComposableCoW contract', () => { + const result = target.findTwapOrder({ + to: zeroAddress, + data: directCalldata, + }); + + expect(result).toBe(null); + }); + }); + + describe('batched createWithContext call', () => { + it('should find order to the official ComposableCoW contract', () => { + const result = target.findTwapOrder({ + to: ComposableCowAddress, + data: batchedCalldata, + }); + + expect(result).toStrictEqual( + // Thirs transaction in batch + '0x0d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011918e600000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb00000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000003782dace9d90000000000000000000000000000000000000000000000000003b1b5fbf83bf2f7160000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000003840000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000', + ); + }); + + // TODO: Encode a batched call with a transaction to an unofficial ComposableCoW contract + it.skip('should not find order to an unofficial ComposableCoW contract', () => { + const result = target.findTwapOrder({ + to: zeroAddress, // MultiSend decoder does not check officiality of address + data: batchedCalldata, + }); + + expect(result).toBe(null); + }); + }); + + describe('generateTwapOrderParts', () => { + it('should generate TWAP order parts', () => { + const twapStruct = + composableCowDecoder.decodeTwapStruct(directCalldata); + const executionDate = faker.date.past(); + const chainId = faker.string.numeric(); + + const result = target.generateTwapOrderParts({ + twapStruct, + executionDate, + chainId, + }); + + expect(result).toStrictEqual([ + { + appData: + '0xf7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda0', + buyAmount: BigInt('611289510998251134'), + buyToken: '0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14', + buyTokenBalance: 'erc20', + feeAmount: BigInt('0'), + kind: 'sell', + partiallyFillable: false, + receiver: '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381', + sellAmount: BigInt('213586875483862141750'), + sellToken: '0xbe72E441BF55620febc26715db68d3494213D8Cb', + sellTokenBalance: 'erc20', + validTo: + Math.ceil(executionDate.getTime() / 1_000) + + // First part of the order + (1 * Number(twapStruct.t) - 1), + }, + { + appData: + '0xf7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda0', + buyAmount: BigInt('611289510998251134'), + buyToken: '0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14', + buyTokenBalance: 'erc20', + feeAmount: BigInt('0'), + kind: 'sell', + partiallyFillable: false, + receiver: '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381', + sellAmount: BigInt('213586875483862141750'), + sellToken: '0xbe72E441BF55620febc26715db68d3494213D8Cb', + sellTokenBalance: 'erc20', + validTo: + Math.ceil(executionDate.getTime() / 1_000) + + // Second part of the order + (2 * Number(twapStruct.t) - 1), + }, + ]); + }); + }); + }); +}); diff --git a/src/routes/transactions/helpers/twap-order.helper.ts b/src/routes/transactions/helpers/twap-order.helper.ts new file mode 100644 index 0000000000..bf3cec3dee --- /dev/null +++ b/src/routes/transactions/helpers/twap-order.helper.ts @@ -0,0 +1,128 @@ +import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; +import { + ComposableCowDecoder, + TwapStruct, +} from '@/domain/swaps/contracts/decoders/composable-cow-decoder.helper'; +import { + BuyTokenBalance, + OrderKind, + SellTokenBalance, +} from '@/domain/swaps/entities/order.entity'; +import { GPv2OrderParameters } from '@/routes/transactions/helpers/gp-v2-order.helper'; +import { Injectable, Module } from '@nestjs/common'; +import { isAddressEqual } from 'viem'; + +/** + * + * @see https://github.com/cowprotocol/contracts/blob/1465e69f6935b3ef9ce45d4878e44f0335ef8531/src/ts/order.ts + */ +@Injectable() +export class TwapOrderHelper { + private static readonly ComposableCowAddress = + '0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74' as const; + + constructor( + private readonly multiSendDecoder: MultiSendDecoder, + private readonly composableCowDecoder: ComposableCowDecoder, + ) {} + + /** + * Finds a TWAP order in a given transaction, either directly called or in a MultiSend + * + * @param args.to - recipient of the transaction + * @param args.data - data of the transaction + * @returns TWAP order data if found, otherwise null + */ + public findTwapOrder(args: { + to: `0x${string}`; + data: `0x${string}`; + }): `0x${string}` | null { + if (this.isTwapOrder(args)) { + return args.data; + } + + if (this.multiSendDecoder.helpers.isMultiSend(args.data)) { + const transactions = this.multiSendDecoder.mapMultiSendTransactions( + args.data, + ); + + for (const transaction of transactions) { + if (this.isTwapOrder(transaction)) { + return transaction.data; + } + } + } + + return null; + } + + private isTwapOrder(args: { + to: `0x${string}`; + data: `0x${string}`; + }): boolean { + return ( + isAddressEqual(args.to, TwapOrderHelper.ComposableCowAddress) && + this.composableCowDecoder.helpers.isCreateWithContext(args.data) + ); + } + + /** + * Generates TWAP order parts based on the given TWAP struct and its execution date. + * + * @param args.twapStruct - {@link TwapStruct} (decoded `staticInput` of `createWithContext`) + * @param args.executionDate - date of the TWAP execution + * @param args.chainId - chain ID of the TWAP + * @returns array of {@link GPv2OrderParameters} that represent the TWAP order parts + * + * Implementation based on CoW Swap app + * @see https://github.com/cowprotocol/cowswap/blob/1cdfa24c6448e3ebf2c6e3c986cb5d7bfd269aa4/apps/cowswap-frontend/src/modules/twap/updaters/PartOrdersUpdater.tsx#L44 + */ + public generateTwapOrderParts(args: { + twapStruct: TwapStruct; + executionDate: Date; + chainId: string; + }): Array { + return Array.from({ length: Number(args.twapStruct.n) }, (_, index) => { + return { + sellToken: args.twapStruct.sellToken, + buyToken: args.twapStruct.buyToken, + receiver: args.twapStruct.receiver, + sellAmount: args.twapStruct.partSellAmount, + buyAmount: args.twapStruct.minPartLimit, + validTo: this.calculateValidTo({ + part: index, + startTime: Math.ceil(args.executionDate.getTime() / 1_000), + span: Number(args.twapStruct.span), + frequency: Number(args.twapStruct.t), + }), + appData: args.twapStruct.appData, + feeAmount: BigInt('0'), + kind: OrderKind.Sell, + partiallyFillable: false, + sellTokenBalance: SellTokenBalance.Erc20, + buyTokenBalance: BuyTokenBalance.Erc20, + }; + }); + } + + private calculateValidTo(args: { + part: number; + startTime: number; + frequency: number; + span: number; + }): number { + const validityPeriod = + args.span === 0 + ? (args.part + 1) * args.frequency - 1 + : args.part * args.frequency + args.span - 1; + + return args.startTime + validityPeriod; + } +} + +@Module({ + imports: [], + providers: [ComposableCowDecoder, MultiSendDecoder, TwapOrderHelper], + exports: [TwapOrderHelper], +}) +export class TwapOrderHelperModule {} diff --git a/src/routes/transactions/mappers/common/transaction-info.mapper.ts b/src/routes/transactions/mappers/common/transaction-info.mapper.ts index f9fc17b01b..23bd24d957 100644 --- a/src/routes/transactions/mappers/common/transaction-info.mapper.ts +++ b/src/routes/transactions/mappers/common/transaction-info.mapper.ts @@ -22,6 +22,9 @@ import { isHex } from 'viem'; import { ILoggingService, LoggingService } from '@/logging/logging.interface'; import { SwapOrderTransactionInfo } from '@/routes/transactions/entities/swaps/swap-order-info.entity'; import { SwapOrderHelper } from '@/routes/transactions/helpers/swap-order.helper'; +import { TwapOrderMapper } from '@/routes/transactions/mappers/common/twap-order.mapper'; +import { TwapOrderHelper } from '@/routes/transactions/helpers/twap-order.helper'; +import { TwapOrderTransactionInfo } from '@/routes/transactions/entities/swaps/twap-order-info.entity'; @Injectable() export class MultisigTransactionInfoMapper { @@ -30,6 +33,7 @@ export class MultisigTransactionInfoMapper { private readonly SAFE_TRANSFER_FROM_METHOD = 'safeTransferFrom'; private readonly isRichFragmentsEnabled: boolean; private readonly isSwapsDecodingEnabled: boolean; + private readonly isTwapsDecodingEnabled: boolean; private readonly ERC20_TRANSFER_METHODS = [ this.TRANSFER_METHOD, @@ -56,6 +60,8 @@ export class MultisigTransactionInfoMapper { private readonly humanDescriptionMapper: HumanDescriptionMapper, private readonly swapOrderMapper: SwapOrderMapper, private readonly swapOrderHelper: SwapOrderHelper, + private readonly twapOrderMapper: TwapOrderMapper, + private readonly twapOrderHelper: TwapOrderHelper, ) { this.isRichFragmentsEnabled = this.configurationService.getOrThrow( 'features.richFragments', @@ -63,6 +69,9 @@ export class MultisigTransactionInfoMapper { this.isSwapsDecodingEnabled = this.configurationService.getOrThrow( 'features.swapsDecoding', ); + this.isTwapsDecodingEnabled = this.configurationService.getOrThrow( + 'features.twapsDecoding', + ); } async mapTransactionInfo( @@ -99,6 +108,14 @@ export class MultisigTransactionInfoMapper { if (swapOrder) return swapOrder; } + if (this.isTwapsDecodingEnabled) { + // If the transaction is a TWAP order, we return it immediately + const twapOrder = await this.mapTwapOrder(chainId, transaction); + if (twapOrder) { + return twapOrder; + } + } + if (this.isCustomTransaction(value, dataSize, transaction.operation)) { return await this.customTransactionMapper.mapCustomTransaction( transaction, @@ -218,6 +235,46 @@ export class MultisigTransactionInfoMapper { } } + /** + * Maps a TWAP order transaction. + * If the transaction is not a TWAP order, it returns null. + * + * @param chainId - chain ID of the transaction + * @param transaction - transaction to map + * @returns mapped {@link TwapOrderTransactionInfo} or null if none found + */ + private async mapTwapOrder( + chainId: string, + transaction: MultisigTransaction | ModuleTransaction, + ): Promise { + if (!transaction?.data || !transaction?.executionDate) { + return null; + } + + const orderData = this.twapOrderHelper.findTwapOrder({ + to: transaction.to, + data: transaction.data, + }); + + if (!orderData) { + return null; + } + + try { + return await this.twapOrderMapper.mapTwapOrder( + chainId, + transaction.safe, + { + data: orderData, + executionDate: transaction.executionDate, + }, + ); + } catch (error) { + this.loggingService.warn(error); + return null; + } + } + private isCustomTransaction( value: number, dataSize: number, diff --git a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts new file mode 100644 index 0000000000..c841574ae7 --- /dev/null +++ b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts @@ -0,0 +1,228 @@ +import { fakeJson } from '@/__tests__/faker'; +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { IChainsRepository } from '@/domain/chains/chains.repository.interface'; +import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; +import { ComposableCowDecoder } from '@/domain/swaps/contracts/decoders/composable-cow-decoder.helper'; +import { SetPreSignatureDecoder } from '@/domain/swaps/contracts/decoders/set-pre-signature-decoder.helper'; +import { Order } from '@/domain/swaps/entities/order.entity'; +import { ISwapsRepository } from '@/domain/swaps/swaps.repository'; +import { tokenBuilder } from '@/domain/tokens/__tests__/token.builder'; +import { ITokenRepository } from '@/domain/tokens/token.repository.interface'; +import { ILoggingService } from '@/logging/logging.interface'; +import { GPv2OrderHelper } from '@/routes/transactions/helpers/gp-v2-order.helper'; +import { SwapOrderHelper } from '@/routes/transactions/helpers/swap-order.helper'; +import { TwapOrderHelper } from '@/routes/transactions/helpers/twap-order.helper'; +import { TwapOrderMapper } from '@/routes/transactions/mappers/common/twap-order.mapper'; + +const mockLoggingService = { + debug: jest.fn(), +} as jest.MockedObjectDeep; + +const mockTokenRepository = { + getToken: jest.fn(), +} as jest.MockedObjectDeep; + +const mockSwapsRepository = { + getOrder: jest.fn(), + getFullAppData: jest.fn(), +} as jest.MockedObjectDeep; + +const mockConfigurationService = { + getOrThrow: jest.fn(), +} as jest.MockedObjectDeep; + +const mockChainsRepository = { + getChain: jest.fn(), +} as jest.MockedObjectDeep; + +describe('TwapOrderMapper', () => { + let mapper: TwapOrderMapper; + + beforeEach(() => { + const multiSendDecoder = new MultiSendDecoder(); + const setPreSignatureDecoder = new SetPreSignatureDecoder( + mockLoggingService, + ); + const allowedApps = new Set(); + const swapOrderHelper = new SwapOrderHelper( + multiSendDecoder, + setPreSignatureDecoder, + mockTokenRepository, + mockSwapsRepository, + mockConfigurationService, + allowedApps, + mockChainsRepository, + ); + const composableCowDecoder = new ComposableCowDecoder(); + const gpv2OrderHelper = new GPv2OrderHelper(); + const twapOrderHelper = new TwapOrderHelper( + multiSendDecoder, + composableCowDecoder, + ); + + mapper = new TwapOrderMapper( + swapOrderHelper, + mockSwapsRepository, + composableCowDecoder, + gpv2OrderHelper, + twapOrderHelper, + ); + }); + + it('should map a TWAP order', async () => { + /** + * @see https://sepolia.etherscan.io/address/0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74 + */ + const chainId = '11155111'; + const owner = '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381'; + const data = + '0x0d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011f294a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000'; + const executionDate = new Date(1718288040000); + + const part1 = { + creationDate: '2024-06-13T14:14:02.269522Z', + owner: '0x31eac7f0141837b266de30f4dc9af15629bd5381', + uid: '0xdaabe82f86545c66074b5565962e96758979ae80124aabef05e0585149d30f7931eac7f0141837b266de30f4dc9af15629bd5381666b05af', + availableBalance: null, + executedBuyAmount: '691671781640850856', + executedSellAmount: '213586875483862141750', + executedSellAmountBeforeFees: '213586875483862141750', + executedFeeAmount: '0', + executedSurplusFee: '2135868754838621119', + invalidated: false, + status: 'fulfilled', + class: 'limit', + settlementContract: '0x9008d19f58aabd9ed0d60971565aa8510560ab41', + fullFeeAmount: '0', + solverFee: '0', + isLiquidityOrder: false, + fullAppData: + '{"appCode":"Safe Wallet Swaps","metadata":{"orderClass":{"orderClass":"twap"},"quote":{"slippageBips":1000},"widget":{"appCode":"CoW Swap-SafeApp","environment":"production"}},"version":"1.1.0"}', + sellToken: '0xbe72e441bf55620febc26715db68d3494213d8cb', + buyToken: '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', + receiver: '0x31eac7f0141837b266de30f4dc9af15629bd5381', + sellAmount: '213586875483862141750', + buyAmount: '611289510998251134', + validTo: 1718289839, + appData: + '0xf7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda0', + feeAmount: '0', + kind: 'sell', + partiallyFillable: false, + sellTokenBalance: 'erc20', + buyTokenBalance: 'erc20', + signingScheme: 'eip1271', + signature: + '0x5fd7e97ddaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230bd5a25ba2e97094ad7d83dc28a6572da797d6b3e7fc6663bd93efb789fc17e489000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000000180000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e00000000000000000000000000000000000000000000000000000000666b05aff7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc90000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011f294a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000', + interactions: { pre: [], post: [] }, + } as unknown as Order; + const part2 = { + creationDate: '2024-06-13T14:44:02.307987Z', + owner: '0x31eac7f0141837b266de30f4dc9af15629bd5381', + uid: '0x557cb31a9dbbd23830c57d9fd3bbfc3694e942c161232b6cf696ba3bd11f9d6631eac7f0141837b266de30f4dc9af15629bd5381666b0cb7', + availableBalance: null, + executedBuyAmount: '687772850053823756', + executedSellAmount: '213586875483862141750', + executedSellAmountBeforeFees: '213586875483862141750', + executedFeeAmount: '0', + executedSurplusFee: '2135868754838621123', + invalidated: false, + status: 'fulfilled', + class: 'limit', + settlementContract: '0x9008d19f58aabd9ed0d60971565aa8510560ab41', + fullFeeAmount: '0', + solverFee: '0', + isLiquidityOrder: false, + fullAppData: + '{"appCode":"Safe Wallet Swaps","metadata":{"orderClass":{"orderClass":"twap"},"quote":{"slippageBips":1000},"widget":{"appCode":"CoW Swap-SafeApp","environment":"production"}},"version":"1.1.0"}', + sellToken: '0xbe72e441bf55620febc26715db68d3494213d8cb', + buyToken: '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', + receiver: '0x31eac7f0141837b266de30f4dc9af15629bd5381', + sellAmount: '213586875483862141750', + buyAmount: '611289510998251134', + validTo: 1718291639, + appData: + '0xf7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda0', + feeAmount: '0', + kind: 'sell', + partiallyFillable: false, + sellTokenBalance: 'erc20', + buyTokenBalance: 'erc20', + signingScheme: 'eip1271', + signature: + '0x5fd7e97ddaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230bd5a25ba2e97094ad7d83dc28a6572da797d6b3e7fc6663bd93efb789fc17e489000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000000180000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e00000000000000000000000000000000000000000000000000000000666b0cb7f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc90000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011f294a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000', + interactions: { pre: [], post: [] }, + } as unknown as Order; + + const buyToken = tokenBuilder().with('address', part1.buyToken).build(); + const sellToken = tokenBuilder().with('address', part1.sellToken).build(); + const fullAppData = JSON.parse(fakeJson()); + + mockSwapsRepository.getOrder + .mockResolvedValueOnce(part1) + .mockResolvedValueOnce(part2); + mockTokenRepository.getToken.mockImplementation(async ({ address }) => { + // We only need mock part1 addresses as all parts use the same tokens + switch (address) { + case part1.buyToken: { + return Promise.resolve(buyToken); + } + case part1.sellToken: { + return Promise.resolve(sellToken); + } + default: { + return Promise.reject(new Error(`Token not found: ${address}`)); + } + } + }); + mockSwapsRepository.getFullAppData.mockResolvedValue({ fullAppData }); + + const result = await mapper.mapTwapOrder(chainId, owner, { + data, + executionDate, + }); + + expect(result).toEqual({ + buyAmount: '1222579021996502268', + buyToken: { + address: buyToken.address, + decimals: buyToken.decimals, + logoUri: buyToken.logoUri, + name: buyToken.name, + symbol: buyToken.symbol, + trusted: buyToken.trusted, + }, + class: 'limit', + durationOfPart: { + durationType: 'AUTO', + }, + executedBuyAmount: '1379444631694674400', + executedSellAmount: '427173750967724300000', + fullAppData, + humanDescription: null, + kind: 'sell', + minPartLimit: '611289510998251134', + numberOfParts: 2, + orderStatus: 'fulfilled', + owner: '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381', + partSellAmount: '213586875483862141750', + receiver: '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381', + richDecodedInfo: null, + sellAmount: '427173750967724283500', + sellToken: { + address: sellToken.address, + decimals: sellToken.decimals, + logoUri: sellToken.logoUri, + name: sellToken.name, + symbol: sellToken.symbol, + trusted: sellToken.trusted, + }, + startTime: { + startType: 'AT_MINING_TIME', + }, + timeBetweenParts: '1800', + type: 'TwapOrder', + validUntil: 1718291639, + }); + }); +}); diff --git a/src/routes/transactions/mappers/common/twap-order.mapper.ts b/src/routes/transactions/mappers/common/twap-order.mapper.ts new file mode 100644 index 0000000000..6df2186473 --- /dev/null +++ b/src/routes/transactions/mappers/common/twap-order.mapper.ts @@ -0,0 +1,195 @@ +import { Inject, Injectable, Module } from '@nestjs/common'; +import { TokenInfo } from '@/routes/transactions/entities/swaps/token-info.entity'; +import { + SwapOrderHelper, + SwapOrderHelperModule, +} from '@/routes/transactions/helpers/swap-order.helper'; +import { ComposableCowDecoder } from '@/domain/swaps/contracts/decoders/composable-cow-decoder.helper'; +import { + DurationType, + StartTimeValue, + TwapOrderInfo, + TwapOrderTransactionInfo, +} from '@/routes/transactions/entities/swaps/twap-order-info.entity'; +import { + TwapOrderHelper, + TwapOrderHelperModule, +} from '@/routes/transactions/helpers/twap-order.helper'; +import { + OrderClass, + OrderKind, + OrderStatus, +} from '@/domain/swaps/entities/order.entity'; +import { ISwapsRepository } from '@/domain/swaps/swaps.repository'; +import { SwapsRepositoryModule } from '@/domain/swaps/swaps-repository.module'; +import { SwapOrderMapperModule } from '@/routes/transactions/mappers/common/swap-order.mapper'; +import { GPv2OrderHelper } from '@/routes/transactions/helpers/gp-v2-order.helper'; + +@Injectable() +export class TwapOrderMapper { + constructor( + private readonly swapOrderHelper: SwapOrderHelper, + @Inject(ISwapsRepository) + private readonly swapsRepository: ISwapsRepository, + private readonly composableCowDecoder: ComposableCowDecoder, + private readonly gpv2OrderHelper: GPv2OrderHelper, + private readonly twapOrderHelper: TwapOrderHelper, + ) {} + + /** + * Maps a TWAP order from a given transaction + * + * @param chainId - chain the order is on + * @param safeAddress - "owner" of the order + * @param transaction - transaction data and execution date + * @returns mapped {@link TwapOrderTransactionInfo} + */ + async mapTwapOrder( + chainId: string, + safeAddress: `0x${string}`, + transaction: { data: `0x${string}`; executionDate: Date }, + ): Promise { + // Decode `staticInput` of `createWithContextCall` + const twapStruct = this.composableCowDecoder.decodeTwapStruct( + transaction.data, + ); + // Generate parts of the TWAP order + const parts = this.twapOrderHelper.generateTwapOrderParts({ + twapStruct, + executionDate: transaction.executionDate, + chainId, + }); + + const [{ fullAppData }, ...orders] = await Promise.all([ + // Decode hash of `appData` + this.swapsRepository.getFullAppData(chainId, twapStruct.appData), + // Fetch all order parts + ...parts.map((order) => { + const orderUid = this.gpv2OrderHelper.computeOrderUid({ + chainId, + owner: safeAddress, + order, + }); + return this.swapOrderHelper.getOrder({ chainId, orderUid }); + }), + ]); + + // All orders have the same sellToken and buyToken + const { sellToken, buyToken } = orders[0]; + + const { n: numberOfParts, partSellAmount, minPartLimit } = twapStruct; + const span = Number(twapStruct.span); + const sellAmount = partSellAmount * numberOfParts; + const buyAmount = minPartLimit * numberOfParts; + + return new TwapOrderTransactionInfo({ + orderStatus: this.getOrderStatus(orders), + kind: OrderKind.Sell, + class: OrderClass.Limit, + validUntil: Math.max(...parts.map((order) => order.validTo)), + sellAmount: sellAmount.toString(), + buyAmount: buyAmount.toString(), + executedSellAmount: this.getExecutedSellAmount(orders).toString(), + executedBuyAmount: this.getExecutedBuyAmount(orders).toString(), + sellToken: new TokenInfo({ + address: sellToken.address, + decimals: sellToken.decimals, + logoUri: sellToken.logoUri, + name: sellToken.name, + symbol: sellToken.symbol, + trusted: sellToken.trusted, + }), + buyToken: new TokenInfo({ + address: buyToken.address, + decimals: buyToken.decimals, + logoUri: buyToken.logoUri, + name: buyToken.name, + symbol: buyToken.symbol, + trusted: buyToken.trusted, + }), + receiver: twapStruct.receiver, + owner: safeAddress, + fullAppData, + numberOfParts: Number(numberOfParts), + partSellAmount: partSellAmount.toString(), + minPartLimit: minPartLimit.toString(), + timeBetweenParts: twapStruct.t.toString(), + durationOfPart: this.getDurationOfPart(span), + startTime: this.getStartTime({ span, startEpoch: Number(twapStruct.t0) }), + }); + } + + private getOrderStatus( + orders: Array>>, + ): OrderStatus { + // If an order is fulfilled, cancelled or expired, the part is "complete" + const completeStatuses = [ + OrderStatus.Fulfilled, + OrderStatus.Cancelled, + OrderStatus.Expired, + ]; + + for (let i = 0; i < orders.length; i++) { + const { order } = orders[i]; + + // Return the status of the last part + if (i === orders.length - 1) { + return order.status; + } + + // If the part is complete, continue to the next part + if (completeStatuses.includes(order.status)) { + continue; + } + + return order.status; + } + + return OrderStatus.Unknown; + } + + private getExecutedSellAmount( + orders: Array>>, + ): number { + return orders.reduce((acc, { order }) => { + return acc + Number(order.executedSellAmount); + }, 0); + } + + private getExecutedBuyAmount( + orders: Array>>, + ): number { + return orders.reduce((acc, { order }) => { + return acc + Number(order.executedBuyAmount); + }, 0); + } + + private getDurationOfPart(span: number): TwapOrderInfo['durationOfPart'] { + if (span === 0) { + return { durationType: DurationType.Auto }; + } + return { durationType: DurationType.LimitDuration, duration: span }; + } + + private getStartTime(args: { + span: number; + startEpoch: number; + }): TwapOrderInfo['startTime'] { + if (args.span === 0) { + return { startType: StartTimeValue.AtMiningTime }; + } + return { startType: StartTimeValue.AtEpoch, epoch: args.startEpoch }; + } +} + +@Module({ + imports: [ + SwapOrderHelperModule, + SwapsRepositoryModule, + SwapOrderMapperModule, + TwapOrderHelperModule, + ], + providers: [ComposableCowDecoder, GPv2OrderHelper, TwapOrderMapper], + exports: [TwapOrderMapper], +}) +export class TwapOrderMapperModule {} diff --git a/src/routes/transactions/transactions.module.ts b/src/routes/transactions/transactions.module.ts index d47031b25e..608e36db07 100644 --- a/src/routes/transactions/transactions.module.ts +++ b/src/routes/transactions/transactions.module.ts @@ -37,6 +37,8 @@ import { HumanDescriptionRepositoryModule } from '@/domain/human-description/hum import { SafeAppsRepositoryModule } from '@/domain/safe-apps/safe-apps.repository.interface'; import { TokenRepositoryModule } from '@/domain/tokens/token.repository.interface'; import { SwapOrderHelperModule } from '@/routes/transactions/helpers/swap-order.helper'; +import { TwapOrderMapperModule } from '@/routes/transactions/mappers/common/twap-order.mapper'; +import { TwapOrderHelperModule } from '@/routes/transactions/helpers/twap-order.helper'; @Module({ controllers: [TransactionsController], @@ -51,6 +53,8 @@ import { SwapOrderHelperModule } from '@/routes/transactions/helpers/swap-order. SwapOrderMapperModule, SwapOrderHelperModule, TokenRepositoryModule, + TwapOrderMapperModule, + TwapOrderHelperModule, ], providers: [ CreationTransactionMapper, From 8953349498a630df3b7a33d09a47a8df5800123b Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Wed, 19 Jun 2024 11:31:27 +0200 Subject: [PATCH 109/207] Rename `SetPreSignatureDecoder` and usage to `GPv2Decoder` (#1671) Renames `SetPreSignatureDecoder` and usage of it to `GPv2Decoder`, which also matches the style across the project for other decoders. --- ...coder.builder.ts => gp-v2-encoder.builder.ts} | 2 +- ...lper.spec.ts => gp-v2-decoder.helper.spec.ts} | 10 +++++----- ...decoder.helper.ts => gp-v2-decoder.helper.ts} | 10 +++++----- .../helpers/swap-order.helper.spec.ts | 16 ++++++++-------- .../transactions/helpers/swap-order.helper.ts | 10 ++++------ .../mappers/common/swap-order.mapper.ts | 8 ++++---- .../mappers/common/twap-order.mapper.spec.ts | 8 +++----- .../transactions-view.controller.spec.ts | 2 +- .../transactions/transactions-view.controller.ts | 4 ++-- .../transactions/transactions-view.service.ts | 6 +++--- src/routes/transactions/transactions.module.ts | 4 ++-- 11 files changed, 38 insertions(+), 42 deletions(-) rename src/domain/swaps/contracts/__tests__/encoders/{set-pre-signature-encoder.builder.ts => gp-v2-encoder.builder.ts} (90%) rename src/domain/swaps/contracts/decoders/{set-pre-signature-decoder.helper.spec.ts => gp-v2-decoder.helper.spec.ts} (75%) rename src/domain/swaps/contracts/decoders/{set-pre-signature-decoder.helper.ts => gp-v2-decoder.helper.ts} (80%) diff --git a/src/domain/swaps/contracts/__tests__/encoders/set-pre-signature-encoder.builder.ts b/src/domain/swaps/contracts/__tests__/encoders/gp-v2-encoder.builder.ts similarity index 90% rename from src/domain/swaps/contracts/__tests__/encoders/set-pre-signature-encoder.builder.ts rename to src/domain/swaps/contracts/__tests__/encoders/gp-v2-encoder.builder.ts index 790385653d..0611c7f4f2 100644 --- a/src/domain/swaps/contracts/__tests__/encoders/set-pre-signature-encoder.builder.ts +++ b/src/domain/swaps/contracts/__tests__/encoders/gp-v2-encoder.builder.ts @@ -2,7 +2,7 @@ import { faker } from '@faker-js/faker'; import { encodeFunctionData, Hex, keccak256, toBytes } from 'viem'; import { Builder } from '@/__tests__/builder'; import { IEncoder } from '@/__tests__/encoder-builder'; -import { abi } from '@/domain/swaps/contracts/decoders/set-pre-signature-decoder.helper'; +import { abi } from '@/domain/swaps/contracts/decoders/gp-v2-decoder.helper'; type SetPreSignatureArgs = { orderUid: `0x${string}`; diff --git a/src/domain/swaps/contracts/decoders/set-pre-signature-decoder.helper.spec.ts b/src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.spec.ts similarity index 75% rename from src/domain/swaps/contracts/decoders/set-pre-signature-decoder.helper.spec.ts rename to src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.spec.ts index 00001f2932..66ae15bd82 100644 --- a/src/domain/swaps/contracts/decoders/set-pre-signature-decoder.helper.spec.ts +++ b/src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.spec.ts @@ -1,7 +1,7 @@ import { Hex } from 'viem'; import { faker } from '@faker-js/faker'; -import { SetPreSignatureDecoder } from '@/domain/swaps/contracts/decoders/set-pre-signature-decoder.helper'; -import { setPreSignatureEncoder } from '@/domain/swaps/contracts/__tests__/encoders/set-pre-signature-encoder.builder'; +import { GPv2Decoder } from '@/domain/swaps/contracts/decoders/gp-v2-decoder.helper'; +import { setPreSignatureEncoder } from '@/domain/swaps/contracts/__tests__/encoders/gp-v2-encoder.builder'; import { ILoggingService } from '@/logging/logging.interface'; const loggingService = { @@ -9,12 +9,12 @@ const loggingService = { } as jest.MockedObjectDeep; const loggingServiceMock = jest.mocked(loggingService); -describe('SetPreSignatureDecoder', () => { - let target: SetPreSignatureDecoder; +describe('GPv2Decoder', () => { + let target: GPv2Decoder; beforeEach(() => { jest.resetAllMocks(); - target = new SetPreSignatureDecoder(loggingServiceMock); + target = new GPv2Decoder(loggingServiceMock); }); it('decodes a setPreSignature function call correctly', () => { diff --git a/src/domain/swaps/contracts/decoders/set-pre-signature-decoder.helper.ts b/src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.ts similarity index 80% rename from src/domain/swaps/contracts/decoders/set-pre-signature-decoder.helper.ts rename to src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.ts index 6906c2cb7a..9381302d46 100644 --- a/src/domain/swaps/contracts/decoders/set-pre-signature-decoder.helper.ts +++ b/src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.ts @@ -8,7 +8,7 @@ export const abi = parseAbi([ ]); @Injectable() -export class SetPreSignatureDecoder extends AbiDecoder { +export class GPv2Decoder extends AbiDecoder { constructor( @Inject(LoggingService) private readonly loggingService: ILoggingService, ) { @@ -21,7 +21,7 @@ export class SetPreSignatureDecoder extends AbiDecoder { * @param data - the transaction data for the setPreSignature call * @returns {`0x${string}`} the order UID or null if the data does not represent a setPreSignature transaction */ - getOrderUid(data: `0x${string}`): `0x${string}` | null { + getOrderUidFromSetPreSignature(data: `0x${string}`): `0x${string}` | null { try { if (!this.helpers.isSetPreSignature(data)) return null; const { args } = this.decodeFunctionData({ data }); @@ -34,7 +34,7 @@ export class SetPreSignatureDecoder extends AbiDecoder { } @Module({ - providers: [SetPreSignatureDecoder], - exports: [SetPreSignatureDecoder], + providers: [GPv2Decoder], + exports: [GPv2Decoder], }) -export class SetPreSignatureDecoderModule {} +export class GPv2DecoderModule {} diff --git a/src/routes/transactions/helpers/swap-order.helper.spec.ts b/src/routes/transactions/helpers/swap-order.helper.spec.ts index 6ac9d7c84d..6d6f75a8f0 100644 --- a/src/routes/transactions/helpers/swap-order.helper.spec.ts +++ b/src/routes/transactions/helpers/swap-order.helper.spec.ts @@ -1,6 +1,6 @@ import { SwapsRepository } from '@/domain/swaps/swaps.repository'; import { ITokenRepository } from '@/domain/tokens/token.repository.interface'; -import { SetPreSignatureDecoder } from '@/domain/swaps/contracts/decoders/set-pre-signature-decoder.helper'; +import { GPv2Decoder } from '@/domain/swaps/contracts/decoders/gp-v2-decoder.helper'; import { faker } from '@faker-js/faker'; import { orderBuilder } from '@/domain/swaps/entities/__tests__/order.builder'; import { tokenBuilder } from '@/domain/tokens/__tests__/token.builder'; @@ -17,10 +17,10 @@ const swapsRepository = { } as jest.MockedObjectDeep; const swapsRepositoryMock = jest.mocked(swapsRepository); -const setPreSignatureDecoder = { - getOrderUid: jest.fn(), -} as jest.MockedObjectDeep; -const setPreSignatureDecoderMock = jest.mocked(setPreSignatureDecoder); +const gpv2Decoder = { + getOrderUidFromSetPreSignature: jest.fn(), +} as jest.MockedObjectDeep; +const gpv2DecoderMock = jest.mocked(gpv2Decoder); const tokenRepository = { getToken: jest.fn(), @@ -57,7 +57,7 @@ describe('Swap Order Helper tests', () => { target = new SwapOrderHelper( multiSendDecoderMock, - setPreSignatureDecoderMock, + gpv2DecoderMock, tokenRepositoryMock, swapsRepositoryMock, configurationServiceMock, @@ -77,7 +77,7 @@ describe('Swap Order Helper tests', () => { .with('buyToken', getAddress(buyToken.address)) .with('sellToken', getAddress(sellToken.address)) .build(); - setPreSignatureDecoderMock.getOrderUid.mockReturnValue( + gpv2DecoderMock.getOrderUidFromSetPreSignature.mockReturnValue( order.uid as `0x${string}`, ); swapsRepositoryMock.getOrder.mockResolvedValue(order); @@ -311,7 +311,7 @@ describe('Swap Order Helper tests', () => { target = new SwapOrderHelper( multiSendDecoderMock, - setPreSignatureDecoderMock, + gpv2DecoderMock, tokenRepositoryMock, swapsRepositoryMock, configurationServiceMock, diff --git a/src/routes/transactions/helpers/swap-order.helper.ts b/src/routes/transactions/helpers/swap-order.helper.ts index 774e7eedbd..00f3c9ff44 100644 --- a/src/routes/transactions/helpers/swap-order.helper.ts +++ b/src/routes/transactions/helpers/swap-order.helper.ts @@ -1,6 +1,6 @@ import { Inject, Injectable, Module } from '@nestjs/common'; import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; -import { SetPreSignatureDecoder } from '@/domain/swaps/contracts/decoders/set-pre-signature-decoder.helper'; +import { GPv2Decoder } from '@/domain/swaps/contracts/decoders/gp-v2-decoder.helper'; import { ITokenRepository, TokenRepositoryModule, @@ -30,7 +30,7 @@ export class SwapOrderHelper { constructor( private readonly multiSendDecoder: MultiSendDecoder, - private readonly setPreSignatureDecoder: SetPreSignatureDecoder, + private readonly gpv2Decoder: GPv2Decoder, @Inject(ITokenRepository) private readonly tokenRepository: ITokenRepository, @Inject(ISwapsRepository) @@ -147,9 +147,7 @@ export class SwapOrderHelper { private isSwapOrder(transaction: { data?: `0x${string}` }): boolean { if (!transaction.data) return false; - return this.setPreSignatureDecoder.helpers.isSetPreSignature( - transaction.data, - ); + return this.gpv2Decoder.helpers.isSetPreSignature(transaction.data); } /** @@ -215,7 +213,7 @@ function allowedAppsFactory( providers: [ SwapOrderHelper, MultiSendDecoder, - SetPreSignatureDecoder, + GPv2Decoder, { provide: 'SWAP_ALLOWED_APPS', useFactory: allowedAppsFactory, diff --git a/src/routes/transactions/mappers/common/swap-order.mapper.ts b/src/routes/transactions/mappers/common/swap-order.mapper.ts index 636592e2b8..8c675e8418 100644 --- a/src/routes/transactions/mappers/common/swap-order.mapper.ts +++ b/src/routes/transactions/mappers/common/swap-order.mapper.ts @@ -1,5 +1,5 @@ import { Injectable, Module } from '@nestjs/common'; -import { SetPreSignatureDecoder } from '@/domain/swaps/contracts/decoders/set-pre-signature-decoder.helper'; +import { GPv2Decoder } from '@/domain/swaps/contracts/decoders/gp-v2-decoder.helper'; import { SwapOrderTransactionInfo } from '@/routes/transactions/entities/swaps/swap-order-info.entity'; import { TokenInfo } from '@/routes/transactions/entities/swaps/token-info.entity'; import { @@ -10,7 +10,7 @@ import { @Injectable() export class SwapOrderMapper { constructor( - private readonly setPreSignatureDecoder: SetPreSignatureDecoder, + private readonly gpv2Decoder: GPv2Decoder, private readonly swapOrderHelper: SwapOrderHelper, ) {} @@ -19,7 +19,7 @@ export class SwapOrderMapper { transaction: { data: `0x${string}` }, ): Promise { const orderUid: `0x${string}` | null = - this.setPreSignatureDecoder.getOrderUid(transaction.data); + this.gpv2Decoder.getOrderUidFromSetPreSignature(transaction.data); if (!orderUid) { throw new Error('Order UID not found in transaction data'); } @@ -70,7 +70,7 @@ export class SwapOrderMapper { @Module({ imports: [SwapOrderHelperModule], - providers: [SwapOrderMapper, SetPreSignatureDecoder], + providers: [SwapOrderMapper, GPv2Decoder], exports: [SwapOrderMapper], }) export class SwapOrderMapperModule {} diff --git a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts index c841574ae7..8df9a522c1 100644 --- a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts +++ b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts @@ -3,7 +3,7 @@ import { IConfigurationService } from '@/config/configuration.service.interface' import { IChainsRepository } from '@/domain/chains/chains.repository.interface'; import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; import { ComposableCowDecoder } from '@/domain/swaps/contracts/decoders/composable-cow-decoder.helper'; -import { SetPreSignatureDecoder } from '@/domain/swaps/contracts/decoders/set-pre-signature-decoder.helper'; +import { GPv2Decoder } from '@/domain/swaps/contracts/decoders/gp-v2-decoder.helper'; import { Order } from '@/domain/swaps/entities/order.entity'; import { ISwapsRepository } from '@/domain/swaps/swaps.repository'; import { tokenBuilder } from '@/domain/tokens/__tests__/token.builder'; @@ -40,13 +40,11 @@ describe('TwapOrderMapper', () => { beforeEach(() => { const multiSendDecoder = new MultiSendDecoder(); - const setPreSignatureDecoder = new SetPreSignatureDecoder( - mockLoggingService, - ); + const gpv2Decoder = new GPv2Decoder(mockLoggingService); const allowedApps = new Set(); const swapOrderHelper = new SwapOrderHelper( multiSendDecoder, - setPreSignatureDecoder, + gpv2Decoder, mockTokenRepository, mockSwapsRepository, mockConfigurationService, diff --git a/src/routes/transactions/transactions-view.controller.spec.ts b/src/routes/transactions/transactions-view.controller.spec.ts index b6f90572a7..85ef382e3a 100644 --- a/src/routes/transactions/transactions-view.controller.spec.ts +++ b/src/routes/transactions/transactions-view.controller.spec.ts @@ -20,7 +20,7 @@ import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; import { dataDecodedBuilder } from '@/domain/data-decoder/entities/__tests__/data-decoded.builder'; import { orderBuilder } from '@/domain/swaps/entities/__tests__/order.builder'; import { tokenBuilder } from '@/domain/tokens/__tests__/token.builder'; -import { setPreSignatureEncoder } from '@/domain/swaps/contracts/__tests__/encoders/set-pre-signature-encoder.builder'; +import { setPreSignatureEncoder } from '@/domain/swaps/contracts/__tests__/encoders/gp-v2-encoder.builder'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { faker } from '@faker-js/faker'; diff --git a/src/routes/transactions/transactions-view.controller.ts b/src/routes/transactions/transactions-view.controller.ts index db8b459b33..d19873bdda 100644 --- a/src/routes/transactions/transactions-view.controller.ts +++ b/src/routes/transactions/transactions-view.controller.ts @@ -25,7 +25,7 @@ import { TransactionDataDto, TransactionDataDtoSchema, } from '@/routes/common/entities/transaction-data.dto.entity'; -import { SetPreSignatureDecoderModule } from '@/domain/swaps/contracts/decoders/set-pre-signature-decoder.helper'; +import { GPv2DecoderModule } from '@/domain/swaps/contracts/decoders/gp-v2-decoder.helper'; import { ValidationPipe } from '@/validation/pipes/validation.pipe'; import { NumericStringSchema } from '@/validation/entities/schemas/numeric-string.schema'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; @@ -70,7 +70,7 @@ export class TransactionsViewController { @Module({ imports: [ DataDecodedRepositoryModule, - SetPreSignatureDecoderModule, + GPv2DecoderModule, SwapOrderHelperModule, ], providers: [TransactionsViewService], diff --git a/src/routes/transactions/transactions-view.service.ts b/src/routes/transactions/transactions-view.service.ts index 7247c26026..d320a1bb5f 100644 --- a/src/routes/transactions/transactions-view.service.ts +++ b/src/routes/transactions/transactions-view.service.ts @@ -7,7 +7,7 @@ import { import { Inject, Injectable } from '@nestjs/common'; import { IDataDecodedRepository } from '@/domain/data-decoder/data-decoded.repository.interface'; import { SwapOrderHelper } from '@/routes/transactions/helpers/swap-order.helper'; -import { SetPreSignatureDecoder } from '@/domain/swaps/contracts/decoders/set-pre-signature-decoder.helper'; +import { GPv2Decoder } from '@/domain/swaps/contracts/decoders/gp-v2-decoder.helper'; import { DataDecoded } from '@/domain/data-decoder/entities/data-decoded.entity'; import { TokenInfo } from '@/routes/transactions/entities/swaps/token-info.entity'; import { ILoggingService, LoggingService } from '@/logging/logging.interface'; @@ -17,7 +17,7 @@ export class TransactionsViewService { constructor( @Inject(IDataDecodedRepository) private readonly dataDecodedRepository: IDataDecodedRepository, - private readonly setPreSignatureDecoder: SetPreSignatureDecoder, + private readonly gpv2Decoder: GPv2Decoder, private readonly swapOrderHelper: SwapOrderHelper, @Inject(LoggingService) private readonly loggingService: ILoggingService, ) {} @@ -64,7 +64,7 @@ export class TransactionsViewService { dataDecoded: DataDecoded; }): Promise { const orderUid: `0x${string}` | null = - this.setPreSignatureDecoder.getOrderUid(args.data); + this.gpv2Decoder.getOrderUidFromSetPreSignature(args.data); if (!orderUid) { throw new Error('Order UID not found in transaction data'); } diff --git a/src/routes/transactions/transactions.module.ts b/src/routes/transactions/transactions.module.ts index 608e36db07..dbcd5d38b7 100644 --- a/src/routes/transactions/transactions.module.ts +++ b/src/routes/transactions/transactions.module.ts @@ -29,7 +29,7 @@ import { TransferImitationMapper } from '@/routes/transactions/mappers/transfers import { TransactionsController } from '@/routes/transactions/transactions.controller'; import { TransactionsService } from '@/routes/transactions/transactions.service'; import { SwapOrderMapperModule } from '@/routes/transactions/mappers/common/swap-order.mapper'; -import { SetPreSignatureDecoderModule } from '@/domain/swaps/contracts/decoders/set-pre-signature-decoder.helper'; +import { GPv2DecoderModule } from '@/domain/swaps/contracts/decoders/gp-v2-decoder.helper'; import { SafeRepositoryModule } from '@/domain/safe/safe.repository.interface'; import { ContractsRepositoryModule } from '@/domain/contracts/contracts.repository.interface'; import { DataDecodedRepositoryModule } from '@/domain/data-decoder/data-decoded.repository.interface'; @@ -49,7 +49,7 @@ import { TwapOrderHelperModule } from '@/routes/transactions/helpers/twap-order. HumanDescriptionRepositoryModule, SafeRepositoryModule, SafeAppsRepositoryModule, - SetPreSignatureDecoderModule, + GPv2DecoderModule, SwapOrderMapperModule, SwapOrderHelperModule, TokenRepositoryModule, From 689396f9aaefb8b2cbbefaf4860191bf744013b8 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Wed, 19 Jun 2024 14:37:15 +0200 Subject: [PATCH 110/207] Limit maximum amount of TWAP parts (#1670) Limits the number of parts we request up to 11 (confirmed by CoW) - which can be configured by an env. var. (`swaps.maxNumberOfParts`). If there are >11 parts, the final one is used to fetch the status and token information but it means that `executedSellAmount` and `executedBuyAmount` are `null` as we cannot calculate them: - Add `swaps.maxNumberOfParts` configuration - Update `TwapOrderInfo['executedSellAmount' | 'executedBuyAmount']` to be `string | null` - Limit number of parts, fetching only the last if more than the maximum - Add appropriate test coverage --- .../entities/__tests__/configuration.ts | 1 + src/config/entities/configuration.ts | 4 + .../entities/swaps/twap-order-info.entity.ts | 24 ++- .../mappers/common/twap-order.mapper.spec.ts | 180 +++++++++++++++--- .../mappers/common/twap-order.mapper.ts | 38 +++- 5 files changed, 207 insertions(+), 40 deletions(-) diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index 8a3fdf61b8..d605767183 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -176,5 +176,6 @@ export default (): ReturnType => ({ explorerBaseUri: faker.internet.url(), restrictApps: false, allowedApps: [], + maxNumberOfParts: faker.number.int(), }, }); diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index b855eaa0f9..aaccc5e4a6 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -256,5 +256,9 @@ export default () => ({ // The app names should match the "App Code" of the metadata provided to CoW Swap. // See https://explorer.cow.fi/appdata?tab=encode allowedApps: process.env.SWAPS_ALLOWED_APPS?.split(',') || [], + // Upper limit of parts we will request from CoW for TWAP orders, after + // which we return base values for those orders + // Note: 11 is the average number of parts, confirmed by CoW + maxNumberOfParts: parseInt(process.env.BALANCES_TTL_SECONDS ?? `${11}`), }, }); diff --git a/src/routes/transactions/entities/swaps/twap-order-info.entity.ts b/src/routes/transactions/entities/swaps/twap-order-info.entity.ts index 6e8e30cdf6..34a2223d82 100644 --- a/src/routes/transactions/entities/swaps/twap-order-info.entity.ts +++ b/src/routes/transactions/entities/swaps/twap-order-info.entity.ts @@ -39,8 +39,8 @@ export type TwapOrderInfo = { validUntil: number; sellAmount: string; buyAmount: string; - executedSellAmount: string; - executedBuyAmount: string; + executedSellAmount: string | null; + executedBuyAmount: string | null; sellToken: TokenInfo; buyToken: TokenInfo; receiver: `0x${string}`; @@ -83,15 +83,19 @@ export class TwapOrderTransactionInfo }) buyAmount: string; - @ApiProperty({ - description: 'The executed sell token raw amount (no decimals)', + @ApiPropertyOptional({ + nullable: true, + description: + 'The executed sell token raw amount (no decimals), or null if there are too many parts', }) - executedSellAmount: string; + executedSellAmount: string | null; - @ApiProperty({ - description: 'The executed buy token raw amount (no decimals)', + @ApiPropertyOptional({ + nullable: true, + description: + 'The executed buy token raw amount (no decimals), or null if there are too many parts', }) - executedBuyAmount: string; + executedBuyAmount: string | null; @ApiProperty({ description: 'The sell token of the TWAP' }) sellToken: TokenInfo; @@ -153,8 +157,8 @@ export class TwapOrderTransactionInfo validUntil: number; sellAmount: string; buyAmount: string; - executedSellAmount: string; - executedBuyAmount: string; + executedSellAmount: string | null; + executedBuyAmount: string | null; sellToken: TokenInfo; buyToken: TokenInfo; receiver: `0x${string}`; diff --git a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts index 8df9a522c1..334025cb58 100644 --- a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts +++ b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts @@ -1,4 +1,5 @@ import { fakeJson } from '@/__tests__/faker'; +import { FakeConfigurationService } from '@/config/__tests__/fake.configuration.service'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { IChainsRepository } from '@/domain/chains/chains.repository.interface'; import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; @@ -36,38 +37,39 @@ const mockChainsRepository = { } as jest.MockedObjectDeep; describe('TwapOrderMapper', () => { - let mapper: TwapOrderMapper; - - beforeEach(() => { - const multiSendDecoder = new MultiSendDecoder(); - const gpv2Decoder = new GPv2Decoder(mockLoggingService); - const allowedApps = new Set(); - const swapOrderHelper = new SwapOrderHelper( - multiSendDecoder, - gpv2Decoder, - mockTokenRepository, - mockSwapsRepository, - mockConfigurationService, - allowedApps, - mockChainsRepository, - ); - const composableCowDecoder = new ComposableCowDecoder(); - const gpv2OrderHelper = new GPv2OrderHelper(); - const twapOrderHelper = new TwapOrderHelper( - multiSendDecoder, - composableCowDecoder, - ); + const configurationService = new FakeConfigurationService(); + const multiSendDecoder = new MultiSendDecoder(); + const gpv2Decoder = new GPv2Decoder(mockLoggingService); + const allowedApps = new Set(); + const swapOrderHelper = new SwapOrderHelper( + multiSendDecoder, + gpv2Decoder, + mockTokenRepository, + mockSwapsRepository, + mockConfigurationService, + allowedApps, + mockChainsRepository, + ); + const composableCowDecoder = new ComposableCowDecoder(); + const gpv2OrderHelper = new GPv2OrderHelper(); + const twapOrderHelper = new TwapOrderHelper( + multiSendDecoder, + composableCowDecoder, + ); - mapper = new TwapOrderMapper( + it('should map a TWAP order', async () => { + configurationService.set('swaps.maxNumberOfParts', 2); + + // We instantiate in tests to be able to set maxNumberOfParts + const mapper = new TwapOrderMapper( + configurationService, swapOrderHelper, mockSwapsRepository, composableCowDecoder, gpv2OrderHelper, twapOrderHelper, ); - }); - it('should map a TWAP order', async () => { /** * @see https://sepolia.etherscan.io/address/0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74 */ @@ -223,4 +225,134 @@ describe('TwapOrderMapper', () => { validUntil: 1718291639, }); }); + + it('should map a TWAP order, up to a limit', async () => { + configurationService.set('swaps.maxNumberOfParts', 1); + + // We instantiate in tests to be able to set maxNumberOfParts + const mapper = new TwapOrderMapper( + configurationService, + swapOrderHelper, + mockSwapsRepository, + composableCowDecoder, + gpv2OrderHelper, + twapOrderHelper, + ); + + /** + * @see https://sepolia.etherscan.io/address/0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74 + */ + const chainId = '11155111'; + const owner = '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381'; + const data = + '0x0d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011f294a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000'; + const executionDate = new Date(1718288040000); + + const part2 = { + creationDate: '2024-06-13T14:44:02.307987Z', + owner: '0x31eac7f0141837b266de30f4dc9af15629bd5381', + uid: '0x557cb31a9dbbd23830c57d9fd3bbfc3694e942c161232b6cf696ba3bd11f9d6631eac7f0141837b266de30f4dc9af15629bd5381666b0cb7', + availableBalance: null, + executedBuyAmount: '687772850053823756', + executedSellAmount: '213586875483862141750', + executedSellAmountBeforeFees: '213586875483862141750', + executedFeeAmount: '0', + executedSurplusFee: '2135868754838621123', + invalidated: false, + status: 'fulfilled', + class: 'limit', + settlementContract: '0x9008d19f58aabd9ed0d60971565aa8510560ab41', + fullFeeAmount: '0', + solverFee: '0', + isLiquidityOrder: false, + fullAppData: + '{"appCode":"Safe Wallet Swaps","metadata":{"orderClass":{"orderClass":"twap"},"quote":{"slippageBips":1000},"widget":{"appCode":"CoW Swap-SafeApp","environment":"production"}},"version":"1.1.0"}', + sellToken: '0xbe72e441bf55620febc26715db68d3494213d8cb', + buyToken: '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', + receiver: '0x31eac7f0141837b266de30f4dc9af15629bd5381', + sellAmount: '213586875483862141750', + buyAmount: '611289510998251134', + validTo: 1718291639, + appData: + '0xf7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda0', + feeAmount: '0', + kind: 'sell', + partiallyFillable: false, + sellTokenBalance: 'erc20', + buyTokenBalance: 'erc20', + signingScheme: 'eip1271', + signature: + '0x5fd7e97ddaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230bd5a25ba2e97094ad7d83dc28a6572da797d6b3e7fc6663bd93efb789fc17e489000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000000180000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e00000000000000000000000000000000000000000000000000000000666b0cb7f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc90000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011f294a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000', + interactions: { pre: [], post: [] }, + } as unknown as Order; + + const buyToken = tokenBuilder().with('address', part2.buyToken).build(); + const sellToken = tokenBuilder().with('address', part2.sellToken).build(); + const fullAppData = JSON.parse(fakeJson()); + + mockSwapsRepository.getOrder.mockResolvedValueOnce(part2); + mockTokenRepository.getToken.mockImplementation(async ({ address }) => { + // We only need mock part1 addresses as all parts use the same tokens + switch (address) { + case part2.buyToken: { + return Promise.resolve(buyToken); + } + case part2.sellToken: { + return Promise.resolve(sellToken); + } + default: { + return Promise.reject(new Error(`Token not found: ${address}`)); + } + } + }); + mockSwapsRepository.getFullAppData.mockResolvedValue({ fullAppData }); + + const result = await mapper.mapTwapOrder(chainId, owner, { + data, + executionDate, + }); + + expect(result).toEqual({ + buyAmount: '1222579021996502268', + buyToken: { + address: buyToken.address, + decimals: buyToken.decimals, + logoUri: buyToken.logoUri, + name: buyToken.name, + symbol: buyToken.symbol, + trusted: buyToken.trusted, + }, + class: 'limit', + durationOfPart: { + durationType: 'AUTO', + }, + executedBuyAmount: null, + executedSellAmount: null, + fullAppData, + humanDescription: null, + kind: 'sell', + minPartLimit: '611289510998251134', + numberOfParts: 2, + orderStatus: 'fulfilled', + owner: '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381', + partSellAmount: '213586875483862141750', + receiver: '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381', + richDecodedInfo: null, + sellAmount: '427173750967724283500', + sellToken: { + address: sellToken.address, + decimals: sellToken.decimals, + logoUri: sellToken.logoUri, + name: sellToken.name, + symbol: sellToken.symbol, + trusted: sellToken.trusted, + }, + startTime: { + startType: 'AT_MINING_TIME', + }, + timeBetweenParts: '1800', + type: 'TwapOrder', + validUntil: 1718291639, + }); + }); }); diff --git a/src/routes/transactions/mappers/common/twap-order.mapper.ts b/src/routes/transactions/mappers/common/twap-order.mapper.ts index 6df2186473..1f05159478 100644 --- a/src/routes/transactions/mappers/common/twap-order.mapper.ts +++ b/src/routes/transactions/mappers/common/twap-order.mapper.ts @@ -24,17 +24,26 @@ import { ISwapsRepository } from '@/domain/swaps/swaps.repository'; import { SwapsRepositoryModule } from '@/domain/swaps/swaps-repository.module'; import { SwapOrderMapperModule } from '@/routes/transactions/mappers/common/swap-order.mapper'; import { GPv2OrderHelper } from '@/routes/transactions/helpers/gp-v2-order.helper'; +import { IConfigurationService } from '@/config/configuration.service.interface'; @Injectable() export class TwapOrderMapper { + private maxNumberOfParts: number; + constructor( + @Inject(IConfigurationService) + private readonly configurationService: IConfigurationService, private readonly swapOrderHelper: SwapOrderHelper, @Inject(ISwapsRepository) private readonly swapsRepository: ISwapsRepository, private readonly composableCowDecoder: ComposableCowDecoder, private readonly gpv2OrderHelper: GPv2OrderHelper, private readonly twapOrderHelper: TwapOrderHelper, - ) {} + ) { + this.maxNumberOfParts = this.configurationService.getOrThrow( + 'swaps.maxNumberOfParts', + ); + } /** * Maps a TWAP order from a given transaction @@ -53,18 +62,29 @@ export class TwapOrderMapper { const twapStruct = this.composableCowDecoder.decodeTwapStruct( transaction.data, ); + // Generate parts of the TWAP order - const parts = this.twapOrderHelper.generateTwapOrderParts({ + const twapParts = this.twapOrderHelper.generateTwapOrderParts({ twapStruct, executionDate: transaction.executionDate, chainId, }); + // There can be up to uint256 parts in a TWAP order so we limit this + // to avoid requesting too many orders + const hasAbundantParts = twapParts.length > this.maxNumberOfParts; + + const partsToFetch = hasAbundantParts + ? // We use the last part (and only one) to get the status of the entire + // order and we only need one to get the token info + twapParts.slice(-1) + : twapParts; + const [{ fullAppData }, ...orders] = await Promise.all([ // Decode hash of `appData` this.swapsRepository.getFullAppData(chainId, twapStruct.appData), // Fetch all order parts - ...parts.map((order) => { + ...partsToFetch.map((order) => { const orderUid = this.gpv2OrderHelper.computeOrderUid({ chainId, owner: safeAddress, @@ -74,6 +94,12 @@ export class TwapOrderMapper { }), ]); + const executedSellAmount: TwapOrderInfo['executedSellAmount'] = + hasAbundantParts ? null : this.getExecutedSellAmount(orders).toString(); + + const executedBuyAmount: TwapOrderInfo['executedBuyAmount'] = + hasAbundantParts ? null : this.getExecutedBuyAmount(orders).toString(); + // All orders have the same sellToken and buyToken const { sellToken, buyToken } = orders[0]; @@ -86,11 +112,11 @@ export class TwapOrderMapper { orderStatus: this.getOrderStatus(orders), kind: OrderKind.Sell, class: OrderClass.Limit, - validUntil: Math.max(...parts.map((order) => order.validTo)), + validUntil: Math.max(...partsToFetch.map((order) => order.validTo)), sellAmount: sellAmount.toString(), buyAmount: buyAmount.toString(), - executedSellAmount: this.getExecutedSellAmount(orders).toString(), - executedBuyAmount: this.getExecutedBuyAmount(orders).toString(), + executedSellAmount, + executedBuyAmount, sellToken: new TokenInfo({ address: sellToken.address, decimals: sellToken.decimals, From c18e46ecde1aeeae0d122d23f6e70865084024f4 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Wed, 19 Jun 2024 15:36:23 +0200 Subject: [PATCH 111/207] Fix mock `kind` of `Order` builder (#1677) Fixeds `Order` builder to allow all `OrderKind`s for mocked `kind` values. --- src/domain/swaps/entities/__tests__/order.builder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/swaps/entities/__tests__/order.builder.ts b/src/domain/swaps/entities/__tests__/order.builder.ts index c88fcdac66..590fcb9953 100644 --- a/src/domain/swaps/entities/__tests__/order.builder.ts +++ b/src/domain/swaps/entities/__tests__/order.builder.ts @@ -31,7 +31,7 @@ export function orderBuilder(): IBuilder { }), ) .with('feeAmount', faker.number.bigInt({ min: 1 })) - .with('kind', faker.helpers.arrayElement([OrderKind.Buy, OrderKind.Buy])) + .with('kind', faker.helpers.arrayElement([OrderKind.Buy, OrderKind.Sell])) .with('partiallyFillable', faker.datatype.boolean()) .with( 'sellTokenBalance', From b946a4febd45ad648d1ef2182aaed251176de5a2 Mon Sep 17 00:00:00 2001 From: Daniel Dimitrov Date: Thu, 20 Jun 2024 14:52:48 +0200 Subject: [PATCH 112/207] change orderStatus to status and export type to openAPI (#1678) * fix: rename orderStatus to status This aligns the TwapOrderInfo type better to SwapOrderInfo type. * feat: export TwapOrderTransactionInfo for openAPI --- .../entities/swaps/twap-order-info.entity.ts | 11 +++++++---- .../transactions/entities/transaction.entity.ts | 3 +++ .../mappers/common/twap-order.mapper.spec.ts | 4 ++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/routes/transactions/entities/swaps/twap-order-info.entity.ts b/src/routes/transactions/entities/swaps/twap-order-info.entity.ts index 34a2223d82..de0cb00e3c 100644 --- a/src/routes/transactions/entities/swaps/twap-order-info.entity.ts +++ b/src/routes/transactions/entities/swaps/twap-order-info.entity.ts @@ -33,7 +33,7 @@ type StartTime = | { startType: StartTimeValue.AtEpoch; epoch: number }; export type TwapOrderInfo = { - orderStatus: OrderStatus; + status: OrderStatus; kind: OrderKind.Sell; class: OrderClass.Limit; validUntil: number; @@ -61,8 +61,11 @@ export class TwapOrderTransactionInfo @ApiProperty({ enum: [TransactionInfoType.TwapOrder] }) override type = TransactionInfoType.TwapOrder; - @ApiProperty({ description: 'The TWAP status' }) - orderStatus: OrderStatus; + @ApiProperty({ + description: 'The TWAP status', + enum: OrderStatus, + }) + status: OrderStatus; @ApiProperty({ enum: OrderKind }) kind: OrderKind.Sell; @@ -172,7 +175,7 @@ export class TwapOrderTransactionInfo startTime: StartTime; }) { super(TransactionInfoType.SwapOrder, null, null); - this.orderStatus = args.orderStatus; + this.status = args.orderStatus; this.kind = args.kind; this.class = args.class; this.validUntil = args.validUntil; diff --git a/src/routes/transactions/entities/transaction.entity.ts b/src/routes/transactions/entities/transaction.entity.ts index b0fed02a94..76fca02fc3 100644 --- a/src/routes/transactions/entities/transaction.entity.ts +++ b/src/routes/transactions/entities/transaction.entity.ts @@ -14,6 +14,7 @@ import { SettingsChangeTransaction } from '@/routes/transactions/entities/settin import { TransactionInfo } from '@/routes/transactions/entities/transaction-info.entity'; import { TransferTransactionInfo } from '@/routes/transactions/entities/transfer-transaction-info.entity'; import { SwapOrderTransactionInfo } from '@/routes/transactions/entities/swaps/swap-order-info.entity'; +import { TwapOrderTransactionInfo } from '@/routes/transactions/entities/swaps/twap-order-info.entity'; @ApiExtraModels( CreationTransactionInfo, @@ -23,6 +24,7 @@ import { SwapOrderTransactionInfo } from '@/routes/transactions/entities/swaps/s ModuleExecutionInfo, MultisigExecutionInfo, SwapOrderTransactionInfo, + TwapOrderTransactionInfo, ) export class Transaction { @ApiProperty() @@ -39,6 +41,7 @@ export class Transaction { { $ref: getSchemaPath(CustomTransactionInfo) }, { $ref: getSchemaPath(SettingsChangeTransaction) }, { $ref: getSchemaPath(SwapOrderTransactionInfo) }, + { $ref: getSchemaPath(TwapOrderTransactionInfo) }, { $ref: getSchemaPath(TransferTransactionInfo) }, ], }) diff --git a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts index 334025cb58..7c3dabcffd 100644 --- a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts +++ b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts @@ -203,7 +203,7 @@ describe('TwapOrderMapper', () => { kind: 'sell', minPartLimit: '611289510998251134', numberOfParts: 2, - orderStatus: 'fulfilled', + status: 'fulfilled', owner: '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381', partSellAmount: '213586875483862141750', receiver: '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381', @@ -333,7 +333,7 @@ describe('TwapOrderMapper', () => { kind: 'sell', minPartLimit: '611289510998251134', numberOfParts: 2, - orderStatus: 'fulfilled', + status: 'fulfilled', owner: '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381', partSellAmount: '213586875483862141750', receiver: '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381', From da262d073bb5a37228d75317a8d120eaf3832077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Thu, 20 Jun 2024 15:24:57 +0200 Subject: [PATCH 113/207] Add automatic row timestamps for Accounts and Groups tables (#1674) Add automatic row timestamps for Accounts and Groups tables --- migrations/00001_accounts/index.sql | 25 +++++- migrations/__tests__/00001_accounts.spec.ts | 81 ++++++++++++++++++- .../accounts/accounts.datasource.spec.ts | 4 + .../entities/__tests__/account.builder.ts | 4 +- .../entities/__tests__/group.builder.ts | 5 +- .../accounts/entities/account.entity.spec.ts | 14 ++++ .../accounts/entities/account.entity.ts | 4 +- .../accounts/entities/group.entity.spec.ts | 14 ++++ .../accounts/entities/group.entity.ts | 5 +- src/datasources/db/entities/row.entity.ts | 14 ++++ .../db/postgres-database.migrator.spec.ts | 15 ++-- .../db/postgres-database.migrator.ts | 29 ++++--- 12 files changed, 184 insertions(+), 30 deletions(-) create mode 100644 src/datasources/db/entities/row.entity.ts diff --git a/migrations/00001_accounts/index.sql b/migrations/00001_accounts/index.sql index de08273229..13a6c40534 100644 --- a/migrations/00001_accounts/index.sql +++ b/migrations/00001_accounts/index.sql @@ -2,12 +2,31 @@ DROP TABLE IF EXISTS groups, accounts CASCADE; CREATE TABLE - groups (id SERIAL PRIMARY KEY); + groups ( + id SERIAL PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() + ); CREATE TABLE accounts ( id SERIAL PRIMARY KEY, - group_id INTEGER REFERENCES groups (id), + group_id INTEGER REFERENCES groups (id) ON DELETE SET NULL, address CHARACTER VARYING(42) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), UNIQUE (address) - ); \ No newline at end of file + ); + +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE TRIGGER update_accounts_updated_at +BEFORE UPDATE ON accounts +FOR EACH ROW +EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file diff --git a/migrations/__tests__/00001_accounts.spec.ts b/migrations/__tests__/00001_accounts.spec.ts index e4f685eebf..16353c94c2 100644 --- a/migrations/__tests__/00001_accounts.spec.ts +++ b/migrations/__tests__/00001_accounts.spec.ts @@ -2,10 +2,22 @@ import { dbFactory } from '@/__tests__/db.factory'; import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; import { Sql } from 'postgres'; +interface AccountRow { + id: number; + group_id: number; + created_at: Date; + updated_at: Date; + address: `0x${string}`; +} + describe('Migration 00001_accounts', () => { const sql = dbFactory(); const migrator = new PostgresDatabaseMigrator(sql); + afterAll(async () => { + await sql.end(); + }); + it('runs successfully', async () => { await sql`DROP TABLE IF EXISTS groups, accounts CASCADE;`; @@ -32,20 +44,81 @@ describe('Migration 00001_accounts', () => { columns: [ { column_name: 'id' }, { column_name: 'group_id' }, + { column_name: 'created_at' }, + { column_name: 'updated_at' }, { column_name: 'address' }, ], rows: [], }, groups: { columns: [ - { - column_name: 'id', - }, + { column_name: 'id' }, + { column_name: 'created_at' }, + { column_name: 'updated_at' }, ], rows: [], }, }); + }); - await sql.end(); + it('should add and update row timestamps', async () => { + await sql`DROP TABLE IF EXISTS groups, accounts CASCADE;`; + + const result: { + before: unknown; + after: AccountRow[]; + } = await migrator.test({ + migration: '00001_accounts', + after: async (sql: Sql): Promise => { + await sql`INSERT INTO groups (id) VALUES (1);`; + await sql`INSERT INTO accounts (id, group_id, address) VALUES (1, 1, '0x0000');`; + await sql`UPDATE accounts set address = '0x0001' WHERE id = 1;`; + return await sql`SELECT * FROM accounts`; + }, + }); + + const createdAt = new Date(result.after[0].created_at); + const updatedAt = new Date(result.after[0].updated_at); + + expect(result.after).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ + created_at: createdAt, + updated_at: updatedAt, + }), + ]), + ); + + expect(updatedAt.getTime()).toBeGreaterThan(createdAt.getTime()); + }); + + it('only updated_at should be updated on row changes', async () => { + await sql`DROP TABLE IF EXISTS groups, accounts CASCADE;`; + + const result: { + before: unknown; + after: AccountRow[]; + } = await migrator.test({ + migration: '00001_accounts', + after: async (sql: Sql): Promise => { + await sql`INSERT INTO groups (id) VALUES (1);`; + await sql`INSERT INTO accounts (id, group_id, address) VALUES (1, 1, '0x0000');`; + return await sql`SELECT * FROM accounts`; + }, + }); + + // created_at and updated_at should be the same after the row is created + const createdAt = new Date(result.after[0].created_at); + const updatedAt = new Date(result.after[0].updated_at); + expect(createdAt).toStrictEqual(updatedAt); + + // only updated_at should be updated after the row is updated + await sql`UPDATE accounts set address = '0x0001' WHERE id = 1;`; + const afterUpdate = await sql`SELECT * FROM accounts`; + const updatedAtAfterUpdate = new Date(afterUpdate[0].updated_at); + const createdAtAfterUpdate = new Date(afterUpdate[0].created_at); + + expect(createdAtAfterUpdate).toStrictEqual(createdAt); + expect(updatedAtAfterUpdate.getTime()).toBeGreaterThan(createdAt.getTime()); }); }); diff --git a/src/datasources/accounts/accounts.datasource.spec.ts b/src/datasources/accounts/accounts.datasource.spec.ts index 4c42803b34..fbe1f037a0 100644 --- a/src/datasources/accounts/accounts.datasource.spec.ts +++ b/src/datasources/accounts/accounts.datasource.spec.ts @@ -43,6 +43,8 @@ describe('AccountsDatasource tests', () => { id: expect.any(Number), group_id: null, address, + created_at: expect.any(Date), + updated_at: expect.any(Date), }); }); @@ -67,6 +69,8 @@ describe('AccountsDatasource tests', () => { id: expect.any(Number), group_id: null, address, + created_at: expect.any(Date), + updated_at: expect.any(Date), }); }); diff --git a/src/datasources/accounts/entities/__tests__/account.builder.ts b/src/datasources/accounts/entities/__tests__/account.builder.ts index 5faf6b1dd4..d1047c90b2 100644 --- a/src/datasources/accounts/entities/__tests__/account.builder.ts +++ b/src/datasources/accounts/entities/__tests__/account.builder.ts @@ -7,5 +7,7 @@ export function accountBuilder(): IBuilder { return new Builder() .with('id', faker.number.int()) .with('group_id', faker.number.int()) - .with('address', getAddress(faker.finance.ethereumAddress())); + .with('address', getAddress(faker.finance.ethereumAddress())) + .with('created_at', faker.date.recent()) + .with('updated_at', faker.date.recent()); } diff --git a/src/datasources/accounts/entities/__tests__/group.builder.ts b/src/datasources/accounts/entities/__tests__/group.builder.ts index aa2806160f..06bc7f59ca 100644 --- a/src/datasources/accounts/entities/__tests__/group.builder.ts +++ b/src/datasources/accounts/entities/__tests__/group.builder.ts @@ -3,5 +3,8 @@ import { Group } from '@/datasources/accounts/entities/group.entity'; import { faker } from '@faker-js/faker'; export function groupBuilder(): IBuilder { - return new Builder().with('id', faker.number.int()); + return new Builder() + .with('id', faker.number.int()) + .with('created_at', faker.date.recent()) + .with('updated_at', faker.date.recent()); } diff --git a/src/datasources/accounts/entities/account.entity.spec.ts b/src/datasources/accounts/entities/account.entity.spec.ts index dbfd3c7655..7f4637bf94 100644 --- a/src/datasources/accounts/entities/account.entity.spec.ts +++ b/src/datasources/accounts/entities/account.entity.spec.ts @@ -95,6 +95,20 @@ describe('AccountSchema', () => { path: ['id'], received: 'undefined', }, + { + code: 'invalid_type', + expected: 'date', + message: 'Required', + path: ['created_at'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'date', + message: 'Required', + path: ['updated_at'], + received: 'undefined', + }, { code: 'invalid_type', expected: 'number', diff --git a/src/datasources/accounts/entities/account.entity.ts b/src/datasources/accounts/entities/account.entity.ts index e3a428d390..32feba9217 100644 --- a/src/datasources/accounts/entities/account.entity.ts +++ b/src/datasources/accounts/entities/account.entity.ts @@ -1,11 +1,11 @@ import { GroupSchema } from '@/datasources/accounts/entities/group.entity'; +import { RowSchema } from '@/datasources/db/entities/row.entity'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; import { z } from 'zod'; export type Account = z.infer; -export const AccountSchema = z.object({ - id: z.number().int(), +export const AccountSchema = RowSchema.extend({ group_id: GroupSchema.shape.id, address: AddressSchema, }); diff --git a/src/datasources/accounts/entities/group.entity.spec.ts b/src/datasources/accounts/entities/group.entity.spec.ts index a22ad49108..0a10d22cc1 100644 --- a/src/datasources/accounts/entities/group.entity.spec.ts +++ b/src/datasources/accounts/entities/group.entity.spec.ts @@ -60,6 +60,20 @@ describe('GroupSchema', () => { path: ['id'], received: 'undefined', }, + { + code: 'invalid_type', + expected: 'date', + message: 'Required', + path: ['created_at'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'date', + message: 'Required', + path: ['updated_at'], + received: 'undefined', + }, ]); }); }); diff --git a/src/datasources/accounts/entities/group.entity.ts b/src/datasources/accounts/entities/group.entity.ts index 3e38dbc310..003eedd677 100644 --- a/src/datasources/accounts/entities/group.entity.ts +++ b/src/datasources/accounts/entities/group.entity.ts @@ -1,7 +1,6 @@ +import { RowSchema } from '@/datasources/db/entities/row.entity'; import { z } from 'zod'; export type Group = z.infer; -export const GroupSchema = z.object({ - id: z.number().int(), -}); +export const GroupSchema = RowSchema; diff --git a/src/datasources/db/entities/row.entity.ts b/src/datasources/db/entities/row.entity.ts new file mode 100644 index 0000000000..3fb6cdbe49 --- /dev/null +++ b/src/datasources/db/entities/row.entity.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export type Row = z.infer; + +/** + * Note: this is a base schema for all entities that are meant to be persisted to the database. + * The 'id' field is a primary key, and the 'created_at' and 'updated_at' fields are timestamps. + * These fields shouldn't be modified by the application, and should be managed by the database. + */ +export const RowSchema = z.object({ + id: z.number().int(), + created_at: z.date(), + updated_at: z.date(), +}); diff --git a/src/datasources/db/postgres-database.migrator.spec.ts b/src/datasources/db/postgres-database.migrator.spec.ts index 92de93a09d..c7dd34459c 100644 --- a/src/datasources/db/postgres-database.migrator.spec.ts +++ b/src/datasources/db/postgres-database.migrator.spec.ts @@ -45,6 +45,8 @@ const migrations: Array<{ }, }, ]; +type TestRow = { a: string; b: number }; +type ExtendedTestRow = { a: string; b: number; c: Date }; describe('PostgresDatabaseMigrator tests', () => { let sql: postgres.Sql; @@ -196,8 +198,9 @@ describe('PostgresDatabaseMigrator tests', () => { await expect( target.test({ migration: migration1.name, - before: (sql) => sql`SELECT * FROM test`, - after: (sql) => sql`SELECT * FROM test`, + before: (sql) => sql`SELECT * FROM test`.catch(() => undefined), + after: (sql): Promise => + sql`SELECT * FROM test`, folder, }), ).resolves.toStrictEqual({ @@ -227,8 +230,10 @@ describe('PostgresDatabaseMigrator tests', () => { await expect( target.test({ migration: migration2.name, - before: (sql) => sql`SELECT * FROM test`, - after: (sql) => sql`SELECT * FROM test`, + before: (sql): Promise => + sql`SELECT * FROM test`, + after: (sql): Promise => + sql`SELECT * FROM test`, folder, }), ).resolves.toStrictEqual({ @@ -265,7 +270,7 @@ describe('PostgresDatabaseMigrator tests', () => { target.test({ migration: migration3.name, before: (sql) => sql`SELECT * FROM test`, - after: (sql) => sql`SELECT * FROM test`, + after: (sql) => sql`SELECT * FROM test`.catch(() => undefined), folder, }), ).resolves.toStrictEqual({ diff --git a/src/datasources/db/postgres-database.migrator.ts b/src/datasources/db/postgres-database.migrator.ts index d2e32c8f3d..0f7d67bffe 100644 --- a/src/datasources/db/postgres-database.migrator.ts +++ b/src/datasources/db/postgres-database.migrator.ts @@ -9,6 +9,11 @@ type Migration = { name: string; }; +type TestResult = { + before: BeforeType | undefined; + after: AfterType; +}; + /** * Migrates a Postgres database using SQL and JavaScript files. * @@ -51,7 +56,8 @@ export class PostgresDatabaseMigrator { } /** - * @private migrates up to/allows for querying before/after migration to test it. + * Migrates up to/allows for querying before/after migration to test it. + * Uses generics to allow the caller to specify the return type of the before/after functions. * * Note: each migration is ran in separate transaction to allow queries in between. * @@ -72,15 +78,12 @@ export class PostgresDatabaseMigrator { * expect(result.after).toStrictEqual(expected); * ``` */ - async test(args: { + async test(args: { migration: string; - before?: (sql: Sql) => Promise; - after: (sql: Sql) => Promise; + before?: (sql: Sql) => Promise; + after: (sql: Sql) => Promise; folder?: string; - }): Promise<{ - before: unknown; - after: unknown; - }> { + }): Promise> { const migrations = this.getMigrations( args.folder ?? PostgresDatabaseMigrator.MIGRATIONS_FOLDER, ); @@ -97,13 +100,15 @@ export class PostgresDatabaseMigrator { // Get migrations up to the specified migration const migrationsToTest = migrations.slice(0, migrationIndex + 1); - let before: unknown; + let before: BeforeType | undefined; for await (const migration of migrationsToTest) { const isMigrationBeingTested = migration.path.includes(args.migration); if (isMigrationBeingTested && args.before) { - before = await args.before(this.sql).catch(() => undefined); + before = await args.before(this.sql).catch((err) => { + throw Error(`Error running before function: ${err}`); + }); } await this.sql.begin((transaction) => { @@ -111,7 +116,9 @@ export class PostgresDatabaseMigrator { }); } - const after = await args.after(this.sql).catch(() => undefined); + const after = await args.after(this.sql).catch((err) => { + throw Error(`Error running after function: ${err}`); + }); return { before, after }; } From 314182ce8cd614206b757ba69a13f3d9ebe48b78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Fri, 21 Jun 2024 17:25:26 +0200 Subject: [PATCH 114/207] Add additional debug logging (#1680) --- .../entities/__tests__/configuration.ts | 2 +- src/config/entities/configuration.ts | 3 +- .../cache/cache.first.data.source.spec.ts | 2 +- .../cache/cache.first.data.source.ts | 42 +++++++++++++++---- 4 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index d605767183..111e79e878 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -110,7 +110,7 @@ export default (): ReturnType => ({ zerionBalancesChainIds: ['137'], swapsDecoding: true, twapsDecoding: true, - historyDebugLogs: false, + debugLogs: false, imitationMapping: false, auth: false, confirmationView: false, diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index aaccc5e4a6..9e11bc6102 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -164,8 +164,7 @@ export default () => ({ process.env.FF_ZERION_BALANCES_CHAIN_IDS?.split(',') ?? [], swapsDecoding: process.env.FF_SWAPS_DECODING?.toLowerCase() === 'true', twapsDecoding: process.env.FF_TWAPS_DECODING?.toLowerCase() === 'true', - historyDebugLogs: - process.env.FF_HISTORY_DEBUG_LOGS?.toLowerCase() === 'true', + debugLogs: process.env.FF_DEBUG_LOGS?.toLowerCase() === 'true', imitationMapping: process.env.FF_IMITATION_MAPPING?.toLowerCase() === 'true', auth: process.env.FF_AUTH?.toLowerCase() === 'true', diff --git a/src/datasources/cache/cache.first.data.source.spec.ts b/src/datasources/cache/cache.first.data.source.spec.ts index a3ba79e038..2066ff10b2 100644 --- a/src/datasources/cache/cache.first.data.source.spec.ts +++ b/src/datasources/cache/cache.first.data.source.spec.ts @@ -32,7 +32,7 @@ describe('CacheFirstDataSource', () => { jest.useFakeTimers(); fakeCacheService = new FakeCacheService(); fakeConfigurationService = new FakeConfigurationService(); - fakeConfigurationService.set('features.historyDebugLogs', true); + fakeConfigurationService.set('features.debugLogs', true); cacheFirstDataSource = new CacheFirstDataSource( fakeCacheService, mockNetworkService, diff --git a/src/datasources/cache/cache.first.data.source.ts b/src/datasources/cache/cache.first.data.source.ts index 7068bc0e2a..9cd3d6e318 100644 --- a/src/datasources/cache/cache.first.data.source.ts +++ b/src/datasources/cache/cache.first.data.source.ts @@ -21,6 +21,7 @@ import { } from '@/domain/safe/entities/transaction.entity'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { isArray } from 'lodash'; +import { Safe } from '@/domain/safe/entities/safe.entity'; /** * A data source which tries to retrieve values from cache using @@ -33,7 +34,7 @@ import { isArray } from 'lodash'; */ @Injectable() export class CacheFirstDataSource { - private readonly isHistoryDebugLogsEnabled: boolean; + private readonly areDebugLogsEnabled: boolean; constructor( @Inject(CacheService) private readonly cacheService: ICacheService, @@ -42,10 +43,8 @@ export class CacheFirstDataSource { @Inject(IConfigurationService) private readonly configurationService: IConfigurationService, ) { - this.isHistoryDebugLogsEnabled = - this.configurationService.getOrThrow( - 'features.historyDebugLogs', - ); + this.areDebugLogsEnabled = + this.configurationService.getOrThrow('features.debugLogs'); } /** @@ -132,8 +131,9 @@ export class CacheFirstDataSource { // TODO: transient logging for debugging if ( - this.isHistoryDebugLogsEnabled && - args.url.includes('all-transactions') + this.areDebugLogsEnabled && + (args.url.includes('all-transactions') || + args.url.includes('multisig-transactions')) ) { this.logTransactionsCacheWrite( startTimeMs, @@ -141,6 +141,14 @@ export class CacheFirstDataSource { data as Page, ); } + + if (this.areDebugLogsEnabled && args.cacheDir.key.includes('_safe_')) { + this.logSafeMetadataCacheWrite( + startTimeMs, + args.cacheDir, + data as Safe, + ); + } } return data; } @@ -237,4 +245,24 @@ export class CacheFirstDataSource { }), }); } + + /** + * Logs the Safe metadata retrieved. + * NOTE: this is a debugging-only function. + * TODO: remove this function after debugging. + */ + private logSafeMetadataCacheWrite( + requestStartTime: number, + cacheDir: CacheDir, + safe: Safe, + ): void { + this.loggingService.info({ + type: 'cache_write', + cacheKey: cacheDir.key, + cacheField: cacheDir.field, + cacheWriteTime: new Date(), + requestStartTime: new Date(requestStartTime), + safe, + }); + } } From ed740aed8bb1ef1d2e55090046239b18aad7a3cd Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 24 Jun 2024 10:26:34 +0200 Subject: [PATCH 115/207] Add method to decode order from `settle` (#1673) Adds a new `decodeOrderFromSettle` method to the `GPv2Decoder` that extracts order information from the `trade` passed to a `settle` call: - Add full GPv2Settlement ABI to `GPv2Decoder - Modify existing methods to work with full ABI - Integrate relevant logic from CoW SDK - Add appropriate test coverage --- .../encoders/gp-v2-encoder.builder.ts | 4 +- .../decoders/gp-v2-decoder.helper.spec.ts | 29 + .../decoders/gp-v2-decoder.helper.ts | 767 +++++++++++++++++- .../helpers/gp-v2-order.helper.ts | 28 +- .../transactions/helpers/twap-order.helper.ts | 2 +- .../mappers/common/twap-order.mapper.spec.ts | 5 +- 6 files changed, 793 insertions(+), 42 deletions(-) diff --git a/src/domain/swaps/contracts/__tests__/encoders/gp-v2-encoder.builder.ts b/src/domain/swaps/contracts/__tests__/encoders/gp-v2-encoder.builder.ts index 0611c7f4f2..b34df9fe54 100644 --- a/src/domain/swaps/contracts/__tests__/encoders/gp-v2-encoder.builder.ts +++ b/src/domain/swaps/contracts/__tests__/encoders/gp-v2-encoder.builder.ts @@ -2,7 +2,7 @@ import { faker } from '@faker-js/faker'; import { encodeFunctionData, Hex, keccak256, toBytes } from 'viem'; import { Builder } from '@/__tests__/builder'; import { IEncoder } from '@/__tests__/encoder-builder'; -import { abi } from '@/domain/swaps/contracts/decoders/gp-v2-decoder.helper'; +import { GPv2Abi } from '@/domain/swaps/contracts/decoders/gp-v2-decoder.helper'; type SetPreSignatureArgs = { orderUid: `0x${string}`; @@ -17,7 +17,7 @@ class SetPreSignatureEncoder const args = this.build(); return encodeFunctionData({ - abi, + abi: GPv2Abi, functionName: 'setPreSignature', args: [args.orderUid, args.signed], }); diff --git a/src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.spec.ts b/src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.spec.ts index 66ae15bd82..5e035cf171 100644 --- a/src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.spec.ts +++ b/src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.spec.ts @@ -33,4 +33,33 @@ describe('GPv2Decoder', () => { expect(() => target.decodeFunctionData({ data })).toThrow(); }); + + // TODO: Add test - should've been added in first swaps integration + it.todo('gets orderUid from setPreSignature function call'); + + it('should decode an order from a settle function call', () => { + /** + * @see https://sepolia.etherscan.io/tx/0x481d710c69dab0215213e50085c30d7e632e3135c25c1f05065e9dc875f87121 + */ + const data = + '0x13d79a0b0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000008600000000000000000000000000000000000000000000000000000000000000004000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb00000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000358c1113177ebd5000000000000000000000000000000000000000000000003f5106b282be12c4d000000000000000000000000000000000000000000000003f5106b282be12c4d00000000000000000000000000000000000000000000000003782dace9d90000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000300000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000003782dace9d90000000000000000000000000000000000000000000000000003b1b5fbf83bf2f71600000000000000000000000000000000000000000000000000000000666af0a3f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000003782dace9d90000000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000004d831eac7f0141837b266de30f4dc9af15629bd53815fd7e97ddaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230bd5a25ba2e97094ad7d83dc28a6572da797d6b3e7fc6663bd93efb789fc17e489000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000000180000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb00000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000003782dace9d90000000000000000000000000000000000000000000000000003b1b5fbf83bf2f71600000000000000000000000000000000000000000000000000000000666af0a3f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc90000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011918e600000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb00000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000003782dace9d90000000000000000000000000000000000000000000000000003b1b5fbf83bf2f7160000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000003840000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000026000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000086dcd3293c53cf8efd7303b57beb2a3f671dde980000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000001048803dbee0000000000000000000000000000000000000000000000041a38b8642795b4e400000000000000000000000000000000000000000000000003810f6985c9400000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000009008d19f58aabd9ed0d60971565aa8510560ab41ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b83733'; + + const order = target.decodeOrderFromSettle(data); + + expect(order).toStrictEqual({ + appData: + '0xf7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda0', + buyAmount: BigInt('68145650380202768150'), + buyToken: '0xbe72E441BF55620febc26715db68d3494213D8Cb', + buyTokenBalance: 'erc20', + feeAmount: BigInt('0'), + kind: 'sell', + partiallyFillable: false, + receiver: '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381', + sellAmount: BigInt('250000000000000000'), + sellToken: '0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14', + sellTokenBalance: 'erc20', + validTo: 1718284451, + }); + }); }); diff --git a/src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.ts b/src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.ts index 9381302d46..59af6b83c7 100644 --- a/src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.ts +++ b/src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.ts @@ -1,18 +1,654 @@ import { Inject, Injectable, Module } from '@nestjs/common'; import { AbiDecoder } from '@/domain/contracts/decoders/abi-decoder.helper'; -import { parseAbi } from 'viem'; import { ILoggingService, LoggingService } from '@/logging/logging.interface'; +import { + BuyTokenBalance, + OrderKind, + SellTokenBalance, +} from '@/domain/swaps/entities/order.entity'; -export const abi = parseAbi([ - 'function setPreSignature(bytes calldata orderUid, bool signed)', -]); +/** + * Taken from CoW contracts: + * + * @see https://github.com/cowprotocol/contracts/blob/1465e69f6935b3ef9ce45d4878e44f0335ef8531/deployments/arbitrumOne/GPv2Settlement.json + * + * TODO: We should locate this in @/abis/... but we will need to refactor the /scripts/generate-abis.js + * to handle ABIs that are present (or alternatively install the @cowprotocol/contracts package and generate + * the ABIs from there) + */ +export const GPv2Abi = [ + { + inputs: [ + { + internalType: 'contract GPv2Authentication', + name: 'authenticator_', + type: 'address', + }, + { + internalType: 'contract IVault', + name: 'vault_', + type: 'address', + }, + ], + stateMutability: 'nonpayable', + type: 'constructor', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'target', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'value', + type: 'uint256', + }, + { + indexed: false, + internalType: 'bytes4', + name: 'selector', + type: 'bytes4', + }, + ], + name: 'Interaction', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + indexed: false, + internalType: 'bytes', + name: 'orderUid', + type: 'bytes', + }, + ], + name: 'OrderInvalidated', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + indexed: false, + internalType: 'bytes', + name: 'orderUid', + type: 'bytes', + }, + { + indexed: false, + internalType: 'bool', + name: 'signed', + type: 'bool', + }, + ], + name: 'PreSignature', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'solver', + type: 'address', + }, + ], + name: 'Settlement', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'owner', + type: 'address', + }, + { + indexed: false, + internalType: 'contract IERC20', + name: 'sellToken', + type: 'address', + }, + { + indexed: false, + internalType: 'contract IERC20', + name: 'buyToken', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'sellAmount', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint256', + name: 'buyAmount', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint256', + name: 'feeAmount', + type: 'uint256', + }, + { + indexed: false, + internalType: 'bytes', + name: 'orderUid', + type: 'bytes', + }, + ], + name: 'Trade', + type: 'event', + }, + { + inputs: [], + name: 'authenticator', + outputs: [ + { + internalType: 'contract GPv2Authentication', + name: '', + type: 'address', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'domainSeparator', + outputs: [ + { + internalType: 'bytes32', + name: '', + type: 'bytes32', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'bytes', + name: '', + type: 'bytes', + }, + ], + name: 'filledAmount', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'bytes[]', + name: 'orderUids', + type: 'bytes[]', + }, + ], + name: 'freeFilledAmountStorage', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'bytes[]', + name: 'orderUids', + type: 'bytes[]', + }, + ], + name: 'freePreSignatureStorage', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'uint256', + name: 'offset', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'length', + type: 'uint256', + }, + ], + name: 'getStorageAt', + outputs: [ + { + internalType: 'bytes', + name: '', + type: 'bytes', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'bytes', + name: 'orderUid', + type: 'bytes', + }, + ], + name: 'invalidateOrder', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'bytes', + name: '', + type: 'bytes', + }, + ], + name: 'preSignature', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'bytes', + name: 'orderUid', + type: 'bytes', + }, + { + internalType: 'bool', + name: 'signed', + type: 'bool', + }, + ], + name: 'setPreSignature', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'contract IERC20[]', + name: 'tokens', + type: 'address[]', + }, + { + internalType: 'uint256[]', + name: 'clearingPrices', + type: 'uint256[]', + }, + { + components: [ + { + internalType: 'uint256', + name: 'sellTokenIndex', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'buyTokenIndex', + type: 'uint256', + }, + { + internalType: 'address', + name: 'receiver', + type: 'address', + }, + { + internalType: 'uint256', + name: 'sellAmount', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'buyAmount', + type: 'uint256', + }, + { + internalType: 'uint32', + name: 'validTo', + type: 'uint32', + }, + { + internalType: 'bytes32', + name: 'appData', + type: 'bytes32', + }, + { + internalType: 'uint256', + name: 'feeAmount', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'flags', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'executedAmount', + type: 'uint256', + }, + { + internalType: 'bytes', + name: 'signature', + type: 'bytes', + }, + ], + internalType: 'struct GPv2Trade.Data[]', + name: 'trades', + type: 'tuple[]', + }, + { + components: [ + { + internalType: 'address', + name: 'target', + type: 'address', + }, + { + internalType: 'uint256', + name: 'value', + type: 'uint256', + }, + { + internalType: 'bytes', + name: 'callData', + type: 'bytes', + }, + ], + internalType: 'struct GPv2Interaction.Data[][3]', + name: 'interactions', + type: 'tuple[][3]', + }, + ], + name: 'settle', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'targetContract', + type: 'address', + }, + { + internalType: 'bytes', + name: 'calldataPayload', + type: 'bytes', + }, + ], + name: 'simulateDelegatecall', + outputs: [ + { + internalType: 'bytes', + name: 'response', + type: 'bytes', + }, + ], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'targetContract', + type: 'address', + }, + { + internalType: 'bytes', + name: 'calldataPayload', + type: 'bytes', + }, + ], + name: 'simulateDelegatecallInternal', + outputs: [ + { + internalType: 'bytes', + name: 'response', + type: 'bytes', + }, + ], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + components: [ + { + internalType: 'bytes32', + name: 'poolId', + type: 'bytes32', + }, + { + internalType: 'uint256', + name: 'assetInIndex', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'assetOutIndex', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + { + internalType: 'bytes', + name: 'userData', + type: 'bytes', + }, + ], + internalType: 'struct IVault.BatchSwapStep[]', + name: 'swaps', + type: 'tuple[]', + }, + { + internalType: 'contract IERC20[]', + name: 'tokens', + type: 'address[]', + }, + { + components: [ + { + internalType: 'uint256', + name: 'sellTokenIndex', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'buyTokenIndex', + type: 'uint256', + }, + { + internalType: 'address', + name: 'receiver', + type: 'address', + }, + { + internalType: 'uint256', + name: 'sellAmount', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'buyAmount', + type: 'uint256', + }, + { + internalType: 'uint32', + name: 'validTo', + type: 'uint32', + }, + { + internalType: 'bytes32', + name: 'appData', + type: 'bytes32', + }, + { + internalType: 'uint256', + name: 'feeAmount', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'flags', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'executedAmount', + type: 'uint256', + }, + { + internalType: 'bytes', + name: 'signature', + type: 'bytes', + }, + ], + internalType: 'struct GPv2Trade.Data', + name: 'trade', + type: 'tuple', + }, + ], + name: 'swap', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [], + name: 'vault', + outputs: [ + { + internalType: 'contract IVault', + name: '', + type: 'address', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'vaultRelayer', + outputs: [ + { + internalType: 'contract GPv2VaultRelayer', + name: '', + type: 'address', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + stateMutability: 'payable', + type: 'receive', + }, +] as const; +export type GPv2OrderParameters = { + sellToken: `0x${string}`; + buyToken: `0x${string}`; + receiver: `0x${string}`; + sellAmount: bigint; + buyAmount: bigint; + validTo: number; + appData: `0x${string}`; + feeAmount: bigint; + kind: OrderKind; + partiallyFillable: boolean; + sellTokenBalance: SellTokenBalance; + buyTokenBalance: BuyTokenBalance; +}; + +/** + * Decoder for GPv2Settlement contract. + * + * The following is based on the CoW SDK implementation: + * @see https://github.com/cowprotocol/contracts/blob/1465e69f6935b3ef9ce45d4878e44f0335ef8531/src/ts/settlement.ts + */ @Injectable() -export class GPv2Decoder extends AbiDecoder { +export class GPv2Decoder extends AbiDecoder { + private static readonly FlagMasks = { + kind: { + offset: 0, + options: [OrderKind.Sell, OrderKind.Buy], + }, + partiallyFillable: { + offset: 1, + options: [false, true], + }, + sellTokenBalance: { + offset: 2, + options: [ + SellTokenBalance.Erc20, + undefined, // unused + SellTokenBalance.External, + SellTokenBalance.Internal, + ], + }, + buyTokenBalance: { + offset: 4, + options: [BuyTokenBalance.Erc20, BuyTokenBalance.Internal], + }, + } as const; + constructor( @Inject(LoggingService) private readonly loggingService: ILoggingService, ) { - super(abi); + super(GPv2Abi); } /** @@ -21,16 +657,127 @@ export class GPv2Decoder extends AbiDecoder { * @param data - the transaction data for the setPreSignature call * @returns {`0x${string}`} the order UID or null if the data does not represent a setPreSignature transaction */ - getOrderUidFromSetPreSignature(data: `0x${string}`): `0x${string}` | null { + public getOrderUidFromSetPreSignature( + data: `0x${string}`, + ): `0x${string}` | null { + if (!this.helpers.isSetPreSignature(data)) { + return null; + } + + try { + const decoded = this.decodeFunctionData({ data }); + + if (decoded.functionName !== 'setPreSignature') { + throw new Error('Data is not of setPreSignature'); + } + + return decoded.args[0]; + } catch (e) { + this.loggingService.debug(e); + return null; + } + } + + /** + * Decodes an order from a settlement trade. + * + * @param trade The trade to decode into an order. + * @param tokens The list of token addresses as they appear in the settlement. + * @returns The decoded {@link GPv2OrderParameters} or null if the trade is invalid. + */ + public decodeOrderFromSettle( + data: `0x${string}`, + ): GPv2OrderParameters | null { + const decoded = this.decodeSettle(data); + + if (!decoded) { + return null; + } + + const [tokens, , [trade]] = decoded; + + const sellTokenIndex = Number(trade.sellTokenIndex); + const buyTokenIndex = Number(trade.buyTokenIndex); + + if (Math.max(sellTokenIndex, buyTokenIndex) >= tokens.length) { + throw new Error('Invalid trade'); + } + + return { + sellToken: tokens[sellTokenIndex], + buyToken: tokens[buyTokenIndex], + receiver: trade.receiver, + sellAmount: trade.sellAmount, + buyAmount: trade.buyAmount, + validTo: trade.validTo, + appData: trade.appData, + feeAmount: trade.feeAmount, + kind: this.decodeFlag('kind', trade.flags), + partiallyFillable: this.decodeFlag('partiallyFillable', trade.flags), + sellTokenBalance: this.decodeFlag('sellTokenBalance', trade.flags), + buyTokenBalance: this.decodeFlag('buyTokenBalance', trade.flags), + }; + } + + // Use inferred return type + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + private decodeSettle(data: `0x${string}`) { + if (!this.helpers.isSettle(data)) { + return null; + } + try { - if (!this.helpers.isSetPreSignature(data)) return null; - const { args } = this.decodeFunctionData({ data }); - return args[0]; + const decoded = this.decodeFunctionData({ data }); + + if (decoded.functionName !== 'settle') { + throw new Error('Data is not of settle'); + } + + return decoded.args; } catch (e) { this.loggingService.debug(e); return null; } } + + /** + * Decodes the specified bitfield flag. + * + * The following is taken from the CoW contracts: + * @see https://github.com/cowprotocol/contracts/blob/1465e69f6935b3ef9ce45d4878e44f0335ef8531/src/ts/settlement.ts#L213 + * + * @param key - encoded key + * @param flag - order flag encoded as a bitfield + * @returns decoded key + */ + private decodeFlag( + key: K, + flag: bigint, + ): Exclude<(typeof GPv2Decoder.FlagMasks)[K]['options'][number], undefined> { + const { offset, options } = GPv2Decoder.FlagMasks[key]; + const numberFlags = Number(flag); + const index = (numberFlags >> offset) & this.mask(options); + + const decoded = options[index] as Exclude< + (typeof GPv2Decoder.FlagMasks)[K]['options'][number], + undefined + >; + + if (decoded === undefined || index < 0) { + throw new Error( + `Invalid input flag for ${key}: 0b${numberFlags.toString(2)}`, + ); + } + + return decoded; + } + + // Counts smallest mask needed to store input options in masked bitfield + private mask(options: readonly unknown[]): number { + const num = options.length; + const bitCount = 32 - Math.clz32(num - 1); + return (1 << bitCount) - 1; + } } @Module({ diff --git a/src/routes/transactions/helpers/gp-v2-order.helper.ts b/src/routes/transactions/helpers/gp-v2-order.helper.ts index bfed845a2e..4a0d14a9d9 100644 --- a/src/routes/transactions/helpers/gp-v2-order.helper.ts +++ b/src/routes/transactions/helpers/gp-v2-order.helper.ts @@ -1,33 +1,7 @@ -import { - BuyTokenBalance, - OrderKind, - SellTokenBalance, -} from '@/domain/swaps/entities/order.entity'; +import { GPv2OrderParameters } from '@/domain/swaps/contracts/decoders/gp-v2-decoder.helper'; import { Injectable } from '@nestjs/common'; import { TypedDataDomain, encodePacked, hashTypedData } from 'viem'; -/** - * We can use generics to infer this from TwapOrderHelper.GPv2OrderTypeFields - * but for readability we manually define it. - * - * Functions that infer this type (`hashOrder`) will still return type errors - * if there is a mismatch between the inferred type and this. - */ -export type GPv2OrderParameters = { - sellToken: `0x${string}`; - buyToken: `0x${string}`; - receiver: `0x${string}`; - sellAmount: bigint; - buyAmount: bigint; - validTo: number; - appData: `0x${string}`; - feeAmount: bigint; - kind: OrderKind; - partiallyFillable: boolean; - sellTokenBalance: SellTokenBalance; - buyTokenBalance: BuyTokenBalance; -}; - @Injectable() export class GPv2OrderHelper { // Domain diff --git a/src/routes/transactions/helpers/twap-order.helper.ts b/src/routes/transactions/helpers/twap-order.helper.ts index bf3cec3dee..69342f1a83 100644 --- a/src/routes/transactions/helpers/twap-order.helper.ts +++ b/src/routes/transactions/helpers/twap-order.helper.ts @@ -8,7 +8,7 @@ import { OrderKind, SellTokenBalance, } from '@/domain/swaps/entities/order.entity'; -import { GPv2OrderParameters } from '@/routes/transactions/helpers/gp-v2-order.helper'; +import { GPv2OrderParameters } from '@/domain/swaps/contracts/decoders/gp-v2-decoder.helper'; import { Injectable, Module } from '@nestjs/common'; import { isAddressEqual } from 'viem'; diff --git a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts index 7c3dabcffd..9d04aef036 100644 --- a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts +++ b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts @@ -9,15 +9,16 @@ import { Order } from '@/domain/swaps/entities/order.entity'; import { ISwapsRepository } from '@/domain/swaps/swaps.repository'; import { tokenBuilder } from '@/domain/tokens/__tests__/token.builder'; import { ITokenRepository } from '@/domain/tokens/token.repository.interface'; -import { ILoggingService } from '@/logging/logging.interface'; import { GPv2OrderHelper } from '@/routes/transactions/helpers/gp-v2-order.helper'; import { SwapOrderHelper } from '@/routes/transactions/helpers/swap-order.helper'; import { TwapOrderHelper } from '@/routes/transactions/helpers/twap-order.helper'; import { TwapOrderMapper } from '@/routes/transactions/mappers/common/twap-order.mapper'; +import { ILoggingService } from '@/logging/logging.interface'; -const mockLoggingService = { +const loggingService = { debug: jest.fn(), } as jest.MockedObjectDeep; +const mockLoggingService = jest.mocked(loggingService); const mockTokenRepository = { getToken: jest.fn(), From 247a909501df159b7d89f5db091c124b2d05df1b Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 24 Jun 2024 10:40:19 +0200 Subject: [PATCH 116/207] Map TWAP swap (`settle`) orders (#1675) Adds decoding of (`settle`) transactions, mapping the order to that of "standard" swaps, returning them as `SwapOrderTransactionInfo` transactions: - Add `SwapOrderHelper['findTwapSwapOrder']` method for isolating transaction data to decode - Add `SwapOrderMapper['mapTwapSwapOrder']` method for mapping TWAP-based swaps - Integrate the aforementioned in `TransactionInfoMapper` - Add appropriate test coverage --- .../confirmation-view.entity.ts | 2 +- .../entities/swaps/swap-order-info.entity.ts | 6 +- .../transactions/helpers/swap-order.helper.ts | 26 +++ .../mappers/common/swap-order.mapper.spec.ts | 184 ++++++++++++++++++ .../mappers/common/swap-order.mapper.ts | 33 +++- .../mappers/common/transaction-info.mapper.ts | 39 +++- .../mappers/common/twap-order.mapper.spec.ts | 6 + .../transactions/transactions-view.service.ts | 2 +- 8 files changed, 287 insertions(+), 11 deletions(-) create mode 100644 src/routes/transactions/mappers/common/swap-order.mapper.spec.ts diff --git a/src/routes/transactions/entities/confirmation-view/confirmation-view.entity.ts b/src/routes/transactions/entities/confirmation-view/confirmation-view.entity.ts index 752fd594cd..ba56cd8421 100644 --- a/src/routes/transactions/entities/confirmation-view/confirmation-view.entity.ts +++ b/src/routes/transactions/entities/confirmation-view/confirmation-view.entity.ts @@ -92,7 +92,7 @@ export class CowSwapConfirmationView implements Baseline, OrderInfo { type: String, description: 'The URL to the explorer page of the order', }) - explorerUrl: URL; + explorerUrl: string; @ApiPropertyOptional({ type: String, diff --git a/src/routes/transactions/entities/swaps/swap-order-info.entity.ts b/src/routes/transactions/entities/swaps/swap-order-info.entity.ts index e703d6f4cb..d5cf0f5b33 100644 --- a/src/routes/transactions/entities/swaps/swap-order-info.entity.ts +++ b/src/routes/transactions/entities/swaps/swap-order-info.entity.ts @@ -20,7 +20,7 @@ export interface OrderInfo { buyAmount: string; executedSellAmount: string; executedBuyAmount: string; - explorerUrl: URL; + explorerUrl: string; executedSurplusFee: string | null; receiver: string | null; owner: `0x${string}`; @@ -84,7 +84,7 @@ export class SwapOrderTransactionInfo type: String, description: 'The URL to the explorer page of the order', }) - explorerUrl: URL; + explorerUrl: string; @ApiPropertyOptional({ type: String, @@ -124,7 +124,7 @@ export class SwapOrderTransactionInfo executedBuyAmount: string; sellToken: TokenInfo; buyToken: TokenInfo; - explorerUrl: URL; + explorerUrl: string; executedSurplusFee: string | null; receiver: string | null; owner: `0x${string}`; diff --git a/src/routes/transactions/helpers/swap-order.helper.ts b/src/routes/transactions/helpers/swap-order.helper.ts index 00f3c9ff44..c6fa5ea8ad 100644 --- a/src/routes/transactions/helpers/swap-order.helper.ts +++ b/src/routes/transactions/helpers/swap-order.helper.ts @@ -42,6 +42,8 @@ export class SwapOrderHelper { private readonly chainsRepository: IChainsRepository, ) {} + // TODO: Refactor findSwapOrder and findTwapSwapOrder to avoid code duplication + /** * Finds the swap order in the transaction data. * The swap order can be in the transaction data directly or in the data of a Multisend transaction. @@ -69,6 +71,30 @@ export class SwapOrderHelper { return null; } + /** + * Finds the `settle` transaction in provided data. + * The call can either be direct or parsed from within a MultiSend batch. + * + * @param data - transaction data to search for the `settle` transaction in + * @returns transaction data of `settle` transaction if found, otherwise null + */ + public findTwapSwapOrder(data: `0x${string}`): `0x${string}` | null { + if (this.gpv2Decoder.helpers.isSettle(data)) { + return data; + } + + if (this.multiSendDecoder.helpers.isMultiSend(data)) { + const transactions = this.multiSendDecoder.mapMultiSendTransactions(data); + for (const transaction of transactions) { + if (this.gpv2Decoder.helpers.isSettle(transaction.data)) { + return transaction.data; + } + } + } + + return null; + } + /** * Retrieves detailed information about a specific order and its associated tokens * diff --git a/src/routes/transactions/mappers/common/swap-order.mapper.spec.ts b/src/routes/transactions/mappers/common/swap-order.mapper.spec.ts new file mode 100644 index 0000000000..7f51d6fea0 --- /dev/null +++ b/src/routes/transactions/mappers/common/swap-order.mapper.spec.ts @@ -0,0 +1,184 @@ +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { IChainsRepository } from '@/domain/chains/chains.repository.interface'; +import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; +import { GPv2Decoder } from '@/domain/swaps/contracts/decoders/gp-v2-decoder.helper'; +import { Order } from '@/domain/swaps/entities/order.entity'; +import { ISwapsRepository } from '@/domain/swaps/swaps.repository'; +import { tokenBuilder } from '@/domain/tokens/__tests__/token.builder'; +import { ITokenRepository } from '@/domain/tokens/token.repository.interface'; +import { GPv2OrderHelper } from '@/routes/transactions/helpers/gp-v2-order.helper'; +import { SwapOrderHelper } from '@/routes/transactions/helpers/swap-order.helper'; +import { SwapOrderMapper } from '@/routes/transactions/mappers/common/swap-order.mapper'; +import { faker } from '@faker-js/faker'; +import { ILoggingService } from '@/logging/logging.interface'; + +const loggingService = { + debug: jest.fn(), +} as jest.MockedObjectDeep; +const mockLoggingService = jest.mocked(loggingService); + +const mockTokenRepository = { + getToken: jest.fn(), +} as jest.MockedObjectDeep; + +const mockSwapsRepository = { + getOrder: jest.fn(), + getFullAppData: jest.fn(), +} as jest.MockedObjectDeep; + +const mockConfigurationService = { + getOrThrow: jest.fn(), +} as jest.MockedObjectDeep; + +const mockChainsRepository = { + getChain: jest.fn(), +} as jest.MockedObjectDeep; + +describe('SwapOrderMapper', () => { + let target: SwapOrderMapper; + const restrictApps = false; + let swapsExplorerBaseUri: string; + + beforeEach(() => { + jest.resetAllMocks(); + + mockConfigurationService.getOrThrow.mockImplementation((key: string) => { + switch (key) { + case 'swaps.restrictApps': { + return restrictApps; + } + case 'swaps.explorerBaseUri': { + swapsExplorerBaseUri = faker.internet.url({ appendSlash: false }); + return swapsExplorerBaseUri; + } + default: { + throw new Error(`Configuration key not found: ${key}`); + } + } + }); + + const gpv2Decoder = new GPv2Decoder(mockLoggingService); + const gpv2OrderHelper = new GPv2OrderHelper(); + const multiSendDecoder = new MultiSendDecoder(); + const allowedApps = new Set(); + const swapOrderHelper = new SwapOrderHelper( + multiSendDecoder, + gpv2Decoder, + mockTokenRepository, + mockSwapsRepository, + mockConfigurationService, + allowedApps, + mockChainsRepository, + ); + target = new SwapOrderMapper(gpv2Decoder, gpv2OrderHelper, swapOrderHelper); + }); + + // TODO: Add test - should've been added in first swaps integration + it.todo('should map a swap order'); + + it('should map a TWAP-based swap order', async () => { + const chainId = '11155111'; + const safeAddress = '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381'; + const data = + '0x13d79a0b0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000008600000000000000000000000000000000000000000000000000000000000000004000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000b8e40feeb23890000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000098b75c35d9dbd0c00000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000300000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e00000000000000000000000000000000000000000000000000000000666b0cb7f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000004d831eac7f0141837b266de30f4dc9af15629bd53815fd7e97ddaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230bd5a25ba2e97094ad7d83dc28a6572da797d6b3e7fc6663bd93efb789fc17e489000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000000180000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e00000000000000000000000000000000000000000000000000000000666b0cb7f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc90000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011f294a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000026000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000086dcd3293c53cf8efd7303b57beb2a3f671dde980000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000001048803dbee00000000000000000000000000000000000000000000000009a4243487ef7e8600000000000000000000000000000000000000000000000bb1c124efe034415400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000009008d19f58aabd9ed0d60971565aa8510560ab41ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b849ec'; + /** + * @see https://explorer.cow.fi/sepolia/orders/0x557cb31a9dbbd23830c57d9fd3bbfc3694e942c161232b6cf696ba3bd11f9d6631eac7f0141837b266de30f4dc9af15629bd5381666b0cb7?tab=overview + */ + const order = { + creationDate: '2024-06-13T14:44:02.307987Z', + owner: '0x31eac7f0141837b266de30f4dc9af15629bd5381', + uid: '0x557cb31a9dbbd23830c57d9fd3bbfc3694e942c161232b6cf696ba3bd11f9d6631eac7f0141837b266de30f4dc9af15629bd5381666b0cb7', + availableBalance: null, + executedBuyAmount: '687772850053823756', + executedSellAmount: '213586875483862141750', + executedSellAmountBeforeFees: '213586875483862141750', + executedFeeAmount: '0', + executedSurplusFee: '2135868754838621123', + invalidated: false, + status: 'fulfilled', + class: 'limit', + settlementContract: '0x9008d19f58aabd9ed0d60971565aa8510560ab41', + fullFeeAmount: '0', + solverFee: '0', + isLiquidityOrder: false, + fullAppData: + '{"appCode":"Safe Wallet Swaps","metadata":{"orderClass":{"orderClass":"twap"},"quote":{"slippageBips":1000},"widget":{"appCode":"CoW Swap-SafeApp","environment":"production"}},"version":"1.1.0"}', + sellToken: '0xbe72e441bf55620febc26715db68d3494213d8cb', + buyToken: '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', + receiver: '0x31eac7f0141837b266de30f4dc9af15629bd5381', + sellAmount: '213586875483862141750', + buyAmount: '611289510998251134', + validTo: 1718291639, + appData: + '0xf7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda0', + feeAmount: '0', + kind: 'sell', + partiallyFillable: false, + sellTokenBalance: 'erc20', + buyTokenBalance: 'erc20', + signingScheme: 'eip1271', + signature: + '0x5fd7e97ddaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230bd5a25ba2e97094ad7d83dc28a6572da797d6b3e7fc6663bd93efb789fc17e489000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000000180000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e00000000000000000000000000000000000000000000000000000000666b0cb7f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc90000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011f294a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000', + interactions: { pre: [], post: [] }, + } as unknown as Order; + + const buyToken = tokenBuilder().with('address', order.buyToken).build(); + const sellToken = tokenBuilder().with('address', order.sellToken).build(); + + mockSwapsRepository.getOrder.mockResolvedValueOnce(order); + mockTokenRepository.getToken.mockImplementation(async ({ address }) => { + switch (address) { + case order.buyToken: { + return Promise.resolve(buyToken); + } + case order.sellToken: { + return Promise.resolve(sellToken); + } + default: { + return Promise.reject(new Error(`Token not found: ${address}`)); + } + } + }); + + const result = await target.mapTwapSwapOrder(chainId, safeAddress, { + data, + }); + + expect(result).toEqual({ + buyAmount: '611289510998251134', + buyToken: { + address: buyToken.address, + decimals: buyToken.decimals, + logoUri: buyToken.logoUri, + name: buyToken.name, + symbol: buyToken.symbol, + trusted: buyToken.trusted, + }, + executedBuyAmount: '687772850053823756', + executedSellAmount: '213586875483862141750', + executedSurplusFee: '2135868754838621123', + explorerUrl: `${swapsExplorerBaseUri}/orders/0x557cb31a9dbbd23830c57d9fd3bbfc3694e942c161232b6cf696ba3bd11f9d6631eac7f0141837b266de30f4dc9af15629bd5381666b0cb7`, + fullAppData: + '{"appCode":"Safe Wallet Swaps","metadata":{"orderClass":{"orderClass":"twap"},"quote":{"slippageBips":1000},"widget":{"appCode":"CoW Swap-SafeApp","environment":"production"}},"version":"1.1.0"}', + humanDescription: null, + kind: 'sell', + orderClass: 'limit', + owner: '0x31eac7f0141837b266de30f4dc9af15629bd5381', + receiver: '0x31eac7f0141837b266de30f4dc9af15629bd5381', + richDecodedInfo: null, + sellAmount: '213586875483862141750', + sellToken: { + address: sellToken.address, + decimals: sellToken.decimals, + logoUri: sellToken.logoUri, + name: sellToken.name, + symbol: sellToken.symbol, + trusted: sellToken.trusted, + }, + status: 'fulfilled', + type: 'SwapOrder', + uid: '0x557cb31a9dbbd23830c57d9fd3bbfc3694e942c161232b6cf696ba3bd11f9d6631eac7f0141837b266de30f4dc9af15629bd5381666b0cb7', + validUntil: 1718291639, + }); + }); +}); diff --git a/src/routes/transactions/mappers/common/swap-order.mapper.ts b/src/routes/transactions/mappers/common/swap-order.mapper.ts index 8c675e8418..8dcb323ef7 100644 --- a/src/routes/transactions/mappers/common/swap-order.mapper.ts +++ b/src/routes/transactions/mappers/common/swap-order.mapper.ts @@ -6,11 +6,13 @@ import { SwapOrderHelper, SwapOrderHelperModule, } from '@/routes/transactions/helpers/swap-order.helper'; +import { GPv2OrderHelper } from '@/routes/transactions/helpers/gp-v2-order.helper'; @Injectable() export class SwapOrderMapper { constructor( private readonly gpv2Decoder: GPv2Decoder, + private readonly gpv2OrderHelper: GPv2OrderHelper, private readonly swapOrderHelper: SwapOrderHelper, ) {} @@ -24,10 +26,33 @@ export class SwapOrderMapper { throw new Error('Order UID not found in transaction data'); } - const { order, sellToken, buyToken } = await this.swapOrderHelper.getOrder({ + return await this.mapSwapOrderTransactionInfo({ chainId, orderUid }); + } + + async mapTwapSwapOrder( + chainId: string, + safeAddress: `0x${string}`, + transaction: { data: `0x${string}` }, + ): Promise { + const order = this.gpv2Decoder.decodeOrderFromSettle(transaction.data); + if (!order) { + throw new Error('Order could not be decoded from transaction data'); + } + + const orderUid = this.gpv2OrderHelper.computeOrderUid({ chainId, - orderUid, + owner: safeAddress, + order, }); + return await this.mapSwapOrderTransactionInfo({ chainId, orderUid }); + } + + private async mapSwapOrderTransactionInfo(args: { + chainId: string; + orderUid: `0x${string}`; + }): Promise { + const { order, sellToken, buyToken } = + await this.swapOrderHelper.getOrder(args); if (!this.swapOrderHelper.isAppAllowed(order)) { throw new Error(`Unsupported App: ${order.fullAppData?.appCode}`); @@ -59,7 +84,7 @@ export class SwapOrderMapper { symbol: buyToken.symbol, trusted: buyToken.trusted, }), - explorerUrl: this.swapOrderHelper.getOrderExplorerUrl(order), + explorerUrl: this.swapOrderHelper.getOrderExplorerUrl(order).toString(), executedSurplusFee: order.executedSurplusFee?.toString() ?? null, receiver: order.receiver, owner: order.owner, @@ -70,7 +95,7 @@ export class SwapOrderMapper { @Module({ imports: [SwapOrderHelperModule], - providers: [SwapOrderMapper, GPv2Decoder], + providers: [SwapOrderMapper, GPv2Decoder, GPv2OrderHelper], exports: [SwapOrderMapper], }) export class SwapOrderMapperModule {} diff --git a/src/routes/transactions/mappers/common/transaction-info.mapper.ts b/src/routes/transactions/mappers/common/transaction-info.mapper.ts index 23bd24d957..33409536ed 100644 --- a/src/routes/transactions/mappers/common/transaction-info.mapper.ts +++ b/src/routes/transactions/mappers/common/transaction-info.mapper.ts @@ -18,7 +18,6 @@ import { HumanDescriptionMapper } from '@/routes/transactions/mappers/common/hum import { NativeCoinTransferMapper } from '@/routes/transactions/mappers/common/native-coin-transfer.mapper'; import { SettingsChangeMapper } from '@/routes/transactions/mappers/common/settings-change.mapper'; import { SwapOrderMapper } from '@/routes/transactions/mappers/common/swap-order.mapper'; -import { isHex } from 'viem'; import { ILoggingService, LoggingService } from '@/logging/logging.interface'; import { SwapOrderTransactionInfo } from '@/routes/transactions/entities/swaps/swap-order-info.entity'; import { SwapOrderHelper } from '@/routes/transactions/helpers/swap-order.helper'; @@ -114,6 +113,12 @@ export class MultisigTransactionInfoMapper { if (twapOrder) { return twapOrder; } + + // If the transaction is a TWAP-based swap order, we return it immediately + const twapSwapOrder = await this.mapTwapSwapOrder(chainId, transaction); + if (twapSwapOrder) { + return twapSwapOrder; + } } if (this.isCustomTransaction(value, dataSize, transaction.operation)) { @@ -200,6 +205,8 @@ export class MultisigTransactionInfoMapper { ); } + // TODO: Refactor mapSwapOrder, mapTwapOrder and mapTwapSwapOrder as they follow the same pattern + /** * Maps a swap order transaction. * If the transaction is not a swap order, it returns null. @@ -212,7 +219,7 @@ export class MultisigTransactionInfoMapper { chainId: string, transaction: MultisigTransaction | ModuleTransaction, ): Promise { - if (!transaction?.data || !isHex(transaction.data)) { + if (!transaction?.data) { return null; } @@ -275,6 +282,34 @@ export class MultisigTransactionInfoMapper { } } + private async mapTwapSwapOrder( + chainId: string, + transaction: MultisigTransaction | ModuleTransaction, + ): Promise { + if (!transaction?.data) { + return null; + } + + const orderData = this.swapOrderHelper.findTwapSwapOrder(transaction.data); + + if (!orderData) { + return null; + } + + try { + return await this.swapOrderMapper.mapTwapSwapOrder( + chainId, + transaction.safe, + { + data: orderData, + }, + ); + } catch (error) { + this.loggingService.warn(error); + return null; + } + } + private isCustomTransaction( value: number, dataSize: number, diff --git a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts index 9d04aef036..24e2f75b61 100644 --- a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts +++ b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts @@ -80,6 +80,9 @@ describe('TwapOrderMapper', () => { '0x0d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011f294a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000'; const executionDate = new Date(1718288040000); + /** + * @see https://explorer.cow.fi/sepolia/orders/0xdaabe82f86545c66074b5565962e96758979ae80124aabef05e0585149d30f7931eac7f0141837b266de30f4dc9af15629bd5381666b05af?tab=overview + */ const part1 = { creationDate: '2024-06-13T14:14:02.269522Z', owner: '0x31eac7f0141837b266de30f4dc9af15629bd5381', @@ -117,6 +120,9 @@ describe('TwapOrderMapper', () => { '0x5fd7e97ddaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230bd5a25ba2e97094ad7d83dc28a6572da797d6b3e7fc6663bd93efb789fc17e489000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000000180000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e00000000000000000000000000000000000000000000000000000000666b05aff7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc90000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011f294a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000', interactions: { pre: [], post: [] }, } as unknown as Order; + /** + * @see https://explorer.cow.fi/sepolia/orders/0x557cb31a9dbbd23830c57d9fd3bbfc3694e942c161232b6cf696ba3bd11f9d6631eac7f0141837b266de30f4dc9af15629bd5381666b0cb7?tab=overview + */ const part2 = { creationDate: '2024-06-13T14:44:02.307987Z', owner: '0x31eac7f0141837b266de30f4dc9af15629bd5381', diff --git a/src/routes/transactions/transactions-view.service.ts b/src/routes/transactions/transactions-view.service.ts index d320a1bb5f..0e063c4f26 100644 --- a/src/routes/transactions/transactions-view.service.ts +++ b/src/routes/transactions/transactions-view.service.ts @@ -90,7 +90,7 @@ export class TransactionsViewService { buyAmount: order.buyAmount.toString(), executedSellAmount: order.executedSellAmount.toString(), executedBuyAmount: order.executedBuyAmount.toString(), - explorerUrl: this.swapOrderHelper.getOrderExplorerUrl(order), + explorerUrl: this.swapOrderHelper.getOrderExplorerUrl(order).toString(), sellToken: new TokenInfo({ address: sellToken.address, decimals: sellToken.decimals, From aa50a4f057c181fa58739e6930137bc9828f1ece Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 24 Jun 2024 11:02:09 +0200 Subject: [PATCH 117/207] Add TWAP decoding to confirmation view (#1681) Adds the relevant decoded to return the `TwapOrderInfo` from the confirmation view endpoint: - Add `CowSwapTwapConfirmationView` with `COW_SWAP_TWAP_ORDER` `type` - Change types of `TwapOrderInfo` to ensure no issue with max. integers/time consistency - `numberOfParts`: number -> string - `timeBetweenParts`: string -> number - Add `TwapOrderHelper['twapStructToPartialOrderInfo']` to reduce duplicate code - Add TWAP decoding to `TransactionsViewService` --- .../entities/__tests__/configuration.ts | 8 +- .../confirmation-view.entity.ts | 166 +++++++++++++++++- .../entities/swaps/twap-order-info.entity.ts | 22 +-- .../transactions/helpers/swap-order.helper.ts | 23 +-- .../transactions/helpers/twap-order.helper.ts | 60 +++++++ .../mappers/common/swap-order.mapper.ts | 2 + .../mappers/common/twap-order.mapper.spec.ts | 8 +- .../mappers/common/twap-order.mapper.ts | 56 ++---- .../transactions-view.controller.spec.ts | 85 ++++++++- .../transactions-view.controller.ts | 8 +- .../transactions/transactions-view.service.ts | 113 +++++++++++- 11 files changed, 465 insertions(+), 86 deletions(-) diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index 111e79e878..dab194371e 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -168,10 +168,10 @@ export default (): ReturnType => ({ }, swaps: { api: { - 1: faker.internet.url(), - 100: faker.internet.url(), - 42161: faker.internet.url(), - 11155111: faker.internet.url(), + 1: faker.internet.url({ appendSlash: false }), + 100: faker.internet.url({ appendSlash: false }), + 42161: faker.internet.url({ appendSlash: false }), + 11155111: faker.internet.url({ appendSlash: false }), }, explorerBaseUri: faker.internet.url(), restrictApps: false, diff --git a/src/routes/transactions/entities/confirmation-view/confirmation-view.entity.ts b/src/routes/transactions/entities/confirmation-view/confirmation-view.entity.ts index ba56cd8421..20ae3b4bba 100644 --- a/src/routes/transactions/entities/confirmation-view/confirmation-view.entity.ts +++ b/src/routes/transactions/entities/confirmation-view/confirmation-view.entity.ts @@ -1,8 +1,17 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { DataDecodedParameter } from '@/routes/data-decode/entities/data-decoded-parameter.entity'; import { OrderInfo } from '@/routes/transactions/entities/swaps/swap-order-info.entity'; -import { OrderClass, OrderStatus } from '@/domain/swaps/entities/order.entity'; +import { + OrderClass, + OrderKind, + OrderStatus, +} from '@/domain/swaps/entities/order.entity'; import { TokenInfo } from '@/routes/transactions/entities/swaps/token-info.entity'; +import { + DurationOfPart, + StartTime, + TwapOrderInfo, +} from '@/routes/transactions/entities/swaps/twap-order-info.entity'; interface Baseline { method: string; @@ -12,11 +21,13 @@ interface Baseline { enum DecodedType { Generic = 'GENERIC', CowSwapOrder = 'COW_SWAP_ORDER', + CowSwapTwapOrder = 'COW_SWAP_TWAP_ORDER', } export type ConfirmationView = | BaselineConfirmationView - | CowSwapConfirmationView; + | CowSwapConfirmationView + | CowSwapTwapConfirmationView; export class BaselineConfirmationView implements Baseline { @ApiProperty({ enum: [DecodedType.Generic] }) @@ -149,3 +160,154 @@ export class CowSwapConfirmationView implements Baseline, OrderInfo { this.fullAppData = args.fullAppData; } } + +export class CowSwapTwapConfirmationView implements Baseline, TwapOrderInfo { + // Baseline implementation + @ApiProperty({ enum: [DecodedType.CowSwapTwapOrder] }) + type = DecodedType.CowSwapTwapOrder; + + @ApiProperty() + method: string; + + @ApiPropertyOptional({ type: [DataDecodedParameter], nullable: true }) + parameters: DataDecodedParameter[] | null; + + // TwapOrderInfo implementation + @ApiProperty({ + enum: OrderStatus, + description: 'The TWAP status', + }) + status: OrderStatus; + + @ApiProperty({ enum: OrderKind }) + kind: OrderKind.Sell; + + @ApiProperty({ enum: OrderClass }) + class: OrderClass.Limit; + + @ApiProperty({ description: 'The timestamp when the TWAP expires' }) + validUntil: number; + + @ApiProperty({ + description: 'The sell token raw amount (no decimals)', + }) + sellAmount: string; + + @ApiProperty({ + description: 'The buy token raw amount (no decimals)', + }) + buyAmount: string; + + @ApiPropertyOptional({ + nullable: true, + description: + 'The executed sell token raw amount (no decimals), or null if there are too many parts', + }) + executedSellAmount: string | null; + + @ApiPropertyOptional({ + nullable: true, + description: + 'The executed buy token raw amount (no decimals), or null if there are too many parts', + }) + executedBuyAmount: string | null; + + @ApiProperty({ description: 'The sell token of the TWAP' }) + sellToken: TokenInfo; + + @ApiProperty({ description: 'The buy token of the TWAP' }) + buyToken: TokenInfo; + + @ApiProperty({ + description: 'The address to receive the proceeds of the trade', + }) + receiver: `0x${string}`; + + @ApiProperty({ + type: String, + }) + owner: `0x${string}`; + + @ApiPropertyOptional({ + type: Object, + nullable: true, + description: 'The App Data for this TWAP', + }) + fullAppData: Record | null; + + @ApiProperty({ + description: 'The number of parts in the TWAP', + }) + numberOfParts: string; + + @ApiProperty({ + description: 'The amount of sellToken to sell in each part', + }) + partSellAmount: string; + + @ApiProperty({ + description: 'The amount of buyToken that must be bought in each part', + }) + minPartLimit: string; + + @ApiProperty({ + description: 'The duration of the TWAP interval', + }) + timeBetweenParts: number; + + @ApiProperty({ + description: 'Whether the TWAP is valid for the entire interval or not', + }) + durationOfPart: DurationOfPart; + + @ApiProperty({ + description: 'The start time of the TWAP', + }) + startTime: StartTime; + + constructor(args: { + method: string; + parameters: DataDecodedParameter[] | null; + status: OrderStatus; + kind: OrderKind.Sell; + class: OrderClass.Limit; + validUntil: number; + sellAmount: string; + buyAmount: string; + executedSellAmount: string | null; + executedBuyAmount: string | null; + sellToken: TokenInfo; + buyToken: TokenInfo; + receiver: `0x${string}`; + owner: `0x${string}`; + fullAppData: Record | null; + numberOfParts: string; + partSellAmount: string; + minPartLimit: string; + timeBetweenParts: number; + durationOfPart: DurationOfPart; + startTime: StartTime; + }) { + this.method = args.method; + this.parameters = args.parameters; + this.status = args.status; + this.kind = args.kind; + this.class = args.class; + this.validUntil = args.validUntil; + this.sellAmount = args.sellAmount; + this.buyAmount = args.buyAmount; + this.executedSellAmount = args.executedSellAmount; + this.executedBuyAmount = args.executedBuyAmount; + this.sellToken = args.sellToken; + this.buyToken = args.buyToken; + this.receiver = args.receiver; + this.owner = args.owner; + this.fullAppData = args.fullAppData; + this.numberOfParts = args.numberOfParts; + this.partSellAmount = args.partSellAmount; + this.minPartLimit = args.minPartLimit; + this.timeBetweenParts = args.timeBetweenParts; + this.durationOfPart = args.durationOfPart; + this.startTime = args.startTime; + } +} diff --git a/src/routes/transactions/entities/swaps/twap-order-info.entity.ts b/src/routes/transactions/entities/swaps/twap-order-info.entity.ts index de0cb00e3c..aedd2c8ddd 100644 --- a/src/routes/transactions/entities/swaps/twap-order-info.entity.ts +++ b/src/routes/transactions/entities/swaps/twap-order-info.entity.ts @@ -24,11 +24,11 @@ export enum StartTimeValue { AtEpoch = 'AT_EPOCH', } -type DurationOfPart = +export type DurationOfPart = | { durationType: DurationType.Auto } - | { durationType: DurationType.LimitDuration; duration: number }; + | { durationType: DurationType.LimitDuration; duration: string }; -type StartTime = +export type StartTime = | { startType: StartTimeValue.AtMiningTime } | { startType: StartTimeValue.AtEpoch; epoch: number }; @@ -45,10 +45,10 @@ export type TwapOrderInfo = { buyToken: TokenInfo; receiver: `0x${string}`; owner: `0x${string}`; - numberOfParts: number; + numberOfParts: string; partSellAmount: string; minPartLimit: string; - timeBetweenParts: string; + timeBetweenParts: number; durationOfPart: DurationOfPart; startTime: StartTime; }; @@ -126,7 +126,7 @@ export class TwapOrderTransactionInfo @ApiProperty({ description: 'The number of parts in the TWAP', }) - numberOfParts: number; + numberOfParts: string; @ApiProperty({ description: 'The amount of sellToken to sell in each part', @@ -141,7 +141,7 @@ export class TwapOrderTransactionInfo @ApiProperty({ description: 'The duration of the TWAP interval', }) - timeBetweenParts: string; + timeBetweenParts: number; @ApiProperty({ description: 'Whether the TWAP is valid for the entire interval or not', @@ -154,7 +154,7 @@ export class TwapOrderTransactionInfo startTime: StartTime; constructor(args: { - orderStatus: OrderStatus; + status: OrderStatus; kind: OrderKind.Sell; class: OrderClass.Limit; validUntil: number; @@ -167,15 +167,15 @@ export class TwapOrderTransactionInfo receiver: `0x${string}`; owner: `0x${string}`; fullAppData: Record | null; - numberOfParts: number; + numberOfParts: string; partSellAmount: string; minPartLimit: string; - timeBetweenParts: string; + timeBetweenParts: number; durationOfPart: DurationOfPart; startTime: StartTime; }) { super(TransactionInfoType.SwapOrder, null, null); - this.status = args.orderStatus; + this.status = args.status; this.kind = args.kind; this.class = args.class; this.validUntil = args.validUntil; diff --git a/src/routes/transactions/helpers/swap-order.helper.ts b/src/routes/transactions/helpers/swap-order.helper.ts index c6fa5ea8ad..5f60d3de77 100644 --- a/src/routes/transactions/helpers/swap-order.helper.ts +++ b/src/routes/transactions/helpers/swap-order.helper.ts @@ -134,14 +134,10 @@ export class SwapOrderHelper { }), ]); - if (buyToken.decimals === null || sellToken.decimals === null) { - throw new Error('Invalid token decimals'); - } - return { order: { ...order, kind: order.kind }, - buyToken: { ...buyToken, decimals: buyToken.decimals }, - sellToken: { ...sellToken, decimals: sellToken.decimals }, + buyToken, + sellToken, }; } @@ -186,15 +182,14 @@ export class SwapOrderHelper { * - `chainId`: A string representing the ID of the blockchain chain. * - `address`: A string representing the Ethereum address of the token, prefixed with '0x'. * @returns {Promise} A promise that resolves to a Token object containing the details - * of either the native currency or the specified token. + * of either the native currency or the specified token with mandatory decimals. * @throws {Error} Throws an error if the token data cannot be retrieved. - * @private * @async */ - private async getToken(args: { + public async getToken(args: { chainId: string; address: `0x${string}`; - }): Promise { + }): Promise }> { // We perform lower case comparison because the provided address (3rd party service) // might not be checksummed. if ( @@ -214,10 +209,16 @@ export class SwapOrderHelper { trusted: true, }; } else { - return this.tokenRepository.getToken({ + const token = await this.tokenRepository.getToken({ chainId: args.chainId, address: args.address, }); + + if (token.decimals === null) { + throw new Error('Invalid token decimals'); + } + + return { ...token, decimals: token.decimals }; } } } diff --git a/src/routes/transactions/helpers/twap-order.helper.ts b/src/routes/transactions/helpers/twap-order.helper.ts index 69342f1a83..f2f953e23e 100644 --- a/src/routes/transactions/helpers/twap-order.helper.ts +++ b/src/routes/transactions/helpers/twap-order.helper.ts @@ -5,9 +5,17 @@ import { } from '@/domain/swaps/contracts/decoders/composable-cow-decoder.helper'; import { BuyTokenBalance, + OrderClass, OrderKind, SellTokenBalance, } from '@/domain/swaps/entities/order.entity'; +import { + DurationType, + StartTimeValue, + DurationOfPart, + StartTime, + TwapOrderInfo, +} from '@/routes/transactions/entities/swaps/twap-order-info.entity'; import { GPv2OrderParameters } from '@/domain/swaps/contracts/decoders/gp-v2-decoder.helper'; import { Injectable, Module } from '@nestjs/common'; import { isAddressEqual } from 'viem'; @@ -66,6 +74,58 @@ export class TwapOrderHelper { ); } + /** + * Maps values of {@link TwapStruct} to partial {@link TwapOrderInfo} + * + * @param struct - {@link TwapStruct} to map + * @returns partial mapping of {@link TwapOrderInfo} + */ + public twapStructToPartialOrderInfo( + struct: TwapStruct, + ): Pick< + TwapOrderInfo, + | 'kind' + | 'class' + | 'sellAmount' + | 'buyAmount' + | 'startTime' + | 'numberOfParts' + | 'timeBetweenParts' + | 'durationOfPart' + > { + const { + n: numberOfParts, + partSellAmount, + minPartLimit, + t: timeBetweenParts, + t0: startEpoch, + span, + } = struct; + + const sellAmount = partSellAmount * numberOfParts; + const buyAmount = minPartLimit * numberOfParts; + + const isSpanZero = Number(span) === 0; + const durationOfPart: DurationOfPart = isSpanZero + ? { durationType: DurationType.Auto } + : { durationType: DurationType.LimitDuration, duration: span.toString() }; + + const startTime: StartTime = isSpanZero + ? { startType: StartTimeValue.AtMiningTime } + : { startType: StartTimeValue.AtEpoch, epoch: Number(startEpoch) }; + + return { + kind: OrderKind.Sell, + class: OrderClass.Limit, + sellAmount: sellAmount.toString(), + buyAmount: buyAmount.toString(), + startTime, + numberOfParts: numberOfParts.toString(), + timeBetweenParts: Number(timeBetweenParts), + durationOfPart, + }; + } + /** * Generates TWAP order parts based on the given TWAP struct and its execution date. * diff --git a/src/routes/transactions/mappers/common/swap-order.mapper.ts b/src/routes/transactions/mappers/common/swap-order.mapper.ts index 8dcb323ef7..3f7e75a2cb 100644 --- a/src/routes/transactions/mappers/common/swap-order.mapper.ts +++ b/src/routes/transactions/mappers/common/swap-order.mapper.ts @@ -16,6 +16,8 @@ export class SwapOrderMapper { private readonly swapOrderHelper: SwapOrderHelper, ) {} + // TODO: Handling of restricted Apps of TWAP mapping + async mapSwapOrder( chainId: string, transaction: { data: `0x${string}` }, diff --git a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts index 24e2f75b61..02bc12931d 100644 --- a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts +++ b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts @@ -209,7 +209,7 @@ describe('TwapOrderMapper', () => { humanDescription: null, kind: 'sell', minPartLimit: '611289510998251134', - numberOfParts: 2, + numberOfParts: '2', status: 'fulfilled', owner: '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381', partSellAmount: '213586875483862141750', @@ -227,7 +227,7 @@ describe('TwapOrderMapper', () => { startTime: { startType: 'AT_MINING_TIME', }, - timeBetweenParts: '1800', + timeBetweenParts: 1800, type: 'TwapOrder', validUntil: 1718291639, }); @@ -339,7 +339,7 @@ describe('TwapOrderMapper', () => { humanDescription: null, kind: 'sell', minPartLimit: '611289510998251134', - numberOfParts: 2, + numberOfParts: '2', status: 'fulfilled', owner: '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381', partSellAmount: '213586875483862141750', @@ -357,7 +357,7 @@ describe('TwapOrderMapper', () => { startTime: { startType: 'AT_MINING_TIME', }, - timeBetweenParts: '1800', + timeBetweenParts: 1800, type: 'TwapOrder', validUntil: 1718291639, }); diff --git a/src/routes/transactions/mappers/common/twap-order.mapper.ts b/src/routes/transactions/mappers/common/twap-order.mapper.ts index 1f05159478..f38dd34e9e 100644 --- a/src/routes/transactions/mappers/common/twap-order.mapper.ts +++ b/src/routes/transactions/mappers/common/twap-order.mapper.ts @@ -6,8 +6,6 @@ import { } from '@/routes/transactions/helpers/swap-order.helper'; import { ComposableCowDecoder } from '@/domain/swaps/contracts/decoders/composable-cow-decoder.helper'; import { - DurationType, - StartTimeValue, TwapOrderInfo, TwapOrderTransactionInfo, } from '@/routes/transactions/entities/swaps/twap-order-info.entity'; @@ -15,11 +13,7 @@ import { TwapOrderHelper, TwapOrderHelperModule, } from '@/routes/transactions/helpers/twap-order.helper'; -import { - OrderClass, - OrderKind, - OrderStatus, -} from '@/domain/swaps/entities/order.entity'; +import { OrderStatus } from '@/domain/swaps/entities/order.entity'; import { ISwapsRepository } from '@/domain/swaps/swaps.repository'; import { SwapsRepositoryModule } from '@/domain/swaps/swaps-repository.module'; import { SwapOrderMapperModule } from '@/routes/transactions/mappers/common/swap-order.mapper'; @@ -62,6 +56,8 @@ export class TwapOrderMapper { const twapStruct = this.composableCowDecoder.decodeTwapStruct( transaction.data, ); + const twapOrderData = + this.twapOrderHelper.twapStructToPartialOrderInfo(twapStruct); // Generate parts of the TWAP order const twapParts = this.twapOrderHelper.generateTwapOrderParts({ @@ -94,6 +90,8 @@ export class TwapOrderMapper { }), ]); + // TODO: Handling of restricted Apps, calling `getToken` directly instead of multiple times in `getOrder` for sellToken and buyToken + const executedSellAmount: TwapOrderInfo['executedSellAmount'] = hasAbundantParts ? null : this.getExecutedSellAmount(orders).toString(); @@ -103,18 +101,13 @@ export class TwapOrderMapper { // All orders have the same sellToken and buyToken const { sellToken, buyToken } = orders[0]; - const { n: numberOfParts, partSellAmount, minPartLimit } = twapStruct; - const span = Number(twapStruct.span); - const sellAmount = partSellAmount * numberOfParts; - const buyAmount = minPartLimit * numberOfParts; - return new TwapOrderTransactionInfo({ - orderStatus: this.getOrderStatus(orders), - kind: OrderKind.Sell, - class: OrderClass.Limit, + status: this.getOrderStatus(orders), + kind: twapOrderData.kind, + class: twapOrderData.class, validUntil: Math.max(...partsToFetch.map((order) => order.validTo)), - sellAmount: sellAmount.toString(), - buyAmount: buyAmount.toString(), + sellAmount: twapOrderData.sellAmount, + buyAmount: twapOrderData.buyAmount, executedSellAmount, executedBuyAmount, sellToken: new TokenInfo({ @@ -136,12 +129,12 @@ export class TwapOrderMapper { receiver: twapStruct.receiver, owner: safeAddress, fullAppData, - numberOfParts: Number(numberOfParts), - partSellAmount: partSellAmount.toString(), - minPartLimit: minPartLimit.toString(), - timeBetweenParts: twapStruct.t.toString(), - durationOfPart: this.getDurationOfPart(span), - startTime: this.getStartTime({ span, startEpoch: Number(twapStruct.t0) }), + numberOfParts: twapOrderData.numberOfParts, + partSellAmount: twapStruct.partSellAmount.toString(), + minPartLimit: twapStruct.minPartLimit.toString(), + timeBetweenParts: twapOrderData.timeBetweenParts, + durationOfPart: twapOrderData.durationOfPart, + startTime: twapOrderData.startTime, }); } @@ -189,23 +182,6 @@ export class TwapOrderMapper { return acc + Number(order.executedBuyAmount); }, 0); } - - private getDurationOfPart(span: number): TwapOrderInfo['durationOfPart'] { - if (span === 0) { - return { durationType: DurationType.Auto }; - } - return { durationType: DurationType.LimitDuration, duration: span }; - } - - private getStartTime(args: { - span: number; - startEpoch: number; - }): TwapOrderInfo['startTime'] { - if (args.span === 0) { - return { startType: StartTimeValue.AtMiningTime }; - } - return { startType: StartTimeValue.AtEpoch, epoch: args.startEpoch }; - } } @Module({ diff --git a/src/routes/transactions/transactions-view.controller.spec.ts b/src/routes/transactions/transactions-view.controller.spec.ts index 85ef382e3a..63fb9104a8 100644 --- a/src/routes/transactions/transactions-view.controller.spec.ts +++ b/src/routes/transactions/transactions-view.controller.spec.ts @@ -25,6 +25,8 @@ import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { faker } from '@faker-js/faker'; import { Server } from 'net'; +import { fakeJson } from '@/__tests__/faker'; +import { getAddress } from 'viem'; describe('TransactionsViewController tests', () => { let app: INestApplication; @@ -32,6 +34,7 @@ describe('TransactionsViewController tests', () => { let swapsApiUrl: string; let networkService: jest.MockedObjectDeep; const verifiedApp = faker.company.buzzNoun(); + const chainId = '1'; beforeEach(async () => { jest.resetAllMocks(); @@ -67,7 +70,7 @@ describe('TransactionsViewController tests', () => { IConfigurationService, ); safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); - swapsApiUrl = configurationService.getOrThrow('swaps.api.1'); + swapsApiUrl = configurationService.getOrThrow(`swaps.api.${chainId}`); networkService = moduleFixture.get(NetworkService); app = await new TestAppProvider().provide(moduleFixture); @@ -111,7 +114,7 @@ describe('TransactionsViewController tests', () => { }); it('Gets swap confirmation view with swap data', async () => { - const chain = chainBuilder().with('chainId', '1').build(); + const chain = chainBuilder().with('chainId', chainId).build(); const safe = safeBuilder().build(); const dataDecoded = dataDecodedBuilder().build(); const preSignatureEncoder = setPreSignatureEncoder(); @@ -198,8 +201,76 @@ describe('TransactionsViewController tests', () => { ); }); + it('gets TWAP confirmation view with TWAP data', async () => { + const ComposableCowAddress = '0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74'; + /** + * @see https://sepolia.etherscan.io/address/0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74 + */ + const chain = chainBuilder().with('chainId', chainId).build(); + const safe = safeBuilder() + .with('address', '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381') + .build(); + const data = + '0x0d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011f294a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000'; + const appDataHash = + '0xf7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda0'; + const fullAppData = JSON.parse(fakeJson()); + const dataDecoded = dataDecodedBuilder().build(); + const buyToken = tokenBuilder() + .with('address', getAddress('0xfff9976782d46cc05630d1f6ebab18b2324d6b14')) + .build(); + const sellToken = tokenBuilder() + .with('address', getAddress('0xbe72e441bf55620febc26715db68d3494213d8cb')) + .build(); + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { + return Promise.resolve({ data: chain, status: 200 }); + } + if ( + url === `${chain.transactionService}/api/v1/tokens/${buyToken.address}` + ) { + return Promise.resolve({ data: buyToken, status: 200 }); + } + if ( + url === `${chain.transactionService}/api/v1/tokens/${sellToken.address}` + ) { + return Promise.resolve({ data: sellToken, status: 200 }); + } + if (url === `${swapsApiUrl}/api/v1/app_data/${appDataHash}`) { + return Promise.resolve({ data: fullAppData, status: 200 }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + networkService.post.mockImplementation(({ url }) => { + if (url === `${chain.transactionService}/api/v1/data-decoder/`) { + return Promise.resolve({ + data: dataDecoded, + status: 200, + }); + } + return Promise.reject(new Error(`Could not match ${url}`)); + }); + + await request(app.getHttpServer()) + .post( + `/v1/chains/${chain.chainId}/safes/${safe.address}/views/transaction-confirmation`, + ) + .send({ + data, + to: ComposableCowAddress, + }) + .expect(200) + .expect(({ body }) => + expect(body).toMatchObject({ + type: 'COW_SWAP_TWAP_ORDER', + method: dataDecoded.method, + parameters: dataDecoded.parameters, + }), + ); + }); + it('Gets Generic confirmation view if order data is not available', async () => { - const chain = chainBuilder().with('chainId', '1').build(); + const chain = chainBuilder().with('chainId', chainId).build(); const safe = safeBuilder().build(); const dataDecoded = dataDecodedBuilder().build(); const preSignatureEncoder = setPreSignatureEncoder(); @@ -243,7 +314,7 @@ describe('TransactionsViewController tests', () => { }); it('Gets Generic confirmation view if buy token data is not available', async () => { - const chain = chainBuilder().with('chainId', '1').build(); + const chain = chainBuilder().with('chainId', chainId).build(); const safe = safeBuilder().build(); const dataDecoded = dataDecodedBuilder().build(); const preSignatureEncoder = setPreSignatureEncoder(); @@ -298,7 +369,7 @@ describe('TransactionsViewController tests', () => { }); it('Gets Generic confirmation view if sell token data is not available', async () => { - const chain = chainBuilder().with('chainId', '1').build(); + const chain = chainBuilder().with('chainId', chainId).build(); const safe = safeBuilder().build(); const dataDecoded = dataDecodedBuilder().build(); const preSignatureEncoder = setPreSignatureEncoder(); @@ -353,7 +424,7 @@ describe('TransactionsViewController tests', () => { }); it('Gets Generic confirmation view if swap app is restricted', async () => { - const chain = chainBuilder().with('chainId', '1').build(); + const chain = chainBuilder().with('chainId', chainId).build(); const safe = safeBuilder().build(); const dataDecoded = dataDecodedBuilder().build(); const preSignatureEncoder = setPreSignatureEncoder(); @@ -410,7 +481,7 @@ describe('TransactionsViewController tests', () => { }); it('executedSurplusFee is rendered as null if not available', async () => { - const chain = chainBuilder().with('chainId', '1').build(); + const chain = chainBuilder().with('chainId', chainId).build(); const safe = safeBuilder().build(); const dataDecoded = dataDecodedBuilder().build(); const preSignatureEncoder = setPreSignatureEncoder(); diff --git a/src/routes/transactions/transactions-view.controller.ts b/src/routes/transactions/transactions-view.controller.ts index d19873bdda..9662e71fce 100644 --- a/src/routes/transactions/transactions-view.controller.ts +++ b/src/routes/transactions/transactions-view.controller.ts @@ -29,6 +29,9 @@ import { GPv2DecoderModule } from '@/domain/swaps/contracts/decoders/gp-v2-decod import { ValidationPipe } from '@/validation/pipes/validation.pipe'; import { NumericStringSchema } from '@/validation/entities/schemas/numeric-string.schema'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; +import { TwapOrderHelperModule } from '@/routes/transactions/helpers/twap-order.helper'; +import { SwapsRepositoryModule } from '@/domain/swaps/swaps-repository.module'; +import { ComposableCowDecoder } from '@/domain/swaps/contracts/decoders/composable-cow-decoder.helper'; @ApiTags('transactions') @Controller({ @@ -62,6 +65,7 @@ export class TransactionsViewController { ): Promise { return this.service.getTransactionConfirmationView({ chainId, + safeAddress, transactionDataDto, }); } @@ -72,8 +76,10 @@ export class TransactionsViewController { DataDecodedRepositoryModule, GPv2DecoderModule, SwapOrderHelperModule, + TwapOrderHelperModule, + SwapsRepositoryModule, ], - providers: [TransactionsViewService], + providers: [TransactionsViewService, ComposableCowDecoder], controllers: [TransactionsViewController], }) export class TransactionsViewControllerModule {} diff --git a/src/routes/transactions/transactions-view.service.ts b/src/routes/transactions/transactions-view.service.ts index 0e063c4f26..9c5c5bf37e 100644 --- a/src/routes/transactions/transactions-view.service.ts +++ b/src/routes/transactions/transactions-view.service.ts @@ -3,6 +3,7 @@ import { BaselineConfirmationView, ConfirmationView, CowSwapConfirmationView, + CowSwapTwapConfirmationView, } from '@/routes/transactions/entities/confirmation-view/confirmation-view.entity'; import { Inject, Injectable } from '@nestjs/common'; import { IDataDecodedRepository } from '@/domain/data-decoder/data-decoded.repository.interface'; @@ -11,6 +12,10 @@ import { GPv2Decoder } from '@/domain/swaps/contracts/decoders/gp-v2-decoder.hel import { DataDecoded } from '@/domain/data-decoder/entities/data-decoded.entity'; import { TokenInfo } from '@/routes/transactions/entities/swaps/token-info.entity'; import { ILoggingService, LoggingService } from '@/logging/logging.interface'; +import { TwapOrderHelper } from '@/routes/transactions/helpers/twap-order.helper'; +import { OrderStatus } from '@/domain/swaps/entities/order.entity'; +import { ISwapsRepository } from '@/domain/swaps/swaps.repository'; +import { ComposableCowDecoder } from '@/domain/swaps/contracts/decoders/composable-cow-decoder.helper'; @Injectable({}) export class TransactionsViewService { @@ -20,10 +25,15 @@ export class TransactionsViewService { private readonly gpv2Decoder: GPv2Decoder, private readonly swapOrderHelper: SwapOrderHelper, @Inject(LoggingService) private readonly loggingService: ILoggingService, + private readonly twapOrderHelper: TwapOrderHelper, + @Inject(ISwapsRepository) + private readonly swapsRepository: ISwapsRepository, + private readonly composableCowDecoder: ComposableCowDecoder, ) {} async getTransactionConfirmationView(args: { chainId: string; + safeAddress: `0x${string}`; transactionDataDto: TransactionDataDto; }): Promise { const dataDecoded = await this.dataDecodedRepository.getDataDecoded({ @@ -36,7 +46,14 @@ export class TransactionsViewService { args.transactionDataDto.data, ); - if (!swapOrderData) { + const twapSwapOrderData = args.transactionDataDto.to + ? this.twapOrderHelper.findTwapOrder({ + to: args.transactionDataDto.to, + data: args.transactionDataDto.data, + }) + : null; + + if (!swapOrderData && !twapSwapOrderData) { return new BaselineConfirmationView({ method: dataDecoded.method, parameters: dataDecoded.parameters, @@ -44,11 +61,23 @@ export class TransactionsViewService { } try { - return await this.getSwapOrderConfirmationView({ - chainId: args.chainId, - data: swapOrderData, - dataDecoded, - }); + if (swapOrderData) { + return await this.getSwapOrderConfirmationView({ + chainId: args.chainId, + data: swapOrderData, + dataDecoded, + }); + } else if (twapSwapOrderData) { + return await this.getTwapOrderConfirmationView({ + chainId: args.chainId, + safeAddress: args.safeAddress, + data: twapSwapOrderData, + dataDecoded, + }); + } else { + // Should not reach here + throw new Error('No swap order data found'); + } } catch (error) { this.loggingService.warn(error); return new BaselineConfirmationView({ @@ -113,4 +142,76 @@ export class TransactionsViewService { fullAppData: order.fullAppData, }); } + + private async getTwapOrderConfirmationView(args: { + chainId: string; + safeAddress: `0x${string}`; + data: `0x${string}`; + dataDecoded: DataDecoded; + }): Promise { + // Decode `staticInput` of `createWithContextCall` + const twapStruct = this.composableCowDecoder.decodeTwapStruct(args.data); + const twapOrderData = + this.twapOrderHelper.twapStructToPartialOrderInfo(twapStruct); + + // Generate parts of the TWAP order + const twapParts = this.twapOrderHelper.generateTwapOrderParts({ + twapStruct, + executionDate: new Date(), + chainId: args.chainId, + }); + + const [{ fullAppData }, buyToken, sellToken] = await Promise.all([ + // Decode hash of `appData` + this.swapsRepository.getFullAppData(args.chainId, twapStruct.appData), + this.swapOrderHelper.getToken({ + chainId: args.chainId, + address: twapStruct.buyToken, + }), + this.swapOrderHelper.getToken({ + chainId: args.chainId, + address: twapStruct.sellToken, + }), + ]); + + // TODO: Handling of restricted Apps + + return new CowSwapTwapConfirmationView({ + method: args.dataDecoded.method, + parameters: args.dataDecoded.parameters, + status: OrderStatus.PreSignaturePending, + kind: twapOrderData.kind, + class: twapOrderData.class, + validUntil: Math.max(...twapParts.map((order) => order.validTo)), + sellAmount: twapOrderData.sellAmount, + buyAmount: twapOrderData.buyAmount, + executedSellAmount: '0', + executedBuyAmount: '0', + sellToken: new TokenInfo({ + address: sellToken.address, + decimals: sellToken.decimals, + logoUri: sellToken.logoUri, + name: sellToken.name, + symbol: sellToken.symbol, + trusted: sellToken.trusted, + }), + buyToken: new TokenInfo({ + address: buyToken.address, + decimals: buyToken.decimals, + logoUri: buyToken.logoUri, + name: buyToken.name, + symbol: buyToken.symbol, + trusted: buyToken.trusted, + }), + receiver: twapStruct.receiver, + owner: args.safeAddress, + fullAppData, + numberOfParts: twapOrderData.numberOfParts, + partSellAmount: twapStruct.partSellAmount.toString(), + minPartLimit: twapStruct.minPartLimit.toString(), + timeBetweenParts: twapOrderData.timeBetweenParts, + durationOfPart: twapOrderData.durationOfPart, + startTime: twapOrderData.startTime, + }); + } } From 5037b6c041f5af67475804b094e91cff11ec7f4b Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 24 Jun 2024 14:03:44 +0200 Subject: [PATCH 118/207] Map TWAP orders in the queue (#1683) Removes the requirement of an `executionDate` to map TWAP orders, meaning queued orders are now mapped: - Decouple fetching of buy/sell token with order, fetching the later separately - Don't fetch orders if the transaction is not executed, mapping "empty" values if no orders were fetched - Add/update the test coverage accordingly --- .../helpers/swap-order.helper.spec.ts | 115 ++------------- .../transactions/helpers/swap-order.helper.ts | 30 +--- .../mappers/common/swap-order.mapper.ts | 14 +- .../mappers/common/transaction-info.mapper.ts | 2 +- .../mappers/common/twap-order.mapper.spec.ts | 135 ++++++++++++++++-- .../mappers/common/twap-order.mapper.ts | 42 ++++-- .../transactions/transactions-view.service.ts | 13 +- 7 files changed, 201 insertions(+), 150 deletions(-) diff --git a/src/routes/transactions/helpers/swap-order.helper.spec.ts b/src/routes/transactions/helpers/swap-order.helper.spec.ts index 6d6f75a8f0..28e859185c 100644 --- a/src/routes/transactions/helpers/swap-order.helper.spec.ts +++ b/src/routes/transactions/helpers/swap-order.helper.spec.ts @@ -87,18 +87,12 @@ describe('Swap Order Helper tests', () => { return Promise.reject(new Error(`Token ${address} not found.`)); }); - const { - order: actualOrder, - sellToken: actualSellToken, - buyToken: actualBuyToken, - } = await target.getOrder({ + const actual = await target.getOrder({ chainId, orderUid: order.uid as `0x${string}`, }); - expect(actualSellToken.address).toBe(actualOrder.sellToken); - expect(actualBuyToken.address).toBe(actualOrder.buyToken); - expect(actualOrder).toEqual({ + expect(actual).toEqual({ appData: order.appData, availableBalance: order.availableBalance, buyAmount: order.buyAmount, @@ -134,25 +128,6 @@ describe('Swap Order Helper tests', () => { uid: order.uid, validTo: order.validTo, }); - - expect(actualSellToken).toStrictEqual({ - address: sellToken.address, - decimals: sellToken.decimals, - logoUri: sellToken.logoUri, - name: sellToken.name, - symbol: sellToken.symbol, - trusted: sellToken.trusted, - type: sellToken.type, - }); - expect(actualBuyToken).toStrictEqual({ - address: buyToken.address, - decimals: buyToken.decimals, - logoUri: buyToken.logoUri, - name: buyToken.name, - symbol: buyToken.symbol, - trusted: buyToken.trusted, - type: buyToken.type, - }); }, ); @@ -169,49 +144,38 @@ describe('Swap Order Helper tests', () => { chainId, expect.any(String), ); - expect(tokenRepositoryMock.getToken).toHaveBeenCalledTimes(0); }); it('should throw if token data is not available', async () => { const chainId = faker.string.numeric(); - const order = orderBuilder().build(); - swapsRepositoryMock.getOrder.mockResolvedValue(order); + const tokenAddress = getAddress(faker.finance.ethereumAddress()); tokenRepositoryMock.getToken.mockRejectedValue( new Error('Token not found'), ); await expect( - target.getOrder({ + target.getToken({ chainId, - orderUid: order.uid as `0x${string}`, + address: tokenAddress, }), ).rejects.toThrow('Token not found'); - expect(swapsRepositoryMock.getOrder).toHaveBeenCalledTimes(1); - expect(swapsRepositoryMock.getOrder).toHaveBeenCalledWith( + expect(tokenRepository.getToken).toHaveBeenCalledTimes(1); + expect(tokenRepository.getToken).toHaveBeenCalledWith({ chainId, - order.uid, - ); + address: tokenAddress, + }); }); it.each(Object.values(OrderStatus))( 'should throw if %s order kind is unknown', async (status) => { const chainId = faker.string.numeric(); - const buyToken = tokenBuilder().with('decimals', 0).build(); - const sellToken = tokenBuilder().build(); const order = orderBuilder() .with('status', status) - .with('buyToken', getAddress(buyToken.address)) - .with('sellToken', getAddress(sellToken.address)) .with('kind', OrderKind.Unknown) .build(); swapsRepositoryMock.getOrder.mockResolvedValue(order); - tokenRepositoryMock.getToken.mockImplementation(({ address }) => { - if (address === order.buyToken) return Promise.resolve(buyToken); - if (address === order.sellToken) return Promise.resolve(sellToken); - return Promise.reject(new Error(`Token ${address} not found.`)); - }); await expect( target.getOrder({ @@ -228,68 +192,19 @@ describe('Swap Order Helper tests', () => { }, ); - it('maps to native token if buy token is 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', async () => { + it('maps to native token if token is 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', async () => { const chainId = faker.string.numeric(); const chain = chainBuilder().with('chainId', chainId).build(); - const sellToken = tokenBuilder().build(); - const order = orderBuilder() - .with( - 'buyToken', - getAddress('0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'), - ) - .with('sellToken', getAddress(sellToken.address)) - .build(); - swapsRepositoryMock.getOrder.mockResolvedValue(order); - tokenRepositoryMock.getToken.mockImplementation(({ address }) => { - if (address === order.sellToken) return Promise.resolve(sellToken); - return Promise.reject(new Error(`Token ${address} not found.`)); - }); - chainsRepositoryMock.getChain.mockResolvedValue(chain); - - const actual = await target.getOrder({ - chainId, - orderUid: order.uid as `0x${string}`, - }); - - expect(chainsRepository.getChain).toHaveBeenCalledTimes(1); - expect(chainsRepository.getChain).toHaveBeenCalledWith(chainId); - expect(actual.buyToken).toStrictEqual({ - address: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', - decimals: chain.nativeCurrency.decimals, - logoUri: chain.nativeCurrency.logoUri, - name: chain.nativeCurrency.name, - symbol: chain.nativeCurrency.symbol, - type: 'NATIVE_TOKEN', - trusted: true, - }); - }); - - it('maps to native token if sell token is 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', async () => { - const chainId = faker.string.numeric(); - const chain = chainBuilder().with('chainId', chainId).build(); - const buyToken = tokenBuilder().build(); - const order = orderBuilder() - .with( - 'sellToken', - getAddress('0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'), - ) - .with('buyToken', getAddress(buyToken.address)) - .build(); - swapsRepositoryMock.getOrder.mockResolvedValue(order); - tokenRepositoryMock.getToken.mockImplementation(({ address }) => { - if (address === order.buyToken) return Promise.resolve(buyToken); - return Promise.reject(new Error(`Token ${address} not found.`)); - }); + const tokenAddress = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'; chainsRepositoryMock.getChain.mockResolvedValue(chain); - const actual = await target.getOrder({ + const actual = await target.getToken({ chainId, - orderUid: order.uid as `0x${string}`, + address: tokenAddress, }); - expect(chainsRepository.getChain).toHaveBeenCalledTimes(1); - expect(chainsRepository.getChain).toHaveBeenCalledWith(chainId); - expect(actual.sellToken).toStrictEqual({ + expect(tokenRepository.getToken).not.toHaveBeenCalledTimes(1); + expect(actual).toStrictEqual({ address: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', decimals: chain.nativeCurrency.decimals, logoUri: chain.nativeCurrency.logoUri, diff --git a/src/routes/transactions/helpers/swap-order.helper.ts b/src/routes/transactions/helpers/swap-order.helper.ts index 5f60d3de77..c46372f978 100644 --- a/src/routes/transactions/helpers/swap-order.helper.ts +++ b/src/routes/transactions/helpers/swap-order.helper.ts @@ -103,19 +103,15 @@ export class SwapOrderHelper { * @param {string} args.orderUid - The unique identifier of the order, prefixed with '0x'. * @returns {Promise} A promise that resolves to an object containing the order and token details. * - * The returned object includes: - * - `order`: An object representing the order. - * - `sellToken`: The Token object with a mandatory `decimals` property - * - `buyToken`: Similar to `sellToken`, for the token being purchased in the order. + * The returned object represents the order. * * @throws {Error} Throws an error if the order `kind` is 'unknown'. * @throws {Error} Throws an error if either the sellToken or buyToken object has null decimals. */ - async getOrder(args: { chainId: string; orderUid: `0x${string}` }): Promise<{ - order: Order & { kind: Exclude }; - sellToken: Token & { decimals: number }; - buyToken: Token & { decimals: number }; - }> { + async getOrder(args: { + chainId: string; + orderUid: `0x${string}`; + }): Promise }> { const order = await this.swapsRepository.getOrder( args.chainId, args.orderUid, @@ -123,21 +119,9 @@ export class SwapOrderHelper { if (order.kind === OrderKind.Unknown) throw new Error('Unknown order kind'); - const [buyToken, sellToken] = await Promise.all([ - this.getToken({ - chainId: args.chainId, - address: order.buyToken, - }), - this.getToken({ - chainId: args.chainId, - address: order.sellToken, - }), - ]); - return { - order: { ...order, kind: order.kind }, - buyToken, - sellToken, + ...order, + kind: order.kind, }; } diff --git a/src/routes/transactions/mappers/common/swap-order.mapper.ts b/src/routes/transactions/mappers/common/swap-order.mapper.ts index 3f7e75a2cb..8bb5e938b5 100644 --- a/src/routes/transactions/mappers/common/swap-order.mapper.ts +++ b/src/routes/transactions/mappers/common/swap-order.mapper.ts @@ -53,13 +53,23 @@ export class SwapOrderMapper { chainId: string; orderUid: `0x${string}`; }): Promise { - const { order, sellToken, buyToken } = - await this.swapOrderHelper.getOrder(args); + const order = await this.swapOrderHelper.getOrder(args); if (!this.swapOrderHelper.isAppAllowed(order)) { throw new Error(`Unsupported App: ${order.fullAppData?.appCode}`); } + const [sellToken, buyToken] = await Promise.all([ + this.swapOrderHelper.getToken({ + address: order.sellToken, + chainId: args.chainId, + }), + this.swapOrderHelper.getToken({ + address: order.buyToken, + chainId: args.chainId, + }), + ]); + return new SwapOrderTransactionInfo({ uid: order.uid, orderStatus: order.status, diff --git a/src/routes/transactions/mappers/common/transaction-info.mapper.ts b/src/routes/transactions/mappers/common/transaction-info.mapper.ts index 33409536ed..14edaf185a 100644 --- a/src/routes/transactions/mappers/common/transaction-info.mapper.ts +++ b/src/routes/transactions/mappers/common/transaction-info.mapper.ts @@ -254,7 +254,7 @@ export class MultisigTransactionInfoMapper { chainId: string, transaction: MultisigTransaction | ModuleTransaction, ): Promise { - if (!transaction?.data || !transaction?.executionDate) { + if (!transaction?.data) { return null; } diff --git a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts index 02bc12931d..9bb4a50fbf 100644 --- a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts +++ b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts @@ -14,6 +14,7 @@ import { SwapOrderHelper } from '@/routes/transactions/helpers/swap-order.helper import { TwapOrderHelper } from '@/routes/transactions/helpers/twap-order.helper'; import { TwapOrderMapper } from '@/routes/transactions/mappers/common/twap-order.mapper'; import { ILoggingService } from '@/logging/logging.interface'; +import { getAddress } from 'viem'; const loggingService = { debug: jest.fn(), @@ -58,7 +59,115 @@ describe('TwapOrderMapper', () => { composableCowDecoder, ); - it('should map a TWAP order', async () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should map a queued TWAP order', async () => { + const now = new Date(); + jest.setSystemTime(now); + + configurationService.set('swaps.maxNumberOfParts', 2); + + // We instantiate in tests to be able to set maxNumberOfParts + const mapper = new TwapOrderMapper( + configurationService, + swapOrderHelper, + mockSwapsRepository, + composableCowDecoder, + gpv2OrderHelper, + twapOrderHelper, + ); + + // Taken from queued transaction of specified owner before execution + const chainId = '11155111'; + const owner = '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381'; + const data = + '0x0d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000001903c57a7700000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb5900000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000006f05b59d3b2000000000000000000000000000000000000000000000000000165e249251c2365980000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000023280000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000'; + + const buyToken = tokenBuilder() + .with('address', '0x0625aFB445C3B6B7B929342a04A22599fd5dBB59') + .build(); + const sellToken = tokenBuilder() + .with('address', '0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14') + .build(); + const fullAppData = JSON.parse(fakeJson()); + + // Orders throw as they don't exist + mockSwapsRepository.getOrder.mockRejectedValue( + new Error('Order not found'), + ); + mockTokenRepository.getToken.mockImplementation(async ({ address }) => { + // We only need mock part1 addresses as all parts use the same tokens + switch (address) { + case buyToken.address: { + return Promise.resolve(buyToken); + } + case sellToken.address: { + return Promise.resolve(sellToken); + } + default: { + return Promise.reject(new Error(`Token not found: ${address}`)); + } + } + }); + mockSwapsRepository.getFullAppData.mockResolvedValue({ fullAppData }); + + const result = await mapper.mapTwapOrder(chainId, owner, { + data, + executionDate: null, + }); + + expect(result).toEqual({ + buyAmount: '51576509680023161648', + buyToken: { + address: buyToken.address, + decimals: buyToken.decimals, + logoUri: buyToken.logoUri, + name: buyToken.name, + symbol: buyToken.symbol, + trusted: buyToken.trusted, + }, + class: 'limit', + durationOfPart: { + durationType: 'AUTO', + }, + executedBuyAmount: '0', + executedSellAmount: '0', + fullAppData, + humanDescription: null, + kind: 'sell', + minPartLimit: '25788254840011580824', + numberOfParts: '2', + status: 'presignaturePending', + owner: '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381', + partSellAmount: '500000000000000000', + receiver: '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381', + richDecodedInfo: null, + sellAmount: '1000000000000000000', + sellToken: { + address: sellToken.address, + decimals: sellToken.decimals, + logoUri: sellToken.logoUri, + name: sellToken.name, + symbol: sellToken.symbol, + trusted: sellToken.trusted, + }, + startTime: { + startType: 'AT_MINING_TIME', + }, + timeBetweenParts: 9000, + type: 'TwapOrder', + validUntil: Math.ceil(now.getTime() / 1_000) + 17999, + }); + }); + + it('should map an executed TWAP order', async () => { configurationService.set('swaps.maxNumberOfParts', 2); // We instantiate in tests to be able to set maxNumberOfParts @@ -161,8 +270,12 @@ describe('TwapOrderMapper', () => { interactions: { pre: [], post: [] }, } as unknown as Order; - const buyToken = tokenBuilder().with('address', part1.buyToken).build(); - const sellToken = tokenBuilder().with('address', part1.sellToken).build(); + const buyToken = tokenBuilder() + .with('address', getAddress(part1.buyToken)) + .build(); + const sellToken = tokenBuilder() + .with('address', getAddress(part1.sellToken)) + .build(); const fullAppData = JSON.parse(fakeJson()); mockSwapsRepository.getOrder @@ -171,10 +284,10 @@ describe('TwapOrderMapper', () => { mockTokenRepository.getToken.mockImplementation(async ({ address }) => { // We only need mock part1 addresses as all parts use the same tokens switch (address) { - case part1.buyToken: { + case buyToken.address: { return Promise.resolve(buyToken); } - case part1.sellToken: { + case sellToken.address: { return Promise.resolve(sellToken); } default: { @@ -293,18 +406,22 @@ describe('TwapOrderMapper', () => { interactions: { pre: [], post: [] }, } as unknown as Order; - const buyToken = tokenBuilder().with('address', part2.buyToken).build(); - const sellToken = tokenBuilder().with('address', part2.sellToken).build(); + const buyToken = tokenBuilder() + .with('address', getAddress(part2.buyToken)) + .build(); + const sellToken = tokenBuilder() + .with('address', getAddress(part2.sellToken)) + .build(); const fullAppData = JSON.parse(fakeJson()); mockSwapsRepository.getOrder.mockResolvedValueOnce(part2); mockTokenRepository.getToken.mockImplementation(async ({ address }) => { // We only need mock part1 addresses as all parts use the same tokens switch (address) { - case part2.buyToken: { + case buyToken.address: { return Promise.resolve(buyToken); } - case part2.sellToken: { + case sellToken.address: { return Promise.resolve(sellToken); } default: { diff --git a/src/routes/transactions/mappers/common/twap-order.mapper.ts b/src/routes/transactions/mappers/common/twap-order.mapper.ts index f38dd34e9e..ea78ac3a32 100644 --- a/src/routes/transactions/mappers/common/twap-order.mapper.ts +++ b/src/routes/transactions/mappers/common/twap-order.mapper.ts @@ -50,7 +50,7 @@ export class TwapOrderMapper { async mapTwapOrder( chainId: string, safeAddress: `0x${string}`, - transaction: { data: `0x${string}`; executionDate: Date }, + transaction: { data: `0x${string}`; executionDate: Date | null }, ): Promise { // Decode `staticInput` of `createWithContextCall` const twapStruct = this.composableCowDecoder.decodeTwapStruct( @@ -62,7 +62,7 @@ export class TwapOrderMapper { // Generate parts of the TWAP order const twapParts = this.twapOrderHelper.generateTwapOrderParts({ twapStruct, - executionDate: transaction.executionDate, + executionDate: transaction.executionDate ?? new Date(), chainId, }); @@ -70,16 +70,18 @@ export class TwapOrderMapper { // to avoid requesting too many orders const hasAbundantParts = twapParts.length > this.maxNumberOfParts; - const partsToFetch = hasAbundantParts - ? // We use the last part (and only one) to get the status of the entire - // order and we only need one to get the token info - twapParts.slice(-1) - : twapParts; + // Fetch all order parts if the transaction has been executed, otherwise none + const partsToFetch = transaction.executionDate + ? hasAbundantParts + ? // We use the last part (and only one) to get the status of the entire + // order and we only need one to get the token info + twapParts.slice(-1) + : twapParts + : []; const [{ fullAppData }, ...orders] = await Promise.all([ // Decode hash of `appData` this.swapsRepository.getFullAppData(chainId, twapStruct.appData), - // Fetch all order parts ...partsToFetch.map((order) => { const orderUid = this.gpv2OrderHelper.computeOrderUid({ chainId, @@ -98,14 +100,22 @@ export class TwapOrderMapper { const executedBuyAmount: TwapOrderInfo['executedBuyAmount'] = hasAbundantParts ? null : this.getExecutedBuyAmount(orders).toString(); - // All orders have the same sellToken and buyToken - const { sellToken, buyToken } = orders[0]; + const [sellToken, buyToken] = await Promise.all([ + this.swapOrderHelper.getToken({ + chainId, + address: twapStruct.sellToken, + }), + this.swapOrderHelper.getToken({ + chainId, + address: twapStruct.buyToken, + }), + ]); return new TwapOrderTransactionInfo({ status: this.getOrderStatus(orders), kind: twapOrderData.kind, class: twapOrderData.class, - validUntil: Math.max(...partsToFetch.map((order) => order.validTo)), + validUntil: Math.max(...twapParts.map((order) => order.validTo)), sellAmount: twapOrderData.sellAmount, buyAmount: twapOrderData.buyAmount, executedSellAmount, @@ -141,6 +151,10 @@ export class TwapOrderMapper { private getOrderStatus( orders: Array>>, ): OrderStatus { + if (orders.length === 0) { + return OrderStatus.PreSignaturePending; + } + // If an order is fulfilled, cancelled or expired, the part is "complete" const completeStatuses = [ OrderStatus.Fulfilled, @@ -149,7 +163,7 @@ export class TwapOrderMapper { ]; for (let i = 0; i < orders.length; i++) { - const { order } = orders[i]; + const order = orders[i]; // Return the status of the last part if (i === orders.length - 1) { @@ -170,7 +184,7 @@ export class TwapOrderMapper { private getExecutedSellAmount( orders: Array>>, ): number { - return orders.reduce((acc, { order }) => { + return orders.reduce((acc, order) => { return acc + Number(order.executedSellAmount); }, 0); } @@ -178,7 +192,7 @@ export class TwapOrderMapper { private getExecutedBuyAmount( orders: Array>>, ): number { - return orders.reduce((acc, { order }) => { + return orders.reduce((acc, order) => { return acc + Number(order.executedBuyAmount); }, 0); } diff --git a/src/routes/transactions/transactions-view.service.ts b/src/routes/transactions/transactions-view.service.ts index 9c5c5bf37e..d70e5a8ee3 100644 --- a/src/routes/transactions/transactions-view.service.ts +++ b/src/routes/transactions/transactions-view.service.ts @@ -98,7 +98,7 @@ export class TransactionsViewService { throw new Error('Order UID not found in transaction data'); } - const { order, sellToken, buyToken } = await this.swapOrderHelper.getOrder({ + const order = await this.swapOrderHelper.getOrder({ chainId: args.chainId, orderUid, }); @@ -107,6 +107,17 @@ export class TransactionsViewService { throw new Error(`Unsupported App: ${order.fullAppData?.appCode}`); } + const [sellToken, buyToken] = await Promise.all([ + this.swapOrderHelper.getToken({ + chainId: args.chainId, + address: order.sellToken, + }), + this.swapOrderHelper.getToken({ + chainId: args.chainId, + address: order.buyToken, + }), + ]); + return new CowSwapConfirmationView({ method: args.dataDecoded.method, parameters: args.dataDecoded.parameters, From 137875bcdf9b08d35c407eb59a12ca9dfcfb3504 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Jun 2024 00:15:00 +0200 Subject: [PATCH 119/207] Bump viem from 2.14.2 to 2.16.2 (#1684) Bumps [viem](https://github.com/wevm/viem) from 2.14.2 to 2.16.2. - [Release notes](https://github.com/wevm/viem/releases) - [Commits](https://github.com/wevm/viem/compare/viem@2.14.2...viem@2.16.2) --- updated-dependencies: - dependency-name: viem dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 30 +++++++++++++++--------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index d0a38d47c7..d8e2f8daab 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "semver": "^7.6.2", - "viem": "^2.14.2", + "viem": "^2.16.2", "winston": "^3.13.0", "zod": "^3.23.8" }, diff --git a/yarn.lock b/yarn.lock index fc1d90c5ec..27ff46ecfa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2302,9 +2302,9 @@ __metadata: languageName: node linkType: hard -"abitype@npm:1.0.0": - version: 1.0.0 - resolution: "abitype@npm:1.0.0" +"abitype@npm:1.0.4": + version: 1.0.4 + resolution: "abitype@npm:1.0.4" peerDependencies: typescript: ">=5.0.4" zod: ^3 >=3.22.0 @@ -2313,7 +2313,7 @@ __metadata: optional: true zod: optional: true - checksum: 10/38c8d965c75c031854385f1c14da0410e271f1a8255332869a77a1ee836c4607420522c1f0077716c7ad7c4091f53c1b2681ed1d30b5161d1424fdb5a480f104 + checksum: 10/816fcf4a7c2043f71707a97075b5df3ca0cb818b68e3df9ae393ed86631201da6e54f9b2ffb192f7c8e0229a97854881300422e33b4b876a38912d3e7ab37b9b languageName: node linkType: hard @@ -7278,7 +7278,7 @@ __metadata: tsconfig-paths: "npm:4.2.0" typescript: "npm:^5.4.5" typescript-eslint: "npm:^7.13.0" - viem: "npm:^2.14.2" + viem: "npm:^2.16.2" winston: "npm:^3.13.0" zod: "npm:^3.23.8" languageName: unknown @@ -8306,24 +8306,24 @@ __metadata: languageName: node linkType: hard -"viem@npm:^2.14.2": - version: 2.14.2 - resolution: "viem@npm:2.14.2" +"viem@npm:^2.16.2": + version: 2.16.2 + resolution: "viem@npm:2.16.2" dependencies: "@adraffy/ens-normalize": "npm:1.10.0" "@noble/curves": "npm:1.2.0" "@noble/hashes": "npm:1.3.2" "@scure/bip32": "npm:1.3.2" "@scure/bip39": "npm:1.2.1" - abitype: "npm:1.0.0" + abitype: "npm:1.0.4" isows: "npm:1.0.4" - ws: "npm:8.13.0" + ws: "npm:8.17.1" peerDependencies: typescript: ">=5.0.4" peerDependenciesMeta: typescript: optional: true - checksum: 10/41f2c7b3c242350867a06e2e0ab75a8e1b58cfef244d62a9c3301b5ff8cad75600992d7a0145aa221b2e271c2aeb7daf4c5f193ed7b5999e976fb42609da7f83 + checksum: 10/fc6f61853f513ecced75b81e1489ad27fd64de94ddac3747378c7ed29af468da5f844a04760239193b6234a3c15d524e462e04642a8a3780f6422e880f6dc8be languageName: node linkType: hard @@ -8523,9 +8523,9 @@ __metadata: languageName: node linkType: hard -"ws@npm:8.13.0": - version: 8.13.0 - resolution: "ws@npm:8.13.0" +"ws@npm:8.17.1": + version: 8.17.1 + resolution: "ws@npm:8.17.1" peerDependencies: bufferutil: ^4.0.1 utf-8-validate: ">=5.0.2" @@ -8534,7 +8534,7 @@ __metadata: optional: true utf-8-validate: optional: true - checksum: 10/1769532b6fdab9ff659f0b17810e7501831d34ecca23fd179ee64091dd93a51f42c59f6c7bb4c7a384b6c229aca8076fb312aa35626257c18081511ef62a161d + checksum: 10/4264ae92c0b3e59c7e309001e93079b26937aab181835fb7af79f906b22cd33b6196d96556dafb4e985742dd401e99139572242e9847661fdbc96556b9e6902d languageName: node linkType: hard From 55b691e31e1c20a2aad110255cdd809d73e65ebb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Jun 2024 00:15:35 +0200 Subject: [PATCH 120/207] Bump ts-jest from 29.1.4 to 29.1.5 (#1687) Bumps [ts-jest](https://github.com/kulshekhar/ts-jest) from 29.1.4 to 29.1.5. - [Release notes](https://github.com/kulshekhar/ts-jest/releases) - [Changelog](https://github.com/kulshekhar/ts-jest/blob/main/CHANGELOG.md) - [Commits](https://github.com/kulshekhar/ts-jest/compare/v29.1.4...v29.1.5) --- updated-dependencies: - dependency-name: ts-jest dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index d8e2f8daab..f7f033600c 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "prettier": "^3.3.2", "source-map-support": "^0.5.20", "supertest": "^7.0.0", - "ts-jest": "29.1.4", + "ts-jest": "29.1.5", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "4.2.0", diff --git a/yarn.lock b/yarn.lock index 27ff46ecfa..cb94083cab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7272,7 +7272,7 @@ __metadata: semver: "npm:^7.6.2" source-map-support: "npm:^0.5.20" supertest: "npm:^7.0.0" - ts-jest: "npm:29.1.4" + ts-jest: "npm:29.1.5" ts-loader: "npm:^9.5.1" ts-node: "npm:^10.9.2" tsconfig-paths: "npm:4.2.0" @@ -7941,9 +7941,9 @@ __metadata: languageName: node linkType: hard -"ts-jest@npm:29.1.4": - version: 29.1.4 - resolution: "ts-jest@npm:29.1.4" +"ts-jest@npm:29.1.5": + version: 29.1.5 + resolution: "ts-jest@npm:29.1.5" dependencies: bs-logger: "npm:0.x" fast-json-stable-stringify: "npm:2.x" @@ -7973,7 +7973,7 @@ __metadata: optional: true bin: ts-jest: cli.js - checksum: 10/3103c0e2f9937ae6bb51918105883565bb2d11cae1121ae20aedd1c4374f843341463a4a1986e02a958d119be0d3a9b996d761bc4aac85152a29385e609fed3c + checksum: 10/11a29a49130f1c9bef5aebe8007f6be3e630af6c2dea6b00ff5a86d649321854a43966b4990a43960d77a3f98d7a753b9b7e19c20c42a2d38341d6e67a3e48d1 languageName: node linkType: hard From fc134b1842087d3e9dfe5e586afd1481b367c9c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Jun 2024 00:38:43 +0200 Subject: [PATCH 121/207] Bump typescript-eslint from 7.13.0 to 7.14.1 (#1685) Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 7.13.0 to 7.14.1. - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.14.1/packages/typescript-eslint) --- updated-dependencies: - dependency-name: typescript-eslint dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 116 +++++++++++++++++++++++++-------------------------- 2 files changed, 59 insertions(+), 59 deletions(-) diff --git a/package.json b/package.json index f7f033600c..4147398971 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "ts-node": "^10.9.2", "tsconfig-paths": "4.2.0", "typescript": "^5.4.5", - "typescript-eslint": "^7.13.0" + "typescript-eslint": "^7.14.1" }, "jest": { "moduleFileExtensions": [ diff --git a/yarn.lock b/yarn.lock index cb94083cab..d7b30ee02a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2012,15 +2012,15 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:7.13.0": - version: 7.13.0 - resolution: "@typescript-eslint/eslint-plugin@npm:7.13.0" +"@typescript-eslint/eslint-plugin@npm:7.14.1": + version: 7.14.1 + resolution: "@typescript-eslint/eslint-plugin@npm:7.14.1" dependencies: "@eslint-community/regexpp": "npm:^4.10.0" - "@typescript-eslint/scope-manager": "npm:7.13.0" - "@typescript-eslint/type-utils": "npm:7.13.0" - "@typescript-eslint/utils": "npm:7.13.0" - "@typescript-eslint/visitor-keys": "npm:7.13.0" + "@typescript-eslint/scope-manager": "npm:7.14.1" + "@typescript-eslint/type-utils": "npm:7.14.1" + "@typescript-eslint/utils": "npm:7.14.1" + "@typescript-eslint/visitor-keys": "npm:7.14.1" graphemer: "npm:^1.4.0" ignore: "npm:^5.3.1" natural-compare: "npm:^1.4.0" @@ -2031,44 +2031,44 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/93c3a0d8871d8351187503152a6c5199714eb62c96991e0d3e0caaee6881839dee4ad55e5de5d1a4389ae12ed10d3a845603de1f2f581337f782f19113022a65 + checksum: 10/48c815dbb92399965483c93b27816fad576c3b3227b59eebfe5525e24d07b39ec8b0c7459de83865c8d61c818696519f50b229714dd3ed705d5b35973bfcc781 languageName: node linkType: hard -"@typescript-eslint/parser@npm:7.13.0": - version: 7.13.0 - resolution: "@typescript-eslint/parser@npm:7.13.0" +"@typescript-eslint/parser@npm:7.14.1": + version: 7.14.1 + resolution: "@typescript-eslint/parser@npm:7.14.1" dependencies: - "@typescript-eslint/scope-manager": "npm:7.13.0" - "@typescript-eslint/types": "npm:7.13.0" - "@typescript-eslint/typescript-estree": "npm:7.13.0" - "@typescript-eslint/visitor-keys": "npm:7.13.0" + "@typescript-eslint/scope-manager": "npm:7.14.1" + "@typescript-eslint/types": "npm:7.14.1" + "@typescript-eslint/typescript-estree": "npm:7.14.1" + "@typescript-eslint/visitor-keys": "npm:7.14.1" debug: "npm:^4.3.4" peerDependencies: eslint: ^8.56.0 peerDependenciesMeta: typescript: optional: true - checksum: 10/ad930d9138c3caa9e0ac2d887798318b5b06df5aa1ecc50c2d8cd912e00cf13eb007256bfb4c11709f0191fc180614a15f84c0f0f03a50f035b0b8af0eb9409c + checksum: 10/f521462a7005cab5e4923937dcf36713d9438ded175b53332ae469d91cc9eb18cb3a23768b3c52063464280baae83f6b66db28cebb2e262d6d869d1a898b23f3 languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:7.13.0": - version: 7.13.0 - resolution: "@typescript-eslint/scope-manager@npm:7.13.0" +"@typescript-eslint/scope-manager@npm:7.14.1": + version: 7.14.1 + resolution: "@typescript-eslint/scope-manager@npm:7.14.1" dependencies: - "@typescript-eslint/types": "npm:7.13.0" - "@typescript-eslint/visitor-keys": "npm:7.13.0" - checksum: 10/2b258a06c5e747c80423b07855f052f327a4d5b0a0cf3a46221ef298653139d3b01ac1534fc0db6609fd962ba45ec87a0e12f8d3778183440923bcf4687832a5 + "@typescript-eslint/types": "npm:7.14.1" + "@typescript-eslint/visitor-keys": "npm:7.14.1" + checksum: 10/600a7beb96f5b96f675125285137339c2438b5b26db203a66eef52dd409e8c0db0dafb22c94547dfb963f8efdf63b0fb59e05655e2dcf84d54624863365a59e7 languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:7.13.0": - version: 7.13.0 - resolution: "@typescript-eslint/type-utils@npm:7.13.0" +"@typescript-eslint/type-utils@npm:7.14.1": + version: 7.14.1 + resolution: "@typescript-eslint/type-utils@npm:7.14.1" dependencies: - "@typescript-eslint/typescript-estree": "npm:7.13.0" - "@typescript-eslint/utils": "npm:7.13.0" + "@typescript-eslint/typescript-estree": "npm:7.14.1" + "@typescript-eslint/utils": "npm:7.14.1" debug: "npm:^4.3.4" ts-api-utils: "npm:^1.3.0" peerDependencies: @@ -2076,23 +2076,23 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/f51ccb3c59963db82a504b02c8d15bc518137c176b8d39891f7bcb7b4b02ca0fa918a3754781f198f592f1047dc24c49086430bbef857d877d085e14d33f7a6c + checksum: 10/75c279948a7e7e546d692e85a0b48fc3b648ffee1773feb7ff199aba1b0847a9a16c432b133aa72d26e645627403852b7dd24829f9b3badd6d4711c4cc38e9e4 languageName: node linkType: hard -"@typescript-eslint/types@npm:7.13.0": - version: 7.13.0 - resolution: "@typescript-eslint/types@npm:7.13.0" - checksum: 10/5adc39c569217ed7d09853385313f1fcf2c05385e5e0144740238e346afbc0dec576c1eb46f779368736b080e6f9f368483fff3378b0bf7e6b275f27a904f04d +"@typescript-eslint/types@npm:7.14.1": + version: 7.14.1 + resolution: "@typescript-eslint/types@npm:7.14.1" + checksum: 10/608057582bb195bd746a7bfb7c04dac4be1d4602b8fa681b2d1d50b564362b681dc2ca293b13cc4c7acc454f3a09f1ea2580415347efb7853e5df8ba34b7acdb languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:7.13.0": - version: 7.13.0 - resolution: "@typescript-eslint/typescript-estree@npm:7.13.0" +"@typescript-eslint/typescript-estree@npm:7.14.1": + version: 7.14.1 + resolution: "@typescript-eslint/typescript-estree@npm:7.14.1" dependencies: - "@typescript-eslint/types": "npm:7.13.0" - "@typescript-eslint/visitor-keys": "npm:7.13.0" + "@typescript-eslint/types": "npm:7.14.1" + "@typescript-eslint/visitor-keys": "npm:7.14.1" debug: "npm:^4.3.4" globby: "npm:^11.1.0" is-glob: "npm:^4.0.3" @@ -2102,31 +2102,31 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/d4cc68e8aa9902c5efa820582b05bfb6c1567e21e7743250778613a045f0b6bb05128f7cfc090368ab808ad91be6193b678569ca803f917b2958c3752bc4810b + checksum: 10/f75b956f7981712d3f85498f9d9fcc2243d79d6fe71b24bc688a7c43d2a4248f73ecfb78f9d58501fde87fc44b02e26c46f9ea2ae51eb8450db79ca169f91ef9 languageName: node linkType: hard -"@typescript-eslint/utils@npm:7.13.0": - version: 7.13.0 - resolution: "@typescript-eslint/utils@npm:7.13.0" +"@typescript-eslint/utils@npm:7.14.1": + version: 7.14.1 + resolution: "@typescript-eslint/utils@npm:7.14.1" dependencies: "@eslint-community/eslint-utils": "npm:^4.4.0" - "@typescript-eslint/scope-manager": "npm:7.13.0" - "@typescript-eslint/types": "npm:7.13.0" - "@typescript-eslint/typescript-estree": "npm:7.13.0" + "@typescript-eslint/scope-manager": "npm:7.14.1" + "@typescript-eslint/types": "npm:7.14.1" + "@typescript-eslint/typescript-estree": "npm:7.14.1" peerDependencies: eslint: ^8.56.0 - checksum: 10/c87bbb90c958ed4617f88767890af2a797adcf28060e85809a9cad2ce4ed55b5db685d3a8d062dbbf89d2a49e85759e2a9deb92ee1946a95d5de6cbd14ea42f4 + checksum: 10/1ef74214ca84e32f151364512a51e82b7da5590dee03d0de0e1abcf18009e569f9a0638506cf03bd4a844af634b4935458e334b7b2459e9a50a67aba7d6228c7 languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:7.13.0": - version: 7.13.0 - resolution: "@typescript-eslint/visitor-keys@npm:7.13.0" +"@typescript-eslint/visitor-keys@npm:7.14.1": + version: 7.14.1 + resolution: "@typescript-eslint/visitor-keys@npm:7.14.1" dependencies: - "@typescript-eslint/types": "npm:7.13.0" + "@typescript-eslint/types": "npm:7.14.1" eslint-visitor-keys: "npm:^3.4.3" - checksum: 10/5568dd435f22337c034da8c2dacd5be23b966c5978d25d96fca1358c59289861dfc4c39f2943c7790e947f75843d60035ad56c1f2c106f0e7d9ecf1ff6646065 + checksum: 10/42246f33cb3f9185c0b467c9a534e34a674e4fc08ba982a03aaa77dc1e569e916f1fca9ce9cd14c4df91f416e6e917bff51f98b8d8ca26ec5f67c253e8646bde languageName: node linkType: hard @@ -7277,7 +7277,7 @@ __metadata: ts-node: "npm:^10.9.2" tsconfig-paths: "npm:4.2.0" typescript: "npm:^5.4.5" - typescript-eslint: "npm:^7.13.0" + typescript-eslint: "npm:^7.14.1" viem: "npm:^2.16.2" winston: "npm:^3.13.0" zod: "npm:^3.23.8" @@ -8107,19 +8107,19 @@ __metadata: languageName: node linkType: hard -"typescript-eslint@npm:^7.13.0": - version: 7.13.0 - resolution: "typescript-eslint@npm:7.13.0" +"typescript-eslint@npm:^7.14.1": + version: 7.14.1 + resolution: "typescript-eslint@npm:7.14.1" dependencies: - "@typescript-eslint/eslint-plugin": "npm:7.13.0" - "@typescript-eslint/parser": "npm:7.13.0" - "@typescript-eslint/utils": "npm:7.13.0" + "@typescript-eslint/eslint-plugin": "npm:7.14.1" + "@typescript-eslint/parser": "npm:7.14.1" + "@typescript-eslint/utils": "npm:7.14.1" peerDependencies: eslint: ^8.56.0 peerDependenciesMeta: typescript: optional: true - checksum: 10/86a4261bccc0695c33b5ea74b16ae33d9a8321edf07aac4549de970b19f962e42d7043080904cde6459629495198e4a370e8a7c9b0a2fa0c3bd77c8d85f5700e + checksum: 10/f017459ca6877e301f74d0bb6efadee354d10e3a1520f902a5ff1959566d0e400ab7ea2008f78f7694d4ce69f4c502138ae78c24442423361725d4373d675720 languageName: node linkType: hard From 5e7a96457c8374eea889629518e71d4f835f446c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Jun 2024 00:39:02 +0200 Subject: [PATCH 122/207] Bump @types/node from 20.14.3 to 20.14.8 (#1686) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.14.3 to 20.14.8. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 4147398971..2cad0038da 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "@types/jest": "29.5.12", "@types/jsonwebtoken": "^9", "@types/lodash": "^4.17.5", - "@types/node": "^20.14.3", + "@types/node": "^20.14.8", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.2", "eslint": "^9.5.0", diff --git a/yarn.lock b/yarn.lock index d7b30ee02a..e32a8b7fd2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1918,12 +1918,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^20.14.3": - version: 20.14.3 - resolution: "@types/node@npm:20.14.3" +"@types/node@npm:^20.14.8": + version: 20.14.8 + resolution: "@types/node@npm:20.14.8" dependencies: undici-types: "npm:~5.26.4" - checksum: 10/4ac40f26cde19536224c1b32c06e20c40f1163bdde617bda73561bb13070cd444c6a280f3f10a0eb621a4e341eb2d9a6f3a18193b715c17e70e5fa884b81ed1a + checksum: 10/73822f66f269ce865df7e2f586787ac7444bd1169fd265cbed1e851b72787f1170517c5b616e0308ec2fbc0934ec6403b0f28d4152acbb0486071aec41167d51 languageName: node linkType: hard @@ -7251,7 +7251,7 @@ __metadata: "@types/jest": "npm:29.5.12" "@types/jsonwebtoken": "npm:^9" "@types/lodash": "npm:^4.17.5" - "@types/node": "npm:^20.14.3" + "@types/node": "npm:^20.14.8" "@types/semver": "npm:^7.5.8" "@types/supertest": "npm:^6.0.2" amqp-connection-manager: "npm:^4.1.14" From ecba22b1e622a1be07d7f87a55e8ae8d38152744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Wed, 26 Jun 2024 13:20:49 +0200 Subject: [PATCH 123/207] Adjust random TTL for not-found token prices calculation (#1693) Changes the implementation of CoingeckoApi._getRandomNotFoundTokenPriceTtl to avoid get negative TTLs if notFoundPriceTtlSeconds < NOT_FOUND_TTL_RANGE_SECONDS --- .../balances-api/coingecko-api.service.spec.ts | 4 ++-- .../balances-api/coingecko-api.service.ts | 17 ++++++++--------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/datasources/balances-api/coingecko-api.service.spec.ts b/src/datasources/balances-api/coingecko-api.service.spec.ts index bb4784ebc9..3433449a22 100644 --- a/src/datasources/balances-api/coingecko-api.service.spec.ts +++ b/src/datasources/balances-api/coingecko-api.service.spec.ts @@ -634,9 +634,9 @@ describe('CoingeckoAPI', () => { JSON.stringify({ [thirdTokenAddress]: { [lowerCaseFiatCode]: null } }), ); expect(mockCacheService.set.mock.calls[0][2]).toBeGreaterThanOrEqual( - (fakeConfigurationService.get( + fakeConfigurationService.get( 'balances.providers.safe.prices.notFoundPriceTtlSeconds', - ) as number) - CoingeckoApi.NOT_FOUND_TTL_RANGE_SECONDS, + ) as number, ); expect(mockCacheService.set.mock.calls[0][2]).toBeLessThanOrEqual( (fakeConfigurationService.get( diff --git a/src/datasources/balances-api/coingecko-api.service.ts b/src/datasources/balances-api/coingecko-api.service.ts index 914830fe87..81fa1bf3cb 100644 --- a/src/datasources/balances-api/coingecko-api.service.ts +++ b/src/datasources/balances-api/coingecko-api.service.ts @@ -16,7 +16,7 @@ import { NetworkService, INetworkService, } from '@/datasources/network/network.service.interface'; -import { difference, get } from 'lodash'; +import { difference, get, random } from 'lodash'; import { LoggingService, ILoggingService } from '@/logging/logging.interface'; import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; import { asError } from '@/logging/utils'; @@ -35,7 +35,7 @@ export class CoingeckoApi implements IPricesApi { /** * Time range in seconds used to get a random value when calculating a TTL for not-found token prices. */ - static readonly NOT_FOUND_TTL_RANGE_SECONDS: number = 60 * 60 * 24; + static readonly NOT_FOUND_TTL_RANGE_SECONDS: number = 600; // 10 minutes private readonly apiKey: string | undefined; private readonly baseUrl: string; private readonly defaultExpirationTimeInSeconds: number; @@ -343,14 +343,13 @@ export class CoingeckoApi implements IPricesApi { } /** - * Gets a random integer value between (notFoundPriceTtlSeconds - notFoundTtlRangeSeconds) - * and (notFoundPriceTtlSeconds + notFoundTtlRangeSeconds). + * Gets a random integer value between notFoundPriceTtlSeconds and (notFoundPriceTtlSeconds + notFoundTtlRangeSeconds). + * The minimum result will be greater than notFoundTtlRangeSeconds to avoid having a negative TTL. */ private _getRandomNotFoundTokenPriceTtl(): number { - const min = - this.notFoundPriceTtlSeconds - CoingeckoApi.NOT_FOUND_TTL_RANGE_SECONDS; - const max = - this.notFoundPriceTtlSeconds + CoingeckoApi.NOT_FOUND_TTL_RANGE_SECONDS; - return Math.floor(Math.random() * (max - min) + min); + return random( + this.notFoundPriceTtlSeconds, + this.notFoundPriceTtlSeconds + CoingeckoApi.NOT_FOUND_TTL_RANGE_SECONDS, + ); } } From 5abc8b8365b329b3edc0143998804d6b30611530 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 26 Jun 2024 13:22:41 +0200 Subject: [PATCH 124/207] Bump typescript from 5.4.5 to 5.5.2 (#1688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bump typescript from 5.4.5 to 5.5.2 Bumps [typescript](https://github.com/Microsoft/TypeScript) from 5.4.5 to 5.5.2. - [Release notes](https://github.com/Microsoft/TypeScript/releases) - [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml) - [Commits](https://github.com/Microsoft/TypeScript/compare/v5.4.5...v5.5.2) --- updated-dependencies: - dependency-name: typescript dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * Bump typescript from 5.4.5 to 5.5.2 Bumps [typescript](https://github.com/Microsoft/TypeScript) from 5.4.5 to 5.5.2. - [Release notes](https://github.com/Microsoft/TypeScript/releases) - [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml) - [Commits](https://github.com/Microsoft/TypeScript/compare/v5.4.5...v5.5.2) --- updated-dependencies: - dependency-name: typescript dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * Adjust FakeCacheService.increment implementation * Bump typescript from 5.4.5 to 5.5.2 Bumps [typescript](https://github.com/Microsoft/TypeScript) from 5.4.5 to 5.5.2. - [Release notes](https://github.com/Microsoft/TypeScript/releases) - [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml) - [Commits](https://github.com/Microsoft/TypeScript/compare/v5.4.5...v5.5.2) --- updated-dependencies: - dependency-name: typescript dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * Change tsconfig target to ES2018 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Hector Gómez Varela --- package.json | 2 +- .../cache/__tests__/fake.cache.service.ts | 10 ++++------ tsconfig.json | 2 +- yarn.lock | 18 +++++++++--------- 4 files changed, 15 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 2cad0038da..34b63a9a35 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "4.2.0", - "typescript": "^5.4.5", + "typescript": "^5.5.2", "typescript-eslint": "^7.14.1" }, "jest": { diff --git a/src/datasources/cache/__tests__/fake.cache.service.ts b/src/datasources/cache/__tests__/fake.cache.service.ts index 9a7c443305..2bf90fc01e 100644 --- a/src/datasources/cache/__tests__/fake.cache.service.ts +++ b/src/datasources/cache/__tests__/fake.cache.service.ts @@ -66,11 +66,9 @@ export class FakeCacheService implements ICacheService, ICacheReadiness { // eslint-disable-next-line @typescript-eslint/no-unused-vars expireTimeSeconds: number | undefined, ): Promise { - if (!this.cache[cacheKey]) { - this.cache[cacheKey] = 1; - } else { - this.cache[cacheKey] = ++(this.cache[cacheKey] as number); - } - return Promise.resolve(this.cache[cacheKey] as number); + let currentValue: number = this.cache[cacheKey] as number; + currentValue = currentValue ? currentValue + 1 : 1; + this.cache[cacheKey] = currentValue; + return Promise.resolve(currentValue); } } diff --git a/tsconfig.json b/tsconfig.json index 1d54375025..09e4e5f1a4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, - "target": "es2017", + "target": "ES2018", "sourceMap": true, "outDir": "./dist", "baseUrl": "./", diff --git a/yarn.lock b/yarn.lock index e32a8b7fd2..c81599f3a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7276,7 +7276,7 @@ __metadata: ts-loader: "npm:^9.5.1" ts-node: "npm:^10.9.2" tsconfig-paths: "npm:4.2.0" - typescript: "npm:^5.4.5" + typescript: "npm:^5.5.2" typescript-eslint: "npm:^7.14.1" viem: "npm:^2.16.2" winston: "npm:^3.13.0" @@ -8133,13 +8133,13 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.4.5": - version: 5.4.5 - resolution: "typescript@npm:5.4.5" +"typescript@npm:^5.5.2": + version: 5.5.2 + resolution: "typescript@npm:5.5.2" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 10/d04a9e27e6d83861f2126665aa8d84847e8ebabcea9125b9ebc30370b98cb38b5dff2508d74e2326a744938191a83a69aa9fddab41f193ffa43eabfdf3f190a5 + checksum: 10/9118b20f248e76b0dbff8737fef65dfa89d02668d4e633d2c5ceac99033a0ca5e8a1c1a53bc94da68e8f67677a88f318663dde859c9e9a09c1e116415daec2ba languageName: node linkType: hard @@ -8153,13 +8153,13 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5.4.5#optional!builtin": - version: 5.4.5 - resolution: "typescript@patch:typescript@npm%3A5.4.5#optional!builtin::version=5.4.5&hash=5adc0c" +"typescript@patch:typescript@npm%3A^5.5.2#optional!builtin": + version: 5.5.2 + resolution: "typescript@patch:typescript@npm%3A5.5.2#optional!builtin::version=5.5.2&hash=5adc0c" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 10/760f7d92fb383dbf7dee2443bf902f4365db2117f96f875cf809167f6103d55064de973db9f78fe8f31ec08fff52b2c969aee0d310939c0a3798ec75d0bca2e1 + checksum: 10/28b3de2ddaf63a7620e7ddbe5d377af71ce93ecc558c41bf0e3d88661d8e6e7aa6c7739164fef98055f69819e41faca49252938ef3633a3dff2734cca6a9042e languageName: node linkType: hard From e0cf121e1ebeadbf17770f4d08c1ccebf5fcf016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Wed, 26 Jun 2024 13:47:06 +0200 Subject: [PATCH 125/207] Implement POST /v1/accounts (#1689) Adds POST /v1/acounts to AccountsController, protected by AuthGuard. --- ....ts => test.accounts.datasource.module.ts} | 0 .../accounts/accounts.datasource.ts | 4 +- .../accounts/accounts.repository.interface.ts | 9 +- src/domain/accounts/accounts.repository.ts | 31 ++- .../entities/__tests__/account.builder.ts | 4 +- .../entities/__tests__/group.builder.ts | 4 +- .../accounts/entities/account.entity.spec.ts | 6 +- .../accounts/entities/account.entity.ts | 4 +- .../accounts/entities/group.entity.spec.ts | 4 +- .../accounts/entities/group.entity.ts | 0 .../accounts.datasource.interface.ts | 2 +- .../accounts/accounts.controller.spec.ts | 238 ++++++++++++++++++ src/routes/accounts/accounts.controller.ts | 36 +++ src/routes/accounts/accounts.module.ts | 9 +- src/routes/accounts/accounts.service.ts | 36 +++ .../__tests__/create-account.dto.builder.ts | 11 + .../accounts/entities/account.entity.ts | 20 ++ .../entities/create-account.dto.entity.ts | 10 + .../create-account.dto.schema.spec.ts | 71 ++++++ .../schemas/create-account.dto.schema.ts | 6 + 20 files changed, 485 insertions(+), 20 deletions(-) rename src/datasources/accounts/__tests__/{test.accounts.datasource.modulte.ts => test.accounts.datasource.module.ts} (100%) rename src/{datasources => domain}/accounts/entities/__tests__/account.builder.ts (75%) rename src/{datasources => domain}/accounts/entities/__tests__/group.builder.ts (66%) rename src/{datasources => domain}/accounts/entities/account.entity.spec.ts (95%) rename src/{datasources => domain}/accounts/entities/account.entity.ts (72%) rename src/{datasources => domain}/accounts/entities/group.entity.spec.ts (92%) rename src/{datasources => domain}/accounts/entities/group.entity.ts (100%) create mode 100644 src/routes/accounts/accounts.controller.spec.ts create mode 100644 src/routes/accounts/accounts.controller.ts create mode 100644 src/routes/accounts/accounts.service.ts create mode 100644 src/routes/accounts/entities/__tests__/create-account.dto.builder.ts create mode 100644 src/routes/accounts/entities/account.entity.ts create mode 100644 src/routes/accounts/entities/create-account.dto.entity.ts create mode 100644 src/routes/accounts/entities/schemas/__tests__/create-account.dto.schema.spec.ts create mode 100644 src/routes/accounts/entities/schemas/create-account.dto.schema.ts diff --git a/src/datasources/accounts/__tests__/test.accounts.datasource.modulte.ts b/src/datasources/accounts/__tests__/test.accounts.datasource.module.ts similarity index 100% rename from src/datasources/accounts/__tests__/test.accounts.datasource.modulte.ts rename to src/datasources/accounts/__tests__/test.accounts.datasource.module.ts diff --git a/src/datasources/accounts/accounts.datasource.ts b/src/datasources/accounts/accounts.datasource.ts index 5b6efcb298..e168461ca7 100644 --- a/src/datasources/accounts/accounts.datasource.ts +++ b/src/datasources/accounts/accounts.datasource.ts @@ -1,6 +1,6 @@ -import { Account } from '@/datasources/accounts/entities/account.entity'; +import { Account } from '@/domain/accounts/entities/account.entity'; import { IAccountsDatasource } from '@/domain/interfaces/accounts.datasource.interface'; -import { LoggingService, ILoggingService } from '@/logging/logging.interface'; +import { ILoggingService, LoggingService } from '@/logging/logging.interface'; import { asError } from '@/logging/utils'; import { Inject, diff --git a/src/domain/accounts/accounts.repository.interface.ts b/src/domain/accounts/accounts.repository.interface.ts index 80d20a6519..8c61916a2b 100644 --- a/src/domain/accounts/accounts.repository.interface.ts +++ b/src/domain/accounts/accounts.repository.interface.ts @@ -1,10 +1,17 @@ import { AccountsDatasourceModule } from '@/datasources/accounts/accounts.datasource.module'; import { AccountsRepository } from '@/domain/accounts/accounts.repository'; +import { Account } from '@/domain/accounts/entities/account.entity'; +import { AuthPayloadDto } from '@/domain/auth/entities/auth-payload.entity'; import { Module } from '@nestjs/common'; export const IAccountsRepository = Symbol('IAccountsRepository'); -export interface IAccountsRepository {} +export interface IAccountsRepository { + createAccount(args: { + auth: AuthPayloadDto; + address: `0x${string}`; + }): Promise; +} @Module({ imports: [AccountsDatasourceModule], diff --git a/src/domain/accounts/accounts.repository.ts b/src/domain/accounts/accounts.repository.ts index 5fd8eb26a8..36f66955b7 100644 --- a/src/domain/accounts/accounts.repository.ts +++ b/src/domain/accounts/accounts.repository.ts @@ -1,5 +1,32 @@ import { IAccountsRepository } from '@/domain/accounts/accounts.repository.interface'; -import { Injectable } from '@nestjs/common'; +import { + Account, + AccountSchema, +} from '@/domain/accounts/entities/account.entity'; +import { + AuthPayload, + AuthPayloadDto, +} from '@/domain/auth/entities/auth-payload.entity'; +import { IAccountsDatasource } from '@/domain/interfaces/accounts.datasource.interface'; +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; @Injectable() -export class AccountsRepository implements IAccountsRepository {} +export class AccountsRepository implements IAccountsRepository { + constructor( + @Inject(IAccountsDatasource) + private readonly datasource: IAccountsDatasource, + ) {} + + async createAccount(args: { + auth: AuthPayloadDto; + address: `0x${string}`; + }): Promise { + const authPayload = new AuthPayload(args.auth); + if (!authPayload.isForSigner(args.address)) { + throw new UnauthorizedException(); + } + + const account = await this.datasource.createAccount(args.address); + return AccountSchema.parse(account); + } +} diff --git a/src/datasources/accounts/entities/__tests__/account.builder.ts b/src/domain/accounts/entities/__tests__/account.builder.ts similarity index 75% rename from src/datasources/accounts/entities/__tests__/account.builder.ts rename to src/domain/accounts/entities/__tests__/account.builder.ts index d1047c90b2..1740dd1c75 100644 --- a/src/datasources/accounts/entities/__tests__/account.builder.ts +++ b/src/domain/accounts/entities/__tests__/account.builder.ts @@ -1,5 +1,5 @@ -import { IBuilder, Builder } from '@/__tests__/builder'; -import { Account } from '@/datasources/accounts/entities/account.entity'; +import { Builder, IBuilder } from '@/__tests__/builder'; +import { Account } from '@/domain/accounts/entities/account.entity'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; diff --git a/src/datasources/accounts/entities/__tests__/group.builder.ts b/src/domain/accounts/entities/__tests__/group.builder.ts similarity index 66% rename from src/datasources/accounts/entities/__tests__/group.builder.ts rename to src/domain/accounts/entities/__tests__/group.builder.ts index 06bc7f59ca..2fc2ed8428 100644 --- a/src/datasources/accounts/entities/__tests__/group.builder.ts +++ b/src/domain/accounts/entities/__tests__/group.builder.ts @@ -1,5 +1,5 @@ -import { IBuilder, Builder } from '@/__tests__/builder'; -import { Group } from '@/datasources/accounts/entities/group.entity'; +import { Builder, IBuilder } from '@/__tests__/builder'; +import { Group } from '@/domain/accounts/entities/group.entity'; import { faker } from '@faker-js/faker'; export function groupBuilder(): IBuilder { diff --git a/src/datasources/accounts/entities/account.entity.spec.ts b/src/domain/accounts/entities/account.entity.spec.ts similarity index 95% rename from src/datasources/accounts/entities/account.entity.spec.ts rename to src/domain/accounts/entities/account.entity.spec.ts index 7f4637bf94..5c4188f726 100644 --- a/src/datasources/accounts/entities/account.entity.spec.ts +++ b/src/domain/accounts/entities/account.entity.spec.ts @@ -1,7 +1,7 @@ -import { accountBuilder } from '@/datasources/accounts/entities/__tests__/account.builder'; -import { AccountSchema } from '@/datasources/accounts/entities/account.entity'; -import { getAddress } from 'viem'; +import { accountBuilder } from '@/domain/accounts/entities/__tests__/account.builder'; +import { AccountSchema } from '@/domain/accounts/entities/account.entity'; import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; describe('AccountSchema', () => { it('should verify an Account', () => { diff --git a/src/datasources/accounts/entities/account.entity.ts b/src/domain/accounts/entities/account.entity.ts similarity index 72% rename from src/datasources/accounts/entities/account.entity.ts rename to src/domain/accounts/entities/account.entity.ts index 32feba9217..778d7e09cd 100644 --- a/src/datasources/accounts/entities/account.entity.ts +++ b/src/domain/accounts/entities/account.entity.ts @@ -1,11 +1,11 @@ -import { GroupSchema } from '@/datasources/accounts/entities/group.entity'; import { RowSchema } from '@/datasources/db/entities/row.entity'; +import { GroupSchema } from '@/domain/accounts/entities/group.entity'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; import { z } from 'zod'; export type Account = z.infer; export const AccountSchema = RowSchema.extend({ - group_id: GroupSchema.shape.id, + group_id: GroupSchema.shape.id.nullable(), address: AddressSchema, }); diff --git a/src/datasources/accounts/entities/group.entity.spec.ts b/src/domain/accounts/entities/group.entity.spec.ts similarity index 92% rename from src/datasources/accounts/entities/group.entity.spec.ts rename to src/domain/accounts/entities/group.entity.spec.ts index 0a10d22cc1..ccaa0118e8 100644 --- a/src/datasources/accounts/entities/group.entity.spec.ts +++ b/src/domain/accounts/entities/group.entity.spec.ts @@ -1,5 +1,5 @@ -import { groupBuilder } from '@/datasources/accounts/entities/__tests__/group.builder'; -import { GroupSchema } from '@/datasources/accounts/entities/group.entity'; +import { groupBuilder } from '@/domain/accounts/entities/__tests__/group.builder'; +import { GroupSchema } from '@/domain/accounts/entities/group.entity'; import { faker } from '@faker-js/faker'; describe('GroupSchema', () => { diff --git a/src/datasources/accounts/entities/group.entity.ts b/src/domain/accounts/entities/group.entity.ts similarity index 100% rename from src/datasources/accounts/entities/group.entity.ts rename to src/domain/accounts/entities/group.entity.ts diff --git a/src/domain/interfaces/accounts.datasource.interface.ts b/src/domain/interfaces/accounts.datasource.interface.ts index a46d598e59..950ad7c107 100644 --- a/src/domain/interfaces/accounts.datasource.interface.ts +++ b/src/domain/interfaces/accounts.datasource.interface.ts @@ -1,4 +1,4 @@ -import { Account } from '@/datasources/accounts/entities/account.entity'; +import { Account } from '@/domain/accounts/entities/account.entity'; export const IAccountsDatasource = Symbol('IAccountsDatasource'); diff --git a/src/routes/accounts/accounts.controller.spec.ts b/src/routes/accounts/accounts.controller.spec.ts new file mode 100644 index 0000000000..9311e5e30c --- /dev/null +++ b/src/routes/accounts/accounts.controller.spec.ts @@ -0,0 +1,238 @@ +import { TestAppProvider } from '@/__tests__/test-app.provider'; +import { AppModule } from '@/app.module'; +import configuration from '@/config/entities/__tests__/configuration'; +import { TestAccountsDataSourceModule } from '@/datasources/accounts/__tests__/test.accounts.datasource.module'; +import { AccountsDatasourceModule } from '@/datasources/accounts/accounts.datasource.module'; +import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; +import { CacheModule } from '@/datasources/cache/cache.module'; +import jwtConfiguration from '@/datasources/jwt/configuration/__tests__/jwt.configuration'; +import { + JWT_CONFIGURATION_MODULE, + JwtConfigurationModule, +} from '@/datasources/jwt/configuration/jwt.configuration.module'; +import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; +import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; +import { NetworkModule } from '@/datasources/network/network.module'; +import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; +import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { accountBuilder } from '@/domain/accounts/entities/__tests__/account.builder'; +import { authPayloadDtoBuilder } from '@/domain/auth/entities/__tests__/auth-payload-dto.entity.builder'; +import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; +import { getSecondsUntil } from '@/domain/common/utils/time'; +import { IAccountsDatasource } from '@/domain/interfaces/accounts.datasource.interface'; +import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; +import { RequestScopedLoggingModule } from '@/logging/logging.module'; +import { faker } from '@faker-js/faker'; +import { + ConflictException, + INestApplication, + UnprocessableEntityException, +} from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Server } from 'net'; +import request from 'supertest'; +import { getAddress } from 'viem'; + +describe('AccountsController', () => { + let app: INestApplication; + let jwtService: IJwtService; + let accountDataSource: jest.MockedObjectDeep; + + beforeEach(async () => { + jest.resetAllMocks(); + jest.useFakeTimers(); + const defaultConfiguration = configuration(); + const testConfiguration = (): typeof defaultConfiguration => ({ + ...defaultConfiguration, + features: { + ...defaultConfiguration.features, + auth: true, + accounts: true, + }, + }); + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule.register(testConfiguration)], + }) + .overrideModule(JWT_CONFIGURATION_MODULE) + .useModule(JwtConfigurationModule.register(jwtConfiguration)) + .overrideModule(AccountsDatasourceModule) + .useModule(TestAccountsDataSourceModule) + .overrideModule(CacheModule) + .useModule(TestCacheModule) + .overrideModule(RequestScopedLoggingModule) + .useModule(TestLoggingModule) + .overrideModule(NetworkModule) + .useModule(TestNetworkModule) + .overrideModule(QueuesApiModule) + .useModule(TestQueuesApiModule) + .compile(); + jwtService = moduleFixture.get(IJwtService); + accountDataSource = moduleFixture.get(IAccountsDatasource); + + app = await new TestAppProvider().provide(moduleFixture); + await app.init(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('Create accounts', () => { + it('should create an account', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + const account = accountBuilder().build(); + accountDataSource.createAccount.mockResolvedValue(account); + + await request(app.getHttpServer()) + .post(`/v1/accounts`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address: address.toLowerCase() }) + .expect(201); + + expect(accountDataSource.createAccount).toHaveBeenCalledTimes(1); + // Check the address was checksummed + expect(accountDataSource.createAccount).toHaveBeenCalledWith(address); + }); + + it('Returns 403 if no token is present', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + + await request(app.getHttpServer()) + .post(`/v1/accounts`) + .send({ address }) + .expect(403); + + expect(accountDataSource.getAccount).not.toHaveBeenCalled(); + }); + + it('returns 403 if token is not a valid JWT', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const accessToken = faker.string.sample(); + + expect(() => jwtService.verify(accessToken)).toThrow('jwt malformed'); + await request(app.getHttpServer()) + .post(`/v1/accounts`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address }) + .expect(403); + + expect(accountDataSource.getAccount).not.toHaveBeenCalled(); + }); + + it('returns 403 is token it not yet valid', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto, { + notBefore: getSecondsUntil(faker.date.future()), + }); + + await request(app.getHttpServer()) + .post(`/v1/accounts`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address }) + .expect(403); + + expect(accountDataSource.getAccount).not.toHaveBeenCalled(); + }); + + it('returns 403 if token has expired', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto, { expiresIn: 0 }); + jest.advanceTimersByTime(1_000); + + await request(app.getHttpServer()) + .post(`/v1/accounts`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address }) + .expect(403); + + expect(accountDataSource.getAccount).not.toHaveBeenCalled(); + }); + + it('returns 403 if signer_address is not a valid Ethereum address', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', faker.string.hexadecimal() as `0x${string}`) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + + await request(app.getHttpServer()) + .post(`/v1/accounts`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address }) + .expect(403); + + expect(accountDataSource.getAccount).not.toHaveBeenCalled(); + }); + + it('returns 403 if chain_id is not a valid chain ID', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', faker.lorem.sentence()) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + + await request(app.getHttpServer()) + .post(`/v1/accounts`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address }) + .expect(403); + + expect(accountDataSource.getAccount).not.toHaveBeenCalled(); + }); + + it('should propagate errors', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + accountDataSource.createAccount.mockRejectedValue( + new UnprocessableEntityException('Datasource error'), + ); + + await request(app.getHttpServer()) + .post(`/v1/accounts`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address: address.toLowerCase() }) + .expect(422); + + accountDataSource.createAccount.mockRejectedValue( + new ConflictException('Datasource error'), + ); + + await request(app.getHttpServer()) + .post(`/v1/accounts`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address: address.toLowerCase() }) + .expect(409); + + expect(accountDataSource.createAccount).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/routes/accounts/accounts.controller.ts b/src/routes/accounts/accounts.controller.ts new file mode 100644 index 0000000000..3cae962127 --- /dev/null +++ b/src/routes/accounts/accounts.controller.ts @@ -0,0 +1,36 @@ +import { AccountsService } from '@/routes/accounts/accounts.service'; +import { Account } from '@/routes/accounts/entities/account.entity'; +import { CreateAccountDto } from '@/routes/accounts/entities/create-account.dto.entity'; +import { CreateAccountDtoSchema } from '@/routes/accounts/entities/schemas/create-account.dto.schema'; +import { AuthGuard } from '@/routes/auth/guards/auth.guard'; +import { ValidationPipe } from '@/validation/pipes/validation.pipe'; +import { + Body, + Controller, + HttpCode, + HttpStatus, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { Request } from 'express'; + +@ApiTags('accounts') +@Controller({ path: 'accounts', version: '1' }) +export class AccountsController { + constructor(private readonly accountsService: AccountsService) {} + + @ApiOkResponse({ type: Account }) + @Post() + @HttpCode(HttpStatus.CREATED) + @UseGuards(AuthGuard) + async createAccount( + @Body(new ValidationPipe(CreateAccountDtoSchema)) + createAccountDto: CreateAccountDto, + @Req() request: Request, + ): Promise { + const auth = request.accessToken; + return this.accountsService.createAccount({ auth, createAccountDto }); + } +} diff --git a/src/routes/accounts/accounts.module.ts b/src/routes/accounts/accounts.module.ts index 2d32ec52e7..10c9536983 100644 --- a/src/routes/accounts/accounts.module.ts +++ b/src/routes/accounts/accounts.module.ts @@ -1,9 +1,12 @@ import { AccountsRepositoryModule } from '@/domain/accounts/accounts.repository.interface'; +import { AuthRepositoryModule } from '@/domain/auth/auth.repository.interface'; +import { AccountsController } from '@/routes/accounts/accounts.controller'; +import { AccountsService } from '@/routes/accounts/accounts.service'; import { Module } from '@nestjs/common'; @Module({ - imports: [AccountsRepositoryModule], - controllers: [], - providers: [], + imports: [AccountsRepositoryModule, AuthRepositoryModule], + controllers: [AccountsController], + providers: [AccountsService], }) export class AccountsModule {} diff --git a/src/routes/accounts/accounts.service.ts b/src/routes/accounts/accounts.service.ts new file mode 100644 index 0000000000..eb2d906f8c --- /dev/null +++ b/src/routes/accounts/accounts.service.ts @@ -0,0 +1,36 @@ +import { IAccountsRepository } from '@/domain/accounts/accounts.repository.interface'; +import { Account as DomainAccount } from '@/domain/accounts/entities/account.entity'; +import { AuthPayloadDto } from '@/domain/auth/entities/auth-payload.entity'; +import { Account } from '@/routes/accounts/entities/account.entity'; +import { CreateAccountDto } from '@/routes/accounts/entities/create-account.dto.entity'; +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; + +@Injectable() +export class AccountsService { + constructor( + @Inject(IAccountsRepository) + private readonly accountsRepository: IAccountsRepository, + ) {} + + async createAccount(args: { + auth?: AuthPayloadDto; + createAccountDto: CreateAccountDto; + }): Promise { + if (!args.auth) { + throw new UnauthorizedException(); + } + const domainAccount = await this.accountsRepository.createAccount({ + auth: args.auth, + address: args.createAccountDto.address, + }); + return this.mapAccount(domainAccount); + } + + private mapAccount(domainAccount: DomainAccount): Account { + return new Account( + domainAccount.id.toString(), + domainAccount.group_id?.toString() ?? null, + domainAccount.address, + ); + } +} diff --git a/src/routes/accounts/entities/__tests__/create-account.dto.builder.ts b/src/routes/accounts/entities/__tests__/create-account.dto.builder.ts new file mode 100644 index 0000000000..720a85ac8b --- /dev/null +++ b/src/routes/accounts/entities/__tests__/create-account.dto.builder.ts @@ -0,0 +1,11 @@ +import { IBuilder, Builder } from '@/__tests__/builder'; +import { CreateAccountDto } from '@/routes/accounts/entities/create-account.dto.entity'; +import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; + +export function createAccountDtoBuilder(): IBuilder { + return new Builder().with( + 'address', + getAddress(faker.finance.ethereumAddress()), + ); +} diff --git a/src/routes/accounts/entities/account.entity.ts b/src/routes/accounts/entities/account.entity.ts new file mode 100644 index 0000000000..6267ba5486 --- /dev/null +++ b/src/routes/accounts/entities/account.entity.ts @@ -0,0 +1,20 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class Account { + @ApiProperty() + accountId: string; + @ApiPropertyOptional({ type: String, nullable: true }) + groupId: string | null; + @ApiProperty() + address: `0x${string}`; + + constructor( + accountId: string, + groupId: string | null, + address: `0x${string}`, + ) { + this.accountId = accountId; + this.groupId = groupId; + this.address = address; + } +} diff --git a/src/routes/accounts/entities/create-account.dto.entity.ts b/src/routes/accounts/entities/create-account.dto.entity.ts new file mode 100644 index 0000000000..dac9f5bf47 --- /dev/null +++ b/src/routes/accounts/entities/create-account.dto.entity.ts @@ -0,0 +1,10 @@ +import { CreateAccountDtoSchema } from '@/routes/accounts/entities/schemas/create-account.dto.schema'; +import { ApiProperty } from '@nestjs/swagger'; +import { z } from 'zod'; + +export class CreateAccountDto + implements z.infer +{ + @ApiProperty() + address!: `0x${string}`; +} diff --git a/src/routes/accounts/entities/schemas/__tests__/create-account.dto.schema.spec.ts b/src/routes/accounts/entities/schemas/__tests__/create-account.dto.schema.spec.ts new file mode 100644 index 0000000000..4260c33807 --- /dev/null +++ b/src/routes/accounts/entities/schemas/__tests__/create-account.dto.schema.spec.ts @@ -0,0 +1,71 @@ +import { createAccountDtoBuilder } from '@/routes/accounts/entities/__tests__/create-account.dto.builder'; +import { CreateAccountDtoSchema } from '@/routes/accounts/entities/schemas/create-account.dto.schema'; +import { ZodError } from 'zod'; + +describe('CreateAccountDtoSchema', () => { + it('should validate a valid CreateAccountDto', () => { + const createAccountDto = createAccountDtoBuilder().build(); + + const result = CreateAccountDtoSchema.safeParse(createAccountDto); + + expect(result.success).toBe(true); + }); + + it('should not validate an invalid CreateAccountDto', () => { + const createAccountDto = { invalid: 'createAccountDto' }; + + const result = CreateAccountDtoSchema.safeParse(createAccountDto); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['address'], + message: 'Required', + }, + ]), + ); + }); + + describe('address', () => { + it('should not validate an invalid address', () => { + const createAccountDto = createAccountDtoBuilder().build(); + // @ts-expect-error - address is expected to be a ETH address + createAccountDto.address = 'invalid address'; + + const result = CreateAccountDtoSchema.safeParse(createAccountDto); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'custom', + message: 'Invalid address', + path: ['address'], + }, + ]), + ); + }); + + it('should not validate without an address', () => { + const createAccountDto = createAccountDtoBuilder().build(); + // @ts-expect-error - inferred type doesn't allow optional properties + delete createAccountDto.address; + + const result = CreateAccountDtoSchema.safeParse(createAccountDto); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['address'], + message: 'Required', + }, + ]), + ); + }); + }); +}); diff --git a/src/routes/accounts/entities/schemas/create-account.dto.schema.ts b/src/routes/accounts/entities/schemas/create-account.dto.schema.ts new file mode 100644 index 0000000000..e615d3e67b --- /dev/null +++ b/src/routes/accounts/entities/schemas/create-account.dto.schema.ts @@ -0,0 +1,6 @@ +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; +import { z } from 'zod'; + +export const CreateAccountDtoSchema = z.object({ + address: AddressSchema, +}); From 8ff861396020b035a3c5572289a26f9d9a300382 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Wed, 26 Jun 2024 16:44:53 +0200 Subject: [PATCH 126/207] Fix bad SWAPS_MAX_NUMBER_OF_PARTS env variable (#1695) Sets the `swaps.maxNumberOfParts` configuration to point to an optional `SWAPS_MAX_NUMBER_OF_PARTS` environment variable. --- src/config/entities/configuration.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 9e11bc6102..f429240aee 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -258,6 +258,8 @@ export default () => ({ // Upper limit of parts we will request from CoW for TWAP orders, after // which we return base values for those orders // Note: 11 is the average number of parts, confirmed by CoW - maxNumberOfParts: parseInt(process.env.BALANCES_TTL_SECONDS ?? `${11}`), + maxNumberOfParts: parseInt( + process.env.SWAPS_MAX_NUMBER_OF_PARTS ?? `${11}`, + ), }, }); From 48b6e6b5044feab57eb0aa902f1344386b9d50fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Wed, 26 Jun 2024 16:47:46 +0200 Subject: [PATCH 127/207] Remove unused balancesTtlSeconds configuration key (#1696) Removes `balances.balancesTtlSeconds` configuration key, as it wasn't used in the service code. --- src/config/entities/__tests__/configuration.ts | 1 - src/config/entities/configuration.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index dab194371e..61af87527d 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -20,7 +20,6 @@ export default (): ReturnType => ({ maxValidityPeriodSeconds: faker.number.int({ min: 1, max: 60 * 1_000 }), }, balances: { - balancesTtlSeconds: faker.number.int(), providers: { safe: { prices: { diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index f429240aee..e9fbbd3ec0 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -34,7 +34,6 @@ export default () => ({ ), }, balances: { - balancesTtlSeconds: parseInt(process.env.BALANCES_TTL_SECONDS ?? `${300}`), providers: { safe: { prices: { From 7adf4f3993b5b2562240466a76fa219b1e344e4c Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Wed, 26 Jun 2024 16:50:01 +0200 Subject: [PATCH 128/207] Add method to `ISwapsApi` for fetching orders by transaction hash (#1690) - Add `ISwapsApi['getOrders']` and implementation - Add `ISwapsRepository['getOrders']` and validated implementation --- src/datasources/swaps-api/cowswap-api.service.ts | 10 ++++++++++ src/domain/interfaces/swaps-api.interface.ts | 2 ++ src/domain/swaps/entities/order.entity.ts | 2 ++ src/domain/swaps/swaps.repository.ts | 14 +++++++++++++- 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/datasources/swaps-api/cowswap-api.service.ts b/src/datasources/swaps-api/cowswap-api.service.ts index 28afc01bc5..c87406bf79 100644 --- a/src/datasources/swaps-api/cowswap-api.service.ts +++ b/src/datasources/swaps-api/cowswap-api.service.ts @@ -21,6 +21,16 @@ export class CowSwapApi implements ISwapsApi { } } + async getOrders(txHash: string): Promise> { + try { + const url = `${this.baseUrl}/api/v1/transactions/${txHash}/orders`; + const { data } = await this.networkService.get>({ url }); + return data; + } catch (error) { + throw this.httpErrorFactory.from(error); + } + } + async getFullAppData(appDataHash: `0x${string}`): Promise { try { const url = `${this.baseUrl}/api/v1/app_data/${appDataHash}`; diff --git a/src/domain/interfaces/swaps-api.interface.ts b/src/domain/interfaces/swaps-api.interface.ts index ba49a32684..b22e576d11 100644 --- a/src/domain/interfaces/swaps-api.interface.ts +++ b/src/domain/interfaces/swaps-api.interface.ts @@ -4,5 +4,7 @@ import { Order } from '@/domain/swaps/entities/order.entity'; export interface ISwapsApi { getOrder(uid: string): Promise; + getOrders(txHash: string): Promise>; + getFullAppData(appDataHash: `0x${string}`): Promise; } diff --git a/src/domain/swaps/entities/order.entity.ts b/src/domain/swaps/entities/order.entity.ts index 8f5ce71f26..18db41c28b 100644 --- a/src/domain/swaps/entities/order.entity.ts +++ b/src/domain/swaps/entities/order.entity.ts @@ -101,3 +101,5 @@ export const OrderSchema = z.object({ executedSurplusFee: z.coerce.bigint().nullish().default(null), fullAppData: FullAppDataSchema.shape.fullAppData, }); + +export const OrdersSchema = z.array(OrderSchema); diff --git a/src/domain/swaps/swaps.repository.ts b/src/domain/swaps/swaps.repository.ts index ef062975dc..93183e67d5 100644 --- a/src/domain/swaps/swaps.repository.ts +++ b/src/domain/swaps/swaps.repository.ts @@ -1,6 +1,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { ISwapsApiFactory } from '@/domain/interfaces/swaps-api.factory'; -import { Order, OrderSchema } from '@/domain/swaps/entities/order.entity'; +import { + Order, + OrderSchema, + OrdersSchema, +} from '@/domain/swaps/entities/order.entity'; import { FullAppData, FullAppDataSchema, @@ -11,6 +15,8 @@ export const ISwapsRepository = Symbol('ISwapsRepository'); export interface ISwapsRepository { getOrder(chainId: string, orderUid: `0x${string}`): Promise; + getOrders(chainId: string, txHash: string): Promise>; + getFullAppData( chainId: string, appDataHash: `0x${string}`, @@ -30,6 +36,12 @@ export class SwapsRepository implements ISwapsRepository { return OrderSchema.parse(order); } + async getOrders(chainId: string, txHash: string): Promise> { + const api = this.swapsApiFactory.get(chainId); + const order = await api.getOrders(txHash); + return OrdersSchema.parse(order); + } + async getFullAppData( chainId: string, appDataHash: `0x${string}`, From 40693414f9fc3e633df03d02a3ee2a3ff122192e Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 27 Jun 2024 08:43:08 +0200 Subject: [PATCH 129/207] Map swap-related transfers (#1691) Create new SwapTransferTransactionInfo Create new SwapTransferHelper Create new SwapTransferInfoMapper and include it in TransferInfoMapper --- .../confirmation-view.entity.ts | 4 +- .../entities/swaps/swap-order-info.entity.ts | 14 +- .../entities/transaction-info.entity.ts | 1 + .../entities/transaction.entity.ts | 3 + .../helpers/gp-v2-order.helper.ts | 7 +- .../transactions/helpers/swap-order.helper.ts | 28 +- .../helpers/swap-transfer.helper.ts | 37 ++ .../transactions/helpers/twap-order.helper.ts | 2 + .../mappers/common/swap-order.mapper.spec.ts | 180 -------- .../mappers/common/swap-order.mapper.ts | 36 +- .../mappers/common/transaction-info.mapper.ts | 34 -- .../swap-transfer-info.mapper.spec.ts | 399 ++++++++++++++++++ .../transfers/swap-transfer-info.mapper.ts | 147 +++++++ .../transfers/transfer-info.mapper.spec.ts | 18 +- .../mappers/transfers/transfer-info.mapper.ts | 48 ++- .../mappers/transfers/transfer.mapper.spec.ts | 13 + .../swap-transfer-transaction-info.entity.ts | 170 ++++++++ .../transactions/transactions.module.ts | 6 + 18 files changed, 855 insertions(+), 292 deletions(-) create mode 100644 src/routes/transactions/helpers/swap-transfer.helper.ts create mode 100644 src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.spec.ts create mode 100644 src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.ts create mode 100644 src/routes/transactions/swap-transfer-transaction-info.entity.ts diff --git a/src/routes/transactions/entities/confirmation-view/confirmation-view.entity.ts b/src/routes/transactions/entities/confirmation-view/confirmation-view.entity.ts index 20ae3b4bba..78006ec21e 100644 --- a/src/routes/transactions/entities/confirmation-view/confirmation-view.entity.ts +++ b/src/routes/transactions/entities/confirmation-view/confirmation-view.entity.ts @@ -68,8 +68,8 @@ export class CowSwapConfirmationView implements Baseline, OrderInfo { }) status: OrderStatus; - @ApiProperty({ enum: ['buy', 'sell'] }) - kind: 'buy' | 'sell'; + @ApiProperty({ enum: Object.values(OrderKind) }) + kind: OrderKind; @ApiProperty({ enum: OrderClass, diff --git a/src/routes/transactions/entities/swaps/swap-order-info.entity.ts b/src/routes/transactions/entities/swaps/swap-order-info.entity.ts index d5cf0f5b33..214be39aed 100644 --- a/src/routes/transactions/entities/swaps/swap-order-info.entity.ts +++ b/src/routes/transactions/entities/swaps/swap-order-info.entity.ts @@ -7,13 +7,17 @@ import { ApiProperty, ApiPropertyOptional, } from '@nestjs/swagger'; -import { OrderClass, OrderStatus } from '@/domain/swaps/entities/order.entity'; +import { + OrderClass, + OrderKind, + OrderStatus, +} from '@/domain/swaps/entities/order.entity'; import { TokenInfo } from '@/routes/transactions/entities/swaps/token-info.entity'; export interface OrderInfo { uid: string; status: OrderStatus; - kind: 'buy' | 'sell'; + kind: OrderKind; orderClass: OrderClass; validUntil: number; sellAmount: string; @@ -43,8 +47,8 @@ export class SwapOrderTransactionInfo }) status: OrderStatus; - @ApiProperty({ enum: ['buy', 'sell'] }) - kind: 'buy' | 'sell'; + @ApiProperty({ enum: Object.values(OrderKind) }) + kind: OrderKind; @ApiProperty({ enum: OrderClass, @@ -115,7 +119,7 @@ export class SwapOrderTransactionInfo constructor(args: { uid: string; orderStatus: OrderStatus; - kind: 'buy' | 'sell'; + kind: OrderKind; class: OrderClass; validUntil: number; sellAmount: string; diff --git a/src/routes/transactions/entities/transaction-info.entity.ts b/src/routes/transactions/entities/transaction-info.entity.ts index 85bc3365cb..5a0f9db06a 100644 --- a/src/routes/transactions/entities/transaction-info.entity.ts +++ b/src/routes/transactions/entities/transaction-info.entity.ts @@ -7,6 +7,7 @@ export enum TransactionInfoType { SettingsChange = 'SettingsChange', Transfer = 'Transfer', SwapOrder = 'SwapOrder', + SwapTransfer = 'SwapTransfer', TwapOrder = 'TwapOrder', } diff --git a/src/routes/transactions/entities/transaction.entity.ts b/src/routes/transactions/entities/transaction.entity.ts index 76fca02fc3..c01f76b79d 100644 --- a/src/routes/transactions/entities/transaction.entity.ts +++ b/src/routes/transactions/entities/transaction.entity.ts @@ -14,6 +14,7 @@ import { SettingsChangeTransaction } from '@/routes/transactions/entities/settin import { TransactionInfo } from '@/routes/transactions/entities/transaction-info.entity'; import { TransferTransactionInfo } from '@/routes/transactions/entities/transfer-transaction-info.entity'; import { SwapOrderTransactionInfo } from '@/routes/transactions/entities/swaps/swap-order-info.entity'; +import { SwapTransferTransactionInfo } from '@/routes/transactions/swap-transfer-transaction-info.entity'; import { TwapOrderTransactionInfo } from '@/routes/transactions/entities/swaps/twap-order-info.entity'; @ApiExtraModels( @@ -24,6 +25,7 @@ import { TwapOrderTransactionInfo } from '@/routes/transactions/entities/swaps/t ModuleExecutionInfo, MultisigExecutionInfo, SwapOrderTransactionInfo, + SwapTransferTransactionInfo, TwapOrderTransactionInfo, ) export class Transaction { @@ -41,6 +43,7 @@ export class Transaction { { $ref: getSchemaPath(CustomTransactionInfo) }, { $ref: getSchemaPath(SettingsChangeTransaction) }, { $ref: getSchemaPath(SwapOrderTransactionInfo) }, + { $ref: getSchemaPath(SwapTransferTransactionInfo) }, { $ref: getSchemaPath(TwapOrderTransactionInfo) }, { $ref: getSchemaPath(TransferTransactionInfo) }, ], diff --git a/src/routes/transactions/helpers/gp-v2-order.helper.ts b/src/routes/transactions/helpers/gp-v2-order.helper.ts index 4a0d14a9d9..cfac0431ac 100644 --- a/src/routes/transactions/helpers/gp-v2-order.helper.ts +++ b/src/routes/transactions/helpers/gp-v2-order.helper.ts @@ -4,11 +4,12 @@ import { TypedDataDomain, encodePacked, hashTypedData } from 'viem'; @Injectable() export class GPv2OrderHelper { + public static readonly SettlementContractAddress = + '0x9008D19f58AAbD9eD0D60971565AA8510560ab41' as const; + // Domain private static readonly DomainName = 'Gnosis Protocol' as const; private static readonly DomainVersion = 'v2' as const; - private static readonly DomainVerifyingContract = - '0x9008D19f58AAbD9eD0D60971565AA8510560ab41' as const; // Typed data private static readonly TypedDataPrimaryType = 'Order' as const; @@ -87,7 +88,7 @@ export class GPv2OrderHelper { name: GPv2OrderHelper.DomainName, version: GPv2OrderHelper.DomainVersion, chainId: Number(chainId), - verifyingContract: GPv2OrderHelper.DomainVerifyingContract, + verifyingContract: GPv2OrderHelper.SettlementContractAddress, }; } } diff --git a/src/routes/transactions/helpers/swap-order.helper.ts b/src/routes/transactions/helpers/swap-order.helper.ts index c46372f978..8d944a11d7 100644 --- a/src/routes/transactions/helpers/swap-order.helper.ts +++ b/src/routes/transactions/helpers/swap-order.helper.ts @@ -19,7 +19,7 @@ import { export class SwapOrderHelper { // This is the Native Currency address considered by CoW Swap // https://docs.cow.fi/cow-protocol/reference/sdks/cow-sdk/modules#buy_eth_address - private static readonly NATIVE_CURRENCY_ADDRESS = + public static readonly NATIVE_CURRENCY_ADDRESS = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'; private readonly restrictApps: boolean = @@ -42,7 +42,7 @@ export class SwapOrderHelper { private readonly chainsRepository: IChainsRepository, ) {} - // TODO: Refactor findSwapOrder and findTwapSwapOrder to avoid code duplication + // TODO: Refactor findSwapOrder, findSwapTransfer and findTwapOrder to avoid code duplication /** * Finds the swap order in the transaction data. @@ -71,30 +71,6 @@ export class SwapOrderHelper { return null; } - /** - * Finds the `settle` transaction in provided data. - * The call can either be direct or parsed from within a MultiSend batch. - * - * @param data - transaction data to search for the `settle` transaction in - * @returns transaction data of `settle` transaction if found, otherwise null - */ - public findTwapSwapOrder(data: `0x${string}`): `0x${string}` | null { - if (this.gpv2Decoder.helpers.isSettle(data)) { - return data; - } - - if (this.multiSendDecoder.helpers.isMultiSend(data)) { - const transactions = this.multiSendDecoder.mapMultiSendTransactions(data); - for (const transaction of transactions) { - if (this.gpv2Decoder.helpers.isSettle(transaction.data)) { - return transaction.data; - } - } - } - - return null; - } - /** * Retrieves detailed information about a specific order and its associated tokens * diff --git a/src/routes/transactions/helpers/swap-transfer.helper.ts b/src/routes/transactions/helpers/swap-transfer.helper.ts new file mode 100644 index 0000000000..4162680b45 --- /dev/null +++ b/src/routes/transactions/helpers/swap-transfer.helper.ts @@ -0,0 +1,37 @@ +import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; +import { GPv2Decoder } from '@/domain/swaps/contracts/decoders/gp-v2-decoder.helper'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class SwapTransferHelper { + constructor( + private readonly multiSendDecoder: MultiSendDecoder, + private readonly gpv2Decoder: GPv2Decoder, + ) {} + + // TODO: Refactor findSwapOrder, findSwapTransfer and findTwapOrder to avoid code duplication + + /** + * Finds the `settle` transaction in provided data. + * The call can either be direct or parsed from within a MultiSend batch. + * + * @param data - transaction data to search for the `settle` transaction in + * @returns transaction data of `settle` transaction if found, otherwise null + */ + public findSwapTransfer(data: `0x${string}`): `0x${string}` | null { + if (this.gpv2Decoder.helpers.isSettle(data)) { + return data; + } + + if (this.multiSendDecoder.helpers.isMultiSend(data)) { + const transactions = this.multiSendDecoder.mapMultiSendTransactions(data); + for (const transaction of transactions) { + if (this.gpv2Decoder.helpers.isSettle(transaction.data)) { + return transaction.data; + } + } + } + + return null; + } +} diff --git a/src/routes/transactions/helpers/twap-order.helper.ts b/src/routes/transactions/helpers/twap-order.helper.ts index f2f953e23e..18bb1af356 100644 --- a/src/routes/transactions/helpers/twap-order.helper.ts +++ b/src/routes/transactions/helpers/twap-order.helper.ts @@ -34,6 +34,8 @@ export class TwapOrderHelper { private readonly composableCowDecoder: ComposableCowDecoder, ) {} + // TODO: Refactor findSwapOrder, findSwapTransfer and findTwapOrder to avoid code duplication + /** * Finds a TWAP order in a given transaction, either directly called or in a MultiSend * diff --git a/src/routes/transactions/mappers/common/swap-order.mapper.spec.ts b/src/routes/transactions/mappers/common/swap-order.mapper.spec.ts index 7f51d6fea0..ea0ad50402 100644 --- a/src/routes/transactions/mappers/common/swap-order.mapper.spec.ts +++ b/src/routes/transactions/mappers/common/swap-order.mapper.spec.ts @@ -1,184 +1,4 @@ -import { IConfigurationService } from '@/config/configuration.service.interface'; -import { IChainsRepository } from '@/domain/chains/chains.repository.interface'; -import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; -import { GPv2Decoder } from '@/domain/swaps/contracts/decoders/gp-v2-decoder.helper'; -import { Order } from '@/domain/swaps/entities/order.entity'; -import { ISwapsRepository } from '@/domain/swaps/swaps.repository'; -import { tokenBuilder } from '@/domain/tokens/__tests__/token.builder'; -import { ITokenRepository } from '@/domain/tokens/token.repository.interface'; -import { GPv2OrderHelper } from '@/routes/transactions/helpers/gp-v2-order.helper'; -import { SwapOrderHelper } from '@/routes/transactions/helpers/swap-order.helper'; -import { SwapOrderMapper } from '@/routes/transactions/mappers/common/swap-order.mapper'; -import { faker } from '@faker-js/faker'; -import { ILoggingService } from '@/logging/logging.interface'; - -const loggingService = { - debug: jest.fn(), -} as jest.MockedObjectDeep; -const mockLoggingService = jest.mocked(loggingService); - -const mockTokenRepository = { - getToken: jest.fn(), -} as jest.MockedObjectDeep; - -const mockSwapsRepository = { - getOrder: jest.fn(), - getFullAppData: jest.fn(), -} as jest.MockedObjectDeep; - -const mockConfigurationService = { - getOrThrow: jest.fn(), -} as jest.MockedObjectDeep; - -const mockChainsRepository = { - getChain: jest.fn(), -} as jest.MockedObjectDeep; - describe('SwapOrderMapper', () => { - let target: SwapOrderMapper; - const restrictApps = false; - let swapsExplorerBaseUri: string; - - beforeEach(() => { - jest.resetAllMocks(); - - mockConfigurationService.getOrThrow.mockImplementation((key: string) => { - switch (key) { - case 'swaps.restrictApps': { - return restrictApps; - } - case 'swaps.explorerBaseUri': { - swapsExplorerBaseUri = faker.internet.url({ appendSlash: false }); - return swapsExplorerBaseUri; - } - default: { - throw new Error(`Configuration key not found: ${key}`); - } - } - }); - - const gpv2Decoder = new GPv2Decoder(mockLoggingService); - const gpv2OrderHelper = new GPv2OrderHelper(); - const multiSendDecoder = new MultiSendDecoder(); - const allowedApps = new Set(); - const swapOrderHelper = new SwapOrderHelper( - multiSendDecoder, - gpv2Decoder, - mockTokenRepository, - mockSwapsRepository, - mockConfigurationService, - allowedApps, - mockChainsRepository, - ); - target = new SwapOrderMapper(gpv2Decoder, gpv2OrderHelper, swapOrderHelper); - }); - // TODO: Add test - should've been added in first swaps integration it.todo('should map a swap order'); - - it('should map a TWAP-based swap order', async () => { - const chainId = '11155111'; - const safeAddress = '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381'; - const data = - '0x13d79a0b0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000008600000000000000000000000000000000000000000000000000000000000000004000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000b8e40feeb23890000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000098b75c35d9dbd0c00000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000300000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e00000000000000000000000000000000000000000000000000000000666b0cb7f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000004d831eac7f0141837b266de30f4dc9af15629bd53815fd7e97ddaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230bd5a25ba2e97094ad7d83dc28a6572da797d6b3e7fc6663bd93efb789fc17e489000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000000180000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e00000000000000000000000000000000000000000000000000000000666b0cb7f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc90000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011f294a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000026000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000086dcd3293c53cf8efd7303b57beb2a3f671dde980000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000001048803dbee00000000000000000000000000000000000000000000000009a4243487ef7e8600000000000000000000000000000000000000000000000bb1c124efe034415400000000000000000000000000000000000000000000000000000000000000a00000000000000000000000009008d19f58aabd9ed0d60971565aa8510560ab41ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000b849ec'; - /** - * @see https://explorer.cow.fi/sepolia/orders/0x557cb31a9dbbd23830c57d9fd3bbfc3694e942c161232b6cf696ba3bd11f9d6631eac7f0141837b266de30f4dc9af15629bd5381666b0cb7?tab=overview - */ - const order = { - creationDate: '2024-06-13T14:44:02.307987Z', - owner: '0x31eac7f0141837b266de30f4dc9af15629bd5381', - uid: '0x557cb31a9dbbd23830c57d9fd3bbfc3694e942c161232b6cf696ba3bd11f9d6631eac7f0141837b266de30f4dc9af15629bd5381666b0cb7', - availableBalance: null, - executedBuyAmount: '687772850053823756', - executedSellAmount: '213586875483862141750', - executedSellAmountBeforeFees: '213586875483862141750', - executedFeeAmount: '0', - executedSurplusFee: '2135868754838621123', - invalidated: false, - status: 'fulfilled', - class: 'limit', - settlementContract: '0x9008d19f58aabd9ed0d60971565aa8510560ab41', - fullFeeAmount: '0', - solverFee: '0', - isLiquidityOrder: false, - fullAppData: - '{"appCode":"Safe Wallet Swaps","metadata":{"orderClass":{"orderClass":"twap"},"quote":{"slippageBips":1000},"widget":{"appCode":"CoW Swap-SafeApp","environment":"production"}},"version":"1.1.0"}', - sellToken: '0xbe72e441bf55620febc26715db68d3494213d8cb', - buyToken: '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', - receiver: '0x31eac7f0141837b266de30f4dc9af15629bd5381', - sellAmount: '213586875483862141750', - buyAmount: '611289510998251134', - validTo: 1718291639, - appData: - '0xf7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda0', - feeAmount: '0', - kind: 'sell', - partiallyFillable: false, - sellTokenBalance: 'erc20', - buyTokenBalance: 'erc20', - signingScheme: 'eip1271', - signature: - '0x5fd7e97ddaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230bd5a25ba2e97094ad7d83dc28a6572da797d6b3e7fc6663bd93efb789fc17e489000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000000180000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e00000000000000000000000000000000000000000000000000000000666b0cb7f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc90000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011f294a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000', - interactions: { pre: [], post: [] }, - } as unknown as Order; - - const buyToken = tokenBuilder().with('address', order.buyToken).build(); - const sellToken = tokenBuilder().with('address', order.sellToken).build(); - - mockSwapsRepository.getOrder.mockResolvedValueOnce(order); - mockTokenRepository.getToken.mockImplementation(async ({ address }) => { - switch (address) { - case order.buyToken: { - return Promise.resolve(buyToken); - } - case order.sellToken: { - return Promise.resolve(sellToken); - } - default: { - return Promise.reject(new Error(`Token not found: ${address}`)); - } - } - }); - - const result = await target.mapTwapSwapOrder(chainId, safeAddress, { - data, - }); - - expect(result).toEqual({ - buyAmount: '611289510998251134', - buyToken: { - address: buyToken.address, - decimals: buyToken.decimals, - logoUri: buyToken.logoUri, - name: buyToken.name, - symbol: buyToken.symbol, - trusted: buyToken.trusted, - }, - executedBuyAmount: '687772850053823756', - executedSellAmount: '213586875483862141750', - executedSurplusFee: '2135868754838621123', - explorerUrl: `${swapsExplorerBaseUri}/orders/0x557cb31a9dbbd23830c57d9fd3bbfc3694e942c161232b6cf696ba3bd11f9d6631eac7f0141837b266de30f4dc9af15629bd5381666b0cb7`, - fullAppData: - '{"appCode":"Safe Wallet Swaps","metadata":{"orderClass":{"orderClass":"twap"},"quote":{"slippageBips":1000},"widget":{"appCode":"CoW Swap-SafeApp","environment":"production"}},"version":"1.1.0"}', - humanDescription: null, - kind: 'sell', - orderClass: 'limit', - owner: '0x31eac7f0141837b266de30f4dc9af15629bd5381', - receiver: '0x31eac7f0141837b266de30f4dc9af15629bd5381', - richDecodedInfo: null, - sellAmount: '213586875483862141750', - sellToken: { - address: sellToken.address, - decimals: sellToken.decimals, - logoUri: sellToken.logoUri, - name: sellToken.name, - symbol: sellToken.symbol, - trusted: sellToken.trusted, - }, - status: 'fulfilled', - type: 'SwapOrder', - uid: '0x557cb31a9dbbd23830c57d9fd3bbfc3694e942c161232b6cf696ba3bd11f9d6631eac7f0141837b266de30f4dc9af15629bd5381666b0cb7', - validUntil: 1718291639, - }); - }); }); diff --git a/src/routes/transactions/mappers/common/swap-order.mapper.ts b/src/routes/transactions/mappers/common/swap-order.mapper.ts index 8bb5e938b5..8e3f599fbe 100644 --- a/src/routes/transactions/mappers/common/swap-order.mapper.ts +++ b/src/routes/transactions/mappers/common/swap-order.mapper.ts @@ -6,13 +6,11 @@ import { SwapOrderHelper, SwapOrderHelperModule, } from '@/routes/transactions/helpers/swap-order.helper'; -import { GPv2OrderHelper } from '@/routes/transactions/helpers/gp-v2-order.helper'; @Injectable() export class SwapOrderMapper { constructor( private readonly gpv2Decoder: GPv2Decoder, - private readonly gpv2OrderHelper: GPv2OrderHelper, private readonly swapOrderHelper: SwapOrderHelper, ) {} @@ -27,33 +25,7 @@ export class SwapOrderMapper { if (!orderUid) { throw new Error('Order UID not found in transaction data'); } - - return await this.mapSwapOrderTransactionInfo({ chainId, orderUid }); - } - - async mapTwapSwapOrder( - chainId: string, - safeAddress: `0x${string}`, - transaction: { data: `0x${string}` }, - ): Promise { - const order = this.gpv2Decoder.decodeOrderFromSettle(transaction.data); - if (!order) { - throw new Error('Order could not be decoded from transaction data'); - } - - const orderUid = this.gpv2OrderHelper.computeOrderUid({ - chainId, - owner: safeAddress, - order, - }); - return await this.mapSwapOrderTransactionInfo({ chainId, orderUid }); - } - - private async mapSwapOrderTransactionInfo(args: { - chainId: string; - orderUid: `0x${string}`; - }): Promise { - const order = await this.swapOrderHelper.getOrder(args); + const order = await this.swapOrderHelper.getOrder({ chainId, orderUid }); if (!this.swapOrderHelper.isAppAllowed(order)) { throw new Error(`Unsupported App: ${order.fullAppData?.appCode}`); @@ -62,11 +34,11 @@ export class SwapOrderMapper { const [sellToken, buyToken] = await Promise.all([ this.swapOrderHelper.getToken({ address: order.sellToken, - chainId: args.chainId, + chainId, }), this.swapOrderHelper.getToken({ address: order.buyToken, - chainId: args.chainId, + chainId, }), ]); @@ -107,7 +79,7 @@ export class SwapOrderMapper { @Module({ imports: [SwapOrderHelperModule], - providers: [SwapOrderMapper, GPv2Decoder, GPv2OrderHelper], + providers: [SwapOrderMapper, GPv2Decoder], exports: [SwapOrderMapper], }) export class SwapOrderMapperModule {} diff --git a/src/routes/transactions/mappers/common/transaction-info.mapper.ts b/src/routes/transactions/mappers/common/transaction-info.mapper.ts index 14edaf185a..5796f48dec 100644 --- a/src/routes/transactions/mappers/common/transaction-info.mapper.ts +++ b/src/routes/transactions/mappers/common/transaction-info.mapper.ts @@ -113,12 +113,6 @@ export class MultisigTransactionInfoMapper { if (twapOrder) { return twapOrder; } - - // If the transaction is a TWAP-based swap order, we return it immediately - const twapSwapOrder = await this.mapTwapSwapOrder(chainId, transaction); - if (twapSwapOrder) { - return twapSwapOrder; - } } if (this.isCustomTransaction(value, dataSize, transaction.operation)) { @@ -282,34 +276,6 @@ export class MultisigTransactionInfoMapper { } } - private async mapTwapSwapOrder( - chainId: string, - transaction: MultisigTransaction | ModuleTransaction, - ): Promise { - if (!transaction?.data) { - return null; - } - - const orderData = this.swapOrderHelper.findTwapSwapOrder(transaction.data); - - if (!orderData) { - return null; - } - - try { - return await this.swapOrderMapper.mapTwapSwapOrder( - chainId, - transaction.safe, - { - data: orderData, - }, - ); - } catch (error) { - this.loggingService.warn(error); - return null; - } - } - private isCustomTransaction( value: number, dataSize: number, diff --git a/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.spec.ts b/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.spec.ts new file mode 100644 index 0000000000..1f917001c2 --- /dev/null +++ b/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.spec.ts @@ -0,0 +1,399 @@ +import { erc20TransferBuilder } from '@/domain/safe/entities/__tests__/erc20-transfer.builder'; +import { orderBuilder } from '@/domain/swaps/entities/__tests__/order.builder'; +import { OrdersSchema } from '@/domain/swaps/entities/order.entity'; +import { ISwapsRepository } from '@/domain/swaps/swaps.repository'; +import { tokenBuilder } from '@/domain/tokens/__tests__/token.builder'; +import { addressInfoBuilder } from '@/routes/common/__tests__/entities/address-info.builder'; +import { TransferDirection } from '@/routes/transactions/entities/transfer-transaction-info.entity'; +import { Erc20Transfer } from '@/routes/transactions/entities/transfers/erc20-transfer.entity'; +import { SwapOrderHelper } from '@/routes/transactions/helpers/swap-order.helper'; +import { getTransferDirection } from '@/routes/transactions/mappers/common/transfer-direction.helper'; +import { SwapTransferInfoMapper } from '@/routes/transactions/mappers/transfers/swap-transfer-info.mapper'; +import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; + +const mockSwapOrderHelper = jest.mocked({ + getToken: jest.fn(), + getOrderExplorerUrl: jest.fn(), +} as jest.MockedObjectDeep); + +const mockSwapsRepository = jest.mocked({ + getOrders: jest.fn(), +} as jest.MockedObjectDeep); + +describe('SwapTransferInfoMapper', () => { + let target: SwapTransferInfoMapper; + + const GPv2SettlementAddress = '0x9008D19f58AAbD9eD0D60971565AA8510560ab41'; + + beforeEach(() => { + jest.resetAllMocks(); + + target = new SwapTransferInfoMapper( + mockSwapOrderHelper, + mockSwapsRepository, + ); + }); + + it('it returns null if nether the sender and recipient are from the GPv2Settlement contract', async () => { + const sender = addressInfoBuilder().build(); + const recipient = addressInfoBuilder().build(); + const direction = faker.helpers.arrayElement( + Object.values(TransferDirection), + ); + const chainId = faker.string.numeric(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const domainTransfer = erc20TransferBuilder() + .with('from', getAddress(sender.value)) + .with('to', getAddress(recipient.value)) + .build(); + const token = tokenBuilder() + .with('address', domainTransfer.tokenAddress) + .build(); + const transferInfo = new Erc20Transfer( + token.address, + domainTransfer.value, + token.name, + token.symbol, + token.logoUri, + token.decimals, + token.trusted, + ); + const order = orderBuilder().with('from', getAddress(sender.value)).build(); + mockSwapsRepository.getOrders.mockResolvedValue([order]); + + const actual = await target.mapSwapTransferInfo({ + sender, + recipient, + direction, + chainId, + safeAddress, + transferInfo, + domainTransfer, + }); + + expect(actual).toBe(null); + }); + + it('maps the SwapTransferTransactionInfo if the sender is the GPv2Settlement contract', async () => { + const sender = addressInfoBuilder() + .with('value', GPv2SettlementAddress) + .build(); + const recipient = addressInfoBuilder().build(); + const chainId = faker.string.numeric(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const direction = getTransferDirection( + safeAddress, + sender.value, + recipient.value, + ); + const domainTransfer = erc20TransferBuilder() + .with('from', getAddress(sender.value)) + .with('to', getAddress(recipient.value)) + .build(); + const token = tokenBuilder() + .with('address', domainTransfer.tokenAddress) + .build(); + const transferInfo = new Erc20Transfer( + token.address, + domainTransfer.value, + token.name, + token.symbol, + token.logoUri, + token.decimals, + token.trusted, + ); + const order = orderBuilder() + .with('from', getAddress(sender.value)) + .with('owner', safeAddress) + .with('buyToken', token.address) + .with('executedBuyAmount', BigInt(domainTransfer.value)) + .build(); + const explorerUrl = faker.internet.url({ appendSlash: true }); + mockSwapsRepository.getOrders.mockResolvedValue([order]); + mockSwapOrderHelper.getToken.mockResolvedValue({ + ...token, + decimals: token.decimals!, + }); + mockSwapOrderHelper.getOrderExplorerUrl.mockReturnValue( + new URL(explorerUrl), + ); + + const actual = await target.mapSwapTransferInfo({ + sender, + recipient, + direction, + chainId, + safeAddress, + transferInfo, + domainTransfer, + }); + + expect(actual).toEqual({ + buyAmount: order.buyAmount.toString(), + buyToken: token, + direction: 'UNKNOWN', + executedBuyAmount: order.executedBuyAmount.toString(), + executedSellAmount: order.executedSellAmount.toString(), + executedSurplusFee: order.executedSurplusFee?.toString() ?? null, + explorerUrl, + fullAppData: order.fullAppData, + humanDescription: null, + kind: order.kind, + orderClass: order.class, + owner: safeAddress, + receiver: order.receiver, + recipient, + richDecodedInfo: null, + sellAmount: order.sellAmount.toString(), + sellToken: token, + sender, + status: order.status, + transferInfo, + type: 'SwapTransfer', + uid: order.uid, + validUntil: order.validTo, + }); + }); + + it('maps the SwapTransferTransactionInfo if the recipient is the GPv2Settlement contract', async () => { + const sender = addressInfoBuilder().build(); + const recipient = addressInfoBuilder() + .with('value', GPv2SettlementAddress) + .build(); + const chainId = faker.string.numeric(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const direction = getTransferDirection( + safeAddress, + sender.value, + recipient.value, + ); + const domainTransfer = erc20TransferBuilder() + .with('from', getAddress(sender.value)) + .with('to', getAddress(recipient.value)) + .build(); + const token = tokenBuilder() + .with('address', domainTransfer.tokenAddress) + .build(); + const transferInfo = new Erc20Transfer( + token.address, + domainTransfer.value, + token.name, + token.symbol, + token.logoUri, + token.decimals, + token.trusted, + ); + const order = orderBuilder() + .with('from', getAddress(sender.value)) + .with('owner', safeAddress) + .with('buyToken', domainTransfer.tokenAddress) + .with('executedBuyAmount', BigInt(domainTransfer.value)) + .build(); + const explorerUrl = faker.internet.url({ appendSlash: true }); + mockSwapsRepository.getOrders.mockResolvedValue([order]); + mockSwapOrderHelper.getToken.mockResolvedValue({ + ...token, + decimals: token.decimals!, + }); + mockSwapOrderHelper.getOrderExplorerUrl.mockReturnValue( + new URL(explorerUrl), + ); + + const actual = await target.mapSwapTransferInfo({ + sender, + recipient, + direction, + chainId, + safeAddress, + transferInfo, + domainTransfer, + }); + + expect(actual).toEqual({ + buyAmount: order.buyAmount.toString(), + buyToken: token, + direction: 'UNKNOWN', + executedBuyAmount: order.executedBuyAmount.toString(), + executedSellAmount: order.executedSellAmount.toString(), + executedSurplusFee: order.executedSurplusFee?.toString() ?? null, + explorerUrl, + fullAppData: order.fullAppData, + humanDescription: null, + kind: order.kind, + orderClass: order.class, + owner: safeAddress, + receiver: order.receiver, + recipient, + richDecodedInfo: null, + sellAmount: order.sellAmount.toString(), + sellToken: token, + sender, + status: order.status, + transferInfo, + type: 'SwapTransfer', + uid: order.uid, + validUntil: order.validTo, + }); + }); + + it('maps the correct order if it was executed in a batch', async () => { + /** + * https://api.cow.fi/mainnet/api/v1/transactions/0x22fe458f3a70aaf83d42af2040f3b98404526b4ca588624e158c4b1f287ced8c/orders + */ + const _orders = [ + { + creationDate: '2024-06-25T12:16:09.646330Z', + owner: '0x6ecba7527448bb56caba8ca7768d271deaea72a9', + uid: '0x0229aadcaf2d06d0ccacca0d7739c9e531e89605c61ac5883252c1f3612761ce6ecba7527448bb56caba8ca7768d271deaea72a9667abc04', + availableBalance: null, + executedBuyAmount: '3824530054984182297195399559', + executedSellAmount: '5555000000', + executedSellAmountBeforeFees: '5555000000', + executedFeeAmount: '0', + executedSurplusFee: '5012654', + invalidated: false, + status: 'fulfilled', + class: 'limit', + settlementContract: '0x9008d19f58aabd9ed0d60971565aa8510560ab41', + fullFeeAmount: '0', + solverFee: '0', + isLiquidityOrder: false, + fullAppData: + '{"appCode":"CoW Swap","environment":"production","metadata":{"orderClass":{"orderClass":"market"},"quote":{"slippageBips":50},"utm":{"utmContent":"header-cta-button","utmMedium":"web","utmSource":"cow.fi"}},"version":"1.1.0"}', + sellToken: '0xdac17f958d2ee523a2206206994597c13d831ec7', + buyToken: '0xaaee1a9723aadb7afa2810263653a34ba2c21c7a', + receiver: '0x6ecba7527448bb56caba8ca7768d271deaea72a9', + sellAmount: '5555000000', + buyAmount: '3807681190768269801973105790', + validTo: 1719319556, + appData: + '0x831ef45ca746d6d67482ba7ad19af3ed3d29da441d869cbf1fa8ea6dec3ebc1f', + feeAmount: '0', + kind: 'sell', + partiallyFillable: false, + sellTokenBalance: 'erc20', + buyTokenBalance: 'erc20', + signingScheme: 'eip712', + signature: + '0x6904193a1483813d7921585493b7e1a295476c12c9b6e08a430b726ae9a4e05660e309c430e21edcb5b7156e80291510159e1a4c39b736471f6bd4c131231b8c1b', + interactions: { pre: [], post: [] }, + }, + { + creationDate: '2024-06-25T12:15:26.924920Z', + owner: '0x6ecba7527448bb56caba8ca7768d271deaea72a9', + uid: '0xaaa1348fc7572d408097d069268db0ecb727ead6b525614999f983d5c5f1c1fb6ecba7527448bb56caba8ca7768d271deaea72a9667abbdc', + availableBalance: null, + executedBuyAmount: '6990751494894782668981616', + executedSellAmount: '3000000000', + executedSellAmountBeforeFees: '3000000000', + executedFeeAmount: '0', + executedSurplusFee: '4290918', + invalidated: false, + status: 'fulfilled', + class: 'limit', + settlementContract: '0x9008d19f58aabd9ed0d60971565aa8510560ab41', + fullFeeAmount: '0', + solverFee: '0', + isLiquidityOrder: false, + fullAppData: + '{"appCode":"CoW Swap","environment":"production","metadata":{"orderClass":{"orderClass":"market"},"quote":{"slippageBips":50},"utm":{"utmContent":"header-cta-button","utmMedium":"web","utmSource":"cow.fi"}},"version":"1.1.0"}', + sellToken: '0xdac17f958d2ee523a2206206994597c13d831ec7', + buyToken: '0x594daad7d77592a2b97b725a7ad59d7e188b5bfa', + receiver: '0x6ecba7527448bb56caba8ca7768d271deaea72a9', + sellAmount: '3000000000', + buyAmount: '6961165527651189129024639', + validTo: 1719319516, + appData: + '0x831ef45ca746d6d67482ba7ad19af3ed3d29da441d869cbf1fa8ea6dec3ebc1f', + feeAmount: '0', + kind: 'sell', + partiallyFillable: false, + sellTokenBalance: 'erc20', + buyTokenBalance: 'erc20', + signingScheme: 'eip712', + signature: + '0x18c6ea08a69ea97a3a1216038547ccb76c734b6ebc6b216cde32b68f8c2fb0c63c3223b056d2caf5dd0ff440b65821452ec84489b4edff00cbd419058a364e3f1c', + interactions: { pre: [], post: [] }, + }, + ]; + + // In order to appease TypeScript, we parse the data + const orders = OrdersSchema.parse(_orders); + + const safeAddress = orders[0].owner; + const sender = addressInfoBuilder().with('value', safeAddress).build(); + const recipient = addressInfoBuilder() + .with('value', GPv2SettlementAddress) + .build(); + const chainId = faker.string.numeric(); + const direction = getTransferDirection( + safeAddress, + sender.value, + recipient.value, + ); + const domainTransfer = erc20TransferBuilder() + .with('from', getAddress(sender.value)) + .with('to', getAddress(recipient.value)) + .with('value', orders[0].executedSellAmount.toString()) + .with('tokenAddress', orders[0].sellToken) + .build(); + const token = tokenBuilder() + .with('address', domainTransfer.tokenAddress) + .build(); + const transferInfo = new Erc20Transfer( + token.address, + domainTransfer.value, + token.name, + token.symbol, + token.logoUri, + token.decimals, + token.trusted, + ); + const explorerUrl = faker.internet.url({ appendSlash: true }); + mockSwapsRepository.getOrders.mockResolvedValue(orders); + mockSwapOrderHelper.getToken.mockResolvedValue({ + ...token, + decimals: token.decimals!, + }); + mockSwapOrderHelper.getOrderExplorerUrl.mockReturnValue( + new URL(explorerUrl), + ); + + const actual = await target.mapSwapTransferInfo({ + sender, + recipient, + direction, + chainId, + safeAddress, + transferInfo, + domainTransfer, + }); + + expect(actual).toEqual({ + buyAmount: orders[0].buyAmount.toString(), + buyToken: token, + direction: 'OUTGOING', + executedBuyAmount: orders[0].executedBuyAmount.toString(), + executedSellAmount: orders[0].executedSellAmount.toString(), + executedSurplusFee: orders[0].executedSurplusFee?.toString() ?? null, + explorerUrl, + fullAppData: orders[0].fullAppData, + humanDescription: null, + kind: orders[0].kind, + orderClass: orders[0].class, + owner: orders[0].owner, + receiver: orders[0].receiver, + recipient, + richDecodedInfo: null, + sellAmount: orders[0].sellAmount.toString(), + sellToken: token, + sender, + status: orders[0].status, + transferInfo, + type: 'SwapTransfer', + uid: orders[0].uid, + validUntil: orders[0].validTo, + }); + }); +}); diff --git a/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.ts b/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.ts new file mode 100644 index 0000000000..0fe76dcda8 --- /dev/null +++ b/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.ts @@ -0,0 +1,147 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Transfer as DomainTransfer } from '@/domain/safe/entities/transfer.entity'; +import { TransferDirection } from '@/routes/transactions/entities/transfer-transaction-info.entity'; +import { Transfer } from '@/routes/transactions/entities/transfers/transfer.entity'; +import { GPv2OrderHelper } from '@/routes/transactions/helpers/gp-v2-order.helper'; +import { SwapOrderHelper } from '@/routes/transactions/helpers/swap-order.helper'; +import { AddressInfo } from '@/routes/common/entities/address-info.entity'; +import { SwapTransferTransactionInfo } from '@/routes/transactions/swap-transfer-transaction-info.entity'; +import { getAddress, isAddressEqual } from 'viem'; +import { ISwapsRepository } from '@/domain/swaps/swaps.repository'; +import { Order } from '@/domain/swaps/entities/order.entity'; + +@Injectable() +export class SwapTransferInfoMapper { + constructor( + private readonly swapOrderHelper: SwapOrderHelper, + @Inject(ISwapsRepository) + private readonly swapsRepository: ISwapsRepository, + ) {} + + /** + * Maps a swap transfer transaction info + * + * @param args.sender - {@link AddrssInfo} sender of the transfer + * @param args.recipient - {@link AddrssInfo} recipient of the transfer + * @param args.direction - {@link TransferDirection} direction of the transfer + * @param args.chainId - chain id of the transfer + * @param args.safeAddress - safe address of the transfer + * @param args.transferInfo - {@link TransferInfo} transfer info + * @param args.domainTransfer - {@link Transfer} domain transfer (used to find the order) + * @returns + */ + public async mapSwapTransferInfo(args: { + sender: AddressInfo; + recipient: AddressInfo; + direction: TransferDirection; + chainId: string; + safeAddress: `0x${string}`; + transferInfo: Transfer; + domainTransfer: DomainTransfer; + }): Promise { + // If settlement contract is not interacted with, not a swap fulfillment + if ( + !this.isSettlement(args.sender.value) && + !this.isSettlement(args.recipient.value) + ) { + return null; + } + + const orders = await this.swapsRepository.getOrders( + args.chainId, + args.domainTransfer.transactionHash, + ); + + // One transaction may contain multiple orders + const order = this.findOrderByTransfer(orders, args.domainTransfer); + + if (!order) { + return null; + } + + const [sellToken, buyToken] = await Promise.all([ + this.swapOrderHelper.getToken({ + address: order.sellToken, + chainId: args.chainId, + }), + this.swapOrderHelper.getToken({ + address: order.buyToken, + chainId: args.chainId, + }), + ]); + + return new SwapTransferTransactionInfo({ + // TransferTransactionInfo + sender: args.sender, + recipient: args.recipient, + direction: args.direction, + transferInfo: args.transferInfo, + humanDescription: null, + richDecodedInfo: null, + // SwapOrderTransactionInfo + uid: order.uid, + orderStatus: order.status, + kind: order.kind, + class: order.class, + validUntil: order.validTo, + sellAmount: order.sellAmount.toString(), + buyAmount: order.buyAmount.toString(), + executedSellAmount: order.executedSellAmount.toString(), + executedBuyAmount: order.executedBuyAmount.toString(), + sellToken, + buyToken, + explorerUrl: this.swapOrderHelper.getOrderExplorerUrl(order).toString(), + executedSurplusFee: order.executedSurplusFee?.toString() ?? null, + receiver: order.receiver, + owner: order.owner, + fullAppData: order.fullAppData, + }); + } + + private isSettlement(address: string): boolean { + return isAddressEqual( + getAddress(address), + GPv2OrderHelper.SettlementContractAddress, + ); + } + + /** + * Finds a order by transfer by comparing the token address and value of the transfer + * with the order's sellToken and buyToken. + * + * @param orders + * @param transfer + * @returns the {@link Order} if found, otherwise `undefined` + */ + private findOrderByTransfer( + orders: Array, + transfer: DomainTransfer, + ): Order | undefined { + return orders.find((order) => { + if (transfer.type === 'ERC721_TRANSFER') { + throw new Error('ERC721 transfers are not supported by swaps'); + } + + const isSender = transfer.from === order.owner; + + const isValue = isSender + ? transfer.value === order.executedSellAmount.toString() + : transfer.value === order.executedBuyAmount.toString(); + + const isToken = ((): boolean => { + const tokenToCompare = isSender ? order.sellToken : order.buyToken; + + if (transfer.type === 'ETHER_TRANSFER') { + return tokenToCompare === SwapOrderHelper.NATIVE_CURRENCY_ADDRESS; + } + if (transfer.type === 'ERC20_TRANSFER') { + return tokenToCompare === transfer.tokenAddress; + } + + return false; + })(); + + return isValue && isToken; + }); + } +} diff --git a/src/routes/transactions/mappers/transfers/transfer-info.mapper.spec.ts b/src/routes/transactions/mappers/transfers/transfer-info.mapper.spec.ts index 46f751c421..66b273a125 100644 --- a/src/routes/transactions/mappers/transfers/transfer-info.mapper.spec.ts +++ b/src/routes/transactions/mappers/transfers/transfer-info.mapper.spec.ts @@ -16,6 +16,17 @@ import { Erc721Transfer } from '@/routes/transactions/entities/transfers/erc721- import { NativeCoinTransfer } from '@/routes/transactions/entities/transfers/native-coin-transfer.entity'; import { TransferInfoMapper } from '@/routes/transactions/mappers/transfers/transfer-info.mapper'; import { getAddress } from 'viem'; +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { SwapTransferInfoMapper } from '@/routes/transactions/mappers/transfers/swap-transfer-info.mapper'; + +const configurationService = jest.mocked({ + getOrThrow: jest.fn(), +} as jest.MockedObjectDeep); + +// Note: we mock this as there is a dedicated test for this mapper +const swapTransferInfoMapper = jest.mocked({ + mapSwapTransferInfo: jest.fn(), +} as jest.MockedObjectDeep); const addressInfoHelper = jest.mocked({ getOrDefault: jest.fn(), @@ -30,7 +41,12 @@ describe('Transfer Info mapper (Unit)', () => { beforeEach(() => { jest.resetAllMocks(); - mapper = new TransferInfoMapper(tokenRepository, addressInfoHelper); + mapper = new TransferInfoMapper( + configurationService, + tokenRepository, + swapTransferInfoMapper, + addressInfoHelper, + ); }); it('should build an ERC20 TransferTransactionInfo', async () => { diff --git a/src/routes/transactions/mappers/transfers/transfer-info.mapper.ts b/src/routes/transactions/mappers/transfers/transfer-info.mapper.ts index d5e1469c58..9be2cd6aba 100644 --- a/src/routes/transactions/mappers/transfers/transfer-info.mapper.ts +++ b/src/routes/transactions/mappers/transfers/transfer-info.mapper.ts @@ -11,13 +11,28 @@ import { Erc721Transfer } from '@/routes/transactions/entities/transfers/erc721- import { NativeCoinTransfer } from '@/routes/transactions/entities/transfers/native-coin-transfer.entity'; import { getTransferDirection } from '@/routes/transactions/mappers/common/transfer-direction.helper'; import { Transfer } from '@/routes/transactions/entities/transfers/transfer.entity'; +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { SwapTransferInfoMapper } from '@/routes/transactions/mappers/transfers/swap-transfer-info.mapper'; @Injectable() export class TransferInfoMapper { + private readonly isSwapsDecodingEnabled: boolean; + private readonly isTwapsDecodingEnabled: boolean; + constructor( + @Inject(IConfigurationService) + private readonly configurationService: IConfigurationService, @Inject(ITokenRepository) private readonly tokenRepository: TokenRepository, + private readonly swapTransferInfoMapper: SwapTransferInfoMapper, private readonly addressInfoHelper: AddressInfoHelper, - ) {} + ) { + this.isSwapsDecodingEnabled = this.configurationService.getOrThrow( + 'features.swapsDecoding', + ); + this.isTwapsDecodingEnabled = this.configurationService.getOrThrow( + 'features.twapsDecoding', + ); + } async mapTransferInfo( chainId: string, @@ -25,23 +40,38 @@ export class TransferInfoMapper { safe: Safe, ): Promise { const { from, to } = domainTransfer; - const sender = await this.addressInfoHelper.getOrDefault(chainId, from, [ - 'TOKEN', - 'CONTRACT', - ]); - const recipient = await this.addressInfoHelper.getOrDefault(chainId, to, [ - 'TOKEN', - 'CONTRACT', + const [sender, recipient, transferInfo] = await Promise.all([ + this.addressInfoHelper.getOrDefault(chainId, from, ['TOKEN', 'CONTRACT']), + this.addressInfoHelper.getOrDefault(chainId, to, ['TOKEN', 'CONTRACT']), + this.getTransferByType(chainId, domainTransfer), ]); const direction = getTransferDirection(safe.address, from, to); + if (this.isSwapsDecodingEnabled && this.isTwapsDecodingEnabled) { + // If the transaction is a swap-based transfer, we return it immediately + const swapTransfer = + await this.swapTransferInfoMapper.mapSwapTransferInfo({ + sender, + recipient, + direction, + transferInfo, + chainId, + safeAddress: safe.address, + domainTransfer, + }); + + if (swapTransfer) { + return swapTransfer; + } + } + return new TransferTransactionInfo( sender, recipient, direction, - await this.getTransferByType(chainId, domainTransfer), + transferInfo, null, null, ); diff --git a/src/routes/transactions/mappers/transfers/transfer.mapper.spec.ts b/src/routes/transactions/mappers/transfers/transfer.mapper.spec.ts index 3fa326e89a..78240e46e9 100644 --- a/src/routes/transactions/mappers/transfers/transfer.mapper.spec.ts +++ b/src/routes/transactions/mappers/transfers/transfer.mapper.spec.ts @@ -1,3 +1,4 @@ +import { IConfigurationService } from '@/config/configuration.service.interface'; import { erc20TransferBuilder } from '@/domain/safe/entities/__tests__/erc20-transfer.builder'; import { erc721TransferBuilder } from '@/domain/safe/entities/__tests__/erc721-transfer.builder'; import { nativeTokenTransferBuilder } from '@/domain/safe/entities/__tests__/native-token-transfer.builder'; @@ -9,11 +10,16 @@ import { AddressInfo } from '@/routes/common/entities/address-info.entity'; import { TransactionStatus } from '@/routes/transactions/entities/transaction-status.entity'; import { Transaction } from '@/routes/transactions/entities/transaction.entity'; import { TransferTransactionInfo } from '@/routes/transactions/entities/transfer-transaction-info.entity'; +import { SwapTransferInfoMapper } from '@/routes/transactions/mappers/transfers/swap-transfer-info.mapper'; import { TransferInfoMapper } from '@/routes/transactions/mappers/transfers/transfer-info.mapper'; import { TransferMapper } from '@/routes/transactions/mappers/transfers/transfer.mapper'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; +const configurationService = jest.mocked({ + getOrThrow: jest.fn(), +} as jest.MockedObjectDeep); + const addressInfoHelper = jest.mocked({ getOrDefault: jest.fn(), } as jest.MockedObjectDeep); @@ -22,13 +28,20 @@ const tokenRepository = jest.mocked({ getToken: jest.fn(), } as jest.MockedObjectDeep); +const swapTransferInfoMapper = jest.mocked({ + mapSwapTransferInfo: jest.fn(), +} as jest.MockedObjectDeep); + describe('Transfer mapper (Unit)', () => { let mapper: TransferMapper; beforeEach(() => { jest.resetAllMocks(); + const transferInfoMapper = new TransferInfoMapper( + configurationService, tokenRepository, + swapTransferInfoMapper, addressInfoHelper, ); mapper = new TransferMapper(transferInfoMapper); diff --git a/src/routes/transactions/swap-transfer-transaction-info.entity.ts b/src/routes/transactions/swap-transfer-transaction-info.entity.ts new file mode 100644 index 0000000000..70ad966c5f --- /dev/null +++ b/src/routes/transactions/swap-transfer-transaction-info.entity.ts @@ -0,0 +1,170 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { AddressInfo } from '@/routes/common/entities/address-info.entity'; +import { RichDecodedInfo } from '@/routes/transactions/entities/human-description.entity'; +import { + TransactionInfo, + TransactionInfoType, +} from '@/routes/transactions/entities/transaction-info.entity'; +import { Transfer } from '@/routes/transactions/entities/transfers/transfer.entity'; +import { SwapOrderTransactionInfo } from '@/routes/transactions/entities/swaps/swap-order-info.entity'; +import { + OrderStatus, + OrderClass, + OrderKind, +} from '@/domain/swaps/entities/order.entity'; +import { TokenInfo } from '@/routes/transactions/entities/swaps/token-info.entity'; +import { + TransferDirection, + TransferTransactionInfo, +} from '@/routes/transactions/entities/transfer-transaction-info.entity'; + +export class SwapTransferTransactionInfo + extends TransactionInfo + implements TransferTransactionInfo, SwapOrderTransactionInfo +{ + // TransferTransactionInfo properties + @ApiProperty() + sender: AddressInfo; + + @ApiProperty() + recipient: AddressInfo; + + @ApiProperty() + direction: TransferDirection; + + @ApiProperty() + transferInfo: Transfer; + + // SwapOrderTransactionInfo properties + @ApiProperty({ description: 'The order UID' }) + uid: string; + + @ApiProperty({ + enum: OrderStatus, + }) + status: OrderStatus; + + @ApiProperty({ enum: Object.values(OrderKind) }) + kind: OrderKind; + + @ApiProperty({ + enum: OrderClass, + }) + orderClass: OrderClass; + + @ApiProperty({ description: 'The timestamp when the order expires' }) + validUntil: number; + + @ApiProperty({ + description: 'The sell token raw amount (no decimals)', + }) + sellAmount: string; + + @ApiProperty({ + description: 'The buy token raw amount (no decimals)', + }) + buyAmount: string; + + @ApiProperty({ + description: 'The executed sell token raw amount (no decimals)', + }) + executedSellAmount: string; + + @ApiProperty({ + description: 'The executed buy token raw amount (no decimals)', + }) + executedBuyAmount: string; + + @ApiProperty({ description: 'The sell token of the order' }) + sellToken: TokenInfo; + + @ApiProperty({ description: 'The buy token of the order' }) + buyToken: TokenInfo; + + @ApiProperty({ + type: String, + description: 'The URL to the explorer page of the order', + }) + explorerUrl: string; + + @ApiPropertyOptional({ + type: String, + nullable: true, + description: 'The amount of fees paid for this order.', + }) + executedSurplusFee: string | null; + + @ApiPropertyOptional({ + type: String, + nullable: true, + description: 'The (optional) address to receive the proceeds of the trade', + }) + receiver: string | null; + + @ApiProperty({ + type: String, + }) + owner: `0x${string}`; + + @ApiPropertyOptional({ + type: Object, + nullable: true, + description: 'The App Data for this order', + }) + fullAppData: Record | null; + + constructor(args: { + // TransferTransactionInfo properties + sender: AddressInfo; + recipient: AddressInfo; + direction: TransferDirection; + transferInfo: Transfer; + humanDescription: string | null; + richDecodedInfo: RichDecodedInfo | null | undefined; + // SwapOrderTransactionInfo properties + uid: string; + orderStatus: OrderStatus; + kind: OrderKind; + class: OrderClass; + validUntil: number; + sellAmount: string; + buyAmount: string; + executedSellAmount: string; + executedBuyAmount: string; + sellToken: TokenInfo; + buyToken: TokenInfo; + explorerUrl: string; + executedSurplusFee: string | null; + receiver: string | null; + owner: `0x${string}`; + fullAppData: Record | null; + }) { + // TransferTransactionInfo constructor + super( + TransactionInfoType.SwapTransfer, + args.humanDescription, + args.richDecodedInfo, + ); + this.sender = args.sender; + this.recipient = args.recipient; + this.direction = args.direction; + this.transferInfo = args.transferInfo; + // SwapOrderTransactionInfo constructor + this.uid = args.uid; + this.status = args.orderStatus; + this.kind = args.kind; + this.orderClass = args.class; + this.validUntil = args.validUntil; + this.sellAmount = args.sellAmount; + this.buyAmount = args.buyAmount; + this.executedSellAmount = args.executedSellAmount; + this.executedBuyAmount = args.executedBuyAmount; + this.sellToken = args.sellToken; + this.buyToken = args.buyToken; + this.explorerUrl = args.explorerUrl; + this.executedSurplusFee = args.executedSurplusFee; + this.receiver = args.receiver; + this.owner = args.owner; + this.fullAppData = args.fullAppData; + } +} diff --git a/src/routes/transactions/transactions.module.ts b/src/routes/transactions/transactions.module.ts index dbcd5d38b7..9dc4edbb08 100644 --- a/src/routes/transactions/transactions.module.ts +++ b/src/routes/transactions/transactions.module.ts @@ -11,6 +11,7 @@ import { SettingsChangeMapper } from '@/routes/transactions/mappers/common/setti import { TransactionDataMapper } from '@/routes/transactions/mappers/common/transaction-data.mapper'; import { MultisigTransactionInfoMapper } from '@/routes/transactions/mappers/common/transaction-info.mapper'; import { CreationTransactionMapper } from '@/routes/transactions/mappers/creation-transaction/creation-transaction.mapper'; +import { GPv2OrderHelper } from '@/routes/transactions/helpers/gp-v2-order.helper'; import { ModuleTransactionDetailsMapper } from '@/routes/transactions/mappers/module-transactions/module-transaction-details.mapper'; import { ModuleTransactionStatusMapper } from '@/routes/transactions/mappers/module-transactions/module-transaction-status.mapper'; import { ModuleTransactionMapper } from '@/routes/transactions/mappers/module-transactions/module-transaction.mapper'; @@ -37,8 +38,10 @@ import { HumanDescriptionRepositoryModule } from '@/domain/human-description/hum import { SafeAppsRepositoryModule } from '@/domain/safe-apps/safe-apps.repository.interface'; import { TokenRepositoryModule } from '@/domain/tokens/token.repository.interface'; import { SwapOrderHelperModule } from '@/routes/transactions/helpers/swap-order.helper'; +import { SwapsRepositoryModule } from '@/domain/swaps/swaps-repository.module'; import { TwapOrderMapperModule } from '@/routes/transactions/mappers/common/twap-order.mapper'; import { TwapOrderHelperModule } from '@/routes/transactions/helpers/twap-order.helper'; +import { SwapTransferInfoMapper } from '@/routes/transactions/mappers/transfers/swap-transfer-info.mapper'; @Module({ controllers: [TransactionsController], @@ -52,6 +55,7 @@ import { TwapOrderHelperModule } from '@/routes/transactions/helpers/twap-order. GPv2DecoderModule, SwapOrderMapperModule, SwapOrderHelperModule, + SwapsRepositoryModule, TokenRepositoryModule, TwapOrderMapperModule, TwapOrderHelperModule, @@ -62,6 +66,7 @@ import { TwapOrderHelperModule } from '@/routes/transactions/helpers/twap-order. DataDecodedParamHelper, Erc20TransferMapper, Erc721TransferMapper, + GPv2OrderHelper, TransferMapper, ModuleTransactionDetailsMapper, ModuleTransactionMapper, @@ -76,6 +81,7 @@ import { TwapOrderHelperModule } from '@/routes/transactions/helpers/twap-order. QueuedItemsMapper, SafeAppInfoMapper, SettingsChangeMapper, + SwapTransferInfoMapper, TransactionDataMapper, TransactionPreviewMapper, TransactionsHistoryMapper, From 08c1b4181368d4bf320fb64b34402e25c7b7562e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Thu, 27 Jun 2024 09:51:30 +0200 Subject: [PATCH 130/207] Add FF_TWAPS_DECODING to sample env (#1697) Adds FF_TWAPS_DECODING to .env.sample file. --- .env.sample | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env.sample b/.env.sample index c0290e3093..7f18946c19 100644 --- a/.env.sample +++ b/.env.sample @@ -139,3 +139,6 @@ # Enable human description feature #FF_HUMAN_DESCRIPTION= + +# Enable CowSwap TWAPs decoding feature +#FF_TWAPS_DECODING= From 8ca961a773213229c77c43a8c6484b1cfe536484 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Thu, 27 Jun 2024 23:20:55 +0200 Subject: [PATCH 131/207] Catch swapOrderHelper.getOrder errors (#1699) Catch swapOrderHelper.getOrder errors --- src/domain/swaps/entities/order.entity.ts | 2 + .../mappers/common/twap-order.mapper.spec.ts | 3 ++ .../mappers/common/twap-order.mapper.ts | 53 ++++++++++++------- 3 files changed, 39 insertions(+), 19 deletions(-) diff --git a/src/domain/swaps/entities/order.entity.ts b/src/domain/swaps/entities/order.entity.ts index 18db41c28b..08abe57efb 100644 --- a/src/domain/swaps/entities/order.entity.ts +++ b/src/domain/swaps/entities/order.entity.ts @@ -5,6 +5,8 @@ import { FullAppDataSchema } from '@/domain/swaps/entities/full-app-data.entity' export type Order = z.infer; +export type KnownOrder = Order & { kind: Exclude }; + export enum OrderStatus { PreSignaturePending = 'presignaturePending', Open = 'open', diff --git a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts index 9bb4a50fbf..1f3a123b24 100644 --- a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts +++ b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts @@ -77,6 +77,7 @@ describe('TwapOrderMapper', () => { // We instantiate in tests to be able to set maxNumberOfParts const mapper = new TwapOrderMapper( configurationService, + mockLoggingService, swapOrderHelper, mockSwapsRepository, composableCowDecoder, @@ -173,6 +174,7 @@ describe('TwapOrderMapper', () => { // We instantiate in tests to be able to set maxNumberOfParts const mapper = new TwapOrderMapper( configurationService, + mockLoggingService, swapOrderHelper, mockSwapsRepository, composableCowDecoder, @@ -352,6 +354,7 @@ describe('TwapOrderMapper', () => { // We instantiate in tests to be able to set maxNumberOfParts const mapper = new TwapOrderMapper( configurationService, + mockLoggingService, swapOrderHelper, mockSwapsRepository, composableCowDecoder, diff --git a/src/routes/transactions/mappers/common/twap-order.mapper.ts b/src/routes/transactions/mappers/common/twap-order.mapper.ts index ea78ac3a32..77fa45ed08 100644 --- a/src/routes/transactions/mappers/common/twap-order.mapper.ts +++ b/src/routes/transactions/mappers/common/twap-order.mapper.ts @@ -13,12 +13,17 @@ import { TwapOrderHelper, TwapOrderHelperModule, } from '@/routes/transactions/helpers/twap-order.helper'; -import { OrderStatus } from '@/domain/swaps/entities/order.entity'; +import { + KnownOrder, + OrderKind, + OrderStatus, +} from '@/domain/swaps/entities/order.entity'; import { ISwapsRepository } from '@/domain/swaps/swaps.repository'; import { SwapsRepositoryModule } from '@/domain/swaps/swaps-repository.module'; import { SwapOrderMapperModule } from '@/routes/transactions/mappers/common/swap-order.mapper'; import { GPv2OrderHelper } from '@/routes/transactions/helpers/gp-v2-order.helper'; import { IConfigurationService } from '@/config/configuration.service.interface'; +import { ILoggingService, LoggingService } from '@/logging/logging.interface'; @Injectable() export class TwapOrderMapper { @@ -27,6 +32,7 @@ export class TwapOrderMapper { constructor( @Inject(IConfigurationService) private readonly configurationService: IConfigurationService, + @Inject(LoggingService) private readonly loggingService: ILoggingService, private readonly swapOrderHelper: SwapOrderHelper, @Inject(ISwapsRepository) private readonly swapsRepository: ISwapsRepository, @@ -79,18 +85,31 @@ export class TwapOrderMapper { : twapParts : []; - const [{ fullAppData }, ...orders] = await Promise.all([ - // Decode hash of `appData` - this.swapsRepository.getFullAppData(chainId, twapStruct.appData), - ...partsToFetch.map((order) => { - const orderUid = this.gpv2OrderHelper.computeOrderUid({ - chainId, - owner: safeAddress, - order, - }); - return this.swapOrderHelper.getOrder({ chainId, orderUid }); - }), - ]); + const { fullAppData } = await this.swapsRepository.getFullAppData( + chainId, + twapStruct.appData, + ); + + const orders: Array = []; + + for (const part of partsToFetch) { + const orderUid = this.gpv2OrderHelper.computeOrderUid({ + chainId, + owner: safeAddress, + order: part, + }); + + try { + const order = await this.swapsRepository.getOrder(chainId, orderUid); + if (order.kind === OrderKind.Buy || order.kind === OrderKind.Sell) { + orders.push(order as KnownOrder); + } + } catch (err) { + this.loggingService.warn( + `Error getting orderUid ${orderUid} from SwapsRepository`, + ); + } + } // TODO: Handling of restricted Apps, calling `getToken` directly instead of multiple times in `getOrder` for sellToken and buyToken @@ -181,17 +200,13 @@ export class TwapOrderMapper { return OrderStatus.Unknown; } - private getExecutedSellAmount( - orders: Array>>, - ): number { + private getExecutedSellAmount(orders: Array): number { return orders.reduce((acc, order) => { return acc + Number(order.executedSellAmount); }, 0); } - private getExecutedBuyAmount( - orders: Array>>, - ): number { + private getExecutedBuyAmount(orders: Array): number { return orders.reduce((acc, order) => { return acc + Number(order.executedBuyAmount); }, 0); From d16565701598610abf9d9e004695a639fc83743c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Fri, 28 Jun 2024 15:59:49 +0200 Subject: [PATCH 132/207] Fix getNumberString utils function (#1703) Changes getNumberString implementation to use a fixed locale, standard notation, and no grouping. --- src/domain/common/utils/utils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/domain/common/utils/utils.ts b/src/domain/common/utils/utils.ts index 4e6206708d..753c7970c1 100644 --- a/src/domain/common/utils/utils.ts +++ b/src/domain/common/utils/utils.ts @@ -1,6 +1,7 @@ export function getNumberString(value: number): string { // Prevent scientific notation - return value.toLocaleString('fullwide', { + return value.toLocaleString('en-US', { + notation: 'standard', useGrouping: false, }); } From ad3805b8b71f64bc87b1baaf392357e8df5dddb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Fri, 28 Jun 2024 16:56:42 +0200 Subject: [PATCH 133/207] Add optional CGW_ENV attribute to the configuration (#1704) Add optional CGW_ENV attribute to the configuration. --- src/config/entities/__tests__/configuration.ts | 5 ++++- src/config/entities/configuration.ts | 5 ++++- src/main.ts | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index 61af87527d..d551002392 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -13,7 +13,10 @@ export default (): ReturnType => ({ queue: faker.string.sample(), prefetch: faker.number.int(), }, - applicationPort: faker.internet.port().toString(), + application: { + env: faker.string.sample(), + port: faker.internet.port().toString(), + }, auth: { token: faker.string.hexadecimal({ length: 32 }), nonceTtlSeconds: faker.number.int(), diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index e9fbbd3ec0..e5639fcedb 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -23,7 +23,10 @@ export default () => ({ ? parseInt(process.env.AMQP_PREFETCH) : 100, }, - applicationPort: process.env.APPLICATION_PORT || '3000', + application: { + env: process.env.CGW_ENV || 'production', + port: process.env.APPLICATION_PORT || '3000', + }, auth: { token: process.env.AUTH_TOKEN, nonceTtlSeconds: parseInt( diff --git a/src/main.ts b/src/main.ts index 629817b54c..7e2e850fe5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,7 +8,7 @@ async function bootstrap(): Promise { const configurationService: IConfigurationService = app.get(IConfigurationService); const applicationPort: string = - configurationService.getOrThrow('applicationPort'); + configurationService.getOrThrow('application.port'); await app.listen(applicationPort); } From e3e71e845c0febf99e4f993cd2fc5f4d61341e3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Fri, 28 Jun 2024 17:36:09 +0200 Subject: [PATCH 134/207] Adjust access token cookie configuration (#1705) Set accessToken cookie SameSite attribute depending on CGW_ENV --- src/routes/auth/auth.controller.spec.ts | 104 ++++++++++++++++++++---- src/routes/auth/auth.controller.ts | 37 +++++++-- 2 files changed, 120 insertions(+), 21 deletions(-) diff --git a/src/routes/auth/auth.controller.spec.ts b/src/routes/auth/auth.controller.spec.ts index ae6e163a2d..2fed0bb69f 100644 --- a/src/routes/auth/auth.controller.spec.ts +++ b/src/routes/auth/auth.controller.spec.ts @@ -44,21 +44,9 @@ describe('AuthController', () => { let blockchainApiManager: FakeBlockchainApiManager; let maxValidityPeriodInMs: number; - beforeEach(async () => { - jest.useFakeTimers(); - jest.resetAllMocks(); - - const defaultConfiguration = configuration(); - const testConfiguration = (): typeof defaultConfiguration => ({ - ...defaultConfiguration, - features: { - ...defaultConfiguration.features, - auth: true, - }, - }); - + async function initApp(config: typeof configuration): Promise { const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule.register(testConfiguration)], + imports: [AppModule.register(config)], }) .overrideModule(JWT_CONFIGURATION_MODULE) .useModule(JwtConfigurationModule.register(jwtConfiguration)) @@ -91,6 +79,26 @@ describe('AuthController', () => { app = await new TestAppProvider().provide(moduleFixture); await app.init(); + } + + beforeEach(async () => { + jest.useFakeTimers(); + jest.resetAllMocks(); + + const defaultConfiguration = configuration(); + const testConfiguration = (): typeof defaultConfiguration => ({ + ...defaultConfiguration, + application: { + ...defaultConfiguration.application, + env: 'production', + }, + features: { + ...defaultConfiguration.features, + auth: true, + }, + }); + + await initApp(testConfiguration); }); afterAll(async () => { @@ -218,6 +226,74 @@ describe('AuthController', () => { await expect(cacheService.get(cacheDir)).resolves.toBe(undefined); }); + it('should set SameSite=none if application.env is not production', async () => { + const defaultConfiguration = configuration(); + const testConfiguration = (): typeof defaultConfiguration => ({ + ...defaultConfiguration, + application: { + ...defaultConfiguration.application, + env: 'staging', + }, + features: { + ...defaultConfiguration.features, + auth: true, + }, + }); + + await initApp(testConfiguration); + + const privateKey = generatePrivateKey(); + const signer = privateKeyToAccount(privateKey); + const nonceResponse = await request(app.getHttpServer()).get( + '/v1/auth/nonce', + ); + const nonce: string = nonceResponse.body.nonce; + const cacheDir = new CacheDir(`auth_nonce_${nonce}`, ''); + const expirationTime = faker.date.between({ + from: new Date(), + to: new Date(Date.now() + maxValidityPeriodInMs), + }); + const message = createSiweMessage( + siweMessageBuilder() + .with('address', signer.address) + .with('nonce', nonce) + .with('expirationTime', expirationTime) + .build(), + ); + const signature = await signer.signMessage({ + message, + }); + const maxAge = getSecondsUntil(expirationTime); + // jsonwebtoken sets expiration based on timespans, not exact dates + // meaning we cannot use expirationTime directly + const expires = new Date(Date.now() + maxAge * 1_000); + + await expect(cacheService.get(cacheDir)).resolves.toBe( + nonceResponse.body.nonce, + ); + + await request(app.getHttpServer()) + .post('/v1/auth/verify') + .send({ + message, + signature, + }) + .expect(200) + .expect(({ headers }) => { + const setCookie = headers['set-cookie']; + const setCookieRegExp = new RegExp( + `access_token=([^;]*); Max-Age=${maxAge}; Path=/; Expires=${expires.toUTCString()}; HttpOnly; Secure; SameSite=None`, + ); + + expect(setCookie).toHaveLength; + expect(setCookie[0]).toMatch(setCookieRegExp); + }); + // Verified off-chain as EOA + expect(verifySiweMessageMock).not.toHaveBeenCalled(); + // Nonce deleted + await expect(cacheService.get(cacheDir)).resolves.toBe(undefined); + }); + it('should not verify a signer if expirationTime is too high', async () => { const privateKey = generatePrivateKey(); const signer = privateKeyToAccount(privateKey); diff --git a/src/routes/auth/auth.controller.ts b/src/routes/auth/auth.controller.ts index b0614929bf..20a69a5270 100644 --- a/src/routes/auth/auth.controller.ts +++ b/src/routes/auth/auth.controller.ts @@ -1,10 +1,19 @@ -import { Body, Controller, Get, Post, HttpCode, Res } from '@nestjs/common'; -import { ApiExcludeController } from '@nestjs/swagger'; -import { ValidationPipe } from '@/validation/pipes/validation.pipe'; +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { getMillisecondsUntil } from '@/domain/common/utils/time'; import { AuthService } from '@/routes/auth/auth.service'; -import { SiweDtoSchema, SiweDto } from '@/routes/auth/entities/siwe.dto.entity'; +import { SiweDto, SiweDtoSchema } from '@/routes/auth/entities/siwe.dto.entity'; +import { ValidationPipe } from '@/validation/pipes/validation.pipe'; +import { + Body, + Controller, + Get, + HttpCode, + Inject, + Post, + Res, +} from '@nestjs/common'; +import { ApiExcludeController } from '@nestjs/swagger'; import { Response } from 'express'; -import { getMillisecondsUntil } from '@/domain/common/utils/time'; /** * The AuthController is responsible for handling authentication: @@ -19,8 +28,19 @@ import { getMillisecondsUntil } from '@/domain/common/utils/time'; @ApiExcludeController() export class AuthController { static readonly ACCESS_TOKEN_COOKIE_NAME = 'access_token'; + static readonly ACCESS_TOKEN_COOKIE_SAME_SITE_LAX = 'lax'; + static readonly ACCESS_TOKEN_COOKIE_SAME_SITE_NONE = 'none'; + static readonly CGW_ENV_PRODUCTION = 'production'; + private readonly cgwEnv: string; - constructor(private readonly authService: AuthService) {} + constructor( + @Inject(IConfigurationService) + private readonly configurationService: IConfigurationService, + private readonly authService: AuthService, + ) { + this.cgwEnv = + this.configurationService.getOrThrow('application.env'); + } @Get('nonce') async getNonce(): Promise<{ @@ -38,11 +58,14 @@ export class AuthController { siweDto: SiweDto, ): Promise { const { accessToken } = await this.authService.getAccessToken(siweDto); + const isProduction = this.cgwEnv === AuthController.CGW_ENV_PRODUCTION; res.cookie(AuthController.ACCESS_TOKEN_COOKIE_NAME, accessToken, { httpOnly: true, secure: true, - sameSite: 'lax', + sameSite: isProduction + ? AuthController.ACCESS_TOKEN_COOKIE_SAME_SITE_LAX + : AuthController.ACCESS_TOKEN_COOKIE_SAME_SITE_NONE, path: '/', // Extract maxAge from token as it may slightly differ to SiWe message maxAge: this.getMaxAge(accessToken), From 4b083a00398e5c84801db3da8e309bc66fb27d2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Fri, 28 Jun 2024 17:55:26 +0200 Subject: [PATCH 135/207] Implement DELETE /v1/accounts/:address (#1694) Implement DELETE /v1/accounts/:address --- .../test.accounts.datasource.module.ts | 3 +- .../accounts/accounts.datasource.spec.ts | 20 +++ .../accounts/accounts.datasource.ts | 35 ++--- .../accounts/accounts.repository.interface.ts | 5 + src/domain/accounts/accounts.repository.ts | 12 ++ .../accounts.datasource.interface.ts | 2 + .../accounts/accounts.controller.spec.ts | 138 ++++++++++++++++++ src/routes/accounts/accounts.controller.ts | 14 ++ src/routes/accounts/accounts.service.ts | 13 ++ 9 files changed, 224 insertions(+), 18 deletions(-) diff --git a/src/datasources/accounts/__tests__/test.accounts.datasource.module.ts b/src/datasources/accounts/__tests__/test.accounts.datasource.module.ts index ae694ac5e4..c3b74d68d7 100644 --- a/src/datasources/accounts/__tests__/test.accounts.datasource.module.ts +++ b/src/datasources/accounts/__tests__/test.accounts.datasource.module.ts @@ -2,8 +2,9 @@ import { Module } from '@nestjs/common'; import { IAccountsDatasource } from '@/domain/interfaces/accounts.datasource.interface'; const accountsDatasource = { - getAccount: jest.fn(), createAccount: jest.fn(), + deleteAccount: jest.fn(), + getAccount: jest.fn(), } as jest.MockedObjectDeep; @Module({ diff --git a/src/datasources/accounts/accounts.datasource.spec.ts b/src/datasources/accounts/accounts.datasource.spec.ts index fbe1f037a0..96578bf726 100644 --- a/src/datasources/accounts/accounts.datasource.spec.ts +++ b/src/datasources/accounts/accounts.datasource.spec.ts @@ -9,6 +9,7 @@ const sql = dbFactory(); const migrator = new PostgresDatabaseMigrator(sql); const mockLoggingService = { + debug: jest.fn(), info: jest.fn(), warn: jest.fn(), } as jest.MockedObjectDeep; @@ -82,4 +83,23 @@ describe('AccountsDatasource tests', () => { ); }); }); + + describe('deleteAccount', () => { + it('deletes an account successfully', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + await target.createAccount(address); + + await expect(target.deleteAccount(address)).resolves.not.toThrow(); + + expect(mockLoggingService.debug).not.toHaveBeenCalled(); + }); + + it('does not throws if no account is found', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + + await expect(target.deleteAccount(address)).resolves.not.toThrow(); + + expect(mockLoggingService.debug).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/datasources/accounts/accounts.datasource.ts b/src/datasources/accounts/accounts.datasource.ts index e168461ca7..0b5b9183f9 100644 --- a/src/datasources/accounts/accounts.datasource.ts +++ b/src/datasources/accounts/accounts.datasource.ts @@ -18,10 +18,9 @@ export class AccountsDatasource implements IAccountsDatasource { ) {} async createAccount(address: `0x${string}`): Promise { - const [account] = await this.sql<[Account]>`INSERT INTO - accounts (address) - VALUES - (${address}) RETURNING *`.catch( + const [account] = await this.sql< + [Account] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING *`.catch( (e) => { this.loggingService.warn( `Error creating account: ${asError(e).message}`, @@ -38,19 +37,12 @@ export class AccountsDatasource implements IAccountsDatasource { } async getAccount(address: `0x${string}`): Promise { - const [account] = await this.sql<[Account]>`SELECT - * - FROM - accounts - WHERE - address = ${address}`.catch( - (e) => { - this.loggingService.info( - `Error getting account: ${asError(e).message}`, - ); - return []; - }, - ); + const [account] = await this.sql< + [Account] + >`SELECT * FROM accounts WHERE address = ${address}`.catch((e) => { + this.loggingService.info(`Error getting account: ${asError(e).message}`); + return []; + }); if (!account) { throw new NotFoundException('Error getting account.'); @@ -58,4 +50,13 @@ export class AccountsDatasource implements IAccountsDatasource { return account; } + + async deleteAccount(address: `0x${string}`): Promise { + const { count } = await this + .sql`DELETE FROM accounts WHERE address = ${address}`; + + if (count === 0) { + this.loggingService.debug(`Error deleting account ${address}: not found`); + } + } } diff --git a/src/domain/accounts/accounts.repository.interface.ts b/src/domain/accounts/accounts.repository.interface.ts index 8c61916a2b..1fbf32aed4 100644 --- a/src/domain/accounts/accounts.repository.interface.ts +++ b/src/domain/accounts/accounts.repository.interface.ts @@ -11,6 +11,11 @@ export interface IAccountsRepository { auth: AuthPayloadDto; address: `0x${string}`; }): Promise; + + deleteAccount(args: { + auth: AuthPayloadDto; + address: `0x${string}`; + }): Promise; } @Module({ diff --git a/src/domain/accounts/accounts.repository.ts b/src/domain/accounts/accounts.repository.ts index 36f66955b7..83bd687fde 100644 --- a/src/domain/accounts/accounts.repository.ts +++ b/src/domain/accounts/accounts.repository.ts @@ -29,4 +29,16 @@ export class AccountsRepository implements IAccountsRepository { const account = await this.datasource.createAccount(args.address); return AccountSchema.parse(account); } + + async deleteAccount(args: { + auth: AuthPayloadDto; + address: `0x${string}`; + }): Promise { + const authPayload = new AuthPayload(args.auth); + if (!authPayload.isForSigner(args.address)) { + throw new UnauthorizedException(); + } + // TODO: trigger a cascade deletion of the account-associated data. + return this.datasource.deleteAccount(args.address); + } } diff --git a/src/domain/interfaces/accounts.datasource.interface.ts b/src/domain/interfaces/accounts.datasource.interface.ts index 950ad7c107..d78b928861 100644 --- a/src/domain/interfaces/accounts.datasource.interface.ts +++ b/src/domain/interfaces/accounts.datasource.interface.ts @@ -6,4 +6,6 @@ export interface IAccountsDatasource { createAccount(address: `0x${string}`): Promise; getAccount(address: `0x${string}`): Promise; + + deleteAccount(address: `0x${string}`): Promise; } diff --git a/src/routes/accounts/accounts.controller.spec.ts b/src/routes/accounts/accounts.controller.spec.ts index 9311e5e30c..0bee6f1c8a 100644 --- a/src/routes/accounts/accounts.controller.spec.ts +++ b/src/routes/accounts/accounts.controller.spec.ts @@ -235,4 +235,142 @@ describe('AccountsController', () => { expect(accountDataSource.createAccount).toHaveBeenCalledTimes(2); }); }); + + describe('Delete accounts', () => { + it('should delete an account', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + const account = accountBuilder().build(); + accountDataSource.createAccount.mockResolvedValue(account); + accountDataSource.deleteAccount.mockResolvedValue(); + + await request(app.getHttpServer()) + .post(`/v1/accounts`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address: address.toLowerCase() }) + .expect(201); + + await request(app.getHttpServer()) + .delete(`/v1/accounts/${address}`) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(204); + + expect(accountDataSource.deleteAccount).toHaveBeenCalledTimes(1); + // Check the address was checksummed + expect(accountDataSource.deleteAccount).toHaveBeenCalledWith(address); + }); + + it('Returns 403 if no token is present', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + + await request(app.getHttpServer()) + .delete(`/v1/accounts/${address}`) + .send({ address }) + .expect(403); + }); + + it('returns 403 if token is not a valid JWT', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const accessToken = faker.string.sample(); + + expect(() => jwtService.verify(accessToken)).toThrow('jwt malformed'); + await request(app.getHttpServer()) + .delete(`/v1/accounts/${address}`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address }) + .expect(403); + }); + + it('returns 403 is token it not yet valid', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto, { + notBefore: getSecondsUntil(faker.date.future()), + }); + + await request(app.getHttpServer()) + .delete(`/v1/accounts/${address}`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address }) + .expect(403); + }); + + it('returns 403 if token has expired', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto, { expiresIn: 0 }); + jest.advanceTimersByTime(1_000); + + await request(app.getHttpServer()) + .delete(`/v1/accounts/${address}`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address }) + .expect(403); + }); + + it('returns 403 if signer_address is not a valid Ethereum address', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', faker.string.hexadecimal() as `0x${string}`) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + + await request(app.getHttpServer()) + .delete(`/v1/accounts/${address}`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address }) + .expect(403); + }); + + it('returns 403 if chain_id is not a valid chain ID', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', faker.lorem.sentence()) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + + await request(app.getHttpServer()) + .delete(`/v1/accounts/${address}`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address }) + .expect(403); + }); + + it('should propagate errors', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + accountDataSource.deleteAccount.mockImplementation(() => { + throw new Error('test error'); + }); + + await request(app.getHttpServer()) + .delete(`/v1/accounts/${address}`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address: address.toLowerCase() }) + .expect(500); + + expect(accountDataSource.deleteAccount).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/routes/accounts/accounts.controller.ts b/src/routes/accounts/accounts.controller.ts index 3cae962127..42627b852d 100644 --- a/src/routes/accounts/accounts.controller.ts +++ b/src/routes/accounts/accounts.controller.ts @@ -3,12 +3,15 @@ import { Account } from '@/routes/accounts/entities/account.entity'; import { CreateAccountDto } from '@/routes/accounts/entities/create-account.dto.entity'; import { CreateAccountDtoSchema } from '@/routes/accounts/entities/schemas/create-account.dto.schema'; import { AuthGuard } from '@/routes/auth/guards/auth.guard'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; import { ValidationPipe } from '@/validation/pipes/validation.pipe'; import { Body, Controller, + Delete, HttpCode, HttpStatus, + Param, Post, Req, UseGuards, @@ -33,4 +36,15 @@ export class AccountsController { const auth = request.accessToken; return this.accountsService.createAccount({ auth, createAccountDto }); } + + @Delete(':address') + @UseGuards(AuthGuard) + @HttpCode(HttpStatus.NO_CONTENT) + async deleteAccount( + @Param('address', new ValidationPipe(AddressSchema)) address: `0x${string}`, + @Req() request: Request, + ): Promise { + const auth = request.accessToken; + return this.accountsService.deleteAccount({ auth, address }); + } } diff --git a/src/routes/accounts/accounts.service.ts b/src/routes/accounts/accounts.service.ts index eb2d906f8c..389a6d58ad 100644 --- a/src/routes/accounts/accounts.service.ts +++ b/src/routes/accounts/accounts.service.ts @@ -26,6 +26,19 @@ export class AccountsService { return this.mapAccount(domainAccount); } + async deleteAccount(args: { + auth?: AuthPayloadDto; + address: `0x${string}`; + }): Promise { + if (!args.auth) { + throw new UnauthorizedException(); + } + await this.accountsRepository.deleteAccount({ + auth: args.auth, + address: args.address, + }); + } + private mapAccount(domainAccount: DomainAccount): Account { return new Account( domainAccount.id.toString(), From 7ff0f1875a831270ed84a9be5bacc7e571e4a9bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Mon, 1 Jul 2024 12:31:25 +0200 Subject: [PATCH 136/207] Implement GET /v1/accounts/:address (#1706) The endpoint `GET /v1/accounts/:address` was added to `AccountsController`, protected by `AuthGuard`. --- .../accounts/accounts.repository.interface.ts | 5 + src/domain/accounts/accounts.repository.ts | 30 ++- .../accounts/entities/account.entity.spec.ts | 7 - .../accounts/entities/account.entity.ts | 2 +- .../accounts/accounts.controller.spec.ts | 172 ++++++++++++++++++ src/routes/accounts/accounts.controller.ts | 12 ++ src/routes/accounts/accounts.service.ts | 14 ++ 7 files changed, 225 insertions(+), 17 deletions(-) diff --git a/src/domain/accounts/accounts.repository.interface.ts b/src/domain/accounts/accounts.repository.interface.ts index 1fbf32aed4..11982bb8d3 100644 --- a/src/domain/accounts/accounts.repository.interface.ts +++ b/src/domain/accounts/accounts.repository.interface.ts @@ -12,6 +12,11 @@ export interface IAccountsRepository { address: `0x${string}`; }): Promise; + getAccount(args: { + auth: AuthPayloadDto; + address: `0x${string}`; + }): Promise; + deleteAccount(args: { auth: AuthPayloadDto; address: `0x${string}`; diff --git a/src/domain/accounts/accounts.repository.ts b/src/domain/accounts/accounts.repository.ts index 83bd687fde..4a984c7df9 100644 --- a/src/domain/accounts/accounts.repository.ts +++ b/src/domain/accounts/accounts.repository.ts @@ -21,12 +21,19 @@ export class AccountsRepository implements IAccountsRepository { auth: AuthPayloadDto; address: `0x${string}`; }): Promise { - const authPayload = new AuthPayload(args.auth); - if (!authPayload.isForSigner(args.address)) { - throw new UnauthorizedException(); - } + const { auth, address } = args; + this.checkAuth(auth, address); + const account = await this.datasource.createAccount(address); + return AccountSchema.parse(account); + } - const account = await this.datasource.createAccount(args.address); + async getAccount(args: { + auth: AuthPayloadDto; + address: `0x${string}`; + }): Promise { + const { auth, address } = args; + this.checkAuth(auth, address); + const account = await this.datasource.getAccount(address); return AccountSchema.parse(account); } @@ -34,11 +41,16 @@ export class AccountsRepository implements IAccountsRepository { auth: AuthPayloadDto; address: `0x${string}`; }): Promise { - const authPayload = new AuthPayload(args.auth); - if (!authPayload.isForSigner(args.address)) { + const { auth, address } = args; + this.checkAuth(auth, address); + // TODO: trigger a cascade deletion of the account-associated data. + return this.datasource.deleteAccount(address); + } + + private checkAuth(auth: AuthPayloadDto, address: `0x${string}`): void { + const authPayload = new AuthPayload(auth); + if (!authPayload.isForSigner(address)) { throw new UnauthorizedException(); } - // TODO: trigger a cascade deletion of the account-associated data. - return this.datasource.deleteAccount(args.address); } } diff --git a/src/domain/accounts/entities/account.entity.spec.ts b/src/domain/accounts/entities/account.entity.spec.ts index 5c4188f726..71ddcca9f4 100644 --- a/src/domain/accounts/entities/account.entity.spec.ts +++ b/src/domain/accounts/entities/account.entity.spec.ts @@ -109,13 +109,6 @@ describe('AccountSchema', () => { path: ['updated_at'], received: 'undefined', }, - { - code: 'invalid_type', - expected: 'number', - message: 'Required', - path: ['group_id'], - received: 'undefined', - }, { code: 'invalid_type', expected: 'string', diff --git a/src/domain/accounts/entities/account.entity.ts b/src/domain/accounts/entities/account.entity.ts index 778d7e09cd..c82a1021cb 100644 --- a/src/domain/accounts/entities/account.entity.ts +++ b/src/domain/accounts/entities/account.entity.ts @@ -6,6 +6,6 @@ import { z } from 'zod'; export type Account = z.infer; export const AccountSchema = RowSchema.extend({ - group_id: GroupSchema.shape.id.nullable(), + group_id: GroupSchema.shape.id.nullish().default(null), address: AddressSchema, }); diff --git a/src/routes/accounts/accounts.controller.spec.ts b/src/routes/accounts/accounts.controller.spec.ts index 0bee6f1c8a..1e81453e74 100644 --- a/src/routes/accounts/accounts.controller.spec.ts +++ b/src/routes/accounts/accounts.controller.spec.ts @@ -22,10 +22,12 @@ import { getSecondsUntil } from '@/domain/common/utils/time'; import { IAccountsDatasource } from '@/domain/interfaces/accounts.datasource.interface'; import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; import { RequestScopedLoggingModule } from '@/logging/logging.module'; +import { Account } from '@/routes/accounts/entities/account.entity'; import { faker } from '@faker-js/faker'; import { ConflictException, INestApplication, + NotFoundException, UnprocessableEntityException, } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; @@ -236,6 +238,176 @@ describe('AccountsController', () => { }); }); + describe('Get accounts', () => { + it('should get a single account', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + const account = accountBuilder().with('group_id', null).build(); + accountDataSource.getAccount.mockResolvedValue(account); + const expected: Account = { + accountId: account.id.toString(), + groupId: null, + address: account.address, + }; + + await request(app.getHttpServer()) + .get(`/v1/accounts/${address}`) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(200) + .expect(expected); + + expect(accountDataSource.getAccount).toHaveBeenCalledTimes(1); + // Check the address was checksummed + expect(accountDataSource.getAccount).toHaveBeenCalledWith(address); + }); + + it('should get a group account', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + const groupId = faker.number.int(); + const account = accountBuilder().with('group_id', groupId).build(); + accountDataSource.getAccount.mockResolvedValue(account); + const expected: Account = { + accountId: account.id.toString(), + groupId: groupId.toString(), + address: account.address, + }; + + await request(app.getHttpServer()) + .get(`/v1/accounts/${address}`) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(200) + .expect(expected); + + expect(accountDataSource.getAccount).toHaveBeenCalledTimes(1); + // Check the address was checksummed + expect(accountDataSource.getAccount).toHaveBeenCalledWith(address); + }); + + it('Returns 403 if no token is present', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + + await request(app.getHttpServer()) + .get(`/v1/accounts/${address}`) + .expect(403); + + expect(accountDataSource.getAccount).not.toHaveBeenCalled(); + }); + + it('returns 403 if token is not a valid JWT', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const accessToken = faker.string.sample(); + + expect(() => jwtService.verify(accessToken)).toThrow('jwt malformed'); + await request(app.getHttpServer()) + .get(`/v1/accounts/${address}`) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(403); + + expect(accountDataSource.getAccount).not.toHaveBeenCalled(); + }); + + it('returns 403 is token it not yet valid', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto, { + notBefore: getSecondsUntil(faker.date.future()), + }); + + await request(app.getHttpServer()) + .get(`/v1/accounts/${address}`) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(403); + + expect(accountDataSource.getAccount).not.toHaveBeenCalled(); + }); + + it('returns 403 if token has expired', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto, { expiresIn: 0 }); + jest.advanceTimersByTime(1_000); + + await request(app.getHttpServer()) + .get(`/v1/accounts/${address}`) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(403); + + expect(accountDataSource.getAccount).not.toHaveBeenCalled(); + }); + + it('returns 403 if signer_address is not a valid Ethereum address', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', faker.string.hexadecimal() as `0x${string}`) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + + await request(app.getHttpServer()) + .get(`/v1/accounts/${address}`) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(403); + + expect(accountDataSource.getAccount).not.toHaveBeenCalled(); + }); + + it('returns 403 if chain_id is not a valid chain ID', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', faker.lorem.sentence()) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + + await request(app.getHttpServer()) + .get(`/v1/accounts/${address}`) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(403); + + expect(accountDataSource.getAccount).not.toHaveBeenCalled(); + }); + + it('should propagate errors', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + accountDataSource.getAccount.mockRejectedValue( + new NotFoundException('Not found'), + ); + + await request(app.getHttpServer()) + .get(`/v1/accounts/${address}`) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(404); + + expect(accountDataSource.getAccount).toHaveBeenCalledTimes(1); + }); + }); + describe('Delete accounts', () => { it('should delete an account', async () => { const address = getAddress(faker.finance.ethereumAddress()); diff --git a/src/routes/accounts/accounts.controller.ts b/src/routes/accounts/accounts.controller.ts index 42627b852d..7f5404f096 100644 --- a/src/routes/accounts/accounts.controller.ts +++ b/src/routes/accounts/accounts.controller.ts @@ -9,6 +9,7 @@ import { Body, Controller, Delete, + Get, HttpCode, HttpStatus, Param, @@ -37,6 +38,17 @@ export class AccountsController { return this.accountsService.createAccount({ auth, createAccountDto }); } + @ApiOkResponse({ type: Account }) + @Get(':address') + @UseGuards(AuthGuard) + async getAccount( + @Param('address', new ValidationPipe(AddressSchema)) address: `0x${string}`, + @Req() request: Request, + ): Promise { + const auth = request.accessToken; + return this.accountsService.getAccount({ auth, address }); + } + @Delete(':address') @UseGuards(AuthGuard) @HttpCode(HttpStatus.NO_CONTENT) diff --git a/src/routes/accounts/accounts.service.ts b/src/routes/accounts/accounts.service.ts index 389a6d58ad..14b47c3611 100644 --- a/src/routes/accounts/accounts.service.ts +++ b/src/routes/accounts/accounts.service.ts @@ -26,6 +26,20 @@ export class AccountsService { return this.mapAccount(domainAccount); } + async getAccount(args: { + auth?: AuthPayloadDto; + address: `0x${string}`; + }): Promise { + if (!args.auth) { + throw new UnauthorizedException(); + } + const domainAccount = await this.accountsRepository.getAccount({ + auth: args.auth, + address: args.address, + }); + return this.mapAccount(domainAccount); + } + async deleteAccount(args: { auth?: AuthPayloadDto; address: `0x${string}`; From 65f4026ccc308e0e73f2e36e0fffca66365df5e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Mon, 1 Jul 2024 13:28:47 +0200 Subject: [PATCH 137/207] Add confirmations info to debug logs (#1707) Adds `confirmations` and `confirmationsRequired` to debug logs. --- src/datasources/cache/cache.first.data.source.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/datasources/cache/cache.first.data.source.ts b/src/datasources/cache/cache.first.data.source.ts index 9cd3d6e318..dd0c5822d0 100644 --- a/src/datasources/cache/cache.first.data.source.ts +++ b/src/datasources/cache/cache.first.data.source.ts @@ -225,6 +225,8 @@ export class CacheFirstDataSource { return { txType: 'multisig', safeTxHash: transaction.safeTxHash, + confirmations: transaction.confirmations, + confirmationRequired: transaction.confirmationsRequired, }; } else if (isEthereumTransaction(transaction)) { return { From fa4f59fe0f45310dab3d9226f7ff9acab513e095 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jul 2024 09:17:16 +0200 Subject: [PATCH 138/207] Bump the nest-js-core group with 4 updates (#1709) Bumps the nest-js-core group with 4 updates: [@nestjs/common](https://github.com/nestjs/nest/tree/HEAD/packages/common), [@nestjs/core](https://github.com/nestjs/nest/tree/HEAD/packages/core), [@nestjs/platform-express](https://github.com/nestjs/nest/tree/HEAD/packages/platform-express) and [@nestjs/testing](https://github.com/nestjs/nest/tree/HEAD/packages/testing). Updates `@nestjs/common` from 10.3.9 to 10.3.10 - [Release notes](https://github.com/nestjs/nest/releases) - [Commits](https://github.com/nestjs/nest/commits/v10.3.10/packages/common) Updates `@nestjs/core` from 10.3.9 to 10.3.10 - [Release notes](https://github.com/nestjs/nest/releases) - [Commits](https://github.com/nestjs/nest/commits/v10.3.10/packages/core) Updates `@nestjs/platform-express` from 10.3.9 to 10.3.10 - [Release notes](https://github.com/nestjs/nest/releases) - [Commits](https://github.com/nestjs/nest/commits/v10.3.10/packages/platform-express) Updates `@nestjs/testing` from 10.3.9 to 10.3.10 - [Release notes](https://github.com/nestjs/nest/releases) - [Commits](https://github.com/nestjs/nest/commits/v10.3.10/packages/testing) --- updated-dependencies: - dependency-name: "@nestjs/common" dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nest-js-core - dependency-name: "@nestjs/core" dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nest-js-core - dependency-name: "@nestjs/platform-express" dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nest-js-core - dependency-name: "@nestjs/testing" dependency-type: direct:development update-type: version-update:semver-patch dependency-group: nest-js-core ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 8 ++++---- yarn.lock | 56 ++++++++++++++++++++++++++-------------------------- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/package.json b/package.json index 34b63a9a35..7590be38d3 100644 --- a/package.json +++ b/package.json @@ -26,10 +26,10 @@ }, "dependencies": { "@nestjs/cli": "^10.3.2", - "@nestjs/common": "^10.3.9", + "@nestjs/common": "^10.3.10", "@nestjs/config": "^3.2.2", - "@nestjs/core": "^10.3.9", - "@nestjs/platform-express": "^10.3.9", + "@nestjs/core": "^10.3.10", + "@nestjs/platform-express": "^10.3.10", "@nestjs/serve-static": "^4.0.2", "@nestjs/swagger": "^7.3.1", "@safe-global/safe-deployments": "^1.37.0", @@ -51,7 +51,7 @@ "devDependencies": { "@faker-js/faker": "^8.4.1", "@nestjs/schematics": "^10.1.1", - "@nestjs/testing": "^10.3.9", + "@nestjs/testing": "^10.3.10", "@types/amqplib": "^0", "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.21", diff --git a/yarn.lock b/yarn.lock index c81599f3a7..95da96abc4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1227,12 +1227,12 @@ __metadata: languageName: node linkType: hard -"@nestjs/common@npm:^10.3.9": - version: 10.3.9 - resolution: "@nestjs/common@npm:10.3.9" +"@nestjs/common@npm:^10.3.10": + version: 10.3.10 + resolution: "@nestjs/common@npm:10.3.10" dependencies: iterare: "npm:1.2.1" - tslib: "npm:2.6.2" + tslib: "npm:2.6.3" uid: "npm:2.0.2" peerDependencies: class-transformer: "*" @@ -1244,7 +1244,7 @@ __metadata: optional: true class-validator: optional: true - checksum: 10/a4886bf1e99f0f1952731dcb3ed4fa0927c0165285683637a719c85e4ddf3a2f711d554136c60d83862fc9f917541c1bbcb75191510930e503e19da190c2de34 + checksum: 10/610b761ccecca3b02e5a15f7ad87f2e6fddd979bead104691a56af95bbd5eae532b48e7dfdea79b1bf2d2bfb1341ca25308e9e76bd913d9f39fcc3709be0fbd8 languageName: node linkType: hard @@ -1263,15 +1263,15 @@ __metadata: languageName: node linkType: hard -"@nestjs/core@npm:^10.3.9": - version: 10.3.9 - resolution: "@nestjs/core@npm:10.3.9" +"@nestjs/core@npm:^10.3.10": + version: 10.3.10 + resolution: "@nestjs/core@npm:10.3.10" dependencies: "@nuxtjs/opencollective": "npm:0.3.2" fast-safe-stringify: "npm:2.1.1" iterare: "npm:1.2.1" path-to-regexp: "npm:3.2.0" - tslib: "npm:2.6.2" + tslib: "npm:2.6.3" uid: "npm:2.0.2" peerDependencies: "@nestjs/common": ^10.0.0 @@ -1287,7 +1287,7 @@ __metadata: optional: true "@nestjs/websockets": optional: true - checksum: 10/90f52b0cf7e80f417202306d65df16a23f5aaa4e9db996524845e5b2e8738f0f175dda9ef75a90d2b5665e2868aafa6e31ff4aa2b68cbb5a5169c43cc2fbaf41 + checksum: 10/0fd695dab07f9101c1d8e55207dd6ed40bc9632e9f8b5240833a2dbd6cdb5a7e0b75c21b478a7d226618391d9bb35798c25b81fad7a4157bdf8b2505e86a7d7c languageName: node linkType: hard @@ -1308,19 +1308,19 @@ __metadata: languageName: node linkType: hard -"@nestjs/platform-express@npm:^10.3.9": - version: 10.3.9 - resolution: "@nestjs/platform-express@npm:10.3.9" +"@nestjs/platform-express@npm:^10.3.10": + version: 10.3.10 + resolution: "@nestjs/platform-express@npm:10.3.10" dependencies: body-parser: "npm:1.20.2" cors: "npm:2.8.5" express: "npm:4.19.2" multer: "npm:1.4.4-lts.1" - tslib: "npm:2.6.2" + tslib: "npm:2.6.3" peerDependencies: "@nestjs/common": ^10.0.0 "@nestjs/core": ^10.0.0 - checksum: 10/3fa49827355239c99882ed83e021f334851ffd12a17ff9a705f1dbc0fcee134dfc0795841aca0dc25663de8ce1628fbbeb338e185e8fb0f1515769657d317f7e + checksum: 10/6d70f7e53acf4ef2aaec0623ed5dc639f91b6a8ad9bc5dc4a3d01bb723eb10f762ed8ca1e6fa76bf035bb260221878c19eff18e5f273ec1dc19d45a7d4675e2c languageName: node linkType: hard @@ -1404,11 +1404,11 @@ __metadata: languageName: node linkType: hard -"@nestjs/testing@npm:^10.3.9": - version: 10.3.9 - resolution: "@nestjs/testing@npm:10.3.9" +"@nestjs/testing@npm:^10.3.10": + version: 10.3.10 + resolution: "@nestjs/testing@npm:10.3.10" dependencies: - tslib: "npm:2.6.2" + tslib: "npm:2.6.3" peerDependencies: "@nestjs/common": ^10.0.0 "@nestjs/core": ^10.0.0 @@ -1419,7 +1419,7 @@ __metadata: optional: true "@nestjs/platform-express": optional: true - checksum: 10/0053a4fffc0675961c3026a33e01237757752646904fb2cbad0a756601045bba7a3b642c765d47347700a6193da9538c61a8743f2ea9075f3f8fd97d0a94a58d + checksum: 10/fd7d40b4c49e7c93eddc96adeb516d7c6e2e3fa1d2adb933debfff1c16570efda402f9718d036643b7aa2e2939d9369c4197bef6db053add1669758df4ed0edc languageName: node linkType: hard @@ -7236,14 +7236,14 @@ __metadata: dependencies: "@faker-js/faker": "npm:^8.4.1" "@nestjs/cli": "npm:^10.3.2" - "@nestjs/common": "npm:^10.3.9" + "@nestjs/common": "npm:^10.3.10" "@nestjs/config": "npm:^3.2.2" - "@nestjs/core": "npm:^10.3.9" - "@nestjs/platform-express": "npm:^10.3.9" + "@nestjs/core": "npm:^10.3.10" + "@nestjs/platform-express": "npm:^10.3.10" "@nestjs/schematics": "npm:^10.1.1" "@nestjs/serve-static": "npm:^4.0.2" "@nestjs/swagger": "npm:^7.3.1" - "@nestjs/testing": "npm:^10.3.9" + "@nestjs/testing": "npm:^10.3.10" "@safe-global/safe-deployments": "npm:^1.37.0" "@types/amqplib": "npm:^0" "@types/cookie-parser": "npm:^1.4.7" @@ -8053,10 +8053,10 @@ __metadata: languageName: node linkType: hard -"tslib@npm:2.6.2": - version: 2.6.2 - resolution: "tslib@npm:2.6.2" - checksum: 10/bd26c22d36736513980091a1e356378e8b662ded04204453d353a7f34a4c21ed0afc59b5f90719d4ba756e581a162ecbf93118dc9c6be5acf70aa309188166ca +"tslib@npm:2.6.3": + version: 2.6.3 + resolution: "tslib@npm:2.6.3" + checksum: 10/52109bb681f8133a2e58142f11a50e05476de4f075ca906d13b596ae5f7f12d30c482feb0bff167ae01cfc84c5803e575a307d47938999246f5a49d174fc558c languageName: node linkType: hard From 06d91e7b83a614d7b1dc4d13e6dbc11f609295a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jul 2024 09:17:32 +0200 Subject: [PATCH 139/207] Bump typescript-eslint from 7.14.1 to 7.15.0 (#1710) Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 7.14.1 to 7.15.0. - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.15.0/packages/typescript-eslint) --- updated-dependencies: - dependency-name: typescript-eslint dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 116 +++++++++++++++++++++++++-------------------------- 2 files changed, 59 insertions(+), 59 deletions(-) diff --git a/package.json b/package.json index 7590be38d3..2afb005783 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "ts-node": "^10.9.2", "tsconfig-paths": "4.2.0", "typescript": "^5.5.2", - "typescript-eslint": "^7.14.1" + "typescript-eslint": "^7.15.0" }, "jest": { "moduleFileExtensions": [ diff --git a/yarn.lock b/yarn.lock index 95da96abc4..04259e0b46 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2012,15 +2012,15 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:7.14.1": - version: 7.14.1 - resolution: "@typescript-eslint/eslint-plugin@npm:7.14.1" +"@typescript-eslint/eslint-plugin@npm:7.15.0": + version: 7.15.0 + resolution: "@typescript-eslint/eslint-plugin@npm:7.15.0" dependencies: "@eslint-community/regexpp": "npm:^4.10.0" - "@typescript-eslint/scope-manager": "npm:7.14.1" - "@typescript-eslint/type-utils": "npm:7.14.1" - "@typescript-eslint/utils": "npm:7.14.1" - "@typescript-eslint/visitor-keys": "npm:7.14.1" + "@typescript-eslint/scope-manager": "npm:7.15.0" + "@typescript-eslint/type-utils": "npm:7.15.0" + "@typescript-eslint/utils": "npm:7.15.0" + "@typescript-eslint/visitor-keys": "npm:7.15.0" graphemer: "npm:^1.4.0" ignore: "npm:^5.3.1" natural-compare: "npm:^1.4.0" @@ -2031,44 +2031,44 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/48c815dbb92399965483c93b27816fad576c3b3227b59eebfe5525e24d07b39ec8b0c7459de83865c8d61c818696519f50b229714dd3ed705d5b35973bfcc781 + checksum: 10/e6b21687ab9e9dc38eb1b1d90a3ac483f3f5e5e9c49aa8a434a24de016822d65c82b926cda2ae79bac2225bd9495fb04f7aa6afcaad2b09f6129fd8014fbcedd languageName: node linkType: hard -"@typescript-eslint/parser@npm:7.14.1": - version: 7.14.1 - resolution: "@typescript-eslint/parser@npm:7.14.1" +"@typescript-eslint/parser@npm:7.15.0": + version: 7.15.0 + resolution: "@typescript-eslint/parser@npm:7.15.0" dependencies: - "@typescript-eslint/scope-manager": "npm:7.14.1" - "@typescript-eslint/types": "npm:7.14.1" - "@typescript-eslint/typescript-estree": "npm:7.14.1" - "@typescript-eslint/visitor-keys": "npm:7.14.1" + "@typescript-eslint/scope-manager": "npm:7.15.0" + "@typescript-eslint/types": "npm:7.15.0" + "@typescript-eslint/typescript-estree": "npm:7.15.0" + "@typescript-eslint/visitor-keys": "npm:7.15.0" debug: "npm:^4.3.4" peerDependencies: eslint: ^8.56.0 peerDependenciesMeta: typescript: optional: true - checksum: 10/f521462a7005cab5e4923937dcf36713d9438ded175b53332ae469d91cc9eb18cb3a23768b3c52063464280baae83f6b66db28cebb2e262d6d869d1a898b23f3 + checksum: 10/0b5e7a14fa5d0680efb17e750a095729a7fb7c785d7a0fea2f9e6cbfef9e65caab2b751654b348b9ab813d222c1c3f8189ebf48561b81224d1821cee5c99d658 languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:7.14.1": - version: 7.14.1 - resolution: "@typescript-eslint/scope-manager@npm:7.14.1" +"@typescript-eslint/scope-manager@npm:7.15.0": + version: 7.15.0 + resolution: "@typescript-eslint/scope-manager@npm:7.15.0" dependencies: - "@typescript-eslint/types": "npm:7.14.1" - "@typescript-eslint/visitor-keys": "npm:7.14.1" - checksum: 10/600a7beb96f5b96f675125285137339c2438b5b26db203a66eef52dd409e8c0db0dafb22c94547dfb963f8efdf63b0fb59e05655e2dcf84d54624863365a59e7 + "@typescript-eslint/types": "npm:7.15.0" + "@typescript-eslint/visitor-keys": "npm:7.15.0" + checksum: 10/45bfdbae2d080691a34f5b37679b4a4067981baa3b82922268abdd21f6917a8dd1c4ccb12133f6c9cce81cfd640040913b223e8125235b92f42fdb57db358a3e languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:7.14.1": - version: 7.14.1 - resolution: "@typescript-eslint/type-utils@npm:7.14.1" +"@typescript-eslint/type-utils@npm:7.15.0": + version: 7.15.0 + resolution: "@typescript-eslint/type-utils@npm:7.15.0" dependencies: - "@typescript-eslint/typescript-estree": "npm:7.14.1" - "@typescript-eslint/utils": "npm:7.14.1" + "@typescript-eslint/typescript-estree": "npm:7.15.0" + "@typescript-eslint/utils": "npm:7.15.0" debug: "npm:^4.3.4" ts-api-utils: "npm:^1.3.0" peerDependencies: @@ -2076,23 +2076,23 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/75c279948a7e7e546d692e85a0b48fc3b648ffee1773feb7ff199aba1b0847a9a16c432b133aa72d26e645627403852b7dd24829f9b3badd6d4711c4cc38e9e4 + checksum: 10/64fa589b413567df3689a19ef88f3dbaed66d965e39cc548a58626eb5bd8fc4e2338496eb632f3472de9ae9800cb14d0e48ef3508efe80bdb91af8f3f1e56ad7 languageName: node linkType: hard -"@typescript-eslint/types@npm:7.14.1": - version: 7.14.1 - resolution: "@typescript-eslint/types@npm:7.14.1" - checksum: 10/608057582bb195bd746a7bfb7c04dac4be1d4602b8fa681b2d1d50b564362b681dc2ca293b13cc4c7acc454f3a09f1ea2580415347efb7853e5df8ba34b7acdb +"@typescript-eslint/types@npm:7.15.0": + version: 7.15.0 + resolution: "@typescript-eslint/types@npm:7.15.0" + checksum: 10/b36c98344469f4bc54a5199733ea4f6d4d0f2da1070605e60d4031e2da2946b84b91a90108516c8e6e83a21030ba4e935053a0906041c920156de40683297d0b languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:7.14.1": - version: 7.14.1 - resolution: "@typescript-eslint/typescript-estree@npm:7.14.1" +"@typescript-eslint/typescript-estree@npm:7.15.0": + version: 7.15.0 + resolution: "@typescript-eslint/typescript-estree@npm:7.15.0" dependencies: - "@typescript-eslint/types": "npm:7.14.1" - "@typescript-eslint/visitor-keys": "npm:7.14.1" + "@typescript-eslint/types": "npm:7.15.0" + "@typescript-eslint/visitor-keys": "npm:7.15.0" debug: "npm:^4.3.4" globby: "npm:^11.1.0" is-glob: "npm:^4.0.3" @@ -2102,31 +2102,31 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/f75b956f7981712d3f85498f9d9fcc2243d79d6fe71b24bc688a7c43d2a4248f73ecfb78f9d58501fde87fc44b02e26c46f9ea2ae51eb8450db79ca169f91ef9 + checksum: 10/c5fb15108fbbc1bc976e827218ff7bfbc78930c5906292325ee42ba03514623e7b861497b3e3087f71ede9a757b16441286b4d234450450b0dd70ff753782736 languageName: node linkType: hard -"@typescript-eslint/utils@npm:7.14.1": - version: 7.14.1 - resolution: "@typescript-eslint/utils@npm:7.14.1" +"@typescript-eslint/utils@npm:7.15.0": + version: 7.15.0 + resolution: "@typescript-eslint/utils@npm:7.15.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.4.0" - "@typescript-eslint/scope-manager": "npm:7.14.1" - "@typescript-eslint/types": "npm:7.14.1" - "@typescript-eslint/typescript-estree": "npm:7.14.1" + "@typescript-eslint/scope-manager": "npm:7.15.0" + "@typescript-eslint/types": "npm:7.15.0" + "@typescript-eslint/typescript-estree": "npm:7.15.0" peerDependencies: eslint: ^8.56.0 - checksum: 10/1ef74214ca84e32f151364512a51e82b7da5590dee03d0de0e1abcf18009e569f9a0638506cf03bd4a844af634b4935458e334b7b2459e9a50a67aba7d6228c7 + checksum: 10/f6de1849dee610a8110638be98ab2ec09e7cdf2f756b538b0544df2dfad86a8e66d5326a765302fe31553e8d9d3170938c0d5d38bd9c7d36e3ee0beb1bdc8172 languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:7.14.1": - version: 7.14.1 - resolution: "@typescript-eslint/visitor-keys@npm:7.14.1" +"@typescript-eslint/visitor-keys@npm:7.15.0": + version: 7.15.0 + resolution: "@typescript-eslint/visitor-keys@npm:7.15.0" dependencies: - "@typescript-eslint/types": "npm:7.14.1" + "@typescript-eslint/types": "npm:7.15.0" eslint-visitor-keys: "npm:^3.4.3" - checksum: 10/42246f33cb3f9185c0b467c9a534e34a674e4fc08ba982a03aaa77dc1e569e916f1fca9ce9cd14c4df91f416e6e917bff51f98b8d8ca26ec5f67c253e8646bde + checksum: 10/0e17d7f5de767da7f98170c2efc905cdb0ceeaf04a667e12ca1a92eae64479a07f4f8e2a9b5023b055b01250916c3bcac86908cd06552610baff734fafae4464 languageName: node linkType: hard @@ -7277,7 +7277,7 @@ __metadata: ts-node: "npm:^10.9.2" tsconfig-paths: "npm:4.2.0" typescript: "npm:^5.5.2" - typescript-eslint: "npm:^7.14.1" + typescript-eslint: "npm:^7.15.0" viem: "npm:^2.16.2" winston: "npm:^3.13.0" zod: "npm:^3.23.8" @@ -8107,19 +8107,19 @@ __metadata: languageName: node linkType: hard -"typescript-eslint@npm:^7.14.1": - version: 7.14.1 - resolution: "typescript-eslint@npm:7.14.1" +"typescript-eslint@npm:^7.15.0": + version: 7.15.0 + resolution: "typescript-eslint@npm:7.15.0" dependencies: - "@typescript-eslint/eslint-plugin": "npm:7.14.1" - "@typescript-eslint/parser": "npm:7.14.1" - "@typescript-eslint/utils": "npm:7.14.1" + "@typescript-eslint/eslint-plugin": "npm:7.15.0" + "@typescript-eslint/parser": "npm:7.15.0" + "@typescript-eslint/utils": "npm:7.15.0" peerDependencies: eslint: ^8.56.0 peerDependenciesMeta: typescript: optional: true - checksum: 10/f017459ca6877e301f74d0bb6efadee354d10e3a1520f902a5ff1959566d0e400ab7ea2008f78f7694d4ce69f4c502138ae78c24442423361725d4373d675720 + checksum: 10/f81129f795cc5a5f01ae3c289113a00232f937bfd8f2ebe519a369c9adce9155de106ccd7d19cd353e6f8d34bde391d31bd83754df2deffb7c2be8238da173d5 languageName: node linkType: hard From 5fc4813ef85b84c84e4325cb21f62582a43e0ed0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jul 2024 09:17:46 +0200 Subject: [PATCH 140/207] Bump @types/lodash from 4.17.5 to 4.17.6 (#1711) Bumps [@types/lodash](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/lodash) from 4.17.5 to 4.17.6. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/lodash) --- updated-dependencies: - dependency-name: "@types/lodash" dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 2afb005783..efe48f3549 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "@types/express": "^4.17.21", "@types/jest": "29.5.12", "@types/jsonwebtoken": "^9", - "@types/lodash": "^4.17.5", + "@types/lodash": "^4.17.6", "@types/node": "^20.14.8", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.2", diff --git a/yarn.lock b/yarn.lock index 04259e0b46..d9e6fa03af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1883,10 +1883,10 @@ __metadata: languageName: node linkType: hard -"@types/lodash@npm:^4.17.5": - version: 4.17.5 - resolution: "@types/lodash@npm:4.17.5" - checksum: 10/10e2e9cbeb16998026f4071f9f5f2a38b651eba15302f512e0b8ab904c07c197ca0282d2821f64e53c2b692d7046af0a1ce3ead190fb077cbe4036948fce1924 +"@types/lodash@npm:^4.17.6": + version: 4.17.6 + resolution: "@types/lodash@npm:4.17.6" + checksum: 10/6d3a68b3e795381f4aaf946855134d24eeb348ad5d66e9a44461d30026da82b215d55b92b70486d811ca45d54d4ab956aa2dced37fd04e19d49afe160ae3da2e languageName: node linkType: hard @@ -7250,7 +7250,7 @@ __metadata: "@types/express": "npm:^4.17.21" "@types/jest": "npm:29.5.12" "@types/jsonwebtoken": "npm:^9" - "@types/lodash": "npm:^4.17.5" + "@types/lodash": "npm:^4.17.6" "@types/node": "npm:^20.14.8" "@types/semver": "npm:^7.5.8" "@types/supertest": "npm:^6.0.2" From 006d4a6f7e3550670d9c5216b4c5b181070c9a13 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jul 2024 09:32:39 +0200 Subject: [PATCH 141/207] Bump @nestjs/config from 3.2.2 to 3.2.3 (#1712) Bumps [@nestjs/config](https://github.com/nestjs/config) from 3.2.2 to 3.2.3. - [Release notes](https://github.com/nestjs/config/releases) - [Changelog](https://github.com/nestjs/config/blob/master/.release-it.json) - [Commits](https://github.com/nestjs/config/compare/3.2.2...3.2.3) --- updated-dependencies: - dependency-name: "@nestjs/config" dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 20 +++++--------------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index efe48f3549..47d22f2cf1 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "dependencies": { "@nestjs/cli": "^10.3.2", "@nestjs/common": "^10.3.10", - "@nestjs/config": "^3.2.2", + "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.3.10", "@nestjs/platform-express": "^10.3.10", "@nestjs/serve-static": "^4.0.2", diff --git a/yarn.lock b/yarn.lock index d9e6fa03af..4afc5a3a01 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1248,18 +1248,17 @@ __metadata: languageName: node linkType: hard -"@nestjs/config@npm:^3.2.2": - version: 3.2.2 - resolution: "@nestjs/config@npm:3.2.2" +"@nestjs/config@npm:^3.2.3": + version: 3.2.3 + resolution: "@nestjs/config@npm:3.2.3" dependencies: dotenv: "npm:16.4.5" dotenv-expand: "npm:10.0.0" lodash: "npm:4.17.21" - uuid: "npm:9.0.1" peerDependencies: "@nestjs/common": ^8.0.0 || ^9.0.0 || ^10.0.0 rxjs: ^7.1.0 - checksum: 10/c7f0cea0f6c73a5168b572510437d6033d27c6abdf686fa600ae42828b668470f1dd6240a3f8897e0f3c779582f4648ce28591369874e2a4bcf1e7b34c88c9b6 + checksum: 10/30c5f783d4251bae75b00db025ae187f3b041bd6983efecfde1690aea83f4303ce4319262281d001dfa994df70d5535fa1e96d5ec5f4605797d8d5ca0442ffcf languageName: node linkType: hard @@ -7237,7 +7236,7 @@ __metadata: "@faker-js/faker": "npm:^8.4.1" "@nestjs/cli": "npm:^10.3.2" "@nestjs/common": "npm:^10.3.10" - "@nestjs/config": "npm:^3.2.2" + "@nestjs/config": "npm:^3.2.3" "@nestjs/core": "npm:^10.3.10" "@nestjs/platform-express": "npm:^10.3.10" "@nestjs/schematics": "npm:^10.1.1" @@ -8272,15 +8271,6 @@ __metadata: languageName: node linkType: hard -"uuid@npm:9.0.1": - version: 9.0.1 - resolution: "uuid@npm:9.0.1" - bin: - uuid: dist/bin/uuid - checksum: 10/9d0b6adb72b736e36f2b1b53da0d559125ba3e39d913b6072f6f033e0c87835b414f0836b45bcfaf2bdf698f92297fea1c3cc19b0b258bc182c9c43cc0fab9f2 - languageName: node - linkType: hard - "v8-compile-cache-lib@npm:^3.0.1": version: 3.0.1 resolution: "v8-compile-cache-lib@npm:3.0.1" From 037e9816c498eb41e0510e6cc8f4b725e1c80332 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jul 2024 09:32:56 +0200 Subject: [PATCH 142/207] Bump viem from 2.16.2 to 2.16.5 (#1713) Bumps [viem](https://github.com/wevm/viem) from 2.16.2 to 2.16.5. - [Release notes](https://github.com/wevm/viem/releases) - [Commits](https://github.com/wevm/viem/compare/viem@2.16.2...viem@2.16.5) --- updated-dependencies: - dependency-name: viem dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 97 +++++++++++++++++++++++++--------------------------- 2 files changed, 47 insertions(+), 52 deletions(-) diff --git a/package.json b/package.json index 47d22f2cf1..390a0da1f7 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "semver": "^7.6.2", - "viem": "^2.16.2", + "viem": "^2.16.5", "winston": "^3.13.0", "zod": "^3.23.8" }, diff --git a/yarn.lock b/yarn.lock index 4afc5a3a01..f23c858d45 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1422,26 +1422,28 @@ __metadata: languageName: node linkType: hard -"@noble/curves@npm:1.2.0, @noble/curves@npm:~1.2.0": - version: 1.2.0 - resolution: "@noble/curves@npm:1.2.0" +"@noble/curves@npm:1.4.0": + version: 1.4.0 + resolution: "@noble/curves@npm:1.4.0" dependencies: - "@noble/hashes": "npm:1.3.2" - checksum: 10/94e02e9571a9fd42a3263362451849d2f54405cb3ce9fa7c45bc6b9b36dcd7d1d20e2e1e14cfded24937a13d82f1e60eefc4d7a14982ce0bc219a9fc0f51d1f9 + "@noble/hashes": "npm:1.4.0" + checksum: 10/b21b30a36ff02bfcc0f5e6163d245cdbaf7f640511fff97ccf83fc207ee79cfd91584b4d97977374de04cb118a55eb63a7964c82596a64162bbc42bc685ae6d9 languageName: node linkType: hard -"@noble/hashes@npm:1.3.2, @noble/hashes@npm:~1.3.2": - version: 1.3.2 - resolution: "@noble/hashes@npm:1.3.2" - checksum: 10/685f59d2d44d88e738114b71011d343a9f7dce9dfb0a121f1489132f9247baa60bc985e5ec6f3213d114fbd1e1168e7294644e46cbd0ce2eba37994f28eeb51b +"@noble/curves@npm:~1.4.0": + version: 1.4.2 + resolution: "@noble/curves@npm:1.4.2" + dependencies: + "@noble/hashes": "npm:1.4.0" + checksum: 10/f433a2e8811ae345109388eadfa18ef2b0004c1f79417553241db4f0ad0d59550be6298a4f43d989c627e9f7551ffae6e402a4edf0173981e6da95fc7cab5123 languageName: node linkType: hard -"@noble/hashes@npm:~1.3.0": - version: 1.3.1 - resolution: "@noble/hashes@npm:1.3.1" - checksum: 10/39474bab7e7813dbbfd8750476f48046d3004984e161fcd4333e40ca823f07b069010b35a20246e5b4ac20858e29913172a4d69720fd1e93620f7bedb70f9b72 +"@noble/hashes@npm:1.4.0, @noble/hashes@npm:~1.4.0": + version: 1.4.0 + resolution: "@noble/hashes@npm:1.4.0" + checksum: 10/e156e65794c473794c52fa9d06baf1eb20903d0d96719530f523cc4450f6c721a957c544796e6efd0197b2296e7cd70efeb312f861465e17940a3e3c7e0febc6 languageName: node linkType: hard @@ -1577,38 +1579,31 @@ __metadata: languageName: node linkType: hard -"@scure/base@npm:~1.1.0": - version: 1.1.1 - resolution: "@scure/base@npm:1.1.1" - checksum: 10/9aaa525ac25215cbe1bde00733a2fd25e99f03793aa1fd2961c567bb62b60c8a3a485a7cb5d748c41604fca79d149de19b05e64449b770c0a04b9ae38d0b5b2b - languageName: node - linkType: hard - -"@scure/base@npm:~1.1.2": - version: 1.1.3 - resolution: "@scure/base@npm:1.1.3" - checksum: 10/cb715fa8cdb043c4d96b6ba0666791d4eb4d033f7b5285a853aba25e0ba94914f05ff5d956029ad060005f9bdd02dab0caef9a0a63f07ed096a2c2a0c0cf9c36 +"@scure/base@npm:~1.1.6": + version: 1.1.7 + resolution: "@scure/base@npm:1.1.7" + checksum: 10/fc50ffaab36cb46ff9fa4dc5052a06089ab6a6707f63d596bb34aaaec76173c9a564ac312a0b981b5e7a5349d60097b8878673c75d6cbfc4da7012b63a82099b languageName: node linkType: hard -"@scure/bip32@npm:1.3.2": - version: 1.3.2 - resolution: "@scure/bip32@npm:1.3.2" +"@scure/bip32@npm:1.4.0": + version: 1.4.0 + resolution: "@scure/bip32@npm:1.4.0" dependencies: - "@noble/curves": "npm:~1.2.0" - "@noble/hashes": "npm:~1.3.2" - "@scure/base": "npm:~1.1.2" - checksum: 10/b90da28dfe75519496a85c97e77c9443734873910f32b8557762910a5c4e642290a462b0ed14fa42e0efed6acb9a7f6155ad5cb5d38d4ff87eb2de4760eb32a4 + "@noble/curves": "npm:~1.4.0" + "@noble/hashes": "npm:~1.4.0" + "@scure/base": "npm:~1.1.6" + checksum: 10/6cd5062d902564d9e970597ec8b1adacb415b2eadfbb95aee1a1a0480a52eb0de4d294d3753aa8b48548064c9795ed108d348a31a8ce3fc88785377bb12c63b9 languageName: node linkType: hard -"@scure/bip39@npm:1.2.1": - version: 1.2.1 - resolution: "@scure/bip39@npm:1.2.1" +"@scure/bip39@npm:1.3.0": + version: 1.3.0 + resolution: "@scure/bip39@npm:1.3.0" dependencies: - "@noble/hashes": "npm:~1.3.0" - "@scure/base": "npm:~1.1.0" - checksum: 10/2ea368bbed34d6b1701c20683bf465e147f231a9e37e639b8c82f585d6f978bb0f3855fca7ceff04954ae248b3e313f5d322d0210614fb7acb402739415aaf31 + "@noble/hashes": "npm:~1.4.0" + "@scure/base": "npm:~1.1.6" + checksum: 10/7d71fd58153de22fe8cd65b525f6958a80487bc9d0fbc32c71c328aeafe41fa259f989d2f1e0fa4fdfeaf83b8fcf9310d52ed9862987e46c2f2bfb9dd8cf9fc1 languageName: node linkType: hard @@ -2301,9 +2296,9 @@ __metadata: languageName: node linkType: hard -"abitype@npm:1.0.4": - version: 1.0.4 - resolution: "abitype@npm:1.0.4" +"abitype@npm:1.0.5": + version: 1.0.5 + resolution: "abitype@npm:1.0.5" peerDependencies: typescript: ">=5.0.4" zod: ^3 >=3.22.0 @@ -2312,7 +2307,7 @@ __metadata: optional: true zod: optional: true - checksum: 10/816fcf4a7c2043f71707a97075b5df3ca0cb818b68e3df9ae393ed86631201da6e54f9b2ffb192f7c8e0229a97854881300422e33b4b876a38912d3e7ab37b9b + checksum: 10/1acd0d9687945dd78442b71bd84ff3b9dceae27d15f0d8b14b16554a0c8c9518eeb971ff8e94d507f4d9f05a8a8b91eb8fafd735eaecebac37d5c5a4aac06d8e languageName: node linkType: hard @@ -7277,7 +7272,7 @@ __metadata: tsconfig-paths: "npm:4.2.0" typescript: "npm:^5.5.2" typescript-eslint: "npm:^7.15.0" - viem: "npm:^2.16.2" + viem: "npm:^2.16.5" winston: "npm:^3.13.0" zod: "npm:^3.23.8" languageName: unknown @@ -8296,16 +8291,16 @@ __metadata: languageName: node linkType: hard -"viem@npm:^2.16.2": - version: 2.16.2 - resolution: "viem@npm:2.16.2" +"viem@npm:^2.16.5": + version: 2.16.5 + resolution: "viem@npm:2.16.5" dependencies: "@adraffy/ens-normalize": "npm:1.10.0" - "@noble/curves": "npm:1.2.0" - "@noble/hashes": "npm:1.3.2" - "@scure/bip32": "npm:1.3.2" - "@scure/bip39": "npm:1.2.1" - abitype: "npm:1.0.4" + "@noble/curves": "npm:1.4.0" + "@noble/hashes": "npm:1.4.0" + "@scure/bip32": "npm:1.4.0" + "@scure/bip39": "npm:1.3.0" + abitype: "npm:1.0.5" isows: "npm:1.0.4" ws: "npm:8.17.1" peerDependencies: @@ -8313,7 +8308,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/fc6f61853f513ecced75b81e1489ad27fd64de94ddac3747378c7ed29af468da5f844a04760239193b6234a3c15d524e462e04642a8a3780f6422e880f6dc8be + checksum: 10/7314dacb72203cfe177e22e28563f370aefac30bfc37a0f0934709ea58da0e8353c4b0f506d507eee292224840d2876a6d0b1c2d50033404e68f37ce213a48f1 languageName: node linkType: hard From cba4fb4ae50ed2e794d7a5b9b5b5ebc5246bbd4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Tue, 2 Jul 2024 09:37:34 +0200 Subject: [PATCH 143/207] Add account data types (#1708) The endpoint GET /v1/data-types was added to AccountsController. --- migrations/00002_account-data-types/index.sql | 20 ++++ .../00002_account-data-types.spec.ts | 113 ++++++++++++++++++ .../test.accounts.datasource.module.ts | 1 + .../accounts/accounts.datasource.spec.ts | 49 +++++++- .../accounts/accounts.datasource.ts | 5 + .../accounts/accounts.repository.interface.ts | 3 + src/domain/accounts/accounts.repository.ts | 6 + .../__tests__/account-data-type.builder.ts | 13 ++ .../entities/account-data-type.entity.spec.ts | 112 +++++++++++++++++ .../entities/account-data-type.entity.ts | 10 ++ .../accounts.datasource.interface.ts | 3 + .../accounts/accounts.controller.spec.ts | 45 ++++++- src/routes/accounts/accounts.controller.ts | 7 ++ src/routes/accounts/accounts.service.ts | 18 +++ .../entities/account-data-type.entity.ts | 24 ++++ 15 files changed, 425 insertions(+), 4 deletions(-) create mode 100644 migrations/00002_account-data-types/index.sql create mode 100644 migrations/__tests__/00002_account-data-types.spec.ts create mode 100644 src/domain/accounts/entities/__tests__/account-data-type.builder.ts create mode 100644 src/domain/accounts/entities/account-data-type.entity.spec.ts create mode 100644 src/domain/accounts/entities/account-data-type.entity.ts create mode 100644 src/routes/accounts/entities/account-data-type.entity.ts diff --git a/migrations/00002_account-data-types/index.sql b/migrations/00002_account-data-types/index.sql new file mode 100644 index 0000000000..d320714667 --- /dev/null +++ b/migrations/00002_account-data-types/index.sql @@ -0,0 +1,20 @@ +DROP TABLE IF EXISTS account_data_types CASCADE; + +CREATE TABLE account_data_types ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +CREATE OR REPLACE TRIGGER update_account_data_types_updated_at +BEFORE UPDATE ON account_data_types +FOR EACH ROW +EXECUTE FUNCTION update_updated_at_column(); + +INSERT INTO account_data_types (name, description, is_active) VALUES + ('CounterfactualSafes', 'Counterfactual Safes', true), + ('AddressBook', 'Address Book', false), + ('Watchlist', 'Watchlist', false); diff --git a/migrations/__tests__/00002_account-data-types.spec.ts b/migrations/__tests__/00002_account-data-types.spec.ts new file mode 100644 index 0000000000..ff9ceaa9c7 --- /dev/null +++ b/migrations/__tests__/00002_account-data-types.spec.ts @@ -0,0 +1,113 @@ +import { dbFactory } from '@/__tests__/db.factory'; +import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; +import { Sql } from 'postgres'; + +interface AccountDataTypeRow { + id: number; + created_at: Date; + updated_at: Date; + name: string; + description: string; + is_active: boolean; +} + +describe('Migration 00002_account-data-types', () => { + const sql = dbFactory(); + const migrator = new PostgresDatabaseMigrator(sql); + + afterAll(async () => { + await sql.end(); + }); + + it('runs successfully', async () => { + await sql`DROP TABLE IF EXISTS account_data_types CASCADE;`; + + const result = await migrator.test({ + migration: '00002_account-data-types', + after: async (sql: Sql) => { + return { + account_data_types: { + columns: + await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'account_data_types'`, + rows: await sql`SELECT * FROM account_data_types`, + }, + }; + }, + }); + + expect(result.after).toStrictEqual({ + account_data_types: { + columns: expect.arrayContaining([ + { column_name: 'id' }, + { column_name: 'created_at' }, + { column_name: 'updated_at' }, + { column_name: 'name' }, + { column_name: 'description' }, + { column_name: 'is_active' }, + ]), + rows: [ + { + created_at: expect.any(Date), + description: 'Counterfactual Safes', + id: expect.any(Number), + is_active: true, + name: 'CounterfactualSafes', + updated_at: expect.any(Date), + }, + { + created_at: expect.any(Date), + description: 'Address Book', + id: expect.any(Number), + is_active: false, + name: 'AddressBook', + updated_at: expect.any(Date), + }, + { + created_at: expect.any(Date), + description: 'Watchlist', + id: expect.any(Number), + is_active: false, + name: 'Watchlist', + updated_at: expect.any(Date), + }, + ], + }, + }); + }); + + it('should add and update row timestamps', async () => { + await sql`DROP TABLE IF EXISTS account_data_types CASCADE;`; + + const result: { before: unknown; after: AccountDataTypeRow[] } = + await migrator.test({ + migration: '00002_account-data-types', + after: async (sql: Sql): Promise => { + await sql`INSERT INTO account_data_types (name) VALUES ('accountDataTypeTestName');`; + return await sql< + AccountDataTypeRow[] + >`SELECT * FROM account_data_types`; + }, + }); + + expect(result.after[3].name).toBe('accountDataTypeTestName'); + expect(result.after[3].description).toBeNull(); + expect(result.after[3].is_active).toBe(true); + + // created_at and updated_at should be the same after the row is created + const createdAt = new Date(result.after[0].created_at); + const updatedAt = new Date(result.after[0].updated_at); + expect(createdAt).toBeInstanceOf(Date); + expect(createdAt).toStrictEqual(updatedAt); + + // only updated_at should be updated after the row is updated + await sql`UPDATE account_data_types set name = 'updatedName' WHERE id = 1;`; + const afterUpdate = await sql< + AccountDataTypeRow[] + >`SELECT * FROM account_data_types WHERE id = 1`; + const updatedAtAfterUpdate = new Date(afterUpdate[0].updated_at); + const createdAtAfterUpdate = new Date(afterUpdate[0].created_at); + + expect(createdAtAfterUpdate).toStrictEqual(createdAt); + expect(updatedAtAfterUpdate.getTime()).toBeGreaterThan(createdAt.getTime()); + }); +}); diff --git a/src/datasources/accounts/__tests__/test.accounts.datasource.module.ts b/src/datasources/accounts/__tests__/test.accounts.datasource.module.ts index c3b74d68d7..16feb2b815 100644 --- a/src/datasources/accounts/__tests__/test.accounts.datasource.module.ts +++ b/src/datasources/accounts/__tests__/test.accounts.datasource.module.ts @@ -5,6 +5,7 @@ const accountsDatasource = { createAccount: jest.fn(), deleteAccount: jest.fn(), getAccount: jest.fn(), + getDataTypes: jest.fn(), } as jest.MockedObjectDeep; @Module({ diff --git a/src/datasources/accounts/accounts.datasource.spec.ts b/src/datasources/accounts/accounts.datasource.spec.ts index 96578bf726..4c26e0a343 100644 --- a/src/datasources/accounts/accounts.datasource.spec.ts +++ b/src/datasources/accounts/accounts.datasource.spec.ts @@ -27,7 +27,7 @@ describe('AccountsDatasource tests', () => { }); afterEach(async () => { - await sql`TRUNCATE TABLE groups, accounts CASCADE`; + await sql`TRUNCATE TABLE accounts, groups, account_data_types CASCADE`; }); afterAll(async () => { @@ -102,4 +102,51 @@ describe('AccountsDatasource tests', () => { expect(mockLoggingService.debug).toHaveBeenCalledTimes(1); }); }); + + describe('getDataTypes', () => { + it('returns data types successfully', async () => { + const dataTypeNames = [ + faker.lorem.slug(), + faker.lorem.slug(), + faker.lorem.slug(), + ]; + await sql` + INSERT INTO account_data_types (name) VALUES + (${dataTypeNames[0]}), + (${dataTypeNames[1]}), + (${dataTypeNames[2]}) + `; + + const result = await target.getDataTypes(); + + expect(result).toStrictEqual( + expect.arrayContaining([ + { + id: expect.any(Number), + name: dataTypeNames[0], + description: null, + is_active: true, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }, + { + id: expect.any(Number), + name: dataTypeNames[1], + description: null, + is_active: true, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }, + { + id: expect.any(Number), + name: dataTypeNames[2], + description: null, + is_active: true, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }, + ]), + ); + }); + }); }); diff --git a/src/datasources/accounts/accounts.datasource.ts b/src/datasources/accounts/accounts.datasource.ts index 0b5b9183f9..90d76e1663 100644 --- a/src/datasources/accounts/accounts.datasource.ts +++ b/src/datasources/accounts/accounts.datasource.ts @@ -1,3 +1,4 @@ +import { AccountDataType } from '@/domain/accounts/entities/account-data-type.entity'; import { Account } from '@/domain/accounts/entities/account.entity'; import { IAccountsDatasource } from '@/domain/interfaces/accounts.datasource.interface'; import { ILoggingService, LoggingService } from '@/logging/logging.interface'; @@ -59,4 +60,8 @@ export class AccountsDatasource implements IAccountsDatasource { this.loggingService.debug(`Error deleting account ${address}: not found`); } } + + async getDataTypes(): Promise { + return this.sql<[AccountDataType]>`SELECT * FROM account_data_types`; + } } diff --git a/src/domain/accounts/accounts.repository.interface.ts b/src/domain/accounts/accounts.repository.interface.ts index 11982bb8d3..17940dc9e5 100644 --- a/src/domain/accounts/accounts.repository.interface.ts +++ b/src/domain/accounts/accounts.repository.interface.ts @@ -1,5 +1,6 @@ import { AccountsDatasourceModule } from '@/datasources/accounts/accounts.datasource.module'; import { AccountsRepository } from '@/domain/accounts/accounts.repository'; +import { AccountDataType } from '@/domain/accounts/entities/account-data-type.entity'; import { Account } from '@/domain/accounts/entities/account.entity'; import { AuthPayloadDto } from '@/domain/auth/entities/auth-payload.entity'; import { Module } from '@nestjs/common'; @@ -21,6 +22,8 @@ export interface IAccountsRepository { auth: AuthPayloadDto; address: `0x${string}`; }): Promise; + + getDataTypes(): Promise; } @Module({ diff --git a/src/domain/accounts/accounts.repository.ts b/src/domain/accounts/accounts.repository.ts index 4a984c7df9..4991ccc669 100644 --- a/src/domain/accounts/accounts.repository.ts +++ b/src/domain/accounts/accounts.repository.ts @@ -1,4 +1,5 @@ import { IAccountsRepository } from '@/domain/accounts/accounts.repository.interface'; +import { AccountDataType } from '@/domain/accounts/entities/account-data-type.entity'; import { Account, AccountSchema, @@ -47,6 +48,11 @@ export class AccountsRepository implements IAccountsRepository { return this.datasource.deleteAccount(address); } + async getDataTypes(): Promise { + // TODO: add caching with clearing mechanism. + return this.datasource.getDataTypes(); + } + private checkAuth(auth: AuthPayloadDto, address: `0x${string}`): void { const authPayload = new AuthPayload(auth); if (!authPayload.isForSigner(address)) { diff --git a/src/domain/accounts/entities/__tests__/account-data-type.builder.ts b/src/domain/accounts/entities/__tests__/account-data-type.builder.ts new file mode 100644 index 0000000000..49bc84d712 --- /dev/null +++ b/src/domain/accounts/entities/__tests__/account-data-type.builder.ts @@ -0,0 +1,13 @@ +import { Builder, IBuilder } from '@/__tests__/builder'; +import { AccountDataType } from '@/domain/accounts/entities/account-data-type.entity'; +import { faker } from '@faker-js/faker'; + +export function accountDataTypeBuilder(): IBuilder { + return new Builder() + .with('id', faker.number.int()) + .with('name', faker.lorem.slug()) + .with('description', faker.lorem.slug()) + .with('is_active', faker.datatype.boolean()) + .with('created_at', faker.date.recent()) + .with('updated_at', faker.date.recent()); +} diff --git a/src/domain/accounts/entities/account-data-type.entity.spec.ts b/src/domain/accounts/entities/account-data-type.entity.spec.ts new file mode 100644 index 0000000000..ff660b1301 --- /dev/null +++ b/src/domain/accounts/entities/account-data-type.entity.spec.ts @@ -0,0 +1,112 @@ +import { accountDataTypeBuilder } from '@/domain/accounts/entities/__tests__/account-data-type.builder'; +import { AccountDataTypeSchema } from '@/domain/accounts/entities/account-data-type.entity'; +import { faker } from '@faker-js/faker'; + +describe('AccountDataTypeSchema', () => { + it('should verify an AccountDataType', () => { + const accountDataType = accountDataTypeBuilder().build(); + + const result = AccountDataTypeSchema.safeParse(accountDataType); + + expect(result.success).toBe(true); + }); + + it.each(['id' as const])( + 'should not verify an AccountDataType with a float %s', + (field) => { + const accountDataType = accountDataTypeBuilder() + .with(field, faker.number.float()) + .build(); + + const result = AccountDataTypeSchema.safeParse(accountDataType); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected: 'integer', + message: 'Expected integer, received float', + path: [field], + received: 'float', + }, + ]); + }, + ); + + it.each(['name' as const, 'description' as const])( + 'should not verify an AccountDataType with a integer %s', + (field) => { + const accountDataType = accountDataTypeBuilder().build(); + // @ts-expect-error - should be strings + accountDataType[field] = faker.number.int(); + + const result = AccountDataTypeSchema.safeParse(accountDataType); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Expected string, received number', + path: [field], + received: 'number', + }, + ]); + }, + ); + + it('should not verify an AccountDataType with a non-boolean is_active', () => { + const accountDataType = accountDataTypeBuilder().build(); + // @ts-expect-error - should be booleans + accountDataType.is_active = faker.datatype.boolean().toString(); + + const result = AccountDataTypeSchema.safeParse(accountDataType); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected: 'boolean', + message: 'Expected boolean, received string', + path: ['is_active'], + received: 'string', + }, + ]); + }); + + it('should not verify an invalid AccountDataType', () => { + const dataType = { + invalid: 'dataType', + }; + + const result = AccountDataTypeSchema.safeParse(dataType); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected: 'number', + message: 'Required', + path: ['id'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'date', + message: 'Required', + path: ['created_at'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'date', + message: 'Required', + path: ['updated_at'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'string', + message: 'Required', + path: ['name'], + received: 'undefined', + }, + ]); + }); +}); diff --git a/src/domain/accounts/entities/account-data-type.entity.ts b/src/domain/accounts/entities/account-data-type.entity.ts new file mode 100644 index 0000000000..d3b364ec5e --- /dev/null +++ b/src/domain/accounts/entities/account-data-type.entity.ts @@ -0,0 +1,10 @@ +import { RowSchema } from '@/datasources/db/entities/row.entity'; +import { z } from 'zod'; + +export type AccountDataType = z.infer; + +export const AccountDataTypeSchema = RowSchema.extend({ + name: z.string(), + description: z.string().nullish().default(null), + is_active: z.boolean().default(true), +}); diff --git a/src/domain/interfaces/accounts.datasource.interface.ts b/src/domain/interfaces/accounts.datasource.interface.ts index d78b928861..1e32089cb9 100644 --- a/src/domain/interfaces/accounts.datasource.interface.ts +++ b/src/domain/interfaces/accounts.datasource.interface.ts @@ -1,3 +1,4 @@ +import { AccountDataType } from '@/domain/accounts/entities/account-data-type.entity'; import { Account } from '@/domain/accounts/entities/account.entity'; export const IAccountsDatasource = Symbol('IAccountsDatasource'); @@ -8,4 +9,6 @@ export interface IAccountsDatasource { getAccount(address: `0x${string}`): Promise; deleteAccount(address: `0x${string}`): Promise; + + getDataTypes(): Promise; } diff --git a/src/routes/accounts/accounts.controller.spec.ts b/src/routes/accounts/accounts.controller.spec.ts index 1e81453e74..deb7ce96cd 100644 --- a/src/routes/accounts/accounts.controller.spec.ts +++ b/src/routes/accounts/accounts.controller.spec.ts @@ -15,6 +15,7 @@ import { TestNetworkModule } from '@/datasources/network/__tests__/test.network. import { NetworkModule } from '@/datasources/network/network.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { accountDataTypeBuilder } from '@/domain/accounts/entities/__tests__/account-data-type.builder'; import { accountBuilder } from '@/domain/accounts/entities/__tests__/account.builder'; import { authPayloadDtoBuilder } from '@/domain/auth/entities/__tests__/auth-payload-dto.entity.builder'; import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; @@ -40,9 +41,7 @@ describe('AccountsController', () => { let jwtService: IJwtService; let accountDataSource: jest.MockedObjectDeep; - beforeEach(async () => { - jest.resetAllMocks(); - jest.useFakeTimers(); + beforeAll(async () => { const defaultConfiguration = configuration(); const testConfiguration = (): typeof defaultConfiguration => ({ ...defaultConfiguration, @@ -76,6 +75,11 @@ describe('AccountsController', () => { await app.init(); }); + beforeEach(() => { + jest.resetAllMocks(); + jest.useFakeTimers(); + }); + afterEach(() => { jest.useRealTimers(); }); @@ -545,4 +549,39 @@ describe('AccountsController', () => { expect(accountDataSource.deleteAccount).toHaveBeenCalledTimes(1); }); }); + + describe('Get Data Types', () => { + it('should return the data types', async () => { + const dataTypes = [ + accountDataTypeBuilder().build(), + accountDataTypeBuilder().build(), + ]; + accountDataSource.getDataTypes.mockResolvedValue(dataTypes); + const expected = dataTypes.map((dataType) => ({ + dataTypeId: dataType.id.toString(), + name: dataType.name, + description: dataType.description, + isActive: dataType.is_active, + })); + + await request(app.getHttpServer()) + .get(`/v1/accounts/data-types`) + .expect(200) + .expect(expected); + + expect(accountDataSource.getDataTypes).toHaveBeenCalledTimes(1); + }); + + it('should propagate errors', async () => { + accountDataSource.getDataTypes.mockImplementation(() => { + throw new Error('test error'); + }); + + await request(app.getHttpServer()) + .get(`/v1/accounts/data-types`) + .expect(500); + + expect(accountDataSource.getDataTypes).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/routes/accounts/accounts.controller.ts b/src/routes/accounts/accounts.controller.ts index 7f5404f096..bc0118a410 100644 --- a/src/routes/accounts/accounts.controller.ts +++ b/src/routes/accounts/accounts.controller.ts @@ -1,4 +1,5 @@ import { AccountsService } from '@/routes/accounts/accounts.service'; +import { AccountDataType } from '@/routes/accounts/entities/account-data-type.entity'; import { Account } from '@/routes/accounts/entities/account.entity'; import { CreateAccountDto } from '@/routes/accounts/entities/create-account.dto.entity'; import { CreateAccountDtoSchema } from '@/routes/accounts/entities/schemas/create-account.dto.schema'; @@ -38,6 +39,12 @@ export class AccountsController { return this.accountsService.createAccount({ auth, createAccountDto }); } + @ApiOkResponse({ type: AccountDataType, isArray: true }) + @Get('data-types') + async getDataTypes(): Promise { + return this.accountsService.getDataTypes(); + } + @ApiOkResponse({ type: Account }) @Get(':address') @UseGuards(AuthGuard) diff --git a/src/routes/accounts/accounts.service.ts b/src/routes/accounts/accounts.service.ts index 14b47c3611..8f101fff52 100644 --- a/src/routes/accounts/accounts.service.ts +++ b/src/routes/accounts/accounts.service.ts @@ -1,6 +1,8 @@ import { IAccountsRepository } from '@/domain/accounts/accounts.repository.interface'; import { Account as DomainAccount } from '@/domain/accounts/entities/account.entity'; +import { AccountDataType as DomainAccountDataType } from '@/domain/accounts/entities/account-data-type.entity'; import { AuthPayloadDto } from '@/domain/auth/entities/auth-payload.entity'; +import { AccountDataType } from '@/routes/accounts/entities/account-data-type.entity'; import { Account } from '@/routes/accounts/entities/account.entity'; import { CreateAccountDto } from '@/routes/accounts/entities/create-account.dto.entity'; import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; @@ -53,6 +55,13 @@ export class AccountsService { }); } + async getDataTypes(): Promise { + const domainDataTypes = await this.accountsRepository.getDataTypes(); + return domainDataTypes.map((domainDataType) => + this.mapDataType(domainDataType), + ); + } + private mapAccount(domainAccount: DomainAccount): Account { return new Account( domainAccount.id.toString(), @@ -60,4 +69,13 @@ export class AccountsService { domainAccount.address, ); } + + private mapDataType(domainDataType: DomainAccountDataType): AccountDataType { + return new AccountDataType( + domainDataType.id.toString(), + domainDataType.name, + domainDataType.description?.toString() ?? null, + domainDataType.is_active, + ); + } } diff --git a/src/routes/accounts/entities/account-data-type.entity.ts b/src/routes/accounts/entities/account-data-type.entity.ts new file mode 100644 index 0000000000..11d80c9b10 --- /dev/null +++ b/src/routes/accounts/entities/account-data-type.entity.ts @@ -0,0 +1,24 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class AccountDataType { + @ApiProperty() + dataTypeId: string; + @ApiProperty() + name: string; + @ApiPropertyOptional({ type: String, nullable: true }) + description: string | null; + @ApiProperty() + isActive: boolean; + + constructor( + dataTypeId: string, + name: string, + description: string | null, + isActive: boolean, + ) { + this.dataTypeId = dataTypeId; + this.name = name; + this.description = description; + this.isActive = isActive; + } +} From 7e16e2a1e5b49ca02ffb404a75df93f378a6c9d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Tue, 2 Jul 2024 12:48:15 +0200 Subject: [PATCH 144/207] Add executedSurplusFee to TwapOrders (#1714) Adds executedSurplusFee to txInfo for TwapOrder items, as the sum of the executedSurplusFee values in the order parts. --- .../confirmation-view/confirmation-view.entity.ts | 9 +++++++++ .../entities/swaps/twap-order-info.entity.ts | 10 ++++++++++ .../mappers/common/twap-order.mapper.spec.ts | 7 +++++-- .../transactions/mappers/common/twap-order.mapper.ts | 10 ++++++++++ src/routes/transactions/transactions-view.service.ts | 1 + 5 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/routes/transactions/entities/confirmation-view/confirmation-view.entity.ts b/src/routes/transactions/entities/confirmation-view/confirmation-view.entity.ts index 78006ec21e..3c5ddaa91d 100644 --- a/src/routes/transactions/entities/confirmation-view/confirmation-view.entity.ts +++ b/src/routes/transactions/entities/confirmation-view/confirmation-view.entity.ts @@ -205,6 +205,13 @@ export class CowSwapTwapConfirmationView implements Baseline, TwapOrderInfo { }) executedSellAmount: string | null; + @ApiPropertyOptional({ + nullable: true, + description: + 'The executed surplus fee raw amount (no decimals), or null if there are too many parts', + }) + executedSurplusFee: string | null; + @ApiPropertyOptional({ nullable: true, description: @@ -276,6 +283,7 @@ export class CowSwapTwapConfirmationView implements Baseline, TwapOrderInfo { buyAmount: string; executedSellAmount: string | null; executedBuyAmount: string | null; + executedSurplusFee: string | null; sellToken: TokenInfo; buyToken: TokenInfo; receiver: `0x${string}`; @@ -298,6 +306,7 @@ export class CowSwapTwapConfirmationView implements Baseline, TwapOrderInfo { this.buyAmount = args.buyAmount; this.executedSellAmount = args.executedSellAmount; this.executedBuyAmount = args.executedBuyAmount; + this.executedSurplusFee = args.executedSurplusFee; this.sellToken = args.sellToken; this.buyToken = args.buyToken; this.receiver = args.receiver; diff --git a/src/routes/transactions/entities/swaps/twap-order-info.entity.ts b/src/routes/transactions/entities/swaps/twap-order-info.entity.ts index aedd2c8ddd..baf5911d5d 100644 --- a/src/routes/transactions/entities/swaps/twap-order-info.entity.ts +++ b/src/routes/transactions/entities/swaps/twap-order-info.entity.ts @@ -41,6 +41,7 @@ export type TwapOrderInfo = { buyAmount: string; executedSellAmount: string | null; executedBuyAmount: string | null; + executedSurplusFee: string | null; sellToken: TokenInfo; buyToken: TokenInfo; receiver: `0x${string}`; @@ -100,6 +101,13 @@ export class TwapOrderTransactionInfo }) executedBuyAmount: string | null; + @ApiPropertyOptional({ + nullable: true, + description: + 'The executed surplus fee raw amount (no decimals), or null if there are too many parts', + }) + executedSurplusFee: string | null; + @ApiProperty({ description: 'The sell token of the TWAP' }) sellToken: TokenInfo; @@ -162,6 +170,7 @@ export class TwapOrderTransactionInfo buyAmount: string; executedSellAmount: string | null; executedBuyAmount: string | null; + executedSurplusFee: string | null; sellToken: TokenInfo; buyToken: TokenInfo; receiver: `0x${string}`; @@ -183,6 +192,7 @@ export class TwapOrderTransactionInfo this.buyAmount = args.buyAmount; this.executedSellAmount = args.executedSellAmount; this.executedBuyAmount = args.executedBuyAmount; + this.executedSurplusFee = args.executedSurplusFee; this.sellToken = args.sellToken; this.buyToken = args.buyToken; this.receiver = args.receiver; diff --git a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts index 1f3a123b24..428aa8ece8 100644 --- a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts +++ b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts @@ -140,6 +140,7 @@ describe('TwapOrderMapper', () => { }, executedBuyAmount: '0', executedSellAmount: '0', + executedSurplusFee: '0', fullAppData, humanDescription: null, kind: 'sell', @@ -203,7 +204,7 @@ describe('TwapOrderMapper', () => { executedSellAmount: '213586875483862141750', executedSellAmountBeforeFees: '213586875483862141750', executedFeeAmount: '0', - executedSurplusFee: '2135868754838621119', + executedSurplusFee: '111111111', invalidated: false, status: 'fulfilled', class: 'limit', @@ -243,7 +244,7 @@ describe('TwapOrderMapper', () => { executedSellAmount: '213586875483862141750', executedSellAmountBeforeFees: '213586875483862141750', executedFeeAmount: '0', - executedSurplusFee: '2135868754838621123', + executedSurplusFee: '111111111', invalidated: false, status: 'fulfilled', class: 'limit', @@ -320,6 +321,7 @@ describe('TwapOrderMapper', () => { }, executedBuyAmount: '1379444631694674400', executedSellAmount: '427173750967724300000', + executedSurplusFee: '222222222', fullAppData, humanDescription: null, kind: 'sell', @@ -455,6 +457,7 @@ describe('TwapOrderMapper', () => { }, executedBuyAmount: null, executedSellAmount: null, + executedSurplusFee: null, fullAppData, humanDescription: null, kind: 'sell', diff --git a/src/routes/transactions/mappers/common/twap-order.mapper.ts b/src/routes/transactions/mappers/common/twap-order.mapper.ts index 77fa45ed08..e7f8257384 100644 --- a/src/routes/transactions/mappers/common/twap-order.mapper.ts +++ b/src/routes/transactions/mappers/common/twap-order.mapper.ts @@ -119,6 +119,9 @@ export class TwapOrderMapper { const executedBuyAmount: TwapOrderInfo['executedBuyAmount'] = hasAbundantParts ? null : this.getExecutedBuyAmount(orders).toString(); + const executedSurplusFee: TwapOrderInfo['executedSurplusFee'] = + hasAbundantParts ? null : this.getExecutedSurplusFee(orders).toString(); + const [sellToken, buyToken] = await Promise.all([ this.swapOrderHelper.getToken({ chainId, @@ -139,6 +142,7 @@ export class TwapOrderMapper { buyAmount: twapOrderData.buyAmount, executedSellAmount, executedBuyAmount, + executedSurplusFee, sellToken: new TokenInfo({ address: sellToken.address, decimals: sellToken.decimals, @@ -211,6 +215,12 @@ export class TwapOrderMapper { return acc + Number(order.executedBuyAmount); }, 0); } + + private getExecutedSurplusFee(orders: Array): number { + return orders.reduce((acc, order) => { + return acc + Number(order.executedSurplusFee); + }, 0); + } } @Module({ diff --git a/src/routes/transactions/transactions-view.service.ts b/src/routes/transactions/transactions-view.service.ts index d70e5a8ee3..6356324243 100644 --- a/src/routes/transactions/transactions-view.service.ts +++ b/src/routes/transactions/transactions-view.service.ts @@ -198,6 +198,7 @@ export class TransactionsViewService { buyAmount: twapOrderData.buyAmount, executedSellAmount: '0', executedBuyAmount: '0', + executedSurplusFee: '0', sellToken: new TokenInfo({ address: sellToken.address, decimals: sellToken.decimals, From d4810f30059018f2af4defb1f6d8167c0bb7041b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Tue, 2 Jul 2024 17:34:36 +0200 Subject: [PATCH 145/207] Fix bad types on TwapOrderMapper (#1716) Changes Number as an intermediate type on getExecutedSellAmount execution. Changes Number as an intermediate type on getExecutedBuyAmount execution. Changes Number as an intermediate type on getExecutedSurplusFee execution. --- .../mappers/common/twap-order.mapper.spec.ts | 4 ++-- .../mappers/common/twap-order.mapper.ts | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts index 428aa8ece8..57d6e3a5fe 100644 --- a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts +++ b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts @@ -319,8 +319,8 @@ describe('TwapOrderMapper', () => { durationOfPart: { durationType: 'AUTO', }, - executedBuyAmount: '1379444631694674400', - executedSellAmount: '427173750967724300000', + executedBuyAmount: '1379444631694674612', + executedSellAmount: '427173750967724283500', executedSurplusFee: '222222222', fullAppData, humanDescription: null, diff --git a/src/routes/transactions/mappers/common/twap-order.mapper.ts b/src/routes/transactions/mappers/common/twap-order.mapper.ts index e7f8257384..baefb3e59a 100644 --- a/src/routes/transactions/mappers/common/twap-order.mapper.ts +++ b/src/routes/transactions/mappers/common/twap-order.mapper.ts @@ -204,22 +204,22 @@ export class TwapOrderMapper { return OrderStatus.Unknown; } - private getExecutedSellAmount(orders: Array): number { + private getExecutedSellAmount(orders: Array): bigint { return orders.reduce((acc, order) => { - return acc + Number(order.executedSellAmount); - }, 0); + return acc + BigInt(order.executedSellAmount); + }, BigInt(0)); } - private getExecutedBuyAmount(orders: Array): number { + private getExecutedBuyAmount(orders: Array): bigint { return orders.reduce((acc, order) => { - return acc + Number(order.executedBuyAmount); - }, 0); + return acc + BigInt(order.executedBuyAmount); + }, BigInt(0)); } - private getExecutedSurplusFee(orders: Array): number { + private getExecutedSurplusFee(orders: Array): bigint { return orders.reduce((acc, order) => { - return acc + Number(order.executedSurplusFee); - }, 0); + return acc + BigInt(order.executedSurplusFee ?? BigInt(0)); + }, BigInt(0)); } } From 5e06389feb51dccf371c8318a333b63121d910e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Wed, 3 Jul 2024 10:18:08 +0200 Subject: [PATCH 146/207] Make Chain.pricesProvider nullable (#1717) Makes Chain.pricesProvider optional. --- .../coingecko-api.service.spec.ts | 46 +++++ .../balances-api/coingecko-api.service.ts | 16 +- .../chains/entities/schemas/chain.schema.ts | 4 +- .../balances/balances.controller.spec.ts | 189 ++++++++++++++++-- .../safes/safes.controller.overview.spec.ts | 30 +-- 5 files changed, 243 insertions(+), 42 deletions(-) diff --git a/src/datasources/balances-api/coingecko-api.service.spec.ts b/src/datasources/balances-api/coingecko-api.service.spec.ts index 3433449a22..98fed645b8 100644 --- a/src/datasources/balances-api/coingecko-api.service.spec.ts +++ b/src/datasources/balances-api/coingecko-api.service.spec.ts @@ -9,6 +9,7 @@ import { INetworkService } from '@/datasources/network/network.service.interface import { sortBy } from 'lodash'; import { ILoggingService } from '@/logging/logging.interface'; import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; +import { pricesProviderBuilder } from '@/domain/chains/entities/__tests__/prices-provider.builder'; const mockCacheFirstDataSource = jest.mocked({ get: jest.fn(), @@ -26,6 +27,7 @@ const mockNetworkService = jest.mocked({ const mockLoggingService = { debug: jest.fn(), + error: jest.fn(), } as jest.MockedObjectDeep; describe('CoingeckoAPI', () => { @@ -145,6 +147,32 @@ describe('CoingeckoAPI', () => { }); }); + it('should return an empty array and log error if pricesProvider.chainName is not defined', async () => { + const chain = chainBuilder() + .with( + 'pricesProvider', + pricesProviderBuilder().with('chainName', null).build(), + ) + .build(); + const tokenAddresses = [ + faker.finance.ethereumAddress(), + faker.finance.ethereumAddress(), + ]; + const fiatCode = faker.finance.currencyCode(); + + const result = await service.getTokenPrices({ + chain, + tokenAddresses, + fiatCode, + }); + + expect(result).toStrictEqual([]); + expect(mockLoggingService.error).toHaveBeenCalledTimes(1); + expect(mockLoggingService.error).toHaveBeenCalledWith( + `Error getting token prices: Error: pricesProvider.chainName is not defined `, + ); + }); + it('should return and cache one token price (using an API key)', async () => { const chain = chainBuilder().build(); const tokenAddress = faker.finance.ethereumAddress(); @@ -707,4 +735,22 @@ describe('CoingeckoAPI', () => { expireTimeSeconds: nativeCoinPricesTtlSeconds, }); }); + + it('should return null and log error if pricesProvider.nativeCoin is not defined', async () => { + const chain = chainBuilder() + .with( + 'pricesProvider', + pricesProviderBuilder().with('nativeCoin', null).build(), + ) + .build(); + const fiatCode = faker.finance.currencyCode(); + + const result = await service.getNativeCoinPrice({ chain, fiatCode }); + + expect(result).toBeNull(); + expect(mockLoggingService.error).toHaveBeenCalledTimes(1); + expect(mockLoggingService.error).toHaveBeenCalledWith( + `Error getting native coin price: Error: pricesProvider.nativeCoinId is not defined `, + ); + }); }); diff --git a/src/datasources/balances-api/coingecko-api.service.ts b/src/datasources/balances-api/coingecko-api.service.ts index 81fa1bf3cb..3a92548013 100644 --- a/src/datasources/balances-api/coingecko-api.service.ts +++ b/src/datasources/balances-api/coingecko-api.service.ts @@ -116,8 +116,11 @@ export class CoingeckoApi implements IPricesApi { fiatCode: string; }): Promise { try { - const lowerCaseFiatCode = args.fiatCode.toLowerCase(); const nativeCoinId = args.chain.pricesProvider.nativeCoin; + if (nativeCoinId == null) { + throw new DataSourceError('pricesProvider.nativeCoinId is not defined'); + } + const lowerCaseFiatCode = args.fiatCode.toLowerCase(); const cacheDir = CacheRouter.getNativeCoinPriceCacheDir({ nativeCoinId, fiatCode: lowerCaseFiatCode, @@ -145,7 +148,7 @@ export class CoingeckoApi implements IPricesApi { // Error at this level are logged out, but not thrown to the upper layers. // The service won't throw an error if a single coin price retrieval fails. this.loggingService.error( - `Error while getting native coin price: ${asError(error)} `, + `Error getting native coin price: ${asError(error)} `, ); return null; } @@ -166,11 +169,14 @@ export class CoingeckoApi implements IPricesApi { fiatCode: string; }): Promise { try { + const chainName = args.chain.pricesProvider.chainName; + if (chainName == null) { + throw new DataSourceError('pricesProvider.chainName is not defined'); + } const lowerCaseFiatCode = args.fiatCode.toLowerCase(); const lowerCaseTokenAddresses = args.tokenAddresses.map((address) => address.toLowerCase(), ); - const chainName = args.chain.pricesProvider.chainName; const pricesFromCache = await this._getTokenPricesFromCache({ chainName, tokenAddresses: lowerCaseTokenAddresses, @@ -193,7 +199,7 @@ export class CoingeckoApi implements IPricesApi { // Error at this level are logged out, but not thrown to the upper layers. // The service won't throw an error if a single token price retrieval fails. this.loggingService.error( - `Error while getting token prices: ${asError(error)} `, + `Error getting token prices: ${asError(error)} `, ); return []; } @@ -219,7 +225,7 @@ export class CoingeckoApi implements IPricesApi { return result.map((item) => item.toUpperCase()); } catch (error) { this.loggingService.error( - `CoinGecko error while getting fiat codes: ${asError(error)} `, + `CoinGecko error getting fiat codes: ${asError(error)} `, ); return []; } diff --git a/src/domain/chains/entities/schemas/chain.schema.ts b/src/domain/chains/entities/schemas/chain.schema.ts index a857023909..4dd8d72221 100644 --- a/src/domain/chains/entities/schemas/chain.schema.ts +++ b/src/domain/chains/entities/schemas/chain.schema.ts @@ -55,8 +55,8 @@ export const GasPriceSchema = z.array( ); export const PricesProviderSchema = z.object({ - chainName: z.string(), - nativeCoin: z.string(), + chainName: z.string().nullish().default(null), + nativeCoin: z.string().nullish().default(null), }); export const BalancesProviderSchema = z.object({ diff --git a/src/routes/balances/balances.controller.spec.ts b/src/routes/balances/balances.controller.spec.ts index f10977a5c6..87d1b52804 100644 --- a/src/routes/balances/balances.controller.spec.ts +++ b/src/routes/balances/balances.controller.spec.ts @@ -1,31 +1,32 @@ -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import request from 'supertest'; import { TestAppProvider } from '@/__tests__/test-app.provider'; +import { AppModule } from '@/app.module'; +import { IConfigurationService } from '@/config/configuration.service.interface'; +import configuration from '@/config/entities/__tests__/configuration'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; +import { CacheModule } from '@/datasources/cache/cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; -import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; -import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; -import { faker } from '@faker-js/faker'; -import configuration from '@/config/entities/__tests__/configuration'; -import { IConfigurationService } from '@/config/configuration.service.interface'; +import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; +import { NetworkModule } from '@/datasources/network/network.module'; import { INetworkService, NetworkService, } from '@/datasources/network/network.service.interface'; -import { AppModule } from '@/app.module'; -import { CacheModule } from '@/datasources/cache/cache.module'; -import { RequestScopedLoggingModule } from '@/logging/logging.module'; -import { NetworkModule } from '@/datasources/network/network.module'; -import { NULL_ADDRESS } from '@/routes/common/constants'; -import { balanceBuilder } from '@/domain/balances/entities/__tests__/balance.builder'; -import { balanceTokenBuilder } from '@/domain/balances/entities/__tests__/balance.token.builder'; -import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; -import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; -import { Server } from 'net'; +import { balanceBuilder } from '@/domain/balances/entities/__tests__/balance.builder'; +import { balanceTokenBuilder } from '@/domain/balances/entities/__tests__/balance.token.builder'; +import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; +import { pricesProviderBuilder } from '@/domain/chains/entities/__tests__/prices-provider.builder'; import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; +import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; +import { RequestScopedLoggingModule } from '@/logging/logging.module'; +import { NULL_ADDRESS } from '@/routes/common/constants'; +import { faker } from '@faker-js/faker'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Server } from 'net'; +import request from 'supertest'; +import { getAddress } from 'viem'; describe('Balances Controller (Unit)', () => { let app: INestApplication; @@ -103,7 +104,7 @@ describe('Balances Controller (Unit)', () => { .getOrThrow('balances.providers.safe.prices.apiKey'); const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [chain.pricesProvider.nativeCoin]: { + [chain.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, }; @@ -304,7 +305,7 @@ describe('Balances Controller (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [chain.pricesProvider.nativeCoin]: { + [chain.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, }; @@ -359,6 +360,154 @@ describe('Balances Controller (Unit)', () => { }); }); + it(`should map the native coin price to 0 when pricesProvider.nativeCoin is not set`, async () => { + const chain = chainBuilder() + .with('chainId', '10') + .with( + 'pricesProvider', + pricesProviderBuilder().with('nativeCoin', null).build(), + ) + .build(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const transactionApiBalancesResponse = [ + balanceBuilder() + .with('tokenAddress', null) + .with('balance', '3000000000000000000') + .with('token', null) + .build(), + ]; + const currency = faker.finance.currencyCode(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: + return Promise.resolve({ data: chain, status: 200 }); + case `${chain.transactionService}/api/v1/safes/${safeAddress}`: + return Promise.resolve({ + data: safeBuilder().build(), + status: 200, + }); + case `${chain.transactionService}/api/v1/safes/${safeAddress}/balances/`: + return Promise.resolve({ + data: transactionApiBalancesResponse, + status: 200, + }); + default: + return Promise.reject(new Error(`Could not match ${url}`)); + } + }); + + await request(app.getHttpServer()) + .get( + `/v1/chains/${ + chain.chainId + }/safes/${safeAddress}/balances/${currency.toUpperCase()}`, + ) + .expect(200) + .expect({ + fiatTotal: '0', + items: [ + { + tokenInfo: { + type: 'NATIVE_TOKEN', + address: NULL_ADDRESS, + decimals: chain.nativeCurrency.decimals, + symbol: chain.nativeCurrency.symbol, + name: chain.nativeCurrency.name, + logoUri: chain.nativeCurrency.logoUri, + }, + balance: '3000000000000000000', + fiatBalance: '0', + fiatConversion: '0', + }, + ], + }); + }); + + it(`should map ERC20 tokens price to 0 when pricesProvider.chainName is not set`, async () => { + const chain = chainBuilder() + .with('chainId', '10') + .with( + 'pricesProvider', + pricesProviderBuilder().with('chainName', null).build(), + ) + .build(); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const transactionApiBalancesResponse = [ + balanceBuilder() + .with('tokenAddress', getAddress(faker.finance.ethereumAddress())) + .with('balance', '3000000000000000000') + .with('token', balanceTokenBuilder().with('decimals', 17).build()) + .build(), + balanceBuilder() + .with('tokenAddress', getAddress(faker.finance.ethereumAddress())) + .with('balance', '3000000000000000000') + .with('token', balanceTokenBuilder().with('decimals', 17).build()) + .build(), + ]; + const currency = faker.finance.currencyCode(); + networkService.get.mockImplementation(({ url }) => { + switch (url) { + case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: + return Promise.resolve({ data: chain, status: 200 }); + case `${chain.transactionService}/api/v1/safes/${safeAddress}`: + return Promise.resolve({ + data: safeBuilder().build(), + status: 200, + }); + case `${chain.transactionService}/api/v1/safes/${safeAddress}/balances/`: + return Promise.resolve({ + data: transactionApiBalancesResponse, + status: 200, + }); + default: + return Promise.reject(new Error(`Could not match ${url}`)); + } + }); + + await request(app.getHttpServer()) + .get( + `/v1/chains/${ + chain.chainId + }/safes/${safeAddress}/balances/${currency.toUpperCase()}`, + ) + .expect(200) + .expect({ + fiatTotal: '0', + items: [ + { + tokenInfo: { + type: 'ERC20', + address: transactionApiBalancesResponse[0].tokenAddress + ? getAddress(transactionApiBalancesResponse[0].tokenAddress) + : transactionApiBalancesResponse[0].tokenAddress, + decimals: 17, + symbol: transactionApiBalancesResponse[0].token?.symbol, + name: transactionApiBalancesResponse[0].token?.name, + logoUri: transactionApiBalancesResponse[0].token?.logoUri, + }, + balance: '3000000000000000000', + fiatBalance: '0', + fiatConversion: '0', + }, + { + tokenInfo: { + type: 'ERC20', + address: transactionApiBalancesResponse[1].tokenAddress + ? getAddress(transactionApiBalancesResponse[1].tokenAddress) + : transactionApiBalancesResponse[1].tokenAddress, + decimals: 17, + symbol: transactionApiBalancesResponse[1].token?.symbol, + name: transactionApiBalancesResponse[1].token?.name, + logoUri: transactionApiBalancesResponse[1].token?.logoUri, + }, + balance: '3000000000000000000', + fiatBalance: '0', + fiatConversion: '0', + }, + ], + }); + }); + it('returns large numbers as is (not in scientific notation)', async () => { const chain = chainBuilder().with('chainId', '10').build(); const safeAddress = getAddress(faker.finance.ethereumAddress()); diff --git a/src/routes/safes/safes.controller.overview.spec.ts b/src/routes/safes/safes.controller.overview.spec.ts index 208e7d1592..798ccd0579 100644 --- a/src/routes/safes/safes.controller.overview.spec.ts +++ b/src/routes/safes/safes.controller.overview.spec.ts @@ -114,7 +114,7 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [chain.pricesProvider.nativeCoin]: { + [chain.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, }; @@ -274,7 +274,7 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [chain.pricesProvider.nativeCoin]: { + [chain.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, }; @@ -486,10 +486,10 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [chain1.pricesProvider.nativeCoin]: { + [chain1.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, - [chain2.pricesProvider.nativeCoin]: { + [chain2.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, }; @@ -710,10 +710,10 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [chain1.pricesProvider.nativeCoin]: { + [chain1.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, - [chain2.pricesProvider.nativeCoin]: { + [chain2.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, }; @@ -891,7 +891,7 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [chain.pricesProvider.nativeCoin]: { + [chain.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, }; @@ -1041,7 +1041,7 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [chain.pricesProvider.nativeCoin]: { + [chain.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, }; @@ -1234,10 +1234,10 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [chain1.pricesProvider.nativeCoin]: { + [chain1.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, - [chain2.pricesProvider.nativeCoin]: { + [chain2.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, }; @@ -1428,10 +1428,10 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [chain1.pricesProvider.nativeCoin]: { + [chain1.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, - [chain2.pricesProvider.nativeCoin]: { + [chain2.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, }; @@ -1628,10 +1628,10 @@ describe('Safes Controller Overview (Unit)', () => { ]; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [chain1.pricesProvider.nativeCoin]: { + [chain1.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, - [chain2.pricesProvider.nativeCoin]: { + [chain2.pricesProvider.nativeCoin!]: { [currency.toLowerCase()]: 1536.75, }, }; @@ -1784,7 +1784,7 @@ describe('Safes Controller Overview (Unit)', () => { const chainName = chain.pricesProvider.chainName; const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [nativeCoinId]: { [currency.toLowerCase()]: 1536.75 }, + [nativeCoinId!]: { [currency.toLowerCase()]: 1536.75 }, }; const walletAddress = faker.finance.ethereumAddress(); const multisigTransactions = [ From b7f9e3f1ab9cf0220077bcd7e244543216235674 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Wed, 3 Jul 2024 16:32:30 +0200 Subject: [PATCH 147/207] Add Swaps decoding debug logs (#1718) Adds transient debug logging for Swaps decoding. --- .../swaps/contracts/decoders/gp-v2-decoder.helper.ts | 7 ++++++- src/routes/transactions/transactions-view.service.ts | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.ts b/src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.ts index 59af6b83c7..1bf96f7f4d 100644 --- a/src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.ts +++ b/src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.ts @@ -660,12 +660,17 @@ export class GPv2Decoder extends AbiDecoder { public getOrderUidFromSetPreSignature( data: `0x${string}`, ): `0x${string}` | null { - if (!this.helpers.isSetPreSignature(data)) { + const isPreSignature = this.helpers.isSetPreSignature(data); + this.loggingService.info( + `this.helpers.isSetPreSignature(data): ${isPreSignature}`, + ); + if (!isPreSignature) { return null; } try { const decoded = this.decodeFunctionData({ data }); + this.loggingService.info(`decoded: ${JSON.stringify(decoded)}`); if (decoded.functionName !== 'setPreSignature') { throw new Error('Data is not of setPreSignature'); diff --git a/src/routes/transactions/transactions-view.service.ts b/src/routes/transactions/transactions-view.service.ts index 6356324243..c442401862 100644 --- a/src/routes/transactions/transactions-view.service.ts +++ b/src/routes/transactions/transactions-view.service.ts @@ -46,6 +46,9 @@ export class TransactionsViewService { args.transactionDataDto.data, ); + this.loggingService.info(JSON.stringify(dataDecoded)); + this.loggingService.info(JSON.stringify(swapOrderData)); + const twapSwapOrderData = args.transactionDataDto.to ? this.twapOrderHelper.findTwapOrder({ to: args.transactionDataDto.to, @@ -92,6 +95,7 @@ export class TransactionsViewService { data: `0x${string}`; dataDecoded: DataDecoded; }): Promise { + this.loggingService.info('getSwapOrderConfirmationView'); const orderUid: `0x${string}` | null = this.gpv2Decoder.getOrderUidFromSetPreSignature(args.data); if (!orderUid) { @@ -107,6 +111,8 @@ export class TransactionsViewService { throw new Error(`Unsupported App: ${order.fullAppData?.appCode}`); } + this.loggingService.info('tokens retrieval'); + const [sellToken, buyToken] = await Promise.all([ this.swapOrderHelper.getToken({ chainId: args.chainId, @@ -118,6 +124,8 @@ export class TransactionsViewService { }), ]); + this.loggingService.info('CowSwapConfirmationView instantiation'); + return new CowSwapConfirmationView({ method: args.dataDecoded.method, parameters: args.dataDecoded.parameters, From 09322ac5e5a3d9a7d02f38154a3c36115cae4380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Wed, 3 Jul 2024 17:10:18 +0200 Subject: [PATCH 148/207] Add Swaps decoding debug logs (#1719) Adds more transient logging to debug Swap orders decoding issues. --- src/routes/transactions/transactions-view.service.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/routes/transactions/transactions-view.service.ts b/src/routes/transactions/transactions-view.service.ts index c442401862..52605d95f4 100644 --- a/src/routes/transactions/transactions-view.service.ts +++ b/src/routes/transactions/transactions-view.service.ts @@ -16,6 +16,7 @@ import { TwapOrderHelper } from '@/routes/transactions/helpers/twap-order.helper import { OrderStatus } from '@/domain/swaps/entities/order.entity'; import { ISwapsRepository } from '@/domain/swaps/swaps.repository'; import { ComposableCowDecoder } from '@/domain/swaps/contracts/decoders/composable-cow-decoder.helper'; +import { asError } from '@/logging/utils'; @Injectable({}) export class TransactionsViewService { @@ -82,7 +83,7 @@ export class TransactionsViewService { throw new Error('No swap order data found'); } } catch (error) { - this.loggingService.warn(error); + this.loggingService.warn(asError(error).message); return new BaselineConfirmationView({ method: dataDecoded.method, parameters: dataDecoded.parameters, @@ -107,6 +108,8 @@ export class TransactionsViewService { orderUid, }); + this.loggingService.info(JSON.stringify(order.kind)); + if (!this.swapOrderHelper.isAppAllowed(order)) { throw new Error(`Unsupported App: ${order.fullAppData?.appCode}`); } From 40a028cc7e05339cdc20e7b3c37d13db33fdeb0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hector=20G=C3=B3mez=20Varela?= Date: Wed, 3 Jul 2024 17:32:58 +0200 Subject: [PATCH 149/207] Revert "Add Swaps decoding debug logs" This reverts commit c0ad06a8b512dff2106e25d0354aabfbbf705fd2. --- .../swaps/contracts/decoders/gp-v2-decoder.helper.ts | 7 +------ src/routes/transactions/transactions-view.service.ts | 8 -------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.ts b/src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.ts index 1bf96f7f4d..59af6b83c7 100644 --- a/src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.ts +++ b/src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.ts @@ -660,17 +660,12 @@ export class GPv2Decoder extends AbiDecoder { public getOrderUidFromSetPreSignature( data: `0x${string}`, ): `0x${string}` | null { - const isPreSignature = this.helpers.isSetPreSignature(data); - this.loggingService.info( - `this.helpers.isSetPreSignature(data): ${isPreSignature}`, - ); - if (!isPreSignature) { + if (!this.helpers.isSetPreSignature(data)) { return null; } try { const decoded = this.decodeFunctionData({ data }); - this.loggingService.info(`decoded: ${JSON.stringify(decoded)}`); if (decoded.functionName !== 'setPreSignature') { throw new Error('Data is not of setPreSignature'); diff --git a/src/routes/transactions/transactions-view.service.ts b/src/routes/transactions/transactions-view.service.ts index 52605d95f4..fc1987677a 100644 --- a/src/routes/transactions/transactions-view.service.ts +++ b/src/routes/transactions/transactions-view.service.ts @@ -47,9 +47,6 @@ export class TransactionsViewService { args.transactionDataDto.data, ); - this.loggingService.info(JSON.stringify(dataDecoded)); - this.loggingService.info(JSON.stringify(swapOrderData)); - const twapSwapOrderData = args.transactionDataDto.to ? this.twapOrderHelper.findTwapOrder({ to: args.transactionDataDto.to, @@ -96,7 +93,6 @@ export class TransactionsViewService { data: `0x${string}`; dataDecoded: DataDecoded; }): Promise { - this.loggingService.info('getSwapOrderConfirmationView'); const orderUid: `0x${string}` | null = this.gpv2Decoder.getOrderUidFromSetPreSignature(args.data); if (!orderUid) { @@ -114,8 +110,6 @@ export class TransactionsViewService { throw new Error(`Unsupported App: ${order.fullAppData?.appCode}`); } - this.loggingService.info('tokens retrieval'); - const [sellToken, buyToken] = await Promise.all([ this.swapOrderHelper.getToken({ chainId: args.chainId, @@ -127,8 +121,6 @@ export class TransactionsViewService { }), ]); - this.loggingService.info('CowSwapConfirmationView instantiation'); - return new CowSwapConfirmationView({ method: args.dataDecoded.method, parameters: args.dataDecoded.parameters, From 976202758dbdaf3fa09d1d5e2326a13dc486e7ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Wed, 3 Jul 2024 17:43:09 +0200 Subject: [PATCH 150/207] Revert "Add Swaps decoding debug logs" (#1720) Revert "Add Swaps decoding debug logs" --- src/routes/transactions/transactions-view.service.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/routes/transactions/transactions-view.service.ts b/src/routes/transactions/transactions-view.service.ts index fc1987677a..6356324243 100644 --- a/src/routes/transactions/transactions-view.service.ts +++ b/src/routes/transactions/transactions-view.service.ts @@ -16,7 +16,6 @@ import { TwapOrderHelper } from '@/routes/transactions/helpers/twap-order.helper import { OrderStatus } from '@/domain/swaps/entities/order.entity'; import { ISwapsRepository } from '@/domain/swaps/swaps.repository'; import { ComposableCowDecoder } from '@/domain/swaps/contracts/decoders/composable-cow-decoder.helper'; -import { asError } from '@/logging/utils'; @Injectable({}) export class TransactionsViewService { @@ -80,7 +79,7 @@ export class TransactionsViewService { throw new Error('No swap order data found'); } } catch (error) { - this.loggingService.warn(asError(error).message); + this.loggingService.warn(error); return new BaselineConfirmationView({ method: dataDecoded.method, parameters: dataDecoded.parameters, @@ -104,8 +103,6 @@ export class TransactionsViewService { orderUid, }); - this.loggingService.info(JSON.stringify(order.kind)); - if (!this.swapOrderHelper.isAppAllowed(order)) { throw new Error(`Unsupported App: ${order.fullAppData?.appCode}`); } From 772a4755fd3188aa1c28e2fec9ace3548cd34c1a Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 4 Jul 2024 13:39:52 +0200 Subject: [PATCH 151/207] Use `AuthDecorator` for instantiation of account-related `AuthPayload`s (#1722) Replaces the manual instantiation of `AuthPayload` with the relevant decorator for the creation, retrieval and deletion of accounts: - Expect an `AuthPayload` instead of `AuthPayloadDto` in `AccountsController`, `AccountsService` and `AccountsRepository`. - Use `AuthPayload` directly for authorisation in `AccountsRepository`, --- .../accounts/accounts.repository.interface.ts | 8 ++-- src/domain/accounts/accounts.repository.ts | 39 ++++++++----------- src/routes/accounts/accounts.controller.ts | 22 +++++------ src/routes/accounts/accounts.service.ts | 25 ++++-------- 4 files changed, 39 insertions(+), 55 deletions(-) diff --git a/src/domain/accounts/accounts.repository.interface.ts b/src/domain/accounts/accounts.repository.interface.ts index 17940dc9e5..6c2243c50f 100644 --- a/src/domain/accounts/accounts.repository.interface.ts +++ b/src/domain/accounts/accounts.repository.interface.ts @@ -2,24 +2,24 @@ import { AccountsDatasourceModule } from '@/datasources/accounts/accounts.dataso import { AccountsRepository } from '@/domain/accounts/accounts.repository'; import { AccountDataType } from '@/domain/accounts/entities/account-data-type.entity'; import { Account } from '@/domain/accounts/entities/account.entity'; -import { AuthPayloadDto } from '@/domain/auth/entities/auth-payload.entity'; +import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; import { Module } from '@nestjs/common'; export const IAccountsRepository = Symbol('IAccountsRepository'); export interface IAccountsRepository { createAccount(args: { - auth: AuthPayloadDto; + authPayload: AuthPayload; address: `0x${string}`; }): Promise; getAccount(args: { - auth: AuthPayloadDto; + authPayload: AuthPayload; address: `0x${string}`; }): Promise; deleteAccount(args: { - auth: AuthPayloadDto; + authPayload: AuthPayload; address: `0x${string}`; }): Promise; diff --git a/src/domain/accounts/accounts.repository.ts b/src/domain/accounts/accounts.repository.ts index 4991ccc669..a5e6cc3d86 100644 --- a/src/domain/accounts/accounts.repository.ts +++ b/src/domain/accounts/accounts.repository.ts @@ -4,10 +4,7 @@ import { Account, AccountSchema, } from '@/domain/accounts/entities/account.entity'; -import { - AuthPayload, - AuthPayloadDto, -} from '@/domain/auth/entities/auth-payload.entity'; +import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; import { IAccountsDatasource } from '@/domain/interfaces/accounts.datasource.interface'; import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; @@ -19,44 +16,40 @@ export class AccountsRepository implements IAccountsRepository { ) {} async createAccount(args: { - auth: AuthPayloadDto; + authPayload: AuthPayload; address: `0x${string}`; }): Promise { - const { auth, address } = args; - this.checkAuth(auth, address); - const account = await this.datasource.createAccount(address); + if (!args.authPayload.isForSigner(args.address)) { + throw new UnauthorizedException(); + } + const account = await this.datasource.createAccount(args.address); return AccountSchema.parse(account); } async getAccount(args: { - auth: AuthPayloadDto; + authPayload: AuthPayload; address: `0x${string}`; }): Promise { - const { auth, address } = args; - this.checkAuth(auth, address); - const account = await this.datasource.getAccount(address); + if (!args.authPayload.isForSigner(args.address)) { + throw new UnauthorizedException(); + } + const account = await this.datasource.getAccount(args.address); return AccountSchema.parse(account); } async deleteAccount(args: { - auth: AuthPayloadDto; + authPayload: AuthPayload; address: `0x${string}`; }): Promise { - const { auth, address } = args; - this.checkAuth(auth, address); + if (!args.authPayload.isForSigner(args.address)) { + throw new UnauthorizedException(); + } // TODO: trigger a cascade deletion of the account-associated data. - return this.datasource.deleteAccount(address); + return this.datasource.deleteAccount(args.address); } async getDataTypes(): Promise { // TODO: add caching with clearing mechanism. return this.datasource.getDataTypes(); } - - private checkAuth(auth: AuthPayloadDto, address: `0x${string}`): void { - const authPayload = new AuthPayload(auth); - if (!authPayload.isForSigner(address)) { - throw new UnauthorizedException(); - } - } } diff --git a/src/routes/accounts/accounts.controller.ts b/src/routes/accounts/accounts.controller.ts index bc0118a410..a464534980 100644 --- a/src/routes/accounts/accounts.controller.ts +++ b/src/routes/accounts/accounts.controller.ts @@ -1,3 +1,4 @@ +import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; import { AccountsService } from '@/routes/accounts/accounts.service'; import { AccountDataType } from '@/routes/accounts/entities/account-data-type.entity'; import { Account } from '@/routes/accounts/entities/account.entity'; @@ -15,11 +16,10 @@ import { HttpStatus, Param, Post, - Req, UseGuards, } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; -import { Request } from 'express'; +import { Auth } from '@/routes/auth/decorators/auth.decorator'; @ApiTags('accounts') @Controller({ path: 'accounts', version: '1' }) @@ -33,10 +33,12 @@ export class AccountsController { async createAccount( @Body(new ValidationPipe(CreateAccountDtoSchema)) createAccountDto: CreateAccountDto, - @Req() request: Request, + @Auth() authPayload: AuthPayload, ): Promise { - const auth = request.accessToken; - return this.accountsService.createAccount({ auth, createAccountDto }); + return this.accountsService.createAccount({ + authPayload, + createAccountDto, + }); } @ApiOkResponse({ type: AccountDataType, isArray: true }) @@ -50,10 +52,9 @@ export class AccountsController { @UseGuards(AuthGuard) async getAccount( @Param('address', new ValidationPipe(AddressSchema)) address: `0x${string}`, - @Req() request: Request, + @Auth() authPayload: AuthPayload, ): Promise { - const auth = request.accessToken; - return this.accountsService.getAccount({ auth, address }); + return this.accountsService.getAccount({ authPayload, address }); } @Delete(':address') @@ -61,9 +62,8 @@ export class AccountsController { @HttpCode(HttpStatus.NO_CONTENT) async deleteAccount( @Param('address', new ValidationPipe(AddressSchema)) address: `0x${string}`, - @Req() request: Request, + @Auth() authPayload: AuthPayload, ): Promise { - const auth = request.accessToken; - return this.accountsService.deleteAccount({ auth, address }); + return this.accountsService.deleteAccount({ authPayload, address }); } } diff --git a/src/routes/accounts/accounts.service.ts b/src/routes/accounts/accounts.service.ts index 8f101fff52..2dfea5dd40 100644 --- a/src/routes/accounts/accounts.service.ts +++ b/src/routes/accounts/accounts.service.ts @@ -1,11 +1,11 @@ import { IAccountsRepository } from '@/domain/accounts/accounts.repository.interface'; import { Account as DomainAccount } from '@/domain/accounts/entities/account.entity'; import { AccountDataType as DomainAccountDataType } from '@/domain/accounts/entities/account-data-type.entity'; -import { AuthPayloadDto } from '@/domain/auth/entities/auth-payload.entity'; +import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; import { AccountDataType } from '@/routes/accounts/entities/account-data-type.entity'; import { Account } from '@/routes/accounts/entities/account.entity'; import { CreateAccountDto } from '@/routes/accounts/entities/create-account.dto.entity'; -import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; @Injectable() export class AccountsService { @@ -15,42 +15,33 @@ export class AccountsService { ) {} async createAccount(args: { - auth?: AuthPayloadDto; + authPayload: AuthPayload; createAccountDto: CreateAccountDto; }): Promise { - if (!args.auth) { - throw new UnauthorizedException(); - } const domainAccount = await this.accountsRepository.createAccount({ - auth: args.auth, + authPayload: args.authPayload, address: args.createAccountDto.address, }); return this.mapAccount(domainAccount); } async getAccount(args: { - auth?: AuthPayloadDto; + authPayload: AuthPayload; address: `0x${string}`; }): Promise { - if (!args.auth) { - throw new UnauthorizedException(); - } const domainAccount = await this.accountsRepository.getAccount({ - auth: args.auth, + authPayload: args.authPayload, address: args.address, }); return this.mapAccount(domainAccount); } async deleteAccount(args: { - auth?: AuthPayloadDto; + authPayload: AuthPayload; address: `0x${string}`; }): Promise { - if (!args.auth) { - throw new UnauthorizedException(); - } await this.accountsRepository.deleteAccount({ - auth: args.auth, + authPayload: args.authPayload, address: args.address, }); } From 821cdd15c625386e47ab0b39675a804cd6bdd529 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 4 Jul 2024 13:40:10 +0200 Subject: [PATCH 152/207] Remove unnecessary test of `SwapOrderMapper` (#1723) Remove unnecessary test of `SwapOrderMapper`. --- .../transactions/mappers/common/swap-order.mapper.spec.ts | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 src/routes/transactions/mappers/common/swap-order.mapper.spec.ts diff --git a/src/routes/transactions/mappers/common/swap-order.mapper.spec.ts b/src/routes/transactions/mappers/common/swap-order.mapper.spec.ts deleted file mode 100644 index ea0ad50402..0000000000 --- a/src/routes/transactions/mappers/common/swap-order.mapper.spec.ts +++ /dev/null @@ -1,4 +0,0 @@ -describe('SwapOrderMapper', () => { - // TODO: Add test - should've been added in first swaps integration - it.todo('should map a swap order'); -}); From 2cb5415c4e05650e109ecb74f72537bd03caf78d Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 4 Jul 2024 15:12:37 +0200 Subject: [PATCH 153/207] Include `SwapTransfer` when checking whether `TransactionInfo['type']` is a transfer (#1725) Adds the relative checks for `SwapTransfer`-typed transfers: (Note: the following can be assumed as we check the address of the settlement contract) - Add early return for imitation flagging - Assume `trust` when filtering transfers - Add test coverage accordingly --- .../transfers/transfer-imitation.mapper.ts | 11 +- .../mappers/transfers/transfer.mapper.spec.ts | 343 +++++++++++++++++- .../mappers/transfers/transfer.mapper.ts | 13 +- .../swap-transfer-transaction-info.entity.ts | 6 + 4 files changed, 368 insertions(+), 5 deletions(-) diff --git a/src/routes/transactions/mappers/transfers/transfer-imitation.mapper.ts b/src/routes/transactions/mappers/transfers/transfer-imitation.mapper.ts index c43bf2157a..d42c2a7d0f 100644 --- a/src/routes/transactions/mappers/transfers/transfer-imitation.mapper.ts +++ b/src/routes/transactions/mappers/transfers/transfer-imitation.mapper.ts @@ -6,6 +6,7 @@ import { TransferTransactionInfo, } from '@/routes/transactions/entities/transfer-transaction-info.entity'; import { isErc20Transfer } from '@/routes/transactions/entities/transfers/erc20-transfer.entity'; +import { isSwapTransferTransactionInfo } from '@/routes/transactions/swap-transfer-transaction-info.entity'; import { Inject } from '@nestjs/common'; import { formatUnits } from 'viem'; @@ -65,7 +66,10 @@ export class TransferImitationMapper { const txInfo = item.transaction.txInfo; // Only transfers can be imitated, of which we are only interested in ERC20s if ( - !isTransferTransactionInfo(txInfo) || + !( + isTransferTransactionInfo(txInfo) || + isSwapTransferTransactionInfo(txInfo) + ) || !isErc20Transfer(txInfo.transferInfo) ) { mappedTransactions.unshift(item); @@ -103,7 +107,10 @@ export class TransferImitationMapper { const isImitation = prevItems.some((prevItem) => { const prevTxInfo = prevItem.transaction.txInfo; if ( - !isTransferTransactionInfo(prevTxInfo) || + !( + isTransferTransactionInfo(prevTxInfo) || + isSwapTransferTransactionInfo(prevTxInfo) + ) || !isErc20Transfer(prevTxInfo.transferInfo) || // Do not compare against previously identified imitations prevTxInfo.transferInfo.imitation diff --git a/src/routes/transactions/mappers/transfers/transfer.mapper.spec.ts b/src/routes/transactions/mappers/transfers/transfer.mapper.spec.ts index 78240e46e9..ceed5a5eff 100644 --- a/src/routes/transactions/mappers/transfers/transfer.mapper.spec.ts +++ b/src/routes/transactions/mappers/transfers/transfer.mapper.spec.ts @@ -3,13 +3,25 @@ import { erc20TransferBuilder } from '@/domain/safe/entities/__tests__/erc20-tra import { erc721TransferBuilder } from '@/domain/safe/entities/__tests__/erc721-transfer.builder'; import { nativeTokenTransferBuilder } from '@/domain/safe/entities/__tests__/native-token-transfer.builder'; import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; +import { + OrderClass, + OrderKind, + OrderStatus, +} from '@/domain/swaps/entities/order.entity'; import { tokenBuilder } from '@/domain/tokens/__tests__/token.builder'; +import { TokenType } from '@/domain/tokens/entities/token.entity'; import { TokenRepository } from '@/domain/tokens/token.repository'; import { AddressInfoHelper } from '@/routes/common/address-info/address-info.helper'; import { AddressInfo } from '@/routes/common/entities/address-info.entity'; +import { TokenInfo } from '@/routes/transactions/entities/swaps/token-info.entity'; +import { TransactionInfoType } from '@/routes/transactions/entities/transaction-info.entity'; import { TransactionStatus } from '@/routes/transactions/entities/transaction-status.entity'; import { Transaction } from '@/routes/transactions/entities/transaction.entity'; -import { TransferTransactionInfo } from '@/routes/transactions/entities/transfer-transaction-info.entity'; +import { + TransferDirection, + TransferTransactionInfo, +} from '@/routes/transactions/entities/transfer-transaction-info.entity'; +import { TransferType } from '@/routes/transactions/entities/transfers/transfer.entity'; import { SwapTransferInfoMapper } from '@/routes/transactions/mappers/transfers/swap-transfer-info.mapper'; import { TransferInfoMapper } from '@/routes/transactions/mappers/transfers/transfer-info.mapper'; import { TransferMapper } from '@/routes/transactions/mappers/transfers/transfer.mapper'; @@ -99,6 +111,7 @@ describe('Transfer mapper (Unit)', () => { const token = tokenBuilder() .with('address', getAddress(transfer.tokenAddress)) .build(); + swapTransferInfoMapper.mapSwapTransferInfo.mockResolvedValue(null); addressInfoHelper.getOrDefault.mockResolvedValue(addressInfo); tokenRepository.getToken.mockResolvedValue(token); @@ -140,6 +153,7 @@ describe('Transfer mapper (Unit)', () => { .with('address', getAddress(transfer.tokenAddress)) .with('trusted', true) .build(); + swapTransferInfoMapper.mapSwapTransferInfo.mockResolvedValue(null); addressInfoHelper.getOrDefault.mockResolvedValue(addressInfo); tokenRepository.getToken.mockResolvedValue(token); @@ -271,6 +285,331 @@ describe('Transfer mapper (Unit)', () => { }); }); + describe('ERC20 swap transfers', () => { + // Note: swap transfers can never have a value of 0 + + describe('without onlyTrusted flag', () => { + it('should map swap transfers of trusted tokens with value', async () => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().build(); + /** + * TODO: Mock the following + * @see https://sepolia.etherscan.io/tx/0x5779dc3891a4693a4c6f44eb86abd4c553e3d3d36cacfc2c791b87b6c136f148 + */ + const transfer = { + type: 'ERC20_TRANSFER', + executionDate: new Date('2024-07-04T09:22:48Z'), + blockNumber: 6243548, + transactionHash: + '0x5779dc3891a4693a4c6f44eb86abd4c553e3d3d36cacfc2c791b87b6c136f148', + to: safe.address, + value: '1625650639290905524', + tokenId: null, + tokenAddress: '0xd3f3d46FeBCD4CdAa2B83799b7A5CdcB69d135De', + transferId: + 'e5779dc3891a4693a4c6f44eb86abd4c553e3d3d36cacfc2c791b87b6c136f148120', + tokenInfo: { + type: 'ERC20', + address: '0xd3f3d46FeBCD4CdAa2B83799b7A5CdcB69d135De', + name: 'GNO (test)', + symbol: 'GNO', + decimals: 18, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0xd3f3d46FeBCD4CdAa2B83799b7A5CdcB69d135De.png', + trusted: true, + }, + from: safe.address, + } as const; + const addressInfo = new AddressInfo(faker.finance.ethereumAddress()); + swapTransferInfoMapper.mapSwapTransferInfo.mockResolvedValue({ + type: TransactionInfoType.SwapTransfer, + humanDescription: null, + richDecodedInfo: null, + sender: { + value: '0x9008D19f58AAbD9eD0D60971565AA8510560ab41', + name: 'GPv2Settlement', + logoUri: + 'https://safe-transaction-assets.safe.global/contracts/logos/0x9008D19f58AAbD9eD0D60971565AA8510560ab41.png', + }, + recipient: { + value: safe.address, + name: 'GnosisSafeProxy', + logoUri: null, + }, + direction: TransferDirection.Incoming, + transferInfo: { ...transfer.tokenInfo, type: TransferType.Erc20 }, + uid: '0xf48010ff178567a04cb9e82341325d2bdcbf646b4ed54ef0305163368819f4bd2a73e61bd15b25b6958b4da3bfc759ca4db249b96686709e', + status: OrderStatus.Fulfilled, + kind: OrderKind.Sell, + orderClass: OrderClass.Limit, + validUntil: 1720086686, + sellAmount: '10000000000000000000', + buyAmount: '1608062657377840160', + executedSellAmount: '10000000000000000000', + executedBuyAmount: '1625650639290905524', + sellToken: tokenBuilder().build() as TokenInfo & { + decimals: number; + }, + buyToken: transfer.tokenInfo, + explorerUrl: + 'https://explorer.cow.fi/orders/0xf48010ff178567a04cb9e82341325d2bdcbf646b4ed54ef0305163368819f4bd2a73e61bd15b25b6958b4da3bfc759ca4db249b96686709e', + executedSurplusFee: '1400734851526479789', + receiver: safe.address, + owner: safe.address, + fullAppData: { + appCode: 'CoW Swap-SafeApp', + environment: 'production', + metadata: { + orderClass: { + orderClass: 'market', + }, + quote: { + slippageBips: 40, + }, + }, + version: '1.1.0', + }, + } as const); + addressInfoHelper.getOrDefault.mockResolvedValue(addressInfo); + tokenRepository.getToken.mockResolvedValue({ + ...transfer.tokenInfo, + type: TokenType.Erc20, + }); + + const actual = await mapper.mapTransfers({ + chainId, + transfers: [transfer], + safe, + onlyTrusted: false, + }); + + expect( + actual.every((transaction) => transaction instanceof Transaction), + ).toBe(true); + expect(actual).toEqual([ + { + id: `transfer_${safe.address}_${transfer.transferId}`, + timestamp: transfer.executionDate.getTime(), + txStatus: TransactionStatus.Success, + txInfo: expect.any(TransferTransactionInfo), + executionInfo: null, + safeAppInfo: null, + txHash: transfer.transactionHash, + }, + ]); + }); + }); + + describe('with onlyTrusted flag', () => { + it('should map swap transfers of trusted tokens', async () => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().build(); + /** + * TODO: Mock the following + * @see https://sepolia.etherscan.io/tx/0x5779dc3891a4693a4c6f44eb86abd4c553e3d3d36cacfc2c791b87b6c136f148 + */ + const transfer = { + type: 'ERC20_TRANSFER', + executionDate: new Date('2024-07-04T09:22:48Z'), + blockNumber: 6243548, + transactionHash: + '0x5779dc3891a4693a4c6f44eb86abd4c553e3d3d36cacfc2c791b87b6c136f148', + to: safe.address, + value: '1625650639290905524', + tokenId: null, + tokenAddress: '0xd3f3d46FeBCD4CdAa2B83799b7A5CdcB69d135De', + transferId: + 'e5779dc3891a4693a4c6f44eb86abd4c553e3d3d36cacfc2c791b87b6c136f148120', + tokenInfo: { + type: 'ERC20', + address: '0xd3f3d46FeBCD4CdAa2B83799b7A5CdcB69d135De', + name: 'GNO (test)', + symbol: 'GNO', + decimals: 18, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0xd3f3d46FeBCD4CdAa2B83799b7A5CdcB69d135De.png', + trusted: true, + }, + from: safe.address, + } as const; + const addressInfo = new AddressInfo(faker.finance.ethereumAddress()); + swapTransferInfoMapper.mapSwapTransferInfo.mockResolvedValue({ + type: TransactionInfoType.SwapTransfer, + humanDescription: null, + richDecodedInfo: null, + sender: { + value: '0x9008D19f58AAbD9eD0D60971565AA8510560ab41', + name: 'GPv2Settlement', + logoUri: + 'https://safe-transaction-assets.safe.global/contracts/logos/0x9008D19f58AAbD9eD0D60971565AA8510560ab41.png', + }, + recipient: { + value: safe.address, + name: 'GnosisSafeProxy', + logoUri: null, + }, + direction: TransferDirection.Incoming, + transferInfo: { ...transfer.tokenInfo, type: TransferType.Erc20 }, + uid: '0xf48010ff178567a04cb9e82341325d2bdcbf646b4ed54ef0305163368819f4bd2a73e61bd15b25b6958b4da3bfc759ca4db249b96686709e', + status: OrderStatus.Fulfilled, + kind: OrderKind.Sell, + orderClass: OrderClass.Limit, + validUntil: 1720086686, + sellAmount: '10000000000000000000', + buyAmount: '1608062657377840160', + executedSellAmount: '10000000000000000000', + executedBuyAmount: '1625650639290905524', + sellToken: tokenBuilder().build() as TokenInfo & { + decimals: number; + }, + buyToken: transfer.tokenInfo, + explorerUrl: + 'https://explorer.cow.fi/orders/0xf48010ff178567a04cb9e82341325d2bdcbf646b4ed54ef0305163368819f4bd2a73e61bd15b25b6958b4da3bfc759ca4db249b96686709e', + executedSurplusFee: '1400734851526479789', + receiver: safe.address, + owner: safe.address, + fullAppData: { + appCode: 'CoW Swap-SafeApp', + environment: 'production', + metadata: { + orderClass: { + orderClass: 'market', + }, + quote: { + slippageBips: 40, + }, + }, + version: '1.1.0', + }, + } as const); + addressInfoHelper.getOrDefault.mockResolvedValue(addressInfo); + tokenRepository.getToken.mockResolvedValue({ + ...transfer.tokenInfo, + type: TokenType.Erc20, + }); + + const actual = await mapper.mapTransfers({ + chainId, + transfers: [transfer], + safe, + onlyTrusted: true, + }); + + expect( + actual.every((transaction) => transaction instanceof Transaction), + ).toBe(true); + expect(actual).toEqual([ + { + id: `transfer_${safe.address}_${transfer.transferId}`, + timestamp: transfer.executionDate.getTime(), + txStatus: TransactionStatus.Success, + txInfo: expect.any(TransferTransactionInfo), + executionInfo: null, + safeAppInfo: null, + txHash: transfer.transactionHash, + }, + ]); + }); + + it('should not map transfers of untrusted tokens', async () => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().build(); + /** + * TODO: Mock the following + * @see https://sepolia.etherscan.io/tx/0x5779dc3891a4693a4c6f44eb86abd4c553e3d3d36cacfc2c791b87b6c136f148 + */ + const transfer = { + type: 'ERC20_TRANSFER', + executionDate: new Date('2024-07-04T09:22:48Z'), + blockNumber: 6243548, + transactionHash: + '0x5779dc3891a4693a4c6f44eb86abd4c553e3d3d36cacfc2c791b87b6c136f148', + to: safe.address, + value: '1625650639290905524', + tokenId: null, + tokenAddress: '0xd3f3d46FeBCD4CdAa2B83799b7A5CdcB69d135De', + transferId: + 'e5779dc3891a4693a4c6f44eb86abd4c553e3d3d36cacfc2c791b87b6c136f148120', + tokenInfo: { + type: 'ERC20', + address: '0xd3f3d46FeBCD4CdAa2B83799b7A5CdcB69d135De', + name: 'GNO (test)', + symbol: 'GNO', + decimals: 18, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0xd3f3d46FeBCD4CdAa2B83799b7A5CdcB69d135De.png', + trusted: false, + }, + from: safe.address, + } as const; + const addressInfo = new AddressInfo(faker.finance.ethereumAddress()); + swapTransferInfoMapper.mapSwapTransferInfo.mockResolvedValue({ + type: TransactionInfoType.SwapTransfer, + humanDescription: null, + richDecodedInfo: null, + sender: { + value: '0x9008D19f58AAbD9eD0D60971565AA8510560ab41', + name: 'GPv2Settlement', + logoUri: + 'https://safe-transaction-assets.safe.global/contracts/logos/0x9008D19f58AAbD9eD0D60971565AA8510560ab41.png', + }, + recipient: { + value: safe.address, + name: 'GnosisSafeProxy', + logoUri: null, + }, + direction: TransferDirection.Incoming, + transferInfo: { ...transfer.tokenInfo, type: TransferType.Erc20 }, + uid: '0xf48010ff178567a04cb9e82341325d2bdcbf646b4ed54ef0305163368819f4bd2a73e61bd15b25b6958b4da3bfc759ca4db249b96686709e', + status: OrderStatus.Fulfilled, + kind: OrderKind.Sell, + orderClass: OrderClass.Limit, + validUntil: 1720086686, + sellAmount: '10000000000000000000', + buyAmount: '1608062657377840160', + executedSellAmount: '10000000000000000000', + executedBuyAmount: '1625650639290905524', + sellToken: tokenBuilder().build() as TokenInfo & { + decimals: number; + }, + buyToken: transfer.tokenInfo, + explorerUrl: + 'https://explorer.cow.fi/orders/0xf48010ff178567a04cb9e82341325d2bdcbf646b4ed54ef0305163368819f4bd2a73e61bd15b25b6958b4da3bfc759ca4db249b96686709e', + executedSurplusFee: '1400734851526479789', + receiver: safe.address, + owner: safe.address, + fullAppData: { + appCode: 'CoW Swap-SafeApp', + environment: 'production', + metadata: { + orderClass: { + orderClass: 'market', + }, + quote: { + slippageBips: 40, + }, + }, + version: '1.1.0', + }, + } as const); + addressInfoHelper.getOrDefault.mockResolvedValue(addressInfo); + tokenRepository.getToken.mockResolvedValue({ + ...transfer.tokenInfo, + type: TokenType.Erc20, + }); + + const actual = await mapper.mapTransfers({ + chainId, + transfers: [transfer], + safe, + onlyTrusted: true, + }); + + expect(actual).toEqual([]); + }); + }); + }); + it('should map and filter a mixture of transfers', async () => { const chainId = faker.string.numeric(); const safe = safeBuilder().build(); @@ -307,6 +646,7 @@ describe('Transfer mapper (Unit)', () => { const untrustedErc20TransferWithoutValue = erc20TransferBuilder() .with('value', '0') .build(); + swapTransferInfoMapper.mapSwapTransferInfo.mockResolvedValue(null); addressInfoHelper.getOrDefault.mockResolvedValue(addressInfo); tokenRepository.getToken .mockResolvedValueOnce(erc721Token) @@ -317,6 +657,7 @@ describe('Transfer mapper (Unit)', () => { const actual = await mapper.mapTransfers({ chainId, + // TODO: Add swap transfers transfers: [ nativeTransfer, erc721Transfer, diff --git a/src/routes/transactions/mappers/transfers/transfer.mapper.ts b/src/routes/transactions/mappers/transfers/transfer.mapper.ts index 31436e5856..521b2090fe 100644 --- a/src/routes/transactions/mappers/transfers/transfer.mapper.ts +++ b/src/routes/transactions/mappers/transfers/transfer.mapper.ts @@ -10,6 +10,7 @@ import { TransferInfoMapper } from '@/routes/transactions/mappers/transfers/tran import { Transaction } from '@/routes/transactions/entities/transaction.entity'; import { isTransferTransactionInfo } from '@/routes/transactions/entities/transfer-transaction-info.entity'; import { isErc20Transfer } from '@/routes/transactions/entities/transfers/erc20-transfer.entity'; +import { isSwapTransferTransactionInfo } from '@/routes/transactions/swap-transfer-transaction-info.entity'; @Injectable() export class TransferMapper { @@ -62,14 +63,22 @@ export class TransferMapper { * @private */ private isTransferWithValue(transaction: Transaction): boolean { - if (!isTransferTransactionInfo(transaction.txInfo)) return true; + if ( + !isTransferTransactionInfo(transaction.txInfo) && + !isSwapTransferTransactionInfo(transaction.txInfo) + ) + return true; if (!isErc20Transfer(transaction.txInfo.transferInfo)) return true; return Number(transaction.txInfo.transferInfo.value) > 0; } private isTrustedTransfer(transaction: Transaction): boolean { - if (!isTransferTransactionInfo(transaction.txInfo)) return true; + if ( + !isTransferTransactionInfo(transaction.txInfo) && + !isSwapTransferTransactionInfo(transaction.txInfo) + ) + return true; if (!isErc20Transfer(transaction.txInfo.transferInfo)) return true; return !!transaction.txInfo.transferInfo.trusted; diff --git a/src/routes/transactions/swap-transfer-transaction-info.entity.ts b/src/routes/transactions/swap-transfer-transaction-info.entity.ts index 70ad966c5f..dfef2c9e5a 100644 --- a/src/routes/transactions/swap-transfer-transaction-info.entity.ts +++ b/src/routes/transactions/swap-transfer-transaction-info.entity.ts @@ -168,3 +168,9 @@ export class SwapTransferTransactionInfo this.fullAppData = args.fullAppData; } } + +export function isSwapTransferTransactionInfo( + txInfo: TransactionInfo, +): txInfo is TransferTransactionInfo { + return txInfo.type === TransactionInfoType.SwapTransfer; +} From 9f20cc03b55471072ac7f2e8b2e8f7c8d6114ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Thu, 4 Jul 2024 17:03:52 +0200 Subject: [PATCH 154/207] Add application.isProduction configuration flag (#1724) Exposes and uses the application.isProduction configuration field, instead of application.cgwEnv. --- src/config/entities/__tests__/configuration.ts | 2 +- src/config/entities/configuration.ts | 2 +- src/routes/auth/auth.controller.spec.ts | 4 ++-- src/routes/auth/auth.controller.ts | 10 +++++----- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index d551002392..5b4c35f29f 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -14,7 +14,7 @@ export default (): ReturnType => ({ prefetch: faker.number.int(), }, application: { - env: faker.string.sample(), + isProduction: faker.datatype.boolean(), port: faker.internet.port().toString(), }, auth: { diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index e5639fcedb..d21512bb28 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -24,7 +24,7 @@ export default () => ({ : 100, }, application: { - env: process.env.CGW_ENV || 'production', + isProduction: process.env.CGW_ENV === 'production', port: process.env.APPLICATION_PORT || '3000', }, auth: { diff --git a/src/routes/auth/auth.controller.spec.ts b/src/routes/auth/auth.controller.spec.ts index 2fed0bb69f..87407f7d92 100644 --- a/src/routes/auth/auth.controller.spec.ts +++ b/src/routes/auth/auth.controller.spec.ts @@ -90,7 +90,7 @@ describe('AuthController', () => { ...defaultConfiguration, application: { ...defaultConfiguration.application, - env: 'production', + isProduction: true, }, features: { ...defaultConfiguration.features, @@ -232,7 +232,7 @@ describe('AuthController', () => { ...defaultConfiguration, application: { ...defaultConfiguration.application, - env: 'staging', + isProduction: false, }, features: { ...defaultConfiguration.features, diff --git a/src/routes/auth/auth.controller.ts b/src/routes/auth/auth.controller.ts index 20a69a5270..59dc40b3b1 100644 --- a/src/routes/auth/auth.controller.ts +++ b/src/routes/auth/auth.controller.ts @@ -31,15 +31,16 @@ export class AuthController { static readonly ACCESS_TOKEN_COOKIE_SAME_SITE_LAX = 'lax'; static readonly ACCESS_TOKEN_COOKIE_SAME_SITE_NONE = 'none'; static readonly CGW_ENV_PRODUCTION = 'production'; - private readonly cgwEnv: string; + private readonly isProduction: boolean; constructor( @Inject(IConfigurationService) private readonly configurationService: IConfigurationService, private readonly authService: AuthService, ) { - this.cgwEnv = - this.configurationService.getOrThrow('application.env'); + this.isProduction = this.configurationService.getOrThrow( + 'application.isProduction', + ); } @Get('nonce') @@ -58,12 +59,11 @@ export class AuthController { siweDto: SiweDto, ): Promise { const { accessToken } = await this.authService.getAccessToken(siweDto); - const isProduction = this.cgwEnv === AuthController.CGW_ENV_PRODUCTION; res.cookie(AuthController.ACCESS_TOKEN_COOKIE_NAME, accessToken, { httpOnly: true, secure: true, - sameSite: isProduction + sameSite: this.isProduction ? AuthController.ACCESS_TOKEN_COOKIE_SAME_SITE_LAX : AuthController.ACCESS_TOKEN_COOKIE_SAME_SITE_NONE, path: '/', From ff2e127e216ac84e4f776d7111139b43453aa043 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 4 Jul 2024 17:24:43 +0200 Subject: [PATCH 155/207] Removes unnecessary TODO comment (#1726) Removes unnecessary TODO comment for a refactor that isn't feasible. --- .../transactions/mappers/common/transaction-info.mapper.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/routes/transactions/mappers/common/transaction-info.mapper.ts b/src/routes/transactions/mappers/common/transaction-info.mapper.ts index 5796f48dec..c31a1abd22 100644 --- a/src/routes/transactions/mappers/common/transaction-info.mapper.ts +++ b/src/routes/transactions/mappers/common/transaction-info.mapper.ts @@ -199,8 +199,6 @@ export class MultisigTransactionInfoMapper { ); } - // TODO: Refactor mapSwapOrder, mapTwapOrder and mapTwapSwapOrder as they follow the same pattern - /** * Maps a swap order transaction. * If the transaction is not a swap order, it returns null. From f67f7a3b99852f4fcb9a25c89068f297250db533 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 4 Jul 2024 17:25:06 +0200 Subject: [PATCH 156/207] Add test for `getOrderUidFromSetPreSignature` (#1727) Adds the relative test coverage for `GPv2Decoder['getOrderUidFromSetPreSignature']`. --- .../contracts/decoders/gp-v2-decoder.helper.spec.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.spec.ts b/src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.spec.ts index 5e035cf171..d818b84600 100644 --- a/src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.spec.ts +++ b/src/domain/swaps/contracts/decoders/gp-v2-decoder.helper.spec.ts @@ -34,8 +34,16 @@ describe('GPv2Decoder', () => { expect(() => target.decodeFunctionData({ data })).toThrow(); }); - // TODO: Add test - should've been added in first swaps integration - it.todo('gets orderUid from setPreSignature function call'); + it('gets orderUid from setPreSignature function call', () => { + const setPreSignature = setPreSignatureEncoder(); + const args = setPreSignature.build(); + + const orderUid = target.getOrderUidFromSetPreSignature( + setPreSignature.encode(), + ); + + expect(orderUid).toBe(args.orderUid); + }); it('should decode an order from a settle function call', () => { /** From 11e324340c7720ca078c293395b72809181ff5f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Fri, 5 Jul 2024 11:40:06 +0200 Subject: [PATCH 157/207] Filter out TWAP orders from not allowed apps (#1698) Add TwapOrderHelper.isAppAllowed --- .../__tests__/full-app-data.builder.ts | 9 + .../helpers/gp-v2-order.helper.spec.ts | 6 + .../transactions/helpers/swap-order.helper.ts | 9 +- .../helpers/twap-order.helper.spec.ts | 11 +- .../transactions/helpers/twap-order.helper.ts | 48 +- .../mappers/common/swap-order.mapper.ts | 3 +- .../mappers/common/twap-order.mapper.spec.ts | 416 ++++++++++++++++++ .../mappers/common/twap-order.mapper.ts | 55 ++- .../swap-transfer-info.mapper.spec.ts | 137 ++++++ .../transfers/swap-transfer-info.mapper.ts | 5 + .../transactions-view.controller.spec.ts | 5 +- .../transactions/transactions-view.service.ts | 20 +- 12 files changed, 691 insertions(+), 33 deletions(-) create mode 100644 src/domain/swaps/entities/__tests__/full-app-data.builder.ts diff --git a/src/domain/swaps/entities/__tests__/full-app-data.builder.ts b/src/domain/swaps/entities/__tests__/full-app-data.builder.ts new file mode 100644 index 0000000000..f86e82750d --- /dev/null +++ b/src/domain/swaps/entities/__tests__/full-app-data.builder.ts @@ -0,0 +1,9 @@ +import { Builder, IBuilder } from '@/__tests__/builder'; +import { FullAppData } from '@/domain/swaps/entities/full-app-data.entity'; +import { faker } from '@faker-js/faker'; + +export function fullAppDataBuilder(): IBuilder { + return new Builder().with('fullAppData', { + appCode: faker.string.alphanumeric(), + }); +} diff --git a/src/routes/transactions/helpers/gp-v2-order.helper.spec.ts b/src/routes/transactions/helpers/gp-v2-order.helper.spec.ts index 89a28c18bb..adda302612 100644 --- a/src/routes/transactions/helpers/gp-v2-order.helper.spec.ts +++ b/src/routes/transactions/helpers/gp-v2-order.helper.spec.ts @@ -1,3 +1,4 @@ +import { FakeConfigurationService } from '@/config/__tests__/fake.configuration.service'; import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; import { ComposableCowDecoder } from '@/domain/swaps/contracts/decoders/composable-cow-decoder.helper'; import { GPv2OrderHelper } from '@/routes/transactions/helpers/gp-v2-order.helper'; @@ -9,9 +10,14 @@ describe('GPv2OrderHelper', () => { const multiSendDecoder = new MultiSendDecoder(); const composableCowDecoder = new ComposableCowDecoder(); + const configurationService = new FakeConfigurationService(); + const allowedApps = new Set(); + configurationService.set('swaps.restrictApps', false); const twapOrderHelper = new TwapOrderHelper( + configurationService, multiSendDecoder, composableCowDecoder, + allowedApps, ); describe('computeOrderUid', () => { diff --git a/src/routes/transactions/helpers/swap-order.helper.ts b/src/routes/transactions/helpers/swap-order.helper.ts index 8d944a11d7..53eacfaddf 100644 --- a/src/routes/transactions/helpers/swap-order.helper.ts +++ b/src/routes/transactions/helpers/swap-order.helper.ts @@ -7,7 +7,11 @@ import { } from '@/domain/tokens/token.repository.interface'; import { ISwapsRepository } from '@/domain/swaps/swaps.repository'; import { Token, TokenType } from '@/domain/tokens/entities/token.entity'; -import { Order, OrderKind } from '@/domain/swaps/entities/order.entity'; +import { + KnownOrder, + Order, + OrderKind, +} from '@/domain/swaps/entities/order.entity'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { SwapsRepositoryModule } from '@/domain/swaps/swaps-repository.module'; import { @@ -87,7 +91,7 @@ export class SwapOrderHelper { async getOrder(args: { chainId: string; orderUid: `0x${string}`; - }): Promise }> { + }): Promise { const order = await this.swapsRepository.getOrder( args.chainId, args.orderUid, @@ -119,6 +123,7 @@ export class SwapOrderHelper { * @param order - the order to which we should verify the app data with * @returns true if the app is allowed, false otherwise. */ + // TODO: Refactor with confirmation view, swaps and TWAPs isAppAllowed(order: Order): boolean { if (!this.restrictApps) return true; const appCode = order.fullAppData?.appCode; diff --git a/src/routes/transactions/helpers/twap-order.helper.spec.ts b/src/routes/transactions/helpers/twap-order.helper.spec.ts index 08acacdfda..ab40668bfb 100644 --- a/src/routes/transactions/helpers/twap-order.helper.spec.ts +++ b/src/routes/transactions/helpers/twap-order.helper.spec.ts @@ -1,3 +1,4 @@ +import { FakeConfigurationService } from '@/config/__tests__/fake.configuration.service'; import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; import { ComposableCowDecoder } from '@/domain/swaps/contracts/decoders/composable-cow-decoder.helper'; import { TwapOrderHelper } from '@/routes/transactions/helpers/twap-order.helper'; @@ -20,7 +21,15 @@ describe('TwapOrderHelper', () => { const multiSendDecoder = new MultiSendDecoder(); const composableCowDecoder = new ComposableCowDecoder(); - const target = new TwapOrderHelper(multiSendDecoder, composableCowDecoder); + const configurationService = new FakeConfigurationService(); + const allowedApps = new Set(); + configurationService.set('swaps.restrictApps', false); + const target = new TwapOrderHelper( + configurationService, + multiSendDecoder, + composableCowDecoder, + allowedApps, + ); describe('findTwapOrder', () => { describe('direct createWithContext call', () => { diff --git a/src/routes/transactions/helpers/twap-order.helper.ts b/src/routes/transactions/helpers/twap-order.helper.ts index 18bb1af356..6e08d154e9 100644 --- a/src/routes/transactions/helpers/twap-order.helper.ts +++ b/src/routes/transactions/helpers/twap-order.helper.ts @@ -17,8 +17,10 @@ import { TwapOrderInfo, } from '@/routes/transactions/entities/swaps/twap-order-info.entity'; import { GPv2OrderParameters } from '@/domain/swaps/contracts/decoders/gp-v2-decoder.helper'; -import { Injectable, Module } from '@nestjs/common'; +import { Inject, Injectable, Module } from '@nestjs/common'; import { isAddressEqual } from 'viem'; +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { FullAppData } from '@/domain/swaps/entities/full-app-data.entity'; /** * @@ -29,10 +31,18 @@ export class TwapOrderHelper { private static readonly ComposableCowAddress = '0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74' as const; + private readonly restrictApps: boolean; + constructor( + @Inject(IConfigurationService) + private readonly configurationService: IConfigurationService, private readonly multiSendDecoder: MultiSendDecoder, private readonly composableCowDecoder: ComposableCowDecoder, - ) {} + @Inject('SWAP_ALLOWED_APPS') private readonly allowedApps: Set, + ) { + this.restrictApps = + this.configurationService.getOrThrow('swaps.restrictApps'); + } // TODO: Refactor findSwapOrder, findSwapTransfer and findTwapOrder to avoid code duplication @@ -167,6 +177,21 @@ export class TwapOrderHelper { }); } + /** + * Checks if the app associated contained in fullAppData is allowed. + * + * @param fullAppData - object to which we should verify the app data with + * @returns true if the app is allowed, false otherwise. + */ + // TODO: Refactor with confirmation view, swaps and TWAPs + public isAppAllowed(fullAppData: FullAppData): boolean { + if (!this.restrictApps) return true; + const appCode = fullAppData.fullAppData?.appCode; + return ( + !!appCode && typeof appCode === 'string' && this.allowedApps.has(appCode) + ); + } + private calculateValidTo(args: { part: number; startTime: number; @@ -182,9 +207,26 @@ export class TwapOrderHelper { } } +function allowedAppsFactory( + configurationService: IConfigurationService, +): Set { + const allowedApps = + configurationService.getOrThrow('swaps.allowedApps'); + return new Set(allowedApps); +} + @Module({ imports: [], - providers: [ComposableCowDecoder, MultiSendDecoder, TwapOrderHelper], + providers: [ + ComposableCowDecoder, + MultiSendDecoder, + TwapOrderHelper, + { + provide: 'SWAP_ALLOWED_APPS', + useFactory: allowedAppsFactory, + inject: [IConfigurationService], + }, + ], exports: [TwapOrderHelper], }) export class TwapOrderHelperModule {} diff --git a/src/routes/transactions/mappers/common/swap-order.mapper.ts b/src/routes/transactions/mappers/common/swap-order.mapper.ts index 8e3f599fbe..747c1f33ab 100644 --- a/src/routes/transactions/mappers/common/swap-order.mapper.ts +++ b/src/routes/transactions/mappers/common/swap-order.mapper.ts @@ -14,8 +14,6 @@ export class SwapOrderMapper { private readonly swapOrderHelper: SwapOrderHelper, ) {} - // TODO: Handling of restricted Apps of TWAP mapping - async mapSwapOrder( chainId: string, transaction: { data: `0x${string}` }, @@ -27,6 +25,7 @@ export class SwapOrderMapper { } const order = await this.swapOrderHelper.getOrder({ chainId, orderUid }); + // TODO: Refactor with confirmation view, swaps and TWAPs if (!this.swapOrderHelper.isAppAllowed(order)) { throw new Error(`Unsupported App: ${order.fullAppData?.appCode}`); } diff --git a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts index 57d6e3a5fe..7e2d191ab3 100644 --- a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts +++ b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts @@ -15,9 +15,11 @@ import { TwapOrderHelper } from '@/routes/transactions/helpers/twap-order.helper import { TwapOrderMapper } from '@/routes/transactions/mappers/common/twap-order.mapper'; import { ILoggingService } from '@/logging/logging.interface'; import { getAddress } from 'viem'; +import { fullAppDataBuilder } from '@/domain/swaps/entities/__tests__/full-app-data.builder'; const loggingService = { debug: jest.fn(), + warn: jest.fn(), } as jest.MockedObjectDeep; const mockLoggingService = jest.mocked(loggingService); @@ -54,9 +56,12 @@ describe('TwapOrderMapper', () => { ); const composableCowDecoder = new ComposableCowDecoder(); const gpv2OrderHelper = new GPv2OrderHelper(); + configurationService.set('swaps.restrictApps', false); const twapOrderHelper = new TwapOrderHelper( + configurationService, multiSendDecoder, composableCowDecoder, + allowedApps, ); beforeEach(() => { @@ -485,4 +490,415 @@ describe('TwapOrderMapper', () => { validUntil: 1718291639, }); }); + + it('should throw an error if source apps are restricted and no fullAppData is available', async () => { + const now = new Date(); + jest.setSystemTime(now); + + configurationService.set('swaps.maxNumberOfParts', 2); + configurationService.set('swaps.restrictApps', true); + + // We instantiate in tests to be able to set maxNumberOfParts + const mapper = new TwapOrderMapper( + configurationService, + loggingService, + swapOrderHelper, + mockSwapsRepository, + composableCowDecoder, + gpv2OrderHelper, + new TwapOrderHelper( + configurationService, + multiSendDecoder, + composableCowDecoder, + allowedApps, + ), + ); + + // Taken from queued transaction of specified owner before execution + const chainId = '11155111'; + const owner = '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381'; + const data = + '0x0d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000001903c57a7700000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb5900000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000006f05b59d3b2000000000000000000000000000000000000000000000000000165e249251c2365980000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000023280000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000'; + + const { fullAppData } = fullAppDataBuilder() + .with('fullAppData', null) + .build(); + + // Orders throw as they don't exist + mockSwapsRepository.getOrder.mockRejectedValue( + new Error('Order not found'), + ); + mockSwapsRepository.getFullAppData.mockResolvedValue({ fullAppData }); + + await expect( + mapper.mapTwapOrder(chainId, owner, { + data, + executionDate: null, + }), + ).rejects.toThrow(`Unsupported App: undefined`); + }); + + it('should throw an error if source apps are restricted and fullAppData does not match any allowed app', async () => { + const now = new Date(); + jest.setSystemTime(now); + + configurationService.set('swaps.maxNumberOfParts', 2); + configurationService.set('swaps.restrictApps', true); + + // We instantiate in tests to be able to set maxNumberOfParts + const mapper = new TwapOrderMapper( + configurationService, + loggingService, + swapOrderHelper, + mockSwapsRepository, + composableCowDecoder, + gpv2OrderHelper, + new TwapOrderHelper( + configurationService, + multiSendDecoder, + composableCowDecoder, + new Set(['app1', 'app2']), + ), + ); + + // Taken from queued transaction of specified owner before execution + const chainId = '11155111'; + const owner = '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381'; + const data = + '0x0d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000001903c57a7700000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb5900000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000006f05b59d3b2000000000000000000000000000000000000000000000000000165e249251c2365980000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000023280000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000'; + + const { fullAppData } = fullAppDataBuilder() + .with('fullAppData', { appCode: 'app3' }) + .build(); + + // Orders throw as they don't exist + mockSwapsRepository.getOrder.mockRejectedValue( + new Error('Order not found'), + ); + mockSwapsRepository.getFullAppData.mockResolvedValue({ fullAppData }); + + await expect( + mapper.mapTwapOrder(chainId, owner, { + data, + executionDate: null, + }), + ).rejects.toThrow(`Unsupported App: ${fullAppData.appCode}`); + }); + + it('should throw an error if source apps are restricted and a part order fullAppData does not match any allowed app', async () => { + configurationService.set('swaps.maxNumberOfParts', 2); + configurationService.set('swaps.restrictApps', true); + + // We instantiate in tests to be able to set maxNumberOfParts + const mapper = new TwapOrderMapper( + configurationService, + mockLoggingService, + swapOrderHelper, + mockSwapsRepository, + composableCowDecoder, + gpv2OrderHelper, + new TwapOrderHelper( + configurationService, + multiSendDecoder, + composableCowDecoder, + allowedApps, + ), + ); + + /** + * @see https://sepolia.etherscan.io/address/0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74 + */ + const chainId = '11155111'; + const owner = '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381'; + const data = + '0x0d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011f294a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000'; + const executionDate = new Date(1718288040000); + /** + * @see https://explorer.cow.fi/sepolia/orders/0xdaabe82f86545c66074b5565962e96758979ae80124aabef05e0585149d30f7931eac7f0141837b266de30f4dc9af15629bd5381666b05af?tab=overview + */ + const part1 = { + creationDate: '2024-06-13T14:14:02.269522Z', + owner: '0x31eac7f0141837b266de30f4dc9af15629bd5381', + uid: '0xdaabe82f86545c66074b5565962e96758979ae80124aabef05e0585149d30f7931eac7f0141837b266de30f4dc9af15629bd5381666b05af', + availableBalance: null, + executedBuyAmount: '691671781640850856', + executedSellAmount: '213586875483862141750', + executedSellAmountBeforeFees: '213586875483862141750', + executedFeeAmount: '0', + executedSurplusFee: '111111111', + invalidated: false, + status: 'fulfilled', + class: 'limit', + settlementContract: '0x9008d19f58aabd9ed0d60971565aa8510560ab41', + fullFeeAmount: '0', + solverFee: '0', + isLiquidityOrder: false, + fullAppData: JSON.parse( + '{"appCode":"Safe Wallet Swaps","metadata":{"orderClass":{"orderClass":"twap"},"quote":{"slippageBips":1000},"widget":{"appCode":"CoW Swap-SafeApp","environment":"production"}},"version":"1.1.0"}', + ), + sellToken: '0xbe72e441bf55620febc26715db68d3494213d8cb', + buyToken: '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', + receiver: '0x31eac7f0141837b266de30f4dc9af15629bd5381', + sellAmount: '213586875483862141750', + buyAmount: '611289510998251134', + validTo: 1718289839, + appData: + '0xf7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda0', + feeAmount: '0', + kind: 'sell', + partiallyFillable: false, + sellTokenBalance: 'erc20', + buyTokenBalance: 'erc20', + signingScheme: 'eip1271', + signature: + '0x5fd7e97ddaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230bd5a25ba2e97094ad7d83dc28a6572da797d6b3e7fc6663bd93efb789fc17e489000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000000180000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e00000000000000000000000000000000000000000000000000000000666b05aff7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc90000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011f294a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000', + interactions: { pre: [], post: [] }, + } as unknown as Order; + + const buyToken = tokenBuilder() + .with('address', getAddress(part1.buyToken)) + .build(); + const sellToken = tokenBuilder() + .with('address', getAddress(part1.sellToken)) + .build(); + const fullAppData = JSON.parse(fakeJson()); + + mockSwapsRepository.getOrder.mockResolvedValueOnce(part1); + mockTokenRepository.getToken.mockImplementation(async ({ address }) => { + // We only need mock part1 addresses as all parts use the same tokens + switch (address) { + case buyToken.address: { + return Promise.resolve(buyToken); + } + case sellToken.address: { + return Promise.resolve(sellToken); + } + default: { + return Promise.reject(new Error(`Token not found: ${address}`)); + } + } + }); + mockSwapsRepository.getFullAppData.mockResolvedValue({ fullAppData }); + + await expect( + mapper.mapTwapOrder(chainId, owner, { + data, + executionDate, + }), + ).rejects.toThrow(`Unsupported App: ${fullAppData.appCode}`); + }); + + it('should map the TWAP order if source apps are restricted and a part order fullAppData matches any of the allowed apps', async () => { + configurationService.set('swaps.restrictApps', true); + + // We instantiate in tests to be able to set maxNumberOfParts + const mapper = new TwapOrderMapper( + configurationService, + mockLoggingService, + swapOrderHelper, + mockSwapsRepository, + composableCowDecoder, + gpv2OrderHelper, + new TwapOrderHelper( + configurationService, + multiSendDecoder, + composableCowDecoder, + new Set(['Safe Wallet Swaps']), + ), + ); + + /** + * @see https://sepolia.etherscan.io/address/0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74 + */ + const chainId = '11155111'; + const owner = '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381'; + const data = + '0x0d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011f294a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000'; + const executionDate = new Date(1718288040000); + + /** + * @see https://explorer.cow.fi/sepolia/orders/0xdaabe82f86545c66074b5565962e96758979ae80124aabef05e0585149d30f7931eac7f0141837b266de30f4dc9af15629bd5381666b05af?tab=overview + */ + const part1 = { + creationDate: '2024-06-13T14:14:02.269522Z', + owner: '0x31eac7f0141837b266de30f4dc9af15629bd5381', + uid: '0xdaabe82f86545c66074b5565962e96758979ae80124aabef05e0585149d30f7931eac7f0141837b266de30f4dc9af15629bd5381666b05af', + availableBalance: null, + executedBuyAmount: '691671781640850856', + executedSellAmount: '213586875483862141750', + executedSellAmountBeforeFees: '213586875483862141750', + executedFeeAmount: '0', + executedSurplusFee: '111111111', + invalidated: false, + status: 'fulfilled', + class: 'limit', + settlementContract: '0x9008d19f58aabd9ed0d60971565aa8510560ab41', + fullFeeAmount: '0', + solverFee: '0', + isLiquidityOrder: false, + fullAppData: JSON.parse( + '{"appCode":"Safe Wallet Swaps","metadata":{"orderClass":{"orderClass":"twap"},"quote":{"slippageBips":1000},"widget":{"appCode":"CoW Swap-SafeApp","environment":"production"}},"version":"1.1.0"}', + ), + sellToken: '0xbe72e441bf55620febc26715db68d3494213d8cb', + buyToken: '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', + receiver: '0x31eac7f0141837b266de30f4dc9af15629bd5381', + sellAmount: '213586875483862141750', + buyAmount: '611289510998251134', + validTo: 1718289839, + appData: + '0xf7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda0', + feeAmount: '0', + kind: 'sell', + partiallyFillable: false, + sellTokenBalance: 'erc20', + buyTokenBalance: 'erc20', + signingScheme: 'eip1271', + signature: + '0x5fd7e97ddaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230bd5a25ba2e97094ad7d83dc28a6572da797d6b3e7fc6663bd93efb789fc17e489000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000000180000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e00000000000000000000000000000000000000000000000000000000666b05aff7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc90000000000000000000000000000000000000000000000000000000000000280000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011f294a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000', + interactions: { pre: [], post: [] }, + } as unknown as Order; + + const buyToken = tokenBuilder() + .with('address', getAddress(part1.buyToken)) + .build(); + const sellToken = tokenBuilder() + .with('address', getAddress(part1.sellToken)) + .build(); + const { fullAppData } = fullAppDataBuilder() + .with('fullAppData', { appCode: 'Safe Wallet Swaps' }) + .build(); + + mockSwapsRepository.getOrder.mockResolvedValueOnce(part1); + mockTokenRepository.getToken.mockImplementation(async ({ address }) => { + // We only need mock part1 addresses as all parts use the same tokens + switch (address) { + case buyToken.address: { + return Promise.resolve(buyToken); + } + case sellToken.address: { + return Promise.resolve(sellToken); + } + default: { + return Promise.reject(new Error(`Token not found: ${address}`)); + } + } + }); + mockSwapsRepository.getFullAppData.mockResolvedValue({ fullAppData }); + + const actual = await mapper.mapTwapOrder(chainId, owner, { + data, + executionDate, + }); + + expect(actual).toBeDefined(); + }); + + it('should map a queued TWAP order if source apps are restricted and fullAppData matches any allowed app', async () => { + const now = new Date(); + jest.setSystemTime(now); + + configurationService.set('swaps.maxNumberOfParts', 2); + configurationService.set('swaps.restrictApps', true); + + // We instantiate in tests to be able to set maxNumberOfParts + const mapper = new TwapOrderMapper( + configurationService, + loggingService, + swapOrderHelper, + mockSwapsRepository, + composableCowDecoder, + gpv2OrderHelper, + new TwapOrderHelper( + configurationService, + multiSendDecoder, + composableCowDecoder, + new Set(['app1', 'app2']), + ), + ); + + // Taken from queued transaction of specified owner before execution + const chainId = '11155111'; + const owner = '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381'; + const data = + '0x0d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a50000000000000000000000000000000000000000000000000000001903c57a7700000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b140000000000000000000000000625afb445c3b6b7b929342a04a22599fd5dbb5900000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000006f05b59d3b2000000000000000000000000000000000000000000000000000165e249251c2365980000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000023280000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000'; + + const buyToken = tokenBuilder() + .with('address', '0x0625aFB445C3B6B7B929342a04A22599fd5dBB59') + .build(); + const sellToken = tokenBuilder() + .with('address', '0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14') + .build(); + const { fullAppData } = fullAppDataBuilder() + .with('fullAppData', { appCode: 'app2' }) + .build(); + + // Orders throw as they don't exist + mockSwapsRepository.getOrder.mockRejectedValue( + new Error('Order not found'), + ); + mockTokenRepository.getToken.mockImplementation(async ({ address }) => { + // We only need mock part1 addresses as all parts use the same tokens + switch (address) { + case buyToken.address: { + return Promise.resolve(buyToken); + } + case sellToken.address: { + return Promise.resolve(sellToken); + } + default: { + return Promise.reject(new Error(`Token not found: ${address}`)); + } + } + }); + mockSwapsRepository.getFullAppData.mockResolvedValue({ fullAppData }); + + const result = await mapper.mapTwapOrder(chainId, owner, { + data, + executionDate: null, + }); + + expect(result).toEqual({ + buyAmount: '51576509680023161648', + buyToken: { + address: buyToken.address, + decimals: buyToken.decimals, + logoUri: buyToken.logoUri, + name: buyToken.name, + symbol: buyToken.symbol, + trusted: buyToken.trusted, + }, + class: 'limit', + durationOfPart: { + durationType: 'AUTO', + }, + executedBuyAmount: '0', + executedSellAmount: '0', + executedSurplusFee: '0', + fullAppData, + humanDescription: null, + kind: 'sell', + minPartLimit: '25788254840011580824', + numberOfParts: '2', + status: 'presignaturePending', + owner: '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381', + partSellAmount: '500000000000000000', + receiver: '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381', + richDecodedInfo: null, + sellAmount: '1000000000000000000', + sellToken: { + address: sellToken.address, + decimals: sellToken.decimals, + logoUri: sellToken.logoUri, + name: sellToken.name, + symbol: sellToken.symbol, + trusted: sellToken.trusted, + }, + startTime: { + startType: 'AT_MINING_TIME', + }, + timeBetweenParts: 9000, + type: 'TwapOrder', + validUntil: Math.ceil(now.getTime() / 1_000) + 17999, + }); + }); }); diff --git a/src/routes/transactions/mappers/common/twap-order.mapper.ts b/src/routes/transactions/mappers/common/twap-order.mapper.ts index baefb3e59a..ba2d064703 100644 --- a/src/routes/transactions/mappers/common/twap-order.mapper.ts +++ b/src/routes/transactions/mappers/common/twap-order.mapper.ts @@ -72,6 +72,16 @@ export class TwapOrderMapper { chainId, }); + const fullAppData = await this.swapsRepository.getFullAppData( + chainId, + twapStruct.appData, + ); + + // TODO: Refactor with confirmation view, swaps and TWAPs + if (!this.twapOrderHelper.isAppAllowed(fullAppData)) { + throw new Error(`Unsupported App: ${fullAppData.fullAppData?.appCode}`); + } + // There can be up to uint256 parts in a TWAP order so we limit this // to avoid requesting too many orders const hasAbundantParts = twapParts.length > this.maxNumberOfParts; @@ -85,33 +95,44 @@ export class TwapOrderMapper { : twapParts : []; - const { fullAppData } = await this.swapsRepository.getFullAppData( - chainId, - twapStruct.appData, - ); - const orders: Array = []; for (const part of partsToFetch) { + const partFullAppData = await this.swapsRepository.getFullAppData( + chainId, + part.appData, + ); + + // TODO: Refactor with confirmation view, swaps and TWAPs + if (!this.twapOrderHelper.isAppAllowed(partFullAppData)) { + throw new Error( + `Unsupported App: ${partFullAppData.fullAppData?.appCode}`, + ); + } + const orderUid = this.gpv2OrderHelper.computeOrderUid({ chainId, owner: safeAddress, order: part, }); + const order = await this.swapsRepository + .getOrder(chainId, orderUid) + .catch(() => { + this.loggingService.warn( + `Error getting orderUid ${orderUid} from SwapsRepository`, + ); + }); - try { - const order = await this.swapsRepository.getOrder(chainId, orderUid); - if (order.kind === OrderKind.Buy || order.kind === OrderKind.Sell) { - orders.push(order as KnownOrder); - } - } catch (err) { - this.loggingService.warn( - `Error getting orderUid ${orderUid} from SwapsRepository`, - ); + if (!order || order.kind == OrderKind.Unknown) { + continue; } - } - // TODO: Handling of restricted Apps, calling `getToken` directly instead of multiple times in `getOrder` for sellToken and buyToken + if (!this.swapOrderHelper.isAppAllowed(order)) { + throw new Error(`Unsupported App: ${order.fullAppData?.appCode}`); + } + + orders.push(order as KnownOrder); + } const executedSellAmount: TwapOrderInfo['executedSellAmount'] = hasAbundantParts ? null : this.getExecutedSellAmount(orders).toString(); @@ -161,7 +182,7 @@ export class TwapOrderMapper { }), receiver: twapStruct.receiver, owner: safeAddress, - fullAppData, + fullAppData: fullAppData.fullAppData, numberOfParts: twapOrderData.numberOfParts, partSellAmount: twapStruct.partSellAmount.toString(), minPartLimit: twapStruct.minPartLimit.toString(), diff --git a/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.spec.ts b/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.spec.ts index 1f917001c2..eb276f16a7 100644 --- a/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.spec.ts +++ b/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.spec.ts @@ -15,6 +15,7 @@ import { getAddress } from 'viem'; const mockSwapOrderHelper = jest.mocked({ getToken: jest.fn(), getOrderExplorerUrl: jest.fn(), + isAppAllowed: jest.fn(), } as jest.MockedObjectDeep); const mockSwapsRepository = jest.mocked({ @@ -118,6 +119,7 @@ describe('SwapTransferInfoMapper', () => { mockSwapOrderHelper.getOrderExplorerUrl.mockReturnValue( new URL(explorerUrl), ); + mockSwapOrderHelper.isAppAllowed.mockReturnValue(true); const actual = await target.mapSwapTransferInfo({ sender, @@ -199,6 +201,7 @@ describe('SwapTransferInfoMapper', () => { mockSwapOrderHelper.getOrderExplorerUrl.mockReturnValue( new URL(explorerUrl), ); + mockSwapOrderHelper.isAppAllowed.mockReturnValue(true); const actual = await target.mapSwapTransferInfo({ sender, @@ -359,6 +362,7 @@ describe('SwapTransferInfoMapper', () => { mockSwapOrderHelper.getOrderExplorerUrl.mockReturnValue( new URL(explorerUrl), ); + mockSwapOrderHelper.isAppAllowed.mockReturnValue(true); const actual = await target.mapSwapTransferInfo({ sender, @@ -396,4 +400,137 @@ describe('SwapTransferInfoMapper', () => { validUntil: orders[0].validTo, }); }); + + it('should throw if the app is not allowed', async () => { + /** + * https://api.cow.fi/mainnet/api/v1/transactions/0x22fe458f3a70aaf83d42af2040f3b98404526b4ca588624e158c4b1f287ced8c/orders + */ + const _orders = [ + { + creationDate: '2024-06-25T12:16:09.646330Z', + owner: '0x6ecba7527448bb56caba8ca7768d271deaea72a9', + uid: '0x0229aadcaf2d06d0ccacca0d7739c9e531e89605c61ac5883252c1f3612761ce6ecba7527448bb56caba8ca7768d271deaea72a9667abc04', + availableBalance: null, + executedBuyAmount: '3824530054984182297195399559', + executedSellAmount: '5555000000', + executedSellAmountBeforeFees: '5555000000', + executedFeeAmount: '0', + executedSurplusFee: '5012654', + invalidated: false, + status: 'fulfilled', + class: 'limit', + settlementContract: '0x9008d19f58aabd9ed0d60971565aa8510560ab41', + fullFeeAmount: '0', + solverFee: '0', + isLiquidityOrder: false, + fullAppData: + '{"appCode":"CoW Swap","environment":"production","metadata":{"orderClass":{"orderClass":"market"},"quote":{"slippageBips":50},"utm":{"utmContent":"header-cta-button","utmMedium":"web","utmSource":"cow.fi"}},"version":"1.1.0"}', + sellToken: '0xdac17f958d2ee523a2206206994597c13d831ec7', + buyToken: '0xaaee1a9723aadb7afa2810263653a34ba2c21c7a', + receiver: '0x6ecba7527448bb56caba8ca7768d271deaea72a9', + sellAmount: '5555000000', + buyAmount: '3807681190768269801973105790', + validTo: 1719319556, + appData: + '0x831ef45ca746d6d67482ba7ad19af3ed3d29da441d869cbf1fa8ea6dec3ebc1f', + feeAmount: '0', + kind: 'sell', + partiallyFillable: false, + sellTokenBalance: 'erc20', + buyTokenBalance: 'erc20', + signingScheme: 'eip712', + signature: + '0x6904193a1483813d7921585493b7e1a295476c12c9b6e08a430b726ae9a4e05660e309c430e21edcb5b7156e80291510159e1a4c39b736471f6bd4c131231b8c1b', + interactions: { pre: [], post: [] }, + }, + { + creationDate: '2024-06-25T12:15:26.924920Z', + owner: '0x6ecba7527448bb56caba8ca7768d271deaea72a9', + uid: '0xaaa1348fc7572d408097d069268db0ecb727ead6b525614999f983d5c5f1c1fb6ecba7527448bb56caba8ca7768d271deaea72a9667abbdc', + availableBalance: null, + executedBuyAmount: '6990751494894782668981616', + executedSellAmount: '3000000000', + executedSellAmountBeforeFees: '3000000000', + executedFeeAmount: '0', + executedSurplusFee: '4290918', + invalidated: false, + status: 'fulfilled', + class: 'limit', + settlementContract: '0x9008d19f58aabd9ed0d60971565aa8510560ab41', + fullFeeAmount: '0', + solverFee: '0', + isLiquidityOrder: false, + fullAppData: + '{"appCode":"CoW Swap","environment":"production","metadata":{"orderClass":{"orderClass":"market"},"quote":{"slippageBips":50},"utm":{"utmContent":"header-cta-button","utmMedium":"web","utmSource":"cow.fi"}},"version":"1.1.0"}', + sellToken: '0xdac17f958d2ee523a2206206994597c13d831ec7', + buyToken: '0x594daad7d77592a2b97b725a7ad59d7e188b5bfa', + receiver: '0x6ecba7527448bb56caba8ca7768d271deaea72a9', + sellAmount: '3000000000', + buyAmount: '6961165527651189129024639', + validTo: 1719319516, + appData: + '0x831ef45ca746d6d67482ba7ad19af3ed3d29da441d869cbf1fa8ea6dec3ebc1f', + feeAmount: '0', + kind: 'sell', + partiallyFillable: false, + sellTokenBalance: 'erc20', + buyTokenBalance: 'erc20', + signingScheme: 'eip712', + signature: + '0x18c6ea08a69ea97a3a1216038547ccb76c734b6ebc6b216cde32b68f8c2fb0c63c3223b056d2caf5dd0ff440b65821452ec84489b4edff00cbd419058a364e3f1c', + interactions: { pre: [], post: [] }, + }, + ]; + + // In order to appease TypeScript, we parse the data + const orders = OrdersSchema.parse(_orders); + + const safeAddress = orders[0].owner; + const sender = addressInfoBuilder().with('value', safeAddress).build(); + const recipient = addressInfoBuilder() + .with('value', GPv2SettlementAddress) + .build(); + const chainId = faker.string.numeric(); + const direction = getTransferDirection( + safeAddress, + sender.value, + recipient.value, + ); + const domainTransfer = erc20TransferBuilder() + .with('from', getAddress(sender.value)) + .with('to', getAddress(recipient.value)) + .with('value', orders[0].executedSellAmount.toString()) + .with('tokenAddress', orders[0].sellToken) + .build(); + const token = tokenBuilder() + .with('address', domainTransfer.tokenAddress) + .build(); + const transferInfo = new Erc20Transfer( + token.address, + domainTransfer.value, + token.name, + token.symbol, + token.logoUri, + token.decimals, + token.trusted, + ); + const explorerUrl = faker.internet.url({ appendSlash: true }); + mockSwapsRepository.getOrders.mockResolvedValue(orders); + mockSwapOrderHelper.getOrderExplorerUrl.mockReturnValue( + new URL(explorerUrl), + ); + mockSwapOrderHelper.isAppAllowed.mockReturnValue(false); + + await expect( + target.mapSwapTransferInfo({ + sender, + recipient, + direction, + chainId, + safeAddress, + transferInfo, + domainTransfer, + }), + ).rejects.toThrow('Unsupported App: CoW Swap'); + }); }); diff --git a/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.ts b/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.ts index 0fe76dcda8..7c323fa5e5 100644 --- a/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.ts +++ b/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.ts @@ -59,6 +59,11 @@ export class SwapTransferInfoMapper { return null; } + // TODO: Refactor with confirmation view, swaps and TWAPs + if (!this.swapOrderHelper.isAppAllowed(order)) { + throw new Error(`Unsupported App: ${order.fullAppData?.appCode}`); + } + const [sellToken, buyToken] = await Promise.all([ this.swapOrderHelper.getToken({ address: order.sellToken, diff --git a/src/routes/transactions/transactions-view.controller.spec.ts b/src/routes/transactions/transactions-view.controller.spec.ts index 63fb9104a8..09fd0d315a 100644 --- a/src/routes/transactions/transactions-view.controller.spec.ts +++ b/src/routes/transactions/transactions-view.controller.spec.ts @@ -25,7 +25,6 @@ import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { faker } from '@faker-js/faker'; import { Server } from 'net'; -import { fakeJson } from '@/__tests__/faker'; import { getAddress } from 'viem'; describe('TransactionsViewController tests', () => { @@ -214,7 +213,9 @@ describe('TransactionsViewController tests', () => { '0x0d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011f294a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000'; const appDataHash = '0xf7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda0'; - const fullAppData = JSON.parse(fakeJson()); + const fullAppData = { + fullAppData: JSON.stringify({ appCode: verifiedApp }), + }; const dataDecoded = dataDecodedBuilder().build(); const buyToken = tokenBuilder() .with('address', getAddress('0xfff9976782d46cc05630d1f6ebab18b2324d6b14')) diff --git a/src/routes/transactions/transactions-view.service.ts b/src/routes/transactions/transactions-view.service.ts index 6356324243..5bb3ff00e5 100644 --- a/src/routes/transactions/transactions-view.service.ts +++ b/src/routes/transactions/transactions-view.service.ts @@ -103,6 +103,7 @@ export class TransactionsViewService { orderUid, }); + // TODO: Refactor with confirmation view, swaps and TWAPs if (!this.swapOrderHelper.isAppAllowed(order)) { throw new Error(`Unsupported App: ${order.fullAppData?.appCode}`); } @@ -172,9 +173,18 @@ export class TransactionsViewService { chainId: args.chainId, }); - const [{ fullAppData }, buyToken, sellToken] = await Promise.all([ - // Decode hash of `appData` - this.swapsRepository.getFullAppData(args.chainId, twapStruct.appData), + // Decode hash of `appData` + const fullAppData = await this.swapsRepository.getFullAppData( + args.chainId, + twapStruct.appData, + ); + + // TODO: Refactor with confirmation view, swaps and TWAPs + if (!this.twapOrderHelper.isAppAllowed(fullAppData)) { + throw new Error(`Unsupported App: ${fullAppData.fullAppData?.appCode}`); + } + + const [buyToken, sellToken] = await Promise.all([ this.swapOrderHelper.getToken({ chainId: args.chainId, address: twapStruct.buyToken, @@ -185,8 +195,6 @@ export class TransactionsViewService { }), ]); - // TODO: Handling of restricted Apps - return new CowSwapTwapConfirmationView({ method: args.dataDecoded.method, parameters: args.dataDecoded.parameters, @@ -217,7 +225,7 @@ export class TransactionsViewService { }), receiver: twapStruct.receiver, owner: args.safeAddress, - fullAppData, + fullAppData: fullAppData.fullAppData, numberOfParts: twapOrderData.numberOfParts, partSellAmount: twapStruct.partSellAmount.toString(), minPartLimit: twapStruct.minPartLimit.toString(), From f9c567e81389c32fb9453fb764d6f37b49c6ac40 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Fri, 5 Jul 2024 12:33:40 +0200 Subject: [PATCH 158/207] Only log when trying to map restricted swap transfers (#1732) Switches from throwing when mapping a swap transfer from a disallowed app to instead logging and returning `null`. This means that the "standard" mapping will be done instead: - Log/return `null` instead of throwing when mapping a disallowed swap transfer - Update test accordingly --- .../swap-transfer-info.mapper.spec.ts | 32 ++++++++++++------- .../transfers/swap-transfer-info.mapper.ts | 8 ++++- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.spec.ts b/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.spec.ts index eb276f16a7..eb53997169 100644 --- a/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.spec.ts +++ b/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.spec.ts @@ -3,6 +3,7 @@ import { orderBuilder } from '@/domain/swaps/entities/__tests__/order.builder'; import { OrdersSchema } from '@/domain/swaps/entities/order.entity'; import { ISwapsRepository } from '@/domain/swaps/swaps.repository'; import { tokenBuilder } from '@/domain/tokens/__tests__/token.builder'; +import { ILoggingService } from '@/logging/logging.interface'; import { addressInfoBuilder } from '@/routes/common/__tests__/entities/address-info.builder'; import { TransferDirection } from '@/routes/transactions/entities/transfer-transaction-info.entity'; import { Erc20Transfer } from '@/routes/transactions/entities/transfers/erc20-transfer.entity'; @@ -22,6 +23,12 @@ const mockSwapsRepository = jest.mocked({ getOrders: jest.fn(), } as jest.MockedObjectDeep); +const mockLoggingService = jest.mocked({ + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), +} as jest.MockedObjectDeep); + describe('SwapTransferInfoMapper', () => { let target: SwapTransferInfoMapper; @@ -33,6 +40,7 @@ describe('SwapTransferInfoMapper', () => { target = new SwapTransferInfoMapper( mockSwapOrderHelper, mockSwapsRepository, + mockLoggingService, ); }); @@ -401,7 +409,7 @@ describe('SwapTransferInfoMapper', () => { }); }); - it('should throw if the app is not allowed', async () => { + it('should return null if the app is not allowed', async () => { /** * https://api.cow.fi/mainnet/api/v1/transactions/0x22fe458f3a70aaf83d42af2040f3b98404526b4ca588624e158c4b1f287ced8c/orders */ @@ -521,16 +529,16 @@ describe('SwapTransferInfoMapper', () => { ); mockSwapOrderHelper.isAppAllowed.mockReturnValue(false); - await expect( - target.mapSwapTransferInfo({ - sender, - recipient, - direction, - chainId, - safeAddress, - transferInfo, - domainTransfer, - }), - ).rejects.toThrow('Unsupported App: CoW Swap'); + const actual = await target.mapSwapTransferInfo({ + sender, + recipient, + direction, + chainId, + safeAddress, + transferInfo, + domainTransfer, + }); + + expect(actual).toEqual(null); }); }); diff --git a/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.ts b/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.ts index 7c323fa5e5..24bf9c2a30 100644 --- a/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.ts +++ b/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.ts @@ -9,6 +9,7 @@ import { SwapTransferTransactionInfo } from '@/routes/transactions/swap-transfer import { getAddress, isAddressEqual } from 'viem'; import { ISwapsRepository } from '@/domain/swaps/swaps.repository'; import { Order } from '@/domain/swaps/entities/order.entity'; +import { LoggingService, ILoggingService } from '@/logging/logging.interface'; @Injectable() export class SwapTransferInfoMapper { @@ -16,6 +17,7 @@ export class SwapTransferInfoMapper { private readonly swapOrderHelper: SwapOrderHelper, @Inject(ISwapsRepository) private readonly swapsRepository: ISwapsRepository, + @Inject(LoggingService) private readonly loggingService: ILoggingService, ) {} /** @@ -61,7 +63,11 @@ export class SwapTransferInfoMapper { // TODO: Refactor with confirmation view, swaps and TWAPs if (!this.swapOrderHelper.isAppAllowed(order)) { - throw new Error(`Unsupported App: ${order.fullAppData?.appCode}`); + this.loggingService.warn( + `Unsupported App: ${order.fullAppData?.appCode}`, + ); + // Don't throw in order to fallback to "standard" transfer mapping + return null; } const [sellToken, buyToken] = await Promise.all([ From 583b282feb6be82b687ba3fb906afa1b04b64d30 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Fri, 5 Jul 2024 17:34:02 +0200 Subject: [PATCH 159/207] Handle all swap transfer mapping errors (#1733) Wraps the call to the swap transfer mapper, logging every error accordingly and meaning that the "normal" mapping of transfers will be used as a fallback: - Always return a mapped transaction from `SwapTransferInfoMapper['mapSwapTransferInfo']` - Add error handling wrapper that calls the above in to `TransferInfoMapper['mapSwapTransfer']` - Update tests accordingly --- .../swap-transfer-info.mapper.spec.ts | 56 ++++++++--------- .../transfers/swap-transfer-info.mapper.ts | 17 ++---- .../transfers/transfer-info.mapper.spec.ts | 15 +++++ .../mappers/transfers/transfer-info.mapper.ts | 60 +++++++++++++++---- .../mappers/transfers/transfer.mapper.spec.ts | 18 +++++- 5 files changed, 108 insertions(+), 58 deletions(-) diff --git a/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.spec.ts b/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.spec.ts index eb53997169..a69a042198 100644 --- a/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.spec.ts +++ b/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.spec.ts @@ -3,7 +3,6 @@ import { orderBuilder } from '@/domain/swaps/entities/__tests__/order.builder'; import { OrdersSchema } from '@/domain/swaps/entities/order.entity'; import { ISwapsRepository } from '@/domain/swaps/swaps.repository'; import { tokenBuilder } from '@/domain/tokens/__tests__/token.builder'; -import { ILoggingService } from '@/logging/logging.interface'; import { addressInfoBuilder } from '@/routes/common/__tests__/entities/address-info.builder'; import { TransferDirection } from '@/routes/transactions/entities/transfer-transaction-info.entity'; import { Erc20Transfer } from '@/routes/transactions/entities/transfers/erc20-transfer.entity'; @@ -23,12 +22,6 @@ const mockSwapsRepository = jest.mocked({ getOrders: jest.fn(), } as jest.MockedObjectDeep); -const mockLoggingService = jest.mocked({ - debug: jest.fn(), - error: jest.fn(), - warn: jest.fn(), -} as jest.MockedObjectDeep); - describe('SwapTransferInfoMapper', () => { let target: SwapTransferInfoMapper; @@ -40,11 +33,10 @@ describe('SwapTransferInfoMapper', () => { target = new SwapTransferInfoMapper( mockSwapOrderHelper, mockSwapsRepository, - mockLoggingService, ); }); - it('it returns null if nether the sender and recipient are from the GPv2Settlement contract', async () => { + it('it throws if nether the sender and recipient are from the GPv2Settlement contract', async () => { const sender = addressInfoBuilder().build(); const recipient = addressInfoBuilder().build(); const direction = faker.helpers.arrayElement( @@ -71,17 +63,17 @@ describe('SwapTransferInfoMapper', () => { const order = orderBuilder().with('from', getAddress(sender.value)).build(); mockSwapsRepository.getOrders.mockResolvedValue([order]); - const actual = await target.mapSwapTransferInfo({ - sender, - recipient, - direction, - chainId, - safeAddress, - transferInfo, - domainTransfer, - }); - - expect(actual).toBe(null); + await expect( + target.mapSwapTransferInfo({ + sender, + recipient, + direction, + chainId, + safeAddress, + transferInfo, + domainTransfer, + }), + ).rejects.toThrow('Neither sender nor receiver are settlement contract'); }); it('maps the SwapTransferTransactionInfo if the sender is the GPv2Settlement contract', async () => { @@ -409,7 +401,7 @@ describe('SwapTransferInfoMapper', () => { }); }); - it('should return null if the app is not allowed', async () => { + it('should throw if the app is not allowed', async () => { /** * https://api.cow.fi/mainnet/api/v1/transactions/0x22fe458f3a70aaf83d42af2040f3b98404526b4ca588624e158c4b1f287ced8c/orders */ @@ -529,16 +521,16 @@ describe('SwapTransferInfoMapper', () => { ); mockSwapOrderHelper.isAppAllowed.mockReturnValue(false); - const actual = await target.mapSwapTransferInfo({ - sender, - recipient, - direction, - chainId, - safeAddress, - transferInfo, - domainTransfer, - }); - - expect(actual).toEqual(null); + await expect( + target.mapSwapTransferInfo({ + sender, + recipient, + direction, + chainId, + safeAddress, + transferInfo, + domainTransfer, + }), + ).rejects.toThrow('Unsupported App: CoW Swap'); }); }); diff --git a/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.ts b/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.ts index 24bf9c2a30..b912765f35 100644 --- a/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.ts +++ b/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.ts @@ -9,7 +9,6 @@ import { SwapTransferTransactionInfo } from '@/routes/transactions/swap-transfer import { getAddress, isAddressEqual } from 'viem'; import { ISwapsRepository } from '@/domain/swaps/swaps.repository'; import { Order } from '@/domain/swaps/entities/order.entity'; -import { LoggingService, ILoggingService } from '@/logging/logging.interface'; @Injectable() export class SwapTransferInfoMapper { @@ -17,7 +16,6 @@ export class SwapTransferInfoMapper { private readonly swapOrderHelper: SwapOrderHelper, @Inject(ISwapsRepository) private readonly swapsRepository: ISwapsRepository, - @Inject(LoggingService) private readonly loggingService: ILoggingService, ) {} /** @@ -40,13 +38,15 @@ export class SwapTransferInfoMapper { safeAddress: `0x${string}`; transferInfo: Transfer; domainTransfer: DomainTransfer; - }): Promise { + }): Promise { // If settlement contract is not interacted with, not a swap fulfillment + // TODO: Also check data is of `settle` call as otherwise _any_ call + // to settlement contract could be considered a swap fulfillment if ( !this.isSettlement(args.sender.value) && !this.isSettlement(args.recipient.value) ) { - return null; + throw new Error('Neither sender nor receiver are settlement contract'); } const orders = await this.swapsRepository.getOrders( @@ -58,16 +58,11 @@ export class SwapTransferInfoMapper { const order = this.findOrderByTransfer(orders, args.domainTransfer); if (!order) { - return null; + throw new Error('Transfer not found in order'); } - // TODO: Refactor with confirmation view, swaps and TWAPs if (!this.swapOrderHelper.isAppAllowed(order)) { - this.loggingService.warn( - `Unsupported App: ${order.fullAppData?.appCode}`, - ); - // Don't throw in order to fallback to "standard" transfer mapping - return null; + throw new Error(`Unsupported App: ${order.fullAppData?.appCode}`); } const [sellToken, buyToken] = await Promise.all([ diff --git a/src/routes/transactions/mappers/transfers/transfer-info.mapper.spec.ts b/src/routes/transactions/mappers/transfers/transfer-info.mapper.spec.ts index 66b273a125..a84eb1aa4f 100644 --- a/src/routes/transactions/mappers/transfers/transfer-info.mapper.spec.ts +++ b/src/routes/transactions/mappers/transfers/transfer-info.mapper.spec.ts @@ -18,6 +18,7 @@ import { TransferInfoMapper } from '@/routes/transactions/mappers/transfers/tran import { getAddress } from 'viem'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { SwapTransferInfoMapper } from '@/routes/transactions/mappers/transfers/swap-transfer-info.mapper'; +import { ILoggingService } from '@/logging/logging.interface'; const configurationService = jest.mocked({ getOrThrow: jest.fn(), @@ -36,6 +37,10 @@ const tokenRepository = jest.mocked({ getToken: jest.fn(), } as jest.MockedObjectDeep); +const mockLoggingService = jest.mocked({ + warn: jest.fn(), +} as jest.MockedObjectDeep); + describe('Transfer Info mapper (Unit)', () => { let mapper: TransferInfoMapper; @@ -46,6 +51,7 @@ describe('Transfer Info mapper (Unit)', () => { tokenRepository, swapTransferInfoMapper, addressInfoHelper, + mockLoggingService, ); }); @@ -63,6 +69,9 @@ describe('Transfer Info mapper (Unit)', () => { const actual = await mapper.mapTransferInfo(chainId, transfer, safe); expect(actual).toBeInstanceOf(TransferTransactionInfo); + if (!(actual instanceof TransferTransactionInfo)) { + throw new Error('Not a TransferTransactionInfo instance'); + } expect(actual.transferInfo).toBeInstanceOf(Erc20Transfer); expect(actual).toEqual( expect.objectContaining({ @@ -96,6 +105,9 @@ describe('Transfer Info mapper (Unit)', () => { const actual = await mapper.mapTransferInfo(chainId, transfer, safe); expect(actual).toBeInstanceOf(TransferTransactionInfo); + if (!(actual instanceof TransferTransactionInfo)) { + throw new Error('Not a TransferTransactionInfo instance'); + } expect(actual.transferInfo).toBeInstanceOf(Erc721Transfer); expect(actual).toEqual( expect.objectContaining({ @@ -126,6 +138,9 @@ describe('Transfer Info mapper (Unit)', () => { const actual = await mapper.mapTransferInfo(chainId, transfer, safe); expect(actual).toBeInstanceOf(TransferTransactionInfo); + if (!(actual instanceof TransferTransactionInfo)) { + throw new Error('Not a TransferTransactionInfo instance'); + } expect(actual.transferInfo).toBeInstanceOf(NativeCoinTransfer); expect(actual).toEqual( expect.objectContaining({ diff --git a/src/routes/transactions/mappers/transfers/transfer-info.mapper.ts b/src/routes/transactions/mappers/transfers/transfer-info.mapper.ts index 9be2cd6aba..89e2c039c1 100644 --- a/src/routes/transactions/mappers/transfers/transfer-info.mapper.ts +++ b/src/routes/transactions/mappers/transfers/transfer-info.mapper.ts @@ -5,7 +5,10 @@ import { Token } from '@/domain/tokens/entities/token.entity'; import { TokenRepository } from '@/domain/tokens/token.repository'; import { ITokenRepository } from '@/domain/tokens/token.repository.interface'; import { AddressInfoHelper } from '@/routes/common/address-info/address-info.helper'; -import { TransferTransactionInfo } from '@/routes/transactions/entities/transfer-transaction-info.entity'; +import { + TransferDirection, + TransferTransactionInfo, +} from '@/routes/transactions/entities/transfer-transaction-info.entity'; import { Erc20Transfer } from '@/routes/transactions/entities/transfers/erc20-transfer.entity'; import { Erc721Transfer } from '@/routes/transactions/entities/transfers/erc721-transfer.entity'; import { NativeCoinTransfer } from '@/routes/transactions/entities/transfers/native-coin-transfer.entity'; @@ -13,6 +16,9 @@ import { getTransferDirection } from '@/routes/transactions/mappers/common/trans import { Transfer } from '@/routes/transactions/entities/transfers/transfer.entity'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { SwapTransferInfoMapper } from '@/routes/transactions/mappers/transfers/swap-transfer-info.mapper'; +import { SwapTransferTransactionInfo } from '@/routes/transactions/swap-transfer-transaction-info.entity'; +import { AddressInfo } from '@/routes/common/entities/address-info.entity'; +import { LoggingService, ILoggingService } from '@/logging/logging.interface'; @Injectable() export class TransferInfoMapper { @@ -25,6 +31,7 @@ export class TransferInfoMapper { @Inject(ITokenRepository) private readonly tokenRepository: TokenRepository, private readonly swapTransferInfoMapper: SwapTransferInfoMapper, private readonly addressInfoHelper: AddressInfoHelper, + @Inject(LoggingService) private readonly loggingService: ILoggingService, ) { this.isSwapsDecodingEnabled = this.configurationService.getOrThrow( 'features.swapsDecoding', @@ -38,7 +45,7 @@ export class TransferInfoMapper { chainId: string, domainTransfer: DomainTransfer, safe: Safe, - ): Promise { + ): Promise { const { from, to } = domainTransfer; const [sender, recipient, transferInfo] = await Promise.all([ @@ -51,16 +58,15 @@ export class TransferInfoMapper { if (this.isSwapsDecodingEnabled && this.isTwapsDecodingEnabled) { // If the transaction is a swap-based transfer, we return it immediately - const swapTransfer = - await this.swapTransferInfoMapper.mapSwapTransferInfo({ - sender, - recipient, - direction, - transferInfo, - chainId, - safeAddress: safe.address, - domainTransfer, - }); + const swapTransfer = await this.mapSwapTransfer({ + sender, + recipient, + direction, + transferInfo, + chainId, + safeAddress: safe.address, + domainTransfer, + }); if (swapTransfer) { return swapTransfer; @@ -77,6 +83,36 @@ export class TransferInfoMapper { ); } + /** + * Maps a swap transfer transaction. + * If the transaction is not a swap transfer, it returns null. + * + * @param args.sender - {@link AddressInfo} sender of the transfer + * @param args.recipient - {@link AddressInfo} recipient of the transfer + * @param args.direction - {@link TransferDirection} of the transfer + * @param args.chainId - chain id of the transfer + * @param args.safeAddress - safe address of the transfer + * @param args.transferInfo - {@link Transfer} info + * @param args.domainTransfer - {@link DomainTransfer} domain transfer + */ + private async mapSwapTransfer(args: { + sender: AddressInfo; + recipient: AddressInfo; + direction: TransferDirection; + chainId: string; + safeAddress: `0x${string}`; + transferInfo: Transfer; + domainTransfer: DomainTransfer; + }): Promise { + try { + return await this.swapTransferInfoMapper.mapSwapTransferInfo(args); + } catch (error) { + // There were either issues mapping the swap transfer or it is a "normal" transfer + this.loggingService.warn(error); + return null; + } + } + private async getTransferByType( chainId: string, domainTransfer: DomainTransfer, diff --git a/src/routes/transactions/mappers/transfers/transfer.mapper.spec.ts b/src/routes/transactions/mappers/transfers/transfer.mapper.spec.ts index ceed5a5eff..7cac1d4dbb 100644 --- a/src/routes/transactions/mappers/transfers/transfer.mapper.spec.ts +++ b/src/routes/transactions/mappers/transfers/transfer.mapper.spec.ts @@ -11,6 +11,7 @@ import { import { tokenBuilder } from '@/domain/tokens/__tests__/token.builder'; import { TokenType } from '@/domain/tokens/entities/token.entity'; import { TokenRepository } from '@/domain/tokens/token.repository'; +import { ILoggingService } from '@/logging/logging.interface'; import { AddressInfoHelper } from '@/routes/common/address-info/address-info.helper'; import { AddressInfo } from '@/routes/common/entities/address-info.entity'; import { TokenInfo } from '@/routes/transactions/entities/swaps/token-info.entity'; @@ -44,6 +45,10 @@ const swapTransferInfoMapper = jest.mocked({ mapSwapTransferInfo: jest.fn(), } as jest.MockedObjectDeep); +const mockLoggingService = jest.mocked({ + warn: jest.fn(), +} as jest.MockedObjectDeep); + describe('Transfer mapper (Unit)', () => { let mapper: TransferMapper; @@ -55,6 +60,7 @@ describe('Transfer mapper (Unit)', () => { tokenRepository, swapTransferInfoMapper, addressInfoHelper, + mockLoggingService, ); mapper = new TransferMapper(transferInfoMapper); }); @@ -111,7 +117,9 @@ describe('Transfer mapper (Unit)', () => { const token = tokenBuilder() .with('address', getAddress(transfer.tokenAddress)) .build(); - swapTransferInfoMapper.mapSwapTransferInfo.mockResolvedValue(null); + swapTransferInfoMapper.mapSwapTransferInfo.mockRejectedValue( + 'Not settlement', + ); addressInfoHelper.getOrDefault.mockResolvedValue(addressInfo); tokenRepository.getToken.mockResolvedValue(token); @@ -153,7 +161,9 @@ describe('Transfer mapper (Unit)', () => { .with('address', getAddress(transfer.tokenAddress)) .with('trusted', true) .build(); - swapTransferInfoMapper.mapSwapTransferInfo.mockResolvedValue(null); + swapTransferInfoMapper.mapSwapTransferInfo.mockRejectedValue( + 'Not settlement', + ); addressInfoHelper.getOrDefault.mockResolvedValue(addressInfo); tokenRepository.getToken.mockResolvedValue(token); @@ -646,7 +656,9 @@ describe('Transfer mapper (Unit)', () => { const untrustedErc20TransferWithoutValue = erc20TransferBuilder() .with('value', '0') .build(); - swapTransferInfoMapper.mapSwapTransferInfo.mockResolvedValue(null); + swapTransferInfoMapper.mapSwapTransferInfo.mockRejectedValue( + 'Not settlement', + ); addressInfoHelper.getOrDefault.mockResolvedValue(addressInfo); tokenRepository.getToken .mockResolvedValueOnce(erc721Token) From e10d4d5c9bc6a21ee963c42c1bad6ca487682c70 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 8 Jul 2024 13:57:32 +0200 Subject: [PATCH 160/207] Improve tests for `decodeTwapStruct` (#1728) Adds a new encoders/builder for `createWithContext` calls, replacing static transaction data in TWAP-related tests: - Add relevant encoders/builder for encoding `createWithContext` transaction data - Use encoders/builder in `ComposableCowDecoder` test - Add test coverage for `createWithContext` calls with an invalid TWAP handler --- .../composable-cow-encoder.builder.ts | 115 ++++++++++++++++++ .../composable-cow-decoder.helper.spec.ts | 40 +++--- 2 files changed, 139 insertions(+), 16 deletions(-) create mode 100644 src/domain/swaps/contracts/__tests__/encoders/composable-cow-encoder.builder.ts diff --git a/src/domain/swaps/contracts/__tests__/encoders/composable-cow-encoder.builder.ts b/src/domain/swaps/contracts/__tests__/encoders/composable-cow-encoder.builder.ts new file mode 100644 index 0000000000..b22f83d50f --- /dev/null +++ b/src/domain/swaps/contracts/__tests__/encoders/composable-cow-encoder.builder.ts @@ -0,0 +1,115 @@ +import { Builder, IBuilder } from '@/__tests__/builder'; +import { IEncoder } from '@/__tests__/encoder-builder'; +import { fakeJson } from '@/__tests__/faker'; +import { ComposableCowAbi } from '@/domain/swaps/contracts/decoders/composable-cow-decoder.helper'; +import { faker } from '@faker-js/faker'; +import { + Hex, + encodeAbiParameters, + encodeFunctionData, + getAddress, + keccak256, + parseAbiParameters, + toHex, +} from 'viem'; + +type StaticInputArgs = { + sellToken: `0x${string}`; + buyToken: `0x${string}`; + receiver: `0x${string}`; + partSellAmount: bigint; + minPartLimit: bigint; + t0: bigint; + n: bigint; + t: bigint; + span: bigint; + appData: `0x${string}`; +}; + +class StaticInputEncoder + extends Builder + implements IEncoder +{ + encode(): Hex { + const args = this.build(); + + return encodeAbiParameters( + parseAbiParameters( + 'address sellToken, address buyToken, address receiver, uint256 partSellAmount, uint256 minPartLimit, uint256 t0, uint256 n, uint256 t, uint256 span, bytes32 appData', + ), + [ + args.sellToken, + args.buyToken, + args.receiver, + args.partSellAmount, + args.minPartLimit, + args.t0, + args.n, + args.t, + args.span, + args.appData, + ], + ); + } +} + +export function staticInputEncoder(): StaticInputEncoder { + return new StaticInputEncoder() + .with('sellToken', getAddress(faker.finance.ethereumAddress())) + .with('buyToken', getAddress(faker.finance.ethereumAddress())) + .with('receiver', getAddress(faker.finance.ethereumAddress())) + .with('partSellAmount', faker.number.bigInt()) + .with('minPartLimit', faker.number.bigInt()) + .with('t0', faker.number.bigInt()) + .with('n', faker.number.bigInt()) + .with('t', faker.number.bigInt()) + .with('span', faker.number.bigInt()) + .with('appData', keccak256(toHex(fakeJson()))); +} + +type ConditionalOrderParamsArgs = { + handler: `0x${string}`; + salt: `0x${string}`; + staticInput: `0x${string}`; +}; + +export function conditionalOrderParamsBuilder(): IBuilder { + return new Builder() + .with('handler', getAddress(faker.finance.ethereumAddress())) + .with('salt', faker.string.hexadecimal({ length: 64 }) as `0x${string}`) + .with('staticInput', staticInputEncoder().encode()); +} + +type CreateWithContextArgs = { + params: { + handler: `0x${string}`; + salt: `0x${string}`; + staticInput: `0x${string}`; + }; + factory: `0x${string}`; + calldata: `0x${string}`; + dispatch: boolean; +}; + +class CreateWithContextEncoder + extends Builder + implements IEncoder +{ + encode(): Hex { + const args = this.build(); + + return encodeFunctionData({ + abi: ComposableCowAbi, + functionName: 'createWithContext', + args: [args.params, args.factory, args.calldata, args.dispatch], + }); + } +} + +export function createWithContextEncoder(): CreateWithContextEncoder { + return new CreateWithContextEncoder() + .with('params', conditionalOrderParamsBuilder().build()) + .with('factory', getAddress(faker.finance.ethereumAddress())) + .with('calldata', faker.string.hexadecimal({ length: 64 }) as `0x${string}`) + .with('dispatch', faker.datatype.boolean()); +} diff --git a/src/domain/swaps/contracts/decoders/composable-cow-decoder.helper.spec.ts b/src/domain/swaps/contracts/decoders/composable-cow-decoder.helper.spec.ts index e4aca4dbc8..0e611908ca 100644 --- a/src/domain/swaps/contracts/decoders/composable-cow-decoder.helper.spec.ts +++ b/src/domain/swaps/contracts/decoders/composable-cow-decoder.helper.spec.ts @@ -1,3 +1,8 @@ +import { + conditionalOrderParamsBuilder, + createWithContextEncoder, + staticInputEncoder, +} from '@/domain/swaps/contracts/__tests__/encoders/composable-cow-encoder.builder'; import { ComposableCowDecoder } from '@/domain/swaps/contracts/decoders/composable-cow-decoder.helper'; describe('ComposableCowDecoder', () => { @@ -5,26 +10,29 @@ describe('ComposableCowDecoder', () => { describe('decodeTwapStruct', () => { it('should decode a createWithContext call', () => { - const data = - '0x0d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011f294a00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b1400000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000b941d039eed310b36000000000000000000000000000000000000000000000000087bbc924df9167e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000007080000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000'; + const staticInput = staticInputEncoder(); + const conditionalOrderParams = conditionalOrderParamsBuilder() + .with('staticInput', staticInput.encode()) + // TWAP handler address + .with('handler', '0x6cF1e9cA41f7611dEf408122793c358a3d11E5a5') + .build(); + const createWithContext = createWithContextEncoder().with( + 'params', + conditionalOrderParams, + ); + const data = createWithContext.encode(); const result = target.decodeTwapStruct(data); - expect(result).toStrictEqual({ - appData: - '0xf7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda0', - buyToken: '0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14', - minPartLimit: BigInt('611289510998251134'), - n: BigInt('2'), - partSellAmount: BigInt('213586875483862141750'), - receiver: '0x31eaC7F0141837B266De30f4dc9aF15629Bd5381', - sellToken: '0xbe72E441BF55620febc26715db68d3494213D8Cb', - span: BigInt('0'), - t: BigInt('1800'), - t0: BigInt('0'), - }); + expect(result).toStrictEqual(staticInput.build()); }); - it.todo('should throw if TWAP handler is invalid'); + it('should throw if TWAP handler is invalid', () => { + const data = createWithContextEncoder().encode(); + + expect(() => target.decodeTwapStruct(data)).toThrow( + 'Invalid TWAP handler', + ); + }); }); }); From e47f01625c776925dd8933f37c7735dd583dd421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Mon, 8 Jul 2024 15:49:56 +0200 Subject: [PATCH 161/207] Refactor test dbFactory to avoid race conditions (#1735) - Changes the dbFactory for TestDbFactory, which spins off a main test database, and exposes functions to create and drop transient databases. - Calls the create/drop test database TestDbFactory functions on the beforeAll/afterAll hooks of the existent persistence-related test classes. --- migrations/__tests__/00001_accounts.spec.ts | 17 +++-- .../00002_account-data-types.spec.ts | 17 +++-- migrations/__tests__/_all.spec.ts | 18 ++++- src/__tests__/db.factory.ts | 74 +++++++++++++------ .../accounts/accounts.datasource.spec.ts | 19 +++-- .../db/postgres-database.migrator.spec.ts | 24 +++--- 6 files changed, 111 insertions(+), 58 deletions(-) diff --git a/migrations/__tests__/00001_accounts.spec.ts b/migrations/__tests__/00001_accounts.spec.ts index 16353c94c2..15dff626d7 100644 --- a/migrations/__tests__/00001_accounts.spec.ts +++ b/migrations/__tests__/00001_accounts.spec.ts @@ -1,6 +1,7 @@ -import { dbFactory } from '@/__tests__/db.factory'; +import { TestDbFactory } from '@/__tests__/db.factory'; import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; -import { Sql } from 'postgres'; +import { faker } from '@faker-js/faker'; +import postgres, { Sql } from 'postgres'; interface AccountRow { id: number; @@ -11,11 +12,17 @@ interface AccountRow { } describe('Migration 00001_accounts', () => { - const sql = dbFactory(); - const migrator = new PostgresDatabaseMigrator(sql); + let sql: postgres.Sql; + let migrator: PostgresDatabaseMigrator; + const testDbFactory = new TestDbFactory(); + + beforeAll(async () => { + sql = await testDbFactory.createTestDatabase(faker.string.uuid()); + migrator = new PostgresDatabaseMigrator(sql); + }); afterAll(async () => { - await sql.end(); + await testDbFactory.destroyTestDatabase(sql); }); it('runs successfully', async () => { diff --git a/migrations/__tests__/00002_account-data-types.spec.ts b/migrations/__tests__/00002_account-data-types.spec.ts index ff9ceaa9c7..c1deb2d76e 100644 --- a/migrations/__tests__/00002_account-data-types.spec.ts +++ b/migrations/__tests__/00002_account-data-types.spec.ts @@ -1,6 +1,7 @@ -import { dbFactory } from '@/__tests__/db.factory'; +import { TestDbFactory } from '@/__tests__/db.factory'; import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; -import { Sql } from 'postgres'; +import { faker } from '@faker-js/faker'; +import postgres, { Sql } from 'postgres'; interface AccountDataTypeRow { id: number; @@ -12,11 +13,17 @@ interface AccountDataTypeRow { } describe('Migration 00002_account-data-types', () => { - const sql = dbFactory(); - const migrator = new PostgresDatabaseMigrator(sql); + let sql: postgres.Sql; + let migrator: PostgresDatabaseMigrator; + const testDbFactory = new TestDbFactory(); + + beforeAll(async () => { + sql = await testDbFactory.createTestDatabase(faker.string.uuid()); + migrator = new PostgresDatabaseMigrator(sql); + }); afterAll(async () => { - await sql.end(); + await testDbFactory.destroyTestDatabase(sql); }); it('runs successfully', async () => { diff --git a/migrations/__tests__/_all.spec.ts b/migrations/__tests__/_all.spec.ts index 31f8d88544..2538644f9b 100644 --- a/migrations/__tests__/_all.spec.ts +++ b/migrations/__tests__/_all.spec.ts @@ -1,9 +1,21 @@ -import { dbFactory } from '@/__tests__/db.factory'; +import { TestDbFactory } from '@/__tests__/db.factory'; import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; +import { faker } from '@faker-js/faker'; +import postgres from 'postgres'; describe('Migrations', () => { - const sql = dbFactory(); - const migrator = new PostgresDatabaseMigrator(sql); + let sql: postgres.Sql; + let migrator: PostgresDatabaseMigrator; + const testDbFactory = new TestDbFactory(); + + beforeAll(async () => { + sql = await testDbFactory.createTestDatabase(faker.string.uuid()); + migrator = new PostgresDatabaseMigrator(sql); + }); + + afterAll(async () => { + await testDbFactory.destroyTestDatabase(sql); + }); it('run successfully', async () => { await expect(migrator.migrate()).resolves.not.toThrow(); diff --git a/src/__tests__/db.factory.ts b/src/__tests__/db.factory.ts index 78da019570..649cf45d79 100644 --- a/src/__tests__/db.factory.ts +++ b/src/__tests__/db.factory.ts @@ -3,28 +3,56 @@ import fs from 'node:fs'; import path from 'node:path'; import configuration from '@/config/entities/__tests__/configuration'; -export function dbFactory(): postgres.Sql { - const config = configuration(); - const isCIContext = process.env.CI?.toLowerCase() === 'true'; +export class TestDbFactory { + private static readonly TEST_CERTIFICATE_PATH = path.join( + process.cwd(), + 'db_config/test/server.crt', + ); + private readonly config = configuration(); + private readonly isCIContext = process.env.CI?.toLowerCase() === 'true'; + private readonly mainConnection: postgres.Sql; - return postgres({ - host: config.db.postgres.host, - port: parseInt(config.db.postgres.port), - db: config.db.postgres.database, - user: config.db.postgres.username, - password: config.db.postgres.password, - // If running on a CI context (e.g.: GitHub Actions), - // disable certificate pinning for the test execution - ssl: - isCIContext || !config.db.postgres.ssl.enabled - ? false - : { - requestCert: config.db.postgres.ssl.requestCert, - rejectUnauthorized: config.db.postgres.ssl.rejectUnauthorized, - ca: fs.readFileSync( - path.join(process.cwd(), 'db_config/test/server.crt'), - 'utf8', - ), - }, - }); + constructor() { + this.mainConnection = this.connect(this.config.db.postgres.database); + } + + async createTestDatabase(dbName: string): Promise { + await this.mainConnection`create database ${this.mainConnection(dbName)}`; + return this.connect(dbName); + } + + async destroyTestDatabase(database: postgres.Sql): Promise { + await database.end(); + await this + .mainConnection`drop database ${this.mainConnection(database.options.database)} with (force)`; + await this.mainConnection.end(); + } + + /** + * Connect to the database pointed by the `dbName` parameter. + * + * If running on a CI context (e.g.: GitHub Actions), + * certificate pinning is disabled for the test execution. + * + * @param dbName - database name + * @returns {@link postgres.Sql} pointing to the database + */ + private connect(dbName: string): postgres.Sql { + const { host, port, username, password, ssl } = this.config.db.postgres; + const sslEnabled = !this.isCIContext && ssl.enabled; + return postgres({ + host, + port: parseInt(port), + db: dbName, + user: username, + password, + ssl: sslEnabled + ? { + requestCert: ssl.requestCert, + rejectUnauthorized: ssl.rejectUnauthorized, + ca: fs.readFileSync(TestDbFactory.TEST_CERTIFICATE_PATH, 'utf8'), + } + : false, + }); + } } diff --git a/src/datasources/accounts/accounts.datasource.spec.ts b/src/datasources/accounts/accounts.datasource.spec.ts index 4c26e0a343..20ce134edc 100644 --- a/src/datasources/accounts/accounts.datasource.spec.ts +++ b/src/datasources/accounts/accounts.datasource.spec.ts @@ -1,13 +1,11 @@ -import { dbFactory } from '@/__tests__/db.factory'; +import { TestDbFactory } from '@/__tests__/db.factory'; import { AccountsDatasource } from '@/datasources/accounts/accounts.datasource'; +import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; import { ILoggingService } from '@/logging/logging.interface'; import { faker } from '@faker-js/faker'; -import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; +import postgres from 'postgres'; import { getAddress } from 'viem'; -const sql = dbFactory(); -const migrator = new PostgresDatabaseMigrator(sql); - const mockLoggingService = { debug: jest.fn(), info: jest.fn(), @@ -16,13 +14,14 @@ const mockLoggingService = { describe('AccountsDatasource tests', () => { let target: AccountsDatasource; + let migrator: PostgresDatabaseMigrator; + let sql: postgres.Sql; + const testDbFactory = new TestDbFactory(); - // Run pending migrations before tests beforeAll(async () => { + sql = await testDbFactory.createTestDatabase(faker.string.uuid()); + migrator = new PostgresDatabaseMigrator(sql); await migrator.migrate(); - }); - - beforeEach(() => { target = new AccountsDatasource(sql, mockLoggingService); }); @@ -31,7 +30,7 @@ describe('AccountsDatasource tests', () => { }); afterAll(async () => { - await sql.end(); + await testDbFactory.destroyTestDatabase(sql); }); describe('createAccount', () => { diff --git a/src/datasources/db/postgres-database.migrator.spec.ts b/src/datasources/db/postgres-database.migrator.spec.ts index c7dd34459c..1488d47b02 100644 --- a/src/datasources/db/postgres-database.migrator.spec.ts +++ b/src/datasources/db/postgres-database.migrator.spec.ts @@ -1,8 +1,9 @@ -import { dbFactory } from '@/__tests__/db.factory'; -import postgres from 'postgres'; -import path from 'node:path'; -import fs from 'node:fs'; +import { TestDbFactory } from '@/__tests__/db.factory'; import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; +import { faker } from '@faker-js/faker'; +import fs from 'node:fs'; +import path from 'node:path'; +import postgres from 'postgres'; const folder = path.join(__dirname, 'migrations'); const migrations: Array<{ @@ -51,19 +52,18 @@ type ExtendedTestRow = { a: string; b: number; c: Date }; describe('PostgresDatabaseMigrator tests', () => { let sql: postgres.Sql; let target: PostgresDatabaseMigrator; + const testDbFactory = new TestDbFactory(); - beforeEach(() => { - sql = dbFactory(); + beforeAll(async () => { + sql = await testDbFactory.createTestDatabase(faker.string.uuid()); target = new PostgresDatabaseMigrator(sql); }); - afterEach(async () => { - // Drop example table after each test - await sql`drop table if exists test`; - - // Close connection after each test - await sql.end(); + afterAll(async () => { + await testDbFactory.destroyTestDatabase(sql); + }); + afterEach(() => { // Remove migrations folder after each test fs.rmSync(folder, { recursive: true, force: true }); }); From 75f440e715d1a4445ae0c1417a9eff6c2c41d848 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jul 2024 23:32:32 +0200 Subject: [PATCH 162/207] Bump docker/setup-qemu-action from 3.0.0 to 3.1.0 (#1740) Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3.0.0 to 3.1.0. - [Release notes](https://github.com/docker/setup-qemu-action/releases) - [Commits](https://github.com/docker/setup-qemu-action/compare/v3.0.0...v3.1.0) --- updated-dependencies: - dependency-name: docker/setup-qemu-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32dcc4fcb4..82d0f1d914 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,7 +120,7 @@ jobs: - run: | BUILD_NUMBER=${{ github.sha }} echo "BUILD_NUMBER=${BUILD_NUMBER::7}" >> "$GITHUB_ENV" - - uses: docker/setup-qemu-action@v3.0.0 + - uses: docker/setup-qemu-action@v3.1.0 with: platforms: arm64 - uses: docker/setup-buildx-action@v3 @@ -149,7 +149,7 @@ jobs: - run: | BUILD_NUMBER=${{ github.sha }} echo "BUILD_NUMBER=${BUILD_NUMBER::7}" >> "$GITHUB_ENV" - - uses: docker/setup-qemu-action@v3.0.0 + - uses: docker/setup-qemu-action@v3.1.0 with: platforms: arm64 - uses: docker/setup-buildx-action@v3 From 774ea513dfe576a9d21130090f4b1bb749d09b78 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jul 2024 23:32:54 +0200 Subject: [PATCH 163/207] Bump @nestjs/cli from 10.3.2 to 10.4.2 (#1741) Bumps [@nestjs/cli](https://github.com/nestjs/nest-cli) from 10.3.2 to 10.4.2. - [Release notes](https://github.com/nestjs/nest-cli/releases) - [Changelog](https://github.com/nestjs/nest-cli/blob/master/.release-it.json) - [Commits](https://github.com/nestjs/nest-cli/compare/10.3.2...10.4.2) --- updated-dependencies: - dependency-name: "@nestjs/cli" dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 550 ++++++++++++++++++++++++--------------------------- 2 files changed, 263 insertions(+), 289 deletions(-) diff --git a/package.json b/package.json index 390a0da1f7..00bf9364dd 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "test:all:cov": "jest --coverage --config ./test/jest-all.json" }, "dependencies": { - "@nestjs/cli": "^10.3.2", + "@nestjs/cli": "^10.4.2", "@nestjs/common": "^10.3.10", "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.3.10", diff --git a/yarn.lock b/yarn.lock index f23c858d45..9cc76fed52 100644 --- a/yarn.lock +++ b/yarn.lock @@ -77,19 +77,38 @@ __metadata: languageName: node linkType: hard -"@angular-devkit/schematics-cli@npm:17.1.2": - version: 17.1.2 - resolution: "@angular-devkit/schematics-cli@npm:17.1.2" +"@angular-devkit/core@npm:17.3.8": + version: 17.3.8 + resolution: "@angular-devkit/core@npm:17.3.8" dependencies: - "@angular-devkit/core": "npm:17.1.2" - "@angular-devkit/schematics": "npm:17.1.2" + ajv: "npm:8.12.0" + ajv-formats: "npm:2.1.1" + jsonc-parser: "npm:3.2.1" + picomatch: "npm:4.0.1" + rxjs: "npm:7.8.1" + source-map: "npm:0.7.4" + peerDependencies: + chokidar: ^3.5.2 + peerDependenciesMeta: + chokidar: + optional: true + checksum: 10/0119001e98950773db1a130d9011528135d9778dc6fe4a5058d9ad4501582f754f26b2ae516e0e8e96c69e9671c9fe31566c656ed87f1b493c2c29568503c098 + languageName: node + linkType: hard + +"@angular-devkit/schematics-cli@npm:17.3.8": + version: 17.3.8 + resolution: "@angular-devkit/schematics-cli@npm:17.3.8" + dependencies: + "@angular-devkit/core": "npm:17.3.8" + "@angular-devkit/schematics": "npm:17.3.8" ansi-colors: "npm:4.1.3" - inquirer: "npm:9.2.12" + inquirer: "npm:9.2.15" symbol-observable: "npm:4.0.0" yargs-parser: "npm:21.1.1" bin: schematics: bin/schematics.js - checksum: 10/0fd6145fb13e59986056257041236a412edab0707c7763c3f8044466d0f52898b05b0fa0f39d5d115e5811f2c695c8f7ab0fcb868e51b4a798c1f8b6c51b72de + checksum: 10/15f28628dad015f3159c4c2d32673dd663a72348e720367b37b838ee5e1f79c76317d4271a2d4d9943233935211724c3f471e6232b754a8c527f96174eed9951 languageName: node linkType: hard @@ -119,6 +138,19 @@ __metadata: languageName: node linkType: hard +"@angular-devkit/schematics@npm:17.3.8": + version: 17.3.8 + resolution: "@angular-devkit/schematics@npm:17.3.8" + dependencies: + "@angular-devkit/core": "npm:17.3.8" + jsonc-parser: "npm:3.2.1" + magic-string: "npm:0.30.8" + ora: "npm:5.4.1" + rxjs: "npm:7.8.1" + checksum: 10/d5419504f36ff4a13c9769372a7aeb6dfa3bc3f843f446ad58ffb2a56e5c82fede68dbd73027f6d1fe6a91817ce302d01cec0065b168e0d19be699046db0209e + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.16.7, @babel/code-frame@npm:^7.18.6": version: 7.18.6 resolution: "@babel/code-frame@npm:7.18.6" @@ -1164,12 +1196,12 @@ __metadata: languageName: node linkType: hard -"@ljharb/through@npm:^2.3.11": - version: 2.3.12 - resolution: "@ljharb/through@npm:2.3.12" +"@ljharb/through@npm:^2.3.12": + version: 2.3.13 + resolution: "@ljharb/through@npm:2.3.13" dependencies: - call-bind: "npm:^1.0.5" - checksum: 10/e1bd9b3a068d6a5886e0116ac34a13ec161d21088f65c5ca17beb141382af2358b46be9e359e6585496e9e61a4390839386dc78f5656e54e89a95c373b9eacfb + call-bind: "npm:^1.0.7" + checksum: 10/6150c6c43a726d52c26863ed6dc4ab54fa7cf625c81463a5ddec86278c99e23bf94dfc99ebf09a9ac3191332d4a27344e092f7e07f252b8cd600e2b38e645870 languageName: node linkType: hard @@ -1187,34 +1219,31 @@ __metadata: languageName: node linkType: hard -"@nestjs/cli@npm:^10.3.2": - version: 10.3.2 - resolution: "@nestjs/cli@npm:10.3.2" +"@nestjs/cli@npm:^10.4.2": + version: 10.4.2 + resolution: "@nestjs/cli@npm:10.4.2" dependencies: - "@angular-devkit/core": "npm:17.1.2" - "@angular-devkit/schematics": "npm:17.1.2" - "@angular-devkit/schematics-cli": "npm:17.1.2" + "@angular-devkit/core": "npm:17.3.8" + "@angular-devkit/schematics": "npm:17.3.8" + "@angular-devkit/schematics-cli": "npm:17.3.8" "@nestjs/schematics": "npm:^10.0.1" chalk: "npm:4.1.2" chokidar: "npm:3.6.0" - cli-table3: "npm:0.6.3" + cli-table3: "npm:0.6.5" commander: "npm:4.1.1" fork-ts-checker-webpack-plugin: "npm:9.0.2" - glob: "npm:10.3.10" + glob: "npm:10.4.2" inquirer: "npm:8.2.6" node-emoji: "npm:1.11.0" ora: "npm:5.4.1" - rimraf: "npm:4.4.1" - shelljs: "npm:0.8.5" - source-map-support: "npm:0.5.21" tree-kill: "npm:1.2.2" tsconfig-paths: "npm:4.2.0" tsconfig-paths-webpack-plugin: "npm:4.1.0" typescript: "npm:5.3.3" - webpack: "npm:5.90.1" + webpack: "npm:5.92.1" webpack-node-externals: "npm:3.0.0" peerDependencies: - "@swc/cli": ^0.1.62 || ^0.3.0 + "@swc/cli": ^0.1.62 || ^0.3.0 || ^0.4.0 "@swc/core": ^1.3.62 peerDependenciesMeta: "@swc/cli": @@ -1223,7 +1252,7 @@ __metadata: optional: true bin: nest: bin/nest.js - checksum: 10/fef0719e22fd9ed8f68f792aac10d75b9a5420395ecf9cc8614b1a8631f92d86c519ac2e798250a405ac13bdcd4747a9ef93ea294adc1f29f3e26f54e2d3fc0e + checksum: 10/20587526f460c9d696a0637354f746efe272547b570321119411bef7298471cd157f384f9097ae529f70e9e67e094ce5d2112e360198502b561cbec91bc271b6 languageName: node linkType: hard @@ -2124,13 +2153,13 @@ __metadata: languageName: node linkType: hard -"@webassemblyjs/ast@npm:1.11.6, @webassemblyjs/ast@npm:^1.11.5": - version: 1.11.6 - resolution: "@webassemblyjs/ast@npm:1.11.6" +"@webassemblyjs/ast@npm:1.12.1, @webassemblyjs/ast@npm:^1.12.1": + version: 1.12.1 + resolution: "@webassemblyjs/ast@npm:1.12.1" dependencies: "@webassemblyjs/helper-numbers": "npm:1.11.6" "@webassemblyjs/helper-wasm-bytecode": "npm:1.11.6" - checksum: 10/4c1303971ccd5188731c9b01073d9738333f37b946a48c4e049f7b788706cdc66f473cd6f3e791423a94c52a3b2230d070007930d29bccbce238b23835839f3c + checksum: 10/a775b0559437ae122d14fec0cfe59fdcaf5ca2d8ff48254014fd05d6797e20401e0f1518e628f9b06819aa085834a2534234977f9608b3f2e51f94b6e8b0bc43 languageName: node linkType: hard @@ -2148,10 +2177,10 @@ __metadata: languageName: node linkType: hard -"@webassemblyjs/helper-buffer@npm:1.11.6": - version: 1.11.6 - resolution: "@webassemblyjs/helper-buffer@npm:1.11.6" - checksum: 10/b14d0573bf680d22b2522e8a341ec451fddd645d1f9c6bd9012ccb7e587a2973b86ab7b89fe91e1c79939ba96095f503af04369a3b356c8023c13a5893221644 +"@webassemblyjs/helper-buffer@npm:1.12.1": + version: 1.12.1 + resolution: "@webassemblyjs/helper-buffer@npm:1.12.1" + checksum: 10/1d8705daa41f4d22ef7c6d422af4c530b84d69d0c253c6db5adec44d511d7caa66837803db5b1addcea611a1498fd5a67d2cf318b057a916283ae41ffb85ba8a languageName: node linkType: hard @@ -2173,15 +2202,15 @@ __metadata: languageName: node linkType: hard -"@webassemblyjs/helper-wasm-section@npm:1.11.6": - version: 1.11.6 - resolution: "@webassemblyjs/helper-wasm-section@npm:1.11.6" +"@webassemblyjs/helper-wasm-section@npm:1.12.1": + version: 1.12.1 + resolution: "@webassemblyjs/helper-wasm-section@npm:1.12.1" dependencies: - "@webassemblyjs/ast": "npm:1.11.6" - "@webassemblyjs/helper-buffer": "npm:1.11.6" + "@webassemblyjs/ast": "npm:1.12.1" + "@webassemblyjs/helper-buffer": "npm:1.12.1" "@webassemblyjs/helper-wasm-bytecode": "npm:1.11.6" - "@webassemblyjs/wasm-gen": "npm:1.11.6" - checksum: 10/38a615ab3d55f953daaf78b69f145e2cc1ff5288ab71715d1a164408b735c643a87acd7e7ba3e9633c5dd965439a45bb580266b05a06b22ff678d6c013514108 + "@webassemblyjs/wasm-gen": "npm:1.12.1" + checksum: 10/e91e6b28114e35321934070a2db8973a08a5cd9c30500b817214c683bbf5269ed4324366dd93ad83bf2fba0d671ac8f39df1c142bf58f70c57a827eeba4a3d2f languageName: node linkType: hard @@ -2210,68 +2239,68 @@ __metadata: languageName: node linkType: hard -"@webassemblyjs/wasm-edit@npm:^1.11.5": - version: 1.11.6 - resolution: "@webassemblyjs/wasm-edit@npm:1.11.6" +"@webassemblyjs/wasm-edit@npm:^1.12.1": + version: 1.12.1 + resolution: "@webassemblyjs/wasm-edit@npm:1.12.1" dependencies: - "@webassemblyjs/ast": "npm:1.11.6" - "@webassemblyjs/helper-buffer": "npm:1.11.6" + "@webassemblyjs/ast": "npm:1.12.1" + "@webassemblyjs/helper-buffer": "npm:1.12.1" "@webassemblyjs/helper-wasm-bytecode": "npm:1.11.6" - "@webassemblyjs/helper-wasm-section": "npm:1.11.6" - "@webassemblyjs/wasm-gen": "npm:1.11.6" - "@webassemblyjs/wasm-opt": "npm:1.11.6" - "@webassemblyjs/wasm-parser": "npm:1.11.6" - "@webassemblyjs/wast-printer": "npm:1.11.6" - checksum: 10/c168bfc6d0cdd371345f36f95a4766d098a96ccc1257e6a6e3a74d987a5c4f2ddd2244a6aecfa5d032a47d74ed2c3b579e00a314d31e4a0b76ad35b31cdfa162 + "@webassemblyjs/helper-wasm-section": "npm:1.12.1" + "@webassemblyjs/wasm-gen": "npm:1.12.1" + "@webassemblyjs/wasm-opt": "npm:1.12.1" + "@webassemblyjs/wasm-parser": "npm:1.12.1" + "@webassemblyjs/wast-printer": "npm:1.12.1" + checksum: 10/5678ae02dbebba2f3a344e25928ea5a26a0df777166c9be77a467bfde7aca7f4b57ef95587e4bd768a402cdf2fddc4c56f0a599d164cdd9fe313520e39e18137 languageName: node linkType: hard -"@webassemblyjs/wasm-gen@npm:1.11.6": - version: 1.11.6 - resolution: "@webassemblyjs/wasm-gen@npm:1.11.6" +"@webassemblyjs/wasm-gen@npm:1.12.1": + version: 1.12.1 + resolution: "@webassemblyjs/wasm-gen@npm:1.12.1" dependencies: - "@webassemblyjs/ast": "npm:1.11.6" + "@webassemblyjs/ast": "npm:1.12.1" "@webassemblyjs/helper-wasm-bytecode": "npm:1.11.6" "@webassemblyjs/ieee754": "npm:1.11.6" "@webassemblyjs/leb128": "npm:1.11.6" "@webassemblyjs/utf8": "npm:1.11.6" - checksum: 10/f91903506ce50763592863df5d80ffee80f71a1994a882a64cdb83b5e44002c715f1ef1727d8ccb0692d066af34d3d4f5e59e8f7a4e2eeb2b7c32692ac44e363 + checksum: 10/ec45bd50e86bc9856f80fe9af4bc1ae5c98fb85f57023d11dff2b670da240c47a7b1b9b6c89755890314212bd167cf3adae7f1157216ddffb739a4ce589fc338 languageName: node linkType: hard -"@webassemblyjs/wasm-opt@npm:1.11.6": - version: 1.11.6 - resolution: "@webassemblyjs/wasm-opt@npm:1.11.6" +"@webassemblyjs/wasm-opt@npm:1.12.1": + version: 1.12.1 + resolution: "@webassemblyjs/wasm-opt@npm:1.12.1" dependencies: - "@webassemblyjs/ast": "npm:1.11.6" - "@webassemblyjs/helper-buffer": "npm:1.11.6" - "@webassemblyjs/wasm-gen": "npm:1.11.6" - "@webassemblyjs/wasm-parser": "npm:1.11.6" - checksum: 10/e0cfeea381ecbbd0ca1616e9a08974acfe7fc81f8a16f9f2d39f565dc51784dd7043710b6e972f9968692d273e32486b9a8a82ca178d4bd520b2d5e2cf28234d + "@webassemblyjs/ast": "npm:1.12.1" + "@webassemblyjs/helper-buffer": "npm:1.12.1" + "@webassemblyjs/wasm-gen": "npm:1.12.1" + "@webassemblyjs/wasm-parser": "npm:1.12.1" + checksum: 10/21f25ae109012c49bb084e09f3b67679510429adc3e2408ad3621b2b505379d9cce337799a7919ef44db64e0d136833216914aea16b0d4856f353b9778e0cdb7 languageName: node linkType: hard -"@webassemblyjs/wasm-parser@npm:1.11.6, @webassemblyjs/wasm-parser@npm:^1.11.5": - version: 1.11.6 - resolution: "@webassemblyjs/wasm-parser@npm:1.11.6" +"@webassemblyjs/wasm-parser@npm:1.12.1, @webassemblyjs/wasm-parser@npm:^1.12.1": + version: 1.12.1 + resolution: "@webassemblyjs/wasm-parser@npm:1.12.1" dependencies: - "@webassemblyjs/ast": "npm:1.11.6" + "@webassemblyjs/ast": "npm:1.12.1" "@webassemblyjs/helper-api-error": "npm:1.11.6" "@webassemblyjs/helper-wasm-bytecode": "npm:1.11.6" "@webassemblyjs/ieee754": "npm:1.11.6" "@webassemblyjs/leb128": "npm:1.11.6" "@webassemblyjs/utf8": "npm:1.11.6" - checksum: 10/6995e0b7b8ebc52b381459c6a555f87763dcd3975c4a112407682551e1c73308db7af23385972a253dceb5af94e76f9c97cb861e8239b5ed1c3e79b95d8e2097 + checksum: 10/f7311685b76c3e1def2abea3488be1e77f06ecd8633143a6c5c943ca289660952b73785231bb76a010055ca64645227a4bc79705c26ab7536216891b6bb36320 languageName: node linkType: hard -"@webassemblyjs/wast-printer@npm:1.11.6": - version: 1.11.6 - resolution: "@webassemblyjs/wast-printer@npm:1.11.6" +"@webassemblyjs/wast-printer@npm:1.12.1": + version: 1.12.1 + resolution: "@webassemblyjs/wast-printer@npm:1.12.1" dependencies: - "@webassemblyjs/ast": "npm:1.11.6" + "@webassemblyjs/ast": "npm:1.12.1" "@xtuc/long": "npm:4.2.2" - checksum: 10/fd45fd0d693141d678cc2f6ff2d3a0d7a8884acb1c92fb0c63cf43b7978e9560be04118b12792638a39dd185640453510229e736f3049037d0c361f6435f2d5f + checksum: 10/1a6a4b6bc4234f2b5adbab0cb11a24911b03380eb1cab6fb27a2250174a279fdc6aa2f5a9cf62dd1f6d4eb39f778f488e8ff15b9deb0670dee5c5077d46cf572 languageName: node linkType: hard @@ -2321,12 +2350,12 @@ __metadata: languageName: node linkType: hard -"acorn-import-assertions@npm:^1.9.0": - version: 1.9.0 - resolution: "acorn-import-assertions@npm:1.9.0" +"acorn-import-attributes@npm:^1.9.5": + version: 1.9.5 + resolution: "acorn-import-attributes@npm:1.9.5" peerDependencies: acorn: ^8 - checksum: 10/af8dd58f6b0c6a43e85849744534b99f2133835c6fcdabda9eea27d0a0da625a0d323c4793ba7cb25cf4507609d0f747c210ccc2fc9b5866de04b0e59c9c5617 + checksum: 10/8bfbfbb6e2467b9b47abb4d095df717ab64fce2525da65eabee073e85e7975fb3a176b6c8bba17c99a7d8ede283a10a590272304eb54a93c4aa1af9790d47a8b languageName: node linkType: hard @@ -2931,14 +2960,16 @@ __metadata: languageName: node linkType: hard -"call-bind@npm:^1.0.5": - version: 1.0.5 - resolution: "call-bind@npm:1.0.5" +"call-bind@npm:^1.0.7": + version: 1.0.7 + resolution: "call-bind@npm:1.0.7" dependencies: + es-define-property: "npm:^1.0.0" + es-errors: "npm:^1.3.0" function-bind: "npm:^1.1.2" - get-intrinsic: "npm:^1.2.1" - set-function-length: "npm:^1.1.1" - checksum: 10/246d44db6ef9bbd418828dbd5337f80b46be4398d522eded015f31554cbb2ea33025b0203b75c7ab05a1a255b56ef218880cca1743e4121e306729f9e414da39 + get-intrinsic: "npm:^1.2.4" + set-function-length: "npm:^1.2.1" + checksum: 10/cd6fe658e007af80985da5185bff7b55e12ef4c2b6f41829a26ed1eef254b1f1c12e3dfd5b2b068c6ba8b86aba62390842d81752e67dcbaec4f6f76e7113b6b7 languageName: node linkType: hard @@ -3108,16 +3139,16 @@ __metadata: languageName: node linkType: hard -"cli-table3@npm:0.6.3": - version: 0.6.3 - resolution: "cli-table3@npm:0.6.3" +"cli-table3@npm:0.6.5": + version: 0.6.5 + resolution: "cli-table3@npm:0.6.5" dependencies: "@colors/colors": "npm:1.5.0" string-width: "npm:^4.2.0" dependenciesMeta: "@colors/colors": optional: true - checksum: 10/8d82b75be7edc7febb1283dc49582a521536527cba80af62a2e4522a0ee39c252886a1a2f02d05ae9d753204dbcffeb3a40d1358ee10dccd7fe8d935cfad3f85 + checksum: 10/8dca71256f6f1367bab84c33add3f957367c7c43750a9828a4212ebd31b8df76bd7419d386e3391ac7419698a8540c25f1a474584028f35b170841cde2e055c5 languageName: node linkType: hard @@ -3523,14 +3554,14 @@ __metadata: languageName: node linkType: hard -"define-data-property@npm:^1.1.1": - version: 1.1.1 - resolution: "define-data-property@npm:1.1.1" +"define-data-property@npm:^1.1.4": + version: 1.1.4 + resolution: "define-data-property@npm:1.1.4" dependencies: - get-intrinsic: "npm:^1.2.1" + es-define-property: "npm:^1.0.0" + es-errors: "npm:^1.3.0" gopd: "npm:^1.0.1" - has-property-descriptors: "npm:^1.0.0" - checksum: 10/5573c8df96b5857408cad64d9b91b69152e305ce4b06218e5f49b59c6cafdbb90a8bd8a0bb83c7bc67a8d479c04aa697063c9bc28d849b7282f9327586d6bc7b + checksum: 10/abdcb2505d80a53524ba871273e5da75e77e52af9e15b3aa65d8aad82b8a3a424dad7aee2cc0b71470ac7acf501e08defac362e8b6a73cdb4309f028061df4ae languageName: node linkType: hard @@ -3721,13 +3752,13 @@ __metadata: languageName: node linkType: hard -"enhanced-resolve@npm:^5.15.0": - version: 5.15.0 - resolution: "enhanced-resolve@npm:5.15.0" +"enhanced-resolve@npm:^5.17.0": + version: 5.17.0 + resolution: "enhanced-resolve@npm:5.17.0" dependencies: graceful-fs: "npm:^4.2.4" tapable: "npm:^2.2.0" - checksum: 10/180c3f2706f9117bf4dc7982e1df811dad83a8db075723f299245ef4488e0cad7e96859c5f0e410682d28a4ecd4da021ec7d06265f7e4eb6eed30c69ca5f7d3e + checksum: 10/8f7bf71537d78e7d20a27363793f2c9e13ec44800c7c7830364a448f80a44994aa19d64beecefa1ab49e4de6f7fbe18cc0931dc449c115f02918ff5fcbe7705f languageName: node linkType: hard @@ -3754,6 +3785,22 @@ __metadata: languageName: node linkType: hard +"es-define-property@npm:^1.0.0": + version: 1.0.0 + resolution: "es-define-property@npm:1.0.0" + dependencies: + get-intrinsic: "npm:^1.2.4" + checksum: 10/f66ece0a887b6dca71848fa71f70461357c0e4e7249696f81bad0a1f347eed7b31262af4a29f5d726dc026426f085483b6b90301855e647aa8e21936f07293c6 + languageName: node + linkType: hard + +"es-errors@npm:^1.3.0": + version: 1.3.0 + resolution: "es-errors@npm:1.3.0" + checksum: 10/96e65d640156f91b707517e8cdc454dd7d47c32833aa3e85d79f24f9eb7ea85f39b63e36216ef0114996581969b59fe609a94e30316b08f5f4df1d44134cf8d5 + languageName: node + linkType: hard + "es-module-lexer@npm:^1.2.1": version: 1.3.0 resolution: "es-module-lexer@npm:1.3.0" @@ -3796,13 +3843,6 @@ __metadata: languageName: node linkType: hard -"escape-string-regexp@npm:^5.0.0": - version: 5.0.0 - resolution: "escape-string-regexp@npm:5.0.0" - checksum: 10/20daabe197f3cb198ec28546deebcf24b3dbb1a5a269184381b3116d12f0532e06007f4bc8da25669d6a7f8efb68db0758df4cd981f57bc5b57f521a3e12c59e - languageName: node - linkType: hard - "eslint-config-prettier@npm:^9.1.0": version: 9.1.0 resolution: "eslint-config-prettier@npm:9.1.0" @@ -4139,7 +4179,7 @@ __metadata: languageName: node linkType: hard -"figures@npm:^3.0.0": +"figures@npm:^3.0.0, figures@npm:^3.2.0": version: 3.2.0 resolution: "figures@npm:3.2.0" dependencies: @@ -4148,16 +4188,6 @@ __metadata: languageName: node linkType: hard -"figures@npm:^5.0.0": - version: 5.0.0 - resolution: "figures@npm:5.0.0" - dependencies: - escape-string-regexp: "npm:^5.0.0" - is-unicode-supported: "npm:^1.2.0" - checksum: 10/951d18be2f450c90462c484eff9bda705293319bc2f17b250194a0cf1a291600db4cb283a6ce199d49380c95b08d85d822ce4b18d2f9242663fd5895476d667c - languageName: node - linkType: hard - "file-entry-cache@npm:^8.0.0": version: 8.0.0 resolution: "file-entry-cache@npm:8.0.0" @@ -4419,7 +4449,7 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.2": +"get-intrinsic@npm:^1.1.3": version: 1.2.2 resolution: "get-intrinsic@npm:1.2.2" dependencies: @@ -4431,6 +4461,19 @@ __metadata: languageName: node linkType: hard +"get-intrinsic@npm:^1.2.4": + version: 1.2.4 + resolution: "get-intrinsic@npm:1.2.4" + dependencies: + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + has-proto: "npm:^1.0.1" + has-symbols: "npm:^1.0.3" + hasown: "npm:^2.0.0" + checksum: 10/85bbf4b234c3940edf8a41f4ecbd4e25ce78e5e6ad4e24ca2f77037d983b9ef943fd72f00f3ee97a49ec622a506b67db49c36246150377efcda1c9eb03e5f06d + languageName: node + linkType: hard + "get-package-type@npm:^0.1.0": version: 0.1.0 resolution: "get-package-type@npm:0.1.0" @@ -4470,22 +4513,23 @@ __metadata: languageName: node linkType: hard -"glob@npm:10.3.10": - version: 10.3.10 - resolution: "glob@npm:10.3.10" +"glob@npm:10.4.2": + version: 10.4.2 + resolution: "glob@npm:10.4.2" dependencies: foreground-child: "npm:^3.1.0" - jackspeak: "npm:^2.3.5" - minimatch: "npm:^9.0.1" - minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0" - path-scurry: "npm:^1.10.1" + jackspeak: "npm:^3.1.2" + minimatch: "npm:^9.0.4" + minipass: "npm:^7.1.2" + package-json-from-dist: "npm:^1.0.0" + path-scurry: "npm:^1.11.1" bin: glob: dist/esm/bin.mjs - checksum: 10/38bdb2c9ce75eb5ed168f309d4ed05b0798f640b637034800a6bf306f39d35409bf278b0eaaffaec07591085d3acb7184a201eae791468f0f617771c2486a6a8 + checksum: 10/e412776b5952a818eba790c830bea161c9a56813fd767d8c4c49f855603b1fb962b3e73f1f627a47298a57d2992b9f0f2fe15cf93e74ecaaa63fb45d63fdd090 languageName: node linkType: hard -"glob@npm:^7.0.0, glob@npm:^7.1.3, glob@npm:^7.1.4": +"glob@npm:^7.1.3, glob@npm:^7.1.4": version: 7.2.3 resolution: "glob@npm:7.2.3" dependencies: @@ -4512,18 +4556,6 @@ __metadata: languageName: node linkType: hard -"glob@npm:^9.2.0": - version: 9.3.5 - resolution: "glob@npm:9.3.5" - dependencies: - fs.realpath: "npm:^1.0.0" - minimatch: "npm:^8.0.2" - minipass: "npm:^4.2.4" - path-scurry: "npm:^1.6.1" - checksum: 10/e5fa8a58adf53525bca42d82a1fad9e6800032b7e4d372209b80cfdca524dd9a7dbe7d01a92d7ed20d89c572457f12c250092bc8817cb4f1c63efefdf9b658c0 - languageName: node - linkType: hard - "globals@npm:^11.1.0": version: 11.12.0 resolution: "globals@npm:11.12.0" @@ -4568,6 +4600,13 @@ __metadata: languageName: node linkType: hard +"graceful-fs@npm:^4.2.11": + version: 4.2.11 + resolution: "graceful-fs@npm:4.2.11" + checksum: 10/bf152d0ed1dc159239db1ba1f74fdbc40cb02f626770dcd5815c427ce0688c2635a06ed69af364396da4636d0408fcf7d4afdf7881724c3307e46aff30ca49e2 + languageName: node + linkType: hard + "graphemer@npm:^1.4.0": version: 1.4.0 resolution: "graphemer@npm:1.4.0" @@ -4596,12 +4635,12 @@ __metadata: languageName: node linkType: hard -"has-property-descriptors@npm:^1.0.0, has-property-descriptors@npm:^1.0.1": - version: 1.0.1 - resolution: "has-property-descriptors@npm:1.0.1" +"has-property-descriptors@npm:^1.0.2": + version: 1.0.2 + resolution: "has-property-descriptors@npm:1.0.2" dependencies: - get-intrinsic: "npm:^1.2.2" - checksum: 10/21a47bb080a24e79594aef1ce71e1a18a1c5ab4120308e218088f67ebb7f6f408847541e2d96e5bd00e90eef5c5a49e4ebbdc8fc2d5b365a2c379aef071642f0 + es-define-property: "npm:^1.0.0" + checksum: 10/2d8c9ab8cebb572e3362f7d06139a4592105983d4317e68f7adba320fe6ddfc8874581e0971e899e633fd5f72e262830edce36d5a0bc863dad17ad20572484b2 languageName: node linkType: hard @@ -4846,17 +4885,17 @@ __metadata: languageName: node linkType: hard -"inquirer@npm:9.2.12": - version: 9.2.12 - resolution: "inquirer@npm:9.2.12" +"inquirer@npm:9.2.15": + version: 9.2.15 + resolution: "inquirer@npm:9.2.15" dependencies: - "@ljharb/through": "npm:^2.3.11" + "@ljharb/through": "npm:^2.3.12" ansi-escapes: "npm:^4.3.2" chalk: "npm:^5.3.0" cli-cursor: "npm:^3.1.0" cli-width: "npm:^4.1.0" external-editor: "npm:^3.1.0" - figures: "npm:^5.0.0" + figures: "npm:^3.2.0" lodash: "npm:^4.17.21" mute-stream: "npm:1.0.0" ora: "npm:^5.4.1" @@ -4865,14 +4904,7 @@ __metadata: string-width: "npm:^4.2.3" strip-ansi: "npm:^6.0.1" wrap-ansi: "npm:^6.2.0" - checksum: 10/02b259c641fd6c6b88c0c530aced23389d586bd5360799bab0ae11d2a965ac5ce9c587402faefad70f08b8b56ccae56fb973da3a8deb6e17ba4577f803d427c5 - languageName: node - linkType: hard - -"interpret@npm:^1.0.0": - version: 1.4.0 - resolution: "interpret@npm:1.4.0" - checksum: 10/5beec568d3f60543d0f61f2c5969d44dffcb1a372fe5abcdb8013968114d4e4aaac06bc971a4c9f5bd52d150881d8ebad72a8c60686b1361f5f0522f39c0e1a3 + checksum: 10/7bca66f54fc3ef511e4be4ed781ef975325ad3a3e5ebeb4d070af78bba37966068a21db53fadac89ba808f19fd2fd88149c80cf6bcfd7e7fbc358fd0127a74f9 languageName: node linkType: hard @@ -4994,13 +5026,6 @@ __metadata: languageName: node linkType: hard -"is-unicode-supported@npm:^1.2.0": - version: 1.3.0 - resolution: "is-unicode-supported@npm:1.3.0" - checksum: 10/20a1fc161afafaf49243551a5ac33b6c4cf0bbcce369fcd8f2951fbdd000c30698ce320de3ee6830497310a8f41880f8066d440aa3eb0a853e2aa4836dd89abc - languageName: node - linkType: hard - "isarray@npm:0.0.1": version: 0.0.1 resolution: "isarray@npm:0.0.1" @@ -5103,16 +5128,16 @@ __metadata: languageName: node linkType: hard -"jackspeak@npm:^2.3.5": - version: 2.3.6 - resolution: "jackspeak@npm:2.3.6" +"jackspeak@npm:^3.1.2": + version: 3.4.1 + resolution: "jackspeak@npm:3.4.1" dependencies: "@isaacs/cliui": "npm:^8.0.2" "@pkgjs/parseargs": "npm:^0.11.0" dependenciesMeta: "@pkgjs/parseargs": optional: true - checksum: 10/6e6490d676af8c94a7b5b29b8fd5629f21346911ebe2e32931c2a54210134408171c24cee1a109df2ec19894ad04a429402a8438cbf5cc2794585d35428ace76 + checksum: 10/73225d15b5d1eb3b882bec6e88d956c91ba954afe6a23f1f5e95494c04b68e5b3636f3fe304c8a416e1e68bef7e664a9e6c4ae7e77be8f502b5d16a9ecdcfe00 languageName: node linkType: hard @@ -5942,6 +5967,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^10.2.0": + version: 10.4.0 + resolution: "lru-cache@npm:10.4.0" + checksum: 10/5073575d9afa20eced8a776d7ac20d79d4ef10bde984faa2b67fb685a94bb014d554951b2d475cf7cdaa0da749170ba91b67144a72f571907eb417a80c649884 + languageName: node + linkType: hard + "lru-cache@npm:^6.0.0": version: 6.0.0 resolution: "lru-cache@npm:6.0.0" @@ -5958,20 +5990,6 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:^9.1.1": - version: 9.1.2 - resolution: "lru-cache@npm:9.1.2" - checksum: 10/8830ad333f5202656e712d40df16a4dbd373a489821c1f22d5dc2b3cf49820734cf814e7fd89bbda80ecb32e1bfd716e2dc2d78fae0dd7b55fe65ffd0158edd7 - languageName: node - linkType: hard - -"lru-cache@npm:^9.1.1 || ^10.0.0": - version: 10.0.1 - resolution: "lru-cache@npm:10.0.1" - checksum: 10/5bb91a97a342a41fd049c3494b44d9e21a7d4843f9284d0a0b26f00bb0e436f1f627d0641c78f88be16b86b4231546c5ee4f284733fb530c7960f0bcd7579026 - languageName: node - linkType: hard - "magic-string@npm:0.30.0": version: 0.30.0 resolution: "magic-string@npm:0.30.0" @@ -5990,6 +6008,15 @@ __metadata: languageName: node linkType: hard +"magic-string@npm:0.30.8": + version: 0.30.8 + resolution: "magic-string@npm:0.30.8" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.4.15" + checksum: 10/72ab63817af600e92c19dc8489c1aa4a9599da00cfd59b2319709bd48fb0cf533fdf354bf140ac86e598dbd63e6b2cc83647fe8448f864a3eb6061c62c94e784 + languageName: node + linkType: hard + "make-dir@npm:^3.0.0": version: 3.1.0 resolution: "make-dir@npm:3.1.0" @@ -6152,24 +6179,6 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^8.0.2": - version: 8.0.4 - resolution: "minimatch@npm:8.0.4" - dependencies: - brace-expansion: "npm:^2.0.1" - checksum: 10/aef05598ee565e1013bc8a10f53410ac681561f901c1a084b8ecfd016c9ed919f58f4bbd5b63e05643189dfb26e8106a84f0e1ff12e4a263aa37e1cae7ce9828 - languageName: node - linkType: hard - -"minimatch@npm:^9.0.1": - version: 9.0.1 - resolution: "minimatch@npm:9.0.1" - dependencies: - brace-expansion: "npm:^2.0.1" - checksum: 10/b4e98f4dc740dcf33999a99af23ae6e5e1c47632f296dc95cb649a282150f92378d41434bf64af4ea2e5975255a757d031c3bf014bad9214544ac57d97f3ba63 - languageName: node - linkType: hard - "minimatch@npm:^9.0.4": version: 9.0.4 resolution: "minimatch@npm:9.0.4" @@ -6246,13 +6255,6 @@ __metadata: languageName: node linkType: hard -"minipass@npm:^4.2.4": - version: 4.2.8 - resolution: "minipass@npm:4.2.8" - checksum: 10/e148eb6dcb85c980234cad889139ef8ddf9d5bdac534f4f0268446c8792dd4c74f4502479be48de3c1cce2f6450f6da4d0d4a86405a8a12be04c1c36b339569a - languageName: node - linkType: hard - "minipass@npm:^5.0.0": version: 5.0.0 resolution: "minipass@npm:5.0.0" @@ -6260,13 +6262,6 @@ __metadata: languageName: node linkType: hard -"minipass@npm:^5.0.0 || ^6.0.2": - version: 6.0.2 - resolution: "minipass@npm:6.0.2" - checksum: 10/d2c0baa39570233002b184840065e5f8abb9f6dda45fd486a0b133896d9749de810966f0b2487af623b84ac4cf05df9156656124c2549858df2b27c18750da2b - languageName: node - linkType: hard - "minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0": version: 7.0.4 resolution: "minipass@npm:7.0.4" @@ -6274,6 +6269,13 @@ __metadata: languageName: node linkType: hard +"minipass@npm:^7.1.2": + version: 7.1.2 + resolution: "minipass@npm:7.1.2" + checksum: 10/c25f0ee8196d8e6036661104bacd743785b2599a21de5c516b32b3fa2b83113ac89a2358465bc04956baab37ffb956ae43be679b2262bf7be15fce467ccd7950 + languageName: node + linkType: hard + "minizlib@npm:^2.1.1, minizlib@npm:^2.1.2": version: 2.1.2 resolution: "minizlib@npm:2.1.2" @@ -6637,6 +6639,13 @@ __metadata: languageName: node linkType: hard +"package-json-from-dist@npm:^1.0.0": + version: 1.0.0 + resolution: "package-json-from-dist@npm:1.0.0" + checksum: 10/ac706ec856a5a03f5261e4e48fa974f24feb044d51f84f8332e2af0af04fbdbdd5bbbfb9cbbe354190409bc8307c83a9e38c6672c3c8855f709afb0006a009ea + languageName: node + linkType: hard + "parent-module@npm:^1.0.0": version: 1.0.1 resolution: "parent-module@npm:1.0.1" @@ -6693,23 +6702,13 @@ __metadata: languageName: node linkType: hard -"path-scurry@npm:^1.10.1": - version: 1.10.1 - resolution: "path-scurry@npm:1.10.1" +"path-scurry@npm:^1.11.1": + version: 1.11.1 + resolution: "path-scurry@npm:1.11.1" dependencies: - lru-cache: "npm:^9.1.1 || ^10.0.0" + lru-cache: "npm:^10.2.0" minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0" - checksum: 10/eebfb8304fef1d4f7e1486df987e4fd77413de4fce16508dea69fcf8eb318c09a6b15a7a2f4c22877cec1cb7ecbd3071d18ca9de79eeece0df874a00f1f0bdc8 - languageName: node - linkType: hard - -"path-scurry@npm:^1.6.1": - version: 1.9.2 - resolution: "path-scurry@npm:1.9.2" - dependencies: - lru-cache: "npm:^9.1.1" - minipass: "npm:^5.0.0 || ^6.0.2" - checksum: 10/b3d05922e26f36999a9a92a79f4c0b0437ea075896cad1a4c7d3f54ae26c1a5ef022627b87b2561bbd82e6f67500f26bb82eadc63549155bd8cc6b0d030e9b76 + checksum: 10/5e8845c159261adda6f09814d7725683257fcc85a18f329880ab4d7cc1d12830967eae5d5894e453f341710d5484b8fdbbd4d75181b4d6e1eb2f4dc7aeadc434 languageName: node linkType: hard @@ -6755,6 +6754,13 @@ __metadata: languageName: node linkType: hard +"picomatch@npm:4.0.1": + version: 4.0.1 + resolution: "picomatch@npm:4.0.1" + checksum: 10/d5005bb1b4021260826d17f64666848bbdea2f449dbf97dd2df384d3253aac43a550f658a855a7a4fa6a2a88f36a832daa008ee3f71fe0bf10990c2672349c76 + languageName: node + linkType: hard + "picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.2.3, picomatch@npm:^2.3.1": version: 2.3.1 resolution: "picomatch@npm:2.3.1" @@ -7009,15 +7015,6 @@ __metadata: languageName: node linkType: hard -"rechoir@npm:^0.6.2": - version: 0.6.2 - resolution: "rechoir@npm:0.6.2" - dependencies: - resolve: "npm:^1.1.6" - checksum: 10/fe76bf9c21875ac16e235defedd7cbd34f333c02a92546142b7911a0f7c7059d2e16f441fe6fb9ae203f459c05a31b2bcf26202896d89e390eda7514d5d2702b - languageName: node - linkType: hard - "redis@npm:^4.6.14": version: 4.6.14 resolution: "redis@npm:4.6.14" @@ -7097,7 +7094,7 @@ __metadata: languageName: node linkType: hard -"resolve@npm:^1.1.6, resolve@npm:^1.20.0": +"resolve@npm:^1.20.0": version: 1.22.1 resolution: "resolve@npm:1.22.1" dependencies: @@ -7110,7 +7107,7 @@ __metadata: languageName: node linkType: hard -"resolve@patch:resolve@npm%3A^1.1.6#optional!builtin, resolve@patch:resolve@npm%3A^1.20.0#optional!builtin": +"resolve@patch:resolve@npm%3A^1.20.0#optional!builtin": version: 1.22.1 resolution: "resolve@patch:resolve@npm%3A1.22.1#optional!builtin::version=1.22.1&hash=c3c19d" dependencies: @@ -7147,17 +7144,6 @@ __metadata: languageName: node linkType: hard -"rimraf@npm:4.4.1": - version: 4.4.1 - resolution: "rimraf@npm:4.4.1" - dependencies: - glob: "npm:^9.2.0" - bin: - rimraf: dist/cjs/src/bin.js - checksum: 10/218ef9122145ccce9d0a71124d36a3894537de46600b37fae7dba26ccff973251eaa98aa63c2c5855a05fa04bca7cbbd7a92d4b29f2875d2203e72530ecf6ede - languageName: node - linkType: hard - "rimraf@npm:^3.0.2": version: 3.0.2 resolution: "rimraf@npm:3.0.2" @@ -7229,7 +7215,7 @@ __metadata: resolution: "safe-client-gateway@workspace:." dependencies: "@faker-js/faker": "npm:^8.4.1" - "@nestjs/cli": "npm:^10.3.2" + "@nestjs/cli": "npm:^10.4.2" "@nestjs/common": "npm:^10.3.10" "@nestjs/config": "npm:^3.2.3" "@nestjs/core": "npm:^10.3.10" @@ -7392,16 +7378,17 @@ __metadata: languageName: node linkType: hard -"set-function-length@npm:^1.1.1": - version: 1.2.0 - resolution: "set-function-length@npm:1.2.0" +"set-function-length@npm:^1.2.1": + version: 1.2.2 + resolution: "set-function-length@npm:1.2.2" dependencies: - define-data-property: "npm:^1.1.1" + define-data-property: "npm:^1.1.4" + es-errors: "npm:^1.3.0" function-bind: "npm:^1.1.2" - get-intrinsic: "npm:^1.2.2" + get-intrinsic: "npm:^1.2.4" gopd: "npm:^1.0.1" - has-property-descriptors: "npm:^1.0.1" - checksum: 10/6d609cd060c488d7d2178a5d4c3689f8a6afa26fa4c48ff4a0516664ff9b84c1c0898915777f5628092dab55c4fcead205525e2edd15c659423bf86f790fdcae + has-property-descriptors: "npm:^1.0.2" + checksum: 10/505d62b8e088468917ca4e3f8f39d0e29f9a563b97dbebf92f4bd2c3172ccfb3c5b8e4566d5fcd00784a00433900e7cb8fbc404e2dbd8c3818ba05bb9d4a8a6d languageName: node linkType: hard @@ -7428,19 +7415,6 @@ __metadata: languageName: node linkType: hard -"shelljs@npm:0.8.5": - version: 0.8.5 - resolution: "shelljs@npm:0.8.5" - dependencies: - glob: "npm:^7.0.0" - interpret: "npm:^1.0.0" - rechoir: "npm:^0.6.2" - bin: - shjs: bin/shjs - checksum: 10/f2178274b97b44332bbe9ddb78161137054f55ecf701c7a99db9552cb5478fe279ad5f5131d8a7c2f0730e01ccf0c629d01094143f0541962ce1a3d0243d23f7 - languageName: node - linkType: hard - "side-channel@npm:^1.0.4": version: 1.0.4 resolution: "side-channel@npm:1.0.4" @@ -7527,7 +7501,7 @@ __metadata: languageName: node linkType: hard -"source-map-support@npm:0.5.21, source-map-support@npm:^0.5.20, source-map-support@npm:~0.5.20": +"source-map-support@npm:^0.5.20, source-map-support@npm:~0.5.20": version: 0.5.21 resolution: "source-map-support@npm:0.5.21" dependencies: @@ -8321,13 +8295,13 @@ __metadata: languageName: node linkType: hard -"watchpack@npm:^2.4.0": - version: 2.4.0 - resolution: "watchpack@npm:2.4.0" +"watchpack@npm:^2.4.1": + version: 2.4.1 + resolution: "watchpack@npm:2.4.1" dependencies: glob-to-regexp: "npm:^0.4.1" graceful-fs: "npm:^4.1.2" - checksum: 10/4280b45bc4b5d45d5579113f2a4af93b67ae1b9607cc3d86ae41cdd53ead10db5d9dc3237f24256d05ef88b28c69a02712f78e434cb7ecc8edaca134a56e8cab + checksum: 10/0736ebd20b75d3931f9b6175c819a66dee29297c1b389b2e178bc53396a6f867ecc2fd5d87a713ae92dcb73e487daec4905beee20ca00a9e27f1184a7c2bca5e languageName: node linkType: hard @@ -8361,25 +8335,25 @@ __metadata: languageName: node linkType: hard -"webpack@npm:5.90.1": - version: 5.90.1 - resolution: "webpack@npm:5.90.1" +"webpack@npm:5.92.1": + version: 5.92.1 + resolution: "webpack@npm:5.92.1" dependencies: "@types/eslint-scope": "npm:^3.7.3" "@types/estree": "npm:^1.0.5" - "@webassemblyjs/ast": "npm:^1.11.5" - "@webassemblyjs/wasm-edit": "npm:^1.11.5" - "@webassemblyjs/wasm-parser": "npm:^1.11.5" + "@webassemblyjs/ast": "npm:^1.12.1" + "@webassemblyjs/wasm-edit": "npm:^1.12.1" + "@webassemblyjs/wasm-parser": "npm:^1.12.1" acorn: "npm:^8.7.1" - acorn-import-assertions: "npm:^1.9.0" + acorn-import-attributes: "npm:^1.9.5" browserslist: "npm:^4.21.10" chrome-trace-event: "npm:^1.0.2" - enhanced-resolve: "npm:^5.15.0" + enhanced-resolve: "npm:^5.17.0" es-module-lexer: "npm:^1.2.1" eslint-scope: "npm:5.1.1" events: "npm:^3.2.0" glob-to-regexp: "npm:^0.4.1" - graceful-fs: "npm:^4.2.9" + graceful-fs: "npm:^4.2.11" json-parse-even-better-errors: "npm:^2.3.1" loader-runner: "npm:^4.2.0" mime-types: "npm:^2.1.27" @@ -8387,14 +8361,14 @@ __metadata: schema-utils: "npm:^3.2.0" tapable: "npm:^2.1.1" terser-webpack-plugin: "npm:^5.3.10" - watchpack: "npm:^2.4.0" + watchpack: "npm:^2.4.1" webpack-sources: "npm:^3.2.3" peerDependenciesMeta: webpack-cli: optional: true bin: webpack: bin/webpack.js - checksum: 10/6ad23518123f1742238177920cefa61152d981f986adac5901236845c86ba9bb375a3ba75e188925c856c3d2a76a2ba119e95b8a608a51424968389041089075 + checksum: 10/76fcfbebcc0719c4734c65a01dcef7a0f18f3f2647484e8a7e8606adbd128ac42756bb3a8b7e2d486fe97f6286ebdc7b937ccdf3cf1d21b4684134bb89bbed89 languageName: node linkType: hard From 9d515e1b52a1b53b28337ee8dadb758e46c03138 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jul 2024 23:33:31 +0200 Subject: [PATCH 164/207] Bump viem from 2.16.5 to 2.17.3 (#1742) Bumps [viem](https://github.com/wevm/viem) from 2.16.5 to 2.17.3. - [Release notes](https://github.com/wevm/viem/releases) - [Commits](https://github.com/wevm/viem/compare/viem@2.16.5...viem@2.17.3) --- updated-dependencies: - dependency-name: viem dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 00bf9364dd..5a3ae9d1b0 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "semver": "^7.6.2", - "viem": "^2.16.5", + "viem": "^2.17.3", "winston": "^3.13.0", "zod": "^3.23.8" }, diff --git a/yarn.lock b/yarn.lock index 9cc76fed52..3e6ac13e61 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7258,7 +7258,7 @@ __metadata: tsconfig-paths: "npm:4.2.0" typescript: "npm:^5.5.2" typescript-eslint: "npm:^7.15.0" - viem: "npm:^2.16.5" + viem: "npm:^2.17.3" winston: "npm:^3.13.0" zod: "npm:^3.23.8" languageName: unknown @@ -8265,9 +8265,9 @@ __metadata: languageName: node linkType: hard -"viem@npm:^2.16.5": - version: 2.16.5 - resolution: "viem@npm:2.16.5" +"viem@npm:^2.17.3": + version: 2.17.3 + resolution: "viem@npm:2.17.3" dependencies: "@adraffy/ens-normalize": "npm:1.10.0" "@noble/curves": "npm:1.4.0" @@ -8282,7 +8282,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/7314dacb72203cfe177e22e28563f370aefac30bfc37a0f0934709ea58da0e8353c4b0f506d507eee292224840d2876a6d0b1c2d50033404e68f37ce213a48f1 + checksum: 10/803b49f7932fd59058f41202682610a0dd767b908cdc7777ef20c62108587a069e23e5ba8fc3d278443620b057571931503dc4e1ff257d1b7fd7f2411199de8b languageName: node linkType: hard From 3302fb76701fb076c7ab088d6108b3b6454cbe18 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jul 2024 23:38:30 +0200 Subject: [PATCH 165/207] Bump @safe-global/safe-deployments from 1.37.0 to 1.37.1 (#1745) Bumps [@safe-global/safe-deployments](https://github.com/safe-global/safe-deployments) from 1.37.0 to 1.37.1. - [Release notes](https://github.com/safe-global/safe-deployments/releases) - [Commits](https://github.com/safe-global/safe-deployments/compare/v1.37.0...v1.37.1) --- updated-dependencies: - dependency-name: "@safe-global/safe-deployments" dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 5a3ae9d1b0..e91c33935f 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "@nestjs/platform-express": "^10.3.10", "@nestjs/serve-static": "^4.0.2", "@nestjs/swagger": "^7.3.1", - "@safe-global/safe-deployments": "^1.37.0", + "@safe-global/safe-deployments": "^1.37.1", "amqp-connection-manager": "^4.1.14", "amqplib": "^0.10.4", "cookie-parser": "^1.4.6", diff --git a/yarn.lock b/yarn.lock index 3e6ac13e61..d8e6633a7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1599,12 +1599,12 @@ __metadata: languageName: node linkType: hard -"@safe-global/safe-deployments@npm:^1.37.0": - version: 1.37.0 - resolution: "@safe-global/safe-deployments@npm:1.37.0" +"@safe-global/safe-deployments@npm:^1.37.1": + version: 1.37.1 + resolution: "@safe-global/safe-deployments@npm:1.37.1" dependencies: - semver: "npm:^7.6.0" - checksum: 10/99304d3b67d564ca014d8d2d2a0773319a53ccc89846c05464353699864f229f35615bcfc8e08f13acbd099b5670d54885309c02f914c7eda75b2cf93e145350 + semver: "npm:^7.6.2" + checksum: 10/2cb05ad0d1768264885d9e92d1fcc1340af77f88605ac46fd99b8aa4118d923671e4bb3d380e3dd7caa13ca4e8ed6edae4765dd5394b78966a51f391375d683b languageName: node linkType: hard @@ -7224,7 +7224,7 @@ __metadata: "@nestjs/serve-static": "npm:^4.0.2" "@nestjs/swagger": "npm:^7.3.1" "@nestjs/testing": "npm:^10.3.10" - "@safe-global/safe-deployments": "npm:^1.37.0" + "@safe-global/safe-deployments": "npm:^1.37.1" "@types/amqplib": "npm:^0" "@types/cookie-parser": "npm:^1.4.7" "@types/express": "npm:^4.17.21" From 74a3b97513de16459d127a59554cbe221e3e85e7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jul 2024 23:40:01 +0200 Subject: [PATCH 166/207] Bump @nestjs/schematics from 10.1.1 to 10.1.2 (#1744) Bumps [@nestjs/schematics](https://github.com/nestjs/schematics) from 10.1.1 to 10.1.2. - [Release notes](https://github.com/nestjs/schematics/releases) - [Changelog](https://github.com/nestjs/schematics/blob/master/.release-it.json) - [Commits](https://github.com/nestjs/schematics/compare/10.1.1...10.1.2) --- updated-dependencies: - dependency-name: "@nestjs/schematics" dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 71 +++++++++++----------------------------------------- 2 files changed, 16 insertions(+), 57 deletions(-) diff --git a/package.json b/package.json index e91c33935f..8170b791de 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ }, "devDependencies": { "@faker-js/faker": "^8.4.1", - "@nestjs/schematics": "^10.1.1", + "@nestjs/schematics": "^10.1.2", "@nestjs/testing": "^10.3.10", "@types/amqplib": "^0", "@types/cookie-parser": "^1.4.7", diff --git a/yarn.lock b/yarn.lock index d8e6633a7b..958ef3d686 100644 --- a/yarn.lock +++ b/yarn.lock @@ -58,25 +58,6 @@ __metadata: languageName: node linkType: hard -"@angular-devkit/core@npm:17.1.2": - version: 17.1.2 - resolution: "@angular-devkit/core@npm:17.1.2" - dependencies: - ajv: "npm:8.12.0" - ajv-formats: "npm:2.1.1" - jsonc-parser: "npm:3.2.0" - picomatch: "npm:3.0.1" - rxjs: "npm:7.8.1" - source-map: "npm:0.7.4" - peerDependencies: - chokidar: ^3.5.2 - peerDependenciesMeta: - chokidar: - optional: true - checksum: 10/c32bb3a32b158b007f894ae7c865fa6618456b154d0abaa3442be2c3acad2c5777eee602c0482f2ee8de96a793b6b6b116f3af73f5f638c76b197a6e7e71bb93 - languageName: node - linkType: hard - "@angular-devkit/core@npm:17.3.8": version: 17.3.8 resolution: "@angular-devkit/core@npm:17.3.8" @@ -125,19 +106,6 @@ __metadata: languageName: node linkType: hard -"@angular-devkit/schematics@npm:17.1.2": - version: 17.1.2 - resolution: "@angular-devkit/schematics@npm:17.1.2" - dependencies: - "@angular-devkit/core": "npm:17.1.2" - jsonc-parser: "npm:3.2.0" - magic-string: "npm:0.30.5" - ora: "npm:5.4.1" - rxjs: "npm:7.8.1" - checksum: 10/478a3801d49da73b2beee694fa240c5acb7cf2d0adbadf46796f146b7524b8e730501190eb3afa848baed66a0e86abe025108a159a86b3e0be40580533dce8ec - languageName: node - linkType: hard - "@angular-devkit/schematics@npm:17.3.8": version: 17.3.8 resolution: "@angular-devkit/schematics@npm:17.3.8" @@ -1367,18 +1335,18 @@ __metadata: languageName: node linkType: hard -"@nestjs/schematics@npm:^10.1.1": - version: 10.1.1 - resolution: "@nestjs/schematics@npm:10.1.1" +"@nestjs/schematics@npm:^10.1.2": + version: 10.1.2 + resolution: "@nestjs/schematics@npm:10.1.2" dependencies: - "@angular-devkit/core": "npm:17.1.2" - "@angular-devkit/schematics": "npm:17.1.2" + "@angular-devkit/core": "npm:17.3.8" + "@angular-devkit/schematics": "npm:17.3.8" comment-json: "npm:4.2.3" - jsonc-parser: "npm:3.2.1" + jsonc-parser: "npm:3.3.1" pluralize: "npm:8.0.0" peerDependencies: typescript: ">=4.8.2" - checksum: 10/4488deae3f96dcc429d2b55478bfd782e0442c48a47f69f9b59862f054ffc792f1a9cfca7700fe049ab68a2e392a0aea1ef7b4d1e2c97ffb9efd83f6c8f8e349 + checksum: 10/c6aff36353bc43da024891d643a7b3683ee9c1764e351bded8430f52d90cf8e53c1e2aee430542efd55943e8ce674cba7ba120543d078ea38058364f80d8d971 languageName: node linkType: hard @@ -5750,6 +5718,13 @@ __metadata: languageName: node linkType: hard +"jsonc-parser@npm:3.3.1": + version: 3.3.1 + resolution: "jsonc-parser@npm:3.3.1" + checksum: 10/9b0dc391f20b47378f843ef1e877e73ec652a5bdc3c5fa1f36af0f119a55091d147a86c1ee86a232296f55c929bba174538c2bf0312610e0817a22de131cc3f4 + languageName: node + linkType: hard + "jsonfile@npm:^6.0.1": version: 6.1.0 resolution: "jsonfile@npm:6.1.0" @@ -5999,15 +5974,6 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:0.30.5": - version: 0.30.5 - resolution: "magic-string@npm:0.30.5" - dependencies: - "@jridgewell/sourcemap-codec": "npm:^1.4.15" - checksum: 10/c8a6b25f813215ca9db526f3a407d6dc0bf35429c2b8111d6f1c2cf6cf6afd5e2d9f9cd189416a0e3959e20ecd635f73639f9825c73de1074b29331fe36ace59 - languageName: node - linkType: hard - "magic-string@npm:0.30.8": version: 0.30.8 resolution: "magic-string@npm:0.30.8" @@ -6747,13 +6713,6 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:3.0.1": - version: 3.0.1 - resolution: "picomatch@npm:3.0.1" - checksum: 10/65ac837fedbd0640586f7c214f6c7481e1e12f41cdcd22a95eb6a2914d1773707ed0f0b5bd2d1e39b5ec7860b43a4c9150152332a3884cd8dd1d419b2a2fa5b5 - languageName: node - linkType: hard - "picomatch@npm:4.0.1": version: 4.0.1 resolution: "picomatch@npm:4.0.1" @@ -7220,7 +7179,7 @@ __metadata: "@nestjs/config": "npm:^3.2.3" "@nestjs/core": "npm:^10.3.10" "@nestjs/platform-express": "npm:^10.3.10" - "@nestjs/schematics": "npm:^10.1.1" + "@nestjs/schematics": "npm:^10.1.2" "@nestjs/serve-static": "npm:^4.0.2" "@nestjs/swagger": "npm:^7.3.1" "@nestjs/testing": "npm:^10.3.10" From 3a81fcb204487bc4d1308924b1a9c892b544c0d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jul 2024 23:40:16 +0200 Subject: [PATCH 167/207] Bump redis from 4.6.14 to 4.6.15 (#1743) Bumps [redis](https://github.com/redis/node-redis) from 4.6.14 to 4.6.15. - [Release notes](https://github.com/redis/node-redis/releases) - [Changelog](https://github.com/redis/node-redis/blob/master/CHANGELOG.md) - [Commits](https://github.com/redis/node-redis/compare/redis@4.6.14...redis@4.6.15) --- updated-dependencies: - dependency-name: redis dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 8170b791de..7831f16871 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "lodash": "^4.17.21", "nestjs-cls": "^4.3.0", "postgres": "^3.4.4", - "redis": "^4.6.14", + "redis": "^4.6.15", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "semver": "^7.6.2", diff --git a/yarn.lock b/yarn.lock index 958ef3d686..f5c8754b4e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1520,14 +1520,14 @@ __metadata: languageName: node linkType: hard -"@redis/client@npm:1.5.16": - version: 1.5.16 - resolution: "@redis/client@npm:1.5.16" +"@redis/client@npm:1.5.17": + version: 1.5.17 + resolution: "@redis/client@npm:1.5.17" dependencies: cluster-key-slot: "npm:1.1.2" generic-pool: "npm:3.9.0" yallist: "npm:4.0.0" - checksum: 10/54bd45dcdb980e9682fc9aaad36607a34b6c05ebc733fc9a132db33ce77b3ff63c229d8d8b43ce2d7db115f31ff2fefcbcc7dceeaa1fc88c03e7c8012e456adf + checksum: 10/f7c3b978829b7151363545c81844cf75ecdb7bddf667f60d3bc4598e4fb3e957ebae6f2ed6b4bfb54038be6342bbe1789fd40e8193f6d49ac8861af279fecec8 languageName: node linkType: hard @@ -6974,17 +6974,17 @@ __metadata: languageName: node linkType: hard -"redis@npm:^4.6.14": - version: 4.6.14 - resolution: "redis@npm:4.6.14" +"redis@npm:^4.6.15": + version: 4.6.15 + resolution: "redis@npm:4.6.15" dependencies: "@redis/bloom": "npm:1.2.0" - "@redis/client": "npm:1.5.16" + "@redis/client": "npm:1.5.17" "@redis/graph": "npm:1.1.1" "@redis/json": "npm:1.0.6" "@redis/search": "npm:1.1.6" "@redis/time-series": "npm:1.0.5" - checksum: 10/5a00d678ea39a2e2fdaa961b593873e21677922b72671b00ab0feda3469506bc89c13221e56b1c00994504538ea45dd7ed6cde5d8be8da308a26f5d2424d0f85 + checksum: 10/72f74fc80c89a8251e997b2c55bd05f5556191adeafe806ea17842ece5d2f80be31a76e161a9f0badcc5c35924044d642de66d3fc27215525f7332e9862e56d3 languageName: node linkType: hard @@ -7205,7 +7205,7 @@ __metadata: nestjs-cls: "npm:^4.3.0" postgres: "npm:^3.4.4" prettier: "npm:^3.3.2" - redis: "npm:^4.6.14" + redis: "npm:^4.6.15" reflect-metadata: "npm:^0.2.2" rxjs: "npm:^7.8.1" semver: "npm:^7.6.2" From 3f0937fa00d05225b757f0af131b7cacd15f11d6 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Tue, 9 Jul 2024 08:50:52 +0200 Subject: [PATCH 168/207] Add coverage ensuring TWAPs to official contract (#1730) Adds test coverage to ensure that `findTwapOrder` only returns TWAP orders from the official Composable CoW contract. --- .../helpers/twap-order.helper.spec.ts | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/src/routes/transactions/helpers/twap-order.helper.spec.ts b/src/routes/transactions/helpers/twap-order.helper.spec.ts index ab40668bfb..f2737d452b 100644 --- a/src/routes/transactions/helpers/twap-order.helper.spec.ts +++ b/src/routes/transactions/helpers/twap-order.helper.spec.ts @@ -1,9 +1,18 @@ +import { + multiSendEncoder, + multiSendTransactionsEncoder, +} from '@/domain/contracts/__tests__/encoders/multi-send-encoder.builder'; import { FakeConfigurationService } from '@/config/__tests__/fake.configuration.service'; import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; +import { + staticInputEncoder, + conditionalOrderParamsBuilder, + createWithContextEncoder, +} from '@/domain/swaps/contracts/__tests__/encoders/composable-cow-encoder.builder'; import { ComposableCowDecoder } from '@/domain/swaps/contracts/decoders/composable-cow-decoder.helper'; import { TwapOrderHelper } from '@/routes/transactions/helpers/twap-order.helper'; import { faker } from '@faker-js/faker'; -import { zeroAddress } from 'viem'; +import { getAddress, zeroAddress } from 'viem'; describe('TwapOrderHelper', () => { const ComposableCowAddress = '0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74'; @@ -65,11 +74,33 @@ describe('TwapOrderHelper', () => { ); }); - // TODO: Encode a batched call with a transaction to an unofficial ComposableCoW contract - it.skip('should not find order to an unofficial ComposableCoW contract', () => { + it('should not find order to an unofficial ComposableCoW contract', () => { + const staticInput = staticInputEncoder(); + const conditionalOrderParams = conditionalOrderParamsBuilder() + .with('staticInput', staticInput.encode()) + // TWAP handler address + .with('handler', '0x6cF1e9cA41f7611dEf408122793c358a3d11E5a5') + .build(); + const createWithContext = createWithContextEncoder().with( + 'params', + conditionalOrderParams, + ); + const transactions = multiSendTransactionsEncoder([ + { + operation: 0, + data: createWithContext.encode(), + // Not official ComposableCoW address + to: getAddress(faker.finance.ethereumAddress()), + value: BigInt(0), + }, + ]); + const data = multiSendEncoder() + .with('transactions', transactions) + .encode(); + const result = target.findTwapOrder({ to: zeroAddress, // MultiSend decoder does not check officiality of address - data: batchedCalldata, + data, }); expect(result).toBe(null); From c75ce14eec254fb3ed8a7ebe8d3f4933e71443d1 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Tue, 9 Jul 2024 13:08:36 +0200 Subject: [PATCH 169/207] Adjust `SwapsApiFactory` to extend `IApiManager` (#1736) Adjusts `SwapsApiFactory` to extend `IApiManager`: - Convert `SwapsApiFactory` to extend `IApiManager` and propagate changes --- src/datasources/swaps-api/swaps-api.factory.ts | 12 +++++++++--- src/domain/interfaces/swaps-api.factory.ts | 6 ++---- src/domain/swaps/swaps.repository.ts | 6 +++--- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/datasources/swaps-api/swaps-api.factory.ts b/src/datasources/swaps-api/swaps-api.factory.ts index d2acb4a94d..7b184479e8 100644 --- a/src/datasources/swaps-api/swaps-api.factory.ts +++ b/src/datasources/swaps-api/swaps-api.factory.ts @@ -20,9 +20,9 @@ export class SwapsApiFactory implements ISwapsApiFactory { private readonly configurationService: IConfigurationService, ) {} - get(chainId: string): ISwapsApi { + getApi(chainId: string): Promise { if (this.apis[chainId]) { - return this.apis[chainId]; + return Promise.resolve(this.apis[chainId]); } const baseUrl = this.configurationService.getOrThrow( @@ -34,6 +34,12 @@ export class SwapsApiFactory implements ISwapsApiFactory { this.networkService, this.httpErrorFactory, ); - return this.apis[chainId]; + return Promise.resolve(this.apis[chainId]); + } + + destroyApi(chainId: string): void { + if (this.apis[chainId] !== undefined) { + delete this.apis[chainId]; + } } } diff --git a/src/domain/interfaces/swaps-api.factory.ts b/src/domain/interfaces/swaps-api.factory.ts index 22d14f292b..9ea9bf9292 100644 --- a/src/domain/interfaces/swaps-api.factory.ts +++ b/src/domain/interfaces/swaps-api.factory.ts @@ -1,8 +1,6 @@ +import { IApiManager } from '@/domain/interfaces/api.manager.interface'; import { ISwapsApi } from '@/domain/interfaces/swaps-api.interface'; export const ISwapsApiFactory = Symbol('ISwapsApiFactory'); -// TODO: Extend IApiManager interface and clear on `CHAIN_UPDATE` -export interface ISwapsApiFactory { - get(chainId: string): ISwapsApi; -} +export interface ISwapsApiFactory extends IApiManager {} diff --git a/src/domain/swaps/swaps.repository.ts b/src/domain/swaps/swaps.repository.ts index 93183e67d5..be27cbce25 100644 --- a/src/domain/swaps/swaps.repository.ts +++ b/src/domain/swaps/swaps.repository.ts @@ -31,13 +31,13 @@ export class SwapsRepository implements ISwapsRepository { ) {} async getOrder(chainId: string, orderUid: `0x${string}`): Promise { - const api = this.swapsApiFactory.get(chainId); + const api = await this.swapsApiFactory.getApi(chainId); const order = await api.getOrder(orderUid); return OrderSchema.parse(order); } async getOrders(chainId: string, txHash: string): Promise> { - const api = this.swapsApiFactory.get(chainId); + const api = await this.swapsApiFactory.getApi(chainId); const order = await api.getOrders(txHash); return OrdersSchema.parse(order); } @@ -46,7 +46,7 @@ export class SwapsRepository implements ISwapsRepository { chainId: string, appDataHash: `0x${string}`, ): Promise { - const api = this.swapsApiFactory.get(chainId); + const api = await this.swapsApiFactory.getApi(chainId); const fullAppData = await api.getFullAppData(appDataHash); return FullAppDataSchema.parse(fullAppData); } From 7559a5261e2c454bfed41ae22986fd45d19f8edf Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Tue, 9 Jul 2024 13:10:57 +0200 Subject: [PATCH 170/207] Improve comment regarding settlement check (#1739) Adds detailed comment regarding the approach of decoding swap transfers from the official settlement contract: - Rename method to be more declarative - Improve comment --- .../transfers/swap-transfer-info.mapper.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.ts b/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.ts index b912765f35..34cd034b96 100644 --- a/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.ts +++ b/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.ts @@ -39,12 +39,17 @@ export class SwapTransferInfoMapper { transferInfo: Transfer; domainTransfer: DomainTransfer; }): Promise { - // If settlement contract is not interacted with, not a swap fulfillment - // TODO: Also check data is of `settle` call as otherwise _any_ call - // to settlement contract could be considered a swap fulfillment + /** + * If settlement contract is interacted with, it may be a swap transfer (`settle` call). + * Ideally we should check the transaction `data` to confirm that it has the signature + * but we don't always access to it, e.g. when directly getting incoming transfers. + * + * In this instance, getting the order will throw if it is not a `settle` call and it will + * be mapped as a "standard" transfer so we can safely ignore the signature check. + */ if ( - !this.isSettlement(args.sender.value) && - !this.isSettlement(args.recipient.value) + !this.isSettlementContract(args.sender.value) && + !this.isSettlementContract(args.recipient.value) ) { throw new Error('Neither sender nor receiver are settlement contract'); } @@ -104,7 +109,7 @@ export class SwapTransferInfoMapper { }); } - private isSettlement(address: string): boolean { + private isSettlementContract(address: string): boolean { return isAddressEqual( getAddress(address), GPv2OrderHelper.SettlementContractAddress, From b81b04659aaef50236654ef9f80bc3b9f0a8e6ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Tue, 9 Jul 2024 16:20:08 +0200 Subject: [PATCH 171/207] Implement PUT /v1/accounts/:address/data-settings (#1715) - The endpoint PUT /v1/accounts/:address/data-settings was added to AccountsController, protected by AuthGuard. - Associated tests and domain classes were added. --- .../00003_account-data-settings/index.sql | 17 ++ .../00003_account-data-settings.spec.ts | 124 ++++++++++++ .../test.accounts.datasource.module.ts | 1 + .../accounts/accounts.datasource.spec.ts | 167 ++++++++++++++++ .../accounts/accounts.datasource.ts | 72 +++++++ .../accounts/accounts.repository.interface.ts | 8 + src/domain/accounts/accounts.repository.ts | 22 ++- .../__tests__/account-data-setting.builder.ts | 12 ++ .../__tests__/create-account.dto.builder.ts | 4 +- ...ccount-data-settings.dto.entity.builder.ts | 13 ++ .../account-data-setting.entity.spec.ts | 98 ++++++++++ .../entities/account-data-setting.entity.ts | 13 ++ .../entities/create-account.dto.entity.ts | 12 ++ .../create-account.dto.schema.spec.ts | 4 +- .../schemas/create-account.dto.schema.ts | 2 + ...upsert-account-data-settings.dto.entity.ts | 24 +++ .../accounts.datasource.interface.ts | 7 + .../accounts/accounts.controller.spec.ts | 185 ++++++++++++++++++ src/routes/accounts/accounts.controller.ts | 24 ++- src/routes/accounts/accounts.service.ts | 46 ++++- .../entities/account-data-setting.entity.ts | 16 ++ .../entities/account-data-type.entity.ts | 2 +- .../entities/create-account.dto.entity.ts | 7 +- ...upsert-account-data-settings.dto.entity.ts | 16 ++ 24 files changed, 882 insertions(+), 14 deletions(-) create mode 100644 migrations/00003_account-data-settings/index.sql create mode 100644 migrations/__tests__/00003_account-data-settings.spec.ts create mode 100644 src/domain/accounts/entities/__tests__/account-data-setting.builder.ts rename src/{routes => domain}/accounts/entities/__tests__/create-account.dto.builder.ts (70%) create mode 100644 src/domain/accounts/entities/__tests__/upsert-account-data-settings.dto.entity.builder.ts create mode 100644 src/domain/accounts/entities/account-data-setting.entity.spec.ts create mode 100644 src/domain/accounts/entities/account-data-setting.entity.ts create mode 100644 src/domain/accounts/entities/create-account.dto.entity.ts rename src/{routes/accounts/entities/schemas/__tests__ => domain/accounts/entities/schemas}/create-account.dto.schema.spec.ts (94%) rename src/{routes => domain}/accounts/entities/schemas/create-account.dto.schema.ts (80%) create mode 100644 src/domain/accounts/entities/upsert-account-data-settings.dto.entity.ts create mode 100644 src/routes/accounts/entities/account-data-setting.entity.ts create mode 100644 src/routes/accounts/entities/upsert-account-data-settings.dto.entity.ts diff --git a/migrations/00003_account-data-settings/index.sql b/migrations/00003_account-data-settings/index.sql new file mode 100644 index 0000000000..3a49d77ef2 --- /dev/null +++ b/migrations/00003_account-data-settings/index.sql @@ -0,0 +1,17 @@ +DROP TABLE IF EXISTS account_data_settings CASCADE; + +CREATE TABLE account_data_settings ( + account_id INTEGER NOT NULL, + account_data_type_id INTEGER NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + PRIMARY KEY (account_id, account_data_type_id), + FOREIGN KEY (account_id) REFERENCES accounts(id), + FOREIGN KEY (account_data_type_id) REFERENCES account_data_types(id) +); + +CREATE OR REPLACE TRIGGER update_account_data_settings_updated_at +BEFORE UPDATE ON account_data_settings +FOR EACH ROW +EXECUTE FUNCTION update_updated_at_column(); diff --git a/migrations/__tests__/00003_account-data-settings.spec.ts b/migrations/__tests__/00003_account-data-settings.spec.ts new file mode 100644 index 0000000000..449e058126 --- /dev/null +++ b/migrations/__tests__/00003_account-data-settings.spec.ts @@ -0,0 +1,124 @@ +import { TestDbFactory } from '@/__tests__/db.factory'; +import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; +import { faker } from '@faker-js/faker'; +import postgres from 'postgres'; +import { getAddress } from 'viem'; + +interface AccountRow { + id: number; + group_id: number; + created_at: Date; + updated_at: Date; + address: `0x${string}`; +} + +interface AccountDataTypeRow { + id: number; + created_at: Date; + updated_at: Date; + name: string; + description: string; + is_active: boolean; +} + +interface AccountDataSettingsRow { + created_at: Date; + updated_at: Date; + account_data_type_id: number; + account_id: number; + enabled: boolean; +} + +describe('Migration 00003_account-data-settings', () => { + let sql: postgres.Sql; + let migrator: PostgresDatabaseMigrator; + const testDbFactory = new TestDbFactory(); + + beforeAll(async () => { + sql = await testDbFactory.createTestDatabase(faker.string.uuid()); + migrator = new PostgresDatabaseMigrator(sql); + }); + + afterAll(async () => { + await testDbFactory.destroyTestDatabase(sql); + }); + + it('runs successfully', async () => { + const result = await migrator.test({ + migration: '00003_account-data-settings', + after: async (sql: postgres.Sql) => { + return { + account_data_types: { + columns: + await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'account_data_settings'`, + rows: await sql`SELECT * FROM account_data_settings`, + }, + }; + }, + }); + expect(result.after).toStrictEqual({ + account_data_types: { + columns: expect.arrayContaining([ + { column_name: 'created_at' }, + { column_name: 'updated_at' }, + { column_name: 'account_data_type_id' }, + { column_name: 'account_id' }, + { column_name: 'enabled' }, + ]), + rows: [], + }, + }); + }); + + it('should add one AccountDataSettings and update its row timestamps', async () => { + const accountAddress = getAddress(faker.finance.ethereumAddress()); + const name = faker.lorem.word(); + let accountRows: AccountRow[] = []; + let accountDataTypeRows: AccountDataTypeRow[] = []; + + const { + after: accountDataSettingRows, + }: { after: AccountDataSettingsRow[] } = await migrator.test({ + migration: '00003_account-data-settings', + after: async (sql: postgres.Sql): Promise => { + accountRows = await sql< + AccountRow[] + >`INSERT INTO accounts (address) VALUES (${accountAddress}) RETURNING *;`; + accountDataTypeRows = await sql< + AccountDataTypeRow[] + >`INSERT INTO account_data_types (name) VALUES (${name}) RETURNING *;`; + return sql< + AccountDataSettingsRow[] + >`INSERT INTO account_data_settings (account_id, account_data_type_id) VALUES (${accountRows[0].id}, ${accountDataTypeRows[0].id}) RETURNING *;`; + }, + }); + + expect(accountDataSettingRows[0]).toMatchObject({ + account_id: accountRows[0].id, + account_data_type_id: accountDataTypeRows[0].id, + enabled: false, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }); + + // created_at and updated_at should be the same after the row is created + const createdAt = new Date(accountDataSettingRows[0].created_at); + const updatedAt = new Date(accountDataSettingRows[0].updated_at); + expect(createdAt).toBeInstanceOf(Date); + expect(createdAt).toStrictEqual(updatedAt); + + // only updated_at should be updated after the row is updated + const afterUpdate = await sql< + AccountDataTypeRow[] + >`UPDATE account_data_settings + SET enabled = true + WHERE account_id = ${accountDataSettingRows[0].account_id} + AND account_data_type_id = ${accountDataSettingRows[0].account_data_type_id} + RETURNING *;`; + + const updatedAtAfterUpdate = new Date(afterUpdate[0].updated_at); + const createdAtAfterUpdate = new Date(afterUpdate[0].created_at); + expect(createdAtAfterUpdate).toStrictEqual(createdAt); + expect(updatedAtAfterUpdate.getTime()).toBeGreaterThan(createdAt.getTime()); + }); +}); diff --git a/src/datasources/accounts/__tests__/test.accounts.datasource.module.ts b/src/datasources/accounts/__tests__/test.accounts.datasource.module.ts index 16feb2b815..051a5810b7 100644 --- a/src/datasources/accounts/__tests__/test.accounts.datasource.module.ts +++ b/src/datasources/accounts/__tests__/test.accounts.datasource.module.ts @@ -6,6 +6,7 @@ const accountsDatasource = { deleteAccount: jest.fn(), getAccount: jest.fn(), getDataTypes: jest.fn(), + upsertAccountDataSettings: jest.fn(), } as jest.MockedObjectDeep; @Module({ diff --git a/src/datasources/accounts/accounts.datasource.spec.ts b/src/datasources/accounts/accounts.datasource.spec.ts index 20ce134edc..59088e1e74 100644 --- a/src/datasources/accounts/accounts.datasource.spec.ts +++ b/src/datasources/accounts/accounts.datasource.spec.ts @@ -1,6 +1,8 @@ import { TestDbFactory } from '@/__tests__/db.factory'; import { AccountsDatasource } from '@/datasources/accounts/accounts.datasource'; import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; +import { accountDataTypeBuilder } from '@/domain/accounts/entities/__tests__/account-data-type.builder'; +import { upsertAccountDataSettingsDtoBuilder } from '@/domain/accounts/entities/__tests__/upsert-account-data-settings.dto.entity.builder'; import { ILoggingService } from '@/logging/logging.interface'; import { faker } from '@faker-js/faker'; import postgres from 'postgres'; @@ -148,4 +150,169 @@ describe('AccountsDatasource tests', () => { ); }); }); + + describe('upsertAccountDataSettings', () => { + it('adds account data settings successfully', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const account = await target.createAccount(address); + const accountDataTypes = Array.from( + { length: faker.number.int({ min: 1, max: 4 }) }, + () => accountDataTypeBuilder().with('is_active', true).build(), + ); + const insertedDataTypes = + await sql`INSERT INTO account_data_types ${sql(accountDataTypes, 'name', 'is_active')} returning *`; + const accountDataSettings = insertedDataTypes.map((dataType) => ({ + id: dataType.id, + enabled: faker.datatype.boolean(), + })); + const upsertAccountDataSettingsDto = upsertAccountDataSettingsDtoBuilder() + .with('accountDataSettings', accountDataSettings) + .build(); + + const actual = await target.upsertAccountDataSettings( + address, + upsertAccountDataSettingsDto, + ); + + const expected = accountDataSettings.map((accountDataSetting) => ({ + account_id: account.id, + account_data_type_id: accountDataSetting.id, + enabled: accountDataSetting.enabled, + created_at: expect.any(Date), + updated_at: expect.any(Date), + })); + + expect(actual).toStrictEqual(expect.arrayContaining(expected)); + }); + + it('updates existing account data settings successfully', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const account = await target.createAccount(address); + const accountDataTypes = Array.from( + { length: faker.number.int({ min: 1, max: 4 }) }, + () => accountDataTypeBuilder().with('is_active', true).build(), + ); + const insertedDataTypes = + await sql`INSERT INTO account_data_types ${sql(accountDataTypes, 'name', 'is_active')} returning *`; + const accountDataSettings = insertedDataTypes.map((dataType) => ({ + id: dataType.id, + enabled: faker.datatype.boolean(), + })); + const upsertAccountDataSettingsDto = upsertAccountDataSettingsDtoBuilder() + .with('accountDataSettings', accountDataSettings) + .build(); + + const beforeUpdate = await target.upsertAccountDataSettings( + address, + upsertAccountDataSettingsDto, + ); + + expect(beforeUpdate).toStrictEqual( + expect.arrayContaining( + accountDataSettings.map((accountDataSetting) => ({ + account_id: account.id, + account_data_type_id: accountDataSetting.id, + enabled: accountDataSetting.enabled, + created_at: expect.any(Date), + updated_at: expect.any(Date), + })), + ), + ); + + const accountDataSettings2 = accountDataSettings.map((ads) => ({ + ...ads, + enabled: !ads.enabled, + })); + const upsertAccountDataSettingsDto2 = + upsertAccountDataSettingsDtoBuilder() + .with('accountDataSettings', accountDataSettings2) + .build(); + + const afterUpdate = await target.upsertAccountDataSettings( + address, + upsertAccountDataSettingsDto2, + ); + + expect(afterUpdate).toStrictEqual( + expect.arrayContaining( + accountDataSettings.map((accountDataSetting) => ({ + account_id: account.id, + account_data_type_id: accountDataSetting.id, + enabled: !accountDataSetting.enabled, // 'enabled' row was updated + created_at: expect.any(Date), + updated_at: expect.any(Date), + })), + ), + ); + }); + + it('throws an error if the account does not exist', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const accountDataTypes = Array.from( + { length: faker.number.int({ min: 1, max: 4 }) }, + () => accountDataTypeBuilder().with('is_active', true).build(), + ); + const insertedDataTypes = + await sql`INSERT INTO account_data_types ${sql(accountDataTypes, 'name', 'is_active')} returning *`; + const accountDataSettings = insertedDataTypes.map((dataType) => ({ + id: dataType.id, + enabled: faker.datatype.boolean(), + })); + const upsertAccountDataSettingsDto = upsertAccountDataSettingsDtoBuilder() + .with('accountDataSettings', accountDataSettings) + .build(); + + await expect( + target.upsertAccountDataSettings(address, upsertAccountDataSettingsDto), + ).rejects.toThrow('Error getting account.'); + }); + + it('throws an error if a non-existent data type is provided', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + await target.createAccount(address); + const accountDataTypes = Array.from( + { length: faker.number.int({ min: 1, max: 4 }) }, + () => accountDataTypeBuilder().with('is_active', true).build(), + ); + const insertedDataTypes = + await sql`INSERT INTO account_data_types ${sql(accountDataTypes, 'name', 'is_active')} returning *`; + const accountDataSettings = insertedDataTypes.map((dataType) => ({ + id: dataType.id, + enabled: faker.datatype.boolean(), + })); + const upsertAccountDataSettingsDto = upsertAccountDataSettingsDtoBuilder() + .with('accountDataSettings', accountDataSettings) + .build(); + upsertAccountDataSettingsDto.accountDataSettings.push({ + id: faker.string.numeric(5), + enabled: faker.datatype.boolean(), + }); + + await expect( + target.upsertAccountDataSettings(address, upsertAccountDataSettingsDto), + ).rejects.toThrow('Data types not found or not active.'); + }); + + it('throws an error if an inactive data type is provided', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + await target.createAccount(address); + const accountDataTypes = [ + accountDataTypeBuilder().with('is_active', false).build(), + accountDataTypeBuilder().build(), + ]; + const insertedDataTypes = + await sql`INSERT INTO account_data_types ${sql(accountDataTypes, 'name', 'is_active')} returning *`; + const accountDataSettings = insertedDataTypes.map((dataType) => ({ + id: dataType.id, + enabled: faker.datatype.boolean(), + })); + const upsertAccountDataSettingsDto = upsertAccountDataSettingsDtoBuilder() + .with('accountDataSettings', accountDataSettings) + .build(); + + await expect( + target.upsertAccountDataSettings(address, upsertAccountDataSettingsDto), + ).rejects.toThrow(`Data types not found or not active.`); + }); + }); }); diff --git a/src/datasources/accounts/accounts.datasource.ts b/src/datasources/accounts/accounts.datasource.ts index 90d76e1663..e6ba168d20 100644 --- a/src/datasources/accounts/accounts.datasource.ts +++ b/src/datasources/accounts/accounts.datasource.ts @@ -1,5 +1,7 @@ +import { AccountDataSetting } from '@/domain/accounts/entities/account-data-setting.entity'; import { AccountDataType } from '@/domain/accounts/entities/account-data-type.entity'; import { Account } from '@/domain/accounts/entities/account.entity'; +import { UpsertAccountDataSettingsDto } from '@/domain/accounts/entities/upsert-account-data-settings.dto.entity'; import { IAccountsDatasource } from '@/domain/interfaces/accounts.datasource.interface'; import { ILoggingService, LoggingService } from '@/logging/logging.interface'; import { asError } from '@/logging/utils'; @@ -62,6 +64,76 @@ export class AccountsDatasource implements IAccountsDatasource { } async getDataTypes(): Promise { + // TODO: add caching with clearing mechanism. return this.sql<[AccountDataType]>`SELECT * FROM account_data_types`; } + + async getAccountDataSettings( + address: `0x${string}`, + ): Promise { + const account = await this.getAccount(address); + return this.sql<[AccountDataSetting]>` + SELECT * FROM account_data_settings WHERE account_id = ${account.id} + `; + } + + /** + * Adds or updates the existing account data settings for a given address/account. + * Requirements: + * - The account must exist. + * - The data type must exist. + * - The data type must be active. + * + * @param address - account address. + * @param upsertAccountDataSettings {@link UpsertAccountDataSettingsDto} object. + * @returns {Array} inserted account data settings. + */ + async upsertAccountDataSettings( + address: `0x${string}`, + upsertAccountDataSettings: UpsertAccountDataSettingsDto, + ): Promise { + const { accountDataSettings } = upsertAccountDataSettings; + await this.checkDataTypes(accountDataSettings); + const account = await this.getAccount(address); + return this.sql.begin(async (sql) => { + await Promise.all( + accountDataSettings.map(async (accountDataSetting) => { + return sql` + INSERT INTO account_data_settings (account_id, account_data_type_id, enabled) + VALUES (${account.id}, ${accountDataSetting.id}, ${accountDataSetting.enabled}) + ON CONFLICT (account_id, account_data_type_id) DO UPDATE SET enabled = EXCLUDED.enabled + `.catch((e) => { + throw new UnprocessableEntityException( + `Error updating data settings: ${asError(e).message}`, + ); + }); + }), + ); + return sql<[AccountDataSetting]>` + SELECT * FROM account_data_settings WHERE account_id = ${account.id}`; + }); + } + + private getActiveDataTypes(): Promise { + // TODO: add caching with clearing mechanism. + return this.sql<[AccountDataType]>` + SELECT * FROM account_data_types WHERE is_active = true + `; + } + + private async checkDataTypes( + accountDataSettings: UpsertAccountDataSettingsDto['accountDataSettings'], + ): Promise { + const activeDataTypes = await this.getActiveDataTypes(); + const activeDataTypeIds = activeDataTypes.map((ads) => ads.id); + if ( + !accountDataSettings.every((ads) => + activeDataTypeIds.includes(Number(ads.id)), + ) + ) { + throw new UnprocessableEntityException( + `Data types not found or not active.`, + ); + } + } } diff --git a/src/domain/accounts/accounts.repository.interface.ts b/src/domain/accounts/accounts.repository.interface.ts index 6c2243c50f..66b5511154 100644 --- a/src/domain/accounts/accounts.repository.interface.ts +++ b/src/domain/accounts/accounts.repository.interface.ts @@ -1,7 +1,9 @@ import { AccountsDatasourceModule } from '@/datasources/accounts/accounts.datasource.module'; import { AccountsRepository } from '@/domain/accounts/accounts.repository'; +import { AccountDataSetting } from '@/domain/accounts/entities/account-data-setting.entity'; import { AccountDataType } from '@/domain/accounts/entities/account-data-type.entity'; import { Account } from '@/domain/accounts/entities/account.entity'; +import { UpsertAccountDataSettingsDto } from '@/domain/accounts/entities/upsert-account-data-settings.dto.entity'; import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; import { Module } from '@nestjs/common'; @@ -24,6 +26,12 @@ export interface IAccountsRepository { }): Promise; getDataTypes(): Promise; + + upsertAccountDataSettings(args: { + authPayload: AuthPayload; + address: `0x${string}`; + upsertAccountDataSettings: UpsertAccountDataSettingsDto; + }): Promise; } @Module({ diff --git a/src/domain/accounts/accounts.repository.ts b/src/domain/accounts/accounts.repository.ts index a5e6cc3d86..ccb9a0403c 100644 --- a/src/domain/accounts/accounts.repository.ts +++ b/src/domain/accounts/accounts.repository.ts @@ -1,9 +1,11 @@ import { IAccountsRepository } from '@/domain/accounts/accounts.repository.interface'; +import { AccountDataSetting } from '@/domain/accounts/entities/account-data-setting.entity'; import { AccountDataType } from '@/domain/accounts/entities/account-data-type.entity'; import { Account, AccountSchema, } from '@/domain/accounts/entities/account.entity'; +import { UpsertAccountDataSettingsDto } from '@/domain/accounts/entities/upsert-account-data-settings.dto.entity'; import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; import { IAccountsDatasource } from '@/domain/interfaces/accounts.datasource.interface'; import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; @@ -49,7 +51,25 @@ export class AccountsRepository implements IAccountsRepository { } async getDataTypes(): Promise { - // TODO: add caching with clearing mechanism. return this.datasource.getDataTypes(); } + + async upsertAccountDataSettings(args: { + authPayload: AuthPayload; + address: `0x${string}`; + upsertAccountDataSettings: UpsertAccountDataSettingsDto; + }): Promise { + const { address, upsertAccountDataSettings } = args; + if (!args.authPayload.isForSigner(args.address)) { + throw new UnauthorizedException(); + } + if (upsertAccountDataSettings.accountDataSettings.length === 0) { + return []; + } + + return this.datasource.upsertAccountDataSettings( + address, + upsertAccountDataSettings, + ); + } } diff --git a/src/domain/accounts/entities/__tests__/account-data-setting.builder.ts b/src/domain/accounts/entities/__tests__/account-data-setting.builder.ts new file mode 100644 index 0000000000..3b21eecf17 --- /dev/null +++ b/src/domain/accounts/entities/__tests__/account-data-setting.builder.ts @@ -0,0 +1,12 @@ +import { IBuilder, Builder } from '@/__tests__/builder'; +import { AccountDataSetting } from '@/domain/accounts/entities/account-data-setting.entity'; +import { faker } from '@faker-js/faker'; + +export function accountDataSettingBuilder(): IBuilder { + return new Builder() + .with('account_id', faker.number.int()) + .with('account_data_type_id', faker.number.int()) + .with('enabled', faker.datatype.boolean()) + .with('created_at', faker.date.recent()) + .with('updated_at', faker.date.recent()); +} diff --git a/src/routes/accounts/entities/__tests__/create-account.dto.builder.ts b/src/domain/accounts/entities/__tests__/create-account.dto.builder.ts similarity index 70% rename from src/routes/accounts/entities/__tests__/create-account.dto.builder.ts rename to src/domain/accounts/entities/__tests__/create-account.dto.builder.ts index 720a85ac8b..b2e7e08b49 100644 --- a/src/routes/accounts/entities/__tests__/create-account.dto.builder.ts +++ b/src/domain/accounts/entities/__tests__/create-account.dto.builder.ts @@ -1,5 +1,5 @@ -import { IBuilder, Builder } from '@/__tests__/builder'; -import { CreateAccountDto } from '@/routes/accounts/entities/create-account.dto.entity'; +import { Builder, IBuilder } from '@/__tests__/builder'; +import { CreateAccountDto } from '@/domain/accounts/entities/create-account.dto.entity'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; diff --git a/src/domain/accounts/entities/__tests__/upsert-account-data-settings.dto.entity.builder.ts b/src/domain/accounts/entities/__tests__/upsert-account-data-settings.dto.entity.builder.ts new file mode 100644 index 0000000000..d8f21adef9 --- /dev/null +++ b/src/domain/accounts/entities/__tests__/upsert-account-data-settings.dto.entity.builder.ts @@ -0,0 +1,13 @@ +import { IBuilder, Builder } from '@/__tests__/builder'; +import { UpsertAccountDataSettingsDto } from '@/domain/accounts/entities/upsert-account-data-settings.dto.entity'; +import { faker } from '@faker-js/faker'; + +export function upsertAccountDataSettingsDtoBuilder(): IBuilder { + return new Builder().with( + 'accountDataSettings', + Array.from({ length: faker.number.int({ min: 1, max: 10 }) }, () => ({ + id: faker.string.numeric(), + enabled: faker.datatype.boolean(), + })), + ); +} diff --git a/src/domain/accounts/entities/account-data-setting.entity.spec.ts b/src/domain/accounts/entities/account-data-setting.entity.spec.ts new file mode 100644 index 0000000000..23adc6381e --- /dev/null +++ b/src/domain/accounts/entities/account-data-setting.entity.spec.ts @@ -0,0 +1,98 @@ +import { accountDataSettingBuilder } from '@/domain/accounts/entities/__tests__/account-data-setting.builder'; +import { AccountDataSettingSchema } from '@/domain/accounts/entities/account-data-setting.entity'; +import { faker } from '@faker-js/faker'; + +describe('AccountDataSettingSchema', () => { + it('should verify an AccountDataSetting', () => { + const accountDataSetting = accountDataSettingBuilder().build(); + + const result = AccountDataSettingSchema.safeParse(accountDataSetting); + + expect(result.success).toBe(true); + }); + + it.each(['account_id' as const, 'account_data_type_id' as const])( + 'should not verify an AccountDataSetting with a float %s', + (field) => { + const accountDataSetting = accountDataSettingBuilder() + .with(field, faker.number.float()) + .build(); + + const result = AccountDataSettingSchema.safeParse(accountDataSetting); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected: 'integer', + message: 'Expected integer, received float', + path: [field], + received: 'float', + }, + ]); + }, + ); + + it('should not verify an AccountDataSetting with a non-boolean enabled', () => { + const accountDataSetting = accountDataSettingBuilder().build(); + // @ts-expect-error - should be booleans + accountDataSetting.enabled = faker.datatype.boolean().toString(); + + const result = AccountDataSettingSchema.safeParse(accountDataSetting); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected: 'boolean', + message: 'Expected boolean, received string', + path: ['enabled'], + received: 'string', + }, + ]); + }); + + it('should not verify an invalid AccountDataSetting', () => { + const accountDataSetting = { + invalid: 'accountDataSetting', + }; + + const result = AccountDataSettingSchema.safeParse(accountDataSetting); + + expect(!result.success && result.error.issues).toStrictEqual([ + { + code: 'invalid_type', + expected: 'number', + message: 'Required', + path: ['account_id'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'number', + message: 'Required', + path: ['account_data_type_id'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'boolean', + message: 'Required', + path: ['enabled'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'date', + message: 'Required', + path: ['created_at'], + received: 'undefined', + }, + { + code: 'invalid_type', + expected: 'date', + message: 'Required', + path: ['updated_at'], + received: 'undefined', + }, + ]); + }); +}); diff --git a/src/domain/accounts/entities/account-data-setting.entity.ts b/src/domain/accounts/entities/account-data-setting.entity.ts new file mode 100644 index 0000000000..0e7121866b --- /dev/null +++ b/src/domain/accounts/entities/account-data-setting.entity.ts @@ -0,0 +1,13 @@ +import { AccountDataTypeSchema } from '@/domain/accounts/entities/account-data-type.entity'; +import { AccountSchema } from '@/domain/accounts/entities/account.entity'; +import { z } from 'zod'; + +export type AccountDataSetting = z.infer; + +export const AccountDataSettingSchema = z.object({ + account_id: AccountSchema.shape.id, + account_data_type_id: AccountDataTypeSchema.shape.id, + enabled: z.boolean(), + created_at: z.date(), + updated_at: z.date(), +}); diff --git a/src/domain/accounts/entities/create-account.dto.entity.ts b/src/domain/accounts/entities/create-account.dto.entity.ts new file mode 100644 index 0000000000..0959d0b801 --- /dev/null +++ b/src/domain/accounts/entities/create-account.dto.entity.ts @@ -0,0 +1,12 @@ +import { CreateAccountDtoSchema } from '@/domain/accounts/entities/schemas/create-account.dto.schema'; +import { z } from 'zod'; + +export class CreateAccountDto + implements z.infer +{ + address: `0x${string}`; + + constructor(props: CreateAccountDto) { + this.address = props.address; + } +} diff --git a/src/routes/accounts/entities/schemas/__tests__/create-account.dto.schema.spec.ts b/src/domain/accounts/entities/schemas/create-account.dto.schema.spec.ts similarity index 94% rename from src/routes/accounts/entities/schemas/__tests__/create-account.dto.schema.spec.ts rename to src/domain/accounts/entities/schemas/create-account.dto.schema.spec.ts index 4260c33807..65294d608b 100644 --- a/src/routes/accounts/entities/schemas/__tests__/create-account.dto.schema.spec.ts +++ b/src/domain/accounts/entities/schemas/create-account.dto.schema.spec.ts @@ -1,5 +1,5 @@ -import { createAccountDtoBuilder } from '@/routes/accounts/entities/__tests__/create-account.dto.builder'; -import { CreateAccountDtoSchema } from '@/routes/accounts/entities/schemas/create-account.dto.schema'; +import { createAccountDtoBuilder } from '@/domain/accounts/entities/__tests__/create-account.dto.builder'; +import { CreateAccountDtoSchema } from '@/domain/accounts/entities/schemas/create-account.dto.schema'; import { ZodError } from 'zod'; describe('CreateAccountDtoSchema', () => { diff --git a/src/routes/accounts/entities/schemas/create-account.dto.schema.ts b/src/domain/accounts/entities/schemas/create-account.dto.schema.ts similarity index 80% rename from src/routes/accounts/entities/schemas/create-account.dto.schema.ts rename to src/domain/accounts/entities/schemas/create-account.dto.schema.ts index e615d3e67b..741a408e0e 100644 --- a/src/routes/accounts/entities/schemas/create-account.dto.schema.ts +++ b/src/domain/accounts/entities/schemas/create-account.dto.schema.ts @@ -1,6 +1,8 @@ import { AddressSchema } from '@/validation/entities/schemas/address.schema'; import { z } from 'zod'; +// TODO: merge with CreateAccountDto entity + export const CreateAccountDtoSchema = z.object({ address: AddressSchema, }); diff --git a/src/domain/accounts/entities/upsert-account-data-settings.dto.entity.ts b/src/domain/accounts/entities/upsert-account-data-settings.dto.entity.ts new file mode 100644 index 0000000000..446e2e872c --- /dev/null +++ b/src/domain/accounts/entities/upsert-account-data-settings.dto.entity.ts @@ -0,0 +1,24 @@ +import { NumericStringSchema } from '@/validation/entities/schemas/numeric-string.schema'; +import { z } from 'zod'; + +export class UpsertAccountDataSettingsDto + implements z.infer +{ + accountDataSettings: { + id: string; + enabled: boolean; + }[]; + + constructor(props: UpsertAccountDataSettingsDto) { + this.accountDataSettings = props.accountDataSettings; + } +} + +export const UpsertAccountDataSettingDtoSchema = z.object({ + id: NumericStringSchema, + enabled: z.boolean(), +}); + +export const UpsertAccountDataSettingsDtoSchema = z.object({ + accountDataSettings: z.array(UpsertAccountDataSettingDtoSchema), +}); diff --git a/src/domain/interfaces/accounts.datasource.interface.ts b/src/domain/interfaces/accounts.datasource.interface.ts index 1e32089cb9..8ba27815c2 100644 --- a/src/domain/interfaces/accounts.datasource.interface.ts +++ b/src/domain/interfaces/accounts.datasource.interface.ts @@ -1,5 +1,7 @@ +import { AccountDataSetting } from '@/domain/accounts/entities/account-data-setting.entity'; import { AccountDataType } from '@/domain/accounts/entities/account-data-type.entity'; import { Account } from '@/domain/accounts/entities/account.entity'; +import { UpsertAccountDataSettingsDto } from '@/domain/accounts/entities/upsert-account-data-settings.dto.entity'; export const IAccountsDatasource = Symbol('IAccountsDatasource'); @@ -11,4 +13,9 @@ export interface IAccountsDatasource { deleteAccount(address: `0x${string}`): Promise; getDataTypes(): Promise; + + upsertAccountDataSettings( + address: `0x${string}`, + upsertAccountDataSettings: UpsertAccountDataSettingsDto, + ): Promise; } diff --git a/src/routes/accounts/accounts.controller.spec.ts b/src/routes/accounts/accounts.controller.spec.ts index deb7ce96cd..afe9ad557b 100644 --- a/src/routes/accounts/accounts.controller.spec.ts +++ b/src/routes/accounts/accounts.controller.spec.ts @@ -15,8 +15,10 @@ import { TestNetworkModule } from '@/datasources/network/__tests__/test.network. import { NetworkModule } from '@/datasources/network/network.module'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { accountDataSettingBuilder } from '@/domain/accounts/entities/__tests__/account-data-setting.builder'; import { accountDataTypeBuilder } from '@/domain/accounts/entities/__tests__/account-data-type.builder'; import { accountBuilder } from '@/domain/accounts/entities/__tests__/account.builder'; +import { upsertAccountDataSettingsDtoBuilder } from '@/domain/accounts/entities/__tests__/upsert-account-data-settings.dto.entity.builder'; import { authPayloadDtoBuilder } from '@/domain/auth/entities/__tests__/auth-payload-dto.entity.builder'; import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; import { getSecondsUntil } from '@/domain/common/utils/time'; @@ -584,4 +586,187 @@ describe('AccountsController', () => { expect(accountDataSource.getDataTypes).toHaveBeenCalledTimes(1); }); }); + + describe('Upsert account data settings', () => { + it('should upsert data settings for an account', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + const account = accountBuilder().build(); + const dataTypes = [ + accountDataTypeBuilder().build(), + accountDataTypeBuilder().build(), + ]; + const domainAccountDataSettings = [ + accountDataSettingBuilder() + .with('account_id', account.id) + .with('account_data_type_id', dataTypes[0].id) + .build(), + accountDataSettingBuilder() + .with('account_id', account.id) + .with('account_data_type_id', dataTypes[1].id) + .build(), + ]; + const upsertAccountDataSettingsDto = + upsertAccountDataSettingsDtoBuilder().build(); + accountDataSource.createAccount.mockResolvedValue(account); + accountDataSource.getDataTypes.mockResolvedValue(dataTypes); + accountDataSource.upsertAccountDataSettings.mockResolvedValue( + domainAccountDataSettings, + ); + + await request(app.getHttpServer()) + .put(`/v1/accounts/${address}/data-settings`) + .set('Cookie', [`access_token=${accessToken}`]) + .send(upsertAccountDataSettingsDto) + .expect(200); + + expect(accountDataSource.upsertAccountDataSettings).toHaveBeenCalledTimes( + 1, + ); + expect(accountDataSource.upsertAccountDataSettings).toHaveBeenCalledWith( + address, + upsertAccountDataSettingsDto, + ); + }); + + it('should accept a empty array of data settings', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + + await request(app.getHttpServer()) + .put(`/v1/accounts/${address}/data-settings`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ accountDataSettings: [] }) + .expect(200) + .expect([]); + + expect( + accountDataSource.upsertAccountDataSettings, + ).not.toHaveBeenCalled(); + }); + + it('Returns 403 if no token is present', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + + await request(app.getHttpServer()) + .put(`/v1/accounts/${address}/data-settings`) + .send({ address }) + .expect(403); + }); + + it('returns 403 if token is not a valid JWT', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const accessToken = faker.string.sample(); + + expect(() => jwtService.verify(accessToken)).toThrow('jwt malformed'); + await request(app.getHttpServer()) + .put(`/v1/accounts/${address}/data-settings`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address }) + .expect(403); + }); + + it('returns 403 is token it not yet valid', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto, { + notBefore: getSecondsUntil(faker.date.future()), + }); + + await request(app.getHttpServer()) + .put(`/v1/accounts/${address}/data-settings`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address }) + .expect(403); + }); + + it('returns 403 if token has expired', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto, { expiresIn: 0 }); + jest.advanceTimersByTime(1_000); + + await request(app.getHttpServer()) + .put(`/v1/accounts/${address}/data-settings`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address }) + .expect(403); + }); + + it('returns 403 if signer_address is not a valid Ethereum address', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', faker.string.hexadecimal() as `0x${string}`) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + + await request(app.getHttpServer()) + .put(`/v1/accounts/${address}/data-settings`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address }) + .expect(403); + }); + + it('returns 403 if chain_id is not a valid chain ID', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', faker.lorem.sentence()) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + + await request(app.getHttpServer()) + .put(`/v1/accounts/${address}/data-settings`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address }) + .expect(403); + }); + + it('should throw an error if the datasource fails', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + accountDataSource.upsertAccountDataSettings.mockImplementation(() => { + throw new Error('test error'); + }); + + await request(app.getHttpServer()) + .put(`/v1/accounts/${address}/data-settings`) + .set('Cookie', [`access_token=${accessToken}`]) + .send(upsertAccountDataSettingsDtoBuilder().build()) + .expect(500) + .expect({ + code: 500, + message: 'Internal server error', + }); + + expect(accountDataSource.upsertAccountDataSettings).toHaveBeenCalledTimes( + 1, + ); + }); + }); }); diff --git a/src/routes/accounts/accounts.controller.ts b/src/routes/accounts/accounts.controller.ts index a464534980..faa39f1070 100644 --- a/src/routes/accounts/accounts.controller.ts +++ b/src/routes/accounts/accounts.controller.ts @@ -1,9 +1,13 @@ +import { CreateAccountDtoSchema } from '@/domain/accounts/entities/schemas/create-account.dto.schema'; +import { UpsertAccountDataSettingsDtoSchema } from '@/domain/accounts/entities/upsert-account-data-settings.dto.entity'; import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; import { AccountsService } from '@/routes/accounts/accounts.service'; +import { AccountDataSetting } from '@/routes/accounts/entities/account-data-setting.entity'; import { AccountDataType } from '@/routes/accounts/entities/account-data-type.entity'; import { Account } from '@/routes/accounts/entities/account.entity'; import { CreateAccountDto } from '@/routes/accounts/entities/create-account.dto.entity'; -import { CreateAccountDtoSchema } from '@/routes/accounts/entities/schemas/create-account.dto.schema'; +import { UpsertAccountDataSettingsDto } from '@/routes/accounts/entities/upsert-account-data-settings.dto.entity'; +import { Auth } from '@/routes/auth/decorators/auth.decorator'; import { AuthGuard } from '@/routes/auth/guards/auth.guard'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; import { ValidationPipe } from '@/validation/pipes/validation.pipe'; @@ -16,10 +20,10 @@ import { HttpStatus, Param, Post, + Put, UseGuards, } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; -import { Auth } from '@/routes/auth/decorators/auth.decorator'; @ApiTags('accounts') @Controller({ path: 'accounts', version: '1' }) @@ -47,6 +51,22 @@ export class AccountsController { return this.accountsService.getDataTypes(); } + @ApiOkResponse({ type: AccountDataSetting, isArray: true }) + @Put(':address/data-settings') + @UseGuards(AuthGuard) + async upsertAccountDataSettings( + @Auth() authPayload: AuthPayload, + @Param('address', new ValidationPipe(AddressSchema)) address: `0x${string}`, + @Body(new ValidationPipe(UpsertAccountDataSettingsDtoSchema)) + upsertAccountDataSettingsDto: UpsertAccountDataSettingsDto, + ): Promise { + return this.accountsService.upsertAccountDataSettings({ + authPayload, + address, + upsertAccountDataSettingsDto, + }); + } + @ApiOkResponse({ type: Account }) @Get(':address') @UseGuards(AuthGuard) diff --git a/src/routes/accounts/accounts.service.ts b/src/routes/accounts/accounts.service.ts index 2dfea5dd40..1cd883a330 100644 --- a/src/routes/accounts/accounts.service.ts +++ b/src/routes/accounts/accounts.service.ts @@ -1,10 +1,13 @@ import { IAccountsRepository } from '@/domain/accounts/accounts.repository.interface'; -import { Account as DomainAccount } from '@/domain/accounts/entities/account.entity'; +import { AccountDataSetting as DomainAccountDataSetting } from '@/domain/accounts/entities/account-data-setting.entity'; import { AccountDataType as DomainAccountDataType } from '@/domain/accounts/entities/account-data-type.entity'; +import { Account as DomainAccount } from '@/domain/accounts/entities/account.entity'; import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; +import { AccountDataSetting } from '@/routes/accounts/entities/account-data-setting.entity'; import { AccountDataType } from '@/routes/accounts/entities/account-data-type.entity'; import { Account } from '@/routes/accounts/entities/account.entity'; import { CreateAccountDto } from '@/routes/accounts/entities/create-account.dto.entity'; +import { UpsertAccountDataSettingsDto } from '@/routes/accounts/entities/upsert-account-data-settings.dto.entity'; import { Inject, Injectable } from '@nestjs/common'; @Injectable() @@ -53,6 +56,28 @@ export class AccountsService { ); } + async upsertAccountDataSettings(args: { + authPayload: AuthPayload; + address: `0x${string}`; + upsertAccountDataSettingsDto: UpsertAccountDataSettingsDto; + }): Promise { + const [domainAccountDataSettings, dataTypes] = await Promise.all([ + this.accountsRepository.upsertAccountDataSettings({ + authPayload: args.authPayload, + address: args.address, + upsertAccountDataSettings: { + accountDataSettings: + args.upsertAccountDataSettingsDto.accountDataSettings, + }, + }), + this.accountsRepository.getDataTypes(), + ]); + + return domainAccountDataSettings.map((domainAccountDataSetting) => + this.mapDataSetting(dataTypes, domainAccountDataSetting), + ); + } + private mapAccount(domainAccount: DomainAccount): Account { return new Account( domainAccount.id.toString(), @@ -69,4 +94,23 @@ export class AccountsService { domainDataType.is_active, ); } + + private mapDataSetting( + dataTypes: DomainAccountDataType[], + domainAccountDataSetting: DomainAccountDataSetting, + ): AccountDataSetting { + const dataType = dataTypes.find( + (dt) => dt.id === domainAccountDataSetting.account_data_type_id, + ); + + if (!dataType) { + throw new Error('Data type not found'); + } + + return { + name: dataType.name, + description: dataType.description, + enabled: domainAccountDataSetting.enabled, + }; + } } diff --git a/src/routes/accounts/entities/account-data-setting.entity.ts b/src/routes/accounts/entities/account-data-setting.entity.ts new file mode 100644 index 0000000000..80fbfa8991 --- /dev/null +++ b/src/routes/accounts/entities/account-data-setting.entity.ts @@ -0,0 +1,16 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class AccountDataSetting { + @ApiProperty() + name: string; + @ApiPropertyOptional({ type: String, nullable: true }) + description: string | null; + @ApiProperty() + enabled: boolean; + + constructor(name: string, description: string | null, enabled: boolean) { + this.name = name; + this.description = description; + this.enabled = enabled; + } +} diff --git a/src/routes/accounts/entities/account-data-type.entity.ts b/src/routes/accounts/entities/account-data-type.entity.ts index 11d80c9b10..16d0510fa5 100644 --- a/src/routes/accounts/entities/account-data-type.entity.ts +++ b/src/routes/accounts/entities/account-data-type.entity.ts @@ -16,7 +16,7 @@ export class AccountDataType { description: string | null, isActive: boolean, ) { - this.dataTypeId = dataTypeId; + this.dataTypeId = dataTypeId; // TODO: rename as 'id' this.name = name; this.description = description; this.isActive = isActive; diff --git a/src/routes/accounts/entities/create-account.dto.entity.ts b/src/routes/accounts/entities/create-account.dto.entity.ts index dac9f5bf47..d202562c97 100644 --- a/src/routes/accounts/entities/create-account.dto.entity.ts +++ b/src/routes/accounts/entities/create-account.dto.entity.ts @@ -1,10 +1,7 @@ -import { CreateAccountDtoSchema } from '@/routes/accounts/entities/schemas/create-account.dto.schema'; +import { CreateAccountDto as DomainCreateAccountDto } from '@/domain/accounts/entities/create-account.dto.entity'; import { ApiProperty } from '@nestjs/swagger'; -import { z } from 'zod'; -export class CreateAccountDto - implements z.infer -{ +export class CreateAccountDto implements DomainCreateAccountDto { @ApiProperty() address!: `0x${string}`; } diff --git a/src/routes/accounts/entities/upsert-account-data-settings.dto.entity.ts b/src/routes/accounts/entities/upsert-account-data-settings.dto.entity.ts new file mode 100644 index 0000000000..266294b499 --- /dev/null +++ b/src/routes/accounts/entities/upsert-account-data-settings.dto.entity.ts @@ -0,0 +1,16 @@ +import { UpsertAccountDataSettingsDto as DomainUpsertAccountDataSettingsDto } from '@/domain/accounts/entities/upsert-account-data-settings.dto.entity'; +import { ApiProperty } from '@nestjs/swagger'; + +class UpsertAccountDataSettingDto { + @ApiProperty() + id!: string; // A 'numeric string' type is used to align with other API endpoints + @ApiProperty() + enabled!: boolean; +} + +export class UpsertAccountDataSettingsDto + implements DomainUpsertAccountDataSettingsDto +{ + @ApiProperty({ type: UpsertAccountDataSettingDto, isArray: true }) + accountDataSettings!: UpsertAccountDataSettingDto[]; +} From fa38aac0fd30aa9e4971a883b3c71e430b8bd81e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Wed, 10 Jul 2024 17:53:08 +0200 Subject: [PATCH 172/207] Implement GET /v1/accounts/:address/data-settings (#1746) - The endpoint `GET /v1/accounts/:address/data-settings` was added to `AccountsController`, protected by `AuthGuard`. --- .../test.accounts.datasource.module.ts | 1 + .../accounts/accounts.datasource.spec.ts | 73 +++++++++ .../accounts/accounts.datasource.ts | 6 +- .../accounts/accounts.repository.interface.ts | 5 + src/domain/accounts/accounts.repository.ts | 11 ++ .../accounts.datasource.interface.ts | 2 + .../accounts/accounts.controller.spec.ts | 153 +++++++++++++++++- src/routes/accounts/accounts.controller.ts | 13 ++ src/routes/accounts/accounts.service.ts | 17 ++ 9 files changed, 278 insertions(+), 3 deletions(-) diff --git a/src/datasources/accounts/__tests__/test.accounts.datasource.module.ts b/src/datasources/accounts/__tests__/test.accounts.datasource.module.ts index 051a5810b7..9271f9fc57 100644 --- a/src/datasources/accounts/__tests__/test.accounts.datasource.module.ts +++ b/src/datasources/accounts/__tests__/test.accounts.datasource.module.ts @@ -6,6 +6,7 @@ const accountsDatasource = { deleteAccount: jest.fn(), getAccount: jest.fn(), getDataTypes: jest.fn(), + getAccountDataSettings: jest.fn(), upsertAccountDataSettings: jest.fn(), } as jest.MockedObjectDeep; diff --git a/src/datasources/accounts/accounts.datasource.spec.ts b/src/datasources/accounts/accounts.datasource.spec.ts index 59088e1e74..6f1efc7f45 100644 --- a/src/datasources/accounts/accounts.datasource.spec.ts +++ b/src/datasources/accounts/accounts.datasource.spec.ts @@ -3,6 +3,7 @@ import { AccountsDatasource } from '@/datasources/accounts/accounts.datasource'; import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; import { accountDataTypeBuilder } from '@/domain/accounts/entities/__tests__/account-data-type.builder'; import { upsertAccountDataSettingsDtoBuilder } from '@/domain/accounts/entities/__tests__/upsert-account-data-settings.dto.entity.builder'; +import { AccountDataType } from '@/domain/accounts/entities/account-data-type.entity'; import { ILoggingService } from '@/logging/logging.interface'; import { faker } from '@faker-js/faker'; import postgres from 'postgres'; @@ -151,6 +152,78 @@ describe('AccountsDatasource tests', () => { }); }); + describe('getAccountDataSettings', () => { + it('should get the account data settings successfully', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const account = await target.createAccount(address); + const accountDataTypes = Array.from( + { length: faker.number.int({ min: 1, max: 4 }) }, + () => accountDataTypeBuilder().with('is_active', true).build(), + ); + const insertedDataTypes = + await sql`INSERT INTO account_data_types ${sql(accountDataTypes, 'name', 'is_active')} returning *`; + const accountDataSettingRows = insertedDataTypes.map((dataType) => ({ + account_id: account.id, + account_data_type_id: dataType.id, + enabled: faker.datatype.boolean(), + })); + await sql` + INSERT INTO account_data_settings + ${sql(accountDataSettingRows, 'account_id', 'account_data_type_id', 'enabled')} returning *`; + + const actual = await target.getAccountDataSettings(address); + + const expected = accountDataSettingRows.map((accountDataSettingRow) => ({ + account_id: account.id, + account_data_type_id: accountDataSettingRow.account_data_type_id, + enabled: accountDataSettingRow.enabled, + created_at: expect.any(Date), + updated_at: expect.any(Date), + })); + + expect(actual).toStrictEqual(expect.arrayContaining(expected)); + }); + + it('should omit account data settings which data type is not active', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const account = await target.createAccount(address); + const accountDataTypes = Array.from( + { length: faker.number.int({ min: 1, max: 4 }) }, + () => accountDataTypeBuilder().with('is_active', true).build(), + ); + accountDataTypes.push( + accountDataTypeBuilder().with('is_active', false).build(), + ); + const insertedDataTypes = + await sql`INSERT INTO account_data_types ${sql(accountDataTypes, 'name', 'is_active')} returning *`; + const [inactiveDataType] = await sql< + AccountDataType[] + >`SELECT * FROM account_data_types WHERE is_active IS FALSE`; + const accountDataSettingRows = insertedDataTypes.map((dataType) => ({ + account_id: account.id, + account_data_type_id: dataType.id, + enabled: faker.datatype.boolean(), + })); + await sql` + INSERT INTO account_data_settings + ${sql(accountDataSettingRows, 'account_id', 'account_data_type_id', 'enabled')} returning *`; + + const actual = await target.getAccountDataSettings(address); + + const expected = accountDataSettingRows + .map((accountDataSettingRow) => ({ + account_id: account.id, + account_data_type_id: accountDataSettingRow.account_data_type_id, + enabled: accountDataSettingRow.enabled, + created_at: expect.any(Date), + updated_at: expect.any(Date), + })) + .filter((ads) => ads.account_data_type_id !== inactiveDataType.id); + + expect(actual).toStrictEqual(expect.arrayContaining(expected)); + }); + }); + describe('upsertAccountDataSettings', () => { it('adds account data settings successfully', async () => { const address = getAddress(faker.finance.ethereumAddress()); diff --git a/src/datasources/accounts/accounts.datasource.ts b/src/datasources/accounts/accounts.datasource.ts index e6ba168d20..70dfe66075 100644 --- a/src/datasources/accounts/accounts.datasource.ts +++ b/src/datasources/accounts/accounts.datasource.ts @@ -73,7 +73,9 @@ export class AccountsDatasource implements IAccountsDatasource { ): Promise { const account = await this.getAccount(address); return this.sql<[AccountDataSetting]>` - SELECT * FROM account_data_settings WHERE account_id = ${account.id} + SELECT ads.* FROM account_data_settings ads INNER JOIN account_data_types adt + ON ads.account_data_type_id = adt.id + WHERE ads.account_id = ${account.id} AND adt.is_active IS TRUE; `; } @@ -117,7 +119,7 @@ export class AccountsDatasource implements IAccountsDatasource { private getActiveDataTypes(): Promise { // TODO: add caching with clearing mechanism. return this.sql<[AccountDataType]>` - SELECT * FROM account_data_types WHERE is_active = true + SELECT * FROM account_data_types WHERE is_active IS TRUE; `; } diff --git a/src/domain/accounts/accounts.repository.interface.ts b/src/domain/accounts/accounts.repository.interface.ts index 66b5511154..5a55c299ec 100644 --- a/src/domain/accounts/accounts.repository.interface.ts +++ b/src/domain/accounts/accounts.repository.interface.ts @@ -27,6 +27,11 @@ export interface IAccountsRepository { getDataTypes(): Promise; + getAccountDataSettings(args: { + authPayload: AuthPayload; + address: `0x${string}`; + }): Promise; + upsertAccountDataSettings(args: { authPayload: AuthPayload; address: `0x${string}`; diff --git a/src/domain/accounts/accounts.repository.ts b/src/domain/accounts/accounts.repository.ts index ccb9a0403c..b61259addb 100644 --- a/src/domain/accounts/accounts.repository.ts +++ b/src/domain/accounts/accounts.repository.ts @@ -54,6 +54,17 @@ export class AccountsRepository implements IAccountsRepository { return this.datasource.getDataTypes(); } + async getAccountDataSettings(args: { + authPayload: AuthPayload; + address: `0x${string}`; + }): Promise { + if (!args.authPayload.isForSigner(args.address)) { + throw new UnauthorizedException(); + } + + return this.datasource.getAccountDataSettings(args.address); + } + async upsertAccountDataSettings(args: { authPayload: AuthPayload; address: `0x${string}`; diff --git a/src/domain/interfaces/accounts.datasource.interface.ts b/src/domain/interfaces/accounts.datasource.interface.ts index 8ba27815c2..3b3fdac598 100644 --- a/src/domain/interfaces/accounts.datasource.interface.ts +++ b/src/domain/interfaces/accounts.datasource.interface.ts @@ -14,6 +14,8 @@ export interface IAccountsDatasource { getDataTypes(): Promise; + getAccountDataSettings(address: `0x${string}`): Promise; + upsertAccountDataSettings( address: `0x${string}`, upsertAccountDataSettings: UpsertAccountDataSettingsDto, diff --git a/src/routes/accounts/accounts.controller.spec.ts b/src/routes/accounts/accounts.controller.spec.ts index afe9ad557b..b86f0592b6 100644 --- a/src/routes/accounts/accounts.controller.spec.ts +++ b/src/routes/accounts/accounts.controller.spec.ts @@ -613,7 +613,6 @@ describe('AccountsController', () => { ]; const upsertAccountDataSettingsDto = upsertAccountDataSettingsDtoBuilder().build(); - accountDataSource.createAccount.mockResolvedValue(account); accountDataSource.getDataTypes.mockResolvedValue(dataTypes); accountDataSource.upsertAccountDataSettings.mockResolvedValue( domainAccountDataSettings, @@ -769,4 +768,156 @@ describe('AccountsController', () => { ); }); }); + + describe('Get account data settings', () => { + it('should get the data settings for an account', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + const account = accountBuilder().build(); + const dataTypes = [ + accountDataTypeBuilder().build(), + accountDataTypeBuilder().build(), + ]; + const domainAccountDataSettings = [ + accountDataSettingBuilder() + .with('account_id', account.id) + .with('account_data_type_id', dataTypes[0].id) + .build(), + accountDataSettingBuilder() + .with('account_id', account.id) + .with('account_data_type_id', dataTypes[1].id) + .build(), + ]; + accountDataSource.getDataTypes.mockResolvedValue(dataTypes); + accountDataSource.getAccountDataSettings.mockResolvedValue( + domainAccountDataSettings, + ); + + await request(app.getHttpServer()) + .get(`/v1/accounts/${address}/data-settings`) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(200); + + expect(accountDataSource.getAccountDataSettings).toHaveBeenCalledTimes(1); + expect(accountDataSource.getAccountDataSettings).toHaveBeenCalledWith( + address, + ); + }); + + it('Returns 403 if no token is present', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + + await request(app.getHttpServer()) + .get(`/v1/accounts/${address}/data-settings`) + .send({ address }) + .expect(403); + }); + + it('returns 403 if token is not a valid JWT', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const accessToken = faker.string.sample(); + + expect(() => jwtService.verify(accessToken)).toThrow('jwt malformed'); + await request(app.getHttpServer()) + .get(`/v1/accounts/${address}/data-settings`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address }) + .expect(403); + }); + + it('returns 403 is token it not yet valid', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto, { + notBefore: getSecondsUntil(faker.date.future()), + }); + + await request(app.getHttpServer()) + .get(`/v1/accounts/${address}/data-settings`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address }) + .expect(403); + }); + + it('returns 403 if token has expired', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto, { expiresIn: 0 }); + jest.advanceTimersByTime(1_000); + + await request(app.getHttpServer()) + .get(`/v1/accounts/${address}/data-settings`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address }) + .expect(403); + }); + + it('returns 403 if signer_address is not a valid Ethereum address', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', faker.string.hexadecimal() as `0x${string}`) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + + await request(app.getHttpServer()) + .get(`/v1/accounts/${address}/data-settings`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address }) + .expect(403); + }); + + it('returns 403 if chain_id is not a valid chain ID', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', faker.lorem.sentence()) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + + await request(app.getHttpServer()) + .get(`/v1/accounts/${address}/data-settings`) + .set('Cookie', [`access_token=${accessToken}`]) + .send({ address }) + .expect(403); + }); + + it('should throw an error if the datasource fails', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .with('signer_address', address) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + accountDataSource.getAccountDataSettings.mockImplementation(() => { + throw new Error('test error'); + }); + + await request(app.getHttpServer()) + .get(`/v1/accounts/${address}/data-settings`) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(500) + .expect({ + code: 500, + message: 'Internal server error', + }); + + expect(accountDataSource.getAccountDataSettings).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/routes/accounts/accounts.controller.ts b/src/routes/accounts/accounts.controller.ts index faa39f1070..f6b6da5918 100644 --- a/src/routes/accounts/accounts.controller.ts +++ b/src/routes/accounts/accounts.controller.ts @@ -51,6 +51,19 @@ export class AccountsController { return this.accountsService.getDataTypes(); } + @ApiOkResponse({ type: AccountDataSetting, isArray: true }) + @Get(':address/data-settings') + @UseGuards(AuthGuard) + async getAccountDataSettings( + @Auth() authPayload: AuthPayload, + @Param('address', new ValidationPipe(AddressSchema)) address: `0x${string}`, + ): Promise { + return this.accountsService.getAccountDataSettings({ + authPayload, + address, + }); + } + @ApiOkResponse({ type: AccountDataSetting, isArray: true }) @Put(':address/data-settings') @UseGuards(AuthGuard) diff --git a/src/routes/accounts/accounts.service.ts b/src/routes/accounts/accounts.service.ts index 1cd883a330..7b65d52408 100644 --- a/src/routes/accounts/accounts.service.ts +++ b/src/routes/accounts/accounts.service.ts @@ -56,6 +56,23 @@ export class AccountsService { ); } + async getAccountDataSettings(args: { + authPayload: AuthPayload; + address: `0x${string}`; + }): Promise { + const [domainAccountDataSettings, dataTypes] = await Promise.all([ + this.accountsRepository.getAccountDataSettings({ + authPayload: args.authPayload, + address: args.address, + }), + this.accountsRepository.getDataTypes(), + ]); + + return domainAccountDataSettings.map((domainAccountDataSetting) => + this.mapDataSetting(dataTypes, domainAccountDataSetting), + ); + } + async upsertAccountDataSettings(args: { authPayload: AuthPayload; address: `0x${string}`; From 890f405c553b27b1285f9958f73542bfd7609514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Wed, 10 Jul 2024 17:53:23 +0200 Subject: [PATCH 173/207] Delete Settings on Account/DataType deletion (#1748) - Adds `ON DELETE CASCADE` triggers to both `account_data_settings.account_id` and `account_data_settings.account_data_type_id` fields foreign keys. --- .../00003_account-data-settings/index.sql | 4 +- .../00003_account-data-settings.spec.ts | 60 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/migrations/00003_account-data-settings/index.sql b/migrations/00003_account-data-settings/index.sql index 3a49d77ef2..49cc29acf0 100644 --- a/migrations/00003_account-data-settings/index.sql +++ b/migrations/00003_account-data-settings/index.sql @@ -7,8 +7,8 @@ CREATE TABLE account_data_settings ( created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), PRIMARY KEY (account_id, account_data_type_id), - FOREIGN KEY (account_id) REFERENCES accounts(id), - FOREIGN KEY (account_data_type_id) REFERENCES account_data_types(id) + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, + FOREIGN KEY (account_data_type_id) REFERENCES account_data_types(id) ON DELETE CASCADE ); CREATE OR REPLACE TRIGGER update_account_data_settings_updated_at diff --git a/migrations/__tests__/00003_account-data-settings.spec.ts b/migrations/__tests__/00003_account-data-settings.spec.ts index 449e058126..a8a5ea0a33 100644 --- a/migrations/__tests__/00003_account-data-settings.spec.ts +++ b/migrations/__tests__/00003_account-data-settings.spec.ts @@ -121,4 +121,64 @@ describe('Migration 00003_account-data-settings', () => { expect(createdAtAfterUpdate).toStrictEqual(createdAt); expect(updatedAtAfterUpdate.getTime()).toBeGreaterThan(createdAt.getTime()); }); + + it('should trigger a cascade delete when the referenced account is deleted', async () => { + const accountAddress = getAddress(faker.finance.ethereumAddress()); + const name = faker.lorem.word(); + let accountRows: AccountRow[] = []; + let accountDataTypeRows: AccountDataTypeRow[] = []; + + const { + after: accountDataSettingRows, + }: { after: AccountDataSettingsRow[] } = await migrator.test({ + migration: '00003_account-data-settings', + after: async (sql: postgres.Sql): Promise => { + accountRows = await sql< + AccountRow[] + >`INSERT INTO accounts (address) VALUES (${accountAddress}) RETURNING *;`; + accountDataTypeRows = await sql< + AccountDataTypeRow[] + >`INSERT INTO account_data_types (name) VALUES (${name}) RETURNING *;`; + await sql< + AccountDataSettingsRow[] + >`INSERT INTO account_data_settings (account_id, account_data_type_id) VALUES (${accountRows[0].id}, ${accountDataTypeRows[0].id}) RETURNING *;`; + await sql`DELETE FROM accounts WHERE id = ${accountRows[0].id};`; + return sql< + AccountDataSettingsRow[] + >`SELECT * FROM account_data_settings WHERE account_id = ${accountRows[0].id}`; + }, + }); + + expect(accountDataSettingRows).toHaveLength(0); + }); + + it('should trigger a cascade delete when the referenced data type is deleted', async () => { + const accountAddress = getAddress(faker.finance.ethereumAddress()); + const name = faker.lorem.word(); + let accountRows: AccountRow[] = []; + let accountDataTypeRows: AccountDataTypeRow[] = []; + + const { + after: accountDataSettingRows, + }: { after: AccountDataSettingsRow[] } = await migrator.test({ + migration: '00003_account-data-settings', + after: async (sql: postgres.Sql): Promise => { + accountRows = await sql< + AccountRow[] + >`INSERT INTO accounts (address) VALUES (${accountAddress}) RETURNING *;`; + accountDataTypeRows = await sql< + AccountDataTypeRow[] + >`INSERT INTO account_data_types (name) VALUES (${name}) RETURNING *;`; + await sql< + AccountDataSettingsRow[] + >`INSERT INTO account_data_settings (account_id, account_data_type_id) VALUES (${accountRows[0].id}, ${accountDataTypeRows[0].id}) RETURNING *;`; + await sql`DELETE FROM account_data_types WHERE id = ${accountDataTypeRows[0].id};`; + return sql< + AccountDataSettingsRow[] + >`SELECT * FROM account_data_settings WHERE account_id = ${accountRows[0].id}`; + }, + }); + + expect(accountDataSettingRows).toHaveLength(0); + }); }); From 2ce4ac81a1e84d9d436c9fffef5ca12c393f092c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Wed, 10 Jul 2024 18:03:30 +0200 Subject: [PATCH 174/207] Rename AccountDataType.dataTypeId to AccountDataType.id (#1751) - Rename `AccountDataType.dataTypeId` to `AccountDataType.id`. --- src/routes/accounts/accounts.controller.spec.ts | 2 +- src/routes/accounts/entities/account-data-type.entity.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/routes/accounts/accounts.controller.spec.ts b/src/routes/accounts/accounts.controller.spec.ts index b86f0592b6..da2727b783 100644 --- a/src/routes/accounts/accounts.controller.spec.ts +++ b/src/routes/accounts/accounts.controller.spec.ts @@ -560,7 +560,7 @@ describe('AccountsController', () => { ]; accountDataSource.getDataTypes.mockResolvedValue(dataTypes); const expected = dataTypes.map((dataType) => ({ - dataTypeId: dataType.id.toString(), + id: dataType.id.toString(), name: dataType.name, description: dataType.description, isActive: dataType.is_active, diff --git a/src/routes/accounts/entities/account-data-type.entity.ts b/src/routes/accounts/entities/account-data-type.entity.ts index 16d0510fa5..db8cbe7b26 100644 --- a/src/routes/accounts/entities/account-data-type.entity.ts +++ b/src/routes/accounts/entities/account-data-type.entity.ts @@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class AccountDataType { @ApiProperty() - dataTypeId: string; + id: string; @ApiProperty() name: string; @ApiPropertyOptional({ type: String, nullable: true }) @@ -11,12 +11,12 @@ export class AccountDataType { isActive: boolean; constructor( - dataTypeId: string, + id: string, name: string, description: string | null, isActive: boolean, ) { - this.dataTypeId = dataTypeId; // TODO: rename as 'id' + this.id = id; this.name = name; this.description = description; this.isActive = isActive; From 416d90b7598f7fd40c243bc6563b79920f15f5a0 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Wed, 10 Jul 2024 18:06:42 +0200 Subject: [PATCH 175/207] Refactor finding of swap-related transaction data (#1737) Refactors the `find-` methods relative to swap orders into a new helper: - Create `TransactionDataFinder` helper - Adjust `findSwapOrder` and `findTwapOrder` to use helper - Add/update tests accordingly. --- .../helpers/gp-v2-order.helper.spec.ts | 4 +- .../helpers/swap-order.helper.spec.ts | 10 +-- .../transactions/helpers/swap-order.helper.ts | 37 +++------ .../helpers/swap-transfer.helper.ts | 37 --------- .../transaction-data-finder.helper.spec.ts | 76 +++++++++++++++++++ .../helpers/transaction-data-finder.helper.ts | 45 +++++++++++ .../helpers/twap-order.helper.spec.ts | 4 +- .../transactions/helpers/twap-order.helper.ts | 44 +++-------- .../mappers/common/twap-order.mapper.spec.ts | 16 ++-- 9 files changed, 164 insertions(+), 109 deletions(-) delete mode 100644 src/routes/transactions/helpers/swap-transfer.helper.ts create mode 100644 src/routes/transactions/helpers/transaction-data-finder.helper.spec.ts create mode 100644 src/routes/transactions/helpers/transaction-data-finder.helper.ts diff --git a/src/routes/transactions/helpers/gp-v2-order.helper.spec.ts b/src/routes/transactions/helpers/gp-v2-order.helper.spec.ts index adda302612..8326d981c0 100644 --- a/src/routes/transactions/helpers/gp-v2-order.helper.spec.ts +++ b/src/routes/transactions/helpers/gp-v2-order.helper.spec.ts @@ -2,6 +2,7 @@ import { FakeConfigurationService } from '@/config/__tests__/fake.configuration. import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; import { ComposableCowDecoder } from '@/domain/swaps/contracts/decoders/composable-cow-decoder.helper'; import { GPv2OrderHelper } from '@/routes/transactions/helpers/gp-v2-order.helper'; +import { TransactionDataFinder } from '@/routes/transactions/helpers/transaction-data-finder.helper'; import { TwapOrderHelper } from '@/routes/transactions/helpers/twap-order.helper'; import { zeroAddress } from 'viem'; @@ -9,13 +10,14 @@ describe('GPv2OrderHelper', () => { const target = new GPv2OrderHelper(); const multiSendDecoder = new MultiSendDecoder(); + const transactionDataFinder = new TransactionDataFinder(multiSendDecoder); const composableCowDecoder = new ComposableCowDecoder(); const configurationService = new FakeConfigurationService(); const allowedApps = new Set(); configurationService.set('swaps.restrictApps', false); const twapOrderHelper = new TwapOrderHelper( configurationService, - multiSendDecoder, + transactionDataFinder, composableCowDecoder, allowedApps, ); diff --git a/src/routes/transactions/helpers/swap-order.helper.spec.ts b/src/routes/transactions/helpers/swap-order.helper.spec.ts index 28e859185c..5cbe58612b 100644 --- a/src/routes/transactions/helpers/swap-order.helper.spec.ts +++ b/src/routes/transactions/helpers/swap-order.helper.spec.ts @@ -8,7 +8,7 @@ import { getAddress } from 'viem'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { OrderKind, OrderStatus } from '@/domain/swaps/entities/order.entity'; import { SwapOrderHelper } from '@/routes/transactions/helpers/swap-order.helper'; -import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; +import { TransactionDataFinder } from '@/routes/transactions/helpers/transaction-data-finder.helper'; import { IChainsRepository } from '@/domain/chains/chains.repository.interface'; import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; @@ -33,8 +33,8 @@ const configurationService = { } as jest.MockedObjectDeep; const configurationServiceMock = jest.mocked(configurationService); -const multiSendDecoder = {} as jest.Mocked; -const multiSendDecoderMock = jest.mocked(multiSendDecoder); +const transactionDataFinder = {} as jest.Mocked; +const transactionDataFinderMock = jest.mocked(transactionDataFinder); const chainsRepository = { getChain: jest.fn(), @@ -56,7 +56,7 @@ describe('Swap Order Helper tests', () => { }); target = new SwapOrderHelper( - multiSendDecoderMock, + transactionDataFinderMock, gpv2DecoderMock, tokenRepositoryMock, swapsRepositoryMock, @@ -225,7 +225,7 @@ describe('Swap Order Helper tests', () => { }); target = new SwapOrderHelper( - multiSendDecoderMock, + transactionDataFinderMock, gpv2DecoderMock, tokenRepositoryMock, swapsRepositoryMock, diff --git a/src/routes/transactions/helpers/swap-order.helper.ts b/src/routes/transactions/helpers/swap-order.helper.ts index 53eacfaddf..7b9b03b7ca 100644 --- a/src/routes/transactions/helpers/swap-order.helper.ts +++ b/src/routes/transactions/helpers/swap-order.helper.ts @@ -1,5 +1,8 @@ import { Inject, Injectable, Module } from '@nestjs/common'; -import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; +import { + TransactionDataFinder, + TransactionDataFinderModule, +} from '@/routes/transactions/helpers/transaction-data-finder.helper'; import { GPv2Decoder } from '@/domain/swaps/contracts/decoders/gp-v2-decoder.helper'; import { ITokenRepository, @@ -33,7 +36,7 @@ export class SwapOrderHelper { this.configurationService.getOrThrow('swaps.explorerBaseUri'); constructor( - private readonly multiSendDecoder: MultiSendDecoder, + private readonly transactionDataFinder: TransactionDataFinder, private readonly gpv2Decoder: GPv2Decoder, @Inject(ITokenRepository) private readonly tokenRepository: ITokenRepository, @@ -46,8 +49,6 @@ export class SwapOrderHelper { private readonly chainsRepository: IChainsRepository, ) {} - // TODO: Refactor findSwapOrder, findSwapTransfer and findTwapOrder to avoid code duplication - /** * Finds the swap order in the transaction data. * The swap order can be in the transaction data directly or in the data of a Multisend transaction. @@ -57,22 +58,11 @@ export class SwapOrderHelper { * @returns The swap order if found, otherwise null */ public findSwapOrder(data: `0x${string}`): `0x${string}` | null { - // The swap order can be in the transaction data directly - if (this.isSwapOrder({ data })) { - return data; - } - // or in the data of a multisend transaction - if (this.multiSendDecoder.helpers.isMultiSend(data)) { - const transactions = this.multiSendDecoder.mapMultiSendTransactions(data); - // TODO If we can build a sorted hash map of the transactions, we can avoid iterating all of them - // as we know the pattern of a Swap Order. - for (const transaction of transactions) { - if (this.isSwapOrder(transaction)) { - return transaction.data; - } - } - } - return null; + return this.transactionDataFinder.findTransactionData( + (transaction) => + this.gpv2Decoder.helpers.isSetPreSignature(transaction.data), + { data }, + ); } /** @@ -132,11 +122,6 @@ export class SwapOrderHelper { ); } - private isSwapOrder(transaction: { data?: `0x${string}` }): boolean { - if (!transaction.data) return false; - return this.gpv2Decoder.helpers.isSetPreSignature(transaction.data); - } - /** * Retrieves a token object based on the provided Ethereum chain ID and token address. * If the specified address is the placeholder for the native currency of the chain, @@ -201,10 +186,10 @@ function allowedAppsFactory( ChainsRepositoryModule, SwapsRepositoryModule, TokenRepositoryModule, + TransactionDataFinderModule, ], providers: [ SwapOrderHelper, - MultiSendDecoder, GPv2Decoder, { provide: 'SWAP_ALLOWED_APPS', diff --git a/src/routes/transactions/helpers/swap-transfer.helper.ts b/src/routes/transactions/helpers/swap-transfer.helper.ts deleted file mode 100644 index 4162680b45..0000000000 --- a/src/routes/transactions/helpers/swap-transfer.helper.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; -import { GPv2Decoder } from '@/domain/swaps/contracts/decoders/gp-v2-decoder.helper'; -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class SwapTransferHelper { - constructor( - private readonly multiSendDecoder: MultiSendDecoder, - private readonly gpv2Decoder: GPv2Decoder, - ) {} - - // TODO: Refactor findSwapOrder, findSwapTransfer and findTwapOrder to avoid code duplication - - /** - * Finds the `settle` transaction in provided data. - * The call can either be direct or parsed from within a MultiSend batch. - * - * @param data - transaction data to search for the `settle` transaction in - * @returns transaction data of `settle` transaction if found, otherwise null - */ - public findSwapTransfer(data: `0x${string}`): `0x${string}` | null { - if (this.gpv2Decoder.helpers.isSettle(data)) { - return data; - } - - if (this.multiSendDecoder.helpers.isMultiSend(data)) { - const transactions = this.multiSendDecoder.mapMultiSendTransactions(data); - for (const transaction of transactions) { - if (this.gpv2Decoder.helpers.isSettle(transaction.data)) { - return transaction.data; - } - } - } - - return null; - } -} diff --git a/src/routes/transactions/helpers/transaction-data-finder.helper.spec.ts b/src/routes/transactions/helpers/transaction-data-finder.helper.spec.ts new file mode 100644 index 0000000000..236c445124 --- /dev/null +++ b/src/routes/transactions/helpers/transaction-data-finder.helper.spec.ts @@ -0,0 +1,76 @@ +import { + multiSendEncoder, + multiSendTransactionsEncoder, +} from '@/domain/contracts/__tests__/encoders/multi-send-encoder.builder'; +import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; +import { TransactionDataFinder } from '@/routes/transactions/helpers/transaction-data-finder.helper'; +import { faker } from '@faker-js/faker'; +import { encodeFunctionData, erc20Abi, getAddress } from 'viem'; + +describe('TransactionDataFinder', () => { + let target: TransactionDataFinder; + + beforeEach(() => { + jest.resetAllMocks(); + const multiSendDecoder = new MultiSendDecoder(); + target = new TransactionDataFinder(multiSendDecoder); + }); + + it('should return the given transaction data if it is the expected one', () => { + const transaction = { + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [getAddress(faker.finance.ethereumAddress()), BigInt(0)], + }), + }; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const isTransactionData = (_: unknown): boolean => true; + + const result = target.findTransactionData(isTransactionData, transaction); + + expect(result).toBe(transaction.data); + }); + + it('should return the transaction data if it is found in a MultiSend transaction', () => { + const transaction = { + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [getAddress(faker.finance.ethereumAddress()), BigInt(0)], + }), + to: getAddress(faker.finance.ethereumAddress()), + operation: 0, + value: BigInt(0), + }; + const multiSend = multiSendEncoder().with( + 'transactions', + multiSendTransactionsEncoder([transaction]), + ); + const isTransactionData = (args: { data: `0x${string}` }): boolean => { + return args.data === transaction.data; + }; + + const result = target.findTransactionData(isTransactionData, { + data: multiSend.encode(), + }); + + expect(result).toBe(transaction.data); + }); + + it('should return null if the transaction data is not found', () => { + const transaction = { + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [getAddress(faker.finance.ethereumAddress()), BigInt(0)], + }), + }; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const isTransactionData = (_: unknown): boolean => false; + + const result = target.findTransactionData(isTransactionData, transaction); + + expect(result).toBe(null); + }); +}); diff --git a/src/routes/transactions/helpers/transaction-data-finder.helper.ts b/src/routes/transactions/helpers/transaction-data-finder.helper.ts new file mode 100644 index 0000000000..41825260c3 --- /dev/null +++ b/src/routes/transactions/helpers/transaction-data-finder.helper.ts @@ -0,0 +1,45 @@ +import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; +import { Injectable, Module } from '@nestjs/common'; + +@Injectable() +export class TransactionDataFinder { + constructor(private readonly multiSendDecoder: MultiSendDecoder) {} + + /** + * Finds transaction data in a given transaction, if directly called or in a MultiSend + * + * @param isTransactionData - function to determine if the transaction data is the one we are looking for + * @param transaction - transaction to search for the data + * @returns transaction data if found, otherwise null + */ + public findTransactionData( + isTransactionData: (args: { + to?: `0x${string}`; + data: `0x${string}`; + }) => boolean, + transaction: { to?: `0x${string}`; data: `0x${string}` }, + ): `0x${string}` | null { + if (isTransactionData(transaction)) { + return transaction.data; + } + + if (this.multiSendDecoder.helpers.isMultiSend(transaction.data)) { + const batchedTransaction = this.multiSendDecoder + .mapMultiSendTransactions(transaction.data) + .find(isTransactionData); + + if (batchedTransaction) { + return batchedTransaction.data; + } + } + + return null; + } +} + +@Module({ + imports: [], + providers: [TransactionDataFinder, MultiSendDecoder], + exports: [TransactionDataFinder], +}) +export class TransactionDataFinderModule {} diff --git a/src/routes/transactions/helpers/twap-order.helper.spec.ts b/src/routes/transactions/helpers/twap-order.helper.spec.ts index f2737d452b..e7e00e9a6c 100644 --- a/src/routes/transactions/helpers/twap-order.helper.spec.ts +++ b/src/routes/transactions/helpers/twap-order.helper.spec.ts @@ -10,6 +10,7 @@ import { createWithContextEncoder, } from '@/domain/swaps/contracts/__tests__/encoders/composable-cow-encoder.builder'; import { ComposableCowDecoder } from '@/domain/swaps/contracts/decoders/composable-cow-decoder.helper'; +import { TransactionDataFinder } from '@/routes/transactions/helpers/transaction-data-finder.helper'; import { TwapOrderHelper } from '@/routes/transactions/helpers/twap-order.helper'; import { faker } from '@faker-js/faker'; import { getAddress, zeroAddress } from 'viem'; @@ -29,13 +30,14 @@ describe('TwapOrderHelper', () => { '0x8d80ff0a000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000003cb0031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000024f08a03230000000000000000000000002f55e8b20d0b9fefa187aa7d00b6cbe563605bf50031eac7f0141837b266de30f4dc9af15629bd5381000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000443365582cdaee378bd0eb30ddf479272accf91761e697bc00e067a268f95f1d2732ed230b000000000000000000000000fdafc9d1902f4e0b84f65f49f244b32b31013b7400fdafc9d1902f4e0b84f65f49f244b32b31013b74000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002640d0d9800000000000000000000000000000000000000000000000000000000000000008000000000000000000000000052ed56da04309aca4c3fecc595298d80c2f16bac000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000000010000000000000000000000006cf1e9ca41f7611def408122793c358a3d11e5a500000000000000000000000000000000000000000000000000000019011918e600000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000140000000000000000000000000fff9976782d46cc05630d1f6ebab18b2324d6b14000000000000000000000000be72e441bf55620febc26715db68d3494213d8cb00000000000000000000000031eac7f0141837b266de30f4dc9af15629bd538100000000000000000000000000000000000000000000000003782dace9d90000000000000000000000000000000000000000000000000003b1b5fbf83bf2f7160000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000003840000000000000000000000000000000000000000000000000000000000000000f7be7261f56698c258bf75f888d68a00c85b22fb21958b9009c719eb88aebda00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'; const multiSendDecoder = new MultiSendDecoder(); + const transactionDataFinder = new TransactionDataFinder(multiSendDecoder); const composableCowDecoder = new ComposableCowDecoder(); const configurationService = new FakeConfigurationService(); const allowedApps = new Set(); configurationService.set('swaps.restrictApps', false); const target = new TwapOrderHelper( configurationService, - multiSendDecoder, + transactionDataFinder, composableCowDecoder, allowedApps, ); diff --git a/src/routes/transactions/helpers/twap-order.helper.ts b/src/routes/transactions/helpers/twap-order.helper.ts index 6e08d154e9..ab055aa813 100644 --- a/src/routes/transactions/helpers/twap-order.helper.ts +++ b/src/routes/transactions/helpers/twap-order.helper.ts @@ -1,4 +1,7 @@ -import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; +import { + TransactionDataFinder, + TransactionDataFinderModule, +} from '@/routes/transactions/helpers/transaction-data-finder.helper'; import { ComposableCowDecoder, TwapStruct, @@ -36,7 +39,7 @@ export class TwapOrderHelper { constructor( @Inject(IConfigurationService) private readonly configurationService: IConfigurationService, - private readonly multiSendDecoder: MultiSendDecoder, + private readonly transactionDataFinder: TransactionDataFinder, private readonly composableCowDecoder: ComposableCowDecoder, @Inject('SWAP_ALLOWED_APPS') private readonly allowedApps: Set, ) { @@ -44,8 +47,6 @@ export class TwapOrderHelper { this.configurationService.getOrThrow('swaps.restrictApps'); } - // TODO: Refactor findSwapOrder, findSwapTransfer and findTwapOrder to avoid code duplication - /** * Finds a TWAP order in a given transaction, either directly called or in a MultiSend * @@ -57,33 +58,13 @@ export class TwapOrderHelper { to: `0x${string}`; data: `0x${string}`; }): `0x${string}` | null { - if (this.isTwapOrder(args)) { - return args.data; - } - - if (this.multiSendDecoder.helpers.isMultiSend(args.data)) { - const transactions = this.multiSendDecoder.mapMultiSendTransactions( - args.data, + return this.transactionDataFinder.findTransactionData(({ to, data }) => { + return ( + !!to && + isAddressEqual(to, TwapOrderHelper.ComposableCowAddress) && + this.composableCowDecoder.helpers.isCreateWithContext(data) ); - - for (const transaction of transactions) { - if (this.isTwapOrder(transaction)) { - return transaction.data; - } - } - } - - return null; - } - - private isTwapOrder(args: { - to: `0x${string}`; - data: `0x${string}`; - }): boolean { - return ( - isAddressEqual(args.to, TwapOrderHelper.ComposableCowAddress) && - this.composableCowDecoder.helpers.isCreateWithContext(args.data) - ); + }, args); } /** @@ -216,10 +197,9 @@ function allowedAppsFactory( } @Module({ - imports: [], + imports: [TransactionDataFinderModule], providers: [ ComposableCowDecoder, - MultiSendDecoder, TwapOrderHelper, { provide: 'SWAP_ALLOWED_APPS', diff --git a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts index 7e2d191ab3..f2378ba16c 100644 --- a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts +++ b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts @@ -16,6 +16,7 @@ import { TwapOrderMapper } from '@/routes/transactions/mappers/common/twap-order import { ILoggingService } from '@/logging/logging.interface'; import { getAddress } from 'viem'; import { fullAppDataBuilder } from '@/domain/swaps/entities/__tests__/full-app-data.builder'; +import { TransactionDataFinder } from '@/routes/transactions/helpers/transaction-data-finder.helper'; const loggingService = { debug: jest.fn(), @@ -43,10 +44,11 @@ const mockChainsRepository = { describe('TwapOrderMapper', () => { const configurationService = new FakeConfigurationService(); const multiSendDecoder = new MultiSendDecoder(); + const transactionDataFinder = new TransactionDataFinder(multiSendDecoder); const gpv2Decoder = new GPv2Decoder(mockLoggingService); const allowedApps = new Set(); const swapOrderHelper = new SwapOrderHelper( - multiSendDecoder, + transactionDataFinder, gpv2Decoder, mockTokenRepository, mockSwapsRepository, @@ -59,7 +61,7 @@ describe('TwapOrderMapper', () => { configurationService.set('swaps.restrictApps', false); const twapOrderHelper = new TwapOrderHelper( configurationService, - multiSendDecoder, + transactionDataFinder, composableCowDecoder, allowedApps, ); @@ -508,7 +510,7 @@ describe('TwapOrderMapper', () => { gpv2OrderHelper, new TwapOrderHelper( configurationService, - multiSendDecoder, + transactionDataFinder, composableCowDecoder, allowedApps, ), @@ -555,7 +557,7 @@ describe('TwapOrderMapper', () => { gpv2OrderHelper, new TwapOrderHelper( configurationService, - multiSendDecoder, + transactionDataFinder, composableCowDecoder, new Set(['app1', 'app2']), ), @@ -599,7 +601,7 @@ describe('TwapOrderMapper', () => { gpv2OrderHelper, new TwapOrderHelper( configurationService, - multiSendDecoder, + transactionDataFinder, composableCowDecoder, allowedApps, ), @@ -701,7 +703,7 @@ describe('TwapOrderMapper', () => { gpv2OrderHelper, new TwapOrderHelper( configurationService, - multiSendDecoder, + transactionDataFinder, composableCowDecoder, new Set(['Safe Wallet Swaps']), ), @@ -810,7 +812,7 @@ describe('TwapOrderMapper', () => { gpv2OrderHelper, new TwapOrderHelper( configurationService, - multiSendDecoder, + transactionDataFinder, composableCowDecoder, new Set(['app1', 'app2']), ), From c6016150fc12a5b062c1f873138cfdd87a496804 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 11 Jul 2024 09:06:47 +0200 Subject: [PATCH 176/207] Extract swap app allowance into helper (#1738) Extracts multiple checks for the allowance of swap transaction decoding to a new `SwapApps` helper, calling it in every places instead: - Create and import `SwapAppsHelper` - Replace all calls to `isAppAllowed` with that from the above helper - Add/update tests accordingly --- .../helpers/gp-v2-order.helper.spec.ts | 6 -- .../helpers/swap-apps.helper.spec.ts | 89 +++++++++++++++++++ .../transactions/helpers/swap-apps.helper.ts | 55 ++++++++++++ .../helpers/swap-order.helper.spec.ts | 43 --------- .../transactions/helpers/swap-order.helper.ts | 37 +------- .../helpers/twap-order.helper.spec.ts | 3 - .../transactions/helpers/twap-order.helper.ts | 47 +--------- .../mappers/common/swap-order.mapper.ts | 10 ++- .../mappers/common/twap-order.mapper.spec.ts | 47 +++------- .../mappers/common/twap-order.mapper.ts | 14 +-- .../swap-transfer-info.mapper.spec.ts | 15 ++-- .../transfers/swap-transfer-info.mapper.ts | 4 +- .../transactions-view.controller.ts | 2 + .../transactions/transactions-view.service.ts | 8 +- .../transactions/transactions.module.ts | 2 + 15 files changed, 199 insertions(+), 183 deletions(-) create mode 100644 src/routes/transactions/helpers/swap-apps.helper.spec.ts create mode 100644 src/routes/transactions/helpers/swap-apps.helper.ts diff --git a/src/routes/transactions/helpers/gp-v2-order.helper.spec.ts b/src/routes/transactions/helpers/gp-v2-order.helper.spec.ts index 8326d981c0..9e1b90e187 100644 --- a/src/routes/transactions/helpers/gp-v2-order.helper.spec.ts +++ b/src/routes/transactions/helpers/gp-v2-order.helper.spec.ts @@ -1,4 +1,3 @@ -import { FakeConfigurationService } from '@/config/__tests__/fake.configuration.service'; import { MultiSendDecoder } from '@/domain/contracts/decoders/multi-send-decoder.helper'; import { ComposableCowDecoder } from '@/domain/swaps/contracts/decoders/composable-cow-decoder.helper'; import { GPv2OrderHelper } from '@/routes/transactions/helpers/gp-v2-order.helper'; @@ -12,14 +11,9 @@ describe('GPv2OrderHelper', () => { const multiSendDecoder = new MultiSendDecoder(); const transactionDataFinder = new TransactionDataFinder(multiSendDecoder); const composableCowDecoder = new ComposableCowDecoder(); - const configurationService = new FakeConfigurationService(); - const allowedApps = new Set(); - configurationService.set('swaps.restrictApps', false); const twapOrderHelper = new TwapOrderHelper( - configurationService, transactionDataFinder, composableCowDecoder, - allowedApps, ); describe('computeOrderUid', () => { diff --git a/src/routes/transactions/helpers/swap-apps.helper.spec.ts b/src/routes/transactions/helpers/swap-apps.helper.spec.ts new file mode 100644 index 0000000000..9ebd644800 --- /dev/null +++ b/src/routes/transactions/helpers/swap-apps.helper.spec.ts @@ -0,0 +1,89 @@ +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { fullAppDataBuilder } from '@/domain/swaps/entities/__tests__/full-app-data.builder'; +import { SwapAppsHelper } from '@/routes/transactions/helpers/swap-apps.helper'; + +const configurationService = { + getOrThrow: jest.fn(), +} as jest.MockedObjectDeep; + +const configurationServiceMock = jest.mocked(configurationService); + +describe('SwapAppsHelper', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('Restricting disabled', () => { + beforeEach(() => { + configurationServiceMock.getOrThrow.mockImplementation((key) => { + if (key === 'swaps.restrictApps') return false; + throw new Error(`Key ${key} not found.`); + }); + }); + + it('should return true if restriction is disabled', () => { + const allowedApps = new Set(['69', '420']); + const target = new SwapAppsHelper(configurationServiceMock, allowedApps); + const fullAppData = fullAppDataBuilder() + .with('fullAppData', { + appCode: '1337', // Not allowed + }) + .build(); + + const result = target.isAppAllowed(fullAppData); + + expect(result).toBe(true); + }); + }); + + describe('Restricting enabled', () => { + beforeEach(() => { + configurationServiceMock.getOrThrow.mockImplementation((key) => { + if (key === 'swaps.restrictApps') return true; + throw new Error(`Key ${key} not found.`); + }); + }); + + it('should return true if the app is allowed', () => { + const allowedApps = new Set(['69', '420']); + const target = new SwapAppsHelper(configurationServiceMock, allowedApps); + const fullAppData = fullAppDataBuilder() + .with('fullAppData', { + appCode: '69', // Allowed + }) + .build(); + + const result = target.isAppAllowed(fullAppData); + + expect(result).toBe(true); + }); + + it('should return false if the app is not allowed', () => { + const allowedApps = new Set(['69', '420']); + const target = new SwapAppsHelper(configurationServiceMock, allowedApps); + const fullAppData = fullAppDataBuilder() + .with('fullAppData', { + appCode: '1337', // Not allowed + }) + .build(); + + const result = target.isAppAllowed(fullAppData); + + expect(result).toBe(false); + }); + + it('should return false if there is no appCode', () => { + const allowedApps = new Set(['69', '420']); + const target = new SwapAppsHelper(configurationServiceMock, allowedApps); + const fullAppData = fullAppDataBuilder() + .with('fullAppData', { + differing: 'fullAppData', // No appCode + }) + .build(); + + const result = target.isAppAllowed(fullAppData); + + expect(result).toBe(false); + }); + }); +}); diff --git a/src/routes/transactions/helpers/swap-apps.helper.ts b/src/routes/transactions/helpers/swap-apps.helper.ts new file mode 100644 index 0000000000..87ef057b30 --- /dev/null +++ b/src/routes/transactions/helpers/swap-apps.helper.ts @@ -0,0 +1,55 @@ +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { FullAppData } from '@/domain/swaps/entities/full-app-data.entity'; +import { Inject, Injectable, Module } from '@nestjs/common'; + +@Injectable() +export class SwapAppsHelper { + private readonly restrictApps: boolean; + + constructor( + @Inject(IConfigurationService) + private readonly configurationService: IConfigurationService, + @Inject('SWAP_ALLOWED_APPS') private readonly allowedApps: Set, + ) { + this.restrictApps = + this.configurationService.getOrThrow('swaps.restrictApps'); + } + + /** + * Checks if the app associated contained in fullAppData is allowed. + * + * @param fullAppData - object to which we should verify the app data with + * @returns true if the app is allowed, false otherwise. + */ + isAppAllowed({ fullAppData }: FullAppData): boolean { + if (!this.restrictApps) { + return true; + } + const appCode = fullAppData?.appCode; + return ( + !!appCode && typeof appCode === 'string' && this.allowedApps.has(appCode) + ); + } +} + +function allowedAppsFactory( + configurationService: IConfigurationService, +): Set { + const allowedApps = + configurationService.getOrThrow('swaps.allowedApps'); + return new Set(allowedApps); +} + +@Module({ + imports: [], + providers: [ + SwapAppsHelper, + { + provide: 'SWAP_ALLOWED_APPS', + useFactory: allowedAppsFactory, + inject: [IConfigurationService], + }, + ], + exports: [SwapAppsHelper], +}) +export class SwapAppsHelperModule {} diff --git a/src/routes/transactions/helpers/swap-order.helper.spec.ts b/src/routes/transactions/helpers/swap-order.helper.spec.ts index 5cbe58612b..26c4658b89 100644 --- a/src/routes/transactions/helpers/swap-order.helper.spec.ts +++ b/src/routes/transactions/helpers/swap-order.helper.spec.ts @@ -44,14 +44,11 @@ const chainsRepositoryMock = jest.mocked(chainsRepository); describe('Swap Order Helper tests', () => { let target: SwapOrderHelper; const explorerBaseUrl = faker.internet.url(); - const restrictApps = false; - const allowedApps = [faker.company.buzzNoun()]; beforeEach(() => { jest.resetAllMocks(); configurationServiceMock.getOrThrow.mockImplementation((key) => { if (key === 'swaps.explorerBaseUri') return explorerBaseUrl; - if (key === 'swaps.restrictApps') return restrictApps; throw new Error(`Key ${key} not found.`); }); @@ -61,7 +58,6 @@ describe('Swap Order Helper tests', () => { tokenRepositoryMock, swapsRepositoryMock, configurationServiceMock, - new Set(allowedApps), chainsRepositoryMock, ); }); @@ -214,43 +210,4 @@ describe('Swap Order Helper tests', () => { trusted: true, }); }); - - describe('Allowed Apps', () => { - beforeEach(() => { - jest.resetAllMocks(); - configurationServiceMock.getOrThrow.mockImplementation((key) => { - if (key === 'swaps.explorerBaseUri') return explorerBaseUrl; - if (key === 'swaps.restrictApps') return true; - throw new Error(`Key ${key} not found.`); - }); - - target = new SwapOrderHelper( - transactionDataFinderMock, - gpv2DecoderMock, - tokenRepositoryMock, - swapsRepositoryMock, - configurationServiceMock, - new Set(allowedApps), - chainsRepositoryMock, - ); - }); - - it('should not allow app not in allowedApp', () => { - const order = orderBuilder().build(); - - const actual = target.isAppAllowed(order); - - expect(actual).toBe(false); - }); - - it('should allow app in allowedApps', () => { - const order = orderBuilder() - .with('fullAppData', { appCode: allowedApps[0] }) - .build(); - - const actual = target.isAppAllowed(order); - - expect(actual).toBe(true); - }); - }); }); diff --git a/src/routes/transactions/helpers/swap-order.helper.ts b/src/routes/transactions/helpers/swap-order.helper.ts index 7b9b03b7ca..b3ae23e344 100644 --- a/src/routes/transactions/helpers/swap-order.helper.ts +++ b/src/routes/transactions/helpers/swap-order.helper.ts @@ -29,9 +29,6 @@ export class SwapOrderHelper { public static readonly NATIVE_CURRENCY_ADDRESS = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'; - private readonly restrictApps: boolean = - this.configurationService.getOrThrow('swaps.restrictApps'); - private readonly swapsExplorerBaseUri: string = this.configurationService.getOrThrow('swaps.explorerBaseUri'); @@ -44,7 +41,6 @@ export class SwapOrderHelper { private readonly swapsRepository: ISwapsRepository, @Inject(IConfigurationService) private readonly configurationService: IConfigurationService, - @Inject('SWAP_ALLOWED_APPS') private readonly allowedApps: Set, @Inject(IChainsRepository) private readonly chainsRepository: IChainsRepository, ) {} @@ -107,21 +103,6 @@ export class SwapOrderHelper { return url; } - /** - * Checks if the app associated with an order is allowed. - * - * @param order - the order to which we should verify the app data with - * @returns true if the app is allowed, false otherwise. - */ - // TODO: Refactor with confirmation view, swaps and TWAPs - isAppAllowed(order: Order): boolean { - if (!this.restrictApps) return true; - const appCode = order.fullAppData?.appCode; - return ( - !!appCode && typeof appCode === 'string' && this.allowedApps.has(appCode) - ); - } - /** * Retrieves a token object based on the provided Ethereum chain ID and token address. * If the specified address is the placeholder for the native currency of the chain, @@ -173,14 +154,6 @@ export class SwapOrderHelper { } } -function allowedAppsFactory( - configurationService: IConfigurationService, -): Set { - const allowedApps = - configurationService.getOrThrow('swaps.allowedApps'); - return new Set(allowedApps); -} - @Module({ imports: [ ChainsRepositoryModule, @@ -188,15 +161,7 @@ function allowedAppsFactory( TokenRepositoryModule, TransactionDataFinderModule, ], - providers: [ - SwapOrderHelper, - GPv2Decoder, - { - provide: 'SWAP_ALLOWED_APPS', - useFactory: allowedAppsFactory, - inject: [IConfigurationService], - }, - ], + providers: [SwapOrderHelper, GPv2Decoder], exports: [SwapOrderHelper], }) export class SwapOrderHelperModule {} diff --git a/src/routes/transactions/helpers/twap-order.helper.spec.ts b/src/routes/transactions/helpers/twap-order.helper.spec.ts index e7e00e9a6c..fc1b609914 100644 --- a/src/routes/transactions/helpers/twap-order.helper.spec.ts +++ b/src/routes/transactions/helpers/twap-order.helper.spec.ts @@ -33,13 +33,10 @@ describe('TwapOrderHelper', () => { const transactionDataFinder = new TransactionDataFinder(multiSendDecoder); const composableCowDecoder = new ComposableCowDecoder(); const configurationService = new FakeConfigurationService(); - const allowedApps = new Set(); configurationService.set('swaps.restrictApps', false); const target = new TwapOrderHelper( - configurationService, transactionDataFinder, composableCowDecoder, - allowedApps, ); describe('findTwapOrder', () => { diff --git a/src/routes/transactions/helpers/twap-order.helper.ts b/src/routes/transactions/helpers/twap-order.helper.ts index ab055aa813..26a8fed7d8 100644 --- a/src/routes/transactions/helpers/twap-order.helper.ts +++ b/src/routes/transactions/helpers/twap-order.helper.ts @@ -20,10 +20,8 @@ import { TwapOrderInfo, } from '@/routes/transactions/entities/swaps/twap-order-info.entity'; import { GPv2OrderParameters } from '@/domain/swaps/contracts/decoders/gp-v2-decoder.helper'; -import { Inject, Injectable, Module } from '@nestjs/common'; +import { Injectable, Module } from '@nestjs/common'; import { isAddressEqual } from 'viem'; -import { IConfigurationService } from '@/config/configuration.service.interface'; -import { FullAppData } from '@/domain/swaps/entities/full-app-data.entity'; /** * @@ -34,18 +32,10 @@ export class TwapOrderHelper { private static readonly ComposableCowAddress = '0xfdaFc9d1902f4e0b84f65F49f244b32b31013b74' as const; - private readonly restrictApps: boolean; - constructor( - @Inject(IConfigurationService) - private readonly configurationService: IConfigurationService, private readonly transactionDataFinder: TransactionDataFinder, private readonly composableCowDecoder: ComposableCowDecoder, - @Inject('SWAP_ALLOWED_APPS') private readonly allowedApps: Set, - ) { - this.restrictApps = - this.configurationService.getOrThrow('swaps.restrictApps'); - } + ) {} /** * Finds a TWAP order in a given transaction, either directly called or in a MultiSend @@ -158,21 +148,6 @@ export class TwapOrderHelper { }); } - /** - * Checks if the app associated contained in fullAppData is allowed. - * - * @param fullAppData - object to which we should verify the app data with - * @returns true if the app is allowed, false otherwise. - */ - // TODO: Refactor with confirmation view, swaps and TWAPs - public isAppAllowed(fullAppData: FullAppData): boolean { - if (!this.restrictApps) return true; - const appCode = fullAppData.fullAppData?.appCode; - return ( - !!appCode && typeof appCode === 'string' && this.allowedApps.has(appCode) - ); - } - private calculateValidTo(args: { part: number; startTime: number; @@ -188,25 +163,9 @@ export class TwapOrderHelper { } } -function allowedAppsFactory( - configurationService: IConfigurationService, -): Set { - const allowedApps = - configurationService.getOrThrow('swaps.allowedApps'); - return new Set(allowedApps); -} - @Module({ imports: [TransactionDataFinderModule], - providers: [ - ComposableCowDecoder, - TwapOrderHelper, - { - provide: 'SWAP_ALLOWED_APPS', - useFactory: allowedAppsFactory, - inject: [IConfigurationService], - }, - ], + providers: [ComposableCowDecoder, TwapOrderHelper], exports: [TwapOrderHelper], }) export class TwapOrderHelperModule {} diff --git a/src/routes/transactions/mappers/common/swap-order.mapper.ts b/src/routes/transactions/mappers/common/swap-order.mapper.ts index 747c1f33ab..9b36674af8 100644 --- a/src/routes/transactions/mappers/common/swap-order.mapper.ts +++ b/src/routes/transactions/mappers/common/swap-order.mapper.ts @@ -6,12 +6,17 @@ import { SwapOrderHelper, SwapOrderHelperModule, } from '@/routes/transactions/helpers/swap-order.helper'; +import { + SwapAppsHelper, + SwapAppsHelperModule, +} from '@/routes/transactions/helpers/swap-apps.helper'; @Injectable() export class SwapOrderMapper { constructor( private readonly gpv2Decoder: GPv2Decoder, private readonly swapOrderHelper: SwapOrderHelper, + private readonly swapAppsHelper: SwapAppsHelper, ) {} async mapSwapOrder( @@ -25,8 +30,7 @@ export class SwapOrderMapper { } const order = await this.swapOrderHelper.getOrder({ chainId, orderUid }); - // TODO: Refactor with confirmation view, swaps and TWAPs - if (!this.swapOrderHelper.isAppAllowed(order)) { + if (!this.swapAppsHelper.isAppAllowed(order)) { throw new Error(`Unsupported App: ${order.fullAppData?.appCode}`); } @@ -77,7 +81,7 @@ export class SwapOrderMapper { } @Module({ - imports: [SwapOrderHelperModule], + imports: [SwapOrderHelperModule, SwapAppsHelperModule], providers: [SwapOrderMapper, GPv2Decoder], exports: [SwapOrderMapper], }) diff --git a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts index f2378ba16c..04745b684e 100644 --- a/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts +++ b/src/routes/transactions/mappers/common/twap-order.mapper.spec.ts @@ -17,6 +17,7 @@ import { ILoggingService } from '@/logging/logging.interface'; import { getAddress } from 'viem'; import { fullAppDataBuilder } from '@/domain/swaps/entities/__tests__/full-app-data.builder'; import { TransactionDataFinder } from '@/routes/transactions/helpers/transaction-data-finder.helper'; +import { SwapAppsHelper } from '@/routes/transactions/helpers/swap-apps.helper'; const loggingService = { debug: jest.fn(), @@ -53,17 +54,14 @@ describe('TwapOrderMapper', () => { mockTokenRepository, mockSwapsRepository, mockConfigurationService, - allowedApps, mockChainsRepository, ); const composableCowDecoder = new ComposableCowDecoder(); const gpv2OrderHelper = new GPv2OrderHelper(); configurationService.set('swaps.restrictApps', false); const twapOrderHelper = new TwapOrderHelper( - configurationService, transactionDataFinder, composableCowDecoder, - allowedApps, ); beforeEach(() => { @@ -90,6 +88,7 @@ describe('TwapOrderMapper', () => { composableCowDecoder, gpv2OrderHelper, twapOrderHelper, + new SwapAppsHelper(configurationService, allowedApps), ); // Taken from queued transaction of specified owner before execution @@ -188,6 +187,7 @@ describe('TwapOrderMapper', () => { composableCowDecoder, gpv2OrderHelper, twapOrderHelper, + new SwapAppsHelper(configurationService, allowedApps), ); /** @@ -369,6 +369,7 @@ describe('TwapOrderMapper', () => { composableCowDecoder, gpv2OrderHelper, twapOrderHelper, + new SwapAppsHelper(configurationService, allowedApps), ); /** @@ -508,12 +509,8 @@ describe('TwapOrderMapper', () => { mockSwapsRepository, composableCowDecoder, gpv2OrderHelper, - new TwapOrderHelper( - configurationService, - transactionDataFinder, - composableCowDecoder, - allowedApps, - ), + new TwapOrderHelper(transactionDataFinder, composableCowDecoder), + new SwapAppsHelper(configurationService, allowedApps), ); // Taken from queued transaction of specified owner before execution @@ -555,12 +552,8 @@ describe('TwapOrderMapper', () => { mockSwapsRepository, composableCowDecoder, gpv2OrderHelper, - new TwapOrderHelper( - configurationService, - transactionDataFinder, - composableCowDecoder, - new Set(['app1', 'app2']), - ), + new TwapOrderHelper(transactionDataFinder, composableCowDecoder), + new SwapAppsHelper(configurationService, new Set(['app1', 'app2'])), ); // Taken from queued transaction of specified owner before execution @@ -599,12 +592,8 @@ describe('TwapOrderMapper', () => { mockSwapsRepository, composableCowDecoder, gpv2OrderHelper, - new TwapOrderHelper( - configurationService, - transactionDataFinder, - composableCowDecoder, - allowedApps, - ), + new TwapOrderHelper(transactionDataFinder, composableCowDecoder), + new SwapAppsHelper(configurationService, allowedApps), ); /** @@ -701,12 +690,8 @@ describe('TwapOrderMapper', () => { mockSwapsRepository, composableCowDecoder, gpv2OrderHelper, - new TwapOrderHelper( - configurationService, - transactionDataFinder, - composableCowDecoder, - new Set(['Safe Wallet Swaps']), - ), + new TwapOrderHelper(transactionDataFinder, composableCowDecoder), + new SwapAppsHelper(configurationService, new Set(['Safe Wallet Swaps'])), ); /** @@ -810,12 +795,8 @@ describe('TwapOrderMapper', () => { mockSwapsRepository, composableCowDecoder, gpv2OrderHelper, - new TwapOrderHelper( - configurationService, - transactionDataFinder, - composableCowDecoder, - new Set(['app1', 'app2']), - ), + new TwapOrderHelper(transactionDataFinder, composableCowDecoder), + new SwapAppsHelper(configurationService, new Set(['app1', 'app2'])), ); // Taken from queued transaction of specified owner before execution diff --git a/src/routes/transactions/mappers/common/twap-order.mapper.ts b/src/routes/transactions/mappers/common/twap-order.mapper.ts index ba2d064703..69d47f3022 100644 --- a/src/routes/transactions/mappers/common/twap-order.mapper.ts +++ b/src/routes/transactions/mappers/common/twap-order.mapper.ts @@ -24,6 +24,10 @@ import { SwapOrderMapperModule } from '@/routes/transactions/mappers/common/swap import { GPv2OrderHelper } from '@/routes/transactions/helpers/gp-v2-order.helper'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { ILoggingService, LoggingService } from '@/logging/logging.interface'; +import { + SwapAppsHelper, + SwapAppsHelperModule, +} from '@/routes/transactions/helpers/swap-apps.helper'; @Injectable() export class TwapOrderMapper { @@ -39,6 +43,7 @@ export class TwapOrderMapper { private readonly composableCowDecoder: ComposableCowDecoder, private readonly gpv2OrderHelper: GPv2OrderHelper, private readonly twapOrderHelper: TwapOrderHelper, + private readonly swapAppsHelper: SwapAppsHelper, ) { this.maxNumberOfParts = this.configurationService.getOrThrow( 'swaps.maxNumberOfParts', @@ -77,8 +82,7 @@ export class TwapOrderMapper { twapStruct.appData, ); - // TODO: Refactor with confirmation view, swaps and TWAPs - if (!this.twapOrderHelper.isAppAllowed(fullAppData)) { + if (!this.swapAppsHelper.isAppAllowed(fullAppData)) { throw new Error(`Unsupported App: ${fullAppData.fullAppData?.appCode}`); } @@ -103,8 +107,7 @@ export class TwapOrderMapper { part.appData, ); - // TODO: Refactor with confirmation view, swaps and TWAPs - if (!this.twapOrderHelper.isAppAllowed(partFullAppData)) { + if (!this.swapAppsHelper.isAppAllowed(partFullAppData)) { throw new Error( `Unsupported App: ${partFullAppData.fullAppData?.appCode}`, ); @@ -127,7 +130,7 @@ export class TwapOrderMapper { continue; } - if (!this.swapOrderHelper.isAppAllowed(order)) { + if (!this.swapAppsHelper.isAppAllowed(order)) { throw new Error(`Unsupported App: ${order.fullAppData?.appCode}`); } @@ -250,6 +253,7 @@ export class TwapOrderMapper { SwapsRepositoryModule, SwapOrderMapperModule, TwapOrderHelperModule, + SwapAppsHelperModule, ], providers: [ComposableCowDecoder, GPv2OrderHelper, TwapOrderMapper], exports: [TwapOrderMapper], diff --git a/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.spec.ts b/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.spec.ts index a69a042198..92e4563d25 100644 --- a/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.spec.ts +++ b/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.spec.ts @@ -6,6 +6,7 @@ import { tokenBuilder } from '@/domain/tokens/__tests__/token.builder'; import { addressInfoBuilder } from '@/routes/common/__tests__/entities/address-info.builder'; import { TransferDirection } from '@/routes/transactions/entities/transfer-transaction-info.entity'; import { Erc20Transfer } from '@/routes/transactions/entities/transfers/erc20-transfer.entity'; +import { SwapAppsHelper } from '@/routes/transactions/helpers/swap-apps.helper'; import { SwapOrderHelper } from '@/routes/transactions/helpers/swap-order.helper'; import { getTransferDirection } from '@/routes/transactions/mappers/common/transfer-direction.helper'; import { SwapTransferInfoMapper } from '@/routes/transactions/mappers/transfers/swap-transfer-info.mapper'; @@ -15,13 +16,16 @@ import { getAddress } from 'viem'; const mockSwapOrderHelper = jest.mocked({ getToken: jest.fn(), getOrderExplorerUrl: jest.fn(), - isAppAllowed: jest.fn(), } as jest.MockedObjectDeep); const mockSwapsRepository = jest.mocked({ getOrders: jest.fn(), } as jest.MockedObjectDeep); +const mockSwapAppsHelper = jest.mocked({ + isAppAllowed: jest.fn(), +} as jest.MockedObjectDeep); + describe('SwapTransferInfoMapper', () => { let target: SwapTransferInfoMapper; @@ -33,6 +37,7 @@ describe('SwapTransferInfoMapper', () => { target = new SwapTransferInfoMapper( mockSwapOrderHelper, mockSwapsRepository, + mockSwapAppsHelper, ); }); @@ -119,7 +124,7 @@ describe('SwapTransferInfoMapper', () => { mockSwapOrderHelper.getOrderExplorerUrl.mockReturnValue( new URL(explorerUrl), ); - mockSwapOrderHelper.isAppAllowed.mockReturnValue(true); + mockSwapAppsHelper.isAppAllowed.mockReturnValue(true); const actual = await target.mapSwapTransferInfo({ sender, @@ -201,7 +206,7 @@ describe('SwapTransferInfoMapper', () => { mockSwapOrderHelper.getOrderExplorerUrl.mockReturnValue( new URL(explorerUrl), ); - mockSwapOrderHelper.isAppAllowed.mockReturnValue(true); + mockSwapAppsHelper.isAppAllowed.mockReturnValue(true); const actual = await target.mapSwapTransferInfo({ sender, @@ -362,7 +367,7 @@ describe('SwapTransferInfoMapper', () => { mockSwapOrderHelper.getOrderExplorerUrl.mockReturnValue( new URL(explorerUrl), ); - mockSwapOrderHelper.isAppAllowed.mockReturnValue(true); + mockSwapAppsHelper.isAppAllowed.mockReturnValue(true); const actual = await target.mapSwapTransferInfo({ sender, @@ -519,7 +524,7 @@ describe('SwapTransferInfoMapper', () => { mockSwapOrderHelper.getOrderExplorerUrl.mockReturnValue( new URL(explorerUrl), ); - mockSwapOrderHelper.isAppAllowed.mockReturnValue(false); + mockSwapAppsHelper.isAppAllowed.mockReturnValue(false); await expect( target.mapSwapTransferInfo({ diff --git a/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.ts b/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.ts index 34cd034b96..7a09cd5004 100644 --- a/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.ts +++ b/src/routes/transactions/mappers/transfers/swap-transfer-info.mapper.ts @@ -9,6 +9,7 @@ import { SwapTransferTransactionInfo } from '@/routes/transactions/swap-transfer import { getAddress, isAddressEqual } from 'viem'; import { ISwapsRepository } from '@/domain/swaps/swaps.repository'; import { Order } from '@/domain/swaps/entities/order.entity'; +import { SwapAppsHelper } from '@/routes/transactions/helpers/swap-apps.helper'; @Injectable() export class SwapTransferInfoMapper { @@ -16,6 +17,7 @@ export class SwapTransferInfoMapper { private readonly swapOrderHelper: SwapOrderHelper, @Inject(ISwapsRepository) private readonly swapsRepository: ISwapsRepository, + private readonly swapAppsHelper: SwapAppsHelper, ) {} /** @@ -66,7 +68,7 @@ export class SwapTransferInfoMapper { throw new Error('Transfer not found in order'); } - if (!this.swapOrderHelper.isAppAllowed(order)) { + if (!this.swapAppsHelper.isAppAllowed(order)) { throw new Error(`Unsupported App: ${order.fullAppData?.appCode}`); } diff --git a/src/routes/transactions/transactions-view.controller.ts b/src/routes/transactions/transactions-view.controller.ts index 9662e71fce..8f33f909d3 100644 --- a/src/routes/transactions/transactions-view.controller.ts +++ b/src/routes/transactions/transactions-view.controller.ts @@ -32,6 +32,7 @@ import { AddressSchema } from '@/validation/entities/schemas/address.schema'; import { TwapOrderHelperModule } from '@/routes/transactions/helpers/twap-order.helper'; import { SwapsRepositoryModule } from '@/domain/swaps/swaps-repository.module'; import { ComposableCowDecoder } from '@/domain/swaps/contracts/decoders/composable-cow-decoder.helper'; +import { SwapAppsHelperModule } from '@/routes/transactions/helpers/swap-apps.helper'; @ApiTags('transactions') @Controller({ @@ -78,6 +79,7 @@ export class TransactionsViewController { SwapOrderHelperModule, TwapOrderHelperModule, SwapsRepositoryModule, + SwapAppsHelperModule, ], providers: [TransactionsViewService, ComposableCowDecoder], controllers: [TransactionsViewController], diff --git a/src/routes/transactions/transactions-view.service.ts b/src/routes/transactions/transactions-view.service.ts index 5bb3ff00e5..c9c389d3fa 100644 --- a/src/routes/transactions/transactions-view.service.ts +++ b/src/routes/transactions/transactions-view.service.ts @@ -16,6 +16,7 @@ import { TwapOrderHelper } from '@/routes/transactions/helpers/twap-order.helper import { OrderStatus } from '@/domain/swaps/entities/order.entity'; import { ISwapsRepository } from '@/domain/swaps/swaps.repository'; import { ComposableCowDecoder } from '@/domain/swaps/contracts/decoders/composable-cow-decoder.helper'; +import { SwapAppsHelper } from '@/routes/transactions/helpers/swap-apps.helper'; @Injectable({}) export class TransactionsViewService { @@ -29,6 +30,7 @@ export class TransactionsViewService { @Inject(ISwapsRepository) private readonly swapsRepository: ISwapsRepository, private readonly composableCowDecoder: ComposableCowDecoder, + private readonly swapAppsHelper: SwapAppsHelper, ) {} async getTransactionConfirmationView(args: { @@ -103,8 +105,7 @@ export class TransactionsViewService { orderUid, }); - // TODO: Refactor with confirmation view, swaps and TWAPs - if (!this.swapOrderHelper.isAppAllowed(order)) { + if (!this.swapAppsHelper.isAppAllowed(order)) { throw new Error(`Unsupported App: ${order.fullAppData?.appCode}`); } @@ -179,8 +180,7 @@ export class TransactionsViewService { twapStruct.appData, ); - // TODO: Refactor with confirmation view, swaps and TWAPs - if (!this.twapOrderHelper.isAppAllowed(fullAppData)) { + if (!this.swapAppsHelper.isAppAllowed(fullAppData)) { throw new Error(`Unsupported App: ${fullAppData.fullAppData?.appCode}`); } diff --git a/src/routes/transactions/transactions.module.ts b/src/routes/transactions/transactions.module.ts index 9dc4edbb08..a7113f9ede 100644 --- a/src/routes/transactions/transactions.module.ts +++ b/src/routes/transactions/transactions.module.ts @@ -42,6 +42,7 @@ import { SwapsRepositoryModule } from '@/domain/swaps/swaps-repository.module'; import { TwapOrderMapperModule } from '@/routes/transactions/mappers/common/twap-order.mapper'; import { TwapOrderHelperModule } from '@/routes/transactions/helpers/twap-order.helper'; import { SwapTransferInfoMapper } from '@/routes/transactions/mappers/transfers/swap-transfer-info.mapper'; +import { SwapAppsHelperModule } from '@/routes/transactions/helpers/swap-apps.helper'; @Module({ controllers: [TransactionsController], @@ -53,6 +54,7 @@ import { SwapTransferInfoMapper } from '@/routes/transactions/mappers/transfers/ SafeRepositoryModule, SafeAppsRepositoryModule, GPv2DecoderModule, + SwapAppsHelperModule, SwapOrderMapperModule, SwapOrderHelperModule, SwapsRepositoryModule, From 283b82940b0c0400cc1fa0231e41378246b76ebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Thu, 11 Jul 2024 11:00:03 +0200 Subject: [PATCH 177/207] Add RUN_MIGRATIONS flag to the service configuration (#1753) Adds RUN_MIGRATIONS (true by default) to control the execution of database migrations at the application bootstrap. Adds a test suite for PostgresDatabaseMigrationHook. --- .../entities/__tests__/configuration.ts | 1 + src/config/entities/configuration.ts | 3 + .../postgres-database.migration.hook.spec.ts | 76 +++++++++++++++++++ .../db/postgres-database.migration.hook.ts | 14 +++- 4 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 src/datasources/db/postgres-database.migration.hook.spec.ts diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index 5b4c35f29f..a93f48565d 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -15,6 +15,7 @@ export default (): ReturnType => ({ }, application: { isProduction: faker.datatype.boolean(), + runMigrations: true, port: faker.internet.port().toString(), }, auth: { diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index d21512bb28..21037e8ffc 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -25,6 +25,9 @@ export default () => ({ }, application: { isProduction: process.env.CGW_ENV === 'production', + // Enables/disables the execution of migrations on startup. + // Defaults to true. + runMigrations: process.env.RUN_MIGRATIONS?.toLowerCase() !== 'false', port: process.env.APPLICATION_PORT || '3000', }, auth: { diff --git a/src/datasources/db/postgres-database.migration.hook.spec.ts b/src/datasources/db/postgres-database.migration.hook.spec.ts new file mode 100644 index 0000000000..76e56a57a0 --- /dev/null +++ b/src/datasources/db/postgres-database.migration.hook.spec.ts @@ -0,0 +1,76 @@ +import { TestDbFactory } from '@/__tests__/db.factory'; +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { PostgresDatabaseMigrationHook } from '@/datasources/db/postgres-database.migration.hook'; +import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; +import { ILoggingService } from '@/logging/logging.interface'; +import { faker } from '@faker-js/faker'; +import postgres from 'postgres'; + +const migrator = jest.mocked({ + migrate: jest.fn(), +} as jest.MockedObjectDeep); + +const loggingService = jest.mocked({ + error: jest.fn(), + info: jest.fn(), +} as jest.MockedObjectDeep); + +const configurationService = jest.mocked({ + getOrThrow: jest.fn(), +} as jest.MockedObjectDeep); + +describe('PostgresDatabaseMigrationHook tests', () => { + let sql: postgres.Sql; + let target: PostgresDatabaseMigrationHook; + const testDbFactory = new TestDbFactory(); + + beforeAll(async () => { + sql = await testDbFactory.createTestDatabase(faker.string.uuid()); + }); + + afterAll(async () => { + await testDbFactory.destroyTestDatabase(sql); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should not run migrations', async () => { + configurationService.getOrThrow.mockImplementation((key) => { + if (key === 'application.runMigrations') return false; + }); + target = new PostgresDatabaseMigrationHook( + sql, + migrator, + loggingService, + configurationService, + ); + + await target.onModuleInit(); + + expect(loggingService.info).toHaveBeenCalledWith( + 'Database migrations are disabled', + ); + }); + + it('should run migrations', async () => { + configurationService.getOrThrow.mockImplementation((key) => { + if (key === 'application.runMigrations') return true; + }); + target = new PostgresDatabaseMigrationHook( + sql, + migrator, + loggingService, + configurationService, + ); + + await target.onModuleInit(); + + expect(loggingService.info).toHaveBeenCalledTimes(2); + expect(loggingService.info).toHaveBeenCalledWith('Checking migrations'); + expect(loggingService.info).toHaveBeenCalledWith( + 'Pending migrations executed', + ); + }); +}); diff --git a/src/datasources/db/postgres-database.migration.hook.ts b/src/datasources/db/postgres-database.migration.hook.ts index f11a81a725..f133333037 100644 --- a/src/datasources/db/postgres-database.migration.hook.ts +++ b/src/datasources/db/postgres-database.migration.hook.ts @@ -2,6 +2,7 @@ import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; import { ILoggingService, LoggingService } from '@/logging/logging.interface'; import postgres from 'postgres'; import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; +import { IConfigurationService } from '@/config/configuration.service.interface'; /** * The {@link PostgresDatabaseMigrationHook} is a Module Init hook meaning @@ -13,15 +14,26 @@ import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.mig @Injectable({}) export class PostgresDatabaseMigrationHook implements OnModuleInit { private static LOCK_MAGIC_NUMBER = 132; + private readonly runMigrations: boolean; constructor( @Inject('DB_INSTANCE') private readonly sql: postgres.Sql, @Inject(PostgresDatabaseMigrator) private readonly migrator: PostgresDatabaseMigrator, @Inject(LoggingService) private readonly loggingService: ILoggingService, - ) {} + @Inject(IConfigurationService) + private readonly configurationService: IConfigurationService, + ) { + this.runMigrations = this.configurationService.getOrThrow( + 'application.runMigrations', + ); + } async onModuleInit(): Promise { + if (!this.runMigrations) { + return this.loggingService.info('Database migrations are disabled'); + } + this.loggingService.info('Checking migrations'); try { // Acquire lock to perform a migration. From 5a66c242d50fc32868b0bfb5c8a4467163053801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Thu, 11 Jul 2024 16:35:33 +0200 Subject: [PATCH 178/207] Change data-decoder E2E test data payload (#1755) Changes data-decoder E2E test data payload for a simpler one. --- .../__tests__/data-decode.e2e-spec.ts | 113 ++---------------- 1 file changed, 7 insertions(+), 106 deletions(-) diff --git a/src/routes/data-decode/__tests__/data-decode.e2e-spec.ts b/src/routes/data-decode/__tests__/data-decode.e2e-spec.ts index 61578762b4..ba547734aa 100644 --- a/src/routes/data-decode/__tests__/data-decode.e2e-spec.ts +++ b/src/routes/data-decode/__tests__/data-decode.e2e-spec.ts @@ -34,119 +34,20 @@ describe('Data decode e2e tests', () => { const getDataDecodedDto = transactionDataDtoBuilder() .with( 'data', - '0x8d80ff0a000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004a60031369c6ecb549c117aff789ad66c708a452296740000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000ccd7c90b85e48682a68e7db1dc1f0339ea46ab020000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000c20aef7964d6c3c966e3ae9e850aee9db81792e30000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000000000bccd4163a8d714eaae683af53accc389fd73fdd0000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000d50ef7b662a07d3fc934891e488b133313cbfd7d0000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000e90975bd7b1937dfa4bf3d9c41368a07255607050000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000e817579b91ae59512ebdb860146001d170018e550000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000000006cd68754b97db054b68397e27ddcdc16d27afb220000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000000002ab2231d49154bb22b58df44b122ef9ba3ae97990000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000000001ea33eb00f2c2f00e1021fd7e9dd22154c82b06f0000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000844d2c79c4a721cbe153b092ba75b4c1e7cb2bc30000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000182ff57e69ec50eff8fc4a9e19e4d02d75061d320000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000b07d074825596798f6e127f86825aafaa81cdd7e0000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000d39d0a6b980218038c4675310399cecebb54de600000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000', + '0x0d582f130000000000000000000000001b9a0da11a5cace4e7035993cbb2e4b1b3b164cf0000000000000000000000000000000000000000000000000000000000000001', ) .with('to', '0xA238CBeb142c10Ef7Ad8442C6D1f9E89e07e7761') .build(); const expectedResponse: DataDecoded = { - method: 'multiSend', + method: 'addOwnerWithThreshold', parameters: [ { - name: 'transactions', - type: 'bytes', - value: - '0x0031369c6ecb549c117aff789ad66c708a452296740000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000ccd7c90b85e48682a68e7db1dc1f0339ea46ab020000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000c20aef7964d6c3c966e3ae9e850aee9db81792e30000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000000000bccd4163a8d714eaae683af53accc389fd73fdd0000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000d50ef7b662a07d3fc934891e488b133313cbfd7d0000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000e90975bd7b1937dfa4bf3d9c41368a07255607050000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000e817579b91ae59512ebdb860146001d170018e550000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000000006cd68754b97db054b68397e27ddcdc16d27afb220000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000000002ab2231d49154bb22b58df44b122ef9ba3ae97990000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000000001ea33eb00f2c2f00e1021fd7e9dd22154c82b06f0000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000844d2c79c4a721cbe153b092ba75b4c1e7cb2bc30000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000182ff57e69ec50eff8fc4a9e19e4d02d75061d320000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000b07d074825596798f6e127f86825aafaa81cdd7e0000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000d39d0a6b980218038c4675310399cecebb54de600000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000000', - valueDecoded: [ - { - operation: 0, - to: '0x31369C6ECB549C117aFF789Ad66C708A45229674', - value: '1000000000000000000', - data: null, - dataDecoded: null, - }, - { - operation: 0, - to: '0xccd7C90B85e48682A68E7Db1dC1F0339EA46Ab02', - value: '1000000000000000000', - data: null, - dataDecoded: null, - }, - { - operation: 0, - to: '0xc20AeF7964d6c3C966E3Ae9E850aEe9DB81792E3', - value: '1000000000000000000', - data: null, - dataDecoded: null, - }, - { - operation: 0, - to: '0x0bCcD4163a8D714EAAE683AF53accC389Fd73FDD', - value: '1000000000000000000', - data: null, - dataDecoded: null, - }, - { - operation: 0, - to: '0xD50EF7b662A07d3fc934891e488B133313cBFd7d', - value: '1000000000000000000', - data: null, - dataDecoded: null, - }, - { - operation: 0, - to: '0xe90975BD7B1937DfA4BF3d9c41368a0725560705', - value: '1000000000000000000', - data: null, - dataDecoded: null, - }, - { - operation: 0, - to: '0xE817579b91aE59512EbdB860146001D170018e55', - value: '1000000000000000000', - data: null, - dataDecoded: null, - }, - { - operation: 0, - to: '0x6cD68754B97DB054b68397E27ddcdC16D27AfB22', - value: '1000000000000000000', - data: null, - dataDecoded: null, - }, - { - operation: 0, - to: '0x2ab2231D49154bB22b58df44B122ef9BA3Ae9799', - value: '1000000000000000000', - data: null, - dataDecoded: null, - }, - { - operation: 0, - to: '0x1ea33eb00F2C2F00e1021FD7E9dD22154C82b06F', - value: '1000000000000000000', - data: null, - dataDecoded: null, - }, - { - operation: 0, - to: '0x844d2C79C4a721CBe153B092bA75B4c1E7Cb2BC3', - value: '1000000000000000000', - data: null, - dataDecoded: null, - }, - { - operation: 0, - to: '0x182FF57E69eC50eFF8fc4a9E19e4d02D75061D32', - value: '1000000000000000000', - data: null, - dataDecoded: null, - }, - { - operation: 0, - to: '0xB07d074825596798f6e127f86825AAFaA81Cdd7e', - value: '1000000000000000000', - data: null, - dataDecoded: null, - }, - { - operation: 0, - to: '0xd39d0a6b980218038C4675310399cecEbb54DE60', - value: '1000000000000000000', - data: null, - dataDecoded: null, - }, - ], + name: 'owner', + type: 'address', + value: '0x1b9a0DA11a5caCE4e7035993Cbb2E4B1B3b164Cf', + valueDecoded: null, }, + { name: '_threshold', type: 'uint256', value: '1', valueDecoded: null }, ], }; From 70d29a95dedec07261703b2d5e2334308ddc7894 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Fri, 12 Jul 2024 12:57:43 +0200 Subject: [PATCH 179/207] Set MAX_TTL as TTL when caching SAFE_EXISTS_KEY (#1757) Changes `INDEFINITE_EXPIRATION_TIME` for `MAX_TTL`, with a value of `2^31 - 1` (maximum value allowed by Redis, maximum positive value of a 32-bit signed integer) --- src/datasources/cache/constants.ts | 9 +++++++++ .../cache/redis.cache.service.spec.ts | 19 +++++++++++++++++++ .../transaction-api.service.spec.ts | 3 +-- .../transaction-api.service.ts | 8 +++----- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/datasources/cache/constants.ts b/src/datasources/cache/constants.ts index af213d6196..91d85b6ba6 100644 --- a/src/datasources/cache/constants.ts +++ b/src/datasources/cache/constants.ts @@ -8,3 +8,12 @@ * that a cache instance might be shared between the tests. */ export const CacheKeyPrefix = Symbol('CacheKeyPrefix'); + +/** + * This number is used to set the maximum TTL for a key in Redis. + * A safe JS value is used to prevent overflow errors. This value in milliseconds equals to 285420 years. + * + * Note: The maximum value allowed by Redis is higher: LLONG_MAX (C's long long int, 2**63-1) minus the unix epoch in milliseconds. + * Ref: https://github.com/redis/redis/blob/cc244370a2d622d5d2fec5573ae87de6c59ed3b9/src/expire.c#L573 + */ +export const MAX_TTL = Number.MAX_SAFE_INTEGER - 1; diff --git a/src/datasources/cache/redis.cache.service.spec.ts b/src/datasources/cache/redis.cache.service.spec.ts index 1f81294645..83b9ddac20 100644 --- a/src/datasources/cache/redis.cache.service.spec.ts +++ b/src/datasources/cache/redis.cache.service.spec.ts @@ -7,6 +7,7 @@ import { fakeJson } from '@/__tests__/faker'; import { IConfigurationService } from '@/config/configuration.service.interface'; import clearAllMocks = jest.clearAllMocks; import { redisClientFactory } from '@/__tests__/redis-client.factory'; +import { MAX_TTL } from '@/datasources/cache/constants'; const mockLoggingService: jest.MockedObjectDeep = { info: jest.fn(), @@ -173,4 +174,22 @@ describe('RedisCacheService', () => { expect(ttl).toBeGreaterThan(0); expect(ttl).toBeLessThanOrEqual(expireTime); }); + + it('stores a key for MAX_TTL seconds', async () => { + const key = faker.string.alphanumeric(); + const value = faker.string.sample(); + + try { + await redisCacheService.set(new CacheDir(key, ''), value, MAX_TTL); + } catch (err) { + console.error(err); + throw new Error('Should not throw'); + } + + const storedValue = await redisClient.hGet(key, ''); + const ttl = await redisClient.ttl(key); + expect(storedValue).toEqual(value); + expect(ttl).toBeGreaterThan(0); + expect(ttl).toBeLessThanOrEqual(Number.MAX_SAFE_INTEGER); + }); }); diff --git a/src/datasources/transaction-api/transaction-api.service.spec.ts b/src/datasources/transaction-api/transaction-api.service.spec.ts index 7c8d5aac18..f3d765ebbd 100644 --- a/src/datasources/transaction-api/transaction-api.service.spec.ts +++ b/src/datasources/transaction-api/transaction-api.service.spec.ts @@ -59,7 +59,6 @@ describe('TransactionApi', () => { const baseUrl = faker.internet.url({ appendSlash: false }); let httpErrorFactory: HttpErrorFactory; let service: TransactionApi; - const indefiniteExpirationTime = -1; let defaultExpirationTimeInSeconds: number; let notFoundExpireTimeSeconds: number; let ownersTtlSeconds: number; @@ -356,7 +355,7 @@ describe('TransactionApi', () => { expect(cacheService.set).toHaveBeenCalledWith( cacheDir, 'true', - indefiniteExpirationTime, + Number.MAX_SAFE_INTEGER - 1, ); }); diff --git a/src/datasources/transaction-api/transaction-api.service.ts b/src/datasources/transaction-api/transaction-api.service.ts index 34cdc46741..c6d02fdd85 100644 --- a/src/datasources/transaction-api/transaction-api.service.ts +++ b/src/datasources/transaction-api/transaction-api.service.ts @@ -2,6 +2,7 @@ import { IConfigurationService } from '@/config/configuration.service.interface' import { CacheFirstDataSource } from '@/datasources/cache/cache.first.data.source'; import { CacheRouter } from '@/datasources/cache/cache.router'; import { ICacheService } from '@/datasources/cache/cache.service.interface'; +import { MAX_TTL } from '@/datasources/cache/constants'; import { HttpErrorFactory } from '@/datasources/errors/http-error-factory'; import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; import { INetworkService } from '@/datasources/network/network.service.interface'; @@ -31,7 +32,6 @@ import { get } from 'lodash'; export class TransactionApi implements ITransactionApi { private static readonly ERROR_ARRAY_PATH = 'nonFieldErrors'; - private static readonly INDEFINITE_EXPIRATION_TIME = -1; private readonly defaultExpirationTimeInSeconds: number; private readonly defaultNotFoundExpirationTimeSeconds: number; @@ -197,10 +197,8 @@ export class TransactionApi implements ITransactionApi { await this.cacheService.set( cacheDir, JSON.stringify(isSafe), - isSafe - ? // We can indefinitely cache this as an address cannot "un-Safe" itself - TransactionApi.INDEFINITE_EXPIRATION_TIME - : this.defaultExpirationTimeInSeconds, + // We can indefinitely cache this as an address cannot "un-Safe" itself + isSafe ? MAX_TTL : this.defaultExpirationTimeInSeconds, ); return isSafe; From efb87bf55fec3d82169c32d2338bff6e987c2627 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 15 Jul 2024 11:37:08 +0200 Subject: [PATCH 180/207] Add `Chain[contractAddresses]` property (#1752) This adds the new `contractAddresses` object to the `ChainSchema` schema and thus `Chain` entity: - Add new `ContractAddressesSchema`, include it in `ChainSchema` and infer a new `ContractAddresses` entity from it - Add new `contractAddressesBuilder` and include it in `chainBuilder` - Add appropriate test coverage --- .../entities/__tests__/chain.builder.ts | 2 + .../__tests__/contract-addresses.builder.ts | 29 +++++++++++ .../entities/contract-addresses.entity.ts | 6 +++ .../schemas/__tests__/chain.schema.spec.ts | 49 +++++++++++++++++++ .../chains/entities/schemas/chain.schema.ts | 13 +++++ 5 files changed, 99 insertions(+) create mode 100644 src/domain/chains/entities/__tests__/contract-addresses.builder.ts create mode 100644 src/domain/chains/entities/contract-addresses.entity.ts diff --git a/src/domain/chains/entities/__tests__/chain.builder.ts b/src/domain/chains/entities/__tests__/chain.builder.ts index 6bd6837ef1..e776f36d0a 100644 --- a/src/domain/chains/entities/__tests__/chain.builder.ts +++ b/src/domain/chains/entities/__tests__/chain.builder.ts @@ -10,6 +10,7 @@ import { themeBuilder } from '@/domain/chains/entities/__tests__/theme.builder'; import { Chain } from '@/domain/chains/entities/chain.entity'; import { pricesProviderBuilder } from '@/domain/chains/entities/__tests__/prices-provider.builder'; import { balancesProviderBuilder } from '@/domain/chains/entities/__tests__/balances-provider.builder'; +import { contractAddressesBuilder } from '@/domain/chains/entities/__tests__/contract-addresses.builder'; export function chainBuilder(): IBuilder { return new Builder() @@ -27,6 +28,7 @@ export function chainBuilder(): IBuilder { .with('nativeCurrency', nativeCurrencyBuilder().build()) .with('pricesProvider', pricesProviderBuilder().build()) .with('balancesProvider', balancesProviderBuilder().build()) + .with('contractAddresses', contractAddressesBuilder().build()) .with('transactionService', faker.internet.url({ appendSlash: false })) .with('vpcTransactionService', faker.internet.url({ appendSlash: false })) .with('theme', themeBuilder().build()) diff --git a/src/domain/chains/entities/__tests__/contract-addresses.builder.ts b/src/domain/chains/entities/__tests__/contract-addresses.builder.ts new file mode 100644 index 0000000000..4ae8c571d3 --- /dev/null +++ b/src/domain/chains/entities/__tests__/contract-addresses.builder.ts @@ -0,0 +1,29 @@ +import { Builder, IBuilder } from '@/__tests__/builder'; +import { ContractAddresses } from '@/domain/chains/entities/contract-addresses.entity'; +import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; + +export function contractAddressesBuilder(): IBuilder { + return new Builder() + .with('safeSingletonAddress', getAddress(faker.finance.ethereumAddress())) + .with( + 'safeProxyFactoryAddress', + getAddress(faker.finance.ethereumAddress()), + ) + .with('multiSendAddress', getAddress(faker.finance.ethereumAddress())) + .with( + 'multiSendCallOnlyAddress', + getAddress(faker.finance.ethereumAddress()), + ) + .with('fallbackHandlerAddress', getAddress(faker.finance.ethereumAddress())) + .with('signMessageLibAddress', getAddress(faker.finance.ethereumAddress())) + .with('createCallAddress', getAddress(faker.finance.ethereumAddress())) + .with( + 'simulateTxAccessorAddress', + getAddress(faker.finance.ethereumAddress()), + ) + .with( + 'safeWebAuthnSignerFactoryAddress', + getAddress(faker.finance.ethereumAddress()), + ); +} diff --git a/src/domain/chains/entities/contract-addresses.entity.ts b/src/domain/chains/entities/contract-addresses.entity.ts new file mode 100644 index 0000000000..dd9eae2601 --- /dev/null +++ b/src/domain/chains/entities/contract-addresses.entity.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; +import { ContractAddressesSchema } from '@/domain/chains/entities/schemas/chain.schema'; + +// Responsible for populating the `ContractNetworksConfig` of the `protocol-kit` +// @see https://docs.safe.global/sdk/protocol-kit/reference/safe#init +export type ContractAddresses = z.infer; diff --git a/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts b/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts index b1e46befd3..be4f0ef33f 100644 --- a/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts +++ b/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts @@ -1,5 +1,6 @@ import { balancesProviderBuilder } from '@/domain/chains/entities/__tests__/balances-provider.builder'; import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; +import { contractAddressesBuilder } from '@/domain/chains/entities/__tests__/contract-addresses.builder'; import { gasPriceFixedEIP1559Builder } from '@/domain/chains/entities/__tests__/gas-price-fixed-eip-1559.builder'; import { gasPriceFixedBuilder } from '@/domain/chains/entities/__tests__/gas-price-fixed.builder'; import { gasPriceOracleBuilder } from '@/domain/chains/entities/__tests__/gas-price-oracle.builder'; @@ -18,8 +19,10 @@ import { PricesProviderSchema, RpcUriSchema, ThemeSchema, + ContractAddressesSchema, } from '@/domain/chains/entities/schemas/chain.schema'; import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; import { ZodError } from 'zod'; describe('Chain schemas', () => { @@ -436,6 +439,52 @@ describe('Chain schemas', () => { }); }); + describe('ContractAddressesSchema', () => { + it('should validate a valid ContractAddresses', () => { + const contractAddresses = contractAddressesBuilder().build(); + + const result = ContractAddressesSchema.safeParse(contractAddresses); + + expect(result.success).toBe(true); + }); + + [ + 'safeSingletonAddress' as const, + 'safeProxyFactoryAddress' as const, + 'multiSendAddress' as const, + 'multiSendCallOnlyAddress' as const, + 'fallbackHandlerAddress' as const, + 'signMessageLibAddress' as const, + 'createCallAddress' as const, + 'simulateTxAccessorAddress' as const, + 'safeWebAuthnSignerFactoryAddress' as const, + ].forEach((field) => { + it(`should checksum the ${field}`, () => { + const contractAddresses = contractAddressesBuilder() + .with( + field, + faker.finance.ethereumAddress().toLowerCase() as `0x${string}`, + ) + .build(); + + const result = ContractAddressesSchema.safeParse(contractAddresses); + + expect(result.success && result.data[field]).toBe( + getAddress(contractAddresses[field]!), + ); + }); + + it(`should allow undefined ${field} and default to null`, () => { + const contractAddresses = contractAddressesBuilder().build(); + delete contractAddresses[field]; + + const result = ContractAddressesSchema.safeParse(contractAddresses); + + expect(result.success && result.data[field]).toBe(null); + }); + }); + }); + describe('ChainSchema', () => { it('should validate a valid chain', () => { const chain = chainBuilder().build(); diff --git a/src/domain/chains/entities/schemas/chain.schema.ts b/src/domain/chains/entities/schemas/chain.schema.ts index 4dd8d72221..08a34ee36c 100644 --- a/src/domain/chains/entities/schemas/chain.schema.ts +++ b/src/domain/chains/entities/schemas/chain.schema.ts @@ -64,6 +64,18 @@ export const BalancesProviderSchema = z.object({ enabled: z.boolean(), }); +export const ContractAddressesSchema = z.object({ + safeSingletonAddress: AddressSchema.nullish().default(null), + safeProxyFactoryAddress: AddressSchema.nullish().default(null), + multiSendAddress: AddressSchema.nullish().default(null), + multiSendCallOnlyAddress: AddressSchema.nullish().default(null), + fallbackHandlerAddress: AddressSchema.nullish().default(null), + signMessageLibAddress: AddressSchema.nullish().default(null), + createCallAddress: AddressSchema.nullish().default(null), + simulateTxAccessorAddress: AddressSchema.nullish().default(null), + safeWebAuthnSignerFactoryAddress: AddressSchema.nullish().default(null), +}); + export const ChainSchema = z.object({ chainId: z.string(), chainName: z.string(), @@ -76,6 +88,7 @@ export const ChainSchema = z.object({ safeAppsRpcUri: RpcUriSchema, publicRpcUri: RpcUriSchema, blockExplorerUriTemplate: BlockExplorerUriTemplateSchema, + contractAddresses: ContractAddressesSchema, nativeCurrency: NativeCurrencySchema, pricesProvider: PricesProviderSchema, balancesProvider: BalancesProviderSchema, From 706b74db59a28c918cb2b1b8a760e3ce633d8a33 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Mon, 15 Jul 2024 17:19:01 +0200 Subject: [PATCH 181/207] Map `contractAddresses` to `Chain` entity (#1759) Maps `contractAddresses` onto the route-level `Chain` entity, as well as adding a fallback value if not present on the Config Service (if a stale cache value is present after release): - Adjust route-level `Chain` entity to accept constructor arguments as an object for clarity - Add `ContractAddresses` route-level entity and include it to `Chain` - Map `contractAddresses` to `Chain` route-level entiy - Add fallback value for `ContractAddresses` - Add appropriate test coverage --- .../schemas/__tests__/chain.schema.spec.ts | 41 +++++++++ .../chains/entities/schemas/chain.schema.ts | 35 ++++--- src/routes/chains/chains.controller.spec.ts | 3 + src/routes/chains/chains.service.ts | 91 ++++++++++--------- src/routes/chains/entities/chain.entity.ts | 87 +++++++++--------- .../entities/contract-addresses.entity.ts | 23 +++++ 6 files changed, 183 insertions(+), 97 deletions(-) create mode 100644 src/routes/chains/entities/contract-addresses.entity.ts diff --git a/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts b/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts index be4f0ef33f..cea2c781a1 100644 --- a/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts +++ b/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts @@ -483,6 +483,47 @@ describe('Chain schemas', () => { expect(result.success && result.data[field]).toBe(null); }); }); + + // TODO: Remove after deployed and all chain caches include the `contractAddresses` field + describe('should default all contract addresses to null if the chain cache does not contain contractAddresses', () => { + it('on a ContractAddresses level', () => { + const contractAddresses = undefined; + + const result = ContractAddressesSchema.safeParse(contractAddresses); + + expect(result.success && result.data).toStrictEqual({ + safeSingletonAddress: null, + safeProxyFactoryAddress: null, + multiSendAddress: null, + multiSendCallOnlyAddress: null, + fallbackHandlerAddress: null, + signMessageLibAddress: null, + createCallAddress: null, + simulateTxAccessorAddress: null, + safeWebAuthnSignerFactoryAddress: null, + }); + }); + + it('on a Chain level', () => { + const chain = chainBuilder().build(); + // @ts-expect-error - pre-inclusion of `contractAddresses` field + delete chain.contractAddresses; + + const result = ChainSchema.safeParse(chain); + + expect(result.success && result.data.contractAddresses).toStrictEqual({ + safeSingletonAddress: null, + safeProxyFactoryAddress: null, + multiSendAddress: null, + multiSendCallOnlyAddress: null, + fallbackHandlerAddress: null, + signMessageLibAddress: null, + createCallAddress: null, + simulateTxAccessorAddress: null, + safeWebAuthnSignerFactoryAddress: null, + }); + }); + }); }); describe('ChainSchema', () => { diff --git a/src/domain/chains/entities/schemas/chain.schema.ts b/src/domain/chains/entities/schemas/chain.schema.ts index 08a34ee36c..16e94ab8f8 100644 --- a/src/domain/chains/entities/schemas/chain.schema.ts +++ b/src/domain/chains/entities/schemas/chain.schema.ts @@ -64,17 +64,30 @@ export const BalancesProviderSchema = z.object({ enabled: z.boolean(), }); -export const ContractAddressesSchema = z.object({ - safeSingletonAddress: AddressSchema.nullish().default(null), - safeProxyFactoryAddress: AddressSchema.nullish().default(null), - multiSendAddress: AddressSchema.nullish().default(null), - multiSendCallOnlyAddress: AddressSchema.nullish().default(null), - fallbackHandlerAddress: AddressSchema.nullish().default(null), - signMessageLibAddress: AddressSchema.nullish().default(null), - createCallAddress: AddressSchema.nullish().default(null), - simulateTxAccessorAddress: AddressSchema.nullish().default(null), - safeWebAuthnSignerFactoryAddress: AddressSchema.nullish().default(null), -}); +export const ContractAddressesSchema = z + .object({ + safeSingletonAddress: AddressSchema.nullish().default(null), + safeProxyFactoryAddress: AddressSchema.nullish().default(null), + multiSendAddress: AddressSchema.nullish().default(null), + multiSendCallOnlyAddress: AddressSchema.nullish().default(null), + fallbackHandlerAddress: AddressSchema.nullish().default(null), + signMessageLibAddress: AddressSchema.nullish().default(null), + createCallAddress: AddressSchema.nullish().default(null), + simulateTxAccessorAddress: AddressSchema.nullish().default(null), + safeWebAuthnSignerFactoryAddress: AddressSchema.nullish().default(null), + }) + // TODO: Remove catch after deployed and all chain caches include the `contractAddresses` field + .catch({ + safeSingletonAddress: null, + safeProxyFactoryAddress: null, + multiSendAddress: null, + multiSendCallOnlyAddress: null, + fallbackHandlerAddress: null, + signMessageLibAddress: null, + createCallAddress: null, + simulateTxAccessorAddress: null, + safeWebAuthnSignerFactoryAddress: null, + }); export const ChainSchema = z.object({ chainId: z.string(), diff --git a/src/routes/chains/chains.controller.spec.ts b/src/routes/chains/chains.controller.spec.ts index d45d363714..a97950a17f 100644 --- a/src/routes/chains/chains.controller.spec.ts +++ b/src/routes/chains/chains.controller.spec.ts @@ -117,6 +117,7 @@ describe('Chains Controller (Unit)', () => { disabledWallets: chainsResponse.results[0].disabledWallets, features: chainsResponse.results[0].features, balancesProvider: chainsResponse.results[0].balancesProvider, + contractAddresses: chainsResponse.results[0].contractAddresses, }, { chainId: chainsResponse.results[1].chainId, @@ -141,6 +142,7 @@ describe('Chains Controller (Unit)', () => { disabledWallets: chainsResponse.results[1].disabledWallets, features: chainsResponse.results[1].features, balancesProvider: chainsResponse.results[1].balancesProvider, + contractAddresses: chainsResponse.results[1].contractAddresses, }, ], }); @@ -237,6 +239,7 @@ describe('Chains Controller (Unit)', () => { ? getAddress(chainDomain.ensRegistryAddress) : chainDomain.ensRegistryAddress, balancesProvider: chainDomain.balancesProvider, + contractAddresses: chainDomain.contractAddresses, }; networkService.get.mockResolvedValueOnce({ data: chainDomain, diff --git a/src/routes/chains/chains.service.ts b/src/routes/chains/chains.service.ts index 08a047d3f1..8e25952101 100644 --- a/src/routes/chains/chains.service.ts +++ b/src/routes/chains/chains.service.ts @@ -35,30 +35,30 @@ export class ChainsService { const nextURL = cursorUrlFromLimitAndOffset(routeUrl, result.next); const previousURL = cursorUrlFromLimitAndOffset(routeUrl, result.previous); - const chains = result.results.map( - (chain) => - new Chain( - chain.chainId, - chain.chainName, - chain.description, - chain.l2, - chain.nativeCurrency, - chain.transactionService, - chain.blockExplorerUriTemplate, - chain.disabledWallets, - chain.features, - chain.gasPrice, - chain.publicRpcUri, - chain.rpcUri, - chain.safeAppsRpcUri, - chain.shortName, - chain.theme, - chain.ensRegistryAddress, - chain.isTestnet, - chain.chainLogoUri, - chain.balancesProvider, - ), - ); + const chains = result.results.map((chain) => { + return new Chain({ + chainId: chain.chainId, + chainName: chain.chainName, + description: chain.description, + l2: chain.l2, + nativeCurrency: chain.nativeCurrency, + transactionService: chain.transactionService, + blockExplorerUriTemplate: chain.blockExplorerUriTemplate, + disabledWallets: chain.disabledWallets, + features: chain.features, + gasPrice: chain.gasPrice, + publicRpcUri: chain.publicRpcUri, + rpcUri: chain.rpcUri, + safeAppsRpcUri: chain.safeAppsRpcUri, + shortName: chain.shortName, + theme: chain.theme, + ensRegistryAddress: chain.ensRegistryAddress, + isTestnet: chain.isTestnet, + chainLogoUri: chain.chainLogoUri, + balancesProvider: chain.balancesProvider, + contractAddresses: chain.contractAddresses, + }); + }); return { count: result.count, @@ -70,27 +70,28 @@ export class ChainsService { async getChain(chainId: string): Promise { const result = await this.chainsRepository.getChain(chainId); - return new Chain( - result.chainId, - result.chainName, - result.description, - result.l2, - result.nativeCurrency, - result.transactionService, - result.blockExplorerUriTemplate, - result.disabledWallets, - result.features, - result.gasPrice, - result.publicRpcUri, - result.rpcUri, - result.safeAppsRpcUri, - result.shortName, - result.theme, - result.ensRegistryAddress, - result.isTestnet, - result.chainLogoUri, - result.balancesProvider, - ); + return new Chain({ + chainId: result.chainId, + chainName: result.chainName, + description: result.description, + l2: result.l2, + nativeCurrency: result.nativeCurrency, + transactionService: result.transactionService, + blockExplorerUriTemplate: result.blockExplorerUriTemplate, + disabledWallets: result.disabledWallets, + features: result.features, + gasPrice: result.gasPrice, + publicRpcUri: result.publicRpcUri, + rpcUri: result.rpcUri, + safeAppsRpcUri: result.safeAppsRpcUri, + shortName: result.shortName, + theme: result.theme, + ensRegistryAddress: result.ensRegistryAddress, + isTestnet: result.isTestnet, + chainLogoUri: result.chainLogoUri, + balancesProvider: result.balancesProvider, + contractAddresses: result.contractAddresses, + }); } async getAboutChain(chainId: string): Promise { diff --git a/src/routes/chains/entities/chain.entity.ts b/src/routes/chains/entities/chain.entity.ts index 72f1348801..630a4e4679 100644 --- a/src/routes/chains/entities/chain.entity.ts +++ b/src/routes/chains/entities/chain.entity.ts @@ -33,6 +33,7 @@ import { Theme as ApiTheme, } from '@/routes/chains/entities/theme.entity'; import { BalancesProvider } from '@/routes/chains/entities/balances-provider.entity'; +import { ContractAddresses } from '@/routes/chains/entities/contract-addresses.entity'; @ApiExtraModels(ApiGasPriceOracle, ApiGasPriceFixed, ApiGasPriceFixedEIP1559) export class Chain { @@ -57,10 +58,12 @@ export class Chain { @ApiProperty() disabledWallets: string[]; @ApiPropertyOptional({ type: String, nullable: true }) - ensRegistryAddress: string | null; + ensRegistryAddress: `0x${string}` | null; @ApiProperty() balancesProvider: BalancesProvider; @ApiProperty() + contractAddresses: ContractAddresses; + @ApiProperty() features: string[]; @ApiProperty({ type: 'array', @@ -86,45 +89,47 @@ export class Chain { @ApiProperty() theme: ApiTheme; - constructor( - chainId: string, - chainName: string, - description: string, - l2: boolean, - nativeCurrency: NativeCurrency, - transactionService: string, - blockExplorerUriTemplate: BlockExplorerUriTemplate, - disabledWallets: string[], - features: string[], - gasPrice: Array, - publicRpcUri: RpcUri, - rpcUri: RpcUri, - safeAppsRpcUri: RpcUri, - shortName: string, - theme: Theme, - ensRegistryAddress: string | null, - isTestnet: boolean, - chainLogoUri: string | null, - balancesProvider: BalancesProvider, - ) { - this.chainId = chainId; - this.chainName = chainName; - this.description = description; - this.chainLogoUri = chainLogoUri; - this.l2 = l2; - this.isTestnet = isTestnet; - this.nativeCurrency = nativeCurrency; - this.transactionService = transactionService; - this.blockExplorerUriTemplate = blockExplorerUriTemplate; - this.disabledWallets = disabledWallets; - this.ensRegistryAddress = ensRegistryAddress; - this.features = features; - this.gasPrice = gasPrice; - this.publicRpcUri = publicRpcUri; - this.rpcUri = rpcUri; - this.safeAppsRpcUri = safeAppsRpcUri; - this.shortName = shortName; - this.theme = theme; - this.balancesProvider = balancesProvider; + constructor(args: { + chainId: string; + chainName: string; + description: string; + l2: boolean; + nativeCurrency: NativeCurrency; + transactionService: string; + blockExplorerUriTemplate: BlockExplorerUriTemplate; + disabledWallets: string[]; + features: string[]; + gasPrice: Array; + publicRpcUri: RpcUri; + rpcUri: RpcUri; + safeAppsRpcUri: RpcUri; + shortName: string; + theme: Theme; + ensRegistryAddress: `0x${string}` | null; + isTestnet: boolean; + chainLogoUri: string | null; + balancesProvider: BalancesProvider; + contractAddresses: ContractAddresses; + }) { + this.chainId = args.chainId; + this.chainName = args.chainName; + this.description = args.description; + this.chainLogoUri = args.chainLogoUri; + this.l2 = args.l2; + this.isTestnet = args.isTestnet; + this.nativeCurrency = args.nativeCurrency; + this.transactionService = args.transactionService; + this.blockExplorerUriTemplate = args.blockExplorerUriTemplate; + this.disabledWallets = args.disabledWallets; + this.ensRegistryAddress = args.ensRegistryAddress; + this.features = args.features; + this.gasPrice = args.gasPrice; + this.publicRpcUri = args.publicRpcUri; + this.rpcUri = args.rpcUri; + this.safeAppsRpcUri = args.safeAppsRpcUri; + this.shortName = args.shortName; + this.theme = args.theme; + this.balancesProvider = args.balancesProvider; + this.contractAddresses = args.contractAddresses; } } diff --git a/src/routes/chains/entities/contract-addresses.entity.ts b/src/routes/chains/entities/contract-addresses.entity.ts new file mode 100644 index 0000000000..9933362abf --- /dev/null +++ b/src/routes/chains/entities/contract-addresses.entity.ts @@ -0,0 +1,23 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { ContractAddresses as DomainContractAddresses } from '@/domain/chains/entities/contract-addresses.entity'; + +export class ContractAddresses implements DomainContractAddresses { + @ApiPropertyOptional({ type: String, nullable: true }) + safeSingletonAddress!: `0x${string}` | null; + @ApiPropertyOptional({ type: String, nullable: true }) + safeProxyFactoryAddress!: `0x${string}` | null; + @ApiPropertyOptional({ type: String, nullable: true }) + multiSendAddress!: `0x${string}` | null; + @ApiPropertyOptional({ type: String, nullable: true }) + multiSendCallOnlyAddress!: `0x${string}` | null; + @ApiPropertyOptional({ type: String, nullable: true }) + fallbackHandlerAddress!: `0x${string}` | null; + @ApiPropertyOptional({ type: String, nullable: true }) + signMessageLibAddress!: `0x${string}` | null; + @ApiPropertyOptional({ type: String, nullable: true }) + createCallAddress!: `0x${string}` | null; + @ApiPropertyOptional({ type: String, nullable: true }) + simulateTxAccessorAddress!: `0x${string}` | null; + @ApiPropertyOptional({ type: String, nullable: true }) + safeWebAuthnSignerFactoryAddress!: `0x${string}` | null; +} From e002af21cca1dc372b20b21e7730191bc019c5aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jul 2024 09:01:33 +0200 Subject: [PATCH 182/207] Bump ts-jest from 29.1.5 to 29.2.2 (#1761) Bumps [ts-jest](https://github.com/kulshekhar/ts-jest) from 29.1.5 to 29.2.2. - [Release notes](https://github.com/kulshekhar/ts-jest/releases) - [Changelog](https://github.com/kulshekhar/ts-jest/blob/main/CHANGELOG.md) - [Commits](https://github.com/kulshekhar/ts-jest/compare/v29.1.5...v29.2.2) --- updated-dependencies: - dependency-name: ts-jest dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 47 +++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 7831f16871..601d780016 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "prettier": "^3.3.2", "source-map-support": "^0.5.20", "supertest": "^7.0.0", - "ts-jest": "29.1.5", + "ts-jest": "29.2.2", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "4.2.0", diff --git a/yarn.lock b/yarn.lock index f5c8754b4e..1ac90a8e55 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2976,7 +2976,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:4.1.2, chalk@npm:^4.0.0, chalk@npm:^4.1.0, chalk@npm:^4.1.1, chalk@npm:^4.1.2": +"chalk@npm:4.1.2, chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0, chalk@npm:^4.1.1, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" dependencies: @@ -3652,6 +3652,17 @@ __metadata: languageName: node linkType: hard +"ejs@npm:^3.0.0": + version: 3.1.10 + resolution: "ejs@npm:3.1.10" + dependencies: + jake: "npm:^10.8.5" + bin: + ejs: bin/cli.js + checksum: 10/a9cb7d7cd13b7b1cd0be5c4788e44dd10d92f7285d2f65b942f33e127230c054f99a42db4d99f766d8dbc6c57e94799593ee66a14efd7c8dd70c4812bf6aa384 + languageName: node + linkType: hard + "electron-to-chromium@npm:^1.4.202": version: 1.4.206 resolution: "electron-to-chromium@npm:1.4.206" @@ -4165,6 +4176,15 @@ __metadata: languageName: node linkType: hard +"filelist@npm:^1.0.4": + version: 1.0.4 + resolution: "filelist@npm:1.0.4" + dependencies: + minimatch: "npm:^5.0.1" + checksum: 10/4b436fa944b1508b95cffdfc8176ae6947b92825483639ef1b9a89b27d82f3f8aa22b21eed471993f92709b431670d4e015b39c087d435a61e1bb04564cf51de + languageName: node + linkType: hard + "fill-range@npm:^7.1.1": version: 7.1.1 resolution: "fill-range@npm:7.1.1" @@ -5109,6 +5129,20 @@ __metadata: languageName: node linkType: hard +"jake@npm:^10.8.5": + version: 10.9.1 + resolution: "jake@npm:10.9.1" + dependencies: + async: "npm:^3.2.3" + chalk: "npm:^4.0.2" + filelist: "npm:^1.0.4" + minimatch: "npm:^3.1.2" + bin: + jake: bin/cli.js + checksum: 10/82603513de5a61bc12360d2b8ba2be9f6bb52495b73f4d1b541cdfef9e43314b132ca10e73d2b41e3c1ea16bf79ec30a64afc9b9e2d2c72a4d4575a8db61cbc8 + languageName: node + linkType: hard + "jest-changed-files@npm:^29.7.0": version: 29.7.0 resolution: "jest-changed-files@npm:29.7.0" @@ -7211,7 +7245,7 @@ __metadata: semver: "npm:^7.6.2" source-map-support: "npm:^0.5.20" supertest: "npm:^7.0.0" - ts-jest: "npm:29.1.5" + ts-jest: "npm:29.2.2" ts-loader: "npm:^9.5.1" ts-node: "npm:^10.9.2" tsconfig-paths: "npm:4.2.0" @@ -7868,11 +7902,12 @@ __metadata: languageName: node linkType: hard -"ts-jest@npm:29.1.5": - version: 29.1.5 - resolution: "ts-jest@npm:29.1.5" +"ts-jest@npm:29.2.2": + version: 29.2.2 + resolution: "ts-jest@npm:29.2.2" dependencies: bs-logger: "npm:0.x" + ejs: "npm:^3.0.0" fast-json-stable-stringify: "npm:2.x" jest-util: "npm:^29.0.0" json5: "npm:^2.2.3" @@ -7900,7 +7935,7 @@ __metadata: optional: true bin: ts-jest: cli.js - checksum: 10/11a29a49130f1c9bef5aebe8007f6be3e630af6c2dea6b00ff5a86d649321854a43966b4990a43960d77a3f98d7a753b9b7e19c20c42a2d38341d6e67a3e48d1 + checksum: 10/6523de2d78493a7901dfc37f2a491b259f5d30beac7a2179ddf8524da0c8e4a7f488aad2d22eca8d074bcc54d7b06a90153fdbf6e9c245f5fc1e484788f0c9d8 languageName: node linkType: hard From 3a32e21912f8bb0f7e10692aac2f56742feccbc7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jul 2024 09:09:37 +0200 Subject: [PATCH 183/207] Bump eslint from 9.5.0 to 9.7.0 (#1765) Bumps [eslint](https://github.com/eslint/eslint) from 9.5.0 to 9.7.0. - [Release notes](https://github.com/eslint/eslint/releases) - [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md) - [Commits](https://github.com/eslint/eslint/compare/v9.5.0...v9.7.0) --- updated-dependencies: - dependency-name: eslint dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 76 +++++++++++++++++++++++++++++++++------------------- 2 files changed, 49 insertions(+), 29 deletions(-) diff --git a/package.json b/package.json index 601d780016..122fe44349 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@types/node": "^20.14.8", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.2", - "eslint": "^9.5.0", + "eslint": "^9.7.0", "eslint-config-prettier": "^9.1.0", "husky": "^9.0.11", "jest": "29.7.0", diff --git a/yarn.lock b/yarn.lock index 1ac90a8e55..557614352c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -675,21 +675,21 @@ __metadata: languageName: node linkType: hard -"@eslint-community/regexpp@npm:^4.6.1": - version: 4.6.2 - resolution: "@eslint-community/regexpp@npm:4.6.2" - checksum: 10/59ea2fa13a70996a8cebbd5a9f4499c92bceeff872286ef2fb34948fcfb9d3467692371d9cc116e7d613f2c18086a1c8337c9d461ccdf213f0dc47f6f6d2fbb6 +"@eslint-community/regexpp@npm:^4.11.0": + version: 4.11.0 + resolution: "@eslint-community/regexpp@npm:4.11.0" + checksum: 10/f053f371c281ba173fe6ee16dbc4fe544c84870d58035ccca08dba7f6ce1830d895ce3237a0db89ba37616524775dca82f1c502066b58e2d5712d7f87f5ba17c languageName: node linkType: hard -"@eslint/config-array@npm:^0.16.0": - version: 0.16.0 - resolution: "@eslint/config-array@npm:0.16.0" +"@eslint/config-array@npm:^0.17.0": + version: 0.17.0 + resolution: "@eslint/config-array@npm:0.17.0" dependencies: "@eslint/object-schema": "npm:^2.1.4" debug: "npm:^4.3.1" - minimatch: "npm:^3.0.5" - checksum: 10/6c1716f896a5bd290a2987ac28ec4fe18f052d2338ccf7822107eb0a6b974c44e6297cb7c9d6e0c5718c510e6c8e53043bea04cf4836dcb26a57e0255bfe99bc + minimatch: "npm:^3.1.2" + checksum: 10/4609b94519cd63ed1aba1429a53c0eb3cb5585056ffaa10184f0b7b91ceaed7ed5e625da3b5b4ffcc9b9093be8d6be7fc46111885936d6543890efb016aa303f languageName: node linkType: hard @@ -710,10 +710,10 @@ __metadata: languageName: node linkType: hard -"@eslint/js@npm:9.5.0": - version: 9.5.0 - resolution: "@eslint/js@npm:9.5.0" - checksum: 10/206364e3a074eaaeccc2b9e1e3f129539106a81ec634f32c51bc1699e0c4a47ab3e6480a6484a198bca6406888ba8f2917c35a87296680905d146075b5ed2738 +"@eslint/js@npm:9.7.0": + version: 9.7.0 + resolution: "@eslint/js@npm:9.7.0" + checksum: 10/b56b9fdec705f2cefae3a6d9d4227c4c28c5cbdbd8849c7997c357cabd4a729cee4445ddb43bb1423fbeb2280a119ced4d0819be8749d107c511e9d81dfe863a languageName: node linkType: hard @@ -2352,6 +2352,15 @@ __metadata: languageName: node linkType: hard +"acorn@npm:^8.12.0": + version: 8.12.1 + resolution: "acorn@npm:8.12.1" + bin: + acorn: bin/acorn + checksum: 10/d08c2d122bba32d0861e0aa840b2ee25946c286d5dc5990abca991baf8cdbfbe199b05aacb221b979411a2fea36f83e26b5ac4f6b4e0ce49038c62316c1848f0 + languageName: node + linkType: hard + "acorn@npm:^8.4.1": version: 8.8.0 resolution: "acorn@npm:8.8.0" @@ -3843,13 +3852,13 @@ __metadata: languageName: node linkType: hard -"eslint-scope@npm:^8.0.1": - version: 8.0.1 - resolution: "eslint-scope@npm:8.0.1" +"eslint-scope@npm:^8.0.2": + version: 8.0.2 + resolution: "eslint-scope@npm:8.0.2" dependencies: esrecurse: "npm:^4.3.0" estraverse: "npm:^5.2.0" - checksum: 10/458513863d3c79005b599f40250437bddba923f18549058ea45820a8d3d4bbc67fe292751d522a0cab69dd01fe211ffde5c1a5fc867e86f2d28727b1d61610da + checksum: 10/d17c2e1ff4d3a98911414a954531078db912e2747d6da8ea4cafd16d0526e32086c676ce9aeaffb3ca0ff695fc951ac3169d7f08a0b42962db683dff126cc95b languageName: node linkType: hard @@ -3874,15 +3883,15 @@ __metadata: languageName: node linkType: hard -"eslint@npm:^9.5.0": - version: 9.5.0 - resolution: "eslint@npm:9.5.0" +"eslint@npm:^9.7.0": + version: 9.7.0 + resolution: "eslint@npm:9.7.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.2.0" - "@eslint-community/regexpp": "npm:^4.6.1" - "@eslint/config-array": "npm:^0.16.0" + "@eslint-community/regexpp": "npm:^4.11.0" + "@eslint/config-array": "npm:^0.17.0" "@eslint/eslintrc": "npm:^3.1.0" - "@eslint/js": "npm:9.5.0" + "@eslint/js": "npm:9.7.0" "@humanwhocodes/module-importer": "npm:^1.0.1" "@humanwhocodes/retry": "npm:^0.3.0" "@nodelib/fs.walk": "npm:^1.2.8" @@ -3891,9 +3900,9 @@ __metadata: cross-spawn: "npm:^7.0.2" debug: "npm:^4.3.2" escape-string-regexp: "npm:^4.0.0" - eslint-scope: "npm:^8.0.1" + eslint-scope: "npm:^8.0.2" eslint-visitor-keys: "npm:^4.0.0" - espree: "npm:^10.0.1" + espree: "npm:^10.1.0" esquery: "npm:^1.5.0" esutils: "npm:^2.0.2" fast-deep-equal: "npm:^3.1.3" @@ -3914,7 +3923,7 @@ __metadata: text-table: "npm:^0.2.0" bin: eslint: bin/eslint.js - checksum: 10/47578c242659a398638918c6f61a12c3e1e0ca71733769a54fdfd7be6d7c4ca0824694861846959829784b23cbfca5aad9599714dc0f4ae48ffdcdafbfe67bea + checksum: 10/f9b3c99a63f1e94feadb2005d854c907d2a9322d14a0ad8a47a127562475bfdcc43fbffcae184e3de94d832218891bb4b7a21a914f7ef9e22b1d2ee19941368d languageName: node linkType: hard @@ -3929,6 +3938,17 @@ __metadata: languageName: node linkType: hard +"espree@npm:^10.1.0": + version: 10.1.0 + resolution: "espree@npm:10.1.0" + dependencies: + acorn: "npm:^8.12.0" + acorn-jsx: "npm:^5.3.2" + eslint-visitor-keys: "npm:^4.0.0" + checksum: 10/a673aa39a19a51763d92272f8f3772ae3d4b10624740bb72d5f273b631b43f1a5a32b385c1da6ae6bc10be05a5913bc4679ebd22a09c7b336a745204834806ea + languageName: node + linkType: hard + "esprima@npm:^4.0.0, esprima@npm:^4.0.1": version: 4.0.1 resolution: "esprima@npm:4.0.1" @@ -6161,7 +6181,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^3.0.4, minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": +"minimatch@npm:^3.0.4, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": version: 3.1.2 resolution: "minimatch@npm:3.1.2" dependencies: @@ -7230,7 +7250,7 @@ __metadata: amqp-connection-manager: "npm:^4.1.14" amqplib: "npm:^0.10.4" cookie-parser: "npm:^1.4.6" - eslint: "npm:^9.5.0" + eslint: "npm:^9.7.0" eslint-config-prettier: "npm:^9.1.0" husky: "npm:^9.0.11" jest: "npm:29.7.0" From d20664667999c034835f53c1bd08604119526ab5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jul 2024 09:12:26 +0200 Subject: [PATCH 184/207] Bump typescript from 5.5.2 to 5.5.3 (#1763) Bumps [typescript](https://github.com/Microsoft/TypeScript) from 5.5.2 to 5.5.3. - [Release notes](https://github.com/Microsoft/TypeScript/releases) - [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml) - [Commits](https://github.com/Microsoft/TypeScript/compare/v5.5.2...v5.5.3) --- updated-dependencies: - dependency-name: typescript dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 122fe44349..3cc9befaa0 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "4.2.0", - "typescript": "^5.5.2", + "typescript": "^5.5.3", "typescript-eslint": "^7.15.0" }, "jest": { diff --git a/yarn.lock b/yarn.lock index 557614352c..8a0cc75819 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7269,7 +7269,7 @@ __metadata: ts-loader: "npm:^9.5.1" ts-node: "npm:^10.9.2" tsconfig-paths: "npm:4.2.0" - typescript: "npm:^5.5.2" + typescript: "npm:^5.5.3" typescript-eslint: "npm:^7.15.0" viem: "npm:^2.17.3" winston: "npm:^3.13.0" @@ -8115,13 +8115,13 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.5.2": - version: 5.5.2 - resolution: "typescript@npm:5.5.2" +"typescript@npm:^5.5.3": + version: 5.5.3 + resolution: "typescript@npm:5.5.3" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 10/9118b20f248e76b0dbff8737fef65dfa89d02668d4e633d2c5ceac99033a0ca5e8a1c1a53bc94da68e8f67677a88f318663dde859c9e9a09c1e116415daec2ba + checksum: 10/11a867312419ed497929aafd2f1d28b2cd41810a5eb6c6e9e169559112e9ea073d681c121a29102e67cd4478d0a4ae37a306a5800f3717f59c4337e6a9bd5e8d languageName: node linkType: hard @@ -8135,13 +8135,13 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5.5.2#optional!builtin": - version: 5.5.2 - resolution: "typescript@patch:typescript@npm%3A5.5.2#optional!builtin::version=5.5.2&hash=5adc0c" +"typescript@patch:typescript@npm%3A^5.5.3#optional!builtin": + version: 5.5.3 + resolution: "typescript@patch:typescript@npm%3A5.5.3#optional!builtin::version=5.5.3&hash=5adc0c" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 10/28b3de2ddaf63a7620e7ddbe5d377af71ce93ecc558c41bf0e3d88661d8e6e7aa6c7739164fef98055f69819e41faca49252938ef3633a3dff2734cca6a9042e + checksum: 10/b61b8bb4b4d6a8a00f9d5f931f8c67070eed6ad11feabf4c41744a326987080bfc806a621596c70fbf2e5974eca3ed65bafeeeb22a078071bdfb51d8abd7c013 languageName: node linkType: hard From 66c17c495f6dabafe0f4071f45b4bba118445686 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jul 2024 09:29:28 +0200 Subject: [PATCH 185/207] Bump viem from 2.17.3 to 2.17.4 (#1762) Bumps [viem](https://github.com/wevm/viem) from 2.17.3 to 2.17.4. - [Release notes](https://github.com/wevm/viem/releases) - [Commits](https://github.com/wevm/viem/compare/viem@2.17.3...viem@2.17.4) --- updated-dependencies: - dependency-name: viem dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 3cc9befaa0..85402cc34b 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "semver": "^7.6.2", - "viem": "^2.17.3", + "viem": "^2.17.4", "winston": "^3.13.0", "zod": "^3.23.8" }, diff --git a/yarn.lock b/yarn.lock index 8a0cc75819..c61f1b2432 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7271,7 +7271,7 @@ __metadata: tsconfig-paths: "npm:4.2.0" typescript: "npm:^5.5.3" typescript-eslint: "npm:^7.15.0" - viem: "npm:^2.17.3" + viem: "npm:^2.17.4" winston: "npm:^3.13.0" zod: "npm:^3.23.8" languageName: unknown @@ -8279,9 +8279,9 @@ __metadata: languageName: node linkType: hard -"viem@npm:^2.17.3": - version: 2.17.3 - resolution: "viem@npm:2.17.3" +"viem@npm:^2.17.4": + version: 2.17.4 + resolution: "viem@npm:2.17.4" dependencies: "@adraffy/ens-normalize": "npm:1.10.0" "@noble/curves": "npm:1.4.0" @@ -8296,7 +8296,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/803b49f7932fd59058f41202682610a0dd767b908cdc7777ef20c62108587a069e23e5ba8fc3d278443620b057571931503dc4e1ff257d1b7fd7f2411199de8b + checksum: 10/29e2b7d9034ea0520c47ef25cd8048078f8a0bacd24e1c468ee2ec5c51f780429e1bbb914611b54b8ee19f7eb251e171af67db7f27b97209c16cf8e917b405bf languageName: node linkType: hard From 86b0c738b2d61ae470d9ea8e8be903307ed240c7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jul 2024 09:35:41 +0200 Subject: [PATCH 186/207] Bump typescript-eslint from 7.15.0 to 7.16.1 (#1764) Bumps [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) from 7.15.0 to 7.16.1. - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.16.1/packages/typescript-eslint) --- updated-dependencies: - dependency-name: typescript-eslint dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 116 +++++++++++++++++++++++++-------------------------- 2 files changed, 59 insertions(+), 59 deletions(-) diff --git a/package.json b/package.json index 85402cc34b..a0ebcaa9ed 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "ts-node": "^10.9.2", "tsconfig-paths": "4.2.0", "typescript": "^5.5.3", - "typescript-eslint": "^7.15.0" + "typescript-eslint": "^7.16.1" }, "jest": { "moduleFileExtensions": [ diff --git a/yarn.lock b/yarn.lock index c61f1b2432..2274f1a108 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2003,15 +2003,15 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:7.15.0": - version: 7.15.0 - resolution: "@typescript-eslint/eslint-plugin@npm:7.15.0" +"@typescript-eslint/eslint-plugin@npm:7.16.1": + version: 7.16.1 + resolution: "@typescript-eslint/eslint-plugin@npm:7.16.1" dependencies: "@eslint-community/regexpp": "npm:^4.10.0" - "@typescript-eslint/scope-manager": "npm:7.15.0" - "@typescript-eslint/type-utils": "npm:7.15.0" - "@typescript-eslint/utils": "npm:7.15.0" - "@typescript-eslint/visitor-keys": "npm:7.15.0" + "@typescript-eslint/scope-manager": "npm:7.16.1" + "@typescript-eslint/type-utils": "npm:7.16.1" + "@typescript-eslint/utils": "npm:7.16.1" + "@typescript-eslint/visitor-keys": "npm:7.16.1" graphemer: "npm:^1.4.0" ignore: "npm:^5.3.1" natural-compare: "npm:^1.4.0" @@ -2022,44 +2022,44 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/e6b21687ab9e9dc38eb1b1d90a3ac483f3f5e5e9c49aa8a434a24de016822d65c82b926cda2ae79bac2225bd9495fb04f7aa6afcaad2b09f6129fd8014fbcedd + checksum: 10/fddbfe461f85d10ee3967b89efa3c704806074af6806833f982915b21754567a98c5a486627174cc6b0ac4cb5f1282865d64ae251a5cbf6dbbbe191d0268520a languageName: node linkType: hard -"@typescript-eslint/parser@npm:7.15.0": - version: 7.15.0 - resolution: "@typescript-eslint/parser@npm:7.15.0" +"@typescript-eslint/parser@npm:7.16.1": + version: 7.16.1 + resolution: "@typescript-eslint/parser@npm:7.16.1" dependencies: - "@typescript-eslint/scope-manager": "npm:7.15.0" - "@typescript-eslint/types": "npm:7.15.0" - "@typescript-eslint/typescript-estree": "npm:7.15.0" - "@typescript-eslint/visitor-keys": "npm:7.15.0" + "@typescript-eslint/scope-manager": "npm:7.16.1" + "@typescript-eslint/types": "npm:7.16.1" + "@typescript-eslint/typescript-estree": "npm:7.16.1" + "@typescript-eslint/visitor-keys": "npm:7.16.1" debug: "npm:^4.3.4" peerDependencies: eslint: ^8.56.0 peerDependenciesMeta: typescript: optional: true - checksum: 10/0b5e7a14fa5d0680efb17e750a095729a7fb7c785d7a0fea2f9e6cbfef9e65caab2b751654b348b9ab813d222c1c3f8189ebf48561b81224d1821cee5c99d658 + checksum: 10/7af36bacc2c38e9fb367edf886a04fde292ff28b49adfc3f4fc0dd456364c5e18444346112ae52557f2f32fe2e5abd144b87b4db89b6960b4957d69a9d390f91 languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:7.15.0": - version: 7.15.0 - resolution: "@typescript-eslint/scope-manager@npm:7.15.0" +"@typescript-eslint/scope-manager@npm:7.16.1": + version: 7.16.1 + resolution: "@typescript-eslint/scope-manager@npm:7.16.1" dependencies: - "@typescript-eslint/types": "npm:7.15.0" - "@typescript-eslint/visitor-keys": "npm:7.15.0" - checksum: 10/45bfdbae2d080691a34f5b37679b4a4067981baa3b82922268abdd21f6917a8dd1c4ccb12133f6c9cce81cfd640040913b223e8125235b92f42fdb57db358a3e + "@typescript-eslint/types": "npm:7.16.1" + "@typescript-eslint/visitor-keys": "npm:7.16.1" + checksum: 10/57ce02c2624e49988b01666b3e13d1adb44ab78f2dafc47a56800d57bff624779b348928a905393fa5f2cce94a5844173ab81f32b81f0bb2897f10bbaf9cab6a languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:7.15.0": - version: 7.15.0 - resolution: "@typescript-eslint/type-utils@npm:7.15.0" +"@typescript-eslint/type-utils@npm:7.16.1": + version: 7.16.1 + resolution: "@typescript-eslint/type-utils@npm:7.16.1" dependencies: - "@typescript-eslint/typescript-estree": "npm:7.15.0" - "@typescript-eslint/utils": "npm:7.15.0" + "@typescript-eslint/typescript-estree": "npm:7.16.1" + "@typescript-eslint/utils": "npm:7.16.1" debug: "npm:^4.3.4" ts-api-utils: "npm:^1.3.0" peerDependencies: @@ -2067,23 +2067,23 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/64fa589b413567df3689a19ef88f3dbaed66d965e39cc548a58626eb5bd8fc4e2338496eb632f3472de9ae9800cb14d0e48ef3508efe80bdb91af8f3f1e56ad7 + checksum: 10/38a72a3de8a2c3455d19e6d43e67ac6e1dc23e93b2d84571282b0323fadadcab33df1a89787c76fc99e45514e41a08bc9f5cb51287a7da48f56c64b512a3269b languageName: node linkType: hard -"@typescript-eslint/types@npm:7.15.0": - version: 7.15.0 - resolution: "@typescript-eslint/types@npm:7.15.0" - checksum: 10/b36c98344469f4bc54a5199733ea4f6d4d0f2da1070605e60d4031e2da2946b84b91a90108516c8e6e83a21030ba4e935053a0906041c920156de40683297d0b +"@typescript-eslint/types@npm:7.16.1": + version: 7.16.1 + resolution: "@typescript-eslint/types@npm:7.16.1" + checksum: 10/cfb48821ffb5a5307e67ce05b9ec2f4775c560dc53011e313d4fa75d033e0130ce0d364ac92ad3634d325c16a889ddc3201e8a742217c73be8d34385da85620b languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:7.15.0": - version: 7.15.0 - resolution: "@typescript-eslint/typescript-estree@npm:7.15.0" +"@typescript-eslint/typescript-estree@npm:7.16.1": + version: 7.16.1 + resolution: "@typescript-eslint/typescript-estree@npm:7.16.1" dependencies: - "@typescript-eslint/types": "npm:7.15.0" - "@typescript-eslint/visitor-keys": "npm:7.15.0" + "@typescript-eslint/types": "npm:7.16.1" + "@typescript-eslint/visitor-keys": "npm:7.16.1" debug: "npm:^4.3.4" globby: "npm:^11.1.0" is-glob: "npm:^4.0.3" @@ -2093,31 +2093,31 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/c5fb15108fbbc1bc976e827218ff7bfbc78930c5906292325ee42ba03514623e7b861497b3e3087f71ede9a757b16441286b4d234450450b0dd70ff753782736 + checksum: 10/7f88176f2d25779ec2d40df4c6bd0a26aa41494ee0302d4895b4d0cb4e284385c1e218ac2ad67ed90b5e1bf82b78b8aa4b903b5906fbf7101b08c409ce778e9c languageName: node linkType: hard -"@typescript-eslint/utils@npm:7.15.0": - version: 7.15.0 - resolution: "@typescript-eslint/utils@npm:7.15.0" +"@typescript-eslint/utils@npm:7.16.1": + version: 7.16.1 + resolution: "@typescript-eslint/utils@npm:7.16.1" dependencies: "@eslint-community/eslint-utils": "npm:^4.4.0" - "@typescript-eslint/scope-manager": "npm:7.15.0" - "@typescript-eslint/types": "npm:7.15.0" - "@typescript-eslint/typescript-estree": "npm:7.15.0" + "@typescript-eslint/scope-manager": "npm:7.16.1" + "@typescript-eslint/types": "npm:7.16.1" + "@typescript-eslint/typescript-estree": "npm:7.16.1" peerDependencies: eslint: ^8.56.0 - checksum: 10/f6de1849dee610a8110638be98ab2ec09e7cdf2f756b538b0544df2dfad86a8e66d5326a765302fe31553e8d9d3170938c0d5d38bd9c7d36e3ee0beb1bdc8172 + checksum: 10/b3c279d706ff1b3a0002c8e0f0fcf559b63f4296e218199a25863054bda5b28d5a7ab6ad4ad1d0b7fa2c6cd9f2d0dcd7f784c3f75026fae7b58846695481ec45 languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:7.15.0": - version: 7.15.0 - resolution: "@typescript-eslint/visitor-keys@npm:7.15.0" +"@typescript-eslint/visitor-keys@npm:7.16.1": + version: 7.16.1 + resolution: "@typescript-eslint/visitor-keys@npm:7.16.1" dependencies: - "@typescript-eslint/types": "npm:7.15.0" + "@typescript-eslint/types": "npm:7.16.1" eslint-visitor-keys: "npm:^3.4.3" - checksum: 10/0e17d7f5de767da7f98170c2efc905cdb0ceeaf04a667e12ca1a92eae64479a07f4f8e2a9b5023b055b01250916c3bcac86908cd06552610baff734fafae4464 + checksum: 10/f5088d72b6ca48f4e525b7b5d6c6c9254d0d039d2959fd91200691218e8ac8f3e56287ec8bc411a79609e9d85ed5fc6c4f7d2edd80fadf734aeb6f6bfc833322 languageName: node linkType: hard @@ -7270,7 +7270,7 @@ __metadata: ts-node: "npm:^10.9.2" tsconfig-paths: "npm:4.2.0" typescript: "npm:^5.5.3" - typescript-eslint: "npm:^7.15.0" + typescript-eslint: "npm:^7.16.1" viem: "npm:^2.17.4" winston: "npm:^3.13.0" zod: "npm:^3.23.8" @@ -8089,19 +8089,19 @@ __metadata: languageName: node linkType: hard -"typescript-eslint@npm:^7.15.0": - version: 7.15.0 - resolution: "typescript-eslint@npm:7.15.0" +"typescript-eslint@npm:^7.16.1": + version: 7.16.1 + resolution: "typescript-eslint@npm:7.16.1" dependencies: - "@typescript-eslint/eslint-plugin": "npm:7.15.0" - "@typescript-eslint/parser": "npm:7.15.0" - "@typescript-eslint/utils": "npm:7.15.0" + "@typescript-eslint/eslint-plugin": "npm:7.16.1" + "@typescript-eslint/parser": "npm:7.16.1" + "@typescript-eslint/utils": "npm:7.16.1" peerDependencies: eslint: ^8.56.0 peerDependenciesMeta: typescript: optional: true - checksum: 10/f81129f795cc5a5f01ae3c289113a00232f937bfd8f2ebe519a369c9adce9155de106ccd7d19cd353e6f8d34bde391d31bd83754df2deffb7c2be8238da173d5 + checksum: 10/97a8a0535a377e13706628000e261d2c5dfa5da606627f8f31e8ddfe7412f134e2ad93615fa8574d1511a46c05c7b154722e6df5d710eb467e0386acbcb4d7fc languageName: node linkType: hard From d6e26291f5ce023a9487da9126c8fc7ac4cdbbba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Tue, 16 Jul 2024 18:25:11 +0200 Subject: [PATCH 187/207] Add Accounts Datasource cache layer (#1760) Adds cache management to AccountDatasource, along with its associated tests. --- .../accounts/accounts.datasource.spec.ts | 338 +++++++++++++++--- .../accounts/accounts.datasource.ts | 159 ++++++-- src/datasources/cache/cache.router.ts | 18 + src/datasources/db/entities/row.entity.ts | 4 +- .../entities/account-data-type.entity.spec.ts | 20 +- .../accounts/entities/account.entity.spec.ts | 12 +- .../accounts/entities/group.entity.spec.ts | 12 +- 7 files changed, 449 insertions(+), 114 deletions(-) diff --git a/src/datasources/accounts/accounts.datasource.spec.ts b/src/datasources/accounts/accounts.datasource.spec.ts index 6f1efc7f45..9987b7abe9 100644 --- a/src/datasources/accounts/accounts.datasource.spec.ts +++ b/src/datasources/accounts/accounts.datasource.spec.ts @@ -1,5 +1,8 @@ import { TestDbFactory } from '@/__tests__/db.factory'; +import { IConfigurationService } from '@/config/configuration.service.interface'; import { AccountsDatasource } from '@/datasources/accounts/accounts.datasource'; +import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.service'; +import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity'; import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; import { accountDataTypeBuilder } from '@/domain/accounts/entities/__tests__/account-data-type.builder'; import { upsertAccountDataSettingsDtoBuilder } from '@/domain/accounts/entities/__tests__/upsert-account-data-settings.dto.entity.builder'; @@ -11,25 +14,43 @@ import { getAddress } from 'viem'; const mockLoggingService = { debug: jest.fn(), + error: jest.fn(), info: jest.fn(), warn: jest.fn(), } as jest.MockedObjectDeep; +const mockConfigurationService = jest.mocked({ + getOrThrow: jest.fn(), +} as jest.MockedObjectDeep); + describe('AccountsDatasource tests', () => { - let target: AccountsDatasource; - let migrator: PostgresDatabaseMigrator; + let fakeCacheService: FakeCacheService; let sql: postgres.Sql; + let migrator: PostgresDatabaseMigrator; + let target: AccountsDatasource; const testDbFactory = new TestDbFactory(); beforeAll(async () => { + fakeCacheService = new FakeCacheService(); sql = await testDbFactory.createTestDatabase(faker.string.uuid()); migrator = new PostgresDatabaseMigrator(sql); await migrator.migrate(); - target = new AccountsDatasource(sql, mockLoggingService); + mockConfigurationService.getOrThrow.mockImplementation((key) => { + if (key === 'expirationTimeInSeconds.default') return faker.number.int(); + }); + + target = new AccountsDatasource( + fakeCacheService, + sql, + mockLoggingService, + mockConfigurationService, + ); }); afterEach(async () => { await sql`TRUNCATE TABLE accounts, groups, account_data_types CASCADE`; + fakeCacheService.clear(); + jest.clearAllMocks(); }); afterAll(async () => { @@ -49,6 +70,19 @@ describe('AccountsDatasource tests', () => { created_at: expect.any(Date), updated_at: expect.any(Date), }); + + // check the account is stored in the cache + const cacheDir = new CacheDir(`account_${address}`, ''); + const cacheContent = await fakeCacheService.get(cacheDir); + expect(JSON.parse(cacheContent as string)).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(Number), + group_id: null, + address, + }), + ]), + ); }); it('throws when an account with the same address already exists', async () => { @@ -68,21 +102,69 @@ describe('AccountsDatasource tests', () => { const result = await target.getAccount(address); - expect(result).toStrictEqual({ - id: expect.any(Number), - group_id: null, - address, - created_at: expect.any(Date), - updated_at: expect.any(Date), + expect(result).toStrictEqual( + expect.objectContaining({ + id: expect.any(Number), + group_id: null, + address, + }), + ); + }); + + it('returns an account from cache', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + await target.createAccount(address); + + const result = await target.getAccount(address); + + expect(result).toStrictEqual( + expect.objectContaining({ + id: expect.any(Number), + group_id: null, + address, + }), + ); + const cacheDir = new CacheDir(`account_${address}`, ''); + const cacheContent = await fakeCacheService.get(cacheDir); + expect(JSON.parse(cacheContent as string)).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(Number), + group_id: null, + address, + }), + ]), + ); + expect(mockLoggingService.debug).toHaveBeenCalledTimes(1); + expect(mockLoggingService.debug).toHaveBeenNthCalledWith(1, { + type: 'cache_hit', + key: `account_${address}`, + field: '', }); }); - it('throws if no account is found', async () => { + it('should not cache if the account is not found', async () => { const address = getAddress(faker.finance.ethereumAddress()); + // should not cache the account await expect(target.getAccount(address)).rejects.toThrow( 'Error getting account.', ); + await expect(target.getAccount(address)).rejects.toThrow( + 'Error getting account.', + ); + + expect(mockLoggingService.debug).toHaveBeenCalledTimes(2); + expect(mockLoggingService.debug).toHaveBeenNthCalledWith(1, { + type: 'cache_miss', + key: `account_${address}`, + field: '', + }); + expect(mockLoggingService.debug).toHaveBeenNthCalledWith(2, { + type: 'cache_miss', + key: `account_${address}`, + field: '', + }); }); }); @@ -103,52 +185,118 @@ describe('AccountsDatasource tests', () => { expect(mockLoggingService.debug).toHaveBeenCalledTimes(1); }); + + it('should clear the cache on account deletion', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + await target.createAccount(address); + + // get the account from the cache + const beforeDeletion = await target.getAccount(address); + expect(beforeDeletion).toStrictEqual( + expect.objectContaining({ + id: expect.any(Number), + group_id: null, + address, + }), + ); + + // the account is deleted from the database and the cache + await expect(target.deleteAccount(address)).resolves.not.toThrow(); + await expect(target.getAccount(address)).rejects.toThrow(); + const cached = await fakeCacheService.get( + new CacheDir(`account_${address}`, ''), + ); + expect(cached).toBeUndefined(); + + expect(mockLoggingService.debug).toHaveBeenCalledTimes(2); + expect(mockLoggingService.debug).toHaveBeenNthCalledWith(1, { + type: 'cache_hit', + key: `account_${address}`, + field: '', + }); + expect(mockLoggingService.debug).toHaveBeenNthCalledWith(2, { + type: 'cache_miss', + key: `account_${address}`, + field: '', + }); + }); }); describe('getDataTypes', () => { - it('returns data types successfully', async () => { - const dataTypeNames = [ - faker.lorem.slug(), - faker.lorem.slug(), - faker.lorem.slug(), + it('returns all data types from the database successfully', async () => { + const dataTypes = [ + { name: faker.lorem.slug() }, + { name: faker.lorem.slug() }, + { name: faker.lorem.slug() }, ]; await sql` - INSERT INTO account_data_types (name) VALUES - (${dataTypeNames[0]}), - (${dataTypeNames[1]}), - (${dataTypeNames[2]}) - `; + INSERT INTO account_data_types ${sql(dataTypes, 'name')}`; const result = await target.getDataTypes(); expect(result).toStrictEqual( - expect.arrayContaining([ - { - id: expect.any(Number), - name: dataTypeNames[0], - description: null, - is_active: true, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - { - id: expect.any(Number), - name: dataTypeNames[1], - description: null, - is_active: true, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - { - id: expect.any(Number), - name: dataTypeNames[2], - description: null, - is_active: true, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - ]), + expect.arrayContaining( + dataTypes.map((dataType) => + expect.objectContaining({ + id: expect.any(Number), + name: dataType.name, + description: null, + is_active: true, + }), + ), + ), + ); + }); + + it('returns all data types from cache successfully', async () => { + const dataTypes = [ + { name: faker.lorem.slug() }, + { name: faker.lorem.slug() }, + { name: faker.lorem.slug() }, + ]; + await sql` + INSERT INTO account_data_types ${sql(dataTypes, 'name')} RETURNING (id)`; + await target.getDataTypes(); + + const result = await target.getDataTypes(); + + expect(result).toStrictEqual( + expect.arrayContaining( + dataTypes.map((dataType) => + expect.objectContaining({ + id: expect.any(Number), + name: dataType.name, + description: null, + is_active: true, + }), + ), + ), + ); + const cacheDir = new CacheDir('account_data_types', ''); + const cacheContent = await fakeCacheService.get(cacheDir); + expect(JSON.parse(cacheContent as string)).toStrictEqual( + expect.arrayContaining( + dataTypes.map((dataType) => + expect.objectContaining({ + id: expect.any(Number), + name: dataType.name, + description: null, + is_active: true, + }), + ), + ), ); + expect(mockLoggingService.debug).toHaveBeenCalledTimes(2); + expect(mockLoggingService.debug).toHaveBeenNthCalledWith(1, { + type: 'cache_miss', + key: 'account_data_types', + field: '', + }); + expect(mockLoggingService.debug).toHaveBeenNthCalledWith(2, { + type: 'cache_hit', + key: 'account_data_types', + field: '', + }); }); }); @@ -184,6 +332,66 @@ describe('AccountsDatasource tests', () => { expect(actual).toStrictEqual(expect.arrayContaining(expected)); }); + it('should get the account data settings from cache', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const account = await target.createAccount(address); + const accountDataTypes = Array.from( + { length: faker.number.int({ min: 1, max: 4 }) }, + () => accountDataTypeBuilder().with('is_active', true).build(), + ); + const insertedDataTypes = + await sql`INSERT INTO account_data_types ${sql(accountDataTypes, 'name', 'is_active')} returning *`; + const accountDataSettingRows = insertedDataTypes.map((dataType) => ({ + account_id: account.id, + account_data_type_id: dataType.id, + enabled: faker.datatype.boolean(), + })); + await sql` + INSERT INTO account_data_settings + ${sql(accountDataSettingRows, 'account_id', 'account_data_type_id', 'enabled')} returning *`; + await target.getAccountDataSettings(address); + + // check the account data settings are in the cache + const actual = await target.getAccountDataSettings(address); + + const expected = accountDataSettingRows.map((accountDataSettingRow) => + expect.objectContaining({ + account_id: account.id, + account_data_type_id: accountDataSettingRow.account_data_type_id, + enabled: accountDataSettingRow.enabled, + }), + ); + + expect(actual).toStrictEqual(expect.arrayContaining(expected)); + const cacheContent = await fakeCacheService.get( + new CacheDir(`account_data_settings_${address}`, ''), + ); + expect(JSON.parse(cacheContent as string)).toStrictEqual( + expect.arrayContaining(expected), + ); + expect(mockLoggingService.debug).toHaveBeenCalledTimes(4); + expect(mockLoggingService.debug).toHaveBeenNthCalledWith(1, { + type: 'cache_hit', + key: `account_${address}`, + field: '', + }); + expect(mockLoggingService.debug).toHaveBeenNthCalledWith(2, { + type: 'cache_miss', + key: `account_data_settings_${address}`, + field: '', + }); + expect(mockLoggingService.debug).toHaveBeenNthCalledWith(3, { + type: 'cache_hit', + key: `account_${address}`, + field: '', + }); + expect(mockLoggingService.debug).toHaveBeenNthCalledWith(4, { + type: 'cache_hit', + key: `account_data_settings_${address}`, + field: '', + }); + }); + it('should omit account data settings which data type is not active', async () => { const address = getAddress(faker.finance.ethereumAddress()); const account = await target.createAccount(address); @@ -258,6 +466,44 @@ describe('AccountsDatasource tests', () => { expect(actual).toStrictEqual(expect.arrayContaining(expected)); }); + it('should write the associated cache on upsert', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const account = await target.createAccount(address); + const accountDataTypes = Array.from( + { length: faker.number.int({ min: 1, max: 4 }) }, + () => accountDataTypeBuilder().with('is_active', true).build(), + ); + const insertedDataTypes = + await sql`INSERT INTO account_data_types ${sql(accountDataTypes, 'name', 'is_active')} returning *`; + const accountDataSettings = insertedDataTypes.map((dataType) => ({ + id: dataType.id, + enabled: faker.datatype.boolean(), + })); + const upsertAccountDataSettingsDto = upsertAccountDataSettingsDtoBuilder() + .with('accountDataSettings', accountDataSettings) + .build(); + + await target.upsertAccountDataSettings( + address, + upsertAccountDataSettingsDto, + ); + + // check the account data settings are stored in the cache + const cacheDir = new CacheDir(`account_data_settings_${address}`, ''); + const cacheContent = await fakeCacheService.get(cacheDir); + expect(JSON.parse(cacheContent as string)).toStrictEqual( + expect.arrayContaining( + accountDataSettings.map((accountDataSetting) => + expect.objectContaining({ + account_id: account.id, + account_data_type_id: accountDataSetting.id, + enabled: accountDataSetting.enabled, + }), + ), + ), + ); + }); + it('updates existing account data settings successfully', async () => { const address = getAddress(faker.finance.ethereumAddress()); const account = await target.createAccount(address); diff --git a/src/datasources/accounts/accounts.datasource.ts b/src/datasources/accounts/accounts.datasource.ts index 70dfe66075..776d474a60 100644 --- a/src/datasources/accounts/accounts.datasource.ts +++ b/src/datasources/accounts/accounts.datasource.ts @@ -1,3 +1,11 @@ +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { CacheRouter } from '@/datasources/cache/cache.router'; +import { + CacheService, + ICacheService, +} from '@/datasources/cache/cache.service.interface'; +import { MAX_TTL } from '@/datasources/cache/constants'; +import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity'; import { AccountDataSetting } from '@/domain/accounts/entities/account-data-setting.entity'; import { AccountDataType } from '@/domain/accounts/entities/account-data-type.entity'; import { Account } from '@/domain/accounts/entities/account.entity'; @@ -8,44 +16,66 @@ import { asError } from '@/logging/utils'; import { Inject, Injectable, + InternalServerErrorException, NotFoundException, + OnModuleInit, UnprocessableEntityException, } from '@nestjs/common'; import postgres from 'postgres'; @Injectable() -export class AccountsDatasource implements IAccountsDatasource { +export class AccountsDatasource implements IAccountsDatasource, OnModuleInit { + private readonly defaultExpirationTimeInSeconds: number; + constructor( + @Inject(CacheService) private readonly cacheService: ICacheService, @Inject('DB_INSTANCE') private readonly sql: postgres.Sql, @Inject(LoggingService) private readonly loggingService: ILoggingService, - ) {} + @Inject(IConfigurationService) + private readonly configurationService: IConfigurationService, + ) { + this.defaultExpirationTimeInSeconds = + this.configurationService.getOrThrow( + 'expirationTimeInSeconds.default', + ); + } + + /** + * Function executed when the module is initialized. + * It deletes the cache for persistent keys. + */ + async onModuleInit(): Promise { + await this.cacheService.deleteByKey( + CacheRouter.getAccountDataTypesCacheDir().key, + ); + } async createAccount(address: `0x${string}`): Promise { - const [account] = await this.sql< - [Account] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING *`.catch( + const [account] = await this.sql<[Account]>` + INSERT INTO accounts (address) VALUES (${address}) RETURNING *`.catch( (e) => { this.loggingService.warn( `Error creating account: ${asError(e).message}`, ); - return []; + throw new UnprocessableEntityException('Error creating account.'); }, ); - - if (!account) { - throw new UnprocessableEntityException('Error creating account.'); - } - + const cacheDir = CacheRouter.getAccountCacheDir(address); + await this.cacheService.set( + cacheDir, + JSON.stringify([account]), + this.defaultExpirationTimeInSeconds, + ); return account; } async getAccount(address: `0x${string}`): Promise { - const [account] = await this.sql< - [Account] - >`SELECT * FROM accounts WHERE address = ${address}`.catch((e) => { - this.loggingService.info(`Error getting account: ${asError(e).message}`); - return []; - }); + const cacheDir = CacheRouter.getAccountCacheDir(address); + const [account] = await this.getFromCacheOrExecuteAndCache( + cacheDir, + this.sql`SELECT * FROM accounts WHERE address = ${address}`, + this.defaultExpirationTimeInSeconds, + ); if (!account) { throw new NotFoundException('Error getting account.'); @@ -55,28 +85,42 @@ export class AccountsDatasource implements IAccountsDatasource { } async deleteAccount(address: `0x${string}`): Promise { - const { count } = await this - .sql`DELETE FROM accounts WHERE address = ${address}`; - - if (count === 0) { - this.loggingService.debug(`Error deleting account ${address}: not found`); + try { + const { count } = await this + .sql`DELETE FROM accounts WHERE address = ${address}`; + if (count === 0) { + this.loggingService.debug( + `Error deleting account ${address}: not found`, + ); + } + } finally { + const { key } = CacheRouter.getAccountCacheDir(address); + await this.cacheService.deleteByKey(key); } } async getDataTypes(): Promise { - // TODO: add caching with clearing mechanism. - return this.sql<[AccountDataType]>`SELECT * FROM account_data_types`; + const cacheDir = CacheRouter.getAccountDataTypesCacheDir(); + return this.getFromCacheOrExecuteAndCache( + cacheDir, + this.sql`SELECT * FROM account_data_types`, + MAX_TTL, + ); } async getAccountDataSettings( address: `0x${string}`, ): Promise { const account = await this.getAccount(address); - return this.sql<[AccountDataSetting]>` - SELECT ads.* FROM account_data_settings ads INNER JOIN account_data_types adt - ON ads.account_data_type_id = adt.id - WHERE ads.account_id = ${account.id} AND adt.is_active IS TRUE; - `; + const cacheDir = CacheRouter.getAccountDataSettingsCacheDir(address); + return this.getFromCacheOrExecuteAndCache( + cacheDir, + this.sql` + SELECT ads.* FROM account_data_settings ads INNER JOIN account_data_types adt + ON ads.account_data_type_id = adt.id + WHERE ads.account_id = ${account.id} AND adt.is_active IS TRUE;`, + this.defaultExpirationTimeInSeconds, + ); } /** @@ -97,7 +141,8 @@ export class AccountsDatasource implements IAccountsDatasource { const { accountDataSettings } = upsertAccountDataSettings; await this.checkDataTypes(accountDataSettings); const account = await this.getAccount(address); - return this.sql.begin(async (sql) => { + + const result = await this.sql.begin(async (sql) => { await Promise.all( accountDataSettings.map(async (accountDataSetting) => { return sql` @@ -114,20 +159,23 @@ export class AccountsDatasource implements IAccountsDatasource { return sql<[AccountDataSetting]>` SELECT * FROM account_data_settings WHERE account_id = ${account.id}`; }); - } - private getActiveDataTypes(): Promise { - // TODO: add caching with clearing mechanism. - return this.sql<[AccountDataType]>` - SELECT * FROM account_data_types WHERE is_active IS TRUE; - `; + const cacheDir = CacheRouter.getAccountDataSettingsCacheDir(address); + await this.cacheService.set( + cacheDir, + JSON.stringify(result), + this.defaultExpirationTimeInSeconds, + ); + return result; } private async checkDataTypes( accountDataSettings: UpsertAccountDataSettingsDto['accountDataSettings'], ): Promise { - const activeDataTypes = await this.getActiveDataTypes(); - const activeDataTypeIds = activeDataTypes.map((ads) => ads.id); + const dataTypes = await this.getDataTypes(); + const activeDataTypeIds = dataTypes + .filter((dt) => dt.is_active) + .map((ads) => ads.id); if ( !accountDataSettings.every((ads) => activeDataTypeIds.includes(Number(ads.id)), @@ -138,4 +186,39 @@ export class AccountsDatasource implements IAccountsDatasource { ); } } + + /** + * Returns the content from cache or executes the query and caches the result. + * If the specified {@link CacheDir} is empty, the query is executed and the result is cached. + * If the specified {@link CacheDir} is not empty, the pointed content is returned. + * + * @param cacheDir {@link CacheDir} to use for caching + * @param query query to execute + * @param ttl time to live for the cache + * @returns content from cache or query result + */ + private async getFromCacheOrExecuteAndCache( + cacheDir: CacheDir, + query: postgres.PendingQuery, + ttl: number, + ): Promise { + const { key, field } = cacheDir; + const cached = await this.cacheService.get(cacheDir); + if (cached != null) { + this.loggingService.debug({ type: 'cache_hit', key, field }); + return JSON.parse(cached); + } + this.loggingService.debug({ type: 'cache_miss', key, field }); + + // log & hide database errors + const result = await query.catch((e) => { + this.loggingService.error(asError(e).message); + throw new InternalServerErrorException(); + }); + + if (result.count > 0) { + await this.cacheService.set(cacheDir, JSON.stringify(result), ttl); + } + return result; + } } diff --git a/src/datasources/cache/cache.router.ts b/src/datasources/cache/cache.router.ts index b44cae54ae..9a68f59c42 100644 --- a/src/datasources/cache/cache.router.ts +++ b/src/datasources/cache/cache.router.ts @@ -1,6 +1,9 @@ import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity'; export class CacheRouter { + private static readonly ACCOUNT_KEY = 'account'; + private static readonly ACCOUNT_DATA_SETTINGS_KEY = 'account_data_settings'; + private static readonly ACCOUNT_DATA_TYPES_KEY = 'account_data_types'; private static readonly ALL_TRANSACTIONS_KEY = 'all_transactions'; private static readonly AUTH_NONCE_KEY = 'auth_nonce'; private static readonly BACKBONE_KEY = 'backbone'; @@ -490,4 +493,19 @@ export class CacheRouter { static getPriceFiatCodesCacheDir(): CacheDir { return new CacheDir(CacheRouter.SAFE_FIAT_CODES_KEY, ''); } + + static getAccountCacheDir(address: `0x${string}`): CacheDir { + return new CacheDir(`${CacheRouter.ACCOUNT_KEY}_${address}`, ''); + } + + static getAccountDataTypesCacheDir(): CacheDir { + return new CacheDir(CacheRouter.ACCOUNT_DATA_TYPES_KEY, ''); + } + + static getAccountDataSettingsCacheDir(address: `0x${string}`): CacheDir { + return new CacheDir( + `${CacheRouter.ACCOUNT_DATA_SETTINGS_KEY}_${address}`, + '', + ); + } } diff --git a/src/datasources/db/entities/row.entity.ts b/src/datasources/db/entities/row.entity.ts index 3fb6cdbe49..072ec04e12 100644 --- a/src/datasources/db/entities/row.entity.ts +++ b/src/datasources/db/entities/row.entity.ts @@ -9,6 +9,6 @@ export type Row = z.infer; */ export const RowSchema = z.object({ id: z.number().int(), - created_at: z.date(), - updated_at: z.date(), + created_at: z.coerce.date(), + updated_at: z.coerce.date(), }); diff --git a/src/domain/accounts/entities/account-data-type.entity.spec.ts b/src/domain/accounts/entities/account-data-type.entity.spec.ts index ff660b1301..746d608bb4 100644 --- a/src/domain/accounts/entities/account-data-type.entity.spec.ts +++ b/src/domain/accounts/entities/account-data-type.entity.spec.ts @@ -82,30 +82,26 @@ describe('AccountDataTypeSchema', () => { { code: 'invalid_type', expected: 'number', - message: 'Required', - path: ['id'], received: 'undefined', + path: ['id'], + message: 'Required', }, { - code: 'invalid_type', - expected: 'date', - message: 'Required', + code: 'invalid_date', path: ['created_at'], - received: 'undefined', + message: 'Invalid date', }, { - code: 'invalid_type', - expected: 'date', - message: 'Required', + code: 'invalid_date', path: ['updated_at'], - received: 'undefined', + message: 'Invalid date', }, { code: 'invalid_type', expected: 'string', - message: 'Required', - path: ['name'], received: 'undefined', + path: ['name'], + message: 'Required', }, ]); }); diff --git a/src/domain/accounts/entities/account.entity.spec.ts b/src/domain/accounts/entities/account.entity.spec.ts index 71ddcca9f4..c7f6e72458 100644 --- a/src/domain/accounts/entities/account.entity.spec.ts +++ b/src/domain/accounts/entities/account.entity.spec.ts @@ -96,18 +96,14 @@ describe('AccountSchema', () => { received: 'undefined', }, { - code: 'invalid_type', - expected: 'date', - message: 'Required', + code: 'invalid_date', + message: 'Invalid date', path: ['created_at'], - received: 'undefined', }, { - code: 'invalid_type', - expected: 'date', - message: 'Required', + code: 'invalid_date', + message: 'Invalid date', path: ['updated_at'], - received: 'undefined', }, { code: 'invalid_type', diff --git a/src/domain/accounts/entities/group.entity.spec.ts b/src/domain/accounts/entities/group.entity.spec.ts index ccaa0118e8..c62bfdcd1d 100644 --- a/src/domain/accounts/entities/group.entity.spec.ts +++ b/src/domain/accounts/entities/group.entity.spec.ts @@ -61,18 +61,14 @@ describe('GroupSchema', () => { received: 'undefined', }, { - code: 'invalid_type', - expected: 'date', - message: 'Required', + code: 'invalid_date', + message: 'Invalid date', path: ['created_at'], - received: 'undefined', }, { - code: 'invalid_type', - expected: 'date', - message: 'Required', + code: 'invalid_date', + message: 'Invalid date', path: ['updated_at'], - received: 'undefined', }, ]); }); From fa8bb8b6519e3aa90a0aeb0e4e0eeddd3bd469ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Wed, 17 Jul 2024 16:57:27 +0200 Subject: [PATCH 188/207] Change AccountDataSettings and UpsertAccountDataSettingsDto entities (#1771) - Adds `dataTypeId` to `AccountDataSetting`. - Changes `UpsertAccountDataSettingDto.id` to `UpsertAccountDataSettingDto.dataTypeId`. - Both `AccountDataSetting.name` and `AccountDataSetting.description` were removed. --- .../accounts/accounts.datasource.spec.ts | 22 +++++++++---------- .../accounts/accounts.datasource.ts | 4 ++-- ...ccount-data-settings.dto.entity.builder.ts | 2 +- ...upsert-account-data-settings.dto.entity.ts | 4 ++-- src/routes/accounts/accounts.service.ts | 3 +-- .../entities/account-data-setting.entity.ts | 11 ++++------ ...upsert-account-data-settings.dto.entity.ts | 2 +- 7 files changed, 22 insertions(+), 26 deletions(-) diff --git a/src/datasources/accounts/accounts.datasource.spec.ts b/src/datasources/accounts/accounts.datasource.spec.ts index 9987b7abe9..c55c3b125d 100644 --- a/src/datasources/accounts/accounts.datasource.spec.ts +++ b/src/datasources/accounts/accounts.datasource.spec.ts @@ -443,7 +443,7 @@ describe('AccountsDatasource tests', () => { const insertedDataTypes = await sql`INSERT INTO account_data_types ${sql(accountDataTypes, 'name', 'is_active')} returning *`; const accountDataSettings = insertedDataTypes.map((dataType) => ({ - id: dataType.id, + dataTypeId: dataType.id, enabled: faker.datatype.boolean(), })); const upsertAccountDataSettingsDto = upsertAccountDataSettingsDtoBuilder() @@ -457,7 +457,7 @@ describe('AccountsDatasource tests', () => { const expected = accountDataSettings.map((accountDataSetting) => ({ account_id: account.id, - account_data_type_id: accountDataSetting.id, + account_data_type_id: accountDataSetting.dataTypeId, enabled: accountDataSetting.enabled, created_at: expect.any(Date), updated_at: expect.any(Date), @@ -476,7 +476,7 @@ describe('AccountsDatasource tests', () => { const insertedDataTypes = await sql`INSERT INTO account_data_types ${sql(accountDataTypes, 'name', 'is_active')} returning *`; const accountDataSettings = insertedDataTypes.map((dataType) => ({ - id: dataType.id, + dataTypeId: dataType.id, enabled: faker.datatype.boolean(), })); const upsertAccountDataSettingsDto = upsertAccountDataSettingsDtoBuilder() @@ -496,7 +496,7 @@ describe('AccountsDatasource tests', () => { accountDataSettings.map((accountDataSetting) => expect.objectContaining({ account_id: account.id, - account_data_type_id: accountDataSetting.id, + account_data_type_id: accountDataSetting.dataTypeId, enabled: accountDataSetting.enabled, }), ), @@ -514,7 +514,7 @@ describe('AccountsDatasource tests', () => { const insertedDataTypes = await sql`INSERT INTO account_data_types ${sql(accountDataTypes, 'name', 'is_active')} returning *`; const accountDataSettings = insertedDataTypes.map((dataType) => ({ - id: dataType.id, + dataTypeId: dataType.id, enabled: faker.datatype.boolean(), })); const upsertAccountDataSettingsDto = upsertAccountDataSettingsDtoBuilder() @@ -530,7 +530,7 @@ describe('AccountsDatasource tests', () => { expect.arrayContaining( accountDataSettings.map((accountDataSetting) => ({ account_id: account.id, - account_data_type_id: accountDataSetting.id, + account_data_type_id: accountDataSetting.dataTypeId, enabled: accountDataSetting.enabled, created_at: expect.any(Date), updated_at: expect.any(Date), @@ -556,7 +556,7 @@ describe('AccountsDatasource tests', () => { expect.arrayContaining( accountDataSettings.map((accountDataSetting) => ({ account_id: account.id, - account_data_type_id: accountDataSetting.id, + account_data_type_id: accountDataSetting.dataTypeId, enabled: !accountDataSetting.enabled, // 'enabled' row was updated created_at: expect.any(Date), updated_at: expect.any(Date), @@ -574,7 +574,7 @@ describe('AccountsDatasource tests', () => { const insertedDataTypes = await sql`INSERT INTO account_data_types ${sql(accountDataTypes, 'name', 'is_active')} returning *`; const accountDataSettings = insertedDataTypes.map((dataType) => ({ - id: dataType.id, + dataTypeId: dataType.id, enabled: faker.datatype.boolean(), })); const upsertAccountDataSettingsDto = upsertAccountDataSettingsDtoBuilder() @@ -596,14 +596,14 @@ describe('AccountsDatasource tests', () => { const insertedDataTypes = await sql`INSERT INTO account_data_types ${sql(accountDataTypes, 'name', 'is_active')} returning *`; const accountDataSettings = insertedDataTypes.map((dataType) => ({ - id: dataType.id, + dataTypeId: dataType.id, enabled: faker.datatype.boolean(), })); const upsertAccountDataSettingsDto = upsertAccountDataSettingsDtoBuilder() .with('accountDataSettings', accountDataSettings) .build(); upsertAccountDataSettingsDto.accountDataSettings.push({ - id: faker.string.numeric(5), + dataTypeId: faker.string.numeric(5), enabled: faker.datatype.boolean(), }); @@ -622,7 +622,7 @@ describe('AccountsDatasource tests', () => { const insertedDataTypes = await sql`INSERT INTO account_data_types ${sql(accountDataTypes, 'name', 'is_active')} returning *`; const accountDataSettings = insertedDataTypes.map((dataType) => ({ - id: dataType.id, + dataTypeId: dataType.id, enabled: faker.datatype.boolean(), })); const upsertAccountDataSettingsDto = upsertAccountDataSettingsDtoBuilder() diff --git a/src/datasources/accounts/accounts.datasource.ts b/src/datasources/accounts/accounts.datasource.ts index 776d474a60..a2d275329b 100644 --- a/src/datasources/accounts/accounts.datasource.ts +++ b/src/datasources/accounts/accounts.datasource.ts @@ -147,7 +147,7 @@ export class AccountsDatasource implements IAccountsDatasource, OnModuleInit { accountDataSettings.map(async (accountDataSetting) => { return sql` INSERT INTO account_data_settings (account_id, account_data_type_id, enabled) - VALUES (${account.id}, ${accountDataSetting.id}, ${accountDataSetting.enabled}) + VALUES (${account.id}, ${accountDataSetting.dataTypeId}, ${accountDataSetting.enabled}) ON CONFLICT (account_id, account_data_type_id) DO UPDATE SET enabled = EXCLUDED.enabled `.catch((e) => { throw new UnprocessableEntityException( @@ -178,7 +178,7 @@ export class AccountsDatasource implements IAccountsDatasource, OnModuleInit { .map((ads) => ads.id); if ( !accountDataSettings.every((ads) => - activeDataTypeIds.includes(Number(ads.id)), + activeDataTypeIds.includes(Number(ads.dataTypeId)), ) ) { throw new UnprocessableEntityException( diff --git a/src/domain/accounts/entities/__tests__/upsert-account-data-settings.dto.entity.builder.ts b/src/domain/accounts/entities/__tests__/upsert-account-data-settings.dto.entity.builder.ts index d8f21adef9..e160613cf6 100644 --- a/src/domain/accounts/entities/__tests__/upsert-account-data-settings.dto.entity.builder.ts +++ b/src/domain/accounts/entities/__tests__/upsert-account-data-settings.dto.entity.builder.ts @@ -6,7 +6,7 @@ export function upsertAccountDataSettingsDtoBuilder(): IBuilder().with( 'accountDataSettings', Array.from({ length: faker.number.int({ min: 1, max: 10 }) }, () => ({ - id: faker.string.numeric(), + dataTypeId: faker.string.numeric(), enabled: faker.datatype.boolean(), })), ); diff --git a/src/domain/accounts/entities/upsert-account-data-settings.dto.entity.ts b/src/domain/accounts/entities/upsert-account-data-settings.dto.entity.ts index 446e2e872c..a446a0856a 100644 --- a/src/domain/accounts/entities/upsert-account-data-settings.dto.entity.ts +++ b/src/domain/accounts/entities/upsert-account-data-settings.dto.entity.ts @@ -5,7 +5,7 @@ export class UpsertAccountDataSettingsDto implements z.infer { accountDataSettings: { - id: string; + dataTypeId: string; enabled: boolean; }[]; @@ -15,7 +15,7 @@ export class UpsertAccountDataSettingsDto } export const UpsertAccountDataSettingDtoSchema = z.object({ - id: NumericStringSchema, + dataTypeId: NumericStringSchema, enabled: z.boolean(), }); diff --git a/src/routes/accounts/accounts.service.ts b/src/routes/accounts/accounts.service.ts index 7b65d52408..deb317071d 100644 --- a/src/routes/accounts/accounts.service.ts +++ b/src/routes/accounts/accounts.service.ts @@ -125,8 +125,7 @@ export class AccountsService { } return { - name: dataType.name, - description: dataType.description, + dataTypeId: dataType.id.toString(), enabled: domainAccountDataSetting.enabled, }; } diff --git a/src/routes/accounts/entities/account-data-setting.entity.ts b/src/routes/accounts/entities/account-data-setting.entity.ts index 80fbfa8991..0faba12095 100644 --- a/src/routes/accounts/entities/account-data-setting.entity.ts +++ b/src/routes/accounts/entities/account-data-setting.entity.ts @@ -1,16 +1,13 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty } from '@nestjs/swagger'; export class AccountDataSetting { @ApiProperty() - name: string; - @ApiPropertyOptional({ type: String, nullable: true }) - description: string | null; + dataTypeId: string; @ApiProperty() enabled: boolean; - constructor(name: string, description: string | null, enabled: boolean) { - this.name = name; - this.description = description; + constructor(dataTypeId: string, enabled: boolean) { + this.dataTypeId = dataTypeId; this.enabled = enabled; } } diff --git a/src/routes/accounts/entities/upsert-account-data-settings.dto.entity.ts b/src/routes/accounts/entities/upsert-account-data-settings.dto.entity.ts index 266294b499..d107e6895c 100644 --- a/src/routes/accounts/entities/upsert-account-data-settings.dto.entity.ts +++ b/src/routes/accounts/entities/upsert-account-data-settings.dto.entity.ts @@ -3,7 +3,7 @@ import { ApiProperty } from '@nestjs/swagger'; class UpsertAccountDataSettingDto { @ApiProperty() - id!: string; // A 'numeric string' type is used to align with other API endpoints + dataTypeId!: string; // A 'numeric string' type is used to align with other API endpoints @ApiProperty() enabled!: boolean; } From dd4ce9647ff302b46fe3825a28490250dbe3d44f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Thu, 18 Jul 2024 11:13:06 +0200 Subject: [PATCH 189/207] Rename Account.accountId to Account.id on the route-level entity (#1772) Rename `Account.accountId` to `Account.id` on the route-level `Account` entity. --- src/routes/accounts/accounts.controller.spec.ts | 4 ++-- src/routes/accounts/entities/account.entity.ts | 10 +++------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/routes/accounts/accounts.controller.spec.ts b/src/routes/accounts/accounts.controller.spec.ts index da2727b783..9a74f6e58a 100644 --- a/src/routes/accounts/accounts.controller.spec.ts +++ b/src/routes/accounts/accounts.controller.spec.ts @@ -256,7 +256,7 @@ describe('AccountsController', () => { const account = accountBuilder().with('group_id', null).build(); accountDataSource.getAccount.mockResolvedValue(account); const expected: Account = { - accountId: account.id.toString(), + id: account.id.toString(), groupId: null, address: account.address, }; @@ -284,7 +284,7 @@ describe('AccountsController', () => { const account = accountBuilder().with('group_id', groupId).build(); accountDataSource.getAccount.mockResolvedValue(account); const expected: Account = { - accountId: account.id.toString(), + id: account.id.toString(), groupId: groupId.toString(), address: account.address, }; diff --git a/src/routes/accounts/entities/account.entity.ts b/src/routes/accounts/entities/account.entity.ts index 6267ba5486..ed28d86419 100644 --- a/src/routes/accounts/entities/account.entity.ts +++ b/src/routes/accounts/entities/account.entity.ts @@ -2,18 +2,14 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class Account { @ApiProperty() - accountId: string; + id: string; @ApiPropertyOptional({ type: String, nullable: true }) groupId: string | null; @ApiProperty() address: `0x${string}`; - constructor( - accountId: string, - groupId: string | null, - address: `0x${string}`, - ) { - this.accountId = accountId; + constructor(id: string, groupId: string | null, address: `0x${string}`) { + this.id = id; this.groupId = groupId; this.address = address; } From 66dd09e0fe664b36f5e794f1147265c09a78cca4 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 18 Jul 2024 14:19:13 +0200 Subject: [PATCH 190/207] Add `IPushNotificationsApi` datasource (#1767) Adds a new `IPushNotificationsApi` datasource, initially implemented by FIrebase (Cloud Messaging). ## Changes - Add new `IPushNotificationsApi` interface - Implement above interface in `FirebaseCloudMessagingApiService` - Add relative push notification configuration --- .env.sample | 10 ++ src/config/configuration.module.ts | 5 + src/config/configuration.validator.spec.ts | 17 ++ .../entities/__tests__/configuration.ts | 8 + src/config/entities/configuration.ts | 12 ++ src/datasources/cache/cache.router.ts | 5 + .../entities/firebase-notification.entity.ts | 5 + .../firebase-cloud-messaging-api.service.ts | 149 ++++++++++++++++++ .../push-notifications-api.module.ts | 18 +++ .../push-notifications-api.interface.ts | 5 + 10 files changed, 234 insertions(+) create mode 100644 src/datasources/push-notifications-api/entities/firebase-notification.entity.ts create mode 100644 src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.ts create mode 100644 src/datasources/push-notifications-api/push-notifications-api.module.ts create mode 100644 src/domain/interfaces/push-notifications-api.interface.ts diff --git a/.env.sample b/.env.sample index 7f18946c19..ae8361fac0 100644 --- a/.env.sample +++ b/.env.sample @@ -35,6 +35,16 @@ # The API Key to be used. If none is set, balances cannot be retrieved using this provider. #ZERION_API_KEY= +# Push Notifications Provider - Firebase Cloud Messaging +# Firebase API URL +# (default=https://fcm.googleapis.com/v1/projects) +# PUSH_NOTIFICATIONS_API_BASE_URI= +# Firebase project +# PUSH_NOTIFICATIONS_API_PROJECT= +# Firebase service account details for authenticating with Google +# PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_CLIENT_EMAIL= +# PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY= + # Relay Provider # The relay provider to be used. # (default='https://api.gelato.digital') diff --git a/src/config/configuration.module.ts b/src/config/configuration.module.ts index 6fe4c29914..21ba06a533 100644 --- a/src/config/configuration.module.ts +++ b/src/config/configuration.module.ts @@ -40,6 +40,7 @@ export const RootConfigurationSchema = z.object({ LOG_LEVEL: z .enum(['error', 'warn', 'info', 'http', 'verbose', 'debug', 'silly']) .optional(), + // TODO: Reassess EMAIL_ keys after email integration EMAIL_API_APPLICATION_CODE: z.string(), EMAIL_API_FROM_EMAIL: z.string().email(), EMAIL_API_KEY: z.string(), @@ -47,6 +48,10 @@ export const RootConfigurationSchema = z.object({ EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: z.string(), EMAIL_TEMPLATE_VERIFICATION_CODE: z.string(), INFURA_API_KEY: z.string(), + PUSH_NOTIFICATIONS_API_BASE_URI: z.string().url(), + PUSH_NOTIFICATIONS_API_PROJECT: z.string(), + PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_CLIENT_EMAIL: z.string().email(), + PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY: z.string(), RELAY_PROVIDER_API_KEY_ARBITRUM_ONE: z.string(), RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN: z.string(), RELAY_PROVIDER_API_KEY_SEPOLIA: z.string(), diff --git a/src/config/configuration.validator.spec.ts b/src/config/configuration.validator.spec.ts index 3cdeb59a19..6db4f6808b 100644 --- a/src/config/configuration.validator.spec.ts +++ b/src/config/configuration.validator.spec.ts @@ -19,6 +19,11 @@ describe('Configuration validator', () => { EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: faker.string.alphanumeric(), EMAIL_TEMPLATE_VERIFICATION_CODE: faker.string.alphanumeric(), INFURA_API_KEY: faker.string.uuid(), + PUSH_NOTIFICATIONS_API_BASE_URI: faker.internet.url({ appendSlash: false }), + PUSH_NOTIFICATIONS_API_PROJECT: faker.word.noun(), + PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_CLIENT_EMAIL: faker.internet.email(), + PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY: + faker.string.alphanumeric(), RELAY_PROVIDER_API_KEY_ARBITRUM_ONE: faker.string.uuid(), RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN: faker.string.uuid(), RELAY_PROVIDER_API_KEY_SEPOLIA: faker.string.uuid(), @@ -49,6 +54,10 @@ describe('Configuration validator', () => { { key: 'EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX' }, { key: 'EMAIL_TEMPLATE_VERIFICATION_CODE' }, { key: 'INFURA_API_KEY' }, + { key: 'PUSH_NOTIFICATIONS_API_BASE_URI' }, + { key: 'PUSH_NOTIFICATIONS_API_PROJECT' }, + { key: 'PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_CLIENT_EMAIL' }, + { key: 'PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY' }, { key: 'RELAY_PROVIDER_API_KEY_ARBITRUM_ONE' }, { key: 'RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN' }, { key: 'RELAY_PROVIDER_API_KEY_SEPOLIA' }, @@ -82,6 +91,14 @@ describe('Configuration validator', () => { EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: faker.string.alphanumeric(), EMAIL_TEMPLATE_VERIFICATION_CODE: faker.string.alphanumeric(), INFURA_API_KEY: faker.string.uuid(), + PUSH_NOTIFICATIONS_API_BASE_URI: faker.internet.url({ + appendSlash: false, + }), + PUSH_NOTIFICATIONS_API_PROJECT: faker.word.noun(), + PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_CLIENT_EMAIL: + faker.internet.email(), + PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY: + faker.string.alphanumeric(), RELAY_PROVIDER_API_KEY_ARBITRUM_ONE: faker.string.uuid(), RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN: faker.string.uuid(), RELAY_PROVIDER_API_KEY_SEPOLIA: faker.string.uuid(), diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index a93f48565d..ce800413db 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -146,6 +146,14 @@ export default (): ReturnType => ({ owners: { ownersTtlSeconds: faker.number.int(), }, + pushNotifications: { + baseUri: faker.internet.url({ appendSlash: false }), + project: faker.word.noun(), + serviceAccount: { + clientEmail: faker.internet.email(), + privateKey: faker.string.alphanumeric(), + }, + }, redis: { host: process.env.REDIS_HOST || 'localhost', port: process.env.REDIS_PORT || '6379', diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 21037e8ffc..317142a06c 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -216,6 +216,18 @@ export default () => ({ maxOverviews: parseInt(process.env.MAX_SAFE_OVERVIEWS ?? `${10}`), }, }, + pushNotifications: { + baseUri: + process.env.PUSH_NOTIFICATIONS_API_BASE_URI || + 'https://fcm.googleapis.com/v1/projects', + project: process.env.PUSH_NOTIFICATIONS_API_PROJECT, + serviceAccount: { + clientEmail: + process.env.PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_CLIENT_EMAIL, + privateKey: + process.env.PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY, + }, + }, redis: { host: process.env.REDIS_HOST || 'localhost', port: process.env.REDIS_PORT || '6379', diff --git a/src/datasources/cache/cache.router.ts b/src/datasources/cache/cache.router.ts index 9a68f59c42..f3716d85ae 100644 --- a/src/datasources/cache/cache.router.ts +++ b/src/datasources/cache/cache.router.ts @@ -12,6 +12,7 @@ export class CacheRouter { private static readonly CONTRACT_KEY = 'contract'; private static readonly CREATION_TRANSACTION_KEY = 'creation_transaction'; private static readonly DELEGATES_KEY = 'delegates'; + private static readonly FIREBASE_OAUTH2_TOKEN_KEY = 'firebase_oauth2_token'; private static readonly INCOMING_TRANSFERS_KEY = 'incoming_transfers'; private static readonly MESSAGE_KEY = 'message'; private static readonly MESSAGES_KEY = 'messages'; @@ -188,6 +189,10 @@ export class CacheRouter { ); } + static getFirebaseOAuth2TokenCacheDir(): CacheDir { + return new CacheDir(CacheRouter.FIREBASE_OAUTH2_TOKEN_KEY, ''); + } + static getTransferCacheDir(args: { chainId: string; transferId: string; diff --git a/src/datasources/push-notifications-api/entities/firebase-notification.entity.ts b/src/datasources/push-notifications-api/entities/firebase-notification.entity.ts new file mode 100644 index 0000000000..13437d7a96 --- /dev/null +++ b/src/datasources/push-notifications-api/entities/firebase-notification.entity.ts @@ -0,0 +1,5 @@ +export type FirebaseNotification> = { + title: string; + body: string; + data: T; +}; diff --git a/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.ts b/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.ts new file mode 100644 index 0000000000..e573dafde6 --- /dev/null +++ b/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.ts @@ -0,0 +1,149 @@ +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { CacheRouter } from '@/datasources/cache/cache.router'; +import { + CacheService, + ICacheService, +} from '@/datasources/cache/cache.service.interface'; +import { HttpErrorFactory } from '@/datasources/errors/http-error-factory'; +import { + NetworkService, + INetworkService, +} from '@/datasources/network/network.service.interface'; +import { IPushNotificationsApi } from '@/domain/interfaces/push-notifications-api.interface'; +import { Inject, Injectable } from '@nestjs/common'; +import { FirebaseNotification } from '@/datasources/push-notifications-api/entities/firebase-notification.entity'; + +// TODO: Refactor to use JwtService +import jwt from 'jsonwebtoken'; + +@Injectable() +export class FirebaseCloudMessagingApiService implements IPushNotificationsApi { + private static readonly OAuth2TokenUrl = + 'https://oauth2.googleapis.com/token'; + private static readonly OAuth2TokenTtlBufferInSeconds = 5; + private static readonly Scope = + 'https://www.googleapis.com/auth/firebase.messaging'; + + private readonly baseUrl: string; + private readonly project: string; + private readonly clientEmail: string; + private readonly privateKey: string; + + constructor( + @Inject(NetworkService) + private readonly networkService: INetworkService, + @Inject(IConfigurationService) + private readonly configurationService: IConfigurationService, + @Inject(CacheService) + private readonly cacheService: ICacheService, + private readonly httpErrorFactory: HttpErrorFactory, + ) { + this.baseUrl = this.configurationService.getOrThrow( + 'pushNotifications.baseUri', + ); + this.project = this.configurationService.getOrThrow( + 'pushNotifications.project', + ); + // Service account credentials are used for OAuth2 assertion + this.clientEmail = this.configurationService.getOrThrow( + 'pushNotifications.serviceAccount.clientEmail', + ); + this.privateKey = this.configurationService.getOrThrow( + 'pushNotifications.serviceAccount.privateKey', + ); + } + + /** + * Enqueues a notification to be sent to a device with given FCM token. + * + * @param fcmToken - device's FCM token + * @param notification - notification payload + */ + async enqueueNotification>( + fcmToken: string, + notification: FirebaseNotification, + ): Promise { + const url = `${this.baseUrl}/${this.project}/messages:send`; + try { + const accessToken = await this.getOauth2Token(); + await this.networkService.post({ + url, + data: { + message: { + token: fcmToken, + notification, + }, + }, + networkRequest: { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + }); + } catch (error) { + /** + * TODO: Error handling based on `error.details[i].reason`, e.g. + * - expired OAuth2 token + * - stale FCM token + * - don't expose the error to clients, logging on domain level + */ + throw this.httpErrorFactory.from(error); + } + } + + /** + * Retrieves and caches OAuth2 token for Firebase Cloud Messaging API. + * + * @returns - OAuth2 token + */ + private async getOauth2Token(): Promise { + const cacheDir = CacheRouter.getFirebaseOAuth2TokenCacheDir(); + const cachedToken = await this.cacheService.get(cacheDir); + + if (cachedToken) { + return cachedToken; + } + + const { data } = await this.networkService.post<{ + access_token: string; + expires_in: number; + token_type: string; + }>({ + url: FirebaseCloudMessagingApiService.OAuth2TokenUrl, + data: { + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: this.getAssertion(), + }, + }); + + // Token cached according to issuance + await this.cacheService.set( + cacheDir, + data.access_token, + // Buffer ensures token is not cached beyond expiration if caching took time + data.expires_in - + FirebaseCloudMessagingApiService.OAuth2TokenTtlBufferInSeconds, + ); + + return data.access_token; + } + + /** + * Generates a signed JWT assertion for OAuth2 token request. + * + * @returns - signed JWT assertion + */ + private getAssertion(): string { + const issuedAt = Math.floor(Date.now() / 1_000); + const payload = { + iss: this.clientEmail, + scope: FirebaseCloudMessagingApiService.Scope, + aud: FirebaseCloudMessagingApiService.OAuth2TokenUrl, + iat: issuedAt, + // Maximum expiration time is 1 hour + exp: issuedAt + 60 * 60, + }; + + return jwt.sign(payload, this.privateKey, { algorithm: 'RS256' }); + } +} diff --git a/src/datasources/push-notifications-api/push-notifications-api.module.ts b/src/datasources/push-notifications-api/push-notifications-api.module.ts new file mode 100644 index 0000000000..dfa6e11e26 --- /dev/null +++ b/src/datasources/push-notifications-api/push-notifications-api.module.ts @@ -0,0 +1,18 @@ +import { CacheModule } from '@/datasources/cache/cache.module'; +import { HttpErrorFactory } from '@/datasources/errors/http-error-factory'; +import { FirebaseCloudMessagingApiService } from '@/datasources/push-notifications-api/firebase-cloud-messaging-api.service'; +import { IPushNotificationsApi } from '@/domain/interfaces/push-notifications-api.interface'; +import { Module } from '@nestjs/common'; + +@Module({ + imports: [CacheModule], + providers: [ + HttpErrorFactory, + { + provide: IPushNotificationsApi, + useClass: FirebaseCloudMessagingApiService, + }, + ], + exports: [IPushNotificationsApi], +}) +export class PushNotificationsApiModule {} diff --git a/src/domain/interfaces/push-notifications-api.interface.ts b/src/domain/interfaces/push-notifications-api.interface.ts new file mode 100644 index 0000000000..3877cf0819 --- /dev/null +++ b/src/domain/interfaces/push-notifications-api.interface.ts @@ -0,0 +1,5 @@ +export const IPushNotificationsApi = Symbol('IPushNotificationsApi'); + +export interface IPushNotificationsApi { + enqueueNotification(token: string, notification: unknown): Promise; +} From 092b2828876e3a419bc5ea4dd9b8d1e561da9583 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 18 Jul 2024 16:26:11 +0200 Subject: [PATCH 191/207] Refactor `JwtService` to expose issuer/secret (#1768) Refactors `JwtService` to allow manually setting the issuer/secret when signing a token: - Expose the option to specify issuer/secret to `JwtClient`, otherwise defaulting to env. vars. in `JwtService` - Remove `options` that override claims on token, instead opting for their spec, e.g. `iat`, `nbf`, `exp` (instead of `issuedAt`, `notBefore`, `expiresIn`) - Accept/return dates for the all spec. specific dateclaims - Use `JwtService` in `FirebaseCloudMessagingApiService` instead of `jsonwebtoken` - Update tests accordingly --- .../jwt-claims.entity.schema.spec.ts | 16 ++++ src/datasources/jwt/jwt-claims.entity.ts | 11 ++- src/datasources/jwt/jwt.module.ts | 57 ++++++------ src/datasources/jwt/jwt.service.interface.ts | 28 ++++-- src/datasources/jwt/jwt.service.spec.ts | 89 +++++++++++++++---- src/datasources/jwt/jwt.service.ts | 51 +++++++++-- .../firebase-cloud-messaging-api.service.ts | 18 ++-- src/domain/auth/auth.repository.interface.ts | 4 +- src/domain/auth/auth.repository.ts | 10 ++- src/domain/common/utils/time.ts | 4 + .../accounts/accounts.controller.spec.ts | 52 +++++++---- src/routes/auth/auth.controller.spec.ts | 24 ++--- src/routes/auth/auth.controller.ts | 2 +- src/routes/auth/auth.service.ts | 5 +- src/routes/auth/guards/auth.guard.spec.ts | 42 +++++---- .../recovery/recovery.controller.spec.ts | 23 ++--- 16 files changed, 298 insertions(+), 138 deletions(-) diff --git a/src/datasources/jwt/__tests__/jwt-claims.entity.schema.spec.ts b/src/datasources/jwt/__tests__/jwt-claims.entity.schema.spec.ts index dd8ea9d0a7..43fc7b1bc2 100644 --- a/src/datasources/jwt/__tests__/jwt-claims.entity.schema.spec.ts +++ b/src/datasources/jwt/__tests__/jwt-claims.entity.schema.spec.ts @@ -1,4 +1,5 @@ import { JwtClaimsSchema } from '@/datasources/jwt/jwt-claims.entity'; +import { toSecondsTimestamp } from '@/domain/common/utils/time'; import { faker } from '@faker-js/faker'; describe('JwtClaimsSchema', () => { @@ -23,4 +24,19 @@ describe('JwtClaimsSchema', () => { expect(result.success).toBe(true); }); + + it.each(['exp' as const, 'nbf' as const, 'iat' as const])( + 'should transform %s seconds to Date', + (field) => { + // As claim is in second, coerced date does not have ms + const date = new Date(faker.date.recent().setMilliseconds(0)); + const seconds = toSecondsTimestamp(date); + + const result = JwtClaimsSchema.safeParse({ + [field]: seconds, + }); + + expect(result.success && result.data[field]).toStrictEqual(date); + }, + ); }); diff --git a/src/datasources/jwt/jwt-claims.entity.ts b/src/datasources/jwt/jwt-claims.entity.ts index da8d43bc1e..59296c8934 100644 --- a/src/datasources/jwt/jwt-claims.entity.ts +++ b/src/datasources/jwt/jwt-claims.entity.ts @@ -2,14 +2,19 @@ import { z } from 'zod'; export type JwtClaims = z.infer; +function maybeSecondsToDate(seconds?: number): Date | undefined { + return seconds ? new Date(seconds * 1_000) : undefined; +} + // Standard claims https://datatracker.ietf.org/doc/html/rfc7519#section-4.1 export const JwtClaimsSchema = z.object({ iss: z.string().optional(), sub: z.string().optional(), aud: z.union([z.string(), z.array(z.string())]).optional(), - exp: z.number().optional(), - nbf: z.number().optional(), - iat: z.number().optional(), + // All dates are second-based NumericDates + exp: z.number().optional().transform(maybeSecondsToDate), + nbf: z.number().optional().transform(maybeSecondsToDate), + iat: z.number().optional().transform(maybeSecondsToDate), jti: z.string().optional(), }); diff --git a/src/datasources/jwt/jwt.module.ts b/src/datasources/jwt/jwt.module.ts index f656138530..91fb25a8fc 100644 --- a/src/datasources/jwt/jwt.module.ts +++ b/src/datasources/jwt/jwt.module.ts @@ -2,52 +2,54 @@ import jwt from 'jsonwebtoken'; import { Module } from '@nestjs/common'; import { JwtService } from '@/datasources/jwt/jwt.service'; import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; -import { IConfigurationService } from '@/config/configuration.service.interface'; +import { toSecondsTimestamp } from '@/domain/common/utils/time'; import { JwtPayloadWithClaims } from '@/datasources/jwt/jwt-claims.entity'; import { JWT_CONFIGURATION_MODULE } from '@/datasources/jwt/configuration/jwt.configuration.module'; // Use inferred type // eslint-disable-next-line @typescript-eslint/explicit-function-return-type -function jwtClientFactory(configurationService: IConfigurationService) { - const issuer = configurationService.getOrThrow('jwt.issuer'); - const secret = configurationService.getOrThrow('jwt.secret'); - +function jwtClientFactory() { return { - sign: ( + sign: < + T extends object & { + iat?: Date; + exp?: Date; + nbf?: Date; + }, + >( payload: T, - options: { - issuedAt?: number; - expiresIn?: number; - notBefore?: number; - } = {}, + options: { secretOrPrivateKey: string }, ): string => { - const { issuedAt = Date.now() / 1_000, ...claims } = options; + // All date-based claims should be second-based NumericDates + const { exp, iat, nbf, ...rest } = payload; return jwt.sign( { - // iat (Issued At) claim is set in payload - // @see https://github.com/auth0/node-jsonwebtoken/blob/bc28861f1fa981ed9c009e29c044a19760a0b128/sign.js#L185 - iat: issuedAt, - ...payload, - }, - secret, - { - ...claims, - issuer, + ...(exp && { exp: toSecondsTimestamp(exp) }), + ...(iat && { iat: toSecondsTimestamp(iat) }), + ...(nbf && { nbf: toSecondsTimestamp(nbf) }), + ...rest, }, + options.secretOrPrivateKey, ); }, - verify: (token: string): T => { - return jwt.verify(token, secret, { - issuer, + verify: ( + token: string, + options: { issuer: string; secretOrPrivateKey: string }, + ): T => { + return jwt.verify(token, options.secretOrPrivateKey, { + issuer: options.issuer, // Return only payload without claims, e.g. no exp, nbf, etc. complete: false, }) as T; }, - decode: (token: string): JwtPayloadWithClaims => { + decode: ( + token: string, + options: { issuer: string; secretOrPrivateKey: string }, + ): JwtPayloadWithClaims => { // Client has `decode` method but we also want to verify the signature - const { payload } = jwt.verify(token, secret, { - issuer, + const { payload } = jwt.verify(token, options.secretOrPrivateKey, { + issuer: options.issuer, // Return headers, payload (with claims) and signature complete: true, }); @@ -65,7 +67,6 @@ export type JwtClient = ReturnType; { provide: 'JwtClient', useFactory: jwtClientFactory, - inject: [IConfigurationService], }, { provide: IJwtService, useClass: JwtService }, ], diff --git a/src/datasources/jwt/jwt.service.interface.ts b/src/datasources/jwt/jwt.service.interface.ts index 83dfcf043c..1b07eacd29 100644 --- a/src/datasources/jwt/jwt.service.interface.ts +++ b/src/datasources/jwt/jwt.service.interface.ts @@ -3,16 +3,32 @@ import { JwtPayloadWithClaims } from '@/datasources/jwt/jwt-claims.entity'; export const IJwtService = Symbol('IJwtService'); export interface IJwtService { - sign( + sign< + T extends object & { + iat?: Date; + exp?: Date; + nbf?: Date; + }, + >( payload: T, options?: { - issuedAt?: number; - expiresIn?: number; - notBefore?: number; + secretOrPrivateKey: string; }, ): string; - verify(token: string): T; + verify( + token: string, + options?: { + issuer: string; + secretOrPrivateKey: string; + }, + ): T; - decode(token: string): JwtPayloadWithClaims; + decode( + token: string, + options?: { + issuer: string; + secretOrPrivateKey: string; + }, + ): JwtPayloadWithClaims; } diff --git a/src/datasources/jwt/jwt.service.spec.ts b/src/datasources/jwt/jwt.service.spec.ts index cfcf07bfff..cdcee94700 100644 --- a/src/datasources/jwt/jwt.service.spec.ts +++ b/src/datasources/jwt/jwt.service.spec.ts @@ -1,4 +1,5 @@ import { fakeJson } from '@/__tests__/faker'; +import { FakeConfigurationService } from '@/config/__tests__/fake.configuration.service'; import { JwtClient } from '@/datasources/jwt/jwt.module'; import { JwtService } from '@/datasources/jwt/jwt.service'; import { faker } from '@faker-js/faker'; @@ -11,12 +12,21 @@ const jwtClientMock: jest.MockedObjectDeep = jest.mocked({ describe('JwtService', () => { let service: JwtService; + let configIssuer: string; + let configSecret: string; beforeEach(() => { jest.useFakeTimers(); jest.resetAllMocks(); - service = new JwtService(jwtClientMock); + configIssuer = faker.word.noun(); + configSecret = faker.string.alphanumeric(); + + const fakeConfigurationService = new FakeConfigurationService(); + fakeConfigurationService.set('jwt.issuer', configIssuer); + fakeConfigurationService.set('jwt.secret', configSecret); + + service = new JwtService(jwtClientMock, fakeConfigurationService); }); afterEach(() => { @@ -24,49 +34,98 @@ describe('JwtService', () => { }); describe('sign', () => { - it('should sign a payload without options', () => { + it('should sign a payload with default issuer/secret', () => { const payload = JSON.parse(fakeJson()) as object; service.sign(payload); expect(jwtClientMock.sign).toHaveBeenCalledTimes(1); - expect(jwtClientMock.sign).toHaveBeenCalledWith(payload, {}); + expect(jwtClientMock.sign).toHaveBeenCalledWith( + { iss: configIssuer, ...payload }, + { + secretOrPrivateKey: configSecret, + }, + ); }); - it('should sign a payload with options', () => { - const payload = JSON.parse(fakeJson()) as object; - const options = { - issuedAt: faker.number.int({ min: 1 }), - expiresIn: faker.number.int({ min: 1 }), - notBefore: faker.number.int({ min: 1 }), + it('should sign a payload with custom issuer/secret', () => { + const customIssuer = faker.word.noun(); + const customSecret = faker.string.alphanumeric(); + const payload = { + ...(JSON.parse(fakeJson()) as object), + iss: customIssuer, }; - service.sign(payload, options); + service.sign(payload, { + secretOrPrivateKey: customSecret, + }); expect(jwtClientMock.sign).toHaveBeenCalledTimes(1); - expect(jwtClientMock.sign).toHaveBeenCalledWith(payload, options); + expect(jwtClientMock.sign).toHaveBeenCalledWith(payload, { + secretOrPrivateKey: customSecret, + }); }); }); describe('verify', () => { - it('should verify a token', () => { + it('should verify a token with the default issuer/secret', () => { const token = faker.string.alphanumeric(); service.verify(token); expect(jwtClientMock.verify).toHaveBeenCalledTimes(1); - expect(jwtClientMock.verify).toHaveBeenCalledWith(token); + expect(jwtClientMock.verify).toHaveBeenCalledWith(token, { + issuer: configIssuer, + secretOrPrivateKey: configSecret, + }); + }); + + it('should verify a token with custom issuer/secret', () => { + const token = faker.string.alphanumeric(); + const customIssuer = faker.word.noun(); + const customSecret = faker.string.alphanumeric(); + + service.verify(token, { + issuer: customIssuer, + secretOrPrivateKey: customSecret, + }); + + expect(jwtClientMock.verify).toHaveBeenCalledTimes(1); + expect(jwtClientMock.verify).toHaveBeenCalledWith(token, { + issuer: customIssuer, + secretOrPrivateKey: customSecret, + }); }); }); describe('decode', () => { - it('should decode a token', () => { + it('should decode a token with the default issuer/secret', () => { const token = faker.string.alphanumeric(); service.decode(token); expect(jwtClientMock.decode).toHaveBeenCalledTimes(1); - expect(jwtClientMock.decode).toHaveBeenCalledWith(token); + expect(jwtClientMock.decode).toHaveBeenCalledWith(token, { + issuer: configIssuer, + secretOrPrivateKey: configSecret, + }); + }); + + it('should decode a token with custom issuer/secret', () => { + const token = faker.string.alphanumeric(); + const customIssuer = faker.word.noun(); + const customSecret = faker.string.alphanumeric(); + + service.decode(token, { + issuer: customIssuer, + secretOrPrivateKey: customSecret, + }); + + expect(jwtClientMock.decode).toHaveBeenCalledTimes(1); + expect(jwtClientMock.decode).toHaveBeenCalledWith(token, { + issuer: customIssuer, + secretOrPrivateKey: customSecret, + }); }); }); }); diff --git a/src/datasources/jwt/jwt.service.ts b/src/datasources/jwt/jwt.service.ts index 40e1665c86..53c3cc0821 100644 --- a/src/datasources/jwt/jwt.service.ts +++ b/src/datasources/jwt/jwt.service.ts @@ -2,26 +2,61 @@ import { JwtClient } from '@/datasources/jwt/jwt.module'; import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; import { JwtPayloadWithClaims } from '@/datasources/jwt/jwt-claims.entity'; import { Inject, Injectable } from '@nestjs/common'; +import { IConfigurationService } from '@/config/configuration.service.interface'; @Injectable() export class JwtService implements IJwtService { + issuer: string; + secret: string; + constructor( @Inject('JwtClient') private readonly client: JwtClient, - ) {} + @Inject(IConfigurationService) + private readonly configurationService: IConfigurationService, + ) { + this.issuer = configurationService.getOrThrow('jwt.issuer'); + this.secret = configurationService.getOrThrow('jwt.secret'); + } - sign( + sign< + T extends object & { + iat?: Date; + exp?: Date; + nbf?: Date; + }, + >( payload: T, - options: { issuedAt?: number; expiresIn?: number; notBefore?: number } = {}, + options: { secretOrPrivateKey: string } = { + secretOrPrivateKey: this.secret, + }, ): string { - return this.client.sign(payload, options); + return this.client.sign( + { + iss: 'iss' in payload ? payload.iss : this.issuer, + ...payload, + }, + options, + ); } - verify(token: string): T { - return this.client.verify(token); + verify( + token: string, + options: { issuer: string; secretOrPrivateKey: string } = { + issuer: this.issuer, + secretOrPrivateKey: this.secret, + }, + ): T { + return this.client.verify(token, options); } - decode(token: string): JwtPayloadWithClaims { - return this.client.decode(token); + decode( + token: string, + options: { issuer: string; secretOrPrivateKey: string } = { + issuer: this.issuer, + secretOrPrivateKey: this.secret, + }, + ): JwtPayloadWithClaims { + return this.client.decode(token, options); } } diff --git a/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.ts b/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.ts index e573dafde6..c424c10c33 100644 --- a/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.ts +++ b/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.ts @@ -12,9 +12,7 @@ import { import { IPushNotificationsApi } from '@/domain/interfaces/push-notifications-api.interface'; import { Inject, Injectable } from '@nestjs/common'; import { FirebaseNotification } from '@/datasources/push-notifications-api/entities/firebase-notification.entity'; - -// TODO: Refactor to use JwtService -import jwt from 'jsonwebtoken'; +import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; @Injectable() export class FirebaseCloudMessagingApiService implements IPushNotificationsApi { @@ -36,6 +34,8 @@ export class FirebaseCloudMessagingApiService implements IPushNotificationsApi { private readonly configurationService: IConfigurationService, @Inject(CacheService) private readonly cacheService: ICacheService, + @Inject(IJwtService) + private readonly jwtService: IJwtService, private readonly httpErrorFactory: HttpErrorFactory, ) { this.baseUrl = this.configurationService.getOrThrow( @@ -134,16 +134,20 @@ export class FirebaseCloudMessagingApiService implements IPushNotificationsApi { * @returns - signed JWT assertion */ private getAssertion(): string { - const issuedAt = Math.floor(Date.now() / 1_000); + const now = new Date(); + const payload = { + alg: 'RS256', iss: this.clientEmail, scope: FirebaseCloudMessagingApiService.Scope, aud: FirebaseCloudMessagingApiService.OAuth2TokenUrl, - iat: issuedAt, + iat: now, // Maximum expiration time is 1 hour - exp: issuedAt + 60 * 60, + exp: new Date(now.getTime() + 60 * 60 * 1_000), }; - return jwt.sign(payload, this.privateKey, { algorithm: 'RS256' }); + return this.jwtService.sign(payload, { + secretOrPrivateKey: this.privateKey, + }); } } diff --git a/src/domain/auth/auth.repository.interface.ts b/src/domain/auth/auth.repository.interface.ts index 2079bf64bd..4090a7d456 100644 --- a/src/domain/auth/auth.repository.interface.ts +++ b/src/domain/auth/auth.repository.interface.ts @@ -10,8 +10,8 @@ export interface IAuthRepository { signToken( payload: T, options?: { - expiresIn?: number; - notBefore?: number; + exp?: Date; + nbf?: Date; }, ): string; diff --git a/src/domain/auth/auth.repository.ts b/src/domain/auth/auth.repository.ts index 890cc350f1..0ab73e9fcc 100644 --- a/src/domain/auth/auth.repository.ts +++ b/src/domain/auth/auth.repository.ts @@ -20,12 +20,16 @@ export class AuthRepository implements IAuthRepository { signToken( payload: T, options?: { - expiresIn?: number; - notBefore?: number; + exp?: Date; + nbf?: Date; }, ): string { const authPayloadDto = AuthPayloadDtoSchema.parse(payload); - return this.jwtService.sign(authPayloadDto, options); + return this.jwtService.sign({ + ...authPayloadDto, + exp: options?.exp, + nbf: options?.nbf, + }); } verifyToken(accessToken: string): AuthPayloadDto { diff --git a/src/domain/common/utils/time.ts b/src/domain/common/utils/time.ts index 3a4b023a81..ff152e2b3f 100644 --- a/src/domain/common/utils/time.ts +++ b/src/domain/common/utils/time.ts @@ -5,3 +5,7 @@ export function getMillisecondsUntil(date: Date): number { export function getSecondsUntil(date: Date): number { return Math.floor(getMillisecondsUntil(date) / 1_000); } + +export const toSecondsTimestamp = (date: Date): number => { + return Math.floor(date.getTime() / 1_000); +}; diff --git a/src/routes/accounts/accounts.controller.spec.ts b/src/routes/accounts/accounts.controller.spec.ts index 9a74f6e58a..f38415f194 100644 --- a/src/routes/accounts/accounts.controller.spec.ts +++ b/src/routes/accounts/accounts.controller.spec.ts @@ -21,7 +21,6 @@ import { accountBuilder } from '@/domain/accounts/entities/__tests__/account.bui import { upsertAccountDataSettingsDtoBuilder } from '@/domain/accounts/entities/__tests__/upsert-account-data-settings.dto.entity.builder'; import { authPayloadDtoBuilder } from '@/domain/auth/entities/__tests__/auth-payload-dto.entity.builder'; import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; -import { getSecondsUntil } from '@/domain/common/utils/time'; import { IAccountsDatasource } from '@/domain/interfaces/accounts.datasource.interface'; import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; import { RequestScopedLoggingModule } from '@/logging/logging.module'; @@ -145,8 +144,9 @@ describe('AccountsController', () => { .with('chain_id', chain.chainId) .with('signer_address', address) .build(); - const accessToken = jwtService.sign(authPayloadDto, { - notBefore: getSecondsUntil(faker.date.future()), + const accessToken = jwtService.sign({ + ...authPayloadDto, + nbf: faker.date.future(), }); await request(app.getHttpServer()) @@ -165,7 +165,10 @@ describe('AccountsController', () => { .with('chain_id', chain.chainId) .with('signer_address', address) .build(); - const accessToken = jwtService.sign(authPayloadDto, { expiresIn: 0 }); + const accessToken = jwtService.sign({ + ...authPayloadDto, + exp: new Date(), + }); jest.advanceTimersByTime(1_000); await request(app.getHttpServer()) @@ -330,8 +333,9 @@ describe('AccountsController', () => { .with('chain_id', chain.chainId) .with('signer_address', address) .build(); - const accessToken = jwtService.sign(authPayloadDto, { - notBefore: getSecondsUntil(faker.date.future()), + const accessToken = jwtService.sign({ + ...authPayloadDto, + nbf: faker.date.future(), }); await request(app.getHttpServer()) @@ -349,7 +353,10 @@ describe('AccountsController', () => { .with('chain_id', chain.chainId) .with('signer_address', address) .build(); - const accessToken = jwtService.sign(authPayloadDto, { expiresIn: 0 }); + const accessToken = jwtService.sign({ + ...authPayloadDto, + exp: new Date(), + }); jest.advanceTimersByTime(1_000); await request(app.getHttpServer()) @@ -471,8 +478,9 @@ describe('AccountsController', () => { .with('chain_id', chain.chainId) .with('signer_address', address) .build(); - const accessToken = jwtService.sign(authPayloadDto, { - notBefore: getSecondsUntil(faker.date.future()), + const accessToken = jwtService.sign({ + ...authPayloadDto, + nbf: faker.date.future(), }); await request(app.getHttpServer()) @@ -489,7 +497,10 @@ describe('AccountsController', () => { .with('chain_id', chain.chainId) .with('signer_address', address) .build(); - const accessToken = jwtService.sign(authPayloadDto, { expiresIn: 0 }); + const accessToken = jwtService.sign({ + authPayloadDto, + exp: new Date(), + }); jest.advanceTimersByTime(1_000); await request(app.getHttpServer()) @@ -682,8 +693,9 @@ describe('AccountsController', () => { .with('chain_id', chain.chainId) .with('signer_address', address) .build(); - const accessToken = jwtService.sign(authPayloadDto, { - notBefore: getSecondsUntil(faker.date.future()), + const accessToken = jwtService.sign({ + ...authPayloadDto, + nbf: faker.date.future(), }); await request(app.getHttpServer()) @@ -700,7 +712,10 @@ describe('AccountsController', () => { .with('chain_id', chain.chainId) .with('signer_address', address) .build(); - const accessToken = jwtService.sign(authPayloadDto, { expiresIn: 0 }); + const accessToken = jwtService.sign({ + ...authPayloadDto, + exp: new Date(), + }); jest.advanceTimersByTime(1_000); await request(app.getHttpServer()) @@ -837,8 +852,9 @@ describe('AccountsController', () => { .with('chain_id', chain.chainId) .with('signer_address', address) .build(); - const accessToken = jwtService.sign(authPayloadDto, { - notBefore: getSecondsUntil(faker.date.future()), + const accessToken = jwtService.sign({ + ...authPayloadDto, + nbf: faker.date.future(), }); await request(app.getHttpServer()) @@ -855,7 +871,10 @@ describe('AccountsController', () => { .with('chain_id', chain.chainId) .with('signer_address', address) .build(); - const accessToken = jwtService.sign(authPayloadDto, { expiresIn: 0 }); + const accessToken = jwtService.sign({ + ...authPayloadDto, + exp: new Date(), + }); jest.advanceTimersByTime(1_000); await request(app.getHttpServer()) @@ -873,7 +892,6 @@ describe('AccountsController', () => { .with('signer_address', faker.string.hexadecimal() as `0x${string}`) .build(); const accessToken = jwtService.sign(authPayloadDto); - await request(app.getHttpServer()) .get(`/v1/accounts/${address}/data-settings`) .set('Cookie', [`access_token=${accessToken}`]) diff --git a/src/routes/auth/auth.controller.spec.ts b/src/routes/auth/auth.controller.spec.ts index 87407f7d92..beebacb6f0 100644 --- a/src/routes/auth/auth.controller.spec.ts +++ b/src/routes/auth/auth.controller.spec.ts @@ -127,6 +127,9 @@ describe('AuthController', () => { describe('POST /v1/auth/verify', () => { it('should verify a signer', async () => { + // Fix "now" as it is otherwise to precisely expect expiration/maxAge + jest.setSystemTime(0); + const privateKey = generatePrivateKey(); const signer = privateKeyToAccount(privateKey); const nonceResponse = await request(app.getHttpServer()).get( @@ -149,9 +152,6 @@ describe('AuthController', () => { message, }); const maxAge = getSecondsUntil(expirationTime); - // jsonwebtoken sets expiration based on timespans, not exact dates - // meaning we cannot use expirationTime directly - const expires = new Date(Date.now() + maxAge * 1_000); await expect(cacheService.get(cacheDir)).resolves.toBe( nonceResponse.body.nonce, @@ -166,7 +166,7 @@ describe('AuthController', () => { .expect(({ headers }) => { const setCookie = headers['set-cookie']; const setCookieRegExp = new RegExp( - `access_token=([^;]*); Max-Age=${maxAge}; Path=/; Expires=${expires.toUTCString()}; HttpOnly; Secure; SameSite=Lax`, + `access_token=([^;]*); Max-Age=${maxAge}; Path=/; Expires=${expirationTime.toUTCString()}; HttpOnly; Secure; SameSite=Lax`, ); expect(setCookie).toHaveLength; @@ -179,6 +179,9 @@ describe('AuthController', () => { }); it('should verify a smart contract signer', async () => { + // Fix "now" as it is otherwise to precisely expect expiration/maxAge + jest.setSystemTime(0); + const nonceResponse = await request(app.getHttpServer()).get( '/v1/auth/nonce', ); @@ -197,9 +200,6 @@ describe('AuthController', () => { const signature = faker.string.hexadecimal({ length: 132 }); verifySiweMessageMock.mockResolvedValue(true); const maxAge = getSecondsUntil(expirationTime); - // jsonwebtoken sets expiration based on timespans, not exact dates - // meaning we cannot use expirationTime directly - const expires = new Date(Date.now() + maxAge * 1_000); await expect(cacheService.get(cacheDir)).resolves.toBe( nonceResponse.body.nonce, @@ -214,7 +214,7 @@ describe('AuthController', () => { .expect(({ headers }) => { const setCookie = headers['set-cookie']; const setCookieRegExp = new RegExp( - `access_token=([^;]*); Max-Age=${maxAge}; Path=/; Expires=${expires.toUTCString()}; HttpOnly; Secure; SameSite=Lax`, + `access_token=([^;]*); Max-Age=${maxAge}; Path=/; Expires=${expirationTime.toUTCString()}; HttpOnly; Secure; SameSite=Lax`, ); expect(setCookie).toHaveLength; @@ -227,6 +227,9 @@ describe('AuthController', () => { }); it('should set SameSite=none if application.env is not production', async () => { + // Fix "now" as it is otherwise to precisely expect expiration/maxAge + jest.setSystemTime(0); + const defaultConfiguration = configuration(); const testConfiguration = (): typeof defaultConfiguration => ({ ...defaultConfiguration, @@ -264,9 +267,6 @@ describe('AuthController', () => { message, }); const maxAge = getSecondsUntil(expirationTime); - // jsonwebtoken sets expiration based on timespans, not exact dates - // meaning we cannot use expirationTime directly - const expires = new Date(Date.now() + maxAge * 1_000); await expect(cacheService.get(cacheDir)).resolves.toBe( nonceResponse.body.nonce, @@ -282,7 +282,7 @@ describe('AuthController', () => { .expect(({ headers }) => { const setCookie = headers['set-cookie']; const setCookieRegExp = new RegExp( - `access_token=([^;]*); Max-Age=${maxAge}; Path=/; Expires=${expires.toUTCString()}; HttpOnly; Secure; SameSite=None`, + `access_token=([^;]*); Max-Age=${maxAge}; Path=/; Expires=${expirationTime.toUTCString()}; HttpOnly; Secure; SameSite=None`, ); expect(setCookie).toHaveLength; diff --git a/src/routes/auth/auth.controller.ts b/src/routes/auth/auth.controller.ts index 59dc40b3b1..5c8db57fe8 100644 --- a/src/routes/auth/auth.controller.ts +++ b/src/routes/auth/auth.controller.ts @@ -83,6 +83,6 @@ export class AuthController { */ private getMaxAge(accessToken: string): number | undefined { const { exp } = this.authService.getTokenPayloadWithClaims(accessToken); - return exp ? getMillisecondsUntil(new Date(exp * 1_000)) : undefined; + return exp ? getMillisecondsUntil(exp) : undefined; } } diff --git a/src/routes/auth/auth.service.ts b/src/routes/auth/auth.service.ts index 4db23bbdf9..d603c90b24 100644 --- a/src/routes/auth/auth.service.ts +++ b/src/routes/auth/auth.service.ts @@ -2,7 +2,6 @@ import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; import { SiweDto } from '@/routes/auth/entities/siwe.dto.entity'; import { ISiweRepository } from '@/domain/siwe/siwe.repository.interface'; import { IAuthRepository } from '@/domain/auth/auth.repository.interface'; -import { getSecondsUntil } from '@/domain/common/utils/time'; import { AuthPayloadDto, AuthPayloadDtoSchema, @@ -45,10 +44,10 @@ export class AuthService { const accessToken = this.authRepository.signToken(payload, { ...(notBefore && { - notBefore: getSecondsUntil(new Date(notBefore)), + nbf: new Date(notBefore), }), ...(expirationTime && { - expiresIn: getSecondsUntil(new Date(expirationTime)), + exp: new Date(expirationTime), }), }); diff --git a/src/routes/auth/guards/auth.guard.spec.ts b/src/routes/auth/guards/auth.guard.spec.ts index 5be74dc609..d1c0be96ac 100644 --- a/src/routes/auth/guards/auth.guard.spec.ts +++ b/src/routes/auth/guards/auth.guard.spec.ts @@ -12,7 +12,6 @@ import { Controller, Get, INestApplication, UseGuards } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import request from 'supertest'; import { AuthRepositoryModule } from '@/domain/auth/auth.repository.interface'; -import { getSecondsUntil } from '@/domain/common/utils/time'; import { JWT_CONFIGURATION_MODULE, JwtConfigurationModule, @@ -96,9 +95,9 @@ describe('AuthGuard', () => { it('should not allow access if a token is not yet valid', async () => { const authPayloadDto = authPayloadDtoBuilder().build(); - const notBefore = faker.date.future(); - const accessToken = jwtService.sign(authPayloadDto, { - notBefore: getSecondsUntil(notBefore), + const accessToken = jwtService.sign({ + ...authPayloadDto, + nbf: faker.date.future(), }); expect(() => jwtService.verify(accessToken)).toThrow('jwt not active'); @@ -116,9 +115,9 @@ describe('AuthGuard', () => { it('should not allow access if a token has expired', async () => { const authPayloadDto = authPayloadDtoBuilder().build(); - const expiresIn = 0; // Now - const accessToken = jwtService.sign(authPayloadDto, { - expiresIn, + const accessToken = jwtService.sign({ + ...authPayloadDto, + exp: new Date(), // Now }); jest.advanceTimersByTime(1_000); @@ -155,7 +154,7 @@ describe('AuthGuard', () => { }); describe('should allow access if the AuthPayload is valid', () => { - it('when notBefore nor expiresIn is specified', async () => { + it('when nbf nor exp is specified', async () => { const authPayloadDto = authPayloadDtoBuilder().build(); const accessToken = jwtService.sign(authPayloadDto); @@ -168,11 +167,11 @@ describe('AuthGuard', () => { .expect({ secret: 'This is a secret message' }); }); - it('when notBefore is and expirationTime is not specified', async () => { + it('when nbf is and exp is not specified', async () => { const authPayloadDto = authPayloadDtoBuilder().build(); - const notBefore = faker.date.past(); - const accessToken = jwtService.sign(authPayloadDto, { - notBefore: getSecondsUntil(notBefore), + const accessToken = jwtService.sign({ + ...authPayloadDto, + nbf: faker.date.past(), }); expect(() => jwtService.verify(accessToken)).not.toThrow(); @@ -184,11 +183,11 @@ describe('AuthGuard', () => { .expect({ secret: 'This is a secret message' }); }); - it('when expiresIn is and notBefore is not specified', async () => { + it('when exp is and nbf is not specified', async () => { const authPayloadDto = authPayloadDtoBuilder().build(); - const expiresIn = faker.date.future(); - const accessToken = jwtService.sign(authPayloadDto, { - expiresIn: getSecondsUntil(expiresIn), + const accessToken = jwtService.sign({ + ...authPayloadDto, + exp: faker.date.future(), }); expect(() => jwtService.verify(accessToken)).not.toThrow(); @@ -200,13 +199,12 @@ describe('AuthGuard', () => { .expect({ secret: 'This is a secret message' }); }); - it('when notBefore and expirationTime are specified', async () => { + it('when nbf and exp are specified', async () => { const authPayloadDto = authPayloadDtoBuilder().build(); - const notBefore = faker.date.past(); - const expiresIn = faker.date.future(); - const accessToken = jwtService.sign(authPayloadDto, { - notBefore: getSecondsUntil(notBefore), - expiresIn: getSecondsUntil(expiresIn), + const accessToken = jwtService.sign({ + ...authPayloadDto, + nbf: faker.date.past(), + exp: faker.date.future(), }); expect(() => jwtService.verify(accessToken)).not.toThrow(); diff --git a/src/routes/recovery/recovery.controller.spec.ts b/src/routes/recovery/recovery.controller.spec.ts index 65ae44a46e..e70d0c5365 100644 --- a/src/routes/recovery/recovery.controller.spec.ts +++ b/src/routes/recovery/recovery.controller.spec.ts @@ -40,7 +40,6 @@ import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues- import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { authPayloadDtoBuilder } from '@/domain/auth/entities/__tests__/auth-payload-dto.entity.builder'; import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; -import { getSecondsUntil } from '@/domain/common/utils/time'; import { getAddress } from 'viem'; import { Server } from 'net'; @@ -193,9 +192,9 @@ describe('Recovery (Unit)', () => { .with('chain_id', chain.chainId) .with('signer_address', signerAddress) .build(); - const notBefore = faker.date.future(); - const accessToken = jwtService.sign(authPayloadDto, { - notBefore: getSecondsUntil(notBefore), + const accessToken = jwtService.sign({ + ...authPayloadDto, + nbf: faker.date.future(), }); expect(() => jwtService.verify(accessToken)).toThrow('jwt not active'); @@ -218,8 +217,9 @@ describe('Recovery (Unit)', () => { .with('chain_id', chain.chainId) .with('signer_address', signerAddress) .build(); - const accessToken = jwtService.sign(authPayloadDto, { - expiresIn: 0, // Now + const accessToken = jwtService.sign({ + ...authPayloadDto, + exp: new Date(), // Now }); jest.advanceTimersByTime(1_000); @@ -548,9 +548,9 @@ describe('Recovery (Unit)', () => { .with('chain_id', chain.chainId) .with('signer_address', signerAddress) .build(); - const notBefore = faker.date.future(); - const accessToken = jwtService.sign(authPayloadDto, { - notBefore: getSecondsUntil(notBefore), + const accessToken = jwtService.sign({ + ...authPayloadDto, + nbf: faker.date.future(), }); expect(() => jwtService.verify(accessToken)).toThrow('jwt not active'); @@ -574,8 +574,9 @@ describe('Recovery (Unit)', () => { .with('chain_id', chain.chainId) .with('signer_address', signerAddress) .build(); - const accessToken = jwtService.sign(authPayloadDto, { - expiresIn: 0, // Now + const accessToken = jwtService.sign({ + ...authPayloadDto, + exp: new Date(), // Now }); jest.advanceTimersByTime(1_000); From 5b08d792cdf36f99c4ea606823c191c88d0df661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Thu, 18 Jul 2024 17:06:27 +0200 Subject: [PATCH 192/207] Remove PUSH_NOTIFICATIONS_API_BASE_URI from required envs (#1775) Removes `PUSH_NOTIFICATIONS_API_BASE_URI` from the environment variables checked at the service startup time, as a default value is provided on the `configuration.ts` file. --- src/config/configuration.module.ts | 1 - src/config/configuration.validator.spec.ts | 7 +------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/config/configuration.module.ts b/src/config/configuration.module.ts index 21ba06a533..5a6e101f6e 100644 --- a/src/config/configuration.module.ts +++ b/src/config/configuration.module.ts @@ -48,7 +48,6 @@ export const RootConfigurationSchema = z.object({ EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: z.string(), EMAIL_TEMPLATE_VERIFICATION_CODE: z.string(), INFURA_API_KEY: z.string(), - PUSH_NOTIFICATIONS_API_BASE_URI: z.string().url(), PUSH_NOTIFICATIONS_API_PROJECT: z.string(), PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_CLIENT_EMAIL: z.string().email(), PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY: z.string(), diff --git a/src/config/configuration.validator.spec.ts b/src/config/configuration.validator.spec.ts index 6db4f6808b..be8e67f41d 100644 --- a/src/config/configuration.validator.spec.ts +++ b/src/config/configuration.validator.spec.ts @@ -19,7 +19,6 @@ describe('Configuration validator', () => { EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: faker.string.alphanumeric(), EMAIL_TEMPLATE_VERIFICATION_CODE: faker.string.alphanumeric(), INFURA_API_KEY: faker.string.uuid(), - PUSH_NOTIFICATIONS_API_BASE_URI: faker.internet.url({ appendSlash: false }), PUSH_NOTIFICATIONS_API_PROJECT: faker.word.noun(), PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_CLIENT_EMAIL: faker.internet.email(), PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY: @@ -54,7 +53,6 @@ describe('Configuration validator', () => { { key: 'EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX' }, { key: 'EMAIL_TEMPLATE_VERIFICATION_CODE' }, { key: 'INFURA_API_KEY' }, - { key: 'PUSH_NOTIFICATIONS_API_BASE_URI' }, { key: 'PUSH_NOTIFICATIONS_API_PROJECT' }, { key: 'PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_CLIENT_EMAIL' }, { key: 'PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY' }, @@ -74,7 +72,7 @@ describe('Configuration validator', () => { }, ); - it('should an invalid LOG_LEVEL configuration in production environment', () => { + it('should detect an invalid LOG_LEVEL configuration in production environment', () => { process.env.NODE_ENV = 'production'; const invalidConfiguration: Record = { ...JSON.parse(fakeJson()), @@ -91,9 +89,6 @@ describe('Configuration validator', () => { EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: faker.string.alphanumeric(), EMAIL_TEMPLATE_VERIFICATION_CODE: faker.string.alphanumeric(), INFURA_API_KEY: faker.string.uuid(), - PUSH_NOTIFICATIONS_API_BASE_URI: faker.internet.url({ - appendSlash: false, - }), PUSH_NOTIFICATIONS_API_PROJECT: faker.word.noun(), PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_CLIENT_EMAIL: faker.internet.email(), From 03923f685c01b646e82ffb2bf88a816300bfd7c7 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Thu, 18 Jul 2024 17:43:15 +0200 Subject: [PATCH 193/207] Add tests for `FirebaseCloudMessagingApiService` (#1769) Add according test coverage for `FirebaseCloudMessagingApiService`. --- .../firebase-notification.builder.ts | 13 ++ .../entities/firebase-notification.entity.ts | 10 +- ...rebase-cloud-messaging-api.service.spec.ts | 151 ++++++++++++++++++ .../firebase-cloud-messaging-api.service.ts | 4 +- 4 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 src/datasources/push-notifications-api/__tests__/firebase-notification.builder.ts create mode 100644 src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.spec.ts diff --git a/src/datasources/push-notifications-api/__tests__/firebase-notification.builder.ts b/src/datasources/push-notifications-api/__tests__/firebase-notification.builder.ts new file mode 100644 index 0000000000..36adb181f7 --- /dev/null +++ b/src/datasources/push-notifications-api/__tests__/firebase-notification.builder.ts @@ -0,0 +1,13 @@ +import { IBuilder, Builder } from '@/__tests__/builder'; +import { fakeJson } from '@/__tests__/faker'; +import { FirebaseNotification } from '@/datasources/push-notifications-api/entities/firebase-notification.entity'; +import { faker } from '@faker-js/faker'; + +export function firebaseNotificationBuilder(): IBuilder { + return new Builder() + .with('notification', { + title: faker.lorem.sentence(), + body: faker.lorem.sentence(), + }) + .with('data', JSON.parse(fakeJson()) as Record); +} diff --git a/src/datasources/push-notifications-api/entities/firebase-notification.entity.ts b/src/datasources/push-notifications-api/entities/firebase-notification.entity.ts index 13437d7a96..50d303212f 100644 --- a/src/datasources/push-notifications-api/entities/firebase-notification.entity.ts +++ b/src/datasources/push-notifications-api/entities/firebase-notification.entity.ts @@ -1,5 +1,7 @@ -export type FirebaseNotification> = { - title: string; - body: string; - data: T; +export type FirebaseNotification = { + notification?: { + title?: string; + body?: string; + }; + data?: Record; }; diff --git a/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.spec.ts b/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.spec.ts new file mode 100644 index 0000000000..7afbcdc986 --- /dev/null +++ b/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.spec.ts @@ -0,0 +1,151 @@ +import { FakeConfigurationService } from '@/config/__tests__/fake.configuration.service'; +import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.service'; +import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity'; +import { HttpErrorFactory } from '@/datasources/errors/http-error-factory'; +import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; +import { INetworkService } from '@/datasources/network/network.service.interface'; +import { firebaseNotificationBuilder } from '@/datasources/push-notifications-api/__tests__/firebase-notification.builder'; +import { FirebaseCloudMessagingApiService } from '@/datasources/push-notifications-api/firebase-cloud-messaging-api.service'; +import { faker } from '@faker-js/faker'; + +const mockNetworkService = jest.mocked({ + get: jest.fn(), + post: jest.fn(), +} as jest.MockedObjectDeep); + +const mockJwtService = jest.mocked({ + sign: jest.fn(), +} as jest.MockedObjectDeep); + +const mockHttpErrorFactory = jest.mocked({ + from: jest.fn(), +} as jest.MockedObjectDeep); + +describe('FirebaseCloudMessagingApiService', () => { + let target: FirebaseCloudMessagingApiService; + let fakeCacheService: FakeCacheService; + + let pushNotificationsBaseUri: string; + let pushNotificationsProject: string; + let pushNotificationsServiceAccountClientEmail: string; + let pushNotificationsServiceAccountPrivateKey: string; + + beforeEach(() => { + jest.resetAllMocks(); + + pushNotificationsBaseUri = faker.internet.url({ appendSlash: false }); + pushNotificationsProject = faker.word.noun(); + pushNotificationsServiceAccountClientEmail = faker.internet.email(); + pushNotificationsServiceAccountPrivateKey = faker.string.alphanumeric(); + + const fakeConfigurationService = new FakeConfigurationService(); + fakeConfigurationService.set( + 'pushNotifications.baseUri', + pushNotificationsBaseUri, + ); + fakeConfigurationService.set( + 'pushNotifications.project', + pushNotificationsProject, + ); + fakeConfigurationService.set( + 'pushNotifications.serviceAccount.clientEmail', + pushNotificationsServiceAccountClientEmail, + ); + fakeConfigurationService.set( + 'pushNotifications.serviceAccount.privateKey', + pushNotificationsServiceAccountPrivateKey, + ); + + fakeCacheService = new FakeCacheService(); + target = new FirebaseCloudMessagingApiService( + mockNetworkService, + fakeConfigurationService, + fakeCacheService, + mockJwtService, + mockHttpErrorFactory, + ); + }); + + it('it should get an OAuth2 token if not cached, cache it and enqueue a notification', async () => { + const oauth2AssertionJwt = faker.string.alphanumeric(); + const oauth2Token = faker.string.alphanumeric(); + const oauth2TokenExpiresIn = faker.number.int(); + const fcmToken = faker.string.alphanumeric(); + const notification = firebaseNotificationBuilder().build(); + mockJwtService.sign.mockReturnValue(oauth2AssertionJwt); + mockNetworkService.post.mockResolvedValueOnce({ + status: 200, + data: { + access_token: oauth2Token, + expires_in: oauth2TokenExpiresIn, + }, + }); + + await expect( + target.enqueueNotification(fcmToken, notification), + ).resolves.toBeUndefined(); + + expect(mockNetworkService.post).toHaveBeenCalledTimes(2); + // Get OAuth2 token + expect(mockNetworkService.post).toHaveBeenNthCalledWith(1, { + url: 'https://oauth2.googleapis.com/token', + data: { + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: oauth2AssertionJwt, + }, + }); + // Send notification + expect(mockNetworkService.post).toHaveBeenNthCalledWith(2, { + url: `${pushNotificationsBaseUri}/${pushNotificationsProject}/messages:send`, + data: { + message: { + token: fcmToken, + notification, + }, + }, + networkRequest: { + headers: { + Authorization: `Bearer ${oauth2Token}`, + }, + }, + }); + // Cached OAuth2 token + expect(fakeCacheService.keyCount()).toBe(1); + await expect( + fakeCacheService.get(new CacheDir('firebase_oauth2_token', '')), + ).resolves.toBe(oauth2Token); + }); + + it('should use an OAuth2 token from cache if available', async () => { + const oauth2Token = faker.string.alphanumeric(); + const oauth2TokenExpiresIn = faker.number.int(); + await fakeCacheService.set( + new CacheDir('firebase_oauth2_token', ''), + oauth2Token, + oauth2TokenExpiresIn, + ); + const fcmToken = faker.string.alphanumeric(); + const notification = firebaseNotificationBuilder().build(); + + await expect( + target.enqueueNotification(fcmToken, notification), + ).resolves.toBeUndefined(); + + expect(mockNetworkService.post).toHaveBeenCalledTimes(1); + // Send notification + expect(mockNetworkService.post).toHaveBeenNthCalledWith(1, { + url: `${pushNotificationsBaseUri}/${pushNotificationsProject}/messages:send`, + data: { + message: { + token: fcmToken, + notification, + }, + }, + networkRequest: { + headers: { + Authorization: `Bearer ${oauth2Token}`, + }, + }, + }); + }); +}); diff --git a/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.ts b/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.ts index c424c10c33..58e92f01f0 100644 --- a/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.ts +++ b/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.ts @@ -59,9 +59,9 @@ export class FirebaseCloudMessagingApiService implements IPushNotificationsApi { * @param fcmToken - device's FCM token * @param notification - notification payload */ - async enqueueNotification>( + async enqueueNotification( fcmToken: string, - notification: FirebaseNotification, + notification: FirebaseNotification, ): Promise { const url = `${this.baseUrl}/${this.project}/messages:send`; try { From 53b7964032eb4415b14365989729cd3e75106d85 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Fri, 19 Jul 2024 11:52:45 +0200 Subject: [PATCH 194/207] Move hook domain logic from routes to domain (#1774) Moves domain-considered logic from `CacheHooksService` to a new `HooksRepository` in the domain: - Move domain logic from `CacheHooksService` to `HooksRepository` - Propagate changes accordingly --- .../hooks/hooks.repository.interface.ts | 35 ++ src/domain/hooks/hooks.repository.ts | 410 ++++++++++++++++++ src/routes/cache-hooks/cache-hooks.module.ts | 22 +- src/routes/cache-hooks/cache-hooks.service.ts | 389 +---------------- 4 files changed, 455 insertions(+), 401 deletions(-) create mode 100644 src/domain/hooks/hooks.repository.interface.ts create mode 100644 src/domain/hooks/hooks.repository.ts diff --git a/src/domain/hooks/hooks.repository.interface.ts b/src/domain/hooks/hooks.repository.interface.ts new file mode 100644 index 0000000000..61390212ad --- /dev/null +++ b/src/domain/hooks/hooks.repository.interface.ts @@ -0,0 +1,35 @@ +import { BalancesRepositoryModule } from '@/domain/balances/balances.repository.interface'; +import { BlockchainRepositoryModule } from '@/domain/blockchain/blockchain.repository.interface'; +import { ChainsRepositoryModule } from '@/domain/chains/chains.repository.interface'; +import { CollectiblesRepositoryModule } from '@/domain/collectibles/collectibles.repository.interface'; +import { HooksRepository } from '@/domain/hooks/hooks.repository'; +import { MessagesRepositoryModule } from '@/domain/messages/messages.repository.interface'; +import { QueuesRepositoryModule } from '@/domain/queues/queues-repository.interface'; +import { SafeAppsRepositoryModule } from '@/domain/safe-apps/safe-apps.repository.interface'; +import { SafeRepositoryModule } from '@/domain/safe/safe.repository.interface'; +import { TransactionsRepositoryModule } from '@/domain/transactions/transactions.repository.interface'; +import { Event } from '@/routes/cache-hooks/entities/event.entity'; +import { Module } from '@nestjs/common'; + +export const IHooksRepository = Symbol('IHooksRepository'); + +export interface IHooksRepository { + onEvent(event: Event): Promise; +} + +@Module({ + imports: [ + BalancesRepositoryModule, + BlockchainRepositoryModule, + ChainsRepositoryModule, + CollectiblesRepositoryModule, + MessagesRepositoryModule, + SafeAppsRepositoryModule, + SafeRepositoryModule, + TransactionsRepositoryModule, + QueuesRepositoryModule, + ], + providers: [{ provide: IHooksRepository, useClass: HooksRepository }], + exports: [IHooksRepository], +}) +export class HooksRepositoryModule {} diff --git a/src/domain/hooks/hooks.repository.ts b/src/domain/hooks/hooks.repository.ts new file mode 100644 index 0000000000..06ec8f6498 --- /dev/null +++ b/src/domain/hooks/hooks.repository.ts @@ -0,0 +1,410 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IBalancesRepository } from '@/domain/balances/balances.repository.interface'; +import { IChainsRepository } from '@/domain/chains/chains.repository.interface'; +import { ICollectiblesRepository } from '@/domain/collectibles/collectibles.repository.interface'; +import { IMessagesRepository } from '@/domain/messages/messages.repository.interface'; +import { ISafeAppsRepository } from '@/domain/safe-apps/safe-apps.repository.interface'; +import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; +import { ITransactionsRepository } from '@/domain/transactions/transactions.repository.interface'; +import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; +import { LoggingService, ILoggingService } from '@/logging/logging.interface'; +import { Event } from '@/routes/cache-hooks/entities/event.entity'; +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { IQueuesRepository } from '@/domain/queues/queues-repository.interface'; +import { ConsumeMessage } from 'amqplib'; +import { WebHookSchema } from '@/routes/cache-hooks/entities/schemas/web-hook.schema'; +import { IBlockchainRepository } from '@/domain/blockchain/blockchain.repository.interface'; +import { IHooksRepository } from '@/domain/hooks/hooks.repository.interface'; + +@Injectable() +export class HooksRepository implements IHooksRepository { + private static readonly HOOK_TYPE = 'hook'; + private readonly queueName: string; + + constructor( + @Inject(IBalancesRepository) + private readonly balancesRepository: IBalancesRepository, + @Inject(IBlockchainRepository) + private readonly blockchainRepository: IBlockchainRepository, + @Inject(IChainsRepository) + private readonly chainsRepository: IChainsRepository, + @Inject(ICollectiblesRepository) + private readonly collectiblesRepository: ICollectiblesRepository, + @Inject(IMessagesRepository) + private readonly messagesRepository: IMessagesRepository, + @Inject(ISafeAppsRepository) + private readonly safeAppsRepository: ISafeAppsRepository, + @Inject(ISafeRepository) + private readonly safeRepository: ISafeRepository, + @Inject(ITransactionsRepository) + private readonly transactionsRepository: ITransactionsRepository, + @Inject(LoggingService) + private readonly loggingService: ILoggingService, + @Inject(IQueuesRepository) + private readonly queuesRepository: IQueuesRepository, + @Inject(IConfigurationService) + private readonly configurationService: IConfigurationService, + ) { + this.queueName = this.configurationService.getOrThrow('amqp.queue'); + } + + onModuleInit(): Promise { + return this.queuesRepository.subscribe( + this.queueName, + async (msg: ConsumeMessage) => { + try { + const content = JSON.parse(msg.content.toString()); + const event: Event = WebHookSchema.parse(content); + await this.onEvent(event); + } catch (err) { + this.loggingService.error(err); + } + }, + ); + } + + async onEvent(event: Event): Promise { + return this.onEventClearCache(event).finally(() => { + this.onEventLog(event); + }); + } + + private async onEventClearCache(event: Event): Promise { + const promises: Promise[] = []; + switch (event.type) { + // A new pending multisig transaction affects: + // queued transactions – clear multisig transactions + // the pending transaction – clear multisig transaction + case EventType.PENDING_MULTISIG_TRANSACTION: + promises.push( + this.safeRepository.clearMultisigTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearMultisigTransaction({ + chainId: event.chainId, + safeTransactionHash: event.safeTxHash, + }), + ); + break; + // A deleted multisig transaction affects: + // queued transactions – clear multisig transactions + // the pending transaction – clear multisig transaction + case EventType.DELETED_MULTISIG_TRANSACTION: + promises.push( + this.safeRepository.clearMultisigTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearMultisigTransaction({ + chainId: event.chainId, + safeTransactionHash: event.safeTxHash, + }), + ); + break; + // An executed module transaction might affect: + // - the list of all executed transactions for the safe + // - the list of module transactions for the safe + // - the safe configuration + case EventType.MODULE_TRANSACTION: + promises.push( + this.safeRepository.clearAllExecutedTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearModuleTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearSafe({ + chainId: event.chainId, + address: event.address, + }), + ); + break; + // A new executed multisig transaction affects: + // - the collectibles that the safe has + // - the list of all executed transactions for the safe + // - the transfers for that safe + // - queued transactions and history – clear multisig transactions + // - the transaction executed – clear multisig transaction + // - the safe configuration - clear safe info + case EventType.EXECUTED_MULTISIG_TRANSACTION: + promises.push( + this.collectiblesRepository.clearCollectibles({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearAllExecutedTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearTransfers({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearMultisigTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearMultisigTransaction({ + chainId: event.chainId, + safeTransactionHash: event.safeTxHash, + }), + this.safeRepository.clearSafe({ + chainId: event.chainId, + address: event.address, + }), + ); + break; + // A new confirmation for a pending transaction affects: + // - queued transactions – clear multisig transactions + // - the pending transaction – clear multisig transaction + case EventType.NEW_CONFIRMATION: + promises.push( + this.safeRepository.clearMultisigTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearMultisigTransaction({ + chainId: event.chainId, + safeTransactionHash: event.safeTxHash, + }), + ); + break; + // Incoming ether affects: + // - the balance of the safe - clear safe balance + // - the list of all executed transactions (including transfers) for the safe + // - the incoming transfers for that safe + case EventType.INCOMING_ETHER: + promises.push( + this.balancesRepository.clearBalances({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearAllExecutedTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearMultisigTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearTransfers({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearIncomingTransfers({ + chainId: event.chainId, + safeAddress: event.address, + }), + ); + break; + // Outgoing ether affects: + // - the balance of the safe - clear safe balance + // - the list of all executed transactions for the safe + // - queued transactions and history – clear multisig transactions + // - the transfers for that safe + case EventType.OUTGOING_ETHER: + promises.push( + this.balancesRepository.clearBalances({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearAllExecutedTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearMultisigTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearTransfers({ + chainId: event.chainId, + safeAddress: event.address, + }), + ); + break; + // An incoming token affects: + // - the balance of the safe - clear safe balance + // - the collectibles that the safe has + // - the list of all executed transactions (including transfers) for the safe + // - queued transactions and history – clear multisig transactions + // - the transfers for that safe + // - the incoming transfers for that safe + case EventType.INCOMING_TOKEN: + promises.push( + this.balancesRepository.clearBalances({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.collectiblesRepository.clearCollectibles({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearAllExecutedTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearMultisigTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearTransfers({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearIncomingTransfers({ + chainId: event.chainId, + safeAddress: event.address, + }), + ); + break; + // An outgoing token affects: + // - the balance of the safe - clear safe balance + // - the collectibles that the safe has + // - the list of all executed transactions (including transfers) for the safe + // - queued transactions and history – clear multisig transactions + // - the transfers for that safe + case EventType.OUTGOING_TOKEN: + promises.push( + this.balancesRepository.clearBalances({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.collectiblesRepository.clearCollectibles({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearAllExecutedTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearMultisigTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearTransfers({ + chainId: event.chainId, + safeAddress: event.address, + }), + ); + break; + // A message created affects: + // - the messages associated to the Safe + case EventType.MESSAGE_CREATED: + promises.push( + this.messagesRepository.clearMessagesBySafe({ + chainId: event.chainId, + safeAddress: event.address, + }), + ); + break; + // A new message confirmation affects: + // - the message itself + // - the messages associated to the Safe + case EventType.MESSAGE_CONFIRMATION: + promises.push( + this.messagesRepository.clearMessagesByHash({ + chainId: event.chainId, + messageHash: event.messageHash, + }), + this.messagesRepository.clearMessagesBySafe({ + chainId: event.chainId, + safeAddress: event.address, + }), + ); + break; + case EventType.CHAIN_UPDATE: + promises.push( + this.chainsRepository.clearChain(event.chainId).then(() => { + // RPC may have changed + this.blockchainRepository.clearApi(event.chainId); + // Transaction Service may have changed + this.transactionsRepository.clearApi(event.chainId); + this.balancesRepository.clearApi(event.chainId); + }), + ); + break; + case EventType.SAFE_APPS_UPDATE: + promises.push(this.safeAppsRepository.clearSafeApps(event.chainId)); + break; + case EventType.SAFE_CREATED: + promises.push(this.safeRepository.clearIsSafe(event)); + break; + } + return Promise.all(promises); + } + + private onEventLog(event: Event): void { + switch (event.type) { + case EventType.PENDING_MULTISIG_TRANSACTION: + case EventType.DELETED_MULTISIG_TRANSACTION: + case EventType.EXECUTED_MULTISIG_TRANSACTION: + case EventType.NEW_CONFIRMATION: + this._logSafeTxEvent(event); + break; + case EventType.MODULE_TRANSACTION: + case EventType.INCOMING_ETHER: + case EventType.OUTGOING_ETHER: + case EventType.INCOMING_TOKEN: + case EventType.OUTGOING_TOKEN: + this._logTxEvent(event); + break; + case EventType.MESSAGE_CREATED: + case EventType.MESSAGE_CONFIRMATION: + this._logMessageEvent(event); + break; + case EventType.CHAIN_UPDATE: + case EventType.SAFE_APPS_UPDATE: + this._logEvent(event); + break; + case EventType.SAFE_CREATED: + break; + } + } + + private _logSafeTxEvent( + event: Event & { address: string; safeTxHash: string }, + ): void { + this.loggingService.info({ + type: HooksRepository.HOOK_TYPE, + eventType: event.type, + address: event.address, + chainId: event.chainId, + safeTxHash: event.safeTxHash, + }); + } + + private _logTxEvent( + event: Event & { address: string; txHash: string }, + ): void { + this.loggingService.info({ + type: HooksRepository.HOOK_TYPE, + eventType: event.type, + address: event.address, + chainId: event.chainId, + txHash: event.txHash, + }); + } + + private _logMessageEvent( + event: Event & { address: string; messageHash: string }, + ): void { + this.loggingService.info({ + type: HooksRepository.HOOK_TYPE, + eventType: event.type, + address: event.address, + chainId: event.chainId, + messageHash: event.messageHash, + }); + } + + private _logEvent(event: Event): void { + this.loggingService.info({ + type: HooksRepository.HOOK_TYPE, + eventType: event.type, + chainId: event.chainId, + }); + } +} diff --git a/src/routes/cache-hooks/cache-hooks.module.ts b/src/routes/cache-hooks/cache-hooks.module.ts index 2c9840c7b1..499989ee44 100644 --- a/src/routes/cache-hooks/cache-hooks.module.ts +++ b/src/routes/cache-hooks/cache-hooks.module.ts @@ -1,28 +1,10 @@ import { Module } from '@nestjs/common'; import { CacheHooksController } from '@/routes/cache-hooks/cache-hooks.controller'; +import { HooksRepositoryModule } from '@/domain/hooks/hooks.repository.interface'; import { CacheHooksService } from '@/routes/cache-hooks/cache-hooks.service'; -import { BalancesRepositoryModule } from '@/domain/balances/balances.repository.interface'; -import { CollectiblesRepositoryModule } from '@/domain/collectibles/collectibles.repository.interface'; -import { ChainsRepositoryModule } from '@/domain/chains/chains.repository.interface'; -import { SafeRepositoryModule } from '@/domain/safe/safe.repository.interface'; -import { TransactionsRepositoryModule } from '@/domain/transactions/transactions.repository.interface'; -import { MessagesRepositoryModule } from '@/domain/messages/messages.repository.interface'; -import { SafeAppsRepositoryModule } from '@/domain/safe-apps/safe-apps.repository.interface'; -import { QueuesRepositoryModule } from '@/domain/queues/queues-repository.interface'; -import { BlockchainRepositoryModule } from '@/domain/blockchain/blockchain.repository.interface'; @Module({ - imports: [ - BalancesRepositoryModule, - BlockchainRepositoryModule, - ChainsRepositoryModule, - CollectiblesRepositoryModule, - MessagesRepositoryModule, - SafeAppsRepositoryModule, - SafeRepositoryModule, - TransactionsRepositoryModule, - QueuesRepositoryModule, - ], + imports: [HooksRepositoryModule], providers: [CacheHooksService], controllers: [CacheHooksController], }) diff --git a/src/routes/cache-hooks/cache-hooks.service.ts b/src/routes/cache-hooks/cache-hooks.service.ts index a045419661..c046d65df2 100644 --- a/src/routes/cache-hooks/cache-hooks.service.ts +++ b/src/routes/cache-hooks/cache-hooks.service.ts @@ -1,388 +1,15 @@ -import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; -import { IBalancesRepository } from '@/domain/balances/balances.repository.interface'; -import { IChainsRepository } from '@/domain/chains/chains.repository.interface'; -import { ICollectiblesRepository } from '@/domain/collectibles/collectibles.repository.interface'; -import { IMessagesRepository } from '@/domain/messages/messages.repository.interface'; -import { ISafeAppsRepository } from '@/domain/safe-apps/safe-apps.repository.interface'; -import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; -import { ITransactionsRepository } from '@/domain/transactions/transactions.repository.interface'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { LoggingService, ILoggingService } from '@/logging/logging.interface'; +import { Inject, Injectable } from '@nestjs/common'; import { Event } from '@/routes/cache-hooks/entities/event.entity'; -import { IConfigurationService } from '@/config/configuration.service.interface'; -import { IQueuesRepository } from '@/domain/queues/queues-repository.interface'; -import { ConsumeMessage } from 'amqplib'; -import { WebHookSchema } from '@/routes/cache-hooks/entities/schemas/web-hook.schema'; -import { IBlockchainRepository } from '@/domain/blockchain/blockchain.repository.interface'; +import { IHooksRepository } from '@/domain/hooks/hooks.repository.interface'; @Injectable() -export class CacheHooksService implements OnModuleInit { - private static readonly HOOK_TYPE = 'hook'; - private readonly queueName: string; - +export class CacheHooksService { constructor( - @Inject(IBalancesRepository) - private readonly balancesRepository: IBalancesRepository, - @Inject(IBlockchainRepository) - private readonly blockchainRepository: IBlockchainRepository, - @Inject(IChainsRepository) - private readonly chainsRepository: IChainsRepository, - @Inject(ICollectiblesRepository) - private readonly collectiblesRepository: ICollectiblesRepository, - @Inject(IMessagesRepository) - private readonly messagesRepository: IMessagesRepository, - @Inject(ISafeAppsRepository) - private readonly safeAppsRepository: ISafeAppsRepository, - @Inject(ISafeRepository) - private readonly safeRepository: ISafeRepository, - @Inject(ITransactionsRepository) - private readonly transactionsRepository: ITransactionsRepository, - @Inject(LoggingService) - private readonly loggingService: ILoggingService, - @Inject(IQueuesRepository) - private readonly queuesRepository: IQueuesRepository, - @Inject(IConfigurationService) - private readonly configurationService: IConfigurationService, - ) { - this.queueName = this.configurationService.getOrThrow('amqp.queue'); - } - - onModuleInit(): Promise { - return this.queuesRepository.subscribe( - this.queueName, - async (msg: ConsumeMessage) => { - try { - const content = JSON.parse(msg.content.toString()); - const event: Event = WebHookSchema.parse(content); - await this.onEvent(event); - } catch (err) { - this.loggingService.error(err); - } - }, - ); - } - - async onEvent(event: Event): Promise { - const promises: Promise[] = []; - switch (event.type) { - // A new pending multisig transaction affects: - // queued transactions – clear multisig transactions - // the pending transaction – clear multisig transaction - case EventType.PENDING_MULTISIG_TRANSACTION: - promises.push( - this.safeRepository.clearMultisigTransactions({ - chainId: event.chainId, - safeAddress: event.address, - }), - this.safeRepository.clearMultisigTransaction({ - chainId: event.chainId, - safeTransactionHash: event.safeTxHash, - }), - ); - this._logSafeTxEvent(event); - break; - // A deleted multisig transaction affects: - // queued transactions – clear multisig transactions - // the pending transaction – clear multisig transaction - case EventType.DELETED_MULTISIG_TRANSACTION: - promises.push( - this.safeRepository.clearMultisigTransactions({ - chainId: event.chainId, - safeAddress: event.address, - }), - this.safeRepository.clearMultisigTransaction({ - chainId: event.chainId, - safeTransactionHash: event.safeTxHash, - }), - ); - this._logSafeTxEvent(event); - break; - // An executed module transaction might affect: - // - the list of all executed transactions for the safe - // - the list of module transactions for the safe - // - the safe configuration - case EventType.MODULE_TRANSACTION: - promises.push( - this.safeRepository.clearAllExecutedTransactions({ - chainId: event.chainId, - safeAddress: event.address, - }), - this.safeRepository.clearModuleTransactions({ - chainId: event.chainId, - safeAddress: event.address, - }), - this.safeRepository.clearSafe({ - chainId: event.chainId, - address: event.address, - }), - ); - this._logTxEvent(event); - break; - // A new executed multisig transaction affects: - // - the collectibles that the safe has - // - the list of all executed transactions for the safe - // - the transfers for that safe - // - queued transactions and history – clear multisig transactions - // - the transaction executed – clear multisig transaction - // - the safe configuration - clear safe info - case EventType.EXECUTED_MULTISIG_TRANSACTION: - promises.push( - this.collectiblesRepository.clearCollectibles({ - chainId: event.chainId, - safeAddress: event.address, - }), - this.safeRepository.clearAllExecutedTransactions({ - chainId: event.chainId, - safeAddress: event.address, - }), - this.safeRepository.clearTransfers({ - chainId: event.chainId, - safeAddress: event.address, - }), - this.safeRepository.clearMultisigTransactions({ - chainId: event.chainId, - safeAddress: event.address, - }), - this.safeRepository.clearMultisigTransaction({ - chainId: event.chainId, - safeTransactionHash: event.safeTxHash, - }), - this.safeRepository.clearSafe({ - chainId: event.chainId, - address: event.address, - }), - ); - this._logSafeTxEvent(event); - break; - // A new confirmation for a pending transaction affects: - // - queued transactions – clear multisig transactions - // - the pending transaction – clear multisig transaction - case EventType.NEW_CONFIRMATION: - promises.push( - this.safeRepository.clearMultisigTransactions({ - chainId: event.chainId, - safeAddress: event.address, - }), - this.safeRepository.clearMultisigTransaction({ - chainId: event.chainId, - safeTransactionHash: event.safeTxHash, - }), - ); - this._logSafeTxEvent(event); - break; - // Incoming ether affects: - // - the balance of the safe - clear safe balance - // - the list of all executed transactions (including transfers) for the safe - // - the incoming transfers for that safe - case EventType.INCOMING_ETHER: - promises.push( - this.balancesRepository.clearBalances({ - chainId: event.chainId, - safeAddress: event.address, - }), - this.safeRepository.clearAllExecutedTransactions({ - chainId: event.chainId, - safeAddress: event.address, - }), - this.safeRepository.clearMultisigTransactions({ - chainId: event.chainId, - safeAddress: event.address, - }), - this.safeRepository.clearTransfers({ - chainId: event.chainId, - safeAddress: event.address, - }), - this.safeRepository.clearIncomingTransfers({ - chainId: event.chainId, - safeAddress: event.address, - }), - ); - this._logTxEvent(event); - break; - // Outgoing ether affects: - // - the balance of the safe - clear safe balance - // - the list of all executed transactions for the safe - // - queued transactions and history – clear multisig transactions - // - the transfers for that safe - case EventType.OUTGOING_ETHER: - promises.push( - this.balancesRepository.clearBalances({ - chainId: event.chainId, - safeAddress: event.address, - }), - this.safeRepository.clearAllExecutedTransactions({ - chainId: event.chainId, - safeAddress: event.address, - }), - this.safeRepository.clearMultisigTransactions({ - chainId: event.chainId, - safeAddress: event.address, - }), - this.safeRepository.clearTransfers({ - chainId: event.chainId, - safeAddress: event.address, - }), - ); - this._logTxEvent(event); - break; - // An incoming token affects: - // - the balance of the safe - clear safe balance - // - the collectibles that the safe has - // - the list of all executed transactions (including transfers) for the safe - // - queued transactions and history – clear multisig transactions - // - the transfers for that safe - // - the incoming transfers for that safe - case EventType.INCOMING_TOKEN: - promises.push( - this.balancesRepository.clearBalances({ - chainId: event.chainId, - safeAddress: event.address, - }), - this.collectiblesRepository.clearCollectibles({ - chainId: event.chainId, - safeAddress: event.address, - }), - this.safeRepository.clearAllExecutedTransactions({ - chainId: event.chainId, - safeAddress: event.address, - }), - this.safeRepository.clearMultisigTransactions({ - chainId: event.chainId, - safeAddress: event.address, - }), - this.safeRepository.clearTransfers({ - chainId: event.chainId, - safeAddress: event.address, - }), - this.safeRepository.clearIncomingTransfers({ - chainId: event.chainId, - safeAddress: event.address, - }), - ); - this._logTxEvent(event); - break; - // An outgoing token affects: - // - the balance of the safe - clear safe balance - // - the collectibles that the safe has - // - the list of all executed transactions (including transfers) for the safe - // - queued transactions and history – clear multisig transactions - // - the transfers for that safe - case EventType.OUTGOING_TOKEN: - promises.push( - this.balancesRepository.clearBalances({ - chainId: event.chainId, - safeAddress: event.address, - }), - this.collectiblesRepository.clearCollectibles({ - chainId: event.chainId, - safeAddress: event.address, - }), - this.safeRepository.clearAllExecutedTransactions({ - chainId: event.chainId, - safeAddress: event.address, - }), - this.safeRepository.clearMultisigTransactions({ - chainId: event.chainId, - safeAddress: event.address, - }), - this.safeRepository.clearTransfers({ - chainId: event.chainId, - safeAddress: event.address, - }), - ); - this._logTxEvent(event); - break; - // A message created affects: - // - the messages associated to the Safe - case EventType.MESSAGE_CREATED: - promises.push( - this.messagesRepository.clearMessagesBySafe({ - chainId: event.chainId, - safeAddress: event.address, - }), - ); - this._logMessageEvent(event); - break; - // A new message confirmation affects: - // - the message itself - // - the messages associated to the Safe - case EventType.MESSAGE_CONFIRMATION: - promises.push( - this.messagesRepository.clearMessagesByHash({ - chainId: event.chainId, - messageHash: event.messageHash, - }), - this.messagesRepository.clearMessagesBySafe({ - chainId: event.chainId, - safeAddress: event.address, - }), - ); - this._logMessageEvent(event); - break; - case EventType.CHAIN_UPDATE: - promises.push( - this.chainsRepository.clearChain(event.chainId).then(() => { - // RPC may have changed - this.blockchainRepository.clearApi(event.chainId); - // Transaction Service may have changed - this.transactionsRepository.clearApi(event.chainId); - this.balancesRepository.clearApi(event.chainId); - }), - ); - this._logEvent(event); - break; - case EventType.SAFE_APPS_UPDATE: - promises.push(this.safeAppsRepository.clearSafeApps(event.chainId)); - this._logEvent(event); - break; - case EventType.SAFE_CREATED: - promises.push(this.safeRepository.clearIsSafe(event)); - break; - } - return Promise.all(promises); - } - - private _logSafeTxEvent( - event: Event & { address: string; safeTxHash: string }, - ): void { - this.loggingService.info({ - type: CacheHooksService.HOOK_TYPE, - eventType: event.type, - address: event.address, - chainId: event.chainId, - safeTxHash: event.safeTxHash, - }); - } - - private _logTxEvent( - event: Event & { address: string; txHash: string }, - ): void { - this.loggingService.info({ - type: CacheHooksService.HOOK_TYPE, - eventType: event.type, - address: event.address, - chainId: event.chainId, - txHash: event.txHash, - }); - } - - private _logMessageEvent( - event: Event & { address: string; messageHash: string }, - ): void { - this.loggingService.info({ - type: CacheHooksService.HOOK_TYPE, - eventType: event.type, - address: event.address, - chainId: event.chainId, - messageHash: event.messageHash, - }); - } + @Inject(IHooksRepository) + private readonly hooksRepository: IHooksRepository, + ) {} - private _logEvent(event: Event): void { - this.loggingService.info({ - type: CacheHooksService.HOOK_TYPE, - eventType: event.type, - chainId: event.chainId, - }); + async onEvent(event: Event): Promise { + return this.hooksRepository.onEvent(event); } } From fe03ec13db87aa8ad0abb39cc912e9e668b8a4d4 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Fri, 19 Jul 2024 12:15:28 +0200 Subject: [PATCH 195/207] Generify hook-related cache invalidation (#1770) Refactor route "cache-hooks" to be a more generic, also adding distinction between Transaction Service and Config. Service events: - Rename `cache-hooks` to `hook` and the associated controller, service(s) and module accordingly - Separate `EventType` into `TransactionEventType` and `ConfigEventType` and propagate changes --- src/app.module.ts | 4 +- .../hooks/hooks.repository.interface.ts | 2 +- src/domain/hooks/hooks.repository.ts | 67 ++++++++++--------- src/routes/cache-hooks/cache-hooks.module.ts | 11 --- .../entities/__tests__/web-hook.builder.ts | 0 .../entities/chain-update.entity.ts | 4 -- .../cache-hooks/entities/event.entity.ts | 4 -- .../entities/executed-transaction.entity.ts | 6 -- .../entities/incoming-ether.entity.ts | 4 -- .../entities/incoming-token.entity.ts | 4 -- .../entities/message-created.entity.ts | 4 -- .../entities/module-transaction.entity.ts | 4 -- .../entities/new-confirmation.entity.ts | 4 -- .../new-message-confirmation.entity.ts | 6 -- .../entities/outgoing-ether.entity.ts | 4 -- .../entities/outgoing-token.entity.ts | 4 -- .../entities/pending-transaction.entity.ts | 4 -- .../entities/safe-apps-update.entity.ts | 4 -- .../entities/safe-created.entity.ts | 4 -- .../entities/schemas/web-hook.schema.ts | 32 --------- .../__tests__/event-hooks-queue.e2e-spec.ts | 0 .../__tests__/chain-update.builder.ts | 6 +- .../deleted-multisig-transaction.builder.ts | 6 +- .../__tests__/executed-transaction.builder.ts | 6 +- .../__tests__/incoming-ether.builder.ts | 6 +- .../__tests__/incoming-token.builder.ts | 6 +- .../__tests__/message-created.builder.ts | 6 +- .../__tests__/module-transaction.builder.ts | 6 +- .../__tests__/new-confirmation.builder.ts | 6 +- .../new-message-confirmation.builder.ts | 6 +- .../__tests__/outgoing-ether.builder.ts | 6 +- .../__tests__/outgoing-token.builder.ts | 6 +- .../__tests__/pending-transaction.builder.ts | 6 +- .../__tests__/safe-apps-update.builder.ts | 6 +- .../entities/__tests__/safe-created.build.ts | 6 +- .../hooks/entities/chain-update.entity.ts | 4 ++ .../deleted-multisig-transaction.entity.ts | 2 +- .../entities/event-type.entity.ts | 9 ++- src/routes/hooks/entities/event.entity.ts | 4 ++ .../entities/executed-transaction.entity.ts | 6 ++ .../hooks/entities/incoming-ether.entity.ts | 4 ++ .../hooks/entities/incoming-token.entity.ts | 4 ++ .../hooks/entities/message-created.entity.ts | 4 ++ .../entities/module-transaction.entity.ts | 4 ++ .../hooks/entities/new-confirmation.entity.ts | 4 ++ .../new-message-confirmation.entity.ts | 6 ++ .../hooks/entities/outgoing-ether.entity.ts | 4 ++ .../hooks/entities/outgoing-token.entity.ts | 4 ++ .../entities/pending-transaction.entity.ts | 4 ++ .../hooks/entities/safe-apps-update.entity.ts | 4 ++ .../hooks/entities/safe-created.entity.ts | 4 ++ .../__tests__/chain-update.schema.spec.ts | 8 +-- ...eleted-multisig-transaction.schema.spec.ts | 8 +-- .../schemas/__tests__/event.schema.spec.ts} | 34 +++++----- .../executed-transaction.schema.spec.ts | 8 +-- .../__tests__/incoming-ether.schema.spec.ts | 8 +-- .../__tests__/incoming-token.schema.spec.ts | 8 +-- .../__tests__/message-created.schema.spec.ts | 8 +-- .../module-transaction.schema.spec.ts | 11 +-- .../__tests__/new-confirmation.schema.spec.ts | 11 +-- .../new-message-confirmation.schema.spec.ts | 11 +-- .../__tests__/outgoing-ether.schema.spec.ts | 8 +-- .../__tests__/outgoing-token.schema.spec.ts | 8 +-- .../pending-transaction.schema.spec.ts | 8 +-- .../__tests__/safe-apps-update.schema.spec.ts | 8 +-- .../__tests__/safe-created.schema.spec.ts | 8 +-- .../entities/schemas/chain-update.schema.ts | 4 +- .../deleted-multisig-transaction.schema.ts | 4 +- .../hooks/entities/schemas/event.schema.ts | 32 +++++++++ .../schemas/executed-transaction.schema.ts | 4 +- .../entities/schemas/incoming-ether.schema.ts | 4 +- .../entities/schemas/incoming-token.schema.ts | 4 +- .../schemas/message-created.schema.ts | 4 +- .../schemas/module-transaction.schema.ts | 4 +- .../schemas/new-confirmation.schema.ts | 4 +- .../new-message-confirmation.schema.ts | 4 +- .../entities/schemas/outgoing-ether.schema.ts | 4 +- .../entities/schemas/outgoing-token.schema.ts | 4 +- .../schemas/pending-transaction.schema.ts | 4 +- .../schemas/safe-apps-update.schema.ts | 4 +- .../entities/schemas/safe-created.schema.ts | 4 +- .../errors/event-protocol-changed.error.ts | 0 .../filters/event-protocol-changed.filter.ts | 2 +- .../hooks-cache.controller.spec.ts} | 4 +- .../hooks.controller.ts} | 28 ++++---- src/routes/hooks/hooks.module.ts | 11 +++ .../hooks.service.ts} | 4 +- 87 files changed, 318 insertions(+), 305 deletions(-) delete mode 100644 src/routes/cache-hooks/cache-hooks.module.ts delete mode 100644 src/routes/cache-hooks/entities/__tests__/web-hook.builder.ts delete mode 100644 src/routes/cache-hooks/entities/chain-update.entity.ts delete mode 100644 src/routes/cache-hooks/entities/event.entity.ts delete mode 100644 src/routes/cache-hooks/entities/executed-transaction.entity.ts delete mode 100644 src/routes/cache-hooks/entities/incoming-ether.entity.ts delete mode 100644 src/routes/cache-hooks/entities/incoming-token.entity.ts delete mode 100644 src/routes/cache-hooks/entities/message-created.entity.ts delete mode 100644 src/routes/cache-hooks/entities/module-transaction.entity.ts delete mode 100644 src/routes/cache-hooks/entities/new-confirmation.entity.ts delete mode 100644 src/routes/cache-hooks/entities/new-message-confirmation.entity.ts delete mode 100644 src/routes/cache-hooks/entities/outgoing-ether.entity.ts delete mode 100644 src/routes/cache-hooks/entities/outgoing-token.entity.ts delete mode 100644 src/routes/cache-hooks/entities/pending-transaction.entity.ts delete mode 100644 src/routes/cache-hooks/entities/safe-apps-update.entity.ts delete mode 100644 src/routes/cache-hooks/entities/safe-created.entity.ts delete mode 100644 src/routes/cache-hooks/entities/schemas/web-hook.schema.ts rename src/routes/{cache-hooks => hooks}/__tests__/event-hooks-queue.e2e-spec.ts (100%) rename src/routes/{cache-hooks => hooks}/entities/__tests__/chain-update.builder.ts (58%) rename src/routes/{cache-hooks => hooks}/entities/__tests__/deleted-multisig-transaction.builder.ts (63%) rename src/routes/{cache-hooks => hooks}/entities/__tests__/executed-transaction.builder.ts (66%) rename src/routes/{cache-hooks => hooks}/entities/__tests__/incoming-ether.builder.ts (67%) rename src/routes/{cache-hooks => hooks}/entities/__tests__/incoming-token.builder.ts (68%) rename src/routes/{cache-hooks => hooks}/entities/__tests__/message-created.builder.ts (65%) rename src/routes/{cache-hooks => hooks}/entities/__tests__/module-transaction.builder.ts (67%) rename src/routes/{cache-hooks => hooks}/entities/__tests__/new-confirmation.builder.ts (68%) rename src/routes/{cache-hooks => hooks}/entities/__tests__/new-message-confirmation.builder.ts (64%) rename src/routes/{cache-hooks => hooks}/entities/__tests__/outgoing-ether.builder.ts (67%) rename src/routes/{cache-hooks => hooks}/entities/__tests__/outgoing-token.builder.ts (68%) rename src/routes/{cache-hooks => hooks}/entities/__tests__/pending-transaction.builder.ts (63%) rename src/routes/{cache-hooks => hooks}/entities/__tests__/safe-apps-update.builder.ts (57%) rename src/routes/{cache-hooks => hooks}/entities/__tests__/safe-created.build.ts (65%) create mode 100644 src/routes/hooks/entities/chain-update.entity.ts rename src/routes/{cache-hooks => hooks}/entities/deleted-multisig-transaction.entity.ts (73%) rename src/routes/{cache-hooks => hooks}/entities/event-type.entity.ts (90%) create mode 100644 src/routes/hooks/entities/event.entity.ts create mode 100644 src/routes/hooks/entities/executed-transaction.entity.ts create mode 100644 src/routes/hooks/entities/incoming-ether.entity.ts create mode 100644 src/routes/hooks/entities/incoming-token.entity.ts create mode 100644 src/routes/hooks/entities/message-created.entity.ts create mode 100644 src/routes/hooks/entities/module-transaction.entity.ts create mode 100644 src/routes/hooks/entities/new-confirmation.entity.ts create mode 100644 src/routes/hooks/entities/new-message-confirmation.entity.ts create mode 100644 src/routes/hooks/entities/outgoing-ether.entity.ts create mode 100644 src/routes/hooks/entities/outgoing-token.entity.ts create mode 100644 src/routes/hooks/entities/pending-transaction.entity.ts create mode 100644 src/routes/hooks/entities/safe-apps-update.entity.ts create mode 100644 src/routes/hooks/entities/safe-created.entity.ts rename src/routes/{cache-hooks => hooks}/entities/schemas/__tests__/chain-update.schema.spec.ts (80%) rename src/routes/{cache-hooks => hooks}/entities/schemas/__tests__/deleted-multisig-transaction.schema.spec.ts (91%) rename src/routes/{cache-hooks/entities/schemas/__tests__/web-hook.schema.spec.ts => hooks/entities/schemas/__tests__/event.schema.spec.ts} (55%) rename src/routes/{cache-hooks => hooks}/entities/schemas/__tests__/executed-transaction.schema.spec.ts (87%) rename src/routes/{cache-hooks => hooks}/entities/schemas/__tests__/incoming-ether.schema.spec.ts (87%) rename src/routes/{cache-hooks => hooks}/entities/schemas/__tests__/incoming-token.schema.spec.ts (87%) rename src/routes/{cache-hooks => hooks}/entities/schemas/__tests__/message-created.schema.spec.ts (87%) rename src/routes/{cache-hooks => hooks}/entities/schemas/__tests__/module-transaction.schema.spec.ts (87%) rename src/routes/{cache-hooks => hooks}/entities/schemas/__tests__/new-confirmation.schema.spec.ts (87%) rename src/routes/{cache-hooks => hooks}/entities/schemas/__tests__/new-message-confirmation.schema.spec.ts (86%) rename src/routes/{cache-hooks => hooks}/entities/schemas/__tests__/outgoing-ether.schema.spec.ts (87%) rename src/routes/{cache-hooks => hooks}/entities/schemas/__tests__/outgoing-token.schema.spec.ts (87%) rename src/routes/{cache-hooks => hooks}/entities/schemas/__tests__/pending-transaction.schema.spec.ts (87%) rename src/routes/{cache-hooks => hooks}/entities/schemas/__tests__/safe-apps-update.schema.spec.ts (80%) rename src/routes/{cache-hooks => hooks}/entities/schemas/__tests__/safe-created.schema.spec.ts (84%) rename src/routes/{cache-hooks => hooks}/entities/schemas/chain-update.schema.ts (50%) rename src/routes/{cache-hooks => hooks}/entities/schemas/deleted-multisig-transaction.schema.ts (61%) create mode 100644 src/routes/hooks/entities/schemas/event.schema.ts rename src/routes/{cache-hooks => hooks}/entities/schemas/executed-transaction.schema.ts (63%) rename src/routes/{cache-hooks => hooks}/entities/schemas/incoming-ether.schema.ts (64%) rename src/routes/{cache-hooks => hooks}/entities/schemas/incoming-token.schema.ts (65%) rename src/routes/{cache-hooks => hooks}/entities/schemas/message-created.schema.ts (62%) rename src/routes/{cache-hooks => hooks}/entities/schemas/module-transaction.schema.ts (64%) rename src/routes/{cache-hooks => hooks}/entities/schemas/new-confirmation.schema.ts (65%) rename src/routes/{cache-hooks => hooks}/entities/schemas/new-message-confirmation.schema.ts (62%) rename src/routes/{cache-hooks => hooks}/entities/schemas/outgoing-ether.schema.ts (64%) rename src/routes/{cache-hooks => hooks}/entities/schemas/outgoing-token.schema.ts (65%) rename src/routes/{cache-hooks => hooks}/entities/schemas/pending-transaction.schema.ts (61%) rename src/routes/{cache-hooks => hooks}/entities/schemas/safe-apps-update.schema.ts (50%) rename src/routes/{cache-hooks => hooks}/entities/schemas/safe-created.schema.ts (63%) rename src/routes/{cache-hooks => hooks}/errors/event-protocol-changed.error.ts (100%) rename src/routes/{cache-hooks => hooks}/filters/event-protocol-changed.filter.ts (84%) rename src/routes/{cache-hooks/cache-hooks.controller.spec.ts => hooks/hooks-cache.controller.spec.ts} (99%) rename src/routes/{cache-hooks/cache-hooks.controller.ts => hooks/hooks.controller.ts} (58%) create mode 100644 src/routes/hooks/hooks.module.ts rename src/routes/{cache-hooks/cache-hooks.service.ts => hooks/hooks.service.ts} (78%) diff --git a/src/app.module.ts b/src/app.module.ts index ebd37b5fad..ff4640830f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -13,12 +13,12 @@ import { BalancesModule } from '@/routes/balances/balances.module'; import { NetworkModule } from '@/datasources/network/network.module'; import { ConfigurationModule } from '@/config/configuration.module'; import { CacheModule } from '@/datasources/cache/cache.module'; -import { CacheHooksModule } from '@/routes/cache-hooks/cache-hooks.module'; import { CollectiblesModule } from '@/routes/collectibles/collectibles.module'; import { CommunityModule } from '@/routes/community/community.module'; import { ContractsModule } from '@/routes/contracts/contracts.module'; import { DataDecodedModule } from '@/routes/data-decode/data-decoded.module'; import { DelegatesModule } from '@/routes/delegates/delegates.module'; +import { HooksModule } from '@/routes/hooks/hooks.module'; import { SafeAppsModule } from '@/routes/safe-apps/safe-apps.module'; import { HealthModule } from '@/routes/health/health.module'; import { OwnersModule } from '@/routes/owners/owners.module'; @@ -68,7 +68,6 @@ export class AppModule implements NestModule { ...(isAccountsFeatureEnabled ? [AccountsModule] : []), ...(isAuthFeatureEnabled ? [AuthModule] : []), BalancesModule, - CacheHooksModule, ChainsModule, CollectiblesModule, CommunityModule, @@ -83,6 +82,7 @@ export class AppModule implements NestModule { : []), EstimationsModule, HealthModule, + HooksModule, MessagesModule, NotificationsModule, OwnersModule, diff --git a/src/domain/hooks/hooks.repository.interface.ts b/src/domain/hooks/hooks.repository.interface.ts index 61390212ad..e897eba17a 100644 --- a/src/domain/hooks/hooks.repository.interface.ts +++ b/src/domain/hooks/hooks.repository.interface.ts @@ -8,7 +8,7 @@ import { QueuesRepositoryModule } from '@/domain/queues/queues-repository.interf import { SafeAppsRepositoryModule } from '@/domain/safe-apps/safe-apps.repository.interface'; import { SafeRepositoryModule } from '@/domain/safe/safe.repository.interface'; import { TransactionsRepositoryModule } from '@/domain/transactions/transactions.repository.interface'; -import { Event } from '@/routes/cache-hooks/entities/event.entity'; +import { Event } from '@/routes/hooks/entities/event.entity'; import { Module } from '@nestjs/common'; export const IHooksRepository = Symbol('IHooksRepository'); diff --git a/src/domain/hooks/hooks.repository.ts b/src/domain/hooks/hooks.repository.ts index 06ec8f6498..6ad5a18ecb 100644 --- a/src/domain/hooks/hooks.repository.ts +++ b/src/domain/hooks/hooks.repository.ts @@ -6,13 +6,16 @@ import { IMessagesRepository } from '@/domain/messages/messages.repository.inter import { ISafeAppsRepository } from '@/domain/safe-apps/safe-apps.repository.interface'; import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; import { ITransactionsRepository } from '@/domain/transactions/transactions.repository.interface'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; +import { + TransactionEventType, + ConfigEventType, +} from '@/routes/hooks/entities/event-type.entity'; import { LoggingService, ILoggingService } from '@/logging/logging.interface'; -import { Event } from '@/routes/cache-hooks/entities/event.entity'; +import { Event } from '@/routes/hooks/entities/event.entity'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { IQueuesRepository } from '@/domain/queues/queues-repository.interface'; import { ConsumeMessage } from 'amqplib'; -import { WebHookSchema } from '@/routes/cache-hooks/entities/schemas/web-hook.schema'; +import { EventSchema } from '@/routes/hooks/entities/schemas/event.schema'; import { IBlockchainRepository } from '@/domain/blockchain/blockchain.repository.interface'; import { IHooksRepository } from '@/domain/hooks/hooks.repository.interface'; @@ -54,7 +57,7 @@ export class HooksRepository implements IHooksRepository { async (msg: ConsumeMessage) => { try { const content = JSON.parse(msg.content.toString()); - const event: Event = WebHookSchema.parse(content); + const event: Event = EventSchema.parse(content); await this.onEvent(event); } catch (err) { this.loggingService.error(err); @@ -75,7 +78,7 @@ export class HooksRepository implements IHooksRepository { // A new pending multisig transaction affects: // queued transactions – clear multisig transactions // the pending transaction – clear multisig transaction - case EventType.PENDING_MULTISIG_TRANSACTION: + case TransactionEventType.PENDING_MULTISIG_TRANSACTION: promises.push( this.safeRepository.clearMultisigTransactions({ chainId: event.chainId, @@ -90,7 +93,7 @@ export class HooksRepository implements IHooksRepository { // A deleted multisig transaction affects: // queued transactions – clear multisig transactions // the pending transaction – clear multisig transaction - case EventType.DELETED_MULTISIG_TRANSACTION: + case TransactionEventType.DELETED_MULTISIG_TRANSACTION: promises.push( this.safeRepository.clearMultisigTransactions({ chainId: event.chainId, @@ -106,7 +109,7 @@ export class HooksRepository implements IHooksRepository { // - the list of all executed transactions for the safe // - the list of module transactions for the safe // - the safe configuration - case EventType.MODULE_TRANSACTION: + case TransactionEventType.MODULE_TRANSACTION: promises.push( this.safeRepository.clearAllExecutedTransactions({ chainId: event.chainId, @@ -129,7 +132,7 @@ export class HooksRepository implements IHooksRepository { // - queued transactions and history – clear multisig transactions // - the transaction executed – clear multisig transaction // - the safe configuration - clear safe info - case EventType.EXECUTED_MULTISIG_TRANSACTION: + case TransactionEventType.EXECUTED_MULTISIG_TRANSACTION: promises.push( this.collectiblesRepository.clearCollectibles({ chainId: event.chainId, @@ -160,7 +163,7 @@ export class HooksRepository implements IHooksRepository { // A new confirmation for a pending transaction affects: // - queued transactions – clear multisig transactions // - the pending transaction – clear multisig transaction - case EventType.NEW_CONFIRMATION: + case TransactionEventType.NEW_CONFIRMATION: promises.push( this.safeRepository.clearMultisigTransactions({ chainId: event.chainId, @@ -176,7 +179,7 @@ export class HooksRepository implements IHooksRepository { // - the balance of the safe - clear safe balance // - the list of all executed transactions (including transfers) for the safe // - the incoming transfers for that safe - case EventType.INCOMING_ETHER: + case TransactionEventType.INCOMING_ETHER: promises.push( this.balancesRepository.clearBalances({ chainId: event.chainId, @@ -205,7 +208,7 @@ export class HooksRepository implements IHooksRepository { // - the list of all executed transactions for the safe // - queued transactions and history – clear multisig transactions // - the transfers for that safe - case EventType.OUTGOING_ETHER: + case TransactionEventType.OUTGOING_ETHER: promises.push( this.balancesRepository.clearBalances({ chainId: event.chainId, @@ -232,7 +235,7 @@ export class HooksRepository implements IHooksRepository { // - queued transactions and history – clear multisig transactions // - the transfers for that safe // - the incoming transfers for that safe - case EventType.INCOMING_TOKEN: + case TransactionEventType.INCOMING_TOKEN: promises.push( this.balancesRepository.clearBalances({ chainId: event.chainId, @@ -266,7 +269,7 @@ export class HooksRepository implements IHooksRepository { // - the list of all executed transactions (including transfers) for the safe // - queued transactions and history – clear multisig transactions // - the transfers for that safe - case EventType.OUTGOING_TOKEN: + case TransactionEventType.OUTGOING_TOKEN: promises.push( this.balancesRepository.clearBalances({ chainId: event.chainId, @@ -292,7 +295,7 @@ export class HooksRepository implements IHooksRepository { break; // A message created affects: // - the messages associated to the Safe - case EventType.MESSAGE_CREATED: + case TransactionEventType.MESSAGE_CREATED: promises.push( this.messagesRepository.clearMessagesBySafe({ chainId: event.chainId, @@ -303,7 +306,7 @@ export class HooksRepository implements IHooksRepository { // A new message confirmation affects: // - the message itself // - the messages associated to the Safe - case EventType.MESSAGE_CONFIRMATION: + case TransactionEventType.MESSAGE_CONFIRMATION: promises.push( this.messagesRepository.clearMessagesByHash({ chainId: event.chainId, @@ -315,7 +318,7 @@ export class HooksRepository implements IHooksRepository { }), ); break; - case EventType.CHAIN_UPDATE: + case ConfigEventType.CHAIN_UPDATE: promises.push( this.chainsRepository.clearChain(event.chainId).then(() => { // RPC may have changed @@ -326,10 +329,10 @@ export class HooksRepository implements IHooksRepository { }), ); break; - case EventType.SAFE_APPS_UPDATE: + case ConfigEventType.SAFE_APPS_UPDATE: promises.push(this.safeAppsRepository.clearSafeApps(event.chainId)); break; - case EventType.SAFE_CREATED: + case TransactionEventType.SAFE_CREATED: promises.push(this.safeRepository.clearIsSafe(event)); break; } @@ -338,28 +341,28 @@ export class HooksRepository implements IHooksRepository { private onEventLog(event: Event): void { switch (event.type) { - case EventType.PENDING_MULTISIG_TRANSACTION: - case EventType.DELETED_MULTISIG_TRANSACTION: - case EventType.EXECUTED_MULTISIG_TRANSACTION: - case EventType.NEW_CONFIRMATION: + case TransactionEventType.PENDING_MULTISIG_TRANSACTION: + case TransactionEventType.DELETED_MULTISIG_TRANSACTION: + case TransactionEventType.EXECUTED_MULTISIG_TRANSACTION: + case TransactionEventType.NEW_CONFIRMATION: this._logSafeTxEvent(event); break; - case EventType.MODULE_TRANSACTION: - case EventType.INCOMING_ETHER: - case EventType.OUTGOING_ETHER: - case EventType.INCOMING_TOKEN: - case EventType.OUTGOING_TOKEN: + case TransactionEventType.MODULE_TRANSACTION: + case TransactionEventType.INCOMING_ETHER: + case TransactionEventType.OUTGOING_ETHER: + case TransactionEventType.INCOMING_TOKEN: + case TransactionEventType.OUTGOING_TOKEN: this._logTxEvent(event); break; - case EventType.MESSAGE_CREATED: - case EventType.MESSAGE_CONFIRMATION: + case TransactionEventType.MESSAGE_CREATED: + case TransactionEventType.MESSAGE_CONFIRMATION: this._logMessageEvent(event); break; - case EventType.CHAIN_UPDATE: - case EventType.SAFE_APPS_UPDATE: + case ConfigEventType.CHAIN_UPDATE: + case ConfigEventType.SAFE_APPS_UPDATE: this._logEvent(event); break; - case EventType.SAFE_CREATED: + case TransactionEventType.SAFE_CREATED: break; } } diff --git a/src/routes/cache-hooks/cache-hooks.module.ts b/src/routes/cache-hooks/cache-hooks.module.ts deleted file mode 100644 index 499989ee44..0000000000 --- a/src/routes/cache-hooks/cache-hooks.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from '@nestjs/common'; -import { CacheHooksController } from '@/routes/cache-hooks/cache-hooks.controller'; -import { HooksRepositoryModule } from '@/domain/hooks/hooks.repository.interface'; -import { CacheHooksService } from '@/routes/cache-hooks/cache-hooks.service'; - -@Module({ - imports: [HooksRepositoryModule], - providers: [CacheHooksService], - controllers: [CacheHooksController], -}) -export class CacheHooksModule {} diff --git a/src/routes/cache-hooks/entities/__tests__/web-hook.builder.ts b/src/routes/cache-hooks/entities/__tests__/web-hook.builder.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/routes/cache-hooks/entities/chain-update.entity.ts b/src/routes/cache-hooks/entities/chain-update.entity.ts deleted file mode 100644 index 539709240e..0000000000 --- a/src/routes/cache-hooks/entities/chain-update.entity.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { ChainUpdateEventSchema } from '@/routes/cache-hooks/entities/schemas/chain-update.schema'; -import { z } from 'zod'; - -export type ChainUpdate = z.infer; diff --git a/src/routes/cache-hooks/entities/event.entity.ts b/src/routes/cache-hooks/entities/event.entity.ts deleted file mode 100644 index 1b72152542..0000000000 --- a/src/routes/cache-hooks/entities/event.entity.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { WebHookSchema } from '@/routes/cache-hooks/entities/schemas/web-hook.schema'; -import { z } from 'zod'; - -export type Event = z.infer; diff --git a/src/routes/cache-hooks/entities/executed-transaction.entity.ts b/src/routes/cache-hooks/entities/executed-transaction.entity.ts deleted file mode 100644 index 209aab7686..0000000000 --- a/src/routes/cache-hooks/entities/executed-transaction.entity.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ExecutedTransactionEventSchema } from '@/routes/cache-hooks/entities/schemas/executed-transaction.schema'; -import { z } from 'zod'; - -export type ExecutedTransaction = z.infer< - typeof ExecutedTransactionEventSchema ->; diff --git a/src/routes/cache-hooks/entities/incoming-ether.entity.ts b/src/routes/cache-hooks/entities/incoming-ether.entity.ts deleted file mode 100644 index 46ee6f93aa..0000000000 --- a/src/routes/cache-hooks/entities/incoming-ether.entity.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { IncomingEtherEventSchema } from '@/routes/cache-hooks/entities/schemas/incoming-ether.schema'; -import { z } from 'zod'; - -export type IncomingEther = z.infer; diff --git a/src/routes/cache-hooks/entities/incoming-token.entity.ts b/src/routes/cache-hooks/entities/incoming-token.entity.ts deleted file mode 100644 index d7da04d275..0000000000 --- a/src/routes/cache-hooks/entities/incoming-token.entity.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { IncomingTokenEventSchema } from '@/routes/cache-hooks/entities/schemas/incoming-token.schema'; -import { z } from 'zod'; - -export type IncomingToken = z.infer; diff --git a/src/routes/cache-hooks/entities/message-created.entity.ts b/src/routes/cache-hooks/entities/message-created.entity.ts deleted file mode 100644 index 6d976c7df8..0000000000 --- a/src/routes/cache-hooks/entities/message-created.entity.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { MessageCreatedEventSchema } from '@/routes/cache-hooks/entities/schemas/message-created.schema'; -import { z } from 'zod'; - -export type MessageCreated = z.infer; diff --git a/src/routes/cache-hooks/entities/module-transaction.entity.ts b/src/routes/cache-hooks/entities/module-transaction.entity.ts deleted file mode 100644 index ad5d0579db..0000000000 --- a/src/routes/cache-hooks/entities/module-transaction.entity.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { ModuleTransactionEventSchema } from '@/routes/cache-hooks/entities/schemas/module-transaction.schema'; -import { z } from 'zod'; - -export type ModuleTransaction = z.infer; diff --git a/src/routes/cache-hooks/entities/new-confirmation.entity.ts b/src/routes/cache-hooks/entities/new-confirmation.entity.ts deleted file mode 100644 index d43097d8a4..0000000000 --- a/src/routes/cache-hooks/entities/new-confirmation.entity.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { NewConfirmationEventSchema } from '@/routes/cache-hooks/entities/schemas/new-confirmation.schema'; -import { z } from 'zod'; - -export type NewConfirmation = z.infer; diff --git a/src/routes/cache-hooks/entities/new-message-confirmation.entity.ts b/src/routes/cache-hooks/entities/new-message-confirmation.entity.ts deleted file mode 100644 index 282ea9fc01..0000000000 --- a/src/routes/cache-hooks/entities/new-message-confirmation.entity.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { NewMessageConfirmationEventSchema } from '@/routes/cache-hooks/entities/schemas/new-message-confirmation.schema'; -import { z } from 'zod'; - -export type NewMessageConfirmation = z.infer< - typeof NewMessageConfirmationEventSchema ->; diff --git a/src/routes/cache-hooks/entities/outgoing-ether.entity.ts b/src/routes/cache-hooks/entities/outgoing-ether.entity.ts deleted file mode 100644 index cd2bc6a9d9..0000000000 --- a/src/routes/cache-hooks/entities/outgoing-ether.entity.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { OutgoingEtherEventSchema } from '@/routes/cache-hooks/entities/schemas/outgoing-ether.schema'; -import { z } from 'zod'; - -export type OutgoingEther = z.infer; diff --git a/src/routes/cache-hooks/entities/outgoing-token.entity.ts b/src/routes/cache-hooks/entities/outgoing-token.entity.ts deleted file mode 100644 index ef79968a0f..0000000000 --- a/src/routes/cache-hooks/entities/outgoing-token.entity.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { OutgoingTokenEventSchema } from '@/routes/cache-hooks/entities/schemas/outgoing-token.schema'; -import { z } from 'zod'; - -export type OutgoingToken = z.infer; diff --git a/src/routes/cache-hooks/entities/pending-transaction.entity.ts b/src/routes/cache-hooks/entities/pending-transaction.entity.ts deleted file mode 100644 index a7593994af..0000000000 --- a/src/routes/cache-hooks/entities/pending-transaction.entity.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PendingTransactionEventSchema } from '@/routes/cache-hooks/entities/schemas/pending-transaction.schema'; -import { z } from 'zod'; - -export type PendingTransaction = z.infer; diff --git a/src/routes/cache-hooks/entities/safe-apps-update.entity.ts b/src/routes/cache-hooks/entities/safe-apps-update.entity.ts deleted file mode 100644 index 3d18b7b576..0000000000 --- a/src/routes/cache-hooks/entities/safe-apps-update.entity.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { SafeAppsUpdateEventSchema } from '@/routes/cache-hooks/entities/schemas/safe-apps-update.schema'; -import { z } from 'zod'; - -export type SafeAppsUpdate = z.infer; diff --git a/src/routes/cache-hooks/entities/safe-created.entity.ts b/src/routes/cache-hooks/entities/safe-created.entity.ts deleted file mode 100644 index 4d11504c1d..0000000000 --- a/src/routes/cache-hooks/entities/safe-created.entity.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { SafeCreatedEventSchema } from '@/routes/cache-hooks/entities/schemas/safe-created.schema'; -import { z } from 'zod'; - -export type SafeCreated = z.infer; diff --git a/src/routes/cache-hooks/entities/schemas/web-hook.schema.ts b/src/routes/cache-hooks/entities/schemas/web-hook.schema.ts deleted file mode 100644 index 2d92d2ac38..0000000000 --- a/src/routes/cache-hooks/entities/schemas/web-hook.schema.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { z } from 'zod'; -import { ChainUpdateEventSchema } from '@/routes/cache-hooks/entities/schemas/chain-update.schema'; -import { DeletedMultisigTransactionEventSchema } from '@/routes/cache-hooks/entities/schemas/deleted-multisig-transaction.schema'; -import { ExecutedTransactionEventSchema } from '@/routes/cache-hooks/entities/schemas/executed-transaction.schema'; -import { IncomingEtherEventSchema } from '@/routes/cache-hooks/entities/schemas/incoming-ether.schema'; -import { IncomingTokenEventSchema } from '@/routes/cache-hooks/entities/schemas/incoming-token.schema'; -import { MessageCreatedEventSchema } from '@/routes/cache-hooks/entities/schemas/message-created.schema'; -import { ModuleTransactionEventSchema } from '@/routes/cache-hooks/entities/schemas/module-transaction.schema'; -import { NewConfirmationEventSchema } from '@/routes/cache-hooks/entities/schemas/new-confirmation.schema'; -import { NewMessageConfirmationEventSchema } from '@/routes/cache-hooks/entities/schemas/new-message-confirmation.schema'; -import { OutgoingEtherEventSchema } from '@/routes/cache-hooks/entities/schemas/outgoing-ether.schema'; -import { OutgoingTokenEventSchema } from '@/routes/cache-hooks/entities/schemas/outgoing-token.schema'; -import { PendingTransactionEventSchema } from '@/routes/cache-hooks/entities/schemas/pending-transaction.schema'; -import { SafeAppsUpdateEventSchema } from '@/routes/cache-hooks/entities/schemas/safe-apps-update.schema'; -import { SafeCreatedEventSchema } from '@/routes/cache-hooks/entities/schemas/safe-created.schema'; - -export const WebHookSchema = z.discriminatedUnion('type', [ - ChainUpdateEventSchema, - DeletedMultisigTransactionEventSchema, - ExecutedTransactionEventSchema, - IncomingEtherEventSchema, - IncomingTokenEventSchema, - MessageCreatedEventSchema, - ModuleTransactionEventSchema, - NewConfirmationEventSchema, - NewMessageConfirmationEventSchema, - OutgoingEtherEventSchema, - OutgoingTokenEventSchema, - PendingTransactionEventSchema, - SafeAppsUpdateEventSchema, - SafeCreatedEventSchema, -]); diff --git a/src/routes/cache-hooks/__tests__/event-hooks-queue.e2e-spec.ts b/src/routes/hooks/__tests__/event-hooks-queue.e2e-spec.ts similarity index 100% rename from src/routes/cache-hooks/__tests__/event-hooks-queue.e2e-spec.ts rename to src/routes/hooks/__tests__/event-hooks-queue.e2e-spec.ts diff --git a/src/routes/cache-hooks/entities/__tests__/chain-update.builder.ts b/src/routes/hooks/entities/__tests__/chain-update.builder.ts similarity index 58% rename from src/routes/cache-hooks/entities/__tests__/chain-update.builder.ts rename to src/routes/hooks/entities/__tests__/chain-update.builder.ts index 3d53a88983..b21cdbed37 100644 --- a/src/routes/cache-hooks/entities/__tests__/chain-update.builder.ts +++ b/src/routes/hooks/entities/__tests__/chain-update.builder.ts @@ -1,10 +1,10 @@ import { IBuilder, Builder } from '@/__tests__/builder'; -import { ChainUpdate } from '@/routes/cache-hooks/entities/chain-update.entity'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; +import { ChainUpdate } from '@/routes/hooks/entities/chain-update.entity'; +import { ConfigEventType } from '@/routes/hooks/entities/event-type.entity'; import { faker } from '@faker-js/faker'; export function chainUpdateEventBuilder(): IBuilder { return new Builder() - .with('type', EventType.CHAIN_UPDATE) + .with('type', ConfigEventType.CHAIN_UPDATE) .with('chainId', faker.string.numeric()); } diff --git a/src/routes/cache-hooks/entities/__tests__/deleted-multisig-transaction.builder.ts b/src/routes/hooks/entities/__tests__/deleted-multisig-transaction.builder.ts similarity index 63% rename from src/routes/cache-hooks/entities/__tests__/deleted-multisig-transaction.builder.ts rename to src/routes/hooks/entities/__tests__/deleted-multisig-transaction.builder.ts index 5f36fc3c7f..218267e668 100644 --- a/src/routes/cache-hooks/entities/__tests__/deleted-multisig-transaction.builder.ts +++ b/src/routes/hooks/entities/__tests__/deleted-multisig-transaction.builder.ts @@ -1,12 +1,12 @@ import { IBuilder, Builder } from '@/__tests__/builder'; -import { DeletedMultisigTransaction } from '@/routes/cache-hooks/entities/deleted-multisig-transaction.entity'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; +import { DeletedMultisigTransaction } from '@/routes/hooks/entities/deleted-multisig-transaction.entity'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; export function deletedMultisigTransactionEventBuilder(): IBuilder { return new Builder() - .with('type', EventType.DELETED_MULTISIG_TRANSACTION) + .with('type', TransactionEventType.DELETED_MULTISIG_TRANSACTION) .with('address', getAddress(faker.finance.ethereumAddress())) .with('chainId', faker.string.numeric()) .with('safeTxHash', faker.string.hexadecimal()); diff --git a/src/routes/cache-hooks/entities/__tests__/executed-transaction.builder.ts b/src/routes/hooks/entities/__tests__/executed-transaction.builder.ts similarity index 66% rename from src/routes/cache-hooks/entities/__tests__/executed-transaction.builder.ts rename to src/routes/hooks/entities/__tests__/executed-transaction.builder.ts index c4ebb3499a..e48ea8e2e1 100644 --- a/src/routes/cache-hooks/entities/__tests__/executed-transaction.builder.ts +++ b/src/routes/hooks/entities/__tests__/executed-transaction.builder.ts @@ -1,12 +1,12 @@ import { IBuilder, Builder } from '@/__tests__/builder'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { ExecutedTransaction } from '@/routes/cache-hooks/entities/executed-transaction.entity'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; +import { ExecutedTransaction } from '@/routes/hooks/entities/executed-transaction.entity'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; export function executedTransactionEventBuilder(): IBuilder { return new Builder() - .with('type', EventType.EXECUTED_MULTISIG_TRANSACTION) + .with('type', TransactionEventType.EXECUTED_MULTISIG_TRANSACTION) .with('address', getAddress(faker.finance.ethereumAddress())) .with('chainId', faker.string.numeric()) .with('safeTxHash', faker.string.hexadecimal()) diff --git a/src/routes/cache-hooks/entities/__tests__/incoming-ether.builder.ts b/src/routes/hooks/entities/__tests__/incoming-ether.builder.ts similarity index 67% rename from src/routes/cache-hooks/entities/__tests__/incoming-ether.builder.ts rename to src/routes/hooks/entities/__tests__/incoming-ether.builder.ts index e30323bd62..a1162f7151 100644 --- a/src/routes/cache-hooks/entities/__tests__/incoming-ether.builder.ts +++ b/src/routes/hooks/entities/__tests__/incoming-ether.builder.ts @@ -1,12 +1,12 @@ import { IBuilder, Builder } from '@/__tests__/builder'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { IncomingEther } from '@/routes/cache-hooks/entities/incoming-ether.entity'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; +import { IncomingEther } from '@/routes/hooks/entities/incoming-ether.entity'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; export function incomingEtherEventBuilder(): IBuilder { return new Builder() - .with('type', EventType.INCOMING_ETHER) + .with('type', TransactionEventType.INCOMING_ETHER) .with('address', getAddress(faker.finance.ethereumAddress())) .with('chainId', faker.string.numeric()) .with('txHash', faker.string.hexadecimal()) diff --git a/src/routes/cache-hooks/entities/__tests__/incoming-token.builder.ts b/src/routes/hooks/entities/__tests__/incoming-token.builder.ts similarity index 68% rename from src/routes/cache-hooks/entities/__tests__/incoming-token.builder.ts rename to src/routes/hooks/entities/__tests__/incoming-token.builder.ts index 5f02780f06..c31307686a 100644 --- a/src/routes/cache-hooks/entities/__tests__/incoming-token.builder.ts +++ b/src/routes/hooks/entities/__tests__/incoming-token.builder.ts @@ -1,12 +1,12 @@ import { IBuilder, Builder } from '@/__tests__/builder'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { IncomingToken } from '@/routes/cache-hooks/entities/incoming-token.entity'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; +import { IncomingToken } from '@/routes/hooks/entities/incoming-token.entity'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; export function incomingTokenEventBuilder(): IBuilder { return new Builder() - .with('type', EventType.INCOMING_TOKEN) + .with('type', TransactionEventType.INCOMING_TOKEN) .with('address', getAddress(faker.finance.ethereumAddress())) .with('chainId', faker.string.numeric()) .with('tokenAddress', getAddress(faker.finance.ethereumAddress())) diff --git a/src/routes/cache-hooks/entities/__tests__/message-created.builder.ts b/src/routes/hooks/entities/__tests__/message-created.builder.ts similarity index 65% rename from src/routes/cache-hooks/entities/__tests__/message-created.builder.ts rename to src/routes/hooks/entities/__tests__/message-created.builder.ts index e0bfbc480f..bf1a0a3726 100644 --- a/src/routes/cache-hooks/entities/__tests__/message-created.builder.ts +++ b/src/routes/hooks/entities/__tests__/message-created.builder.ts @@ -1,12 +1,12 @@ import { IBuilder, Builder } from '@/__tests__/builder'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { MessageCreated } from '@/routes/cache-hooks/entities/message-created.entity'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; +import { MessageCreated } from '@/routes/hooks/entities/message-created.entity'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; export function messageCreatedEventBuilder(): IBuilder { return new Builder() - .with('type', EventType.MESSAGE_CREATED) + .with('type', TransactionEventType.MESSAGE_CREATED) .with('address', getAddress(faker.finance.ethereumAddress())) .with('chainId', faker.string.numeric()) .with('messageHash', faker.string.hexadecimal()); diff --git a/src/routes/cache-hooks/entities/__tests__/module-transaction.builder.ts b/src/routes/hooks/entities/__tests__/module-transaction.builder.ts similarity index 67% rename from src/routes/cache-hooks/entities/__tests__/module-transaction.builder.ts rename to src/routes/hooks/entities/__tests__/module-transaction.builder.ts index dfad038552..de4ac0f40b 100644 --- a/src/routes/cache-hooks/entities/__tests__/module-transaction.builder.ts +++ b/src/routes/hooks/entities/__tests__/module-transaction.builder.ts @@ -1,12 +1,12 @@ import { IBuilder, Builder } from '@/__tests__/builder'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { ModuleTransaction } from '@/routes/cache-hooks/entities/module-transaction.entity'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; +import { ModuleTransaction } from '@/routes/hooks/entities/module-transaction.entity'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; export function moduleTransactionEventBuilder(): IBuilder { return new Builder() - .with('type', EventType.MODULE_TRANSACTION) + .with('type', TransactionEventType.MODULE_TRANSACTION) .with('address', getAddress(faker.finance.ethereumAddress())) .with('chainId', faker.string.numeric()) .with('module', getAddress(faker.finance.ethereumAddress())) diff --git a/src/routes/cache-hooks/entities/__tests__/new-confirmation.builder.ts b/src/routes/hooks/entities/__tests__/new-confirmation.builder.ts similarity index 68% rename from src/routes/cache-hooks/entities/__tests__/new-confirmation.builder.ts rename to src/routes/hooks/entities/__tests__/new-confirmation.builder.ts index bec4b9a3a5..73ede49f0c 100644 --- a/src/routes/cache-hooks/entities/__tests__/new-confirmation.builder.ts +++ b/src/routes/hooks/entities/__tests__/new-confirmation.builder.ts @@ -1,12 +1,12 @@ import { IBuilder, Builder } from '@/__tests__/builder'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { NewConfirmation } from '@/routes/cache-hooks/entities/new-confirmation.entity'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; +import { NewConfirmation } from '@/routes/hooks/entities/new-confirmation.entity'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; export function newConfirmationEventBuilder(): IBuilder { return new Builder() - .with('type', EventType.NEW_CONFIRMATION) + .with('type', TransactionEventType.NEW_CONFIRMATION) .with('address', getAddress(faker.finance.ethereumAddress())) .with('chainId', faker.string.numeric()) .with('owner', getAddress(faker.finance.ethereumAddress())) diff --git a/src/routes/cache-hooks/entities/__tests__/new-message-confirmation.builder.ts b/src/routes/hooks/entities/__tests__/new-message-confirmation.builder.ts similarity index 64% rename from src/routes/cache-hooks/entities/__tests__/new-message-confirmation.builder.ts rename to src/routes/hooks/entities/__tests__/new-message-confirmation.builder.ts index b4a64810de..1ef637282f 100644 --- a/src/routes/cache-hooks/entities/__tests__/new-message-confirmation.builder.ts +++ b/src/routes/hooks/entities/__tests__/new-message-confirmation.builder.ts @@ -1,12 +1,12 @@ import { IBuilder, Builder } from '@/__tests__/builder'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { NewMessageConfirmation } from '@/routes/cache-hooks/entities/new-message-confirmation.entity'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; +import { NewMessageConfirmation } from '@/routes/hooks/entities/new-message-confirmation.entity'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; export function newMessageConfirmationEventBuilder(): IBuilder { return new Builder() - .with('type', EventType.MESSAGE_CONFIRMATION) + .with('type', TransactionEventType.MESSAGE_CONFIRMATION) .with('address', getAddress(faker.finance.ethereumAddress())) .with('chainId', faker.string.numeric()) .with('messageHash', faker.string.hexadecimal()); diff --git a/src/routes/cache-hooks/entities/__tests__/outgoing-ether.builder.ts b/src/routes/hooks/entities/__tests__/outgoing-ether.builder.ts similarity index 67% rename from src/routes/cache-hooks/entities/__tests__/outgoing-ether.builder.ts rename to src/routes/hooks/entities/__tests__/outgoing-ether.builder.ts index 009f668e17..77d4fc5685 100644 --- a/src/routes/cache-hooks/entities/__tests__/outgoing-ether.builder.ts +++ b/src/routes/hooks/entities/__tests__/outgoing-ether.builder.ts @@ -1,12 +1,12 @@ import { IBuilder, Builder } from '@/__tests__/builder'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { OutgoingEther } from '@/routes/cache-hooks/entities/outgoing-ether.entity'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; +import { OutgoingEther } from '@/routes/hooks/entities/outgoing-ether.entity'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; export function outgoingEtherEventBuilder(): IBuilder { return new Builder() - .with('type', EventType.OUTGOING_ETHER) + .with('type', TransactionEventType.OUTGOING_ETHER) .with('address', getAddress(faker.finance.ethereumAddress())) .with('chainId', faker.string.numeric()) .with('txHash', faker.string.hexadecimal()) diff --git a/src/routes/cache-hooks/entities/__tests__/outgoing-token.builder.ts b/src/routes/hooks/entities/__tests__/outgoing-token.builder.ts similarity index 68% rename from src/routes/cache-hooks/entities/__tests__/outgoing-token.builder.ts rename to src/routes/hooks/entities/__tests__/outgoing-token.builder.ts index ce470acb97..b536caa098 100644 --- a/src/routes/cache-hooks/entities/__tests__/outgoing-token.builder.ts +++ b/src/routes/hooks/entities/__tests__/outgoing-token.builder.ts @@ -1,12 +1,12 @@ import { IBuilder, Builder } from '@/__tests__/builder'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { OutgoingToken } from '@/routes/cache-hooks/entities/outgoing-token.entity'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; +import { OutgoingToken } from '@/routes/hooks/entities/outgoing-token.entity'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; export function outgoingTokenEventBuilder(): IBuilder { return new Builder() - .with('type', EventType.OUTGOING_TOKEN) + .with('type', TransactionEventType.OUTGOING_TOKEN) .with('address', getAddress(faker.finance.ethereumAddress())) .with('chainId', faker.string.numeric()) .with('tokenAddress', getAddress(faker.finance.ethereumAddress())) diff --git a/src/routes/cache-hooks/entities/__tests__/pending-transaction.builder.ts b/src/routes/hooks/entities/__tests__/pending-transaction.builder.ts similarity index 63% rename from src/routes/cache-hooks/entities/__tests__/pending-transaction.builder.ts rename to src/routes/hooks/entities/__tests__/pending-transaction.builder.ts index 0a7ddd586d..d5df492d5b 100644 --- a/src/routes/cache-hooks/entities/__tests__/pending-transaction.builder.ts +++ b/src/routes/hooks/entities/__tests__/pending-transaction.builder.ts @@ -1,12 +1,12 @@ import { IBuilder, Builder } from '@/__tests__/builder'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { PendingTransaction } from '@/routes/cache-hooks/entities/pending-transaction.entity'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; +import { PendingTransaction } from '@/routes/hooks/entities/pending-transaction.entity'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; export function pendingTransactionEventBuilder(): IBuilder { return new Builder() - .with('type', EventType.PENDING_MULTISIG_TRANSACTION) + .with('type', TransactionEventType.PENDING_MULTISIG_TRANSACTION) .with('address', getAddress(faker.finance.ethereumAddress())) .with('chainId', faker.string.numeric()) .with('safeTxHash', faker.string.hexadecimal()); diff --git a/src/routes/cache-hooks/entities/__tests__/safe-apps-update.builder.ts b/src/routes/hooks/entities/__tests__/safe-apps-update.builder.ts similarity index 57% rename from src/routes/cache-hooks/entities/__tests__/safe-apps-update.builder.ts rename to src/routes/hooks/entities/__tests__/safe-apps-update.builder.ts index b8647fbcc7..4e4d57f5b2 100644 --- a/src/routes/cache-hooks/entities/__tests__/safe-apps-update.builder.ts +++ b/src/routes/hooks/entities/__tests__/safe-apps-update.builder.ts @@ -1,10 +1,10 @@ import { IBuilder, Builder } from '@/__tests__/builder'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { SafeAppsUpdate } from '@/routes/cache-hooks/entities/safe-apps-update.entity'; +import { ConfigEventType } from '@/routes/hooks/entities/event-type.entity'; +import { SafeAppsUpdate } from '@/routes/hooks/entities/safe-apps-update.entity'; import { faker } from '@faker-js/faker'; export function safeAppsEventBuilder(): IBuilder { return new Builder() - .with('type', EventType.SAFE_APPS_UPDATE) + .with('type', ConfigEventType.SAFE_APPS_UPDATE) .with('chainId', faker.string.numeric()); } diff --git a/src/routes/cache-hooks/entities/__tests__/safe-created.build.ts b/src/routes/hooks/entities/__tests__/safe-created.build.ts similarity index 65% rename from src/routes/cache-hooks/entities/__tests__/safe-created.build.ts rename to src/routes/hooks/entities/__tests__/safe-created.build.ts index d8c4a7fe32..62c4661b0c 100644 --- a/src/routes/cache-hooks/entities/__tests__/safe-created.build.ts +++ b/src/routes/hooks/entities/__tests__/safe-created.build.ts @@ -1,12 +1,12 @@ import { Builder, IBuilder } from '@/__tests__/builder'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { SafeCreated } from '@/routes/cache-hooks/entities/safe-created.entity'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; +import { SafeCreated } from '@/routes/hooks/entities/safe-created.entity'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; export function safeCreatedEventBuilder(): IBuilder { return new Builder() - .with('type', EventType.SAFE_CREATED) + .with('type', TransactionEventType.SAFE_CREATED) .with('chainId', faker.string.numeric()) .with('address', getAddress(faker.finance.ethereumAddress())) .with('blockNumber', faker.number.int()); diff --git a/src/routes/hooks/entities/chain-update.entity.ts b/src/routes/hooks/entities/chain-update.entity.ts new file mode 100644 index 0000000000..2832a2437b --- /dev/null +++ b/src/routes/hooks/entities/chain-update.entity.ts @@ -0,0 +1,4 @@ +import { ChainUpdateEventSchema } from '@/routes/hooks/entities/schemas/chain-update.schema'; +import { z } from 'zod'; + +export type ChainUpdate = z.infer; diff --git a/src/routes/cache-hooks/entities/deleted-multisig-transaction.entity.ts b/src/routes/hooks/entities/deleted-multisig-transaction.entity.ts similarity index 73% rename from src/routes/cache-hooks/entities/deleted-multisig-transaction.entity.ts rename to src/routes/hooks/entities/deleted-multisig-transaction.entity.ts index dc72bfa3e9..90e7eb655d 100644 --- a/src/routes/cache-hooks/entities/deleted-multisig-transaction.entity.ts +++ b/src/routes/hooks/entities/deleted-multisig-transaction.entity.ts @@ -1,4 +1,4 @@ -import { DeletedMultisigTransactionEventSchema } from '@/routes/cache-hooks/entities/schemas/deleted-multisig-transaction.schema'; +import { DeletedMultisigTransactionEventSchema } from '@/routes/hooks/entities/schemas/deleted-multisig-transaction.schema'; import { z } from 'zod'; export type DeletedMultisigTransaction = z.infer< diff --git a/src/routes/cache-hooks/entities/event-type.entity.ts b/src/routes/hooks/entities/event-type.entity.ts similarity index 90% rename from src/routes/cache-hooks/entities/event-type.entity.ts rename to src/routes/hooks/entities/event-type.entity.ts index af4fa3381c..4662511660 100644 --- a/src/routes/cache-hooks/entities/event-type.entity.ts +++ b/src/routes/hooks/entities/event-type.entity.ts @@ -1,5 +1,4 @@ -export const enum EventType { - CHAIN_UPDATE = 'CHAIN_UPDATE', +export enum TransactionEventType { DELETED_MULTISIG_TRANSACTION = 'DELETED_MULTISIG_TRANSACTION', EXECUTED_MULTISIG_TRANSACTION = 'EXECUTED_MULTISIG_TRANSACTION', INCOMING_ETHER = 'INCOMING_ETHER', @@ -11,6 +10,10 @@ export const enum EventType { OUTGOING_ETHER = 'OUTGOING_ETHER', OUTGOING_TOKEN = 'OUTGOING_TOKEN', PENDING_MULTISIG_TRANSACTION = 'PENDING_MULTISIG_TRANSACTION', - SAFE_APPS_UPDATE = 'SAFE_APPS_UPDATE', SAFE_CREATED = 'SAFE_CREATED', } + +export enum ConfigEventType { + CHAIN_UPDATE = 'CHAIN_UPDATE', + SAFE_APPS_UPDATE = 'SAFE_APPS_UPDATE', +} diff --git a/src/routes/hooks/entities/event.entity.ts b/src/routes/hooks/entities/event.entity.ts new file mode 100644 index 0000000000..0f93b1c0c4 --- /dev/null +++ b/src/routes/hooks/entities/event.entity.ts @@ -0,0 +1,4 @@ +import { EventSchema } from '@/routes/hooks/entities/schemas/event.schema'; +import { z } from 'zod'; + +export type Event = z.infer; diff --git a/src/routes/hooks/entities/executed-transaction.entity.ts b/src/routes/hooks/entities/executed-transaction.entity.ts new file mode 100644 index 0000000000..37945572e8 --- /dev/null +++ b/src/routes/hooks/entities/executed-transaction.entity.ts @@ -0,0 +1,6 @@ +import { ExecutedTransactionEventSchema } from '@/routes/hooks/entities/schemas/executed-transaction.schema'; +import { z } from 'zod'; + +export type ExecutedTransaction = z.infer< + typeof ExecutedTransactionEventSchema +>; diff --git a/src/routes/hooks/entities/incoming-ether.entity.ts b/src/routes/hooks/entities/incoming-ether.entity.ts new file mode 100644 index 0000000000..6dfd254441 --- /dev/null +++ b/src/routes/hooks/entities/incoming-ether.entity.ts @@ -0,0 +1,4 @@ +import { IncomingEtherEventSchema } from '@/routes/hooks/entities/schemas/incoming-ether.schema'; +import { z } from 'zod'; + +export type IncomingEther = z.infer; diff --git a/src/routes/hooks/entities/incoming-token.entity.ts b/src/routes/hooks/entities/incoming-token.entity.ts new file mode 100644 index 0000000000..79bc0c4ffc --- /dev/null +++ b/src/routes/hooks/entities/incoming-token.entity.ts @@ -0,0 +1,4 @@ +import { IncomingTokenEventSchema } from '@/routes/hooks/entities/schemas/incoming-token.schema'; +import { z } from 'zod'; + +export type IncomingToken = z.infer; diff --git a/src/routes/hooks/entities/message-created.entity.ts b/src/routes/hooks/entities/message-created.entity.ts new file mode 100644 index 0000000000..6511e4bc2a --- /dev/null +++ b/src/routes/hooks/entities/message-created.entity.ts @@ -0,0 +1,4 @@ +import { MessageCreatedEventSchema } from '@/routes/hooks/entities/schemas/message-created.schema'; +import { z } from 'zod'; + +export type MessageCreated = z.infer; diff --git a/src/routes/hooks/entities/module-transaction.entity.ts b/src/routes/hooks/entities/module-transaction.entity.ts new file mode 100644 index 0000000000..96953e8029 --- /dev/null +++ b/src/routes/hooks/entities/module-transaction.entity.ts @@ -0,0 +1,4 @@ +import { ModuleTransactionEventSchema } from '@/routes/hooks/entities/schemas/module-transaction.schema'; +import { z } from 'zod'; + +export type ModuleTransaction = z.infer; diff --git a/src/routes/hooks/entities/new-confirmation.entity.ts b/src/routes/hooks/entities/new-confirmation.entity.ts new file mode 100644 index 0000000000..57bebb8474 --- /dev/null +++ b/src/routes/hooks/entities/new-confirmation.entity.ts @@ -0,0 +1,4 @@ +import { NewConfirmationEventSchema } from '@/routes/hooks/entities/schemas/new-confirmation.schema'; +import { z } from 'zod'; + +export type NewConfirmation = z.infer; diff --git a/src/routes/hooks/entities/new-message-confirmation.entity.ts b/src/routes/hooks/entities/new-message-confirmation.entity.ts new file mode 100644 index 0000000000..9abdcaed8a --- /dev/null +++ b/src/routes/hooks/entities/new-message-confirmation.entity.ts @@ -0,0 +1,6 @@ +import { NewMessageConfirmationEventSchema } from '@/routes/hooks/entities/schemas/new-message-confirmation.schema'; +import { z } from 'zod'; + +export type NewMessageConfirmation = z.infer< + typeof NewMessageConfirmationEventSchema +>; diff --git a/src/routes/hooks/entities/outgoing-ether.entity.ts b/src/routes/hooks/entities/outgoing-ether.entity.ts new file mode 100644 index 0000000000..38ff4e4af4 --- /dev/null +++ b/src/routes/hooks/entities/outgoing-ether.entity.ts @@ -0,0 +1,4 @@ +import { OutgoingEtherEventSchema } from '@/routes/hooks/entities/schemas/outgoing-ether.schema'; +import { z } from 'zod'; + +export type OutgoingEther = z.infer; diff --git a/src/routes/hooks/entities/outgoing-token.entity.ts b/src/routes/hooks/entities/outgoing-token.entity.ts new file mode 100644 index 0000000000..3a17141a82 --- /dev/null +++ b/src/routes/hooks/entities/outgoing-token.entity.ts @@ -0,0 +1,4 @@ +import { OutgoingTokenEventSchema } from '@/routes/hooks/entities/schemas/outgoing-token.schema'; +import { z } from 'zod'; + +export type OutgoingToken = z.infer; diff --git a/src/routes/hooks/entities/pending-transaction.entity.ts b/src/routes/hooks/entities/pending-transaction.entity.ts new file mode 100644 index 0000000000..40471733b3 --- /dev/null +++ b/src/routes/hooks/entities/pending-transaction.entity.ts @@ -0,0 +1,4 @@ +import { PendingTransactionEventSchema } from '@/routes/hooks/entities/schemas/pending-transaction.schema'; +import { z } from 'zod'; + +export type PendingTransaction = z.infer; diff --git a/src/routes/hooks/entities/safe-apps-update.entity.ts b/src/routes/hooks/entities/safe-apps-update.entity.ts new file mode 100644 index 0000000000..ab2596c39f --- /dev/null +++ b/src/routes/hooks/entities/safe-apps-update.entity.ts @@ -0,0 +1,4 @@ +import { SafeAppsUpdateEventSchema } from '@/routes/hooks/entities/schemas/safe-apps-update.schema'; +import { z } from 'zod'; + +export type SafeAppsUpdate = z.infer; diff --git a/src/routes/hooks/entities/safe-created.entity.ts b/src/routes/hooks/entities/safe-created.entity.ts new file mode 100644 index 0000000000..e6de943ae8 --- /dev/null +++ b/src/routes/hooks/entities/safe-created.entity.ts @@ -0,0 +1,4 @@ +import { SafeCreatedEventSchema } from '@/routes/hooks/entities/schemas/safe-created.schema'; +import { z } from 'zod'; + +export type SafeCreated = z.infer; diff --git a/src/routes/cache-hooks/entities/schemas/__tests__/chain-update.schema.spec.ts b/src/routes/hooks/entities/schemas/__tests__/chain-update.schema.spec.ts similarity index 80% rename from src/routes/cache-hooks/entities/schemas/__tests__/chain-update.schema.spec.ts rename to src/routes/hooks/entities/schemas/__tests__/chain-update.schema.spec.ts index 8275bce5ed..21f530bd1e 100644 --- a/src/routes/cache-hooks/entities/schemas/__tests__/chain-update.schema.spec.ts +++ b/src/routes/hooks/entities/schemas/__tests__/chain-update.schema.spec.ts @@ -1,6 +1,6 @@ -import { chainUpdateEventBuilder } from '@/routes/cache-hooks/entities/__tests__/chain-update.builder'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { ChainUpdateEventSchema } from '@/routes/cache-hooks/entities/schemas/chain-update.schema'; +import { chainUpdateEventBuilder } from '@/routes/hooks/entities/__tests__/chain-update.builder'; +import { ConfigEventType } from '@/routes/hooks/entities/event-type.entity'; +import { ChainUpdateEventSchema } from '@/routes/hooks/entities/schemas/chain-update.schema'; import { faker } from '@faker-js/faker'; import { ZodError } from 'zod'; @@ -15,7 +15,7 @@ describe('ChainUpdateEventSchema', () => { it('should not allow an non-CHAIN_UPDATE event', () => { const chainUpdateEvent = chainUpdateEventBuilder() - .with('type', faker.word.sample() as EventType.CHAIN_UPDATE) + .with('type', faker.word.sample() as ConfigEventType.CHAIN_UPDATE) .build(); const result = ChainUpdateEventSchema.safeParse(chainUpdateEvent); diff --git a/src/routes/cache-hooks/entities/schemas/__tests__/deleted-multisig-transaction.schema.spec.ts b/src/routes/hooks/entities/schemas/__tests__/deleted-multisig-transaction.schema.spec.ts similarity index 91% rename from src/routes/cache-hooks/entities/schemas/__tests__/deleted-multisig-transaction.schema.spec.ts rename to src/routes/hooks/entities/schemas/__tests__/deleted-multisig-transaction.schema.spec.ts index 6f0af0a3fe..d07725d62f 100644 --- a/src/routes/cache-hooks/entities/schemas/__tests__/deleted-multisig-transaction.schema.spec.ts +++ b/src/routes/hooks/entities/schemas/__tests__/deleted-multisig-transaction.schema.spec.ts @@ -1,6 +1,6 @@ -import { deletedMultisigTransactionEventBuilder } from '@/routes/cache-hooks/entities/__tests__/deleted-multisig-transaction.builder'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { DeletedMultisigTransactionEventSchema } from '@/routes/cache-hooks/entities/schemas/deleted-multisig-transaction.schema'; +import { deletedMultisigTransactionEventBuilder } from '@/routes/hooks/entities/__tests__/deleted-multisig-transaction.builder'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; +import { DeletedMultisigTransactionEventSchema } from '@/routes/hooks/entities/schemas/deleted-multisig-transaction.schema'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; import { ZodError } from 'zod'; @@ -22,7 +22,7 @@ describe('DeletedMultisigTransactionEventSchema', () => { deletedMultisigTransactionEventBuilder() .with( 'type', - faker.word.sample() as EventType.DELETED_MULTISIG_TRANSACTION, + faker.word.sample() as TransactionEventType.DELETED_MULTISIG_TRANSACTION, ) .build(); diff --git a/src/routes/cache-hooks/entities/schemas/__tests__/web-hook.schema.spec.ts b/src/routes/hooks/entities/schemas/__tests__/event.schema.spec.ts similarity index 55% rename from src/routes/cache-hooks/entities/schemas/__tests__/web-hook.schema.spec.ts rename to src/routes/hooks/entities/schemas/__tests__/event.schema.spec.ts index 77a8bd2fbd..d64e8ca5a9 100644 --- a/src/routes/cache-hooks/entities/schemas/__tests__/web-hook.schema.spec.ts +++ b/src/routes/hooks/entities/schemas/__tests__/event.schema.spec.ts @@ -1,20 +1,20 @@ -import { chainUpdateEventBuilder } from '@/routes/cache-hooks/entities/__tests__/chain-update.builder'; -import { deletedMultisigTransactionEventBuilder } from '@/routes/cache-hooks/entities/__tests__/deleted-multisig-transaction.builder'; -import { executedTransactionEventBuilder } from '@/routes/cache-hooks/entities/__tests__/executed-transaction.builder'; -import { incomingEtherEventBuilder } from '@/routes/cache-hooks/entities/__tests__/incoming-ether.builder'; -import { incomingTokenEventBuilder } from '@/routes/cache-hooks/entities/__tests__/incoming-token.builder'; -import { messageCreatedEventBuilder } from '@/routes/cache-hooks/entities/__tests__/message-created.builder'; -import { moduleTransactionEventBuilder } from '@/routes/cache-hooks/entities/__tests__/module-transaction.builder'; -import { newConfirmationEventBuilder } from '@/routes/cache-hooks/entities/__tests__/new-confirmation.builder'; -import { newMessageConfirmationEventBuilder } from '@/routes/cache-hooks/entities/__tests__/new-message-confirmation.builder'; -import { outgoingEtherEventBuilder } from '@/routes/cache-hooks/entities/__tests__/outgoing-ether.builder'; -import { outgoingTokenEventBuilder } from '@/routes/cache-hooks/entities/__tests__/outgoing-token.builder'; -import { pendingTransactionEventBuilder } from '@/routes/cache-hooks/entities/__tests__/pending-transaction.builder'; -import { safeAppsEventBuilder } from '@/routes/cache-hooks/entities/__tests__/safe-apps-update.builder'; -import { WebHookSchema } from '@/routes/cache-hooks/entities/schemas/web-hook.schema'; +import { chainUpdateEventBuilder } from '@/routes/hooks/entities/__tests__/chain-update.builder'; +import { deletedMultisigTransactionEventBuilder } from '@/routes/hooks/entities/__tests__/deleted-multisig-transaction.builder'; +import { executedTransactionEventBuilder } from '@/routes/hooks/entities/__tests__/executed-transaction.builder'; +import { incomingEtherEventBuilder } from '@/routes/hooks/entities/__tests__/incoming-ether.builder'; +import { incomingTokenEventBuilder } from '@/routes/hooks/entities/__tests__/incoming-token.builder'; +import { messageCreatedEventBuilder } from '@/routes/hooks/entities/__tests__/message-created.builder'; +import { moduleTransactionEventBuilder } from '@/routes/hooks/entities/__tests__/module-transaction.builder'; +import { newConfirmationEventBuilder } from '@/routes/hooks/entities/__tests__/new-confirmation.builder'; +import { newMessageConfirmationEventBuilder } from '@/routes/hooks/entities/__tests__/new-message-confirmation.builder'; +import { outgoingEtherEventBuilder } from '@/routes/hooks/entities/__tests__/outgoing-ether.builder'; +import { outgoingTokenEventBuilder } from '@/routes/hooks/entities/__tests__/outgoing-token.builder'; +import { pendingTransactionEventBuilder } from '@/routes/hooks/entities/__tests__/pending-transaction.builder'; +import { safeAppsEventBuilder } from '@/routes/hooks/entities/__tests__/safe-apps-update.builder'; +import { EventSchema } from '@/routes/hooks/entities/schemas/event.schema'; import { ZodError } from 'zod'; -describe('WebHookSchema', () => { +describe('EventSchema', () => { [ chainUpdateEventBuilder, deletedMultisigTransactionEventBuilder, @@ -33,7 +33,7 @@ describe('WebHookSchema', () => { const event = builder().build(); it(`should validate a ${event.type} event`, () => { - const result = WebHookSchema.safeParse(event); + const result = EventSchema.safeParse(event); expect(result.success).toBe(true); }); @@ -44,7 +44,7 @@ describe('WebHookSchema', () => { type: 'INVALID_EVENT', }; - const result = WebHookSchema.safeParse(invalidEvent); + const result = EventSchema.safeParse(invalidEvent); expect(!result.success && result.error).toStrictEqual( new ZodError([ diff --git a/src/routes/cache-hooks/entities/schemas/__tests__/executed-transaction.schema.spec.ts b/src/routes/hooks/entities/schemas/__tests__/executed-transaction.schema.spec.ts similarity index 87% rename from src/routes/cache-hooks/entities/schemas/__tests__/executed-transaction.schema.spec.ts rename to src/routes/hooks/entities/schemas/__tests__/executed-transaction.schema.spec.ts index 9eaceea4d5..85157839e8 100644 --- a/src/routes/cache-hooks/entities/schemas/__tests__/executed-transaction.schema.spec.ts +++ b/src/routes/hooks/entities/schemas/__tests__/executed-transaction.schema.spec.ts @@ -1,6 +1,6 @@ -import { executedTransactionEventBuilder } from '@/routes/cache-hooks/entities/__tests__/executed-transaction.builder'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { ExecutedTransactionEventSchema } from '@/routes/cache-hooks/entities/schemas/executed-transaction.schema'; +import { executedTransactionEventBuilder } from '@/routes/hooks/entities/__tests__/executed-transaction.builder'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; +import { ExecutedTransactionEventSchema } from '@/routes/hooks/entities/schemas/executed-transaction.schema'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; import { ZodError } from 'zod'; @@ -20,7 +20,7 @@ describe('ExecutedTransactionEventSchema', () => { const executedTransactionEvent = executedTransactionEventBuilder() .with( 'type', - faker.word.sample() as EventType.EXECUTED_MULTISIG_TRANSACTION, + faker.word.sample() as TransactionEventType.EXECUTED_MULTISIG_TRANSACTION, ) .build(); diff --git a/src/routes/cache-hooks/entities/schemas/__tests__/incoming-ether.schema.spec.ts b/src/routes/hooks/entities/schemas/__tests__/incoming-ether.schema.spec.ts similarity index 87% rename from src/routes/cache-hooks/entities/schemas/__tests__/incoming-ether.schema.spec.ts rename to src/routes/hooks/entities/schemas/__tests__/incoming-ether.schema.spec.ts index dec4577203..ec749283ef 100644 --- a/src/routes/cache-hooks/entities/schemas/__tests__/incoming-ether.schema.spec.ts +++ b/src/routes/hooks/entities/schemas/__tests__/incoming-ether.schema.spec.ts @@ -1,6 +1,6 @@ -import { incomingEtherEventBuilder } from '@/routes/cache-hooks/entities/__tests__/incoming-ether.builder'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { IncomingEtherEventSchema } from '@/routes/cache-hooks/entities/schemas/incoming-ether.schema'; +import { incomingEtherEventBuilder } from '@/routes/hooks/entities/__tests__/incoming-ether.builder'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; +import { IncomingEtherEventSchema } from '@/routes/hooks/entities/schemas/incoming-ether.schema'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; import { ZodError } from 'zod'; @@ -16,7 +16,7 @@ describe('IncomingEtherEventSchema', () => { it('should not allow a non-INCOMING_ETHER event', () => { const incomingEtherEvent = incomingEtherEventBuilder() - .with('type', faker.word.sample() as EventType.INCOMING_ETHER) + .with('type', faker.word.sample() as TransactionEventType.INCOMING_ETHER) .build(); const result = IncomingEtherEventSchema.safeParse(incomingEtherEvent); diff --git a/src/routes/cache-hooks/entities/schemas/__tests__/incoming-token.schema.spec.ts b/src/routes/hooks/entities/schemas/__tests__/incoming-token.schema.spec.ts similarity index 87% rename from src/routes/cache-hooks/entities/schemas/__tests__/incoming-token.schema.spec.ts rename to src/routes/hooks/entities/schemas/__tests__/incoming-token.schema.spec.ts index d5e08bfd5f..4ed6475e1a 100644 --- a/src/routes/cache-hooks/entities/schemas/__tests__/incoming-token.schema.spec.ts +++ b/src/routes/hooks/entities/schemas/__tests__/incoming-token.schema.spec.ts @@ -1,6 +1,6 @@ -import { incomingTokenEventBuilder } from '@/routes/cache-hooks/entities/__tests__/incoming-token.builder'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { IncomingTokenEventSchema } from '@/routes/cache-hooks/entities/schemas/incoming-token.schema'; +import { incomingTokenEventBuilder } from '@/routes/hooks/entities/__tests__/incoming-token.builder'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; +import { IncomingTokenEventSchema } from '@/routes/hooks/entities/schemas/incoming-token.schema'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; import { ZodError } from 'zod'; @@ -16,7 +16,7 @@ describe('IncomingTokenEventSchema', () => { it('should not allow a non-INCOMING_TOKEN event', () => { const incomingTokenEvent = incomingTokenEventBuilder() - .with('type', faker.word.sample() as EventType.INCOMING_TOKEN) + .with('type', faker.word.sample() as TransactionEventType.INCOMING_TOKEN) .build(); const result = IncomingTokenEventSchema.safeParse(incomingTokenEvent); diff --git a/src/routes/cache-hooks/entities/schemas/__tests__/message-created.schema.spec.ts b/src/routes/hooks/entities/schemas/__tests__/message-created.schema.spec.ts similarity index 87% rename from src/routes/cache-hooks/entities/schemas/__tests__/message-created.schema.spec.ts rename to src/routes/hooks/entities/schemas/__tests__/message-created.schema.spec.ts index 0512a267a1..4b275d9485 100644 --- a/src/routes/cache-hooks/entities/schemas/__tests__/message-created.schema.spec.ts +++ b/src/routes/hooks/entities/schemas/__tests__/message-created.schema.spec.ts @@ -1,6 +1,6 @@ -import { messageCreatedEventBuilder } from '@/routes/cache-hooks/entities/__tests__/message-created.builder'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { MessageCreatedEventSchema } from '@/routes/cache-hooks/entities/schemas/message-created.schema'; +import { messageCreatedEventBuilder } from '@/routes/hooks/entities/__tests__/message-created.builder'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; +import { MessageCreatedEventSchema } from '@/routes/hooks/entities/schemas/message-created.schema'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; import { ZodError } from 'zod'; @@ -16,7 +16,7 @@ describe('MessageCreatedEventSchema', () => { it('should not allow a non-MESSAGE_CREATED event', () => { const messageCreatedEvent = messageCreatedEventBuilder() - .with('type', faker.word.sample() as EventType.MESSAGE_CREATED) + .with('type', faker.word.sample() as TransactionEventType.MESSAGE_CREATED) .build(); const result = MessageCreatedEventSchema.safeParse(messageCreatedEvent); diff --git a/src/routes/cache-hooks/entities/schemas/__tests__/module-transaction.schema.spec.ts b/src/routes/hooks/entities/schemas/__tests__/module-transaction.schema.spec.ts similarity index 87% rename from src/routes/cache-hooks/entities/schemas/__tests__/module-transaction.schema.spec.ts rename to src/routes/hooks/entities/schemas/__tests__/module-transaction.schema.spec.ts index 17272313f6..f29156ed7a 100644 --- a/src/routes/cache-hooks/entities/schemas/__tests__/module-transaction.schema.spec.ts +++ b/src/routes/hooks/entities/schemas/__tests__/module-transaction.schema.spec.ts @@ -1,9 +1,9 @@ -import { ModuleTransactionEventSchema } from '@/routes/cache-hooks/entities/schemas/module-transaction.schema'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; +import { ModuleTransactionEventSchema } from '@/routes/hooks/entities/schemas/module-transaction.schema'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; import { ZodError } from 'zod'; -import { moduleTransactionEventBuilder } from '@/routes/cache-hooks/entities/__tests__/module-transaction.builder'; +import { moduleTransactionEventBuilder } from '@/routes/hooks/entities/__tests__/module-transaction.builder'; describe('ModuleTransactionEventSchema', () => { it('should validate an module transaction event', () => { @@ -18,7 +18,10 @@ describe('ModuleTransactionEventSchema', () => { it('should not allow a non-MODULE_TRANSACTION event', () => { const moduleTransactionEvent = moduleTransactionEventBuilder() - .with('type', faker.word.sample() as EventType.MODULE_TRANSACTION) + .with( + 'type', + faker.word.sample() as TransactionEventType.MODULE_TRANSACTION, + ) .build(); const result = ModuleTransactionEventSchema.safeParse( diff --git a/src/routes/cache-hooks/entities/schemas/__tests__/new-confirmation.schema.spec.ts b/src/routes/hooks/entities/schemas/__tests__/new-confirmation.schema.spec.ts similarity index 87% rename from src/routes/cache-hooks/entities/schemas/__tests__/new-confirmation.schema.spec.ts rename to src/routes/hooks/entities/schemas/__tests__/new-confirmation.schema.spec.ts index 8c27eb1361..4cf488514c 100644 --- a/src/routes/cache-hooks/entities/schemas/__tests__/new-confirmation.schema.spec.ts +++ b/src/routes/hooks/entities/schemas/__tests__/new-confirmation.schema.spec.ts @@ -1,6 +1,6 @@ -import { newConfirmationEventBuilder } from '@/routes/cache-hooks/entities/__tests__/new-confirmation.builder'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { NewConfirmationEventSchema } from '@/routes/cache-hooks/entities/schemas/new-confirmation.schema'; +import { newConfirmationEventBuilder } from '@/routes/hooks/entities/__tests__/new-confirmation.builder'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; +import { NewConfirmationEventSchema } from '@/routes/hooks/entities/schemas/new-confirmation.schema'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; import { ZodError } from 'zod'; @@ -16,7 +16,10 @@ describe('NewConfirmationEventSchema', () => { it('should not allow non-NEW_CONFIRMATION event', () => { const newConfirmationEvent = newConfirmationEventBuilder() - .with('type', faker.word.sample() as EventType.NEW_CONFIRMATION) + .with( + 'type', + faker.word.sample() as TransactionEventType.NEW_CONFIRMATION, + ) .build(); const result = NewConfirmationEventSchema.safeParse(newConfirmationEvent); diff --git a/src/routes/cache-hooks/entities/schemas/__tests__/new-message-confirmation.schema.spec.ts b/src/routes/hooks/entities/schemas/__tests__/new-message-confirmation.schema.spec.ts similarity index 86% rename from src/routes/cache-hooks/entities/schemas/__tests__/new-message-confirmation.schema.spec.ts rename to src/routes/hooks/entities/schemas/__tests__/new-message-confirmation.schema.spec.ts index 3d07f6c11e..6404a8c6f5 100644 --- a/src/routes/cache-hooks/entities/schemas/__tests__/new-message-confirmation.schema.spec.ts +++ b/src/routes/hooks/entities/schemas/__tests__/new-message-confirmation.schema.spec.ts @@ -1,6 +1,6 @@ -import { newMessageConfirmationEventBuilder } from '@/routes/cache-hooks/entities/__tests__/new-message-confirmation.builder'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { NewMessageConfirmationEventSchema } from '@/routes/cache-hooks/entities/schemas/new-message-confirmation.schema'; +import { newMessageConfirmationEventBuilder } from '@/routes/hooks/entities/__tests__/new-message-confirmation.builder'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; +import { NewMessageConfirmationEventSchema } from '@/routes/hooks/entities/schemas/new-message-confirmation.schema'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; import { ZodError } from 'zod'; @@ -19,7 +19,10 @@ describe('NewMessageConfirmationEventSchema', () => { it('should not allow a non-MESSAGE_CONFIRMATION event', () => { const newMessageConfirmationEvent = newMessageConfirmationEventBuilder() - .with('type', faker.word.sample() as EventType.MESSAGE_CONFIRMATION) + .with( + 'type', + faker.word.sample() as TransactionEventType.MESSAGE_CONFIRMATION, + ) .build(); const result = NewMessageConfirmationEventSchema.safeParse( diff --git a/src/routes/cache-hooks/entities/schemas/__tests__/outgoing-ether.schema.spec.ts b/src/routes/hooks/entities/schemas/__tests__/outgoing-ether.schema.spec.ts similarity index 87% rename from src/routes/cache-hooks/entities/schemas/__tests__/outgoing-ether.schema.spec.ts rename to src/routes/hooks/entities/schemas/__tests__/outgoing-ether.schema.spec.ts index cfda1c07e1..00348dad9f 100644 --- a/src/routes/cache-hooks/entities/schemas/__tests__/outgoing-ether.schema.spec.ts +++ b/src/routes/hooks/entities/schemas/__tests__/outgoing-ether.schema.spec.ts @@ -1,6 +1,6 @@ -import { outgoingEtherEventBuilder } from '@/routes/cache-hooks/entities/__tests__/outgoing-ether.builder'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { OutgoingEtherEventSchema } from '@/routes/cache-hooks/entities/schemas/outgoing-ether.schema'; +import { outgoingEtherEventBuilder } from '@/routes/hooks/entities/__tests__/outgoing-ether.builder'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; +import { OutgoingEtherEventSchema } from '@/routes/hooks/entities/schemas/outgoing-ether.schema'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; import { ZodError } from 'zod'; @@ -16,7 +16,7 @@ describe('OutgoingEtherEventSchema', () => { it('should not allow a non-OUTGOING_ETHER event', () => { const outgoingEtherEvent = outgoingEtherEventBuilder() - .with('type', faker.word.sample() as EventType.OUTGOING_ETHER) + .with('type', faker.word.sample() as TransactionEventType.OUTGOING_ETHER) .build(); const result = OutgoingEtherEventSchema.safeParse(outgoingEtherEvent); diff --git a/src/routes/cache-hooks/entities/schemas/__tests__/outgoing-token.schema.spec.ts b/src/routes/hooks/entities/schemas/__tests__/outgoing-token.schema.spec.ts similarity index 87% rename from src/routes/cache-hooks/entities/schemas/__tests__/outgoing-token.schema.spec.ts rename to src/routes/hooks/entities/schemas/__tests__/outgoing-token.schema.spec.ts index c0316fae36..33ed4bc303 100644 --- a/src/routes/cache-hooks/entities/schemas/__tests__/outgoing-token.schema.spec.ts +++ b/src/routes/hooks/entities/schemas/__tests__/outgoing-token.schema.spec.ts @@ -1,6 +1,6 @@ -import { outgoingTokenEventBuilder } from '@/routes/cache-hooks/entities/__tests__/outgoing-token.builder'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { OutgoingTokenEventSchema } from '@/routes/cache-hooks/entities/schemas/outgoing-token.schema'; +import { outgoingTokenEventBuilder } from '@/routes/hooks/entities/__tests__/outgoing-token.builder'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; +import { OutgoingTokenEventSchema } from '@/routes/hooks/entities/schemas/outgoing-token.schema'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; import { ZodError } from 'zod'; @@ -16,7 +16,7 @@ describe('OutgoingTokenEventSchema', () => { it('should not allow a non-OUTGOING_TOKEN event', () => { const outgoingTokenEvent = outgoingTokenEventBuilder() - .with('type', faker.word.sample() as EventType.OUTGOING_TOKEN) + .with('type', faker.word.sample() as TransactionEventType.OUTGOING_TOKEN) .build(); const result = OutgoingTokenEventSchema.safeParse(outgoingTokenEvent); diff --git a/src/routes/cache-hooks/entities/schemas/__tests__/pending-transaction.schema.spec.ts b/src/routes/hooks/entities/schemas/__tests__/pending-transaction.schema.spec.ts similarity index 87% rename from src/routes/cache-hooks/entities/schemas/__tests__/pending-transaction.schema.spec.ts rename to src/routes/hooks/entities/schemas/__tests__/pending-transaction.schema.spec.ts index 7f150f588e..bbb52ba4d8 100644 --- a/src/routes/cache-hooks/entities/schemas/__tests__/pending-transaction.schema.spec.ts +++ b/src/routes/hooks/entities/schemas/__tests__/pending-transaction.schema.spec.ts @@ -1,6 +1,6 @@ -import { pendingTransactionEventBuilder } from '@/routes/cache-hooks/entities/__tests__/pending-transaction.builder'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { PendingTransactionEventSchema } from '@/routes/cache-hooks/entities/schemas/pending-transaction.schema'; +import { pendingTransactionEventBuilder } from '@/routes/hooks/entities/__tests__/pending-transaction.builder'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; +import { PendingTransactionEventSchema } from '@/routes/hooks/entities/schemas/pending-transaction.schema'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; import { ZodError } from 'zod'; @@ -20,7 +20,7 @@ describe('PendingTransactionEventSchema', () => { const executedTransactionEvent = pendingTransactionEventBuilder() .with( 'type', - faker.word.sample() as EventType.PENDING_MULTISIG_TRANSACTION, + faker.word.sample() as TransactionEventType.PENDING_MULTISIG_TRANSACTION, ) .build(); diff --git a/src/routes/cache-hooks/entities/schemas/__tests__/safe-apps-update.schema.spec.ts b/src/routes/hooks/entities/schemas/__tests__/safe-apps-update.schema.spec.ts similarity index 80% rename from src/routes/cache-hooks/entities/schemas/__tests__/safe-apps-update.schema.spec.ts rename to src/routes/hooks/entities/schemas/__tests__/safe-apps-update.schema.spec.ts index c37ddd1897..91beab4127 100644 --- a/src/routes/cache-hooks/entities/schemas/__tests__/safe-apps-update.schema.spec.ts +++ b/src/routes/hooks/entities/schemas/__tests__/safe-apps-update.schema.spec.ts @@ -1,6 +1,6 @@ -import { safeAppsEventBuilder } from '@/routes/cache-hooks/entities/__tests__/safe-apps-update.builder'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { SafeAppsUpdateEventSchema } from '@/routes/cache-hooks/entities/schemas/safe-apps-update.schema'; +import { safeAppsEventBuilder } from '@/routes/hooks/entities/__tests__/safe-apps-update.builder'; +import { ConfigEventType } from '@/routes/hooks/entities/event-type.entity'; +import { SafeAppsUpdateEventSchema } from '@/routes/hooks/entities/schemas/safe-apps-update.schema'; import { faker } from '@faker-js/faker'; import { ZodError } from 'zod'; @@ -15,7 +15,7 @@ describe('SafeAppsUpdateEventSchema', () => { it('should not allow a non-SAFE_APPS_UPDATE event', () => { const safeAppsEvent = safeAppsEventBuilder() - .with('type', faker.word.sample() as EventType.SAFE_APPS_UPDATE) + .with('type', faker.word.sample() as ConfigEventType.SAFE_APPS_UPDATE) .build(); const result = SafeAppsUpdateEventSchema.safeParse(safeAppsEvent); diff --git a/src/routes/cache-hooks/entities/schemas/__tests__/safe-created.schema.spec.ts b/src/routes/hooks/entities/schemas/__tests__/safe-created.schema.spec.ts similarity index 84% rename from src/routes/cache-hooks/entities/schemas/__tests__/safe-created.schema.spec.ts rename to src/routes/hooks/entities/schemas/__tests__/safe-created.schema.spec.ts index c5d316427e..4ad7917add 100644 --- a/src/routes/cache-hooks/entities/schemas/__tests__/safe-created.schema.spec.ts +++ b/src/routes/hooks/entities/schemas/__tests__/safe-created.schema.spec.ts @@ -1,6 +1,6 @@ -import { safeCreatedEventBuilder } from '@/routes/cache-hooks/entities/__tests__/safe-created.build'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; -import { SafeCreatedEventSchema } from '@/routes/cache-hooks/entities/schemas/safe-created.schema'; +import { safeCreatedEventBuilder } from '@/routes/hooks/entities/__tests__/safe-created.build'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; +import { SafeCreatedEventSchema } from '@/routes/hooks/entities/schemas/safe-created.schema'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; import { ZodError } from 'zod'; @@ -29,7 +29,7 @@ describe('SafeCreatedEventSchema', () => { it('should not allow a non-SAFE_CREATED event', () => { const safeCreatedEvent = safeCreatedEventBuilder() - .with('type', faker.word.sample() as EventType.SAFE_CREATED) + .with('type', faker.word.sample() as TransactionEventType.SAFE_CREATED) .build(); const result = SafeCreatedEventSchema.safeParse(safeCreatedEvent); diff --git a/src/routes/cache-hooks/entities/schemas/chain-update.schema.ts b/src/routes/hooks/entities/schemas/chain-update.schema.ts similarity index 50% rename from src/routes/cache-hooks/entities/schemas/chain-update.schema.ts rename to src/routes/hooks/entities/schemas/chain-update.schema.ts index 348fd2ed8e..82be53b22e 100644 --- a/src/routes/cache-hooks/entities/schemas/chain-update.schema.ts +++ b/src/routes/hooks/entities/schemas/chain-update.schema.ts @@ -1,7 +1,7 @@ -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; +import { ConfigEventType } from '@/routes/hooks/entities/event-type.entity'; import { z } from 'zod'; export const ChainUpdateEventSchema = z.object({ - type: z.literal(EventType.CHAIN_UPDATE), + type: z.literal(ConfigEventType.CHAIN_UPDATE), chainId: z.string(), }); diff --git a/src/routes/cache-hooks/entities/schemas/deleted-multisig-transaction.schema.ts b/src/routes/hooks/entities/schemas/deleted-multisig-transaction.schema.ts similarity index 61% rename from src/routes/cache-hooks/entities/schemas/deleted-multisig-transaction.schema.ts rename to src/routes/hooks/entities/schemas/deleted-multisig-transaction.schema.ts index 690967ebe4..74b6358ca1 100644 --- a/src/routes/cache-hooks/entities/schemas/deleted-multisig-transaction.schema.ts +++ b/src/routes/hooks/entities/schemas/deleted-multisig-transaction.schema.ts @@ -1,9 +1,9 @@ -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; import { z } from 'zod'; export const DeletedMultisigTransactionEventSchema = z.object({ - type: z.literal(EventType.DELETED_MULTISIG_TRANSACTION), + type: z.literal(TransactionEventType.DELETED_MULTISIG_TRANSACTION), address: AddressSchema, chainId: z.string(), safeTxHash: z.string(), diff --git a/src/routes/hooks/entities/schemas/event.schema.ts b/src/routes/hooks/entities/schemas/event.schema.ts new file mode 100644 index 0000000000..20adcca9ce --- /dev/null +++ b/src/routes/hooks/entities/schemas/event.schema.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; +import { ChainUpdateEventSchema } from '@/routes/hooks/entities/schemas/chain-update.schema'; +import { DeletedMultisigTransactionEventSchema } from '@/routes/hooks/entities/schemas/deleted-multisig-transaction.schema'; +import { ExecutedTransactionEventSchema } from '@/routes/hooks/entities/schemas/executed-transaction.schema'; +import { IncomingEtherEventSchema } from '@/routes/hooks/entities/schemas/incoming-ether.schema'; +import { IncomingTokenEventSchema } from '@/routes/hooks/entities/schemas/incoming-token.schema'; +import { MessageCreatedEventSchema } from '@/routes/hooks/entities/schemas/message-created.schema'; +import { ModuleTransactionEventSchema } from '@/routes/hooks/entities/schemas/module-transaction.schema'; +import { NewConfirmationEventSchema } from '@/routes/hooks/entities/schemas/new-confirmation.schema'; +import { NewMessageConfirmationEventSchema } from '@/routes/hooks/entities/schemas/new-message-confirmation.schema'; +import { OutgoingEtherEventSchema } from '@/routes/hooks/entities/schemas/outgoing-ether.schema'; +import { OutgoingTokenEventSchema } from '@/routes/hooks/entities/schemas/outgoing-token.schema'; +import { PendingTransactionEventSchema } from '@/routes/hooks/entities/schemas/pending-transaction.schema'; +import { SafeAppsUpdateEventSchema } from '@/routes/hooks/entities/schemas/safe-apps-update.schema'; +import { SafeCreatedEventSchema } from '@/routes/hooks/entities/schemas/safe-created.schema'; + +export const EventSchema = z.discriminatedUnion('type', [ + ChainUpdateEventSchema, + DeletedMultisigTransactionEventSchema, + ExecutedTransactionEventSchema, + IncomingEtherEventSchema, + IncomingTokenEventSchema, + MessageCreatedEventSchema, + ModuleTransactionEventSchema, + NewConfirmationEventSchema, + NewMessageConfirmationEventSchema, + OutgoingEtherEventSchema, + OutgoingTokenEventSchema, + PendingTransactionEventSchema, + SafeAppsUpdateEventSchema, + SafeCreatedEventSchema, +]); diff --git a/src/routes/cache-hooks/entities/schemas/executed-transaction.schema.ts b/src/routes/hooks/entities/schemas/executed-transaction.schema.ts similarity index 63% rename from src/routes/cache-hooks/entities/schemas/executed-transaction.schema.ts rename to src/routes/hooks/entities/schemas/executed-transaction.schema.ts index 563faaf020..a3aedfd705 100644 --- a/src/routes/cache-hooks/entities/schemas/executed-transaction.schema.ts +++ b/src/routes/hooks/entities/schemas/executed-transaction.schema.ts @@ -1,9 +1,9 @@ -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; import { z } from 'zod'; export const ExecutedTransactionEventSchema = z.object({ - type: z.literal(EventType.EXECUTED_MULTISIG_TRANSACTION), + type: z.literal(TransactionEventType.EXECUTED_MULTISIG_TRANSACTION), address: AddressSchema, chainId: z.string(), safeTxHash: z.string(), diff --git a/src/routes/cache-hooks/entities/schemas/incoming-ether.schema.ts b/src/routes/hooks/entities/schemas/incoming-ether.schema.ts similarity index 64% rename from src/routes/cache-hooks/entities/schemas/incoming-ether.schema.ts rename to src/routes/hooks/entities/schemas/incoming-ether.schema.ts index 444ce4ae43..3368109653 100644 --- a/src/routes/cache-hooks/entities/schemas/incoming-ether.schema.ts +++ b/src/routes/hooks/entities/schemas/incoming-ether.schema.ts @@ -1,9 +1,9 @@ -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; import { z } from 'zod'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; export const IncomingEtherEventSchema = z.object({ - type: z.literal(EventType.INCOMING_ETHER), + type: z.literal(TransactionEventType.INCOMING_ETHER), address: AddressSchema, chainId: z.string(), txHash: z.string(), diff --git a/src/routes/cache-hooks/entities/schemas/incoming-token.schema.ts b/src/routes/hooks/entities/schemas/incoming-token.schema.ts similarity index 65% rename from src/routes/cache-hooks/entities/schemas/incoming-token.schema.ts rename to src/routes/hooks/entities/schemas/incoming-token.schema.ts index 4970b714d7..ab27034233 100644 --- a/src/routes/cache-hooks/entities/schemas/incoming-token.schema.ts +++ b/src/routes/hooks/entities/schemas/incoming-token.schema.ts @@ -1,9 +1,9 @@ -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; import { z } from 'zod'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; export const IncomingTokenEventSchema = z.object({ - type: z.literal(EventType.INCOMING_TOKEN), + type: z.literal(TransactionEventType.INCOMING_TOKEN), address: AddressSchema, chainId: z.string(), tokenAddress: AddressSchema, diff --git a/src/routes/cache-hooks/entities/schemas/message-created.schema.ts b/src/routes/hooks/entities/schemas/message-created.schema.ts similarity index 62% rename from src/routes/cache-hooks/entities/schemas/message-created.schema.ts rename to src/routes/hooks/entities/schemas/message-created.schema.ts index 157d09f04c..284be87556 100644 --- a/src/routes/cache-hooks/entities/schemas/message-created.schema.ts +++ b/src/routes/hooks/entities/schemas/message-created.schema.ts @@ -1,9 +1,9 @@ -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; import { z } from 'zod'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; export const MessageCreatedEventSchema = z.object({ - type: z.literal(EventType.MESSAGE_CREATED), + type: z.literal(TransactionEventType.MESSAGE_CREATED), address: AddressSchema, chainId: z.string(), messageHash: z.string(), diff --git a/src/routes/cache-hooks/entities/schemas/module-transaction.schema.ts b/src/routes/hooks/entities/schemas/module-transaction.schema.ts similarity index 64% rename from src/routes/cache-hooks/entities/schemas/module-transaction.schema.ts rename to src/routes/hooks/entities/schemas/module-transaction.schema.ts index 4a07292e62..8d03cace54 100644 --- a/src/routes/cache-hooks/entities/schemas/module-transaction.schema.ts +++ b/src/routes/hooks/entities/schemas/module-transaction.schema.ts @@ -1,9 +1,9 @@ -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; import { z } from 'zod'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; export const ModuleTransactionEventSchema = z.object({ - type: z.literal(EventType.MODULE_TRANSACTION), + type: z.literal(TransactionEventType.MODULE_TRANSACTION), address: AddressSchema, chainId: z.string(), module: AddressSchema, diff --git a/src/routes/cache-hooks/entities/schemas/new-confirmation.schema.ts b/src/routes/hooks/entities/schemas/new-confirmation.schema.ts similarity index 65% rename from src/routes/cache-hooks/entities/schemas/new-confirmation.schema.ts rename to src/routes/hooks/entities/schemas/new-confirmation.schema.ts index 17b04a1154..e8468b0d11 100644 --- a/src/routes/cache-hooks/entities/schemas/new-confirmation.schema.ts +++ b/src/routes/hooks/entities/schemas/new-confirmation.schema.ts @@ -1,9 +1,9 @@ -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; import { z } from 'zod'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; export const NewConfirmationEventSchema = z.object({ - type: z.literal(EventType.NEW_CONFIRMATION), + type: z.literal(TransactionEventType.NEW_CONFIRMATION), address: AddressSchema, chainId: z.string(), owner: AddressSchema, diff --git a/src/routes/cache-hooks/entities/schemas/new-message-confirmation.schema.ts b/src/routes/hooks/entities/schemas/new-message-confirmation.schema.ts similarity index 62% rename from src/routes/cache-hooks/entities/schemas/new-message-confirmation.schema.ts rename to src/routes/hooks/entities/schemas/new-message-confirmation.schema.ts index e409c7b989..cd18980281 100644 --- a/src/routes/cache-hooks/entities/schemas/new-message-confirmation.schema.ts +++ b/src/routes/hooks/entities/schemas/new-message-confirmation.schema.ts @@ -1,9 +1,9 @@ -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; import { z } from 'zod'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; export const NewMessageConfirmationEventSchema = z.object({ - type: z.literal(EventType.MESSAGE_CONFIRMATION), + type: z.literal(TransactionEventType.MESSAGE_CONFIRMATION), address: AddressSchema, chainId: z.string(), messageHash: z.string(), diff --git a/src/routes/cache-hooks/entities/schemas/outgoing-ether.schema.ts b/src/routes/hooks/entities/schemas/outgoing-ether.schema.ts similarity index 64% rename from src/routes/cache-hooks/entities/schemas/outgoing-ether.schema.ts rename to src/routes/hooks/entities/schemas/outgoing-ether.schema.ts index e242894b4a..8eb076999f 100644 --- a/src/routes/cache-hooks/entities/schemas/outgoing-ether.schema.ts +++ b/src/routes/hooks/entities/schemas/outgoing-ether.schema.ts @@ -1,9 +1,9 @@ -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; import { z } from 'zod'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; export const OutgoingEtherEventSchema = z.object({ - type: z.literal(EventType.OUTGOING_ETHER), + type: z.literal(TransactionEventType.OUTGOING_ETHER), address: AddressSchema, chainId: z.string(), txHash: z.string(), diff --git a/src/routes/cache-hooks/entities/schemas/outgoing-token.schema.ts b/src/routes/hooks/entities/schemas/outgoing-token.schema.ts similarity index 65% rename from src/routes/cache-hooks/entities/schemas/outgoing-token.schema.ts rename to src/routes/hooks/entities/schemas/outgoing-token.schema.ts index d1e36e43e8..e5d05c3f97 100644 --- a/src/routes/cache-hooks/entities/schemas/outgoing-token.schema.ts +++ b/src/routes/hooks/entities/schemas/outgoing-token.schema.ts @@ -1,9 +1,9 @@ -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; import { z } from 'zod'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; export const OutgoingTokenEventSchema = z.object({ - type: z.literal(EventType.OUTGOING_TOKEN), + type: z.literal(TransactionEventType.OUTGOING_TOKEN), address: AddressSchema, chainId: z.string(), tokenAddress: AddressSchema, diff --git a/src/routes/cache-hooks/entities/schemas/pending-transaction.schema.ts b/src/routes/hooks/entities/schemas/pending-transaction.schema.ts similarity index 61% rename from src/routes/cache-hooks/entities/schemas/pending-transaction.schema.ts rename to src/routes/hooks/entities/schemas/pending-transaction.schema.ts index 960afd46c0..026dbe9c46 100644 --- a/src/routes/cache-hooks/entities/schemas/pending-transaction.schema.ts +++ b/src/routes/hooks/entities/schemas/pending-transaction.schema.ts @@ -1,9 +1,9 @@ -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; import { z } from 'zod'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; export const PendingTransactionEventSchema = z.object({ - type: z.literal(EventType.PENDING_MULTISIG_TRANSACTION), + type: z.literal(TransactionEventType.PENDING_MULTISIG_TRANSACTION), address: AddressSchema, chainId: z.string(), safeTxHash: z.string(), diff --git a/src/routes/cache-hooks/entities/schemas/safe-apps-update.schema.ts b/src/routes/hooks/entities/schemas/safe-apps-update.schema.ts similarity index 50% rename from src/routes/cache-hooks/entities/schemas/safe-apps-update.schema.ts rename to src/routes/hooks/entities/schemas/safe-apps-update.schema.ts index f900afb68c..44de276100 100644 --- a/src/routes/cache-hooks/entities/schemas/safe-apps-update.schema.ts +++ b/src/routes/hooks/entities/schemas/safe-apps-update.schema.ts @@ -1,7 +1,7 @@ -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; +import { ConfigEventType } from '@/routes/hooks/entities/event-type.entity'; import { z } from 'zod'; export const SafeAppsUpdateEventSchema = z.object({ - type: z.literal(EventType.SAFE_APPS_UPDATE), + type: z.literal(ConfigEventType.SAFE_APPS_UPDATE), chainId: z.string(), }); diff --git a/src/routes/cache-hooks/entities/schemas/safe-created.schema.ts b/src/routes/hooks/entities/schemas/safe-created.schema.ts similarity index 63% rename from src/routes/cache-hooks/entities/schemas/safe-created.schema.ts rename to src/routes/hooks/entities/schemas/safe-created.schema.ts index 74125cdc1a..15f35134a6 100644 --- a/src/routes/cache-hooks/entities/schemas/safe-created.schema.ts +++ b/src/routes/hooks/entities/schemas/safe-created.schema.ts @@ -1,9 +1,9 @@ -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; import { z } from 'zod'; export const SafeCreatedEventSchema = z.object({ - type: z.literal(EventType.SAFE_CREATED), + type: z.literal(TransactionEventType.SAFE_CREATED), chainId: z.string(), address: AddressSchema, blockNumber: z.number(), diff --git a/src/routes/cache-hooks/errors/event-protocol-changed.error.ts b/src/routes/hooks/errors/event-protocol-changed.error.ts similarity index 100% rename from src/routes/cache-hooks/errors/event-protocol-changed.error.ts rename to src/routes/hooks/errors/event-protocol-changed.error.ts diff --git a/src/routes/cache-hooks/filters/event-protocol-changed.filter.ts b/src/routes/hooks/filters/event-protocol-changed.filter.ts similarity index 84% rename from src/routes/cache-hooks/filters/event-protocol-changed.filter.ts rename to src/routes/hooks/filters/event-protocol-changed.filter.ts index b2d0a4c73d..7b1e7efbee 100644 --- a/src/routes/cache-hooks/filters/event-protocol-changed.filter.ts +++ b/src/routes/hooks/filters/event-protocol-changed.filter.ts @@ -1,4 +1,4 @@ -import { EventProtocolChangedError } from '@/routes/cache-hooks/errors/event-protocol-changed.error'; +import { EventProtocolChangedError } from '@/routes/hooks/errors/event-protocol-changed.error'; import { ArgumentsHost, Catch, diff --git a/src/routes/cache-hooks/cache-hooks.controller.spec.ts b/src/routes/hooks/hooks-cache.controller.spec.ts similarity index 99% rename from src/routes/cache-hooks/cache-hooks.controller.spec.ts rename to src/routes/hooks/hooks-cache.controller.spec.ts index fcdeb1adcf..2b7627494d 100644 --- a/src/routes/cache-hooks/cache-hooks.controller.spec.ts +++ b/src/routes/hooks/hooks-cache.controller.spec.ts @@ -25,11 +25,11 @@ import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; import { IBlockchainApiManager } from '@/domain/interfaces/blockchain-api.manager.interface'; -import { safeCreatedEventBuilder } from '@/routes/cache-hooks/entities/__tests__/safe-created.build'; +import { safeCreatedEventBuilder } from '@/routes/hooks/entities/__tests__/safe-created.build'; import { ITransactionApiManager } from '@/domain/interfaces/transaction-api.manager.interface'; import { IBalancesApiManager } from '@/domain/interfaces/balances-api.manager.interface'; -describe('Post Hook Events (Unit)', () => { +describe('Post Hook Events for Cache (Unit)', () => { let app: INestApplication; let authToken: string; let safeConfigUrl: string; diff --git a/src/routes/cache-hooks/cache-hooks.controller.ts b/src/routes/hooks/hooks.controller.ts similarity index 58% rename from src/routes/cache-hooks/cache-hooks.controller.ts rename to src/routes/hooks/hooks.controller.ts index 4d7da879f7..66f0dd185b 100644 --- a/src/routes/cache-hooks/cache-hooks.controller.ts +++ b/src/routes/hooks/hooks.controller.ts @@ -8,31 +8,27 @@ import { UseGuards, } from '@nestjs/common'; import { ApiExcludeController } from '@nestjs/swagger'; -import { CacheHooksService } from '@/routes/cache-hooks/cache-hooks.service'; +import { HooksService } from '@/routes/hooks/hooks.service'; import { ValidationPipe } from '@/validation/pipes/validation.pipe'; import { BasicAuthGuard } from '@/routes/common/auth/basic-auth.guard'; -import { Event } from '@/routes/cache-hooks/entities/event.entity'; -import { WebHookSchema } from '@/routes/cache-hooks/entities/schemas/web-hook.schema'; +import { Event } from '@/routes/hooks/entities/event.entity'; +import { EventSchema } from '@/routes/hooks/entities/schemas/event.schema'; import { ILoggingService, LoggingService } from '@/logging/logging.interface'; import { IConfigurationService } from '@/config/configuration.service.interface'; -import { EventProtocolChangedError } from '@/routes/cache-hooks/errors/event-protocol-changed.error'; -import { EventProtocolChangedFilter } from '@/routes/cache-hooks/filters/event-protocol-changed.filter'; -import { EventType } from '@/routes/cache-hooks/entities/event-type.entity'; +import { EventProtocolChangedError } from '@/routes/hooks/errors/event-protocol-changed.error'; +import { EventProtocolChangedFilter } from '@/routes/hooks/filters/event-protocol-changed.filter'; +import { ConfigEventType } from '@/routes/hooks/entities/event-type.entity'; @Controller({ path: '', version: '1', }) @ApiExcludeController() -export class CacheHooksController { +export class HooksController { private readonly isEventsQueueEnabled: boolean; - private readonly configServiceEventTypes = [ - EventType.CHAIN_UPDATE, - EventType.SAFE_APPS_UPDATE, - ]; constructor( - private readonly service: CacheHooksService, + private readonly hooksService: HooksService, @Inject(LoggingService) private readonly loggingService: ILoggingService, @Inject(IConfigurationService) private readonly configurationService: IConfigurationService, @@ -46,9 +42,9 @@ export class CacheHooksController { @Post('/hooks/events') @UseFilters(EventProtocolChangedFilter) @HttpCode(202) - postEvent(@Body(new ValidationPipe(WebHookSchema)) event: Event): void { + postEvent(@Body(new ValidationPipe(EventSchema)) event: Event): void { if (!this.isEventsQueueEnabled || this.isHttpEvent(event)) { - this.service.onEvent(event).catch((error) => { + this.hooksService.onEvent(event).catch((error) => { this.loggingService.error(error); }); } else { @@ -57,6 +53,8 @@ export class CacheHooksController { } private isHttpEvent(event: Event): boolean { - return this.configServiceEventTypes.includes(event.type); + return Object.values(ConfigEventType).includes( + event.type as ConfigEventType, + ); } } diff --git a/src/routes/hooks/hooks.module.ts b/src/routes/hooks/hooks.module.ts new file mode 100644 index 0000000000..99cff20bf2 --- /dev/null +++ b/src/routes/hooks/hooks.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { HooksController } from '@/routes/hooks/hooks.controller'; +import { HooksRepositoryModule } from '@/domain/hooks/hooks.repository.interface'; +import { HooksService } from '@/routes/hooks/hooks.service'; + +@Module({ + imports: [HooksRepositoryModule], + providers: [HooksService], + controllers: [HooksController], +}) +export class HooksModule {} diff --git a/src/routes/cache-hooks/cache-hooks.service.ts b/src/routes/hooks/hooks.service.ts similarity index 78% rename from src/routes/cache-hooks/cache-hooks.service.ts rename to src/routes/hooks/hooks.service.ts index c046d65df2..20f97dfae9 100644 --- a/src/routes/cache-hooks/cache-hooks.service.ts +++ b/src/routes/hooks/hooks.service.ts @@ -1,9 +1,9 @@ import { Inject, Injectable } from '@nestjs/common'; -import { Event } from '@/routes/cache-hooks/entities/event.entity'; +import { Event } from '@/routes/hooks/entities/event.entity'; import { IHooksRepository } from '@/domain/hooks/hooks.repository.interface'; @Injectable() -export class CacheHooksService { +export class HooksService { constructor( @Inject(IHooksRepository) private readonly hooksRepository: IHooksRepository, From f6237e2de5597997ad5a72273c5e513610fcd7c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Mon, 22 Jul 2024 15:01:26 +0200 Subject: [PATCH 196/207] Add Counterfactual Safes Datasource (#1773) Adds `CounterfactualSafesDatasource`, database migration, and associated tests. --- .../00004_counterfactual-safes/index.sql | 23 + .../00004_counterfactual-safes.spec.ts | 236 ++++++++++ .../accounts/accounts.datasource.spec.ts | 69 ++- .../accounts/accounts.datasource.ts | 72 +-- .../counterfactual-safes.datasource.spec.ts | 420 ++++++++++++++++++ .../counterfactual-safes.datasource.ts | 163 +++++++ src/datasources/cache/cache.router.ts | 19 + src/datasources/db/utils.ts | 48 ++ .../accounts/accounts.repository.interface.ts | 2 +- src/domain/accounts/accounts.repository.ts | 13 +- ...-counterfactual-safe.dto.entity.builder.ts | 18 + .../entities/counterfactual-safe.entity.ts | 18 + .../create-counterfactual-safe.dto.entity.ts | 34 ++ .../entities/__tests__/account.builder.ts | 4 +- .../accounts.datasource.interface.ts | 8 +- ...unterfactual-safes.datasource.interface.ts | 31 ++ .../accounts/accounts.controller.spec.ts | 4 +- src/routes/accounts/accounts.service.ts | 2 +- 18 files changed, 1103 insertions(+), 81 deletions(-) create mode 100644 migrations/00004_counterfactual-safes/index.sql create mode 100644 migrations/__tests__/00004_counterfactual-safes.spec.ts create mode 100644 src/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource.spec.ts create mode 100644 src/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource.ts create mode 100644 src/datasources/db/utils.ts create mode 100644 src/domain/accounts/counterfactual-safes/entities/__tests__/create-counterfactual-safe.dto.entity.builder.ts create mode 100644 src/domain/accounts/counterfactual-safes/entities/counterfactual-safe.entity.ts create mode 100644 src/domain/accounts/counterfactual-safes/entities/create-counterfactual-safe.dto.entity.ts create mode 100644 src/domain/interfaces/counterfactual-safes.datasource.interface.ts diff --git a/migrations/00004_counterfactual-safes/index.sql b/migrations/00004_counterfactual-safes/index.sql new file mode 100644 index 0000000000..e76b6bac77 --- /dev/null +++ b/migrations/00004_counterfactual-safes/index.sql @@ -0,0 +1,23 @@ +DROP TABLE IF EXISTS counterfactual_safes CASCADE; + +CREATE TABLE counterfactual_safes ( + id SERIAL PRIMARY KEY, + chain_id VARCHAR(32) NOT NULL, + creator VARCHAR(42) NOT NULL, + fallback_handler VARCHAR(42) NOT NULL, + owners VARCHAR(42)[] NOT NULL, + predicted_address VARCHAR(42) NOT NULL, + salt_nonce VARCHAR(255) NOT NULL, + singleton_address VARCHAR(42) NOT NULL, + threshold INTEGER NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + account_id INTEGER NOT NULL, + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, + CONSTRAINT unique_chain_address UNIQUE (account_id, chain_id, predicted_address) +); + +CREATE OR REPLACE TRIGGER update_counterfactual_safes_updated_at +BEFORE UPDATE ON counterfactual_safes +FOR EACH ROW +EXECUTE FUNCTION update_updated_at_column(); diff --git a/migrations/__tests__/00004_counterfactual-safes.spec.ts b/migrations/__tests__/00004_counterfactual-safes.spec.ts new file mode 100644 index 0000000000..fc641dd85e --- /dev/null +++ b/migrations/__tests__/00004_counterfactual-safes.spec.ts @@ -0,0 +1,236 @@ +import { TestDbFactory } from '@/__tests__/db.factory'; +import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; +import { faker } from '@faker-js/faker'; +import postgres from 'postgres'; +import { getAddress } from 'viem'; + +interface AccountRow { + id: number; + group_id: number; + created_at: Date; + updated_at: Date; + address: `0x${string}`; +} + +interface CounterfactualSafesRow { + created_at: Date; + updated_at: Date; + id: number; + chain_id: string; + creator: `0x${string}`; + fallback_handler: `0x${string}`; + owners: `0x${string}`[]; + predicted_address: `0x${string}`; + salt_nonce: string; + singleton_address: `0x${string}`; + threshold: number; + account_id: number; +} + +describe('Migration 00004_counterfactual-safes', () => { + let sql: postgres.Sql; + let migrator: PostgresDatabaseMigrator; + const testDbFactory = new TestDbFactory(); + + beforeAll(async () => { + sql = await testDbFactory.createTestDatabase(faker.string.uuid()); + migrator = new PostgresDatabaseMigrator(sql); + }); + + afterAll(async () => { + await testDbFactory.destroyTestDatabase(sql); + }); + + it('runs successfully', async () => { + const result = await migrator.test({ + migration: '00004_counterfactual-safes', + after: async (sql: postgres.Sql) => { + return { + account_data_types: { + columns: + await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'counterfactual_safes'`, + rows: await sql`SELECT * FROM account_data_settings`, + }, + }; + }, + }); + + expect(result.after).toStrictEqual({ + account_data_types: { + columns: expect.arrayContaining([ + { column_name: 'id' }, + { column_name: 'created_at' }, + { column_name: 'updated_at' }, + { column_name: 'chain_id' }, + { column_name: 'creator' }, + { column_name: 'fallback_handler' }, + { column_name: 'owners' }, + { column_name: 'predicted_address' }, + { column_name: 'salt_nonce' }, + { column_name: 'singleton_address' }, + { column_name: 'threshold' }, + { column_name: 'account_id' }, + ]), + rows: [], + }, + }); + }); + + it('should add one CounterfactualSafe and update its row timestamps', async () => { + const accountAddress = getAddress(faker.finance.ethereumAddress()); + let accountRows: AccountRow[] = []; + let counterfactualSafes: Partial[] = []; + + const { + after: counterfactualSafesRows, + }: { after: CounterfactualSafesRow[] } = await migrator.test({ + migration: '00004_counterfactual-safes', + after: async (sql: postgres.Sql): Promise => { + accountRows = await sql< + AccountRow[] + >`INSERT INTO accounts (address) VALUES (${accountAddress}) RETURNING *;`; + counterfactualSafes = [ + { + chain_id: faker.string.numeric(), + creator: accountAddress, + fallback_handler: getAddress(faker.finance.ethereumAddress()), + owners: [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ], + predicted_address: getAddress(faker.finance.ethereumAddress()), + salt_nonce: faker.string.numeric(), + singleton_address: getAddress(faker.finance.ethereumAddress()), + threshold: faker.number.int({ min: 1, max: 10 }), + account_id: accountRows[0].id, + }, + ]; + return sql< + CounterfactualSafesRow[] + >`INSERT INTO counterfactual_safes ${sql(counterfactualSafes)} RETURNING *`; + }, + }); + + expect(counterfactualSafesRows[0]).toMatchObject({ + chain_id: counterfactualSafes[0].chain_id, + creator: counterfactualSafes[0].creator, + fallback_handler: counterfactualSafes[0].fallback_handler, + owners: counterfactualSafes[0].owners, + predicted_address: counterfactualSafes[0].predicted_address, + salt_nonce: counterfactualSafes[0].salt_nonce, + singleton_address: counterfactualSafes[0].singleton_address, + threshold: counterfactualSafes[0].threshold, + account_id: accountRows[0].id, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }); + // created_at and updated_at should be the same after the row is created + const createdAt = new Date(counterfactualSafesRows[0].created_at); + const updatedAt = new Date(counterfactualSafesRows[0].updated_at); + expect(createdAt).toBeInstanceOf(Date); + expect(createdAt).toStrictEqual(updatedAt); + // only updated_at should be updated after the row is updated + const afterUpdate = await sql< + CounterfactualSafesRow[] + >`UPDATE counterfactual_safes + SET threshold = 4 + WHERE account_id = ${accountRows[0].id} + RETURNING *;`; + const updatedAtAfterUpdate = new Date(afterUpdate[0].updated_at); + const createdAtAfterUpdate = new Date(afterUpdate[0].created_at); + expect(createdAtAfterUpdate).toStrictEqual(createdAt); + expect(updatedAtAfterUpdate.getTime()).toBeGreaterThan(createdAt.getTime()); + }); + + it('should trigger a cascade delete when the referenced account is deleted', async () => { + const accountAddress = getAddress(faker.finance.ethereumAddress()); + let accountRows: AccountRow[] = []; + + const { + after: counterfactualSafesRows, + }: { after: CounterfactualSafesRow[] } = await migrator.test({ + migration: '00004_counterfactual-safes', + after: async (sql: postgres.Sql): Promise => { + accountRows = await sql< + AccountRow[] + >`INSERT INTO accounts (address) VALUES (${accountAddress}) RETURNING *;`; + await sql< + CounterfactualSafesRow[] + >`INSERT INTO counterfactual_safes ${sql([ + { + chain_id: faker.string.numeric(), + creator: accountAddress, + fallback_handler: getAddress(faker.finance.ethereumAddress()), + owners: [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ], + predicted_address: getAddress(faker.finance.ethereumAddress()), + salt_nonce: faker.string.numeric(), + singleton_address: getAddress(faker.finance.ethereumAddress()), + threshold: faker.number.int({ min: 1, max: 10 }), + account_id: accountRows[0].id, + }, + ])}`; + await sql`DELETE FROM accounts WHERE id = ${accountRows[0].id};`; + return sql< + CounterfactualSafesRow[] + >`SELECT * FROM counterfactual_safes WHERE account_id = ${accountRows[0].id}`; + }, + }); + + expect(counterfactualSafesRows).toHaveLength(0); + }); + + it('should throw an error if the unique(account_id, chain_id, predicted_address) constraint is violated', async () => { + const accountAddress = getAddress(faker.finance.ethereumAddress()); + let accountRows: AccountRow[] = []; + + await migrator.test({ + migration: '00004_counterfactual-safes', + after: async (sql: postgres.Sql) => { + accountRows = await sql< + AccountRow[] + >`INSERT INTO accounts (address) VALUES (${accountAddress}) RETURNING *;`; + const predicted_address = getAddress(faker.finance.ethereumAddress()); + const chain_id = faker.string.numeric(); + await sql< + CounterfactualSafesRow[] + >`INSERT INTO counterfactual_safes ${sql([ + { + chain_id, + creator: accountAddress, + fallback_handler: getAddress(faker.finance.ethereumAddress()), + owners: [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ], + predicted_address, + salt_nonce: faker.string.numeric(), + singleton_address: getAddress(faker.finance.ethereumAddress()), + threshold: faker.number.int({ min: 1, max: 10 }), + account_id: accountRows[0].id, + }, + ])}`; + await expect( + sql`INSERT INTO counterfactual_safes ${sql([ + { + chain_id, + creator: accountAddress, + fallback_handler: getAddress(faker.finance.ethereumAddress()), + owners: [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ], + predicted_address, + salt_nonce: faker.string.numeric(), + singleton_address: getAddress(faker.finance.ethereumAddress()), + threshold: faker.number.int({ min: 1, max: 10 }), + account_id: accountRows[0].id, + }, + ])}`, + ).rejects.toThrow('duplicate key value violates unique constraint'); + }, + }); + }); +}); diff --git a/src/datasources/accounts/accounts.datasource.spec.ts b/src/datasources/accounts/accounts.datasource.spec.ts index c55c3b125d..604e707bd9 100644 --- a/src/datasources/accounts/accounts.datasource.spec.ts +++ b/src/datasources/accounts/accounts.datasource.spec.ts @@ -2,6 +2,7 @@ import { TestDbFactory } from '@/__tests__/db.factory'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { AccountsDatasource } from '@/datasources/accounts/accounts.datasource'; import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.service'; +import { MAX_TTL } from '@/datasources/cache/constants'; import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity'; import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; import { accountDataTypeBuilder } from '@/domain/accounts/entities/__tests__/account-data-type.builder'; @@ -200,14 +201,43 @@ describe('AccountsDatasource tests', () => { }), ); + // store settings and counterfactual safes in the cache + const accountDataSettingsCacheDir = new CacheDir( + `account_data_settings_${address}`, + '', + ); + await fakeCacheService.set( + accountDataSettingsCacheDir, + faker.string.alpha(), + MAX_TTL, + ); + const counterfactualSafesCacheDir = new CacheDir( + `counterfactual_safes_${address}`, + '', + ); + await fakeCacheService.set( + counterfactualSafesCacheDir, + faker.string.alpha(), + MAX_TTL, + ); + // the account is deleted from the database and the cache await expect(target.deleteAccount(address)).resolves.not.toThrow(); await expect(target.getAccount(address)).rejects.toThrow(); - const cached = await fakeCacheService.get( - new CacheDir(`account_${address}`, ''), - ); + const accountCacheDir = new CacheDir(`account_${address}`, ''); + const cached = await fakeCacheService.get(accountCacheDir); expect(cached).toBeUndefined(); + // the settings and counterfactual safes are deleted from the cache + const accountDataSettingsCached = await fakeCacheService.get( + accountDataSettingsCacheDir, + ); + expect(accountDataSettingsCached).toBeUndefined(); + const counterfactualSafesCached = await fakeCacheService.get( + counterfactualSafesCacheDir, + ); + expect(counterfactualSafesCached).toBeUndefined(); + expect(mockLoggingService.debug).toHaveBeenCalledTimes(2); expect(mockLoggingService.debug).toHaveBeenNthCalledWith(1, { type: 'cache_hit', @@ -450,10 +480,10 @@ describe('AccountsDatasource tests', () => { .with('accountDataSettings', accountDataSettings) .build(); - const actual = await target.upsertAccountDataSettings( + const actual = await target.upsertAccountDataSettings({ address, upsertAccountDataSettingsDto, - ); + }); const expected = accountDataSettings.map((accountDataSetting) => ({ account_id: account.id, @@ -483,10 +513,10 @@ describe('AccountsDatasource tests', () => { .with('accountDataSettings', accountDataSettings) .build(); - await target.upsertAccountDataSettings( + await target.upsertAccountDataSettings({ address, upsertAccountDataSettingsDto, - ); + }); // check the account data settings are stored in the cache const cacheDir = new CacheDir(`account_data_settings_${address}`, ''); @@ -521,10 +551,10 @@ describe('AccountsDatasource tests', () => { .with('accountDataSettings', accountDataSettings) .build(); - const beforeUpdate = await target.upsertAccountDataSettings( + const beforeUpdate = await target.upsertAccountDataSettings({ address, upsertAccountDataSettingsDto, - ); + }); expect(beforeUpdate).toStrictEqual( expect.arrayContaining( @@ -547,10 +577,10 @@ describe('AccountsDatasource tests', () => { .with('accountDataSettings', accountDataSettings2) .build(); - const afterUpdate = await target.upsertAccountDataSettings( + const afterUpdate = await target.upsertAccountDataSettings({ address, - upsertAccountDataSettingsDto2, - ); + upsertAccountDataSettingsDto: upsertAccountDataSettingsDto2, + }); expect(afterUpdate).toStrictEqual( expect.arrayContaining( @@ -582,7 +612,10 @@ describe('AccountsDatasource tests', () => { .build(); await expect( - target.upsertAccountDataSettings(address, upsertAccountDataSettingsDto), + target.upsertAccountDataSettings({ + address, + upsertAccountDataSettingsDto, + }), ).rejects.toThrow('Error getting account.'); }); @@ -608,7 +641,10 @@ describe('AccountsDatasource tests', () => { }); await expect( - target.upsertAccountDataSettings(address, upsertAccountDataSettingsDto), + target.upsertAccountDataSettings({ + address, + upsertAccountDataSettingsDto, + }), ).rejects.toThrow('Data types not found or not active.'); }); @@ -630,7 +666,10 @@ describe('AccountsDatasource tests', () => { .build(); await expect( - target.upsertAccountDataSettings(address, upsertAccountDataSettingsDto), + target.upsertAccountDataSettings({ + address, + upsertAccountDataSettingsDto, + }), ).rejects.toThrow(`Data types not found or not active.`); }); }); diff --git a/src/datasources/accounts/accounts.datasource.ts b/src/datasources/accounts/accounts.datasource.ts index a2d275329b..5e5067e1df 100644 --- a/src/datasources/accounts/accounts.datasource.ts +++ b/src/datasources/accounts/accounts.datasource.ts @@ -5,7 +5,7 @@ import { ICacheService, } from '@/datasources/cache/cache.service.interface'; import { MAX_TTL } from '@/datasources/cache/constants'; -import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity'; +import { getFromCacheOrExecuteAndCache } from '@/datasources/db/utils'; import { AccountDataSetting } from '@/domain/accounts/entities/account-data-setting.entity'; import { AccountDataType } from '@/domain/accounts/entities/account-data-type.entity'; import { Account } from '@/domain/accounts/entities/account.entity'; @@ -16,7 +16,6 @@ import { asError } from '@/logging/utils'; import { Inject, Injectable, - InternalServerErrorException, NotFoundException, OnModuleInit, UnprocessableEntityException, @@ -71,7 +70,9 @@ export class AccountsDatasource implements IAccountsDatasource, OnModuleInit { async getAccount(address: `0x${string}`): Promise { const cacheDir = CacheRouter.getAccountCacheDir(address); - const [account] = await this.getFromCacheOrExecuteAndCache( + const [account] = await getFromCacheOrExecuteAndCache( + this.loggingService, + this.cacheService, cacheDir, this.sql`SELECT * FROM accounts WHERE address = ${address}`, this.defaultExpirationTimeInSeconds, @@ -94,14 +95,20 @@ export class AccountsDatasource implements IAccountsDatasource, OnModuleInit { ); } } finally { - const { key } = CacheRouter.getAccountCacheDir(address); - await this.cacheService.deleteByKey(key); + const keys = [ + CacheRouter.getAccountCacheDir(address).key, + CacheRouter.getAccountDataSettingsCacheDir(address).key, + CacheRouter.getCounterfactualSafesCacheDir(address).key, + ]; + await Promise.all(keys.map((key) => this.cacheService.deleteByKey(key))); } } async getDataTypes(): Promise { const cacheDir = CacheRouter.getAccountDataTypesCacheDir(); - return this.getFromCacheOrExecuteAndCache( + return getFromCacheOrExecuteAndCache( + this.loggingService, + this.cacheService, cacheDir, this.sql`SELECT * FROM account_data_types`, MAX_TTL, @@ -113,7 +120,9 @@ export class AccountsDatasource implements IAccountsDatasource, OnModuleInit { ): Promise { const account = await this.getAccount(address); const cacheDir = CacheRouter.getAccountDataSettingsCacheDir(address); - return this.getFromCacheOrExecuteAndCache( + return getFromCacheOrExecuteAndCache( + this.loggingService, + this.cacheService, cacheDir, this.sql` SELECT ads.* FROM account_data_settings ads INNER JOIN account_data_types adt @@ -134,13 +143,13 @@ export class AccountsDatasource implements IAccountsDatasource, OnModuleInit { * @param upsertAccountDataSettings {@link UpsertAccountDataSettingsDto} object. * @returns {Array} inserted account data settings. */ - async upsertAccountDataSettings( - address: `0x${string}`, - upsertAccountDataSettings: UpsertAccountDataSettingsDto, - ): Promise { - const { accountDataSettings } = upsertAccountDataSettings; + async upsertAccountDataSettings(args: { + address: `0x${string}`; + upsertAccountDataSettingsDto: UpsertAccountDataSettingsDto; + }): Promise { + const { accountDataSettings } = args.upsertAccountDataSettingsDto; await this.checkDataTypes(accountDataSettings); - const account = await this.getAccount(address); + const account = await this.getAccount(args.address); const result = await this.sql.begin(async (sql) => { await Promise.all( @@ -160,7 +169,7 @@ export class AccountsDatasource implements IAccountsDatasource, OnModuleInit { SELECT * FROM account_data_settings WHERE account_id = ${account.id}`; }); - const cacheDir = CacheRouter.getAccountDataSettingsCacheDir(address); + const cacheDir = CacheRouter.getAccountDataSettingsCacheDir(args.address); await this.cacheService.set( cacheDir, JSON.stringify(result), @@ -186,39 +195,4 @@ export class AccountsDatasource implements IAccountsDatasource, OnModuleInit { ); } } - - /** - * Returns the content from cache or executes the query and caches the result. - * If the specified {@link CacheDir} is empty, the query is executed and the result is cached. - * If the specified {@link CacheDir} is not empty, the pointed content is returned. - * - * @param cacheDir {@link CacheDir} to use for caching - * @param query query to execute - * @param ttl time to live for the cache - * @returns content from cache or query result - */ - private async getFromCacheOrExecuteAndCache( - cacheDir: CacheDir, - query: postgres.PendingQuery, - ttl: number, - ): Promise { - const { key, field } = cacheDir; - const cached = await this.cacheService.get(cacheDir); - if (cached != null) { - this.loggingService.debug({ type: 'cache_hit', key, field }); - return JSON.parse(cached); - } - this.loggingService.debug({ type: 'cache_miss', key, field }); - - // log & hide database errors - const result = await query.catch((e) => { - this.loggingService.error(asError(e).message); - throw new InternalServerErrorException(); - }); - - if (result.count > 0) { - await this.cacheService.set(cacheDir, JSON.stringify(result), ttl); - } - return result; - } } diff --git a/src/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource.spec.ts b/src/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource.spec.ts new file mode 100644 index 0000000000..957b89e087 --- /dev/null +++ b/src/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource.spec.ts @@ -0,0 +1,420 @@ +import { TestDbFactory } from '@/__tests__/db.factory'; +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { CounterfactualSafesDatasource } from '@/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource'; +import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.service'; +import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity'; +import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; +import { createCounterfactualSafeDtoBuilder } from '@/domain/accounts/counterfactual-safes/entities/__tests__/create-counterfactual-safe.dto.entity.builder'; +import { accountBuilder } from '@/domain/accounts/entities/__tests__/account.builder'; +import { Account } from '@/domain/accounts/entities/account.entity'; +import { ILoggingService } from '@/logging/logging.interface'; +import { faker } from '@faker-js/faker'; +import postgres from 'postgres'; +import { getAddress } from 'viem'; + +const mockLoggingService = { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), +} as jest.MockedObjectDeep; + +const mockConfigurationService = jest.mocked({ + getOrThrow: jest.fn(), +} as jest.MockedObjectDeep); + +describe('CounterfactualSafesDatasource tests', () => { + let fakeCacheService: FakeCacheService; + let sql: postgres.Sql; + let migrator: PostgresDatabaseMigrator; + let target: CounterfactualSafesDatasource; + const testDbFactory = new TestDbFactory(); + + beforeAll(async () => { + fakeCacheService = new FakeCacheService(); + sql = await testDbFactory.createTestDatabase(faker.string.uuid()); + migrator = new PostgresDatabaseMigrator(sql); + await migrator.migrate(); + mockConfigurationService.getOrThrow.mockImplementation((key) => { + if (key === 'expirationTimeInSeconds.default') return faker.number.int(); + }); + + target = new CounterfactualSafesDatasource( + fakeCacheService, + sql, + mockLoggingService, + mockConfigurationService, + ); + }); + + afterEach(async () => { + await sql`TRUNCATE TABLE accounts, account_data_settings, counterfactual_safes CASCADE`; + fakeCacheService.clear(); + jest.clearAllMocks(); + }); + + afterAll(async () => { + await testDbFactory.destroyTestDatabase(sql); + }); + + describe('createCounterfactualSafe', () => { + it('should create a Counterfactual Safe', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const [account] = await sql< + Account[] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING *`; + const createCounterfactualSafeDto = + createCounterfactualSafeDtoBuilder().build(); + + const actual = await target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto, + }); + expect(actual).toStrictEqual( + expect.objectContaining({ + id: expect.any(Number), + chain_id: createCounterfactualSafeDto.chainId, + creator: account.address, + fallback_handler: createCounterfactualSafeDto.fallbackHandler, + owners: createCounterfactualSafeDto.owners, + predicted_address: createCounterfactualSafeDto.predictedAddress, + salt_nonce: createCounterfactualSafeDto.saltNonce, + singleton_address: createCounterfactualSafeDto.singletonAddress, + threshold: createCounterfactualSafeDto.threshold, + account_id: account.id, + }), + ); + }); + + it('should delete the cache for the account Counterfactual Safes', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const [account] = await sql< + Account[] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING *`; + await target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto: + createCounterfactualSafeDtoBuilder().build(), + }); + await target.getCounterfactualSafesForAccount(account); + const cacheDir = new CacheDir(`counterfactual_safes_${address}`, ''); + await fakeCacheService.set( + cacheDir, + JSON.stringify([]), + faker.number.int(), + ); + + // the cache is cleared after creating a new CF Safe for the same account + await target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto: + createCounterfactualSafeDtoBuilder().build(), + }); + expect(await fakeCacheService.get(cacheDir)).toBeUndefined(); + }); + }); + + describe('getCounterfactualSafe', () => { + it('should get a Counterfactual Safe', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const [account] = await sql< + Account[] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING *`; + const counterfactualSafe = await target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto: + createCounterfactualSafeDtoBuilder().build(), + }); + + const actual = await target.getCounterfactualSafe({ + chainId: counterfactualSafe.chain_id, + predictedAddress: counterfactualSafe.predicted_address, + }); + expect(actual).toStrictEqual(counterfactualSafe); + }); + + it('returns a Counterfactual Safe from cache', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const [account] = await sql< + Account[] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING *`; + const counterfactualSafe = await target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto: + createCounterfactualSafeDtoBuilder().build(), + }); + + // first call is not cached + const actual = await target.getCounterfactualSafe({ + chainId: counterfactualSafe.chain_id, + predictedAddress: counterfactualSafe.predicted_address, + }); + await target.getCounterfactualSafe({ + chainId: counterfactualSafe.chain_id, + predictedAddress: counterfactualSafe.predicted_address, + }); + + expect(actual).toStrictEqual(counterfactualSafe); + const cacheDir = new CacheDir( + `${counterfactualSafe.chain_id}_counterfactual_safe_${counterfactualSafe.predicted_address}`, + '', + ); + const cacheContent = await fakeCacheService.get(cacheDir); + expect(JSON.parse(cacheContent as string)).toHaveLength(1); + expect(mockLoggingService.debug).toHaveBeenCalledTimes(2); + expect(mockLoggingService.debug).toHaveBeenNthCalledWith(1, { + type: 'cache_miss', + key: `${counterfactualSafe.chain_id}_counterfactual_safe_${counterfactualSafe.predicted_address}`, + field: '', + }); + expect(mockLoggingService.debug).toHaveBeenNthCalledWith(2, { + type: 'cache_hit', + key: `${counterfactualSafe.chain_id}_counterfactual_safe_${counterfactualSafe.predicted_address}`, + field: '', + }); + }); + + it('should not cache if the Counterfactual Safe is not found', async () => { + const counterfactualSafe = createCounterfactualSafeDtoBuilder().build(); + + // should not cache the Counterfactual Safe + await expect( + target.getCounterfactualSafe({ + chainId: counterfactualSafe.chainId, + predictedAddress: counterfactualSafe.predictedAddress, + }), + ).rejects.toThrow('Error getting Counterfactual Safe.'); + await expect( + target.getCounterfactualSafe({ + chainId: counterfactualSafe.chainId, + predictedAddress: counterfactualSafe.predictedAddress, + }), + ).rejects.toThrow('Error getting Counterfactual Safe.'); + + const cacheDir = new CacheDir( + `${counterfactualSafe.chainId}_counterfactual_safe_${counterfactualSafe.predictedAddress}`, + '', + ); + expect(await fakeCacheService.get(cacheDir)).toBeUndefined(); + expect(mockLoggingService.debug).toHaveBeenCalledTimes(2); + expect(mockLoggingService.debug).toHaveBeenNthCalledWith(1, { + type: 'cache_miss', + key: `${counterfactualSafe.chainId}_counterfactual_safe_${counterfactualSafe.predictedAddress}`, + field: '', + }); + expect(mockLoggingService.debug).toHaveBeenNthCalledWith(2, { + type: 'cache_miss', + key: `${counterfactualSafe.chainId}_counterfactual_safe_${counterfactualSafe.predictedAddress}`, + field: '', + }); + }); + }); + + describe('getCounterfactualSafesForAccount', () => { + it('should get the Counterfactual Safes for an account', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const [account] = await sql< + Account[] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING *`; + const counterfactualSafes = await Promise.all([ + target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto: createCounterfactualSafeDtoBuilder() + .with('chainId', '1') + .build(), + }), + target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto: createCounterfactualSafeDtoBuilder() + .with('chainId', '2') + .build(), + }), + ]); + + const actual = await target.getCounterfactualSafesForAccount(account); + expect(actual).toStrictEqual(expect.arrayContaining(counterfactualSafes)); + }); + + it('should get the Counterfactual Safes for an account from cache', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const [account] = await sql< + Account[] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING *`; + const counterfactualSafes = await Promise.all([ + target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto: createCounterfactualSafeDtoBuilder() + .with('chainId', '1') + .build(), + }), + target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto: createCounterfactualSafeDtoBuilder() + .with('chainId', '2') + .build(), + }), + ]); + + // first call is not cached + const actual = await target.getCounterfactualSafesForAccount(account); + await target.getCounterfactualSafesForAccount(account); + + expect(actual).toStrictEqual(expect.arrayContaining(counterfactualSafes)); + const cacheDir = new CacheDir(`counterfactual_safes_${address}`, ''); + const cacheContent = await fakeCacheService.get(cacheDir); + expect(JSON.parse(cacheContent as string)).toHaveLength(2); + expect(mockLoggingService.debug).toHaveBeenCalledTimes(2); + expect(mockLoggingService.debug).toHaveBeenNthCalledWith(1, { + type: 'cache_miss', + key: `counterfactual_safes_${account.address}`, + field: '', + }); + expect(mockLoggingService.debug).toHaveBeenNthCalledWith(2, { + type: 'cache_hit', + key: `counterfactual_safes_${account.address}`, + field: '', + }); + }); + }); + + describe('deleteCounterfactualSafe', () => { + it('should delete a Counterfactual Safe', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const [account] = await sql< + Account[] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING *`; + const counterfactualSafe = await target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto: + createCounterfactualSafeDtoBuilder().build(), + }); + + await expect( + target.deleteCounterfactualSafe({ + account, + chainId: counterfactualSafe.chain_id, + predictedAddress: counterfactualSafe.predicted_address, + }), + ).resolves.not.toThrow(); + + expect(mockLoggingService.debug).not.toHaveBeenCalled(); + }); + + it('should not throw if no Counterfactual Safe is found', async () => { + await expect( + target.deleteCounterfactualSafe({ + account: accountBuilder().build(), + chainId: faker.string.numeric({ length: 6 }), + predictedAddress: getAddress(faker.finance.ethereumAddress()), + }), + ).resolves.not.toThrow(); + + expect(mockLoggingService.debug).toHaveBeenCalledTimes(1); + }); + + it('should clear the cache on Counterfactual Safe deletion', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const [account] = await sql< + Account[] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING *`; + const counterfactualSafe = await target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto: + createCounterfactualSafeDtoBuilder().build(), + }); + + // the Counterfactual Safe is cached + await target.getCounterfactualSafe({ + chainId: counterfactualSafe.chain_id, + predictedAddress: counterfactualSafe.predicted_address, + }); + const cacheDir = new CacheDir( + `${counterfactualSafe.chain_id}_counterfactual_safe_${counterfactualSafe.predicted_address}`, + '', + ); + const beforeDeletion = await fakeCacheService.get(cacheDir); + expect(JSON.parse(beforeDeletion as string)).toHaveLength(1); + + // the counterfactualSafe is deleted from the database and the cache + await expect( + target.deleteCounterfactualSafe({ + account, + chainId: counterfactualSafe.chain_id, + predictedAddress: counterfactualSafe.predicted_address, + }), + ).resolves.not.toThrow(); + await expect( + target.getCounterfactualSafe({ + chainId: counterfactualSafe.chain_id, + predictedAddress: counterfactualSafe.predicted_address, + }), + ).rejects.toThrow(); + + const afterDeletion = await fakeCacheService.get(cacheDir); + expect(afterDeletion).toBeUndefined(); + const cacheDirByAddress = new CacheDir( + `counterfactual_safes_${address}`, + '', + ); + const cachedByAddress = await fakeCacheService.get(cacheDirByAddress); + expect(cachedByAddress).toBeUndefined(); + }); + }); + + describe('deleteCounterfactualSafesForAccount', () => { + it('should delete all the Counterfactual Safes for an account', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const [account] = await sql< + Account[] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING *`; + const counterfactualSafes = await Promise.all([ + target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto: createCounterfactualSafeDtoBuilder() + .with('chainId', '1') + .build(), + }), + target.createCounterfactualSafe({ + account, + createCounterfactualSafeDto: createCounterfactualSafeDtoBuilder() + .with('chainId', '2') + .build(), + }), + ]); + + // store data in the cache dirs + const counterfactualSafesCacheDir = new CacheDir( + `counterfactual_safes_${address}`, + faker.string.alpha(), + ); + const counterfactualSafeCacheDirs = [ + new CacheDir( + `counterfactual_safe_${counterfactualSafes[0].id}`, + faker.string.alpha(), + ), + new CacheDir( + `counterfactual_safe_${counterfactualSafes[1].id}`, + faker.string.alpha(), + ), + ]; + + await expect( + target.deleteCounterfactualSafesForAccount(account), + ).resolves.not.toThrow(); + + // database is cleared + const actual = await target.getCounterfactualSafesForAccount(account); + expect(actual).toHaveLength(0); + // cache is cleared + expect( + await fakeCacheService.get(counterfactualSafesCacheDir), + ).toBeUndefined(); + expect( + await fakeCacheService.get(counterfactualSafeCacheDirs[0]), + ).toBeUndefined(); + expect( + await fakeCacheService.get(counterfactualSafeCacheDirs[1]), + ).toBeUndefined(); + }); + }); +}); diff --git a/src/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource.ts b/src/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource.ts new file mode 100644 index 0000000000..7ea2854128 --- /dev/null +++ b/src/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource.ts @@ -0,0 +1,163 @@ +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { CacheRouter } from '@/datasources/cache/cache.router'; +import { + CacheService, + ICacheService, +} from '@/datasources/cache/cache.service.interface'; +import { getFromCacheOrExecuteAndCache } from '@/datasources/db/utils'; +import { CounterfactualSafe } from '@/domain/accounts/counterfactual-safes/entities/counterfactual-safe.entity'; +import { CreateCounterfactualSafeDto } from '@/domain/accounts/counterfactual-safes/entities/create-counterfactual-safe.dto.entity'; +import { Account } from '@/domain/accounts/entities/account.entity'; +import { ICounterfactualSafesDatasource } from '@/domain/interfaces/counterfactual-safes.datasource.interface'; +import { ILoggingService, LoggingService } from '@/logging/logging.interface'; +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import postgres from 'postgres'; + +@Injectable() +export class CounterfactualSafesDatasource + implements ICounterfactualSafesDatasource +{ + private readonly defaultExpirationTimeInSeconds: number; + + constructor( + @Inject(CacheService) private readonly cacheService: ICacheService, + @Inject('DB_INSTANCE') private readonly sql: postgres.Sql, + @Inject(LoggingService) private readonly loggingService: ILoggingService, + @Inject(IConfigurationService) + private readonly configurationService: IConfigurationService, + ) { + this.defaultExpirationTimeInSeconds = + this.configurationService.getOrThrow( + 'expirationTimeInSeconds.default', + ); + } + + // TODO: the repository calling this function should: + // - check the AccountDataSettings to see if counterfactual-safes is enabled. + // - check the AccountDataType to see if it's active. + async createCounterfactualSafe(args: { + account: Account; + createCounterfactualSafeDto: CreateCounterfactualSafeDto; + }): Promise { + const [counterfactualSafe] = await this.sql` + INSERT INTO counterfactual_safes + ${this.sql([this.mapCreationDtoToRow(args.account, args.createCounterfactualSafeDto)])} + RETURNING *`; + const { key } = CacheRouter.getCounterfactualSafesCacheDir( + args.account.address, + ); + await this.cacheService.deleteByKey(key); + return counterfactualSafe; + } + + async getCounterfactualSafe(args: { + chainId: string; + predictedAddress: `0x${string}`; + }): Promise { + const cacheDir = CacheRouter.getCounterfactualSafeCacheDir( + args.chainId, + args.predictedAddress, + ); + const [counterfactualSafe] = await getFromCacheOrExecuteAndCache< + CounterfactualSafe[] + >( + this.loggingService, + this.cacheService, + cacheDir, + this.sql` + SELECT * FROM counterfactual_safes WHERE chain_id = ${args.chainId} AND predicted_address = ${args.predictedAddress}`, + this.defaultExpirationTimeInSeconds, + ); + + if (!counterfactualSafe) { + throw new NotFoundException('Error getting Counterfactual Safe.'); + } + + return counterfactualSafe; + } + + getCounterfactualSafesForAccount( + account: Account, + ): Promise { + const cacheDir = CacheRouter.getCounterfactualSafesCacheDir( + account.address, + ); + return getFromCacheOrExecuteAndCache( + this.loggingService, + this.cacheService, + cacheDir, + this.sql` + SELECT * FROM counterfactual_safes WHERE account_id = ${account.id}`, + this.defaultExpirationTimeInSeconds, + ); + } + + async deleteCounterfactualSafe(args: { + account: Account; + chainId: string; + predictedAddress: `0x${string}`; + }): Promise { + try { + const { count } = await this + .sql`DELETE FROM counterfactual_safes WHERE chain_id = ${args.chainId} AND predicted_address = ${args.predictedAddress} AND account_id = ${args.account.id}`; + if (count === 0) { + this.loggingService.debug( + `Error deleting Counterfactual Safe (${args.chainId}, ${args.predictedAddress}): not found`, + ); + } + } finally { + await Promise.all([ + this.cacheService.deleteByKey( + CacheRouter.getCounterfactualSafeCacheDir( + args.chainId, + args.predictedAddress, + ).key, + ), + this.cacheService.deleteByKey( + CacheRouter.getCounterfactualSafesCacheDir(args.account.address).key, + ), + ]); + } + } + + async deleteCounterfactualSafesForAccount(account: Account): Promise { + let deleted: CounterfactualSafe[] = []; + try { + const rows = await this.sql< + CounterfactualSafe[] + >`DELETE FROM counterfactual_safes WHERE account_id = ${account.id} RETURNING *`; + deleted = rows; + } finally { + await this.cacheService.deleteByKey( + CacheRouter.getCounterfactualSafesCacheDir(account.address).key, + ); + await Promise.all( + deleted.map((row) => { + return this.cacheService.deleteByKey( + CacheRouter.getCounterfactualSafeCacheDir( + row.chain_id, + row.predicted_address, + ).key, + ); + }), + ); + } + } + + private mapCreationDtoToRow( + account: Account, + createCounterfactualSafeDto: CreateCounterfactualSafeDto, + ): Partial { + return { + account_id: account.id, + chain_id: createCounterfactualSafeDto.chainId, + creator: account.address, + fallback_handler: createCounterfactualSafeDto.fallbackHandler, + owners: createCounterfactualSafeDto.owners, + predicted_address: createCounterfactualSafeDto.predictedAddress, + salt_nonce: createCounterfactualSafeDto.saltNonce, + singleton_address: createCounterfactualSafeDto.singletonAddress, + threshold: createCounterfactualSafeDto.threshold, + }; + } +} diff --git a/src/datasources/cache/cache.router.ts b/src/datasources/cache/cache.router.ts index f3716d85ae..ef45c3a098 100644 --- a/src/datasources/cache/cache.router.ts +++ b/src/datasources/cache/cache.router.ts @@ -10,6 +10,8 @@ export class CacheRouter { private static readonly CHAIN_KEY = 'chain'; private static readonly CHAINS_KEY = 'chains'; private static readonly CONTRACT_KEY = 'contract'; + private static readonly COUNTERFACTUAL_SAFE_KEY = 'counterfactual_safe'; + private static readonly COUNTERFACTUAL_SAFES_KEY = 'counterfactual_safes'; private static readonly CREATION_TRANSACTION_KEY = 'creation_transaction'; private static readonly DELEGATES_KEY = 'delegates'; private static readonly FIREBASE_OAUTH2_TOKEN_KEY = 'firebase_oauth2_token'; @@ -513,4 +515,21 @@ export class CacheRouter { '', ); } + + static getCounterfactualSafeCacheDir( + chainId: string, + predictedAddress: `0x${string}`, + ): CacheDir { + return new CacheDir( + `${chainId}_${CacheRouter.COUNTERFACTUAL_SAFE_KEY}_${predictedAddress}`, + '', + ); + } + + static getCounterfactualSafesCacheDir(address: `0x${string}`): CacheDir { + return new CacheDir( + `${CacheRouter.COUNTERFACTUAL_SAFES_KEY}_${address}`, + '', + ); + } } diff --git a/src/datasources/db/utils.ts b/src/datasources/db/utils.ts new file mode 100644 index 0000000000..ebc7da1104 --- /dev/null +++ b/src/datasources/db/utils.ts @@ -0,0 +1,48 @@ +import { ICacheService } from '@/datasources/cache/cache.service.interface'; +import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity'; +import { ILoggingService } from '@/logging/logging.interface'; +import { asError } from '@/logging/utils'; +import { InternalServerErrorException } from '@nestjs/common'; +import postgres from 'postgres'; + +/** + * Returns the content from cache or executes the query and caches the result. + * If the specified {@link CacheDir} is empty, the query is executed and the result is cached. + * If the specified {@link CacheDir} is not empty, the pointed content is returned. + * + * @param loggingService {@link ILoggingService} to use for logging + * @param cacheService {@link ICacheService} to use for caching + * @param cacheDir {@link CacheDir} to use for caching + * @param query query to execute + * @param ttl time to live for the cache + * @returns content from cache or query result + */ +// TODO: add tests +export async function getFromCacheOrExecuteAndCache< + T extends postgres.MaybeRow[], +>( + loggingService: ILoggingService, + cacheService: ICacheService, + cacheDir: CacheDir, + query: postgres.PendingQuery, + ttl: number, +): Promise { + const { key, field } = cacheDir; + const cached = await cacheService.get(cacheDir); + if (cached != null) { + loggingService.debug({ type: 'cache_hit', key, field }); + return JSON.parse(cached); + } + loggingService.debug({ type: 'cache_miss', key, field }); + + // log & hide database errors + const result = await query.catch((e) => { + loggingService.error(asError(e).message); + throw new InternalServerErrorException(); + }); + + if (result.count > 0) { + await cacheService.set(cacheDir, JSON.stringify(result), ttl); + } + return result; +} diff --git a/src/domain/accounts/accounts.repository.interface.ts b/src/domain/accounts/accounts.repository.interface.ts index 5a55c299ec..eeba431a97 100644 --- a/src/domain/accounts/accounts.repository.interface.ts +++ b/src/domain/accounts/accounts.repository.interface.ts @@ -35,7 +35,7 @@ export interface IAccountsRepository { upsertAccountDataSettings(args: { authPayload: AuthPayload; address: `0x${string}`; - upsertAccountDataSettings: UpsertAccountDataSettingsDto; + upsertAccountDataSettingsDto: UpsertAccountDataSettingsDto; }): Promise; } diff --git a/src/domain/accounts/accounts.repository.ts b/src/domain/accounts/accounts.repository.ts index b61259addb..44062ff923 100644 --- a/src/domain/accounts/accounts.repository.ts +++ b/src/domain/accounts/accounts.repository.ts @@ -46,7 +46,6 @@ export class AccountsRepository implements IAccountsRepository { if (!args.authPayload.isForSigner(args.address)) { throw new UnauthorizedException(); } - // TODO: trigger a cascade deletion of the account-associated data. return this.datasource.deleteAccount(args.address); } @@ -68,19 +67,19 @@ export class AccountsRepository implements IAccountsRepository { async upsertAccountDataSettings(args: { authPayload: AuthPayload; address: `0x${string}`; - upsertAccountDataSettings: UpsertAccountDataSettingsDto; + upsertAccountDataSettingsDto: UpsertAccountDataSettingsDto; }): Promise { - const { address, upsertAccountDataSettings } = args; + const { address, upsertAccountDataSettingsDto } = args; if (!args.authPayload.isForSigner(args.address)) { throw new UnauthorizedException(); } - if (upsertAccountDataSettings.accountDataSettings.length === 0) { + if (upsertAccountDataSettingsDto.accountDataSettings.length === 0) { return []; } - return this.datasource.upsertAccountDataSettings( + return this.datasource.upsertAccountDataSettings({ address, - upsertAccountDataSettings, - ); + upsertAccountDataSettingsDto, + }); } } diff --git a/src/domain/accounts/counterfactual-safes/entities/__tests__/create-counterfactual-safe.dto.entity.builder.ts b/src/domain/accounts/counterfactual-safes/entities/__tests__/create-counterfactual-safe.dto.entity.builder.ts new file mode 100644 index 0000000000..bdb29a120a --- /dev/null +++ b/src/domain/accounts/counterfactual-safes/entities/__tests__/create-counterfactual-safe.dto.entity.builder.ts @@ -0,0 +1,18 @@ +import { IBuilder, Builder } from '@/__tests__/builder'; +import { CreateCounterfactualSafeDto } from '@/domain/accounts/counterfactual-safes/entities/create-counterfactual-safe.dto.entity'; +import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; + +export function createCounterfactualSafeDtoBuilder(): IBuilder { + return new Builder() + .with('chainId', faker.string.numeric({ length: 6 })) + .with('fallbackHandler', getAddress(faker.finance.ethereumAddress())) + .with('owners', [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ]) + .with('predictedAddress', getAddress(faker.finance.ethereumAddress())) + .with('saltNonce', faker.string.hexadecimal()) + .with('singletonAddress', getAddress(faker.finance.ethereumAddress())) + .with('threshold', faker.number.int({ min: 1, max: 10 })); +} diff --git a/src/domain/accounts/counterfactual-safes/entities/counterfactual-safe.entity.ts b/src/domain/accounts/counterfactual-safes/entities/counterfactual-safe.entity.ts new file mode 100644 index 0000000000..eba2fc241f --- /dev/null +++ b/src/domain/accounts/counterfactual-safes/entities/counterfactual-safe.entity.ts @@ -0,0 +1,18 @@ +import { RowSchema } from '@/datasources/db/entities/row.entity'; +import { AccountSchema } from '@/domain/accounts/entities/account.entity'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; +import { z } from 'zod'; + +export type CounterfactualSafe = z.infer; + +export const CounterfactualSafeSchema = RowSchema.extend({ + chain_id: z.string(), + creator: AddressSchema, + fallback_handler: AddressSchema, + owners: z.array(AddressSchema).min(1), + predicted_address: AddressSchema, + salt_nonce: z.string(), + singleton_address: AddressSchema, + threshold: z.number().int().gte(1), + account_id: AccountSchema.shape.id, +}); diff --git a/src/domain/accounts/counterfactual-safes/entities/create-counterfactual-safe.dto.entity.ts b/src/domain/accounts/counterfactual-safes/entities/create-counterfactual-safe.dto.entity.ts new file mode 100644 index 0000000000..0509c87b0d --- /dev/null +++ b/src/domain/accounts/counterfactual-safes/entities/create-counterfactual-safe.dto.entity.ts @@ -0,0 +1,34 @@ +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; +import { z } from 'zod'; + +export class CreateCounterfactualSafeDto + implements z.infer +{ + chainId: string; + fallbackHandler: `0x${string}`; + owners: `0x${string}`[]; + predictedAddress: `0x${string}`; + saltNonce: string; + singletonAddress: `0x${string}`; + threshold: number; + + constructor(props: CreateCounterfactualSafeDto) { + this.chainId = props.chainId; + this.fallbackHandler = props.fallbackHandler; + this.owners = props.owners; + this.predictedAddress = props.predictedAddress; + this.saltNonce = props.saltNonce; + this.singletonAddress = props.singletonAddress; + this.threshold = props.threshold; + } +} + +export const CreateCounterfactualSafeDtoSchema = z.object({ + chainId: z.string(), + fallbackHandler: AddressSchema, + owners: z.array(AddressSchema).min(1), + predictedAddress: AddressSchema, + saltNonce: z.string(), + singletonAddress: AddressSchema, + threshold: z.number().int().gte(1), +}); diff --git a/src/domain/accounts/entities/__tests__/account.builder.ts b/src/domain/accounts/entities/__tests__/account.builder.ts index 1740dd1c75..29ff1dd6db 100644 --- a/src/domain/accounts/entities/__tests__/account.builder.ts +++ b/src/domain/accounts/entities/__tests__/account.builder.ts @@ -5,8 +5,8 @@ import { getAddress } from 'viem'; export function accountBuilder(): IBuilder { return new Builder() - .with('id', faker.number.int()) - .with('group_id', faker.number.int()) + .with('id', faker.number.int({ max: 1_000_000 })) + .with('group_id', faker.number.int({ max: 1_000_000 })) .with('address', getAddress(faker.finance.ethereumAddress())) .with('created_at', faker.date.recent()) .with('updated_at', faker.date.recent()); diff --git a/src/domain/interfaces/accounts.datasource.interface.ts b/src/domain/interfaces/accounts.datasource.interface.ts index 3b3fdac598..aa4f9969d0 100644 --- a/src/domain/interfaces/accounts.datasource.interface.ts +++ b/src/domain/interfaces/accounts.datasource.interface.ts @@ -16,8 +16,8 @@ export interface IAccountsDatasource { getAccountDataSettings(address: `0x${string}`): Promise; - upsertAccountDataSettings( - address: `0x${string}`, - upsertAccountDataSettings: UpsertAccountDataSettingsDto, - ): Promise; + upsertAccountDataSettings(args: { + address: `0x${string}`; + upsertAccountDataSettingsDto: UpsertAccountDataSettingsDto; + }): Promise; } diff --git a/src/domain/interfaces/counterfactual-safes.datasource.interface.ts b/src/domain/interfaces/counterfactual-safes.datasource.interface.ts new file mode 100644 index 0000000000..a2d62b7200 --- /dev/null +++ b/src/domain/interfaces/counterfactual-safes.datasource.interface.ts @@ -0,0 +1,31 @@ +import { CounterfactualSafe } from '@/domain/accounts/counterfactual-safes/entities/counterfactual-safe.entity'; +import { CreateCounterfactualSafeDto } from '@/domain/accounts/counterfactual-safes/entities/create-counterfactual-safe.dto.entity'; +import { Account } from '@/domain/accounts/entities/account.entity'; + +export const ICounterfactualSafesDatasource = Symbol( + 'ICounterfactualSafesDatasource', +); + +export interface ICounterfactualSafesDatasource { + createCounterfactualSafe(args: { + account: Account; + createCounterfactualSafeDto: CreateCounterfactualSafeDto; + }): Promise; + + getCounterfactualSafe(args: { + chainId: string; + predictedAddress: `0x${string}`; + }): Promise; + + getCounterfactualSafesForAccount( + account: Account, + ): Promise; + + deleteCounterfactualSafe(args: { + account: Account; + chainId: string; + predictedAddress: `0x${string}`; + }): Promise; + + deleteCounterfactualSafesForAccount(account: Account): Promise; +} diff --git a/src/routes/accounts/accounts.controller.spec.ts b/src/routes/accounts/accounts.controller.spec.ts index f38415f194..8dbaf22d82 100644 --- a/src/routes/accounts/accounts.controller.spec.ts +++ b/src/routes/accounts/accounts.controller.spec.ts @@ -638,10 +638,10 @@ describe('AccountsController', () => { expect(accountDataSource.upsertAccountDataSettings).toHaveBeenCalledTimes( 1, ); - expect(accountDataSource.upsertAccountDataSettings).toHaveBeenCalledWith( + expect(accountDataSource.upsertAccountDataSettings).toHaveBeenCalledWith({ address, upsertAccountDataSettingsDto, - ); + }); }); it('should accept a empty array of data settings', async () => { diff --git a/src/routes/accounts/accounts.service.ts b/src/routes/accounts/accounts.service.ts index deb317071d..06c4b029ac 100644 --- a/src/routes/accounts/accounts.service.ts +++ b/src/routes/accounts/accounts.service.ts @@ -82,7 +82,7 @@ export class AccountsService { this.accountsRepository.upsertAccountDataSettings({ authPayload: args.authPayload, address: args.address, - upsertAccountDataSettings: { + upsertAccountDataSettingsDto: { accountDataSettings: args.upsertAccountDataSettingsDto.accountDataSettings, }, From 59c2c0e3e9b6e5637089acbe149d9ea88f9bcf47 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jul 2024 08:54:30 +0200 Subject: [PATCH 197/207] Bump docker/setup-qemu-action from 3.1.0 to 3.2.0 (#1779) Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3.1.0 to 3.2.0. - [Release notes](https://github.com/docker/setup-qemu-action/releases) - [Commits](https://github.com/docker/setup-qemu-action/compare/v3.1.0...v3.2.0) --- updated-dependencies: - dependency-name: docker/setup-qemu-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82d0f1d914..43f6bae87f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,7 +120,7 @@ jobs: - run: | BUILD_NUMBER=${{ github.sha }} echo "BUILD_NUMBER=${BUILD_NUMBER::7}" >> "$GITHUB_ENV" - - uses: docker/setup-qemu-action@v3.1.0 + - uses: docker/setup-qemu-action@v3.2.0 with: platforms: arm64 - uses: docker/setup-buildx-action@v3 @@ -149,7 +149,7 @@ jobs: - run: | BUILD_NUMBER=${{ github.sha }} echo "BUILD_NUMBER=${BUILD_NUMBER::7}" >> "$GITHUB_ENV" - - uses: docker/setup-qemu-action@v3.1.0 + - uses: docker/setup-qemu-action@v3.2.0 with: platforms: arm64 - uses: docker/setup-buildx-action@v3 From 0b7d8f70f478aae41436469e292131d6151f37a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jul 2024 08:55:31 +0200 Subject: [PATCH 198/207] Bump ts-jest from 29.2.2 to 29.2.3 (#1781) Bumps [ts-jest](https://github.com/kulshekhar/ts-jest) from 29.2.2 to 29.2.3. - [Release notes](https://github.com/kulshekhar/ts-jest/releases) - [Changelog](https://github.com/kulshekhar/ts-jest/blob/main/CHANGELOG.md) - [Commits](https://github.com/kulshekhar/ts-jest/compare/v29.2.2...v29.2.3) --- updated-dependencies: - dependency-name: ts-jest dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index a0ebcaa9ed..e934860c96 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "prettier": "^3.3.2", "source-map-support": "^0.5.20", "supertest": "^7.0.0", - "ts-jest": "29.2.2", + "ts-jest": "29.2.3", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "4.2.0", diff --git a/yarn.lock b/yarn.lock index 2274f1a108..81eaf03388 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3661,7 +3661,7 @@ __metadata: languageName: node linkType: hard -"ejs@npm:^3.0.0": +"ejs@npm:^3.1.10": version: 3.1.10 resolution: "ejs@npm:3.1.10" dependencies: @@ -7265,7 +7265,7 @@ __metadata: semver: "npm:^7.6.2" source-map-support: "npm:^0.5.20" supertest: "npm:^7.0.0" - ts-jest: "npm:29.2.2" + ts-jest: "npm:29.2.3" ts-loader: "npm:^9.5.1" ts-node: "npm:^10.9.2" tsconfig-paths: "npm:4.2.0" @@ -7922,12 +7922,12 @@ __metadata: languageName: node linkType: hard -"ts-jest@npm:29.2.2": - version: 29.2.2 - resolution: "ts-jest@npm:29.2.2" +"ts-jest@npm:29.2.3": + version: 29.2.3 + resolution: "ts-jest@npm:29.2.3" dependencies: bs-logger: "npm:0.x" - ejs: "npm:^3.0.0" + ejs: "npm:^3.1.10" fast-json-stable-stringify: "npm:2.x" jest-util: "npm:^29.0.0" json5: "npm:^2.2.3" @@ -7955,7 +7955,7 @@ __metadata: optional: true bin: ts-jest: cli.js - checksum: 10/6523de2d78493a7901dfc37f2a491b259f5d30beac7a2179ddf8524da0c8e4a7f488aad2d22eca8d074bcc54d7b06a90153fdbf6e9c245f5fc1e484788f0c9d8 + checksum: 10/d3c3388cea8ea4a7f52c7c97e34a1abf1d83152fa0625ddea7b82c0e3599a786185b95d3e12a09eb27521adedc90780a3dc9df29156ca83f1094e163113ede62 languageName: node linkType: hard From 94c4fa5108398935dbfb67e676eabc7ae6162b8e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jul 2024 08:57:17 +0200 Subject: [PATCH 199/207] Bump winston from 3.13.0 to 3.13.1 (#1782) Bumps [winston](https://github.com/winstonjs/winston) from 3.13.0 to 3.13.1. - [Release notes](https://github.com/winstonjs/winston/releases) - [Changelog](https://github.com/winstonjs/winston/blob/master/CHANGELOG.md) - [Commits](https://github.com/winstonjs/winston/compare/v3.13.0...v3.13.1) --- updated-dependencies: - dependency-name: winston dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 37 +++++++++++++++++++++++++++++-------- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index e934860c96..e09a1c051a 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "rxjs": "^7.8.1", "semver": "^7.6.2", "viem": "^2.17.4", - "winston": "^3.13.0", + "winston": "^3.13.1", "zod": "^3.23.8" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index 81eaf03388..bbb45cbd92 100644 --- a/yarn.lock +++ b/yarn.lock @@ -630,7 +630,7 @@ __metadata: languageName: node linkType: hard -"@colors/colors@npm:^1.6.0": +"@colors/colors@npm:1.6.0, @colors/colors@npm:^1.6.0": version: 1.6.0 resolution: "@colors/colors@npm:1.6.0" checksum: 10/66d00284a3a9a21e5e853b256942e17edbb295f4bd7b9aa7ef06bbb603568d5173eb41b0f64c1e51748bc29d382a23a67d99956e57e7431c64e47e74324182d9 @@ -1987,6 +1987,13 @@ __metadata: languageName: node linkType: hard +"@types/triple-beam@npm:^1.3.2": + version: 1.3.5 + resolution: "@types/triple-beam@npm:1.3.5" + checksum: 10/519b6a1b30d4571965c9706ad5400a200b94e4050feca3e7856e3ea7ac00ec9903e32e9a10e2762d0f7e472d5d03e5f4b29c16c0bd8c1f77c8876c683b2231f1 + languageName: node + linkType: hard + "@types/yargs-parser@npm:*": version: 21.0.0 resolution: "@types/yargs-parser@npm:21.0.0" @@ -5983,7 +5990,7 @@ __metadata: languageName: node linkType: hard -"logform@npm:^2.3.2, logform@npm:^2.4.0": +"logform@npm:^2.3.2": version: 2.4.2 resolution: "logform@npm:2.4.2" dependencies: @@ -5996,6 +6003,20 @@ __metadata: languageName: node linkType: hard +"logform@npm:^2.6.0": + version: 2.6.1 + resolution: "logform@npm:2.6.1" + dependencies: + "@colors/colors": "npm:1.6.0" + "@types/triple-beam": "npm:^1.3.2" + fecha: "npm:^4.2.0" + ms: "npm:^2.1.1" + safe-stable-stringify: "npm:^2.3.1" + triple-beam: "npm:^1.3.0" + checksum: 10/e67f414787fbfe1e6a997f4c84300c7e06bee3d0bd579778af667e24b36db3ea200ed195d41b61311ff738dab7faabc615a07b174b22fe69e0b2f39e985be64b + languageName: node + linkType: hard + "lru-cache@npm:^10.2.0": version: 10.4.0 resolution: "lru-cache@npm:10.4.0" @@ -7272,7 +7293,7 @@ __metadata: typescript: "npm:^5.5.3" typescript-eslint: "npm:^7.16.1" viem: "npm:^2.17.4" - winston: "npm:^3.13.0" + winston: "npm:^3.13.1" zod: "npm:^3.23.8" languageName: unknown linkType: soft @@ -8427,22 +8448,22 @@ __metadata: languageName: node linkType: hard -"winston@npm:^3.13.0": - version: 3.13.0 - resolution: "winston@npm:3.13.0" +"winston@npm:^3.13.1": + version: 3.13.1 + resolution: "winston@npm:3.13.1" dependencies: "@colors/colors": "npm:^1.6.0" "@dabh/diagnostics": "npm:^2.0.2" async: "npm:^3.2.3" is-stream: "npm:^2.0.0" - logform: "npm:^2.4.0" + logform: "npm:^2.6.0" one-time: "npm:^1.0.0" readable-stream: "npm:^3.4.0" safe-stable-stringify: "npm:^2.3.1" stack-trace: "npm:0.0.x" triple-beam: "npm:^1.3.0" winston-transport: "npm:^4.7.0" - checksum: 10/436675598359af27e4eabde2ce578cf77da893ffd57d0479f037fef939e8eb721031f0102b14399eee93b3412b545946c431d1fff23db3beeac2ffa395537f7b + checksum: 10/bc78202708800f74b94a2cc4fbdd46569dea90f939ad2149a936b2deee612d63a512f9e5725251349090bc12ba35351dd67336b3c92bf094892f9ea03d34fdc4 languageName: node linkType: hard From cac46a8fdc725936c5668b02cc7f09c7ad380cfa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jul 2024 09:02:29 +0200 Subject: [PATCH 200/207] Bump husky from 9.0.11 to 9.1.1 (#1780) Bumps [husky](https://github.com/typicode/husky) from 9.0.11 to 9.1.1. - [Release notes](https://github.com/typicode/husky/releases) - [Commits](https://github.com/typicode/husky/compare/v9.0.11...v9.1.1) --- updated-dependencies: - dependency-name: husky dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index e09a1c051a..c2a2eca306 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "@types/supertest": "^6.0.2", "eslint": "^9.7.0", "eslint-config-prettier": "^9.1.0", - "husky": "^9.0.11", + "husky": "^9.1.1", "jest": "29.7.0", "prettier": "^3.3.2", "source-map-support": "^0.5.20", diff --git a/yarn.lock b/yarn.lock index bbb45cbd92..5f20dd0783 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4769,12 +4769,12 @@ __metadata: languageName: node linkType: hard -"husky@npm:^9.0.11": - version: 9.0.11 - resolution: "husky@npm:9.0.11" +"husky@npm:^9.1.1": + version: 9.1.1 + resolution: "husky@npm:9.1.1" bin: - husky: bin.mjs - checksum: 10/8a9b7cb9dc8494b470b3b47b386e65d579608c6206da80d3cc8b71d10e37947264af3dfe00092368dad9673b51d2a5ee87afb4b2291e77ba9e7ec1ac36e56cd1 + husky: bin.js + checksum: 10/c3be0392071b78c680fc6b9fd7978f52c26e18238a2840c6eabfc0db395e19fcd798da8eff0e31a9e76c479d6019a567d83a8de80f360d28552bc83bd1839b7c languageName: node linkType: hard @@ -7273,7 +7273,7 @@ __metadata: cookie-parser: "npm:^1.4.6" eslint: "npm:^9.7.0" eslint-config-prettier: "npm:^9.1.0" - husky: "npm:^9.0.11" + husky: "npm:^9.1.1" jest: "npm:29.7.0" jsonwebtoken: "npm:^9.0.2" lodash: "npm:^4.17.21" From e2ae97fd0f0bb8d64af29dd3c0fdf136da96f3cb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jul 2024 09:03:00 +0200 Subject: [PATCH 201/207] Bump semver from 7.6.2 to 7.6.3 (#1783) Bumps [semver](https://github.com/npm/node-semver) from 7.6.2 to 7.6.3. - [Release notes](https://github.com/npm/node-semver/releases) - [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md) - [Commits](https://github.com/npm/node-semver/compare/v7.6.2...v7.6.3) --- updated-dependencies: - dependency-name: semver dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index c2a2eca306..d2c27bb76a 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "redis": "^4.6.15", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "semver": "^7.6.2", + "semver": "^7.6.3", "viem": "^2.17.4", "winston": "^3.13.1", "zod": "^3.23.8" diff --git a/yarn.lock b/yarn.lock index 5f20dd0783..98899ce443 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7283,7 +7283,7 @@ __metadata: redis: "npm:^4.6.15" reflect-metadata: "npm:^0.2.2" rxjs: "npm:^7.8.1" - semver: "npm:^7.6.2" + semver: "npm:^7.6.3" source-map-support: "npm:^0.5.20" supertest: "npm:^7.0.0" ts-jest: "npm:29.2.3" @@ -7363,6 +7363,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.6.3": + version: 7.6.3 + resolution: "semver@npm:7.6.3" + bin: + semver: bin/semver.js + checksum: 10/36b1fbe1a2b6f873559cd57b238f1094a053dbfd997ceeb8757d79d1d2089c56d1321b9f1069ce263dc64cfa922fa1d2ad566b39426fe1ac6c723c1487589e10 + languageName: node + linkType: hard + "send@npm:0.18.0": version: 0.18.0 resolution: "send@npm:0.18.0" From b4f144746fb5b9b78cca7fa38046dc06cc7ea3eb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jul 2024 09:03:22 +0200 Subject: [PATCH 202/207] Bump @nestjs/swagger from 7.3.1 to 7.4.0 (#1784) Bumps [@nestjs/swagger](https://github.com/nestjs/swagger) from 7.3.1 to 7.4.0. - [Release notes](https://github.com/nestjs/swagger/releases) - [Changelog](https://github.com/nestjs/swagger/blob/master/.release-it.json) - [Commits](https://github.com/nestjs/swagger/compare/7.3.1...7.4.0) --- updated-dependencies: - dependency-name: "@nestjs/swagger" dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 30 +++++++++++++++--------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index d2c27bb76a..12ccce005f 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@nestjs/core": "^10.3.10", "@nestjs/platform-express": "^10.3.10", "@nestjs/serve-static": "^4.0.2", - "@nestjs/swagger": "^7.3.1", + "@nestjs/swagger": "^7.4.0", "@safe-global/safe-deployments": "^1.37.1", "amqp-connection-manager": "^4.1.14", "amqplib": "^0.10.4", diff --git a/yarn.lock b/yarn.lock index 98899ce443..31f969686f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1180,10 +1180,10 @@ __metadata: languageName: node linkType: hard -"@microsoft/tsdoc@npm:^0.14.2": - version: 0.14.2 - resolution: "@microsoft/tsdoc@npm:0.14.2" - checksum: 10/00c3d4fc184e8e09e17aef57e4a990402bd9752607a5d50bd62a9e85bc4b8791c985a51e238affa6b9a2d23110f24d373becbfc84e1e6e9a84cf977822e3b00a +"@microsoft/tsdoc@npm:^0.15.0": + version: 0.15.0 + resolution: "@microsoft/tsdoc@npm:0.15.0" + checksum: 10/fd025e5e3966248cd5477b9ddad4e9aa0dd69291f372a207f18a686b3097dcf5ecf38325caf0f4ad2697f1f39fd45b536e4ada6756008b8bcc5eccbc3201313d languageName: node linkType: hard @@ -1372,16 +1372,16 @@ __metadata: languageName: node linkType: hard -"@nestjs/swagger@npm:^7.3.1": - version: 7.3.1 - resolution: "@nestjs/swagger@npm:7.3.1" +"@nestjs/swagger@npm:^7.4.0": + version: 7.4.0 + resolution: "@nestjs/swagger@npm:7.4.0" dependencies: - "@microsoft/tsdoc": "npm:^0.14.2" + "@microsoft/tsdoc": "npm:^0.15.0" "@nestjs/mapped-types": "npm:2.0.5" js-yaml: "npm:4.1.0" lodash: "npm:4.17.21" path-to-regexp: "npm:3.2.0" - swagger-ui-dist: "npm:5.11.2" + swagger-ui-dist: "npm:5.17.14" peerDependencies: "@fastify/static": ^6.0.0 || ^7.0.0 "@nestjs/common": ^9.0.0 || ^10.0.0 @@ -1396,7 +1396,7 @@ __metadata: optional: true class-validator: optional: true - checksum: 10/1545da1f32eb4c59f0f201426f8d683bfa455435c0c38b688603e28a6549c573973fe8c6b1650a8fe6431e0691eeec6e3d4d1f91f53fadd99d38ed360b7d3e01 + checksum: 10/8c20c41f9f0e2e2239cc35776d794052e216c8b258f1f2bccc91338706351892b835e63b7e317311d102a4fceed89a61a107479522ae12410e91a7b46d6981b6 languageName: node linkType: hard @@ -7256,7 +7256,7 @@ __metadata: "@nestjs/platform-express": "npm:^10.3.10" "@nestjs/schematics": "npm:^10.1.2" "@nestjs/serve-static": "npm:^4.0.2" - "@nestjs/swagger": "npm:^7.3.1" + "@nestjs/swagger": "npm:^7.4.0" "@nestjs/testing": "npm:^10.3.10" "@safe-global/safe-deployments": "npm:^1.37.1" "@types/amqplib": "npm:^0" @@ -7778,10 +7778,10 @@ __metadata: languageName: node linkType: hard -"swagger-ui-dist@npm:5.11.2": - version: 5.11.2 - resolution: "swagger-ui-dist@npm:5.11.2" - checksum: 10/5f88842dcd9876c0f5e26b1ed1bb4cdc54441c065783a63448258f996949a32c136dc579e9c57be8489020c7d1a40ef71dd7c0414bb3e01602bdae97225e524b +"swagger-ui-dist@npm:5.17.14": + version: 5.17.14 + resolution: "swagger-ui-dist@npm:5.17.14" + checksum: 10/b9e62d7ecb64e837849252c9f82af654b26cae60ebd551cff96495d826166d3ed866ebae40f22a2c61d307330151945d79d995e50659ae17eea6cf4ece788f9d languageName: node linkType: hard From 0b1a91ef5a29fde4acddc54c5a32e41e4273af2f Mon Sep 17 00:00:00 2001 From: Den Smalonski Date: Thu, 25 Jul 2024 14:42:47 +0200 Subject: [PATCH 203/207] fix: update chain id of sepolia relay --- src/config/entities/configuration.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 0e62d807b9..e7811ccf84 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -299,6 +299,7 @@ export default () => ({ apiKey: { 100: process.env.RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN, 11155111: process.env.RELAY_PROVIDER_API_KEY_SEPOLIA, + 185: process.env.RELAY_PROVIDER_API_KEY_SEPOLIA, }, }, safeConfig: { From 127b82f3cafed276fa22a4e7ca1fa195ff972f32 Mon Sep 17 00:00:00 2001 From: Den Smalonski Date: Thu, 25 Jul 2024 14:57:57 +0200 Subject: [PATCH 204/207] fix: update chain id of sepolia relay --- src/config/entities/configuration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index e7811ccf84..c5de3823a0 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -299,7 +299,7 @@ export default () => ({ apiKey: { 100: process.env.RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN, 11155111: process.env.RELAY_PROVIDER_API_KEY_SEPOLIA, - 185: process.env.RELAY_PROVIDER_API_KEY_SEPOLIA, + 18233: process.env.RELAY_PROVIDER_API_KEY_SEPOLIA, }, }, safeConfig: { From fb8d3fdaf54a5cfe52062fb65ec07af0cf620e28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Fri, 26 Jul 2024 11:55:59 +0200 Subject: [PATCH 205/207] Add Chains retrieval and cache deletion debug logs (#1786) - Adds a feature flag to control Chain-related debugging logs: `configHooksDebugLogs`. - Adds optional logging to `ConfigApi` when the `clearChain` function is executed (after the reception of a `CHAIN_UPDATED` event). - Adds optional logging to `CacheFirstDatasource` when the `chain/chains` cache key is written in the cache. --- .../entities/__tests__/configuration.ts | 1 + src/config/entities/configuration.ts | 2 ++ .../cache/cache.first.data.source.spec.ts | 1 + .../cache/cache.first.data.source.ts | 32 +++++++++++++++++++ .../config-api/config-api.service.spec.ts | 8 +++++ .../config-api/config-api.service.ts | 13 +++++++- 6 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index ce800413db..8d7fbed034 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -114,6 +114,7 @@ export default (): ReturnType => ({ swapsDecoding: true, twapsDecoding: true, debugLogs: false, + configHooksDebugLogs: false, imitationMapping: false, auth: false, confirmationView: false, diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 317142a06c..4387f1f26f 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -170,6 +170,8 @@ export default () => ({ swapsDecoding: process.env.FF_SWAPS_DECODING?.toLowerCase() === 'true', twapsDecoding: process.env.FF_TWAPS_DECODING?.toLowerCase() === 'true', debugLogs: process.env.FF_DEBUG_LOGS?.toLowerCase() === 'true', + configHooksDebugLogs: + process.env.FF_CONFIG_HOOKS_DEBUG_LOGS?.toLowerCase() === 'true', imitationMapping: process.env.FF_IMITATION_MAPPING?.toLowerCase() === 'true', auth: process.env.FF_AUTH?.toLowerCase() === 'true', diff --git a/src/datasources/cache/cache.first.data.source.spec.ts b/src/datasources/cache/cache.first.data.source.spec.ts index 2066ff10b2..32492c5477 100644 --- a/src/datasources/cache/cache.first.data.source.spec.ts +++ b/src/datasources/cache/cache.first.data.source.spec.ts @@ -33,6 +33,7 @@ describe('CacheFirstDataSource', () => { fakeCacheService = new FakeCacheService(); fakeConfigurationService = new FakeConfigurationService(); fakeConfigurationService.set('features.debugLogs', true); + fakeConfigurationService.set('features.configHooksDebugLogs', false); cacheFirstDataSource = new CacheFirstDataSource( fakeCacheService, mockNetworkService, diff --git a/src/datasources/cache/cache.first.data.source.ts b/src/datasources/cache/cache.first.data.source.ts index dd0c5822d0..cafe562f39 100644 --- a/src/datasources/cache/cache.first.data.source.ts +++ b/src/datasources/cache/cache.first.data.source.ts @@ -35,6 +35,7 @@ import { Safe } from '@/domain/safe/entities/safe.entity'; @Injectable() export class CacheFirstDataSource { private readonly areDebugLogsEnabled: boolean; + private readonly areConfigHooksDebugLogsEnabled: boolean; constructor( @Inject(CacheService) private readonly cacheService: ICacheService, @@ -45,6 +46,10 @@ export class CacheFirstDataSource { ) { this.areDebugLogsEnabled = this.configurationService.getOrThrow('features.debugLogs'); + this.areConfigHooksDebugLogsEnabled = + this.configurationService.getOrThrow( + 'features.configHooksDebugLogs', + ); } /** @@ -149,6 +154,13 @@ export class CacheFirstDataSource { data as Safe, ); } + + if ( + this.areConfigHooksDebugLogsEnabled && + args.cacheDir.key.includes('chain') + ) { + this.logChainUpdateCacheWrite(startTimeMs, args.cacheDir, data); + } } return data; } @@ -267,4 +279,24 @@ export class CacheFirstDataSource { safe, }); } + + /** + * Logs the chain/chains retrieved. + * NOTE: this is a debugging-only function. + * TODO: remove this function after debugging. + */ + private logChainUpdateCacheWrite( + requestStartTime: number, + cacheDir: CacheDir, + data: unknown, + ): void { + this.loggingService.info({ + type: 'cache_write', + cacheKey: cacheDir.key, + cacheField: cacheDir.field, + cacheWriteTime: new Date(), + requestStartTime: new Date(requestStartTime), + data, + }); + } } diff --git a/src/datasources/config-api/config-api.service.spec.ts b/src/datasources/config-api/config-api.service.spec.ts index 7020a732ec..b25b1bc998 100644 --- a/src/datasources/config-api/config-api.service.spec.ts +++ b/src/datasources/config-api/config-api.service.spec.ts @@ -7,6 +7,7 @@ import { HttpErrorFactory } from '@/datasources/errors/http-error-factory'; import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; import { DataSourceError } from '@/domain/errors/data-source.error'; import { safeAppBuilder } from '@/domain/safe-apps/entities/__tests__/safe-app.builder'; +import { ILoggingService } from '@/logging/logging.interface'; import { faker } from '@faker-js/faker'; const dataSource = { @@ -25,6 +26,10 @@ const httpErrorFactory = { } as jest.MockedObjectDeep; const mockHttpErrorFactory = jest.mocked(httpErrorFactory); +const mockLoggingService = { + info: jest.fn(), +} as jest.MockedObjectDeep; + describe('ConfigApi', () => { const baseUri = faker.internet.url({ appendSlash: false }); const expirationTimeInSeconds = faker.number.int(); @@ -43,6 +48,7 @@ describe('ConfigApi', () => { 'expirationTimeInSeconds.notFound.default', notFoundExpirationTimeInSeconds, ); + fakeConfigurationService.set('features.configHooksDebugLogs', false); }); beforeEach(() => { @@ -52,6 +58,7 @@ describe('ConfigApi', () => { mockCacheService, fakeConfigurationService, mockHttpErrorFactory, + mockLoggingService, ); }); @@ -65,6 +72,7 @@ describe('ConfigApi', () => { mockCacheService, fakeConfigurationService, mockHttpErrorFactory, + mockLoggingService, ), ).toThrow(); }); diff --git a/src/datasources/config-api/config-api.service.ts b/src/datasources/config-api/config-api.service.ts index e9c0dacbf0..56e47cc5dd 100644 --- a/src/datasources/config-api/config-api.service.ts +++ b/src/datasources/config-api/config-api.service.ts @@ -1,4 +1,3 @@ -import { Inject, Injectable } from '@nestjs/common'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { CacheFirstDataSource } from '@/datasources/cache/cache.first.data.source'; import { CacheRouter } from '@/datasources/cache/cache.router'; @@ -11,12 +10,15 @@ import { Chain } from '@/domain/chains/entities/chain.entity'; import { Page } from '@/domain/entities/page.entity'; import { IConfigApi } from '@/domain/interfaces/config-api.interface'; import { SafeApp } from '@/domain/safe-apps/entities/safe-app.entity'; +import { ILoggingService, LoggingService } from '@/logging/logging.interface'; +import { Inject, Injectable } from '@nestjs/common'; @Injectable() export class ConfigApi implements IConfigApi { private readonly baseUri: string; private readonly defaultExpirationTimeInSeconds: number; private readonly defaultNotFoundExpirationTimeSeconds: number; + private readonly areConfigHooksDebugLogsEnabled: boolean; constructor( private readonly dataSource: CacheFirstDataSource, @@ -24,6 +26,7 @@ export class ConfigApi implements IConfigApi { @Inject(IConfigurationService) private readonly configurationService: IConfigurationService, private readonly httpErrorFactory: HttpErrorFactory, + @Inject(LoggingService) private readonly loggingService: ILoggingService, ) { this.baseUri = this.configurationService.getOrThrow('safeConfig.baseUri'); @@ -35,6 +38,10 @@ export class ConfigApi implements IConfigApi { this.configurationService.getOrThrow( 'expirationTimeInSeconds.notFound.default', ); + this.areConfigHooksDebugLogsEnabled = + this.configurationService.getOrThrow( + 'features.configHooksDebugLogs', + ); } async getChains(args: { @@ -76,6 +83,10 @@ export class ConfigApi implements IConfigApi { async clearChain(chainId: string): Promise { const chainCacheKey = CacheRouter.getChainCacheKey(chainId); const chainsCacheKey = CacheRouter.getChainsCacheKey(); + if (this.areConfigHooksDebugLogsEnabled) { + this.loggingService.info(`Clearing chain ${chainId}: ${chainCacheKey}`); + this.loggingService.info(`Clearing chains: ${chainsCacheKey}`); + } await Promise.all([ this.cacheService.deleteByKey(chainCacheKey), this.cacheService.deleteByKey(chainsCacheKey), From 40b1555a4dfa984317563b31dbd0b5d7bf841789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9ctor=20G=C3=B3mez?= Date: Fri, 26 Jul 2024 17:34:14 +0200 Subject: [PATCH 206/207] Extract getFromCacheOrExecuteAndCache utility function (#1777) - Adds `CachedQueryResolver` class, to be used to retrieve data from the service cache before executing a query. --- .../accounts/accounts.datasource.spec.ts | 2 + .../accounts/accounts.datasource.ts | 39 ++++---- .../counterfactual-safes.datasource.spec.ts | 2 + .../counterfactual-safes.datasource.ts | 27 +++--- .../db/cached-query-resolver.interface.ts | 12 +++ .../db/cached-query-resolver.spec.ts | 94 +++++++++++++++++++ src/datasources/db/cached-query-resolver.ts | 62 ++++++++++++ .../db/postgres-database.module.spec.ts | 8 +- .../db/postgres-database.module.ts | 8 +- 9 files changed, 215 insertions(+), 39 deletions(-) create mode 100644 src/datasources/db/cached-query-resolver.interface.ts create mode 100644 src/datasources/db/cached-query-resolver.spec.ts create mode 100644 src/datasources/db/cached-query-resolver.ts diff --git a/src/datasources/accounts/accounts.datasource.spec.ts b/src/datasources/accounts/accounts.datasource.spec.ts index 604e707bd9..d49090fb2f 100644 --- a/src/datasources/accounts/accounts.datasource.spec.ts +++ b/src/datasources/accounts/accounts.datasource.spec.ts @@ -4,6 +4,7 @@ import { AccountsDatasource } from '@/datasources/accounts/accounts.datasource'; import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.service'; import { MAX_TTL } from '@/datasources/cache/constants'; import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity'; +import { CachedQueryResolver } from '@/datasources/db/cached-query-resolver'; import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; import { accountDataTypeBuilder } from '@/domain/accounts/entities/__tests__/account-data-type.builder'; import { upsertAccountDataSettingsDtoBuilder } from '@/domain/accounts/entities/__tests__/upsert-account-data-settings.dto.entity.builder'; @@ -43,6 +44,7 @@ describe('AccountsDatasource tests', () => { target = new AccountsDatasource( fakeCacheService, sql, + new CachedQueryResolver(mockLoggingService, fakeCacheService), mockLoggingService, mockConfigurationService, ); diff --git a/src/datasources/accounts/accounts.datasource.ts b/src/datasources/accounts/accounts.datasource.ts index 5e5067e1df..dc27efd657 100644 --- a/src/datasources/accounts/accounts.datasource.ts +++ b/src/datasources/accounts/accounts.datasource.ts @@ -5,7 +5,8 @@ import { ICacheService, } from '@/datasources/cache/cache.service.interface'; import { MAX_TTL } from '@/datasources/cache/constants'; -import { getFromCacheOrExecuteAndCache } from '@/datasources/db/utils'; +import { CachedQueryResolver } from '@/datasources/db/cached-query-resolver'; +import { ICachedQueryResolver } from '@/datasources/db/cached-query-resolver.interface'; import { AccountDataSetting } from '@/domain/accounts/entities/account-data-setting.entity'; import { AccountDataType } from '@/domain/accounts/entities/account-data-type.entity'; import { Account } from '@/domain/accounts/entities/account.entity'; @@ -29,6 +30,8 @@ export class AccountsDatasource implements IAccountsDatasource, OnModuleInit { constructor( @Inject(CacheService) private readonly cacheService: ICacheService, @Inject('DB_INSTANCE') private readonly sql: postgres.Sql, + @Inject(ICachedQueryResolver) + private readonly cachedQueryResolver: CachedQueryResolver, @Inject(LoggingService) private readonly loggingService: ILoggingService, @Inject(IConfigurationService) private readonly configurationService: IConfigurationService, @@ -70,13 +73,11 @@ export class AccountsDatasource implements IAccountsDatasource, OnModuleInit { async getAccount(address: `0x${string}`): Promise { const cacheDir = CacheRouter.getAccountCacheDir(address); - const [account] = await getFromCacheOrExecuteAndCache( - this.loggingService, - this.cacheService, + const [account] = await this.cachedQueryResolver.get({ cacheDir, - this.sql`SELECT * FROM accounts WHERE address = ${address}`, - this.defaultExpirationTimeInSeconds, - ); + query: this.sql`SELECT * FROM accounts WHERE address = ${address}`, + ttl: this.defaultExpirationTimeInSeconds, + }); if (!account) { throw new NotFoundException('Error getting account.'); @@ -106,13 +107,11 @@ export class AccountsDatasource implements IAccountsDatasource, OnModuleInit { async getDataTypes(): Promise { const cacheDir = CacheRouter.getAccountDataTypesCacheDir(); - return getFromCacheOrExecuteAndCache( - this.loggingService, - this.cacheService, + return this.cachedQueryResolver.get({ cacheDir, - this.sql`SELECT * FROM account_data_types`, - MAX_TTL, - ); + query: this.sql`SELECT * FROM account_data_types`, + ttl: MAX_TTL, + }); } async getAccountDataSettings( @@ -120,16 +119,14 @@ export class AccountsDatasource implements IAccountsDatasource, OnModuleInit { ): Promise { const account = await this.getAccount(address); const cacheDir = CacheRouter.getAccountDataSettingsCacheDir(address); - return getFromCacheOrExecuteAndCache( - this.loggingService, - this.cacheService, + return this.cachedQueryResolver.get({ cacheDir, - this.sql` - SELECT ads.* FROM account_data_settings ads INNER JOIN account_data_types adt - ON ads.account_data_type_id = adt.id + query: this.sql` + SELECT ads.* FROM account_data_settings ads + INNER JOIN account_data_types adt ON ads.account_data_type_id = adt.id WHERE ads.account_id = ${account.id} AND adt.is_active IS TRUE;`, - this.defaultExpirationTimeInSeconds, - ); + ttl: this.defaultExpirationTimeInSeconds, + }); } /** diff --git a/src/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource.spec.ts b/src/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource.spec.ts index 957b89e087..632a2b08d3 100644 --- a/src/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource.spec.ts +++ b/src/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource.spec.ts @@ -3,6 +3,7 @@ import { IConfigurationService } from '@/config/configuration.service.interface' import { CounterfactualSafesDatasource } from '@/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource'; import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.service'; import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity'; +import { CachedQueryResolver } from '@/datasources/db/cached-query-resolver'; import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; import { createCounterfactualSafeDtoBuilder } from '@/domain/accounts/counterfactual-safes/entities/__tests__/create-counterfactual-safe.dto.entity.builder'; import { accountBuilder } from '@/domain/accounts/entities/__tests__/account.builder'; @@ -42,6 +43,7 @@ describe('CounterfactualSafesDatasource tests', () => { target = new CounterfactualSafesDatasource( fakeCacheService, sql, + new CachedQueryResolver(mockLoggingService, fakeCacheService), mockLoggingService, mockConfigurationService, ); diff --git a/src/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource.ts b/src/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource.ts index 7ea2854128..607c006c36 100644 --- a/src/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource.ts +++ b/src/datasources/accounts/counterfactual-safes/counterfactual-safes.datasource.ts @@ -4,7 +4,8 @@ import { CacheService, ICacheService, } from '@/datasources/cache/cache.service.interface'; -import { getFromCacheOrExecuteAndCache } from '@/datasources/db/utils'; +import { CachedQueryResolver } from '@/datasources/db/cached-query-resolver'; +import { ICachedQueryResolver } from '@/datasources/db/cached-query-resolver.interface'; import { CounterfactualSafe } from '@/domain/accounts/counterfactual-safes/entities/counterfactual-safe.entity'; import { CreateCounterfactualSafeDto } from '@/domain/accounts/counterfactual-safes/entities/create-counterfactual-safe.dto.entity'; import { Account } from '@/domain/accounts/entities/account.entity'; @@ -22,6 +23,8 @@ export class CounterfactualSafesDatasource constructor( @Inject(CacheService) private readonly cacheService: ICacheService, @Inject('DB_INSTANCE') private readonly sql: postgres.Sql, + @Inject(ICachedQueryResolver) + private readonly cachedQueryResolver: CachedQueryResolver, @Inject(LoggingService) private readonly loggingService: ILoggingService, @Inject(IConfigurationService) private readonly configurationService: IConfigurationService, @@ -58,16 +61,14 @@ export class CounterfactualSafesDatasource args.chainId, args.predictedAddress, ); - const [counterfactualSafe] = await getFromCacheOrExecuteAndCache< + const [counterfactualSafe] = await this.cachedQueryResolver.get< CounterfactualSafe[] - >( - this.loggingService, - this.cacheService, + >({ cacheDir, - this.sql` + query: this.sql` SELECT * FROM counterfactual_safes WHERE chain_id = ${args.chainId} AND predicted_address = ${args.predictedAddress}`, - this.defaultExpirationTimeInSeconds, - ); + ttl: this.defaultExpirationTimeInSeconds, + }); if (!counterfactualSafe) { throw new NotFoundException('Error getting Counterfactual Safe.'); @@ -82,14 +83,12 @@ export class CounterfactualSafesDatasource const cacheDir = CacheRouter.getCounterfactualSafesCacheDir( account.address, ); - return getFromCacheOrExecuteAndCache( - this.loggingService, - this.cacheService, + return this.cachedQueryResolver.get({ cacheDir, - this.sql` + query: this.sql` SELECT * FROM counterfactual_safes WHERE account_id = ${account.id}`, - this.defaultExpirationTimeInSeconds, - ); + ttl: this.defaultExpirationTimeInSeconds, + }); } async deleteCounterfactualSafe(args: { diff --git a/src/datasources/db/cached-query-resolver.interface.ts b/src/datasources/db/cached-query-resolver.interface.ts new file mode 100644 index 0000000000..aafe0385a1 --- /dev/null +++ b/src/datasources/db/cached-query-resolver.interface.ts @@ -0,0 +1,12 @@ +import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity'; +import postgres from 'postgres'; + +export const ICachedQueryResolver = Symbol('ICachedQueryResolver'); + +export interface ICachedQueryResolver { + get(args: { + cacheDir: CacheDir; + query: postgres.PendingQuery; + ttl: number; + }): Promise; +} diff --git a/src/datasources/db/cached-query-resolver.spec.ts b/src/datasources/db/cached-query-resolver.spec.ts new file mode 100644 index 0000000000..d34919fdfa --- /dev/null +++ b/src/datasources/db/cached-query-resolver.spec.ts @@ -0,0 +1,94 @@ +import { fakeJson } from '@/__tests__/faker'; +import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.service'; +import { CachedQueryResolver } from '@/datasources/db/cached-query-resolver'; +import { ILoggingService } from '@/logging/logging.interface'; +import { faker } from '@faker-js/faker'; +import { InternalServerErrorException } from '@nestjs/common'; +import postgres, { MaybeRow } from 'postgres'; + +const mockLoggingService = jest.mocked({ + debug: jest.fn(), + error: jest.fn(), +} as jest.MockedObjectDeep); + +const mockQuery = jest.mocked({ + execute: jest.fn(), +} as jest.MockedObjectDeep>); + +describe('CachedQueryResolver', () => { + let fakeCacheService: FakeCacheService; + let target: CachedQueryResolver; + + beforeAll(() => { + fakeCacheService = new FakeCacheService(); + target = new CachedQueryResolver(mockLoggingService, fakeCacheService); + }); + + afterEach(() => { + jest.clearAllMocks(); + fakeCacheService.clear(); + }); + + describe('get', () => { + it('should return the content from cache if it exists', async () => { + const cacheDir = { key: 'key', field: 'field' }; + const ttl = faker.number.int({ min: 1, max: 1000 }); + const value = fakeJson(); + await fakeCacheService.set(cacheDir, JSON.stringify(value), ttl); + + const actual = await target.get({ + cacheDir, + query: mockQuery, + ttl, + }); + + expect(actual).toBe(value); + expect(mockLoggingService.debug).toHaveBeenCalledWith({ + type: 'cache_hit', + key: 'key', + field: 'field', + }); + }); + + it('should execute the query and cache the result if the cache is empty', async () => { + const cacheDir = { key: 'key', field: 'field' }; + const ttl = faker.number.int({ min: 1, max: 1000 }); + const dbResult = { ...JSON.parse(fakeJson()), count: 1 }; + mockQuery.execute.mockImplementation(() => dbResult); + + const actual = await target.get({ + cacheDir, + query: mockQuery, + ttl, + }); + + expect(actual).toBe(dbResult); + expect(mockLoggingService.debug).toHaveBeenCalledWith({ + type: 'cache_miss', + key: 'key', + field: 'field', + }); + const cacheContent = await fakeCacheService.get(cacheDir); + expect(cacheContent).toBe(JSON.stringify(dbResult)); + }); + + it('should log the error and throw a generic error if the query fails', async () => { + const cacheDir = { key: 'key', field: 'field' }; + const ttl = faker.number.int({ min: 1, max: 1000 }); + const error = new Error('error'); + mockQuery.execute.mockRejectedValue(error); + + await expect( + target.get({ + cacheDir, + query: mockQuery, + ttl, + }), + ).rejects.toThrow( + new InternalServerErrorException('Internal Server Error'), + ); + + expect(mockLoggingService.error).toHaveBeenCalledWith('error'); + }); + }); +}); diff --git a/src/datasources/db/cached-query-resolver.ts b/src/datasources/db/cached-query-resolver.ts new file mode 100644 index 0000000000..3cfbe4332c --- /dev/null +++ b/src/datasources/db/cached-query-resolver.ts @@ -0,0 +1,62 @@ +import { + CacheService, + ICacheService, +} from '@/datasources/cache/cache.service.interface'; +import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity'; +import { ICachedQueryResolver } from '@/datasources/db/cached-query-resolver.interface'; +import { ILoggingService, LoggingService } from '@/logging/logging.interface'; +import { asError } from '@/logging/utils'; +import { + Inject, + Injectable, + InternalServerErrorException, +} from '@nestjs/common'; +import postgres from 'postgres'; + +@Injectable() +export class CachedQueryResolver implements ICachedQueryResolver { + constructor( + @Inject(LoggingService) private readonly loggingService: ILoggingService, + @Inject(CacheService) private readonly cacheService: ICacheService, + ) {} + + /** + * Returns the content from cache or executes the query, caches the result and returns it. + * If the specified {@link CacheDir} is empty, the query is executed and the result is cached. + * If the specified {@link CacheDir} is not empty, the pointed content is returned. + * + * @param cacheDir {@link CacheDir} to use for caching + * @param query query to execute + * @param ttl time to live for the cache + * @returns content from cache or query result + */ + async get(args: { + cacheDir: CacheDir; + query: postgres.PendingQuery; + ttl: number; + }): Promise { + const { key, field } = args.cacheDir; + const cached = await this.cacheService.get(args.cacheDir); + if (cached != null) { + this.loggingService.debug({ type: 'cache_hit', key, field }); + return JSON.parse(cached); + } + this.loggingService.debug({ type: 'cache_miss', key, field }); + + try { + const result = await args.query.execute(); + if (result.count > 0) { + await this.cacheService.set( + args.cacheDir, + JSON.stringify(result), + args.ttl, + ); + } + return result; + } catch (err) { + // log & hide database errors + this.loggingService.error(asError(err).message); + throw new InternalServerErrorException(); + } + } +} diff --git a/src/datasources/db/postgres-database.module.spec.ts b/src/datasources/db/postgres-database.module.spec.ts index f430b9d722..2345db535c 100644 --- a/src/datasources/db/postgres-database.module.spec.ts +++ b/src/datasources/db/postgres-database.module.spec.ts @@ -1,10 +1,11 @@ -import { Test } from '@nestjs/testing'; -import { PostgresDatabaseModule } from '@/datasources/db/postgres-database.module'; import { ConfigurationModule } from '@/config/configuration.module'; import configuration from '@/config/entities/__tests__/configuration'; +import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; +import { PostgresDatabaseModule } from '@/datasources/db/postgres-database.module'; import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; -import postgres from 'postgres'; +import { Test } from '@nestjs/testing'; import { join } from 'path'; +import postgres from 'postgres'; describe('PostgresDatabaseModule tests', () => { let sql: postgres.Sql; @@ -34,6 +35,7 @@ describe('PostgresDatabaseModule tests', () => { PostgresDatabaseModule, ConfigurationModule.register(testConfiguration), TestLoggingModule, + TestCacheModule, ], }).compile(); diff --git a/src/datasources/db/postgres-database.module.ts b/src/datasources/db/postgres-database.module.ts index c8fb93e79f..936b33b56b 100644 --- a/src/datasources/db/postgres-database.module.ts +++ b/src/datasources/db/postgres-database.module.ts @@ -5,6 +5,8 @@ import { IConfigurationService } from '@/config/configuration.service.interface' import { PostgresDatabaseMigrationHook } from '@/datasources/db/postgres-database.migration.hook'; import fs from 'fs'; import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; +import { ICachedQueryResolver } from '@/datasources/db/cached-query-resolver.interface'; +import { CachedQueryResolver } from '@/datasources/db/cached-query-resolver'; function dbFactory(configurationService: IConfigurationService): postgres.Sql { const caPath = configurationService.get('db.postgres.ssl.caPath'); @@ -48,9 +50,13 @@ function migratorFactory(sql: postgres.Sql): PostgresDatabaseMigrator { useFactory: migratorFactory, inject: ['DB_INSTANCE'], }, + { + provide: ICachedQueryResolver, + useClass: CachedQueryResolver, + }, PostgresDatabaseShutdownHook, PostgresDatabaseMigrationHook, ], - exports: ['DB_INSTANCE'], + exports: ['DB_INSTANCE', ICachedQueryResolver], }) export class PostgresDatabaseModule {} From dde728ae701ab05179fbfdb1020b7097ded168fa Mon Sep 17 00:00:00 2001 From: Denis Smolonski Date: Tue, 30 Jul 2024 16:12:04 +0100 Subject: [PATCH 207/207] updates --- .env.custom | 2 +- src/config/configuration.module.ts | 10 +++++----- src/config/entities/configuration.ts | 12 ++++++------ 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.env.custom b/.env.custom index f542a06c26..a3566e4745 100644 --- a/.env.custom +++ b/.env.custom @@ -1 +1 @@ -APPLICATION_VERSION=1.40.0 \ No newline at end of file +APPLICATION_VERSION=1.52.0 \ No newline at end of file diff --git a/src/config/configuration.module.ts b/src/config/configuration.module.ts index 5a6e101f6e..d600360188 100644 --- a/src/config/configuration.module.ts +++ b/src/config/configuration.module.ts @@ -47,11 +47,11 @@ export const RootConfigurationSchema = z.object({ EMAIL_TEMPLATE_RECOVERY_TX: z.string(), EMAIL_TEMPLATE_UNKNOWN_RECOVERY_TX: z.string(), EMAIL_TEMPLATE_VERIFICATION_CODE: z.string(), - INFURA_API_KEY: z.string(), - PUSH_NOTIFICATIONS_API_PROJECT: z.string(), - PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_CLIENT_EMAIL: z.string().email(), - PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY: z.string(), - RELAY_PROVIDER_API_KEY_ARBITRUM_ONE: z.string(), + INFURA_API_KEY: z.string().optional(), + PUSH_NOTIFICATIONS_API_PROJECT: z.string().optional(), + PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_CLIENT_EMAIL: z.string().email().optional(), + PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY: z.string().optional(), + RELAY_PROVIDER_API_KEY_ARBITRUM_ONE: z.string().optional(), RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN: z.string(), RELAY_PROVIDER_API_KEY_SEPOLIA: z.string(), }); diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 9cbf7317a1..601040b1da 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -3,7 +3,7 @@ export default () => ({ about: { name: 'safe-client-gateway', - version: process.env.APPLICATION_VERSION || 'v1.39.0', + version: process.env.APPLICATION_VERSION || 'v1.52.0', buildNumber: process.env.APPLICATION_BUILD_NUMBER, }, amqp: { @@ -180,7 +180,7 @@ export default () => ({ }, blockchain: { infura: { - apiKey: process.env.INFURA_API_KEY, + apiKey: process.env.INFURA_API_KEY || '', }, }, db: { @@ -292,12 +292,12 @@ export default () => ({ baseUri: process.env.PUSH_NOTIFICATIONS_API_BASE_URI || 'https://fcm.googleapis.com/v1/projects', - project: process.env.PUSH_NOTIFICATIONS_API_PROJECT, + project: process.env.PUSH_NOTIFICATIONS_API_PROJECT || '', serviceAccount: { clientEmail: - process.env.PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_CLIENT_EMAIL, + process.env.PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_CLIENT_EMAIL || '', privateKey: - process.env.PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY, + process.env.PUSH_NOTIFICATIONS_API_SERVICE_ACCOUNT_PRIVATE_KEY || '', }, }, redis: { @@ -313,7 +313,7 @@ export default () => ({ ), apiKey: { 100: process.env.RELAY_PROVIDER_API_KEY_GNOSIS_CHAIN, - 42161: process.env.RELAY_PROVIDER_API_KEY_ARBITRUM_ONE, + 42161: process.env.RELAY_PROVIDER_API_KEY_ARBITRUM_ONE || '', 11155111: process.env.RELAY_PROVIDER_API_KEY_SEPOLIA, 18233: process.env.RELAY_PROVIDER_API_KEY_SEPOLIA, },