diff --git a/ATTESTATION-INTEGRATION.md b/ATTESTATION-INTEGRATION.md new file mode 100644 index 00000000..b1915fb7 --- /dev/null +++ b/ATTESTATION-INTEGRATION.md @@ -0,0 +1,292 @@ +# Attestation Integration — Surfacing Package Attestations in MVR + +This document is the implementation plan for displaying package attestations on +the MVR web app, sourced from a Sui attestation registry. It records the +decisions reached during design exploration and the steps to build from. + +> **Two repos.** Paths under `app/` and `crates/` are in **this (mvr) repo**. +> Paths like `DESIGN.md`, `CONVENTIONS.md`, `ts/`, `packages/`, `scripts/` +> refer to the **attestation-registry repo** (`sui-attestation-registry`), +> which defines the on-chain registry, the Display-field conventions, and the +> TypeScript read library this builds on. See that repo's `DESIGN.md` for +> on-chain rationale and `CONVENTIONS.md` for the Display conventions. + +## Goal + +When browsing a subject package on the MVR web app, show the attestations made +about it by a curated, hardcoded set of **trusted attestor packages** — +rendered from each attestation's on-chain Display. Stand the whole thing up +against a simulated (localnet) network, faithfully enough that the same code +path runs in production. + +**Primary outcome: an upstreamable integration** (fits MVR's architecture and +conventions), not a throwaway demo. + +## How MVR fetches data (findings) + +MVR's frontend (`app/`, Next.js + dapp-kit + react-query) reads from two +independent sources: + +1. **`mvr-api` (REST)** — name resolution and search. `useResolveMvrName` → + `GET {mvrEndpoint}/v1/names/{name}` returns a `ResolvedName` + (`package_address`, `package_info`, `version`, …). Backed by Postgres, which + `mvr-indexer` normally fills from checkpoints. +2. **dapp-kit `SuiClient` (JSON-RPC)** — on-chain reads (versions, deps, + package-info objects). `DefaultClients` (`app/src/components/providers/client-provider.tsx`) + hardcodes per-network URLs and already includes a `localnet` client plus + `SuiGraphQLClient`s. + +**Reading attestations does not touch the mvr-api backend** — it is a pure +chain read (derive the Box address from the subject, list its owned +`Attestation` objects). So the attestation surface is fundamentally a +frontend feature pointed at whatever chain the `SuiClient` uses. + +## Locked decisions + +| # | Decision | Rationale | +|---|----------|-----------| +| D1 | **Approach A**: seed Postgres + localnet | Faithful to how the app fetches; isolates us from MVR's on-chain name-registration contracts. The `crates/mvr-api/tests/mvr_test_cluster.rs` `setup_dummy_data` pattern inserts directly into `name_records`/`packages`/`package_infos` — no indexer, no live chain needed for resolution. | +| D2 | **JSON-RPC**, not gRPC | MVR is pinned to `@mysten/sui@1.39.0`, which has no `/grpc` export. Adding `SuiGrpcClient` forces a 1.x→2.x SDK upgrade dragging dapp-kit `0.19→1.0`, kiosk, suins — a repo-wide modernization that dwarfs (and destabilizes) this feature. gRPC stays a separate, future MVR initiative; the attestation-registry repo's `ts/src/queries.ts` (gRPC) ports back trivially when it lands. | +| D3 | **Subject = `package_address`** (resolved version), not original ID | Directly available on `ResolvedName`; the demo seeds the on-chain attestation against the same value. | +| D4 | **Trusted set = original package IDs** | Mirrors on-chain `attester_of() = type_name::original_id()`. Any type from any version of a trusted package counts. | +| D5 | **Option 2**: server-side exact-type `MatchAny`, trusted set = `Attestation` types each trusted attester **registered a Display for** | The JSON-RPC `StructType` filter matches type params all-or-nothing (`sui-json-rpc-types` `SuiObjectDataFilter::matches`) — no inner-package prefix. Spam-resistance therefore requires the exact trusted-type list. "Registered a Display" is the deliberate, finite, evolution-friendly definition, and Display registration carries the same `internal::Permit` bytecode identity as `attest`, so it can't be forged. | +| D6 | **Display-gate**: only count `Attestation` with a registered Display | Legibility/trust signal; already implied by D5. | +| D7 | **Identity from config; content host-constrained** | Attester brand icon/name come from the trusted-list config, never from on-chain data (defeats within-whitelist impersonation). Per-attestation `image_url`/`link` may come from Display, constrained to the attester's declared `domains`. | +| D8 | **Show revoked/expired, de-emphasized** | A revoked audit is itself information; a trust surface should be transparent. | +| D9 | **Demo == production code path** | `sui start --with-graphql` serves GraphQL on localnet, so lineage/Display enumeration runs identically locally and in prod. Only mvr-api's name→address rows are synthetic; all chain state the read touches is real. | + +## Architecture + +``` +Browser (MVR app, @mysten/sui 1.39 JSON-RPC) + │ + ├─(REST)──► mvr-api ──► Postgres (seeded: name_records → package_address) [resolution only] + │ + ├─(JSON-RPC)──► localnet fullnode :9000 + │ • getOwnedObjects(boxAddr, MatchAny[Attestation]) [the attestations] + │ + └─(GraphQL)───► localnet graphql (--with-graphql) + • packageVersions(address) → trusted lineage / original id + • objects(type: Display<…Attestation>) → trusted type set [cached per attester] +``` + +`boxAddr = deriveObjectID(registryId, '0x2::object::ID', subjectBytes)` — the +client-agnostic derivation in the attestation-registry repo's `ts/src/boxes.ts`, +ports to MVR as-is. + +## Implementation sections + +### Section 1 — Demo environment (the simulated network) + +1. `sui start --with-faucet --with-graphql` (localnet + faucet + GraphQL/indexer). +2. Publish on localnet: `attestation_registry` (creates the shared `Registry` + in `init`), `audit_example`/`vuln_example` attestors, and subject package(s). + **Exercise evolution**: upgrade an attestor to add a second schema type + (e.g. `AuditV2`) and register its Display, so both surface under one attester. + Create `Attestation`/`Attestation`/`Attestation` + about the subjects, and revoke one (to show the de-emphasized state). Extend + the attestation-registry repo's `scripts/run-demo.sh` + `ts/demo.ts`; emit the + published IDs for step 3. +3. Seed Postgres (test-cluster style): run `mvr-schema` `MIGRATIONS`, insert + `name_records` + `packages` + `package_infos` so a demo name (`@demo/subject`) + resolves to the **same `package_address`** published in step 2. Lift + `mvr_test_cluster.rs::setup_dummy_data` into a standalone seeding binary. +4. Run `mvr-api` against that Postgres (`--network mainnet`; cosmetic, resolution + is a DB read). +5. Point the frontend locally: override the `mainnet` slot of `DefaultClients` + (SuiClient URL → localnet, `mvrEndpoints.mainnet` → local mvr-api, graphql → + local). Keep using the `mainnet` slot rather than adding a UI network — the + feature stays network-generic; the demo is "mainnet, repointed." + +**Invariant:** seeded `name_records.package_address` == published subject ID on +localnet, so resolving the name lands on a chain object whose Box has attestations. + +### Section 2 — Attestation read layer (JSON-RPC) + +New code in MVR (`app/src`): + +- **`lib/constants.ts`**: `attestationRegistryPkg`, `attestationRegistryId` + (per network; demo fills `mainnet` with the localnet registry id), and + `trustedAttestors: { originalId, name, iconUrl, domains? }[]`. +- **`boxAddress` helper**: port the attestation-registry repo's `ts/src/boxes.ts` + (pure `deriveObjectID`). +- **Trusted-type resolver** (cached per attester, GraphQL): + 1. For each trusted `originalId`, get its lineage via `packageVersions`. + 2. Enumerate `Attestation` types the lineage registered Displays for — + either `objects(filter:{type:"0x2::display_registry::Display<…attestation_registry::Attestation>"})` + filtered to trusted lineages, or per-attester datatypes → derived + `Display>` existence check. **Spike: confirm GraphQL generic + type-filter matching; pick the mechanism.** + 3. Result: exact `trustedAttestationTypes: string[]`. +- **`hooks/useGetAttestations.ts`**: `getOwnedObjects(boxAddr, { MatchAny: + trustedAttestationTypes.map(StructType) }, { showType, showDisplay, showContent })`, + paginated → map each `SuiObjectResponse` into the `AttestationInfo` shape + (`{ id, version, digest, type, display, content }`; JSON-RPC nests Display + under `data.display.data`). +- **Effectiveness**: port the attestation-registry repo's `ts/src/conventions.ts` + (`isEffective`, `active`/`expires_at`/`requires`) — operates purely on + `display` + `id`, so it drops in unchanged. + +### Section 3 — UI surface + +- **New "Attestations" tab** in `SinglePackage.tsx`'s `Tabs` array + (`key`/`title`/`icon`/`component` + a `label` count badge like `DependencyCount`; + the non-zero count is the at-a-glance trust signal). +- **`SinglePackageAttestations`** (Dependencies-tab idiom: `Accordion` + + `LoadingState` + `EmptyState`), data via `useGetAttestations(name.package_address, network)`: + - **Group by trusted attester** — section per attester, headed by config + `name` + `originalId` + config `iconUrl`. Evolution shows here (`Audit` and + `AuditV2` under one attester). + - **Row**: Display `name` + `description`; the **exact `T`** (monospace, + truncated + tooltip); effectiveness badge (active / **revoked** / expired / + requires-unmet) from `conventions.ts`, ineffective shown de-emphasized. + - `image_url` / `link` rendered per Section 4 hygiene rules. +- **(Phase 2) Sidebar trust badge** in `SinglePackageSidebar` — compact + "✓ Attested by N trusted attestors"; same hook (react-query dedupes). + +### Section 4 — Convention additions (attestation-registry repo: `CONVENTIONS.md` / `ts/src/conventions.ts`) + +Add two **optional** conventions using the standard Sui Display keys (so +attestations render in any Display-aware tool, not just MVR): + +- **`image_url`** — per-attestation content (badge/grade/report thumbnail). +- **`link`** — URL to the full report/detail. + +Security (D7): identity icon/name come from config, never Display. For +`image_url`/`link` from Display: https-only, `referrerPolicy="no-referrer"`, +`rel="noopener noreferrer"`, render destination host visibly, and **constrain +the host to the attester's configured `domains`** (soft allowlist; default to +hygiene-only if an attester declares no domains). + +## Build order (milestones) + +- **M0 ✅** — Localnet env up; packages published (incl. an upgraded attester); + attestations created + one revoked; IDs emitted (attestation-registry repo, + commit `ff6845b`). +- **M1 ✅ (data path)** — Postgres seeded + mvr-api running + frontend repointed; + `@demo/subject` resolves to the localnet package address (verified). Visual + page render is confirmed alongside M2 (the Attestations tab), which is where + there's something attestation-specific to see. +- **M2 ✅** — Read hook with a **client-side lineage filter** (proves the + end-to-end pipeline; not yet spam-proof) + the Attestations tab rendering + Display fields and effectiveness. Verified: the tab shows the AuditV2 + (matched via the upgraded lineage) as ineffective post-revoke, and the + Vulnerability as effective. +- **M3 ✅** — Spam-proof **server-side `MatchAny`**. Spike outcome: GraphQL's + `objects` type filter only matches package/module/full-name/full-instantiation + (so `Display>` can't be matched as a prefix, and it needs + GraphQL infra anyway). Took a simpler **JSON-RPC-only** path instead: enumerate + each trusted attester lineage's `store` types via + `getNormalizedMoveModulesByPackage`, build the exact `Attestation` set, and + `getOwnedObjects(box, { MatchAny })`. Untrusted attestations are never + returned; trusted-but-undisplayed types (e.g. `InternalNote`) are returned but + dropped by the read-time Display-gate. No GraphQL, no localnet restart. + Verified against localnet (Untrusted excluded server-side). +- **M4** — `image_url`/`link` conventions + host-allowlist policing; sidebar + trust badge. + +## Running locally (M0–M1) + +Three terminals; the first holds the localnet + published packages + attestations. + +```bash +# 1) attestation-registry repo: localnet + publish + upgrade + attest, kept up. +# WITH_GRAPHQL=1 starts localnet GraphQL on :9125 (the frontend reads need it). +KEEP_ALIVE=1 WITH_GRAPHQL=1 bash scripts/run-demo.sh # writes demo-ids.json, holds :9000/:9125 + +# 2) mvr repo: real mvr-api over an ephemeral Postgres, seeded from demo-ids.json. +# Pass the path to the attestation-registry checkout's demo-ids.json (written +# by its run-demo.sh in step 1). +cargo run -p mvr-api --example demo_server -- \ + --demo-ids /demo-ids.json --port 8000 + +# 3) mvr repo: the frontend, all networks repointed at the local stack via +# app/.env (NEXT_PUBLIC_LOCAL_RPC_URL=http://127.0.0.1:9000, +# NEXT_PUBLIC_LOCAL_MVR_ENDPOINT=http://127.0.0.1:8000) so it never touches +# live Sui infra. Browse http://localhost:3000/package/@demo/subject +pnpm --dir app install && pnpm --dir app dev +``` + +Resolution check: `curl http://127.0.0.1:8000/v1/names/@demo/subject` returns +the localnet `package_address`. The demo server lives at +`crates/mvr-api/examples/demo_server.rs`; the frontend override is in +`app/src/components/providers/client-provider.tsx` (see `app/.env.example`). + +## Task checklist + +- [x] Extend the attestation-registry repo's `scripts/run-demo.sh` / `ts/demo.ts`: + publish + upgrade attester (`AuditV2`), create + revoke attestations, emit + published IDs. +- [x] Standalone Postgres seeder (lift `setup_dummy_data`) → `name_records` + pointing at published subject IDs. (`examples/demo_server.rs`) +- [x] Local run recipe: localnet + mvr-api + frontend env overrides. +- [x] Attestation config (`lib/attestations.ts`, env `NEXT_PUBLIC_ATTESTATION_CONFIG` + generated from `demo-ids.json` by `scripts/write-demo-env.sh`). +- [x] Port `boxAddress`; add JSON-RPC `AttestationInfo` mapper. +- [x] Spike: GraphQL type-filter — concluded JSON-RPC `MatchAny` over types + enumerated from `getNormalizedMoveModulesByPackage` is simpler (no GraphQL). +- [x] Trusted-type resolver (`resolveTrustedTypes`, cached per config); read via + server-side `MatchAny`. +- [x] `useGetAttestations` hook + port `conventions.ts`. +- [x] Attestations tab + count label; group-by-attester; row with exact `T`, + effectiveness, de-emphasized ineffective. +- [ ] `image_url`/`link` conventions in `CONVENTIONS.md` + `conventions.ts`; + host-allowlist rendering in the tab. +- [ ] Sidebar trust badge (phase 2). + +## Later passes (post-M2 UI feedback) + +- **Pass 1 ✅** — polarity convention (positive/negative), Trust Signals tab + with separate Vulnerabilities/Audits sections + per-kind count pills + + attester avatars; negative test data (untrusted attester + undisplayed type) + proving both filters. +- **Pass 2 ✅** — negative **propagation** (a dependency's effective vulns + surface on its dependents; seeded `subject → dependency` edge); CVSS + `severity` convention with severity-sorted, band-colored vulnerabilities; + friendly attester names + MVR-page links for attesters. +- **Pass 3** — `requires`/propagation provenance + an attestation detail view + ("why ineffective", which required attestation was revoked). +- **Pass 4 ✅** — reverse "Issued" tab on attester pages: GraphQL + `objects(filter:{type})` over the attester's `Attestation` types → + issued attestations grouped by subject (linked to each subject's page), + Display-gated, with revoked/expired entries shown inactive. Tab gated on + whitelist membership (free in-memory check; no per-package probing). + +## Out of scope / follow-ups + +- **Web-of-trust whitelist bootstrap.** Replace the hardcoded `trustedAttestors` + with on-chain meta-attestations: MVR defines a `TrustedAuditor` schema and + issues `Attestation` about auditor packages; the only + hardcoded value becomes MVR's own attester package id (the trust root). Per + attestation, check whether its attester package carries an effective + `TrustedAuditor` attestation from MVR (a per-attester lookup, dynamic and + revocable). Non-transitive to start. **Deferred** pending a team discussion: + the per-attester on-chain lookups add RPC roundtrips on the read path, and we + want to scope that (batching/caching) before replacing the hardcoded list. +- **`summary` vs `description` convention.** A short `summary` field for list + rows, separate from a fuller `description`, if on-chain description size + becomes a concern. Undecided. +- `image_url`/`link` host-allowlisting (constrain to the attester's declared + domains) — currently https-only. +- **Read-path round-trip reduction** (fine at local/demo scale; revisit for + real-network latency). All three are latency, not correctness: + - *Batch the sequential reads.* `fetchTrustedAttestations`, + `enumerateAttestationTypes`, and `useIssuedAttestations` issue their + `getObject`/`getOwnedObjects`/per-type GraphQL calls one at a time in + `for…await` loops, so round-trips ≈ latency. Use `multiGetObjects` for the + re-reads and a single aliased query (or an `Any` type filter) for the + per-type GraphQL. + - *Reuse the trusted-type cache on the Issued path.* `useIssuedAttestations` + calls `enumerateAttestationTypes` directly instead of going through the + `resolveTrustedTypes` module cache, so a cold Issued page re-runs + `getNormalizedMoveModulesByPackage` per lineage version. + - *Drop the redundant Display re-read on the Issued path.* The reverse query + already pulls `contents.json`; we then `getObject` each result again purely + for server-rendered Display. Fetching `display { key value }` in the same + GraphQL query removes the ~1-per-object JSON-RPC re-reads (Issued page would + go from ~7 JSON-RPC + 4 GraphQL to just the per-type GraphQL). +- gRPC read path (revisit when MVR moves to `@mysten/sui` 2.x). +- Full `mvr-indexer`-on-localnet stack (D1 seeds Postgres directly instead). +- Adding a first-class `localnet` network to the MVR UI (the demo repoints + `mainnet`). +- Upstream PR: trusted-attestor list as real config vs. hardcoded constant. diff --git a/app/.env.example b/app/.env.example index adfe8367..78881a68 100644 --- a/app/.env.example +++ b/app/.env.example @@ -12,3 +12,9 @@ # Example: # SERVERVAR="foo" # NEXT_PUBLIC_CLIENTVAR="bar" + +# Optional: point ALL networks at a local stack (a localnet + the attestation +# demo server) so the app doesn't depend on live Sui infra. Leave unset for +# production. See ATTESTATION-INTEGRATION.md. +# NEXT_PUBLIC_LOCAL_RPC_URL="http://127.0.0.1:9000" +# NEXT_PUBLIC_LOCAL_MVR_ENDPOINT="http://127.0.0.1:8000" diff --git a/app/public/demo-attestors/auditor.svg b/app/public/demo-attestors/auditor.svg new file mode 100644 index 00000000..79d0bd70 --- /dev/null +++ b/app/public/demo-attestors/auditor.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/public/demo-attestors/scanner.svg b/app/public/demo-attestors/scanner.svg new file mode 100644 index 00000000..d100645b --- /dev/null +++ b/app/public/demo-attestors/scanner.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/app/attestors/page.tsx b/app/src/app/attestors/page.tsx new file mode 100644 index 00000000..b973b605 --- /dev/null +++ b/app/src/app/attestors/page.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { PlainPageLayout } from "@/components/layouts/PlainPageLayout"; +import { Text } from "@/components/ui/Text"; +import { AttesterAvatar } from "@/components/single-package/SinglePackageTrustSignals"; +import { attestationConfig, type TrustedAttestor } from "@/lib/attestations"; +import { beautifySuiAddress } from "@/lib/utils"; + +export default function TrustedAttestorsPage() { + const cfg = attestationConfig(); + const attestors = cfg?.trustedAttestors ?? []; + + return ( + +
+
+ + Trusted attestors + + + Attestations are surfaced only from this curated set of attesters. + Trust is a consumer-side choice — these are the packages MVR + recognizes as authoritative sources of audits and vulnerability + disclosures. + +
+ + {attestors.length === 0 ? ( + + No trusted attestors are configured. + + ) : ( +
+ {attestors.map((a) => ( + + ))} +
+ )} +
+
+ ); +} + +function AttestorCard({ attestor }: { attestor: TrustedAttestor }) { + return ( +
+ +
+ + {attestor.name} + + {attestor.mvrName ? ( + + + {attestor.mvrName} + + + ) : ( + + {beautifySuiAddress(attestor.originalId)} + + )} +
+
+ ); +} diff --git a/app/src/components/providers/client-provider.tsx b/app/src/components/providers/client-provider.tsx index 015f8442..068bb5e9 100644 --- a/app/src/components/providers/client-provider.tsx +++ b/app/src/components/providers/client-provider.tsx @@ -33,15 +33,23 @@ export type Clients = { }; }; +// When set (the local demo stack — a localnet + the attestation demo server), +// ALL networks are pointed at it so the app does not depend on live Sui infra. +// Unset in production, where the per-network defaults apply. +// See ATTESTATION-INTEGRATION.md. +const LOCAL_RPC = process.env.NEXT_PUBLIC_LOCAL_RPC_URL; +const LOCAL_MVR = process.env.NEXT_PUBLIC_LOCAL_MVR_ENDPOINT; +const LOCAL_GRAPHQL = process.env.NEXT_PUBLIC_LOCAL_GRAPHQL; + const mainnet = new SuiClient({ - url: "https://suins-rpc.mainnet.sui.io:443", + url: LOCAL_RPC ?? "https://suins-rpc.mainnet.sui.io:443", network: "mainnet", }); export const DefaultClients: Clients = { mainnet, testnet: new SuiClient({ - url: "https://suins-rpc.testnet.sui.io", + url: LOCAL_RPC ?? "https://suins-rpc.testnet.sui.io", network: "testnet", }), devnet: new SuiClient({ url: getFullnodeUrl("devnet"), network: "devnet" }), @@ -57,19 +65,19 @@ export const DefaultClients: Clients = { }, graphql: { mainnet: new SuiGraphQLClient({ - url: "https://graphql.mainnet.sui.io/graphql", + url: LOCAL_GRAPHQL ?? "https://graphql.mainnet.sui.io/graphql", }), testnet: new SuiGraphQLClient({ - url: "https://graphql.testnet.sui.io/graphql", + url: LOCAL_GRAPHQL ?? "https://graphql.testnet.sui.io/graphql", }), }, mvrEndpoints: { - mainnet: "https://mainnet.mvr.mystenlabs.com", - testnet: "https://testnet.mvr.mystenlabs.com", + mainnet: LOCAL_MVR ?? "https://mainnet.mvr.mystenlabs.com", + testnet: LOCAL_MVR ?? "https://testnet.mvr.mystenlabs.com", }, mvrExperimentalEndpoints: { - mainnet: "https://qa.mainnet.mvr.mystenlabs.com", - testnet: "https://qa.testnet.mvr.mystenlabs.com", + mainnet: LOCAL_MVR ?? "https://qa.mainnet.mvr.mystenlabs.com", + testnet: LOCAL_MVR ?? "https://qa.testnet.mvr.mystenlabs.com", }, }; diff --git a/app/src/components/single-package/SinglePackage.tsx b/app/src/components/single-package/SinglePackage.tsx index 15334ddd..6bdbbaec 100644 --- a/app/src/components/single-package/SinglePackage.tsx +++ b/app/src/components/single-package/SinglePackage.tsx @@ -12,6 +12,14 @@ import { ReadMeRenderer } from "./ReadMeRenderer"; import { SinglePackageDependencies } from "./SinglePackageDependencies"; import { SinglePackageDependents } from "./SinglePackageDependents"; import { SinglePackageVersions } from "./SinglePackageVersions"; +import { + SinglePackageTrustSignals, + TrustSignalCount, +} from "./SinglePackageTrustSignals"; +import { + SinglePackageIssued, + IssuedCount, +} from "./SinglePackageIssued"; import { DependenciesIconSelected } from "@/icons/single-package/DependenciesIcon"; import { DependendsIconSelected } from "@/icons/single-package/DependendsIcon"; import { DependenciesIconUnselected } from "@/icons/single-package/DependenciesIcon"; @@ -23,6 +31,24 @@ import { ReadMeIconUnselected } from "@/icons/single-package/ReadMeIcon"; import { SinglePackageTab } from "@/utils/types"; import { AnalyticsIconUnselected } from "@/icons/single-package/AnalyticsIcon"; import { AnalyticsIconSelected } from "@/icons/single-package/AnalyticsIcon"; +import { attestationConfig, isConfiguredAttestor } from "@/lib/attestations"; + +// Simple shield-check glyph for the Trust Signals tab; reused for both states. +const TrustSignalsIcon = () => ( + + + + +); + +// Upload/outbox glyph for the Issued tab (attestations this package emits). +const IssuedIcon = () => ( + + + + + +); export const Tabs: SinglePackageTab[] = [ { @@ -68,6 +94,26 @@ export const Tabs: SinglePackageTab[] = [ ) => , component: (name: ResolvedName) => , }, + { + key: "trust-signals", + title: "Security", + selectedIcon: , + unselectedIcon: , + label: (address: string, network: "mainnet" | "testnet") => ( + + ), + component: (name: ResolvedName) => , + }, + { + key: "issued", + title: "Attestations", + selectedIcon: , + unselectedIcon: , + label: (address: string, network: "mainnet" | "testnet") => ( + + ), + component: (name: ResolvedName) => , + }, { key: "analytics", title: "Analytics", @@ -89,11 +135,19 @@ export function SinglePackage({ const router = useRouter(); const searchParams = useSearchParams(); - const [activeTab, setActiveTab] = useState(Tabs[0]!.key); + // The "Issued" tab only applies to attesters — gate it on whitelist + // membership (a free in-memory check) rather than probing every package. + const cfg = attestationConfig(); + const visibleTabs = + cfg && isConfiguredAttestor(cfg, name.package_address) + ? Tabs + : Tabs.filter((t) => t.key !== "issued"); + + const [activeTab, setActiveTab] = useState(visibleTabs[0]!.key); useEffect(() => { const tab = searchParams.get("tab"); - if (tab && Tabs.some((t) => t.key === tab) && tab !== activeTab) { + if (tab && visibleTabs.some((t) => t.key === tab) && tab !== activeTab) { setActiveTab(tab as string); } }, [searchParams]); @@ -112,14 +166,14 @@ export function SinglePackage({
- {Tabs.find((t) => t.key === activeTab)?.component(name)} + {visibleTabs.find((t) => t.key === activeTab)?.component(name)}
diff --git a/app/src/components/single-package/SinglePackageHeader.tsx b/app/src/components/single-package/SinglePackageHeader.tsx index a98a4ecc..dbd749c3 100644 --- a/app/src/components/single-package/SinglePackageHeader.tsx +++ b/app/src/components/single-package/SinglePackageHeader.tsx @@ -3,6 +3,41 @@ import { Text } from "../ui/Text"; import ImageWithFallback from "../ui/image-with-fallback"; import { beautifySuiAddress } from "@/lib/utils"; import { CopyBtn } from "../ui/CopyBtn"; +import { attestationConfig, isConfiguredAttestor } from "@/lib/attestations"; + +/** Shield-check glyph; color comes from the surrounding text color. */ +function ShieldCheckIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +/** Pill marking a package as a trusted attestor in this consumer's trust + * config. Links to the full trusted-attestors list. */ +function TrustedAttestorBadge() { + return ( + + + + MVR-trusted attestor + + + ); +} export function SinglePackageHeader({ name, @@ -11,6 +46,10 @@ export function SinglePackageHeader({ name: ResolvedName; network: "mainnet" | "testnet"; }) { + const cfg = attestationConfig(); + const isTrustedAttestor = + !!cfg && isConfiguredAttestor(cfg, name.package_address); + return (
@@ -20,9 +59,12 @@ export function SinglePackageHeader({ className="h-14 w-14 rounded-sm" />
- - {name.name} - +
+ + {name.name} + + {isTrustedAttestor && } +
+ + {count} + +
+ ); +} + +export function SinglePackageIssued({ name }: { name: ResolvedName }) { + const network = usePackagesNetwork() as "mainnet" | "testnet"; + const { data, isLoading } = useIssuedAttestations(name.package_address, network); + const issued = data ?? []; + + const subjects = [...new Set(issued.map((i) => i.subject))]; + const { items: names } = useReverseResolution(subjects, network); + const nameOf = (subject: string) => (names[subject] as { name?: string })?.name; + + // Live attestations group at the top; revoked ones move to their own + // section at the bottom so they don't read as endorsements. + const liveGroups = groupBySubject(issued.filter((i) => !i.revoked)); + const revokedGroups = groupBySubject(issued.filter((i) => i.revoked)); + + return ( +
+
+ +

Issued attestations

+
+ + On-chain claims this package has signed about other packages — audits, + vulnerability disclosures, and the like. Each one also appears on the + subject package's Security tab. + +
+ + {isLoading && ( + + )} + + {!isLoading && issued.length === 0 && ( + + This package hasn't issued any attestations. + + )} + + {liveGroups.map((g) => ( + + ))} + + {revokedGroups.length > 0 && ( +
+ +

Revoked

+
+ {revokedGroups.map((g) => ( + + ))} +
+ )} +
+ ); +} + +/** Group issued attestations by the subject they're about. */ +function groupBySubject( + items: IssuedAttestation[], +): { subject: string; items: IssuedAttestation[] }[] { + const groups: { subject: string; items: IssuedAttestation[] }[] = []; + for (const item of items) { + let g = groups.find((x) => x.subject === item.subject); + if (!g) { + g = { subject: item.subject, items: [] }; + groups.push(g); + } + g.items.push(item); + } + return groups; +} + +/** A card of the attestations one package issued about a single subject. */ +function SubjectGroup({ + group, + name, +}: { + group: { subject: string; items: IssuedAttestation[] }; + name?: string; +}) { + return ( +
+ + about + + {group.items.map((item) => ( + + ))} +
+ ); +} + +function IssuedRow({ item }: { item: IssuedAttestation }) { + const { display, innerType } = item.info; + const title = str(display["description"]) ?? str(display["name"]) ?? "Attestation"; + const live = !item.revoked && item.effective; + + return ( +
+
+ + {title} + {item.revoked ? ( + (revoked) + ) : !item.effective ? ( + (expired) + ) : null} + +
+ + {innerType.split("::").slice(1).join("::")} + +
+ ); +} + +/** Link a subject (attested package) to its MVR page by name, or show its id. */ +function SubjectLink({ id, name }: { id: string; name?: string }) { + if (name) { + return ( + + {name} + + ); + } + return ( + + {id.length > 16 ? `${id.slice(0, 8)}…${id.slice(-4)}` : id} + + ); +} + +function str(v: unknown): string | undefined { + return typeof v === "string" && v.length > 0 ? v : undefined; +} diff --git a/app/src/components/single-package/SinglePackageTrustSignals.tsx b/app/src/components/single-package/SinglePackageTrustSignals.tsx new file mode 100644 index 00000000..c54e4e05 --- /dev/null +++ b/app/src/components/single-package/SinglePackageTrustSignals.tsx @@ -0,0 +1,344 @@ +import { ResolvedName } from "@/hooks/mvrResolution"; +import { usePackagesNetwork } from "../providers/packages-provider"; +import { + useGetAttestations, + useGetRevokedAttestations, + type AttributedAttestation, + type DisplayedAttestation, +} from "@/hooks/useGetAttestations"; +import { Text } from "../ui/Text"; +import LoadingState from "../LoadingState"; +import ExplorerLink from "../ui/explorer-link"; +import { type TrustedAttestor } from "@/lib/attestations"; + +/** A check glyph; color comes from the text color (currentColor). */ +function CheckIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +/** A triangle-exclamation glyph; color comes from the text color (currentColor). */ +function WarningIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +/** Tab label: a pill with the count of effective attestations. */ +export function TrustSignalCount({ + address, + network, +}: { + address: string; + network: "mainnet" | "testnet"; +}) { + const { data } = useGetAttestations(address, network); + const positives = (data ?? []).filter((a) => a.effective).length; + + if (!positives) return null; + return ( +
+ } + count={positives} + /> +
+ ); +} + +/** A count pill: only the icon is colored; the number uses the default color + * to match the app's other count chips. */ +function CountPill({ icon, count }: { icon: React.ReactNode; count: number }) { + return ( +
+ {icon} + + {count} + +
+ ); +} + +export function SinglePackageTrustSignals({ name }: { name: ResolvedName }) { + const network = usePackagesNetwork() as "mainnet" | "testnet"; + const { data, isLoading } = useGetAttestations(name.package_address, network); + const { data: revokedData } = useGetRevokedAttestations(name.package_address, network); + const revoked = revokedData ?? []; + + const positives = data ?? []; + const hasLiveAttestation = positives.some((a) => a.effective); + + return ( +
+ +

Security

+
+ + {isLoading && ( + + )} + + {!isLoading && !hasLiveAttestation && ( +
+ + + This package has no active attestations published on MVR — it may + not have been audited. + +
+ )} + + {positives.length > 0 && } + + {revoked.length > 0 && ( +
+ +
+ )} +
+ ); +} + +/** Audits (positive attestations), grouped by attester. */ +function AuditsSection({ items }: { items: DisplayedAttestation[] }) { + const ineffective = items.filter((a) => !a.effective); + const groups = groupByAttestor(items.filter((a) => a.effective)); + return ( +
+
+ + + Audits + +
+ {groups.map((g) => ( + + ))} + {ineffective.length > 0 && } +
+ ); +} + +/** Effective attestations from one trusted attester. */ +function AttestorGroupCard({ group }: { group: AttestorGroup }) { + return ( +
+
+ +
+ + {group.attestor.name} + + {group.attestor.mvrName ? ( + + {mvrLink(group.attestor.mvrName)} + + ) : ( + + {truncateId(group.attestor.originalId)} + + )} +
+
+ {group.items.map((item) => ( + + ))} +
+ ); +} + +/** A compact, de-emphasized list — `label` is "Inactive" (expired) or + * "Revoked". Items are attributed attestations; effectiveness isn't read. */ +function InactiveList({ + label, + items, +}: { + label: string; + items: AttributedAttestation[]; +}) { + return ( + + {label}:{" "} + {items.map((it, i) => ( + + {i > 0 ? ", " : ""} + {it.attestor.name} ({str(it.info.display["name"]) ?? "Attestation"}) + + ))} + + ); +} + +export function AttesterAvatar({ + attestor, + size = "md", +}: { + attestor: TrustedAttestor; + size?: "sm" | "md" | "lg"; +}) { + const dim = size === "sm" ? "h-5 w-5" : size === "lg" ? "h-12 w-12" : "h-9 w-9"; + const base = `flex ${dim} shrink-0 items-center justify-center rounded-md overflow-hidden`; + if (attestor.iconUrl) { + return ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + + + ); + } + return ( + + + {initials(attestor.name)} + + + ); +} + +/** A positive attestation row (audits). */ +function AttestationRow({ item }: { item: DisplayedAttestation }) { + const network = usePackagesNetwork() as "mainnet" | "testnet"; + const { display, innerType, id } = item.info; + const headline = str(display["description"]) ?? str(display["name"]) ?? "Attestation"; + const link = httpsLink(display["link"]); + + return ( +
+ + {headline} + {link && <> ({reportLink(link)})} + + + {moduleAndType(innerType)} ( + {objectLink(id, network)}) + +
+ ); +} + +/** The attestation object id, truncated and linked to an explorer. */ +function objectLink(id: string, network: "mainnet" | "testnet"): React.ReactNode { + return ( + + {truncateId(id)} + + ); +} + +/** The report/advisory URL as a link showing its host. */ +function reportLink(url: string): React.ReactNode { + return ( + + {hostOf(url)} ↗ + + ); +} + +/** Link to a dependency's MVR page by name, or show its id when unresolved + * (the package route resolves by name, so a raw id isn't linkable). */ +/** Render an MVR name as a link to its package page. */ +function mvrLink(name: string): React.ReactNode { + return ( + + {name} + + ); +} + +interface AttestorGroup { + attestor: TrustedAttestor; + items: DisplayedAttestation[]; +} + +/** Group attestations by their trusted attester, preserving discovery order. */ +function groupByAttestor(items: DisplayedAttestation[]): AttestorGroup[] { + const groups: AttestorGroup[] = []; + for (const item of items) { + let group = groups.find( + (g) => g.attestor.originalId === item.attestor.originalId, + ); + if (!group) { + group = { attestor: item.attestor, items: [] }; + groups.push(group); + } + group.items.push(item); + } + return groups; +} + +const AVATAR_COLORS = ["bg-pastel-blue", "bg-pastel-green", "bg-pastel-purple", "bg-pastel-orange"]; + +/** Deterministic avatar background from the attester name. */ +function avatarColor(name: string): string { + let h = 0; + for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0; + return AVATAR_COLORS[h % AVATAR_COLORS.length]!; +} + +/** Up to two uppercase initials from the attester name. */ +function initials(name: string): string { + const parts = name.split(/[\s_-]+/).filter(Boolean); + const letters = parts.length >= 2 ? parts[0]![0]! + parts[1]![0]! : name.slice(0, 2); + return letters.toUpperCase(); +} + +/** CVSS score for sorting; unscored attestations sort last. */ +function truncateId(id: string): string { + return id.length > 16 ? `${id.slice(0, 8)}…${id.slice(-4)}` : id; +} + +/** The `module::Type` part of an inner type, dropping the package address. */ +function moduleAndType(innerType: string): string { + const parts = innerType.split("::"); + return parts.length > 1 ? parts.slice(1).join("::") : innerType; +} + +function str(v: unknown): string | undefined { + return typeof v === "string" && v.length > 0 ? v : undefined; +} + +/** Only surface https links (basic hygiene; full host-allowlisting is M4). */ +function httpsLink(v: unknown): string | undefined { + const s = str(v); + return s && s.startsWith("https://") ? s : undefined; +} + +function hostOf(url: string): string { + try { + return new URL(url).host; + } catch { + return url; + } +} diff --git a/app/src/hooks/useGetAttestations.ts b/app/src/hooks/useGetAttestations.ts new file mode 100644 index 00000000..21d3e038 --- /dev/null +++ b/app/src/hooks/useGetAttestations.ts @@ -0,0 +1,280 @@ +import { useSuiClientsContext } from "@/components/providers/client-provider"; +import { AppQueryKeys } from "@/utils/types"; +import { useQuery } from "@tanstack/react-query"; +import type { SuiClient } from "@mysten/sui/client"; +import { normalizeSuiAddress } from "@mysten/sui/utils"; +import { + attestationConfig, + attestorFor, + boxAddress, + revokedBoxAddress, + isEffective, + toAttestationInfo, + type AttestationConfig, + type AttestationInfo, + type TrustedAttestor, +} from "@/lib/attestations"; + +/** A trusted attestation attributed to the attester whose lineage defines its + * type — the shared base for the active-box and revoked-sink reads. */ +export interface AttributedAttestation { + info: AttestationInfo; + /** The trusted attester this attestation's type belongs to. */ + attestor: TrustedAttestor; +} + +export interface DisplayedAttestation extends AttributedAttestation { + /** Effectiveness per the conventions (unexpired; revocation is box membership). */ + effective: boolean; +} + +/** + * Core read: the attestations about `subject` from the configured trusted + * attesters, with effectiveness. Reads the per-subject Box directly over + * JSON-RPC (no MVR backend). + * + * Spam-resistant (M3): rather than fetch every `Attestation<*>` on the box and + * filter client-side, it asks the node for only the exact trusted types via a + * `MatchAny` `StructType` filter — so attestations from untrusted attesters are + * never returned. The trusted type set is the `Attestation` for every store + * type `T` defined by a trusted attester's lineage (see `resolveTrustedTypes`). + * A read-time Display-gate still drops trusted-but-undisplayed types. + */ +export async function fetchTrustedAttestations( + client: SuiClient, + cfg: AttestationConfig, + subject: string, +): Promise { + const box = boxAddress(cfg.registryPkg, cfg.registryId, subject); + const trusted = await fetchBoxAttestations(client, cfg, box); + // Effectiveness: unexpired. Revocation is handled by box membership — a + // revoked attestation isn't in this box at all. + return trusted.map((a) => ({ ...a, effective: isEffective(a.info) })); +} + +/** + * The revoked attestations about `subject`: the trusted, displayed ones that + * `revoke` moved out of the active box into the subject's revoked sink. Same + * trusted/Display filter, just against the sink address. + */ +export async function fetchRevokedAttestations( + client: SuiClient, + cfg: AttestationConfig, + subject: string, +): Promise { + const sink = revokedBoxAddress(cfg.registryPkg, cfg.registryId, subject); + return fetchBoxAttestations(client, cfg, sink); +} + +/** + * Trusted, displayed attestations owned by `boxAddr`, attributed to their + * attester. Shared by the active-box and revoked-sink reads. Uses the gRPC + * `MatchAny` `StructType` filter so untrusted attesters are never fetched; a + * read-time Display-gate drops trusted-but-undisplayed types. + */ +async function fetchBoxAttestations( + client: SuiClient, + cfg: AttestationConfig, + boxAddr: string, +): Promise { + const trustedTypes = await resolveTrustedTypes(client, cfg); + if (trustedTypes.length === 0) return []; + + const infos: AttestationInfo[] = []; + let cursor: string | null | undefined = null; + do { + const page = await client.getOwnedObjects({ + owner: boxAddr, + filter: { MatchAny: trustedTypes.map((StructType) => ({ StructType })) }, + options: { showType: true, showDisplay: true }, + cursor, + }); + for (const r of page.data) { + const info = toAttestationInfo(r); + if (info) infos.push(info); + } + cursor = page.hasNextPage ? page.nextCursor : null; + } while (cursor); + + return infos + .map((info) => ({ info, attestor: attestorFor(cfg, info.innerType) })) + .filter( + (x): x is AttributedAttestation => + !!x.attestor && Object.keys(x.info.display).length > 0, + ); +} + +// Cache the trusted type set per config — the lineage is static, so this only +// changes when an attester upgrades (re-load the app to refresh). +const trustedTypesCache = new Map>(); + +/** + * The exact set of trusted `Attestation` type strings: for every package in + * a trusted attester's lineage, every `store` struct it defines becomes a + * candidate `T`. Querying each lineage version covers types by their defining + * (canonical) id; non-canonical combinations simply match no objects. + */ +export function resolveTrustedTypes( + client: SuiClient, + cfg: AttestationConfig, +): Promise { + const lineage = cfg.trustedAttestors.flatMap((a) => a.lineage); + const key = `${cfg.registryPkg}|${[...new Set(lineage.map((id) => normalizeSuiAddress(id)))].join(",")}`; + const cached = trustedTypesCache.get(key); + if (cached) return cached; + const promise = enumerateAttestationTypes(client, lineage, cfg.registryPkg); + trustedTypesCache.set(key, promise); + return promise; +} + +/** + * The `Attestation` type strings for every `store` type `T` defined across + * the given package lineage. Querying each version covers types by their + * defining (canonical) id; non-canonical combinations match no objects. + */ +export async function enumerateAttestationTypes( + client: SuiClient, + lineageIds: string[], + registryPkg: string, +): Promise { + const lineage = [...new Set(lineageIds.map((id) => normalizeSuiAddress(id)))]; + const types = new Set(); + for (const pkg of lineage) { + const modules = await client.getNormalizedMoveModulesByPackage({ package: pkg }); + for (const [moduleName, mod] of Object.entries(modules)) { + for (const [structName, struct] of Object.entries(mod.structs ?? {})) { + if (struct.abilities.abilities.includes("Store")) { + types.add( + `${registryPkg}::attestation_registry::Attestation<${pkg}::${moduleName}::${structName}>`, + ); + } + } + } + } + return [...types]; +} + +/** The attestations about `subject` from the configured trusted attesters. */ +export function useGetAttestations( + subject: string | undefined, + network: "mainnet" | "testnet", +) { + const client = useSuiClientsContext()[network]; + const cfg = attestationConfig(); + + return useQuery({ + queryKey: [AppQueryKeys.ATTESTATIONS, network, subject], + enabled: !!subject && !!cfg, + queryFn: () => fetchTrustedAttestations(client, cfg!, subject!), + }); +} + +/** The revoked attestations about `subject` (read from the revoked sink). */ +export function useGetRevokedAttestations( + subject: string | undefined, + network: "mainnet" | "testnet", +) { + const client = useSuiClientsContext()[network]; + const cfg = attestationConfig(); + + return useQuery({ + queryKey: [AppQueryKeys.ATTESTATIONS, "revoked", network, subject], + enabled: !!subject && !!cfg, + queryFn: () => fetchRevokedAttestations(client, cfg!, subject!), + }); +} + + +/** An attestation issued *by* a package, and the subject it is about. */ +export interface IssuedAttestation { + info: AttestationInfo; + /** The subject (package) the attestation is about. */ + subject: string; + /** Moved to the subject's revoked sink (vs. its active box). */ + revoked: boolean; + /** Unexpired per the `expires_at` convention (independent of `revoked`). */ + effective: boolean; +} + +const ISSUED_QUERY = `query($type: String!) { + objects(filter: { type: $type }) { + nodes { address asMoveObject { contents { json } } } + } +}`; + +/** + * The attestations *issued by* `pkg` — the reverse of the per-subject read. + * Uses GraphQL `objects(type:)` to find every `Attestation` of the + * package's types across all Boxes (the object's `subject` field says who it's + * about), then re-reads each over JSON-RPC for Display + effectiveness. Only + * configured trusted attesters issue attestations in the demo, so a package not + * in the trust config returns nothing. + */ +export function useIssuedAttestations( + pkg: string | undefined, + network: "mainnet" | "testnet", +) { + const clients = useSuiClientsContext(); + const client = clients[network]; + const gql = clients.graphql[network]; + const cfg = attestationConfig(); + + return useQuery({ + queryKey: [AppQueryKeys.ATTESTATIONS, "issued", network, pkg], + enabled: !!pkg && !!cfg, + queryFn: async (): Promise => { + const attestor = cfg!.trustedAttestors.find((a) => + a.lineage.some((id) => normalizeSuiAddress(id) === normalizeSuiAddress(pkg!)), + ); + if (!attestor) return []; + const types = await enumerateAttestationTypes(client, attestor.lineage, cfg!.registryPkg); + + // Reverse query: every object of each issued type, across all Boxes. + const subjectById = new Map(); + for (const type of types) { + const res = await gql.query<{ + objects: { + nodes: { + address: string; + asMoveObject: { contents: { json: { subject?: string } } | null } | null; + }[]; + }; + }>({ query: ISSUED_QUERY, variables: { type } }); + for (const node of res.data?.objects?.nodes ?? []) { + const subject = node.asMoveObject?.contents?.json?.subject; + if (node.address && subject) subjectById.set(node.address, subject); + } + } + if (subjectById.size === 0) return []; + + // Re-read each for Display + owner; drop undisplayed types. An issued + // attestation is revoked iff it now lives in its subject's revoked sink + // rather than the active box — the read-by-type Issued view is the one + // place that recovers revocation from ownership, since the object itself + // carries no status field. + const out: IssuedAttestation[] = []; + for (const [id, subject] of subjectById) { + const resp = await client + .getObject({ id, options: { showType: true, showDisplay: true, showOwner: true } }) + .catch(() => null); + if (!resp) continue; + const info = toAttestationInfo(resp); + if (!info || Object.keys(info.display).length === 0) continue; + const ownerField = resp.data?.owner; + const owner = + ownerField && typeof ownerField === "object" && "AddressOwner" in ownerField + ? ownerField.AddressOwner + : undefined; + const sink = revokedBoxAddress(cfg!.registryPkg, cfg!.registryId, subject); + const revoked = !!owner && normalizeSuiAddress(owner) === normalizeSuiAddress(sink); + out.push({ + info, + subject: normalizeSuiAddress(subject), + revoked, + effective: isEffective(info), + }); + } + return out; + }, + }); +} diff --git a/app/src/lib/attestations.ts b/app/src/lib/attestations.ts new file mode 100644 index 00000000..5e8d9c62 --- /dev/null +++ b/app/src/lib/attestations.ts @@ -0,0 +1,180 @@ +// Reading package attestations from the attestation registry. This is a pure +// chain read (no MVR backend): derive the per-subject Box address and list the +// `Attestation` objects it owns, keeping only those from trusted attesters. +// See ATTESTATION-INTEGRATION.md. + +import { bcs } from "@mysten/sui/bcs"; +import { deriveObjectID, normalizeSuiAddress } from "@mysten/sui/utils"; +import type { SuiObjectResponse } from "@mysten/sui/client"; + +// === Config (NEXT_PUBLIC_ATTESTATION_CONFIG, JSON) === + +export interface TrustedAttestor { + /** Human-readable label, shown as the attester heading. */ + name: string; + /** Optional brand icon URL (from trust config, never from on-chain data). + * Absent → the UI renders an initials avatar. */ + iconUrl?: string; + /** Optional MVR name of the attester package, for linking to its page. */ + mvrName?: string; + /** Original publish id of the attester package — the trust anchor. */ + originalId: string; + /** Every package-version id in the attester's lineage. Matching an + * attestation's inner-type package against this set is equivalent to + * resolving that package's original id (the on-chain `attester_of` rule). + * M2 seeds this directly; M3 derives it via GraphQL `packageVersions`. */ + lineage: string[]; +} + +export interface AttestationConfig { + /** attestation_registry package id (the `Attestation<>` wrapper type). */ + registryPkg: string; + /** The shared Registry object id — parent for per-subject Box derivation. */ + registryId: string; + trustedAttestors: TrustedAttestor[]; +} + +let cached: AttestationConfig | null | undefined; + +/** Parse the attestation config from env, or null if unset (production). */ +export function attestationConfig(): AttestationConfig | null { + if (cached === undefined) { + const raw = process.env.NEXT_PUBLIC_ATTESTATION_CONFIG; + cached = raw ? (JSON.parse(raw) as AttestationConfig) : null; + } + return cached; +} + +// === Box address === + +const BoxKey = bcs.struct("BoxKey", { subject: bcs.Address, revoked: bcs.bool() }); + +/** Derive a subject's box address, mirroring on-chain + * `derived_object::derive_address(registry, BoxKey { subject, revoked })`. */ +function derivedBox( + registryPkg: string, + registryId: string, + subject: string, + revoked: boolean, +): string { + const keyBytes = BoxKey.serialize({ + subject: normalizeSuiAddress(subject), + revoked, + }).toBytes(); + return deriveObjectID( + registryId, + `${registryPkg}::attestation_registry::BoxKey`, + keyBytes, + ); +} + +/** Address of the per-subject active `Box` (`revoked: false`). `revoke` moves + * attestations out to the sibling revoked sink, so a read of this address + * yields exactly the un-revoked set. */ +export function boxAddress( + registryPkg: string, + registryId: string, + subject: string, +): string { + return derivedBox(registryPkg, registryId, subject, false); +} + +/** Address of the per-subject revoked sink (`revoked: true`) — where `revoke` + * moves attestations. Lets a read-by-type view (the Issued tab) tell a revoked + * attestation from a live one by its owner, since the object carries no status. */ +export function revokedBoxAddress( + registryPkg: string, + registryId: string, + subject: string, +): string { + return derivedBox(registryPkg, registryId, subject, true); +} + +// === Attestation info + mapping === + +export interface AttestationInfo { + id: string; + /** Full object type, `…::attestation_registry::Attestation`. */ + type: string; + /** The inner type `T`, e.g. `0xAUD::audit::Audit`. */ + innerType: string; + /** Server-rendered Display v2 fields (all values are strings). */ + display: Record; +} + +const ATTESTATION_RE = /::attestation_registry::Attestation<(.+)>$/; + +/** + * Map a `getOwnedObjects`/`getObject` response into `AttestationInfo`, or null + * if it isn't a well-formed `Attestation`. JSON-RPC nests Display fields + * under `data.display.data`. + */ +export function toAttestationInfo(resp: SuiObjectResponse): AttestationInfo | null { + const d = resp.data; + if (!d?.type) return null; + const m = d.type.match(ATTESTATION_RE); + if (!m) return null; + return { + id: d.objectId, + type: d.type, + innerType: m[1]!, + display: (d.display?.data ?? {}) as Record, + }; +} + +/** The defining (origin) package id of an inner type string. */ +export function innerTypePackage(innerType: string): string { + return normalizeSuiAddress(innerType.split("::")[0]!); +} + +/** Whether `pkg` is a configured trusted attester (any lineage version). + * A pure in-memory check — used to gate the "Issued" tab without any RPC. */ +export function isConfiguredAttestor(cfg: AttestationConfig, pkg: string): boolean { + const id = normalizeSuiAddress(pkg); + return cfg.trustedAttestors.some((a) => + a.lineage.some((v) => normalizeSuiAddress(v) === id), + ); +} + +/** The trusted attester whose lineage defines `innerType`, if any. */ +export function attestorFor( + cfg: AttestationConfig, + innerType: string, +): TrustedAttestor | undefined { + const pkg = innerTypePackage(innerType); + return cfg.trustedAttestors.find((a) => + a.lineage.some((id) => normalizeSuiAddress(id) === pkg), + ); +} + +// === Conventions (effectiveness) — ported from the attestation-registry repo's +// ts/src/conventions.ts. Operates purely on Display fields. Revocation is no +// longer a convention: a revoked attestation is moved out of the active Box +// (the address `boxAddress` reads), so anything fetched from there is, by +// construction, un-revoked. === + +/** `expires_at` as Unix-ms, or null if absent/unparseable. */ +function readExpiresAt(att: AttestationInfo): number | null { + const raw = att.display["expires_at"]; + if (raw == null) return null; + if (typeof raw === "number") return raw; + if (typeof raw === "string") { + const asNum = Number(raw); + if (!Number.isNaN(asNum) && asNum > 0) return asNum; + const asDate = Date.parse(raw); + if (!Number.isNaN(asDate)) return asDate; + } + return null; +} + +/** + * An attestation is effective iff its `expires_at` Display field (if present) + * is still in the future. Revocation is handled upstream by box membership. + */ +export function isEffective( + att: AttestationInfo, + now: () => number = Date.now, +): boolean { + const expiresAt = readExpiresAt(att); + return expiresAt === null || now() < expiresAt; +} diff --git a/app/src/utils/types.ts b/app/src/utils/types.ts index 51355fbc..9cba5b02 100644 --- a/app/src/utils/types.ts +++ b/app/src/utils/types.ts @@ -75,4 +75,5 @@ export enum AppQueryKeys { MVR_VERSION_ADDRESSES = "mvr-version-addresses", SUINS_NAME_RESOLUTION = "suins-name-resolution", NAME_ANALYTICS = "name-analytics", + ATTESTATIONS = "attestations", } diff --git a/crates/mvr-api/examples/demo_server.rs b/crates/mvr-api/examples/demo_server.rs new file mode 100644 index 00000000..58bae295 --- /dev/null +++ b/crates/mvr-api/examples/demo_server.rs @@ -0,0 +1,224 @@ +//! Demo server for the MVR attestation integration (see ATTESTATION-INTEGRATION.md). +//! +//! Spins up an ephemeral Postgres (`TempDb`), seeds it so the localnet-published +//! demo subjects resolve by MVR name, and runs the real mvr-api `run_server` +//! against it. Reads the published package ids from the attestation-registry +//! repo's `demo-ids.json` (produced by its `scripts/run-demo.sh`). +//! +//! This mirrors the seeding pattern in `tests/mvr_test_cluster.rs` +//! (`setup_dummy_data`): mvr-api resolution is a pure Postgres read, so we +//! insert `packages` / `package_infos` / `name_records` rows directly rather +//! than running the indexer. Neither resolution loader reads `move_package` +//! or filters by `chain_id`, so empty/placeholder values suffice there. +//! +//! Run (from the mvr repo), with a localnet up and the demo already run: +//! cargo run -p mvr-api --example demo_server -- \ +//! --demo-ids /demo-ids.json --port 8000 +//! +//! Then point the MVR frontend's mainnet `mvrEndpoint` at http://localhost:8000. + +use std::{net::SocketAddr, path::PathBuf, str::FromStr}; + +use chrono::NaiveDateTime; +use clap::Parser; +use diesel::insert_into; +use diesel_async::RunQueryDsl; +use mvr_api::{run_server, Network}; +use mvr_schema::{ + models::{GitInfo, NameRecord, Package, PackageDependency, PackageInfo}, + schema::{git_infos, name_records, package_dependencies, package_infos, packages}, + MIGRATIONS, +}; + +// Where the auditor package READMEs live, for MVR's git-backed README fetch. +const DEMO_REPO_URL: &str = "https://github.com/mdgeorge4153/sui-attestation-registry"; +const DEMO_GIT_TAG: &str = "mvr-demo"; +use serde_json::json; +use sui_pg_db::{temp::TempDb, Db, DbArgs}; +use tokio_util::sync::CancellationToken; + +// Synthetic PackageInfo object ids for the seeded names. On a real network +// these would be MVR PackageInfo objects; the demo subjects have none, and +// resolution only uses these as join keys, so any distinct addresses work. +const PKG_INFO_SUBJECT: &str = + "0x00000000000000000000000000000000000000000000000000000000dee00001"; +const PKG_INFO_DEPENDENCY: &str = + "0x00000000000000000000000000000000000000000000000000000000dee00002"; + +#[derive(Parser)] +struct Args { + /// Path to the attestation-registry `demo-ids.json`. + #[clap(long, env = "DEMO_IDS")] + demo_ids: PathBuf, + /// Port for the mvr-api server. + #[clap(long, default_value_t = 8000)] + port: u16, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + let ids: serde_json::Value = serde_json::from_slice(&std::fs::read(&args.demo_ids)?)?; + let subject = field(&ids, "subject"); + let dependency = field(&ids, "dependency"); + + // Ephemeral Postgres, alive for the lifetime of this process. + let temp_db = TempDb::new()?; + let url = temp_db.database().url().clone(); + + let mut db = Db::for_write(url.clone(), DbArgs::default()).await?; + db.run_migrations(Some(&MIGRATIONS)).await?; + + seed( + &mut db, + "@demo/subject", + &subject, + PKG_INFO_SUBJECT, + "The example subject package browsed in the MVR attestation demo.", + None, + ) + .await?; + seed( + &mut db, + "@demo/dependency", + &dependency, + PKG_INFO_DEPENDENCY, + "A dependency of @demo/subject.", + None, + ) + .await?; + + // The real `subject -> dependency` edge, so the dependencies endpoint + // returns it (powering both the Dependencies tab and vuln propagation). + { + let mut conn = db.connect().await?; + insert_into(package_dependencies::table) + .values(vec![PackageDependency { + package_id: subject.clone(), + dependency_package_id: dependency.clone(), + chain_id: "localnet".to_string(), + immediate_dependency: true, + }]) + .execute(&mut *conn) + .await?; + } + + // Give each trusted attester package an MVR name, so the UI can link to + // its page. Names are kept in sync with scripts/write-demo-env.sh. + if let Some(attestors) = ids["trustedAttestors"].as_array() { + for (i, a) in attestors.iter().enumerate() { + let pkg_name = a["name"].as_str().unwrap_or_default(); + let latest = a["latestId"].as_str().unwrap_or_default().to_string(); + let mvr_name = auditor_mvr_name(pkg_name); + let pkg_info_id = format!("0x{:064x}", 0xdee0_0010u64 + i as u64); + let git_path = format!("packages/{pkg_name}"); + seed( + &mut db, + &mvr_name, + &latest, + &pkg_info_id, + "A trusted attester in the demo.", + Some(&git_path), + ) + .await?; + println!("seeded {mvr_name} -> {latest}"); + } + } + + println!("seeded @demo/subject -> {subject}"); + println!("seeded @demo/dependency -> {dependency}"); + println!("seeded dependency edge {subject} -> {dependency}"); + println!("point the frontend's mainnet mvrEndpoint at http://127.0.0.1:{}", args.port); + + run_server( + url, + DbArgs::default(), + Network::Mainnet, + args.port, + CancellationToken::new(), + SocketAddr::from_str("0.0.0.0:9184")?, + ) + .await +} + +/// MVR name for a trusted attester package (kept in sync with the frontend +/// trust config in scripts/write-demo-env.sh). +fn auditor_mvr_name(pkg_name: &str) -> String { + match pkg_name { + "audit_example" => "@example-auditor/audits".to_string(), + "vuln_example" => "@example-scanner/disclosures".to_string(), + other => format!("@demo/{other}"), + } +} + +/// Read `subjects.` from demo-ids.json, or panic with a clear message. +fn field(ids: &serde_json::Value, key: &str) -> String { + ids["subjects"][key] + .as_str() + .unwrap_or_else(|| panic!("demo-ids.json missing subjects.{key}")) + .to_string() +} + +/// Insert the `packages` / `package_infos` / `name_records` rows that make +/// `name` resolve to `package_id` (version 1, not upgraded) on mainnet. +async fn seed( + db: &mut Db, + name: &str, + package_id: &str, + pkg_info_id: &str, + description: &str, + git_path: Option<&str>, +) -> anyhow::Result<()> { + let package = Package { + package_id: package_id.to_string(), + original_id: package_id.to_string(), + package_version: 1, + move_package: vec![], + chain_id: "localnet".to_string(), + tx_hash: String::new(), + sender: String::new(), + timestamp: NaiveDateTime::MAX, + deps: vec![], + }; + let package_info = PackageInfo { + id: pkg_info_id.to_string(), + object_version: 0, + package_id: package_id.to_string(), + git_table_id: if git_path.is_some() { + pkg_info_id.to_string() + } else { + String::new() + }, + chain_id: "localnet".to_string(), + default_name: Some(name.to_string()), + metadata: serde_json::Value::Null, + }; + let name_record = NameRecord { + name: name.to_string(), + object_version: 0, + mainnet_id: Some(pkg_info_id.to_string()), + testnet_id: None, + metadata: json!({ "description": description }), + }; + + let mut conn = db.connect().await?; + insert_into(packages::table).values(vec![package]).execute(&mut *conn).await?; + insert_into(package_infos::table).values(vec![package_info]).execute(&mut *conn).await?; + insert_into(name_records::table).values(vec![name_record]).execute(&mut *conn).await?; + if let Some(path) = git_path { + // git_table_id == pkg_info_id (set above); join key for the README. + insert_into(git_infos::table) + .values(vec![GitInfo { + table_id: pkg_info_id.to_string(), + object_version: 0, + version: 1, + chain_id: "localnet".to_string(), + repository: Some(DEMO_REPO_URL.to_string()), + path: Some(path.to_string()), + tag: Some(DEMO_GIT_TAG.to_string()), + }]) + .execute(&mut *conn) + .await?; + } + Ok(()) +} diff --git a/scripts/write-demo-env.sh b/scripts/write-demo-env.sh new file mode 100644 index 00000000..54a9a8dd --- /dev/null +++ b/scripts/write-demo-env.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Generate app/.env for the local attestation demo from the attestation-registry +# repo's demo-ids.json: repoint all networks at the local stack and inject the +# attestation config (registry id/pkg + trusted attesters with their lineage). +# +# Usage: +# bash scripts/write-demo-env.sh +# The path is required (or set ATTESTATION_DEMO_IDS); demo-ids.json is written +# by the attestation-registry repo's run-demo.sh and lives in that checkout, +# whose location this repo can't know. Env overrides: RPC_URL, MVR_ENDPOINT. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +APP_ENV="$SCRIPT_DIR/../app/.env" +DEMO_IDS="${1:-${ATTESTATION_DEMO_IDS:-}}" +if [[ -z "$DEMO_IDS" ]]; then + echo "usage: write-demo-env.sh (or set ATTESTATION_DEMO_IDS)" >&2 + echo " demo-ids.json is produced by the attestation-registry repo's run-demo.sh" >&2 + exit 2 +fi +RPC="${RPC_URL:-http://127.0.0.1:9000}" +MVR="${MVR_ENDPOINT:-http://127.0.0.1:8000}" +GRAPHQL="${GRAPHQL_URL:-http://127.0.0.1:9125/graphql}" + +if [[ ! -f "$DEMO_IDS" ]]; then + echo "demo-ids.json not found at $DEMO_IDS (run the attestation demo first)" >&2 + exit 1 +fi + +# Build the attestation config. Each attester's lineage is its original id plus +# any later version ids (deduped) — the M2 client-side trusted set. +CONFIG=$(python3 - "$DEMO_IDS" <<'PY' +import json, sys +d = json.load(open(sys.argv[1])) +# Friendly display names for the demo attesters (presentation lives in the +# consumer's trust config, not on-chain). +NAMES = { + "audit_example": "Example Auditor", + "vuln_example": "Example Security Scanner", +} +# MVR names of the attester packages (kept in sync with demo_server.rs). +MVR_NAMES = { + "audit_example": "@example-auditor/audits", + "vuln_example": "@example-scanner/disclosures", +} +# Brand icons (served from app/public). Presentation lives in the consumer's +# trust config, never on-chain; absent → the UI falls back to an initials avatar. +ICONS = { + "audit_example": "/demo-attestors/auditor.svg", + "vuln_example": "/demo-attestors/scanner.svg", +} +attestors = [] +for a in d["trustedAttestors"]: + lineage = list(dict.fromkeys([a["originalId"], a.get("latestId", a["originalId"])])) + attestors.append({ + "name": NAMES.get(a["name"], a["name"]), + "iconUrl": ICONS.get(a["name"]), + "mvrName": MVR_NAMES.get(a["name"]), + "originalId": a["originalId"], + "lineage": lineage, + }) +print(json.dumps({ + "registryPkg": d["attestationRegistryPkg"], + "registryId": d["registryId"], + "trustedAttestors": attestors, +}, separators=(",", ":"))) +PY +) + +{ + echo "NEXT_PUBLIC_LOCAL_RPC_URL=\"$RPC\"" + echo "NEXT_PUBLIC_LOCAL_MVR_ENDPOINT=\"$MVR\"" + echo "NEXT_PUBLIC_LOCAL_GRAPHQL=\"$GRAPHQL\"" + echo "NEXT_PUBLIC_ATTESTATION_CONFIG='$CONFIG'" +} > "$APP_ENV" + +echo "wrote $APP_ENV" +cat "$APP_ENV"