Skip to content

fix(ads): solvent marketplace + free-tier backfill - #170

Merged
ralyodio merged 1 commit into
masterfrom
fix/ad-network-solvency
Jul 31, 2026
Merged

ralyodio merged 1 commit into
masterfrom
fix/ad-network-solvency

Conversation

@ralyodio

@ralyodio ralyodio commented Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

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.5c can never exceed N*5c. That holds only if every credit was sold at rack. Two things broke it:

  1. Signup grants. credits_balance defaults to 20 free credits (60 before 20260608010000), 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.
  2. Volume packs. The 100-scan pack sells credits at 2.5¢ — exactly the publisher floor, so the spread is 1:1 there. The 100% deposit match was granted at rack (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.

Before After
16,454 credits outstanding 31 cash-backed
$12.00 lifetime cash in $12.00
~$288 publisher liability $0.43
4% coverage 2,791%
83 / 83 valid clicks self-dealt blocked

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

  • Credit provenance. profiles.promo_credits tracks 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.
  • Self-dealing block in ad_charge_click (before the debit, so it doesn't consume budget) and in serveAd.
  • Restored margin. Payout floor 2.5¢ → 2.0¢ (1.4¢/credit to the publisher); the deposit match now matches credits bought rather than dollars at rack, capped so post-match cash in never drops below 1.75¢.
  • DB-level invariant. A trigger on ad_payouts enforces cumulative solvency where it can't be bypassed.

Also corrects creditsToPayoutCents, which ignored PLATFORM_RATE and overstated balances by 43%.

Margin by pack

Pack Cash in / credit Payout / credit Margin With match
Starter $1 / 20 5.0¢ 1.4¢ 72% 2.5¢ → 44%
10 scans $9 / 200 4.5¢ 1.4¢ 69% 2.25¢ → 38%
50 scans $35 / 1000 3.5¢ 1.4¢ 60% 1.75¢ → 20%
100 scans $50 / 2000 2.5¢ 1.4¢ 44% 1.75¢ → 20% (match capped 2000→857)

2. Campaigns never go dark — they drop to a free tier

Running out of money used to kill a campaign: status flipped 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.tier record 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 by serveAd() and campaignTier().

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:

Probe Result
Payout above lifetime cash in FIRED -> would exceed platform cash in (requested 500000c, paid 0c, cash in 1200c)
Self-click charged=0 earn=0 valid=f
Advertiser broke charged=0 earn=0 tier=free status=active ← did not deactivate
Advertiser funded charged=20 earn=5 tier=paid
Over daily budget charged=0 tier=free status=active

Post-migration: 0 probe rows persisted, 0 campaigns left exhausted (34 active), accruals unchanged at $5.30, stats views serving the new columns.

tsc --noEmit clean. Full suite 1196 passed, 0 failures — 20 new tests in tests/ad-solvency.test.ts asserting cash-in-per-credit exceeds payout-per-credit for every pack with and without the promo, plus a rewritten tests/ads-campaign-status.test.ts (23) covering the tier model.

Follow-ups (not in this PR)

  • The historical $5.30 of self-dealt publisher_accrual is left in the ledger rather than silently rewritten — worth deciding whether to void it.
  • Other credit debit paths (scans, outreach) don't decrement promo_credits; the least(promo, balance) clamp makes that drift conservative, but a shared debit helper would make it exact.
  • Free-tier fill is uniform across dry campaigns — if that pool grows large, some pacing or recency weighting may be worth adding.

🤖 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_series only 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:

RPC Rows Why
ad_account_series (buckets) Account-wide. 34 campaigns × 90 daily buckets = 3,060 rows would silently truncate; account-wide it's 90.
ad_campaign_totals (campaigns) Per campaign but unbucketed — lets the list obey the same range without re-introducing the product.

Both are security invoker with an explicit owner_id = auth.uid() scope, so publisher-side read grants on ad_impressions / ad_clicks can't leak another advertiser's campaigns in.

Details worth knowing

  • Epoch-aligned axis. The JS axis is aligned to the epoch to match SQL's 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.ts pins that agreement per range.
  • Point band. Ranges are sized to land in 24–100 points: fewer and a line reads as a bar chart, more and buckets fall under a pixel.
  • Range lives in the URL, so it survives a refresh and can be linked, and it scopes everything below it — header stats, chart, and per-campaign rows all read the same slice, so the numbers on the page agree.
  • Single axis. The chart plots impressions only, paid vs free stacked — one measure, one unit, one axis. Clicks run ~1% of impressions and spend is money, so either as a second line would need a second y-scale, and a dual-axis chart invites the false correlations it appears to show. Both live in the stat tiles instead, with sparklines.
  • Chart color tokens. --color-chart-1/2 rather 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):

DAILY: 07-31 imp=283 | 07-30 imp=2351 | 07-29 imp=818 clk=1 spent=25 | 07-28 imp=1156 clk=2 spent=45
ALL (weekly, 5 buckets): 07-30 imp=2634 | 07-23 imp=7266 | 07-16 imp=4687
TOTALS: imp=1108 clk=2 spent=40 | imp=1073 clk=4 spent=80 | imp=1025 clk=10 spent=200

next build compiles clean with /ads rendering. Full suite now 1217 passed, 0 failures (+21 new range tests).

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>
@github-actions

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@ralyodio
ralyodio marked this pull request as ready for review July 31, 2026 06:07
@ralyodio
ralyodio merged commit 23fdc44 into master Jul 31, 2026
8 checks passed
@ralyodio
ralyodio deleted the fix/ad-network-solvency branch July 31, 2026 06:07
@ralyodio ralyodio changed the title fix(ads): stop free credits converting into withdrawable cash fix(ads): solvent marketplace + free-tier backfill Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant