diff --git a/.env.sample b/.env.sample index 0d866c0b1a..2d41bb1de9 100644 --- a/.env.sample +++ b/.env.sample @@ -12,16 +12,6 @@ # (default is 259200 [72 hours]) # NOT_FOUND_PRICE_TTL_SECONDS= -# Balances Provider - Valk API -# Chain ids configured to use this provider. (comma-separated numbers) -# (default='') -#FF_VALK_BALANCES_CHAIN_IDS= -# The base Valk API URL to be used. -# (default is https://merlin-api-v1.cf/api/merlin/public if none is set) -#VALK_BASE_URI= -# The API Key to be used. If none is set, balances cannot be retrieved using this provider. -#VALK_API_KEY= - # Balances Provider - Zerion API # Chain ids configured to use this provider. (comma-separated numbers) # (default='') @@ -32,6 +22,11 @@ # The API Key to be used. If none is set, balances cannot be retrieved using this provider. #ZERION_API_KEY= +# Relay Provider - Gelato API +# The API key to be used for Gnosis Chain +# FF_RELAY= +# GELATO_API_KEY_GNOSIS_CHAIN= + # The cache TTL for each token price datapoint. #BALANCES_TTL_SECONDS= diff --git a/.gitignore b/.gitignore index 424a394b9c..38a65080da 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ lerna-debug.log* # Database mounted volume data + +# ABIs +/abis diff --git a/.prettierignore b/.prettierignore index b0fc18a512..57c7875899 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,4 @@ /.yarn + +# ABIs +/abis diff --git a/Dockerfile b/Dockerfile index 4da3c7b5d0..85fb988853 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,7 @@ WORKDIR /app COPY --chown=node:node .yarn/releases ./.yarn/releases COPY --chown=node:node .yarn/patches ./.yarn/patches COPY --chown=node:node package.json yarn.lock .yarnrc.yml tsconfig*.json ./ +COPY --chown=node:node scripts/generate-abis.js ./scripts/generate-abis.js RUN --mount=type=cache,target=/root/.yarn yarn COPY --chown=node:node assets ./assets COPY --chown=node:node migrations ./migrations @@ -28,8 +29,9 @@ ARG BUILD_NUMBER ENV APPLICATION_VERSION=${VERSION} \ APPLICATION_BUILD_NUMBER=${BUILD_NUMBER} +COPY --chown=node:node --from=base /app/abis ./abis COPY --chown=node:node --from=base /app/node_modules ./node_modules COPY --chown=node:node --from=base /app/dist ./dist COPY --chown=node:node --from=base /app/assets ./assets COPY --chown=node:node --from=base /app/migrations ./migrations -CMD [ "node", "dist/main.js" ] +CMD [ "node", "dist/src/main.js" ] diff --git a/package.json b/package.json index c969fc6a02..d9be967872 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,15 @@ "private": true, "license": "MIT", "scripts": { - "build": "nest build", + "build": "yarn generate-abis && nest build", "format": "prettier --write .", "format-check": "prettier --check .", + "generate-abis": "node ./scripts/generate-abis.js", + "postinstall": "yarn generate-abis", "start": "nest start", "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", + "start:prod": "node dist/src/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint-check": "eslint \"{src,apps,libs,test}/**/*.ts\"", "test": "jest", @@ -23,7 +25,6 @@ "test:all:cov": "jest --coverage --config ./test/jest-all.json" }, "dependencies": { - "@gelatonetwork/relay-sdk": "^5.5.5", "@nestjs/cli": "^10.3.1", "@nestjs/common": "^10.3.3", "@nestjs/config": "^3.1.1", @@ -92,6 +93,7 @@ ], "testEnvironment": "node", "moduleNameMapper": { + "^@/abis/(.*)$": "/../abis/$1", "^@/(.*)$": "/../src/$1" }, "globalSetup": "/../test/global-setup.ts" diff --git a/scripts/generate-abis.js b/scripts/generate-abis.js new file mode 100644 index 0000000000..0a32fc7f6f --- /dev/null +++ b/scripts/generate-abis.js @@ -0,0 +1,83 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const path = require('path'); +const fs = require('fs'); + +/** + * This generates const TypeScript ABIs for each asset in + * `@safe-global/safe-deployments` package for `viem` to infer. + * + * Although it is possible to get a singleton programmatically and + * import the JSON directly, neither is strictly typed. + * + * Once it is possible to import JSON "as const", the deployments + * package should be updated to return the singletons as such. + * + * @see https://github.com/microsoft/TypeScript/issues/32063 + */ + +// Path to directory containing JSON assets +const assetsDir = path.join( + process.cwd(), + 'node_modules', + '@safe-global', + 'safe-deployments', + 'dist', + 'assets', +); + +// Path to directory where ABIs will be written +const outputDir = path.join(process.cwd(), 'abis', 'safe'); + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +function main() { + // Remove any existing ABIs + try { + fs.rmSync(outputDir, { recursive: true }); + } catch { + // Swallow error if directory does not exist (first run) + } + + // For each version... + for (const version of fs.readdirSync(assetsDir)) { + const versionOutputDir = path.join(outputDir, version); + + fs.mkdirSync(versionOutputDir, { recursive: true }); + + const versionDir = path.join(assetsDir, version); + + // ...parse the ABI for each asset + for (const assetFile of fs.readdirSync(versionDir)) { + // Read the asset JSON + const assetPath = path.join(assetsDir, version, assetFile); + const assetJson = fs.readFileSync(assetPath, 'utf8'); + + // Parse the asset JSON + const { contractName, abi } = JSON.parse(assetJson); + + // Write the ABI to a file + const fileName = `${contractName}.abi.ts`; + const filePath = path.join(versionOutputDir, fileName); + + // It is generally better to use the Stream API for larger files + // but as we are storing the JSON in memory, this is likely of + // minimal benefit. As this script runs on build, we need not + // worry too much about performance though. + const stream = fs.createWriteStream(filePath); + + // Write formatted ABI to file + stream.write( + '// This file is auto-generated by scripts/generate-abis.js\nexport default ', + ); + stream.write(JSON.stringify(abi, null, 2)); + + // Most important step: assert ABI as readonly + stream.write(' as const;'); + + stream.end(); + } + } + + console.log('ABIs generated successfully!'); +} + +main(); diff --git a/src/app.module.ts b/src/app.module.ts index d964c58f99..9e75351338 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -40,6 +40,7 @@ import { RootModule } from '@/routes/root/root.module'; import { EmailControllerModule } from '@/routes/email/email.controller.module'; import { AlertsControllerModule } from '@/routes/alerts/alerts.controller.module'; import { RecoveryModule } from '@/routes/recovery/recovery.module'; +import { RelayControllerModule } from '@/routes/relay/relay.controller.module'; import { SubscriptionControllerModule } from '@/routes/subscriptions/subscription.module'; @Module({}) @@ -48,7 +49,8 @@ export class AppModule implements NestModule { // into account. The .env file loading is done by the ConfigurationModule // which is not available at this stage. static register(configFactory = configuration): DynamicModule { - const isEmailFeatureEnabled = configFactory()['features']['email']; + const { email: isEmailFeatureEnabled, relay: isRelayFeatureEnabled } = + configFactory()['features']; return { module: AppModule, @@ -75,6 +77,7 @@ export class AppModule implements NestModule { MessagesModule, NotificationsModule, OwnersModule, + ...(isRelayFeatureEnabled ? [RelayControllerModule] : []), RootModule, SafeAppsModule, SafesModule, diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index ad0ac27e30..ca51578e16 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -21,35 +21,6 @@ export default (): ReturnType => ({ balances: { balancesTtlSeconds: faker.number.int(), providers: { - valk: { - baseUri: faker.internet.url({ appendSlash: false }), - apiKey: faker.string.hexadecimal({ length: 32 }), - chains: { - 1: { chainName: faker.string.sample() }, - 10: { chainName: faker.string.sample() }, - 100: { chainName: faker.string.sample() }, - 1101: { chainName: faker.string.sample() }, - 1313161554: { chainName: faker.string.sample() }, - 137: { chainName: faker.string.sample() }, - 324: { chainName: faker.string.sample() }, - 42161: { chainName: faker.string.sample() }, - 42220: { chainName: faker.string.sample() }, - 43114: { chainName: faker.string.sample() }, - 56: { chainName: faker.string.sample() }, - 8453: { chainName: faker.string.sample() }, - }, - currencies: Array.from( - new Set([ - ...Array.from( - { length: faker.number.int({ min: 2, max: 5 }) }, - () => faker.finance.currencyCode(), - ), - 'ETH', - 'EUR', - 'USD', - ]), - ), - }, zerion: { baseUri: faker.internet.url({ appendSlash: false }), apiKey: faker.string.hexadecimal({ length: 32 }), @@ -96,7 +67,7 @@ export default (): ReturnType => ({ }, email: { applicationCode: faker.string.alphanumeric(), - baseUri: faker.internet.url({ appendSlash: true }), + baseUri: faker.internet.url({ appendSlash: false }), apiKey: faker.string.hexadecimal({ length: 32 }), fromEmail: faker.internet.email(), fromName: faker.person.fullName(), @@ -122,8 +93,8 @@ export default (): ReturnType => ({ features: { richFragments: true, email: true, - valkBalancesChainIds: ['100'], zerionBalancesChainIds: ['137'], + relay: true, }, httpClient: { requestTimeout: faker.number.int() }, log: { @@ -211,7 +182,13 @@ export default (): ReturnType => ({ host: process.env.REDIS_HOST || 'localhost', port: process.env.REDIS_PORT || '6379', }, - relay: { limit: faker.number.int({ min: 1 }) }, + relay: { + baseUri: faker.internet.url({ appendSlash: false }), + limit: faker.number.int({ min: 1 }), + apiKey: { + 100: faker.string.hexadecimal({ length: 32 }), + }, + }, safeConfig: { baseUri: faker.internet.url({ appendSlash: false }), }, diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 27cfa52f00..d3f5537754 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -21,29 +21,6 @@ export default () => ({ balances: { balancesTtlSeconds: parseInt(process.env.BALANCES_TTL_SECONDS ?? `${300}`), providers: { - valk: { - baseUri: - process.env.VALK_BASE_URI || - 'https://merlin-api-v1.cf/api/merlin/public', - apiKey: process.env.VALK_API_KEY, - chains: { - 1: { chainName: 'eth' }, - 10: { chainName: 'op' }, - 56: { chainName: 'bsc' }, - 100: { chainName: 'xdai' }, - 137: { chainName: 'matic' }, - 324: { chainName: 'era' }, - 1101: { chainName: 'pze' }, - 8453: { chainName: 'base' }, - 42161: { chainName: 'arb' }, - 42220: { chainName: 'celo' }, - 43114: { chainName: 'avax' }, - // 11155111 (Sepolia) is not available on Valk - // 11155111: { chainName: '' }, - 1313161554: { chainName: 'aurora' }, - }, - currencies: ['AED', 'AUD', 'CAD', 'EUR', 'GBP', 'INR', 'USD'], - }, zerion: { baseUri: process.env.ZERION_BASE_URI || 'https://api.zerion.io', apiKey: process.env.ZERION_API_KEY, @@ -148,10 +125,9 @@ export default () => ({ features: { richFragments: process.env.FF_RICH_FRAGMENTS?.toLowerCase() === 'true', email: process.env.FF_EMAIL?.toLowerCase() === 'true', - valkBalancesChainIds: - process.env.FF_VALK_BALANCES_CHAIN_IDS?.split(',') ?? [], zerionBalancesChainIds: process.env.FF_ZERION_BALANCES_CHAIN_IDS?.split(',') ?? [], + relay: process.env.FF_RELAY?.toLowerCase() === 'true', }, httpClient: { // Timeout in milliseconds to be used for the HTTP client. @@ -204,7 +180,12 @@ export default () => ({ port: process.env.REDIS_PORT || '6379', }, relay: { + baseUri: + process.env.RELAY_PROVIDER_API_BASE_URI || 'https://api.gelato.digital', limit: parseInt(process.env.RELAY_THROTTLE_LIMIT ?? `${5}`), + apiKey: { + 100: process.env.GELATO_API_KEY_GNOSIS_CHAIN, + }, }, safeConfig: { baseUri: diff --git a/src/datasources/balances-api/balances-api.manager.spec.ts b/src/datasources/balances-api/balances-api.manager.spec.ts index 87d4e0d00a..44160652cf 100644 --- a/src/datasources/balances-api/balances-api.manager.spec.ts +++ b/src/datasources/balances-api/balances-api.manager.spec.ts @@ -1,25 +1,39 @@ import { IConfigurationService } from '@/config/configuration.service.interface'; import { BalancesApiManager } from '@/datasources/balances-api/balances-api.manager'; +import { CacheFirstDataSource } from '@/datasources/cache/cache.first.data.source'; +import { ICacheService } from '@/datasources/cache/cache.service.interface'; +import { HttpErrorFactory } from '@/datasources/errors/http-error-factory'; +import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; import { IBalancesApi } from '@/domain/interfaces/balances-api.interface'; +import { IConfigApi } from '@/domain/interfaces/config-api.interface'; +import { faker } from '@faker-js/faker'; const configurationService = { getOrThrow: jest.fn(), - get: jest.fn(), -} as IConfigurationService; +} as jest.MockedObjectDeep; const configurationServiceMock = jest.mocked(configurationService); -const valkBalancesApi = { - getBalances: jest.fn(), - clearBalances: jest.fn(), - getFiatCodes: jest.fn(), -} as IBalancesApi; +const configApi = { + getChain: jest.fn(), +} as jest.MockedObjectDeep; -const valkBalancesApiMock = jest.mocked(valkBalancesApi); +const configApiMock = jest.mocked(configApi); + +const dataSource = { + get: jest.fn(), +} as jest.MockedObjectDeep; + +const dataSourceMock = jest.mocked(dataSource); + +const cacheService = {} as jest.MockedObjectDeep; +const httpErrorFactory = {} as jest.MockedObjectDeep; const zerionBalancesApi = { getBalances: jest.fn(), clearBalances: jest.fn(), + getCollectibles: jest.fn(), + clearCollectibles: jest.fn(), getFiatCodes: jest.fn(), } as IBalancesApi; @@ -28,8 +42,7 @@ const zerionBalancesApiMock = jest.mocked(zerionBalancesApi); beforeEach(() => { jest.resetAllMocks(); configurationServiceMock.getOrThrow.mockImplementation((key) => { - if (key === 'features.valkBalancesChainIds') return ['1', '2', '3']; - if (key === 'features.zerionBalancesChainIds') return ['4', '5', '6']; + if (key === 'features.zerionBalancesChainIds') return ['1', '2', '3']; }); }); @@ -38,68 +51,125 @@ describe('Balances API Manager Tests', () => { it('should return true if the chain is included in the balance-externalized chains', () => { const manager = new BalancesApiManager( configurationService, - valkBalancesApiMock, + configApiMock, + dataSourceMock, + cacheService, + httpErrorFactory, zerionBalancesApiMock, ); expect(manager.useExternalApi('1')).toEqual(true); - expect(manager.useExternalApi('5')).toEqual(true); + expect(manager.useExternalApi('3')).toEqual(true); }); it('should return false if the chain is included in the balance-externalized chains', () => { const manager = new BalancesApiManager( configurationService, - valkBalancesApiMock, + configApiMock, + dataSourceMock, + cacheService, + httpErrorFactory, zerionBalancesApiMock, ); - expect(manager.useExternalApi('7')).toEqual(false); + expect(manager.useExternalApi('4')).toEqual(false); }); }); describe('getBalancesApi checks', () => { - it('should return the Valk API', () => { + it('should return the Zerion API', async () => { const manager = new BalancesApiManager( configurationService, - valkBalancesApiMock, + configApiMock, + dataSourceMock, + cacheService, + httpErrorFactory, zerionBalancesApiMock, ); - expect(manager.getBalancesApi('2')).toEqual(valkBalancesApi); + + const result = await manager.getBalancesApi('2'); + + expect(result).toEqual(zerionBalancesApi); }); - it('should return the Zerion API', () => { - const manager = new BalancesApiManager( + const txServiceUrl = faker.internet.url({ appendSlash: false }); + const vpcTxServiceUrl = faker.internet.url({ appendSlash: false }); + + /** + * In the following tests, getBalances is used to check the parameters to + * which {@link CacheFirstDataSource} was called with. + */ + it.each([ + [true, vpcTxServiceUrl], + [false, txServiceUrl], + ])('vpcUrl is %s', async (useVpcUrl, expectedUrl) => { + const zerionChainIds = ['1', '2', '3']; + const chain = chainBuilder() + .with('chainId', '4') + .with('transactionService', txServiceUrl) + .with('vpcTransactionService', vpcTxServiceUrl) + .build(); + const expirationTimeInSeconds = faker.number.int(); + const notFoundExpireTimeSeconds = faker.number.int(); + configurationServiceMock.getOrThrow.mockImplementation((key) => { + if (key === 'safeTransaction.useVpcUrl') return useVpcUrl; + else if (key === 'expirationTimeInSeconds.default') + return expirationTimeInSeconds; + else if (key === 'expirationTimeInSeconds.notFound.default') + return notFoundExpireTimeSeconds; + else if (key === 'features.zerionBalancesChainIds') + return zerionChainIds; + throw new Error(`Unexpected key: ${key}`); + }); + configApiMock.getChain.mockResolvedValue(chain); + const balancesApiManager = new BalancesApiManager( configurationService, - valkBalancesApiMock, + configApiMock, + dataSourceMock, + cacheService, + httpErrorFactory, zerionBalancesApiMock, ); - expect(manager.getBalancesApi('6')).toEqual(zerionBalancesApi); - }); - it('should throw an error if no API is found for the input chainId', () => { - const manager = new BalancesApiManager( - configurationService, - valkBalancesApiMock, - zerionBalancesApiMock, + const safeBalancesApi = await balancesApiManager.getBalancesApi( + chain.chainId, ); - expect(() => manager.getBalancesApi('100')).toThrow(); + const safeAddress = faker.finance.ethereumAddress(); + const trusted = faker.datatype.boolean(); + const excludeSpam = faker.datatype.boolean(); + + await safeBalancesApi.getBalances({ + safeAddress, + trusted, + excludeSpam, + }); + + expect(dataSourceMock.get).toHaveBeenCalledWith({ + cacheDir: expect.anything(), + url: `${expectedUrl}/api/v1/safes/${safeAddress}/balances/`, + notFoundExpireTimeSeconds: notFoundExpireTimeSeconds, + expireTimeSeconds: expirationTimeInSeconds, + networkRequest: expect.objectContaining({ + params: expect.objectContaining({ + trusted: trusted, + exclude_spam: excludeSpam, + }), + }), + }); }); }); describe('getFiatCodes checks', () => { it('should return the intersection of all providers supported currencies', () => { - valkBalancesApiMock.getFiatCodes.mockReturnValue([ - 'USD', - 'BTC', - 'EUR', - 'ETH', - ]); zerionBalancesApiMock.getFiatCodes.mockReturnValue(['EUR', 'GBP', 'ETH']); const manager = new BalancesApiManager( configurationService, - valkBalancesApiMock, + configApiMock, + dataSourceMock, + cacheService, + httpErrorFactory, zerionBalancesApiMock, ); - expect(manager.getFiatCodes()).toStrictEqual(['ETH', 'EUR']); + expect(manager.getFiatCodes()).toStrictEqual(['ETH', 'EUR', 'GBP']); }); }); }); diff --git a/src/datasources/balances-api/balances-api.manager.ts b/src/datasources/balances-api/balances-api.manager.ts index 0e8a603f50..66a7a8f94b 100644 --- a/src/datasources/balances-api/balances-api.manager.ts +++ b/src/datasources/balances-api/balances-api.manager.ts @@ -1,66 +1,72 @@ import { IConfigurationService } from '@/config/configuration.service.interface'; -import { IValkBalancesApi } from '@/datasources/balances-api/valk-balances-api.service'; +import { SafeBalancesApi } from '@/datasources/balances-api/safe-balances-api.service'; import { IZerionBalancesApi } from '@/datasources/balances-api/zerion-balances-api.service'; +import { CacheFirstDataSource } from '@/datasources/cache/cache.first.data.source'; +import { + CacheService, + ICacheService, +} from '@/datasources/cache/cache.service.interface'; +import { HttpErrorFactory } from '@/datasources/errors/http-error-factory'; import { IBalancesApi } from '@/domain/interfaces/balances-api.interface'; import { IBalancesApiManager } from '@/domain/interfaces/balances-api.manager.interface'; +import { IConfigApi } from '@/domain/interfaces/config-api.interface'; import { Inject, Injectable } from '@nestjs/common'; -import { intersection } from 'lodash'; @Injectable() export class BalancesApiManager implements IBalancesApiManager { - private readonly valkBalancesChainIds: string[]; - private readonly valkBalancesApi: IBalancesApi; - private readonly zerionBalancesChainIds: string[]; + private safeBalancesApiMap: Record = {}; + private readonly zerionChainIds: string[]; private readonly zerionBalancesApi: IBalancesApi; - private readonly externalApiChainIds: string[]; + private readonly useVpcUrl: boolean; constructor( @Inject(IConfigurationService) private readonly configurationService: IConfigurationService, - @Inject(IValkBalancesApi) valkBalancesApi: IBalancesApi, + @Inject(IConfigApi) private readonly configApi: IConfigApi, + private readonly dataSource: CacheFirstDataSource, + @Inject(CacheService) private readonly cacheService: ICacheService, + private readonly httpErrorFactory: HttpErrorFactory, @Inject(IZerionBalancesApi) zerionBalancesApi: IBalancesApi, ) { - this.valkBalancesChainIds = this.configurationService.getOrThrow( - 'features.valkBalancesChainIds', + this.zerionChainIds = this.configurationService.getOrThrow( + 'features.zerionBalancesChainIds', + ); + this.useVpcUrl = this.configurationService.getOrThrow( + 'safeTransaction.useVpcUrl', ); - this.zerionBalancesChainIds = this.configurationService.getOrThrow< - string[] - >('features.zerionBalancesChainIds'); - - this.externalApiChainIds = [ - ...this.valkBalancesChainIds, - ...this.zerionBalancesChainIds, - ]; - this.valkBalancesApi = valkBalancesApi; this.zerionBalancesApi = zerionBalancesApi; } useExternalApi(chainId: string): boolean { - return this.externalApiChainIds.includes(chainId); + return this.zerionChainIds.includes(chainId); } - getBalancesApi(chainId: string): IBalancesApi { - if (this._isSupportedByValk(chainId)) { - return this.valkBalancesApi; - } + async getBalancesApi(chainId: string): Promise { if (this._isSupportedByZerion(chainId)) { return this.zerionBalancesApi; } - throw new Error(`Chain ID ${chainId} balances provider is not configured`); - } - getFiatCodes(): string[] { - const valkFiatCodes = this.valkBalancesApi.getFiatCodes(); - const zerionFiatCodes = this.zerionBalancesApi.getFiatCodes(); - return intersection(valkFiatCodes, zerionFiatCodes).sort(); + const safeBalancesApi = this.safeBalancesApiMap[chainId]; + if (safeBalancesApi !== undefined) return safeBalancesApi; + + const chain = await this.configApi.getChain(chainId); + this.safeBalancesApiMap[chainId] = new SafeBalancesApi( + chainId, + this.useVpcUrl ? chain.vpcTransactionService : chain.transactionService, + this.dataSource, + this.cacheService, + this.configurationService, + this.httpErrorFactory, + ); + return this.safeBalancesApiMap[chainId]; } - private _isSupportedByValk(chainId: string): boolean { - return this.valkBalancesChainIds.includes(chainId); + getFiatCodes(): string[] { + return this.zerionBalancesApi.getFiatCodes().sort(); } private _isSupportedByZerion(chainId: string): boolean { - return this.zerionBalancesChainIds.includes(chainId); + return this.zerionChainIds.includes(chainId); } } diff --git a/src/datasources/balances-api/balances-api.module.ts b/src/datasources/balances-api/balances-api.module.ts index ed5d502c54..2ea1c57842 100644 --- a/src/datasources/balances-api/balances-api.module.ts +++ b/src/datasources/balances-api/balances-api.module.ts @@ -2,20 +2,17 @@ import { Module } from '@nestjs/common'; import { CacheFirstDataSourceModule } from '@/datasources/cache/cache.first.data.source.module'; import { BalancesApiManager } from '@/datasources/balances-api/balances-api.manager'; import { IBalancesApiManager } from '@/domain/interfaces/balances-api.manager.interface'; -import { - IValkBalancesApi, - ValkBalancesApi, -} from '@/datasources/balances-api/valk-balances-api.service'; import { IZerionBalancesApi, ZerionBalancesApi, } from '@/datasources/balances-api/zerion-balances-api.service'; +import { HttpErrorFactory } from '@/datasources/errors/http-error-factory'; @Module({ imports: [CacheFirstDataSourceModule], providers: [ + HttpErrorFactory, { provide: IBalancesApiManager, useClass: BalancesApiManager }, - { provide: IValkBalancesApi, useClass: ValkBalancesApi }, { provide: IZerionBalancesApi, useClass: ZerionBalancesApi }, ], exports: [IBalancesApiManager], diff --git a/src/datasources/balances-api/entities/__tests__/valk-balance.entity.builder.ts b/src/datasources/balances-api/entities/__tests__/valk-balance.entity.builder.ts deleted file mode 100644 index 85203efcc6..0000000000 --- a/src/datasources/balances-api/entities/__tests__/valk-balance.entity.builder.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Builder, IBuilder } from '@/__tests__/builder'; -import { ValkBalance } from '@/datasources/balances-api/entities/valk-balance.entity'; -import { faker } from '@faker-js/faker'; - -export function valkBalanceBuilder(): IBuilder { - return new Builder() - .with('token_address', faker.finance.ethereumAddress()) - .with('name', faker.finance.currencyName()) - .with('symbol', faker.finance.currencySymbol()) - .with('logo', faker.internet.url({ appendSlash: false })) - .with('thumbnail', faker.internet.url({ appendSlash: false })) - .with('decimals', faker.number.int({ min: 10, max: 20 })) - .with('balance', faker.number.int()) - .with( - 'prices', - Array.from({ length: faker.number.int({ min: 1, max: 10 }) }, () => - faker.finance.currencyCode(), - ).reduce( - (prices, currencyCode) => ({ - ...prices, - [currencyCode]: faker.number.float({ min: 0.01, precision: 0.0001 }), - }), - {}, - ), - ); -} diff --git a/src/datasources/balances-api/entities/__tests__/zerion-collectible.entity.builder.ts b/src/datasources/balances-api/entities/__tests__/zerion-collectible.entity.builder.ts new file mode 100644 index 0000000000..99a633bbfa --- /dev/null +++ b/src/datasources/balances-api/entities/__tests__/zerion-collectible.entity.builder.ts @@ -0,0 +1,70 @@ +import { Builder, IBuilder } from '@/__tests__/builder'; +import { + ZerionCollectible, + ZerionCollectibleAttributes, + ZerionCollectibles, + ZerionCollectionInfo, + ZerionNFTInfo, +} from '@/datasources/balances-api/entities/zerion-collectible.entity'; +import { faker } from '@faker-js/faker'; + +export function zerionNFTInfoBuilder(): IBuilder { + return new Builder() + .with('content', { + preview: { url: faker.internet.url({ appendSlash: false }) }, + detail: { url: faker.internet.url({ appendSlash: false }) }, + }) + .with('contract_address', faker.finance.ethereumAddress()) + .with('flags', { is_spam: faker.datatype.boolean() }) + .with('interface', faker.string.alphanumeric()) + .with('name', faker.string.alphanumeric()) + .with('token_id', faker.string.numeric()); +} + +export function zerionCollectionInfoBuilder(): IBuilder { + return new Builder() + .with('content', { + icon: { url: faker.internet.url({ appendSlash: false }) }, + banner: { url: faker.internet.url({ appendSlash: false }) }, + }) + .with('description', faker.string.alphanumeric()) + .with('name', faker.string.alphanumeric()); +} + +export function zerionCollectibleAttributesBuilder(): IBuilder { + return new Builder() + .with('amount', faker.string.numeric()) + .with('changed_at', faker.date.recent().toString()) + .with('collection_info', zerionCollectionInfoBuilder().build()) + .with('nft_info', zerionNFTInfoBuilder().build()) + .with('price', faker.number.float()) + .with('value', faker.number.float()); +} + +export function zerionCollectibleBuilder(): IBuilder { + return new Builder() + .with('type', 'nft_positions') + .with('id', faker.string.sample()) + .with('attributes', zerionCollectibleAttributesBuilder().build()); +} + +export function zerionCollectiblesBuilder(): IBuilder { + const limit = faker.number.int({ min: 1 }); + const offset = Buffer.from( + `"${faker.number.int({ min: 1 })}"`, + 'utf8', + ).toString('base64'); + + return new Builder() + .with( + 'data', + Array.from({ length: faker.number.int({ min: 0, max: 5 }) }, () => + zerionCollectibleBuilder().build(), + ), + ) + .with('links', { + next: `${faker.internet.url()}?${encodeURIComponent( + `page[after]=${offset}&page[size]=${limit}`, + )}`, + }); +} diff --git a/src/datasources/balances-api/entities/valk-balance.entity.ts b/src/datasources/balances-api/entities/valk-balance.entity.ts deleted file mode 100644 index e2daa24732..0000000000 --- a/src/datasources/balances-api/entities/valk-balance.entity.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface ValkBalance { - token_address: string; - name: string; - symbol: string; - logo: string; - thumbnail: string | null; - decimals: number; - balance: number; - prices: Record; -} diff --git a/src/datasources/balances-api/entities/zerion-collectible.entity.ts b/src/datasources/balances-api/entities/zerion-collectible.entity.ts new file mode 100644 index 0000000000..adc9772e6e --- /dev/null +++ b/src/datasources/balances-api/entities/zerion-collectible.entity.ts @@ -0,0 +1,40 @@ +export interface ZerionCollectionInfo { + content: { + icon: { url: string }; + banner: { url: string }; + } | null; + description: string | null; + name: string | null; +} + +export interface ZerionNFTInfo { + content: { + preview: { url: string } | null; + detail: { url: string } | null; + } | null; + contract_address: string; + flags: { is_spam: boolean } | null; + interface: string | null; + name: string | null; + token_id: string; +} + +export interface ZerionCollectibleAttributes { + amount: string; + changed_at: string; + collection_info: ZerionCollectionInfo | null; + nft_info: ZerionNFTInfo; + price: number; + value: number; +} + +export interface ZerionCollectible { + attributes: ZerionCollectibleAttributes; + id: string; + type: 'nft_positions'; +} + +export interface ZerionCollectibles { + data: ZerionCollectible[]; + links: { next: string | null }; +} diff --git a/src/datasources/balances-api/safe-balances-api.service.ts b/src/datasources/balances-api/safe-balances-api.service.ts new file mode 100644 index 0000000000..f0245954e3 --- /dev/null +++ b/src/datasources/balances-api/safe-balances-api.service.ts @@ -0,0 +1,117 @@ +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { CacheFirstDataSource } from '@/datasources/cache/cache.first.data.source'; +import { CacheRouter } from '@/datasources/cache/cache.router'; +import { ICacheService } from '@/datasources/cache/cache.service.interface'; +import { HttpErrorFactory } from '@/datasources/errors/http-error-factory'; +import { Balance } from '@/domain/balances/entities/balance.entity'; +import { Collectible } from '@/domain/collectibles/entities/collectible.entity'; +import { Page } from '@/domain/entities/page.entity'; +import { IBalancesApi } from '@/domain/interfaces/balances-api.interface'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class SafeBalancesApi implements IBalancesApi { + private readonly defaultExpirationTimeInSeconds: number; + private readonly defaultNotFoundExpirationTimeSeconds: number; + + constructor( + private readonly chainId: string, + private readonly baseUrl: string, + private readonly dataSource: CacheFirstDataSource, + private readonly cacheService: ICacheService, + private readonly configurationService: IConfigurationService, + private readonly httpErrorFactory: HttpErrorFactory, + ) { + this.defaultExpirationTimeInSeconds = + this.configurationService.getOrThrow( + 'expirationTimeInSeconds.default', + ); + this.defaultNotFoundExpirationTimeSeconds = + this.configurationService.getOrThrow( + 'expirationTimeInSeconds.notFound.default', + ); + } + + async getBalances(args: { + safeAddress: string; + trusted?: boolean; + excludeSpam?: boolean; + }): Promise { + try { + const cacheDir = CacheRouter.getBalancesCacheDir({ + chainId: this.chainId, + ...args, + }); + const url = `${this.baseUrl}/api/v1/safes/${args.safeAddress}/balances/`; + return await this.dataSource.get({ + cacheDir, + url, + notFoundExpireTimeSeconds: this.defaultNotFoundExpirationTimeSeconds, + networkRequest: { + params: { + trusted: args.trusted, + exclude_spam: args.excludeSpam, + }, + }, + expireTimeSeconds: this.defaultExpirationTimeInSeconds, + }); + } catch (error) { + throw this.httpErrorFactory.from(error); + } + } + + async clearBalances(args: { safeAddress: string }): Promise { + const key = CacheRouter.getBalancesCacheKey({ + chainId: this.chainId, + safeAddress: args.safeAddress, + }); + await this.cacheService.deleteByKey(key); + } + + async getCollectibles(args: { + safeAddress: string; + limit?: number; + offset?: number; + trusted?: boolean; + excludeSpam?: boolean; + }): Promise> { + try { + const cacheDir = CacheRouter.getCollectiblesCacheDir({ + chainId: this.chainId, + ...args, + }); + const url = `${this.baseUrl}/api/v2/safes/${args.safeAddress}/collectibles/`; + return await this.dataSource.get({ + cacheDir, + url, + notFoundExpireTimeSeconds: this.defaultNotFoundExpirationTimeSeconds, + networkRequest: { + params: { + limit: args.limit, + offset: args.offset, + trusted: args.trusted, + exclude_spam: args.excludeSpam, + }, + }, + expireTimeSeconds: this.defaultExpirationTimeInSeconds, + }); + } catch (error) { + throw this.httpErrorFactory.from(error); + } + } + + async clearCollectibles(args: { safeAddress: string }): Promise { + const key = CacheRouter.getCollectiblesKey({ + chainId: this.chainId, + safeAddress: args.safeAddress, + }); + await this.cacheService.deleteByKey(key); + } + + /** + * No fiat prices are available from this provider. + */ + getFiatCodes(): string[] { + return []; + } +} diff --git a/src/datasources/balances-api/valk-balances-api.service.ts b/src/datasources/balances-api/valk-balances-api.service.ts deleted file mode 100644 index 73fdfa5170..0000000000 --- a/src/datasources/balances-api/valk-balances-api.service.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { IConfigurationService } from '@/config/configuration.service.interface'; -import { ChainAttributes } from '@/datasources/balances-api/entities/provider-chain-attributes.entity'; -import { ValkBalance } from '@/datasources/balances-api/entities/valk-balance.entity'; -import { CacheFirstDataSource } from '@/datasources/cache/cache.first.data.source'; -import { CacheRouter } from '@/datasources/cache/cache.router'; -import { - CacheService, - ICacheService, -} from '@/datasources/cache/cache.service.interface'; -import { - Balance, - Erc20Balance, - NativeBalance, -} from '@/domain/balances/entities/balance.entity'; -import { getNumberString } from '@/domain/common/utils/utils'; -import { DataSourceError } from '@/domain/errors/data-source.error'; -import { IBalancesApi } from '@/domain/interfaces/balances-api.interface'; -import { asError } from '@/logging/utils'; -import { Inject, Injectable } from '@nestjs/common'; -import { isAddress } from 'viem'; - -export const IValkBalancesApi = Symbol('IValkBalancesApi'); - -@Injectable() -export class ValkBalancesApi implements IBalancesApi { - private readonly apiKey: string | undefined; - private readonly baseUri: string; - private readonly chainsConfiguration: Record; - private readonly defaultExpirationTimeInSeconds: number; - private readonly defaultNotFoundExpirationTimeSeconds: number; - private readonly fiatCodes: string[]; - - constructor( - @Inject(CacheService) private readonly cacheService: ICacheService, - @Inject(IConfigurationService) - private readonly configurationService: IConfigurationService, - private readonly dataSource: CacheFirstDataSource, - ) { - this.apiKey = this.configurationService.get( - 'balances.providers.valk.apiKey', - ); - this.baseUri = this.configurationService.getOrThrow( - 'balances.providers.valk.baseUri', - ); - this.defaultExpirationTimeInSeconds = - this.configurationService.getOrThrow( - 'expirationTimeInSeconds.default', - ); - this.defaultNotFoundExpirationTimeSeconds = - this.configurationService.getOrThrow( - 'expirationTimeInSeconds.notFound.default', - ); - this.chainsConfiguration = this.configurationService.getOrThrow< - Record - >('balances.providers.valk.chains'); - this.fiatCodes = this.configurationService - .getOrThrow('balances.providers.valk.currencies') - .map((currency) => currency.toUpperCase()); - } - - async getBalances(args: { - chainId: string; - safeAddress: string; - fiatCode: string; - }): Promise { - try { - const cacheDir = CacheRouter.getValkBalancesCacheDir(args); - const chainName = this._getChainName(args.chainId); - const url = `${this.baseUri}/balances/token/${args.safeAddress}`; - const valkBalances = await this.dataSource.get({ - cacheDir, - url, - notFoundExpireTimeSeconds: this.defaultNotFoundExpirationTimeSeconds, - networkRequest: { - headers: { Authorization: `${this.apiKey}` }, - params: { chain: chainName }, - }, - expireTimeSeconds: this.defaultExpirationTimeInSeconds, - }); - return this._mapBalances(valkBalances, args.fiatCode); - } catch (error) { - throw new DataSourceError( - `Error getting ${args.safeAddress} balances from provider: ${asError(error).message}}`, - ); - } - } - - async clearBalances(args: { - chainId: string; - safeAddress: string; - }): Promise { - const key = CacheRouter.getValkBalancesCacheKey(args); - await this.cacheService.deleteByKey(key); - } - - getFiatCodes(): string[] { - return this.fiatCodes; - } - - private _mapBalances( - valkBalances: ValkBalance[], - fiatCode: string, - ): Balance[] { - return valkBalances.map((valkBalance) => { - const price = valkBalance.prices[fiatCode.toUpperCase()] ?? null; - const fiatBalance = getNumberString( - (valkBalance.balance / Math.pow(10, valkBalance.decimals)) * price, - ); - const fiatConversion = getNumberString(price); - - // Valk returns a string representing the native coin (e.g.: 'eth') as token_address - // for native coins balances. An Ethereum address is returned for ERC20 tokens. - return { - ...(isAddress(valkBalance.token_address) - ? this._mapErc20Balance(valkBalance) - : this._mapNativeBalance(valkBalance)), - fiatBalance, - fiatConversion, - }; - }); - } - - private _mapErc20Balance(valkBalance: ValkBalance): Erc20Balance { - return { - tokenAddress: valkBalance.token_address, - token: { - name: valkBalance.name, - symbol: valkBalance.symbol, - decimals: valkBalance.decimals, - logoUri: valkBalance.logo ?? '', - }, - balance: getNumberString(valkBalance.balance), - }; - } - - private _mapNativeBalance(valkBalance: ValkBalance): NativeBalance { - return { - tokenAddress: null, - token: null, - balance: getNumberString(valkBalance.balance), - }; - } - - private _getChainName(chainId: string): string { - const chainName = this.chainsConfiguration[Number(chainId)]?.chainName; - if (!chainName) - throw Error( - `Chain ${chainId} balances retrieval via Valk is not configured`, - ); - return chainName; - } -} diff --git a/src/datasources/balances-api/zerion-balances-api.service.ts b/src/datasources/balances-api/zerion-balances-api.service.ts index edae15a00b..4dbd9bf634 100644 --- a/src/datasources/balances-api/zerion-balances-api.service.ts +++ b/src/datasources/balances-api/zerion-balances-api.service.ts @@ -5,27 +5,33 @@ import { ZerionBalance, ZerionBalances, } from '@/datasources/balances-api/entities/zerion-balance.entity'; +import { + ZerionCollectible, + ZerionCollectibles, +} from '@/datasources/balances-api/entities/zerion-collectible.entity'; import { CacheFirstDataSource } from '@/datasources/cache/cache.first.data.source'; import { CacheRouter } from '@/datasources/cache/cache.router'; import { CacheService, ICacheService, } from '@/datasources/cache/cache.service.interface'; +import { HttpErrorFactory } from '@/datasources/errors/http-error-factory'; import { Balance, Erc20Balance, NativeBalance, } from '@/domain/balances/entities/balance.entity'; +import { Collectible } from '@/domain/collectibles/entities/collectible.entity'; import { getNumberString } from '@/domain/common/utils/utils'; -import { DataSourceError } from '@/domain/errors/data-source.error'; +import { Page } from '@/domain/entities/page.entity'; import { IBalancesApi } from '@/domain/interfaces/balances-api.interface'; -import { asError } from '@/logging/utils'; import { Inject, Injectable } from '@nestjs/common'; export const IZerionBalancesApi = Symbol('IZerionBalancesApi'); @Injectable() export class ZerionBalancesApi implements IBalancesApi { + private static readonly collectiblesSorting = '-floor_price'; private readonly apiKey: string | undefined; private readonly baseUri: string; private readonly chainsConfiguration: Record; @@ -38,6 +44,7 @@ export class ZerionBalancesApi implements IBalancesApi { @Inject(IConfigurationService) private readonly configurationService: IConfigurationService, private readonly dataSource: CacheFirstDataSource, + private readonly httpErrorFactory: HttpErrorFactory, ) { this.apiKey = this.configurationService.get( 'balances.providers.zerion.apiKey', @@ -87,12 +94,69 @@ export class ZerionBalancesApi implements IBalancesApi { }); return this._mapBalances(chainName, data); } catch (error) { - throw new DataSourceError( - `Error getting ${args.safeAddress} balances from provider: ${asError(error).message}}`, - ); + throw this.httpErrorFactory.from(error); + } + } + + /** + * NOTE: Zerion does not support limit & offset parameters. + * Documentation: https://developers.zerion.io/reference/listwalletnftpositions + * + * It uses a "size" query param for the page size, and an "after" parameter for the offset. + * "size" is an integer which could be mapped to "limit", but "after" is a base64-encoded string. + * + * Since this setup does not align well with the CGW API, it is needed to encode/decode these parameters. + */ + async getCollectibles(args: { + chainId: string; + safeAddress: string; + limit?: number; + offset?: number; + }): Promise> { + try { + const cacheDir = CacheRouter.getZerionCollectiblesCacheDir(args); + const chainName = this._getChainName(args.chainId); + const url = `${this.baseUri}/v1/wallets/${args.safeAddress}/nft-positions`; + const pageAfter = this._encodeZerionPageOffset(args.offset); + const params = { + 'filter[chain_ids]': chainName, + sort: ZerionBalancesApi.collectiblesSorting, + 'page[size]': args.limit, + ...(pageAfter && { 'page[after]': pageAfter }), + }; + + const zerionCollectibles = await this.dataSource.get({ + cacheDir, + url, + notFoundExpireTimeSeconds: this.defaultNotFoundExpirationTimeSeconds, + networkRequest: { + headers: { Authorization: `Basic ${this.apiKey}` }, + params, + }, + expireTimeSeconds: this.defaultExpirationTimeInSeconds, + }); + + // TODO: Zerion does not provide the items count. Change the Page entity to make count attribute nullable. + // Zerion does not provide a "previous" cursor. + return { + count: zerionCollectibles.data.length, + next: this._decodeZerionPagination(zerionCollectibles.links.next ?? ''), + previous: null, + results: this._mapCollectibles(zerionCollectibles.data), + }; + } catch (error) { + throw this.httpErrorFactory.from(error); } } + async clearCollectibles(args: { + chainId: string; + safeAddress: string; + }): Promise { + const key = CacheRouter.getZerionCollectiblesCacheKey(args); + await this.cacheService.deleteByKey(key); + } + private _mapBalances( chainName: string, zerionBalances: ZerionBalance[], @@ -168,4 +232,61 @@ export class ZerionBalancesApi implements IBalancesApi { ); return chainName; } + + private _mapCollectibles( + zerionCollectibles: ZerionCollectible[], + ): Collectible[] { + return zerionCollectibles.map( + ({ attributes: { nft_info, collection_info } }) => ({ + address: nft_info.contract_address, + tokenName: nft_info.name ?? '', + tokenSymbol: nft_info.name ?? '', + logoUri: collection_info?.content?.icon.url ?? '', + id: nft_info.token_id, + uri: nft_info.content?.detail?.url ?? null, + name: collection_info?.name ?? null, + description: collection_info?.description ?? null, + imageUri: nft_info.content?.preview?.url ?? '', + metadata: nft_info.content, + }), + ); + } + + /** + * Zerion represents cursor offsets by base64 string + * contained within double quotation marks. + * + * @param offset number representing the offset + * @returns base64 string representing the offset + */ + private _encodeZerionPageOffset(offset?: number): string | null { + return offset + ? Buffer.from(`"${offset}"`, 'utf8').toString('base64') + : null; + } + + /** + * Zerion uses page[size] as pagination limit. + * Zerion uses page[after] as pagination offset, which is a + * base64 string contained within double quotation marks. + * + * @param url Zerion-formatted string representing an URL + * @returns URL string optionally containing "limit" and "offset" query params + */ + private _decodeZerionPagination(url: string): string { + const zerionUrl = new URL(url); + const size = zerionUrl.searchParams.get('page[size]'); + const after = zerionUrl.searchParams.get('page[after]'); + + if (size) zerionUrl.searchParams.set('limit', size); + if (after) { + zerionUrl.searchParams.set( + 'offset', + Buffer.from(after ?? '0', 'base64') + .toString('utf8') + .replace(/"/g, ''), + ); + } + return zerionUrl.toString(); + } } diff --git a/src/datasources/cache/cache.router.ts b/src/datasources/cache/cache.router.ts index dfa2ebe865..e7b8e0c1f1 100644 --- a/src/datasources/cache/cache.router.ts +++ b/src/datasources/cache/cache.router.ts @@ -3,6 +3,7 @@ import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity'; export class CacheRouter { private static readonly ALL_TRANSACTIONS_KEY = 'all_transactions'; private static readonly BACKBONE_KEY = 'backbone'; + private static readonly BALANCES_KEY = 'balances'; private static readonly CHAIN_KEY = 'chain'; private static readonly CHAINS_KEY = 'chains'; private static readonly COLLECTIBLES_KEY = 'collectibles'; @@ -23,14 +24,13 @@ export class CacheRouter { private static readonly SAFE_APPS_KEY = 'safe_apps'; private static readonly SAFE_KEY = 'safe'; private static readonly SINGLETONS_KEY = 'singletons'; - private static readonly BALANCES_KEY = 'balances'; - private static readonly VALK_BALANCES_KEY = 'valk_balances'; - private static readonly ZERION_BALANCES_KEY = 'zerion_balances'; private static readonly TOKEN_KEY = 'token'; private static readonly TOKEN_PRICE_KEY = 'token_price'; private static readonly TOKENS_KEY = 'tokens'; private static readonly TRANSFER_KEY = 'transfer'; private static readonly TRANSFERS_KEY = 'transfers'; + private static readonly ZERION_BALANCES_KEY = 'zerion_balances'; + private static readonly ZERION_COLLECTIBLES_KEY = 'zerion_collectibles'; static getBalancesCacheKey(args: { chainId: string; @@ -51,37 +51,40 @@ export class CacheRouter { ); } - // TODO: remove this prefixed key if eventually only one balances provider is used - static getValkBalancesCacheKey(args: { + static getZerionBalancesCacheKey(args: { chainId: string; safeAddress: string; }): string { - return `${args.chainId}_${CacheRouter.VALK_BALANCES_KEY}_${args.safeAddress}`; + return `${args.chainId}_${CacheRouter.ZERION_BALANCES_KEY}_${args.safeAddress}`; } - static getValkBalancesCacheDir(args: { + static getZerionBalancesCacheDir(args: { chainId: string; safeAddress: string; + fiatCode: string; }): CacheDir { - return new CacheDir(CacheRouter.getValkBalancesCacheKey(args), ''); + return new CacheDir( + CacheRouter.getZerionBalancesCacheKey(args), + args.fiatCode, + ); } - // TODO: remove this prefixed key if eventually only one balances provider is used - static getZerionBalancesCacheKey(args: { + static getZerionCollectiblesCacheKey(args: { chainId: string; safeAddress: string; }): string { - return `${args.chainId}_${CacheRouter.ZERION_BALANCES_KEY}_${args.safeAddress}`; + return `${args.chainId}_${CacheRouter.ZERION_COLLECTIBLES_KEY}_${args.safeAddress}`; } - static getZerionBalancesCacheDir(args: { + static getZerionCollectiblesCacheDir(args: { chainId: string; safeAddress: string; - fiatCode: string; + limit?: number; + offset?: number; }): CacheDir { return new CacheDir( - CacheRouter.getZerionBalancesCacheKey(args), - args.fiatCode, + CacheRouter.getZerionCollectiblesCacheKey(args), + `${args.limit}_${args.offset}`, ); } diff --git a/src/datasources/relay-api/gelato-api.service.spec.ts b/src/datasources/relay-api/gelato-api.service.spec.ts index 10fda62d83..acdd0918ef 100644 --- a/src/datasources/relay-api/gelato-api.service.spec.ts +++ b/src/datasources/relay-api/gelato-api.service.spec.ts @@ -1,67 +1,49 @@ import { FakeConfigurationService } from '@/config/__tests__/fake.configuration.service'; -import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.service'; -import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity'; import { GelatoApi } from '@/datasources/relay-api/gelato-api.service'; -import { ILoggingService } from '@/logging/logging.interface'; import { faker } from '@faker-js/faker'; -import { GelatoRelay } from '@gelatonetwork/relay-sdk'; +import { INetworkService } from '@/datasources/network/network.service.interface'; import { Hex } from 'viem'; +import { HttpErrorFactory } from '@/datasources/errors/http-error-factory'; +import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; +import { DataSourceError } from '@/domain/errors/data-source.error'; -const mockGelatoClient = jest.mocked({ - sponsoredCall: jest.fn(), -} as jest.MockedObjectDeep>); - -const mockLoggingService = { - warn: jest.fn(), -} as jest.MockedObjectDeep; +const mockNetworkService = jest.mocked({ + post: jest.fn(), +} as jest.MockedObjectDeep); describe('GelatoApi', () => { let target: GelatoApi; let fakeConfigurationService: FakeConfigurationService; - let fakeCacheService: FakeCacheService; + let baseUri: string; + let httpErrorFactory: HttpErrorFactory; beforeEach(async () => { jest.resetAllMocks(); + httpErrorFactory = new HttpErrorFactory(); fakeConfigurationService = new FakeConfigurationService(); - fakeCacheService = new FakeCacheService(); + baseUri = faker.internet.url({ appendSlash: false }); + fakeConfigurationService.set('relay.baseUri', baseUri); target = new GelatoApi( - mockGelatoClient, + mockNetworkService, fakeConfigurationService, - fakeCacheService, - mockLoggingService, + httpErrorFactory, ); }); - describe('getRelayCount', () => { - it('should return the current count from the cache', async () => { - const chainId = faker.string.numeric(); - const address = faker.finance.ethereumAddress(); - const cacheDir = new CacheDir(`${chainId}_relay_${address}`, ''); - const count = faker.number.int(); - - await fakeCacheService.set(cacheDir, count.toString()); - - const result = await target.getRelayCount({ - chainId, - address, - }); - - expect(result).toEqual(count); - }); - - it('should return 0 if the cache is empty', async () => { - const chainId = faker.string.numeric(); - const address = faker.finance.ethereumAddress(); - - const result = await target.getRelayCount({ - chainId, - address, - }); - - expect(result).toEqual(0); - }); + it('should error if baseUri is not defined', () => { + const fakeConfigurationService = new FakeConfigurationService(); + const httpErrorFactory = new HttpErrorFactory(); + + expect( + () => + new GelatoApi( + mockNetworkService, + fakeConfigurationService, + httpErrorFactory, + ), + ).toThrow(); }); describe('relay', () => { @@ -70,26 +52,29 @@ describe('GelatoApi', () => { const address = faker.finance.ethereumAddress() as Hex; const data = faker.string.hexadecimal() as Hex; const apiKey = faker.string.sample(); - const taskId = faker.string.alphanumeric(); - fakeConfigurationService.set(`gelato.apiKey.${chainId}`, apiKey); - mockGelatoClient.sponsoredCall.mockResolvedValue({ taskId }); + const taskId = faker.string.uuid(); + fakeConfigurationService.set(`relay.apiKey.${chainId}`, apiKey); + mockNetworkService.post.mockResolvedValueOnce({ + status: 200, + data: { + taskId, + }, + }); - const result = await target.relay({ + await target.relay({ chainId, to: address, data, + gasLimit: null, }); - expect(result).toEqual({ taskId }); - expect(mockGelatoClient.sponsoredCall).toHaveBeenCalledWith( + expect(mockNetworkService.post).toHaveBeenCalledWith( + `${baseUri}/relays/v2/sponsored-call`, { - chainId: BigInt(chainId), - data, + sponsorApiKey: apiKey, + chainId, target: address, - }, - apiKey, - { - gasLimit: undefined, + data, }, ); }); @@ -98,9 +83,16 @@ describe('GelatoApi', () => { const chainId = faker.string.numeric(); const address = faker.finance.ethereumAddress() as Hex; const data = faker.string.hexadecimal() as Hex; - const gasLimit = faker.number.bigInt(); + const gasLimit = faker.string.numeric(); const apiKey = faker.string.sample(); - fakeConfigurationService.set(`gelato.apiKey.${chainId}`, apiKey); + const taskId = faker.string.uuid(); + fakeConfigurationService.set(`relay.apiKey.${chainId}`, apiKey); + mockNetworkService.post.mockResolvedValueOnce({ + status: 200, + data: { + taskId, + }, + }); await target.relay({ chainId, @@ -109,15 +101,14 @@ describe('GelatoApi', () => { gasLimit, }); - expect(mockGelatoClient.sponsoredCall).toHaveBeenCalledWith( + expect(mockNetworkService.post).toHaveBeenCalledWith( + `${baseUri}/relays/v2/sponsored-call`, { - chainId: BigInt(chainId), - data, + sponsorApiKey: apiKey, + chainId, target: address, - }, - apiKey, - { - gasLimit: gasLimit + BigInt(150_000), + data, + gasLimit: (BigInt(gasLimit) + BigInt(150_000)).toString(), }, ); }); @@ -132,49 +123,37 @@ describe('GelatoApi', () => { chainId, to: address, data, + gasLimit: null, }), ).rejects.toThrow(); }); - it('should increment the count after relaying', async () => { + it('should forward error', async () => { const chainId = faker.string.numeric(); const address = faker.finance.ethereumAddress() as Hex; const data = faker.string.hexadecimal() as Hex; + const status = faker.internet.httpStatusCode({ types: ['serverError'] }); const apiKey = faker.string.sample(); - fakeConfigurationService.set(`gelato.apiKey.${chainId}`, apiKey); - - await target.relay({ - chainId, - to: address, - data, - }); - - const currentCount = await fakeCacheService.get( - new CacheDir(`${chainId}_relay_${address}`, ''), + const error = new NetworkResponseError( + new URL(`${baseUri}/relays/v2/sponsored-call`), + { + status, + } as Response, + { + message: 'Unexpected error', + }, ); - expect(currentCount).toEqual('1'); - }); - - it('should not fail the relay if incrementing the count fails', async () => { - const chainId = faker.string.numeric(); - const address = faker.finance.ethereumAddress() as Hex; - const data = faker.string.hexadecimal() as Hex; - const apiKey = faker.string.sample(); - fakeConfigurationService.set(`gelato.apiKey.${chainId}`, apiKey); - // Incremeting the cache throws - jest - .spyOn(fakeCacheService, 'set') - .mockRejectedValue(new Error('Setting cache threw an error')); + fakeConfigurationService.set(`relay.apiKey.${chainId}`, apiKey); + mockNetworkService.post.mockRejectedValueOnce(error); await expect( target.relay({ chainId, to: address, data, + gasLimit: null, }), - ).resolves.not.toThrow(); - - expect(fakeCacheService.set).toHaveBeenCalledTimes(1); + ).rejects.toThrow(new DataSourceError('Unexpected error', status)); }); }); }); diff --git a/src/datasources/relay-api/gelato-api.service.ts b/src/datasources/relay-api/gelato-api.service.ts index 7a37e4cccd..a8b27b861c 100644 --- a/src/datasources/relay-api/gelato-api.service.ts +++ b/src/datasources/relay-api/gelato-api.service.ts @@ -1,14 +1,11 @@ import { Inject, Injectable } from '@nestjs/common'; -import { GelatoRelay } from '@gelatonetwork/relay-sdk'; +import { + NetworkService, + INetworkService, +} from '@/datasources/network/network.service.interface'; import { IRelayApi } from '@/domain/interfaces/relay-api.interface'; import { IConfigurationService } from '@/config/configuration.service.interface'; -import { RelayPayload } from '@/domain/relay/limit-addresses.mapper'; -import { - CacheService, - ICacheService, -} from '@/datasources/cache/cache.service.interface'; -import { CacheRouter } from '@/datasources/cache/cache.router'; -import { ILoggingService, LoggingService } from '@/logging/logging.interface'; +import { HttpErrorFactory } from '@/datasources/errors/http-error-factory'; @Injectable() export class GelatoApi implements IRelayApi { @@ -18,69 +15,50 @@ export class GelatoApi implements IRelayApi { * buffer reduces your chance of the task cancelling before it is executed on-chain. * @see https://docs.gelato.network/developer-services/relay/quick-start/optional-parameters */ + // TODO: Add documentationn to Swagger private static GAS_LIMIT_BUFFER = BigInt(150_000); + private readonly baseUri: string; + constructor( - @Inject('GelatoRelayClient') - private readonly relayClient: GelatoRelay, + @Inject(NetworkService) + private readonly networkService: INetworkService, @Inject(IConfigurationService) private readonly configurationService: IConfigurationService, - @Inject(CacheService) private readonly cacheService: ICacheService, - @Inject(LoggingService) private readonly loggingService: ILoggingService, - ) {} - - async getRelayCount(args: { - chainId: string; - address: string; - }): Promise { - const cacheDir = CacheRouter.getRelayCacheDir(args); - const currentCount = await this.cacheService.get(cacheDir); - return currentCount ? parseInt(currentCount) : 0; + private readonly httpErrorFactory: HttpErrorFactory, + ) { + this.baseUri = + this.configurationService.getOrThrow('relay.baseUri'); } - async relay(args: RelayPayload): Promise<{ taskId: string }> { - const apiKey = this.configurationService.getOrThrow( - `gelato.apiKey.${args.chainId}`, + async relay(args: { + chainId: string; + to: string; + data: string; + gasLimit: string | null; + }): Promise<{ taskId: string }> { + const sponsorApiKey = this.configurationService.getOrThrow( + `relay.apiKey.${args.chainId}`, ); - const gasLimit = args.gasLimit - ? this.getRelayGasLimit(args.gasLimit) - : undefined; - - const relayResponse = await this.relayClient.sponsoredCall( - { - chainId: BigInt(args.chainId), - data: args.data, + try { + const url = `${this.baseUri}/relays/v2/sponsored-call`; + const { data } = await this.networkService.post<{ taskId: string }>(url, { + sponsorApiKey, + chainId: args.chainId, target: args.to, - }, - apiKey, - { - gasLimit, - }, - ); - - await this.incrementRelayCount({ - chainId: args.chainId, - address: args.to, - }).catch((error) => { - // If we fail to increment count, we should not fail the relay - this.loggingService.warn(error.message); - }); - - return relayResponse; - } - - private getRelayGasLimit(gasLimit: bigint): bigint { - return gasLimit + GelatoApi.GAS_LIMIT_BUFFER; + data: args.data, + ...(args.gasLimit && { + gasLimit: this.getRelayGasLimit(args.gasLimit), + }), + }); + return data; + } catch (error) { + throw this.httpErrorFactory.from(error); + } } - private async incrementRelayCount(args: { - chainId: string; - address: string; - }): Promise { - const currentCount = await this.getRelayCount(args); - const incremented = currentCount + 1; - const cacheDir = CacheRouter.getRelayCacheDir(args); - return this.cacheService.set(cacheDir, incremented.toString()); + private getRelayGasLimit(gasLimit: string): string { + return (BigInt(gasLimit) + GelatoApi.GAS_LIMIT_BUFFER).toString(); } } diff --git a/src/datasources/relay-api/relay-api.module.ts b/src/datasources/relay-api/relay-api.module.ts index 3d793dec4d..7c2df8a5f0 100644 --- a/src/datasources/relay-api/relay-api.module.ts +++ b/src/datasources/relay-api/relay-api.module.ts @@ -1,16 +1,10 @@ import { Module } from '@nestjs/common'; -import { GelatoRelay } from '@gelatonetwork/relay-sdk'; import { GelatoApi } from '@/datasources/relay-api/gelato-api.service'; import { IRelayApi } from '@/domain/interfaces/relay-api.interface'; +import { HttpErrorFactory } from '@/datasources/errors/http-error-factory'; @Module({ - providers: [ - { - provide: 'GelatoRelayClient', - useFactory: (): GelatoRelay => new GelatoRelay(), - }, - { provide: IRelayApi, useClass: GelatoApi }, - ], + providers: [HttpErrorFactory, { provide: IRelayApi, useClass: GelatoApi }], exports: [IRelayApi], }) export class RelayApiModule {} diff --git a/src/datasources/transaction-api/transaction-api.service.spec.ts b/src/datasources/transaction-api/transaction-api.service.spec.ts index f0a6e89296..9af5fe4c8b 100644 --- a/src/datasources/transaction-api/transaction-api.service.spec.ts +++ b/src/datasources/transaction-api/transaction-api.service.spec.ts @@ -9,7 +9,6 @@ import { TransactionApi } from '@/datasources/transaction-api/transaction-api.se import { backboneBuilder } from '@/domain/backbone/entities/__tests__/backbone.builder'; import { DataSourceError } from '@/domain/errors/data-source.error'; import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; -import { balanceBuilder } from '@/domain/balances/entities/__tests__/balance.builder'; const dataSource = { get: jest.fn(), @@ -77,39 +76,6 @@ describe('TransactionApi', () => { ); }); - describe('Balances', () => { - it('should return the balances retrieved', async () => { - const data = [balanceBuilder().build(), balanceBuilder().build()]; - mockDataSource.get.mockResolvedValue(data); - - const actual = await service.getBalances({ - safeAddress: 'test', - trusted: true, - excludeSpam: true, - }); - - expect(actual).toBe(data); - expect(mockHttpErrorFactory.from).toHaveBeenCalledTimes(0); - }); - - it('should forward error', async () => { - const expected = new DataSourceError('something happened'); - mockDataSource.get.mockRejectedValueOnce(new Error('Some error')); - mockHttpErrorFactory.from.mockReturnValue(expected); - - await expect( - service.getBalances({ - safeAddress: 'test', - trusted: true, - excludeSpam: true, - }), - ).rejects.toThrow(expected); - - expect(mockDataSource.get).toHaveBeenCalledTimes(1); - expect(mockHttpErrorFactory.from).toHaveBeenCalledTimes(1); - }); - }); - describe('Backbone', () => { it('should return the backbone retrieved', async () => { const data = backboneBuilder().build(); @@ -133,21 +99,6 @@ describe('TransactionApi', () => { }); }); - describe('Clear Local Balances', () => { - it('should call delete', async () => { - const safeAddress = faker.finance.ethereumAddress(); - mockCacheService.deleteByKey.mockResolvedValueOnce(1); - - await service.clearLocalBalances(safeAddress); - - expect(mockCacheService.deleteByKey).toHaveBeenCalledTimes(1); - expect(mockCacheService.deleteByKey).toHaveBeenCalledWith( - `${chainId}_balances_${safeAddress}`, - ); - expect(mockHttpErrorFactory.from).toHaveBeenCalledTimes(0); - }); - }); - describe('Safe', () => { describe('getSafe', () => { it('should return retrieved safe', async () => { diff --git a/src/datasources/transaction-api/transaction-api.service.ts b/src/datasources/transaction-api/transaction-api.service.ts index 06ecac925c..96efb865b9 100644 --- a/src/datasources/transaction-api/transaction-api.service.ts +++ b/src/datasources/transaction-api/transaction-api.service.ts @@ -6,7 +6,6 @@ import { HttpErrorFactory } from '@/datasources/errors/http-error-factory'; import { INetworkService } from '@/datasources/network/network.service.interface'; import { Backbone } from '@/domain/backbone/entities/backbone.entity'; import { Singleton } from '@/domain/chains/entities/singleton.entity'; -import { Collectible } from '@/domain/collectibles/entities/collectible.entity'; import { Contract } from '@/domain/contracts/entities/contract.entity'; import { DataDecoded } from '@/domain/data-decoder/entities/data-decoded.entity'; import { Delegate } from '@/domain/delegate/entities/delegate.entity'; @@ -26,7 +25,6 @@ import { Transfer } from '@/domain/safe/entities/transfer.entity'; import { Token } from '@/domain/tokens/entities/token.entity'; import { AddConfirmationDto } from '@/domain/transactions/entities/add-confirmation.dto.entity'; import { ProposeTransactionDto } from '@/domain/transactions/entities/propose-transaction.dto.entity'; -import { Balance } from '@/domain/balances/entities/balance.entity'; export class TransactionApi implements ITransactionApi { private readonly defaultExpirationTimeInSeconds: number; @@ -61,42 +59,6 @@ export class TransactionApi implements ITransactionApi { ); } - async getBalances(args: { - safeAddress: string; - trusted?: boolean; - excludeSpam?: boolean; - }): Promise { - try { - const cacheDir = CacheRouter.getBalancesCacheDir({ - chainId: this.chainId, - ...args, - }); - const url = `${this.baseUrl}/api/v1/safes/${args.safeAddress}/balances/`; - return await this.dataSource.get({ - cacheDir, - url, - notFoundExpireTimeSeconds: this.defaultNotFoundExpirationTimeSeconds, - networkRequest: { - params: { - trusted: args.trusted, - exclude_spam: args.excludeSpam, - }, - }, - expireTimeSeconds: this.defaultExpirationTimeInSeconds, - }); - } catch (error) { - throw this.httpErrorFactory.from(error); - } - } - - async clearLocalBalances(safeAddress: string): Promise { - const key = CacheRouter.getBalancesCacheKey({ - chainId: this.chainId, - safeAddress, - }); - await this.cacheService.deleteByKey(key); - } - async getDataDecoded(args: { data: string; to?: string; @@ -116,46 +78,6 @@ export class TransactionApi implements ITransactionApi { } } - async getCollectibles(args: { - safeAddress: string; - limit?: number; - offset?: number; - trusted?: boolean; - excludeSpam?: boolean; - }): Promise> { - try { - const cacheDir = CacheRouter.getCollectiblesCacheDir({ - chainId: this.chainId, - ...args, - }); - const url = `${this.baseUrl}/api/v2/safes/${args.safeAddress}/collectibles/`; - return await this.dataSource.get({ - cacheDir, - url, - notFoundExpireTimeSeconds: this.defaultNotFoundExpirationTimeSeconds, - networkRequest: { - params: { - limit: args.limit, - offset: args.offset, - trusted: args.trusted, - exclude_spam: args.excludeSpam, - }, - }, - expireTimeSeconds: this.defaultExpirationTimeInSeconds, - }); - } catch (error) { - throw this.httpErrorFactory.from(error); - } - } - - async clearCollectibles(safeAddress: string): Promise { - const key = CacheRouter.getCollectiblesKey({ - chainId: this.chainId, - safeAddress, - }); - await this.cacheService.deleteByKey(key); - } - // Important: there is no hook which invalidates this endpoint, // Therefore, this data will live in cache until [defaultExpirationTimeInSeconds] async getBackbone(): Promise { diff --git a/src/domain/account/entities/__tests__/subscription.builder.ts b/src/domain/account/entities/__tests__/subscription.builder.ts new file mode 100644 index 0000000000..0559e6458b --- /dev/null +++ b/src/domain/account/entities/__tests__/subscription.builder.ts @@ -0,0 +1,9 @@ +import { Builder, IBuilder } from '@/__tests__/builder'; +import { Subscription } from '@/domain/account/entities/subscription.entity'; +import { faker } from '@faker-js/faker'; + +export function subscriptionBuilder(): IBuilder { + return new Builder() + .with('key', faker.word.sample()) + .with('name', faker.word.words()); +} diff --git a/src/domain/alerts/alerts.domain.module.ts b/src/domain/alerts/alerts.domain.module.ts index f14b3c7035..62ffb43b65 100644 --- a/src/domain/alerts/alerts.domain.module.ts +++ b/src/domain/alerts/alerts.domain.module.ts @@ -6,6 +6,7 @@ import { AlertsDecodersModule } from '@/domain/alerts/alerts-decoders.module'; import { EmailApiModule } from '@/datasources/email-api/email-api.module'; import { AccountDomainModule } from '@/domain/account/account.domain.module'; import { UrlGeneratorModule } from '@/domain/alerts/urls/url-generator.module'; +import { SubscriptionDomainModule } from '@/domain/subscriptions/subscription.domain.module'; @Module({ imports: [ @@ -13,6 +14,7 @@ import { UrlGeneratorModule } from '@/domain/alerts/urls/url-generator.module'; AlertsApiModule, AlertsDecodersModule, EmailApiModule, + SubscriptionDomainModule, UrlGeneratorModule, ], providers: [{ provide: IAlertsRepository, useClass: AlertsRepository }], diff --git a/src/domain/alerts/alerts.repository.ts b/src/domain/alerts/alerts.repository.ts index 51ef42e82b..556bcce0a6 100644 --- a/src/domain/alerts/alerts.repository.ts +++ b/src/domain/alerts/alerts.repository.ts @@ -15,6 +15,9 @@ import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; import { Safe } from '@/domain/safe/entities/safe.entity'; import { UrlGeneratorHelper } from '@/domain/alerts/urls/url-generator.helper'; import { IChainsRepository } from '@/domain/chains/chains.repository.interface'; +import { ISubscriptionRepository } from '@/domain/subscriptions/subscription.repository.interface'; +import { SubscriptionRepository } from '@/domain/subscriptions/subscription.repository'; +import { Account } from '@/domain/account/entities/account.entity'; @Injectable() export class AlertsRepository implements IAlertsRepository { @@ -40,6 +43,8 @@ export class AlertsRepository implements IAlertsRepository { private readonly safeRepository: ISafeRepository, @Inject(IChainsRepository) private readonly chainRepository: IChainsRepository, + @Inject(ISubscriptionRepository) + private readonly subscriptionRepository: ISubscriptionRepository, ) {} async addContracts(contracts: Array): Promise { @@ -63,14 +68,11 @@ export class AlertsRepository implements IAlertsRepository { // Recovery module is deployed per Safe so we can assume that it is only enabled on one const safeAddress = safes[0]; - - const verifiedAccounts = await this.accountRepository.getAccounts({ + const subscribedAccounts = await this._getSubscribedAccounts({ chainId, safeAddress, - onlyVerified: true, }); - - if (verifiedAccounts.length === 0) { + if (subscribedAccounts.length === 0) { this.loggingService.debug( `An alert for a Safe with no associated emails was received. moduleAddress=${moduleAddress}, safeAddress=${safeAddress}`, ); @@ -99,19 +101,55 @@ export class AlertsRepository implements IAlertsRepository { await this._notifySafeSetup({ chainId, newSafeState, + accountsToNotify: subscribedAccounts, }); } catch { - const emails = verifiedAccounts.map( - (account) => account.emailAddress.value, - ); await this._notifyUnknownTransaction({ chainId, safeAddress, - emails, + accountsToNotify: subscribedAccounts, }); } } + /** + * Gets all the subscribed accounts to CATEGORY_ACCOUNT_RECOVERY for a given safe + * + * @param args.chainId - the chain id where the safe is deployed + * @param args.safeAddress - the safe address to which the accounts should be retrieved + * + * @private + */ + private async _getSubscribedAccounts(args: { + chainId: string; + safeAddress: string; + }): Promise { + const accounts = await this.accountRepository.getAccounts({ + chainId: args.chainId, + safeAddress: args.safeAddress, + onlyVerified: true, + }); + + const subscribedAccounts = accounts.map(async (account) => { + const accountSubscriptions = + await this.subscriptionRepository.getSubscriptions({ + chainId: account.chainId, + safeAddress: account.safeAddress, + signer: account.signer, + }); + return accountSubscriptions.some( + (subscription) => + subscription.key === SubscriptionRepository.CATEGORY_ACCOUNT_RECOVERY, + ) + ? account + : null; + }); + + return (await Promise.all(subscribedAccounts)).filter( + (account): account is Account => account !== null, + ); + } + private _decodeTransactionAdded( data: Hex, ): Array> { @@ -197,7 +235,7 @@ export class AlertsRepository implements IAlertsRepository { private async _notifyUnknownTransaction(args: { safeAddress: string; chainId: string; - emails: string[]; + accountsToNotify: Account[]; }): Promise { const chain = await this.chainRepository.getChain(args.chainId); @@ -206,8 +244,11 @@ export class AlertsRepository implements IAlertsRepository { safeAddress: args.safeAddress, }); + const emails = args.accountsToNotify.map( + (account) => account.emailAddress.value, + ); return this.emailApi.createMessage({ - to: args.emails, + to: emails, template: this.configurationService.getOrThrow( 'email.templates.unknownRecoveryTx', ), @@ -221,20 +262,8 @@ export class AlertsRepository implements IAlertsRepository { private async _notifySafeSetup(args: { chainId: string; newSafeState: Safe; + accountsToNotify: Account[]; }): Promise { - const verifiedAccounts = await this.accountRepository.getAccounts({ - chainId: args.chainId, - safeAddress: args.newSafeState.address, - onlyVerified: true, - }); - - if (!verifiedAccounts.length) { - this.loggingService.debug( - `An alert log for an transaction with no verified emails associated was thrown for Safe ${args.newSafeState.address}`, - ); - return; - } - const chain = await this.chainRepository.getChain(args.chainId); const webAppUrl = this.urlGenerator.addressToSafeWebAppUrl({ @@ -251,7 +280,7 @@ export class AlertsRepository implements IAlertsRepository { }; }); - const emails = verifiedAccounts.map( + const emails = args.accountsToNotify.map( (account) => account.emailAddress.value, ); return this.emailApi.createMessage({ diff --git a/src/domain/balances/balances.repository.interface.ts b/src/domain/balances/balances.repository.interface.ts index 05d491d33f..d804754afe 100644 --- a/src/domain/balances/balances.repository.interface.ts +++ b/src/domain/balances/balances.repository.interface.ts @@ -17,10 +17,7 @@ export interface IBalancesRepository { /** * Clears any stored local balance data of {@link safeAddress} on {@link chainId} */ - clearLocalBalances(args: { - chainId: string; - safeAddress: string; - }): Promise; + clearBalances(args: { chainId: string; safeAddress: string }): Promise; /** * Gets the list of supported fiat codes. diff --git a/src/domain/balances/balances.repository.ts b/src/domain/balances/balances.repository.ts index 41ae6d8e6d..d1e621181c 100644 --- a/src/domain/balances/balances.repository.ts +++ b/src/domain/balances/balances.repository.ts @@ -32,24 +32,18 @@ export class BalancesRepository implements IBalancesRepository { trusted?: boolean; excludeSpam?: boolean; }): Promise { + // TODO: route TransactionApi balances retrieval from BalancesApiManager return this.balancesApiManager.useExternalApi(args.chainId) ? this._getBalancesFromBalancesApi(args) : this._getBalancesFromTransactionApi(args); } - async clearLocalBalances(args: { + async clearBalances(args: { chainId: string; safeAddress: string; }): Promise { - if (this.balancesApiManager.useExternalApi(args.chainId)) { - const api = this.balancesApiManager.getBalancesApi(args.chainId); - await api.clearBalances(args); - } else { - const api = await this.transactionApiManager.getTransactionApi( - args.chainId, - ); - await api.clearLocalBalances(args.safeAddress); - } + const api = await this.balancesApiManager.getBalancesApi(args.chainId); + await api.clearBalances(args); } getFiatCodes(): string[] { @@ -63,7 +57,7 @@ export class BalancesRepository implements IBalancesRepository { trusted?: boolean; excludeSpam?: boolean; }): Promise { - const api = this.balancesApiManager.getBalancesApi(args.chainId); + const api = await this.balancesApiManager.getBalancesApi(args.chainId); const balances = await api.getBalances(args); return balances.map((balance) => this.balancesValidator.validate(balance)); } @@ -76,7 +70,7 @@ export class BalancesRepository implements IBalancesRepository { excludeSpam?: boolean; }): Promise { const { chainId, safeAddress, fiatCode, trusted, excludeSpam } = args; - const api = await this.transactionApiManager.getTransactionApi(chainId); + const api = await this.balancesApiManager.getBalancesApi(chainId); const balances = await api.getBalances({ safeAddress, trusted, diff --git a/src/domain/chains/entities/chain.entity.ts b/src/domain/chains/entities/chain.entity.ts index fd6ef59358..09194d0d05 100644 --- a/src/domain/chains/entities/chain.entity.ts +++ b/src/domain/chains/entities/chain.entity.ts @@ -13,8 +13,7 @@ export interface Chain { // TODO: Make required when deemed stable on config service chainLogoUri?: string; l2: boolean; - // TODO: Make required when deemed stable on config service - isTestnet?: boolean; + isTestnet: boolean; shortName: string; rpcUri: RpcUri; safeAppsRpcUri: RpcUri; diff --git a/src/domain/chains/entities/schemas/chain.schema.ts b/src/domain/chains/entities/schemas/chain.schema.ts index d606fb9bca..e18bc1e587 100644 --- a/src/domain/chains/entities/schemas/chain.schema.ts +++ b/src/domain/chains/entities/schemas/chain.schema.ts @@ -123,8 +123,7 @@ export const chainSchema: JSONSchemaType = { // TODO: Make required when deemed stable on config service chainLogoUri: { type: 'string', format: 'uri', nullable: true }, l2: { type: 'boolean' }, - // TODO: Make required when deemed stable on config service - isTestnet: { type: 'boolean', nullable: true }, + isTestnet: { type: 'boolean' }, shortName: { type: 'string' }, rpcUri: { $ref: 'rpc-uri.json' }, safeAppsRpcUri: { $ref: 'rpc-uri.json' }, @@ -149,7 +148,7 @@ export const chainSchema: JSONSchemaType = { 'description', // 'chainLogoUri', 'l2', - // isTestnet, + 'isTestnet', 'shortName', 'rpcUri', 'safeAppsRpcUri', diff --git a/src/domain/collectibles/collectibles.repository.ts b/src/domain/collectibles/collectibles.repository.ts index f902c17a52..4ca206ece2 100644 --- a/src/domain/collectibles/collectibles.repository.ts +++ b/src/domain/collectibles/collectibles.repository.ts @@ -4,12 +4,15 @@ import { CollectiblesValidator } from '@/domain/collectibles/collectibles.valida import { Collectible } from '@/domain/collectibles/entities/collectible.entity'; import { Page } from '@/domain/entities/page.entity'; import { ITransactionApiManager } from '@/domain/interfaces/transaction-api.manager.interface'; +import { IBalancesApiManager } from '@/domain/interfaces/balances-api.manager.interface'; @Injectable() export class CollectiblesRepository implements ICollectiblesRepository { constructor( @Inject(ITransactionApiManager) private readonly transactionApiManager: ITransactionApiManager, + @Inject(IBalancesApiManager) + private readonly balancesApiManager: IBalancesApiManager, private readonly validator: CollectiblesValidator, ) {} @@ -21,16 +24,9 @@ export class CollectiblesRepository implements ICollectiblesRepository { trusted?: boolean; excludeSpam?: boolean; }): Promise> { - const transactionApi = await this.transactionApiManager.getTransactionApi( - args.chainId, - ); - const page = await transactionApi.getCollectibles({ - safeAddress: args.safeAddress, - limit: args.limit, - offset: args.offset, - trusted: args.trusted, - excludeSpam: args.excludeSpam, - }); + const page = (await this.balancesApiManager.useExternalApi(args.chainId)) + ? await this._getCollectiblesFromBalancesApi(args) + : await this._getCollectiblesFromTransactionApi(args); page?.results.map((result) => this.validator.validate(result)); return page; @@ -40,10 +36,35 @@ export class CollectiblesRepository implements ICollectiblesRepository { chainId: string; safeAddress: string; }): Promise { - const transactionApi = await this.transactionApiManager.getTransactionApi( - args.chainId, - ); + const api = await this.balancesApiManager.getBalancesApi(args.chainId); + await api.clearCollectibles(args); + } + + private async _getCollectiblesFromBalancesApi(args: { + chainId: string; + safeAddress: string; + limit?: number; + offset?: number; + }): Promise> { + const api = await this.balancesApiManager.getBalancesApi(args.chainId); + return api.getCollectibles(args); + } - return transactionApi.clearCollectibles(args.safeAddress); + private async _getCollectiblesFromTransactionApi(args: { + chainId: string; + safeAddress: string; + limit?: number; + offset?: number; + trusted?: boolean; + excludeSpam?: boolean; + }): Promise> { + const api = await this.balancesApiManager.getBalancesApi(args.chainId); + return api.getCollectibles({ + safeAddress: args.safeAddress, + limit: args.limit, + offset: args.offset, + trusted: args.trusted, + excludeSpam: args.excludeSpam, + }); } } diff --git a/src/domain/contracts/contracts/__tests__/erc20-encoder.builder.ts b/src/domain/contracts/contracts/__tests__/erc20-encoder.builder.ts new file mode 100644 index 0000000000..6dea6bf348 --- /dev/null +++ b/src/domain/contracts/contracts/__tests__/erc20-encoder.builder.ts @@ -0,0 +1,37 @@ +import { faker } from '@faker-js/faker'; +import { Hex, parseAbi, encodeFunctionData, getAddress } from 'viem'; +import { Builder } from '@/__tests__/builder'; +import { IEncoder } from '@/__tests__/encoder-builder'; + +// transfer + +type Erc20TransferArgs = { + to: Hex; + value: bigint; +}; + +class Erc20TransferEncoder + extends Builder + implements IEncoder +{ + static readonly FUNCTION_SIGNATURE = + 'function transfer(address to, uint256 value)' as const; + + encode(): Hex { + const abi = parseAbi([Erc20TransferEncoder.FUNCTION_SIGNATURE]); + + const args = this.build(); + + return encodeFunctionData({ + abi, + functionName: 'transfer', + args: [args.to, args.value], + }); + } +} + +export function erc20TransferEncoder(): Erc20TransferEncoder { + return new Erc20TransferEncoder() + .with('to', getAddress(faker.finance.ethereumAddress())) + .with('value', faker.number.bigInt()); +} diff --git a/src/domain/contracts/contracts/__tests__/multi-send-encoder.builder.ts b/src/domain/contracts/contracts/__tests__/multi-send-encoder.builder.ts index 13c5999a4d..79e1ce7105 100644 --- a/src/domain/contracts/contracts/__tests__/multi-send-encoder.builder.ts +++ b/src/domain/contracts/contracts/__tests__/multi-send-encoder.builder.ts @@ -6,9 +6,9 @@ import { encodePacked, getAddress, Hex, - parseAbi, size, } from 'viem'; +import MultiSendCallOnly130 from '@/abis/safe/v1.3.0/MultiSendCallOnly.abi'; import { Builder } from '@/__tests__/builder'; // multiSend @@ -21,16 +21,11 @@ class MultiSendEncoder extends Builder implements IEncoder { - static readonly FUNCTION_SIGNATURE = - 'function multiSend(bytes memory transactions)' as const; - encode(): Hex { - const abi = parseAbi([MultiSendEncoder.FUNCTION_SIGNATURE]); - const args = this.build(); return encodeFunctionData({ - abi, + abi: MultiSendCallOnly130, functionName: 'multiSend', args: [args.transactions], }); diff --git a/src/domain/contracts/contracts/__tests__/safe-decoder.helper.spec.ts b/src/domain/contracts/contracts/__tests__/safe-decoder.helper.spec.ts index f2413eaa20..4f8207c400 100644 --- a/src/domain/contracts/contracts/__tests__/safe-decoder.helper.spec.ts +++ b/src/domain/contracts/contracts/__tests__/safe-decoder.helper.spec.ts @@ -1,5 +1,3 @@ -import { Hex } from 'viem'; -import { faker } from '@faker-js/faker'; import { SafeDecoder } from '@/domain/contracts/contracts/safe-decoder.helper'; import { addOwnerWithThresholdEncoder, @@ -103,10 +101,4 @@ describe('SafeDecoder', () => { ], }); }); - - it('throws if the function call cannot be decoded', () => { - const data = faker.string.hexadecimal({ length: 138 }) as Hex; - - expect(() => target.decodeFunctionData({ data })).toThrow(); - }); }); diff --git a/src/domain/contracts/contracts/__tests__/safe-encoder.builder.ts b/src/domain/contracts/contracts/__tests__/safe-encoder.builder.ts index b8277dec45..021250dafd 100644 --- a/src/domain/contracts/contracts/__tests__/safe-encoder.builder.ts +++ b/src/domain/contracts/contracts/__tests__/safe-encoder.builder.ts @@ -1,6 +1,6 @@ import { faker } from '@faker-js/faker'; -import { encodeFunctionData, getAddress, Hex, pad, parseAbi } from 'viem'; - +import { encodeFunctionData, getAddress, Hex, pad } from 'viem'; +import Safe130 from '@/abis/safe/v1.3.0/GnosisSafe.abi'; import { Safe } from '@/domain/safe/entities/safe.entity'; import { IEncoder } from '@/__tests__/encoder-builder'; import { Builder } from '@/__tests__/builder'; @@ -38,16 +38,11 @@ type SetupArgs = { }; class SetupEncoder extends Builder implements IEncoder { - static readonly FUNCTION_SIGNATURE = - 'function setup(address[] calldata _owners, uint256 _threshold, address to, bytes calldata data, address fallbackHandler, address paymentToken, uint256 payment, address paymentReceiver)'; - encode(): Hex { - const abi = parseAbi([SetupEncoder.FUNCTION_SIGNATURE]); - const args = this.build(); return encodeFunctionData({ - abi, + abi: Safe130, functionName: 'setup', args: [ args.owners, @@ -94,16 +89,11 @@ class ExecTransactionEncoder extends Builder implements IEncoder { - static readonly FUNCTION_SIGNATURE = - 'function execTransaction(address to, uint256 value, bytes calldata data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, bytes signatures)' as const; - encode(): Hex { - const abi = parseAbi([ExecTransactionEncoder.FUNCTION_SIGNATURE]); - const args = this.build(); return encodeFunctionData({ - abi, + abi: Safe130, functionName: 'execTransaction', args: [ args.to, @@ -146,16 +136,11 @@ class AddOwnerWithThresholdEncoder extends Builder implements IEncoder { - static readonly FUNCTION_SIGNATURE = - 'function addOwnerWithThreshold(address owner, uint256 _threshold)' as const; - encode(): Hex { - const abi = parseAbi([AddOwnerWithThresholdEncoder.FUNCTION_SIGNATURE]); - const args = this.build(); return encodeFunctionData({ - abi, + abi: Safe130, functionName: 'addOwnerWithThreshold', args: [args.owner, args.threshold], }); @@ -180,16 +165,11 @@ class RemoveOwnerEncoder extends Builder implements IEncoder { - static readonly FUNCTION_SIGNATURE = - 'function removeOwner(address prevOwner, address owner, uint256 _threshold)'; - encode(): Hex { - const abi = parseAbi([RemoveOwnerEncoder.FUNCTION_SIGNATURE]); - const args = this.build(); return encodeFunctionData({ - abi, + abi: Safe130, functionName: 'removeOwner', args: [args.prevOwner, args.owner, args.threshold], }); @@ -220,16 +200,11 @@ class SwapOwnerEncoder extends Builder implements IEncoder { - static readonly FUNCTION_SIGNATURE = - 'function swapOwner(address prevOwner, address oldOwner, address newOwner)'; - encode(): Hex { - const abi = parseAbi([SwapOwnerEncoder.FUNCTION_SIGNATURE]); - const args = this.build(); return encodeFunctionData({ - abi, + abi: Safe130, functionName: 'swapOwner', args: [args.prevOwner, args.oldOwner, args.newOwner], }); @@ -258,16 +233,11 @@ class ChangeThresholdEncoder extends Builder implements IEncoder { - static readonly FUNCTION_SIGNATURE = - 'function changeThreshold(uint256 _threshold)'; - encode(): Hex { - const abi = parseAbi([ChangeThresholdEncoder.FUNCTION_SIGNATURE]); - const args = this.build(); return encodeFunctionData({ - abi, + abi: Safe130, functionName: 'changeThreshold', args: [args.threshold], }); @@ -280,3 +250,115 @@ export function changeThresholdEncoder(): ChangeThresholdEncoder + extends Builder + implements IEncoder +{ + encode(): Hex { + const args = this.build(); + + return encodeFunctionData({ + abi: Safe130, + functionName: 'enableModule', + args: [args.module], + }); + } +} + +export function enableModuleEncoder(): EnableModuleEncoder { + return new EnableModuleEncoder().with( + 'module', + getAddress(faker.finance.ethereumAddress()), + ); +} + +// disableModule + +type DisableModuleArgs = { + prevModule: Hex; + module: Hex; +}; + +class DisableModuleEncoder + extends Builder + implements IEncoder +{ + encode(): Hex { + const args = this.build(); + + return encodeFunctionData({ + abi: Safe130, + functionName: 'disableModule', + args: [args.prevModule, args.module], + }); + } +} + +export function disableModuleEncoder(): DisableModuleEncoder { + return new DisableModuleEncoder() + .with('prevModule', getAddress(faker.finance.ethereumAddress())) + .with('module', getAddress(faker.finance.ethereumAddress())); +} + +// setFallbackHandler + +type SetFallbackHandlerArgs = { + handler: Hex; +}; + +class SetFallbackHandlerEncoder + extends Builder + implements IEncoder +{ + encode(): Hex { + const args = this.build(); + + return encodeFunctionData({ + abi: Safe130, + functionName: 'setFallbackHandler', + args: [args.handler], + }); + } +} + +export function setFallbackHandlerEncoder(): SetFallbackHandlerEncoder { + return new SetFallbackHandlerEncoder().with( + 'handler', + getAddress(faker.finance.ethereumAddress()), + ); +} + +// setGuard + +type SetGuardArgs = { + guard: Hex; +}; + +class SetGuardEncoder + extends Builder + implements IEncoder +{ + encode(): Hex { + const args = this.build(); + + return encodeFunctionData({ + abi: Safe130, + functionName: 'setGuard', + args: [args.guard], + }); + } +} + +export function setGuardEncoder(): SetGuardEncoder { + return new SetGuardEncoder().with( + 'guard', + getAddress(faker.finance.ethereumAddress()), + ); +} diff --git a/src/domain/contracts/contracts/multi-send-decoder.helper.ts b/src/domain/contracts/contracts/multi-send-decoder.helper.ts index 50d6eabe42..796ee808a4 100644 --- a/src/domain/contracts/contracts/multi-send-decoder.helper.ts +++ b/src/domain/contracts/contracts/multi-send-decoder.helper.ts @@ -1,21 +1,10 @@ import { AbiDecoder } from '@/domain/contracts/contracts/abi-decoder.helper'; import { Injectable } from '@nestjs/common'; -import { - getAddress, - Hex, - hexToBigInt, - hexToNumber, - parseAbi, - size, - slice, -} from 'viem'; - -const MULTISEND_ABI = parseAbi([ - 'function multiSend(bytes memory transactions)', -]); +import { getAddress, Hex, hexToBigInt, hexToNumber, size, slice } from 'viem'; +import MultiSendCallOnly130 from '@/abis/safe/v1.3.0/MultiSendCallOnly.abi'; @Injectable() -export class MultiSendDecoder extends AbiDecoder { +export class MultiSendDecoder extends AbiDecoder { // uint8 operation, address to, value uint256, dataLength uint256, bytes data private static readonly OPERATION_SIZE = 1; private static readonly TO_SIZE = 20; @@ -23,7 +12,7 @@ export class MultiSendDecoder extends AbiDecoder { private static readonly DATA_LENGTH_SIZE = 32; constructor() { - super(MULTISEND_ABI); + super(MultiSendCallOnly130); } mapMultiSendTransactions(multiSendData: Hex): Array<{ diff --git a/src/domain/contracts/contracts/safe-decoder.helper.ts b/src/domain/contracts/contracts/safe-decoder.helper.ts index 5ba13b2db1..dfa8906f93 100644 --- a/src/domain/contracts/contracts/safe-decoder.helper.ts +++ b/src/domain/contracts/contracts/safe-decoder.helper.ts @@ -1,20 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { parseAbi } from 'viem'; +import Safe130 from '@/abis/safe/v1.3.0/GnosisSafe.abi'; import { AbiDecoder } from '@/domain/contracts/contracts/abi-decoder.helper'; -const SAFE_ABI = parseAbi([ - 'function setup(address[] calldata _owners, uint256 _threshold, address to, bytes calldata data, address fallbackHandler, address paymentToken, uint256 payment, address paymentReceiver)', - // Owner management - 'function addOwnerWithThreshold(address owner, uint256 _threshold)', - 'function removeOwner(address prevOwner, address owner, uint256 _threshold)', - 'function swapOwner(address prevOwner, address oldOwner, address newOwner)', - 'function changeThreshold(uint256 _threshold)', - 'function execTransaction(address to, uint256 value, bytes calldata data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, bytes signatures)', -]); - @Injectable() -export class SafeDecoder extends AbiDecoder { +export class SafeDecoder extends AbiDecoder { constructor() { - super(SAFE_ABI); + super(Safe130); } } diff --git a/src/domain/interfaces/balances-api.interface.ts b/src/domain/interfaces/balances-api.interface.ts index 8abba776d4..86657000a5 100644 --- a/src/domain/interfaces/balances-api.interface.ts +++ b/src/domain/interfaces/balances-api.interface.ts @@ -1,13 +1,31 @@ import { Balance } from '@/domain/balances/entities/balance.entity'; +import { Collectible } from '@/domain/collectibles/entities/collectible.entity'; +import { Page } from '@/domain/entities/page.entity'; export interface IBalancesApi { getBalances(args: { - chainId: string; safeAddress: string; - fiatCode: string; + fiatCode?: string; + chainId?: string; + trusted?: boolean; + excludeSpam?: boolean; }): Promise; clearBalances(args: { chainId: string; safeAddress: string }): Promise; + getCollectibles(args: { + safeAddress: string; + chainId?: string; + limit?: number; + offset?: number; + trusted?: boolean; + excludeSpam?: boolean; + }): Promise>; + + clearCollectibles(args: { + chainId: string; + safeAddress: string; + }): Promise; + getFiatCodes(): string[]; } diff --git a/src/domain/interfaces/balances-api.manager.interface.ts b/src/domain/interfaces/balances-api.manager.interface.ts index 1721eb92b3..a838d61f3f 100644 --- a/src/domain/interfaces/balances-api.manager.interface.ts +++ b/src/domain/interfaces/balances-api.manager.interface.ts @@ -20,7 +20,7 @@ export interface IBalancesApiManager { * @param chainId - the chain identifier to check. * @returns {@link IBalancesApi} configured for the input chain ID. */ - getBalancesApi(chainId: string): IBalancesApi; + getBalancesApi(chainId: string): Promise; /** * Gets the list of supported fiat codes. diff --git a/src/domain/interfaces/relay-api.interface.ts b/src/domain/interfaces/relay-api.interface.ts index bc2d87bd95..aa3b65a7e4 100644 --- a/src/domain/interfaces/relay-api.interface.ts +++ b/src/domain/interfaces/relay-api.interface.ts @@ -1,9 +1,10 @@ -import { RelayPayload } from '@/domain/relay/limit-addresses.mapper'; - export const IRelayApi = Symbol('IRelayApi'); export interface IRelayApi { - getRelayCount(args: { chainId: string; address: string }): Promise; - - relay(args: RelayPayload): Promise<{ taskId: string }>; + relay(args: { + chainId: string; + to: string; + data: string; + gasLimit: string | null; + }): Promise<{ taskId: string }>; } diff --git a/src/domain/interfaces/transaction-api.interface.ts b/src/domain/interfaces/transaction-api.interface.ts index fef4371d05..3d8a287979 100644 --- a/src/domain/interfaces/transaction-api.interface.ts +++ b/src/domain/interfaces/transaction-api.interface.ts @@ -1,7 +1,5 @@ import { Backbone } from '@/domain/backbone/entities/backbone.entity'; -import { Balance } from '@/domain/balances/entities/balance.entity'; import { Singleton } from '@/domain/chains/entities/singleton.entity'; -import { Collectible } from '@/domain/collectibles/entities/collectible.entity'; import { Contract } from '@/domain/contracts/entities/contract.entity'; import { DataDecoded } from '@/domain/data-decoder/entities/data-decoded.entity'; import { Delegate } from '@/domain/delegate/entities/delegate.entity'; @@ -22,26 +20,8 @@ import { AddConfirmationDto } from '@/domain/transactions/entities/add-confirmat import { ProposeTransactionDto } from '@/domain/transactions/entities/propose-transaction.dto.entity'; export interface ITransactionApi { - getBalances(args: { - safeAddress: string; - trusted?: boolean; - excludeSpam?: boolean; - }): Promise; - - clearLocalBalances(safeAddress: string): Promise; - getDataDecoded(args: { data: string; to?: string }): Promise; - getCollectibles(args: { - safeAddress: string; - limit?: number; - offset?: number; - trusted?: boolean; - excludeSpam?: boolean; - }): Promise>; - - clearCollectibles(safeAddress: string): Promise; - getBackbone(): Promise; getSingletons(): Promise; diff --git a/src/domain/relay/contracts/__tests__/proxy-factory-encoder.builder.ts b/src/domain/relay/contracts/__tests__/proxy-factory-encoder.builder.ts index bf1007c160..5c027e9570 100644 --- a/src/domain/relay/contracts/__tests__/proxy-factory-encoder.builder.ts +++ b/src/domain/relay/contracts/__tests__/proxy-factory-encoder.builder.ts @@ -1,6 +1,6 @@ import { faker } from '@faker-js/faker'; -import { encodeFunctionData, getAddress, Hex, parseAbi } from 'viem'; - +import { encodeFunctionData, getAddress, Hex } from 'viem'; +import ProxyFactory130 from '@/abis/safe/v1.3.0/GnosisSafeProxyFactory.abi'; import { IEncoder } from '@/__tests__/encoder-builder'; import { Builder } from '@/__tests__/builder'; import { setupEncoder } from '@/domain/contracts/contracts/__tests__/safe-encoder.builder'; @@ -17,16 +17,11 @@ class SetupEncoder extends Builder implements IEncoder { - static readonly FUNCTION_SIGNATURE = - 'function createProxyWithNonce(address _singleton, bytes memory initializer, uint256 saltNonce)'; - encode(): Hex { - const abi = parseAbi([SetupEncoder.FUNCTION_SIGNATURE]); - const args = this.build(); return encodeFunctionData({ - abi, + abi: ProxyFactory130, functionName: 'createProxyWithNonce', args: [args.singleton, args.initializer, args.saltNonce], }); diff --git a/src/domain/relay/contracts/proxy-factory-decoder.helper.ts b/src/domain/relay/contracts/proxy-factory-decoder.helper.ts index 7d5d379315..297befbc4a 100644 --- a/src/domain/relay/contracts/proxy-factory-decoder.helper.ts +++ b/src/domain/relay/contracts/proxy-factory-decoder.helper.ts @@ -1,14 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { parseAbi } from 'viem'; +import ProxyFactory130 from '@/abis/safe/v1.3.0/GnosisSafeProxyFactory.abi'; import { AbiDecoder } from '@/domain/contracts/contracts/abi-decoder.helper'; -const PROXY_FACTORY_ABI = parseAbi([ - 'function createProxyWithNonce(address _singleton, bytes memory initializer, uint256 saltNonce)', -]); - @Injectable() -export class ProxyFactoryDecoder extends AbiDecoder { +export class ProxyFactoryDecoder extends AbiDecoder { constructor() { - super(PROXY_FACTORY_ABI); + super(ProxyFactory130); } } diff --git a/src/domain/relay/errors/invalid-multisend.error.ts b/src/domain/relay/errors/invalid-multisend.error.ts new file mode 100644 index 0000000000..4fb302e3da --- /dev/null +++ b/src/domain/relay/errors/invalid-multisend.error.ts @@ -0,0 +1,7 @@ +export class InvalidMultiSendError extends Error { + constructor() { + super( + 'Invalid multiSend call. The batch is not all execTransaction calls to same address.', + ); + } +} diff --git a/src/domain/relay/errors/invalid-transfer.error.ts b/src/domain/relay/errors/invalid-transfer.error.ts new file mode 100644 index 0000000000..3a7e5eda96 --- /dev/null +++ b/src/domain/relay/errors/invalid-transfer.error.ts @@ -0,0 +1,7 @@ +export class InvalidTransferError extends Error { + constructor() { + super( + 'Invalid transfer. The proposed transfer is not an execTransaction, multiSend, or createProxyWithNonce call.', + ); + } +} diff --git a/src/domain/relay/errors/relay-limit-reached.error.ts b/src/domain/relay/errors/relay-limit-reached.error.ts new file mode 100644 index 0000000000..3ae3abc3d0 --- /dev/null +++ b/src/domain/relay/errors/relay-limit-reached.error.ts @@ -0,0 +1,13 @@ +import { Hex } from 'viem'; + +export class RelayLimitReachedError extends Error { + constructor( + readonly address: Hex, + readonly current: number, + readonly limit: number, + ) { + super( + `Relay limit reached for ${address} | current: ${current} | limit: ${limit}`, + ); + } +} diff --git a/src/domain/relay/errors/unofficial-master-copy.error.ts b/src/domain/relay/errors/unofficial-master-copy.error.ts new file mode 100644 index 0000000000..5062f3ad44 --- /dev/null +++ b/src/domain/relay/errors/unofficial-master-copy.error.ts @@ -0,0 +1,7 @@ +export class UnofficialMasterCopyError extends Error { + constructor() { + super( + 'Safe attempting to relay is not official. Only official Safe singletons are supported.', + ); + } +} diff --git a/src/domain/relay/errors/unofficial-multisend.error.ts b/src/domain/relay/errors/unofficial-multisend.error.ts new file mode 100644 index 0000000000..3867383a4f --- /dev/null +++ b/src/domain/relay/errors/unofficial-multisend.error.ts @@ -0,0 +1,7 @@ +export class UnofficialMultiSendError extends Error { + constructor() { + super( + 'MultiSend contract is not official. Only official MultiSend contracts are supported.', + ); + } +} diff --git a/src/domain/relay/exception-filters/invalid-multisend.exception-filter.ts b/src/domain/relay/exception-filters/invalid-multisend.exception-filter.ts new file mode 100644 index 0000000000..46930a2630 --- /dev/null +++ b/src/domain/relay/exception-filters/invalid-multisend.exception-filter.ts @@ -0,0 +1,21 @@ +import { Response } from 'express'; +import { + Catch, + ExceptionFilter, + ArgumentsHost, + HttpStatus, +} from '@nestjs/common'; +import { InvalidMultiSendError } from '@/domain/relay/errors/invalid-multisend.error'; + +@Catch(InvalidMultiSendError) +export class InvalidMultiSendExceptionFilter implements ExceptionFilter { + catch(error: InvalidMultiSendError, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + response.status(HttpStatus.UNPROCESSABLE_ENTITY).json({ + message: error.message, + statusCode: HttpStatus.UNPROCESSABLE_ENTITY, + }); + } +} diff --git a/src/domain/relay/exception-filters/invalid-transfer.exception-filter.ts b/src/domain/relay/exception-filters/invalid-transfer.exception-filter.ts new file mode 100644 index 0000000000..20fb476bae --- /dev/null +++ b/src/domain/relay/exception-filters/invalid-transfer.exception-filter.ts @@ -0,0 +1,21 @@ +import { Response } from 'express'; +import { + Catch, + ExceptionFilter, + ArgumentsHost, + HttpStatus, +} from '@nestjs/common'; +import { InvalidTransferError } from '@/domain/relay/errors/invalid-transfer.error'; + +@Catch(InvalidTransferError) +export class InvalidTransferExceptionFilter implements ExceptionFilter { + catch(error: InvalidTransferError, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + response.status(HttpStatus.UNPROCESSABLE_ENTITY).json({ + message: error.message, + statusCode: HttpStatus.UNPROCESSABLE_ENTITY, + }); + } +} diff --git a/src/domain/relay/exception-filters/relay-limit-reached.exception-filter.ts b/src/domain/relay/exception-filters/relay-limit-reached.exception-filter.ts new file mode 100644 index 0000000000..cf72216569 --- /dev/null +++ b/src/domain/relay/exception-filters/relay-limit-reached.exception-filter.ts @@ -0,0 +1,21 @@ +import { RelayLimitReachedError } from '@/domain/relay/errors/relay-limit-reached.error'; +import { Response } from 'express'; +import { + Catch, + ExceptionFilter, + ArgumentsHost, + HttpStatus, +} from '@nestjs/common'; + +@Catch(RelayLimitReachedError) +export class RelayLimitReachedExceptionFilter implements ExceptionFilter { + catch(exception: RelayLimitReachedError, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + response.status(HttpStatus.TOO_MANY_REQUESTS).json({ + message: `Relay limit reached for ${exception.address}`, + statusCode: HttpStatus.TOO_MANY_REQUESTS, + }); + } +} diff --git a/src/domain/relay/exception-filters/unofficial-master-copy.exception-filter.ts b/src/domain/relay/exception-filters/unofficial-master-copy.exception-filter.ts new file mode 100644 index 0000000000..ada7e353c2 --- /dev/null +++ b/src/domain/relay/exception-filters/unofficial-master-copy.exception-filter.ts @@ -0,0 +1,21 @@ +import { Response } from 'express'; +import { + Catch, + ExceptionFilter, + ArgumentsHost, + HttpStatus, +} from '@nestjs/common'; +import { UnofficialMasterCopyError } from '@/domain/relay/errors/unofficial-master-copy.error'; + +@Catch(UnofficialMasterCopyError) +export class UnofficialMasterCopyExceptionFilter implements ExceptionFilter { + catch(_: UnofficialMasterCopyError, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + response.status(HttpStatus.UNPROCESSABLE_ENTITY).json({ + message: 'Unsupported base contract.', + statusCode: HttpStatus.UNPROCESSABLE_ENTITY, + }); + } +} diff --git a/src/domain/relay/exception-filters/unofficial-multisend.error.ts b/src/domain/relay/exception-filters/unofficial-multisend.error.ts new file mode 100644 index 0000000000..41b9ea1f7b --- /dev/null +++ b/src/domain/relay/exception-filters/unofficial-multisend.error.ts @@ -0,0 +1,21 @@ +import { Response } from 'express'; +import { + Catch, + ExceptionFilter, + ArgumentsHost, + HttpStatus, +} from '@nestjs/common'; +import { UnofficialMultiSendError } from '@/domain/relay/errors/unofficial-multisend.error'; + +@Catch(UnofficialMultiSendError) +export class UnofficialMultiSendExceptionFilter implements ExceptionFilter { + catch(_: UnofficialMultiSendError, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + response.status(HttpStatus.UNPROCESSABLE_ENTITY).json({ + message: 'Unofficial MultiSend contract.', + statusCode: HttpStatus.UNPROCESSABLE_ENTITY, + }); + } +} diff --git a/src/domain/relay/limit-addresses.mapper.spec.ts b/src/domain/relay/limit-addresses.mapper.spec.ts new file mode 100644 index 0000000000..0bc849b0e7 --- /dev/null +++ b/src/domain/relay/limit-addresses.mapper.spec.ts @@ -0,0 +1,736 @@ +import { erc20TransferEncoder } from '@/domain/contracts/contracts/__tests__/erc20-encoder.builder'; +import { + multiSendEncoder, + multiSendTransactionsEncoder, +} from '@/domain/contracts/contracts/__tests__/multi-send-encoder.builder'; +import { + addOwnerWithThresholdEncoder, + changeThresholdEncoder, + disableModuleEncoder, + enableModuleEncoder, + execTransactionEncoder, + removeOwnerEncoder, + setFallbackHandlerEncoder, + setGuardEncoder, + setupEncoder, + swapOwnerEncoder, +} from '@/domain/contracts/contracts/__tests__/safe-encoder.builder'; +import { MultiSendDecoder } from '@/domain/contracts/contracts/multi-send-decoder.helper'; +import { SafeDecoder } from '@/domain/contracts/contracts/safe-decoder.helper'; +import { createProxyWithNonceEncoder } from '@/domain/relay/contracts/__tests__/proxy-factory-encoder.builder'; +import { Erc20ContractHelper } from '@/domain/relay/contracts/erc20-contract.helper'; +import { ProxyFactoryDecoder } from '@/domain/relay/contracts/proxy-factory-decoder.helper'; +import { SafeContractHelper } from '@/domain/relay/contracts/safe-contract.helper'; +import { LimitAddressesMapper } from '@/domain/relay/limit-addresses.mapper'; +import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; +import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; +import { faker } from '@faker-js/faker'; +import { + getMultiSendCallOnlyDeployment, + getMultiSendDeployment, + getSafeL2SingletonDeployment, + getSafeSingletonDeployment, +} from '@safe-global/safe-deployments'; +import { Hex, getAddress } from 'viem'; + +const mockSafeRepository = jest.mocked({ + getSafe: jest.fn(), +} as jest.MockedObjectDeep); + +describe('LimitAddressesMapper', () => { + let target: LimitAddressesMapper; + + beforeEach(() => { + jest.resetAllMocks(); + + const safeContractHelper = new SafeContractHelper(); + const erc20ContractHelper = new Erc20ContractHelper(); + const safeDecoder = new SafeDecoder(); + const multiSendDecoder = new MultiSendDecoder(); + const proxyFactoryDecoder = new ProxyFactoryDecoder(); + + target = new LimitAddressesMapper( + mockSafeRepository, + safeContractHelper, + erc20ContractHelper, + safeDecoder, + multiSendDecoder, + proxyFactoryDecoder, + ); + }); + + describe('execTransaction', () => { + // execTransaction + it('should return the limit address when sending native currency to another party', async () => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().build(); + const safeAddress = getAddress(safe.address); + const data = execTransactionEncoder() + .with('value', faker.number.bigInt()) + .encode() as Hex; + // Official mastercopy + mockSafeRepository.getSafe.mockResolvedValue(safe); + + const expectedLimitAddresses = await target.getLimitAddresses({ + chainId, + data, + to: safeAddress, + }); + expect(expectedLimitAddresses).toStrictEqual([safeAddress]); + }); + + // transfer (execTransaction) + it('should return the limit when sending ERC-20 tokens to another party', async () => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().build(); + const safeAddress = getAddress(safe.address); + const data = execTransactionEncoder() + .with('data', erc20TransferEncoder().encode()) + .encode() as Hex; + // Official mastercopy + mockSafeRepository.getSafe.mockResolvedValue(safe); + + const expectedLimitAddresses = await target.getLimitAddresses({ + chainId, + data, + to: safeAddress, + }); + expect(expectedLimitAddresses).toStrictEqual([safeAddress]); + }); + + // cancellation (execTransaction) + it('should return the limit address when cancelling a transaction', async () => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().build(); + const safeAddress = getAddress(safe.address); + const data = execTransactionEncoder() + .with('to', safeAddress) + .with('data', '0x') + .encode() as Hex; + // Official mastercopy + mockSafeRepository.getSafe.mockResolvedValue(safe); + + const expectedLimitAddresses = await target.getLimitAddresses({ + chainId, + data, + to: safeAddress, + }); + expect(expectedLimitAddresses).toStrictEqual([safeAddress]); + }); + + // addOwnerWithThreshold (execTransaction) + it('should return the limit address when making an addOwnerWithThreshold call', async () => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().build(); + const safeAddress = getAddress(safe.address); + const data = execTransactionEncoder() + .with('to', safeAddress) + .with('data', addOwnerWithThresholdEncoder().encode()) + .encode() as Hex; + // Official mastercopy + mockSafeRepository.getSafe.mockResolvedValue(safe); + + const expectedLimitAddresses = await target.getLimitAddresses({ + chainId, + data, + to: safeAddress, + }); + expect(expectedLimitAddresses).toStrictEqual([safeAddress]); + }); + + // changeThreshold (execTransaction) + it('should return the limit address when making a changeThreshold call', async () => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().build(); + const safeAddress = getAddress(safe.address); + const data = execTransactionEncoder() + .with('to', safeAddress) + .with('data', changeThresholdEncoder().encode()) + .encode() as Hex; + // Official mastercopy + mockSafeRepository.getSafe.mockResolvedValue(safe); + + const expectedLimitAddresses = await target.getLimitAddresses({ + chainId, + data, + to: safeAddress, + }); + expect(expectedLimitAddresses).toStrictEqual([safeAddress]); + }); + + // enableModule (execTransaction) + it('should return the limit address when making a enableModule call', async () => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().build(); + const safeAddress = getAddress(safe.address); + const data = execTransactionEncoder() + .with('to', safeAddress) + .with('data', enableModuleEncoder().encode()) + .encode() as Hex; + // Official mastercopy + mockSafeRepository.getSafe.mockResolvedValue(safe); + + const expectedLimitAddresses = await target.getLimitAddresses({ + chainId, + data, + to: safeAddress, + }); + expect(expectedLimitAddresses).toStrictEqual([safeAddress]); + }); + + // disableModule (execTransaction) + it('should return the limit address when making a disableModule call', async () => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().build(); + const safeAddress = getAddress(safe.address); + const data = execTransactionEncoder() + .with('to', safeAddress) + .with('data', disableModuleEncoder().encode()) + .encode() as Hex; + // Official mastercopy + mockSafeRepository.getSafe.mockResolvedValue(safe); + + const expectedLimitAddresses = await target.getLimitAddresses({ + chainId, + data, + to: safeAddress, + }); + expect(expectedLimitAddresses).toStrictEqual([safeAddress]); + }); + + // removeOwner (execTransaction) + it('should return the limit address when making a removeOwner call', async () => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().build(); + const safeAddress = getAddress(safe.address); + const data = execTransactionEncoder() + .with('to', safeAddress) + .with('data', removeOwnerEncoder().encode()) + .encode() as Hex; + // Official mastercopy + mockSafeRepository.getSafe.mockResolvedValue(safe); + + const expectedLimitAddresses = await target.getLimitAddresses({ + chainId, + data, + to: safeAddress, + }); + expect(expectedLimitAddresses).toStrictEqual([safeAddress]); + }); + + // setFallbackHandler (execTransaction) + it('should return the limit address when making a setFallbackHandler call', async () => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().build(); + const safeAddress = getAddress(safe.address); + const data = execTransactionEncoder() + .with('to', safeAddress) + .with('data', setFallbackHandlerEncoder().encode()) + .encode() as Hex; + // Official mastercopy + mockSafeRepository.getSafe.mockResolvedValue(safe); + + const expectedLimitAddresses = await target.getLimitAddresses({ + chainId, + data, + to: safeAddress, + }); + expect(expectedLimitAddresses).toStrictEqual([safeAddress]); + }); + + // setGuard (execTransaction) + it('should return the limit address when making a setGuard call', async () => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().build(); + const safeAddress = getAddress(safe.address); + const data = execTransactionEncoder() + .with('to', safeAddress) + .with('data', setGuardEncoder().encode()) + .encode() as Hex; + // Official mastercopy + mockSafeRepository.getSafe.mockResolvedValue(safe); + + const expectedLimitAddresses = await target.getLimitAddresses({ + chainId, + data, + to: safeAddress, + }); + expect(expectedLimitAddresses).toStrictEqual([safeAddress]); + }); + + // swapOwner (execTransaction) + it('should return the limit address when making a swapOwner call', async () => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().build(); + const safeAddress = getAddress(safe.address); + const data = execTransactionEncoder() + .with('to', safeAddress) + .with('data', swapOwnerEncoder().encode()) + .encode() as Hex; + // Official mastercopy + mockSafeRepository.getSafe.mockResolvedValue(safe); + + const expectedLimitAddresses = await target.getLimitAddresses({ + chainId, + data, + to: safeAddress, + }); + expect(expectedLimitAddresses).toStrictEqual([safeAddress]); + }); + + // execTransaction (execTransaction) + it('should return the limit address calling execTransaction on a nested Safe', async () => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().build(); + const safeAddress = getAddress(safe.address); + const data = execTransactionEncoder() + .with('data', execTransactionEncoder().encode()) + .encode() as Hex; + // Official mastercopy + mockSafeRepository.getSafe.mockResolvedValue(safe); + + const expectedLimitAddresses = await target.getLimitAddresses({ + chainId, + data, + to: safeAddress, + }); + expect(expectedLimitAddresses).toStrictEqual([safeAddress]); + }); + + // execTransaction + it('should throw when sending native currency to self', async () => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().build(); + const safeAddress = getAddress(safe.address); + const data = execTransactionEncoder() + .with('to', safeAddress) + .with('value', faker.number.bigInt()) + .encode() as Hex; + // Official mastercopy + mockSafeRepository.getSafe.mockRejectedValue(true); + + await expect( + target.getLimitAddresses({ + chainId, + data, + to: safeAddress, + }), + ).rejects.toThrow( + 'Invalid transfer. The proposed transfer is not an execTransaction, multiSend, or createProxyWithNonce call.', + ); + }); + + // transfer (execTransaction) + it('should throw when sending ERC-20 tokens to self', async () => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().build(); + const safeAddress = getAddress(safe.address); + const data = execTransactionEncoder() + .with('data', erc20TransferEncoder().with('to', safeAddress).encode()) + .encode() as Hex; + // Official mastercopy + mockSafeRepository.getSafe.mockResolvedValue(safe); + + await expect( + target.getLimitAddresses({ + chainId, + data, + to: safeAddress, + }), + ).rejects.toThrow( + 'Invalid transfer. The proposed transfer is not an execTransaction, multiSend, or createProxyWithNonce call.', + ); + }); + + // Unofficial mastercopy + it('should throw when the mastercopy is not official', async () => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().build(); + const safeAddress = getAddress(safe.address); + const data = execTransactionEncoder() + .with('to', safeAddress) + .encode() as Hex; + // Unofficial mastercopy + mockSafeRepository.getSafe.mockRejectedValue( + new Error('Not official mastercopy'), + ); + + await expect( + target.getLimitAddresses({ + chainId, + data, + to: safeAddress, + }), + ).rejects.toThrow( + 'Safe attempting to relay is not official. Only official Safe singletons are supported.', + ); + }); + }); + + describe('multiSend', () => { + it('should return the limit address when entire batch is valid', async () => { + // Fixed chain ID for deployment address + const chainId = '1'; + const version = '1.3.0'; + 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]; + // Official mastercopy + mockSafeRepository.getSafe.mockResolvedValue(safe); + + const expectedLimitAddresses = await target.getLimitAddresses({ + chainId, + data, + to: getAddress(to), + }); + expect(expectedLimitAddresses).toStrictEqual([safeAddress]); + }); + + it('should return the limit address for valid "standard" MultiSend calls', async () => { + // Fixed chain ID for deployment address + const chainId = '1'; + const version = '1.3.0'; + 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]; + // Official mastercopy + mockSafeRepository.getSafe.mockResolvedValue(safe); + + const expectedLimitAddresses = await target.getLimitAddresses({ + chainId, + data, + to: getAddress(to), + }); + expect(expectedLimitAddresses).toStrictEqual([safeAddress]); + }); + + it('should throw when the batch has an invalid transaction', async () => { + // Fixed chain ID for deployment address + const chainId = '1'; + const version = '1.3.0'; + const safe = safeBuilder().build(); + const transactions = [ + execTransactionEncoder().encode(), + // Native ERC-20 transfer + erc20TransferEncoder().encode(), + ].map((data) => ({ + operation: faker.number.int({ min: 0, max: 1 }), + data, + to: getAddress(safe.address), + value: faker.number.bigInt(), + })); + const data = multiSendEncoder() + .with('transactions', multiSendTransactionsEncoder(transactions)) + .encode(); + const to = getMultiSendCallOnlyDeployment({ + version, + network: chainId, + })!.networkAddresses[chainId]; + // Official mastercopy + mockSafeRepository.getSafe.mockResolvedValue(safe); + + await expect( + target.getLimitAddresses({ + chainId, + data, + to: getAddress(to), + }), + ).rejects.toThrow( + 'Invalid multiSend call. The batch is not all execTransaction calls to same address.', + ); + }); + + it('should throw when the mastercopy is not official', async () => { + // Fixed chain ID for deployment address + const chainId = '1'; + const version = '1.3.0'; + const safe = safeBuilder().build(); + const transactions = [ + execTransactionEncoder().encode(), + execTransactionEncoder().encode(), + ].map((data) => ({ + operation: faker.number.int({ min: 0, max: 1 }), + data, + to: getAddress(safe.address), + value: faker.number.bigInt(), + })); + const data = multiSendEncoder() + .with('transactions', multiSendTransactionsEncoder(transactions)) + .encode(); + const to = getMultiSendCallOnlyDeployment({ + version, + network: chainId, + })!.networkAddresses[chainId]; + // Unofficial mastercopy + mockSafeRepository.getSafe.mockRejectedValue( + new Error('Not official mastercopy'), + ); + + await expect( + target.getLimitAddresses({ + chainId, + data, + to: getAddress(to), + }), + ).rejects.toThrow( + 'Safe attempting to relay is not official. Only official Safe singletons are supported.', + ); + }); + + it('should throw when the batch is to varying parties', async () => { + const chainId = '1'; + const version = '1.3.0'; + const safe = safeBuilder().build(); + const safeAddress = getAddress(safe.address); + const otherParty = getAddress(faker.finance.ethereumAddress()); + const transactions = [ + execTransactionEncoder().with('to', safeAddress).encode(), + execTransactionEncoder().with('to', otherParty).encode(), + ].map((data, i) => ({ + operation: faker.number.int({ min: 0, max: 1 }), + data, + // Varying parties + to: i === 0 ? safeAddress : otherParty, + value: faker.number.bigInt(), + })); + const data = multiSendEncoder() + .with('transactions', multiSendTransactionsEncoder(transactions)) + .encode(); + const to = getMultiSendCallOnlyDeployment({ + version, + network: chainId, + })!.networkAddresses[chainId]; + // Official mastercopy + mockSafeRepository.getSafe.mockResolvedValue(safe); + + await expect( + target.getLimitAddresses({ + chainId, + data, + to: getAddress(to), + }), + ).rejects.toThrow( + 'Invalid multiSend call. The batch is not all execTransaction calls to same address.', + ); + }); + + it('should throw for unofficial MultiSend deployments', async () => { + const chainId = faker.string.numeric(); + 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(); + // Unofficial MultiSend deployment + const to = getAddress(faker.finance.ethereumAddress()); + // Official mastercopy + mockSafeRepository.getSafe.mockResolvedValue(safe); + + await expect( + target.getLimitAddresses({ + chainId, + data, + to, + }), + ).rejects.toThrow( + 'MultiSend contract is not official. Only official MultiSend contracts are supported.', + ); + }); + }); + + describe('createProxyWithNonce', () => { + it('should return the limit addresses when creating an official L1 Safe', async () => { + // Fixed chain ID for deployment address + const chainId = '1'; + const version = '1.3.0'; + const owners = [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ]; + const singleton = getSafeSingletonDeployment({ + version, + network: chainId, + })!.networkAddresses[chainId]; + const data = createProxyWithNonceEncoder() + .with('singleton', getAddress(singleton)) + .with('initializer', setupEncoder().with('owners', owners).encode()) + .encode(); + // ProxyFactory address (singletons are checked for official mastercopies so we need not check this) + const to = getAddress(faker.finance.ethereumAddress()); + + const expectedLimitAddresses = await target.getLimitAddresses({ + chainId, + data, + to, + }); + expect(expectedLimitAddresses).toStrictEqual(owners); + }); + + it('should return the limit addresses when creating an official L2 Safe', async () => { + // Fixed chain ID for deployment address + const chainId = '1'; + const version = '1.3.0'; + const owners = [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ]; + const singleton = getSafeL2SingletonDeployment({ + version, + network: chainId, + })!.networkAddresses[chainId]; + const data = createProxyWithNonceEncoder() + .with('singleton', getAddress(singleton)) + .with('initializer', setupEncoder().with('owners', owners).encode()) + .encode(); + // ProxyFactory address (singletons are checked for official mastercopies so we need not check this) + const to = getAddress(faker.finance.ethereumAddress()); + + const expectedLimitAddresses = await target.getLimitAddresses({ + chainId, + data, + to, + }); + expect(expectedLimitAddresses).toStrictEqual(owners); + }); + + it('should throw when creating an unofficial Safe', async () => { + const chainId = faker.string.numeric(); + const owners = [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ]; + // Unofficial singleton + const singleton = getAddress(faker.finance.ethereumAddress()); + const data = createProxyWithNonceEncoder() + .with('singleton', getAddress(singleton)) + .with('initializer', setupEncoder().with('owners', owners).encode()) + .encode(); + // ProxyFactory address (singletons are checked for official mastercopies so we need not check this) + const to = getAddress(faker.finance.ethereumAddress()); + + await expect( + target.getLimitAddresses({ + chainId, + data, + to, + }), + ).rejects.toThrow( + 'Invalid transfer. The proposed transfer is not an execTransaction, multiSend, or createProxyWithNonce call.', + ); + }); + }); + + describe('Validation', () => { + it('should throw if not an execTransaction, multiSend or createProxyWithNonceCall', async () => { + const chainId = faker.string.numeric(); + const safe = safeBuilder().build(); + const safeAddress = getAddress(safe.address); + const data = erc20TransferEncoder().encode(); + // Official mastercopy + mockSafeRepository.getSafe.mockResolvedValue(safe); + + await expect( + target.getLimitAddresses({ + chainId, + data, + to: safeAddress, + }), + ).rejects.toThrow( + 'Invalid transfer. The proposed transfer is not an execTransaction, multiSend, or createProxyWithNonce call.', + ); + }); + + it('should throw if the to address is not valid', async () => { + const chainId = faker.string.numeric(); + const to = '0x000000000000000000000000000000000INVALID'; + const data = erc20TransferEncoder().encode(); + + await expect( + target.getLimitAddresses({ + chainId, + data, + to, + }), + ).rejects.toThrow('Invalid to provided'); + }); + + it('should throw if the to address is not hexadecimal', async () => { + const chainId = faker.string.numeric(); + const to = 'not hexadecimal'; + const data = erc20TransferEncoder().encode(); + + await expect( + target.getLimitAddresses({ + chainId, + data, + to, + }), + ).rejects.toThrow('Invalid to provided'); + }); + + it('should throw if the calldata is not hexadecimal', async () => { + const chainId = faker.string.numeric(); + const to = faker.finance.ethereumAddress(); + const data = 'not hexadecimal'; + + await expect( + target.getLimitAddresses({ + chainId, + data, + to, + }), + ).rejects.toThrow('Invalid data provided'); + }); + }); +}); diff --git a/src/domain/relay/limit-addresses.mapper.ts b/src/domain/relay/limit-addresses.mapper.ts index a8644dc37c..21d4a23225 100644 --- a/src/domain/relay/limit-addresses.mapper.ts +++ b/src/domain/relay/limit-addresses.mapper.ts @@ -2,22 +2,21 @@ import { Inject, Injectable } from '@nestjs/common'; import { Hex } from 'viem/types/misc'; import { Erc20ContractHelper } from '@/domain/relay/contracts/erc20-contract.helper'; import { SafeContractHelper } from '@/domain/relay/contracts/safe-contract.helper'; -import { ILoggingService, LoggingService } from '@/logging/logging.interface'; +import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; import { MultiSendDecoder } from '@/domain/contracts/contracts/multi-send-decoder.helper'; import { ProxyFactoryDecoder } from '@/domain/relay/contracts/proxy-factory-decoder.helper'; import { getSafeSingletonDeployment, getSafeL2SingletonDeployment, + getMultiSendCallOnlyDeployment, + getMultiSendDeployment, } from '@safe-global/safe-deployments'; import { SafeDecoder } from '@/domain/contracts/contracts/safe-decoder.helper'; - -// TODO: Coerce DTO to match RelayPayload -export interface RelayPayload { - chainId: string; - data: Hex; - to: Hex; - gasLimit?: bigint; -} +import { isAddress, isHex } from 'viem'; +import { UnofficialMasterCopyError } from '@/domain/relay/errors/unofficial-master-copy.error'; +import { UnofficialMultiSendError } from '@/domain/relay/errors/unofficial-multisend.error'; +import { InvalidTransferError } from '@/domain/relay/errors/invalid-transfer.error'; +import { InvalidMultiSendError } from '@/domain/relay/errors/invalid-multisend.error'; @Injectable() export class LimitAddressesMapper { @@ -25,7 +24,8 @@ export class LimitAddressesMapper { private static SUPPORTED_SAFE_VERSION = '1.3.0'; constructor( - @Inject(LoggingService) private readonly loggingService: ILoggingService, + @Inject(ISafeRepository) + private readonly safeRepository: ISafeRepository, private readonly safeContract: SafeContractHelper, private readonly erc20Contract: Erc20ContractHelper, private readonly safeDecoder: SafeDecoder, @@ -33,33 +33,89 @@ export class LimitAddressesMapper { private readonly proxyFactoryDecoder: ProxyFactoryDecoder, ) {} - getLimitAddresses(relayPayload: RelayPayload): readonly Hex[] { - if (this.isValidExecTransactionCall(relayPayload.to, relayPayload.data)) { - return [relayPayload.to]; + async getLimitAddresses(args: { + chainId: string; + to: string; + data: string; + }): Promise { + if (!isAddress(args.to)) { + throw Error('Invalid to provided'); } - if (this.multiSendDecoder.isMultiSend(relayPayload.data)) { - // Validity of MultiSend is part of address retrieval - const safeAddress = this.getSafeAddressFromMultiSend(relayPayload.data); + if (!isHex(args.data)) { + throw Error('Invalid data provided'); + } + + // Calldata matches that of execTransaction and meets validity requirements + if ( + this.isValidExecTransactionCall({ + to: args.to, + data: args.data, + }) + ) { + // Safe attempting to relay is official + const isOfficial = await this.isOfficialMastercopy({ + chainId: args.chainId, + address: args.to, + }); + + if (!isOfficial) { + throw new UnofficialMasterCopyError(); + } + + // Safe targeted by execTransaction will be limited + return [args.to]; + } + + // Calldata matches that of multiSend and is from an official MultiSend contract + if (this.multiSendDecoder.isMultiSend(args.data)) { + if ( + !this.isOfficialMultiSendDeployment({ + chainId: args.chainId, + address: args.to, + }) + ) { + throw new UnofficialMultiSendError(); + } + + // multiSend calldata meets the validity requirements + const safeAddress = this.getSafeAddressFromMultiSend(args.data); + + // Safe attempting to relay is official + const isOfficial = await this.isOfficialMastercopy({ + chainId: args.chainId, + address: safeAddress, + }); + + if (!isOfficial) { + throw new UnofficialMasterCopyError(); + } + + // Safe targeted in batch will be limited return [safeAddress]; } + // Calldata matches that of createProxyWithNonce and meets validity requirements if ( - this.isValidCreateProxyWithNonce(relayPayload.chainId, relayPayload.data) + this.isValidCreateProxyWithNonceCall({ + chainId: args.chainId, + data: args.data, + }) ) { - return this.getOwnersFromCreateProxyWithNonce(relayPayload.data); + // Owners of safe-to-be-created will be limited + return this.getOwnersFromCreateProxyWithNonce(args.data); } - throw Error('Cannot get limit addresses – Invalid transfer'); + throw new InvalidTransferError(); } - private isValidExecTransactionCall(to: string, data: Hex): boolean { + private isValidExecTransactionCall(args: { to: string; data: Hex }): boolean { let execTransaction: { data: Hex; to: Hex; value: bigint }; // If transaction is an execTransaction try { execTransaction = this.safeContract.decode( SafeContractHelper.EXEC_TRANSACTION, - data, + args.data, ); } catch (e) { return false; @@ -72,13 +128,13 @@ export class LimitAddressesMapper { execTransaction.data, ); // If the ERC20 transfer targets 'self' (the Safe), we consider it to be invalid - return erc20DecodedData.to !== to; + return erc20DecodedData.to !== args.to; } catch { // swallow exception if data is not an ERC20 transfer } // If a transaction does not target 'self' consider it valid - if (to !== execTransaction.to) { + if (args.to !== execTransaction.to) { return true; } @@ -92,17 +148,57 @@ export class LimitAddressesMapper { return isCancellation || this.safeContract.isCall(execTransaction.data); } + private async isOfficialMastercopy(args: { + chainId: string; + address: string; + }): Promise { + try { + await this.safeRepository.getSafe(args); + return true; + } catch { + return false; + } + } + + private isOfficialMultiSendDeployment(args: { + chainId: string; + address: string; + }): boolean { + const multiSendCallOnlyDeployment = getMultiSendCallOnlyDeployment({ + version: LimitAddressesMapper.SUPPORTED_SAFE_VERSION, + network: args.chainId, + }); + + const isCallOnly = + multiSendCallOnlyDeployment?.networkAddresses[args.chainId] === + args.address || + multiSendCallOnlyDeployment?.defaultAddress === args.address; + + if (isCallOnly) { + return true; + } + + const multiSendCallDeployment = getMultiSendDeployment({ + version: LimitAddressesMapper.SUPPORTED_SAFE_VERSION, + network: args.chainId, + }); + return ( + multiSendCallDeployment?.networkAddresses[args.chainId] === + args.address || multiSendCallDeployment?.defaultAddress === args.address + ); + } + private getSafeAddressFromMultiSend = (data: Hex): Hex => { // Decode transactions within MultiSend const transactions = this.multiSendDecoder.mapMultiSendTransactions(data); // Every transaction is a valid execTransaction const isEveryValid = transactions.every((transaction) => { - return this.isValidExecTransactionCall(transaction.to, transaction.data); + return this.isValidExecTransactionCall(transaction); }); if (!isEveryValid) { - throw Error('Invalid MultiSend transactions'); + throw new InvalidMultiSendError(); } const firstRecipient = transactions[0].to; @@ -111,19 +207,23 @@ export class LimitAddressesMapper { return transaction.to === firstRecipient; }); + // Transactions calls execTransaction on varying addresses if (!isSameRecipient) { - throw Error('MultiSend transactions target different addresses'); + throw new InvalidMultiSendError(); } return firstRecipient; }; - private isValidCreateProxyWithNonce(chainId: string, data: Hex): boolean { + private isValidCreateProxyWithNonceCall(args: { + chainId: string; + data: Hex; + }): boolean { let singleton: string | null = null; try { const decoded = this.proxyFactoryDecoder.decodeFunctionData({ - data, + data: args.data, }); if (decoded.functionName !== 'createProxyWithNonce') { @@ -137,17 +237,17 @@ export class LimitAddressesMapper { const safeL1Deployment = getSafeSingletonDeployment({ version: LimitAddressesMapper.SUPPORTED_SAFE_VERSION, - network: chainId, + network: args.chainId, }); const safeL2Deployment = getSafeL2SingletonDeployment({ version: LimitAddressesMapper.SUPPORTED_SAFE_VERSION, - network: chainId, + network: args.chainId, }); const isL1Singleton = - safeL1Deployment?.networkAddresses[chainId] === singleton; + safeL1Deployment?.networkAddresses[args.chainId] === singleton; const isL2Singleton = - safeL2Deployment?.networkAddresses[chainId] === singleton; + safeL2Deployment?.networkAddresses[args.chainId] === singleton; return isL1Singleton || isL2Singleton; } @@ -158,6 +258,7 @@ export class LimitAddressesMapper { }); if (decodedProxyFactory.functionName !== 'createProxyWithNonce') { + // Should never happen but check is needed to satisfy TypeScript throw Error('Not a createProxyWithNonce call'); } @@ -167,6 +268,7 @@ export class LimitAddressesMapper { }); if (decodedSafe.functionName !== 'setup') { + // No custom error thrown, as caller subsequently throws InvalidTransferError throw Error('Not a setup call'); } diff --git a/src/domain/relay/relay.module.ts b/src/domain/relay/relay-decoders.module.ts similarity index 59% rename from src/domain/relay/relay.module.ts rename to src/domain/relay/relay-decoders.module.ts index a0ec547178..ae21344b4d 100644 --- a/src/domain/relay/relay.module.ts +++ b/src/domain/relay/relay-decoders.module.ts @@ -1,20 +1,25 @@ import { Module } from '@nestjs/common'; -import { LimitAddressesMapper } from '@/domain/relay/limit-addresses.mapper'; import { Erc20ContractHelper } from '@/domain/relay/contracts/erc20-contract.helper'; import { SafeContractHelper } from '@/domain/relay/contracts/safe-contract.helper'; import { MultiSendDecoder } from '@/domain/contracts/contracts/multi-send-decoder.helper'; import { ProxyFactoryDecoder } from '@/domain/relay/contracts/proxy-factory-decoder.helper'; +import { SafeDecoder } from '@/domain/contracts/contracts/safe-decoder.helper'; +// TODO: Temporary until https://github.com/safe-global/safe-client-gateway/pull/1148 is merged @Module({ providers: [ - LimitAddressesMapper, - // TODO: Look into refactoring these with `abi-decoder` Erc20ContractHelper, SafeContractHelper, - // TODO: Generify AlertsDecodersModule and import here + SafeDecoder, + MultiSendDecoder, + ProxyFactoryDecoder, + ], + exports: [ + Erc20ContractHelper, + SafeContractHelper, + SafeDecoder, MultiSendDecoder, ProxyFactoryDecoder, ], - exports: [LimitAddressesMapper], }) -export class RelayModule {} +export class RelayDecodersModule {} diff --git a/src/domain/relay/relay.domain.module.ts b/src/domain/relay/relay.domain.module.ts new file mode 100644 index 0000000000..1e46b2854c --- /dev/null +++ b/src/domain/relay/relay.domain.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { LimitAddressesMapper } from '@/domain/relay/limit-addresses.mapper'; +import { RelayRepository } from '@/domain/relay/relay.repository'; +import { RelayApiModule } from '@/datasources/relay-api/relay-api.module'; +import { RelayDecodersModule } from '@/domain/relay/relay-decoders.module'; + +@Module({ + imports: [RelayApiModule, RelayDecodersModule], + providers: [LimitAddressesMapper, RelayRepository], + exports: [RelayRepository], +}) +export class RelayDomainModule {} diff --git a/src/domain/relay/relay.repository.ts b/src/domain/relay/relay.repository.ts index e940fd7bb3..cf702638a3 100644 --- a/src/domain/relay/relay.repository.ts +++ b/src/domain/relay/relay.repository.ts @@ -1,27 +1,18 @@ import { Inject, Injectable } from '@nestjs/common'; -import { Hex } from 'viem/types/misc'; +import { RelayLimitReachedError } from '@/domain/relay/errors/relay-limit-reached.error'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { IRelayApi } from '@/domain/interfaces/relay-api.interface'; -import { - LimitAddressesMapper, - RelayPayload, -} from '@/domain/relay/limit-addresses.mapper'; +import { LimitAddressesMapper } from '@/domain/relay/limit-addresses.mapper'; import { ILoggingService, LoggingService } from '@/logging/logging.interface'; +import { CacheRouter } from '@/datasources/cache/cache.router'; +import { + CacheService, + ICacheService, +} from '@/datasources/cache/cache.service.interface'; +import { getAddress } from 'viem'; +import { CacheDir } from '@/datasources/cache/entities/cache-dir.entity'; -// TODO: Move to error folder and create exception filter -class RelayLimitReachedError extends Error { - constructor( - readonly address: Hex, - readonly current: number, - readonly limit: number, - ) { - super( - `Relay limit reached for ${address} | current: ${current} | limit: ${limit}`, - ); - } -} - -@Injectable({}) +@Injectable() export class RelayRepository { // Number of relay requests per ttl private readonly limit: number; @@ -30,14 +21,22 @@ export class RelayRepository { @Inject(LoggingService) private readonly loggingService: ILoggingService, @Inject(IConfigurationService) configurationService: IConfigurationService, private readonly limitAddressesMapper: LimitAddressesMapper, + @Inject(IRelayApi) private readonly relayApi: IRelayApi, + @Inject(CacheService) private readonly cacheService: ICacheService, ) { this.limit = configurationService.getOrThrow('relay.limit'); } - async relay(relayPayload: RelayPayload): Promise<{ taskId: string }> { + async relay(relayPayload: { + chainId: string; + to: string; + data: string; + gasLimit: string | null; + }): Promise<{ taskId: string }> { const relayAddresses = - this.limitAddressesMapper.getLimitAddresses(relayPayload); + await this.limitAddressesMapper.getLimitAddresses(relayPayload); + for (const address of relayAddresses) { const canRelay = await this.canRelay({ chainId: relayPayload.chainId, @@ -54,7 +53,29 @@ export class RelayRepository { } } - return this.relayApi.relay(relayPayload); + const relayResponse = await this.relayApi.relay(relayPayload); + + // If we fail to increment count, we should not fail the relay + for (const address of relayAddresses) { + await this.incrementRelayCount({ + chainId: relayPayload.chainId, + address, + }).catch((error) => { + // If we fail to increment count, we should not fail the relay + this.loggingService.warn(error.message); + }); + } + + return relayResponse; + } + + async getRelayCount(args: { + chainId: string; + address: string; + }): Promise { + const cacheDir = this.getRelayCacheKey(args); + const currentCount = await this.cacheService.get(cacheDir); + return currentCount ? parseInt(currentCount) : 0; } private async canRelay(args: { @@ -65,7 +86,24 @@ export class RelayRepository { return { result: currentCount < this.limit, currentCount }; } - getRelayCount(args: { chainId: string; address: string }): Promise { - return this.relayApi.getRelayCount(args); + private async incrementRelayCount(args: { + chainId: string; + address: string; + }): Promise { + const currentCount = await this.getRelayCount(args); + const incremented = currentCount + 1; + const cacheDir = this.getRelayCacheKey(args); + return this.cacheService.set(cacheDir, incremented.toString()); + } + + private getRelayCacheKey(args: { + chainId: string; + address: string; + }): CacheDir { + return CacheRouter.getRelayCacheDir({ + chainId: args.chainId, + // Ensure address is checksummed to always have a consistent cache key + address: getAddress(args.address), + }); } } diff --git a/src/domain/subscriptions/subscription.repository.interface.ts b/src/domain/subscriptions/subscription.repository.interface.ts index 6d47405209..9a22d49c98 100644 --- a/src/domain/subscriptions/subscription.repository.interface.ts +++ b/src/domain/subscriptions/subscription.repository.interface.ts @@ -3,6 +3,12 @@ import { Subscription } from '@/domain/account/entities/subscription.entity'; export const ISubscriptionRepository = Symbol('ISubscriptionRepository'); export interface ISubscriptionRepository { + getSubscriptions(args: { + chainId: string; + safeAddress: string; + signer: string; + }): Promise; + subscribe(args: { chainId: string; safeAddress: string; diff --git a/src/domain/subscriptions/subscription.repository.ts b/src/domain/subscriptions/subscription.repository.ts index 281adac821..61a17449aa 100644 --- a/src/domain/subscriptions/subscription.repository.ts +++ b/src/domain/subscriptions/subscription.repository.ts @@ -12,6 +12,14 @@ export class SubscriptionRepository implements ISubscriptionRepository { private readonly accountDataSource: IAccountDataSource, ) {} + getSubscriptions(args: { + chainId: string; + safeAddress: string; + signer: string; + }): Promise { + return this.accountDataSource.getSubscriptions(args); + } + subscribe(args: { chainId: string; safeAddress: string; diff --git a/src/routes/alerts/alerts.controller.spec.ts b/src/routes/alerts/alerts.controller.spec.ts index 75a0d07afb..1475384468 100644 --- a/src/routes/alerts/alerts.controller.spec.ts +++ b/src/routes/alerts/alerts.controller.spec.ts @@ -48,6 +48,7 @@ import { import { UrlGeneratorHelper } from '@/domain/alerts/urls/url-generator.helper'; import { accountBuilder } from '@/domain/account/entities/__tests__/account.builder'; import { EmailAddress } from '@/domain/account/entities/account.entity'; +import { subscriptionBuilder } from '@/domain/account/entities/__tests__/subscription.builder'; // The `x-tenderly-signature` header contains a cryptographic signature. The webhook request signature is // a HMAC SHA256 hash of concatenated signing secret, request payload, and timestamp, in this order. @@ -77,6 +78,10 @@ describe('Alerts (Unit)', () => { let accountDataSource: jest.MockedObjectDeep; let urlGenerator: UrlGeneratorHelper; + const accountRecoverySubscription = subscriptionBuilder() + .with('key', 'account_recovery') + .build(); + describe('/alerts route enabled', () => { let app: INestApplication; let signingKey: string; @@ -187,6 +192,9 @@ describe('Alerts (Unit)', () => { .build(), ]; accountDataSource.getAccounts.mockResolvedValue(verifiedAccounts); + accountDataSource.getSubscriptions.mockResolvedValue([ + subscriptionBuilder().with('key', 'account_recovery').build(), + ]); networkService.get.mockImplementation((url) => { switch (url) { @@ -294,6 +302,9 @@ describe('Alerts (Unit)', () => { .build(), ]; accountDataSource.getAccounts.mockResolvedValue(verifiedAccounts); + accountDataSource.getSubscriptions.mockResolvedValue([ + accountRecoverySubscription, + ]); networkService.get.mockImplementation((url) => { switch (url) { @@ -400,6 +411,9 @@ describe('Alerts (Unit)', () => { .build(), ]; accountDataSource.getAccounts.mockResolvedValue(verifiedAccounts); + accountDataSource.getSubscriptions.mockResolvedValue([ + accountRecoverySubscription, + ]); networkService.get.mockImplementation((url) => { switch (url) { @@ -496,6 +510,9 @@ describe('Alerts (Unit)', () => { .build(), ]; accountDataSource.getAccounts.mockResolvedValue(verifiedAccounts); + accountDataSource.getSubscriptions.mockResolvedValue([ + accountRecoverySubscription, + ]); networkService.get.mockImplementation((url) => { switch (url) { @@ -623,6 +640,9 @@ describe('Alerts (Unit)', () => { .build(), ]; accountDataSource.getAccounts.mockResolvedValue(verifiedAccounts); + accountDataSource.getSubscriptions.mockResolvedValue([ + accountRecoverySubscription, + ]); networkService.get.mockImplementation((url) => { switch (url) { @@ -722,6 +742,9 @@ describe('Alerts (Unit)', () => { .build(), ]; accountDataSource.getAccounts.mockResolvedValue(verifiedAccounts); + accountDataSource.getSubscriptions.mockResolvedValue([ + accountRecoverySubscription, + ]); networkService.get.mockImplementation((url) => { switch (url) { @@ -845,6 +868,9 @@ describe('Alerts (Unit)', () => { .build(), ]; accountDataSource.getAccounts.mockResolvedValue(verifiedAccounts); + accountDataSource.getSubscriptions.mockResolvedValue([ + accountRecoverySubscription, + ]); networkService.get.mockImplementation((url) => { switch (url) { @@ -941,6 +967,9 @@ describe('Alerts (Unit)', () => { .build(), ]; accountDataSource.getAccounts.mockResolvedValue(verifiedAccounts); + accountDataSource.getSubscriptions.mockResolvedValue([ + accountRecoverySubscription, + ]); networkService.get.mockImplementation((url) => { switch (url) { @@ -1024,6 +1053,9 @@ describe('Alerts (Unit)', () => { .build(), ]; accountDataSource.getAccounts.mockResolvedValue(verifiedAccounts); + accountDataSource.getSubscriptions.mockResolvedValue([ + accountRecoverySubscription, + ]); networkService.get.mockImplementation((url) => { switch (url) { @@ -1152,6 +1184,9 @@ describe('Alerts (Unit)', () => { .build(), ]; accountDataSource.getAccounts.mockResolvedValue(verifiedAccounts); + accountDataSource.getSubscriptions.mockResolvedValue([ + accountRecoverySubscription, + ]); networkService.get.mockImplementation((url) => { switch (url) { @@ -1265,6 +1300,9 @@ describe('Alerts (Unit)', () => { .build(), ]; accountDataSource.getAccounts.mockResolvedValue(verifiedAccounts); + accountDataSource.getSubscriptions.mockResolvedValue([ + accountRecoverySubscription, + ]); networkService.get.mockImplementation((url) => { switch (url) { @@ -1369,6 +1407,9 @@ describe('Alerts (Unit)', () => { .build(), ]; accountDataSource.getAccounts.mockResolvedValue(verifiedAccounts); + accountDataSource.getSubscriptions.mockResolvedValue([ + accountRecoverySubscription, + ]); networkService.get.mockImplementation((url) => { switch (url) { @@ -1421,6 +1462,80 @@ describe('Alerts (Unit)', () => { }); }); + it('does not notify accounts not subscribed to CATEGORY_ACCOUNT_RECOVERY', async () => { + const chain = chainBuilder().build(); + const delayModifier = faker.finance.ethereumAddress(); + const safe = safeBuilder().with('modules', [delayModifier]).build(); + const addOwnerWithThreshold = addOwnerWithThresholdEncoder(); + const transactionAddedEvent = transactionAddedEventBuilder() + .with('data', addOwnerWithThreshold.encode()) + .with('to', getAddress(safe.address)) + .encode(); + const alert = alertBuilder() + .with( + 'transaction', + alertTransactionBuilder() + .with('to', delayModifier) + .with('logs', [ + alertLogBuilder() + .with('address', delayModifier) + .with('data', transactionAddedEvent.data) + .with('topics', transactionAddedEvent.topics) + .build(), + ]) + .with('network', chain.chainId) + .build(), + ) + .with('event_type', EventType.ALERT) + .build(); + const timestamp = Date.now().toString(); + const signature = fakeTenderlySignature({ + signingKey, + alert, + timestamp, + }); + const accounts = [ + accountBuilder() + .with('emailAddress', new EmailAddress(faker.internet.email())) + .with('isVerified', true) + .build(), + accountBuilder() + .with('emailAddress', new EmailAddress(faker.internet.email())) + .with('isVerified', true) + .build(), + ]; + accountDataSource.getAccounts.mockResolvedValue(accounts); + accountDataSource.getSubscriptions.mockResolvedValue([ + subscriptionBuilder().build(), + ]); + + networkService.get.mockImplementation((url) => { + switch (url) { + case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: + return Promise.resolve({ data: chain, status: 200 }); + case `${chain.transactionService}/api/v1/modules/${delayModifier}/safes/`: + return Promise.resolve({ + data: { safes: [safe.address] }, + status: 200, + }); + case `${chain.transactionService}/api/v1/safes/${safe.address}`: + return Promise.resolve({ data: safe, status: 200 }); + default: + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post('/v1/alerts') + .set('x-tenderly-signature', signature) + .set('date', timestamp) + .send(alert) + .expect(202) + .expect({}); + + expect(emailApi.createMessage).toHaveBeenCalledTimes(0); + }); + it('returns 400 (Bad Request) for valid signature/invalid payload', async () => { const alert = {}; const timestamp = Date.now().toString(); diff --git a/src/routes/balances/__tests__/controllers/valk-balances.controller.spec.ts b/src/routes/balances/__tests__/controllers/valk-balances.controller.spec.ts deleted file mode 100644 index 3c53cdf087..0000000000 --- a/src/routes/balances/__tests__/controllers/valk-balances.controller.spec.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { faker } from '@faker-js/faker'; -import { INestApplication } from '@nestjs/common'; -import { Test, TestingModule } from '@nestjs/testing'; -import * as request from 'supertest'; -import { TestAppProvider } from '@/__tests__/test-app.provider'; -import { AppModule } from '@/app.module'; -import { IConfigurationService } from '@/config/configuration.service.interface'; -import configuration from '@/config/entities/__tests__/configuration'; -import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; -import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; -import { valkBalanceBuilder } from '@/datasources/balances-api/entities/__tests__/valk-balance.entity.builder'; -import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; -import { CacheModule } from '@/datasources/cache/cache.module'; -import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; -import { NetworkModule } from '@/datasources/network/network.module'; -import { - INetworkService, - NetworkService, -} from '@/datasources/network/network.service.interface'; -import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; -import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; -import { RequestScopedLoggingModule } from '@/logging/logging.module'; -import { NULL_ADDRESS } from '@/routes/common/constants'; -import { NetworkResponseError } from '@/datasources/network/entities/network.error.entity'; - -describe('Balances Controller (Unit)', () => { - let app: INestApplication; - let safeConfigUrl: string; - let networkService: jest.MockedObjectDeep; - let valkBaseUri: string; - let valkChainIds: string[]; - - 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) - .compile(); - - const configurationService = moduleFixture.get(IConfigurationService); - safeConfigUrl = configurationService.get('safeConfig.baseUri'); - valkBaseUri = configurationService.get('balances.providers.valk.baseUri'); - valkChainIds = configurationService.get('features.valkBalancesChainIds'); - networkService = moduleFixture.get(NetworkService); - - app = await new TestAppProvider().provide(moduleFixture); - await app.init(); - }); - - afterAll(async () => { - await app.close(); - }); - - describe('Balances provider: Valk', () => { - describe('GET /balances (externalized)', () => { - it(`maps native coin + ERC20 token balance correctly, and sorts balances by fiatBalance`, async () => { - const chain = chainBuilder().with('chainId', valkChainIds[0]).build(); - const safeAddress = faker.finance.ethereumAddress(); - const currency = faker.finance.currencyCode(); - const valkApiBalancesResponse = [ - valkBalanceBuilder() - .with('token_address', 'eth') - .with('balance', 1 * Math.pow(10, 18)) - .with('decimals', 18) - .with('prices', { [currency]: 1 }) - .build(), - valkBalanceBuilder() - .with('balance', 1.5 * Math.pow(10, 17)) - .with('decimals', 17) - .with('prices', { [currency]: 3 }) - .build(), - valkBalanceBuilder() - .with('balance', 3 * Math.pow(10, 17)) - .with('decimals', 17) - .with('prices', { [currency]: 2.0 }) - .build(), - ]; - const chainName = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.valk.chains.${chain.chainId}.chainName`, - ); - const apiKey = app - .get(IConfigurationService) - .getOrThrow(`balances.providers.valk.apiKey`); - networkService.get.mockImplementation((url) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${valkBaseUri}/balances/token/${safeAddress}`: - return Promise.resolve({ - data: valkApiBalancesResponse, - status: 200, - }); - default: - return Promise.reject(new Error(`Could not match ${url}`)); - } - }); - - await request(app.getHttpServer()) - .get( - `/v1/chains/${ - chain.chainId - }/safes/${safeAddress}/balances/${currency.toUpperCase()}`, - ) - .expect(200) - .expect(({ body }) => { - expect(body).toEqual({ - fiatTotal: '11.5', - items: [ - { - tokenInfo: { - type: 'ERC20', - address: valkApiBalancesResponse[2].token_address, - decimals: 17, - symbol: valkApiBalancesResponse[2].symbol, - name: valkApiBalancesResponse[2].name, - logoUri: valkApiBalancesResponse[2].logo, - }, - balance: '300000000000000000', - fiatBalance: '6', - fiatConversion: '2', - }, - { - tokenInfo: { - type: 'ERC20', - address: valkApiBalancesResponse[1].token_address, - decimals: 17, - symbol: valkApiBalancesResponse[1].symbol, - name: valkApiBalancesResponse[1].name, - logoUri: valkApiBalancesResponse[1].logo, - }, - balance: '150000000000000000', - fiatBalance: '4.5', - fiatConversion: '3', - }, - { - tokenInfo: { - type: 'NATIVE_TOKEN', - address: NULL_ADDRESS, - decimals: chain.nativeCurrency.decimals, - symbol: chain.nativeCurrency.symbol, - name: chain.nativeCurrency.name, - logoUri: chain.nativeCurrency.logoUri, - }, - balance: '1000000000000000000', - fiatBalance: '1', - fiatConversion: '1', - }, - ], - }); - }); - - expect(networkService.get.mock.calls.length).toBe(2); - expect(networkService.get.mock.calls[0][0]).toBe( - `${valkBaseUri}/balances/token/${safeAddress}`, - ); - expect(networkService.get.mock.calls[0][1]).toStrictEqual({ - headers: { Authorization: apiKey }, - params: { chain: chainName }, - }); - expect(networkService.get.mock.calls[1][0]).toBe( - `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, - ); - }); - - it('returns large numbers as is (not in scientific notation)', async () => { - const chain = chainBuilder().with('chainId', valkChainIds[0]).build(); - const safeAddress = faker.finance.ethereumAddress(); - const currency = faker.finance.currencyCode(); - const valkApiBalancesResponse = [ - valkBalanceBuilder() - .with('balance', 3 * Math.pow(10, 20)) - .with('decimals', 5) - .with('prices', { [currency]: 2 }) - .build(), - ]; - const chainName = app - .get(IConfigurationService) - .getOrThrow( - `balances.providers.valk.chains.${chain.chainId}.chainName`, - ); - const apiKey = app - .get(IConfigurationService) - .getOrThrow(`balances.providers.valk.apiKey`); - networkService.get.mockImplementation((url) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${valkBaseUri}/balances/token/${safeAddress}`: - return Promise.resolve({ - data: valkApiBalancesResponse, - status: 200, - }); - default: - return Promise.reject(new Error(`Could not match ${url}`)); - } - }); - - await request(app.getHttpServer()) - .get( - `/v1/chains/${ - chain.chainId - }/safes/${safeAddress}/balances/${currency.toUpperCase()}`, - ) - .expect(200) - .expect(({ body }) => { - expect(body).toEqual({ - fiatTotal: '6000000000000000', - items: [ - { - tokenInfo: { - type: 'ERC20', - address: valkApiBalancesResponse[0].token_address, - decimals: 5, - symbol: valkApiBalancesResponse[0].symbol, - name: valkApiBalancesResponse[0].name, - logoUri: valkApiBalancesResponse[0].logo, - }, - balance: '300000000000000000000', - fiatBalance: '6000000000000000', - fiatConversion: '2', - }, - ], - }); - }); - - expect(networkService.get.mock.calls.length).toBe(2); - expect(networkService.get.mock.calls[0][0]).toBe( - `${valkBaseUri}/balances/token/${safeAddress}`, - ); - expect(networkService.get.mock.calls[0][1]).toStrictEqual({ - headers: { Authorization: apiKey }, - params: { chain: chainName }, - }); - expect(networkService.get.mock.calls[1][0]).toBe( - `${safeConfigUrl}/api/v1/chains/${chain.chainId}`, - ); - }); - }); - - describe('Config API Error', () => { - it(`500 error response`, async () => { - const chainId = valkChainIds[0]; - const safeAddress = faker.finance.ethereumAddress(); - const error = new NetworkResponseError( - new URL( - `${safeConfigUrl}/v1/chains/${chainId}/safes/${safeAddress}/balances/usd`, - ), - { - status: 500, - } as Response, - ); - networkService.get.mockImplementation((url) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chainId}`: - return Promise.reject(error); - case `${valkBaseUri}/balances/token/${safeAddress}`: - return Promise.resolve({ data: [], status: 200 }); - default: - return Promise.reject(new Error(`Could not match ${url}`)); - } - }); - - await request(app.getHttpServer()) - .get(`/v1/chains/${chainId}/safes/${safeAddress}/balances/usd`) - .expect(500) - .expect({ - message: 'An error occurred', - code: 500, - }); - }); - }); - - describe('Valk Balances API Error', () => { - it(`500 error response`, async () => { - const chain = chainBuilder().with('chainId', valkChainIds[0]).build(); - const safeAddress = faker.finance.ethereumAddress(); - networkService.get.mockImplementation((url) => { - switch (url) { - case `${safeConfigUrl}/api/v1/chains/${chain.chainId}`: - return Promise.resolve({ data: chain, status: 200 }); - case `${valkBaseUri}/balances/token/${safeAddress}`: - return Promise.reject(new Error('test error')); - default: - return Promise.reject(new Error(`Could not match ${url}`)); - } - }); - - await request(app.getHttpServer()) - .get(`/v1/chains/${chain.chainId}/safes/${safeAddress}/balances/usd`) - .expect(503) - .expect({ - message: `Error getting ${safeAddress} balances from provider: test error}`, - code: 503, - }); - }); - }); - }); -}); 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 0f3d4cdf55..ee6808d492 100644 --- a/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts +++ b/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts @@ -420,8 +420,8 @@ describe('Balances Controller (Unit)', () => { ) .expect(503) .expect({ - message: `Error getting ${safeAddress} balances from provider: test error}`, code: 503, + message: 'Service unavailable', }); }); }); diff --git a/src/routes/balances/balances.controller.spec.ts b/src/routes/balances/balances.controller.spec.ts index 491b63bc98..9c894d139f 100644 --- a/src/routes/balances/balances.controller.spec.ts +++ b/src/routes/balances/balances.controller.spec.ts @@ -621,9 +621,7 @@ describe('Balances Controller (Unit)', () => { describe('GET /balances/supported-fiat-codes', () => { it('should return the ordered list of supported fiat codes', async () => { - // Test configuration of available currencies for Valk includes ['ETH', 'EUR', 'USD'] - // Test configuration of available currencies for Zerion includes ['btc', 'eth', 'eur', 'usd'] - // So BalancesApiManager available currencies should include ['ETH', 'EUR', 'USD'] + // So BalancesApiManager available currencies should include ['btc', 'eth', 'eur', 'usd'] const pricesProviderFiatCodes = ['eur', 'usd']; networkService.get.mockImplementation((url) => { switch (url) { diff --git a/src/routes/cache-hooks/cache-hooks.service.ts b/src/routes/cache-hooks/cache-hooks.service.ts index 0704347293..e2468ebbe3 100644 --- a/src/routes/cache-hooks/cache-hooks.service.ts +++ b/src/routes/cache-hooks/cache-hooks.service.ts @@ -144,7 +144,7 @@ export class CacheHooksService { // - the incoming transfers for that safe case EventType.INCOMING_ETHER: promises.push( - this.balancesRepository.clearLocalBalances({ + this.balancesRepository.clearBalances({ chainId: event.chainId, safeAddress: event.address, }), @@ -174,7 +174,7 @@ export class CacheHooksService { // - the transfers for that safe case EventType.OUTGOING_ETHER: promises.push( - this.balancesRepository.clearLocalBalances({ + this.balancesRepository.clearBalances({ chainId: event.chainId, safeAddress: event.address, }), @@ -202,7 +202,7 @@ export class CacheHooksService { // - the incoming transfers for that safe case EventType.INCOMING_TOKEN: promises.push( - this.balancesRepository.clearLocalBalances({ + this.balancesRepository.clearBalances({ chainId: event.chainId, safeAddress: event.address, }), @@ -237,7 +237,7 @@ export class CacheHooksService { // - the transfers for that safe case EventType.OUTGOING_TOKEN: promises.push( - this.balancesRepository.clearLocalBalances({ + this.balancesRepository.clearBalances({ chainId: event.chainId, safeAddress: event.address, }), diff --git a/src/routes/chains/entities/chain.entity.ts b/src/routes/chains/entities/chain.entity.ts index 77cf028303..94859d4055 100644 --- a/src/routes/chains/entities/chain.entity.ts +++ b/src/routes/chains/entities/chain.entity.ts @@ -46,9 +46,8 @@ export class Chain { chainLogoUri?: string; @ApiProperty() l2: boolean; - // TODO: Make required when implemented on config service and deemed stable - @ApiPropertyOptional() - isTestnet?: boolean; + @ApiProperty() + isTestnet: boolean; @ApiProperty() nativeCurrency: ApiNativeCurrency; @ApiProperty() @@ -102,8 +101,7 @@ export class Chain { shortName: string, theme: Theme, ensRegistryAddress: string | null, - // TODO: Make required when deemed stable on config service - isTestnet?: boolean, + isTestnet: boolean, // TODO: Make required when deemed stable on config service chainLogoUri?: string, ) { diff --git a/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts b/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts new file mode 100644 index 0000000000..68c3c118b3 --- /dev/null +++ b/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts @@ -0,0 +1,456 @@ +import { faker } from '@faker-js/faker'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import * as request from 'supertest'; +import { TestAppProvider } from '@/__tests__/test-app.provider'; +import { AppModule } from '@/app.module'; +import { IConfigurationService } from '@/config/configuration.service.interface'; +import configuration from '@/config/entities/__tests__/configuration'; +import { TestAccountDataSourceModule } from '@/datasources/account/__tests__/test.account.datasource.module'; +import { AccountDataSourceModule } from '@/datasources/account/account.datasource.module'; +import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; +import { CacheModule } from '@/datasources/cache/cache.module'; +import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; +import { NetworkModule } from '@/datasources/network/network.module'; +import { + INetworkService, + NetworkService, +} from '@/datasources/network/network.service.interface'; +import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; +import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; +import { RequestScopedLoggingModule } from '@/logging/logging.module'; +import { + zerionCollectibleAttributesBuilder, + zerionCollectibleBuilder, + zerionCollectiblesBuilder, + zerionNFTInfoBuilder, +} from '@/datasources/balances-api/entities/__tests__/zerion-collectible.entity.builder'; + +describe('Zerion Collectibles Controller', () => { + let app: INestApplication; + let networkService: jest.MockedObjectDeep; + let zerionBaseUri: string; + let zerionChainIds: string[]; + + beforeEach(async () => { + jest.clearAllMocks(); + + 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) + .compile(); + + const configurationService = moduleFixture.get(IConfigurationService); + zerionBaseUri = configurationService.get( + 'balances.providers.zerion.baseUri', + ); + zerionChainIds = configurationService.get( + 'features.zerionBalancesChainIds', + ); + networkService = moduleFixture.get(NetworkService); + + app = await new TestAppProvider().provide(moduleFixture); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('Collectibles provider: Zerion', () => { + describe('GET /v2/collectibles', () => { + it('successfully gets collectibles from Zerion', async () => { + const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); + const safeAddress = faker.finance.ethereumAddress(); + const aTokenAddress = faker.finance.ethereumAddress(); + const aNFTName = faker.string.sample(); + const aUrl = faker.internet.url({ appendSlash: false }); + const zerionApiCollectiblesResponse = zerionCollectiblesBuilder() + .with('data', [ + zerionCollectibleBuilder() + .with( + 'attributes', + zerionCollectibleAttributesBuilder() + .with( + 'nft_info', + zerionNFTInfoBuilder() + .with('contract_address', aTokenAddress) + .build(), + ) + .build(), + ) + .build(), + zerionCollectibleBuilder() + .with( + 'attributes', + zerionCollectibleAttributesBuilder() + .with( + 'nft_info', + zerionNFTInfoBuilder().with('name', aNFTName).build(), + ) + .build(), + ) + .build(), + zerionCollectibleBuilder() + .with( + 'attributes', + zerionCollectibleAttributesBuilder() + .with( + 'nft_info', + zerionNFTInfoBuilder() + .with('content', { + preview: { url: aUrl }, + detail: { url: aUrl }, + }) + .build(), + ) + .build(), + ) + .build(), + ]) + .build(); + const chainName = app + .get(IConfigurationService) + .getOrThrow( + `balances.providers.zerion.chains.${chain.chainId}.chainName`, + ); + const apiKey = app + .get(IConfigurationService) + .getOrThrow(`balances.providers.zerion.apiKey`); + networkService.get.mockImplementation((url) => { + switch (url) { + case `${zerionBaseUri}/v1/wallets/${safeAddress}/nft-positions`: + return Promise.resolve({ + data: zerionApiCollectiblesResponse, + status: 200, + }); + default: + return Promise.reject(new Error(`Could not match ${url}`)); + } + }); + + await request(app.getHttpServer()) + .get(`/v2/chains/${chain.chainId}/safes/${safeAddress}/collectibles`) + .expect(200) + .expect(({ body }) => { + expect(body).toMatchObject({ + count: zerionApiCollectiblesResponse.data.length, + next: expect.any(String), + previous: null, + results: [ + { + address: aTokenAddress, + tokenName: + zerionApiCollectiblesResponse.data[0].attributes.nft_info + .name, + tokenSymbol: + zerionApiCollectiblesResponse.data[0].attributes.nft_info + .name, + logoUri: + zerionApiCollectiblesResponse.data[0].attributes + .collection_info?.content?.icon.url, + id: zerionApiCollectiblesResponse.data[0].attributes.nft_info + .token_id, + uri: zerionApiCollectiblesResponse.data[0].attributes.nft_info + .content?.detail?.url, + name: zerionApiCollectiblesResponse.data[0].attributes + .collection_info?.name, + description: + zerionApiCollectiblesResponse.data[0].attributes + .collection_info?.description, + imageUri: + zerionApiCollectiblesResponse.data[0].attributes.nft_info + .content?.preview?.url, + metadata: + zerionApiCollectiblesResponse.data[0].attributes.nft_info + .content, + }, + { + address: + zerionApiCollectiblesResponse.data[1].attributes.nft_info + .contract_address, + tokenName: aNFTName, + tokenSymbol: + zerionApiCollectiblesResponse.data[1].attributes.nft_info + .name, + logoUri: + zerionApiCollectiblesResponse.data[1].attributes + .collection_info?.content?.icon.url, + id: zerionApiCollectiblesResponse.data[1].attributes.nft_info + .token_id, + uri: zerionApiCollectiblesResponse.data[1].attributes.nft_info + .content?.detail?.url, + name: zerionApiCollectiblesResponse.data[1].attributes + .collection_info?.name, + description: + zerionApiCollectiblesResponse.data[1].attributes + .collection_info?.description, + imageUri: + zerionApiCollectiblesResponse.data[1].attributes.nft_info + .content?.preview?.url, + metadata: + zerionApiCollectiblesResponse.data[1].attributes.nft_info + .content, + }, + { + address: + zerionApiCollectiblesResponse.data[2].attributes.nft_info + .contract_address, + tokenSymbol: + zerionApiCollectiblesResponse.data[2].attributes.nft_info + .name, + logoUri: + zerionApiCollectiblesResponse.data[2].attributes + .collection_info?.content?.icon.url, + id: zerionApiCollectiblesResponse.data[2].attributes.nft_info + .token_id, + uri: aUrl, + name: zerionApiCollectiblesResponse.data[2].attributes + .collection_info?.name, + description: + zerionApiCollectiblesResponse.data[2].attributes + .collection_info?.description, + imageUri: + zerionApiCollectiblesResponse.data[2].attributes.nft_info + .content?.preview?.url, + metadata: + zerionApiCollectiblesResponse.data[2].attributes.nft_info + .content, + }, + ], + }); + }); + + expect(networkService.get.mock.calls.length).toBe(1); + expect(networkService.get.mock.calls[0][0]).toBe( + `${zerionBaseUri}/v1/wallets/${safeAddress}/nft-positions`, + ); + expect(networkService.get.mock.calls[0][1]).toStrictEqual({ + headers: { Authorization: `Basic ${apiKey}` }, + params: { + 'filter[chain_ids]': chainName, + sort: '-floor_price', + 'page[size]': 20, + }, + }); + }); + it('successfully maps pagination option (no limit)', async () => { + const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); + const safeAddress = faker.finance.ethereumAddress(); + const inputPaginationCursor = `cursor=${encodeURIComponent(`&offset=10`)}`; + const zerionNext = `${faker.internet.url({ appendSlash: false })}?page%5Bsize%5D=20&page%5Bafter%5D=IjMwIg==`; + const expectedNext = `${encodeURIComponent(`limit=20&offset=30`)}`; + const zerionApiCollectiblesResponse = zerionCollectiblesBuilder() + .with('data', [ + zerionCollectibleBuilder().build(), + zerionCollectibleBuilder().build(), + ]) + .with('links', { next: zerionNext }) + .build(); + const chainName = app + .get(IConfigurationService) + .getOrThrow( + `balances.providers.zerion.chains.${chain.chainId}.chainName`, + ); + const apiKey = app + .get(IConfigurationService) + .getOrThrow(`balances.providers.zerion.apiKey`); + networkService.get.mockImplementation((url) => { + switch (url) { + case `${zerionBaseUri}/v1/wallets/${safeAddress}/nft-positions`: + return Promise.resolve({ + data: zerionApiCollectiblesResponse, + status: 200, + }); + default: + return Promise.reject(new Error(`Could not match ${url}`)); + } + }); + + await request(app.getHttpServer()) + .get( + `/v2/chains/${chain.chainId}/safes/${safeAddress}/collectibles?${inputPaginationCursor}`, + ) + .expect(200) + .expect(({ body }) => { + expect(body).toMatchObject({ + count: zerionApiCollectiblesResponse.data.length, + next: expect.stringContaining(expectedNext), + previous: null, + results: expect.any(Array), + }); + }); + + expect(networkService.get.mock.calls.length).toBe(1); + expect(networkService.get.mock.calls[0][0]).toBe( + `${zerionBaseUri}/v1/wallets/${safeAddress}/nft-positions`, + ); + expect(networkService.get.mock.calls[0][1]).toStrictEqual({ + headers: { Authorization: `Basic ${apiKey}` }, + params: { + 'filter[chain_ids]': chainName, + sort: '-floor_price', + 'page[size]': 20, + 'page[after]': 'IjEwIg==', + }, + }); + }); + + it('successfully maps pagination option (no offset)', async () => { + const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); + const safeAddress = faker.finance.ethereumAddress(); + const paginationLimit = 4; + const inputPaginationCursor = `cursor=${encodeURIComponent(`limit=${paginationLimit}`)}`; + const zerionNext = `${faker.internet.url({ appendSlash: false })}?page%5Bsize%5D=4&page%5Bafter%5D=IjQi`; + const expectedNext = `${encodeURIComponent(`limit=${paginationLimit}&offset=4`)}`; + const zerionApiCollectiblesResponse = zerionCollectiblesBuilder() + .with('data', [ + zerionCollectibleBuilder().build(), + zerionCollectibleBuilder().build(), + ]) + .with('links', { next: zerionNext }) + .build(); + const chainName = app + .get(IConfigurationService) + .getOrThrow( + `balances.providers.zerion.chains.${chain.chainId}.chainName`, + ); + const apiKey = app + .get(IConfigurationService) + .getOrThrow(`balances.providers.zerion.apiKey`); + networkService.get.mockImplementation((url) => { + switch (url) { + case `${zerionBaseUri}/v1/wallets/${safeAddress}/nft-positions`: + return Promise.resolve({ + data: zerionApiCollectiblesResponse, + status: 200, + }); + default: + return Promise.reject(new Error(`Could not match ${url}`)); + } + }); + + await request(app.getHttpServer()) + .get( + `/v2/chains/${chain.chainId}/safes/${safeAddress}/collectibles?${inputPaginationCursor}`, + ) + .expect(200) + .expect(({ body }) => { + expect(body).toMatchObject({ + count: zerionApiCollectiblesResponse.data.length, + next: expect.stringContaining(expectedNext), + previous: null, + results: expect.any(Array), + }); + }); + + expect(networkService.get.mock.calls.length).toBe(1); + expect(networkService.get.mock.calls[0][0]).toBe( + `${zerionBaseUri}/v1/wallets/${safeAddress}/nft-positions`, + ); + expect(networkService.get.mock.calls[0][1]).toStrictEqual({ + headers: { Authorization: `Basic ${apiKey}` }, + params: { + 'filter[chain_ids]': chainName, + sort: '-floor_price', + 'page[size]': paginationLimit, + }, + }); + }); + + it('successfully maps pagination option (both limit and offset)', async () => { + const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); + const safeAddress = faker.finance.ethereumAddress(); + const paginationLimit = 4; + const inputPaginationCursor = `cursor=${encodeURIComponent(`limit=${paginationLimit}&offset=20`)}`; + const zerionNext = `${faker.internet.url({ appendSlash: false })}?page%5Bsize%5D=4&page%5Bafter%5D=IjMwIg==`; + const expectedNext = `${encodeURIComponent(`limit=${paginationLimit}&offset=30`)}`; + const zerionApiCollectiblesResponse = zerionCollectiblesBuilder() + .with('data', [ + zerionCollectibleBuilder().build(), + zerionCollectibleBuilder().build(), + ]) + .with('links', { next: zerionNext }) + .build(); + const chainName = app + .get(IConfigurationService) + .getOrThrow( + `balances.providers.zerion.chains.${chain.chainId}.chainName`, + ); + const apiKey = app + .get(IConfigurationService) + .getOrThrow(`balances.providers.zerion.apiKey`); + networkService.get.mockImplementation((url) => { + switch (url) { + case `${zerionBaseUri}/v1/wallets/${safeAddress}/nft-positions`: + return Promise.resolve({ + data: zerionApiCollectiblesResponse, + status: 200, + }); + default: + return Promise.reject(new Error(`Could not match ${url}`)); + } + }); + + await request(app.getHttpServer()) + .get( + `/v2/chains/${chain.chainId}/safes/${safeAddress}/collectibles?${inputPaginationCursor}`, + ) + .expect(200) + .expect(({ body }) => { + expect(body).toMatchObject({ + count: zerionApiCollectiblesResponse.data.length, + next: expect.stringContaining(expectedNext), + previous: null, + results: expect.any(Array), + }); + }); + + expect(networkService.get.mock.calls.length).toBe(1); + expect(networkService.get.mock.calls[0][0]).toBe( + `${zerionBaseUri}/v1/wallets/${safeAddress}/nft-positions`, + ); + expect(networkService.get.mock.calls[0][1]).toStrictEqual({ + headers: { Authorization: `Basic ${apiKey}` }, + params: { + 'filter[chain_ids]': chainName, + sort: '-floor_price', + 'page[size]': paginationLimit, + 'page[after]': 'IjIwIg==', + }, + }); + }); + }); + + describe('Zerion Balances API Error', () => { + it(`500 error response`, async () => { + const chain = chainBuilder().with('chainId', zerionChainIds[0]).build(); + const safeAddress = faker.finance.ethereumAddress(); + networkService.get.mockImplementation((url) => { + switch (url) { + case `${zerionBaseUri}/v1/wallets/${safeAddress}/nft-positions`: + return Promise.reject(new Error('test error')); + default: + return Promise.reject(new Error(`Could not match ${url}`)); + } + }); + + await request(app.getHttpServer()) + .get(`/v2/chains/${chain.chainId}/safes/${safeAddress}/collectibles`) + .expect(503) + .expect({ + code: 503, + message: 'Service unavailable', + }); + }); + }); + }); +}); diff --git a/src/routes/email/email.controller.ts b/src/routes/email/email.controller.ts index acb53f6ce0..47f237e389 100644 --- a/src/routes/email/email.controller.ts +++ b/src/routes/email/email.controller.ts @@ -22,7 +22,7 @@ import { EmailAlreadyVerifiedExceptionFilter } from '@/routes/email/exception-fi import { ResendVerificationTimespanExceptionFilter } from '@/routes/email/exception-filters/resend-verification-timespan-error.exception-filter'; import { VerifyEmailDto } from '@/routes/email/entities/verify-email-dto.entity'; import { InvalidVerificationCodeExceptionFilter } from '@/routes/email/exception-filters/invalid-verification-code.exception-filter'; -import { AccountDoesNotExistExceptionFilter } from '@/routes/email/exception-filters/acocunt-does-not-exist.exception-filter'; +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'; diff --git a/src/routes/email/exception-filters/acocunt-does-not-exist.exception-filter.ts b/src/routes/email/exception-filters/account-does-not-exist.exception-filter.ts similarity index 100% rename from src/routes/email/exception-filters/acocunt-does-not-exist.exception-filter.ts rename to src/routes/email/exception-filters/account-does-not-exist.exception-filter.ts diff --git a/src/routes/relay/entities/relay.dto.entity.ts b/src/routes/relay/entities/relay.dto.entity.ts new file mode 100644 index 0000000000..a40f9a780d --- /dev/null +++ b/src/routes/relay/entities/relay.dto.entity.ts @@ -0,0 +1,12 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class RelayDto { + @ApiProperty() + to!: string; + + @ApiProperty() + data!: string; + + @ApiPropertyOptional({ type: BigInt, nullable: true }) + gasLimit!: string | null; +} diff --git a/src/routes/relay/entities/schemas/relay.dto.schema.ts b/src/routes/relay/entities/schemas/relay.dto.schema.ts new file mode 100644 index 0000000000..546db31b4f --- /dev/null +++ b/src/routes/relay/entities/schemas/relay.dto.schema.ts @@ -0,0 +1,16 @@ +import { RelayDto } from '@/routes/relay/entities/relay.dto.entity'; +import { JSONSchemaType } from 'ajv'; + +export const RELAY_DTO_SCHEMA_ID = + 'https://safe-client.safe.global/schemas/relay/relay.dto.json'; + +export const relayDtoSchema: JSONSchemaType = { + $id: RELAY_DTO_SCHEMA_ID, + type: 'object', + properties: { + to: { type: 'string' }, + data: { type: 'string' }, + gasLimit: { oneOf: [{ type: 'string' }, { type: 'null', nullable: true }] }, + }, + required: ['to', 'data'], +}; diff --git a/src/routes/relay/pipes/relay.validation.pipe.ts b/src/routes/relay/pipes/relay.validation.pipe.ts new file mode 100644 index 0000000000..b154d5abe7 --- /dev/null +++ b/src/routes/relay/pipes/relay.validation.pipe.ts @@ -0,0 +1,29 @@ +import { RelayDto } from '@/routes/relay/entities/relay.dto.entity'; +import { + RELAY_DTO_SCHEMA_ID, + relayDtoSchema, +} from '@/routes/relay/entities/schemas/relay.dto.schema'; +import { GenericValidator } from '@/validation/providers/generic.validator'; +import { JsonSchemaService } from '@/validation/providers/json-schema.service'; +import { Injectable, PipeTransform } from '@nestjs/common'; +import { ValidateFunction } from 'ajv'; + +@Injectable() +export class RelayDtoValidationPipe + implements PipeTransform +{ + private readonly isValid: ValidateFunction; + + constructor( + private readonly genericValidator: GenericValidator, + private readonly jsonSchemaService: JsonSchemaService, + ) { + this.isValid = this.jsonSchemaService.getSchema( + RELAY_DTO_SCHEMA_ID, + relayDtoSchema, + ); + } + transform(data: unknown): RelayDto { + return this.genericValidator.validate(this.isValid, data); + } +} diff --git a/src/routes/relay/relay.controller.module.ts b/src/routes/relay/relay.controller.module.ts new file mode 100644 index 0000000000..b75730f445 --- /dev/null +++ b/src/routes/relay/relay.controller.module.ts @@ -0,0 +1,12 @@ +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], +}) +export class RelayControllerModule {} diff --git a/src/routes/relay/relay.controller.spec.ts b/src/routes/relay/relay.controller.spec.ts new file mode 100644 index 0000000000..c0e1e0efe7 --- /dev/null +++ b/src/routes/relay/relay.controller.spec.ts @@ -0,0 +1,1317 @@ +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 { IConfigurationService } from '@/config/configuration.service.interface'; +import { + INetworkService, + NetworkService, +} from '@/datasources/network/network.service.interface'; +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 { + addOwnerWithThresholdEncoder, + changeThresholdEncoder, + disableModuleEncoder, + enableModuleEncoder, + execTransactionEncoder, + removeOwnerEncoder, + setFallbackHandlerEncoder, + setGuardEncoder, + setupEncoder, + swapOwnerEncoder, +} from '@/domain/contracts/contracts/__tests__/safe-encoder.builder'; +import { erc20TransferEncoder } from '@/domain/contracts/contracts/__tests__/erc20-encoder.builder'; +import { + multiSendEncoder, + multiSendTransactionsEncoder, +} from '@/domain/contracts/contracts/__tests__/multi-send-encoder.builder'; +import { + getMultiSendCallOnlyDeployment, + getMultiSendDeployment, + getSafeL2SingletonDeployment, + getSafeSingletonDeployment, +} from '@safe-global/safe-deployments'; +import { createProxyWithNonceEncoder } from '@/domain/relay/contracts/__tests__/proxy-factory-encoder.builder'; + +describe('Relay controller', () => { + let app: INestApplication; + let configurationService: jest.MockedObjectDeep; + let networkService: jest.MockedObjectDeep; + let safeConfigUrl: string; + let relayUrl: string; + const supportedChainIds = Object.keys(configuration().relay.apiKey); + + beforeEach(async () => { + jest.resetAllMocks(); + + const defaultConfiguration = configuration(); + const testConfiguration = (): typeof defaultConfiguration => ({ + ...defaultConfiguration, + features: { + ...defaultConfiguration.features, + relay: true, + }, + relay: { + ...defaultConfiguration.relay, + limit: 5, + }, + }); + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule.register(testConfiguration)], + }) + .overrideModule(AccountDataSourceModule) + .useModule(TestAccountDataSourceModule) + .overrideModule(CacheModule) + .useModule(TestCacheModule) + .overrideModule(RequestScopedLoggingModule) + .useModule(TestLoggingModule) + .overrideModule(NetworkModule) + .useModule(TestNetworkModule) + .compile(); + + configurationService = moduleFixture.get(IConfigurationService); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); + relayUrl = configurationService.getOrThrow('relay.baseUri'); + networkService = moduleFixture.get(NetworkService); + + app = await new TestAppProvider().provide(moduleFixture); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('POST /v1/chains/:chainId/relay', () => { + describe('Relayer', () => { + describe('execTransaction', () => { + it('should return 201 when sending native currency to another party', async () => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + 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; + 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: + fail(`Unexpected URL: ${url}`); + } + }); + networkService.post.mockImplementation((url) => { + switch (url) { + case `${relayUrl}/relays/v2/sponsored-call`: + return Promise.resolve({ data: { taskId }, status: 200 }); + default: + fail(`Unexpected URL: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to: safeAddress, + data, + }) + .expect(201) + .expect({ + taskId, + }); + }); + + it('should return 201 with manual gasLimit', async () => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + const chain = chainBuilder().with('chainId', chainId).build(); + const safe = safeBuilder().build(); + const safeAddress = getAddress(safe.address); + const gasLimit = faker.number.bigInt(); + const data = execTransactionEncoder().encode() as Hex; + 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: + fail(`Unexpected URL: ${url}`); + } + }); + networkService.post.mockImplementation((url) => { + switch (url) { + case `${relayUrl}/relays/v2/sponsored-call`: + return Promise.resolve({ data: { taskId }, status: 200 }); + default: + fail(`Unexpected URL: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to: safeAddress, + data, + gasLimit: gasLimit.toString(), + }) + .expect(201) + .expect({ + taskId, + }); + + // The gasLimit should have a buffer added + const expectedGasLimit = ( + BigInt(gasLimit) + BigInt(150_000) + ).toString(); + expect(networkService.post).toHaveBeenCalledWith( + `${relayUrl}/relays/v2/sponsored-call`, + expect.objectContaining({ + gasLimit: expectedGasLimit, + }), + ); + }); + + it.each([ + [ + 'sending ERC-20 tokens to another party', + erc20TransferEncoder().encode(), + ], + ['cancelling a transaction', '0x' as const], + [ + 'making an addOwnerWithThreshold call', + addOwnerWithThresholdEncoder().encode(), + ], + ['making a changeThreshold call', changeThresholdEncoder().encode()], + ['making an enableModule call', enableModuleEncoder().encode()], + ['making a disableModule call', disableModuleEncoder().encode()], + ['making a removeOwner call', removeOwnerEncoder().encode()], + [ + 'making a setFallbackHandler call', + setFallbackHandlerEncoder().encode(), + ], + ['making a setGuard call', setGuardEncoder().encode()], + ['making a swapOwner call', swapOwnerEncoder().encode()], + ])(`should return 201 when %s`, async (_, execTransactionData) => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + const chain = chainBuilder().with('chainId', chainId).build(); + const safe = safeBuilder().build(); + const data = execTransactionEncoder() + .with('data', execTransactionData) + .encode() as Hex; + 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/${safe.address}`: + // Official mastercopy + return Promise.resolve({ data: safe, status: 200 }); + default: + fail(`Unexpected URL: ${url}`); + } + }); + networkService.post.mockImplementation((url) => { + switch (url) { + case `${relayUrl}/relays/v2/sponsored-call`: + return Promise.resolve({ data: { taskId }, status: 200 }); + default: + fail(`Unexpected URL: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to: safe.address, + data, + }) + .expect(201) + .expect({ + taskId, + }); + }); + + it('should return 201 calling execTransaction on a nested Safe', async () => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + const chain = chainBuilder().with('chainId', chainId).build(); + const safe = safeBuilder().build(); + const safeAddress = getAddress(safe.address); + const data = execTransactionEncoder() + .with('to', safeAddress) + .with('data', execTransactionEncoder().encode()) + .encode() as Hex; + 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: + fail(`Unexpected URL: ${url}`); + } + }); + networkService.post.mockImplementation((url) => { + switch (url) { + case `${relayUrl}/relays/v2/sponsored-call`: + return Promise.resolve({ data: { taskId }, status: 200 }); + default: + fail(`Unexpected URL: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to: safeAddress, + data, + }) + .expect(201) + .expect({ + taskId, + }); + }); + }); + + describe('multiSend', () => { + it('should return 201 when entire batch is valid', async () => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + const chain = chainBuilder().with('chainId', chainId).build(); + const version = '1.3.0'; + 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: + fail(`Unexpected URL: ${url}`); + } + }); + networkService.post.mockImplementation((url) => { + switch (url) { + case `${relayUrl}/relays/v2/sponsored-call`: + return Promise.resolve({ data: { taskId }, status: 200 }); + default: + fail(`Unexpected URL: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to, + data, + }) + .expect(201) + .expect({ + taskId, + }); + }); + + it('should return 201 when entire batch is valid for "standard" MultiSend contracts', async () => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + const chain = chainBuilder().with('chainId', chainId).build(); + const version = '1.3.0'; + 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: + fail(`Unexpected URL: ${url}`); + } + }); + networkService.post.mockImplementation((url) => { + switch (url) { + case `${relayUrl}/relays/v2/sponsored-call`: + return Promise.resolve({ data: { taskId }, status: 200 }); + default: + fail(`Unexpected URL: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to, + data, + }) + .expect(201) + .expect({ + taskId, + }); + }); + }); + + describe('createProxyWithNonce', () => { + it('should return 201 when creating an official L1 Safe', async () => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + const chain = chainBuilder().with('chainId', chainId).build(); + const version = '1.3.0'; + + const owners = [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ]; + const singleton = getSafeSingletonDeployment({ + version, + network: chainId, + })!.networkAddresses[chainId]; + const to = faker.finance.ethereumAddress(); + 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: + fail(`Unexpected URL: ${url}`); + } + }); + networkService.post.mockImplementation((url) => { + switch (url) { + case `${relayUrl}/relays/v2/sponsored-call`: + return Promise.resolve({ data: { taskId }, status: 200 }); + default: + fail(`Unexpected URL: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to, + data, + }) + .expect(201) + .expect({ + taskId, + }); + }); + + it('should return 201 when creating an official L2 Safe', async () => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + const chain = chainBuilder().with('chainId', chainId).build(); + const version = '1.3.0'; + const owners = [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ]; + const singleton = getSafeL2SingletonDeployment({ + version, + network: chainId, + })!.networkAddresses[chainId]; + const to = faker.finance.ethereumAddress(); + 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: + fail(`Unexpected URL: ${url}`); + } + }); + networkService.post.mockImplementation((url) => { + switch (url) { + case `${relayUrl}/relays/v2/sponsored-call`: + return Promise.resolve({ data: { taskId }, status: 200 }); + default: + fail(`Unexpected URL: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to, + data, + }) + .expect(201) + .expect({ + taskId, + }); + }); + }); + }); + + describe('Transaction validation', () => { + describe('execTransaction', () => { + // execTransaction + it('should return 422 when sending native currency to self', async () => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + const chain = chainBuilder().with('chainId', chainId).build(); + const safe = safeBuilder().build(); + const safeAddress = getAddress(safe.address); + const data = execTransactionEncoder() + .with('to', safeAddress) + .with('value', faker.number.bigInt()) + .encode() as Hex; + 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: + fail(`Unexpected URL: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to: safeAddress, + data, + }) + .expect(422) + .expect({ + message: + 'Invalid transfer. The proposed transfer is not an execTransaction, multiSend, or createProxyWithNonce call.', + statusCode: 422, + }); + }); + + // transfer (execTransaction) + it('should return 422 sending ERC-20 tokens to self', async () => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + const chain = chainBuilder().with('chainId', chainId).build(); + const safe = safeBuilder().build(); + const safeAddress = getAddress(safe.address); + const data = execTransactionEncoder() + .with( + 'data', + erc20TransferEncoder().with('to', safeAddress).encode(), + ) + .encode() as Hex; + 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: + fail(`Unexpected URL: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to: safeAddress, + data, + }) + .expect(422) + .expect({ + message: + 'Invalid transfer. The proposed transfer is not an execTransaction, multiSend, or createProxyWithNonce call.', + statusCode: 422, + }); + }); + + // Unofficial mastercopy + it('should return 422 when the mastercopy is not official', async () => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + const chain = chainBuilder().with('chainId', chainId).build(); + const safeAddress = faker.finance.ethereumAddress(); + const data = execTransactionEncoder() + .with('value', faker.number.bigInt()) + .encode() as Hex; + 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}`: + // Unofficial mastercopy + return Promise.reject(new Error('Not found')); + default: + fail(`Unexpected URL: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to: safeAddress, + data, + }) + .expect(422) + .expect({ + message: 'Unsupported base contract.', + statusCode: 422, + }); + }); + }); + + describe('multiSend', () => { + it('should return 422 when the batch has an invalid transaction', async () => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + const chain = chainBuilder().with('chainId', chainId).build(); + const version = '1.3.0'; + const safe = safeBuilder().build(); + const transactions = [ + execTransactionEncoder().encode(), + // Native ERC-20 transfer + erc20TransferEncoder().encode(), + ].map((data) => ({ + operation: faker.number.int({ min: 0, max: 1 }), + data, + to: getAddress(safe.address), + 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/${safe.address}`: + // Official mastercopy + return Promise.resolve({ data: safe, status: 200 }); + default: + fail(`Unexpected URL: ${url}`); + } + }); + networkService.post.mockImplementation((url) => { + switch (url) { + case `${relayUrl}/relays/v2/sponsored-call`: + return Promise.resolve({ data: { taskId }, status: 200 }); + default: + fail(`Unexpected URL: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to, + data, + }) + .expect(422) + .expect({ + message: + 'Invalid multiSend call. The batch is not all execTransaction calls to same address.', + statusCode: 422, + }); + }); + + it('should return 422 when the mastercopy is not official', async () => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + const chain = chainBuilder().with('chainId', chainId).build(); + const version = '1.3.0'; + 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]; + 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}`: + // Unofficial mastercopy + return Promise.reject(new Error('Not found')); + default: + fail(`Unexpected URL: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to, + data, + }) + .expect(422) + .expect({ + message: 'Unsupported base contract.', + statusCode: 422, + }); + }); + + it('should return 422 when the batch is to varying parties', async () => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + const chain = chainBuilder().with('chainId', chainId).build(); + const version = '1.3.0'; + const safe = safeBuilder().build(); + const safeAddress = getAddress(safe.address); + const otherParty = getAddress(faker.finance.ethereumAddress()); + const transactions = [ + execTransactionEncoder().with('to', safeAddress).encode(), + execTransactionEncoder().with('to', otherParty).encode(), + ].map((data, i) => ({ + operation: faker.number.int({ min: 0, max: 1 }), + data, + // Varying parties + to: i === 0 ? safeAddress : otherParty, + value: faker.number.bigInt(), + })); + const data = multiSendEncoder() + .with('transactions', multiSendTransactionsEncoder(transactions)) + .encode(); + const to = getMultiSendCallOnlyDeployment({ + version, + network: chainId, + })!.networkAddresses[chainId]; + 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}`: + // Unofficial mastercopy + return Promise.reject(new Error('Not found')); + default: + fail(`Unexpected URL: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to, + data, + }) + .expect(422) + .expect({ + message: + 'Invalid multiSend call. The batch is not all execTransaction calls to same address.', + statusCode: 422, + }); + }); + + it('should return 422 for unofficial MultiSend deployments', async () => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + 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(); + // Unofficial MultiSend deployment + const to = faker.finance.ethereumAddress(); + 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: + fail(`Unexpected URL: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to, + data, + }) + .expect(422) + .expect({ + message: 'Unofficial MultiSend contract.', + statusCode: 422, + }); + }); + }); + + describe('createProxyWithNonce', () => { + it('should return 422 creating an unofficial Safe', async () => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + const chain = chainBuilder().with('chainId', chainId).build(); + const owners = [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ]; + const singleton = faker.finance.ethereumAddress(); + const to = faker.finance.ethereumAddress(); + const data = createProxyWithNonceEncoder() + .with('singleton', getAddress(singleton)) + .with('initializer', setupEncoder().with('owners', owners).encode()) + .encode(); + networkService.get.mockImplementation((url) => { + switch (url) { + case `${safeConfigUrl}/api/v1/chains/${chainId}`: + return Promise.resolve({ data: chain, status: 200 }); + default: + fail(`Unexpected URL: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to, + data, + }) + .expect(422) + .expect({ + message: + 'Invalid transfer. The proposed transfer is not an execTransaction, multiSend, or createProxyWithNonce call.', + statusCode: 422, + }); + }); + }); + + it('should otherwise return 422', async () => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + const chain = chainBuilder().with('chainId', chainId).build(); + const safe = safeBuilder().build(); + const safeAddress = getAddress(safe.address); + const data = erc20TransferEncoder().encode(); + 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: + fail(`Unexpected URL: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to: safeAddress, + data, + }) + .expect(422) + .expect({ + message: + 'Invalid transfer. The proposed transfer is not an execTransaction, multiSend, or createProxyWithNonce call.', + statusCode: 422, + }); + }); + }); + + describe('Rate limiting', () => { + it('should increment the rate limit counter of execTransaction calls', async () => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + 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; + 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: + fail(`Unexpected URL: ${url}`); + } + }); + networkService.post.mockImplementation((url) => { + switch (url) { + case `${relayUrl}/relays/v2/sponsored-call`: + return Promise.resolve({ data: { taskId }, status: 200 }); + default: + fail(`Unexpected URL: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to: safeAddress, + data, + }); + + await request(app.getHttpServer()) + .get(`/v1/chains/${chain.chainId}/relay/${safeAddress}`) + .expect(({ body }) => { + expect(body).toMatchObject({ + remaining: 4, + }); + }); + }); + + it('should increment the rate limit counter of multiSend calls', async () => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + const chain = chainBuilder().with('chainId', chainId).build(); + const version = '1.3.0'; + 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: + fail(`Unexpected URL: ${url}`); + } + }); + networkService.post.mockImplementation((url) => { + switch (url) { + case `${relayUrl}/relays/v2/sponsored-call`: + return Promise.resolve({ data: { taskId }, status: 200 }); + default: + fail(`Unexpected URL: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to, + data, + }); + + await request(app.getHttpServer()) + .get(`/v1/chains/${chain.chainId}/relay/${safeAddress}`) + .expect(({ body }) => { + expect(body).toMatchObject({ + remaining: 4, + }); + }); + }); + + it('should increment the rate limit counter of the owners of a createProxyWithNonce call', async () => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + const chain = chainBuilder().with('chainId', chainId).build(); + const version = '1.3.0'; + + const owners = [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ]; + const singleton = getSafeSingletonDeployment({ + version, + network: chainId, + })!.networkAddresses[chainId]; + const to = faker.finance.ethereumAddress(); + 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: + fail(`Unexpected URL: ${url}`); + } + }); + networkService.post.mockImplementation((url) => { + switch (url) { + case `${relayUrl}/relays/v2/sponsored-call`: + return Promise.resolve({ data: { taskId }, status: 200 }); + default: + fail(`Unexpected URL: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to, + data, + }); + + for (const owner of owners) { + await request(app.getHttpServer()) + .get(`/v1/chains/${chain.chainId}/relay/${owner}`) + .expect(({ body }) => { + expect(body).toMatchObject({ + remaining: 4, + }); + }); + } + }); + + it('should handle both checksummed and non-checksummed addresses', async () => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + 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; + 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/${nonChecksummedAddress}`: + case `${chain.transactionService}/api/v1/safes/${checksummedSafeAddress}`: + // Official mastercopy + return Promise.resolve({ data: safe, status: 200 }); + default: + fail(`Unexpected URL: ${url}`); + } + }); + networkService.post.mockImplementation((url) => { + switch (url) { + case `${relayUrl}/relays/v2/sponsored-call`: + return Promise.resolve({ data: { taskId }, status: 200 }); + default: + fail(`Unexpected URL: ${url}`); + } + }); + + for (const address of [nonChecksummedAddress, checksummedSafeAddress]) { + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to: address, + data, + }); + } + + await request(app.getHttpServer()) + .get(`/v1/chains/${chain.chainId}/relay/${nonChecksummedAddress}`) + .expect(({ body }) => { + expect(body).toMatchObject({ + remaining: 3, + }); + }); + await request(app.getHttpServer()) + .get(`/v1/chains/${chain.chainId}/relay/${checksummedSafeAddress}`) + .expect(({ body }) => { + expect(body).toMatchObject({ + remaining: 3, + }); + }); + }); + + it('should not rate limit the same address on different chains', async () => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + const differentChainId = faker.string.numeric({ exclude: chainId }); + 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; + 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: + fail(`Unexpected URL: ${url}`); + } + }); + networkService.post.mockImplementation((url) => { + switch (url) { + case `${relayUrl}/relays/v2/sponsored-call`: + return Promise.resolve({ data: { taskId }, status: 200 }); + default: + fail(`Unexpected URL: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to: safeAddress, + data, + }); + + await request(app.getHttpServer()) + .get(`/v1/chains/${differentChainId}/relay/${safeAddress}`) + .expect(({ body }) => { + expect(body).toMatchObject({ + remaining: 5, + }); + }); + }); + + it('should return 429 if the rate limit is reached', async () => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + 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; + 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: + fail(`Unexpected URL: ${url}`); + } + }); + networkService.post.mockImplementation((url) => { + switch (url) { + case `${relayUrl}/relays/v2/sponsored-call`: + return Promise.resolve({ data: { taskId }, status: 200 }); + default: + fail(`Unexpected URL: ${url}`); + } + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _ of Array.from({ length: 5 })) { + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to: safeAddress, + data, + }); + } + + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to: safeAddress, + data, + }) + .expect(429) + .expect({ + message: `Relay limit reached for ${safeAddress}`, + statusCode: 429, + }); + }); + }); + + it('should return 503 if the relayer throws', async () => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + const chain = chainBuilder().with('chainId', chainId).build(); + const safe = safeBuilder().build(); + const data = execTransactionEncoder().encode() as Hex; + 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/${safe.address}`: + // Official mastercopy + return Promise.resolve({ data: safe, status: 200 }); + default: + fail(`Unexpected URL: ${url}`); + } + }); + networkService.post.mockImplementation((url) => { + switch (url) { + case `${relayUrl}/relays/v2/sponsored-call`: + return Promise.reject(new Error('Relayer error')); + default: + fail(`Unexpected URL: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to: safe.address, + data, + }) + .expect(503); + }); + }); + + describe('GET /v1/chains/:chainId/relay/:safeAddress', () => { + it('should return the limit and remaining relay attempts', async () => { + const chainId = faker.string.numeric(); + const safeAddress = faker.finance.ethereumAddress(); + await request(app.getHttpServer()) + .get(`/v1/chains/${chainId}/relay/${safeAddress}`) + .expect(200) + .expect({ remaining: 5, limit: 5 }); + }); + + it('should not return negative limits if more requests were made than the limit', async () => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + 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; + 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: + fail(`Unexpected URL: ${url}`); + } + }); + networkService.post.mockImplementation((url) => { + switch (url) { + case `${relayUrl}/relays/v2/sponsored-call`: + return Promise.resolve({ data: { taskId }, status: 200 }); + default: + fail(`Unexpected URL: ${url}`); + } + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const _ of Array.from({ length: 6 })) { + await request(app.getHttpServer()) + .post(`/v1/chains/${chain.chainId}/relay`) + .send({ + to: safeAddress, + data, + }); + } + + await request(app.getHttpServer()) + .get(`/v1/chains/${chain.chainId}/relay/${safeAddress}`) + .expect(200) + .expect({ + // Not negative + remaining: 0, + limit: 5, + }); + }); + }); +}); diff --git a/src/routes/relay/relay.controller.ts b/src/routes/relay/relay.controller.ts new file mode 100644 index 0000000000..a1597a9010 --- /dev/null +++ b/src/routes/relay/relay.controller.ts @@ -0,0 +1,46 @@ +import { Controller, Post, Param, Get, UseFilters, Body } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { RelayDto } from '@/routes/relay/entities/relay.dto.entity'; +import { RelayService } from '@/routes/relay/relay.service'; +import { RelayLimitReachedExceptionFilter } from '@/domain/relay/exception-filters/relay-limit-reached.exception-filter'; +import { RelayDtoValidationPipe } from '@/routes/relay/pipes/relay.validation.pipe'; +import { InvalidMultiSendExceptionFilter } from '@/domain/relay/exception-filters/invalid-multisend.exception-filter'; +import { InvalidTransferExceptionFilter } from '@/domain/relay/exception-filters/invalid-transfer.exception-filter'; +import { UnofficialMasterCopyExceptionFilter } from '@/domain/relay/exception-filters/unofficial-master-copy.exception-filter'; +import { UnofficialMultiSendExceptionFilter } from '@/domain/relay/exception-filters/unofficial-multisend.error'; + +@ApiTags('relay') +@Controller({ + version: '1', + path: 'chains/:chainId/relay', +}) +export class RelayController { + constructor(private readonly relayService: RelayService) {} + + @Post() + @UseFilters( + RelayLimitReachedExceptionFilter, + InvalidMultiSendExceptionFilter, + InvalidTransferExceptionFilter, + UnofficialMasterCopyExceptionFilter, + UnofficialMultiSendExceptionFilter, + ) + async relay( + @Param('chainId') chainId: string, + @Body(RelayDtoValidationPipe) + relayDto: RelayDto, + ): Promise<{ taskId: string }> { + return this.relayService.relay({ chainId, relayDto }); + } + + @Get(':safeAddress') + async getRelaysRemaining( + @Param('chainId') chainId: string, + @Param('safeAddress') safeAddress: string, + ): Promise<{ + remaining: number; + limit: number; + }> { + return this.relayService.getRelaysRemaining({ chainId, safeAddress }); + } +} diff --git a/src/routes/relay/relay.legacy.controller.spec.ts b/src/routes/relay/relay.legacy.controller.spec.ts new file mode 100644 index 0000000000..7f0d43f1ed --- /dev/null +++ b/src/routes/relay/relay.legacy.controller.spec.ts @@ -0,0 +1,87 @@ +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'; + +describe('Relay controller', () => { + let app: INestApplication; + const supportedChainIds = Object.keys(configuration().relay.apiKey); + + beforeEach(async () => { + jest.resetAllMocks(); + + const defaultConfiguration = configuration(); + const testConfiguration = (): typeof defaultConfiguration => ({ + ...defaultConfiguration, + features: { + ...defaultConfiguration.features, + relay: true, + }, + }); + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule.register(testConfiguration)], + }) + .overrideModule(AccountDataSourceModule) + .useModule(TestAccountDataSourceModule) + .overrideModule(CacheModule) + .useModule(TestCacheModule) + .overrideModule(RequestScopedLoggingModule) + .useModule(TestLoggingModule) + .overrideModule(NetworkModule) + .useModule(TestNetworkModule) + .compile(); + + app = await new TestAppProvider().provide(moduleFixture); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('POST /v1/chains/:chainId/relay', () => { + it('should return 302 and redirect to the new endpoint', async () => { + const chainId = faker.helpers.arrayElement(supportedChainIds); + const safeAddress = faker.finance.ethereumAddress(); + const data = faker.string.hexadecimal(); + + await request(app.getHttpServer()) + .post(`/v1/relay/${chainId}`) + .send({ + 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 chainId = faker.string.numeric(); + 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 new file mode 100644 index 0000000000..a541c46a99 --- /dev/null +++ b/src/routes/relay/relay.legacy.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Post, Redirect, Get, HttpStatus } from '@nestjs/common'; + +@Controller({ + version: '1', + path: 'relay/:chainId', +}) +export class RelayLegacyController { + @Post() + @Redirect('/v1/chains/:chainId/relay', HttpStatus.PERMANENT_REDIRECT) + relay(): void {} + + @Get(':safeAddress') + @Redirect( + '/v1/chains/:chainId/relay/:safeAddress', + HttpStatus.MOVED_PERMANENTLY, + ) + getRelaysRemaining(): void {} +} diff --git a/src/routes/relay/relay.service.ts b/src/routes/relay/relay.service.ts new file mode 100644 index 0000000000..eaf38ce0c8 --- /dev/null +++ b/src/routes/relay/relay.service.ts @@ -0,0 +1,44 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { RelayRepository } from '@/domain/relay/relay.repository'; +import { RelayDto } from '@/routes/relay/entities/relay.dto.entity'; +import { IConfigurationService } from '@/config/configuration.service.interface'; + +@Injectable() +export class RelayService { + // Number of relay requests per ttl + private readonly limit: number; + + constructor( + @Inject(IConfigurationService) configurationService: IConfigurationService, + private readonly relayRepository: RelayRepository, + ) { + this.limit = configurationService.getOrThrow('relay.limit'); + } + + async relay(args: { + chainId: string; + relayDto: RelayDto; + }): Promise<{ taskId: string }> { + return this.relayRepository.relay({ + chainId: args.chainId, + to: args.relayDto.to, + data: args.relayDto.data, + gasLimit: args.relayDto.gasLimit, + }); + } + + async getRelaysRemaining(args: { + chainId: string; + safeAddress: string; + }): Promise<{ remaining: number; limit: number }> { + const currentCount = await this.relayRepository.getRelayCount({ + chainId: args.chainId, + address: args.safeAddress, + }); + + return { + remaining: Math.max(this.limit - currentCount, 0), + limit: this.limit, + }; + } +} diff --git a/test/jest-all.json b/test/jest-all.json index ec45693637..7e878bbd93 100644 --- a/test/jest-all.json +++ b/test/jest-all.json @@ -11,6 +11,7 @@ }, "coverageDirectory": "./coverage", "moduleNameMapper": { + "^@/abis/(.*)$": "/abis/$1", "^@/(.*)$": "/src/$1" }, "globalSetup": "/test/global-setup.ts" diff --git a/test/jest-e2e.json b/test/jest-e2e.json index 8de2758a83..9b21fb9645 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -11,6 +11,7 @@ }, "coverageDirectory": "./coverage", "moduleNameMapper": { + "^@/abis/(.*)$": "/abis/$1", "^@/(.*)$": "/src/$1" }, "globalSetup": "/test/global-setup.ts" diff --git a/tsconfig.json b/tsconfig.json index dcc464d59e..bfc6cb9b61 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,7 @@ "noFallthroughCasesInSwitch": false, "resolveJsonModule": true, "paths": { + "@/abis/*": ["abis/*"], "@/*": ["src/*"], }, "strict": true, diff --git a/yarn.lock b/yarn.lock index e3a338ad36..8addf722e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19,13 +19,6 @@ __metadata: languageName: node linkType: hard -"@adraffy/ens-normalize@npm:1.9.2": - version: 1.9.2 - resolution: "@adraffy/ens-normalize@npm:1.9.2" - checksum: 5f33f6570d6e4017b9afdf0dd8ff64af05f7e1cc09c9b30a17460451b9ec2655a979e272148470fabdd8cbad9fb4b750a216387b4f87ae2389023b7e3f9d8ad7 - languageName: node - linkType: hard - "@ampproject/remapping@npm:^2.1.0": version: 2.2.0 resolution: "@ampproject/remapping@npm:2.2.0" @@ -748,18 +741,6 @@ __metadata: languageName: node linkType: hard -"@gelatonetwork/relay-sdk@npm:^5.5.5": - version: 5.5.5 - resolution: "@gelatonetwork/relay-sdk@npm:5.5.5" - dependencies: - axios: "npm:0.27.2" - ethers: "npm:6.7.0" - isomorphic-ws: "npm:^5.0.0" - ws: "npm:^8.5.0" - checksum: 5ac4d149c6ae2c2c983e382a1bccf40622f912e3cc382bdb4fe9942034b857bdf4347a29baf6cbd51cfd9582c0ea417d2ced6c90a7e99a781f8831b86f50b664 - languageName: node - linkType: hard - "@humanwhocodes/config-array@npm:^0.11.13": version: 0.11.13 resolution: "@humanwhocodes/config-array@npm:0.11.13" @@ -1457,13 +1438,6 @@ __metadata: languageName: node linkType: hard -"@noble/hashes@npm:1.1.2": - version: 1.1.2 - resolution: "@noble/hashes@npm:1.1.2" - checksum: 2826c94ea30b8d2447fda549f4ffa97a637a480eeef5c96702a2f932c305038465f7436caf5b2bad41eb43c08c270b921e101488b18165feebe3854091b56d91 - languageName: node - linkType: hard - "@noble/hashes@npm:1.3.2, @noble/hashes@npm:~1.3.2": version: 1.3.2 resolution: "@noble/hashes@npm:1.3.2" @@ -1478,13 +1452,6 @@ __metadata: languageName: node linkType: hard -"@noble/secp256k1@npm:1.7.1": - version: 1.7.1 - resolution: "@noble/secp256k1@npm:1.7.1" - checksum: 214d4756c20ed20809d948d0cc161e95664198cb127266faf747fd7deffe5444901f05fe9f833787738f2c6e60b09e544c2f737f42f73b3699e3999ba15b1b63 - languageName: node - linkType: hard - "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -1937,13 +1904,6 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:18.15.13": - version: 18.15.13 - resolution: "@types/node@npm:18.15.13" - checksum: b9bbe923573797ef7c5fd2641a6793489e25d9369c32aeadcaa5c7c175c85b42eb12d6fe173f6781ab6f42eaa1ebd9576a419eeaa2a1ec810094adb8adaa9a54 - languageName: node - linkType: hard - "@types/node@npm:^20.11.17": version: 20.11.17 resolution: "@types/node@npm:20.11.17" @@ -2470,13 +2430,6 @@ __metadata: languageName: node linkType: hard -"aes-js@npm:4.0.0-beta.5": - version: 4.0.0-beta.5 - resolution: "aes-js@npm:4.0.0-beta.5" - checksum: 8f745da2e8fb38e91297a8ec13c2febe3219f8383303cd4ed4660ca67190242ccfd5fdc2f0d1642fd1ea934818fb871cd4cc28d3f28e812e3dc6c3d0f1f97c24 - languageName: node - linkType: hard - "agent-base@npm:6, agent-base@npm:^6.0.2": version: 6.0.2 resolution: "agent-base@npm:6.0.2" @@ -2727,16 +2680,6 @@ __metadata: languageName: node linkType: hard -"axios@npm:0.27.2": - version: 0.27.2 - resolution: "axios@npm:0.27.2" - dependencies: - follow-redirects: "npm:^1.14.9" - form-data: "npm:^4.0.0" - checksum: 2efaf18dd0805f7bc772882bc86f004abd92d51007b54c5081f74db0d08ce3593e2c010261896d25a14318eeaa2e966fd825e34f810e8a3339dc64b9d177cf70 - languageName: node - linkType: hard - "babel-jest@npm:^29.7.0": version: 29.7.0 resolution: "babel-jest@npm:29.7.0" @@ -4042,21 +3985,6 @@ __metadata: languageName: node linkType: hard -"ethers@npm:6.7.0": - version: 6.7.0 - resolution: "ethers@npm:6.7.0" - dependencies: - "@adraffy/ens-normalize": "npm:1.9.2" - "@noble/hashes": "npm:1.1.2" - "@noble/secp256k1": "npm:1.7.1" - "@types/node": "npm:18.15.13" - aes-js: "npm:4.0.0-beta.5" - tslib: "npm:2.4.0" - ws: "npm:8.5.0" - checksum: 0ea1627fdab31605e06a39f7200d70b464a57024d269f618226cab69f72889765f9eaaff7ccb8c9d3671809f40d85159d735401b0a05d9b5a679f8c85abd5772 - languageName: node - linkType: hard - "events@npm:^3.2.0": version: 3.3.0 resolution: "events@npm:3.3.0" @@ -4326,16 +4254,6 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.14.9": - version: 1.15.5 - resolution: "follow-redirects@npm:1.15.5" - peerDependenciesMeta: - debug: - optional: true - checksum: d467f13c1c6aa734599b8b369cd7a625b20081af358f6204ff515f6f4116eb440de9c4e0c49f10798eeb0df26c95dd05d5e0d9ddc5786ab1a8a8abefe92929b4 - languageName: node - linkType: hard - "foreground-child@npm:^3.1.0": version: 3.1.1 resolution: "foreground-child@npm:3.1.1" @@ -5119,15 +5037,6 @@ __metadata: languageName: node linkType: hard -"isomorphic-ws@npm:^5.0.0": - version: 5.0.0 - resolution: "isomorphic-ws@npm:5.0.0" - peerDependencies: - ws: "*" - checksum: e20eb2aee09ba96247465fda40c6d22c1153394c0144fa34fe6609f341af4c8c564f60ea3ba762335a7a9c306809349f9b863c8beedf2beea09b299834ad5398 - languageName: node - linkType: hard - "isows@npm:1.0.3": version: 1.0.3 resolution: "isows@npm:1.0.3" @@ -7210,7 +7119,6 @@ __metadata: resolution: "safe-client-gateway@workspace:." dependencies: "@faker-js/faker": "npm:^8.4.1" - "@gelatonetwork/relay-sdk": "npm:^5.5.5" "@nestjs/cli": "npm:^10.3.1" "@nestjs/common": "npm:^10.3.3" "@nestjs/config": "npm:^3.1.1" @@ -8040,13 +7948,6 @@ __metadata: languageName: node linkType: hard -"tslib@npm:2.4.0, tslib@npm:^2.1.0": - version: 2.4.0 - resolution: "tslib@npm:2.4.0" - checksum: d8379e68b36caf082c1905ec25d17df8261e1d68ddc1abfd6c91158a064f6e4402039ae7c02cf4c81d12e3a2a2c7cd8ea2f57b233eb80136a2e3e7279daf2911 - languageName: node - linkType: hard - "tslib@npm:2.6.2": version: 2.6.2 resolution: "tslib@npm:2.6.2" @@ -8054,6 +7955,13 @@ __metadata: languageName: node linkType: hard +"tslib@npm:^2.1.0": + version: 2.4.0 + resolution: "tslib@npm:2.4.0" + checksum: d8379e68b36caf082c1905ec25d17df8261e1d68ddc1abfd6c91158a064f6e4402039ae7c02cf4c81d12e3a2a2c7cd8ea2f57b233eb80136a2e3e7279daf2911 + languageName: node + linkType: hard + "type-check@npm:^0.4.0, type-check@npm:~0.4.0": version: 0.4.0 resolution: "type-check@npm:0.4.0" @@ -8486,36 +8394,6 @@ __metadata: languageName: node linkType: hard -"ws@npm:8.5.0": - version: 8.5.0 - resolution: "ws@npm:8.5.0" - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ^5.0.2 - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - checksum: f0ee700970a0bf925b1ec213ca3691e84fb8b435a91461fe3caf52f58c6cec57c99ed5890fbf6978824c932641932019aafc55d864cad38ac32577496efd5d3a - languageName: node - linkType: hard - -"ws@npm:^8.5.0": - version: 8.16.0 - resolution: "ws@npm:8.16.0" - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ">=5.0.2" - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - checksum: 7c511c59e979bd37b63c3aea4a8e4d4163204f00bd5633c053b05ed67835481995f61a523b0ad2b603566f9a89b34cb4965cb9fab9649fbfebd8f740cea57f17 - languageName: node - linkType: hard - "xtend@npm:^4.0.0": version: 4.0.2 resolution: "xtend@npm:4.0.2"