From a50410c8be397b4dec84743c19200df5b52da40a Mon Sep 17 00:00:00 2001 From: 0xJMC Date: Mon, 22 Jun 2026 18:08:40 -0400 Subject: [PATCH 01/16] =?UTF-8?q?feat(credits):=20per-chain=20balances=20?= =?UTF-8?q?=E2=80=94=20payment-chain=20=3D=20earning-chain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Balances and the credit ledger are now keyed by (wallet, chain) instead of wallet alone. A consumer who pays on Celo spends a Celo balance, and the providers whose knowledge they consumed earn on Celo; Base stays separate. This is what makes the multi-chain claim distributions correct — earnings are segregated by the chain the payment settled on, so a provider claims the right amount from the right chain's vault (no cross-chain double-pay). - agent_accounts: drop wallet PK -> (wallet, chain) unique index; chain column defaults 'base' so existing rows migrate cleanly. - credit_ledger: + chain column. - credits.ts: getBalance/ensureAccount/debit/credit are chain-aware (default 'base'); getAccountSummary aggregates across chains and adds byChain[]. - /skill/query: derives payChain (body.payChain|chain|x-payment-chain) and threads it through debit + attribution + provider credit. - /api/deposit: credits the chain the deposit settled on (chain: net); dedup balance read scoped to that chain. - admin grant: optional chain (base|celo) for per-chain stipend/promo credits. Verified live: grants on base+celo segregate; same-chain grants accumulate, cross-chain untouched. --- App/app/api/admin/credits/grant/route.ts | 11 +++- App/app/api/deposit/route.ts | 5 +- App/app/skill/query/route.ts | 12 +++- App/lib/credits.ts | 75 ++++++++++++++---------- App/lib/db.ts | 11 +++- 5 files changed, 76 insertions(+), 38 deletions(-) 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/deposit/route.ts b/App/app/api/deposit/route.ts index cf09e89..3c02183 100644 --- a/App/app/api/deposit/route.ts +++ b/App/app/api/deposit/route.ts @@ -90,16 +90,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..c400a4c 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 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..ad71a84 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,6 +329,7 @@ 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))`); From 5893f18a8a7d9cd22c94e6fa926905253ba8d8de Mon Sep 17 00:00:00 2001 From: 0xJMC Date: Mon, 22 Jun 2026 18:20:10 -0400 Subject: [PATCH 02/16] =?UTF-8?q?feat(admin):=20vault=20owner-ops=20panel?= =?UTF-8?q?=20=E2=80=94=20setRewardToken=20from=20the=20connected=20wallet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The only owner-only on-chain step left (point Base's vault at Base $PERKOS so the 5% reward leg pays out) is now doable from knowledge.perkos.xyz/admin/billing instead of a CLI cast send with a raw private key — the owner signs it with their connected wallet via wagmi, same pattern as the dashboard ClaimPanel. - VaultOwnerPanel renders ONLY when the connected wallet == vault owner() on-chain (re-checked per render; the contract enforces onlyOwner anyway). - Reads each chain's current rewardToken(); shows set (Celo, already Celo $PERKOS) or a 'Set on ' button that signs setRewardToken() on the right chainId (wagmi prompts a network switch). - Reuses /api/claims/:wallet for vaultAddress + per-chain {chainId, perkos}; no new endpoint. Distributor ops (post-root, fund) stay in the operator scripts as the treasury wallet — not exposed here. --- App/components/AdminClient.tsx | 4 + App/components/VaultOwnerPanel.tsx | 118 +++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 App/components/VaultOwnerPanel.tsx diff --git a/App/components/AdminClient.tsx b/App/components/AdminClient.tsx index 79e37dd..d47ea7d 100644 --- a/App/components/AdminClient.tsx +++ b/App/components/AdminClient.tsx @@ -2,6 +2,7 @@ import { 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 }; @@ -190,6 +191,9 @@ export default function AdminClient() { ) :

Loading tokenomics…

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

Whitelist · agent_billing

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} +
+
+ ); +} From 50adfe3519c4faae314b4dfe2058a0dcb11814d8 Mon Sep 17 00:00:00 2001 From: 0xJMC Date: Mon, 22 Jun 2026 18:22:53 -0400 Subject: [PATCH 03/16] docs(llms): multi-chain (Base+Celo), on-chain deposit, payChain, claim/pull model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit llms.txt + llms-full.txt were stale (Base-only, old prices, 'self-deposit coming'). Now document: per-(wallet,chain) balances + payChain selection (payment-chain = earning-chain), POST /api/deposit (x402 via PerkOS Stack, Base+Celo USDC, creditTo), the 75/20/5 fee split, the enterprise tier, and the pull/claim payout model (GET /api/claims/:wallet + PerkosClaimVault). Prices stay 'authoritative live at /api/x402/policy' — no hardcoded ladder. --- App/public/llms-full.txt | 14 ++++++++------ App/public/llms.txt | 13 ++++++++----- 2 files changed, 16 insertions(+), 11 deletions(-) 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) From f281f41c006388920d26f96656aec62d9745d9fe Mon Sep 17 00:00:00 2001 From: 0xJMC Date: Mon, 22 Jun 2026 22:47:07 -0400 Subject: [PATCH 04/16] fix(dashboard): wallet connect button + active-wallet indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard read useAccount() for balance/deposit/claim but never showed a connect button or which wallet was active — you couldn't tell where a deposit would land or which wallet you'd claim from, and a disconnected visitor just span on 'Loading...' forever (the data fetch early-returns without an address). - RainbowKit ConnectButton in the nav (address + change/disconnect), on every render state. - Not-connected state now shows a 'Connect your wallet' hero instead of an infinite spinner. - Hero echoes the active 0x1234…5678 so it's unambiguous whose balance/earnings are shown. --- App/components/DashboardClient.tsx | 47 ++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/App/components/DashboardClient.tsx b/App/components/DashboardClient.tsx index 52eb20c..50dca4d 100644 --- a/App/components/DashboardClient.tsx +++ b/App/components/DashboardClient.tsx @@ -1,5 +1,6 @@ 'use client'; +import { ConnectButton } from '@rainbow-me/rainbowkit'; import { useEffect, useState } from 'react'; import { useAccount } from 'wagmi'; @@ -74,7 +75,7 @@ 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(''); @@ -101,25 +102,53 @@ export default function DashboardClient() { return () => { active = false; }; }, [address]); - if (error) return

{error}

; - if (!usage) return

Loading live dashboard data…

; + // 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 = ( + + ); + + // 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}.

From 932241d178042fb6db95d35c649045df79fd74f5 Mon Sep 17 00:00:00 2001 From: 0xJMC Date: Tue, 23 Jun 2026 10:19:38 -0400 Subject: [PATCH 05/16] fix(deposit): call Stack /verify + /settle (not /api/v2/x402/*) + add admin error log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real deposit failed with "Invalid input: expected string, received number". Root cause: lib/payments.ts hit Stack's /api/v2/x402/verify + /settle, whose request schema validates x402Version as a STRING while a later version check compares it as a NUMBER — so it rejects every payload (number fails the schema, string fails the version check). The x402-standard /verify + /settle endpoints accept the normal { x402Version: 1, paymentPayload, paymentRequirements } and only reject on a bad signature. Switched to those. Also adds a system error log (the user asked for in-admin visibility, and it's what pinned this down): - system_errors table + lib/errlog.ts (logError is best-effort, never throws; context is size-capped and admin-readable — redact secrets). - The deposit route logs the full Stack interaction on a settle failure (net/amount/wallet, HTTP status, Stack response, paymentRequirements, and a signature-redacted payload shape) under scope "deposit.settle". - settleViaStack now returns status + raw for that capture. - GET /api/admin/errors (admin-gated) + a "System · error log" panel in AdminClient (recent errors, click a row to expand its JSON context). 204 tests green. Base deposits now reach settle (real signature required to complete). Note: x402-fetch 1.2.0's client network enum lacks "celo", so Celo deposits still fail client-side — separate follow-up. --- App/app/api/admin/errors/route.ts | 25 ++++++++++++ App/app/api/deposit/route.ts | 27 +++++++++++++ App/components/AdminClient.tsx | 46 +++++++++++++++++++++- App/lib/db.ts | 14 +++++++ App/lib/errlog.ts | 63 +++++++++++++++++++++++++++++++ App/lib/payments.ts | 21 +++++++++-- 6 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 App/app/api/admin/errors/route.ts create mode 100644 App/lib/errlog.ts 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/deposit/route.ts b/App/app/api/deposit/route.ts index 3c02183..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 }); } diff --git a/App/components/AdminClient.tsx b/App/components/AdminClient.tsx index d47ea7d..7eaa8ae 100644 --- a/App/components/AdminClient.tsx +++ b/App/components/AdminClient.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { Fragment, useCallback, useEffect, useState } from 'react'; import { useAccount } from 'wagmi'; import VaultOwnerPanel from './VaultOwnerPanel'; @@ -8,6 +8,7 @@ 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 }; @@ -39,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( @@ -49,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]); @@ -194,6 +199,43 @@ export default function AdminClient() { {/* 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/lib/db.ts b/App/lib/db.ts index ad71a84..8d3a9e2 100644 --- a/App/lib/db.ts +++ b/App/lib/db.ts @@ -334,6 +334,20 @@ export async function ensureSchema(client: Client) { 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 ( 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..af4cdf2 100644 --- a/App/lib/payments.ts +++ b/App/lib/payments.ts @@ -8,6 +8,12 @@ * 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"; @@ -107,7 +113,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,9 +125,10 @@ 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", { + const { httpOk, status, data } = await callFacilitator("/settle", { x402Version: 1, paymentPayload, paymentRequirements, @@ -131,6 +138,12 @@ export async function settleViaStack(paymentPayload: unknown, paymentRequirement 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, }; } From 000a8011a59e547368322ac09afb7f9af3576acf Mon Sep 17 00:00:00 2001 From: 0xJMC Date: Tue, 23 Jun 2026 10:45:55 -0400 Subject: [PATCH 06/16] fix(dashboard): refresh credit balance after a deposit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deposit succeeded (balance credited on-chain + in the ledger) but the 'Credit balance' metric still read 0 — the dashboard fetched credits once on mount and DepositPanel's onDeposited callback was never wired. Extracted loadCredits (useCallback) and pass it as onDeposited so a successful deposit re-fetches /api/credits and the balance updates in place (no reload). --- App/components/DashboardClient.tsx | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/App/components/DashboardClient.tsx b/App/components/DashboardClient.tsx index 50dca4d..a834a46 100644 --- a/App/components/DashboardClient.tsx +++ b/App/components/DashboardClient.tsx @@ -1,7 +1,7 @@ 'use client'; import { ConnectButton } from '@rainbow-me/rainbowkit'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useAccount } from 'wagmi'; import ClaimPanel from './ClaimPanel'; @@ -80,6 +80,19 @@ export default function DashboardClient() { 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; @@ -93,14 +106,10 @@ 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 @@ -158,7 +167,7 @@ export default function DashboardClient() {
Knowledge items{usage.knowledge.knowledgeItemsAvailable}
- + From 5c40af3dcdc497c21855ca8795b655f96e2706e7 Mon Sep 17 00:00:00 2001 From: 0xJMC Date: Tue, 23 Jun 2026 14:12:30 -0400 Subject: [PATCH 07/16] =?UTF-8?q?feat(deposit):=20enable=20Celo=20?= =?UTF-8?q?=E2=80=94=20per-token=20EIP-712=20domain=20+=20manual=20x402=20?= =?UTF-8?q?client=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs blocked Celo deposits: 1. lib/payments.ts hardcoded the x402 paymentRequirements extra.name to 'USD Coin' for BOTH chains, but Celo USDC's on-chain EIP-712 domain name is 'USDC' (Base = 'USD Coin'; both version '2', verified on-chain). A wrong domain name = an invalid EIP-3009 signature the facilitator rejects. NETWORKS now carries each token's real name/version and buildPaymentRequirements uses them. 2. x402-fetch 1.2.0's network enum has no 'celo', so wrapFetchWithPayment can't be used for Celo (it rejects the 402's accepts before signing). DepositPanel now does the x402 dance by hand for Celo: switch chain, fetch the 402, sign the EIP-3009 transferWithAuthorization against the token's domain via the wagmi/viem walletClient, base64 the x402 payload into X-PAYMENT, retry. Base keeps the proven x402-fetch path. Verified: the live Celo 402 challenge now carries extra {name:'USDC'} matching the contract. 205 tests (added a Base-domain assertion; fixed the Celo one that encoded the wrong name). --- App/components/DepositPanel.tsx | 144 ++++++++++++++++++++++++++++---- App/lib/payments.ts | 15 +++- App/tests/payments.test.ts | 9 +- 3 files changed, 146 insertions(+), 22 deletions(-) diff --git a/App/components/DepositPanel.tsx b/App/components/DepositPanel.tsx index 472f235..bb1aba0 100644 --- a/App/components/DepositPanel.tsx +++ b/App/components/DepositPanel.tsx @@ -1,9 +1,91 @@ 'use client'; import { useState } from 'react'; -import { useAccount, useWalletClient } from 'wagmi'; +import 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: payer, + to: req.payTo as `0x${string}`, + value, + validAfter, + validBefore, + nonce, + }; + const signature = await (walletClient.signTypedData as (a: unknown) => Promise<`0x${string}`>)({ + account: payer, + domain: { + name: req.extra?.name || 'USDC', + version: req.extra?.version || '2', + chainId, + verifyingContract: req.asset as `0x${string}`, + }, + types: TRANSFER_WITH_AUTHORIZATION_TYPES, + 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 +99,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 +107,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/lib/payments.ts b/App/lib/payments.ts index af4cdf2..587b585 100644 --- a/App/lib/payments.ts +++ b/App/lib/payments.ts @@ -19,9 +19,16 @@ 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 { @@ -76,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 }, }; } 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" }); }); }); From 9588f0a9b57d14a603bf68302209e64f0074483a Mon Sep 17 00:00:00 2001 From: 0xJMC Date: Tue, 23 Jun 2026 14:26:56 -0400 Subject: [PATCH 08/16] fix(deposit): handle facilitator timeout + give Celo settle 60s (was 500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Celo deposit failed with HTTP 500. Cause: callFacilitator aborted the Stack /settle call after 12s and the AbortError propagated unhandled → 500. Settle broadcasts + waits for an on-chain USDC transfer, and Celo finality is slower than Base, so 12s wasn't enough. Now: callFacilitator catches network/abort errors and returns a handled failure (so a slow/failed facilitator surfaces as a clear 402 reason + admin error-log entry, never a 500), and settleViaStack uses a 60s timeout. Verified the user did NOT lose USDC on the failed attempt (treasury Celo USDC balance = 0; nothing settled on-chain). --- App/lib/payments.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/App/lib/payments.ts b/App/lib/payments.ts index 587b585..434305f 100644 --- a/App/lib/payments.ts +++ b/App/lib/payments.ts @@ -113,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); } @@ -135,11 +147,13 @@ export async function verifyViaStack(paymentPayload: unknown, paymentRequirement /** 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) { + // 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, From c1253d6ee300ea1c44c43c6db0a1bffd88995b06 Mon Sep 17 00:00:00 2001 From: 0xJMC Date: Tue, 23 Jun 2026 23:44:02 -0400 Subject: [PATCH 09/16] fix(deposit): Celo signs gaslessly on smart wallets + ENS via CORS-friendly RPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two dashboard fixes: 1. Celo deposit on a smart wallet executed instead of signing. The manual x402 path passed an explicit `account` to walletClient.signTypedData. With an EIP-7702 smart wallet (account 0xc256, EOA signer 0xdeB7), that made MetaMask try to EXECUTE the transferWithAuthorization as a transaction from the EOA signer (which has no CELO for gas) → 'insufficient funds for transfer'. x402-fetch (used for Base) calls signTypedData(data) with NO account field, letting the wallet sign with its connected account gaslessly — replicated that exactly for Celo (+ getAddress on from/to/asset, matching x402). 2. ENS resolution + console noise. mainnet was in the wagmi chains (for ENS) but its default RPC (eth.merkle.io) blocks browser CORS, spamming ~16 errors and failing ENS. Pointed mainnet at a CORS-friendly RPC; Base/Celo keep defaults. --- App/components/DepositPanel.tsx | 17 +++++++++++------ App/lib/wagmi.ts | 13 ++++++++++++- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/App/components/DepositPanel.tsx b/App/components/DepositPanel.tsx index bb1aba0..8718959 100644 --- a/App/components/DepositPanel.tsx +++ b/App/components/DepositPanel.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState } from 'react'; -import type { WalletClient } from 'viem'; +import { getAddress, type WalletClient } from 'viem'; import { useAccount, useSwitchChain, useWalletClient } from 'wagmi'; import { wrapFetchWithPayment } from 'x402-fetch'; @@ -48,22 +48,27 @@ async function signX402Payment( const nonce = randomNonce(); const value = BigInt(req.maxAmountRequired); const authorization = { - from: payer, - to: req.payTo as `0x${string}`, + 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}`>)({ - account: payer, + types: TRANSFER_WITH_AUTHORIZATION_TYPES, domain: { name: req.extra?.name || 'USDC', version: req.extra?.version || '2', chainId, - verifyingContract: req.asset as `0x${string}`, + verifyingContract: getAddress(req.asset), }, - types: TRANSFER_WITH_AUTHORIZATION_TYPES, primaryType: 'TransferWithAuthorization', message: authorization, }); 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, }); From f3421fd8364ad13451e1565f7fa3485c3fdbfbde Mon Sep 17 00:00:00 2001 From: 0xJMC Date: Wed, 24 Jun 2026 16:41:37 -0400 Subject: [PATCH 10/16] =?UTF-8?q?feat(rewards):=20monthly=20$PERKOS=20usag?= =?UTF-8?q?e-drop=20=E2=80=94=20accounting=20+=20dry-run=20calc=20(Phases?= =?UTF-8?q?=20A+B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframes the 5% reward as a monthly usage drop: at month end the accrued 5% (per chain) buys $PERKOS, 40% stays with the platform, 60% drops to users proportional to their TOTAL usage that month (USDC spent on queries + USDC earned from attributions — both sides of the market). Pull-model: $PERKOS lands in the existing vault, users claim it alongside USDC. Phase A (accounting, on-chain-free): - reward_pool gains a 'chain' column; /skill/query threads payChain into accrueReward so Base reward USDC buys Base $PERKOS, Celo buys Celo. - tokenomics_config gains reward_platform_bps (default 4000 = 40% platform / 60% users), admin-editable. Phase B (calculation, read-only dry-run): - lib/rewardsDrop.ts computeMonthlyDrop({month,chain}) → budget + platform/user split + per-wallet usage-weighted share. - GET /api/admin/rewards/drop?month=&chain= shows what a drop WOULD pay (no trade, no writes). Validated live with synthetic data: budget 0.0075 → platform 0.003 / users 0.0045, split 66.7%/33.3% by spend. 205 tests green. Design: docs/PERKOS-REWARDS-BUYBACK-DESIGN.md. Phase C (the Base-v3/Celo-v4 buyback swap + distribute) is next — needs the router addresses + is real money. --- App/app/api/admin/rewards/drop/route.ts | 39 ++++++++ App/app/skill/query/route.ts | 1 + App/lib/db.ts | 8 +- App/lib/rewardsDrop.ts | 122 ++++++++++++++++++++++++ App/lib/tokenomics.ts | 23 +++-- docs/PERKOS-REWARDS-BUYBACK-DESIGN.md | 91 ++++++++++++++++++ 6 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 App/app/api/admin/rewards/drop/route.ts create mode 100644 App/lib/rewardsDrop.ts create mode 100644 docs/PERKOS-REWARDS-BUYBACK-DESIGN.md 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..c89c863 --- /dev/null +++ b/App/app/api/admin/rewards/drop/route.ts @@ -0,0 +1,39 @@ +/** + * 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 { computeMonthlyDrop } from "../../../../../lib/rewardsDrop"; + +export const dynamic = "force-dynamic"; + +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 })); + return Response.json({ ok: true, dryRun: true, drop, 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/skill/query/route.ts b/App/app/skill/query/route.ts index c400a4c..61f0b0d 100644 --- a/App/app/skill/query/route.ts +++ b/App/app/skill/query/route.ts @@ -172,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/lib/db.ts b/App/lib/db.ts index 8d3a9e2..069764f 100644 --- a/App/lib/db.ts +++ b/App/lib/db.ts @@ -384,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(` @@ -412,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, @@ -422,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 diff --git a/App/lib/rewardsDrop.ts b/App/lib/rewardsDrop.ts new file mode 100644 index 0000000..f018b13 --- /dev/null +++ b/App/lib/rewardsDrop.ts @@ -0,0 +1,122 @@ +/** + * 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, + }; +} 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/docs/PERKOS-REWARDS-BUYBACK-DESIGN.md b/docs/PERKOS-REWARDS-BUYBACK-DESIGN.md new file mode 100644 index 0000000..820906a --- /dev/null +++ b/docs/PERKOS-REWARDS-BUYBACK-DESIGN.md @@ -0,0 +1,91 @@ +# $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 (Base v3 / Celo v4), 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. +- **Phase D — drop UX:** reword `ClaimPanel` to a "$PERKOS drop" with a first-time toast. +- **Phase E — automate:** a month-end cron once B+C are proven by hand (slippage + per-month caps). + +## 6. New inputs / config + +| Thing | Value / source | +|---|---| +| Base pool | Uniswap **v3**, USDC/$PERKOS, fee **3000** (0.3%). $PERKOS Base `0xF714…9BA3`. | +| Celo pool | Uniswap **v4**, USDC/$PERKOS, fee **3000** (0.3%). $PERKOS Celo `0xb7Ba…6A46`. Needs PoolKey: tickSpacing + hooks addr. | +| Routers | Base v3 SwapRouter; Celo v4 Universal Router + PoolManager (addresses TBD). | +| `rewardPlatformBps` | 4000 (40% platform / 60% users). | +| Treasury signer | `KNOWLEDGE_TREASURY_PRIVATE_KEY` = `0x3f0D`; needs native gas per chain. | + +## 7. Risks + +- **Celo is Uniswap v4** — different from Base v3 (PoolManager + Universal Router + PoolKey/hooks). The swap leg must branch per chain; v4 is the harder one. Get the v4 PoolKey + router right (and confirm a USDC/$PERKOS v4 pool with liquidity exists at 0.3%). +- **Slippage / thin pools:** one monthly buy can move a small pool. Quote first, `minAmountOut`, 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). From f601524ea2e70a6b36270d532eff47c964c9e9c5 Mon Sep 17 00:00:00 2001 From: 0xJMC Date: Thu, 25 Jun 2026 21:01:45 -0400 Subject: [PATCH 11/16] feat(rewards): wire Uniswap Trading API quote into the drop dry-run (Phase C1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/uniswapTrade.ts: a thin Uniswap Trading API client. quoteBuyback() quotes USDC -> $PERKOS per chain via the hosted API (CLASSIC routing, no UniswapX), so one path covers Base v3 + Celo v4 without touching v4 routers. Read-only. The /api/admin/rewards/drop dry-run now also returns a 'buyback' block: the budget quoted to $PERKOS, split platform 40% / users 60%, and each wallet's usage-weighted $PERKOS share — exactly what a real buyback would distribute, no trade executed. Validated live (Base): $1 budget → ~1,108,520 $PERKOS → platform 443,408 / users 665,112, split 66.7/33.3 by usage. Quotes confirmed on Base + Celo (HTTP 200, CLASSIC). UNISWAP_API_KEY wired into the prod env + compose. Next (C2): execute the swap (check_approval -> swap, treasury signs) + distribute to token_rewards + fund vault + post root. --- App/app/api/admin/rewards/drop/route.ts | 29 ++++++- App/lib/uniswapTrade.ts | 108 ++++++++++++++++++++++++ docs/PERKOS-REWARDS-BUYBACK-DESIGN.md | 15 ++-- 3 files changed, 144 insertions(+), 8 deletions(-) create mode 100644 App/lib/uniswapTrade.ts diff --git a/App/app/api/admin/rewards/drop/route.ts b/App/app/api/admin/rewards/drop/route.ts index c89c863..b9af83e 100644 --- a/App/app/api/admin/rewards/drop/route.ts +++ b/App/app/api/admin/rewards/drop/route.ts @@ -8,10 +8,14 @@ */ 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(); @@ -29,7 +33,30 @@ export async function GET(request: Request) { try { const drop = await withDb((c) => computeMonthlyDrop(c, { month, chain })); - return Response.json({ ok: true, dryRun: true, drop, ts: new Date().toISOString() }); + + // 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" }, 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/docs/PERKOS-REWARDS-BUYBACK-DESIGN.md b/docs/PERKOS-REWARDS-BUYBACK-DESIGN.md index 820906a..f1db85d 100644 --- a/docs/PERKOS-REWARDS-BUYBACK-DESIGN.md +++ b/docs/PERKOS-REWARDS-BUYBACK-DESIGN.md @@ -67,7 +67,8 @@ DASHBOARD (reworded) - **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 (Base v3 / Celo v4), 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. +- **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:** reword `ClaimPanel` to a "$PERKOS drop" with a first-time toast. - **Phase E — automate:** a month-end cron once B+C are proven by hand (slippage + per-month caps). @@ -75,16 +76,16 @@ DASHBOARD (reworded) | Thing | Value / source | |---|---| -| Base pool | Uniswap **v3**, USDC/$PERKOS, fee **3000** (0.3%). $PERKOS Base `0xF714…9BA3`. | -| Celo pool | Uniswap **v4**, USDC/$PERKOS, fee **3000** (0.3%). $PERKOS Celo `0xb7Ba…6A46`. Needs PoolKey: tickSpacing + hooks addr. | -| Routers | Base v3 SwapRouter; Celo v4 Universal Router + PoolManager (addresses TBD). | +| **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. | +| Treasury signer | `KNOWLEDGE_TREASURY_PRIVATE_KEY` = `0x3f0D`; needs native gas per chain (Base ETH + Celo CELO). | ## 7. Risks -- **Celo is Uniswap v4** — different from Base v3 (PoolManager + Universal Router + PoolKey/hooks). The swap leg must branch per chain; v4 is the harder one. Get the v4 PoolKey + router right (and confirm a USDC/$PERKOS v4 pool with liquidity exists at 0.3%). -- **Slippage / thin pools:** one monthly buy can move a small pool. Quote first, `minAmountOut`, optionally split the buy. +- **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). From dedfa24176804de85ef9e7bc2df568c65dee36b1 Mon Sep 17 00:00:00 2001 From: 0xJMC Date: Thu, 25 Jun 2026 22:57:26 -0400 Subject: [PATCH 12/16] feat(rewards): distribute the bought $PERKOS into claimable cumReward (Phase C2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the drop's $PERKOS into the existing claim/pull model: - token_rewards is now per-(wallet, chain) — a wallet's $PERKOS is segregated by the chain it was bought on, claimed from that chain's vault. - rollupEntries unions provider USDC earnings (attributions) with each wallet's $PERKOS drop (token_rewards) per chain, so cumReward is no longer hardcoded 0 — a consumer who only spent (no USDC earnings) still gets a claimable $PERKOS row. - distributeDrop(month, chain, perkosBought): keeps the platform cut (40%), credits each wallet's usage-weighted share into token_rewards (integer pro-rata, remainder stays with platform), marks that month's pending reward_pool rows distributed. Accounting-only — the swap + vault funding + root posting are the operator's on-chain steps. - POST /api/admin/rewards/distribute (dry-run default) runs it after a buyback. Validated live: 1,000,000 $PERKOS bought → platform 400k / users 600k → A 400k (66.7%) / B 200k (33.3%) by usage → built the base root → /api/claims/A shows cumReward 400k $PERKOS claimable (cumUsdc 0, a pure consumer). 205 tests green. --- App/app/api/admin/rewards/distribute/route.ts | 66 ++++++++++++++++++ App/lib/claim.ts | 42 +++++++---- App/lib/db.ts | 9 ++- App/lib/rewardsDrop.ts | 69 +++++++++++++++++++ 4 files changed, 172 insertions(+), 14 deletions(-) create mode 100644 App/app/api/admin/rewards/distribute/route.ts 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/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/db.ts b/App/lib/db.ts index 069764f..a36fc09 100644 --- a/App/lib/db.ts +++ b/App/lib/db.ts @@ -458,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/rewardsDrop.ts b/App/lib/rewardsDrop.ts index f018b13..5b8ca97 100644 --- a/App/lib/rewardsDrop.ts +++ b/App/lib/rewardsDrop.ts @@ -120,3 +120,72 @@ export async function computeMonthlyDrop( 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, + }; +} From f713e284e7b032aaf512b0ee4400a9e396152172 Mon Sep 17 00:00:00 2001 From: 0xJMC Date: Thu, 25 Jun 2026 23:00:14 -0400 Subject: [PATCH 13/16] feat(rewards): frame the $PERKOS as a usage drop in the dashboard (Phase D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClaimPanel now reads as a reward/drop, not a refund: 'earnings + your $PERKOS drop', 'The more you use PerkOS, the bigger your $PERKOS drop — earned just for using the platform.' Each chain row shows the $PERKOS as a highlighted '… $PERKOS drop' (only when > 0), USDC only when > 0. No mention of the 5% mechanism — the user just sees they got $PERKOS for using PerkOS. --- App/components/ClaimPanel.tsx | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) 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}
From 5d4727a5deab5d696d7eb3b1b0a77726a85a95db Mon Sep 17 00:00:00 2001 From: 0xJMC Date: Thu, 25 Jun 2026 23:06:58 -0400 Subject: [PATCH 14/16] feat(rewards): month-end drop orchestrator script (ties all legs together) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App/scripts/monthly-drop.mjs runs the whole monthly $PERKOS drop for one chain: read budget → market-buy $PERKOS (Uniswap Trading API, treasury signs) → distribute (platform 40% kept, 60% to users by usage → token_rewards) → build the Merkle root → fund the vault with the user $PERKOS → post the root. Users then claim from the dashboard. DRY-RUN by default (just reads budget + quote + split); --apply sends the real swap + on-chain txs. Keys read locally (admin token + Uniswap key from .env, treasury 0x3f0D from Contracts/.env) — never the VPS. Validated in dry-run: budget 0.3 USDC → quote 342,606 $PERKOS, platform 0.12 / users 0.18. Completes the $PERKOS usage-drop system (Phases A–D + orchestrator). Run per chain at month end with --apply when there's accrued reward; doc has the runbook. --- App/scripts/monthly-drop.mjs | 115 ++++++++++++++++++++++++++ docs/PERKOS-REWARDS-BUYBACK-DESIGN.md | 11 ++- 2 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 App/scripts/monthly-drop.mjs diff --git a/App/scripts/monthly-drop.mjs b/App/scripts/monthly-drop.mjs new file mode 100644 index 0000000..79a36ee --- /dev/null +++ b/App/scripts/monthly-drop.mjs @@ -0,0 +1,115 @@ +#!/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); + +console.log(`\n✅ drop complete — ${MONTH} / ${CHAIN}. Users can claim their $PERKOS from the dashboard.`); diff --git a/docs/PERKOS-REWARDS-BUYBACK-DESIGN.md b/docs/PERKOS-REWARDS-BUYBACK-DESIGN.md index f1db85d..9f0d436 100644 --- a/docs/PERKOS-REWARDS-BUYBACK-DESIGN.md +++ b/docs/PERKOS-REWARDS-BUYBACK-DESIGN.md @@ -69,8 +69,15 @@ DASHBOARD (reworded) - **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:** reword `ClaimPanel` to a "$PERKOS drop" with a first-time toast. -- **Phase E — automate:** a month-end cron once B+C are proven by hand (slippage + per-month caps). +- **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 From b527f0015257db2870574242df6adee79e507523 Mon Sep 17 00:00:00 2001 From: 0xJMC Date: Thu, 25 Jun 2026 23:27:23 -0400 Subject: [PATCH 15/16] feat(claims): mark-posted endpoint + orchestrator step 7 Found during the live orchestrator test: nothing ever flipped claim_distributions.posted to true, so the dashboard showed 'root pending on-chain' for every claim even after the root was live. Add POST /api/admin/claims/mark-posted { chain, root, txHash } (UPDATE posted=true, tx_hash) and call it from monthly-drop.mjs right after setMerkleRoot. Test validated end-to-end on Base (synthetic 0.05 USDC budget, cleaned up after): swap 0x81555b9b (0.05 USDC -> 56,651 $PERKOS) -> distribute (users 33,990 / platform 22,660) -> root 0x85c46f2e -> fund vault 0x96fe5cf2 -> setMerkleRoot 0x649a8a50. On-chain merkleRoot + vault balance + the claim endpoint (posted=true) all reconciled. --- App/app/api/admin/claims/mark-posted/route.ts | 41 +++++++++++++++++++ App/scripts/monthly-drop.mjs | 4 ++ 2 files changed, 45 insertions(+) create mode 100644 App/app/api/admin/claims/mark-posted/route.ts 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/scripts/monthly-drop.mjs b/App/scripts/monthly-drop.mjs index 79a36ee..51dcebf 100644 --- a/App/scripts/monthly-drop.mjs +++ b/App/scripts/monthly-drop.mjs @@ -112,4 +112,8 @@ const ph = await wallet.writeContract({ address: VAULT, abi: VAULT_ABI, function 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.`); From a97faaed4825435de7570275fe67f6813924d232 Mon Sep 17 00:00:00 2001 From: 0xJMC Date: Thu, 25 Jun 2026 23:37:26 -0400 Subject: [PATCH 16/16] =?UTF-8?q?fix(ci):=20pin=20@vitest/coverage-v8=20to?= =?UTF-8?q?=20vitest=203.x=20line=20(was=204.1.7=20=E2=86=92=20ERESOLVE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package.json had @vitest/coverage-v8@^4.1.7 (peer vitest@4.1.7) alongside vitest@^3.2.4, so 'npm ci' failed with ERESOLVE before any test ran. Pre-existing on main; this PR is just the first to trigger CI. Pin coverage-v8 to ^3.2.4 (resolves 3.2.6) + regenerate the lockfile. npm test is 'vitest run' (no coverage), so this only fixes install. All 205 unit tests pass locally. --- App/package-lock.json | 804 ++++++++++++++++++++++++++++++------------ App/package.json | 2 +- 2 files changed, 576 insertions(+), 230 deletions(-) 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" }