Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
24f9e11
feat: subscription
ariessa Feb 14, 2026
d0a9423
feat(subscription): refactor initiateJob, fix subscription/fixed-pric…
ariessa Feb 15, 2026
4b796c5
Merge branch 'main' into feat/subscription
ariessa Feb 16, 2026
b4c08c7
fix(subscription): fix broken validateRequest signature and test errors
ariessa Feb 16, 2026
62afaab
fix(subscription): fix compile errors in acpJobOffering and acpClient
ariessa Feb 16, 2026
a2ff6c0
feat(subscription): update ABI with subscription errors, events, and …
ariessa Feb 16, 2026
1d60323
fix(subscription): fix compile errors, simplify createPayableRequirem…
ariessa Feb 20, 2026
78915f4
chore: enrich initial memo with offering metadata and fix subscriptio…
ariessa Feb 20, 2026
7d5a166
chore: replace expiry with expiryAt
ariessa Feb 23, 2026
ba67bf9
chore: use this.slaMinutes instead of hardcoded value
ariessa Feb 23, 2026
359f017
chore: use expiryAt instead of expiry
ariessa Feb 24, 2026
6b2aa34
Merge branch 'main' into feat/subscription
ariessa Feb 24, 2026
9cc7486
fix: update failing tests
ariessa Feb 24, 2026
3272b89
refactor: clean up
ariessa Feb 24, 2026
c1883da
fix: subscription logic in acpClient.initiateJob
ariessa Feb 26, 2026
2bf0649
Merge remote-tracking branch 'origin/main' into feat/subscription
ariessa Feb 27, 2026
496c641
fix: remove calculateGasFees test in acpContractClientV2
ariessa Feb 27, 2026
7124537
feat: builder code config
andrew-virtuals Mar 4, 2026
a9b443f
feat: builder code config (#190)
andrew-virtuals Mar 4, 2026
cc5d3dd
fix: package.json
andrew-virtuals Mar 4, 2026
7f45e71
Merge branch 'feat/builder-code' into feat/subscription
andrew-virtuals Mar 4, 2026
f693e64
Merge branch 'feat/subscription' of github.com:Virtual-Protocol/acp-n…
andrew-virtuals Mar 4, 2026
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.
193 changes: 193 additions & 0 deletions examples/acp-base/subscription/buyer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/**
* Subscription Example - Buyer (Client) using jobOffering.initiateJob
*
* This version uses `chosenJobOffering.initiateJob(...)` which automatically
* wraps the service requirement with offering metadata (name, priceValue, priceType).
*
* 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) {
const lastMemo = job.memos[job.memos.length - 1];
const completedTx = lastMemo?.signedTxHash ?? lastMemo?.txHash;
console.log(
`Buyer: Job ${job.id} — Completed (tx: ${completedTx})! Deliverable:`,
await job.getDeliverable(),
);
} 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