fix(ads): solvent marketplace + free-tier backfill - #170
Merged
Merged
Conversation
The ad network could pay out real USDC for credits nobody ever bought.
Phase 4 argued solvency from a rack<->floor spread: advertisers spend
credits valued at 5c, publishers cash out at 2.5c, so 0.7*N*2.5c never
exceeds N*5c. That holds only if every credit was SOLD at rack. Two
things broke the assumption:
1. Signup grants. credits_balance defaults to 20 free credits (60
before 20260608010000) and admin grants add more. Once in the
balance they are indistinguishable from purchased credits, and each
one obligated 1.75c of real USDC the moment it funded a click.
Production held 16,454 credits against $12.00 of lifetime deposits
— ~$288 of liability at 4% coverage.
2. Volume packs. The 100-scan pack sells credits at 2.5c, exactly the
publisher floor, so the spread is 1:1 on the deepest tier. The 100%
deposit match was granted at rack regardless of what the buyer paid
per credit, dropping cash in to 1.67c against 1.75c out.
Compounding both: nothing stopped a user owning the campaign and the
slot. All 83 valid clicks in production were self-dealt — the entire
"Earnings & spend" dashboard was one account's free credits round-
tripping through the platform fee.
The fix:
* profiles.promo_credits tracks the non-cash-backed slice of the
balance. Promo-funded clicks still bill the advertiser and count as
valid delivery, but accrue nothing to the publisher — there is no
cash behind them. Backfilled from purchase history, conservatively.
* ad_charge_click refuses to bill or accrue when the slot owner and
campaign owner match; serveAd filters those pairs so the impression
is not wasted either.
* Payout floor 2.5c -> 2.0c (publisher earns 1.4c/credit), and the
deposit match is capped so post-match cash in per credit never falls
below 1.75c — a 25% margin on every pack.
* A trigger on ad_payouts enforces the cumulative solvency invariant
in the database, not just in requestPayout().
Also corrects creditsToPayoutCents, which ignored PLATFORM_RATE and
overstated publisher balances by 43%.
Liability after backfill: $0.43 against $12.00 cash in.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vu1nz Security Review0 finding(s) in PR #? No security issues found. |
ralyodio
marked this pull request as ready for review
July 31, 2026 06:07
This was referenced Jul 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two related changes to the ad network economics. Both migrations are already applied to production.
1. Free credits could be withdrawn as real cash
Phase 4 argued solvency from a rack↔floor spread: advertisers spend credits valued at 5¢, publishers cash out at 2.5¢, so
0.7*N*2.5ccan never exceedN*5c. That holds only if every credit was sold at rack. Two things broke it:credits_balancedefaults to 20 free credits (60 before20260608010000), and admin grants add more. Once in the balance they're indistinguishable from purchased credits, and each obligated 1.75¢ of real USDC the moment it funded a click.floor(amount / 5)) regardless of what the buyer paid per credit, dropping cash in to 1.67¢/credit against 1.75¢ out.Compounding both: nothing checked that slot owner ≠ campaign owner.
The entire "Earnings & spend" dashboard — $15.50 spend, $5.30 earned, −$10.20 net — was one account's admin-granted free credits round-tripping through the platform fee.
Fix
profiles.promo_creditstracks the non-cash-backed slice. Promo-funded clicks still bill the advertiser and count as delivery, but accrue nothing — there's no cash behind them. Backfilled from purchase history in the direction that under-accrues.ad_charge_click(before the debit, so it doesn't consume budget) and inserveAd.ad_payoutsenforces cumulative solvency where it can't be bypassed.Also corrects
creditsToPayoutCents, which ignoredPLATFORM_RATEand overstated balances by 43%.Margin by pack
2. Campaigns never go dark — they drop to a free tier
Running out of money used to kill a campaign:
statusflipped to'exhausted'and nothing ever flipped it back, so it needed a manual Activate. The slot then showed a CrawlProof house ad — which earns the publisher exactly as little as the advertiser's ad would have.A dry campaign now keeps serving as free backfill: it fills requests no paying campaign wanted, bills nobody, accrues nothing. Better than a house ad for everyone — the advertiser keeps getting traffic and a reason to top up, the publisher shows a real ad, the network keeps inventory full. Paid delivery resumes on its own when credits arrive or the budget rolls over at 00:00 UTC.
Two-tier serving
serveAd()partitions candidates by whether the owner can cover a click at the campaign's bid, runs the bid-weighted auction over the paid set only, then falls through to a uniform pick from the free set (no bid weighting — nobody's paying, so a high bid buys no priority).Paid inventory is never displaced — free-tier ads only fill what the auction left empty, so this cannot cannibalise publisher earnings. The serve-time funds check is new and load-bearing: without it a broke campaign would win the auction and hand the publisher an unbillable click on inventory a funded advertiser wanted.
ad_impressions.tier/ad_clicks.tierrecord which inventory each event came from, and the stats views report paid and free separately — otherwise free delivery would inflate impressions while spend and earnings stayed flat, and both sides would think their rates collapsed.'exhausted'is no longer written; existing rows are reactivated by the migration and tolerated as free tier byserveAd()andcampaignTier().Solvency is untouched — free-tier clicks charge 0 and accrue 0.
Verification
Both migrations applied to production; every guard probed live in rolled-back transactions:
FIRED -> would exceed platform cash in (requested 500000c, paid 0c, cash in 1200c)charged=0 earn=0 valid=fcharged=0 earn=0 tier=free status=active← did not deactivatecharged=20 earn=5 tier=paidcharged=0 tier=free status=activePost-migration: 0 probe rows persisted, 0 campaigns left
exhausted(34 active), accruals unchanged at $5.30, stats views serving the new columns.tsc --noEmitclean. Full suite 1196 passed, 0 failures — 20 new tests intests/ad-solvency.test.tsasserting cash-in-per-credit exceeds payout-per-credit for every pack with and without the promo, plus a rewrittentests/ads-campaign-status.test.ts(23) covering the tier model.Follow-ups (not in this PR)
publisher_accrualis left in the ledger rather than silently rewritten — worth deciding whether to void it.promo_credits; theleast(promo, balance)clamp makes that drift conservative, but a shared debit helper would make it exact.🤖 Generated with Claude Code
3. Stock-chart time ranges on /ads
Adds 1H · 4H · 1D · 1W · 1M · 3M · 1Y · ALL to the campaigns dashboard, with a delivery chart and range-scoped stats above the campaign list.
ad_campaign_daily_seriesonly buckets by UTC calendar day, so it can't answer "the last hour in one-minute steps". Two new RPCs take the window and bucket width as parameters — split deliberately because of the same PostgREST 1000-row cap that forced server-side aggregation originally:ad_account_seriesad_campaign_totalsBoth are
security invokerwith an explicitowner_id = auth.uid()scope, so publisher-side read grants onad_impressions/ad_clickscan't leak another advertiser's campaigns in.Details worth knowing
date_bin(step, ts, 'epoch'). Aligning to "now" instead would offset every point off its slot and render an all-zero chart.tests/ads-ranges.test.tspins that agreement per range.--color-chart-1/2rather than reusing--color-accent/--color-warn: the brand pair sits at OKLCH L ~0.84, outside the 0.48–0.67 band a dark chart surface wants. The new pair is the same hues stepped darker and passes all six palette checks against--color-card— lightness band, chroma floor, CVD separation (ΔE 10.4 deutan), normal-vision separation (ΔE 21.2), 3:1 contrast.Verification
Migration applied to production. Both RPCs probed under a real user's RLS context (rolled back):
next buildcompiles clean with/adsrendering. Full suite now 1217 passed, 0 failures (+21 new range tests).