diff --git a/app/contracts/deployments.ts b/app/contracts/deployments.ts new file mode 100644 index 0000000..20ce5eb --- /dev/null +++ b/app/contracts/deployments.ts @@ -0,0 +1,118 @@ +/** + * Local mirror of the deployment registry in modeltrace-contract. + * + * The canonical registry lives in the contracts repository as + * `deployments/{testnet,mainnet}.json` (tracked in modeltrace-contract#62). + * This file is shaped to be a straight copy of that record — contract id, + * WASM hash, build commit, deploy timestamp — so that when the registry lands, + * updating this page is a data swap, not a rewrite. + * + * Until the contracts are actually deployed, every address and hash below is + * `null` and the page renders the honest "not yet deployed" state. This file + * must never be filled with unverified values: the entire point of the page + * is that a reader can verify the deployed bytes against the source, so an + * unverifiable address would be worse than none. + */ + +export type ContractSlug = "audit-registry" | "usage-meter" | "payment-router"; +export type ContractRole = "Attestation" | "Metering" | "Settlement"; + +export interface ContractMeta { + slug: ContractSlug; + name: string; + role: ContractRole; + /** What the contract holds on-chain. */ + holds: string; + /** Why this contract exists separately from the other two. */ + rationale: string; + /** Direct link to the crate source in modeltrace-contract. */ + sourceUrl: string; +} + +export const CONTRACT_META: Record = { + "audit-registry": { + slug: "audit-registry", + name: "Audit Registry", + role: "Attestation", + holds: "Signed inference events — model version, policy ref, timestamp, submitter.", + rationale: + "Attestation is the hottest path in the system: every inference a gateway logs becomes a record. It must be cheap, append-only, and safe to call at volume, so it stays free of money-moving logic that would force it to be conservative.", + sourceUrl: + "https://github.com/FinesseStudioLab/modeltrace-contract/tree/main/audit-registry", + }, + "usage-meter": { + slug: "usage-meter", + name: "Usage Meter", + role: "Metering", + holds: "Usage units, quota buckets, pricing tiers.", + rationale: + "Metering converts raw attestations into billable units. It reads attestations and prices them against tiers, but it holds no funds — keeping it between the two extremes lets it change pricing without touching either escrow or the audit trail.", + sourceUrl: + "https://github.com/FinesseStudioLab/modeltrace-contract/tree/main/usage-meter", + }, + "payment-router": { + slug: "payment-router", + name: "Payment Router", + role: "Settlement", + holds: "Escrow, dispute windows, payout release.", + rationale: + "Settlement moves money, so it must be the most conservative contract in the system: slow to act, gated by dispute windows, and rare by design. Isolating it means an upgrade or audit of money movement never touches the attestation rail.", + sourceUrl: + "https://github.com/FinesseStudioLab/modeltrace-contract/tree/main/payment-router", + }, +}; + +/** One row of the deployment registry, mirroring the contracts repo record. */ +export interface DeployedContract { + slug: ContractSlug; + /** Soroban contract id (C…). `null` until the registry lands. */ + address: string | null; + /** SHA-256 of the deployed WASM bytes. `null` until the registry lands. */ + wasmHash: string | null; + /** Source commit the deployed WASM was built from. */ + commit: string | null; + /** ISO-8601 deploy timestamp. */ + deployedAt: string | null; +} + +export interface NetworkDeployments { + network: "testnet" | "mainnet"; + label: string; + /** Explorer prefix; the contract address is appended to build the link. */ + explorerUrl: string; + contracts: DeployedContract[]; +} + +/** + * Read by the page — never hardcode an address in JSX. When + * modeltrace-contract#62 lands, copy the registry values in here. + */ +export const DEPLOYMENTS: NetworkDeployments[] = [ + { + network: "testnet", + label: "Testnet", + explorerUrl: "https://stellar.expert/explorer/testnet/contract/", + contracts: [ + { slug: "audit-registry", address: null, wasmHash: null, commit: null, deployedAt: null }, + { slug: "usage-meter", address: null, wasmHash: null, commit: null, deployedAt: null }, + { slug: "payment-router", address: null, wasmHash: null, commit: null, deployedAt: null }, + ], + }, + { + network: "mainnet", + label: "Mainnet", + explorerUrl: "https://stellar.expert/explorer/public/contract/", + contracts: [ + { slug: "audit-registry", address: null, wasmHash: null, commit: null, deployedAt: null }, + { slug: "usage-meter", address: null, wasmHash: null, commit: null, deployedAt: null }, + { slug: "payment-router", address: null, wasmHash: null, commit: null, deployedAt: null }, + ], + }, +]; + +/** True once any contract on any network carries a real address. */ +export function hasLiveDeployments(): boolean { + return DEPLOYMENTS.some((network) => + network.contracts.some((c) => c.address !== null), + ); +} diff --git a/app/contracts/page.tsx b/app/contracts/page.tsx index ff64af5..c557d9b 100644 --- a/app/contracts/page.tsx +++ b/app/contracts/page.tsx @@ -1,3 +1,146 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { CONTRACT_META, hasLiveDeployments } from "./deployments"; +import { DeploymentTable } from "@/components/deployment-table"; +import { FlowDiagram } from "@/components/flow-diagram"; + +export const metadata: Metadata = { + title: "Contracts", + description: + "The three ModelTrace contracts — audit-registry, usage-meter, payment-router — why they are separate, where they are deployed, and how to verify the deployed bytes yourself.", +}; + +const CONTRACT_ORDER = ["audit-registry", "usage-meter", "payment-router"] as const; + +const REGISTRY_ISSUE_URL = + "https://github.com/FinesseStudioLab/modeltrace-contract/issues/62"; +const CONTRACTS_REPO_URL = "https://github.com/FinesseStudioLab/modeltrace-contract"; +const CONTRACTS_STATUS_URL = + "https://github.com/FinesseStudioLab/modeltrace-contract#current-status"; + +export default function Page() { + const live = hasLiveDeployments(); + + return ( +
+ Contracts +

The contracts behind ModelTrace

+

+ ModelTrace's rules live in three Soroban contracts on Stellar: + attestation, metering, and settlement. They are deliberately separate — + attestation is cheap and frequent, settlement is conservative and rare — + so each one can be reasoned about and audited on its own. +

+ + {/* Why three contracts */} +
+ {CONTRACT_ORDER.map((slug) => { + const meta = CONTRACT_META[slug]; + return ( + + ); + })} +
+

+ One rule of thumb ties the design together: the closer a contract gets + to moving money, the slower and more careful it must be. Attestations + are written on every inference and never cost anything to dispute; + settlements move funds, so they wait out a dispute window and only fire + when the case is clear. +

+ + {/* Live deployments */} +
+

Deployed addresses

+

+ Addresses and WASM hashes below are read from the deployment registry, + not hardcoded in this page. Each address links to its explorer entry. +

+ {!live ? ( +
+ Not yet deployed. The contracts are compiling + scaffolds today, and the deployment registry they will be published + in is tracked in{" "} + + modeltrace-contract#62 + + . This page goes live the moment that registry lands — no page + rewrite needed. +
+ ) : null} + +
+ + {/* Verify it yourself */} +
+

Verify it yourself

+

+ The WASM hash next to each address is a SHA-256 of the deployed + bytes. Rebuild from source and compare — if the hashes match, the + deployed contract is exactly what the source says it is. +

+
    +
  1. + Clone and build the contracts with the pinned toolchain (requires + Rust 1.84+ and the Soroban WASM target): + {/* Scrolls on narrow screens (unbreakable command lines), so it + needs a tab stop to satisfy axe's scrollable-region-focusable. */} +
    {`git clone https://github.com/FinesseStudioLab/modeltrace-contract
    +cd modeltrace-contract
    +rustup target add wasm32v1-none
    +cargo build --release --target wasm32v1-none`}
    +
  2. +
  3. + Hash the artifact for the contract you want to check. The release + profile in the workspace (opt-level z, LTO, stripped) is what makes + the output reproducible: +
    {`sha256sum target/wasm32v1-none/release/audit_registry.wasm
    +sha256sum target/wasm32v1-none/release/usage_meter.wasm
    +sha256sum target/wasm32v1-none/release/payment_router.wasm`}
    +
  4. +
  5. + Compare each result with the WASM hash in the table above. A match + means the bytes on-chain were built from this source at the + recorded commit. Use the Soroban WASM target{" "} + wasm32v1-nonewasm32-unknown-unknown{" "} + produces bytes the network rejects. +
  6. +
+
+ + {/* Flow */} +
+

How an inference flows through

+ +
+ + {/* Source & audit status */} +
+

Source and audit status

+

+ The contracts are open source under Apache-2.0. They are currently + compiling scaffolds — real domain entrypoints, authorization, and + tests are tracked as open issues in the contracts repository. +

+
+ + modeltrace-contract on GitHub + + + Audit status + + + Production milestones + +
import { Address, Hash } from "@/components/address-hash"; // Synthetic but realistic-length values for the demo. diff --git a/app/globals.css b/app/globals.css index a3daa11..c241860 100644 --- a/app/globals.css +++ b/app/globals.css @@ -490,116 +490,231 @@ a { color: inherit; text-decoration: none; } letter-spacing: 0.04em; } -/* === Responsive breakpoints ========================================== - * - * Until now this sheet had no media queries at all: every layout relied on - * flex-wrap, auto-fit grids, and clamp(). That degrades rather than adapts — - * it stops things overflowing but leaves the header cramped and the hero - * oversized on small screens. - * - * Two breakpoints, chosen from where the layouts actually break rather than - * from device names: - * 820px — the header runs out of room for the brand plus seven nav links - * 520px — single-column territory; padding and type need to come down - * ==================================================================== */ - -/* The header restructures at exactly the width where SiteNav switches modes. - * These rules previously sat at 820px while the toggle and the mobile panel - * appear at 767px, which left a 53px band where the bar was stacked into a - * column with no toggle to justify it. */ -@media (max-width: 767px) { - .nav-inner { - flex-direction: column; - align-items: flex-start; - gap: 10px; - padding: 12px 0; - } +/* === contracts page === */ +.contracts-lead { + color: var(--muted); + max-width: 720px; + font-size: 1.02rem; + margin: 0 0 26px; +} - /* Wrapped nav links need a larger tap target once they are no longer on a - single line — 44px minimum per WCAG 2.5.5. */ - .links { - gap: 4px 10px; - width: 100%; - } +.contracts-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 16px; + padding: 8px 0 6px; +} - .links a { - display: inline-flex; - align-items: center; - min-height: 44px; - padding: 0 2px; - } +.contract-card { + display: flex; + flex-direction: column; + gap: 8px; } -/* Spacing density, unrelated to the nav — this threshold stays where it was. */ -@media (max-width: 820px) { - .landing-hero { - padding: 24px 0 36px; - } +.contract-role { + align-self: flex-start; + padding: 4px 10px; + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 14%, transparent); + border: 1px solid var(--ring); + color: color-mix(in srgb, var(--accent) 85%, white); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.06em; +} - .landing-pillars { - gap: 14px; - padding-bottom: 32px; - } +.contract-rationale { font-size: 0.86rem; } +.contract-source { color: var(--accent); font-size: 0.88rem; margin-top: auto; } - .grid { - gap: 14px; - } +.contracts-separation { + border-left: 3px solid var(--accent); + border-radius: 0 12px 12px 0; + padding: 12px 16px; + margin: 20px 0 4px; + background: color-mix(in srgb, var(--surface) 70%, transparent); + color: var(--muted); + font-size: 0.92rem; } -@media (max-width: 520px) { - .container { - padding: 0 16px; - } +.contracts-block { margin-top: 40px; } +.contracts-block h3 { margin: 0 0 6px; font-size: 1.25rem; } +.contracts-sub { color: var(--muted); max-width: 720px; margin: 0 0 16px; font-size: 0.93rem; } - .landing-lead { - font-size: 1rem; - } +/* Deployment registry table */ +.deploy-network { margin-bottom: 22px; } +.deploy-network-title { margin: 0 0 6px; font-size: 0.95rem; } - /* Full-width call to action rather than two buttons squeezed side by side. */ - .landing-cta-row { - flex-direction: column; - align-items: stretch; - gap: 10px; - } +.deploy-grid { + width: 100%; + border-collapse: collapse; + font-size: 0.88rem; +} +.deploy-grid th, .deploy-grid td { + text-align: left; + padding: 10px 12px; + border-bottom: 1px solid color-mix(in srgb, var(--muted) 25%, transparent); + vertical-align: top; +} +.deploy-grid th { color: var(--muted); font-weight: 600; white-space: nowrap; } - .landing-cta-row .cta, - .landing-cta-row .cta-secondary { - text-align: center; - } +.deploy-contract { display: block; font-weight: 650; } +.deploy-role { + display: block; + font-size: 0.74rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.deploy-address { color: var(--accent); word-break: break-all; } +.deploy-address:hover { text-decoration: underline; } +.deploy-hash { font-size: 0.78rem; word-break: break-all; } +.deploy-pending { color: var(--muted); font-size: 0.82rem; font-style: italic; } - .landing-stats { - gap: 8px; - font-size: 0.78rem; - } +.deploy-notice { + border: 1px dashed color-mix(in srgb, var(--accent) 45%, transparent); + border-radius: 12px; + padding: 12px 16px; + margin-bottom: 16px; + background: color-mix(in srgb, var(--accent) 8%, transparent); + color: var(--muted); + font-size: 0.88rem; +} +.deploy-notice strong { color: var(--text); } +.deploy-notice a { color: var(--accent); } - .landing-pillar { - padding: 18px; - } +/* Verification steps and code blocks */ +.contracts-steps { + padding-left: 20px; + color: var(--muted); +} +.contracts-steps li { margin-bottom: 16px; } +.contracts-steps strong, .contracts-steps code { color: var(--text); } - .card { - padding: 16px; - } +.contracts-code { + background: color-mix(in srgb, var(--bg) 60%, var(--surface)); + border: 1px solid color-mix(in srgb, var(--muted) 20%, transparent); + border-radius: 10px; + padding: 12px 14px; + overflow-x: auto; + font-size: 0.8rem; + line-height: 1.6; + margin: 10px 0 2px; +} - .landing-trust { - padding: 16px; - letter-spacing: 0.02em; - } +.contracts-links { + display: flex; + flex-wrap: wrap; + gap: 12px; +} +.contracts-links .cta-secondary { margin-top: 0; } - /* The decorative blur orbs are sized off viewport width and dominate the - hero on a phone. */ - .landing-orbs::before, - .landing-orbs::after { - opacity: 0.26; - } +/* Flow diagram. Colours are the app's own tokens (or color-mix of them) plus + one amber for settlement, so the diagram follows the active theme — dark + today, and a future light theme without edits. */ +.flow { + --flow-attest: var(--accent, #59c2ff); + --flow-meter: var(--accent-2, #7cf9c4); + --flow-settle: #e8b36a; + margin: 16px 0 0; } -/* The sticky header costs a disproportionate share of a short landscape - viewport, so it scrolls away there. */ -@media (max-height: 460px) and (orientation: landscape) { - .nav { - position: static; - } +.flow-canvas { + position: relative; + display: flex; + align-items: stretch; + gap: 10px; + padding: 12px 6px 40px; +} + +.flow-node { + --flow-accent: var(--flow-attest); + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + padding: 14px 14px 12px; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--flow-accent) 45%, transparent); + border-top: 3px solid var(--flow-accent); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--surface) 92%, var(--bg)) 0%, + var(--surface) 100% + ); +} +.flow-node-meter { --flow-accent: var(--flow-meter); } +.flow-node-settle { --flow-accent: var(--flow-settle); } + +.flow-stage { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--flow-accent); } +.flow-name { font-weight: 700; font-size: 0.95rem; } +.flow-note { font-size: 0.78rem; color: var(--muted); } + +.flow-arrow { + align-self: center; + color: var(--muted); + font-size: 1.2rem; + flex-shrink: 0; +} + +.flow-loop { + position: absolute; + left: 10%; + right: 10%; + bottom: 8px; + border-top: 1px dashed color-mix(in srgb, var(--muted) 60%, transparent); + text-align: center; +} +.flow-loop span { + position: relative; + top: -0.7em; + background: var(--bg); + padding: 0 10px; + font-size: 0.74rem; + color: var(--muted); + letter-spacing: 0.02em; +} +.flow-loop::before { + content: "▲"; + position: absolute; + left: 0; + top: -0.6em; + font-size: 0.68rem; + color: var(--flow-settle); +} +.flow-loop::after { + content: "▼"; + position: absolute; + right: 0; + top: -0.6em; + font-size: 0.68rem; + color: var(--flow-settle); +} + +.flow-caption { + color: var(--muted); + font-size: 0.86rem; + margin: 4px 0 14px; +} + +.flow-steps { + padding-left: 20px; + color: var(--muted); + font-size: 0.92rem; +} +.flow-steps li { margin-bottom: 10px; } +.flow-steps strong { color: var(--text); } + +@media (max-width: 640px) { + .flow-canvas { flex-direction: column; } + .flow-arrow { transform: rotate(90deg); align-self: center; } + .flow-loop { display: none; } /* ── Address / Hash primitives ─────────────────────────────────────────────── * * Used by
and in components/address-hash.tsx. diff --git a/components/charts/chart-frame.tsx b/components/charts/chart-frame.tsx index 2cc55d0..df60f4c 100644 --- a/components/charts/chart-frame.tsx +++ b/components/charts/chart-frame.tsx @@ -76,6 +76,9 @@ export function ChartFrame({ className={wide ? `${styles.plot} ${styles.plotWide}` : styles.plot} role="img" aria-label={summary} + // Wide plots scroll on narrow screens (520px legibility floor); a + // scroll container without a tab stop fails axe's + // scrollable-region-focusable and is unreachable by keyboard. // axe scrollable-region-focusable: any overflow:auto region must be // reachable by keyboard so users can scroll it without a mouse. tabIndex={wide ? 0 : undefined} @@ -106,7 +109,11 @@ export function ChartFrame({ {table ? (
{tableLabel} -
{table}
+ {/* Same rule as the plot: the nowrap table scrolls on narrow + screens, so the wrapper needs a tab stop. */} +
+ {table} +
) : null} diff --git a/components/deployment-table.tsx b/components/deployment-table.tsx new file mode 100644 index 0000000..8c6c3ef --- /dev/null +++ b/components/deployment-table.tsx @@ -0,0 +1,99 @@ +import { CONTRACT_META, DEPLOYMENTS, type NetworkDeployments } from "../app/contracts/deployments"; + +/** + * The deployed-address table for the contracts page. + * + * Every value is read from the deployment registry (`DEPLOYMENTS`), never + * hardcoded in JSX. Until the contracts repo publishes its registry the + * address and WASM-hash cells render an honest "not yet deployed" state; the + * moment a real address exists it becomes an explorer link, so the page goes + * live by editing data, not markup. + */ +export function DeploymentTable({ + networks = DEPLOYMENTS, +}: { + networks?: NetworkDeployments[]; +}) { + return ( +
+ {networks.map((network) => ( +
+

{network.label}

+ {/* Scrolls on narrow screens, so it must be keyboard-focusable: + axe's scrollable-region-focusable requires a tab stop on any + scroll container, or the table is unreachable without a mouse. */} +
+ + + + + + + + + + + + {network.contracts.map((contract) => { + const meta = CONTRACT_META[contract.slug]; + const explorerLink = contract.address + ? `${network.explorerUrl}${contract.address}` + : null; + + return ( + + + + + + + + ); + })} + +
ContractAddressWASM hashBuilt fromDeployed
+ {meta.name} + {meta.role} + + {explorerLink ? ( + + {contract.address} + + ) : ( + Not yet deployed + )} + + {contract.wasmHash ? ( + {contract.wasmHash} + ) : ( + Pending + )} + + {contract.commit ? ( + {contract.commit.slice(0, 7)} + ) : ( + + )} + + {contract.deployedAt ? ( + {new Date(contract.deployedAt).toISOString().slice(0, 10)} + ) : ( + + )} +
+
+
+ ))} +
+ ); +} diff --git a/components/flow-diagram.tsx b/components/flow-diagram.tsx new file mode 100644 index 0000000..7f41e38 --- /dev/null +++ b/components/flow-diagram.tsx @@ -0,0 +1,63 @@ +/** + * The attest → meter → settle flow, drawn with the app's own tokens so it + * follows whatever theme is active (the site is dark today; if a light theme + * is added, every colour here is a `var()` or a `color-mix` of one, so the + * diagram stays legible without edits). + * + * The canvas is decorative to screen readers (`role="img"` + aria-label); the + * ordered list below it is the real content and carries the flow in words. + */ +export function FlowDiagram() { + return ( +
+
+
+ Attest + Audit Registry + cheap · frequent +
+ +
+ Meter + Usage Meter + priced · quota-gated +
+ +
+ Settle + Payment Router + conservative · rare +
+ +
+
+ One inference, three contracts: the event is attested, priced, and only + then settled — with a dispute window between money moving and the case + closing. +
+
    +
  1. + The gateway signs an inference event — model version, policy ref, + timestamp, submitter — and the Audit Registry stores + it. Append-only, cheap, safe to call on every request. +
  2. +
  3. + The Usage Meter reads attestations and prices them + against the customer's tier and quota, producing billable units. + No funds move here. +
  4. +
  5. + The Payment Router escrows the settlement and opens a + dispute window. When the window closes uncontested, it releases the + payout; a dispute reopens the attestation for review. +
  6. +
+
+ ); +} diff --git a/tests/unit/deployment-table.test.tsx b/tests/unit/deployment-table.test.tsx new file mode 100644 index 0000000..d9b69ac --- /dev/null +++ b/tests/unit/deployment-table.test.tsx @@ -0,0 +1,78 @@ +import { render, screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { DeploymentTable } from "../../components/deployment-table"; +import { DEPLOYMENTS } from "../../app/contracts/deployments"; + +describe("DeploymentTable", () => { + it("renders every network from the registry with the three contracts", () => { + render(); + + for (const network of DEPLOYMENTS) { + const heading = screen.getByRole("heading", { name: network.label }); + const table = heading.closest("section") as HTMLElement; + expect(within(table).getByText("Audit Registry")).toBeInTheDocument(); + expect(within(table).getByText("Usage Meter")).toBeInTheDocument(); + expect(within(table).getByText("Payment Router")).toBeInTheDocument(); + } + }); + + it("shows the honest pending state while nothing is deployed", () => { + render(); + + // Until the deployment registry lands, every address cell must say so + // instead of showing a value that cannot be verified. + const pending = screen.getAllByText("Not yet deployed"); + expect(pending.length).toBe(DEPLOYMENTS.length * 3); + }); + + it("turns a real address into an explorer link", () => { + const address = "CB7XBJUIZVL3KIT2HUYQATIKOJCNXYPBTDQZ3MZQBZ6WCTUVDPVTTU7U"; + render( + + ); + + const link = screen.getByRole("link", { name: address }); + expect(link).toHaveAttribute( + "href", + `https://stellar.expert/explorer/testnet/contract/${address}` + ); + expect(link).toHaveAttribute("target", "_blank"); + }); + + it("publishes the WASM hash next to the address", () => { + const wasmHash = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"; + render( + + ); + + const table = screen.getByRole("table"); + expect(within(table).getByText(wasmHash)).toBeInTheDocument(); + expect(within(table).getByText("abc1234")).toBeInTheDocument(); + }); +}); diff --git a/tests/unit/deployments.test.tsx b/tests/unit/deployments.test.tsx new file mode 100644 index 0000000..486806c --- /dev/null +++ b/tests/unit/deployments.test.tsx @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + CONTRACT_META, + DEPLOYMENTS, + hasLiveDeployments, +} from "../../app/contracts/deployments"; + +const SLUGS = ["audit-registry", "usage-meter", "payment-router"] as const; + +describe("deployment registry", () => { + it("covers every network with exactly the three contracts", () => { + expect(DEPLOYMENTS.map((n) => n.network)).toEqual(["testnet", "mainnet"]); + + for (const network of DEPLOYMENTS) { + expect(network.contracts.map((c) => c.slug)).toEqual([...SLUGS]); + } + }); + + it("documents every deployed contract in CONTRACT_META", () => { + for (const slug of SLUGS) { + const meta = CONTRACT_META[slug]; + expect(meta).toBeDefined(); + expect(meta.name.length).toBeGreaterThan(0); + expect(meta.holds.length).toBeGreaterThan(0); + expect(meta.rationale.length).toBeGreaterThan(0); + expect(meta.sourceUrl).toMatch(/^https:\/\/github\.com\/FinesseStudioLab\/modeltrace-contract\//); + } + }); + + it("holds no unverifiable addresses today", () => { + // The registry is the honest source of truth: until modeltrace-contract#62 + // lands, every value must be null. A fake address on this page would + // contradict the page's entire "verify it yourself" promise. + expect(hasLiveDeployments()).toBe(false); + for (const network of DEPLOYMENTS) { + for (const contract of network.contracts) { + expect(contract.address).toBeNull(); + expect(contract.wasmHash).toBeNull(); + } + } + }); + + it("accepts only well-formed Soroban ids once the registry lands", () => { + // The invariant that must hold the day real deployments are pasted in: + // a Stellar contract id is a base32 string starting with C, 56 chars long. + const stellarContractId = /^C[A-Z2-7]{55}$/; + for (const network of DEPLOYMENTS) { + for (const contract of network.contracts) { + if (contract.address !== null) { + expect(contract.address).toMatch(stellarContractId); + } + } + } + }); + + it("points each network at its own explorer", () => { + const byNetwork = Object.fromEntries(DEPLOYMENTS.map((n) => [n.network, n.explorerUrl])); + expect(byNetwork.testnet).toContain("testnet"); + expect(byNetwork.mainnet).not.toContain("testnet"); + }); +}); diff --git a/tests/unit/flow-diagram.test.tsx b/tests/unit/flow-diagram.test.tsx new file mode 100644 index 0000000..105f3a4 --- /dev/null +++ b/tests/unit/flow-diagram.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { FlowDiagram } from "../../components/flow-diagram"; + +describe("FlowDiagram", () => { + it("names all three stages in order", () => { + render(); + + const stages = screen.getAllByText(/^(Attest|Meter|Settle)$/); + expect(stages.map((el) => el.textContent)).toEqual(["Attest", "Meter", "Settle"]); + }); + + it("is labelled as a single image for screen readers", () => { + render(); + const canvas = screen.getByRole("img"); + + expect(canvas.getAttribute("aria-label")).toContain("Audit Registry"); + expect(canvas.getAttribute("aria-label")).toContain("Payment Router"); + }); + + it("carries the flow as text, not just as a picture", () => { + render(); + const steps = screen.getAllByRole("listitem"); + + expect(steps.length).toBeGreaterThanOrEqual(3); + // Each name appears in the visual canvas and in the step list — the + // picture and the text both carry the same flow. + expect(screen.getAllByText("Audit Registry").length).toBeGreaterThanOrEqual(2); + expect(screen.getAllByText("Payment Router").length).toBeGreaterThanOrEqual(2); + }); +});