From 6b48de6168ff2cbf7164830f4185b978d70b5ce2 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Thu, 30 Jul 2026 21:17:06 -0400
Subject: [PATCH] Widget: offerNwcConnection() -- consent-gated offer-to-save
The inverse of requestNwcConnection: an app that obtained an NWC URI by
its own means (its own wallet chooser, its own paste box) offers the
user a portable copy. The credential is already in the app's hands, so
the only question on screen is whether a copy enters the user's vault
-- and that write happens in BitLogin's own chrome or not at all, never
silently, because a page that can pump records into someone's account
without a tap is a spam vector wearing a feature's clothes.
Dedupe by connection identity (wallet pubkey + secret; relays and
labels may drift without making a new connection -- sameNwcCredential
in core): a duplicate resolves 'already-saved' with a silent
origin-binding refresh and no UI, which is what makes the call safe to
fire after every successful connect. 'unavailable' (no session, no
vault root) is a quiet no-op by contract.
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
---
CHANGELOG.md | 7 ++
packages/core/src/vault/nwc.ts | 7 ++
packages/core/src/vault/vault.test.ts | 10 +-
packages/demo/public/docs.html | 9 ++
packages/demo/public/llms.txt | 6 ++
packages/widget/src/element.ts | 102 ++++++++++++++++++++-
packages/widget/src/worker/cryptoWorker.ts | 34 +++++++
packages/widget/src/worker/protocol.ts | 13 +++
packages/widget/src/worker/workerClient.ts | 6 +-
9 files changed, 191 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9128f99..ecbe159 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,13 @@ would plausibly want. Newest first.
widget now ships FIVE files (two extra lazy chunks) that must travel
together, and hosts using the wallet flow need `style-src-attr
'unsafe-inline'` in their CSP.
+- **Widget: `offerNwcConnection()` — offer-to-save for app-obtained
+ wallets.** The inverse of `requestNwcConnection`: an app that got an NWC
+ URI through its own chooser or paste box can offer the user a portable
+ copy. Consent-gated in the widget's own UI, never silent (the write goes
+ to the user's account); duplicates by wallet+secret resolve
+ `already-saved` with a silent origin-binding refresh and no UI, so apps
+ can fire it after every successful connect.
- **Connection Vault protocol layer in `@bitlogin/core`** (`src/vault/`):
encrypted per-connection records signed by a derived vault identity, the
finalized §CV6 key hierarchy with pinned test vectors, the NWC credential
diff --git a/packages/core/src/vault/nwc.ts b/packages/core/src/vault/nwc.ts
index a88b33d..ab8f1eb 100644
--- a/packages/core/src/vault/nwc.ts
+++ b/packages/core/src/vault/nwc.ts
@@ -90,6 +90,13 @@ export function toNwcUri(credential: NwcCredential): string {
return `nostr+walletconnect://${credential.wallet_pubkey}?${params.toString()}`;
}
+/** True when two credentials are the SAME connection: same wallet service,
+ * same client secret. Relays/labels/lud16 may drift without making a new
+ * connection — the secret is the identity of the grant. */
+export function sameNwcCredential(a: NwcCredential, b: NwcCredential): boolean {
+ return a.wallet_pubkey === b.wallet_pubkey && a.secret === b.secret;
+}
+
export function validateNwcCredential(credential: unknown): asserts credential is NwcCredential {
const c = credential as Partial | null;
const fail = (message: string) => {
diff --git a/packages/core/src/vault/vault.test.ts b/packages/core/src/vault/vault.test.ts
index 5359af2..82ca976 100644
--- a/packages/core/src/vault/vault.test.ts
+++ b/packages/core/src/vault/vault.test.ts
@@ -18,7 +18,7 @@ import {
deriveVaultPublicKey,
newConnectionId
} from "./derivation.js";
-import { parseNwcUri, toNwcUri, NwcParseError } from "./nwc.js";
+import { parseNwcUri, toNwcUri, sameNwcCredential, NwcParseError } from "./nwc.js";
import {
buildConnectionRecordEvent,
decryptConnectionRecordEvent,
@@ -132,6 +132,14 @@ describe("NWC profile (nwc-connections.md §5-§6)", () => {
expect(parseNwcUri(toNwcUri(credential))).toEqual(credential);
});
+ it("identity of a connection is wallet + secret, not relays or labels", () => {
+ const a = parseNwcUri(URI);
+ const b = { ...a, relays: ["wss://relay.other"], lud16: null };
+ const c = { ...a, secret: "ef".repeat(32) };
+ expect(sameNwcCredential(a, b)).toBe(true);
+ expect(sameNwcCredential(a, c)).toBe(false);
+ });
+
it("refuses missing relay, missing secret, bad pubkey, and non-websocket relays", () => {
const base = `nostr+walletconnect://${"ab".repeat(32)}`;
expect(() => parseNwcUri(`${base}?secret=${"cd".repeat(32)}`)).toThrow(NwcParseError);
diff --git a/packages/demo/public/docs.html b/packages/demo/public/docs.html
index f4dd3cb..6e94037 100644
--- a/packages/demo/public/docs.html
+++ b/packages/demo/public/docs.html
@@ -125,6 +125,15 @@ if (uri) payWith(uri); // full nostr+walletconnect:// URI, or null if declined
stored as an encrypted record on the user's account. A bitlogin-connection-granted event
fires on the element after each share, and users manage or revoke grants from the widget dashboard's
"Wallet connections" screen.
+
The inverse direction exists too: if your app obtained a connection by its own means (its own
+ wallet chooser, its own paste box), offer the user a portable copy —
The widget shows its own consent screen ("Save this wallet to your BitLogin?") and never saves
+ silently — the write goes to the user's account, so it happens in BitLogin's chrome or not at all.
+ Duplicates (same wallet + secret) resolve already-saved with no UI, so it's safe to
+ fire after every successful connect. unavailable means nobody is signed in or the
+ account has no vault; treat it as a no-op.
This is reveal mode, and the consent screen says so. Your origin receives the full
bearer credential and everything its wallet-side budget allows — an embedded same-origin widget is
diff --git a/packages/demo/public/llms.txt b/packages/demo/public/llms.txt
index bc0a154..56665f2 100644
--- a/packages/demo/public/llms.txt
+++ b/packages/demo/public/llms.txt
@@ -56,6 +56,12 @@ deployment above.
mode: the origin receives the full bearer credential — wallet-side budgets
are the real security boundary. Requires `style-src-attr 'unsafe-inline'`
in the host CSP. Fires `bitlogin-connection-granted` after each share.
+- `offerNwcConnection(uri, { appName, label })` on the element — the inverse:
+ the app obtained an NWC URI by its own means and offers the user a portable
+ copy. Consent screen in the widget's own UI, never a silent save; resolves
+ 'saved' | 'declined' | 'already-saved' (same wallet+secret exists; origin
+ binding refreshed, no UI) | 'unavailable' (no session/vault — treat as
+ no-op). Safe to fire after every successful connect.
- Events on the element: `bitlogin-login` (`detail.publicKey`),
`bitlogin-logout`, `bitlogin-signer-claimed`, `bitlogin-signer-released`
(the last also fires on `window` when triggered via
diff --git a/packages/widget/src/element.ts b/packages/widget/src/element.ts
index e4c180f..55414c6 100644
--- a/packages/widget/src/element.ts
+++ b/packages/widget/src/element.ts
@@ -32,7 +32,8 @@ type Screen =
| "rollback-confirm"
| "vault-consent"
| "vault-import"
- | "vault-manage";
+ | "vault-manage"
+ | "vault-offer";
interface ConfirmSlot {
index: number;
@@ -120,6 +121,14 @@ export class BitLoginAuthElement extends HTMLElement {
private vaultUnsaved = false;
private vaultUnsavedReason: "no-vault" | "stale-cache" | undefined;
private vaultConnections: VaultConnectionSummary[] | null = null;
+ /** offerNwcConnection state: the app already holds this URI; the only
+ * question on screen is whether a copy enters the user's vault. */
+ private vaultOffer: {
+ uri: string;
+ appName: string;
+ label: string;
+ resolve: (outcome: "saved" | "declined") => void;
+ } | null = null;
constructor() {
super();
@@ -246,6 +255,71 @@ export class BitLoginAuthElement extends HTMLElement {
});
}
+ /**
+ * Offer-to-save (the inverse of requestNwcConnection): the app OBTAINED an
+ * NWC URI by its own means — its own wallet chooser, its own paste box —
+ * and offers the user a portable copy. Consent-gated in this widget's own
+ * UI, never silent: the write goes to the user's account (encrypted events
+ * under their vault identity), and nothing enters or leaves the vault
+ * without the user seeing it happen in BitLogin's chrome.
+ *
+ * Resolves "saved", "declined", "already-saved" (same wallet + secret
+ * exists; its origin binding was refreshed, no UI shown), or "unavailable"
+ * (no session or no vault root — the offer is quietly impossible, and an
+ * app should treat that as a no-op rather than an error).
+ */
+ async offerNwcConnection(
+ uri: string,
+ options: { appName?: string; label?: string } = {}
+ ): Promise<"saved" | "declined" | "already-saved" | "unavailable"> {
+ if (this.vaultOffer || this.vaultRequest) return "unavailable";
+ const status = await this.worker.getSessionStatus().catch(() => ({ unlocked: false }));
+ if (!status.unlocked) return "unavailable";
+ const vaultStatus = await this.worker.vaultStatus();
+ if (!vaultStatus.enabled) return "unavailable";
+ const origin = window.location.origin;
+ const check = await this.worker.vaultOfferCheck({ uri, origin });
+ if (check.duplicate) return "already-saved";
+
+ const appName = options.appName?.trim() || window.location.hostname || "This app";
+ return new Promise((resolve) => {
+ this.vaultOffer = {
+ uri,
+ appName,
+ label: options.label?.trim() || `${appName} wallet`,
+ resolve
+ };
+ this.goto("vault-offer");
+ });
+ }
+
+ private finishVaultOffer(outcome: "saved" | "declined"): void {
+ const offer = this.vaultOffer;
+ if (!offer) return;
+ this.vaultOffer = null;
+ offer.resolve(outcome);
+ this.goto(this.session ? "dashboard" : "welcome");
+ }
+
+ private async acceptVaultOffer(): Promise {
+ const offer = this.vaultOffer;
+ if (!offer) return;
+ this.setBusy(true);
+ try {
+ const label = this.field("vaultOfferLabel").trim() || offer.label;
+ await this.worker.vaultSaveNwc({ uri: offer.uri, label, origin: window.location.origin });
+ this.setBusy(false);
+ this.finishVaultOffer("saved");
+ this.flashSuccess(this.screen, "Wallet saved");
+ this.dispatchEvent(
+ new CustomEvent("bitlogin-connection-granted", { detail: { origin: window.location.origin } })
+ );
+ } catch (err) {
+ this.setBusy(false);
+ this.fail(err);
+ }
+ }
+
/** Settles the pending request exactly once and returns to a neutral screen. */
private finishVaultRequest(uri: string | null): void {
const request = this.vaultRequest;
@@ -463,6 +537,10 @@ export class BitLoginAuthElement extends HTMLElement {
this.finishVaultRequest(null);
return;
}
+ if (screen === "welcome" && this.vaultOffer) {
+ this.finishVaultOffer("declined");
+ return;
+ }
this.screen = screen;
this.errorMessage = undefined;
this.render();
@@ -545,6 +623,11 @@ export class BitLoginAuthElement extends HTMLElement {
if (!target) return;
const action = target.dataset.action!;
switch (action) {
+ case "vault-offer-save":
+ return this.acceptVaultOffer();
+ case "vault-offer-decline":
+ this.finishVaultOffer("declined");
+ return;
case "vault-approve":
return this.approveVaultCandidate();
case "vault-different":
@@ -1414,6 +1497,23 @@ export class BitLoginAuthElement extends HTMLElement {
`;
}
+ case "vault-offer": {
+ const offer = this.vaultOffer;
+ if (!offer) return `
No wallet offer is in progress.
`;
+ return `
+
Save this wallet to your BitLogin?
+
${escapeHtml(offer.appName)} just connected a wallet on this device. Saved to your BitLogin, the connection follows you — on a new device it's one tap instead of another paste.
+ ${this.renderError()}
+
+
+
+
+
Stored encrypted on your account; ${escapeHtml(offer.appName)} keeps working either way. Remove it any time from Wallet connections.
+ `;
+ }
+
case "vault-manage": {
const list = this.vaultConnections;
const rows =
diff --git a/packages/widget/src/worker/cryptoWorker.ts b/packages/widget/src/worker/cryptoWorker.ts
index 16e7a41..4b78eb6 100644
--- a/packages/widget/src/worker/cryptoWorker.ts
+++ b/packages/widget/src/worker/cryptoWorker.ts
@@ -24,6 +24,7 @@ import {
VaultSession,
parseNwcUri,
toNwcUri,
+ sameNwcCredential,
validateNwcCredential,
type DecryptedConnectionRecord,
type NwcCredential
@@ -570,6 +571,39 @@ async function handle(action: string, payload: unknown): Promise {
return {};
}
+ case "vaultOfferCheck": {
+ // The offer-to-save flow's dedupe: the same wallet + secret already
+ // stored means nothing NEW would enter the vault, so no consent screen
+ // is warranted. The origin binding is refreshed silently — the offering
+ // origin already HOLDS the credential (it supplied it), so binding is
+ // bookkeeping for later one-tap approvals, not a new grant.
+ const p = payload as { uri: string; origin: string | null };
+ const credential = parseNwcUri(p.uri);
+ const { vault, connections } = await listLiveConnections();
+ const existing = connections.find(
+ (c) =>
+ c.record.connection_type === "nwc" &&
+ sameNwcCredential(c.record.credential as unknown as NwcCredential, credential)
+ );
+ if (!existing) return { duplicate: false };
+ if (p.origin !== null && existing.record.application_binding.origin !== p.origin) {
+ const { record, event } = await vault.updateConnection(existing, {
+ application_binding: { origin: p.origin, app_pubkey: null }
+ });
+ const publish = await vault.publish({
+ event,
+ connectionId: record.connection_id,
+ relayUrls: vaultRelayUrls,
+ store
+ });
+ if (publish.success) {
+ const decrypted = await vault.decryptEvent(event);
+ return { duplicate: true, connection: summarize(decrypted!) };
+ }
+ }
+ return { duplicate: true, connection: summarize(existing) };
+ }
+
default:
throw new Error(`Unknown worker action: ${action}`);
}
diff --git a/packages/widget/src/worker/protocol.ts b/packages/widget/src/worker/protocol.ts
index 4b796f2..fce7956 100644
--- a/packages/widget/src/worker/protocol.ts
+++ b/packages/widget/src/worker/protocol.ts
@@ -220,6 +220,18 @@ export interface VaultDeletePayload {
connectionId: string;
}
+export interface VaultOfferCheckPayload {
+ uri: string;
+ origin: string | null;
+}
+/** duplicate=true means the same wallet+secret is already stored; the worker
+ * has silently refreshed its origin binding, and no consent UI is needed —
+ * nothing new entered the vault. */
+export interface VaultOfferCheckResult {
+ duplicate: boolean;
+ connection?: VaultConnectionSummary;
+}
+
export type WorkerActionMap = {
configure: [ConfigurePayload, Record];
register: [RegisterPayload, RegisterResult];
@@ -248,6 +260,7 @@ export type WorkerActionMap = {
vaultRevealNwc: [VaultRevealNwcPayload, VaultRevealNwcResult];
vaultSetBinding: [VaultSetBindingPayload, VaultConnectionSummary];
vaultDelete: [VaultDeletePayload, Record];
+ vaultOfferCheck: [VaultOfferCheckPayload, VaultOfferCheckResult];
};
export type WorkerAction = keyof WorkerActionMap;
diff --git a/packages/widget/src/worker/workerClient.ts b/packages/widget/src/worker/workerClient.ts
index 6063e67..98a54e8 100644
--- a/packages/widget/src/worker/workerClient.ts
+++ b/packages/widget/src/worker/workerClient.ts
@@ -21,7 +21,8 @@ import type {
VaultFindForOriginPayload,
VaultRevealNwcPayload,
VaultSetBindingPayload,
- VaultDeletePayload
+ VaultDeletePayload,
+ VaultOfferCheckPayload
} from "./protocol.js";
export class WorkerClient {
@@ -141,6 +142,9 @@ export class WorkerClient {
vaultDelete(payload: VaultDeletePayload) {
return this.call("vaultDelete", payload);
}
+ vaultOfferCheck(payload: VaultOfferCheckPayload) {
+ return this.call("vaultOfferCheck", payload);
+ }
logout() {
return this.call("logout", {});
}