+ Every organisation below registered on StackStream from its own wallet, on Stacks
+ mainnet. Nothing here is typed in by us: each name, date and stream count comes
+ straight from the chain, and every entry links to its receipt.
+
+ The contract counts {unlisted} more{" "}
+ {unlisted === 1 ? "registration" : "registrations"} that could not be listed from the
+ transaction history yet. They are included in the total above.
+
+ The directory is read live from Stacks, and the node did not answer. Refresh in a moment,
+ or check the live count at{" "}
+
+ /api/stats
+
+ .
+
+
+ );
+}
+
+// ============================================================================
+// How to join
+// ============================================================================
+
+const STEPS = [
+ {
+ icon: Building2,
+ title: "Register once",
+ body: "One transaction from your organisation's wallet puts you in this directory.",
+ },
+ {
+ icon: Link2,
+ title: "Open a stream",
+ body: "Pick who, how much and how long, in sBTC, USDA or your own token. One link does it.",
+ },
+ {
+ icon: Waves,
+ title: "They claim as they earn",
+ body: "Balances grow every few seconds. You can pause, top up or cancel at any time.",
+ },
+];
+
+function HowToJoin() {
+ return (
+
+
+
+ Joining takes a few minutes.
+
+
+ No fee from us. Funds sit in an audited contract, never with us.
+
+
+
+ {STEPS.map((step, i) => (
+
+
+ 0{i + 1}
+
+
+
{step.title}
+
{step.body}
+
+ ))}
+
+
+
+ Register your organisation
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index 5f841cf..80a4548 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -7,39 +7,12 @@ import { AssistantSection } from "@/components/landing/assistant-section";
import { FAQ } from "@/components/landing/faq";
import { CTASection } from "@/components/landing/cta-section";
import { Footer } from "@/components/landing/footer";
-import Link from "next/link";
-import Image from "next/image";
+import { SiteNav } from "@/components/landing/site-nav";
export default function LandingPage() {
return (
- {/* Landing nav */}
-
+
diff --git a/frontend/src/components/landing/footer.tsx b/frontend/src/components/landing/footer.tsx
index 7db10ec..98ff27e 100644
--- a/frontend/src/components/landing/footer.tsx
+++ b/frontend/src/components/landing/footer.tsx
@@ -41,6 +41,7 @@ const footerLinks = [
{
heading: "Protocol",
links: [
+ { label: "Organisations", href: "/organisations" },
{ label: "Dashboard", href: "/dashboard" },
{ label: "Earn", href: "/earn" },
{ label: "Create Stream", href: "/dashboard/create" },
diff --git a/frontend/src/components/landing/site-nav.tsx b/frontend/src/components/landing/site-nav.tsx
new file mode 100644
index 0000000..586683f
--- /dev/null
+++ b/frontend/src/components/landing/site-nav.tsx
@@ -0,0 +1,43 @@
+import Link from "next/link";
+import Image from "next/image";
+
+const links = [
+ { href: "/organisations", label: "Organisations" },
+ { href: "/dashboard", label: "Dashboard" },
+ { href: "/earn", label: "Earn" },
+];
+
+/** Top navigation for the public pages (landing, organisations). */
+export function SiteNav() {
+ return (
+
+ );
+}
diff --git a/frontend/src/lib/openclaw-server.ts b/frontend/src/lib/openclaw-server.ts
index ff454e8..53c236f 100644
--- a/frontend/src/lib/openclaw-server.ts
+++ b/frontend/src/lib/openclaw-server.ts
@@ -25,9 +25,9 @@ const CONTRACT_DEPLOYER =
"SP2V6TCRFTYQHP8F4D9HSFZHRQNGVBQEZR0TMSM79";
const STREAM_MANAGER_CONTRACT = `${CONTRACT_DEPLOYER}.stream-manager`;
-const STREAM_FACTORY_CONTRACT = `${CONTRACT_DEPLOYER}.stream-factory`;
+export const STREAM_FACTORY_CONTRACT = `${CONTRACT_DEPLOYER}.stream-factory`;
-const HIRO_API_BASE = IS_MAINNET
+export const HIRO_API_BASE = IS_MAINNET
? "https://api.mainnet.hiro.so"
: "https://api.testnet.hiro.so";
diff --git a/frontend/src/lib/organisations.ts b/frontend/src/lib/organisations.ts
new file mode 100644
index 0000000..1a3af10
--- /dev/null
+++ b/frontend/src/lib/organisations.ts
@@ -0,0 +1,181 @@
+/**
+ * The public directory of organisations registered on StackStream.
+ *
+ * stream-factory keeps organisations in a map keyed by admin address, and
+ * Clarity maps cannot be enumerated: the contract can say whether an address is
+ * registered and how many are, but not who they are. The list therefore comes
+ * from the chain's transaction history (every successful `register-dao` call),
+ * and each entry's live record (name, streams, status) is then read back from
+ * the contract, so nothing shown here comes from us.
+ *
+ * Server-only: it pages through the Hiro API and makes read-only calls.
+ */
+
+import {
+ getDao,
+ getDaoCount,
+ getNetwork,
+ HIRO_API_BASE,
+ STREAM_FACTORY_CONTRACT,
+} from "./openclaw-server";
+
+/** One organisation, as shown on /organisations and returned by /api/organisations. */
+export interface Organisation {
+ name: string;
+ admin: string;
+ isActive: boolean;
+ /** Streams the organisation has linked to its workspace via `track-stream`. */
+ streamsTracked: number;
+ registeredAtBlock: number;
+ /** ISO time of the Bitcoin block that anchored the registration, if known. */
+ registeredAt: string | null;
+ registrationTxId: string;
+}
+
+export interface OrganisationDirectory {
+ network: "mainnet" | "testnet";
+ /** `get-dao-count`, the authoritative total. */
+ total: number;
+ /** Newest first. */
+ organisations: Organisation[];
+ /**
+ * Registrations the count includes but the history scan could not list, for
+ * example a `register-dao` reached through another contract. Shown, never hidden.
+ */
+ unlisted: number;
+ asOf: string;
+}
+
+// ============================================================================
+// Parsing (pure, unit-tested)
+// ============================================================================
+
+/** The fields this module reads from a Hiro `/transactions` row. */
+export interface HiroTxRow {
+ tx_id?: string;
+ tx_type?: string;
+ tx_status?: string;
+ sender_address?: string;
+ block_height?: number;
+ burn_block_time_iso?: string;
+ contract_call?: { contract_id?: string; function_name?: string };
+}
+
+export interface Registration {
+ admin: string;
+ txId: string;
+ blockHeight: number;
+ registeredAt: string | null;
+}
+
+/**
+ * Pick the successful `register-dao` calls on the factory out of a page of
+ * transactions. Failed calls (a duplicate name, an address that already
+ * registered) are dropped because they registered nothing. An admin can only
+ * register once, but if the history ever showed two, the earliest is the real one.
+ */
+export function parseRegistrations(
+ rows: readonly HiroTxRow[],
+ factoryContract: string
+): Registration[] {
+ const byAdmin = new Map();
+ for (const row of rows) {
+ if (row.tx_type !== "contract_call" || row.tx_status !== "success") continue;
+ if (row.contract_call?.contract_id !== factoryContract) continue;
+ if (row.contract_call.function_name !== "register-dao") continue;
+ if (!row.sender_address || !row.tx_id || typeof row.block_height !== "number") continue;
+
+ const registration: Registration = {
+ admin: row.sender_address,
+ txId: row.tx_id,
+ blockHeight: row.block_height,
+ registeredAt: row.burn_block_time_iso ?? null,
+ };
+ const existing = byAdmin.get(registration.admin);
+ if (!existing || registration.blockHeight < existing.blockHeight) {
+ byAdmin.set(registration.admin, registration);
+ }
+ }
+ return [...byAdmin.values()].sort((a, b) => b.blockHeight - a.blockHeight);
+}
+
+/** Two-letter monogram for an organisation's avatar. */
+export function organisationInitials(name: string): string {
+ const words = name.trim().split(/\s+/).filter(Boolean);
+ if (words.length === 0) return "?";
+ const letters =
+ words.length === 1 ? [...words[0]].slice(0, 2) : [[...words[0]][0], [...words[1]][0]];
+ return letters.join("").toUpperCase();
+}
+
+/** Explorer link for a transaction on the network the app runs on. */
+export function explorerTxUrl(txId: string, network: "mainnet" | "testnet"): string {
+ return `https://explorer.hiro.so/txid/${txId}?chain=${network}`;
+}
+
+// ============================================================================
+// Fetching
+// ============================================================================
+
+const PAGE_SIZE = 50;
+/** Hard stop on paging, so a slow or misbehaving API cannot loop forever. */
+const MAX_PAGES = 40;
+
+async function fetchFactoryTransactions(): Promise {
+ const rows: HiroTxRow[] = [];
+ for (let page = 0; page < MAX_PAGES; page++) {
+ const url =
+ `${HIRO_API_BASE}/extended/v1/address/${STREAM_FACTORY_CONTRACT}/transactions` +
+ `?limit=${PAGE_SIZE}&offset=${page * PAGE_SIZE}`;
+ const res = await fetch(url, { cache: "no-store", headers: { Accept: "application/json" } });
+ if (!res.ok) throw new Error(`Hiro transactions request failed (${res.status})`);
+ const data = (await res.json()) as { total?: number; results?: HiroTxRow[] };
+ const results = data.results ?? [];
+ rows.push(...results);
+ if (results.length < PAGE_SIZE || rows.length >= (data.total ?? 0)) break;
+ }
+ return rows;
+}
+
+/**
+ * Registrations change rarely and the page may be shared widely, so the
+ * directory is cached per warm instance for a minute, the same policy as
+ * /api/stats.
+ */
+const CACHE_TTL_MS = 60_000;
+let cached: { directory: OrganisationDirectory; expires: number } | null = null;
+
+export async function getOrganisationDirectory(): Promise {
+ if (cached && Date.now() < cached.expires) return cached.directory;
+
+ const [rows, total] = await Promise.all([fetchFactoryTransactions(), getDaoCount()]);
+ const registrations = parseRegistrations(rows, STREAM_FACTORY_CONTRACT);
+
+ // The contract is the source of truth for each record. A registration whose
+ // record is gone (it never should be) is left out rather than shown stale.
+ const records = await Promise.all(registrations.map((r) => getDao(r.admin)));
+ const organisations: Organisation[] = [];
+ registrations.forEach((registration, i) => {
+ const record = records[i];
+ if (!record) return;
+ organisations.push({
+ name: record.name,
+ admin: record.admin,
+ isActive: record.isActive,
+ streamsTracked: record.totalStreamsCreated,
+ registeredAtBlock: record.createdAtBlock,
+ registeredAt: registration.registeredAt,
+ registrationTxId: registration.txId,
+ });
+ });
+
+ const directory: OrganisationDirectory = {
+ network: getNetwork(),
+ total,
+ organisations,
+ unlisted: Math.max(0, total - organisations.length),
+ asOf: new Date().toISOString(),
+ };
+ cached = { directory, expires: Date.now() + CACHE_TTL_MS };
+ return directory;
+}
diff --git a/tests/organisations.test.ts b/tests/organisations.test.ts
new file mode 100644
index 0000000..4813228
--- /dev/null
+++ b/tests/organisations.test.ts
@@ -0,0 +1,77 @@
+import { describe, it, expect } from "vitest";
+
+import {
+ parseRegistrations,
+ organisationInitials,
+ explorerTxUrl,
+ type HiroTxRow,
+} from "../frontend/src/lib/organisations";
+
+const FACTORY = "SP2V6TCRFTYQHP8F4D9HSFZHRQNGVBQEZR0TMSM79.stream-factory";
+const GRIMALDO = "SP1A5M0ZRSNQMF8BNPQPM8WWC5PJ6HJ4GEKFP8W4M";
+const OTHER = "SP2C2YFP12AJZB4MABJBAJ55XECVS7E4PMMZ89YZR";
+
+function registration(overrides: Partial = {}): HiroTxRow {
+ return {
+ tx_id: "0x0e1d",
+ tx_type: "contract_call",
+ tx_status: "success",
+ sender_address: GRIMALDO,
+ block_height: 9_134_238,
+ burn_block_time_iso: "2026-10-06T15:33:44.000Z",
+ contract_call: { contract_id: FACTORY, function_name: "register-dao" },
+ ...overrides,
+ };
+}
+
+describe("parseRegistrations", () => {
+ it("reads a successful register-dao call", () => {
+ expect(parseRegistrations([registration()], FACTORY)).toEqual([
+ {
+ admin: GRIMALDO,
+ txId: "0x0e1d",
+ blockHeight: 9_134_238,
+ registeredAt: "2026-10-06T15:33:44.000Z",
+ },
+ ]);
+ });
+
+ it("ignores failed calls, other functions, other contracts and the deploy", () => {
+ const rows: HiroTxRow[] = [
+ registration({ tx_status: "abort_by_response" }),
+ registration({ contract_call: { contract_id: FACTORY, function_name: "track-stream" } }),
+ registration({ contract_call: { contract_id: "SP000.other", function_name: "register-dao" } }),
+ { tx_id: "0xdeploy", tx_type: "smart_contract", tx_status: "success", block_height: 1 },
+ ];
+ expect(parseRegistrations(rows, FACTORY)).toEqual([]);
+ });
+
+ it("keeps the earliest registration per admin and lists newest first", () => {
+ const rows: HiroTxRow[] = [
+ registration({ tx_id: "0xlater", block_height: 9_200_000 }),
+ registration({ tx_id: "0xfirst", block_height: 9_134_238 }),
+ registration({ tx_id: "0xother", sender_address: OTHER, block_height: 9_150_000 }),
+ ];
+ const parsed = parseRegistrations(rows, FACTORY);
+ expect(parsed.map((r) => r.txId)).toEqual(["0xother", "0xfirst"]);
+ });
+
+ it("keeps a registration whose anchor time is not known yet", () => {
+ const [parsed] = parseRegistrations([registration({ burn_block_time_iso: undefined })], FACTORY);
+ expect(parsed.registeredAt).toBeNull();
+ });
+});
+
+describe("organisationInitials", () => {
+ it("uses the first two letters of a single word, and the first of two words", () => {
+ expect(organisationInitials("Grimaldo")).toBe("GR");
+ expect(organisationInitials("Zero Authority DAO")).toBe("ZA");
+ expect(organisationInitials(" ")).toBe("?");
+ });
+});
+
+describe("explorerTxUrl", () => {
+ it("links to the network the app runs on", () => {
+ expect(explorerTxUrl("0xabc", "mainnet")).toBe("https://explorer.hiro.so/txid/0xabc?chain=mainnet");
+ });
+});