From ead695ac60c11b78ea7b985dd418a2668a3f6011 Mon Sep 17 00:00:00 2001 From: Michael George Date: Fri, 7 Aug 2026 12:29:55 +0000 Subject: [PATCH] attestations-core: shared attestation infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Factor out the plumbing shared by the security-tab and source-verification features — the config/box-derivation lib, the read hook (incl. enumerateAttestationTypes), the GraphQL client wiring, and shared types/icons — as a clean base off main. Compiles standalone, so either feature can rebase onto this to land independently when ready. Co-Authored-By: Claude Opus 4.8 --- ATTESTATION-INTEGRATION.md | 363 ++++++++++++++++++ app/.env.example | 6 + .../components/providers/client-provider.tsx | 39 +- app/src/hooks/useGetAttestations.ts | 321 ++++++++++++++++ app/src/hooks/useTrustedAttestors.ts | 63 +++ app/src/icons/single-package/CheckIcon.tsx | 16 + app/src/lib/attestation-config.json | 29 ++ app/src/lib/attestations.ts | 207 ++++++++++ app/src/utils/types.ts | 4 + 9 files changed, 1036 insertions(+), 12 deletions(-) create mode 100644 ATTESTATION-INTEGRATION.md create mode 100644 app/src/hooks/useGetAttestations.ts create mode 100644 app/src/hooks/useTrustedAttestors.ts create mode 100644 app/src/icons/single-package/CheckIcon.tsx create mode 100644 app/src/lib/attestation-config.json create mode 100644 app/src/lib/attestations.ts diff --git a/ATTESTATION-INTEGRATION.md b/ATTESTATION-INTEGRATION.md new file mode 100644 index 00000000..d27bc040 --- /dev/null +++ b/ATTESTATION-INTEGRATION.md @@ -0,0 +1,363 @@ +# 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** (`MystenLabs/attestations`), +> 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. + +> **Status note — transport.** This plan was written while MVR was pinned to +> `@mysten/sui@1.39.0`, which had no gRPC export. That repo-wide modernization +> landed separately (#244) and this work was rebased onto it, so **decision D2 +> below is superseded**: reads now go through the gRPC core client +> (`client.core.listOwnedObjects` / `getObject`) with GraphQL for type +> enumeration. Passages further down that say "JSON-RPC" — D5, the architecture +> diagram, Section 2's heading — describe the original plan, not the shipped +> code. + +> **Status note — trust config.** The trusted set is checked in as +> `app/src/lib/attestation-config.json`, keyed by network +> (`Partial>`) with the same shape the +> `NEXT_PUBLIC_ATTESTATION_CONFIG` override uses; the feature surfaces on whichever +> network has a config, not a hardcoded testnet gate. Each attester is configured +> by `originalId` only (per **D4**) — its lineage is resolved at load via +> `packageVersions` (`useTrustedAttestors`), not hand-listed, so attestation types +> added in later upgrades are picked up automatically. The **M2** "client-side +> lineage filter" note and Section 2's `lib/constants.ts` config shape describe +> the earlier plan, not the shipped code. + +## 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~~ — **superseded**, see the status note above; the read layer is now gRPC. Original rationale: | 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: `attestations` (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<…attestations::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. + +## Demo modes + +There are two ways to run this, and both are kept deliberately. + +**Localnet — synthetic names, full feature coverage.** `demo_server` seeds +`name_records` for synthetic names (`@demo/subject`, `@auditor-a/audit`, …) +against a throwaway localnet. Because the *attesters* have resolvable names here, +this is the only mode that exercises the attester-side UI: the Issued tab, trust +filtering between a trusted and an untrusted attester, and the attester heading's +link to its MVR page. + +**Testnet — real names and data, subject-side only.** The registry, an +illustrative "Demo Auditor", and a source-verification package are published on +testnet, with attestations about *real* testnet packages (`@cetuspackages/clmm`, +`@suins/core`, and the registry itself). The app talks to public testnet +infrastructure: no localnet, no `demo_server`, no env file. The trust config is +compiled in via `CHECKED_IN_CONFIG`, so a deployed build (a Vercel preview) +renders attestations with no environment configuration. + +Testnet cannot show anything keyed on the *attester* having an MVR name — the +Issued tab, trust filtering, the attester link — because attester names are +mainnet MVR registrations, and there's no reason to register throwaway ones for +demo attesters. When a real third-party attester onboards with its own name, +testnet gains those surfaces and retiring the localnet path becomes a real +question. Until then it covers ground testnet cannot, which is why both are kept. + +## Running the demo + +### Localnet (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`). + +### Testnet + +Nothing to start but the frontend — no localnet, no `demo_server`, no env file: + +```bash +pnpm --dir app install && pnpm --dir app dev +# then browse, and switch to the *testnet* tab: +# http://localhost:3000/package/@mysten/attestations (source verification) +# http://localhost:3000/package/@cetuspackages/clmm (audit, latest version) +# http://localhost:3000/package/@suins/core/1 (audit on v1 only — +# the bare @suins/core page has none, showing attestations are version-pinned) +``` + +The registry ids and trusted attesters come from `CHECKED_IN_CONFIG` in +`app/src/lib/attestations.ts`. Being in code rather than an env var is what lets +a deployed build render attestations with no dashboard configuration; +`NEXT_PUBLIC_ATTESTATION_CONFIG` still overrides it for a local variant. +Endpoints come from the per-network defaults in `client-provider.tsx` — leave +`NEXT_PUBLIC_LOCAL_*` unset, or they repoint *every* network at a local stack. + +Attestations are gated to testnet (the registry is deployed there only), so the +Security and Issued tabs are hidden on the mainnet tab. + +## 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/src/components/providers/client-provider.tsx b/app/src/components/providers/client-provider.tsx index bb4382ec..010c617c 100644 --- a/app/src/components/providers/client-provider.tsx +++ b/app/src/components/providers/client-provider.tsx @@ -30,7 +30,7 @@ export type Clients = { }; // gRPC (gRPC-web) full node endpoints. Swap to a preferred / higher-rate-limit -// gRPC endpoint if needed (the previous JSON-RPC setup pointed at suins-rpc.*.sui.io). +// gRPC endpoint if needed. const GRPC_URLS = { mainnet: "https://fullnode.mainnet.sui.io:443", testnet: "https://fullnode.testnet.sui.io:443", @@ -43,16 +43,25 @@ const MVR_ENDPOINTS = { testnet: "https://testnet.mvr.mystenlabs.com", }; +// 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. +// NEXT_PUBLIC_LOCAL_RPC_URL now holds the localnet *gRPC* base URL (the JSON-RPC +// port is gone post-migration). Unset in production, where the per-network +// defaults apply. See ATTESTATION-INTEGRATION.md. +const LOCAL_GRPC = 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; + // gRPC clients resolve `@mvr/...` named packages during transaction building via // the `mvr` option (replaces the old `namedPackagesPlugin`). const mainnet = new SuiGrpcClient({ network: "mainnet", - baseUrl: GRPC_URLS.mainnet, - mvr: { url: MVR_ENDPOINTS.mainnet }, + baseUrl: LOCAL_GRPC ?? GRPC_URLS.mainnet, + mvr: { url: LOCAL_MVR ?? MVR_ENDPOINTS.mainnet }, }); const mainnetGraphql = new SuiGraphQLClient({ - url: "https://graphql.mainnet.sui.io/graphql", + url: LOCAL_GRAPHQL ?? "https://graphql.mainnet.sui.io/graphql", network: "mainnet", }); @@ -60,13 +69,16 @@ export const DefaultClients: Clients = { mainnet, testnet: new SuiGrpcClient({ network: "testnet", - baseUrl: GRPC_URLS.testnet, - mvr: { url: MVR_ENDPOINTS.testnet }, + baseUrl: LOCAL_GRPC ?? GRPC_URLS.testnet, + mvr: { url: LOCAL_MVR ?? MVR_ENDPOINTS.testnet }, + }), + devnet: new SuiGrpcClient({ + network: "devnet", + baseUrl: LOCAL_GRPC ?? GRPC_URLS.devnet, }), - devnet: new SuiGrpcClient({ network: "devnet", baseUrl: GRPC_URLS.devnet }), localnet: new SuiGrpcClient({ network: "localnet", - baseUrl: GRPC_URLS.localnet, + baseUrl: LOCAL_GRPC ?? GRPC_URLS.localnet, }), kiosk: { // kiosk 1.x's KioskCompatibleClient is JSON-RPC | GraphQL (not gRPC), so the @@ -79,14 +91,17 @@ export const DefaultClients: Clients = { graphql: { mainnet: mainnetGraphql, testnet: new SuiGraphQLClient({ - url: "https://graphql.testnet.sui.io/graphql", + url: LOCAL_GRAPHQL ?? "https://graphql.testnet.sui.io/graphql", network: "testnet", }), }, - mvrEndpoints: MVR_ENDPOINTS, + mvrEndpoints: { + mainnet: LOCAL_MVR ?? MVR_ENDPOINTS.mainnet, + testnet: LOCAL_MVR ?? MVR_ENDPOINTS.testnet, + }, 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/hooks/useGetAttestations.ts b/app/src/hooks/useGetAttestations.ts new file mode 100644 index 00000000..19b2b5c6 --- /dev/null +++ b/app/src/hooks/useGetAttestations.ts @@ -0,0 +1,321 @@ +import { useSuiClientsContext } from "@/components/providers/client-provider"; +import { AppQueryKeys } from "@/utils/types"; +import { useQuery } from "@tanstack/react-query"; +import type { SuiGrpcClient } from "@mysten/sui/grpc"; +import type { SuiGraphQLClient } from "@mysten/sui/graphql"; +import { normalizeSuiAddress } from "@mysten/sui/utils"; +import { + attestationConfig, + attestorFor, + boxAddress, + revokedBoxAddress, + toAttestationInfo, + type AttestationConfig, + type AttestationInfo, + type ResolvedAttestor, +} from "@/lib/attestations"; +import { useTrustedAttestors } from "./useTrustedAttestors"; +import { useGetMvrVersionAddresses } from "./useGetMvrVersionAddresses"; +import { ResolvedName } from "./mvrResolution"; +import { fetchAllPages } from "@/utils/query"; + +/** A trusted attestation attributed to the attester whose lineage defines its + * type — the shared base for the active-box and revoked-box reads. */ +export interface AttributedAttestation { + info: AttestationInfo; + /** The trusted attester this attestation's type belongs to. */ + attestor: ResolvedAttestor; +} + +/** A trusted, display-gated attestation ready to render. (Currently the same + * shape as AttributedAttestation; kept as a distinct name on the render path.) */ +export type DisplayedAttestation = AttributedAttestation; + +/** + * Core read: the attestations about `subject` from the configured trusted + * attesters. Reads the per-subject Box directly from the chain (no MVR backend), + * then filters to trusted, displayed attestations client-side (see + * `fetchBoxAttestations`). + */ +export async function fetchTrustedAttestations( + client: SuiGrpcClient, + cfg: AttestationConfig, + attestors: ResolvedAttestor[], + subject: string, +): Promise { + // Revocation is handled by box membership — a revoked attestation isn't in + // the active box at all, so everything read here is live. + const box = boxAddress(cfg.registryPkg, cfg.registryId, subject); + return fetchBoxAttestations(client, attestors, box); +} + +/** + * The revoked attestations about `subject`: the trusted, displayed ones that + * `revoke` moved out of the active box into the subject's revoked box. Same + * trusted/Display filter, just against the revoked-box address. + */ +export async function fetchRevokedAttestations( + client: SuiGrpcClient, + cfg: AttestationConfig, + attestors: ResolvedAttestor[], + subject: string, +): Promise { + const revokedBox = revokedBoxAddress(cfg.registryPkg, cfg.registryId, subject); + return fetchBoxAttestations(client, attestors, revokedBox); +} + +/** + * Trusted, displayed attestations owned by `boxAddr`, attributed to their + * attester. Shared by the active-box and revoked-box reads. Lists every object + * the box owns (`listOwnedObjects`) — a box holds only `Attestation`, + * transferred to it — and filters client-side: keep only those from a configured + * trusted attester (`attestorFor`) that carry a registered Display. Untrusted + * attesters can transfer junk into a box; `toAttestationInfo` and the trust + * filter drop it here. + */ +async function fetchBoxAttestations( + client: SuiGrpcClient, + attestors: ResolvedAttestor[], + boxAddr: string, +): Promise { + const objects = await fetchAllPages({ + asyncFn: async (cursor) => { + const page = await client.core.listOwnedObjects({ + owner: boxAddr, + cursor, + include: { display: true }, + }); + return { items: page.objects, hasNextPage: page.hasNextPage, cursor: page.cursor }; + }, + }); + + return objects + .map((obj) => toAttestationInfo(obj)) + .filter((info): info is AttestationInfo => info !== null) + .map((info) => ({ info, attestor: attestorFor(attestors, info.innerType) })) + .filter( + (x): x is AttributedAttestation => + !!x.attestor && + // Source-verification attesters render in the source panel, not as + // endorsement cards — keep them out of the Security-tab reads. + x.attestor.role !== "source-verification" && + Object.keys(x.info.display).length > 0, + ); +} + +/** + * The `Attestation` type strings for every `store` type `T` defined across + * the given package lineage. Used by the reverse (Issued) read's per-type GraphQL + * query. Querying each version covers types by their defining (canonical) id; + * non-canonical combinations match no objects. + */ +const PACKAGE_STRUCTS_QUERY = `query($pkg: SuiAddress!) { + object(address: $pkg) { + asMovePackage { + modules { nodes { name datatypes { nodes { name asMoveStruct { abilities } } } } } + } + } +}`; + +export async function enumerateAttestationTypes( + gql: SuiGraphQLClient, + lineageIds: string[], + registryPkg: string, +): Promise { + const lineage = [...new Set(lineageIds.map((id) => normalizeSuiAddress(id)))]; + const types = new Set(); + for (const pkg of lineage) { + const res = await gql.query<{ + object: { + asMovePackage: { + modules: { + nodes: { + name: string; + datatypes: { + nodes: { name: string; asMoveStruct: { abilities: string[] } | null }[]; + }; + }[]; + }; + } | null; + } | null; + }>({ query: PACKAGE_STRUCTS_QUERY, variables: { pkg } }); + if (res.errors?.length) { + throw new Error(`GraphQL query failed: ${res.errors[0]?.message}`); + } + for (const mod of res.data?.object?.asMovePackage?.modules?.nodes ?? []) { + for (const dt of mod.datatypes?.nodes ?? []) { + // gRPC/GraphQL report abilities in UPPER_CASE (e.g. "STORE"). + if (dt.asMoveStruct?.abilities.includes("STORE")) { + types.add( + `${registryPkg}::attestations::Attestation<${pkg}::${mod.name}::${dt.name}>`, + ); + } + } + } + } + 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(network); + const { attestors } = useTrustedAttestors(network); + + return useQuery({ + queryKey: [AppQueryKeys.ATTESTATIONS, network, subject, attestors.map((a) => a.originalId)], + enabled: !!subject && !!cfg && attestors.length > 0, + queryFn: () => fetchTrustedAttestations(client, cfg!, attestors, subject!), + }); +} + +/** The revoked attestations about `subject` (read from the revoked box). */ +export function useGetRevokedAttestations( + subject: string | undefined, + network: "mainnet" | "testnet", +) { + const client = useSuiClientsContext()[network]; + const cfg = attestationConfig(network); + const { attestors } = useTrustedAttestors(network); + + return useQuery({ + queryKey: [AppQueryKeys.ATTESTATIONS, "revoked", network, subject, attestors.map((a) => a.originalId)], + enabled: !!subject && !!cfg && attestors.length > 0, + queryFn: () => fetchRevokedAttestations(client, cfg!, attestors, 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 box (vs. its active box). */ + revoked: boolean; +} + +const ISSUED_QUERY = `query($type: String!, $after: String) { + objects(filter: { type: $type }, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { address asMoveObject { contents { json } } } + } +}`; + +/** + * The attestations *issued by* a package — the reverse of the per-subject read. + * Enumerates the package's own `store` types across its mvr version lineage, uses + * GraphQL `objects(type:)` to find every `Attestation` of those types across + * all Boxes (the object's `subject` field says who it's about), then re-reads + * each via the gRPC core client for Display + owner. Works for ANY package, not just + * configured trusted attesters — the trust config governs only how the UI frames + * the result (see the Issued tab's not-trusted warning), not whether it loads. + */ +export function useIssuedAttestations( + name: ResolvedName | undefined, + network: "mainnet" | "testnet", +) { + const clients = useSuiClientsContext(); + const client = clients[network]; + const gql = clients.graphql[network]; + const cfg = attestationConfig(network); + // The package's own types may be defined in any version, so enumerate across + // its whole mvr lineage rather than just the resolved version. + const { data: versions } = useGetMvrVersionAddresses( + name?.name ?? "", + name?.version ?? 0, + network, + ); + const lineage = (versions ?? []).map((v) => v.address); + + return useQuery({ + queryKey: [AppQueryKeys.ATTESTATIONS, "issued", network, name?.package_address, lineage], + enabled: !!name && !!cfg && lineage.length > 0, + queryFn: async (): Promise<{ items: IssuedAttestation[]; failures: number }> => { + const types = await enumerateAttestationTypes(gql, lineage, cfg!.registryPkg); + + // Reverse query: page through every object of each issued type, across all + // Boxes. A typed helper (explicit `after` param) keeps the cursor out of + // the query's own return-type inference. + const issuedPage = (type: string, after: string | null) => + gql.query<{ + objects: { + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + nodes: { + address: string; + asMoveObject: { contents: { json: { subject?: string } } | null } | null; + }[]; + }; + }>({ query: ISSUED_QUERY, variables: { type, after } }); + + const subjectById = new Map(); + for (const type of types) { + let after: string | null = null; + do { + const res = await issuedPage(type, after); + // Surface GraphQL errors instead of treating a failed query as "no + // results" — e.g. the localnet GraphQL's "Request is outside consistent + // range" when its consistent store lags. Swallowing it renders a + // failure as an empty Issued tab, which is misleading. + if (res.errors?.length) { + throw new Error(`GraphQL query failed: ${res.errors[0]?.message}`); + } + for (const node of res.data?.objects?.nodes ?? []) { + const subject = node.asMoveObject?.contents?.json?.subject; + if (node.address && subject) subjectById.set(node.address, subject); + } + const pageInfo = res.data?.objects?.pageInfo; + after = pageInfo?.hasNextPage ? pageInfo.endCursor : null; + } while (after); + } + if (subjectById.size === 0) return { items: [], failures: 0 }; + + // Re-read each for Display + owner; drop undisplayed types. An issued + // attestation is revoked iff it now lives in its subject's revoked box + // 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. A re-read that fails (vs. a legitimately + // undisplayed/non-attestation object) is counted so the UI can flag that + // some attestations couldn't be loaded rather than silently dropping them. + const out: IssuedAttestation[] = []; + let failures = 0; + for (const [id, subject] of subjectById) { + let object; + try { + const res = await client.core.getObject({ + objectId: id, + include: { display: true }, + }); + object = res.object; + } catch { + failures++; + continue; + } + const info = toAttestationInfo(object); + if (!info || Object.keys(info.display).length === 0) continue; + const owner = ownerAddress(object.owner); + const revokedBox = revokedBoxAddress(cfg!.registryPkg, cfg!.registryId, subject); + const revoked = !!owner && normalizeSuiAddress(owner) === normalizeSuiAddress(revokedBox); + out.push({ + info, + subject: normalizeSuiAddress(subject), + revoked, + }); + } + return { items: out, failures }; + }, + }); +} + +/** The address of an address-owned object, or undefined for other owner kinds. + * Tolerant of the SDK's owner representation. */ +function ownerAddress(owner: unknown): string | undefined { + if (owner && typeof owner === "object") { + const o = owner as Record; + if (typeof o.AddressOwner === "string") return o.AddressOwner; + if (o.$kind === "Address" && typeof o.address === "string") return o.address; + } + return undefined; +} diff --git a/app/src/hooks/useTrustedAttestors.ts b/app/src/hooks/useTrustedAttestors.ts new file mode 100644 index 00000000..f73cb552 --- /dev/null +++ b/app/src/hooks/useTrustedAttestors.ts @@ -0,0 +1,63 @@ +import { useQuery } from "@tanstack/react-query"; +import { normalizeSuiAddress } from "@mysten/sui/utils"; +import type { SuiGraphQLClient } from "@mysten/sui/graphql"; +import { useSuiClientsContext } from "@/components/providers/client-provider"; +import { AppQueryKeys } from "@/utils/types"; +import { attestationConfig, type ResolvedAttestor } from "@/lib/attestations"; + +/** A package's version ids. One page (50) is plenty — no attester has that many + * upgrades — so pagination is skipped. */ +const VERSIONS_QUERY = `query($address: SuiAddress!) { + packageVersions(address: $address, first: 50) { nodes { address } } +}`; + +/** Every published version id of the package originally published at `originalId` + * — the attester's lineage. Always includes `originalId`, so a config entry still + * matches its own base package even if the lookup returns nothing. */ +async function lineageOf( + gql: SuiGraphQLClient, + originalId: string, +): Promise { + const res = await gql.query<{ + packageVersions: { nodes: { address: string }[] }; + }>({ query: VERSIONS_QUERY, variables: { address: originalId } }); + if (res.errors?.length) { + throw new Error(`packageVersions failed: ${res.errors[0]?.message}`); + } + const ids = (res.data?.packageVersions?.nodes ?? []).map((n) => n.address); + return [...new Set([originalId, ...ids].map((a) => normalizeSuiAddress(a)))]; +} + +/** + * The network's trusted attesters with their lineages resolved from `originalId` + * via `packageVersions`. This is the off-chain form of the on-chain original-id + * trust rule (`attester_of() = type_name::original_id()`): the config carries + * only `originalId`, and every version — including types introduced in later + * upgrades — is derived here rather than hand-listed. + * + * Returns `[]` while resolving (and when the network has no config), so callers + * that gate on it (the Issued tab) show nothing until it's ready. + */ +export function useTrustedAttestors(network: "mainnet" | "testnet") { + const gql = useSuiClientsContext().graphql[network]; + const attestors = attestationConfig(network)?.trustedAttestors ?? []; + + const { data, isLoading } = useQuery({ + queryKey: [ + AppQueryKeys.TRUSTED_ATTESTORS, + network, + attestors.map((a) => a.originalId), + ], + enabled: attestors.length > 0, + staleTime: Infinity, + queryFn: (): Promise => + Promise.all( + attestors.map(async (a) => ({ + ...a, + lineage: await lineageOf(gql, a.originalId), + })), + ), + }); + + return { attestors: data ?? [], isLoading }; +} diff --git a/app/src/icons/single-package/CheckIcon.tsx b/app/src/icons/single-package/CheckIcon.tsx new file mode 100644 index 00000000..4e6193ac --- /dev/null +++ b/app/src/icons/single-package/CheckIcon.tsx @@ -0,0 +1,16 @@ +/** A check glyph; color comes from the text color (currentColor). */ +export function CheckIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/app/src/lib/attestation-config.json b/app/src/lib/attestation-config.json new file mode 100644 index 00000000..e3e55a60 --- /dev/null +++ b/app/src/lib/attestation-config.json @@ -0,0 +1,29 @@ +{ + "testnet": { + "registryPkg": "0x6e0e1141d77448253ab434b008a01259e81c5c31bd1cdac8922a5256da690c09", + "registryId": "0x5a8a789c0385d5e891519612a7d3d8ab36f1d9fc03d63cdabf1cefb3d848b568", + "trustedAttestors": [ + { + "name": "Demo Auditor", + "mvrName": "@pkg/attestations-demo-auditor", + "originalId": "0xa44c54ed294089b0fe2f4d1620d86aa5cb4e05d25e60f4dff935670accfbbc6b" + }, + { + "name": "Asymptotic", + "mvrName": "@asymptotic/attestation", + "iconUrl": "https://www.asymptotic.tech/favicon.svg", + "originalId": "0xbd7f5ade9c9dd44fce79a34bbc468cebe1f89255984475eddc0d6454c445bcdb" + }, + { + "name": "Certora", + "iconUrl": "https://www.certora.com/favicon.ico", + "originalId": "0xd425b829cf1be09427569afca8f865d9157a124ee8a08c56ad6e7caa825091e6" + }, + { + "name": "Source Verification", + "originalId": "0x3a38d71666de1b83e4e3d3a7ad5c49cbb8b1aaab35da0d8c13e186753968b9d7", + "role": "source-verification" + } + ] + } +} diff --git a/app/src/lib/attestations.ts b/app/src/lib/attestations.ts new file mode 100644 index 00000000..45baf5fb --- /dev/null +++ b/app/src/lib/attestations.ts @@ -0,0 +1,207 @@ +// 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 { SuiClientTypes } from "@mysten/sui/client"; +import checkedInConfigs from "./attestation-config.json"; + +/** A 0x-prefixed Sui address. */ +export type SuiAddress = string; +/** A 0x-prefixed object id. */ +export type ObjectId = string; + +// === 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. The full + * lineage (every version) is resolved from this at load; see + * `useTrustedAttestors`. */ + originalId: SuiAddress; + /** How this attester's attestations are surfaced. Default (`undefined`) is an + * endorsement, shown as a card on the subject's Security tab. + * `"source-verification"` routes them out of that list and into the source + * panel next to the package's source link instead. This is a *consumer-side* + * decision (config, not the attester-asserted Display), so it can't be + * spoofed by an attestation claiming a role. */ + role?: "source-verification"; +} + +/** A trusted attester with its package lineage resolved from `originalId` (every + * version id — original publish + upgrades). An attestation is this attester's + * when its inner type's defining package is in `lineage` — the off-chain form of + * the on-chain `attester_of() = type_name::original_id()` rule, expanded to + * every version so a type added in an upgrade still matches. */ +export interface ResolvedAttestor extends TrustedAttestor { + lineage: SuiAddress[]; +} + +export interface AttestationConfig { + /** attestations package id (the `Attestation<>` wrapper type). */ + registryPkg: SuiAddress; + /** The shared Registry object id — parent for per-subject Box derivation. */ + registryId: ObjectId; + trustedAttestors: TrustedAttestor[]; +} + +/** Per-network trust configs — the checked-in JSON's shape and the env override's + * shape are the same. A network with no entry keeps the feature dormant there. */ +export type AttestationConfigs = Partial< + Record<"mainnet" | "testnet", AttestationConfig> +>; + +let cached: AttestationConfigs | undefined; + +/** + * All trust configs: the `NEXT_PUBLIC_ATTESTATION_CONFIG` env override (the local + * demo) if set, else the checked-in `attestation-config.json`. Checked-in-as-code + * (rather than an env var) is what lets a deployed build — a Vercel preview, say — + * show attestations with no dashboard configuration; the override is the local + * demo's, like `NEXT_PUBLIC_LOCAL_*` for the endpoints. + */ +function allConfigs(): AttestationConfigs { + if (cached === undefined) { + const raw = process.env.NEXT_PUBLIC_ATTESTATION_CONFIG; + cached = raw + ? (JSON.parse(raw) as AttestationConfigs) + : (checkedInConfigs as AttestationConfigs); + } + return cached; +} + +/** The trust config for `network`, or null when none is set there (the feature is + * dormant on that network). */ +export function attestationConfig( + network: "mainnet" | "testnet", +): AttestationConfig | null { + const cfg = allConfigs()[network]; + return cfg && cfg.trustedAttestors.length > 0 ? cfg : null; +} + +/** Every trusted attester across all networks, deduped by `originalId` — for the + * network-agnostic "trusted attestors" listing. */ +export function allTrustedAttestors(): TrustedAttestor[] { + const seen = new Set(); + const out: TrustedAttestor[] = []; + for (const cfg of Object.values(allConfigs())) { + for (const a of cfg?.trustedAttestors ?? []) { + const id = normalizeSuiAddress(a.originalId); + if (!seen.has(id)) { + seen.add(id); + out.push(a); + } + } + } + return out; +} + +// === 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: SuiAddress, + registryId: ObjectId, + subject: SuiAddress, + revoked: boolean, +): ObjectId { + const keyBytes = BoxKey.serialize({ + subject: normalizeSuiAddress(subject), + revoked, + }).toBytes(); + return deriveObjectID( + registryId, + `${registryPkg}::attestations::BoxKey`, + keyBytes, + ); +} + +/** Address of the subject's active `Box` (`revoked: false`). `revoke` moves an + * attestation to the sibling revoked box, so reading this address yields + * exactly the un-revoked set. */ +export function boxAddress( + registryPkg: SuiAddress, + registryId: ObjectId, + subject: SuiAddress, +): ObjectId { + return derivedBox(registryPkg, registryId, subject, false); +} + +/** Address of the subject's revoked `Box` (`revoked: true`) — where `revoke` + * moves attestations. Lets the read-by-type Issued tab tell a revoked + * attestation from a live one by its owner, since the object carries no + * status field. */ +export function revokedBoxAddress( + registryPkg: SuiAddress, + registryId: ObjectId, + subject: SuiAddress, +): ObjectId { + return derivedBox(registryPkg, registryId, subject, true); +} + +// === Attestation info + mapping === + +export interface AttestationInfo { + id: ObjectId; + /** The inner type `T`, e.g. `0xAUD::audit::Audit`. The full object type is + * always `${registryPkg}::attestations::Attestation<${innerType}>`. */ + innerType: string; + /** Server-rendered Display v2 fields (all values are strings). */ + display: Record; +} + +const ATTESTATION_RE = /::attestations::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( + object: SuiClientTypes.Object<{ display: true }>, +): AttestationInfo | null { + if (!object.type) return null; + const m = object.type.match(ATTESTATION_RE); + if (!m) return null; + return { + id: object.objectId, + innerType: m[1]!, + display: (object.display?.output ?? {}) as Record, + }; +} + +/** The defining (origin) package id of an inner type string. */ +export function innerTypePackage(innerType: string): SuiAddress { + return normalizeSuiAddress(innerType.split("::")[0]!); +} + +/** Whether `pkg` is a version of a trusted attester's package. `attestors` come + * from `useTrustedAttestors` (lineages resolved); an empty list — still resolving + * or none configured — yields false. */ +export function isConfiguredAttestor( + attestors: ResolvedAttestor[], + pkg: SuiAddress, +): boolean { + const id = normalizeSuiAddress(pkg); + return attestors.some((a) => a.lineage.some((v) => normalizeSuiAddress(v) === id)); +} + +/** The trusted attester whose lineage defines `innerType`, if any. */ +export function attestorFor( + attestors: ResolvedAttestor[], + innerType: string, +): ResolvedAttestor | undefined { + const pkg = innerTypePackage(innerType); + return attestors.find((a) => a.lineage.some((id) => normalizeSuiAddress(id) === pkg)); +} diff --git a/app/src/utils/types.ts b/app/src/utils/types.ts index 51355fbc..26629eeb 100644 --- a/app/src/utils/types.ts +++ b/app/src/utils/types.ts @@ -75,4 +75,8 @@ export enum AppQueryKeys { MVR_VERSION_ADDRESSES = "mvr-version-addresses", SUINS_NAME_RESOLUTION = "suins-name-resolution", NAME_ANALYTICS = "name-analytics", + ATTESTATIONS = "attestations", + TRUSTED_ATTESTORS = "trusted-attestors", + SOURCE_VERIFICATIONS = "source-verifications", + RESOLVE_GIT_COMMIT = "resolve-git-commit", }