feat: provider base rewards — economical earnings floor (max(earned, floor))#282
feat: provider base rewards — economical earnings floor (max(earned, floor))#282Gajesh2007 wants to merge 5 commits into
Conversation
…floor)) Replaces the rejected additive stream-payments design with a bounded, self-extinguishing earnings FLOOR. Payout = earned + max(0, floor − k·earned) (k=1 ⇒ max(earned, floor)): the floor is reduced dollar-for-dollar by what a provider earns, so the subsidy targets idle machines and shrinks to $0 as real demand grows. Total draw is hard-capped by a fixed monthly pool. Off by default behind EIGENINFERENCE_BASE_REWARDS — zero behavior change unset. Phase 0 — bounded floor + restart-safe idempotent settlement: - coordinator/payments/baserewards: floor table by verified-memory tier, avail (≥90% uptime ramp), k-draw, pool water-fill protecting the 48–96GB workhorse tier, monthly-epoch SettleEpoch from durable provider_sessions. - store: provider_floor_draws (UNIQUE(provider_key,epoch_id)); UNIQUE(job_id) on provider_earnings + ON CONFLICT no-op (fixes a latent double-credit path); session-overlap uptime query; cross-instance WithEpochSettlementLock so the pool cap holds across concurrent settlers. - GET /v1/admin/base-rewards. Phase 1 — verified capacity + correctness probe: - mdm HardwareModel → max-memory downward cap (mac_models.go); a self-reported memory tier can only be lowered, never raised. - coordinator correctness prober (encrypts like a consumer, records willingness); work-gate = billed other-account job OR passed probe. Self-route excluded. Phase 2/3 — taper.go (absolute-revenue taper, calendar glide, 30% cliff guard) implemented as pure, tested functions; settlement runs at taper=1 pending activation. console-ui: provider-facing BaseRewardsPanel on /earn (honest 64GB+ anchor, no "guarantee"); admin proxy route. docs/base-rewards.md: full design + economics. AGENTS.md/CLAUDE.md now require before/after mermaid diagrams in every PR.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
This PR adds a base-rewards settlement engine and correctness prober; no security-relevant changes are made to the files mapped to T-032 or T-038, and no existing mitigations are weakened. Trust boundaries touched
Threat analysis
New attack surface not covered by an existing threat
Open findings resolved by this PRNone. SEC-027 (T-038) and SEC-012 (T-030) remain open and are not addressed here. 🔐 Threat model: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 03398bc9a1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| SelfRouteOnly: true, // pin + never fall back to a paid provider | ||
| FreeSelfRoute: true, // settles free — no earning row, no payout |
There was a problem hiding this comment.
Allow probes to reserve the pinned provider
With SelfRouteOnly set here and no OwnerAccountID, every candidate is filtered out before the serial pin is considered: providerOwnedBy explicitly returns false for an empty account id, so ReserveProviderEx returns nil for all probes. In the zero-demand case this means the new probe work-gate can never record a success, so otherwise eligible providers cannot qualify for base rewards unless they already have organic billed jobs. Use the serial restriction without self-route ownership filtering, or stamp the provider's real owner account.
Useful? React with 👍 / 👎.
| remainingBudget := e.cfg.PoolBudgetMicroUSD - settledSum | ||
| if remainingBudget < 0 { | ||
| remainingBudget = 0 | ||
| } | ||
| allocs := AllocateDraws(pureCands, remainingBudget, e.cfg.WorkhorseReserveFrac, e.cfg.PerAccountCapFrac) |
There was a problem hiding this comment.
Enforce per-account caps across re-settlement runs
This allocation only subtracts the prior epoch draw total; it does not seed the allocator with amounts already paid per account. If an epoch is settled once and later a new machine from the same account becomes eligible, AllocateDraws starts that account at zero and grants up to a fresh per-account cap (computed from the remaining budget), letting that account exceed the intended 5% monthly concentration cap across idempotent re-runs. Existing provider_floor_draws need to count toward accountGranted or the candidate budget before allocating new rows.
Useful? React with 👍 / 👎.
| ModelLoaded: p.CurrentModel != "" || len(p.WarmModels) > 0, | ||
| CurrentModel: probeModel(p), |
There was a problem hiding this comment.
Use backend slots for base-reward warm checks
For Swift providers BackendCapacity.Slots is the authoritative loaded-model state, but this snapshot treats any non-empty CurrentModel or WarmModels as loaded and also picks the probe target from those fallback fields. When a provider reports a slot as crashed, reloading, or idle_shutdown (or has no slot for the model) while those legacy fields remain populated, buildCandidates can mark it eligible and the prober can target it even though the scheduler would not consider that model warm/serving. Derive ModelLoaded and the probe model from backend slots (running/idle) whenever BackendCapacity is present.
Useful? React with 👍 / 👎.
| defer func() { | ||
| provider.RemovePending(requestID) | ||
| s.registry.SetProviderIdle(provider.ID) | ||
| }() |
There was a problem hiding this comment.
Cancel timed-out probe requests on the provider
This deferred cleanup removes the pending request and marks the provider idle, but it never sends a cancel frame. When awaitCompletion returns on probeTimeout or context cancellation after the request was written, the provider can keep generating the probe while the coordinator has already made it schedulable for more work, wasting capacity and potentially overlapping a new request with the abandoned probe. Use the same cancel cleanup as other timed-out dispatches.
Useful? React with 👍 / 👎.
| INSERT INTO ledger_entries (account_id, entry_type, amount_micro_usd, balance_after, reference, created_at) | ||
| SELECT d.account_id, $9, d.amount_micro_usd, c.balance_micro_usd, $3, NOW() | ||
| FROM draw d CROSS JOIN credit c WHERE d.amount_micro_usd > 0 |
There was a problem hiding this comment.
Record floor draws in provider earnings summaries
Settling a positive floor draw only creates the balance/ledger credit here; it never inserts a provider_earnings row or updates earnings_summary, which is what /v1/provider/account-earnings returns for the provider dashboard totals and recent history. After monthly settlement, a provider's withdrawable balance increases but their displayed lifetime/recent earnings stay unchanged, making the new floor payout effectively invisible in the earnings UI except as an unexplained balance jump.
Useful? React with 👍 / 👎.
| "Mac16,13": 32, // MacBook Air 15" (M4, 2025) | ||
|
|
||
| // --- Mac Studio (2025): M4 Max + M3 Ultra --- | ||
| "Mac16,9": 512, // Mac Studio (M4 Max 128GB / M3 Ultra 512GB, 2025) — Ultra → 512 |
There was a problem hiding this comment.
Disambiguate Mac16,9 before granting the 512GB tier
This identifier is documented in the comment as covering both 128GB M4 Max and 512GB M3 Ultra Mac Studio machines, but the cap is set to 512GB for all of them. Because the engine only clamps self-reported memory downward and the current probe does not verify a tier-sized model, a 128GB M4 Max Mac Studio can self-report 512GB and receive the top $40 floor instead of its real tier. Use a more specific hardware signal or avoid the 512GB cap for ambiguous identifiers until tier verification exists.
Useful? React with 👍 / 👎.
- P1 prober: drop SelfRouteOnly on probe requests. With no OwnerAccountID it made ReserveProviderEx filter out every provider (providerOwnedBy=false), so the probe work-gate could never record a success. Pin via the serial allowlist + FreeSelfRoute (settles free) instead — the Phase-1 probe path now works. - P2 per-account cap across re-runs: AllocateDraws now takes the full pool as the cap basis and a priorByAccount seed, so an account's 5% cap holds cumulatively across idempotent re-settlement runs (no fresh full cap for a new same-account machine). Engine seeds it from already-settled draws. - P2 warm check: derive ModelLoaded + the probe target from authoritative BackendCapacity slots (running/idle, matching the scheduler) when present, not from legacy CurrentModel/WarmModels — a crashed/reloading slot no longer marks a provider eligible or probe-targetable. - P2 probe cancel: send a cancel frame to the provider on probe timeout/error so it stops generating instead of overlapping later real work. - P2 earnings visibility: floor settlement now records a base_reward earning row (+ summary) so the draw shows in the provider's earnings history/summary instead of an unexplained balance jump — excluded from organic gating by Model. - P2 Mac16,9: cap conservatively at 128GB (ambiguous M4 Max 128 / M3 Ultra 512) so a 128GB Studio cannot self-report 512GB for the top floor before the tier-sized probe exists. Regression tests added: cross-run per-account cap, floor-draw earnings visibility (excluded from organic), memory-cap overclaim already covered. golangci-lint v2.1.6 clean; coordinator + console-ui suites green.
An operator running N real, attested, serving Macs contributes N machines of capacity and should earn N floors. The per-account concentration cap penalized exactly those honest multi-machine operators (the supply we want), added nothing to cost control (the pool is already bounded), and was dodgeable by splitting machines across free accounts. Attestation is the real Sybil defense. Default PerAccountCapFrac to 0 (per-machine). Kept as an optional knob (EIGENINFERENCE_BASE_REWARDS_ACCOUNT_CAP, default 0); when set it still binds on the Stripe payout identity and enforces cumulatively across re-settlement runs.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c7f828bf30
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if capGB, known := mdm.ModelMaxMemoryGB(p.HardwareModel); known && capGB > 0 && memGB > capGB { | ||
| memGB = capGB |
There was a problem hiding this comment.
Cap unknown hardware models before paying top tiers
When ModelMaxMemoryGB returns known == false (for example if hw.model reports Unknown or a new/missing identifier), this leaves memGB entirely self-reported, while the current probe only asks for an 8-token completion and does not verify a tier-sized model. In that case a small attested Mac with an uncatalogued or failed hardware-model string can report MemoryGB=512 and receive the 512GB floor instead of being conservatively capped until real tier verification exists.
Useful? React with 👍 / 👎.
| // Success = a valid, SE-signed, non-empty completion arrived in time. | ||
| // (Byte-exact known-answer matching is a documented follow-up.) | ||
| success := awaitErr == nil && seSig != "" && len(text) > 0 |
There was a problem hiding this comment.
Verify probe signatures before marking success
For the base-reward work gate, any non-empty se_signature makes the probe successful; handleComplete only copies msg.SESignature/ResponseHash onto the pending request and does not verify the signature. An attested but modified provider can therefore satisfy the probe half of eligibility with a bogus signature and non-empty text, even though the comment and reward gate require a valid SE-signed completion.
Useful? React with 👍 / 👎.
|
|
||
| // Metrics snapshot (admin only) | ||
| s.mux.HandleFunc("GET /v1/admin/metrics", s.handleAdminMetrics) | ||
| s.mux.HandleFunc("GET /v1/admin/base-rewards", s.handleAdminBaseRewards) |
There was a problem hiding this comment.
Authenticate the Privy-backed admin status route
The new console proxy forwards a Privy bearer token to /v1/admin/base-rewards, but this coordinator route is registered without requireAuth, and the global handler stack does not populate auth.UserFromContext. As a result isAdminAuthorized can only accept the raw admin key here, so Privy admins using the UI endpoint get 403 instead of the base-rewards status.
Useful? React with 👍 / 👎.
| ConsumerKey: "base-rewards-probe", | ||
| AllowedProviderSerials: []string{snap.SerialNumber}, // pin to this machine | ||
| // FreeSelfRoute makes the dispatch settle free (no earning row, no payout). | ||
| // We deliberately do NOT set SelfRouteOnly: that flag means "owned-only" | ||
| // and, with no OwnerAccountID, ReserveProviderEx would filter out every | ||
| // provider (providerOwnedBy == false), so the probe could never reserve | ||
| // the pinned machine. The serial allowlist alone does the pinning. | ||
| FreeSelfRoute: true, |
There was a problem hiding this comment.
Keep probes out of paid usage accounting
Setting FreeSelfRoute here does not actually make the probe a free internal request because handleComplete only sets its freeSelfRoute flag when the serving provider is owned by ConsumerKey; this synthetic key owns no provider. Successful probes therefore enter the paid path, fail a debit, and still persist a zero-cost RecordUsageFull row because freeSelfRoute remains false, inflating public usage/request stats with synthetic probe traffic.
Useful? React with 👍 / 👎.
| "Mac14,15": 24, // MacBook Air 15" (M2, 2023) | ||
| "Mac14,7": 24, // MacBook Pro 13" (M2, 2022) | ||
| "Mac14,5": 96, // MacBook Pro 14" (M2 Max, 2023) | ||
| "Mac14,9": 96, // MacBook Pro 14" (M2 Pro/Max, 2023) — Max → 96 |
There was a problem hiding this comment.
Cap shared Pro/Max identifiers conservatively
For model identifiers this table labels as shared Pro/Max, using the Max memory ceiling lets the lower-memory Pro variant self-report the Max tier because the current probe does not verify a tier-sized model. For example, a Mac14,9 M2 Pro machine can claim 96GB and receive the 96GB floor even if its physical ceiling is 32GB; apply the same conservative treatment used for Mac16,9 until a stronger hardware signal or tier probe exists.
Useful? React with 👍 / 👎.
| warm := warmServingModel(p) | ||
| out = append(out, ProviderSnapshot{ | ||
| ID: p.ID, | ||
| ProviderKey: p.PublicKey, |
There was a problem hiding this comment.
Use a stable machine key for reward identity
This makes the base-rewards provider identity the X25519 encryption public key, but the Swift provider creates that key with NodeKeyPair.generate() at ProviderLoop startup. Because sessions, earnings, probe successes, and floor draws are all matched by provider_key, a normal provider restart or update splits one machine into multiple identities; the current live key may have only a fraction of the month's uptime/work and can miss the 90% gate despite the same serial being online all month. Key rewards on a stable attested machine identity, or persist the X25519 key across restarts.
Useful? React with 👍 / 👎.
| remainingBudget := e.cfg.PoolBudgetMicroUSD - settledSum | ||
| if remainingBudget < 0 { | ||
| remainingBudget = 0 | ||
| } | ||
| allocs := AllocateDraws(pureCands, remainingBudget, e.cfg.PoolBudgetMicroUSD, e.cfg.WorkhorseReserveFrac, e.cfg.PerAccountCapFrac, priorByAccount) |
There was a problem hiding this comment.
Preserve the workhorse reserve across re-settlement
If an epoch is first settled before some 48–96GB machines become eligible (for example, their probe lands after the first run), already-settled non-workhorse draws are frozen and this rerun reserves only a fraction of the remaining budget. A first run containing only large machines can therefore consume the whole pool, leaving later eligible workhorses with $0 even though a one-shot allocation with the same final candidate set would have protected the workhorse tier.
Useful? React with 👍 / 👎.
…range Macs The fleet skews ~27GB average, and 24/32GB Macs can serve the gpt-oss-20B baseline plus specialist work (STT, embeddings) — but the floor started at 48GB, so the bulk of useful supply earned nothing. Add 24GB ($10/mo) and 32GB ($12/mo) tiers; threshold drops from 48GB to 24GB (sub-24GB still usage-only). They earn only while actually serving (the work gate), so the floor never rewards an idle small machine. Updated floor table, console-ui panel, docs, and tests.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 531e5e4a2c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| `SELECT EXISTS( | ||
| SELECT 1 FROM provider_earnings | ||
| WHERE provider_key = $1 | ||
| AND created_at >= $2 | ||
| AND amount_micro_usd > 0 | ||
| AND model NOT IN ('base_reward', 'probe'))`, |
There was a problem hiding this comment.
Reject self-dealt jobs from the work gate
When a provider creates a paid request from another account and pins its own machine via provider_serial, the consumer path accepts that routing hint and the scheduler restricts dispatch to that serial; the resulting provider_earnings row has no consumer-account provenance, so this predicate treats any positive non-base_reward/probe payout as organic work. That lets a provider buy one tiny request to itself and satisfy the billed-job half of base-reward eligibility without real external demand; record/check the consumer account or otherwise exclude pinned/self-dealt jobs here before clearing the gate.
Useful? React with 👍 / 👎.
|
|
||
| workSince := e.now().Add(-e.cfg.WorkWindow) | ||
| out := make([]candidate, 0) | ||
| for _, p := range e.reg.ListProviders() { |
There was a problem hiding this comment.
Settle closed epochs from durable provider state
Because candidates are built only from e.reg.ListProviders(), settlement for a closed month depends on who is connected at the moment this hourly job runs, not on the durable session/earning rows for that epoch. A machine that stayed online and served work for ≥90% of April but is offline or restarting during the May 1 settlement tick is skipped entirely until it reconnects, and may receive nothing if the pool is allocated before then; use the persisted provider/session state for the epoch instead of the live registry as the candidate source.
Useful? React with 👍 / 👎.
| cur, ok := best[ps.ProviderKey] | ||
| if !ok || ps.ConnectedAt.After(cur.when) { | ||
| best[ps.ProviderKey] = pick{account: ps.AccountID, when: ps.ConnectedAt} | ||
| } |
There was a problem hiding this comment.
Prorate floor payouts when ownership changes
This picks the most recent session's account and then credits the entire epoch's floor for the provider key to that account. If a machine is linked to account A for most of the month and then relinked or sold to account B shortly before settlement, B receives the whole monthly top-up while A's uptime/earnings can still be part of the same provider-key calculation; split candidates by session account/time or otherwise attribute the floor to the accounts that actually owned the machine during the epoch.
Useful? React with 👍 / 👎.
| // Gate 4: healthy + advertised model loaded for routing. | ||
| if !p.Online || !p.ModelLoaded { | ||
| continue |
There was a problem hiding this comment.
Apply the scheduler's runtime and challenge gates
For currently connected providers this treats Online plus ModelLoaded as loaded for routing, but ReserveProviderEx/snapshotProviderLocked also requires runtime verification, private-text support, and a fresh LastChallengeVerified before public inference can route. If a provider served a billed job earlier and then fails runtime verification or lets challenge freshness lapse while staying online with an idle slot, it still passes settlement here and can receive the floor despite being unroutable to users; mirror the scheduler's structural gates before adding the candidate.
Useful? React with 👍 / 👎.
Summary
Adds a provider base-rewards earnings floor to stabilize supply during cold-start — the economical successor to the rejected additive
stream-paymentsdesign.The rule is a draw against earnings, not a bonus:
The floor is reduced dollar-for-dollar by what a provider earns, so the subsidy targets idle machines and self-extinguishes to $0 as real demand grows. Total spend is hard-capped by a fixed monthly pool. It is the worst-case marketing promise ("a 64GB+ Mac covers your Netflix even when the network is quiet") without the runaway cost of paying
base + usage.Off by default behind
EIGENINFERENCE_BASE_REWARDS— zero behavior change when unset.Full design + economics:
docs/base-rewards.md.Before / After
Payout model — the old path paid per-token only (an idle machine earns ~$0 and churns); the new path tops a machine up to its floor and disappears once it out-earns it.
flowchart LR subgraph Before["Before — per-token only"] A1[Provider serves tokens] --> A2["earned = sum of per-token"] A2 --> A3["payout = earned"] A4[Quiet network] --> A5["earned approx 0 -> payout approx 0 -> churn"] endflowchart LR subgraph After["After — earnings floor: max(earned, floor)"] B1["earned = organic per-token"] --> B3 B2["floor = tier(verified mem) x avail x taper"] --> B3 B3["draw = max(0, floor - k*earned)"] --> B4["payout = earned + draw"] B4 --> B5{"earned >= floor?"} B5 -- yes --> B6["draw = 0, keep 100%"] B5 -- no --> B7["topped up to the floor"] endSettlement flow (new) — once per closed monthly epoch, restart-safe and idempotent, with the pool cap enforced under a cross-instance lock:
flowchart TD T["Hourly tick"] --> C{"epoch closed?"} C -- no --> Z["no-op"] C -- yes --> L["WithEpochSettlementLock (advisory lock)"] L --> CA["build candidates from live registry"] CA --> G{"gates: attested · uptime >= 90% · healthy · work-proven · linked account"} G -- fail --> X["excluded (earns 0)"] G -- pass --> UP["uptime from provider_sessions (interval union)"] UP --> AL["AllocateDraws: <= pool, workhorse-protected, minus already-settled"] AL --> S["SettleProviderFloorDraw — idempotent per (provider_key, epoch_id)"] S --> LG["ledger credit + provider_floor_draws audit row"]Data model — new tables + the idempotency fix on the existing earnings table:
erDiagram provider_floor_draws { string provider_key string epoch_id "UNIQUE(provider_key, epoch_id)" int64 amount_micro_usd int64 floor_micro_usd int64 earned_micro_usd float uptime_frac int memory_gb } provider_probe_results { string provider_key bool success int64 latency_ms } provider_earnings { string job_id "now UNIQUE (idempotent ON CONFLICT)" string provider_key int64 amount_micro_usd }What's included
coordinator/payments/baserewards(floor table by verified-memory tier,avail≥90%-uptime ramp,k-draw, pool water-fill protecting the 48–96GB workhorse tier,SettleEpochfrom durableprovider_sessions); store layer (provider_floor_draws,UNIQUE(job_id)+ON CONFLICTno-op that fixes a latent double-credit path, session-overlap uptime, cross-instanceWithEpochSettlementLock);GET /v1/admin/base-rewards.HardwareModel→max-memory downward cap (mdm/mac_models.go; a self-reported tier can only be lowered, never raised); a coordinator correctness prober (encrypts like a consumer, records willingness-to-serve); work-gate = a billed other-account job OR a passed probe (self-route excluded).taper.go(absolute-revenue taper, calendar glide, 30% month-over-month cliff guard) implemented as pure, tested functions; settlement runs attaper = 1pending activation. Fee-pool handoff is design-only (platform fee is 0% in alpha).BaseRewardsPanelon/earn(honest 64GB+ anchor, never the word "guarantee") + an admin proxy route.Honest caveats (carried from the design)
Testing
go build ./...✅, Linux cross-compile ✅,go vet ./...✅,gofmtclean ✅.payments/baserewards(floor boundaries, k-draw, pool water-fill determinism, taper/cliff, settlement idempotency/restart-safety, memory-cap anti-overclaim, self-route-excluded, blue-green double-open, empty-fleet no-NaN),store/base_rewards_test.go(double-credit no-op, idempotent floor-draw, overlap union — memory always + postgres behindDATABASE_URL),mdm(model→memory cap),apisuite green, console-uiBaseRewardsPanelvitest.ON CONFLICTmismatch that would have errored every earning insert, a cross-run pool-cap gap, an empty-account settlement, one wrong memory-cap entry) — all fixed in this PR and re-verified.🤖 Generated with Claude Code
Need help on this PR? Tag
/codesmithwith what you need. Autofix is disabled.