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..b130412 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 contracts and signs with them, so the + # extension does not build without this package either. + - name: Build smplx_wasm + uses: ./.github/actions/build-smplx-wasm + - name: Install dependencies uses: ./.github/actions/install diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..ddd0dcd --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,49 @@ +name: Check + +# Runs the same gate a commit runs locally, on every push and pull request. Until this +# existed nothing in CI ran the tests at all: the only workflows were a manual extension +# build and two deploys, so 456 tests protected nothing that could block a merge. +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. A leaner + # job was tried and does not work: on a checkout without them `bun install` reports + # "Failed to install 2 packages", typechecking fails with eleven errors about + # `lwk_wasm` and `smplx-wasm` having no declarations, and three test files fail + # outright on `Cannot find module 'smplx-wasm/smplx_wasm_bg.js'` — they drive the + # real module rather than a substitute, which 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 + + # typecheck across apps/extension, packages/ and apps/web, then lint, format and + # the test suite. The three projects are separate deliberately — see the comment in + # lefthook.yml for why one `tsc --noEmit` never covered them. + - name: Check + run: bun run check diff --git a/.gitmodules b/.gitmodules index e257c04..7d356c5 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 = humid/wasm-issuance diff --git a/.oxfmtrc.json b/.oxfmtrc.json index b61b8c1..e51b915 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -10,6 +10,8 @@ "sortTailwindcss": true, "ignorePatterns": [ "lwk/**", + "smplx/**", + "**/__fixtures__/**", "AGENTS.md", "CLAUDE.md", "README.md", 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/background.ts b/apps/extension/src/background.ts index 9a07aa3..3c7ab74 100644 --- a/apps/extension/src/background.ts +++ b/apps/extension/src/background.ts @@ -2,6 +2,7 @@ import browser from "webextension-polyfill"; import { createAccountRegistry } from "@/core/accounts/application/account-registry"; import type { AccountModelState } from "@/core/accounts/application/account-registry/model/account-model"; +import type { AccountGroupId } from "@/core/accounts/application/account-registry/model/identifiers"; import type { ActivityPage, EstimateMaxSendInput, @@ -16,6 +17,10 @@ import type { import type { Caip25Scopes } from "@/core/caip25"; import { addUnlockedChainRecord } from "@/core/chains/application/chain-store/addChainRecord"; import { getUnlockedChainStoreState } from "@/core/chains/application/chain-store/secureChainStore"; +import { + type LiquidContractIdentity, + readLiquidContractIdentity, +} from "@/core/chains/liquid/application/contractIdentity"; import { buildLiquidDappAccountScope, resolveAccountGroupIdsForIdentifiers, @@ -275,6 +280,35 @@ const init = async () => { const getReceiveAddress = async (): Promise => liquidChainGroup.accountRuntime.getReceiveAddress((await resolveSelectedLiquidAccount()).input); + // The address and key contract actions are signed with, for the selected account. Not + // the same as the receive address above: the contract SDK signs with one key at a fixed + // path and returns change to that key's own unblinded address, so a covenant action can + // only spend what sits there. Reading it is what makes that limit visible. + const readContractIdentity = async ( + accountGroupId?: AccountGroupId, + ): Promise => { + const { input } = await resolveSelectedLiquidAccount(); + + // The settings page is per-account, and the account it shows is not necessarily the + // selected one. Reading the selected account's identity there would put one + // account's address and key on another account's screen, with nothing to say so — + // and the values are what someone then funds and locks a covenant to. + const group = + accountGroupId === undefined + ? undefined + : input.keyManagerState.accountModel.accountGroups[accountGroupId]; + + if (accountGroupId !== undefined && !group) { + throw new Error(`No account group ${accountGroupId}.`); + } + + return readLiquidContractIdentity({ + accountGroupIndex: group ? (group.groupIndex ?? 0) : input.accountGroupIndex, + chain: input.chain, + keyManagerState: input.keyManagerState, + }); + }; + // In-extension send: preview then execute against the SELECTED account (resolved exactly like // getReceiveAddress). Both call the chain group's runtime, which calls the same backend fns the // dapp path uses — but WITHOUT the dapp confirmation popup, because the popup's own review screen @@ -580,6 +614,7 @@ const init = async () => { getActivity, getPortfolio, getReceiveAddress, + readContractIdentity, inspectTransfer, purgeAccountPortfolio, purgeAccountWalletConnectSessions, 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..4d36288 --- /dev/null +++ b/apps/extension/src/bun-test-env.d.ts @@ -0,0 +1,14 @@ +/// + +// 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. +// +// Side effect worth knowing: this also makes Bun's globals visible to application +// code, which does not run under Bun. Reach for a browser or extension API there, +// not `Bun.*`. + +export {}; diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/createLwkWalletBackend.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/createLwkWalletBackend.ts index 9c8900d..a91e921 100644 --- a/apps/extension/src/core/chains/liquid/adapters/lwk/createLwkWalletBackend.ts +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/createLwkWalletBackend.ts @@ -1,9 +1,10 @@ import type { LiquidWalletBackend } from "../../application/backends/LiquidWalletBackend"; import { getWalletActivityForAsset } from "./wallet/getActivity"; import { getWalletBalanceForAsset } from "./wallet/getBalance"; -import { getWalletReceiveAddress } from "./wallet/getReceiveAddress"; -import { getWalletUtxosForAsset } from "./wallet/getUTXOs"; +import { getWalletReceiveAddress, getWalletSigningAddress } from "./wallet/getReceiveAddress"; +import { getExplicitWalletUtxosForAsset, getWalletUtxosForAsset } from "./wallet/getUTXOs"; import { getWalletDescriptorEntries } from "./wallet/getWalletDescriptor"; +import { readChainTipHeight } from "./wallet/readChainTipHeight"; import { createLwkLiquidAccount } from "./wallet/resolveAccount"; import { estimateMaxSend, inspectTransfer, sendTransfer } from "./wallet/sendTransfer"; import { inspectMessageSigning, signMessage } from "./wallet/signMessage"; @@ -16,7 +17,10 @@ export function createLwkWalletBackend(): LiquidWalletBackend { getActivity: getWalletActivityForAsset, getBalance: getWalletBalanceForAsset, getReceiveAddress: getWalletReceiveAddress, + getSigningAddress: getWalletSigningAddress, getDescriptorEntries: getWalletDescriptorEntries, + getExplicitUtxos: getExplicitWalletUtxosForAsset, + getTipHeight: readChainTipHeight, getUtxos: getWalletUtxosForAsset, inspectMessageSigning, inspectTransfer, diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/createInlineScanClient.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/createInlineScanClient.ts index 2d39cb9..4797ffb 100644 --- a/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/createInlineScanClient.ts +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/createInlineScanClient.ts @@ -1,6 +1,7 @@ import type { SyncWorkerClient } from "./createWorkerScanClient"; import { broadcastPset as runBroadcastPset, + broadcastTransaction as runBroadcastTransaction, readActivity as runReadActivity, scanAndRead as runScanAndRead, scanFresh as runScanFresh, @@ -20,6 +21,9 @@ export function createInlineScanClient(): SyncWorkerClient { async broadcast(input) { return { txid: await runBroadcastPset({ ...input, id: (seq += 1) }) }; }, + async broadcastTransaction(input) { + return { txid: await runBroadcastTransaction({ ...input, id: (seq += 1) }) }; + }, async readActivity(input) { return runReadActivity({ ...input, id: (seq += 1) }); }, diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/createOffscreenScanClient.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/createOffscreenScanClient.ts index 10f3274..9ba495c 100644 --- a/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/createOffscreenScanClient.ts +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/createOffscreenScanClient.ts @@ -2,6 +2,7 @@ import browser from "webextension-polyfill"; import type { BroadcastInput, + BroadcastTxInput, ReadActivityInput, ScanInput, SyncWorkerClient, @@ -52,6 +53,7 @@ async function ensureOffscreenDocument(offscreen: ChromeOffscreenApi): Promise; /** A promise-per-request handle to a scan backend (a dedicated worker, offscreen, or inline). */ export type SyncWorkerClient = { broadcast: (input: BroadcastInput) => Promise; + broadcastTransaction: (input: BroadcastTxInput) => Promise; readActivity: (input: ReadActivityInput) => Promise; scan: (input: ScanInput) => Promise; scanAndRead: (input: ScanInput) => Promise; @@ -95,6 +104,13 @@ export function createWorkerScanClient(): SyncWorkerClient { } return { + broadcastTransaction() { + // Same reason as `broadcast` below: LWK's Esplora client needs a `window` this + // context does not have. + return Promise.reject( + new Error("The dedicated worker cannot broadcast; use the offscreen or inline client."), + ); + }, broadcast() { // LWK can't run in a dedicated Worker (Esplora's async retry/sleep needs a `window` a // Worker lacks), so this path never broadcasts — the offscreen/inline clients do. Present diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/liquidScanCore.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/liquidScanCore.ts index ce916ad..de68e57 100644 --- a/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/liquidScanCore.ts +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/liquidScanCore.ts @@ -1,3 +1,5 @@ +import { logger } from "@/core/logger"; + import type { LiquidActivityPage, LiquidAssetBalance, @@ -10,6 +12,15 @@ import { createLwkNetwork, type LwkNetwork } from "../createLwkNetwork"; import { loadLwkWasm, type LwkWasmModule } from "../loadLwkWasm"; import { readWalletActivityForAsset, readWalletAssetBalances } from "../wallet/readWalletData"; import { readWalletUtxos } from "../wallet/readWalletUtxos"; + +/** + * The scan worker's own logger. + * + * These lines carried a hand-written "[liquid-sync]" prefix on every call, which is what a + * child logger's scope is for. The facade is a console wrapper with no transports, so it + * behaves the same inside a worker as it does on a page. + */ +const log = logger.child({ module: "liquid-sync" }); import { type AssetMetadata, resolveIssuedAssetMetadata } from "../wallet/resolveAssetMetadata"; type LwkWollet = InstanceType; @@ -35,6 +46,12 @@ export type LiquidBroadcastInput = { psetBase64: string; }; +export type LiquidBroadcastTxInput = { + chain: LiquidChainRecord; + id: number; + txHex: string; +}; + /** Issued assets get 8 decimals until the registry pass provides their real precision. */ const DEFAULT_ISSUED_ASSET_DECIMALS = 8; @@ -57,11 +74,11 @@ export async function scanFresh(input: LiquidScanInput): Promise { + const lwk = await loadLwkWasm(); + const network = createLwkNetwork(lwk, input.chain); + const client = createLwkBlockchainClient(lwk, input.chain, network); + const transaction = lwk.Transaction.fromString(input.txHex); + + log.warn("broadcast tx…", { chainId: input.chain.id, id: input.id }); + const startedAt = Date.now(); + const txid = await client.broadcastTx(transaction); + const txidString = txid.toString(); + + log.warn("broadcast tx done", { + id: input.id, + ms: Date.now() - startedAt, + txid: txidString, + }); + + txid.free(); + transaction.free(); + client.free(); + + return txidString; +} + /** Incremental scan on a cached wollet; reads balance and activity directly from it. */ export async function scanAndRead(input: LiquidScanInput): Promise { const lwk = await loadLwkWasm(); @@ -117,7 +167,7 @@ export async function scanAndRead(input: LiquidScanInput): Promise ({ + address: () => ({ toString: () => `address:${txid}:${spec.vout}` }), + extInt: () => spec.chain ?? 0, + height: () => spec.height, + wildcardIndex: () => spec.index ?? 0, + outpoint: () => ({ txid: () => ({ toString: () => txid }), vout: () => spec.vout }), + scriptPubkey: () => ({ toString: () => `script:${spec.vout}` }), + unblinded: () => ({ + asset: () => ({ toString: () => "asset" }), + value: () => ({ toString: () => spec.amount }), + }), + }); + + return { + inputs: () => + spends.map((spend) => ({ + get: () => ({ + outpoint: () => ({ + txid: () => ({ toString: () => spend.txid }), + vout: () => spend.vout, + }), + }), + })), + outputs: () => outputs.map((spec) => ({ get: () => owned(spec) })), + tx: () => ({ + outputs: outputs.map((spec) => ({ + isPartiallyBlinded: () => spec.blinded, + toString: () => `txout:${txid}:${spec.vout}`, + })), + }), + txid: () => ({ toString: () => txid }), + }; +} + +const wollet = (txs: unknown[]) => ({ transactions: () => txs }) as never; + +const A = "aa".repeat(32); +const B = "bb".repeat(32); + +describe("the wallet's own outputs that hide nothing", () => { + test("an unspent explicit output is reported", () => { + const utxos = readExplicitWalletUtxos( + wollet([walletTx(A, [{ amount: "30000", blinded: false, height: 12, vout: 0 }])]), + ); + + expect(utxos).toHaveLength(1); + expect(utxos[0]).toMatchObject({ + amountSats: "30000", + confidential: false, + spendable: true, + txid: A, + txOut: `txout:${A}:0`, + vout: 0, + }); + }); + + // The ordinary read already reports these, and a wallet that counted them twice would + // believe it has more money than it does. + test("a blinded output is left to the ordinary read", () => { + const utxos = readExplicitWalletUtxos( + wollet([walletTx(A, [{ amount: "30000", blinded: true, height: 12, vout: 0 }])]), + ); + + expect(utxos).toEqual([]); + }); + + test("an explicit output a later transaction spent is gone", () => { + const utxos = readExplicitWalletUtxos( + wollet([ + walletTx(A, [{ amount: "30000", blinded: false, height: 12, vout: 0 }]), + walletTx( + B, + [{ amount: "20000", blinded: false, height: 13, vout: 0 }], + [{ txid: A, vout: 0 }], + ), + ]), + ); + + expect(utxos.map((utxo) => utxo.txid)).toEqual([B]); + }); + + // The spending transaction can be read before the one it spends from, and a reader that + // decided as it went would report an output it had already been told was gone. + test("order does not decide it", () => { + const utxos = readExplicitWalletUtxos( + wollet([ + walletTx( + B, + [{ amount: "20000", blinded: false, height: 13, vout: 0 }], + [{ txid: A, vout: 0 }], + ), + walletTx(A, [{ amount: "30000", blinded: false, height: 12, vout: 0 }]), + ]), + ); + + expect(utxos.map((utxo) => utxo.txid)).toEqual([B]); + }); + + test("an output still in the mempool is reported, and not as spendable", () => { + const utxos = readExplicitWalletUtxos( + wollet([walletTx(A, [{ amount: "30000", blinded: false, vout: 0 }])]), + ); + + expect(utxos[0]).toMatchObject({ spendable: false }); + }); + + test("only the wallet's own outputs, never a counterparty's", () => { + const tx = walletTx(A, [{ amount: "30000", blinded: false, height: 1, vout: 0 }]); + const withStranger = { + ...tx, + outputs: () => [...tx.outputs(), { get: () => undefined }], + tx: () => ({ + outputs: [ + ...tx.tx().outputs, + { isPartiallyBlinded: () => false, toString: () => "somebody-else" }, + ], + }), + }; + + const utxos = readExplicitWalletUtxos(wollet([withStranger])); + + expect(utxos).toHaveLength(1); + expect(utxos[0]?.txOut).toBe(`txout:${A}:0`); + }); + + // The contract path signs every wallet input with one key, the account's first external + // address. An explicit output anywhere else in the range is money the wallet owns and + // cannot spend here, and offering it would buy a failure at signing — after the person + // approved — instead of a shortfall said plainly beforehand. + test("an explicit output the contract path cannot sign is not offered", () => { + const elsewhere = readExplicitWalletUtxos( + wollet([walletTx(A, [{ amount: "30000", blinded: false, height: 1, index: 4, vout: 0 }])]), + ); + + expect(elsewhere).toEqual([]); + + const change = readExplicitWalletUtxos( + wollet([walletTx(A, [{ amount: "30000", blinded: false, chain: 1, height: 1, vout: 0 }])]), + ); + + expect(change).toEqual([]); + }); + + test("an input the wallet did not own does not remove anything", () => { + const tx = walletTx(A, [{ amount: "30000", blinded: false, height: 1, vout: 0 }]); + const withForeignInput = { ...tx, inputs: () => [{ get: () => undefined }] }; + + expect(readExplicitWalletUtxos(wollet([withForeignInput]))).toHaveLength(1); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readExplicitWalletUtxos.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readExplicitWalletUtxos.ts new file mode 100644 index 0000000..99dbcda --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readExplicitWalletUtxos.ts @@ -0,0 +1,121 @@ +import { + WALLET_RPC_ERROR_REASONS, + WalletRpcResourceUnavailableError, +} from "@/core/wallet-rpc/errors"; + +import type { LiquidUtxoSnapshot } from "../../../application/backends/LiquidWalletBackend"; +import type { LwkWasmModule } from "../loadLwkWasm"; + +type LwkWollet = InstanceType; + +/** `Chain::External` — the side of the descriptor addresses are handed out from. */ +const CHAIN_EXTERNAL = 0; + +/** + * The one index the contract path can sign. + * + * The signing module derives a single key at the account's first external address and signs + * every wallet input with it. Until it takes a derivation path per input, that address is the + * whole of what a contract action can be funded from — the limitation the contract identity + * screen exists to make visible rather than to hide. + */ +const SIGNING_INDEX = 0; + +/** + * The wallet's own unspent outputs that hide nothing. + * + * `Wollet::utxos` cannot answer this. It walks the unspent cache and then skips every entry + * whose amount is explicit, so an unblinded output at one of the wallet's own scripts is never + * listed — the library states the same rule in its own words, that "unblinded UTXOs with the + * same scriptpubkeys as the wallet, are considered external". The output is in the cache; only + * the listing drops it. + * + * That matters because a contract action can spend nothing else. Unblinding an output needs the + * secrets that go with it, and the signing module is handed an outpoint and its bytes and + * nothing more — so the money a person can put behind a contract is exactly the money that is + * already in the open. Without this the wallet cannot see what it sent itself. + * + * Built from the wallet's own transactions rather than from a second source: each one reports + * which of its outputs belong to the wallet and which of its inputs spent wallet outputs, so + * what is unspent is the difference. No network call, and nothing is treated as the wallet's + * that the wallet's own scan did not already claim. + */ +export function readExplicitWalletUtxos(wollet: LwkWollet): LiquidUtxoSnapshot[] { + const spent = new Set(); + const candidates = new Map(); + + for (const walletTx of wollet.transactions()) { + for (const input of walletTx.inputs()) { + const previous = input.get(); + + if (!previous) { + continue; + } + + const outpoint = previous.outpoint(); + + spent.add(outpointKey(outpoint.txid().toString(), outpoint.vout())); + } + + const txid = walletTx.txid().toString(); + const rawOutputs = walletTx.tx().outputs; + + for (const output of walletTx.outputs()) { + const owned = output.get(); + + if (!owned) { + continue; + } + + const outpoint = owned.outpoint(); + const vout = outpoint.vout(); + const rawTxOut = rawOutputs[vout]; + + if (!rawTxOut) { + throw new WalletRpcResourceUnavailableError( + "Could not locate the raw output for a wallet transaction output.", + { txid, vout }, + WALLET_RPC_ERROR_REASONS.WALLET_UTXO_READ_FAILED, + ); + } + + // The only ones this reader is for. A blinded output is already reported by the + // ordinary read, and reporting it twice would have the wallet count it twice. + if (rawTxOut.isPartiallyBlinded()) { + continue; + } + + // And only the ones the contract path can actually sign. That path signs every wallet + // input with one key, the account's first external one, because the signing module is + // given an outpoint and its bytes and no derivation path. An explicit output anywhere + // else in the range is real money the wallet owns and cannot spend here, and offering + // it to coin selection would buy a failure at signing — after the person approved — + // in place of a shortfall said plainly beforehand. + if (owned.extInt() !== CHAIN_EXTERNAL || owned.wildcardIndex() !== SIGNING_INDEX) { + continue; + } + + const unblinded = owned.unblinded(); + + candidates.set(outpointKey(txid, vout), { + address: owned.address().toString(), + amountSats: unblinded.value().toString(), + confidential: false, + rawAssetId: unblinded.asset().toString(), + scriptPubKey: owned.scriptPubkey().toString(), + // The same conservative reading the ordinary read takes: confirmed is spendable, + // still in the mempool is not. + spendable: owned.height() !== undefined, + txid, + txOut: rawTxOut.toString(), + vout, + } satisfies LiquidUtxoSnapshot); + } + } + + return [...candidates].filter(([key]) => !spent.has(key)).map(([, utxo]) => utxo); +} + +function outpointKey(txid: string, vout: number): string { + return `${txid}:${vout}`; +} diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/resolveAccount.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/resolveAccount.ts index 20f99c5..1f4818b 100644 --- a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/resolveAccount.ts +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/resolveAccount.ts @@ -69,6 +69,7 @@ export async function createLwkLiquidAccount( // Threaded through so dapp read methods can key the persisted portfolio snapshot; may be // undefined for internal callers that resolve the default account without a group. accountGroupId: input.accountGroupId, + accountGroupIndex, accountIdentifier, chain: input.chain, chainId: input.chain.id, diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/sendTransfer/index.test.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/sendTransfer/index.test.ts new file mode 100644 index 0000000..aabab28 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/sendTransfer/index.test.ts @@ -0,0 +1,140 @@ +// oxlint-disable no-extraneous-class -- this stands in for a chain-library class the real code constructs with new; a function would not be substitutable for it +import { describe, expect, mock, test } from "bun:test"; + +/** + * Which builder call a recipient gets, and nothing else. + * + * The substitutes hold themselves to the chain library's own rule — the ordinary recipient path + * refuses an address with no blinding key, and the explicit path refuses one that has it — so a + * branch chosen wrongly here fails the way it would fail in a browser rather than passing green. + */ +type Recorded = { calls: string[] }; + +const recorded: Recorded = { calls: [] }; + +function makeBuilder() { + const builder = { + addExplicitRecipient(address: { isBlinded: () => boolean }, satoshi: bigint) { + if (address.isBlinded()) { + throw new Error("Address must be explicit"); + } + + recorded.calls.push(`explicit:${satoshi}`); + + return builder; + }, + addLbtcRecipient(address: { isBlinded: () => boolean }, satoshi: bigint) { + if (!address.isBlinded()) { + throw new Error("Address must be confidential"); + } + + recorded.calls.push(`lbtc:${satoshi}`); + + return builder; + }, + addRecipient(address: { isBlinded: () => boolean }, satoshi: bigint) { + if (!address.isBlinded()) { + throw new Error("Address must be confidential"); + } + + recorded.calls.push(`asset:${satoshi}`); + + return builder; + }, + drainLbtcTo() { + recorded.calls.push("drain"); + + return builder; + }, + drainLbtcWallet() { + return builder; + }, + finish() { + return { toString: () => "pset" }; + }, + }; + + return builder; +} + +const POLICY = "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d"; +let blinded = true; + +mock.module("../../loadLwkWasm", () => ({ + loadLwkWasm: async () => ({ + Address: class { + isBlinded() { + return blinded; + } + isMainnet() { + return false; + } + toString() { + return blinded ? "tlq1_confidential" : "tex1_explicit"; + } + }, + AssetId: { fromString: (id: string) => ({ id }) }, + TxBuilder: class { + constructor() { + return makeBuilder() as never; + } + }, + }), +})); + +mock.module("../../sync-worker/createSyncWorkerClient", () => ({ + getSyncWorkerClient: () => ({ broadcast: async () => ({ txid: "sent" }) }), +})); + +const { sendTransfer } = await import("./index"); + +const account = { + accountIdentifier: "acct", + chain: {}, + chainId: "bip122:liquid-testnet", + implementation: { + network: {}, + signer: { sign: (pset: unknown) => pset }, + wollet: { finalize: (pset: unknown) => pset }, + }, + policyAssetId: `bip122:liquid-testnet/asset:${POLICY}`, + rawPolicyAssetId: POLICY, +} as never; + +async function send(overrides: Record = {}) { + recorded.calls = []; + + return sendTransfer( + account, + { amount: "5000", recipientAddress: "irrelevant", ...overrides } as never, + POLICY, + ); +} + +describe("which builder call a recipient gets", () => { + test("a confidential recipient takes the ordinary L-BTC path", async () => { + blinded = true; + + await expect(send()).resolves.toEqual({ txid: "sent" }); + expect(recorded.calls).toEqual(["lbtc:5000"]); + }); + + // Without this the wallet cannot pay an explicit output at all, and a contract action can + // only spend an explicit one — so nobody could fund one, including from their own wallet. + test("an unconfidential recipient takes the explicit path", async () => { + blinded = false; + + await expect(send()).resolves.toEqual({ txid: "sent" }); + expect(recorded.calls).toEqual(["explicit:5000"]); + }); + + test("draining takes the address as it is, either way", async () => { + blinded = false; + await send({ sendAll: true }); + expect(recorded.calls).toEqual(["drain"]); + + blinded = true; + await send({ sendAll: true }); + expect(recorded.calls).toEqual(["drain"]); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/sendTransfer/index.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/sendTransfer/index.ts index 2590b7f..8c44b38 100644 --- a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/sendTransfer/index.ts +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/sendTransfer/index.ts @@ -87,7 +87,20 @@ export async function sendTransfer( // Native "Max": drain every L-BTC input to the recipient, ignoring `amount`. LWK selects all // inputs and subtracts the fee, so the broadcast pays whatever the fee is off the freshly // re-synced UTXO set — no dependence on the amount estimated earlier (no feeRate() = default). + // The drain path takes the address as it is, so an unconfidential one produces an explicit + // output without needing the branch below. builder = builder.drainLbtcWallet().drainLbtcTo(recipientAddress); + } else if (!recipientAddress.isBlinded()) { + // An unconfidential recipient needs the explicit path: the ordinary one refuses an address + // with no blinding key outright ("Address must be confidential"). Without this the wallet + // cannot pay an explicit output at all — which means it cannot fund a contract action, since + // a covenant can only spend an explicit one. The confidentiality that is lost is the point + // of the address, and the review screen says so before anyone confirms. + builder = builder.addExplicitRecipient( + recipientAddress, + amount, + lwk.AssetId.fromString(rawAssetId), + ); } else if (rawAssetId === account.rawPolicyAssetId) { builder = builder.addLbtcRecipient(recipientAddress, amount); } else { diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/toScriptPubKeyHex.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/toScriptPubKeyHex.ts new file mode 100644 index 0000000..f88579a --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/toScriptPubKeyHex.ts @@ -0,0 +1,25 @@ +import { loadLwkWasm } from "../loadLwkWasm"; + +/** + * The scriptPubKey an address pays to, as lowercase hex. + * + * Exists so a caller that needs a wallet output's script does not have to reach for key + * material to get it. An address is public; deriving a script from one should not require + * touching a seed, and this is what keeps that true. + */ +export async function toScriptPubKeyHex(address: string): Promise { + const lwk = await loadLwkWasm(); + const parsed = new lwk.Address(address); + + try { + const script = parsed.scriptPubkey(); + + try { + return script.toString(); + } finally { + script.free(); + } + } finally { + parsed.free(); + } +} diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/withAccountMnemonic.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/withAccountMnemonic.ts new file mode 100644 index 0000000..22c54a2 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/withAccountMnemonic.ts @@ -0,0 +1,75 @@ +import type { KeySourceId } from "@/core/accounts/application/account-registry/model/identifiers"; +import type { KeyManagerState } from "@/core/key-manager/types"; + +import type { LiquidChainRecord } from "../../../chains/LiquidChainRecord"; +import { createLwkMnemonicFromSeedMaterial } from "../createLwkMnemonic"; +import { createLwkNetwork } from "../createLwkNetwork"; +import { getLocalRootSeedMaterial, getSeedMaterialForKeySource } from "../getLocalRootSeedMaterial"; +import { loadLwkWasm } from "../loadLwkWasm"; + +export type AccountMnemonicRequest = { + accountGroupIndex?: number; + chain: LiquidChainRecord; + keyManagerState: KeyManagerState; + keySourceId?: KeySourceId; +}; + +/** + * Runs `use` with the account's BIP-39 mnemonic, and takes it away again afterwards. + * + * The mnemonic is the whole account secret. It exists here only for the duration of one + * call, in one place, and every wasm object that held it on the way is freed before this + * returns — including when `use` throws. Nothing is cached and nothing is returned, so + * there is no handle a later caller could reach it through. + * + * The derivation is LWK's, unchanged from how accounts are resolved everywhere else: + * group 0 is the master seed's own mnemonic; group N derives a BIP-85 child at index N. + * Duplicating that math here rather than reusing it would be a second place for the + * account model to drift. + * + * Why this exists at all: smplx signs and blinds from one source, and blinding derives + * from SLIP77 material an extended private key does not carry. Handing over the mnemonic + * is the accepted debt recorded in this change's specification, not a shortcut — and the + * conditions that should reopen it are recorded there too. + */ +export async function withAccountMnemonic( + request: AccountMnemonicRequest, + use: (mnemonic: string) => Promise | T, +): Promise { + const seedMaterial = request.keySourceId + ? getSeedMaterialForKeySource(request.keyManagerState, request.keySourceId) + : getLocalRootSeedMaterial(request.keyManagerState); + + const lwk = await loadLwkWasm(); + const network = createLwkNetwork(lwk, request.chain); + const masterMnemonic = createLwkMnemonicFromSeedMaterial(lwk, seedMaterial); + + let masterSigner: ReturnType | undefined; + let accountMnemonic: InstanceType | undefined; + + function buildSigner() { + return new lwk.Signer(masterMnemonic, network); + } + + try { + masterSigner = buildSigner(); + + const accountGroupIndex = request.accountGroupIndex ?? 0; + + accountMnemonic = + accountGroupIndex === 0 + ? masterMnemonic + : masterSigner.derive_bip85_mnemonic(accountGroupIndex, 12); + + return await use(accountMnemonic.toString()); + } finally { + masterSigner?.free(); + + if (accountMnemonic && accountMnemonic !== masterMnemonic) { + accountMnemonic.free(); + } + + masterMnemonic.free(); + network.free(); + } +} diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/assetOrder.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/assetOrder.test.ts new file mode 100644 index 0000000..3b87c02 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/assetOrder.test.ts @@ -0,0 +1,111 @@ +import { beforeAll, describe, expect, test } from "bun:test"; + +import { contractSource, smplx } from "./smplxWasmForTests"; + +/** + * Which way round an asset id goes into a covenant, settled by running one. + * + * An asset id is written one way and committed the other, the same way a transaction id is. + * Everything on this side of the wallet uses the written order — the chain reader turns each + * one round on the way in, a document states one that way, a person reads one that way — and a + * covenant compares against what `jet::input_amount` reports, which is the committed order. + * + * Getting this wrong is not an error anywhere. Both orders are thirty-two valid bytes, so both + * compile, and both produce a real address that a wallet would then compare against the chain + * and refuse — or, on the paying side, pay to. So it is not decided by reading: the contract is + * built both ways here and executed against a transaction carrying the asset, and only one of + * them runs. + * + * `asset_auth.simf` is the corpus's smallest contract that takes an asset id. Every asset-id + * parameter in the corpus is used the same way it uses this one — compared against what a jet + * reports about an input or an output — in `asset_auth_vault.simf` and `lending.simf` too. + */ + +/** An asset id as everything states one, chosen so that turning it round changes it. */ +const STATED = "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec8ef5b4d5"; +const TXID = "2".repeat(64); +const AMOUNT = 7n; + +/** The witness names the indices the contract checks: input 0 and output 0. */ +const WITNESSES = JSON.stringify({ + INPUT_ASSET_INDEX: { type: "u32", value: "0" }, + OUTPUT_ASSET_INDEX: { type: "u32", value: "0" }, +}); + +let source = ""; + +beforeAll(async () => { + source = await contractSource("asset_auth.simf"); +}); + +function turnRound(hex: string): string { + return (hex.match(/../g) ?? []).toReversed().join(""); +} + +function argumentsWith(assetHex: string): string { + return JSON.stringify({ + ASSET_AMOUNT: { type: "u64", value: String(AMOUNT) }, + ASSET_ID: { type: "u256", value: `0x${assetHex}` }, + WITH_ASSET_BURN: { type: "bool", value: "false" }, + }); +} + +/** + * The covenant's own output, serialised the way a transaction carries one. + * + * The asset is written committed-order here because that is what a transaction holds; the + * builder is separately given the stated order for the output it makes, so the two ends of the + * check are constructed independently and can only agree by being right. + */ +function txOut(scriptPubKeyHex: string): string { + const value = AMOUNT.toString(16).padStart(16, "0"); + const length = (scriptPubKeyHex.length / 2).toString(16).padStart(2, "0"); + + return `01${turnRound(STATED)}01${value}00${length}${scriptPubKeyHex}`; +} + +/** Builds the covenant with the given asset bytes and runs it against a transaction. */ +function outcomeOf(assetHex: string): { address: string; ran: boolean } { + const argumentsJson = argumentsWith(assetHex); + const contract = new smplx.Contract(source, argumentsJson, "[]", false); + const scriptPubKeyHex = contract.scriptPubKeyHex("liquid-testnet"); + const address = contract.contractAddress("liquid-testnet"); + const builder = new smplx.TransactionBuilder(); + + try { + builder.addContractInput(TXID, 0, txOut(scriptPubKeyHex), source, argumentsJson, WITNESSES); + builder.addOutput(scriptPubKeyHex, AMOUNT, STATED); + builder.dryRunContractInput(0, "liquid-testnet"); + + return { address, ran: true }; + } catch { + return { address, ran: false }; + } finally { + builder.free(); + contract.free(); + } +} + +describe("an asset id compiled into a covenant", () => { + test("executes when it is the committed order", () => { + expect(outcomeOf(turnRound(STATED)).ran).toBe(true); + }); + + test("and does not when it is the order the document states it in", () => { + expect(outcomeOf(STATED).ran).toBe(false); + }); + + /** + * The reason this is decided by running rather than by reading: the wrong one is not a + * failure to compile or a failure to derive. It is a different covenant, at a real address, + * that nothing reports until money is already there. + */ + test("and the wrong order still produces a perfectly good address", () => { + const wrong = outcomeOf(STATED); + const right = outcomeOf(turnRound(STATED)); + + expect(wrong.address).toMatch(/^tex1p/); + expect(right.address).toMatch(/^tex1p/); + expect(wrong.address).not.toBe(right.address); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/blindedOutputs.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/blindedOutputs.test.ts new file mode 100644 index 0000000..c2ed444 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/blindedOutputs.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, test } from "bun:test"; + +import { guardBlindedOutputs, txOutAt } from "@humid/tx-manifest"; + +import { smplx as bindings } from "./smplxWasmForTests"; + +/** + * What a blinding key actually does to a transaction, established by building one. + * + * The wallet decides whether an output hides what it carries while it reads the document, and + * the module that builds the transaction has never read it. All that crosses between them is a + * blinding key or the absence of one, and the call says nothing about what became of it — so + * until this file, the whole seam rested on a comment. Every other test that finalises a + * transaction here builds every output in the open, which means the blinding path had never + * run anywhere in this repository while the published protocols hide amounts in it. + * + * So both halves are measured here rather than assumed: that a key handed over produces a + * commitment where the amount would be, and that the guard reading those bytes afterwards + * tells the two apart. The second half is the one that matters when the first stops being + * true, which is why the wrong build is exercised alongside the right one. + */ + +// A BIP39 test vector, not a wallet mnemonic. +const TEST_MNEMONIC = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; +const TXID = "7".repeat(64); +// L-BTC on Liquid testnet, the policy asset the fee is paid in. +const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; +const FEE_RATE = 100; + +/** An explicit output of `sats` of the policy asset — the only kind a contract action spends. */ +function encodeTxOut(sats: bigint, scriptHex: string): string { + const assetLe = (POLICY_ASSET.match(/../g) ?? []).toReversed().join(""); + const value = sats.toString(16).padStart(16, "0"); + const scriptLen = (scriptHex.length / 2).toString(16).padStart(2, "0"); + + return `01${assetLe}01${value}00${scriptLen}${scriptHex}`; +} + +/** + * One transaction built exactly the way the method builds one, and signed. + * + * `paymentBlinded` and `changeBlinded` are what the wallet decided; passing them through as a + * blinding key or as nothing is the same line the method runs. + */ +function build(paymentBlinded: boolean, changeBlinded: boolean): string { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + const builder = new bindings.TransactionBuilder(); + + try { + const script = signer.scriptPubKeyHex(); + + builder.addWalletInput(TXID, 0, encodeTxOut(100_000n, script)); + builder.addOutput( + script, + 50_000n, + POLICY_ASSET, + paymentBlinded ? signer.blindingPublicKey() : undefined, + ); + builder.addChange(script, changeBlinded ? signer.blindingPublicKey() : undefined); + + const signed = signer.finalizeTransaction(builder, FEE_RATE); + const hex = signed.hex; + + signed.free(); + + return hex; + } finally { + builder.free(); + signer.free(); + } +} + +/** Whether the output at `vout` came back with an amount anyone can read. */ +function published(transactionHex: string, vout: number): boolean { + const found = txOutAt(transactionHex, vout); + + if (!found.ok) { + throw new Error(found.reason); + } + + return found.txOut.amountSats !== undefined; +} + +describe("an output built with a blinding key", () => { + test("comes back with its amount and its asset committed rather than written", () => { + const built = build(true, true); + + expect(published(built, 0)).toBe(false); + expect(published(built, 1)).toBe(false); + // The fee is the one output the network has to read, and it stays in the open. + expect(published(built, 2)).toBe(true); + }); + + // The same builder, the same inputs, one argument dropped. Nothing about the call fails + // and the transaction is perfectly valid; the amount is simply on the chain. + test("and comes back written when the key is not passed", () => { + const built = build(false, false); + + expect(published(built, 0)).toBe(true); + expect(published(built, 1)).toBe(true); + }); + + // Each output answers for itself. A transaction is not blinded or unblinded as a whole, + // which is what makes a per-output decision meaningful at all. + test("independently of what the other outputs did", () => { + const built = build(true, false); + + expect(published(built, 0)).toBe(false); + expect(published(built, 1)).toBe(true); + }); +}); + +describe("the guard against what was actually built", () => { + const hides = { changeBlinded: true, outputs: [{ blinded: true, id: "principal_claimed" }] }; + + test("passes a transaction that hides exactly what the wallet decided to hide", () => { + expect(guardBlindedOutputs(build(true, true), hides)).toEqual({ ok: true }); + }); + + // What a dropped decision looks like from the far side: the wallet decided to hide, the + // transaction published, and nothing between the two said so. + test("refuses the same transaction built without the key", () => { + const result = guardBlindedOutputs(build(false, false), hides); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("publishes the amount on principal_claimed"); + }); + + test("refuses a hidden amount where the wallet decided on an open one", () => { + const result = guardBlindedOutputs(build(true, true), { + changeBlinded: false, + outputs: [{ blinded: false, id: "vault_out" }], + }); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("hides the amount on vault_out"); + }); + + // The change is not one of the outputs the wallet adds, so it is the one the guard finds + // by position rather than by name — and getting that wrong would pass everything. + test("refuses a published change where the wallet decided to hide it", () => { + const result = guardBlindedOutputs(build(true, false), hides); + + expect(result.ok).toBe(false); + expect(result.ok ? "" : result.reason).toContain("publishes the amount on the change"); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/covenantLeaves.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/covenantLeaves.test.ts new file mode 100644 index 0000000..d023c97 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/covenantLeaves.test.ts @@ -0,0 +1,128 @@ +import { beforeAll, describe, expect, test } from "bun:test"; + +import { contractSource, smplx } from "./smplxWasmForTests"; + +/** + * The compiler end of a covenant's extra taproot leaves. + * + * `tx-manifest` proves that reading a live lending protocol's published document produces exactly + * the two leaf payloads below, from a flag it writes as a literal and a debt it writes as a typed + * value. It holds no compiler, by design. This is the half that cannot be asserted there: that + * those bytes reach a real compiler, build a real covenant, and that every way of getting them + * wrong builds a different one. + * + * **Why a leaf cannot be guessed.** The compiler puts each payload in a storage slot and adds it + * to the taproot tree as a hidden node hashed `sha256(tag ‖ tag ‖ payload)`, `tag = + * sha256("TapData")`. A hidden node has no script to fail on and no witness to check: a payload + * that is wrong in any byte, or in its order, produces a perfectly valid address for a covenant + * nobody deployed. Nothing anywhere reports it. The wallet then compares that address against the + * one holding the funds, finds a difference, and refuses an action that was legitimate — for a + * reason nothing on screen can explain. So the bytes are read out of the document and checked + * against the protocol's own two implementations of them, never inferred. + * + * **Nothing here was compared against a chain.** The published document records a deployed + * scriptPubKey for its factory, which carries no extra leaves, and none for the collateral + * covenant, which carries these two. So these addresses are reproducible rather than confirmed. + * Confirming one needs a deployed offer whose script is readable from Liquid. + */ + +const ASSET = (byte: string) => `0x${byte.repeat(32)}`; + +/** + * The compile parameters `tx-manifest`'s review builds for the active collateral covenant, from + * this deployment's fields. Kept character for character as that review hands them over. + */ +const ACTIVE_COLLATERAL = JSON.stringify({ + BORROWER_NFT_ASSET_ID: { type: "u256", value: ASSET("b1") }, + COLLATERAL_AMOUNT: { type: "u64", value: "100000" }, + COLLATERAL_ASSET_ID: { type: "u256", value: ASSET("c1") }, + FINALIZED_LENDER_VAULT_COV_HASH: { type: "u256", value: ASSET("11") }, + FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH: { type: "u256", value: ASSET("33") }, + LENDER_NFT_ASSET_ID: { type: "u256", value: ASSET("d1") }, + LENDER_VAULT_COV_HASH: { type: "u256", value: ASSET("22") }, + LOAN_EXPIRATION_TIME: { type: "u32", value: "1900000000" }, + PRINCIPAL_AMOUNT: { type: "u64", value: "50000" }, + PRINCIPAL_ASSET_ID: { type: "u256", value: ASSET("a1") }, + PRINCIPAL_INTEREST_RATE: { type: "u64", value: "500" }, + PRINCIPAL_OUTPUT_SCRIPT_HASH: { type: "u256", value: ASSET("55") }, + PROTOCOL_FEE_VAULT_COV_HASH: { type: "u256", value: ASSET("44") }, +}); + +/** The offer is active: the flag slot is thirty-one zero bytes and a one. */ +const ACTIVE = `${"00".repeat(31)}01`; + +/** The debt slot: 52500 as eight big-endian bytes, right-aligned in thirty-two. */ +const DEBT = `${"00".repeat(30)}cd14`; + +/** The document says its contracts were built with debug symbols, and that changes the address. */ +const DEBUG_SYMBOLS = true; + +let lending = ""; + +beforeAll(async () => { + lending = await contractSource("lending.simf"); +}); + +function scriptPubKeyFor(leaves: string[]): string { + const contract = new smplx.Contract( + lending, + ACTIVE_COLLATERAL, + JSON.stringify(leaves), + DEBUG_SYMBOLS, + ); + const script = contract.scriptPubKeyHex("liquid"); + + contract.free(); + + return script; +} + +describe("the leaves tx-manifest encodes, through the compiler that builds the address", () => { + test("build a covenant", () => { + expect(scriptPubKeyFor([ACTIVE, DEBT])).toMatch(/^5120[0-9a-f]{64}$/); + }); + + test("and the compiler takes them as hex, prefixed or not", () => { + expect(scriptPubKeyFor([`0x${ACTIVE}`, `0x${DEBT}`])).toBe(scriptPubKeyFor([ACTIVE, DEBT])); + }); + + test("a payload that is not hex is refused rather than hashed as something", () => { + expect(() => scriptPubKeyFor([ACTIVE, "not-hex"])).toThrow(); + }); +}); + +/** + * Every way of getting the two leaves wrong, and what each one costs. + * + * All of these compile. None of them fails anywhere. Each is a different covenant, which is the + * whole argument for reading the bytes rather than inferring them. + */ +describe("the covenants a wrong leaf would have built instead", () => { + const right = () => scriptPubKeyFor([ACTIVE, DEBT]); + + test("dropping the leaves entirely is a different covenant", () => { + expect(scriptPubKeyFor([])).not.toBe(right()); + }); + + test("declaration order is part of the address, so swapping the two changes it", () => { + expect(scriptPubKeyFor([DEBT, ACTIVE])).not.toBe(right()); + }); + + test("the flag is one bit of one byte, and the pending offer is a different covenant", () => { + expect(scriptPubKeyFor([`${"00".repeat(32)}`, DEBT])).not.toBe(right()); + }); + + test("one satoshi of debt is a different covenant", () => { + expect(scriptPubKeyFor([ACTIVE, `${"00".repeat(30)}cd15`])).not.toBe(right()); + }); + + /** The same number, written the way this format's other byte vocabulary would write it. */ + test("and the debt written little-endian is a different covenant again", () => { + expect(scriptPubKeyFor([ACTIVE, `14cd${"00".repeat(30)}`])).not.toBe(right()); + }); + + /** Left alignment instead of right: the same eight bytes, at the other end of the slot. */ + test("as is the debt padded at the wrong end", () => { + expect(scriptPubKeyFor([ACTIVE, `000000000000cd14${"00".repeat(24)}`])).not.toBe(right()); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/createdDeployment.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/createdDeployment.test.ts new file mode 100644 index 0000000..3e9b272 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/createdDeployment.test.ts @@ -0,0 +1,136 @@ +import { beforeAll, describe, expect, test } from "bun:test"; + +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; + +import { contractSource, smplx } from "./smplxWasmForTests"; + +/** + * The join between a deployment a wallet records and the covenant it goes on to create. + * + * A constructor works out a field that is a covenant's script hash, and the same document + * declares that covenant as a utxo type the action pays into. Those two are the same contract + * compiled twice — once to a hash the deployment stores, once to an address the transaction + * pays to — and if they ever disagree the protocol's own program will reject the spend, long + * afterwards, for a reason nothing on a confirmation screen could have shown. + * + * `tx-manifest` proves the wallet computes both from one reading of the document. This proves + * that the real compiler, given exactly what the wallet emits, makes them equal. Three things + * decide it and each one is checked below on its own, because getting any of them wrong + * produces a perfectly valid hash of the wrong covenant and nothing fails: + * + * - the extra taproot leaves, which are part of the tree the scriptPubKey is derived from; + * - the build mode the document declares, which changes the script outright; + * - the arguments, encoded at the types the document states beside them. + * + * The values below are the ones `tx-manifest`'s own tests produce for the published lending + * document at a principal of 50000 and a rate of 500 basis points, written out here character + * for character so a change at either end breaks one of the two files. + */ + +/** + * What the wallet emits for the covenant this action creates. + * + * The two leaves are the protocol's own storage slots: an all-zero state marker, and the debt + * as a big-endian u64 right-aligned in thirty-two bytes. 52500 is 50000 plus 5% of it, which is + * the value the document's own formula computes and the contract's `get_total_amount_to_repay` + * arrives at independently. + */ +const EXTRA_LEAVES = JSON.stringify([ + "0000000000000000000000000000000000000000000000000000000000000000", + "000000000000000000000000000000000000000000000000000000000000cd14", +]); + +/** 52500, big-endian, in the low eight bytes of the second leaf. */ +const DEBT = 0xcd_14; + +/** The document says its contracts were built with debug symbols, and that changes the script. */ +const DEBUG_SYMBOLS = true; + +let lending = ""; + +beforeAll(async () => { + lending = await contractSource("lending.simf"); +}); + +function scriptPubKeyOf( + argumentsJson: string, + extraLeavesJson: string, + includeDebugSymbols = DEBUG_SYMBOLS, +): string { + const contract = new smplx.Contract(lending, argumentsJson, extraLeavesJson, includeDebugSymbols); + + try { + return contract.scriptPubKeyHex("liquid"); + } finally { + contract.free(); + } +} + +const hashOf = (scriptPubKeyHex: string) => bytesToHex(sha256(hexToBytes(scriptPubKeyHex))); + +const asset = (byte: string) => byte.repeat(32); +const reversed = (id: string) => (id.match(/../g) ?? []).toReversed().join(""); + +/** + * The arguments the wallet emits for this covenant, at the types the document declares. + * + * Asset ids are reversed because that is how the chain commits them and how the jets that read + * them report them; the widths are the ones stated beside each value. Both are `tx-manifest`'s + * decisions and both are what make this a hash of the right contract. + */ +const ARGUMENTS = JSON.stringify({ + BORROWER_NFT_ASSET_ID: { type: "u256", value: `0x${reversed(asset("b1"))}` }, + COLLATERAL_AMOUNT: { type: "u64", value: "100000" }, + COLLATERAL_ASSET_ID: { type: "u256", value: `0x${reversed(asset("c1"))}` }, + FINALIZED_LENDER_VAULT_COV_HASH: { type: "u256", value: `0x${"11".repeat(32)}` }, + FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH: { type: "u256", value: `0x${"33".repeat(32)}` }, + LENDER_NFT_ASSET_ID: { type: "u256", value: `0x${reversed(asset("d1"))}` }, + LENDER_VAULT_COV_HASH: { type: "u256", value: `0x${"22".repeat(32)}` }, + LOAN_EXPIRATION_TIME: { type: "u32", value: "1900000000" }, + PRINCIPAL_AMOUNT: { type: "u64", value: "50000" }, + PRINCIPAL_ASSET_ID: { type: "u256", value: `0x${reversed(asset("a1"))}` }, + PRINCIPAL_INTEREST_RATE: { type: "u64", value: "500" }, + PRINCIPAL_OUTPUT_SCRIPT_HASH: { type: "u256", value: `0x${"55".repeat(32)}` }, + PROTOCOL_FEE_VAULT_COV_HASH: { type: "u256", value: `0x${"44".repeat(32)}` }, +}); + +describe("a covenant hash a deployment stores", () => { + test("is the hash of the scriptPubKey the same contract compiles to", () => { + const script = scriptPubKeyOf(ARGUMENTS, EXTRA_LEAVES); + + expect(hashOf(script)).toBe(hashOf(scriptPubKeyOf(ARGUMENTS, EXTRA_LEAVES))); + expect(hashOf(script)).toHaveLength(64); + }); + + // Dropping the leaves is the failure this whole seam exists to prevent. A hidden taproot + // node has no script to fail on, so the wrong hash is not an error anywhere — it is a + // covenant nobody deployed, and the funds would be locked by a different one. + test("changes when the extra leaves are dropped", () => { + expect(hashOf(scriptPubKeyOf(ARGUMENTS, EXTRA_LEAVES))).not.toBe( + hashOf(scriptPubKeyOf(ARGUMENTS, "[]")), + ); + }); + + // The debt is one of those leaves, so the value a document computes for itself reaches the + // address. A wallet that rounded it differently would derive a different covenant. + test("changes when the computed debt in a leaf changes by one", () => { + const other = JSON.stringify([ + "0000000000000000000000000000000000000000000000000000000000000000", + `${"0".repeat(60)}${(DEBT + 1).toString(16).padStart(4, "0")}`, + ]); + + expect(hashOf(scriptPubKeyOf(ARGUMENTS, EXTRA_LEAVES))).not.toBe( + hashOf(scriptPubKeyOf(ARGUMENTS, other)), + ); + }); + + // The mode is not a refinement of an address; it is part of one. The wallet binds it from + // the document for the hashes a manifest computes and for the covenants it derives, and + // this is what says the two would differ if it were bound for only one of them. + test("changes when the build mode is not the one the document declares", () => { + expect(hashOf(scriptPubKeyOf(ARGUMENTS, EXTRA_LEAVES, true))).not.toBe( + hashOf(scriptPubKeyOf(ARGUMENTS, EXTRA_LEAVES, false)), + ); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/deployedCovenant.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/deployedCovenant.test.ts new file mode 100644 index 0000000..db3d547 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/deployedCovenant.test.ts @@ -0,0 +1,101 @@ +import { beforeAll, describe, expect, test } from "bun:test"; + +import { contractSource, smplx } from "./smplxWasmForTests"; + +/** + * The other end of the covenant-parameter chain: what the real compiler makes of the arguments + * this wallet emits. + * + * `tx-manifest` proves that reading a live protocol's published document produces exactly the + * argument string below — it holds no compiler, by design, because a wallet supplies one. This + * proves that string is the one that reproduces the covenant the protocol is actually deployed + * at. The string is the join, written out character for character at both ends, so an encoding + * that changed at either would break one of these two files. + * + * The script is not this module's own output pinned against itself. `lending_v3.manifest.json` + * records it as the deployed factory's fixed scriptPubKey — its address depends only on the two + * integers below, not on the asset it holds, which is why one published constant covers every + * deployment of it. + */ + +/** What `tx-manifest` asks for, from the published document. Kept identical to its own copy. */ +const ARGUMENTS = + '{"ISSUING_UTXOS_COUNT":{"type":"u8","value":"2"},"REISSUANCE_FLAGS":{"type":"u64","value":"0"}}'; + +/** What the published document says the deployed factory is locked by. */ +const DEPLOYED_SCRIPT_PUB_KEY = + "5120456881785cc7d561caaa059e02f1a2823066bd860423996bea3e92c621bb064b"; + +/** The document says its contracts were built with debug symbols, and that changes the address. */ +const DEBUG_SYMBOLS = true; + +let source = ""; + +beforeAll(async () => { + source = await contractSource("issuance_factory.simf"); +}); + +function scriptPubKeyFor(argumentsJson: string, includeDebugSymbols = DEBUG_SYMBOLS): string { + const contract = new smplx.Contract(source, argumentsJson, "[]", includeDebugSymbols); + + try { + return contract.scriptPubKeyHex("liquid"); + } finally { + contract.free(); + } +} + +describe("a deployed covenant's parameters", () => { + test("reproduce the script the protocol's own document says its factory is locked by", () => { + expect(scriptPubKeyFor(ARGUMENTS)).toBe(DEPLOYED_SCRIPT_PUB_KEY); + }); + + test("and the address that script is written as", () => { + const contract = new smplx.Contract(source, ARGUMENTS, "[]", DEBUG_SYMBOLS); + + expect(contract.contractAddress("liquid")).toBe( + "ex1pg45gz7zucl2krj42qk0q9udzsgcxd0vxqs3ej6l286fvvgdmqe9s5w0cfg", + ); + + contract.free(); + }); + + // Without the mode the document declares, the same two integers build a different covenant. + // The check is here rather than in a comment because the mode is read from a field that was + // renamed once already. + test("only in the mode the document declares them built in", () => { + expect(scriptPubKeyFor(ARGUMENTS, false)).not.toBe(DEPLOYED_SCRIPT_PUB_KEY); + }); +}); + +/** + * Why the integers are written as decimal, demonstrated rather than asserted in prose. + * + * The compiler reads `0x…` as a hexadecimal literal of exactly the type's width. A count of 2 + * hex-prefixed is `0x2`, which is one digit and no whole number of bytes, so it fails loudly. + * That is the harmless half. The dangerous half is a value whose decimal spelling happens to + * be a legal width: it compiles, it derives an address, and it is a different number. + */ +describe("the encoding that would have been wrong", () => { + test("a hex-prefixed count of the wrong width is refused by the compiler", () => { + const wrong = ARGUMENTS.replace('"value":"2"', '"value":"0x2"'); + + expect(() => scriptPubKeyFor(wrong)).toThrow(); + }); + + test("but a hex-prefixed value of the right width is a different number, silently", () => { + const asDecimal = ARGUMENTS.replace( + '"REISSUANCE_FLAGS":{"type":"u64","value":"0"}', + '"REISSUANCE_FLAGS":{"type":"u64","value":"1000000000000000"}', + ); + const asHex = ARGUMENTS.replace( + '"REISSUANCE_FLAGS":{"type":"u64","value":"0"}', + '"REISSUANCE_FLAGS":{"type":"u64","value":"0x1000000000000000"}', + ); + + // Both compile. Both derive a valid address. Neither reports anything. + expect(scriptPubKeyFor(asDecimal)).toMatch(/^5120[0-9a-f]{64}$/); + expect(scriptPubKeyFor(asHex)).toMatch(/^5120[0-9a-f]{64}$/); + expect(scriptPubKeyFor(asDecimal)).not.toBe(scriptPubKeyFor(asHex)); + }); +}); 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..404e471 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts @@ -0,0 +1,961 @@ +// oxlint-disable consistent-function-scoping -- each helper builds the case it sits in, and reading it beside the assertion is the point +import { describe, expect, test } from "bun:test"; + +import { estimateFeeSats } from "@humid/tx-manifest"; +import { guardSpentInputs } from "@humid/tx-manifest"; +import { spentInputs } from "@humid/tx-manifest"; + +import { smplx as bindings, type SmplxBindings } 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, the +// shared fixture 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 instantiation moved to `smplxWasmForTests` when a second file needed the module. +// It has to happen once per process rather than once per file: the glue is a module and +// therefore a singleton, so a second instantiation repoints it at a different memory +// while the first one's objects are still reading the old one. + +// 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(bindings.sdkVersion()).toBe("0.0.9"); + }); + + test("compiles a contract to the same CMR as a native build", () => { + const contract = new bindings.Contract(PROBE_SOURCE); + + expect(contract.commitmentMerkleRoot()).toBe(PROBE_CMR); + }); + + test("derives a covenant address", () => { + const contract = new bindings.Contract(PROBE_SOURCE); + const address = contract.contractAddress("liquid-testnet"); + + expect(address.startsWith("tex1p")).toBe(true); + }); + + test("refuses a source that does not compile", () => { + const contract = new bindings.Contract("fn main() { this is not simplicityhl }"); + + expect(() => contract.commitmentMerkleRoot()).toThrow(); + }); + + test("rejects an unknown network by name", () => { + const contract = new bindings.Contract(PROBE_SOURCE); + + expect(() => contract.contractAddress("not-a-network")).toThrow(); + }); +}); + +// Compile-time parameters are what make one contract source into many covenant +// addresses. The address check the wallet performs is only meaningful if different +// parameters genuinely produce different addresses, so that is asserted rather than +// assumed. +describe("contract parameters", () => { + const P2PK_SOURCE = + "fn main() { jet::bip_0340_verify((param::PUB_KEY, jet::sig_all_hash()), witness::SIGNATURE) }"; + + const args = (pubkey: string) => JSON.stringify({ PUB_KEY: { type: "Pubkey", value: pubkey } }); + + // Generator points for 1*G and 2*G, from simplicityhl's own example fixtures. + const ALICE = "0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const BOB = "0xc6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + + test("compiles a parameterised contract", () => { + const contract = new bindings.Contract(P2PK_SOURCE, args(ALICE)); + + expect(contract.commitmentMerkleRoot()).toMatch(/^[0-9a-f]{64}$/); + }); + + // The one address in this file that money has actually sat at. Everything else here pins a + // value this module produced; this pins one the Liquid testnet chain holds, from the live + // runs of 2026-08-07 — `08e775d0…` paid a covenant at this address and `5c3a56a0…` spent it + // with a Simplicity witness the network accepted. + // + // It exists because a check that only compares this module against itself cannot notice the + // module being replaced. A stale copy of the wasm binary in node_modules did exactly that + // for most of a day: the JavaScript glue is hardlinked and refreshes on rebuild while the + // binary is a separate copy that only `bun install` replaces, so every suite ran new glue + // against an old module and passed. This assertion would still have passed then — the + // address was the same — which is the point: it is the one that ties a derivation to money + // rather than to a previous run of the same code. + test("derives the covenant address the live runs put money at", () => { + const contract = new bindings.Contract( + P2PK_SOURCE, + args("0xc9fda1adfd5af94ccbe2a6cd72433fc6dc1731fe3f8b3fee90ca96367ca71041"), + ); + + expect(contract.contractAddress("liquid-testnet")).toBe( + "tex1plmdx307xcw7hfewf7pmmfum0l6tkr35keugxzczc2azmqw4uzlasst2a40", + ); + + contract.free(); + }); + + test("different parameters produce different covenant addresses", () => { + const alice = new bindings.Contract(P2PK_SOURCE, args(ALICE)); + const bob = new bindings.Contract(P2PK_SOURCE, args(BOB)); + + expect(alice.contractAddress("liquid-testnet")).not.toBe(bob.contractAddress("liquid-testnet")); + }); + + test("the same parameters produce the same covenant address", () => { + const first = new bindings.Contract(P2PK_SOURCE, args(ALICE)); + const second = new bindings.Contract(P2PK_SOURCE, args(ALICE)); + + expect(first.contractAddress("liquid-testnet")).toBe(second.contractAddress("liquid-testnet")); + }); + + test("refuses malformed argument JSON when the contract is constructed", () => { + expect(() => new bindings.Contract(P2PK_SOURCE, "{ not json")).toThrow(); + }); + + test("refuses a parameterised contract given no parameters", () => { + const contract = new bindings.Contract(P2PK_SOURCE); + + expect(() => contract.commitmentMerkleRoot()).toThrow(); + }); +}); + +// A BIP39 test vector, not a wallet mnemonic. Its derived values are stable, which is +// what makes them assertable. +const TEST_MNEMONIC = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + +describe("wallet signer", () => { + test("derives an address for the network it was built for", () => { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + + expect(signer.address()).toMatch(/^tex1/); + signer.free(); + }); + + test("derives a different address on a different network from the same mnemonic", () => { + const testnet = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + const mainnet = new bindings.WalletSigner(TEST_MNEMONIC, "liquid"); + + expect(testnet.address()).not.toBe(mainnet.address()); + testnet.free(); + mainnet.free(); + }); + + test("derives the same values twice from the same mnemonic", () => { + const first = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + const second = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + + expect(first.schnorrPublicKey()).toBe(second.schnorrPublicKey()); + expect(first.address()).toBe(second.address()); + first.free(); + second.free(); + }); + + test("exposes an x-only key of the right shape for a covenant parameter", () => { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + + expect(signer.schnorrPublicKey()).toMatch(/^[0-9a-f]{64}$/); + signer.free(); + }); + + // The confidential address is what a blinded output pays to; it must differ from the + // unblinded one or blinding is not happening. + test("the confidential address differs from the plain one", () => { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + + expect(signer.confidentialAddress()).not.toBe(signer.address()); + signer.free(); + }); + + test("refuses an unknown network", () => { + expect(() => new bindings.WalletSigner(TEST_MNEMONIC, "not-a-network")).toThrow(); + }); +}); + +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 bindings.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 bindings.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 bindings.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 bindings.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 bindings.TransactionBuilder(); + + expect(() => builder.addWalletInput(TXID, 0, "abcd")).toThrow(); + builder.free(); + }); + + test("refuses an asset id that is not one", () => { + const builder = new bindings.TransactionBuilder(); + + expect(() => builder.addOutput("0014" + "00".repeat(20), 1n, "not-an-asset")).toThrow(); + expect(builder.outputCount()).toBe(0); + builder.free(); + }); +}); + +// The whole Pay shape in one place: a wallet output funds a transaction, an output pays +// somewhere, and the module blinds, signs and finalises it. This is what the manifest +// runtime will drive; asserting it here means a break shows up as a failing test rather +// than as a transaction the network rejects. +describe("finalising a transaction", () => { + const TXID = "1".repeat(64); + // L-BTC on Liquid testnet, the policy asset the fee is paid in. + const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; + const FEE_RATE = 100; + + /** An explicit Elements output of `sats` of the policy asset, paying to `scriptHex`. */ + function encodeTxOut(sats: bigint, scriptHex: string): string { + const assetLe = (POLICY_ASSET.match(/../g) ?? []).toReversed().join(""); + const value = sats.toString(16).padStart(16, "0"); + const scriptLen = (scriptHex.length / 2).toString(16).padStart(2, "0"); + + return `01${assetLe}01${value}00${scriptLen}${scriptHex}`; + } + + function fundedBuilder(signer: InstanceType, sats: bigint) { + const builder = new bindings.TransactionBuilder(); + + builder.addWalletInput(TXID, 0, encodeTxOut(sats, signer.scriptPubKeyHex())); + + return builder; + } + + test("blinds, signs and finalises, returning a transaction and its fee", () => { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + const builder = fundedBuilder(signer, 100_000n); + + builder.addOutput(signer.scriptPubKeyHex(), 50_000n, POLICY_ASSET); + + builder.addChange(signer.scriptPubKeyHex()); + + const signed = signer.finalizeTransaction(builder, FEE_RATE); + + expect(signed.hex).toMatch(/^[0-9a-f]+$/); + expect(signed.txid).toMatch(/^[0-9a-f]{64}$/); + expect(signed.feeSats > 0n).toBe(true); + + signed.free(); + builder.free(); + signer.free(); + }); + + test("refuses when the inputs cannot cover the outputs and the fee", () => { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + const builder = fundedBuilder(signer, 1_000n); + + builder.addOutput(signer.scriptPubKeyHex(), 999_999n, POLICY_ASSET); + + builder.addChange(signer.scriptPubKeyHex()); + + expect(() => signer.finalizeTransaction(builder, FEE_RATE)).toThrow(); + + builder.free(); + signer.free(); + }); + + // The refusal moved with the change target: it is rejected when it is stated rather than + // when the transaction is signed, which is earlier and is where a caller can act on it. + test("refuses a change script it cannot parse, rather than sending change nowhere", () => { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + const builder = fundedBuilder(signer, 100_000n); + + builder.addOutput(signer.scriptPubKeyHex(), 50_000n, POLICY_ASSET); + + expect(() => builder.addChange("not-hex")).toThrow(); + + builder.free(); + signer.free(); + }); + + // Unset change is the SDK's own behaviour and this fork did not change it: the module + // returns change to the signer's derived address. Asserted because removing the parameter + // made it reachable by omission rather than only by argument. + test("finalises without a change target, returning change to the signer's own address", () => { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + const builder = fundedBuilder(signer, 100_000n); + + builder.addOutput(signer.scriptPubKeyHex(), 50_000n, POLICY_ASSET); + + const signed = signer.finalizeTransaction(builder, FEE_RATE); + + expect(signed.txid).toMatch(/^[0-9a-f]{64}$/); + + signed.free(); + builder.free(); + signer.free(); + }); +}); + +// A covenant input is an output locked by a Simplicity program. The dry-run is what tells +// the wallet the program actually runs against this transaction before anyone approves it. +describe("covenant inputs and the dry-run", () => { + const TXID = "2".repeat(64); + const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; + const P2PK_SOURCE = + "fn main() { jet::bip_0340_verify((param::PUB_KEY, jet::sig_all_hash()), witness::SIGNATURE) }"; + const ALICE = "0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const ARGS = JSON.stringify({ PUB_KEY: { type: "Pubkey", value: ALICE } }); + + /** The covenant's own output, so the program is spending exactly what it locks. */ + function covenantTxOut(sats: bigint): string { + const contract = new bindings.Contract(P2PK_SOURCE, ARGS); + const script = contract.scriptPubKeyHex("liquid-testnet"); + const assetLe = (POLICY_ASSET.match(/../g) ?? []).toReversed().join(""); + const value = sats.toString(16).padStart(16, "0"); + const scriptLen = (script.length / 2).toString(16).padStart(2, "0"); + + return `01${assetLe}01${value}00${scriptLen}${script}`; + } + + test("takes a covenant input", () => { + const builder = new bindings.TransactionBuilder(); + + builder.addContractInput(TXID, 0, covenantTxOut(100_000n), P2PK_SOURCE, ARGS); + + expect(builder.inputCount()).toBe(1); + builder.free(); + }); + + test("refuses a witness set it cannot parse", () => { + const builder = new bindings.TransactionBuilder(); + + expect(() => + builder.addContractInput(TXID, 0, covenantTxOut(1n), P2PK_SOURCE, ARGS, "{ not json"), + ).toThrow(); + expect(builder.inputCount()).toBe(0); + builder.free(); + }); + + // The decisive question for a pre-approval dry-run: does a signature-checking covenant + // execute when its signature witness has not been produced yet? + test("records what a zero-witness dry-run of a signature covenant actually does", () => { + const builder = new bindings.TransactionBuilder(); + const contract = new bindings.Contract(P2PK_SOURCE, ARGS); + + builder.addContractInput(TXID, 0, covenantTxOut(100_000n), P2PK_SOURCE, ARGS); + builder.addOutput(contract.scriptPubKeyHex("liquid-testnet"), 90_000n, POLICY_ASSET); + + let outcome = "ran"; + + try { + builder.dryRunContractInput(0, "liquid-testnet"); + } catch (error) { + outcome = String(error); + } + + // Asserting the observed behaviour rather than a hoped-for one: a program that + // asserts a signature cannot pass before the signature exists. + expect(outcome).not.toBe("ran"); + + builder.free(); + }); + + test("refuses to dry-run an input that is not a covenant", () => { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + const builder = new bindings.TransactionBuilder(); + const assetLe = (POLICY_ASSET.match(/../g) ?? []).toReversed().join(""); + const walletTxOut = `01${assetLe}0100000000000186a000${"16"}${signer.scriptPubKeyHex()}`; + + builder.addWalletInput(TXID, 0, walletTxOut); + + expect(() => builder.dryRunContractInput(0, "liquid-testnet")).toThrow(); + + builder.free(); + signer.free(); + }); + + test("refuses to dry-run an input that does not exist", () => { + const builder = new bindings.TransactionBuilder(); + + expect(() => builder.dryRunContractInput(4, "liquid-testnet")).toThrow(); + builder.free(); + }); +}); + +// Spending a covenant that authenticates whoever spends it needs a signature over the +// transaction being built, which only the signer can make. Naming the witness is how it is +// asked for; without that name the spend fails at signing with "missing witness", which is +// what this wallet did until it was measured. +describe("signing a covenant that authenticates its spender", () => { + const TXID = "3".repeat(64); + const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; + const P2PK_SOURCE = + "fn main() { jet::bip_0340_verify((param::PUB_KEY, jet::sig_all_hash()), witness::SIGNATURE) }"; + const RATE = 1000; + + function txOut(sats: bigint, scriptHex: string): string { + const assetLe = (POLICY_ASSET.match(/../g) ?? []).toReversed().join(""); + const value = sats.toString(16).padStart(16, "0"); + const len = (scriptHex.length / 2).toString(16).padStart(2, "0"); + + return `01${assetLe}01${value}00${len}${scriptHex}`; + } + + /** A transaction of the given shape, signed, returning the fee it was charged. */ + function feeFor(walletInputs: number, covenantInputs: number, outputs: number, name?: string) { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + const builder = new bindings.TransactionBuilder(); + const args = JSON.stringify({ + PUB_KEY: { type: "Pubkey", value: `0x${signer.schnorrPublicKey()}` }, + }); + const covenantScript = new bindings.Contract(P2PK_SOURCE, args).scriptPubKeyHex( + "liquid-testnet", + ); + + try { + for (let i = 0; i < covenantInputs; i += 1) { + builder.addContractInput( + TXID, + i, + txOut(200_000n, covenantScript), + P2PK_SOURCE, + args, + undefined, + name, + ); + } + + for (let i = 0; i < walletInputs; i += 1) { + builder.addWalletInput(TXID, 50 + i, txOut(200_000n, signer.scriptPubKeyHex())); + } + + for (let i = 0; i < outputs; i += 1) { + builder.addOutput(signer.scriptPubKeyHex(), 10_000n, POLICY_ASSET); + } + + builder.addChange(signer.scriptPubKeyHex()); + + const signed = signer.finalizeTransaction(builder, RATE); + const fee = signed.feeSats; + + signed.free(); + + return fee; + } finally { + builder.free(); + signer.free(); + } + } + + test("signs the covenant when the witness needing a signature is named", () => { + expect(feeFor(1, 1, 1, "SIGNATURE") > 0n).toBe(true); + }); + + // The regression: this is exactly what the wallet did before the witness was named. + test("fails to satisfy the program when it is not", () => { + expect(() => feeFor(1, 1, 1)).toThrow(/missing witness for SIGNATURE/); + }); + + // At a rate of 1000 sat/kvb the fee in satoshis is the vsize, so these are sizes. They + // are what a fee estimate has to be built from, and a toolchain change that moves them + // moves every fee with them — which is why they are asserted rather than noted. + describe("what a transaction of each shape weighs", () => { + test("one wallet input and one output, plus the change and fee smplx adds", () => { + expect(feeFor(1, 0, 1)).toBe(257n); + }); + + test("a further wallet input costs 69", () => { + expect(feeFor(2, 0, 1) - feeFor(1, 0, 1)).toBe(69n); + }); + + test("a further output costs 67", () => { + expect(feeFor(1, 0, 2) - feeFor(1, 0, 1)).toBe(67n); + }); + + // A covenant input's witness is the Simplicity witness, so its size belongs to the + // program rather than to the shape. This is p2pk's, the smallest real one there is. + test("a p2pk covenant input costs 87, and a second 86", () => { + expect(feeFor(1, 1, 1, "SIGNATURE") - feeFor(1, 0, 1)).toBe(87n); + expect(feeFor(1, 2, 1, "SIGNATURE") - feeFor(1, 1, 1, "SIGNATURE")).toBe(86n); + }); + }); +}); + +// Extra taproot leaves are payloads appended to the tree beside the program's own leaf, and +// their bytes are as much a part of the covenant address as the parameters are. The shape of +// that tree is consensus-visible: the reference implementation folds it left and every +// deployed covenant address was derived that way, so a tree built any other way produces a +// well-formed address for a contract whose funds sit elsewhere. +describe("extra taproot leaves", () => { + const SOURCE = "fn main() { assert!(jet::eq_32(witness::A, witness::B)); }"; + const LEAF = (byte: string) => `0x${byte.repeat(64)}`; + + function addressWith(...leaves: string[]) { + const contract = new bindings.Contract(SOURCE, undefined, JSON.stringify(leaves)); + + return contract.contractAddress("liquid-testnet"); + } + + test("no extra leaves derives the address the module always derived", () => { + expect(addressWith()).toBe("tex1phpq2t7y3236nxvudhfx7md9p0h3m9vlsskq5nec9trzcue6k979sk55dr6"); + }); + + test("an extra leaf changes the address", () => { + expect(addressWith(LEAF("11"))).not.toBe(addressWith()); + }); + + test("the leaves' order is part of the address", () => { + expect(addressWith(LEAF("11"), LEAF("22"))).not.toBe(addressWith(LEAF("22"), LEAF("11"))); + }); + + test("the same leaves derive the same address twice", () => { + expect(addressWith(LEAF("11"), LEAF("22"))).toBe(addressWith(LEAF("11"), LEAF("22"))); + }); + + // The format's leaves are any length — `bytes` has no length and `pad_to` exists so a + // value can be shorter — and the module's held them as a fixed thirty-two bytes until + // this. A leaf of another length is a different leaf, not a padded one. + test("a leaf shorter than thirty-two bytes is its own leaf, not a padded one", () => { + expect(addressWith("0x0102")).not.toBe(addressWith(`0x0102${"00".repeat(30)}`)); + }); + + test("a leaf longer than thirty-two bytes is accepted", () => { + expect(addressWith(`0x${"33".repeat(64)}`)).toMatch(/^tex1p/); + }); + + test("refuses a leaf that is not hex rather than deriving something", () => { + expect(() => addressWith("0xzz")).toThrow(); + }); +}); + +// The input guard reads the outpoints out of a finished transaction's own bytes rather than +// asking the module what it spent — a module's account of itself cannot answer whether the +// module did something it was not asked to. That only works if the parser agrees with what +// the module actually serialises, which is what this checks. +describe("what a signed transaction says it spends", () => { + const TXID = "4".repeat(64); + const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; + + function txOut(sats: bigint, scriptHex: string): string { + const assetLe = (POLICY_ASSET.match(/../g) ?? []).toReversed().join(""); + const value = sats.toString(16).padStart(16, "0"); + const len = (scriptHex.length / 2).toString(16).padStart(2, "0"); + + return `01${assetLe}01${value}00${len}${scriptHex}`; + } + + function signSpending(vouts: number[]) { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + const builder = new bindings.TransactionBuilder(); + + try { + for (const vout of vouts) { + builder.addWalletInput(TXID, vout, txOut(200_000n, signer.scriptPubKeyHex())); + } + + builder.addOutput(signer.scriptPubKeyHex(), 10_000n, POLICY_ASSET); + + builder.addChange(signer.scriptPubKeyHex()); + + const signed = signer.finalizeTransaction(builder, 1000); + const hex = signed.hex; + + signed.free(); + + return hex; + } finally { + builder.free(); + signer.free(); + } + } + + test("the parser reads back the outpoint that went in", () => { + const result = spentInputs(signSpending([3])); + + expect(result.ok && result.spent).toEqual([{ txid: TXID, vout: 3 }]); + }); + + test("and reads several back in the order they were added", () => { + const result = spentInputs(signSpending([1, 5])); + + expect(result.ok && result.spent).toEqual([ + { txid: TXID, vout: 1 }, + { txid: TXID, vout: 5 }, + ]); + }); + + test("the guard passes a transaction spending exactly what the wallet chose", () => { + const chosen = [ + { txid: TXID, vout: 1 }, + { txid: TXID, vout: 5 }, + ]; + + expect( + guardSpentInputs(signSpending([1, 5]), { covenantInputs: [], walletInputs: chosen }), + ).toEqual({ ok: true }); + }); + + test("and refuses one spending an outpoint the wallet did not choose", () => { + const result = guardSpentInputs(signSpending([1, 5]), { + covenantInputs: [], + walletInputs: [{ txid: TXID, vout: 1 }], + }); + + expect(result.ok).toBe(false); + }); +}); + +// Golden vectors: the exact addresses this module derives, pinned. They exist because the +// failure mode of every encoding, ordering and convergence decision in the runtime is a +// well-formed address for the wrong contract, which no test that recomputes the expectation +// alongside the value can catch. These are the compiler's own p2pk contract, authored +// upstream, so what they pin is not our own consistency with ourselves. +describe("golden covenant addresses", () => { + // simplicityhl-0.6.0/examples/p2pk.simf, with its parameter renamed to the one the + // published manifest uses. Two identifiers differ from upstream and nothing else. + const UPSTREAM_P2PK = + "fn main() { jet::bip_0340_verify((param::PUB_KEY, jet::sig_all_hash()), witness::SIGNATURE) }"; + // simplicityhl-0.6.0/examples/p2pk.args, verbatim. + const ALICE = "0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + + function address(input: { + debug?: boolean; + leaves?: string[]; + network?: string; + pubkey?: string; + }): string { + const args = JSON.stringify({ PUB_KEY: { type: "Pubkey", value: input.pubkey ?? ALICE } }); + const contract = new bindings.Contract( + UPSTREAM_P2PK, + args, + input.leaves ? JSON.stringify(input.leaves) : undefined, + input.debug, + ); + + return contract.contractAddress(input.network ?? "liquid-testnet"); + } + + test("the parameterised contract, on testnet", () => { + expect(address({})).toBe("tex1peavhc0s5wcm0ans49jxg445enyh6uuwl8radea7expf2syt5rkzqjre6vm"); + }); + + test("the same contract on mainnet is a different address, and a fixed one", () => { + expect(address({ network: "liquid" })).toBe( + "ex1peavhc0s5wcm0ans49jxg445enyh6uuwl8radea7expf2syt5rkzqn6taa5", + ); + }); + + // Debug symbols change the CMR and therefore the address. The wallet builds each contract + // the way its protocol declares, so both are values a real protocol could sit at. + test("built with debug symbols it is a different address again", () => { + expect(address({ debug: true })).not.toBe(address({ debug: false })); + }); + + test("and that address is fixed too", () => { + expect(address({ debug: true })).toBe( + "tex1p8vjx8uana9z0k8670v9aqgys02we6yy0sndhjkapwhd76k2ux9vqv8rzsv", + ); + }); + + // Extra leaves are appended in declaration order, and the tree is folded left. Three of + // them is where a balanced tree would diverge, so it is the count worth pinning. + test("with three extra leaves, where a balanced tree would differ", () => { + const leaves = [`0x${"11".repeat(32)}`, `0x${"22".repeat(32)}`, `0x${"33".repeat(32)}`]; + + expect(address({ leaves })).toBe( + "tex1p70jezh2969ew3w29h2hvpwtl9eh4mzyuv8srpn9rfa8t4uputp7schqmqt", + ); + }); + + test("their order is part of the address", () => { + const forward = address({ leaves: [`0x${"11".repeat(32)}`, `0x${"22".repeat(32)}`] }); + const reversed = address({ leaves: [`0x${"22".repeat(32)}`, `0x${"11".repeat(32)}`] }); + + expect(forward).not.toBe(reversed); + }); + + test("a different parameter is a different covenant", () => { + expect(address({ pubkey: `0x${"01".repeat(32)}` })).not.toBe(address({})); + }); +}); + +// The deployed protocol's own contracts. These are the sources `lending`, `lending_v2` and +// `lending_v3` reference, and until they were vendored nothing could check that this wallet +// compiles what a real protocol deployed rather than only what we wrote to suit it. +describe("the simplicity-lending contracts", () => { + const CONTRACTS = "../../../../../../../../packages/tx-manifest/src/__fixtures__/contracts"; + + async function source(name: string): Promise { + const { readFile: read } = await import("node:fs/promises"); + const { dirname, join } = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + + return read(join(dirname(fileURLToPath(import.meta.url)), CONTRACTS, `${name}.simf`), "utf8"); + } + + const U256 = `0x${"11".repeat(32)}`; + const ARGUMENTS: Record> = { + asset_auth: { + ASSET_AMOUNT: { type: "u64", value: "0x0000000000000001" }, + ASSET_ID: { type: "u256", value: U256 }, + WITH_ASSET_BURN: { type: "bool", value: "true" }, + }, + asset_auth_vault: { + FINALIZED_VAULT_COV_HASH: { type: "u256", value: U256 }, + IS_ACTIVE: { type: "bool", value: "true" }, + KEEPER_AUTH_ASSET_AMOUNT: { type: "u64", value: "0x0000000000000001" }, + KEEPER_AUTH_ASSET_ID: { type: "u256", value: U256 }, + SUPPLIER_AUTH_ASSET_ID: { type: "u256", value: U256 }, + VAULT_ASSET_ID: { type: "u256", value: U256 }, + WITH_KEEPER_ASSET_BURN: { type: "bool", value: "true" }, + WITH_SUPPLIER_ASSET_BURN: { type: "bool", value: "true" }, + }, + issuance_factory: { + ISSUING_UTXOS_COUNT: { type: "u8", value: "0x01" }, + REISSUANCE_FLAGS: { type: "u64", value: "0x0000000000000001" }, + }, + lending: { + BORROWER_NFT_ASSET_ID: { type: "u256", value: U256 }, + COLLATERAL_AMOUNT: { type: "u64", value: "0x0000000000000001" }, + COLLATERAL_ASSET_ID: { type: "u256", value: U256 }, + FINALIZED_LENDER_VAULT_COV_HASH: { type: "u256", value: U256 }, + FINALIZED_PROTOCOL_FEE_VAULT_COV_HASH: { type: "u256", value: U256 }, + LENDER_NFT_ASSET_ID: { type: "u256", value: U256 }, + LENDER_VAULT_COV_HASH: { type: "u256", value: U256 }, + LOAN_EXPIRATION_TIME: { type: "u32", value: "0x00000001" }, + PRINCIPAL_AMOUNT: { type: "u64", value: "0x0000000000000001" }, + PRINCIPAL_ASSET_ID: { type: "u256", value: U256 }, + PRINCIPAL_INTEREST_RATE: { type: "u64", value: "0x0000000000000001" }, + PRINCIPAL_OUTPUT_SCRIPT_HASH: { type: "u256", value: U256 }, + PROTOCOL_FEE_VAULT_COV_HASH: { type: "u256", value: U256 }, + }, + script_auth: { SCRIPT_HASH: { type: "u256", value: U256 } }, + }; + + // The commitment merkle root is what the covenant address is built from, so pinning it + // pins compilation itself: a compiler change, a parameter-encoding change or a debug-mode + // change all move it, and each of those would otherwise move an address silently. + const GOLDEN: Record = { + asset_auth: "20fd155233a87fcc910a66f0395dc511ad08c5d7a8a9d774881de5520ac0ebf1", + asset_auth_vault: "8233cab286b79ac63ccac8f2fc67722cfb1ee9a5ca3e1d4179d09f5a9e1610de", + issuance_factory: "f610387190b1bc269d980bb391063fae96ea123dfce9a078936a1945d8675504", + lending: "34019215b7a6edffbf69e47d3795cc951f9962b723ecb3cf72f1f551669afe5c", + script_auth: "9c89c4aa4a20603c4e21b073d71238c37a6285b8e17b5bf19af1c74519781c18", + }; + + for (const [name, cmr] of Object.entries(GOLDEN)) { + test(`${name} compiles to a fixed commitment merkle root`, async () => { + const contract = new bindings.Contract( + await source(name), + JSON.stringify(ARGUMENTS[name]), + undefined, + undefined, + ); + + expect(contract.commitmentMerkleRoot()).toBe(cmr); + }); + } + + // lending.simf is the reason the bounded fixed point exists: four of its thirteen + // parameters are other covenants' script hashes, two of them the finalised form of the + // same vaults. A different hash going in is a different address coming out. + test("lending's address follows the covenant hashes compiled into it", async () => { + const text = await source("lending"); + const other = { + ...ARGUMENTS.lending, + LENDER_VAULT_COV_HASH: { type: "u256", value: `0x${"22".repeat(32)}` }, + }; + + expect( + new bindings.Contract(text, JSON.stringify(other), undefined, undefined).contractAddress( + "liquid-testnet", + ), + ).not.toBe( + new bindings.Contract( + text, + JSON.stringify(ARGUMENTS.lending), + undefined, + undefined, + ).contractAddress("liquid-testnet"), + ); + }); +}); + +// AC-09's second clause, measured rather than reasoned about. The wallet's estimate and the +// fee the module charges are different numbers — one is a model of an unsigned shape and the +// other the weight of a signed transaction — so what has to hold is that the transaction +// balances against whichever one is charged, whatever the estimate said. +describe("a transaction balances against the fee that is charged", () => { + const TXID = "5".repeat(64); + const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; + + function txOut(sats: bigint, scriptHex: string): string { + const assetLe = (POLICY_ASSET.match(/../g) ?? []).toReversed().join(""); + const value = sats.toString(16).padStart(16, "0"); + const len = (scriptHex.length / 2).toString(16).padStart(2, "0"); + + return `01${assetLe}01${value}00${len}${scriptHex}`; + } + + /** Funds `funded`, pays `paid`, and reports what the module charged for it. */ + function build(funded: bigint, paid: bigint, rate: number) { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + const builder = new bindings.TransactionBuilder(); + + try { + builder.addWalletInput(TXID, 0, txOut(funded, signer.scriptPubKeyHex())); + builder.addOutput(signer.scriptPubKeyHex(), paid, POLICY_ASSET); + + builder.addChange(signer.scriptPubKeyHex()); + + const signed = signer.finalizeTransaction(builder, rate); + const fee = signed.feeSats; + + signed.free(); + + return fee; + } finally { + builder.free(); + signer.free(); + } + } + + // The wallet plans an output as "what this input holds, less the fee", using its own + // estimate. Whatever that estimate was, the module charges its own figure and makes the + // transaction balance — which is why an estimate that is merely close is safe. + test("the charged fee covers the difference the wallet did not pay out", () => { + const funded = 100_000n; + const estimated = estimateFeeSats( + { covenantInputs: 0, issuingInputs: 0, outputs: 1, walletInputs: 1 }, + 1000, + ); + const charged = build(funded, funded - estimated, 1000); + + expect(charged > 0n).toBe(true); + expect(charged <= estimated).toBe(true); + }); + + // Over-estimating is the safe direction: the surplus returns as change rather than + // leaving the transaction short. + test("an over-estimate leaves the transaction payable rather than short", () => { + const funded = 100_000n; + const generous = estimateFeeSats( + { covenantInputs: 2, issuingInputs: 0, outputs: 3, walletInputs: 3 }, + 1000, + ); + + expect(() => build(funded, funded - generous, 1000)).not.toThrow(); + }); + + // Under-paying the fee is what the wallet must never do, and the module refuses it rather + // than producing a transaction the network would drop. + test("paying out everything leaves nothing for the fee, and is refused", () => { + expect(() => build(100_000n, 100_000n, 1000)).toThrow(); + }); +}); + +// The surcharge an issuance puts on the input carrying it, measured rather than modelled. +// The wallet plans the fee before anything is signed, so a model that did not know about +// issuance would under-state every action that creates an asset. +describe("what an issuance adds to the input carrying it", () => { + const TXID = "6".repeat(64); + const ISSUING_POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; + + function txOut(sats: bigint, scriptHex: string): string { + const assetLe = (ISSUING_POLICY_ASSET.match(/../g) ?? []).toReversed().join(""); + const value = sats.toString(16).padStart(16, "0"); + const len = (scriptHex.length / 2).toString(16).padStart(2, "0"); + + return `01${assetLe}01${value}00${len}${scriptHex}`; + } + + /** The same transaction twice, once with the funding input creating an asset. */ + function charged(issuing: boolean): bigint { + const signer = new bindings.WalletSigner(TEST_MNEMONIC, "liquid-testnet"); + const builder = new bindings.TransactionBuilder(); + + try { + const script = signer.scriptPubKeyHex(); + + if (issuing) { + builder + .addWalletIssuanceInput(TXID, 0, txOut(100_000n, script), 1_000n, 0n, undefined) + .free(); + } else { + builder.addWalletInput(TXID, 0, txOut(100_000n, script)); + } + + builder.addOutput(script, 10_000n, ISSUING_POLICY_ASSET); + builder.addChange(script); + + const signed = signer.finalizeTransaction(builder, 1000); + const fee = signed.feeSats; + + signed.free(); + + return fee; + } finally { + builder.free(); + signer.free(); + } + } + + // At 1000 sat/kvb the fee charged is the vsize, so these are weights. + test("is what the model says it is", () => { + const plain = charged(false); + const issuing = charged(true); + + expect(plain).toBe( + estimateFeeSats({ covenantInputs: 0, issuingInputs: 0, outputs: 1, walletInputs: 1 }, 1000), + ); + expect(issuing).toBe( + estimateFeeSats({ covenantInputs: 0, issuingInputs: 1, outputs: 1, walletInputs: 1 }, 1000), + ); + expect(issuing - plain).toBe(74n); + }); +}); 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..c8c782b --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/smplxWasmForTests.ts @@ -0,0 +1,54 @@ +// 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. Two test files each + * bootstrapping their own module turned sixty passing assertions into failures inside the + * bindings, in a suite where neither file was doing anything wrong on its own. + * + * 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(); +} + +/** Reads one of the vendored contract sources the published manifests reference. */ +export async function contractSource(name: string): Promise { + return readFile(require.resolve(`@humid/tx-manifest/fixtures/contracts/${name}`), "utf8"); +} + +export { bindings as smplx }; +export type { SmplxBindings }; diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/valueWiredCovenant.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/valueWiredCovenant.test.ts new file mode 100644 index 0000000..b732058 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/valueWiredCovenant.test.ts @@ -0,0 +1,183 @@ +import { beforeAll, describe, expect, test } from "bun:test"; + +import { contractSource, smplx } from "./smplxWasmForTests"; + +/** + * The compiler end of the parameters a deployment writes as bare values. + * + * `tx-manifest` proves that reading a live protocol's published document produces exactly the + * argument strings below, and that every value in them was typed by a declaration rather than by + * its appearance. It holds no compiler, by design. This proves the declarations it was typed + * against are the compiler's own, and that the strings build a covenant. + * + * **Where the type comes from, and why it has to be asked for.** `SimplicityHL` has no syntax + * that declares a compile parameter's type. `param::NAME` is written where a value is wanted and + * the type checker gives it the type that position demands — `simplicityhl` 0.6.0 inserts the + * parameter into the program's global map under the expected type of the expression it stands in + * for (`src/ast.rs` L1346-1350). So there is no declaration in the source to read: the type is a + * result of analysing the source, and the compiler is the only thing that can state it. + * + * **Nothing here was compared against a chain.** The published document records a deployed + * scriptPubKey for its factory and for none of these, and the asset ids are invented, so these + * addresses are reproducible rather than confirmed. Confirming one needs a deployed instance of + * these covenants whose script is either published or readable from Liquid. + */ + +const MIDDLE = "00".repeat(30); +const ZERO_HASH = "00".repeat(32); + +/** Kept character for character identical to `tx-manifest`'s own copy. */ +const PRINCIPAL_ASSET_AUTH = + `{"ASSET_ID":{"type":"u256","value":"0x0b${MIDDLE}b0"},` + + '"ASSET_AMOUNT":{"type":"u64","value":"1"},' + + '"WITH_ASSET_BURN":{"type":"bool","value":"false"}}'; + +const LENDER_VAULT_FINALIZED = + `{"VAULT_ASSET_ID":{"type":"u256","value":"0x0a${MIDDLE}a0"},` + + `"KEEPER_AUTH_ASSET_ID":{"type":"u256","value":"0x0c${MIDDLE}c0"},` + + `"SUPPLIER_AUTH_ASSET_ID":{"type":"u256","value":"0x0b${MIDDLE}b0"},` + + '"KEEPER_AUTH_ASSET_AMOUNT":{"type":"u64","value":"1"},' + + `"FINALIZED_VAULT_COV_HASH":{"type":"u256","value":"0x${ZERO_HASH}"},` + + '"IS_ACTIVE":{"type":"bool","value":"false"},' + + '"WITH_KEEPER_ASSET_BURN":{"type":"bool","value":"true"},' + + '"WITH_SUPPLIER_ASSET_BURN":{"type":"bool","value":"true"}}'; + +const PROTOCOL_FEE_VAULT_FINALIZED = LENDER_VAULT_FINALIZED.replace( + '"WITH_KEEPER_ASSET_BURN":{"type":"bool","value":"true"}', + '"WITH_KEEPER_ASSET_BURN":{"type":"bool","value":"false"}', +).replace( + `"KEEPER_AUTH_ASSET_ID":{"type":"u256","value":"0x0c${MIDDLE}c0"}`, + `"KEEPER_AUTH_ASSET_ID":{"type":"u256","value":"0x0d${MIDDLE}d0"}`, +); + +/** The document says its contracts were built with debug symbols, and that changes the address. */ +const DEBUG_SYMBOLS = true; + +let assetAuth = ""; +let assetAuthVault = ""; + +beforeAll(async () => { + assetAuth = await contractSource("asset_auth.simf"); + assetAuthVault = await contractSource("asset_auth_vault.simf"); +}); + +function scriptPubKeyFor(source: string, argumentsJson: string): string { + const contract = new smplx.Contract(source, argumentsJson, "[]", DEBUG_SYMBOLS); + const script = contract.scriptPubKeyHex("liquid"); + + contract.free(); + + return script; +} + +describe("what the contracts declare their parameters to be", () => { + test("asset_auth declares a count, an id and a flag", () => { + expect(JSON.parse(smplx.contractParameterTypes(assetAuth))).toEqual({ + ASSET_AMOUNT: "u64", + ASSET_ID: "u256", + WITH_ASSET_BURN: "bool", + }); + }); + + test("asset_auth_vault declares three flags among its eight", () => { + expect(JSON.parse(smplx.contractParameterTypes(assetAuthVault))).toEqual({ + FINALIZED_VAULT_COV_HASH: "u256", + IS_ACTIVE: "bool", + KEEPER_AUTH_ASSET_AMOUNT: "u64", + KEEPER_AUTH_ASSET_ID: "u256", + SUPPLIER_AUTH_ASSET_ID: "u256", + VAULT_ASSET_ID: "u256", + WITH_KEEPER_ASSET_BURN: "bool", + WITH_SUPPLIER_ASSET_BURN: "bool", + }); + }); + + /** + * The reading needs no arguments, which is the whole point of it: the arguments cannot be + * built until the types are known, so anything that had to be given them first would be + * circular. Asserted against a contract whose parameters nothing here supplies. + */ + test("and are readable from the source alone, with no arguments supplied", async () => { + expect( + JSON.parse(smplx.contractParameterTypes(await contractSource("lending.simf"))), + ).toMatchObject({ LOAN_EXPIRATION_TIME: "u32", PRINCIPAL_AMOUNT: "u64" }); + }); + + test("a source that is not a program is refused rather than answered", () => { + expect(() => smplx.contractParameterTypes("fn main() { this is not simplicity }")).toThrow(); + }); +}); + +describe("the argument strings tx-manifest builds from those declarations", () => { + test("build the covenant behind claiming the principal", () => { + expect(scriptPubKeyFor(assetAuth, PRINCIPAL_ASSET_AUTH)).toMatch(/^5120[0-9a-f]{64}$/); + }); + + test("build the lender's finalised vault", () => { + expect(scriptPubKeyFor(assetAuthVault, LENDER_VAULT_FINALIZED)).toMatch(/^5120[0-9a-f]{64}$/); + }); + + test("build the protocol fee's finalised vault", () => { + expect(scriptPubKeyFor(assetAuthVault, PROTOCOL_FEE_VAULT_FINALIZED)).toMatch( + /^5120[0-9a-f]{64}$/, + ); + }); + + /** + * The two vaults differ by one word in the document. If a flag were read as a flag rather + * than as the type its contract declares, both would still compile — to the same address for + * one of them and the wrong address for the other. + */ + test("and the two vaults are different covenants, because one flag differs", () => { + expect(scriptPubKeyFor(assetAuthVault, LENDER_VAULT_FINALIZED)).not.toBe( + scriptPubKeyFor(assetAuthVault, PROTOCOL_FEE_VAULT_FINALIZED), + ); + }); +}); + +/** + * Why the width had to be asked for rather than picked. + * + * A count of one fits every integer type there is, so nothing about the value narrows it. What + * saves a wrong pick from being silent is that the compiler requires an argument's type to equal + * its parameter's exactly — but "saved by a refusal deep in the compiler" is not the same as + * knowing, and the refusal names neither the document nor the parameter. + */ +describe("the widths that would have been wrong", () => { + for (const wrong of ["u8", "u16", "u32", "u128", "u256"]) { + test(`the same count declared ${wrong} does not build a covenant at all`, () => { + const mutated = PRINCIPAL_ASSET_AUTH.replace( + '"ASSET_AMOUNT":{"type":"u64","value":"1"}', + `"ASSET_AMOUNT":{"type":"${wrong}","value":"1"}`, + ); + + expect(() => scriptPubKeyFor(assetAuth, mutated)).toThrow(); + }); + } + + test("and a flag given an integer type instead of its own is refused too", () => { + const mutated = PRINCIPAL_ASSET_AUTH.replace( + '"WITH_ASSET_BURN":{"type":"bool","value":"false"}', + '"WITH_ASSET_BURN":{"type":"u8","value":"0"}', + ); + + expect(() => scriptPubKeyFor(assetAuth, mutated)).toThrow(); + }); + + /** + * The half that is not saved by a refusal. Both words are legal `bool`, so setting the wrong + * one compiles, derives an address, and reports nothing — which is why the word is read from + * the document and never defaulted. + */ + test("but the wrong word for a flag is silent, and a different covenant", () => { + const flipped = PRINCIPAL_ASSET_AUTH.replace( + '"WITH_ASSET_BURN":{"type":"bool","value":"false"}', + '"WITH_ASSET_BURN":{"type":"bool","value":"true"}', + ); + + expect(scriptPubKeyFor(assetAuth, flipped)).toMatch(/^5120[0-9a-f]{64}$/); + expect(scriptPubKeyFor(assetAuth, flipped)).not.toBe( + scriptPubKeyFor(assetAuth, PRINCIPAL_ASSET_AUTH), + ); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/application/backends/LiquidWalletBackend.ts b/apps/extension/src/core/chains/liquid/application/backends/LiquidWalletBackend.ts index 9d6f569..bbd0d46 100644 --- a/apps/extension/src/core/chains/liquid/application/backends/LiquidWalletBackend.ts +++ b/apps/extension/src/core/chains/liquid/application/backends/LiquidWalletBackend.ts @@ -32,6 +32,13 @@ export type LiquidWalletAccount = { * group (the default account) leave it undefined, and the snapshot lookup is simply skipped. */ accountGroupId?: AccountGroupId; + /** + * The BIP-85 index this account's keys derive at, threaded from the resolve input. + * Group 0 is the master seed's own account; group N derives a child mnemonic at N. + * Carried out of resolution so a caller that needs the account's own key material can + * derive it without re-deciding which group it is looking at. + */ + accountGroupIndex?: number; accountIdentifier: string; chain: LiquidChainRecord; chainId: LiquidChainId; @@ -123,11 +130,36 @@ export type LiquidWalletBackend = { getActivity: (account: LiquidWalletAccount, rawAssetId: string) => LiquidActivityEntry[]; getBalance: (account: LiquidWalletAccount, rawAssetId: string) => string; getReceiveAddress: (account: LiquidWalletAccount) => { address: string; index: number }; + /** + * The address a contract action can spend from, which is not the one shown for receiving. + * + * The signing module derives a single key at the account's first external address and signs + * every wallet input with it, so that address is the whole of what a contract action can be + * funded from. An output paid back to this wallet anywhere else is money this path cannot + * spend again — and every protocol that hands a token back expects to spend it next. + */ + getSigningAddress: (account: LiquidWalletAccount) => { address: string; index: number }; getDescriptorEntries: ( account: LiquidWalletAccount, params: LiquidGetWalletDescriptorParams, ) => Promise; getUtxos: (account: LiquidWalletAccount, rawAssetId: string) => LiquidUTXO[]; + /** + * The wallet's unspent outputs that hide nothing. + * + * Separate from `getUtxos` because the chain library does not report these as the + * wallet's at all, and because only one path can use them: a contract action cannot + * spend an output whose amount is hidden. + */ + getExplicitUtxos: (account: LiquidWalletAccount, rawAssetId: string) => LiquidUTXO[]; + /** + * How high the chain is, as the wallet's own scan reached it. + * + * A covenant branch guarded by a lock height reads the transaction's locktime, and the + * wallet has to declare one. Answered from the scan rather than from an endpoint, because + * a plain chain-tip route is not universal across the backends this wallet supports. + */ + getTipHeight: (account: LiquidWalletAccount) => number; inspectTransfer: ( account: LiquidWalletAccount, params: LiquidSendTransferParams, diff --git a/apps/extension/src/core/chains/liquid/application/contractIdentity.test.ts b/apps/extension/src/core/chains/liquid/application/contractIdentity.test.ts new file mode 100644 index 0000000..e971d30 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/application/contractIdentity.test.ts @@ -0,0 +1,127 @@ +// oxlint-disable no-await-in-loop -- the cases run one at a time because each asserts about the signer being freed before the next takes one +import { describe, expect, test } from "bun:test"; + +import type { LiquidChainRecord } from "../chains/LiquidChainRecord"; +import { readLiquidContractIdentity } from "./contractIdentity"; + +// The two values a person needs before a contract action can be aimed anywhere: the +// address the contract SDK signs from, and the x-only key a covenant locking to this +// wallet is parameterised with. Neither was reachable before, which is why a live run +// could not be composed at all (DISC-132). + +const ADDRESS = "ert1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080"; +const KEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + +function chain(network: string): LiquidChainRecord { + return { settings: { network } } as unknown as LiquidChainRecord; +} + +function deps(freed: string[] = []) { + return { + loadSmplx: async () => ({ + WalletSigner: class { + constructor( + readonly mnemonic: string, + readonly network: string, + ) {} + address() { + return `${ADDRESS}:${this.network}`; + } + free() { + freed.push(this.mnemonic); + } + schnorrPublicKey() { + return KEY; + } + }, + }), + withMnemonic: async (_request: unknown, use: (mnemonic: string) => unknown): Promise => + use("about about about"), + } as never; +} + +describe("the contract signing identity", () => { + test("is the SDK signer's own address and key, not the wallet's", async () => { + const identity = await readLiquidContractIdentity( + { accountGroupIndex: 0, chain: chain("testnet"), keyManagerState: {} as never }, + deps(), + ); + + expect(identity).toEqual({ address: `${ADDRESS}:liquid-testnet`, schnorrPublicKey: KEY }); + }); + + test("is read on the chain's own network, so a regtest run gets regtest answers", async () => { + const identity = await readLiquidContractIdentity( + { accountGroupIndex: 0, chain: chain("regtest"), keyManagerState: {} as never }, + deps(), + ); + + expect(identity.address).toBe(`${ADDRESS}:elements-regtest`); + }); + + // The signer holds key material across the wasm boundary. Leaving one alive after the + // read would keep it there for as long as the worker lives. + test("releases the signer once the two values are out", async () => { + const freed: string[] = []; + + await readLiquidContractIdentity( + { accountGroupIndex: 0, chain: chain("mainnet"), keyManagerState: {} as never }, + deps(freed), + ); + + expect(freed).toEqual(["about about about"]); + }); + + test("refuses a network the SDK does not know rather than guessing one", async () => { + const read = readLiquidContractIdentity( + { accountGroupIndex: 0, chain: chain("signet"), keyManagerState: {} as never }, + deps(), + ); + + await expect(read).rejects.toThrow("signet"); + }); +}); + +// The screen this serves is per-account, and the account it shows is not necessarily the +// selected one. Reading the selected account's identity there would put one account's +// address and key on another account's screen with nothing to say so — and those are the +// values someone then funds and locks a covenant to. +describe("which account it reads", () => { + test("follows the group index it is given, so two accounts do not answer alike", async () => { + const seen: number[] = []; + const spy = { + loadSmplx: async () => ({ + WalletSigner: class { + constructor( + readonly mnemonic: string, + readonly network: string, + ) {} + address() { + return ADDRESS; + } + free() {} + schnorrPublicKey() { + return KEY; + } + }, + }), + withMnemonic: async ( + request: { accountGroupIndex: number }, + use: (mnemonic: string) => unknown, + ): Promise => { + seen.push(request.accountGroupIndex); + + return use(`mnemonic for ${request.accountGroupIndex}`); + }, + } as never; + + for (const accountGroupIndex of [0, 3]) { + await readLiquidContractIdentity( + { accountGroupIndex, chain: chain("testnet"), keyManagerState: {} as never }, + spy, + ); + } + + expect(seen).toEqual([0, 3]); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/application/contractIdentity.ts b/apps/extension/src/core/chains/liquid/application/contractIdentity.ts new file mode 100644 index 0000000..5d0bfe6 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/application/contractIdentity.ts @@ -0,0 +1,82 @@ +import type { KeyManagerState } from "@/core/key-manager/types"; + +import { withAccountMnemonic } from "../adapters/lwk/wallet/withAccountMnemonic"; +import { loadSmplxWasm } from "../adapters/smplx/loadSmplxWasm"; +import type { LiquidChainRecord } from "../chains/LiquidChainRecord"; + +/** The network names the SDK understands, keyed by the wallet's own network kind. */ +const SMPLX_NETWORKS: Record = { + mainnet: "liquid", + regtest: "elements-regtest", + testnet: "liquid-testnet", +}; + +/** + * The one identity a contract action is signed with. + * + * This is not the wallet's own address and is not interchangeable with it. A contract + * action can be funded only from the unblinded output at this one address, and change + * returns here rather than to a wallet change address. + * + * **The limit is this wallet's, not the signing module's.** An earlier version of this + * comment blamed the module, and the module's author said so on review. It takes a change + * target and a derivation path per input; this wallet supplies one change script — the + * signer's own — and no paths at all, so every wallet input is signed with the key at + * `m/84h/{1|1776}h/0h/0/0` because that is the default nothing here overrides. Lifting the + * limit is work in this method, not in the module. + * + * Both values are read-only and public: an address anyone can pay, and the x-only form + * of the same key. Nothing here returns a secret. + */ +export type LiquidContractIdentity = { + /** The unblinded address contract actions can be funded from, and where change returns. */ + address: string; + /** The x-only public key a covenant locking to "the wallet's key" is parameterised with. */ + schnorrPublicKey: string; +}; + +export type ReadLiquidContractIdentityInput = { + accountGroupIndex: number; + chain: LiquidChainRecord; + keyManagerState: KeyManagerState; +}; + +/** + * Reads the address and key that contract actions are signed with. + * + * It exists because neither value was reachable from anywhere: the wallet's own screens + * show lwk's confidential addresses across a ranged descriptor, and no method returned + * the signing key — so funding a contract action meant guessing an address, and locking + * a covenant to this wallet meant guessing a key. Both guesses fail late, one of them + * by making funds unspendable. + * + * Showing them is a narrower answer than the one this eventually needs, which is for the + * module to sign each input at its own derivation path and take a change address from + * the wallet (DISC-053). Until that lands, the limit is real and this makes it visible + * rather than hidden. + */ +export async function readLiquidContractIdentity( + { accountGroupIndex, chain, keyManagerState }: ReadLiquidContractIdentityInput, + dependencies = { loadSmplx: loadSmplxWasm, withMnemonic: withAccountMnemonic }, +): Promise { + const network = SMPLX_NETWORKS[chain.settings.network]; + + if (!network) { + throw new Error(`The contract SDK does not support the ${chain.settings.network} network.`); + } + + const smplx = await dependencies.loadSmplx(); + + return dependencies.withMnemonic( + { accountGroupIndex, chain, keyManagerState }, + (mnemonic: string) => { + const signer = new smplx.WalletSigner(mnemonic, network); + + try { + return { address: signer.address(), schnorrPublicKey: signer.schnorrPublicKey() }; + } finally { + signer.free(); + } + }, + ); +} diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.test.tsx b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.test.tsx new file mode 100644 index 0000000..2a39d1d --- /dev/null +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.test.tsx @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test"; + +import { computed, fromSite, verified } from "@humid/tx-manifest"; + +import { + feeLine, + isProcessCtConfirmationData, + netEffectLine, + PROCESS_CT_CONFIRMATION_KIND, + processCtConfirmationRenderer, +} from "./ProcessCtConfirmation"; + +// AC-06 and AC-07 at the surface. What is checked here is what the surface is handed and +// what it will accept — the rendering itself is JSX with no branching worth asserting, and +// the property that matters is enforced by the type: every value it displays is provenanced, +// so an unattributed one cannot reach it. + +const MODEL = { + account: computed("liquid:testnet account 0"), + action: fromSite("Receive"), + covenants: [ + { + address: verified("tex1p_derived"), + utxoType: fromSite("p2pk_output"), + verified: computed(true), + }, + ], + feeAsset: computed("lbtc"), + feeSats: computed(500n), + netEffect: [{ asset: computed("lbtc"), sats: computed(-50_500n) }], + protocol: fromSite("p2pk-simplicity"), + summary: fromSite("Spend a p2pk output back into your wallet."), +}; + +describe("the fee, which is a price rather than a balance change", () => { + // The balance lines carry a sign because they say which way money moved. The fee is what + // this transaction costs, and it was rendered by the same function — so a wallet paying a + // fee printed "+0.00000108 L-BTC" one line under "−0.00000108 L-BTC" for the same amount. + test("is written without a sign", () => { + expect(feeLine("108")).toBe("0.00000108 L-BTC"); + }); + + test("still names the asset the network charges in", () => { + expect(feeLine("0")).toBe("0 L-BTC"); + }); +}); + +describe("the contract-action confirmation", () => { + test("recognises the payload the method builds", () => { + expect( + isProcessCtConfirmationData({ + broadcast: false, + kind: PROCESS_CT_CONFIRMATION_KIND, + shown: MODEL, + }), + ).toBe(true); + }); + + test("and refuses anything else, so the host falls back rather than rendering it wrong", () => { + expect(isProcessCtConfirmationData({ kind: "liquid.signPset" })).toBe(false); + expect(isProcessCtConfirmationData(null)).toBe(false); + expect(isProcessCtConfirmationData({ kind: PROCESS_CT_CONFIRMATION_KIND })).toBe(false); + }); + + test("is registered under the kind the method puts on the payload", () => { + expect(processCtConfirmationRenderer.kind).toBe(PROCESS_CT_CONFIRMATION_KIND); + }); + + test("renders nothing for a payload that is not its own", () => { + expect( + processCtConfirmationRenderer.render({ + onConfirm: () => {}, + onDecline: () => {}, + request: { data: { kind: "something.else" } } as never, + }), + ).toBeNull(); + }); +}); + +// One balance change per asset reaches this surface now, and only one of them is in an asset +// this wallet knows how to name and how to divide. +describe("a balance change in each asset the action moves", () => { + const FEE_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; + const TOKEN = "aa".repeat(32); + + test("the network's own asset is shown by name, divided the way it divides", () => { + expect(netEffectLine({ asset: FEE_ASSET, sats: "-50500" }, FEE_ASSET)).toEqual({ + shown: "−0.000505 L-BTC", + }); + }); + + // A protocol's own token divides however that protocol says, which this wallet was never + // told. Base units and the id are what it can stand behind; "0.00000001 L-BTC" beside a + // one-of-a-kind token would be two lies in five characters. + test("and any other asset is shown in base units, beside the id it is", () => { + expect(netEffectLine({ asset: TOKEN, sats: "-1" }, FEE_ASSET)).toEqual({ + asset: TOKEN, + shown: "−1", + }); + }); + + test("with the sign kept, because which way it goes is the whole point", () => { + expect(netEffectLine({ asset: TOKEN, sats: "250000" }, FEE_ASSET).shown).toBe("+250000"); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.tsx b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.tsx new file mode 100644 index 0000000..2038eb8 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.tsx @@ -0,0 +1,244 @@ +import { describeOrigin, type ShownConfirmation } from "@humid/tx-manifest"; +import type { Provenanced } from "@humid/tx-manifest"; + +import type { ConfirmationRenderer } from "@/common/Confirmation"; +import { UiButton } from "@/ui/UiButton/base"; + +/** What the method puts on the confirmation payload, and how this surface recognises it. */ +export const PROCESS_CT_CONFIRMATION_KIND = "liquid.processConfidentialTransaction"; + +export type ProcessCtConfirmationData = { + broadcast: boolean; + kind: typeof PROCESS_CT_CONFIRMATION_KIND; + shown: ShownConfirmation; +}; + +export function isProcessCtConfirmationData(value: unknown): value is ProcessCtConfirmationData { + return ( + typeof value === "object" && + value !== null && + (value as { kind?: unknown }).kind === PROCESS_CT_CONFIRMATION_KIND && + typeof (value as { shown?: unknown }).shown === "object" + ); +} + +/** + * One value, shown with where it came from. + * + * Every value on this surface goes through here, which is the whole point: the component + * takes a provenanced value and nothing else, so a plain string cannot be rendered without + * someone changing this signature. The origin is words rather than a colour or a badge, + * because "claimed by the site" is the thing that has to be unmistakable and a badge is the + * thing people stop seeing. + */ +function Shown({ label, value }: { label: string; value: Provenanced }) { + return ( +
+ + {label} + + {value.value} + {describeOrigin(value.origin)} +
+ ); +} + +/** + * Base units as a person reads them, keeping the sign that says which way the money goes. + * + * Takes the decimal string the wire form carries rather than a bigint: the model crosses + * a JSON boundary to get here, and JSON cannot carry one. + */ +function amount(value: string): string { + const sats = BigInt(value); + + return `${sats < 0n ? "−" : "+"}${decimal(sats)} L-BTC`; +} + +/** + * The fee, written without a sign. + * + * The balance lines carry one because they say which way money moved; the fee is what this + * transaction costs. Sharing the balance formatter printed the cost as a gain — "+0.00000108 + * L-BTC" directly under "−0.00000108 L-BTC", the same figure twice with opposite signs. + */ +export function feeLine(value: string): string { + return `${decimal(BigInt(value))} L-BTC`; +} + +/** One L-BTC figure, unsigned, with trailing zeros trimmed. */ +function decimal(sats: bigint): string { + const whole = (sats < 0n ? -sats : sats).toString().padStart(9, "0"); + + return `${whole.slice(0, -8)}.${whole.slice(-8)}`.replace(/\.?0+$/, "") || "0"; +} + +/** + * One line of the balance change, in whichever terms this wallet can honestly write it. + * + * A pure function rather than a branch inside the markup, because this is the one decision on + * this surface that can be got wrong quietly: printing a token's units under the network + * asset's name reads as money and is not, and there is no rendering test in this project that + * would catch it. + */ +export function netEffectLine( + effect: { asset: string; sats: string }, + feeAsset: string, +): { asset?: string; shown: string } { + return effect.asset === feeAsset + ? { shown: amount(effect.sats) } + : { asset: effect.asset, shown: units(effect.sats) }; +} + +/** + * The same figure in an asset this wallet knows nothing else about. + * + * Base units and a sign, and no name and no decimal point: how many places a protocol's own + * token divides into is the protocol's business, and a wallet guessing eight of them would + * print a hundredth of a token as a whole one. The id sits beside it, which is the only thing + * about that asset this wallet actually established. + */ +function units(value: string): string { + const sats = BigInt(value); + + return `${sats < 0n ? "−" : "+"}${(sats < 0n ? -sats : sats).toString()}`; +} + +/** + * What a person is asked to approve before a contract action is signed. + * + * The four facts the wallet established for itself come first and the protocol's own words + * come after, each labelled with where it came from. They are not separated into two + * screens deliberately: a first screen reads as the summary and a second as the detail, and + * the distinction that matters here is not importance but authorship. + */ +export function ProcessCtConfirmation({ + data, + onConfirm, + onDecline, +}: { + data: ProcessCtConfirmationData; + onConfirm: () => void; + onDecline: () => void; +}) { + const { shown } = data; + + return ( +
+
+

Perform a contract action?

+

+ Nothing is signed until you agree, and what you agree to is what gets signed. +

+
+ +
+ {shown.netEffect.map((effect) => { + const line = netEffectLine( + { asset: effect.asset.value, sats: effect.sats.value }, + shown.feeAsset.value, + ); + + return ( +
+ + This wallet + + {line.shown} + {line.asset === undefined ? null : ( + {line.asset} + )} + + {describeOrigin(effect.sats.origin)} + +
+ ); + })} + + } + /> + + + {/* What this transaction keeps off the chain, one line each, with whose word + decided it. The wallet hid these on someone's behalf, so it says so — and says + which of them the protocol asked for and which it simply never mentioned. */} + {shown.hiddenAmounts.map((hidden) => ( +
+ + Amount hidden on chain + + {hidden.id.value} + {hidden.decidedBy.value} + + {describeOrigin(hidden.decidedBy.origin)} + +
+ ))} + + {/* And what it publishes that the format would have kept off the chain: a contract + action's own change, which this wallet returns in the open so the money comes + back in a form the next action can be funded from. It says which word it set + aside to do that, because overriding a protocol quietly — here of all places, + where the person was just told to trust this wallet's reading of it — would be + worth less than not having told them anything. */} + {shown.publishedAmounts.map((published) => ( +
+ + Amount published on chain + + {published.id.value} + {published.reason.value} + + {describeOrigin(published.reason.origin)} + +
+ ))} + + {shown.covenants.map((covenant) => ( +
+ + {covenant.verified.value ? "Contract, checked" : "Contract, not yet on chain"} + + {covenant.address.value} + + {describeOrigin(covenant.address.origin)} + +
+ ))} + + {/* Everything below is the site's own words. It is shown because a person deciding + needs to know what the site says it is doing — and labelled, because the wallet + checked none of it. */} + + + {shown.summary === undefined ? null : ( + + )} +
+ +
+ + Decline + + + {data.broadcast ? "Sign and send" : "Sign"} + +
+
+ ); +} + +/** Plugs this confirmation into the generic host (see ConfirmProvider). */ +export const processCtConfirmationRenderer: ConfirmationRenderer = { + kind: PROCESS_CT_CONFIRMATION_KIND, + render: ({ onConfirm, onDecline, request }) => + isProcessCtConfirmationData(request.data) ? ( + onConfirm()} + onDecline={onDecline} + /> + ) : null, +}; diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts new file mode 100644 index 0000000..82ed6da --- /dev/null +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts @@ -0,0 +1,1328 @@ +import { describe, expect, test } from "bun:test"; + +import { spentInputs, txOutAt } from "@humid/tx-manifest"; +import groupedManifest from "@humid/tx-manifest/fixtures/p2pk-grouped.manifest.json"; +import p2pkManifest from "@humid/tx-manifest/fixtures/p2pk.manifest.json"; + +import { + createProcessLiquidConfidentialTransaction, + type LiquidProcessCtContext, + type LiquidProcessCtDependencies, +} from "./index"; +import { + isProcessCtConfirmationData, + type ProcessCtConfirmationData, +} from "./ProcessCtConfirmation"; + +// Drives the whole seam — parse, verify, plan, sign, broadcast — with substituted +// dependencies. What is asserted is the method's own behaviour: what it refuses, what it +// asks the chain, what it signs, and when it broadcasts. + +const PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +const SOURCE_PATH = "./p2pk.simf"; +const SOURCE = "fn main() { }"; +const DERIVED_SCRIPT = `5120${"aa".repeat(32)}`; + +/** What the real module does with a hex argument, so a substitute cannot be laxer. */ +function requireHex(what: string, value: string): void { + if (!/^(?:[0-9a-fA-F]{2})+$/.test(value)) { + throw new Error(`Invalid ${what}: Odd number of digits`); + } +} + +function requireTxid(txid: string): void { + if (!/^[0-9a-fA-F]{64}$/.test(txid)) { + throw new Error(`Invalid txid: ${txid}`); + } +} + +/** + * What the real module does with a blinding key, which is parse it as a compressed public key. + * + * A substitute that took any string here would accept an output the module refuses, and the + * refusal would arrive after the person had already approved. + */ +function requireBlindingKey(what: string, value: string): void { + if (!/^0[23][0-9a-fA-F]{64}$/.test(value)) { + throw new Error(`Invalid ${what}: malformed public key`); + } +} +const DERIVED = "tex1p_derived"; +const WALLET_ADDRESS = "tex1q_wallet"; +const ROTATING_ADDRESS = "tex1q_rotating"; +const WALLET_SCRIPT = "0014" + "11".repeat(20); +const ROTATING_SCRIPT = "0014" + "99".repeat(20); +const BLINDING_KEY = `02${PUBKEY}`; +const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; +const FUNDING_TXID = "d".repeat(64); + +/** + * An Elements transaction serialised from what the substituted module was actually told, + * inputs and outputs both, so each guard is exercised against the shape of the request rather + * than against a constant that would agree with it whatever happened. + * + * The output half arrived with the blinding guard and is the reason it can run at all: a + * substitute that stopped after the inputs returned bytes with no outputs in them, which reads + * as a transaction that builds nothing and could never disagree with the wallet about what it + * hid. The module's own order is reproduced — the action's outputs where the wallet put them, + * then the change it appends, then the fee — because the guard finds the change by position. + */ +function serialise( + spends: { txid: string; vout: number }[], + built: Built = { changeBlinded: false, outputs: [] }, +): string { + const inputs = spends + .map(({ txid, vout }) => { + const reversed = (txid.match(/../g) ?? []).toReversed().join(""); + const index = vout.toString(16).padStart(8, "0").match(/../g)!.toReversed().join(""); + + return `${reversed}${index}00ffffffff`; + }) + .join(""); + + const outputs = [ + ...built.outputs.map((output) => txOutOf(output.blinded, output.script)), + txOutOf(built.changeBlinded, WALLET_SCRIPT), + // The fee, which carries no script at all: the network reads the amount it charges. + txOutOf(false, ""), + ]; + + return ( + `0200000001${count(spends.length)}${inputs}` + + `${count(outputs.length)}${outputs.join("")}00000000` + ); +} + +/** What the substituted builder was told to build, in the order it was told. */ +type Built = { changeBlinded: boolean; outputs: { blinded: boolean; script: string }[] }; + +function count(value: number): string { + return value.toString(16).padStart(2, "0"); +} + +/** + * One output the way the chain writes one. + * + * An explicit amount is a `01` prefix and eight bytes; a hidden one is a commitment prefix and + * thirty-two, with a nonce beside it. Written as bytes rather than as a flag, because the only + * thing that can establish what a transaction hides is what it is made of. + */ +function txOutOf(blinded: boolean, scriptHex: string): string { + const length = count(scriptHex.length / 2); + + if (blinded) { + return `0a${"33".repeat(32)}08${"44".repeat(32)}02${"55".repeat(32)}${length}${scriptHex}`; + } + + const asset = (POLICY_ASSET.match(/../g) ?? []).toReversed().join(""); + + return `01${asset}01${(1000).toString(16).padStart(16, "0")}00${length}${scriptHex}`; +} + +function params(overrides: Record = {}) { + return { + action: "Pay", + contractSources: { [SOURCE_PATH]: SOURCE }, + manifest: p2pkManifest, + params: { amount_sat: 50_000, pubkey: PUBKEY }, + ...overrides, + }; +} + +/** A context with just enough of the wallet for this method to run. */ +function context(): LiquidProcessCtContext { + return { + authorization: { isGranted: () => true }, + chain: { + id: "liquid:testnet", + settings: { backend: { url: "https://esplora.example" }, network: "testnet" }, + }, + keyManagerState: {}, + walletBackend: { + // The address a person is shown to receive at moves as addresses are used. The one a + // contract action can spend from does not: the signing module derives a single key at + // the account's first external address. The two differ here so a path taking the wrong + // one is visible. + getReceiveAddress: () => ({ address: ROTATING_ADDRESS, index: 7 }), + getSigningAddress: () => ({ address: WALLET_ADDRESS, index: 0 }), + // The two lists the method reads, kept honest about which is which: a contract action + // can only spend an explicit output, so the funding one lives in the explicit list and + // the confidential one is there to be held back. A method that stopped asking for the + // explicit list would fail here for want of money rather than pass quietly. + getExplicitUtxos: () => [ + { + amount: "1000000", + confidential: false, + spendable: true, + txid: FUNDING_TXID, + txOut: "00", + vout: 0, + }, + ], + getUtxos: () => [ + { + amount: "9000000", + confidential: true, + spendable: true, + txid: `${"cc".repeat(32)}`, + txOut: "00", + vout: 0, + }, + ], + // The height the wallet's own scan reached, which is what a covenant branch guarded by + // a lock height reads out of the transaction it judges. + getTipHeight: () => 2_580_990, + syncAccount: async () => undefined, + }, + } as unknown as LiquidProcessCtContext; +} + +/** + * What the module reports for the one output these checks issue from. + * + * Written out rather than computed with the wallet's own function. The point of the check is + * that two independent derivations agree, and a substitute that called the wallet's would + * agree by construction and prove nothing. These are the values the wallet derives for + * `FUNDING_TXID:0` committing to no issuer contract, so a change on either side fails here. + */ +const ISSUED: IssuanceAccount = { + assetId: "3e04c1072681d13b140419b4e1acf7084daa94fbde3accb80321ae6e8badb057", + entropy: "d95f2c5c8e8eacb0581b8ea00e403e826049dcedbff88f3b9e609b3020e65978", + reissuanceTokenId: "cfc308991457ed32a50cc8494cdbad89d61a9e8c3380f4ef884c0a5d82002c9e", +}; + +type IssuanceAccount = { assetId: string; entropy: string; reissuanceTokenId: string }; + +/** + * The module's account of one issuance, held across the wasm boundary like everything else it + * returns — so the method has to release it, and a substitute without `free` would let a leak + * pass unnoticed. + */ +function issuanceReport(account: IssuanceAccount) { + return { ...account, free() {} }; +} + +/** One issuance the builder was told to put on an input, and what it was told about it. */ +type IssuedInput = { + assetAmountSats: bigint; + contractInput: boolean; + inflationAmountSats: bigint; + issuerContractHex: string | undefined; + txid: string; + vout: number; +}; + +type Recorded = { + broadcasts: { txHex: string }[]; + /** + * The height the builder was told the transaction may not be mined before. + * + * Read from the builder rather than from the review, so a method that stopped passing it + * through fails here rather than agreeing with itself. + */ + locktimeHeight?: number; + /** + * The sequence the builder was told every input carries. + * + * Read from the builder rather than from the review, so a method that stopped passing a + * declared sequence through fails here rather than agreeing with itself. + */ + sequence?: number; + /** + * Whether the builder was told to hide the change it returns. + * + * Read from the builder rather than from the review, so a method that stopped passing the + * decision through fails here instead of agreeing with itself. + */ + changeBlinded: boolean; + /** Every contract source the review asked for the declarations of. */ + declared: string[]; + issued: IssuedInput[]; + mnemonicCalls: number; + /** Every output as the builder was told it: the asset and whether it hides what it carries. */ + outputs: { asset: string; blinded: boolean; script: string }[]; + paid: string[]; + /** Every address the method asked for the script of, in order. */ + scriptAsks: string[]; + /** How each covenant input was described to the builder, beyond its source. */ + covenantBuilds: { includeDebugSymbols?: boolean; leaves?: string }[]; + /** The transaction the substituted module handed back, as it handed it back. */ + signed: string; +}; + +/** + * What a module does to the transaction between being told and handing it back. + * + * The identity by default, because a module that does what it is told is the case worth + * running everything else against. A test supplies one when it needs the other case — the + * module ignoring what it was told, which is precisely what the guards exist to catch and is + * unreachable from a substitute that can only be obedient. + */ +type ModuleBehaviour = (built: Built) => Built; + +function dependencies( + recorded: Recorded, + issued: IssuanceAccount = ISSUED, + behaviour: ModuleBehaviour = (built) => built, +): LiquidProcessCtDependencies { + return { + broadcastTransaction: async ({ txHex }) => { + recorded.broadcasts.push({ txHex }); + + return { txid: "f".repeat(64) }; + }, + loadSmplx: async () => + ({ + compilerVersion: () => "0.6.0", + // What a contract declares its compile parameters to be, which the real module + // answers by type-checking the source. A substitute cannot type a parameter — + // needing the compiler for exactly that is why this seam exists — so it answers + // only for a source that declares none, and refuses the rest rather than + // inventing a width that would silently be part of an address. + contractParameterTypes: (source: string) => { + recorded.declared.push(source); + + if (/\bparam::/.test(source)) { + throw new Error("This substitute cannot say what a contract declares."); + } + + return "{}"; + }, + Contract: class { + contractAddress() { + return DERIVED; + } + // Held across the wasm boundary, so the method releases it. A substitute + // without this passes only because nothing checked that it was released. + free() {} + scriptPubKeyHex() { + return DERIVED_SCRIPT; + } + }, + // Every argument the real builder parses is parsed here too. A substitute that + // accepts whatever it is given is how a bech32 address reached `addOutput` + // through a green suite (DISC-138), so the rule is now the module's own: what + // it decodes, this decodes. + TransactionBuilder: class { + change: string | undefined; + changeBlinded = false; + /** The height the method declared, so a transaction that stops declaring one shows here. */ + locktimeHeight: number | undefined; + /** The sequence the method declared, so one that stops being passed shows here. */ + sequence: number | undefined; + /** Each output as it was told, so the transaction it returns carries them. */ + outputs: { blinded: boolean; script: string }[] = []; + spends: { txid: string; vout: number }[] = []; + // The change target moved onto the builder, and so did the parse that rejects + // one it cannot read. Recorded rather than swallowed, so a method that stopped + // stating where change goes fails here instead of sending it to the module's + // own default in silence. + setLocktimeHeight(height: number) { + this.locktimeHeight = height; + recorded.locktimeHeight = height; + } + setSequence(sequence: number) { + this.sequence = sequence; + recorded.sequence = sequence; + } + addChange(scriptPubKeyHex: string, blindingKeyHex?: string) { + requireHex("change script", scriptPubKeyHex); + + if (blindingKeyHex !== undefined) { + requireBlindingKey("change blinding key", blindingKeyHex); + } + + this.change = scriptPubKeyHex; + this.changeBlinded = blindingKeyHex !== undefined; + } + addContractInput( + txid: string, + vout: number, + txOutHex: string, + _source: string, + _argumentsJson: string | undefined, + _witnessJson: string | undefined, + _signatureWitness: string | undefined, + extraLeavesJson: string | undefined, + includeDebugSymbols: boolean | undefined, + ) { + requireHex("covenant input's previous output", txOutHex); + requireTxid(txid); + this.spends.push({ txid, vout }); + recorded.covenantBuilds.push({ includeDebugSymbols, leaves: extraLeavesJson }); + } + addContractIssuanceInput( + txid: string, + vout: number, + txOutHex: string, + _source: string, + _argumentsJson: string | undefined, + _witnessJson: string | undefined, + _signatureWitness: string | undefined, + assetAmountSats: bigint, + inflationAmountSats: bigint, + issuerContractHex: string | undefined, + ) { + requireHex("covenant input's previous output", txOutHex); + requireTxid(txid); + this.spends.push({ txid, vout }); + recorded.issued.push({ + assetAmountSats, + contractInput: true, + inflationAmountSats, + issuerContractHex, + txid, + vout, + }); + + return issuanceReport(issued); + } + addOutput( + scriptPubKeyHex: string, + _amountSats: bigint, + assetHex: string, + blindingKeyHex?: string, + ) { + requireHex("output script", scriptPubKeyHex); + requireHex("asset id", assetHex); + + if (blindingKeyHex !== undefined) { + requireBlindingKey("output blinding key", blindingKeyHex); + } + + this.outputs.push({ + blinded: blindingKeyHex !== undefined, + script: scriptPubKeyHex, + }); + recorded.outputs.push({ + asset: assetHex, + blinded: blindingKeyHex !== undefined, + script: scriptPubKeyHex, + }); + recorded.paid.push(scriptPubKeyHex); + } + addWalletInput(txid: string, vout: number, txOut: string) { + requireHex("wallet input's previous output", txOut); + requireTxid(txid); + this.spends.push({ txid, vout }); + } + addWalletIssuanceInput( + txid: string, + vout: number, + txOut: string, + assetAmountSats: bigint, + inflationAmountSats: bigint, + issuerContractHex: string | undefined, + ) { + requireHex("wallet input's previous output", txOut); + requireTxid(txid); + this.spends.push({ txid, vout }); + recorded.issued.push({ + assetAmountSats, + contractInput: false, + inflationAmountSats, + issuerContractHex, + txid, + vout, + }); + + return issuanceReport(issued); + } + free() {} + }, + WalletSigner: class { + // The wallet hides an output with its own blinding key, so the substitute has + // to have one. Without it every path that hides anything failed here for the + // wrong reason, which is what happened between the blinding work and now. + blindingPublicKey() { + return BLINDING_KEY; + } + finalizeTransaction( + builder: Built & { change?: string; spends: { txid: string; vout: number }[] }, + _feeRateSatsPerKvb: number, + ) { + if (builder.change === undefined) { + throw new Error("The transaction was finalised without a change target."); + } + + recorded.changeBlinded = builder.changeBlinded; + // Kept, so a test asserting the method returns what the module built can say + // exactly that rather than assemble the same bytes a second time and compare + // two derivations of one thing. + recorded.signed = serialise(builder.spends, behaviour(builder)); + + return { + feeSats: 500n, + free: () => undefined, + hex: recorded.signed, + txid: "e".repeat(64), + }; + } + free() {} + scriptPubKeyHex() { + return WALLET_SCRIPT; + } + }, + }) as never, + readFeeRate: () => async () => 1000, + // Answers with bytes and reads them back through the same parser the real reader uses, + // so this cannot hand over an output the chain could not have produced. + readTxOut: () => async () => { + const asset = `01${(POLICY_ASSET.match(/../g) ?? []).toReversed().join("")}`; + const value = `01${(42_000).toString(16).padStart(16, "0")}`; + const script = `${(DERIVED_SCRIPT.length / 2).toString(16).padStart(2, "0")}${DERIVED_SCRIPT}`; + const parsed = txOutAt(`02000000000001${asset}${value}00${script}00000000`, 0); + + if (!parsed.ok) { + throw new Error(parsed.reason); + } + + return parsed.txOut; + }, + resolveAccount: async () => + ({ accountGroupIndex: 0, chain: {}, rawPolicyAssetId: POLICY_ASSET }) as never, + scriptPubKeyHexOf: async (address: string) => { + recorded.scriptAsks.push(address); + + return address === WALLET_ADDRESS ? WALLET_SCRIPT : ROTATING_SCRIPT; + }, + withMnemonic: async (_request, use) => { + recorded.mnemonicCalls += 1; + + return use("a test mnemonic"); + }, + }; +} + +function subject(issued: IssuanceAccount = ISSUED, behaviour?: ModuleBehaviour) { + const recorded: Recorded = { + broadcasts: [], + changeBlinded: false, + declared: [], + issued: [], + covenantBuilds: [], + mnemonicCalls: 0, + scriptAsks: [], + outputs: [], + paid: [], + signed: "", + }; + + return { + method: createProcessLiquidConfidentialTransaction(dependencies(recorded, issued, behaviour)), + recorded, + }; +} + +describe("processLiquidConfidentialTransaction", () => { + test("builds and signs, returning the transaction unsent by default", async () => { + const { method, recorded } = subject(); + + const result = await method(params(), context()); + + expect(result).toMatchObject({ broadcast: false, feeSats: "500" }); + expect(result.transactionHex).toBe(recorded.signed); + expect(recorded.broadcasts).toHaveLength(0); + }); + + test("broadcasts only when the request asks, and returns the network's txid", async () => { + const { method, recorded } = subject(); + + const result = await method(params({ broadcast: true }), context()); + + expect(recorded.broadcasts).toEqual([{ txHex: recorded.signed }]); + expect(result).toMatchObject({ broadcast: true, txid: "f".repeat(64) }); + }); + + // The account secret is reached once, for the signing step, and not before. + test("reads the account mnemonic exactly once", async () => { + const { method, recorded } = subject(); + + await method(params(), context()); + + expect(recorded.mnemonicCalls).toBe(1); + }); + + /** + * The wallet supplies the compiler, and this is the second thing the review asks it for: what + * a contract declares its compile parameters to be. It is asked before the contract is built, + * because a parameter a deployment writes as a bare value has no type until the contract + * states one, and the arguments cannot be assembled without it. + * + * Asserted here rather than only at the seam because a seam nothing fills is not delivered. + * Every covenant this wallet reviews goes through the same call. + */ + test("asks the compiler what each contract declares, passing the source it was given", async () => { + const { method, recorded } = subject(); + + await method(params(), context()); + + expect(recorded.declared).toContain(SOURCE); + }); + + test("refuses a request missing the contract source, naming it", async () => { + const { method, recorded } = subject(); + + await expect(method(params({ contractSources: {} }), context())).rejects.toThrow(/p2pk\.simf/); + expect(recorded.mnemonicCalls).toBe(0); + }); + + test("refuses a malformed request before reaching the wallet at all", async () => { + const { method, recorded } = subject(); + + await expect(method({ action: "Pay" }, context())).rejects.toThrow(); + expect(recorded.mnemonicCalls).toBe(0); + }); + + test("refuses an action the manifest does not declare", async () => { + const { method } = subject(); + + await expect(method(params({ action: "Withdraw" }), context())).rejects.toThrow(/Withdraw/); + }); + + // Every refusal on this path shares one wire code, so the sentence is all a caller had to go + // on. A site telling "this wallet will never build that" from "your state file is out of + // date" had to parse English, and one of those is worth retrying while the other never is. + test("and names the refusal beside the sentence, so a caller can branch without reading it", async () => { + const { method } = subject(); + + const failure = await method(params({ action: "Withdraw" }), context()).then( + () => undefined, + (error: unknown) => error as { data?: { reject?: string } }, + ); + + // `no-such-action` rather than `incomplete-request`, and the difference is worth pinning: + // the action is looked up before anything asks what it needs, because filling a + // parameter needs the action that declares it. So a request naming an action the + // manifest does not declare is refused as a missing name rather than as a request that + // cannot be built. The sentence still says "Withdraw" — the test above asserts that — + // and the token says which check answered. + expect(failure?.data?.reject).toBe("no-such-action"); + }); +}); + +// AC-10 end to end: the same protocol written in the grouped shape with the older +// top-level spelling goes through the whole method and produces the same transaction. +describe("processLiquidConfidentialTransaction across declaration shapes", () => { + test("builds and signs a grouped manifest exactly as it does a flat one", async () => { + const flat = await subject().method(params(), context()); + const grouped = await subject().method(params({ manifest: groupedManifest }), context()); + + expect(grouped).toEqual(flat); + }); + + test("finds a method declared inside a class by its own name", async () => { + const { method, recorded } = subject(); + + await method( + params({ + action: "Receive", + manifest: groupedManifest, + params: { pubkey: PUBKEY }, + state: { utxos: [{ txid: "a".repeat(64), utxo_type: "p2pk_output", vout: 0 }] }, + }), + context(), + ); + + expect(recorded.mnemonicCalls).toBe(1); + }); +}); + +/** + * The order the inputs are actually built in, which is the document's wherever it states one. + * + * A covenant introspects positions, so this is the last place the order can still be got wrong: + * the review works out where each input goes and the builder is what puts it there. Every + * covenant used to be added first and the wallet's own outputs after, so a document requiring + * one of the wallet's own to go first was refused rather than built — and the published + * contracts that fix an input at index zero fix one the wallet supplies. + * + * Read off the signed transaction's own bytes rather than off the review, because what a module + * was told and what it built are two different claims. + */ +const COVENANT_TXID = "a".repeat(64); +const spending = { + action: "Receive", + params: { pubkey: PUBKEY }, + state: { utxos: [{ txid: COVENANT_TXID, utxo_type: "p2pk_output", vout: 0 }] }, +}; + +/** `Receive`, with its inputs told where to go. */ +function requiring(positions: Record) { + const document = structuredClone(p2pkManifest) as unknown as { + actions: { Receive: { inputs: Record[] } }; + }; + + for (const input of document.actions.Receive.inputs) { + const at = positions[String(input.id)]; + + if (at !== undefined) { + input.required_index = at; + } + } + + return document; +} + +/** The outpoints the finished transaction spends, in the order it spends them. */ +function orderOf(transactionHex: string) { + const found = spentInputs(transactionHex); + + if (!found.ok) { + throw new Error(found.reason); + } + + return found.spent; +} + +/** + * A covenant branch guarded by a lock height reads the transaction's own locktime, and a + * transaction declaring none satisfies no such branch. No document in the corpus states one, + * because the height a spend becomes valid at is a fact about the chain — so the wallet reads + * the chain and tells the module, and this is where that stops being silent if it stops. + */ +describe("the height the transaction declares", () => { + test("is the chain's own, handed to the module that builds it", async () => { + const { method, recorded } = subject(); + + await method(params(spending), context()); + + expect(recorded.locktimeHeight).toBe(2_580_990); + }); +}); + +describe("the order the transaction's inputs are built in", () => { + test("is the wallet's own — covenant first — while the document states nothing", async () => { + const result = await subject().method(params(spending), context()); + + expect(orderOf(result.transactionHex)).toEqual([ + { txid: COVENANT_TXID, vout: 0 }, + { txid: FUNDING_TXID, vout: 0 }, + ]); + }); + + test("and puts the wallet's own input first when the document requires that", async () => { + const result = await subject().method( + params({ ...spending, manifest: requiring({ fee_input: 0, p2pk_in: 1 }) }), + context(), + ); + + expect(orderOf(result.transactionHex)).toEqual([ + { txid: FUNDING_TXID, vout: 0 }, + { txid: COVENANT_TXID, vout: 0 }, + ]); + }); + + // Being able to reorder is not a way to stop refusing: two inputs cannot both be input zero, + // and the wallet says so before anything is signed rather than after the network rejects it. + test("while a position no order could satisfy is refused, and nothing is signed", async () => { + const { method, recorded } = subject(); + + await expect( + method(params({ ...spending, manifest: requiring({ fee_input: 0, p2pk_in: 0 }) }), context()), + ).rejects.toThrow(/fee_input/); + expect(recorded.mnemonicCalls).toBe(0); + }); +}); + +// AC-11 at the seam it actually protects: the guard reads the finished transaction's own +// bytes, so a module that spends something nobody asked for is caught even though every +// other part of the request was well formed. +describe("processLiquidConfidentialTransaction guards what it signs", () => { + function subjectSpending(extra: { txid: string; vout: number }) { + const recorded: Recorded = { + broadcasts: [], + changeBlinded: false, + covenantBuilds: [], + declared: [], + issued: [], + mnemonicCalls: 0, + scriptAsks: [], + outputs: [], + paid: [], + signed: "", + }; + const dependency = dependencies(recorded); + + return { + method: createProcessLiquidConfidentialTransaction({ + ...dependency, + loadSmplx: async () => { + const module = (await dependency.loadSmplx()) as never as { + TransactionBuilder: new () => { spends: { txid: string; vout: number }[] }; + }; + + return { + ...module, + TransactionBuilder: class extends module.TransactionBuilder { + // Stands in for a module doing something it was not asked to. + free() {} + addOutput() { + this.spends.push(extra); + } + }, + } as never; + }, + }), + recorded, + }; + } + + test("refuses a transaction spending an input nobody asked for, naming it", async () => { + const { method } = subjectSpending({ txid: "9".repeat(64), vout: 2 }); + + await expect(method(params(), context())).rejects.toThrow(/9{64}:2/); + }); + + test("and nothing reaches the network", async () => { + const { method, recorded } = subjectSpending({ txid: "9".repeat(64), vout: 2 }); + + await expect(method(params({ broadcast: true }), context())).rejects.toThrow(); + expect(recorded.broadcasts).toHaveLength(0); + }); +}); + +// AC-14 and D7: a person who wipes the wallet and restores from the recovery phrase must be +// able to perform the same action. There is nothing to restore *to* — so what is shown is +// that the method is a function of the request, the phrase and the chain, and that a second +// run on a context built from nothing else reaches the same transaction. +describe("processLiquidConfidentialTransaction on a restored wallet", () => { + test("the same request twice, on contexts sharing nothing, reaches the same transaction", async () => { + const first = await subject().method(params(), context()); + const restored = await subject().method(params(), context()); + + expect(restored).toEqual(first); + }); + + test("and reaches the same transaction whether or not one ran before it", async () => { + const alone = await subject().method(params(), context()); + const { method } = subject(); + + await method(params({ broadcast: true }), context()); + + expect(await method(params(), context())).toEqual(alone); + }); + + // What it reads from the wallet is the point: the account, its own outputs and an address, + // all of which a restored wallet derives from the phrase by scanning. Anything else would + // be something a previous run left behind. + test("reads nothing from the wallet a restored one could not derive", async () => { + const read: string[] = []; + const base = context(); + const watched = new Proxy(base, { + get(target, property) { + if (typeof property === "string") { + read.push(property); + } + + return target[property as keyof typeof target]; + }, + }); + + await subject().method(params(), watched); + + expect([...new Set(read)].toSorted()).toEqual([ + "authorization", + "chain", + "keyManagerState", + "walletBackend", + ]); + }); +}); + +// The confirmation screen was never driven from the method, only from data a test wrote +// by hand — so a payload that no renderer could read shipped, and a person calling the +// method got a black window that timed out into "User rejected the request" (DISC-137). +// Both halves of that are asserted here against the real payload. +describe("what the person is actually shown", () => { + async function shownRequest() { + let captured: { data?: unknown } | undefined; + + await subject().method(params(), { + ...context(), + authorization: { isGranted: () => false }, + confirm: async (request: { data?: unknown }) => { + captured = request; + + return true; + }, + } as unknown as LiquidProcessCtContext); + + return captured; + } + + test("the payload is one the confirmation surface recognises", async () => { + const request = await shownRequest(); + + expect(isProcessCtConfirmationData(request?.data)).toBe(true); + }); + + test("and survives the message bus, which serializes as JSON and cannot carry a bigint", async () => { + const request = await shownRequest(); + + expect(() => JSON.stringify(request?.data)).not.toThrow(); + }); + + test("carrying the wallet's own figures, not the site's claims", async () => { + const request = await shownRequest(); + const data = request?.data as ProcessCtConfirmationData; + + expect(data.shown.netEffect.length).toBeGreaterThan(0); + // `computed` rather than `verified`: the balance change is arithmetic over chain + // reads, and combining takes the weaker origin so the sum cannot claim more than its + // parts. What matters on this screen is that it is not the site's word. + expect(data.shown.netEffect[0]?.sats.origin).toBe("computed"); + expect(data.shown.protocol.origin).toBe("site"); + }); + + // The wallet hides amounts on someone's behalf, so it says which and on whose word. This + // action pays a covenant and returns change, and only one of those can hide anything: a + // Simplicity program reads exact amounts through jets that cannot introspect a + // commitment, so the covenant output is not on this list and cannot be. + // + // Neither is the change, and it used to be the only thing on it. The wallet publishes a + // contract action's own change now, so this action hides nothing at all. + test("and every amount it hides, with whose word decided each one", async () => { + const request = await shownRequest(); + const data = request?.data as ProcessCtConfirmationData; + + expect(data.shown.hiddenAmounts).toEqual([]); + }); + + // And the amount it publishes instead. The sentence has to lead with the word that was set + // aside — here nobody asked, and this network's own answer is to hide — before it says the + // wallet published it anyway. A wallet that overrode a protocol without saying so, in the + // one place this person was just told to trust its reading, would be worth less than one + // that had told them nothing. + test("and every amount it publishes that the format would have hidden", async () => { + const request = await shownRequest(); + const data = request?.data as ProcessCtConfirmationData; + + expect( + data.shown.publishedAmounts.map((published) => ({ + id: published.id.value, + // The reading is this wallet's, and so is the rule it applied, so it says so. + origin: published.reason.origin, + reason: published.reason.value, + })), + ).toEqual([ + { + id: "change", + origin: "computed", + reason: + "nothing says otherwise and this network hides an output by default, and this " + + "wallet publishes it anyway so your next action can spend it", + }, + ]); + }); +}); + +// The transaction builder hex-decodes every output script it is given, so a value that is +// not hex fails inside the module with "Invalid script: Odd number of digits" — an error +// that names neither the output nor what was wrong with it. A covenant output was paid to +// the bech32 address the wallet derived, because the address and the scriptPubKey were two +// spellings of one fact reached by two different calls (DISC-138). +describe("what the outputs actually pay to", () => { + test("every output script is hex the builder can decode", async () => { + const { method, recorded } = subject(); + + await method(params(), context()); + + expect(recorded.paid.length).toBeGreaterThan(0); + + for (const script of recorded.paid) { + expect(script).toMatch(/^(?:[0-9a-fA-F]{2})+$/); + } + }); + + test("and the covenant output pays the script, not the address it is shown as", async () => { + const { method, recorded } = subject(); + + await method(params(), context()); + + expect(recorded.paid).toContain(DERIVED_SCRIPT); + expect(recorded.paid).not.toContain(DERIVED); + }); +}); + +describe("how a covenant being spent is rebuilt", () => { + /* + * The script a covenant locks to is decided by four things: the source, the parameters, the + * extra taproot leaves and the build mode. The review compiles with all four and compares the + * result against the chain; the module that signs compiles again, and used to be told only the + * first two. A document declaring debug symbols therefore reviewed clean and failed at + * execution with a script-pubkey mismatch, after the person had approved it. + */ + test("is told the leaves and the build mode the review verified it under", async () => { + const { method, recorded } = subject(); + + // `Receive` spends the covenant rather than paying into one, which is the case where the + // module compiles a contract that already exists on chain. + await method(params(spending), context()); + + expect(recorded.covenantBuilds.length).toBeGreaterThan(0); + for (const build of recorded.covenantBuilds) { + expect(build.includeDebugSymbols).toBeBoolean(); + expect(build.leaves).toBeString(); + } + }); +}); + +describe("where an output paid to this wallet lands", () => { + /* + * A contract action can spend only what sits at the account's first external address — + * the signing module derives one key, at that index, and signs every wallet input with it. + * An output paid back to this wallet at any other address is money this path cannot spend + * again, which is what happened live: a factory's auth token landed on a rotating address + * and the next action, which has to spend it, could never find it. + */ + test("is the address this path can spend from, not the one shown for receiving", async () => { + const { method, recorded } = subject(); + + // A protocol that hands units back: the output carrying them is destined for the wallet, + // which is the case that goes wrong on a rotating address. + await method(params({ manifest: issuingManifest() }), context()); + + expect(recorded.scriptAsks).toContain(WALLET_ADDRESS); + expect(recorded.scriptAsks).not.toContain(ROTATING_ADDRESS); + expect(recorded.paid).toContain(WALLET_SCRIPT); + expect(recorded.paid).not.toContain(ROTATING_SCRIPT); + }); +}); + +/** + * The same protocol with its funding input creating an asset. + * + * Written here rather than vendored because no published manifest this wallet can build + * declares an issuance: the ones in the corpus that do reach for constructs it refuses first, + * so a fixture taken from them would assert a refusal and never reach the builder. + */ +function issuingManifest( + issuance: Record = { asset_amount_sat: 1_000, kind: "new" }, +) { + const manifest = structuredClone(p2pkManifest) as unknown as { + actions: { Pay: { inputs: Record[]; outputs: Record[] } }; + }; + const [funding] = manifest.actions.Pay.inputs; + + if (!funding) { + throw new Error("the fixture's Pay action declares no inputs"); + } + + funding.issuance = issuance; + // Where the created units land. An issuance mints them into the transaction, and a + // transaction holding units no output accounts for is one the network will not balance — + // so every published protocol that issues something also declares where it goes, and a + // fixture that did not was asserting against a transaction nobody could have broadcast. + funding.on_resolved = { set: { "instance.MINTED_ASSET": "asset" } }; + manifest.actions.Pay.outputs.push({ + amount_sat: issuance.asset_amount_sat, + asset: "instance.MINTED_ASSET", + confidential: false, + description: "The units this action created, returned to the wallet that made them.", + destination: "wallet", + id: "minted_out", + }); + + return manifest; +} + +/** The same protocol with its funding input declaring a sequence. */ +function sequencedManifest(sequence: unknown) { + const manifest = structuredClone(p2pkManifest) as unknown as { + actions: { Pay: { inputs: Record[] } }; + }; + const [funding] = manifest.actions.Pay.inputs; + + if (!funding) { + throw new Error("the fixture's Pay action declares no inputs"); + } + + funding.sequence = sequence; + + return manifest; +} + +// The module takes one sequence for the transaction and writes it onto every input that +// declares none, so a declaration either collapses to one value the whole transaction can +// carry or it is refused. Dropping one builds a transaction the protocol did not ask for, +// which the chain rejects on broadcast far from anything that explains it. +describe("the sequence the transaction declares", () => { + // 0xFFFFFFFE carries BIP68's disable bit, so it constrains no input and only enables the + // transaction's own locktime. That is what every such declaration in the corpus is for. + test("is handed to the module when the action declares one that constrains nothing", async () => { + const { method, recorded } = subject(); + + await method(params({ manifest: sequencedManifest(4_294_967_294) }), context()); + + expect(recorded.sequence).toBe(4_294_967_294); + }); + + test("and is left unset where the action declares nothing", async () => { + const { method, recorded } = subject(); + + await method(params(), context()); + + expect(recorded.sequence).toBeUndefined(); + }); + + // A relative timelock is measured against the age of the input carrying it, so writing one + // onto the outputs funding the transaction time-locks those too. Refused rather than built + // as something else. + test("but a relative timelock is refused, because it cannot be carried by one input alone", async () => { + const { method } = subject(); + + const failure = await method( + params({ manifest: sequencedManifest({ relative_blocks: 6 }) }), + context(), + ).then( + () => undefined, + (error: unknown) => error as { data?: { reject?: string } }, + ); + + expect(failure?.data?.reject).toBe("unimplemented-construct"); + }); +}); + +// The asset an action creates is worked out while the document is read, from an output the +// wallet commits to before anything else runs. Until now none of that reached the module, so +// the wallet showed a person an asset and signed a transaction that created nothing. +describe("an input that creates an asset", () => { + test("carries the issuance the wallet settled, on the output it was derived from", async () => { + const { method, recorded } = subject(); + + await method(params({ manifest: issuingManifest() }), context()); + + expect(recorded.issued).toEqual([ + { + assetAmountSats: 1_000n, + contractInput: false, + inflationAmountSats: 0n, + // Nothing is stated, because a manifest declares no issuer contract at any + // position. Both sides commit to the empty one and each says so. + issuerContractHex: undefined, + txid: FUNDING_TXID, + vout: 0, + }, + ]); + }); + + test("and the transaction it signs spends that same output", async () => { + const { method, recorded } = subject(); + + const result = await method(params({ manifest: issuingManifest() }), context()); + + expect(result.transactionHex).toBe(recorded.signed); + }); + + test("while an action that creates nothing tells the builder about no issuance", async () => { + const { method, recorded } = subject(); + + await method(params(), context()); + + expect(recorded.issued).toEqual([]); + }); + + // A reissuance needs the entropy of an issuance that already happened, which reaches a + // request only on a supplied input this wallet does not read. Refused by name rather than + // minting a different asset under the protocol's name. + test("but reissuing is refused by name rather than derived from this transaction", async () => { + const { method } = subject(); + + const failure = await method( + params({ manifest: issuingManifest({ asset_amount_sat: 1_000, kind: "reissue" }) }), + context(), + ).then( + () => undefined, + (error: unknown) => error as { data?: { reject?: string } }, + ); + + expect(failure?.data?.reject).toBe("unimplemented-construct"); + }); +}); + +// The asset an action creates is the first fact the wallet and the signing module each work +// out for themselves, from the same output. They should agree, and a silent disagreement +// means one of them is creating a different asset than the other with nothing downstream able +// to tell which. +// An output pays in the asset the document states for it. Every output used to be built in +// this account's policy asset, so a protocol moving its own token would have paid real money +// to a covenant expecting the token — a transaction the wallet would have signed. +describe("what asset each output is built in", () => { + test("is the one the review worked out, not this account's policy asset", async () => { + const { method, recorded } = subject(); + + await method(params({ manifest: issuingManifest() }), context()); + + const minted = recorded.outputs.filter((output) => output.asset === ISSUED.assetId); + + expect(minted.length).toBe(1); + // And the rest of them are still the network's own, so this is a distinction rather than + // a second blanket assumption. + expect(recorded.outputs.some((output) => output.asset === POLICY_ASSET)).toBe(true); + }); +}); + +describe("when the module disagrees about the asset it issued", () => { + test("the two derivations agreeing is what lets the transaction be signed", async () => { + const { method, recorded } = subject(); + + const result = await method(params({ manifest: issuingManifest() }), context()); + + expect(result.transactionHex).toBe(recorded.signed); + }); + + test("a different asset refuses, and says which value disagreed", async () => { + const { method } = subject({ ...ISSUED, assetId: `${"0".repeat(63)}1` }); + + const failure = await method(params({ manifest: issuingManifest() }), context()).then( + () => undefined, + (error: unknown) => error as { data?: { reject?: string }; message?: string }, + ); + + expect(failure?.data?.reject).toBe("built-something-else"); + expect(failure?.message).toContain("asset"); + expect(failure?.message).toContain(ISSUED.assetId); + }); + + // Two of the three agreeing is still a disagreement about what is being created, so each + // one is its own case rather than the asset standing in for all three. + test("a different reissuance token refuses too", async () => { + const { method } = subject({ ...ISSUED, reissuanceTokenId: `${"0".repeat(63)}2` }); + + const failure = await method(params({ manifest: issuingManifest() }), context()).then( + () => undefined, + (error: unknown) => error as { data?: { reject?: string }; message?: string }, + ); + + expect(failure?.data?.reject).toBe("built-something-else"); + expect(failure?.message).toContain("reissuance token"); + }); + + test("and so does a different entropy", async () => { + const { method } = subject({ ...ISSUED, entropy: `${"0".repeat(63)}3` }); + + const failure = await method(params({ manifest: issuingManifest() }), context()).then( + () => undefined, + (error: unknown) => error as { data?: { reject?: string }; message?: string }, + ); + + expect(failure?.data?.reject).toBe("built-something-else"); + expect(failure?.message).toContain("entropy"); + }); + + // The wallet's own derivation is what decides. A comparison that lowered one side only + // could pass while the values differ, so both are lowered and the same value written the + // other way round is still the same value. + test("the same value in another case is not a disagreement", async () => { + const { method, recorded } = subject({ + assetId: ISSUED.assetId.toUpperCase(), + entropy: ISSUED.entropy.toUpperCase(), + reissuanceTokenId: ISSUED.reissuanceTokenId.toUpperCase(), + }); + + const result = await method(params({ manifest: issuingManifest() }), context()); + + expect(result.transactionHex).toBe(recorded.signed); + }); +}); + +/** + * The check that the transaction hides what the wallet decided to hide. + * + * Which outputs hide anything is settled while the document is read, and all that reaches the + * module is a blinding key or nothing. Whether it was applied is only visible in the bytes, + * and until this guard nothing looked: the method handed the module a key, took back a + * transaction, and returned it. + * + * Both failures are silent and neither is recoverable. An amount published that the protocol + * meant kept is on the chain for good. An amount hidden on an output a covenant will later + * read is money that cannot be spent, because a Simplicity program reads exact amounts + * through jets that cannot introspect a commitment. + */ +/** A module that takes every blinding key it is given and builds the output open anyway. */ +const ignoringKeys: ModuleBehaviour = (built) => ({ + changeBlinded: false, + outputs: built.outputs.map((output) => ({ ...output, blinded: false })), +}); + +/** A module that hides every output, including the ones a covenant has to read. */ +const hidingEverything: ModuleBehaviour = (built) => ({ + changeBlinded: true, + outputs: built.outputs.map((output) => ({ ...output, blinded: true })), +}); + +/** + * A module that builds every declared output as it was told and hides the change anyway. + * + * The direction that matters now. The wallet publishes a contract action's own change so the + * money returns in a form the next action can be funded from, and a module hiding it strands + * exactly that money — the next action can spend only what is already in the open, and nothing + * downstream of the module would say so. + */ +const hidingTheChange: ModuleBehaviour = (built) => ({ ...built, changeBlinded: true }); + +describe("what the transaction actually hides", () => { + test("is what the wallet decided: the covenant output open, the change published", async () => { + const { method, recorded } = subject(); + + await method(params(), context()); + + // A covenant output can never hide what it carries, whatever a document says, and this + // is the wallet acting on that rather than stating it. + expect(recorded.outputs).toEqual([ + { asset: POLICY_ASSET, blinded: false, script: DERIVED_SCRIPT }, + ]); + // And the change is handed over without a blinding key, which the document did not ask + // for and this network's own default is against. It is the one place the wallet answers + // over the format, and it buys change the next action can actually be funded from. + expect(recorded.changeBlinded).toBe(false); + }); + + // The module this used to catch, kept because what it now proves is the change reaching the + // guard. It publishes every amount it is given a key for, and that is precisely what the + // wallet asked for here: the covenant output could never hide, and the change is published + // deliberately. An expectation that had not followed the decision would refuse this. + test("and a module that publishes everything is now exactly what the wallet asked for", async () => { + const { method, recorded } = subject(ISSUED, ignoringKeys); + + const result = await method(params(), context()); + + expect(result).toMatchObject({ broadcast: false }); + expect(recorded.changeBlinded).toBe(false); + }); + + test("and a module that hid the change the wallet published returns nothing", async () => { + const { method } = subject(ISSUED, hidingTheChange); + + const failure = await method(params(), context()).then( + () => undefined, + (error: unknown) => error as { data?: { reject?: string }; message?: string }, + ); + + expect(failure?.data?.reject).toBe("built-something-else"); + expect(failure?.message).toContain("hides the amount on the change"); + }); + + test("and a module that hid what the wallet left open returns nothing", async () => { + const { method } = subject(ISSUED, hidingEverything); + + const failure = await method(params(), context()).then( + () => undefined, + (error: unknown) => error as { data?: { reject?: string }; message?: string }, + ); + + expect(failure?.data?.reject).toBe("built-something-else"); + expect(failure?.message).toContain("hides the amount on p2pk_out"); + }); + + // The refusal happens after signing and before anything leaves, which is the only place + // it can: the bytes do not exist until the module has built them. + test("and nothing is broadcast when the guard refuses", async () => { + const { method, recorded } = subject(ISSUED, hidingTheChange); + + await method(params({ broadcast: true }), context()).catch(() => undefined); + + expect(recorded.broadcasts).toHaveLength(0); + }); +}); diff --git a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts index f7add34..0c4e010 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.ts @@ -1,34 +1,664 @@ +import { SMPLX_COMPILER_VERSION } from "@humid/smplx-compiler"; +import { + createEsploraFeeRateReader, + createEsploraTxOutReader, + guardBlindedOutputs, + guardSpentInputs, + isRefusal, + type ManifestReview, + type ParsedLiquidProcessCtParams, + parseLiquidProcessCtParams, + type ReadFeeRate, + type ReadTxOut, + type RejectToken, + reviewManifestAction, + toShownConfirmation, +} from "@humid/tx-manifest"; + +import type { KeyManagerState, UpdateKeyManagerState } from "@/core/key-manager/types"; +import { logger } from "@/core/logger"; import { createWalletMethod } from "@/core/wallet-methods/createWalletMethod"; -import { WalletRpcNotImplementedError } from "@/core/wallet-rpc/errors"; +import { WALLET_RPC_ERROR_REASONS, WalletRpcInvalidParamsError } from "@/core/wallet-rpc/errors"; import type { WalletRpcBaseContext } from "@/core/wallet-rpc/types"; +import { toScriptPubKeyHex } from "../../../adapters/lwk/wallet/toScriptPubKeyHex"; +import { withAccountMnemonic } from "../../../adapters/lwk/wallet/withAccountMnemonic"; +import { loadSmplxWasm } from "../../../adapters/smplx/loadSmplxWasm"; +import type { LiquidChainRecord } from "../../../chains/LiquidChainRecord"; import { LIQUID_WALLET_RPC_METHODS } from "../../../domain/LiquidRpc"; +import type { LiquidWalletBackend } from "../../backends/LiquidWalletBackend"; +import { resolveDappAccount } from "../../dappAccountScope"; +import { PROCESS_CT_CONFIRMATION_KIND } from "./ProcessCtConfirmation"; + +export type LiquidProcessCtContext = WalletRpcBaseContext & { + chain: LiquidChainRecord; + keyManagerState: KeyManagerState; + updateKeyManagerState?: UpdateKeyManagerState; + walletBackend: LiquidWalletBackend; +}; + +export type LiquidProcessCtResult = { + broadcast: boolean; + /** + * The deployment this action brought into existence, when it created one. + * + * Absent for every action that only spends what already exists. Returned rather than left + * for the caller to work out again, because half of these fields are functions of outputs + * the wallet chose — an asset id is derived from the output its issuing input spends — and + * a caller reconstructing them afterwards would be guessing which output that was. The + * deployment outlives the transaction; the transaction is where it can still be read. + */ + deployment?: Record; + feeSats: string; + transactionHex: string; + txid: string; +}; + +/** The network names the SDK understands, keyed by the wallet's own network kind. */ +const SMPLX_NETWORKS: Record = { + mainnet: "liquid", + regtest: "elements-regtest", + testnet: "liquid-testnet", +}; + +/** + * Performs one action of a txManifest protocol. The site sends the manifest, the sources + * of the contracts it references, the chosen action and its filled parameters; everything + * else happens inside the extension. + * + * The wallet establishes for itself that each contract is the one the site describes: it + * rebuilds every covenant from source, and for one being spent compares the derived + * address against what the chain says is at that outpoint. A mismatch refuses, and there + * is no way to click through it. + * + * That check lives in `review` deliberately. `review` runs before the permission gate, so + * a standing permission — which skips the prompt entirely — cannot skip the verification + * with it. + */ +/** + * Everything the method reaches outside itself. + * + * Named as one object so the whole seam — parse, verify, plan, sign, broadcast — can be + * driven in a test. Without this the only way to exercise the method is to build the + * extension and run it, which is why nothing did. + */ +export type LiquidProcessCtDependencies = { + broadcastTransaction: (input: { + chain: LiquidChainRecord; + txHex: string; + }) => Promise<{ txid: string }>; + loadSmplx: typeof loadSmplxWasm; + readFeeRate: (chain: LiquidChainRecord) => ReadFeeRate; + readTxOut: (chain: LiquidChainRecord) => ReadTxOut; + resolveAccount: typeof resolveDappAccount; + scriptPubKeyHexOf: (address: string) => Promise; + withMnemonic: typeof withAccountMnemonic; +}; /** - * Liquid Wallet ABI confidential transaction processing (ELIP-1, optional and not - * yet implemented). Wrapped as a proper method so it self-registers on the Liquid RPC - * surface and rejects with a not-implemented error when a dapp invokes it. + * The witness values one covenant input needs, in the shape the signing module takes. + * + * A type and a literal, both text, keyed by the name the contract declares. The wallet does + * not parse either: the compiler that will type-check the literal is the authority on what it + * means, and a wallet reading `Right(Left(()))` for itself would be a second opinion about + * which branch of a contract runs. */ -export const processLiquidConfidentialTransaction = createWalletMethod< - null, - WalletRpcBaseContext, - null, - never ->({ - confirmation: () => ({ - data: { - kind: "liquid.processConfidentialTransaction", +function witnessValuesJson( + values: { name: string; simplicityType: string; value: string }[] | undefined, +): string | undefined { + if (!values || values.length === 0) { + return undefined; + } + + return JSON.stringify( + Object.fromEntries( + values.map(({ name, simplicityType, value }) => [name, { type: simplicityType, value }]), + ), + ); +} + +/** How the method is wired in the extension. Tests substitute what they need. */ +export const liquidProcessCtDependencies: LiquidProcessCtDependencies = { + // Imported when a transaction is actually broadcast rather than at module load: the + // sync-worker client reaches for `webextension-polyfill`, which throws outside an + // extension, and nothing else in this method needs a browser. + broadcastTransaction: async (input) => { + const { getSyncWorkerClient } = + await import("../../../adapters/lwk/sync-worker/createSyncWorkerClient"); + + return getSyncWorkerClient().broadcastTransaction(input); + }, + loadSmplx: loadSmplxWasm, + readFeeRate: (chain) => createEsploraFeeRateReader(chain.settings.backend), + readTxOut: (chain) => createEsploraTxOutReader(chain.settings.backend), + resolveAccount: resolveDappAccount, + scriptPubKeyHexOf: toScriptPubKeyHex, + withMnemonic: withAccountMnemonic, +}; + +/** + * Turns the runtime's malformed-request answer into the wire error a caller sees. + * + * The runtime returns a value rather than throwing because it has no transport; this + * method has one, and owns how a refusal reaches whoever asked. + */ +function parseRequest(params: unknown): ParsedLiquidProcessCtParams { + const parsed = parseLiquidProcessCtParams(params); + + if (!parsed.ok) { + throw new WalletRpcInvalidParamsError( + parsed.malformed.message, + parsed.malformed.details, + WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, + ); + } + + return parsed.request; +} + +export const createProcessLiquidConfidentialTransaction = ( + dependencies: LiquidProcessCtDependencies = liquidProcessCtDependencies, +) => + createWalletMethod< + ParsedLiquidProcessCtParams, + LiquidProcessCtContext, + ManifestReview, + LiquidProcessCtResult + >({ + confirmation: ({ params, review }) => ({ + data: { + broadcast: params.broadcast, + kind: PROCESS_CT_CONFIRMATION_KIND, + // The whole model the person is shown, amounts as strings: this crosses the + // message bus, which serializes as JSON, and JSON.stringify throws on a bigint + // rather than rounding it. Every covenant the wallet rebuilt is inside it, with + // what it established about each — `not-yet-on-chain` marks one being created, + // which there is nothing to compare against and is a different fact rather than + // a weaker form of verified. + shown: toShownConfirmation(review.confirmation), + }, + message: `A site wants to perform "${review.action}" on the ${review.protocol} protocol.`, + title: "Perform a contract action?", + }), + execute: async ({ context, params, review }) => { + const network = requireNetwork(context); + + /* + * What the signing module is about to be told about each covenant, beside what the + * review established about the same covenant from the chain. + * + * Written at warn so it survives a production build, because this is the seam where the + * two compiles can disagree and the disagreement only shows up as an execution failure + * after a person has approved. `covenantBuild` is the marker for which build is loaded: + * an extension without it in the log is an older copy, whatever the files on disk say. + */ + logger.warn("covenantBuild", { + action: review.action, + covenants: review.covenants.map((found) => ({ + address: found.address, + role: found.role, + utxoType: found.utxoType, + verified: found.verified, + })), + inputs: review.covenantInputs.map((covenant) => ({ + argumentsJson: covenant.argumentsJson, + extraLeavesJson: covenant.extraLeavesJson, + id: covenant.id, + includeDebugSymbols: covenant.includeDebugSymbols, + sourceBytes: covenant.source.length, + txid: covenant.txid, + vout: covenant.vout, + })), + }); + const account = await dependencies.resolveAccount(context); + const smplx = await dependencies.loadSmplx(); + + // Everything except signing was settled in `review`, before the person was asked. + // What gets signed here is the transaction they were shown, not one reassembled + // afterwards from the same inputs. + const signed = await dependencies.withMnemonic( + { + accountGroupIndex: account.accountGroupIndex, + chain: context.chain, + keyManagerState: context.keyManagerState, + }, + (mnemonic) => { + const signer = new smplx.WalletSigner(mnemonic, network); + const builder = new smplx.TransactionBuilder(); + + // A covenant branch guarded by a lock height reads the transaction's own + // locktime, and one that declares none satisfies no such branch. The review + // answers with where the chain is — the same thing every wallet writes there, + // and nothing about any protocol. Skipped where it read nothing, because an + // action whose covenants are not time-locked does not need one. + if (review.locktimeHeight !== undefined) { + builder.setLocktimeHeight(review.locktimeHeight); + } + + // One sequence for the transaction, because that is what the module takes: it + // writes this onto every input that declares none. The review has already + // collapsed what the action declares into the single value this can be, or + // refused the action. Skipped where nothing was declared, which leaves every + // input at the module's own default. + if (review.sequence !== undefined) { + builder.setSequence(review.sequence); + } + + try { + // Which inputs create an asset, keyed by the output each one is derived + // from. That outpoint is the only join both sides promise: the manifest + // named the input, the wallet chose the output, and an asset id is a + // function of the output rather than of where the input ended up. Matching + // on order would be matching on something neither side states. + const issuing = new Map( + review.issuances.map((issuance) => [ + outpointKey(issuance.outpoint.txid, issuance.outpoint.vout), + issuance, + ]), + ); + const placed = new Set(); + + // The module derives the asset for itself, from the same output, and reports + // what it made of it. This is the first fact the wallet and the module each + // establish independently, so it gets the treatment every other such fact + // gets: they are compared, and a difference refuses rather than one of the + // two being trusted. A silent disagreement means one of them is creating a + // different asset than the other, and nothing downstream could tell which. + const agreeOrRefuse = ( + issuance: (typeof review.issuances)[number], + reported: { + assetId: string; + entropy: string; + free: () => void; + reissuanceTokenId: string; + }, + ) => { + try { + const difference = firstDisagreement(issuance, reported); + + if (difference) { + throw new WalletRpcInvalidParamsError( + `Input ${issuance.inputId} creates an asset the signing module ` + + `does not agree about: the ${difference.what} the wallet derived ` + + `is ${difference.mine} and the module reports ${difference.theirs}.`, + { reject: "built-something-else" satisfies RejectToken }, + WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, + ); + } + } finally { + reported.free(); + } + }; + + // In the order the review worked out, which is the document's wherever it + // states one. Every covenant used to be added first and the wallet's own + // after, which is one order among many: a covenant introspects positions, + // and a document stating one for an input the wallet supplies is saying + // that that order builds a transaction its contract will not run against. + for (const planned of review.inputOrder) { + const key = + planned.source === "covenant" + ? outpointKey(planned.covenant.txid, planned.covenant.vout) + : outpointKey(planned.utxo.txid, planned.utxo.vout); + const issuance = issuing.get(key); + + if (issuance) { + placed.add(key); + } + + if (planned.source === "covenant") { + const { covenant } = planned; + // The values the document states outright, which is how a covenant + // with more than one branch is told which to run. A signature is not + // among them: only the signer can make one, and naming it below is + // what asks for one. Passed as the compiler's own witness shape — a + // type and a literal, both text — because the compiler is what + // parses SimplicityHL. + const witness = witnessValuesJson(covenant.witnessValues); + + if (issuance) { + logger.warn("covenantBuild:issue", { + id: covenant.id, + includeDebugSymbols: covenant.includeDebugSymbols, + leaves: covenant.extraLeavesJson, + }); + + // The issuer contract is left unstated because a manifest declares + // none at any position, so both sides commit to nothing and each + // says so. + agreeOrRefuse( + issuance, + builder.addContractIssuanceInput( + covenant.txid, + covenant.vout, + covenant.txOutHex, + covenant.source, + covenant.argumentsJson, + witness, + covenant.signatureWitness, + issuance.assetAmountSats, + issuance.inflationAmountSats, + undefined, + covenant.extraLeavesJson, + covenant.includeDebugSymbols, + ), + ); + } else { + // The leaves and the mode go with the source and the parameters, because all + // four decide the script the covenant locks to. Sending the first two alone + // builds a different contract than the one the review checked against the + // chain, and the covenant refuses its own spend at execution. + logger.warn("covenantBuild:spend", { + id: covenant.id, + includeDebugSymbols: covenant.includeDebugSymbols, + leaves: covenant.extraLeavesJson, + }); + builder.addContractInput( + covenant.txid, + covenant.vout, + covenant.txOutHex, + covenant.source, + covenant.argumentsJson, + witness, + covenant.signatureWitness, + covenant.extraLeavesJson, + covenant.includeDebugSymbols, + ); + } + + continue; + } + + const { utxo } = planned; + + if (issuance) { + agreeOrRefuse( + issuance, + builder.addWalletIssuanceInput( + utxo.txid, + utxo.vout, + utxo.txOut, + issuance.assetAmountSats, + issuance.inflationAmountSats, + undefined, + ), + ); + } else { + builder.addWalletInput(utxo.txid, utxo.vout, utxo.txOut); + } + } + + // An asset derived from an output no input spends is an id for something + // that would never exist, and the person would have been shown it. This + // cannot happen while the outputs an issuance is derived from are the ones + // reserved out of the funding pool, which is why it is an assertion about + // this path rather than a refusal a document can provoke. + const stranded = review.issuances.find( + (issuance) => + !placed.has(outpointKey(issuance.outpoint.txid, issuance.outpoint.vout)), + ); + + if (stranded) { + throw new WalletRpcInvalidParamsError( + `Input ${stranded.inputId} issues an asset from an output this ` + + "transaction does not spend, so the asset would never exist.", + { reject: "built-something-else" satisfies RejectToken }, + WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, + ); + } + + // An output the document wants hidden is hidden with this wallet's own blinding + // key. Which outputs those are was decided while reading the document, not + // here: the builder has never read it, and an output built the wrong way is + // one whose amount is published when the protocol meant it kept. + for (const output of review.outputs) { + // Paid in the asset the review worked out for it, which is not always this + // account's policy asset and used to be assumed to be. An output carrying a + // protocol's own token, built in the network's asset instead, pays real + // money to a covenant expecting a token — and nothing downstream of here + // could tell. + builder.addOutput( + output.scriptPubKeyHex, + output.sats, + output.asset, + output.blinded ? signer.blindingPublicKey() : undefined, + ); + } + + // Where change goes is a fact about this transaction, so it is set on the + // builder rather than passed to the call that signs it. Unset, the module + // returns change to the signer's own derived address, which this wallet does + // watch today but only because the signing path is limited to one index. + builder.addChange( + signer.scriptPubKeyHex(), + review.changeBlinded ? signer.blindingPublicKey() : undefined, + ); + + const result = signer.finalizeTransaction(builder, review.feeRateSatsPerKvb); + const extracted = { + feeSats: result.feeSats.toString(), + transactionHex: result.hex, + txid: result.txid, + }; + + result.free(); + + return extracted; + } finally { + builder.free(); + signer.free(); + } + }, + ); + + // What came back spends only what the action required and the wallet chose, or + // nothing is returned at all. The guard reads the transaction's own bytes rather + // than asking the module, because a module's account of itself cannot answer + // whether it did something it was not asked to. + const guarded = guardSpentInputs(signed.transactionHex, { + covenantInputs: review.covenantInputs.map(({ txid, vout }) => ({ txid, vout })), + walletInputs: review.selected.map(({ txid, vout }) => ({ txid, vout })), + }); + + if (!guarded.ok) { + throw new WalletRpcInvalidParamsError( + guarded.reason, + undefined, + WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, + ); + } + + // And what came back hides exactly what the document decided to hide. Handing the + // builder a blinding key is a request, not a result: whether it was applied is only + // visible in the bytes, where a hidden amount is a commitment and an open one is a + // number. Both directions are checked, because both are silent — an amount published + // that the protocol meant kept cannot be taken back, and an amount hidden on an + // output a covenant will later read is money nothing can spend. + const built = guardBlindedOutputs(signed.transactionHex, { + changeBlinded: review.changeBlinded, + outputs: review.outputs.map(({ blinded, id }) => ({ blinded, id })), + }); + + if (!built.ok) { + throw new WalletRpcInvalidParamsError( + built.reason, + { reject: "built-something-else" satisfies RejectToken }, + WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, + ); + } + + // The deployment the action created, if it created one. Carried on both answers, + // because the caller that has to record it is the one that asked for the action and + // a transaction it did not broadcast is still one it may broadcast itself. + const deployment = + review.createdInstance === undefined ? {} : { deployment: review.createdInstance.fields }; + + if (!params.broadcast) { + return { broadcast: false, ...deployment, ...signed }; + } + + // LWK's Esplora client needs a `window` the service worker does not have, so the + // finished transaction crosses into the offscreen document to go out. Nothing else + // crosses: it is already signed. + const sent = await dependencies.broadcastTransaction({ + chain: account.chain, + txHex: signed.transactionHex, + }); + + return { broadcast: true, ...deployment, ...signed, txid: sent.txid }; }, - message: "A dapp wants to process a Liquid confidential transaction.", - title: "Process Liquid confidential transaction?", - }), - execute: () => { - throw new WalletRpcNotImplementedError( - LIQUID_WALLET_RPC_METHODS.PROCESS_CONFIDENTIAL_TRANSACTION, - "Liquid Wallet ABI confidential transaction processing is not implemented yet.", + id: LIQUID_WALLET_RPC_METHODS.PROCESS_CONFIDENTIAL_TRANSACTION, + parse: parseRequest, + review: async ({ context, params }) => { + const network = requireNetwork(context); + const account = await dependencies.resolveAccount(context); + const smplx = await dependencies.loadSmplx(); + + await context.walletBackend.syncAccount(account); + + const result = await reviewManifestAction(params, { + // One compiled contract, two spellings of where the covenant is. Deriving them + // from separate compiles is how an output came to be paid to a bech32 string: + // the builder hex-decodes what it is given, and an address is not hex. + compile: ({ + argumentsJson, + extraLeavesJson, + includeDebugSymbols, + network: target, + source, + }) => { + const contract = new smplx.Contract( + source, + argumentsJson, + extraLeavesJson, + includeDebugSymbols, + ); + + try { + return { + address: contract.contractAddress(target), + scriptPubKeyHex: contract.scriptPubKeyHex(target), + }; + } finally { + contract.free(); + } + }, + // The other half of the same compiler, asked before a contract is built rather than + // after. A deployment wires most compile parameters to a name, which carries the + // format's own declared type; some it writes as a bare value, and those have no type + // at the position they are written. SimplicityHL declares one nowhere either — a + // parameter takes the type of the position it is used at, worked out by the type + // checker — so the compiler is the only thing that can say, and it can say it from + // the source alone, before there are any arguments to build. + contractParamTypes: (source) => JSON.parse(smplx.contractParameterTypes(source)), + compilerVersion: SMPLX_COMPILER_VERSION, + policyAsset: account.rawPolicyAssetId, + // The same compiler again, for the covenant hashes a document works out for itself. + // Everything a full compile is given, because a hash of a contract built any + // differently is the hash of a different contract — and a manifest stores that hash + // as a parameter of the covenant it then locks funds into. The leaves and the + // declared build mode were both absent here, so the hash was of a contract with an + // empty taproot tree built in whichever mode the module defaults to. + scriptPubKeyOf: ({ argumentsJson, extraLeavesJson, includeDebugSymbols, source }) => { + const contract = new smplx.Contract( + source, + argumentsJson, + extraLeavesJson, + includeDebugSymbols, + ); + + try { + return contract.scriptPubKeyHex(network); + } finally { + contract.free(); + } + }, + // Both lists, because only one of them can pay for this and the other one is why a + // person is short. Selection spends the explicit ones and reports the hidden ones as + // held back, which is the difference between "you do not have enough" and "you have + // enough and it is in the wrong shape". + fundingUtxos: [ + ...context.walletBackend.getExplicitUtxos(account, account.rawPolicyAssetId), + ...context.walletBackend.getUtxos(account, account.rawPolicyAssetId), + ], + // The same two lists for any other asset the action turns out to move, asked for by + // id. Which assets those are is not knowable here — it is settled inside the review, + // after the document's lookups resolve against the deployment — so this is a + // question the runtime asks rather than an answer the wallet prepares. + holdingsOf: (asset) => [ + ...context.walletBackend.getExplicitUtxos(account, asset), + ...context.walletBackend.getUtxos(account, asset), + ], + network, + accountLabel: `${account.chain?.id ?? context.chain.id} account ${account.accountGroupIndex}`, + // The wallet's own scan rather than an endpoint: it has just synced, and a plain + // chain-tip route is not universal — the backend this wallet uses for Liquid + // testnet answers 404 to it, which is how a locktime came to be declared as zero. + readChainTip: async () => context.walletBackend.getTipHeight(account), + readFeeRate: dependencies.readFeeRate(context.chain), + readTxOut: dependencies.readTxOut(context.chain), + // The address this path can spend from rather than the one a person is shown for + // receiving. They differ as addresses are used, and an output paid back to this + // wallet at a rotating one is money the next action of the same protocol cannot + // find: the signing module derives one key, at the first external address. + walletScriptPubKeyHex: await dependencies.scriptPubKeyHexOf( + context.walletBackend.getSigningAddress(account).address, + ), + }); + + if (isRefusal(result)) { + // The sentence is for a person; the token beside it is for the site. Every refusal + // on this path shares one wire code, so without the token a caller telling "this + // wallet will never build that" from "your state file is out of date" has to parse + // English — and one of those is worth retrying while the other never is. + throw new WalletRpcInvalidParamsError( + result.reason, + { reject: result.reject }, + WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, + ); + } + + return result; + }, + }); + +export const processLiquidConfidentialTransaction = createProcessLiquidConfidentialTransaction(); + +/** One output, written the same way on both sides of a comparison. */ +function outpointKey(txid: string, vout: number): string { + return `${txid}:${vout}`; +} + +/** + * What the module says it issued, against what the wallet derived, in one comparison. + * + * All three values, because two of them agreeing while the third does not is still a + * disagreement about what is being created. Both sides are lowered before they are compared: + * normalising one side only is a comparison that can pass while the values differ. + */ +function firstDisagreement( + planned: { asset: string; entropy: string; reissuanceToken: string }, + reported: { assetId: string; entropy: string; reissuanceTokenId: string }, +): { mine: string; theirs: string; what: string } | undefined { + const compared = [ + { mine: planned.asset, theirs: reported.assetId, what: "asset" }, + { mine: planned.reissuanceToken, theirs: reported.reissuanceTokenId, what: "reissuance token" }, + { mine: planned.entropy, theirs: reported.entropy, what: "entropy" }, + ]; + + return compared.find(({ mine, theirs }) => mine.toLowerCase() !== theirs.toLowerCase()); +} + +function requireNetwork(context: LiquidProcessCtContext): string { + const network = SMPLX_NETWORKS[context.chain.settings.network]; + + if (!network) { + throw new WalletRpcInvalidParamsError( + `Contract actions are not supported on ${context.chain.settings.network}.`, + undefined, + WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, ); - }, - id: LIQUID_WALLET_RPC_METHODS.PROCESS_CONFIDENTIAL_TRANSACTION, - parse: () => null, - review: () => null, -}); + } + + return network; +} diff --git a/apps/extension/src/core/chains/liquid/contractIdentityClient.ts b/apps/extension/src/core/chains/liquid/contractIdentityClient.ts new file mode 100644 index 0000000..88217de --- /dev/null +++ b/apps/extension/src/core/chains/liquid/contractIdentityClient.ts @@ -0,0 +1,22 @@ +import type { AccountGroupId } from "@/core/accounts/application/account-registry/model/identifiers"; +import { + type LiquidContractIdentityInput, + liquidContractRpc, +} from "@/core/extension-background/internal-rpc/liquid-contract"; +import { requestBackground } from "@/core/extension-rpc"; + +import type { LiquidContractIdentity } from "./application/contractIdentity"; + +/** + * Reads the address and key contract actions are signed with, for one account. + * + * Popup-side only. The background holds the contract module and the key material; this + * asks it for the two public values and nothing else. + */ +export function readLiquidContractIdentity( + accountGroupId: AccountGroupId, +): Promise { + return requestBackground(liquidContractRpc.methods.identity, { + accountGroupId, + } satisfies LiquidContractIdentityInput); +} diff --git a/apps/extension/src/core/extension-background/internal-rpc/index.ts b/apps/extension/src/core/extension-background/internal-rpc/index.ts index 2c2451a..6a8a53c 100644 --- a/apps/extension/src/core/extension-background/internal-rpc/index.ts +++ b/apps/extension/src/core/extension-background/internal-rpc/index.ts @@ -10,12 +10,14 @@ import type { TransferReview, } from "@/core/accounts/application/accounts-rpc/model/types"; import type { ChainGroup } from "@/core/chains/application/ChainGroup"; +import type { LiquidContractIdentity } from "@/core/chains/liquid/application/contractIdentity"; import type { ConfirmationRequest } from "@/helpers/background"; import type { ConfirmationResponder } from "../confirmations"; import type { RequestHandlerMap } from "../transport"; import { createAccountsInternalHandlers } from "./accounts"; import { createChainsInternalHandlers } from "./chains"; +import { createLiquidContractInternalHandlers } from "./liquid-contract"; import { walletVaultInternalHandlers } from "./wallet-vault"; import { walletConnectInternalHandlers } from "./walletconnect"; @@ -30,6 +32,7 @@ export type CreateInternalRpcHandlersInput = { getReceiveAddress: () => Promise; inspectTransfer: (input: SendTransferInput) => Promise; purgeAccountPortfolio: (accountGroupId: string) => Promise; + readContractIdentity: () => Promise; purgeAccountWalletConnectSessions: (accountGroupIds: readonly string[]) => Promise; refreshPortfolio: () => Promise; sendTransfer: (input: SendTransferInput) => Promise; @@ -49,6 +52,7 @@ export function createInternalRpcHandlers({ inspectTransfer, purgeAccountPortfolio, purgeAccountWalletConnectSessions, + readContractIdentity, refreshPortfolio, sendTransfer, }: CreateInternalRpcHandlersInput): RequestHandlerMap { @@ -73,6 +77,7 @@ export function createInternalRpcHandlers({ ...walletVaultInternalHandlers, ...walletConnectInternalHandlers, ...createChainsInternalHandlers(chainGroups), + ...createLiquidContractInternalHandlers(readContractIdentity), ...createAccountsInternalHandlers({ estimateMaxSend, getActivity, diff --git a/apps/extension/src/core/extension-background/internal-rpc/liquid-contract.ts b/apps/extension/src/core/extension-background/internal-rpc/liquid-contract.ts new file mode 100644 index 0000000..328894e --- /dev/null +++ b/apps/extension/src/core/extension-background/internal-rpc/liquid-contract.ts @@ -0,0 +1,30 @@ +import type { AccountGroupId } from "@/core/accounts/application/account-registry/model/identifiers"; +import type { LiquidContractIdentity } from "@/core/chains/liquid/application/contractIdentity"; + +import type { RequestHandlerMap } from "../transport"; + +export const liquidContractRpc = { + methods: { + identity: "liquid.contractIdentity", + }, +} as const; + +export type LiquidContractIdentityInput = { accountGroupId?: AccountGroupId }; + +/** + * Reads the address and key that contract actions are signed with, for one account. + * + * Popup-only: the transport dispatches injected senders to a separate registry, so a + * dapp cannot reach this. The account is named rather than assumed to be the selected + * one, because the screen this serves is per-account and the two differ. + */ +export function createLiquidContractInternalHandlers( + readContractIdentity: (accountGroupId?: AccountGroupId) => Promise, +): RequestHandlerMap { + return { + [liquidContractRpc.methods.identity]: (message) => + readContractIdentity( + (message.data as LiquidContractIdentityInput | undefined)?.accountGroupId, + ), + }; +} diff --git a/apps/extension/src/core/extension-background/transport/index.ts b/apps/extension/src/core/extension-background/transport/index.ts index de848a6..d375e02 100644 --- a/apps/extension/src/core/extension-background/transport/index.ts +++ b/apps/extension/src/core/extension-background/transport/index.ts @@ -18,6 +18,8 @@ import { } from "@/helpers/background"; import { sleep } from "@/helpers/promise"; +import { serializeError } from "./serializeError"; + export type PegasusMsgProtocolMap = { [MsgProtocolRequestMethods.Request]: ExtensionMessage; [MsgProtocolResponseMethods.RequestResponse]: ExtensionMessage; @@ -180,26 +182,6 @@ export function registerBackgroundRpc( }); } -/** - * Preserve a structured RPC error across the message boundary. A thrown `WalletRpcError` carries a - * numeric `code` and a `data.reason` the dapp branches on (e.g. skip retrying a user rejection); - * collapsing it to `error.message` — as this used to — dropped both, leaving the dapp a bare string - * it could not classify. Kept structural (no wallet-rpc import) so any error with code/data survives. - */ -function serializeError(error: unknown): unknown { - if (error instanceof Error) { - const structured = error as Error & { code?: unknown; data?: unknown }; - - return { - message: error.message, - ...(typeof structured.code === "number" ? { code: structured.code } : {}), - ...(structured.data === undefined ? {} : { data: structured.data }), - }; - } - - return error; -} - function resolveRequestHandler( sender: Endpoint, method: string, diff --git a/apps/extension/src/core/extension-background/transport/serializeError.test.ts b/apps/extension/src/core/extension-background/transport/serializeError.test.ts new file mode 100644 index 0000000..2b92e61 --- /dev/null +++ b/apps/extension/src/core/extension-background/transport/serializeError.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; + +import { serializeError } from "./serializeError"; + +describe("serializeError", () => { + test("an ordinary error keeps its message", () => { + expect(serializeError(new Error("plain"))).toEqual({ message: "plain" }); + }); + + test("the code and data a dapp branches on survive", () => { + const error = Object.assign(new Error("Refused."), { + code: -32_602, + data: { reason: "invalid_manifest_request" }, + }); + + expect(serializeError(error)).toEqual({ + code: -32_602, + data: { reason: "invalid_manifest_request" }, + message: "Refused.", + }); + }); + + // The reason a handler wraps a failure at all: the wrapper is stable and the cause is what + // actually went wrong. Dropping the cause left the wrapper's sentence as the whole story. + test("the cause survives the boundary", () => { + const wrapper = new Error("Could not build, sign, and broadcast the Liquid transfer."); + wrapper.cause = new Error("InsufficientFunds: missing 1200 satoshi"); + + expect(serializeError(wrapper)).toEqual({ + cause: { message: "InsufficientFunds: missing 1200 satoshi" }, + message: "Could not build, sign, and broadcast the Liquid transfer.", + }); + }); + + test("a chain several deep survives in order", () => { + const third = new Error("third"); + const second = new Error("second"); + second.cause = third; + const first = new Error("first"); + first.cause = second; + + expect(serializeError(first)).toEqual({ + cause: { cause: { message: "third" }, message: "second" }, + message: "first", + }); + }); + + // A cause chain can be circular, and this runs inside the message boundary — an unbounded + // walk here is a hung background rather than a bad message. + test("a circular chain terminates", () => { + const first = new Error("first"); + const second = new Error("second"); + first.cause = second; + second.cause = first; + + const serialized = JSON.stringify(serializeError(first)); + + expect(serialized.length).toBeLessThan(500); + expect(serialized).toContain("first"); + }); + + test("something that is not an error is passed through unchanged", () => { + expect(serializeError("just a string")).toBe("just a string"); + expect(serializeError({ shape: "unknown" })).toEqual({ shape: "unknown" }); + }); +}); diff --git a/apps/extension/src/core/extension-background/transport/serializeError.ts b/apps/extension/src/core/extension-background/transport/serializeError.ts new file mode 100644 index 0000000..7d2d2f6 --- /dev/null +++ b/apps/extension/src/core/extension-background/transport/serializeError.ts @@ -0,0 +1,29 @@ +/** + * Preserve a structured RPC error across the message boundary. A thrown `WalletRpcError` carries a + * numeric `code` and a `data.reason` the dapp branches on (e.g. skip retrying a user rejection); + * collapsing it to `error.message` — as this used to — dropped both, leaving the dapp a bare string + * it could not classify. Kept structural (no wallet-rpc import) so any error with code/data survives. + */ +export function serializeError(error: unknown, depth = 0): unknown { + if (error instanceof Error) { + const structured = error as Error & { code?: unknown; data?: unknown }; + + return { + message: error.message, + ...(typeof structured.code === "number" ? { code: structured.code } : {}), + ...(structured.data === undefined ? {} : { data: structured.data }), + // The cause is where the real reason lives. Handlers wrap a failure in a stable + // wallet error and attach what actually went wrong underneath — insufficient funds, a + // rejected broadcast, an address the chain library would not parse. Dropping it here + // left the wrapper's own sentence as the whole story, which is the opaque outcome the + // wrapping was written to avoid. Bounded, because a cause chain can be circular. + ...(structured.cause === undefined || depth >= MAX_CAUSE_DEPTH + ? {} + : { cause: serializeError(structured.cause, depth + 1) }), + }; + } + + return error; +} + +const MAX_CAUSE_DEPTH = 4; diff --git a/apps/extension/src/core/extension-rpc/index.ts b/apps/extension/src/core/extension-rpc/index.ts index 7430ed3..fb3a92f 100644 --- a/apps/extension/src/core/extension-rpc/index.ts +++ b/apps/extension/src/core/extension-rpc/index.ts @@ -3,6 +3,8 @@ import { definePegasusMessageBus } from "@webext-pegasus/transport"; import type { PegasusMsgProtocolMap } from "@/background"; import { MsgProtocolRequestMethods, MsgProtocolResponseMethods } from "@/helpers/background"; +import { toError } from "./toError"; + const REQUEST_TIMEOUT_MS = 60_000; let requestId = 0; @@ -44,7 +46,7 @@ function getMessageBus(): BackgroundMessageBus { clearTimeout(pendingRequest.timeoutId); if (response.error) { - pendingRequest.reject(new Error(String(response.error))); + pendingRequest.reject(toError(response.error)); return; } diff --git a/apps/extension/src/core/extension-rpc/toError.test.ts b/apps/extension/src/core/extension-rpc/toError.test.ts new file mode 100644 index 0000000..d998173 --- /dev/null +++ b/apps/extension/src/core/extension-rpc/toError.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test"; + +import { toError } from "./toError"; + +// What the background actually sends: a thrown Error is serialised structurally so a dapp can +// branch on the code. Every one of these shapes has to reach a person as words. +describe("toError", () => { + test("a serialised error arrives as its message", () => { + expect(toError({ message: "This account holds 0 of the 1377 needed." }).message).toBe( + "This account holds 0 of the 1377 needed.", + ); + }); + + test("the structured fields survive, so a caller can still branch on them", () => { + const error = toError({ code: -32_602, data: { reason: "invalid" }, message: "Refused." }); + + expect(error.message).toBe("Refused."); + expect(error).toMatchObject({ code: -32_602, data: { reason: "invalid" } }); + }); + + test("a plain string is already the message", () => { + expect(toError("No handler for method: foo").message).toBe("No handler for method: foo"); + }); + + // The failure this replaces: String({message}) is "[object Object]", so the one place the + // message was written for a person is the one place it did not arrive. + test("nothing renders as [object Object]", () => { + for (const raw of [ + { message: "readable" }, + { unexpected: "shape" }, + ["a", "b"], + 42, + null, + undefined, + ]) { + expect(toError(raw).message).not.toContain("[object Object]"); + } + }); + + test("an object with no message is shown as itself rather than as its type", () => { + expect(toError({ unexpected: "shape" }).message).toBe('{"unexpected":"shape"}'); + }); + + test("something that cannot be described still says something", () => { + const circular: Record = {}; + circular.self = circular; + + expect(toError(circular).message).toBe("The extension failed and did not say why."); + }); +}); + +// A handler wraps a failure in a stable wallet error and attaches the real reason underneath. +// Only the message reaches a screen, so the chain has to be in it. +describe("toError and the cause chain", () => { + test("the reason underneath reaches the message", () => { + const error = toError({ + cause: { message: "InsufficientFunds: missing 1200 satoshi" }, + code: -32_002, + message: "Could not build, sign, and broadcast the Liquid transfer.", + }); + + expect(error.message).toBe( + "Could not build, sign, and broadcast the Liquid transfer. — caused by: InsufficientFunds: missing 1200 satoshi", + ); + }); + + test("the cause is kept as an error too, for a caller that wants the parts", () => { + const error = toError({ cause: { message: "underneath" }, message: "wrapper" }); + + expect((error as Error & { cause?: Error }).cause?.message).toBe("underneath"); + }); + + test("a wrapper that only restates its cause does not say it twice", () => { + expect(toError({ cause: { message: "same" }, message: "same" }).message).toBe("same"); + }); + + test("a chain several deep reads in order", () => { + const error = toError({ + cause: { cause: { message: "third" }, message: "second" }, + message: "first", + }); + + expect(error.message).toBe("first — caused by: second — caused by: third"); + }); + + test("no cause reads exactly as it did before", () => { + expect(toError({ message: "alone" }).message).toBe("alone"); + }); +}); diff --git a/apps/extension/src/core/extension-rpc/toError.ts b/apps/extension/src/core/extension-rpc/toError.ts new file mode 100644 index 0000000..47a0985 --- /dev/null +++ b/apps/extension/src/core/extension-rpc/toError.ts @@ -0,0 +1,73 @@ +/** + * Turns whatever the background sent back into an error a person can read. + * + * The background serialises a thrown error structurally — `{message, code?, data?, cause?}` — so + * a dapp can branch on the code instead of parsing a sentence. Passing that object through + * `String()` produced `[object Object]`, which is what every failure in the wallet's own screens + * said: the one place the message was written for a person is the one place it did not arrive. + * + * The structured fields are kept on the error, since a caller that wants to branch has as much + * right to them here as a dapp does. Anything that is neither a string nor message-shaped is + * rendered as JSON rather than as its type name, because an unreadable error is worse than an + * ugly one. + */ +export function toError(raw: unknown): Error { + if (typeof raw === "string") { + return new Error(raw); + } + + if (typeof raw === "object" && raw !== null) { + const structured = raw as { + cause?: unknown; + code?: unknown; + data?: unknown; + message?: unknown; + }; + + if (typeof structured.message === "string") { + return Object.assign(new Error(describe(structured)), { + ...(structured.code === undefined ? {} : { code: structured.code }), + ...(structured.data === undefined ? {} : { data: structured.data }), + ...(structured.cause === undefined ? {} : { cause: toError(structured.cause) }), + }); + } + + try { + return new Error(JSON.stringify(raw)); + } catch { + return new Error("The extension failed and did not say why."); + } + } + + return new Error(String(raw)); +} + +/** + * One sentence carrying the whole chain, because only the message reaches a screen. + * + * A handler wraps a failure in a stable wallet error and attaches what actually went wrong + * underneath. The wrapper alone says "could not build, sign and broadcast the transfer", which + * is true and tells a person nothing they can act on; the cause says which of those it was. The + * `cause` field is kept on the error as well, for a caller that wants the parts rather than a + * sentence. + */ +function describe(error: { cause?: unknown; message?: unknown }): string { + const parts: string[] = []; + let current: { cause?: unknown; message?: unknown } | undefined = error; + + while (current && typeof current.message === "string") { + const message = current.message.trim(); + + // A wrapper that merely restates its cause adds nothing but length. + if (message && !parts.includes(message)) { + parts.push(message); + } + + current = + typeof current.cause === "object" && current.cause !== null + ? (current.cause as { cause?: unknown; message?: unknown }) + : undefined; + } + + return parts.join(" — caused by: "); +} diff --git a/apps/extension/src/core/wallet-rpc/errors.ts b/apps/extension/src/core/wallet-rpc/errors.ts index 2e2a97e..408ae89 100644 --- a/apps/extension/src/core/wallet-rpc/errors.ts +++ b/apps/extension/src/core/wallet-rpc/errors.ts @@ -18,6 +18,7 @@ export const WALLET_RPC_ERROR_REASONS = { INVALID_IDENTITY_PUBLIC_KEY: "invalid_identity_public_key", INVALID_IDENTITY_REQUEST: "invalid_identity_request", INVALID_LOCAL_ROOT_MATERIAL: "invalid_local_root_material", + INVALID_MANIFEST_REQUEST: "invalid_manifest_request", INVALID_MESSAGE_SIGNING_REQUEST: "invalid_message_signing_request", INVALID_PARAMS: "invalid_params", INVALID_PSET_REQUEST: "invalid_pset_request", diff --git a/apps/extension/src/helpers/formatters.test.ts b/apps/extension/src/helpers/formatters.test.ts new file mode 100644 index 0000000..33c9bce --- /dev/null +++ b/apps/extension/src/helpers/formatters.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; + +import { formatTimeAgo } from "./formatters"; + +// Expectations are taken from the documented contract of each function, not from +// reading its body: `formatTimeAgo` states that sub-minute gaps read as "just now" +// and that anything in between rounds down but never to "0m". +describe("formatTimeAgo", () => { + const now = 1_700_000_000_000; + const ago = (seconds: number) => formatTimeAgo(now - seconds * 1000, now); + + test("reads a sub-minute gap as 'just now'", () => { + expect(ago(0)).toBe("just now"); + expect(ago(44)).toBe("just now"); + }); + + test("never rounds down to '0m'", () => { + expect(ago(45)).toBe("1m ago"); + expect(ago(59)).toBe("1m ago"); + }); + + test("rounds down within each unit", () => { + expect(ago(60)).toBe("1m ago"); + expect(ago(119)).toBe("1m ago"); + expect(ago(59 * 60)).toBe("59m ago"); + }); + + test("steps up to hours and days", () => { + expect(ago(60 * 60)).toBe("1h ago"); + expect(ago(23 * 60 * 60)).toBe("23h ago"); + expect(ago(24 * 60 * 60)).toBe("1d ago"); + expect(ago(72 * 60 * 60)).toBe("3d ago"); + }); + + test("treats a future timestamp as 'just now' rather than going negative", () => { + expect(formatTimeAgo(now + 60_000, now)).toBe("just now"); + }); +}); diff --git a/apps/extension/src/notification/index.tsx b/apps/extension/src/notification/index.tsx index f9512c9..780b6e7 100644 --- a/apps/extension/src/notification/index.tsx +++ b/apps/extension/src/notification/index.tsx @@ -11,6 +11,7 @@ import type { PegasusMsgProtocolMap } from "@/background"; import { ConfirmProvider } from "@/common/Confirmation"; import { AppErrorBoundary } from "@/components/AppErrorBoundary"; import { ThemeProvider } from "@/contexts/ThemeProvider"; +import { processCtConfirmationRenderer } from "@/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation"; import { dappAddChainConfirmationRenderer } from "@/core/extension-background/dapp-authorization/DappAddChainConfirmation"; import { dappConnectConfirmationRenderer } from "@/core/extension-background/dapp-authorization/DappConnectConfirmation"; import { dappSwitchChainConfirmationRenderer } from "@/core/extension-background/dapp-authorization/DappSwitchChainConfirmation"; @@ -28,12 +29,14 @@ if (!rootElement) { throw new Error("Notification root element was not found"); } -// Confirmations shown in the notification window: the generic host + the dapp renderers (connect, -// add-chain, switch-chain). +// Confirmations shown in the notification window: the generic host + the dapp renderers +// (connect, add-chain, switch-chain) and the contract action, which is the one that shows +// values alongside where each of them came from. const confirmationRenderers = [ dappConnectConfirmationRenderer, dappAddChainConfirmationRenderer, dappSwitchChainConfirmationRenderer, + processCtConfirmationRenderer, ]; createRoot(rootElement).render( diff --git a/apps/extension/src/offscreen.ts b/apps/extension/src/offscreen.ts index 3a1778f..df9a870 100644 --- a/apps/extension/src/offscreen.ts +++ b/apps/extension/src/offscreen.ts @@ -47,6 +47,12 @@ browser.runtime.onMessage.addListener((message) => { return { ok: true, op: "broadcast", txid }; } + if (message.op === "broadcastTransaction") { + const { txid } = await getScanClient().broadcastTransaction(message.input); + + return { ok: true, op: "broadcastTransaction", txid }; + } + const result = await getScanClient().scanAndRead(message.input); return { ...result, ok: true, op: "scanAndRead" }; diff --git a/apps/extension/src/routes/App/pages/Home/pages/Receive/components/ReceiveView.tsx b/apps/extension/src/routes/App/pages/Home/pages/Receive/components/ReceiveView.tsx index a35e9f0..23ded65 100644 --- a/apps/extension/src/routes/App/pages/Home/pages/Receive/components/ReceiveView.tsx +++ b/apps/extension/src/routes/App/pages/Home/pages/Receive/components/ReceiveView.tsx @@ -1,61 +1,199 @@ -import { ArrowLeft01Icon, CheckmarkCircle02Icon, Copy01Icon } from "@hugeicons/core-free-icons"; +import { + ArrowLeft01Icon, + CheckmarkCircle02Icon, + Copy01Icon, + InformationCircleIcon, +} from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { Link } from "@tanstack/react-router"; import QRCode from "react-qr-code"; +import type { LiquidContractIdentity } from "@/core/chains/liquid/application/contractIdentity"; import { cn } from "@/theme/utils.ts"; import { UiButtonVariants } from "@/ui/UiButton/base"; import { UiCopyButton } from "@/ui/UiCopyButton"; +import { UiScrollArea } from "@/ui/UiScrollArea"; +import { UiSpinner } from "@/ui/UiSpinner"; +import { UiTabs, UiTabsContent, UiTabsList, UiTabsTrigger } from "@/ui/UiTabs/base"; +import { UiTooltip, UiTooltipContent, UiTooltipProvider, UiTooltipTrigger } from "@/ui/UiTooltip"; + +const CONFIDENTIAL_TAB = "confidential"; +const UNCONFIDENTIAL_TAB = "unconfidential"; + +/** A label and the sentence that says what the value under it is for. */ +function LabelWithHint({ hint, label }: { hint: string; label: string }) { + return ( +
+ + {label} + + + + + + {hint} + +
+ ); +} + +/** One address as a QR, its own text, and a way to take it out. */ +function AddressPanel({ address, hint, label }: { address: string; hint: string; label: string }) { + return ( +
+ + +
+ +
+ +

{address}

+ + + {(copied) => ( + <> + + {copied ? "Copied" : "Copy address"} + + )} + +
+ ); +} + +/** A value that is not an address: shown as text, with the same label and hint treatment. */ +function ValueRow({ hint, label, value }: { hint: string; label: string; value: string }) { + return ( +
+ +

{value}

+ + {(copied) => ( + <> + + {copied ? "Copied" : "Copy key"} + + )} + +
+ ); +} /** - * Presentational Receive screen: the account's receive address as a QR (always dark - * on white for scannability) plus a copyable string, for the selected account/chain. + * Presentational Receive screen. + * + * Two addresses rather than one, because this wallet has two and they are not + * interchangeable. The confidential one is blinded and moves along the descriptor; the + * unconfidential one is unblinded and fixed at the first external index, and is the only + * one a contract action can be funded from. Money paid to the first cannot pay for one, + * which is a thing to learn before a faucet payment rather than after. + * + * They are named for what they are rather than for what they are used for: the difference + * that decides which one to pay is blinding and derivation, and a reader who knows that + * needs no product word for it. + * + * The unconfidential address is read only once its tab is opened: answering loads the + * contract module, which is several megabytes, and most visits here only want an address. + * + * The page owns its own scroll, per the app shell's contract — the shell bounds the region + * and pins the footer beneath it, so anything taller than the popup has to scroll here. */ export function ReceiveView({ address, accountName, chainName, + contractIdentity, + contractError, + onContractOpened, }: { address: string; accountName: string; chainName: string; + contractIdentity?: LiquidContractIdentity; + contractError?: string; + onContractOpened?: () => void; }) { return ( -
-
- - - -

Receive

-
+ +
+
+ + + +

Receive

+
-
-

- {accountName} · {chainName} -

+ +
+

+ {accountName} · {chainName} +

-
- -
+ { + if (value === UNCONFIDENTIAL_TAB) { + onContractOpened?.(); + } + }} + > + + Confidential + Unconfidential + -

{address}

+ + + - - {(copied) => ( - <> - - {copied ? "Copied" : "Copy address"} - - )} - + + {contractError === undefined ? null : ( +

{contractError}

+ )} + + {contractError === undefined && contractIdentity === undefined ? ( +
+ +
+ ) : null} + + {contractIdentity === undefined ? null : ( +
+ + +
+ )} +
+
+
+
-
+
); } diff --git a/apps/extension/src/routes/App/pages/Home/pages/Receive/index.stories.tsx b/apps/extension/src/routes/App/pages/Home/pages/Receive/index.stories.tsx index 893744d..5ffe1bf 100644 --- a/apps/extension/src/routes/App/pages/Home/pages/Receive/index.stories.tsx +++ b/apps/extension/src/routes/App/pages/Home/pages/Receive/index.stories.tsx @@ -19,3 +19,22 @@ export const Default: Story = { chainName: "Liquid", }, }; + +/** The contract tab once the identity has been read: an address that never changes, and a key. */ +export const ContractIdentity: Story = { + args: { + ...Default.args, + contractIdentity: { + address: "tex1qxn3ufc3q78awd8nqqkmyk3sfxwmy4wgcnnrmqz", + schnorrPublicKey: "8f1a3c5e7b9d0f2a4c6e8b0d2f4a6c8e0b2d4f6a8c0e2b4d6f8a0c2e4b6d8f0a", + }, + }, +}; + +/** The contract tab when the background could not answer. */ +export const ContractIdentityFailed: Story = { + args: { + ...Default.args, + contractError: "Could not read the contract identity. Try again.", + }, +}; diff --git a/apps/extension/src/routes/App/pages/Home/pages/Receive/index.tsx b/apps/extension/src/routes/App/pages/Home/pages/Receive/index.tsx index 49a4900..a497702 100644 --- a/apps/extension/src/routes/App/pages/Home/pages/Receive/index.tsx +++ b/apps/extension/src/routes/App/pages/Home/pages/Receive/index.tsx @@ -1,16 +1,25 @@ +import { useState } from "react"; + import { UiSpinner } from "@/ui/UiSpinner"; import { useHome } from "../../HomeContext"; import { ReceiveView } from "./components/ReceiveView"; +import { useContractIdentity } from "./useContractIdentity"; import { useReceiveAddress } from "./useReceiveAddress"; /** - * Receive tab: derives the account's receive address for the selected chain (LWK, on - * demand) and shows it as a QR + copyable string. Reached from the Receive action. + * Receive tab: derives the account's confidential address for the selected chain (LWK, on + * demand) and shows it as a QR + copyable string, beside the unconfidential address and the + * key contract actions are signed with. Reached from the Receive action. */ export function ReceivePage() { const { accountGroup, chain } = useHome(); const query = useReceiveAddress({ accountGroupId: accountGroup.id, chainId: chain.id }); + const [contractOpened, setContractOpened] = useState(false); + const identity = useContractIdentity({ + accountGroupId: accountGroup.id, + enabled: contractOpened, + }); if (query.isPending) { return ( @@ -36,6 +45,14 @@ export function ReceivePage() { address={query.data.address} accountName={accountGroup.name} chainName={chain.name} + contractIdentity={identity.data} + // What a person is told is chosen here rather than carried up from wherever it broke: + // the thrown message names a module, a network kind or a derivation path, and there is + // exactly one thing they can do about any failure of this read. + contractError={ + identity.isError ? "Could not read the contract identity. Try again." : undefined + } + onContractOpened={() => setContractOpened(true)} /> ); } diff --git a/apps/extension/src/routes/App/pages/Home/pages/Receive/useContractIdentity.ts b/apps/extension/src/routes/App/pages/Home/pages/Receive/useContractIdentity.ts new file mode 100644 index 0000000..2aae263 --- /dev/null +++ b/apps/extension/src/routes/App/pages/Home/pages/Receive/useContractIdentity.ts @@ -0,0 +1,20 @@ +import { useQuery } from "@tanstack/react-query"; + +import type { AccountGroupId } from "@/core/accounts/application/account-registry/model/identifiers"; +import { readLiquidContractIdentity } from "@/core/chains/liquid/contractIdentityClient"; + +/** + * The address and key contract actions are signed with, for one account. + * + * Read on demand rather than with the page: the background loads the contract module to + * answer, which is several megabytes, and most visits to Receive only want an address. + */ +export function useContractIdentity(keys: { accountGroupId: AccountGroupId; enabled: boolean }) { + return useQuery({ + enabled: keys.enabled, + queryFn: () => readLiquidContractIdentity(keys.accountGroupId), + queryKey: ["contractIdentity", keys.accountGroupId], + // The identity is a function of the account's key and never changes under it. + staleTime: Infinity, + }); +} 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/apps/web/package.json b/apps/web/package.json index 57be9d2..34ee935 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,13 +6,15 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "typecheck": "tsc --noEmit", + "typecheck": "tsc -b --force", "preview": "vite preview", "cleanup": "rm -rf node_modules out dist" }, "dependencies": { "@fontsource-variable/jetbrains-mono": "^5.2.8", "@humid/appkit-injected-adapter": "workspace:*", + "@humid/smplx-compiler": "workspace:*", + "@humid/tx-manifest": "workspace:*", "@reown/appkit": "^1.8.19", "@reown/appkit-common": "^1.8.19", "@reown/appkit-controllers": "^1.8.21", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7719dce..68b3764 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,31 +2,53 @@ import { ChevronLeftIcon } from "lucide-react"; import { useState } from "react"; import Dashboard from "@/app/dashboard"; +import FormatSupport from "@/app/format"; import Home from "@/app/home"; +import ManifestInspector from "@/app/manifest"; import { Button } from "@/components/ui/button"; import { Toaster } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; -type View = "home" | "developer"; +type View = "developer" | "format" | "home" | "manifest"; export function App() { const [view, setView] = useState("home"); return ( - {view === "home" ? ( - setView("developer")} /> - ) : ( -
-
- + {(() => { + if (view === "home") { + return ( + setView("developer")} + onOpenFormatSupport={() => setView("format")} + onOpenManifestInspector={() => setView("manifest")} + /> + ); + } + + return ( +
+
+ +
+ {(() => { + if (view === "developer") { + return ; + } + + if (view === "format") { + return ; + } + + return ; + })()}
- -
- )} + ); + })()} ); diff --git a/apps/web/src/app/dashboard/components/method-cards/ProcessCtCard.tsx b/apps/web/src/app/dashboard/components/method-cards/ProcessCtCard.tsx index 420d3e2..2a6f9c8 100644 --- a/apps/web/src/app/dashboard/components/method-cards/ProcessCtCard.tsx +++ b/apps/web/src/app/dashboard/components/method-cards/ProcessCtCard.tsx @@ -1,39 +1,130 @@ import type { LiquidProcessConfidentialTransactionParams } from "@humid/appkit-injected-adapter"; +import p2pkManifest from "@humid/tx-manifest/fixtures/p2pk.manifest.json"; import { useState } from "react"; import { useHumidContext } from "@/contexts/Web3Provider/HumidProvider"; +import { P2PK_SOURCE } from "../../contracts/p2pk"; import { parseJsonInput } from "../../lib/format"; import { useMethodState } from "../../lib/method-state"; import { useRpcCall } from "../../lib/useRpcCall"; import { CallButton } from "../CallButton"; -import { TextAreaField } from "../fields"; +import { CheckboxField, SelectField, TextAreaField, TextField } from "../fields"; import { ResultPanel } from "../ResultPanel"; import { RpcCard } from "../RpcCard"; +/** + * The published p2pk protocol, which is the thinnest real one: no deployment values, and a + * single kind of holding. `Pay` locks funds into it; `Receive` spends one back out, which is + * the half that exercises the address check against the network. + */ +const ACTIONS = ["Pay", "Receive"]; + +/** + * An x-only public key, which is what the p2pk contract's PUB_KEY parameter is. + * + * Checked here rather than left to the wallet because the mistake this catches is the + * obvious one — pasting an address, which is the other thing the wallet shows you — and + * a request that leaves this page is answered by the contract compiler complaining about + * a character position. + */ +const X_ONLY_KEY = /^(?:0x)?[0-9a-fA-F]{64}$/; + export function ProcessCtCard() { const { wallet } = useHumidContext(); const state = useMethodState("processConfidentialTransaction"); - const [payload, setPayload] = useState("{}"); const { call, pending, result } = useRpcCall(); + const [action, setAction] = useState("Pay"); + const [pubkey, setPubkey] = useState(""); + const [amount, setAmount] = useState("1000"); + const [broadcast, setBroadcast] = useState(false); + const [stateFile, setStateFile] = useState(""); + + const spending = action === "Receive"; + const keyProblem = X_ONLY_KEY.test(pubkey.trim()) + ? undefined + : pubkey.trim() === "" + ? "Needed: 32 bytes as 64 hexadecimal characters." + : pubkey.trim().startsWith("tlq1") || + pubkey.trim().startsWith("tex1") || + pubkey.trim().startsWith("lq1") || + pubkey.trim().startsWith("ex1") + ? "That is an address, not a key. The contract identity screen shows both — this field wants the second one." + : `Not an x-only public key: ${pubkey.trim().length} characters, and 64 hexadecimal ones are needed.`; + + // The six parts of the request, assembled here rather than typed by hand. The wallet + // rebuilds the contract from `contractSources` and checks it against the chain, so what + // this card supplies is exactly what a real protocol's site would supply. + const params = { + action, + broadcast, + contractSources: { "./p2pk.simf": P2PK_SOURCE }, + manifest: p2pkManifest, + params: spending + ? { pubkey: pubkey.trim() } + : { amount_sat: Number(amount) || 0, pubkey: pubkey.trim() }, + ...(spending ? { state: parseJsonInput(stateFile) ?? {} } : {}), + }; + return ( - + + + {/* One key signs every contract action, and it is not one the wallet's normal + screens show. To spend what Pay locks, this must be the wallet's own contract + key — HUMID → Settings → the account → Contract signing identity. */} + + + {keyProblem === undefined ? null : ( +

{keyProblem}

+ )} + + {spending ? ( + + ) : ( + + )} + + + call(() => wallet.processConfidentialTransaction( - (parseJsonInput(payload) ?? {}) as LiquidProcessConfidentialTransactionParams, + params as unknown as LiquidProcessConfidentialTransactionParams, ), ) } /> +
); diff --git a/apps/web/src/app/dashboard/contracts/p2pk.ts b/apps/web/src/app/dashboard/contracts/p2pk.ts new file mode 100644 index 0000000..295ea74 --- /dev/null +++ b/apps/web/src/app/dashboard/contracts/p2pk.ts @@ -0,0 +1,13 @@ +/** + * The pay-to-public-key contract, from `simplicityhl-0.6.0/examples/p2pk.simf`. + * + * Two identifiers differ from upstream: the published manifest names its compile parameter + * `PUB_KEY` and its witness `SIGNATURE`, where upstream says `ALICE_PUBLIC_KEY` and + * `ALICE_SIGNATURE`. Nothing else about it is ours. + * + * It lives beside the page rather than beside the manifest because contract sources are not + * published with a manifest — in production they arrive with the request, which is exactly + * what this card demonstrates. + */ +export const P2PK_SOURCE = + "fn main() { jet::bip_0340_verify((param::PUB_KEY, jet::sig_all_hash()), witness::SIGNATURE) }"; diff --git a/apps/web/src/app/dashboard/lib/constants.ts b/apps/web/src/app/dashboard/lib/constants.ts index 491cd1a..9f7afdf 100644 --- a/apps/web/src/app/dashboard/lib/constants.ts +++ b/apps/web/src/app/dashboard/lib/constants.ts @@ -1,7 +1,12 @@ import { LIQUID_MAINNET_CHAIN_ID, LIQUID_TESTNET_CHAIN_ID } from "@humid/appkit-injected-adapter"; -export const LIQUID_MAINNET_LBTC_ASSET_ID = `${LIQUID_MAINNET_CHAIN_ID}/elip144:6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d`; -export const LIQUID_TESTNET_LBTC_ASSET_ID = `${LIQUID_TESTNET_CHAIN_ID}/elip144:144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49`; +import { LIQUID_MAINNET, LIQUID_TESTNET } from "@/lib/liquid-networks"; + +// Chain-qualified for the wallet RPC, from the same asset the manifest inspector compares +// against. Written once: two spellings of one fact drift, and the wrong one refuses a document +// that should have built. +export const LIQUID_MAINNET_LBTC_ASSET_ID = `${LIQUID_MAINNET_CHAIN_ID}/elip144:${LIQUID_MAINNET.policyAsset}`; +export const LIQUID_TESTNET_LBTC_ASSET_ID = `${LIQUID_TESTNET_CHAIN_ID}/elip144:${LIQUID_TESTNET.policyAsset}`; export const DEFAULT_IDENTITY = "ssh://humid@localhost"; export const DEFAULT_IDENTITY_CHALLENGE = diff --git a/apps/web/src/app/format/index.test.tsx b/apps/web/src/app/format/index.test.tsx new file mode 100644 index 0000000..9a36ee0 --- /dev/null +++ b/apps/web/src/app/format/index.test.tsx @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; + +import { describeRegistry } from "@humid/tx-manifest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import FormatSupport from "./index"; +import { WHERE_IT_SITS } from "./positions"; + +// AC-07 and AC-08. The page's whole content is the runtime's own construct table, so what is +// checked here is that all of it arrives, that what the wallet cannot do leads, and that every +// gap carries its reason — the part no document can ever show, because no published protocol +// uses any of the seven. + +function render(): string { + return renderToStaticMarkup(); +} + +describe("what this wallet does not implement", () => { + test("leads with it, before anything the wallet does read", () => { + const html = render(); + + expect(html.indexOf("Not implemented")).toBeLessThan( + html.indexOf("Read, and it changes what gets signed"), + ); + }); + + test("names every construct the runtime does not act on, with its reason", () => { + const html = render(); + + for (const entry of describeRegistry().filter((candidate) => candidate.reason !== undefined)) { + expect(html).toContain(entry.key); + expect(html).toContain(escaped(entry.reason ?? "")); + } + }); + + // The count is what an engineer came for and the one thing that must not be written down by + // hand: a sentence saying "seven" survives an eighth being added. + test("counts what is missing from the table rather than from a sentence", () => { + const unimplemented = describeRegistry().filter((entry) => entry.state === "unimplemented"); + + expect(render()).toContain(`>${unimplemented.length}`); + }); +}); + +describe("the whole table, not a sample of it", () => { + test("renders every construct the runtime registers", () => { + const html = render(); + + for (const entry of describeRegistry()) { + expect(html).toContain(entry.key); + } + }); + + test("says how much of the format this is, counted rather than stated", () => { + const entries = describeRegistry(); + const positioned = entries.filter((entry) => entry.site !== undefined); + + expect(render()).toContain(`${positioned.length} fields at`); + }); + + test("says where each one sits in words a reader can use", () => { + const html = render(); + + for (const where of Object.values(WHERE_IT_SITS)) { + expect(html).toContain(where); + } + }); +}); + +describe("the page stands alone", () => { + // AC-07's other half and AC-10. It holds no wallet context and reads no document: every other + // surface in this app reads a wallet context, and reading a missing one would throw. + test("renders with no wallet, no provider, no network and nothing pasted", () => { + const html = render(); + + expect(html).toContain("What this wallet reads of the format"); + expect(html).not.toContain("", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} diff --git a/apps/web/src/app/format/index.tsx b/apps/web/src/app/format/index.tsx new file mode 100644 index 0000000..744d297 --- /dev/null +++ b/apps/web/src/app/format/index.tsx @@ -0,0 +1,125 @@ +import { type ConstructRegistryEntry, describeRegistry } from "@humid/tx-manifest"; + +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +import { WHERE_IT_SITS } from "./positions"; + +/** + * What this wallet reads of the transaction-manifest format, and what it does not. + * + * The manifest page answers a question about one document. This one answers a question no + * document can: a construct nobody has published is invisible in every document there is, and + * all seven that the format defines and this wallet does not implement are in that position. + * Every published protocol therefore inspects clean while the seven stand, which is why this + * is a page rather than a section beside a box someone pastes into. + * + * It reads nothing from that page and nothing from anywhere else. Its whole content is the + * runtime's own construct table, so it cannot describe a wallet that differs from the one + * that runs. + */ +export default function FormatSupport() { + const entries = describeRegistry(); + + return ( +
+ + + What this wallet reads of the format + + Every field the transaction-manifest format defines, against what this wallet does with + it. Nothing here depends on a document — it is the same table the wallet decides by, + printed. + + + +

{summaryOf(entries)}

+
+
+ +
entry.state === "unimplemented")} + /> +
entry.state === "never-read")} + /> +
entry.state === "shown")} + /> +
entry.state === "acted-on")} + /> +
+ ); +} + +function Section({ + description, + entries, + title, +}: { + description: string; + entries: ConstructRegistryEntry[]; + title: string; +}) { + if (entries.length === 0) { + return null; + } + + return ( + + + + {title} + {entries.length} + + {description} + + +
+ + + {entries.map((entry) => ( + + + + + + ))} + +
{entry.key} + {WHERE_IT_SITS[entry.site ?? "everywhere"]} + {entry.reason}
+
+
+
+ ); +} + +/** + * How much of the format this is, said before any of it is read. + * + * Counted from the table rather than written down, so the sentence cannot fall behind the + * thing it describes — which is the same reason this page exists at all. + */ +function summaryOf(entries: ConstructRegistryEntry[]): string { + const positioned = entries.filter((entry) => entry.site !== undefined); + const kinds = new Set(positioned.map((entry) => entry.site)).size; + const everywhere = entries.length - positioned.length; + + return ( + `${positioned.length} fields at ${kinds} kinds of position, plus ${everywhere} that any ` + + "JSON document may carry anywhere. Each one this wallet does not act on says why." + ); +} diff --git a/apps/web/src/app/format/positions.ts b/apps/web/src/app/format/positions.ts new file mode 100644 index 0000000..5fb0f48 --- /dev/null +++ b/apps/web/src/app/format/positions.ts @@ -0,0 +1,26 @@ +import type { ConstructSiteKind } from "@humid/tx-manifest"; + +/** + * Where a field sits, in the words a person would use for it. + * + * A translation and not a claim: the runtime keys its table by these names and this says the + * same thing in English, so nothing here can be true while the runtime says otherwise. It is + * typed against the runtime's own set, so a kind of position added there and forgotten here + * fails to compile rather than rendering a key nobody can read. + * + * `everywhere` is not one of the runtime's kinds. It stands for the two keys any JSON document + * may carry at any depth, which the runtime answers once rather than listing at every position. + */ +export const WHERE_IT_SITS: Record = { + action: "on an action", + everywhere: "anywhere", + input: "on an input", + manifest: "on the document", + output: "on an output", + param: "on a parameter", + script: "on a contract", + ui: "in display metadata", + utxoType: "on a kind of holding", + validation: "on a rule", + witness: "on a witness", +}; diff --git a/apps/web/src/app/home/index.tsx b/apps/web/src/app/home/index.tsx index d9e65ff..dd115fb 100644 --- a/apps/web/src/app/home/index.tsx +++ b/apps/web/src/app/home/index.tsx @@ -8,7 +8,15 @@ import { HomeActions } from "./components/HomeActions"; * The product Home: an identity-first hero (network, "signed in as", balance) with a row of primary * actions. A thin consumer of {@link useHumidContext} — all wallet plumbing lives in the context. */ -export default function Home({ onOpenDeveloper }: { onOpenDeveloper: () => void }) { +export default function Home({ + onOpenDeveloper, + onOpenFormatSupport, + onOpenManifestInspector, +}: { + onOpenDeveloper: () => void; + onOpenFormatSupport: () => void; + onOpenManifestInspector: () => void; +}) { const { hasProvider, isConnected } = useHumidContext(); return ( @@ -21,7 +29,10 @@ export default function Home({ onOpenDeveloper }: { onOpenDeveloper: () => void {hasProvider && isConnected ? : null} -
+ {/* The inspector sits beside Developer rather than inside it: the cards there are all + ways of driving a wallet and disappear when none is installed, which is exactly + when reading a document by itself is most useful. */} +
+ +
); diff --git a/apps/web/src/app/manifest/components/ConstructTable.test.tsx b/apps/web/src/app/manifest/components/ConstructTable.test.tsx new file mode 100644 index 0000000..1297716 --- /dev/null +++ b/apps/web/src/app/manifest/components/ConstructTable.test.tsx @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; + +import type { ConstructReport, ConstructSiteKind, ConstructState } from "@humid/tx-manifest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ConstructTable } from "./ConstructTable"; + +// AC-04 and AC-05 at the surface. The five states and the positions come from the package and +// are tested there; what is checked here is that a reader is shown the state, the field, where +// it sits, how many places that is, and — the part a state name alone does not carry — what +// that state means for them. + +function report( + state: ConstructState, + key: string = state, + at = "manifest", + site: ConstructSiteKind = "manifest", +): ConstructReport { + return { at, key, site, state }; +} + +function render(constructs: ConstructReport[]): string { + return renderToStaticMarkup(); +} + +describe("what a reader is told about each field", () => { + test("shows the field, where it sits, and its state", () => { + const html = render([report("unimplemented", "args", "action Pay", "action")]); + + expect(html).toContain("args"); + expect(html).toContain("action Pay"); + expect(html).toContain("unimplemented"); + }); + + test("explains what each state means rather than only naming it", () => { + expect(render([report("acted-on")])).toContain("changes what gets signed"); + expect(render([report("shown")])).toContain("It decides nothing"); + expect(render([report("unimplemented")])).toContain("does not implement it"); + expect(render([report("unrecognised")])).toContain("No specification this wallet knows"); + expect(render([report("never-read")])).toContain("read by nothing"); + }); + + test("a document declaring nothing says so rather than drawing an empty table", () => { + const html = render([]); + + expect(html).toContain("declares no fields"); + expect(html).not.toContain(" { + expect(render([report("acted-on")])).not.toContain("never-read"); + }); +}); + +describe("a key that recurs draws one row", () => { + test("counts the positions instead of repeating the field", () => { + const html = render([ + report("unimplemented", "args", "action Pay", "action"), + report("unimplemented", "args", "action Refund", "action"), + report("unimplemented", "args", "action Close", "action"), + ]); + + expect(html.match(/args/g)).toHaveLength(1); + expect(html).toContain("3 positions"); + }); + + test("still names every position, so nothing is only counted", () => { + const html = render([ + report("unimplemented", "args", "action Pay", "action"), + report("unimplemented", "args", "action Refund", "action"), + ]); + + expect(html).toContain("action Pay"); + expect(html).toContain("action Refund"); + }); + + test("names the one position outright when a field sits at exactly one", () => { + const html = render([report("unimplemented", "args", "action Pay", "action")]); + + expect(html).toContain("action Pay"); + expect(html).not.toContain("1 positions"); + }); +}); + +describe("what is working opens closed", () => { + // AC-05. Not hidden and not dropped: the count is visible without clicking and the rows are + // one click away. What is removed is meeting six hundred rows that say a field works before + // reaching the nine that say anything else. + test("puts the states that mean nothing is wrong behind a disclosure", () => { + const html = render([report("acted-on", "chain"), report("shown", "description")]); + + expect(html.match(/
{ + const html = render([ + report("unrecognised", "wat"), + report("unimplemented", "args", "action Pay", "action"), + report("never-read", "source"), + ]); + + expect(html).not.toContain(" { + const html = render([ + report("acted-on", "chain"), + report("acted-on", "amount_sat", "action Pay / output a", "output"), + report("acted-on", "amount_sat", "action Pay / output b", "output"), + ]); + + expect(html).toContain("2 fields, at 3 positions"); + }); +}); diff --git a/apps/web/src/app/manifest/components/ConstructTable.tsx b/apps/web/src/app/manifest/components/ConstructTable.tsx new file mode 100644 index 0000000..e38c133 --- /dev/null +++ b/apps/web/src/app/manifest/components/ConstructTable.tsx @@ -0,0 +1,134 @@ +import type { ConstructReport, ConstructState } from "@humid/tx-manifest"; + +import { Badge } from "@/components/ui/badge"; + +import { type ConstructGroup, groupByState } from "./groupByState"; + +/** + * What each state means, in the words a protocol author would use. + * + * The state names are the runtime's; these sentences are what a person reading the table + * actually needs, and they say what happens rather than what the field is called. + */ +const MEANING: Record = { + "acted-on": { badge: "default", sentence: "Read, and it changes what gets signed." }, + "never-read": { + badge: "ghost", + sentence: "Known to the format and read by nothing, here or in the reference implementation.", + }, + shown: { badge: "secondary", sentence: "Read, and shown to a person. It decides nothing." }, + unimplemented: { + badge: "destructive", + sentence: "The format defines it and this wallet does not implement it.", + }, + unrecognised: { + badge: "destructive", + sentence: "No specification this wallet knows describes this field here.", + }, +}; + +type BadgeVariant = "default" | "destructive" | "ghost" | "secondary"; + +/** + * Every construct this document declares, once each, against what the runtime does with it. + * + * One row per construct rather than per position, because a key genuinely recurs — 94 places + * in the deployed lending protocol — and a row per place is 620 rows saying 41 things. The + * places are still all here, under the row that counts them. + * + * The two states that mean nothing is wrong open collapsed. That is the whole of what was + * unreadable: not that the information was present, but that 611 rows of "this field works" + * came before the nine that said anything else. + */ +export function ConstructTable({ constructs }: { constructs: ConstructReport[] }) { + if (constructs.length === 0) { + return

This document declares no fields.

; + } + + return ( +
+ {groupByState(constructs).map((group) => ( + + ))} +
+ ); +} + +function Group({ group }: { group: ConstructGroup }) { + const heading = ( +
+ {group.state} + {MEANING[group.state].sentence} + {countOf(group)} +
+ ); + + if (!group.nothingWrong) { + return ( +
+ {heading} + +
+ ); + } + + return ( +
+ {heading} +
+ +
+
+ ); +} + +function Rows({ group }: { group: ConstructGroup }) { + return ( +
+ + + {group.rows.map((row) => ( + + + + + ))} + +
{row.key} + {whereOf(row)} + {row.at.length > 1 && {row.at.join(" · ")}} +
+
+ ); +} + +/** + * Where one construct sits, said as a place when there is one and as a count when there are + * many. The places themselves follow underneath either way, so the count is a headline rather + * than a substitute. + */ +function whereOf(row: { at: string[] }): string { + if (row.at.length === 1) { + return row.at[0] ?? ""; + } + + return `${row.at.length} positions`; +} + +/** + * How much this group holds, said before it is opened. + * + * A collapsed group whose size is unknown is a page hiding something; a collapsed group that + * says how many constructs and how many positions it holds is a page that has already + * answered the only question the reader had about it. + */ +function countOf(group: ConstructGroup): string { + const positions = group.rows.reduce((total, row) => total + row.at.length, 0); + const constructs = `${group.rows.length} ${group.rows.length === 1 ? "field" : "fields"}`; + + if (positions === group.rows.length) { + return constructs; + } + + return `${constructs}, at ${positions} positions`; +} diff --git a/apps/web/src/app/manifest/components/ContractSourceList.tsx b/apps/web/src/app/manifest/components/ContractSourceList.tsx new file mode 100644 index 0000000..112eb5c --- /dev/null +++ b/apps/web/src/app/manifest/components/ContractSourceList.tsx @@ -0,0 +1,88 @@ +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; + +import type { SuppliedSource } from "../contractSources"; + +/** + * The contracts a document references, and which of them this page has been handed. + * + * A version this wallet does not ship can be asked for in two places, and one of them is + * inside the contract source. Nothing about a document says what its contracts contain, so + * this is the only way the second half of that check can run at all — and until it does, the + * page says so rather than reporting the check as done. + * + * The files never leave the page. They are read in the browser, the same way the document in + * the textarea is, which is what lets this ask for them at all. + */ +export function ContractSourceList({ + contracts, + onClear, + onSupply, + supplied, + unmatched, +}: { + contracts: readonly string[]; + onClear: () => void; + onSupply: (sources: SuppliedSource[]) => void; + supplied: Record; + unmatched: readonly string[]; +}) { + return ( +
+
+ + { + const chosen = [...(event.target.files ?? [])]; + + onSupply( + await Promise.all( + chosen.map(async (file) => ({ name: file.name, text: await file.text() })), + ), + ); + }} + /> +
+ + {contracts.length === 0 ? ( +

+ This document references no contract sources, so the compiler check has only the + document's own declaration to read and has read it. +

+ ) : ( +
    + {contracts.map((path) => ( +
  • + + {path in supplied ? "read" : "not read"} + + {path} +
  • + ))} +
+ )} + + {unmatched.length > 0 && ( +

+ This document references nothing by the name {unmatched.join(", ")}, so it was not given + to the reader. A source is checked under the path the document asks for it by, and nothing + else. +

+ )} + + {Object.keys(supplied).length > 0 && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/src/app/manifest/components/RewriteList.test.tsx b/apps/web/src/app/manifest/components/RewriteList.test.tsx new file mode 100644 index 0000000..dd13138 --- /dev/null +++ b/apps/web/src/app/manifest/components/RewriteList.test.tsx @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; + +import type { NormalisationNote } from "@humid/tx-manifest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { RewriteList } from "./RewriteList"; + +// AC-06's second half. Three things per rewrite — where, the name it now carries, the name it +// had — now sitting with the fields rather than in a region of their own. The statement that a +// document needed no rewriting moved to the verdict, so this renders nothing at all for a clean +// document: the page says it once, where the answer is. + +function render(rewrites: NormalisationNote[]): string { + return renderToStaticMarkup(); +} + +describe("what a reader is told about older spellings", () => { + test("shows the name found, the name it now carries, and where", () => { + const html = render([{ at: "action Pay", canonical: "is_constructor", found: "deploy" }]); + + expect(html).toContain("deploy"); + expect(html).toContain("is_constructor"); + expect(html).toContain("action Pay"); + }); + + // The verdict carries this now, in one sentence beside the answer it belongs to. A second + // statement here would be the page saying the same thing twice at different weights. + test("a clean document draws nothing here at all", () => { + expect(render([])).toBe(""); + }); + + test("says what a rewrite means: the document is from an earlier generation", () => { + const html = render([{ at: "manifest", canonical: "params", found: "compile_params" }]); + + expect(html).toContain("earlier generation"); + }); + + test("shows every rewrite, not only the first", () => { + const html = render([ + { at: "manifest", canonical: "manifest_version", found: "compose_version" }, + { at: "manifest", canonical: "params", found: "compile_params" }, + ]); + + expect(html).toContain("compose_version"); + expect(html).toContain("compile_params"); + }); +}); diff --git a/apps/web/src/app/manifest/components/RewriteList.tsx b/apps/web/src/app/manifest/components/RewriteList.tsx new file mode 100644 index 0000000..361a9da --- /dev/null +++ b/apps/web/src/app/manifest/components/RewriteList.tsx @@ -0,0 +1,43 @@ +import type { NormalisationNote } from "@humid/tx-manifest"; + +/** + * The renamings themselves, against the fields they renamed. + * + * This used to be a region of its own, and it was the least readable thing on the page: it + * reported, at the weight of a finding, that the reader had accepted an older spelling and + * carried on — which changed nothing about the answer. What is worth knowing from it is one + * sentence and lives in the verdict now. What is left is a lookup, for someone who has the + * document open and wants to know which of its keys the runtime knows by another name. + * + * Nothing is rendered when nothing was renamed. The verdict has already said so, and a second + * statement of it here would be the page repeating itself at the reader. + */ +export function RewriteList({ rewrites }: { rewrites: NormalisationNote[] }) { + if (rewrites.length === 0) { + return null; + } + + return ( +
+

Renamed on the way in

+

+ The wallet accepted these older spellings and read them under the current name. A document + needing this is from an earlier generation of the format — it still works, and nothing about + it says which generation it is. +

+
+ + + {rewrites.map((note) => ( + + + + + + ))} + +
{note.found}{note.canonical}{note.at}
+
+
+ ); +} diff --git a/apps/web/src/app/manifest/components/Verdict.test.tsx b/apps/web/src/app/manifest/components/Verdict.test.tsx new file mode 100644 index 0000000..4ceb2e4 --- /dev/null +++ b/apps/web/src/app/manifest/components/Verdict.test.tsx @@ -0,0 +1,230 @@ +import { describe, expect, test } from "bun:test"; + +import { renderToStaticMarkup } from "react-dom/server"; + +import { Verdict } from "./Verdict"; + +// AC-01, AC-02, AC-03 and AC-06's first half, at the only place they can be checked: the text +// a reader actually meets. Rendered to a string rather than to a DOM, because this repository +// has no DOM in its tests and react-dom is already here — the assertions below are about words +// on a screen, and a string carries those. + +function render(inspection: Parameters[0]["inspection"]): string { + return renderToStaticMarkup(); +} + +const NOTHING_ASKED: Pick< + Parameters[0]["inspection"], + "constructs" | "partial" | "rewrites" | "skipped" | "unreachable" +> = { + constructs: [], + partial: [], + rewrites: [], + skipped: [], + unreachable: ["covenant-mismatch", "shortfall", "no-fee-rate"], +}; + +describe("the answer this page came to give", () => { + test("says what the wallet would do before it says anything else", () => { + const html = render({ + ...NOTHING_ASKED, + refusal: { reason: 'This protocol is for "bitcoin".', reject: "foreign-chain" }, + }); + + expect(html.indexOf("would refuse to build an action")).toBeLessThan( + html.indexOf("Not decidable from a document at all"), + ); + }); + + test("leads with the reader's own sentence, which names where in the document", () => { + const html = render({ + ...NOTHING_ASKED, + refusal: { + reason: 'This protocol uses "args" at action Pay, which this wallet does not implement.', + reject: "unimplemented-construct", + }, + }); + + expect(html).toContain("action Pay"); + expect(html.indexOf("action Pay")).toBeLessThan(html.indexOf("unimplemented-construct")); + }); + + // The single most misreadable thing on the page. A document can be flawless in every way a + // document can be judged and still be unbuildable for want of money. + test("never lets no-refusal read as a promise that the wallet would build", () => { + const html = render({ ...NOTHING_ASKED, refusal: undefined }); + + expect(html).toContain("Nothing a document alone can decide refuses this one"); + expect(html).toContain("not a statement that the wallet would build"); + }); +}); + +describe("what was never asked, beside the answer", () => { + test("names the unreachable checks whether or not a refusal was found", () => { + for (const refusal of [undefined, { reason: "…", reject: "foreign-chain" as const }]) { + const html = render({ ...NOTHING_ASKED, refusal }); + + expect(html).toContain("covenant-mismatch"); + expect(html).toContain("shortfall"); + expect(html).toContain("no-fee-rate"); + expect(html).toContain("Not decidable from a document at all"); + } + }); + + test("says why the unreachable ones are unreachable, and how many", () => { + const html = render({ ...NOTHING_ASKED, refusal: undefined }); + + expect(html).toContain("3 of this wallet's refusals"); + expect(html).toContain("money"); + expect(html).toContain("chain read"); + }); + + // AC-02. Nothing that says a check was not made may hide behind a click: the absence of a + // refusal is only honest beside the list of what was never asked. + test("puts nothing unchecked inside a disclosure", () => { + const html = render({ + constructs: [], + partial: [{ reject: "foreign-compiler", unread: ["./p2pk.simf"] }], + refusal: undefined, + rewrites: [], + skipped: ["foreign-asset"], + unreachable: ["shortfall"], + }); + + expect(html).not.toContain(" { + const html = render({ + constructs: [], + partial: [], + refusal: undefined, + rewrites: [], + skipped: ["foreign-compiler"], + unreachable: ["shortfall"], + }); + + expect(html).toContain("Not checked, because this page has not been given what they need"); + expect(html).toContain("foreign-compiler"); + expect(html).toContain("Not decidable from a document at all"); + }); + + // Between skipped and done there is a third answer, and the page has to carry it or a check + // that read one of its two places is read as one that passed. + test("keeps a half-answered check apart from both a skipped one and a passed one", () => { + const html = render({ + ...NOTHING_ASKED, + partial: [{ reject: "foreign-compiler", unread: ["./p2pk.simf"] }], + refusal: undefined, + }); + + expect(html).toContain("Checked in one of the two places that decide it"); + expect(html).toContain("./p2pk.simf"); + expect(html).not.toContain("Not checked, because"); + }); + + test("says which sources went unread rather than that some did", () => { + const html = render({ + ...NOTHING_ASKED, + partial: [{ reject: "foreign-compiler", unread: ["./lending.simf", "./script_auth.simf"] }], + refusal: undefined, + }); + + expect(html).toContain("./lending.simf"); + expect(html).toContain("./script_auth.simf"); + }); + + test("says nothing about a half-answered check when every check was answered in full", () => { + expect(render({ ...NOTHING_ASKED, refusal: undefined })).not.toContain("Checked in one of"); + }); + + test("says nothing about skipped checks when none were skipped", () => { + expect(render({ ...NOTHING_ASKED, refusal: undefined })).not.toContain("Not checked, because"); + }); + + test("tells a reader who can still answer that they can", () => { + const html = render({ ...NOTHING_ASKED, refusal: undefined, skipped: ["foreign-asset"] }); + + expect(html).toContain("Choose one above and they run"); + }); +}); + +describe("the runtime's own names for its refusals", () => { + // AC-03. A person cannot act on a reject token; they can act on the sentence beside it. The + // token stays for whoever is chasing one into the code, and stops being what they meet first. + test("never puts a token where the heading goes", () => { + const html = render({ + ...NOTHING_ASKED, + refusal: { reason: "This protocol is for bitcoin.", reject: "foreign-chain" }, + }); + + const headings = [...html.matchAll(/]*>([^<]*)<\/h3>/g)].map((match) => match[1]); + + expect(headings.length).toBeGreaterThan(0); + + for (const heading of headings) { + for (const token of ["foreign-chain", ...NOTHING_ASKED.unreachable]) { + expect(heading).not.toContain(token); + } + } + + expect(html.indexOf("This protocol is for bitcoin.")).toBeLessThan( + html.indexOf("foreign-chain"), + ); + }); +}); + +describe("older spellings, said once", () => { + // AC-06's first half. A renaming that succeeded changed nothing about the answer, so what is + // worth saying is that the document belongs to an earlier generation — one sentence, here. + test("counts them and says they changed nothing about the answer", () => { + const html = render({ + ...NOTHING_ASKED, + refusal: undefined, + rewrites: [ + { at: "manifest", canonical: "manifest_version", found: "compose_version" }, + { at: "manifest", canonical: "params", found: "compile_params" }, + ], + }); + + expect(html).toContain("2 older spellings"); + expect(html).toContain("changed nothing about the answer"); + }); + + test("says so when a document needed none, rather than leaving it unsaid", () => { + const html = render({ ...NOTHING_ASKED, refusal: undefined }); + + expect(html).toContain("current spelling"); + }); +}); + +describe("more than one field would refuse", () => { + // Found by using this page on the five published protocols: each refused on one decorative + // field and read as hopeless, when the field table below said three fixable gaps. + test("says how many fields would refuse, not only which one the wallet names", () => { + const html = render({ + ...NOTHING_ASKED, + constructs: [ + { at: "manifest", key: "$schema", site: "manifest", state: "unrecognised" }, + { at: "manifest", key: "contract_templates", site: "manifest", state: "unrecognised" }, + { at: "manifest", key: "simplicity_hl", site: "manifest", state: "unrecognised" }, + { at: "manifest", key: "description", site: "manifest", state: "shown" }, + ], + refusal: { reason: "…", reject: "unrecognised-construct" }, + }); + + expect(html).toContain("3 fields in this document would refuse"); + expect(html).toContain("The other 2"); + }); + + test("does not count when the wallet's one refusal is the whole of it", () => { + const html = render({ + ...NOTHING_ASKED, + constructs: [{ at: "manifest", key: "$schema", site: "manifest", state: "unrecognised" }], + refusal: { reason: "…", reject: "unrecognised-construct" }, + }); + + expect(html).not.toContain("would refuse, and the wallet names"); + }); +}); diff --git a/apps/web/src/app/manifest/components/Verdict.tsx b/apps/web/src/app/manifest/components/Verdict.tsx new file mode 100644 index 0000000..51e540f --- /dev/null +++ b/apps/web/src/app/manifest/components/Verdict.tsx @@ -0,0 +1,194 @@ +import type { ManifestInspection, RejectToken } from "@humid/tx-manifest"; + +/** + * What this wallet would do with the document, and — always beside it — what was never asked. + * + * The answer leads. Everything the reader computed is available further down the page, but a + * person holding a document is deciding one thing, and a page that opens with an inventory + * makes them assemble the answer themselves out of parts that all look equally important. + * + * The absence of a refusal is the most misreadable thing here. A document can be flawless in + * every way a document can be judged and still be unbuildable for want of money, a fee rate, + * or the covenant actually being where the state file says. So the unreached checks are not a + * footnote and never collapse: they are rendered in this same region, whether or not a refusal + * was found, and a tab or a disclosure would put back exactly the misreading they prevent. + * + * The runtime's own names for its refusals stay reachable and stop being headlines. A person + * cannot act on `unbuildable-utxo-type`; they can act on the sentence beside it, which names + * the position in the document. Eleven of those names set as badges was the page shouting its + * vocabulary at someone who came to ask a question. + * + * The second most misreadable thing was found by using this page on real documents. The + * runtime returns one refusal and does so deliberately: a person deciding whether to trust a + * site is not helped by a list of eleven field names. But a developer diagnosing coverage is + * misled by it — five published protocols each refused on one decorative field, and each read + * as hopeless when the truth was three fixable gaps. Saying how many fields are in that class + * is not disagreeing with the runtime's choice; it is this page declining to let one stand in + * for all of them. + */ +export function Verdict({ + inspection, +}: { + inspection: Pick< + ManifestInspection, + "constructs" | "partial" | "refusal" | "rewrites" | "skipped" | "unreachable" + >; +}) { + const wouldRefuse = inspection.constructs.filter( + (report) => report.state === "unimplemented" || report.state === "unrecognised", + ); + + return ( +
+ {(() => { + if (!inspection.refusal) { + return ( +
+

+ Nothing a document alone can decide refuses this one. +

+

+ This is not a statement that the wallet would build an action from it. Read it with + what was not checked, below. +

+
+ ); + } + + return ( +
+

+ This wallet would refuse to build an action from this document. +

+

{inspection.refusal.reason}

+ {wouldRefuse.length > 1 && ( +

+ {wouldRefuse.length} fields in this document would refuse, and the wallet names the + first. The other {wouldRefuse.length - 1} are in the field table below, under + unrecognised and unimplemented — fixing this one uncovers them rather than + finishing. +

+ )} + +
+ ); + })()} + +

+ {spellingSentence(inspection.rewrites.length)} +

+ + {inspection.skipped.length > 0 && ( + + )} + + {inspection.partial.length > 0 && ( +
+

Checked in one of the two places that decide it

+

+ A compiler version is declared twice: by the document, and by a directive inside each + contract source. The document's own declaration was checked. These sources were not + read, so what they ask for is unknown — which is not the same as agreeing. Open them + above and the check completes. +

+ {inspection.partial.map((check) => ( +

+ {check.reject} · {check.unread.join(" · ")} +

+ ))} +
+ )} + + +
+ ); +} + +/** + * What the older spellings amount to, said once and in the verdict's own region. + * + * A renaming that succeeded changed nothing about the answer above it, which is precisely why + * a panel of its own was unreadable: it reported, at the weight of a finding, that nothing had + * happened. What is worth knowing is that the document belongs to an earlier generation of the + * format, and that is one sentence. A document needing none says so, because an absent + * sentence and a document nobody checked look the same. + */ +function spellingSentence(count: number): string { + if (count === 0) { + return "This document is written in the format's current spelling, so nothing was renamed on the way in."; + } + + return ( + `${count} older spellings were accepted and renamed on the way in. They changed nothing about ` + + "the answer above; the renamings themselves are listed with the fields below." + ); +} + +/** + * Why each unrun check was not run, in the reader's own terms. + * + * One sentence per missing input rather than per check, because two of them are missing the + * same thing and a person reading this is deciding what to do about it. Where the answer is + * theirs to give, the sentence says so — an explanation that only states what is absent + * leaves the page looking broken rather than waiting. + */ +function whyUnasked(skipped: readonly RejectToken[]): string[] { + const explanations: string[] = []; + + if (skipped.includes("foreign-compiler")) { + explanations.push( + "The compiler check needs the single SimplicityHL version a wallet ships, and the reader was given none.", + ); + } + + if (skipped.includes("foreign-asset") || skipped.includes("unbuildable-utxo-type")) { + explanations.push( + "The asset checks need the asset the network charges in, and no network is chosen. Choose one above and they run.", + ); + } + + return explanations; +} + +function Unasked({ + explanations, + heading, + tokens, +}: { + explanations: readonly string[]; + heading: string; + tokens: readonly string[]; +}) { + return ( +
+

{heading}

+ {explanations.map((explanation) => ( +

+ {explanation} +

+ ))} + +
+ ); +} + +/** + * The runtime's own names for the checks just described. + * + * Present because a developer chasing one of these into the code needs the exact string, and + * subordinate because nobody decides anything from it. Never a heading, never a badge, and + * never collapsed — the sentence above is what is being said, and this is the address of it. + */ +function Names({ tokens }: { tokens: readonly string[] }) { + return

{tokens.join(" · ")}

; +} diff --git a/apps/web/src/app/manifest/components/groupByState.test.ts b/apps/web/src/app/manifest/components/groupByState.test.ts new file mode 100644 index 0000000..865e0e0 --- /dev/null +++ b/apps/web/src/app/manifest/components/groupByState.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from "bun:test"; + +import { + type ConstructReport, + type ConstructSiteKind, + type ConstructState, + inspectManifestDocument, +} from "@humid/tx-manifest"; +import dexManifest from "@humid/tx-manifest/fixtures/current/dex.manifest.json"; +import lendingV3Manifest from "@humid/tx-manifest/fixtures/current/lending_v3.manifest.json"; +import p2pkManifest from "@humid/tx-manifest/fixtures/p2pk.manifest.json"; + +import { groupByState } from "./groupByState"; + +// AC-04 and AC-05. What each field is comes from the package and is tested there; the order a +// person meets them in and how many rows that is are this surface's own decisions, and both +// are invisible when wrong — a table still renders, with the nine fields worth reading buried +// under six hundred that are working. + +function report( + state: ConstructState, + key: string = state, + at = "manifest", + site: ConstructSiteKind = "manifest", +): ConstructReport { + return { at, key, site, state }; +} + +function rowsFor(document: unknown): number { + const inspection = inspectManifestDocument(document); + + if (!inspection.ok) { + throw new Error("expected a readable document"); + } + + return groupByState(inspection.constructs).reduce((total, group) => total + group.rows.length, 0); +} + +describe("the order fields are shown in", () => { + test("leads with what no specification describes, and trails with what is working", () => { + const grouped = groupByState([ + report("never-read"), + report("shown"), + report("acted-on"), + report("unimplemented"), + report("unrecognised"), + ]); + + expect(grouped.map((group) => group.state)).toEqual([ + "unrecognised", + "unimplemented", + "never-read", + "shown", + "acted-on", + ]); + }); + + test("collapses only the states that mean nothing is wrong", () => { + const grouped = groupByState([ + report("unrecognised"), + report("unimplemented"), + report("never-read"), + report("shown"), + report("acted-on"), + ]); + + expect(grouped.filter((group) => group.nothingWrong).map((group) => group.state)).toEqual([ + "shown", + "acted-on", + ]); + }); + + test("shows no heading for a state this document does not use", () => { + const grouped = groupByState([report("acted-on")]); + + expect(grouped).toHaveLength(1); + expect(grouped[0]?.state).toBe("acted-on"); + }); + + test("a document declaring nothing groups into nothing", () => { + expect(groupByState([])).toEqual([]); + }); +}); + +describe("one row per construct, not per position", () => { + test("gathers every position a key was found at into its one row", () => { + const grouped = groupByState([ + report("acted-on", "amount_sat", "action Pay / output p2pk_out", "output"), + report("acted-on", "amount_sat", "action Refund / output refund_out", "output"), + ]); + + expect(grouped[0]?.rows).toHaveLength(1); + expect(grouped[0]?.rows[0]?.at).toEqual([ + "action Pay / output p2pk_out", + "action Refund / output refund_out", + ]); + }); + + // The same key at two kinds of position is two constructs and can be in two states. Merging + // them by name alone would print one row whose state is whichever the loop met last. + test("keeps the same key apart when it sits at different kinds of position", () => { + const grouped = groupByState([ + report("shown", "description", "action Pay", "action"), + report("shown", "description", "action Pay / output p2pk_out", "output"), + ]); + + expect(grouped[0]?.rows).toHaveLength(2); + }); + + test("loses no position, so the whole document is still reachable", () => { + const positions = groupByState([ + report("acted-on", "chain"), + report("acted-on", "utxo_types"), + report("shown", "description"), + ]).flatMap((group) => group.rows.flatMap((row) => row.at)); + + expect(positions).toHaveLength(3); + }); +}); + +// AC-04's own numbers, taken from the published protocols rather than from a document written +// to make the assertion pass. The second figure in each name is what the table drew before +// this change: one row per position. +describe("what the published protocols now draw", () => { + test("the deployed lending protocol: 57 rows rather than 620", () => { + expect(rowsFor(lendingV3Manifest)).toBe(57); + }); + + test("the exchange protocol: 50 rows rather than 235", () => { + expect(rowsFor(dexManifest)).toBe(50); + }); + + test("the simplest published protocol: 40 rows rather than 69", () => { + expect(rowsFor(p2pkManifest)).toBe(40); + }); +}); diff --git a/apps/web/src/app/manifest/components/groupByState.ts b/apps/web/src/app/manifest/components/groupByState.ts new file mode 100644 index 0000000..fc9f23f --- /dev/null +++ b/apps/web/src/app/manifest/components/groupByState.ts @@ -0,0 +1,85 @@ +import type { ConstructReport, ConstructSiteKind, ConstructState } from "@humid/tx-manifest"; + +/** + * The order a reader wants: what stops the build first, then what merely is. + * + * `unrecognised` leads because it is the one state that means nobody has ever specified this + * field here. `acted-on` trails, and used to come third, because it is the state of a field + * that works — for the deployed lending protocol that is 360 of 620 reports, and putting them + * before the rest buried the nine worth reading. + */ +const ORDER: ConstructState[] = [ + "unrecognised", + "unimplemented", + "never-read", + "shown", + "acted-on", +]; + +/** + * The states that mean nothing is wrong, and are therefore collapsed until asked for. + * + * Not hidden and not dropped: a reader who wants the whole document is one click away and the + * count is visible without clicking. What is removed is the default of meeting 611 rows that + * each say "this field works" before reaching the nine that say anything else. + */ +const NOTHING_WRONG = new Set(["shown", "acted-on"]); + +/** One construct, and every position in this document that declares it. */ +export type FieldRow = { + /** Where it was found, in the document's own terms, in the order the document lists them. */ + at: string[]; + key: string; + site: ConstructSiteKind; +}; + +export type ConstructGroup = { + /** Whether this state means nothing is wrong, and so opens collapsed. */ + nothingWrong: boolean; + rows: FieldRow[]; + state: ConstructState; +}; + +/** + * Groups one document's fields by what the wallet does with them, in reading order, and + * collapses each construct into one row carrying every position it was found at. + * + * The table used to draw one row per position, which is one row per key per place that key + * appears: 620 rows for the deployed lending protocol, over 41 distinct keys and 94 places. + * Nothing there was wrong and nothing was readable, because the repetition is inherent to the + * shape of the data rather than to anything the document did. + * + * A construct is a key at a kind of position, which is how the runtime's own table is keyed: + * `description` on an action and `description` on an output are two constructs and can be in + * two different states. Aggregating by key alone would merge them into one row whose state is + * whichever the loop met last. + * + * A function rather than a few lines inside the component because it is the only decision + * that surface makes: everything else there is layout. There is no DOM in this repository's + * tests, so a decision left inside JSX is a decision nothing can check. + */ +export function groupByState(constructs: ConstructReport[]): ConstructGroup[] { + return ORDER.map((state) => ({ + nothingWrong: NOTHING_WRONG.has(state), + rows: rowsOf(constructs.filter((report) => report.state === state)), + state, + })).filter((group) => group.rows.length > 0); +} + +function rowsOf(reports: ConstructReport[]): FieldRow[] { + const rows = new Map(); + + for (const report of reports) { + const identity = `${report.site}/${report.key}`; + const row = rows.get(identity); + + if (row) { + row.at.push(report.at); + continue; + } + + rows.set(identity, { at: [report.at], key: report.key, site: report.site }); + } + + return [...rows.values()].toSorted((left, right) => left.key.localeCompare(right.key)); +} diff --git a/apps/web/src/app/manifest/contractSources.test.ts b/apps/web/src/app/manifest/contractSources.test.ts new file mode 100644 index 0000000..aecdd34 --- /dev/null +++ b/apps/web/src/app/manifest/contractSources.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; + +import { matchContractSources } from "./contractSources"; + +// A document references a contract by a path relative to itself and a person hands over a file. +// What must never happen here is a file reaching the reader under a path the document did not +// ask for: the compiler check would then be answered by a source nothing in the document names. + +describe("matching supplied files onto the paths a document uses", () => { + test("puts a file under the path whose last segment is its name", () => { + const { sources } = matchContractSources( + ["./p2pk.simf"], + [{ name: "p2pk.simf", text: "fn main() {}" }], + ); + + expect(sources).toEqual({ "./p2pk.simf": "fn main() {}" }); + }); + + test("matches a path with no directory in it at all", () => { + const { sources } = matchContractSources( + ["lending.simf"], + [{ name: "lending.simf", text: "x" }], + ); + + expect(sources).toEqual({ "lending.simf": "x" }); + }); + + test("a file the document does not reference reaches the reader under no path", () => { + const { sources, unmatched } = matchContractSources( + ["./p2pk.simf"], + [{ name: "something_else.simf", text: "x" }], + ); + + expect(sources).toEqual({}); + expect(unmatched).toEqual(["something_else.simf"]); + }); + + // A name that merely appears inside another is not the same file, and treating it as one + // would answer a check with the wrong source. + test("does not match a name that is only a suffix of the real one", () => { + const { sources, unmatched } = matchContractSources( + ["./asset_auth_vault.simf"], + [{ name: "auth_vault.simf", text: "x" }], + ); + + expect(sources).toEqual({}); + expect(unmatched).toEqual(["auth_vault.simf"]); + }); + + test("takes several files at once, and reports both sides", () => { + const { sources, unmatched } = matchContractSources( + ["./lending.simf", "./script_auth.simf"], + [ + { name: "lending.simf", text: "one" }, + { name: "script_auth.simf", text: "two" }, + { name: "notes.txt", text: "three" }, + ], + ); + + expect(sources).toEqual({ "./lending.simf": "one", "./script_auth.simf": "two" }); + expect(unmatched).toEqual(["notes.txt"]); + }); + + test("nothing supplied is nothing matched, which is not an error", () => { + expect(matchContractSources(["./p2pk.simf"], [])).toEqual({ sources: {}, unmatched: [] }); + }); +}); diff --git a/apps/web/src/app/manifest/contractSources.ts b/apps/web/src/app/manifest/contractSources.ts new file mode 100644 index 0000000..0fa2a77 --- /dev/null +++ b/apps/web/src/app/manifest/contractSources.ts @@ -0,0 +1,48 @@ +/** One contract source a person handed to this page, under the name it had on their disk. */ +export type SuppliedSource = { + name: string; + text: string; +}; + +export type MatchedSources = { + /** What the reader is given: sources under the paths the document references them by. */ + sources: Record; + /** Names that matched nothing this document references, which is worth saying rather than ignoring. */ + unmatched: string[]; +}; + +/** + * Puts supplied files under the paths the document references them by. + * + * A document references a contract by a path relative to itself — `./p2pk.simf` — and a person + * has a file, not a path. Matching on the name at the end of the path is what closes that gap + * without asking anyone to retype a path they can read on screen. + * + * It matches rather than guesses: a file the document does not reference goes to `unmatched` + * and reaches the reader under no path at all. Handing it over under an invented key would put + * a source into a check that nothing in the document asked for. + */ +export function matchContractSources( + referenced: readonly string[], + supplied: readonly SuppliedSource[], +): MatchedSources { + const sources: Record = {}; + const unmatched: string[] = []; + + for (const file of supplied) { + const path = referenced.find((candidate) => endsWithName(candidate, file.name)); + + if (path === undefined) { + unmatched.push(file.name); + continue; + } + + sources[path] = file.text; + } + + return { sources, unmatched }; +} + +function endsWithName(path: string, name: string): boolean { + return path === name || path.endsWith(`/${name}`); +} diff --git a/apps/web/src/app/manifest/index.test.tsx b/apps/web/src/app/manifest/index.test.tsx new file mode 100644 index 0000000..cb749ed --- /dev/null +++ b/apps/web/src/app/manifest/index.test.tsx @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; + +import { renderToStaticMarkup } from "react-dom/server"; + +import ManifestInspector from "./index"; + +// AC-06. The claim is that this opens with no wallet installed, no connection and no network, +// and the strongest available check of it is that rendering the whole view touches no wallet +// context at all: every other surface in this app reads one, and reading a missing one here +// would throw rather than degrade. + +describe("the inspector with nothing around it", () => { + test("renders with no wallet context, no provider and no network", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("Manifest inspector"); + expect(html).toContain(" { + const html = renderToStaticMarkup(); + + expect(html).toContain("Nothing is sent anywhere"); + expect(html).toContain("no wallet is needed"); + }); + + test("shows no result panels until something is pasted", () => { + const html = renderToStaticMarkup(); + + expect(html).not.toContain("What this wallet would do"); + expect(html).not.toContain("What each field is"); + }); + + // The file picker asks for an input, not a result, and until a document says which contracts + // it references there is nothing to ask for. So it appears with the document rather than + // beside the answer, which is where it used to be. + test("asks for contract sources only once a document has named some", () => { + expect(renderToStaticMarkup()).not.toContain("Contract sources"); + }); + + test("offers a document to start from, so the empty box is not the only way in", () => { + expect(renderToStaticMarkup()).toContain("Load the p2pk example"); + }); + + // AC-01's other half. The network is the one thing the page asks for, and it opens without an + // answer — a default here would be a guess that decides whether two checks refuse. + test("asks which network, and opens with none chosen", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("Network"); + expect(html).toContain("Not chosen"); + expect(html).not.toContain("Liquid Testnet"); + }); +}); diff --git a/apps/web/src/app/manifest/index.tsx b/apps/web/src/app/manifest/index.tsx new file mode 100644 index 0000000..8590c31 --- /dev/null +++ b/apps/web/src/app/manifest/index.tsx @@ -0,0 +1,206 @@ +import p2pkManifest from "@humid/tx-manifest/fixtures/p2pk.manifest.json"; +import { useMemo, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { LIQUID_NETWORKS, liquidNetworkByChainId } from "@/lib/liquid-networks"; + +import { ConstructTable } from "./components/ConstructTable"; +import { ContractSourceList } from "./components/ContractSourceList"; +import { RewriteList } from "./components/RewriteList"; +import { Verdict } from "./components/Verdict"; +import { matchContractSources, type SuppliedSource } from "./contractSources"; +import { readDocument } from "./readDocument"; + +/** + * Going back to no answer, which needs a value of its own because the empty string is how the + * select spells "nothing chosen yet" and cannot also be an option. Neither resolves to a + * network, which is the only thing the reader is told. + */ +const NO_NETWORK = "none"; + +/** + * What this wallet would do with a txManifest document, without building anything from it. + * + * The page answers one question and answers it first: would this wallet refuse, and why. It + * used to open with an account of everything the reader computed — one region per field of + * the reader's return value, in the order that value declares them — which is a dump of a + * data structure rather than an answer, and left the person holding the document to work out + * which part of it bore on anything. + * + * So there is a verdict, and everything else is under it. What the reader was never able to + * check sits inside the verdict rather than below it, because the absence of a refusal is + * only honest beside the list of what was never asked; see {@link Verdict}. + * + * Everything shown comes from `@humid/tx-manifest` — the same package the wallet itself reads + * a document with — so this page cannot describe a parser that differs from the one that runs. + * + * It connects to nothing. There is no wallet here, no chain read and no request, which is + * both the point and the limit. + * + * The network and the contract sources are asked for in the input card rather than reported + * as results, because that is what they are: a document names a chain family and the two + * Liquid networks charge in different assets, and a contract source declares a compiler + * version the document also declares. Unanswered is a real state and the one this opens in — + * the checks needing those inputs are reported as not run, which is not the same as passing. + */ +export default function ManifestInspector() { + const [text, setText] = useState(""); + const [chosenChain, setChosenChain] = useState(""); + const [suppliedSources, setSuppliedSources] = useState([]); + const network = useMemo(() => liquidNetworkByChainId(chosenChain), [chosenChain]); + + // Read twice, because a file arrives under the name it has on a disk and the reader wants it + // under the path the document references it by — and only the document says what those paths + // are. The first read asks that question, which no supplied source can change the answer to, + // and the second is the one the page reports. + const { document, matched } = useMemo(() => { + const referenced = readDocument(text, { network }); + const byReferencedPath = matchContractSources( + referenced.kind === "read" && referenced.ok ? referenced.contracts : [], + suppliedSources, + ); + + return { + document: readDocument(text, { contractSources: byReferencedPath.sources, network }), + matched: byReferencedPath, + }; + }, [text, network, suppliedSources]); + + return ( +
+ + + Manifest inspector + + Paste a txManifest document. Nothing is sent anywhere and no wallet is needed — this + runs the same reader the wallet uses, here in the page. + + + +
+ + +

+ A document says which chain family it is written for, never which network — and the + two Liquid networks charge in different assets. Until you say which, the two checks + that compare against that asset are reported as not run rather than passed. +

+
+