diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.test.ts index 740f314..8a3fc95 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.test.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.test.ts @@ -15,6 +15,19 @@ import type { SmplxWasmModule } from "./loadSmplxWasm"; // accepted anything could not let a call the real module refuses pass unnoticed. const COVENANT_SCRIPT = `5120${"11".repeat(32)}`; +/** + * What the review says the covenant was built from, carried through rather than re-resolved. + * + * All four, because a module spending this covenant compiles the contract again to satisfy it and + * a compile differing in any one of them produces a different script. + */ +const COVENANT_BUILD = { + argumentsJson: '{"PUB_KEY":{"type":"Pubkey","value":"0x00"}}', + extraLeavesJson: "[]", + includeDebugSymbols: false, + source: "fn main() { }", + sourcePath: "./p2pk.simf", +}; const WALLET_SCRIPT = `0014${"33".repeat(20)}`; const CHANGE_SCRIPT = `0014${"44".repeat(20)}`; const ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; @@ -63,6 +76,7 @@ function review(overrides: Partial = {}): ManifestReview { covenants: [ { address: "tex1p_derived", + ...COVENANT_BUILD, role: "created", scriptPubKeyHex: COVENANT_SCRIPT, utxoType: "p2pk_output", @@ -70,6 +84,7 @@ function review(overrides: Partial = {}): ManifestReview { }, ], feeRateSatsPerKvb: 1000, + normalisation: [], outputs: [{ asset: ASSET, id: "p2pk_out", sats: 50_000n, scriptPubKeyHex: COVENANT_SCRIPT }], protocol: "p2pk-simplicity", selected: [ @@ -271,6 +286,7 @@ describe("assembleReviewedTransaction", () => { covenants: [ { address: "tex1p_derived", + ...COVENANT_BUILD, role: "spent", scriptPubKeyHex: COVENANT_SCRIPT, utxoType: "p2pk_output", diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/compileCovenantWithSmplx.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/compileCovenantWithSmplx.test.ts index 23d3ad8..d74f165 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/compileCovenantWithSmplx.test.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/compileCovenantWithSmplx.test.ts @@ -1,26 +1,50 @@ import { describe, expect, test } from "bun:test"; -import { createSmplxCovenantCompiler } from "./compileCovenantWithSmplx"; +import { + createSmplxContractParamTypes, + createSmplxCovenantCompiler, + createSmplxScriptPubKeyCompiler, +} from "./compileCovenantWithSmplx"; import type { SmplxWasmModule } from "./loadSmplxWasm"; import { smplx } from "./smplxWasmForTests"; const PROBE_SOURCE = "fn main() { assert!(jet::eq_32(witness::A, witness::B)); }"; +const SCRIPT = `5120${"11".repeat(32)}`; -/** A substitute that counts what it was asked to release, since the real one cannot say. */ -function counting(): { module: Pick; released: () => number } { +/** Everything one covenant was constructed with, which is what the module is told. */ +type Construction = [ + source: string, + argumentsJson?: string | null, + extraLeavesJson?: string | null, + includeDebugSymbols?: boolean | null, +]; + +/** + * A substitute that records how each covenant was constructed and how many were released. + * + * The real module can say neither: a handle across the wasm boundary does not report what it was + * built from, and nothing observes a free. Both are exactly what this adapter is responsible for, + * so they are what a substitute is here to see. + */ +function recording(answers: { address?: () => string; scriptPubKeyHex?: () => string } = {}) { + const built: Construction[] = []; let freed = 0; return { + built, module: { Covenant: class { + constructor(...construction: Construction) { + built.push(construction); + } address() { - return "tex1p_derived"; + return answers.address?.() ?? "tex1p_derived"; } free() { freed += 1; } scriptPubKeyHex() { - return `5120${"11".repeat(32)}`; + return answers.scriptPubKeyHex?.() ?? SCRIPT; } }, } as unknown as Pick, @@ -29,58 +53,53 @@ function counting(): { module: Pick; released: () = } describe("createSmplxCovenantCompiler", () => { - // The real module, because the point of this adapter is that both spellings come from one - // compile. A substitute could return any pair and agree with itself. - const compile = createSmplxCovenantCompiler(smplx); + /** + * All four build inputs reach the module, and none of them is left to its default. Passing + * nothing is not the same as passing "none": the module's own default is a different taproot + * tree and a different commitment root, so it is a different address — and one that compiles. + */ + test("forwards the source, the arguments, the leaves and the build mode", () => { + const { built, module } = recording(); - test("reports both spellings of where a covenant is, from one compile", async () => { - const compiled = await compile({ - argumentsJson: "{}", - network: "liquid-testnet", + createSmplxCovenantCompiler(module)({ + argumentsJson: '{"PUB_KEY":{"type":"Pubkey","value":"0x00"}}', + extraLeavesJson: "[]", + includeDebugSymbols: true, + network: "liquid", source: PROBE_SOURCE, }); - expect(compiled.address.startsWith("tex1p")).toBe(true); - expect(compiled.scriptPubKeyHex).toMatch(/^(?:[0-9a-f]{2})+$/); + expect(built).toEqual([ + [PROBE_SOURCE, '{"PUB_KEY":{"type":"Pubkey","value":"0x00"}}', "[]", true], + ]); }); - test("agrees with what a covenant compiled on its own says", async () => { - const compiled = await compile({ + test("forwards the mode the review decided, not one of its own", () => { + const { built, module } = recording(); + const compile = createSmplxCovenantCompiler(module); + const asked = { argumentsJson: "{}", - network: "liquid-testnet", + extraLeavesJson: "[]", + network: "liquid", source: PROBE_SOURCE, - }); - const covenant = new smplx.Covenant(PROBE_SOURCE, "{}"); + }; - expect(compiled.address).toBe(covenant.address("liquid-testnet")); - expect(compiled.scriptPubKeyHex).toBe(covenant.scriptPubKeyHex("liquid-testnet")); - covenant.free(); - }); - - test("lets a source that will not compile throw, rather than reporting an address for it", () => { - expect(() => - compile({ - argumentsJson: "{}", - network: "liquid-testnet", - source: "fn main() { this is not simplicityhl }", - }), - ).toThrow(); - }); + compile({ ...asked, includeDebugSymbols: false }); + compile({ ...asked, includeDebugSymbols: true }); - test("lets an unknown network throw", () => { - expect(() => - compile({ argumentsJson: "{}", network: "not-a-network", source: PROBE_SOURCE }), - ).toThrow(); + expect(built.map((construction) => construction[3])).toEqual([false, true]); }); describe("what it releases", () => { // The covenant is a handle across the wasm boundary, so it is released here rather than // left to a collector that does not know it holds wasm memory. test("releases the covenant it compiled", () => { - const { module, released } = counting(); + const { module, released } = recording(); createSmplxCovenantCompiler(module)({ argumentsJson: "{}", + extraLeavesJson: "[]", + includeDebugSymbols: false, network: "liquid", source: PROBE_SOURCE, }); @@ -91,29 +110,217 @@ describe("createSmplxCovenantCompiler", () => { // A compile that throws holds the same handle as one that does not, which is why this is // a `finally` and not a trailing call. test("releases the covenant when reading it throws", () => { - let freed = 0; - const module = { - Covenant: class { - address(): string { - throw new Error("unknown network"); - } - free() { - freed += 1; - } - scriptPubKeyHex() { - return ""; - } + const { module, released } = recording({ + address: () => { + throw new Error("unknown network"); }, - } as unknown as Pick; + }); expect(() => createSmplxCovenantCompiler(module)({ argumentsJson: "{}", + extraLeavesJson: "[]", + includeDebugSymbols: false, network: "not-a-network", source: PROBE_SOURCE, }), ).toThrow(); - expect(freed).toBe(1); + expect(released()).toBe(1); }); }); }); + +describe("createSmplxScriptPubKeyCompiler", () => { + /** + * The network is bound rather than asked for: a script's bytes do not depend on it — a network + * decides how those bytes are rendered as an address — so it is this wallet's own setting, and + * a port taking it per call would invite a caller to vary something that cannot vary. + */ + test("binds the network and forwards everything the document decided", () => { + const { built, module } = recording(); + const hex = createSmplxScriptPubKeyCompiler( + module, + "liquid-testnet", + )({ + argumentsJson: "{}", + extraLeavesJson: "[]", + includeDebugSymbols: true, + source: PROBE_SOURCE, + }); + + expect(hex).toBe(SCRIPT); + expect(built).toEqual([[PROBE_SOURCE, "{}", "[]", true]]); + }); + + /** + * Synchronous, because the review calls it inside a fixed point: a set of covenant hashes that + * name each other is settled by recompiling all of them together, once per round, and an + * asynchronous step there would make the number of rounds depend on scheduling. + */ + test("answers without a promise", () => { + const { module } = recording(); + const answer = createSmplxScriptPubKeyCompiler( + module, + "liquid", + )({ + argumentsJson: "{}", + extraLeavesJson: "[]", + includeDebugSymbols: false, + source: PROBE_SOURCE, + }); + + expect(typeof answer).toBe("string"); + }); + + test("releases the covenant, including when compiling it throws", () => { + const { module, released } = recording({ + scriptPubKeyHex: () => { + throw new Error("did not compile"); + }, + }); + + expect(() => + createSmplxScriptPubKeyCompiler( + module, + "liquid", + )({ + argumentsJson: "{}", + extraLeavesJson: "[]", + includeDebugSymbols: false, + source: PROBE_SOURCE, + }), + ).toThrow(); + expect(released()).toBe(1); + }); +}); + +/** The port, reading a fixed answer, for the cases that are about the answer's shape. */ +const answering = (answer: string) => + createSmplxContractParamTypes({ covenantParameterTypes: () => answer }); + +describe("createSmplxContractParamTypes", () => { + test("reads the types the compiler reports for a contract", () => { + expect(answering('{"SLOT_COUNT":"u8","WITH_BURN":"bool"}')(PROBE_SOURCE)).toEqual({ + SLOT_COUNT: "u8", + WITH_BURN: "bool", + }); + }); + + test("reads a contract that declares none as declaring none", () => { + expect(answering("{}")(PROBE_SOURCE)).toEqual({}); + }); + + test("passes the source through unchanged", () => { + const asked: string[] = []; + + createSmplxContractParamTypes({ + covenantParameterTypes: (source: string) => { + asked.push(source); + + return "{}"; + }, + })(PROBE_SOURCE); + + expect(asked).toEqual([PROBE_SOURCE]); + }); + + /** + * A malformed answer throws rather than being passed through half-read. The review catches it + * and reports the contract as one that did not compile, which is what it is — whereas a + * partially-read map would silently leave a parameter untyped, and an untyped parameter is one + * the wallet then declines to encode for a reason about the wrong thing. + */ + test("throws on an answer that is not JSON at all", () => { + expect(() => answering("not json")(PROBE_SOURCE)).toThrow(); + }); + + test("throws on an answer that is not a set of names", () => { + for (const answer of ["[]", '"u8"', "null", "7"]) { + expect(() => answering(answer)(PROBE_SOURCE)).toThrow(); + } + }); + + test("throws naming the parameter whose type is not a type", () => { + expect(() => answering('{"SLOT_COUNT":8}')(PROBE_SOURCE)).toThrow(/SLOT_COUNT/); + }); +}); + +/** + * The same three ports against the real wasm module. + * + * A substitute can agree with itself about anything; only the module can say what a source + * actually compiles to, and that both spellings of where a covenant sits come from one compile. + */ +describe("against the module this wallet ships", () => { + const compile = createSmplxCovenantCompiler(smplx); + const asked = { + argumentsJson: "{}", + extraLeavesJson: "[]", + includeDebugSymbols: false, + network: "liquid-testnet", + source: PROBE_SOURCE, + }; + + test("reports both spellings of where a covenant is, from one compile", async () => { + const compiled = await compile(asked); + + expect(compiled.address.startsWith("tex1p")).toBe(true); + expect(compiled.scriptPubKeyHex).toMatch(/^(?:[0-9a-f]{2})+$/); + }); + + test("agrees with what a covenant compiled on its own says", async () => { + const compiled = await compile(asked); + const covenant = new smplx.Covenant(PROBE_SOURCE, "{}", "[]", false); + + // A failing assertion throws, and the handle it holds is the same one a passing assertion + // holds — so the release is a `finally` here for the reason it is one in production. + try { + expect(compiled.address).toBe(covenant.address("liquid-testnet")); + expect(compiled.scriptPubKeyHex).toBe(covenant.scriptPubKeyHex("liquid-testnet")); + } finally { + covenant.free(); + } + }); + + /** The flag changes the commitment root, so the same source lands somewhere else entirely. */ + test("builds a different covenant in the other mode", async () => { + const plain = await compile(asked); + const debug = await compile({ ...asked, includeDebugSymbols: true }); + + expect(debug.scriptPubKeyHex).not.toBe(plain.scriptPubKeyHex); + }); + + test("hashes the same script the full compile locks to", () => { + // Released the way production releases one. A handle constructed inside the assertion is a + // handle nothing frees, and it holds wasm memory a collector does not know about. + const covenant = new smplx.Covenant(PROBE_SOURCE, "{}", "[]", false); + + try { + expect(createSmplxScriptPubKeyCompiler(smplx, "liquid-testnet")(asked)).toBe( + covenant.scriptPubKeyHex("liquid-testnet"), + ); + } finally { + covenant.free(); + } + }); + + // Awaited because the port the review declares accepts an answer either way round: this + // adapter answers at once, and one reading a contract across a boundary that cannot would + // answer with a promise. The caller is written for both, so the test reads it as the caller + // does rather than as this implementation happens to. + test("reads what the module says a contract's parameters are", async () => { + const declared = await createSmplxContractParamTypes(smplx)( + "fn main() { assert!(jet::eq_8(param::SLOTS, 2)); }", + ); + + expect(declared.SLOTS).toBe("u8"); + }); + + test("lets a source that will not compile throw, rather than reporting an address for it", () => { + expect(() => compile({ ...asked, source: "fn main() { this is not simplicityhl }" })).toThrow(); + }); + + test("lets an unknown network throw", () => { + expect(() => compile({ ...asked, network: "not-a-network" })).toThrow(); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/compileCovenantWithSmplx.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/compileCovenantWithSmplx.ts index 4527ddd..71b8b28 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/compileCovenantWithSmplx.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/compileCovenantWithSmplx.ts @@ -3,31 +3,45 @@ import type { reviewManifestAction } from "@humid/tx-manifest"; import type { SmplxWasmModule } from "./loadSmplxWasm"; /** - * The compiler the review asks a wallet for, read off the function that asks. + * The three ports the review asks a wallet for, read off the function that asks. * - * Derived rather than imported by name because the package does not publish one: the port is - * part of what `reviewManifestAction` takes, and taking it from there is what keeps this - * adapter and the thing it is passed to from drifting apart under a second spelling. + * Derived rather than imported by name because the package does not publish them: they are part + * of what `reviewManifestAction` takes, and taking them from there is what keeps this adapter and + * the thing it is passed to from drifting apart under a second spelling. */ -type CompileCovenant = Parameters[1]["compile"]; +type ReviewInput = Parameters[1]; +type CompileCovenant = ReviewInput["compile"]; +type ContractParamTypesOf = NonNullable; +type CompileScriptPubKey = ReviewInput["scriptPubKeyOf"]; /** * The wallet's own compiler, as the review package's port asks for it. * - * One compiled covenant, two spellings of where it is. Deriving them from separate compiles - * is how an output comes to be paid to a bech32 string: the transaction builder hex-decodes - * every output script it is given, and an address is not hex. Two compiles can also drift - * apart in a way nothing would catch, since nothing compares them. + * One compiled covenant, two spellings of where it is. Deriving them from separate compiles is + * how an output comes to be paid to a bech32 string: the transaction builder hex-decodes every + * output script it is given, and an address is not hex. Two compiles can also drift apart in a + * way nothing would catch, since nothing compares them. * - * The covenant handle lives across the wasm boundary, so it is released here rather than - * left to a collector that does not know it holds wasm memory. A `finally` and not a - * trailing call, because a compile that throws holds the same handle as one that does not. + * **All four build inputs are forwarded, and none of them is optional.** The module takes the + * leaves and the build mode as nullable arguments, and passing nothing is not the same as passing + * "none" — it is the module's own default, which is a different taproot tree and a different + * commitment root, and therefore a different address that compiles perfectly well. The review has + * already decided both; this passes on what it decided. + * + * The covenant handle lives across the wasm boundary, so it is released here rather than left to + * a collector that does not know it holds wasm memory. A `finally` and not a trailing call, + * because a compile that throws holds the same handle as one that does not. */ export function createSmplxCovenantCompiler( smplx: Pick, ): CompileCovenant { - return ({ argumentsJson, network, source }) => { - const covenant = new smplx.Covenant(source, argumentsJson); + return ({ argumentsJson, extraLeavesJson, includeDebugSymbols, network, source }) => { + const covenant = new smplx.Covenant( + source, + argumentsJson, + extraLeavesJson, + includeDebugSymbols, + ); try { return { @@ -39,3 +53,81 @@ export function createSmplxCovenantCompiler( } }; } + +/** + * The same compiler again, for the covenant hashes a document works out for itself. + * + * Separate from the compiler above because a hash needs no address, and because the review calls + * it synchronously inside a fixed point: a set of covenant hashes that name each other is settled + * by recompiling all of them together, once per round, and an asynchronous step in that loop + * would make the number of rounds depend on scheduling rather than on the document. + * + * **The network is bound here rather than asked for.** A script's bytes do not depend on it — a + * network decides how those bytes are rendered as an address — so it is this wallet's own + * setting, and a port that took it per call would be inviting a caller to vary something that + * cannot vary. The build mode is forwarded rather than bound, because it belongs to the document + * being reviewed and the review is what read it. + */ +export function createSmplxScriptPubKeyCompiler( + smplx: Pick, + network: string, +): CompileScriptPubKey { + return ({ argumentsJson, extraLeavesJson, includeDebugSymbols, source }) => { + const covenant = new smplx.Covenant( + source, + argumentsJson, + extraLeavesJson, + includeDebugSymbols, + ); + + try { + return covenant.scriptPubKeyHex(network); + } finally { + covenant.free(); + } + }; +} + +/** + * What a contract declares the types of its own compile parameters to be. + * + * SimplicityHL has no syntax for declaring one: `param::NAME` is written where a value is wanted + * and the type checker gives it the type that position demands. So a parameter's type is not in + * the source text at all — it is the result of analysing the source, and the compiler is the only + * thing that can state it. The review needs the answer before it can build any arguments, which + * is why this is a port of its own rather than something read off a compiled covenant. + * + * The module answers with JSON, and what comes back is checked rather than trusted into the + * package's own shape. A malformed answer is thrown rather than passed through half-read: the + * review catches it and reports the contract as one that did not compile, which is what it is — + * whereas a partially-read map would silently leave a parameter untyped, and an untyped parameter + * is one this wallet then declines to encode for a reason about the wrong thing. + */ +export function createSmplxContractParamTypes( + smplx: Pick, +): ContractParamTypesOf { + return (source) => { + const answered: unknown = JSON.parse(smplx.covenantParameterTypes(source)); + + if (typeof answered !== "object" || answered === null || Array.isArray(answered)) { + throw new TypeError( + "the compiler did not report this contract's parameter types as a set of names.", + ); + } + + const declared: Record = {}; + + for (const [name, type] of Object.entries(answered)) { + if (typeof type !== "string") { + throw new TypeError( + `the compiler reported the type of ${name} as ${JSON.stringify(type)}, which does ` + + "not name a type.", + ); + } + + declared[name] = type; + } + + return declared; + }; +} diff --git a/bun.lock b/bun.lock index 7af6d77..b5602de 100644 --- a/bun.lock +++ b/bun.lock @@ -172,6 +172,7 @@ "name": "@humid/tx-manifest", "version": "1.0.0", "dependencies": { + "@noble/hashes": "^1.7.1", "zod": "^4.0.0", }, }, diff --git a/packages/tx-manifest/package.json b/packages/tx-manifest/package.json index 560dee7..479b10c 100644 --- a/packages/tx-manifest/package.json +++ b/packages/tx-manifest/package.json @@ -10,6 +10,7 @@ "./fixtures/*": "./src/__fixtures__/*" }, "dependencies": { + "@noble/hashes": "^1.7.1", "zod": "^4.0.0" } } diff --git a/packages/tx-manifest/src/__fixtures__/README.md b/packages/tx-manifest/src/__fixtures__/README.md new file mode 100644 index 0000000..f52cb6a --- /dev/null +++ b/packages/tx-manifest/src/__fixtures__/README.md @@ -0,0 +1,23 @@ +# Fixtures + +Documents the tests read rather than construct. A test that builds its own manifest inline is a +test of the shape that test's author had in mind; these are documents in the shapes the format +actually appears in. + +`p2pk.manifest.json` and `p2pk.simf` are the published p2pk protocol at txmanifest-wallet +`7d56516a1a1e44a586f25d45a34619c3953758dd`, unmodified. + +`vaultlet.manifest.json` and `current/vaultlet.manifest.json` are one synthetic protocol written +twice, in the two container generations the corpus carries: `classes..methods` with a +`deploy` flag, and `contract_templates..actions` with `is_constructor`. Nothing derived +from either may differ from the other, which is most of what makes the pair worth having. Between +them they exercise a class method reading a deployment's fields, a covenant wired to a bare value, +a covenant hash that depends on another covenant hash, and the deprecated `compile_params.` +reference namespace. + +`mutual.manifest.json` declares two covenant hashes each built from the other. No published +protocol does this and none should: it is here so that the refusal for a set of hashes that never +settles is a refusal about a real document rather than about a contrived one. + +`contracts/` holds the synthetic SimplicityHL sources those two name. They are never compiled +here — every test supplies a substitute for the compiler, because this package holds none. diff --git a/packages/tx-manifest/src/__fixtures__/contracts/README.md b/packages/tx-manifest/src/__fixtures__/contracts/README.md new file mode 100644 index 0000000..682afb2 --- /dev/null +++ b/packages/tx-manifest/src/__fixtures__/contracts/README.md @@ -0,0 +1,11 @@ +# Contract sources + +Synthetic SimplicityHL sources for the `vaultlet` fixture. They are never compiled by anything +in this package — a wallet supplies the compiler, and every test here supplies a substitute for +it — so what they say matters only in that a reader can see which parameters each covenant +takes and why one of them can only be typed by the compiler. + +`param::NAME` is written where a value is wanted and the type checker gives it the type that +position demands. There is no syntax here for declaring a parameter's type, which is why a +parameter wired to a bare value has to be typed by asking the compiler rather than by reading +this text. diff --git a/packages/tx-manifest/src/__fixtures__/contracts/guard.simf b/packages/tx-manifest/src/__fixtures__/contracts/guard.simf new file mode 100644 index 0000000..430633f --- /dev/null +++ b/packages/tx-manifest/src/__fixtures__/contracts/guard.simf @@ -0,0 +1,8 @@ +// A covenant whose only parameter is another covenant's script hash. It is what makes the +// deployment's fields a dependency rather than a list: the guard cannot be compiled until the +// reserve has been, and the document states no order in which to do that. +fn main() { + let reserve: u256 = param::RESERVE_COV_HASH; + + jet::verify(jet::eq_256(jet::output_script_hash(0), reserve)); +} diff --git a/packages/tx-manifest/src/__fixtures__/contracts/left.simf b/packages/tx-manifest/src/__fixtures__/contracts/left.simf new file mode 100644 index 0000000..ccb3e70 --- /dev/null +++ b/packages/tx-manifest/src/__fixtures__/contracts/left.simf @@ -0,0 +1,8 @@ +// One half of a pair of covenants each built from the other's script hash. Compiling either +// requires the other to have been compiled first, and there is no order in which that is +// possible — which is the point: a document declaring this pair has no answer to settle on. +fn main() { + let other: u256 = param::OTHER_COV_HASH; + + jet::verify(jet::eq_256(jet::output_script_hash(0), other)); +} diff --git a/packages/tx-manifest/src/__fixtures__/contracts/reserve.simf b/packages/tx-manifest/src/__fixtures__/contracts/reserve.simf new file mode 100644 index 0000000..26da3a0 --- /dev/null +++ b/packages/tx-manifest/src/__fixtures__/contracts/reserve.simf @@ -0,0 +1,9 @@ +// Where the vault pays out after its timeout. Nothing about this contract depends on any +// other covenant, so its script hash is the fixed point of one round. +fn main() { + let owner: Pubkey = param::OWNER_PUB_KEY; + let timeout: u32 = param::TIMEOUT; + + jet::check_lock_height(timeout); + jet::bip_0340_verify((owner, jet::sig_all_hash()), witness::SIGNATURE); +} diff --git a/packages/tx-manifest/src/__fixtures__/contracts/right.simf b/packages/tx-manifest/src/__fixtures__/contracts/right.simf new file mode 100644 index 0000000..3e252d5 --- /dev/null +++ b/packages/tx-manifest/src/__fixtures__/contracts/right.simf @@ -0,0 +1,6 @@ +// The other half of the pair. See `left.simf`. +fn main() { + let other: u256 = param::OTHER_COV_HASH; + + jet::verify(jet::eq_256(jet::output_script_hash(1), other)); +} diff --git a/packages/tx-manifest/src/__fixtures__/contracts/vault.simf b/packages/tx-manifest/src/__fixtures__/contracts/vault.simf new file mode 100644 index 0000000..26926ad --- /dev/null +++ b/packages/tx-manifest/src/__fixtures__/contracts/vault.simf @@ -0,0 +1,20 @@ +// The vault. Funds sit here until the owner spends them, and the address commits to the +// reserve covenant's script hash — so the reserve behind a vault cannot be changed without +// moving the vault first. +// +// SLOT_COUNT and WITH_BURN are the two parameters the document wires a bare value into. The +// document says nothing about what they are; the type checker below is the only thing that +// does, which is what makes them unencodable without asking the compiler. +fn main() { + let owner: Pubkey = param::OWNER_PUB_KEY; + let asset: u256 = param::VAULT_ASSET_ID; + let reserve: u256 = param::RESERVE_COV_HASH; + let slots: u8 = param::SLOT_COUNT; + let burn: bool = param::WITH_BURN; + + jet::verify(jet::eq_256(jet::input_amount(0).1, asset)); + jet::verify(jet::eq_256(jet::input_script_hash(0), reserve)); + jet::verify(jet::eq_8(slots, 2)); + jet::verify(jet::not(burn)); + jet::bip_0340_verify((owner, jet::sig_all_hash()), witness::SIGNATURE); +} diff --git a/packages/tx-manifest/src/__fixtures__/current/vaultlet.manifest.json b/packages/tx-manifest/src/__fixtures__/current/vaultlet.manifest.json new file mode 100644 index 0000000..850d129 --- /dev/null +++ b/packages/tx-manifest/src/__fixtures__/current/vaultlet.manifest.json @@ -0,0 +1,165 @@ +{ + "$comment": "The same synthetic protocol as `../vaultlet.manifest.json`, written the way the corpus's current generation writes one: the container is `contract_templates..actions` and the constructor is marked `is_constructor`. Nothing else differs, and nothing derived from either document may differ either — that is what the pair is here to say.", + "manifest_version": "1.0", + "protocol": "vaultlet", + "description": "A time-locked vault with a reserve covenant behind it.", + "chain": "liquid", + "utxo_types": { + "vault": { + "description": "Where the vault's funds sit. Its address commits to the reserve covenant's script hash, so the reserve cannot be changed without moving the vault.", + "script": { + "type": "simplicity", + "source": "./vault.simf", + "compile_params": { + "OWNER_PUB_KEY": "OWNER_PUB_KEY", + "VAULT_ASSET_ID": "VAULT_ASSET_ID", + "RESERVE_COV_HASH": "RESERVE_COV_HASH", + "SLOT_COUNT": "2", + "WITH_BURN": "false" + } + } + }, + "reserve": { + "description": "Where the vault pays out after its timeout. Its parameters are written in the deprecated `compile_params.` namespace, which one generation of the format uses where the next writes `instance.`.", + "script": { + "type": "simplicity", + "source": "./reserve.simf", + "compile_params": { + "OWNER_PUB_KEY": "instance.OWNER_PUB_KEY", + "TIMEOUT": "compile_params.TIMEOUT" + } + } + }, + "guard": { + "description": "A covenant whose only parameter is the reserve's script hash. It is what makes this document's deployment fields a dependency rather than a list.", + "script": { + "type": "simplicity", + "source": "./guard.simf", + "compile_params": { + "RESERVE_COV_HASH": "RESERVE_COV_HASH" + } + } + } + }, + "contract_templates": { + "vaultlet_contract": { + "description": "One deployed vault.", + "fields": { + "OWNER_PUB_KEY": { + "type": "pubkey", + "description": "The owner's x-only key." + }, + "VAULT_ASSET_ID": { + "type": "liquid.asset_id", + "description": "The asset the vault holds." + }, + "VAULT_AMOUNT": { + "type": "u64", + "description": "How much of it the vault was funded with." + }, + "TIMEOUT": { + "type": "u32", + "description": "Absolute block height the reserve becomes spendable at." + }, + "RESERVE_COV_HASH": { + "type": "bytes32", + "description": "sha256 of the reserve covenant's scriptPubKey. Computed by OpenVault." + }, + "GUARD_COV_HASH": { + "type": "bytes32", + "description": "sha256 of the guard covenant's scriptPubKey, which is itself built from RESERVE_COV_HASH. Computed by OpenVault." + } + }, + "actions": { + "OpenVault": { + "description": "Fund a new vault and record the deployment it creates.", + "is_constructor": true, + "params": { + "OWNER_PUB_KEY": { + "type": "pubkey" + }, + "VAULT_ASSET_ID": { + "type": "liquid.asset_id" + }, + "VAULT_AMOUNT": { + "type": "u64" + }, + "TIMEOUT": { + "type": "u32" + } + }, + "inputs": [ + { + "id": "funding", + "utxo_source": "wallet" + } + ], + "outputs": [ + { + "id": "vault_out", + "destination": { + "utxo_type": "vault" + }, + "amount_sat": "params.VAULT_AMOUNT" + }, + { + "id": "vault_change", + "destination": "change", + "optional": true + } + ], + "create_instance": { + "fields": { + "OWNER_PUB_KEY": "$params.OWNER_PUB_KEY", + "VAULT_ASSET_ID": "params.VAULT_ASSET_ID", + "VAULT_AMOUNT": "params.VAULT_AMOUNT", + "TIMEOUT": "params.TIMEOUT", + "RESERVE_COV_HASH": { + "type": "tapleaf", + "simf": "./reserve.simf", + "params": { + "OWNER_PUB_KEY": { + "type": "pubkey", + "value": "OWNER_PUB_KEY" + }, + "TIMEOUT": { + "type": "u32", + "value": "TIMEOUT" + } + } + }, + "GUARD_COV_HASH": { + "type": "tapleaf", + "simf": "./guard.simf", + "params": { + "RESERVE_COV_HASH": { + "type": "bytes32", + "value": "RESERVE_COV_HASH" + } + } + } + } + } + }, + "Withdraw": { + "description": "Spend the vault back to the owner's wallet. Every value it compiles the vault with comes off the deployment; this action declares no parameters of its own.", + "inputs": [ + { + "id": "vault_in", + "utxo_source": { + "utxo_type": "vault" + } + } + ], + "outputs": [ + { + "id": "withdrawn", + "destination": "wallet", + "amount_sat": "instance.VAULT_AMOUNT" + } + ] + } + } + } + } +} diff --git a/packages/tx-manifest/src/__fixtures__/mutual.manifest.json b/packages/tx-manifest/src/__fixtures__/mutual.manifest.json new file mode 100644 index 0000000..068caaa --- /dev/null +++ b/packages/tx-manifest/src/__fixtures__/mutual.manifest.json @@ -0,0 +1,38 @@ +{ + "$comment": "A synthetic protocol whose deployment declares two covenant hashes, each built from the other. There is no order in which the pair can be compiled, so there is no answer for the iteration to settle on — which is what this fixture is for. Nothing in the published corpus does this; a document that did would produce an address nobody had checked, and a runtime that returned the last round's values rather than refusing would pay to it.", + "manifest_version": "1.0", + "protocol": "mutual", + "chain": "liquid", + "utxo_types": { + "left": { + "script": { + "type": "simplicity", + "source": "./left.simf", + "compile_params": { "OTHER_COV_HASH": "RIGHT_COV_HASH" } + } + } + }, + "actions": { + "Knot": { + "description": "Deploy the pair. Declared at the top level rather than inside a class, so the same field resolution is exercised for an action bound to no deployment.", + "inputs": [{ "id": "funding", "utxo_source": "wallet" }], + "outputs": [ + { "id": "left_out", "destination": { "utxo_type": "left" }, "amount_sat": "1000" } + ], + "create_instance": { + "fields": { + "LEFT_COV_HASH": { + "type": "tapleaf", + "simf": "./left.simf", + "params": { "OTHER_COV_HASH": { "type": "bytes32", "value": "RIGHT_COV_HASH" } } + }, + "RIGHT_COV_HASH": { + "type": "tapleaf", + "simf": "./right.simf", + "params": { "OTHER_COV_HASH": { "type": "bytes32", "value": "LEFT_COV_HASH" } } + } + } + } + } + } +} diff --git a/packages/tx-manifest/src/__fixtures__/vaultlet-debug.manifest.json b/packages/tx-manifest/src/__fixtures__/vaultlet-debug.manifest.json new file mode 100644 index 0000000..fd3804c --- /dev/null +++ b/packages/tx-manifest/src/__fixtures__/vaultlet-debug.manifest.json @@ -0,0 +1,169 @@ +{ + "$comment": "The same synthetic protocol as `vaultlet.manifest.json`, published by authors who built their contracts with debug symbols and said so in the block the newer generation moved the statement into. The flag changes the commitment merkle root, so every covenant of this document sits at a different address from the identical document without it — which is why it is read rather than assumed, and why a statement that cannot be read is refused.", + "manifest_version": "1.0", + "protocol": "vaultlet", + "simplicity_hl": { + "version": "0.6.0", + "debug_symbols": true + }, + "description": "A time-locked vault with a reserve covenant behind it.", + "chain": "liquid", + "utxo_types": { + "vault": { + "description": "Where the vault's funds sit. Its address commits to the reserve covenant's script hash, so the reserve cannot be changed without moving the vault.", + "script": { + "type": "simplicity", + "source": "./vault.simf", + "compile_params": { + "OWNER_PUB_KEY": "OWNER_PUB_KEY", + "VAULT_ASSET_ID": "VAULT_ASSET_ID", + "RESERVE_COV_HASH": "RESERVE_COV_HASH", + "SLOT_COUNT": "2", + "WITH_BURN": "false" + } + } + }, + "reserve": { + "description": "Where the vault pays out after its timeout. Its parameters are written in the deprecated `compile_params.` namespace, which one generation of the format uses where the next writes `instance.`.", + "script": { + "type": "simplicity", + "source": "./reserve.simf", + "compile_params": { + "OWNER_PUB_KEY": "instance.OWNER_PUB_KEY", + "TIMEOUT": "compile_params.TIMEOUT" + } + } + }, + "guard": { + "description": "A covenant whose only parameter is the reserve's script hash. It is what makes this document's deployment fields a dependency rather than a list.", + "script": { + "type": "simplicity", + "source": "./guard.simf", + "compile_params": { + "RESERVE_COV_HASH": "RESERVE_COV_HASH" + } + } + } + }, + "classes": { + "vaultlet_contract": { + "description": "One deployed vault.", + "fields": { + "OWNER_PUB_KEY": { + "type": "pubkey", + "description": "The owner's x-only key." + }, + "VAULT_ASSET_ID": { + "type": "liquid.asset_id", + "description": "The asset the vault holds." + }, + "VAULT_AMOUNT": { + "type": "u64", + "description": "How much of it the vault was funded with." + }, + "TIMEOUT": { + "type": "u32", + "description": "Absolute block height the reserve becomes spendable at." + }, + "RESERVE_COV_HASH": { + "type": "bytes32", + "description": "sha256 of the reserve covenant's scriptPubKey. Computed by OpenVault." + }, + "GUARD_COV_HASH": { + "type": "bytes32", + "description": "sha256 of the guard covenant's scriptPubKey, which is itself built from RESERVE_COV_HASH. Computed by OpenVault." + } + }, + "methods": { + "OpenVault": { + "deploy": true, + "description": "Fund a new vault and record the deployment it creates.", + "params": { + "OWNER_PUB_KEY": { + "type": "pubkey" + }, + "VAULT_ASSET_ID": { + "type": "liquid.asset_id" + }, + "VAULT_AMOUNT": { + "type": "u64" + }, + "TIMEOUT": { + "type": "u32" + } + }, + "inputs": [ + { + "id": "funding", + "utxo_source": "wallet" + } + ], + "outputs": [ + { + "id": "vault_out", + "destination": { + "utxo_type": "vault" + }, + "amount_sat": "params.VAULT_AMOUNT" + }, + { + "id": "vault_change", + "destination": "change", + "optional": true + } + ], + "create_instance": { + "fields": { + "OWNER_PUB_KEY": "$params.OWNER_PUB_KEY", + "VAULT_ASSET_ID": "params.VAULT_ASSET_ID", + "VAULT_AMOUNT": "params.VAULT_AMOUNT", + "TIMEOUT": "params.TIMEOUT", + "RESERVE_COV_HASH": { + "type": "tapleaf", + "simf": "./reserve.simf", + "params": { + "OWNER_PUB_KEY": { + "type": "pubkey", + "value": "OWNER_PUB_KEY" + }, + "TIMEOUT": { + "type": "u32", + "value": "TIMEOUT" + } + } + }, + "GUARD_COV_HASH": { + "type": "tapleaf", + "simf": "./guard.simf", + "params": { + "RESERVE_COV_HASH": { + "type": "bytes32", + "value": "RESERVE_COV_HASH" + } + } + } + } + } + }, + "Withdraw": { + "description": "Spend the vault back to the owner's wallet. Every value it compiles the vault with comes off the deployment; this action declares no parameters of its own.", + "inputs": [ + { + "id": "vault_in", + "utxo_source": { + "utxo_type": "vault" + } + } + ], + "outputs": [ + { + "id": "withdrawn", + "destination": "wallet", + "amount_sat": "instance.VAULT_AMOUNT" + } + ] + } + } + } + } +} diff --git a/packages/tx-manifest/src/__fixtures__/vaultlet.manifest.json b/packages/tx-manifest/src/__fixtures__/vaultlet.manifest.json new file mode 100644 index 0000000..e929db1 --- /dev/null +++ b/packages/tx-manifest/src/__fixtures__/vaultlet.manifest.json @@ -0,0 +1,126 @@ +{ + "$comment": "A synthetic protocol, written the way the corpus's earlier generation writes one: actions live inside `classes..methods`, and the constructor is marked with `deploy`. It is the same document as `current/vaultlet.manifest.json`, which spells the container `contract_templates..actions` and the flag `is_constructor`. Both generations exist in the published corpus, and a wallet that read only one would be blind to the money the other locates. Kept small on purpose: it exercises the two declaration shapes, a deployment's fields, a covenant wired to a bare value, and a covenant hash that depends on another covenant hash — and nothing else.", + "manifest_version": "1.0", + "protocol": "vaultlet", + "description": "A time-locked vault with a reserve covenant behind it.", + "chain": "liquid", + "utxo_types": { + "vault": { + "description": "Where the vault's funds sit. Its address commits to the reserve covenant's script hash, so the reserve cannot be changed without moving the vault.", + "script": { + "type": "simplicity", + "source": "./vault.simf", + "compile_params": { + "OWNER_PUB_KEY": "OWNER_PUB_KEY", + "VAULT_ASSET_ID": "VAULT_ASSET_ID", + "RESERVE_COV_HASH": "RESERVE_COV_HASH", + "SLOT_COUNT": "2", + "WITH_BURN": "false" + } + } + }, + "reserve": { + "description": "Where the vault pays out after its timeout. Its parameters are written in the deprecated `compile_params.` namespace, which one generation of the format uses where the next writes `instance.`.", + "script": { + "type": "simplicity", + "source": "./reserve.simf", + "compile_params": { + "OWNER_PUB_KEY": "instance.OWNER_PUB_KEY", + "TIMEOUT": "compile_params.TIMEOUT" + } + } + }, + "guard": { + "description": "A covenant whose only parameter is the reserve's script hash. It is what makes this document's deployment fields a dependency rather than a list.", + "script": { + "type": "simplicity", + "source": "./guard.simf", + "compile_params": { + "RESERVE_COV_HASH": "RESERVE_COV_HASH" + } + } + } + }, + "classes": { + "vaultlet_contract": { + "description": "One deployed vault.", + "fields": { + "OWNER_PUB_KEY": { + "type": "pubkey", + "description": "The owner's x-only key." + }, + "VAULT_ASSET_ID": { + "type": "liquid.asset_id", + "description": "The asset the vault holds." + }, + "VAULT_AMOUNT": { + "type": "u64", + "description": "How much of it the vault was funded with." + }, + "TIMEOUT": { + "type": "u32", + "description": "Absolute block height the reserve becomes spendable at." + }, + "RESERVE_COV_HASH": { + "type": "bytes32", + "description": "sha256 of the reserve covenant's scriptPubKey. Computed by OpenVault." + }, + "GUARD_COV_HASH": { + "type": "bytes32", + "description": "sha256 of the guard covenant's scriptPubKey, which is itself built from RESERVE_COV_HASH. Computed by OpenVault." + } + }, + "methods": { + "OpenVault": { + "deploy": true, + "description": "Fund a new vault and record the deployment it creates.", + "params": { + "OWNER_PUB_KEY": { "type": "pubkey" }, + "VAULT_ASSET_ID": { "type": "liquid.asset_id" }, + "VAULT_AMOUNT": { "type": "u64" }, + "TIMEOUT": { "type": "u32" } + }, + "inputs": [{ "id": "funding", "utxo_source": "wallet" }], + "outputs": [ + { + "id": "vault_out", + "destination": { "utxo_type": "vault" }, + "amount_sat": "params.VAULT_AMOUNT" + }, + { "id": "vault_change", "destination": "change", "optional": true } + ], + "create_instance": { + "fields": { + "OWNER_PUB_KEY": "$params.OWNER_PUB_KEY", + "VAULT_ASSET_ID": "params.VAULT_ASSET_ID", + "VAULT_AMOUNT": "params.VAULT_AMOUNT", + "TIMEOUT": "params.TIMEOUT", + "RESERVE_COV_HASH": { + "type": "tapleaf", + "simf": "./reserve.simf", + "params": { + "OWNER_PUB_KEY": { "type": "pubkey", "value": "OWNER_PUB_KEY" }, + "TIMEOUT": { "type": "u32", "value": "TIMEOUT" } + } + }, + "GUARD_COV_HASH": { + "type": "tapleaf", + "simf": "./guard.simf", + "params": { + "RESERVE_COV_HASH": { "type": "bytes32", "value": "RESERVE_COV_HASH" } + } + } + } + } + }, + "Withdraw": { + "description": "Spend the vault back to the owner's wallet. Every value it compiles the vault with comes off the deployment; this action declares no parameters of its own.", + "inputs": [{ "id": "vault_in", "utxo_source": { "utxo_type": "vault" } }], + "outputs": [ + { "id": "withdrawn", "destination": "wallet", "amount_sat": "instance.VAULT_AMOUNT" } + ] + } + } + } + } +} diff --git a/packages/tx-manifest/src/covenants/compileParams.ts b/packages/tx-manifest/src/covenants/compileParams.ts index c1892e4..eb77a86 100644 --- a/packages/tx-manifest/src/covenants/compileParams.ts +++ b/packages/tx-manifest/src/covenants/compileParams.ts @@ -1,4 +1,7 @@ -import type { ParsedLiquidProcessCtParams } from "../request/request"; +import type { NormalisationNote } from "../document/normalise"; +import { parseReference, type ReferenceScope, resolveReference } from "../document/references"; +import { type DeclaringContract, encodeContractLiteral } from "./contractParamTypes"; +import { encodeCompileParam, encodesDeclaredType, unencodableReason } from "./paramEncoding"; /** * A contract's compile-time parameters, in SimplicityHL's own argument JSON shape. @@ -13,38 +16,33 @@ export type ResolveCompileParamsResult = | { ok: false; reason: string }; /** - * The manifest's declared parameter types, mapped to the compiler's. + * Resolves the compile-time parameters a contract is built with, from the manifest's wiring and + * what the request and the deployment supply. * - * 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. + * 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 map is read as + * wiring; a reference inside it is resolved at the compile-parameter site, which is what decides + * that `instance.`, `params.`, `args.` and a bare name are meaningful here. * - * 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. + * What each value is encoded as comes from the type its parameter was declared with and from + * nothing else — never from the value's own shape. `paramEncoding` holds the closed list of + * types that have an encoding and refuses the rest by name, because the compiler accepts almost + * anything shaped like a value and returns a valid address for the wrong contract. * - * 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. + * `declaredAtUse` is the third place a type can come from, and the only one the document states + * outright. At one position — a `tapleaf` field of the deployment an action creates — the wiring + * is written `{"IS_ACTIVE": {"type": "bool", "value": "false"}}`, so the type sits beside the + * value rather than on a parameter declared elsewhere. Where it is given it wins, because a + * declaration at the point of use cannot be a different parameter's by accident. */ export function resolveCompileParams( - request: ParsedLiquidProcessCtParams, wiring: Record, declaredTypes: Record, + scope: ReferenceScope, + notes?: NormalisationNote[], + contract?: DeclaringContract, + declaredAtUse?: Record, ): ResolveCompileParamsResult { const resolved: ContractArguments = {}; @@ -53,50 +51,175 @@ export function resolveCompileParams( return { ok: false, reason: `Compile parameter ${name} is not a reference.` }; } - const paramName = referencedParam(reference); + const found = resolveCovenantReference(reference, scope, notes); + + if (!found.ok) { + const literal = + asStatedValue(name, reference, found.reason, declaredAtUse?.[name]) ?? + asContractLiteral(name, reference, found.reason, contract); + + if (!literal.ok) { + return { ok: false, reason: `Compile parameter ${name}: ${literal.reason}` }; + } + + resolved[name] = literal.encoded; - if (!paramName) { + continue; + } + + if (typeof found.value !== "string") { return { ok: false, - reason: `Compile parameter ${name} references ${reference}, which this runtime does not resolve yet.`, + reason: `Compile parameter ${name} resolves to ${reference}, which is not a value this runtime can encode yet.`, }; } - const value = request.params[paramName]; + // A compile parameter's type comes from the parameter the manifest declares, so a + // reference to something with no declared type has nothing to encode against. + const declaredType = declaredAtUse?.[name] ?? declaredTypeOf(reference, declaredTypes); - if (typeof value !== "string") { - return { - ok: false, - reason: `Compile parameter ${name} needs parameter ${paramName}, which the request did not supply as a value.`, - }; + if (!encodesDeclaredType(declaredType)) { + return { ok: false, reason: `${reference} ${unencodableReason(declaredType)}.` }; } - const declaredType = declaredTypes[paramName]; - const compilerType = declaredType ? PARAM_TYPES[declaredType] : undefined; + const encoded = encodeCompileParam(declaredType ?? "", found.value, name, reference); - if (!compilerType) { - return { - ok: false, - reason: `Parameter ${paramName} is declared as ${declaredType ?? "an unstated type"}, which this runtime does not encode yet.`, - }; + if (!encoded.ok) { + return encoded; } - resolved[name] = { type: compilerType, value: withHexPrefix(value) }; + resolved[name] = encoded.encoded; } return { arguments: resolved, ok: true }; } /** - * The action parameter a reference points at, or undefined when it points elsewhere. + * One compile-parameter reference, resolved against everything that can supply it. + * + * A bare name is tried as the request's own first, which is the order every other site reads one + * in. What is added here is the third place a covenant's parameter can come from: the fields of + * the deployment it belongs to. * - * Accepts the `$`-prefixed spelling alongside the bare one: the corpus carries both, and - * `lending` uses one where `lending_v2` uses the other. + * The corpus writes it that way throughout — `{"ASSET_B": "ASSET_B"}` on a swap's offer + * covenant — and those name fields of the deployment rather than parameters of the action being + * run. A protocol's constructor supplies them as parameters and every later action reads them + * back off the deployment, so a runtime reading only the request compiles a protocol's first + * action and refuses every one after it. + * + * Falling through here encodes nothing on a guess. A field reached this way still has to have + * been declared with a type before anything is built out of it. */ -function referencedParam(reference: string): string | undefined { - return /^\$?params\.(?[A-Za-z0-9_]+)$/.exec(reference)?.groups?.name; +function resolveCovenantReference( + reference: string, + scope: ReferenceScope, + notes?: NormalisationNote[], +): { ok: false; reason: string } | { ok: true; value: unknown } { + const found = resolveReference(reference, "compileParam", scope, notes); + const parsed = parseReference(reference); + + if (found.ok || parsed?.form !== "bare") { + return found; + } + + return scope.instance && parsed.name in scope.instance + ? { ok: true, value: scope.instance[parsed.name] } + : found; } -function withHexPrefix(value: string): string { - return value.startsWith("0x") ? value : `0x${value}`; +/** + * One wiring entry read as the value it is, at the type the document declared beside it. + * + * Nothing is returned where the document declared no type there, so the caller falls through to + * what the contract says. The two are not alternatives to choose between by preference: this one + * is a statement in the document being read, and the contract's is a fact about a different + * artifact that happens to line up. + * + * A text shaped like a name that will not encode reports the lookup's own failure, for the same + * reason `asContractLiteral` does: text shaped like a name is nearly always meant as one, and + * "that is not 32 bytes of hex" would explain the wrong mistake to whoever reads it. + */ +function asStatedValue( + name: string, + text: string, + referenceReason: string, + declaredType: string | undefined, +): EncodeLiteralResult | undefined { + if (declaredType === undefined) { + return undefined; + } + + if (!encodesDeclaredType(declaredType)) { + return { ok: false, reason: `${name} is declared ${unencodableReason(declaredType)}.` }; + } + + const encoded = encodeCompileParam(declaredType, text, name, "a value"); + + if (encoded.ok) { + return encoded; + } + + return { + ok: false, + reason: parseReference(text) === undefined ? encoded.reason : referenceReason, + }; +} + +/** + * One wiring entry that resolved to nothing, read as the value it is instead. + * + * Some compile parameters are wired to a bare value rather than to a name, and a value is not a + * reference — resolving one always fails. The failure is the same one a misspelled field + * produces, so the two are told apart by what the contract says rather than by how the text + * looks: a parameter the contract declares can take a value, and a parameter it does not declare + * is a lookup that failed. + * + * **A reference is tried first and keeps winning.** A deployment field is what a name means + * wherever one exists, so nothing that resolves today is re-read as a value. + */ +function asContractLiteral( + name: string, + text: string, + referenceReason: string, + contract: DeclaringContract | undefined, +): EncodeLiteralResult { + if (!contract || contract.declares[name] === undefined) { + return { ok: false, reason: referenceReason }; + } + + const encoded = encodeContractLiteral(name, text, contract); + + if (encoded.ok) { + return encoded; + } + + return { + ok: false, + reason: parseReference(text) === undefined ? encoded.reason : referenceReason, + }; +} + +type EncodeLiteralResult = + | { encoded: { type: string; value: string }; ok: true } + | { ok: false; reason: string }; + +/** + * The declared type of whatever a reference points at. + * + * The corpus writes three spellings at this site — a bare name, a `params.` one and an + * `instance.` one — and all three name a declaration by the same name. Anything else has no + * declared type here rather than a guessed one: encoding a value at the wrong width changes the + * address silently. + */ +function declaredTypeOf( + reference: string, + declaredTypes: Record, +): string | undefined { + const parsed = parseReference(reference); + + if (!parsed || parsed.form === "input-attribute") { + return undefined; + } + + return declaredTypes[parsed.name]; } diff --git a/packages/tx-manifest/src/covenants/contractParamTypes.ts b/packages/tx-manifest/src/covenants/contractParamTypes.ts new file mode 100644 index 0000000..3cf319a --- /dev/null +++ b/packages/tx-manifest/src/covenants/contractParamTypes.ts @@ -0,0 +1,134 @@ +import { encodeCompileParam, type EncodeParamResult } from "./paramEncoding"; + +/** + * What a contract itself says the types of its compile parameters are. + * + * A deployment mostly wires a compile parameter to a name, and the name carries the format's own + * declared type. Some parameters are wired to a bare value instead — a number, or one of the two + * words a flag is written as. That position declares nothing, because it is a deployment's + * wiring rather than a list of parameters, so the value arrives with no type and there is + * nothing to encode it against. + * + * The type still exists. It is just not in the document. + * + * **SimplicityHL has no syntax for declaring a parameter's type.** `param::NAME` is written + * where a value is wanted and the type checker gives it the type that position demands. So a + * parameter's type is not written down anywhere in the source; it is a result of analysing the + * source, and the only thing that can state it is the compiler. + * + * That is why this takes the types as given rather than reading the contract text. A reader that + * recovered them from the source would be reimplementing the type checker, and the failure mode + * of getting one wrong is a value encoded at a width nobody stated. + */ +export type ContractParamTypes = Record; + +/** + * A contract, and what it declares — enough to encode a value against and to name in a refusal. + * + * The source path is carried because a refusal has to say which contract decided the answer. Two + * utxo types can wire the same parameter name into different contracts, and "the contract does + * not take a value there" is only actionable if the reader knows which one is meant. + */ +export type DeclaringContract = { + declares: ContractParamTypes; + /** The contract source path, as the document names it. */ + source: string; +}; + +/** + * The compiler's own type names, mapped to the encoding entry each one shares. + * + * The entry names on the right are the format's declared-type names, and for these five they are + * spelled identically to the compiler's. That is not relied upon quietly: `encodeContractLiteral` + * checks that what comes back is typed as the contract asked for, so a mapping that ever stopped + * lining up would refuse rather than compile something else. + * + * The list is closed for the same reason the encoding list is closed. + */ +const CONTRACT_TYPES: Record = { + bool: "bool", + u8: "u8", + u16: "u16", + u32: "u32", + u64: "u64", +}; + +/** + * Why a type a contract really does declare still cannot take a bare value. + * + * These are not gaps waiting to be filled in by pattern. Each one names something the position + * does not say, and a value written there would have to be guessed at rather than read. + */ +const UNENCODABLE: Record = { + u256: + "a thirty-two byte value's byte order is not decided by its width. An asset id is " + + "committed in the reverse of how it is written and a covenant hash is not, and a " + + "contract declares both as u256 — so a name, which carries the format's own type, can " + + "be encoded here and a bare value cannot", +}; + +/** + * One compile parameter written as a bare value, encoded from the type its contract declares. + * + * Nothing here looks at the value. `"1"` is not read as a small number, `"false"` is not read as + * a flag, and a run of sixty-four hexadecimal characters is not read as a hash. The type + * decides, and where the contract's type does not decide the encoding on its own, this refuses. + * + * The refusal names the parameter and the contract, because those are what a person can act on: + * the document wires a value into a parameter, and the contract is what says whether a value + * belongs there at all. + */ +export function encodeContractLiteral( + name: string, + value: string, + contract: DeclaringContract, +): EncodeParamResult { + const declaredType = contract.declares[name]; + + if (declaredType === undefined) { + return { + ok: false, + reason: + `${name} is written as the value "${value}", and ${contract.source} declares no ` + + "compile parameter of that name to take it.", + }; + } + + const entry = CONTRACT_TYPES[declaredType]; + + if (entry === undefined) { + const known = UNENCODABLE[declaredType]; + + return { + ok: false, + reason: + `${name} is written as the value "${value}", and ${contract.source} declares it ` + + `${declaredType}, which this runtime does not encode from a value` + + `${known === undefined ? "" : `: ${known}`}.`, + }; + } + + const encoded = encodeCompileParam(entry, value, name, "a value"); + + if (!encoded.ok) { + return { + ok: false, + reason: `${encoded.reason} That is the type ${contract.source} declares for it.`, + }; + } + + // The compiler requires an argument's type to equal its parameter's exactly, so anything + // typed differently from what the contract declared would be refused there rather than built + // wrongly. Refusing it here says which parameter and which contract, which the compiler's own + // message does not. + if (encoded.encoded.type !== declaredType) { + return { + ok: false, + reason: + `${name} is declared ${declaredType} by ${contract.source}, and this runtime ` + + `encoded it as ${encoded.encoded.type}.`, + }; + } + + return encoded; +} diff --git a/packages/tx-manifest/src/covenants/covenant.ts b/packages/tx-manifest/src/covenants/covenant.ts index a5a6cb7..6bad181 100644 --- a/packages/tx-manifest/src/covenants/covenant.ts +++ b/packages/tx-manifest/src/covenants/covenant.ts @@ -1,13 +1,15 @@ -import { asRecord } from "../document/json"; -import type { ParsedLiquidProcessCtParams } from "../request/request"; +import { asArray, asRecord } from "../document/json"; +import type { NormalisationNote, NormalisedManifest } from "../document/normalise"; +import type { ReferenceScope } from "../document/references"; import { resolveCompileParams } from "./compileParams"; +import type { ContractParamTypes } from "./contractParamTypes"; /** * 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. + * 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. */ @@ -24,15 +26,62 @@ export type CompiledCovenant = { */ export type CompileCovenant = (input: { argumentsJson: string; + /** + * Already-encoded taproot leaf payloads, appended to the tree in declaration order. + * + * Always an empty list in this slice, which refuses a utxo type declaring any. It is passed + * rather than omitted because the compiler distinguishes "no leaves" from "not told", and a + * covenant built the second way is a different covenant at a different address. + */ + extraLeavesJson: string; + /** The mode this protocol declares its contracts were built in. */ + includeDebugSymbols: boolean; network: string; source: string; }) => Promise | CompiledCovenant; +/** + * What a contract says the types of its own compile parameters are. + * + * Supplied the same way the compile step is, and for the same reason: the answer comes from the + * compiler, and the compiler's lifecycle belongs to the wallet rather than to this package. + * + * It is separate from compiling because it is needed *before* a compile — a parameter written as + * a bare value has no type until the contract states one, and the arguments a compile takes + * cannot be built until it does. Asking a compiled contract instead would be circular. + * + * Optional, because a document that wires every parameter to a name needs nothing from it. A + * document that writes one as a value and has no reader here is refused rather than guessed at. + */ +export type ContractParamTypesOf = ( + source: string, +) => Promise | ContractParamTypes; + +/** + * Everything one covenant was built from, and where that put it. + * + * The four build inputs are carried out rather than left to be worked out again. A module that + * spends this covenant compiles the contract a second time to satisfy it, and a compile that + * differs in any one of them produces a different script — which the covenant's own execution + * then rejects, after a person has approved a transaction the wallet had already checked. So the + * source text itself travels rather than the path to it: a path is a key into a request, and + * asking the request again is exactly the second resolution this exists to prevent. + */ export type CovenantDerivation = { /** The address the wallet derived by rebuilding the contract itself. */ address: string; + /** The parameters it was built with, in the compiler's own shape. */ + argumentsJson: string; + /** The taproot leaves it was built with, encoded. Empty in this slice, and stated anyway. */ + extraLeavesJson: string; + /** The mode it was built in, which decides its address as much as its parameters do. */ + includeDebugSymbols: boolean; /** The same covenant as an output pays it, from the same compile. */ scriptPubKeyHex: string; + /** The contract text it was built from, as the request supplied it. */ + source: string; + /** The path the document named it by, for a reader who has to find it in the document. */ + sourcePath: string; /** The manifest's name for the kind of UTXO this is. */ utxoType: string; }; @@ -45,47 +94,98 @@ export type DeriveCovenantResult = * 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. + * 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, + manifest: NormalisedManifest, input: { compile: CompileCovenant; + /** What the contract itself declares, for the parameters a deployment writes as values. */ + contractParamTypes?: ContractParamTypesOf; + contractSources: Record; declaredTypes: Record; + /** The mode this protocol states its contracts were built in. */ + includeDebugSymbols: boolean; network: string; + notes?: NormalisationNote[]; + scope: ReferenceScope; utxoType: string; wiring: Record; }, ): Promise { - const declared = asRecord(asRecord(request.manifest.utxo_types)?.[input.utxoType]); + const declared = asRecord(manifest.utxoTypes[input.utxoType]); if (!declared) { return { ok: false, reason: `The manifest declares no utxo type named "${input.utxoType}".` }; } - const sourcePath = asRecord(declared.script)?.source; + const script = asRecord(declared.script); + const sourcePath = script?.source; if (typeof sourcePath !== "string") { return { ok: false, reason: `Utxo type "${input.utxoType}" names no contract source.` }; } - const source = request.contractSources[sourcePath]; + const source = input.contractSources[sourcePath]; if (source === undefined) { return { ok: false, reason: `The source of ${sourcePath} was not supplied.` }; } - const params = resolveCompileParams(request, input.wiring, input.declaredTypes); + // A leaf is part of the taproot tree the address is derived from, so a covenant built without + // one is a different covenant at a different address — and there is nothing for that to fail + // on later, because a hidden node is simply absent. Refused rather than ignored. + if (asArray(script?.extra_leaves).length > 0) { + return { + ok: false, + reason: `Utxo type "${input.utxoType}" declares extra_leaves, which this runtime does not encode yet.`, + }; + } + + // The wiring at the site the covenant is named from, layered over the wiring the utxo type + // declares for itself — the site is more specific, so it wins. + const wiring = { ...asRecord(script?.compile_params), ...input.wiring }; + + // Read before resolving, because what the contract declares is what decides the parameters + // the document writes as values. A contract that will not analyse is reported the way one + // that will not compile is: it is the same failure, found one step earlier. + let declaring: { declares: ContractParamTypes; source: string } | undefined; + + if (input.contractParamTypes) { + try { + declaring = { declares: await input.contractParamTypes(source), source: sourcePath }; + } catch (error) { + return { + ok: false, + reason: `The contract at ${sourcePath} did not compile: ${String(error)}`, + }; + } + } + + const params = resolveCompileParams( + wiring, + input.declaredTypes, + input.scope, + input.notes, + declaring, + ); if (!params.ok) { return params; } + const argumentsJson = JSON.stringify(params.arguments); + // Empty, and said rather than omitted: this slice refuses a utxo type declaring any leaf, so + // an empty list is the whole truth here rather than a value stood in for one. + const extraLeavesJson = "[]"; + try { const compiled = await input.compile({ - argumentsJson: JSON.stringify(params.arguments), + argumentsJson, + extraLeavesJson, + includeDebugSymbols: input.includeDebugSymbols, network: input.network, source, }); @@ -93,7 +193,12 @@ export async function deriveCovenantAddress( return { derivation: { address: compiled.address, + argumentsJson, + extraLeavesJson, + includeDebugSymbols: input.includeDebugSymbols, scriptPubKeyHex: compiled.scriptPubKeyHex, + source, + sourcePath, utxoType: input.utxoType, }, ok: true, @@ -107,18 +212,17 @@ export async function deriveCovenantAddress( } /** - * Whether a covenant UTXO is what the manifest claims: does the script the wallet derived - * match the one the funds are actually locked by? + * 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. + * `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. + * 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. */ diff --git a/packages/tx-manifest/src/covenants/covenantHash.ts b/packages/tx-manifest/src/covenants/covenantHash.ts new file mode 100644 index 0000000..3dd8ecc --- /dev/null +++ b/packages/tx-manifest/src/covenants/covenantHash.ts @@ -0,0 +1,114 @@ +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; + +/** + * What the first round of iteration stands a covenant hash on. + * + * Thirty-two zero bytes, matching the format's reference implementation. It is not a plausible + * hash and is not meant to be — it exists so a contract whose parameters name a hash that does + * not exist yet can still be compiled once, which is what makes the second round possible. + */ +export const COVENANT_HASH_SEED = "0".repeat(64); + +/** + * How many rounds a set of mutually referencing hashes gets before the action fails. + * + * A chain of n covenants each naming the next settles in n rounds, so the bound is a limit on + * how deep a protocol may nest rather than on how hard convergence is. Eight is far past + * anything the published corpus contains and small enough that an unstable manifest fails + * quickly. + */ +export const ITERATION_BOUND = 8; + +/** + * Compiles a contract to the scriptPubKey it locks to, as hex. + * + * Separate from the full compile port because a hash needs no address: what is hashed is the + * locking script, and an address is one rendering of it. The network the script is rendered for + * is the wallet's own and is bound by whoever supplies this, which is why it is absent here. + * + * Everything else a full compile is given is here, because a hash of a contract built any + * differently is the hash of a different contract — and a manifest stores that hash as a + * parameter of the covenant it then locks funds into. + */ +export type CompileScriptPubKey = (input: { + argumentsJson: string; + /** Already-encoded taproot leaf payloads, appended to the tree in declaration order. */ + extraLeavesJson: string; + /** The mode this protocol declares its contracts were built in. */ + includeDebugSymbols: boolean; + source: string; +}) => string; + +export type CovenantHashResult = { hash: string; ok: true } | { ok: false; reason: string }; + +/** + * Compiles a contract with its arguments and returns the hash of its scriptPubKey. + * + * A synchronous port on purpose: the fixed point that settles a set of mutually referencing + * hashes recompiles all of them together, once per round, and an asynchronous step inside that + * loop would make the number of rounds depend on scheduling rather than on the document. + * + * It reports a failure rather than throwing one. A compiler is a wallet's own module across a + * wasm boundary and it can fail — a source that will not compile, a module that will not load — + * and every such failure has to reach the person as a refusal that says which contract. An + * exception escaping the fixed point would instead reject the whole review, which the caller + * reads as the wallet crashing rather than as the wallet declining. + */ +export type HashCovenant = (input: { + argumentsJson: string; + extraLeavesJson: string; + source: string; +}) => CovenantHashResult; + +/** + * Turns a compiler into the covenant-hash function a document's computed fields need. + * + * A covenant's script hash is `SHA256(scriptPubKey)` — the value Simplicity's + * `input_script_hash` jet returns, and what a manifest's `*_COV_HASH` fields hold. It is + * therefore a hash of the *bytes*, and the compiler hands back hex; decoding first rather than + * hashing the text is the difference between the value a contract will check against and a + * plausible-looking wrong one. + * + * **The build mode is bound here rather than asked for per call.** It is a property of the + * document — one protocol declares its contracts were built with debug symbols and another does + * not — so it cannot vary between two covenants of the same manifest, and a caller able to pass + * it per call is a caller able to pass it inconsistently. + * + * What comes back is checked before it is hashed. A compiler that throws, or returns something + * that is not a whole number of bytes of hex, has not produced a scriptPubKey — and hashing the + * text of whatever it did produce would yield thirty-two plausible bytes that no contract will + * ever match. + */ +export function covenantHashFrom( + compile: CompileScriptPubKey, + includeDebugSymbols: boolean, +): HashCovenant { + return ({ argumentsJson, extraLeavesJson, source }) => { + let scriptPubKeyHex: string; + + try { + scriptPubKeyHex = compile({ + argumentsJson, + extraLeavesJson, + includeDebugSymbols, + source, + }); + } catch (error) { + return { ok: false, reason: `the contract did not compile: ${String(error)}` }; + } + + const hex = scriptPubKeyHex.trim(); + + if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) { + return { + ok: false, + reason: + "the compiler did not return a scriptPubKey. A covenant's hash is the hash of its " + + "locking script's bytes, and what came back is not bytes.", + }; + } + + return { hash: bytesToHex(sha256(hexToBytes(hex))), ok: true }; + }; +} diff --git a/packages/tx-manifest/src/covenants/declaredTypes.ts b/packages/tx-manifest/src/covenants/declaredTypes.ts index d99ad78..cce0d2c 100644 --- a/packages/tx-manifest/src/covenants/declaredTypes.ts +++ b/packages/tx-manifest/src/covenants/declaredTypes.ts @@ -1,21 +1,54 @@ import { asRecord } from "../document/json"; +import { + declaredFields, + type NormalisedAction, + type NormalisedManifest, +} from "../document/normalise"; /** * 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. + * Two positions state them and the corpus uses both. An action declares the parameters a request + * supplies — a constructor says `ASSET_B` is an asset id — and the contract's own class declares + * the fields a deployment of it holds, which is where the same protocol's later methods state + * the same thing. They are not two generations: one document writes both, for the two halves of + * the same protocol, because a value supplied when an offer is made is a value read back when it + * is filled. + * + * Reading only the first is why every covenant a live deployment names went unencodable while + * the constructor beside it compiled. It also cannot be fixed by reading only the second: a + * constructor has no deployment to read yet. + * + * **The action wins where both declare a name.** A value comes from the request before it comes + * from the deployment, so the type has to be read in that order or a request would be encoded + * against a declaration it did not come from. + */ +export function declaredParamTypes( + manifest: NormalisedManifest, + action: NormalisedAction, +): Record { + return { + ...typesOf(declaredFields(manifest, action)), + ...typesOf(asRecord(action.node.params) ?? {}), + }; +} + +/** + * The types one map of declarations states. + * + * Read rather than inferred, always. A value's own 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 { +function typesOf(declared: Record): Record { const types: Record = {}; - for (const [name, declared] of Object.entries(asRecord(action.params) ?? {})) { - const type = asRecord(declared)?.type; + for (const [name, entry] of Object.entries(declared)) { + const type = asRecord(entry)?.type; if (typeof type === "string") { types[name] = type; diff --git a/packages/tx-manifest/src/covenants/instance.test.ts b/packages/tx-manifest/src/covenants/instance.test.ts new file mode 100644 index 0000000..8322869 --- /dev/null +++ b/packages/tx-manifest/src/covenants/instance.test.ts @@ -0,0 +1,483 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +import currentVaultletJson from "../__fixtures__/current/vaultlet.manifest.json"; +import mutualJson from "../__fixtures__/mutual.manifest.json"; +import vaultletJson from "../__fixtures__/vaultlet.manifest.json"; +import { findAction, type NormalisedAction, normaliseManifest } from "../document/normalise"; +import { COVENANT_HASH_SEED, covenantHashFrom, ITERATION_BOUND } from "./covenantHash"; +import { createsInstance, resolveCreatedInstance } from "./instance"; + +/** + * A deployment that does not exist yet, worked out from the action that creates it. + * + * Everything else in this package starts from a contract that already exists: the wallet rebuilds + * it, reads what is at its outpoint, and refuses when the two disagree. A constructor has none of + * that. There is no deployment to read its fields from, and half of what it records is compiler + * output — a covenant's script hash, which nothing but a wallet can produce. + * + * The hashes are not worked out in an order, because the document states none: one field's + * covenant may be built from another's hash, in either direction or both. Every unknown starts at + * a seed, all of them are recomputed together, and the round that reproduces its own input is the + * answer. + */ + +const KEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +const ASSET = `a0${"00".repeat(30)}0a`; + +const SOURCES = Object.fromEntries( + ["vault", "reserve", "guard", "left", "right"].map((name) => [ + `./${name}.simf`, + readFileSync(new URL(`../__fixtures__/contracts/${name}.simf`, import.meta.url), "utf8"), + ]), +); + +/** + * A compiler substitute that is a real function of what it is handed. + * + * A stub returning a constant would converge in one round whatever the document said, which is + * the one thing these tests are about. This is deterministic and depends on every argument, so a + * chain of hashes settles exactly as deep as the document makes it and a cycle never settles at + * all — the same behaviour a real compiler produces, without one. + */ +function recordingCompiler(debugSymbols = false) { + const calls: { + argumentsJson: string; + extraLeavesJson: string; + includeDebugSymbols: boolean; + source: string; + }[] = []; + + return { + calls, + hashCovenant: covenantHashFrom( + ({ argumentsJson, extraLeavesJson, includeDebugSymbols, source }) => { + calls.push({ argumentsJson, extraLeavesJson, includeDebugSymbols, source }); + + return `5120${Bun.hash(JSON.stringify([source, argumentsJson, includeDebugSymbols])) + .toString(16) + .padStart(64, "0")}`; + }, + debugSymbols, + ), + }; +} + +const vaultlet = normaliseManifest(vaultletJson as unknown as Record).manifest; +const mutual = normaliseManifest(mutualJson as unknown as Record).manifest; + +function actionNamed(manifest: typeof vaultlet, name: string): NormalisedAction { + const found = findAction(manifest, name); + + if (!found) { + throw new Error(`This fixture declares no action named ${name}.`); + } + + return found; +} + +const OPEN_VAULT = actionNamed(vaultlet, "OpenVault"); +const PARAMS = { + OWNER_PUB_KEY: KEY, + TIMEOUT: "900000", + VAULT_AMOUNT: "50000", + VAULT_ASSET_ID: ASSET, +}; + +function open(params: Record = PARAMS) { + const { calls, hashCovenant } = recordingCompiler(); + + return { + calls, + result: resolveCreatedInstance(OPEN_VAULT, { + contractSources: SOURCES, + hashCovenant, + scope: { params }, + }), + }; +} + +describe("which actions create a deployment", () => { + test("the one carrying the block does, and the one that only spends does not", () => { + expect(createsInstance(OPEN_VAULT)).toBe(true); + expect(createsInstance(actionNamed(vaultlet, "Withdraw"))).toBe(false); + }); +}); + +describe("the deployment a constructor creates", () => { + test("records the values the request supplied, under the names the document gives them", () => { + const { result } = open(); + + expect(result.ok).toBe(true); + + if (result.ok) { + expect(result.instance.fields.OWNER_PUB_KEY).toBe(KEY); + expect(result.instance.fields.VAULT_ASSET_ID).toBe(ASSET); + expect(result.instance.fields.TIMEOUT).toBe("900000"); + } + }); + + test("and the covenant hashes nothing but a compiler could produce", () => { + const { result } = open(); + + expect(result.ok ? result.instance.fields.RESERVE_COV_HASH : "").toHaveLength(64); + expect(result.ok ? result.instance.fields.GUARD_COV_HASH : "").toHaveLength(64); + }); + + /** + * The guard is built from the reserve's hash, and the reserve from nothing but the request. So + * one round produces a guard built on the seed, the next produces one built on the reserve's + * real hash, and the third reproduces its own input — which is what says it settled rather + * than merely stopped. + */ + test("settles a hash that depends on another hash, and says in how many rounds", () => { + const { result } = open(); + + expect(result.ok ? result.instance.rounds : 0).toBe(3); + }); + + test("compiles the guard against the reserve's settled hash, not against the seed", () => { + const { calls, result } = open(); + + if (!result.ok) { + throw new Error(result.reason); + } + + const guard = calls.findLast((call) => call.source === SOURCES["./guard.simf"]); + + expect(JSON.parse(guard?.argumentsJson ?? "{}")).toEqual({ + RESERVE_COV_HASH: { type: "u256", value: `0x${result.instance.fields.RESERVE_COV_HASH}` }, + }); + }); + + /** + * The type beside a tapleaf's value is the only thing that says what it is. The reserve's own + * parameters are a key and a height, and the height is written as decimal at the width the + * document declared it at rather than as hexadecimal of some width nobody stated. + */ + test("builds each covenant at the types the document declares beside the values", () => { + const { calls, result } = open(); + + expect(result.ok).toBe(true); + + const reserve = calls.find((call) => call.source === SOURCES["./reserve.simf"]); + + expect(JSON.parse(reserve?.argumentsJson ?? "{}")).toEqual({ + OWNER_PUB_KEY: { type: "Pubkey", value: `0x${KEY}` }, + TIMEOUT: { type: "u32", value: "900000" }, + }); + }); + + test("is the same deployment whichever generation of the document declared it", () => { + const current = normaliseManifest( + currentVaultletJson as unknown as Record, + ).manifest; + const { hashCovenant } = recordingCompiler(); + const asked = { + contractSources: SOURCES, + hashCovenant, + scope: { params: PARAMS }, + }; + + expect(resolveCreatedInstance(actionNamed(current, "OpenVault"), asked)).toEqual( + resolveCreatedInstance(OPEN_VAULT, asked), + ); + }); +}); + +/** + * The mode a protocol says its contracts were built in reaches the compiler that hashes them. + * + * A covenant hash is the hash of a scriptPubKey, and the flag changes the commitment root the + * script is derived from — so a hash taken in the wrong mode is the hash of a contract nobody + * deployed. It is then compiled into the covenant the action creates, which lands at an address + * nothing can spend. + */ +describe("the mode the document states its contracts were built in", () => { + test("is passed to the compiler that takes each hash", () => { + const { calls, hashCovenant } = recordingCompiler(true); + const result = resolveCreatedInstance(OPEN_VAULT, { + contractSources: SOURCES, + hashCovenant, + scope: { params: PARAMS }, + }); + + expect(result.ok).toBe(true); + expect(calls.length).toBeGreaterThan(0); + expect(calls.every((call) => call.includeDebugSymbols)).toBe(true); + }); + + test("and changes the hashes the deployment records", () => { + const plain = open().result; + const { hashCovenant } = recordingCompiler(true); + const debug = resolveCreatedInstance(OPEN_VAULT, { + contractSources: SOURCES, + hashCovenant, + scope: { params: PARAMS }, + }); + + expect(plain.ok && debug.ok).toBe(true); + + if (!plain.ok || !debug.ok) { + return; + } + + expect(debug.instance.fields.RESERVE_COV_HASH).not.toBe(plain.instance.fields.RESERVE_COV_HASH); + }); + + /** + * Empty, and said rather than omitted. The compiler distinguishes "no leaves" from "not told", + * and a covenant built the second way has a different taproot tree and therefore a different + * script — so the hash of it is the hash of something else. + */ + test("travels beside an explicitly empty leaf list", () => { + const { calls } = open(); + + expect(calls.every((call) => call.extraLeavesJson === "[]")).toBe(true); + }); +}); + +/** + * A compiler is a wallet's own module across a wasm boundary, and it can fail. Every such failure + * has to arrive as a refusal naming the field, not as an exception escaping the fixed point — + * which a caller reads as the wallet crashing rather than as the wallet declining. + */ +describe("when the compiler cannot produce a hash", () => { + function computing(compile: () => string) { + return resolveCreatedInstance(OPEN_VAULT, { + contractSources: SOURCES, + hashCovenant: covenantHashFrom(compile, false), + scope: { params: PARAMS }, + }); + } + + test("a compiler that throws refuses, naming the field and carrying the reason", () => { + const result = computing(() => { + throw new Error("wasm module not loaded"); + }); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("RESERVE_COV_HASH"); + expect(result.ok ? "" : result.reason).toContain("wasm module not loaded"); + }); + + /** + * A hash is taken over the script's bytes. Hashing the text of something that is not hex would + * produce thirty-two plausible bytes that no contract will ever match — a wrong answer rather + * than an error, which is the failure this check exists to prevent. + */ + test("a compiler returning something that is not a script refuses rather than hashing it", () => { + for (const answer of ["", "not hex", "5120abc"]) { + const result = computing(() => answer); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("not bytes"); + } + }); +}); + +/** + * One computed field naming another through the deployment namespace rather than a bare name. + * + * The corpus writes the reading both ways, and both mean the fields of the deployment this action + * is in the middle of writing. Resolving the explicit spelling against whatever deployment came + * before would, for a constructor, resolve it against nothing at all — so the round would refuse + * for want of a hash it had just worked out. + */ +/** The same pair of computed fields, with only how the second names the first varying. */ +const dependant = (spelling: string): NormalisedAction => ({ + isConstructor: true, + name: "Open", + node: { + create_instance: { + fields: { + RESERVE_COV_HASH: { + params: { + OWNER_PUB_KEY: { type: "pubkey", value: "OWNER_PUB_KEY" }, + TIMEOUT: { type: "u32", value: "TIMEOUT" }, + }, + simf: "./reserve.simf", + type: "tapleaf", + }, + GUARD_COV_HASH: { + params: { RESERVE_COV_HASH: { type: "bytes32", value: spelling } }, + simf: "./guard.simf", + type: "tapleaf", + }, + }, + }, + }, +}); + +describe("a covenant hash that names another through the deployment", () => { + function settle(spelling: string) { + const { calls, hashCovenant } = recordingCompiler(); + + return { + calls, + result: resolveCreatedInstance(dependant(spelling), { + contractSources: SOURCES, + hashCovenant, + scope: { params: PARAMS }, + }), + }; + } + + test("settles through an explicit instance reference", () => { + const { result } = settle("instance.RESERVE_COV_HASH"); + + expect(result.ok).toBe(true); + expect(result.ok ? result.instance.rounds : 0).toBe(3); + }); + + test("and compiles the dependant against the settled hash, not the seed", () => { + const { calls, result } = settle("instance.RESERVE_COV_HASH"); + + if (!result.ok) { + throw new Error(result.reason); + } + + const guard = calls.findLast((call) => call.source === SOURCES["./guard.simf"]); + + expect(JSON.parse(guard?.argumentsJson ?? "{}")).toEqual({ + RESERVE_COV_HASH: { type: "u256", value: `0x${result.instance.fields.RESERVE_COV_HASH}` }, + }); + expect(result.instance.fields.RESERVE_COV_HASH).not.toBe(COVENANT_HASH_SEED); + }); + + /** The deprecated spelling of the same namespace is the same lookup and must settle alike. */ + test("identically under the deprecated compile_params. spelling", () => { + expect(settle("compile_params.RESERVE_COV_HASH").result).toEqual( + settle("instance.RESERVE_COV_HASH").result, + ); + }); + + /** And a bare name still means what it always did: the parameters first. */ + test("and identically to the bare name the same reading is also written as", () => { + expect(settle("RESERVE_COV_HASH").result).toEqual(settle("instance.RESERVE_COV_HASH").result); + }); + + /** + * An earlier deployment stays underneath. A name neither round produced still resolves off the + * file the request supplied, so exposing the new fields adds a namespace rather than replacing + * one. + */ + test("without hiding the deployment the request supplied", () => { + const { hashCovenant } = recordingCompiler(); + const result = resolveCreatedInstance( + { + isConstructor: true, + name: "Open", + node: { + create_instance: { + fields: { + GUARD_COV_HASH: { + params: { RESERVE_COV_HASH: { type: "bytes32", value: "instance.OLD_HASH" } }, + simf: "./guard.simf", + type: "tapleaf", + }, + }, + }, + }, + }, + { + contractSources: SOURCES, + hashCovenant, + scope: { instance: { OLD_HASH: "ab".repeat(32) }, params: {} }, + }, + ); + + expect(result.ok).toBe(true); + expect(result.ok ? result.instance.rounds : 0).toBe(2); + }); +}); + +describe("what it refuses rather than recording a value nobody chose", () => { + test("a field naming something the request did not supply", () => { + const { result } = open({ OWNER_PUB_KEY: KEY, TIMEOUT: "900000" }); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("VAULT_ASSET_ID"); + }); + + test("a contract whose source was not supplied", () => { + const { hashCovenant } = recordingCompiler(); + const result = resolveCreatedInstance(OPEN_VAULT, { + contractSources: { "./reserve.simf": SOURCES["./reserve.simf"] ?? "" }, + hashCovenant, + scope: { params: PARAMS }, + }); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("./guard.simf"); + }); + + /** + * Two covenants each built from the other's hash. There is no order in which the pair can be + * compiled and no value for the iteration to settle on, so the bound is reached. + * + * Returning the last round's values instead would be an address derived from something that + * never agreed with itself. The wallet would compare it against the chain and refuse anyway, + * having spent the work — or, for a covenant it was creating, pay to it. + */ + test("a pair of hashes that never settle, saying which fields and after how many rounds", () => { + const { hashCovenant } = recordingCompiler(); + const result = resolveCreatedInstance(actionNamed(mutual, "Knot"), { + contractSources: SOURCES, + hashCovenant, + scope: { params: {} }, + }); + + expect(result.ok).toBe(false); + + const reason = result.ok ? "" : result.reason; + + expect(reason).toContain("LEFT_COV_HASH, RIGHT_COV_HASH"); + expect(reason).toContain(`${ITERATION_BOUND} rounds`); + }); + + test("a field computed by something this runtime does not implement", () => { + const { hashCovenant } = recordingCompiler(); + const result = resolveCreatedInstance( + { + isConstructor: true, + name: "Odd", + node: { create_instance: { fields: { X: { compute: "contract", simf: "./a.simf" } } } }, + }, + { contractSources: SOURCES, hashCovenant, scope: { params: {} } }, + ); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("contract"); + }); + + /** + * A leaf is part of the taproot tree the scriptPubKey is derived from, so a hash taken without + * one is the hash of a different covenant — and a hidden node has nothing to fail on later. + * Refused rather than ignored, which is the difference between an error and a wrong answer. + */ + test("a tapleaf carrying extra leaves this runtime cannot encode", () => { + const { hashCovenant } = recordingCompiler(); + const result = resolveCreatedInstance( + { + isConstructor: true, + name: "Leafy", + node: { + create_instance: { + fields: { + X: { + compute: "tapleaf", + extra_leaves: [{ payload: ["0x00"], type: "tapdata" }], + simf: "./reserve.simf", + }, + }, + }, + }, + }, + { contractSources: SOURCES, hashCovenant, scope: { params: {} } }, + ); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("extra_leaves"); + }); +}); diff --git a/packages/tx-manifest/src/covenants/instance.ts b/packages/tx-manifest/src/covenants/instance.ts new file mode 100644 index 0000000..4666bfc --- /dev/null +++ b/packages/tx-manifest/src/covenants/instance.ts @@ -0,0 +1,354 @@ +import { asArray, asRecord } from "../document/json"; +import type { NormalisationNote, NormalisedAction } from "../document/normalise"; +import { type ReferenceScope, resolveReference } from "../document/references"; +import { resolveCompileParams } from "./compileParams"; +import { COVENANT_HASH_SEED, type HashCovenant, ITERATION_BOUND } from "./covenantHash"; + +/** + * The deployment an action creates, once its field values are worked out. + * + * `rounds` is how many passes the covenant hashes took to settle, and is zero when the action's + * fields hold no computed value at all. + */ +export type CreatedInstance = { fields: Record; rounds: number }; + +export type CreateInstanceResult = + | { instance: CreatedInstance; ok: true } + | { ok: false; reason: string }; + +/** + * Whether this action deploys a new contract instance. + * + * Two generations spell it differently and one of them spells it twice. The older writes a + * boolean flag beside a block; the newer dropped the flag on the ground that an action carrying + * the block *is* the constructor, and there is nothing a flag could add that the block does not + * already say. So the block alone is read — and a flag with no block is a document declaring a + * constructor that constructs nothing, which is a fault rather than a generation. + */ +export function createsInstance(action: NormalisedAction): boolean { + return asRecord(action.node.create_instance) !== undefined; +} + +/** + * What a field's value is computed by, whichever word this document uses for it. + * + * Three spellings name the same thing across the corpus — `compute`, `type` and `lang` — and + * none of them is a version marker, so a runtime that reads one reads part of the corpus and + * refuses the rest for a reason that is not about the protocol. + */ +function computeKind(node: Record): string | undefined { + for (const key of ["compute", "type", "lang"]) { + const value = node[key]; + + if (typeof value === "string") { + return value; + } + } + + return undefined; +} + +/** + * Works out the field values of the instance an action deploys. + * + * A field is either a value the request or an earlier deployment already holds — reached by + * reference — or a covenant's script hash, which has to be compiled to be known. The second kind + * may name other fields of the same new instance, including in a cycle, so it is resolved the + * way the format's reference implementation resolves computed parameters: every unknown starts + * at a seed, all of them are recomputed together, and the round that reproduces its own input is + * the answer. + * + * A chain that is not circular converges as fast as its depth, so this covers the ordinary case + * without a separate topological pass — the ordering falls out of the iteration. + * + * **Exceeding the bound refuses.** The alternative is returning the last round's values, which + * are an address derived from something that never agreed with itself; the wallet would then + * compare that address against the chain and refuse anyway, having spent the work, or worse, pay + * to it. + * + * **A literal stays a literal.** Some fields hold `"0"` or `"2"` rather than a reference, and a + * manifest saying a field is two means two. Resolution is tried first, and only a text that + * could not have named anything — no `$`, no dot — falls through to being itself. One that named + * something absent is a document asking for a value nobody supplied, and reading it as the + * string `"$params.X"` would hide that. + */ +export function resolveCreatedInstance( + action: NormalisedAction, + input: { + contractSources: Record; + hashCovenant: HashCovenant; + notes?: NormalisationNote[]; + scope: ReferenceScope; + }, +): CreateInstanceResult { + const block = asRecord(action.node.create_instance); + + if (!block) { + return { ok: false, reason: "This action does not create an instance." }; + } + + const declared = asRecord(block.fields); + + if (!declared) { + return { ok: false, reason: "The action creates an instance and declares no fields for it." }; + } + + const direct: Record = {}; + const computed: ComputedField[] = []; + + for (const [name, value] of Object.entries(declared)) { + if (typeof value === "string") { + const resolved = resolveFieldReference(name, value, input.scope, input.notes); + + if (!resolved.ok) { + return resolved; + } + + direct[name] = resolved.value; + + continue; + } + + const node = asRecord(value); + + if (!node) { + return { ok: false, reason: `Field ${name} is neither a reference nor a computed value.` }; + } + + const kind = computeKind(node); + + if (kind !== "tapleaf") { + return { + ok: false, + reason: + `Field ${name} is computed by "${String(kind)}", which this runtime does not ` + + "implement. Honouring it means executing a contract while building the " + + "transaction, not merely compiling one.", + }; + } + + // A leaf is part of the taproot tree the scriptPubKey is derived from, so a hash taken + // without one is the hash of a different covenant — and a hidden node has nothing to fail + // on, so the wrong answer would be a well-formed one. Refused rather than ignored. + if (asArray(node.extra_leaves).length > 0) { + return { + ok: false, + reason: `Field ${name} carries extra_leaves, which this runtime does not encode yet.`, + }; + } + + const simf = node.simf; + + if (typeof simf !== "string") { + return { ok: false, reason: `Field ${name} names no contract to compute from.` }; + } + + const source = input.contractSources[simf]; + + if (source === undefined) { + return { ok: false, reason: `The source of ${simf} was not supplied.` }; + } + + computed.push({ name, node, source }); + } + + if (computed.length === 0) { + return { instance: { fields: direct, rounds: 0 }, ok: true }; + } + + const declaredTypes = fieldTypes(declared); + let values: Record = Object.fromEntries( + computed.map(({ name }) => [name, COVENANT_HASH_SEED]), + ); + + for (let round = 1; round <= ITERATION_BOUND; round += 1) { + const next: Record = {}; + + for (const { name, node, source } of computed) { + // A tapleaf's own wiring names fields of the instance being created, so the scope a + // covenant compiles against is this instance rather than only the request's parameters. + // + // **Both namespaces, because the document writes the reading both ways.** A bare name + // is looked up among the parameters first, so the new fields go there; an explicit + // `instance.OTHER_HASH` — or the deprecated `compile_params.OTHER_HASH` spelling of it — + // is looked up in the deployment, and the deployment it means is this one. Offering + // only the first would resolve that spelling against whatever came before, which for a + // constructor is nothing at all: the round would refuse for want of a value it had just + // worked out. The request's own parameters and any earlier deployment stay underneath, + // so a name neither of these rounds produced still resolves the way it always did. + const withNewFields = { ...direct, ...values }; + const scope: ReferenceScope = { + ...input.scope, + instance: { ...input.scope.instance, ...withNewFields }, + params: { ...input.scope.params, ...withNewFields }, + }; + const wiring = tapleafWiring(node); + + if (!wiring.ok) { + return { ok: false, reason: `Computing ${name}: ${wiring.reason}` }; + } + + const resolved = resolveCompileParams( + wiring.wiring, + declaredTypes, + scope, + input.notes, + undefined, + wiring.declaredAtUse, + ); + + if (!resolved.ok) { + return { ok: false, reason: `Computing ${name}: ${resolved.reason}` }; + } + + // An empty leaf list, stated rather than omitted: a tapleaf declaring any leaf is + // refused above, so this is the whole truth here rather than a value stood in for one. + const hashed = input.hashCovenant({ + argumentsJson: JSON.stringify(resolved.arguments), + extraLeavesJson: "[]", + source, + }); + + if (!hashed.ok) { + return { ok: false, reason: `Computing ${name}: ${hashed.reason}` }; + } + + next[name] = hashed.hash; + } + + if (computed.every(({ name }) => next[name] === values[name])) { + return { instance: { fields: { ...direct, ...next }, rounds: round }, ok: true }; + } + + values = next; + } + + return { + ok: false, + reason: + `The covenant hashes this deployment's fields compute never settle: ${computed + .map(({ name }) => name) + .join(", ")} still change after ${ITERATION_BOUND} rounds. ` + + "A deployment recorded from values that never agreed with themselves would locate " + + "funds at an address nobody checked.", + }; +} + +type ComputedField = { name: string; node: Record; source: string }; + +type TapleafWiring = { + /** The type the document wrote beside each value, keyed by the contract's parameter name. */ + declaredAtUse: Record; + ok: true; + wiring: Record; +}; + +/** + * The wiring a tapleaf's parameters describe, in the shape the compile-parameter resolver reads. + * + * The two positions spell the same thing differently, and this is where they meet. A covenant's + * own wiring map holds references — `{"PUB_KEY": "MAKER_PUB_KEY"}`. A tapleaf inside a + * deployment's fields holds objects — `{"PUB_KEY": {"type": "pubkey", "value": "MAKER_PUB_KEY"}}` + * — because the declaration carries the type at the point of use rather than from a parameter + * declared elsewhere. The reference is the `value`, and the `type` beside it is what the encoder + * needs. + * + * So the type is carried out beside the wiring rather than dropped. Most of these values are + * names and take their type from what they name; the rest are written outright — `"1"`, `"true"` + * — and the only thing that says what width or kind those are is the word the document wrote + * next to them. + */ +function tapleafWiring( + node: Record, +): TapleafWiring | { ok: false; reason: string } { + const declared = asRecord(node.params); + + if (!declared) { + return { declaredAtUse: {}, ok: true, wiring: {} }; + } + + const declaredAtUse: Record = {}; + const wiring: Record = {}; + + for (const [name, spec] of Object.entries(declared)) { + if (typeof spec === "string") { + wiring[name] = spec; + + continue; + } + + const value = asRecord(spec)?.value; + + if (typeof value !== "string") { + return { ok: false, reason: `Parameter ${name} names no value to compile with.` }; + } + + const type = asRecord(spec)?.type; + + if (typeof type === "string") { + declaredAtUse[name] = type; + } + + wiring[name] = value; + } + + return { declaredAtUse, ok: true, wiring }; +} + +/** Reads one field written as a string: a reference where it names something, else itself. */ +function resolveFieldReference( + name: string, + text: string, + scope: ReferenceScope, + notes?: NormalisationNote[], +): { ok: false; reason: string } | { ok: true; value: string } { + const found = resolveReference(text, "compileParam", scope, notes); + + if (!found.ok) { + return text.startsWith("$") || text.includes(".") + ? { ok: false, reason: `Field ${name}: ${found.reason}` } + : { ok: true, value: text }; + } + + if (typeof found.value !== "string") { + return { + ok: false, + reason: `Field ${name} resolves to a value this runtime cannot record as a field yet.`, + }; + } + + return { ok: true, value: found.value }; +} + +/** + * The declared types of the fields a tapleaf's wiring can name. + * + * A computed field is a covenant's script hash and is thirty-two bytes by construction, so it + * needs no declaration. Every other field takes the type its own tapleaf parameters declare, + * which is where the corpus states them. + */ +function fieldTypes(declared: Record): Record { + const types: Record = {}; + + for (const [name, value] of Object.entries(declared)) { + const node = asRecord(value); + + if (!node || computeKind(node) !== "tapleaf") { + continue; + } + + types[name] = "bytes32"; + + for (const [param, spec] of Object.entries(asRecord(node.params) ?? {})) { + const type = asRecord(spec)?.type; + const target = asRecord(spec)?.value; + + if (typeof type === "string" && typeof target === "string") { + types[target] = type; + types[param] ??= type; + } + } + } + + return types; +} diff --git a/packages/tx-manifest/src/covenants/paramEncoding.test.ts b/packages/tx-manifest/src/covenants/paramEncoding.test.ts new file mode 100644 index 0000000..6468764 --- /dev/null +++ b/packages/tx-manifest/src/covenants/paramEncoding.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from "bun:test"; + +import { encodeCompileParam, encodesDeclaredType, unencodableReason } from "./paramEncoding"; + +/** + * What a value is encoded as comes from the type it was declared at, and from nothing else. + * + * Every case below could be got wrong in a way that compiles: the compiler accepts almost + * anything shaped like a value and hands back a perfectly valid address for the wrong contract. + * That is why the list of types is closed and why a value's own appearance decides nothing. + */ + +const KEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +const encode = (type: string, value: string) => encodeCompileParam(type, value, "P", "a value"); + +describe("the fixed-width types", () => { + test("a public key is written as hex of exactly its width", () => { + expect(encode("pubkey", KEY)).toEqual({ + encoded: { type: "Pubkey", value: `0x${KEY}` }, + ok: true, + }); + }); + + test("a covenant hash is thirty-two bytes and is not turned round", () => { + const hash = `ab${"00".repeat(30)}cd`; + + expect(encode("bytes32", hash)).toMatchObject({ encoded: { value: `0x${hash}` } }); + }); + + /** + * An asset id is stated one way and committed in the reverse of it. A covenant compares what + * a jet reports — the committed form — against its baked-in parameter, so the turn belongs + * here, at the one place a stated id becomes bytes a contract is built with. Passed through, + * it compiles, derives an address, and is wrong. + */ + test("an asset id is turned round on the way in, where a hash of the same width is not", () => { + const stated = `a0${"00".repeat(30)}0a`; + const committed = `0a${"00".repeat(30)}a0`; + + expect(encode("liquid.asset_id", stated)).toMatchObject({ + encoded: { type: "u256", value: `0x${committed}` }, + }); + expect(encode("bytes32", stated)).toMatchObject({ encoded: { value: `0x${stated}` } }); + }); + + test("a value of the wrong width refuses, saying how wide it was", () => { + const found = encode("pubkey", "79be667e"); + + expect(found.ok).toBe(false); + expect(found.ok ? "" : found.reason).toContain("8 hexadecimal characters"); + }); + + test("an already-prefixed value is accepted rather than doubled", () => { + expect(encode("pubkey", `0x${KEY}`)).toMatchObject({ encoded: { value: `0x${KEY}` } }); + }); +}); + +describe("the integer widths", () => { + /** + * A number is hex-prefixed nowhere, and that is a rule rather than a convention. The compiler + * reads `0x…` as a hexadecimal literal of exactly the type's width, so a decimal amount that + * happens to be sixteen characters long would be a legal `u64` hexadecimal literal standing + * for an entirely different number. It compiles, and it says nothing. + */ + test("are written as decimal, never as hex", () => { + expect(encode("u64", "1000000000000000")).toEqual({ + encoded: { type: "u64", value: "1000000000000000" }, + ok: true, + }); + }); + + test("refuse a value too large for the width it was declared at, naming the range", () => { + const found = encode("u32", "4294967296"); + + expect(found.ok).toBe(false); + expect(found.ok ? "" : found.reason).toContain("0 to 4294967295"); + }); + + test("accept the largest value the width holds", () => { + expect(encode("u8", "255")).toMatchObject({ encoded: { value: "255" } }); + expect(encode("u8", "256").ok).toBe(false); + }); + + test("read a leading zero as the number it writes", () => { + expect(encode("u16", "0005")).toMatchObject({ encoded: { value: "5" } }); + }); + + test("refuse anything that is not a run of digits", () => { + expect(encode("u64", "0x10").ok).toBe(false); + expect(encode("u64", "-1").ok).toBe(false); + }); +}); + +describe("flags", () => { + test("read the two words the compiler reads and the two bits the corpus writes", () => { + for (const [written, expected] of [ + ["true", "true"], + ["false", "false"], + ["1", "true"], + ["0", "false"], + ] as const) { + expect(encode("bool", written)).toMatchObject({ encoded: { type: "bool", value: expected } }); + } + }); + + /** A value quietly read as `false` is a different covenant, and it is one that compiles. */ + test("refuse anything else rather than treating it as one of them", () => { + expect(encode("bool", "yes").ok).toBe(false); + expect(encode("bool", "").ok).toBe(false); + }); +}); + +describe("the types with no encoding", () => { + test("are refused by name rather than passed through", () => { + for (const type of ["address", "bytes", "string"]) { + expect(encodesDeclaredType(type)).toBe(false); + expect(encode(type, "anything").ok).toBe(false); + } + }); + + /** + * A type the format names and this runtime has not mapped is told apart from one nobody has + * heard of. They call for different things: the first waits on a decision about what it means, + * and the second is usually a typo in the document. + */ + test("say why, where the reason is a decision nobody has made", () => { + expect(unencodableReason("bytes")).toContain("carries no width"); + expect(unencodableReason("u128")).toContain("does not encode yet"); + expect(unencodableReason(undefined)).toContain("unstated type"); + }); +}); diff --git a/packages/tx-manifest/src/covenants/paramEncoding.ts b/packages/tx-manifest/src/covenants/paramEncoding.ts new file mode 100644 index 0000000..6c1f7dc --- /dev/null +++ b/packages/tx-manifest/src/covenants/paramEncoding.ts @@ -0,0 +1,249 @@ +/** + * What a manifest's declared parameter type encodes to, in the compiler's own argument shape. + * + * The list is closed, and it stays closed. A type nobody has mapped is refused by name rather + * than passed through, because the compiler will accept almost anything shaped like a value and + * hand back a perfectly valid address for the wrong contract. Nothing here is derived from a + * type's name or from what a manifest says it means. + * + * Two facts from SimplicityHL decide the whole table. + * + * A value is parsed as an expression and const-analysed against the declared type. So `0x…` is a + * hexadecimal literal and a run of digits is a decimal one, and they are different literals + * rather than two spellings of one. + * + * A hexadecimal literal must be exactly the type's width — a string whose length is not + * `byte_width * 2` is rejected. That is what makes hex safe for the fixed-width types and unsafe + * for the integers: `0x1000000000000000` is a legal `u64` and is not the number + * `1000000000000000`. + */ + +/** How a value of a declared type is written for the compiler. */ +type Encoding = "boolean" | "decimal" | "hex" | "reversedHex"; + +type ParamType = { + /** How many bytes the value occupies, for the types that have a fixed width. */ + bytes?: number; + /** The type name the compiler parses, which is not always the name the manifest uses. */ + compiler: string; + encoding: Encoding; + /** How wide the value may be, for the types bounded by a range rather than a width. */ + max?: bigint; + /** What a value of this type should look like, for a refusal that can be acted on. */ + shape: string; +}; + +const PARAM_TYPES: Record = { + /** A flag a contract branches on. One word here is a different covenant at a different address. */ + bool: { compiler: "bool", encoding: "boolean", shape: "true or false" }, + /** + * A covenant script hash is thirty-two bytes. `u256` rather than one of the compiler's + * aliases because they are the same type: `Pubkey`, `Message`, `Scalar` and the rest all + * resolve to `U256`, so the encoded value does not depend on which name a contract happens + * to use for it. + */ + bytes32: { + bytes: 32, + compiler: "u256", + encoding: "hex", + shape: "32 bytes as 64 hexadecimal characters", + }, + /** + * An asset id is thirty-two bytes and is **written in reverse of how it is committed**. + * + * This is the one entry in the table where passing the value through would compile, derive an + * address, and be wrong. A covenant reads an asset with `jet::input_amount` or + * `jet::output_amount` and compares the bits it gets against its baked-in parameter, and what + * those jets report is the asset as the transaction commits it — which is the reverse of the + * form everything states an id in. + * + * Everything on this side of the wallet states an id the way a person reads it, so the turn + * belongs here, at the one place a stated id becomes committed bytes. + */ + "liquid.asset_id": { + bytes: 32, + compiler: "u256", + encoding: "reversedHex", + shape: "an asset id: 32 bytes as 64 hexadecimal characters", + }, + pubkey: { + bytes: 32, + compiler: "Pubkey", + encoding: "hex", + shape: "an x-only public key: 32 bytes as 64 hexadecimal characters, no prefix and no address", + }, + /** + * The integer widths, written as decimal. + * + * A number is hex-prefixed nowhere here, and that is the point rather than a convention. The + * compiler reads `0x…` as a hexadecimal literal of exactly the type's width, so a decimal + * amount that happens to be sixteen characters long is a legal `u64` hexadecimal literal + * standing for an entirely different number. It compiles, derives an address, and says + * nothing. + * + * The bound is the type's own, so a value too large for the width it was declared at is + * refused here naming both, rather than inside the compiler's parser naming a column. + */ + u8: { compiler: "u8", encoding: "decimal", max: 255n, shape: "a whole number from 0 to 255" }, + u16: { + compiler: "u16", + encoding: "decimal", + max: 65_535n, + shape: "a whole number from 0 to 65535", + }, + u32: { + compiler: "u32", + encoding: "decimal", + max: 4_294_967_295n, + shape: "a whole number from 0 to 4294967295", + }, + u64: { + compiler: "u64", + encoding: "decimal", + max: 18_446_744_073_709_551_615n, + shape: "a whole number from 0 to 18446744073709551615", + }, +}; + +/** + * Why a declared type this runtime knows of still has no encoding. + * + * Kept apart from the table above because these are not gaps to be filled by pattern: each one + * names something the format has not said, and a refusal that says which is the difference + * between a person fixing a request and a person guessing at one. + */ +const UNENCODABLE: Record = { + address: + "an address is a rendering of a locking script rather than a value, and neither the " + + "format nor the compiler says which of the two a contract is meant to be built with", + bytes: + "a value of this type carries no width, and the compiler needs an exact one — the same " + + "bytes at two widths are two different covenants", + string: + "the compiler has no string type, so there is nothing to encode a run of text into " + + "without choosing an encoding the format never states", +}; + +export type EncodedParam = { type: string; value: string }; + +export type EncodeParamResult = { encoded: EncodedParam; ok: true } | { ok: false; reason: string }; + +/** Whether this runtime can build a contract argument out of a value of that declared type. */ +export function encodesDeclaredType(declaredType: string | undefined): boolean { + return declaredType !== undefined && declaredType in PARAM_TYPES; +} + +/** + * Why a declared type cannot be encoded, in words the person filling the request can act on. + * + * A type the format names and this runtime has not mapped is told apart from one nobody has + * heard of, because they call for different things: the first waits on a decision about what it + * means, and the second is usually a typo in the document. + */ +export function unencodableReason(declaredType: string | undefined): string { + if (declaredType === undefined) { + return "is declared as an unstated type, which this runtime does not encode"; + } + + const known = UNENCODABLE[declaredType]; + + return known === undefined + ? `is declared as ${declaredType}, which this runtime does not encode yet` + : `is declared as ${declaredType}, which this runtime does not encode: ${known}`; +} + +/** + * One compile parameter, in the compiler's argument shape. + * + * The refusals say which compile parameter, which reference, what arrived and what was needed, + * because all four are things the person filling the request can act on and none of them + * survives into the compiler's own message. + */ +export function encodeCompileParam( + declaredType: string, + value: string, + name: string, + reference: string, +): EncodeParamResult { + const declared = PARAM_TYPES[declaredType]; + + if (!declared) { + return { ok: false, reason: `${reference} ${unencodableReason(declaredType)}.` }; + } + + const wrong = (found: string): EncodeParamResult => ({ + ok: false, + reason: + `${name} is wired to ${reference}, declared ${declaredType}, which is ` + + `${declared.shape}. Got ${found}.`, + }); + + switch (declared.encoding) { + case "boolean": { + // The compiler reads `true` and `false` and nothing else of this type. The corpus also + // writes the two as 1 and 0, which are the same two values written as a bit. + const literal = BOOLEANS[value.trim().toLowerCase()]; + + return literal === undefined + ? wrong(quoted(value)) + : { encoded: { type: declared.compiler, value: literal }, ok: true }; + } + + case "decimal": { + const digits = value.trim(); + + if (!/^\d+$/.test(digits)) { + return wrong(quoted(value)); + } + + // A leading zero is dropped rather than refused: a document writing `0005` means five, + // and the compiler reads the digits as a number either way. + const number = BigInt(digits); + + return number > (declared.max ?? 0n) + ? wrong(`${number}`) + : { encoded: { type: declared.compiler, value: number.toString(10) }, ok: true }; + } + + case "hex": + case "reversedHex": { + const digits = withoutHexPrefix(value.trim()); + const width = declared.bytes ?? 0; + + if (digits.length !== width * 2 || !/^[0-9a-fA-F]+$/.test(digits)) { + return wrong( + /^[0-9a-fA-F]*$/.test(digits) ? `${digits.length} hexadecimal characters` : quoted(value), + ); + } + + const ordered = declared.encoding === "reversedHex" ? reverseBytes(digits) : digits; + + return { encoded: { type: declared.compiler, value: `0x${ordered}` }, ok: true }; + } + } +} + +/** + * The two words the compiler reads, and the two bits the corpus writes for them. + * + * Anything else is refused rather than treated as one of them. A value quietly read as `false` + * is a different covenant, and it is one that compiles. + */ +const BOOLEANS: Record = { + "0": "false", + "1": "true", + false: "false", + true: "true", +}; + +function withoutHexPrefix(value: string): string { + return value.startsWith("0x") || value.startsWith("0X") ? value.slice(2) : value; +} + +function reverseBytes(hex: string): string { + return (hex.match(/../g) ?? []).toReversed().join(""); +} + +function quoted(value: string): string { + return `"${value.length > 24 ? `${value.slice(0, 24)}…` : value}"`; +} diff --git a/packages/tx-manifest/src/covenants/valueWiredCovenant.test.ts b/packages/tx-manifest/src/covenants/valueWiredCovenant.test.ts new file mode 100644 index 0000000..babf5d2 --- /dev/null +++ b/packages/tx-manifest/src/covenants/valueWiredCovenant.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +import manifestJson from "../__fixtures__/vaultlet.manifest.json"; +import { findAction, normaliseManifest } from "../document/normalise"; +import { deriveCovenantAddress } from "./covenant"; +import { declaredParamTypes } from "./declaredTypes"; + +/** + * A covenant some of whose parameters the document writes as bare values. + * + * A deployment mostly wires a compile parameter to a name, and the name carries the format's own + * declared type. `SLOT_COUNT: "2"` and `WITH_BURN: "false"` declare nothing — that position is a + * deployment's wiring rather than a list of parameters. So the value arrives with no type, and + * `"2"` is not a number until something says at what width. A width is part of the address. + * + * SimplicityHL states a parameter's type nowhere in its source either: `param::NAME` takes the + * type of the position it is written in, worked out by the type checker. The compiler is the only + * thing that can say, which is why it is asked — and why a document that writes a value where + * nothing can be asked is refused rather than built. + * + * **What this proves and what it does not.** It proves this runtime turns the document into + * exactly the argument string below. That the string compiles to any particular address is a + * question for the wallet's compiler, which this package deliberately does not hold. + */ + +const KEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +const ASSET_STATED = `a0${"00".repeat(30)}0a`; +const ASSET_COMMITTED = `0a${"00".repeat(30)}a0`; +const RESERVE_HASH = "cc".repeat(32); + +/** This deployment's field values, as `Withdraw` reads them back off it. */ +const INSTANCE = { + OWNER_PUB_KEY: KEY, + RESERVE_COV_HASH: RESERVE_HASH, + TIMEOUT: "900000", + VAULT_AMOUNT: "50000", + VAULT_ASSET_ID: ASSET_STATED, +}; + +/** + * The compiler's answer for the vault contract, pinned so this file needs no compiler. + * + * Not a reading of the contract text, and it could not be one — a reader that recovered these + * from the source would be reimplementing the type checker, and the failure mode of getting one + * wrong is a value encoded at a width nobody stated. + */ +const DECLARED = { SLOT_COUNT: "u8", WITH_BURN: "bool" }; + +const SOURCE = readFileSync( + new URL("../__fixtures__/contracts/vault.simf", import.meta.url), + "utf8", +); + +const { manifest } = normaliseManifest(manifestJson as unknown as Record); + +async function derive(input: { contractParamTypes?: () => Record } = {}) { + const action = findAction(manifest, "Withdraw"); + + if (!action) { + throw new Error("This fixture declares no action named Withdraw."); + } + + const calls: { + argumentsJson: string; + extraLeavesJson: string; + includeDebugSymbols: boolean; + source: string; + }[] = []; + const result = await deriveCovenantAddress(manifest, { + compile: (asked) => { + calls.push({ + argumentsJson: asked.argumentsJson, + extraLeavesJson: asked.extraLeavesJson, + includeDebugSymbols: asked.includeDebugSymbols, + source: asked.source, + }); + + return { address: "ex1p_recorded", scriptPubKeyHex: `5120${"00".repeat(32)}` }; + }, + ...input, + contractSources: { "./vault.simf": SOURCE }, + declaredTypes: declaredParamTypes(manifest, action), + includeDebugSymbols: false, + network: "liquid", + scope: { instance: INSTANCE, params: {} }, + utxoType: "vault", + wiring: {}, + }); + + return { calls, result }; +} + +describe("the vault covenant", () => { + test("is built with a count and a flag typed by its contract, not by how they look", async () => { + const { calls, result } = await derive({ contractParamTypes: () => DECLARED }); + + expect(result.ok).toBe(true); + expect(calls[0]?.argumentsJson).toBe( + `{"OWNER_PUB_KEY":{"type":"Pubkey","value":"0x${KEY}"},` + + `"VAULT_ASSET_ID":{"type":"u256","value":"0x${ASSET_COMMITTED}"},` + + `"RESERVE_COV_HASH":{"type":"u256","value":"0x${RESERVE_HASH}"},` + + '"SLOT_COUNT":{"type":"u8","value":"2"},' + + '"WITH_BURN":{"type":"bool","value":"false"}}', + ); + }); + + test("and from the contract source the request supplied, not one of its own", async () => { + const { calls } = await derive({ contractParamTypes: () => DECLARED }); + + expect(calls[0]?.source).toBe(SOURCE); + }); + + /** + * Every value here comes off the deployment. Until a class's own field declarations were read, + * there was no type to encode any of them against and therefore no address to compare a live + * deployment's funds to. + */ + test("reads every other parameter off the deployment, at the type the class declares", async () => { + const { calls } = await derive({ contractParamTypes: () => DECLARED }); + const args = JSON.parse(calls[0]?.argumentsJson ?? "{}") as Record; + + expect(args.OWNER_PUB_KEY).toEqual({ type: "Pubkey", value: `0x${KEY}` }); + expect(args.RESERVE_COV_HASH).toEqual({ type: "u256", value: `0x${RESERVE_HASH}` }); + }); +}); + +describe("what it refuses rather than getting wrong", () => { + /** + * The state this covenant was in before, reproduced by withholding the contract's + * declarations. The values are unchanged and neither is readable. + */ + test("the same covenant, when nothing says what the contract declares", async () => { + const { calls, result } = await derive(); + + expect(result.ok).toBe(false); + expect(calls).toHaveLength(0); + }); + + /** + * A contract that will not analyse is reported the way one that will not compile is. It is the + * same failure found one step earlier, and saying so keeps the two from reading as different + * problems. + */ + test("a contract whose declarations cannot be read at all", async () => { + const { calls, result } = await derive({ + contractParamTypes: () => { + throw new Error("not a program"); + }, + }); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("./vault.simf"); + expect(calls).toHaveLength(0); + }); + + test("a deployment missing a field the covenant is built from", async () => { + const action = findAction(manifest, "Withdraw"); + const { RESERVE_COV_HASH: _absent, ...short } = INSTANCE; + const result = await deriveCovenantAddress(manifest, { + compile: () => ({ address: "ex1p", scriptPubKeyHex: "51" }), + contractParamTypes: () => DECLARED, + contractSources: { "./vault.simf": SOURCE }, + declaredTypes: declaredParamTypes(manifest, action!), + includeDebugSymbols: false, + network: "liquid", + scope: { instance: short, params: {} }, + utxoType: "vault", + wiring: {}, + }); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("RESERVE_COV_HASH"); + }); +}); diff --git a/packages/tx-manifest/src/document/buildMode.test.ts b/packages/tx-manifest/src/document/buildMode.test.ts new file mode 100644 index 0000000..e6c6217 --- /dev/null +++ b/packages/tx-manifest/src/document/buildMode.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from "bun:test"; + +import debugVaultlet from "../__fixtures__/vaultlet-debug.manifest.json"; +import groupedVaultlet from "../__fixtures__/vaultlet.manifest.json"; +import { normaliseManifest } from "./normalise"; + +/** + * Which mode a protocol says its contracts were built in, read rather than assumed. + * + * The flag changes the commitment merkle root, so the identical document with and without it + * describes covenants at two different addresses — and both compile. A wallet that ignored it + * would derive a well-formed address for a contract nobody deployed, then refuse against the + * money that is actually there and say the site had lied. + */ + +const mode = (document: unknown) => + normaliseManifest(document as Record).manifest.buildMode; + +const declaring = (declared: Record) => + mode({ actions: {}, protocol: "p", ...declared }); + +describe("the two spellings a protocol states it in", () => { + test("reads the flat one the older generation writes", () => { + expect(declaring({ compile_debug_symbols: true })).toEqual({ + includeDebugSymbols: true, + ok: true, + }); + }); + + test("and the block the newer generation moved it into", () => { + expect(mode(debugVaultlet)).toEqual({ includeDebugSymbols: true, ok: true }); + }); + + /** A rewrite is never silent, here least of all: this one decides an address. */ + test("records having read the newer spelling under the older name", () => { + expect( + normaliseManifest(debugVaultlet as unknown as Record).notes, + ).toContainEqual({ + at: "manifest", + canonical: "compile_debug_symbols", + found: "simplicity_hl.debug_symbols", + }); + }); + + /** + * A document saying nothing is built plainly. That is not a hole in the address check: the + * wallet still rebuilds the contract and refuses unless the result matches where the funds + * actually sit, so the mode decides what is computed and never what it is compared against. + */ + test("builds plainly where a document states nothing", () => { + expect(mode(groupedVaultlet)).toEqual({ includeDebugSymbols: false, ok: true }); + expect(declaring({ compile_debug_symbols: false })).toEqual({ + includeDebugSymbols: false, + ok: true, + }); + }); + + test("takes the older spelling where a document carries both and they agree", () => { + expect( + declaring({ compile_debug_symbols: true, simplicity_hl: { debug_symbols: true } }), + ).toEqual({ includeDebugSymbols: true, ok: true }); + }); +}); + +describe("what it refuses rather than picking a mode", () => { + /** There is no third mode to build in, so a statement that is neither is not a statement. */ + test("a declaration that is neither on nor off", () => { + const found = declaring({ compile_debug_symbols: "yes" }); + + expect(found.ok).toBe(false); + expect(found.ok ? "" : found.reason).toContain("neither on nor off"); + }); + + test("the same, written in the newer block", () => { + const found = declaring({ simplicity_hl: { debug_symbols: 1 } }); + + expect(found.ok).toBe(false); + expect(found.ok ? "" : found.reason).toContain("simplicity_hl.debug_symbols"); + }); + + /** + * Two statements that disagree are the same problem written twice. The document says both + * modes, the two produce different addresses, and nothing in the format says which spelling + * wins — so following either would be this wallet deciding what the protocol meant. + */ + test("two spellings declaring opposite modes", () => { + const found = declaring({ + compile_debug_symbols: false, + simplicity_hl: { debug_symbols: true }, + }); + + expect(found.ok).toBe(false); + expect(found.ok ? "" : found.reason).toContain("opposite modes"); + }); +}); diff --git a/packages/tx-manifest/src/document/normalise.test.ts b/packages/tx-manifest/src/document/normalise.test.ts new file mode 100644 index 0000000..cda58ec --- /dev/null +++ b/packages/tx-manifest/src/document/normalise.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test"; + +import currentVaultlet from "../__fixtures__/current/vaultlet.manifest.json"; +import p2pkManifest from "../__fixtures__/p2pk.manifest.json"; +import groupedVaultlet from "../__fixtures__/vaultlet.manifest.json"; +import { declaredFields, findAction, normaliseInstance, normaliseManifest } from "./normalise"; + +/** + * One document, two generations, one reading. + * + * The corpus renamed a container and both halves of it at once — `classes.methods` became + * `contract_templates.actions` — and the same protocol is published in both. A wallet that read + * one would be as blind to the other generation as it was to this one, and the money the older + * documents locate is demonstrably on chain. So the two normalise to one shape, and every test + * below that names one spelling asserts the other produces the same answer. + */ + +const GROUPED = groupedVaultlet as unknown as Record; +const CURRENT = currentVaultlet as unknown as Record; + +describe("the two container generations", () => { + test("declare the same actions under both spellings", () => { + const grouped = normaliseManifest(GROUPED).manifest.actions.map((action) => action.name); + const current = normaliseManifest(CURRENT).manifest.actions.map((action) => action.name); + + expect(grouped).toEqual(["OpenVault", "Withdraw"]); + expect(current).toEqual(grouped); + }); + + test("bind each method to the class that holds it", () => { + for (const document of [GROUPED, CURRENT]) { + const manifest = normaliseManifest(document).manifest; + + expect(findAction(manifest, "Withdraw")?.boundTo).toBe("vaultlet_contract"); + } + }); + + test("mark the constructor under either spelling of the flag", () => { + for (const document of [GROUPED, CURRENT]) { + const manifest = normaliseManifest(document).manifest; + + expect(findAction(manifest, "OpenVault")?.isConstructor).toBe(true); + expect(findAction(manifest, "Withdraw")?.isConstructor).toBe(false); + } + }); + + /** + * The class is where a deployment's field types are stated — there is nowhere else — and it is + * reached through the same container list the actions were found through. A reader that looked + * for one name would find the fields of half the corpus. + */ + test("reach the same declared fields from a method in either", () => { + for (const document of [GROUPED, CURRENT]) { + const manifest = normaliseManifest(document).manifest; + const action = findAction(manifest, "Withdraw"); + + expect(Object.keys(declaredFields(manifest, action!))).toEqual([ + "OWNER_PUB_KEY", + "VAULT_ASSET_ID", + "VAULT_AMOUNT", + "TIMEOUT", + "RESERVE_COV_HASH", + "GUARD_COV_HASH", + ]); + } + }); + + /** + * A rewrite is never silent. Both documents are rewritten — one's flag, the other's container + * — and each records what it was found under, so a reader can say which generation a document + * came from without the value it produced depending on the answer. + */ + test("record the spelling each document was written in", () => { + expect(normaliseManifest(GROUPED).notes).toEqual([ + { at: "action OpenVault", canonical: "is_constructor", found: "deploy" }, + ]); + expect(normaliseManifest(CURRENT).notes).toEqual([ + { at: "container vaultlet_contract", canonical: "classes", found: "contract_templates" }, + ]); + }); +}); + +describe("an action declared at the top level", () => { + test("belongs to no class and therefore to no deployment", () => { + const manifest = normaliseManifest(p2pkManifest as unknown as Record).manifest; + const action = findAction(manifest, "Pay"); + + expect(action?.boundTo).toBeUndefined(); + expect(declaredFields(manifest, action!)).toEqual({}); + }); +}); + +describe("a deployment's field values", () => { + test("are read from the nested shape a current tool writes", () => { + const { instance } = normaliseInstance({ + instance: { class: "vaultlet_contract", fields: { TIMEOUT: "900000" } }, + }); + + expect(instance).toEqual({ className: "vaultlet_contract", fields: { TIMEOUT: "900000" } }); + }); + + test("and from the flat legacy map beside it, recording that it was one", () => { + const { instance, notes } = normaliseInstance({ instance_params: { TIMEOUT: "900000" } }); + + expect(instance.fields).toEqual({ TIMEOUT: "900000" }); + expect(notes).toContainEqual({ + at: "instance", + canonical: "instance.fields", + found: "instance_params", + }); + }); + + /** + * A file carrying both is not a conflict to resolve by merging. The nested form is what a + * current tool writes, so it wins outright — layering the legacy map underneath would let a + * stale value the newer half replaced come back. + */ + test("take the nested shape outright when a file carries both", () => { + const { instance } = normaliseInstance({ + instance: { fields: { TIMEOUT: "1" } }, + instance_params: { OTHER: "2", TIMEOUT: "2" }, + }); + + expect(instance.fields).toEqual({ TIMEOUT: "1" }); + }); + + test("are absent, rather than empty of meaning, when no file was supplied", () => { + expect(normaliseInstance(undefined).instance.fields).toEqual({}); + }); + + /** + * A file naming a class and no fields has no fields. Reading its top level as the fields + * themselves would make a deployment holding one field called `instance` whose value is an + * object — which resolves, encodes as nothing, and refuses somewhere further on for a reason + * about the wrong thing. + */ + test("are empty for a file that names its class and writes no fields", () => { + const { instance } = normaliseInstance({ instance: { class: "vaultlet_contract" } }); + + expect(instance).toEqual({ className: "vaultlet_contract", fields: {} }); + }); + + test("and empty for a file in neither accepted shape, rather than read off its top level", () => { + expect(normaliseInstance({ TIMEOUT: "900000" }).instance.fields).toEqual({}); + }); +}); diff --git a/packages/tx-manifest/src/document/normalise.ts b/packages/tx-manifest/src/document/normalise.ts new file mode 100644 index 0000000..6f9d2a5 --- /dev/null +++ b/packages/tx-manifest/src/document/normalise.ts @@ -0,0 +1,359 @@ +import { asRecord } from "./json"; + +/** + * One spelling the runtime accepted and rewrote, and where it did so. + * + * Kept rather than discarded because a document that needed rewriting is a document from an + * older generation of the format, and that is worth being able to say out loud — both to the + * person approving an action and to whoever reads a refusal later. + */ +export type NormalisationNote = { + /** Where the rename happened, in the document's own terms. */ + at: string; + /** The name the document now carries. */ + canonical: string; + /** The spelling that was found. */ + found: string; +}; + +/** + * One action, however the manifest chose to declare it. + * + * `boundTo` is the whole of the difference between the two declaration shapes: a method belongs + * to a class and therefore to a deployment, and reads that deployment's field values; a free + * action belongs to nothing and reads no instance file. Everything else about the two is the + * same, which is why they normalise to one type rather than two. + */ +export type NormalisedAction = { + /** The class this method belongs to; absent for a free action. */ + boundTo?: string; + isConstructor: boolean; + name: string; + /** The action's own record, with the legacy spellings already rewritten. */ + node: Record; +}; + +export type NormalisedManifest = { + actions: NormalisedAction[]; + /** + * The mode this protocol states its contracts were built in, under either spelling. + * + * A result rather than a boolean, because a document can state it in a way that cannot be + * followed and the alternative to saying so is picking one. See `readBuildMode`. + */ + buildMode: BuildMode; + chain?: string; + protocol?: string; + /** The document exactly as it arrived, so nothing this layer does not model is lost. */ + raw: Record; + utxoTypes: Record; +}; + +/** + * Whether this protocol's contracts are built with debug symbols — or why that cannot be said. + * + * Not a detail of the build. The flag changes the commitment merkle root and therefore both the + * covenant address and every covenant script hash a document computes, so a contract built in the + * wrong mode lands somewhere else entirely. The wallet follows the mode the protocol states and + * builds plainly when it states nothing. + * + * That is not a hole in the address check: whatever a site declares, the wallet rebuilds the + * contract and refuses unless the result matches where the funds actually sit, so a misdeclared + * mode produces a refusal rather than an exploit. It decides what the wallet computes, never what + * it compares against — which is why no user-facing setting governs it and none exists. + */ +export type BuildMode = { includeDebugSymbols: boolean; ok: true } | { ok: false; reason: string }; + +export type NormaliseManifestResult = { + manifest: NormalisedManifest; + notes: NormalisationNote[]; +}; + +/** + * The names a container of actions has been known by, newest last. + * + * One shape under two vocabularies rather than two shapes: a container names a contract, holds + * the values one deployment of it fills in, and holds the actions performed against it. The + * corpus renamed both halves at once — `classes.methods` became `contract_templates.actions` — + * and a document written in either is the same document. Both are read, because a wallet that + * traded one for the other would be as blind to the previous generation as it was to this one, + * and the corpus keeps several generations of the same protocol side by side. + */ +const CONTAINERS = [ + { holder: "classes", holds: "methods" }, + { holder: "contract_templates", holds: "actions" }, +] as const; + +/** + * Rewrites a manifest's known spellings into one canonical vocabulary. + * + * The format has changed faster than its own specification, so a real document may be written + * in any of several generations and there is no field that reliably says which. So this selects + * by observation — it looks for each legacy spelling where that spelling can appear — rather + * than by branching on a declared generation. + * + * **Every rename is positional.** `compile_params` is both a deprecated reference namespace and + * the name of the wiring map on a script, an input and an output; renaming by key alone would + * rewrite the wiring and change what gets compiled. So this rewrites keys only at the paths + * where the legacy meaning applies, and the namespace — which is a spelling inside a reference + * string rather than a key — is canonicalised where references are resolved instead. + * + * Nothing here refuses. A construct this slice does not model survives untouched into `raw`. + */ +export function normaliseManifest(raw: Record): NormaliseManifestResult { + const notes: NormalisationNote[] = []; + + return { + manifest: { + actions: normaliseActions(raw, notes), + buildMode: readBuildMode(raw, notes), + chain: asString(raw.chain), + protocol: asString(raw.protocol), + raw, + utxoTypes: asRecord(raw.utxo_types) ?? {}, + }, + notes, + }; +} + +/** + * The build mode moved into a block of its own, and the wallet reads it where it was. + * + * `compile_debug_symbols` at the top level became `simplicity_hl.debug_symbols`. A document + * carrying only the older spelling keeps it, and the rewrite is recorded rather than applied + * silently. + * + * **Two things refuse rather than resolve.** A statement that is neither on nor off cannot be + * followed — there is no third mode to build in, and picking one would be this wallet deciding + * what the protocol meant. Two statements that disagree are the same problem written twice: the + * document says both modes, the two produce different addresses, and nothing in the format says + * which spelling wins. Guessing either way silently derives the wrong contract. + */ +function readBuildMode(raw: Record, notes: NormalisationNote[]): BuildMode { + const flat = raw.compile_debug_symbols; + const nested = asRecord(raw.simplicity_hl)?.debug_symbols; + + for (const [declared, at] of [ + [flat, "compile_debug_symbols"], + [nested, "simplicity_hl.debug_symbols"], + ] as const) { + if (declared !== undefined && typeof declared !== "boolean") { + return { + ok: false, + reason: + `This protocol declares ${at} as ${JSON.stringify(declared)}, which is neither on ` + + "nor off. The wallet builds each contract the way its protocol states, and cannot " + + "follow a statement it cannot read.", + }; + } + } + + if (flat !== undefined && nested !== undefined && flat !== nested) { + return { + ok: false, + reason: + "This protocol declares compile_debug_symbols and simplicity_hl.debug_symbols as " + + "opposite modes. The two build different contracts at different addresses, and the " + + "format does not say which spelling wins.", + }; + } + + if (flat === undefined && nested !== undefined) { + notes.push({ + at: "manifest", + canonical: "compile_debug_symbols", + found: "simplicity_hl.debug_symbols", + }); + } + + return { includeDebugSymbols: (flat ?? nested) === true, ok: true }; +} + +/** The action of that name, whichever shape declared it. */ +export function findAction( + manifest: NormalisedManifest, + name: string, +): NormalisedAction | undefined { + return manifest.actions.find((action) => action.name === name); +} + +/** + * What a deployment of this action's contract declares about its own fields. + * + * A deployment's fields are declared once on the container and filled in per deployment, so the + * container is where a field's type is stated — there is nowhere else. They are read through the + * same container list the actions were found through rather than through a name of their own, + * because the rename that hid every one of these documents renamed both halves at once. + * + * Empty for a free action, which belongs to no container and therefore to no deployment. + */ +export function declaredFields( + manifest: NormalisedManifest, + action: NormalisedAction, +): Record { + if (action.boundTo === undefined) { + return {}; + } + + for (const container of CONTAINERS) { + const fields = asRecord( + asRecord(asRecord(manifest.raw[container.holder])?.[action.boundTo])?.fields, + ); + + if (fields) { + return fields; + } + } + + return {}; +} + +/** One deployment's field values, as the runtime reads them. */ +export type NormalisedInstance = { + className?: string; + fields: Record; +}; + +export type NormaliseInstanceResult = { + instance: NormalisedInstance; + notes: NormalisationNote[]; +}; + +/** + * Reads a deployment's field values under either spelling. + * + * The current shape nests them under `instance.fields`; the legacy one is a flat + * `instance_params` map beside it. A file carrying both is not a conflict to resolve by merging + * — the nested form is the one a current tool writes, so it wins outright and the legacy map is + * ignored rather than layered underneath. + * + * **Only those two shapes.** A file matching neither has no fields, and reading its top level as + * the fields themselves would turn `{"instance": {"class": "X"}}` into a deployment holding one + * field named `instance` whose value is an object. That resolves, encodes as nothing, and refuses + * somewhere further on for a reason about the wrong thing. A deployment nobody wrote fields into + * is empty, and every name read against it says so. + */ +export function normaliseInstance( + raw: Record | undefined, +): NormaliseInstanceResult { + const notes: NormalisationNote[] = []; + + if (!raw) { + return { instance: { fields: {} }, notes }; + } + + const nested = asRecord(raw.instance); + const fields = asRecord(nested?.fields); + const legacy = asRecord(raw.instance_params); + + if (!fields && legacy) { + notes.push({ at: "instance", canonical: "instance.fields", found: "instance_params" }); + } + + return { + instance: { + ...(asString(nested?.class) === undefined ? {} : { className: asString(nested?.class) }), + fields: fields ?? legacy ?? {}, + }, + notes, + }; +} + +/** + * Every declaration shape, in declaration order: flat `actions` first, then each container's. + * + * A name declared twice resolves to the flat one, which is what every reader of this manifest + * did before the shapes were unified. + */ +function normaliseActions( + raw: Record, + notes: NormalisationNote[], +): NormalisedAction[] { + const actions: NormalisedAction[] = []; + const seen = new Set(); + + for (const [name, declared] of Object.entries(asRecord(raw.actions) ?? {})) { + const node = asRecord(declared); + + if (!node) { + continue; + } + + seen.add(name); + actions.push(normaliseAction(name, node, undefined, notes)); + } + + for (const container of CONTAINERS) { + for (const [owner, declared] of Object.entries(asRecord(raw[container.holder]) ?? {})) { + const held = asRecord(asRecord(declared)?.[container.holds]); + + if (held && container.holder !== "classes") { + notes.push({ at: `container ${owner}`, canonical: "classes", found: container.holder }); + } + + for (const [name, method] of Object.entries(held ?? {})) { + const node = asRecord(method); + + if (!node || seen.has(name)) { + continue; + } + + seen.add(name); + actions.push(normaliseAction(name, node, owner, notes)); + } + } + } + + return actions; +} + +function normaliseAction( + name: string, + declared: Record, + boundTo: string | undefined, + notes: NormalisationNote[], +): NormalisedAction { + const node = { ...declared }; + const isConstructor = pick(node, "is_constructor", "deploy", `action ${name}`, notes); + + delete node.deploy; + + if (isConstructor !== undefined) { + node.is_constructor = Boolean(isConstructor); + } + + return { + ...(boundTo === undefined ? {} : { boundTo }), + isConstructor: Boolean(isConstructor), + name, + node, + }; +} + +/** + * The value under the current name, or under the legacy one — recording which was found so a + * rewrite is never silent. + */ +function pick( + node: Record, + canonical: string, + legacy: string, + at: string, + notes: NormalisationNote[], +): unknown { + if (canonical in node) { + return node[canonical]; + } + + if (!(legacy in node)) { + return undefined; + } + + notes.push({ at, canonical, found: legacy }); + + return node[legacy]; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} diff --git a/packages/tx-manifest/src/document/references.test.ts b/packages/tx-manifest/src/document/references.test.ts new file mode 100644 index 0000000..879f53b --- /dev/null +++ b/packages/tx-manifest/src/document/references.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from "bun:test"; + +import type { NormalisationNote } from "./normalise"; +import { parseReference, type ReferenceScope, resolveReference } from "./references"; + +/** + * What a name may mean is decided by where it is written, not by what it looks like. + * + * That is the whole of this module and it is not a style choice: the same string is a legitimate + * compile parameter at one position and nonsense at another, and nothing about the string says + * which. Every test below asks the question at a position. + */ + +const SCOPE: ReferenceScope = { + args: { seat: "row-4" }, + instance: { TIMEOUT: "900000" }, + params: { amount_sat: "1000" }, +}; + +describe("the namespaces a reference can name", () => { + test("reads the request's parameters", () => { + expect(resolveReference("params.amount_sat", "amount", SCOPE)).toEqual({ + form: "params", + ok: true, + value: "1000", + }); + }); + + test("reads them through the $-prefixed spelling too, which the corpus also writes", () => { + expect(resolveReference("$params.amount_sat", "amount", SCOPE)).toMatchObject({ + ok: true, + value: "1000", + }); + }); + + test("reads this deployment's field values", () => { + expect(resolveReference("instance.TIMEOUT", "compileParam", SCOPE)).toEqual({ + form: "instance", + ok: true, + value: "900000", + }); + }); + + test("reads the request's arguments", () => { + expect(resolveReference("args.seat", "compileParam", SCOPE)).toMatchObject({ + ok: true, + value: "row-4", + }); + }); + + /** + * The format is mid-rename and both spellings are live in the corpus, one generation writing + * each. They are the same lookup, so they must be indistinguishable in the value — a + * deprecation marker riding on the result would make two documents that say the same thing + * behave differently downstream. + */ + test("reads the deprecated compile_params. namespace as the same lookup", () => { + const notes: NormalisationNote[] = []; + const deprecated = resolveReference("compile_params.TIMEOUT", "compileParam", SCOPE, notes); + + expect(deprecated).toEqual(resolveReference("instance.TIMEOUT", "compileParam", SCOPE)); + expect(notes).toContainEqual({ + at: "a compile parameter", + canonical: "instance.", + found: "compile_params.", + }); + }); + + /** + * An unqualified word is ambiguous by design: the format offers no way to say whether a + * parameter or an argument was meant. Parameters are tried first, which is the order the + * format's own reference implementation reads one in. + */ + test("reads a bare name as a parameter first and an argument second", () => { + expect(resolveReference("amount_sat", "amount", SCOPE)).toMatchObject({ value: "1000" }); + expect(resolveReference("seat", "compileParam", SCOPE)).toMatchObject({ value: "row-4" }); + }); +}); + +describe("what a name must come from", () => { + test("a name nothing in scope supplies refuses rather than resolving to nothing", () => { + const found = resolveReference("MISSING", "compileParam", SCOPE); + + expect(found.ok).toBe(false); + expect(found.ok ? "" : found.reason).toContain("MISSING"); + }); + + test("a namespace the request did not carry says so, rather than saying the name is absent", () => { + const found = resolveReference("instance.TIMEOUT", "compileParam", { params: {} }); + + expect(found.ok ? "" : found.reason).toContain("carries no instance"); + }); + + /** + * Zero is a value. A reference that resolved to one where nothing was supplied would put a + * plausible number into an address, and there is nothing downstream that could tell. + */ + test("an absent value is not a zero", () => { + expect(resolveReference("params.amount_sat", "amount", { params: {} }).ok).toBe(false); + }); +}); + +describe("what a position refuses", () => { + /** + * A destination names an output's payee and the corpus writes only a parameter there. This + * deployment's fields resolve perfectly well at other positions and are not accepted here, + * which is the point: the lookup succeeding is not what decides whether it was allowed. + */ + test("a destination takes only a parameter, even where another form would resolve", () => { + const found = resolveReference("instance.TIMEOUT", "destination", SCOPE); + + expect(found.ok).toBe(false); + expect(found.ok ? "" : found.reason).toContain("cannot be used as a destination"); + }); + + test("an attribute of a transaction input is recognised and refused by name", () => { + expect(parseReference("vault_in.amount_sat")).toEqual({ + attribute: "amount_sat", + form: "input-attribute", + name: "vault_in", + }); + + const found = resolveReference("vault_in.amount_sat", "amount", SCOPE); + + expect(found.ok).toBe(false); + expect(found.ok ? "" : found.reason).toContain("vault_in.amount_sat"); + }); + + /** + * An expression whose terms include a reference is not a reference. Reading one would resolve + * the first term and lose the rest, which is an answer rather than an error. + */ + test("an expression is not a reference", () => { + expect(parseReference("params.amount_sat - fee")).toBeUndefined(); + expect(resolveReference("params.amount_sat - fee", "amount", SCOPE).ok).toBe(false); + }); + + test("a bare value is not a reference either", () => { + expect(parseReference("2")).toBeUndefined(); + expect(parseReference("0xdeadbeef")).toBeUndefined(); + }); +}); diff --git a/packages/tx-manifest/src/document/references.ts b/packages/tx-manifest/src/document/references.ts new file mode 100644 index 0000000..709aa4e --- /dev/null +++ b/packages/tx-manifest/src/document/references.ts @@ -0,0 +1,390 @@ +import { asArray, asRecord } from "./json"; +import { + declaredFields, + type NormalisationNote, + type NormalisedAction, + type NormalisedManifest, +} from "./normalise"; +import { namedUtxoTypes } from "./sites"; + +/** + * The shapes a reference can take. + * + * These are not variations on a syntax; they are different lookups that happen to be written as + * strings. `instance` is this deployment's field values, `params` and `args` are the request's, + * `bare` is whichever of the last two has the name, and `input-attribute` is something about a + * transaction input the wallet would have had to read the chain to know. + * + * `input-attribute` is parsed and accepted nowhere in this slice. It is here so that a dotted + * name in an unknown namespace is recognised as the lookup it is and refused for what it is, + * rather than falling through to something that happens to resolve. + */ +export type ReferenceForm = "args" | "bare" | "input-attribute" | "instance" | "params"; + +export type ParsedReference = { + /** The attribute being read, for the input-attribute form. */ + attribute?: string; + /** Whether the document used a spelling the format has deprecated. */ + deprecated?: boolean; + form: ReferenceForm; + /** The name being looked up. */ + name: string; +}; + +/** + * What a reference can be resolved against. + * + * Everything is optional except the request's parameters, because a reference resolves against + * whatever exists at the moment it is asked, and saying "there is no deployment to read that + * from" is more useful than resolving it to zero. + */ +export type ReferenceScope = { + args?: Record; + /** This deployment's field values. */ + instance?: Record; + params: Record; +}; + +/** + * The value and how it was found — and deliberately nothing about how it was spelled. + * + * Two documents writing one lookup in two accepted spellings must be indistinguishable to + * everything downstream, so a deprecation marker cannot ride on the result. That a deprecated + * spelling was used is recorded on the notes channel instead, where it informs a reader without + * changing a value. + */ +export type ReferenceResolution = + | { form: ReferenceForm; ok: true; value: unknown } + | { ok: false; reason: string }; + +/** + * A position in a manifest where a reference may appear, and the forms it accepts there. + * + * This is the cornerstone: a reference means what its position says it may mean, not what its + * text looks like. The same string is a legitimate compile parameter in one place and nonsense + * in another, and the difference is not detectable from the string. Listing the accepted forms + * per site makes the wrong ones unrepresentable rather than a mistake to be caught downstream. + */ +export type ReferenceSiteKind = "amount" | "compileParam" | "destination"; + +const SITES: Record = { + /** An output's amount, or an input's minimum. */ + amount: { accepts: ["instance", "params", "args", "bare"], describes: "an amount" }, + /** A value compiled into a contract, which therefore decides its address. */ + compileParam: { + accepts: ["instance", "params", "args", "bare"], + describes: "a compile parameter", + }, + /** Where an output pays, when it names a parameter rather than a keyword. */ + destination: { accepts: ["params"], describes: "a destination" }, +}; + +/** The namespaces a prefixed reference can name, and what each canonically resolves as. */ +const NAMESPACES: Record = { + args: { deprecated: false, form: "args" }, + // The format is mid-rename from compile_params. to instance.; both are live in the corpus, + // and one manifest generation writes each. They are the same lookup. + compile_params: { deprecated: true, form: "instance" }, + instance: { deprecated: false, form: "instance" }, + params: { deprecated: false, form: "params" }, +}; + +const NAME = "[A-Za-z_][A-Za-z0-9_]*"; +const REFERENCE = new RegExp(`^\\$?(?${NAME})(?:\\.(?${NAME}))?$`); + +/** + * Reads one reference, or reports that the text is not one. + * + * Deliberately not an expression parser: `params.a + 1` is an expression whose terms happen to + * include a reference, and evaluating it belongs to the slice that owns arithmetic. This returns + * nothing for it rather than resolving the first term and losing the rest. + */ +export function parseReference(text: string): ParsedReference | undefined { + const match = REFERENCE.exec(text.trim()); + const head = match?.groups?.head; + + if (!head) { + return undefined; + } + + const tail = match.groups?.tail; + + if (tail === undefined) { + return { form: "bare", name: head }; + } + + const namespace = NAMESPACES[head]; + + if (namespace) { + return { + ...(namespace.deprecated ? { deprecated: true } : {}), + form: namespace.form, + name: tail, + }; + } + + // Anything else with one dot names an input and an attribute of it — `amount_sat`, `asset`, + // or something the wallet derived by reading the chain at that input's outpoint. + return { attribute: tail, form: "input-attribute", name: head }; +} + +/** + * Resolves one reference at one site. + * + * A refusal names both the text and what was wrong with it, because the reader of that message + * is a person deciding whether to trust a site, not the author of the manifest. + * + * `notes` collects the deprecated spellings encountered. It is optional because most callers + * only want the value; a caller building something a person will read passes one so the + * document's generation can be reported. + */ +export function resolveReference( + text: string, + site: ReferenceSiteKind, + scope: ReferenceScope, + notes?: NormalisationNote[], +): ReferenceResolution { + const reference = parseReference(text); + const accepted = SITES[site]; + + if (!reference) { + return { ok: false, reason: `"${text}" is not a reference.` }; + } + + if (!accepted.accepts.includes(reference.form)) { + return { ok: false, reason: `"${text}" cannot be used as ${accepted.describes}.` }; + } + + if (reference.deprecated) { + notes?.push({ at: accepted.describes, canonical: "instance.", found: "compile_params." }); + } + + const found = lookUp(reference, scope); + + return found.ok ? { form: reference.form, ok: true, value: found.value } : found; +} + +function lookUp( + reference: ParsedReference, + scope: ReferenceScope, +): { ok: true; value: unknown } | { ok: false; reason: string } { + switch (reference.form) { + case "args": { + return read(scope.args, reference.name, "args"); + } + + // Tried as a parameter first and then as an argument, which is the order the format's own + // reference implementation uses. An unqualified word is ambiguous by design: the format + // offers no way to say which of the two was meant. + case "bare": { + if (reference.name in scope.params) { + return { ok: true, value: scope.params[reference.name] }; + } + + if (scope.args && reference.name in scope.args) { + return { ok: true, value: scope.args[reference.name] }; + } + + return { + ok: false, + reason: `"${reference.name}" is neither a parameter nor an argument of this action.`, + }; + } + + case "input-attribute": { + return { + ok: false, + reason: + `"${reference.name}.${reference.attribute ?? ""}" reads an attribute of a ` + + "transaction input, which this runtime does not resolve yet.", + }; + } + + case "instance": { + return read(scope.instance, reference.name, "instance"); + } + + case "params": { + return read(scope.params, reference.name, "params"); + } + } +} + +function read( + source: Record | undefined, + name: string, + label: string, +): { ok: true; value: unknown } | { ok: false; reason: string } { + if (!source) { + return { ok: false, reason: `This request carries no ${label} to resolve "${name}" against.` }; + } + + if (!(name in source)) { + return { ok: false, reason: `${label} carries no "${name}".` }; + } + + return { ok: true, value: source[name] }; +} + +/** One reference the runtime found, and the position that says what it may mean. */ +export type ReferenceOccurrence = { + /** Where it is, in the document's own terms. */ + at: string; + site: ReferenceSiteKind; + text: string; +}; + +/** Destination words that are keywords rather than references. */ +const DESTINATION_KEYWORDS = new Set(["change", "wallet"]); + +/** + * Every reference an action reaches, with the site each one sits at. + * + * This is the enumeration the rest of the runtime asks instead of searching a document for + * reference-shaped text. The difference is not tidiness: a search finds `params.pubkey` inside a + * description and treats it as a reference, and misses one at a position it did not think to + * look. Positions are declared here once. + * + * The positions listed are the ones this slice resolves — a covenant's compile wiring at both + * places it can be written, an output's amount, and an output's destination — together with the + * fields of a deployment an action creates, which are read at the compile-parameter position + * because that is what they are compiled into. A position this runtime does not yet read is + * absent rather than guessed at, and anything reading one is refused where it is reached. + */ +export function actionReferences( + manifest: NormalisedManifest, + action: NormalisedAction, +): ReferenceOccurrence[] { + const found: ReferenceOccurrence[] = []; + const where = `action ${action.name}`; + + const add = (site: ReferenceSiteKind, at: string, value: unknown): void => { + if (typeof value === "string") { + found.push({ at, site, text: value }); + } + }; + + const addWiring = (at: string, wiring: unknown): void => { + for (const [name, value] of Object.entries(asRecord(wiring) ?? {})) { + add("compileParam", `${at} / ${name}`, value); + } + }; + + for (const declared of asArray(action.node.inputs)) { + const input = asRecord(declared); + + addWiring( + `${where} / input ${identifierOf(input)}`, + asRecord(input?.utxo_source)?.compile_params, + ); + } + + for (const declared of asArray(action.node.outputs)) { + const output = asRecord(declared); + const at = `${where} / output ${identifierOf(output)}`; + const destination = output?.destination; + + addWiring(at, asRecord(destination)?.compile_params); + add("amount", `${at} / amount_sat`, output?.amount_sat); + + if (typeof destination === "string" && !DESTINATION_KEYWORDS.has(destination)) { + add("destination", `${at} / destination`, destination); + } + } + + // The fields of the deployment this action creates, which are values it compiles covenants + // from — including a tapleaf's own wiring, written as an object carrying the reference. + for (const [name, value] of Object.entries( + asRecord(asRecord(action.node.create_instance)?.fields) ?? {}, + )) { + const at = `${where} / new deployment / ${name}`; + + add("compileParam", at, value); + + for (const [param, spec] of Object.entries(asRecord(asRecord(value)?.params) ?? {})) { + add( + "compileParam", + `${at} / ${param}`, + typeof spec === "string" ? spec : asRecord(spec)?.value, + ); + } + } + + // A covenant's parameters can also be wired on the utxo type itself rather than at the site + // that names it, so the types this action reaches are part of its reference surface. + for (const name of namedUtxoTypes(action.node)) { + addWiring( + `utxo type ${name} / script`, + asRecord(asRecord(manifest.utxoTypes[name])?.script)?.compile_params, + ); + } + + return found; +} + +/** + * Every place the action reads the field values of a deployment it did not create. + * + * Two kinds of reading, and leaving out either would ask a site for the wrong thing. The first is + * a reference that names the deployment outright — `instance.X`, or the deprecated + * `compile_params.X` spelling of it. The second is the one the corpus actually writes most: + * `{"ASSET_B": "ASSET_B"}`, a bare name at a compile-parameter position, which means the + * request's own parameter where the request supplied one and the deployment's field where it did + * not. + * + * **A bare name counts only where the class declares a field of that name.** The compile-parameter + * position is also where a document writes a bare *value* — `{"WITH_BURN": "false"}`, `{"SLOT_COUNT": + * "2"}` — and `false` is a perfectly well-formed name. Nothing about the text tells the two apart; + * only the compiler can, and it is not asked until much later. What the document itself says is + * enough: a name the class declares as a field is a field, and a name it declares nowhere is a + * value. Reading it the other way asks a site for a deployment file to answer the word `false`. + * + * What the request already filled is subtracted for the same reason — a name it supplied is not a + * reading of anything else. So is a field the constructor's own new deployment declares, and that + * subtraction applies to **both** spellings rather than only to the bare one: a constructor that + * works out a covenant hash and then wires `instance.HASH` into the covenant it creates is naming + * the deployment it is in the middle of writing, and there is no earlier file that could hold it. + * + * Returned rather than reduced to a flag because a free action reaching for a deployment is a + * document that cannot be satisfied rather than a request that is short a file: fields belong to a + * class, and an action declared outside one has no deployment to read. Naming the positions is + * what lets that be said rather than merely detected. + */ +export function instanceReferences( + manifest: NormalisedManifest, + action: NormalisedAction, + supplied: Record, +): ReferenceOccurrence[] { + const fields = declaredFields(manifest, action); + const created = new Set( + Object.keys(asRecord(asRecord(action.node.create_instance)?.fields) ?? {}), + ); + + return actionReferences(manifest, action).filter((occurrence) => { + const reference = parseReference(occurrence.text); + + // A field this very action creates is answered by the deployment it is creating, whichever + // way the document spells the reading. A constructor works out a covenant hash and then + // wires the covenant it creates to `instance.HASH` — naming the deployment it is in the + // middle of writing, not one that came before it — so counting that as a read would demand + // a file for a value nothing else could have held. + if (reference === undefined || created.has(reference.name)) { + return false; + } + + if (reference.form === "instance") { + return true; + } + + return ( + reference.form === "bare" && + occurrence.site === "compileParam" && + reference.name in fields && + !(reference.name in supplied) + ); + }); +} + +function identifierOf(node: Record | undefined): string { + return typeof node?.id === "string" ? node.id : "(unnamed)"; +} diff --git a/packages/tx-manifest/src/evaluation/plan.test.ts b/packages/tx-manifest/src/evaluation/plan.test.ts index 53861e5..63cb00e 100644 --- a/packages/tx-manifest/src/evaluation/plan.test.ts +++ b/packages/tx-manifest/src/evaluation/plan.test.ts @@ -1,28 +1,22 @@ import { describe, expect, test } from "bun:test"; import p2pkManifest from "../__fixtures__/p2pk.manifest.json"; -import type { ParsedLiquidProcessCtParams } from "../request/request"; +import type { ReferenceScope } from "../document/references"; import { planAction } from "./plan"; const PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; const MANIFEST = p2pkManifest as unknown as Record; const PAY = (MANIFEST.actions as Record>).Pay; -function request(params: Record): ParsedLiquidProcessCtParams { - return { - action: "Pay", - broadcast: false, - contractSources: {}, - manifest: MANIFEST, - params, - }; +function scope(params: Record): ReferenceScope { + return { params }; } describe("planAction", () => { // Pay declares two outputs: the covenant, whose amount is params.amount_sat, and an // optional change output. test("resolves the covenant amount from the request's parameters", () => { - const result = planAction(request({ amount_sat: 50_000, pubkey: PUBKEY }), PAY); + const result = planAction(PAY, scope({ amount_sat: 50_000, pubkey: PUBKEY })); expect(result).toMatchObject({ ok: true }); @@ -37,7 +31,7 @@ describe("planAction", () => { }); test("leaves change without an amount, because it is whatever survives the fee", () => { - const result = planAction(request({ amount_sat: 50_000, pubkey: PUBKEY }), PAY); + const result = planAction(PAY, scope({ amount_sat: 50_000, pubkey: PUBKEY })); expect(result).toMatchObject({ ok: true }); @@ -52,7 +46,7 @@ describe("planAction", () => { // Amounts are base units and must survive past 2^53, which a number cannot. test("keeps an amount beyond a double's range exact", () => { const huge = "9007199254740993"; - const result = planAction(request({ amount_sat: huge, pubkey: PUBKEY }), PAY); + const result = planAction(PAY, scope({ amount_sat: huge, pubkey: PUBKEY })); expect(result).toMatchObject({ ok: true }); @@ -63,35 +57,36 @@ describe("planAction", () => { test("refuses an amount it cannot evaluate rather than assuming one", () => { const result = planAction( - request({ amount_sat: "will_in.amount_sat - fee", pubkey: PUBKEY }), PAY, + scope({ amount_sat: "will_in.amount_sat - fee", pubkey: PUBKEY }), ); expect(result).toMatchObject({ ok: false }); }); test("refuses when the referenced parameter was not supplied", () => { - const result = planAction(request({ pubkey: PUBKEY }), PAY); + const result = planAction(PAY, scope({ pubkey: PUBKEY })); expect(result).toMatchObject({ ok: false }); }); test("refuses an output that would pay nothing", () => { - const result = planAction(request({ amount_sat: 0, pubkey: PUBKEY }), PAY); + const result = planAction(PAY, scope({ amount_sat: 0, pubkey: PUBKEY })); expect(result).toMatchObject({ ok: false }); }); test("refuses a destination it does not resolve", () => { - const result = planAction(request({ amount_sat: 1, pubkey: PUBKEY }), { - outputs: [{ amount_sat: 1, destination: { if: "something" }, id: "odd" }], - }); + const result = planAction( + { outputs: [{ amount_sat: 1, destination: { if: "something" }, id: "odd" }] }, + scope({ amount_sat: 1, pubkey: PUBKEY }), + ); expect(result).toMatchObject({ ok: false }); }); test("refuses an action with no outputs", () => { - const result = planAction(request({ amount_sat: 1, pubkey: PUBKEY }), { outputs: [] }); + const result = planAction({ outputs: [] }, scope({ amount_sat: 1, pubkey: PUBKEY })); expect(result).toMatchObject({ ok: false }); }); diff --git a/packages/tx-manifest/src/evaluation/plan.ts b/packages/tx-manifest/src/evaluation/plan.ts index 765154b..5a330a5 100644 --- a/packages/tx-manifest/src/evaluation/plan.ts +++ b/packages/tx-manifest/src/evaluation/plan.ts @@ -1,5 +1,6 @@ import { asArray, asRecord } from "../document/json"; -import type { ParsedLiquidProcessCtParams } from "../request/request"; +import type { NormalisationNote } from "../document/normalise"; +import { type ReferenceScope, resolveReference } from "../document/references"; /** * A concrete amount the wallet worked out for one of the action's outputs. @@ -27,16 +28,18 @@ export type PlanResult = { ok: false; reason: string } | { ok: true; plan: Plann /** * Turns the action's declared outputs into concrete amounts. * - * Knowingly minimal at this stage: it resolves a literal and a `params.` reference and - * refuses everything else by name. The format's amounts can also be arithmetic over other + * Knowingly minimal at this stage: it resolves a literal and a reference the amount position + * accepts — this deployment's fields, the request's parameters and arguments, and a bare name — + * and refuses everything else by name. The format's amounts can also be arithmetic over other * outputs, the fee and chain state, and evaluating those is a dependency graph with a fee - * re-pass — a later slice's whole subject, which this module grows to take on rather than - * being replaced by. Until then it refuses loudly instead of falling through, so an amount - * this cannot evaluate is a refusal naming the output rather than a number nobody chose. + * re-pass — a later slice's whole subject, which this module grows to take on rather than being + * replaced by. Until then it refuses loudly instead of falling through, so an amount this cannot + * evaluate is a refusal naming the output rather than a number nobody chose. */ export function planAction( - request: ParsedLiquidProcessCtParams, action: Record, + scope: ReferenceScope, + notes?: NormalisationNote[], ): PlanResult { const outputs: PlannedOutput[] = []; let fundingSats = 0n; @@ -64,7 +67,7 @@ export function planAction( continue; } - const amount = resolveAmount(request, output.amount_sat); + const amount = resolveAmount(output.amount_sat, scope, notes); if (amount === undefined) { return { @@ -102,25 +105,42 @@ function resolveTarget(destination: unknown): PlannedOutput["target"] | undefine return typeof utxoType === "string" ? { kind: "covenant", utxoType } : undefined; } -/** A literal, or a `params.` reference to one. Anything else is refused by the caller. */ -function resolveAmount(request: ParsedLiquidProcessCtParams, amount: unknown): bigint | undefined { +/** + * A literal, or a reference to one that the amount position accepts. + * + * Recursive by one step on purpose: a reference resolves to whatever was supplied for it, and + * what was supplied is itself a literal rather than a second reference. A value that resolves to + * another reference is refused rather than chased, because a chain of them is an evaluation + * order and that belongs to the slice that owns evaluation. + */ +function resolveAmount( + amount: unknown, + scope: ReferenceScope, + notes?: NormalisationNote[], +): bigint | undefined { if (typeof amount === "number" && Number.isSafeInteger(amount)) { return BigInt(amount); } - if (typeof amount === "string") { - const literal = /^\d+$/.test(amount) ? BigInt(amount) : undefined; + if (typeof amount !== "string") { + return undefined; + } - if (literal !== undefined) { - return literal; - } + if (/^\d+$/.test(amount)) { + return BigInt(amount); + } + + const found = resolveReference(amount, "amount", scope, notes); + + if (!found.ok) { + return undefined; + } - const referenced = /^\$?params\.(?[A-Za-z0-9_]+)$/.exec(amount)?.groups?.name; + const value = found.value; - return referenced === undefined - ? undefined - : resolveAmount(request, request.params[referenced]); + if (typeof value === "number" && Number.isSafeInteger(value)) { + return BigInt(value); } - return undefined; + return typeof value === "string" && /^\d+$/.test(value) ? BigInt(value) : undefined; } diff --git a/packages/tx-manifest/src/request/requirements.test.ts b/packages/tx-manifest/src/request/requirements.test.ts index bcf42d0..d9459c5 100644 --- a/packages/tx-manifest/src/request/requirements.test.ts +++ b/packages/tx-manifest/src/request/requirements.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import p2pkManifest from "../__fixtures__/p2pk.manifest.json"; +import { findAction, normaliseManifest } from "../document/normalise"; import type { ParsedLiquidProcessCtParams } from "./request"; import { resolveActionRequirements } from "./requirements"; @@ -25,17 +26,30 @@ function request( }; } -/** The same question asked of the published manifest. */ +/** + * The same question asked of the published manifest. + * + * The action is found before it is asked about, because a name that matches nothing is a + * different answer from an action that needs something: which of the two declaration shapes + * holds it is the normalisation layer's business, and it is settled before this is asked. + */ function requirements(overrides: Partial = {}) { - return resolveActionRequirements(request(overrides)); + const asked = request(overrides); + const { manifest } = normaliseManifest(asked.manifest); + const action = findAction(manifest, asked.action); + + if (!action) { + throw new Error(`This manifest declares no action named ${asked.action}.`); + } + + return resolveActionRequirements(asked, manifest, action); } describe("resolveActionRequirements", () => { - test("refuses an action the manifest does not declare, naming it", () => { - const { missing } = requirements({ action: "Withdraw" }); + test("an action the manifest does not declare is not one to ask about", () => { + const { manifest } = normaliseManifest(MANIFEST); - expect(missing).toHaveLength(1); - expect(missing[0]?.reason).toContain("Withdraw"); + expect(findAction(manifest, "Withdraw")).toBeUndefined(); }); // Pay locks funds into a new p2pk output: one wallet input, a covenant destination. @@ -164,3 +178,215 @@ describe("wallet inputs and covenant inputs", () => { expect(required).toContain("contractSources"); }); }); + +/** + * A deployment's field values are asked for when the action actually reads them, and not because + * of where the action was declared. + * + * A method belongs to a class, but belonging is not reading: a method whose covenant is wired + * entirely to its own parameters has nothing to look up, and demanding a file for it sends a site + * looking for something the document never asked it to send. + */ +describe("when a deployment's field values are needed", () => { + const CLASS_FIELDS = { + OWNER_PUB_KEY: { type: "pubkey" }, + TIMEOUT: { type: "u32" }, + }; + + function classMethod(method: Record, params: Record = {}) { + return requirements({ + action: "Act", + contractSources: { [SOURCE_PATH]: "fn main() {}" }, + manifest: { + classes: { thing: { fields: CLASS_FIELDS, methods: { Act: method } } }, + utxo_types: { v: { script: { source: SOURCE_PATH } } }, + }, + params, + }); + } + + test("a method reading nothing off the deployment needs no instance file", () => { + const { missing, required } = classMethod({ + outputs: [ + { + amount_sat: "params.amount_sat", + destination: { compile_params: { OWNER_PUB_KEY: "params.key" }, utxo_type: "v" }, + id: "out", + }, + ], + }); + + expect(required).not.toContain("instance"); + expect(missing).toEqual([]); + }); + + test("a method naming the deployment outright does", () => { + const { missing, required } = classMethod({ + outputs: [ + { + amount_sat: "1000", + destination: { + compile_params: { OWNER_PUB_KEY: "instance.OWNER_PUB_KEY" }, + utxo_type: "v", + }, + id: "out", + }, + ], + }); + + expect(required).toContain("instance"); + expect(missing.find((entry) => entry.part === "instance")?.keys).toEqual([ + "action Act / output out / OWNER_PUB_KEY", + ]); + }); + + /** + * The spelling the corpus writes most: a bare name, which means the request's own parameter + * where the request supplied one and the deployment's field where it did not. + */ + test("a bare name the class declares as a field does, unless the request filled it", () => { + const wiring = { + outputs: [ + { + amount_sat: "1000", + destination: { compile_params: { OWNER_PUB_KEY: "OWNER_PUB_KEY" }, utxo_type: "v" }, + id: "out", + }, + ], + }; + + expect(classMethod(wiring).required).toContain("instance"); + expect(classMethod(wiring, { OWNER_PUB_KEY: PUBKEY }).required).not.toContain("instance"); + }); + + /** + * The same position also carries bare values — a count, or one of the two words a flag is + * written as — and `false` is a perfectly well-formed name. Only the document can tell them + * apart, by whether the class declares a field of that name. + */ + test("a bare value at the same position does not, however name-shaped it looks", () => { + const { missing, required } = classMethod({ + outputs: [ + { + amount_sat: "1000", + destination: { compile_params: { SLOT_COUNT: "2", WITH_BURN: "false" }, utxo_type: "v" }, + id: "out", + }, + ], + }); + + expect(required).not.toContain("instance"); + expect(missing).toEqual([]); + }); + + test("an amount read off the deployment needs it too", () => { + const { required } = classMethod({ + outputs: [{ amount_sat: "instance.TIMEOUT", destination: "wallet", id: "out" }], + }); + + expect(required).toContain("instance"); + }); + + /** + * A constructor names the deployment it is in the middle of writing. + * + * It works out a covenant hash, then wires the covenant it creates to that field. The spelling + * is `instance.HASH` — an explicit reading of a deployment — but the deployment it reads is the + * one this very action produces, and no earlier file could have held it. Asking for one demands + * a value only this wallet can make. + */ + test("a constructor reading a field its own create_instance produces needs no instance file", () => { + const { missing, required } = classMethod( + { + create_instance: { + fields: { + OWNER_PUB_KEY: "params.OWNER_PUB_KEY", + RESERVE_HASH: { + params: { OWNER_PUB_KEY: "OWNER_PUB_KEY" }, + simf: "./r.simf", + type: "tapleaf", + }, + }, + }, + outputs: [ + { + amount_sat: "1000", + destination: { + compile_params: { RESERVE_COV_HASH: "instance.RESERVE_HASH" }, + utxo_type: "v", + }, + id: "out", + }, + ], + }, + { OWNER_PUB_KEY: PUBKEY }, + ); + + expect(required).not.toContain("instance"); + expect(missing).toEqual([]); + }); + + /** The deprecated spelling of the same reading is subtracted the same way. */ + test("and the same under the deprecated namespace", () => { + const { required } = classMethod({ + create_instance: { fields: { RESERVE_HASH: { simf: "./r.simf", type: "tapleaf" } } }, + outputs: [ + { + amount_sat: "1000", + destination: { + compile_params: { RESERVE_COV_HASH: "compile_params.RESERVE_HASH" }, + utxo_type: "v", + }, + id: "out", + }, + ], + }); + + expect(required).not.toContain("instance"); + }); + + /** A field the constructor does not create is still a real read of an earlier deployment. */ + test("but a field its create_instance does not produce is still read from one", () => { + const { required } = classMethod({ + create_instance: { fields: { RESERVE_HASH: { simf: "./r.simf", type: "tapleaf" } } }, + outputs: [ + { + amount_sat: "1000", + destination: { + compile_params: { OWNER_PUB_KEY: "instance.OWNER_PUB_KEY" }, + utxo_type: "v", + }, + id: "out", + }, + ], + }); + + expect(required).toContain("instance"); + }); + + /** + * A free action has no class and therefore no deployment. This is a document that cannot be + * satisfied rather than a request that is short a file — sending one would not answer it — so + * it is named as a fault instead of asked for. + */ + test("a free action reading a deployment is unsatisfiable rather than short a file", () => { + const { missing, required } = requirements({ + action: "Free", + contractSources: { [SOURCE_PATH]: "fn main() {}" }, + manifest: { + actions: { + Free: { + outputs: [{ amount_sat: "instance.AMOUNT", destination: "wallet", id: "out" }], + }, + }, + }, + instance: { instance: { fields: { AMOUNT: "1" } } }, + params: {}, + }); + + expect(required).not.toContain("instance"); + expect(missing.find((entry) => entry.part === "instance")?.reason).toContain( + "declared outside any class", + ); + }); +}); diff --git a/packages/tx-manifest/src/request/requirements.ts b/packages/tx-manifest/src/request/requirements.ts index d7ad93e..dce8c88 100644 --- a/packages/tx-manifest/src/request/requirements.ts +++ b/packages/tx-manifest/src/request/requirements.ts @@ -1,4 +1,6 @@ import { asRecord } from "../document/json"; +import type { NormalisedAction, NormalisedManifest } from "../document/normalise"; +import { instanceReferences } from "../document/references"; import { covenantSites, namedUtxoTypes } from "../document/sites"; import type { ActionRequirements, MissingPart, ParsedLiquidProcessCtParams } from "./request"; @@ -17,25 +19,14 @@ import type { ActionRequirements, MissingPart, ParsedLiquidProcessCtParams } fro */ export function resolveActionRequirements( request: ParsedLiquidProcessCtParams, + manifest: NormalisedManifest, + declared: NormalisedAction, ): 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 action = declared.node; const required: ActionRequirements["required"] = []; const missing: MissingPart[] = []; - const sources = referencedContractSources(request.manifest, action); + const sources = referencedContractSources(manifest, action); if (sources.length > 0) { required.push("contractSources"); @@ -66,6 +57,35 @@ export function resolveActionRequirements( }); } + const reads = instanceReferences(manifest, declared, request.params); + + if (reads.length > 0) { + // An action declared outside a class has no deployment, so this is not a file the request + // forgot — it is a document asking for something that does not exist. Named as a fault + // rather than as a missing part, because sending the file would not answer it. + if (declared.boundTo === undefined) { + missing.push({ + part: "instance", + keys: reads.map((occurrence) => occurrence.at), + reason: + `The action "${declared.name}" reads a deployment's field values and is declared ` + + "outside any class, so there is no deployment for it to read.", + }); + } else { + required.push("instance"); + + if (!request.instance) { + missing.push({ + keys: reads.map((occurrence) => occurrence.at), + part: "instance", + reason: + `The action "${declared.name}" is a method of ${declared.boundTo} and reads the ` + + "field values of one deployment of it.", + }); + } + } + } + if (spendsCovenant(action)) { required.push("state"); @@ -82,10 +102,10 @@ export function resolveActionRequirements( /** Contract source paths the action reaches, through the utxo types it names. */ function referencedContractSources( - manifest: Record, + manifest: NormalisedManifest, action: Record, ): string[] { - const utxoTypes = asRecord(manifest.utxo_types) ?? {}; + const utxoTypes = manifest.utxoTypes; const paths = new Set(); for (const name of namedUtxoTypes(action)) { diff --git a/packages/tx-manifest/src/review/classAction.test.ts b/packages/tx-manifest/src/review/classAction.test.ts new file mode 100644 index 0000000..0918fa0 --- /dev/null +++ b/packages/tx-manifest/src/review/classAction.test.ts @@ -0,0 +1,628 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +import currentVaultlet from "../__fixtures__/current/vaultlet.manifest.json"; +import mutualManifest from "../__fixtures__/mutual.manifest.json"; +import debugVaultlet from "../__fixtures__/vaultlet-debug.manifest.json"; +import groupedVaultlet from "../__fixtures__/vaultlet.manifest.json"; +import type { ParsedLiquidProcessCtParams } from "../request/request"; +import { isRefusal, reviewManifestAction } from "./index"; + +/** + * An action declared inside a class, reviewed end to end — both halves of what that means. + * + * A class method reads the field values of one deployment and derives its covenant from them; a + * constructor has no deployment to read, so it works one out — covenant script hashes and all — + * and derives from what it worked out. Before either was possible, an action inside a class was + * not found at all, and one that was found had no name in its wiring that could be resolved. + * + * The contracts are compiled by a substitute. This package holds no compiler by design: a wallet + * supplies one, and what a real one makes of these arguments is the adapter's own question. What + * is checked here is what the compiler is asked for and what the review reports having + * established. + */ + +const KEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +const ASSET_STATED = `a0${"00".repeat(30)}0a`; +const ASSET_COMMITTED = `0a${"00".repeat(30)}a0`; +const RESERVE_HASH = "cc".repeat(32); +const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; + +const DERIVED = "ex1p_derived"; +const DERIVED_SCRIPT = `5120${"11".repeat(32)}`; +const ELSEWHERE_SCRIPT = `5120${"22".repeat(32)}`; +const WALLET_SCRIPT = `0014${"33".repeat(20)}`; + +const SOURCES = Object.fromEntries( + ["vault", "reserve", "guard", "left", "right"].map((name) => [ + `./${name}.simf`, + readFileSync(new URL(`../__fixtures__/contracts/${name}.simf`, import.meta.url), "utf8"), + ]), +); + +/** What the vault contract declares about the two parameters the document writes as values. */ +const DECLARED = { SLOT_COUNT: "u8", WITH_BURN: "bool" }; + +/** This deployment's field values, in the nested shape a current tool writes. */ +const DEPLOYMENT = { + instance: { + class: "vaultlet_contract", + fields: { + GUARD_COV_HASH: "dd".repeat(32), + OWNER_PUB_KEY: KEY, + RESERVE_COV_HASH: RESERVE_HASH, + TIMEOUT: "900000", + VAULT_AMOUNT: "50000", + VAULT_ASSET_ID: ASSET_STATED, + }, + }, +}; + +const FUNDING = [ + { amount: "100000000", spendable: true, txOut: "00", txid: "c".repeat(64), vout: 0 }, +]; + +function review( + request: Partial, + overrides: { hashedBy?: () => string; onChain?: string } = {}, +) { + const compiled: { + argumentsJson: string; + extraLeavesJson: string; + includeDebugSymbols: boolean; + source: string; + }[] = []; + const hashed: { includeDebugSymbols: boolean; source: string }[] = []; + + return { + compiled, + hashed, + result: reviewManifestAction( + { + broadcast: false, + contractSources: SOURCES, + params: {}, + ...request, + } as ParsedLiquidProcessCtParams, + { + compile: (input) => { + compiled.push({ + argumentsJson: input.argumentsJson, + extraLeavesJson: input.extraLeavesJson, + includeDebugSymbols: input.includeDebugSymbols, + source: input.source, + }); + + return { address: DERIVED, scriptPubKeyHex: DERIVED_SCRIPT }; + }, + contractParamTypes: () => DECLARED, + fundingUtxos: FUNDING, + network: "liquid", + policyAsset: POLICY_ASSET, + readFeeRate: async () => 1000, + readTxOut: async () => ({ scriptPubKeyHex: overrides.onChain ?? DERIVED_SCRIPT }), + scriptPubKeyOf: ({ argumentsJson, includeDebugSymbols, source }) => { + hashed.push({ includeDebugSymbols, source }); + + return ( + overrides.hashedBy?.() ?? + `5120${Bun.hash(JSON.stringify([source, argumentsJson, includeDebugSymbols])) + .toString(16) + .padStart(64, "0")}` + ); + }, + walletScriptPubKeyHex: WALLET_SCRIPT, + }, + ), + }; +} + +const withdraw = (document: unknown) => ({ + action: "Withdraw", + instance: DEPLOYMENT, + manifest: document as Record, + state: { utxos: [{ txid: "b".repeat(64), utxo_type: "vault", vout: 0 }] }, +}); + +const openVault = (document: unknown) => ({ + action: "OpenVault", + manifest: document as Record, + params: { + OWNER_PUB_KEY: KEY, + TIMEOUT: "900000", + VAULT_AMOUNT: "50000", + VAULT_ASSET_ID: ASSET_STATED, + }, +}); + +describe("a class method against a deployment that exists", () => { + test("is reviewed rather than refused for an action nobody could find", async () => { + const { result } = review(withdraw(groupedVaultlet)); + + expect(isRefusal(await result)).toBe(false); + }); + + test("reports which class the method belongs to", async () => { + const { result } = review(withdraw(groupedVaultlet)); + const reviewed = await result; + + expect(isRefusal(reviewed) ? undefined : reviewed.boundTo).toBe("vaultlet_contract"); + }); + + /** + * Every value in this covenant's wiring is a bare name, and every one of them is a field of + * the deployment rather than a parameter of the action — which declares none. The types come + * from the class's own field declarations, which is the only place they are stated. + */ + test("rebuilds its covenant from the deployment's own fields, at the class's declared types", async () => { + const { compiled, result } = review(withdraw(groupedVaultlet)); + + await result; + + expect(JSON.parse(compiled[0]?.argumentsJson ?? "{}")).toEqual({ + OWNER_PUB_KEY: { type: "Pubkey", value: `0x${KEY}` }, + RESERVE_COV_HASH: { type: "u256", value: `0x${RESERVE_HASH}` }, + SLOT_COUNT: { type: "u8", value: "2" }, + VAULT_ASSET_ID: { type: "u256", value: `0x${ASSET_COMMITTED}` }, + WITH_BURN: { type: "bool", value: "false" }, + }); + }); + + test("checks what it rebuilt against what the chain says, and reports it verified", async () => { + const { result } = review(withdraw(groupedVaultlet)); + const reviewed = await result; + + expect(isRefusal(reviewed) ? [] : reviewed.covenants).toEqual([ + { + address: DERIVED, + argumentsJson: expect.any(String), + extraLeavesJson: "[]", + includeDebugSymbols: false, + role: "spent", + scriptPubKeyHex: DERIVED_SCRIPT, + source: SOURCES["./vault.simf"], + sourcePath: "./vault.simf", + utxoType: "vault", + verified: "matches-chain", + }, + ]); + }); + + /** + * The review carries the derivation itself rather than the fact that one happened. Anything + * that goes on to spend this covenant rebuilds it from exactly what was verified here; + * resolving the request a second time would be a second answer to the same question, and + * nothing downstream could tell the two apart. + */ + test("carries out what it compiled with, so nothing has to resolve the references again", async () => { + const { compiled, result } = review(withdraw(groupedVaultlet)); + const reviewed = await result; + + expect(isRefusal(reviewed) ? "" : reviewed.covenants[0]?.argumentsJson).toBe( + compiled[0]?.argumentsJson ?? "", + ); + }); + + test("refuses when the funds are locked by a different contract", async () => { + const { result } = review(withdraw(groupedVaultlet), { onChain: ELSEWHERE_SCRIPT }); + const reviewed = await result; + + expect(isRefusal(reviewed)).toBe(true); + expect(isRefusal(reviewed) ? reviewed.reason : "").toContain("not the contract the site"); + }); + + /** + * A method belongs to a class and therefore to a deployment. Refused before anything is + * compiled, naming the part of the request that was absent rather than failing later on a + * name nobody could resolve. + */ + test("refuses without the deployment file, naming it", async () => { + const { compiled, result } = review({ ...withdraw(groupedVaultlet), instance: undefined }); + const reviewed = await result; + + expect(isRefusal(reviewed)).toBe(true); + expect(isRefusal(reviewed) ? reviewed.reason : "").toContain( + "is a method of vaultlet_contract", + ); + // Named by position, so a person can see which readings needed it. + expect(isRefusal(reviewed) ? reviewed.reason : "").toContain( + "utxo type vault / script / RESERVE_COV_HASH", + ); + expect(compiled).toHaveLength(0); + }); + + test("resolves the output's amount from the deployment rather than from the request", async () => { + const { result } = review(withdraw(groupedVaultlet)); + const reviewed = await result; + + expect(isRefusal(reviewed) ? [] : reviewed.outputs).toEqual([ + { asset: POLICY_ASSET, id: "withdrawn", sats: 50_000n, scriptPubKeyHex: WALLET_SCRIPT }, + ]); + }); +}); + +describe("the constructor of the same class", () => { + test("is reviewed with no deployment to read, and reports the one it creates", async () => { + const { result } = review(openVault(groupedVaultlet)); + const reviewed = await result; + + expect(isRefusal(reviewed)).toBe(false); + + if (isRefusal(reviewed)) { + return; + } + + expect(reviewed.createdInstance?.fields.OWNER_PUB_KEY).toBe(KEY); + expect(reviewed.createdInstance?.fields.RESERVE_COV_HASH).toHaveLength(64); + expect(reviewed.createdInstance?.rounds).toBe(3); + }); + + /** + * What a person is shown for a contract with no history. Not "unverified", which is what a + * check that failed would be, and not "verified", which would claim a comparison nobody could + * make: the wallet derived the address itself from the deployment it just worked out, and that + * is a different fact rather than a weaker one. + */ + test("reports the covenant it creates as one with nothing yet to compare against", async () => { + const { result } = review(openVault(groupedVaultlet)); + const reviewed = await result; + + expect(isRefusal(reviewed) ? [] : reviewed.covenants.map((found) => found.verified)).toEqual([ + "not-yet-on-chain", + ]); + }); + + test("never consults the chain for something that does not exist yet", async () => { + let asked = 0; + + await reviewManifestAction( + { + broadcast: false, + contractSources: SOURCES, + ...openVault(groupedVaultlet), + } as ParsedLiquidProcessCtParams, + { + compile: () => ({ address: DERIVED, scriptPubKeyHex: DERIVED_SCRIPT }), + contractParamTypes: () => DECLARED, + fundingUtxos: FUNDING, + network: "liquid", + policyAsset: POLICY_ASSET, + readFeeRate: async () => 1000, + readTxOut: async () => { + asked += 1; + + return { scriptPubKeyHex: DERIVED_SCRIPT }; + }, + scriptPubKeyOf: () => DERIVED_SCRIPT, + walletScriptPubKeyHex: WALLET_SCRIPT, + }, + ); + + expect(asked).toBe(0); + }); + + /** + * The covenant it creates is built from the deployment it just worked out, not from the + * request — `RESERVE_COV_HASH` is a value no request could have supplied. + */ + test("derives the covenant it creates from the deployment it worked out", async () => { + const { compiled, result } = review(openVault(groupedVaultlet)); + const reviewed = await result; + + if (isRefusal(reviewed)) { + throw new Error(reviewed.reason); + } + + const vault = compiled.findLast((call) => call.source === SOURCES["./vault.simf"]); + + expect(JSON.parse(vault?.argumentsJson ?? "{}")).toMatchObject({ + RESERVE_COV_HASH: { + type: "u256", + value: `0x${reviewed.createdInstance?.fields.RESERVE_COV_HASH ?? ""}`, + }, + }); + }); + + test("carries no deployment for the method that only spends what exists", async () => { + const { result } = review(withdraw(groupedVaultlet)); + const reviewed = await result; + + expect(isRefusal(reviewed) ? "refused" : reviewed.createdInstance).toBeUndefined(); + }); +}); + +/** + * The same protocol, published in the generation before its container was renamed. Both are in + * the corpus and both locate real money, so a runtime that read one and refused the other would + * be refusing against funds that are demonstrably there. + */ +describe("both generations of the same document", () => { + test("review a class method identically", async () => { + const grouped = review(withdraw(groupedVaultlet)); + const current = review(withdraw(currentVaultlet)); + // Everything but the spellings each recorded, which are the one thing that must differ. + const { normalisation: _grouped, ...groupedReview } = (await grouped.result) as Record< + string, + unknown + >; + const { normalisation: _current, ...currentReview } = (await current.result) as Record< + string, + unknown + >; + + expect(groupedReview).toEqual(currentReview); + expect(grouped.compiled).toEqual(current.compiled); + }); + + test("and create the identical deployment from the constructor", async () => { + const grouped = await review(openVault(groupedVaultlet)).result; + const current = await review(openVault(currentVaultlet)).result; + + expect(isRefusal(grouped) ? undefined : grouped.createdInstance).toEqual( + isRefusal(current) ? undefined : current.createdInstance, + ); + }); + + /** The value each was read as is the same; which spelling it was written in is still said. */ + test("differ only in the spellings each records having rewritten", async () => { + const grouped = await review(withdraw(groupedVaultlet)).result; + + expect(isRefusal(grouped) ? [] : grouped.normalisation).toContainEqual({ + at: "action OpenVault", + canonical: "is_constructor", + found: "deploy", + }); + }); +}); + +describe("what a review still refuses", () => { + /** + * A set of covenant hashes with no order to compile them in has no value to settle on. + * Refused rather than built from the last round, which would be an address nobody checked — + * and this one is an address the transaction would pay to. + */ + test("a deployment whose covenant hashes never settle", async () => { + const { compiled, result } = review({ + action: "Knot", + manifest: mutualManifest as unknown as Record, + }); + const reviewed = await result; + + expect(isRefusal(reviewed)).toBe(true); + expect(isRefusal(reviewed) ? reviewed.reason : "").toContain("never settle"); + expect(compiled).toHaveLength(0); + }); + + test("a covenant wired to a value when nothing says what the contract declares", async () => { + const reviewed = await reviewManifestAction( + { + broadcast: false, + contractSources: SOURCES, + params: {}, + ...withdraw(groupedVaultlet), + } as ParsedLiquidProcessCtParams, + { + compile: () => ({ address: DERIVED, scriptPubKeyHex: DERIVED_SCRIPT }), + fundingUtxos: FUNDING, + network: "liquid", + policyAsset: POLICY_ASSET, + readFeeRate: async () => 1000, + readTxOut: async () => ({ scriptPubKeyHex: DERIVED_SCRIPT }), + scriptPubKeyOf: () => DERIVED_SCRIPT, + walletScriptPubKeyHex: WALLET_SCRIPT, + }, + ); + + expect(isRefusal(reviewed)).toBe(true); + }); + + test("an action neither declaration shape declares", async () => { + const { result } = review({ + action: "Nowhere", + manifest: groupedVaultlet as unknown as Record, + }); + const reviewed = await result; + + expect(isRefusal(reviewed) ? reviewed.reason : "").toContain("Nowhere"); + }); +}); + +/** + * The mode a protocol says its contracts were built in, followed rather than assumed. + * + * It changes the commitment merkle root, so the same document with and without it describes + * covenants at two different addresses, and both compile. A wallet ignoring it would derive a + * well-formed address for a contract nobody deployed, then refuse against the money that is + * actually there and report that the site had lied. + */ +describe("the build mode a document declares", () => { + test("reaches the compiler for an ordinary derivation", async () => { + const plain = review(withdraw(groupedVaultlet)); + const debug = review(withdraw(debugVaultlet)); + + await plain.result; + await debug.result; + + expect(plain.compiled.map((call) => call.includeDebugSymbols)).toEqual([false]); + expect(debug.compiled.map((call) => call.includeDebugSymbols)).toEqual([true]); + }); + + test("and the compiler that takes the hashes a deployment's fields compute", async () => { + const plain = review(openVault(groupedVaultlet)); + const debug = review(openVault(debugVaultlet)); + + await plain.result; + await debug.result; + + expect(plain.hashed.length).toBeGreaterThan(0); + expect(plain.hashed.every((call) => call.includeDebugSymbols)).toBe(false); + expect(debug.hashed.every((call) => call.includeDebugSymbols)).toBe(true); + }); + + test("so the deployment the same constructor creates differs between the two", async () => { + const plain = await review(openVault(groupedVaultlet)).result; + const debug = await review(openVault(debugVaultlet)).result; + + expect(isRefusal(plain) || isRefusal(debug)).toBe(false); + + if (isRefusal(plain) || isRefusal(debug)) { + return; + } + + expect(debug.createdInstance?.fields.RESERVE_COV_HASH).not.toBe( + plain.createdInstance?.fields.RESERVE_COV_HASH, + ); + }); + + test("and is reported on every covenant the review establishes", async () => { + const reviewed = await review(withdraw(debugVaultlet)).result; + + expect(isRefusal(reviewed) ? [] : reviewed.covenants.map((f) => f.includeDebugSymbols)).toEqual( + [true], + ); + }); + + /** + * There is no third mode to build in. Refused before anything is compiled, because picking one + * would be this wallet deciding what the protocol meant about an address. + */ + test("a mode that cannot be read refuses before any contract is compiled", async () => { + const { compiled, result } = review({ + action: "Withdraw", + instance: DEPLOYMENT, + manifest: { ...(groupedVaultlet as object), compile_debug_symbols: "yes" } as Record< + string, + unknown + >, + state: { utxos: [{ txid: "b".repeat(64), utxo_type: "vault", vout: 0 }] }, + }); + const reviewed = await result; + + expect(isRefusal(reviewed)).toBe(true); + expect(isRefusal(reviewed) ? reviewed.reason : "").toContain("neither on nor off"); + expect(compiled).toHaveLength(0); + }); +}); + +/** + * What the review hands on about a covenant is everything the covenant was built from. + * + * A module that spends this covenant compiles the contract again to satisfy it. A compile that + * differs in the source, the parameters, the leaves or the mode produces a different script, which + * the covenant's own execution rejects — after a person has approved a transaction the wallet had + * already checked. So all four travel, and the source travels as text: a path is a key into a + * request, and asking the request again is the second resolution this exists to prevent. + */ +describe("the derivation the review carries out", () => { + test("is everything the covenant was compiled from, with nothing left to look up", async () => { + const { compiled, result } = review(withdraw(groupedVaultlet)); + const reviewed = await result; + + if (isRefusal(reviewed)) { + throw new Error(reviewed.reason); + } + + const [found] = reviewed.covenants; + const [asked] = compiled; + + expect({ + argumentsJson: found?.argumentsJson, + extraLeavesJson: found?.extraLeavesJson, + includeDebugSymbols: found?.includeDebugSymbols, + source: found?.source, + }).toEqual({ + argumentsJson: asked?.argumentsJson ?? "", + extraLeavesJson: asked?.extraLeavesJson ?? "", + includeDebugSymbols: asked?.includeDebugSymbols ?? false, + source: asked?.source ?? "", + }); + }); + + /** + * Recompiling from the finding alone reproduces the same script. That is the property the + * finding exists for: nothing downstream reaches back into the request. + */ + test("recompiles to the same script without the request", async () => { + const { compiled, result } = review(withdraw(groupedVaultlet)); + const reviewed = await result; + + if (isRefusal(reviewed)) { + throw new Error(reviewed.reason); + } + + const found = reviewed.covenants[0]; + const rebuilt = compiled.find( + (call) => + call.source === found?.source && + call.argumentsJson === found.argumentsJson && + call.extraLeavesJson === found.extraLeavesJson && + call.includeDebugSymbols === found.includeDebugSymbols, + ); + + expect(rebuilt).toBeDefined(); + expect(found?.scriptPubKeyHex).toBe(DERIVED_SCRIPT); + }); + + test("names the path the document used it under, for a reader who has to find it", async () => { + const reviewed = await review(withdraw(groupedVaultlet)).result; + + expect(isRefusal(reviewed) ? [] : reviewed.covenants.map((f) => f.sourcePath)).toEqual([ + "./vault.simf", + ]); + }); +}); + +/** + * A compiler failing is a refusal, never a rejected promise. The review runs before the permission + * gate, so a caller that sees an exception here cannot tell a wallet that declined from a wallet + * that broke, and has nothing to show the person either way. + */ +describe("when the hash compiler fails", () => { + test("a compiler that throws becomes a refusal naming the field", async () => { + const { result } = review(openVault(groupedVaultlet), { + hashedBy: () => { + throw new Error("wasm module not loaded"); + }, + }); + const reviewed = await result; + + expect(isRefusal(reviewed)).toBe(true); + expect(isRefusal(reviewed) ? reviewed.reason : "").toContain("RESERVE_COV_HASH"); + }); + + test("a compiler returning something that is not a script becomes one too", async () => { + const { result } = review(openVault(groupedVaultlet), { hashedBy: () => "not hex" }); + const reviewed = await result; + + expect(isRefusal(reviewed)).toBe(true); + expect(isRefusal(reviewed) ? reviewed.reason : "").toContain("not bytes"); + }); + + /** The same, for the compiler that derives an address rather than a hash. */ + test("a covenant compiler that throws becomes a refusal naming the contract", async () => { + const reviewed = await reviewManifestAction( + { + broadcast: false, + contractSources: SOURCES, + params: {}, + ...withdraw(groupedVaultlet), + } as ParsedLiquidProcessCtParams, + { + compile: () => { + throw new Error("wasm module not loaded"); + }, + contractParamTypes: () => DECLARED, + fundingUtxos: FUNDING, + network: "liquid", + policyAsset: POLICY_ASSET, + readFeeRate: async () => 1000, + readTxOut: async () => ({ scriptPubKeyHex: DERIVED_SCRIPT }), + scriptPubKeyOf: () => DERIVED_SCRIPT, + walletScriptPubKeyHex: WALLET_SCRIPT, + }, + ); + + expect(isRefusal(reviewed)).toBe(true); + expect(isRefusal(reviewed) ? reviewed.reason : "").toContain("./vault.simf"); + }); +}); diff --git a/packages/tx-manifest/src/review/index.test.ts b/packages/tx-manifest/src/review/index.test.ts index 88d8dc3..29b4a5a 100644 --- a/packages/tx-manifest/src/review/index.test.ts +++ b/packages/tx-manifest/src/review/index.test.ts @@ -26,6 +26,8 @@ const ELSEWHERE_SCRIPT = `5120${"22".repeat(32)}`; const COMPILED = { address: DERIVED, scriptPubKeyHex: DERIVED_SCRIPT }; const compile = () => COMPILED; +/** The same compiler again, for the hashes a document works out for itself. */ +const scriptPubKeyOf = () => DERIVED_SCRIPT; /** The wallet's own side of the transaction: where it pays, what it holds, what a fee costs. */ const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; @@ -38,6 +40,7 @@ const readFeeRate = async () => 1000; /** What every case shares; individual tests override only what they exercise. */ const deps = { compile, + scriptPubKeyOf, fundingUtxos, network: "liquid", policyAsset: POLICY_ASSET, @@ -90,8 +93,13 @@ describe("reviewManifestAction", () => { expect(result.covenants).toEqual([ { address: DERIVED, + argumentsJson: JSON.stringify({ PUB_KEY: { type: "Pubkey", value: `0x${PUBKEY}` } }), + extraLeavesJson: "[]", + includeDebugSymbols: false, role: "created", scriptPubKeyHex: DERIVED_SCRIPT, + source: SOURCE, + sourcePath: SOURCE_PATH, utxoType: "p2pk_output", verified: "not-yet-on-chain", }, diff --git a/packages/tx-manifest/src/review/index.ts b/packages/tx-manifest/src/review/index.ts index 417bec6..3959aee 100644 --- a/packages/tx-manifest/src/review/index.ts +++ b/packages/tx-manifest/src/review/index.ts @@ -1,11 +1,26 @@ import type { ReadFeeRate, ReadTxOut } from "../chain/chainRead"; import { type CompileCovenant, + type ContractParamTypesOf, + type CovenantDerivation, covenantMatchesChain, deriveCovenantAddress, } from "../covenants/covenant"; +import { type CompileScriptPubKey, covenantHashFrom } from "../covenants/covenantHash"; import { declaredParamTypes } from "../covenants/declaredTypes"; +import { + type CreatedInstance, + createsInstance, + resolveCreatedInstance, +} from "../covenants/instance"; import { asArray, asRecord } from "../document/json"; +import { + findAction, + type NormalisationNote, + normaliseInstance, + normaliseManifest, +} from "../document/normalise"; +import type { ReferenceScope } from "../document/references"; import { covenantSites } from "../document/sites"; import { planAction } from "../evaluation/plan"; import type { ParsedLiquidProcessCtParams } from "../request/request"; @@ -15,16 +30,20 @@ import { type CoinSelection, type SelectableUtxo, selectCoins } from "./coinSele /** * 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. + * `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. + * + * Every input the covenant was built from travels with it — the source text, the parameters, the + * leaves and the build mode — rather than being left to be worked out again. Anything that goes + * on to spend or pay this covenant compiles the contract a second time to satisfy it, and a + * compile differing in any one of those produces a different script, which the covenant's own + * execution rejects after a person has approved a transaction the wallet had already checked. + * Resolving the request a second time would be a second answer to the same question, and nothing + * downstream could tell the two apart. */ -export type CovenantFinding = { - address: string; +export type CovenantFinding = CovenantDerivation & { 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"; }; @@ -34,8 +53,8 @@ export type ReviewedOutput = { * The asset this output pays in, as the chain writes the id. * * Carried rather than assumed, because a builder told only an amount pays it in whatever - * asset it defaults to. Every output this slice plans pays the network's own asset; the - * fact is still written down, because the builder is told it rather than left to guess. + * asset it defaults to. Every output this slice plans pays the network's own asset; the fact + * is still written down, because the builder is told it rather than left to guess. */ asset: string; id: string; @@ -47,21 +66,41 @@ export type ReviewedOutput = { /** * Everything the wallet established, worked out and decided — before anyone approves it. * - * An exact plan rather than a transaction: nothing here is a builder, a handle or an - * encoding, and reading it moves nothing. What it settles is every decision the wallet gets - * to make — which of its outputs pay, what each output pays and to which script, and at what - * rate — so that whoever drives a builder from it adds what is written here and decides - * nothing further. + * An exact plan rather than a transaction: nothing here is a builder, a handle or an encoding, + * and reading it moves nothing. What it settles is every decision the wallet gets to make — + * which of its outputs pay, what each output pays and to which script, and at what rate — so + * that whoever drives a builder from it adds what is written here and decides nothing further. * - * Settled before the confirmation rather than after it deliberately: what a person is asked - * to approve should be the plan that gets built, not a description of one that will be worked - * out again afterwards from the same inputs and might not match. + * Settled before the confirmation rather than after it deliberately: what a person is asked to + * approve should be the plan that gets built, not a description of one that will be worked out + * again afterwards from the same inputs and might not match. */ export type ManifestReview = { action: string; + /** + * The class this action is a method of, when the document declares it inside one. + * + * Carried because it is the difference between the two declaration shapes and it survives + * normalisation: a method reads the field values of one deployment of its class, and a free + * action reads none. Absent for an action declared at the top level. + */ + boundTo?: string; covenants: CovenantFinding[]; + /** + * The deployment this action brings into existence, when it creates one. + * + * Absent for every action that only spends what already exists. Present, it is the record of + * a contract that has no history yet: the wallet worked out each field, and every covenant + * this action creates was compiled from exactly these values. + * + * Reported rather than kept because the deployment outlives the transaction and half its + * fields are covenant script hashes — compiler output that nothing but a wallet can produce. + */ + createdInstance?: CreatedInstance; /** What the wallet will pay, established from the chain rather than from the request. */ feeRateSatsPerKvb: number; + /** Legacy spellings the document used, so the generation it came from can be reported. */ + normalisation: NormalisationNote[]; outputs: ReviewedOutput[]; protocol: string; /** The wallet's own outputs that fund this, chosen by the wallet. */ @@ -80,31 +119,41 @@ export function isRefusal(result: ReviewManifestActionResult): result is ReviewR * 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. + * 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. + * 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, an amount this runtime does not evaluate, a fee - * rate that could not be read, an account that cannot cover it. There is no return value - * meaning "probably fine". + * The order below is the whole of how a class method and a constructor differ. A method reads + * the field values of the deployment it belongs to and derives its covenants from them. A + * constructor has no deployment to read: it works one out — including the fields that are + * covenant script hashes, which have to be compiled to be known — and then derives the covenants + * it creates from what it worked out. Both end up deriving from an instance; only one of them + * was handed it. * - * The plan is settled here rather than after the confirmation: what a person is asked to - * approve should be what gets built, not a description of it worked out again afterwards from - * the same inputs. So this also plans the outputs, establishes the fee rate and selects the - * coins, and everything downstream builds exactly what came back. + * 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 value nobody supplied, a set of covenant hashes that never settle, a + * state file listing no such covenant, a chain that cannot be read, a covenant that does not + * match, an amount this runtime does not evaluate, a fee rate that could not be read, an account + * that cannot cover it. There is no return value meaning "probably fine". */ export async function reviewManifestAction( request: ParsedLiquidProcessCtParams, input: { compile: CompileCovenant; + /** + * What a contract declares about its own compile parameters. + * + * Optional because a document that wires every parameter to a name needs nothing from it. + * A document that writes one as a bare value and has no reader here is refused rather than + * built at a width nobody stated. + */ + contractParamTypes?: ContractParamTypesOf; /** The wallet's spendable outputs in the asset the network charges its fees in. */ fundingUtxos: SelectableUtxo[]; network: string; @@ -112,11 +161,40 @@ export async function reviewManifestAction( policyAsset: string; readFeeRate: ReadFeeRate; readTxOut: ReadTxOut; + /** + * The same compiler again, for the covenant hashes a document works out for itself. + * + * Separate from `compile` because a hash needs no address and no network, and because it + * is called inside a fixed point that must not be asynchronous — the number of rounds a + * document takes to settle is a fact about the document, not about scheduling. + */ + scriptPubKeyOf: CompileScriptPubKey; /** Where the wallet's own share of an action is paid, as a script rather than an address. */ walletScriptPubKeyHex: string; }, ): Promise { - const requirements = resolveActionRequirements(request); + const normalised = normaliseManifest(request.manifest); + const manifest = normalised.manifest; + const deployment = normaliseInstance(request.instance); + const notes: NormalisationNote[] = [...normalised.notes, ...deployment.notes]; + + const action = findAction(manifest, request.action); + + if (!action) { + return { reason: `The manifest declares no action named "${request.action}".`, refused: true }; + } + + // The mode this protocol says its contracts were built in, before anything is compiled. It + // changes the commitment root and therefore both every covenant address and every covenant + // hash the document computes, so a statement that cannot be read is a refusal rather than a + // default: building the other way derives a well-formed address for a different contract. + const buildMode = manifest.buildMode; + + if (!buildMode.ok) { + return { reason: buildMode.reason, refused: true }; + } + + const requirements = resolveActionRequirements(request, manifest, action); if (requirements.missing.length > 0) { const named = requirements.missing @@ -126,24 +204,41 @@ export async function reviewManifestAction( 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 declaredTypes = declaredParamTypes(manifest, action); + const hashCovenant = covenantHashFrom(input.scriptPubKeyOf, buildMode.includeDebugSymbols); const covenants: CovenantFinding[] = []; - for (const site of covenantSites(action)) { + /** + * What a name means while this action is being read. + * + * The deployment's fields as they arrived, which is all a method ever has and all a + * constructor starts with. The constructor's own fields are folded in below, once they have + * been worked out. + */ + let scope: ReferenceScope = { instance: deployment.instance.fields, params: request.params }; + + // The covenants this action spends, which are the ones there is something on chain to compare + // against. They are derived first because a spent covenant is named by an input and a created + // one by an output, so the declared order already puts every spent site first — and because + // nothing a constructor works out can change what an existing deployment is locked by. + for (const site of covenantSites(action.node).filter((declared) => declared.role === "spent")) { // 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. + // contracts and send chain reads for covenants after the answer is already known, and would + // make which refusal a person is shown depend on which request finished first instead of on + // the order the manifest declares. // oxlint-disable-next-line no-await-in-loop - const derived = await deriveCovenantAddress(request, { + const derived = await deriveCovenantAddress(manifest, { compile: input.compile, + ...(input.contractParamTypes === undefined + ? {} + : { contractParamTypes: input.contractParamTypes }), + contractSources: request.contractSources, declaredTypes, + includeDebugSymbols: buildMode.includeDebugSymbols, network: input.network, + notes, + scope, utxoType: site.utxoType, wiring: site.wiring, }); @@ -152,29 +247,17 @@ export async function reviewManifestAction( 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); + const outpoint = stateOutpoint(request, site.utxoType); if (!outpoint) { - return { reason: `The state file lists no ${utxoType} to spend.`, refused: true }; + return { reason: `The state file lists no ${site.utxoType} to spend.`, refused: true }; } let onChain; try { + // Same loop, same reason: the first refusal is the answer, so nothing after it is worth + // reading. // oxlint-disable-next-line no-await-in-loop onChain = await input.readTxOut(outpoint); } catch (error) { @@ -190,25 +273,69 @@ export async function reviewManifestAction( return { reason: matched.reason, refused: true }; } - covenants.push({ - address, - role: "spent", - scriptPubKeyHex, - utxoType, - verified: "matches-chain", + covenants.push({ ...derived.derivation, role: "spent", verified: "matches-chain" }); + } + + // The deployment this action creates, worked out before anything is derived from it. Its + // covenant-hash fields are compiled here rather than asked for, because nothing but a wallet + // can produce one — and they are worked out together rather than in an order, because one may + // name another and the format offers no way to say which comes first. + const created = createsInstance(action) + ? resolveCreatedInstance(action, { + contractSources: request.contractSources, + hashCovenant, + notes, + scope, + }) + : undefined; + + if (created && !created.ok) { + return { reason: created.reason, refused: true }; + } + + if (created) { + scope = { ...scope, instance: { ...scope.instance, ...created.instance.fields } }; + } + + // The covenants this action creates, derived once the deployment they are compiled with is + // complete. There is nothing on chain to compare them against — that is what creating one + // means — so the wallet reports what it derived and that it derived it, which is a different + // fact from a check that passed rather than a weaker one. + for (const site of covenantSites(action.node).filter((declared) => declared.role === "created")) { + // Sequential for the same reason the loop above is: the first refusal is the answer. + // oxlint-disable-next-line no-await-in-loop + const derived = await deriveCovenantAddress(manifest, { + compile: input.compile, + ...(input.contractParamTypes === undefined + ? {} + : { contractParamTypes: input.contractParamTypes }), + contractSources: request.contractSources, + declaredTypes, + includeDebugSymbols: buildMode.includeDebugSymbols, + network: input.network, + notes, + scope, + utxoType: site.utxoType, + wiring: site.wiring, }); + + if (!derived.ok) { + return { reason: derived.reason, refused: true }; + } + + covenants.push({ ...derived.derivation, role: "created", verified: "not-yet-on-chain" }); } - const plan = planAction(request, action); + const plan = planAction(action.node, scope, notes); if (!plan.ok) { return { reason: plan.reason, refused: true }; } - // Keyed by the script rather than by the address. They are two spellings of one fact, and - // only one of them is hex: a builder hex-decodes every output script it is given, so - // handing it a bech32 address fails inside the module with an error naming neither the - // output nor what was wrong with it. + // Keyed by the script rather than by the address. They are two spellings of one fact, and only + // one of them is hex: a builder hex-decodes every output script it is given, so handing it a + // bech32 address fails inside the module with an error naming neither the output nor what was + // wrong with it. const covenantScripts = new Map( covenants.map((found) => [found.utxoType, found.scriptPubKeyHex]), ); @@ -219,8 +346,8 @@ export async function reviewManifestAction( continue; } - // A covenant output pays the script the wallet derived, never one the request - // supplied. There is no path from a site-supplied address to a transaction output. + // A covenant output pays the script the wallet derived, never one the request supplied. + // There is no path from a site-supplied address to a transaction output. const scriptPubKeyHex = planned.target.kind === "covenant" ? covenantScripts.get(planned.target.utxoType) @@ -259,10 +386,13 @@ export async function reviewManifestAction( return { action: request.action, + ...(action.boundTo === undefined ? {} : { boundTo: action.boundTo }), covenants, + ...(created === undefined ? {} : { createdInstance: created.instance }), feeRateSatsPerKvb, + normalisation: notes, outputs, - protocol: typeof request.manifest.protocol === "string" ? request.manifest.protocol : "", + protocol: manifest.protocol ?? "", selected: selection.selected, }; } @@ -273,9 +403,9 @@ const FEE_TARGET_BLOCKS = 6; /** * What to over-select by so the finished transaction can pay its own fee. * - * The real fee comes from the assembled transaction's weight, which does not exist until - * after selection. A small transaction is on the order of a kilo-vbyte, so one kvb at the - * chosen rate covers it with room to spare, and whatever is left over comes back as change. + * The real fee comes from the assembled transaction's weight, which does not exist until after + * selection. A small transaction is on the order of a kilo-vbyte, so one kvb at the chosen rate + * covers it with room to spare, and whatever is left over comes back as change. */ function feeHeadroomSats(feeRateSatsPerKvb: number): bigint { return BigInt(Math.ceil(feeRateSatsPerKvb)); @@ -284,9 +414,8 @@ function feeHeadroomSats(feeRateSatsPerKvb: number): bigint { /** * 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. + * 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,