From ce09d98b83e6f394050c145ecddf89ce3b2d763f Mon Sep 17 00:00:00 2001 From: lukachi Date: Wed, 2 Sep 2026 14:32:20 +0300 Subject: [PATCH] feat(tx-manifest): add smplx transaction engine --- .github/actions/build-smplx-wasm/action.yml | 71 ++++ .github/workflows/build-extension.yml | 7 +- .github/workflows/deploy-storybook.yml | 9 + .github/workflows/deploy-web-netlify.yml | 9 + .github/workflows/tx-manifest-check.yml | 85 +++++ .gitmodules | 4 + .oxfmtrc.json | 6 + .oxlintrc.json | 1 + apps/extension/src/bun-test-env.d.ts | 16 + .../smplx/assembleReviewedTransaction.test.ts | 326 ++++++++++++++++++ .../smplx/assembleReviewedTransaction.ts | 124 +++++++ .../smplx/compileCovenantWithSmplx.test.ts | 119 +++++++ .../smplx/compileCovenantWithSmplx.ts | 41 +++ .../adapters/smplx/loadSmplxWasm.test.ts | 152 ++++++++ .../liquid/adapters/smplx/loadSmplxWasm.ts | 66 ++++ .../adapters/smplx/smplxWasmForTests.ts | 47 +++ apps/extension/src/vite-env.d.ts | 5 + bun.lock | 4 + package.json | 3 + packages/tx-manifest/src/chain/chainRead.ts | 14 + .../tx-manifest/src/evaluation/plan.test.ts | 98 ++++++ packages/tx-manifest/src/evaluation/plan.ts | 126 +++++++ packages/tx-manifest/src/index.ts | 14 +- .../src/review/coinSelection.test.ts | 107 ++++++ .../tx-manifest/src/review/coinSelection.ts | 87 +++++ packages/tx-manifest/src/review/index.test.ts | 186 ++++++++-- packages/tx-manifest/src/review/index.ts | 130 ++++++- smplx | 1 + 28 files changed, 1813 insertions(+), 45 deletions(-) create mode 100644 .github/actions/build-smplx-wasm/action.yml create mode 100644 .github/workflows/tx-manifest-check.yml create mode 100644 apps/extension/src/bun-test-env.d.ts create mode 100644 apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.test.ts create mode 100644 apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.ts create mode 100644 apps/extension/src/core/chains/liquid/adapters/smplx/compileCovenantWithSmplx.test.ts create mode 100644 apps/extension/src/core/chains/liquid/adapters/smplx/compileCovenantWithSmplx.ts create mode 100644 apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts create mode 100644 apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.ts create mode 100644 apps/extension/src/core/chains/liquid/adapters/smplx/smplxWasmForTests.ts create mode 100644 packages/tx-manifest/src/evaluation/plan.test.ts create mode 100644 packages/tx-manifest/src/evaluation/plan.ts create mode 100644 packages/tx-manifest/src/review/coinSelection.test.ts create mode 100644 packages/tx-manifest/src/review/coinSelection.ts create mode 160000 smplx 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