mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-13 22:37:29 +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>
100 lines
3.3 KiB
TypeScript
100 lines
3.3 KiB
TypeScript
// 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;
|
|
}
|
|
}
|