Skip to content

Commit

Permalink
Add relay controller (safe-global#1153)
Browse files Browse the repository at this point in the history
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`
  • Loading branch information
iamacook authored Feb 16, 2024
1 parent e0ab5ef commit ce444fa
Show file tree
Hide file tree
Showing 20 changed files with 1,566 additions and 182 deletions.
5 changes: 5 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand Down
5 changes: 4 additions & 1 deletion src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({})
Expand All @@ -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,
Expand All @@ -75,6 +77,7 @@ export class AppModule implements NestModule {
MessagesModule,
NotificationsModule,
OwnersModule,
...(isRelayFeatureEnabled ? [RelayControllerModule] : []),
RootModule,
SafeAppsModule,
SafesModule,
Expand Down
4 changes: 4 additions & 0 deletions src/config/entities/__tests__/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export default (): ReturnType<typeof configuration> => ({
richFragments: true,
email: true,
zerionBalancesChainIds: ['137'],
relay: true,
},
httpClient: { requestTimeout: faker.number.int() },
log: {
Expand Down Expand Up @@ -184,6 +185,9 @@ export default (): ReturnType<typeof configuration> => ({
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 }),
Expand Down
4 changes: 4 additions & 0 deletions src/config/entities/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
106 changes: 6 additions & 100 deletions src/datasources/relay-api/gelato-api.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,16 @@
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';
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<ICacheService>);

const mockNetworkService = jest.mocked({
post: jest.fn(),
} as jest.MockedObjectDeep<INetworkService>);

const mockLoggingService = {
warn: jest.fn(),
} as jest.MockedObjectDeep<ILoggingService>;

describe('GelatoApi', () => {
let target: GelatoApi;
let fakeConfigurationService: FakeConfigurationService;
Expand All @@ -40,8 +28,6 @@ describe('GelatoApi', () => {
target = new GelatoApi(
mockNetworkService,
fakeConfigurationService,
mockCacheService,
mockLoggingService,
httpErrorFactory,
);
});
Expand All @@ -55,49 +41,19 @@ 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();
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);
fakeConfigurationService.set(`relay.apiKey.${chainId}`, apiKey);
mockNetworkService.post.mockResolvedValueOnce({
status: 200,
data: {
Expand All @@ -109,6 +65,7 @@ describe('GelatoApi', () => {
chainId,
to: address,
data,
gasLimit: null,
});

expect(mockNetworkService.post).toHaveBeenCalledWith(
Expand All @@ -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: {
Expand Down Expand Up @@ -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;
Expand All @@ -238,14 +143,15 @@ describe('GelatoApi', () => {
message: 'Unexpected error',
},
);
fakeConfigurationService.set(`gelato.apiKey.${chainId}`, apiKey);
fakeConfigurationService.set(`relay.apiKey.${chainId}`, apiKey);
mockNetworkService.post.mockRejectedValueOnce(error);

await expect(
target.relay({
chainId,
to: address,
data,
gasLimit: null,
}),
).rejects.toThrow(new DataSourceError('Unexpected error', status));
});
Expand Down
51 changes: 3 additions & 48 deletions src/datasources/relay-api/gelato-api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -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<string>('relay.baseUri');
}

async getRelayCount(args: {
chainId: string;
address: string;
}): Promise<number> {
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<string>(
`gelato.apiKey.${args.chainId}`,
`relay.apiKey.${args.chainId}`,
);

try {
Expand All @@ -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<void> {
const currentCount = await this.getRelayCount(args);
const incremented = currentCount + 1;
const cacheDir = CacheRouter.getRelayCacheDir(args);
return this.cacheService.set(cacheDir, incremented.toString());
}
}
4 changes: 1 addition & 3 deletions src/domain/interfaces/relay-api.interface.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
export const IRelayApi = Symbol('IRelayApi');

export interface IRelayApi {
getRelayCount(args: { chainId: string; address: string }): Promise<number>;

relay(args: {
chainId: string;
to: string;
data: string;
gasLimit?: string;
gasLimit: string | null;
}): Promise<{ taskId: string }>;
}
13 changes: 13 additions & 0 deletions src/domain/relay/errors/relay-limit-reached.error.ts
Original file line number Diff line number Diff line change
@@ -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}`,
);
}
}
Loading

0 comments on commit ce444fa

Please sign in to comment.