Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
a50410c
feat(credits): per-chain balances — payment-chain = earning-chain
JulioMCruz Jun 22, 2026
5893f18
feat(admin): vault owner-ops panel — setRewardToken from the connecte…
JulioMCruz Jun 22, 2026
50adfe3
docs(llms): multi-chain (Base+Celo), on-chain deposit, payChain, clai…
JulioMCruz Jun 22, 2026
f281f41
fix(dashboard): wallet connect button + active-wallet indicator
JulioMCruz Jun 23, 2026
932241d
fix(deposit): call Stack /verify + /settle (not /api/v2/x402/*) + add…
JulioMCruz Jun 23, 2026
000a801
fix(dashboard): refresh credit balance after a deposit
JulioMCruz Jun 23, 2026
5c40af3
feat(deposit): enable Celo — per-token EIP-712 domain + manual x402 c…
JulioMCruz Jun 23, 2026
9588f0a
fix(deposit): handle facilitator timeout + give Celo settle 60s (was …
JulioMCruz Jun 23, 2026
c1253d6
fix(deposit): Celo signs gaslessly on smart wallets + ENS via CORS-fr…
JulioMCruz Jun 24, 2026
f3421fd
feat(rewards): monthly $PERKOS usage-drop — accounting + dry-run calc…
JulioMCruz Jun 24, 2026
f601524
feat(rewards): wire Uniswap Trading API quote into the drop dry-run (…
JulioMCruz Jun 26, 2026
dedfa24
feat(rewards): distribute the bought $PERKOS into claimable cumReward…
JulioMCruz Jun 26, 2026
f713e28
feat(rewards): frame the $PERKOS as a usage drop in the dashboard (Ph…
JulioMCruz Jun 26, 2026
5d4727a
feat(rewards): month-end drop orchestrator script (ties all legs toge…
JulioMCruz Jun 26, 2026
b527f00
feat(claims): mark-posted endpoint + orchestrator step 7
JulioMCruz Jun 26, 2026
a97faae
fix(ci): pin @vitest/coverage-v8 to vitest 3.x line (was 4.1.7 → ERES…
JulioMCruz Jun 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions App/app/api/admin/claims/mark-posted/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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 });
}
11 changes: 8 additions & 3 deletions App/app/api/admin/credits/grant/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 });
}
25 changes: 25 additions & 0 deletions App/app/api/admin/errors/route.ts
Original file line number Diff line number Diff line change
@@ -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() });
}
66 changes: 66 additions & 0 deletions App/app/api/admin/rewards/distribute/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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 });
}
}
66 changes: 66 additions & 0 deletions App/app/api/admin/rewards/drop/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* GET /api/admin/rewards/drop?month=YYYY-MM&chain=base|celo — DRY-RUN of the
* monthly $PERKOS usage drop. Read-only: shows the budget (5% accrued that
* month), the platform/user split, and each wallet's usage-weighted share. No
* trade, no writes — what a buyback WOULD pay. Admin-gated.
*
* `month` defaults to the current UTC month; `chain` defaults to base.
*/
import { requireAdmin } from "../../../../../lib/admin";
import { withDb } from "../../../../../lib/db";
import type { PayNetwork } from "../../../../../lib/payments";
import { computeMonthlyDrop } from "../../../../../lib/rewardsDrop";
import { quoteBuyback } from "../../../../../lib/uniswapTrade";

export const dynamic = "force-dynamic";

const TREASURY = (process.env.KNOWLEDGE_TREASURY_ADDRESS || "0x3f0D7b9916212fA0A9Ac0EF8f72a25EB56F7046C").trim();

function currentMonthUtc(): string {
const now = new Date();
const y = now.getUTCFullYear();
const m = String(now.getUTCMonth() + 1).padStart(2, "0");
return `${y}-${m}`;
}

export async function GET(request: Request) {
const unauthorized = requireAdmin(request);
if (unauthorized) return unauthorized;

const url = new URL(request.url);
const month = url.searchParams.get("month")?.trim() || currentMonthUtc();
const chain = url.searchParams.get("chain")?.trim() || "base";

try {
const drop = await withDb((c) => computeMonthlyDrop(c, { month, chain }));

// Quote the budget → $PERKOS via the Uniswap Trading API (read-only, no
// trade), then apply the platform/user split + per-wallet shares so the
// admin sees exactly what $PERKOS each side would get. Best-effort: needs
// UNISWAP_API_KEY; surfaces an `error` instead of failing the whole view.
let buyback: unknown = null;
if (drop.budgetUsdc > 0) {
const q = await quoteBuyback({ chain: drop.chain as PayNetwork, amountUsdc: drop.budgetUsdc, swapper: TREASURY });
if (q.ok && q.amountOutPerkosFloat != null) {
const perkosTotal = q.amountOutPerkosFloat;
const platformPerkos = (perkosTotal * drop.platformBps) / 10000;
const userPerkos = perkosTotal - platformPerkos;
buyback = {
perkosTotal,
platformPerkos,
userPerkos,
perWallet: drop.wallets.map((w) => ({ wallet: w.wallet, sharePct: w.sharePct, perkos: userPerkos * w.sharePct })),
};
} else {
buyback = { error: q.error ?? "quote_failed" };
}
}

return Response.json({ ok: true, dryRun: true, drop, buyback, ts: new Date().toISOString() });
} catch (e) {
return Response.json(
{ ok: false, error: e instanceof Error ? e.message : "compute_failed" },
{ status: 400 },
);
}
}
32 changes: 31 additions & 1 deletion App/app/api/deposit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
*/
import { credit } from "../../../lib/credits";
import { withDb } from "../../../lib/db";
import { logError } from "../../../lib/errlog";
import {
buildPaymentRequirements,
decodePaymentHeader,
Expand Down Expand Up @@ -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<string, unknown>;
const inner = (pl.payload ?? {}) as Record<string, unknown>;
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 });
}

Expand All @@ -90,16 +117,19 @@ export async function POST(request: Request) {
settle.transaction,
]);
if (dup.rowCount) {
const r = await c.query(`SELECT balance::float8 b FROM agent_accounts WHERE lower(wallet)=lower($1)`, [payee]);
const r = await c.query(`SELECT balance::float8 b FROM agent_accounts WHERE lower(wallet)=lower($1) AND chain=$2`, [payee, net]);
return { balance: r.rows[0]?.b ?? 0, deduped: true };
}
}
// Credit the deposit ON THE CHAIN it was paid — that's the chain those
// credits will earn providers on when spent.
const balance = await credit(c, {
wallet: payee,
amount,
reason: "deposit",
deposited: true,
x402ReceiptId: settle.transaction,
chain: net,
});
return { balance, deduped: false };
});
Expand Down
13 changes: 10 additions & 3 deletions App/app/skill/query/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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 };
}
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -166,6 +172,7 @@ export async function POST(request: Request) {
requesterWallet: access.wallet,
researcherWallets: attr.creditedByWallet.map((c) => c.wallet),
researcherBps: cfg.rewardResearcherBps,
chain: payChain,
});
} catch {
// attribution/credit/fee accounting is secondary to the response — swallow.
Expand Down
Loading
Loading