Repository: safe-global/safe-client-gateway
Pull request #2427: refactor: centralise delegate call verification in verifier
Author: login: iamacook
Created: 2025-03-06T08:30:26Z | Merged: 2025-03-07T15:20:16Z

## Summary

We refine confirmations and hashes of transaction proposal, confirmation and that from our API. We also block delegate calls by default or based on their trust. This was added as part of verification but it is not within the verifier.

This moves the relevant logic into the verifier, thereby centralising all logic in one place.

## Changes

- Move delegate call verification into verifier
- Add/update test coverage appropriately


Diff:
diff --git a/src/domain/safe/safe.repository.ts b/src/domain/safe/safe.repository.ts
index da8e1bdb4d..d1b25d96b3 100644
--- a/src/domain/safe/safe.repository.ts
+++ b/src/domain/safe/safe.repository.ts
@@ -1,4 +1,4 @@
-import { HttpStatus, Inject, Injectable } from '@nestjs/common';
+import { Inject, Injectable } from '@nestjs/common';
 import isEmpty from 'lodash/isEmpty';
 import { Page } from '@/domain/entities/page.entity';
 import { ITransactionApiManager } from '@/domain/interfaces/transaction-api.manager.interface';
@@ -32,31 +32,17 @@ import { CreationTransactionSchema } from '@/domain/safe/entities/schemas/creati
 import { SafeSchema } from '@/domain/safe/entities/schemas/safe.schema';
 import { z } from 'zod';
 import { TransactionVerifierHelper } from '@/routes/transactions/helpers/transaction-verifier.helper';
-import { IContractsRepository } from '@/domain/contracts/contracts.repository.interface';
-import { IConfigurationService } from '@/config/configuration.service.interface';
-import { Operation } from '@/domain/safe/entities/operation.entity';
-import { HttpExceptionNoLog } from '@/domain/common/errors/http-exception-no-log.error';
 
 @Injectable()
 export class SafeRepository implements ISafeRepository {
-  private readonly isTrustedDelegateCallEnabled: boolean;
-
   constructor(
     @Inject(ITransactionApiManager)
     private readonly transactionApiManager: ITransactionApiManager,
     @Inject(LoggingService) private readonly loggingService: ILoggingService,
     @Inject(IChainsRepository)
     private readonly chainsRepository: IChainsRepository,
-    @Inject(IConfigurationService)
-    private readonly configurationService: IConfigurationService,
-    @Inject(IContractsRepository)
-    private readonly contractsRepository: IContractsRepository,
     private readonly transactionVerifier: TransactionVerifierHelper,
-  ) {
-    this.isTrustedDelegateCallEnabled = this.configurationService.getOrThrow(
-      'features.trustedDelegateCall',
-    );
-  }
+  ) {}
 
   async getSafe(args: {
     chainId: string;
@@ -635,32 +621,6 @@ export class SafeRepository implements ISafeRepository {
     safeAddress: `0x${string}`;
     proposeTransactionDto: ProposeTransactionDto;
   }): Promise<unknown> {
-    const transactionService = await this.transactionApiManager.getApi(
-      args.chainId,
-    );
-
-    const error = new HttpExceptionNoLog(
-      'Delegate call is disabled',
-      HttpStatus.UNPROCESSABLE_ENTITY,
-    );
-    if (args.proposeTransactionDto.operation === Operation.DELEGATE) {
-      if (!this.isTrustedDelegateCallEnabled) {
-        throw error;
-      }
-      try {
-        const isTrusted =
-          await this.contractsRepository.isTrustedForDelegateCall({
-            chainId: args.chainId,
-            contractAddress: args.proposeTransactionDto.to,
-          });
-        if (!isTrusted) {
-          throw error;
-        }
-      } catch {
-        throw error;
-      }
-    }
-
     const safe = await this.getSafe({
       chainId: args.chainId,
       address: args.safeAddress,
@@ -672,6 +632,10 @@ export class SafeRepository implements ISafeRepository {
       proposal: args.proposeTransactionDto,
     });
 
+    const transactionService = await this.transactionApiManager.getApi(
+      args.chainId,
+    );
+
     return transactionService.postMultisigTransaction({
       address: args.safeAddress,
       data: args.proposeTransactionDto,
diff --git a/src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts
index 0bc144e0f2..cceb5ee0dc 100644
--- a/src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts
+++ b/src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts
@@ -545,18 +545,402 @@ describe('Propose transaction - Transactions Controller (Unit)', () => {
       expect(loggingService.error).not.toHaveBeenCalled();
     });
 
-    // TODO: Add once https://github.com/safe-global/safe-client-gateway/pull/2427 is merged
-    it.todo('should allow trusted delegate calls');
+    it('should allow trusted delegate calls', async () => {
+      const baseConfiguration = configuration();
+      const testConfiguration = (): typeof baseConfiguration => ({
+        ...baseConfiguration,
+        features: {
+          ...baseConfiguration.features,
+          ethSign: true,
+          trustedDelegateCall: true,
+          trustedForDelegateCallContractsList: true,
+        },
+      });
+      await initApp(testConfiguration);
+
+      const chainId = faker.string.numeric();
+      const safeAddress = getAddress(faker.finance.ethereumAddress());
+      const chain = chainBuilder().with('chainId', chainId).build();
+      const privateKey = generatePrivateKey();
+      const signer = privateKeyToAccount(privateKey);
+      const safe = safeBuilder()
+        .with('address', safeAddress)
+        .with('owners', [signer.address])
+        .build();
+      const safeApps = [safeAppBuilder().build()];
+      const transaction = await multisigTransactionBuilder()
+        .with('safe', safeAddress)
+        .with('nonce', safe.nonce)
+        .with('operation', Operation.DELEGATE)
+        .buildWithConfirmations({
+          chainId,
+          safe,
+          signers: [signer],
+        });
+      const contractPage = pageBuilder()
+        .with('results', [
+          contractBuilder().with('trustedForDelegateCall', true).build(),
+          contractBuilder()
+            .with('trustedForDelegateCall', true)
+            .with('address', transaction.to) // transaction.to address is a trusted contract
+            .build(),
+          contractBuilder().with('trustedForDelegateCall', true).build(),
+        ])
+        .build();
+      const proposeTransactionDto = proposeTransactionDtoBuilder()
+        .with('to', transaction.to)
+        .with('value', transaction.value)
+        .with('data', transaction.data)
+        .with('nonce', transaction.nonce.toString())
+        .with('operation', transaction.operation)
+        .with('safeTxGas', transaction.safeTxGas!.toString())
+        .with('baseGas', transaction.baseGas!.toString())
+        .with('gasPrice', transaction.gasPrice!)
+        .with('gasToken', transaction.gasToken!)
+        .with('refundReceiver', transaction.refundReceiver)
+        .with('safeTxHash', transaction.safeTxHash)
+        .with('sender', transaction.confirmations![0].owner)
+        .with('signature', transaction.confirmations![0].signature)
+        .build();
+      const transactions = pageBuilder().build();
+      const token = tokenBuilder().build();
+      const gasToken = tokenBuilder().build();
+      networkService.get.mockImplementation(({ url }) => {
+        const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chainId}`;
+        const getMultisigTransactionUrl = `${chain.transactionService}/api/v1/multisig-transactions/${proposeTransactionDto.safeTxHash}/`;
+        const getMultisigTransactionsUrl = `${chain.transactionService}/api/v1/safes/${safe.address}/multisig-transactions/`;
+        const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safeAddress}`;
+        const getSafeAppsUrl = `${safeConfigUrl}/api/v1/safe-apps/`;
+        const getContractsUrl = `${chain.transactionService}/api/v1/contracts/`;
+        const getTokenUrl = `${chain.transactionService}/api/v1/tokens/${transaction.to}`;
+        const getGasTokenContractUrl = `${chain.transactionService}/api/v1/tokens/${transaction.gasToken}`;
+        switch (url) {
+          case getChainUrl:
+            return Promise.resolve({ data: rawify(chain), status: 200 });
+          case getMultisigTransactionUrl:
+            return Promise.resolve({
+              data: rawify(multisigToJson(transaction)),
+              status: 200,
+            });
+          case getMultisigTransactionsUrl:
+            return Promise.resolve({ data: rawify(transactions), status: 200 });
+          case getSafeUrl:
+            return Promise.resolve({ data: rawify(safe), status: 200 });
+          case getSafeAppsUrl:
+            return Promise.resolve({ data: rawify(safeApps), status: 200 });
+          case getContractsUrl:
+            return Promise.resolve({ data: rawify(contractPage), status: 200 });
+          case getTokenUrl:
+            return Promise.resolve({ data: rawify(token), status: 200 });
+          case getGasTokenContractUrl:
+            return Promise.resolve({ data: rawify(gasToken), status: 200 });
+          default:
+            return Promise.reject(new Error(`Could not match ${url}`));
+        }
+      });
+      networkService.post.mockImplementation(({ url }) => {
+        const proposeTransactionUrl = `${chain.transactionService}/api/v1/safes/${safeAddress}/multisig-transactions/`;
+        switch (url) {
+          case proposeTransactionUrl:
+            return Promise.resolve({ data: rawify({}), status: 200 });
+          default:
+            return Promise.reject(new Error(`Could not match ${url}`));
+        }
+      });
+      await request(app.getHttpServer())
+        .post(`/v1/chains/${chainId}/transactions/${safeAddress}/propose`)
+        .send(proposeTransactionDto)
+        .expect(200)
+        .expect(({ body }) =>
+          expect(body).toEqual(
+            expect.objectContaining({
+              txId: `multisig_${safeAddress}_${transaction.safeTxHash}`,
+            }),
+          ),
+        );
+    });
 
-    it.todo('should throw for delegate calls by default');
+    it('should throw for delegate calls by default', async () => {
+      const baseConfiguration = configuration();
+      const testConfiguration = (): typeof baseConfiguration => ({
+        ...baseConfiguration,
+        features: {
+          ...baseConfiguration.features,
+          ethSign: true,
+          trustedDelegateCall: false,
+        },
+      });
+      await initApp(testConfiguration);
 
-    it.todo(
-      'should throw for untrusted delegate calls if only trusted delegate calls are enabled',
-    );
+      const chainId = faker.string.numeric();
+      const safeAddress = getAddress(faker.finance.ethereumAddress());
+      const chain = chainBuilder().with('chainId', chainId).build();
+      const privateKey = generatePrivateKey();
+      const signer = privateKeyToAccount(privateKey);
+      const safe = safeBuilder()
+        .with('address', safeAddress)
+        .with('owners', [signer.address])
+        .build();
+      const transaction = await multisigTransactionBuilder()
+        .with('safe', safeAddress)
+        .with('nonce', safe.nonce)
+        .with('operation', Operation.DELEGATE)
+        .buildWithConfirmations({
+          chainId,
+          safe,
+          signers: [signer],
+        });
+      const proposeTransactionDto = proposeTransactionDtoBuilder()
+        .with('to', transaction.to)
+        .with('value', transaction.value)
+        .with('data', transaction.data)
+        .with('nonce', transaction.nonce.toString())
+        .with('operation', transaction.operation)
+        .with('safeTxGas', transaction.safeTxGas!.toString())
+        .with('baseGas', transaction.baseGas!.toString())
+        .with('gasPrice', transaction.gasPrice!)
+        .with('gasToken', transaction.gasToken!)
+        .with('refundReceiver', transaction.refundReceiver)
+        .with('safeTxHash', transaction.safeTxHash)
+        .with('sender', transaction.confirmations![0].owner)
+        .with('signature', transaction.confirmations![0].signature)
+        .build();
+      networkService.get.mockImplementation(({ url }) => {
+        const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chainId}`;
+        const getMultisigTransactionUrl = `${chain.transactionService}/api/v1/multisig-transactions/${proposeTransactionDto.safeTxHash}/`;
+        const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safeAddress}`;
+        switch (url) {
+          case getChainUrl:
+            return Promise.resolve({ data: rawify(chain), status: 200 });
+          case getMultisigTransactionUrl:
+            return Promise.resolve({
+              data: rawify(multisigToJson(transaction)),
+              status: 200,
+            });
+          case getSafeUrl:
+            return Promise.resolve({ data: rawify(safe), status: 200 });
+          default:
+            return Promise.reject(new Error(`Could not match ${url}`));
+        }
+      });
+      await request(app.getHttpServer())
+        .post(`/v1/chains/${chainId}/transactions/${safeAddress}/propose`)
+        .send(proposeTransactionDto)
+        .expect(422)
+        .expect({
+          message: 'Delegate call is disabled',
+          statusCode: 422,
+        });
+
+      expect(loggingService.error).not.toHaveBeenCalled();
+    });
+
+    it('should throw for untrusted delegate calls if only trusted delegate calls are enabled', async () => {
+      const baseConfiguration = configuration();
+      const testConfiguration = (): typeof baseConfiguration => ({
+        ...baseConfiguration,
+        features: {
+          ...baseConfiguration.features,
+          ethSign: true,
+          trustedDelegateCall: true,
+          trustedForDelegateCallContractsList: true,
+        },
+      });
+      await initApp(testConfiguration);
+
+      const chainId = faker.string.numeric();
+      const safeAddress = getAddress(faker.finance.ethereumAddress());
+      const chain = chainBuilder().with('chainId', chainId).build();
+      const privateKey = generatePrivateKey();
+      const signer = privateKeyToAccount(privateKey);
+      const safe = safeBuilder()
+        .with('address', safeAddress)
+        .with('owners', [signer.address])
+        .build();
+      const safeApps = [safeAppBuilder().build()];
+      const transaction = await multisigTransactionBuilder()
+        .with('safe', safeAddress)
+        .with('nonce', safe.nonce)
+        .with('operation', Operation.DELEGATE)
+        .buildWithConfirmations({
+          chainId,
+          safe,
+          signers: [signer],
+        });
+      const contractPage = pageBuilder()
+        .with('results', [
+          // transaction.to address is not in the list of trusted contracts
+          contractBuilder().with('trustedForDelegateCall', true).build(),
+          contractBuilder().with('trustedForDelegateCall', true).build(),
+        ])
+        .build();
+      const proposeTransactionDto = proposeTransactionDtoBuilder()
+        .with('to', transaction.to)
+        .with('value', transaction.value)
+        .with('data', transaction.data)
+        .with('nonce', transaction.nonce.toString())
+        .with('operation', transaction.operation)
+        .with('safeTxGas', transaction.safeTxGas!.toString())
+        .with('baseGas', transaction.baseGas!.toString())
+        .with('gasPrice', transaction.gasPrice!)
+        .with('gasToken', transaction.gasToken!)
+        .with('refundReceiver', transaction.refundReceiver)
+        .with('safeTxHash', transaction.safeTxHash)
+        .with('sender', transaction.confirmations![0].owner)
+        .with('signature', transaction.confirmations![0].signature)
+        .build();
+      const transactions = pageBuilder().build();
+      const token = tokenBuilder().build();
+      const gasToken = tokenBuilder().build();
+      networkService.get.mockImplementation(({ url }) => {
+        const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chainId}`;
+        const getMultisigTransactionUrl = `${chain.transactionService}/api/v1/multisig-transactions/${proposeTransactionDto.safeTxHash}/`;
+        const getMultisigTransactionsUrl = `${chain.transactionService}/api/v1/safes/${safe.address}/multisig-transactions/`;
+        const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safeAddress}`;
+        const getSafeAppsUrl = `${safeConfigUrl}/api/v1/safe-apps/`;
+        const getContractsUrl = `${chain.transactionService}/api/v1/contracts/`;
+        const getTokenUrl = `${chain.transactionService}/api/v1/tokens/${transaction.to}`;
+        const getGasTokenContractUrl = `${chain.transactionService}/api/v1/tokens/${transaction.gasToken}`;
+        switch (url) {
+          case getChainUrl:
+            return Promise.resolve({ data: rawify(chain), status: 200 });
+          case getMultisigTransactionUrl:
+            return Promise.resolve({
+              data: rawify(multisigToJson(transaction)),
+              status: 200,
+            });
+          case getMultisigTransactionsUrl:
+            return Promise.resolve({
+              data: rawify(transactions),
+              status: 200,
+            });
+          case getSafeUrl:
+            return Promise.resolve({ data: rawify(safe), status: 200 });
+          case getSafeAppsUrl:
+            return Promise.resolve({ data: rawify(safeApps), status: 200 });
+          case getContractsUrl:
+            return Promise.resolve({
+              data: rawify(contractPage),
+              status: 200,
+            });
+          case getTokenUrl:
+            return Promise.resolve({ data: rawify(token), status: 200 });
+          case getGasTokenContractUrl:
+            return Promise.resolve({ data: rawify(gasToken), status: 200 });
+          default:
+            return Promise.reject(new Error(`Could not match ${url}`));
+        }
+      });
+      networkService.post.mockImplementation(({ url }) => {
+        const proposeTransactionUrl = `${chain.transactionService}/api/v1/safes/${safeAddress}/multisig-transactions/`;
+        switch (url) {
+          case proposeTransactionUrl:
+            return Promise.resolve({ data: rawify({}), status: 200 });
+          default:
+            return Promise.reject(new Error(`Could not match ${url}`));
+        }
+      });
+      await request(app.getHttpServer())
+        .post(`/v1/chains/${chainId}/transactions/${safeAddress}/propose`)
+        .send(proposeTransactionDto)
+        .expect(422)
+        .expect({
+          message: 'Delegate call is disabled',
+          statusCode: 422,
+        });
+
+      expect(loggingService.error).not.toHaveBeenCalled();
+    });
+
+    it('should throw for delegate calls when the contract cannot be found and only trusted delegate calls are enabled', async () => {
+      const baseConfiguration = configuration();
+      const testConfiguration = (): typeof baseConfiguration => ({
+        ...baseConfiguration,
+        features: {
+          ...baseConfiguration.features,
+          ethSign: true,
+          trustedDelegateCall: true,
+          trustedForDelegateCallContractsList: true,
+        },
+      });
+      await initApp(testConfiguration);
+
+      const chainId = faker.string.numeric();
+      const safeAddress = getAddress(faker.finance.ethereumAddress());
+      const chain = chainBuilder().with('chainId', chainId).build();
+      const privateKey = generatePrivateKey();
+      const signer = privateKeyToAccount(privateKey);
+      const safe = safeBuilder()
+        .with('address', safeAddress)
+        .with('owners', [signer.address])
+        .build();
+      const safeApps = [safeAppBuilder().build()];
+      const transaction = await multisigTransactionBuilder()
+        .with('safe', safeAddress)
+        .with('nonce', safe.nonce)
+        .with('operation', Operation.DELEGATE)
+        .buildWithConfirmations({
+          chainId,
+          safe,
+          signers: [signer],
+        });
+      const proposeTransactionDto = proposeTransactionDtoBuilder()
+        .with('to', transaction.to)
+        .with('value', transaction.value)
+        .with('data', transaction.data)
+        .with('nonce', transaction.nonce.toString())
+        .with('operation', transaction.operation)
+        .with('safeTxGas', transaction.safeTxGas!.toString())
+        .with('baseGas', transaction.baseGas!.toString())
+        .with('gasPrice', transaction.gasPrice!)
+        .with('gasToken', transaction.gasToken!)
+        .with('refundReceiver', transaction.refundReceiver)
+        .with('safeTxHash', transaction.safeTxHash)
+        .with('sender', transaction.confirmations![0].owner)
+        .with('signature', transaction.confirmations![0].signature)
+        .build();
+      const transactions = pageBuilder().build();
+      networkService.get.mockImplementation(({ url }) => {
+        const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chainId}`;
+        const getMultisigTransactionUrl = `${chain.transactionService}/api/v1/multisig-transactions/${proposeTransactionDto.safeTxHash}/`;
+        const getMultisigTransactionsUrl = `${chain.transactionService}/api/v1/safes/${safe.address}/multisig-transactions/`;
+        const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safeAddress}`;
+        const getSafeAppsUrl = `${safeConfigUrl}/api/v1/safe-apps/`;
+        const getContractsUrl = `${chain.transactionService}/api/v1/contracts/`;
+        switch (url) {
+          case getChainUrl:
+            return Promise.resolve({ data: rawify(chain), status: 200 });
+          case getMultisigTransactionUrl:
+            return Promise.resolve({
+              data: rawify(multisigToJson(transaction)),
+              status: 200,
+            });
+          case getMultisigTransactionsUrl:
+            return Promise.resolve({
+              data: rawify(transactions),
+              status: 200,
+            });
+          case getSafeUrl:
+            return Promise.resolve({ data: rawify(safe), status: 200 });
+          case getSafeAppsUrl:
+            return Promise.resolve({ data: rawify(safeApps), status: 200 });
+          case getContractsUrl:
+            return Promise.reject(new Error('Contracts not found'));
+          default:
+            return Promise.reject(new Error(`Could not match ${url}`));
+        }
+      });
+      await request(app.getHttpServer())
+        .post(`/v1/chains/${chainId}/transactions/${safeAddress}/propose`)
+        .send(proposeTransactionDto)
+        .expect(422)
+        .expect({
+          message: 'Delegate call is disabled',
+          statusCode: 422,
+        });
 
-    it.todo(
-      'should throw for delegate calls when the contract cannot be found and only trusted delegate calls are enabled',
-    );
+      expect(loggingService.error).not.toHaveBeenCalled();
+    });
 
     it('should throw and log if the safeTxHash could not be calculated', async () => {
       const chain = chainBuilder().build();
@@ -1168,230 +1552,5 @@ describe('Propose transaction - Transactions Controller (Unit)', () => {
         source: 'PROPOSAL',
       });
     });
-
-    // TODO: Move to TODOs further up
-    it('should allow delegate calls if the contract is included in the list of trusted contracts', async () => {
-      const baseConfiguration = configuration();
-      const testConfiguration = (): typeof baseConfiguration => ({
-        ...baseConfiguration,
-        features: {
-          ...baseConfiguration.features,
-          ethSign: true,
-          trustedDelegateCall: true,
-          trustedForDelegateCallContractsList: true,
-        },
-      });
-      await initApp(testConfiguration);
-
-      const chainId = faker.string.numeric();
-      const safeAddress = getAddress(faker.finance.ethereumAddress());
-      const chain = chainBuilder().with('chainId', chainId).build();
-      const privateKey = generatePrivateKey();
-      const signer = privateKeyToAccount(privateKey);
-      const safe = safeBuilder()
-        .with('address', safeAddress)
-        .with('owners', [signer.address])
-        .build();
-      const safeApps = [safeAppBuilder().build()];
-      const transaction = await multisigTransactionBuilder()
-        .with('safe', safeAddress)
-        .with('nonce', safe.nonce)
-        .with('operation', Operation.DELEGATE)
-        .buildWithConfirmations({
-          chainId,
-          safe,
-          signers: [signer],
-        });
-      const contractPage = pageBuilder()
-        .with('results', [
-          contractBuilder().with('trustedForDelegateCall', true).build(),
-          contractBuilder()
-            .with('trustedForDelegateCall', true)
-            .with('address', transaction.to) // transaction.to address is a trusted contract
-            .build(),
-          contractBuilder().with('trustedForDelegateCall', true).build(),
-        ])
-        .build();
-      const proposeTransactionDto = proposeTransactionDtoBuilder()
-        .with('to', transaction.to)
-        .with('value', transaction.value)
-        .with('data', transaction.data)
-        .with('nonce', transaction.nonce.toString())
-        .with('operation', transaction.operation)
-        .with('safeTxGas', transaction.safeTxGas!.toString())
-        .with('baseGas', transaction.baseGas!.toString())
-        .with('gasPrice', transaction.gasPrice!)
-        .with('gasToken', transaction.gasToken!)
-        .with('refundReceiver', transaction.refundReceiver)
-        .with('safeTxHash', transaction.safeTxHash)
-        .with('sender', transaction.confirmations![0].owner)
-        .with('signature', transaction.confirmations![0].signature)
-        .build();
-      const transactions = pageBuilder().build();
-      const token = tokenBuilder().build();
-      const gasToken = tokenBuilder().build();
-      networkService.get.mockImplementation(({ url }) => {
-        const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chainId}`;
-        const getMultisigTransactionUrl = `${chain.transactionService}/api/v1/multisig-transactions/${proposeTransactionDto.safeTxHash}/`;
-        const getMultisigTransactionsUrl = `${chain.transactionService}/api/v1/safes/${safe.address}/multisig-transactions/`;
-        const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safeAddress}`;
-        const getSafeAppsUrl = `${safeConfigUrl}/api/v1/safe-apps/`;
-        const getContractsUrl = `${chain.transactionService}/api/v1/contracts/`;
-        const getTokenUrl = `${chain.transactionService}/api/v1/tokens/${transaction.to}`;
-        const getGasTokenContractUrl = `${chain.transactionService}/api/v1/tokens/${transaction.gasToken}`;
-        switch (url) {
-          case getChainUrl:
-            return Promise.resolve({ data: rawify(chain), status: 200 });
-          case getMultisigTransactionUrl:
-            return Promise.resolve({
-              data: rawify(multisigToJson(transaction)),
-              status: 200,
-            });
-          case getMultisigTransactionsUrl:
-            return Promise.resolve({ data: rawify(transactions), status: 200 });
-          case getSafeUrl:
-            return Promise.resolve({ data: rawify(safe), status: 200 });
-          case getSafeAppsUrl:
-            return Promise.resolve({ data: rawify(safeApps), status: 200 });
-          case getContractsUrl:
-            return Promise.resolve({ data: rawify(contractPage), status: 200 });
-          case getTokenUrl:
-            return Promise.resolve({ data: rawify(token), status: 200 });
-          case getGasTokenContractUrl:
-            return Promise.resolve({ data: rawify(gasToken), status: 200 });
-          default:
-            return Promise.reject(new Error(`Could not match ${url}`));
-        }
-      });
-      networkService.post.mockImplementation(({ url }) => {
-        const proposeTransactionUrl = `${chain.transactionService}/api/v1/safes/${safeAddress}/multisig-transactions/`;
-        switch (url) {
-          case proposeTransactionUrl:
-            return Promise.resolve({ data: rawify({}), status: 200 });
-          default:
-            return Promise.reject(new Error(`Could not match ${url}`));
-        }
-      });
-      await request(app.getHttpServer())
-        .post(`/v1/chains/${chainId}/transactions/${safeAddress}/propose`)
-        .send(proposeTransactionDto)
-        .expect(200)
-        .expect(({ body }) =>
-          expect(body).toEqual(
-            expect.objectContaining({
-              txId: `multisig_${safeAddress}_${transaction.safeTxHash}`,
-            }),
-          ),
-        );
-    });
-
-    it('should disallow delegate calls if the contract is not included in the list of trusted contracts', async () => {
-      const baseConfiguration = configuration();
-      const testConfiguration = (): typeof baseConfiguration => ({
-        ...baseConfiguration,
-        features: {
-          ...baseConfiguration.features,
-          ethSign: true,
-          trustedDelegateCall: true,
-          trustedForDelegateCallContractsList: true,
-        },
-      });
-      await initApp(testConfiguration);
-
-      const chainId = faker.string.numeric();
-      const safeAddress = getAddress(faker.finance.ethereumAddress());
-      const chain = chainBuilder().with('chainId', chainId).build();
-      const privateKey = generatePrivateKey();
-      const signer = privateKeyToAccount(privateKey);
-      const safe = safeBuilder()
-        .with('address', safeAddress)
-        .with('owners', [signer.address])
-        .build();
-      const safeApps = [safeAppBuilder().build()];
-      const transaction = await multisigTransactionBuilder()
-        .with('safe', safeAddress)
-        .with('nonce', safe.nonce)
-        .with('operation', Operation.DELEGATE)
-        .buildWithConfirmations({
-          chainId,
-          safe,
-          signers: [signer],
-        });
-      const contractPage = pageBuilder()
-        .with('results', [
-          // transaction.to address is not in the list of trusted contracts
-          contractBuilder().with('trustedForDelegateCall', true).build(),
-          contractBuilder().with('trustedForDelegateCall', true).build(),
-        ])
-        .build();
-      const proposeTransactionDto = proposeTransactionDtoBuilder()
-        .with('to', transaction.to)
-        .with('value', transaction.value)
-        .with('data', transaction.data)
-        .with('nonce', transaction.nonce.toString())
-        .with('operation', transaction.operation)
-        .with('safeTxGas', transaction.safeTxGas!.toString())
-        .with('baseGas', transaction.baseGas!.toString())
-        .with('gasPrice', transaction.gasPrice!)
-        .with('gasToken', transaction.gasToken!)
-        .with('refundReceiver', transaction.refundReceiver)
-        .with('safeTxHash', transaction.safeTxHash)
-        .with('sender', transaction.confirmations![0].owner)
-        .with('signature', transaction.confirmations![0].signature)
-        .build();
-      const transactions = pageBuilder().build();
-      const token = tokenBuilder().build();
-      const gasToken = tokenBuilder().build();
-      networkService.get.mockImplementation(({ url }) => {
-        const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chainId}`;
-        const getMultisigTransactionUrl = `${chain.transactionService}/api/v1/multisig-transactions/${proposeTransactionDto.safeTxHash}/`;
-        const getMultisigTransactionsUrl = `${chain.transactionService}/api/v1/safes/${safe.address}/multisig-transactions/`;
-        const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safeAddress}`;
-        const getSafeAppsUrl = `${safeConfigUrl}/api/v1/safe-apps/`;
-        const getContractsUrl = `${chain.transactionService}/api/v1/contracts/`;
-        const getTokenUrl = `${chain.transactionService}/api/v1/tokens/${transaction.to}`;
-        const getGasTokenContractUrl = `${chain.transactionService}/api/v1/tokens/${transaction.gasToken}`;
-        switch (url) {
-          case getChainUrl:
-            return Promise.resolve({ data: rawify(chain), status: 200 });
-          case getMultisigTransactionUrl:
-            return Promise.resolve({
-              data: rawify(multisigToJson(transaction)),
-              status: 200,
-            });
-          case getMultisigTransactionsUrl:
-            return Promise.resolve({ data: rawify(transactions), status: 200 });
-          case getSafeUrl:
-            return Promise.resolve({ data: rawify(safe), status: 200 });
-          case getSafeAppsUrl:
-            return Promise.resolve({ data: rawify(safeApps), status: 200 });
-          case getContractsUrl:
-            return Promise.resolve({ data: rawify(contractPage), status: 200 });
-          case getTokenUrl:
-            return Promise.resolve({ data: rawify(token), status: 200 });
-          case getGasTokenContractUrl:
-            return Promise.resolve({ data: rawify(gasToken), status: 200 });
-          default:
-            return Promise.reject(new Error(`Could not match ${url}`));
-        }
-      });
-      networkService.post.mockImplementation(({ url }) => {
-        const proposeTransactionUrl = `${chain.transactionService}/api/v1/safes/${safeAddress}/multisig-transactions/`;
-        switch (url) {
-          case proposeTransactionUrl:
-            return Promise.resolve({ data: rawify({}), status: 200 });
-          default:
-            return Promise.reject(new Error(`Could not match ${url}`));
-        }
-      });
-      await request(app.getHttpServer())
-        .post(`/v1/chains/${chainId}/transactions/${safeAddress}/propose`)
-        .send(proposeTransactionDto)
-        .expect(422)
-        .expect({
-          message: 'Delegate call is disabled',
-          statusCode: 422,
-        });
-    });
   });
 });
diff --git a/src/routes/transactions/helpers/transaction-verifier.helper.spec.ts b/src/routes/transactions/helpers/transaction-verifier.helper.spec.ts
index 384d3a198c..faab356b54 100644
--- a/src/routes/transactions/helpers/transaction-verifier.helper.spec.ts
+++ b/src/routes/transactions/helpers/transaction-verifier.helper.spec.ts
@@ -17,6 +17,7 @@ import type { IConfigurationService } from '@/config/configuration.service.inter
 import type { DelegatesV2Repository } from '@/domain/delegate/v2/delegates.v2.repository';
 import type { ILoggingService } from '@/logging/logging.interface';
 import type { Delegate } from '@/domain/delegate/entities/delegate.entity';
+import type { IContractsRepository } from '@/domain/contracts/contracts.repository.interface';
 
 const mockConfigurationService = jest.mocked({
   getOrThrow: jest.fn(),
@@ -30,6 +31,10 @@ const mockLoggingRepository = jest.mocked({
   error: jest.fn(),
 } as jest.MockedObjectDeep<ILoggingService>);
 
+const mockContractsRepository = jest.mocked({
+  isTrustedForDelegateCall: jest.fn(),
+} as jest.MockedObjectDeep<IContractsRepository>);
+
 describe('TransactionVerifierHelper', () => {
   let target: TransactionVerifierHelper;
 
@@ -42,6 +47,7 @@ describe('TransactionVerifierHelper', () => {
       mockConfigurationService,
       mockDelegatesRepository,
       mockLoggingRepository,
+      mockContractsRepository,
     );
   }
 
@@ -775,6 +781,7 @@ describe('TransactionVerifierHelper', () => {
       const transaction = await multisigTransactionBuilder()
         .with('safe', safe.address)
         .with('nonce', safe.nonce)
+        .with('operation', Operation.CALL)
         .buildWithConfirmations({
           chainId,
           signers: faker.helpers.arrayElements(signers, {
@@ -866,18 +873,261 @@ describe('TransactionVerifierHelper', () => {
       expect(mockLoggingRepository.error).not.toHaveBeenCalled();
     });
 
-    // TODO: Add once https://github.com/safe-global/safe-client-gateway/pull/2427 is merged
-    it.todo('should allow trusted delegate calls');
+    it('should allow trusted delegate calls', async () => {
+      const defaultConfiguration = configuration();
+      const testConfiguration = (): ReturnType<typeof configuration> => {
+        return {
+          ...defaultConfiguration,
+          features: {
+            ...defaultConfiguration.features,
+            trustedDelegateCall: true,
+          },
+        };
+      };
+      initTarget(testConfiguration);
+      const chainId = faker.string.numeric();
+      const signers = Array.from(
+        { length: faker.number.int({ min: 1, max: 5 }) },
+        () => {
+          const privateKey = generatePrivateKey();
+          return privateKeyToAccount(privateKey);
+        },
+      );
+      const safe = safeBuilder()
+        .with(
+          'owners',
+          signers.map((s) => s.address),
+        )
+        .build();
+      const transaction = await multisigTransactionBuilder()
+        .with('safe', safe.address)
+        .with('nonce', safe.nonce)
+        .with('operation', Operation.DELEGATE)
+        .buildWithConfirmations({
+          chainId,
+          signers: faker.helpers.arrayElements(signers, {
+            min: 1,
+            max: signers.length,
+          }),
+          safe,
+        });
+      const proposal = proposeTransactionDtoBuilder()
+        .with('to', transaction.to)
+        .with('value', transaction.value)
+        .with('data', transaction.data)
+        .with('nonce', transaction.nonce.toString())
+        .with('operation', transaction.operation)
+        .with('safeTxGas', transaction.safeTxGas!.toString())
+        .with('baseGas', transaction.baseGas!.toString())
+        .with('gasPrice', transaction.gasPrice!)
+        .with('gasToken', transaction.gasToken!)
+        .with('refundReceiver', transaction.refundReceiver)
+        .with('safeTxHash', transaction.safeTxHash)
+        .with('sender', transaction.confirmations![0].owner)
+        .with('signature', transaction.confirmations![0].signature)
+        .build();
+      mockContractsRepository.isTrustedForDelegateCall.mockResolvedValue(true);
 
-    it.todo('should throw for delegate calls by default');
+      await expect(
+        target.verifyProposal({ chainId, safe, proposal }),
+      ).resolves.not.toThrow();
 
-    it.todo(
-      'should throw for untrusted delegate calls if only trusted delegate calls are enabled',
-    );
+      expect(mockLoggingRepository.error).not.toHaveBeenCalled();
+    });
 
-    it.todo(
-      'should throw for delegate calls when the contract cannot be found and only trusted delegate calls are enabled',
-    );
+    it('should throw for delegate calls by default', async () => {
+      const defaultConfiguration = configuration();
+      const testConfiguration = (): ReturnType<typeof configuration> => {
+        return {
+          ...defaultConfiguration,
+          features: {
+            ...defaultConfiguration.features,
+            trustedDelegateCall: false,
+          },
+        };
+      };
+      initTarget(testConfiguration);
+      const chainId = faker.string.numeric();
+      const signers = Array.from(
+        { length: faker.number.int({ min: 1, max: 5 }) },
+        () => {
+          const privateKey = generatePrivateKey();
+          return privateKeyToAccount(privateKey);
+        },
+      );
+      const safe = safeBuilder()
+        .with(
+          'owners',
+          signers.map((s) => s.address),
+        )
+        .build();
+      const transaction = await multisigTransactionBuilder()
+        .with('safe', safe.address)
+        .with('nonce', safe.nonce)
+        .with('operation', Operation.DELEGATE)
+        .buildWithConfirmations({
+          chainId,
+          signers: faker.helpers.arrayElements(signers, {
+            min: 1,
+            max: signers.length,
+          }),
+          safe,
+        });
+      const proposal = proposeTransactionDtoBuilder()
+        .with('to', transaction.to)
+        .with('value', transaction.value)
+        .with('data', transaction.data)
+        .with('nonce', transaction.nonce.toString())
+        .with('operation', transaction.operation)
+        .with('safeTxGas', transaction.safeTxGas!.toString())
+        .with('baseGas', transaction.baseGas!.toString())
+        .with('gasPrice', transaction.gasPrice!)
+        .with('gasToken', transaction.gasToken!)
+        .with('refundReceiver', transaction.refundReceiver)
+        .with('safeTxHash', transaction.safeTxHash)
+        .with('sender', transaction.confirmations![0].owner)
+        .with('signature', transaction.confirmations![0].signature)
+        .build();
+      mockContractsRepository.isTrustedForDelegateCall.mockResolvedValue(true);
+
+      await expect(
+        target.verifyProposal({ chainId, safe, proposal }),
+      ).rejects.toThrow(
+        new HttpExceptionNoLog('Delegate call is disabled', 422),
+      );
+
+      expect(mockLoggingRepository.error).not.toHaveBeenCalled();
+    });
+
+    it('should throw for untrusted delegate calls if only trusted delegate calls are enabled', async () => {
+      const defaultConfiguration = configuration();
+      const testConfiguration = (): ReturnType<typeof configuration> => {
+        return {
+          ...defaultConfiguration,
+          features: {
+            ...defaultConfiguration.features,
+            trustedDelegateCall: true,
+          },
+        };
+      };
+      initTarget(testConfiguration);
+      const chainId = faker.string.numeric();
+      const signers = Array.from(
+        { length: faker.number.int({ min: 1, max: 5 }) },
+        () => {
+          const privateKey = generatePrivateKey();
+          return privateKeyToAccount(privateKey);
+        },
+      );
+      const safe = safeBuilder()
+        .with(
+          'owners',
+          signers.map((s) => s.address),
+        )
+        .build();
+      const transaction = await multisigTransactionBuilder()
+        .with('safe', safe.address)
+        .with('nonce', safe.nonce)
+        .with('operation', Operation.DELEGATE)
+        .buildWithConfirmations({
+          chainId,
+          signers: faker.helpers.arrayElements(signers, {
+            min: 1,
+            max: signers.length,
+          }),
+          safe,
+        });
+      const proposal = proposeTransactionDtoBuilder()
+        .with('to', transaction.to)
+        .with('value', transaction.value)
+        .with('data', transaction.data)
+        .with('nonce', transaction.nonce.toString())
+        .with('operation', transaction.operation)
+        .with('safeTxGas', transaction.safeTxGas!.toString())
+        .with('baseGas', transaction.baseGas!.toString())
+        .with('gasPrice', transaction.gasPrice!)
+        .with('gasToken', transaction.gasToken!)
+        .with('refundReceiver', transaction.refundReceiver)
+        .with('safeTxHash', transaction.safeTxHash)
+        .with('sender', transaction.confirmations![0].owner)
+        .with('signature', transaction.confirmations![0].signature)
+        .build();
+      mockContractsRepository.isTrustedForDelegateCall.mockResolvedValue(false);
+
+      await expect(
+        target.verifyProposal({ chainId, safe, proposal }),
+      ).rejects.toThrow(
+        new HttpExceptionNoLog('Delegate call is disabled', 422),
+      );
+
+      expect(mockLoggingRepository.error).not.toHaveBeenCalled();
+    });
+
+    it('should throw for delegate calls when the contract cannot be found and only trusted delegate calls are enabled', async () => {
+      const defaultConfiguration = configuration();
+      const testConfiguration = (): ReturnType<typeof configuration> => {
+        return {
+          ...defaultConfiguration,
+          features: {
+            ...defaultConfiguration.features,
+            trustedDelegateCall: true,
+          },
+        };
+      };
+      initTarget(testConfiguration);
+      const chainId = faker.string.numeric();
+      const signers = Array.from(
+        { length: faker.number.int({ min: 1, max: 5 }) },
+        () => {
+          const privateKey = generatePrivateKey();
+          return privateKeyToAccount(privateKey);
+        },
+      );
+      const safe = safeBuilder()
+        .with(
+          'owners',
+          signers.map((s) => s.address),
+        )
+        .build();
+      const transaction = await multisigTransactionBuilder()
+        .with('safe', safe.address)
+        .with('nonce', safe.nonce)
+        .with('operation', Operation.DELEGATE)
+        .buildWithConfirmations({
+          chainId,
+          signers: faker.helpers.arrayElements(signers, {
+            min: 1,
+            max: signers.length,
+          }),
+          safe,
+        });
+      const proposal = proposeTransactionDtoBuilder()
+        .with('to', transaction.to)
+        .with('value', transaction.value)
+        .with('data', transaction.data)
+        .with('nonce', transaction.nonce.toString())
+        .with('operation', transaction.operation)
+        .with('safeTxGas', transaction.safeTxGas!.toString())
+        .with('baseGas', transaction.baseGas!.toString())
+        .with('gasPrice', transaction.gasPrice!)
+        .with('gasToken', transaction.gasToken!)
+        .with('refundReceiver', transaction.refundReceiver)
+        .with('safeTxHash', transaction.safeTxHash)
+        .with('sender', transaction.confirmations![0].owner)
+        .with('signature', transaction.confirmations![0].signature)
+        .build();
+      mockContractsRepository.isTrustedForDelegateCall.mockRejectedValue(
+        new Error('Contract not found'),
+      );
+
+      await expect(
+        target.verifyProposal({ chainId, safe, proposal }),
+      ).rejects.toThrow(
+        new HttpExceptionNoLog('Delegate call is disabled', 422),
+      );
+
+      expect(mockLoggingRepository.error).not.toHaveBeenCalled();
+    });
 
     it('should throw and log if the safeTxHash could not be calculated', async () => {
       const chainId = faker.string.numeric();
diff --git a/src/routes/transactions/helpers/transaction-verifier.helper.ts b/src/routes/transactions/helpers/transaction-verifier.helper.ts
index 03c0d64aaa..872fd49f45 100644
--- a/src/routes/transactions/helpers/transaction-verifier.helper.ts
+++ b/src/routes/transactions/helpers/transaction-verifier.helper.ts
@@ -16,6 +16,8 @@ import { SafeSignature } from '@/domain/common/entities/safe-signature';
 import { SignatureType } from '@/domain/common/entities/signature-type.entity';
 import { LogSource } from '@/domain/common/entities/log-source.entity';
 import { isAddressEqual } from 'viem';
+import { IContractsRepository } from '@/domain/contracts/contracts.repository.interface';
+import { Operation } from '@/domain/safe/entities/operation.entity';
 
 enum ErrorMessage {
   MalformedHash = 'Could not calculate safeTxHash',
@@ -25,11 +27,13 @@ enum ErrorMessage {
   InvalidSignature = 'Invalid signature',
   BlockedAddress = 'Unauthorized address',
   EthSignDisabled = 'eth_sign is disabled',
+  DelegateCallDisabled = 'Delegate call is disabled',
   InvalidNonce = 'Invalid nonce',
 }
 
 @Injectable()
 export class TransactionVerifierHelper {
+  private readonly isTrustedDelegateCallEnabled: boolean;
   private readonly isEthSignEnabled: boolean;
   private readonly isApiHashVerificationEnabled: boolean;
   private readonly isApiSignatureVerificationEnabled: boolean;
@@ -44,7 +48,12 @@ export class TransactionVerifierHelper {
     private readonly delegatesV2Repository: IDelegatesV2Repository,
     @Inject(LoggingService)
     private readonly loggingService: ILoggingService,
+    @Inject(IContractsRepository)
+    private readonly contractsRepository: IContractsRepository,
   ) {
+    this.isTrustedDelegateCallEnabled = this.configurationService.getOrThrow(
+      'features.trustedDelegateCall',
+    );
     this.isEthSignEnabled =
       this.configurationService.getOrThrow('features.ethSign');
     this.isApiHashVerificationEnabled = this.configurationService.getOrThrow(
@@ -98,6 +107,9 @@ export class TransactionVerifierHelper {
     if (Number(args.proposal.nonce) < args.safe.nonce) {
       throw new HttpExceptionNoLog(ErrorMessage.InvalidNonce, code);
     }
+
+    await this.verifyProposalDelegateCall({ ...args, code });
+
     if (this.isProposalHashVerificationEnabled) {
       this.verifyProposalSafeTxHash({ ...args, code });
     }
@@ -131,6 +143,35 @@ export class TransactionVerifierHelper {
     }
   }
 
+  private async verifyProposalDelegateCall(args: {
+    chainId: string;
+    proposal: ProposeTransactionDto;
+    code: HttpStatus;
+  }): Promise<void> {
+    if (args.proposal.operation !== Operation.DELEGATE) {
+      return;
+    }
+
+    const error = new HttpExceptionNoLog(
+      ErrorMessage.DelegateCallDisabled,
+      args.code,
+    );
+    if (!this.isTrustedDelegateCallEnabled) {
+      throw error;
+    }
+
+    const isTrustedDelegateCallEnabled = await this.contractsRepository
+      .isTrustedForDelegateCall({
+        chainId: args.chainId,
+        contractAddress: args.proposal.to,
+      })
+      .catch(() => false);
+
+    if (!isTrustedDelegateCallEnabled) {
+      throw error;
+    }
+  }
+
   private verifyApiSafeTxHash(args: {
     chainId: string;
     safe: Safe;
diff --git a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-details.mapper.spec.ts b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-details.mapper.spec.ts
index b1b6ec1d55..1057a12d0f 100644
--- a/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-details.mapper.spec.ts
+++ b/src/routes/transactions/mappers/multisig-transactions/multisig-transaction-details.mapper.spec.ts
@@ -18,6 +18,8 @@ import { TransactionVerifierHelper } from '@/routes/transactions/helpers/transac
 import type { DelegatesV2Repository } from '@/domain/delegate/v2/delegates.v2.repository';
 import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
 import type { ILoggingService } from '@/logging/logging.interface';
+import type { IContractsRepository } from '@/domain/contracts/contracts.repository.interface';
+import { Operation } from '@/domain/safe/entities/operation.entity';
 
 const addressInfoHelper = jest.mocked({
   getOrDefault: jest.fn(),
@@ -60,6 +62,10 @@ const mockLoggingService = {
   error: jest.fn(),
 } as jest.MockedObjectDeep<ILoggingService>;
 
+const mockContractsRepository = jest.mocked({
+  isTrustedForDelegateCall: jest.fn(),
+} as jest.MockedObjectDeep<IContractsRepository>);
+
 describe('MultisigTransactionDetails mapper (Unit)', () => {
   let mapper: MultisigTransactionDetailsMapper;
 
@@ -89,6 +95,7 @@ describe('MultisigTransactionDetails mapper (Unit)', () => {
         mockConfigurationService,
         mockDelegatesRepository,
         mockLoggingService,
+        mockContractsRepository,
       ),
     );
   }
@@ -108,6 +115,7 @@ describe('MultisigTransactionDetails mapper (Unit)', () => {
       .with('safe', safe.address)
       .with('isExecuted', false)
       .with('nonce', safe.nonce)
+      .with('operation', Operation.CALL)
       .buildWithConfirmations({
         chainId,
         safe,
@@ -160,6 +168,7 @@ describe('MultisigTransactionDetails mapper (Unit)', () => {
       .with('safe', safe.address)
       .with('isExecuted', false)
       .with('nonce', safe.nonce)
+      .with('operation', Operation.CALL)
       .buildWithConfirmations({
         chainId,
         safe,


Reviews:
user: login: PooyaRaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T16:27:22Z
user: login: iamacook
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T17:07:57Z
user: login: iamacook
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T17:08:05Z
user: login: hectorgomezv
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T10:16:49Z
user: login: hectorgomezv
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T15:03:30Z

Review comments:
diff_hunk: @@ -418,12 +418,16 @@ describe('Propose transaction - Transactions Controller (Unit)', () => {
       .with('signature', transaction.confirmations![0].signature)
       .build();
     const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chainId}`;
+    const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safeAddress}`;
     const getMultisigTransactionUrl = `${chain.transactionService}/api/v1/multisig-transactions/${proposeTransactionDto.safeTxHash}/`;
     const getContractUrlPattern = `${chain.transactionService}/api/v1/contracts/`;
     networkService.get.mockImplementation(({ url }) => {
       if (url === getChainUrl) {
         return Promise.resolve({ data: rawify(chain), status: 200 });
       }
+      if (url === getSafeUrl) {
path: src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts
user: login: PooyaRaki
body: Nit: We could change this if statement and the one after to else if for better readability/performance.
created_at: 2025-03-06T14:24:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1654,6 +1760,293 @@ describe('TransactionVerifierHelper', () => {
 
       expect(mockLoggingRepository.error).not.toHaveBeenCalled();
     });
+
+    it('should block delegate calls by default', async () => {
+      initTarget({
+        ethSign: true,
+        trustedDelegateCall: false,
+        blocklist: [],
+      });
+      const chainId = faker.string.numeric();
+      const signers = Array.from(
+        { length: faker.number.int({ min: 1, max: 5 }) },
+        () => {
+          const privateKey = generatePrivateKey();
+          return privateKeyToAccount(privateKey);
+        },
+      );
+      const safe = safeBuilder()
+        .with(
+          'owners',
+          signers.map((s) => s.address),
+        )
+        .build();
+      const transaction = await multisigTransactionBuilder()
+        .with('safe', safe.address)
+        .with('nonce', safe.nonce)
+        .with('operation', Operation.DELEGATE)
+        .buildWithConfirmations({
+          chainId,
+          signers: faker.helpers.arrayElements(signers, {
+            min: 1,
+            max: signers.length,
+          }),
+          safe,
+        });
+      if (
+        !transaction.confirmations ||
path: src/routes/transactions/helpers/transaction-verifier.helper.spec.ts
user: login: PooyaRaki
body: We are building with confirmation and the minLength of the confirmation faker is 1 which means there must be at least 1 confirmation. Why do we need to check here whether a confirmation exist or not?
created_at: 2025-03-06T16:23:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1654,6 +1760,293 @@ describe('TransactionVerifierHelper', () => {
 
       expect(mockLoggingRepository.error).not.toHaveBeenCalled();
     });
+
+    it('should block delegate calls by default', async () => {
+      initTarget({
+        ethSign: true,
+        trustedDelegateCall: false,
+        blocklist: [],
+      });
+      const chainId = faker.string.numeric();
+      const signers = Array.from(
+        { length: faker.number.int({ min: 1, max: 5 }) },
+        () => {
+          const privateKey = generatePrivateKey();
+          return privateKeyToAccount(privateKey);
+        },
+      );
+      const safe = safeBuilder()
+        .with(
+          'owners',
+          signers.map((s) => s.address),
+        )
+        .build();
+      const transaction = await multisigTransactionBuilder()
+        .with('safe', safe.address)
+        .with('nonce', safe.nonce)
+        .with('operation', Operation.DELEGATE)
+        .buildWithConfirmations({
+          chainId,
+          signers: faker.helpers.arrayElements(signers, {
+            min: 1,
+            max: signers.length,
+          }),
+          safe,
+        });
+      if (
+        !transaction.confirmations ||
+        transaction.confirmations.length === 0
+      ) {
+        throw new Error('Transaction must have at least 1 confirmation');
+      }
+      const confirmation = faker.helpers.arrayElement(
+        transaction.confirmations,
+      );
+      const proposal = proposeTransactionDtoBuilder()
+        .with('to', transaction.to)
+        .with('value', transaction.value)
+        .with('data', transaction.data)
+        .with('nonce', transaction.nonce.toString())
+        .with('operation', transaction.operation)
+        .with('safeTxGas', transaction.safeTxGas!.toString())
+        .with('baseGas', transaction.baseGas!.toString())
+        .with('gasPrice', transaction.gasPrice!)
+        .with('gasToken', transaction.gasToken!)
+        .with('refundReceiver', transaction.refundReceiver)
+        .with('safeTxHash', transaction.safeTxHash)
+        .with('sender', confirmation.owner)
+        .with('signature', confirmation.signature)
+        .build();
+      const contract = contractBuilder()
+        .with('address', transaction.to)
+        .with('trustedForDelegateCall', true)
+        .build();
+      mockContractsRepository.getContract.mockResolvedValue(contract);
+
+      await expect(
+        target.verifyProposal({ chainId, safe, proposal }),
+      ).rejects.toThrow(
+        new HttpExceptionNoLog(
+          'Delegate call is disabled',
+          HttpStatus.UNPROCESSABLE_ENTITY,
+        ),
+      );
+
+      expect(mockLoggingRepository.error).not.toHaveBeenCalled();
+    });
+
+    it('should allow trusted delegate calls', async () => {
+      initTarget({
+        ethSign: true,
+        trustedDelegateCall: true,
+        blocklist: [],
+      });
+      const chainId = faker.string.numeric();
+      const signers = Array.from(
+        { length: faker.number.int({ min: 1, max: 5 }) },
+        () => {
+          const privateKey = generatePrivateKey();
+          return privateKeyToAccount(privateKey);
+        },
+      );
+      const safe = safeBuilder()
+        .with(
+          'owners',
+          signers.map((s) => s.address),
+        )
+        .build();
+      const transaction = await multisigTransactionBuilder()
+        .with('safe', safe.address)
+        .with('nonce', safe.nonce)
+        .with('operation', Operation.DELEGATE)
+        .buildWithConfirmations({
+          chainId,
+          signers: faker.helpers.arrayElements(signers, {
+            min: 1,
+            max: signers.length,
+          }),
+          safe,
+        });
+      if (
+        !transaction.confirmations ||
path: src/routes/transactions/helpers/transaction-verifier.helper.spec.ts
user: login: PooyaRaki
body: Same as before, I won’t mark any further occurrences of such checks in the code.
created_at: 2025-03-06T16:25:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -418,12 +418,16 @@ describe('Propose transaction - Transactions Controller (Unit)', () => {
       .with('signature', transaction.confirmations![0].signature)
       .build();
     const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chainId}`;
+    const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safeAddress}`;
     const getMultisigTransactionUrl = `${chain.transactionService}/api/v1/multisig-transactions/${proposeTransactionDto.safeTxHash}/`;
     const getContractUrlPattern = `${chain.transactionService}/api/v1/contracts/`;
     networkService.get.mockImplementation(({ url }) => {
       if (url === getChainUrl) {
         return Promise.resolve({ data: rawify(chain), status: 200 });
       }
+      if (url === getSafeUrl) {
path: src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts
user: login: iamacook
body: As other mocks here use early returns, I suggest we sooner unify the implementation across the entire project in a follow up. There are places that use `switch`, `if/else if` and early returns. What do you think?
created_at: 2025-03-06T17:07:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1654,6 +1760,293 @@ describe('TransactionVerifierHelper', () => {
 
       expect(mockLoggingRepository.error).not.toHaveBeenCalled();
     });
+
+    it('should block delegate calls by default', async () => {
+      initTarget({
+        ethSign: true,
+        trustedDelegateCall: false,
+        blocklist: [],
+      });
+      const chainId = faker.string.numeric();
+      const signers = Array.from(
+        { length: faker.number.int({ min: 1, max: 5 }) },
+        () => {
+          const privateKey = generatePrivateKey();
+          return privateKeyToAccount(privateKey);
+        },
+      );
+      const safe = safeBuilder()
+        .with(
+          'owners',
+          signers.map((s) => s.address),
+        )
+        .build();
+      const transaction = await multisigTransactionBuilder()
+        .with('safe', safe.address)
+        .with('nonce', safe.nonce)
+        .with('operation', Operation.DELEGATE)
+        .buildWithConfirmations({
+          chainId,
+          signers: faker.helpers.arrayElements(signers, {
+            min: 1,
+            max: signers.length,
+          }),
+          safe,
+        });
+      if (
+        !transaction.confirmations ||
path: src/routes/transactions/helpers/transaction-verifier.helper.spec.ts
user: login: iamacook
body: This is to prevent TypeScript errors. I'm currently working on increased test coverage where this is entriely removed though.

Edit: I've opened [the PR removing these](https://github.com/safe-global/safe-client-gateway/pull/2431) across all suites.
created_at: 2025-03-06T17:08:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -418,12 +418,16 @@ describe('Propose transaction - Transactions Controller (Unit)', () => {
       .with('signature', transaction.confirmations![0].signature)
       .build();
     const getChainUrl = `${safeConfigUrl}/api/v1/chains/${chainId}`;
+    const getSafeUrl = `${chain.transactionService}/api/v1/safes/${safeAddress}`;
     const getMultisigTransactionUrl = `${chain.transactionService}/api/v1/multisig-transactions/${proposeTransactionDto.safeTxHash}/`;
     const getContractUrlPattern = `${chain.transactionService}/api/v1/contracts/`;
     networkService.get.mockImplementation(({ url }) => {
       if (url === getChainUrl) {
         return Promise.resolve({ data: rawify(chain), status: 200 });
       }
+      if (url === getSafeUrl) {
path: src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts
user: login: hectorgomezv
body: In my opinion performance is not a problem here, as each `if` statement makes the mock to return early. Anyway, I agree we should unify these _mock routers_ across the entire project. For the sake of readability and considering they use to have many branches, I'd go with `switch` statements, but I also think this would be a low value/priority refactor, wdyt?
created_at: 2025-03-07T10:16:47Z
author_association: CONTRIBUTOR

PR comments:
user: login: hectorgomezv
created_at: 2025-03-07T10:31:29Z
body: LGTM, but the PR has some conflicts with the main branch that would possibly require a refactor and the test adjustment 🤔 

author_association: CONTRIBUTOR
user: login: iamacook
created_at: 2025-03-07T11:32:20Z
body: > LGTM, but the PR has some conflicts with the main branch that would possibly require a refactor and the test adjustment 🤔

I'll hold off on merging this until https://github.com/safe-global/safe-client-gateway/pull/2431 is finished as there are tests marked as todo there that require changes here.
author_association: CONTRIBUTOR
user: login: iamacook
created_at: 2025-03-07T14:37:52Z
body: > LGTM, but the PR has some conflicts with the main branch that would possibly require a refactor and the test adjustment 🤔

This has now been addressed in accordance with my [previous comment](https://github.com/safe-global/safe-client-gateway/pull/2427#issuecomment-2706221692).
author_association: CONTRIBUTOR