Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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 @@ -111,6 +111,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 @@ -55,6 +55,17 @@ Please review the required API keys in the `.env` file and ensure you have creat
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
6 changes: 6 additions & 0 deletions src/__tests__/testing-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { CacheKeyPrefix } from '@/datasources/cache/constants';
import type { Provider } from '@nestjs/common';
import { CsvExportModule } from '@/modules/csv-export/csv-export.module';
import { TestCsvExportModule } from '@/modules/csv-export/v1/__tests__/test.csv-export.module';
import { TxAuthNetworkModule } from '@/datasources/network/tx-auth.network.module';
import { TestTxAuthNetworkModule } from '@/datasources/network/__tests__/test.tx-auth.network.module';

export interface CreateBaseTestModuleOptions {
config?: typeof configuration;
Expand Down Expand Up @@ -71,6 +73,10 @@ export async function createTestModule(
originalModule: NetworkModule,
testModule: TestNetworkModule,
},
{
originalModule: TxAuthNetworkModule,
testModule: TestTxAuthNetworkModule,
},
...additionalOverrides,
],
});
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 @@ -376,6 +376,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 @@ -533,6 +533,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 @@ -143,6 +143,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
2 changes: 1 addition & 1 deletion src/datasources/network/__tests__/test.network.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
NetworkService,
} from '@/datasources/network/network.service.interface';

const networkService: INetworkService = {
export const networkService: INetworkService = {
get: jest.fn(),
post: jest.fn(),
delete: jest.fn(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Module } from '@nestjs/common';
import {
INetworkService,
NetworkService,
} from '@/datasources/network/network.service.interface';
import { CacheFirstDataSource } from '@/datasources/cache/cache.first.data.source';
import { HttpErrorFactory } from '@/datasources/errors/http-error-factory';
import { CacheFirstDataSourceModule } from '@/datasources/cache/cache.first.data.source.module';
import { networkService } from '@/datasources/network/__tests__/test.network.module';

/**
* Test module that overrides {@link TxAuthNetworkModule} with mocked dependencies.
*
* Key points:
* - Reuses the same NetworkService mock instance from {@link TestNetworkModule}
* - Exports real CacheFirstDataSource & HttpErrorFactory (with mocked NetworkService injected)
* - Required by TransactionApiManager and other consumers that inject these dependencies
*/
@Module({
imports: [CacheFirstDataSourceModule],
providers: [
{
provide: NetworkService,
useFactory: (): jest.MockedObjectDeep<INetworkService> => {
return jest.mocked(networkService);
},
},
CacheFirstDataSource,
HttpErrorFactory,
],
exports: [NetworkService, CacheFirstDataSource, HttpErrorFactory],
})
export class TestTxAuthNetworkModule {}
86 changes: 86 additions & 0 deletions src/datasources/network/auth/tx-auth-headers.helper.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { IConfigurationService } from '@/config/configuration.service.interface';
import { getTxAuthHeaders } from '@/datasources/network/auth/tx-auth-headers.helper';

describe('getTxAuthHeaders', () => {
let mockConfigService: jest.Mocked<IConfigurationService>;

beforeEach(() => {
jest.resetAllMocks();
mockConfigService = {
getOrThrow: jest.fn(),
get: jest.fn(),
} as jest.Mocked<IConfigurationService>;
});

it('should return Authorization header when TX auth is enabled', () => {
const apiKey = 'test-api-key-123';
mockConfigService.getOrThrow.mockImplementation((key: string) => {
if (key === 'application.isDevelopment') return true;
if (key === 'safeTransaction.useVpcUrl') return false;
throw new Error(`Unexpected key: ${key}`);
});
mockConfigService.get.mockReturnValue(apiKey);

const result = getTxAuthHeaders(mockConfigService);

expect(result).toEqual({
Authorization: `Bearer ${apiKey}`,
});
expect(mockConfigService.getOrThrow).toHaveBeenCalledWith(
'application.isDevelopment',
);
expect(mockConfigService.getOrThrow).toHaveBeenCalledWith(
'safeTransaction.useVpcUrl',
);
expect(mockConfigService.get).toHaveBeenCalledWith(
'safeTransaction.apiKey',
);
});

it.each([
{
description: 'not in development mode',
isDevelopment: false,
useVpcUrl: false,
apiKey: 'test-key',
},
{
description: 'useVpcUrl is true',
isDevelopment: true,
useVpcUrl: true,
apiKey: 'test-key',
},
{
description: 'API key is undefined',
isDevelopment: true,
useVpcUrl: false,
apiKey: undefined,
},
{
description: 'API key is empty string',
isDevelopment: true,
useVpcUrl: false,
apiKey: '',
},
{
description: 'in production with VPC URL',
isDevelopment: false,
useVpcUrl: true,
apiKey: 'test-key',
},
])(
'should return undefined when $description',
({ isDevelopment, useVpcUrl, apiKey }) => {
mockConfigService.getOrThrow.mockImplementation((key: string) => {
if (key === 'application.isDevelopment') return isDevelopment;
if (key === 'safeTransaction.useVpcUrl') return useVpcUrl;
throw new Error(`Unexpected key: ${key}`);
});
mockConfigService.get.mockReturnValue(apiKey);

const result = getTxAuthHeaders(mockConfigService);

expect(result).toBeUndefined();
},
);
});
32 changes: 32 additions & 0 deletions src/datasources/network/auth/tx-auth-headers.helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { IConfigurationService } from '@/config/configuration.service.interface';

/**
* Returns Transaction Service auth headers when running in development
* against the public Transaction Service.
*
* @param configurationService - Configuration service used to read settings
* @returns An object containing the `Authorization` header (`{ Authorization: `Bearer ${apiKey}` }`)
* when TX auth is enabled and an API key is configured; otherwise `undefined`.
*/
export function getTxAuthHeaders(
configurationService: IConfigurationService,
): Record<string, string> | undefined {
const isDevelopment = configurationService.getOrThrow<boolean>(
'application.isDevelopment',
);
const useVpcUrl = configurationService.getOrThrow<boolean>(
'safeTransaction.useVpcUrl',
);
const apiKey = configurationService.get<string | undefined>(
'safeTransaction.apiKey',
);

const isTxAuthEnabled = isDevelopment && !useVpcUrl;
if (!isTxAuthEnabled || !apiKey) {
return undefined;
}

return {
Authorization: `Bearer ${apiKey}`,
};
}
Loading