# SovAds Protocol — LLM Context > SovAds is a decentralized ad protocol settling on Celo in GoodDollar (G$). > Publishers earn for verified impressions, viewers earn SovPoints > (1 SovPoint = 1 G$), advertisers fund campaigns and optionally attach > CTA tasks. This file is the canonical compact summary for AI assistants. > Treat anything not listed here as not yet shipped. Base URL: https://ads.sovseas.xyz Chain: Celo mainnet (chain id 42220) Settlement token: GoodDollar G$ — 0x62B8B11039FcfE5aB0C56E502b1C372A3d2a9c7A Streaming contract: SovAdsStreaming — 0xFb76103FC70702413cEa55805089106D0626823f Legacy manager: SovAdsManager — 0xcE580DfF039cA6b3516A496Bf55814Ce1d66F66a Each `/docs/` page is also served as raw markdown at `/docs/.mdx` for AI ingestion. ## SDK package NPM: `sovads-sdk` CDN: `https://ads.sovseas.xyz/_sovads_sdk/index.js` (ES module) ### Exports - `SovAds(config)` — root client - `Banner(ads, containerId, slotConfig?)` — inline rectangular ad - `Popup(ads)` — modal, frequency-capped - `Sidebar(ads, containerId)` — vertical placement - `BottomBar(ads)` — fixed bottom bar ### SovAds config ```ts new SovAds({ siteId?: string // site_ apiUrl?: string // default https://ads.sovseas.xyz apiKey?: string // signed tracking apiSecret?: string // server-side only debug?: boolean refreshInterval?: number // seconds, 0 disables rotation lazyLoad?: boolean // default true rotationEnabled?: boolean popupMinIntervalMinutes?: number // default 30 popupSessionMax?: number // default 1 walletAddress?: string consumerId?: string // target one advertiser disclosureLabel?: boolean | string // "Sponsored" label advertiserName?: string }) ``` ### Public methods - `ads.identify(wallet: string): void` — link fingerprint to wallet - `ads.onIdentify(cb): () => void` — subscribe to identity changes - `ads.loadAd(opts?): Promise` — manual ad fetch - `new Banner(ads, containerId, slotConfig?).render(): Promise` - `new Popup(ads).show(): Promise` - `new Sidebar(ads, containerId).render(): Promise` - `new BottomBar(ads).show(): Promise` ### SlotConfig ```ts { placementId?: string size?: string // e.g. "300x250" attached?: boolean // render inline CTA chips under banner onCtaComplete?: (ev) => void clickTarget?: 'media' | 'button' disclosureLabel?: boolean | string } ``` ### Attached CTAs (inline under a Banner) Pass `attached: true` to a Banner. Up to **2 inline CTA chips** render below the creative. Inline-allowed kinds: `VISIT_URL`, `SIGN_MESSAGE`, `POLL`. Other kinds live on standalone unit pages. ### LocalStorage keys - `sovads_fingerprint_v1` — anonymous device id - `sovads_wallet_address` — last connected wallet ## HTTP API (CORS open for SDK routes) ### Ad serving (banners + standalone units) `GET /api/serve?siteId=…&kind=BANNER,POLL,FEEDBACK,SURVEY&size=300x250&placement=…&wallet=0x…&attached=1` `kind` is a comma-separated allowlist; default is `BANNER`. Response is a discriminated union: - `{ kind: "BANNER", ad: {...}, bannerClickActive: bool, attachedTasks: [...] }` - `{ kind: "POLL"|"FEEDBACK"|"SURVEY", task: {...} }` - `{ kind: "NONE" }` `bannerClickActive` drops to `false` when the campaign has spent its budget. The banner stays visible; attached CTAs keep awarding SovPoints as a fallback. ### Tracking `POST /api/track` ```json { "type": "IMPRESSION" | "CLICK", "campaignId": "cmp_…", "adId": "ad_…", "siteId": "site_…", "fingerprint": "fp_…", "rendered": true, "viewportVisible": true, "renderTime": 142, "timestamp": 1735200000000, "pageUrl": "https://…", "userAgent": "…" } ``` Deduped server-side by fingerprint + campaign + type, 1h window. ### Standalone units (POLL / FEEDBACK / SURVEY) `POST /api/interactions` ```json { "taskId": "task_…", "wallet": "0x…", // optional, fingerprint is enough "fingerprint": "fp_…", "siteId": "site_…", "sessionId": "ss_…", // SURVEY only; resumes prior state "step": 1, // SURVEY only "final": false, // SURVEY only; true on last step "proof": { "optionId"?, "rating"?, "text"?, "answers"? } } ``` Multi-step SURVEYs use a `survey_sessions` row keyed by `taskId` + `fingerprint`. Sessions have `status: in_progress | completed | abandoned` plus `currentStep / totalSteps`. The viewer can disconnect and resume; the server keeps the pointer in Postgres and the answer payload in Turso. ### Task completion (CTAs) `POST /api/tasks/complete` ```json { "taskId": "task_…", "wallet": "0x…", "fingerprint": "fp_…", "proof": { "dwellMs"?: 3500, // VISIT_URL "signature"?: "0x…", // SIGN_MESSAGE "message"?: "…", "txHash"?: "0x…", // ONCHAIN_EVENT "answer"?: "…", // QUIZ "optionId"?: "…", // POLL "rating"?: 5, // FEEDBACK "text"?: "…" } } ``` Response (on success): ```json { "success": true, "completionId": "tc_…", "awarded": { "points": 5, "gs": 0.5 }, "transaction": { "claimRef": "0x…", "signature": "0x…", // EIP-712 "nonce": "12", "deadline": "1735210000" } } ``` ### Viewer points & redeem - `GET /api/viewers/points?wallet=0x…` → totals, claimed, pending, last interaction, fingerprint - `POST /api/viewers/redeem` `{ wallet, amount }` → returns EIP-712 signed claim - `GET /api/viewers/redeem/health` → operator readiness probe - Minimum redeem = **10 SovPoints** - Submit on-chain via `SovAdsStreaming.claimWithSignature(...)` ### Publisher routes (wallet-ownership auth) - `POST /api/publishers/register` `{ wallet, domain }` → returns `siteId` - `GET /api/publishers/sites?wallet=0x…` - `GET /api/publishers/balance?siteId=…` - `POST /api/publishers/topup` - `POST /api/publishers/withdraw` - `POST /api/publishers/exchange` ### Advertiser / campaign routes (wallet-ownership auth) - `POST /api/campaigns/create` — saves as `status: draft` - `GET /api/campaigns/list?wallet=0x…` - `GET /api/campaigns/detail?id=…` - `PATCH /api/campaigns/update` - `DELETE /api/campaigns/delete` - `POST /api/campaigns/submit` — flips `draft` → `review` and publishes on-chain; admin sign-off flips to `approved | rejected` - `POST /api/tasks/create` — attach a CTA task to a campaign (or create a standalone task with `parentCampaignId`) ## Campaign lifecycle Campaigns now carry an explicit `status` enum independent of on-chain state: ``` draft → review → approved → rejected ↘ (admin) ``` - `draft` — saved by advertiser, not visible to viewers - `review` — submitted; on-chain creative is live but unverified - `approved` — admin sign-off, full distribution - `rejected` — admin block, ad pulled `submittedAt` and `approvedAt` are timestamped; the legacy `verificationStatus` field is backfilled but new code reads `status`. ## CTA task system **9 task kinds × 7 verifier types.** Tasks live on two surfaces. ### Surfaces | Surface | Field value | Where it appears | Linked to a campaign? | |---------|-------------|------------------|------------------------| | Attached | `surface: 'attached'` | Inline chips under a banner. Max 2 per ad. | Required parent `campaignId` | | Standalone | `surface: 'standalone'` | Dedicated unit pages, served via `/api/serve?kind=POLL,…` | Optional `parentCampaignId` for budget rollup | `campaign_tasks` rows also carry a `display: JSONB` blob for surface- specific presentation (icons, colours, ordering). ### Task kinds (with allowed verifiers) | Kind | Inline-allowed | Verifiers | |------|----------------|-----------| | `VISIT_URL` | yes | `ORACLE`, `WEBHOOK`, `AI_PLAN` | | `SIGN_MESSAGE` | yes | `SELF_SIGNED`, `ORACLE`, `WEBHOOK` | | `POLL` | yes | `ORACLE`, `WEBHOOK` | | `FEEDBACK` | no | `ORACLE`, `WEBHOOK` | | `SURVEY` | no | `ORACLE`, `WEBHOOK` | | `QUIZ` | no | `ORACLE`, `WEBHOOK` | | `SOCIAL_FOLLOW` | no | `ORACLE`, `WEBHOOK` | | `STAKE_GS` | no | `STAKE_PROOF`, `AI_PLAN` | | `CONTRACT_CALL` | no | `ONCHAIN_EVENT`, `AI_PLAN` | ### Verifier types - `ORACLE` — backend trusts SDK proof (with sanity checks) - `SELF_SIGNED` — wallet personal_sign of fixed message - `STAKE_PROOF` — on-chain read of stake balance - `ONCHAIN_EVENT` — verify tx receipt sender / target / event signature - `WEBHOOK` — HMAC-signed callback from advertiser server / pixel - `AI_PLAN` — Groq LLM-generated multi-step read-only on-chain plan - `MANUAL` — admin review ### AI plan storage (verifier = AI_PLAN) Plans are generated once and persisted on the task row: - `verificationPlan: JSONB` — the JSON plan executed at verify time - `contractAllowlist: TEXT[]` — addresses the plan may read - `planAuthor: TEXT` — wallet that authorised generation - `planModel: TEXT` — e.g. `llama-3.3-70b-versatile` - `planPrompt: TEXT` — the original `aiPrompt` input - `planGeneratedAt: TIMESTAMP` ### Task completion lifecycle `task_completions.status` transitions: ``` pending → verified → paid ↘ rejected ↘ failed ``` Each completion stores: `claimRef` (unique bytes32), `nonce`, `deadline`, operator `signature`, optional `payoutTxHash`, optional `error`. ### Reward fields on a task - `rewardPoints?: number` — SovPoints per completion - `rewardGs?: number` — G$ payout, minimum **6 G$** when set - `budgetGs?: number` — optional task-level cap; otherwise rolls up to `parentCampaignId` (or own `campaignId` for attached tasks) - `spentGs: number` — running total - `maxPerWallet?: number` - `cooldownSecs?: number` - `startDate?` / `endDate?` (ISO 8601) - `active: boolean` ## Webhook integration Use when `verifier: "WEBHOOK"`. Two delivery modes: ### Server postback `POST /api/cta-postback` Header: `X-Sovads-Sig` (alias `X-Signature`) — HMAC-SHA256 hex. Body: ```json { "taskId", "wallet"?, "fingerprint"?, "externalRef"?, "ts": 1735200000 } ``` Signing: canonical JSON (keys sorted alphabetically) HMAC'd with the per-task secret from the dashboard. ### Tracking pixel `GET /api/cta-pixel?taskId=…&wallet=…&ts=…&sig=HEX_HMAC&externalRef=…` Always returns a 43-byte transparent GIF (success / failure indistinguishable from the network). ### Replay protection - `ts` is Unix seconds. Default acceptance window: ±5 minutes. - Configurable via task `maxAgeSec` (up to 24h). - Set `deduplicateBy: "externalRef"` on task config for idempotent credit. ## On-chain claim flow The protocol never custodies viewer funds. Every redeem returns an EIP-712 signature the user submits themselves. ### EIP-712 type ``` Claim(address recipient,uint256 amount,bytes32 claimRef,uint256 nonce,uint256 deadline) ``` Domain separator: read from `SovAdsStreaming.DOMAIN_SEPARATOR()`. `claimRef = keccak256(recipient, nonce, randomSalt)`, consumed once. `nonces[recipient]` increments on each claim. ### Submit call ```solidity function claimWithSignature( address recipient, uint256 amount, bytes32 claimRef, uint256 nonce, uint256 deadline, bytes signature ) external returns (uint256 claimId); ``` ## Economics (current) - 1 SovPoint = 1 G$ (no slippage) - Base impression price: **$0.0002** (admin-tunable per token) - Click price: per-campaign CPC - Protocol fee: **10 %** flat (funds viewer reward pool) - Minimum cashout: **10 SovPoints** - Minimum campaign: **2 000 G$** (enforced at publish) - Minimum CTA reward: **6 G$** when `rewardGs > 0` Unified budget model: `effectiveSpent = clicks×cpc + impressions×impressionCost + Σ ctaSpend`. When `effectiveSpent ≥ budget` the banner stays visible but `bannerClickActive=false`; CTAs continue to award SovPoints. ## Auth model - Anonymous viewer: identified by fingerprint (`localStorage` key `sovads_fingerprint_v1`). Earns points, cannot redeem. - Wallet viewer: calls `ads.identify(wallet)`; points migrate from fingerprint to wallet. EIP-712 signatures for redeems. - Publisher: wallet ownership of the publisher record (no API key). - Advertiser: wallet ownership of the campaign record. - Operator: holds `SOVADS_OPERATOR_PRIVATE_KEY`, must be whitelisted via `addOperator()` on SovAdsStreaming. Signs claims, runs AI plan generator. **SIWE is not used.** Auth = wallet match on each request + EIP-712 for value-bearing actions. ## Error codes - 400 — validation (bad address, missing field, below minimum) - 401 — wallet does not own the referenced record - 403 — HMAC invalid or replay window exceeded - 409 — `claimRef` reused, or `externalRef` already credited - 503 — operator not configured / not whitelisted ## AI plan verifier Set `verifier: "AI_PLAN"` on a task and supply `aiPrompt` plus `contractAllowlist: string[]`. Backend calls Groq (`llama-3.3-70b-versatile`, temperature 0.1) to generate a JSON plan of read-only on-chain steps: ```json { "steps": [ { "kind": "readContract", "address": "0x…", "abi": [...], "function": "…" }, { "kind": "getBalance", "tokenAddress": "0x…", "holder": "0x…" } ] } ``` Steps are validated against `contractAllowlist` before storage, then written to `verificationPlan` on the task row. At completion time, plans execute against `https://rpc.ankr.com/celo`. ## Glossary - **siteId** — `site_` - **fingerprint** — anonymous browser id (`fp_…`) - **trackingToken** — server-issued per-ad token (`tt_…`) - **SovPoint** — reward unit (1 : 1 G$) - **G$** — GoodDollar SuperToken, 18 decimals - **claimRef** — single-use 32-byte handle on a claim - **externalRef** — idempotency key supplied by advertiser server - **Vault** — per-campaign G$ holding - **Unit** — standalone non-banner CTA surface (POLL / FEEDBACK / SURVEY) - **Surface** — `attached` (inline under banner) or `standalone` (own page) - **Parent campaign** — `parentCampaignId` on a standalone task; rolls spend up to that campaign's budget - **Survey session** — per-viewer pointer for a multi-step `SURVEY` task - **Operator** — off-chain signer key whitelisted on SovAdsStreaming ## Where to look in the source - SDK: `sdk/index.ts` - API routes: `frontend/src/app/api/` - Chain config + ABIs: `frontend/src/lib/chain-config.ts`, `frontend/src/contract/` - Claim signing: `frontend/src/lib/streaming-claims.ts` - Task / verifier engine: `frontend/src/lib/tasks.ts`, `frontend/src/lib/webhook-process.ts`, `frontend/src/lib/verify-executor.ts` - AI plan: `frontend/src/lib/plan-generator.ts`, `frontend/src/lib/plan-prompt.ts`, `frontend/src/lib/plan-schemas.ts`, `frontend/src/lib/groq.ts` - Survey sessions: `frontend/prisma/migrations/20260624_add_task_surfaces_and_survey_sessions/` - Economics: `frontend/src/lib/sovadgs.ts`, `frontend/src/lib/impression-pricing.ts`, `frontend/src/lib/campaign-spend.ts`, `frontend/src/lib/campaign-limits.ts` End of file.