logicsrc/packages/agentad/src/exchange.test.ts
Anthony Ettinger 7d62e3b661
Some checks failed
CI / build (push) Has been cancelled
test / test (push) Has been cancelled
feat(agentad): AgentAd Marketplace PRD + reference exchange (M5)
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>
2026-07-01 10:29:36 +00:00

338 lines
11 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { AgentAdExchange } from "./exchange.js";
import { InMemorySettlement } from "./settlement.js";
import { createAd, createCampaign, createPlacement, createAdRequest } from "./builders.js";
import { validate } from "./validate.js";
import type { Ad } from "./types.js";
const NOW = Date.parse("2026-07-01T00:00:00Z");
const clock = () => NOW;
function newExchange(feeRate = 0.15) {
return new AgentAdExchange({
secret: "test-secret",
now: clock,
settlement: new InMemorySettlement({ networkFeeRate: feeRate })
});
}
function jsonPlacement(exchange: AgentAdExchange, overrides = {}) {
return exchange.registerPlacement(
createPlacement({
publisher_did: "agentbbs.sh",
surface: "agent",
accepted_formats: ["json"],
...overrides
})
);
}
function activeCampaign(exchange: AgentAdExchange, total = 100) {
const campaign = createCampaign({
advertiser_did: "railway.app",
name: "Launch",
status: "active",
budget: { total, currency: "USD" }
});
return exchange.registerCampaign(campaign);
}
describe("AgentAdExchange serving", () => {
let exchange: AgentAdExchange;
beforeEach(() => {
exchange = newExchange();
});
it("serves a matching, disclosed ad and produces a schema-valid response", () => {
const campaign = activeCampaign(exchange);
exchange.registerAd(
createAd({
advertiser_did: "railway.app",
campaign_id: campaign.id,
format: "json",
title: "Ship your CLI in 60s",
url: "https://railway.app/?ref=cl1s",
pricing: { model: "cpc", bid: 0.5, currency: "USD" },
machine_readable: { product: "railway" }
})
);
const placement = jsonPlacement(exchange);
const res = exchange.requestAds(
createAdRequest({ placement_id: placement.id, consumer: "agent" })
);
expect(res.no_fill_reason).toBeUndefined();
expect(res.ads).toHaveLength(1);
expect(validate("agentad-ad-response", res).ok).toBe(true);
const rendered = JSON.parse(res.ads[0].rendered ?? "{}");
expect(rendered.sponsored).toBe(true);
expect(rendered.data.product).toBe("railway");
});
it("no-fills with no_inventory when nothing matches", () => {
jsonPlacement(exchange);
const placementId = jsonPlacement(exchange).id;
const res = exchange.requestAds(createAdRequest({ placement_id: placementId }));
expect(res.ads).toHaveLength(0);
expect(res.no_fill_reason).toBe("no_inventory");
});
it("no-fills with invalid_request for an unknown placement", () => {
const res = exchange.requestAds(createAdRequest({ placement_id: "does-not-exist" }));
expect(res.no_fill_reason).toBe("invalid_request");
});
it("does not serve ads from draft campaigns", () => {
const draft = exchange.registerCampaign(
createCampaign({
advertiser_did: "railway.app",
name: "Draft",
budget: { total: 100, currency: "USD" }
})
);
exchange.registerAd(
createAd({
advertiser_did: "railway.app",
campaign_id: draft.id,
format: "json",
title: "Draft ad",
url: "https://x.dev",
pricing: { model: "cpm", bid: 5, currency: "USD" }
})
);
const placement = jsonPlacement(exchange);
expect(exchange.requestAds(createAdRequest({ placement_id: placement.id })).no_fill_reason).toBe(
"no_inventory"
);
});
it("blocks ads whose category is on the placement block list", () => {
const campaign = activeCampaign(exchange);
exchange.registerAd(
createAd({
advertiser_did: "railway.app",
campaign_id: campaign.id,
format: "json",
title: "Buy coins",
url: "https://coins.example",
pricing: { model: "cpm", bid: 5, currency: "USD" },
machine_readable: { category: "crypto" }
})
);
const placement = jsonPlacement(exchange, { block_categories: ["crypto"] });
expect(exchange.requestAds(createAdRequest({ placement_id: placement.id })).no_fill_reason).toBe(
"blocked_category"
);
});
it("refuses to register an undisclosed ad", () => {
const campaign = activeCampaign(exchange);
const undisclosed = {
type: "agentad.ad",
version: "0.1",
id: "ad-bad",
campaign_id: campaign.id,
advertiser_did: "railway.app",
format: "text",
title: "Sneaky",
url: "https://x.dev",
disclosure: { sponsored: false, label: "Sponsored" }
} as unknown as Ad;
expect(() => exchange.registerAd(undisclosed)).toThrow();
});
});
describe("AgentAdExchange metering & settlement", () => {
it("charges CPC on click (not impression) and pays the publisher net of fee", () => {
const exchange = newExchange(0.15);
const campaign = activeCampaign(exchange);
exchange.registerAd(
createAd({
advertiser_did: "railway.app",
campaign_id: campaign.id,
format: "json",
title: "CPC ad",
url: "https://railway.app",
pricing: { model: "cpc", bid: 1, currency: "USD" }
})
);
const placement = jsonPlacement(exchange);
const res = exchange.requestAds(createAdRequest({ placement_id: placement.id }));
const imp = exchange.confirmImpression(res.ads[0].impression_token);
expect(imp.charged).toBe(0); // cpc is billed on click
expect(validate("agentad-impression", imp.impression).ok).toBe(true);
const click = exchange.confirmClick(imp.click_token, { action: "open_url" });
expect(validate("agentad-click", click).ok).toBe(true);
// single candidate → pays own bid ($1); publisher gets 85%
expect(exchange.earnings("agentbbs.sh")).toBeCloseTo(0.85, 6);
expect(exchange.remaining(campaign.id)).toBeCloseTo(99, 6);
expect(exchange.ledger().filter((e) => e.kind === "click" && e.amount > 0)).toHaveLength(1);
});
it("charges CPM on impression", () => {
const exchange = newExchange(0);
const campaign = activeCampaign(exchange, 100);
exchange.registerAd(
createAd({
advertiser_did: "railway.app",
campaign_id: campaign.id,
format: "json",
title: "CPM ad",
url: "https://railway.app",
pricing: { model: "cpm", bid: 10, currency: "USD" } // $10 CPM = $0.01 / impression
})
);
const placement = jsonPlacement(exchange);
const res = exchange.requestAds(createAdRequest({ placement_id: placement.id }));
const imp = exchange.confirmImpression(res.ads[0].impression_token);
expect(imp.charged).toBeCloseTo(0.01, 6);
expect(exchange.earnings("agentbbs.sh")).toBeCloseTo(0.01, 6); // 0% fee
});
it("runs a second-price auction: the winner pays the runner-up's price", () => {
const exchange = newExchange(0);
const high = exchange.registerCampaign(
createCampaign({
advertiser_did: "high.dev",
name: "High",
status: "active",
budget: { total: 100, currency: "USD" }
})
);
const low = exchange.registerCampaign(
createCampaign({
advertiser_did: "low.dev",
name: "Low",
status: "active",
budget: { total: 100, currency: "USD" }
})
);
exchange.registerAd(
createAd({
advertiser_did: "high.dev",
campaign_id: high.id,
format: "json",
title: "High bid",
url: "https://high.dev",
pricing: { model: "cpc", bid: 1, currency: "USD" }
})
);
exchange.registerAd(
createAd({
advertiser_did: "low.dev",
campaign_id: low.id,
format: "json",
title: "Low bid",
url: "https://low.dev",
pricing: { model: "cpc", bid: 0.5, currency: "USD" }
})
);
const placement = jsonPlacement(exchange);
const res = exchange.requestAds(createAdRequest({ placement_id: placement.id }));
expect(res.ads).toHaveLength(1);
// winner is high.dev; clearing factor = 0.5/1 → charged 0.5 on click
const imp = exchange.confirmImpression(res.ads[0].impression_token);
exchange.confirmClick(imp.click_token);
expect(exchange.remaining(high.id)).toBeCloseTo(99.5, 6);
expect(exchange.remaining(low.id)).toBeCloseTo(100, 6); // runner-up not charged
});
it("never charges beyond escrowed budget", () => {
const exchange = newExchange(0);
const campaign = exchange.registerCampaign(
createCampaign({
advertiser_did: "railway.app",
name: "Tiny",
status: "active",
budget: { total: 0.005, currency: "USD" }
})
);
exchange.registerAd(
createAd({
advertiser_did: "railway.app",
campaign_id: campaign.id,
format: "json",
title: "CPM ad",
url: "https://railway.app",
pricing: { model: "cpm", bid: 10, currency: "USD" } // wants $0.01 / impression
})
);
const placement = jsonPlacement(exchange);
const res = exchange.requestAds(createAdRequest({ placement_id: placement.id }));
const imp = exchange.confirmImpression(res.ads[0].impression_token);
expect(imp.charged).toBeCloseTo(0.005, 6); // capped at escrow
expect(exchange.remaining(campaign.id)).toBe(0);
// budget exhausted → no more inventory
expect(exchange.requestAds(createAdRequest({ placement_id: placement.id })).no_fill_reason).toBe(
"no_inventory"
);
});
it("rejects reused and forged tokens", () => {
const exchange = newExchange();
const campaign = activeCampaign(exchange);
exchange.registerAd(
createAd({
advertiser_did: "railway.app",
campaign_id: campaign.id,
format: "json",
title: "Ad",
url: "https://railway.app",
pricing: { model: "cpc", bid: 1, currency: "USD" }
})
);
const placement = jsonPlacement(exchange);
const res = exchange.requestAds(createAdRequest({ placement_id: placement.id }));
const token = res.ads[0].impression_token;
exchange.confirmImpression(token);
expect(() => exchange.confirmImpression(token)).toThrow(/already consumed/);
expect(() => exchange.confirmImpression("garbage.token")).toThrow(/invalid impression token/);
});
});
describe("AgentAdExchange frequency capping", () => {
it("caps impressions per session", () => {
const exchange = newExchange();
const campaign = activeCampaign(exchange);
exchange.registerAd(
createAd({
advertiser_did: "railway.app",
campaign_id: campaign.id,
format: "json",
title: "Ad",
url: "https://railway.app",
pricing: { model: "cpm", bid: 5, currency: "USD" }
})
);
const placement = jsonPlacement(exchange, { frequency_cap: { max_per_session: 1 } });
const first = exchange.requestAds(
createAdRequest({ placement_id: placement.id }),
{ sessionId: "s1" }
);
expect(first.ads).toHaveLength(1);
const second = exchange.requestAds(
createAdRequest({ placement_id: placement.id }),
{ sessionId: "s1" }
);
expect(second.no_fill_reason).toBe("frequency_capped");
// a different session is unaffected
const other = exchange.requestAds(
createAdRequest({ placement_id: placement.id }),
{ sessionId: "s2" }
);
expect(other.ads).toHaveLength(1);
});
});