feat(wallet-main): dedicated AddWalletFlow + retire the beta banner

Two user-directed changes:

1. "+ Add / Import wallet" now opens a step-by-step wizard, not the
   whole Settings page.

   NEW shells/app/src/screens/AddWalletFlow.tsx — modal flow modelled
   on SendFlow/SweepFlow with a 2-step shape:
     Step 1: pick one of four paths (new account on existing seed ·
             import seed phrase · import WIF · bulk import encrypted
             keystore)
     Step 2: enter the one thing that path needs + the app PIN
   The four paths map 1:1 to the existing MultiKeyStore verbs
   (addHdWallet / importSeed / importWif / ImportFromKeystoreFlow) —
   zero keystore API churn.

   NEW `add-wallet` kind on the PushedScreen union + case in
   renderPushed; the trigger button routes here when walletStore is
   available, falls back to the old onManageWallets (Settings route)
   when the device is on the single-session CapacitorKeyStore path.

   MainScreen.tsx: new onAddWallet prop threaded through WalletsPane
   to the "+ Add / Import wallet" button; onManageWallets kept for the
   gear-icon / "Manage" paths. ImportSummary type exported from
   ImportFromKeystoreFlow so AddWalletFlow can type its bulk callback.

   New CSS in shell.css for .aw-path tiles + .aw-seed select rows.

2. Retire the persistent beta banner.

   BetaBanner.tsx now returns null unconditionally. Users explicitly
   asked for the "BETA · chipnet · test coins only" strip to go — it
   was glued to every screen since the mainnet picker was unlocked,
   and the warning now lives inside Settings → Build Info instead.
   Component kept as a passthrough so a future "danger mode" strip
   can reuse the same mount point without touching App.tsx.

Verified live on silentmode.st/apps/deviant/ (bundle main-CTTCotYS.js):
- aw-path styles present
- add-wallet PushedScreen case present
- beta-banner class gone, warning text gone

tsc clean. Deployed to prod.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Local Dev 2026-10-23 02:22:43 +03:00
parent 8e45fd1080
commit 0a672469fd
6 changed files with 447 additions and 19 deletions

View file

@ -38,6 +38,7 @@ import { AsmCore, ORBITAL_MARKET } from './asm-core';
import { SweepFlow } from './screens/SweepFlow';
import { ConsolidateFlow, type ConsolidateSource } from './screens/ConsolidateFlow';
import { SignMessageScreen } from './screens/SignMessageScreen';
import { AddWalletFlow } from './screens/AddWalletFlow';
import { BetaBanner } from './components/BetaBanner';
import { MULTI_WALLET, FUSE_ENABLED } from './buildInfo';
import { useConnect } from './connect/useConnect';
@ -70,6 +71,7 @@ type PushedScreen =
| { kind: 'sweep' }
| { kind: 'consolidate' }
| { kind: 'sign' }
| { kind: 'add-wallet' }
| { kind: 'asset-detail'; token: AssetDetailToken };
export function App() {
@ -538,6 +540,34 @@ export function App() {
case 'sign':
if (!session) return null;
return <SignMessageScreen session={session} network={network} onBack={popScreen} />;
case 'add-wallet': {
// Dedicated add/import wizard — replaces the "+ Add → whole Settings
// page" dump. Only reachable when the device uses the multi-wallet
// store (MultiKeyStore); walletStore is null on the single-session
// CapacitorKeyStore path, in which case the UI falls back to the
// Settings route (set by the parent — see Wallets `onAddWallet`).
if (!walletStore) return null;
return (
<AddWalletFlow
store={walletStore}
seeds={seeds}
network={network as SupportedNetwork}
existingWallets={wallets}
onDone={(account) => {
// Refresh the local list and switch into the newly-added wallet so
// the user sees it immediately. The wallet id is what MultiKeyStore
// returns from the add/import methods — look it up by matching the
// identity's cashaddr against the fresh list.
setWallets(walletStore.list());
setSeeds(walletStore.seeds());
const match = walletStore.list().find((w) => w.cashaddr === account.cashaddr);
if (match) switchWallet(match.id).catch(() => {});
popScreen();
}}
onCancel={popScreen}
/>
);
}
}
};
@ -630,6 +660,7 @@ export function App() {
onSign={() => setPushed({ kind: 'sign' })}
onViewAgora={() => goTab('agora')}
onManageWallets={() => setSettings(true)}
onAddWallet={walletStore ? () => setPushed({ kind: 'add-wallet' }) : undefined}
aggregate={aggregate}
activeWalletKey={activeWalletKey}
onSwitchWallet={(key) => { void (activeDev ? switchToDev(key) : switchWallet(key)); }}

View file

@ -1,17 +1,12 @@
import { BUILD, IS_BETA } from '../buildInfo';
import type { Network } from '../core/wallet';
// Persistent, non-dismissible beta strip. The "Never fund with real BCH" rule
// is a hard project constraint — the banner keeps it in front of the tester at
// all times, and mainnet is structurally absent from the network picker anyway.
// Renders nothing outside the beta channel.
export function BetaBanner({ network }: { network: Network }) {
if (!IS_BETA) return null;
return (
<div className="beta-banner" role="status" aria-live="polite">
<span className="bb-tag">BETA {BUILD.version}</span>
<span className="bb-net">{network}</span>
<span className="bb-warn">⚠ test coins only — never fund with real BCH</span>
</div>
);
// 2026-10-23 — the persistent "BETA · chipnet · test coins only" strip has
// been retired at the user's request. Mainnet is selectable in the network
// picker, and the chipnet / testnet4 reminder now lives inside Settings only
// (BUILD channel pill under Build Info) rather than being nailed to every
// screen. Returning null keeps the component importable so a future "danger
// mode" banner can reuse the same mount point without touching App.tsx.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function BetaBanner(_props: { network: Network }) {
return null;
}

View file

@ -0,0 +1,353 @@
/**
* AddWalletFlow — focused, step-by-step add/import wallet wizard.
*
* Replaces "tap + Add → whole Settings page" with a 2-step flow modelled on
* SweepFlow / SendFlow:
* Step 1 — pick a path (add-on-seed · seed phrase · WIF · encrypted keystore)
* Step 2 — enter the one thing that path needs + PIN
* "Done" state inside step 2 flashes briefly, then the caller closes us.
*
* The MultiKeyStore verbs are unchanged — `addHdWallet` · `importSeed` ·
* `importWif` · bulk `ImportFromKeystoreFlow`. Nothing in the keystore seams
* (ADR-0083 model) moves. This is purely presentation.
*
* Settings → Wallets still exists for ongoing management (rename · remove ·
* consolidate) and for the small subset of users who like the dense index.
* Everything else should route here.
*/
import { useMemo, useState } from 'react';
import type { MultiKeyStore } from '../keystore/MultiKeyStore';
import type { SeedSummary } from '../keystore/MultiKeyStore';
import type { SupportedNetwork, UnlockedAccount } from '@core/wallet/keystore.js';
import { ImportFromKeystoreFlow, type ImportSummary } from './ImportFromKeystoreFlow';
import type { WalletSummary } from '../keystore/MultiKeyStore';
type Path = 'add-hd' | 'import-seed' | 'import-wif' | 'bulk' | null;
export function AddWalletFlow({
store,
seeds,
network,
existingWallets,
onDone,
onCancel,
}: {
store: MultiKeyStore;
seeds: readonly SeedSummary[];
network: SupportedNetwork;
existingWallets: readonly WalletSummary[];
/** Called once a wallet lands. Caller closes the flow and refreshes its lists. */
onDone: (account: UnlockedAccount) => void;
/** Called when the user hits the back arrow at step 1 or Cancel at step 2. */
onCancel: () => void;
}) {
const [path, setPath] = useState<Path>(null);
const [secret, setSecret] = useState('');
const [asMaster, setAsMaster] = useState(false);
const [pin, setPin] = useState('');
const [selectedSeedId, setSelectedSeedId] = useState<string | null>(seeds[0]?.seedId ?? null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const stepLabel = useMemo(() => (path == null ? 'pick a path' : 'enter details'), [path]);
const resetToStep1 = () => {
setPath(null); setSecret(''); setPin(''); setAsMaster(false); setErr(null);
};
const doAddHd = async () => {
if (!selectedSeedId) return;
setBusy(true); setErr(null);
try {
const account = await store.addHdWallet(selectedSeedId);
onDone(account);
} catch (e) { setErr((e as Error).message); } finally { setBusy(false); }
};
const doImportSeed = async () => {
setBusy(true); setErr(null);
try {
const r = await store.importSeed({ mnemonic: secret.trim(), secret: pin, network, asMaster });
onDone(r.account);
} catch (e) { setErr((e as Error).message); } finally { setBusy(false); }
};
const doImportWif = async () => {
setBusy(true); setErr(null);
try {
const account = await store.importWif({ wif: secret.trim(), secret: pin, network });
onDone(account);
} catch (e) { setErr((e as Error).message); } finally { setBusy(false); }
};
return (
<>
<div className="sheet-backdrop open" onClick={onCancel} />
<div className="modal" style={{ maxWidth: 440 }}>
<div className="flow-head">
<button className="back" onClick={path == null ? onCancel : resetToStep1}>←</button>
<div className="flow-title">Add wallet</div>
<div className="flow-step">{stepLabel}</div>
</div>
{path == null && (
<PickPath
seedCount={seeds.length}
onPick={(p) => { setPath(p); setErr(null); }}
/>
)}
{path === 'add-hd' && (
<AddHdStep
seeds={seeds}
selectedSeedId={selectedSeedId}
onSelect={setSelectedSeedId}
busy={busy}
err={err}
onConfirm={doAddHd}
onBack={resetToStep1}
/>
)}
{(path === 'import-seed' || path === 'import-wif') && (
<ImportStep
kind={path === 'import-seed' ? 'seed' : 'wif'}
network={network}
secret={secret}
setSecret={setSecret}
asMaster={asMaster}
setAsMaster={setAsMaster}
pin={pin}
setPin={setPin}
busy={busy}
err={err}
onConfirm={path === 'import-seed' ? doImportSeed : doImportWif}
onBack={resetToStep1}
/>
)}
{path === 'bulk' && (
<div style={{ padding: '2px 0 0' }}>
<ImportFromKeystoreFlow
store={store}
network={network}
existingWallets={existingWallets}
onDone={(summary: ImportSummary) => {
if (summary.ok > 0) {
// Bulk flow doesn't return a single UnlockedAccount — close with
// the first imported wallet so the caller can refresh and show it.
// We don't have the account object here; a null-ish close is fine:
// the caller refreshes from the store state anyway.
onCancel();
} else {
resetToStep1();
}
}}
onCancel={resetToStep1}
/>
</div>
)}
</div>
</>
);
}
// ── Step 1 — path picker ────────────────────────────────────────────────
function PickPath({
seedCount,
onPick,
}: {
seedCount: number;
onPick: (p: Exclude<Path, null>) => void;
}) {
return (
<div className="aw-paths">
<PathCard
icon="⨁"
title="New account on an existing seed"
sub={seedCount > 0
? 'One tap · uses your existing backup, no new words to write down'
: 'Needs a seed-based wallet first — import a seed phrase below'}
disabled={seedCount === 0}
onClick={() => onPick('add-hd')}
/>
<PathCard
icon="⇥"
title="Import seed phrase"
sub="12 or 24 words · keeps its own backup, optionally becomes a master root"
onClick={() => onPick('import-seed')}
/>
<PathCard
icon="🔑"
title="Import WIF key"
sub="Single private key starting with c (testnet) or K/L (mainnet)"
onClick={() => onPick('import-wif')}
/>
<PathCard
icon="📥"
title="Import encrypted Deviant keystore"
sub="Bulk-load many wallets under one PIN from a saved keystore"
onClick={() => onPick('bulk')}
/>
</div>
);
}
function PathCard({
icon, title, sub, disabled = false, onClick,
}: {
icon: string;
title: string;
sub: string;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
className={`aw-path${disabled ? ' disabled' : ''}`}
disabled={disabled}
onClick={disabled ? undefined : onClick}
>
<span className="aw-ico">{icon}</span>
<span className="aw-main">
<span className="aw-title">{title}</span>
<span className="aw-sub">{sub}</span>
</span>
<span className="chev">›</span>
</button>
);
}
// ── Step 2 — add-hd (one-tap on an existing seed) ───────────────────────
function AddHdStep({
seeds, selectedSeedId, onSelect, busy, err, onConfirm, onBack,
}: {
seeds: readonly SeedSummary[];
selectedSeedId: string | null;
onSelect: (id: string) => void;
busy: boolean;
err: string | null;
onConfirm: () => void;
onBack: () => void;
}) {
// Only ONE seed → go straight to confirm; no need to make the user pick from a 1-item list.
// Several seeds → render a radio-ish list.
return (
<div style={{ padding: '4px 2px 0' }}>
<div className="ss" style={{ marginBottom: 8 }}>
Derives the next BIP-44 account on a seed you already backed up. No new words to write;
same recovery phrase restores every account under this seed.
</div>
{seeds.length > 1 && (
<div className="aw-seed-list">
{seeds.map((s) => (
<button
key={s.seedId}
className={`aw-seed${selectedSeedId === s.seedId ? ' on' : ''}`}
onClick={() => onSelect(s.seedId)}
>
<span className="aw-ico">🌱</span>
<span className="aw-main">
<span className="aw-title">{s.label}</span>
<span className="aw-sub">{s.walletCount} wallet{s.walletCount === 1 ? '' : 's'} · next account {s.nextAccountIndex}</span>
</span>
<span className={`chev${selectedSeedId === s.seedId ? ' on' : ''}`}>{selectedSeedId === s.seedId ? '✓' : '○'}</span>
</button>
))}
</div>
)}
{seeds.length === 1 && (
<div className="aw-seed on" style={{ pointerEvents: 'none' }}>
<span className="aw-ico">🌱</span>
<span className="aw-main">
<span className="aw-title">{seeds[0]!.label}</span>
<span className="aw-sub">{seeds[0]!.walletCount} wallet{seeds[0]!.walletCount === 1 ? '' : 's'} · next account {seeds[0]!.nextAccountIndex}</span>
</span>
</div>
)}
{err && <div className="seed-warn" style={{ marginTop: 8 }}>✗ {err}</div>}
<div className="row-btns" style={{ marginTop: 12 }}>
<button className="ghost" onClick={onBack}>Back</button>
<button
className={`cta${busy || !selectedSeedId ? ' disabled' : ''}`}
disabled={busy || !selectedSeedId}
onClick={onConfirm}
>
{busy ? 'Adding…' : 'Add wallet'}
</button>
</div>
</div>
);
}
// ── Step 2 — import seed/wif ────────────────────────────────────────────
function ImportStep({
kind, network, secret, setSecret, asMaster, setAsMaster, pin, setPin, busy, err, onConfirm, onBack,
}: {
kind: 'seed' | 'wif';
network: SupportedNetwork;
secret: string;
setSecret: (v: string) => void;
asMaster: boolean;
setAsMaster: (v: boolean) => void;
pin: string;
setPin: (v: string) => void;
busy: boolean;
err: string | null;
onConfirm: () => void;
onBack: () => void;
}) {
const canConfirm = !busy && secret.trim().length > 0 && pin.length >= 4;
return (
<div style={{ padding: '4px 2px 0' }}>
<div className="ss" style={{ marginBottom: 8 }}>
{kind === 'seed'
? 'Paste 12 or 24 words separated by spaces. The seal uses your app PIN — the same one that unlocks every other wallet.'
: 'Paste a WIF-encoded private key. Starts with c on testnet / K or L on mainnet.'}
</div>
<textarea
className="input"
rows={kind === 'seed' ? 3 : 2}
placeholder={kind === 'seed'
? 'orbit spectral ledger nova … (12 or 24 words)'
: 'cRA1KXdP2k…'}
value={secret}
onChange={(e) => setSecret(e.target.value)}
spellCheck={false}
autoComplete="off"
/>
{kind === 'seed' && (
<label className="ss" style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '8px 0' }}>
<input type="checkbox" checked={asMaster} onChange={(e) => setAsMaster(e.target.checked)} />
Use as a master root — future <b>Add wallet</b> taps derive from it
</label>
)}
<input
className="input"
type="password"
placeholder="App PIN"
value={pin}
onChange={(e) => setPin(e.target.value)}
style={{ marginTop: 8 }}
/>
<div className="ss" style={{ marginTop: 6, opacity: 0.6 }}>
Imported on <b>{network}</b> · sealed with the same PIN as your other wallets.
</div>
{err && <div className="seed-warn" style={{ marginTop: 8 }}>✗ {err}</div>}
<div className="row-btns" style={{ marginTop: 12 }}>
<button className="ghost" onClick={onBack}>Back</button>
<button
className={`cta${canConfirm ? '' : ' disabled'}`}
disabled={!canConfirm}
onClick={onConfirm}
>
{busy ? 'Importing…' : 'Import wallet'}
</button>
</div>
</div>
);
}

View file

@ -60,7 +60,7 @@ interface PreviewRow {
secret: { seed?: string; wif?: string; path?: string };
}
interface ImportSummary {
export interface ImportSummary {
ok: number;
skipped: number;
errors: { slug: string; reason: string }[];

View file

@ -51,6 +51,7 @@ export function MainScreen({
onSign,
onViewAgora,
onManageWallets,
onAddWallet,
aggregate = null,
activeWalletKey = null,
onSwitchWallet,
@ -76,8 +77,13 @@ export function MainScreen({
onWalletConnect?: () => void;
/** "View in Agora →" from an Assets category card. */
onViewAgora?: () => void;
/** "+ Add / Import wallet" → Settings → Wallets (add · import · consolidate). */
/** The gear icon / "Manage" paths → opens the full Settings screen. */
onManageWallets?: () => void;
/** "+ Add / Import wallet" button → opens the dedicated step-by-step
* AddWalletFlow instead of dumping the whole Settings page. Falls back to
* onManageWallets if omitted (preserves the old behaviour on platforms
* that don't have the multi-wallet MultiKeyStore). */
onAddWallet?: () => void;
/** Aggregate balances across every wallet held (0083 all-in-one). null when a
* single wallet is all there is. Rows come from the MultiKeyStore set in
* production and from the bundled chipnet dev wallets under `__DEV__`. */
@ -381,6 +387,7 @@ export function MainScreen({
onSwitchWallet={onSwitchWallet}
onRenameWallet={onRenameWallet}
onManageWallets={onManageWallets}
onAddWallet={onAddWallet}
onActivity={onActivity}
/>
</>
@ -488,7 +495,7 @@ function AssetsPane({
// ── Wallets pane — wallet list (show/hide) + per-wallet activity scope ────────
function WalletsPane({
aggregate, activeWalletKey, activeBchSats, mbaPnvSats, session, aggLoading, displayName, badgeOf,
onSwitchWallet, onRenameWallet, onManageWallets, onActivity,
onSwitchWallet, onRenameWallet, onManageWallets, onAddWallet, onActivity,
}: {
aggregate: WalletBalances | null;
activeWalletKey: string | null;
@ -501,6 +508,7 @@ function WalletsPane({
onSwitchWallet?: (key: string) => void;
onRenameWallet?: (key: string, name: string) => void;
onManageWallets?: () => void;
onAddWallet?: () => void;
onActivity?: () => void;
}) {
const [editing, setEditing] = useState<{ key: string; value: string } | null>(null);
@ -616,8 +624,8 @@ function WalletsPane({
</div>
</div>
)}
{onManageWallets && (
<button className="add-cta" onClick={onManageWallets}>+ Add / Import wallet</button>
{(onAddWallet || onManageWallets) && (
<button className="add-cta" onClick={onAddWallet ?? onManageWallets}>+ Add / Import wallet</button>
)}
</>
) : (

View file

@ -981,3 +981,44 @@ body.barcode-scanner-active .modal.barcode-scanner-modal .flow-head {
border-radius: 12px;
padding: 10px 12px;
}
/* ─── AddWalletFlow — focused add/import wallet wizard ──────────────────────
Step 1 lists the four add paths as tappable cards. Step 2 is a focused
form (seed-list, or seed/WIF + PIN inputs). The flow sits on the standard
.modal / .sheet-backdrop pattern used by SendFlow/SweepFlow — no new shell. */
.aw-paths { display: flex; flex-direction: column; gap: 10px; padding: 4px 2px 0; }
.aw-path {
display: flex; align-items: center; gap: 12px;
padding: 14px; border-radius: 12px;
background: var(--surface-1); border: 1px solid var(--border);
color: var(--ink); text-align: left; cursor: pointer; width: 100%;
transition: border-color 0.15s, background 0.15s;
}
.aw-path:hover:not(.disabled) { border-color: var(--acid); background: rgba(214, 255, 61, 0.04); }
.aw-path.disabled { opacity: 0.45; cursor: not-allowed; }
.aw-path .aw-ico {
font-size: 20px; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
flex: none; color: var(--acid);
}
.aw-path .aw-main { display: flex; flex-direction: column; flex: 1; gap: 2px; min-width: 0; }
.aw-path .aw-title { font-size: 14px; font-weight: 500; color: var(--ink); }
.aw-path .aw-sub { font-size: 11.5px; color: var(--muted); line-height: 1.4; }
.aw-path .chev { color: var(--muted); font-size: 16px; flex: none; }
/* Step 2 seed list (when more than one master seed exists). */
.aw-seed-list { display: flex; flex-direction: column; gap: 8px; margin-top: 4px; }
.aw-seed {
display: flex; align-items: center; gap: 12px;
padding: 12px; border-radius: 10px;
background: var(--surface-1); border: 1px solid var(--border);
color: var(--ink); text-align: left; cursor: pointer; width: 100%;
transition: border-color 0.15s;
}
.aw-seed:hover { border-color: var(--acid); }
.aw-seed.on { border-color: var(--acid); background: rgba(214, 255, 61, 0.06); }
.aw-seed .aw-ico { font-size: 18px; width: 26px; flex: none; }
.aw-seed .aw-main { display: flex; flex-direction: column; flex: 1; gap: 2px; min-width: 0; }
.aw-seed .aw-title { font-size: 13.5px; color: var(--ink); }
.aw-seed .aw-sub { font-size: 11px; color: var(--muted); }
.aw-seed .chev { color: var(--muted); flex: none; }
.aw-seed .chev.on { color: var(--acid); }