Skip to content

Commit 24f9e11

Browse files
committed
feat: subscription
1 parent 2928b3d commit 24f9e11

21 files changed

Lines changed: 1471 additions & 342 deletions
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# ACP Subscription Example
2+
3+
This example demonstrates how to test subscription-backed jobs with ACP v2 using a buyer (client) and seller (provider).
4+
5+
## Overview
6+
7+
The flow covers:
8+
9+
- Assumes the selected agent has:
10+
- subscription offering at `jobOfferings[0]`
11+
- fixed-price offering at `jobOfferings[1]`
12+
- Buyer runs one of two scenarios:
13+
- Scenario 1: subscription offering
14+
- Scenario 2: fixed-price offering
15+
- Seller handles incoming jobs by price type.
16+
- For subscription jobs, seller checks account subscription status.
17+
- If no valid subscription exists, seller requests subscription payment.
18+
- If subscription is active, seller proceeds without requesting subscription payment.
19+
20+
## Files
21+
22+
- buyer.ts: Runs scenario-based job initiation and handles subscription/fixed-price memo flows.
23+
- seller.ts: Handles fixed-price and subscription paths, including subscription payment requirements.
24+
- env.ts: Loads environment variables from .env.
25+
26+
## Setup
27+
28+
1. Create a .env file:
29+
- Place it in examples/acp-base/subscription/.env
30+
- Required variables:
31+
- BUYER_AGENT_WALLET_ADDRESS
32+
- SELLER_AGENT_WALLET_ADDRESS
33+
- BUYER_ENTITY_ID
34+
- SELLER_ENTITY_ID
35+
- WHITELISTED_WALLET_PRIVATE_KEY
36+
37+
2. Install dependencies (from repo root):
38+
- npm install
39+
40+
3. Ensure selected agent has at least:
41+
- One subscription offering at index `jobOfferings[0]`
42+
- One fixed-price offering at index `jobOfferings[1]`
43+
44+
## Run
45+
46+
1. Start the seller:
47+
- cd examples/acp-base/subscription
48+
- npx ts-node seller.ts
49+
50+
2. Start the buyer in another terminal:
51+
- cd examples/acp-base/subscription
52+
- npx ts-node buyer.ts --scenario 1 # Subscription offering
53+
- npx ts-node buyer.ts --scenario 2 # Fixed-price offering
54+
55+
## Expected Flow
56+
57+
- Scenario 1 (Subscription offering):
58+
- Buyer initiates a subscription job with tier metadata (for example `sub_premium`).
59+
- Seller checks subscription validity.
60+
- If missing/expired, seller creates `PAYABLE_REQUEST_SUBSCRIPTION`.
61+
- Buyer calls `paySubscription(...)`.
62+
- Seller moves forward and eventually delivers in `TRANSACTION` phase.
63+
- If you run scenario 1 again while subscription is active, seller skips subscription payment and sends a plain requirement.
64+
65+
- Scenario 2 (Fixed-price offering):
66+
- Buyer initiates a non-subscription job.
67+
- Seller accepts and creates `PAYABLE_REQUEST`.
68+
- Buyer pays with `payAndAcceptRequirement(...)`.
69+
- Seller delivers in `TRANSACTION` phase.
70+
71+
## Notes
72+
73+
- Both agents must be registered and whitelisted on ACP.
74+
- Subscription tier name in buyer defaults to `sub_premium`; adjust to match seller offering config.
75+
- 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.
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/**
2+
* Subscription Example - Buyer (Client)
3+
*
4+
* Run a specific scenario via --scenario flag:
5+
* npx ts-node buyer.ts --scenario 1 # Subscription offering
6+
* npx ts-node buyer.ts --scenario 2 # Non-subscription offering (fixed-price)
7+
*
8+
* Default: scenario 1
9+
*
10+
* Assumption:
11+
* - chosenAgent.jobOfferings[0] is a subscription offering
12+
* - chosenAgent.jobOfferings[1] is a non-subscription (fixed-price) offering
13+
*/
14+
import AcpClient, {
15+
AcpContractClientV2,
16+
AcpJobPhases,
17+
AcpJob,
18+
AcpMemo,
19+
MemoType,
20+
AcpAgentSort,
21+
AcpGraduationStatus,
22+
AcpOnlineStatus,
23+
baseSepoliaAcpConfigV2,
24+
} from "../../../src/index";
25+
import {
26+
BUYER_AGENT_WALLET_ADDRESS,
27+
BUYER_ENTITY_ID,
28+
WHITELISTED_WALLET_PRIVATE_KEY,
29+
} from "./env";
30+
31+
// Subscription tier name — adjust to match your offering config
32+
const SUBSCRIPTION_TIER = "sub_premium";
33+
34+
// Parse --scenario N from argv
35+
const scenarioArg = process.argv.indexOf("--scenario");
36+
const SCENARIO =
37+
scenarioArg !== -1 ? parseInt(process.argv[scenarioArg + 1], 10) : 1;
38+
39+
async function buyer() {
40+
console.log(`=== Subscription Example - Buyer (Scenario ${SCENARIO}) ===\n`);
41+
42+
const acpClient = new AcpClient({
43+
acpContractClient: await AcpContractClientV2.build(
44+
WHITELISTED_WALLET_PRIVATE_KEY,
45+
BUYER_ENTITY_ID,
46+
BUYER_AGENT_WALLET_ADDRESS,
47+
baseSepoliaAcpConfigV2,
48+
),
49+
onNewTask: async (job: AcpJob, memoToSign?: AcpMemo) => {
50+
console.log(
51+
`Buyer: onNewTask - Job ${job.id}, phase: ${AcpJobPhases[job.phase]}, ` +
52+
`memoToSign: ${memoToSign?.id ?? "None"}, ` +
53+
`nextPhase: ${memoToSign?.nextPhase !== undefined ? AcpJobPhases[memoToSign.nextPhase] : "None"}`,
54+
);
55+
56+
// Subscription payment requested (Scenario 1)
57+
if (
58+
job.phase === AcpJobPhases.NEGOTIATION &&
59+
memoToSign?.type === MemoType.PAYABLE_REQUEST_SUBSCRIPTION
60+
) {
61+
console.log(
62+
`Buyer: Job ${job.id} — Subscription payment requested: ${memoToSign.content}`,
63+
);
64+
console.log(
65+
`Buyer: Job ${job.id} — Amount: ${memoToSign.payableDetails?.amount}`,
66+
);
67+
const { txnHash: subPayTx } = await job.paySubscription(
68+
`Subscription payment for ${SUBSCRIPTION_TIER}`,
69+
);
70+
console.log(
71+
`Buyer: Job ${job.id} — Subscription paid (tx: ${subPayTx})`,
72+
);
73+
74+
// Fixed-price requirement — pay and advance to delivery (Scenario 2)
75+
} else if (
76+
job.phase === AcpJobPhases.NEGOTIATION &&
77+
memoToSign?.type === MemoType.PAYABLE_REQUEST
78+
) {
79+
console.log(
80+
`Buyer: Job ${job.id} — Fixed-price requirement, paying now`,
81+
);
82+
const payResult = await job.payAndAcceptRequirement("Payment for job");
83+
console.log(
84+
`Buyer: Job ${job.id} — Paid and advanced to TRANSACTION phase (tx: ${payResult?.txnHash})`,
85+
);
86+
87+
// Active subscription path — accept requirement without payment
88+
} else if (
89+
job.phase === AcpJobPhases.NEGOTIATION &&
90+
memoToSign?.type === MemoType.MESSAGE &&
91+
memoToSign?.nextPhase === AcpJobPhases.TRANSACTION
92+
) {
93+
console.log(
94+
`Buyer: Job ${job.id} — Subscription active, accepting without payment`,
95+
);
96+
const { txnHash: signMemoTx } = await job.acceptRequirement(
97+
memoToSign,
98+
"Subscription verified, proceeding to delivery",
99+
);
100+
console.log(
101+
`Buyer: Job ${job.id} — Advanced to TRANSACTION phase (tx: ${signMemoTx})`,
102+
);
103+
} else if (job.phase === AcpJobPhases.COMPLETED) {
104+
console.log(
105+
`Buyer: Job ${job.id} — Completed! Deliverable:`,
106+
job.deliverable,
107+
);
108+
} else if (job.phase === AcpJobPhases.REJECTED) {
109+
console.log(
110+
`Buyer: Job ${job.id} — Rejected. Reason:`,
111+
job.rejectionReason,
112+
);
113+
} else {
114+
console.log(
115+
`Buyer: Job ${job.id} — Unhandled event (phase: ${AcpJobPhases[job.phase]}, ` +
116+
`memoType: ${memoToSign?.type !== undefined ? MemoType[memoToSign.type] : "None"}, ` +
117+
`nextPhase: ${memoToSign?.nextPhase !== undefined ? AcpJobPhases[memoToSign.nextPhase] : "None"})`,
118+
);
119+
}
120+
},
121+
});
122+
123+
// Browse available agents
124+
const relevantAgents = await acpClient.browseAgents("", {
125+
sortBy: [AcpAgentSort.SUCCESSFUL_JOB_COUNT],
126+
topK: 5,
127+
graduationStatus: AcpGraduationStatus.ALL,
128+
onlineStatus: AcpOnlineStatus.ALL,
129+
showHiddenOfferings: true,
130+
});
131+
132+
console.log("Relevant agents:", relevantAgents);
133+
134+
if (!relevantAgents || relevantAgents.length === 0) {
135+
console.error("No agents found");
136+
return;
137+
}
138+
139+
// Pick one of the agents based on your criteria (in this example we just pick the first one)
140+
const chosenAgent = relevantAgents[0];
141+
142+
// Pick one of the service offerings based on your criteria:
143+
// - index 0: subscription offering
144+
// - index 1: non-subscription (fixed-price) offering
145+
const subscriptionOffering = chosenAgent.jobOfferings[0];
146+
const fixedOffering = chosenAgent.jobOfferings[1];
147+
148+
switch (SCENARIO) {
149+
case 1: {
150+
const chosenJobOffering = subscriptionOffering;
151+
const jobId = await chosenJobOffering.initiateJob(
152+
// Requirement payload schema depends on your ACP service configuration.
153+
// If your service requires fields, replace {} with the expected schema payload.
154+
{},
155+
undefined, // evaluator address, undefined fallback to empty address
156+
new Date(Date.now() + 1000 * 60 * 15), // job expiry duration, minimum 5 minutes
157+
SUBSCRIPTION_TIER,
158+
);
159+
console.log(`Buyer: [Scenario 1 — Subscription Offering] Job ${jobId} initiated`);
160+
break;
161+
}
162+
163+
case 2: {
164+
const chosenJobOffering = fixedOffering;
165+
const jobId = await chosenJobOffering.initiateJob(
166+
// Requirement payload schema depends on your ACP service configuration.
167+
// If your service requires fields, replace {} with the expected schema payload.
168+
{},
169+
undefined, // evaluator address, undefined fallback to empty address
170+
new Date(Date.now() + 1000 * 60 * 15), // job expiry duration, minimum 5 minutes
171+
);
172+
console.log(`Buyer: [Scenario 2 — Fixed-Price Job] Job ${jobId} initiated`);
173+
break;
174+
}
175+
176+
default:
177+
console.error(`Unknown scenario: ${SCENARIO}. Use --scenario 1 or 2.`);
178+
process.exit(1);
179+
}
180+
}
181+
182+
buyer().catch((error) => {
183+
console.error("Buyer error:", error);
184+
process.exit(1);
185+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import dotenv from "dotenv";
2+
import { Address } from "viem";
3+
4+
dotenv.config({ path: __dirname + "/.env" });
5+
6+
function getEnvVar<T extends string = string>(key: string, required = true): T {
7+
const value = process.env[key];
8+
if (required && (value === undefined || value === "")) {
9+
throw new Error(`${key} is not defined or is empty in the .env file`);
10+
}
11+
return value as T;
12+
}
13+
14+
export const WHITELISTED_WALLET_PRIVATE_KEY = getEnvVar<Address>(
15+
"WHITELISTED_WALLET_PRIVATE_KEY"
16+
);
17+
18+
export const BUYER_AGENT_WALLET_ADDRESS = getEnvVar<Address>(
19+
"BUYER_AGENT_WALLET_ADDRESS"
20+
);
21+
22+
export const BUYER_ENTITY_ID = parseInt(getEnvVar("BUYER_ENTITY_ID"));
23+
24+
export const SELLER_AGENT_WALLET_ADDRESS = getEnvVar<Address>(
25+
"SELLER_AGENT_WALLET_ADDRESS"
26+
);
27+
28+
export const SELLER_ENTITY_ID = parseInt(getEnvVar("SELLER_ENTITY_ID"));
29+
30+
const entities = {
31+
BUYER_ENTITY_ID,
32+
SELLER_ENTITY_ID,
33+
};
34+
35+
for (const [key, value] of Object.entries(entities)) {
36+
if (isNaN(value)) throw new Error(`${key} must be a valid number`);
37+
}

0 commit comments

Comments
 (0)