From c08c6c6b91655f4b7cb7c2f9f3c429c3fffd849d Mon Sep 17 00:00:00 2001 From: lukachi Date: Thu, 3 Sep 2026 00:09:59 +0300 Subject: [PATCH] feat(liquid): activate manifest transactions --- .github/workflows/check.yml | 49 ++ .github/workflows/tx-manifest-check.yml | 126 ---- apps/extension/package.json | 2 +- apps/extension/src/background.ts | 43 ++ .../adapters/lwk/createLwkWalletBackend.ts | 8 +- .../sync-worker/broadcastTransaction.test.ts | 120 ++++ .../lwk/sync-worker/createInlineScanClient.ts | 4 + .../sync-worker/createOffscreenScanClient.ts | 13 + .../lwk/sync-worker/createWorkerScanClient.ts | 17 + .../lwk/sync-worker/liquidScanCore.ts | 38 + .../lwk/sync-worker/offscreenProtocol.ts | 13 +- .../adapters/lwk/wallet/getReceiveAddress.ts | 20 + .../adapters/lwk/wallet/getUTXOs/index.ts | 32 + .../adapters/lwk/wallet/readChainTipHeight.ts | 35 + .../wallet/readExplicitWalletUtxos.test.ts | 173 +++++ .../lwk/wallet/readExplicitWalletUtxos.ts | 121 ++++ .../adapters/lwk/wallet/resolveAccount.ts | 4 + .../lwk/wallet/sendTransfer/index.test.ts | 160 +++++ .../adapters/lwk/wallet/sendTransfer/index.ts | 18 + .../adapters/lwk/wallet/toScriptPubKeyHex.ts | 25 + .../lwk/wallet/withAccountMnemonic.ts | 75 ++ .../smplx/assembleReviewedTransaction.test.ts | 398 ++++++++++- .../smplx/assembleReviewedTransaction.ts | 313 ++++++-- .../backends/LiquidWalletBackend.ts | 48 ++ .../application/contractIdentity.test.ts | 184 +++++ .../liquid/application/contractIdentity.ts | 103 +++ .../ProcessCtConfirmation.test.tsx | 50 +- .../ProcessCtConfirmation.tsx | 29 +- .../index.test.ts | 674 ++++++++++++++++++ .../processConfidentialTransaction/index.ts | 405 ++++++++++- .../chains/liquid/contractIdentityClient.ts | 22 + .../internal-rpc/index.ts | 5 + .../internal-rpc/liquid-contract.ts | 30 + apps/extension/src/core/wallet-rpc/errors.ts | 4 + apps/extension/src/notification/index.tsx | 8 +- apps/extension/src/offscreen.ts | 6 + .../pages/Receive/components/ReceiveView.tsx | 206 +++++- .../Receive/contractIdentityQueryKey.test.ts | 47 ++ .../pages/Receive/contractIdentityQueryKey.ts | 23 + .../Home/pages/Receive/index.stories.tsx | 19 + .../App/pages/Home/pages/Receive/index.tsx | 24 +- .../Home/pages/Receive/useContractIdentity.ts | 36 + apps/extension/tsconfig.confirmation.json | 13 - apps/web/package.json | 6 +- .../components/method-cards/ProcessCtCard.tsx | 103 ++- apps/web/src/app/dashboard/contracts/p2pk.ts | 13 + apps/web/src/app/manifest/index.test.tsx | 11 +- apps/web/src/app/manifest/index.tsx | 40 +- .../web/src/app/manifest/readDocument.test.ts | 37 +- apps/web/src/app/manifest/readDocument.ts | 27 +- apps/web/tsconfig.tooling.json | 22 - bun.lock | 25 +- lefthook.yml | 7 + package.json | 13 +- .../appkit-injected-adapter/src/liquid-rpc.ts | 44 +- .../appkit-injected-adapter/src/wallet.ts | 10 +- packages/smplx-compiler/package.json | 11 + .../src/compilerVersion.test.ts | 47 ++ .../smplx-compiler/src/compilerVersion.ts | 26 + packages/tx-manifest/package.json | 2 +- .../tx-manifest/src/chain/chainRead.test.ts | 181 +++++ packages/tx-manifest/src/chain/chainRead.ts | 155 ++++ packages/tx-manifest/src/chain/guards.test.ts | 3 + .../tx-manifest/src/chain/rawTransaction.ts | 45 ++ packages/tx-manifest/src/document/sites.ts | 41 +- packages/tx-manifest/src/index.ts | 24 +- .../src/review/classAction.test.ts | 13 +- .../src/review/confirmation.test.ts | 2 + .../src/review/covenantSpend.test.ts | 345 +++++++++ packages/tx-manifest/src/review/index.test.ts | 9 +- packages/tx-manifest/src/review/index.ts | 336 +++++++-- .../tx-manifest/src/review/multiAsset.test.ts | 12 +- .../tx-manifest/src/review/rejection.test.ts | 3 + .../tx-manifest/src/review/semantics.test.ts | 2 + scripts/checkSmplxWasm.ts | 47 ++ scripts/manifestVersion.test.ts | 40 ++ scripts/manifestVersion.ts | 40 ++ vite.config.ts | 4 +- 78 files changed, 5010 insertions(+), 479 deletions(-) create mode 100644 .github/workflows/check.yml delete mode 100644 .github/workflows/tx-manifest-check.yml create mode 100644 apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/broadcastTransaction.test.ts create mode 100644 apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readChainTipHeight.ts create mode 100644 apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readExplicitWalletUtxos.test.ts create mode 100644 apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readExplicitWalletUtxos.ts create mode 100644 apps/extension/src/core/chains/liquid/adapters/lwk/wallet/sendTransfer/index.test.ts create mode 100644 apps/extension/src/core/chains/liquid/adapters/lwk/wallet/toScriptPubKeyHex.ts create mode 100644 apps/extension/src/core/chains/liquid/adapters/lwk/wallet/withAccountMnemonic.ts create mode 100644 apps/extension/src/core/chains/liquid/application/contractIdentity.test.ts create mode 100644 apps/extension/src/core/chains/liquid/application/contractIdentity.ts create mode 100644 apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts create mode 100644 apps/extension/src/core/chains/liquid/contractIdentityClient.ts create mode 100644 apps/extension/src/core/extension-background/internal-rpc/liquid-contract.ts create mode 100644 apps/extension/src/routes/App/pages/Home/pages/Receive/contractIdentityQueryKey.test.ts create mode 100644 apps/extension/src/routes/App/pages/Home/pages/Receive/contractIdentityQueryKey.ts create mode 100644 apps/extension/src/routes/App/pages/Home/pages/Receive/useContractIdentity.ts delete mode 100644 apps/extension/tsconfig.confirmation.json create mode 100644 apps/web/src/app/dashboard/contracts/p2pk.ts delete mode 100644 apps/web/tsconfig.tooling.json create mode 100644 packages/smplx-compiler/package.json create mode 100644 packages/smplx-compiler/src/compilerVersion.test.ts create mode 100644 packages/smplx-compiler/src/compilerVersion.ts create mode 100644 packages/tx-manifest/src/chain/chainRead.test.ts create mode 100644 packages/tx-manifest/src/review/covenantSpend.test.ts create mode 100644 scripts/checkSmplxWasm.ts create mode 100644 scripts/manifestVersion.test.ts create mode 100644 scripts/manifestVersion.ts 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/.github/workflows/tx-manifest-check.yml b/.github/workflows/tx-manifest-check.yml deleted file mode 100644 index c7cf652..0000000 --- a/.github/workflows/tx-manifest-check.yml +++ /dev/null @@ -1,126 +0,0 @@ -name: tx-manifest / smplx check - -# The gate for the tx-manifest package, the smplx adapter and the offline developer -# tooling in the web app, and only for those. -# -# Deliberately not `bun run check`. On a clean frozen install against the pinned -# submodules, the repository-wide typecheck fails in existing UI code on duplicate React -# and Zod type families reached through separate dependency trees — a problem that predates -# this work and that is repaired later on this branch. Running the full gate here would -# report those failures against every change to the manifest runtime and say nothing about -# it. So this checks what this slice owns, in full, and the root gate is restored in the -# activation slice once the cumulative branch carries the dependency repairs. -on: - push: - branches: ["**"] - pull_request: - -permissions: - contents: read - -jobs: - check: - runs-on: ubuntu-latest - steps: - # Recursive, and both wasm packages are built before anything else runs. Neither is - # optional: `lwk_wasm` and `smplx-wasm` are local `file:` dependencies, so on a - # checkout without them the frozen install cannot resolve the workspace, the - # typechecks have no declarations to read, and the two test files that drive the real - # smplx module rather than a substitute fail on - # `Cannot find module 'smplx-wasm/smplx_wasm_bg.js'` — driving the real one is the - # point of them. - - name: Checkout (with the lwk and smplx submodules) - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Setup Bun - uses: ./.github/actions/setup-bun - - # dev rather than release: this job checks code, and an unoptimised wasm builds - # faster. The release profile belongs to the build workflow, which ships the result. - - name: Build lwk_wasm - uses: ./.github/actions/build-lwk-wasm - with: - profile: dev - - - name: Build smplx_wasm - uses: ./.github/actions/build-smplx-wasm - - - name: Install dependencies - uses: ./.github/actions/install - - - name: Typecheck packages - run: bun run typecheck:packages - - # The extension half of this slice, typechecked by naming its files rather than by - # running the root project over `apps/extension/src`. `--ignoreConfig` is what makes - # that possible: the root `tsconfig.json` would pull in the whole app and with it the - # baseline failures above, so the flags this surface actually needs are stated here - # instead. The two ambient declaration files are named because nothing imports them — - # they are what make `smplx-wasm/smplx_wasm_bg.js` and `bun:test` resolvable. - - name: Typecheck the smplx adapter - run: | - bunx tsc --ignoreConfig --noEmit --strict \ - --module ESNext --moduleResolution bundler --target ESNext \ - --lib ESNext,DOM --types bun-types --skipLibCheck \ - apps/extension/src/vite-env.d.ts \ - apps/extension/src/bun-test-env.d.ts \ - apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.ts \ - apps/extension/src/core/chains/liquid/adapters/smplx/compileCovenantWithSmplx.ts \ - apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.ts \ - apps/extension/src/core/chains/liquid/adapters/smplx/smplxWasmForTests.ts \ - apps/extension/src/core/chains/liquid/adapters/smplx/loadSmplxWasm.test.ts \ - apps/extension/src/core/chains/liquid/adapters/smplx/compileCovenantWithSmplx.test.ts \ - apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.test.ts - - # The confirmation surface, which is React where the rest of this slice is not: it needs - # the JSX transform, the React declarations and the app's own `@/` mapping, and a path - # mapping cannot be given on a tsc command line. So it is named in a config of its own — - # one that extends the root project rather than restating a subset of it, because a - # surface checked under settings the app does not use has been checked against something - # nobody ships. It overrides only what is read: these two files and what they import, - # not the whole app whose baseline failures this job deliberately skips. - - name: Typecheck the confirmation surface - run: bunx tsc -p apps/extension/tsconfig.confirmation.json - - # The web half of this slice: the manifest inspector and the format support page, which - # read the manifest runtime's TypeScript rather than only a JSON fixture from it. - # - # `tsconfig.tooling.json` narrows what is checked, not what is read: it starts from these - # two directories and follows every UI component and package they import under the app's - # own settings, with nothing stubbed or skipped. The whole-app and root typechecks still - # fail on the duplicate type families described above, and are restored in the activation - # slice — gating on them here would report that against every change to this tooling. - - name: Typecheck the manifest tooling - run: bun --filter='./apps/web' run typecheck:tooling - - # The production module graph, bundled. It is what says the two new views are reachable - # through the App/Home navigation and that everything they pull in resolves for a browser. - # Vite directly rather than the app's `build` script, which runs the whole-app typecheck - # first: bundling succeeds where that typecheck does not, and this step is about the bundle. - - name: Build the web app - working-directory: apps/web - run: bunx vite build - - # Both are repository-wide and both pass on this branch, so they are run as they are - # rather than narrowed to these paths. - - name: Lint - run: bun run lint - - - name: Check formatting - run: bun run format:check - - # `apps/web` carries the server-rendered assertions for the inspector and the format - # page. They render each view to a string with no wallet, no provider and no document, - # which is the strongest available check that those pages stand alone — so they have to - # run here rather than only in a full-repository gate this job deliberately skips. - # - # The confirmation surface is rendered the same way and for the same reason: what a - # person is shown before they authorise a contract action, and what this wallet refuses - # to show them, are assertions about markup rather than about a model. - - name: Test - run: | - bun test packages/tx-manifest apps/web \ - apps/extension/src/core/chains/liquid/adapters/smplx \ - apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction diff --git a/apps/extension/package.json b/apps/extension/package.json index eb62e0d..df400f7 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -1,6 +1,6 @@ { "name": "Humid", - "version": "1.0.0", + "version": "1.1.0-rc.0", "private": true, "type": "module", "dependencies": { diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts index 9a07aa3..ded6180 100644 --- a/apps/extension/src/background.ts +++ b/apps/extension/src/background.ts @@ -16,6 +16,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 +279,44 @@ const init = async () => { const getReceiveAddress = async (): Promise => liquidChainGroup.accountRuntime.getReceiveAddress((await resolveSelectedLiquidAccount()).input); + // The address and key contract actions are signed with, for one account. Not the same as + // the receive address above: the contract module signs with one key at a fixed path and + // returns change to that key's own unblinded address, so a contract action can only spend + // what sits there. Reading it is what makes that limit visible rather than hidden. + const readContractIdentity = async (accountGroupId?: string): Promise => { + const { input } = await resolveSelectedLiquidAccount(); + + // 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 values + // are what somebody then funds and locks a covenant to. + const group = + accountGroupId === undefined + ? undefined + : Object.values(input.keyManagerState.accountModel.accountGroups).find( + (candidate) => candidate.id === accountGroupId, + ); + + if (accountGroupId !== undefined && !group) { + throw new Error(`No account group ${accountGroupId}.`); + } + + // The source that group's own seed comes from, not the selected account's. Both halves + // move together or neither does: an index read against the wrong seed is a different + // account's address and key, shown with nothing to say so — and the transaction that + // later signs for the real account cannot spend what was sent there. + const keySourceId = group + ? input.keyManagerState.accountModel.wallets[group.walletId]?.keySourceId + : input.keySourceId; + + return readLiquidContractIdentity({ + accountGroupIndex: group ? (group.groupIndex ?? 0) : input.accountGroupIndex, + chain: input.chain, + keyManagerState: input.keyManagerState, + ...(keySourceId === undefined ? {} : { keySourceId }), + }); + }; + // 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 +622,7 @@ const init = async () => { getActivity, getPortfolio, getReceiveAddress, + readContractIdentity, inspectTransfer, purgeAccountPortfolio, purgeAccountWalletConnectSessions, 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/broadcastTransaction.test.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/broadcastTransaction.test.ts new file mode 100644 index 0000000..0126c80 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/broadcastTransaction.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, mock, test } from "bun:test"; + +/** + * How a finished transaction reaches the network, beside the PSET route rather than instead of + * it. + * + * The manifest path does not produce a PSET: the contract module blinds, signs and finalises + * internally and hands back consensus bytes. Those bytes still have to leave the service worker + * to go out, because LWK's Esplora client does its retry and backoff through a `window` the + * service worker does not have — so this checks the one thing a unit test can check about that + * crossing: that the request is addressed to the offscreen document under its own operation, + * carries the transaction, and that the answer is read back as the network's own txid. + */ +const sent: unknown[] = []; +let reply: unknown = { ok: true, op: "broadcastTransaction", txid: "a".repeat(64) }; + +mock.module("webextension-polyfill", () => ({ + default: { + runtime: { + sendMessage: (message: unknown) => { + sent.push(message); + + return Promise.resolve(reply); + }, + }, + }, +})); + +// The offscreen document is a Chrome API this context does not have, and the client refuses +// without it before it sends anything. Stubbed as already existing, because what is under test +// is the message and the answer rather than the document's creation. +(globalThis as { chrome?: unknown }).chrome = { + offscreen: { + createDocument: () => Promise.resolve(), + hasDocument: () => Promise.resolve(true), + }, +}; + +const { createOffscreenScanClient } = await import("./createOffscreenScanClient"); +const { isOffscreenScanMessage, OFFSCREEN_SCAN_TARGET } = await import("./offscreenProtocol"); + +const chain = { id: "liquid:testnet" } as never; + +describe("broadcasting a signed transaction", () => { + test("addresses the offscreen document, under its own operation, carrying the bytes", async () => { + sent.length = 0; + reply = { ok: true, op: "broadcastTransaction", txid: "a".repeat(64) }; + + const result = await createOffscreenScanClient().broadcastTransaction({ + chain, + txHex: "deadbeef", + }); + + expect(result).toEqual({ txid: "a".repeat(64) }); + expect(sent).toEqual([ + { + input: { chain, txHex: "deadbeef" }, + op: "broadcastTransaction", + target: OFFSCREEN_SCAN_TARGET, + }, + ]); + }); + + // The target is what stops another extension context answering this. A message without it is + // not one the offscreen document handles, which is what the guard is for. + test("sends a message the offscreen document recognises as its own", async () => { + sent.length = 0; + reply = { ok: true, op: "broadcastTransaction", txid: "a".repeat(64) }; + + await createOffscreenScanClient().broadcastTransaction({ chain, txHex: "deadbeef" }); + + expect(isOffscreenScanMessage(sent[0])).toBe(true); + }); + + // Answering a broadcast with a scan's answer would hand back a txid nothing sent. The op is + // checked rather than the shape, because the two responses carry the same field names. + test("refuses an answer that is not this operation's", async () => { + reply = { ok: true, op: "broadcast", txid: "b".repeat(64) }; + + await expect( + createOffscreenScanClient().broadcastTransaction({ chain, txHex: "deadbeef" }), + ).rejects.toThrow("Unexpected offscreen scan response"); + }); + + test("carries the failure through rather than answering with a txid", async () => { + reply = { error: "the node rejected it", ok: false }; + + await expect( + createOffscreenScanClient().broadcastTransaction({ chain, txHex: "deadbeef" }), + ).rejects.toThrow("the node rejected it"); + }); + + // The PSET route is unchanged and still goes out under its own operation. Both exist: the + // ordinary send path produces a PSET and the contract path does not. + test("leaves the PSET route alone", async () => { + sent.length = 0; + reply = { ok: true, op: "broadcast", txid: "c".repeat(64) }; + + const result = await createOffscreenScanClient().broadcast({ chain, psetBase64: "cHNldA==" }); + + expect(result).toEqual({ txid: "c".repeat(64) }); + expect(sent).toEqual([ + { + input: { chain, psetBase64: "cHNldA==" }, + op: "broadcast", + target: OFFSCREEN_SCAN_TARGET, + }, + ]); + }); +}); + +describe("the dedicated worker, which cannot broadcast either kind", () => { + test("refuses rather than pretending, naming what can", async () => { + const { createWorkerScanClient } = await import("./createWorkerScanClient"); + + await expect( + createWorkerScanClient().broadcastTransaction({ chain, txHex: "deadbeef" }), + ).rejects.toThrow("offscreen or inline"); + }); +}); 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 +105,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..29907e3 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 @@ -35,6 +35,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; @@ -109,6 +115,38 @@ export async function broadcastPset(input: LiquidBroadcastInput): 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); + + console.warn("[liquid-sync] broadcast tx…", { chainId: input.chain.id, id: input.id }); + const startedAt = Date.now(); + const txid = await client.broadcastTx(transaction); + const txidString = txid.toString(); + + console.warn("[liquid-sync] 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(); diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/offscreenProtocol.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/offscreenProtocol.ts index e6a42be..77f543b 100644 --- a/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/offscreenProtocol.ts +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/sync-worker/offscreenProtocol.ts @@ -3,7 +3,12 @@ import type { LiquidAssetBalance, LiquidUtxoSnapshot, } from "../../../application/backends/LiquidWalletBackend"; -import type { BroadcastInput, ReadActivityInput, ScanInput } from "./createWorkerScanClient"; +import type { + BroadcastInput, + BroadcastTxInput, + ReadActivityInput, + ScanInput, +} from "./createWorkerScanClient"; /** Discriminator so only the offscreen document (not other extension contexts) handles these. */ export const OFFSCREEN_SCAN_TARGET = "liquid-offscreen-scan"; @@ -14,6 +19,11 @@ export type OffscreenScanMessage = op: "broadcast"; target: typeof OFFSCREEN_SCAN_TARGET; } + | { + input: BroadcastTxInput; + op: "broadcastTransaction"; + target: typeof OFFSCREEN_SCAN_TARGET; + } | { input: ScanInput; op: "scan" | "scanAndRead"; @@ -40,6 +50,7 @@ export type OffscreenScanResponse = op: "readActivity"; } | { ok: true; op: "broadcast"; txid: string } + | { ok: true; op: "broadcastTransaction"; txid: string } | { ok: true; op: "scan"; updateBase64: string | null }; export function isOffscreenScanMessage(value: unknown): value is OffscreenScanMessage { diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/getReceiveAddress.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/getReceiveAddress.ts index 20e9bcb..75e23e8 100644 --- a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/getReceiveAddress.ts +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/getReceiveAddress.ts @@ -1,6 +1,9 @@ import type { LiquidWalletAccount } from "../../../application/backends/LiquidWalletBackend"; import { getLwkImplementation } from "./getLwkImplementation"; +/** The one index the contract path signs at, as `readExplicitWalletUtxos` states. */ +const SIGNING_ADDRESS_INDEX = 0; + /** * The wallet's current receive address — the last unused address (index 0 for a * fresh, unsynced wallet). Deriving it needs no network sync. @@ -14,3 +17,20 @@ export function getWalletReceiveAddress(account: LiquidWalletAccount): { return { address: result.address().toString(), index: result.index() }; } + +/** + * The address a contract action can spend from: the account's first external address. + * + * Fixed rather than rotating, and deliberately so — `readExplicitWalletUtxos` accepts only + * outputs at this index, because the signing module derives one key there. Handing a protocol's + * token back to a rotating address makes it unspendable by the same path that received it. + */ +export function getWalletSigningAddress(account: LiquidWalletAccount): { + address: string; + index: number; +} { + const implementation = getLwkImplementation(account); + const result = implementation.wollet.address(SIGNING_ADDRESS_INDEX); + + return { address: result.address().toString(), index: result.index() }; +} diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/getUTXOs/index.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/getUTXOs/index.ts index 85625ef..5a058a5 100644 --- a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/getUTXOs/index.ts +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/getUTXOs/index.ts @@ -8,6 +8,7 @@ import { mapLiquidUtxosForAsset } from "../../../../application/backends/mapLiqu import type { LiquidUTXO } from "../../../../domain/LiquidRpc"; import { toLiquidAssetId } from "../../../../domain/validation"; import { getLwkImplementation } from "../getLwkImplementation"; +import { readExplicitWalletUtxos } from "../readExplicitWalletUtxos"; import { readWalletUtxos } from "../readWalletUtxos"; export function getWalletUtxosForAsset( @@ -36,3 +37,34 @@ export function getWalletUtxosForAsset( ); } } + +/** + * The wallet's unspent outputs that hide nothing, for the one path that can only spend those. + * + * Deliberately not folded into `getWalletUtxosForAsset`. That one answers the dapp-facing + * `getUTXOs` and the portfolio snapshot, and both describe the wallet as the chain library + * reports it; widening them would change an existing contract to fix a different problem. + */ +export function getExplicitWalletUtxosForAsset( + account: LiquidWalletAccount, + rawAssetId: string, +): LiquidUTXO[] { + const implementation = getLwkImplementation(account); + + try { + return mapLiquidUtxosForAsset(readExplicitWalletUtxos(implementation.wollet), { + assetId: toLiquidAssetId(account.chainId, rawAssetId), + rawAssetId, + }); + } catch (error) { + if (error instanceof WalletRpcResourceUnavailableError) { + throw error; + } + + throw new WalletRpcResourceUnavailableError( + "Could not read the wallet's explicit Liquid UTXOs.", + undefined, + WALLET_RPC_ERROR_REASONS.WALLET_UTXO_READ_FAILED, + ); + } +} diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readChainTipHeight.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readChainTipHeight.ts new file mode 100644 index 0000000..43be5ca --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readChainTipHeight.ts @@ -0,0 +1,35 @@ +import { + WALLET_RPC_ERROR_REASONS, + WalletRpcResourceUnavailableError, +} from "@/core/wallet-rpc/errors"; + +import type { LiquidWalletAccount } from "../../../application/backends/LiquidWalletBackend"; +import { getLwkImplementation } from "./getLwkImplementation"; + +/** + * How high the chain is, as this wallet already knows it. + * + * Read from the scan rather than from an endpoint. The wallet syncs its descriptor against + * whichever backend a chain is configured with, and the tip is what that scan reached — so it + * costs no network call and is available wherever the wallet works. A plain Esplora route for + * the same fact is not universal: the Waterfalls server this wallet uses for Liquid testnet + * serves the descriptor scan and answers 404 to `/blocks/tip/height`, which is exactly how a + * transaction that should have declared a locktime came to declare zero. + * + * Accurate as of the last sync, which for a contract action is moments earlier: the method + * syncs the account before it reviews anything. + */ +export function readChainTipHeight(account: LiquidWalletAccount): number { + const implementation = getLwkImplementation(account); + const tip = implementation.wollet.tip(); + + try { + return tip.height(); + } catch { + throw new WalletRpcResourceUnavailableError( + "Could not read the chain tip from the LWK wallet state.", + undefined, + WALLET_RPC_ERROR_REASONS.WALLET_UTXO_READ_FAILED, + ); + } +} diff --git a/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readExplicitWalletUtxos.test.ts b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readExplicitWalletUtxos.test.ts new file mode 100644 index 0000000..eaf7ad9 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/readExplicitWalletUtxos.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, test } from "bun:test"; + +import { readExplicitWalletUtxos } from "./readExplicitWalletUtxos"; + +/** + * A wallet built out of the shapes the chain library returns: each transaction reports which of + * its outputs belong to the wallet and which of its inputs spent wallet outputs, and the raw + * output says whether the amount is hidden. + */ +type OutputSpec = { + amount: string; + blinded: boolean; + vout: number; + chain?: number; + height?: number; + index?: number; +}; + +function walletTx( + txid: string, + outputs: OutputSpec[], + spends: { txid: string; vout: number }[] = [], +) { + const owned = (spec: OutputSpec) => ({ + 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..bf0edfa 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,7 +69,11 @@ 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, + // The source this account's seed actually came from, so anything that later needs its + // key material derives from the same one rather than from the local root by default. + ...(input.keySourceId === undefined ? {} : { keySourceId: input.keySourceId }), chain: input.chain, chainId: input.chain.id, descriptor: descriptor.toString(), 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..8b162b7 --- /dev/null +++ b/apps/extension/src/core/chains/liquid/adapters/lwk/wallet/sendTransfer/index.test.ts @@ -0,0 +1,160 @@ +// 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; + +/** An asset that is not the network's own, so the issued-asset branches can be reached. */ +const TOKEN = "b".repeat(64); + +async function send(overrides: Record = {}, rawAssetId = POLICY) { + recorded.calls = []; + + return sendTransfer( + account, + { amount: "5000", recipientAddress: "irrelevant", ...overrides } as never, + rawAssetId, + ); +} + +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("a confidential recipient of an issued asset keeps the ordinary asset path", async () => { + blinded = true; + + await expect(send({}, TOKEN)).resolves.toEqual({ txid: "sent" }); + expect(recorded.calls).toEqual(["asset:5000"]); + }); + + // One call takes either asset, because it is told which one. A protocol's own token has to + // be payable to an unconfidential address for the same reason the network's own does: a + // covenant reads exact amounts and cannot introspect a commitment. + test("an unconfidential recipient of an issued asset takes the same explicit path", async () => { + blinded = false; + + await expect(send({}, TOKEN)).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..2d4bb1c 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,25 @@ 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, and the Receive screen's unconfidential tab + // exists to show people the address to fund. The confidentiality that is lost is the point + // of the address, and the review screen says so before anyone confirms. + // + // Before the asset branches rather than inside them, because this one call takes either: + // the asset is named explicitly, so the network's own and an issued one need no separate + // path here. + 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/assembleReviewedTransaction.test.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.test.ts index 535349c..7da9ef2 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.test.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.test.ts @@ -88,6 +88,10 @@ const FEE_OUT = outputBytes("", { sats: 300n }); const COVENANT_OUT = outputBytes(COVENANT_SCRIPT, { sats: 50_000n }); /** What is left over, returned to the script the caller named. */ const CHANGE_OUT = outputBytes(CHANGE_SCRIPT, { sats: 900n }); +/** What a spend of the covenant pays back to this wallet. */ +const RECEIVED_OUT = outputBytes(WALLET_SCRIPT, { sats: 50_000n }); +/** The transaction the covenant this wallet spends sits in. */ +const COVENANT_TXID = "e".repeat(64); function signedHex(spends: Parameters[0][], outs: string[]): string { const inputCount = spends.length.toString(16).padStart(2, "0"); @@ -111,6 +115,19 @@ const TXOUT_HEX = `01${"49".repeat(32)}0100000000000186a000160014${"00".repeat(2 type Recorded = { changes: { blindingKey: string | null | undefined; script: string }[]; + /** Every covenant input added, with all nine values the module is given for it. */ + covenants: { + argumentsJson: string | undefined; + extraLeavesJson: string | undefined; + includeDebugSymbols: boolean | undefined; + issued?: { assetAmountSats: bigint; inflationAmountSats: bigint }; + signatureWitness: string | undefined; + source: string; + txOutHex: string; + txid: string; + vout: number; + witnessJson: string | undefined; + }[]; freed: number; /** How many issuance reports were released, which must match how many were handed over. */ freedReports: number; @@ -128,6 +145,8 @@ type Recorded = { sats: bigint; script: string; }[]; + locktimes: number[]; + sequences: number[]; spends: { txOut: string; txid: string; vout: number }[]; }; @@ -200,6 +219,71 @@ function substitute( reissuanceTokenId: reports.reissuanceToken ?? ISSUED.reissuanceToken, }; } + addCovenantInput( + txid: string, + vout: number, + txOutHex: string, + source: string, + argumentsJson?: string, + witnessJson?: string, + signatureWitness?: string, + extraLeavesJson?: string, + includeDebugSymbols?: boolean, + ) { + recorded.covenants.push({ + argumentsJson, + extraLeavesJson, + includeDebugSymbols, + signatureWitness, + source, + txOutHex, + txid, + vout, + witnessJson, + }); + } + addCovenantIssuanceInput( + txid: string, + vout: number, + txOutHex: string, + source: string, + argumentsJson: string | undefined, + witnessJson: string | undefined, + signatureWitness: string | undefined, + assetAmountSats: bigint, + inflationAmountSats: bigint, + _issuerContractHex: string | undefined, + extraLeavesJson?: string, + includeDebugSymbols?: boolean, + ) { + recorded.covenants.push({ + argumentsJson, + extraLeavesJson, + includeDebugSymbols, + issued: { assetAmountSats, inflationAmountSats }, + signatureWitness, + source, + txOutHex, + txid, + vout, + witnessJson, + }); + + return { + assetId: reports.asset ?? ISSUED.asset, + entropy: reports.entropy ?? ISSUED.entropy, + free: () => { + recorded.freedReports += 1; + }, + reissuanceTokenId: reports.reissuanceToken ?? ISSUED.reissuanceToken, + }; + } + setLocktimeHeight(height: number) { + recorded.locktimes.push(height); + } + setSequence(sequence: number) { + recorded.sequences.push(sequence); + } // Held across the wasm boundary, so the module under test releases it. A substitute // without this passes only because nothing checked that it was released. free() { @@ -212,7 +296,43 @@ function substitute( /** What this module needs of the SDK, which is what it states for itself. */ type SmplxModule = { TransactionBuilder: new () => AssemblingBuilder }; +/** + * The wallet's own output every case funds from, and the order that has only it in it. + * + * `selected` says which outputs the transaction spends and `inputOrder` says in which order, + * and once a covenant is in the transaction the two are different lists. A helper that derived + * one from the other would make it impossible to write the case where they disagree. + */ +const WALLET_UTXO = { + amount: "1000000", + spendable: true, + txOut: TXOUT_HEX, + txid: "c".repeat(64), + vout: 0, +}; + +/** + * The plan, with the order defaulted from the selection unless a case states one. + * + * A case about which of the wallet's outputs get added says so by overriding `selected`, and + * the order it is added in follows. A case about the order itself states `inputOrder` outright, + * which is the only way to write one where the two disagree. + */ function review(overrides: Partial = {}): ManifestReview { + const built = plan(overrides); + + return overrides.inputOrder === undefined + ? { + ...built, + inputOrder: [ + ...built.covenantInputs.map((covenant) => ({ covenant, source: "covenant" as const })), + ...built.selected.map((utxo) => ({ source: "wallet" as const, utxo })), + ], + } + : built; +} + +function plan(overrides: Partial = {}): ManifestReview { return { action: "Pay", // What a person would be shown, which this module never reads: it builds from the plan. @@ -229,6 +349,8 @@ function review(overrides: Partial = {}): ManifestReview { protocol: fromSite("p2pk-simplicity"), publishedAmounts: [], }, + covenantInputs: [], + inputOrder: [{ source: "wallet", utxo: WALLET_UTXO }], covenants: [ { address: "tex1p_derived", @@ -258,9 +380,7 @@ function review(overrides: Partial = {}): ManifestReview { ], policyAsset: ASSET, protocol: "p2pk-simplicity", - selected: [ - { amount: "1000000", spendable: true, txOut: TXOUT_HEX, txid: "c".repeat(64), vout: 0 }, - ], + selected: [WALLET_UTXO], ...overrides, }; } @@ -286,10 +406,13 @@ function subject( ) { const recorded: Recorded = { changes: [], + covenants: [], freed: 0, freedReports: 0, issues: [], + locktimes: [], outputs: [], + sequences: [], spends: [], }; @@ -420,10 +543,13 @@ describe("assembleReviewedTransaction", () => { test("releases the builder when an output the module will not take throws", async () => { const recorded: Recorded = { changes: [], + covenants: [], freed: 0, freedReports: 0, issues: [], + locktimes: [], outputs: [], + sequences: [], spends: [], }; const smplx = substitute(recorded); @@ -447,10 +573,13 @@ describe("assembleReviewedTransaction", () => { test("releases the builder when the change script is refused", async () => { const recorded: Recorded = { changes: [], + covenants: [], freed: 0, freedReports: 0, issues: [], + locktimes: [], outputs: [], + sequences: [], spends: [], }; let finalized = 0; @@ -807,36 +936,253 @@ describe("assembleReviewedTransaction", () => { }); }); - describe("what it will not build", () => { - // Receive spends the covenant, and this wallet has neither the amount reference its - // output needs nor the signing witness its spend needs. Building the rest of it would be - // a transaction the covenant refuses at execution, after a person approved it. - test("refuses an action that spends a covenant rather than building part of it", async () => { - const { assemble, recorded } = subject({ - action: "Receive", - covenants: [ - { - address: "tex1p_derived", - ...COVENANT_BUILD, - role: "spent", - scriptPubKeyHex: COVENANT_SCRIPT, - utxoType: "p2pk_output", - verified: "matches-chain", - }, + describe("an action that spends a covenant", () => { + /** What the review established about the covenant, as a builder is handed it. */ + const covenantInput = { + argumentsJson: COVENANT_BUILD.argumentsJson, + extraLeavesJson: COVENANT_BUILD.extraLeavesJson, + id: "p2pk_in", + includeDebugSymbols: COVENANT_BUILD.includeDebugSymbols, + signatureWitness: "SIGNATURE", + source: COVENANT_BUILD.source, + txOutHex: TXOUT_HEX, + txid: COVENANT_TXID, + utxoType: "p2pk_output", + vout: 1, + }; + const spendingPlan = (overrides: Partial = {}): Partial => ({ + action: "Receive", + covenantInputs: [covenantInput], + covenants: [ + { + address: "tex1p_derived", + ...COVENANT_BUILD, + role: "spent", + scriptPubKeyHex: COVENANT_SCRIPT, + utxoType: "p2pk_output", + verified: "matches-chain", + }, + ], + outputs: [ + { + asset: ASSET, + blinded: false, + decidedBy: "unblindable", + id: "received_out", + sats: 50_000n, + scriptPubKeyHex: WALLET_SCRIPT, + }, + ], + ...overrides, + }); + /** The finished transaction for a spend: the covenant input first, then the wallet's. */ + const spent = () => + signed( + [ + { txid: COVENANT_TXID, vout: 1 }, + { txid: "c".repeat(64), vout: 0 }, ], - }); + [RECEIVED_OUT, CHANGE_OUT, FEE_OUT], + ); - const result = await assemble(); + // All nine values, because every one of them decides the script the covenant locks to or + // what satisfies it. Sending the source and the parameters alone builds a different + // contract than the one the review checked against the chain, and the covenant refuses + // its own spend at execution — after a person has approved. + test("hands the module everything the review verified the covenant under", async () => { + const { assemble, recorded } = subject(spendingPlan(), spent); - expect(result).toMatchObject({ ok: false }); + expect(await assemble()).toMatchObject({ ok: true }); + expect(recorded.covenants).toEqual([ + { + argumentsJson: COVENANT_BUILD.argumentsJson, + extraLeavesJson: "[]", + includeDebugSymbols: false, + signatureWitness: "SIGNATURE", + source: COVENANT_BUILD.source, + txOutHex: TXOUT_HEX, + txid: COVENANT_TXID, + vout: 1, + witnessJson: undefined, + }, + ]); + }); + + // A covenant with more than one branch is told which to run by a witness the document + // states outright. It crosses as the compiler's own shape — a type and a literal, both + // text — because the compiler is what parses SimplicityHL and this module is not. + test("passes the stated witness values through as the compiler's own shape", async () => { + const { assemble, recorded } = subject( + spendingPlan({ + covenantInputs: [ + { + ...covenantInput, + witnessValues: [ + { name: "BRANCH", simplicityType: "Either<(), ()>", value: "Left(())" }, + ], + }, + ], + }), + spent, + ); + + expect(await assemble()).toMatchObject({ ok: true }); + expect(recorded.covenants[0]?.witnessJson).toBe( + JSON.stringify({ BRANCH: { type: "Either<(), ()>", value: "Left(())" } }), + ); + }); + + // Only the signer can make a signature, and naming the witness is what asks for one. + // A covenant that needs none must not be told to fill one that does not exist. + test("asks for no signature where the document declares none", async () => { + const { assemble, recorded } = subject( + spendingPlan({ + covenantInputs: [{ ...covenantInput, signatureWitness: undefined }], + }), + spent, + ); + + expect(await assemble()).toMatchObject({ ok: true }); + expect(recorded.covenants[0]?.signatureWitness).toBeUndefined(); + }); + + // A contract asserting its own index will not run against a transaction built the other + // way, and nothing after signing could say why. So the order is the plan's, not this + // module's habit of adding every covenant first. + test("adds the inputs in the order the plan states, not covenants first", async () => { + const { assemble, recorded } = subject( + spendingPlan({ + inputOrder: [ + { source: "wallet", utxo: WALLET_UTXO }, + { covenant: covenantInput, source: "covenant" }, + ], + }), + () => + signed( + [ + { txid: "c".repeat(64), vout: 0 }, + { txid: COVENANT_TXID, vout: 1 }, + ], + [RECEIVED_OUT, CHANGE_OUT, FEE_OUT], + ), + ); + + expect(await assemble()).toMatchObject({ ok: true }); + expect(recorded.spends).toHaveLength(1); + expect(recorded.covenants).toHaveLength(1); + }); + + // An action whose covenant already holds everything its outputs cost is funded entirely + // by the covenant it spends. Refusing that for holding none of the wallet's own outputs + // would refuse the ordinary case of a protocol paying itself out. + test("builds an action funded entirely by the covenant it spends", async () => { + const { assemble, recorded } = subject( + spendingPlan({ + inputOrder: [{ covenant: covenantInput, source: "covenant" }], + selected: [], + }), + () => signed([{ txid: COVENANT_TXID, vout: 1 }], [RECEIVED_OUT, CHANGE_OUT, FEE_OUT]), + ); + + expect(await assemble()).toMatchObject({ ok: true }); expect(recorded.spends).toEqual([]); - expect(recorded.outputs).toEqual([]); + expect(recorded.covenants).toHaveLength(1); + }); - if (!result.ok) { - expect(result.reason).toContain("p2pk_output"); - } + test("refuses when there is nothing funding it at all", async () => { + const { assemble } = subject(spendingPlan({ inputOrder: [], selected: [] })); + + expect(await assemble()).toMatchObject({ ok: false }); }); + // The lock height and the sequence are facts about the transaction rather than about any + // one input, and a branch guarded by a lock height reads the locktime this sets. + test("declares the locktime and the sequence the plan carries", async () => { + const { assemble, recorded } = subject( + spendingPlan({ locktimeHeight: 3_210_987, sequence: 4_294_967_294 }), + spent, + ); + + expect(await assemble()).toMatchObject({ ok: true }); + expect(recorded.locktimes).toEqual([3_210_987]); + expect(recorded.sequences).toEqual([4_294_967_294]); + }); + + test("declares neither where the plan carries neither", async () => { + const { assemble, recorded } = subject(spendingPlan(), spent); + + await assemble(); + + expect(recorded.locktimes).toEqual([]); + expect(recorded.sequences).toEqual([]); + }); + + // The guard reads the finished transaction's own bytes rather than this module's account + // of what it asked for. A covenant input the action required and the bytes do not carry + // is a transaction nobody agreed to. + test("refuses when the finished transaction does not spend the covenant", async () => { + const { assemble } = subject(spendingPlan(), () => + signed([{ txid: "c".repeat(64), vout: 0 }], [RECEIVED_OUT, CHANGE_OUT, FEE_OUT]), + ); + + expect(await assemble()).toMatchObject({ ok: false, reject: "built-something-else" }); + }); + + test("releases the builder even when the guard refuses what came back", async () => { + const { assemble, recorded } = subject(spendingPlan(), () => + signed([{ txid: "c".repeat(64), vout: 0 }], [RECEIVED_OUT, CHANGE_OUT, FEE_OUT]), + ); + + await assemble(); + + expect(recorded.freed).toBe(1); + }); + + // A covenant can issue an asset on the input that spends it, and the module has one call + // that does both. Added twice it would spend the same output twice, which is not a + // transaction at all. + test("adds a covenant that also issues exactly once, through the one call that does both", async () => { + const { assemble, recorded } = subject( + spendingPlan({ + issuances: [{ ...plannedIssuance(), outpoint: { txid: COVENANT_TXID, vout: 1 } }], + }), + () => + signed( + [ + { issuance: true, txid: COVENANT_TXID, vout: 1 }, + { txid: "c".repeat(64), vout: 0 }, + ], + [RECEIVED_OUT, CHANGE_OUT, FEE_OUT], + ), + ); + + expect(await assemble()).toMatchObject({ ok: true }); + expect(recorded.covenants).toHaveLength(1); + expect(recorded.covenants[0]?.issued).toEqual({ + assetAmountSats: 1000n, + inflationAmountSats: 0n, + }); + expect(recorded.issues).toEqual([]); + // The report is a handle across the wasm boundary, so it is released whatever it said. + expect(recorded.freedReports).toBe(1); + }); + + test("refuses when the module derives a different asset than the wallet did", async () => { + const { assemble, recorded } = subject( + spendingPlan({ + issuances: [{ ...plannedIssuance(), outpoint: { txid: COVENANT_TXID, vout: 1 } }], + }), + spent, + { reports: { asset: "d".repeat(64) } }, + ); + + expect(await assemble()).toMatchObject({ ok: false, reject: "built-something-else" }); + expect(recorded.freedReports).toBe(1); + expect(recorded.freed).toBe(1); + }); + }); + + describe("what it will not build", () => { test("refuses when nothing of the wallet's funds it", async () => { const { assemble } = subject({ selected: [] }); diff --git a/apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.ts b/apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.ts index 0ec67f1..da707c2 100644 --- a/apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.ts +++ b/apps/extension/src/core/chains/liquid/adapters/smplx/assembleReviewedTransaction.ts @@ -3,6 +3,7 @@ import { guardSpentInputs, type ManifestReview, type RejectToken, + type StaticWitness, } from "@humid/tx-manifest"; import type { SmplxWasmModule } from "./loadSmplxWasm"; @@ -45,6 +46,62 @@ export type AssemblingBuilder = Pick< InstanceType, "addChange" | "addOutput" | "addWalletInput" | "free" > & { + /** + * Adds a covenant input: an output locked by a Simplicity program, spent by satisfying it. + * + * Everything the covenant was compiled from is passed again, because the module compiles the + * contract a second time to satisfy it and a compile differing in any of them produces a + * different script. The witness values are the compiler's own `.wit` shape as text; the + * signature witness is a name rather than a value, because only the signer can make one and + * the transaction it signs over does not exist yet. + */ + addCovenantInput: ( + txid: string, + vout: number, + txOutHex: string, + source: string, + argumentsJson?: string, + witnessJson?: string, + signatureWitness?: string, + extraLeavesJson?: string, + includeDebugSymbols?: boolean, + ) => void; + /** + * Adds a covenant input that also creates a new asset. + * + * The covenant half is `addCovenantInput` and the issuance half `addWalletIssuanceInput`; + * this exists because an input can only be added once and a document may declare both on it. + */ + addCovenantIssuanceInput: ( + txid: string, + vout: number, + txOutHex: string, + source: string, + argumentsJson: string | undefined, + witnessJson: string | undefined, + signatureWitness: string | undefined, + assetAmountSats: bigint, + inflationAmountSats: bigint, + issuerContractHex: string | undefined, + extraLeavesJson?: string, + includeDebugSymbols?: boolean, + ) => AssembledIssuanceReport; + /** + * The block height this transaction may not be mined before. + * + * Set rather than defaulted, and only where the review read one: a covenant branch guarded + * by a lock height reads this field, and a transaction declaring none satisfies no such + * branch. + */ + setLocktimeHeight: (height: number) => void; + /** + * The sequence written onto every input that declares none. + * + * One value for the transaction, because that is what the module takes. The review has + * already collapsed what the action declares into the single value this can be, or refused + * the action. + */ + setSequence: (sequence: number) => void; /** * Adds a wallet input that also creates a new asset. * @@ -138,27 +195,14 @@ export async function assembleReviewedTransaction( smplx: { TransactionBuilder: new () => AssemblingBuilder }; }, ): Promise { - // A covenant being spent needs the source, the arguments and the witness the review - // verified it under, and a signature over this transaction for the branch that asserts - // one. None of that is established yet, and a transaction assembled without it is not a - // smaller version of the right one — it is one the covenant refuses at execution, after - // a person has approved it. So it refuses here, where the reason can be read. - const spent = review.covenants.find((covenant) => covenant.role === "spent"); - - if (spent) { - return { - ok: false, - reason: - `"${review.action}" spends the ${spent.utxoType} covenant, and this wallet cannot ` + - "yet satisfy a covenant input. It will not build part of the transaction and call it whole.", - reject: "unimplemented-construct", - }; - } - - if (review.selected.length === 0) { + // Nothing to spend at all, which is not a transaction. Asked of the order rather than of + // the wallet's own selection: an action whose covenant already holds everything its outputs + // cost is funded entirely by the covenant it spends, and refusing that for holding none of + // the wallet's own outputs would refuse the ordinary case of a protocol paying itself out. + if (review.inputOrder.length === 0) { return { ok: false, - reason: `"${review.action}" has no wallet output funding it.`, + reason: `"${review.action}" has nothing funding it.`, reject: "shortfall", }; } @@ -233,9 +277,18 @@ export async function assembleReviewedTransaction( issuing.set(key, issuance); } - // An asset derived from an output no input spends is an id for something that would never - // come to exist, and the person would already have been shown it. - const spending = new Set(review.selected.map((utxo) => outpointKey(utxo))); + /** + * Every output this transaction will spend, covenant and wallet alike. + * + * Read off the order rather than off the selection, because the order is what actually gets + * added and a covenant input is in one and not the other. An asset derived from an output + * that is in neither is an id for something that would never come to exist. + */ + const spending = new Set( + review.inputOrder.map((planned) => + outpointKey(planned.source === "covenant" ? planned.covenant : planned.utxo), + ), + ); const stranded = review.issuances.find( (issuance) => !spending.has(outpointKey(issuance.outpoint)), ); @@ -253,10 +306,10 @@ export async function assembleReviewedTransaction( // One output described twice is still one output, and adding both is a transaction that // spends it twice. Selection removes these, so reaching here means the review was assembled // by something other than a review — which is exactly when a builder should not be started. - if (spending.size !== review.selected.length) { + if (spending.size !== review.inputOrder.length) { return { ok: false, - reason: `"${review.action}" spends one of this wallet's outputs more than once.`, + reason: `"${review.action}" spends one of its outputs more than once.`, reject: "document-fault", }; } @@ -264,8 +317,129 @@ export async function assembleReviewedTransaction( const builder = new input.smplx.TransactionBuilder(); try { - for (const utxo of review.selected) { - const issuance = issuing.get(outpointKey(utxo)); + // 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. + if (review.sequence !== undefined) { + builder.setSequence(review.sequence); + } + + /** Which of the outputs an issuance was derived from have actually been added. */ + const placed = new Set(); + + /** + * The module derived the asset for itself, from the same output, and says 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 — after a person has already approved the one the wallet showed them. + */ + const disagreement = ( + issuance: ManifestReview["issuances"][number], + reported: AssembledIssuanceReport, + ): AssembleResult | undefined => { + try { + const difference = firstDisagreement(issuance, reported); + + return difference === undefined + ? undefined + : { + ok: false, + reason: + `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", + }; + } finally { + reported.free(); + } + }; + + // In the order the review worked out, which is the document's wherever it states one. + // Adding every covenant first and the wallet's own after 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) : outpointKey(planned.utxo); + 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 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) { + // 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. + builder.addCovenantInput( + covenant.txid, + covenant.vout, + covenant.txOutHex, + covenant.source, + covenant.argumentsJson, + witness, + covenant.signatureWitness, + covenant.extraLeavesJson, + covenant.includeDebugSymbols, + ); + + continue; + } + + // A covenant that also issues is added once, by the call that does both. The + // issuer contract is left unstated because a manifest declares none at any + // position, so both sides commit to nothing and each says so. + const refusal = disagreement( + issuance, + builder.addCovenantIssuanceInput( + covenant.txid, + covenant.vout, + covenant.txOutHex, + covenant.source, + covenant.argumentsJson, + witness, + covenant.signatureWitness, + issuance.assetAmountSats, + issuance.inflationAmountSats, + undefined, + covenant.extraLeavesJson, + covenant.includeDebugSymbols, + ), + ); + + if (refusal) { + return refusal; + } + + continue; + } + + const { utxo } = planned; if (!issuance) { builder.addWalletInput(utxo.txid, utxo.vout, utxo.txOut); @@ -276,42 +450,38 @@ export async function assembleReviewedTransaction( // An issuing input is added once, as an issuance. Adding it here and again as an // ordinary wallet input would spend the same output twice, which is not a // transaction at all. - // - // The issuer contract is left unstated because a manifest declares none at any - // position, so both sides commit to nothing and each says so. - const reported = builder.addWalletIssuanceInput( - utxo.txid, - utxo.vout, - utxo.txOut, - issuance.assetAmountSats, - issuance.inflationAmountSats, - undefined, + const refusal = disagreement( + issuance, + builder.addWalletIssuanceInput( + utxo.txid, + utxo.vout, + utxo.txOut, + issuance.assetAmountSats, + issuance.inflationAmountSats, + undefined, + ), ); - // The module derived the asset for itself, from the same output. 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 — after a person has already approved the one the wallet showed them. - try { - const difference = firstDisagreement(issuance, reported); - - if (difference) { - return { - ok: false, - reason: - `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", - }; - } - } finally { - reported.free(); + if (refusal) { + return refusal; } } + // Every issuance was actually added, rather than merely matched against an order that + // contains its output. The pre-check above says the outpoint is in the order; this says + // the loop reached it — and the two differ if the order is ever walked partially. + const missed = review.issuances.find((issuance) => !placed.has(outpointKey(issuance.outpoint))); + + if (missed) { + return { + ok: false, + reason: + `Input ${missed.inputId} issues an asset from an output this transaction does not ` + + "spend, so the asset would never exist.", + reject: "document-fault", + }; + } + // Paid in the asset the review worked out for it, and to the script it derived. An // output built from an amount alone pays whatever asset the module defaults to, and one // built from an address is not hex the module can decode. @@ -365,6 +535,26 @@ export async function assembleReviewedTransaction( } } +/** + * 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. Nothing here parses + * 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 — given by the one component with no way to check it. + */ +function witnessValuesJson(values: StaticWitness[] | undefined): string | undefined { + if (!values || values.length === 0) { + return undefined; + } + + return JSON.stringify( + Object.fromEntries( + values.map(({ name, simplicityType, value }) => [name, { type: simplicityType, value }]), + ), + ); +} + /** * The first of the three ids the two sides disagree about, if they disagree at all. * @@ -427,10 +617,11 @@ function disagreementWith( changeScriptPubKeyHex: string, ): string | undefined { const spent = guardSpentInputs(transaction.hex, { - // Empty rather than derived, and the emptiness is the claim: an action spending a - // covenant is refused above, before a builder exists, so a covenant input in these bytes - // is one nothing here asked for and the guard says so. - covenantInputs: [], + // The covenant outputs the action requires, as the review established them from the + // chain. Read off the review rather than off what was added: what was added is this + // module's own account of itself, which is the one source that cannot say whether the + // module spent something nobody asked it to. + covenantInputs: review.covenantInputs.map(({ txid, vout }) => ({ txid, vout })), walletInputs: review.selected.map(({ txid, vout }) => ({ txid, vout })), }); 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..c1c8276 100644 --- a/apps/extension/src/core/chains/liquid/application/backends/LiquidWalletBackend.ts +++ b/apps/extension/src/core/chains/liquid/application/backends/LiquidWalletBackend.ts @@ -32,7 +32,29 @@ 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 — two places deciding that is two places + * that can decide it differently. + */ + accountGroupIndex?: number; accountIdentifier: string; + /** + * Which of the wallet's key sources this account's seed came from. + * + * Carried out of resolution beside the group index, and for the same reason: a caller that + * needs the account's own key material has to derive it from the source this account was + * resolved against. A session may authorise a group whose seed is not the local root, and a + * signer built without this would sign from the local root instead — a valid signature, by + * the wrong key, over a transaction a person approved for a different account. + * + * Absent for an account resolved against the local root, which is what leaving it unset + * already means everywhere it is read. + */ + keySourceId?: KeySourceId; chain: LiquidChainRecord; chainId: LiquidChainId; /** The watch-only descriptor string — safe to hand to the scan worker (no keys). */ @@ -123,11 +145,37 @@ 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, since the signing module is handed an outpoint and its bytes and + * nothing that could unblind it. + */ + 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..8d1e3ba --- /dev/null +++ b/apps/extension/src/core/chains/liquid/application/contractIdentity.test.ts @@ -0,0 +1,184 @@ +// 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]); + }); + + // A group index says which BIP-85 child; the key source says whose seed that child is + // taken from. Read against the local root for an account whose seed is elsewhere, both + // values on the screen belong to a different account — and what a person then sends to + // that address cannot be spent by the transaction that signs for the real one. + test("follows the key source it is given, so the screen shows the key that will sign", async () => { + const asked: { accountGroupIndex: number; keySourceId?: string }[] = []; + 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; keySourceId?: string }, + use: (mnemonic: string) => unknown, + ): Promise => { + asked.push({ + accountGroupIndex: request.accountGroupIndex, + ...(request.keySourceId === undefined ? {} : { keySourceId: request.keySourceId }), + }); + + return use("about about about"); + }, + } as never; + + await readLiquidContractIdentity( + { + accountGroupIndex: 2, + chain: chain("testnet"), + keyManagerState: {} as never, + keySourceId: "key-source:hardware-1" as never, + }, + spy, + ); + await readLiquidContractIdentity( + { accountGroupIndex: 2, chain: chain("testnet"), keyManagerState: {} as never }, + spy, + ); + + expect(asked).toEqual([ + { accountGroupIndex: 2, keySourceId: "key-source:hardware-1" }, + // Nothing given means the local root, which is what an absent source already means + // everywhere else it is read. Passed as absent rather than as a name for it. + { accountGroupIndex: 2 }, + ]); + }); +}); 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..8b04b6d --- /dev/null +++ b/apps/extension/src/core/chains/liquid/application/contractIdentity.ts @@ -0,0 +1,103 @@ +import type { KeySourceId } from "@/core/accounts/application/account-registry/model/identifiers"; +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; + /** + * Which of the wallet's key sources this account's seed comes from. + * + * Beside the group index rather than derived from it, because the two are independent: a + * group says which BIP-85 child, and this says whose seed that child is taken from. Absent + * means the local root, which is what leaving it unset already means everywhere it is read. + * + * It has to be here because what this returns is shown to a person as the address they fund + * and the key they lock a covenant to. Read against the local root for an account whose seed + * is elsewhere, both values belong to a different account — and the transaction that later + * signs for the real one cannot spend what was sent to them. + */ + keySourceId?: KeySourceId; +}; + +/** + * 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, keySourceId }: 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, + // The same source the signing path will use. Shown and signed with have to be one + // key: a person funds what this screen shows them. + ...(keySourceId === undefined ? {} : { keySourceId }), + }, + (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 index cee8d5e..81cfc34 100644 --- 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 @@ -52,12 +52,16 @@ const MODEL: ShownConfirmation = { summary: fromSite("Spend a p2pk output back into your wallet."), }; -const payload = (shown: unknown = MODEL) => ({ kind: PROCESS_CT_CONFIRMATION_KIND, shown }); +const payload = (shown: unknown = MODEL, broadcast = false) => ({ + broadcast, + kind: PROCESS_CT_CONFIRMATION_KIND, + shown, +}); -const markup = (shown: ShownConfirmation = MODEL) => +const markup = (shown: ShownConfirmation = MODEL, broadcast = false) => renderToStaticMarkup( {}} onDecline={() => {}} />, @@ -430,3 +434,43 @@ describe("what the screen says", () => { expect(markup(withoutSummary)).not.toContain("What the site says this does"); }); }); + +/** + * Which of the two authorisations this screen is asking for. + * + * A signature handed back to the site and a signature broadcast are different things to agree + * to, and the request says which. A screen that showed one word for both would be asking a + * person to approve something the wallet knew and did not tell them. + */ +describe("what the button says it will do", () => { + test("offers to sign, for a request that will not send", () => { + const rendered = markup(MODEL, false); + + expect(rendered).toContain(">Sign<"); + expect(rendered).not.toContain("Sign and send"); + expect(rendered).toContain("handed back to the site rather than sent"); + }); + + test("offers to sign and send, for a request that will", () => { + const rendered = markup(MODEL, true); + + expect(rendered).toContain("Sign and send"); + expect(rendered).toContain("signed and sent"); + }); + + // Not defaulted. A payload that omits it cannot say which of the two questions is being + // asked, and reading the absence as the quieter answer would put "Sign" on a screen that is + // about to broadcast. + test("refuses a payload that does not say", () => { + expect(isProcessCtConfirmationData({ kind: PROCESS_CT_CONFIRMATION_KIND, shown: MODEL })).toBe( + false, + ); + expect( + isProcessCtConfirmationData({ + broadcast: "yes", + kind: PROCESS_CT_CONFIRMATION_KIND, + shown: MODEL, + }), + ).toBe(false); + }); +}); 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 index 3b69ba0..54ce202 100644 --- a/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.tsx +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.tsx @@ -7,6 +7,15 @@ import { UiButton } from "@/ui/UiButton/base"; export const PROCESS_CT_CONFIRMATION_KIND = "liquid.processConfidentialTransaction"; export type ProcessCtConfirmationData = { + /** + * Whether agreeing also sends this transaction, which is what the request asked for. + * + * On the payload rather than left to the screen, because it is the request's word and not + * this surface's: two different things are being agreed to — a signature handed back, or a + * signature broadcast — and one button labelled for both would be describing something this + * screen does not know. + */ + broadcast: boolean; kind: typeof PROCESS_CT_CONFIRMATION_KIND; shown: ShownConfirmation; }; @@ -64,6 +73,13 @@ export function isProcessCtConfirmationData(value: unknown): value is ProcessCtC return false; } + // Checked rather than defaulted. A payload that omits it is one this surface cannot say + // which of the two questions it is asking, and defaulting to the quieter answer would put + // "Sign" on a screen that is about to broadcast. + if (typeof value.broadcast !== "boolean") { + return false; + } + const shown = value.shown; if (!isRecord(shown)) { @@ -224,9 +240,10 @@ export function ProcessCtUnreadable({ onDecline }: { onDecline: () => void }) { * 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. * - * What this screen asks for is authorisation and nothing beyond it. Whether the signed - * transaction is then handed back or sent is decided where it is sent, and a button here that - * said so would be describing something this surface does not do. + * What this screen asks for is authorisation, and it says which of the two authorisations it + * is: handing the signed transaction back to the site, or sending it. The request states that + * and the screen repeats it, because a person agreeing to a signature that goes nowhere and a + * person agreeing to money moving are agreeing to different things. */ export function ProcessCtConfirmation({ data, @@ -244,7 +261,9 @@ export function ProcessCtConfirmation({

Perform a contract action?

- Nothing is signed until you agree, and what you agree to is what gets signed. + {data.broadcast + ? "Nothing is signed until you agree, and what you agree to is what gets signed and sent." + : "Nothing is signed until you agree, and what you agree to is what gets signed. This one is handed back to the site rather than sent."}

@@ -358,7 +377,7 @@ export function ProcessCtConfirmation({ Decline - Sign + {data.broadcast ? "Sign and send" : "Sign"} 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..509574e --- /dev/null +++ b/apps/extension/src/core/chains/liquid/application/methods/processConfidentialTransaction/index.test.ts @@ -0,0 +1,674 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +import p2pkManifest from "@humid/tx-manifest/fixtures/p2pk.manifest.json"; + +import { DENY_ALL_AUTHORIZATION } from "@/core/wallet-rpc/types"; + +import type { LiquidWalletAccount } from "../../backends/LiquidWalletBackend"; +import { + createProcessLiquidConfidentialTransaction, + type LiquidProcessCtContext, + type LiquidProcessCtDependencies, + type LiquidProcessCtResult, +} from "./index"; +import { PROCESS_CT_CONFIRMATION_KIND } from "./ProcessCtConfirmation"; + +/** + * The whole method, driven without a browser. + * + * Every seam it reaches through is substituted — the contract module, the chain reads, the key + * material, the broadcast — so what is asserted here is the method's own order and its own + * refusals rather than what any of those do. The manifest and the contract source are the + * published p2pk fixture, unmodified: the thinnest real protocol there is. + */ +const SOURCE_PATH = "./p2pk.simf"; +const SOURCE = readFileSync( + new URL( + "../../../../../../../../../packages/tx-manifest/src/__fixtures__/p2pk.simf", + import.meta.url, + ), + "utf8", +); +const PUBKEY = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; +const POLICY_ASSET = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; +const COVENANT_SCRIPT = `5120${"11".repeat(32)}`; +const WALLET_SCRIPT = `0014${"33".repeat(20)}`; +const SIGNER_SCRIPT = `0014${"77".repeat(20)}`; +const BLINDING_KEY = `02${"88".repeat(32)}`; +const FUNDING_TXID = "c".repeat(64); +const COVENANT_TXID = "e".repeat(64); +const SIGNED_TXID = "f".repeat(64); +const SENT_TXID = "a".repeat(64); +const FEE_SATS = 344n; + +/** + * The finished transaction, written as the bytes one actually is. + * + * The method checks what came back against what was agreed to by reading these bytes rather + * than by asking the module — a module's account of itself cannot answer whether it did + * something it was not asked to. So a placeholder here would let that check pass without + * seeing anything, which is the one thing this fixture exists to prevent. + */ +function txIn(txid: string, vout: number): string { + const reversed = (txid.match(/../g) ?? []).toReversed().join(""); + const index = ((vout >>> 0).toString(16).padStart(8, "0").match(/../g) ?? []) + .toReversed() + .join(""); + + return `${reversed}${index}00ffffffff`; +} + +/** One explicit output as the chain writes one: the asset reversed, then eight value bytes. */ +function txOut(scriptHex: string, sats: bigint, asset = POLICY_ASSET): string { + const reversed = (asset.match(/../g) ?? []).toReversed().join(""); + const length = (scriptHex.length / 2).toString(16).padStart(2, "0"); + + return `01${reversed}01${sats.toString(16).padStart(16, "0")}00${length}${scriptHex}`; +} + +/** + * One output whose amount and asset are commitments rather than numbers. + * + * The guard reads the finished transaction's own bytes, so an output the document wants hidden + * has to actually be written hidden here — a blinding key handed to the builder is a request, + * and whether it was applied is only visible in the encoding. + */ +function hiddenOut(scriptHex: string): string { + const length = (scriptHex.length / 2).toString(16).padStart(2, "0"); + + return `0a${"33".repeat(32)}08${"44".repeat(32)}02${"55".repeat(32)}${length}${scriptHex}`; +} + +function transaction(inputs: string[], outputs: string[]): string { + return ( + `0200000000${inputs.length.toString(16).padStart(2, "0")}${inputs.join("")}` + + `${outputs.length.toString(16).padStart(2, "0")}${outputs.join("")}00000000` + ); +} + +/** What Pay builds: the covenant output, the change the module appends, and the fee. */ +const SIGNED_HEX = transaction( + [txIn(FUNDING_TXID, 0)], + [txOut(COVENANT_SCRIPT, 1000n), txOut(SIGNER_SCRIPT, 998_656n), txOut("", FEE_SATS)], +); + +/** What the wallet's own scan reports, in the shape the backend hands over. */ +const explicitUtxo = { + address: "tex1q_signing", + amount: "1000000", + assetId: `liquid:testnet/elip144:${POLICY_ASSET}`, + confidential: false, + scriptPubKey: WALLET_SCRIPT, + scriptPubKeyHex: WALLET_SCRIPT, + spendable: true, + txid: FUNDING_TXID, + txOut: `01${"49".repeat(32)}0100000000000f424000160014${"33".repeat(20)}`, + vout: 0, +}; + +/** + * The covenant output, written the way the chain writes one. + * + * Built from the same asset, amount and script the chain reader below reports, rather than from + * arbitrary bytes: these are what the wallet hands the builder for the input it is spending, and + * a fixture whose bytes said something else would let the covenant path pass while carrying an + * output that has nothing to do with what the review established. + */ +const COVENANT_HELD_SATS = 50_000n; +const COVENANT_TXOUT = txOut(COVENANT_SCRIPT, COVENANT_HELD_SATS); + +type Journal = { + broadcasts: { txHex: string }[]; + /** Every covenant input the builder was given, with the values it is spent under. */ + covenantInputs: { + signatureWitness: string | undefined; + source: string; + txOutHex: string; + txid: string; + vout: number; + }[]; + /** What the signer hands back, so a case can return the transaction its own plan builds. */ + finalizedHex: string; + /** Every wasm handle taken and released, so a leak is visible rather than assumed. */ + freed: string[]; + mnemonicRequests: { accountGroupIndex?: number; keySourceId?: string }[]; + /** Whether the mnemonic was still reachable after the call that used it returned. */ + mnemonicHeldAfter: boolean; + steps: string[]; + taken: string[]; + /** Every ordinary wallet input the builder was given. */ + walletInputs: { txOutHex: string; txid: string; vout: number }[]; +}; + +function journal(): Journal { + return { + broadcasts: [], + covenantInputs: [], + finalizedHex: SIGNED_HEX, + freed: [], + mnemonicHeldAfter: false, + mnemonicRequests: [], + steps: [], + taken: [], + walletInputs: [], + }; +} + +/** + * A stand-in for the contract module, exact in the names and arities this method calls. + * + * A substitute that accepted anything could not notice a call the real module refuses, which is + * the whole reason it records what it was handed rather than only that it was called. + */ +function smplxSubstitute(log: Journal) { + class Covenant { + constructor( + readonly source: string, + readonly argumentsJson?: string, + readonly extraLeavesJson?: string, + readonly includeDebugSymbols?: boolean, + ) { + log.taken.push("covenant"); + } + address() { + return "tex1p_derived"; + } + scriptPubKeyHex() { + return COVENANT_SCRIPT; + } + free() { + log.freed.push("covenant"); + } + } + + class TransactionBuilder { + constructor() { + log.taken.push("builder"); + } + addChange() {} + addCovenantInput( + txid: string, + vout: number, + txOutHex: string, + source: string, + _argumentsJson?: string, + _witnessJson?: string, + signatureWitness?: string, + ) { + log.covenantInputs.push({ signatureWitness, source, txOutHex, txid, vout }); + } + addCovenantIssuanceInput() { + throw new Error("not reached"); + } + addOutput() {} + addWalletInput(txid: string, vout: number, txOutHex: string) { + log.walletInputs.push({ txOutHex, txid, vout }); + } + addWalletIssuanceInput() { + throw new Error("not reached"); + } + setLocktimeHeight() {} + setSequence() {} + free() { + log.freed.push("builder"); + } + } + + class WalletSigner { + constructor( + readonly mnemonic: string, + readonly network: string, + ) { + log.taken.push("signer"); + log.steps.push(`signer:${network}`); + } + blindingPublicKey() { + return BLINDING_KEY; + } + scriptPubKeyHex() { + return SIGNER_SCRIPT; + } + finalizeTransaction() { + log.taken.push("signed"); + + return { + feeSats: FEE_SATS, + free: () => { + log.freed.push("signed"); + }, + hex: log.finalizedHex, + txid: SIGNED_TXID, + }; + } + free() { + log.freed.push("signer"); + } + } + + return { + Covenant, + covenantParameterTypes: () => JSON.stringify({ PUB_KEY: "Pubkey" }), + TransactionBuilder, + WalletSigner, + }; +} + +const account = (overrides: Partial = {}) => + ({ + accountGroupIndex: 3, + accountIdentifier: "liquid:testnet:dwid", + chain: { id: "liquid:testnet" }, + chainId: "liquid:testnet", + descriptor: "ct(...)", + dwid: "dwid", + implementation: {}, + policyAssetId: `liquid:testnet/elip144:${POLICY_ASSET}`, + rawPolicyAssetId: POLICY_ASSET, + ...overrides, + }) as unknown as LiquidWalletAccount; + +function walletBackend(log: Journal, overrides: Record = {}) { + return { + getExplicitUtxos: (_account: unknown, asset: string) => + asset === POLICY_ASSET ? [explicitUtxo] : [], + getSigningAddress: () => ({ address: "tex1q_signing", index: 0 }), + getTipHeight: () => 3_210_987, + getUtxos: () => [], + syncAccount: async () => { + log.steps.push("sync"); + }, + ...overrides, + } as unknown as LiquidProcessCtContext["walletBackend"]; +} + +function context(log: Journal, overrides: Partial = {}) { + return { + authorization: DENY_ALL_AUTHORIZATION, + chain: { + id: "liquid:testnet", + settings: { backend: { url: "https://esplora.invalid" }, network: "testnet" }, + }, + confirm: async () => { + log.steps.push("confirm"); + + return true; + }, + keyManagerState: { keyrings: [] }, + walletBackend: walletBackend(log), + ...overrides, + } as unknown as LiquidProcessCtContext; +} + +function dependencies( + log: Journal, + overrides: Partial = {}, +): LiquidProcessCtDependencies { + return { + broadcastTransaction: async ({ txHex }) => { + log.steps.push("broadcast"); + log.broadcasts.push({ txHex }); + + return { txid: SENT_TXID }; + }, + loadSmplx: (async () => + smplxSubstitute(log)) as unknown as LiquidProcessCtDependencies["loadSmplx"], + readFeeRate: () => async () => 1000, + readTxOut: () => async () => ({ + amountSats: "50000", + rawAssetId: POLICY_ASSET, + scriptPubKeyHex: COVENANT_SCRIPT, + txOutHex: COVENANT_TXOUT, + }), + resolveAccount: (async () => + account()) as unknown as LiquidProcessCtDependencies["resolveAccount"], + scriptPubKeyHexOf: async () => WALLET_SCRIPT, + withMnemonic: (async ( + request: { accountGroupIndex?: number; keySourceId?: string }, + use: (mnemonic: string) => unknown, + ) => { + log.steps.push("mnemonic"); + log.mnemonicRequests.push({ + ...(request.accountGroupIndex === undefined + ? {} + : { accountGroupIndex: request.accountGroupIndex }), + ...(request.keySourceId === undefined ? {} : { keySourceId: request.keySourceId }), + }); + + let held: string | undefined = "abandon abandon about"; + const answer = await use(held); + + // Taken away again, which is the whole of what the callback shape buys: after this + // there is no handle a later caller could reach the credential through. + held = undefined; + log.mnemonicHeldAfter = held !== undefined; + + return answer; + }) as unknown as LiquidProcessCtDependencies["withMnemonic"], + ...overrides, + }; +} + +const payRequest = (broadcast = false) => ({ + action: "Pay", + broadcast, + contractSources: { [SOURCE_PATH]: SOURCE }, + manifest: p2pkManifest, + params: { amount_sat: 1000, pubkey: PUBKEY }, +}); + +const receiveRequest = (broadcast = false) => ({ + action: "Receive", + broadcast, + contractSources: { [SOURCE_PATH]: SOURCE }, + manifest: p2pkManifest, + params: { pubkey: PUBKEY }, + state: { utxos: [{ txid: COVENANT_TXID, utxo_type: "p2pk_output", vout: 0 }] }, +}); + +async function run( + request: unknown, + options: { + context?: Partial; + dependencies?: Partial; + } = {}, +) { + const log = journal(); + const method = createProcessLiquidConfidentialTransaction( + dependencies(log, options.dependencies), + ); + + return { + log, + result: (await method(request, context(log, options.context))) as LiquidProcessCtResult, + }; +} + +async function failing( + request: unknown, + options: Parameters[1] = {}, +): Promise<{ data: unknown; log: Journal; message: string }> { + const log = journal(); + const method = createProcessLiquidConfidentialTransaction( + dependencies(log, options.dependencies), + ); + + try { + await method(request, context(log, options.context)); + } catch (error) { + const thrown = error as { data?: unknown; message: string }; + + return { data: thrown.data, log, message: thrown.message }; + } + + throw new Error("The method was expected to refuse and did not."); +} + +describe("the order a contract action happens in", () => { + // The review is what establishes that each contract is the one the site describes. It runs + // before the gate deliberately: a standing permission skips the prompt, and if the check sat + // behind the prompt it would be skipped with it. + test("reviews before it asks, and signs only after agreement", async () => { + const { log } = await run(payRequest()); + + expect(log.steps.filter((step) => step !== "signer:liquid-testnet")).toEqual([ + "sync", + "confirm", + "mnemonic", + ]); + }); + + test("reviews even when a standing permission means nobody is asked", async () => { + const { log } = await run(payRequest(), { + context: { authorization: { isGranted: () => true } }, + }); + + expect(log.steps).toContain("sync"); + expect(log.steps).not.toContain("confirm"); + }); + + test("does not acquire the mnemonic when the person declines", async () => { + const log = journal(); + const method = createProcessLiquidConfidentialTransaction(dependencies(log)); + + await expect( + method(payRequest(), context(log, { confirm: async () => false })), + ).rejects.toThrow(); + expect(log.steps).not.toContain("mnemonic"); + expect(log.taken).not.toContain("signer"); + }); + + // What the person is shown says which of the two authorisations is being asked for, because + // a signature handed back and a signature broadcast are different things to agree to. + test("tells the confirmation whether this will be sent", async () => { + const shown: unknown[] = []; + const log = journal(); + const method = createProcessLiquidConfidentialTransaction(dependencies(log)); + + await method( + payRequest(true), + context(log, { + confirm: async (request) => { + shown.push(request.data); + + return true; + }, + }), + ); + + expect(shown[0]).toMatchObject({ broadcast: true, kind: PROCESS_CT_CONFIRMATION_KIND }); + }); +}); + +describe("what comes back", () => { + test("hands back the signed transaction and reaches no network when broadcast is off", async () => { + const { log, result } = await run(payRequest(false)); + + expect(result).toMatchObject({ + broadcast: false, + feeSats: "344", + transactionHex: SIGNED_HEX, + txid: SIGNED_TXID, + }); + expect(log.broadcasts).toEqual([]); + }); + + test("sends it and answers with the network's own txid when broadcast is on", async () => { + const { log, result } = await run(payRequest(true)); + + expect(result).toMatchObject({ broadcast: true, transactionHex: SIGNED_HEX, txid: SENT_TXID }); + expect(log.broadcasts).toEqual([{ txHex: SIGNED_HEX }]); + }); + + // The deployment outlives the transaction, and half its fields are functions of outputs the + // wallet chose. A caller working them out again afterwards would be guessing which. + test("carries no deployment for an action that creates none", async () => { + const { result } = await run(payRequest()); + + expect(result.deployment).toBeUndefined(); + }); +}); + +describe("what it refuses, and how", () => { + test("refuses a request it cannot read as a structured invalid-params error", async () => { + const { data, message } = await failing({ action: 7 }); + + expect(message).toBeTruthy(); + expect(data).toMatchObject({ reason: "invalid_manifest_request" }); + }); + + // 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" would have to parse English. + test("carries the review's own reject token beside the sentence", async () => { + const { data, message } = await failing({ + ...receiveRequest(), + state: { utxos: [] }, + }); + + expect(message).toContain("state file"); + expect(data).toMatchObject({ reason: "invalid_manifest_request", reject: "no-utxo-to-spend" }); + }); + + test("refuses when the chain says something else is at the covenant's outpoint", async () => { + const { data } = await failing(receiveRequest(), { + dependencies: { + readTxOut: () => async () => ({ + amountSats: "50000", + rawAssetId: POLICY_ASSET, + scriptPubKeyHex: `0014${"99".repeat(20)}`, + txOutHex: COVENANT_TXOUT, + }), + }, + }); + + expect(data).toMatchObject({ reject: "covenant-mismatch" }); + }); + + test("refuses a network the contract module does not support", async () => { + const { message } = await failing(payRequest(), { + context: { + chain: { + id: "liquid:other", + settings: { backend: { url: "https://esplora.invalid" }, network: "elsewhere" }, + }, + } as unknown as Partial, + }); + + expect(message).toContain("elsewhere"); + }); + + test("refuses before the gate, so nothing is shown for an action it will not build", async () => { + const { log } = await failing({ ...receiveRequest(), state: { utxos: [] } }); + + expect(log.steps).not.toContain("confirm"); + }); +}); + +describe("what it holds, and for how long", () => { + // Every one of these is wasm memory. A collector that does not know it holds any will not + // release them, and a refused action would leak a signer and a transaction. + test("releases every handle it took, on the path that succeeds", async () => { + const { log } = await run(payRequest()); + + expect(log.freed.toSorted()).toEqual(log.taken.toSorted()); + expect(log.freed).toContain("signer"); + expect(log.freed).toContain("builder"); + expect(log.freed).toContain("signed"); + }); + + test("releases the signer and the builder when finalising throws", async () => { + const log = journal(); + const module = smplxSubstitute(log); + + module.WalletSigner.prototype.finalizeTransaction = () => { + throw new Error("could not balance the transaction"); + }; + + const method = createProcessLiquidConfidentialTransaction( + dependencies(log, { + loadSmplx: (async () => module) as unknown as LiquidProcessCtDependencies["loadSmplx"], + }), + ); + + await expect(method(payRequest(), context(log))).rejects.toThrow(); + expect(log.freed).toContain("signer"); + expect(log.freed).toContain("builder"); + }); + + test("keeps no handle on the mnemonic once the call that used it has returned", async () => { + const { log } = await run(payRequest()); + + expect(log.mnemonicHeldAfter).toBe(false); + }); + + // A session may authorise a group whose seed is not the local root. A signer built without + // the source this account was resolved against signs with the wrong key — a valid signature, + // over a transaction a person approved for a different account. + test("asks for the mnemonic of exactly the account and key source that were resolved", async () => { + const { log } = await run(payRequest(), { + dependencies: { + resolveAccount: (async () => + account({ + accountGroupIndex: 4, + keySourceId: "keysource:hardware-1" as LiquidWalletAccount["keySourceId"], + })) as unknown as LiquidProcessCtDependencies["resolveAccount"], + }, + }); + + expect(log.mnemonicRequests).toEqual([ + { accountGroupIndex: 4, keySourceId: "keysource:hardware-1" }, + ]); + }); + + test("asks against the local root when the account was resolved against it", async () => { + const { log } = await run(payRequest()); + + expect(log.mnemonicRequests).toEqual([{ accountGroupIndex: 3 }]); + }); +}); + +/** + * The Receive action end to end, which is the path the covenant work exists for. + * + * Every other case here holds one seam still. This one runs the public method over a real + * covenant spend — the state file names an outpoint, the chain reader answers with the output's + * own bytes, the review verifies the covenant against them, the person approves, and the + * assembler drives the builder — so it is the only thing that says the review's covenant facts + * and the assembler's builder calls are wired to each other rather than merely each correct. + */ +describe("spending a covenant, end to end", () => { + /** What the builder is expected to be handed, and therefore what it must give back. */ + const receiveTransaction = (outputs: string[]) => + transaction([txIn(COVENANT_TXID, 0), txIn(FUNDING_TXID, 0)], outputs); + + test("carries the covenant and the wallet's fee input into the builder", async () => { + const log = journal(); + + // The reclaimed funds are hidden — the document says nothing about that output and this + // network's silence means hidden — and the change is published, which is this wallet's + // own override so the next action of a protocol can be funded from it. + log.finalizedHex = receiveTransaction([ + hiddenOut(WALLET_SCRIPT), + txOut(SIGNER_SCRIPT, 999_656n), + txOut("", FEE_SATS), + ]); + + const method = createProcessLiquidConfidentialTransaction(dependencies(log)); + const result = (await method(receiveRequest(false), context(log))) as LiquidProcessCtResult; + + // The covenant, with the bytes the chain reader answered with and the witness the + // document says a signature goes in. The source is the contract the request supplied, + // which is what the review compiled and compared against the chain — so this is the + // join: what the review established reaches the builder unchanged. + expect(log.covenantInputs).toEqual([ + { + signatureWitness: "SIGNATURE", + source: SOURCE, + txOutHex: COVENANT_TXOUT, + txid: COVENANT_TXID, + vout: 0, + }, + ]); + + // And the wallet's own output beside it, which is what pays the fee. A covenant spend + // that reached the builder without this would be a transaction with nothing to charge. + expect(log.walletInputs).toEqual([ + { txOutHex: explicitUtxo.txOut, txid: FUNDING_TXID, vout: 0 }, + ]); + + // It got as far as a finished transaction, which means every guard between the plan and + // the bytes agreed: what was spent, what was paid, and which of it was hidden. + expect(result).toMatchObject({ + broadcast: false, + feeSats: FEE_SATS.toString(), + transactionHex: log.finalizedHex, + txid: SIGNED_TXID, + }); + expect(log.steps).toContain("confirm"); + expect(log.broadcasts).toEqual([]); + // Nothing of the wasm is still held: the covenant compiles, the signer and the builder + // are all handles, and a covenant spend takes more of them than any other path. + expect(log.freed.toSorted()).toEqual(log.taken.toSorted()); + }); +}); 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..cb343fd 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,387 @@ +import { SMPLX_COMPILER_VERSION } from "@humid/smplx-compiler"; +import { + createEsploraFeeRateReader, + createEsploraTxOutReader, + isRefusal, + type ManifestReview, + type ParsedLiquidProcessCtParams, + parseLiquidProcessCtParams, + type ReadFeeRate, + type ReadTxOut, + reviewManifestAction, + toShownConfirmation, +} from "@humid/tx-manifest"; + import { createWalletMethod } from "@/core/wallet-methods/createWalletMethod"; -import { WalletRpcNotImplementedError } from "@/core/wallet-rpc/errors"; -import type { WalletRpcBaseContext } from "@/core/wallet-rpc/types"; +import { WALLET_RPC_ERROR_REASONS, WalletRpcInvalidParamsError } from "@/core/wallet-rpc/errors"; +import { toScriptPubKeyHex } from "../../../adapters/lwk/wallet/toScriptPubKeyHex"; +import { withAccountMnemonic } from "../../../adapters/lwk/wallet/withAccountMnemonic"; +import { assembleReviewedTransaction } from "../../../adapters/smplx/assembleReviewedTransaction"; +import { + createSmplxContractParamTypes, + createSmplxCovenantCompiler, + createSmplxScriptPubKeyCompiler, +} from "../../../adapters/smplx/compileCovenantWithSmplx"; +import { loadSmplxWasm } from "../../../adapters/smplx/loadSmplxWasm"; +import type { LiquidChainRecord } from "../../../chains/LiquidChainRecord"; import { LIQUID_WALLET_RPC_METHODS } from "../../../domain/LiquidRpc"; +import type { LiquidWalletAccount } from "../../backends/LiquidWalletBackend"; +import { resolveDappAccount } from "../../dappAccountScope"; +import type { LiquidRpcMethodContext } from "../../LiquidRpcContext"; +import { PROCESS_CT_CONFIRMATION_KIND } from "./ProcessCtConfirmation"; + +export type LiquidProcessCtContext = LiquidRpcMethodContext; + +export type LiquidProcessCtResult = { + /** Whether this transaction was sent, which is what the request asked for. */ + 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; this is where it can still be read. + */ + deployment?: Record; + /** What the network charged, as a string: this crosses a bus that cannot carry a bigint. */ + 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", +}; + +/** + * Everything the method reaches outside itself. + * + * Named as one object so the whole seam — parse, review, confirm, 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 in a browser, which is why nothing did. + */ +export type LiquidProcessCtDependencies = { + /** + * Sends a finished transaction, which is the one step that reaches the network. + * + * Loaded lazily where it is wired below rather than imported at the top, because the + * sync-worker client reaches for `webextension-polyfill` and that throws outside an + * extension — and nothing else on this path needs a browser. + */ + 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; + /** + * Runs one function with the account's mnemonic and takes it away again afterwards. + * + * A callback rather than a getter, and that is the whole of its safety: there is no handle + * a later caller could reach the credential through, and nothing on this path holds one + * outside the single call that signs. + */ + withMnemonic: typeof withAccountMnemonic; +}; + +/** How the method is wired in the extension. Tests substitute only what they exercise. */ +export const liquidProcessCtDependencies: LiquidProcessCtDependencies = { + 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, +}; /** - * 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. + * 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 script + * 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 review cannot establish comes back as a refusal carrying a token beside its + * sentence, and both cross to the caller: the sentence is for a person and the token is for a + * program, and a caller handed only the first has to parse English to tell "this wallet will + * never build that" from "your state file is out of date". + * + * A factory rather than a constant so the whole of it can be driven without a browser. The + * registered method is one instance of it, wired to the real dependencies. */ -export const processLiquidConfidentialTransaction = createWalletMethod< - null, - WalletRpcBaseContext, - null, - never ->({ - confirmation: () => ({ - data: { - kind: "liquid.processConfidentialTransaction", +export const createProcessLiquidConfidentialTransaction = ( + dependencies: LiquidProcessCtDependencies = liquidProcessCtDependencies, +) => + createWalletMethod< + ParsedLiquidProcessCtParams, + LiquidProcessCtContext, + ManifestReview, + LiquidProcessCtResult + >({ + confirmation: ({ params, review }) => ({ + data: { + // What the request asked for, so the screen can say whether agreeing sends this or + // hands it back. Two different things to agree to, and one button for both would + // be describing something this surface does not do. + 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 one. + 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); + 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: the plan is driven as it stands + // and the document is not read again, so there is no second resolution that could + // disagree with the first. + const assembled = await dependencies.withMnemonic( + { + ...(account.accountGroupIndex === undefined + ? {} + : { accountGroupIndex: account.accountGroupIndex }), + chain: context.chain, + keyManagerState: context.keyManagerState, + // The source this account was actually resolved against, not the local root by + // default. A session may authorise a group whose seed is a different one, and a + // signer built without this signs with the wrong key — a valid signature over a + // transaction a person approved for a different account. + ...(account.keySourceId === undefined ? {} : { keySourceId: account.keySourceId }), + }, + async (mnemonic) => { + const signer = new smplx.WalletSigner(mnemonic, network); + + try { + return await assembleReviewedTransaction(review, { + // A public key rather than a signer: hiding an output needs only the + // blinding key of the address it pays to, and the assembler still holds + // no credential of any kind. + blindingPublicKeyHex: signer.blindingPublicKey(), + // Where change goes is the wallet's own business and the review has no + // say in it. Left unset the module returns change to whichever address + // the signer happens to derive, which is a wallet decision made + // somewhere the wallet cannot see. + changeScriptPubKeyHex: signer.scriptPubKeyHex(), + finalize: (builder, feeRateSatsPerKvb) => { + // Narrowed back to the concrete handle. `AssemblingBuilder` is the + // structural surface the assembler drives, which is what lets a + // substitute stand in for the module in a test; the signer takes the + // module's own class. Only this direction is a cast, and only because + // a structural type cannot prove it is the very object the builder + // constructor above made — which it is, since nothing else made one. + const result = signer.finalizeTransaction( + builder as InstanceType, + feeRateSatsPerKvb, + ); + + // Read out and released here rather than handed back as a handle: it is + // wasm memory, and the only thing that knows when it is finished with + // it is the call that made it. + try { + return { feeSats: result.feeSats, hex: result.hex, txid: result.txid }; + } finally { + result.free(); + } + }, + // The module's own constructor, handed over uncast. That is the point: + // `AssemblingBuilder` states the methods this path calls, so assigning the + // real constructor to it is the compile-time proof that the shipped module + // has `addCovenantInput`, `addCovenantIssuanceInput`, `setLocktimeHeight` + // and `setSequence` under those names and those arguments. A cast here + // would suppress exactly the check the type exists for, and a wasm build + // that had drifted from the pinned SDK would compile and fail at the call. + smplx, + }); + } finally { + signer.free(); + } + }, + ); + + // A refusal from the assembler is not a thing a person can be asked about: by this + // point the document has been read, the action resolved and the person has already + // approved. What failed is the agreement between this wallet and the module underneath + // it, and nothing is returned rather than a transaction with a note attached. + if (!assembled.ok) { + throw new WalletRpcInvalidParamsError( + assembled.reason, + { reject: assembled.reject }, + WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, + ); + } + + const { feeSats, hex, txid } = assembled.transaction; + const signed = { feeSats: feeSats.toString(), transactionHex: hex, txid }; + // 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, { + accountLabel: accountLabelOf(context, account), + // One compiled contract, two spellings of where the covenant is, from the adapter that + // owns that pairing: deriving them from separate compiles is how an output came to + // be paid to a bech32 string, since the builder hex-decodes what it is given and an + // address is not hex. Reached for rather than repeated, because a second wiring of + // the same module is a second place the four build inputs can be dropped from. + compile: createSmplxCovenantCompiler(smplx), + // The version this wallet's shipped module compiles with, checked against the one a + // protocol declares. One constant, guarded against the submodule it describes, so + // the extension and the dapp's own inspector cannot answer the question differently. + compilerVersion: SMPLX_COMPILER_VERSION, + // 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, so the + // compiler is the only thing that can say — and it can say it from the source alone. + contractParamTypes: createSmplxContractParamTypes(smplx), + // 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 — 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, + policyAsset: account.rawPolicyAssetId, + // 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 same compiler again, for the covenant hashes a document works out for itself. 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 — so the same adapter answers both, and the network is bound rather than + // asked for, because a script's bytes do not depend on one. + scriptPubKeyOf: createSmplxScriptPubKeyCompiler(smplx, network), + // 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)) { + throw new WalletRpcInvalidParamsError( + result.reason, + { reject: result.reject }, + WALLET_RPC_ERROR_REASONS.INVALID_MANIFEST_REQUEST, + ); + } + + return result; + }, + }); + +export const processLiquidConfidentialTransaction = createProcessLiquidConfidentialTransaction(); + +/** + * 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, ); - }, - id: LIQUID_WALLET_RPC_METHODS.PROCESS_CONFIDENTIAL_TRANSACTION, - parse: () => null, - review: () => null, -}); + } + + return parsed.request; +} + +/** + * How the wallet names the account that is acting, in the wallet's own terms. + * + * Shown because it is otherwise the one thing on that screen nobody stated: the wallet chose it + * by choosing the outputs, and a person approving a contract action is entitled to know which of + * their accounts is about to pay for it. + */ +function accountLabelOf(context: LiquidProcessCtContext, account: LiquidWalletAccount): string { + return `${account.chain?.id ?? context.chain.id} account ${account.accountGroupIndex ?? 0}`; +} + +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, + ); + } + + 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..39ae066 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: (accountGroupId?: string) => 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/wallet-rpc/errors.ts b/apps/extension/src/core/wallet-rpc/errors.ts index 2e2a97e..21c9cbd 100644 --- a/apps/extension/src/core/wallet-rpc/errors.ts +++ b/apps/extension/src/core/wallet-rpc/errors.ts @@ -18,6 +18,10 @@ 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", + // One code for every way a contract action is refused. Which way it was is the `reject` + // token beside it, because a caller telling "this wallet will never build that" from "your + // state file is out of date" cannot do it by parsing an English sentence. + 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/notification/index.tsx b/apps/extension/src/notification/index.tsx index f9512c9..ccb7e8e 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,15 @@ 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. Unregistered, the host has no body for the +// contract action's kind and a person is asked to approve a blank screen. 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/contractIdentityQueryKey.test.ts b/apps/extension/src/routes/App/pages/Home/pages/Receive/contractIdentityQueryKey.test.ts new file mode 100644 index 0000000..aa75d23 --- /dev/null +++ b/apps/extension/src/routes/App/pages/Home/pages/Receive/contractIdentityQueryKey.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; + +import type { AccountGroupId } from "@/core/accounts/application/account-registry/model/identifiers"; + +import { contractIdentityQueryKey } from "./contractIdentityQueryKey"; + +/** + * What the contract identity is cached under. + * + * The answer is held forever — it is a function of a key that does not change — so the key is + * the whole of what decides whether a person is shown their own address or the last one read. + * The address is rendered for one network: `tex1…` on testnet and `ex1…` on mainnet are the same + * key written two ways, and only one of them can be funded on the chain that is selected. + */ +const GROUP = "account-group:one" as AccountGroupId; +const OTHER_GROUP = "account-group:two" as AccountGroupId; + +describe("what the contract identity is cached under", () => { + test("names the chain as well as the account", () => { + expect(contractIdentityQueryKey(GROUP, "liquid:testnet")).toEqual([ + "contractIdentity", + GROUP, + "liquid:testnet", + ]); + }); + + // The failure this exists for: one account, two chains. Cached under the account alone and + // kept forever, switching chains serves the previous network's address out of the cache — + // and that address is what somebody then funds a contract action from. + test("separates one account's two chains", () => { + expect(contractIdentityQueryKey(GROUP, "liquid:testnet")).not.toEqual( + contractIdentityQueryKey(GROUP, "liquid:mainnet"), + ); + }); + + test("separates two accounts on one chain", () => { + expect(contractIdentityQueryKey(GROUP, "liquid:testnet")).not.toEqual( + contractIdentityQueryKey(OTHER_GROUP, "liquid:testnet"), + ); + }); + + test("is the same key for the same pair, so the read happens once", () => { + expect(contractIdentityQueryKey(GROUP, "liquid:testnet")).toEqual( + contractIdentityQueryKey(GROUP, "liquid:testnet"), + ); + }); +}); diff --git a/apps/extension/src/routes/App/pages/Home/pages/Receive/contractIdentityQueryKey.ts b/apps/extension/src/routes/App/pages/Home/pages/Receive/contractIdentityQueryKey.ts new file mode 100644 index 0000000..2000187 --- /dev/null +++ b/apps/extension/src/routes/App/pages/Home/pages/Receive/contractIdentityQueryKey.ts @@ -0,0 +1,23 @@ +import type { AccountGroupId } from "@/core/accounts/application/account-registry/model/identifiers"; + +/** + * What the contract identity is cached under. + * + * A module of its own rather than a literal inside the hook, so the cache's identity is something + * that can be asserted about. There is no hook-test harness in this project — no renderer and no + * query client in a test — and the hook itself reaches the background through + * `webextension-polyfill`, which throws outside an extension. So a key written inline is a + * decision nothing could hold to. + * + * It is worth holding to. The answer is kept forever, because it is a function of a key that does + * not change; the address half of it, though, is rendered for one network — `tex1…` on testnet + * and `ex1…` on mainnet are the same key written two ways. Cached under the account alone, + * switching chains serves the previous network's address out of the cache, and that address is + * what somebody then funds a contract action from. + */ +export function contractIdentityQueryKey( + accountGroupId: AccountGroupId, + chainId: string, +): [string, AccountGroupId, string] { + return ["contractIdentity", accountGroupId, chainId]; +} 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..d1de734 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,28 @@ +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, + // Named here rather than left to the background's own selection: the background reads the + // selected chain either way, and what this decides is which cached answer is this one. + chainId: chain.id, + enabled: contractOpened, + }); if (query.isPending) { return ( @@ -36,6 +48,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..11cd2ee --- /dev/null +++ b/apps/extension/src/routes/App/pages/Home/pages/Receive/useContractIdentity.ts @@ -0,0 +1,36 @@ +import { useQuery } from "@tanstack/react-query"; + +import type { AccountGroupId } from "@/core/accounts/application/account-registry/model/identifiers"; +import { readLiquidContractIdentity } from "@/core/chains/liquid/contractIdentityClient"; + +import { contractIdentityQueryKey } from "./contractIdentityQueryKey"; + +/** + * The address and key contract actions are signed with, for one account on one chain. + * + * 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. + * + * Keyed by the chain as well as the account, because the address is rendered for one network — + * `tex1…` on testnet and `ex1…` on mainnet are the same key written two ways. Kept forever under + * the account alone, switching chains would serve the previous network's address out of the + * cache, which is what somebody then funds a contract action from. The background still reads the + * selected chain; what the chain is doing here is naming which answer this is. + * + * The key itself does not vary by network, so the two halves of the answer age differently — but + * they arrive together and only one identity can be cached, so the shorter-lived half decides. + */ +export function useContractIdentity(keys: { + accountGroupId: AccountGroupId; + chainId: string; + enabled: boolean; +}) { + return useQuery({ + enabled: keys.enabled, + queryFn: () => readLiquidContractIdentity(keys.accountGroupId), + queryKey: contractIdentityQueryKey(keys.accountGroupId, keys.chainId), + // The identity is a function of the account's key and the chain it is rendered for, and + // changes under neither — so once read for a pair, it is read once. + staleTime: Infinity, + }); +} diff --git a/apps/extension/tsconfig.confirmation.json b/apps/extension/tsconfig.confirmation.json deleted file mode 100644 index 799cf85..0000000 --- a/apps/extension/tsconfig.confirmation.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "//": "The contract-action confirmation surface, checked on its own under the app's own compiler rules. It extends the root project rather than restating a subset of its options: a copied subset drifts, and a surface checked under settings the app does not use has been checked against something nobody ships. What is overridden is only what is read — `include` is emptied and `files` names these two plus the ambient declaration that makes `bun:test` resolvable, so this reads them and what they import and not the whole app, whose baseline dependency-family failures this slice's gate deliberately skips. That exclusion is temporary and the root gate is restored in the activation slice.", - "extends": "../../tsconfig.json", - "compilerOptions": { - "types": ["bun-types", "react", "react-dom"] - }, - "include": [], - "files": [ - "src/bun-test-env.d.ts", - "src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.tsx", - "src/core/chains/liquid/application/methods/processConfidentialTransaction/ProcessCtConfirmation.test.tsx" - ] -} diff --git a/apps/web/package.json b/apps/web/package.json index cc48b75..7d5bf2f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,14 +6,14 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "typecheck": "tsc --noEmit", - "typecheck:tooling": "tsc -p tsconfig.tooling.json --noEmit --pretty false", + "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", @@ -36,7 +36,7 @@ }, "devDependencies": { "@types/node": "^24.10.1", - "@types/react": "^19.2.5", + "@types/react": "^19.2.16", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", "typescript": "~5.9.3", 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..267b791 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. Receive → Unconfidential 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 the one the wallet's ordinary + receive screen shows. To spend what Pay locks, this must be the wallet's own + contract key — HUMID → Receive → Unconfidential. */} + + + {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/manifest/index.test.tsx b/apps/web/src/app/manifest/index.test.tsx index 84ddd0a..f087de4 100644 --- a/apps/web/src/app/manifest/index.test.tsx +++ b/apps/web/src/app/manifest/index.test.tsx @@ -42,13 +42,12 @@ describe("the inspector with nothing around it", () => { expect(renderToStaticMarkup()).toContain("Load the p2pk example"); }); - // The one thing the page asks for, and it opens without an answer: a default here would be a - // guess that decides whether a document is refused. - test("asks which SimplicityHL version, and opens with none given", () => { + // It no longer asks. The compiler version is one constant this repository ships and the + // extension reads the same one, so a box here could only disagree with the wallet — and + // left blank, as it opened, it reported a check as unrun that the wallet could answer. + test("does not ask which SimplicityHL version, because it reads the shipped one", () => { const html = renderToStaticMarkup(); - expect(html).toContain("SimplicityHL version"); - expect(html).toContain("Not given"); - expect(html).toContain("reported as not run"); + expect(html).not.toContain("SimplicityHL version"); }); }); diff --git a/apps/web/src/app/manifest/index.tsx b/apps/web/src/app/manifest/index.tsx index 7062791..cbaa1fa 100644 --- a/apps/web/src/app/manifest/index.tsx +++ b/apps/web/src/app/manifest/index.tsx @@ -3,8 +3,6 @@ import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { ConstructTable } from "./components/ConstructTable"; @@ -32,15 +30,18 @@ import { readDocument } from "./readDocument"; * It connects to nothing. There is no wallet here, no chain read and no request, which is both * the point and the limit. * - * The compiler version and the contract sources are asked for in the input card rather than - * reported as results, because that is what they are: this page holds no wallet, so it holds - * neither the version one ships nor the sources a document references. Unanswered is a real - * state and the one this opens in — a check needing one of them is reported as not run, which + * The compiler version is no longer asked for. It is one constant this repository ships and the + * extension reads the same one, so a field here could only disagree with the wallet — and left + * blank, as it opened, it reported a check as not run that the wallet could have answered. + * + * The contract sources are still asked for in the input card rather than reported as results, + * because that is what they are: a compiler version is declared twice and the second + * declaration lives inside the source, which this page has no way to fetch. Unanswered is a + * real state and the one this opens in — the check needing them is reported as not run, which * is not the same as passing. */ export default function ManifestInspector() { const [text, setText] = useState(""); - const [compilerVersion, setCompilerVersion] = useState(""); const [suppliedSources, setSuppliedSources] = useState([]); // Read twice, because a file arrives under the name it has on a disk and the reader wants it @@ -48,20 +49,17 @@ export default function ManifestInspector() { // 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, { compilerVersion }); + const referenced = readDocument(text); const byReferencedPath = matchContractSources( referenced.kind === "read" && referenced.ok ? referenced.contracts : [], suppliedSources, ); return { - document: readDocument(text, { - compilerVersion, - contractSources: byReferencedPath.sources, - }), + document: readDocument(text, { contractSources: byReferencedPath.sources }), matched: byReferencedPath, }; - }, [text, compilerVersion, suppliedSources]); + }, [text, suppliedSources]); return (
@@ -74,22 +72,6 @@ export default function ManifestInspector() { -
- - setCompilerVersion(event.target.value)} - placeholder="Not given" - spellCheck={false} - className="w-72 font-mono" - /> -

- The single version a reading wallet ships. This page holds no wallet, so there is - nothing here to read it from — and left blank, the compiler check is reported as not - run rather than answered against a stand-in. -

-