feat(nip46): pair a second signer account — park the live session, switch re-dials it

One live NIP-46 session, many saved ones (Option A):
- start_pairing/connect while a session is live PARKS it instead of
  refusing: row, pairing secret, and persisted client key stay intact,
  so the parked account is restorable with no fresh scan.
- SelectProfile follows the switch: target has a restorable connection ->
  park current + re-dial target's row (expected_identity guard applies);
  target is local-key or unpaired -> live session untouched.
- New nip46_cancel_pairing IPC: aborts ONLY an in-flight pairing and
  re-dials the parked session, so cancel-after-park is transparent.
  The QR cancel paths (Add-profile modal, Signer Mode screen) use it —
  plain disconnect would revoke the parked connection.
- e2e: two fake Ambers on one relay; A pairs, B's pairing parks A
  (revoked_at none, client key resolvable), switch back re-dials A and
  signs; no-op switch; local profile leaves session alone; B restorable.
This commit is contained in:
Avi 2026-09-27 21:01:23 -05:00
commit c89b31aaf1
9 changed files with 482 additions and 23 deletions

View file

@ -121,6 +121,7 @@ export const api = {
call<Nip46SignerStatus>('nip46_connect', { uri, label }),
nip46PairStart: (label: string) => call<Nip46SignerStatus>('nip46_pair_start', { label }),
nip46Disconnect: () => call<Nip46SignerStatus>('nip46_disconnect'),
nip46CancelPairing: () => call<Nip46SignerStatus>('nip46_cancel_pairing'),
nip46Status: () => call<Nip46SignerStatus>('nip46_status'),
nip46Approve: (id: string, approved: boolean, always = false) =>
call<Nip46SignerStatus>('nip46_approve', { id, approved, always }),

View file

@ -15,7 +15,8 @@ interface CreateProfileModalProps {
type Phase = 'choice' | 'pairing' | 'paired' | 'local' | 'creating' | 'success';
export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
const { state, createProfile, nip46PairStart, nip46Status, nip46Disconnect, refresh } = useApp();
const { state, createProfile, nip46PairStart, nip46Status, nip46CancelPairing, refresh } =
useApp();
const [label, setLabel] = useState('');
const [phase, setPhase] = useState<Phase>('choice');
const [error, setError] = useState<string | null>(null);
@ -149,13 +150,16 @@ export function CreateProfileModal({ open, onClose }: CreateProfileModalProps) {
// Leaving the QR view mid-pairing aborts the in-flight pairing; nothing
// was persisted yet, so teardown is safe at any point (same as Signer
// Mode's "Cancel pairing").
// Mode's "Cancel pairing"). Cancel (not disconnect): when pairing a
// second signer account parked the first one, cancelling must restore
// the parked session rather than revoke anything.
const cancelPairing = async () => {
setLivePairingUri(null);
setPairingQr(null);
setPhase('choice');
try {
await nip46Disconnect();
await nip46CancelPairing();
void refresh();
} catch {
// Best-effort abort; a dead pairing attempt expires on its own.
}

View file

@ -17,6 +17,7 @@ export function SignerModeScreen() {
nip46Connect,
nip46PairStart,
nip46Disconnect,
nip46CancelPairing,
nip46Approve,
embeddedSignerApprove,
refresh,
@ -192,17 +193,19 @@ export function SignerModeScreen() {
};
}, [pairingUri]);
// Abort an in-flight pairing (e.g. expired QR) — same teardown as a
// disconnect; nothing was persisted yet so it is safe at any point.
// Abort an in-flight pairing (e.g. expired QR): cancel the pairing
// attempt only. Never disconnect here — if pairing a second signer
// account parked the first one, a cancel must bring the parked session
// back instead of revoking it.
const handlePairCancel = useCallback(async () => {
setPairError(null);
try {
const status = await nip46Disconnect();
const status = await nip46CancelPairing();
setNip46StatusState(status);
} catch (err) {
setPairError(err instanceof Error ? err.message : String(err));
}
}, [nip46Disconnect]);
}, [nip46CancelPairing]);
const handleNip46Disconnect = useCallback(async () => {
setError(null);

View file

@ -82,6 +82,7 @@ interface AppContextValue {
nip46Connect: (uri: string, label: string) => Promise<Nip46SignerStatus>;
nip46PairStart: (label: string) => Promise<Nip46SignerStatus>;
nip46Disconnect: () => Promise<Nip46SignerStatus>;
nip46CancelPairing: () => Promise<Nip46SignerStatus>;
nip46Status: () => Promise<Nip46SignerStatus>;
nip46Approve: (id: string, approved: boolean, always?: boolean) => Promise<Nip46SignerStatus>;
// Legacy NIP-46 bunker (deprecated)
@ -289,6 +290,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
[],
);
const nip46Disconnect = useCallback(() => api.nip46Disconnect(), []);
const nip46CancelPairing = useCallback(() => api.nip46CancelPairing(), []);
const nip46Status = useCallback(() => api.nip46Status(), []);
const nip46PairStart = useCallback((label: string) => api.nip46PairStart(label), []);
const nip46Approve = useCallback(
@ -385,6 +387,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
nip46Connect,
nip46PairStart,
nip46Disconnect,
nip46CancelPairing,
nip46Status,
nip46Approve,
signerConnect,
@ -445,6 +448,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
nip46Connect,
nip46PairStart,
nip46Disconnect,
nip46CancelPairing,
nip46Status,
nip46Approve,
signerConnect,

View file

@ -137,7 +137,10 @@ describe('CreateProfileModal', () => {
await screen.findByText(/Waiting for the signer to scan/i);
await user.click(screen.getByRole('button', { name: 'Cancel pairing' }));
expect(backend.requests.some((r) => r.method === 'nip46_disconnect')).toBe(true);
// Cancel must be the NON-revoking cancel (parked sessions survive it),
// never the disconnect that revokes the stored connection.
expect(backend.requests.some((r) => r.method === 'nip46_cancel_pairing')).toBe(true);
expect(backend.requests.some((r) => r.method === 'nip46_disconnect')).toBe(false);
expect(
screen.getByRole('button', { name: /Sign in with a signer app \(Amber\)/ }),
).toBeInTheDocument();

View file

@ -226,6 +226,14 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
backend.setNip46(next);
return next;
}
case 'nip46_cancel_pairing': {
// Mirrors the real backend: aborts ONLY the pairing attempt and
// re-dials the parked session — a cancel must not clear a
// connected session, only the pairing URI.
const next = { ...backend.nip46, pairing_uri: undefined };
backend.setNip46(next);
return next;
}
case 'nip46_approve':
return backend.nip46;