aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-04 09:10:04 -0700
committerAdam Soltys <[email protected]>2026-07-04 09:10:04 -0700
commit11b2a56a320bbd66b98991fd08478d154ff6c717 (patch)
tree035e64613eda8ea99cee6c7d7de352ed6bbdf2fe
parent5fb48ee5284dbba34c8fa3b77c36379f49404ac2 (diff)
downloadcoinosv3-11b2a56a320bbd66b98991fd08478d154ff6c717.tar.gz
coinosv3-11b2a56a320bbd66b98991fd08478d154ff6c717.zip
Build profiles: HAL_FEATURES selects which features ship
build.js swaps src/features/index.js for a generated module importing only the enabled features (gifts,swaps,ark,sp — unset means all, "none" means a plain on-chain wallet), so excluded feature code and its network endpoints never enter the bundle. Minimal profile: 476KB vs 577KB, with the Ark/gRPC, Boltz, and MuSig2 code verifiably absent. `bun run build:minimal` builds it. tools/minimal-smoke.js proves the minimal profile end-to-end: no receive-mode dropdown, no gift button, no swap/Ark/SP settings cards, no page errors, and a plain on-chain receive still lands. The two blessed profiles are full (everything, the existing e2e suites) and minimal (this smoke test); other combos should work but aren't CI'd. Gift and SP primitives still live inside wallet.js, so those bytes remain in the minimal bundle for now — extracting them is the next step if we want the core leaner still. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01QFAcD4V284DvYZZHP7Rxxy
-rw-r--r--build.js30
-rw-r--r--package.json7
-rw-r--r--src/app.js7
-rw-r--r--src/features/index.js16
-rw-r--r--tools/minimal-smoke.js99
5 files changed, 150 insertions, 9 deletions
diff --git a/build.js b/build.js
index 97744b1..8a0805b 100644
--- a/build.js
+++ b/build.js
@@ -136,11 +136,39 @@ export async function buildSpWorker({ minify = true } = {}) {
return result.outputs[0].text();
}
-export async function buildHtml({ minify = true, pwa = minify } = {}) {
+// Optional-feature selection: HAL_FEATURES is a comma list of enabled
+// features (gifts,swaps,ark,sp). Unset means all; "none"/"" means a minimal
+// on-chain-only wallet. A build plugin swaps src/features/index.js for a
+// generated module that only imports the enabled features, so a disabled
+// feature's code (and its network endpoints) never enters the bundle.
+const ALL_FEATURES = { gifts: 'giftsFeature', swaps: 'swapsFeature', ark: 'arkFeature', sp: 'spFeature' };
+
+export function enabledFeatures(spec = process.env.HAL_FEATURES) {
+ if (spec == null) return Object.keys(ALL_FEATURES);
+ return spec.split(',').map((x) => x.trim().toLowerCase()).filter((x) => x in ALL_FEATURES);
+}
+
+function featureIndexSource(enabled) {
+ return enabled.map((f) => `import { ${ALL_FEATURES[f]} } from './${f}.js';`).join('\n')
+ + `\nexport function buildFeatures(ctx) { return [${enabled.map((f) => `${ALL_FEATURES[f]}(ctx)`).join(', ')}]; }\n`;
+}
+
+const featurePlugin = (enabled) => ({
+ name: 'hal-features',
+ setup(b) {
+ b.onLoad({ filter: /src[\/\\]features[\/\\]index\.js$/ }, () => ({
+ contents: featureIndexSource(enabled),
+ loader: 'js',
+ }));
+ },
+});
+
+export async function buildHtml({ minify = true, pwa = minify, features = process.env.HAL_FEATURES } = {}) {
const result = await Bun.build({
entrypoints: ['./src/app.js'],
target: 'browser',
minify,
+ plugins: [featurePlugin(enabledFeatures(features))],
});
if (!result.success) {
for (const log of result.logs) console.error(log);
diff --git a/package.json b/package.json
index 0ca02a9..e861882 100644
--- a/package.json
+++ b/package.json
@@ -3,10 +3,11 @@
"private": true,
"version": "0.1.0",
"type": "module",
- "description": "Hal Wallet — offline-capable, single-file self-custody Bitcoin wallet for the browser, in memory of Hal Finney",
+ "description": "Hal Wallet \u2014 offline-capable, single-file self-custody Bitcoin wallet for the browser, in memory of Hal Finney",
"scripts": {
"dev": "bun run dev.js",
- "build": "bun run build.js"
+ "build": "bun run build.js",
+ "build:minimal": "HAL_FEATURES=none bun run build.js"
},
"dependencies": {
"@noble/ciphers": "^2.1.1",
@@ -21,4 +22,4 @@
"devDependencies": {
"puppeteer-core": "^25.1.0"
}
-}
+} \ No newline at end of file
diff --git a/src/app.js b/src/app.js
index 2c2a314..a7fed0f 100644
--- a/src/app.js
+++ b/src/app.js
@@ -9,10 +9,7 @@ import { qrSvg } from './qr.js';
import { scanQr } from './scan.js';
import { getSyncConfig, setSyncConfig, parseNostrPubkey, npubOf, fetchNostrProfile, decryptWithCode } from './nostr.js';
import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js';
-import { swapsFeature } from './features/swaps.js';
-import { giftsFeature } from './features/gifts.js';
-import { arkFeature } from './features/ark.js';
-import { spFeature } from './features/sp.js';
+import { buildFeatures } from './features/index.js';
import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js';
import {
fmtBtc,
@@ -2976,7 +2973,7 @@ const ctx = {
getAccounts: () => accounts,
claimTargets, enterWallet, activateAccount, commitAccount,
};
-const FEATURES = [giftsFeature, swapsFeature, arkFeature, spFeature].map((f) => f(ctx));
+const FEATURES = buildFeatures(ctx);
// apply text direction, then restore a wallet left open in this tab — otherwise
// show the unlock screen.
diff --git a/src/features/index.js b/src/features/index.js
new file mode 100644
index 0000000..590020a
--- /dev/null
+++ b/src/features/index.js
@@ -0,0 +1,16 @@
+// Which optional features this build includes. In dev (and by default in
+// builds) everything is on. Release builds can exclude features entirely:
+// build.js replaces this module's contents based on HAL_FEATURES, so a
+// disabled feature's code — and its network endpoints — never enters the
+// bundle. See featureIndexSource() in build.js.
+
+import { giftsFeature } from './gifts.js';
+import { swapsFeature } from './swaps.js';
+import { arkFeature } from './ark.js';
+import { spFeature } from './sp.js';
+
+// NB order is meaningful where hooks stack: gifts' receive takeover and
+// balance line come before ark's, matching the pre-plugin layout.
+export function buildFeatures(ctx) {
+ return [giftsFeature(ctx), swapsFeature(ctx), arkFeature(ctx), spFeature(ctx)];
+}
diff --git a/tools/minimal-smoke.js b/tools/minimal-smoke.js
new file mode 100644
index 0000000..ab31619
--- /dev/null
+++ b/tools/minimal-smoke.js
@@ -0,0 +1,99 @@
+// Smoke test for the minimal build profile (HAL_FEATURES=none): a plain
+// on-chain wallet with no swap/Ark/gift/SP surfaces — and still fully working
+// (imports a wallet, receives a payment) against the regtest stack.
+//
+// Usage: bun tools/minimal-smoke.js
+
+import { execSync } from 'node:child_process';
+import puppeteer from 'puppeteer-core';
+import { generateMnemonic } from '@scure/bip39';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { buildHtml } from '../build.js';
+
+const BCLI = `bitcoin-cli -regtest -datadir=${process.env.HOME}/ark-regtest/bitcoind -rpcport=18543 -rpcwallet=miner`;
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const sh = (cmd) => execSync(cmd, { shell: '/bin/bash' }).toString().trim();
+
+let ok = true;
+const check = (name, cond, detail = '') => {
+ console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`);
+ if (!cond) ok = false;
+};
+
+// --- build the minimal profile and serve it ---
+const html = await buildHtml({ minify: true, pwa: false, features: 'none' });
+const full = await buildHtml({ minify: true, pwa: false });
+console.log(`minimal bundle: ${(html.length / 1024).toFixed(0)}KB (full: ${(full.length / 1024).toFixed(0)}KB)`);
+check('meaningfully smaller than full', html.length < full.length * 0.9);
+check('no ark protocol code', !html.includes('Ark VTXO mailbox authorization'));
+check('no swap protocol code', !html.includes('swap/reverse'));
+check('no musig code', !html.includes('MuSig/noncecoef'));
+
+const server = Bun.serve({ port: 5198, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+const errs = [];
+page.on('pageerror', (e) => errs.push(e.message));
+const bodyText = () => page.evaluate(() => document.body.innerText);
+const waitText = async (text, ms = 20000) => {
+ for (let i = 0; i < ms / 250; i++) {
+ if ((await bodyText()).toLowerCase().includes(text.toLowerCase())) return true;
+ await sleep(250);
+ }
+ return false;
+};
+const clickText = (sel, text) => page.evaluate((s, x) => {
+ const el = [...document.querySelectorAll(s)].find((e) => e.textContent.trim().toLowerCase().includes(x.toLowerCase()));
+ if (el) { el.click(); return true; }
+ return false;
+}, sel, text);
+
+try {
+ await page.goto('http://localhost:5198', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => {
+ localStorage.setItem('btc-wallet-network', 'regtest');
+ localStorage.setItem('btc-wallet-source:regtest', JSON.stringify({ id: 'custom', url: 'http://localhost:30002' }));
+ localStorage.setItem('btc-wallet-ark-provider:regtest', 'local'); // must be ignored by this build
+ });
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+
+ console.log('\n[1] import + core wallet');
+ await clickText('button', 'Import existing');
+ await sleep(200);
+ await page.type('textarea', generateMnemonic(wordlist));
+ await clickText('button', 'Open wallet');
+ check('wallet opened', await waitText('receive', 15000));
+
+ console.log('\n[2] no feature surfaces');
+ await sleep(2500); // give any (wrongly present) feature time to appear
+ check('no receive-mode dropdown', await page.evaluate(() =>
+ ![...document.querySelectorAll('option')].some((o) => ['ln', 'ark', 'sp'].includes(o.value))));
+ await clickText('.tabs button', 'Send');
+ await sleep(300);
+ check('no gift button', !(await bodyText()).includes('Gift some Bitcoin'));
+ await clickText('.tabs button', 'Settings');
+ await sleep(300);
+ const settings = await bodyText();
+ check('no swap provider card', !settings.includes('Swap provider'));
+ check('no ark card', !/⚔ Ark/.test(settings));
+ check('no sp indexer card', !settings.includes('Silent payment indexer'));
+
+ console.log('\n[3] core receive still works');
+ await clickText('.tabs button', 'Receive');
+ await sleep(300);
+ const addr = await page.evaluate(() => document.querySelector('.addr-box')?.textContent.trim());
+ check('onchain address shown', /^bcrt1/.test(addr || ''), (addr || '').slice(0, 16) + '…');
+ sh(`${BCLI} sendtoaddress ${addr} 0.0004`);
+ sh(`${BCLI} generatetoaddress 1 $(${BCLI} getnewaddress) >/dev/null`);
+ check('payment lands', await waitText('40,000', 30000));
+ check('no page errors', errs.length === 0, errs.join(' | '));
+
+ console.log(ok ? '\n✅ SUCCESS: minimal profile is a working on-chain-only wallet'
+ : '\n❌ some checks failed');
+} finally {
+ await browser.close();
+ server.stop(true);
+}
+process.exit(ok ? 0 : 1);