aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-04 09:46:54 -0700
committerAdam Soltys <[email protected]>2026-08-04 09:46:54 -0700
commit399a100248792fe8d6aaf19849eab3ee3baa1984 (patch)
tree6afb67e1801997f63eb278c384b1d424bed4371c /src
parente462bc1e7756dcbf7c651ee282e96ee8c48b5a14 (diff)
downloadcoinosv3-399a100248792fe8d6aaf19849eab3ee3baa1984.tar.gz
coinosv3-399a100248792fe8d6aaf19849eab3ee3baa1984.zip
The Nostr card owns its built-in account; the exported key opens this wallet anywhere
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01M5kFodm9azZJVTQxnkDdKe
Diffstat (limited to 'src')
-rw-r--r--src/app.js1
-rw-r--r--src/features/nostrlogin.js77
-rw-r--r--src/i18n.js8
-rw-r--r--src/nostr.js4
4 files changed, 84 insertions, 6 deletions
diff --git a/src/app.js b/src/app.js
index e68fc52..1a89ac7 100644
--- a/src/app.js
+++ b/src/app.js
@@ -2572,6 +2572,7 @@ function tabsBar() {
ui.tab = id;
ui.sendError = ''; // a stale send error never survives a tab change
ui.revealShown = false; // re-mask the recovery phrase whenever tabs change
+ ui.nostrExportStep = false; // and the exported nostr key
ui.txDetail = null; // back to the history list when leaving/returning
ui.arkMoveDetail = null;
ui.giftDetail = null;
diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js
index 222b516..4c28ac5 100644
--- a/src/features/nostrlogin.js
+++ b/src/features/nostrlogin.js
@@ -9,7 +9,7 @@
// published, because it means the nostr key can recover the money.
import { newMnemonic } from '../wallet.js';
-import { npubOf } from '../nostr.js';
+import { npubOf, nsecOf } from '../nostr.js';
import { t } from '../i18n.js';
import { qrSvg } from '../qr.js';
import {
@@ -289,6 +289,68 @@ export function nostrLoginFeature(ctx) {
} catch (e) { fail(e); }
}
+ // Reveal the seed-derived nostr key. A self-backup is published FIRST so
+ // the promise on the reveal screen is true: pasting this nsec into any
+ // coinos login finds the backup and opens THIS wallet, instead of
+ // key-deriving a fresh empty one (walletForSigner checks relays before
+ // deriving).
+ async function exportWalletKey() {
+ ui.nostrLoginError = '';
+ busy(true);
+ try {
+ const sk = wallet.nostr && wallet.nostr.sk;
+ if (!sk || !wallet.mnemonic) throw new Error(t('nlNeedSeed'));
+ await publishWalletBackup(keySigner(sk), { mnemonic: wallet.mnemonic, passphrase: wallet.passphrase || '' });
+ ui.nostrExportStep = 'shown';
+ busy(false);
+ } catch (e) { fail(e); }
+ }
+
+ // The built-in key, behind the same two-step gate as the recovery phrase:
+ // a warning you have to act on before anything secret is in the DOM.
+ function exportSection() {
+ const sk = wallet.nostr && wallet.nostr.sk;
+ if (!sk) return null;
+ if (ui.nostrExportStep === 'shown') {
+ const nsec = nsecOf(sk);
+ return h('div', { class: 'col', style: 'gap:8px' },
+ h('div', { class: 'addr-box break' }, nsec),
+ h('p', { class: 'small faint', style: 'margin:0' }, t('nlExportedNote')),
+ h('div', { class: 'row gap6' },
+ copyBtn(nsec, t('copy')),
+ h('button', { class: 'btn-sm grow', onClick: () => { ui.nostrExportStep = false; render(); } }, t('hide'))));
+ }
+ if (ui.nostrExportStep === 'warn') {
+ return h('div', { class: 'col', style: 'gap:8px' },
+ h('div', { class: 'warn-box' }, t('nlExportWarn')),
+ ui.nostrLoginError ? h('div', { class: 'notice err' }, ui.nostrLoginError) : null,
+ h('div', { class: 'row gap6' },
+ h('button', { class: 'btn-primary grow', disabled: ui.nostrLoginBusy, onClick: exportWalletKey },
+ ui.nostrLoginBusy ? h('span', { class: 'spinner' }) : t('nlExportReveal')),
+ h('button', { class: 'btn-sm', onClick: () => { ui.nostrExportStep = false; ui.nostrLoginError = ''; render(); } }, t('cancel'))));
+ }
+ return h('button', {
+ class: 'btn-block', disabled: ui.nostrLoginBusy,
+ onClick: () => { ui.nostrExportStep = 'warn'; ui.nostrLoginError = ''; render(); },
+ }, t('nlExportBtn'));
+ }
+
+ function linkSection() {
+ if (!ui.nostrLinkOpen) {
+ return h('button', {
+ class: 'btn-block', disabled: ui.nostrLoginBusy,
+ onClick: () => { ui.nostrLinkOpen = true; ui.nostrLoginError = ''; render(); },
+ }, t('nlChangeBtn'));
+ }
+ return h('div', { class: 'col', style: 'gap:10px' },
+ h('div', { class: 'row between' },
+ h('strong', { class: 'small' }, t('nlChangeBtn')),
+ h('span', { class: 'linklike small', onClick: () => { ui.nostrLinkOpen = false; ui.nostrLoginError = ''; render(); } }, t('cancel'))),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('nlLinkDesc')),
+ h('div', { class: 'notice info small' }, t('nlBackupWarning')),
+ signerButtons(linkOpenWallet));
+ }
+
function settingsCard() {
if (wallet.watchOnly || !wallet.mnemonic) return null;
const st = load();
@@ -306,11 +368,18 @@ export function nostrLoginFeature(ctx) {
h('p', { class: 'small muted', style: 'margin:0' }, t('nlReconnectDesc')),
signerButtons(reconnectSigner)));
}
+ // No external account linked — but the wallet still HAS a nostr account:
+ // the one derived from its seed, already signing chat and holding the
+ // payment address. A bare login form here read as "no account yet", so
+ // the built-in identity comes first and both exporting its key and
+ // switching to another account are explicit steps behind buttons.
+ const npub = wallet.nostrNpub && wallet.nostrNpub();
return h('div', { class: 'card col' },
h('h3', {}, t('nlLinkTitle')),
- h('p', { class: 'small muted', style: 'margin:0' }, t('nlLinkDesc')),
- h('div', { class: 'notice info small' }, t('nlBackupWarning')),
- signerButtons(linkOpenWallet));
+ npub ? h('div', { class: 'small muted break' }, npub) : null,
+ h('p', { class: 'small muted', style: 'margin:0' }, t('nlBuiltinDesc')),
+ exportSection(),
+ linkSection());
}
return {
diff --git a/src/i18n.js b/src/i18n.js
index fa0a48f..3295358 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -579,7 +579,13 @@ const STR = {
nlCreateBtn: 'Create my wallet',
nlCreated: 'Wallet created and linked',
nlLinkTitle: 'Nostr account',
- nlLinkDesc: 'Link this wallet to your Nostr account so you can open it on other devices by logging in with Nostr.',
+ nlLinkDesc: 'Log in with your own Nostr account instead — chat and your payment address will use its identity, and logging in with it on another device opens this wallet.',
+ nlBuiltinDesc: 'This wallet has a built-in Nostr account, derived from its seed. Chat and your payment address already use it — you don’t need to link anything.',
+ nlExportBtn: 'Export Nostr key',
+ nlExportWarn: 'This key opens this wallet. Anyone who has it can spend your money, read your chats, and change your payment address — guard it like your recovery phrase.',
+ nlExportReveal: 'Show the key',
+ nlExportedNote: 'Logging in with Nostr using this key opens this wallet.',
+ nlChangeBtn: 'Use a different Nostr account',
nlLinkedDesc: 'This wallet opens when you log in with this Nostr account.',
nlConnected: 'connected',
nlDisconnected: 'not connected',
diff --git a/src/nostr.js b/src/nostr.js
index cf3cc88..98515fe 100644
--- a/src/nostr.js
+++ b/src/nostr.js
@@ -13,7 +13,7 @@ import * as nip06 from 'nostr-tools/nip06';
import * as nip44 from 'nostr-tools/nip44';
import * as nip04 from 'nostr-tools/nip04';
import { getPublicKey, finalizeEvent, generateSecretKey } from 'nostr-tools/pure';
-import { decode as nip19decode, npubEncode } from 'nostr-tools/nip19';
+import { decode as nip19decode, npubEncode, nsecEncode } from 'nostr-tools/nip19';
import { SimplePool } from 'nostr-tools/pool';
import { randomBytes } from '@noble/hashes/utils';
import { base64urlnopad } from '@scure/base';
@@ -112,6 +112,8 @@ export function parseNostrPubkey(input) {
}
// An npub or 64-hex nostr pubkey → hex, or null if it isn't one.
export function npubOf(pkHex) { try { return npubEncode(pkHex); } catch { return null; } }
+// A raw secret key → nsec, for the one place that deliberately exports it.
+export function nsecOf(sk) { try { return nsecEncode(sk); } catch { return null; } }
// Profiles live across the network, so look them up on popular relays (broader
// than the sync relays).