diff --git a/.oxfmtrc.json b/.oxfmtrc.json
index b61b8c1..43a82e5 100644
--- a/.oxfmtrc.json
+++ b/.oxfmtrc.json
@@ -10,6 +10,7 @@
"sortTailwindcss": true,
"ignorePatterns": [
"lwk/**",
+ "**/__fixtures__/**",
"AGENTS.md",
"CLAUDE.md",
"README.md",
diff --git a/bun.lock b/bun.lock
index 802fa22..9fa37e5 100644
--- a/bun.lock
+++ b/bun.lock
@@ -69,6 +69,7 @@
"@stylistic/eslint-plugin-jsx": "^3.0.1",
"@stylistic/eslint-plugin-ts": "^3.0.1",
"@tailwindcss/postcss": "^4.3.0",
+ "@types/bun": "^1.3.14",
"@types/node": "^25.9.1",
"@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3",
@@ -77,6 +78,7 @@
"@typescript-eslint/parser": "^8.23.0",
"@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.5.0",
+ "bun-types": "^1.3.14",
"chalk": "^5.6.2",
"cross-env": "^10.1.0",
"dotenv": "^17.4.2",
@@ -164,6 +166,13 @@
"@walletconnect/universal-provider": ">=2.0.0",
},
},
+ "packages/tx-manifest": {
+ "name": "@humid/tx-manifest",
+ "version": "1.0.0",
+ "dependencies": {
+ "zod": "^4.0.0",
+ },
+ },
},
"trustedDependencies": [
"@parcel/watcher",
@@ -401,6 +410,8 @@
"@humid/appkit-injected-adapter": ["@humid/appkit-injected-adapter@workspace:packages/appkit-injected-adapter"],
+ "@humid/tx-manifest": ["@humid/tx-manifest@workspace:packages/tx-manifest"],
+
"@joshwooding/vite-plugin-react-docgen-typescript": ["@joshwooding/vite-plugin-react-docgen-typescript@0.7.0", "", { "dependencies": { "glob": "^13.0.1", "react-docgen-typescript": "^2.2.2" }, "peerDependencies": { "typescript": ">= 4.3.x", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["typescript"] }, "sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@@ -1081,6 +1092,8 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
+ "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
+
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
@@ -1431,6 +1444,8 @@
"builtin-status-codes": ["builtin-status-codes@3.0.0", "", {}, "sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ=="],
+ "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
+
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
"bunyan": ["bunyan@1.8.15", "", { "optionalDependencies": { "dtrace-provider": "0.8.8", "moment": "2.30.1", "mv": "2.1.1", "safe-json-stringify": "1.2.0" }, "bin": { "bunyan": "bin/bunyan" } }, "sha512-0tECWShh6wUysgucJcBAoYegf3JJoZWibxdqhTm7OHPeT42qdjkZ29QCMcKwbgU1kiH+auSIasNRXMLWXafXig=="],
diff --git a/package.json b/package.json
index 2f9ae0f..cf392f1 100644
--- a/package.json
+++ b/package.json
@@ -26,7 +26,10 @@
"validate:i18n": "i18n-check -l apps/extension/src/localization/resources -s en -f i18next",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
- "check": "bun run typecheck && bun run lint && bun run format:check"
+ "test": "bun test",
+ "test:watch": "bun test --watch",
+ "typecheck:packages": "tsc -p packages/tsconfig.json --noEmit",
+ "check": "bun run typecheck && bun run typecheck:packages && bun run lint && bun run format:check && bun run test"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
@@ -93,6 +96,7 @@
"@stylistic/eslint-plugin-jsx": "^3.0.1",
"@stylistic/eslint-plugin-ts": "^3.0.1",
"@tailwindcss/postcss": "^4.3.0",
+ "@types/bun": "^1.3.14",
"@types/node": "^25.9.1",
"@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3",
@@ -101,6 +105,7 @@
"@typescript-eslint/parser": "^8.23.0",
"@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.5.0",
+ "bun-types": "^1.3.14",
"chalk": "^5.6.2",
"cross-env": "^10.1.0",
"dotenv": "^17.4.2",
diff --git a/packages/bun-test-env.d.ts b/packages/bun-test-env.d.ts
new file mode 100644
index 0000000..40d4679
--- /dev/null
+++ b/packages/bun-test-env.d.ts
@@ -0,0 +1,5 @@
+///
+
+// Makes `bun:test` resolvable to `tsc`, which the test files import from. `@types/bun`
+// re-exports `bun-types` and is supposed to be picked up automatically, but is not under
+// this project's configuration.
diff --git a/packages/tsconfig.json b/packages/tsconfig.json
new file mode 100644
index 0000000..32ac2ce
--- /dev/null
+++ b/packages/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "tsBuildInfoFile": "../node_modules/.cache/tsbuildinfo.packages.json",
+ "paths": {}
+ },
+ "include": ["."]
+}
diff --git a/packages/tx-manifest/package.json b/packages/tx-manifest/package.json
new file mode 100644
index 0000000..dda8e3a
--- /dev/null
+++ b/packages/tx-manifest/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@humid/tx-manifest",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Reads the request a site sends to perform one action of a txManifest protocol, and works out which parts of that request the chosen action requires. Wallet-agnostic: it holds no keys, reaches no network and remembers nothing between calls.",
+ "type": "module",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": "./src/index.ts",
+ "./fixtures/*": "./src/__fixtures__/*"
+ },
+ "dependencies": {
+ "zod": "^4.0.0"
+ }
+}
diff --git a/packages/tx-manifest/src/__fixtures__/p2pk.manifest.json b/packages/tx-manifest/src/__fixtures__/p2pk.manifest.json
new file mode 100644
index 0000000..85bca28
--- /dev/null
+++ b/packages/tx-manifest/src/__fixtures__/p2pk.manifest.json
@@ -0,0 +1,133 @@
+{
+ "manifest_version": "0.1.0",
+ "attestation_version": "1",
+ "protocol": "p2pk-simplicity",
+ "description": "Hello World — Pay-to-public-key using a Simplicity checksig program on Liquid.",
+ "chain": "liquid",
+ "utxo_types": {
+ "p2pk_output": {
+ "description": "A Liquid UTXO locked to PUBKEY via the compiled p2pk.simf program.",
+ "script": {
+ "type": "simplicity",
+ "source": "./p2pk.simf"
+ },
+ "asset": "lbtc",
+ "confidential": false
+ }
+ },
+ "actions": {
+ "Pay": {
+ "description": "Lock funds into a p2pk output that only PUBKEY's owner can spend.",
+ "params": {
+ "pubkey": {
+ "type": "pubkey",
+ "description": "The x-only public key that will be able to spend this output (the recipient)."
+ },
+ "amount_sat": {
+ "type": "u64",
+ "description": "Amount in satoshis to lock in the output."
+ }
+ },
+ "inputs": [
+ {
+ "id": "funding_input",
+ "description": "Wallet UTXO providing the funds.",
+ "utxo_source": "wallet",
+ "asset": "lbtc",
+ "amount_sat": {
+ "min_amount": "params.amount_sat"
+ }
+ }
+ ],
+ "outputs": [
+ {
+ "id": "p2pk_out",
+ "description": "The funded p2pk output, locked to PUBKEY.",
+ "destination": {
+ "utxo_type": "p2pk_output",
+ "compile_params": {
+ "PUB_KEY": "params.pubkey"
+ }
+ },
+ "amount_sat": "params.amount_sat",
+ "asset": "lbtc"
+ },
+ {
+ "id": "change_out",
+ "description": "Change returned to the funding wallet.",
+ "destination": "change",
+ "asset": "lbtc",
+ "optional": true
+ }
+ ],
+ "validations": [
+ {
+ "id": "amount_nonzero",
+ "rule": {
+ "type": "arithmetic",
+ "expr": "params.amount_sat > 0"
+ },
+ "error": {
+ "code": "INVALID_AMOUNT",
+ "message": "Amount must be greater than zero"
+ }
+ }
+ ]
+ },
+ "Receive": {
+ "description": "Spend a p2pk output back into your wallet. Requires a BIP340 signature from the pubkey the output was locked to.",
+ "params": {
+ "pubkey": {
+ "type": "pubkey",
+ "description": "The x-only public key the output was locked to in Pay. Must be one of your own wallet's keys so the wallet can sign the spend."
+ }
+ },
+ "inputs": [
+ {
+ "id": "p2pk_in",
+ "description": "The p2pk covenant UTXO to spend, located via the state file by its utxo_type.",
+ "utxo_source": {
+ "utxo_type": "p2pk_output",
+ "compile_params": {
+ "PUB_KEY": "params.pubkey"
+ }
+ },
+ "witnesses": {
+ "SIGNATURE": {
+ "type": "Signature",
+ "sig_type": "sig_hash_all",
+ "source": {
+ "type": "wallet",
+ "key": "params.pubkey"
+ },
+ "description": "BIP340 Schnorr signature over the whole transaction, from the recipient key."
+ }
+ }
+ },
+ {
+ "id": "fee_input",
+ "description": "Wallet L-BTC UTXO to pay the network fee.",
+ "utxo_source": "wallet",
+ "asset": "lbtc",
+ "optional": true
+ }
+ ],
+ "outputs": [
+ {
+ "id": "received_out",
+ "description": "The reclaimed funds, sent to your wallet.",
+ "destination": "wallet",
+ "asset": "lbtc",
+ "amount_sat": "p2pk_in.amount_sat"
+ },
+ {
+ "id": "fee_change",
+ "description": "L-BTC change from the fee input.",
+ "destination": "change",
+ "asset": "lbtc",
+ "optional": true
+ }
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/tx-manifest/src/document/json.ts b/packages/tx-manifest/src/document/json.ts
new file mode 100644
index 0000000..108a45a
--- /dev/null
+++ b/packages/tx-manifest/src/document/json.ts
@@ -0,0 +1,19 @@
+/**
+ * The three shape tests every part of the runtime needs on a document it did not write.
+ *
+ * A manifest arrives as parsed JSON and nothing about its interior is guaranteed, so
+ * reading it is a sequence of "is this the shape I think it is" questions. These were
+ * redefined in four files before the runtime core existed; they live here once.
+ */
+
+export function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+export function asRecord(value: unknown): Record | undefined {
+ return isRecord(value) ? value : undefined;
+}
+
+export function asArray(value: unknown): unknown[] {
+ return Array.isArray(value) ? value : [];
+}
diff --git a/packages/tx-manifest/src/index.ts b/packages/tx-manifest/src/index.ts
new file mode 100644
index 0000000..a357df4
--- /dev/null
+++ b/packages/tx-manifest/src/index.ts
@@ -0,0 +1,17 @@
+/**
+ * Reads the request a site sends to perform one action of a txManifest protocol.
+ *
+ * What is here holds no keys, opens no network connection of its own and remembers nothing
+ * between calls: a wallet supplies the chain reads and the signing, and the same request twice
+ * is answered the same way. That is what makes it a package rather than part of one wallet.
+ *
+ * This surface is what a wallet needs and nothing else. A module absent from here is private
+ * even though its directory is not hidden — the way to make one public is to add it,
+ * deliberately, when something outside actually needs it.
+ */
+
+// What the site sent, checked into a shape the rest can rely on. What a particular action
+// then needs from that request is worked out inside the package rather than answered here:
+// a caller holding the answer has nothing to do with it until there is something to build.
+export type { ParsedLiquidProcessCtParams } from "./request/request";
+export { parseLiquidProcessCtParams } from "./request/validation";
diff --git a/packages/tx-manifest/src/request/request.ts b/packages/tx-manifest/src/request/request.ts
new file mode 100644
index 0000000..855a720
--- /dev/null
+++ b/packages/tx-manifest/src/request/request.ts
@@ -0,0 +1,58 @@
+/**
+ * The request a site sends to perform one action of a txManifest protocol.
+ *
+ * Six parts, per the accepted request contract: the manifest, the sources of the
+ * contracts it references, the chosen action and its filled parameters, and the two
+ * mutable protocol files the site holds — the instance file (this deployment's field
+ * values) and the state file (its live covenant UTXO set).
+ *
+ * The last two are optional at the wire level because not every manifest needs them —
+ * a protocol with no covenant parameters has no instance file, and an action that
+ * creates rather than spends has nothing to read from state. Whether a *specific*
+ * action can proceed without them is a different question, answered by inspecting what
+ * that action actually references rather than by the shape of the request.
+ *
+ * The fee is deliberately absent. The wallet establishes the fee and the fee rate; a
+ * fee-like value arriving here would be ignored rather than honoured.
+ */
+export type ParsedLiquidProcessCtParams = {
+ /** The chosen action's name, as it appears in the manifest. */
+ action: string;
+ /** Whether to broadcast the finished transaction or return it unsent. */
+ broadcast: boolean;
+ /**
+ * Source text of every contract the manifest references, keyed by the path the
+ * manifest uses. Sources are not published with a manifest; they arrive here.
+ */
+ contractSources: Record;
+ /** This deployment's field values, when the protocol has any. */
+ instance?: Record;
+ /** The txManifest document itself. */
+ manifest: Record;
+ /** The filled parameters of the chosen action. */
+ params: Record;
+ /** The deployment's live covenant UTXO set, when the action reads one. */
+ state?: Record;
+};
+
+/** One request part, named the way a refusal message names it. */
+export type RequestPart = "contractSources" | "instance" | "params" | "state";
+
+/**
+ * What a specific action needs from the request, and what of that is absent.
+ *
+ * `missing` is what makes a refusal answerable: it names the part and, where the part
+ * is a map, the exact keys that were referenced and not supplied.
+ */
+export type ActionRequirements = {
+ missing: MissingPart[];
+ required: RequestPart[];
+};
+
+export type MissingPart = {
+ /** Which keys were referenced and not supplied, when the part is a map. */
+ keys?: string[];
+ part: RequestPart;
+ /** Why the action needs it, in the manifest's own terms. */
+ reason: string;
+};
diff --git a/packages/tx-manifest/src/request/requirements.test.ts b/packages/tx-manifest/src/request/requirements.test.ts
new file mode 100644
index 0000000..bcf42d0
--- /dev/null
+++ b/packages/tx-manifest/src/request/requirements.test.ts
@@ -0,0 +1,166 @@
+import { describe, expect, test } from "bun:test";
+
+import p2pkManifest from "../__fixtures__/p2pk.manifest.json";
+import type { ParsedLiquidProcessCtParams } from "./request";
+import { resolveActionRequirements } from "./requirements";
+
+// The fixture is the published p2pk manifest at txmanifest-wallet
+// 7d56516a1a1e44a586f25d45a34619c3953758dd, unmodified. Expectations below come from
+// what that document says an action needs, not from what the resolver happens to return.
+
+const SOURCE_PATH = "./p2pk.simf";
+const PUBKEY = "0".repeat(64);
+const MANIFEST = p2pkManifest as unknown as Record;
+
+function request(
+ overrides: Partial = {},
+): ParsedLiquidProcessCtParams {
+ return {
+ action: "Pay",
+ broadcast: false,
+ contractSources: { [SOURCE_PATH]: "fn main() {}" },
+ manifest: MANIFEST,
+ params: { amount_sat: 1000, pubkey: PUBKEY },
+ ...overrides,
+ };
+}
+
+/** The same question asked of the published manifest. */
+function requirements(overrides: Partial = {}) {
+ return resolveActionRequirements(request(overrides));
+}
+
+describe("resolveActionRequirements", () => {
+ test("refuses an action the manifest does not declare, naming it", () => {
+ const { missing } = requirements({ action: "Withdraw" });
+
+ expect(missing).toHaveLength(1);
+ expect(missing[0]?.reason).toContain("Withdraw");
+ });
+
+ // Pay locks funds into a new p2pk output: one wallet input, a covenant destination.
+ // It spends no covenant, so a request without the state file is complete.
+ describe("Pay", () => {
+ test("needs the contract source and the declared parameters", () => {
+ const { required } = requirements();
+
+ expect(required).toContain("contractSources");
+ expect(required).toContain("params");
+ });
+
+ test("does not need the state file", () => {
+ expect(requirements().required).not.toContain("state");
+ });
+
+ test("is complete when the source and parameters are supplied", () => {
+ expect(requirements().missing).toEqual([]);
+ });
+
+ test("names the contract source that was not supplied", () => {
+ const { missing } = requirements({ contractSources: {} });
+ const entry = missing.find((item) => item.part === "contractSources");
+
+ expect(entry?.keys).toEqual([SOURCE_PATH]);
+ });
+
+ test("names each parameter the request did not fill", () => {
+ const { missing } = requirements({ params: { pubkey: PUBKEY } });
+ const entry = missing.find((item) => item.part === "params");
+
+ expect(entry?.keys).toEqual(["amount_sat"]);
+ });
+ });
+
+ // Receive spends the covenant UTXO, which the manifest locates by utxo_type — a lookup
+ // into the state file rather than into the chain.
+ describe("Receive", () => {
+ const receive = (overrides: Partial = {}) =>
+ requirements({ action: "Receive", params: { pubkey: PUBKEY }, ...overrides });
+
+ test("needs the state file", () => {
+ expect(receive().required).toContain("state");
+ });
+
+ test("refuses without it, saying why", () => {
+ const entry = receive().missing.find((item) => item.part === "state");
+
+ expect(entry?.reason).toContain("state file");
+ });
+
+ test("is complete once the state file is supplied", () => {
+ expect(receive({ state: { utxos: [] } }).missing).toEqual([]);
+ });
+ });
+});
+
+// The reference implementation's own comment calls a param's `formula` informational only
+// for display and never evaluates it, so a parameter carrying one is still one the request
+// has to fill. Treating it as derived accepts a short request and fails later.
+describe("which parameters the request must fill", () => {
+ function paramsOf(declared: Record) {
+ return requirements({
+ action: "Open",
+ contractSources: {},
+ manifest: { actions: { Open: { params: declared } } },
+ params: {},
+ });
+ }
+
+ test("a parameter with a display formula is still prompted for", () => {
+ const { missing } = paramsOf({ total: { formula: "a + b", type: "u64" } });
+
+ expect(missing.find((entry) => entry.part === "params")?.keys).toEqual(["total"]);
+ });
+
+ test("a parameter the wallet supplies is not", () => {
+ const { missing } = paramsOf({ key: { source: { type: "wallet_key" }, type: "pubkey" } });
+
+ expect(missing).toEqual([]);
+ });
+
+ test("a computed parameter is not", () => {
+ const { missing } = paramsOf({ hash: { compute: "tapleaf", simf: "./a.simf" } });
+
+ expect(missing).toEqual([]);
+ });
+});
+
+// An input sourced from the wallet names no covenant, and an output that creates one names
+// a covenant that does not exist yet. Neither is a lookup into the state file, and reading
+// either as one would ask a site for a file that could not answer.
+describe("wallet inputs and covenant inputs", () => {
+ function ask(action: Record) {
+ return requirements({
+ action: "Open",
+ manifest: {
+ actions: { Open: action },
+ utxo_types: { v: { script: { source: SOURCE_PATH } } },
+ },
+ params: {},
+ });
+ }
+
+ test("a wallet-funded input needs no state file", () => {
+ expect(ask({ inputs: [{ id: "funding", utxo_source: "wallet" }] }).required).not.toContain(
+ "state",
+ );
+ });
+
+ test("an input spent from a covenant does", () => {
+ expect(ask({ inputs: [{ id: "held", utxo_source: { utxo_type: "v" } }] }).required).toContain(
+ "state",
+ );
+ });
+
+ test("creating a covenant does not, because there is nothing yet to locate", () => {
+ expect(
+ ask({ outputs: [{ destination: { utxo_type: "v" }, id: "made" }] }).required,
+ ).not.toContain("state");
+ });
+
+ test("a covenant an output creates still needs its contract source", () => {
+ const { required } = ask({ outputs: [{ destination: { utxo_type: "v" }, id: "made" }] });
+
+ expect(required).toContain("contractSources");
+ });
+});
diff --git a/packages/tx-manifest/src/request/requirements.ts b/packages/tx-manifest/src/request/requirements.ts
new file mode 100644
index 0000000..c7e410e
--- /dev/null
+++ b/packages/tx-manifest/src/request/requirements.ts
@@ -0,0 +1,186 @@
+import { asArray, asRecord } from "../document/json";
+import type { ActionRequirements, MissingPart, ParsedLiquidProcessCtParams } from "./request";
+
+/**
+ * Works out what the chosen action actually needs from the request, and what of that is
+ * absent — so a request can be refused before anything is built, naming what was missing.
+ *
+ * It answers the question by reading the action, not by checking the request's shape: a
+ * manifest with no covenant parameters needs no instance file, and an action that creates
+ * rather than spends reads nothing from state. Requiring all six parts of every request
+ * would refuse valid ones; requiring none would fail later and less legibly.
+ *
+ * What it does not read, it does not vouch for. The action's declarations are carried
+ * through to whatever builds from them rather than checked here: this says a part of the
+ * request is present, never that what the document asks for can be honoured.
+ */
+export function resolveActionRequirements(
+ request: ParsedLiquidProcessCtParams,
+): ActionRequirements {
+ const action = asRecord(asRecord(request.manifest.actions)?.[request.action]);
+
+ if (!action) {
+ return {
+ missing: [
+ {
+ part: "params",
+ reason: `The manifest declares no action named "${request.action}".`,
+ },
+ ],
+ required: [],
+ };
+ }
+
+ const required: ActionRequirements["required"] = [];
+ const missing: MissingPart[] = [];
+
+ const sources = referencedContractSources(request.manifest, action);
+
+ if (sources.length > 0) {
+ required.push("contractSources");
+
+ const absent = sources.filter((path) => !(path in request.contractSources));
+
+ if (absent.length > 0) {
+ missing.push({
+ keys: absent,
+ part: "contractSources",
+ reason: "The action builds contracts whose source was not supplied.",
+ });
+ }
+ }
+
+ const params = promptedParams(action);
+ const unfilled = params.filter((name) => !(name in request.params));
+
+ if (params.length > 0) {
+ required.push("params");
+ }
+
+ if (unfilled.length > 0) {
+ missing.push({
+ keys: unfilled,
+ part: "params",
+ reason: "The action declares parameters the request did not fill.",
+ });
+ }
+
+ if (spendsCovenant(action)) {
+ required.push("state");
+
+ if (!request.state) {
+ missing.push({
+ part: "state",
+ reason: "The action spends a covenant UTXO, which is located through the state file.",
+ });
+ }
+ }
+
+ return { missing, required };
+}
+
+/** Contract source paths the action reaches, through the utxo types it names. */
+function referencedContractSources(
+ manifest: Record,
+ action: Record,
+): string[] {
+ const utxoTypes = asRecord(manifest.utxo_types) ?? {};
+ const paths = new Set();
+
+ for (const name of namedUtxoTypes(action)) {
+ const source = asRecord(asRecord(utxoTypes[name])?.script)?.source;
+
+ if (typeof source === "string") {
+ paths.add(source);
+ }
+ }
+
+ return [...paths];
+}
+
+/**
+ * The utxo types this action reaches, in the order it names them.
+ *
+ * An input spends a covenant and an output creates one, and both name it the same way — as
+ * a `utxo_type` beside the parameters it is compiled with. An input sourced from the wallet
+ * names none, which is what makes it a wallet input rather than a covenant one.
+ */
+function namedUtxoTypes(action: Record): string[] {
+ const named = new Set();
+
+ for (const site of covenantSites(action)) {
+ named.add(site.utxoType);
+ }
+
+ return [...named];
+}
+
+/** Whether the action spends a covenant UTXO, which is a lookup into the state file. */
+function spendsCovenant(action: Record): boolean {
+ return covenantSites(action).some((site) => site.role === "spent");
+}
+
+/**
+ * Every place in the action where a covenant appears, and which side it is on.
+ *
+ * Inputs spend a covenant and outputs create one. The distinction is what decides whether
+ * the request needs a state file: a covenant that already exists has to be located, and a
+ * covenant this action creates has nowhere to be looked up.
+ */
+function covenantSites(action: Record): CovenantSite[] {
+ const sites: CovenantSite[] = [];
+
+ for (const entry of asArray(action.inputs)) {
+ const utxoType = namedUtxoType(asRecord(entry)?.utxo_source);
+
+ if (utxoType !== undefined) {
+ sites.push({ role: "spent", utxoType });
+ }
+ }
+
+ for (const entry of asArray(action.outputs)) {
+ const utxoType = namedUtxoType(asRecord(entry)?.destination);
+
+ if (utxoType !== undefined) {
+ sites.push({ role: "created", utxoType });
+ }
+ }
+
+ return sites;
+}
+
+type CovenantSite = { role: "created" | "spent"; utxoType: string };
+
+/**
+ * The utxo type one input source or output destination names, if it names one.
+ *
+ * The keywords — `wallet`, `change` — are written where the object would be, so anything
+ * that is not an object naming a `utxo_type` is not a covenant site.
+ */
+function namedUtxoType(value: unknown): string | undefined {
+ const utxoType = asRecord(value)?.utxo_type;
+
+ return typeof utxoType === "string" ? utxoType : undefined;
+}
+
+/**
+ * Parameter names the request has to fill.
+ *
+ * A parameter the wallet or the protocol supplies is not prompted for: `source` names
+ * where the value comes from, and `compute` and `derived` say it is worked out rather than
+ * entered. `formula` is **not** one of these — the reference implementation's own comment
+ * calls it informational only for display and never evaluates it, so a parameter carrying
+ * one is still a parameter the request must fill. Treating it as derived accepts a request
+ * that is short a value and fails later, further from the cause.
+ */
+function promptedParams(action: Record): string[] {
+ const params = asRecord(action.params) ?? {};
+
+ return Object.entries(params)
+ .filter(([, declared]) => {
+ const record = asRecord(declared);
+
+ return !record || !("source" in record || "compute" in record || "derived" in record);
+ })
+ .map(([name]) => name);
+}
diff --git a/packages/tx-manifest/src/request/validation.test.ts b/packages/tx-manifest/src/request/validation.test.ts
new file mode 100644
index 0000000..ea4ae93
--- /dev/null
+++ b/packages/tx-manifest/src/request/validation.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, test } from "bun:test";
+
+import p2pkManifest from "../__fixtures__/p2pk.manifest.json";
+import { parseLiquidProcessCtParams } from "./validation";
+
+// AC-13's other half: a site cannot set the fee. The request contract has no place to put
+// one, and a request that invents one is refused rather than quietly stripped — a site that
+// believes it set the fee and was ignored is a site that will keep believing it.
+
+const base = {
+ action: "Pay",
+ contractSources: { "./p2pk.simf": "fn main() { }" },
+ manifest: p2pkManifest,
+ params: { amount_sat: 1000 },
+};
+
+describe("parseLiquidProcessCtParams", () => {
+ test("accepts the six-part request", () => {
+ const parsed = parseLiquidProcessCtParams(base);
+
+ expect(parsed.ok && parsed.request.action).toBe("Pay");
+ });
+
+ test("defaults broadcast to off, so nothing reaches the network unasked", () => {
+ const parsed = parseLiquidProcessCtParams(base);
+
+ expect(parsed.ok && parsed.request.broadcast).toBe(false);
+ });
+
+ for (const supplied of ["fee", "feeSats", "feeRate", "feeRateSatsPerKvb"]) {
+ test(`refuses a request carrying ${supplied}`, () => {
+ expect(parseLiquidProcessCtParams({ ...base, [supplied]: 500 }).ok).toBe(false);
+ });
+ }
+
+ test("refuses a request with no manifest", () => {
+ expect(parseLiquidProcessCtParams({ ...base, manifest: undefined }).ok).toBe(false);
+ });
+
+ // The caller needs to know which field was wrong, not only that something was. It is a
+ // value rather than a thrown transport error because this package has no transport.
+ test("and says which field, so the caller can name it", () => {
+ const parsed = parseLiquidProcessCtParams({ ...base, manifest: undefined });
+
+ expect(parsed.ok ? [] : Object.keys(parsed.malformed.details.fieldErrors)).toContain(
+ "manifest",
+ );
+ });
+});
diff --git a/packages/tx-manifest/src/request/validation.ts b/packages/tx-manifest/src/request/validation.ts
new file mode 100644
index 0000000..cbfcae3
--- /dev/null
+++ b/packages/tx-manifest/src/request/validation.ts
@@ -0,0 +1,50 @@
+import { z } from "zod";
+
+import type { ParsedLiquidProcessCtParams } from "./request";
+
+const jsonObjectSchema = z.record(z.string(), z.unknown());
+
+const processCtParamsSchema = z
+ .object({
+ action: z.string().min(1).max(256),
+ broadcast: z.boolean().optional().default(false),
+ contractSources: z.record(z.string().min(1), z.string().min(1).max(1_000_000)),
+ instance: jsonObjectSchema.optional(),
+ manifest: jsonObjectSchema,
+ params: jsonObjectSchema.optional().default({}),
+ state: jsonObjectSchema.optional(),
+ })
+ .strict();
+
+/** A malformed request, with the field-by-field detail a caller can show or wrap. */
+export type MalformedRequest = {
+ details: z.core.$ZodFlattenedError>;
+ message: string;
+};
+
+export type ParseRequestResult =
+ | { ok: false; malformed: MalformedRequest }
+ | { ok: true; request: ParsedLiquidProcessCtParams };
+
+/**
+ * Checks the request is well-formed. Whether the chosen action can actually be built
+ * from it is a separate question — see `resolveActionRequirements`, which reads the
+ * manifest rather than the request's shape.
+ *
+ * A malformed request comes back as a value rather than a thrown transport error: this
+ * package has no transport, and the caller that does owns how a refusal reaches whoever
+ * asked.
+ */
+export function parseLiquidProcessCtParams(value: unknown): ParseRequestResult {
+ const parsed = processCtParamsSchema.safeParse(value);
+
+ return parsed.success
+ ? { ok: true, request: parsed.data }
+ : {
+ malformed: {
+ details: z.flattenError(parsed.error),
+ message: "Invalid processConfidentialTransaction parameters.",
+ },
+ ok: false,
+ };
+}