Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
210 changes: 210 additions & 0 deletions aastar-frontend/app/community/credibility/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
"use client";

/**
* Community xPNTs economic-credibility disclosure (CC-33).
*
* Lists every community xPNTs token with its on-chain credibility score (0–100
* backing coverage) and an over-issue warning, then discloses the audit/slash
* rules behind the score (sourced from DVT `docs/AUDIT_SLASH_MODEL.md`). Pure
* read-only, client-side: data comes from `@aastar/sdk` typed views via
* `lib/sdk/credibility`, no wallet/signing required.
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { formatUnits } from "viem";
import {
ShieldCheckIcon,
ExclamationTriangleIcon,
ArrowPathIcon,
} from "@heroicons/react/24/outline";
import Layout from "@/components/Layout";
import { listCommunityCredibility, type CommunityCredibility } from "@/lib/sdk/credibility";

/** USD fields are 18-decimal fixed point (USD × 1e18) — format, don't treat as wei. */
function formatUsd(value: bigint): string {
const whole = formatUnits(value, 18);
const n = Number(whole);
if (!Number.isFinite(n)) return whole;
return n.toLocaleString(undefined, { maximumFractionDigits: 2 });
}

/** Score → semantic color band (score is a backing-coverage %). */
function scoreBand(score: number): { bar: string; text: string } {
if (score >= 80) return { bar: "bg-green-500", text: "text-green-700 dark:text-green-400" };
if (score >= 50) return { bar: "bg-amber-500", text: "text-amber-700 dark:text-amber-400" };
return { bar: "bg-red-500", text: "text-red-700 dark:text-red-400" };
}

function shortenAddr(addr: string): string {
return `${addr.slice(0, 6)}…${addr.slice(-4)}`;
}

function CredibilityRow({ entry }: { entry: CommunityCredibility }) {
const { t } = useTranslation();
const { credibility: c } = entry;
const score = Math.max(0, Math.min(100, c.credibilityScore));
const band = scoreBand(score);

return (
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 sm:p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-900 dark:text-white truncate">{entry.name}</span>
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">{entry.symbol}</span>
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 font-mono mt-0.5">
{shortenAddr(entry.token)}
</div>
</div>
{c.isOverIssued && (
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 dark:bg-red-900/40 px-2.5 py-1 text-xs font-medium text-red-700 dark:text-red-300 shrink-0">
<ExclamationTriangleIcon className="h-3.5 w-3.5" />
{t("credibilityPage.overIssuedBadge")}
</span>
)}
</div>

<div className="mt-3">
<div className="flex items-baseline justify-between">
<span className="text-xs text-gray-500 dark:text-gray-400">
{t("credibilityPage.scoreLabel")}
</span>
<span className={`text-sm font-bold ${band.text}`}>{score}/100</span>
</div>
<div className="mt-1 h-2 w-full rounded-full bg-gray-100 dark:bg-gray-700 overflow-hidden">
<div className={`h-full ${band.bar}`} style={{ width: `${score}%` }} />
</div>
</div>

<dl className="mt-3 grid grid-cols-3 gap-2 text-center">
<div>
<dt className="text-[11px] text-gray-400 dark:text-gray-500">{t("credibilityPage.backing")}</dt>
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">${formatUsd(c.backingValueUSD)}</dd>
</div>
<div>
<dt className="text-[11px] text-gray-400 dark:text-gray-500">{t("credibilityPage.issued")}</dt>
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">${formatUsd(c.issuedValueUSD)}</dd>
</div>
<div>
<dt className="text-[11px] text-gray-400 dark:text-gray-500">{t("credibilityPage.cap")}</dt>
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">${formatUsd(c.effectiveCapUSD)}</dd>
</div>
</dl>

{c.isOverIssued && (
<p className="mt-3 text-xs text-red-600 dark:text-red-400">
{t("credibilityPage.overIssuedHint")}
</p>
)}
</div>
);
}

function RulesDisclosure() {
const { t } = useTranslation();
const penalties = t("credibilityPage.rules.penalties", { returnObjects: true }) as Array<{
name: string;
desc: string;
}>;
const ruleList = t("credibilityPage.rules.ruleList", { returnObjects: true }) as string[];

return (
<section className="mt-8 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 p-4 sm:p-5">
<h2 className="flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<ShieldCheckIcon className="h-4 w-4 text-gray-500" />
{t("credibilityPage.rules.title")}
</h2>
<p className="mt-2 text-xs text-gray-600 dark:text-gray-400">{t("credibilityPage.rules.intro")}</p>

<div className="mt-3 space-y-2">
{Array.isArray(penalties) &&
penalties.map((p) => (
<div key={p.name} className="text-xs">
<span className="font-semibold text-gray-800 dark:text-gray-200">{p.name}</span>
<span className="text-gray-600 dark:text-gray-400"> — {p.desc}</span>
</div>
))}
</div>

<p className="mt-4 text-xs font-medium text-gray-700 dark:text-gray-300">
{t("credibilityPage.rules.formulaTitle")}
</p>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">{t("credibilityPage.rules.formula")}</p>

{Array.isArray(ruleList) && ruleList.length > 0 && (
<ul className="mt-3 list-disc pl-5 space-y-1 text-xs text-gray-600 dark:text-gray-400">
{ruleList.map((r, i) => (
<li key={i}>{r}</li>
))}
</ul>
)}

<p className="mt-4 text-[11px] text-gray-400 dark:text-gray-500">{t("credibilityPage.rules.source")}</p>
</section>
);
}

export default function CredibilityPage() {
const { t } = useTranslation();
const [entries, setEntries] = useState<CommunityCredibility[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const rows = await listCommunityCredibility();
setEntries(rows.filter((r): r is CommunityCredibility => r !== null));
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, []);

useEffect(() => {
void load();
}, [load]);

return (
<Layout>
<div className="max-w-3xl mx-auto px-4 py-6">
<div className="flex items-start justify-between gap-3">
<div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white">{t("credibilityPage.title")}</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{t("credibilityPage.subtitle")}</p>
</div>
<button
onClick={() => void load()}
disabled={loading}
className="inline-flex items-center gap-1.5 rounded-md border border-gray-300 dark:border-gray-600 px-3 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
>
<ArrowPathIcon className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
{t("credibilityPage.refresh")}
</button>
</div>

<div className="mt-5 space-y-3">
{loading && (
<div className="text-center text-sm text-gray-400 py-10">{t("credibilityPage.loading")}</div>
)}
{!loading && error && (
<div className="rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-700 dark:text-red-300">
{t("credibilityPage.error")}: {error}
</div>
)}
{!loading && !error && entries.length === 0 && (
<div className="text-center text-sm text-gray-400 py-10">{t("credibilityPage.empty")}</div>
)}
{!loading &&
!error &&
entries.map((entry) => <CredibilityRow key={entry.token} entry={entry} />)}
</div>

<RulesDisclosure />
</div>
</Layout>
);
}
23 changes: 23 additions & 0 deletions aastar-frontend/app/community/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,29 @@ export default function CommunityPage() {
)}
</section>

{/* Credibility disclosure entry */}
<section className="mb-6">
<button
onClick={() => router.push("/community/credibility")}
className="w-full flex items-center justify-between gap-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-700/60"
>
<div className="flex items-center gap-3">
<CheckBadgeIcon className="h-6 w-6 text-green-500 shrink-0" />
<div>
<p className="font-semibold text-gray-900 dark:text-white text-sm">
{t("credibilityPage.title")}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t("credibilityPage.subtitle")}
</p>
</div>
</div>
<span className="text-gray-400" aria-hidden="true">
</span>
</button>
</section>

{/* Community List */}
<section>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
Expand Down
33 changes: 33 additions & 0 deletions aastar-frontend/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,39 @@
"loadError": "Failed to load registry data",
"queryError": "Failed to query role for address"
},
"credibilityPage": {
"title": "Community xPNTs Credibility",
"subtitle": "The on-chain credibility score (0–100) and over-issue status of each community's xPNTs. Computed live from on-chain economic facts — no manual input.",
"refresh": "Refresh",
"loading": "Reading on-chain credibility…",
"error": "Failed to load",
"empty": "No community xPNTs deployed yet",
"overIssuedBadge": "Over-issued",
"overIssuedHint": "This community has issued more xPNTs value than its backing collateral — circulating value exceeds redeemable value, so holdings carry depreciation risk.",
"scoreLabel": "Credibility score (backing coverage)",
"backing": "Backing (USD)",
"issued": "Issued (USD)",
"cap": "Cap (USD)",
"rules": {
"title": "Where does the score come from? Audit & slash rules",
"intro": "The credibility score is a purely informational on-chain reputation signal (not a penalty). The DVT validator network has three independent, never-conflated actions on nodes:",
"penalties": [
{ "name": "SLASH (burn stake)", "desc": "Burns the operator's stake, irreversible. Only for objective, attributable, globally-verifiable on-chain economic fraud, decided by BLS-quorum consensus." },
{ "name": "JAIL (exclude)", "desc": "Stops fee and removes the node from the active set; self-heals. For objective on-chain liveness (offline) facts, auto-enforced by SuperPaymaster." },
{ "name": "REPUTATION (score)", "desc": "This public credibility score itself — no penalty, purely informational, computed live from on-chain views." }
],
"formulaTitle": "Credibility score formula (computed live on-chain)",
"formula": "score = min(100, backingValue ÷ issuedValue × 100), i.e. backing coverage %; flagged over-issued when issuedValue > effectiveCap. All three USD figures are 18-decimal fixed-point accounting units.",
"ruleList": [
"Over-issue is disclosed only as an on-chain credibility score, never slashed — the score and over-issued flag are computed by the contract and anyone can verify them directly on-chain.",
"Effective cap = per-industry baseline (factory-governed; a community can't self-pick a high baseline) × cap ratio + backing value.",
"Backing = the community's aPNTs staked in SuperPaymaster (redemption services via MyShop may be added later).",
"Going offline only jails (fee-stop / active-set removal), never slashes stake; an individual over their spend limit is refused at sign time, which also never slashes the operator's stake.",
"Forged slash proposals (proof-forgery) are not enforced — co-sign re-verification and on-chain aggregate rejection already block forgeries; residual spam falls to reputation."
],
"source": "Basis: DVT repo docs/AUDIT_SLASH_MODEL.md (CC-28 on-chain credibility views + the corrected four-rule model)."
}
},
"communityPage": {
"title": "Community Portal",
"loadError": "Failed to load community data",
Expand Down
33 changes: 33 additions & 0 deletions aastar-frontend/lib/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,39 @@
"loadError": "加载注册表数据失败",
"queryError": "查询该地址角色失败"
},
"credibilityPage": {
"title": "社区 xPNTs 可信度披露",
"subtitle": "每个社区积分(xPNTs)的链上可信度分(0–100)与是否超发。分数实时由链上经济事实自动算出,无人工干预。",
"refresh": "刷新",
"loading": "读取链上可信度…",
"error": "加载失败",
"empty": "暂无已部署的社区 xPNTs",
"overIssuedBadge": "已超发",
"overIssuedHint": "该社区发行的 xPNTs 价值已超过其抵押背书 —— 实际流通 > 可兑付价值,持有存在贬值风险。",
"scoreLabel": "可信度分(背书覆盖率)",
"backing": "背书 (USD)",
"issued": "已发行 (USD)",
"cap": "发行上限 (USD)",
"rules": {
"title": "分数怎么来的?审计与惩罚规则",
"intro": "可信度分是纯信息性的链上声誉指标(不是惩罚)。DVT 验证网络对节点有三类处置,各自独立、切勿混淆:",
"penalties": [
{ "name": "SLASH(罚没质押)", "desc": "烧掉运营者的质押,不可逆。仅针对客观、可归因、全网可验证的链上经济欺诈,需 BLS 多签共识。" },
{ "name": "JAIL(隔离)", "desc": "停止计费并移出活跃集,可自愈。针对客观的链上掉线事实,由 SuperPaymaster 自动执行。" },
{ "name": "REPUTATION(声誉)", "desc": "就是这个公开的可信度分,不含任何惩罚,纯信息披露,由链上视图实时计算。" }
],
"formulaTitle": "可信度分公式(链上实时计算)",
"formula": "可信度分 = min(100, 背书价值 ÷ 已发行价值 × 100),即背书覆盖率百分比;当「已发行价值 > 有效上限」时标记为已超发。三项 USD 数值均为 18 位精度定点记账。",
"ruleList": [
"超发只作为链上可信度分披露,不触发罚没 —— 分数与「已超发」标记由合约自动算出,任何人可直接读链核对。",
"有效上限 = 行业基线(工厂治理,社区不可自选高基线)× 上限系数 + 背书价值。",
"背书 = 社区在 SuperPaymaster 质押的 aPNTs(未来可加 MyShop 兑付服务)。",
"掉线只隔离(停计费/移出活跃集)不罚质押;个人超额消费在签名前被拒,也不罚运营者质押。",
"伪造罚没提案(proof-forgery)暂未启用 —— 联签复验 + 链上聚合拒绝已能挡住伪造,残余刷屏归入声誉分。"
],
"source": "规则依据:DVT 仓 docs/AUDIT_SLASH_MODEL.md(CC-28 链上可信度视图 + 修正后的四规则模型)。"
}
},
"communityPage": {
"title": "社区门户",
"loadError": "加载社区数据失败",
Expand Down
Loading
Loading