From 24f9e11c9d52c73bb1ca7adc227fc001b6190693 Mon Sep 17 00:00:00 2001 From: ariessa Date: Sat, 14 Feb 2026 23:05:35 +0800 Subject: [PATCH 1/5] feat: subscription --- examples/acp-base/subscription/README.md | 75 +++ examples/acp-base/subscription/buyer.ts | 185 +++++++ examples/acp-base/subscription/env.ts | 37 ++ examples/acp-base/subscription/seller.ts | 151 ++++++ package-lock.json | 5 + package.json | 1 + src/abis/acpAbiV2.ts | 139 ++---- src/abis/acpV2X402Abi.ts | 1 + src/acpAccount.ts | 10 +- src/acpClient.ts | 451 ++++++++++++++---- src/acpFare.ts | 5 +- src/acpJob.ts | 179 ++++++- src/acpJobOffering.ts | 254 +++++++--- src/contractClients/acpContractClient.ts | 42 +- src/contractClients/acpContractClientV2.ts | 36 ++ src/contractClients/baseAcpContractClient.ts | 134 ++++-- src/index.ts | 13 + src/interfaces.ts | 29 +- .../integration/acpClient.integration.test.ts | 2 + test/unit/acpJob.test.ts | 4 +- test/unit/acpJobOffering.test.ts | 60 +-- 21 files changed, 1471 insertions(+), 342 deletions(-) create mode 100644 examples/acp-base/subscription/README.md create mode 100644 examples/acp-base/subscription/buyer.ts create mode 100644 examples/acp-base/subscription/env.ts create mode 100644 examples/acp-base/subscription/seller.ts diff --git a/examples/acp-base/subscription/README.md b/examples/acp-base/subscription/README.md new file mode 100644 index 00000000..5dff9cab --- /dev/null +++ b/examples/acp-base/subscription/README.md @@ -0,0 +1,75 @@ +# ACP Subscription Example + +This example demonstrates how to test subscription-backed jobs with ACP v2 using a buyer (client) and seller (provider). + +## Overview + +The flow covers: + +- Assumes the selected agent has: + - subscription offering at `jobOfferings[0]` + - fixed-price offering at `jobOfferings[1]` +- Buyer runs one of two scenarios: + - Scenario 1: subscription offering + - Scenario 2: fixed-price offering +- Seller handles incoming jobs by price type. +- For subscription jobs, seller checks account subscription status. +- If no valid subscription exists, seller requests subscription payment. +- If subscription is active, seller proceeds without requesting subscription payment. + +## Files + +- buyer.ts: Runs scenario-based job initiation and handles subscription/fixed-price memo flows. +- seller.ts: Handles fixed-price and subscription paths, including subscription payment requirements. +- env.ts: Loads environment variables from .env. + +## Setup + +1. Create a .env file: + - Place it in examples/acp-base/subscription/.env + - Required variables: + - BUYER_AGENT_WALLET_ADDRESS + - SELLER_AGENT_WALLET_ADDRESS + - BUYER_ENTITY_ID + - SELLER_ENTITY_ID + - WHITELISTED_WALLET_PRIVATE_KEY + +2. Install dependencies (from repo root): + - npm install + +3. Ensure selected agent has at least: + - One subscription offering at index `jobOfferings[0]` + - One fixed-price offering at index `jobOfferings[1]` + +## Run + +1. Start the seller: + - cd examples/acp-base/subscription + - npx ts-node seller.ts + +2. Start the buyer in another terminal: + - cd examples/acp-base/subscription + - npx ts-node buyer.ts --scenario 1 # Subscription offering + - npx ts-node buyer.ts --scenario 2 # Fixed-price offering + +## Expected Flow + +- Scenario 1 (Subscription offering): + - Buyer initiates a subscription job with tier metadata (for example `sub_premium`). + - Seller checks subscription validity. + - If missing/expired, seller creates `PAYABLE_REQUEST_SUBSCRIPTION`. + - Buyer calls `paySubscription(...)`. + - Seller moves forward and eventually delivers in `TRANSACTION` phase. + - If you run scenario 1 again while subscription is active, seller skips subscription payment and sends a plain requirement. + +- Scenario 2 (Fixed-price offering): + - Buyer initiates a non-subscription job. + - Seller accepts and creates `PAYABLE_REQUEST`. + - Buyer pays with `payAndAcceptRequirement(...)`. + - Seller delivers in `TRANSACTION` phase. + +## Notes + +- Both agents must be registered and whitelisted on ACP. +- Subscription tier name in buyer defaults to `sub_premium`; adjust to match seller offering config. +- If the buyer does not see the seller, make sure the seller has at least one job offering and is searchable by the buyer's keyword. diff --git a/examples/acp-base/subscription/buyer.ts b/examples/acp-base/subscription/buyer.ts new file mode 100644 index 00000000..f29d3912 --- /dev/null +++ b/examples/acp-base/subscription/buyer.ts @@ -0,0 +1,185 @@ +/** + * Subscription Example - Buyer (Client) + * + * Run a specific scenario via --scenario flag: + * npx ts-node buyer.ts --scenario 1 # Subscription offering + * npx ts-node buyer.ts --scenario 2 # Non-subscription offering (fixed-price) + * + * Default: scenario 1 + * + * Assumption: + * - chosenAgent.jobOfferings[0] is a subscription offering + * - chosenAgent.jobOfferings[1] is a non-subscription (fixed-price) offering + */ +import AcpClient, { + AcpContractClientV2, + AcpJobPhases, + AcpJob, + AcpMemo, + MemoType, + AcpAgentSort, + AcpGraduationStatus, + AcpOnlineStatus, + baseSepoliaAcpConfigV2, +} from "../../../src/index"; +import { + BUYER_AGENT_WALLET_ADDRESS, + BUYER_ENTITY_ID, + WHITELISTED_WALLET_PRIVATE_KEY, +} from "./env"; + +// Subscription tier name — adjust to match your offering config +const SUBSCRIPTION_TIER = "sub_premium"; + +// Parse --scenario N from argv +const scenarioArg = process.argv.indexOf("--scenario"); +const SCENARIO = + scenarioArg !== -1 ? parseInt(process.argv[scenarioArg + 1], 10) : 1; + +async function buyer() { + console.log(`=== Subscription Example - Buyer (Scenario ${SCENARIO}) ===\n`); + + const acpClient = new AcpClient({ + acpContractClient: await AcpContractClientV2.build( + WHITELISTED_WALLET_PRIVATE_KEY, + BUYER_ENTITY_ID, + BUYER_AGENT_WALLET_ADDRESS, + baseSepoliaAcpConfigV2, + ), + onNewTask: async (job: AcpJob, memoToSign?: AcpMemo) => { + console.log( + `Buyer: onNewTask - Job ${job.id}, phase: ${AcpJobPhases[job.phase]}, ` + + `memoToSign: ${memoToSign?.id ?? "None"}, ` + + `nextPhase: ${memoToSign?.nextPhase !== undefined ? AcpJobPhases[memoToSign.nextPhase] : "None"}`, + ); + + // Subscription payment requested (Scenario 1) + if ( + job.phase === AcpJobPhases.NEGOTIATION && + memoToSign?.type === MemoType.PAYABLE_REQUEST_SUBSCRIPTION + ) { + console.log( + `Buyer: Job ${job.id} — Subscription payment requested: ${memoToSign.content}`, + ); + console.log( + `Buyer: Job ${job.id} — Amount: ${memoToSign.payableDetails?.amount}`, + ); + const { txnHash: subPayTx } = await job.paySubscription( + `Subscription payment for ${SUBSCRIPTION_TIER}`, + ); + console.log( + `Buyer: Job ${job.id} — Subscription paid (tx: ${subPayTx})`, + ); + + // Fixed-price requirement — pay and advance to delivery (Scenario 2) + } else if ( + job.phase === AcpJobPhases.NEGOTIATION && + memoToSign?.type === MemoType.PAYABLE_REQUEST + ) { + console.log( + `Buyer: Job ${job.id} — Fixed-price requirement, paying now`, + ); + const payResult = await job.payAndAcceptRequirement("Payment for job"); + console.log( + `Buyer: Job ${job.id} — Paid and advanced to TRANSACTION phase (tx: ${payResult?.txnHash})`, + ); + + // Active subscription path — accept requirement without payment + } else if ( + job.phase === AcpJobPhases.NEGOTIATION && + memoToSign?.type === MemoType.MESSAGE && + memoToSign?.nextPhase === AcpJobPhases.TRANSACTION + ) { + console.log( + `Buyer: Job ${job.id} — Subscription active, accepting without payment`, + ); + const { txnHash: signMemoTx } = await job.acceptRequirement( + memoToSign, + "Subscription verified, proceeding to delivery", + ); + console.log( + `Buyer: Job ${job.id} — Advanced to TRANSACTION phase (tx: ${signMemoTx})`, + ); + } else if (job.phase === AcpJobPhases.COMPLETED) { + console.log( + `Buyer: Job ${job.id} — Completed! Deliverable:`, + job.deliverable, + ); + } else if (job.phase === AcpJobPhases.REJECTED) { + console.log( + `Buyer: Job ${job.id} — Rejected. Reason:`, + job.rejectionReason, + ); + } else { + console.log( + `Buyer: Job ${job.id} — Unhandled event (phase: ${AcpJobPhases[job.phase]}, ` + + `memoType: ${memoToSign?.type !== undefined ? MemoType[memoToSign.type] : "None"}, ` + + `nextPhase: ${memoToSign?.nextPhase !== undefined ? AcpJobPhases[memoToSign.nextPhase] : "None"})`, + ); + } + }, + }); + + // Browse available agents + const relevantAgents = await acpClient.browseAgents("", { + sortBy: [AcpAgentSort.SUCCESSFUL_JOB_COUNT], + topK: 5, + graduationStatus: AcpGraduationStatus.ALL, + onlineStatus: AcpOnlineStatus.ALL, + showHiddenOfferings: true, + }); + + console.log("Relevant agents:", relevantAgents); + + if (!relevantAgents || relevantAgents.length === 0) { + console.error("No agents found"); + return; + } + + // Pick one of the agents based on your criteria (in this example we just pick the first one) + const chosenAgent = relevantAgents[0]; + + // Pick one of the service offerings based on your criteria: + // - index 0: subscription offering + // - index 1: non-subscription (fixed-price) offering + const subscriptionOffering = chosenAgent.jobOfferings[0]; + const fixedOffering = chosenAgent.jobOfferings[1]; + + switch (SCENARIO) { + case 1: { + const chosenJobOffering = subscriptionOffering; + const jobId = await chosenJobOffering.initiateJob( + // Requirement payload schema depends on your ACP service configuration. + // If your service requires fields, replace {} with the expected schema payload. + {}, + undefined, // evaluator address, undefined fallback to empty address + new Date(Date.now() + 1000 * 60 * 15), // job expiry duration, minimum 5 minutes + SUBSCRIPTION_TIER, + ); + console.log(`Buyer: [Scenario 1 — Subscription Offering] Job ${jobId} initiated`); + break; + } + + case 2: { + const chosenJobOffering = fixedOffering; + const jobId = await chosenJobOffering.initiateJob( + // Requirement payload schema depends on your ACP service configuration. + // If your service requires fields, replace {} with the expected schema payload. + {}, + undefined, // evaluator address, undefined fallback to empty address + new Date(Date.now() + 1000 * 60 * 15), // job expiry duration, minimum 5 minutes + ); + console.log(`Buyer: [Scenario 2 — Fixed-Price Job] Job ${jobId} initiated`); + break; + } + + default: + console.error(`Unknown scenario: ${SCENARIO}. Use --scenario 1 or 2.`); + process.exit(1); + } +} + +buyer().catch((error) => { + console.error("Buyer error:", error); + process.exit(1); +}); diff --git a/examples/acp-base/subscription/env.ts b/examples/acp-base/subscription/env.ts new file mode 100644 index 00000000..612821b3 --- /dev/null +++ b/examples/acp-base/subscription/env.ts @@ -0,0 +1,37 @@ +import dotenv from "dotenv"; +import { Address } from "viem"; + +dotenv.config({ path: __dirname + "/.env" }); + +function getEnvVar(key: string, required = true): T { + const value = process.env[key]; + if (required && (value === undefined || value === "")) { + throw new Error(`${key} is not defined or is empty in the .env file`); + } + return value as T; +} + +export const WHITELISTED_WALLET_PRIVATE_KEY = getEnvVar
( + "WHITELISTED_WALLET_PRIVATE_KEY" +); + +export const BUYER_AGENT_WALLET_ADDRESS = getEnvVar
( + "BUYER_AGENT_WALLET_ADDRESS" +); + +export const BUYER_ENTITY_ID = parseInt(getEnvVar("BUYER_ENTITY_ID")); + +export const SELLER_AGENT_WALLET_ADDRESS = getEnvVar
( + "SELLER_AGENT_WALLET_ADDRESS" +); + +export const SELLER_ENTITY_ID = parseInt(getEnvVar("SELLER_ENTITY_ID")); + +const entities = { + BUYER_ENTITY_ID, + SELLER_ENTITY_ID, +}; + +for (const [key, value] of Object.entries(entities)) { + if (isNaN(value)) throw new Error(`${key} must be a valid number`); +} diff --git a/examples/acp-base/subscription/seller.ts b/examples/acp-base/subscription/seller.ts new file mode 100644 index 00000000..9a661097 --- /dev/null +++ b/examples/acp-base/subscription/seller.ts @@ -0,0 +1,151 @@ +/** + * Subscription Example - Seller (Provider) + * + * Demonstrates provider-side handling for both subscription and fixed-price jobs: + * 1. Listen for new jobs + * 2. If job is fixed-price, accept and create PAYABLE_REQUEST + * 3. If job is subscription, check account status via getSubscriptionPaymentRequirement + * 4. If no active subscription, create PAYABLE_REQUEST_SUBSCRIPTION + * 5. Deliver once job reaches TRANSACTION phase + */ +import AcpClient, { + AcpContractClientV2, + AcpJobPhases, + AcpJob, + AcpMemo, + FareAmount, + MemoType, + PriceType, + DeliverablePayload, + baseSepoliaAcpConfigV2, +} from "../../../src/index"; +import { + SELLER_AGENT_WALLET_ADDRESS, + SELLER_ENTITY_ID, + WHITELISTED_WALLET_PRIVATE_KEY, +} from "./env"; + +async function seller() { + console.log("=== Subscription Example - Seller ===\n"); + + const acpClient = new AcpClient({ + acpContractClient: await AcpContractClientV2.build( + WHITELISTED_WALLET_PRIVATE_KEY, + SELLER_ENTITY_ID, + SELLER_AGENT_WALLET_ADDRESS, + baseSepoliaAcpConfigV2, + ), + + onNewTask: async (job: AcpJob, memoToSign?: AcpMemo) => { + console.log(`Seller: onNewTask - Job ${job.id}, phase: ${AcpJobPhases[job.phase]}, memoToSign: ${memoToSign?.id ?? "None"}, nextPhase: ${memoToSign?.nextPhase !== undefined ? AcpJobPhases[memoToSign.nextPhase] : "None"}`); + + // PHASE 1: Handle new job request — check subscription + if ( + job.phase === AcpJobPhases.REQUEST && + memoToSign?.nextPhase === AcpJobPhases.NEGOTIATION + ) { + await handleSubscriptionCheck(acpClient, job); + } + + // PHASE 2: Deliver work (reached directly after subscription payment or normal flow) + else if (job.phase === AcpJobPhases.TRANSACTION) { + const deliverable: DeliverablePayload = { + type: "url", + value: "https://example.com/deliverable", + }; + console.log(`Seller: Delivering job ${job.id}`); + const { txnHash: deliverTx } = await job.deliver(deliverable); + console.log(`Seller: Job ${job.id} delivered (tx: ${deliverTx})`); + } + + else if (job.phase === AcpJobPhases.COMPLETED) { + console.log(`Seller: Job ${job.id} completed`); + } else if (job.phase === AcpJobPhases.REJECTED) { + console.log(`Seller: Job ${job.id} rejected`); + } + }, + }); +} + +/** + * Handles pricing logic for incoming jobs: + * - Fixed-price jobs: accept + create PAYABLE_REQUEST + * - Subscription jobs with active subscription: accept + create plain requirement + * - Subscription jobs without active subscription: accept + create PAYABLE_REQUEST_SUBSCRIPTION + */ +async function handleSubscriptionCheck(acpClient: AcpClient, job: AcpJob) { + const offeringName = job.name; + + if (!offeringName) { + console.log(`Seller: Job ${job.id} — No offering name found, rejecting`); + const { txnHash: rejectTx } = await job.reject( + "No offering name associated with this job" + ); + console.log(`Seller: Job ${job.id} — Job rejected (tx: ${rejectTx})`); + return; + } + + // Fixed-price offering — skip subscription check, create payable requirement directly + if (job.priceType !== PriceType.SUBSCRIPTION) { + const { txnHash: acceptTx } = await job.accept("Job accepted"); + console.log(`Seller: Job ${job.id} — Job accepted (tx: ${acceptTx})`); + const fareAmount = new FareAmount(job.priceValue, job.config.baseFare); + const { txnHash: reqTx } = await job.createPayableRequirement( + "Payment required to proceed", + MemoType.PAYABLE_REQUEST, + fareAmount, + ); + console.log(`Seller: Job ${job.id} — Payable requirement created (tx: ${reqTx})`); + return; + } + + const result = await acpClient.getSubscriptionPaymentRequirement( + job.clientAddress, + job.providerAddress, + offeringName, + ); + + if (!result.needsSubscriptionPayment) { + const { txnHash: acceptTx } = await job.accept("Job accepted"); + console.log(`Seller: Job ${job.id} — Job accepted (tx: ${acceptTx})`); + // Subscription is active — create plain requirement (no payment needed) + const { txnHash: reqTx } = await job.createRequirement("Proceeding to delivery"); + console.log(`Seller: Job ${job.id} — Subscription active, requirement created (tx: ${reqTx})`); + return; + } + + const { name: tierName, price: subscriptionPrice, duration: durationSeconds } = + result.tier; + const subscriptionMetadata = JSON.stringify({ + name: tierName, + price: subscriptionPrice, + duration: durationSeconds, + }); + const durationDays = Math.floor(durationSeconds / (24 * 60 * 60)); + console.log( + `Seller: Job ${job.id} — Subscription required. Requesting ${subscriptionPrice} TOKENS for "${tierName}" (${durationDays} days)` + ); + + const fareAmount = new FareAmount(subscriptionPrice, job.config.baseFare); + const { txnHash: acceptTx } = await job.accept( + `Subscription required for "${tierName}"` + ); + console.log(`Seller: Job ${job.id} — Job accepted (tx: ${acceptTx})`); + + const { txnHash: subReqTx } = await job.createPayableRequirement( + subscriptionMetadata, + MemoType.PAYABLE_REQUEST_SUBSCRIPTION, + fareAmount, + undefined, + { duration: durationSeconds }, + ); + console.log( + `Seller: Job ${job.id} — Subscription payment request created (tx: ${subReqTx})` + ); +} + + +seller().catch((error) => { + console.error("Seller error:", error); + process.exit(1); +}); diff --git a/package-lock.json b/package-lock.json index ad97bc56..a1ebccc7 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", + "dotenv": "^17.2.3", "axios": "^1.13.2", "jwt-decode": "^4.0.0", "socket.io-client": "^4.8.1", @@ -4764,6 +4765,9 @@ ] }, "node_modules/@virtuals-protocol/acp-node": { + "version": "0.3.0-beta.11", + "resolved": "https://registry.npmjs.org/@virtuals-protocol/acp-node/-/acp-node-0.3.0-beta.11.tgz", + "integrity": "sha512-V35VZXRmzC2BN5xmObHY6NxO2XR3vz0xt5pUNdLTEtRrcFthB1ew1p1GmpNTMzBHSlrSsh9vko/pcgA+VFxCXA==", "version": "0.3.0-beta.11", "resolved": "https://registry.npmjs.org/@virtuals-protocol/acp-node/-/acp-node-0.3.0-beta.11.tgz", "integrity": "sha512-V35VZXRmzC2BN5xmObHY6NxO2XR3vz0xt5pUNdLTEtRrcFthB1ew1p1GmpNTMzBHSlrSsh9vko/pcgA+VFxCXA==", @@ -4773,6 +4777,7 @@ "@account-kit/infra": "^4.73.0", "@account-kit/smart-contracts": "^4.73.0", "@virtuals-protocol/acp-node": "^0.3.0-beta.9", + "@virtuals-protocol/acp-node": "^0.3.0-beta.9", "ajv": "^8.17.1", "socket.io-client": "^4.8.1", "tsup": "^8.5.0", diff --git a/package.json b/package.json index 945e711f..a0bedc28 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "ajv": "^8.17.1", "axios": "^1.13.2", "jwt-decode": "^4.0.0", + "dotenv": "^17.2.3", "socket.io-client": "^4.8.1", "tsup": "^8.5.0", "viem": "^2.28.2" diff --git a/src/abis/acpAbiV2.ts b/src/abis/acpAbiV2.ts index a2438c9f..90cce730 100644 --- a/src/abis/acpAbiV2.ts +++ b/src/abis/acpAbiV2.ts @@ -83,56 +83,6 @@ const ACP_V2_ABI = [ name: "AccountStatusUpdated", type: "event", }, - { - anonymous: false, - inputs: [ - { - indexed: false, - internalType: "uint256", - name: "jobId", - type: "uint256", - }, - { - indexed: true, - internalType: "address", - name: "evaluator", - type: "address", - }, - { - indexed: false, - internalType: "uint256", - name: "evaluatorFee", - type: "uint256", - }, - ], - name: "ClaimedEvaluatorFee", - type: "event", - }, - { - anonymous: false, - inputs: [ - { - indexed: false, - internalType: "uint256", - name: "jobId", - type: "uint256", - }, - { - indexed: true, - internalType: "address", - name: "provider", - type: "address", - }, - { - indexed: false, - internalType: "uint256", - name: "providerFee", - type: "uint256", - }, - ], - name: "ClaimedProviderFee", - type: "event", - }, { anonymous: false, inputs: [ @@ -184,31 +134,6 @@ const ACP_V2_ABI = [ name: "Paused", type: "event", }, - { - anonymous: false, - inputs: [ - { - indexed: false, - internalType: "uint256", - name: "jobId", - type: "uint256", - }, - { - indexed: true, - internalType: "address", - name: "client", - type: "address", - }, - { - indexed: false, - internalType: "uint256", - name: "amount", - type: "uint256", - }, - ], - name: "RefundedBudget", - type: "event", - }, { anonymous: false, inputs: [ @@ -356,6 +281,13 @@ const ACP_V2_ABI = [ stateMutability: "nonpayable", type: "function", }, + { + inputs: [{ internalType: "uint256", name: "jobId", type: "uint256" }], + name: "claimBudgetFromMemoManager", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, { inputs: [ { internalType: "address", name: "provider", type: "address" }, @@ -469,6 +401,28 @@ const ACP_V2_ABI = [ stateMutability: "nonpayable", type: "function", }, + { + inputs: [ + { internalType: "uint256", name: "jobId", type: "uint256" }, + { internalType: "string", name: "content", type: "string" }, + { internalType: "address", name: "token", type: "address" }, + { internalType: "uint256", name: "amount", type: "uint256" }, + { internalType: "address", name: "recipient", type: "address" }, + { internalType: "uint256", name: "feeAmount", type: "uint256" }, + { internalType: "enum ACPTypes.FeeType", name: "feeType", type: "uint8" }, + { internalType: "uint256", name: "duration", type: "uint256" }, + { internalType: "uint256", name: "expiredAt", type: "uint256" }, + { + internalType: "enum ACPTypes.JobPhase", + name: "nextPhase", + type: "uint8", + }, + ], + name: "createSubscriptionMemo", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "nonpayable", + type: "function", + }, { inputs: [ { internalType: "address", name: "provider", type: "address" }, @@ -538,6 +492,7 @@ const ACP_V2_ABI = [ type: "uint256", }, { internalType: "bool", name: "isActive", type: "bool" }, + { internalType: "uint256", name: "expiry", type: "uint256" }, ], internalType: "struct ACPTypes.Account", name: "", @@ -694,13 +649,6 @@ const ACP_V2_ABI = [ stateMutability: "view", type: "function", }, - { - inputs: [], - name: "getPhases", - outputs: [{ internalType: "string[7]", name: "", type: "string[7]" }], - stateMutability: "pure", - type: "function", - }, { inputs: [{ internalType: "bytes32", name: "role", type: "bytes32" }], name: "getRoleAdmin", @@ -744,16 +692,6 @@ const ACP_V2_ABI = [ stateMutability: "nonpayable", type: "function", }, - { - inputs: [ - { internalType: "uint256", name: "jobId", type: "uint256" }, - { internalType: "address", name: "account", type: "address" }, - ], - name: "isJobEvaluator", - outputs: [{ internalType: "bool", name: "", type: "bool" }], - stateMutability: "view", - type: "function", - }, { inputs: [], name: "jobManager", @@ -857,6 +795,13 @@ const ACP_V2_ABI = [ stateMutability: "nonpayable", type: "function", }, + { + inputs: [{ internalType: "uint256", name: "jobId", type: "uint256" }], + name: "setupEscrowFromMemoManager", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, { inputs: [ { internalType: "uint256", name: "memoId", type: "uint256" }, @@ -882,6 +827,16 @@ const ACP_V2_ABI = [ stateMutability: "nonpayable", type: "function", }, + { + inputs: [ + { internalType: "uint256", name: "accountId", type: "uint256" }, + { internalType: "uint256", name: "duration", type: "uint256" }, + ], + name: "updateAccountExpiryFromMemoManager", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, { inputs: [ { internalType: "uint256", name: "accountId", type: "uint256" }, diff --git a/src/abis/acpV2X402Abi.ts b/src/abis/acpV2X402Abi.ts index 9ecb87a1..479542b3 100644 --- a/src/abis/acpV2X402Abi.ts +++ b/src/abis/acpV2X402Abi.ts @@ -510,6 +510,7 @@ const ACP_V2_X402_ABI = [ type: "uint256", }, { internalType: "bool", name: "isActive", type: "bool" }, + { internalType: "uint256", name: "expiry", type: "uint256" }, ], internalType: "struct ACPTypes.Account", name: "", diff --git a/src/acpAccount.ts b/src/acpAccount.ts index 127fade2..88f56a53 100644 --- a/src/acpAccount.ts +++ b/src/acpAccount.ts @@ -7,9 +7,17 @@ export class AcpAccount { public id: number, public clientAddress: Address, public providerAddress: Address, - public metadata: Record + public metadata: Record, + public expiry?: number ) {} + isSubscriptionValid(): boolean { + if (!this.expiry || this.expiry === 0) { + return false; + } + return this.expiry > Math.floor(Date.now() / 1000); + } + async updateMetadata(metadata: Record) { const hash = await this.contractClient.updateAccountMetadata( this.id, diff --git a/src/acpClient.ts b/src/acpClient.ts index bce7dfce..9f742cff 100644 --- a/src/acpClient.ts +++ b/src/acpClient.ts @@ -8,7 +8,7 @@ import BaseAcpContractClient, { } from "./contractClients/baseAcpContractClient"; import AcpJob from "./acpJob"; import AcpMemo from "./acpMemo"; -import AcpJobOffering, { PriceType } from "./acpJobOffering"; +import AcpJobOffering from "./acpJobOffering"; import { IAcpAgent, AcpAgentSort, @@ -17,6 +17,9 @@ import { AcpOnlineStatus, IAcpAccount, IAcpClientOptions, + ISubscriptionCheckResponse, + ISubscriptionTier, + SubscriptionPaymentRequirementResult, IAcpJob, IAcpMemoData, IAcpResponse, @@ -51,8 +54,6 @@ interface IAcpBrowseAgentsOptions { cluster?: string; sortBy?: AcpAgentSort[]; topK?: number; - sort_by?: AcpAgentSort[]; // deprecated - top_k?: number; // deprecated graduationStatus?: AcpGraduationStatus; onlineStatus?: AcpOnlineStatus; showHiddenOfferings?: boolean; @@ -203,7 +204,8 @@ class AcpClient { }); return response.data.data; - } catch (err) { + } catch (err: any) { + console.log("err->>", err.response.data); throw new AcpError("Failed to verify auth challenge", err); } } @@ -282,10 +284,19 @@ class AcpClient { if (this.onNewTask) { const job = this._hydrateJob(data); - this.onNewTask( - job, - job.memos.find((m) => m.id == data.memoToSign), - ); + if (job.phase === AcpJobPhases.EXPIRED) { + console.warn(`onNewTask skipped for job ${data.id}: job has expired`); + return; + } + + try { + await this.onNewTask( + job, + job.memos.find((m) => m.id == data.memoToSign), + ); + } catch (err) { + console.error(`onNewTask error for job ${data.id}:`, err); + } } }); @@ -322,8 +333,6 @@ class AcpClient { errCallback(err); } else if (err.response?.data.error?.message) { throw new AcpError(err.response?.data.error.message as string); - } else { - throw new AcpError(`Failed to fetch ${url}: ${err.message}`, err); } } else { throw new AcpError( @@ -348,7 +357,7 @@ class AcpClient { memo.status, memo.senderAddress, memo.signedReason, - memo.expiry ? new Date(Number(memo.expiry) * 1000) : undefined, + memo.expiry ? new Date(parseInt(memo.expiry) * 1000) : undefined, memo.payableDetails, memo.txHash, memo.signedTxHash, @@ -418,28 +427,18 @@ class AcpClient { id: agent.id, name: agent.name, description: agent.description, - jobOfferings: agent.jobs - .filter( - (offering) => - offering.priceV2?.value != null || offering.price != null, - ) - .map((offering) => { - const price = offering.priceV2?.value ?? offering.price!; - - const priceType = offering.priceV2?.type ?? PriceType.FIXED; - - return new AcpJobOffering( - this, - acpContractClient, - agent.walletAddress, - offering.name, - price, - priceType, - offering.requiredFunds, - offering.requirement, - offering.deliverable, - ); - }), + jobOfferings: agent.jobs.map((jobs) => { + return new AcpJobOffering( + this, + acpContractClient, + agent.walletAddress, + jobs.name, + jobs.priceV2.value, + jobs.priceV2.type, + jobs.requirement, + jobs.subscriptionTiers ?? [], + ); + }), contractAddress: agent.contractAddress, twitterHandle: agent.twitterHandle, walletAddress: agent.walletAddress, @@ -451,13 +450,11 @@ class AcpClient { async browseAgents( keyword: string, options: IAcpBrowseAgentsOptions = {}, - ): Promise { + ): Promise { const { cluster, sortBy, topK = 5, - sort_by, - top_k = 5, graduationStatus, onlineStatus, showHiddenOfferings, @@ -467,12 +464,11 @@ class AcpClient { search: keyword, }; - params.top_k = topK || top_k; + params.top_k = topK; params.walletAddressesToExclude = this.walletAddress; - const sortByArray = sortBy || sort_by; - if (sortByArray && sortByArray.length > 0) { - params.sortBy = sortByArray.join(","); + if (sortBy && sortBy.length > 0) { + params.sortBy = sortBy.join(","); } if (cluster) { @@ -521,6 +517,8 @@ class AcpClient { fareAmount: FareAmountBase, evaluatorAddress?: Address, expiredAt: Date = new Date(Date.now() + 1000 * 60 * 60 * 24), + offeringName?: string, + preferredSubscriptionTier?: string, ) { if (providerAddress === this.walletAddress) { throw new AcpError( @@ -528,11 +526,95 @@ class AcpClient { ); } - const account = await this.getByClientAndProvider( - this.walletAddress, - providerAddress, - this.acpContractClient, - ); + // When no offeringName, account and subscriptionTier stay default (null / "") + + let account: AcpAccount | null = null; + let subscriptionTier = ""; + let selectedTierDetails: ISubscriptionTier | null = null; + + if (offeringName) { + const raw = await this.getByClientAndProvider( + this.walletAddress, + providerAddress, + this.acpContractClient, + offeringName, + ); + const subscriptionCheck = + raw && typeof raw === "object" && "accounts" in raw + ? (raw as ISubscriptionCheckResponse) + : null; + + if (subscriptionCheck && !preferredSubscriptionTier) { + const validAccount = this._getValidSubscriptionAccountFromResponse( + subscriptionCheck, + this.acpContractClient, + ); + if (validAccount) { + account = validAccount; + subscriptionTier = ""; + } + } else if (subscriptionCheck) { + const expiryZero = + this._getAccountWithExpiryZeroFromResponse(subscriptionCheck); + if (expiryZero) { + account = new AcpAccount( + this.acpContractClient, + expiryZero.id, + expiryZero.clientAddress, + expiryZero.providerAddress, + expiryZero.metadata, + expiryZero.expiry, + ); + selectedTierDetails = + expiryZero.metadata?.name != null + ? { + name: expiryZero.metadata.name, + price: expiryZero.metadata.price ?? 0, + duration: expiryZero.metadata.duration ?? 0, + } + : null; + } else { + const tierName = preferredSubscriptionTier ?? ""; + subscriptionTier = tierName; + const tierAccount = subscriptionCheck.accounts?.find( + (a) => a.metadata?.name === tierName, + ); + selectedTierDetails = tierAccount?.metadata?.name + ? { + name: tierAccount.metadata.name, + price: tierAccount.metadata.price ?? 0, + duration: tierAccount.metadata.duration ?? 0, + } + : null; + const createPayload = this.acpContractClient.createAccount( + providerAddress, + tierName, + ); + if (createPayload) { + const { userOpHash: createUserOpHash } = + await this.acpContractClient.handleOperation([createPayload]); + const newAccountId = + await this.acpContractClient.getAccountIdFromUserOpHash( + createUserOpHash, + ); + if (newAccountId != null) { + account = new AcpAccount( + this.acpContractClient, + newAccountId, + this.walletAddress, + providerAddress, + { name: tierName }, + 0, + ); + } else { + account = null; + } + } else { + account = null; + } + } + } + } const isV1 = [ baseSepoliaAcpConfig.contractAddress, @@ -554,29 +636,59 @@ class AcpClient { const isX402Job = this.acpContractClient.config.x402Config && isUsdcPaymentToken; - const createJobPayload = - isV1 || !account - ? this.acpContractClient.createJob( - providerAddress, - evaluatorAddress || defaultEvaluatorAddress, - expiredAt, - fareAmount.fare.contractAddress, - fareAmount.amount, - "", - isX402Job, - ) - : this.acpContractClient.createJobWithAccount( + // For subscription jobs, include full tier details as account metadata + const subscriptionMetadata = + subscriptionTier && selectedTierDetails + ? JSON.stringify({ + name: selectedTierDetails.name, + price: selectedTierDetails.price, + duration: selectedTierDetails.duration, + }) + : subscriptionTier; + + const createJobOperations: OperationPayload[] = []; + + if (isV1 || !account) { + createJobOperations.push( + this.acpContractClient.createJob( + providerAddress, + evaluatorAddress || defaultEvaluatorAddress, + expiredAt, + fareAmount.fare.contractAddress, + fareAmount.amount, + subscriptionMetadata, + isX402Job, + ), + ); + } else { + createJobOperations.push( + this.acpContractClient.createJobWithAccount( + account.id, + evaluatorAddress || defaultEvaluatorAddress, + fareAmount.amount, + fareAmount.fare.contractAddress, + expiredAt, + isX402Job, + ), + ); + + // Batch account metadata update with job creation for subscription jobs + if (selectedTierDetails) { + createJobOperations.push( + this.acpContractClient.updateAccountMetadata( account.id, - evaluatorAddress || defaultEvaluatorAddress, - fareAmount.amount, - fareAmount.fare.contractAddress, - expiredAt, - isX402Job, - ); + JSON.stringify({ + name: selectedTierDetails.name, + price: selectedTierDetails.price, + duration: selectedTierDetails.duration, + }), + ), + ); + } + } - const { userOpHash } = await this.acpContractClient.handleOperation([ - createJobPayload, - ]); + const { userOpHash } = + await this.acpContractClient.handleOperation(createJobOperations); const jobId = await this.acpContractClient.getJobId( userOpHash, @@ -729,40 +841,213 @@ class AcpClient { account.clientAddress, account.providerAddress, account.metadata, + account.expiry, ); } + /** + * Gets account or subscription data for a client–provider pair. + * When offeringName is provided, the backend may return subscription tiers and accounts + * (ISubscriptionCheckResponse). When not provided, returns a single AcpAccount or null. + */ async getByClientAndProvider( clientAddress: Address, providerAddress: Address, acpContractClient?: BaseAcpContractClient, - ) { - const response = await this._fetch( - `/accounts/client/${clientAddress}/provider/${providerAddress}`, - "GET", - {}, - {}, - (err) => { - if (err.response?.status === 404) { - return; - } - throw new AcpError("Failed to get account by client and provider", err); - }, - ); + offeringName?: string, + ): Promise { + let endpoint = `/accounts/client/${clientAddress}/provider/${providerAddress}`; + + if (offeringName) { + endpoint = `/accounts/sub/client/${clientAddress}/provider/${providerAddress}`; + } + + const response = await this._fetch< + IAcpAccount | ISubscriptionCheckResponse + >(endpoint, "GET", {}, {}, (err) => { + if (err.response?.status === 404) { + return; + } + throw new AcpError("Failed to get account by client and provider", err); + }); if (!response) { return null; } + // Subscription response shape (has accounts array) + if ( + typeof response === "object" && + "accounts" in response && + Array.isArray((response as ISubscriptionCheckResponse).accounts) + ) { + return response as ISubscriptionCheckResponse; + } + + // Single account response + const account = response as IAcpAccount; return new AcpAccount( acpContractClient || this.contractClients[0], - response.id, - response.clientAddress, - response.providerAddress, - response.metadata, + account.id, + account.clientAddress, + account.providerAddress, + account.metadata, + account.expiry, ); } + /** + * Returns the first subscription account with expiry > now, or null. + */ + private _getValidSubscriptionAccountFromResponse( + response: ISubscriptionCheckResponse, + acpContractClient: BaseAcpContractClient, + ): AcpAccount | null { + const now = Math.floor(Date.now() / 1000); + const valid = response.accounts?.find( + (a) => a.expiry != null && a.expiry > now, + ); + if (!valid) return null; + return new AcpAccount( + acpContractClient, + valid.id, + valid.clientAddress, + valid.providerAddress, + valid.metadata, + valid.expiry, + ); + } + + /** + * Returns the first account with expiry === 0 or no expiry (unactivated), or null. + */ + private _getAccountWithExpiryZeroFromResponse( + response: ISubscriptionCheckResponse, + ): IAcpAccount | null { + return ( + (response.accounts ?? []).find( + (a) => a.expiry == null || a.expiry === 0, + ) ?? null + ); + } + + /** + * Seller-facing: determines whether to create a subscription payment request memo. + * Call this when handling a new job (e.g. in REQUEST phase); then branch on + * needsSubscriptionPayment and use tier when true. + */ + async getSubscriptionPaymentRequirement( + clientAddress: Address, + providerAddress: Address, + offeringName: string, + ): Promise { + let raw: AcpAccount | ISubscriptionCheckResponse | null; + try { + raw = await this.getByClientAndProvider( + clientAddress, + providerAddress, + undefined, + offeringName, + ); + } catch { + return { + needsSubscriptionPayment: false, + action: "no_subscription_required", + }; + } + + const response = + raw && typeof raw === "object" && "accounts" in raw + ? (raw as ISubscriptionCheckResponse) + : null; + + if (!response || !response.accounts) { + return { + needsSubscriptionPayment: false, + action: "no_subscription_required", + }; + } + + if (!response.accounts.length) { + return { + needsSubscriptionPayment: false, + action: "no_subscription_required", + }; + } + + const now = Math.floor(Date.now() / 1000); + const hasValidSubscription = response.accounts.some( + (a) => a.expiry != null && a.expiry > now, + ); + if (hasValidSubscription) { + return { + needsSubscriptionPayment: false, + action: "valid_subscription", + }; + } + + const firstAccount = response.accounts[0]; + const tier: ISubscriptionTier = { + name: firstAccount.metadata?.name ?? "", + price: firstAccount.metadata?.price ?? 0, + duration: firstAccount.metadata?.duration ?? 0, + }; + return { + needsSubscriptionPayment: true, + tier, + }; + } + + async getValidSubscriptionAccount( + providerAddress: Address, + offeringName: string, + clientAddress: Address, + acpContractClient?: BaseAcpContractClient, + ): Promise { + const raw = await this.getByClientAndProvider( + clientAddress, + providerAddress, + acpContractClient, + offeringName, + ); + + const subscriptionCheck = + raw && typeof raw === "object" && "accounts" in raw + ? (raw as ISubscriptionCheckResponse) + : null; + + if (!subscriptionCheck) return null; + + const contractClient = acpContractClient || this.contractClients[0]; + const account = this._getValidSubscriptionAccountFromResponse( + subscriptionCheck, + contractClient, + ); + if (account) return account; + + // Legacy shape: optional account / hasValidSubscription from backend + const legacy = subscriptionCheck as ISubscriptionCheckResponse & { + subscriptionRequired?: boolean; + hasValidSubscription?: boolean; + account?: IAcpAccount; + }; + if ( + legacy.subscriptionRequired && + legacy.hasValidSubscription && + legacy.account + ) { + return new AcpAccount( + contractClient, + legacy.account.id, + legacy.account.clientAddress, + legacy.account.providerAddress, + legacy.account.metadata, + legacy.account.expiry, + ); + } + return null; + } + async createMemoContent(jobId: number, content: string) { const response = await this._fetch( `/memo-contents`, diff --git a/src/acpFare.ts b/src/acpFare.ts index 6ab15e5b..150b83a1 100644 --- a/src/acpFare.ts +++ b/src/acpFare.ts @@ -106,7 +106,10 @@ class FareAmount extends FareAmountBase { } add(other: FareAmountBase) { - if (this.fare.contractAddress !== other.fare.contractAddress) { + if ( + this.fare.contractAddress.toLowerCase() !== + other.fare.contractAddress.toLowerCase() + ) { throw new Error("Token addresses do not match"); } diff --git a/src/acpJob.ts b/src/acpJob.ts index adc43975..b60219c1 100644 --- a/src/acpJob.ts +++ b/src/acpJob.ts @@ -146,16 +146,85 @@ class AcpJob { return await this.acpContractClient.handleOperation(operations); } + async acceptRequirement(memo: AcpMemo, reason?: string) { + const operations: OperationPayload[] = []; + + operations.push( + this.acpContractClient.signMemo( + memo.id, + true, + reason ?? "Requirement accepted" + ) + ); + + operations.push( + this.acpContractClient.createMemo( + this.id, + reason ?? "Proceeding to delivery", + MemoType.MESSAGE, + true, + AcpJobPhases.TRANSACTION + ) + ); + + return await this.acpContractClient.handleOperation(operations); + } + async createPayableRequirement( content: string, type: | MemoType.PAYABLE_REQUEST | MemoType.PAYABLE_TRANSFER_ESCROW - | MemoType.PAYABLE_TRANSFER, + | MemoType.PAYABLE_TRANSFER + | MemoType.PAYABLE_REQUEST_SUBSCRIPTION, amount: FareAmountBase, - recipient: Address, - expiredAt: Date = new Date(Date.now() + 1000 * 60 * 5) // 5 minutes + recipient?: Address, + options?: { + expiredAt?: Date; + duration?: number; // Required for PAYABLE_REQUEST_SUBSCRIPTION + nextPhase?: AcpJobPhases; + } ) { + const expiredAt = options?.expiredAt ?? new Date(Date.now() + 1000 * 60 * 5); // 5 minutes + const nextPhase = options?.nextPhase ?? AcpJobPhases.TRANSACTION; + const finalRecipient = recipient ?? this.providerAddress; + + // Validate subscription-specific requirements + if (type === MemoType.PAYABLE_REQUEST_SUBSCRIPTION && !options?.duration) { + throw new AcpError("Duration is required for subscription payment requests"); + } + + if (type === MemoType.PAYABLE_REQUEST_SUBSCRIPTION) { + this.priceType = PriceType.SUBSCRIPTION; + + if (this.priceValue !== 0) { + throw new AcpError( + `Subscription payment request zero budget, got: ${this.priceValue}` + ); + } + + let parsed: Record; + try { + const result = JSON.parse(content); + if (typeof result !== "object" || result === null || Array.isArray(result)) { + throw new Error(); + } + parsed = result; + } catch { + throw new AcpError( + `Subscription memo content must be a JSON object string, got: ${content}` + ); + } + const missing = (["name", "duration", "price"] as const).filter( + (k) => !(k in parsed) + ); + if (missing.length > 0) { + throw new AcpError( + `Subscription memo content is missing required fields: ${missing.join(", ")}` + ); + } + } + const operations: OperationPayload[] = []; if (type === MemoType.PAYABLE_TRANSFER_ESCROW) { @@ -171,7 +240,27 @@ class AcpJob { const isPercentagePricing: boolean = this.priceType === PriceType.PERCENTAGE; - if ( + // Handle subscription payment request + if (type === MemoType.PAYABLE_REQUEST_SUBSCRIPTION) { + operations.push( + this.acpContractClient.createSubscriptionMemo( + this.id, + content, + amount.amount, + finalRecipient, + isPercentagePricing + ? BigInt(Math.round(this.priceValue * 10000)) + : feeAmount.amount, + isPercentagePricing ? FeeType.PERCENTAGE_FEE : FeeType.NO_FEE, + options?.duration!, + nextPhase, + expiredAt, + amount.fare.contractAddress + ) + ); + } + // Handle cross-chain payable + else if ( amount.fare.chainId && amount.fare.chainId !== this.acpContractClient.config.chain.id ) { @@ -181,29 +270,31 @@ class AcpJob { content, amount.fare.contractAddress, amount.amount, - recipient, + finalRecipient, isPercentagePricing ? BigInt(Math.round(this.priceValue * 10000)) // convert to basis points : feeAmount.amount, isPercentagePricing ? FeeType.PERCENTAGE_FEE : FeeType.NO_FEE, type as MemoType.PAYABLE_REQUEST, expiredAt, - AcpJobPhases.TRANSACTION, + nextPhase, getDestinationEndpointId(amount.fare.chainId as number) ) ); - } else { + } + // Handle regular payable + else { operations.push( this.acpContractClient.createPayableMemo( this.id, content, amount.amount, - recipient, + finalRecipient, isPercentagePricing ? BigInt(Math.round(this.priceValue * 10000)) // convert to basis points : feeAmount.amount, isPercentagePricing ? FeeType.PERCENTAGE_FEE : FeeType.NO_FEE, - AcpJobPhases.TRANSACTION, + nextPhase, type, expiredAt, amount.fare.contractAddress @@ -246,11 +337,13 @@ class AcpJob { ) : new FareAmount(0, this.baseFare); - const totalAmount = - baseFareAmount.fare.contractAddress === - transferAmount.fare.contractAddress - ? baseFareAmount.add(transferAmount) - : baseFareAmount; + const sameToken = + baseFareAmount.fare.contractAddress.toLowerCase() === + transferAmount.fare.contractAddress.toLowerCase(); + + const totalAmount = sameToken + ? baseFareAmount.add(transferAmount) + : baseFareAmount; operations.push( this.acpContractClient.approveAllowance( @@ -259,10 +352,7 @@ class AcpJob { ) ); - if ( - baseFareAmount.fare.contractAddress !== - transferAmount.fare.contractAddress - ) { + if (!sameToken) { operations.push( this.acpContractClient.approveAllowance( transferAmount.amount, @@ -500,6 +590,59 @@ class AcpJob { return await this.acpContractClient.handleOperation(operations); } + async paySubscription(reason?: string) { + if (this.phase === AcpJobPhases.EXPIRED) { + throw new AcpError("Job has expired, cannot process subscription payment"); + } + + if (this.phase === AcpJobPhases.COMPLETED) { + throw new AcpError( + "Job is already completed, cannot process subscription payment" + ); + } + + const memo = this.memos.find( + (m) => m.type === MemoType.PAYABLE_REQUEST_SUBSCRIPTION + ); + + if (!memo) { + throw new AcpError("No subscription payment request memo found"); + } + + if (!memo.payableDetails) { + throw new AcpError("Subscription memo has no payable details"); + } + + const operations: OperationPayload[] = []; + + operations.push( + this.acpContractClient.approveAllowance( + memo.payableDetails.amount, + memo.payableDetails.token + ) + ); + + operations.push( + this.acpContractClient.signMemo( + memo.id, + true, + reason || "Subscription payment approved" + ) + ); + + operations.push( + this.acpContractClient.createMemo( + this.id, + `Subscription payment made. ${reason ?? ""}`.trim(), + MemoType.MESSAGE, + true, + memo.nextPhase + ) + ); + + return await this.acpContractClient.handleOperation(operations); + } + async evaluate(accept: boolean, reason?: string) { if (this.latestMemo?.nextPhase !== AcpJobPhases.COMPLETED) { throw new AcpError("No evaluation memo found"); diff --git a/src/acpJobOffering.ts b/src/acpJobOffering.ts index d7ac515d..3a84615a 100644 --- a/src/acpJobOffering.ts +++ b/src/acpJobOffering.ts @@ -15,10 +15,13 @@ import { baseSepoliaAcpX402Config, } from "./configs/acpConfigs"; import { USDC_TOKEN_ADDRESS } from "./constants"; +import { AcpAccount } from "./acpAccount"; +import { IAcpAccount, ISubscriptionCheckResponse } from "./interfaces"; export enum PriceType { FIXED = "fixed", PERCENTAGE = "percentage", + SUBSCRIPTION = "subscription", } class AcpJobOffering { private ajv: Ajv; @@ -29,10 +32,9 @@ class AcpJobOffering { public providerAddress: Address, public name: string, public price: number, - public priceType: PriceType, - public requiredFunds: boolean, + public priceType: PriceType = PriceType.FIXED, public requirement?: Object | string, - public deliverable?: Object | string + public subscriptionTiers: string[] = [], ) { this.ajv = new Ajv({ allErrors: true }); } @@ -40,41 +42,165 @@ class AcpJobOffering { async initiateJob( serviceRequirement: Object | string, evaluatorAddress?: Address, - expiredAt: Date = new Date(Date.now() + 1000 * 60 * 60 * 24) // default: 1 day + expiredAt: Date = new Date(Date.now() + 1000 * 60 * 60 * 24), // default: 1 day + preferredSubscriptionTier?: string, ) { + this.validateRequest(serviceRequirement); + + const subscriptionRequired = this.isSubscriptionRequired(preferredSubscriptionTier); + this.validateSubscriptionTier(preferredSubscriptionTier); + + const effectivePrice = subscriptionRequired ? 0 : this.price; + const effectivePriceType = subscriptionRequired + ? PriceType.SUBSCRIPTION + : this.priceType === PriceType.SUBSCRIPTION + ? PriceType.FIXED + : this.priceType; + + const fareAmount = new FareAmount( + effectivePriceType === PriceType.FIXED ? effectivePrice : 0, + this.acpContractClient.config.baseFare, + ); + + const account = await this.resolveAccount( + subscriptionRequired, + preferredSubscriptionTier, + ); + + const jobId = await this.createJob( + account, + evaluatorAddress, + expiredAt, + fareAmount, + subscriptionRequired, + preferredSubscriptionTier ?? "", + ); + + await this.sendInitialMemo(jobId, fareAmount, subscriptionRequired, { + name: this.name, + requirement: serviceRequirement, + priceValue: effectivePrice, + priceType: effectivePriceType, + }); + + return jobId; + } + + private validateRequest(serviceRequirement: Object | string) { if (this.providerAddress === this.acpClient.walletAddress) { throw new AcpError( - "Provider address cannot be the same as the client address" + "Provider address cannot be the same as the client address", ); } if (this.requirement && typeof this.requirement === "object") { const validator = this.ajv.compile(this.requirement); - const valid = validator(serviceRequirement); - - if (!valid) { + if (!validator(serviceRequirement)) { throw new AcpError(this.ajv.errorsText(validator.errors)); } } + } - const finalServiceRequirement: Record = { - name: this.name, - requirement: serviceRequirement, - priceValue: this.price, - priceType: this.priceType, - }; - - const fareAmount = new FareAmount( - this.priceType === PriceType.FIXED ? this.price : 0, - this.acpContractClient.config.baseFare + private isSubscriptionRequired(preferredSubscriptionTier?: string): boolean { + const hasSubscriptionTiers = this.subscriptionTiers.length > 0; + return ( + preferredSubscriptionTier != null || + (this.priceType === PriceType.SUBSCRIPTION && hasSubscriptionTiers) ); + } + + private validateSubscriptionTier(preferredSubscriptionTier?: string) { + if (!preferredSubscriptionTier) return; - const account = await this.acpClient.getByClientAndProvider( + if (this.subscriptionTiers.length === 0) { + throw new AcpError( + `Offering "${this.name}" does not support subscription tiers`, + ); + } + if (!this.subscriptionTiers.includes(preferredSubscriptionTier)) { + throw new AcpError( + `Preferred subscription tier "${preferredSubscriptionTier}" is not offered. Available: ${this.subscriptionTiers.join(", ")}`, + ); + } + } + + /** + * Resolve the account to use for the job. + * + * For non-subscription jobs: returns the existing account if found. + * For subscription jobs, priority: + * 1. Valid account matching preferred tier + * 2. Any valid (non-expired) account + * 3. Expired/unactivated account (expiry = 0) to reuse + * 4. null — createJob will create a new one + */ + private async resolveAccount( + subscriptionRequired: boolean, + preferredSubscriptionTier?: string, + ): Promise { + const raw = await this.acpClient.getByClientAndProvider( this.acpContractClient.walletAddress, this.providerAddress, - this.acpContractClient + this.acpContractClient, + subscriptionRequired ? this.name : undefined, ); + if (!subscriptionRequired) { + return raw instanceof AcpAccount ? raw : null; + } + + const subscriptionCheck = + raw && typeof raw === "object" && "accounts" in raw + ? (raw as ISubscriptionCheckResponse) + : null; + + if (!subscriptionCheck) return null; + + const now = Math.floor(Date.now() / 1000); + const allAccounts = subscriptionCheck.accounts ?? []; + + const matchedAccount = + this.findPreferredAccount(allAccounts, preferredSubscriptionTier, now) + ?? allAccounts.find((a) => a.expiry != null && a.expiry > now) + ?? allAccounts.find((a) => a.expiry == null || a.expiry === 0); + + if (!matchedAccount) return null; + + return new AcpAccount( + this.acpContractClient, + matchedAccount.id, + matchedAccount.clientAddress, + matchedAccount.providerAddress, + matchedAccount.metadata, + matchedAccount.expiry, + ); + } + + private findPreferredAccount( + accounts: IAcpAccount[], + preferredTier: string | undefined, + now: number, + ): IAcpAccount | undefined { + if (!preferredTier) return undefined; + + return accounts.find((a) => { + if (a.expiry == null || a.expiry <= now) return false; + const meta = + typeof a.metadata === "string" + ? (() => { try { return JSON.parse(a.metadata); } catch { return {}; } })() + : (a.metadata ?? {}); + return meta?.name === preferredTier; + }); + } + + private async createJob( + account: AcpAccount | null, + evaluatorAddress: Address | undefined, + expiredAt: Date, + fareAmount: FareAmount, + subscriptionRequired: boolean, + subscriptionTier: string, + ): Promise { const isV1 = [ baseSepoliaAcpConfig.contractAddress, baseSepoliaAcpX402Config.contractAddress, @@ -82,75 +208,79 @@ class AcpJobOffering { baseAcpX402Config.contractAddress, ].includes(this.acpContractClient.config.contractAddress); - let createJobPayload: OperationPayload; - const chainId = this.acpContractClient.config.chain .id as keyof typeof USDC_TOKEN_ADDRESS; - const isUsdcPaymentToken = USDC_TOKEN_ADDRESS[chainId].toLowerCase() === fareAmount.fare.contractAddress.toLowerCase(); - const isX402Job = this.acpContractClient.config.x402Config && isUsdcPaymentToken; - if (isV1 || !account) { - createJobPayload = this.acpContractClient.createJob( - this.providerAddress, - evaluatorAddress || this.acpContractClient.walletAddress, - expiredAt, - fareAmount.fare.contractAddress, - fareAmount.amount, - "", - isX402Job - ); - } else { - createJobPayload = this.acpContractClient.createJobWithAccount( - account.id, - evaluatorAddress || zeroAddress, - fareAmount.amount, - fareAmount.fare.contractAddress, - expiredAt, - isX402Job - ); - } + const budget = subscriptionRequired ? 0n : fareAmount.amount; + const subscriptionMetadata = JSON.stringify({ name: subscriptionTier }); + + const operation = + isV1 || !account + ? this.acpContractClient.createJob( + this.providerAddress, + evaluatorAddress || this.acpContractClient.walletAddress, + expiredAt, + fareAmount.fare.contractAddress, + budget, + subscriptionMetadata, + isX402Job, + ) + : this.acpContractClient.createJobWithAccount( + account.id, + evaluatorAddress || zeroAddress, + budget, + fareAmount.fare.contractAddress, + expiredAt, + isX402Job, + ); - const { userOpHash } = await this.acpContractClient.handleOperation([ - createJobPayload, + const { userOpHash } = await this.acpContractClient.handleOperation([ + operation, ]); - const jobId = await this.acpContractClient.getJobId( + return this.acpContractClient.getJobId( userOpHash, this.acpContractClient.walletAddress, - this.providerAddress + this.providerAddress, ); + } + private async sendInitialMemo( + jobId: number, + fareAmount: FareAmount, + subscriptionRequired: boolean, + serviceRequirement: Record, + ) { const payloads: OperationPayload[] = []; - const setBudgetWithPaymentTokenPayload = - this.acpContractClient.setBudgetWithPaymentToken( - jobId, - fareAmount.amount, - fareAmount.fare.contractAddress - ); - - if (setBudgetWithPaymentTokenPayload) { - payloads.push(setBudgetWithPaymentTokenPayload); + if (!subscriptionRequired) { + const setBudgetPayload = + this.acpContractClient.setBudgetWithPaymentToken( + jobId, + fareAmount.amount, + fareAmount.fare.contractAddress, + ); + if (setBudgetPayload) { + payloads.push(setBudgetPayload); + } } payloads.push( this.acpContractClient.createMemo( jobId, - JSON.stringify(finalServiceRequirement), + JSON.stringify(serviceRequirement), MemoType.MESSAGE, true, - AcpJobPhases.NEGOTIATION - ) + AcpJobPhases.NEGOTIATION, + ), ); await this.acpContractClient.handleOperation(payloads); - - return jobId; } } diff --git a/src/contractClients/acpContractClient.ts b/src/contractClients/acpContractClient.ts index 647f0657..97705b25 100644 --- a/src/contractClients/acpContractClient.ts +++ b/src/contractClients/acpContractClient.ts @@ -42,7 +42,7 @@ class AcpContractClient extends BaseAcpContractClient { constructor( agentWalletAddress: Address, - config: AcpContractConfig = baseAcpConfig + config: AcpContractConfig = baseAcpConfig, ) { super(agentWalletAddress, config); } @@ -51,7 +51,7 @@ class AcpContractClient extends BaseAcpContractClient { walletPrivateKey: Address, sessionEntityKeyId: number, agentWalletAddress: Address, - config: AcpContractConfig = baseAcpConfig + config: AcpContractConfig = baseAcpConfig, ) { const acpContractClient = new AcpContractClient(agentWalletAddress, config); await acpContractClient.init(walletPrivateKey, sessionEntityKeyId); @@ -79,7 +79,7 @@ class AcpContractClient extends BaseAcpContractClient { this._acpX402 = new AcpX402( this.config, this.sessionKeyClient, - this.publicClient + this.publicClient, ); const account = this.sessionKeyClient.account; @@ -89,13 +89,13 @@ class AcpContractClient extends BaseAcpContractClient { if (!(await account.isAccountDeployed())) { throw new AcpError( - `ACP Contract Client validation failed: agent account ${this.agentWalletAddress} is not deployed on-chain` + `ACP Contract Client validation failed: agent account ${this.agentWalletAddress} is not deployed on-chain`, ); } await this.validateSessionKeyOnChain( sessionSignerAddress, - sessionEntityKeyId + sessionEntityKeyId, ); this.RETRY_CONFIG = this.config.retryConfig || this.RETRY_CONFIG; @@ -113,7 +113,7 @@ class AcpContractClient extends BaseAcpContractClient { crypto.getRandomValues(array); let hex = Array.from(array, (b) => b.toString(16).padStart(2, "0")).join( - "" + "", ); return BigInt("0x" + hex); } @@ -144,7 +144,7 @@ class AcpContractClient extends BaseAcpContractClient { } async handleOperation( - operations: OperationPayload[] + operations: OperationPayload[], ): Promise<{ userOpHash: Address; txnHash: Address }> { const basePayload: any = { uo: operations.map((op) => ({ @@ -202,11 +202,10 @@ class AcpContractClient extends BaseAcpContractClient { async getJobId( createJobUserOpHash: Address, clientAddress: Address, - providerAddress: 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"); @@ -225,14 +224,14 @@ class AcpContractClient extends BaseAcpContractClient { }) as { eventName: string; args: any; - } + }, ); const createdJobEvent = contractLogs.find( (log) => log.eventName === "JobCreated" && log.args.client.toLowerCase() === clientAddress.toLowerCase() && - log.args.provider.toLowerCase() === providerAddress.toLowerCase() + log.args.provider.toLowerCase() === providerAddress.toLowerCase(), ); if (!createdJobEvent) { @@ -249,7 +248,7 @@ class AcpContractClient extends BaseAcpContractClient { paymentTokenAddress: Address, budgetBaseUnit: bigint, metadata: string, - isX402Job?: boolean + isX402Job?: boolean, ): OperationPayload { try { const data = encodeFunctionData({ @@ -276,7 +275,7 @@ class AcpContractClient extends BaseAcpContractClient { setBudgetWithPaymentToken( jobId: number, budgetBaseUnit: bigint, - paymentTokenAddress: Address = this.config.baseFare.contractAddress + paymentTokenAddress: Address = this.config.baseFare.contractAddress, ): OperationPayload { try { const data = encodeFunctionData({ @@ -304,10 +303,13 @@ class AcpContractClient extends BaseAcpContractClient { feeAmountBaseUnit: bigint, feeType: FeeType, nextPhase: AcpJobPhases, - type: MemoType.PAYABLE_REQUEST | MemoType.PAYABLE_TRANSFER_ESCROW, + type: + | MemoType.PAYABLE_REQUEST + | MemoType.PAYABLE_TRANSFER_ESCROW + | MemoType.PAYABLE_REQUEST_SUBSCRIPTION, expiredAt: Date, token: Address = this.config.baseFare.contractAddress, - secured: boolean = true + secured: boolean = true, ): OperationPayload { try { const data = encodeFunctionData({ @@ -344,7 +346,7 @@ class AcpContractClient extends BaseAcpContractClient { budgetBaseUnit: bigint, paymentTokenAddress: Address, expiredAt: Date, - isX402Job?: boolean + isX402Job?: boolean, ): OperationPayload { throw new AcpError("Not Supported"); } @@ -359,7 +361,7 @@ class AcpContractClient extends BaseAcpContractClient { async generateX402Payment( payableRequest: X402PayableRequest, - requirements: X402PayableRequirements + requirements: X402PayableRequirements, ): Promise { return await this.acpX402.generatePayment(payableRequest, requirements); } @@ -368,7 +370,7 @@ class AcpContractClient extends BaseAcpContractClient { url: string, version: string, budget?: string, - signature?: string + signature?: string, ): Promise { return await this.acpX402.performRequest(url, version, budget, signature); } diff --git a/src/contractClients/acpContractClientV2.ts b/src/contractClients/acpContractClientV2.ts index a0ef061b..01fdb2b3 100644 --- a/src/contractClients/acpContractClientV2.ts +++ b/src/contractClients/acpContractClientV2.ts @@ -354,6 +354,42 @@ class AcpContractClientV2 extends BaseAcpContractClient { return Number(createdJobEvent.args.jobId); } + async getAccountIdFromUserOpHash(userOpHash: Address): Promise { + const result = await this.sessionKeyClient.getUserOperationReceipt( + userOpHash, + "pending" + ); + + if (!result || !result.logs?.length) { + return null; + } + + const contractAddresses = [ + this.contractAddress.toLowerCase(), + this.accountManagerAddress.toLowerCase(), + ]; + + for (const log of result.logs) { + if (!contractAddresses.includes((log as any).address?.toLowerCase())) { + continue; + } + try { + const decoded = decodeEventLog({ + abi: this.abi, + data: (log as any).data, + topics: (log as any).topics, + }); + if (decoded.eventName === "AccountCreated") { + const args = decoded.args as { accountId?: bigint }; + if (args?.accountId != null) return Number(args.accountId); + } + } catch { + // not AccountCreated or wrong ABI, skip + } + } + return null; + } + async updateJobX402Nonce(jobId: number, nonce: string): Promise { return await this.acpX402.updateJobNonce(jobId, nonce); } diff --git a/src/contractClients/baseAcpContractClient.ts b/src/contractClients/baseAcpContractClient.ts index aa905efd..cd62ccb8 100644 --- a/src/contractClients/baseAcpContractClient.ts +++ b/src/contractClients/baseAcpContractClient.ts @@ -43,6 +43,7 @@ export enum MemoType { PAYABLE_TRANSFER_ESCROW, // 8 - Escrowed payment transfer NOTIFICATION, // 9 - Notification PAYABLE_NOTIFICATION, // 10 - Payable notification + PAYABLE_REQUEST_SUBSCRIPTION, // 11 - Subscription payment request } export enum AcpJobPhases { @@ -79,14 +80,14 @@ abstract class BaseAcpContractClient { constructor( public agentWalletAddress: Address, - public config: AcpContractConfig = baseAcpConfig + public config: AcpContractConfig = baseAcpConfig, ) { this.chain = config.chain; this.abi = config.abi; this.contractAddress = config.contractAddress; const jobCreated = ACP_ABI.find( - (abi) => abi.name === "JobCreated" + (abi) => abi.name === "JobCreated", ) as AbiEvent; const signature = toEventSignature(jobCreated); this.jobCreatedSignature = keccak256(toHex(signature)); @@ -98,7 +99,7 @@ abstract class BaseAcpContractClient { protected async validateSessionKeyOnChain( sessionSignerAddress: Address, - sessionEntityKeyId: number + sessionEntityKeyId: number, ): Promise { const onChainSignerAddress = ( (await this.publicClient.readContract({ @@ -118,8 +119,8 @@ abstract class BaseAcpContractClient { agentWalletAddress: this.agentWalletAddress, }, null, - 2 - )}` + 2, + )}`, ); } @@ -134,23 +135,58 @@ abstract class BaseAcpContractClient { reason: "session signer address mismatch", }, null, - 2 - )}` + 2, + )}`, ); } } abstract handleOperation( operations: OperationPayload[], - chainId?: number + chainId?: number, ): Promise<{ userOpHash: Address; txnHash: Address }>; abstract getJobId( createJobUserOpHash: Address, clientAddress: Address, - providerAddress: Address + providerAddress: Address, ): Promise; + /** + * Returns a createAccount operation payload if the contract supports it (V2). + * Returns null for V1 or when the ABI does not include createAccount. + */ + createAccount( + providerAddress: Address, + metadata: string, + ): OperationPayload | null { + try { + const hasCreateAccount = (this.abi as any[]).some( + (item: any) => + item.type === "function" && item.name === "createAccount", + ); + if (!hasCreateAccount) return null; + const data = encodeFunctionData({ + abi: this.abi, + functionName: "createAccount", + args: [providerAddress, metadata], + }); + return { + data, + contractAddress: this.contractAddress, + }; + } catch { + return null; + } + } + + /** + * Returns the new account id from a createAccount user op receipt, or null if not supported. + */ + async getAccountIdFromUserOpHash(_userOpHash: Address): Promise { + return null; + } + get walletAddress() { return this.agentWalletAddress; } @@ -161,7 +197,7 @@ abstract class BaseAcpContractClient { budgetBaseUnit: bigint, paymentTokenAddress: Address, expiredAt: Date, - isX402Job?: boolean + isX402Job?: boolean, ): OperationPayload { try { const data = encodeFunctionData({ @@ -196,7 +232,7 @@ abstract class BaseAcpContractClient { paymentTokenAddress: Address, budgetBaseUnit: bigint, metadata: string, - isX402Job?: boolean + isX402Job?: boolean, ): OperationPayload { try { const data = encodeFunctionData({ @@ -226,7 +262,7 @@ abstract class BaseAcpContractClient { approveAllowance( amountBaseUnit: bigint, paymentTokenAddress: Address = this.config.baseFare.contractAddress, - targetAddress?: Address + targetAddress?: Address, ): OperationPayload { try { const data = encodeFunctionData({ @@ -258,10 +294,11 @@ abstract class BaseAcpContractClient { | MemoType.PAYABLE_REQUEST | MemoType.PAYABLE_TRANSFER_ESCROW | MemoType.PAYABLE_TRANSFER - | MemoType.PAYABLE_NOTIFICATION, + | MemoType.PAYABLE_NOTIFICATION + | MemoType.PAYABLE_REQUEST_SUBSCRIPTION, expiredAt: Date, token: Address = this.config.baseFare.contractAddress, - secured: boolean = true + secured: boolean = true, ): OperationPayload { try { const data = encodeFunctionData({ @@ -293,6 +330,47 @@ abstract class BaseAcpContractClient { } } + createSubscriptionMemo( + jobId: number, + content: string, + amountBaseUnit: bigint, + recipient: Address, + feeAmountBaseUnit: bigint, + feeType: FeeType, + duration: number, + nextPhase: AcpJobPhases, + expiredAt: Date, + token: Address = this.config.baseFare.contractAddress, + ): OperationPayload { + try { + const data = encodeFunctionData({ + abi: this.abi, + functionName: "createSubscriptionMemo", + args: [ + jobId, + content, + token, + amountBaseUnit, + recipient, + feeAmountBaseUnit, + feeType, + duration, + Math.floor(expiredAt.getTime() / 1000), + nextPhase, + ], + }); + + const payload: OperationPayload = { + data: data, + contractAddress: this.contractAddress, + }; + + return payload; + } catch (error) { + throw new AcpError("Failed to create subscription memo", error); + } + } + createCrossChainPayableMemo( jobId: number, content: string, @@ -308,7 +386,7 @@ abstract class BaseAcpContractClient { expiredAt: Date, nextPhase: AcpJobPhases, destinationEid: number, - secured: boolean = true + secured: boolean = true, ): OperationPayload { try { const data = encodeFunctionData({ @@ -346,7 +424,7 @@ abstract class BaseAcpContractClient { content: string, type: MemoType, isSecured: boolean, - nextPhase: AcpJobPhases + nextPhase: AcpJobPhases, ): OperationPayload { try { const data = encodeFunctionData({ @@ -372,7 +450,7 @@ abstract class BaseAcpContractClient { type: MemoType, isSecured: boolean, nextPhase: AcpJobPhases, - metadata: string + metadata: string, ): OperationPayload { try { const data = encodeFunctionData({ @@ -395,7 +473,7 @@ abstract class BaseAcpContractClient { signMemo( memoId: number, isApproved: boolean, - reason?: string + reason?: string, ): OperationPayload { try { const data = encodeFunctionData({ @@ -418,7 +496,7 @@ abstract class BaseAcpContractClient { setBudgetWithPaymentToken( jobId: number, budgetBaseUnit: bigint, - paymentTokenAddress: Address = this.config.baseFare.contractAddress + paymentTokenAddress: Address = this.config.baseFare.contractAddress, ): OperationPayload | undefined { return undefined; } @@ -462,7 +540,7 @@ abstract class BaseAcpContractClient { } async getX402PaymentDetails( - jobId: number + jobId: number, ): Promise { try { const result = (await this.publicClient.readContract({ @@ -483,19 +561,19 @@ abstract class BaseAcpContractClient { abstract updateJobX402Nonce( jobId: number, - nonce: string + nonce: string, ): Promise; abstract generateX402Payment( payableRequest: X402PayableRequest, - requirements: X402PayableRequirements + requirements: X402PayableRequirements, ): Promise; abstract performX402Request( url: string, version: string, budget?: string, - signature?: string + signature?: string, ): Promise; async submitTransferWithAuthorization( @@ -505,7 +583,7 @@ abstract class BaseAcpContractClient { validAfter: bigint, validBefore: bigint, nonce: string, - signature: string + signature: string, ): Promise { try { const operations: OperationPayload[] = []; @@ -532,7 +610,7 @@ abstract class BaseAcpContractClient { async getERC20Balance( chainId: number, tokenAddress: Address, - walletAddress: Address + walletAddress: Address, ): Promise { const publicClient = this.publicClients[chainId]; if (!publicClient) { @@ -551,7 +629,7 @@ abstract class BaseAcpContractClient { chainId: number, tokenAddress: Address, walletAddress: Address, - spenderAddress: Address + spenderAddress: Address, ): Promise { const publicClient = this.publicClients[chainId]; if (!publicClient) { @@ -568,7 +646,7 @@ abstract class BaseAcpContractClient { async getERC20Symbol( chainId: number, - tokenAddress: Address + tokenAddress: Address, ): Promise { const publicClient = this.publicClients[chainId]; if (!publicClient) { @@ -584,7 +662,7 @@ abstract class BaseAcpContractClient { async getERC20Decimals( chainId: number, - tokenAddress: Address + tokenAddress: Address, ): Promise { const publicClient = this.publicClients[chainId]; if (!publicClient) { diff --git a/src/index.ts b/src/index.ts index 8051b7c5..df5d107e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,13 @@ import { AcpMemoStatus, AcpMemoState, DeliverablePayload, + IAcpAgent, + IAcpAccount, + ISubscriptionCheckResponse, + ISubscriptionTier, + SubscriptionPaymentRequirementResult, } from "./interfaces"; +import { AcpAccount } from "./acpAccount"; import { AcpContractConfig, baseAcpConfig, @@ -30,6 +36,7 @@ import { import { ethFare, Fare, FareAmount, FareBigInt, wethFare } from "./acpFare"; import AcpError from "./acpError"; import AcpContractClientV2 from "./contractClients/acpContractClientV2"; +import { PriceType } from "./acpJobOffering"; export default AcpClient; export { @@ -58,9 +65,15 @@ export { AcpMemo, AcpAgent, ACP_ABI, + AcpAccount, + IAcpAccount, + ISubscriptionCheckResponse, + ISubscriptionTier, + SubscriptionPaymentRequirementResult, AcpAgentSort, AcpGraduationStatus, AcpOnlineStatus, AcpMemoStatus, AcpMemoState, + PriceType, }; diff --git a/src/interfaces.ts b/src/interfaces.ts index d4ac6ba9..3b30604b 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -114,7 +114,7 @@ export interface IAcpClientOptions { } export interface IAcpAgent { - id: number; + id: string | number; name: string; description: string; walletAddress: Address; @@ -134,6 +134,7 @@ export interface IAcpAgent { requiredFunds: boolean; requirement?: Object | string; deliverable?: Object | string; + subscriptionTiers?: string[]; }[]; resources: { name: string; @@ -159,6 +160,32 @@ export type IAcpAccount = { clientAddress: Address; providerAddress: Address; metadata: Record; + expiry?: number; +}; + +export type ISubscriptionTier = { + name: string; + price: number; + duration: number; // duration in seconds +}; + +export type ISubscriptionCheckResponse = { + accounts: IAcpAccount[]; +}; + +export type SubscriptionPaymentRequirementResult = + | { + needsSubscriptionPayment: false; + action: "no_subscription_required" | "valid_subscription"; + } + | { + needsSubscriptionPayment: true; + tier: ISubscriptionTier; + }; + +export type IBackendAccountsResponse = { + subscriptionTiers: ISubscriptionTier[]; + accounts: IAcpAccount[]; }; export type X402Config = { diff --git a/test/integration/acpClient.integration.test.ts b/test/integration/acpClient.integration.test.ts index ccbdbea2..d3e0555c 100644 --- a/test/integration/acpClient.integration.test.ts +++ b/test/integration/acpClient.integration.test.ts @@ -132,6 +132,8 @@ describe("AcpClient Integration Testing", () => { if (result.length > 0) { const firstAgent = result[0]; + console.log("firstAgent: ", firstAgent); + expect(firstAgent).toHaveProperty("id"); expect(firstAgent).toHaveProperty("name"); expect(firstAgent).toHaveProperty("description"); diff --git a/test/unit/acpJob.test.ts b/test/unit/acpJob.test.ts index 582c858f..fd8e7291 100644 --- a/test/unit/acpJob.test.ts +++ b/test/unit/acpJob.test.ts @@ -430,7 +430,7 @@ describe("AcpJob Unit Testing", () => { MemoType.PAYABLE_REQUEST, mockFareAmount, recipient, - expiredAt, + { expiredAt }, ); expect(mockContractClient.approveAllowance).not.toHaveBeenCalled(); @@ -472,7 +472,7 @@ describe("AcpJob Unit Testing", () => { MemoType.PAYABLE_TRANSFER_ESCROW, mockFareAmount, recipient, - expiredAt, + { expiredAt }, ); expect(mockContractClient.approveAllowance).toHaveBeenCalledWith( diff --git a/test/unit/acpJobOffering.test.ts b/test/unit/acpJobOffering.test.ts index 8aad3df9..975f7b35 100644 --- a/test/unit/acpJobOffering.test.ts +++ b/test/unit/acpJobOffering.test.ts @@ -65,7 +65,6 @@ describe("AcpJobOffering Unit Testing", () => { "MockJob", 100, PriceType.FIXED, - true, ); expect(offering).toBeInstanceOf(AcpJobOffering); @@ -73,12 +72,11 @@ describe("AcpJobOffering Unit Testing", () => { expect(offering.name).toBe("MockJob"); expect(offering.price).toBe(100); expect(offering.priceType).toBe(PriceType.FIXED); - expect(offering.requiredFunds).toBe(true); expect(offering.requirement).toBe(undefined); - expect(offering.deliverable).toBe(undefined); + expect(offering.subscriptionTiers).toEqual([]); }); - it("should use priceType FIXED and requiredFunds", () => { + it("should use priceType FIXED", () => { const offering = new AcpJobOffering( mockAcpClient, mockContractClient, @@ -86,12 +84,10 @@ describe("AcpJobOffering Unit Testing", () => { "MockJob", 100, PriceType.FIXED, - false, ); expect(offering).toBeInstanceOf(AcpJobOffering); expect(offering.priceType).toBe(PriceType.FIXED); - expect(offering.requiredFunds).toBe(false); }); it("should accept custom priceType", () => { @@ -102,7 +98,6 @@ describe("AcpJobOffering Unit Testing", () => { "MockJob", 100, PriceType.PERCENTAGE, - true, ); expect(offering.priceType).toBe(PriceType.PERCENTAGE); @@ -116,7 +111,6 @@ describe("AcpJobOffering Unit Testing", () => { "MockJob", 100, PriceType.FIXED, - true, "custom requirement", ); @@ -137,7 +131,6 @@ describe("AcpJobOffering Unit Testing", () => { "MockJob", 100, PriceType.FIXED, - true, requirementObject, ); @@ -145,7 +138,7 @@ describe("AcpJobOffering Unit Testing", () => { expect(offering.requirement).toBe(requirementObject); }); - it("should accept deliverable as string", () => { + it("should accept subscription tiers", () => { const offering = new AcpJobOffering( mockAcpClient, mockContractClient, @@ -153,18 +146,15 @@ describe("AcpJobOffering Unit Testing", () => { "MockJob", 100, PriceType.FIXED, - true, undefined, - "custom deliverable", + ["sub_basic", "sub_premium"], ); expect(offering).toBeInstanceOf(AcpJobOffering); - expect(offering.deliverable).toBe("custom deliverable"); + expect(offering.subscriptionTiers).toEqual(["sub_basic", "sub_premium"]); }); - it("should accept deliverable as object", () => { - const deliverableObject = { type: "image", format: "png" }; - + it("should default subscription tiers to empty array", () => { const offering = new AcpJobOffering( mockAcpClient, mockContractClient, @@ -172,13 +162,10 @@ describe("AcpJobOffering Unit Testing", () => { "MockJob", 100, PriceType.FIXED, - false, - undefined, - deliverableObject, ); expect(offering).toBeInstanceOf(AcpJobOffering); - expect(offering.deliverable).toEqual(deliverableObject); + expect(offering.subscriptionTiers).toEqual([]); }); }); @@ -207,7 +194,6 @@ describe("AcpJobOffering Unit Testing", () => { "Generate Image", 100, PriceType.FIXED, - true, ); const result = await offering.initiateJob( @@ -264,7 +250,6 @@ describe("AcpJobOffering Unit Testing", () => { "Generate Image", 100, PriceType.FIXED, - true, requirementSchema, ); @@ -296,7 +281,6 @@ describe("AcpJobOffering Unit Testing", () => { "Generate Image", 100, PriceType.FIXED, - true, requirementSchema, ); @@ -334,7 +318,6 @@ describe("AcpJobOffering Unit Testing", () => { "Generate Image", 100, PriceType.PERCENTAGE, - true, ); const result = await offering.initiateJob( @@ -374,7 +357,6 @@ describe("AcpJobOffering Unit Testing", () => { "Generate Image", 100, PriceType.FIXED, - true, ); const result = await offering.initiateJob( @@ -396,12 +378,18 @@ describe("AcpJobOffering Unit Testing", () => { const mockCreateJobPayload = { data: "createJobWithAccountPayload" }; const mockSetBudgetPayload = { data: "setBudgetPayload" }; const mockMemoPayload = { data: "memoPayload" }; - const mockAccount = { id: BigInt(999) }; - - // Mock getByClientAndProvider to return an account (V2 behavior) - jest - .spyOn(mockAcpClient, "getByClientAndProvider") - .mockResolvedValue(mockAccount as any); + // Mock getByClientAndProvider to return subscription account response + jest.spyOn(mockAcpClient, "getByClientAndProvider").mockResolvedValue({ + accounts: [ + { + id: 999, + clientAddress: mockContractClient.walletAddress, + providerAddress: "0xProvider" as Address, + metadata: { name: "sub" }, + expiry: Math.floor(Date.now() / 1000) + 3600, + }, + ], + } as any); mockContractClient.createJobWithAccount.mockReturnValue( mockCreateJobPayload as any, @@ -425,12 +413,16 @@ describe("AcpJobOffering Unit Testing", () => { "0xProvider" as Address, "Generate Image", 100, - PriceType.FIXED, - true, + PriceType.SUBSCRIPTION, + undefined, + ["sub"], ); const result = await offering.initiateJob( "generate an image about Virtuals", + undefined, + undefined, + "sub", ); expect(result).toBe(mockJobId); @@ -441,7 +433,7 @@ describe("AcpJobOffering Unit Testing", () => { const createJobCall = mockContractClient.createJobWithAccount.mock.calls[0]; const accountIdParam = createJobCall[0]; - expect(accountIdParam).toBe(mockAccount.id); + expect(accountIdParam).toBe(999); }); }); }); From d0a94237ddab0eae2cdc6722b8834491e7b96a0c Mon Sep 17 00:00:00 2001 From: ariessa Date: Sun, 15 Feb 2026 21:17:50 +0800 Subject: [PATCH 2/5] feat(subscription): refactor initiateJob, fix subscription/fixed-price flows, expose agent subscriptions --- examples/acp-base/subscription/buyer.ts | 55 ++--- examples/acp-base/subscription/seller.ts | 61 +++--- src/acpAgent.ts | 4 + src/acpClient.ts | 261 ++++++++++++----------- src/acpJob.ts | 2 +- src/acpJobOffering.ts | 45 ++-- src/interfaces.ts | 6 +- 7 files changed, 234 insertions(+), 200 deletions(-) diff --git a/examples/acp-base/subscription/buyer.ts b/examples/acp-base/subscription/buyer.ts index f29d3912..6e3e9b5d 100644 --- a/examples/acp-base/subscription/buyer.ts +++ b/examples/acp-base/subscription/buyer.ts @@ -71,35 +71,34 @@ async function buyer() { `Buyer: Job ${job.id} — Subscription paid (tx: ${subPayTx})`, ); - // Fixed-price requirement — pay and advance to delivery (Scenario 2) - } else if ( - job.phase === AcpJobPhases.NEGOTIATION && - memoToSign?.type === MemoType.PAYABLE_REQUEST - ) { - console.log( - `Buyer: Job ${job.id} — Fixed-price requirement, paying now`, - ); - const payResult = await job.payAndAcceptRequirement("Payment for job"); - console.log( - `Buyer: Job ${job.id} — Paid and advanced to TRANSACTION phase (tx: ${payResult?.txnHash})`, - ); - - // Active subscription path — accept requirement without payment + // Requirement to proceed — two paths: + // - Active subscription (budget = 0): just sign the memo, no payment needed. + // - Fixed-price (budget > 0): approve budget and sign. } else if ( job.phase === AcpJobPhases.NEGOTIATION && memoToSign?.type === MemoType.MESSAGE && memoToSign?.nextPhase === AcpJobPhases.TRANSACTION ) { - console.log( - `Buyer: Job ${job.id} — Subscription active, accepting without payment`, - ); - const { txnHash: signMemoTx } = await job.acceptRequirement( - memoToSign, - "Subscription verified, proceeding to delivery", - ); - console.log( - `Buyer: Job ${job.id} — Advanced to TRANSACTION phase (tx: ${signMemoTx})`, - ); + const isSubscriptionJob = job.price === 0; + if (isSubscriptionJob) { + console.log( + `Buyer: Job ${job.id} — Subscription active, advancing without payment`, + ); + const { txnHash } = await job.acceptRequirement( + memoToSign, + "Subscription active, proceeding to delivery", + ); + console.log( + `Buyer: Job ${job.id} — Advanced to TRANSACTION phase (tx: ${txnHash})`, + ); + } else { + console.log(`Buyer: Job ${job.id} — Paying budget and advancing`); + const payResult = + await job.payAndAcceptRequirement("Payment for job"); + console.log( + `Buyer: Job ${job.id} — Advanced to TRANSACTION phase (tx: ${payResult?.txnHash})`, + ); + } } else if (job.phase === AcpJobPhases.COMPLETED) { console.log( `Buyer: Job ${job.id} — Completed! Deliverable:`, @@ -156,7 +155,9 @@ async function buyer() { new Date(Date.now() + 1000 * 60 * 15), // job expiry duration, minimum 5 minutes SUBSCRIPTION_TIER, ); - console.log(`Buyer: [Scenario 1 — Subscription Offering] Job ${jobId} initiated`); + console.log( + `Buyer: [Scenario 1 — Subscription Offering] Job ${jobId} initiated`, + ); break; } @@ -169,7 +170,9 @@ async function buyer() { undefined, // evaluator address, undefined fallback to empty address new Date(Date.now() + 1000 * 60 * 15), // job expiry duration, minimum 5 minutes ); - console.log(`Buyer: [Scenario 2 — Fixed-Price Job] Job ${jobId} initiated`); + console.log( + `Buyer: [Scenario 2 — Fixed-Price Job] Job ${jobId} initiated`, + ); break; } diff --git a/examples/acp-base/subscription/seller.ts b/examples/acp-base/subscription/seller.ts index 9a661097..1fa99aa4 100644 --- a/examples/acp-base/subscription/seller.ts +++ b/examples/acp-base/subscription/seller.ts @@ -35,9 +35,11 @@ async function seller() { SELLER_AGENT_WALLET_ADDRESS, baseSepoliaAcpConfigV2, ), - + onNewTask: async (job: AcpJob, memoToSign?: AcpMemo) => { - console.log(`Seller: onNewTask - Job ${job.id}, phase: ${AcpJobPhases[job.phase]}, memoToSign: ${memoToSign?.id ?? "None"}, nextPhase: ${memoToSign?.nextPhase !== undefined ? AcpJobPhases[memoToSign.nextPhase] : "None"}`); + console.log( + `Seller: onNewTask - Job ${job.id}, phase: ${AcpJobPhases[job.phase]}, memoToSign: ${memoToSign?.id ?? "None"}, nextPhase: ${memoToSign?.nextPhase !== undefined ? AcpJobPhases[memoToSign.nextPhase] : "None"}`, + ); // PHASE 1: Handle new job request — check subscription if ( @@ -47,18 +49,22 @@ async function seller() { await handleSubscriptionCheck(acpClient, job); } - // PHASE 2: Deliver work (reached directly after subscription payment or normal flow) - else if (job.phase === AcpJobPhases.TRANSACTION) { + // PHASE 2: Deliver work (self-evaluated — deliver auto-completes the job) + // Matches either: + // - Scenario 1 (subscription): job.phase === TRANSACTION after subscription payment + // - Scenario 2 (fixed-price): buyer's payAndAcceptRequirement creates memo with nextPhase EVALUATION + else if ( + job.phase === AcpJobPhases.TRANSACTION || + memoToSign?.nextPhase === AcpJobPhases.EVALUATION + ) { const deliverable: DeliverablePayload = { type: "url", value: "https://example.com/deliverable", }; console.log(`Seller: Delivering job ${job.id}`); const { txnHash: deliverTx } = await job.deliver(deliverable); - console.log(`Seller: Job ${job.id} delivered (tx: ${deliverTx})`); - } - - else if (job.phase === AcpJobPhases.COMPLETED) { + console.log(`Seller: Job ${job.id} completed (tx: ${deliverTx})`); + } else if (job.phase === AcpJobPhases.COMPLETED) { console.log(`Seller: Job ${job.id} completed`); } else if (job.phase === AcpJobPhases.REJECTED) { console.log(`Seller: Job ${job.id} rejected`); @@ -69,7 +75,7 @@ async function seller() { /** * Handles pricing logic for incoming jobs: - * - Fixed-price jobs: accept + create PAYABLE_REQUEST + * - Fixed-price jobs: accept + create plain requirement * - Subscription jobs with active subscription: accept + create plain requirement * - Subscription jobs without active subscription: accept + create PAYABLE_REQUEST_SUBSCRIPTION */ @@ -79,23 +85,22 @@ async function handleSubscriptionCheck(acpClient: AcpClient, job: AcpJob) { if (!offeringName) { console.log(`Seller: Job ${job.id} — No offering name found, rejecting`); const { txnHash: rejectTx } = await job.reject( - "No offering name associated with this job" + "No offering name associated with this job", ); console.log(`Seller: Job ${job.id} — Job rejected (tx: ${rejectTx})`); return; } - // Fixed-price offering — skip subscription check, create payable requirement directly + // Fixed-price offering — skip subscription check, create plain requirement + // Budget is already set by the buyer via setBudgetWithPaymentToken during initiateJob, + // so no PAYABLE_REQUEST is needed. The budget escrow handles payment on phase transition. if (job.priceType !== PriceType.SUBSCRIPTION) { const { txnHash: acceptTx } = await job.accept("Job accepted"); console.log(`Seller: Job ${job.id} — Job accepted (tx: ${acceptTx})`); - const fareAmount = new FareAmount(job.priceValue, job.config.baseFare); - const { txnHash: reqTx } = await job.createPayableRequirement( - "Payment required to proceed", - MemoType.PAYABLE_REQUEST, - fareAmount, + const { txnHash: reqTx } = await job.createRequirement( + "Job accepted, please make payment to proceed", ); - console.log(`Seller: Job ${job.id} — Payable requirement created (tx: ${reqTx})`); + console.log(`Seller: Job ${job.id} — Requirement created (tx: ${reqTx})`); return; } @@ -109,13 +114,20 @@ async function handleSubscriptionCheck(acpClient: AcpClient, job: AcpJob) { const { txnHash: acceptTx } = await job.accept("Job accepted"); console.log(`Seller: Job ${job.id} — Job accepted (tx: ${acceptTx})`); // Subscription is active — create plain requirement (no payment needed) - const { txnHash: reqTx } = await job.createRequirement("Proceeding to delivery"); - console.log(`Seller: Job ${job.id} — Subscription active, requirement created (tx: ${reqTx})`); + const { txnHash: reqTx } = await job.createRequirement( + "Proceeding to delivery", + ); + console.log( + `Seller: Job ${job.id} — Subscription active, requirement created (tx: ${reqTx})`, + ); return; } - const { name: tierName, price: subscriptionPrice, duration: durationSeconds } = - result.tier; + const { + name: tierName, + price: subscriptionPrice, + duration: durationSeconds, + } = result.tier; const subscriptionMetadata = JSON.stringify({ name: tierName, price: subscriptionPrice, @@ -123,12 +135,12 @@ async function handleSubscriptionCheck(acpClient: AcpClient, job: AcpJob) { }); const durationDays = Math.floor(durationSeconds / (24 * 60 * 60)); console.log( - `Seller: Job ${job.id} — Subscription required. Requesting ${subscriptionPrice} TOKENS for "${tierName}" (${durationDays} days)` + `Seller: Job ${job.id} — Subscription required. Requesting ${subscriptionPrice} TOKENS for "${tierName}" (${durationDays} days)`, ); const fareAmount = new FareAmount(subscriptionPrice, job.config.baseFare); const { txnHash: acceptTx } = await job.accept( - `Subscription required for "${tierName}"` + `Subscription required for "${tierName}"`, ); console.log(`Seller: Job ${job.id} — Job accepted (tx: ${acceptTx})`); @@ -140,11 +152,10 @@ async function handleSubscriptionCheck(acpClient: AcpClient, job: AcpJob) { { duration: durationSeconds }, ); console.log( - `Seller: Job ${job.id} — Subscription payment request created (tx: ${subReqTx})` + `Seller: Job ${job.id} — Subscription payment request created (tx: ${subReqTx})`, ); } - seller().catch((error) => { console.error("Seller error:", error); process.exit(1); diff --git a/src/acpAgent.ts b/src/acpAgent.ts index 723bdc0e..4d96cae6 100644 --- a/src/acpAgent.ts +++ b/src/acpAgent.ts @@ -1,5 +1,6 @@ import { Address } from "viem"; import AcpJobOffering from "./acpJobOffering"; +import { ISubscriptionTier } from "./interfaces"; export type AcpAgentArgs = { id: string | number; @@ -11,6 +12,7 @@ export type AcpAgentArgs = { twitterHandle?: string; metrics?: unknown; resources?: unknown; + subscriptions?: ISubscriptionTier[]; }; export class AcpAgent { @@ -24,6 +26,7 @@ export class AcpAgent { public readonly twitterHandle?: string; public readonly metrics?: unknown; public readonly resources?: unknown; + public readonly subscriptions: readonly ISubscriptionTier[]; constructor(args: AcpAgentArgs) { this.id = String(args.id); @@ -38,6 +41,7 @@ export class AcpAgent { this.twitterHandle = args.twitterHandle; this.metrics = args.metrics; this.resources = args.resources; + this.subscriptions = Object.freeze([...(args.subscriptions ?? [])]); } } diff --git a/src/acpClient.ts b/src/acpClient.ts index 9f742cff..52149eb4 100644 --- a/src/acpClient.ts +++ b/src/acpClient.ts @@ -444,6 +444,7 @@ class AcpClient { walletAddress: agent.walletAddress, metrics: agent.metrics, resources: agent.resources, + subscriptions: agent.subscriptions ?? [], }); } @@ -526,95 +527,13 @@ class AcpClient { ); } - // When no offeringName, account and subscriptionTier stay default (null / "") - - let account: AcpAccount | null = null; - let subscriptionTier = ""; - let selectedTierDetails: ISubscriptionTier | null = null; - - if (offeringName) { - const raw = await this.getByClientAndProvider( - this.walletAddress, + // Resolve subscription account & tier details (no-ops when offeringName is absent) + const { account, subscriptionTier, selectedTierDetails } = + await this._resolveSubscriptionAccount( providerAddress, - this.acpContractClient, offeringName, + preferredSubscriptionTier, ); - const subscriptionCheck = - raw && typeof raw === "object" && "accounts" in raw - ? (raw as ISubscriptionCheckResponse) - : null; - - if (subscriptionCheck && !preferredSubscriptionTier) { - const validAccount = this._getValidSubscriptionAccountFromResponse( - subscriptionCheck, - this.acpContractClient, - ); - if (validAccount) { - account = validAccount; - subscriptionTier = ""; - } - } else if (subscriptionCheck) { - const expiryZero = - this._getAccountWithExpiryZeroFromResponse(subscriptionCheck); - if (expiryZero) { - account = new AcpAccount( - this.acpContractClient, - expiryZero.id, - expiryZero.clientAddress, - expiryZero.providerAddress, - expiryZero.metadata, - expiryZero.expiry, - ); - selectedTierDetails = - expiryZero.metadata?.name != null - ? { - name: expiryZero.metadata.name, - price: expiryZero.metadata.price ?? 0, - duration: expiryZero.metadata.duration ?? 0, - } - : null; - } else { - const tierName = preferredSubscriptionTier ?? ""; - subscriptionTier = tierName; - const tierAccount = subscriptionCheck.accounts?.find( - (a) => a.metadata?.name === tierName, - ); - selectedTierDetails = tierAccount?.metadata?.name - ? { - name: tierAccount.metadata.name, - price: tierAccount.metadata.price ?? 0, - duration: tierAccount.metadata.duration ?? 0, - } - : null; - const createPayload = this.acpContractClient.createAccount( - providerAddress, - tierName, - ); - if (createPayload) { - const { userOpHash: createUserOpHash } = - await this.acpContractClient.handleOperation([createPayload]); - const newAccountId = - await this.acpContractClient.getAccountIdFromUserOpHash( - createUserOpHash, - ); - if (newAccountId != null) { - account = new AcpAccount( - this.acpContractClient, - newAccountId, - this.walletAddress, - providerAddress, - { name: tierName }, - 0, - ); - } else { - account = null; - } - } else { - account = null; - } - } - } - } const isV1 = [ baseSepoliaAcpConfig.contractAddress, @@ -623,36 +542,34 @@ class AcpClient { baseAcpX402Config.contractAddress, ].includes(this.acpContractClient.config.contractAddress); - const defaultEvaluatorAddress = - isV1 && !evaluatorAddress ? this.walletAddress : zeroAddress; + const resolvedEvaluator = + evaluatorAddress || (isV1 ? this.walletAddress : zeroAddress); const chainId = this.acpContractClient.config.chain .id as keyof typeof USDC_TOKEN_ADDRESS; - const isUsdcPaymentToken = - USDC_TOKEN_ADDRESS[chainId].toLowerCase() === - fareAmount.fare.contractAddress.toLowerCase(); - const isX402Job = - this.acpContractClient.config.x402Config && isUsdcPaymentToken; + this.acpContractClient.config.x402Config && + USDC_TOKEN_ADDRESS[chainId].toLowerCase() === + fareAmount.fare.contractAddress.toLowerCase(); - // For subscription jobs, include full tier details as account metadata + // Serialize tier details for on-chain metadata; falls back to raw tier name + const tierDetailsJson = selectedTierDetails + ? JSON.stringify(selectedTierDetails) + : ""; const subscriptionMetadata = subscriptionTier && selectedTierDetails - ? JSON.stringify({ - name: selectedTierDetails.name, - price: selectedTierDetails.price, - duration: selectedTierDetails.duration, - }) + ? tierDetailsJson : subscriptionTier; + // Build job-creation operations const createJobOperations: OperationPayload[] = []; if (isV1 || !account) { createJobOperations.push( this.acpContractClient.createJob( providerAddress, - evaluatorAddress || defaultEvaluatorAddress, + resolvedEvaluator, expiredAt, fareAmount.fare.contractAddress, fareAmount.amount, @@ -664,7 +581,7 @@ class AcpClient { createJobOperations.push( this.acpContractClient.createJobWithAccount( account.id, - evaluatorAddress || defaultEvaluatorAddress, + resolvedEvaluator, fareAmount.amount, fareAmount.fare.contractAddress, expiredAt, @@ -672,16 +589,11 @@ class AcpClient { ), ); - // Batch account metadata update with job creation for subscription jobs if (selectedTierDetails) { createJobOperations.push( this.acpContractClient.updateAccountMetadata( account.id, - JSON.stringify({ - name: selectedTierDetails.name, - price: selectedTierDetails.price, - duration: selectedTierDetails.duration, - }), + tierDetailsJson, ), ); } @@ -696,17 +608,18 @@ class AcpClient { providerAddress, ); + // Set budget & initial memo const payloads: OperationPayload[] = []; - const setBudgetWithPaymentTokenPayload = + const setBudgetPayload = this.acpContractClient.setBudgetWithPaymentToken( jobId, fareAmount.amount, fareAmount.fare.contractAddress, ); - if (setBudgetWithPaymentTokenPayload) { - payloads.push(setBudgetWithPaymentTokenPayload); + if (setBudgetPayload) { + payloads.push(setBudgetPayload); } payloads.push( @@ -896,6 +809,114 @@ class AcpClient { ); } + /** + * Narrows a backend response to ISubscriptionCheckResponse if it has an accounts array. + */ + private _asSubscriptionCheck( + raw: AcpAccount | ISubscriptionCheckResponse | null, + ): ISubscriptionCheckResponse | null { + return raw && typeof raw === "object" && "accounts" in raw + ? (raw as ISubscriptionCheckResponse) + : null; + } + + /** + * Extracts { name, price, duration } from account metadata, or null. + */ + private _extractTierDetails( + metadata: Record | undefined, + ): ISubscriptionTier | null { + if (metadata?.name == null) return null; + return { + name: metadata.name, + price: metadata.price ?? 0, + duration: metadata.duration ?? 0, + }; + } + + /** + * Resolves subscription account state for initiateJob. + * Returns the account to use (if any), the raw tier name string, and parsed tier details. + */ + private async _resolveSubscriptionAccount( + providerAddress: Address, + offeringName?: string, + preferredSubscriptionTier?: string, + ): Promise<{ + account: AcpAccount | null; + subscriptionTier: string; + selectedTierDetails: ISubscriptionTier | null; + }> { + const none = { account: null, subscriptionTier: "", selectedTierDetails: null }; + + if (!offeringName) return none; + + const raw = await this.getByClientAndProvider( + this.walletAddress, + providerAddress, + this.acpContractClient, + offeringName, + ); + const subscriptionCheck = this._asSubscriptionCheck(raw); + if (!subscriptionCheck) return none; + + // No preferred tier: try reusing an active subscription + if (!preferredSubscriptionTier) { + const validAccount = this._getValidSubscriptionAccountFromResponse( + subscriptionCheck, + this.acpContractClient, + ); + return { account: validAccount, subscriptionTier: "", selectedTierDetails: null }; + } + + // Reuse an unactivated (expiry === 0) account if available + const expiryZero = this._getAccountWithExpiryZeroFromResponse(subscriptionCheck); + if (expiryZero) { + return { + account: new AcpAccount( + this.acpContractClient, + expiryZero.id, + expiryZero.clientAddress, + expiryZero.providerAddress, + expiryZero.metadata, + expiryZero.expiry, + ), + subscriptionTier: "", + selectedTierDetails: this._extractTierDetails(expiryZero.metadata), + }; + } + + // Create a new account for the requested tier + const tierName = preferredSubscriptionTier ?? ""; + const tierAccount = subscriptionCheck.accounts?.find( + (a) => a.metadata?.name === tierName, + ); + const selectedTierDetails = this._extractTierDetails(tierAccount?.metadata); + + const createPayload = this.acpContractClient.createAccount(providerAddress, JSON.stringify({ name: tierName })); + if (!createPayload) { + return { account: null, subscriptionTier: tierName, selectedTierDetails }; + } + + const { userOpHash } = + await this.acpContractClient.handleOperation([createPayload]); + const newAccountId = + await this.acpContractClient.getAccountIdFromUserOpHash(userOpHash); + + const account = newAccountId != null + ? new AcpAccount( + this.acpContractClient, + newAccountId, + this.walletAddress, + providerAddress, + { name: tierName }, + 0, + ) + : null; + + return { account, subscriptionTier: tierName, selectedTierDetails }; + } + /** * Returns the first subscription account with expiry > now, or null. */ @@ -956,19 +977,9 @@ class AcpClient { }; } - const response = - raw && typeof raw === "object" && "accounts" in raw - ? (raw as ISubscriptionCheckResponse) - : null; - - if (!response || !response.accounts) { - return { - needsSubscriptionPayment: false, - action: "no_subscription_required", - }; - } + const response = this._asSubscriptionCheck(raw); - if (!response.accounts.length) { + if (!response?.accounts?.length) { return { needsSubscriptionPayment: false, action: "no_subscription_required", @@ -1011,11 +1022,7 @@ class AcpClient { offeringName, ); - const subscriptionCheck = - raw && typeof raw === "object" && "accounts" in raw - ? (raw as ISubscriptionCheckResponse) - : null; - + const subscriptionCheck = this._asSubscriptionCheck(raw); if (!subscriptionCheck) return null; const contractClient = acpContractClient || this.contractClients[0]; diff --git a/src/acpJob.ts b/src/acpJob.ts index b60219c1..7039a445 100644 --- a/src/acpJob.ts +++ b/src/acpJob.ts @@ -650,7 +650,7 @@ class AcpJob { const memo = this.latestMemo; - await memo.sign(accept, reason); + return await memo.sign(accept, reason); } async createNotification(content: string) { diff --git a/src/acpJobOffering.ts b/src/acpJobOffering.ts index 3a84615a..af2b1c23 100644 --- a/src/acpJobOffering.ts +++ b/src/acpJobOffering.ts @@ -47,7 +47,9 @@ class AcpJobOffering { ) { this.validateRequest(serviceRequirement); - const subscriptionRequired = this.isSubscriptionRequired(preferredSubscriptionTier); + const subscriptionRequired = this.isSubscriptionRequired( + preferredSubscriptionTier, + ); this.validateSubscriptionTier(preferredSubscriptionTier); const effectivePrice = subscriptionRequired ? 0 : this.price; @@ -146,7 +148,11 @@ class AcpJobOffering { ); if (!subscriptionRequired) { - return raw instanceof AcpAccount ? raw : null; + if (!(raw instanceof AcpAccount)) return null; + // Skip subscription accounts — they can't be used for non-subscription jobs + const meta = raw.metadata; + if (meta && typeof meta === "object" && meta.name) return null; + return raw; } const subscriptionCheck = @@ -160,17 +166,17 @@ class AcpJobOffering { const allAccounts = subscriptionCheck.accounts ?? []; const matchedAccount = - this.findPreferredAccount(allAccounts, preferredSubscriptionTier, now) - ?? allAccounts.find((a) => a.expiry != null && a.expiry > now) - ?? allAccounts.find((a) => a.expiry == null || a.expiry === 0); + this.findPreferredAccount(allAccounts, preferredSubscriptionTier, now) ?? + allAccounts.find((a) => a.expiry != null && a.expiry > now) ?? + allAccounts.find((a) => a.expiry == null || a.expiry === 0); if (!matchedAccount) return null; return new AcpAccount( this.acpContractClient, matchedAccount.id, - matchedAccount.clientAddress, - matchedAccount.providerAddress, + matchedAccount.clientAddress ?? this.acpContractClient.walletAddress, + matchedAccount.providerAddress ?? this.providerAddress, matchedAccount.metadata, matchedAccount.expiry, ); @@ -187,7 +193,13 @@ class AcpJobOffering { if (a.expiry == null || a.expiry <= now) return false; const meta = typeof a.metadata === "string" - ? (() => { try { return JSON.parse(a.metadata); } catch { return {}; } })() + ? (() => { + try { + return JSON.parse(a.metadata); + } catch { + return {}; + } + })() : (a.metadata ?? {}); return meta?.name === preferredTier; }); @@ -217,7 +229,9 @@ class AcpJobOffering { this.acpContractClient.config.x402Config && isUsdcPaymentToken; const budget = subscriptionRequired ? 0n : fareAmount.amount; - const subscriptionMetadata = JSON.stringify({ name: subscriptionTier }); + const subscriptionMetadata = subscriptionRequired + ? JSON.stringify({ name: subscriptionTier }) + : ""; const operation = isV1 || !account @@ -239,7 +253,7 @@ class AcpJobOffering { isX402Job, ); - const { userOpHash } = await this.acpContractClient.handleOperation([ + const { userOpHash, txnHash } = await this.acpContractClient.handleOperation([ operation, ]); @@ -259,12 +273,11 @@ class AcpJobOffering { const payloads: OperationPayload[] = []; if (!subscriptionRequired) { - const setBudgetPayload = - this.acpContractClient.setBudgetWithPaymentToken( - jobId, - fareAmount.amount, - fareAmount.fare.contractAddress, - ); + const setBudgetPayload = this.acpContractClient.setBudgetWithPaymentToken( + jobId, + fareAmount.amount, + fareAmount.fare.contractAddress, + ); if (setBudgetPayload) { payloads.push(setBudgetPayload); } diff --git a/src/interfaces.ts b/src/interfaces.ts index 3b30604b..513496c6 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -153,6 +153,7 @@ export interface IAcpAgent { isOnline: boolean; }; contractAddress: Address; + subscriptions?: ISubscriptionTier[]; } export type IAcpAccount = { @@ -183,11 +184,6 @@ export type SubscriptionPaymentRequirementResult = tier: ISubscriptionTier; }; -export type IBackendAccountsResponse = { - subscriptionTiers: ISubscriptionTier[]; - accounts: IAcpAccount[]; -}; - export type X402Config = { url: string; }; From b4c08c72a9a39b65d061fafad6e6766524d8965c Mon Sep 17 00:00:00 2001 From: ariessa Date: Mon, 16 Feb 2026 15:41:16 +0800 Subject: [PATCH 3/5] fix(subscription): fix broken validateRequest signature and test errors --- src/acpJobOffering.ts | 3 --- test/unit/acpJobOffering.test.ts | 3 ++- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/acpJobOffering.ts b/src/acpJobOffering.ts index c95ea139..df2f2064 100644 --- a/src/acpJobOffering.ts +++ b/src/acpJobOffering.ts @@ -91,9 +91,6 @@ class AcpJobOffering { } private validateRequest(serviceRequirement: Object | string) { - evaluatorAddress?: Address - ) { - const expiredAt = new Date(Date.now() + this.slaMinutes * 60 * 1000); if (this.providerAddress === this.acpClient.walletAddress) { throw new AcpError( "Provider address cannot be the same as the client address", diff --git a/test/unit/acpJobOffering.test.ts b/test/unit/acpJobOffering.test.ts index e3dd0b6b..228d55cf 100644 --- a/test/unit/acpJobOffering.test.ts +++ b/test/unit/acpJobOffering.test.ts @@ -179,7 +179,6 @@ describe("AcpJobOffering Unit Testing", () => { false, 1440, undefined, - deliverableObject, ); expect(offering).toBeInstanceOf(AcpJobOffering); @@ -444,6 +443,8 @@ describe("AcpJobOffering Unit Testing", () => { PriceType.FIXED, true, 1440, + undefined, + ["sub"], ); const result = await offering.initiateJob( From 62afaab0dfe40ed0ecb91fc27e4af5328586d799 Mon Sep 17 00:00:00 2001 From: ariessa Date: Mon, 16 Feb 2026 17:33:14 +0800 Subject: [PATCH 4/5] fix(subscription): fix compile errors in acpJobOffering and acpClient --- src/acpClient.ts | 2 +- src/acpJobOffering.ts | 1 + test/unit/acpJobOffering.test.ts | 2 ++ 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/acpClient.ts b/src/acpClient.ts index dad6bb98..ed8038a9 100644 --- a/src/acpClient.ts +++ b/src/acpClient.ts @@ -8,7 +8,7 @@ import BaseAcpContractClient, { } from "./contractClients/baseAcpContractClient"; import AcpJob from "./acpJob"; import AcpMemo from "./acpMemo"; -import AcpJobOffering from "./acpJobOffering"; +import AcpJobOffering, { PriceType } from "./acpJobOffering"; import { IAcpAgent, AcpAgentSort, diff --git a/src/acpJobOffering.ts b/src/acpJobOffering.ts index df2f2064..e4ea359f 100644 --- a/src/acpJobOffering.ts +++ b/src/acpJobOffering.ts @@ -36,6 +36,7 @@ class AcpJobOffering { public requiredFunds: boolean, public slaMinutes: number, public requirement?: Object | string, + public deliverable?: Object | string, public subscriptionTiers: string[] = [], ) { this.ajv = new Ajv({ allErrors: true }); diff --git a/test/unit/acpJobOffering.test.ts b/test/unit/acpJobOffering.test.ts index 228d55cf..f3002b3a 100644 --- a/test/unit/acpJobOffering.test.ts +++ b/test/unit/acpJobOffering.test.ts @@ -161,6 +161,7 @@ describe("AcpJobOffering Unit Testing", () => { true, 1440, undefined, + undefined, ["sub_basic", "sub_premium"], ); @@ -444,6 +445,7 @@ describe("AcpJobOffering Unit Testing", () => { true, 1440, undefined, + undefined, ["sub"], ); From a2ff6c040ae6b11c8c7f1da63152bfc4bca8c41a Mon Sep 17 00:00:00 2001 From: ariessa Date: Mon, 16 Feb 2026 17:43:01 +0800 Subject: [PATCH 5/5] feat(subscription): update ABI with subscription errors, events, and contract methods --- src/abis/acpAbiV2.ts | 50 ++++++++++++- src/abis/memoManagerAbi.ts | 146 +++++++++++++++++-------------------- 2 files changed, 112 insertions(+), 84 deletions(-) diff --git a/src/abis/acpAbiV2.ts b/src/abis/acpAbiV2.ts index 90cce730..ca6855b4 100644 --- a/src/abis/acpAbiV2.ts +++ b/src/abis/acpAbiV2.ts @@ -9,6 +9,10 @@ const ACP_V2_ABI = [ name: "AccessControlUnauthorizedAccount", type: "error", }, + { inputs: [], name: "AccountAlreadySubscribed", type: "error" }, + { inputs: [], name: "AccountDoesNotExist", type: "error" }, + { inputs: [], name: "AccountManagerNotSet", type: "error" }, + { inputs: [], name: "AccountNotActive", type: "error" }, { inputs: [{ internalType: "address", name: "target", type: "address" }], name: "AddressEmptyCode", @@ -19,6 +23,13 @@ const ACP_V2_ABI = [ name: "AddressInsufficientBalance", type: "error", }, + { inputs: [], name: "AmountMustBeGreaterThanZero", type: "error" }, + { inputs: [], name: "AmountOrFeeRequired", type: "error" }, + { inputs: [], name: "AssetManagerNotSet", type: "error" }, + { inputs: [], name: "BudgetNotReceived", type: "error" }, + { inputs: [], name: "CannotSetBudgetOnSubscriptionJob", type: "error" }, + { inputs: [], name: "DestinationEndpointRequired", type: "error" }, + { inputs: [], name: "DurationMustBeGreaterThanZero", type: "error" }, { inputs: [ { internalType: "address", name: "implementation", type: "address" }, @@ -28,22 +39,46 @@ const ACP_V2_ABI = [ }, { inputs: [], name: "ERC1967NonPayable", type: "error" }, { inputs: [], name: "EnforcedPause", type: "error" }, + { inputs: [], name: "EvaluatorFeeTooHigh", type: "error" }, { inputs: [], name: "ExpectedPause", type: "error" }, + { inputs: [], name: "ExpiredAtMustBeInFuture", type: "error" }, + { inputs: [], name: "ExpiryTooShort", type: "error" }, { inputs: [], name: "FailedInnerCall", type: "error" }, + { inputs: [], name: "InvalidCrossChainMemoType", type: "error" }, { inputs: [], name: "InvalidInitialization", type: "error" }, + { inputs: [], name: "InvalidMemoType", type: "error" }, + { inputs: [], name: "InvalidModuleType", type: "error" }, + { inputs: [], name: "InvalidPaymentToken", type: "error" }, + { inputs: [], name: "InvalidRecipient", type: "error" }, + { inputs: [], name: "JobManagerNotSet", type: "error" }, + { inputs: [], name: "MemoManagerNotSet", type: "error" }, { inputs: [], name: "NotInitializing", type: "error" }, + { inputs: [], name: "OnlyClient", type: "error" }, + { inputs: [], name: "OnlyMemoManager", type: "error" }, + { inputs: [], name: "OnlyProvider", type: "error" }, + { inputs: [], name: "PaymentManagerNotSet", type: "error" }, + { inputs: [], name: "PlatformFeeTooHigh", type: "error" }, { inputs: [], name: "ReentrancyGuardReentrantCall", type: "error" }, { inputs: [{ internalType: "address", name: "token", type: "address" }], name: "SafeERC20FailedOperation", type: "error", }, + { inputs: [], name: "SubscriptionAccountExpired", type: "error" }, + { inputs: [], name: "SubscriptionJobMustHaveZeroBudget", type: "error" }, + { inputs: [], name: "SubscriptionJobMustHaveZeroBudgetMemo", type: "error" }, + { inputs: [], name: "TokenAddressRequired", type: "error" }, + { inputs: [], name: "TokenMustBeERC20", type: "error" }, { inputs: [], name: "UUPSUnauthorizedCallContext", type: "error" }, { inputs: [{ internalType: "bytes32", name: "slot", type: "bytes32" }], name: "UUPSUnsupportedProxiableUUID", type: "error", }, + { inputs: [], name: "UnableToRefund", type: "error" }, + { inputs: [], name: "Unauthorized", type: "error" }, + { inputs: [], name: "ZeroAddress", type: "error" }, + { inputs: [], name: "ZeroAddressProvider", type: "error" }, { anonymous: false, inputs: [ @@ -692,6 +727,13 @@ const ACP_V2_ABI = [ stateMutability: "nonpayable", type: "function", }, + { + inputs: [{ internalType: "uint256", name: "jobId", type: "uint256" }], + name: "isSubscriptionJob", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, { inputs: [], name: "jobManager", @@ -830,9 +872,9 @@ const ACP_V2_ABI = [ { inputs: [ { internalType: "uint256", name: "accountId", type: "uint256" }, - { internalType: "uint256", name: "duration", type: "uint256" }, + { internalType: "string", name: "metadata", type: "string" }, ], - name: "updateAccountExpiryFromMemoManager", + name: "updateAccountMetadata", outputs: [], stateMutability: "nonpayable", type: "function", @@ -840,9 +882,11 @@ const ACP_V2_ABI = [ { inputs: [ { internalType: "uint256", name: "accountId", type: "uint256" }, + { internalType: "uint256", name: "duration", type: "uint256" }, { internalType: "string", name: "metadata", type: "string" }, + { internalType: "address", name: "provider", type: "address" }, ], - name: "updateAccountMetadata", + name: "updateAccountSubscriptionFromMemoManager", outputs: [], stateMutability: "nonpayable", type: "function", diff --git a/src/abis/memoManagerAbi.ts b/src/abis/memoManagerAbi.ts index ab1e23ff..c94a0367 100644 --- a/src/abis/memoManagerAbi.ts +++ b/src/abis/memoManagerAbi.ts @@ -34,6 +34,7 @@ const MEMO_MANAGER_ABI = [ { inputs: [], name: "InvalidMemoState", type: "error" }, { inputs: [], name: "InvalidMemoStateTransition", type: "error" }, { inputs: [], name: "InvalidMemoType", type: "error" }, + { inputs: [], name: "InvalidSubscriptionDuration", type: "error" }, { inputs: [], name: "JobAlreadyCompleted", type: "error" }, { inputs: [], name: "JobDoesNotExist", type: "error" }, { inputs: [], name: "MemoAlreadyApproved", type: "error" }, @@ -69,6 +70,7 @@ const MEMO_MANAGER_ABI = [ { inputs: [], name: "ZeroAddressToken", type: "error" }, { inputs: [], name: "ZeroAssetManagerAddress", type: "error" }, { inputs: [], name: "ZeroJobManagerAddress", type: "error" }, + { inputs: [], name: "ZeroPaymentManagerAddress", type: "error" }, { anonymous: false, inputs: [ @@ -341,6 +343,31 @@ const MEMO_MANAGER_ABI = [ name: "RoleRevoked", type: "event", }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "uint256", + name: "memoId", + type: "uint256", + }, + { + indexed: true, + internalType: "uint256", + name: "accountId", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "duration", + type: "uint256", + }, + ], + name: "SubscriptionActivated", + type: "event", + }, { anonymous: false, inputs: [ @@ -408,17 +435,6 @@ const MEMO_MANAGER_ABI = [ stateMutability: "view", type: "function", }, - { - inputs: [ - { internalType: "uint256[]", name: "memoIds", type: "uint256[]" }, - { internalType: "bool", name: "approved", type: "bool" }, - { internalType: "string", name: "reason", type: "string" }, - ], - name: "bulkApproveMemos", - outputs: [], - stateMutability: "nonpayable", - type: "function", - }, { inputs: [ { internalType: "uint256", name: "memoId", type: "uint256" }, @@ -496,9 +512,40 @@ const MEMO_MANAGER_ABI = [ type: "function", }, { - inputs: [{ internalType: "uint256", name: "memoId", type: "uint256" }], - name: "emergencyApproveMemo", - outputs: [], + inputs: [ + { internalType: "uint256", name: "jobId", type: "uint256" }, + { internalType: "address", name: "sender", type: "address" }, + { internalType: "string", name: "content", type: "string" }, + { + components: [ + { internalType: "address", name: "token", type: "address" }, + { internalType: "uint256", name: "amount", type: "uint256" }, + { internalType: "address", name: "recipient", type: "address" }, + { internalType: "uint256", name: "feeAmount", type: "uint256" }, + { + internalType: "enum ACPTypes.FeeType", + name: "feeType", + type: "uint8", + }, + { internalType: "bool", name: "isExecuted", type: "bool" }, + { internalType: "uint256", name: "expiredAt", type: "uint256" }, + { internalType: "uint32", name: "lzSrcEid", type: "uint32" }, + { internalType: "uint32", name: "lzDstEid", type: "uint32" }, + ], + internalType: "struct ACPTypes.PayableDetails", + name: "payableDetails_", + type: "tuple", + }, + { internalType: "uint256", name: "duration", type: "uint256" }, + { internalType: "uint256", name: "expiredAt", type: "uint256" }, + { + internalType: "enum ACPTypes.JobPhase", + name: "nextPhase", + type: "uint8", + }, + ], + name: "createSubscriptionMemo", + outputs: [{ internalType: "uint256", name: "memoId", type: "uint256" }], stateMutability: "nonpayable", type: "function", }, @@ -509,13 +556,6 @@ const MEMO_MANAGER_ABI = [ stateMutability: "nonpayable", type: "function", }, - { - inputs: [], - name: "getAssetManager", - outputs: [{ internalType: "address", name: "", type: "address" }], - stateMutability: "view", - type: "function", - }, { inputs: [ { internalType: "uint256", name: "jobId", type: "uint256" }, @@ -712,17 +752,6 @@ const MEMO_MANAGER_ABI = [ stateMutability: "view", type: "function", }, - { - inputs: [{ internalType: "uint256", name: "memoId", type: "uint256" }], - name: "getMemoApprovalStatus", - outputs: [ - { internalType: "bool", name: "isApproved", type: "bool" }, - { internalType: "address", name: "approvedBy", type: "address" }, - { internalType: "uint256", name: "approvedAt", type: "uint256" }, - ], - stateMutability: "view", - type: "function", - }, { inputs: [{ internalType: "uint256", name: "memoId", type: "uint256" }], name: "getMemoWithPayableDetails", @@ -785,34 +814,6 @@ const MEMO_MANAGER_ABI = [ stateMutability: "view", type: "function", }, - { - inputs: [{ internalType: "uint256", name: "memoId", type: "uint256" }], - name: "getPayableDetails", - outputs: [ - { - components: [ - { internalType: "address", name: "token", type: "address" }, - { internalType: "uint256", name: "amount", type: "uint256" }, - { internalType: "address", name: "recipient", type: "address" }, - { internalType: "uint256", name: "feeAmount", type: "uint256" }, - { - internalType: "enum ACPTypes.FeeType", - name: "feeType", - type: "uint8", - }, - { internalType: "bool", name: "isExecuted", type: "bool" }, - { internalType: "uint256", name: "expiredAt", type: "uint256" }, - { internalType: "uint32", name: "lzSrcEid", type: "uint32" }, - { internalType: "uint32", name: "lzDstEid", type: "uint32" }, - ], - internalType: "struct ACPTypes.PayableDetails", - name: "", - type: "tuple", - }, - ], - stateMutability: "view", - type: "function", - }, { inputs: [{ internalType: "bytes32", name: "role", type: "bytes32" }], name: "getRoleAdmin", @@ -851,16 +852,6 @@ const MEMO_MANAGER_ABI = [ stateMutability: "nonpayable", type: "function", }, - { - inputs: [ - { internalType: "uint256", name: "jobId", type: "uint256" }, - { internalType: "address", name: "user", type: "address" }, - ], - name: "isJobEvaluator", - outputs: [{ internalType: "bool", name: "", type: "bool" }], - stateMutability: "view", - type: "function", - }, { inputs: [ { internalType: "uint256", name: "memoId", type: "uint256" }, @@ -1032,23 +1023,16 @@ const MEMO_MANAGER_ABI = [ }, { inputs: [ - { - internalType: "enum ACPTypes.MemoType", - name: "memoType", - type: "uint8", - }, - { internalType: "uint256", name: "requiredApprovals_", type: "uint256" }, + { internalType: "address", name: "assetManager_", type: "address" }, ], - name: "setApprovalRequirements", + name: "setAssetManager", outputs: [], stateMutability: "nonpayable", type: "function", }, { - inputs: [ - { internalType: "address", name: "assetManager_", type: "address" }, - ], - name: "setAssetManager", + inputs: [{ internalType: "uint256", name: "memoId", type: "uint256" }], + name: "setPayableDetailsExecuted", outputs: [], stateMutability: "nonpayable", type: "function",