$PERKOS monthly usage drop + multi-chain deposits/balances + vault owner-ops - #36
Merged
Conversation
Balances and the credit ledger are now keyed by (wallet, chain) instead of wallet alone. A consumer who pays on Celo spends a Celo balance, and the providers whose knowledge they consumed earn on Celo; Base stays separate. This is what makes the multi-chain claim distributions correct — earnings are segregated by the chain the payment settled on, so a provider claims the right amount from the right chain's vault (no cross-chain double-pay). - agent_accounts: drop wallet PK -> (wallet, chain) unique index; chain column defaults 'base' so existing rows migrate cleanly. - credit_ledger: + chain column. - credits.ts: getBalance/ensureAccount/debit/credit are chain-aware (default 'base'); getAccountSummary aggregates across chains and adds byChain[]. - /skill/query: derives payChain (body.payChain|chain|x-payment-chain) and threads it through debit + attribution + provider credit. - /api/deposit: credits the chain the deposit settled on (chain: net); dedup balance read scoped to that chain. - admin grant: optional chain (base|celo) for per-chain stipend/promo credits. Verified live: grants on base+celo segregate; same-chain grants accumulate, cross-chain untouched.
…d wallet
The only owner-only on-chain step left (point Base's vault at Base $PERKOS so
the 5% reward leg pays out) is now doable from knowledge.perkos.xyz/admin/billing
instead of a CLI cast send with a raw private key — the owner signs it with
their connected wallet via wagmi, same pattern as the dashboard ClaimPanel.
- VaultOwnerPanel renders ONLY when the connected wallet == vault owner()
on-chain (re-checked per render; the contract enforces onlyOwner anyway).
- Reads each chain's current rewardToken(); shows set (Celo, already Celo
$PERKOS) or a 'Set on <chain>' button that signs setRewardToken(<that chain's
$PERKOS>) on the right chainId (wagmi prompts a network switch).
- Reuses /api/claims/:wallet for vaultAddress + per-chain {chainId, perkos};
no new endpoint. Distributor ops (post-root, fund) stay in the operator
scripts as the treasury wallet — not exposed here.
…m/pull model llms.txt + llms-full.txt were stale (Base-only, old prices, 'self-deposit coming'). Now document: per-(wallet,chain) balances + payChain selection (payment-chain = earning-chain), POST /api/deposit (x402 via PerkOS Stack, Base+Celo USDC, creditTo), the 75/20/5 fee split, the enterprise tier, and the pull/claim payout model (GET /api/claims/:wallet + PerkosClaimVault). Prices stay 'authoritative live at /api/x402/policy' — no hardcoded ladder.
The dashboard read useAccount() for balance/deposit/claim but never showed a connect button or which wallet was active — you couldn't tell where a deposit would land or which wallet you'd claim from, and a disconnected visitor just span on 'Loading...' forever (the data fetch early-returns without an address). - RainbowKit ConnectButton in the nav (address + change/disconnect), on every render state. - Not-connected state now shows a 'Connect your wallet' hero instead of an infinite spinner. - Hero echoes the active 0x1234…5678 so it's unambiguous whose balance/earnings are shown.
… admin error log
A real deposit failed with "Invalid input: expected string, received number".
Root cause: lib/payments.ts hit Stack's /api/v2/x402/verify + /settle, whose
request schema validates x402Version as a STRING while a later version check
compares it as a NUMBER — so it rejects every payload (number fails the schema,
string fails the version check). The x402-standard /verify + /settle endpoints
accept the normal { x402Version: 1, paymentPayload, paymentRequirements } and
only reject on a bad signature. Switched to those.
Also adds a system error log (the user asked for in-admin visibility, and it's
what pinned this down):
- system_errors table + lib/errlog.ts (logError is best-effort, never throws;
context is size-capped and admin-readable — redact secrets).
- The deposit route logs the full Stack interaction on a settle failure
(net/amount/wallet, HTTP status, Stack response, paymentRequirements, and a
signature-redacted payload shape) under scope "deposit.settle".
- settleViaStack now returns status + raw for that capture.
- GET /api/admin/errors (admin-gated) + a "System · error log" panel in
AdminClient (recent errors, click a row to expand its JSON context).
204 tests green. Base deposits now reach settle (real signature required to
complete). Note: x402-fetch 1.2.0's client network enum lacks "celo", so Celo
deposits still fail client-side — separate follow-up.
The deposit succeeded (balance credited on-chain + in the ledger) but the 'Credit balance' metric still read 0 — the dashboard fetched credits once on mount and DepositPanel's onDeposited callback was never wired. Extracted loadCredits (useCallback) and pass it as onDeposited so a successful deposit re-fetches /api/credits and the balance updates in place (no reload).
…lient flow
Two bugs blocked Celo deposits:
1. lib/payments.ts hardcoded the x402 paymentRequirements extra.name to
'USD Coin' for BOTH chains, but Celo USDC's on-chain EIP-712 domain name is
'USDC' (Base = 'USD Coin'; both version '2', verified on-chain). A wrong
domain name = an invalid EIP-3009 signature the facilitator rejects. NETWORKS
now carries each token's real name/version and buildPaymentRequirements uses
them.
2. x402-fetch 1.2.0's network enum has no 'celo', so wrapFetchWithPayment can't
be used for Celo (it rejects the 402's accepts before signing). DepositPanel
now does the x402 dance by hand for Celo: switch chain, fetch the 402, sign
the EIP-3009 transferWithAuthorization against the token's domain via the
wagmi/viem walletClient, base64 the x402 payload into X-PAYMENT, retry. Base
keeps the proven x402-fetch path.
Verified: the live Celo 402 challenge now carries extra {name:'USDC'} matching
the contract. 205 tests (added a Base-domain assertion; fixed the Celo one that
encoded the wrong name).
…500) A Celo deposit failed with HTTP 500. Cause: callFacilitator aborted the Stack /settle call after 12s and the AbortError propagated unhandled → 500. Settle broadcasts + waits for an on-chain USDC transfer, and Celo finality is slower than Base, so 12s wasn't enough. Now: callFacilitator catches network/abort errors and returns a handled failure (so a slow/failed facilitator surfaces as a clear 402 reason + admin error-log entry, never a 500), and settleViaStack uses a 60s timeout. Verified the user did NOT lose USDC on the failed attempt (treasury Celo USDC balance = 0; nothing settled on-chain).
…iendly RPC Two dashboard fixes: 1. Celo deposit on a smart wallet executed instead of signing. The manual x402 path passed an explicit `account` to walletClient.signTypedData. With an EIP-7702 smart wallet (account 0xc256, EOA signer 0xdeB7), that made MetaMask try to EXECUTE the transferWithAuthorization as a transaction from the EOA signer (which has no CELO for gas) → 'insufficient funds for transfer'. x402-fetch (used for Base) calls signTypedData(data) with NO account field, letting the wallet sign with its connected account gaslessly — replicated that exactly for Celo (+ getAddress on from/to/asset, matching x402). 2. ENS resolution + console noise. mainnet was in the wagmi chains (for ENS) but its default RPC (eth.merkle.io) blocks browser CORS, spamming ~16 errors and failing ENS. Pointed mainnet at a CORS-friendly RPC; Base/Celo keep defaults.
… (Phases A+B)
Reframes the 5% reward as a monthly usage drop: at month end the accrued 5%
(per chain) buys $PERKOS, 40% stays with the platform, 60% drops to users
proportional to their TOTAL usage that month (USDC spent on queries + USDC
earned from attributions — both sides of the market). Pull-model: $PERKOS lands
in the existing vault, users claim it alongside USDC.
Phase A (accounting, on-chain-free):
- reward_pool gains a 'chain' column; /skill/query threads payChain into
accrueReward so Base reward USDC buys Base $PERKOS, Celo buys Celo.
- tokenomics_config gains reward_platform_bps (default 4000 = 40% platform /
60% users), admin-editable.
Phase B (calculation, read-only dry-run):
- lib/rewardsDrop.ts computeMonthlyDrop({month,chain}) → budget + platform/user
split + per-wallet usage-weighted share.
- GET /api/admin/rewards/drop?month=&chain= shows what a drop WOULD pay (no
trade, no writes).
Validated live with synthetic data: budget 0.0075 → platform 0.003 / users
0.0045, split 66.7%/33.3% by spend. 205 tests green. Design:
docs/PERKOS-REWARDS-BUYBACK-DESIGN.md. Phase C (the Base-v3/Celo-v4 buyback swap
+ distribute) is next — needs the router addresses + is real money.
…Phase C1) lib/uniswapTrade.ts: a thin Uniswap Trading API client. quoteBuyback() quotes USDC -> $PERKOS per chain via the hosted API (CLASSIC routing, no UniswapX), so one path covers Base v3 + Celo v4 without touching v4 routers. Read-only. The /api/admin/rewards/drop dry-run now also returns a 'buyback' block: the budget quoted to $PERKOS, split platform 40% / users 60%, and each wallet's usage-weighted $PERKOS share — exactly what a real buyback would distribute, no trade executed. Validated live (Base): $1 budget → ~1,108,520 $PERKOS → platform 443,408 / users 665,112, split 66.7/33.3 by usage. Quotes confirmed on Base + Celo (HTTP 200, CLASSIC). UNISWAP_API_KEY wired into the prod env + compose. Next (C2): execute the swap (check_approval -> swap, treasury signs) + distribute to token_rewards + fund vault + post root.
… (Phase C2) Wires the drop's $PERKOS into the existing claim/pull model: - token_rewards is now per-(wallet, chain) — a wallet's $PERKOS is segregated by the chain it was bought on, claimed from that chain's vault. - rollupEntries unions provider USDC earnings (attributions) with each wallet's $PERKOS drop (token_rewards) per chain, so cumReward is no longer hardcoded 0 — a consumer who only spent (no USDC earnings) still gets a claimable $PERKOS row. - distributeDrop(month, chain, perkosBought): keeps the platform cut (40%), credits each wallet's usage-weighted share into token_rewards (integer pro-rata, remainder stays with platform), marks that month's pending reward_pool rows distributed. Accounting-only — the swap + vault funding + root posting are the operator's on-chain steps. - POST /api/admin/rewards/distribute (dry-run default) runs it after a buyback. Validated live: 1,000,000 $PERKOS bought → platform 400k / users 600k → A 400k (66.7%) / B 200k (33.3%) by usage → built the base root → /api/claims/A shows cumReward 400k $PERKOS claimable (cumUsdc 0, a pure consumer). 205 tests green.
…ase D) ClaimPanel now reads as a reward/drop, not a refund: 'earnings + your $PERKOS drop', 'The more you use PerkOS, the bigger your $PERKOS drop — earned just for using the platform.' Each chain row shows the $PERKOS as a highlighted '… $PERKOS drop' (only when > 0), USDC only when > 0. No mention of the 5% mechanism — the user just sees they got $PERKOS for using PerkOS.
…ther) App/scripts/monthly-drop.mjs runs the whole monthly $PERKOS drop for one chain: read budget → market-buy $PERKOS (Uniswap Trading API, treasury signs) → distribute (platform 40% kept, 60% to users by usage → token_rewards) → build the Merkle root → fund the vault with the user $PERKOS → post the root. Users then claim from the dashboard. DRY-RUN by default (just reads budget + quote + split); --apply sends the real swap + on-chain txs. Keys read locally (admin token + Uniswap key from .env, treasury 0x3f0D from Contracts/.env) — never the VPS. Validated in dry-run: budget 0.3 USDC → quote 342,606 $PERKOS, platform 0.12 / users 0.18. Completes the $PERKOS usage-drop system (Phases A–D + orchestrator). Run per chain at month end with --apply when there's accrued reward; doc has the runbook.
Found during the live orchestrator test: nothing ever flipped
claim_distributions.posted to true, so the dashboard showed 'root pending
on-chain' for every claim even after the root was live. Add
POST /api/admin/claims/mark-posted { chain, root, txHash } (UPDATE posted=true,
tx_hash) and call it from monthly-drop.mjs right after setMerkleRoot.
Test validated end-to-end on Base (synthetic 0.05 USDC budget, cleaned up
after): swap 0x81555b9b (0.05 USDC -> 56,651 $PERKOS) -> distribute
(users 33,990 / platform 22,660) -> root 0x85c46f2e -> fund vault 0x96fe5cf2
-> setMerkleRoot 0x649a8a50. On-chain merkleRoot + vault balance + the claim
endpoint (posted=true) all reconciled.
…OLVE) package.json had @vitest/coverage-v8@^4.1.7 (peer vitest@4.1.7) alongside vitest@^3.2.4, so 'npm ci' failed with ERESOLVE before any test ran. Pre-existing on main; this PR is just the first to trigger CI. Pin coverage-v8 to ^3.2.4 (resolves 3.2.6) + regenerate the lockfile. npm test is 'vitest run' (no coverage), so this only fixes install. All 205 unit tests pass locally.
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.
This session bundles the Knowledge monetization work (15 commits), grouped below. All validated on-chain with real money on Base + Celo; test data cleaned up after.
$PERKOS monthly usage drop (new)
Turns the 5% reward into a monthly "$PERKOS drop earned for using PerkOS" (never framed as a refund).
reward_poolaccrual +rewardPlatformBps(40% platform / 60% users, admin-editable).lib/rewardsDrop.ts—computeMonthlyDrop(budget + usage-weighted shares) +distributeDrop(writestoken_rewardsper (wallet,chain)).lib/uniswapTrade.ts— Uniswap Trading API client (one flow covers Base v3 + Celo v4, CLASSIC routing).GET /api/admin/rewards/drop(dry-run: budget + live quote + per-wallet),POST /api/admin/rewards/distribute.ClaimPanelreworded to a "$PERKOS drop";claim.tsrollup unions cumUsdc + cumReward so a pure consumer gets a claimable $PERKOS row.App/scripts/monthly-drop.mjschains every leg for one chain (budget → swap → distribute → build root → fund vault → post root → mark-posted). Dry-run by default;--applysends real txs.POST /api/admin/claims/mark-posted— flipsclaim_distributions.postedonce the root is live on-chain (was never set → dashboard showed "root pending on-chain" for every claim).Validated
--applyon Base (synthetic $0.05 budget): swap 0.05 USDC → 56,651 $PERKOS → users 33,990 / platform 22,660 → root posted → vault funded → claim endpoint reconciled. Cleaned up after.Multi-chain deposits + balances
agent_accounts/credit_ledgerkeyed by (wallet, chain); payment-chain = earning-chain (no cross-chain double-claim)./verify+/settle(not the buggy/api/v2/x402/*); facilitator timeout handling (60s for Celo finality). Validated on-chain ($1 USDC, both chains).system_errors).Vault owner-ops
VaultOwnerPanelon/admin/billing— vault owner signssetRewardTokenper chain from the connected wallet (no CLI key); renders only when connected == on-chainowner().Docs
docs/PERKOS-REWARDS-BUYBACK-DESIGN.md(the drop design + runbook);llms.txtupdated for multi-chain + deposits + claim/pull model.