mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-03 05:07:10 +00:00
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>
338 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|