aboutsummaryrefslogtreecommitdiffstats
path: root/src/app.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-24 16:39:55 -0700
committerAdam Soltys <[email protected]>2026-06-24 16:39:55 -0700
commite30c0623c28815a8249c32a79e28afbbc9e88633 (patch)
treec2a4ef59fbb4ceca29727f3e1aa85380c53566ce /src/app.js
parentd3199c42de3652f1e4b81a59b8b0756b9c2c4eb7 (diff)
downloadcoinosv3-e30c0623c28815a8249c32a79e28afbbc9e88633.tar.gz
coinosv3-e30c0623c28815a8249c32a79e28afbbc9e88633.zip
Fold watch-only into Import; support xprv spending import
Home screen is back to two tabs (Create / Import). The Import box now takes a recovery phrase, an xpub/zpub, or an xprv/zprv: - mnemonic → full wallet (as before) - xpub/zpub → watch-only (view/receive, no Send) - xprv/zprv → full spending wallet with no mnemonic. parseExtendedKey classifies + normalizes version bytes; account() derives the BIP84 path from a master xprv (depth 0) or uses an account-level xprv as-is; signing works (only watch-only blocks it). Vault persists xprv accounts too; Settings shows an "Imported key" note instead of a recovery card and hides Nostr sync (no seed). Public-key export still works. Note: a seed phrase cannot be derived from an xprv (one-way KDF), so an xprv wallet has no recovery words by design. New strings in all 19 locales. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'src/app.js')
-rw-r--r--src/app.js87
1 files changed, 33 insertions, 54 deletions
diff --git a/src/app.js b/src/app.js
index c77ee44..71e5185 100644
--- a/src/app.js
+++ b/src/app.js
@@ -4,7 +4,7 @@
// which rebuilds the active screen. Text inputs write back into `ui` on `input`
// (without re-rendering) so their values survive structural re-renders.
-import { Wallet, newMnemonic, isValidMnemonic, utxoId, previewGift, buildClaimTx, giftMinimum, parseAccountKey, xpubToZpub, encryptVault, decryptVault } from './wallet.js';
+import { Wallet, newMnemonic, isValidMnemonic, utxoId, previewGift, buildClaimTx, giftMinimum, parseExtendedKey, xpubToZpub, encryptVault, decryptVault } from './wallet.js';
import { qrSvg } from './qr.js';
import { scanQr } from './scan.js';
import { getSyncConfig, setSyncConfig } from './nostr.js';
@@ -261,10 +261,9 @@ function unlockScreen() {
'div',
{ class: 'tabs' },
tabBtn(t('createNew'), ui.unlockTab === 'create', () => { ui.unlockTab = 'create'; ui.unlockError = ''; render(); }),
- tabBtn(t('importExisting'), ui.unlockTab === 'import', () => { ui.unlockTab = 'import'; ui.unlockError = ''; render(); }),
- tabBtn(t('watchOnly'), ui.unlockTab === 'watch', () => { ui.unlockTab = 'watch'; ui.unlockError = ''; render(); })
+ tabBtn(t('importExisting'), ui.unlockTab === 'import', () => { ui.unlockTab = 'import'; ui.unlockError = ''; render(); })
),
- ui.unlockTab === 'create' ? createPane() : ui.unlockTab === 'import' ? importPane() : watchPane(),
+ ui.unlockTab === 'create' ? createPane() : importPane(),
ui.unlockError && h('div', { class: 'notice err' }, ui.unlockError)
),
ui.fromWallet && accounts.length
@@ -273,41 +272,6 @@ function unlockScreen() {
);
}
-function watchPane() {
- return h(
- 'div',
- { class: 'col', style: 'gap:12px; margin-top:14px' },
- h('p', { class: 'small muted', style: 'margin:0' }, t('watchOnlyDesc')),
- h('div', { class: 'field' },
- h('span', { class: 'lab' }, t('watchXpubLabel')),
- h('textarea', { rows: '3', placeholder: 'zpub… / xpub…', value: ui.watchXpub, onInput: (e) => (ui.watchXpub = e.target.value) })
- ),
- h('div', { class: 'field' },
- h('span', { class: 'lab' }, t('labelOptional')),
- h('input', { type: 'text', placeholder: defaultLabel('watch'), value: ui.watchLabel, onInput: (e) => (ui.watchLabel = e.target.value) })
- ),
- h('button', { class: 'btn-primary btn-block', onClick: addWatchOnly }, t('watchOnlyAdd'))
- );
-}
-
-function addWatchOnly() {
- ui.unlockError = '';
- let xpub;
- try {
- xpub = parseAccountKey(ui.watchXpub);
- } catch (e) {
- ui.unlockError = e.message;
- render();
- return;
- }
- const label = (ui.watchLabel || '').trim() || defaultLabel('watch');
- const acc = addOrGetAccount({ type: 'watch', label, xpub });
- ui.watchXpub = '';
- ui.watchLabel = '';
- ui.fromWallet = false;
- activateAccount(acc, { fresh: true });
-}
-
// ================================================================ HOW IT WORKS
function howItWorksScreen() {
const back = () => {
@@ -499,9 +463,9 @@ function importPane() {
h(
'label',
{ class: 'field' },
- h('span', { class: 'lab' }, t('seedPhrase')),
+ h('span', { class: 'lab' }, t('importLabel')),
h('textarea', {
- placeholder: t('seedPlaceholder'),
+ placeholder: t('importPlaceholder'),
autocapitalize: 'none',
autocomplete: 'off',
spellcheck: 'false',
@@ -534,15 +498,20 @@ function optionsPanel() {
);
}
-async function openWallet(mnemonic) {
+// Import accepts a recovery phrase, an xpub/zpub (watch-only), or an xprv/zprv
+// (full spending). Classify the pasted text and open the right kind of wallet.
+async function openWallet(input) {
ui.unlockError = '';
- const m = (mnemonic || '').trim().replace(/\s+/g, ' ');
- if (!isValidMnemonic(m)) {
- ui.unlockError = t('invalidSeed');
- render();
- return;
- }
- await enterWallet(m, ui.passphrase);
+ const raw = (input || '').trim();
+ const m = raw.replace(/\s+/g, ' ');
+ if (isValidMnemonic(m)) { await enterWallet(m, ui.passphrase); return; }
+ let pk;
+ try { pk = parseExtendedKey(raw); } catch { ui.unlockError = t('invalidImport'); render(); return; }
+ const acc = pk.kind === 'xpub'
+ ? addOrGetAccount({ type: 'watch', label: defaultLabel('watch'), xpub: pk.key })
+ : addOrGetAccount({ type: 'full', label: defaultLabel('full'), xprv: pk.key });
+ ui.fromWallet = false;
+ await activateAccount(acc, { fresh: true });
}
// Register a full (seed) wallet as an account and open it.
@@ -561,6 +530,7 @@ async function enterWallet(mnemonic, passphrase, opts = {}) {
async function activateAccount(acc, opts = {}) {
activeId = acc.id;
if (acc.type === 'watch') wallet.load({ xpub: acc.xpub, netName: 'mainnet', offline: false });
+ else if (acc.xprv) wallet.load({ xprv: acc.xprv, netName: 'mainnet', offline: false });
else wallet.load({ mnemonic: acc.mnemonic, passphrase: acc.passphrase || '', netName: 'mainnet', offline: false });
persistAccounts();
const hadCache = wallet.restoreCache(); // show last-known balance/history instantly
@@ -632,7 +602,7 @@ let accounts = [];
let activeId = null;
const genId = () => 'a' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
-const credId = (a) => (a.type === 'watch' ? 'w:' + a.xpub : 'f:' + a.mnemonic + '|' + (a.passphrase || ''));
+const credId = (a) => (a.type === 'watch' ? 'w:' + a.xpub : a.xprv ? 'x:' + a.xprv : 'f:' + a.mnemonic + '|' + (a.passphrase || ''));
const activeAccount = () => accounts.find((a) => a.id === activeId) || null;
function defaultLabel(type) {
@@ -726,7 +696,7 @@ function hasVault() { return !!loadVaultBlob(); }
function writeVault() {
if (vaultPassword == null) return;
const list = accounts.filter((a) => a.type === 'full' && a.persisted)
- .map((a) => ({ label: a.label, mnemonic: a.mnemonic, passphrase: a.passphrase || '' }));
+ .map((a) => (a.xprv ? { label: a.label, xprv: a.xprv } : { label: a.label, mnemonic: a.mnemonic, passphrase: a.passphrase || '' }));
try {
if (!list.length) localStorage.removeItem(VAULT_KEY);
else localStorage.setItem(VAULT_KEY, JSON.stringify(encryptVault(list, vaultPassword)));
@@ -735,7 +705,11 @@ function writeVault() {
function mergeVaultList(list) {
for (const v of list) {
- const acc = addOrGetAccount({ type: 'full', label: v.label || defaultLabel('full'), mnemonic: v.mnemonic, passphrase: v.passphrase || '' });
+ const acc = addOrGetAccount(
+ v.xprv
+ ? { type: 'full', label: v.label || defaultLabel('full'), xprv: v.xprv }
+ : { type: 'full', label: v.label || defaultLabel('full'), mnemonic: v.mnemonic, passphrase: v.passphrase || '' }
+ );
acc.persisted = true;
}
}
@@ -786,7 +760,7 @@ function unlockVault() {
try { list = decryptVault(loadVaultBlob(), ui.vaultPw); } catch { ui.vaultError = t('pwWrong'); render(); return; }
vaultPassword = ui.vaultPw;
accounts = list
- .map((v) => ({ id: genId(), type: 'full', label: v.label || defaultLabel('full'), mnemonic: v.mnemonic, passphrase: v.passphrase || '', persisted: true }))
+ .map((v) => ({ id: genId(), type: 'full', label: v.label || defaultLabel('full'), mnemonic: v.mnemonic || '', passphrase: v.passphrase || '', xprv: v.xprv || '', persisted: true }))
.concat(loadWatchAccounts());
ui.vaultPw = '';
ui.vaultError = '';
@@ -894,6 +868,11 @@ function settingsTab() {
h('h3', {}, t('watchOnly')),
h('p', { class: 'small muted', style: 'margin:0' }, t('watchOnlyNote'))
)
+ : !wallet.mnemonic
+ ? h('div', { class: 'card col' },
+ h('h3', {}, t('importedKey')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('importedKeyNote'))
+ )
: h(
'div',
{ class: 'card col' },
@@ -935,7 +914,7 @@ function settingsTab() {
wallet.scanning ? t('scanning') : t('rescanWallet'))
),
explorerCard(),
- wallet.watchOnly ? null : syncCard(),
+ wallet.watchOnly || !wallet.mnemonic ? null : syncCard(),
h(
'div',
{ class: 'card col' },