diff --git a/src/configs/acpConfigs.ts b/src/configs/acpConfigs.ts index 654d76d..cebbb65 100644 --- a/src/configs/acpConfigs.ts +++ b/src/configs/acpConfigs.ts @@ -19,6 +19,11 @@ class AcpContractConfig { public maxRetries: number, public rpcEndpoint?: string, public x402Config?: X402Config, + public retryConfig?: { + intervalMs: number; + multiplier: number; + maxRetries: number; + } ) {} } @@ -31,7 +36,7 @@ const baseSepoliaAcpConfig = new AcpContractConfig( ACP_ABI, V1_MAX_RETRIES, undefined, - undefined, + undefined ); const baseSepoliaAcpX402Config = new AcpContractConfig( @@ -45,7 +50,7 @@ const baseSepoliaAcpX402Config = new AcpContractConfig( undefined, { url: "https://dev-acp-x402.virtuals.io", - }, + } ); const baseSepoliaAcpConfigV2 = new AcpContractConfig( @@ -57,7 +62,7 @@ const baseSepoliaAcpConfigV2 = new AcpContractConfig( ACP_V2_ABI, V2_MAX_RETRIES, undefined, - undefined, + undefined ); const baseSepoliaAcpX402ConfigV2 = new AcpContractConfig( @@ -71,7 +76,7 @@ const baseSepoliaAcpX402ConfigV2 = new AcpContractConfig( undefined, { url: "https://dev-acp-x402.virtuals.io", - }, + } ); const baseAcpConfig = new AcpContractConfig( @@ -83,7 +88,7 @@ const baseAcpConfig = new AcpContractConfig( ACP_ABI, V1_MAX_RETRIES, undefined, - undefined, + undefined ); const baseAcpX402Config = new AcpContractConfig( @@ -97,7 +102,7 @@ const baseAcpX402Config = new AcpContractConfig( undefined, { url: "https://acp-x402.virtuals.io", - }, + } ); const baseAcpConfigV2 = new AcpContractConfig( @@ -109,7 +114,7 @@ const baseAcpConfigV2 = new AcpContractConfig( ACP_V2_ABI, V2_MAX_RETRIES, undefined, - undefined, + undefined ); const baseAcpX402ConfigV2 = new AcpContractConfig( @@ -123,7 +128,7 @@ const baseAcpX402ConfigV2 = new AcpContractConfig( undefined, { url: "https://acp-x402.virtuals.io", - }, + } ); export { diff --git a/src/contractClients/acpContractClient.ts b/src/contractClients/acpContractClient.ts index 90806ed..43d6a4d 100644 --- a/src/contractClients/acpContractClient.ts +++ b/src/contractClients/acpContractClient.ts @@ -26,13 +26,18 @@ class AcpContractClient extends BaseAcpContractClient { protected PRIORITY_FEE_MULTIPLIER = 2; protected MAX_FEE_PER_GAS = 20000000; protected MAX_PRIORITY_FEE_PER_GAS = 21000000; + private RETRY_CONFIG = { + intervalMs: 200, + multiplier: 1.1, + maxRetries: 10, + }; private _sessionKeyClient: ModularAccountV2Client | undefined; private _acpX402: AcpX402 | undefined; constructor( agentWalletAddress: Address, - config: AcpContractConfig = baseAcpConfig, + config: AcpContractConfig = baseAcpConfig ) { super(agentWalletAddress, config); } @@ -41,12 +46,9 @@ class AcpContractClient extends BaseAcpContractClient { walletPrivateKey: Address, sessionEntityKeyId: number, agentWalletAddress: Address, - config: AcpContractConfig = baseAcpConfig, + config: AcpContractConfig = baseAcpConfig ) { - const acpContractClient = new AcpContractClient( - agentWalletAddress, - config, - ); + const acpContractClient = new AcpContractClient(agentWalletAddress, config); await acpContractClient.init(walletPrivateKey, sessionEntityKeyId); return acpContractClient; } @@ -76,15 +78,22 @@ class AcpContractClient extends BaseAcpContractClient { ); const account = this.sessionKeyClient.account; - const sessionSignerAddress: Address = await account.getSigner().getAddress(); + const sessionSignerAddress: Address = await account + .getSigner() + .getAddress(); - if (!await account.isAccountDeployed()) { + if (!(await account.isAccountDeployed())) { throw new AcpError( `ACP Contract Client validation failed: agent account ${this.agentWalletAddress} is not deployed on-chain` ); } - await this.validateSessionKeyOnChain(sessionSignerAddress, sessionEntityKeyId); + await this.validateSessionKeyOnChain( + sessionSignerAddress, + sessionEntityKeyId + ); + + this.RETRY_CONFIG = this.config.retryConfig || this.RETRY_CONFIG; console.log("Connected to ACP with v1 Contract Client (Legacy):", { agentWalletAddress: this.agentWalletAddress, @@ -129,46 +138,56 @@ class AcpContractClient extends BaseAcpContractClient { return finalMaxFeePerGas; } - async handleOperation(operations: OperationPayload[]): Promise<{ userOpHash: Address , txnHash: Address }> { - const payload: any = { + async handleOperation( + operations: OperationPayload[] + ): Promise<{ userOpHash: Address; txnHash: Address }> { + const basePayload: any = { uo: operations.map((op) => ({ target: op.contractAddress, data: op.data, value: op.value, })), - overrides: { - nonceKey: this.getRandomNonce(), - }, }; - let retries = this.config.maxRetries; + let iteration = 0; let finalError: unknown; - while (retries > 0) { + while (iteration < this.config.maxRetries) { try { - if (this.config.maxRetries > retries) { - const gasFees = await this.calculateGasFees(); - - payload["overrides"] = { - maxFeePerGas: `0x${gasFees.toString(16)}`, - }; - } + const currentMultiplier = 1 + 0.1 * (iteration + 1); + + const payload: any = { + ...basePayload, + overrides: { + nonceKey: this.getRandomNonce(), + maxFeePerGas: { + multiplier: currentMultiplier, + }, + maxPriorityFeePerGas: { + multiplier: currentMultiplier, + }, + }, + }; const { hash } = await this.sessionKeyClient.sendUserOperation(payload); - const txnHash = await this.sessionKeyClient.waitForUserOperationTransaction({ - hash, - }); + const txnHash = + await this.sessionKeyClient.waitForUserOperationTransaction({ + hash, + tag: "pending", + retries: this.RETRY_CONFIG, + }); return { userOpHash: hash, txnHash }; } catch (error) { - retries -= 1; - if (retries === 0) { + iteration++; + + if (iteration === this.config.maxRetries) { finalError = error; break; } - await new Promise((resolve) => setTimeout(resolve, 2000 * retries)); + await new Promise((resolve) => setTimeout(resolve, 2000 * iteration)); } } @@ -180,7 +199,9 @@ class AcpContractClient extends BaseAcpContractClient { clientAddress: Address, providerAddress: Address ) { - const result = await this.sessionKeyClient.getUserOperationReceipt(createJobUserOpHash); + const result = await this.sessionKeyClient.getUserOperationReceipt( + createJobUserOpHash + ); if (!result) { throw new AcpError("Failed to get user operation receipt"); diff --git a/src/contractClients/acpContractClientV2.ts b/src/contractClients/acpContractClientV2.ts index 8157f76..0bd6ade 100644 --- a/src/contractClients/acpContractClientV2.ts +++ b/src/contractClients/acpContractClientV2.ts @@ -25,6 +25,12 @@ class AcpContractClientV2 extends BaseAcpContractClient { private PRIORITY_FEE_MULTIPLIER = 2; private MAX_FEE_PER_GAS = 20000000; private MAX_PRIORITY_FEE_PER_GAS = 21000000; + private GAS_FEE_MULTIPLIER = 0.5; + private RETRY_CONFIG = { + intervalMs: 200, + multiplier: 1.1, + maxRetries: 10, + }; private _sessionKeyClient: ModularAccountV2Client | undefined; private _acpX402: AcpX402 | undefined; @@ -34,7 +40,7 @@ class AcpContractClientV2 extends BaseAcpContractClient { private memoManagerAddress: Address, private accountManagerAddress: Address, agentWalletAddress: Address, - config: AcpContractConfig = baseAcpConfigV2, + config: AcpContractConfig = baseAcpConfigV2 ) { super(agentWalletAddress, config); } @@ -43,7 +49,7 @@ class AcpContractClientV2 extends BaseAcpContractClient { walletPrivateKey: Address, sessionEntityKeyId: number, agentWalletAddress: Address, - config: AcpContractConfig = baseAcpConfigV2, + config: AcpContractConfig = baseAcpConfigV2 ) { const publicClient = createPublicClient({ chain: config.chain, @@ -82,7 +88,7 @@ class AcpContractClientV2 extends BaseAcpContractClient { memoManagerAddress.result as Address, accountManagerAddress.result as Address, agentWalletAddress, - config, + config ); await acpContractClient.init(walletPrivateKey, sessionEntityKeyId); @@ -115,15 +121,22 @@ class AcpContractClientV2 extends BaseAcpContractClient { ); const account = this.sessionKeyClient.account; - const sessionSignerAddress: Address = await account.getSigner().getAddress(); + const sessionSignerAddress: Address = await account + .getSigner() + .getAddress(); - if (!await account.isAccountDeployed()) { + if (!(await account.isAccountDeployed())) { throw new AcpError( `ACP Contract Client validation failed: agent account ${this.agentWalletAddress} is not deployed on-chain` ); } - await this.validateSessionKeyOnChain(sessionSignerAddress, sessionEntityKeyId); + await this.validateSessionKeyOnChain( + sessionSignerAddress, + sessionEntityKeyId + ); + + this.RETRY_CONFIG = this.config.retryConfig || this.RETRY_CONFIG; console.log("Connected to ACP:", { agentWalletAddress: this.agentWalletAddress, @@ -168,52 +181,56 @@ class AcpContractClientV2 extends BaseAcpContractClient { return finalMaxFeePerGas; } - async handleOperation(operations: OperationPayload[]): Promise<{ userOpHash: Address , txnHash: Address }> { - const payload: any = { + async handleOperation( + operations: OperationPayload[] + ): Promise<{ userOpHash: Address; txnHash: Address }> { + const basePayload: any = { uo: operations.map((operation) => ({ target: operation.contractAddress, data: operation.data, value: operation.value, })), - overrides: { - nonceKey: this.getRandomNonce(), - }, }; - let retries = this.config.maxRetries; + let iteration = 0; let finalError: unknown; - while (retries > 0) { + while (iteration < this.config.maxRetries) { try { - if (this.config.maxRetries > retries) { - const gasFees = await this.calculateGasFees(); - - payload["overrides"] = { - maxFeePerGas: `0x${gasFees.toString(16)}`, - }; - } + const currentMultiplier = 1 + 0.1 * (iteration + 1); + + const payload: any = { + ...basePayload, + overrides: { + nonceKey: this.getRandomNonce(), + maxFeePerGas: { + multiplier: currentMultiplier, + }, + maxPriorityFeePerGas: { + multiplier: currentMultiplier, + }, + }, + }; const { hash } = await this.sessionKeyClient.sendUserOperation(payload); - const txnHash = await this.sessionKeyClient.waitForUserOperationTransaction({ - hash, - tag: "pending", - retries: { - intervalMs: 200, - multiplier: 1.1, - maxRetries: 10, - }, - }); + const txnHash = + await this.sessionKeyClient.waitForUserOperationTransaction({ + hash, + tag: "pending", + retries: this.RETRY_CONFIG, + }); return { userOpHash: hash, txnHash }; } catch (error) { - retries -= 1; - if (retries === 0) { + iteration++; + + if (iteration === this.config.maxRetries) { finalError = error; break; } - await new Promise((resolve) => setTimeout(resolve, 2000 * retries)); + await new Promise((resolve) => setTimeout(resolve, 2000 * iteration)); } } diff --git a/test/unit/acpContractClientV2.test.ts b/test/unit/acpContractClientV2.test.ts index da88901..e7d060c 100644 --- a/test/unit/acpContractClientV2.test.ts +++ b/test/unit/acpContractClientV2.test.ts @@ -18,7 +18,7 @@ describe("AcpContractClient V2 Unit Testing", () => { "0x2222222222222222222222222222222222222222" as Address, "0x3333333333333333333333333333333333333333" as Address, "0x4444444444444444444444444444444444444444" as Address, - baseAcpConfigV2, + baseAcpConfigV2 ); }); describe("Random Nonce Generation", () => { @@ -149,7 +149,7 @@ describe("AcpContractClient V2 Unit Testing", () => { // Start the operation and immediately set up the expectation const operationPromise = expect( - contractClient.handleOperation([mockOperation]), + contractClient.handleOperation([mockOperation]) ).rejects.toThrow(AcpError); await jest.runAllTimersAsync(); @@ -205,7 +205,7 @@ describe("AcpContractClient V2 Unit Testing", () => { }); }); - it("should able to invoke calculateGasFees during retries", async () => { + it("should able to increase maxFeePerGas multiplier during retries", async () => { jest.useFakeTimers(); const mockOperation: OperationPayload = { @@ -234,23 +234,48 @@ describe("AcpContractClient V2 Unit Testing", () => { waitForUserOperationTransaction: mockWaitForUserOperation, } as any; - // Spy on calculateGasFees to track if it's called - const calculateGasFeesSpy = jest.spyOn( - contractClient as any, - "calculateGasFees", - ); - const operationPromise = contractClient.handleOperation([mockOperation]); await jest.runAllTimersAsync(); await operationPromise; - expect(calculateGasFeesSpy).toHaveBeenCalledTimes(2); - expect(mockSendUserOperation).toHaveBeenCalledTimes(3); - calculateGasFeesSpy.mockRestore(); + // Verify multipliers increase with each iteration + // iteration 0: multiplier = 1 + 0.1 * (0 + 1) = 1.1 + // iteration 1: multiplier = 1 + 0.1 * (1 + 1) = 1.2 + // iteration 2: multiplier = 1 + 0.1 * (2 + 1) = 1.3 + expect(mockSendUserOperation).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + overrides: expect.objectContaining({ + maxFeePerGas: { multiplier: 1.1 }, + maxPriorityFeePerGas: { multiplier: 1.1 }, + }), + }) + ); + + expect(mockSendUserOperation).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + overrides: expect.objectContaining({ + maxFeePerGas: { multiplier: 1.2 }, + maxPriorityFeePerGas: { multiplier: 1.2 }, + }), + }) + ); + + expect(mockSendUserOperation).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + overrides: expect.objectContaining({ + maxFeePerGas: { multiplier: 1.3 }, + maxPriorityFeePerGas: { multiplier: 1.3 }, + }), + }) + ); + jest.useRealTimers(); }); }); @@ -273,14 +298,14 @@ describe("AcpContractClient V2 Unit Testing", () => { mockUrl, mockVersion, mockBudget, - mockSignature, + mockSignature ); expect(mockPerformRequest).toHaveBeenCalledWith( mockUrl, mockVersion, mockBudget, - mockSignature, + mockSignature ); expect(results).toBe(mockResponse); @@ -301,12 +326,12 @@ describe("AcpContractClient V2 Unit Testing", () => { const results = await contractClient.generateX402Payment( mockX402PayableRequest, - mockX402PayableRequirements, + mockX402PayableRequirements ); expect(mockGenerateX402Payment).toHaveBeenCalledWith( mockX402PayableRequest, - mockX402PayableRequirements, + mockX402PayableRequirements ); expect(results).toBe(mockResponse); @@ -351,12 +376,12 @@ describe("AcpContractClient V2 Unit Testing", () => { // Expect it to throw AcpError await expect( - contractClient.getX402PaymentDetails(mockJobId), + contractClient.getX402PaymentDetails(mockJobId) ).rejects.toThrow(AcpError); // Also verify the error message await expect( - contractClient.getX402PaymentDetails(mockJobId), + contractClient.getX402PaymentDetails(mockJobId) ).rejects.toThrow("Failed to get X402 payment details"); }); @@ -373,12 +398,12 @@ describe("AcpContractClient V2 Unit Testing", () => { const results = await contractClient.updateJobX402Nonce( mockJobIdNumber, - mockNonce, + mockNonce ); expect(mockUpdateJobNonce).toHaveBeenCalledWith( mockJobIdNumber, - mockNonce, + mockNonce ); expect(results).toBe(mockResponse); });