feat(agentad): AgentAd Marketplace PRD + reference exchange (M5)
Some checks failed
CI / build (push) Has been cancelled
test / test (push) Has been cancelled

Add the AgentBBS M5 "AgentAd marketplace" spec and a working reference
implementation built on the existing @logicsrc/schemas AgentAd contracts.

- docs/agentad-marketplace.md: two-sided exchange PRD (buy/sell sides,
  match -> auction -> pace -> serve -> meter -> settle, CoinPay settlement,
  AgentBBS as reference publisher, milestones M5.0-M5.5).
- packages/agentad (@logicsrc/agentad): reference exchange
  - builders that emit schema-valid, always-disclosed ad/campaign/placement docs
  - HMAC-signed, single-use impression/click tracking tokens
  - AgentAdExchange: targeting/format/category matching, second-price auction,
    budget pacing + daily caps, frequency capping, token-driven metering
  - pluggable settlement (InMemorySettlement) that can't overspend escrow
  - runtime validation against the canonical agentad-*.schema.json
- 22 vitest cases (builders, tokens, full serve/meter/settle lifecycle).
- Wire package into root build; link the PRD from README + docs/agentad.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-07-01 10:29:18 +00:00
parent 0bca203527
commit 7d62e3b661
18 changed files with 1862 additions and 2 deletions

View file

@ -0,0 +1,100 @@
// Settlement abstraction. In production this is backed by the CoinPay plugin
// (DID balances, escrow, payouts). The in-memory implementation here is the
// reference used by tests and local development; it enforces the one invariant
// that matters: an advertiser can never be charged beyond what it escrowed.
export interface SettlementProvider {
/** Lock `amount` of an advertiser's balance to a campaign. */
escrow(campaignId: string, advertiserDid: string, amount: number, currency: string): void;
/** How much escrow remains unspent for a campaign. */
remaining(campaignId: string): number;
/**
* Charge the advertiser and credit the publisher (minus network fee).
* Returns the amount actually charged (0 if escrow was exhausted).
*/
charge(input: {
campaignId: string;
publisherDid: string;
amount: number;
currency: string;
}): number;
/** Total credited to a publisher, net of fees. */
earnings(publisherDid: string): number;
/** Total network fee collected. */
fees(): number;
}
interface EscrowRecord {
advertiserDid: string;
currency: string;
locked: number;
spent: number;
}
export interface InMemorySettlementOptions {
/** Network take rate, 0..1. Default 0.15. */
networkFeeRate?: number;
}
export class InMemorySettlement implements SettlementProvider {
private readonly escrows = new Map<string, EscrowRecord>();
private readonly publisherEarnings = new Map<string, number>();
private feePool = 0;
private readonly feeRate: number;
constructor(options: InMemorySettlementOptions = {}) {
const rate = options.networkFeeRate ?? 0.15;
if (rate < 0 || rate >= 1) {
throw new Error(`networkFeeRate must be in [0, 1), got ${rate}`);
}
this.feeRate = rate;
}
escrow(campaignId: string, advertiserDid: string, amount: number, currency: string): void {
if (amount < 0) throw new Error("escrow amount must be >= 0");
const existing = this.escrows.get(campaignId);
if (existing) {
if (existing.currency !== currency) {
throw new Error(`campaign ${campaignId} escrow currency mismatch`);
}
existing.locked += amount;
return;
}
this.escrows.set(campaignId, { advertiserDid, currency, locked: amount, spent: 0 });
}
remaining(campaignId: string): number {
const rec = this.escrows.get(campaignId);
if (!rec) return 0;
return Math.max(0, rec.locked - rec.spent);
}
charge(input: { campaignId: string; publisherDid: string; amount: number; currency: string }): number {
const rec = this.escrows.get(input.campaignId);
if (!rec || input.amount <= 0) return 0;
if (rec.currency !== input.currency) {
throw new Error(`campaign ${input.campaignId} charge currency mismatch`);
}
const available = Math.max(0, rec.locked - rec.spent);
const charged = Math.min(available, input.amount);
if (charged <= 0) return 0;
rec.spent += charged;
const fee = charged * this.feeRate;
this.feePool += fee;
this.publisherEarnings.set(
input.publisherDid,
(this.publisherEarnings.get(input.publisherDid) ?? 0) + (charged - fee)
);
return charged;
}
earnings(publisherDid: string): number {
return this.publisherEarnings.get(publisherDid) ?? 0;
}
fees(): number {
return this.feePool;
}
}