From e982b12f1190a402fed4caf2fde52d52f41fb01d Mon Sep 17 00:00:00 2001 From: lukachi Date: Wed, 2 Sep 2026 13:52:16 +0300 Subject: [PATCH] feat(tx-manifest): add deterministic action review --- packages/tx-manifest/package.json | 2 +- .../tx-manifest/src/__fixtures__/p2pk.simf | 10 + packages/tx-manifest/src/chain/chainRead.ts | 22 ++ .../src/covenants/compileParams.ts | 102 +++++++ .../tx-manifest/src/covenants/covenant.ts | 140 ++++++++++ .../src/covenants/declaredTypes.ts | 26 ++ packages/tx-manifest/src/document/sites.ts | 62 +++++ packages/tx-manifest/src/index.ts | 27 +- .../tx-manifest/src/request/requirements.ts | 63 +---- packages/tx-manifest/src/review/index.test.ts | 254 ++++++++++++++++++ packages/tx-manifest/src/review/index.ts | 190 +++++++++++++ 11 files changed, 829 insertions(+), 69 deletions(-) create mode 100644 packages/tx-manifest/src/__fixtures__/p2pk.simf create mode 100644 packages/tx-manifest/src/chain/chainRead.ts create mode 100644 packages/tx-manifest/src/covenants/compileParams.ts create mode 100644 packages/tx-manifest/src/covenants/covenant.ts create mode 100644 packages/tx-manifest/src/covenants/declaredTypes.ts create mode 100644 packages/tx-manifest/src/document/sites.ts create mode 100644 packages/tx-manifest/src/review/index.test.ts create mode 100644 packages/tx-manifest/src/review/index.ts diff --git a/packages/tx-manifest/package.json b/packages/tx-manifest/package.json index dda8e3a..560dee7 100644 --- a/packages/tx-manifest/package.json +++ b/packages/tx-manifest/package.json @@ -2,7 +2,7 @@ "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.", + "description": "Reads the request a site sends to perform one action of a txManifest protocol, and establishes what the wallet knows about that action before anyone approves it: the covenants it touches, rebuilt and compared against what the chain says, or a refusal naming what could not be established. Wallet-agnostic: it holds no keys, reaches no network and remembers nothing between calls.", "type": "module", "types": "./src/index.ts", "exports": { diff --git a/packages/tx-manifest/src/__fixtures__/p2pk.simf b/packages/tx-manifest/src/__fixtures__/p2pk.simf new file mode 100644 index 0000000..1004d06 --- /dev/null +++ b/packages/tx-manifest/src/__fixtures__/p2pk.simf @@ -0,0 +1,10 @@ +/* + * PAY TO PUBLIC KEY + * + * The coins move if the person with the given public key signs the transaction. + * + * https://docs.ivylang.org/bitcoin/language/ExampleContracts.html#lockwithpublickey + */ +fn main() { + jet::bip_0340_verify((param::PUB_KEY, jet::sig_all_hash()), witness::SIGNATURE) +} diff --git a/packages/tx-manifest/src/chain/chainRead.ts b/packages/tx-manifest/src/chain/chainRead.ts new file mode 100644 index 0000000..481de9e --- /dev/null +++ b/packages/tx-manifest/src/chain/chainRead.ts @@ -0,0 +1,22 @@ +/** + * Reading what the chain says sits at an outpoint. + * + * This is a port rather than a reader: what is here is the shape of the answer and the shape + * of the asking, and the wallet supplies the thing that actually asks. The package holds no + * endpoint and opens no connection of its own — that is what keeps the same request answered + * the same way twice, and what lets every check below be exercised without a network. + * + * It exists at all because no component a wallet ships can answer the question. A wallet's + * UTXO snapshot only ever contains outputs the wallet owns, and a covenant output belongs to + * a contract rather than to anyone. What is read is public chain data: no key, no descriptor, + * no wallet state. + */ + +export type OutPoint = { txid: string; vout: number }; + +export type TxOutAtOutPoint = { + /** The output's scriptPubKey in hex — the locking condition itself. */ + scriptPubKeyHex: string; +}; + +export type ReadTxOut = (outpoint: OutPoint) => Promise; diff --git a/packages/tx-manifest/src/covenants/compileParams.ts b/packages/tx-manifest/src/covenants/compileParams.ts new file mode 100644 index 0000000..c1892e4 --- /dev/null +++ b/packages/tx-manifest/src/covenants/compileParams.ts @@ -0,0 +1,102 @@ +import type { ParsedLiquidProcessCtParams } from "../request/request"; + +/** + * A contract's compile-time parameters, in SimplicityHL's own argument JSON shape. + * + * Kept as the compiler's format rather than a shape of our own so the value that reaches + * compilation is the value the compiler documents, with nothing translating in between. + */ +export type ContractArguments = Record; + +export type ResolveCompileParamsResult = + | { arguments: ContractArguments; ok: true } + | { ok: false; reason: string }; + +/** + * The manifest's declared parameter types, mapped to the compiler's. + * + * Deliberately a closed list: a type nobody has mapped is refused rather than passed + * through, because a wrong type here produces a valid-looking wrong address rather than + * an error. The corpus's remaining types — the integer widths, `bytes32`, + * `liquid.asset_id` and `address` — arrive with the slices that need them. + */ +const PARAM_TYPES: Record = { + pubkey: "Pubkey", +}; + +/** + * Resolves the compile-time parameters a contract is built with, from the manifest's + * wiring and the parameters the request filled. + * + * The wiring lives in `compile_params`, a map of the contract's parameter name to a + * reference — `{"PUB_KEY": "params.pubkey"}`. Note the collision the format carries: + * `compile_params` is both this wiring map and a deprecated namespace prefix for + * references. This reads the wiring; the namespace is a later slice's problem. + * + * Scope: resolves `params.` references only. Instance references, computed values and + * formulas belong to the slices that own them, and are refused here rather than silently + * mishandled. + * + * Everything it cannot resolve refuses rather than resolving to something plausible. That + * strictness is the point — these values participate in the address, so a wrong one produces + * a well-formed address for the wrong contract instead of an error. + */ +export function resolveCompileParams( + request: ParsedLiquidProcessCtParams, + wiring: Record, + declaredTypes: Record, +): ResolveCompileParamsResult { + const resolved: ContractArguments = {}; + + for (const [name, reference] of Object.entries(wiring)) { + if (typeof reference !== "string") { + return { ok: false, reason: `Compile parameter ${name} is not a reference.` }; + } + + const paramName = referencedParam(reference); + + if (!paramName) { + return { + ok: false, + reason: `Compile parameter ${name} references ${reference}, which this runtime does not resolve yet.`, + }; + } + + const value = request.params[paramName]; + + if (typeof value !== "string") { + return { + ok: false, + reason: `Compile parameter ${name} needs parameter ${paramName}, which the request did not supply as a value.`, + }; + } + + const declaredType = declaredTypes[paramName]; + const compilerType = declaredType ? PARAM_TYPES[declaredType] : undefined; + + if (!compilerType) { + return { + ok: false, + reason: `Parameter ${paramName} is declared as ${declaredType ?? "an unstated type"}, which this runtime does not encode yet.`, + }; + } + + resolved[name] = { type: compilerType, value: withHexPrefix(value) }; + } + + return { arguments: resolved, ok: true }; +} + +/** + * The action parameter a reference points at, or undefined when it points elsewhere. + * + * Accepts the `$`-prefixed spelling alongside the bare one: the corpus carries both, and + * `lending` uses one where `lending_v2` uses the other. + */ +function referencedParam(reference: string): string | undefined { + return /^\$?params\.(?[A-Za-z0-9_]+)$/.exec(reference)?.groups?.name; +} + +function withHexPrefix(value: string): string { + return value.startsWith("0x") ? value : `0x${value}`; +} diff --git a/packages/tx-manifest/src/covenants/covenant.ts b/packages/tx-manifest/src/covenants/covenant.ts new file mode 100644 index 0000000..a5a6cb7 --- /dev/null +++ b/packages/tx-manifest/src/covenants/covenant.ts @@ -0,0 +1,140 @@ +import { asRecord } from "../document/json"; +import type { ParsedLiquidProcessCtParams } from "../request/request"; +import { resolveCompileParams } from "./compileParams"; + +/** + * What one compile yields: where the covenant is, in both forms a wallet needs. + * + * Both come from the same compiled contract rather than from two calls, because they are two + * spellings of one fact. Deriving them separately is how an output comes to be paid to a + * bech32 string — what pays a covenant is a scriptPubKey, and an address is not hex. + */ +export type CompiledCovenant = { + /** What a person is shown. */ + address: string; + /** What an output actually pays to, and what the chain is compared against. */ + scriptPubKeyHex: string; +}; + +/** + * Compiles a covenant and reports where it lands. + * + * The caller supplies the compile step, so this can be exercised without a wasm module and so + * the module's lifecycle stays where it belongs — with the wallet, not with this package. + */ +export type CompileCovenant = (input: { + argumentsJson: string; + network: string; + source: string; +}) => Promise | CompiledCovenant; + +export type CovenantDerivation = { + /** The address the wallet derived by rebuilding the contract itself. */ + address: string; + /** The same covenant as an output pays it, from the same compile. */ + scriptPubKeyHex: string; + /** The manifest's name for the kind of UTXO this is. */ + utxoType: string; +}; + +export type DeriveCovenantResult = + | { derivation: CovenantDerivation; ok: true } + | { ok: false; reason: string }; + +/** + * Derives one covenant UTXO type, from the contract source the request supplied and the + * parameters the manifest wires into it. + * + * This is the wallet establishing a fact for itself. Nothing the site says about where the + * funds are is consulted; the site's contribution is the source text and the parameter + * values, and both change what is derived rather than what it is checked against. + */ +export async function deriveCovenantAddress( + request: ParsedLiquidProcessCtParams, + input: { + compile: CompileCovenant; + declaredTypes: Record; + network: string; + utxoType: string; + wiring: Record; + }, +): Promise { + const declared = asRecord(asRecord(request.manifest.utxo_types)?.[input.utxoType]); + + if (!declared) { + return { ok: false, reason: `The manifest declares no utxo type named "${input.utxoType}".` }; + } + + const sourcePath = asRecord(declared.script)?.source; + + if (typeof sourcePath !== "string") { + return { ok: false, reason: `Utxo type "${input.utxoType}" names no contract source.` }; + } + + const source = request.contractSources[sourcePath]; + + if (source === undefined) { + return { ok: false, reason: `The source of ${sourcePath} was not supplied.` }; + } + + const params = resolveCompileParams(request, input.wiring, input.declaredTypes); + + if (!params.ok) { + return params; + } + + try { + const compiled = await input.compile({ + argumentsJson: JSON.stringify(params.arguments), + network: input.network, + source, + }); + + return { + derivation: { + address: compiled.address, + scriptPubKeyHex: compiled.scriptPubKeyHex, + utxoType: input.utxoType, + }, + ok: true, + }; + } catch (error) { + return { + ok: false, + reason: `The contract at ${sourcePath} did not compile: ${String(error)}`, + }; + } +} + +/** + * Whether a covenant UTXO is what the manifest claims: does the script the wallet derived + * match the one the funds are actually locked by? + * + * `onChainScriptPubKeyHex` must come from the chain, never from the request. Comparing two + * values the same site supplied would pass for any pair it chose to make consistent. The + * state file carries an outpoint and no script precisely because the script has to be read + * rather than told. + * + * The comparison is over the script rather than the address it is written as. The script is + * the locking condition itself; an address is one rendering of it, and rendering is where a + * difference can hide — the same script has a different address on a different network, and + * two spellings of one address are not equal as strings. + * + * A mismatch is a refusal. There is no shape of this function that returns a warning. + */ +export function covenantMatchesChain( + derivation: CovenantDerivation, + onChainScriptPubKeyHex: string, +): { matched: true } | { matched: false; reason: string } { + if (derivation.scriptPubKeyHex.toLowerCase() === onChainScriptPubKeyHex.toLowerCase()) { + return { matched: true }; + } + + return { + matched: false, + reason: + `The ${derivation.utxoType} contract rebuilds to ${derivation.address}, ` + + "but the funds are locked by a different contract. " + + "This is not the contract the site described.", + }; +} diff --git a/packages/tx-manifest/src/covenants/declaredTypes.ts b/packages/tx-manifest/src/covenants/declaredTypes.ts new file mode 100644 index 0000000..d99ad78 --- /dev/null +++ b/packages/tx-manifest/src/covenants/declaredTypes.ts @@ -0,0 +1,26 @@ +import { asRecord } from "../document/json"; + +/** + * The declared types a covenant's compile parameters are encoded against. + * + * Read from the action's own parameter declarations rather than inferred from the values the + * request filled them with. A value's shape is not evidence of what it was declared as, and a + * runtime that guessed from it would read a covenant hash of sixty-four zeros as a number. + * + * A declaration this cannot read leaves the name with no type, which refuses. That is the + * direction to fail in: the alternative is a value encoded at a width nobody stated, and the + * width is part of the address. + */ +export function declaredParamTypes(action: Record): Record { + const types: Record = {}; + + for (const [name, declared] of Object.entries(asRecord(action.params) ?? {})) { + const type = asRecord(declared)?.type; + + if (typeof type === "string") { + types[name] = type; + } + } + + return types; +} diff --git a/packages/tx-manifest/src/document/sites.ts b/packages/tx-manifest/src/document/sites.ts new file mode 100644 index 0000000..395b13a --- /dev/null +++ b/packages/tx-manifest/src/document/sites.ts @@ -0,0 +1,62 @@ +import { asArray, asRecord } from "./json"; + +/** + * One place in an action where a covenant appears, and which side it is on. + * + * Inputs spend a covenant, outputs create one, and the distinction decides whether there + * is anything on chain to compare a derived covenant against. Enumerating both from one + * place is what stops "where are the covenants" being answered differently by whichever + * function happens to be asking. + */ +export type CovenantSite = { + role: "created" | "spent"; + utxoType: string; + /** The compile parameters wired in at this site, unresolved. */ + wiring: Record; +}; + +export function covenantSites(action: Record): CovenantSite[] { + const sites: CovenantSite[] = []; + + for (const entry of asArray(action.inputs)) { + const site = covenantReference(asRecord(entry)?.utxo_source); + + if (site) { + sites.push({ ...site, role: "spent" }); + } + } + + for (const entry of asArray(action.outputs)) { + const site = covenantReference(asRecord(entry)?.destination); + + if (site) { + sites.push({ ...site, role: "created" }); + } + } + + return sites; +} + +/** The utxo types this action reaches, in the order it names them. */ +export function namedUtxoTypes(action: Record): string[] { + return [...new Set(covenantSites(action).map((site) => site.utxoType))]; +} + +/** + * The covenant 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 covenantReference( + value: unknown, +): { utxoType: string; wiring: Record } | undefined { + const record = asRecord(value); + const utxoType = record?.utxo_type; + + if (typeof utxoType !== "string") { + return undefined; + } + + return { utxoType, wiring: asRecord(record?.compile_params) ?? {} }; +} diff --git a/packages/tx-manifest/src/index.ts b/packages/tx-manifest/src/index.ts index a357df4..a5354c5 100644 --- a/packages/tx-manifest/src/index.ts +++ b/packages/tx-manifest/src/index.ts @@ -1,17 +1,30 @@ /** - * Reads the request a site sends to perform one action of a txManifest protocol. + * Reads the request a site sends to perform one action of a txManifest protocol, and + * establishes what the wallet knows about that action before anyone approves it. * * 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. + * between calls: a wallet supplies the chain reads and the compiler, 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. + * This surface is what a wallet needs and nothing else, listed in the order a wallet uses it + * rather than alphabetically, because the order is the point: read the request, then review + * the action against what the chain says. 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 +// 1. 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"; + +// 2. What the chain says, which only a wallet can ask for. A port rather than an +// implementation: this package states the question and holds no endpoint of its own. +export type { ReadTxOut } from "./chain/chainRead"; + +// 3. The action, resolved into what the wallet established about it — or a refusal. This runs +// before the permission gate, where a standing permission cannot skip it, which is why +// everything it cannot establish refuses rather than warns. +export { type ManifestReview, isRefusal, reviewManifestAction } from "./review"; diff --git a/packages/tx-manifest/src/request/requirements.ts b/packages/tx-manifest/src/request/requirements.ts index c7e410e..d7ad93e 100644 --- a/packages/tx-manifest/src/request/requirements.ts +++ b/packages/tx-manifest/src/request/requirements.ts @@ -1,4 +1,5 @@ -import { asArray, asRecord } from "../document/json"; +import { asRecord } from "../document/json"; +import { covenantSites, namedUtxoTypes } from "../document/sites"; import type { ActionRequirements, MissingPart, ParsedLiquidProcessCtParams } from "./request"; /** @@ -98,71 +99,11 @@ function referencedContractSources( 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. * diff --git a/packages/tx-manifest/src/review/index.test.ts b/packages/tx-manifest/src/review/index.test.ts new file mode 100644 index 0000000..11dab88 --- /dev/null +++ b/packages/tx-manifest/src/review/index.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +import p2pkManifest from "../__fixtures__/p2pk.manifest.json"; +import type { TxOutAtOutPoint } from "../chain/chainRead"; +import { isRefusal, reviewManifestAction } from "../index"; +import type { ParsedLiquidProcessCtParams } from "../request/request"; + +// The fixture is the published p2pk manifest and its contract source, unmodified. What the +// review is expected to report comes from that document and from the compiler fake below — +// never from re-deriving it the way the code under test does. + +const SOURCE_PATH = "./p2pk.simf"; +const SOURCE = readFileSync(new URL("../__fixtures__/p2pk.simf", import.meta.url), "utf8"); +const PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +const TXID = "b".repeat(64); +const MANIFEST = p2pkManifest as unknown as Record; + +// A compile yields both spellings of where the covenant is. They are distinct on purpose: +// the address is what a person is shown, the scriptPubKey is what an output pays to and what +// the chain is compared against, and only one of them is hex. +const DERIVED = "tex1p_derived"; +const DERIVED_SCRIPT = `5120${"11".repeat(32)}`; +/** A script that is not the covenant's, for the cases where the chain must disagree. */ +const ELSEWHERE_SCRIPT = `5120${"22".repeat(32)}`; +const COMPILED = { address: DERIVED, scriptPubKeyHex: DERIVED_SCRIPT }; + +const compile = () => COMPILED; + +const chainHolding = (scriptPubKeyHex: string) => async (): Promise => ({ + scriptPubKeyHex, +}); + +function request( + overrides: Partial = {}, +): ParsedLiquidProcessCtParams { + return { + action: "Pay", + broadcast: false, + contractSources: { [SOURCE_PATH]: SOURCE }, + manifest: MANIFEST, + params: { amount_sat: 1000, pubkey: PUBKEY }, + ...overrides, + }; +} + +/** The Receive action, which spends the covenant the state file locates. */ +const spendRequest = (state?: unknown) => + request({ + action: "Receive", + params: { pubkey: PUBKEY }, + ...(state === undefined ? {} : { state: state as Record }), + }); + +const oneCovenantUtxo = { utxos: [{ txid: TXID, utxo_type: "p2pk_output", vout: 0 }] }; + +describe("reviewManifestAction", () => { + // Pay creates a covenant output. There is nothing on chain yet, so the wallet reports what + // it derived and says plainly that it has not compared it against anything. + describe("creating a covenant", () => { + test("reports the derived covenant as not yet on chain", async () => { + const result = await reviewManifestAction(request(), { + compile, + network: "liquid", + readTxOut: chainHolding(DERIVED_SCRIPT), + }); + + expect(isRefusal(result)).toBe(false); + + if (!isRefusal(result)) { + expect(result.action).toBe("Pay"); + expect(result.protocol).toBe("p2pk-simplicity"); + expect(result.covenants).toEqual([ + { + address: DERIVED, + role: "created", + scriptPubKeyHex: DERIVED_SCRIPT, + utxoType: "p2pk_output", + verified: "not-yet-on-chain", + }, + ]); + } + }); + + test("never consults the chain for something that does not exist yet", async () => { + let asked = 0; + + await reviewManifestAction(request(), { + compile, + network: "liquid", + readTxOut: async () => { + asked += 1; + + return { scriptPubKeyHex: DERIVED_SCRIPT }; + }, + }); + + expect(asked).toBe(0); + }); + + test("compiles against the parameters the manifest wires in", async () => { + const seen: string[] = []; + + await reviewManifestAction(request(), { + compile: (input) => { + seen.push(input.argumentsJson); + + return COMPILED; + }, + network: "liquid", + readTxOut: chainHolding(DERIVED_SCRIPT), + }); + + // PUB_KEY is wired to params.pubkey, declared `pubkey` by the action. + expect(seen).toEqual([JSON.stringify({ PUB_KEY: { type: "Pubkey", value: `0x${PUBKEY}` } })]); + }); + }); + + // Receive spends the covenant. This is where the wallet's derivation is checked against + // something it did not get from the requester. + describe("spending a covenant", () => { + test("passes when the rebuilt contract locks the funds that are there", async () => { + const result = await reviewManifestAction(spendRequest(oneCovenantUtxo), { + compile, + network: "liquid", + readTxOut: chainHolding(DERIVED_SCRIPT), + }); + + expect(isRefusal(result)).toBe(false); + + if (!isRefusal(result)) { + expect(result.covenants).toEqual([ + { + address: DERIVED, + role: "spent", + scriptPubKeyHex: DERIVED_SCRIPT, + utxoType: "p2pk_output", + verified: "matches-chain", + }, + ]); + } + }); + + test("reads the outpoint the state file names", async () => { + const asked: { txid: string; vout: number }[] = []; + + await reviewManifestAction(spendRequest(oneCovenantUtxo), { + compile, + network: "liquid", + readTxOut: async (outpoint) => { + asked.push(outpoint); + + return { scriptPubKeyHex: DERIVED_SCRIPT }; + }, + }); + + expect(asked).toEqual([{ txid: TXID, vout: 0 }]); + }); + + test("refuses when the funds are locked by something else", async () => { + const result = await reviewManifestAction(spendRequest(oneCovenantUtxo), { + compile, + network: "liquid", + readTxOut: chainHolding(ELSEWHERE_SCRIPT), + }); + + expect(isRefusal(result)).toBe(true); + + if (isRefusal(result)) { + expect(result.reason).toContain("p2pk_output"); + } + }); + + test("refuses when the state file lists no such covenant", async () => { + const result = await reviewManifestAction(spendRequest({ utxos: [] }), { + compile, + network: "liquid", + readTxOut: chainHolding(DERIVED_SCRIPT), + }); + + expect(isRefusal(result)).toBe(true); + }); + + test("refuses before reading anything when the state file is absent", async () => { + const result = await reviewManifestAction(spendRequest(), { + compile, + network: "liquid", + readTxOut: chainHolding(DERIVED_SCRIPT), + }); + + expect(isRefusal(result)).toBe(true); + }); + + test("refuses when the chain cannot be read, rather than proceeding unchecked", async () => { + const result = await reviewManifestAction(spendRequest(oneCovenantUtxo), { + compile, + network: "liquid", + readTxOut: async () => { + throw new Error("offline"); + }, + }); + + expect(isRefusal(result)).toBe(true); + + if (isRefusal(result)) { + expect(result.reason).toContain("offline"); + } + }); + }); + + test("refuses a request missing a part the action needs, naming it", async () => { + const result = await reviewManifestAction(request({ contractSources: {} }), { + compile, + network: "liquid", + readTxOut: chainHolding(DERIVED_SCRIPT), + }); + + expect(isRefusal(result)).toBe(true); + + if (isRefusal(result)) { + expect(result.reason).toContain(SOURCE_PATH); + } + }); + + test("refuses an action the manifest does not declare, naming it", async () => { + const result = await reviewManifestAction(request({ action: "Withdraw" }), { + compile, + network: "liquid", + readTxOut: chainHolding(DERIVED_SCRIPT), + }); + + expect(isRefusal(result)).toBe(true); + + if (isRefusal(result)) { + expect(result.reason).toContain("Withdraw"); + } + }); + + test("refuses when the contract does not compile", async () => { + const result = await reviewManifestAction(request(), { + compile: () => { + throw new Error("parse error"); + }, + network: "liquid", + readTxOut: chainHolding(DERIVED_SCRIPT), + }); + + expect(isRefusal(result)).toBe(true); + + if (isRefusal(result)) { + expect(result.reason).toContain(SOURCE_PATH); + } + }); +}); diff --git a/packages/tx-manifest/src/review/index.ts b/packages/tx-manifest/src/review/index.ts new file mode 100644 index 0000000..e4a1bd2 --- /dev/null +++ b/packages/tx-manifest/src/review/index.ts @@ -0,0 +1,190 @@ +import type { ReadTxOut } from "../chain/chainRead"; +import { + type CompileCovenant, + covenantMatchesChain, + deriveCovenantAddress, +} from "../covenants/covenant"; +import { declaredParamTypes } from "../covenants/declaredTypes"; +import { asArray, asRecord } from "../document/json"; +import { covenantSites } from "../document/sites"; +import type { ParsedLiquidProcessCtParams } from "../request/request"; +import { resolveActionRequirements } from "../request/requirements"; + +/** + * What the wallet established for itself about one covenant this action touches. + * + * `verified` is the wallet's own finding, never the site's claim. A covenant the action + * creates has nothing to compare against yet — its protection is that the destination is + * derived rather than supplied — and says so rather than reporting a check it did not do. + */ +export type CovenantFinding = { + address: string; + role: "created" | "spent"; + /** What an output pays to, which is not the address and is not interchangeable with it. */ + scriptPubKeyHex: string; + utxoType: string; + verified: "matches-chain" | "not-yet-on-chain"; +}; + +/** + * Everything the wallet established about an action, before anyone approves it. + * + * A description of established fact rather than something to sign: what the action is, which + * protocol declared it, and what was found out about every covenant it touches. Building the + * transaction is a later step and reads this rather than repeating it. + */ +export type ManifestReview = { + action: string; + covenants: CovenantFinding[]; + protocol: string; +}; + +export type ReviewRefusal = { reason: string; refused: true }; + +export type ReviewManifestActionResult = ManifestReview | ReviewRefusal; + +export function isRefusal(result: ReviewManifestActionResult): result is ReviewRefusal { + return "refused" in result; +} + +/** + * Establishes what the wallet knows about an action before anyone is asked to approve it. + * + * For every covenant the action touches, the contract is rebuilt from the source the request + * supplied; one being spent is then compared against what the chain says is at its outpoint, + * and one being created is reported as derived-but-not-yet-on-chain rather than as verified. + * + * That distinction is the point. An action that creates a covenant has nothing to compare + * against, and saying so is more honest than reporting a check that did not happen. Its + * protection is different in kind: the destination is derived by the wallet rather than + * supplied by the site. + * + * Runs before the permission gate deliberately: a standing permission skips the prompt, so + * this is the only thing between a request and a signature. Everything it cannot establish is + * a refusal, and the refusal says which thing — a missing request part named by key, a + * contract that will not compile, a state file listing no such covenant, a chain that cannot + * be read, a covenant that does not match. There is no return value meaning "probably fine". + */ +export async function reviewManifestAction( + request: ParsedLiquidProcessCtParams, + input: { + compile: CompileCovenant; + network: string; + readTxOut: ReadTxOut; + }, +): Promise { + const requirements = resolveActionRequirements(request); + + if (requirements.missing.length > 0) { + const named = requirements.missing + .map((entry) => (entry.keys ? `${entry.reason} (${entry.keys.join(", ")})` : entry.reason)) + .join(" "); + + return { reason: `This request cannot be built. ${named}`, refused: true }; + } + + const action = asRecord(asRecord(request.manifest.actions)?.[request.action]); + + if (!action) { + return { reason: `The manifest declares no action named "${request.action}".`, refused: true }; + } + + const declaredTypes = declaredParamTypes(action); + const covenants: CovenantFinding[] = []; + + for (const site of covenantSites(action)) { + // Sequential on purpose, and the rule is disabled here rather than obeyed. This loop + // returns on the first site it refuses, so running the sites concurrently would compile + // contracts and send chain reads for covenants after the answer is already known. + // oxlint-disable-next-line no-await-in-loop + const derived = await deriveCovenantAddress(request, { + compile: input.compile, + declaredTypes, + network: input.network, + utxoType: site.utxoType, + wiring: site.wiring, + }); + + if (!derived.ok) { + return { reason: derived.reason, refused: true }; + } + + const { address, scriptPubKeyHex, utxoType } = derived.derivation; + + if (site.role === "created") { + covenants.push({ + address, + role: "created", + scriptPubKeyHex, + utxoType, + verified: "not-yet-on-chain", + }); + + continue; + } + + const outpoint = stateOutpoint(request, utxoType); + + if (!outpoint) { + return { reason: `The state file lists no ${utxoType} to spend.`, refused: true }; + } + + let onChain; + + try { + // oxlint-disable-next-line no-await-in-loop + onChain = await input.readTxOut(outpoint); + } catch (error) { + return { + reason: `Could not read what is at ${outpoint.txid}:${outpoint.vout}: ${String(error)}`, + refused: true, + }; + } + + const matched = covenantMatchesChain(derived.derivation, onChain.scriptPubKeyHex); + + if (!matched.matched) { + return { reason: matched.reason, refused: true }; + } + + covenants.push({ + address, + role: "spent", + scriptPubKeyHex, + utxoType, + verified: "matches-chain", + }); + } + + return { + action: request.action, + covenants, + protocol: typeof request.manifest.protocol === "string" ? request.manifest.protocol : "", + }; +} + +/** + * Where the state file says this deployment's covenant of that type sits. + * + * The state file carries an outpoint and no script: what is at an outpoint is read from the + * chain rather than told by whoever asked, which is the whole reason the comparison means + * anything. + */ +function stateOutpoint( + request: ParsedLiquidProcessCtParams, + utxoType: string, +): { txid: string; vout: number } | undefined { + for (const entry of asArray(request.state?.utxos)) { + const utxo = asRecord(entry); + + if (utxo?.utxo_type !== utxoType) { + continue; + } + + if (typeof utxo.txid === "string" && typeof utxo.vout === "number") { + return { txid: utxo.txid, vout: utxo.vout }; + } + } + + return undefined; +}