From ecf3b96a949ee294aae30b4cadbb1211ca0dc3b8 Mon Sep 17 00:00:00 2001 From: lukachi Date: Wed, 2 Sep 2026 13:30:09 +0300 Subject: [PATCH] feat(tx-manifest): add request parser foundation --- .oxfmtrc.json | 1 + bun.lock | 15 ++ package.json | 7 +- packages/bun-test-env.d.ts | 5 + packages/tsconfig.json | 8 + packages/tx-manifest/package.json | 15 ++ .../src/__fixtures__/p2pk.manifest.json | 133 +++++++++++++ packages/tx-manifest/src/document/json.ts | 19 ++ packages/tx-manifest/src/index.ts | 17 ++ packages/tx-manifest/src/request/request.ts | 58 ++++++ .../src/request/requirements.test.ts | 166 ++++++++++++++++ .../tx-manifest/src/request/requirements.ts | 186 ++++++++++++++++++ .../src/request/validation.test.ts | 49 +++++ .../tx-manifest/src/request/validation.ts | 50 +++++ 14 files changed, 728 insertions(+), 1 deletion(-) create mode 100644 packages/bun-test-env.d.ts create mode 100644 packages/tsconfig.json create mode 100644 packages/tx-manifest/package.json create mode 100644 packages/tx-manifest/src/__fixtures__/p2pk.manifest.json create mode 100644 packages/tx-manifest/src/document/json.ts create mode 100644 packages/tx-manifest/src/index.ts create mode 100644 packages/tx-manifest/src/request/request.ts create mode 100644 packages/tx-manifest/src/request/requirements.test.ts create mode 100644 packages/tx-manifest/src/request/requirements.ts create mode 100644 packages/tx-manifest/src/request/validation.test.ts create mode 100644 packages/tx-manifest/src/request/validation.ts 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, + }; +}