diff --git a/README.md b/README.md index 875507d..cf3c394 100644 --- a/README.md +++ b/README.md @@ -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/): @@ -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 diff --git a/src/core/fee.ts b/src/core/fee.ts new file mode 100644 index 0000000..6d8a807 --- /dev/null +++ b/src/core/fee.ts @@ -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 +): 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); + } + 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}` + ); + } +} diff --git a/src/core/settlement.ts b/src/core/settlement.ts new file mode 100644 index 0000000..ecf9f12 --- /dev/null +++ b/src/core/settlement.ts @@ -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 +): 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; + 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; diff --git a/src/events/types.ts b/src/events/types.ts index cd69a75..1b7ac57 100644 --- a/src/events/types.ts +++ b/src/events/types.ts @@ -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; } +/** + * 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; diff --git a/src/examples/README.md b/src/examples/README.md index e926c2f..4e18267 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -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, @@ -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 ``` diff --git a/src/examples/off-escrow-percentage/README.md b/src/examples/off-escrow-percentage/README.md new file mode 100644 index 0000000..3aadba5 --- /dev/null +++ b/src/examples/off-escrow-percentage/README.md @@ -0,0 +1,121 @@ +# Off-escrow proportional-fee example + +Demonstrates a **facilitator** job: the seller relays a value transfer that +settles **outside ACP escrow**, and ACP escrows **only a proportional fee** +(e.g. 8 bps of the transfer notional). The reference use case is Raxol's +`Xochi.TransferOffering` cross-chain stablecoin transfer — see Raxol issue +[#373](https://github.com/DROOdotFOO/raxol/issues/373). + +Unlike [`fund-transfer/`](../fund-transfer/README.md) (where the buyer's capital +is custodied and forwarded through a hook), here the **principal never touches +the seller's wallet**. The buyer authorizes the transfer with their own signed +intent (ERC-3009 / Permit2); the SDK job carries only the fee. + +> This example is **self-contained and stubbed**: it builds the offering object +> locally (the Virtuals registry does not yet allow *listing* a +> `percentage + requiredFunds:false` offering — relaxing that is the server-side +> change this motivates) and the Xochi quote/sign/settle calls are stubbed +> (`// TODO`). No cross-chain funds move. The ACP job lifecycle is real. + +## Why this is a plain job + +`priceType: "percentage"` with `requiredFunds: false` maps to the existing +**plain-job path** in `createJobFromOffering` (`hook = address(0)`). The seller +sets `budget = fee`; the buyer funds the fee; `submit`/`complete` split the fee +(90% provider / 5% platform / 5% evaluator). No contract change, no new hook. + +## The three integrity checks + +1. **Notional can't be faked.** The buyer declares the notional in the + requirement (`feeBasisField` → `notionalAtomic`), but it is also **bound in + their signed intent**. The seller calls `assertNotionalMatches(declared, + boundInIntent)` and **rejects a mismatch before setting the budget**. +2. **Buyer sees the exact fee before paying.** On `budget.set` the buyer + recomputes `computePercentageFee(notional, rate, unit)` and **only funds when + it equals the seller's proposed budget** — otherwise it rejects. +3. **Disputes reduce to "does the settlement tx exist?"** The deliverable is a + `settlement_tx_hash` on the destination chain + (`buildSettlementDeliverable`); the evaluator verifies it + (`parseSettlementDeliverable`). ACP only ever held the fee, so worst case is + a fee refund, never lost principal. + +## Lifecycle + +``` +buyer seller +───── ────── +buildTransferOffering() (percentage, requiredFunds:false) +createJobFromOffering() (plain job + signed-intent requirement) + │ ▶ job.created ──────────────────────▶ case "job.created" + │ assertNotionalMatches(...) + │ fee = rate * notional + │ setBudget(fee) + │ ◀────────── budget.set ◀────────── +case "budget.set" +recompute fee; fund() only if it matches + │ ──────────▶ job.funded ───────────▶ case "job.funded" + │ relay intent to Xochi (STUB) + │ submit(settlement_tx_hash) + │ ◀────────── job.submitted ◀──────── +case "job.submitted" +parseSettlementDeliverable(...); complete() +case "job.completed" → transcript, stop +``` + +## Files + +| File | Role | +| ---- | ---- | +| `buyer.ts` | Builds the local offering, `createJobFromOffering`, fee-preview check, funds the fee, verifies the settlement proof | +| `seller.ts` | Notional/intent check, `computePercentageFee`, `setBudget(fee)`, stubbed Xochi relay, `buildSettlementDeliverable` | +| `jobTypes.ts` | Transfer requirement type + parser, the local percentage offering, the shared `FEE_UNIT`, sample body | + +## Run + +Same `.env` as [`basic/`](../basic/README.md) (`BUYER_*`, `SELLER_*`). Start the +**seller first**, then the buyer: + +```bash +npx tsx src/examples/off-escrow-percentage/seller.ts +npx tsx src/examples/off-escrow-percentage/buyer.ts +``` + +## Environment variables + +| Variable | Default | Meaning | +| -------- | ------- | ------- | +| `OFF_ESCROW_FEE_RATE` | `8` | Fee rate in `FEE_UNIT`; buyer and seller must agree (the buyer's fee-preview check enforces it) | + +## Security — what this stub does not do + +The example demonstrates the ACP job *shape*, not a production-ready facilitator. +Three things must be real before this is safe to run with live funds: + +1. **Signature verification.** `boundNotionalFromIntent` reads a plaintext field + here, so `assertNotionalMatches` currently compares two buyer-supplied values + and proves nothing. The whole "notional can't be faked" property depends on + recovering the notional from the buyer's **verified** intent signature + (Permit2 / ERC-3009), which lives outside this SDK (in raxol). Wire that up + first. +2. **On-chain settlement check.** The evaluator here only checks the proof's + shape and destination chain. A real evaluator must confirm + `settlementTxHash` exists on the destination chain and moved the expected + notional to the recipient. Completing without that is not evidence the + transfer happened. +3. **Always use an evaluator.** This buyer self-evaluates (`evaluatorAddress`). + Under skip-evaluation (`evaluatorAddress` omitted) a `submit` auto-completes + and releases the fee with no settlement check — do not use skip-evaluation + for this job type. + +Also note the fee is computed in the **notional token's** units; +`computePercentageFee` does not convert tokens. The example works because the +notional token is USDC. If the notional token differs from the fee (budget) +token, USD-normalize before `setBudget`. + +## OPEN QUESTION — confirm before merge + +**Is `priceValue` for `priceType: "percentage"` in basis points or percent?** +Raxol's agent prices in **bps**; the Virtuals frontend may show **percent**. +`FEE_UNIT` in [`jobTypes.ts`](./jobTypes.ts) is the single switch, and +`computePercentageFee` takes the unit explicitly (it never guesses). Pin the +backend convention, then set `FEE_UNIT`. diff --git a/src/examples/off-escrow-percentage/buyer.ts b/src/examples/off-escrow-percentage/buyer.ts new file mode 100644 index 0000000..ee43ea4 --- /dev/null +++ b/src/examples/off-escrow-percentage/buyer.ts @@ -0,0 +1,268 @@ +import { base } from "@account-kit/infra"; +import dotenv from "dotenv"; +import { + AcpAgent, + PrivyAlchemyEvmProviderAdapter, + computePercentageFee, + readFeeBasis, + parseSettlementDeliverable, + type AcpAgentOffering, + type JobRoomEntry, + type JobSession, +} from "../../index.js"; +import { + DEFAULT_FEE_RATE, + FEE_UNIT, + buildTransferOffering, + exampleTransferRequirement, + parseTransferRequirement, + type TransferRequirement, +} from "./jobTypes.js"; + +dotenv.config({ quiet: true }); + +// --------------------------------------------------------------------------- +// Off-escrow proportional-fee buyer. +// +// 1. buildTransferOffering() → local percentage offering (requiredFunds:false) +// 2. createJobFromOffering() → plain job (hook=0); the requirement carries +// the signed intent + fee notional +// 3. budget.set → recompute the fee from the notional and the +// offering rate; fund only if it matches the +// seller's proposed budget (buyer sees the +// exact fee before paying) +// 4. job.submitted → verify the settlement proof, then complete() +// 5. job.completed → transcript, buyer.stop() +// +// The transferred value never enters ACP escrow; ACP escrows only the fee. In +// this stub the principal does not actually move; see the folder README. +// +// Required env: BUYER_WALLET_ADDRESS, BUYER_WALLET_ID, BUYER_SIGNER_PRIVATE_KEY, +// SELLER_WALLET_ADDRESS. Optional: OFF_ESCROW_FEE_RATE (default 8). +// --------------------------------------------------------------------------- + +const chain = base; + +function requireEnv(name: string): string { + const v = process.env[name]; + if (!v) throw new Error(`Missing required env var: ${name}`); + return v; +} + +const shortAddr = (a: string): string => + !a || !a.startsWith("0x") || a.length < 12 + ? a + : `${a.slice(0, 6)}…${a.slice(-4)}`; + +const log = { + info: (m: string) => console.log(`[buyer-offesc] ${m}`), + job: (id: string | number, m: string) => + console.log(`[buyer-offesc] [job ${id}] ${m}`), + warn: (m: string) => console.warn(`[buyer-offesc] [warn] ${m}`), + error: (m: string, e?: unknown) => + console.error(`[buyer-offesc] [error] ${m}`, e ?? ""), +}; + +/** Recover the transfer requirement from the job's own requirement message. */ +function extractTransferRequirement( + session: JobSession +): TransferRequirement | null { + for (const e of session.entries) { + if (e.kind === "message" && e.contentType === "requirement") { + try { + return parseTransferRequirement(JSON.parse(e.content)); + } catch { + return null; + } + } + } + return null; +} + +/** Fee the buyer expects for a job, from that job's declared notional. */ +function expectedFeeFor( + offering: AcpAgentOffering, + req: TransferRequirement +): bigint { + return computePercentageFee( + readFeeBasis(offering, { ...req }), + offering.priceValue, + FEE_UNIT + ); +} + +async function main(): Promise { + const feeRate = Number(process.env.OFF_ESCROW_FEE_RATE ?? DEFAULT_FEE_RATE); + const offering = buildTransferOffering(feeRate); + const requirement: TransferRequirement = { ...exampleTransferRequirement }; + const requirementData: Record = { ...requirement }; + + const buyer = await AcpAgent.create({ + provider: await PrivyAlchemyEvmProviderAdapter.create({ + walletAddress: requireEnv("BUYER_WALLET_ADDRESS") as `0x${string}`, + walletId: requireEnv("BUYER_WALLET_ID"), + signerPrivateKey: requireEnv("BUYER_SIGNER_PRIVATE_KEY"), + chains: [chain], + }), + }); + + const buyerAddress = await buyer.getAddress(); + const buyerAddressLower = buyerAddress.toLowerCase(); + log.info(`address: ${buyerAddress}`); + log.info( + `fee rate ${feeRate} ${FEE_UNIT}; expected fee ` + + `${expectedFeeFor(offering, requirement)} atomic on notional ` + + `${requirement.notionalAtomic}` + ); + + buyer.on("entry", async (session: JobSession, entry: JobRoomEntry) => { + if (entry.kind !== "system") return; + + switch (entry.event.type) { + case "budget.set": { + try { + await session.fetchJob(); + // Recompute the fee from THIS job's requirement, not a value cached + // for another job. + const req = extractTransferRequirement(session); + if (!req) { + log.job(session.jobId, "no transfer requirement in job — rejecting"); + await session.reject("Could not recover the transfer requirement"); + return; + } + const expectedFee = expectedFeeFor(offering, req); + const proposed = session.job?.budget.rawAmount; + log.job( + session.jobId, + `seller proposed budget ${proposed} atomic; expected fee ${expectedFee}` + ); + // Fund only when the proposed fee equals the fee derived from the + // notional and the offering rate. + if (proposed !== expectedFee) { + log.job(session.jobId, "budget != expected fee — rejecting"); + await session.reject( + `Proposed budget ${proposed} != expected fee ${expectedFee}` + ); + return; + } + await session.fund(); + log.job(session.jobId, `funded the fee (${expectedFee} atomic)`); + } catch (err) { + log.error(`funding failed on job ${session.jobId}`, err); + } + break; + } + + case "job.submitted": { + // The destination chain comes from THIS job's requirement, not a cached + // constant. + const req = extractTransferRequirement(session); + if (!req) { + log.job(session.jobId, "no transfer requirement in job — rejecting"); + try { + await session.reject("Could not recover the transfer requirement"); + } catch (err) { + log.error(`reject failed on job ${session.jobId}`, err); + } + return; + } + const expectedDestChainId = req.toChainId; + const proof = parseSettlementDeliverable(entry.event.deliverable); + if (!proof) { + log.job(session.jobId, "deliverable is not a settlement proof — rejecting"); + try { + await session.reject("Deliverable is not a valid settlement proof"); + } catch (err) { + log.error(`reject failed on job ${session.jobId}`, err); + } + return; + } + // The settlement must be on the transfer's destination chain. A + // well-formed proof mined on any other chain does not settle this job. + if (proof.chainId !== expectedDestChainId) { + log.job( + session.jobId, + `settlement on chain ${proof.chainId}, expected destination ${expectedDestChainId} — rejecting` + ); + try { + await session.reject( + `Settlement proof chain ${proof.chainId} != expected destination ${expectedDestChainId}` + ); + } catch (err) { + log.error(`reject failed on job ${session.jobId}`, err); + } + return; + } + // A production evaluator confirms proof.settlementTxHash exists on + // proof.chainId (via RPC / explorer) and moved the expected notional to + // the recipient. This example only checks the proof's shape and + // destination chain, so completing here does not prove the transfer + // happened. This is why the job needs an evaluator: this buyer + // self-evaluates via evaluatorAddress, and under skip-evaluation the + // fee would auto-release on submit with no settlement check. + log.job( + session.jobId, + `settlement proof: ${proof.settlementTxHash} on chain ${proof.chainId}` + ); + try { + await session.complete("Settlement verified"); + } catch (err) { + log.error(`completion failed on job ${session.jobId}`, err); + } + break; + } + + case "job.completed": + log.job(session.jobId, "completed"); + log.info("---- transcript ----"); + console.log(await session.toContext()); + log.info("---- end transcript ----"); + await buyer.stop(); + break; + + case "job.rejected": + log.job( + session.jobId, + `rejected by ${shortAddr(entry.event.rejector)}: ${entry.event.reason}` + ); + await buyer.stop(); + break; + + case "job.expired": + log.job(session.jobId, "expired"); + await buyer.stop(); + break; + } + }); + + await buyer.start(); + log.info("ready"); + + const shutdown = async (signal: NodeJS.Signals) => { + log.info(`received ${signal}, shutting down`); + await buyer.stop(); + process.exit(0); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); + + const sellerWallet = requireEnv("SELLER_WALLET_ADDRESS"); + log.info(`creating transfer job with seller ${shortAddr(sellerWallet)}`); + log.info(`requirement: ${JSON.stringify(requirementData)}`); + + try { + const jobId = await buyer.createJobFromOffering( + chain.id, + offering, + sellerWallet, + requirementData, + { evaluatorAddress: buyerAddress } + ); + log.job(jobId.toString(), "created — waiting for seller to set the fee"); + } catch (err) { + log.error("createJobFromOffering failed", err); + await buyer.stop(); + } +} + +main().catch(console.error); diff --git a/src/examples/off-escrow-percentage/jobTypes.ts b/src/examples/off-escrow-percentage/jobTypes.ts new file mode 100644 index 0000000..1ab3f03 --- /dev/null +++ b/src/examples/off-escrow-percentage/jobTypes.ts @@ -0,0 +1,170 @@ +import type { AcpAgentOffering, FeeUnit } from "../../index.js"; + +/** + * Shared types + constants for the off-escrow proportional-fee example. + * + * The offering sells a cross-chain stablecoin transfer as a pure storefront: + * the buyer signs a Xochi intent (ERC-3009 / Permit2), the principal moves + * off-escrow directly from the buyer's wallet to the destination chain, and ACP + * only ever escrows the proportional facilitator fee (a few bps). See the + * folder README and Raxol issue #373. + */ + +export type HexAddress = `0x${string}`; + +/** + * Fee unit for this example. Whether a percentage offering's priceValue is + * basis points or percent is a registry convention that must be confirmed + * before relying on it. Buyer and seller share this one constant so they always + * agree, and `computePercentageFee` takes the unit explicitly. + */ +export const FEE_UNIT: FeeUnit = "bps"; + +/** Default fee rate in `FEE_UNIT` (8 bps). Override with OFF_ESCROW_FEE_RATE. */ +export const DEFAULT_FEE_RATE = 8; + +/** Offering name === on-chain `job.description`; the seller routes on it. */ +export const XOCHI_TRANSFER_OFFERING_NAME = + "xochi_cross_chain_transfer" as const; + +/** Requirement field the offering points `feeBasisField` at. */ +export const FEE_BASIS_FIELD = "notionalAtomic" as const; + +/** + * Stub for the buyer's signed Xochi intent (an ERC-3009 / Permit2 bundle). In + * the real flow this is produced by + * `Raxol.Payments.Protocols.Xochi.quote_and_sign/3` and its signature binds + * `boundNotionalAtomic`. Here we keep only the bound notional so the seller can + * cross-check it against the declared notional. + * + * TODO: replace with the real signed intent bundle + on-chain intent decode. + */ +export type SignedIntentStub = { + boundNotionalAtomic: string; + signature: string; +}; + +export type TransferRequirement = { + fromChainId: number; + toChainId: number; + token: HexAddress; + /** Fee notional in atomic units, decimal string (the `feeBasisField`). */ + notionalAtomic: string; + recipient: HexAddress; + signedIntent: SignedIntentStub; +}; + +function isHexAddress(x: unknown): x is HexAddress { + return typeof x === "string" && /^0x[a-fA-F0-9]{40}$/.test(x); +} + +export function parseTransferRequirement( + data: unknown +): TransferRequirement | null { + if (!data || typeof data !== "object") return null; + const o = data as Record; + const si = o.signedIntent as Record | undefined; + if ( + typeof o.fromChainId !== "number" || + typeof o.toChainId !== "number" || + !isHexAddress(o.token) || + typeof o.notionalAtomic !== "string" || + !/^\d+$/.test(o.notionalAtomic) || + !isHexAddress(o.recipient) || + !si || + typeof si.boundNotionalAtomic !== "string" || + typeof si.signature !== "string" + ) { + return null; + } + return { + fromChainId: o.fromChainId, + toChainId: o.toChainId, + token: o.token, + notionalAtomic: o.notionalAtomic, + recipient: o.recipient, + signedIntent: { + boundNotionalAtomic: si.boundNotionalAtomic, + signature: si.signature, + }, + }; +} + +/** + * Notional bound in the (stub) signed intent, as bigint atomic units. + * + * This reads a plaintext field the buyer supplied, so it is not a real check on + * its own: the buyer can set `boundNotionalAtomic` to any value. It stands in + * for recovering the notional from the verified intent signature (Permit2 / + * ERC-3009), which the buyer cannot forge. `assertNotionalMatches` is only as + * trustworthy as the value this returns, so replace the stub with signature + * verification before using it with live funds. + */ +export function boundNotionalFromIntent(intent: SignedIntentStub): bigint { + return BigInt(intent.boundNotionalAtomic); +} + +/** + * Locally-constructed percentage offering (`requiredFunds: false`). + * + * The registry does not yet allow listing this shape, but the SDK and the + * on-chain contract already support the job flow, so the offering is built here + * to run the demo end to end without a registry row. + */ +export function buildTransferOffering(feeRate: number): AcpAgentOffering { + return { + name: XOCHI_TRANSFER_OFFERING_NAME, + description: + "Off-escrow cross-chain stablecoin transfer. ACP escrows only the " + + "proportional facilitator fee; the principal moves via the buyer's " + + "signed intent.", + requirements: { + type: "object", + required: [ + "fromChainId", + "toChainId", + "token", + FEE_BASIS_FIELD, + "recipient", + "signedIntent", + ], + properties: { + fromChainId: { type: "number" }, + toChainId: { type: "number" }, + token: { type: "string" }, + [FEE_BASIS_FIELD]: { type: "string", pattern: "^\\d+$" }, + recipient: { type: "string" }, + signedIntent: { type: "object" }, + }, + }, + deliverable: { + type: "object", + required: ["kind", "settlementTxHash", "chainId"], + properties: { + kind: { const: "settlement" }, + settlementTxHash: { type: "string" }, + chainId: { type: "number" }, + }, + }, + slaMinutes: 5, + priceType: "percentage", + priceValue: feeRate, + requiredFunds: false, + feeBasisField: FEE_BASIS_FIELD, + isHidden: false, + isPrivate: false, + }; +} + +/** Sample requirement: transfer 1000 USDC (6-dec atomic) Base -> Arbitrum. */ +export const exampleTransferRequirement: TransferRequirement = { + fromChainId: 8453, + toChainId: 42161, + token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as HexAddress, + notionalAtomic: "1000000000", + recipient: "0x000000000000000000000000000000000000dEaD" as HexAddress, + signedIntent: { + boundNotionalAtomic: "1000000000", + signature: "0xstub", + }, +}; diff --git a/src/examples/off-escrow-percentage/seller.ts b/src/examples/off-escrow-percentage/seller.ts new file mode 100644 index 0000000..9da0976 --- /dev/null +++ b/src/examples/off-escrow-percentage/seller.ts @@ -0,0 +1,264 @@ +import { base } from "@account-kit/infra"; +import dotenv from "dotenv"; +import { + AcpAgent, + AssetToken, + PrivyAlchemyEvmProviderAdapter, + assertNotionalMatches, + buildSettlementDeliverable, + computePercentageFee, + type JobRoomEntry, + type JobSession, +} from "../../index.js"; +import { + DEFAULT_FEE_RATE, + FEE_UNIT, + XOCHI_TRANSFER_OFFERING_NAME, + boundNotionalFromIntent, + buildTransferOffering, + parseTransferRequirement, + type TransferRequirement, +} from "./jobTypes.js"; + +dotenv.config({ quiet: true }); + +// --------------------------------------------------------------------------- +// Off-escrow proportional-fee seller (facilitator). +// +// requirement message → parse; check the declared notional against the +// notional bound in the buyer's signed intent (reject a +// mismatch); compute fee = rate * notional; +// session.setBudget(fee) escrows only the fee +// budget.set → buyer funds the fee +// job.funded → relay the signed intent to Xochi (stubbed), then +// submit the settlement tx hash as the deliverable +// job.completed → transcript +// +// The principal never touches this wallet; the SDK escrows only the fee. +// +// Required env: SELLER_WALLET_ADDRESS, SELLER_WALLET_ID, SELLER_SIGNER_PRIVATE_KEY. +// Optional: OFF_ESCROW_FEE_RATE (default 8; must match the buyer). +// --------------------------------------------------------------------------- + +const chain = base; + +function requireEnv(name: string): string { + const v = process.env[name]; + if (!v) throw new Error(`Missing required env var: ${name}`); + return v; +} + +const shortAddr = (a: string): string => + !a || !a.startsWith("0x") || a.length < 12 + ? a + : `${a.slice(0, 6)}…${a.slice(-4)}`; + +const log = { + info: (m: string) => console.log(`[seller-offesc] ${m}`), + job: (id: string | number, m: string) => + console.log(`[seller-offesc] [job ${id}] ${m}`), + warn: (m: string) => console.warn(`[seller-offesc] [warn] ${m}`), + error: (m: string, e?: unknown) => + console.error(`[seller-offesc] [error] ${m}`, e ?? ""), +}; + +/** Recover the transfer requirement from the job's requirement message. */ +function extractTransferRequirement( + session: JobSession +): TransferRequirement | null { + for (const e of session.entries) { + if (e.kind === "message" && e.contentType === "requirement") { + try { + return parseTransferRequirement(JSON.parse(e.content)); + } catch { + return null; + } + } + } + return null; +} + +/** + * Placeholder for the Xochi relay. The real relay hands the buyer's signed + * intent to Raxol.ACP.Xochi.Settler (`execute_signed/2`) and polls to + * settlement on the destination chain (`toChainId`), returning the settlement + * tx hash mined there. No funds move here; returns a deterministic fake hash. + */ +async function relaySignedIntentToXochi( + jobId: string, + toChainId: number +): Promise { + void toChainId; // the real relay settles on toChainId + return `0x${jobId.replace(/\D/g, "").padStart(64, "0").slice(0, 64)}`; +} + +async function main(): Promise { + const feeRate = Number(process.env.OFF_ESCROW_FEE_RATE ?? DEFAULT_FEE_RATE); + const offering = buildTransferOffering(feeRate); + + const seller = await AcpAgent.create({ + provider: await PrivyAlchemyEvmProviderAdapter.create({ + walletAddress: requireEnv("SELLER_WALLET_ADDRESS") as `0x${string}`, + walletId: requireEnv("SELLER_WALLET_ID"), + signerPrivateKey: requireEnv("SELLER_SIGNER_PRIVATE_KEY"), + chains: [chain], + }), + }); + + const sellerAddressLower = (await seller.getAddress()).toLowerCase(); + log.info(`address: ${sellerAddressLower}`); + log.info( + `offering "${offering.name}": ${feeRate} ${FEE_UNIT} fee, requiredFunds=false` + ); + + seller.on("entry", async (session: JobSession, entry: JobRoomEntry) => { + if (entry.kind === "system") { + switch (entry.event.type) { + case "job.created": + log.job( + session.jobId, + `new transfer job from ${shortAddr(entry.event.client)}` + ); + break; + + case "job.funded": { + const req = extractTransferRequirement(session); + if (!req) { + // A funded job always has a parseable requirement (the budget was + // set from it), so this is an unexpected room. Surface it instead + // of stalling; the job expires at its SLA and the fee refunds. + log.error( + `job ${session.jobId}: could not recover transfer requirement to relay` + ); + try { + await session.sendMessage( + "Cannot recover the transfer requirement for this job; not " + + "submitting a deliverable. The job will expire at its SLA " + + "and the fee will be refunded." + ); + } catch (err) { + log.error(`notify failed on job ${session.jobId}`, err); + } + break; + } + log.job( + session.jobId, + `fee funded, relaying transfer to chain ${req.toChainId}` + ); + try { + const settlementTxHash = await relaySignedIntentToXochi( + session.jobId, + req.toChainId + ); + // The deliverable's chainId is the DESTINATION chain where the + // settlement tx was mined, not the ACP job chain (Base). + const deliverable = buildSettlementDeliverable({ + settlementTxHash, + chainId: req.toChainId, + notional: req.notionalAtomic, + token: req.token, + }); + await session.submit(deliverable); + log.job(session.jobId, `submitted settlement ${settlementTxHash}`); + } catch (err) { + log.error(`relay/submit failed on job ${session.jobId}`, err); + } + break; + } + + case "job.completed": + log.job(session.jobId, "completed (fee released)"); + break; + + case "job.rejected": + log.job( + session.jobId, + `rejected by ${shortAddr(entry.event.rejector)}: ${entry.event.reason}` + ); + break; + + case "job.expired": + log.job(session.jobId, "expired"); + break; + } + } + + if ( + entry.kind === "message" && + entry.contentType === "requirement" && + session.status === "open" + ) { + const rejectWithDetail = async ( + tag: string, + detail: string + ): Promise => { + log.job(session.jobId, `rejecting (${tag}): ${detail}`); + await session.sendMessage(detail); + await session.reject(tag); + }; + + if (session.job?.description !== XOCHI_TRANSFER_OFFERING_NAME) { + await rejectWithDetail( + "unknown offering", + `This seller only handles "${XOCHI_TRANSFER_OFFERING_NAME}" jobs` + ); + return; + } + + let parsed: unknown; + try { + parsed = JSON.parse(entry.content); + } catch (err) { + await rejectWithDetail( + "unparseable requirement", + `Could not parse requirement payload: ${err}` + ); + return; + } + + const req = parseTransferRequirement(parsed); + if (!req) { + await rejectWithDetail( + "invalid requirement", + "Requirement does not match the expected transfer shape" + ); + return; + } + + try { + // The declared notional must match the notional bound in the buyer's + // signed intent; reject before setting a budget if it doesn't. The stub + // boundNotionalFromIntent trusts a plaintext field — production code + // verifies the signature instead (see boundNotionalFromIntent). + const declared = BigInt(req.notionalAtomic); + assertNotionalMatches(declared, boundNotionalFromIntent(req.signedIntent)); + + const fee = computePercentageFee(declared, offering.priceValue, FEE_UNIT); + log.job( + session.jobId, + `notional ${declared} → fee ${fee} atomic (${feeRate} ${FEE_UNIT})` + ); + // computePercentageFee returns the fee in the notional token's atomic + // units. Here the notional token is Base USDC, so it maps directly to a + // USDC budget; normalize first if the notional and fee tokens differ. + await session.setBudget(AssetToken.usdcFromRaw(fee, session.chainId)); + log.job(session.jobId, "set budget to the fee"); + } catch (err) { + await rejectWithDetail("notional/fee error", String(err)); + } + } + }); + + await seller.start(); + log.info("ready, listening for jobs"); + + const shutdown = async (signal: NodeJS.Signals) => { + log.info(`received ${signal}, shutting down`); + await seller.stop(); + process.exit(0); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); +} + +main().catch(console.error); diff --git a/src/index.ts b/src/index.ts index 9496dcb..90792d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,8 @@ export * from "./core/chains.js"; export * from "./core/constants.js"; export * from "./core/assetToken.js"; export * from "./core/approvalGate.js"; +export * from "./core/fee.js"; +export * from "./core/settlement.js"; // Provider interfaces & adapters export * from "./providers/types.js"; @@ -33,7 +35,7 @@ export { AcpApiClient } from "./events/acpApiClient.js"; export { SseTransport, STREAMS } from "./events/sseTransport.js"; // Public enums -export { AcpJobStatus } from "./events/types.js"; +export { AcpJobStatus, PRICE_TYPE } from "./events/types.js"; // Event / room types (public) export type { @@ -64,6 +66,7 @@ export type { BrowseAgentParams, FundIntent, SupportedStreams, + PriceType, } from "./events/types.js"; // Utilities