Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@
# Default if none is set: https://safe-locking.safe.global
# LOCKING_PROVIDER_API_BASE_URI=https://safe-locking.safe.global

# Transaction Service API Key (optional, but recommended for local development)
# Set this to avoid rate limiting when running CGW locally with public Tx Service
# Generate keys at: https://developer.5afe.dev/api-keys (staging) or https://developer.safe.global/api-keys (prod)
# TX_SERVICE_API_KEY=dummy

# Redis
# The host name of where the Redis instance is running
# (default=localhost)
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ yarn generate-abis
docker compose up -d redis
```

If you run the service locally against a local Safe{Wallet} instance,

- set `TX_SERVICE_API_KEY` to a valid key to avoid hitting the Transaction Service rate limit
- set `CGW_ENV=development`
- set `ALLOW_CORS=true`

To generate a key, go to:

- [Tx Service staging](https://developer.5afe.dev/api-keys)
- [Tx Service production](https://developer.safe.global/api-keys)

2. Start the Safe Client Gateway

```bash
Expand Down
16 changes: 16 additions & 0 deletions src/config/configuration.validator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ describe('Configuration validator', () => {
CSV_AWS_SECRET_ACCESS_KEY: faker.string.uuid(),
CSV_EXPORT_QUEUE_CONCURRENCY: faker.number.int({ min: 1, max: 5 }),
BLOCKAID_CLIENT_API_KEY: faker.string.uuid(),
TX_SERVICE_API_KEY: faker.string.hexadecimal({ length: 32 }),
};

it('should bypass this validation on test environment', () => {
Expand Down Expand Up @@ -133,6 +134,21 @@ describe('Configuration validator', () => {
},
);

it.each(['', ' '])(
'should reject empty TX_SERVICE_API_KEY values in production environment',
(apiKey) => {
process.env.NODE_ENV = 'production';
expect(() =>
configurationValidator(
{ ...validConfiguration, TX_SERVICE_API_KEY: apiKey },
RootConfigurationSchema,
),
).toThrow(
'Configuration is invalid: TX_SERVICE_API_KEY String must contain at least 1 character(s)',
);
},
);

it('should detect an invalid LOG_LEVEL configuration in production environment', () => {
process.env.NODE_ENV = 'production';
const invalidConfiguration: Record<string, unknown> = {
Expand Down
1 change: 1 addition & 0 deletions src/config/entities/__tests__/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ export default (): ReturnType<typeof configuration> => ({
},
safeTransaction: {
useVpcUrl: false,
apiKey: faker.string.hexadecimal({ length: 32 }),
},
safeWebApp: {
baseUri: faker.internet.url({ appendSlash: false }),
Expand Down
1 change: 1 addition & 0 deletions src/config/entities/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,7 @@ export default () => ({
},
safeTransaction: {
useVpcUrl: process.env.USE_TX_SERVICE_VPC_URL?.toLowerCase() === 'true',
apiKey: process.env.TX_SERVICE_API_KEY,
},
safeWebApp: {
baseUri: process.env.SAFE_WEB_APP_BASE_URI || 'https://app.safe.global',
Expand Down
1 change: 1 addition & 0 deletions src/config/entities/schemas/configuration.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export const RootConfigurationSchema = z
CSV_AWS_SECRET_ACCESS_KEY: z.string().optional(),
CSV_EXPORT_QUEUE_CONCURRENCY: z.number({ coerce: true }).min(1).optional(),
BLOCKAID_CLIENT_API_KEY: z.string().optional(),
TX_SERVICE_API_KEY: z.string().trim().min(1).optional(),
})
.superRefine((config, ctx) =>
// Check for AWS_* and Blockaid fields in production and staging environments
Expand Down
41 changes: 39 additions & 2 deletions src/datasources/cache/cache.first.data.source.module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,46 @@
import { Module } from '@nestjs/common';
import { CacheFirstDataSource } from '@/datasources/cache/cache.first.data.source';
import { HttpErrorFactory } from '@/datasources/errors/http-error-factory';
import {
CacheService,
type ICacheService,
} from '@/datasources/cache/cache.service.interface';
import {
INetworkService,
TxNetworkService,
} from '@/datasources/network/network.service.interface';
import { ILoggingService, LoggingService } from '@/logging/logging.interface';
import { IConfigurationService } from '@/config/configuration.service.interface';

export const TxCacheFirstDataSource = Symbol('TxCacheFirstDataSource');

@Module({
providers: [CacheFirstDataSource, HttpErrorFactory],
exports: [CacheFirstDataSource],
providers: [
CacheFirstDataSource,
HttpErrorFactory,
{
provide: TxCacheFirstDataSource,
// Tx-flavored CacheFirstDataSource wired to the TxNetworkService so Tx Service consumers get the auth header behavior.
useFactory: (
cacheService: ICacheService,
networkService: INetworkService,
loggingService: ILoggingService,
configurationService: IConfigurationService,
): CacheFirstDataSource =>
new CacheFirstDataSource(
cacheService,
networkService,
loggingService,
configurationService,
),
inject: [
CacheService,
TxNetworkService,
LoggingService,
IConfigurationService,
],
},
],
exports: [CacheFirstDataSource, TxCacheFirstDataSource],
})
export class CacheFirstDataSourceModule {}
7 changes: 6 additions & 1 deletion src/datasources/network/__tests__/test.network.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Global, Module } from '@nestjs/common';
import {
INetworkService,
NetworkService,
TxNetworkService,
} from '@/datasources/network/network.service.interface';

const networkService: INetworkService = {
Expand Down Expand Up @@ -29,7 +30,11 @@ const networkService: INetworkService = {
return jest.mocked(networkService);
},
},
{
provide: TxNetworkService,
useExisting: NetworkService,
},
],
exports: [NetworkService],
exports: [NetworkService, TxNetworkService],
})
export class TestNetworkModule {}
9 changes: 7 additions & 2 deletions src/datasources/network/network.module.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { IConfigurationService } from '@/config/configuration.service.interface';
import { FetchNetworkService } from '@/datasources/network/fetch.network.service';
import { NetworkService } from '@/datasources/network/network.service.interface';
import {
NetworkService,
TxNetworkService,
} from '@/datasources/network/network.service.interface';
import { NetworkResponse } from '@/datasources/network/entities/network.response.entity';
import {
NetworkRequestError,
Expand All @@ -11,6 +14,7 @@ import type { Raw } from '@/validation/entities/raw.entity';
import { ILoggingService, LoggingService } from '@/logging/logging.interface';
import { LogType } from '@/domain/common/entities/log-type.entity';
import { hashSha1 } from '@/domain/common/utils/utils';
import { TxAuthNetworkService } from '@/datasources/network/tx-auth.network.service';

export type FetchClient = <T>(
url: string,
Expand Down Expand Up @@ -157,7 +161,8 @@ function getCacheKey(
inject: [IConfigurationService, LoggingService],
},
{ provide: NetworkService, useClass: FetchNetworkService },
{ provide: TxNetworkService, useClass: TxAuthNetworkService },
],
exports: [NetworkService],
exports: [NetworkService, TxNetworkService],
})
export class NetworkModule {}
1 change: 1 addition & 0 deletions src/datasources/network/network.service.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { NetworkRequest } from '@/datasources/network/entities/network.requ
import type { NetworkResponse } from '@/datasources/network/entities/network.response.entity';

export const NetworkService = Symbol('INetworkService');
export const TxNetworkService = Symbol('ITxNetworkService');

export interface INetworkService {
get<T>(args: {
Expand Down
120 changes: 120 additions & 0 deletions src/datasources/network/tx-auth.network.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { TxAuthNetworkService } from '@/datasources/network/tx-auth.network.service';
import type { INetworkService } from '@/datasources/network/network.service.interface';
import type { IConfigurationService } from '@/config/configuration.service.interface';
import { rawify } from '@/validation/entities/raw.entity';

describe('TxAuthNetworkService', () => {
const baseNetworkService: jest.MockedObjectDeep<INetworkService> = {
get: jest.fn(),
post: jest.fn(),
delete: jest.fn(),
};

const config = {
getOrThrow: jest.fn(),
get: jest.fn(),
} as jest.MockedObjectDeep<IConfigurationService>;

const buildService = (
options: Partial<{
isDevelopment: boolean;
useVpcUrl: boolean;
apiKey: string | undefined;
}> = {},
): TxAuthNetworkService => {
const { isDevelopment = true, useVpcUrl = false } = options;
const apiKey = 'apiKey' in options ? options.apiKey : 'tx-key';

config.getOrThrow.mockImplementation((key: string) => {
switch (key) {
case 'application.isDevelopment':
return isDevelopment;
case 'safeTransaction.useVpcUrl':
return useVpcUrl;
default:
throw new Error(`Unexpected key: ${key}`);
}
});
config.get.mockImplementation((key: string) => {
if (key === 'safeTransaction.apiKey') {
return apiKey;
}
throw new Error(`Unexpected key: ${key}`);
});

return new TxAuthNetworkService(baseNetworkService, config);
};

beforeEach(() => {
jest.resetAllMocks();
});

it('adds Authorization header when in development, not using VPC, and apiKey exists', async () => {
const service = buildService();
const args = {
url: 'https://example.test',
networkRequest: { headers: { Foo: 'bar' } },
};
baseNetworkService.get.mockResolvedValue({
data: rawify('ok'),
status: 200,
});

await service.get(args);

expect(baseNetworkService.get).toHaveBeenCalledWith({
url: args.url,
networkRequest: {
headers: { Foo: 'bar', Authorization: 'Bearer tx-key' },
},
});
});

it('preserves params when adding Authorization header', async () => {
const service = buildService();
const args = {
url: 'https://example.test',
networkRequest: { params: { a: '1' } },
};
baseNetworkService.post.mockResolvedValue({
data: rawify('ok'),
status: 200,
});

await service.post(args);

expect(baseNetworkService.post).toHaveBeenCalledWith({
url: args.url,
networkRequest: {
params: { a: '1' },
headers: { Authorization: 'Bearer tx-key' },
},
});
});

it.each([
['not development', { isDevelopment: false }],
['using VPC', { useVpcUrl: true }],
['missing apiKey', { apiKey: undefined }],
])('does not add Authorization header when %s', async (_name, overrides) => {
const service = buildService(
overrides as Partial<{
isDevelopment: boolean;
useVpcUrl: boolean;
apiKey: string | undefined;
}>,
);
const args = {
url: 'https://example.test',
networkRequest: { headers: { Foo: 'bar' } },
};
baseNetworkService.delete.mockResolvedValue({
data: rawify('ok'),
status: 200,
});

await service.delete(args);

expect(baseNetworkService.delete).toHaveBeenCalledWith(args);
});
});
80 changes: 80 additions & 0 deletions src/datasources/network/tx-auth.network.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Inject, Injectable } from '@nestjs/common';
import { IConfigurationService } from '@/config/configuration.service.interface';
import {
INetworkService,
NetworkService,
} from '@/datasources/network/network.service.interface';
import { NetworkRequest } from '@/datasources/network/entities/network.request.entity';
import type { NetworkResponse } from '@/datasources/network/entities/network.response.entity';

/**
* Decorates the base {@link INetworkService} to add Tx API auth when
* running in development against the public Transaction Service.
*/
@Injectable()
export class TxAuthNetworkService implements INetworkService {
private readonly isDevelopment: boolean;
private readonly useVpcUrl: boolean;
private readonly apiKey: string | undefined;

constructor(
@Inject(NetworkService)
private readonly networkService: INetworkService,
@Inject(IConfigurationService)
private readonly configurationService: IConfigurationService,
) {
this.isDevelopment = this.configurationService.getOrThrow<boolean>(
'application.isDevelopment',
);
this.useVpcUrl = this.configurationService.getOrThrow<boolean>(
'safeTransaction.useVpcUrl',
);
this.apiKey = this.configurationService.get<string | undefined>(
'safeTransaction.apiKey',
);
}

get<T>(args: {
url: string;
networkRequest?: NetworkRequest;
}): Promise<NetworkResponse<T>> {
return this.networkService.get<T>(this.withAuth(args));
}

post<T>(args: {
url: string;
data?: object;
networkRequest?: NetworkRequest;
}): Promise<NetworkResponse<T>> {
return this.networkService.post<T>(this.withAuth(args));
}

delete<T>(args: {
url: string;
data?: object;
networkRequest?: NetworkRequest;
}): Promise<NetworkResponse<T>> {
return this.networkService.delete<T>(this.withAuth(args));
}

private withAuth<TArgs extends { networkRequest?: NetworkRequest }>(
args: TArgs,
): TArgs {
const isTxAuthEnabled = this.isDevelopment && !this.useVpcUrl;
if (!isTxAuthEnabled || !this.apiKey) {
return args;
}

// Only add auth header when in development, and using the public Tx Service and API key is set
return {
...args,
networkRequest: {
...args.networkRequest,
headers: {
...(args.networkRequest?.headers ?? {}),
Authorization: `Bearer ${this.apiKey}`,
},
},
};
}
}
Loading