Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions examples/acp-base/subscription/README.md
Original file line number Diff line number Diff line change
@@ -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.
188 changes: 188 additions & 0 deletions examples/acp-base/subscription/buyer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/**
* 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})`,
);

// 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
) {
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:`,
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);
});
37 changes: 37 additions & 0 deletions examples/acp-base/subscription/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import dotenv from "dotenv";
import { Address } from "viem";

dotenv.config({ path: __dirname + "/.env" });

function getEnvVar<T extends string = string>(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<Address>(
"WHITELISTED_WALLET_PRIVATE_KEY"
);

export const BUYER_AGENT_WALLET_ADDRESS = getEnvVar<Address>(
"BUYER_AGENT_WALLET_ADDRESS"
);

export const BUYER_ENTITY_ID = parseInt(getEnvVar("BUYER_ENTITY_ID"));

export const SELLER_AGENT_WALLET_ADDRESS = getEnvVar<Address>(
"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`);
}
Loading