mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-14 06:47:28 +00:00
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>
This commit is contained in:
parent
0bca203527
commit
7d62e3b661
18 changed files with 1862 additions and 2 deletions
100
packages/agentad/src/settlement.ts
Normal file
100
packages/agentad/src/settlement.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue