feat(wallet): Bitcoin Connect NWC "Connect wallet" flow [LOCAL — flag ON, do not push as-is]

Phase 0+1 of docs/bitcoin-connect-plan.md (#61). Adds Alby's Bitcoin Connect as a
one-tap NWC connect front-end that feeds bitvid's EXISTING wallet pipeline; manual
URI entry stays as the backup.

- Vendored: pinned @getalby/[email protected].3 bundled by
  scripts/build-bitcoin-connect.mjs → vendor/bitcoin-connect.bundle.min.js (esbuild,
  browser ESM). crypto-shim aliases the L402-only `import('crypto')` bitvid never
  calls. `npm run build:bitcoin-connect` rebuilds it.
- Flag: FEATURE_BITCOIN_CONNECT wired like FEATURE_AUDIO_INGEST
  (instance-config → config → constants).
- UI: a "Connect wallet" button in the Wallet Connect pane (hidden unless the flag
  is on); the manual URI field stays below as the backup.
- Controller: ProfileWalletController.handleBitcoinConnect() lazy-imports the bundle
  (only on click — the ~700KB never touches the main bundle), inits NWC-only with
  least-privilege requestMethods and persistConnection:false, then on onConnected
  extracts provider.client.nostrWalletConnectUrl → the SAME validate→save path as
  the manual field → disconnect() so bitvid solely holds the spending secret. The
  URI is never logged.

NOTE: config/instance-config.js FEATURE_BITCOIN_CONNECT is TRUE for local testing
only. Reset to false (shipping default) before pushing/deploying. Local commit;
not pushed, not deployed.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-09 14:49:37 -04:00
1 parent 6dd40ce330
commit 69e87c6e15
12 files changed
+1663 -3

No files matched your search

+2
View File
@@ -26,6 +26,7 @@ import {
ALLOW_NSFW_CONTENT,
FEATURE_NIP71_INGEST,
FEATURE_AUDIO_INGEST,
FEATURE_BITCOIN_CONNECT,
CARD_LIVENESS_POLICY,
LIVENESS_PROBE_PREFETCH_MARGIN,
DEFAULT_TRUST_SEED_NPUBS,
@@ -123,6 +124,7 @@ export const ADMIN_COMMUNITY_BLACKLIST_PREFIX = "community:blacklist";
export { ALLOW_NSFW_CONTENT };
export { FEATURE_NIP71_INGEST };
export { FEATURE_AUDIO_INGEST };
export { FEATURE_BITCOIN_CONNECT };
export { CARD_LIVENESS_POLICY };
export { LIVENESS_PROBE_PREFETCH_MARGIN };
export { DEFAULT_TRUST_SEED_NPUBS };
+10
View File
@@ -8,6 +8,7 @@ import {
DEFAULT_PLAYBACK_START_TIMEOUT,
FEATURE_NIP71_INGEST as CONFIG_FEATURE_NIP71_INGEST,
FEATURE_AUDIO_INGEST as CONFIG_FEATURE_AUDIO_INGEST,
FEATURE_BITCOIN_CONNECT as CONFIG_FEATURE_BITCOIN_CONNECT,
CARD_LIVENESS_POLICY as CONFIG_CARD_LIVENESS_POLICY,
LIVENESS_PROBE_PREFETCH_MARGIN as CONFIG_LIVENESS_PROBE_PREFETCH_MARGIN,
IS_DEV_MODE,
@@ -199,6 +200,9 @@ const DEFAULT_FLAGS = Object.freeze({
// Default OFF ("off = no trace") — only an explicit `true` in the instance
// config lights it up.
FEATURE_AUDIO_INGEST: CONFIG_FEATURE_AUDIO_INGEST === true,
// Bitcoin Connect NWC "Connect wallet" flow (docs/bitcoin-connect-plan.md, #61).
// Default OFF; only an explicit `true` in the instance config enables it.
FEATURE_BITCOIN_CONNECT: CONFIG_FEATURE_BITCOIN_CONNECT === true,
FEATURE_HASHTAG_PREFERENCES: false,
FEATURE_TRUST_SEEDS: true, // Rollback: disable to drop baseline trust seeds without code changes.
FEATURE_TRUSTED_HIDE_CONTROLS: true,
@@ -226,6 +230,7 @@ const runtimeFlags = (() => {
FEATURE_NIP71_MIRROR: DEFAULT_FLAGS.FEATURE_NIP71_MIRROR,
FEATURE_NIP71_INGEST: DEFAULT_FLAGS.FEATURE_NIP71_INGEST,
FEATURE_AUDIO_INGEST: DEFAULT_FLAGS.FEATURE_AUDIO_INGEST,
FEATURE_BITCOIN_CONNECT: DEFAULT_FLAGS.FEATURE_BITCOIN_CONNECT,
FEATURE_HASHTAG_PREFERENCES: DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES,
FEATURE_TRUST_SEEDS: DEFAULT_FLAGS.FEATURE_TRUST_SEEDS,
FEATURE_TRUSTED_HIDE_CONTROLS: DEFAULT_FLAGS.FEATURE_TRUSTED_HIDE_CONTROLS,
@@ -276,6 +281,11 @@ export let FEATURE_AUDIO_INGEST = coerceBoolean(
DEFAULT_FLAGS.FEATURE_AUDIO_INGEST
);
export let FEATURE_BITCOIN_CONNECT = coerceBoolean(
runtimeFlags.FEATURE_BITCOIN_CONNECT,
DEFAULT_FLAGS.FEATURE_BITCOIN_CONNECT
);
export let FEATURE_HASHTAG_PREFERENCES = coerceBoolean(
runtimeFlags.FEATURE_HASHTAG_PREFERENCES,
DEFAULT_FLAGS.FEATURE_HASHTAG_PREFERENCES
@@ -1,6 +1,13 @@
import { devLogger } from "../../utils/logger.js";
import { FEATURE_BITCOIN_CONNECT } from "../../constants.js";
import { showConfirm } from "../confirmDialog.js";
import { NWC_URI_SCHEME } from "../profileModalContract.js";
// Vendored, pinned Bitcoin Connect bundle (scripts/build-bitcoin-connect.mjs).
// Lazy-imported only when the user clicks "Connect wallet", so its ~700KB never
// weighs on the main bundle. See docs/bitcoin-connect-plan.md.
const BITCOIN_CONNECT_BUNDLE_URL =
"../../../vendor/bitcoin-connect.bundle.min.js";
import { createWalletSyncService } from "../../services/walletSyncService.js";
import { runWalletZappabilityCheck } from "./walletZappabilityCheck.js";
import {
@@ -32,6 +39,11 @@ export class ProfileWalletController {
this.walletSyncRestoreBtn = null;
this.walletSyncStatus = null;
this._walletSyncService = null;
this.walletBitcoinConnectRow = null;
this.walletBitcoinConnectButton = null;
this._bitcoinConnectModulePromise = null;
this._bitcoinConnectInitialized = false;
}
cacheDomReferences() {
@@ -46,9 +58,27 @@ export class ProfileWalletController {
this.walletSyncToggle = document.getElementById("walletSyncToggle") || null;
this.walletSyncRestoreBtn = document.getElementById("walletSyncRestoreBtn") || null;
this.walletSyncStatus = document.getElementById("walletSyncStatus") || null;
this.walletBitcoinConnectRow =
document.getElementById("profileWalletBitcoinConnectRow") || null;
this.walletBitcoinConnectButton =
document.getElementById("profileWalletBitcoinConnect") || null;
// Backwards compatibility alias if needed by tests/external code
this.mainController.profileWalletStatusText = this.walletStatusText;
this.applyBitcoinConnectVisibility();
}
// Reveal the "Connect wallet" button only when the deployment enables the flag.
// Off ⇒ the row stays hidden and the vendored bundle is never imported.
applyBitcoinConnectVisibility() {
if (!(this.walletBitcoinConnectRow instanceof HTMLElement)) {
return;
}
this.walletBitcoinConnectRow.classList.toggle(
"hidden",
FEATURE_BITCOIN_CONNECT !== true,
);
}
registerEventListeners() {
@@ -90,6 +120,15 @@ export class ProfileWalletController {
});
}
if (
FEATURE_BITCOIN_CONNECT === true &&
this.walletBitcoinConnectButton instanceof HTMLElement
) {
this.walletBitcoinConnectButton.addEventListener("click", () => {
void this.handleBitcoinConnect();
});
}
if (this.walletSyncToggle instanceof HTMLElement) {
this.walletSyncToggle.addEventListener("change", () => {
void this.handleToggleSync();
@@ -521,6 +560,104 @@ export class ProfileWalletController {
return { valid: true, sanitized: value };
}
// Lazy-load + one-time init of the vendored Bitcoin Connect bundle. Restricts
// the modal to NWC and requests only the methods bitvid actually calls. Does
// NOT let Bitcoin Connect persist the connection — bitvid owns the secret.
async ensureBitcoinConnectModule() {
if (this._bitcoinConnectModulePromise) {
return this._bitcoinConnectModulePromise;
}
this._bitcoinConnectModulePromise = (async () => {
const bc = await import(BITCOIN_CONNECT_BUNDLE_URL);
if (!this._bitcoinConnectInitialized) {
bc.init({
appName: "bitvid",
filters: ["nwc"],
persistConnection: false,
providerConfig: {
nwc: {
authorizationUrlOptions: {
requestMethods: [
"pay_invoice",
"get_balance",
"make_invoice",
"lookup_invoice",
],
},
},
},
});
// One persistent handler: extract the NWC URI, hand it to the SAME
// validate→save path as the manual field, then disconnect so bitvid is
// the only holder of the spending secret. NEVER log the URI.
bc.onConnected(async (provider) => {
const uri =
typeof provider?.client?.nostrWalletConnectUrl === "string"
? provider.client.nostrWalletConnectUrl
: "";
try {
if (uri && this.walletUriInput instanceof HTMLElement) {
this.setSecretInputValue(this.walletUriInput, uri);
await this.handleWalletSave();
} else if (!uri) {
this.updateWalletStatus(
"That wallet didn't expose an NWC connection. Paste one manually below.",
"error",
);
}
} catch (error) {
devLogger.error("[wallet] Bitcoin Connect save failed", error);
} finally {
try {
bc.disconnect();
} catch (disconnectError) {
devLogger.warn(
"[wallet] Bitcoin Connect disconnect failed",
disconnectError,
);
}
try {
bc.closeModal?.();
} catch (closeError) {
// non-fatal
}
}
});
this._bitcoinConnectInitialized = true;
}
return bc;
})().catch((error) => {
// Reset so a later click can retry a failed load.
this._bitcoinConnectModulePromise = null;
throw error;
});
return this._bitcoinConnectModulePromise;
}
async handleBitcoinConnect() {
if (FEATURE_BITCOIN_CONNECT !== true || this.isWalletBusy()) {
return;
}
let bc;
try {
bc = await this.ensureBitcoinConnectModule();
} catch (error) {
devLogger.error("[wallet] Failed to load Bitcoin Connect", error);
this.updateWalletStatus(
"Couldn't load the wallet connector. Try again, or paste a connection manually.",
"error",
);
this.mainController.showError?.("Couldn't load the wallet connector.");
return;
}
try {
bc.launchModal();
} catch (error) {
devLogger.error("[wallet] Bitcoin Connect launch failed", error);
this.updateWalletStatus("Couldn't open the wallet connector.", "error");
}
}
async handleWalletSave() {
const { uri, defaultZap, error } = this.getWalletFormValues();
const context = {