Skip to content

Commit 4562980

Browse files
committed
feat: subscription
1 parent 2928b3d commit 4562980

22 files changed

Lines changed: 1444 additions & 351 deletions
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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+
- Buyer initiates a job with subscription metadata.
10+
- Seller checks the account subscription status.
11+
- If no valid subscription exists, seller requests subscription payment.
12+
- Buyer pays the subscription and continues the normal job flow.
13+
- Subsequent jobs reuse the active subscription account (no additional subscription payment).
14+
15+
## Files
16+
17+
- buyer.ts: Initiates the subscription job and handles subscription payment.
18+
- seller.ts: Validates subscription status and requests subscription payment when needed.
19+
- env.ts: Loads environment variables from .env.
20+
21+
## Setup
22+
23+
1. Create a .env file:
24+
- Place it in examples/acp-base/subscription/.env
25+
- Required variables:
26+
- BUYER_AGENT_WALLET_ADDRESS
27+
- SELLER_AGENT_WALLET_ADDRESS
28+
- BUYER_ENTITY_ID
29+
- SELLER_ENTITY_ID
30+
- WHITELISTED_WALLET_PRIVATE_KEY
31+
32+
2. Install dependencies (from repo root):
33+
- npm install
34+
35+
3. Update buyer.ts placeholders:
36+
- Replace <your-filter-agent-keyword>
37+
- Replace <your-schema-key> and <your-schema-value>
38+
39+
## Run
40+
41+
1. Start the seller:
42+
- cd examples/acp-base/subscription
43+
- npx ts-node seller.ts
44+
45+
2. Start the buyer in another terminal:
46+
- cd examples/acp-base/subscription
47+
- npx ts-node buyer.ts
48+
49+
## Expected Flow
50+
51+
- Job 1:
52+
- Buyer creates a job with subscription metadata (sub_premium).
53+
- Seller checks subscription validity and requests payment if expired/missing.
54+
- Buyer pays the subscription and then pays the job requirement.
55+
- Seller delivers, job completes.
56+
57+
- Job 2:
58+
- Buyer creates a second job with the same metadata.
59+
- Subscription account is reused, no subscription payment requested.
60+
- Normal job flow continues.
61+
62+
## Notes
63+
64+
- Both agents must be registered and whitelisted on ACP.
65+
- The seller config uses a local price table for subscription tiers.
66+
- 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: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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+
* Scenarios:
11+
* 1. Subscription Offering
12+
* - Seller creates PAYABLE_REQUEST_SUBSCRIPTION memo
13+
* - Buyer pays subscription; job proceeds to delivery
14+
*
15+
* 2. Non-Subscription Offering (Fixed-Price)
16+
* - Uses a non-subscription offering (jobOfferings[1])
17+
* - Seller accepts and creates a payable requirement
18+
* - Buyer pays and advances to delivery
19+
*/
20+
import AcpClient, {
21+
AcpContractClientV2,
22+
AcpJobPhases,
23+
AcpJob,
24+
AcpMemo,
25+
MemoType,
26+
AcpAgentSort,
27+
AcpGraduationStatus,
28+
AcpOnlineStatus,
29+
baseSepoliaAcpConfigV2,
30+
} from "../../../src/index";
31+
import {
32+
BUYER_AGENT_WALLET_ADDRESS,
33+
BUYER_ENTITY_ID,
34+
WHITELISTED_WALLET_PRIVATE_KEY,
35+
} from "./env";
36+
37+
// Subscription tier name — adjust to match your offering config
38+
const SUBSCRIPTION_TIER = "sub_premium";
39+
40+
// Parse --scenario N from argv
41+
const scenarioArg = process.argv.indexOf("--scenario");
42+
const SCENARIO = scenarioArg !== -1 ? parseInt(process.argv[scenarioArg + 1], 10) : 1;
43+
44+
async function buyer() {
45+
console.log(`=== Subscription Example - Buyer (Scenario ${SCENARIO}) ===\n`);
46+
47+
const acpClient = new AcpClient({
48+
acpContractClient: await AcpContractClientV2.build(
49+
WHITELISTED_WALLET_PRIVATE_KEY,
50+
BUYER_ENTITY_ID,
51+
BUYER_AGENT_WALLET_ADDRESS,
52+
baseSepoliaAcpConfigV2,
53+
),
54+
onNewTask: async (job: AcpJob, memoToSign?: AcpMemo) => {
55+
console.log(
56+
`Buyer: onNewTask - Job ${job.id}, phase: ${AcpJobPhases[job.phase]}, ` +
57+
`memoToSign: ${memoToSign?.id ?? "None"}, ` +
58+
`nextPhase: ${memoToSign?.nextPhase !== undefined ? AcpJobPhases[memoToSign.nextPhase] : "None"}`,
59+
);
60+
61+
// Subscription payment requested (Scenario 1)
62+
if (
63+
job.phase === AcpJobPhases.NEGOTIATION &&
64+
memoToSign?.type === MemoType.PAYABLE_REQUEST_SUBSCRIPTION
65+
) {
66+
console.log(
67+
`Buyer: Job ${job.id} — Subscription payment requested: ${memoToSign.content}`,
68+
);
69+
console.log(`Buyer: Job ${job.id} — Amount: ${memoToSign.payableDetails?.amount}`);
70+
const { txnHash: subPayTx } = await job.paySubscription(
71+
`Subscription payment for ${SUBSCRIPTION_TIER}`,
72+
);
73+
console.log(`Buyer: Job ${job.id} — Subscription paid (tx: ${subPayTx})`);
74+
75+
// Fixed-price requirement — pay and advance to delivery (Scenario 3)
76+
} else if (
77+
job.phase === AcpJobPhases.NEGOTIATION &&
78+
memoToSign?.type === MemoType.PAYABLE_REQUEST
79+
) {
80+
console.log(`Buyer: Job ${job.id} — Fixed-price requirement, paying now`);
81+
const payResult = await job.payAndAcceptRequirement("Payment for job");
82+
console.log(`Buyer: Job ${job.id} — Paid and advanced to TRANSACTION phase (tx: ${payResult?.txnHash})`);
83+
84+
// Valid subscription — accept requirement without payment (Scenario 2)
85+
} else if (
86+
job.phase === AcpJobPhases.NEGOTIATION &&
87+
memoToSign?.type === MemoType.MESSAGE &&
88+
memoToSign?.nextPhase === AcpJobPhases.TRANSACTION
89+
) {
90+
console.log(`Buyer: Job ${job.id} — Subscription active, accepting without payment`);
91+
const { txnHash: signMemoTx } = await job.acceptRequirement(
92+
memoToSign,
93+
"Subscription verified, proceeding to delivery",
94+
);
95+
console.log(`Buyer: Job ${job.id} — Advanced to TRANSACTION phase (tx: ${signMemoTx})`);
96+
} else if (job.phase === AcpJobPhases.COMPLETED) {
97+
console.log(`Buyer: Job ${job.id} — Completed! Deliverable:`, job.deliverable);
98+
} else if (job.phase === AcpJobPhases.REJECTED) {
99+
console.log(`Buyer: Job ${job.id} — Rejected. Reason:`, job.rejectionReason);
100+
} else {
101+
console.log(
102+
`Buyer: Job ${job.id} — Unhandled event (phase: ${AcpJobPhases[job.phase]}, ` +
103+
`memoType: ${memoToSign?.type !== undefined ? MemoType[memoToSign.type] : "None"}, ` +
104+
`nextPhase: ${memoToSign?.nextPhase !== undefined ? AcpJobPhases[memoToSign.nextPhase] : "None"})`,
105+
);
106+
}
107+
},
108+
});
109+
110+
// Browse available agents
111+
const relevantAgents = await acpClient.browseAgents("", {
112+
sortBy: [AcpAgentSort.SUCCESSFUL_JOB_COUNT],
113+
topK: 5,
114+
graduationStatus: AcpGraduationStatus.ALL,
115+
onlineStatus: AcpOnlineStatus.ALL,
116+
showHiddenOfferings: true,
117+
});
118+
119+
if (!relevantAgents || relevantAgents.length === 0) {
120+
console.error("No agents found");
121+
return;
122+
}
123+
124+
const chosenAgent = relevantAgents[0];
125+
const subscriptionOffering = chosenAgent.jobOfferings[0];
126+
const fixedOffering = chosenAgent.jobOfferings[1];
127+
128+
switch (SCENARIO) {
129+
case 1: {
130+
console.log("--- Scenario 1: Subscription Offering ---\n");
131+
const jobId1 = await subscriptionOffering.initiateJob(
132+
{},
133+
undefined,
134+
new Date(Date.now() + 1000 * 60 * 15), // 15 min job expiry
135+
SUBSCRIPTION_TIER,
136+
);
137+
console.log(`\nBuyer: [Scenario 1 — Subscription Offering] Job ${jobId1} initiated`);
138+
break;
139+
}
140+
141+
case 2: {
142+
console.log("--- Scenario 2: Non-Subscription Offering (Fixed-Price) ---\n");
143+
const jobId2 = await fixedOffering.initiateJob(
144+
{},
145+
undefined,
146+
new Date(Date.now() + 1000 * 60 * 15), // 15 min job expiry
147+
);
148+
console.log(`\nBuyer: [Scenario 2 — Fixed-Price Job] Job ${jobId2} initiated`);
149+
break;
150+
}
151+
152+
default:
153+
console.error(`Unknown scenario: ${SCENARIO}. Use --scenario 1 or 2.`);
154+
process.exit(1);
155+
}
156+
}
157+
158+
buyer().catch((error) => {
159+
console.error("Buyer error:", error);
160+
process.exit(1);
161+
});
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)