Skip to content

Commit

Permalink
Merge branch 'main' into fix/increase-refinement-tests
Browse files Browse the repository at this point in the history
  • Loading branch information
iamacook authored Mar 7, 2025
2 parents 1403069 + 299b390 commit 4b20e69
Show file tree
Hide file tree
Showing 13 changed files with 435 additions and 31 deletions.
1 change: 1 addition & 0 deletions src/config/entities/__tests__/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ export default (): ReturnType<typeof configuration> => ({
messageVerification: true,
ethSign: true,
trustedDelegateCall: false,
trustedForDelegateCallContractsList: false,
},
httpClient: { requestTimeout: faker.number.int() },
locking: {
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 @@ -263,6 +263,10 @@ export default () => ({
ethSign: process.env.FF_ETH_SIGN?.toLowerCase() === 'true',
trustedDelegateCall:
process.env.FF_TRUSTED_DELEGATE_CALL?.toLowerCase() === 'true',
// TODO: Remove this feature flag once the feature is established.
trustedForDelegateCallContractsList:
process.env.FF_TRUSTED_FOR_DELEGATE_CALL_CONTRACTS_LIST?.toLowerCase() ===
'true',
},
httpClient: {
// Timeout in milliseconds to be used for the HTTP client.
Expand Down
13 changes: 13 additions & 0 deletions src/datasources/cache/cache.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export class CacheRouter {
private static readonly CHAIN_KEY = 'chain';
private static readonly CHAINS_KEY = 'chains';
private static readonly CONTRACT_KEY = 'contract';
private static readonly TRUSTED_FOR_DELEGATE_CALL_CONTRACTS_KEY =
'trusted_contracts';
private static readonly COUNTERFACTUAL_SAFE_KEY = 'counterfactual_safe';
private static readonly COUNTERFACTUAL_SAFES_KEY = 'counterfactual_safes';
private static readonly CREATION_TRANSACTION_KEY = 'creation_transaction';
Expand Down Expand Up @@ -170,6 +172,17 @@ export class CacheRouter {
);
}

static getTrustedForDelegateCallContractsCacheKey(chainId: string): string {
return `${chainId}_${CacheRouter.TRUSTED_FOR_DELEGATE_CALL_CONTRACTS_KEY}`;
}

static getTrustedForDelegateCallContractsCacheDir(chainId: string): CacheDir {
return new CacheDir(
CacheRouter.getTrustedForDelegateCallContractsCacheKey(chainId),
'',
);
}

static getBackboneCacheDir(chainId: string): CacheDir {
return new CacheDir(`${chainId}_${CacheRouter.BACKBONE_KEY}`, '');
}
Expand Down
69 changes: 69 additions & 0 deletions src/datasources/transaction-api/transaction-api.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,75 @@ describe('TransactionApi', () => {
});
});

describe('getTrustedForDelegateCallContracts', () => {
it('should return the trusted for delegate call contracts received', async () => {
const contractPage = pageBuilder()
.with('results', [
contractBuilder().with('trustedForDelegateCall', true).build(),
contractBuilder().with('trustedForDelegateCall', true).build(),
])
.build();
const getTrustedForDelegateCallContractsUrl = `${baseUrl}/api/v1/contracts/`;
const cacheDir = new CacheDir(`${chainId}_trusted_contracts`, '');
mockDataSource.get.mockResolvedValueOnce(rawify(contractPage));

const actual = await service.getTrustedForDelegateCallContracts();

expect(actual).toBe(contractPage);
expect(mockDataSource.get).toHaveBeenCalledTimes(1);
expect(mockDataSource.get).toHaveBeenCalledWith({
cacheDir,
url: getTrustedForDelegateCallContractsUrl,
notFoundExpireTimeSeconds: notFoundExpireTimeSeconds,
expireTimeSeconds: defaultExpirationTimeInSeconds,
networkRequest: {
params: {
trusted_for_delegate_call: true,
},
},
});
});

const errorMessage = faker.word.words();
it.each([
['Transaction Service', { nonFieldErrors: [errorMessage] }],
['standard', new Error(errorMessage)],
])(`should forward a %s error`, async (_, error) => {
const getTrustedForDelegateCallContractsUrl = `${baseUrl}/api/v1/contracts/`;
const statusCode = faker.internet.httpStatusCode({
types: ['clientError', 'serverError'],
});
const expected = new DataSourceError(errorMessage, statusCode);
const cacheDir = new CacheDir(`${chainId}_trusted_contracts`, '');
mockDataSource.get.mockRejectedValueOnce(
new NetworkResponseError(
new URL(getTrustedForDelegateCallContractsUrl),
{
status: statusCode,
} as Response,
error,
),
);

await expect(
service.getTrustedForDelegateCallContracts(),
).rejects.toThrow(expected);

expect(mockDataSource.get).toHaveBeenCalledTimes(1);
expect(mockDataSource.get).toHaveBeenCalledWith({
cacheDir,
url: getTrustedForDelegateCallContractsUrl,
notFoundExpireTimeSeconds: notFoundExpireTimeSeconds,
expireTimeSeconds: defaultExpirationTimeInSeconds,
networkRequest: {
params: {
trusted_for_delegate_call: true,
},
},
});
});
});

describe('getContract', () => {
it('should return retrieved contract', async () => {
const contract = contractBuilder().build();
Expand Down
24 changes: 24 additions & 0 deletions src/datasources/transaction-api/transaction-api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,30 @@ export class TransactionApi implements ITransactionApi {
}
}

// Important: there is no hook which invalidates this endpoint,
// Therefore, this data will live in cache until [defaultExpirationTimeInSeconds]
async getTrustedForDelegateCallContracts(): Promise<Raw<Page<Contract>>> {
try {
const cacheDir = CacheRouter.getTrustedForDelegateCallContractsCacheDir(
this.chainId,
);
const url = `${this.baseUrl}/api/v1/contracts/`;
return await this.dataSource.get<Page<Contract>>({
cacheDir,
url,
notFoundExpireTimeSeconds: this.defaultNotFoundExpirationTimeSeconds,
expireTimeSeconds: this.defaultExpirationTimeInSeconds,
networkRequest: {
params: {
trusted_for_delegate_call: true,
},
},
});
} catch (error) {
throw this.httpErrorFactory.from(this.mapError(error));
}
}

async getDelegates(args: {
safeAddress?: `0x${string}`;
delegate?: `0x${string}`;
Expand Down
8 changes: 8 additions & 0 deletions src/domain/contracts/contracts.repository.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ export interface IContractsRepository {
chainId: string;
contractAddress: `0x${string}`;
}): Promise<Contract>;

/**
* Determines if the contract at the {@link contractAddress} is trusted for delegate calls.
*/
isTrustedForDelegateCall(args: {
chainId: string;
contractAddress: `0x${string}`;
}): Promise<boolean>;
}

@Module({
Expand Down
67 changes: 65 additions & 2 deletions src/domain/contracts/contracts.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,29 @@ import { Inject, Injectable } from '@nestjs/common';
import { IContractsRepository } from '@/domain/contracts/contracts.repository.interface';
import { Contract } from '@/domain/contracts/entities/contract.entity';
import { ITransactionApiManager } from '@/domain/interfaces/transaction-api.manager.interface';
import { ContractSchema } from '@/domain/contracts/entities/schemas/contract.schema';
import {
ContractPageSchema,
ContractSchema,
} from '@/domain/contracts/entities/schemas/contract.schema';
import { Page } from '@/domain/entities/page.entity';
import { isAddressEqual } from 'viem';
import { IConfigurationService } from '@/config/configuration.service.interface';

@Injectable()
export class ContractsRepository implements IContractsRepository {
private readonly isTrustedForDelegateCallContractsListEnabled: boolean;

constructor(
@Inject(ITransactionApiManager)
private readonly transactionApiManager: ITransactionApiManager,
) {}
@Inject(IConfigurationService)
private readonly configurationService: IConfigurationService,
) {
this.isTrustedForDelegateCallContractsListEnabled =
this.configurationService.getOrThrow(
'features.trustedForDelegateCallContractsList',
);
}

async getContract(args: {
chainId: string;
Expand All @@ -19,4 +34,52 @@ export class ContractsRepository implements IContractsRepository {
const data = await api.getContract(args.contractAddress);
return ContractSchema.parse(data);
}

async isTrustedForDelegateCall(args: {
chainId: string;
contractAddress: `0x${string}`;
}): Promise<boolean> {
return this.isTrustedForDelegateCallContractsListEnabled
? await this.isIncludedInTrustedForDelegateCallContractsList({
chainId: args.chainId,
contractAddress: args.contractAddress,
})
: await this.isTrustedForDelegateCallContract({
chainId: args.chainId,
contractAddress: args.contractAddress,
});
}

private async getTrustedForDelegateCallContracts(
chainId: string,
): Promise<Page<Contract>> {
const api = await this.transactionApiManager.getApi(chainId);
const contracts = await api.getTrustedForDelegateCallContracts();
return ContractPageSchema.parse(contracts);
}

private async isIncludedInTrustedForDelegateCallContractsList(args: {
chainId: string;
contractAddress: `0x${string}`;
}): Promise<boolean> {
const trustedContracts = await this.getTrustedForDelegateCallContracts(
args.chainId,
);
return trustedContracts.results.some(
(contract) =>
isAddressEqual(contract.address, args.contractAddress) &&
contract.trustedForDelegateCall,
);
}

private async isTrustedForDelegateCallContract(args: {
chainId: string;
contractAddress: `0x${string}`;
}): Promise<boolean> {
const contract = await this.getContract({
chainId: args.chainId,
contractAddress: args.contractAddress,
});
return contract.trustedForDelegateCall;
}
}
3 changes: 3 additions & 0 deletions src/domain/contracts/entities/schemas/contract.schema.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { buildPageSchema } from '@/domain/entities/schemas/page.schema.factory';
import { AddressSchema } from '@/validation/entities/schemas/address.schema';
import { z } from 'zod';

Expand All @@ -9,3 +10,5 @@ export const ContractSchema = z.object({
contractAbi: z.record(z.unknown()).nullish().default(null),
trustedForDelegateCall: z.boolean(),
});

export const ContractPageSchema = buildPageSchema(ContractSchema);
2 changes: 2 additions & 0 deletions src/domain/interfaces/transaction-api.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export interface ITransactionApi {

getContract(contractAddress: `0x${string}`): Promise<Raw<Contract>>;

getTrustedForDelegateCallContracts(): Promise<Raw<Page<Contract>>>;

getDelegates(args: {
safeAddress?: `0x${string}`;
delegate?: `0x${string}`;
Expand Down
11 changes: 6 additions & 5 deletions src/domain/safe/safe.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,11 +648,12 @@ export class SafeRepository implements ISafeRepository {
throw error;
}
try {
const contract = await this.contractsRepository.getContract({
chainId: args.chainId,
contractAddress: args.proposeTransactionDto.to,
});
if (!contract.trustedForDelegateCall) {
const isTrusted =
await this.contractsRepository.isTrustedForDelegateCall({
chainId: args.chainId,
contractAddress: args.proposeTransactionDto.to,
});
if (!isTrusted) {
throw error;
}
} catch {
Expand Down
Loading

0 comments on commit 4b20e69

Please sign in to comment.