diff --git a/packages/apps/fortune/recording-oracle/src/common/config/env-schema.ts b/packages/apps/fortune/recording-oracle/src/common/config/env-schema.ts index 7a1a4d6144..865b9f747d 100644 --- a/packages/apps/fortune/recording-oracle/src/common/config/env-schema.ts +++ b/packages/apps/fortune/recording-oracle/src/common/config/env-schema.ts @@ -6,6 +6,7 @@ export const envValidator = Joi.object({ PORT: Joi.string(), // Web3 WEB3_PRIVATE_KEY: Joi.string().required(), + SDK_TX_TIMEOUT_MS: Joi.number(), RPC_URL_POLYGON: Joi.string(), RPC_URL_BSC: Joi.string(), RPC_URL_POLYGON_AMOY: Joi.string(), diff --git a/packages/apps/fortune/recording-oracle/src/common/config/web3-config.service.ts b/packages/apps/fortune/recording-oracle/src/common/config/web3-config.service.ts index 619e9a0444..e740cf11ca 100644 --- a/packages/apps/fortune/recording-oracle/src/common/config/web3-config.service.ts +++ b/packages/apps/fortune/recording-oracle/src/common/config/web3-config.service.ts @@ -12,4 +12,12 @@ export class Web3ConfigService { get privateKey(): string { return this.configService.getOrThrow('WEB3_PRIVATE_KEY'); } + + /** + * Timeout for web3 transactions in milliseconds. + * Default: 60000 (60 seconds) + */ + get txTimeoutMs(): number { + return +this.configService.get('SDK_TX_TIMEOUT_MS', 60000); + } } diff --git a/packages/apps/fortune/recording-oracle/src/modules/job/job.service.ts b/packages/apps/fortune/recording-oracle/src/modules/job/job.service.ts index 09210cec12..9ce3e2b726 100644 --- a/packages/apps/fortune/recording-oracle/src/modules/job/job.service.ts +++ b/packages/apps/fortune/recording-oracle/src/modules/job/job.service.ts @@ -202,6 +202,7 @@ export class JobService { jobSolutionUploaded.url, jobSolutionUploaded.hash, !lastProcessedSolution?.error ? amountToReserve : 0n, + { timeoutMs: this.web3ConfigService.txTimeoutMs }, ); if ( @@ -307,6 +308,7 @@ export class JobService { intermediateResultsURL, intermediateResultsHash, 0n, + { timeoutMs: this.web3ConfigService.txTimeoutMs }, ); let reputationOracleWebhook: string | null = null; diff --git a/packages/apps/job-launcher/server/src/common/config/env-schema.ts b/packages/apps/job-launcher/server/src/common/config/env-schema.ts index 2dca07e7b4..195f9becb1 100644 --- a/packages/apps/job-launcher/server/src/common/config/env-schema.ts +++ b/packages/apps/job-launcher/server/src/common/config/env-schema.ts @@ -26,6 +26,7 @@ export const envValidator = Joi.object({ // Web3 WEB3_ENV: Joi.string(), WEB3_PRIVATE_KEY: Joi.string().required(), + SDK_TX_TIMEOUT_MS: Joi.number(), GAS_PRICE_MULTIPLIER: Joi.number(), APPROVE_AMOUNT_USD: Joi.number(), REPUTATION_ORACLE_ADDRESS: Joi.string().required(), diff --git a/packages/apps/job-launcher/server/src/common/config/web3-config.service.ts b/packages/apps/job-launcher/server/src/common/config/web3-config.service.ts index b2f0f8a5d0..9c5806f0f4 100644 --- a/packages/apps/job-launcher/server/src/common/config/web3-config.service.ts +++ b/packages/apps/job-launcher/server/src/common/config/web3-config.service.ts @@ -96,4 +96,12 @@ export class Web3ConfigService { get approveAmountUsd(): number { return this.configService.get('APPROVE_AMOUNT_USD', 0); } + + /** + * Timeout for web3 transactions in milliseconds. + * Default: 60000 (60 seconds) + */ + get txTimeoutMs(): number { + return +this.configService.get('SDK_TX_TIMEOUT_MS', 60000); + } } diff --git a/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts b/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts index a179f84e47..cdf7d3a25a 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts @@ -15,6 +15,7 @@ import { Test } from '@nestjs/testing'; import { ethers, ZeroAddress } from 'ethers'; import { createSignerMock } from '../../../test/fixtures/web3'; import { ServerConfigService } from '../../common/config/server-config.service'; +import { Web3ConfigService } from '../../common/config/web3-config.service'; import { ErrorEscrow, ErrorJob } from '../../common/constants/errors'; import { TOKEN_ADDRESSES } from '../../common/constants/tokens'; import { @@ -82,6 +83,9 @@ const mockRateService = createMock(); const mockRoutingProtocolService = createMock(); const mockManifestService = createMock(); const mockWhitelistService = createMock(); +const mockWeb3ConfigService = { + txTimeoutMs: faker.number.int({ min: 30000, max: 120000 }), +}; const mockedEscrowClient = jest.mocked(EscrowClient); const mockedEscrowUtils = jest.mocked(EscrowUtils); @@ -129,6 +133,7 @@ describe('JobService', () => { provide: ManifestService, useValue: mockManifestService, }, + { provide: Web3ConfigService, useValue: mockWeb3ConfigService }, ], }).compile(); @@ -794,7 +799,11 @@ describe('JobService', () => { manifest: jobEntity.manifestUrl, manifestHash: jobEntity.manifestHash, }), - { maxFeePerGas: 1n, maxPriorityFeePerGas: 1n }, + { + maxFeePerGas: 1n, + maxPriorityFeePerGas: 1n, + timeoutMs: mockWeb3ConfigService.txTimeoutMs, + }, ); expect(result.status).toBe(JobStatus.LAUNCHED); expect(result.escrowAddress).toBe(escrowAddress); @@ -870,7 +879,11 @@ describe('JobService', () => { expectedWeiAmount, jobEntity.userId.toString(), expect.any(Object), - { maxFeePerGas: 1n, maxPriorityFeePerGas: 1n }, + { + maxFeePerGas: 1n, + maxPriorityFeePerGas: 1n, + timeoutMs: mockWeb3ConfigService.txTimeoutMs, + }, ); expect(mockJobRepository.updateOne).not.toHaveBeenCalled(); diff --git a/packages/apps/job-launcher/server/src/modules/job/job.service.ts b/packages/apps/job-launcher/server/src/modules/job/job.service.ts index 0a426131eb..efe70a86f7 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.service.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.service.ts @@ -14,6 +14,7 @@ import { validate, } from 'class-validator'; import { ethers } from 'ethers'; +import { Web3ConfigService } from '../../common/config/web3-config.service'; import { ServerConfigService } from '../../common/config/server-config.service'; import { CANCEL_JOB_STATUSES } from '../../common/constants'; import { @@ -87,6 +88,7 @@ export class JobService { constructor( @Inject(Web3Service) private readonly web3Service: Web3Service, + private readonly web3ConfigService: Web3ConfigService, private readonly jobRepository: JobRepository, private readonly webhookRepository: WebhookRepository, private readonly paymentService: PaymentService, @@ -345,7 +347,10 @@ export class JobService { weiAmount, jobEntity.userId.toString(), escrowConfig, - await this.web3Service.calculateTxFees(jobEntity.chainId), + { + ...(await this.web3Service.calculateTxFees(jobEntity.chainId)), + timeoutMs: this.web3ConfigService.txTimeoutMs, + }, ); if (!escrowAddress) { @@ -607,10 +612,10 @@ export class JobService { // Attempt requestCancellation; on any error attempt direct cancel once. // TODO: Remove try-catch when requestCancellation is fully supported by all escrows try { - await (escrowClient as any).requestCancellation( - escrowAddress!, - await this.web3Service.calculateTxFees(chainId), - ); + await (escrowClient as any).requestCancellation(escrowAddress!, { + ...(await this.web3Service.calculateTxFees(chainId)), + timeoutMs: this.web3ConfigService.txTimeoutMs, + }); } catch (error: any) { this.logger.warn( 'requestCancellation failed, attempting cancel fallback', @@ -621,10 +626,10 @@ export class JobService { error, }, ); - await (escrowClient as any).cancel( - escrowAddress!, - await this.web3Service.calculateTxFees(chainId), - ); + await (escrowClient as any).cancel(escrowAddress!, { + ...(await this.web3Service.calculateTxFees(chainId)), + timeoutMs: this.web3ConfigService.txTimeoutMs, + }); } } diff --git a/packages/apps/reputation-oracle/server/src/config/env-schema.ts b/packages/apps/reputation-oracle/server/src/config/env-schema.ts index 78876e7ec8..c2a55a98d8 100644 --- a/packages/apps/reputation-oracle/server/src/config/env-schema.ts +++ b/packages/apps/reputation-oracle/server/src/config/env-schema.ts @@ -47,6 +47,7 @@ export const envValidator = Joi.object({ // Web3 WEB3_ENV: Joi.string().valid(...Object.values(Web3Network)), WEB3_PRIVATE_KEY: Joi.string().required(), + SDK_TX_TIMEOUT_MS: Joi.number().integer(), GAS_PRICE_MULTIPLIER: Joi.number().positive(), RPC_URL_SEPOLIA: Joi.string().uri({ scheme: ['http', 'https'] }), RPC_URL_POLYGON: Joi.string().uri({ scheme: ['http', 'https'] }), diff --git a/packages/apps/reputation-oracle/server/src/config/web3-config.service.ts b/packages/apps/reputation-oracle/server/src/config/web3-config.service.ts index dbb40d55f4..98cc7b4a0e 100644 --- a/packages/apps/reputation-oracle/server/src/config/web3-config.service.ts +++ b/packages/apps/reputation-oracle/server/src/config/web3-config.service.ts @@ -56,6 +56,14 @@ export class Web3ConfigService { return Number(this.configService.get('GAS_PRICE_MULTIPLIER')) || 1; } + /** + * Timeout for web3 transactions in milliseconds. + * Default: 60000 (60 seconds) + */ + get txTimeoutMs(): number { + return +this.configService.get('SDK_TX_TIMEOUT_MS', 60000); + } + getRpcUrlByChainId(chainId: number): string | undefined { const rpcUrlsByChainId: Record = { [ChainId.POLYGON]: this.configService.get('RPC_URL_POLYGON'), diff --git a/packages/apps/reputation-oracle/server/src/modules/escrow-completion/escrow-completion.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/escrow-completion/escrow-completion.service.spec.ts index b6af6e4211..d8a951f98f 100644 --- a/packages/apps/reputation-oracle/server/src/modules/escrow-completion/escrow-completion.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/escrow-completion/escrow-completion.service.spec.ts @@ -26,11 +26,14 @@ import stringify from 'json-stable-stringify'; import _ from 'lodash'; import { CvatJobType, FortuneJobType } from '@/common/enums'; -import { ServerConfigService } from '@/config'; +import { ServerConfigService, Web3ConfigService } from '@/config'; import { ReputationService } from '@/modules/reputation'; import { StorageService } from '@/modules/storage'; import { WalletWithProvider, Web3Service } from '@/modules/web3'; -import { generateTestnetChainId } from '@/modules/web3/fixtures'; +import { + generateTestnetChainId, + mockWeb3ConfigService, +} from '@/modules/web3/fixtures'; import { OutgoingWebhookService } from '@/modules/webhook'; import { createSignerMock, type SignerMock } from '~/test/fixtures/web3'; @@ -99,6 +102,10 @@ describe('EscrowCompletionService', () => { provide: StorageService, useValue: mockStorageService, }, + { + provide: Web3ConfigService, + useValue: mockWeb3ConfigService, + }, { provide: OutgoingWebhookService, useValue: mockOutgoingWebhookService, @@ -1044,7 +1051,10 @@ describe('EscrowCompletionService', () => { }); expect(mockCompleteEscrow).toHaveBeenCalledWith( paidPayoutsRecord.escrowAddress, - mockFees, + { + ...mockFees, + timeoutMs: mockWeb3ConfigService.txTimeoutMs, + }, ); expect(mockReputationService.assessEscrowParties).toHaveBeenCalledTimes( 1, diff --git a/packages/apps/reputation-oracle/server/src/modules/escrow-completion/escrow-completion.service.ts b/packages/apps/reputation-oracle/server/src/modules/escrow-completion/escrow-completion.service.ts index 817c6904c8..cf119c2515 100644 --- a/packages/apps/reputation-oracle/server/src/modules/escrow-completion/escrow-completion.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/escrow-completion/escrow-completion.service.ts @@ -16,7 +16,7 @@ import { v4 as uuidv4 } from 'uuid'; import { BACKOFF_INTERVAL_SECONDS } from '@/common/constants'; import { JobManifest, JobRequestType } from '@/common/types'; -import { ServerConfigService } from '@/config'; +import { ServerConfigService, Web3ConfigService } from '@/config'; import { isDuplicatedError } from '@/database'; import logger from '@/logger'; import { ReputationService } from '@/modules/reputation'; @@ -58,6 +58,7 @@ export class EscrowCompletionService { private readonly escrowCompletionRepository: EscrowCompletionRepository, private readonly escrowPayoutsBatchRepository: EscrowPayoutsBatchRepository, private readonly web3Service: Web3Service, + private readonly web3ConfigService: Web3ConfigService, private readonly storageService: StorageService, private readonly outgoingWebhookService: OutgoingWebhookService, private readonly reputationService: ReputationService, @@ -243,10 +244,16 @@ export class EscrowCompletionService { const feeOverrides = await this.web3Service.calculateTxFees(chainId); if (escrowStatus === EscrowStatus.ToCancel) { - await escrowClient.cancel(escrowAddress, feeOverrides); + await escrowClient.cancel(escrowAddress, { + ...feeOverrides, + timeoutMs: this.web3ConfigService.txTimeoutMs, + }); escrowStatus = EscrowStatus.Cancelled; } else { - await escrowClient.complete(escrowAddress, feeOverrides); + await escrowClient.complete(escrowAddress, { + ...feeOverrides, + timeoutMs: this.web3ConfigService.txTimeoutMs, + }); escrowStatus = EscrowStatus.Complete; } @@ -453,7 +460,10 @@ export class EscrowCompletionService { try { const transactionResponse = await signer.sendTransaction(rawTransaction); - await transactionResponse.wait(); + await transactionResponse.wait( + undefined, + this.web3ConfigService.txTimeoutMs, + ); await this.escrowPayoutsBatchRepository.deleteOne(payoutsBatch); } catch (error) { diff --git a/packages/apps/reputation-oracle/server/src/modules/user/user.service.spec.ts b/packages/apps/reputation-oracle/server/src/modules/user/user.service.spec.ts index be2744f64a..c81002a821 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/user.service.spec.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/user.service.spec.ts @@ -30,7 +30,6 @@ import { } from './user.error'; import { UserRepository } from './user.repository'; import { UserService, OperatorStatus } from './user.service'; - const mockUserRepository = createMock(); const mockSiteKeyRepository = createMock(); const mockHCaptchaService = createMock(); @@ -472,6 +471,7 @@ describe('UserService', () => { expect(mockedKVStoreSet).toHaveBeenCalledWith( user.evmAddress, OperatorStatus.ACTIVE, + { timeoutMs: mockWeb3ConfigService.txTimeoutMs }, ); }); }); @@ -554,6 +554,7 @@ describe('UserService', () => { expect(mockedKVStoreSet).toHaveBeenCalledWith( user.evmAddress, OperatorStatus.INACTIVE, + { timeoutMs: mockWeb3ConfigService.txTimeoutMs }, ); }); }); diff --git a/packages/apps/reputation-oracle/server/src/modules/user/user.service.ts b/packages/apps/reputation-oracle/server/src/modules/user/user.service.ts index 331a6003c0..9c18211859 100644 --- a/packages/apps/reputation-oracle/server/src/modules/user/user.service.ts +++ b/packages/apps/reputation-oracle/server/src/modules/user/user.service.ts @@ -222,7 +222,9 @@ export class UserService { ); } - await kvstore.set(operatorUser.evmAddress, OperatorStatus.ACTIVE); + await kvstore.set(operatorUser.evmAddress, OperatorStatus.ACTIVE, { + timeoutMs: this.web3ConfigService.txTimeoutMs, + }); } async disableOperator(userId: number, signature: string): Promise { @@ -266,7 +268,9 @@ export class UserService { ); } - await kvstore.set(operatorUser.evmAddress, OperatorStatus.INACTIVE); + await kvstore.set(operatorUser.evmAddress, OperatorStatus.INACTIVE, { + timeoutMs: this.web3ConfigService.txTimeoutMs, + }); } async registrationInExchangeOracle( diff --git a/packages/apps/reputation-oracle/server/src/modules/web3/fixtures/index.ts b/packages/apps/reputation-oracle/server/src/modules/web3/fixtures/index.ts index fde77f55ed..fe1eff7c1f 100644 --- a/packages/apps/reputation-oracle/server/src/modules/web3/fixtures/index.ts +++ b/packages/apps/reputation-oracle/server/src/modules/web3/fixtures/index.ts @@ -18,6 +18,7 @@ export const mockWeb3ConfigService: Omit = { operatorAddress: testWallet.address, network: Web3Network.TESTNET, gasPriceMultiplier: faker.number.int({ min: 1, max: 42 }), + txTimeoutMs: faker.number.int({ min: 30000, max: 120000 }), reputationNetworkChainId: generateTestnetChainId(), getRpcUrlByChainId: () => faker.internet.url(), };