Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
@@ -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
126 changes: 0 additions & 126 deletions .github/workflows/tx-manifest-check.yml

This file was deleted.

2 changes: 1 addition & 1 deletion apps/extension/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "Humid",
"version": "1.0.0",
"version": "1.1.0-rc.0",
"private": true,
"type": "module",
"dependencies": {
Expand Down
43 changes: 43 additions & 0 deletions apps/extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
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,
Expand Down Expand Up @@ -113,7 +117,7 @@
/** Skip a background refresh if the cached snapshot was synced within this window. */
const BACKGROUND_REFRESH_MIN_INTERVAL_MS = 60_000;

const init = async () => {

Check warning on line 120 in apps/extension/src/background.ts

View workflow job for this annotation

GitHub Actions / check

unicorn(consistent-function-scoping)

Function `disconnectWalletConnect` does not capture any variables from its parent scope

Check warning on line 120 in apps/extension/src/background.ts

View workflow job for this annotation

GitHub Actions / check

unicorn(consistent-function-scoping)

Function `listWalletConnectSessions` does not capture any variables from its parent scope

Check warning on line 120 in apps/extension/src/background.ts

View workflow job for this annotation

GitHub Actions / check

unicorn(consistent-function-scoping)

Function `disconnectWalletConnect` does not capture any variables from its parent scope

Check warning on line 120 in apps/extension/src/background.ts

View workflow job for this annotation

GitHub Actions / check

unicorn(consistent-function-scoping)

Function `listWalletConnectSessions` does not capture any variables from its parent scope
const { eventBus, messageBus } = setupBackgroundTransport();

// Capture the event bus so the wallet-event broadcaster can push provider events (accountsChanged,
Expand Down Expand Up @@ -275,6 +279,44 @@
const getReceiveAddress = async (): Promise<ReceiveAddress> =>
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<LiquidContractIdentity> => {
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
Expand Down Expand Up @@ -392,7 +434,7 @@
if (!group) continue;

accountIds.push(
await liquidChainGroup.accountRuntime.resolveAccountIdentifier({

Check warning on line 437 in apps/extension/src/background.ts

View workflow job for this annotation

GitHub Actions / check

eslint(no-await-in-loop)

Unexpected `await` inside a loop.

Check warning on line 437 in apps/extension/src/background.ts

View workflow job for this annotation

GitHub Actions / check

eslint(no-await-in-loop)

Unexpected `await` inside a loop.
accountGroupId: group.id,
accountGroupIndex: group.groupIndex ?? 0,
chain,
Expand Down Expand Up @@ -580,6 +622,7 @@
getActivity,
getPortfolio,
getReceiveAddress,
readContractIdentity,
inspectTransfer,
purgeAccountPortfolio,
purgeAccountWalletConnectSessions,
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading