Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions .github/workflows/tx-manifest-check.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: tx-manifest / smplx check

# The gate for the tx-manifest package and the smplx adapter, and only for those.
# The gate for the tx-manifest package, the smplx adapter and the offline developer
# tooling in the web app, and only for those.
#
# Deliberately not `bun run check`. On a clean frozen install against the pinned
# submodules, the repository-wide typecheck fails in existing UI code on duplicate React
Expand Down Expand Up @@ -73,6 +74,25 @@ jobs:
apps/extension/src/core/chains/liquid/adapters/smplx/compileCovenantWithSmplx.test.ts \
apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.test.ts

# The web half of this slice: the manifest inspector and the format support page, which
# read the manifest runtime's TypeScript rather than only a JSON fixture from it.
#
# `tsconfig.tooling.json` narrows what is checked, not what is read: it starts from these
# two directories and follows every UI component and package they import under the app's
# own settings, with nothing stubbed or skipped. The whole-app and root typechecks still
# fail on the duplicate type families described above, and are restored in the activation
# slice — gating on them here would report that against every change to this tooling.
- name: Typecheck the manifest tooling
run: bun --filter='./apps/web' run typecheck:tooling

# The production module graph, bundled. It is what says the two new views are reachable
# through the App/Home navigation and that everything they pull in resolves for a browser.
# Vite directly rather than the app's `build` script, which runs the whole-app typecheck
# first: bundling succeeds where that typecheck does not, and this step is about the bundle.
- name: Build the web app
working-directory: apps/web
run: bunx vite build

# Both are repository-wide and both pass on this branch, so they are run as they are
# rather than narrowed to these paths.
- name: Lint
Expand All @@ -81,5 +101,11 @@ jobs:
- name: Check formatting
run: bun run format:check

# `apps/web` carries the server-rendered assertions for the inspector and the format
# page. They render each view to a string with no wallet, no provider and no document,
# which is the strongest available check that those pages stand alone — so they have to
# run here rather than only in a full-repository gate this job deliberately skips.
- name: Test
run: bun test packages/tx-manifest apps/extension/src/core/chains/liquid/adapters/smplx
run: |
bun test packages/tx-manifest apps/web \
apps/extension/src/core/chains/liquid/adapters/smplx
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@
"dev": "vite",
"build": "tsc -b && vite build",
"typecheck": "tsc --noEmit",
"typecheck:tooling": "tsc -p tsconfig.tooling.json --noEmit --pretty false",
"preview": "vite preview",
"cleanup": "rm -rf node_modules out dist"
},
"dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@humid/appkit-injected-adapter": "workspace:*",
"@humid/tx-manifest": "workspace:*",
"@reown/appkit": "^1.8.19",
"@reown/appkit-common": "^1.8.19",
"@reown/appkit-controllers": "^1.8.21",
Expand Down
48 changes: 35 additions & 13 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,53 @@ import { ChevronLeftIcon } from "lucide-react";
import { useState } from "react";

import Dashboard from "@/app/dashboard";
import FormatSupport from "@/app/format";
import Home from "@/app/home";
import ManifestInspector from "@/app/manifest";
import { Button } from "@/components/ui/button";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";

type View = "home" | "developer";
type View = "developer" | "format" | "home" | "manifest";

export function App() {
const [view, setView] = useState<View>("home");

return (
<TooltipProvider>
{view === "home" ? (
<Home onOpenDeveloper={() => setView("developer")} />
) : (
<div className="flex min-h-svh flex-col">
<div className="mx-auto flex w-full max-w-4xl px-4 pt-4">
<Button variant="ghost" size="sm" onClick={() => setView("home")}>
<ChevronLeftIcon />
Back to app
</Button>
{(() => {
if (view === "home") {
return (
<Home
onOpenDeveloper={() => setView("developer")}
onOpenFormatSupport={() => setView("format")}
onOpenManifestInspector={() => setView("manifest")}
/>
);
}

return (
<div className="flex min-h-svh flex-col">
<div className="mx-auto flex w-full max-w-4xl px-4 pt-4">
<Button variant="ghost" size="sm" onClick={() => setView("home")}>
<ChevronLeftIcon />
Back to app
</Button>
</div>
{(() => {
if (view === "developer") {
return <Dashboard />;
}

if (view === "format") {
return <FormatSupport />;
}

return <ManifestInspector />;
})()}
</div>
<Dashboard />
</div>
)}
);
})()}
<Toaster />
</TooltipProvider>
);
Expand Down
87 changes: 87 additions & 0 deletions apps/web/src/app/format/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, expect, test } from "bun:test";

import { describeRegistry } from "@humid/tx-manifest";
import { renderToStaticMarkup } from "react-dom/server";

import FormatSupport from "./index";
import { WHERE_IT_SITS } from "./positions";

// The page's whole content is the runtime's own construct table, so what is checked here is
// that all of it arrives, that what the wallet cannot do leads, and that every gap carries its
// reason — the part no document can ever show, because no published protocol uses any of them.

function render(): string {
return renderToStaticMarkup(<FormatSupport />);
}

describe("what this wallet does not implement", () => {
test("leads with it, before anything the wallet does read", () => {
const html = render();

expect(html.indexOf("Not implemented")).toBeLessThan(
html.indexOf("Read, and it changes what gets signed"),
);
});

test("names every construct the runtime does not act on, with its reason", () => {
const html = render();

for (const entry of describeRegistry().filter((candidate) => candidate.reason !== undefined)) {
expect(html).toContain(entry.key);
expect(html).toContain(escaped(entry.reason ?? ""));
}
});

// The count is what an engineer came for and the one thing that must not be written down by
// hand: a sentence saying "eight" survives a ninth being added.
test("counts what is missing from the table rather than from a sentence", () => {
const unimplemented = describeRegistry().filter((entry) => entry.state === "unimplemented");

expect(render()).toContain(`>${unimplemented.length}</span>`);
});
});

describe("the whole table, not a sample of it", () => {
test("renders every construct the runtime registers", () => {
const html = render();

for (const entry of describeRegistry()) {
expect(html).toContain(entry.key);
}
});

test("says how much of the format this is, counted rather than stated", () => {
const entries = describeRegistry();
const positioned = entries.filter((entry) => entry.site !== undefined);

expect(render()).toContain(`${positioned.length} fields at`);
});

test("says where each one sits in words a reader can use", () => {
const html = render();

for (const where of Object.values(WHERE_IT_SITS)) {
expect(html).toContain(where);
}
});
});

describe("the page stands alone", () => {
// It holds no wallet context and reads no document: every other surface in this app reads a
// wallet context, and reading a missing one would throw.
test("renders with no wallet, no provider, no network and nothing pasted", () => {
const html = render();

expect(html).toContain("What this wallet reads of the format");
expect(html).not.toContain("<textarea");
});
});

function escaped(text: string): string {
return text
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#x27;");
}
126 changes: 126 additions & 0 deletions apps/web/src/app/format/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { type ConstructRegistryEntry, describeRegistry } from "@humid/tx-manifest";

import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";

import { WHERE_IT_SITS } from "./positions";

/**
* What this wallet reads of the transaction-manifest format, and what it does not.
*
* The manifest page answers a question about one document. This one answers a question no
* document can: a construct nobody has published is invisible in every document there is, and
* every construct the format defines and this wallet does not implement is in that position.
* Every published protocol therefore inspects clean while they stand, which is why this is a
* page rather than a section beside a box someone pastes into.
*
* It reads nothing from that page and nothing from anywhere else. Its whole content is the
* runtime's own construct table, so it cannot describe a wallet that differs from the one that
* runs — including the reason beside each gap, which is data the table refuses to compile
* without rather than a sentence written here.
*/
export default function FormatSupport() {
const entries = describeRegistry();

return (
<div className="mx-auto flex min-h-svh w-full max-w-4xl flex-col gap-6 p-4 md:p-6">
<Card>
<CardHeader>
<CardTitle>What this wallet reads of the format</CardTitle>
<CardDescription>
Every field the transaction-manifest format defines, against what this wallet does with
it. Nothing here depends on a document — it is the same table the wallet decides by,
printed.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">{summaryOf(entries)}</p>
</CardContent>
</Card>

<Section
title="Not implemented"
description="The format defines these and this wallet does not act on them. A document using one is refused rather than read past."
entries={entries.filter((entry) => entry.state === "unimplemented")}
/>
<Section
title="Deliberately read by nothing"
description="Known, and read by nothing here or in the reference implementation. A document using one is not refused, because being wrong about it cannot change what gets signed."
entries={entries.filter((entry) => entry.state === "never-read")}
/>
<Section
title="Read, and shown to a person"
description="Read and put in front of whoever approves the action. None of it decides a value."
entries={entries.filter((entry) => entry.state === "shown")}
/>
<Section
title="Read, and it changes what gets signed"
description="The part of the format this wallet acts on."
entries={entries.filter((entry) => entry.state === "acted-on")}
/>
</div>
);
}

function Section({
description,
entries,
title,
}: {
description: string;
entries: ConstructRegistryEntry[];
title: string;
}) {
if (entries.length === 0) {
return null;
}

return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{title}
<Badge variant="secondary">{entries.length}</Badge>
</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<tbody>
{entries.map((entry) => (
<tr
key={`${entry.site ?? "everywhere"}/${entry.key}`}
className="border-border/50 border-b align-top"
>
<td className="py-2 pr-4 font-mono whitespace-nowrap">{entry.key}</td>
<td className="text-muted-foreground py-2 pr-4 whitespace-nowrap">
{WHERE_IT_SITS[entry.site ?? "everywhere"]}
</td>
<td className="text-muted-foreground py-2">{entry.reason}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}

/**
* How much of the format this is, said before any of it is read.
*
* Counted from the table rather than written down, so the sentence cannot fall behind the thing
* it describes — which is the same reason this page exists at all.
*/
function summaryOf(entries: ConstructRegistryEntry[]): string {
const positioned = entries.filter((entry) => entry.site !== undefined);
const kinds = new Set(positioned.map((entry) => entry.site)).size;
const everywhere = entries.length - positioned.length;

return (
`${positioned.length} fields at ${kinds} kinds of position, plus ${everywhere} that any ` +
"JSON document may carry anywhere. Each one this wallet does not act on says why."
);
}
26 changes: 26 additions & 0 deletions apps/web/src/app/format/positions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { ConstructSiteKind } from "@humid/tx-manifest";

/**
* Where a field sits, in the words a person would use for it.
*
* A translation and not a claim: the runtime keys its table by these names and this says the
* same thing in English, so nothing here can be true while the runtime says otherwise. It is
* typed against the runtime's own set, so a kind of position added there and forgotten here
* fails to compile rather than rendering a key nobody can read.
*
* `everywhere` is not one of the runtime's kinds. It stands for the keys any JSON document may
* carry at any depth, which the runtime answers once rather than listing at every position.
*/
export const WHERE_IT_SITS: Record<ConstructSiteKind | "everywhere", string> = {
action: "on an action",
everywhere: "anywhere",
input: "on an input",
manifest: "on the document",
output: "on an output",
param: "on a parameter",
script: "on a contract",
ui: "in display metadata",
utxoType: "on a kind of holding",
validation: "on a rule",
witness: "on a witness",
};
Loading
Loading