From ce444fa1eb7f0e903c7a8ad3092977371f49b2e3 Mon Sep 17 00:00:00 2001 From: Aaron Cook Date: Fri, 16 Feb 2024 15:29:48 +0100 Subject: [PATCH] Add relay controller (#1153) This adds a relay controller for relaying transactions and retrieving the number of remaining relays possible: - `POST` `/v1/chains/:chainId/relay` - `GET` `/v1/chains/:chainId/relay/:safeAddress` --- .env.sample | 5 + src/app.module.ts | 5 +- .../entities/__tests__/configuration.ts | 4 + src/config/entities/configuration.ts | 4 + .../relay-api/gelato-api.service.spec.ts | 106 +- .../relay-api/gelato-api.service.ts | 51 +- src/domain/interfaces/relay-api.interface.ts | 4 +- .../relay/errors/relay-limit-reached.error.ts | 13 + .../relay-limit-reached.exception-filter.ts | 21 + .../relay/limit-addresses.mapper.spec.ts | 8 +- ...lay.module.ts => relay-decoders.module.ts} | 17 +- src/domain/relay/relay.domain.module.ts | 12 + src/domain/relay/relay.repository.ts | 74 +- src/routes/relay/entities/relay.dto.entity.ts | 12 + .../entities/schemas/relay.dto.schema.ts | 16 + .../relay/pipes/relay.validation.pipe.ts | 29 + src/routes/relay/relay.controller.module.ts | 11 + src/routes/relay/relay.controller.spec.ts | 1276 +++++++++++++++++ src/routes/relay/relay.controller.ts | 36 + src/routes/relay/relay.service.ts | 44 + 20 files changed, 1566 insertions(+), 182 deletions(-) create mode 100644 src/domain/relay/errors/relay-limit-reached.error.ts create mode 100644 src/domain/relay/exception-filters/relay-limit-reached.exception-filter.ts rename src/domain/relay/{relay.module.ts => relay-decoders.module.ts} (59%) create mode 100644 src/domain/relay/relay.domain.module.ts create mode 100644 src/routes/relay/entities/relay.dto.entity.ts create mode 100644 src/routes/relay/entities/schemas/relay.dto.schema.ts create mode 100644 src/routes/relay/pipes/relay.validation.pipe.ts create mode 100644 src/routes/relay/relay.controller.module.ts create mode 100644 src/routes/relay/relay.controller.spec.ts create mode 100644 src/routes/relay/relay.controller.ts create mode 100644 src/routes/relay/relay.service.ts diff --git a/.env.sample b/.env.sample index cbf09c61d7..2d41bb1de9 100644 --- a/.env.sample +++ b/.env.sample @@ -22,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/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 3121547855..ca51578e16 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -94,6 +94,7 @@ export default (): ReturnType => ({ richFragments: true, email: true, zerionBalancesChainIds: ['137'], + relay: true, }, httpClient: { requestTimeout: faker.number.int() }, log: { @@ -184,6 +185,9 @@ export default (): ReturnType => ({ 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 e29d5fc287..d3f5537754 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -127,6 +127,7 @@ export default () => ({ email: process.env.FF_EMAIL?.toLowerCase() === 'true', 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. @@ -182,6 +183,9 @@ export default () => ({ 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/relay-api/gelato-api.service.spec.ts b/src/datasources/relay-api/gelato-api.service.spec.ts index e071f7ba1f..acdd0918ef 100644 --- a/src/datasources/relay-api/gelato-api.service.spec.ts +++ b/src/datasources/relay-api/gelato-api.service.spec.ts @@ -1,8 +1,5 @@ import { FakeConfigurationService } from '@/config/__tests__/fake.configuration.service'; -import { ICacheService } from '@/datasources/cache/cache.service.interface'; -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 { INetworkService } from '@/datasources/network/network.service.interface'; import { Hex } from 'viem'; @@ -10,19 +7,10 @@ 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 mockCacheService = jest.mocked({ - get: jest.fn(), - set: jest.fn(), -} as jest.MockedObjectDeep); - const mockNetworkService = jest.mocked({ post: jest.fn(), } as jest.MockedObjectDeep); -const mockLoggingService = { - warn: jest.fn(), -} as jest.MockedObjectDeep; - describe('GelatoApi', () => { let target: GelatoApi; let fakeConfigurationService: FakeConfigurationService; @@ -40,8 +28,6 @@ describe('GelatoApi', () => { target = new GelatoApi( mockNetworkService, fakeConfigurationService, - mockCacheService, - mockLoggingService, httpErrorFactory, ); }); @@ -55,41 +41,11 @@ describe('GelatoApi', () => { new GelatoApi( mockNetworkService, fakeConfigurationService, - mockCacheService, - mockLoggingService, httpErrorFactory, ), ).toThrow(); }); - describe('getRelayCount', () => { - it('should return the current count from the cache', async () => { - const chainId = faker.string.numeric(); - const address = faker.finance.ethereumAddress(); - const count = faker.number.int(); - mockCacheService.get.mockResolvedValueOnce(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); - }); - }); - describe('relay', () => { it('should relay the payload', async () => { const chainId = faker.string.numeric(); @@ -97,7 +53,7 @@ describe('GelatoApi', () => { const data = faker.string.hexadecimal() as Hex; const apiKey = faker.string.sample(); const taskId = faker.string.uuid(); - fakeConfigurationService.set(`gelato.apiKey.${chainId}`, apiKey); + fakeConfigurationService.set(`relay.apiKey.${chainId}`, apiKey); mockNetworkService.post.mockResolvedValueOnce({ status: 200, data: { @@ -109,6 +65,7 @@ describe('GelatoApi', () => { chainId, to: address, data, + gasLimit: null, }); expect(mockNetworkService.post).toHaveBeenCalledWith( @@ -129,7 +86,7 @@ describe('GelatoApi', () => { const gasLimit = faker.string.numeric(); const apiKey = faker.string.sample(); const taskId = faker.string.uuid(); - fakeConfigurationService.set(`gelato.apiKey.${chainId}`, apiKey); + fakeConfigurationService.set(`relay.apiKey.${chainId}`, apiKey); mockNetworkService.post.mockResolvedValueOnce({ status: 200, data: { @@ -166,63 +123,11 @@ describe('GelatoApi', () => { chainId, to: address, data, + gasLimit: null, }), ).rejects.toThrow(); }); - it('should increment the count after relaying', 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(); - const taskId = faker.string.uuid(); - fakeConfigurationService.set(`gelato.apiKey.${chainId}`, apiKey); - mockNetworkService.post.mockResolvedValueOnce({ - status: 200, - data: { - taskId, - }, - }); - - await target.relay({ - chainId, - to: address, - data, - }); - - expect(mockCacheService.set).toHaveBeenCalledTimes(1); - expect(mockCacheService.set).toHaveBeenCalledWith( - new CacheDir(`${chainId}_relay_${address}`, ''), - '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(); - const taskId = faker.string.uuid(); - fakeConfigurationService.set(`gelato.apiKey.${chainId}`, apiKey); - mockNetworkService.post.mockResolvedValueOnce({ - status: 200, - data: { - taskId, - }, - }); - mockCacheService.set.mockRejectedValueOnce( - new Error('Setting cache threw'), - ); - - await expect( - target.relay({ - chainId, - to: address, - data, - }), - ).resolves.not.toThrow(); - }); - it('should forward error', async () => { const chainId = faker.string.numeric(); const address = faker.finance.ethereumAddress() as Hex; @@ -238,7 +143,7 @@ describe('GelatoApi', () => { message: 'Unexpected error', }, ); - fakeConfigurationService.set(`gelato.apiKey.${chainId}`, apiKey); + fakeConfigurationService.set(`relay.apiKey.${chainId}`, apiKey); mockNetworkService.post.mockRejectedValueOnce(error); await expect( @@ -246,6 +151,7 @@ describe('GelatoApi', () => { chainId, to: address, data, + gasLimit: null, }), ).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 eff62decb3..a8b27b861c 100644 --- a/src/datasources/relay-api/gelato-api.service.ts +++ b/src/datasources/relay-api/gelato-api.service.ts @@ -6,12 +6,6 @@ import { import { IRelayApi } from '@/domain/interfaces/relay-api.interface'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { HttpErrorFactory } from '@/datasources/errors/http-error-factory'; -import { - CacheService, - ICacheService, -} from '@/datasources/cache/cache.service.interface'; -import { CacheRouter } from '@/datasources/cache/cache.router'; -import { ILoggingService, LoggingService } from '@/logging/logging.interface'; @Injectable() export class GelatoApi implements IRelayApi { @@ -21,6 +15,7 @@ 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; @@ -30,50 +25,20 @@ export class GelatoApi implements IRelayApi { private readonly networkService: INetworkService, @Inject(IConfigurationService) private readonly configurationService: IConfigurationService, - @Inject(CacheService) private readonly cacheService: ICacheService, - @Inject(LoggingService) private readonly loggingService: ILoggingService, private readonly httpErrorFactory: HttpErrorFactory, ) { this.baseUri = this.configurationService.getOrThrow('relay.baseUri'); } - 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; - } - async relay(args: { chainId: string; to: string; data: string; - gasLimit?: string; - }): Promise<{ taskId: string }> { - const relayResponse = await this.sponsoredCall(args); - - 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 async sponsoredCall(args: { - chainId: string; - to: string; - data: string; - gasLimit?: string; + gasLimit: string | null; }): Promise<{ taskId: string }> { const sponsorApiKey = this.configurationService.getOrThrow( - `gelato.apiKey.${args.chainId}`, + `relay.apiKey.${args.chainId}`, ); try { @@ -96,14 +61,4 @@ export class GelatoApi implements IRelayApi { private getRelayGasLimit(gasLimit: string): string { return (BigInt(gasLimit) + GelatoApi.GAS_LIMIT_BUFFER).toString(); } - - 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()); - } } diff --git a/src/domain/interfaces/relay-api.interface.ts b/src/domain/interfaces/relay-api.interface.ts index 536e190c9f..aa3b65a7e4 100644 --- a/src/domain/interfaces/relay-api.interface.ts +++ b/src/domain/interfaces/relay-api.interface.ts @@ -1,12 +1,10 @@ export const IRelayApi = Symbol('IRelayApi'); export interface IRelayApi { - getRelayCount(args: { chainId: string; address: string }): Promise; - relay(args: { chainId: string; to: string; data: string; - gasLimit?: string; + gasLimit: string | null; }): Promise<{ taskId: string }>; } 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/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/limit-addresses.mapper.spec.ts b/src/domain/relay/limit-addresses.mapper.spec.ts index deef2d779d..dd7d0bab43 100644 --- a/src/domain/relay/limit-addresses.mapper.spec.ts +++ b/src/domain/relay/limit-addresses.mapper.spec.ts @@ -66,7 +66,7 @@ describe('LimitAddressesMapper', () => { const safe = safeBuilder().build(); const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() - .with('to', getAddress(faker.finance.ethereumAddress())) + .with('value', faker.number.bigInt()) .encode() as Hex; // Official mastercopy mockSafeRepository.getSafe.mockResolvedValue(safe); @@ -85,7 +85,6 @@ describe('LimitAddressesMapper', () => { const safe = safeBuilder().build(); const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() - .with('to', getAddress(faker.finance.ethereumAddress())) .with('data', erc20TransferEncoder().encode()) .encode() as Hex; // Official mastercopy @@ -285,7 +284,6 @@ describe('LimitAddressesMapper', () => { const safe = safeBuilder().build(); const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() - .with('to', getAddress(faker.finance.ethereumAddress())) .with('data', execTransactionEncoder().encode()) .encode() as Hex; // Official mastercopy @@ -306,6 +304,7 @@ describe('LimitAddressesMapper', () => { 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); @@ -316,7 +315,7 @@ describe('LimitAddressesMapper', () => { data, to: safeAddress, }), - ).rejects.toThrow('execTransaction via unofficial Safe mastercopy'); + ).rejects.toThrow('Cannot get limit addresses – Invalid transfer'); }); // transfer (execTransaction) @@ -325,7 +324,6 @@ describe('LimitAddressesMapper', () => { const safe = safeBuilder().build(); const safeAddress = getAddress(safe.address); const data = execTransactionEncoder() - .with('to', getAddress(faker.finance.ethereumAddress())) .with('data', erc20TransferEncoder().with('to', safeAddress).encode()) .encode() as Hex; // Official mastercopy 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 0da14b32ec..cf702638a3 100644 --- a/src/domain/relay/relay.repository.ts +++ b/src/domain/relay/relay.repository.ts @@ -1,24 +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 } 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; @@ -27,7 +21,9 @@ 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'); } @@ -36,10 +32,11 @@ export class RelayRepository { chainId: string; to: string; data: string; - gasLimit?: string; + gasLimit: string | null; }): Promise<{ taskId: string }> { const relayAddresses = await this.limitAddressesMapper.getLimitAddresses(relayPayload); + for (const address of relayAddresses) { const canRelay = await this.canRelay({ chainId: relayPayload.chainId, @@ -56,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: { @@ -67,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/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..dd0514fb70 --- /dev/null +++ b/src/routes/relay/relay.controller.module.ts @@ -0,0 +1,11 @@ +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'; + +@Module({ + imports: [RelayDomainModule], + providers: [RelayService], + controllers: [RelayController], +}) +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..f1a76ac867 --- /dev/null +++ b/src/routes/relay/relay.controller.spec.ts @@ -0,0 +1,1276 @@ +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', () => { + // TODO: Return a 422 when the transactions are invalid + describe('execTransaction', () => { + // execTransaction + it('should return 500 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(500); + }); + + // transfer (execTransaction) + it('should return 500 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(500); + }); + + // Unofficial mastercopy + it('should return 500 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(500); + }); + }); + + describe('multiSend', () => { + it('should return 500 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(500); + }); + + it('should return 500 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(500); + }); + + it('should return 500 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(500); + }); + + it('should return 500 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(500); + }); + }); + + describe('createProxyWithNonce', () => { + it('should return 500 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(500); + }); + }); + + it('should otherwise return 500', 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(500); + }); + }); + + 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..465b1f46f7 --- /dev/null +++ b/src/routes/relay/relay.controller.ts @@ -0,0 +1,36 @@ +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'; + +@ApiTags('relay') +@Controller({ + version: '1', + path: 'chains/:chainId/relay', +}) +export class RelayController { + constructor(private readonly relayService: RelayService) {} + + @Post() + @UseFilters(RelayLimitReachedExceptionFilter) + 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.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, + }; + } +}