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
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,57 @@ await session.setBudgetWithFundRequest(
);
```

## Off-escrow proportional-fee jobs

For **facilitator** offerings where the transferred value settles **outside ACP
escrow** and ACP escrows only a **proportional fee** (e.g. an off-escrow
cross-chain transfer priced at 8 bps of the notional). This is a fenced-off job
type — `priceType: "percentage"` + `requiredFunds: false` + a
`settlement_tx_hash` deliverable — with **no impact on existing offerings**.

Because `requiredFunds` is false it uses the ordinary **plain-job path**
(`hook = address(0)`): the seller sets `budget = fee`, the buyer funds the fee,
and the principal never touches the seller's wallet (it moves via the buyer's
own signed intent — ERC-3009 / Permit2). No contract change.

```typescript
import {
computePercentageFee,
readFeeBasis,
assertNotionalMatches,
buildSettlementDeliverable,
AssetToken,
} from "@virtuals-protocol/acp-node-v2";

// Offering names the requirement field carrying the fee notional:
// { priceType: "percentage", priceValue: 8, requiredFunds: false,
// feeBasisField: "notionalAtomic" }

// Seller, on the requirement: reject a faked notional, then price the fee.
const notional = readFeeBasis(offering, requirement); // bigint, atomic
assertNotionalMatches(notional, notionalBoundInSignedIntent);
const fee = computePercentageFee(notional, offering.priceValue, "bps");
await session.setBudget(AssetToken.usdcFromRaw(fee, session.chainId));

// Seller, on job.funded: relay the buyer's intent off-escrow, then prove it.
await session.submit(
buildSettlementDeliverable({ settlementTxHash, chainId })
);
```

Two properties fall out of this. The notional is bound in the buyer's signed
intent, so the seller rejects a mismatch and the buyer previews the exact fee
before funding. And a dispute reduces to whether the settlement tx exists on the
destination chain, with only the fee ever at risk, never the principal.

> **Fee unit is a backend convention to confirm:** whether `priceValue` is
> **basis points** or **percent** must match the Virtuals registry.
> `computePercentageFee` takes the unit (`"bps"` | `"percent"`) explicitly and
> never guesses.

See [`src/examples/off-escrow-percentage/`](./src/examples/off-escrow-percentage/)
for a runnable (stubbed-relay) buyer/seller pair.

## Examples

Runnable buyer/seller pairs are organized by use case under [`src/examples/`](./src/examples/):
Expand All @@ -433,6 +484,7 @@ Runnable buyer/seller pairs are organized by use case under [`src/examples/`](./
| [`fund-transfer/`](./src/examples/fund-transfer/) | Jobs that forward USDC on submission: buyer uses `createJobFromOffering` when `requiredFunds`; seller uses `setBudgetWithFundRequest`. |
| [`subscription/`](./src/examples/subscription/) | Jobs that activate (or renew) an on-chain `SubscriptionHook` package via `createJobFromOffering({ packageId })` + `setBudgetWithSubscription`. |
| [`subscription-fund-transfer/`](./src/examples/subscription-fund-transfer/) | Multi-hook variant: subscription + per-job fund forwarding in a single job (`setBudgetWithSubscriptionAndFundRequest`). |
| [`off-escrow-percentage/`](./src/examples/off-escrow-percentage/) | Facilitator jobs: proportional fee (`priceType: "percentage"`, `requiredFunds: false`), principal settles off-escrow via a signed intent, `settlement_tx_hash` deliverable. |
| [`llm/`](./src/examples/llm/) | Both sides driven by Claude through `session.availableTools()` + `session.executeTool()`. Requires `ANTHROPIC_API_KEY`. |

Each folder has its own README with the lifecycle, expected log output, and any
Expand Down
118 changes: 118 additions & 0 deletions src/core/fee.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type { AcpAgentOffering } from "../events/types.js";

/**
* Unit of an offering's percentage `priceValue`: basis points or percent.
*
* Which one a `percentage` offering uses is a registry convention and must be
* confirmed before relying on it. {@link computePercentageFee} takes the unit
* explicitly rather than assuming one.
*/
export type FeeUnit = "bps" | "percent";

// `priceValue` is a JS number and may be fractional (e.g. 8.5 bps). We scale it
// to an integer at this fixed precision so the fee math stays in bigint for the
// (large) notional. Six decimals of rate precision is ample for bps / percent.
const RATE_PRECISION = 1_000_000n;

/**
* Compute a proportional fee from a notional amount.
*
* The fee is returned in the same atomic units / token as `notionalAtomic`.
* When the notional token differs from the fee (budget) token, the caller must
* USD-normalize first; the SDK does not know cross-token rates.
*
* Rounds DOWN (integer division). `priceValue` may be fractional.
*
* @param notionalAtomic Fee notional in atomic units (e.g. 1000 USDC ->
* 1_000_000_000n at 6 decimals).
* @param priceValue The offering's `priceValue` (the rate).
* @param unit Whether `priceValue` is basis points or percent. See {@link FeeUnit}.
*/
export function computePercentageFee(
notionalAtomic: bigint,
priceValue: number,
unit: FeeUnit
): bigint {
if (notionalAtomic < 0n) {
throw new Error(
`notionalAtomic must be non-negative, got ${notionalAtomic}`
);
}
if (!Number.isFinite(priceValue) || priceValue < 0) {
throw new Error(
`priceValue must be a non-negative finite number, got ${priceValue}`
);
}
const scaledRate = BigInt(Math.round(priceValue * Number(RATE_PRECISION)));
const denom = (unit === "bps" ? 10_000n : 100n) * RATE_PRECISION;
return (notionalAtomic * scaledRate) / denom;
}

/**
* Read the fee notional (atomic, as bigint) from a requirement payload, using
* the field named by `offering.feeBasisField`. Throws if the offering declares
* no fee-basis field, or the requirement value is missing / not an integer
* atomic amount.
*/
export function readFeeBasis(
offering: AcpAgentOffering,
requirementData: Record<string, unknown>
): bigint {
const field = offering.feeBasisField;
if (!field) {
throw new Error(
`Offering "${offering.name}" does not declare feeBasisField; cannot derive a proportional fee`
);
}
const raw = requirementData[field];
if (raw === undefined || raw === null) {
throw new Error(`Requirement is missing fee-basis field "${field}"`);
}
return toBigIntAtomic(raw, field);
}

function toBigIntAtomic(value: unknown, field: string): bigint {
if (typeof value === "bigint") return value;
if (typeof value === "number") {
// A JS number cannot represent an integer above 2^53 exactly, so a large
// atomic amount is silently rounded before it ever reaches BigInt (and
// Number.isInteger still returns true for the rounded double). Reject
// unsafe values and require large notionals as a decimal string, which the
// branch below converts losslessly.
if (!Number.isSafeInteger(value)) {
throw new Error(
`Fee-basis field "${field}" must be a safe-integer atomic amount ` +
`(<= ${Number.MAX_SAFE_INTEGER}); pass larger amounts as a decimal ` +
`string, got ${value}`
);
}
return BigInt(value);
Comment thread
cursor[bot] marked this conversation as resolved.
}
if (typeof value === "string" && /^\d+$/.test(value)) {
return BigInt(value);
}
throw new Error(
`Fee-basis field "${field}" is not a valid atomic amount: ${String(value)}`
);
}

/**
* Assert that the notional the buyer DECLARED in the requirement matches the
* notional BOUND in their signed intent (Permit2 / ERC-3009). A provider should
* call this before `setBudget` so an under- or over-declared notional is
* rejected before any payment.
*
* Extracting `bound` from the signed intent is intent-standard-specific
* (Permit2 / ERC-3009) and lives outside this SDK; this helper only compares.
* It is only as trustworthy as `bound`: pass a value recovered from the
* verified signature, not one the buyer supplied in the clear. Both amounts
* must use the same atomic scale (same token and decimals). The comparison is
* exact.
*/
export function assertNotionalMatches(declared: bigint, bound: bigint): void {
if (declared !== bound) {
throw new Error(
`Declared notional ${declared} does not match the notional bound in the signed intent ${bound}`
);
}
}
82 changes: 82 additions & 0 deletions src/core/settlement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Settlement-proof deliverable convention for off-escrow facilitator jobs.
*
* For jobs where the transferred value moves outside ACP escrow (the provider
* only relays the buyer's signed intent), the deliverable is a proof that the
* facilitated settlement happened on-chain: a settlement tx hash on the
* destination chain, so an evaluator / reputation layer can verify it
* independently even though no ACP escrow moved the principal.
*/
export interface SettlementDeliverable {
kind: "settlement";
/** Settlement transaction hash on the destination chain. */
settlementTxHash: string;
/** Destination chain id the settlement tx was mined on. */
chainId: number;
/** Optional: notional settled, in atomic units (as a decimal string). */
notional?: string;
/** Optional: token address the notional settled in. */
token?: string;
}

/** Serialize a settlement proof for `session.submit(...)`. */
export function buildSettlementDeliverable(
proof: Omit<SettlementDeliverable, "kind">
): string {
const payload: SettlementDeliverable = { kind: "settlement", ...proof };
return JSON.stringify(payload);
}

/**
* Parse and shape-validate a settlement deliverable. Returns `null` when the
* string is not a well-formed settlement proof (mirrors the `parse*` helpers in
* the fund-transfer example). Use this in an evaluator before releasing the fee.
*/
export function parseSettlementDeliverable(
deliverable: string
): SettlementDeliverable | null {
let data: unknown;
try {
data = JSON.parse(deliverable);
} catch {
return null;
}
if (!data || typeof data !== "object") return null;
const o = data as Record<string, unknown>;
if (o.kind !== "settlement") return null;
if (
typeof o.settlementTxHash !== "string" ||
o.settlementTxHash.length === 0
) {
return null;
}
if (typeof o.chainId !== "number" || !Number.isInteger(o.chainId) || o.chainId <= 0) {
return null;
}

const out: SettlementDeliverable = {
kind: "settlement",
settlementTxHash: o.settlementTxHash,
chainId: o.chainId,
};
if (typeof o.notional === "string") out.notional = o.notional;
if (typeof o.token === "string") out.token = o.token;
return out;
}

/**
* JSON schema usable as `AcpAgentOffering.deliverable` so an off-escrow
* offering documents the settlement-proof shape it returns.
*/
export const SETTLEMENT_DELIVERABLE_SCHEMA = {
type: "object",
required: ["kind", "settlementTxHash", "chainId"],
properties: {
kind: { const: "settlement" },
settlementTxHash: { type: "string" },
// chain ids are positive integers (incl. the synthetic Solana ids 500/501)
chainId: { type: "integer", minimum: 1 },
notional: { type: "string" },
token: { type: "string" },
},
} as const;
24 changes: 24 additions & 0 deletions src/events/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,11 +272,35 @@ export interface AcpAgentOffering {
priceType: string;
priceValue: number;
requiredFunds: boolean;
/**
* Off-escrow proportional-fee offerings (`priceType` "percentage" with
* `requiredFunds: false`): names the requirement field carrying the fee
* notional in atomic units, e.g. "notionalAtomic". The fee is computed as a
* proportion of that value via {@link computePercentageFee} — the SDK never
* derives it from custodied capital. Undefined for flat-fee and custodial
* offerings. See `src/core/fee.ts` and the `off-escrow-percentage` example.
*/
feeBasisField?: string;
isHidden: boolean;
isPrivate: boolean;
subscriptions?: Array<AcpAgentSubscription>;
}

/**
* Known `AcpAgentOffering.priceType` values.
* - `FIXED`: flat fee; `priceValue` is the fee amount.
* - `PERCENTAGE`: proportional fee; `priceValue` is a rate applied to the fee
* notional (see {@link AcpAgentOffering.feeBasisField} and
* `computePercentageFee`). Whether `priceValue` is basis points or percent is
* a backend convention — pass the matching `FeeUnit`.
*/
export const PRICE_TYPE = {
FIXED: "fixed",
PERCENTAGE: "percentage",
} as const;

export type PriceType = (typeof PRICE_TYPE)[keyof typeof PRICE_TYPE];

export interface AcpAgentResource {
name: string;
url: string;
Expand Down
8 changes: 6 additions & 2 deletions src/examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ that matches what you're building.
| [`fund-transfer/`](./fund-transfer/) | Fund-transfer hook via `createJobFromOffering` when `requiredFunds`; same lifecycle patterns as `basic/`, optional structured payloads in `jobTypes.ts`. |
| [`subscription/`](./subscription/) | Jobs that activate (or renew) an on-chain `SubscriptionHook` package via `createJobFromOffering({ packageId })` + `setBudgetWithSubscription`. |
| [`subscription-fund-transfer/`](./subscription-fund-transfer/) | Multi-hook variant: subscription + per-job fund forwarding in a single job (`setBudgetWithSubscriptionAndFundRequest`). |
| [`off-escrow-percentage/`](./off-escrow-percentage/) | Facilitator jobs: proportional fee (`priceType: "percentage"`, `requiredFunds: false`), principal settles off-escrow via a signed intent, `settlement_tx_hash` deliverable. |
| [`llm/`](./llm/) | Both sides driven by Claude through `session.availableTools()` + `session.executeTool()`. Requires `ANTHROPIC_API_KEY`. |

Each folder has its own `README.md` with the lifecycle, expected log output,
Expand All @@ -27,8 +28,11 @@ Are both sides agents on the same chain settling in USDC?
│ │ └─ Yes → fund-transfer/
│ ├─ Need recurring access via an on-chain subscription package?
│ │ └─ Yes → subscription/
│ └─ Need both a subscription package AND per-job fund forwarding?
│ └─ Yes → subscription-fund-transfer/
│ ├─ Need both a subscription package AND per-job fund forwarding?
│ │ └─ Yes → subscription-fund-transfer/
│ └─ Charge a proportional fee while the principal settles off-escrow
│ (e.g. a cross-chain relay via a signed intent)?
│ └─ Yes → off-escrow-percentage/
└─ No → start from basic/ and adapt; see the main README's Provider Adapters section
```

Expand Down
Loading