diff --git a/.github/actions/build-smplx-wasm/action.yml b/.github/actions/build-smplx-wasm/action.yml
new file mode 100644
index 0000000..782c946
--- /dev/null
+++ b/.github/actions/build-smplx-wasm/action.yml
@@ -0,0 +1,71 @@
+name: Build smplx_wasm
+description: >-
+ Build the smplx_wasm WASM package from the vendored `smplx` git submodule so the
+ `file:smplx/crates/wasm/pkg` dependency resolves. Like `lwk_wasm/pkg`, it is a wasm-pack
+ build artifact that is not committed, so it must be produced in CI. The output is cached
+ by the pinned submodule commit, so the Rust build only runs when the submodule bumps.
+ Requires the repo to be checked out with `submodules: recursive`.
+
+ The fork's build script needs a C compiler with a WebAssembly backend — Apple's system
+ clang has none, and neither does a bare ubuntu runner without LLVM's clang on PATH, which
+ is why one is installed rather than assumed. Without it the build fails inside
+ `secp256k1-sys` and `simplicity-sys` with "unable to create target", which points at the
+ crates and misleads.
+
+runs:
+ using: composite
+ steps:
+ - name: Resolve pinned smplx commit
+ id: smplx
+ shell: bash
+ run: echo "sha=$(git rev-parse HEAD:smplx)" >> "$GITHUB_OUTPUT"
+
+ - name: Restore built smplx pkg
+ id: pkg-cache
+ uses: actions/cache@v4
+ with:
+ path: smplx/crates/wasm/pkg
+ key: smplx-wasm-${{ runner.os }}-${{ steps.smplx.outputs.sha }}
+
+ # 1.91.0 because `crates/simplex` declares it as the workspace's minimum, and the
+ # build tree demands it independently: `ar_archive_writer` reached through wasm-pack
+ # requires 1.88.0. An earlier pin of 1.85.0 built locally on a newer toolchain and
+ # failed here on the runner's, which is the whole reason to pin rather than inherit.
+ - name: Install Rust toolchain (1.91.0 + wasm32)
+ if: steps.pkg-cache.outputs.cache-hit != 'true'
+ uses: dtolnay/rust-toolchain@master
+ with:
+ toolchain: "1.91.0"
+ targets: wasm32-unknown-unknown
+
+ - name: Cache cargo registry + build
+ if: steps.pkg-cache.outputs.cache-hit != 'true'
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.cargo/registry/index
+ ~/.cargo/registry/cache
+ ~/.cargo/git/db
+ smplx/target
+ key: cargo-smplx-${{ runner.os }}-${{ steps.smplx.outputs.sha }}
+ restore-keys: |
+ cargo-smplx-${{ runner.os }}-
+
+ - name: Install wasm-pack
+ if: steps.pkg-cache.outputs.cache-hit != 'true'
+ uses: jetli/wasm-pack-action@v0.4.0
+
+ # Installed rather than exported: the fork's build script already searches for a clang
+ # with a WebAssembly backend and fails with a clear message when there is none, so
+ # putting one on PATH keeps that guard rather than bypassing it.
+ - name: Install a C compiler with a WebAssembly backend
+ if: steps.pkg-cache.outputs.cache-hit != 'true'
+ shell: bash
+ run: |
+ sudo apt-get update
+ sudo apt-get install --no-install-recommends -y clang llvm
+
+ - name: Build smplx_wasm
+ if: steps.pkg-cache.outputs.cache-hit != 'true'
+ shell: bash
+ run: smplx/crates/wasm/build.sh
diff --git a/.github/workflows/build-extension.yml b/.github/workflows/build-extension.yml
index 855d1d5..46c6e56 100644
--- a/.github/workflows/build-extension.yml
+++ b/.github/workflows/build-extension.yml
@@ -25,7 +25,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- - name: Checkout (with the lwk submodule)
+ - name: Checkout (with the lwk and smplx submodules)
uses: actions/checkout@v4
with:
submodules: recursive
@@ -38,6 +38,11 @@ jobs:
with:
profile: ${{ inputs.profile }}
+ # The manifest runtime compiles Simplicity covenants and assembles transactions with
+ # this module, so the extension does not build without the package either.
+ - name: Build smplx_wasm
+ uses: ./.github/actions/build-smplx-wasm
+
- name: Install dependencies
uses: ./.github/actions/install
diff --git a/.github/workflows/deploy-storybook.yml b/.github/workflows/deploy-storybook.yml
index 21338a8..1cf8a59 100644
--- a/.github/workflows/deploy-storybook.yml
+++ b/.github/workflows/deploy-storybook.yml
@@ -47,6 +47,15 @@ jobs:
restore-keys: |
${{ runner.os }}-bun-
+ # This checkout has no submodules and Storybook imports neither wasm package, so the
+ # two local `file:` dependencies are given a package at their path rather than built.
+ # Without one at `smplx/crates/wasm/pkg`, `bun install` cannot resolve the workspace.
+ - name: Prepare unused wasm dependencies
+ run: |
+ mkdir -p lwk/lwk_wasm/pkg smplx/crates/wasm/pkg
+ printf '{"name":"lwk_wasm","version":"0.0.0","type":"module"}\n' > lwk/lwk_wasm/pkg/package.json
+ printf '{"name":"smplx-wasm","version":"0.0.0","type":"module"}\n' > smplx/crates/wasm/pkg/package.json
+
- name: Install dependencies
run: bun install --frozen-lockfile
diff --git a/.github/workflows/deploy-web-netlify.yml b/.github/workflows/deploy-web-netlify.yml
index 19c7f1a..2f84253 100644
--- a/.github/workflows/deploy-web-netlify.yml
+++ b/.github/workflows/deploy-web-netlify.yml
@@ -27,6 +27,15 @@ jobs:
mkdir -p lwk/lwk_wasm/pkg
printf '{"name":"lwk_wasm","version":"0.0.0","type":"module"}\n' > lwk/lwk_wasm/pkg/package.json
+ # Same again for `smplx-wasm`, added for the manifest runtime the extension ships and
+ # likewise not imported by the web app. This checkout has no submodules, so without a
+ # package at that path `bun install` cannot resolve the workspace and the deploy fails
+ # on a dependency nothing here builds with.
+ - name: Prepare unused smplx-wasm dependency
+ run: |
+ mkdir -p smplx/crates/wasm/pkg
+ printf '{"name":"smplx-wasm","version":"0.0.0","type":"module"}\n' > smplx/crates/wasm/pkg/package.json
+
- name: Install dependencies
uses: ./.github/actions/install
diff --git a/.github/workflows/tx-manifest-check.yml b/.github/workflows/tx-manifest-check.yml
new file mode 100644
index 0000000..140057a
--- /dev/null
+++ b/.github/workflows/tx-manifest-check.yml
@@ -0,0 +1,85 @@
+name: tx-manifest / smplx check
+
+# The gate for the tx-manifest package and the smplx adapter, and only for those.
+#
+# Deliberately not `bun run check`. On a clean frozen install against the pinned
+# submodules, the repository-wide typecheck fails in existing UI code on duplicate React
+# and Zod type families reached through separate dependency trees — a problem that predates
+# this work and that is repaired later on this branch. Running the full gate here would
+# report those failures against every change to the manifest runtime and say nothing about
+# it. So this checks what this slice owns, in full, and the root gate is restored in the
+# activation slice once the cumulative branch carries the dependency repairs.
+on:
+ push:
+ branches: ["**"]
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ steps:
+ # Recursive, and both wasm packages are built before anything else runs. Neither is
+ # optional: `lwk_wasm` and `smplx-wasm` are local `file:` dependencies, so on a
+ # checkout without them the frozen install cannot resolve the workspace, the
+ # typechecks have no declarations to read, and the two test files that drive the real
+ # smplx module rather than a substitute fail on
+ # `Cannot find module 'smplx-wasm/smplx_wasm_bg.js'` — driving the real one is the
+ # point of them.
+ - name: Checkout (with the lwk and smplx submodules)
+ uses: actions/checkout@v4
+ with:
+ submodules: recursive
+
+ - name: Setup Bun
+ uses: ./.github/actions/setup-bun
+
+ # dev rather than release: this job checks code, and an unoptimised wasm builds
+ # faster. The release profile belongs to the build workflow, which ships the result.
+ - name: Build lwk_wasm
+ uses: ./.github/actions/build-lwk-wasm
+ with:
+ profile: dev
+
+ - name: Build smplx_wasm
+ uses: ./.github/actions/build-smplx-wasm
+
+ - name: Install dependencies
+ uses: ./.github/actions/install
+
+ - name: Typecheck packages
+ run: bun run typecheck:packages
+
+ # The extension half of this slice, typechecked by naming its files rather than by
+ # running the root project over `apps/extension/src`. `--ignoreConfig` is what makes
+ # that possible: the root `tsconfig.json` would pull in the whole app and with it the
+ # baseline failures above, so the flags this surface actually needs are stated here
+ # instead. The two ambient declaration files are named because nothing imports them —
+ # they are what make `smplx-wasm/smplx_wasm_bg.js` and `bun:test` resolvable.
+ - name: Typecheck the smplx adapter
+ run: |
+ bunx tsc --ignoreConfig --noEmit --strict \
+ --module ESNext --moduleResolution bundler --target ESNext \
+ --lib ESNext,DOM --types bun-types --skipLibCheck \
+ apps/extension/src/vite-env.d.ts \
+ apps/extension/src/bun-test-env.d.ts \
+ apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.ts \
+ apps/extension/src/core/chains/liquid/adapters/smplx/compileCovenantWithSmplx.ts \
+ apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.ts \
+ apps/extension/src/core/chains/liquid/adapters/smplx/smplxWasmForTests.ts \
+ apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts \
+ apps/extension/src/core/chains/liquid/adapters/smplx/compileCovenantWithSmplx.test.ts \
+ apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.test.ts
+
+ # Both are repository-wide and both pass on this branch, so they are run as they are
+ # rather than narrowed to these paths.
+ - name: Lint
+ run: bun run lint
+
+ - name: Check formatting
+ run: bun run format:check
+
+ - name: Test
+ run: bun test packages/tx-manifest apps/extension/src/core/chains/liquid/adapters/smplx
diff --git a/.gitmodules b/.gitmodules
index e257c04..96e6c4b 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -2,3 +2,7 @@
path = lwk
url = https://github.com/lukachi/lwk.git
branch = humid/esplora-backend-config
+[submodule "smplx"]
+ path = smplx
+ url = https://github.com/lukachi/smplx.git
+ branch = dev
diff --git a/.oxfmtrc.json b/.oxfmtrc.json
index 43a82e5..0a5cb7f 100644
--- a/.oxfmtrc.json
+++ b/.oxfmtrc.json
@@ -10,9 +10,15 @@
"sortTailwindcss": true,
"ignorePatterns": [
"lwk/**",
+ "smplx/**",
"**/__fixtures__/**",
"AGENTS.md",
"CLAUDE.md",
+ "PROJECT_WORKFLOW.md",
+ "skills-lock.json",
+ ".workflow/**",
+ ".claude/**",
+ ".agents/**",
"README.md",
"CHANGELOG.md",
"dist/**",
diff --git a/.oxlintrc.json b/.oxlintrc.json
index be13118..7ebce94 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -37,6 +37,7 @@
},
"ignorePatterns": [
"lwk/**",
+ "smplx/**",
"dist/**",
"build/**",
"node_modules/**",
diff --git a/apps/extension/src/bun-test-env.d.ts b/apps/extension/src/bun-test-env.d.ts
new file mode 100644
index 0000000..4f3852b
--- /dev/null
+++ b/apps/extension/src/bun-test-env.d.ts
@@ -0,0 +1,16 @@
+///
+
+// Makes `bun:test` resolvable to `tsc`, which the test files import from.
+//
+// `@types/bun` re-exports `bun-types` and is supposed to be picked up automatically,
+// but it is not under this project's configuration, so the reference is stated once
+// here rather than repeated at the top of every test file — the same arrangement
+// `vite-env.d.ts` already uses for Vite's ambient types.
+//
+// No `export {}` below, deliberately: this file is a declaration script rather than a
+// module, which is what a bare triple-slash reference wants to be. An empty export would
+// make it a module whose only statement exports nothing.
+//
+// Side effect worth knowing: the reference makes Bun's globals visible to application
+// code, which does not run under Bun. Reach for a browser or extension API there,
+// not `Bun.*`.
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
new file mode 100644
index 0000000..740f314
--- /dev/null
+++ b/apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.test.ts
@@ -0,0 +1,326 @@
+import { describe, expect, test } from "bun:test";
+
+import type { ManifestReview } from "@humid/tx-manifest";
+
+import {
+ type AssembledTransaction,
+ type AssemblingBuilder,
+ assembleReviewedTransaction,
+} from "./assembleReviewedTransaction";
+import type { SmplxWasmModule } from "./loadSmplxWasm";
+
+// A substitute rather than the real module, because what is under test is what this assembles
+// and what it releases, not what the module makes of it. Its method names and shapes are the
+// real binding's — `loadSmplxWasm.test.ts` is what holds that claim true — so a substitute that
+// accepted anything could not let a call the real module refuses pass unnoticed.
+
+const COVENANT_SCRIPT = `5120${"11".repeat(32)}`;
+const WALLET_SCRIPT = `0014${"33".repeat(20)}`;
+const CHANGE_SCRIPT = `0014${"44".repeat(20)}`;
+const ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49";
+const SIGNED: AssembledTransaction = { feeSats: 300n, hex: "02000000", txid: "f".repeat(64) };
+// A P2WPKH output consensus-encoded, which is what the real builder decodes and what the
+// wallet's own snapshot already holds for an output it can spend.
+const TXOUT_HEX = `01${"49".repeat(32)}0100000000000186a000160014${"00".repeat(20)}`;
+
+type Recorded = {
+ changes: { blindingKey: string | null | undefined; script: string }[];
+ freed: number;
+ outputs: { asset: string; sats: bigint; script: string }[];
+ spends: { txOut: string; txid: string; vout: number }[];
+};
+
+// Narrow — it stands in for the four methods this module calls and nothing else — but exact
+// for each of them. A substitute that drops an argument is a substitute that cannot fail when
+// the wrong value is passed in it, which is how a bech32 address reached the real builder
+// unremarked. `loadSmplxWasm.test.ts` is what holds these signatures to the real binding.
+function substitute(recorded: Recorded): Pick {
+ return {
+ TransactionBuilder: class {
+ addChange(script: string, blindingKey?: string | null) {
+ recorded.changes.push({ blindingKey, script });
+ }
+ addOutput(script: string, sats: bigint, asset: string) {
+ recorded.outputs.push({ asset, sats, script });
+ }
+ // The encoded output is recorded with the outpoint, because the module needs all three
+ // and a substitute that ignores one cannot notice it going missing.
+ addWalletInput(txid: string, vout: number, txOut: string) {
+ recorded.spends.push({ txOut, txid, vout });
+ }
+ // Held across the wasm boundary, so the module under test releases it. A substitute
+ // without this passes only because nothing checked that it was released.
+ free() {
+ recorded.freed += 1;
+ }
+ },
+ } as unknown as Pick;
+}
+
+function review(overrides: Partial = {}): ManifestReview {
+ return {
+ action: "Pay",
+ covenants: [
+ {
+ address: "tex1p_derived",
+ role: "created",
+ scriptPubKeyHex: COVENANT_SCRIPT,
+ utxoType: "p2pk_output",
+ verified: "not-yet-on-chain",
+ },
+ ],
+ feeRateSatsPerKvb: 1000,
+ outputs: [{ asset: ASSET, id: "p2pk_out", sats: 50_000n, scriptPubKeyHex: COVENANT_SCRIPT }],
+ protocol: "p2pk-simplicity",
+ selected: [
+ { amount: "1000000", spendable: true, txOut: TXOUT_HEX, txid: "c".repeat(64), vout: 0 },
+ ],
+ ...overrides,
+ };
+}
+
+function subject(overrides: Partial = {}, finalize = () => SIGNED) {
+ const recorded: Recorded = { changes: [], freed: 0, outputs: [], spends: [] };
+
+ return {
+ assemble: () =>
+ assembleReviewedTransaction(review(overrides), {
+ changeScriptPubKeyHex: CHANGE_SCRIPT,
+ finalize,
+ smplx: substitute(recorded),
+ }),
+ recorded,
+ };
+}
+
+describe("assembleReviewedTransaction", () => {
+ // The outpoint says which output; the encoding says what is in it. The module takes all
+ // three and cannot read the third off the chain, so passing two is a transaction it
+ // refuses — or worse, one it balances against an amount nobody supplied.
+ test("spends exactly the wallet outputs the review selected, with what each holds", async () => {
+ const { assemble, recorded } = subject();
+
+ await assemble();
+
+ expect(recorded.spends).toEqual([{ txOut: TXOUT_HEX, txid: "c".repeat(64), vout: 0 }]);
+ });
+
+ test("pays exactly the outputs the review planned, in the asset it worked out", async () => {
+ const { assemble, recorded } = subject();
+
+ await assemble();
+
+ expect(recorded.outputs).toEqual([{ asset: ASSET, sats: 50_000n, script: COVENANT_SCRIPT }]);
+ });
+
+ // The builder hex-decodes every script it is given, so an address reaching it fails inside
+ // the module with an error naming neither the output nor what was wrong with it.
+ test("every output script is hex the builder can decode", async () => {
+ const { assemble, recorded } = subject();
+
+ await assemble();
+
+ expect(recorded.outputs.length).toBeGreaterThan(0);
+
+ for (const output of recorded.outputs) {
+ expect(output.script).toMatch(/^(?:[0-9a-fA-F]{2})+$/);
+ }
+ });
+
+ // Where change goes is the wallet's, and unset the module returns it to whichever address
+ // the signer derives — a decision made somewhere the wallet cannot see it.
+ test("returns change to the script the caller named, and to nothing else", async () => {
+ const { assemble, recorded } = subject();
+
+ await assemble();
+
+ expect(recorded.changes).toEqual([{ blindingKey: undefined, script: CHANGE_SCRIPT }]);
+ });
+
+ // Nothing in this slice reads what the document wants hidden, so change is returned in the
+ // open rather than hidden against a guess at the answer.
+ test("passes no blinding key with the change", async () => {
+ const { assemble, recorded } = subject();
+
+ await assemble();
+
+ expect(recorded.changes[0]?.blindingKey).toBeUndefined();
+ });
+
+ test("hands the finalizer the rate the review established, and returns what it made", async () => {
+ const rates: number[] = [];
+ const { assemble } = subject({}, ((_builder: AssemblingBuilder, rate: number) => {
+ rates.push(rate);
+
+ return SIGNED;
+ }) as () => AssembledTransaction);
+
+ const result = await assemble();
+
+ expect(rates).toEqual([1000]);
+ expect(result).toEqual({ ok: true, transaction: SIGNED });
+ });
+
+ // Nothing here acquires a mnemonic, builds a signer or signs. The one thing that can is
+ // the caller's, which is what lets assembly be reviewed without a credential in reach.
+ test("signs nothing itself: the finalizer is the only thing that finishes a transaction", async () => {
+ let finalized = 0;
+ const { assemble } = subject({}, () => {
+ finalized += 1;
+
+ return SIGNED;
+ });
+
+ await assemble();
+
+ expect(finalized).toBe(1);
+ });
+
+ describe("what it releases", () => {
+ test("releases the builder once the transaction is finished", async () => {
+ const { assemble, recorded } = subject();
+
+ await assemble();
+
+ expect(recorded.freed).toBe(1);
+ });
+
+ // A refused action that leaks a builder leaks wasm memory a collector cannot see.
+ test("releases the builder when the finalizer fails", async () => {
+ const { assemble, recorded } = subject({}, () => {
+ throw new Error("could not balance");
+ });
+
+ const result = await assemble();
+
+ expect(result).toMatchObject({ ok: false });
+ expect(recorded.freed).toBe(1);
+
+ if (!result.ok) {
+ expect(result.reason).toContain("could not balance");
+ }
+ });
+
+ test("releases the builder when an output the module will not take throws", async () => {
+ const recorded: Recorded = { changes: [], freed: 0, outputs: [], spends: [] };
+ const smplx = {
+ TransactionBuilder: class {
+ addChange() {}
+ addOutput() {
+ throw new Error("Invalid script: Odd number of digits");
+ }
+ addWalletInput() {}
+ free() {
+ recorded.freed += 1;
+ }
+ },
+ } as unknown as Pick;
+
+ const result = await assembleReviewedTransaction(review(), {
+ changeScriptPubKeyHex: CHANGE_SCRIPT,
+ finalize: () => SIGNED,
+ smplx,
+ });
+
+ expect(result).toMatchObject({ ok: false });
+ expect(recorded.freed).toBe(1);
+ });
+
+ // A change script the module will not decode fails the same way an output does, and
+ // after every input has already been added.
+ test("releases the builder when the change script is refused", async () => {
+ const recorded: Recorded = { changes: [], freed: 0, outputs: [], spends: [] };
+ let finalized = 0;
+ const smplx = {
+ TransactionBuilder: class {
+ addChange() {
+ throw new Error("Invalid script: Odd number of digits");
+ }
+ addOutput() {}
+ addWalletInput() {}
+ free() {
+ recorded.freed += 1;
+ }
+ },
+ } as unknown as Pick;
+
+ const result = await assembleReviewedTransaction(review(), {
+ changeScriptPubKeyHex: "tex1q_wallet",
+ finalize: () => {
+ finalized += 1;
+
+ return SIGNED;
+ },
+ smplx,
+ });
+
+ expect(result).toMatchObject({ ok: false });
+ expect(recorded.freed).toBe(1);
+ // Nothing is signed once the transaction could not be finished being assembled.
+ expect(finalized).toBe(0);
+ });
+ });
+
+ describe("what it will not build", () => {
+ // Receive spends the covenant, and this wallet has neither the amount reference its
+ // output needs nor the signing witness its spend needs. Building the rest of it would be
+ // a transaction the covenant refuses at execution, after a person approved it.
+ test("refuses an action that spends a covenant rather than building part of it", async () => {
+ const { assemble, recorded } = subject({
+ action: "Receive",
+ covenants: [
+ {
+ address: "tex1p_derived",
+ role: "spent",
+ scriptPubKeyHex: COVENANT_SCRIPT,
+ utxoType: "p2pk_output",
+ verified: "matches-chain",
+ },
+ ],
+ });
+
+ const result = await assemble();
+
+ expect(result).toMatchObject({ ok: false });
+ expect(recorded.spends).toEqual([]);
+ expect(recorded.outputs).toEqual([]);
+
+ if (!result.ok) {
+ expect(result.reason).toContain("p2pk_output");
+ }
+ });
+
+ test("refuses when nothing of the wallet's funds it", async () => {
+ const { assemble } = subject({ selected: [] });
+
+ expect(await assemble()).toMatchObject({ ok: false });
+ });
+
+ test("refuses when there is nothing to pay", async () => {
+ const { assemble } = subject({ outputs: [] });
+
+ expect(await assemble()).toMatchObject({ ok: false });
+ });
+
+ test("builds nothing at all when it refuses", async () => {
+ const { assemble, recorded } = subject({ selected: [] });
+
+ await assemble();
+
+ expect(recorded.freed).toBe(0);
+ expect(recorded.outputs).toEqual([]);
+ });
+ });
+
+ // The wallet's own output is one the review derived from an address, not one this reads
+ // off a signer. Deriving a script from an address is public work.
+ test("pays a wallet output the script the review derived", async () => {
+ const { assemble, recorded } = subject({
+ outputs: [{ asset: ASSET, id: "received_out", sats: 10n, scriptPubKeyHex: WALLET_SCRIPT }],
+ });
+
+ await assemble();
+
+ expect(recorded.outputs).toEqual([{ asset: ASSET, sats: 10n, script: WALLET_SCRIPT }]);
+ });
+});
diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.ts
new file mode 100644
index 0000000..4958cd5
--- /dev/null
+++ b/apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.ts
@@ -0,0 +1,124 @@
+import type { ManifestReview } from "@humid/tx-manifest";
+
+import type { SmplxWasmModule } from "./loadSmplxWasm";
+
+/** A transaction that has been balanced, blinded, signed and finalised, as plain facts. */
+export type AssembledTransaction = {
+ feeSats: bigint;
+ hex: string;
+ txid: string;
+};
+
+/**
+ * The transaction under assembly, as this module drives it.
+ *
+ * Named rather than taken from the module's own type so what is used is visible: this adds
+ * inputs and outputs and nothing else. Everything that needs a key happens on the other side
+ * of `FinalizeTransaction`.
+ */
+export type AssemblingBuilder = InstanceType;
+
+/**
+ * Turns an assembled transaction into a finished one.
+ *
+ * A seam rather than a step, because the real one blinds, signs and finalises in a single
+ * atomic call on a signer built from a mnemonic. Whoever holds that credential owns this
+ * function; this module never acquires one, so it can assemble a transaction without being
+ * able to sign it — which is what makes assembly reviewable on its own.
+ *
+ * It also owns whatever the module hands back: the finished transaction is a handle across
+ * the wasm boundary, and only the caller that made it knows when it is done with it.
+ */
+export type FinalizeTransaction = (
+ builder: AssemblingBuilder,
+ feeRateSatsPerKvb: number,
+) => AssembledTransaction | Promise;
+
+export type AssembleResult =
+ | { ok: false; reason: string }
+ | { ok: true; transaction: AssembledTransaction };
+
+/**
+ * Builds the transaction from the plan the review settled, and hands it to the caller's
+ * finalizer.
+ *
+ * Everything the document decides comes from the review: which of the wallet's outputs fund
+ * this, what each output pays and in which asset, and at what rate the fee is worked out.
+ * This interprets none of it — a module that re-read the document here would be building
+ * something nobody was shown.
+ *
+ * Where change goes is the one fact that does not come from there, and is passed in beside
+ * it. A site has no say in it and a review has no business carrying it: it is the wallet's
+ * own address, and only the caller knows which one. Left unset the module returns change to
+ * whichever address the signer happens to derive, which is a wallet-owned decision made
+ * somewhere the wallet cannot see — so it is stated rather than defaulted.
+ *
+ * The builder is a handle across the wasm boundary and is released on every path, including
+ * the ones where an input the module will not take throws part-way through and the one where
+ * the finalizer itself fails. Left to a collector that does not know it holds wasm memory,
+ * a refused action leaks a transaction.
+ */
+export async function assembleReviewedTransaction(
+ review: ManifestReview,
+ input: {
+ /**
+ * Where this transaction's change goes, as a script rather than an address.
+ *
+ * The wallet's own, supplied by the caller. No blinding key goes with it in this slice:
+ * what an output hides is a decision the document makes and this runtime does not read
+ * yet, so change is returned in the open rather than hidden on a guess.
+ */
+ changeScriptPubKeyHex: string;
+ finalize: FinalizeTransaction;
+ smplx: Pick;
+ },
+): Promise {
+ // A covenant being spent needs the source, the arguments and the witness the review
+ // verified it under, and a signature over this transaction for the branch that asserts
+ // one. None of that is established yet, and a transaction assembled without it is not a
+ // smaller version of the right one — it is one the covenant refuses at execution, after
+ // a person has approved it. So it refuses here, where the reason can be read.
+ const spent = review.covenants.find((covenant) => covenant.role === "spent");
+
+ if (spent) {
+ return {
+ ok: false,
+ reason:
+ `"${review.action}" spends the ${spent.utxoType} covenant, and this wallet cannot ` +
+ "yet satisfy a covenant input. It will not build part of the transaction and call it whole.",
+ };
+ }
+
+ if (review.selected.length === 0) {
+ return { ok: false, reason: `"${review.action}" has no wallet output funding it.` };
+ }
+
+ if (review.outputs.length === 0) {
+ return { ok: false, reason: `"${review.action}" pays nothing, so there is nothing to build.` };
+ }
+
+ const builder = new input.smplx.TransactionBuilder();
+
+ try {
+ for (const utxo of review.selected) {
+ builder.addWalletInput(utxo.txid, utxo.vout, utxo.txOut);
+ }
+
+ // Paid in the asset the review worked out for it, and to the script it derived. An
+ // output built from an amount alone pays whatever asset the module defaults to, and one
+ // built from an address is not hex the module can decode.
+ for (const output of review.outputs) {
+ builder.addOutput(output.scriptPubKeyHex, output.sats, output.asset);
+ }
+
+ // Set on the builder rather than passed to the call that signs, because where change
+ // goes is a fact about this transaction and not about the signature over it.
+ builder.addChange(input.changeScriptPubKeyHex);
+
+ return { ok: true, transaction: await input.finalize(builder, review.feeRateSatsPerKvb) };
+ } catch (error) {
+ return { ok: false, reason: `This transaction could not be assembled: ${String(error)}` };
+ } finally {
+ builder.free();
+ }
+}
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
new file mode 100644
index 0000000..23d3ad8
--- /dev/null
+++ b/apps/extension/src/core/chains/liquid/adapters/smplx/compileCovenantWithSmplx.test.ts
@@ -0,0 +1,119 @@
+import { describe, expect, test } from "bun:test";
+
+import { createSmplxCovenantCompiler } from "./compileCovenantWithSmplx";
+import type { SmplxWasmModule } from "./loadSmplxWasm";
+import { smplx } from "./smplxWasmForTests";
+
+const PROBE_SOURCE = "fn main() { assert!(jet::eq_32(witness::A, witness::B)); }";
+
+/** A substitute that counts what it was asked to release, since the real one cannot say. */
+function counting(): { module: Pick; released: () => number } {
+ let freed = 0;
+
+ return {
+ module: {
+ Covenant: class {
+ address() {
+ return "tex1p_derived";
+ }
+ free() {
+ freed += 1;
+ }
+ scriptPubKeyHex() {
+ return `5120${"11".repeat(32)}`;
+ }
+ },
+ } as unknown as Pick,
+ released: () => freed,
+ };
+}
+
+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);
+
+ test("reports both spellings of where a covenant is, from one compile", async () => {
+ const compiled = await compile({
+ argumentsJson: "{}",
+ network: "liquid-testnet",
+ source: PROBE_SOURCE,
+ });
+
+ 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({
+ argumentsJson: "{}",
+ network: "liquid-testnet",
+ 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();
+ });
+
+ test("lets an unknown network throw", () => {
+ expect(() =>
+ compile({ argumentsJson: "{}", network: "not-a-network", source: PROBE_SOURCE }),
+ ).toThrow();
+ });
+
+ 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();
+
+ createSmplxCovenantCompiler(module)({
+ argumentsJson: "{}",
+ network: "liquid",
+ source: PROBE_SOURCE,
+ });
+
+ expect(released()).toBe(1);
+ });
+
+ // 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 "";
+ }
+ },
+ } as unknown as Pick;
+
+ expect(() =>
+ createSmplxCovenantCompiler(module)({
+ argumentsJson: "{}",
+ network: "not-a-network",
+ source: PROBE_SOURCE,
+ }),
+ ).toThrow();
+ expect(freed).toBe(1);
+ });
+ });
+});
diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/compileCovenantWithSmplx.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/compileCovenantWithSmplx.ts
new file mode 100644
index 0000000..4527ddd
--- /dev/null
+++ b/apps/extension/src/core/chains/liquid/adapters/smplx/compileCovenantWithSmplx.ts
@@ -0,0 +1,41 @@
+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.
+ *
+ * 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.
+ */
+type CompileCovenant = Parameters[1]["compile"];
+
+/**
+ * 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.
+ *
+ * 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);
+
+ try {
+ return {
+ address: covenant.address(network),
+ scriptPubKeyHex: covenant.scriptPubKeyHex(network),
+ };
+ } finally {
+ covenant.free();
+ }
+ };
+}
diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts
new file mode 100644
index 0000000..5f237b4
--- /dev/null
+++ b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts
@@ -0,0 +1,152 @@
+import { describe, expect, test } from "bun:test";
+
+import { smplx } from "./smplxWasmForTests";
+
+// Exercises the exact bindings `loadSmplxWasm` consumes. The only difference is where the
+// module bytes come from: the extension fetches them through a Vite asset URL, this reads
+// them off disk. Everything after instantiation — the `__wbg_set_wasm` handshake, the start
+// call, and every exported binding — is the same code path.
+//
+// `loadSmplxWasm` itself cannot be imported here: it uses Vite's `?url` import, which only
+// resolves under Vite.
+
+// The reference value: this source compiled natively against simplicityhl 0.6.0 with debug
+// symbols off. Asserting the wasm build reproduces it is what makes recomputing a covenant
+// address in the wallet meaningful — a browser that derived a different CMR would refuse
+// every legitimately deployed protocol.
+const PROBE_SOURCE = "fn main() { assert!(jet::eq_32(witness::A, witness::B)); }";
+const PROBE_CMR = "43041b02608dc3ba245a2e3dc7aa5bc991fcf6c097c6a165a18e97a486461729";
+
+describe("smplx wasm module", () => {
+ test("reports the SDK version compiled into it", () => {
+ expect(smplx.sdkVersion()).toBe("0.0.10");
+ });
+
+ test("compiles a covenant to the same CMR as a native build", () => {
+ const covenant = new smplx.Covenant(PROBE_SOURCE);
+
+ expect(covenant.commitmentMerkleRoot()).toBe(PROBE_CMR);
+ covenant.free();
+ });
+
+ test("derives a covenant address", () => {
+ const covenant = new smplx.Covenant(PROBE_SOURCE);
+
+ expect(covenant.address("liquid-testnet").startsWith("tex1p")).toBe(true);
+ covenant.free();
+ });
+
+ // The address and the script are two spellings of one fact, and only one of them is hex.
+ // An output pays the script; the address is what a person is shown.
+ test("reports the script an output pays, as hex, beside the address", () => {
+ const covenant = new smplx.Covenant(PROBE_SOURCE);
+
+ expect(covenant.scriptPubKeyHex("liquid-testnet")).toMatch(/^(?:[0-9a-f]{2})+$/);
+ covenant.free();
+ });
+
+ test("derives a different address on a different network from the same source", () => {
+ const testnet = new smplx.Covenant(PROBE_SOURCE);
+ const mainnet = new smplx.Covenant(PROBE_SOURCE);
+
+ expect(testnet.address("liquid-testnet")).not.toBe(mainnet.address("liquid"));
+ testnet.free();
+ mainnet.free();
+ });
+
+ // Not released afterwards, and that is the binding rather than an oversight. The failed
+ // compile leaves the handle borrowed on the Rust side, so `free` here does not release it —
+ // it throws "attempted to take ownership of Rust value while it was borrowed" and that is
+ // the error the assertion would end up reporting, in place of the compile error this is
+ // about. The completed test leaves it unfreed for the same reason.
+ test("refuses a source that does not compile", () => {
+ const covenant = new smplx.Covenant("fn main() { this is not simplicityhl }");
+
+ expect(() => covenant.commitmentMerkleRoot()).toThrow();
+ });
+
+ test("rejects an unknown network by name", () => {
+ const covenant = new smplx.Covenant(PROBE_SOURCE);
+
+ expect(() => covenant.address("not-a-network")).toThrow();
+ covenant.free();
+ });
+});
+
+describe("transaction assembly", () => {
+ const TXID = "0".repeat(64);
+ // L-BTC on Liquid testnet.
+ const ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49";
+ // A P2WPKH output of 100_000 sats of the asset above, consensus-encoded.
+ const TXOUT_HEX =
+ "01" +
+ "499a818545f6bae39fc03b637f2a4e1e64e590cac1bc3a6f6d71aa4443654c14" +
+ "01" +
+ "00000000000186a0" +
+ "00" +
+ "160014" +
+ "0000000000000000000000000000000000000000";
+
+ test("starts empty", () => {
+ const builder = new smplx.TransactionBuilder();
+
+ expect(builder.inputCount()).toBe(0);
+ expect(builder.outputCount()).toBe(0);
+ builder.free();
+ });
+
+ test("takes a wallet input as an outpoint plus the output it spends", () => {
+ const builder = new smplx.TransactionBuilder();
+
+ builder.addWalletInput(TXID, 0, TXOUT_HEX);
+
+ expect(builder.inputCount()).toBe(1);
+ builder.free();
+ });
+
+ // Amounts are u64 in the module, so they cross as BigInt rather than number — the same
+ // base-unit discipline the wallet already keeps on its own side.
+ test("takes an unblinded output", () => {
+ const builder = new smplx.TransactionBuilder();
+
+ builder.addOutput(`0014${"00".repeat(20)}`, 50_000n, ASSET);
+
+ expect(builder.outputCount()).toBe(1);
+ builder.free();
+ });
+
+ test("refuses a txid that is not one", () => {
+ const builder = new smplx.TransactionBuilder();
+
+ expect(() => builder.addWalletInput("nope", 0, TXOUT_HEX)).toThrow();
+ expect(builder.inputCount()).toBe(0);
+ builder.free();
+ });
+
+ test("refuses an output encoding it cannot parse", () => {
+ const builder = new smplx.TransactionBuilder();
+
+ expect(() => builder.addWalletInput(TXID, 0, "abcd")).toThrow();
+ expect(builder.inputCount()).toBe(0);
+ builder.free();
+ });
+
+ test("refuses an asset id that is not one", () => {
+ const builder = new smplx.TransactionBuilder();
+
+ expect(() => builder.addOutput(`0014${"00".repeat(20)}`, 1n, "not-an-asset")).toThrow();
+ expect(builder.outputCount()).toBe(0);
+ builder.free();
+ });
+
+ // A script that is not hex fails inside the module with an error naming neither the output
+ // nor what was wrong with it, which is why the review derives a script rather than passing
+ // on the address it is shown as.
+ test("refuses an output script that is not hex, such as an address", () => {
+ const builder = new smplx.TransactionBuilder();
+
+ expect(() => builder.addOutput("tex1p_derived", 1n, ASSET)).toThrow();
+ expect(builder.outputCount()).toBe(0);
+ builder.free();
+ });
+});
diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.ts
new file mode 100644
index 0000000..72e302a
--- /dev/null
+++ b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.ts
@@ -0,0 +1,66 @@
+/* eslint-disable no-underscore-dangle */
+
+import * as smplxWasmBindings from "smplx-wasm/smplx_wasm_bg.js";
+import smplxWasmUrl from "smplx-wasm/smplx_wasm_bg.wasm?url";
+
+export type SmplxWasmModule = typeof import("smplx-wasm");
+
+type SmplxWasmBindings = SmplxWasmModule & {
+ __wbg_set_wasm: (exports: WebAssembly.Exports) => void;
+};
+
+const bindings = smplxWasmBindings as unknown as SmplxWasmBindings;
+
+let smplxWasmInitializePromise: Promise | null = null;
+
+/**
+ * Loads the Simplex SDK wasm module, initializing it once per execution context.
+ *
+ * Deliberately mirrors `loadLwkWasm`: same streaming-with-fallback instantiation and the
+ * same wasm-bindgen start handshake, because both modules are produced the same way and a
+ * second shape here would be a difference nobody could explain later.
+ *
+ * Unlike lwk, this module needs no network, so it can be initialized in any context the
+ * extension runs in rather than only where a `window` exists.
+ */
+export async function loadSmplxWasm(): Promise {
+ smplxWasmInitializePromise ??= initializeSmplxWasm();
+
+ await smplxWasmInitializePromise;
+
+ return bindings;
+}
+
+async function initializeSmplxWasm(): Promise {
+ const imports = {
+ "./smplx_wasm_bg.js": bindings as unknown as WebAssembly.ModuleImports,
+ };
+ const instance = await instantiateSmplxWasm(imports);
+
+ bindings.__wbg_set_wasm(instance.exports);
+ startSmplxWasm(instance.exports);
+}
+
+async function instantiateSmplxWasm(imports: WebAssembly.Imports): Promise {
+ const response = await fetch(smplxWasmUrl);
+
+ try {
+ const { instance } = await WebAssembly.instantiateStreaming(response, imports);
+
+ return instance;
+ } catch {
+ const fallbackResponse = await fetch(smplxWasmUrl);
+ const bytes = await fallbackResponse.arrayBuffer();
+ const { instance } = await WebAssembly.instantiate(bytes, imports);
+
+ return instance;
+ }
+}
+
+function startSmplxWasm(exports: WebAssembly.Exports): void {
+ const start = exports.__wbindgen_start;
+
+ if (typeof start === "function") {
+ start();
+ }
+}
diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/smplxWasmForTests.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/smplxWasmForTests.ts
new file mode 100644
index 0000000..ba9d8bd
--- /dev/null
+++ b/apps/extension/src/core/chains/liquid/adapters/smplx/smplxWasmForTests.ts
@@ -0,0 +1,47 @@
+// oxlint-disable no-underscore-dangle -- these are wasm-bindgen's own exported names; renaming them would stop the module loading
+import { readFile } from "node:fs/promises";
+import { createRequire } from "node:module";
+
+import * as smplxWasmBindings from "smplx-wasm/smplx_wasm_bg.js";
+
+/**
+ * The real smplx module, instantiated once for every test that needs it.
+ *
+ * **Once is not an optimisation.** The generated glue is a module, and a module is a singleton:
+ * `__wbg_set_wasm` points it at one instance's exports, and every handle it hands out reads
+ * that instance's memory. A second instantiation in the same process repoints the glue while
+ * the first instance's objects are still alive, so they start reading a different memory —
+ * which is not an error anywhere, just wrong values and torn objects.
+ *
+ * So the bootstrap lives here and the test files import it. Top-level await plus the module
+ * cache is what makes that exactly-once: whichever test file is loaded first pays for it, and
+ * the rest get the same instance.
+ *
+ * This is a test fixture rather than production loading. The extension fetches the module bytes
+ * through a Vite asset URL, which only resolves under Vite; everything after instantiation —
+ * the handshake, the start call, and every exported binding — is the same code path.
+ */
+
+type SmplxBindings = typeof import("smplx-wasm") & {
+ __wbg_set_wasm: (exports: WebAssembly.Exports) => void;
+};
+
+const bindings = smplxWasmBindings as unknown as SmplxBindings;
+
+const require = createRequire(import.meta.url);
+const bytes = await readFile(require.resolve("smplx-wasm/smplx_wasm_bg.wasm"));
+
+const { instance } = await WebAssembly.instantiate(bytes, {
+ "./smplx_wasm_bg.js": bindings as unknown as WebAssembly.ModuleImports,
+});
+
+bindings.__wbg_set_wasm(instance.exports);
+
+const start = instance.exports.__wbindgen_start;
+
+if (typeof start === "function") {
+ start();
+}
+
+export { bindings as smplx };
+export type { SmplxBindings };
diff --git a/apps/extension/src/vite-env.d.ts b/apps/extension/src/vite-env.d.ts
index 1bacd5a..63ee7c0 100644
--- a/apps/extension/src/vite-env.d.ts
+++ b/apps/extension/src/vite-env.d.ts
@@ -11,3 +11,8 @@ declare module "lwk_wasm/lwk_wasm_bg.js" {
export * from "lwk_wasm";
export function __wbg_set_wasm(exports: WebAssembly.Exports): void;
}
+
+declare module "smplx-wasm/smplx_wasm_bg.js" {
+ export * from "smplx-wasm";
+ export function __wbg_set_wasm(exports: WebAssembly.Exports): void;
+}
diff --git a/bun.lock b/bun.lock
index 9fa37e5..7af6d77 100644
--- a/bun.lock
+++ b/bun.lock
@@ -13,6 +13,7 @@
"@hookform/resolvers": "^5.4.0",
"@hugeicons/core-free-icons": "^4.2.0",
"@hugeicons/react": "^1.1.6",
+ "@humid/tx-manifest": "workspace:*",
"@noble/curves": "1.9.7",
"@noble/hashes": "1.8.0",
"@reactuses/core": "^6.3.3",
@@ -51,6 +52,7 @@
"react-qr-code": "^2.2.0",
"recharts": "^3.8.1",
"shadcn": "^4.10.0",
+ "smplx-wasm": "file:smplx/crates/wasm/pkg",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.2.2",
@@ -2954,6 +2956,8 @@
"slow-redact": ["slow-redact@0.3.2", "", {}, "sha512-MseHyi2+E/hBRqdOi5COy6wZ7j7DxXRz9NkseavNYSvvWC06D8a5cidVZX3tcG5eCW3NIyVU4zT63hw0Q486jw=="],
+ "smplx-wasm": ["smplx-wasm@file:smplx/crates/wasm/pkg", {}],
+
"snapdragon": ["snapdragon@0.8.2", "", { "dependencies": { "base": "^0.11.1", "debug": "^2.2.0", "define-property": "^0.2.5", "extend-shallow": "^2.0.1", "map-cache": "^0.2.2", "source-map": "^0.5.6", "source-map-resolve": "^0.5.0", "use": "^3.1.0" } }, "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg=="],
"snapdragon-node": ["snapdragon-node@2.1.1", "", { "dependencies": { "define-property": "^1.0.0", "isobject": "^3.0.0", "snapdragon-util": "^3.0.1" } }, "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw=="],
diff --git a/package.json b/package.json
index cf392f1..ac29d3f 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"dev": "vite",
"start": "vite",
"build": "tsc && vite build",
+ "build:wasm": "smplx/crates/wasm/build.sh",
"build:watch": "vite build --watch --mode development",
"analyze": "vite build --mode analyze",
"preview": "vite preview",
@@ -40,6 +41,7 @@
"@hookform/resolvers": "^5.4.0",
"@hugeicons/core-free-icons": "^4.2.0",
"@hugeicons/react": "^1.1.6",
+ "@humid/tx-manifest": "workspace:*",
"@noble/curves": "1.9.7",
"@noble/hashes": "1.8.0",
"@reactuses/core": "^6.3.3",
@@ -78,6 +80,7 @@
"react-qr-code": "^2.2.0",
"recharts": "^3.8.1",
"shadcn": "^4.10.0",
+ "smplx-wasm": "file:smplx/crates/wasm/pkg",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.2.2",
diff --git a/packages/tx-manifest/src/chain/chainRead.ts b/packages/tx-manifest/src/chain/chainRead.ts
index 481de9e..dccd6e2 100644
--- a/packages/tx-manifest/src/chain/chainRead.ts
+++ b/packages/tx-manifest/src/chain/chainRead.ts
@@ -20,3 +20,17 @@ export type TxOutAtOutPoint = {
};
export type ReadTxOut = (outpoint: OutPoint) => Promise;
+
+/**
+ * Reads a fee rate the wallet is willing to pay, in satoshis per kilo-vbyte.
+ *
+ * The fee is the wallet's business, not the requester's. A request has no field to put one
+ * in: the parser accepts a closed set of keys, so a `fee` or `feeRate` alongside them is a
+ * malformed request rather than a value that gets quietly dropped — the difference between a
+ * site being told no and a site finding out what it can slip past.
+ *
+ * So the rate is read, and an action is refused rather than built when none can be. Guessing
+ * a default here would convert "we do not know" into "we are sure", which is the failure this
+ * refusal exists to prevent.
+ */
+export type ReadFeeRate = (targetBlocks: number) => Promise;
diff --git a/packages/tx-manifest/src/evaluation/plan.test.ts b/packages/tx-manifest/src/evaluation/plan.test.ts
new file mode 100644
index 0000000..53861e5
--- /dev/null
+++ b/packages/tx-manifest/src/evaluation/plan.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, test } from "bun:test";
+
+import p2pkManifest from "../__fixtures__/p2pk.manifest.json";
+import type { ParsedLiquidProcessCtParams } from "../request/request";
+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,
+ };
+}
+
+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);
+
+ expect(result).toMatchObject({ ok: true });
+
+ if (result.ok) {
+ expect(result.plan.fundingSats).toBe(50_000n);
+ expect(result.plan.outputs).toContainEqual({
+ id: "p2pk_out",
+ sats: 50_000n,
+ target: { kind: "covenant", utxoType: "p2pk_output" },
+ });
+ }
+ });
+
+ test("leaves change without an amount, because it is whatever survives the fee", () => {
+ const result = planAction(request({ amount_sat: 50_000, pubkey: PUBKEY }), PAY);
+
+ expect(result).toMatchObject({ ok: true });
+
+ if (result.ok) {
+ const change = result.plan.outputs.find((output) => output.target.kind === "change");
+
+ expect(change).toBeDefined();
+ expect(change?.sats).toBeUndefined();
+ }
+ });
+
+ // 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);
+
+ expect(result).toMatchObject({ ok: true });
+
+ if (result.ok) {
+ expect(result.plan.fundingSats).toBe(9_007_199_254_740_993n);
+ }
+ });
+
+ 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,
+ );
+
+ expect(result).toMatchObject({ ok: false });
+ });
+
+ test("refuses when the referenced parameter was not supplied", () => {
+ const result = planAction(request({ pubkey: PUBKEY }), PAY);
+
+ expect(result).toMatchObject({ ok: false });
+ });
+
+ test("refuses an output that would pay nothing", () => {
+ const result = planAction(request({ amount_sat: 0, pubkey: PUBKEY }), PAY);
+
+ 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" }],
+ });
+
+ expect(result).toMatchObject({ ok: false });
+ });
+
+ test("refuses an action with no outputs", () => {
+ const result = planAction(request({ amount_sat: 1, pubkey: PUBKEY }), { outputs: [] });
+
+ expect(result).toMatchObject({ ok: false });
+ });
+});
diff --git a/packages/tx-manifest/src/evaluation/plan.ts b/packages/tx-manifest/src/evaluation/plan.ts
new file mode 100644
index 0000000..765154b
--- /dev/null
+++ b/packages/tx-manifest/src/evaluation/plan.ts
@@ -0,0 +1,126 @@
+import { asArray, asRecord } from "../document/json";
+import type { ParsedLiquidProcessCtParams } from "../request/request";
+
+/**
+ * A concrete amount the wallet worked out for one of the action's outputs.
+ *
+ * Amounts stay base units end to end and never become `number`: a satoshi count above
+ * 2^53 is representable in a transaction and not in a double.
+ */
+export type PlannedOutput = {
+ /** The manifest's id for this output, for anything that has to name it. */
+ id: string;
+ /** Absent for change, whose amount is whatever is left after the fee. */
+ sats?: bigint;
+ /** Where it pays: a covenant type the wallet derived, the wallet, or change. */
+ target: { kind: "change" } | { kind: "covenant"; utxoType: string } | { kind: "wallet" };
+};
+
+export type PlannedSpend = {
+ /** Base units this action needs the wallet to fund, before the fee. */
+ fundingSats: bigint;
+ outputs: PlannedOutput[];
+};
+
+export type PlanResult = { ok: false; reason: string } | { ok: true; plan: PlannedSpend };
+
+/**
+ * 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
+ * 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.
+ */
+export function planAction(
+ request: ParsedLiquidProcessCtParams,
+ action: Record,
+): PlanResult {
+ const outputs: PlannedOutput[] = [];
+ let fundingSats = 0n;
+
+ for (const declared of asArray(action.outputs)) {
+ const output = asRecord(declared);
+
+ if (!output) {
+ continue;
+ }
+
+ const id = typeof output.id === "string" ? output.id : "";
+ const target = resolveTarget(output.destination);
+
+ if (!target) {
+ return {
+ ok: false,
+ reason: `Output ${id || "(unnamed)"} pays somewhere this runtime does not resolve yet.`,
+ };
+ }
+
+ if (target.kind === "change") {
+ outputs.push({ id, target });
+
+ continue;
+ }
+
+ const amount = resolveAmount(request, output.amount_sat);
+
+ if (amount === undefined) {
+ return {
+ ok: false,
+ reason: `Output ${id || "(unnamed)"} has an amount this runtime does not evaluate yet.`,
+ };
+ }
+
+ if (amount <= 0n) {
+ return { ok: false, reason: `Output ${id || "(unnamed)"} would pay nothing.` };
+ }
+
+ fundingSats += amount;
+ outputs.push({ id, sats: amount, target });
+ }
+
+ if (outputs.length === 0) {
+ return { ok: false, reason: "The action declares no outputs." };
+ }
+
+ return { ok: true, plan: { fundingSats, outputs } };
+}
+
+function resolveTarget(destination: unknown): PlannedOutput["target"] | undefined {
+ if (destination === "change") {
+ return { kind: "change" };
+ }
+
+ if (destination === "wallet") {
+ return { kind: "wallet" };
+ }
+
+ const utxoType = asRecord(destination)?.utxo_type;
+
+ 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 {
+ if (typeof amount === "number" && Number.isSafeInteger(amount)) {
+ return BigInt(amount);
+ }
+
+ if (typeof amount === "string") {
+ const literal = /^\d+$/.test(amount) ? BigInt(amount) : undefined;
+
+ if (literal !== undefined) {
+ return literal;
+ }
+
+ const referenced = /^\$?params\.(?[A-Za-z0-9_]+)$/.exec(amount)?.groups?.name;
+
+ return referenced === undefined
+ ? undefined
+ : resolveAmount(request, request.params[referenced]);
+ }
+
+ return undefined;
+}
diff --git a/packages/tx-manifest/src/index.ts b/packages/tx-manifest/src/index.ts
index a5354c5..577671c 100644
--- a/packages/tx-manifest/src/index.ts
+++ b/packages/tx-manifest/src/index.ts
@@ -22,9 +22,15 @@ export { parseLiquidProcessCtParams } from "./request/validation";
// 2. What the chain says, which only a wallet can ask for. A port rather than an
// implementation: this package states the question and holds no endpoint of its own.
-export type { ReadTxOut } from "./chain/chainRead";
+export type { ReadFeeRate, ReadTxOut } from "./chain/chainRead";
-// 3. The action, resolved into what the wallet established about it — or a refusal. This runs
-// before the permission gate, where a standing permission cannot skip it, which is why
-// everything it cannot establish refuses rather than warns.
+// 3. The action, resolved into an exact plan of what the wallet would do — or a refusal.
+// This runs before the permission gate, where a standing permission cannot skip it, which is
+// why everything it cannot establish refuses rather than warns. What comes back is the plan a
+// builder is driven from rather than a description written up afterwards, so what a person is
+// shown and what gets signed are worked out once.
+//
+// The compiler and the shapes this plan is written in are reachable through this function
+// rather than named again beside it. A wallet supplying a compiler already holds its shape,
+// and a second public name for one is a second thing to keep in step.
export { type ManifestReview, isRefusal, reviewManifestAction } from "./review";
diff --git a/packages/tx-manifest/src/review/coinSelection.test.ts b/packages/tx-manifest/src/review/coinSelection.test.ts
new file mode 100644
index 0000000..8b284f6
--- /dev/null
+++ b/packages/tx-manifest/src/review/coinSelection.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, test } from "bun:test";
+
+import { type SelectableUtxo, selectCoins } from "./coinSelection";
+
+function utxo(amount: string, overrides: Partial = {}): SelectableUtxo {
+ return {
+ amount,
+ spendable: true,
+ txOut: "00",
+ txid: amount.padStart(64, "0"),
+ vout: 0,
+ ...overrides,
+ };
+}
+
+describe("selectCoins", () => {
+ test("covers the target plus the fee headroom", () => {
+ const result = selectCoins([utxo("30000"), utxo("80000")], 50_000n, 5_000n);
+
+ expect(result).toMatchObject({ ok: true });
+
+ if (result.ok) {
+ expect(result.totalSats).toBeGreaterThanOrEqual(55_000n);
+ }
+ });
+
+ // Fewer inputs is a smaller transaction and therefore a smaller fee.
+ test("takes the largest first and stops once covered", () => {
+ const result = selectCoins([utxo("10000"), utxo("90000"), utxo("20000")], 50_000n, 0n);
+
+ expect(result).toMatchObject({ ok: true });
+
+ if (result.ok) {
+ expect(result.selected).toHaveLength(1);
+ expect(result.selected[0]?.amount).toBe("90000");
+ }
+ });
+
+ test("refuses when the account cannot cover the fee, even if it covers the outputs", () => {
+ const result = selectCoins([utxo("50000")], 50_000n, 5_000n);
+
+ expect(result).toMatchObject({ ok: false });
+ });
+
+ test("ignores what the wallet says it cannot spend", () => {
+ const result = selectCoins([utxo("90000", { spendable: false })], 50_000n, 0n);
+
+ expect(result).toMatchObject({ ok: false });
+ });
+
+ test("refuses to fund nothing", () => {
+ const result = selectCoins([utxo("90000")], 0n, 0n);
+
+ expect(result).toMatchObject({ ok: false });
+ });
+
+ // Base units past a double's range have to stay exact, or a large balance rounds into a
+ // wrong decision.
+ test("keeps amounts beyond a double's range exact", () => {
+ const result = selectCoins([utxo("9007199254740993")], 9_007_199_254_740_992n, 1n);
+
+ expect(result).toMatchObject({ ok: true });
+
+ if (result.ok) {
+ expect(result.totalSats).toBe(9_007_199_254_740_993n);
+ }
+ });
+
+ // The wallet does not select what it cannot leave a fee out of, so it never selects
+ // nothing and calls that a selection.
+ test("does not leave the selection short when the last output is exactly enough", () => {
+ const result = selectCoins([utxo("55000")], 50_000n, 5_000n);
+
+ expect(result).toMatchObject({ ok: true });
+ });
+
+ // Which of two equal outputs gets spent must be the wallet's answer, not the sort
+ // implementation's. A comparator that never returns 0 contradicts itself on a tie and a
+ // sort may act on either answer, so the same request could select different outputs twice.
+ test("keeps equal amounts in the order the wallet listed them", () => {
+ const first = utxo("40000", { txid: `a${"0".repeat(63)}` });
+ const second = utxo("40000", { txid: `b${"0".repeat(63)}` });
+ const third = utxo("40000", { txid: `c${"0".repeat(63)}` });
+
+ const result = selectCoins([first, second, third], 70_000n, 0n);
+
+ expect(result).toMatchObject({ ok: true });
+
+ if (result.ok) {
+ expect(result.selected.map((selected) => selected.txid)).toEqual([first.txid, second.txid]);
+ }
+ });
+
+ test("still takes a larger output ahead of equal smaller ones", () => {
+ const small = utxo("10000", { txid: `a${"0".repeat(63)}` });
+ const big = utxo("90000", { txid: `b${"0".repeat(63)}` });
+ const alsoSmall = utxo("10000", { txid: `c${"0".repeat(63)}` });
+
+ const result = selectCoins([small, big, alsoSmall], 50_000n, 0n);
+
+ expect(result).toMatchObject({ ok: true });
+
+ if (result.ok) {
+ expect(result.selected.map((selected) => selected.txid)).toEqual([big.txid]);
+ }
+ });
+});
diff --git a/packages/tx-manifest/src/review/coinSelection.ts b/packages/tx-manifest/src/review/coinSelection.ts
new file mode 100644
index 0000000..47b0289
--- /dev/null
+++ b/packages/tx-manifest/src/review/coinSelection.ts
@@ -0,0 +1,87 @@
+/** One wallet output the selector may spend, as the wallet already describes it. */
+export type SelectableUtxo = {
+ amount: string;
+ spendable: boolean;
+ txOut: string;
+ txid: string;
+ vout: number;
+};
+
+export type CoinSelection =
+ | { ok: false; reason: string }
+ | { ok: true; selected: SelectableUtxo[]; totalSats: bigint };
+
+/**
+ * Chooses which of the wallet's outputs pay for an action.
+ *
+ * Largest-first, which keeps the input count and therefore the fee down, and stops as soon
+ * as the target is covered. `headroomSats` is what the caller adds for a fee it cannot know
+ * exactly yet — the final figure comes from the assembled transaction's weight, and
+ * selecting for the outputs alone would leave nothing to pay it with.
+ *
+ * Selection stays here rather than inside the signing module deliberately: the wallet knows
+ * which of its outputs it is willing to spend, and a module choosing on its behalf would be
+ * making that decision somewhere the wallet cannot see.
+ */
+export function selectCoins(
+ available: SelectableUtxo[],
+ targetSats: bigint,
+ headroomSats: bigint,
+): CoinSelection {
+ if (targetSats <= 0n) {
+ return { ok: false, reason: "Nothing to fund." };
+ }
+
+ const needed = targetSats + headroomSats;
+ const spendable = available.filter((utxo) => utxo.spendable).toSorted(byLargestFirst);
+
+ const selected: SelectableUtxo[] = [];
+ let totalSats = 0n;
+
+ for (const utxo of spendable) {
+ if (totalSats >= needed) {
+ break;
+ }
+
+ selected.push(utxo);
+ totalSats += toSats(utxo.amount);
+ }
+
+ if (totalSats < needed) {
+ return {
+ ok: false,
+ reason: `This account holds ${totalSats} of the ${needed} needed to perform the action and pay its fee.`,
+ };
+ }
+
+ return { ok: true, selected, totalSats };
+}
+
+/**
+ * Largest first, and equal amounts in the order the wallet listed them.
+ *
+ * Returning 0 for a tie is what makes that second half true. A comparator that answers -1 to
+ * both "a before b" and "b before a" contradicts itself, and a sort is free to act on either
+ * answer — so two outputs of the same size could come out in either order, and which of them
+ * a transaction spent would depend on the engine rather than on anything the wallet decided.
+ * The same request has to select the same outputs twice.
+ */
+function byLargestFirst(a: SelectableUtxo, b: SelectableUtxo): number {
+ const left = toSats(a.amount);
+ const right = toSats(b.amount);
+
+ if (left === right) {
+ return 0;
+ }
+
+ return left > right ? -1 : 1;
+}
+
+/** Amounts arrive as base-unit strings and stay exact; a double would round past 2^53. */
+export function toSats(amount: string): bigint {
+ try {
+ return BigInt(amount);
+ } catch {
+ return 0n;
+ }
+}
diff --git a/packages/tx-manifest/src/review/index.test.ts b/packages/tx-manifest/src/review/index.test.ts
index 11dab88..88d8dc3 100644
--- a/packages/tx-manifest/src/review/index.test.ts
+++ b/packages/tx-manifest/src/review/index.test.ts
@@ -27,6 +27,24 @@ const COMPILED = { address: DERIVED, scriptPubKeyHex: DERIVED_SCRIPT };
const compile = () => COMPILED;
+/** The wallet's own side of the transaction: where it pays, what it holds, what a fee costs. */
+const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49";
+const WALLET_SCRIPT = `0014${"33".repeat(20)}`;
+const fundingUtxos = [
+ { amount: "1000000", spendable: true, txOut: "00", txid: "c".repeat(64), vout: 0 },
+];
+const readFeeRate = async () => 1000;
+
+/** What every case shares; individual tests override only what they exercise. */
+const deps = {
+ compile,
+ fundingUtxos,
+ network: "liquid",
+ policyAsset: POLICY_ASSET,
+ readFeeRate,
+ walletScriptPubKeyHex: WALLET_SCRIPT,
+};
+
const chainHolding = (scriptPubKeyHex: string) => async (): Promise => ({
scriptPubKeyHex,
});
@@ -60,8 +78,7 @@ describe("reviewManifestAction", () => {
describe("creating a covenant", () => {
test("reports the derived covenant as not yet on chain", async () => {
const result = await reviewManifestAction(request(), {
- compile,
- network: "liquid",
+ ...deps,
readTxOut: chainHolding(DERIVED_SCRIPT),
});
@@ -86,8 +103,7 @@ describe("reviewManifestAction", () => {
let asked = 0;
await reviewManifestAction(request(), {
- compile,
- network: "liquid",
+ ...deps,
readTxOut: async () => {
asked += 1;
@@ -102,12 +118,12 @@ describe("reviewManifestAction", () => {
const seen: string[] = [];
await reviewManifestAction(request(), {
+ ...deps,
compile: (input) => {
seen.push(input.argumentsJson);
return COMPILED;
},
- network: "liquid",
readTxOut: chainHolding(DERIVED_SCRIPT),
});
@@ -119,25 +135,22 @@ describe("reviewManifestAction", () => {
// Receive spends the covenant. This is where the wallet's derivation is checked against
// something it did not get from the requester.
describe("spending a covenant", () => {
- test("passes when the rebuilt contract locks the funds that are there", async () => {
+ // Receive verifies but cannot yet be built: its output amount references another
+ // input, which this runtime does not evaluate, and its spend needs a signing witness.
+ // Asserting the refusal is about the amount rather than the covenant is what shows
+ // verification got past — a weaker claim than "it builds", and the true one. Building a
+ // Receive on a guess would be building a partial transaction and calling it whole.
+ test("gets past verification when the rebuilt contract locks the funds that are there", async () => {
const result = await reviewManifestAction(spendRequest(oneCovenantUtxo), {
- compile,
- network: "liquid",
+ ...deps,
readTxOut: chainHolding(DERIVED_SCRIPT),
});
- expect(isRefusal(result)).toBe(false);
+ expect(isRefusal(result)).toBe(true);
- if (!isRefusal(result)) {
- expect(result.covenants).toEqual([
- {
- address: DERIVED,
- role: "spent",
- scriptPubKeyHex: DERIVED_SCRIPT,
- utxoType: "p2pk_output",
- verified: "matches-chain",
- },
- ]);
+ if (isRefusal(result)) {
+ expect(result.reason).toContain("amount");
+ expect(result.reason).not.toContain("rebuilds to");
}
});
@@ -145,8 +158,7 @@ describe("reviewManifestAction", () => {
const asked: { txid: string; vout: number }[] = [];
await reviewManifestAction(spendRequest(oneCovenantUtxo), {
- compile,
- network: "liquid",
+ ...deps,
readTxOut: async (outpoint) => {
asked.push(outpoint);
@@ -159,8 +171,7 @@ describe("reviewManifestAction", () => {
test("refuses when the funds are locked by something else", async () => {
const result = await reviewManifestAction(spendRequest(oneCovenantUtxo), {
- compile,
- network: "liquid",
+ ...deps,
readTxOut: chainHolding(ELSEWHERE_SCRIPT),
});
@@ -173,8 +184,7 @@ describe("reviewManifestAction", () => {
test("refuses when the state file lists no such covenant", async () => {
const result = await reviewManifestAction(spendRequest({ utxos: [] }), {
- compile,
- network: "liquid",
+ ...deps,
readTxOut: chainHolding(DERIVED_SCRIPT),
});
@@ -183,8 +193,7 @@ describe("reviewManifestAction", () => {
test("refuses before reading anything when the state file is absent", async () => {
const result = await reviewManifestAction(spendRequest(), {
- compile,
- network: "liquid",
+ ...deps,
readTxOut: chainHolding(DERIVED_SCRIPT),
});
@@ -193,8 +202,7 @@ describe("reviewManifestAction", () => {
test("refuses when the chain cannot be read, rather than proceeding unchecked", async () => {
const result = await reviewManifestAction(spendRequest(oneCovenantUtxo), {
- compile,
- network: "liquid",
+ ...deps,
readTxOut: async () => {
throw new Error("offline");
},
@@ -210,8 +218,7 @@ describe("reviewManifestAction", () => {
test("refuses a request missing a part the action needs, naming it", async () => {
const result = await reviewManifestAction(request({ contractSources: {} }), {
- compile,
- network: "liquid",
+ ...deps,
readTxOut: chainHolding(DERIVED_SCRIPT),
});
@@ -224,8 +231,7 @@ describe("reviewManifestAction", () => {
test("refuses an action the manifest does not declare, naming it", async () => {
const result = await reviewManifestAction(request({ action: "Withdraw" }), {
- compile,
- network: "liquid",
+ ...deps,
readTxOut: chainHolding(DERIVED_SCRIPT),
});
@@ -236,12 +242,124 @@ describe("reviewManifestAction", () => {
}
});
+ // Everything below is the transaction the review settles, so that what a person approves
+ // is what gets signed rather than a description of it reassembled afterwards.
+ describe("the transaction it settles", () => {
+ test("pays the covenant output the script it derived, not the address it is shown as", async () => {
+ const result = await reviewManifestAction(request(), {
+ ...deps,
+ readTxOut: chainHolding(DERIVED_SCRIPT),
+ });
+
+ expect(isRefusal(result)).toBe(false);
+
+ if (!isRefusal(result)) {
+ expect(result.outputs).toEqual([
+ {
+ asset: POLICY_ASSET,
+ id: "p2pk_out",
+ sats: 1000n,
+ scriptPubKeyHex: DERIVED_SCRIPT,
+ },
+ ]);
+ }
+ });
+
+ // The builder hex-decodes every script it is handed, so a bech32 address fails inside
+ // the module with an error naming neither the output nor what was wrong with it.
+ test("gives every output a script the builder can decode", async () => {
+ const result = await reviewManifestAction(request(), {
+ ...deps,
+ readTxOut: chainHolding(DERIVED_SCRIPT),
+ });
+
+ expect(isRefusal(result)).toBe(false);
+
+ if (!isRefusal(result)) {
+ expect(result.outputs.length).toBeGreaterThan(0);
+
+ for (const output of result.outputs) {
+ expect(output.scriptPubKeyHex).toMatch(/^(?:[0-9a-fA-F]{2})+$/);
+ }
+ }
+ });
+
+ // Change carries no amount, because change is whatever is left after the fee — and the
+ // fee is not known until the transaction has a shape.
+ test("plans no output for the change the action declares", async () => {
+ const result = await reviewManifestAction(request(), {
+ ...deps,
+ readTxOut: chainHolding(DERIVED_SCRIPT),
+ });
+
+ expect(isRefusal(result)).toBe(false);
+
+ if (!isRefusal(result)) {
+ expect(result.outputs.map((output) => output.id)).not.toContain("change_out");
+ }
+ });
+
+ test("selects the wallet's own outputs to fund it, and reports the rate it will pay", async () => {
+ const result = await reviewManifestAction(request(), {
+ ...deps,
+ readTxOut: chainHolding(DERIVED_SCRIPT),
+ });
+
+ expect(isRefusal(result)).toBe(false);
+
+ if (!isRefusal(result)) {
+ expect(result.feeRateSatsPerKvb).toBe(1000);
+ expect(result.selected).toEqual(fundingUtxos);
+ }
+ });
+
+ // The fee is the wallet's business: the request carries none, and an action is refused
+ // rather than built when no rate can be established. A default here would quietly turn
+ // "we do not know" into "we are sure".
+ test("refuses when no fee rate can be established, rather than assuming one", async () => {
+ const result = await reviewManifestAction(request(), {
+ ...deps,
+ readFeeRate: async () => {
+ throw new Error("no estimate");
+ },
+ readTxOut: chainHolding(DERIVED_SCRIPT),
+ });
+
+ expect(isRefusal(result)).toBe(true);
+
+ if (isRefusal(result)) {
+ expect(result.reason).toContain("fee rate");
+ }
+ });
+
+ test("refuses when the account cannot cover the action and its fee", async () => {
+ const result = await reviewManifestAction(request(), {
+ ...deps,
+ fundingUtxos: [
+ { amount: "10", spendable: true, txOut: "00", txid: "d".repeat(64), vout: 0 },
+ ],
+ readTxOut: chainHolding(DERIVED_SCRIPT),
+ });
+
+ expect(isRefusal(result)).toBe(true);
+ });
+
+ test("refuses an amount this runtime does not evaluate, rather than guessing one", async () => {
+ const result = await reviewManifestAction(
+ request({ params: { amount_sat: "params.amount_sat - fee", pubkey: PUBKEY } }),
+ { ...deps, readTxOut: chainHolding(DERIVED_SCRIPT) },
+ );
+
+ expect(isRefusal(result)).toBe(true);
+ });
+ });
+
test("refuses when the contract does not compile", async () => {
const result = await reviewManifestAction(request(), {
+ ...deps,
compile: () => {
throw new Error("parse error");
},
- network: "liquid",
readTxOut: chainHolding(DERIVED_SCRIPT),
});
diff --git a/packages/tx-manifest/src/review/index.ts b/packages/tx-manifest/src/review/index.ts
index e4a1bd2..417bec6 100644
--- a/packages/tx-manifest/src/review/index.ts
+++ b/packages/tx-manifest/src/review/index.ts
@@ -1,4 +1,4 @@
-import type { ReadTxOut } from "../chain/chainRead";
+import type { ReadFeeRate, ReadTxOut } from "../chain/chainRead";
import {
type CompileCovenant,
covenantMatchesChain,
@@ -7,8 +7,10 @@ import {
import { declaredParamTypes } from "../covenants/declaredTypes";
import { asArray, asRecord } from "../document/json";
import { covenantSites } from "../document/sites";
+import { planAction } from "../evaluation/plan";
import type { ParsedLiquidProcessCtParams } from "../request/request";
import { resolveActionRequirements } from "../request/requirements";
+import { type CoinSelection, type SelectableUtxo, selectCoins } from "./coinSelection";
/**
* What the wallet established for itself about one covenant this action touches.
@@ -26,17 +28,44 @@ export type CovenantFinding = {
verified: "matches-chain" | "not-yet-on-chain";
};
+/** One output of the transaction the wallet worked out, ready to be shown and then built. */
+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: string;
+ id: string;
+ sats: bigint;
+ /** What the output actually pays to. Hex the builder decodes, never an address. */
+ scriptPubKeyHex: string;
+};
+
/**
- * Everything the wallet established about an action, before anyone approves it.
+ * 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.
*
- * A description of established fact rather than something to sign: what the action is, which
- * protocol declared it, and what was found out about every covenant it touches. Building the
- * transaction is a later step and reads this rather than repeating it.
+ * 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;
covenants: CovenantFinding[];
+ /** What the wallet will pay, established from the chain rather than from the request. */
+ feeRateSatsPerKvb: number;
+ outputs: ReviewedOutput[];
protocol: string;
+ /** The wallet's own outputs that fund this, chosen by the wallet. */
+ selected: SelectableUtxo[];
};
export type ReviewRefusal = { reason: string; refused: true };
@@ -63,14 +92,28 @@ export function isRefusal(result: ReviewManifestActionResult): result is ReviewR
* this is the only thing between a request and a signature. Everything it cannot establish is
* a refusal, and the refusal says which thing — a missing request part named by key, a
* contract that will not compile, a state file listing no such covenant, a chain that cannot
- * be read, a covenant that does not match. There is no return value meaning "probably fine".
+ * 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 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.
*/
export async function reviewManifestAction(
request: ParsedLiquidProcessCtParams,
input: {
compile: CompileCovenant;
+ /** The wallet's spendable outputs in the asset the network charges its fees in. */
+ fundingUtxos: SelectableUtxo[];
network: string;
+ /** The asset this wallet pays fees in and is the only one this slice moves. */
+ policyAsset: string;
+ readFeeRate: ReadFeeRate;
readTxOut: ReadTxOut;
+ /** 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);
@@ -156,13 +199,88 @@ export async function reviewManifestAction(
});
}
+ const plan = planAction(request, action);
+
+ 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.
+ const covenantScripts = new Map(
+ covenants.map((found) => [found.utxoType, found.scriptPubKeyHex]),
+ );
+ const outputs: ReviewedOutput[] = [];
+
+ for (const planned of plan.plan.outputs) {
+ if (planned.target.kind === "change" || planned.sats === undefined) {
+ 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.
+ const scriptPubKeyHex =
+ planned.target.kind === "covenant"
+ ? covenantScripts.get(planned.target.utxoType)
+ : input.walletScriptPubKeyHex;
+
+ if (!scriptPubKeyHex) {
+ return {
+ reason: `Output ${planned.id} pays a covenant the wallet did not verify.`,
+ refused: true,
+ };
+ }
+
+ outputs.push({ asset: input.policyAsset, id: planned.id, sats: planned.sats, scriptPubKeyHex });
+ }
+
+ let feeRateSatsPerKvb: number;
+
+ try {
+ feeRateSatsPerKvb = await input.readFeeRate(FEE_TARGET_BLOCKS);
+ } catch (error) {
+ return {
+ reason: `The wallet could not establish a fee rate, so it will not build this: ${String(error)}`,
+ refused: true,
+ };
+ }
+
+ const selection: CoinSelection = selectCoins(
+ input.fundingUtxos,
+ plan.plan.fundingSats,
+ feeHeadroomSats(feeRateSatsPerKvb),
+ );
+
+ if (!selection.ok) {
+ return { reason: selection.reason, refused: true };
+ }
+
return {
action: request.action,
covenants,
+ feeRateSatsPerKvb,
+ outputs,
protocol: typeof request.manifest.protocol === "string" ? request.manifest.protocol : "",
+ selected: selection.selected,
};
}
+/** Confirmation target for the fee estimate, in blocks. */
+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.
+ */
+function feeHeadroomSats(feeRateSatsPerKvb: number): bigint {
+ return BigInt(Math.ceil(feeRateSatsPerKvb));
+}
+
/**
* Where the state file says this deployment's covenant of that type sits.
*
diff --git a/smplx b/smplx
new file mode 160000
index 0000000..8f0215c
--- /dev/null
+++ b/smplx
@@ -0,0 +1 @@
+Subproject commit 8f0215ce1d63ab9dc5c1310151a12840240f1026