diff --git a/aastar-frontend/app/community/credibility/page.tsx b/aastar-frontend/app/community/credibility/page.tsx new file mode 100644 index 0000000..2c0c6a8 --- /dev/null +++ b/aastar-frontend/app/community/credibility/page.tsx @@ -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 ( +
+
+
+
+ {entry.name} + {entry.symbol} +
+
+ {shortenAddr(entry.token)} +
+
+ {c.isOverIssued && ( + + + {t("credibilityPage.overIssuedBadge")} + + )} +
+ +
+
+ + {t("credibilityPage.scoreLabel")} + + {score}/100 +
+
+
+
+
+ +
+
+
{t("credibilityPage.backing")}
+
${formatUsd(c.backingValueUSD)}
+
+
+
{t("credibilityPage.issued")}
+
${formatUsd(c.issuedValueUSD)}
+
+
+
{t("credibilityPage.cap")}
+
${formatUsd(c.effectiveCapUSD)}
+
+
+ + {c.isOverIssued && ( +

+ {t("credibilityPage.overIssuedHint")} +

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

+ + {t("credibilityPage.rules.title")} +

+

{t("credibilityPage.rules.intro")}

+ +
+ {Array.isArray(penalties) && + penalties.map((p) => ( +
+ {p.name} + — {p.desc} +
+ ))} +
+ +

+ {t("credibilityPage.rules.formulaTitle")} +

+

{t("credibilityPage.rules.formula")}

+ + {Array.isArray(ruleList) && ruleList.length > 0 && ( + + )} + +

{t("credibilityPage.rules.source")}

+
+ ); +} + +export default function CredibilityPage() { + const { t } = useTranslation(); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + +
+
+
+

{t("credibilityPage.title")}

+

{t("credibilityPage.subtitle")}

+
+ +
+ +
+ {loading && ( +
{t("credibilityPage.loading")}
+ )} + {!loading && error && ( +
+ {t("credibilityPage.error")}: {error} +
+ )} + {!loading && !error && entries.length === 0 && ( +
{t("credibilityPage.empty")}
+ )} + {!loading && + !error && + entries.map((entry) => )} +
+ + +
+
+ ); +} diff --git a/aastar-frontend/app/community/page.tsx b/aastar-frontend/app/community/page.tsx index 4bdf3f9..9e944f4 100644 --- a/aastar-frontend/app/community/page.tsx +++ b/aastar-frontend/app/community/page.tsx @@ -334,6 +334,29 @@ export default function CommunityPage() { )} + {/* Credibility disclosure entry */} +
+ +
+ {/* Community List */}

diff --git a/aastar-frontend/lib/i18n/locales/en.json b/aastar-frontend/lib/i18n/locales/en.json index d3dea16..e5637b1 100644 --- a/aastar-frontend/lib/i18n/locales/en.json +++ b/aastar-frontend/lib/i18n/locales/en.json @@ -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", diff --git a/aastar-frontend/lib/i18n/locales/zh.json b/aastar-frontend/lib/i18n/locales/zh.json index e4b34c1..db251a1 100644 --- a/aastar-frontend/lib/i18n/locales/zh.json +++ b/aastar-frontend/lib/i18n/locales/zh.json @@ -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": "加载社区数据失败", diff --git a/aastar-frontend/lib/sdk/credibility.ts b/aastar-frontend/lib/sdk/credibility.ts new file mode 100644 index 0000000..38dbc47 --- /dev/null +++ b/aastar-frontend/lib/sdk/credibility.ts @@ -0,0 +1,126 @@ +/** + * xPNTs economic-credibility disclosure — SDK boundary (CC-33). + * + * A community's xPNTs token exposes an on-chain, auto-computed credibility + * snapshot (CC-28, SP-side): a 0–100 backing-coverage score plus the raw USD + * accounting figures behind it. This module is a thin, read-only wrapper over the + * @aastar/sdk 0.43.0 (`@aastar/sdk/core`) typed views — no signing, no writes — + * so the disclosure page (`app/community/credibility`) only wires UI. + * + * - `xPNTsFactoryActions(factory)(client).getAllTokens()` enumerates the deployed + * community tokens (factory address from the SDK canonical table, never hardcoded). + * - `xPNTsTokenActions()(client).getCredibility({ token })` reads all five views + * pinned to a single block, so the snapshot is self-consistent. + * + * The three `*ValueUSD` fields are **18-decimal fixed-point USD** (USD × 1e18), + * NOT wei — verified on-chain (SDK CC-33): `formatUnits(v, 18)` for display, + * raw `bigint` compares for the over-issue verdict (no float). + * + * @module lib/sdk/credibility + */ +import { erc20Abi } from "viem"; +import type { Address, PublicClient } from "viem"; +import { + CHAIN_SEPOLIA, + getCanonicalAddresses, + xPNTsFactoryActions, + xPNTsTokenActions, + type Credibility, +} from "@aastar/sdk/core"; +import { ensureSdkConfig, getPublicClient } from "./client"; + +export type { Credibility }; + +/** A community xPNTs token plus its display metadata and credibility snapshot. */ +export interface CommunityCredibility { + /** The xPNTs token contract address. */ + token: Address; + /** ERC-20 name (falls back to the short address if the read reverts). */ + name: string; + /** ERC-20 symbol (falls back to "xPNTs" if the read reverts). */ + symbol: string; + /** Self-consistent on-chain credibility snapshot (5 views pinned to one block). */ + credibility: Credibility; +} + +/** Canonical xPNTs factory for the configured chain (from the SDK, not hardcoded). */ +export function getXPNTsFactory(chainId: number = CHAIN_SEPOLIA): Address { + ensureSdkConfig(chainId); + const factory = getCanonicalAddresses(chainId)?.xPNTsFactory; + if (!factory) { + throw new Error(`No canonical xPNTs factory for chain ${chainId}`); + } + return factory as Address; +} + +/** Enumerate every community xPNTs token deployed by the canonical factory. */ +export async function listCommunityTokens( + client?: PublicClient, + chainId: number = CHAIN_SEPOLIA +): Promise { + ensureSdkConfig(chainId); + const pc = client ?? getPublicClient(); + return xPNTsFactoryActions(getXPNTsFactory(chainId))(pc).getAllTokens(); +} + +/** + * One-shot credibility snapshot for a token — the five credibility views batched + * at a single block so `credibilityScore` can't drift out of sync with the USD + * figures. Optionally pin a specific block for deterministic/historical reads. + */ +export async function getCredibility( + token: Address, + client?: PublicClient, + chainId: number = CHAIN_SEPOLIA, + blockNumber?: bigint +): Promise { + ensureSdkConfig(chainId); + const pc = client ?? getPublicClient(); + return xPNTsTokenActions()(pc).getCredibility( + blockNumber === undefined ? { token } : { token, blockNumber } + ); +} + +/** Short 0x…abcd form for display fallbacks. */ +function shortAddr(addr: Address): string { + return `${addr.slice(0, 6)}…${addr.slice(-4)}`; +} + +/** Read ERC-20 name/symbol for a token, tolerating non-standard tokens. */ +export async function getTokenMeta( + token: Address, + client?: PublicClient +): Promise<{ name: string; symbol: string }> { + const pc = client ?? getPublicClient(); + const [name, symbol] = await Promise.all([ + pc.readContract({ address: token, abi: erc20Abi, functionName: "name" }).catch(() => shortAddr(token)), + pc.readContract({ address: token, abi: erc20Abi, functionName: "symbol" }).catch(() => "xPNTs"), + ]); + return { name, symbol }; +} + +/** + * Full disclosure feed: every community token with its metadata + credibility. + * A single failed token is surfaced as `null` so one bad token can't blank the + * whole page; callers filter it out. + */ +export async function listCommunityCredibility( + chainId: number = CHAIN_SEPOLIA +): Promise> { + ensureSdkConfig(chainId); + const pc = getPublicClient(); + const tokens = await listCommunityTokens(pc, chainId); + return Promise.all( + tokens.map(async (token): Promise => { + try { + const [meta, credibility] = await Promise.all([ + getTokenMeta(token, pc), + getCredibility(token, pc, chainId), + ]); + return { token, name: meta.name, symbol: meta.symbol, credibility }; + } catch { + return null; + } + }) + ); +}