fix(nip46): spec-correct connect params, full-handshake [NIP46] trace, visible handshake errors

This commit is contained in:
Avi 2026-09-23 13:02:19 -05:00
commit 8f055f71bf
5 changed files with 672 additions and 51 deletions

View file

@ -592,6 +592,11 @@ export function SignerModeScreen() {
Waiting for the signer to scan… the connection appears automatically once
approved. The code expires after a few minutes.
</p>
{nip46StatusState?.error && (
<Alert tone="error" title="Pairing failed">
{nip46StatusState.error}
</Alert>
)}
{pairError && <ErrorText>{pairError}</ErrorText>}
<div className="settings-inline" style={{ justifyContent: 'center' }}>
<Button variant="ghost" onClick={() => void handlePairCancel()}>
@ -636,6 +641,21 @@ export function SignerModeScreen() {
</div>
{error && <ErrorText>{error}</ErrorText>}
{pairError && <ErrorText>{pairError}</ErrorText>}
{/* A failed handshake must never look like an idle form:
surface the backend's error so a timeout is visible. */}
{nip46StatusState?.error && (
<Alert tone="error" title="Connection failed">
{nip46StatusState.error}
</Alert>
)}
{/* A sent-but-unapproved connection request is in flight:
say so instead of showing a blank form. */}
{!nip46StatusState?.error && (nip46StatusState?.relays?.length ?? 0) > 0 && (
<p className="hint">
Connection request sent — approve it in Amber. This updates automatically; it
can take up to a couple of minutes on a slow network.
</p>
)}
<div className="settings-inline">
{mode === 'nip46_client' && (
<Button

View file

@ -0,0 +1,72 @@
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, expect, vi } from 'vitest';
import { SignerModeScreen } from '../screens/SignerModeScreen';
import { renderWithApp } from './render';
import { createFakeBackend, installFakeBackend } from './fakeBackend';
import { makeState } from './apiMock';
vi.mock('qrcode', () => ({
default: { toDataURL: vi.fn(async () => 'data:image/png;base64,QR') },
}));
function installNip46Backend() {
const backend = createFakeBackend(makeState({ signer_mode: 'nip46_client' }));
installFakeBackend(backend);
return backend;
}
beforeEach(() => {
vi.clearAllMocks();
});
describe('SignerModeScreen handshake states', () => {
it('shows the QR waiting hint while a pairing is in flight', async () => {
const backend = installNip46Backend();
const user = userEvent.setup();
renderWithApp(<SignerModeScreen />);
await screen.findByRole('button', { name: /Show QR/i });
await user.click(screen.getByRole('button', { name: /Show QR/i }));
expect(await screen.findByText(/Waiting for the signer to scan/i)).toBeInTheDocument();
expect(backend.requests.some((r) => r.method === 'nip46_pair_start')).toBe(true);
});
it('shows a connecting hint after a paste-URI connect is sent but unapproved', async () => {
const backend = installNip46Backend();
backend.setNip46({
type: 'nip46',
connected: false,
relays: ['wss://relay.test'],
connected_relays: ['wss://relay.test'],
pending_approvals: [],
});
renderWithApp(<SignerModeScreen />);
expect(await screen.findByText(/Connection request sent/i)).toBeInTheDocument();
});
it('surfaces a failed handshake as a visible error, not a silent idle form', async () => {
const backend = installNip46Backend();
backend.setNip46({
type: 'nip46',
connected: false,
relays: ['wss://relay.test'],
connected_relays: [],
error:
'The signer would not reveal its public key (timeout). Keep Amber open in the foreground with network access and try again.',
pending_approvals: [],
});
renderWithApp(<SignerModeScreen />);
expect(await screen.findByText('Connection failed')).toBeInTheDocument();
expect(
await screen.findByText(/The signer would not reveal its public key/i),
).toBeInTheDocument();
// The failure must poll through the same status channel the screen reads.
await waitFor(() =>
expect(backend.requests.some((r) => r.method === 'nip46_status')).toBe(true),
);
});
});

View file

@ -2,6 +2,7 @@ import type {
AppState,
BackendResponse,
FeedItem,
Nip46SignerStatus,
ProfileSummary,
PublishReport,
RelayTestResult,
@ -42,6 +43,9 @@ export interface FakeBackend {
/** Current NIP-46 signer status. */
signer: SignerStatus;
setSigner: (next: SignerStatus) => void;
/** NIP-46 client handshake status backing the nip46_* methods. */
nip46: Nip46SignerStatus;
setNip46: (next: Nip46SignerStatus) => void;
/** Standing "always allow" grants returned by signer_grants_list. */
signerGrants: SignerGrant[];
/** Notes returned by `feed_get`. */
@ -109,6 +113,16 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
setSigner(next) {
backend.signer = next;
},
nip46: {
type: 'nip46',
connected: false,
relays: [],
connected_relays: [],
pending_approvals: [],
},
setNip46(next) {
backend.nip46 = next;
},
signerGrants: [],
feedItems: [
{
@ -181,6 +195,38 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
case 'get_state':
return state;
// NIP-46 client handshake surface used by SignerModeScreen. The fake
// keeps a Nip46SignerStatus-shaped object so handshake-state tests
// (pairing URI, connecting relays, failure errors) run without relays.
case 'nip46_status':
return backend.nip46;
case 'nip46_pair_start': {
const next = {
...backend.nip46,
pairing_uri: `nostrconnect://deadbeef?relay=${encodeURIComponent('wss://relay.test')}&secret=fake`,
};
backend.setNip46(next);
return next;
}
case 'nip46_connect': {
const next = { ...backend.nip46, relays: ['wss://relay.test'] };
backend.setNip46(next);
return next;
}
case 'nip46_disconnect': {
const next: Nip46SignerStatus = {
type: 'nip46',
connected: false,
relays: [],
connected_relays: [],
pending_approvals: [],
};
backend.setNip46(next);
return next;
}
case 'nip46_approve':
return backend.nip46;
case 'create_profile': {
const label = String(params.label ?? '');
const profile: ProfileSummary = {