diff --git a/App/app/api/admin/claims/mark-posted/route.ts b/App/app/api/admin/claims/mark-posted/route.ts new file mode 100644 index 0000000..f96501b --- /dev/null +++ b/App/app/api/admin/claims/mark-posted/route.ts @@ -0,0 +1,41 @@ +/** + * POST /api/admin/claims/mark-posted — flip a distribution's `posted` flag once + * its Merkle root is live on-chain (after setMerkleRoot). + * + * Body: { chain: "base"|"celo", root: "0x…", txHash?: "0x…" } + * + * Until this is called the dashboard shows "root pending on-chain" for that + * chain's claims (the proof still validates against the on-chain root either + * way — this is the display hint). The monthly-drop orchestrator + the manual + * claim-publish flow call it right after posting the root. Admin-gated. + */ +import { requireAdmin } from "../../../../../lib/admin"; +import { withDb } from "../../../../../lib/db"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + const blocked = requireAdmin(request); + if (blocked) return blocked; + + const body = (await request.json().catch(() => ({}))) as Record; + const chain = body.chain === "celo" ? "celo" : "base"; + const root = typeof body.root === "string" ? body.root.trim() : ""; + const txHash = typeof body.txHash === "string" && body.txHash.trim() ? body.txHash.trim() : null; + if (!/^0x[0-9a-fA-F]{64}$/.test(root)) { + return Response.json({ ok: false, error: "root must be 0x + 64 hex" }, { status: 400 }); + } + + const updated = await withDb(async (c) => { + const r = await c.query( + `UPDATE claim_distributions SET posted = true, tx_hash = COALESCE($3, tx_hash) + WHERE lower(chain) = lower($1) AND lower(root) = lower($2)`, + [chain, root, txHash], + ); + return r.rowCount ?? 0; + }); + if (updated === 0) { + return Response.json({ ok: false, error: "no_distribution_matched" }, { status: 404 }); + } + return Response.json({ ok: true, chain, root, txHash, updated }); +} diff --git a/App/app/api/admin/credits/grant/route.ts b/App/app/api/admin/credits/grant/route.ts index 50aab82..d6c309f 100644 --- a/App/app/api/admin/credits/grant/route.ts +++ b/App/app/api/admin/credits/grant/route.ts @@ -1,11 +1,15 @@ /** * Admin: grant credits to a wallet (top-up / deposit / research stipend). * - * POST /api/admin/credits/grant body: { wallet, amount, reason?, agentId? } + * POST /api/admin/credits/grant body: { wallet, amount, reason?, agentId?, chain? } * * Used to seed balances, settle off-chain deposits, or pay research agents a * credit stipend. The on-chain deposit flow (verify an x402 receipt -> credit) * lands later; this is the trusted admin path. Admin-token gated. + * + * `chain` (base|celo, default base) picks WHICH per-chain balance to credit — + * credits are segregated by chain so they earn providers on the chain they're + * spent from (payment-chain = earning-chain). */ import { requireAdmin } from "../../../../../lib/admin"; import { credit } from "../../../../../lib/credits"; @@ -29,10 +33,11 @@ export async function POST(request: Request) { const reason = typeof body.reason === "string" && body.reason.trim() ? body.reason.trim() : "grant"; const agentId = typeof body.agentId === "string" ? body.agentId : null; const deposited = reason === "deposit" || body.deposited === true; + const chain = String(body.chain || "base").toLowerCase() === "celo" ? "celo" : "base"; const balance = await withDb((client) => - credit(client, { wallet, agentId, amount, reason, deposited }), + credit(client, { wallet, agentId, amount, reason, deposited, chain }), ); - return Response.json({ ok: true, wallet: wallet.toLowerCase(), granted: amount, reason, balance }); + return Response.json({ ok: true, wallet: wallet.toLowerCase(), granted: amount, reason, chain, balance }); } diff --git a/App/app/api/admin/errors/route.ts b/App/app/api/admin/errors/route.ts new file mode 100644 index 0000000..0095154 --- /dev/null +++ b/App/app/api/admin/errors/route.ts @@ -0,0 +1,25 @@ +/** + * GET /api/admin/errors — recent system errors for the admin error log. + * + * ?limit=100 (max 500) + * ?scope=deposit.settle (optional filter) + * + * Admin-gated (Bearer token or allowlisted wallet via x-admin-wallet). + */ +import { requireAdmin } from "../../../../lib/admin"; +import { withDb } from "../../../../lib/db"; +import { recentErrors } from "../../../../lib/errlog"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const unauthorized = requireAdmin(request); + if (unauthorized) return unauthorized; + + const url = new URL(request.url); + const limit = Number(url.searchParams.get("limit") || 100); + const scope = url.searchParams.get("scope")?.trim() || undefined; + + const errors = await withDb((c) => recentErrors(c, { limit, scope })); + return Response.json({ ok: true, errors, ts: new Date().toISOString() }); +} diff --git a/App/app/api/admin/rewards/distribute/route.ts b/App/app/api/admin/rewards/distribute/route.ts new file mode 100644 index 0000000..d69eca2 --- /dev/null +++ b/App/app/api/admin/rewards/distribute/route.ts @@ -0,0 +1,66 @@ +/** + * POST /api/admin/rewards/distribute — the ACCOUNTING half of a monthly drop, + * called AFTER the on-chain buyback swap lands $PERKOS in the treasury. + * + * Body: { month?: "YYYY-MM", chain?: "base"|"celo", perkosBought: "<18-dec base + * units>", execute?: boolean } + * + * execute=false (default) → dry-run: returns what would be written (platform cut, + * user pool, per-wallet shares) without touching the DB. + * execute=true → writes token_rewards (each wallet's usage-weighted $PERKOS) + + * marks that month's pending reward_pool rows distributed. The caller then funds + * the vault with the user $PERKOS + posts the per-chain root (claim scripts). + * + * No on-chain work here (the swap + fund + root are the operator's job with the + * treasury key). Admin-gated. + */ +import { requireAdmin } from "../../../../../lib/admin"; +import { withDb } from "../../../../../lib/db"; +import { computeMonthlyDrop, distributeDrop } from "../../../../../lib/rewardsDrop"; + +export const dynamic = "force-dynamic"; + +function currentMonthUtc(): string { + const now = new Date(); + return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`; +} + +export async function POST(request: Request) { + const unauthorized = requireAdmin(request); + if (unauthorized) return unauthorized; + + const body = (await request.json().catch(() => ({}))) as Record; + const month = typeof body.month === "string" && body.month.trim() ? body.month.trim() : currentMonthUtc(); + const chain = body.chain === "celo" ? "celo" : "base"; + const execute = body.execute === true; + + let perkosBought: bigint; + try { + perkosBought = BigInt(String(body.perkosBought ?? "0")); + } catch { + return Response.json({ ok: false, error: "perkosBought must be an integer (18-dec base units)" }, { status: 400 }); + } + if (perkosBought <= 0n) { + return Response.json({ ok: false, error: "perkosBought must be > 0" }, { status: 400 }); + } + + try { + if (!execute) { + // Dry-run: show the plan without writing. + const drop = await withDb((c) => computeMonthlyDrop(c, { month, chain })); + const platformPerkos = (perkosBought * BigInt(drop.platformBps)) / 10000n; + const userPerkos = perkosBought - platformPerkos; + const totalScaled = BigInt(Math.round(drop.totalActivity * 1e6)); + const perWallet = drop.wallets.map((w) => ({ + wallet: w.wallet, + sharePct: w.sharePct, + perkos: totalScaled > 0n ? ((userPerkos * BigInt(Math.round(w.activity * 1e6))) / totalScaled).toString() : "0", + })); + return Response.json({ ok: true, dryRun: true, plan: { month, chain, perkosBought: perkosBought.toString(), platformPerkos: platformPerkos.toString(), userPerkos: userPerkos.toString(), walletCount: drop.wallets.length, perWallet } }); + } + const result = await withDb((c) => distributeDrop(c, { month, chain, perkosBoughtBaseUnits: perkosBought })); + return Response.json({ ok: true, dryRun: false, distribution: result }); + } catch (e) { + return Response.json({ ok: false, error: e instanceof Error ? e.message : "distribute_failed" }, { status: 400 }); + } +} diff --git a/App/app/api/admin/rewards/drop/route.ts b/App/app/api/admin/rewards/drop/route.ts new file mode 100644 index 0000000..b9af83e --- /dev/null +++ b/App/app/api/admin/rewards/drop/route.ts @@ -0,0 +1,66 @@ +/** + * GET /api/admin/rewards/drop?month=YYYY-MM&chain=base|celo — DRY-RUN of the + * monthly $PERKOS usage drop. Read-only: shows the budget (5% accrued that + * month), the platform/user split, and each wallet's usage-weighted share. No + * trade, no writes — what a buyback WOULD pay. Admin-gated. + * + * `month` defaults to the current UTC month; `chain` defaults to base. + */ +import { requireAdmin } from "../../../../../lib/admin"; +import { withDb } from "../../../../../lib/db"; +import type { PayNetwork } from "../../../../../lib/payments"; +import { computeMonthlyDrop } from "../../../../../lib/rewardsDrop"; +import { quoteBuyback } from "../../../../../lib/uniswapTrade"; + +export const dynamic = "force-dynamic"; + +const TREASURY = (process.env.KNOWLEDGE_TREASURY_ADDRESS || "0x3f0D7b9916212fA0A9Ac0EF8f72a25EB56F7046C").trim(); + +function currentMonthUtc(): string { + const now = new Date(); + const y = now.getUTCFullYear(); + const m = String(now.getUTCMonth() + 1).padStart(2, "0"); + return `${y}-${m}`; +} + +export async function GET(request: Request) { + const unauthorized = requireAdmin(request); + if (unauthorized) return unauthorized; + + const url = new URL(request.url); + const month = url.searchParams.get("month")?.trim() || currentMonthUtc(); + const chain = url.searchParams.get("chain")?.trim() || "base"; + + try { + const drop = await withDb((c) => computeMonthlyDrop(c, { month, chain })); + + // Quote the budget → $PERKOS via the Uniswap Trading API (read-only, no + // trade), then apply the platform/user split + per-wallet shares so the + // admin sees exactly what $PERKOS each side would get. Best-effort: needs + // UNISWAP_API_KEY; surfaces an `error` instead of failing the whole view. + let buyback: unknown = null; + if (drop.budgetUsdc > 0) { + const q = await quoteBuyback({ chain: drop.chain as PayNetwork, amountUsdc: drop.budgetUsdc, swapper: TREASURY }); + if (q.ok && q.amountOutPerkosFloat != null) { + const perkosTotal = q.amountOutPerkosFloat; + const platformPerkos = (perkosTotal * drop.platformBps) / 10000; + const userPerkos = perkosTotal - platformPerkos; + buyback = { + perkosTotal, + platformPerkos, + userPerkos, + perWallet: drop.wallets.map((w) => ({ wallet: w.wallet, sharePct: w.sharePct, perkos: userPerkos * w.sharePct })), + }; + } else { + buyback = { error: q.error ?? "quote_failed" }; + } + } + + return Response.json({ ok: true, dryRun: true, drop, buyback, ts: new Date().toISOString() }); + } catch (e) { + return Response.json( + { ok: false, error: e instanceof Error ? e.message : "compute_failed" }, + { status: 400 }, + ); + } +} diff --git a/App/app/api/deposit/route.ts b/App/app/api/deposit/route.ts index cf09e89..71f1b32 100644 --- a/App/app/api/deposit/route.ts +++ b/App/app/api/deposit/route.ts @@ -14,6 +14,7 @@ */ import { credit } from "../../../lib/credits"; import { withDb } from "../../../lib/db"; +import { logError } from "../../../lib/errlog"; import { buildPaymentRequirements, decodePaymentHeader, @@ -69,6 +70,32 @@ export async function POST(request: Request) { const requirements = buildPaymentRequirements(net, amount, RESOURCE); const settle = await settleViaStack(payload, requirements); if (!settle.ok) { + // Capture the full facilitator interaction so the admin error log shows + // exactly what PerkOS Stack rejected (signature redacted — keep the shapes). + const pl = payload as Record; + const inner = (pl.payload ?? {}) as Record; + await withDb((c) => + logError(c, { + scope: "deposit.settle", + message: settle.error || `settle failed (HTTP ${settle.status})`, + context: { + net, + amount, + wallet, + httpStatus: settle.status, + stackResponse: settle.raw, + paymentRequirements: requirements, + payloadShape: { + x402Version: pl.x402Version, + scheme: pl.scheme, + network: pl.network, + payloadKeys: Object.keys(inner), + authorization: inner.authorization, + signaturePresent: Boolean(inner.signature), + }, + }, + }), + ).catch(() => {}); return Response.json({ ok: false, error: "settlement_failed", reason: settle.error }, { status: 402 }); } @@ -90,16 +117,19 @@ export async function POST(request: Request) { settle.transaction, ]); if (dup.rowCount) { - const r = await c.query(`SELECT balance::float8 b FROM agent_accounts WHERE lower(wallet)=lower($1)`, [payee]); + const r = await c.query(`SELECT balance::float8 b FROM agent_accounts WHERE lower(wallet)=lower($1) AND chain=$2`, [payee, net]); return { balance: r.rows[0]?.b ?? 0, deduped: true }; } } + // Credit the deposit ON THE CHAIN it was paid — that's the chain those + // credits will earn providers on when spent. const balance = await credit(c, { wallet: payee, amount, reason: "deposit", deposited: true, x402ReceiptId: settle.transaction, + chain: net, }); return { balance, deduped: false }; }); diff --git a/App/app/skill/query/route.ts b/App/app/skill/query/route.ts index 845f121..61f0b0d 100644 --- a/App/app/skill/query/route.ts +++ b/App/app/skill/query/route.ts @@ -38,6 +38,10 @@ export async function POST(request: Request) { const access = await getAccessContext(client, request); const cfg = await loadTokenomics(client); const requestedOrg = Boolean(request.headers.get('x-organization-id') || request.headers.get('x-org-id')); + // The consumer pays from a specific chain's balance — and that's the chain + // the provider earns on (payment-chain = earning-chain). Default Base. + const reqChain = String(body.payChain || body.chain || request.headers.get('x-payment-chain') || 'base').toLowerCase(); + const payChain = reqChain === 'celo' ? 'celo' : 'base'; // Validated-only queries buy the top (enterprise) tier — guaranteed quality. const tier = resolveX402Tier({ requestedTier: body.tier || body.scope, hasOrganizationScope: requestedOrg || access.organizationIds.length > 0, mode, validated: requireValidated }); const policy = getX402Policy('/skill/query', tier, priceForTier(cfg, tier)); @@ -59,7 +63,7 @@ export async function POST(request: Request) { if (!access.wallet) { return { paymentRequired: true as const, policy, x402, rows: [], creditError: 'wallet_required' as const }; } - const charged = await debit(client, { wallet: access.wallet, agentId: access.agentId, amount: price, reason: 'query', requestId: id }); + const charged = await debit(client, { wallet: access.wallet, agentId: access.agentId, amount: price, reason: 'query', requestId: id, chain: payChain }); if (!charged.ok) { return { paymentRequired: true as const, policy, x402, rows: [], creditError: 'insufficient_credit' as const, balance: charged.balance, price }; } @@ -141,11 +145,12 @@ export async function POST(request: Request) { access, retrievedItemIds: rows.map((row) => row.id), amountPaid: split.provider, - chain: policy.price.chain, + chain: payChain, token: policy.price.token, x402ReceiptId: receiptId, }); - // Move the provider share into each provider's prepaid balance (earnings). + // Move the provider share into each provider's prepaid balance (earnings), + // ON THE PAYMENT CHAIN — so the provider earns where the consumer paid. for (const c of attr.creditedByWallet) { await credit(client, { wallet: c.wallet, @@ -154,6 +159,7 @@ export async function POST(request: Request) { reason: 'attribution', requestId: id, earned: true, + chain: payChain, }); } // Platform take → recognized PerkOS revenue. Reward share → accrue to the @@ -166,6 +172,7 @@ export async function POST(request: Request) { requesterWallet: access.wallet, researcherWallets: attr.creditedByWallet.map((c) => c.wallet), researcherBps: cfg.rewardResearcherBps, + chain: payChain, }); } catch { // attribution/credit/fee accounting is secondary to the response — swallow. diff --git a/App/components/AdminClient.tsx b/App/components/AdminClient.tsx index 79e37dd..7eaa8ae 100644 --- a/App/components/AdminClient.tsx +++ b/App/components/AdminClient.tsx @@ -1,12 +1,14 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { Fragment, useCallback, useEffect, useState } from 'react'; import { useAccount } from 'wagmi'; +import VaultOwnerPanel from './VaultOwnerPanel'; type Policy = { tier: string; price: { amount: string; currency: string } }; type Cfg = { mode: string; policies: Policy[]; env: Record }; type BillingRow = { agent_id: string; wallet: string | null; exempt: boolean; role: string; note: string | null; updated_at: string | null }; type Settlement = { id: string; provider_wallet: string; amount: number; currency: string; status: string; tx_hash: string | null; created_at: string | null }; +type SysErr = { id: string; createdAt: string | null; scope: string; severity: string; message: string; context: unknown }; type Tk = { mode: string; prices: { public: number; private: number; premium: number; enterprise: number }; @@ -38,6 +40,8 @@ export default function AdminClient() { const [tk, setTk] = useState(null); const [tkSum, setTkSum] = useState(null); const [tkForm, setTkForm] = useState(null); + const [errors, setErrors] = useState([]); + const [openErr, setOpenErr] = useState(null); const [msg, setMsg] = useState(''); const adminFetch = useCallback( @@ -48,16 +52,18 @@ export default function AdminClient() { const refresh = useCallback(async () => { if (!address) return; - const [c, b, s, t] = await Promise.all([ + const [c, b, s, t, e] = await Promise.all([ adminFetch('/api/admin/x402/config').then((r) => (r.ok ? r.json() : null)).catch(() => null), adminFetch('/api/admin/billing').then((r) => (r.ok ? r.json() : null)).catch(() => null), adminFetch('/api/admin/settle').then((r) => (r.ok ? r.json() : null)).catch(() => null), adminFetch('/api/admin/tokenomics').then((r) => (r.ok ? r.json() : null)).catch(() => null), + adminFetch('/api/admin/errors?limit=50').then((r) => (r.ok ? r.json() : null)).catch(() => null), ]); if (c?.ok) setCfg(c); if (b?.ok) setBilling(b.rows || []); if (s?.ok) { setSettlements(s.settlements || []); setOnChain(Boolean(s.onChain)); } if (t?.ok) { setTk(t.config); setTkForm(t.config); setTkSum(t.summary); } + if (e?.ok) setErrors(e.errors || []); if (!c?.ok && !b?.ok && !t?.ok) setMsg('Admin access denied — connect an allowlisted wallet.'); }, [address, adminFetch]); @@ -190,6 +196,46 @@ export default function AdminClient() { ) :

Loading tokenomics…

} + {/* Vault owner ops — only renders when the connected wallet is the vault owner */} + + + {/* System error log */} +
+
+
+

System · error log

+

Recent errors {errors.length ? ({errors.length}) : null}

+
+ +
+

Server-side failures (deposit/settle, billing, claims). Click a row to expand its context.

+ {errors.length ? ( + + + + {errors.map((e) => ( + + setOpenErr(openErr === e.id ? null : e.id)}> + + + + + + {openErr === e.id ? ( + + + + ) : null} + + ))} + +
WhenScopeSeverityMessage
{e.createdAt ? new Date(e.createdAt).toLocaleString('en-US', { dateStyle: 'short', timeStyle: 'medium' }) : '—'}{e.scope}{e.severity}{e.message.length > 90 ? `${e.message.slice(0, 90)}…` : e.message}
+ {e.message} + {e.context ? `\n\n${JSON.stringify(e.context, null, 2)}` : ''} +
+ ) :

No errors logged. 🎉

} +
+ {/* Whitelist */}

Whitelist · agent_billing

diff --git a/App/components/ClaimPanel.tsx b/App/components/ClaimPanel.tsx index 49a148e..d7f19bd 100644 --- a/App/components/ClaimPanel.tsx +++ b/App/components/ClaimPanel.tsx @@ -52,18 +52,21 @@ export default function ClaimPanel() { return (
-

Claim · pull your earnings + $PERKOS

-

Claimable

+

Claim · earnings + your $PERKOS drop

+

Your rewards

{!hasVault ? ( -

The claim vault isn't deployed yet — your earnings accrue and become claimable here once it's live.

+

The claim vault isn't live yet — your earnings and $PERKOS accrue and become claimable here once it's up.

) : claimable.length === 0 ? ( -

Nothing to claim yet. Earnings are paid out on the chain a consumer paid on; when a distribution includes you, each chain's claim shows here.

+

Nothing to claim yet. The more you use PerkOS, the bigger your $PERKOS drop — earned just for using the platform. When a distribution includes you, it shows up here to claim.

) : ( -
- {claimable.map((c) => ( - - ))} -
+ <> +

Your USDC earnings + your $PERKOS drop for using PerkOS — claim anytime, on each chain.

+
+ {claimable.map((c) => ( + + ))} +
+ )}
); @@ -91,8 +94,8 @@ function ClaimChainRow({ vault, cc, account, onClaimed }: { vault: `0x${string}`
{cc.chain} - {formatUnits(owedUsdc, 6)} USDC - {formatUnits(owedReward, 18)} PERKOS + {owedUsdc > 0n ? {formatUnits(owedUsdc, 6)} USDC : null} + {owedReward > 0n ? {Math.round(Number(formatUnits(owedReward, 18))).toLocaleString()} $PERKOS drop : null} {!cc.claim!.posted ? root pending on-chain : null}
diff --git a/App/components/DashboardClient.tsx b/App/components/DashboardClient.tsx index 52eb20c..a834a46 100644 --- a/App/components/DashboardClient.tsx +++ b/App/components/DashboardClient.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { ConnectButton } from '@rainbow-me/rainbowkit'; +import { useCallback, useEffect, useState } from 'react'; import { useAccount } from 'wagmi'; import ClaimPanel from './ClaimPanel'; @@ -74,11 +75,24 @@ function short(id: string | null) { } export default function DashboardClient() { - const { address } = useAccount(); + const { address, isConnected } = useAccount(); const [usage, setUsage] = useState(null); const [credits, setCredits] = useState(null); const [error, setError] = useState(''); + // Re-fetchable on its own so a deposit/claim can refresh the balance + ledger + // without a full reload (best-effort — the dashboard renders without it). + const loadCredits = useCallback(async () => { + if (!address) return; + try { + const res = await fetch(`/api/credits/${address}`, { cache: 'no-store' }); + const data: Credits | null = res.ok ? await res.json() : null; + if (data?.ok) setCredits(data.account); + } catch { + /* ignore */ + } + }, [address]); + useEffect(() => { let active = true; if (!address) return; @@ -92,34 +106,58 @@ export default function DashboardClient() { .then((data) => { if (active) setUsage(data); }) .catch(() => { if (active) setError('Unable to load live dashboard data.'); }); - // Credits/earnings are best-effort — the dashboard still renders without them. - fetch(`/api/credits/${address}`, { cache: 'no-store' }) - .then(async (res) => (res.ok ? res.json() : null)) - .then((data: Credits | null) => { if (active && data?.ok) setCredits(data.account); }) - .catch(() => {}); + loadCredits(); return () => { active = false; }; - }, [address]); + }, [address, loadCredits]); + + // The wallet bar lives in the nav on every state, so it's always clear which + // wallet (if any) this dashboard is showing — its balance, deposits, and + // claims all key off it. showBalance=false: we surface the USDC credit + // balance below, not the wallet's native gas balance. + const nav = ( + + ); - if (error) return

{error}

; - if (!usage) return

Loading live dashboard data…

; + // Not connected → don't spin on "Loading…" forever; tell them to connect. + if (!isConnected || !address) { + return ( +
+ {nav} +
+

User dashboard

+

Connect your wallet.

+

Connect the wallet your agents bill and earn from — its credit balance, earnings, deposits, and claims show up here.

+
+
+
+ ); + } + + if (error) return
{nav}

{error}

; + if (!usage) return
{nav}

Loading live dashboard data…

; const lastUpdate = usage.knowledge.lastKnowledgeUpdate ? new Date(usage.knowledge.lastKnowledgeUpdate).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' }) : 'No sync yet'; const cur = credits?.currency ?? 'USDC'; + const shortAddr = `${address.slice(0, 6)}…${address.slice(-4)}`; return (
- + {nav}
-

User dashboard

+

User dashboard · {shortAddr}

Your agents' knowledge earnings.

-

Credit balance, what your agents earned providing research, and what they spent querying — live.

+

Credit balance, what your agents earned providing research, and what they spent querying — live for {shortAddr}.

@@ -129,7 +167,7 @@ export default function DashboardClient() {
Knowledge items{usage.knowledge.knowledgeItemsAvailable}
- + diff --git a/App/components/DepositPanel.tsx b/App/components/DepositPanel.tsx index 472f235..8718959 100644 --- a/App/components/DepositPanel.tsx +++ b/App/components/DepositPanel.tsx @@ -1,9 +1,96 @@ 'use client'; import { useState } from 'react'; -import { useAccount, useWalletClient } from 'wagmi'; +import { getAddress, type WalletClient } from 'viem'; +import { useAccount, useSwitchChain, useWalletClient } from 'wagmi'; import { wrapFetchWithPayment } from 'x402-fetch'; +const CHAIN_ID: Record<'base' | 'celo', number> = { base: 8453, celo: 42220 }; + +// x402 "exact" EIP-3009 authorization typed-data (USDC transferWithAuthorization). +const TRANSFER_WITH_AUTHORIZATION_TYPES = { + TransferWithAuthorization: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'validAfter', type: 'uint256' }, + { name: 'validBefore', type: 'uint256' }, + { name: 'nonce', type: 'bytes32' }, + ], +} as const; + +type PaymentRequirements = { + scheme: string; network: string; maxAmountRequired: string; payTo: string; + asset: string; maxTimeoutSeconds: number; extra?: { name?: string; version?: string }; +}; + +function randomNonce(): `0x${string}` { + const b = new Uint8Array(32); + crypto.getRandomValues(b); + return `0x${[...b].map((x) => x.toString(16).padStart(2, '0')).join('')}`; +} + +/** + * Build a signed x402 `X-PAYMENT` header manually — x402-fetch 1.2.0's network + * enum lacks "celo", so it can't be used for Celo. We sign the EIP-3009 + * authorization against the token's real EIP-712 domain (name/version come from + * the 402's paymentRequirements.extra, which the server sets per token) and + * base64 the x402 payload. Works for any EIP-3009 USDC; used here for Celo. + */ +async function signX402Payment( + walletClient: WalletClient, + req: PaymentRequirements, + payer: `0x${string}`, + chainId: number, +): Promise { + const validAfter = 0n; + const validBefore = BigInt(Math.floor(Date.now() / 1000) + (req.maxTimeoutSeconds || 120)); + const nonce = randomNonce(); + const value = BigInt(req.maxAmountRequired); + const authorization = { + from: getAddress(payer), + to: getAddress(req.payTo), + value, + validAfter, + validBefore, + nonce, + }; + // Replicate x402's signAuthorization EXACTLY: call signTypedData(data) with NO + // `account` field, letting the wallet sign with its connected account. Passing + // an explicit `account` made a smart wallet (EIP-7702) try to EXECUTE the + // transferWithAuthorization as a transaction (from its EOA signer, no gas) + // instead of signing the EIP-3009 auth gaslessly. (Base worked via x402-fetch, + // which signs this way; Celo uses this manual path, so it must match.) + const signature = await (walletClient.signTypedData as (a: unknown) => Promise<`0x${string}`>)({ + types: TRANSFER_WITH_AUTHORIZATION_TYPES, + domain: { + name: req.extra?.name || 'USDC', + version: req.extra?.version || '2', + chainId, + verifyingContract: getAddress(req.asset), + }, + primaryType: 'TransferWithAuthorization', + message: authorization, + }); + const paymentPayload = { + x402Version: 1, + scheme: 'exact', + network: req.network, + payload: { + signature, + authorization: { + from: authorization.from, + to: authorization.to, + value: value.toString(), + validAfter: validAfter.toString(), + validBefore: validBefore.toString(), + nonce, + }, + }, + }; + return btoa(JSON.stringify(paymentPayload)); +} + const inp: React.CSSProperties = { padding: '8px 10px', borderRadius: 8, border: '1px solid rgba(255,255,255,0.15)', background: 'rgba(255,255,255,0.04)', color: 'inherit', fontSize: 13 }; const btn: React.CSSProperties = { padding: '10px 18px', borderRadius: 10, border: '1px solid rgba(124,247,200,0.4)', background: 'rgba(124,247,200,0.16)', color: 'inherit', cursor: 'pointer', fontSize: 14, fontWeight: 700 }; const lbl: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12, opacity: 0.85 }; @@ -17,6 +104,7 @@ const NETS = [ export default function DepositPanel({ onDeposited }: { onDeposited?: () => void }) { const { address } = useAccount(); const { data: walletClient } = useWalletClient(); + const { switchChainAsync } = useSwitchChain(); const [network, setNetwork] = useState<'base' | 'celo'>('base'); const [amount, setAmount] = useState(''); const [creditTo, setCreditTo] = useState(''); @@ -24,32 +112,59 @@ export default function DepositPanel({ onDeposited }: { onDeposited?: () => void const [msg, setMsg] = useState(''); const [ok, setOk] = useState(false); + function onSuccess(amt: number, data: { balance?: number; transaction?: string }) { + setOk(true); + setMsg(`Deposited ${amt} USDC on ${network}. New balance: ${data.balance} USDC${data.transaction ? ` · tx ${String(data.transaction).slice(0, 12)}…` : ''}`); + setAmount(''); + onDeposited?.(); + } + async function deposit() { const amt = Number(amount); setMsg(''); setOk(false); if (!(amt > 0)) { setMsg('Enter an amount.'); return; } - if (!walletClient) { setMsg('Connect a wallet first.'); return; } + if (!walletClient || !address) { setMsg('Connect a wallet first.'); return; } setBusy(true); try { - const maxBase = BigInt(Math.round(amt * 1e6)); // USDC 6-dec; cap = amount - // x402-fetch handles the 402 → EIP-3009 signature → retry with X-PAYMENT. - const fetchWithPay = wrapFetchWithPayment(fetch, walletClient as Parameters[1], maxBase); const body: Record = { amount: amt, network, wallet: address }; const to = creditTo.trim(); if (/^0x[0-9a-fA-F]{40}$/.test(to)) body.creditTo = to; - const res = await fetchWithPay('/api/deposit', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - }); - const data = await res.json().catch(() => ({})); - if (data.ok) { - setOk(true); - setMsg(`Deposited ${amt} USDC on ${network}. New balance: ${data.balance} USDC${data.transaction ? ` · tx ${String(data.transaction).slice(0, 12)}…` : ''}`); - setAmount(''); - onDeposited?.(); + + if (network === 'base') { + // Base: x402-fetch handles 402 → EIP-3009 signature → retry. (Proven path.) + const maxBase = BigInt(Math.round(amt * 1e6)); + const fetchWithPay = wrapFetchWithPayment(fetch, walletClient as Parameters[1], maxBase); + const res = await fetchWithPay('/api/deposit', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + if (data.ok) onSuccess(amt, data); + else setMsg(`Deposit failed: ${data.reason || data.error || res.status}`); } else { - setMsg(`Deposit failed: ${data.reason || data.error || res.status}`); + // Celo: x402-fetch's network enum has no "celo", so do the x402 dance by + // hand — get the 402 challenge, sign the EIP-3009 auth, retry with X-PAYMENT. + await switchChainAsync({ chainId: CHAIN_ID.celo }).catch(() => {}); + const r1 = await fetch('/api/deposit', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), + }); + if (r1.status !== 402) { + const d = await r1.json().catch(() => ({})); + setMsg(`Deposit failed: ${d.reason || d.error || r1.status}`); + return; + } + const challenge = await r1.json(); + const accepts: PaymentRequirements[] = challenge.accepts || []; + const req = accepts.find((a) => a.network === 'celo'); + if (!req) { setMsg('Celo not offered by the server.'); return; } + const xPayment = await signX402Payment(walletClient, req, address, CHAIN_ID.celo); + const r2 = await fetch('/api/deposit', { + method: 'POST', + headers: { 'content-type': 'application/json', 'X-PAYMENT': xPayment }, + body: JSON.stringify(body), + }); + const data = await r2.json().catch(() => ({})); + if (data.ok) onSuccess(amt, data); + else setMsg(`Deposit failed: ${data.reason || data.error || r2.status}`); } } catch (e) { setMsg(e instanceof Error ? e.message.slice(0, 140) : 'Deposit failed'); diff --git a/App/components/VaultOwnerPanel.tsx b/App/components/VaultOwnerPanel.tsx new file mode 100644 index 0000000..b318c57 --- /dev/null +++ b/App/components/VaultOwnerPanel.tsx @@ -0,0 +1,118 @@ +'use client'; + +/** + * Vault · owner ops — owner-only on-chain actions on the PerkosClaimVault, + * signed from the connected wallet (no private key in a CLI / on the server). + * + * Renders ONLY when the connected wallet equals the vault `owner()` on-chain + * (re-checked per render; the contract enforces `onlyOwner` regardless). The + * one action today is `setRewardToken` — pointing each chain's vault at that + * chain's $PERKOS so the 5% reward leg of a claim actually pays out. The vault + * has the same address on every chain; each chain is set independently. + * + * Distributor ops (post Merkle root, fund the vault) are NOT here — those run + * as the treasury/distributor wallet (0x3f0D…) via the operator scripts. + */ +import { useCallback, useEffect, useState } from 'react'; +import { useAccount, useReadContract, useWaitForTransactionReceipt, useWriteContract } from 'wagmi'; + +const VAULT_ABI = [ + { type: 'function', name: 'owner', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] }, + { type: 'function', name: 'rewardToken', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] }, + { type: 'function', name: 'setRewardToken', stateMutability: 'nonpayable', inputs: [{ name: 'rewardToken_', type: 'address' }], outputs: [] }, +] as const; + +const ZERO = '0x0000000000000000000000000000000000000000'; +const btn: React.CSSProperties = { padding: '8px 14px', borderRadius: 8, border: '1px solid rgba(124,247,200,0.4)', background: 'rgba(124,247,200,0.16)', color: 'inherit', cursor: 'pointer', fontSize: 13, fontWeight: 700 }; + +type ChainInfo = { chain: string; chainId: number; perkos: string }; + +export default function VaultOwnerPanel() { + const { address } = useAccount(); + const [vault, setVault] = useState(null); + const [chains, setChains] = useState([]); + const [loaded, setLoaded] = useState(false); + + const load = useCallback(async () => { + if (!address) return; + try { + const r = await fetch(`/api/claims/${address}`, { cache: 'no-store' }); + const d = await r.json(); + if (d?.ok) { + setVault(d.vaultAddress || null); + setChains((d.chains || []).map((c: ChainInfo) => ({ chain: c.chain, chainId: c.chainId, perkos: c.perkos }))); + } + } catch { + /* ignore */ + } finally { + setLoaded(true); + } + }, [address]); + useEffect(() => { load(); }, [load]); + + // Owner is the same on every chain — read it on the first chain to gate visibility. + const gate = chains.find((c) => c.chain === 'base') || chains[0]; + const { data: ownerData } = useReadContract({ + abi: VAULT_ABI, + address: (vault as `0x${string}`) || undefined, + functionName: 'owner', + chainId: gate?.chainId, + query: { enabled: Boolean(vault && gate) }, + }); + const isOwner = Boolean(address && ownerData && String(address).toLowerCase() === String(ownerData).toLowerCase()); + + if (!loaded || !vault || !isOwner) return null; + + return ( +
+

Vault · owner ops

+

Reward token ($PERKOS)

+

+ Point each chain's vault at that chain's $PERKOS so the 5% reward leg of a claim pays out. Owner-only; signed with your connected wallet. +

+
+ {chains.map((c) => ( + + ))} +
+
+ ); +} + +function VaultChainRow({ vault, info, onDone }: { vault: `0x${string}`; info: ChainInfo; onDone: () => void }) { + const { data: current, refetch } = useReadContract({ abi: VAULT_ABI, address: vault, functionName: 'rewardToken', chainId: info.chainId }); + const cur = (current as string | undefined) || ''; + const isZero = !cur || cur.toLowerCase() === ZERO; + const matches = Boolean(cur) && cur.toLowerCase() === info.perkos.toLowerCase(); + + const { writeContract, data: tx, isPending, error } = useWriteContract(); + const { isLoading: confirming, isSuccess } = useWaitForTransactionReceipt({ hash: tx }); + useEffect(() => { if (isSuccess) { refetch(); onDone(); } }, [isSuccess, refetch, onDone]); + + const doSet = () => + writeContract({ abi: VAULT_ABI, address: vault, functionName: 'setRewardToken', chainId: info.chainId, args: [info.perkos as `0x${string}`] }); + + return ( +
+
+ {info.chain} + {matches ? ( + ✓ $PERKOS set + ) : isZero ? ( + not set — reward leg off + ) : ( + set to {cur.slice(0, 10)}… (≠ target) + )} + target {info.perkos.slice(0, 8)}…{info.perkos.slice(-4)} +
+
+ {error ? {error.message.slice(0, 50)} : null} + {!matches ? ( + + ) : null} +
+
+ ); +} diff --git a/App/lib/claim.ts b/App/lib/claim.ts index 40a078e..cd77c39 100644 --- a/App/lib/claim.ts +++ b/App/lib/claim.ts @@ -61,21 +61,37 @@ export function buildDistribution(entries: ClaimEntry[]): BuiltDistribution | nu * segregated by the chain the consumer paid on (`knowledge_attributions.chain`), * so each chain's distribution is independent — a provider claims their Base * earnings on Base and their Celo earnings on Celo, never the same amount twice. - * cumReward stays 0 until the per-chain $PERKOS buyback is wired. + * cumReward is the wallet's cumulative $PERKOS drop on this chain (token_rewards, + * filled by the monthly usage-drop). A wallet may have USDC (earned), $PERKOS + * (drop), or both — a consumer who only spent has $PERKOS but no USDC — so we + * union both per-chain sources. */ export async function rollupEntries(client: Client, chain: string): Promise { - const r = await client.query( - `SELECT lower(provider_wallet) w, coalesce(sum(amount),0)::float8 e - FROM knowledge_attributions - WHERE lower(chain) = lower($1) AND provider_wallet IS NOT NULL - GROUP BY 1 HAVING sum(amount) > 0`, - [chain], - ); - return (r.rows as { w: string; e: number }[]).map((row) => ({ - wallet: row.w, - cumUsdc: usdcBaseUnits(row.e), - cumReward: 0n, - })); + const [usdc, reward] = await Promise.all([ + client.query( + `SELECT lower(provider_wallet) w, coalesce(sum(amount),0)::float8 e + FROM knowledge_attributions + WHERE lower(chain) = lower($1) AND provider_wallet IS NOT NULL + GROUP BY 1 HAVING sum(amount) > 0`, + [chain], + ), + client.query( + `SELECT lower(wallet) w, cumulative_perkos::text p + FROM token_rewards WHERE chain = $1 AND cumulative_perkos > 0`, + [chain], + ), + ]); + const map = new Map(); + for (const row of usdc.rows as { w: string; e: number }[]) { + map.set(row.w, { cumUsdc: usdcBaseUnits(row.e), cumReward: 0n }); + } + for (const row of reward.rows as { w: string; p: string }[]) { + const cur = map.get(row.w) ?? { cumUsdc: 0n, cumReward: 0n }; + // cumulative_perkos is stored in 18-dec base units; floor to integer. + cur.cumReward = BigInt(String(row.p).split(".")[0] || "0"); + map.set(row.w, cur); + } + return [...map.entries()].map(([wallet, x]) => ({ wallet, cumUsdc: x.cumUsdc, cumReward: x.cumReward })); } export async function persistDistribution( diff --git a/App/lib/credits.ts b/App/lib/credits.ts index e95c1b1..4428acd 100644 --- a/App/lib/credits.ts +++ b/App/lib/credits.ts @@ -40,18 +40,20 @@ export async function isExempt( return r.rows[0]?.exempt === true; } -export async function getBalance(client: Client, wallet: string): Promise { +/** Balance lives PER (wallet, chain) — a consumer who deposits on Celo has Celo + * credits; spending them earns providers on Celo. chain defaults to 'base'. */ +export async function getBalance(client: Client, wallet: string, chain = "base"): Promise { const r = await client.query( - `SELECT balance::float8 AS balance FROM agent_accounts WHERE lower(wallet) = lower($1)`, - [wallet], + `SELECT balance::float8 AS balance FROM agent_accounts WHERE lower(wallet) = lower($1) AND chain = $2`, + [wallet, chain], ); return r.rows[0]?.balance ?? 0; } -async function ensureAccount(client: Client, wallet: string): Promise { +async function ensureAccount(client: Client, wallet: string, chain: string): Promise { await client.query( - `INSERT INTO agent_accounts (wallet) VALUES (lower($1)) ON CONFLICT (wallet) DO NOTHING`, - [wallet], + `INSERT INTO agent_accounts (wallet, chain) VALUES (lower($1), $2) ON CONFLICT (wallet, chain) DO NOTHING`, + [wallet, chain], ); } @@ -60,9 +62,9 @@ export type DebitResult = | { ok: false; reason: "insufficient"; balance: number }; /** - * Atomically debit a wallet's balance. Overdraft-safe: the UPDATE only matches - * when balance >= amount, so a concurrent double-spend can't drive it negative - * (single statement, no read-modify-write race). amount<=0 is a no-op success. + * Atomically debit a (wallet, chain) balance. Overdraft-safe: the UPDATE only + * matches when balance >= amount, so a concurrent double-spend can't drive it + * negative. amount<=0 is a no-op success. */ export async function debit( client: Client, @@ -72,32 +74,34 @@ export async function debit( amount: number; reason: string; requestId?: string | null; + chain?: string; }, ): Promise { + const chain = input.chain ?? "base"; if (!(input.amount > 0)) { - return { ok: true, balanceAfter: await getBalance(client, input.wallet) }; + return { ok: true, balanceAfter: await getBalance(client, input.wallet, chain) }; } - await ensureAccount(client, input.wallet); + await ensureAccount(client, input.wallet, chain); const upd = await client.query( `UPDATE agent_accounts - SET balance = balance - $2, total_spent = total_spent + $2, updated_at = now() - WHERE lower(wallet) = lower($1) AND balance >= $2 + SET balance = balance - $3, total_spent = total_spent + $3, updated_at = now() + WHERE lower(wallet) = lower($1) AND chain = $2 AND balance >= $3 RETURNING balance::float8 AS balance`, - [input.wallet, input.amount], + [input.wallet, chain, input.amount], ); if (!upd.rowCount) { - return { ok: false, reason: "insufficient", balance: await getBalance(client, input.wallet) }; + return { ok: false, reason: "insufficient", balance: await getBalance(client, input.wallet, chain) }; } const balanceAfter = upd.rows[0].balance as number; await client.query( - `INSERT INTO credit_ledger (wallet, agent_id, kind, amount, reason, request_id, balance_after) - VALUES (lower($1), $2, 'debit', $3, $4, $5, $6)`, - [input.wallet, input.agentId ?? null, input.amount, input.reason, input.requestId ?? null, balanceAfter], + `INSERT INTO credit_ledger (wallet, chain, agent_id, kind, amount, reason, request_id, balance_after) + VALUES (lower($1), $2, $3, 'debit', $4, $5, $6, $7)`, + [input.wallet, chain, input.agentId ?? null, input.amount, input.reason, input.requestId ?? null, balanceAfter], ); return { ok: true, balanceAfter }; } -/** Credit a wallet's balance (provider earnings, deposit, or admin grant). */ +/** Credit a (wallet, chain) balance (provider earnings, deposit, or admin grant). */ export async function credit( client: Client, input: { @@ -109,26 +113,29 @@ export async function credit( x402ReceiptId?: string | null; earned?: boolean; deposited?: boolean; + chain?: string; }, ): Promise { - if (!(input.amount > 0)) return getBalance(client, input.wallet); - await ensureAccount(client, input.wallet); + const chain = input.chain ?? "base"; + if (!(input.amount > 0)) return getBalance(client, input.wallet, chain); + await ensureAccount(client, input.wallet, chain); const extra = - (input.earned ? ", total_earned = total_earned + $2" : "") + - (input.deposited ? ", total_deposited = total_deposited + $2" : ""); + (input.earned ? ", total_earned = total_earned + $3" : "") + + (input.deposited ? ", total_deposited = total_deposited + $3" : ""); const upd = await client.query( `UPDATE agent_accounts - SET balance = balance + $2${extra}, updated_at = now() - WHERE lower(wallet) = lower($1) + SET balance = balance + $3${extra}, updated_at = now() + WHERE lower(wallet) = lower($1) AND chain = $2 RETURNING balance::float8 AS balance`, - [input.wallet, input.amount], + [input.wallet, chain, input.amount], ); const balanceAfter = upd.rows[0].balance as number; await client.query( - `INSERT INTO credit_ledger (wallet, agent_id, kind, amount, reason, request_id, x402_receipt_id, balance_after) - VALUES (lower($1), $2, 'credit', $3, $4, $5, $6, $7)`, + `INSERT INTO credit_ledger (wallet, chain, agent_id, kind, amount, reason, request_id, x402_receipt_id, balance_after) + VALUES (lower($1), $2, $3, 'credit', $4, $5, $6, $7, $8)`, [ input.wallet, + chain, input.agentId ?? null, input.amount, input.reason, @@ -176,6 +183,7 @@ export type AccountSummary = { totalEarned: number; totalSpent: number; totalDeposited: number; + byChain: Array<{ chain: string; balance: number; totalEarned: number; totalSpent: number; totalDeposited: number }>; earningsByAgent: Array<{ agentId: string | null; amount: number; count: number }>; spendByAgent: Array<{ agentId: string | null; amount: number; count: number }>; recent: Array<{ @@ -195,12 +203,18 @@ export async function getAccountSummary( limit = 25, ): Promise { const w = wallet.toLowerCase(); - const [acct, earned, spent, recent] = await Promise.all([ + const [acct, byChain, earned, spent, recent] = await Promise.all([ client.query( - `SELECT balance::float8 b, currency, total_earned::float8 te, total_spent::float8 ts, total_deposited::float8 td + `SELECT coalesce(sum(balance),0)::float8 b, max(currency) currency, coalesce(sum(total_earned),0)::float8 te, + coalesce(sum(total_spent),0)::float8 ts, coalesce(sum(total_deposited),0)::float8 td FROM agent_accounts WHERE lower(wallet) = $1`, [w], ), + client.query( + `SELECT chain, balance::float8 b, total_earned::float8 te, total_spent::float8 ts, total_deposited::float8 td + FROM agent_accounts WHERE lower(wallet) = $1 ORDER BY chain`, + [w], + ), client.query( `SELECT provider_agent_id AS agent_id, coalesce(sum(amount),0)::float8 AS amount, count(*)::int AS count FROM knowledge_attributions WHERE lower(provider_wallet) = $1 GROUP BY 1 ORDER BY amount DESC`, @@ -225,6 +239,7 @@ export async function getAccountSummary( totalEarned: a?.te ?? 0, totalSpent: a?.ts ?? 0, totalDeposited: a?.td ?? 0, + byChain: byChain.rows.map((r) => ({ chain: r.chain, balance: r.b, totalEarned: r.te, totalSpent: r.ts, totalDeposited: r.td })), earningsByAgent: earned.rows.map((r) => ({ agentId: r.agent_id ?? null, amount: r.amount, count: r.count })), spendByAgent: spent.rows.map((r) => ({ agentId: r.agent_id ?? null, amount: r.amount, count: r.count })), recent: recent.rows.map((r) => ({ diff --git a/App/lib/db.ts b/App/lib/db.ts index 879f959..a36fc09 100644 --- a/App/lib/db.ts +++ b/App/lib/db.ts @@ -281,7 +281,8 @@ export async function ensureSchema(client: Client) { // Prepaid credit balance per OWNER wallet — the money lives at the wallet. await client.query(` CREATE TABLE IF NOT EXISTS agent_accounts ( - wallet text PRIMARY KEY, + wallet text NOT NULL, + chain text NOT NULL DEFAULT 'base', balance numeric NOT NULL DEFAULT 0, currency text NOT NULL DEFAULT 'USDC', total_earned numeric NOT NULL DEFAULT 0, @@ -291,6 +292,12 @@ export async function ensureSchema(client: Client) { created_at timestamptz NOT NULL DEFAULT now() ) `); + // Per-chain balances: balance/earnings live per (wallet, chain) so the chain a + // consumer pays on is the chain the provider earns on. Migrate the legacy + // single-wallet PK → a (wallet, chain) unique key (existing rows default 'base'). + await client.query(`ALTER TABLE agent_accounts ADD COLUMN IF NOT EXISTS chain text NOT NULL DEFAULT 'base'`); + await client.query(`ALTER TABLE agent_accounts DROP CONSTRAINT IF EXISTS agent_accounts_pkey`); + await client.query(`CREATE UNIQUE INDEX IF NOT EXISTS agent_accounts_wallet_chain_uidx ON agent_accounts (wallet, chain)`); // Per-agent billing config = the whitelist. exempt agents query for free; // role marks providers (earn) vs consumers. PerkOS internal / research // agents are exempt:true, role 'provider' or 'both'. @@ -311,6 +318,7 @@ export async function ensureSchema(client: Client) { CREATE TABLE IF NOT EXISTS credit_ledger ( id bigserial PRIMARY KEY, wallet text NOT NULL, + chain text NOT NULL DEFAULT 'base', agent_id text, kind text NOT NULL, amount numeric NOT NULL, @@ -321,10 +329,25 @@ export async function ensureSchema(client: Client) { created_at timestamptz NOT NULL DEFAULT now() ) `); + await client.query(`ALTER TABLE credit_ledger ADD COLUMN IF NOT EXISTS chain text NOT NULL DEFAULT 'base'`); await client.query(`CREATE INDEX IF NOT EXISTS credit_ledger_wallet_idx ON credit_ledger (lower(wallet), created_at DESC)`); await client.query(`CREATE INDEX IF NOT EXISTS credit_ledger_agent_idx ON credit_ledger (agent_id, created_at DESC)`); await client.query(`CREATE INDEX IF NOT EXISTS agent_billing_wallet_idx ON agent_billing (lower(wallet))`); + // System error log — captured server-side failures (deposit/settle, query, + // claim, …) surfaced in the admin so ops can see what's breaking without SSH. + await client.query(` + CREATE TABLE IF NOT EXISTS system_errors ( + id bigserial PRIMARY KEY, + created_at timestamptz NOT NULL DEFAULT now(), + scope text NOT NULL, + severity text NOT NULL DEFAULT 'error', + message text NOT NULL, + context jsonb + ) + `); + await client.query(`CREATE INDEX IF NOT EXISTS system_errors_created_idx ON system_errors (created_at DESC)`); + // Provider payouts (F4 settlement) — on-chain USDC transfers treasury->provider. await client.query(` CREATE TABLE IF NOT EXISTS settlements ( @@ -361,12 +384,14 @@ export async function ensureSchema(client: Client) { fee_platform_bps integer, fee_reward_bps integer, reward_researcher_bps integer, + reward_platform_bps integer, buyback_enabled boolean, buyback_threshold numeric, updated_by text, updated_at timestamptz NOT NULL DEFAULT now() ) `); + await client.query(`ALTER TABLE tokenomics_config ADD COLUMN IF NOT EXISTS reward_platform_bps integer`); // Recognized platform fee per paid query (the 20% take) — PerkOS revenue. await client.query(` @@ -389,6 +414,7 @@ export async function ensureSchema(client: Client) { CREATE TABLE IF NOT EXISTS reward_pool ( id bigserial PRIMARY KEY, request_id text, + chain text NOT NULL DEFAULT 'base', amount numeric NOT NULL, currency text NOT NULL DEFAULT 'USDC', requester_wallet text, @@ -399,7 +425,10 @@ export async function ensureSchema(client: Client) { created_at timestamptz NOT NULL DEFAULT now() ) `); - await client.query(`CREATE INDEX IF NOT EXISTS reward_pool_status_idx ON reward_pool (status, created_at)`); + // Per-chain: the reward buys that chain's $PERKOS (Base reward USDC → Base + // $PERKOS, Celo → Celo). Existing rows default to 'base'. + await client.query(`ALTER TABLE reward_pool ADD COLUMN IF NOT EXISTS chain text NOT NULL DEFAULT 'base'`); + await client.query(`CREATE INDEX IF NOT EXISTS reward_pool_status_idx ON reward_pool (status, chain, created_at)`); // Claim distributions (pull model) — each row is a Merkle root the platform // posts to PerkosClaimVault. tree_dump holds the StandardMerkleTree so any @@ -429,11 +458,18 @@ export async function ensureSchema(client: Client) { // the claim roll-up reads it. Empty until the buyback is wired. await client.query(` CREATE TABLE IF NOT EXISTS token_rewards ( - wallet text PRIMARY KEY, + wallet text NOT NULL, + chain text NOT NULL DEFAULT 'base', cumulative_perkos numeric NOT NULL DEFAULT 0, updated_at timestamptz NOT NULL DEFAULT now() ) `); + // Per-chain: a wallet's $PERKOS drop is segregated by the chain it was bought + // on (Base $PERKOS ≠ Celo $PERKOS), claimed from that chain's vault. Migrate + // the old wallet-PK shape to a (wallet, chain) unique index. + await client.query(`ALTER TABLE token_rewards ADD COLUMN IF NOT EXISTS chain text NOT NULL DEFAULT 'base'`); + await client.query(`ALTER TABLE token_rewards DROP CONSTRAINT IF EXISTS token_rewards_pkey`); + await client.query(`CREATE UNIQUE INDEX IF NOT EXISTS token_rewards_wallet_chain_uidx ON token_rewards (wallet, chain)`); await client.query(`CREATE INDEX IF NOT EXISTS research_items_agents_idx ON research_items USING gin (agents)`); diff --git a/App/lib/errlog.ts b/App/lib/errlog.ts new file mode 100644 index 0000000..e02338b --- /dev/null +++ b/App/lib/errlog.ts @@ -0,0 +1,63 @@ +/** + * System error log — a lightweight, best-effort capture of server-side failures + * (deposit/settle, query billing, claim build, …) so ops can see what's breaking + * from the admin instead of tailing container logs over SSH. + * + * `logError` NEVER throws — a logging failure must not mask or escalate the + * original error. Context is JSON-stringified and size-capped; redact secrets + * before passing them in (this table is readable from the admin). + */ +import type { Client } from "pg"; + +export type ErrorSeverity = "error" | "warn" | "info"; + +export async function logError( + client: Client, + input: { scope: string; message: string; context?: unknown; severity?: ErrorSeverity }, +): Promise { + try { + const ctx = input.context === undefined ? null : JSON.stringify(input.context).slice(0, 12000); + await client.query( + `INSERT INTO system_errors (scope, severity, message, context) VALUES ($1, $2, $3, $4::jsonb)`, + [input.scope.slice(0, 120), input.severity ?? "error", String(input.message).slice(0, 2000), ctx], + ); + } catch { + /* logging is best-effort — never throw */ + } +} + +export type SystemError = { + id: string; + createdAt: string | null; + scope: string; + severity: string; + message: string; + context: unknown; +}; + +export async function recentErrors( + client: Client, + opts: { limit?: number; scope?: string } = {}, +): Promise { + const limit = Math.max(1, Math.min(opts.limit ?? 100, 500)); + const params: unknown[] = []; + let where = ""; + if (opts.scope) { + params.push(opts.scope); + where = `WHERE scope = $${params.length}`; + } + params.push(limit); + const r = await client.query( + `SELECT id, created_at, scope, severity, message, context + FROM system_errors ${where} ORDER BY created_at DESC LIMIT $${params.length}`, + params, + ); + return r.rows.map((row) => ({ + id: String(row.id), + createdAt: row.created_at ?? null, + scope: row.scope, + severity: row.severity, + message: row.message, + context: row.context ?? null, + })); +} diff --git a/App/lib/payments.ts b/App/lib/payments.ts index 996c738..434305f 100644 --- a/App/lib/payments.ts +++ b/App/lib/payments.ts @@ -8,14 +8,27 @@ * Networks: Base + Celo mainnet, both USDC (6-dec). The asset/payTo go in the * x402 paymentRequirements; the payer signs a gasless authorization that Stack * settles. + * + * Facilitator endpoints are the x402-standard `/verify` + `/settle` (NOT + * `/api/v2/x402/*` — that route's request schema validates x402Version as a + * string while its version check compares it as a number, so it rejects every + * payload with "expected string, received number"; `/verify` + `/settle` accept + * the standard `{ x402Version: 1, paymentPayload, paymentRequirements }`). */ import { parseUnits } from "viem"; export type PayNetwork = "base" | "celo"; -export const NETWORKS: Record = { - base: { chainId: 8453, usdc: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", decimals: 6, x402: "base" }, - celo: { chainId: 42220, usdc: "0xcebA9300f2b948710d2653dD7B07f33A8B32118C", decimals: 6, x402: "celo" }, +// `name`/`version` are the on-chain EIP-712 domain of each USDC (verified live): +// Base USDC = "USD Coin"/"2", Celo USDC = "USDC"/"2". They MUST match the token +// contract exactly — the payer signs the EIP-3009 authorization against this +// domain and the facilitator verifies it, so a wrong name = invalid signature. +export const NETWORKS: Record< + PayNetwork, + { chainId: number; usdc: string; decimals: number; x402: string; name: string; version: string } +> = { + base: { chainId: 8453, usdc: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", decimals: 6, x402: "base", name: "USD Coin", version: "2" }, + celo: { chainId: 42220, usdc: "0xcebA9300f2b948710d2653dD7B07f33A8B32118C", decimals: 6, x402: "celo", name: "USDC", version: "2" }, }; export function isPayNetwork(n: unknown): n is PayNetwork { @@ -70,7 +83,7 @@ export function buildPaymentRequirements(net: PayNetwork, amount: number, resour payTo: treasuryPayTo(), maxTimeoutSeconds: 120, asset: n.usdc, - extra: { name: "USD Coin", version: "2" }, + extra: { name: n.name, version: n.version }, }; } @@ -100,6 +113,18 @@ async function callFacilitator(path: string, payload: unknown, timeoutMs = 12000 }); const data = (await res.json().catch(() => ({}))) as Record; return { httpOk: res.ok, status: res.status, data }; + } catch (e) { + // Network error or timeout (AbortError). Return a HANDLED failure instead of + // throwing — a throw here surfaces as an unhelpful HTTP 500 to the depositor. + // Settle does an on-chain transfer (Celo has slower finality than Base), so + // a timeout here usually means "still settling", not "failed". + const reason = + e instanceof Error && e.name === "AbortError" + ? `facilitator timeout after ${timeoutMs}ms` + : e instanceof Error + ? e.message + : "facilitator error"; + return { httpOk: false, status: 0, data: { errorReason: reason } as Record }; } finally { clearTimeout(t); } @@ -107,7 +132,7 @@ async function callFacilitator(path: string, payload: unknown, timeoutMs = 12000 /** Verify (no settlement) a payment via Stack. */ export async function verifyViaStack(paymentPayload: unknown, paymentRequirements: unknown) { - const { httpOk, status, data } = await callFacilitator("/api/v2/x402/verify", { + const { httpOk, status, data } = await callFacilitator("/verify", { x402Version: 1, paymentPayload, paymentRequirements, @@ -119,18 +144,27 @@ export async function verifyViaStack(paymentPayload: unknown, paymentRequirement }; } -/** Verify + settle a payment on-chain via Stack. */ +/** Verify + settle a payment on-chain via Stack. `status`/`raw` are returned for + * error logging (the full facilitator response when a settle is rejected). */ export async function settleViaStack(paymentPayload: unknown, paymentRequirements: unknown) { - const { httpOk, status, data } = await callFacilitator("/api/v2/x402/settle", { + // 60s: settle broadcasts + waits for an on-chain USDC transfer; Celo finality + // is slower than Base, so the 12s default aborted Celo deposits (HTTP 500). + const { httpOk, status, data } = await callFacilitator("/settle", { x402Version: 1, paymentPayload, paymentRequirements, - }); + }, 60000); return { ok: httpOk && data.success === true, transaction: (data.transaction as string) ?? null, payer: (data.payer as string) ?? null, network: (data.network as string) ?? null, - error: (data.errorReason as string) ?? (httpOk ? null : `HTTP ${status}`), + error: + (data.errorReason as string) ?? + (data.error as string) ?? + (data.message as string) ?? + (httpOk ? null : `HTTP ${status}`), + status, + raw: data, }; } diff --git a/App/lib/rewardsDrop.ts b/App/lib/rewardsDrop.ts new file mode 100644 index 0000000..5b8ca97 --- /dev/null +++ b/App/lib/rewardsDrop.ts @@ -0,0 +1,191 @@ +/** + * Monthly $PERKOS usage drop — the CALCULATION (read-only, no on-chain, no writes). + * + * Budget = the 5% reward accrued that month, per chain (reward_pool, pending). + * Split = rewardPlatformBps to the platform (default 40%), the rest to users. + * User cut = split across every wallet by its TOTAL usage that month — + * activity(wallet) = USDC spent on paid queries + USDC earned from + * attributions (both sides of the market). Each wallet's share of the + * user budget is activity / Σ activity. + * + * This is the dry-run the admin inspects before any buyback runs: it shows what a + * drop WOULD pay. The actual $PERKOS each wallet gets is decided when the buyback + * swaps `userUsdc` → $PERKOS (price-dependent); here we report the USDC weighting. + */ +import type { Client } from "pg"; + +import { loadTokenomics } from "./tokenomics"; + +export type DropWallet = { + wallet: string; + spent: number; + earned: number; + activity: number; + /** 0..1 — this wallet's fraction of the user drop pool. */ + sharePct: number; + /** User-budget USDC weighting that converts to $PERKOS for this wallet. */ + usdcShare: number; +}; + +export type MonthlyDrop = { + month: string; // YYYY-MM (UTC) + chain: string; + budgetUsdc: number; + platformBps: number; + platformUsdc: number; + userUsdc: number; + totalActivity: number; + walletCount: number; + wallets: DropWallet[]; +}; + +/** [start, nextMonthStart) in UTC for a "YYYY-MM" string. */ +function monthRange(month: string): { start: string; end: string } { + const m = /^(\d{4})-(\d{2})$/.exec(month); + if (!m) throw new Error("month must be YYYY-MM"); + const y = Number(m[1]); + const mo = Number(m[2]); + if (mo < 1 || mo > 12) throw new Error("month must be 01..12"); + return { + start: new Date(Date.UTC(y, mo - 1, 1)).toISOString(), + end: new Date(Date.UTC(y, mo, 1)).toISOString(), + }; +} + +export async function computeMonthlyDrop( + client: Client, + opts: { month: string; chain: string }, +): Promise { + const { start, end } = monthRange(opts.month); + const chain = opts.chain === "celo" ? "celo" : "base"; + const cfg = await loadTokenomics(client); + + // Budget: the 5% reward accrued this month on this chain, still pending payout. + const b = await client.query( + `SELECT coalesce(sum(amount),0)::float8 b FROM reward_pool + WHERE chain = $1 AND status = 'pending' AND created_at >= $2 AND created_at < $3`, + [chain, start, end], + ); + const budgetUsdc = b.rows[0]?.b ?? 0; + + // Activity per wallet = spent (debits) + earned (attributions), this month/chain. + const [spent, earned] = await Promise.all([ + client.query( + `SELECT lower(wallet) w, coalesce(sum(amount),0)::float8 v FROM credit_ledger + WHERE kind = 'debit' AND chain = $1 AND created_at >= $2 AND created_at < $3 + GROUP BY 1`, + [chain, start, end], + ), + client.query( + `SELECT lower(provider_wallet) w, coalesce(sum(amount),0)::float8 v FROM knowledge_attributions + WHERE lower(chain) = $1 AND amount > 0 AND created_at >= $2 AND created_at < $3 + GROUP BY 1`, + [chain, start, end], + ), + ]); + + const map = new Map(); + for (const r of spent.rows) map.set(r.w, { spent: r.v, earned: 0 }); + for (const r of earned.rows) { + const cur = map.get(r.w) ?? { spent: 0, earned: 0 }; + cur.earned += r.v; + map.set(r.w, cur); + } + + const platformBps = cfg.rewardPlatformBps; + const platformUsdc = (budgetUsdc * platformBps) / 10000; + const userUsdc = budgetUsdc - platformUsdc; + + const rows = [...map.entries()] + .map(([wallet, x]) => ({ wallet, spent: x.spent, earned: x.earned, activity: x.spent + x.earned })) + .filter((r) => r.activity > 0); + const totalActivity = rows.reduce((a, r) => a + r.activity, 0); + + const wallets: DropWallet[] = rows + .map((r) => { + const sharePct = totalActivity > 0 ? r.activity / totalActivity : 0; + return { ...r, sharePct, usdcShare: userUsdc * sharePct }; + }) + .sort((a, b) => b.activity - a.activity); + + return { + month: opts.month, + chain, + budgetUsdc, + platformBps, + platformUsdc, + userUsdc, + totalActivity, + walletCount: wallets.length, + wallets, + }; +} + +export type DropDistribution = { + month: string; + chain: string; + perkosBought: string; + platformPerkos: string; + userPerkos: string; + allocated: string; + walletCount: number; + rewardRowsMarked: number; +}; + +/** + * Distribute a completed buyback's $PERKOS for a month/chain — call AFTER the + * swap lands `perkosBoughtBaseUnits` $PERKOS (18-dec) in the treasury. Keeps the + * platform cut, credits each wallet's usage-weighted share into token_rewards + * (which `rollupEntries` turns into a claimable `cumReward`), and marks that + * month's pending reward_pool rows distributed. Integer math; the rounding + * remainder stays with the platform (never over-allocate vs what was bought). + * + * Idempotent guard is the caller's job (mark/record the buyback tx first) — this + * function is the accounting half only; it does no on-chain work. + */ +export async function distributeDrop( + client: Client, + opts: { month: string; chain: string; perkosBoughtBaseUnits: bigint }, +): Promise { + const { start, end } = monthRange(opts.month); + const chain = opts.chain === "celo" ? "celo" : "base"; + const drop = await computeMonthlyDrop(client, { month: opts.month, chain }); + + const platformBps = BigInt(drop.platformBps); + const platformPerkos = (opts.perkosBoughtBaseUnits * platformBps) / 10000n; + const userPerkos = opts.perkosBoughtBaseUnits - platformPerkos; + + // Integer pro-rata by activity. Scale floats to 1e6 fixed-point for the ratio. + const totalScaled = BigInt(Math.round(drop.totalActivity * 1e6)); + let allocated = 0n; + for (const w of drop.wallets) { + if (totalScaled <= 0n) break; + const share = (userPerkos * BigInt(Math.round(w.activity * 1e6))) / totalScaled; + if (share <= 0n) continue; + allocated += share; + await client.query( + `INSERT INTO token_rewards (wallet, chain, cumulative_perkos) + VALUES (lower($1), $2, $3) + ON CONFLICT (wallet, chain) + DO UPDATE SET cumulative_perkos = token_rewards.cumulative_perkos + EXCLUDED.cumulative_perkos, updated_at = now()`, + [w.wallet, chain, share.toString()], + ); + } + + const marked = await client.query( + `UPDATE reward_pool SET status = 'distributed', epoch = $1 + WHERE chain = $2 AND status = 'pending' AND created_at >= $3 AND created_at < $4`, + [opts.month, chain, start, end], + ); + + return { + month: opts.month, + chain, + perkosBought: opts.perkosBoughtBaseUnits.toString(), + platformPerkos: platformPerkos.toString(), + userPerkos: userPerkos.toString(), + allocated: allocated.toString(), + walletCount: drop.wallets.length, + rewardRowsMarked: marked.rowCount ?? 0, + }; +} diff --git a/App/lib/tokenomics.ts b/App/lib/tokenomics.ts index 87ee3cf..9f45c55 100644 --- a/App/lib/tokenomics.ts +++ b/App/lib/tokenomics.ts @@ -30,6 +30,9 @@ export type TokenomicsConfig = { feeRewardBps: number; /** Researcher's share of the reward pool (bps); requester gets the rest. */ rewardResearcherBps: number; + /** Platform's share of the BOUGHT $PERKOS (bps); the rest drops to users. + * Applied at buyback/distribution time, not at accrual. Default 4000 = 40%. */ + rewardPlatformBps: number; buybackEnabled: boolean; /** Min accrued reward pool (USDC) before a buyback epoch fires. */ buybackThreshold: number; @@ -55,6 +58,7 @@ export function defaultTokenomics(): TokenomicsConfig { feePlatformBps: 2000, feeRewardBps: 500, rewardResearcherBps: 6000, + rewardPlatformBps: 4000, buybackEnabled: false, buybackThreshold: 100, updatedBy: null, @@ -68,7 +72,7 @@ export async function loadTokenomics(client: Client): Promise const r = await client.query( `SELECT mode, price_public, price_private, price_premium, price_enterprise, fee_provider_bps, fee_platform_bps, fee_reward_bps, reward_researcher_bps, - buyback_enabled, buyback_threshold, updated_by, updated_at + reward_platform_bps, buyback_enabled, buyback_threshold, updated_by, updated_at FROM tokenomics_config WHERE id = 'default'`, ); const row = r.rows[0]; @@ -87,6 +91,7 @@ export async function loadTokenomics(client: Client): Promise feePlatformBps: num(row.fee_platform_bps, d.feePlatformBps), feeRewardBps: num(row.fee_reward_bps, d.feeRewardBps), rewardResearcherBps: num(row.reward_researcher_bps, d.rewardResearcherBps), + rewardPlatformBps: num(row.reward_platform_bps, d.rewardPlatformBps), buybackEnabled: row.buyback_enabled ?? d.buybackEnabled, buybackThreshold: num(row.buyback_threshold, d.buybackThreshold), updatedBy: row.updated_by ?? null, @@ -101,6 +106,7 @@ export type TokenomicsPatch = Partial<{ feePlatformBps: number; feeRewardBps: number; rewardResearcherBps: number; + rewardPlatformBps: number; buybackEnabled: boolean; buybackThreshold: number; }>; @@ -132,6 +138,7 @@ export async function saveTokenomics( feePlatformBps: patch.feePlatformBps ?? cur.feePlatformBps, feeRewardBps: patch.feeRewardBps ?? cur.feeRewardBps, rewardResearcherBps: patch.rewardResearcherBps ?? cur.rewardResearcherBps, + rewardPlatformBps: patch.rewardPlatformBps ?? cur.rewardPlatformBps, buybackEnabled: patch.buybackEnabled ?? cur.buybackEnabled, buybackThreshold: patch.buybackThreshold ?? cur.buybackThreshold, }; @@ -139,12 +146,12 @@ export async function saveTokenomics( `INSERT INTO tokenomics_config (id, mode, price_public, price_private, price_premium, price_enterprise, fee_provider_bps, fee_platform_bps, fee_reward_bps, reward_researcher_bps, - buyback_enabled, buyback_threshold, updated_by, updated_at) - VALUES ('default',$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12, now()) + reward_platform_bps, buyback_enabled, buyback_threshold, updated_by, updated_at) + VALUES ('default',$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13, now()) ON CONFLICT (id) DO UPDATE SET mode=$1, price_public=$2, price_private=$3, price_premium=$4, price_enterprise=$5, fee_provider_bps=$6, fee_platform_bps=$7, fee_reward_bps=$8, reward_researcher_bps=$9, - buyback_enabled=$10, buyback_threshold=$11, updated_by=$12, updated_at=now()`, + reward_platform_bps=$10, buyback_enabled=$11, buyback_threshold=$12, updated_by=$13, updated_at=now()`, [ merged.mode, merged.prices.public, @@ -155,6 +162,7 @@ export async function saveTokenomics( merged.feePlatformBps, merged.feeRewardBps, merged.rewardResearcherBps, + merged.rewardPlatformBps, merged.buybackEnabled, merged.buybackThreshold, updatedBy, @@ -213,15 +221,18 @@ export async function accrueReward( requesterWallet: string | null; researcherWallets: string[]; researcherBps: number; + /** Chain the query paid on — the reward buys that chain's $PERKOS. */ + chain?: string; }, ): Promise { if (!(input.amount > 0)) return; await client.query( `INSERT INTO reward_pool - (request_id, amount, currency, requester_wallet, researcher_wallets, researcher_bps, status) - VALUES ($1,$2,$3,$4,$5::jsonb,$6,'pending')`, + (request_id, chain, amount, currency, requester_wallet, researcher_wallets, researcher_bps, status) + VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,'pending')`, [ input.requestId, + input.chain ?? "base", input.amount, input.currency, input.requesterWallet, diff --git a/App/lib/uniswapTrade.ts b/App/lib/uniswapTrade.ts new file mode 100644 index 0000000..400abde --- /dev/null +++ b/App/lib/uniswapTrade.ts @@ -0,0 +1,108 @@ +/** + * Uniswap Trading API client — the buyback swap rail. + * + * The hosted API (`https://trade-api.gateway.uniswap.org/v1`) quotes + builds + * classic AMM swaps across v2/v3/v4 on both Base and Celo, so one flow covers + * Base-v3 + Celo-v4 without us touching the v4 Universal Router / PoolManager. + * + * Flow per chain: check_approval (Permit2 for USDC) → quote → swap (ready tx) → + * the treasury signs + broadcasts (gasful). Here we expose `quoteBuyback` (the + * read-only dry-run leg) + the approval/swap builders for execution. The + * treasury private key + on-chain send live in the execute path (Phase C exec), + * never in this module. + */ +import { NETWORKS, type PayNetwork, usdcBaseUnits } from "./payments"; + +const TRADE_API = (process.env.UNISWAP_TRADE_API_URL || "https://trade-api.gateway.uniswap.org/v1").replace(/\/+$/, ""); + +/** $PERKOS token per chain (the buyback's tokenOut). */ +export const PERKOS_TOKEN: Record = { + base: "0xF714E60f85497D70508F7E356b5DB80e64539BA3", + celo: "0xb7Ba43fBD4F2E85FCE929f7d4DFE3905Ae846A46", +}; + +export function uniswapApiKey(): string { + return (process.env.UNISWAP_API_KEY || "").trim(); +} + +async function callTradeApi(path: string, body: unknown, timeoutMs = 15000) { + const key = uniswapApiKey(); + if (!key) return { httpOk: false, status: 0, data: { errorCode: "uniswap_api_key_missing" } as Record }; + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(`${TRADE_API}${path}`, { + method: "POST", + headers: { "content-type": "application/json", "x-api-key": key }, + signal: controller.signal, + body: JSON.stringify(body), + }); + const data = (await res.json().catch(() => ({}))) as Record; + return { httpOk: res.ok, status: res.status, data }; + } catch (e) { + const reason = e instanceof Error && e.name === "AbortError" ? `timeout after ${timeoutMs}ms` : e instanceof Error ? e.message : "trade_api_error"; + return { httpOk: false, status: 0, data: { errorCode: reason } as Record }; + } finally { + clearTimeout(t); + } +} + +export type BuybackQuote = { + ok: boolean; + chain: PayNetwork; + amountInUsdc: number; + /** $PERKOS out, base units (18-dec) as a string, and a human float. */ + amountOutPerkos: string | null; + amountOutPerkosFloat: number | null; + /** Echoed for the swap step. */ + quote: unknown; + error?: string; +}; + +/** + * DRY-RUN: quote `amountUsdc` USDC → $PERKOS on `chain`, CLASSIC routing only + * (no gasless UniswapX). Read-only — no approval, no trade. `swapper` is the + * treasury that would execute (0x3f0D). + */ +export async function quoteBuyback(opts: { + chain: PayNetwork; + amountUsdc: number; + swapper: string; +}): Promise { + const n = NETWORKS[opts.chain]; + const base = { ok: false as const, chain: opts.chain, amountInUsdc: opts.amountUsdc, amountOutPerkos: null, amountOutPerkosFloat: null, quote: null }; + if (!(opts.amountUsdc > 0)) return { ...base, error: "amount_must_be_positive" }; + + const { httpOk, data } = await callTradeApi("/quote", { + type: "EXACT_INPUT", + amount: usdcBaseUnits(opts.amountUsdc, opts.chain), + tokenInChainId: n.chainId, + tokenOutChainId: n.chainId, + tokenIn: n.usdc, + tokenOut: PERKOS_TOKEN[opts.chain], + swapper: opts.swapper, + routing: "CLASSIC", + }); + + if (!httpOk) { + const err = (data.errorCode as string) || (data.detail as string) || (data.message as string) || "quote_failed"; + return { ...base, error: err }; + } + // The CLASSIC quote nests the output under `quote.output.amount` (18-dec base units). + const q = (data.quote ?? data) as Record; + const output = (q.output ?? {}) as Record; + const outRaw = + (output.amount as string) ?? + (q.amountOut as string) ?? + (q.quote as string) ?? + null; + const outFloat = outRaw != null ? Number(outRaw) / 1e18 : null; + return { + ok: true, + chain: opts.chain, + amountInUsdc: opts.amountUsdc, + amountOutPerkos: outRaw, + amountOutPerkosFloat: outFloat, + quote: data, + }; +} diff --git a/App/lib/wagmi.ts b/App/lib/wagmi.ts index 3339ca5..f3092f8 100644 --- a/App/lib/wagmi.ts +++ b/App/lib/wagmi.ts @@ -1,6 +1,7 @@ 'use client'; import { getDefaultConfig } from '@rainbow-me/rainbowkit'; +import { http } from 'wagmi'; import { base, baseSepolia, celo, mainnet } from 'wagmi/chains'; export const wagmiConfig = getDefaultConfig({ @@ -8,5 +9,15 @@ export const wagmiConfig = getDefaultConfig({ projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || 'perkOS-knowledge-preview', // baseSepolia for the claim vault on testnet before mainnet. chains: [base, baseSepolia, celo, mainnet], - ssr: true + // mainnet is only here so RainbowKit can resolve ENS names. Its default public + // RPC (eth.merkle.io) blocks browser requests (no CORS header), which spams the + // console and fails ENS resolution — point it at a CORS-friendly RPC instead. + // Base/Celo/baseSepolia defaults already allow CORS, so keep them. + transports: { + [base.id]: http(), + [baseSepolia.id]: http(), + [celo.id]: http(), + [mainnet.id]: http('https://ethereum-rpc.publicnode.com'), + }, + ssr: true, }); diff --git a/App/package-lock.json b/App/package-lock.json index 572abbb..3d3d2ac 100644 --- a/App/package-lock.json +++ b/App/package-lock.json @@ -26,7 +26,7 @@ "@types/pg": "latest", "@types/react": "latest", "@types/react-dom": "latest", - "@vitest/coverage-v8": "^4.1.7", + "@vitest/coverage-v8": "^3.2.4", "typescript": "latest", "vitest": "^3.2.4" } @@ -37,6 +37,20 @@ "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", "license": "MIT" }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -1306,6 +1320,130 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -2117,6 +2255,17 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@rainbow-me/rainbowkit": { "version": "2.2.11", "resolved": "https://registry.npmjs.org/@rainbow-me/rainbowkit/-/rainbowkit-2.2.11.tgz", @@ -4662,7 +4811,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -4728,29 +4877,32 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", - "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.6.tgz", + "integrity": "sha512-LsAdmUapA0qSN306d8+zOyawM0hFm2m2Hg9IwVNIKBm+qJV8cijiq2c+gxKZcB1HCfIWAy+0qEZDCUQA58A1cw==", "dev": true, "license": "MIT", "dependencies": { + "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.7", - "ast-v8-to-istanbul": "^1.0.0", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.7", - "vitest": "4.1.7" + "@vitest/browser": "3.2.6", + "vitest": "3.2.6" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -4758,69 +4910,56 @@ } } }, - "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "node_modules/@vitest/coverage-v8/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "ms": "^2.1.3" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/@vitest/expect/node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "node_modules/@vitest/coverage-v8/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } + "license": "MIT" }, - "node_modules/@vitest/expect/node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "node_modules/@vitest/expect": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/expect/node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.4", + "@vitest/spy": "3.2.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -4841,26 +4980,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", - "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.1.0" + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", + "@vitest/utils": "3.2.6", "pathe": "^2.0.3", "strip-literal": "^3.0.0" }, @@ -4868,52 +5007,14 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/runner/node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner/node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner/node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", + "@vitest/pretty-format": "3.2.6", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -4921,33 +5022,10 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot/node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", "dev": true, "license": "MIT", "dependencies": { @@ -4958,15 +5036,15 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", - "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" + "@vitest/pretty-format": "3.2.6", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -5741,9 +5819,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.2.tgz", - "integrity": "sha512-dKmJxJsGItLmc5CYZKuEjuG6GnBs6PG4gohMhyFOWKaNQoYCuRZJDECaBlHmcG0lv2wc2E0uU8lESmBEumC3DQ==", + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", "dev": true, "license": "MIT", "dependencies": { @@ -5814,6 +5892,16 @@ "axios": "0.x || 1.x" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/base-x": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", @@ -5883,6 +5971,19 @@ "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", "license": "MIT" }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/bs58": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", @@ -6170,13 +6271,6 @@ "node": ">=20" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, "node_modules/cookie-es": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", @@ -6210,6 +6304,21 @@ "node-fetch": "^2.7.0" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/crossws": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", @@ -6488,6 +6597,13 @@ "stream-shift": "^1.0.2" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/eciesjs": { "version": "0.4.18", "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.18.tgz", @@ -6973,6 +7089,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -7077,6 +7210,61 @@ "node": ">= 0.4" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -7369,6 +7557,13 @@ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/isows": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", @@ -7409,6 +7604,21 @@ "node": ">=10" } }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/istanbul-reports": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", @@ -7423,6 +7633,22 @@ "node": ">=8" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jose": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", @@ -7558,15 +7784,15 @@ } }, "node_modules/magicast": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", - "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.3", - "@babel/types": "^7.29.0", - "source-map-js": "^1.2.1" + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" } }, "node_modules/make-dir": { @@ -7641,6 +7867,32 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mipd": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/mipd/-/mipd-0.0.7.tgz", @@ -7855,17 +8107,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, "node_modules/ofetch": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", @@ -7973,6 +8214,13 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7982,6 +8230,33 @@ "node": ">=8" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -8800,6 +9075,29 @@ "@img/sharp-win32-x64": "0.34.5" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -8807,6 +9105,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/socket.io-client": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", @@ -8925,9 +9236,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true, "license": "MIT" }, @@ -8969,6 +9280,22 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -8981,6 +9308,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-literal": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", @@ -9055,6 +9396,21 @@ "bintrees": "1.0.2" } }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/thread-stream": { "version": "0.15.2", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-0.15.2.tgz", @@ -9137,9 +9493,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", "dev": true, "license": "MIT", "engines": { @@ -9200,7 +9556,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -9736,20 +10092,20 @@ } }, "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", @@ -9779,8 +10135,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", "happy-dom": "*", "jsdom": "*" }, @@ -9808,34 +10164,6 @@ } } }, - "node_modules/vitest/node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest/node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vitest/node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -9874,23 +10202,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vitest/node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/vitest/node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/wagmi": { "version": "2.19.5", "resolved": "https://registry.npmjs.org/wagmi/-/wagmi-2.19.5.tgz", @@ -9938,6 +10249,22 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", @@ -9996,6 +10323,25 @@ "node": ">=8" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/App/package.json b/App/package.json index 8def016..1177399 100644 --- a/App/package.json +++ b/App/package.json @@ -29,7 +29,7 @@ "@types/pg": "latest", "@types/react": "latest", "@types/react-dom": "latest", - "@vitest/coverage-v8": "^4.1.7", + "@vitest/coverage-v8": "^3.2.4", "typescript": "latest", "vitest": "^3.2.4" } diff --git a/App/public/llms-full.txt b/App/public/llms-full.txt index 501c75e..43f762b 100644 --- a/App/public/llms-full.txt +++ b/App/public/llms-full.txt @@ -28,7 +28,9 @@ Integration plugin: https://github.com/PerkOS-xyz/PerkOS-Knowledge-Plugin - `GET /skill/manifest` — live skill manifest: capabilities, auth headers, visibility model, endpoints. - `POST /skill/query` — agent skill query; returns context/sources for the caller's own LLM. Runs in x402 `credit` mode: a paid tier debits the consumer's prepaid balance and credits the answering providers. Public/private/premium tiers. - `GET /api/x402/policy` — public x402 policy metadata (live per-tier prices and payment mode). -- `GET /api/credits/:wallet` — balance, total earned/spent, per-agent earnings & spend, recent credit ledger (authorized wallets). +- `GET /api/credits/:wallet` — balance (with a per-chain `byChain` breakdown), total earned/spent, per-agent earnings & spend, recent credit ledger (authorized wallets). +- `POST /api/deposit` — on-chain USDC top-up via x402 (Base or Celo); returns a 402 challenge, settles through PerkOS Stack, credits the verified payer on the pay-chain. Optional `creditTo` to fund another wallet. +- `GET /api/claims/:wallet` — per-chain claim entry + Merkle proof for pulling provider earnings + $PERKOS reward from the PerkosClaimVault. - `GET /healthz` — service health check. - `GET /api/health` — JSON API health check. - `GET /api/stats` — operational database stats. @@ -55,13 +57,13 @@ Integration plugin: https://github.com/PerkOS-xyz/PerkOS-Knowledge-Plugin PerkOS Knowledge runs a live two-sided credit market in x402 `credit` mode: -- **Prepaid credit balance per wallet.** A consumer's wallet holds a credit balance. Each paid query debits it by the tier price. -- **Providers earn on consumption.** When a query is answered, its value is split equally across the providers whose items were used, recorded as an attribution, and credited to each provider's wallet. Every debit/credit is journaled in a credit ledger. -- **Tiers & prices** (authoritative live values at `/api/x402/policy`): `public` = $0 (free, open), `private` = $0.005/query (organization-scoped), `premium` = $0.01/query. Currency is USDC on Base (token `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913`). +- **Prepaid credit balance per (wallet, chain).** A consumer's wallet holds a credit balance *per chain* (Base, Celo). Each paid query debits the balance on its pay-chain by the tier price. Pick the chain via `POST /skill/query` body `payChain`/`chain` or header `x-payment-chain` (`base`|`celo`, default `base`). +- **Providers earn on the pay-chain.** When a query is answered, the provider share is split equally across the providers whose items were used, recorded as a per-chain attribution, and credited to each provider's balance **on the chain the consumer paid on** (payment-chain = earning-chain). The charged amount splits provider 75% / platform 20% (PerkOS revenue) / $PERKOS reward 5% (live split at `/api/x402/policy`). Every move is journaled in a credit ledger. +- **Tiers & prices** (authoritative live values at `/api/x402/policy` — don't hardcode): `public` = $0 (free, open), `private` (organization-scoped), `premium`, `enterprise` (validated-only, highest tier). Currency is USDC on **Base** (`0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913`) or **Celo** (`0xcebA9300f2b948710d2653dD7B07f33A8B32118C`). - **Exemptions / whitelist.** PerkOS internal and research agents can be marked exempt (`agent_billing.exempt` or an exempt-wallet list) so they query for free — useful for agents whose job is to research and provide, not pay. - **Insufficient funds.** A paid query against a low balance returns HTTP `402` with `{ "creditError": "insufficient_credit", "balance", "price" }`. A paid tier with no wallet returns `{ "creditError": "wallet_required" }`. `public` ($0) is never blocked. -- **Funding.** Today balances are seeded by an admin grant/top-up (`POST /api/admin/credits/grant`); on-chain self-deposit (pay USDC → auto-credit via an x402 receipt) is planned. -- **Payouts.** Providers' accrued credits are paid out on-chain in USDC from the PerkOS treasury via `POST /api/admin/settle`; without a treasury key configured a settlement is recorded for manual payout (balance kept). +- **Funding (on-chain x402).** `POST /api/deposit` `{ wallet, amount, network: "base"|"celo", creditTo? }` tops up a balance with on-chain USDC. No `X-PAYMENT` header → HTTP `402` returning `accepts` (payment requirements for Base + Celo); the payer signs a gasless EIP-3009 USDC authorization and retries. Settled through **PerkOS Stack** (`stack.perkos.xyz`, the standards x402 facilitator); the verified on-chain payer is credited on the chain they paid (tx-hash + EIP-3009 nonce make it replay-safe). `creditTo` lets a human fund an agent's wallet directly — supports both the shared-owner-wallet and per-agent-wallet models. Admin grants (`POST /api/admin/credits/grant`, optional `chain`) remain for stipends. +- **Payouts (pull / claim model).** Providers **claim** their accrued USDC earnings + $PERKOS reward on-chain from the dashboard. The platform funds a `PerkosClaimVault` (UUPS, same address on Base + Celo) and posts a per-chain cumulative-Merkle root; a provider calls `claim(account, cumUsdc, cumReward, proof)` to pull what they're owed on each chain (re-posting roots + partial claims are safe). `GET /api/claims/{wallet}` returns the entry + proof per chain. (The legacy push path `POST /api/admin/settle` still exists for manual treasury payouts.) ## Agent Identity Model diff --git a/App/public/llms.txt b/App/public/llms.txt index 6b78964..2199eef 100644 --- a/App/public/llms.txt +++ b/App/public/llms.txt @@ -1,6 +1,6 @@ # PerkOS Knowledge -> A two-sided, agent-native knowledge market. Agents **consume** operational knowledge by querying it (paying per query from a prepaid credit balance) and **provide** knowledge by contributing research — earning credits when their contributions answer someone else's paid query. Money is USDC-denominated credits, settled on Base. Consumer agents keep their own LLM/runtime (PerkOS Ollama, OpenAI, Anthropic, local — anything). +> A two-sided, agent-native knowledge market. Agents **consume** operational knowledge by querying it (paying per query from a prepaid credit balance) and **provide** knowledge by contributing research — earning credits when their contributions answer someone else's paid query. Money is USDC-denominated credits, **multi-chain on Base and Celo**: you choose the chain you pay/deposit on, and providers earn on that same chain (payment-chain = earning-chain). Consumer agents keep their own LLM/runtime (PerkOS Ollama, OpenAI, Anthropic, local — anything). Use this file as the concise agent-readable index. For the expanded version read `llms-full.txt`. Prices and policy are authoritative live at `/api/x402/policy` and `/skill/manifest` — read them at runtime, don't hardcode from this page. @@ -23,11 +23,14 @@ Privacy boundary: private organization records are ACL-protected and sensitive o ## Payments & credits (how the market works) -- **Prepaid credit balance per wallet.** Each paid query debits the consumer's balance; the providers whose items answered it are credited the same amount, split equally. Every move is journaled. -- **Tiers** (live values at `/api/x402/policy`): `public` = **$0** · `private` = **$0.005/query** (org-scoped) · `premium` = **$0.01/query**. Token: USDC on Base (`0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913`). +- **Prepaid credit balance per (wallet, chain).** Each paid query debits the consumer's balance on its pay-chain; the providers whose items answered it earn on **that same chain**. The charged amount splits **provider 75% / platform 20% / $PERKOS reward 5%** (live split + prices at `/api/x402/policy`). Every move is journaled. +- **Tiers** (authoritative live at `/api/x402/policy` — don't hardcode): `public` = **$0** · `private` (org-scoped) · `premium` · `enterprise` (validated-only, highest). Token: **USDC on Base** (`0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913`) **or Celo** (`0xcebA9300f2b948710d2653dD7B07f33A8B32118C`). +- **Pick the chain:** `POST /skill/query` reads `payChain` from body `payChain` / `chain` or header `x-payment-chain` (`base`|`celo`, default `base`). You spend that chain's balance and the provider earns there — so deposit on the chain you want to transact on. +- **Top up on-chain (x402):** `POST /api/deposit` `{ "wallet", "amount", "network": "base|celo", "creditTo"? }`. With no `X-PAYMENT` header it returns HTTP `402` with `accepts` (payment requirements for Base + Celo); sign the gasless USDC authorization (EIP-3009) and retry. Settled through **PerkOS Stack** (`stack.perkos.xyz` facilitator); the verified on-chain payer is credited (tx-hash + nonce replay-safe). `creditTo` lets a human fund an agent's wallet. Supports both shared-owner-wallet and per-agent-wallet models. Admin grants still exist for stipends. - **Exemptions:** whitelisted agents (PerkOS internal / research) query for free. -- **Insufficient funds:** `POST /skill/query` returns HTTP `402` `{ "creditError": "insufficient_credit", "balance", "price" }`; a paid tier with no wallet returns `{ "creditError": "wallet_required" }`. -- **Check balance / earnings:** `GET /api/credits/{wallet}` (authorized wallets) or the [dashboard](https://knowledge.perkos.xyz/dashboard). Funding is admin-granted today; on-chain self-deposit is coming. +- **Insufficient funds:** `POST /skill/query` returns HTTP `402` `{ "creditError": "insufficient_credit", "balance", "price" }`; a paid tier with no wallet returns `{ "creditError": "wallet_required" }`. Top up via `/api/deposit` on the chain you're querying. +- **Check balance / earnings:** `GET /api/credits/{wallet}` (authorized wallets; includes a `byChain` breakdown) or the [dashboard](https://knowledge.perkos.xyz/dashboard). +- **Claim earnings (pull):** provider USDC earnings + the $PERKOS reward are claimed on-chain from the [dashboard](https://knowledge.perkos.xyz/dashboard) — the platform posts a per-chain Merkle root to the `PerkosClaimVault` (same address on Base + Celo) and you `claim()` what you're owed per chain. `GET /api/claims/{wallet}` returns your entry + proof per chain. ## Consume knowledge (ask / request) diff --git a/App/scripts/monthly-drop.mjs b/App/scripts/monthly-drop.mjs new file mode 100644 index 0000000..51dcebf --- /dev/null +++ b/App/scripts/monthly-drop.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +/** + * Month-end $PERKOS usage-drop orchestrator — ONE CHAIN per run. + * + * Chains the already-validated legs: read the month's 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. + * + * Run from the App dir (needs viem in App/node_modules): + * node scripts/monthly-drop.mjs --chain=base # DRY-RUN (default) + * node scripts/monthly-drop.mjs --chain=celo --month=2026-06 + * node scripts/monthly-drop.mjs --chain=base --apply # executes real txs + * + * Keys are read locally (never the VPS): admin token + Uniswap key from + * PerkOS-Knowledge/.env, treasury key (0x3f0D) from Contracts/.env. --apply + * sends real on-chain transactions; the treasury signer needs native gas. + */ +import fs from "node:fs"; +import { createWalletClient, createPublicClient, http, parseAbi } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { base, celo } from "viem/chains"; + +const args = Object.fromEntries( + process.argv.slice(2).map((a) => { + const [k, v] = a.replace(/^--/, "").split("="); + return [k, v ?? true]; + }), +); +const CHAIN = args.chain === "celo" ? "celo" : "base"; +const APPLY = args.apply === true || args.apply === "true"; +const BASE_URL = args["base-url"] || "https://knowledge.perkos.xyz"; +const MONTH = + args.month || + (() => { + const d = new Date(); + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`; + })(); + +const ROOT = "/Users/osx/Projects/PerkOS/PerkOS-App/PerkOS-Knowledge"; +const readEnv = (p, k) => + (fs.readFileSync(p, "utf8").match(new RegExp(`^${k}=(.*)$`, "m"))?.[1] || "").trim().replace(/^["']|["']$/g, ""); +const ADMIN = readEnv(`${ROOT}/.env`, "KNOWLEDGE_ADMIN_TOKEN"); +const UNIKEY = readEnv(`${ROOT}/.env`, "UNISWAP_API_KEY"); +let PK = readEnv(`${ROOT}/Contracts/.env`, "DEPLOYER_PRIVATE_KEY"); +if (PK && !PK.startsWith("0x")) PK = "0x" + PK; + +const CFG = { + base: { chainId: 8453, viem: base, rpc: "https://mainnet.base.org", usdc: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", perkos: "0xF714E60f85497D70508F7E356b5DB80e64539BA3" }, + celo: { chainId: 42220, viem: celo, rpc: "https://forno.celo.org", usdc: "0xcebA9300f2b948710d2653dD7B07f33A8B32118C", perkos: "0xb7Ba43fBD4F2E85FCE929f7d4DFE3905Ae846A46" }, +}[CHAIN]; +const VAULT = "0xC609BB99C9CAc2b10cc7796b96d0a2EDf2B6f589"; + +const account = privateKeyToAccount(PK); +const wallet = createWalletClient({ account, chain: CFG.viem, transport: http(CFG.rpc) }); +const pub = createPublicClient({ chain: CFG.viem, transport: http(CFG.rpc) }); +const ERC20 = parseAbi(["function balanceOf(address) view returns (uint256)", "function transfer(address,uint256) returns (bool)"]); +const VAULT_ABI = parseAbi(["function setMerkleRoot(bytes32)"]); +const UH = { "content-type": "application/json", "x-api-key": UNIKEY }; +const AH = { "content-type": "application/json", authorization: `Bearer ${ADMIN}` }; +const fmt = (b) => (Number(b) / 1e18).toLocaleString(undefined, { maximumFractionDigits: 0 }); + +console.log(`\n=== $PERKOS monthly drop — ${MONTH} / ${CHAIN} — ${APPLY ? "APPLY (real txs)" : "DRY-RUN"} ===`); +console.log("treasury:", account.address, "| vault:", VAULT); + +// 1. budget + plan (the dry-run endpoint also quotes the buyback) +let d = await (await fetch(`${BASE_URL}/api/admin/rewards/drop?month=${MONTH}&chain=${CHAIN}`, { headers: AH })).json(); +if (!d.ok) { console.error("drop endpoint error:", d); process.exit(1); } +const budget = d.drop.budgetUsdc; +console.log(`budget: ${budget} USDC | wallets: ${d.drop.walletCount} | platform ${d.drop.platformUsdc} / users ${d.drop.userUsdc} USDC`); +if (d.buyback?.perkosTotal) console.log(`quote: → ${fmt(BigInt(Math.round(d.buyback.perkosTotal)) * 10n ** 18n)} $PERKOS (≈${Math.round(d.buyback.perkosTotal)})`); +if (budget <= 0) { console.log("nothing to drop (budget 0)."); process.exit(0); } +if (!APPLY) { console.log("\nDRY-RUN — re-run with --apply to execute the real swap + distribution."); process.exit(0); } + +// 2. buyback: USDC → $PERKOS +const amount = String(Math.round(budget * 1e6)); +const before = await pub.readContract({ address: CFG.perkos, abi: ERC20, functionName: "balanceOf", args: [account.address] }); +const ap = await (await fetch("https://trade-api.gateway.uniswap.org/v1/check_approval", { method: "POST", headers: UH, body: JSON.stringify({ token: CFG.usdc, amount, walletAddress: account.address, chainId: CFG.chainId }) })).json(); +if (ap.approval) { const h = await wallet.sendTransaction({ to: ap.approval.to, data: ap.approval.data, value: BigInt(ap.approval.value || 0) }); await pub.waitForTransactionReceipt({ hash: h }); console.log("approval:", h); } +const qr = await (await fetch("https://trade-api.gateway.uniswap.org/v1/quote", { method: "POST", headers: UH, body: JSON.stringify({ type: "EXACT_INPUT", amount, tokenInChainId: CFG.chainId, tokenOutChainId: CFG.chainId, tokenIn: CFG.usdc, tokenOut: CFG.perkos, swapper: account.address, routing: "CLASSIC" }) })).json(); +let sig; const pd = qr.permitData; +if (pd) { const pt = Object.keys(pd.types).find((k) => k !== "EIP712Domain"); sig = await wallet.signTypedData({ domain: pd.domain, types: pd.types, primaryType: pt, message: pd.values }); } +const sr = await (await fetch("https://trade-api.gateway.uniswap.org/v1/swap", { method: "POST", headers: UH, body: JSON.stringify(sig ? { quote: qr.quote, permitData: pd, signature: sig } : { quote: qr.quote }) })).json(); +if (!sr.swap?.to) { console.error("no swap tx:", JSON.stringify(sr).slice(0, 300)); process.exit(1); } +const sh = await wallet.sendTransaction({ to: sr.swap.to, data: sr.swap.data, value: BigInt(sr.swap.value || 0) }); +await pub.waitForTransactionReceipt({ hash: sh }); +await new Promise((r) => setTimeout(r, 3000)); +const bought = (await pub.readContract({ address: CFG.perkos, abi: ERC20, functionName: "balanceOf", args: [account.address] })) - before; +console.log(`swap: ${sh} | bought ${fmt(bought)} $PERKOS`); +if (bought <= 0n) { console.error("swap landed no $PERKOS — aborting."); process.exit(1); } + +// 3. distribute (accounting) → token_rewards +d = await (await fetch(`${BASE_URL}/api/admin/rewards/distribute`, { method: "POST", headers: AH, body: JSON.stringify({ month: MONTH, chain: CHAIN, perkosBought: bought.toString(), execute: true }) })).json(); +if (!d.ok) { console.error("distribute error:", d); process.exit(1); } +const userPerkos = BigInt(d.distribution.userPerkos); +console.log(`distributed → users ${fmt(userPerkos)} | platform keeps ${fmt(BigInt(d.distribution.platformPerkos))} $PERKOS`); + +// 4. build the Merkle root (cumUsdc + cumReward) +d = await (await fetch(`${BASE_URL}/api/admin/claims/build`, { method: "POST", headers: AH, body: JSON.stringify({}) })).json(); +const dist = (d.distributions || []).find((x) => x.chain === CHAIN); +if (!dist) { console.error("no root built for", CHAIN, d); process.exit(1); } +console.log(`root: ${dist.root} | entries ${dist.entryCount}`); + +// 5. fund the vault with the user $PERKOS +const fh = await wallet.writeContract({ address: CFG.perkos, abi: ERC20, functionName: "transfer", args: [VAULT, userPerkos] }); +await pub.waitForTransactionReceipt({ hash: fh }); +console.log("funded vault:", fh); + +// 6. post the root (distributor) +const ph = await wallet.writeContract({ address: VAULT, abi: VAULT_ABI, functionName: "setMerkleRoot", args: [dist.root] }); +await pub.waitForTransactionReceipt({ hash: ph }); +console.log("posted root:", ph); + +// 7. mark the distribution posted (dashboard drops the "root pending on-chain" hint) +const mp = await (await fetch(`${BASE_URL}/api/admin/claims/mark-posted`, { method: "POST", headers: AH, body: JSON.stringify({ chain: CHAIN, root: dist.root, txHash: ph }) })).json(); +if (!mp.ok) console.warn("mark-posted warning:", mp.error); + +console.log(`\n✅ drop complete — ${MONTH} / ${CHAIN}. Users can claim their $PERKOS from the dashboard.`); diff --git a/App/tests/payments.test.ts b/App/tests/payments.test.ts index 9e93bbe..784ff9e 100644 --- a/App/tests/payments.test.ts +++ b/App/tests/payments.test.ts @@ -49,7 +49,14 @@ describe("buildPaymentRequirements", () => { expect(r.asset.toLowerCase()).toBe(CELO_USDC); expect(r.maxAmountRequired).toBe("2000000"); expect(r.payTo).toBe("0x3f0D7b9916212fA0A9Ac0EF8f72a25EB56F7046C"); - expect(r.extra).toEqual({ name: "USD Coin", version: "2" }); + // Celo USDC's on-chain EIP-712 domain name is "USDC" (not "USD Coin"). + expect(r.extra).toEqual({ name: "USDC", version: "2" }); + }); + + it("uses each token's real EIP-712 domain name (Base = USD Coin)", () => { + process.env.KNOWLEDGE_X402_PAY_TO = "0x3f0D7b9916212fA0A9Ac0EF8f72a25EB56F7046C"; + const base = buildPaymentRequirements("base", 1, "https://x/api/deposit"); + expect(base.extra).toEqual({ name: "USD Coin", version: "2" }); }); }); diff --git a/docs/PERKOS-REWARDS-BUYBACK-DESIGN.md b/docs/PERKOS-REWARDS-BUYBACK-DESIGN.md new file mode 100644 index 0000000..9f0d436 --- /dev/null +++ b/docs/PERKOS-REWARDS-BUYBACK-DESIGN.md @@ -0,0 +1,99 @@ +# $PERKOS Rewards — Monthly Usage Drop + +**Status:** design confirmed 2026-06-24; implementing in phases. + +## 1. Goal + +Every month, turn the platform's accrued reward into **$PERKOS that drops into users' wallets proportional to how much they used PerkOS** — they just *see they got $PERKOS for using the platform*. A cut of each drop also stays with the **platform** (its own $PERKOS treasury). + +It should feel like a **usage drop / reward**, never a refund. UI says "you earned X $PERKOS for using PerkOS", never "5% of your spend, converted". + +## 2. The model (confirmed) + +- **Budget = the 5% reward accrued that month**, per chain. Each paid query already routes 5% into `reward_pool` (USDC). At month end, `sum(reward_pool pending that month, per chain)` is the budget. Scales purely with usage; no extra funding decision. +- **Buyback once per month**, per chain: the budget USDC market-buys $PERKOS on that chain's Uniswap pool (Base **v3** 0.3%, Celo **v4** 0.3%). One monthly buy = less gas + less slippage than a continuous drip. +- **Split of the bought $PERKOS:** `rewardPlatformBps` (default **4000 = 40%**) stays with the platform; the rest (**60%**) drops to users. +- **Who gets the user drop:** both sides of the market, by **total usage** that month: + `activity(wallet) = USDC spent on paid queries + USDC earned from attributions` + (consumers earn it by spending, providers by contributing — both incentivized). Each wallet's drop = `userBudgetPerkos × activity(wallet) / Σ activity`. +- **Pull, not push:** bought $PERKOS goes into the existing `PerkosClaimVault`; `token_rewards.cumulative_perkos` grows; users **claim** it from the dashboard alongside their USDC earnings (one `claim()` pays both). + +## 3. What already exists + +| Piece | State | +|---|---| +| `reward_pool` (5% accrual) | ✅ + now `chain` column (Phase A). The monthly budget source. | +| `rewardPlatformBps` config | ✅ added (Phase A), default 4000, admin-editable. | +| Usage tracking | ✅ `credit_ledger` (every debit/credit, wallet+chain+ts) + `knowledge_attributions`. No new tracking needed — just aggregate by month. | +| `token_rewards` (wallet → cum $PERKOS) | ✅ table; the drop ledger. Written by the monthly job. | +| `PerkosClaimVault` | ✅ `claim(account, cumUsdc, cumReward, proof)` pays USDC + $PERKOS; `rewardToken` set on Base + Celo. | +| `claim.ts` rollup | ✅ tree has `cumReward`; flip it from `0n` → read `token_rewards`. | +| `lib/buyback.ts` | ⚠️ scaffold; replace with the monthly-drop job. | + +## 4. End-to-end flow + +``` +DURING THE MONTH (live) + paid query → 5% reward (USDC, per chain) → reward_pool(status=pending, chain) + credit_ledger records spend (debit) + earnings (credit) per wallet/chain/day + │ + ▼ MONTH END — admin runs the drop, per chain (dry-run first) + budget = Σ reward_pool.amount (pending, this month, chain C) + buy: treasury(0x3f0D) swaps `budget` USDC → $PERKOS on chain C's Uniswap + Base = v3 SwapRouter exactInputSingle(USDC,PERKOS,3000,…) + Celo = v4 Universal Router (PoolKey: USDC/PERKOS, 0.3%, tickSpacing, hooks) + bought = $PERKOS received (slippage-guarded by a quote × (1-maxSlippage)) + │ + ├─► platform keep = bought × rewardPlatformBps (40%) → stays in treasury + │ + ▼ user drop = bought × 60% + activity(wallet) = spent + earned that month on chain C (credit_ledger + attributions) + for each wallet: drop = userDrop × activity / Σ activity + token_rewards[wallet].cumulative_perkos += drop + mark reward_pool rows status='distributed', epoch=YYYY-MM + transfer the user-drop $PERKOS into the vault on chain C + │ + ▼ +CLAIM (exists) + rollupEntries(C): cumUsdc = total_earned, cumReward = token_rewards.cumulative_perkos + post per-chain Merkle root (setMerkleRoot, distributor) → users claim() USDC + $PERKOS + │ + ▼ +DASHBOARD (reworded) + "Your $PERKOS drop — earned for using PerkOS this month." → Claim. +``` + +## 5. Components / phases + +- **Phase A — accounting (DONE, not deployed):** `reward_pool.chain` + thread `payChain`; `rewardPlatformBps` (40%) in config + DB. Reversible, no on-chain. +- **Phase B — monthly drop *calculation* (dry-run):** `lib/rewardsDrop.ts` → `computeMonthlyDrop(client, {year, month, chain})` returns `{ budgetUsdc, platformBps, perWallet: [{wallet, activity, sharePct}] }`. Admin endpoint `GET /api/admin/rewards/drop?month=&chain=` shows exactly what a drop *would* pay — no trade, no writes. Safe to ship + run anytime. +- **Phase C — buyback + distribute (real money):** per chain, swap budget→$PERKOS, split 40/60, write `token_rewards`, mark `reward_pool` distributed, fund the vault, post the root. Behind the `buybackEnabled` + treasury-key gates; admin-triggered, dry-run flag honored. + - **Swap via the Uniswap Trading API** (decided 2026-06-24) — NOT hand-rolled v3/v4 router calldata. The hosted API (`https://trade-api.gateway.uniswap.org/v1`, `x-api-key`) supports **both Base and Celo** and **v2/v3/v4** classic AMM swaps, so one flow covers Base-v3 + Celo-v4 without us touching the v4 Universal Router / PoolManager / PoolKey+hooks. Flow per chain: `POST /v1/check_approval` (Permit2 for USDC; sign the returned approval tx once) → `POST /v1/quote` (`type:EXACT_INPUT`, `routing:CLASSIC` to exclude gasless UniswapX, `tokenIn`=USDC, `tokenOut`=$PERKOS, `amount`=budget, `swapper`=`0x3f0D`, `tokenInChainId`=`tokenOutChainId`=8453/42220) → `POST /v1/swap` returns a ready tx `{to,data,value,chainId,gasLimit}` → treasury `0x3f0D` signs + broadcasts (gasful) → receives $PERKOS. Slippage handled by the API (`slippageTolerance`). We still record the swap tx hash + a min-out guard before distributing. +- **Phase D — drop UX (DONE):** `ClaimPanel` reads as a "$PERKOS drop earned for using PerkOS"; per-chain rows show the highlighted `… $PERKOS drop`. +- **Orchestrator (DONE):** `App/scripts/monthly-drop.mjs` chains all legs for one chain. Run from the App dir (needs viem), keys read locally (admin token + Uniswap key from `.env`, treasury key from `Contracts/.env`): + ``` + node scripts/monthly-drop.mjs --chain=base # DRY-RUN: budget + quote + split + node scripts/monthly-drop.mjs --chain=base --apply # real: swap → distribute → build root → fund vault → post root + node scripts/monthly-drop.mjs --chain=celo --apply + ``` + Dry-run validated (budget 0.3 USDC → quote 342,606 $PERKOS, platform 0.12 / users 0.18). `--apply` sends real txs; the treasury signer (`0x3f0D`) needs native gas per chain. Run per chain at month end when there's accrued reward. +- **Phase E — automate (later):** wrap the orchestrator in a month-end cron once a real drop has been run by hand (add slippage + per-month USDC caps first). + +## 6. New inputs / config + +| Thing | Value / source | +|---|---| +| **Uniswap Trading API key** | `x-api-key` from hub.uniswap.org / the Uniswap dashboard. The one external thing we still need. | +| Base | chainId 8453; USDC `0x8335…2913`; $PERKOS `0xF714…9BA3`. API picks the v3 route. | +| Celo | chainId 42220; USDC `0xcebA…118C`; $PERKOS `0xb7Ba…6A46`. API picks the v4 route. | +| `rewardPlatformBps` | 4000 (40% platform / 60% users). | +| Treasury signer | `KNOWLEDGE_TREASURY_PRIVATE_KEY` = `0x3f0D`; needs native gas per chain (Base ETH + Celo CELO). | + +## 7. Risks + +- **Uniswap Trading API dependency:** a hosted API (key, rate limits, uptime). For a once-a-month buyback that's fine; if it's ever down we just run the drop later. Removes the v3-vs-v4 integration risk entirely (the API abstracts both). Keep a fallback note: the same swap could be done with on-chain routers if needed. +- **Slippage / thin pools:** one monthly buy can move a small pool. The API quotes + applies `slippageTolerance`; still cap per-month USDC and refuse if the quote's price impact is above a threshold. Optionally split the buy. +- **Price volatility:** show the **$PERKOS amount**, not a USD promise. +- **Gas per chain:** treasury signer needs Base ETH + Celo CELO. +- **Rounding:** floor each wallet's 18-dec share; keep the remainder in the platform cut (never over-allocate vs vault balance). +- **Legal:** user confirmed the framing is fine (user pays for the service; PerkOS later returns tokens for usage).