diff options
| -rw-r--r-- | build.js | 30 | ||||
| -rw-r--r-- | package.json | 7 | ||||
| -rw-r--r-- | src/app.js | 7 | ||||
| -rw-r--r-- | src/features/index.js | 16 | ||||
| -rw-r--r-- | tools/minimal-smoke.js | 99 |
5 files changed, 150 insertions, 9 deletions
@@ -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 @@ -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); |
