From ca4489eb135d42aff01ab34d8a5c616182493aed Mon Sep 17 00:00:00 2001 From: Teja Reddy Date: Sat, 14 Feb 2026 11:27:28 -0500 Subject: [PATCH] fix: add ajv address format validation and chainId guard in deliverPayable --- package-lock.json | 18 +++++++ package.json | 1 + src/acpJob.ts | 5 +- src/acpJobOffering.ts | 3 ++ test/unit/acpJob.test.ts | 84 +++++++++++++++++++++++++++++++- test/unit/acpJobOffering.test.ts | 75 ++++++++++++++++++++++++++++ 6 files changed, 184 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index ad97bc5..7bc3b15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@account-kit/smart-contracts": "^4.73.0", "@virtuals-protocol/acp-node": "^0.3.0-beta.10", "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "axios": "^1.13.2", "jwt-decode": "^4.0.0", "socket.io-client": "^4.8.1", @@ -4842,6 +4843,23 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/alchemy-sdk": { "version": "3.5.8", "resolved": "https://registry.npmjs.org/alchemy-sdk/-/alchemy-sdk-3.5.8.tgz", diff --git a/package.json b/package.json index 945e711..741138c 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@account-kit/smart-contracts": "^4.73.0", "@virtuals-protocol/acp-node": "^0.3.0-beta.10", "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", "axios": "^1.13.2", "jwt-decode": "^4.0.0", "socket.io-client": "^4.8.1", diff --git a/src/acpJob.ts b/src/acpJob.ts index adc4397..eec83b9 100644 --- a/src/acpJob.ts +++ b/src/acpJob.ts @@ -458,7 +458,10 @@ class AcpJob { expiredAt: Date = new Date(Date.now() + 1000 * 60 * 5) // 5 minutes ) { // If payable chain belongs to non ACP native chain, we route to transfer service - if (amount.fare.chainId !== this.acpContractClient.config.chain.id) { + if ( + amount.fare.chainId && + amount.fare.chainId !== this.acpContractClient.config.chain.id + ) { return await this.deliverCrossChainPayable( this.clientAddress, preparePayload(deliverable), diff --git a/src/acpJobOffering.ts b/src/acpJobOffering.ts index d7ac515..2ce77ac 100644 --- a/src/acpJobOffering.ts +++ b/src/acpJobOffering.ts @@ -1,6 +1,7 @@ import { Address, zeroAddress } from "viem"; import AcpClient from "./acpClient"; import Ajv from "ajv"; +import addFormats from "ajv-formats"; import { FareAmount } from "./acpFare"; import AcpError from "./acpError"; import BaseAcpContractClient, { @@ -35,6 +36,8 @@ class AcpJobOffering { public deliverable?: Object | string ) { this.ajv = new Ajv({ allErrors: true }); + addFormats(this.ajv); + this.ajv.addFormat("address", /^0x[a-fA-F0-9]{40}$/); } async initiateJob( diff --git a/test/unit/acpJob.test.ts b/test/unit/acpJob.test.ts index 582c858..a08de68 100644 --- a/test/unit/acpJob.test.ts +++ b/test/unit/acpJob.test.ts @@ -1,7 +1,7 @@ import { Address } from "viem"; import AcpJob from "../../src/acpJob"; import AcpMemo from "../../src/acpMemo"; -import { Fare } from "../../src/acpFare"; +import { Fare, FareBigInt } from "../../src/acpFare"; import AcpClient from "../../src/acpClient"; import AcpError from "../../src/acpError"; import { AcpMemoStatus } from "../../src/interfaces"; @@ -1415,6 +1415,88 @@ describe("AcpJob Unit Testing", () => { expect(timeDiff).toBeLessThan(1000); }); + + it("should use local payable when fare chainId is undefined", async () => { + const fareAmountWithoutChainId = new FareBigInt( + BigInt(2000000000000000000), + new Fare("0xTokenAddress" as Address, 18), + ); + + const deliverable = { result: "Done" }; + const expiredAt = new Date(Date.now() + 1000 * 60 * 5); + + const mockApprovedResult = { type: "APPROVE_ALLOWANCE" }; + const mockPayableResult = { type: "CREATE_PAYABLE_MEMO" }; + + (mockContractClient.approveAllowance as jest.Mock).mockReturnValue( + mockApprovedResult, + ); + (mockContractClient.createPayableMemo as jest.Mock).mockReturnValue( + mockPayableResult, + ); + + const result = await acpJob.deliverPayable( + deliverable, + fareAmountWithoutChainId, + false, + expiredAt, + ); + + expect(mockContractClient.approveAllowance).toHaveBeenCalledWith( + fareAmountWithoutChainId.amount, + fareAmountWithoutChainId.fare.contractAddress, + ); + expect(mockContractClient.createPayableMemo).toHaveBeenCalledWith( + 123, + JSON.stringify(deliverable), + fareAmountWithoutChainId.amount, + "0xClient", + BigInt(0), + FeeType.NO_FEE, + AcpJobPhases.COMPLETED, + MemoType.PAYABLE_TRANSFER, + expiredAt, + fareAmountWithoutChainId.fare.contractAddress, + ); + expect(mockContractClient.handleOperation).toHaveBeenCalledWith([ + mockApprovedResult, + mockPayableResult, + ]); + expect(result).toEqual({ hash: "0xHash" }); + }); + + it("should route to cross-chain payable when chainId differs from contract chain", async () => { + const crossChainFareAmount = new FareBigInt( + BigInt(2000000000000000000), + new Fare("0xTokenAddress" as Address, 18, 42161), + ); + + const deliverable = { result: "Cross chain delivery" }; + + mockContractClient.getAssetManager = jest + .fn() + .mockResolvedValue("0xAssetManager" as Address); + mockContractClient.getERC20Balance = jest + .fn() + .mockResolvedValue(BigInt(5000000000000000000)); + mockContractClient.getERC20Allowance = jest + .fn() + .mockResolvedValue(BigInt(0)); + mockContractClient.getERC20Symbol = jest.fn().mockResolvedValue("USDC"); + mockContractClient.agentWalletAddress = "0xAgentWallet" as Address; + mockContractClient.createCrossChainPayableMemo = jest + .fn() + .mockReturnValue({ type: "CROSS_CHAIN_PAYABLE" }); + + await acpJob.deliverPayable(deliverable, crossChainFareAmount); + + expect(mockContractClient.getAssetManager).toHaveBeenCalled(); + expect(mockContractClient.getERC20Balance).toHaveBeenCalledWith( + 42161, + "0xTokenAddress", + "0xAgentWallet", + ); + }); }); describe("createNotification", () => { diff --git a/test/unit/acpJobOffering.test.ts b/test/unit/acpJobOffering.test.ts index 8aad3df..c7316b5 100644 --- a/test/unit/acpJobOffering.test.ts +++ b/test/unit/acpJobOffering.test.ts @@ -310,6 +310,81 @@ describe("AcpJobOffering Unit Testing", () => { ).rejects.toThrow(AcpError); }); + it("should validate schema with address format without throwing", async () => { + const mockUserOpHash = "0xmockUserOpHash"; + const mockJobId = 12345; + const mockCreateJobPayload = { data: "createJobPayload" }; + const mockSetBudgetPayload = { data: "setBudgetPayload" }; + const mockMemoPayload = { data: "memoPayload" }; + + mockContractClient.createJob.mockReturnValue(mockCreateJobPayload as any); + mockContractClient.handleOperation.mockResolvedValue({ + userOpHash: mockUserOpHash, + } as any); + mockContractClient.getJobId.mockResolvedValue(mockJobId); + mockContractClient.setBudgetWithPaymentToken.mockReturnValue( + mockSetBudgetPayload as any, + ); + mockContractClient.createMemo.mockReturnValue(mockMemoPayload as any); + + const requirementSchema = { + type: "object", + properties: { + walletAddress: { type: "string", format: "address" }, + }, + required: ["walletAddress"], + }; + + const offering = new AcpJobOffering( + mockAcpClient, + mockContractClient, + "0xProvider" as Address, + "Transfer Funds", + 100, + PriceType.FIXED, + true, + requirementSchema, + ); + + const validServiceRequirement = { + walletAddress: "0xc3bD156486aA42f671061a8cBD1F9CB4be50C001", + }; + + const result = await offering.initiateJob(validServiceRequirement); + + expect(result).toBe(mockJobId); + expect(mockContractClient.createJob).toHaveBeenCalledTimes(1); + }); + + it("should throw AcpError when address format validation fails", async () => { + const requirementSchema = { + type: "object", + properties: { + walletAddress: { type: "string", format: "address" }, + }, + required: ["walletAddress"], + }; + + const offering = new AcpJobOffering( + mockAcpClient, + mockContractClient, + "0xProvider" as Address, + "Transfer Funds", + 100, + PriceType.FIXED, + true, + requirementSchema, + ); + + const invalidServiceRequirement = { + walletAddress: "not-a-valid-address", + }; + + await expect( + offering.initiateJob(invalidServiceRequirement), + ).rejects.toThrow(AcpError); + }); + it("should set fareAmount to 0 for percentage pricing", async () => { const mockUserOpHash = "0xmockUserOpHash"; const mockJobId = 12345;