diff --git a/.env.protofire b/.env.protofire new file mode 100644 index 0000000000..f542a06c26 --- /dev/null +++ b/.env.protofire @@ -0,0 +1 @@ +APPLICATION_VERSION=1.40.0 \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index d869120c92..0000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,27 +0,0 @@ -version: 2 - -updates: - - package-ecosystem: 'npm' - directory: '/' - schedule: - interval: 'weekly' - day: 'monday' - groups: - nest-js-core: - patterns: - - '@nestjs/common' - - '@nestjs/core' - - '@nestjs/platform-express' - - '@nestjs/testing' - - - package-ecosystem: 'docker' - directory: '/' - schedule: - interval: 'weekly' - day: 'monday' - - - package-ecosystem: 'github-actions' - directory: '/' - schedule: - interval: 'weekly' - day: 'monday' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 578b36e9dd..6c2f756d23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,7 +95,7 @@ jobs: LOG_SILENT: true - name: Coveralls Parallel continue-on-error: true - uses: coverallsapp/github-action@v2.2.3 + uses: coverallsapp/github-action@v2.3.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} flag-name: run-${{ matrix.task }} @@ -106,7 +106,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Coveralls Finished - uses: coverallsapp/github-action@v2.2.3 + uses: coverallsapp/github-action@v2.3.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} parallel-finished: true diff --git a/eslint.config.mjs b/eslint.config.mjs index 39e52ef3e8..7aef3a0df5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -31,9 +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-misused-promises': 'off', - '@typescript-eslint/no-redundant-type-constituents': 'off', - '@typescript-eslint/no-unnecessary-type-assertion': 'off', '@typescript-eslint/no-unsafe-argument': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/no-unsafe-call': 'off', diff --git a/package.json b/package.json index dc41ff108b..2f3cf8cb9a 100644 --- a/package.json +++ b/package.json @@ -44,10 +44,10 @@ "redis": "^4.6.13", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "semver": "^7.6.0", - "viem": "^2.10.1", + "semver": "^7.6.2", + "viem": "^2.10.5", "winston": "^3.13.0", - "zod": "^3.23.6" + "zod": "^3.23.8" }, "devDependencies": { "@faker-js/faker": "^8.4.1", @@ -59,10 +59,10 @@ "@types/jest": "29.5.12", "@types/jsonwebtoken": "^9", "@types/lodash": "^4.17.1", - "@types/node": "^20.12.10", + "@types/node": "^20.12.11", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.2", - "eslint": "^9.0.0", + "eslint": "^9.2.0", "eslint-config-prettier": "^9.1.0", "husky": "^9.0.11", "jest": "29.7.0", diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index 0628f9c054..07c01d2265 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -185,6 +185,7 @@ export default (): ReturnType => ({ zerionBalancesChainIds: ['137'], swapsDecoding: true, historyDebugLogs: false, + imitationMapping: false, auth: false, confirmationView: false, eventsQueue: false, @@ -199,6 +200,10 @@ export default (): ReturnType => ({ silent: process.env.LOG_SILENT?.toLowerCase() === 'true', }, mappings: { + imitation: { + prefixLength: faker.number.int(), + suffixLength: faker.number.int(), + }, history: { maxNestedTransfers: faker.number.int({ min: 1, max: 5 }), }, diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 12674c0138..147066a775 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, + version: process.env.APPLICATION_VERSION || 'v1.39.0', buildNumber: process.env.APPLICATION_BUILD_NUMBER, }, amqp: { @@ -68,6 +68,55 @@ export default () => ({ 84531: { nativeCoin: 'ethereum', chainName: 'base' }, 84532: { nativeCoin: 'ethereum', chainName: 'base' }, 2810: { nativeCoin: 'ethereum', chainName: 'morph-holesky' }, + 1284: { nativeCoin: 'moonbeam', chainName: 'moonbeam' }, + 1285: { nativeCoin: 'moonriver', chainName: 'moonriver' }, + 1287: { nativeCoin: 'moonbeam', chainName: 'moonbeam' }, + 59144: { nativeCoin: 'ethereum', chainName: 'linea' }, + 59140: { nativeCoin: 'ethereum', chainName: 'linea' }, + 245022934: { nativeCoin: 'neon', chainName: 'neon-evm' }, + 534351: { nativeCoin: 'ethereum', chainName: 'scroll' }, + 80085: { nativeCoin: 'berachain-bera', chainName: 'berachain' }, + 17000: { nativeCoin: 'ethereum', chainName: 'ethereum' }, + 255: { nativeCoin: 'ethereum', chainName: 'kroma' }, + 2358: { nativeCoin: 'ethereum', chainName: 'kroma' }, + 11155420: { nativeCoin: 'ethereum', chainName: 'ethereum' }, + 7777777: { nativeCoin: 'ethereum', chainName: 'zora-network' }, + 999999999: { nativeCoin: 'ethereum', chainName: 'zora-network' }, + 34443: { nativeCoin: 'ethereum', chainName: 'mode' }, + 919: { nativeCoin: 'ethereum', chainName: 'mode' }, + 1111: { nativeCoin: 'wemix-token', chainName: 'wemix-network' }, + 1112: { nativeCoin: 'wemix-token', chainName: 'wemix-network' }, + 7000: { nativeCoin: 'zetachain', chainName: 'zetachain' }, + 7001: { nativeCoin: 'zetachain', chainName: 'zetachain' }, + 168587773: { nativeCoin: 'ethereum', chainName: 'blast' }, + 1666600000: { nativeCoin: 'harmony', chainName: 'harmony-shard-0' }, + 1666700000: { nativeCoin: 'harmony', chainName: 'harmony-shard-0' }, + 23294: { nativeCoin: 'oasis-network', chainName: 'oasis-sapphire' }, + 23295: { nativeCoin: 'oasis-network', chainName: 'oasis-sapphire' }, + 30: { nativeCoin: 'rootstock', chainName: 'rootstock' }, + 31: { nativeCoin: 'rootstock', chainName: 'rootstock' }, + 18233: { nativeCoin: 'ethereum', chainName: 'unreal' }, + 111188: { nativeCoin: 'ethereum', chainName: 're-al' }, + 713715: { nativeCoin: 'sei-network', chainName: 'sei-network' }, + 1135: { nativeCoin: 'ethereum', chainName: 'lisk' }, + 4202: { nativeCoin: 'ethereum', chainName: 'lisksep' }, + 81457: { nativeCoin: 'ethereum', chainName: 'blast' }, + 252: { nativeCoin: 'ethereum', chainName: 'fraxtal' }, + 424: { nativeCoin: 'ethereum', chainName: 'PGN' }, + 1729: { nativeCoin: 'ethereum', chainName: 'reya' }, + 810180: { nativeCoin: 'ethereum', chainName: 'zklink' }, + 810182: { nativeCoin: 'ethereum', chainName: 'zklink' }, + 4157: { nativeCoin: 'crossfi-2', chainName: 'crossfi-2' }, + 2523: { nativeCoin: 'ethereum', chainName: 'fraxtal' }, + 60808: { nativeCoin: 'ethereum', chainName: 'gobob' }, + 111: { nativeCoin: 'ethereum', chainName: 'gobob' }, + 123420111: { nativeCoin: 'ethereum', chainName: 'opcelestia-raspberry' }, + 88153591557: { nativeCoin: 'ethereum', chainName: 'arb-blueberry' }, + 94204209: { nativeCoin: 'ethereum', chainName: 'polygon-blackberry' }, + 690: { nativeCoin: 'ethereum', chainName: 'redstone-mainnet' }, + 17069: { nativeCoin: 'ethereum', chainName: 'redstone-garnet' }, + 7560: { nativeCoin: 'ethereum', chainName: 'cyber' }, + 111557560: { nativeCoin: 'ethereum', chainName: 'cyber' }, }, highRefreshRateTokens: process.env.HIGH_REFRESH_RATE_TOKENS?.split(',') ?? [], @@ -194,6 +243,8 @@ export default () => ({ swapsDecoding: process.env.FF_SWAPS_DECODING?.toLowerCase() === 'true', historyDebugLogs: process.env.FF_HISTORY_DEBUG_LOGS?.toLowerCase() === 'true', + imitationMapping: + process.env.FF_IMITATION_MAPPING?.toLowerCase() === 'true', auth: process.env.FF_AUTH?.toLowerCase() === 'true', confirmationView: process.env.FF_CONFIRMATION_VIEW?.toLowerCase() === 'true', @@ -221,6 +272,10 @@ export default () => ({ ownersTtlSeconds: parseInt(process.env.OWNERS_TTL_SECONDS ?? `${0}`), }, mappings: { + imitation: { + prefixLength: parseInt(process.env.IMITATION_PREFIX_LENGTH ?? `${3}`), + suffixLength: parseInt(process.env.IMITATION_SUFFIX_LENGTH ?? `${4}`), + }, history: { maxNestedTransfers: parseInt( process.env.MAX_NESTED_TRANSFERS ?? `${100}`, diff --git a/src/datasources/alerts-api/tenderly-api.service.spec.ts b/src/datasources/alerts-api/tenderly-api.service.spec.ts index cd08e11deb..7e708c3479 100644 --- a/src/datasources/alerts-api/tenderly-api.service.spec.ts +++ b/src/datasources/alerts-api/tenderly-api.service.spec.ts @@ -7,6 +7,7 @@ import { AlertsRegistration } from '@/domain/alerts/entities/alerts-registration import { DataSourceError } from '@/domain/errors/data-source.error'; import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; import { AlertsDeletion } from '@/domain/alerts/entities/alerts-deletion.entity'; +import { getAddress } from 'viem'; const networkService = { post: jest.fn(), @@ -71,7 +72,7 @@ describe('TenderlyApi', () => { }; const contract: AlertsRegistration = { - address: faker.finance.ethereumAddress(), + address: getAddress(faker.finance.ethereumAddress()), displayName: fakeDisplayName(), chainId: faker.string.numeric(), }; @@ -108,7 +109,7 @@ describe('TenderlyApi', () => { await expect( service.addContract({ - address: faker.finance.ethereumAddress(), + address: getAddress(faker.finance.ethereumAddress()), chainId: faker.string.numeric(), }), ).rejects.toThrow(new DataSourceError('Unexpected error', status)); @@ -120,7 +121,7 @@ describe('TenderlyApi', () => { describe('deleteContract', () => { it('should delete a contract', async () => { const contract: AlertsDeletion = { - address: faker.finance.ethereumAddress(), + address: getAddress(faker.finance.ethereumAddress()), chainId: faker.string.numeric(), }; @@ -151,7 +152,7 @@ describe('TenderlyApi', () => { await expect( service.deleteContract({ - address: faker.finance.ethereumAddress(), + address: getAddress(faker.finance.ethereumAddress()), chainId: faker.string.numeric(), }), ).rejects.toThrow(new DataSourceError('Unexpected error', status)); diff --git a/src/datasources/balances-api/coingecko-api.service.spec.ts b/src/datasources/balances-api/coingecko-api.service.spec.ts index 477974de53..a78e7c4da7 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(), @@ -147,7 +148,6 @@ describe('CoingeckoAPI', () => { it('should return and cache one token price (using an API key)', async () => { const chain = chainBuilder().build(); - const chainName = faker.string.sample(); const tokenAddress = faker.finance.ethereumAddress(); const fiatCode = faker.finance.currencyCode(); const lowerCaseFiatCode = fiatCode.toLowerCase(); @@ -160,26 +160,24 @@ describe('CoingeckoAPI', () => { data: coingeckoPrice, status: 200, }); - fakeConfigurationService.set( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - chainName, - ); const assetPrice = await service.getTokenPrices({ - chainId: chain.chainId, + chain, tokenAddresses: [tokenAddress], fiatCode, }); const expectedCacheDir = new CacheDir( - `${chainName}_token_price_${tokenAddress}_${lowerCaseFiatCode}`, + // @ts-expect-error - TODO: remove after migration + `${chain.pricesProvider.chainName}_token_price_${tokenAddress}_${lowerCaseFiatCode}`, '', ); expect(assetPrice).toEqual([ { [tokenAddress]: { [lowerCaseFiatCode]: price } }, ]); expect(mockNetworkService.get).toHaveBeenCalledWith({ - url: `${coingeckoBaseUri}/simple/token_price/${chainName}`, + // @ts-expect-error - TODO: remove after migration + url: `${coingeckoBaseUri}/simple/token_price/${chain.pricesProvider.chainName}`, networkRequest: { headers: { 'x-cg-pro-api-key': coingeckoApiKey, @@ -203,6 +201,69 @@ describe('CoingeckoAPI', () => { it('should return and cache one token price (with no API key)', async () => { fakeConfigurationService.set('balances.providers.safe.prices.apiKey', null); const chain = chainBuilder().build(); + 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, + }); + const service = new CoingeckoApi( + fakeConfigurationService, + mockCacheFirstDataSource, + mockNetworkService, + mockCacheService, + mockLoggingService, + ); + + const assetPrice = await service.getTokenPrices({ + chain, + tokenAddresses: [tokenAddress], + fiatCode, + }); + + const expectedCacheDir = new CacheDir( + // @ts-expect-error - TODO: remove after migration + `${chain.pricesProvider.chainName}_token_price_${tokenAddress}_${lowerCaseFiatCode}`, + '', + ); + expect(assetPrice).toEqual([ + { [tokenAddress]: { [lowerCaseFiatCode]: price } }, + ]); + expect(mockNetworkService.get).toHaveBeenCalledWith({ + // @ts-expect-error - TODO: remove after migration + url: `${coingeckoBaseUri}/simple/token_price/${chain.pricesProvider.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, + ); + }); + + // 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(); @@ -229,7 +290,7 @@ describe('CoingeckoAPI', () => { ); const assetPrice = await service.getTokenPrices({ - chainId: chain.chainId, + chain, tokenAddresses: [tokenAddress], fiatCode, }); @@ -261,7 +322,6 @@ describe('CoingeckoAPI', () => { }); it('should return and cache multiple token prices', async () => { - const chainName = faker.string.sample(); const chain = chainBuilder().build(); const fiatCode = faker.finance.currencyCode(); const lowerCaseFiatCode = fiatCode.toLowerCase(); @@ -276,10 +336,6 @@ describe('CoingeckoAPI', () => { [secondTokenAddress]: { [lowerCaseFiatCode]: secondPrice }, [thirdTokenAddress]: { [lowerCaseFiatCode]: thirdPrice }, }; - fakeConfigurationService.set( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - chainName, - ); mockCacheService.get.mockResolvedValue(undefined); mockNetworkService.get.mockResolvedValue({ data: coingeckoPrice, @@ -287,7 +343,7 @@ describe('CoingeckoAPI', () => { }); const assetPrice = await service.getTokenPrices({ - chainId: chain.chainId, + chain, tokenAddresses: [ firstTokenAddress, secondTokenAddress, @@ -302,7 +358,8 @@ describe('CoingeckoAPI', () => { { [thirdTokenAddress]: { [lowerCaseFiatCode]: thirdPrice } }, ]); expect(mockNetworkService.get).toHaveBeenCalledWith({ - url: `${coingeckoBaseUri}/simple/token_price/${chainName}`, + // @ts-expect-error - TODO: remove after migration + url: `${coingeckoBaseUri}/simple/token_price/${chain.pricesProvider.chainName}`, networkRequest: { headers: { 'x-cg-pro-api-key': coingeckoApiKey, @@ -320,26 +377,30 @@ describe('CoingeckoAPI', () => { expect(mockCacheService.get).toHaveBeenCalledTimes(3); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, + // @ts-expect-error - TODO: remove after migration + `${chain.pricesProvider.chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${secondTokenAddress}_${lowerCaseFiatCode}`, + // @ts-expect-error - TODO: remove after migration + `${chain.pricesProvider.chainName}_token_price_${secondTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, + // @ts-expect-error - TODO: remove after migration + `${chain.pricesProvider.chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.set).toHaveBeenCalledTimes(3); expect(mockCacheService.set).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, + // @ts-expect-error - TODO: remove after migration + `${chain.pricesProvider.chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, '', ), JSON.stringify({ @@ -349,7 +410,8 @@ describe('CoingeckoAPI', () => { ); expect(mockCacheService.set).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${secondTokenAddress}_${lowerCaseFiatCode}`, + // @ts-expect-error - TODO: remove after migration + `${chain.pricesProvider.chainName}_token_price_${secondTokenAddress}_${lowerCaseFiatCode}`, '', ), JSON.stringify({ @@ -359,7 +421,8 @@ describe('CoingeckoAPI', () => { ); expect(mockCacheService.set).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, + // @ts-expect-error - TODO: remove after migration + `${chain.pricesProvider.chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, '', ), JSON.stringify({ @@ -371,7 +434,6 @@ describe('CoingeckoAPI', () => { it('should return and cache with low TTL one high-refresh-rate token price', async () => { const chain = chainBuilder().build(); - const chainName = faker.string.sample(); const highRefreshRateTokenAddress = faker.finance.ethereumAddress(); const anotherTokenAddress = faker.finance.ethereumAddress(); const fiatCode = faker.finance.currencyCode(); @@ -387,10 +449,6 @@ describe('CoingeckoAPI', () => { data: coingeckoPrice, status: 200, }); - fakeConfigurationService.set( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - chainName, - ); fakeConfigurationService.set( `balances.providers.safe.prices.highRefreshRateTokens`, [ @@ -408,7 +466,7 @@ describe('CoingeckoAPI', () => { ); const assetPrice = await service.getTokenPrices({ - chainId: chain.chainId, + chain, tokenAddresses: [highRefreshRateTokenAddress, anotherTokenAddress], fiatCode, }); @@ -418,7 +476,8 @@ describe('CoingeckoAPI', () => { { [anotherTokenAddress]: { [lowerCaseFiatCode]: anotherPrice } }, ]); expect(mockNetworkService.get).toHaveBeenCalledWith({ - url: `${coingeckoBaseUri}/simple/token_price/${chainName}`, + // @ts-expect-error - TODO: remove after migration + url: `${coingeckoBaseUri}/simple/token_price/${chain.pricesProvider.chainName}`, networkRequest: { headers: { 'x-cg-pro-api-key': coingeckoApiKey, @@ -437,13 +496,15 @@ describe('CoingeckoAPI', () => { // high-refresh-rate token price is cached with highRefreshRateTokensTtlSeconds expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${highRefreshRateTokenAddress}_${lowerCaseFiatCode}`, + // @ts-expect-error - TODO: remove after migration + `${chain.pricesProvider.chainName}_token_price_${highRefreshRateTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.set).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${highRefreshRateTokenAddress}_${lowerCaseFiatCode}`, + // @ts-expect-error - TODO: remove after migration + `${chain.pricesProvider.chainName}_token_price_${highRefreshRateTokenAddress}_${lowerCaseFiatCode}`, '', ), JSON.stringify({ @@ -454,13 +515,15 @@ describe('CoingeckoAPI', () => { // another token price is cached with pricesCacheTtlSeconds expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${anotherTokenAddress}_${lowerCaseFiatCode}`, + // @ts-expect-error - TODO: remove after migration + `${chain.pricesProvider.chainName}_token_price_${anotherTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.set).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${anotherTokenAddress}_${lowerCaseFiatCode}`, + // @ts-expect-error - TODO: remove after migration + `${chain.pricesProvider.chainName}_token_price_${anotherTokenAddress}_${lowerCaseFiatCode}`, '', ), JSON.stringify({ @@ -471,7 +534,6 @@ describe('CoingeckoAPI', () => { }); it('should cache new token prices only', async () => { - const chainName = faker.string.sample(); const chain = chainBuilder().build(); const fiatCode = faker.finance.currencyCode(); const lowerCaseFiatCode = fiatCode.toLowerCase(); @@ -496,13 +558,9 @@ describe('CoingeckoAPI', () => { data: coingeckoPrice, status: 200, }); - fakeConfigurationService.set( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - chainName, - ); const assetPrices = await service.getTokenPrices({ - chainId: chain.chainId, + chain, tokenAddresses: [ firstTokenAddress, secondTokenAddress, @@ -522,7 +580,8 @@ describe('CoingeckoAPI', () => { ), ); expect(mockNetworkService.get).toHaveBeenCalledWith({ - url: `${coingeckoBaseUri}/simple/token_price/${chainName}`, + // @ts-expect-error - TODO: remove after migration + url: `${coingeckoBaseUri}/simple/token_price/${chain.pricesProvider.chainName}`, networkRequest: { headers: { 'x-cg-pro-api-key': coingeckoApiKey, @@ -536,19 +595,22 @@ describe('CoingeckoAPI', () => { expect(mockCacheService.get).toHaveBeenCalledTimes(3); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, + // @ts-expect-error - TODO: remove after migration + `${chain.pricesProvider.chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${secondTokenAddress}_${lowerCaseFiatCode}`, + // @ts-expect-error - TODO: remove after migration + `${chain.pricesProvider.chainName}_token_price_${secondTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, + // @ts-expect-error - TODO: remove after migration + `${chain.pricesProvider.chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, '', ), ); @@ -556,7 +618,8 @@ describe('CoingeckoAPI', () => { expect(mockCacheService.set).toHaveBeenNthCalledWith( 1, new CacheDir( - `${chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, + // @ts-expect-error - TODO: remove after migration + `${chain.pricesProvider.chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, '', ), JSON.stringify({ @@ -567,7 +630,8 @@ describe('CoingeckoAPI', () => { expect(mockCacheService.set).toHaveBeenNthCalledWith( 2, new CacheDir( - `${chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, + // @ts-expect-error - TODO: remove after migration + `${chain.pricesProvider.chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, '', ), JSON.stringify({ @@ -578,7 +642,6 @@ describe('CoingeckoAPI', () => { }); it('should cache not found token prices with an extended TTL', async () => { - const chainName = faker.string.sample(); const chain = chainBuilder().build(); const fiatCode = faker.finance.currencyCode(); const lowerCaseFiatCode = fiatCode.toLowerCase(); @@ -603,13 +666,9 @@ describe('CoingeckoAPI', () => { data: coingeckoPrice, status: 200, }); - fakeConfigurationService.set( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - chainName, - ); const assetPrices = await service.getTokenPrices({ - chainId: chain.chainId, + chain, tokenAddresses: [ firstTokenAddress, secondTokenAddress, @@ -629,7 +688,8 @@ describe('CoingeckoAPI', () => { ), ); expect(mockNetworkService.get).toHaveBeenCalledWith({ - url: `${coingeckoBaseUri}/simple/token_price/${chainName}`, + // @ts-expect-error - TODO: remove after migration + url: `${coingeckoBaseUri}/simple/token_price/${chain.pricesProvider.chainName}`, networkRequest: { headers: { 'x-cg-pro-api-key': coingeckoApiKey, @@ -643,19 +703,22 @@ describe('CoingeckoAPI', () => { expect(mockCacheService.get).toHaveBeenCalledTimes(3); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, + // @ts-expect-error - TODO: remove after migration + `${chain.pricesProvider.chainName}_token_price_${firstTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${secondTokenAddress}_${lowerCaseFiatCode}`, + // @ts-expect-error - TODO: remove after migration + `${chain.pricesProvider.chainName}_token_price_${secondTokenAddress}_${lowerCaseFiatCode}`, '', ), ); expect(mockCacheService.get).toHaveBeenCalledWith( new CacheDir( - `${chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, + // @ts-expect-error - TODO: remove after migration + `${chain.pricesProvider.chainName}_token_price_${thirdTokenAddress}_${lowerCaseFiatCode}`, '', ), ); @@ -676,25 +739,18 @@ describe('CoingeckoAPI', () => { }); it('should return the native coin price (using an API key)', async () => { - const nativeCoinId = faker.string.sample(); const chain = chainBuilder().build(); 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.chains.${chain.chainId}.nativeCoin`, - nativeCoinId, - ); - await service.getNativeCoinPrice({ - chainId: chain.chainId, - fiatCode, - }); + await service.getNativeCoinPrice({ chain, fiatCode }); expect(mockCacheFirstDataSource.get).toHaveBeenCalledWith({ cacheDir: new CacheDir( - `${nativeCoinId}_native_coin_price_${lowerCaseFiatCode}`, + // @ts-expect-error - TODO: remove after migration + `${chain.pricesProvider.nativeCoin}_native_coin_price_${lowerCaseFiatCode}`, '', ), url: `${coingeckoBaseUri}/simple/price`, @@ -703,7 +759,8 @@ describe('CoingeckoAPI', () => { 'x-cg-pro-api-key': coingeckoApiKey, }, params: { - ids: nativeCoinId, + // @ts-expect-error - TODO: remove after migration + ids: chain.pricesProvider.nativeCoin, vs_currencies: lowerCaseFiatCode, }, }, @@ -713,13 +770,55 @@ describe('CoingeckoAPI', () => { }); it('should return the native coin price (with no API key)', async () => { - const nativeCoinId = faker.string.sample(); const chain = chainBuilder().build(); 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); + const service = new CoingeckoApi( + fakeConfigurationService, + mockCacheFirstDataSource, + mockNetworkService, + mockCacheService, + mockLoggingService, + ); + + await service.getNativeCoinPrice({ chain, fiatCode }); + + 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, + }, + }, + notFoundExpireTimeSeconds: notFoundExpirationTimeInSeconds, + 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, @@ -732,7 +831,7 @@ describe('CoingeckoAPI', () => { mockLoggingService, ); - await service.getNativeCoinPrice({ chainId: chain.chainId, fiatCode }); + await service.getNativeCoinPrice({ chain, fiatCode }); expect(mockCacheFirstDataSource.get).toHaveBeenCalledWith({ cacheDir: new CacheDir( diff --git a/src/datasources/balances-api/coingecko-api.service.ts b/src/datasources/balances-api/coingecko-api.service.ts index c2c01d02a3..c58b467540 100644 --- a/src/datasources/balances-api/coingecko-api.service.ts +++ b/src/datasources/balances-api/coingecko-api.service.ts @@ -17,6 +17,7 @@ import { difference, get } from 'lodash'; import { LoggingService, ILoggingService } from '@/logging/logging.interface'; import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; import { asError } from '@/logging/utils'; +import { Chain } from '@/domain/chains/entities/chain.entity'; @Injectable() export class CoingeckoApi implements IPricesApi { @@ -99,15 +100,26 @@ export class CoingeckoApi implements IPricesApi { ); } + /** + * Gets prices for a chain's native coin, trying to get it from cache first. + * If it's not found in the cache, it tries to retrieve it from the Coingecko API. + * + * @param args.chain Chain entity containing the chain-specific configuration + * @param args.fiatCode + * @returns number representing the native coin price, or null if not found. + */ async getNativeCoinPrice(args: { - chainId: string; + chain: Chain; fiatCode: string; }): Promise { try { const lowerCaseFiatCode = args.fiatCode.toLowerCase(); - const nativeCoinId = this.configurationService.getOrThrow( - `balances.providers.safe.prices.chains.${args.chainId}.nativeCoin`, - ); + // 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 cacheDir = CacheRouter.getNativeCoinPriceCacheDir({ nativeCoinId, fiatCode: lowerCaseFiatCode, @@ -145,13 +157,13 @@ export class CoingeckoApi implements IPricesApi { * Gets prices for a set of token addresses, trying to get them from cache first. * For those not found in the cache, it tries to retrieve them from the Coingecko API. * - * @param args.chainName Coingecko's name for the chain (see configuration) + * @param args.chain Chain entity containing the chain-specific configuration * @param args.tokenAddresses Array of token addresses which prices are being retrieved * @param args.fiatCode * @returns Array of {@link AssetPrice} */ async getTokenPrices(args: { - chainId: string; + chain: Chain; tokenAddresses: string[]; fiatCode: string; }): Promise { @@ -160,9 +172,12 @@ export class CoingeckoApi implements IPricesApi { const lowerCaseTokenAddresses = args.tokenAddresses.map((address) => address.toLowerCase(), ); - const chainName = this.configurationService.getOrThrow( - `balances.providers.safe.prices.chains.${args.chainId}.chainName`, - ); + // 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 pricesFromCache = await this._getTokenPricesFromCache({ chainName, tokenAddresses: lowerCaseTokenAddresses, diff --git a/src/datasources/balances-api/prices-api.interface.ts b/src/datasources/balances-api/prices-api.interface.ts index de92f88682..a6cf452d99 100644 --- a/src/datasources/balances-api/prices-api.interface.ts +++ b/src/datasources/balances-api/prices-api.interface.ts @@ -1,15 +1,16 @@ import { AssetPrice } from '@/datasources/balances-api/entities/asset-price.entity'; +import { Chain } from '@/domain/chains/entities/chain.entity'; export const IPricesApi = Symbol('IPricesApi'); export interface IPricesApi { getNativeCoinPrice(args: { - chainId: string; + chain: Chain; fiatCode: string; }): Promise; getTokenPrices(args: { - chainId: string; + chain: Chain; tokenAddresses: string[]; fiatCode: string; }): Promise; diff --git a/src/datasources/balances-api/safe-balances-api.service.ts b/src/datasources/balances-api/safe-balances-api.service.ts index f0a46fc85d..a528335d97 100644 --- a/src/datasources/balances-api/safe-balances-api.service.ts +++ b/src/datasources/balances-api/safe-balances-api.service.ts @@ -10,6 +10,7 @@ import { Page } from '@/domain/entities/page.entity'; import { IBalancesApi } from '@/domain/interfaces/balances-api.interface'; import { IPricesApi } from '@/datasources/balances-api/prices-api.interface'; import { Injectable } from '@nestjs/common'; +import { Chain } from '@/domain/chains/entities/chain.entity'; @Injectable() export class SafeBalancesApi implements IBalancesApi { @@ -39,6 +40,7 @@ export class SafeBalancesApi implements IBalancesApi { async getBalances(args: { safeAddress: string; fiatCode: string; + chain: Chain; trusted?: boolean; excludeSpam?: boolean; }): Promise { @@ -61,7 +63,7 @@ export class SafeBalancesApi implements IBalancesApi { expireTimeSeconds: this.defaultExpirationTimeInSeconds, }); - return this._mapBalances(data, args.fiatCode); + return this._mapBalances(data, args.fiatCode, args.chain); } catch (error) { throw this.httpErrorFactory.from(error); } @@ -122,13 +124,14 @@ export class SafeBalancesApi implements IBalancesApi { private async _mapBalances( balances: Balance[], fiatCode: string, + chain: Chain, ): Promise { const tokenAddresses = balances .map((balance) => balance.tokenAddress) .filter((address): address is `0x${string}` => address !== null); const assetPrices = await this.coingeckoApi.getTokenPrices({ - chainId: this.chainId, + chain, fiatCode, tokenAddresses, }); @@ -145,7 +148,7 @@ export class SafeBalancesApi implements IBalancesApi { let price: number | null; if (tokenAddress === null) { price = await this.coingeckoApi.getNativeCoinPrice({ - chainId: this.chainId, + chain, fiatCode, }); } else { diff --git a/src/datasources/cache/redis.cache.service.ts b/src/datasources/cache/redis.cache.service.ts index dbf14d6d8e..ef74c58a99 100644 --- a/src/datasources/cache/redis.cache.service.ts +++ b/src/datasources/cache/redis.cache.service.ts @@ -108,10 +108,9 @@ export class RedisCacheService */ async onModuleDestroy(): Promise { this.loggingService.info('Closing Redis connection'); - const forceQuitTimeout = setTimeout( - this.forceQuit.bind(this), - this.quitTimeoutInSeconds * 1000, - ); + const forceQuitTimeout = setTimeout(() => { + this.forceQuit.bind(this); + }, this.quitTimeoutInSeconds * 1000); await this.client.quit(); clearTimeout(forceQuitTimeout); this.loggingService.info('Redis connection closed'); diff --git a/src/datasources/locking-api/locking-api.service.spec.ts b/src/datasources/locking-api/locking-api.service.spec.ts index 2c26bed3fd..dc00ca2ab5 100644 --- a/src/datasources/locking-api/locking-api.service.spec.ts +++ b/src/datasources/locking-api/locking-api.service.spec.ts @@ -13,6 +13,8 @@ import { } from '@/domain/locking/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 { holderBuilder } from '@/domain/locking/entities/__tests__/holder.builder'; const networkService = { get: jest.fn(), @@ -43,6 +45,116 @@ describe('LockingApi', () => { ); }); + describe('getCampaignById', () => { + it('should get a campaign by campaignId', async () => { + const campaign = campaignBuilder().build(); + + mockNetworkService.get.mockResolvedValueOnce({ + data: campaign, + status: 200, + }); + + const result = await service.getCampaignById(campaign.campaignId); + + expect(result).toEqual(campaign); + expect(mockNetworkService.get).toHaveBeenCalledWith({ + url: `${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}`, + }); + }); + + it('should forward error', async () => { + const status = faker.internet.httpStatusCode({ types: ['serverError'] }); + const campaign = campaignBuilder().build(); + const error = new NetworkResponseError( + new URL(`${lockingBaseUri}/api/v1/campaigns/${campaign.campaignId}`), + { + status, + } as Response, + { + message: 'Unexpected error', + }, + ); + mockNetworkService.get.mockRejectedValueOnce(error); + + await expect( + service.getCampaignById(campaign.campaignId), + ).rejects.toThrow(new DataSourceError('Unexpected error', status)); + + expect(mockNetworkService.get).toHaveBeenCalledTimes(1); + }); + }); + + describe('getCampaigns', () => { + it('should get campaigns', async () => { + const campaignsPage = pageBuilder() + .with('results', [campaignBuilder().build(), campaignBuilder().build()]) + .build(); + + mockNetworkService.get.mockResolvedValueOnce({ + data: campaignsPage, + status: 200, + }); + + const result = await service.getCampaigns({}); + + expect(result).toEqual(campaignsPage); + expect(mockNetworkService.get).toHaveBeenCalledWith({ + url: `${lockingBaseUri}/api/v1/campaigns`, + networkRequest: { + params: { + limit: undefined, + offset: undefined, + }, + }, + }); + }); + + it('should forward pagination queries', async () => { + const limit = faker.number.int(); + const offset = faker.number.int(); + const campaignsPage = pageBuilder() + .with('results', [campaignBuilder().build(), campaignBuilder().build()]) + .build(); + + mockNetworkService.get.mockResolvedValueOnce({ + data: campaignsPage, + status: 200, + }); + + await service.getCampaigns({ limit, offset }); + + expect(mockNetworkService.get).toHaveBeenCalledWith({ + url: `${lockingBaseUri}/api/v1/campaigns`, + networkRequest: { + params: { + limit, + offset, + }, + }, + }); + }); + + it('should forward error', async () => { + const status = faker.internet.httpStatusCode({ types: ['serverError'] }); + const error = new NetworkResponseError( + new URL(`${lockingBaseUri}/api/v1/campaigns`), + { + status, + } as Response, + { + message: 'Unexpected error', + }, + ); + mockNetworkService.get.mockRejectedValueOnce(error); + + await expect(service.getCampaigns({})).rejects.toThrow( + new DataSourceError('Unexpected error', status), + ); + + expect(mockNetworkService.get).toHaveBeenCalledTimes(1); + }); + }); + describe('getRank', () => { it('should get rank', async () => { const safeAddress = getAddress(faker.finance.ethereumAddress()); @@ -153,6 +265,78 @@ describe('LockingApi', () => { }); }); + describe('getLeaderboardV2', () => { + it('should get leaderboard v2', async () => { + const campaignId = faker.string.uuid(); + const leaderboardV2Page = pageBuilder() + .with('results', [holderBuilder().build(), holderBuilder().build()]) + .build(); + mockNetworkService.get.mockResolvedValueOnce({ + data: leaderboardV2Page, + status: 200, + }); + + const result = await service.getLeaderboardV2({ campaignId }); + + expect(result).toEqual(leaderboardV2Page); + expect(mockNetworkService.get).toHaveBeenCalledWith({ + url: `${lockingBaseUri}/api/v2/leaderboard/${campaignId}`, + networkRequest: { + params: { + limit: undefined, + offset: undefined, + }, + }, + }); + }); + + it('should forward pagination queries', async () => { + const limit = faker.number.int(); + const offset = faker.number.int(); + const campaignId = faker.string.uuid(); + const leaderboardV2Page = pageBuilder() + .with('results', [holderBuilder().build(), holderBuilder().build()]) + .build(); + mockNetworkService.get.mockResolvedValueOnce({ + data: leaderboardV2Page, + status: 200, + }); + + await service.getLeaderboardV2({ campaignId, limit, offset }); + + expect(mockNetworkService.get).toHaveBeenCalledWith({ + url: `${lockingBaseUri}/api/v2/leaderboard/${campaignId}`, + networkRequest: { + params: { + limit, + offset, + }, + }, + }); + }); + + it('should forward error', async () => { + const status = faker.internet.httpStatusCode({ types: ['serverError'] }); + const campaignId = faker.string.uuid(); + const error = new NetworkResponseError( + new URL(`${lockingBaseUri}/api/v2/leaderboard/${campaignId}`), + { + status, + } as Response, + { + message: 'Unexpected error', + }, + ); + mockNetworkService.get.mockRejectedValueOnce(error); + + await expect(service.getLeaderboardV2({ campaignId })).rejects.toThrow( + new DataSourceError('Unexpected error', status), + ); + + expect(mockNetworkService.get).toHaveBeenCalledTimes(1); + }); + }); + describe('getLockingHistory', () => { it('should get locking history', 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 94b383f553..f6d21d0c75 100644 --- a/src/datasources/locking-api/locking-api.service.ts +++ b/src/datasources/locking-api/locking-api.service.ts @@ -6,6 +6,8 @@ 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 { Holder } from '@/domain/locking/entities/holder.entity'; import { LockingEvent } from '@/domain/locking/entities/locking-event.entity'; import { Rank } from '@/domain/locking/entities/rank.entity'; import { Inject } from '@nestjs/common'; @@ -24,6 +26,37 @@ export class LockingApi implements ILockingApi { this.configurationService.getOrThrow('locking.baseUri'); } + async getCampaignById(campaignId: string): Promise { + try { + const url = `${this.baseUri}/api/v1/campaigns/${campaignId}`; + const { data } = await this.networkService.get({ url }); + return data; + } catch (error) { + throw this.httpErrorFactory.from(error); + } + } + + async getCampaigns(args: { + limit?: number; + offset?: number; + }): Promise> { + try { + const url = `${this.baseUri}/api/v1/campaigns`; + const { data } = await this.networkService.get>({ + url, + networkRequest: { + params: { + limit: args.limit, + offset: args.offset, + }, + }, + }); + return data; + } catch (error) { + throw this.httpErrorFactory.from(error); + } + } + async getRank(safeAddress: `0x${string}`): Promise { try { const url = `${this.baseUri}/api/v1/leaderboard/${safeAddress}`; @@ -55,6 +88,28 @@ export class LockingApi implements ILockingApi { } } + async getLeaderboardV2(args: { + campaignId: string; + limit?: number; + offset?: number; + }): Promise> { + try { + const url = `${this.baseUri}/api/v2/leaderboard/${args.campaignId}`; + const { data } = await this.networkService.get>({ + url, + networkRequest: { + params: { + limit: args.limit, + offset: args.offset, + }, + }, + }); + return data; + } catch (error) { + throw this.httpErrorFactory.from(error); + } + } + async getLockingHistory(args: { safeAddress: `0x${string}`; limit?: number; diff --git a/src/datasources/queues/queues-api.service.ts b/src/datasources/queues/queues-api.service.ts index 3b3d265790..ae387e2a04 100644 --- a/src/datasources/queues/queues-api.service.ts +++ b/src/datasources/queues/queues-api.service.ts @@ -36,14 +36,11 @@ export class QueueApiService implements IQueuesApiService, IQueueReadiness { `Cannot subscribe to queue: ${queueName}. AMQP consumer is disabled`, ); } - await this.consumer.channel.consume( - queueName, - async (msg: ConsumeMessage) => { - await fn(msg); - // Note: each message is explicitly acknowledged at this point, only after a success callback execution. - this.consumer.channel.ack(msg); - }, - ); + await this.consumer.channel.consume(queueName, (msg: ConsumeMessage) => { + // Note: each message is explicitly acknowledged at this point, regardless the callback execution result. + // The callback is expected to handle any errors and/or retries. Messages are not re-enqueued on error. + void fn(msg).finally(() => this.consumer.channel.ack(msg)); + }); this.loggingService.info(`Subscribed to queue: ${queueName}`); } } diff --git a/src/datasources/transaction-api/transaction-api.service.spec.ts b/src/datasources/transaction-api/transaction-api.service.spec.ts index 1f7b97ac3e..7b889e5666 100644 --- a/src/datasources/transaction-api/transaction-api.service.spec.ts +++ b/src/datasources/transaction-api/transaction-api.service.spec.ts @@ -1487,7 +1487,7 @@ describe('TransactionApi', () => { it('should delete a transaction', async () => { const safeTxHash = faker.string.hexadecimal(); const signature = faker.string.hexadecimal(); - const deleteTransactionUrl = `${baseUrl}/api/v1/transactions/${safeTxHash}`; + const deleteTransactionUrl = `${baseUrl}/api/v1/multisig-transactions/${safeTxHash}`; networkService.delete.mockResolvedValueOnce({ status: 200, data: {}, @@ -1514,7 +1514,7 @@ describe('TransactionApi', () => { ])(`should forward a %s error`, async (_, error) => { const safeTxHash = faker.string.hexadecimal(); const signature = faker.string.hexadecimal(); - const deleteTransactionUrl = `${baseUrl}/api/v1/transactions/${safeTxHash}`; + const deleteTransactionUrl = `${baseUrl}/api/v1/multisig-transactions/${safeTxHash}`; 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 c5ca71d656..21767eb2c1 100644 --- a/src/datasources/transaction-api/transaction-api.service.ts +++ b/src/datasources/transaction-api/transaction-api.service.ts @@ -619,7 +619,7 @@ export class TransactionApi implements ITransactionApi { signature: string; }): Promise { try { - const url = `${this.baseUrl}/api/v1/transactions/${args.safeTxHash}`; + const url = `${this.baseUrl}/api/v1/multisig-transactions/${args.safeTxHash}`; await this.networkService.delete({ url, data: { diff --git a/src/domain/account/account.repository.interface.ts b/src/domain/account/account.repository.interface.ts index ec414ddef3..15f78a4171 100644 --- a/src/domain/account/account.repository.interface.ts +++ b/src/domain/account/account.repository.interface.ts @@ -92,6 +92,7 @@ export interface IAccountRepository { * @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 */ @@ -99,6 +100,7 @@ export interface IAccountRepository { chainId: string; safeAddress: string; emailAddress: string; - signer: string; + signer: `0x${string}`; + authPayload: AuthPayload; }): Promise; } diff --git a/src/domain/account/account.repository.ts b/src/domain/account/account.repository.ts index 4899f355e2..9e074696dc 100644 --- a/src/domain/account/account.repository.ts +++ b/src/domain/account/account.repository.ts @@ -325,10 +325,19 @@ export class AccountRepository implements IAccountRepository { chainId: string; safeAddress: string; emailAddress: string; - signer: 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) + ) { + throw new UnauthorizedException(); + } + const account = await this.accountDataSource.getAccount({ chainId: args.chainId, safeAddress, diff --git a/src/domain/alerts/contracts/__tests__/encoders/delay-modifier-encoder.builder.ts b/src/domain/alerts/contracts/__tests__/encoders/delay-modifier-encoder.builder.ts index dbd8048746..73f16b31b2 100644 --- a/src/domain/alerts/contracts/__tests__/encoders/delay-modifier-encoder.builder.ts +++ b/src/domain/alerts/contracts/__tests__/encoders/delay-modifier-encoder.builder.ts @@ -44,15 +44,15 @@ class TransactionAddedEventBuilder const data = encodeAbiParameters( parseAbiParameters(TransactionAddedEventBuilder.NON_INDEXED_PARAMS), - [args.to!, args.value!, args.data!, args.operation!], + [args.to, args.value, args.data, args.operation], ); const topics = encodeEventTopics({ abi, eventName: 'TransactionAdded', args: { - queueNonce: args.queueNonce!, - txHash: args.txHash!, + queueNonce: args.queueNonce, + txHash: args.txHash, }, }) as TransactionAddedEvent['topics']; diff --git a/src/domain/alerts/entities/alerts-deletion.entity.ts b/src/domain/alerts/entities/alerts-deletion.entity.ts index 45138f79cf..4e24b7884e 100644 --- a/src/domain/alerts/entities/alerts-deletion.entity.ts +++ b/src/domain/alerts/entities/alerts-deletion.entity.ts @@ -1,4 +1,4 @@ export type AlertsDeletion = { chainId: string; - address: string; + address: `0x${string}`; }; diff --git a/src/domain/alerts/entities/alerts-registration.entity.ts b/src/domain/alerts/entities/alerts-registration.entity.ts index c8ad97e0a1..d211518098 100644 --- a/src/domain/alerts/entities/alerts-registration.entity.ts +++ b/src/domain/alerts/entities/alerts-registration.entity.ts @@ -1,5 +1,6 @@ export type AlertsRegistration = { - address: string; + address: `0x${string}`; chainId: string; + // {chainId}:{safeAddress}:{moduleAddress} displayName?: `${string}:${string}:${string}`; }; diff --git a/src/domain/balances/balances.repository.interface.ts b/src/domain/balances/balances.repository.interface.ts index 2ea8f1fea9..471e526c42 100644 --- a/src/domain/balances/balances.repository.interface.ts +++ b/src/domain/balances/balances.repository.interface.ts @@ -2,6 +2,7 @@ import { Balance } from '@/domain/balances/entities/balance.entity'; import { Module } from '@nestjs/common'; import { BalancesRepository } from '@/domain/balances/balances.repository'; import { BalancesApiModule } from '@/datasources/balances-api/balances-api.module'; +import { Chain } from '@/domain/chains/entities/chain.entity'; export const IBalancesRepository = Symbol('IBalancesRepository'); @@ -11,7 +12,7 @@ export interface IBalancesRepository { * on {@link chainId} */ getBalances(args: { - chainId: string; + chain: Chain; safeAddress: string; fiatCode: string; trusted?: boolean; diff --git a/src/domain/balances/balances.repository.ts b/src/domain/balances/balances.repository.ts index 668867e8fc..4d2cd1e017 100644 --- a/src/domain/balances/balances.repository.ts +++ b/src/domain/balances/balances.repository.ts @@ -3,6 +3,7 @@ import { IBalancesRepository } from '@/domain/balances/balances.repository.inter import { Balance } from '@/domain/balances/entities/balance.entity'; import { BalanceSchema } from '@/domain/balances/entities/balance.entity'; import { IBalancesApiManager } from '@/domain/interfaces/balances-api.manager.interface'; +import { Chain } from '@/domain/chains/entities/chain.entity'; @Injectable() export class BalancesRepository implements IBalancesRepository { @@ -12,13 +13,15 @@ export class BalancesRepository implements IBalancesRepository { ) {} async getBalances(args: { - chainId: string; + chain: Chain; safeAddress: string; fiatCode: string; trusted?: boolean; excludeSpam?: boolean; }): Promise { - const api = await this.balancesApiManager.getBalancesApi(args.chainId); + const api = await this.balancesApiManager.getBalancesApi( + args.chain.chainId, + ); const balances = await api.getBalances(args); return balances.map((balance) => BalanceSchema.parse(balance)); } diff --git a/src/domain/chains/entities/__tests__/chain.builder.ts b/src/domain/chains/entities/__tests__/chain.builder.ts index edfd6d4786..26c2ab59a0 100644 --- a/src/domain/chains/entities/__tests__/chain.builder.ts +++ b/src/domain/chains/entities/__tests__/chain.builder.ts @@ -8,6 +8,7 @@ import { nativeCurrencyBuilder } from '@/domain/chains/entities/__tests__/native import { rpcUriBuilder } from '@/domain/chains/entities/__tests__/rpc-uri.builder'; 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'; export function chainBuilder(): IBuilder { return new Builder() @@ -23,6 +24,7 @@ export function chainBuilder(): IBuilder { .with('publicRpcUri', rpcUriBuilder().build()) .with('blockExplorerUriTemplate', blockExplorerUriTemplateBuilder().build()) .with('nativeCurrency', nativeCurrencyBuilder().build()) + .with('pricesProvider', pricesProviderBuilder().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__/prices-provider.builder.ts b/src/domain/chains/entities/__tests__/prices-provider.builder.ts new file mode 100644 index 0000000000..ebac95fb78 --- /dev/null +++ b/src/domain/chains/entities/__tests__/prices-provider.builder.ts @@ -0,0 +1,9 @@ +import { Builder, IBuilder } from '@/__tests__/builder'; +import { PricesProvider } from '@/domain/chains/entities/prices-provider.entity'; +import { faker } from '@faker-js/faker'; + +export function pricesProviderBuilder(): IBuilder { + return new Builder() + .with('chainName', faker.company.name()) + .with('nativeCoin', faker.finance.currencyName()); +} diff --git a/src/domain/chains/entities/prices-provider.entity.ts b/src/domain/chains/entities/prices-provider.entity.ts new file mode 100644 index 0000000000..ddce9fa206 --- /dev/null +++ b/src/domain/chains/entities/prices-provider.entity.ts @@ -0,0 +1,4 @@ +import { PricesProviderSchema } from '@/domain/chains/entities/schemas/chain.schema'; +import { z } from 'zod'; + +export type PricesProvider = 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 6d91877ee0..eeda718f77 100644 --- a/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts +++ b/src/domain/chains/entities/schemas/__tests__/chain.schema.spec.ts @@ -3,6 +3,7 @@ import { gasPriceFixedEIP1559Builder } from '@/domain/chains/entities/__tests__/ import { gasPriceFixedBuilder } from '@/domain/chains/entities/__tests__/gas-price-fixed.builder'; import { gasPriceOracleBuilder } from '@/domain/chains/entities/__tests__/gas-price-oracle.builder'; import { nativeCurrencyBuilder } from '@/domain/chains/entities/__tests__/native.currency.builder'; +import { pricesProviderBuilder } from '@/domain/chains/entities/__tests__/prices-provider.builder'; import { rpcUriBuilder } from '@/domain/chains/entities/__tests__/rpc-uri.builder'; import { themeBuilder } from '@/domain/chains/entities/__tests__/theme.builder'; import { @@ -12,6 +13,7 @@ import { GasPriceOracleSchema, GasPriceSchema, NativeCurrencySchema, + PricesProviderSchema, RpcUriSchema, ThemeSchema, } from '@/domain/chains/entities/schemas/chain.schema'; @@ -302,6 +304,56 @@ describe('Chain schemas', () => { }); }); + describe('PricesProviderSchema', () => { + it('should validate a valid prices provider', () => { + const pricesProvider = pricesProviderBuilder().build(); + + const result = PricesProviderSchema.safeParse(pricesProvider); + + expect(result.success).toBe(true); + }); + + it('should not validate an invalid prices provider chainName', () => { + const pricesProvider = { + chainName: 1, + }; + + const result = PricesProviderSchema.safeParse(pricesProvider); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'number', + path: ['chainName'], + message: 'Expected string, received number', + }, + ]), + ); + }); + + it('should not validate an invalid prices provider nativeCoin', () => { + const pricesProvider = { + nativeCoin: 1, + }; + + const result = PricesProviderSchema.safeParse(pricesProvider); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'number', + path: ['nativeCoin'], + message: 'Expected string, received number', + }, + ]), + ); + }); + }); + describe('ChainSchema', () => { it('should validate a valid chain', () => { const chain = chainBuilder().build(); @@ -311,6 +363,15 @@ 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 2095e34c4a..9855e2505e 100644 --- a/src/domain/chains/entities/schemas/chain.schema.ts +++ b/src/domain/chains/entities/schemas/chain.schema.ts @@ -54,6 +54,11 @@ export const GasPriceSchema = z.array( ]), ); +export const PricesProviderSchema = z.object({ + chainName: z.string().nullish().default(null), + nativeCoin: z.string().nullish().default(null), +}); + export const ChainSchema = z.object({ chainId: z.string(), chainName: z.string(), @@ -67,6 +72,8 @@ export const ChainSchema = z.object({ publicRpcUri: RpcUriSchema, blockExplorerUriTemplate: BlockExplorerUriTemplateSchema, nativeCurrency: NativeCurrencySchema, + // TODO: remove optionality when fully migrated. + pricesProvider: PricesProviderSchema.optional(), transactionService: z.string().url(), vpcTransactionService: z.string().url(), theme: ThemeSchema, diff --git a/src/domain/interfaces/balances-api.interface.ts b/src/domain/interfaces/balances-api.interface.ts index 5678a87ab0..529a8cc572 100644 --- a/src/domain/interfaces/balances-api.interface.ts +++ b/src/domain/interfaces/balances-api.interface.ts @@ -1,4 +1,5 @@ import { Balance } from '@/domain/balances/entities/balance.entity'; +import { Chain } from '@/domain/chains/entities/chain.entity'; import { Collectible } from '@/domain/collectibles/entities/collectible.entity'; import { Page } from '@/domain/entities/page.entity'; @@ -6,7 +7,7 @@ export interface IBalancesApi { getBalances(args: { safeAddress: string; fiatCode: string; - chainId?: string; + chain?: Chain; trusted?: boolean; excludeSpam?: boolean; }): Promise; diff --git a/src/domain/interfaces/locking-api.interface.ts b/src/domain/interfaces/locking-api.interface.ts index d3c136524b..f790d2cf2c 100644 --- a/src/domain/interfaces/locking-api.interface.ts +++ b/src/domain/interfaces/locking-api.interface.ts @@ -1,10 +1,18 @@ import { Page } from '@/domain/entities/page.entity'; +import { Campaign } from '@/domain/locking/entities/campaign.entity'; import { LockingEvent } from '@/domain/locking/entities/locking-event.entity'; import { Rank } from '@/domain/locking/entities/rank.entity'; export const ILockingApi = Symbol('ILockingApi'); export interface ILockingApi { + getCampaignById(campaignId: string): Promise; + + getCampaigns(args: { + limit?: number; + offset?: number; + }): Promise>; + getRank(safeAddress: `0x${string}`): Promise; getLeaderboard(args: { diff --git a/src/domain/interfaces/transaction-api.interface.ts b/src/domain/interfaces/transaction-api.interface.ts index 36587230f4..017965bf72 100644 --- a/src/domain/interfaces/transaction-api.interface.ts +++ b/src/domain/interfaces/transaction-api.interface.ts @@ -212,7 +212,7 @@ export interface ITransactionApi { postMessage(args: { safeAddress: string; - message: string | unknown; + message: unknown; safeAppId: number | null; signature: string; }): Promise; diff --git a/src/domain/locking/entities/__tests__/activity-metadata.builder.ts b/src/domain/locking/entities/__tests__/activity-metadata.builder.ts new file mode 100644 index 0000000000..1b500f0e4d --- /dev/null +++ b/src/domain/locking/entities/__tests__/activity-metadata.builder.ts @@ -0,0 +1,11 @@ +import { Builder, IBuilder } from '@/__tests__/builder'; +import { ActivityMetadata } from '@/domain/locking/entities/activity-metadata.entity'; +import { faker } from '@faker-js/faker'; + +export function activityMetadataBuilder(): IBuilder { + return new Builder() + .with('campaignId', faker.string.uuid()) + .with('name', faker.word.words()) + .with('description', faker.lorem.sentence()) + .with('maxPoints', faker.string.numeric()); +} diff --git a/src/domain/locking/entities/__tests__/campaign.builder.ts b/src/domain/locking/entities/__tests__/campaign.builder.ts new file mode 100644 index 0000000000..454c1471a1 --- /dev/null +++ b/src/domain/locking/entities/__tests__/campaign.builder.ts @@ -0,0 +1,20 @@ +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 { faker } from '@faker-js/faker'; + +export function campaignBuilder(): IBuilder { + return new Builder() + .with('campaignId', faker.string.uuid()) + .with('name', faker.word.words()) + .with('description', faker.lorem.sentence()) + .with('startDate', faker.date.recent()) + .with('endDate', faker.date.future()) + .with('lastUpdated', faker.date.recent()) + .with( + 'activitiesMetadata', + Array.from({ length: faker.number.int({ min: 0, max: 5 }) }, () => + activityMetadataBuilder().build(), + ), + ); +} diff --git a/src/domain/locking/entities/__tests__/holder.builder.ts b/src/domain/locking/entities/__tests__/holder.builder.ts new file mode 100644 index 0000000000..c991559942 --- /dev/null +++ b/src/domain/locking/entities/__tests__/holder.builder.ts @@ -0,0 +1,13 @@ +import { Builder, IBuilder } from '@/__tests__/builder'; +import { Holder } from '@/domain/locking/entities/holder.entity'; +import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; + +export function holderBuilder(): 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()); +} diff --git a/src/domain/locking/entities/activity-metadata.entity.ts b/src/domain/locking/entities/activity-metadata.entity.ts new file mode 100644 index 0000000000..84f80be0ef --- /dev/null +++ b/src/domain/locking/entities/activity-metadata.entity.ts @@ -0,0 +1,11 @@ +import { NumericStringSchema } from '@/validation/entities/schemas/numeric-string.schema'; +import { z } from 'zod'; + +export type ActivityMetadata = z.infer; + +export const ActivityMetadataSchema = z.object({ + campaignId: z.string(), + name: z.string(), + description: z.string(), + maxPoints: NumericStringSchema, +}); diff --git a/src/domain/locking/entities/campaign.entity.ts b/src/domain/locking/entities/campaign.entity.ts new file mode 100644 index 0000000000..5132f31644 --- /dev/null +++ b/src/domain/locking/entities/campaign.entity.ts @@ -0,0 +1,17 @@ +import { buildPageSchema } from '@/domain/entities/schemas/page.schema.factory'; +import { ActivityMetadataSchema } from '@/domain/locking/entities/activity-metadata.entity'; +import { z } from 'zod'; + +export type Campaign = z.infer; + +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), +}); + +export const CampaignPageSchema = buildPageSchema(CampaignSchema); diff --git a/src/domain/locking/entities/holder.entity.ts b/src/domain/locking/entities/holder.entity.ts new file mode 100644 index 0000000000..a34084b745 --- /dev/null +++ b/src/domain/locking/entities/holder.entity.ts @@ -0,0 +1,16 @@ +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 HolderSchema = z.object({ + holder: AddressSchema, + position: z.number(), + boost: NumericStringSchema, + points: NumericStringSchema, + boostedPoints: NumericStringSchema, +}); + +export const HolderPageSchema = buildPageSchema(HolderSchema); + +export type Holder = z.infer; diff --git a/src/domain/locking/entities/schemas/__tests__/activity-metadata.schema.spec.ts b/src/domain/locking/entities/schemas/__tests__/activity-metadata.schema.spec.ts new file mode 100644 index 0000000000..0b1e3272e7 --- /dev/null +++ b/src/domain/locking/entities/schemas/__tests__/activity-metadata.schema.spec.ts @@ -0,0 +1,71 @@ +import { activityMetadataBuilder } from '@/domain/locking/entities/__tests__/activity-metadata.builder'; +import { ActivityMetadataSchema } from '@/domain/locking/entities/activity-metadata.entity'; +import { faker } from '@faker-js/faker'; +import { ZodError } from 'zod'; + +describe('ActivityMetadataSchema', () => { + it('should validate a valid activity metadata', () => { + const activityMetadata = activityMetadataBuilder().build(); + + const result = ActivityMetadataSchema.safeParse(activityMetadata); + + 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' }; + + const result = ActivityMetadataSchema.safeParse(activityMetadata); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['campaignId'], + message: 'Required', + }, + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['name'], + message: 'Required', + }, + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['description'], + message: 'Required', + }, + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['maxPoints'], + message: 'Required', + }, + ]), + ); + }); +}); diff --git a/src/domain/locking/entities/schemas/__tests__/campaign.schema.spec.ts b/src/domain/locking/entities/schemas/__tests__/campaign.schema.spec.ts new file mode 100644 index 0000000000..5c3024613c --- /dev/null +++ b/src/domain/locking/entities/schemas/__tests__/campaign.schema.spec.ts @@ -0,0 +1,73 @@ +import { campaignBuilder } from '@/domain/locking/entities/__tests__/campaign.builder'; +import { CampaignSchema } from '@/domain/locking/entities/campaign.entity'; +import { ZodError } from 'zod'; + +describe('CampaignSchema', () => { + it('should validate a valid campaign', () => { + const campaign = campaignBuilder().build(); + + const result = CampaignSchema.safeParse(campaign); + + expect(result.success).toBe(true); + }); + + it.each(['startDate' as const, 'endDate' as const, 'lastUpdated' as const])( + `should coerce %s to a date`, + (field) => { + const campaign = campaignBuilder().build(); + + const result = CampaignSchema.safeParse(campaign); + + expect(result.success && result.data[field]).toStrictEqual( + new Date(campaign[field]), + ); + }, + ); + + it('should not validate an invalid campaign', () => { + const campaign = { invalid: 'campaign' }; + + const result = CampaignSchema.safeParse(campaign); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['campaignId'], + message: 'Required', + }, + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['name'], + message: 'Required', + }, + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['description'], + message: 'Required', + }, + { + code: 'invalid_date', + path: ['startDate'], + message: 'Invalid date', + }, + { + code: 'invalid_date', + path: ['endDate'], + message: 'Invalid date', + }, + { + code: 'invalid_date', + path: ['lastUpdated'], + message: 'Invalid date', + }, + ]), + ); + }); +}); diff --git a/src/domain/locking/entities/schemas/__tests__/holder.schema.spec.ts b/src/domain/locking/entities/schemas/__tests__/holder.schema.spec.ts new file mode 100644 index 0000000000..3364e1e2e1 --- /dev/null +++ b/src/domain/locking/entities/schemas/__tests__/holder.schema.spec.ts @@ -0,0 +1,76 @@ +import { holderBuilder } from '@/domain/locking/entities/__tests__/holder.builder'; +import { HolderSchema } from '@/domain/locking/entities/holder.entity'; +import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; +import { ZodError } from 'zod'; + +describe('HolderSchema', () => { + it('should validate a valid holder', () => { + const holder = holderBuilder().build(); + + const result = HolderSchema.safeParse(holder); + + expect(result.success).toBe(true); + }); + + it('should checksum the holder address', () => { + const nonChecksummedAddress = faker.finance + .ethereumAddress() + .toLowerCase() as `0x${string}`; + const holder = holderBuilder() + .with('holder', nonChecksummedAddress) + .build(); + + const result = HolderSchema.safeParse(holder); + + expect(result.success && result.data.holder).toBe( + getAddress(nonChecksummedAddress), + ); + }); + + it('should not validate an invalid holder', () => { + const holder = { invalid: 'holder' }; + + const result = HolderSchema.safeParse(holder); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['holder'], + message: 'Required', + }, + { + code: 'invalid_type', + expected: 'number', + received: 'undefined', + path: ['position'], + message: 'Required', + }, + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['boost'], + message: 'Required', + }, + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['points'], + message: 'Required', + }, + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['boostedPoints'], + message: 'Required', + }, + ]), + ); + }); +}); diff --git a/src/domain/locking/entities/schemas/campaign.schema.ts b/src/domain/locking/entities/schemas/campaign.schema.ts new file mode 100644 index 0000000000..8b0d81db3d --- /dev/null +++ b/src/domain/locking/entities/schemas/campaign.schema.ts @@ -0,0 +1,12 @@ +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/domain/locking/locking.repository.interface.ts b/src/domain/locking/locking.repository.interface.ts index 5a9d22e94c..88d1613a16 100644 --- a/src/domain/locking/locking.repository.interface.ts +++ b/src/domain/locking/locking.repository.interface.ts @@ -1,10 +1,18 @@ import { Page } from '@/domain/entities/page.entity'; +import { Campaign } from '@/domain/locking/entities/campaign.entity'; import { LockingEvent } from '@/domain/locking/entities/locking-event.entity'; import { Rank } from '@/domain/locking/entities/rank.entity'; export const ILockingRepository = Symbol('ILockingRepository'); export interface ILockingRepository { + getCampaignById(campaignId: string): Promise; + + getCampaigns(args: { + limit?: number; + offset?: number; + }): Promise>; + getRank(safeAddress: `0x${string}`): Promise; getLeaderboard(args: { diff --git a/src/domain/locking/locking.repository.ts b/src/domain/locking/locking.repository.ts index a7520d3571..67531b3c49 100644 --- a/src/domain/locking/locking.repository.ts +++ b/src/domain/locking/locking.repository.ts @@ -1,5 +1,9 @@ import { Page } from '@/domain/entities/page.entity'; import { ILockingApi } from '@/domain/interfaces/locking-api.interface'; +import { + Campaign, + CampaignPageSchema, +} from '@/domain/locking/entities/campaign.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'; @@ -17,6 +21,18 @@ export class LockingRepository implements ILockingRepository { private readonly lockingApi: ILockingApi, ) {} + async getCampaignById(campaignId: string): Promise { + return this.lockingApi.getCampaignById(campaignId); + } + + async getCampaigns(args: { + limit?: number | undefined; + offset?: number | undefined; + }): Promise> { + const page = await this.lockingApi.getCampaigns(args); + return CampaignPageSchema.parse(page); + } + async getRank(safeAddress: `0x${string}`): Promise { const rank = await this.lockingApi.getRank(safeAddress); return RankSchema.parse(rank); diff --git a/src/domain/relay/limit-addresses.mapper.spec.ts b/src/domain/relay/limit-addresses.mapper.spec.ts index 5673ca0559..fdbb197212 100644 --- a/src/domain/relay/limit-addresses.mapper.spec.ts +++ b/src/domain/relay/limit-addresses.mapper.spec.ts @@ -35,7 +35,7 @@ import { getSafeL2SingletonDeployment, getSafeSingletonDeployment, } from '@safe-global/safe-deployments'; -import { Hex, getAddress } from 'viem'; +import { getAddress } from 'viem'; import configuration from '@/config/entities/configuration'; import { getDeploymentVersionsByChainIds } from '@/__tests__/deployments.helper'; @@ -97,7 +97,7 @@ describe('LimitAddressesMapper', () => { const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('value', faker.number.bigInt()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -116,7 +116,7 @@ describe('LimitAddressesMapper', () => { const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('data', erc20TransferEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -135,7 +135,7 @@ describe('LimitAddressesMapper', () => { const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('data', erc20TransferFromEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -154,7 +154,7 @@ describe('LimitAddressesMapper', () => { const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('data', erc20ApproveEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -174,7 +174,7 @@ describe('LimitAddressesMapper', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('data', '0x') - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -194,7 +194,7 @@ describe('LimitAddressesMapper', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('data', addOwnerWithThresholdEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -214,7 +214,7 @@ describe('LimitAddressesMapper', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('data', changeThresholdEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -234,7 +234,7 @@ describe('LimitAddressesMapper', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('data', enableModuleEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -254,7 +254,7 @@ describe('LimitAddressesMapper', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('data', disableModuleEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -274,7 +274,7 @@ describe('LimitAddressesMapper', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('data', removeOwnerEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -294,7 +294,7 @@ describe('LimitAddressesMapper', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('data', setFallbackHandlerEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -314,7 +314,7 @@ describe('LimitAddressesMapper', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('data', setGuardEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -334,7 +334,7 @@ describe('LimitAddressesMapper', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('data', swapOwnerEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -353,7 +353,7 @@ describe('LimitAddressesMapper', () => { const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('data', execTransactionEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -373,7 +373,7 @@ describe('LimitAddressesMapper', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('value', faker.number.bigInt()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockRejectedValue(true); @@ -398,7 +398,7 @@ describe('LimitAddressesMapper', () => { 'data', erc20TransferEncoder().with('to', safeAddress).encode(), ) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -425,7 +425,7 @@ describe('LimitAddressesMapper', () => { .with('recipient', safeAddress) .encode(), ) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -453,7 +453,7 @@ describe('LimitAddressesMapper', () => { .with('recipient', recipient) .encode(), ) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -476,7 +476,7 @@ describe('LimitAddressesMapper', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('data', erc20ApproveEncoder().encode()) - .encode() as Hex; + .encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -498,7 +498,7 @@ describe('LimitAddressesMapper', () => { const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('to', safeAddress) - .encode() as Hex; + .encode(); // Unofficial mastercopy mockSafeRepository.getSafe.mockRejectedValue( new Error('Not official mastercopy'), @@ -523,7 +523,7 @@ describe('LimitAddressesMapper', () => { const version = '0.0.1'; const safe = safeBuilder().build(); const safeAddress = getAddress(safe.address); - const data = execTransactionEncoder().encode() as Hex; + const data = execTransactionEncoder().encode(); // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); diff --git a/src/logging/__tests__/test.logging.module.ts b/src/logging/__tests__/test.logging.module.ts index c4c1cd94b1..5ddb101aa0 100644 --- a/src/logging/__tests__/test.logging.module.ts +++ b/src/logging/__tests__/test.logging.module.ts @@ -12,22 +12,22 @@ class TestLoggingService implements ILoggingService { this.isSilent = configurationService.getOrThrow('log.silent'); } - debug(message: string | unknown): void { + debug(message: unknown): void { if (this.isSilent) return; console.debug(message); } - error(message: string | unknown): void { + error(message: unknown): void { if (this.isSilent) return; console.error(message); } - info(message: string | unknown): void { + info(message: unknown): void { if (this.isSilent) return; console.info(message); } - warn(message: string | unknown): void { + warn(message: unknown): void { if (this.isSilent) return; console.warn(message); } diff --git a/src/logging/logging.interface.ts b/src/logging/logging.interface.ts index ae21663424..a06317745f 100644 --- a/src/logging/logging.interface.ts +++ b/src/logging/logging.interface.ts @@ -1,11 +1,11 @@ export const LoggingService = Symbol('ILoggingService'); export interface ILoggingService { - info(message: string | unknown): void; + info(message: unknown): void; - debug(message: string | unknown): void; + debug(message: unknown): void; - error(message: string | unknown): void; + error(message: unknown): void; - warn(message: string | unknown): void; + warn(message: unknown): void; } diff --git a/src/logging/logging.service.ts b/src/logging/logging.service.ts index 2a5a04292c..5e55fde79b 100644 --- a/src/logging/logging.service.ts +++ b/src/logging/logging.service.ts @@ -25,23 +25,23 @@ export class RequestScopedLoggingService implements ILoggingService { this.buildNumber = configurationService.get('about.buildNumber'); } - info(message: string | unknown): void { + info(message: unknown): void { this.logger.log('info', this.formatMessage(message)); } - error(message: string | unknown): void { + error(message: unknown): void { this.logger.log('error', this.formatMessage(message)); } - warn(message: string | unknown): void { + warn(message: unknown): void { this.logger.log('warn', this.formatMessage(message)); } - debug(message: string | unknown): void { + debug(message: unknown): void { this.logger.log('debug', this.formatMessage(message)); } - private formatMessage(message: string | unknown): { + private formatMessage(message: unknown): { message: unknown; build_number: string | undefined; request_id: string; diff --git a/src/logging/utils.ts b/src/logging/utils.ts index 5bdfae1d86..ce35eaa973 100644 --- a/src/logging/utils.ts +++ b/src/logging/utils.ts @@ -3,6 +3,7 @@ import { get } from 'lodash'; const HEADER_IP_ADDRESS = 'X-Real-IP'; const HEADER_SAFE_APP_USER_AGENT = 'Safe-App-User-Agent'; +const HEADER_ORIGIN = 'Origin'; export function formatRouteLogMessage( statusCode: number, @@ -19,11 +20,12 @@ export function formatRouteLogMessage( safe_app_user_agent: string | null; status_code: number; detail: string | null; + origin: string | null; } { const clientIp = request.header(HEADER_IP_ADDRESS) ?? null; - const safe_app_user_agent = - request.header(HEADER_SAFE_APP_USER_AGENT) ?? null; + const safeAppUserAgent = request.header(HEADER_SAFE_APP_USER_AGENT) ?? null; const chainId = request.params['chainId'] ?? null; + const origin = request.header(HEADER_ORIGIN) ?? null; return { chain_id: chainId, @@ -32,9 +34,10 @@ export function formatRouteLogMessage( response_time_ms: performance.now() - startTimeMs, route: request.route.path, path: request.url, - safe_app_user_agent: safe_app_user_agent, + safe_app_user_agent: safeAppUserAgent, status_code: statusCode, detail: detail ?? null, + origin, }; } diff --git a/src/routes/alerts/alerts.controller.spec.ts b/src/routes/alerts/alerts.controller.spec.ts index 8e0ded493e..e565d3b634 100644 --- a/src/routes/alerts/alerts.controller.spec.ts +++ b/src/routes/alerts/alerts.controller.spec.ts @@ -630,7 +630,7 @@ describe('Alerts (Unit)', () => { .with('data', multiSend.encode()) .with( 'to', - getAddress(getMultiSendCallOnlyDeployment()!.defaultAddress!), + getAddress(getMultiSendCallOnlyDeployment()!.defaultAddress), ) .encode(); @@ -1171,7 +1171,7 @@ describe('Alerts (Unit)', () => { .with('data', multiSend.encode()) .with( 'to', - getAddress(getMultiSendCallOnlyDeployment()!.defaultAddress!), + getAddress(getMultiSendCallOnlyDeployment()!.defaultAddress), ) .encode(); @@ -1286,7 +1286,7 @@ describe('Alerts (Unit)', () => { .with('data', multiSend.encode()) .with( 'to', - getAddress(getMultiSendCallOnlyDeployment()!.defaultAddress!), + getAddress(getMultiSendCallOnlyDeployment()!.defaultAddress), ) .encode(); 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 7266631011..995e3cd212 100644 --- a/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts +++ b/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts @@ -227,10 +227,13 @@ describe('Balances Controller (Unit)', () => { 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}/positions`, ); expect( - networkService.get.mock.calls[0][0].networkRequest, + networkService.get.mock.calls[1][0].networkRequest, ).toStrictEqual({ headers: { Authorization: `Basic ${apiKey}` }, params: { @@ -239,9 +242,6 @@ describe('Balances Controller (Unit)', () => { sort: 'value', }, }); - expect(networkService.get.mock.calls[1][0].url).toBe( - `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, - ); }); it('returns large numbers as is (not in scientific notation)', async () => { @@ -376,10 +376,13 @@ describe('Balances Controller (Unit)', () => { 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}/positions`, ); expect( - networkService.get.mock.calls[0][0].networkRequest, + networkService.get.mock.calls[1][0].networkRequest, ).toStrictEqual({ headers: { Authorization: `Basic ${apiKey}` }, params: { @@ -388,9 +391,6 @@ describe('Balances Controller (Unit)', () => { sort: 'value', }, }); - expect(networkService.get.mock.calls[1][0].url).toBe( - `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, - ); }); }); diff --git a/src/routes/balances/balances.controller.spec.ts b/src/routes/balances/balances.controller.spec.ts index 67655bc76a..c9a950fa75 100644 --- a/src/routes/balances/balances.controller.spec.ts +++ b/src/routes/balances/balances.controller.spec.ts @@ -89,22 +89,15 @@ describe('Balances Controller (Unit)', () => { .with('token', balanceTokenBuilder().with('decimals', 17).build()) .build(), ]; - const nativeCoinId = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain.chainId}.nativeCoin`, - ); const apiKey = app .get(IConfigurationService) .getOrThrow('balances.providers.safe.prices.apiKey'); - const chainName = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - ); const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [nativeCoinId]: { [currency.toLowerCase()]: 1536.75 }, + // @ts-expect-error - TODO: remove after migration + [chain.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, }; const tokenPriceProviderResponse = { [tokenAddress]: { [currency.toLowerCase()]: 12.5 }, @@ -124,7 +117,8 @@ describe('Balances Controller (Unit)', () => { data: nativeCoinPriceProviderResponse, status: 200, }); - case `${pricesProviderUrl}/simple/token_price/${chainName}`: + // @ts-expect-error - TODO: remove after migration + case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, @@ -203,7 +197,8 @@ describe('Balances Controller (Unit)', () => { params: { trusted: false, exclude_spam: true }, }); expect(networkService.get.mock.calls[2][0].url).toBe( - `${pricesProviderUrl}/simple/token_price/${chainName}`, + // @ts-expect-error - TODO: remove after migration + `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); expect(networkService.get.mock.calls[2][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': apiKey }, @@ -220,7 +215,11 @@ describe('Balances Controller (Unit)', () => { ); expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': apiKey }, - params: { ids: nativeCoinId, vs_currencies: currency.toLowerCase() }, + params: { + // @ts-expect-error - TODO: remove after migration + ids: chain.pricesProvider.nativeCoin, + vs_currencies: currency.toLowerCase(), + }, }); }); @@ -237,11 +236,6 @@ describe('Balances Controller (Unit)', () => { ]; const excludeSpam = true; const trusted = true; - const chainName = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - ); const currency = faker.finance.currencyCode(); const tokenPriceProviderResponse = { [tokenAddress]: { [currency.toLowerCase()]: 2.5 }, @@ -255,7 +249,8 @@ describe('Balances Controller (Unit)', () => { data: transactionApiBalancesResponse, status: 200, }); - case `${pricesProviderUrl}/simple/token_price/${chainName}`: + // @ts-expect-error - TODO: remove after migration + case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, @@ -291,13 +286,11 @@ describe('Balances Controller (Unit)', () => { .build(), ]; const currency = faker.finance.currencyCode(); - const nativeCoinId = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain.chainId}.nativeCoin`, - ); const nativeCoinPriceProviderResponse = { - [nativeCoinId]: { [currency.toLowerCase()]: 1536.75 }, + // @ts-expect-error - TODO: remove after migration + [chain.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, }; networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -356,11 +349,6 @@ describe('Balances Controller (Unit)', () => { .with('token', balanceTokenBuilder().with('decimals', 17).build()) .build(), ]; - const chainName = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - ); const currency = faker.finance.currencyCode(); const tokenPriceProviderResponse = { [tokenAddress]: { [currency.toLowerCase()]: 2.5 }, @@ -374,7 +362,8 @@ describe('Balances Controller (Unit)', () => { data: transactionApiBalancesResponse, status: 200, }); - case `${pricesProviderUrl}/simple/token_price/${chainName}`: + // @ts-expect-error - TODO: remove after migration + case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, @@ -423,7 +412,8 @@ describe('Balances Controller (Unit)', () => { params: { trusted: false, exclude_spam: true }, }); expect(networkService.get.mock.calls[2][0].url).toBe( - `${pricesProviderUrl}/simple/token_price/${chainName}`, + // @ts-expect-error - TODO: remove after migration + `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); }); @@ -465,11 +455,6 @@ describe('Balances Controller (Unit)', () => { .with('token', balanceTokenBuilder().with('decimals', 17).build()) .build(), ]; - const chainName = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - ); networkService.get.mockImplementation(({ url }) => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: @@ -479,7 +464,8 @@ describe('Balances Controller (Unit)', () => { data: transactionApiBalancesResponse, status: 200, }); - case `${pricesProviderUrl}/simple/token_price/${chainName}`: + // @ts-expect-error - TODO: remove after migration + case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: return Promise.reject(); default: return Promise.reject(new Error(`Could not match ${url}`)); @@ -528,11 +514,6 @@ describe('Balances Controller (Unit)', () => { .with('token', balanceTokenBuilder().with('decimals', 17).build()) .build(), ]; - const chainName = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain.chainId}.chainName`, - ); const tokenPriceProviderResponse = 'notAnObject'; networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -543,7 +524,8 @@ describe('Balances Controller (Unit)', () => { data: transactionApiBalancesResponse, status: 200, }); - case `${pricesProviderUrl}/simple/token_price/${chainName}`: + // @ts-expect-error - TODO: remove after migration + case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, diff --git a/src/routes/balances/balances.service.ts b/src/routes/balances/balances.service.ts index 4b8840eb42..d019697ac7 100644 --- a/src/routes/balances/balances.service.ts +++ b/src/routes/balances/balances.service.ts @@ -27,10 +27,13 @@ export class BalancesService { excludeSpam: boolean; }): Promise { const { chainId } = args; - const domainBalances = await this.balancesRepository.getBalances(args); - const { nativeCurrency } = await this.chainsRepository.getChain(chainId); + const chain = await this.chainsRepository.getChain(chainId); + const domainBalances = await this.balancesRepository.getBalances({ + ...args, + chain, + }); const balances: Balance[] = domainBalances.map((balance) => - this._mapBalance(balance, nativeCurrency), + this._mapBalance(balance, chain.nativeCurrency), ); const fiatTotal = balances .filter((b) => b.fiatBalance !== null) diff --git a/src/routes/cache-hooks/cache-hooks.controller.ts b/src/routes/cache-hooks/cache-hooks.controller.ts index 6a67823e7e..df077e9ef7 100644 --- a/src/routes/cache-hooks/cache-hooks.controller.ts +++ b/src/routes/cache-hooks/cache-hooks.controller.ts @@ -12,7 +12,6 @@ import { CacheHooksService } from '@/routes/cache-hooks/cache-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 { PreExecutionLogGuard } from '@/routes/cache-hooks/guards/pre-execution.guard'; import { WebHookSchema } from '@/routes/cache-hooks/entities/schemas/web-hook.schema'; import { ILoggingService, LoggingService } from '@/logging/logging.interface'; import { IConfigurationService } from '@/config/configuration.service.interface'; @@ -43,7 +42,7 @@ export class CacheHooksController { ); } - @UseGuards(PreExecutionLogGuard, BasicAuthGuard) + @UseGuards(BasicAuthGuard) @Post('/hooks/events') @UseFilters(EventProtocolChangedFilter) @HttpCode(202) diff --git a/src/routes/cache-hooks/guards/pre-execution.guard.ts b/src/routes/cache-hooks/guards/pre-execution.guard.ts deleted file mode 100644 index 4f62033cb8..0000000000 --- a/src/routes/cache-hooks/guards/pre-execution.guard.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ILoggingService, LoggingService } from '@/logging/logging.interface'; -import { - CanActivate, - ExecutionContext, - Inject, - Injectable, -} from '@nestjs/common'; - -/** - * The PreExecutionLogGuard guard outputs a log line containing parts of the request data. - * Currently only the request path is being logged. - */ -@Injectable() -export class PreExecutionLogGuard implements CanActivate { - private static readonly PRE_EXECUTION_LOGGING_DETAIL = 'pre-execution-log'; - - constructor( - @Inject(LoggingService) private readonly loggingService: ILoggingService, - ) {} - - canActivate(context: ExecutionContext): boolean { - const httpContext = context.switchToHttp(); - const request = httpContext.getRequest(); - this.loggingService.info({ - type: PreExecutionLogGuard.PRE_EXECUTION_LOGGING_DETAIL, - route: request.route.path, - }); - return true; - } -} diff --git a/src/routes/common/guards/only-safe-owner.guard.spec.ts b/src/routes/common/guards/only-safe-owner.guard.spec.ts deleted file mode 100644 index 357ab9697c..0000000000 --- a/src/routes/common/guards/only-safe-owner.guard.spec.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { - Controller, - HttpCode, - INestApplication, - Post, - UseGuards, -} from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; -import { TestAppProvider } from '@/__tests__/test-app.provider'; -import { ConfigurationModule } from '@/config/configuration.module'; -import configuration from '@/config/entities/__tests__/configuration'; -import * as request from 'supertest'; -import { faker } from '@faker-js/faker'; -import { OnlySafeOwnerGuard } from '@/routes/common/guards/only-safe-owner.guard'; -import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; - -const safeRepository = { - isOwner: jest.fn(), -} as jest.MockedObjectDeep; - -const safeRepositoryMock = jest.mocked(safeRepository); - -@Controller() -class TestController { - @Post('test/:chainId/:safeAddress') - @HttpCode(200) - @UseGuards(OnlySafeOwnerGuard) - async validRoute(): Promise {} - - @Post('test/invalid/chains/:chainId') - @HttpCode(200) - @UseGuards(OnlySafeOwnerGuard) - async invalidRouteWithChainId(): Promise {} - - @Post('test/invalid/safes/:safeAddress') - @HttpCode(200) - @UseGuards(OnlySafeOwnerGuard) - async invalidRouteWithSafeAddress(): Promise {} -} - -describe('OnlySafeOwner guard tests', () => { - let app: INestApplication; - - beforeEach(async () => { - jest.resetAllMocks(); - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [TestLoggingModule, ConfigurationModule.register(configuration)], - controllers: [TestController], - providers: [ - { - provide: ISafeRepository, - useValue: safeRepositoryMock, - }, - ], - }).compile(); - app = await new TestAppProvider().provide(moduleFixture); - await app.init(); - }); - - afterEach(async () => { - await app.close(); - }); - - it('returns 403 on empty body', async () => { - const chainId = faker.string.numeric(); - const safe = faker.finance.ethereumAddress(); - - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safe}`) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 200 if account is an owner of the safe', async () => { - const chainId = faker.string.numeric(); - const safe = faker.finance.ethereumAddress(); - const signer = faker.finance.ethereumAddress(); - safeRepositoryMock.isOwner.mockImplementation((args) => { - if ( - args.chainId !== chainId || - args.address !== signer || - args.safeAddress !== safe - ) - return Promise.reject(); - else return Promise.resolve(true); - }); - - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safe}`) - .send({ - signer: signer, - }) - .expect(200); - }); - - it('returns 403 if account is not an owner of the safe', async () => { - const chainId = faker.string.numeric(); - const safe = faker.finance.ethereumAddress(); - const account = faker.finance.ethereumAddress(); - safeRepositoryMock.isOwner.mockImplementation((args) => { - if ( - args.chainId !== chainId || - args.address !== account || - args.safeAddress !== safe - ) - return Promise.reject(); - else return Promise.resolve(false); - }); - - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safe}`) - .send({ - account: account, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 on routes without safe address', async () => { - const chainId = faker.string.numeric(); - const account = faker.finance.ethereumAddress(); - - await request(app.getHttpServer()) - .post(`/test/invalid/chains/${chainId}`) - .send({ - account: account, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 on routes without chain id', async () => { - const safeAddress = faker.finance.ethereumAddress(); - const account = faker.finance.ethereumAddress(); - - await request(app.getHttpServer()) - .post(`/test/invalid/safes/${safeAddress}`) - .send({ - account: account, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); -}); diff --git a/src/routes/common/guards/only-safe-owner.guard.ts b/src/routes/common/guards/only-safe-owner.guard.ts deleted file mode 100644 index 19da62e3c0..0000000000 --- a/src/routes/common/guards/only-safe-owner.guard.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { - CanActivate, - ExecutionContext, - Inject, - Injectable, -} from '@nestjs/common'; -import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; - -/** - * The OnlySafeOwner guard can be applied to any route that requires - * that a provided 'signer' (owner) is part of a Safe - * - * This guard does not validate that a message came from said owner. - * - * To use this guard, the route should have: - * - the 'chainId' as part of the path parameters - * - the 'safeAddress' as part of the path parameters - * - the 'signer' as part of the path parameters or as part of the JSON body (top level) - */ -@Injectable() -export class OnlySafeOwnerGuard implements CanActivate { - constructor( - @Inject(ISafeRepository) private readonly safeRepository: ISafeRepository, - ) {} - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - - const chainId = request.params['chainId']; - const safe = request.params['safeAddress']; - const signer = request.params['signer'] ?? request.body['signer']; - - // Required fields - if (!chainId || !safe || !signer) return false; - - return await this.safeRepository.isOwner({ - chainId, - safeAddress: safe, - address: signer, - }); - } -} diff --git a/src/routes/common/interceptors/route-logger.interceptor.spec.ts b/src/routes/common/interceptors/route-logger.interceptor.spec.ts index 615ed0426a..7911b2a6f9 100644 --- a/src/routes/common/interceptors/route-logger.interceptor.spec.ts +++ b/src/routes/common/interceptors/route-logger.interceptor.spec.ts @@ -88,6 +88,7 @@ describe('RouteLoggerInterceptor tests', () => { route: '/test/server-error', safe_app_user_agent: null, status_code: 500, + origin: null, }); expect(mockLoggingService.info).not.toHaveBeenCalled(); expect(mockLoggingService.debug).not.toHaveBeenCalled(); @@ -115,6 +116,7 @@ describe('RouteLoggerInterceptor tests', () => { route: '/test/server-data-source-error', safe_app_user_agent: null, status_code: 501, + origin: null, }); expect(mockLoggingService.info).not.toHaveBeenCalled(); expect(mockLoggingService.debug).not.toHaveBeenCalled(); @@ -135,6 +137,7 @@ describe('RouteLoggerInterceptor tests', () => { route: '/test/client-error', safe_app_user_agent: null, status_code: 405, + origin: null, }); expect(mockLoggingService.error).not.toHaveBeenCalled(); expect(mockLoggingService.debug).not.toHaveBeenCalled(); @@ -155,6 +158,7 @@ describe('RouteLoggerInterceptor tests', () => { route: '/test/success', safe_app_user_agent: null, status_code: 200, + origin: null, }); expect(mockLoggingService.error).not.toHaveBeenCalled(); expect(mockLoggingService.debug).not.toHaveBeenCalled(); @@ -178,6 +182,7 @@ describe('RouteLoggerInterceptor tests', () => { route: '/test/success/:chainId', safe_app_user_agent: null, status_code: 200, + origin: null, }); expect(mockLoggingService.error).not.toHaveBeenCalled(); expect(mockLoggingService.debug).not.toHaveBeenCalled(); @@ -200,6 +205,7 @@ describe('RouteLoggerInterceptor tests', () => { route: '/test/server-error-non-http', safe_app_user_agent: null, status_code: 500, + origin: null, }); expect(mockLoggingService.info).not.toHaveBeenCalled(); expect(mockLoggingService.debug).not.toHaveBeenCalled(); @@ -224,6 +230,7 @@ describe('RouteLoggerInterceptor tests', () => { route: '/test/success', safe_app_user_agent: safeAppUserAgentHeader, status_code: 200, + origin: null, }); }); }); diff --git a/src/routes/email/email.controller.edit-email.spec.ts b/src/routes/email/email.controller.edit-email.spec.ts index d459852c6e..8515e4959e 100644 --- a/src/routes/email/email.controller.edit-email.spec.ts +++ b/src/routes/email/email.controller.edit-email.spec.ts @@ -12,7 +12,6 @@ import { AccountDataSourceModule } from '@/datasources/account/account.datasourc import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; import * as request from 'supertest'; import { faker } from '@faker-js/faker'; -import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { INetworkService, @@ -35,6 +34,9 @@ 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'; @@ -45,6 +47,7 @@ describe('Email controller edit email tests', () => { let safeConfigUrl: string; let accountDataSource: jest.MockedObjectDeep; let networkService: jest.MockedObjectDeep; + let jwtService: IJwtService; beforeEach(async () => { jest.resetAllMocks(); @@ -86,6 +89,7 @@ describe('Email controller edit email tests', () => { safeConfigUrl = configurationService.get('safeConfig.baseUri'); accountDataSource = moduleFixture.get(IAccountDataSource); networkService = moduleFixture.get(NetworkService); + jwtService = moduleFixture.get(IJwtService); app = await new TestAppProvider().provide(moduleFixture); await app.init(); @@ -108,16 +112,16 @@ describe('Email controller edit email tests', () => { const chain = chainBuilder().build(); const prevEmailAddress = faker.internet.email(); const emailAddress = faker.internet.email(); - const timestamp = jest.now(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); - const signerAddress = signer.address; const safe = safeBuilder() // Allow test of non-checksummed address by casting .with('address', safeAddress as `0x${string}`) .build(); - const message = `email-edit-${chain.chainId}-${safe.address}-${emailAddress}-${signerAddress}-${timestamp}`; - const signature = await signer.signMessage({ message }); + 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}`: @@ -133,7 +137,7 @@ describe('Email controller edit email tests', () => { .with('chainId', chain.chainId) .with('signer', signerAddress) .with('isVerified', true) - .with('safeAddress', getAddress(safe.address)) + .with('safeAddress', safe.address) .with('emailAddress', new EmailAddress(prevEmailAddress)) .build(), ); @@ -145,10 +149,9 @@ describe('Email controller edit email tests', () => { await request(app.getHttpServer()) .put( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signer.address}`, + `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) + .set('Cookie', [`access_token=${accessToken}`]) .send({ emailAddress, }) @@ -188,146 +191,312 @@ describe('Email controller edit email tests', () => { // TODO: validate that `IEmailApi.createMessage` is triggered with the correct code }); - it('should return 409 if trying to edit with the same email', async () => { + it('returns 403 if no token is present', async () => { const chain = chainBuilder().build(); const emailAddress = faker.internet.email(); - const timestamp = jest.now(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); - const signerAddress = signer.address; const safe = safeBuilder().build(); - const message = `email-edit-${chain.chainId}-${safe.address}-${emailAddress}-${signerAddress}-${timestamp}`; - const signature = await signer.signMessage({ message }); - 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); + const signerAddress = safe.owners[0]; await request(app.getHttpServer()) .put( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signer.address}`, + `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) .send({ emailAddress, }) - .expect(409) - .expect({ - statusCode: 409, - message: 'Email address matches that of the Safe owner.', - }); + .expect(403); - expect(accountDataSource.updateAccountEmail).toHaveBeenCalledTimes(0); - expect(accountDataSource.setEmailVerificationCode).toHaveBeenCalledTimes(0); + expect(accountDataSource.updateAccountEmail).not.toHaveBeenCalled(); + expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); + expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); expect( accountDataSource.setEmailVerificationSentDate, - ).toHaveBeenCalledTimes(0); + ).not.toHaveBeenCalled(); }); - it('returns 422 if Safe address is not a valid Ethereum address', async () => { + it('returns 403 if token is not a valid JWT', async () => { const chain = chainBuilder().build(); const emailAddress = faker.internet.email(); - const timestamp = jest.now(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); - const signerAddress = signer.address; - const invalidSafeAddress = faker.word.sample(); - const message = `email-edit-${chain.chainId}-${invalidSafeAddress}-${emailAddress}-${signerAddress}-${timestamp}`; - const signature = await signer.signMessage({ message }); - accountDataSource.getAccount.mockResolvedValue({ - emailAddress: new EmailAddress(emailAddress), - } as Account); + 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/${invalidSafeAddress}/emails/${signer.address}`, + `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) + .set('Cookie', [`access_token=${accessToken}`]) .send({ emailAddress, }) - .expect(422) - .expect({ - message: `Address "${invalidSafeAddress}" is invalid.`, - error: 'Unprocessable Entity', - statusCode: 422, - }); + .expect(403); - expect(accountDataSource.updateAccountEmail).toHaveBeenCalledTimes(0); - expect(accountDataSource.setEmailVerificationCode).toHaveBeenCalledTimes(0); + expect(accountDataSource.updateAccountEmail).not.toHaveBeenCalled(); + expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); + expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); expect( accountDataSource.setEmailVerificationSentDate, - ).toHaveBeenCalledTimes(0); + ).not.toHaveBeenCalled(); }); - it('should return 404 if trying to edit a non-existent email entry', async () => { + it('returns 403 if token is not yet valid', async () => { const chain = chainBuilder().build(); const emailAddress = faker.internet.email(); - const timestamp = jest.now(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); - const signerAddress = signer.address; const safe = safeBuilder().build(); - const message = `email-edit-${chain.chainId}-${safe.address}-${emailAddress}-${signerAddress}-${timestamp}`; - const signature = await signer.signMessage({ message }); - 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}`)); - } + 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), }); - accountDataSource.getAccount.mockRejectedValue( - new AccountDoesNotExistError(chain.chainId, safe.address, signerAddress), - ); + expect(() => jwtService.verify(accessToken)).toThrow('jwt not active'); await request(app.getHttpServer()) .put( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signer.address}`, + `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) + .set('Cookie', [`access_token=${accessToken}`]) .send({ emailAddress, }) - .expect(404) - .expect({ - statusCode: 404, - message: `No email address was found for the provided signer ${signerAddress}.`, - }); + .expect(403); - expect(accountDataSource.updateAccountEmail).toHaveBeenCalledTimes(0); - expect(accountDataSource.setEmailVerificationCode).toHaveBeenCalledTimes(0); + expect(accountDataSource.updateAccountEmail).not.toHaveBeenCalled(); + expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); + expect(accountDataSource.setEmailVerificationCode).not.toHaveBeenCalled(); expect( accountDataSource.setEmailVerificationSentDate, - ).toHaveBeenCalledTimes(0); + ).not.toHaveBeenCalled(); }); - it('return 500 if updating fails in general', async () => { + 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 prevEmailAddress = faker.internet.email(); const emailAddress = faker.internet.email(); - const timestamp = jest.now(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); - const signerAddress = signer.address; const safe = safeBuilder().build(); - const message = `email-edit-${chain.chainId}-${safe.address}-${emailAddress}-${signerAddress}-${timestamp}`; - const signature = await signer.signMessage({ message }); + 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}`: @@ -339,59 +508,66 @@ describe('Email controller edit email tests', () => { } }); accountDataSource.getAccount.mockResolvedValue({ - emailAddress: new EmailAddress(prevEmailAddress), + emailAddress: new EmailAddress(emailAddress), } as Account); - accountDataSource.updateAccountEmail.mockRejectedValue(new Error()); await request(app.getHttpServer()) .put( - `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signer.address}`, + `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) + .set('Cookie', [`access_token=${accessToken}`]) .send({ emailAddress, }) - .expect(500) + .expect(409) .expect({ - code: 500, - message: 'Internal server error', + statusCode: 409, + message: 'Email address matches that of the Safe owner.', }); - expect(accountDataSource.updateAccountEmail).toHaveBeenCalledTimes(1); + expect(accountDataSource.updateAccountEmail).toHaveBeenCalledTimes(0); expect(accountDataSource.setEmailVerificationCode).toHaveBeenCalledTimes(0); expect( accountDataSource.setEmailVerificationSentDate, ).toHaveBeenCalledTimes(0); }); - it('returns 403 is message was signed with a timestamp older than 5 minutes', async () => { + it('should return 404 if trying to edit a non-existent email entry', async () => { const chain = chainBuilder().build(); const emailAddress = faker.internet.email(); - const timestamp = jest.now(); - const privateKey = generatePrivateKey(); - const account = privateKeyToAccount(privateKey); - const accountAddress = account.address; const safe = safeBuilder().build(); - const message = `email-edit-${chain.chainId}-${safe.address}-${emailAddress}-${accountAddress}-${timestamp}`; - const signature = await account.signMessage({ message }); - - jest.advanceTimersByTime(5 * 60 * 1000); + 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/${account.address}`, + `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) + .set('Cookie', [`access_token=${accessToken}`]) .send({ emailAddress, }) - .expect(403) + .expect(404) .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, + statusCode: 404, + message: `No email address was found for the provided signer ${signerAddress}.`, }); expect(accountDataSource.updateAccountEmail).toHaveBeenCalledTimes(0); @@ -401,34 +577,47 @@ describe('Email controller edit email tests', () => { ).toHaveBeenCalledTimes(0); }); - it('returns 403 on wrong message signature', async () => { + it('return 500 if updating fails in general', async () => { const chain = chainBuilder().build(); + const prevEmailAddress = faker.internet.email(); const emailAddress = faker.internet.email(); - const timestamp = jest.now(); - const privateKey = generatePrivateKey(); - const account = privateKeyToAccount(privateKey); - const accountAddress = account.address; const safe = safeBuilder().build(); - const message = `some-action-${chain.chainId}-${safe.address}-${emailAddress}-${accountAddress}-${timestamp}`; - const signature = await account.signMessage({ message }); + 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/${account.address}`, + `/v1/chains/${chain.chainId}/safes/${safe.address}/emails/${signerAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) + .set('Cookie', [`access_token=${accessToken}`]) .send({ emailAddress, }) - .expect(403) + .expect(500) .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, + code: 500, + message: 'Internal server error', }); - expect(accountDataSource.updateAccountEmail).toHaveBeenCalledTimes(0); + expect(accountDataSource.updateAccountEmail).toHaveBeenCalledTimes(1); expect(accountDataSource.setEmailVerificationCode).toHaveBeenCalledTimes(0); expect( accountDataSource.setEmailVerificationSentDate, diff --git a/src/routes/email/email.controller.ts b/src/routes/email/email.controller.ts index ac179c17a4..38d555a2fa 100644 --- a/src/routes/email/email.controller.ts +++ b/src/routes/email/email.controller.ts @@ -12,7 +12,6 @@ import { UseGuards, } from '@nestjs/common'; import { EmailService } from '@/routes/email/email.service'; -import { TimestampGuard } from '@/routes/email/guards/timestamp.guard'; import { SaveEmailDto, SaveEmailDtoSchema, @@ -21,7 +20,6 @@ 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 { EmailEditGuard } from '@/routes/email/guards/email-edit.guard'; 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'; @@ -125,10 +123,7 @@ export class EmailController { } @Put(':signer') - @UseGuards( - EmailEditGuard, - TimestampGuard(5 * 60 * 1000), // 5 minutes - ) + @UseGuards(AuthGuard) @UseFilters( EmailEditMatchesExceptionFilter, AccountDoesNotExistExceptionFilter, @@ -137,14 +132,16 @@ export class EmailController { async editEmail( @Param('chainId') chainId: string, @Param('safeAddress') safeAddress: string, - @Param('signer') signer: 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.service.ts b/src/routes/email/email.service.ts index 5c6aae13f3..e5036be692 100644 --- a/src/routes/email/email.service.ts +++ b/src/routes/email/email.service.ts @@ -68,8 +68,9 @@ export class EmailService { async editEmail(args: { chainId: string; safeAddress: string; - signer: string; + signer: `0x${string}`; emailAddress: string; + authPayload: AuthPayload; }): Promise { return this.repository .editEmail(args) 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 new file mode 100644 index 0000000000..bf80850c0c --- /dev/null +++ b/src/routes/email/entities/__tests__/edit-email-dto.entity.spec.ts @@ -0,0 +1,34 @@ +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/edit-email-dto.entity.ts b/src/routes/email/entities/edit-email-dto.entity.ts index 8eb9c11f31..20564a4f5b 100644 --- a/src/routes/email/entities/edit-email-dto.entity.ts +++ b/src/routes/email/entities/edit-email-dto.entity.ts @@ -1,6 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; +import { z } from 'zod'; -export class EditEmailDto { +export class EditEmailDto implements z.infer { @ApiProperty() emailAddress!: string; } + +export const EditEmailDtoSchema = z.object({ + emailAddress: z.string().email(), +}); diff --git a/src/routes/email/guards/email-edit.guard.spec.ts b/src/routes/email/guards/email-edit.guard.spec.ts deleted file mode 100644 index 34c152cc79..0000000000 --- a/src/routes/email/guards/email-edit.guard.spec.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { - Controller, - HttpCode, - INestApplication, - Post, - UseGuards, -} from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; -import { TestAppProvider } from '@/__tests__/test-app.provider'; -import { ConfigurationModule } from '@/config/configuration.module'; -import configuration from '@/config/entities/__tests__/configuration'; -import * as request from 'supertest'; -import { faker } from '@faker-js/faker'; -import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; -import { Hash } from 'viem'; -import { EmailEditGuard } from '@/routes/email/guards/email-edit.guard'; - -@Controller() -class TestController { - @Post('test/:chainId/:safeAddress/:signer') - @HttpCode(200) - @UseGuards(EmailEditGuard) - async validRoute(): Promise {} - - @Post('test/invalid/1/chains/:safeAddress/:signer') - @HttpCode(200) - @UseGuards(EmailEditGuard) - async invalidRouteWithoutChainId(): Promise {} - - @Post('test/invalid/2/chains/:chainId/:signer') - @HttpCode(200) - @UseGuards(EmailEditGuard) - async invalidRouteWithoutSafeAddress(): Promise {} - - @Post('test/invalid/3/chains/:chainId/:safeAddress') - @HttpCode(200) - @UseGuards(EmailEditGuard) - async invalidRouteWithoutSigner(): Promise {} -} - -describe('EmailEdit guard tests', () => { - let app: INestApplication; - - const chainId = faker.string.numeric(); - const safe = faker.finance.ethereumAddress(); - const emailAddress = faker.internet.email(); - const timestamp = faker.date.recent().getTime(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); - const signerAddress = signer.address; - let signature: Hash; - - beforeAll(async () => { - const message = `email-edit-${chainId}-${safe}-${emailAddress}-${signerAddress}-${timestamp}`; - signature = await signer.signMessage({ message }); - }); - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [TestLoggingModule, ConfigurationModule.register(configuration)], - controllers: [TestController], - }).compile(); - app = await new TestAppProvider().provide(moduleFixture); - await app.init(); - }); - - afterEach(async () => { - await app.close(); - }); - - it('returns 403 on empty body', async () => { - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safe}/${signer.address}`) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 200 on a valid signature', async () => { - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safe}/${signer.address}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - emailAddress, - }) - .expect(200); - }); - - it('returns 403 on an invalid signature', async () => { - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safe}/${signer.address}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - emailAddress: faker.internet.email(), // different email should have different signature - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 if the email address is missing from payload', async () => { - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safe}/${signer.address}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({}) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 if the signature is missing from headers', async () => { - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safe}/${signer.address}`) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - emailAddress, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 if the timestamp is missing from headers', async () => { - const chainId = faker.string.numeric(); - const safeAddress = faker.finance.ethereumAddress(); - - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safeAddress}/${signer.address}`) - .set('Safe-Wallet-Signature', signature) - .send({ - emailAddress, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 on routes without chain id', async () => { - await request(app.getHttpServer()) - .post(`/test/invalid/1/chains/${safe}}/${signer.address}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - emailAddress, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 on routes without safe address', async () => { - await request(app.getHttpServer()) - .post(`/test/invalid/2/chains/${chainId}}/${signer.address}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - emailAddress, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 on routes without signer', async () => { - await request(app.getHttpServer()) - .post(`/test/invalid/3/chains/${chainId}}/${safe}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - emailAddress, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); -}); diff --git a/src/routes/email/guards/email-edit.guard.ts b/src/routes/email/guards/email-edit.guard.ts deleted file mode 100644 index d21bc0ce65..0000000000 --- a/src/routes/email/guards/email-edit.guard.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { - CanActivate, - ExecutionContext, - Inject, - Injectable, -} from '@nestjs/common'; -import { ILoggingService, LoggingService } from '@/logging/logging.interface'; -import { verifyMessage } from 'viem'; - -/** - * The EmailEditGuard guard should be used on routes that require - * authenticated actions on updating email addresses. - * - * This guard therefore validates if the message came from the specified signer. - * - * The following message should be signed: - * email-edit-${chainId}-${safe}-${emailAddress}-${signer}-${timestamp} - * - * (where ${} represents placeholder values for the respective data) - * - * To use this guard, the route should have: - * - the 'chainId' as part of the path parameters - * - the 'safeAddress' as part of the path parameters - * - the 'signer' as part of the path parameters - * - the 'emailAddress' as part of the JSON body (top level) - * - the 'Safe-Wallet-Signature' header set to the signature - * - the 'Safe-Wallet-Signature-Timestamp' header set to the signature timestamp - */ -@Injectable() -export class EmailEditGuard implements CanActivate { - constructor( - @Inject(LoggingService) private readonly loggingService: ILoggingService, - ) {} - - private static readonly ACTION_PREFIX = 'email-edit'; - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - - const chainId = request.params['chainId']; - const safe = request.params['safeAddress']; - const signer = request.params['signer']; - const emailAddress = request.body['emailAddress']; - const signature = request.headers['safe-wallet-signature']; - const timestamp = request.headers['safe-wallet-signature-timestamp']; - - // Required fields - if ( - !chainId || - !safe || - !signature || - !emailAddress || - !signer || - !timestamp - ) - return false; - - const message = `${EmailEditGuard.ACTION_PREFIX}-${chainId}-${safe}-${emailAddress}-${signer}-${timestamp}`; - - try { - return await verifyMessage({ - address: signer, - message, - signature, - }); - } catch (e) { - this.loggingService.debug(e); - return false; - } - } -} diff --git a/src/routes/email/guards/timestamp.guard.spec.ts b/src/routes/email/guards/timestamp.guard.spec.ts deleted file mode 100644 index a4cad899a3..0000000000 --- a/src/routes/email/guards/timestamp.guard.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; -import { ConfigurationModule } from '@/config/configuration.module'; -import configuration from '@/config/entities/configuration'; -import { - Controller, - HttpCode, - INestApplication, - Post, - UseGuards, -} from '@nestjs/common'; -import { TimestampGuard } from '@/routes/email/guards/timestamp.guard'; -import { TestAppProvider } from '@/__tests__/test-app.provider'; -import * as request from 'supertest'; -import { faker } from '@faker-js/faker'; - -const MAX_ELAPSED_TIME_MS = 5_000; - -@Controller() -class TestController { - @Post('test') - @HttpCode(200) - @UseGuards(TimestampGuard(MAX_ELAPSED_TIME_MS)) - async validRoute(): Promise {} -} - -describe('TimestampGuard tests', () => { - let app: INestApplication; - - beforeEach(async () => { - jest.useFakeTimers(); - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [TestLoggingModule, ConfigurationModule.register(configuration)], - controllers: [TestController], - }).compile(); - app = await new TestAppProvider().provide(moduleFixture); - await app.init(); - }); - - afterEach(async () => { - jest.useRealTimers(); - await app.close(); - }); - - it('returns 403 on empty Safe-Wallet-Signature-Timestamp header', async () => { - await request(app.getHttpServer()).post(`/test`).expect(403).expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 if timestamp is not a number', async () => { - await request(app.getHttpServer()) - .post(`/test`) - .set('Safe-Wallet-Signature-Timestamp', faker.word.sample()) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 200 with 1ms to go', async () => { - const timestamp = jest.now(); - jest.advanceTimersByTime(MAX_ELAPSED_TIME_MS - 1); - - await request(app.getHttpServer()) - .post(`/test`) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .expect(200); - }); - - it('returns 403 with 0ms to go', async () => { - const timestamp = jest.now(); - jest.advanceTimersByTime(MAX_ELAPSED_TIME_MS); - - await request(app.getHttpServer()) - .post(`/test`) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); -}); diff --git a/src/routes/email/guards/timestamp.guard.ts b/src/routes/email/guards/timestamp.guard.ts deleted file mode 100644 index cf349dbc31..0000000000 --- a/src/routes/email/guards/timestamp.guard.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { CanActivate, ExecutionContext, mixin, Type } from '@nestjs/common'; - -/** - * Returns a guard mixin that can be used to check if a 'timestamp' - * provided in the body of the HTTP request is within maxElapsedTimeMs - * from the current system time in UTC. - * - * @param maxElapsedTimeMs - the amount in ms to which this guard should allow - * the request to go through - */ -export const TimestampGuard = (maxElapsedTimeMs: number): Type => { - class TimestampGuardMixin implements CanActivate { - canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest(); - - const timestampRaw = request.headers['safe-wallet-signature-timestamp']; - const timestamp = parseInt(timestampRaw); - if (isNaN(timestamp)) return false; - - // UTC timezone - const now = Date.now(); - return now - timestamp < maxElapsedTimeMs; - } - } - - return mixin(TimestampGuardMixin); -}; diff --git a/src/routes/locking/entities/activity-metadata.entity.ts b/src/routes/locking/entities/activity-metadata.entity.ts new file mode 100644 index 0000000000..cbce0edacf --- /dev/null +++ b/src/routes/locking/entities/activity-metadata.entity.ts @@ -0,0 +1,13 @@ +import { ActivityMetadata as DomainActivityMetadata } from '@/domain/locking/entities/activity-metadata.entity'; +import { ApiProperty } from '@nestjs/swagger'; + +export class ActivityMetadata implements DomainActivityMetadata { + @ApiProperty() + campaignId!: string; + @ApiProperty() + name!: string; + @ApiProperty() + description!: string; + @ApiProperty() + maxPoints!: string; +} diff --git a/src/routes/locking/entities/campaign.entity.ts b/src/routes/locking/entities/campaign.entity.ts new file mode 100644 index 0000000000..aee07bbb8e --- /dev/null +++ b/src/routes/locking/entities/campaign.entity.ts @@ -0,0 +1,20 @@ +import { Campaign as DomainCampaign } from '@/domain/locking/entities/campaign.entity'; +import { ActivityMetadata } from '@/routes/locking/entities/activity-metadata.entity'; +import { ApiProperty } from '@nestjs/swagger'; + +export class Campaign implements DomainCampaign { + @ApiProperty() + campaignId!: string; + @ApiProperty() + name!: string; + @ApiProperty() + description!: string; + @ApiProperty({ type: String }) + startDate!: Date; + @ApiProperty({ type: String }) + endDate!: Date; + @ApiProperty({ type: String }) + lastUpdated!: Date; + @ApiProperty({ type: [ActivityMetadata] }) + activitiesMetadata!: ActivityMetadata[] | null; +} diff --git a/src/routes/locking/entities/campaign.page.entity.ts b/src/routes/locking/entities/campaign.page.entity.ts new file mode 100644 index 0000000000..efa8cf9b50 --- /dev/null +++ b/src/routes/locking/entities/campaign.page.entity.ts @@ -0,0 +1,8 @@ +import { Page } from '@/routes/common/entities/page.entity'; +import { Campaign } from '@/routes/locking/entities/campaign.entity'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CampaignPage extends Page { + @ApiProperty({ type: [Campaign] }) + results!: Array; +} diff --git a/src/routes/locking/locking.controller.spec.ts b/src/routes/locking/locking.controller.spec.ts index 4ee62d6c6d..e28d946bbc 100644 --- a/src/routes/locking/locking.controller.spec.ts +++ b/src/routes/locking/locking.controller.spec.ts @@ -31,6 +31,8 @@ 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 { Campaign } from '@/domain/locking/entities/campaign.entity'; describe('Locking (Unit)', () => { let app: INestApplication; @@ -67,6 +69,165 @@ 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({ + ...campaign, + startDate: campaign.startDate.toISOString(), + endDate: campaign.endDate.toISOString(), + lastUpdated: campaign.lastUpdated.toISOString(), + }); + }); + + 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((campaign) => ({ + ...campaign, + startDate: campaign.startDate.toISOString(), + endDate: campaign.endDate.toISOString(), + lastUpdated: campaign.lastUpdated.toISOString(), + })), + }); + }); + + 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((campaign) => ({ + ...campaign, + startDate: campaign.startDate.toISOString(), + endDate: campaign.endDate.toISOString(), + lastUpdated: campaign.lastUpdated.toISOString(), + })), + }); + + 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 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 067883e4c9..fede794622 100644 --- a/src/routes/locking/locking.controller.ts +++ b/src/routes/locking/locking.controller.ts @@ -9,6 +9,8 @@ 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'; @ApiTags('locking') @Controller({ @@ -18,6 +20,28 @@ import { ApiOkResponse, ApiQuery, ApiTags } from '@nestjs/swagger'; 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: Rank }) @Get('/leaderboard/rank/:safeAddress') async getRank( diff --git a/src/routes/locking/locking.service.ts b/src/routes/locking/locking.service.ts index e7205ca2cb..625135b763 100644 --- a/src/routes/locking/locking.service.ts +++ b/src/routes/locking/locking.service.ts @@ -1,4 +1,5 @@ import { Page } from '@/domain/entities/page.entity'; +import { Campaign } from '@/domain/locking/entities/campaign.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'; @@ -15,6 +16,32 @@ export class LockingService { private readonly lockingRepository: ILockingRepository, ) {} + async getCampaignById(campaignId: string): Promise { + return this.lockingRepository.getCampaignById(campaignId); + } + + async getCampaigns(args: { + routeUrl: URL; + paginationData: PaginationData; + }): Promise> { + const result = await this.lockingRepository.getCampaigns( + args.paginationData, + ); + + 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 getRank(safeAddress: `0x${string}`): Promise { return this.lockingRepository.getRank(safeAddress); } diff --git a/src/routes/messages/entities/__tests__/create-message.dto.builder.ts b/src/routes/messages/entities/__tests__/create-message.dto.builder.ts index 821e22fa2e..8fd3e374bb 100644 --- a/src/routes/messages/entities/__tests__/create-message.dto.builder.ts +++ b/src/routes/messages/entities/__tests__/create-message.dto.builder.ts @@ -5,6 +5,6 @@ import { CreateMessageDto } from '@/routes/messages/entities/create-message.dto. export function createMessageDtoBuilder(): IBuilder { return new Builder() .with('message', faker.word.words({ count: { min: 1, max: 5 } })) - .with('safeAppId', faker.number.int()) + .with('safeAppId', faker.number.int({ min: 0 })) .with('signature', faker.string.hexadecimal({ length: 32 })); } 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 f59323b73f..2a8deefd4b 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 @@ -5,98 +5,167 @@ import { faker } from '@faker-js/faker'; import { ZodError } from 'zod'; describe('CreateMessageDtoSchema', () => { - it('should validate a valid record message', () => { - const createMessageDto = createMessageDtoBuilder() - .with('message', JSON.parse(fakeJson())) - .build(); + describe('message', () => { + it('should validate a valid record message', () => { + const createMessageDto = createMessageDtoBuilder() + .with('message', JSON.parse(fakeJson())) + .build(); - const result = CreateMessageDtoSchema.safeParse(createMessageDto); + const result = CreateMessageDtoSchema.safeParse(createMessageDto); - expect(result.success).toBe(true); - }); + expect(result.success).toBe(true); + }); - it('should validate a valid string message', () => { - const createMessageDto = createMessageDtoBuilder() - .with('message', faker.word.words()) - .build(); + it('should validate a valid string message', () => { + const createMessageDto = createMessageDtoBuilder() + .with('message', faker.word.words()) + .build(); - const result = CreateMessageDtoSchema.safeParse(createMessageDto); + const result = CreateMessageDtoSchema.safeParse(createMessageDto); - expect(result.success).toBe(true); - }); + expect(result.success).toBe(true); + }); - it('should allow optional safeAppId, defaulting to null', () => { - const createMessageDto = createMessageDtoBuilder().build(); - // @ts-expect-error - inferred type doesn't allow optional properties - delete createMessageDto.safeAppId; + it('should not validate without a message', () => { + const createMessageDto = createMessageDtoBuilder().build(); + // @ts-expect-error - inferred type doesn't allow optional properties + delete createMessageDto.message; - const result = CreateMessageDtoSchema.safeParse(createMessageDto); + const result = CreateMessageDtoSchema.safeParse(createMessageDto); - expect(result.success && result.data.safeAppId).toBe(null); + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_union', + unionErrors: [ + // @ts-expect-error - inferred type doesn't allow optional properties + { + issues: [ + { + code: 'invalid_type', + expected: 'object', + received: 'undefined', + path: ['message'], + message: 'Required', + }, + ], + name: 'ZodError', + }, + // @ts-expect-error - inferred type doesn't allow optional properties + { + issues: [ + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['message'], + message: 'Required', + }, + ], + name: 'ZodError', + }, + ], + path: ['message'], + message: 'Invalid input', + }, + ]), + ); + }); }); - it('should not validated without a message', () => { - const createMessageDto = createMessageDtoBuilder().build(); - // @ts-expect-error - inferred type doesn't allow optional properties - delete createMessageDto.message; - - const result = CreateMessageDtoSchema.safeParse(createMessageDto); - - expect(!result.success && result.error).toStrictEqual( - new ZodError([ - { - code: 'invalid_union', - unionErrors: [ - // @ts-expect-error - inferred type doesn't allow optional properties - { - issues: [ - { - code: 'invalid_type', - expected: 'object', - received: 'undefined', - path: ['message'], - message: 'Required', - }, - ], - name: 'ZodError', - }, - // @ts-expect-error - inferred type doesn't allow optional properties - { - issues: [ - { - code: 'invalid_type', - expected: 'string', - received: 'undefined', - path: ['message'], - message: 'Required', - }, - ], - name: 'ZodError', - }, - ], - path: ['message'], - message: 'Invalid input', - }, - ]), - ); + describe('safeAppId', () => { + it('should validate a safeAppId of 0', () => { + const createMessageDto = createMessageDtoBuilder() + .with('safeAppId', 0) + .build(); + + const result = CreateMessageDtoSchema.safeParse(createMessageDto); + + expect(result.success).toBe(true); + }); + + it('should validate a positive integer safeAppId', () => { + const createMessageDto = createMessageDtoBuilder() + .with('safeAppId', faker.number.int({ min: 1 })) + .build(); + + const result = CreateMessageDtoSchema.safeParse(createMessageDto); + + expect(result.success).toBe(true); + }); + + it('should not validate a negative safeAppId', () => { + const createMessageDto = createMessageDtoBuilder() + .with('safeAppId', -1) + .build(); + + const result = CreateMessageDtoSchema.safeParse(createMessageDto); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'too_small', + minimum: 0, + type: 'number', + inclusive: true, + exact: false, + message: 'Number must be greater than or equal to 0', + path: ['safeAppId'], + }, + ]), + ); + }); + + it('should not validate a float safeAppId', () => { + const createMessageDto = createMessageDtoBuilder() + .with('safeAppId', faker.number.float()) + .build(); + + const result = CreateMessageDtoSchema.safeParse(createMessageDto); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'integer', + received: 'float', + message: 'Expected integer, received float', + path: ['safeAppId'], + }, + ]), + ); + }); + + it('should validate without safeAppId, defaulting to null', () => { + const createMessageDto = createMessageDtoBuilder().build(); + // @ts-expect-error - inferred type doesn't allow optional properties + delete createMessageDto.safeAppId; + + const result = CreateMessageDtoSchema.safeParse(createMessageDto); + + expect(result.success && result.data.safeAppId).toBe(null); + }); }); - it('should not validated without a signature', () => { - const createMessageDto = createMessageDtoBuilder().build(); - // @ts-expect-error - inferred type doesn't allow optional properties - delete createMessageDto.signature; - - const result = CreateMessageDtoSchema.safeParse(createMessageDto); - - expect(!result.success && result.error).toStrictEqual( - new ZodError([ - { - code: 'invalid_type', - expected: 'string', - received: 'undefined', - path: ['signature'], - message: 'Required', - }, - ]), - ); + + describe('signature', () => { + it('should not validate without a signature', () => { + const createMessageDto = createMessageDtoBuilder().build(); + // @ts-expect-error - inferred type doesn't allow optional properties + delete createMessageDto.signature; + + const result = CreateMessageDtoSchema.safeParse(createMessageDto); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'invalid_type', + expected: 'string', + received: 'undefined', + path: ['signature'], + message: 'Required', + }, + ]), + ); + }); }); }); diff --git a/src/routes/messages/entities/schemas/create-message.dto.schema.ts b/src/routes/messages/entities/schemas/create-message.dto.schema.ts index 0432aeb498..2cba821a83 100644 --- a/src/routes/messages/entities/schemas/create-message.dto.schema.ts +++ b/src/routes/messages/entities/schemas/create-message.dto.schema.ts @@ -2,6 +2,6 @@ import { z } from 'zod'; export const CreateMessageDtoSchema = z.object({ message: z.union([z.record(z.unknown()), z.string()]), - safeAppId: z.number().nullish().default(null), + safeAppId: z.number().int().gte(0).nullish().default(null), signature: z.string(), }); diff --git a/src/routes/recovery/guards/disable-recovery-alerts.guard.spec.ts b/src/routes/recovery/guards/disable-recovery-alerts.guard.spec.ts deleted file mode 100644 index c354afa40b..0000000000 --- a/src/routes/recovery/guards/disable-recovery-alerts.guard.spec.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { - Controller, - Delete, - INestApplication, - UseGuards, -} from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; -import { TestAppProvider } from '@/__tests__/test-app.provider'; -import { ConfigurationModule } from '@/config/configuration.module'; -import configuration from '@/config/entities/__tests__/configuration'; -import * as request from 'supertest'; -import { faker } from '@faker-js/faker'; -import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; -import { Hash, getAddress } from 'viem'; -import { DisableRecoveryAlertsGuard } from '@/routes/recovery/guards/disable-recovery-alerts.guard'; -import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; - -const safeRepository = { - getSafesByModule: jest.fn(), -} as jest.MockedObjectDeep; -const safeRepositoryMock = jest.mocked(safeRepository); - -@Controller() -class TestController { - @Delete('test/:chainId/:safeAddress/:moduleAddress') - @UseGuards(DisableRecoveryAlertsGuard) - async validRouteWithSigner(): Promise {} - - @Delete('test/invalid/1/chains/:safeAddress/:moduleAddress') - @UseGuards(DisableRecoveryAlertsGuard) - async invalidRouteWithoutChainId(): Promise {} - - @Delete('test/invalid/2/:chainId/:moduleAddress') - @UseGuards(DisableRecoveryAlertsGuard) - async invalidRouteWithoutSafeAddress(): Promise {} - - @Delete('test/invalid/3/:chainId/:safeAddress') - @UseGuards(DisableRecoveryAlertsGuard) - async invalidRouteWithoutModuleAddress(): Promise {} -} - -describe('DisableRecoveryAlertsGuard guard tests', () => { - let app: INestApplication; - - const chainId = faker.string.numeric(); - const safeAddress = faker.finance.ethereumAddress(); - const timestamp = faker.date.recent().getTime(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); - const moduleAddress = faker.finance.ethereumAddress(); - let signature: Hash; - - beforeAll(async () => { - const message = `disable-recovery-alerts-${chainId}-${safeAddress}-${moduleAddress}-${signer.address}-${timestamp}`; - signature = await signer.signMessage({ message }); - }); - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [TestLoggingModule, ConfigurationModule.register(configuration)], - controllers: [TestController], - providers: [ - { - provide: ISafeRepository, - useValue: safeRepositoryMock, - }, - ], - }).compile(); - app = await new TestAppProvider().provide(moduleFixture); - await app.init(); - }); - - afterEach(async () => { - await app.close(); - }); - - it('returns 200 for a valid signature for module on given Safe', async () => { - safeRepositoryMock.getSafesByModule.mockResolvedValue({ - safes: [getAddress(safeAddress)], - }); - - await request(app.getHttpServer()) - .delete(`/test/${chainId}/${safeAddress}/${moduleAddress}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(200); - }); - - it('returns 403 for a valid signature for module not on given Safe', async () => { - safeRepositoryMock.getSafesByModule.mockResolvedValue({ - safes: [], - }); - - await request(app.getHttpServer()) - .delete(`/test/${chainId}/${safeAddress}/${moduleAddress}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(403); - }); - - it('returns 403 for an invalid signature', async () => { - const invalidSignature = await signer.signMessage({ - message: 'some invalid message', - }); - - await request(app.getHttpServer()) - .delete(`/test/${chainId}/${safeAddress}/${moduleAddress}`) - .set('Safe-Wallet-Signature', invalidSignature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 on routes without the moduleAddress', async () => { - const invalidSignature = await signer.signMessage({ - message: 'some invalid message', - }); - - await request(app.getHttpServer()) - .delete(`/test/invalid/3/${chainId}/${safeAddress}`) - .set('Safe-Wallet-Signature', invalidSignature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - signer: signer.address, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 if the signature is missing from payload', async () => { - await request(app.getHttpServer()) - .delete(`/test/${chainId}/${safeAddress}/${moduleAddress}`) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 if the timestamp is missing from payload', async () => { - const chainId = faker.string.numeric(); - const safeAddress = faker.finance.ethereumAddress(); - - await request(app.getHttpServer()) - .delete(`/test/${chainId}/${safeAddress}/${moduleAddress}`) - .set('Safe-Wallet-Signature', signature) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 on routes without chain id', async () => { - await request(app.getHttpServer()) - .delete(`/test/invalid/1/chains/${safeAddress}/${moduleAddress}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 on routes without safe address', async () => { - await request(app.getHttpServer()) - .delete(`/test/invalid/2/${chainId}/${moduleAddress}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 if the signer is missing from the payload', async () => { - await request(app.getHttpServer()) - .delete(`/test/invalid/3/${chainId}/${safeAddress}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); -}); diff --git a/src/routes/recovery/guards/disable-recovery-alerts.guard.ts b/src/routes/recovery/guards/disable-recovery-alerts.guard.ts deleted file mode 100644 index 549663fc27..0000000000 --- a/src/routes/recovery/guards/disable-recovery-alerts.guard.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { - CanActivate, - ExecutionContext, - Inject, - Injectable, -} from '@nestjs/common'; -import { ILoggingService, LoggingService } from '@/logging/logging.interface'; -import { getAddress, verifyMessage } from 'viem'; -import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; - -/** - * The DisableRecoveryAlertsGuard guard should be used on routes that require - * authenticated actions for disabling recovery alerts. - * - * This guard therefore validates if the message came from the specified signer. - * - * The following message should be signed: - * disable-recovery-alerts-${chainId}-${safeAddress}-${moduleAddress}-${signer}-${timestamp} - * - * (where ${} represents placeholder values for the respective data) - * - * To use this guard, the route should have: - * - the 'chainId' as part of the path parameters - * - the 'safeAddress' as part of the path parameters - * - the 'moduleAddress' as part of the path parameters - * - the 'signer' as part of the JSON body (top level) - * - the 'Safe-Wallet-Signature' header set to the signature - * - the 'Safe-Wallet-Signature-Timestamp' header set to the signature timestamp - */ -@Injectable() -export class DisableRecoveryAlertsGuard implements CanActivate { - constructor( - @Inject(ISafeRepository) private readonly safeRepository: ISafeRepository, - @Inject(LoggingService) private readonly loggingService: ILoggingService, - ) {} - - private static readonly ACTION_PREFIX = 'disable-recovery-alerts'; - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - - const chainId = request.params['chainId']; - const safeAddress = request.params['safeAddress']; - const moduleAddress = request.params['moduleAddress']; - const signer = request.body['signer']; - const signature = request.headers['safe-wallet-signature']; - const timestamp = request.headers['safe-wallet-signature-timestamp']; - - // Required fields - if ( - !chainId || - !safeAddress || - !moduleAddress || - !signer || - !signature || - !timestamp - ) - return false; - - const message = `${DisableRecoveryAlertsGuard.ACTION_PREFIX}-${chainId}-${safeAddress}-${moduleAddress}-${signer}-${timestamp}`; - - try { - const isValid = await verifyMessage({ - address: signer, - message, - signature, - }); - - if (!isValid) { - return false; - } - - const { safes } = await this.safeRepository.getSafesByModule({ - chainId, - moduleAddress, - }); - - return safes.some((safe) => getAddress(safe) === getAddress(safeAddress)); - } catch (e) { - this.loggingService.debug(e); - return false; - } - } -} diff --git a/src/routes/recovery/guards/enable-recovery-alerts.guard.spec.ts b/src/routes/recovery/guards/enable-recovery-alerts.guard.spec.ts deleted file mode 100644 index 53908e26f5..0000000000 --- a/src/routes/recovery/guards/enable-recovery-alerts.guard.spec.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { Controller, INestApplication, Post, UseGuards } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; -import { TestAppProvider } from '@/__tests__/test-app.provider'; -import { ConfigurationModule } from '@/config/configuration.module'; -import configuration from '@/config/entities/__tests__/configuration'; -import * as request from 'supertest'; -import { faker } from '@faker-js/faker'; -import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; -import { Hash, getAddress } from 'viem'; -import { EnableRecoveryAlertsGuard } from '@/routes/recovery/guards/enable-recovery-alerts.guard'; -import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; - -const safeRepository = { - getSafesByModule: jest.fn(), -} as jest.MockedObjectDeep; -const safeRepositoryMock = jest.mocked(safeRepository); - -@Controller() -class TestController { - @Post('test/:chainId/:safeAddress') - @UseGuards(EnableRecoveryAlertsGuard) - async validRoute(): Promise {} - - @Post('test/invalid/1/chains/:safeAddress') - @UseGuards(EnableRecoveryAlertsGuard) - async invalidRouteWithoutChainId(): Promise {} - - @Post('test/invalid/2/:chainId') - @UseGuards(EnableRecoveryAlertsGuard) - async invalidRouteWithoutSafeAddress(): Promise {} -} - -describe('EnableRecoveryAlertsGuard guard tests', () => { - let app: INestApplication; - - const chainId = faker.string.numeric(); - const safeAddress = faker.finance.ethereumAddress(); - const timestamp = faker.date.recent().getTime(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); - const moduleAddress = faker.finance.ethereumAddress(); - let signature: Hash; - - beforeAll(async () => { - const message = `enable-recovery-alerts-${chainId}-${safeAddress}-${moduleAddress}-${signer.address}-${timestamp}`; - signature = await signer.signMessage({ message }); - }); - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [TestLoggingModule, ConfigurationModule.register(configuration)], - controllers: [TestController], - providers: [ - { - provide: ISafeRepository, - useValue: safeRepositoryMock, - }, - ], - }).compile(); - app = await new TestAppProvider().provide(moduleFixture); - await app.init(); - }); - - afterEach(async () => { - await app.close(); - }); - - it('returns 201 for a valid signature for module on given Safe', async () => { - safeRepositoryMock.getSafesByModule.mockResolvedValue({ - safes: [getAddress(safeAddress)], - }); - - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safeAddress}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(201); - }); - - it('returns 403 for a valid signature for module not on given Safe', async () => { - safeRepositoryMock.getSafesByModule.mockResolvedValue({ - safes: [], - }); - - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safeAddress}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(403); - }); - - it('returns 403 for an invalid signature', async () => { - const invalidSignature = await signer.signMessage({ - message: 'some invalid message', - }); - - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safeAddress}`) - .set('Safe-Wallet-Signature', invalidSignature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 if the moduleAddress is missing from payload', async () => { - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safeAddress}`) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - signer: signer.address, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 if the signature is missing from payload', async () => { - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safeAddress}`) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 if the timestamp is missing from payload', async () => { - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safeAddress}`) - .set('Safe-Wallet-Signature', signature) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 on routes without chain id', async () => { - await request(app.getHttpServer()) - .post(`/test/invalid/1/chains/${safeAddress}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 on routes without safeAddress', async () => { - await request(app.getHttpServer()) - .post(`/test/invalid/2/${chainId}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - signer: signer.address, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); - - it('returns 403 if the signer is missing from the payload', async () => { - await request(app.getHttpServer()) - .post(`/test/${chainId}/${safeAddress}`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - moduleAddress, - }) - .expect(403) - .expect({ - message: 'Forbidden resource', - error: 'Forbidden', - statusCode: 403, - }); - }); -}); diff --git a/src/routes/recovery/guards/enable-recovery-alerts.guard.ts b/src/routes/recovery/guards/enable-recovery-alerts.guard.ts deleted file mode 100644 index 793fd2a463..0000000000 --- a/src/routes/recovery/guards/enable-recovery-alerts.guard.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { - CanActivate, - ExecutionContext, - Inject, - Injectable, -} from '@nestjs/common'; -import { ILoggingService, LoggingService } from '@/logging/logging.interface'; -import { getAddress, verifyMessage } from 'viem'; -import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; - -/** - * The EnableRecoveryAlertsGuard guard should be used on routes that require - * authenticated actions for enabling recovery alerts. - * - * This guard therefore validates if the message came from the specified signer. - * - * The following message should be signed: - * enable-recovery-alerts-${chainId}-${safeAddress}-${moduleAddress}-${signer}-${timestamp} - * - * (where ${} represents placeholder values for the respective data) - * - * To use this guard, the route should have: - * - the 'chainId' as part of the path parameters - * - the 'safeAddress' as part of the path parameters - * - the 'moduleAddress' as part of the JSON body (top level) - * - the 'signer' as part of the JSON body (top level) - * - the 'Safe-Wallet-Signature' header set to the signature - * - the 'Safe-Wallet-Signature-Timestamp' header set to the signature timestamp - */ -@Injectable() -export class EnableRecoveryAlertsGuard implements CanActivate { - constructor( - @Inject(ISafeRepository) private readonly safeRepository: ISafeRepository, - @Inject(LoggingService) private readonly loggingService: ILoggingService, - ) {} - - private static readonly ACTION_PREFIX = 'enable-recovery-alerts'; - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - - const chainId = request.params['chainId']; - const safeAddress = request.params['safeAddress']; - const moduleAddress = request.body['moduleAddress']; - const signer = request.body['signer']; - const signature = request.headers['safe-wallet-signature']; - const timestamp = request.headers['safe-wallet-signature-timestamp']; - - // Required fields - if ( - !chainId || - !safeAddress || - !moduleAddress || - !signer || - !signature || - !timestamp - ) - return false; - - const message = `${EnableRecoveryAlertsGuard.ACTION_PREFIX}-${chainId}-${safeAddress}-${moduleAddress}-${signer}-${timestamp}`; - - try { - const isValid = await verifyMessage({ - address: signer, - message, - signature, - }); - - if (!isValid) { - return false; - } - - const { safes } = await this.safeRepository.getSafesByModule({ - chainId, - moduleAddress, - }); - - return safes.some((safe) => getAddress(safe) === getAddress(safeAddress)); - } catch (e) { - this.loggingService.debug(e); - return false; - } - } -} diff --git a/src/routes/recovery/recovery.controller.spec.ts b/src/routes/recovery/recovery.controller.spec.ts index 016c66f70a..60bd648d3d 100644 --- a/src/routes/recovery/recovery.controller.spec.ts +++ b/src/routes/recovery/recovery.controller.spec.ts @@ -40,6 +40,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 { 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'; describe('Recovery (Unit)', () => { let app: INestApplication; @@ -48,6 +52,7 @@ describe('Recovery (Unit)', () => { let alertsProject: string; let safeConfigUrl: string; let networkService: jest.MockedObjectDeep; + let jwtService: IJwtService; beforeEach(async () => { jest.resetAllMocks(); @@ -89,6 +94,7 @@ describe('Recovery (Unit)', () => { alertsProject = configurationService.get('alerts-api.project'); safeConfigUrl = configurationService.get('safeConfig.baseUri'); networkService = moduleFixture.get(NetworkService); + jwtService = moduleFixture.get(IJwtService); app = await new TestAppProvider().provide(moduleFixture); await app.init(); @@ -105,13 +111,14 @@ describe('Recovery (Unit)', () => { describe('POST add recovery module for a Safe', () => { it('Success', async () => { const addRecoveryModuleDto = addRecoveryModuleDtoBuilder().build(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); const chain = chainBuilder().build(); - const safe = safeBuilder().with('owners', [signer.address]).build(); - const timestamp = jest.now(); - const message = `enable-recovery-alerts-${chain.chainId}-${safe.address}-${addRecoveryModuleDto.moduleAddress}-${signer.address}-${timestamp}`; - const signature = await signer.signMessage({ message }); + 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 }) => { if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { @@ -142,73 +149,102 @@ describe('Recovery (Unit)', () => { await request(app.getHttpServer()) .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/recovery`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - ...addRecoveryModuleDto, - signer: signer.address, - }) + .set('Cookie', [`access_token=${accessToken}`]) + .send(addRecoveryModuleDto) .expect(200); }); - it('should prevent requests for modules not on specified Safe', async () => { + it('should return 403 if no token is present', async () => { const addRecoveryModuleDto = addRecoveryModuleDtoBuilder().build(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); const chain = chainBuilder().build(); - const safe = safeBuilder().with('owners', [signer.address]).build(); - const timestamp = jest.now(); - const message = `enable-recovery-alerts-${chain.chainId}-${safe.address}-${addRecoveryModuleDto.moduleAddress}-${signer.address}-${timestamp}`; - const signature = await signer.signMessage({ message }); + const safe = safeBuilder().build(); - networkService.get.mockImplementation(({ url }) => { - if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { - return Promise.resolve({ status: 200, data: chain }); - } - if ( - url === `${chain.transactionService}/api/v1/safes/${safe.address}` - ) { - return Promise.resolve({ status: 200, data: safe }); - } - if ( - url === - `${chain.transactionService}/api/v1/modules/${addRecoveryModuleDto.moduleAddress}/safes/` - ) { - return Promise.resolve({ - status: 200, - data: { safes: [] }, - }); - } - return Promise.reject(`No matching rule for url: ${url}`); + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/recovery`) + .send(addRecoveryModuleDto) + .expect(403); + + expect(networkService.get).not.toHaveBeenCalled(); + expect(networkService.post).not.toHaveBeenCalled(); + }); + + it('should return 403 if token is not a JWT', async () => { + const addRecoveryModuleDto = addRecoveryModuleDtoBuilder().build(); + const chain = chainBuilder().build(); + const safe = safeBuilder().build(); + const accessToken = faker.string.alphanumeric(); + + expect(() => jwtService.verify(accessToken)).toThrow('jwt malformed'); + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/recovery`) + .set('Cookie', [`access_token=${accessToken}`]) + .send(addRecoveryModuleDto) + .expect(403); + + expect(networkService.get).not.toHaveBeenCalled(); + expect(networkService.post).not.toHaveBeenCalled(); + }); + + it('should return 403 if token is not yet valid', async () => { + const addRecoveryModuleDto = addRecoveryModuleDtoBuilder().build(); + 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), }); - networkService.post.mockImplementation(({ url }) => - url === - `${alertsUrl}/api/v1/account/${alertsAccount}/project/${alertsProject}/address` - ? Promise.resolve({ status: 200, data: {} }) - : Promise.reject(`No matching rule for url: ${url}`), - ); + expect(() => jwtService.verify(accessToken)).toThrow('jwt not active'); await request(app.getHttpServer()) .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/recovery`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - ...addRecoveryModuleDto, - signer: signer.address, - }) + .set('Cookie', [`access_token=${accessToken}`]) + .send(addRecoveryModuleDto) .expect(403); + + expect(networkService.get).not.toHaveBeenCalled(); + expect(networkService.post).not.toHaveBeenCalled(); }); - it('should prevent requests older than 5 minutes', async () => { + it('should return 403 if token has expired', async () => { const addRecoveryModuleDto = addRecoveryModuleDtoBuilder().build(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); const chain = chainBuilder().build(); - const safe = safeBuilder().with('owners', [signer.address]).build(); - const timestamp = jest.now(); - const message = `enable-recovery-alerts-${chain.chainId}-${safe.address}-${addRecoveryModuleDto.moduleAddress}-${signer.address}-${timestamp}`; - const signature = await signer.signMessage({ message }); + 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}/recovery`) + .set('Cookie', [`access_token=${accessToken}`]) + .send(addRecoveryModuleDto) + .expect(403); + + expect(networkService.get).not.toHaveBeenCalled(); + expect(networkService.post).not.toHaveBeenCalled(); + }); + + it('should return 401 if chain_id does not match that of the request', async () => { + const addRecoveryModuleDto = addRecoveryModuleDtoBuilder().build(); + 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); networkService.get.mockImplementation(({ url }) => { if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { return Promise.resolve({ status: 200, data: chain }); @@ -218,41 +254,27 @@ describe('Recovery (Unit)', () => { ) { return Promise.resolve({ status: 200, data: safe }); } - if ( - url === - `${chain.transactionService}/api/v1/modules/${addRecoveryModuleDto.moduleAddress}/safes/` - ) { - return Promise.resolve({ - status: 200, - data: { safes: [safe.address] }, - }); - } return Promise.reject(`No matching rule for url: ${url}`); }); - jest.advanceTimersByTime(5 * 60 * 1000); - + expect(() => jwtService.verify(accessToken)).not.toThrow(); await request(app.getHttpServer()) .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/recovery`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - ...addRecoveryModuleDto, - signer: signer.address, - }) - .expect(403); + .set('Cookie', [`access_token=${accessToken}`]) + .send(addRecoveryModuleDto) + .expect(401); + + expect(networkService.post).not.toHaveBeenCalled(); }); - it('should prevent non-Safe owner requests', async () => { + it('should return 401 if token is not from that of a Safe owner', async () => { const addRecoveryModuleDto = addRecoveryModuleDtoBuilder().build(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); const chain = chainBuilder().build(); - const safe = safeBuilder().build(); // Signer is not an owner - const timestamp = jest.now(); - const message = `enable-recovery-alerts-${chain.chainId}-${safe.address}-${addRecoveryModuleDto.moduleAddress}-${signer.address}-${timestamp}`; - const signature = await signer.signMessage({ message }); - + const safe = safeBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .build(); + const accessToken = jwtService.sign(authPayloadDto); networkService.get.mockImplementation(({ url }) => { if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { return Promise.resolve({ status: 200, data: chain }); @@ -262,40 +284,29 @@ describe('Recovery (Unit)', () => { ) { return Promise.resolve({ status: 200, data: safe }); } - if ( - url === - `${chain.transactionService}/api/v1/modules/${addRecoveryModuleDto.moduleAddress}/safes/` - ) { - return Promise.resolve({ - status: 200, - data: { safes: [safe.address] }, - }); - } return Promise.reject(`No matching rule for url: ${url}`); }); + expect(() => jwtService.verify(accessToken)).not.toThrow(); await request(app.getHttpServer()) .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/recovery`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - ...addRecoveryModuleDto, - signer: signer.address, - }) - .expect(403); + .set('Cookie', [`access_token=${accessToken}`]) + .send(addRecoveryModuleDto) + .expect(401); + + expect(networkService.post).not.toHaveBeenCalled(); }); - it('should get a validation error', async () => { - const addRecoveryModuleDto = { - moduleAddress: faker.number.int(), // Invalid address - }; - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); + it('should return 401 if module is not enabled on the Safe', async () => { + const addRecoveryModuleDto = addRecoveryModuleDtoBuilder().build(); const chain = chainBuilder().build(); - const safe = safeBuilder().with('owners', [signer.address]).build(); - const timestamp = jest.now(); - const message = `enable-recovery-alerts-${chain.chainId}-${safe.address}-${addRecoveryModuleDto.moduleAddress}-${signer.address}-${timestamp}`; - const signature = await signer.signMessage({ message }); + 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 }) => { if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { @@ -312,7 +323,7 @@ describe('Recovery (Unit)', () => { ) { return Promise.resolve({ status: 200, - data: { safes: [safe.address] }, + data: { safes: [] }, }); } return Promise.reject(`No matching rule for url: ${url}`); @@ -326,12 +337,30 @@ describe('Recovery (Unit)', () => { await request(app.getHttpServer()) .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/recovery`) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - ...addRecoveryModuleDto, - signer: signer.address, - }) + .set('Cookie', [`access_token=${accessToken}`]) + .send(addRecoveryModuleDto) + .expect(401); + + expect(networkService.post).not.toHaveBeenCalled(); + }); + + it('should get a validation error', async () => { + const addRecoveryModuleDto = { + moduleAddress: faker.number.int(), // Invalid 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', signerAddress) + .build(); + const accessToken = jwtService.sign(authPayloadDto); + + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/recovery`) + .set('Cookie', [`access_token=${accessToken}`]) + .send(addRecoveryModuleDto) .expect(422) .expect({ statusCode: 422, @@ -341,6 +370,9 @@ describe('Recovery (Unit)', () => { path: ['moduleAddress'], message: 'Expected string, received number', }); + + expect(networkService.get).not.toHaveBeenCalled(); + expect(networkService.post).not.toHaveBeenCalled(); }); it('Should return the alerts provider error message', async () => { @@ -374,15 +406,6 @@ describe('Recovery (Unit)', () => { ) { return Promise.resolve({ status: 200, data: safe }); } - if ( - url === - `${chain.transactionService}/api/v1/modules/${addRecoveryModuleDto.moduleAddress}/safes/` - ) { - return Promise.resolve({ - status: 200, - data: { safes: [safe.address] }, - }); - } return Promise.reject(`No matching rule for url: ${url}`); }); networkService.post.mockImplementation(({ url }) => @@ -396,10 +419,7 @@ describe('Recovery (Unit)', () => { .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/recovery`) .set('Safe-Wallet-Signature', signature) .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - ...addRecoveryModuleDto, - signer: signer.address, - }); + .send(addRecoveryModuleDto); }); it('Should fail with An error occurred', async () => { @@ -432,15 +452,6 @@ describe('Recovery (Unit)', () => { ) { return Promise.resolve({ status: 200, data: safe }); } - if ( - url === - `${chain.transactionService}/api/v1/modules/${addRecoveryModuleDto.moduleAddress}/safes/` - ) { - return Promise.resolve({ - status: 200, - data: { safes: [safe.address] }, - }); - } return Promise.reject(`No matching rule for url: ${url}`); }); networkService.post.mockImplementation(({ url }) => @@ -454,23 +465,21 @@ describe('Recovery (Unit)', () => { .post(`/v1/chains/${chain.chainId}/safes/${safe.address}/recovery`) .set('Safe-Wallet-Signature', signature) .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - ...addRecoveryModuleDto, - signer: signer.address, - }); + .send(addRecoveryModuleDto); }); }); describe('DELETE remove recovery module for a Safe', () => { it('Success', async () => { - const moduleAddress = faker.finance.ethereumAddress(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); + const moduleAddress = getAddress(faker.finance.ethereumAddress()); const chain = chainBuilder().build(); - const safe = safeBuilder().with('owners', [signer.address]).build(); - const timestamp = jest.now(); - const message = `disable-recovery-alerts-${chain.chainId}-${safe.address}-${moduleAddress}-${signer.address}-${timestamp}`; - const signature = await signer.signMessage({ message }); + 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 }) => { if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { @@ -481,15 +490,6 @@ describe('Recovery (Unit)', () => { ) { return Promise.resolve({ status: 200, data: safe }); } - if ( - url === - `${chain.transactionService}/api/v1/modules/${moduleAddress}/safes/` - ) { - return Promise.resolve({ - status: 200, - data: { safes: [safe.address] }, - }); - } return Promise.reject(`No matching rule for url: ${url}`); }); networkService.delete.mockImplementation(({ url }) => @@ -503,73 +503,105 @@ describe('Recovery (Unit)', () => { .delete( `/v1/chains/${chain.chainId}/safes/${safe.address}/recovery/${moduleAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - signer: signer.address, - }) + .set('Cookie', [`access_token=${accessToken}`]) .expect(204); }); - it('should prevent requests for modules not on specified Safe', async () => { - const moduleAddress = faker.finance.ethereumAddress(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); + it('should return 403 if no token is present', async () => { + const moduleAddress = getAddress(faker.finance.ethereumAddress()); const chain = chainBuilder().build(); - const safe = safeBuilder().with('owners', [signer.address]).build(); - const timestamp = jest.now(); - const message = `disable-recovery-alerts-${chain.chainId}-${safe.address}-${moduleAddress}-${signer.address}-${timestamp}`; - const signature = await signer.signMessage({ message }); + const safe = safeBuilder().build(); - networkService.get.mockImplementation(({ url }) => { - if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { - return Promise.resolve({ status: 200, data: chain }); - } - if ( - url === `${chain.transactionService}/api/v1/safes/${safe.address}` - ) { - return Promise.resolve({ status: 200, data: safe }); - } - if ( - url === - `${chain.transactionService}/api/v1/modules/${moduleAddress}/safes/` - ) { - return Promise.resolve({ - status: 200, - data: { safes: [] }, - }); - } - return Promise.reject(`No matching rule for url: ${url}`); + await request(app.getHttpServer()) + .delete( + `/v1/chains/${chain.chainId}/safes/${safe.address}/recovery/${moduleAddress}`, + ) + .expect(403); + + expect(networkService.get).not.toHaveBeenCalled(); + expect(networkService.delete).not.toHaveBeenCalled(); + }); + + it('should return 403 if token is not a JWT', async () => { + const moduleAddress = getAddress(faker.finance.ethereumAddress()); + const chain = chainBuilder().build(); + const safe = safeBuilder().build(); + const accessToken = faker.string.alphanumeric(); + + expect(() => jwtService.verify(accessToken)).toThrow('jwt malformed'); + await request(app.getHttpServer()) + .delete( + `/v1/chains/${chain.chainId}/safes/${safe.address}/recovery/${moduleAddress}`, + ) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(403); + + expect(networkService.get).not.toHaveBeenCalled(); + expect(networkService.delete).not.toHaveBeenCalled(); + }); + + it('should return 403 if token is not yet valid', async () => { + const moduleAddress = getAddress(faker.finance.ethereumAddress()); + 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), }); - networkService.delete.mockImplementation(({ url }) => - url === - `${alertsUrl}/api/v1/account/${alertsAccount}/project/${alertsProject}/contract/${chain.chainId}/${moduleAddress}` - ? Promise.resolve({ status: 204, data: {} }) - : Promise.reject(`No matching rule for url: ${url}`), - ); + expect(() => jwtService.verify(accessToken)).toThrow('jwt not active'); await request(app.getHttpServer()) .delete( `/v1/chains/${chain.chainId}/safes/${safe.address}/recovery/${moduleAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - signer: signer.address, - }) + .set('Cookie', [`access_token=${accessToken}`]) .expect(403); + + expect(networkService.get).not.toHaveBeenCalled(); + expect(networkService.delete).not.toHaveBeenCalled(); }); - it('should prevent requests older than 5 minutes', async () => { - const moduleAddress = faker.finance.ethereumAddress(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); + it('should return 403 if token has expired', async () => { + const moduleAddress = getAddress(faker.finance.ethereumAddress()); const chain = chainBuilder().build(); - const safe = safeBuilder().with('owners', [signer.address]).build(); - const timestamp = jest.now(); - const message = `disable-recovery-alerts-${chain.chainId}-${safe.address}-${moduleAddress}-${signer.address}-${timestamp}`; - const signature = await signer.signMessage({ message }); + 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}/recovery/${moduleAddress}`, + ) + .set('Cookie', [`access_token=${accessToken}`]) + .expect(403); + + expect(networkService.get).not.toHaveBeenCalled(); + expect(networkService.delete).not.toHaveBeenCalled(); + }); + + it('should return 401 if chain_id does not match that of the request', async () => { + const moduleAddress = getAddress(faker.finance.ethereumAddress()); + 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); networkService.get.mockImplementation(({ url }) => { if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { return Promise.resolve({ status: 200, data: chain }); @@ -579,48 +611,28 @@ describe('Recovery (Unit)', () => { ) { return Promise.resolve({ status: 200, data: safe }); } - if ( - url === - `${chain.transactionService}/api/v1/modules/${moduleAddress}/safes/` - ) { - return Promise.resolve({ - status: 200, - data: { safes: [safe.address] }, - }); - } return Promise.reject(`No matching rule for url: ${url}`); }); - networkService.delete.mockImplementation(({ url }) => - url === - `${alertsUrl}/api/v1/account/${alertsAccount}/project/${alertsProject}/contract/${chain.chainId}/${moduleAddress}` - ? Promise.resolve({ status: 204, data: {} }) - : Promise.reject(`No matching rule for url: ${url}`), - ); - - jest.advanceTimersByTime(5 * 60 * 1000); + expect(() => jwtService.verify(accessToken)).not.toThrow(); await request(app.getHttpServer()) .delete( `/v1/chains/${chain.chainId}/safes/${safe.address}/recovery/${moduleAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - signer: signer.address, - }) - .expect(403); + .set('Cookie', [`access_token=${accessToken}`]) + .expect(401); + + expect(networkService.delete).not.toHaveBeenCalled(); }); - it('should prevent non-Safe owner requests', async () => { - const moduleAddress = faker.finance.ethereumAddress(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); + it('should return 401 if token is not from that of a Safe owner', async () => { + const moduleAddress = getAddress(faker.finance.ethereumAddress()); const chain = chainBuilder().build(); - const safe = safeBuilder().build(); // Signer is not an owner - const timestamp = jest.now(); - const message = `disable-recovery-alerts-${chain.chainId}-${safe.address}-${moduleAddress}-${signer.address}-${timestamp}`; - const signature = await signer.signMessage({ message }); - + const safe = safeBuilder().build(); + const authPayloadDto = authPayloadDtoBuilder() + .with('chain_id', chain.chainId) + .build(); + const accessToken = jwtService.sign(authPayloadDto); networkService.get.mockImplementation(({ url }) => { if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { return Promise.resolve({ status: 200, data: chain }); @@ -630,45 +642,30 @@ describe('Recovery (Unit)', () => { ) { return Promise.resolve({ status: 200, data: safe }); } - if ( - url === - `${chain.transactionService}/api/v1/modules/${moduleAddress}/safes/` - ) { - return Promise.resolve({ - status: 200, - data: { safes: [safe.address] }, - }); - } return Promise.reject(`No matching rule for url: ${url}`); }); - networkService.delete.mockImplementation(({ url }) => - url === - `${alertsUrl}/api/v1/account/${alertsAccount}/project/${alertsProject}/contract/${chain.chainId}/${moduleAddress}` - ? Promise.resolve({ status: 204, data: {} }) - : Promise.reject(`No matching rule for url: ${url}`), - ); + expect(() => jwtService.verify(accessToken)).not.toThrow(); await request(app.getHttpServer()) .delete( `/v1/chains/${chain.chainId}/safes/${safe.address}/recovery/${moduleAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - signer: signer.address, - }) - .expect(403); + .set('Cookie', [`access_token=${accessToken}`]) + .expect(401); + + expect(networkService.delete).not.toHaveBeenCalled(); }); it('Should return the alerts provider error message', async () => { - const moduleAddress = faker.finance.ethereumAddress(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); + const moduleAddress = getAddress(faker.finance.ethereumAddress()); const chain = chainBuilder().build(); - const safe = safeBuilder().with('owners', [signer.address]).build(); - const timestamp = jest.now(); - const message = `disable-recovery-alerts-${chain.chainId}-${safe.address}-${moduleAddress}-${signer.address}-${timestamp}`; - const signature = await signer.signMessage({ message }); + 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 error = new NetworkResponseError( new URL( `${alertsUrl}/api/v1/account/${alertsAccount}/project/${alertsProject}/contract/${chain.chainId}/${moduleAddress}`, @@ -691,15 +688,6 @@ describe('Recovery (Unit)', () => { ) { return Promise.resolve({ status: 200, data: safe }); } - if ( - url === - `${chain.transactionService}/api/v1/modules/${moduleAddress}/safes/` - ) { - return Promise.resolve({ - status: 200, - data: { safes: [safe.address] }, - }); - } return Promise.reject(`No matching rule for url: ${url}`); }); networkService.delete.mockImplementation(({ url }) => @@ -713,11 +701,7 @@ describe('Recovery (Unit)', () => { .delete( `/v1/chains/${chain.chainId}/safes/${safe.address}/recovery/${moduleAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - signer: signer.address, - }) + .set('Cookie', [`access_token=${accessToken}`]) .expect(400) .expect({ message: 'Malformed body', @@ -726,14 +710,15 @@ describe('Recovery (Unit)', () => { }); it('Should fail with An error occurred', async () => { - const moduleAddress = faker.finance.ethereumAddress(); - const privateKey = generatePrivateKey(); - const signer = privateKeyToAccount(privateKey); + const moduleAddress = getAddress(faker.finance.ethereumAddress()); const chain = chainBuilder().build(); - const safe = safeBuilder().with('owners', [signer.address]).build(); - const timestamp = jest.now(); - const message = `disable-recovery-alerts-${chain.chainId}-${safe.address}-${moduleAddress}-${signer.address}-${timestamp}`; - const signature = await signer.signMessage({ message }); + 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 statusCode = faker.internet.httpStatusCode({ types: ['clientError', 'serverError'], }); @@ -755,15 +740,6 @@ describe('Recovery (Unit)', () => { ) { return Promise.resolve({ status: 200, data: safe }); } - if ( - url === - `${chain.transactionService}/api/v1/modules/${moduleAddress}/safes/` - ) { - return Promise.resolve({ - status: 200, - data: { safes: [safe.address] }, - }); - } return Promise.reject(`No matching rule for url: ${url}`); }); networkService.delete.mockImplementation(({ url }) => @@ -777,11 +753,7 @@ describe('Recovery (Unit)', () => { .delete( `/v1/chains/${chain.chainId}/safes/${safe.address}/recovery/${moduleAddress}`, ) - .set('Safe-Wallet-Signature', signature) - .set('Safe-Wallet-Signature-Timestamp', timestamp.toString()) - .send({ - signer: signer.address, - }) + .set('Cookie', [`access_token=${accessToken}`]) .expect(statusCode); }); }); diff --git a/src/routes/recovery/recovery.controller.ts b/src/routes/recovery/recovery.controller.ts index 65cdb2d666..6b1c04e653 100644 --- a/src/routes/recovery/recovery.controller.ts +++ b/src/routes/recovery/recovery.controller.ts @@ -11,10 +11,10 @@ import { ApiTags } from '@nestjs/swagger'; import { AddRecoveryModuleDto } from '@/routes/recovery/entities/add-recovery-module.dto.entity'; import { RecoveryService } from '@/routes/recovery/recovery.service'; import { AddRecoveryModuleDtoSchema } from '@/routes/recovery/entities/schemas/add-recovery-module.dto.schema'; -import { EnableRecoveryAlertsGuard } from '@/routes/recovery/guards/enable-recovery-alerts.guard'; -import { OnlySafeOwnerGuard } from '@/routes/common/guards/only-safe-owner.guard'; -import { TimestampGuard } from '@/routes/email/guards/timestamp.guard'; -import { DisableRecoveryAlertsGuard } from '@/routes/recovery/guards/disable-recovery-alerts.guard'; +import { AuthGuard } from '@/routes/auth/guards/auth.guard'; +import { Auth } from '@/routes/auth/decorators/auth.decorator'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; +import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; import { ValidationPipe } from '@/validation/pipes/validation.pipe'; @ApiTags('recovery') @@ -27,38 +27,39 @@ export class RecoveryController { @HttpCode(200) @Post() - @UseGuards( - EnableRecoveryAlertsGuard, - TimestampGuard(5 * 60 * 1000), // 5 minutes - OnlySafeOwnerGuard, - ) + @UseGuards(AuthGuard) async addRecoveryModule( @Param('chainId') chainId: string, - @Param('safeAddress') safeAddress: string, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, @Body(new ValidationPipe(AddRecoveryModuleDtoSchema)) addRecoveryModuleDto: AddRecoveryModuleDto, + @Auth() authPayload: AuthPayload, ): Promise { return this.recoveryService.addRecoveryModule({ chainId, safeAddress, addRecoveryModuleDto, + authPayload, }); } @HttpCode(204) @Delete('/:moduleAddress') - @UseGuards( - DisableRecoveryAlertsGuard, - TimestampGuard(5 * 60 * 1000), // 5 minutes - OnlySafeOwnerGuard, - ) + @UseGuards(AuthGuard) async deleteRecoveryModule( @Param('chainId') chainId: string, - @Param('moduleAddress') moduleAddress: string, + @Param('moduleAddress', new ValidationPipe(AddressSchema)) + moduleAddress: `0x${string}`, + @Param('safeAddress', new ValidationPipe(AddressSchema)) + safeAddress: `0x${string}`, + @Auth() authPayload: AuthPayload, ): Promise { return this.recoveryService.deleteRecoveryModule({ chainId, moduleAddress, + safeAddress, + authPayload, }); } } diff --git a/src/routes/recovery/recovery.module.ts b/src/routes/recovery/recovery.module.ts index 967fbef033..585f66100f 100644 --- a/src/routes/recovery/recovery.module.ts +++ b/src/routes/recovery/recovery.module.ts @@ -3,9 +3,10 @@ import { RecoveryController } from '@/routes/recovery/recovery.controller'; import { RecoveryService } from '@/routes/recovery/recovery.service'; import { AlertsDomainModule } from '@/domain/alerts/alerts.domain.module'; import { SafeRepositoryModule } from '@/domain/safe/safe.repository.interface'; +import { AuthRepositoryModule } from '@/domain/auth/auth.repository.interface'; @Module({ - imports: [AlertsDomainModule, SafeRepositoryModule], + imports: [AlertsDomainModule, SafeRepositoryModule, AuthRepositoryModule], controllers: [RecoveryController], providers: [RecoveryService], }) diff --git a/src/routes/recovery/recovery.service.ts b/src/routes/recovery/recovery.service.ts index 6c48d3b8b8..f6670de5d5 100644 --- a/src/routes/recovery/recovery.service.ts +++ b/src/routes/recovery/recovery.service.ts @@ -1,21 +1,62 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; import { IAlertsRepository } from '@/domain/alerts/alerts.repository.interface'; import { AlertsRepository } from '@/domain/alerts/alerts.repository'; import { AddRecoveryModuleDto } from '@/routes/recovery/entities/add-recovery-module.dto.entity'; import { AlertsRegistration } from '@/domain/alerts/entities/alerts-registration.entity'; +import { AuthPayload } from '@/domain/auth/entities/auth-payload.entity'; +import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; @Injectable() export class RecoveryService { constructor( @Inject(IAlertsRepository) private readonly alertsRepository: AlertsRepository, + @Inject(ISafeRepository) + private readonly safeRepository: ISafeRepository, ) {} async addRecoveryModule(args: { chainId: string; - safeAddress: string; + safeAddress: `0x${string}`; addRecoveryModuleDto: AddRecoveryModuleDto; + authPayload: AuthPayload; }): Promise { + if ( + !args.authPayload.isForChain(args.chainId) || + !args.authPayload.signer_address + ) { + 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.authPayload.signer_address, + }) + // Swallow error to avoid leaking information + .catch(() => false); + if (!isOwner) { + throw new UnauthorizedException(); + } + + // After after owner check to avoid unnecessary request + const isEnabled = await this.safeRepository + .getSafesByModule({ + chainId: args.chainId, + moduleAddress: args.addRecoveryModuleDto.moduleAddress, + }) + .then(({ safes }) => { + return safes.some((safe) => safe === args.safeAddress); + }) + + // Swallow error to avoid leaking information + .catch(() => false); + if (!isEnabled) { + throw new UnauthorizedException(); + } + const contract: AlertsRegistration = { chainId: args.chainId, address: args.addRecoveryModuleDto.moduleAddress, @@ -26,8 +67,30 @@ export class RecoveryService { async deleteRecoveryModule(args: { chainId: string; - moduleAddress: string; + moduleAddress: `0x${string}`; + safeAddress: `0x${string}`; + authPayload: AuthPayload; }): Promise { + if ( + !args.authPayload.isForChain(args.chainId) || + !args.authPayload.signer_address + ) { + 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.authPayload.signer_address, + }) + // Swallow error to avoid leaking information + .catch(() => false); + if (!isOwner) { + throw new UnauthorizedException(); + } + await this.alertsRepository.deleteContract({ chainId: args.chainId, address: args.moduleAddress, diff --git a/src/routes/relay/entities/relay.legacy.dto.entity.ts b/src/routes/relay/entities/relay.legacy.dto.entity.ts deleted file mode 100644 index 246a68e651..0000000000 --- a/src/routes/relay/entities/relay.legacy.dto.entity.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { AddressSchema } from '@/validation/entities/schemas/address.schema'; -import { HexSchema } from '@/validation/entities/schemas/hex.schema'; -import { NumericStringSchema } from '@/validation/entities/schemas/numeric-string.schema'; -import { z } from 'zod'; - -export class RelayLegacyDto implements z.infer { - chainId!: string; - to!: `0x${string}`; - data!: `0x${string}`; - gasLimit!: string | null; -} - -export const RelayLegacyDtoSchema = z.object({ - chainId: NumericStringSchema, - to: AddressSchema, - data: HexSchema, - gasLimit: z.string().nullish().default(null), -}); diff --git a/src/routes/relay/entities/schemas/__tests__/relay.legacy.dto.schema.spec.ts b/src/routes/relay/entities/schemas/__tests__/relay.legacy.dto.schema.spec.ts deleted file mode 100644 index b231ef94bf..0000000000 --- a/src/routes/relay/entities/schemas/__tests__/relay.legacy.dto.schema.spec.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { RelayLegacyDtoSchema } from '@/routes/relay/entities/relay.legacy.dto.entity'; -import { faker } from '@faker-js/faker'; -import { getAddress } from 'viem'; - -describe('RelayLegacyDtoSchema', () => { - it('should validate a valid legacy relay DTO with a gasLimit', () => { - const relayLegacyDto = { - chainId: faker.string.numeric(), - to: getAddress(faker.finance.ethereumAddress()), - data: faker.string.hexadecimal(), - gasLimit: faker.string.numeric(), - }; - - const result = RelayLegacyDtoSchema.safeParse(relayLegacyDto); - - expect(result.success && result.data.chainId).toBe(relayLegacyDto.chainId); - expect(result.success && result.data.to).toBe(relayLegacyDto.to); - expect(result.success && result.data.data).toBe(relayLegacyDto.data); - expect(result.success && result.data.gasLimit).toBe( - relayLegacyDto.gasLimit, - ); - }); - - it('should validate a valid legacy relay DTO without a gasLimit and coerce it to null', () => { - const relayLegacyDto = { - chainId: faker.string.numeric(), - to: getAddress(faker.finance.ethereumAddress()), - data: faker.string.hexadecimal(), - }; - - const result = RelayLegacyDtoSchema.safeParse(relayLegacyDto); - - expect(result.success && result.data.chainId).toBe(relayLegacyDto.chainId); - expect(result.success && result.data.to).toBe(relayLegacyDto.to); - expect(result.success && result.data.data).toBe(relayLegacyDto.data); - expect(result.success && result.data.gasLimit).toBeNull(); // Coerced to null - }); - - it('should checksum a non-checksummed to address', () => { - const relayLegacyDto = { - chainId: faker.string.numeric(), - to: faker.finance.ethereumAddress().toLowerCase(), - data: faker.string.hexadecimal(), - }; - - const result = RelayLegacyDtoSchema.safeParse(relayLegacyDto); - - expect(result.success && result.data.to).toBe( - getAddress(relayLegacyDto.to), - ); - }); - - it('should throw for a non-numeric chainId', () => { - const relayLegacyDto = { - chainId: faker.string.alpha(), - to: getAddress(faker.finance.ethereumAddress()), - data: faker.string.hexadecimal(), - }; - - const result = RelayLegacyDtoSchema.safeParse(relayLegacyDto); - - expect(!result.success && result.error.issues).toStrictEqual([ - { - code: 'custom', - message: 'Invalid base-10 numeric string', - path: ['chainId'], - }, - ]); - }); - - it('should throw for a non-address to address', () => { - const relayLegacyDto = { - chainId: faker.string.numeric(), - to: faker.string.numeric(), - data: faker.string.hexadecimal(), - }; - - const result = RelayLegacyDtoSchema.safeParse(relayLegacyDto); - - expect(!result.success && result.error.issues).toStrictEqual([ - { - code: 'custom', - message: 'Invalid address', - path: ['to'], - }, - ]); - }); - - it('should throw for non-hex data', () => { - const relayLegacyDto = { - chainId: faker.string.numeric(), - to: getAddress(faker.finance.ethereumAddress()), - data: faker.string.numeric(), - }; - - const result = RelayLegacyDtoSchema.safeParse(relayLegacyDto); - - expect(!result.success && result.error.issues).toStrictEqual([ - { - code: 'custom', - message: 'Invalid "0x" notated hex string', - path: ['data'], - }, - ]); - }); - - it('should throw for an invalid gasLimit', () => { - const relayLegacyDto = { - chainId: faker.string.numeric(), - to: getAddress(faker.finance.ethereumAddress()), - data: faker.string.hexadecimal(), - gasLimit: faker.number.int(), - }; - - const result = RelayLegacyDtoSchema.safeParse(relayLegacyDto); - - expect(!result.success && result.error.issues).toStrictEqual([ - { - code: 'invalid_type', - expected: 'string', - message: 'Expected string, received number', - path: ['gasLimit'], - received: 'number', - }, - ]); - }); -}); diff --git a/src/routes/relay/entities/schemas/relay.dto.schema.ts b/src/routes/relay/entities/schemas/relay.dto.schema.ts index 72703f6380..2c4aa48649 100644 --- a/src/routes/relay/entities/schemas/relay.dto.schema.ts +++ b/src/routes/relay/entities/schemas/relay.dto.schema.ts @@ -3,18 +3,12 @@ import * as semver from 'semver'; import { AddressSchema } from '@/validation/entities/schemas/address.schema'; import { HexSchema } from '@/validation/entities/schemas/hex.schema'; -// TODO: Remove default when legacy support is removed -const LEGACY_SUPPORTED_VERSION = '1.3.0'; - export const RelayDtoSchema = z.object({ to: AddressSchema, data: HexSchema, - version: z - .string() - .refine((value) => semver.parse(value) !== null, { - message: 'Invalid semver string', - }) - .default(LEGACY_SUPPORTED_VERSION), + version: z.string().refine((value) => semver.parse(value) !== null, { + message: 'Invalid semver string', + }), gasLimit: z .string() .optional() diff --git a/src/routes/relay/relay.controller.module.ts b/src/routes/relay/relay.controller.module.ts index b75730f445..dd0514fb70 100644 --- a/src/routes/relay/relay.controller.module.ts +++ b/src/routes/relay/relay.controller.module.ts @@ -2,11 +2,10 @@ import { Module } from '@nestjs/common'; import { RelayDomainModule } from '@/domain/relay/relay.domain.module'; import { RelayService } from '@/routes/relay/relay.service'; import { RelayController } from '@/routes/relay/relay.controller'; -import { RelayLegacyController } from '@/routes/relay/relay.legacy.controller'; @Module({ imports: [RelayDomainModule], providers: [RelayService], - controllers: [RelayController, RelayLegacyController], + controllers: [RelayController], }) export class RelayControllerModule {} diff --git a/src/routes/relay/relay.controller.spec.ts b/src/routes/relay/relay.controller.spec.ts index 7b75e1500a..22deb8adfc 100644 --- a/src/routes/relay/relay.controller.spec.ts +++ b/src/routes/relay/relay.controller.spec.ts @@ -20,7 +20,7 @@ import { INestApplication } from '@nestjs/common'; import { faker } from '@faker-js/faker'; import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; -import { Hex, getAddress } from 'viem'; +import { getAddress } from 'viem'; import { addOwnerWithThresholdEncoder, changeThresholdEncoder, @@ -137,7 +137,7 @@ describe('Relay controller', () => { const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('value', faker.number.bigInt()) - .encode() as Hex; + .encode(); const taskId = faker.string.uuid(); networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -177,7 +177,7 @@ describe('Relay controller', () => { const safe = safeBuilder().build(); const safeAddress = getAddress(safe.address); const gasLimit = faker.string.numeric({ exclude: '0' }); - const data = execTransactionEncoder().encode() as Hex; + const data = execTransactionEncoder().encode(); const taskId = faker.string.uuid(); networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -262,7 +262,7 @@ describe('Relay controller', () => { const safe = safeBuilder().build(); const data = execTransactionEncoder() .with('data', execTransactionData) - .encode() as Hex; + .encode(); const taskId = faker.string.uuid(); networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -312,7 +312,7 @@ describe('Relay controller', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('data', execTransactionEncoder().encode()) - .encode() as Hex; + .encode(); const taskId = faker.string.uuid(); networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -416,65 +416,6 @@ describe('Relay controller', () => { }); }, ); - - it('should otherwise default to version 1.3.0', async () => { - const version = '1.3.0'; - const chain = chainBuilder().with('chainId', chainId).build(); - const safe = safeBuilder().build(); - const safeAddress = getAddress(safe.address); - const transactions = [ - execTransactionEncoder() - .with('data', addOwnerWithThresholdEncoder().encode()) - .encode(), - execTransactionEncoder() - .with('data', changeThresholdEncoder().encode()) - .encode(), - ].map((data) => ({ - operation: faker.number.int({ min: 0, max: 1 }), - data, - to: safeAddress, - value: faker.number.bigInt(), - })); - const data = multiSendEncoder() - .with('transactions', multiSendTransactionsEncoder(transactions)) - .encode(); - const to = getMultiSendCallOnlyDeployment({ - version, - network: chainId, - })!.networkAddresses[chainId]; - const taskId = faker.string.uuid(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/safes/${safeAddress}`: - // Official mastercopy - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - networkService.post.mockImplementation(({ url }) => { - switch (url) { - case `${relayUrl}/relays/v2/sponsored-call`: - return Promise.resolve({ data: { taskId }, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .post(`/v1/chains/${chain.chainId}/relay`) - .send({ - // No version - to, - data, - }) - .expect(201) - .expect({ - taskId, - }); - }); }); describe('MultiSend', () => { @@ -543,66 +484,6 @@ describe('Relay controller', () => { }); }, ); - - // TODO: Remove when legacy support is removed - it('should otherwise default to version 1.3.0', async () => { - const version = '1.3.0'; - const chain = chainBuilder().with('chainId', chainId).build(); - const safe = safeBuilder().build(); - const safeAddress = getAddress(safe.address); - const transactions = [ - execTransactionEncoder() - .with('data', addOwnerWithThresholdEncoder().encode()) - .encode(), - execTransactionEncoder() - .with('data', changeThresholdEncoder().encode()) - .encode(), - ].map((data) => ({ - operation: faker.number.int({ min: 0, max: 1 }), - data, - to: safeAddress, - value: faker.number.bigInt(), - })); - const data = multiSendEncoder() - .with('transactions', multiSendTransactionsEncoder(transactions)) - .encode(); - const to = getMultiSendDeployment({ - version, - network: chainId, - })!.networkAddresses[chainId]; - const taskId = faker.string.uuid(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${chain.transactionService}/api/v1/safes/${safeAddress}`: - // Official mastercopy - return Promise.resolve({ data: safe, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - networkService.post.mockImplementation(({ url }) => { - switch (url) { - case `${relayUrl}/relays/v2/sponsored-call`: - return Promise.resolve({ data: { taskId }, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .post(`/v1/chains/${chain.chainId}/relay`) - .send({ - // No version - to, - data, - }) - .expect(201) - .expect({ - taskId, - }); - }); }); describe('ProxyFactory', () => { @@ -850,78 +731,6 @@ describe('Relay controller', () => { } }, ); - - // TODO: Remove when legacy support is removed - it.each([ - [ - 'creating an official Safe', - (chainId: string): string => - getSafeSingletonDeployment({ - version: '1.3.0', - network: chainId, - })!.networkAddresses[chainId], - ], - [ - 'creating an official L2 Safe', - (chainId: string): string => - getSafeL2SingletonDeployment({ - version: '1.3.0', - network: chainId, - })!.networkAddresses[chainId], - ], - ])( - 'should otherwise default to version 1.3.0 singletons when %s', - async (_, getSingleton) => { - const version = '1.3.0'; - const singleton = getSingleton(chainId); - const chain = chainBuilder().with('chainId', chainId).build(); - const owners = [ - getAddress(faker.finance.ethereumAddress()), - getAddress(faker.finance.ethereumAddress()), - ]; - const proxyFactory = getProxyFactoryDeployment({ - version, - network: chainId, - })!.networkAddresses[chainId]; - const to = getAddress(proxyFactory); - const data = createProxyWithNonceEncoder() - .with('singleton', getAddress(singleton)) - .with( - 'initializer', - setupEncoder().with('owners', owners).encode(), - ) - .encode(); - const taskId = faker.string.uuid(); - networkService.get.mockImplementation(({ url }) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - networkService.post.mockImplementation(({ url }) => { - switch (url) { - case `${relayUrl}/relays/v2/sponsored-call`: - return Promise.resolve({ data: { taskId }, status: 200 }); - default: - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .post(`/v1/chains/${chain.chainId}/relay`) - .send({ - version, - to, - data, - }) - .expect(201) - .expect({ - taskId, - }); - }, - ); }); }); @@ -938,7 +747,7 @@ describe('Relay controller', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('value', faker.number.bigInt()) - .encode() as Hex; + .encode(); networkService.get.mockImplementation(({ url }) => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chainId}`: @@ -976,7 +785,7 @@ describe('Relay controller', () => { 'data', erc20TransferEncoder().with('to', safeAddress).encode(), ) - .encode() as Hex; + .encode(); networkService.get.mockImplementation(({ url }) => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chainId}`: @@ -1016,7 +825,7 @@ describe('Relay controller', () => { .with('recipient', safeAddress) .encode(), ) - .encode() as Hex; + .encode(); networkService.get.mockImplementation(({ url }) => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chainId}`: @@ -1057,7 +866,7 @@ describe('Relay controller', () => { .with('recipient', recipient) .encode(), ) - .encode() as Hex; + .encode(); networkService.get.mockImplementation(({ url }) => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chainId}`: @@ -1093,7 +902,7 @@ describe('Relay controller', () => { const data = execTransactionEncoder() .with('to', safeAddress) .with('data', erc20ApproveEncoder().encode()) - .encode() as Hex; + .encode(); networkService.get.mockImplementation(({ url }) => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chainId}`: @@ -1127,7 +936,7 @@ describe('Relay controller', () => { const safeAddress = faker.finance.ethereumAddress(); const data = execTransactionEncoder() .with('value', faker.number.bigInt()) - .encode() as Hex; + .encode(); networkService.get.mockImplementation(({ url }) => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chainId}`: @@ -1436,7 +1245,7 @@ describe('Relay controller', () => { const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('value', faker.number.bigInt()) - .encode() as Hex; + .encode(); const gasLimit = 'invalid'; await request(app.getHttpServer()) @@ -1501,7 +1310,7 @@ describe('Relay controller', () => { const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('value', faker.number.bigInt()) - .encode() as Hex; + .encode(); const taskId = faker.string.uuid(); networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -1678,13 +1487,15 @@ describe('Relay controller', () => { }); it('should handle both checksummed and non-checksummed addresses', async () => { + // Version supported by all contracts + const version = '1.3.0'; const chain = chainBuilder().with('chainId', chainId).build(); const safe = safeBuilder().build(); const nonChecksummedAddress = safe.address.toLowerCase(); const checksummedSafeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('value', faker.number.bigInt()) - .encode() as Hex; + .encode(); const taskId = faker.string.uuid(); networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -1716,6 +1527,7 @@ describe('Relay controller', () => { .send({ to: address, data, + version, }); } @@ -1742,7 +1554,7 @@ describe('Relay controller', () => { const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('value', faker.number.bigInt()) - .encode() as Hex; + .encode(); const taskId = faker.string.uuid(); networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -1781,12 +1593,14 @@ describe('Relay controller', () => { }); it('should return 429 if the rate limit is reached', async () => { + // Version supported by all contracts + const version = '1.3.0'; const chain = chainBuilder().with('chainId', chainId).build(); const safe = safeBuilder().build(); const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('value', faker.number.bigInt()) - .encode() as Hex; + .encode(); const taskId = faker.string.uuid(); networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -1815,6 +1629,7 @@ describe('Relay controller', () => { .send({ to: safeAddress, data, + version, }); } @@ -1823,6 +1638,7 @@ describe('Relay controller', () => { .send({ to: safeAddress, data, + version, }) .expect(429) .expect({ @@ -1833,9 +1649,11 @@ describe('Relay controller', () => { }); it('should return 503 if the relayer throws', async () => { + // Version supported by all contracts + const version = '1.3.0'; const chain = chainBuilder().with('chainId', chainId).build(); const safe = safeBuilder().build(); - const data = execTransactionEncoder().encode() as Hex; + const data = execTransactionEncoder().encode(); networkService.get.mockImplementation(({ url }) => { switch (url) { case `${safeConfigUrl}/api/v1/chains/${chainId}`: @@ -1861,6 +1679,7 @@ describe('Relay controller', () => { .send({ to: safe.address, data, + version, }) .expect(503); }); @@ -1876,12 +1695,14 @@ describe('Relay controller', () => { }); it('should not return negative limits if more requests were made than the limit', async () => { + // Version supported by all contracts + const version = '1.3.0'; const chain = chainBuilder().with('chainId', chainId).build(); const safe = safeBuilder().build(); const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() .with('value', faker.number.bigInt()) - .encode() as Hex; + .encode(); const taskId = faker.string.uuid(); networkService.get.mockImplementation(({ url }) => { switch (url) { @@ -1910,6 +1731,7 @@ describe('Relay controller', () => { .send({ to: safeAddress, data, + version, }); } diff --git a/src/routes/relay/relay.legacy.controller.spec.ts b/src/routes/relay/relay.legacy.controller.spec.ts deleted file mode 100644 index bd84a93617..0000000000 --- a/src/routes/relay/relay.legacy.controller.spec.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; -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 { INestApplication } from '@nestjs/common'; -import { faker } from '@faker-js/faker'; -import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; -import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; - -describe('Relay controller', () => { - 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(); - }); - - const supportedChainIds = Object.keys(configuration().relay.apiKey); - - describe.each(supportedChainIds)('Chain %s', (chainId) => { - describe('POST /v1/chains/:chainId/relay', () => { - it('should return 302 and redirect to the new endpoint', async () => { - const safeAddress = faker.finance.ethereumAddress(); - const data = faker.string.hexadecimal(); - - await request(app.getHttpServer()) - .post(`/v1/relay`) - .send({ - chainId, - to: safeAddress, - data, - }) - .expect(308) - .expect((res) => { - expect(res.get('location')).toBe(`/v1/chains/${chainId}/relay`); - }); - }); - }); - describe('GET /v1/relay/:chainId/:safeAddress', () => { - it('should return 302 and redirect to the new endpoint', async () => { - const safeAddress = faker.finance.ethereumAddress(); - - await request(app.getHttpServer()) - .get(`/v1/relay/${chainId}/${safeAddress}`) - .expect(301) - .expect((res) => { - expect(res.get('location')).toBe( - `/v1/chains/${chainId}/relay/${safeAddress}`, - ); - }); - }); - }); - }); -}); diff --git a/src/routes/relay/relay.legacy.controller.ts b/src/routes/relay/relay.legacy.controller.ts deleted file mode 100644 index 022e386395..0000000000 --- a/src/routes/relay/relay.legacy.controller.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { RelayLegacyDto } from '@/routes/relay/entities/relay.legacy.dto.entity'; -import { RelayLegacyDtoSchema } from '@/routes/relay/entities/relay.legacy.dto.entity'; -import { ValidationPipe } from '@/validation/pipes/validation.pipe'; -import { - Body, - Controller, - Get, - HttpStatus, - Param, - Post, - Redirect, -} from '@nestjs/common'; - -@Controller({ - version: '1', - path: 'relay', -}) -export class RelayLegacyController { - @Post() - @Redirect(undefined, HttpStatus.PERMANENT_REDIRECT) - relay( - @Body(new ValidationPipe(RelayLegacyDtoSchema)) - relayLegacyDto: RelayLegacyDto, - ): { url: string } { - return { url: `/v1/chains/${relayLegacyDto.chainId}/relay` }; - } - - @Get('/:chainId/:safeAddress') - @Redirect(undefined, HttpStatus.MOVED_PERMANENTLY) - getRelaysRemaining( - @Param('chainId') chainId: string, - @Param('safeAddress') safeAddress: string, - ): { url: string } { - return { url: `/v1/chains/${chainId}/relay/${safeAddress}` }; - } -} diff --git a/src/routes/safes/safes.controller.overview.spec.ts b/src/routes/safes/safes.controller.overview.spec.ts index d944cb70c1..7446889cc0 100644 --- a/src/routes/safes/safes.controller.overview.spec.ts +++ b/src/routes/safes/safes.controller.overview.spec.ts @@ -109,19 +109,12 @@ 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 currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [nativeCoinId]: { [currency.toLowerCase()]: 1536.75 }, + // @ts-expect-error - TODO: remove after migration + [chain.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, }; const tokenPriceProviderResponse = { [tokenAddress]: { [currency.toLowerCase()]: 12.5 }, @@ -131,6 +124,7 @@ describe('Safes Controller Overview (Unit)', () => { const multisigTransactions = [ multisigTransactionToJson( multisigTransactionBuilder() + .with('confirmationsRequired', 0) .with('confirmations', [ // Signature provided confirmationBuilder().with('owner', walletAddress).build(), @@ -164,7 +158,8 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName}`: { + // @ts-expect-error - TODO: remove after migration + case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, @@ -209,27 +204,198 @@ describe('Safes Controller Overview (Unit)', () => { ]), ); - expect(networkService.get.mock.calls.length).toBe(7); + 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( - `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, + `${chain.transactionService}/api/v1/safes/${safeInfo.address}`, ); expect(networkService.get.mock.calls[2][0].url).toBe( - `${chain.transactionService}/api/v1/safes/${safeInfo.address}`, + `${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( - `${chain.transactionService}/api/v1/safes/${safeInfo.address}/balances/`, + // @ts-expect-error - TODO: remove after migration + `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`, ); expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ - params: { trusted: false, exclude_spam: true }, + 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/token_price/${chainName}`, + `${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(); + 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', 0) + .with('confirmations', [ + // Not wallet address + confirmationBuilder() + .with('owner', getAddress(faker.finance.ethereumAddress())) + .build(), + ]) + .build(), + ), + multisigTransactionToJson( + multisigTransactionBuilder() + .with('confirmationsRequired', 0) + .with('confirmations', [ + // Not wallet address + confirmationBuilder() + .with('owner', getAddress(faker.finance.ethereumAddress())) + .build(), + ]) + .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: 0, + }, + ]), + ); + + 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(), @@ -239,14 +405,18 @@ describe('Safes Controller Overview (Unit)', () => { ].join(','), }, }); - expect(networkService.get.mock.calls[5][0].url).toBe( + expect(networkService.get.mock.calls[4][0].url).toBe( `${pricesProviderUrl}/simple/price`, ); - expect(networkService.get.mock.calls[5][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, - params: { ids: nativeCoinId, vs_currencies: currency.toLowerCase() }, + params: { + // @ts-expect-error - TODO: remove after migration + ids: chain.pricesProvider.nativeCoin, + vs_currencies: currency.toLowerCase(), + }, }); - expect(networkService.get.mock.calls[6][0].url).toBe( + expect(networkService.get.mock.calls[5][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeInfo.address}/multisig-transactions/`, ); }); @@ -313,30 +483,16 @@ describe('Safes Controller Overview (Unit)', () => { .with('token', balanceTokenBuilder().with('decimals', 17).build()) .build(), ]; - const nativeCoinId1 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain1.chainId}.nativeCoin`, - ); - const nativeCoinId2 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain2.chainId}.nativeCoin`, - ); - const chainName1 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain1.chainId}.chainName`, - ); - const chainName2 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain2.chainId}.chainName`, - ); const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [nativeCoinId1]: { [currency.toLowerCase()]: 1536.75 }, - [nativeCoinId2]: { [currency.toLowerCase()]: 1536.75 }, + // @ts-expect-error - TODO: remove after migration + [chain1.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, + // @ts-expect-error - TODO: remove after migration + [chain2.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, }; const tokenPriceProviderResponse = { [tokenAddress1]: { [currency.toLowerCase()]: 12.5 }, @@ -390,13 +546,15 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName1}`: { + // @ts-expect-error - TODO: remove after migration + case `${pricesProviderUrl}/simple/token_price/${chain1.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName2}`: { + // @ts-expect-error - TODO: remove after migration + case `${pricesProviderUrl}/simple/token_price/${chain2.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, @@ -552,30 +710,16 @@ describe('Safes Controller Overview (Unit)', () => { .with('token', balanceTokenBuilder().with('decimals', 17).build()) .build(), ]; - const nativeCoinId1 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain1.chainId}.nativeCoin`, - ); - const nativeCoinId2 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain2.chainId}.nativeCoin`, - ); - const chainName1 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain1.chainId}.chainName`, - ); - const chainName2 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain2.chainId}.chainName`, - ); const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [nativeCoinId1]: { [currency.toLowerCase()]: 1536.75 }, - [nativeCoinId2]: { [currency.toLowerCase()]: 1536.75 }, + // @ts-expect-error - TODO: remove after migration + [chain1.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, + // @ts-expect-error - TODO: remove after migration + [chain2.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, }; const tokenPriceProviderResponse = { [tokenAddress1]: { [currency.toLowerCase()]: 12.5 }, @@ -629,13 +773,15 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName1}`: { + // @ts-expect-error - TODO: remove after migration + case `${pricesProviderUrl}/simple/token_price/${chain1.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName2}`: { + // @ts-expect-error - TODO: remove after migration + case `${pricesProviderUrl}/simple/token_price/${chain2.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, @@ -749,19 +895,12 @@ 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 currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [nativeCoinId]: { [currency.toLowerCase()]: 1536.75 }, + // @ts-expect-error - TODO: remove after migration + [chain.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, }; const tokenPriceProviderResponse = { [tokenAddress]: { [currency.toLowerCase()]: 12.5 }, @@ -796,7 +935,8 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName}`: { + // @ts-expect-error - TODO: remove after migration + case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, @@ -840,27 +980,25 @@ describe('Safes Controller Overview (Unit)', () => { }, ]); - expect(networkService.get.mock.calls.length).toBe(7); + 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( - `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, - ); - 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( + expect(networkService.get.mock.calls[2][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeInfo.address}/balances/`, ); - expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[2][0].networkRequest).toStrictEqual({ params: { trusted: false, exclude_spam: true }, }); - expect(networkService.get.mock.calls[4][0].url).toBe( - `${pricesProviderUrl}/simple/token_price/${chainName}`, + 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[4][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, params: { vs_currencies: currency.toLowerCase(), @@ -870,19 +1008,23 @@ describe('Safes Controller Overview (Unit)', () => { ].join(','), }, }); - expect(networkService.get.mock.calls[5][0].url).toBe( + expect(networkService.get.mock.calls[4][0].url).toBe( `${pricesProviderUrl}/simple/price`, ); - expect(networkService.get.mock.calls[5][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, - params: { ids: nativeCoinId, vs_currencies: currency.toLowerCase() }, + params: { + // @ts-expect-error - TODO: remove after migration + ids: chain.pricesProvider.nativeCoin, + vs_currencies: currency.toLowerCase(), + }, }); - expect(networkService.get.mock.calls[6][0].url).toBe( + expect(networkService.get.mock.calls[5][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeInfo.address}/multisig-transactions/`, ); }); - it('forwards trusted and exlude spam queries', async () => { + it('forwards trusted and exclude spam queries', async () => { const chain = chainBuilder().with('chainId', '10').build(); const safeInfo = safeBuilder().build(); const tokenAddress = faker.finance.ethereumAddress(); @@ -904,19 +1046,12 @@ 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 currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [nativeCoinId]: { [currency.toLowerCase()]: 1536.75 }, + // @ts-expect-error - TODO: remove after migration + [chain.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, }; const tokenPriceProviderResponse = { [tokenAddress]: { [currency.toLowerCase()]: 12.5 }, @@ -952,7 +1087,8 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName}`: { + // @ts-expect-error - TODO: remove after migration + case `${pricesProviderUrl}/simple/token_price/${chain.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, @@ -995,28 +1131,26 @@ describe('Safes Controller Overview (Unit)', () => { }, ]); - expect(networkService.get.mock.calls.length).toBe(7); + 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( - `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, - ); - 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( + expect(networkService.get.mock.calls[2][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeInfo.address}/balances/`, ); - expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[2][0].networkRequest).toStrictEqual({ // Forwarded params params: { trusted: true, exclude_spam: false }, }); - expect(networkService.get.mock.calls[4][0].url).toBe( - `${pricesProviderUrl}/simple/token_price/${chainName}`, + 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[4][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[3][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, params: { vs_currencies: currency.toLowerCase(), @@ -1026,14 +1160,18 @@ describe('Safes Controller Overview (Unit)', () => { ].join(','), }, }); - expect(networkService.get.mock.calls[5][0].url).toBe( + expect(networkService.get.mock.calls[4][0].url).toBe( `${pricesProviderUrl}/simple/price`, ); - expect(networkService.get.mock.calls[5][0].networkRequest).toStrictEqual({ + expect(networkService.get.mock.calls[4][0].networkRequest).toStrictEqual({ headers: { 'x-cg-pro-api-key': pricesApiKey }, - params: { ids: nativeCoinId, vs_currencies: currency.toLowerCase() }, + params: { + // @ts-expect-error - TODO: remove after migration + ids: chain.pricesProvider.nativeCoin, + vs_currencies: currency.toLowerCase(), + }, }); - expect(networkService.get.mock.calls[6][0].url).toBe( + expect(networkService.get.mock.calls[5][0].url).toBe( `${chain.transactionService}/api/v1/safes/${safeInfo.address}/multisig-transactions/`, ); }); @@ -1102,30 +1240,16 @@ describe('Safes Controller Overview (Unit)', () => { .with('token', balanceTokenBuilder().with('decimals', 17).build()) .build(), ]; - const nativeCoinId1 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain1.chainId}.nativeCoin`, - ); - const nativeCoinId2 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain2.chainId}.nativeCoin`, - ); - const chainName1 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain1.chainId}.chainName`, - ); - const chainName2 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain2.chainId}.chainName`, - ); const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [nativeCoinId1]: { [currency.toLowerCase()]: 1536.75 }, - [nativeCoinId2]: { [currency.toLowerCase()]: 1536.75 }, + // @ts-expect-error - TODO: remove after migration + [chain1.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, + // @ts-expect-error - TODO: remove after migration + [chain2.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, }; const tokenPriceProviderResponse = { [tokenAddress1]: { [currency.toLowerCase()]: 12.5 }, @@ -1185,13 +1309,15 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName1}`: { + // @ts-expect-error - TODO: remove after migration + case `${pricesProviderUrl}/simple/token_price/${chain1.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName2}`: { + // @ts-expect-error - TODO: remove after migration + case `${pricesProviderUrl}/simple/token_price/${chain2.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, @@ -1312,30 +1438,16 @@ describe('Safes Controller Overview (Unit)', () => { .with('token', balanceTokenBuilder().with('decimals', 17).build()) .build(), ]; - const nativeCoinId1 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain1.chainId}.nativeCoin`, - ); - const nativeCoinId2 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain2.chainId}.nativeCoin`, - ); - const chainName1 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain1.chainId}.chainName`, - ); - const chainName2 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain2.chainId}.chainName`, - ); const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [nativeCoinId1]: { [currency.toLowerCase()]: 1536.75 }, - [nativeCoinId2]: { [currency.toLowerCase()]: 1536.75 }, + // @ts-expect-error - TODO: remove after migration + [chain1.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, + // @ts-expect-error - TODO: remove after migration + [chain2.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, }; const tokenPriceProviderResponse = { [tokenAddress1]: { [currency.toLowerCase()]: 12.5 }, @@ -1390,13 +1502,15 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName1}`: { + // @ts-expect-error - TODO: remove after migration + case `${pricesProviderUrl}/simple/token_price/${chain1.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName2}`: { + // @ts-expect-error - TODO: remove after migration + case `${pricesProviderUrl}/simple/token_price/${chain2.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, @@ -1528,30 +1642,16 @@ describe('Safes Controller Overview (Unit)', () => { .with('token', balanceTokenBuilder().with('decimals', 17).build()) .build(), ]; - const nativeCoinId1 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain1.chainId}.nativeCoin`, - ); - const nativeCoinId2 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain2.chainId}.nativeCoin`, - ); - const chainName1 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain1.chainId}.chainName`, - ); - const chainName2 = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.safe.prices.chains.${chain2.chainId}.chainName`, - ); const currency = faker.finance.currencyCode(); const nativeCoinPriceProviderResponse = { - [nativeCoinId1]: { [currency.toLowerCase()]: 1536.75 }, - [nativeCoinId2]: { [currency.toLowerCase()]: 1536.75 }, + // @ts-expect-error - TODO: remove after migration + [chain1.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, + // @ts-expect-error - TODO: remove after migration + [chain2.pricesProvider.nativeCoin!]: { + [currency.toLowerCase()]: 1536.75, + }, }; const tokenPriceProviderResponse = { [tokenAddress1]: { [currency.toLowerCase()]: 12.5 }, @@ -1605,13 +1705,15 @@ describe('Safes Controller Overview (Unit)', () => { status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName1}`: { + // @ts-expect-error - TODO: remove after migration + case `${pricesProviderUrl}/simple/token_price/${chain1.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, }); } - case `${pricesProviderUrl}/simple/token_price/${chainName2}`: { + // @ts-expect-error - TODO: remove after migration + case `${pricesProviderUrl}/simple/token_price/${chain2.pricesProvider.chainName}`: { return Promise.resolve({ data: tokenPriceProviderResponse, status: 200, diff --git a/src/routes/safes/safes.controller.ts b/src/routes/safes/safes.controller.ts index 5771236996..fd47bad51d 100644 --- a/src/routes/safes/safes.controller.ts +++ b/src/routes/safes/safes.controller.ts @@ -12,6 +12,8 @@ import { SafesService } from '@/routes/safes/safes.service'; import { SafeNonces } from '@/routes/safes/entities/nonces.entity'; import { SafeOverview } from '@/routes/safes/entities/safe-overview.entity'; import { Caip10AddressesPipe } from '@/routes/safes/pipes/caip-10-addresses.pipe'; +import { ValidationPipe } from '@/validation/pipes/validation.pipe'; +import { AddressSchema } from '@/validation/entities/schemas/address.schema'; @ApiTags('safes') @Controller({ @@ -48,8 +50,8 @@ export class SafesController { trusted: boolean, @Query('exclude_spam', new DefaultValuePipe(true), ParseBoolPipe) excludeSpam: boolean, - @Query('wallet_address') - walletAddress?: string, + @Query('wallet_address', new ValidationPipe(AddressSchema.optional())) + walletAddress?: `0x${string}`, ): Promise> { return this.service.getSafeOverview({ currency, diff --git a/src/routes/safes/safes.service.ts b/src/routes/safes/safes.service.ts index 35e24a9c95..c4f4930e25 100644 --- a/src/routes/safes/safes.service.ts +++ b/src/routes/safes/safes.service.ts @@ -132,19 +132,20 @@ export class SafesService { addresses: Array<{ chainId: string; address: string }>; trusted: boolean; excludeSpam: boolean; - walletAddress?: string; + walletAddress?: `0x${string}`; }): Promise> { const limitedSafes = args.addresses.slice(0, this.maxOverviews); const settledOverviews = await Promise.allSettled( limitedSafes.map(async ({ chainId, address }) => { + const chain = await this.chainsRepository.getChain(chainId); const [safe, balances] = await Promise.all([ this.safeRepository.getSafe({ chainId, address, }), this.balancesRepository.getBalances({ - chainId, + chain, safeAddress: address, trusted: args.trusted, fiatCode: args.currency, @@ -204,14 +205,24 @@ export class SafesService { private computeAwaitingConfirmation(args: { transactions: Array; - walletAddress: string; + walletAddress: `0x${string}`; }): number { - return args.transactions.reduce((acc, { confirmations }) => { - const isConfirmed = !!confirmations?.some((confirmation) => { - return confirmation.owner === args.walletAddress; - }); - return isConfirmed ? acc - 1 : acc; - }, args.transactions.length); + return args.transactions.reduce( + (acc, { confirmationsRequired, confirmations }) => { + const isConfirmed = + !!confirmations && confirmations.length >= confirmationsRequired; + const isSignable = + !isConfirmed && + !confirmations?.some((confirmation) => { + return confirmation.owner === args.walletAddress; + }); + if (isSignable) { + acc++; + } + return acc; + }, + 0, + ); } private toUnixTimestampInSecondsOrNull(date: Date | null): string | null { 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 85b808aa7e..cf9e0a075f 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 @@ -111,7 +111,7 @@ describe('Delete Transaction - Transactions Controller (Unit', () => { networkService.delete.mockImplementation(({ url }) => { if ( url === - `${chain.transactionService}/api/v1/transactions/${tx.safeTxHash}` + `${chain.transactionService}/api/v1/multisig-transactions/${tx.safeTxHash}` ) { return Promise.resolve({ data: {}, status: 204 }); } @@ -146,7 +146,7 @@ describe('Delete Transaction - Transactions Controller (Unit', () => { networkService.delete.mockImplementation(({ url }) => { if ( url === - `${chain.transactionService}/api/v1/transactions/${tx.safeTxHash}` + `${chain.transactionService}/api/v1/multisig-transactions/${tx.safeTxHash}` ) { return Promise.resolve({ data: {}, status: 204 }); } @@ -195,7 +195,7 @@ describe('Delete Transaction - Transactions Controller (Unit', () => { networkService.delete.mockImplementation(({ url }) => { if ( url === - `${chain.transactionService}/api/v1/transactions/${tx.safeTxHash}` + `${chain.transactionService}/api/v1/multisig-transactions/${tx.safeTxHash}` ) { return Promise.reject( new NetworkResponseError( diff --git a/src/routes/transactions/entities/transfers/erc20-transfer.entity.ts b/src/routes/transactions/entities/transfers/erc20-transfer.entity.ts index ca048a5ae6..d8c5c9d68b 100644 --- a/src/routes/transactions/entities/transfers/erc20-transfer.entity.ts +++ b/src/routes/transactions/entities/transfers/erc20-transfer.entity.ts @@ -19,6 +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; constructor( tokenAddress: `0x${string}`, @@ -28,6 +30,7 @@ export class Erc20Transfer extends Transfer { logoUri: string | null = null, decimals: number | null = null, trusted: boolean | null = null, + imitation: boolean | null = null, ) { super(TransferType.Erc20); this.tokenAddress = tokenAddress; @@ -37,6 +40,7 @@ export class Erc20Transfer extends Transfer { this.logoUri = logoUri; this.decimals = decimals; this.trusted = trusted; + this.imitation = imitation; } } diff --git a/src/routes/transactions/helpers/swap-order.helper.spec.ts b/src/routes/transactions/helpers/swap-order.helper.spec.ts index 47f3476e12..b5d853f9e5 100644 --- a/src/routes/transactions/helpers/swap-order.helper.spec.ts +++ b/src/routes/transactions/helpers/swap-order.helper.spec.ts @@ -162,12 +162,7 @@ describe('Swap Order Helper tests', () => { const error = new Error('Order not found'); swapsRepositoryMock.getOrder.mockRejectedValue(error); - await expect( - target.getOrder({ - chainId, - orderUid: orderUid as `0x${string}`, - }), - ).rejects.toThrow(error); + await expect(target.getOrder({ chainId, orderUid })).rejects.toThrow(error); expect(swapsRepositoryMock.getOrder).toHaveBeenCalledTimes(1); expect(swapsRepositoryMock.getOrder).toHaveBeenCalledWith( diff --git a/src/routes/transactions/mappers/transactions-history.mapper.ts b/src/routes/transactions/mappers/transactions-history.mapper.ts index 0705e242ce..cf8992c34e 100644 --- a/src/routes/transactions/mappers/transactions-history.mapper.ts +++ b/src/routes/transactions/mappers/transactions-history.mapper.ts @@ -1,9 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; import { groupBy } from 'lodash'; -import { CreationTransaction } from '@/domain/safe/entities/creation-transaction.entity'; -import { EthereumTransaction } from '@/domain/safe/entities/ethereum-transaction.entity'; -import { ModuleTransaction } from '@/domain/safe/entities/module-transaction.entity'; -import { MultisigTransaction } from '@/domain/safe/entities/multisig-transaction.entity'; import { Safe } from '@/domain/safe/entities/safe.entity'; import { isCreationTransaction, @@ -20,29 +16,26 @@ import { ModuleTransactionMapper } from '@/routes/transactions/mappers/module-tr import { MultisigTransactionMapper } from '@/routes/transactions/mappers/multisig-transactions/multisig-transaction.mapper'; import { TransferMapper } from '@/routes/transactions/mappers/transfers/transfer.mapper'; import { IConfigurationService } from '@/config/configuration.service.interface'; - -class TransactionDomainGroup { - timestamp!: number; - transactions!: ( - | MultisigTransaction - | ModuleTransaction - | EthereumTransaction - | CreationTransaction - )[]; -} +import { TransferImitationMapper } from '@/routes/transactions/mappers/transfers/transfer-imitation.mapper'; @Injectable() export class TransactionsHistoryMapper { + private readonly isImitationMappingEnabled: boolean; private readonly maxNestedTransfers: number; constructor( - @Inject(IConfigurationService) configurationService: IConfigurationService, + @Inject(IConfigurationService) + private readonly configurationService: IConfigurationService, private readonly multisigTransactionMapper: MultisigTransactionMapper, private readonly moduleTransactionMapper: ModuleTransactionMapper, private readonly transferMapper: TransferMapper, + private readonly transferImitationMapper: TransferImitationMapper, private readonly creationTransactionMapper: CreationTransactionMapper, ) { - this.maxNestedTransfers = configurationService.getOrThrow( + this.isImitationMappingEnabled = this.configurationService.getOrThrow( + 'features.imitationMapping', + ); + this.maxNestedTransfers = this.configurationService.getOrThrow( 'mappings.history.maxNestedTransfers', ); } @@ -54,114 +47,124 @@ export class TransactionsHistoryMapper { offset: number, timezoneOffset: number, onlyTrusted: boolean, + showImitations: boolean, ): Promise> { if (transactionsDomain.length == 0) { return []; } - const previousTransaction = this.getPreviousItem( + // Must be retrieved before mapping others as we remove it from transactionsDomain + const previousTransaction = await this.getPreviousTransaction({ offset, transactionsDomain, - ); - let prevPageTimestamp = 0; - if (previousTransaction !== null) { - prevPageTimestamp = this.getDayFromTransactionDate( - previousTransaction, - timezoneOffset, - ).getTime(); - // Remove first transaction that was requested to get previous day timestamp - transactionsDomain = transactionsDomain.slice(1); - } + chainId, + safe, + onlyTrusted, + }); - const transactionsDomainGroups = this.groupByDay( + const mappedTransactions = await this.getMappedTransactions({ transactionsDomain, + chainId, + safe, + previousTransaction, + onlyTrusted, + showImitations, + }); + + // The groups respect timezone offset – this was done for grouping only. + const transactionsByDay = this.groupByDay( + mappedTransactions, timezoneOffset, ); - - const transactionList = await Promise.all( - transactionsDomainGroups.map(async (transactionGroup) => { - const items: (TransactionItem | DateLabel)[] = []; - const groupTransactions = ( - await this.mapGroupTransactions( - transactionGroup, - chainId, - safe, - onlyTrusted, - ) - ) - .filter((x: T | undefined): x is T => x != null) - .flat(); + return transactionsByDay.reduce>( + (transactionList, transactionsOnDay) => { + // The actual value of the group should be in the UTC timezone instead + // A group should always have at least one transaction. + const { timestamp } = transactionsOnDay[0].transaction; // If the current group is a follow-up from the previous page, // or the group is empty, the date label shouldn't be added. - const isFollowUp = transactionGroup.timestamp == prevPageTimestamp; - if (!isFollowUp && groupTransactions.length) { - items.push(new DateLabel(transactionGroup.timestamp)); + const isFollowUp = + timestamp == previousTransaction?.transaction.timestamp; + if (!isFollowUp && transactionsOnDay.length > 0 && timestamp) { + transactionList.push(new DateLabel(timestamp)); } - items.push(...groupTransactions); - return items; - }), + return transactionList.concat(transactionsOnDay); + }, + [], ); - - return transactionList.flat(); } - private getTransactionTimestamp(transaction: TransactionDomain): Date { - let timestamp: Date | null; - if (isMultisigTransaction(transaction)) { - const executionDate = transaction.executionDate; - timestamp = executionDate ?? transaction.submissionDate; - } else if (isEthereumTransaction(transaction)) { - timestamp = transaction.executionDate; - } else if (isModuleTransaction(transaction)) { - timestamp = transaction.executionDate; - } else if (isCreationTransaction(transaction)) { - timestamp = transaction.created; - } else { - throw Error('Unknown transaction type'); + private async getPreviousTransaction(args: { + offset: number; + transactionsDomain: TransactionDomain[]; + chainId: string; + safe: Safe; + onlyTrusted: boolean; + }): Promise { + // More than 1 element is required to get the previous transaction + if (args.offset <= 0 || args.transactionsDomain.length <= 1) { + return; } - if (timestamp == null) { - throw Error('ExecutionDate cannot be null'); - } - return timestamp; - } + const prevDomainTransaction = args.transactionsDomain[0]; + // We map in order to filter last list item against it + const mappedPreviousTransaction = await this.mapTransaction( + prevDomainTransaction, + args.chainId, + args.safe, + args.onlyTrusted, + ); + // Remove first transaction that was requested to get previous day timestamp + args.transactionsDomain = args.transactionsDomain.slice(1); - private getPreviousItem( - offset: number, - transactions: TransactionDomain[], - ): TransactionDomain | null { - // More than 1 element is required to get the previous page date - if (offset <= 0 || transactions.length <= 1) return null; - return transactions[0]; + return Array.isArray(mappedPreviousTransaction) + ? // All transfers should have same execution date but the last is "true" previous + mappedPreviousTransaction.at(-1) + : mappedPreviousTransaction; } - private getDayFromTransactionDate( - transaction: TransactionDomain, - timezoneOffset: number, - ): Date { - const timestamp = this.getTransactionTimestamp(transaction); - return this.getDayStartForDate(timestamp, timezoneOffset); + private async getMappedTransactions(args: { + transactionsDomain: TransactionDomain[]; + chainId: string; + safe: Safe; + previousTransaction: TransactionItem | undefined; + onlyTrusted: boolean; + showImitations: boolean; + }): Promise { + const mappedTransactions = await Promise.all( + args.transactionsDomain.map((transaction) => { + return this.mapTransaction( + transaction, + args.chainId, + args.safe, + args.onlyTrusted, + ); + }), + ); + const transactionItems = mappedTransactions + .filter((x: T): x is NonNullable => x != null) + .flat(); + + if (!this.isImitationMappingEnabled) { + return transactionItems; + } + + return this.transferImitationMapper.mapImitations({ + transactions: transactionItems, + previousTransaction: args.previousTransaction, + showImitations: args.showImitations, + }); } private groupByDay( - transactions: TransactionDomain[], + transactions: TransactionItem[], timezoneOffset: number, - ): TransactionDomainGroup[] { - return Object.entries( - groupBy(transactions, (transaction) => { - return this.getDayFromTransactionDate( - transaction, - timezoneOffset, - ).getTime(); - }), - ).map(([, transactions]): TransactionDomainGroup => { - // The groups respect the timezone offset – this was done for grouping only. - // The actual value of the group should be in the UTC timezone instead - // A group should always have at least one transaction. - return { - timestamp: this.getTransactionTimestamp(transactions[0]).getTime(), - transactions: transactions, - }; + ): TransactionItem[][] { + const grouped = groupBy(transactions, ({ transaction }) => { + // timestamp will always be defined for historical transactions + const date = new Date(transaction.timestamp ?? 0); + return this.getDayStartForDate(date, timezoneOffset).getTime(); }); + return Object.values(grouped); } /** @@ -198,52 +201,40 @@ export class TransactionsHistoryMapper { ); } - private mapGroupTransactions( - transactionGroup: TransactionDomainGroup, + private async mapTransaction( + transaction: TransactionDomain, chainId: string, safe: Safe, onlyTrusted: boolean, - ): Promise<(TransactionItem | TransactionItem[] | undefined)[]> { - return Promise.all( - transactionGroup.transactions.map(async (transaction) => { - if (isMultisigTransaction(transaction)) { - return new TransactionItem( - await this.multisigTransactionMapper.mapTransaction( - chainId, - transaction, - safe, - ), - ); - } else if (isModuleTransaction(transaction)) { - return new TransactionItem( - await this.moduleTransactionMapper.mapTransaction( - chainId, - transaction, - ), - ); - } else if (isEthereumTransaction(transaction)) { - const transfers = transaction.transfers; - if (transfers != null) { - return await this.mapTransfers( - transfers, - chainId, - safe, - onlyTrusted, - ); - } - } else if (isCreationTransaction(transaction)) { - return new TransactionItem( - await this.creationTransactionMapper.mapTransaction( - chainId, - transaction, - safe, - ), - ); - } else { - // This should never happen as Zod would not allow an unknown transaction to get to this stage - throw Error('Unrecognized transaction type'); - } - }), - ); + ): Promise { + if (isMultisigTransaction(transaction)) { + return new TransactionItem( + await this.multisigTransactionMapper.mapTransaction( + chainId, + transaction, + safe, + ), + ); + } else if (isModuleTransaction(transaction)) { + return new TransactionItem( + await this.moduleTransactionMapper.mapTransaction(chainId, transaction), + ); + } else if (isEthereumTransaction(transaction)) { + const transfers = transaction.transfers; + if (transfers != null) { + return await this.mapTransfers(transfers, chainId, safe, onlyTrusted); + } + } else if (isCreationTransaction(transaction)) { + return new TransactionItem( + await this.creationTransactionMapper.mapTransaction( + chainId, + transaction, + safe, + ), + ); + } else { + // This should never happen as Zod would not allow an unknown transaction to get to this stage + throw Error('Unrecognized transaction type'); + } } } diff --git a/src/routes/transactions/mappers/transfers/transfer-imitation.mapper.ts b/src/routes/transactions/mappers/transfers/transfer-imitation.mapper.ts new file mode 100644 index 0000000000..64312afe1f --- /dev/null +++ b/src/routes/transactions/mappers/transfers/transfer-imitation.mapper.ts @@ -0,0 +1,134 @@ +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { TransactionItem } from '@/routes/transactions/entities/transaction-item.entity'; +import { + isTransferTransactionInfo, + TransferDirection, +} from '@/routes/transactions/entities/transfer-transaction-info.entity'; +import { isErc20Transfer } from '@/routes/transactions/entities/transfers/erc20-transfer.entity'; +import { Inject } from '@nestjs/common'; + +export class TransferImitationMapper { + private readonly prefixLength: number; + private readonly suffixLength: number; + + constructor( + @Inject(IConfigurationService) configurationService: IConfigurationService, + ) { + this.prefixLength = configurationService.getOrThrow( + 'mappings.imitation.prefixLength', + ); + this.suffixLength = configurationService.getOrThrow( + 'mappings.imitation.suffixLength', + ); + } + + mapImitations(args: { + transactions: Array; + previousTransaction: TransactionItem | undefined; + showImitations: boolean; + }): Array { + const transactions = this.mapTransferInfoImitation( + args.transactions, + args.previousTransaction, + ); + + if (args.showImitations) { + return transactions; + } + + 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; + } + + if ( + // Only consider transfers... + !isTransferTransactionInfo(item.transaction.txInfo) || + !isTransferTransactionInfo(prevItem.transaction.txInfo) || + // ...of ERC20s... + !isErc20Transfer(item.transaction.txInfo.transferInfo) || + !isErc20Transfer(prevItem.transaction.txInfo.transferInfo) + ) { + return item; + } + + // ...that are outgoing + const isOutgoing = + item.transaction.txInfo.direction === TransferDirection.Outgoing; + const isPrevOutgoing = + prevItem.transaction.txInfo.direction === TransferDirection.Outgoing; + if (!isOutgoing || !isPrevOutgoing) { + return item; + } + + // Imitation transfers are of the same value... + const isSameValue = + item.transaction.txInfo.transferInfo.value === + prevItem.transaction.txInfo.transferInfo.value; + if (!isSameValue) { + return item; + } + + item.transaction.txInfo.transferInfo.imitation = this.isImitatorAddress( + item.transaction.txInfo.recipient.value, + prevItem.transaction.txInfo.recipient.value, + ); + + return item; + }); + } + + private isImitatorAddress(address1: string, address2: string): boolean { + const a1 = address1.toLowerCase(); + const a2 = address2.toLowerCase(); + + // Same address is not an imitation + if (a1 === a2) { + return false; + } + + const isSamePrefix = + // Ignore `0x` prefix + a1.slice(2, 2 + this.prefixLength) === a2.slice(2, 2 + this.prefixLength); + const isSameSuffix = + a1.slice(-this.suffixLength) === a2.slice(-this.suffixLength); + return isSamePrefix && isSameSuffix; + } +} diff --git a/src/routes/transactions/transactions-history.controller.spec.ts b/src/routes/transactions/transactions-history.controller.spec.ts index 14b0fb6687..51c881e8f9 100644 --- a/src/routes/transactions/transactions-history.controller.spec.ts +++ b/src/routes/transactions/transactions-history.controller.spec.ts @@ -67,6 +67,8 @@ 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(); @@ -78,6 +80,14 @@ describe('Transactions History Controller (Unit)', () => { history: { maxNestedTransfers: 5, }, + imitation: { + prefixLength, + suffixLength, + }, + }, + features: { + ...configuration().features, + imitationMapping: true, }, }); @@ -251,10 +261,12 @@ describe('Transactions History Controller (Unit)', () => { .with('executionDate', new Date('2022-12-25T00:00:00Z')) .build(), ); - const nativeTokenTransfer = nativeTokenTransferBuilder().build(); + const nativeTokenTransfer = nativeTokenTransferBuilder() + .with('executionDate', new Date('2022-12-31T00:00:00Z')) + .build(); const incomingTransaction = ethereumTransactionToJson( ethereumTransactionBuilder() - .with('executionDate', new Date('2022-12-31T00:00:00Z')) + .with('executionDate', nativeTokenTransfer.executionDate) .with('transfers', [ nativeTokenTransferToJson(nativeTokenTransfer) as Transfer, ]) @@ -996,12 +1008,14 @@ describe('Transactions History Controller (Unit)', () => { erc20TransferBuilder() .with('tokenAddress', untrustedToken.address) .with('value', faker.string.numeric({ exclude: ['0'] })) + .with('executionDate', date) .build(), ) as Transfer, erc20TransferToJson( erc20TransferBuilder() .with('tokenAddress', trustedToken.address) .with('value', faker.string.numeric({ exclude: ['0'] })) + .with('executionDate', date) .build(), ) as Transfer, ]) @@ -1015,12 +1029,14 @@ describe('Transactions History Controller (Unit)', () => { erc20TransferBuilder() .with('tokenAddress', untrustedToken.address) .with('value', faker.string.numeric({ exclude: ['0'] })) + .with('executionDate', oneDayAfter) .build(), ) as Transfer, erc20TransferToJson( erc20TransferBuilder() .with('tokenAddress', untrustedToken.address) .with('value', faker.string.numeric({ exclude: ['0'] })) + .with('executionDate', oneDayAfter) .build(), ) as Transfer, ]) @@ -1034,6 +1050,7 @@ describe('Transactions History Controller (Unit)', () => { erc20TransferBuilder() .with('tokenAddress', trustedToken.address) .with('value', faker.string.numeric({ exclude: ['0'] })) + .with('executionDate', twoDaysAfter) .build(), ) as Transfer, ]) @@ -1335,4 +1352,1135 @@ 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('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', + ]) + .build(); + + 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', + }, + ]; + + 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/${results[0].transfers[0].tokenAddress}`; + const getTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[1].transfers[0].tokenAddress}`; + 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: results[0].transfers[0].tokenInfo, + status: 200, + }); + } + if (url === getTokenAddressUrl) { + return Promise.resolve({ + data: results[1].transfers[0].tokenInfo, + 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, + id: 'transfer_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_ef6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb44', + safeAppInfo: null, + timestamp: 1710927778000, + txInfo: { + direction: 'OUTGOING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: '0xFd737d98d9F6b566cc104Fd40aEcC449b8EaA512', + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + }, + transferInfo: { + decimals: 18, + imitation: true, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0xcDB94376E0330B13F5Becaece169602cbB14399c.png', + tokenAddress: + '0xcDB94376E0330B13F5Becaece169602cbB14399c', + tokenName: 'Arbitrum', + tokenSymbol: 'ARB', + trusted: true, + type: 'ERC20', + value: '40000000000000000000000', + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + }, + type: 'TRANSACTION', + }, + { + conflictType: 'None', + transaction: { + executionInfo: { + confirmationsRequired: 2, + confirmationsSubmitted: 2, + missingSigners: null, + nonce: 3, + type: 'MULTISIG', + }, + id: 'multisig_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', + safeAppInfo: null, + timestamp: 1710927685000, + txInfo: { + direction: 'OUTGOING', + humanDescription: 'Send 40000 ARB to 0xFd7e...A512', + 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', + }, + ], + }, + sender: { + logoUri: null, + name: null, + value: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + }, + transferInfo: { + decimals: 18, + imitation: null, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', + tokenAddress: + '0x912CE59144191C1204E64559FE8253a0e49E6548', + tokenName: 'Arbitrum', + tokenSymbol: 'ARB', + trusted: null, + type: 'ERC20', + value: '40000000000000000000000', + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + }, + type: 'TRANSACTION', + }, + ]); + }); + }); + + 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', + ]) + .build(); + + 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', + }, + ]; + + 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/${results[0].transfers[0].tokenAddress}`; + const getTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[1].transfers[0].tokenAddress}`; + 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: results[0].transfers[0].tokenInfo, + status: 200, + }); + } + if (url === getTokenAddressUrl) { + return Promise.resolve({ + data: results[1].transfers[0].tokenInfo, + 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: 2, + confirmationsSubmitted: 2, + missingSigners: null, + nonce: 3, + type: 'MULTISIG', + }, + id: 'multisig_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', + safeAppInfo: null, + timestamp: 1710927685000, + txInfo: { + direction: 'OUTGOING', + humanDescription: 'Send 40000 ARB to 0xFd7e...A512', + 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', + }, + ], + }, + sender: { + logoUri: null, + name: null, + value: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + }, + transferInfo: { + decimals: 18, + imitation: null, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', + tokenAddress: + '0x912CE59144191C1204E64559FE8253a0e49E6548', + tokenName: 'Arbitrum', + tokenSymbol: 'ARB', + trusted: null, + type: 'ERC20', + value: '40000000000000000000000', + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + }, + type: 'TRANSACTION', + }, + ]); + }); + }); + }); + + 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', + ]) + .build(); + + 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', + }, + ]; + + 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/${results[0].transfers[0].tokenAddress}`; + const getTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[1].transfers[0].tokenAddress}`; + 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: results[0].transfers[0].tokenInfo, + status: 200, + }); + } + if (url === getTokenAddressUrl) { + return Promise.resolve({ + data: results[1].transfers[0].tokenInfo, + 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, + id: 'transfer_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_ef6ab60f4e79f01e6f9615aa134725d5fe0d7222b47a441fff6233f9219593bb44', + safeAppInfo: null, + timestamp: 1710927778000, + txInfo: { + direction: 'OUTGOING', + humanDescription: null, + recipient: { + logoUri: null, + name: null, + value: '0xFd737d98d9F6b566cc104Fd40aEcC449b8EaA512', + }, + richDecodedInfo: null, + sender: { + logoUri: null, + name: null, + value: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + }, + transferInfo: { + decimals: 18, + imitation: true, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0xcDB94376E0330B13F5Becaece169602cbB14399c.png', + tokenAddress: + '0xcDB94376E0330B13F5Becaece169602cbB14399c', + tokenName: 'Arbitrum', + tokenSymbol: 'ARB', + trusted: false, + type: 'ERC20', + value: '40000000000000000000000', + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + }, + type: 'TRANSACTION', + }, + { + conflictType: 'None', + transaction: { + executionInfo: { + confirmationsRequired: 2, + confirmationsSubmitted: 2, + missingSigners: null, + nonce: 3, + type: 'MULTISIG', + }, + id: 'multisig_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', + safeAppInfo: null, + timestamp: 1710927685000, + txInfo: { + direction: 'OUTGOING', + humanDescription: 'Send 40000 ARB to 0xFd7e...A512', + 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', + }, + ], + }, + sender: { + logoUri: null, + name: null, + value: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + }, + transferInfo: { + decimals: 18, + imitation: null, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', + tokenAddress: + '0x912CE59144191C1204E64559FE8253a0e49E6548', + tokenName: 'Arbitrum', + tokenSymbol: 'ARB', + trusted: null, + type: 'ERC20', + value: '40000000000000000000000', + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + }, + type: 'TRANSACTION', + }, + ]); + }); + }); + + 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', + ]) + .build(); + + 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', + }, + ]; + + 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/${results[0].transfers[0].tokenAddress}`; + const getTokenAddressUrl = `${chain.transactionService}/api/v1/tokens/${results[1].transfers[0].tokenAddress}`; + 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: results[0].transfers[0].tokenInfo, + status: 200, + }); + } + if (url === getTokenAddressUrl) { + return Promise.resolve({ + data: results[1].transfers[0].tokenInfo, + 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: 2, + confirmationsSubmitted: 2, + missingSigners: null, + nonce: 3, + type: 'MULTISIG', + }, + id: 'multisig_0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4_0xa0772fe5d26572fa777e0b4557da9a03d208086078215245ed26502f7a7bf683', + safeAppInfo: null, + timestamp: 1710927685000, + txInfo: { + direction: 'OUTGOING', + humanDescription: 'Send 40000 ARB to 0xFd7e...A512', + 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', + }, + ], + }, + sender: { + logoUri: null, + name: null, + value: '0x9a6dE84bF23ed9ba92BDB8027037975ef181b1c4', + }, + transferInfo: { + decimals: 18, + imitation: null, + logoUri: + 'https://safe-transaction-assets.safe.global/tokens/logos/0x912CE59144191C1204E64559FE8253a0e49E6548.png', + tokenAddress: + '0x912CE59144191C1204E64559FE8253a0e49E6548', + tokenName: 'Arbitrum', + tokenSymbol: 'ARB', + trusted: null, + type: 'ERC20', + value: '40000000000000000000000', + }, + type: 'Transfer', + }, + txStatus: 'SUCCESS', + }, + type: 'TRANSACTION', + }, + ]); + }); + }); + }); + }); }); diff --git a/src/routes/transactions/transactions.controller.ts b/src/routes/transactions/transactions.controller.ts index 0323e0e48c..c8a95d0a2a 100644 --- a/src/routes/transactions/transactions.controller.ts +++ b/src/routes/transactions/transactions.controller.ts @@ -233,6 +233,8 @@ export class TransactionsController { timezoneOffsetMs: number, @Query('trusted', new DefaultValuePipe(true), ParseBoolPipe) trusted: boolean, + @Query('imitation', new DefaultValuePipe(true), ParseBoolPipe) + imitation: boolean, ): Promise> { return this.transactionsService.getTransactionHistory({ chainId, @@ -241,6 +243,7 @@ export class TransactionsController { paginationData, timezoneOffsetMs, onlyTrusted: trusted, + showImitations: imitation, }); } diff --git a/src/routes/transactions/transactions.module.ts b/src/routes/transactions/transactions.module.ts index aa5c9e8791..d47031b25e 100644 --- a/src/routes/transactions/transactions.module.ts +++ b/src/routes/transactions/transactions.module.ts @@ -25,6 +25,7 @@ import { TransactionsHistoryMapper } from '@/routes/transactions/mappers/transac import { TransferDetailsMapper } from '@/routes/transactions/mappers/transfers/transfer-details.mapper'; import { TransferInfoMapper } from '@/routes/transactions/mappers/transfers/transfer-info.mapper'; import { TransferMapper } from '@/routes/transactions/mappers/transfers/transfer.mapper'; +import { TransferImitationMapper } from '@/routes/transactions/mappers/transfers/transfer-imitation.mapper'; import { TransactionsController } from '@/routes/transactions/transactions.controller'; import { TransactionsService } from '@/routes/transactions/transactions.service'; import { SwapOrderMapperModule } from '@/routes/transactions/mappers/common/swap-order.mapper'; @@ -77,6 +78,7 @@ import { SwapOrderHelperModule } from '@/routes/transactions/helpers/swap-order. TransactionsService, TransferDetailsMapper, TransferInfoMapper, + TransferImitationMapper, HumanDescriptionMapper, ], }) diff --git a/src/routes/transactions/transactions.service.ts b/src/routes/transactions/transactions.service.ts index 0270750bf3..7e7ab7c136 100644 --- a/src/routes/transactions/transactions.service.ts +++ b/src/routes/transactions/transactions.service.ts @@ -364,6 +364,7 @@ export class TransactionsService { paginationData: PaginationData; timezoneOffsetMs: number; onlyTrusted: boolean; + showImitations: boolean; }): Promise { const paginationDataAdjusted = this.getAdjustedPaginationForHistory( args.paginationData, @@ -395,6 +396,7 @@ export class TransactionsService { args.paginationData.offset, args.timezoneOffsetMs, args.onlyTrusted, + args.showImitations, ); return { diff --git a/yarn.lock b/yarn.lock index 67df0691fc..aa0cb319b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -699,10 +699,10 @@ __metadata: languageName: node linkType: hard -"@eslint/js@npm:9.0.0": - version: 9.0.0 - resolution: "@eslint/js@npm:9.0.0" - checksum: 10/b14b20af72410ef53e3e77e7d83cc1d6e6554b0092ceb9f969d25d765f4d775b4be32b0cd99bbfd6ce72eb2e4fb6b39b42a159b31909fbe1b3a5e88d75211687 +"@eslint/js@npm:9.2.0": + version: 9.2.0 + resolution: "@eslint/js@npm:9.2.0" + checksum: 10/4e9fec5395a8f6797bfa57b28b67c3b1c63ebcaf665e457546a34a42b14ebbf992d3617a64ae65addf32ab89cd7448008a275a4d73f9bcb1829f4eae67301841 languageName: node linkType: hard @@ -720,14 +720,14 @@ __metadata: languageName: node linkType: hard -"@humanwhocodes/config-array@npm:^0.12.3": - version: 0.12.3 - resolution: "@humanwhocodes/config-array@npm:0.12.3" +"@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/b05f528c110aa1657d95d213e4ad2662f4161e838806af01a4d3f3b6ee3878d9b6f87d1b10704917f5c2f116757cb5c818480c32c4c4c6f84fe775a170b5f758 + checksum: 10/524df31e61a85392a2433bf5d03164e03da26c03d009f27852e7dcfdafbc4a23f17f021dacf88e0a7a9fe04ca032017945d19b57a16e2676d9114c22a53a9d11 languageName: node linkType: hard @@ -745,6 +745,13 @@ __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 + languageName: node + linkType: hard + "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -1918,12 +1925,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^20.12.10": - version: 20.12.10 - resolution: "@types/node@npm:20.12.10" +"@types/node@npm:^20.12.11": + version: 20.12.11 + resolution: "@types/node@npm:20.12.11" dependencies: undici-types: "npm:~5.26.4" - checksum: 10/b3ab044969880084e4da22a0173fc234239de81c22fb9418bd992de98369a3065c0c6119216476711af69ff520331cf06711acdeb1959554de6bbf0912a0494e + checksum: 10/c6afe7c2c4504a4f488814d7b306ebad16bf42cbb43bf9db9fe1aed8c5fb99235593c3be5088979a64526b106cf022256688e2f002811be8273d87dc2e0d484f languageName: node linkType: hard @@ -3866,16 +3873,17 @@ __metadata: languageName: node linkType: hard -"eslint@npm:^9.0.0": - version: 9.0.0 - resolution: "eslint@npm:9.0.0" +"eslint@npm:^9.2.0": + version: 9.2.0 + resolution: "eslint@npm:9.2.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.0.0" - "@humanwhocodes/config-array": "npm:^0.12.3" + "@eslint/js": "npm:9.2.0" + "@humanwhocodes/config-array": "npm:^0.13.0" "@humanwhocodes/module-importer": "npm:^1.0.1" + "@humanwhocodes/retry": "npm:^0.2.3" "@nodelib/fs.walk": "npm:^1.2.8" ajv: "npm:^6.12.4" chalk: "npm:^4.0.0" @@ -3891,7 +3899,6 @@ __metadata: file-entry-cache: "npm:^8.0.0" find-up: "npm:^5.0.0" glob-parent: "npm:^6.0.2" - graphemer: "npm:^1.4.0" ignore: "npm:^5.2.0" imurmurhash: "npm:^0.1.4" is-glob: "npm:^4.0.0" @@ -3906,7 +3913,7 @@ __metadata: text-table: "npm:^0.2.0" bin: eslint: bin/eslint.js - checksum: 10/5cf03e14eb114f95bc4e553c8ae2da65ec09d519779beb08e326d98518bce647ce9c8bf3467bcea4cab35a2657cc3a8e945717e784afa4b1bdb9d1ecd9173ba0 + checksum: 10/691626f7e6059966338d00bc11d232190974e10b701048fcbd2c34031ac80b6eed0e0c5612fc4e32205b56bdbf7d0be34f5c19b8f61ff655b67ad4fd2c0515d3 languageName: node linkType: hard @@ -7270,13 +7277,13 @@ __metadata: "@types/jest": "npm:29.5.12" "@types/jsonwebtoken": "npm:^9" "@types/lodash": "npm:^4.17.1" - "@types/node": "npm:^20.12.10" + "@types/node": "npm:^20.12.11" "@types/semver": "npm:^7.5.8" "@types/supertest": "npm:^6.0.2" amqp-connection-manager: "npm:^4.1.14" amqplib: "npm:^0.10.4" cookie-parser: "npm:^1.4.6" - eslint: "npm:^9.0.0" + eslint: "npm:^9.2.0" eslint-config-prettier: "npm:^9.1.0" husky: "npm:^9.0.11" jest: "npm:29.7.0" @@ -7289,7 +7296,7 @@ __metadata: redis: "npm:^4.6.13" reflect-metadata: "npm:^0.2.2" rxjs: "npm:^7.8.1" - semver: "npm:^7.6.0" + semver: "npm:^7.6.2" source-map-support: "npm:^0.5.20" supertest: "npm:^7.0.0" ts-jest: "npm:29.1.2" @@ -7298,9 +7305,9 @@ __metadata: tsconfig-paths: "npm:4.2.0" typescript: "npm:^5.4.5" typescript-eslint: "npm:^7.8.0" - viem: "npm:^2.10.1" + viem: "npm:^2.10.5" winston: "npm:^3.13.0" - zod: "npm:^3.23.6" + zod: "npm:^3.23.8" languageName: unknown linkType: soft @@ -7360,6 +7367,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.6.2": + version: 7.6.2 + resolution: "semver@npm:7.6.2" + bin: + semver: bin/semver.js + checksum: 10/296b17d027f57a87ef645e9c725bff4865a38dfc9caf29b26aa084b85820972fbe7372caea1ba6857162fa990702c6d9c1d82297cecb72d56c78ab29070d2ca2 + languageName: node + linkType: hard + "send@npm:0.18.0": version: 0.18.0 resolution: "send@npm:0.18.0" @@ -8314,9 +8330,9 @@ __metadata: languageName: node linkType: hard -"viem@npm:^2.10.1": - version: 2.10.1 - resolution: "viem@npm:2.10.1" +"viem@npm:^2.10.5": + version: 2.10.5 + resolution: "viem@npm:2.10.5" dependencies: "@adraffy/ens-normalize": "npm:1.10.0" "@noble/curves": "npm:1.2.0" @@ -8331,7 +8347,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/4c3cff33c826913e942ab663bf914dde651324cad672dd106788a765d102a92f18e981054d20cb2969d4f4c74199e230d59053f8b231b24297d831b9b82c8312 + checksum: 10/8ef40085caf77a2414c6d5d8b14c49d086534f8300d0a47645722a062deede12a0b22d8d9a18597a25f8892e4c479e7b9ebb3f7387f58aff9854fb0508e30a3b languageName: node linkType: hard @@ -8610,9 +8626,9 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.23.6": - version: 3.23.6 - resolution: "zod@npm:3.23.6" - checksum: 10/a3b0ea904f0615c67ef01ab3abc4e917d3bb87d46fc39515bcb68e59450b54f175dc9d29ddc7e13217dd456099729afa0b76609db135ca158e3ccebcac6153d9 +"zod@npm:^3.23.8": + version: 3.23.8 + resolution: "zod@npm:3.23.8" + checksum: 10/846fd73e1af0def79c19d510ea9e4a795544a67d5b34b7e1c4d0425bf6bfd1c719446d94cdfa1721c1987d891321d61f779e8236fde517dc0e524aa851a6eff1 languageName: node linkType: hard