Skip to content

chore: NIP-46 wire-trace observability for client debugging#27

Merged
DocNR merged 2 commits into
mainfrom
chore/nip46-wire-trace-logging
May 7, 2026
Merged

chore: NIP-46 wire-trace observability for client debugging#27
DocNR merged 2 commits into
mainfrom
chore/nip46-wire-trace-logging

Conversation

@DocNR
Copy link
Copy Markdown
Owner

@DocNR DocNR commented May 7, 2026

Summary

Two observability-only changes to make Clave a cleaner reference signer for debugging NIP-46 client implementations (specifically: investigating Wisp client-side pairing flake while Jumble + noStrudel work as controls). No behavior change in either commit.

LightSigner.swift (5990335)

  • [LightSigner] Method: log gets id=<8-char> suffix so every wire-trace step is correlatable to one RPC
  • connect warns (doesn't reject) when params[0] doesn't match this signer's pubkey — closes BACKLOG Audit-2
  • New warning when params cast falls through (client sent mixed-type instead of [String]); reports actual type. Distinguishes shape-mismatch from genuinely-missing-params.

AppState.swift (ca98b5d)

  • New "pair" Logger category — added to LogExporter.allCategories + regression test
  • pairClientWithProxy + unpairClientWithProxy had zero log lines and five silent-return points each. Now log: begin (client/signer/relays), each early-return reason (empty signer, no nsec, Bech32 decode, URL/body/NIP-98 failures), and response (200 OK or status/net-err with retry context).
  • Pure observability. Existing retry-queue behavior on non-200 unchanged. Privacy-safe: privacy: .public only on already-public values (8-char pubkey prefixes, status codes, error messages); full pubkeys never logged.

Why now

When debugging Wisp's NIP-46 implementation against Clave, the failure point needs to be unambiguous from logs alone. Previously a pair-client call could fail at any of five guard points with no breadcrumb — making it impossible to tell from iOS-side traces whether a Wisp pairing failed because Wisp didn't trigger the call, because Clave's NIP-98 sign failed, or because the proxy 5xx'd. Same for the LightSigner request id correlation: noStrudel + Wisp interleaving in one capture session needs per-RPC correlation to compare.

Companion ask: Stage 1 of the Wisp debugging plan (capture clean noStrudel control trace + Wisp failure trace via ~/hq/clave/scripts/capture-test.sh) is dramatically more useful with these breadcrumbs in place.

Test plan

  • xcodebuild test -skip-testing:ClaveUITests passes baseline (build 62 / 1e081e7)
  • xcodebuild test -skip-testing:ClaveUITests passes post-change (ca98b5d)
  • LogExporterFormattingTests.testAllCategories_includesEveryShippedCategory updated to include "pair"
  • Internal TestFlight smoke: pair Clave with noStrudel via nostrconnect → verify [Pair] pair-client begin + [Pair] pair-client ok in iOS log; verify [LightSigner] Method: connect id=… lines correlate across multiple RPCs
  • Stage 1 capture (separate task): use capture-test.sh to grab control + Wisp traces with these breadcrumbs in place

🤖 Generated with Claude Code

DocNR and others added 2 commits May 7, 2026 11:51
Three observability-only additions to make Clave a cleaner reference
signer for debugging NIP-46 client implementations. No behavior change.

- Append `id=<8-char>` to the existing `[LightSigner] Method:` log line so
  every wire-trace step in iOS logs can be cross-correlated to a single
  RPC by request id.
- Warn (don't reject) when `connect` params[0] doesn't match Clave's
  signer pubkey. Spec says params[0] is the remote-signer-pubkey;
  routing already worked via the kind:24133 #p tag, so a mismatch is
  informational — but it's a useful diagnostic for clients that target
  the wrong signer in multi-account flows. Closes BACKLOG Audit-2.
- Log when `params` cast falls through (client sent mixed-type instead
  of [String]). Without this, a shape-mismatch silently degrades to
  "missing params" downstream and the wire trace can't tell shape-issues
  from genuinely-missing params. Reports the actual type for easier
  client-side debug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-client

pairClientWithProxy and unpairClientWithProxy had zero log lines and
five silent-return points each. When a client pair fails (e.g. Wisp
during NIP-46 client implementation), there is currently no way from
iOS logs to know whether pair-client was even called, what signer it
resolved, whether the URL/auth/body construction failed, or what status
came back from the proxy.

This adds a new "pair" Logger category and threads observability through
both functions:

- begin: client/signer/relay-count
- each early-return: explicit reason (empty signer, no nsec, Bech32
  decode err, invalid URL, body serialize, NIP-98 sign err)
- response: status=200 ok, or status=<code> / net-err / no-response
  with retry-queue context

Pure observability, no behavior change. Existing retry queueing on
non-200 is unchanged. All log calls use `privacy: .public` only on
already-public values (8-char pubkey prefixes, status codes, error
messages); full pubkeys never appear in log lines.

Updates LogExporter.allCategories + the regression-guard test so the
new category is captured by the dev-menu "Copy Recent Logs" export.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@DocNR DocNR merged commit 6f2cb92 into main May 7, 2026
@DocNR DocNR deleted the chore/nip46-wire-trace-logging branch May 7, 2026 15:54
DocNR added a commit that referenced this pull request May 7, 2026
Carries PR #27's NIP-46 wire-trace observability changes (LightSigner
requestId log + connect target warn + params shape log; AppState 'pair'
log category + breadcrumbs across pair/unpair-client). Internal-TF
build to enable Stage 1 capture for Wisp client debugging.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DocNR added a commit that referenced this pull request May 8, 2026
Stage 3c — last of the Stage 3 sub-extractions. Moves all proxy HTTP +
NIP-98 + retry-queue logic out of AppState.swift into a dedicated
extension file. The new file owns the [Pair] log category from PR #27.

Moves out of Clave/AppState.swift:
- registerWithProxy(completion:) — single-account wrapper
- registerAllAccountsWithProxy() — multi-account fan-out
- registerSignerWithProxy(signer:completion:) — private NIP-98 POST
  /register impl
- ensureRegisteredFresh() — throttled scenePhase trigger
- ensureAllRegisteredFresh() — multi-account variant
- unregisterWithProxy(signer:) — NIP-98 POST /unregister
- pairClientWithProxy(clientPubkey:relayUrls:signer:) — POST /pair-client
  with retry queue on failure
- unpairClientWithProxy(clientPubkey:signer:) — POST /unpair-client
- drainPendingPairOps() — retry queue worker
- retryPairOp(op:relayUrls:) — private retry impl
- retryUnpairOp(op:) — private retry impl
- "// MARK: - Proxy per-client-relay (V2)" header
- private let logger = Logger(...category: "pair") relocated from
  AppState.swift line 8 (16/16 of its uses moved with the cluster)

Into the new file:
- Clave/AppState+ProxyClient.swift (577 LOC) — extension AppState

Logger relocation note: declaring Logger(subsystem:..., category:...)
in two files is equivalent — multiple Logger instances with the same
subsystem + category combine into one log stream. The dev-menu "Copy
Recent Logs" path (LogExporter.allCategories includes "pair") is
unchanged.

Zero behavior change:
- Function bodies preserved byte-for-byte.
- Original `private` modifiers preserved on registerSignerWithProxy,
  retryPairOp, retryUnpairOp.
- All call sites (network code, Task { @mainactor in } closures,
  URLSession callbacks, NIP-98 signing) unchanged.
- Cross-extension call from AppState+NostrConnect.swift's
  handleNostrConnect to pairClientWithProxy still works — both are
  extension AppState in the same module.

External callers unchanged:
- Clave/ClaveApp.swift (drainPendingPairOps via NotificationCenter)
- Clave/Views/MainTabView.swift (ensureRegisteredFresh +
  ensureAllRegisteredFresh on scenePhase)
- Clave/Views/Settings/SettingsView.swift (registerWithProxy,
  unregisterWithProxy)
- Clave/Views/Onboarding/OnboardingView.swift (registerWithProxy)
- Clave/Views/Home/HomeView.swift (registerAllAccountsWithProxy)
- Clave/Views/Home/ClientDetailView.swift (unpairClientWithProxy)
All call via appState.<method> — extension dispatch.

No pbxproj edits — Clave/ directory is auto-synced.

Verification:
- xcodebuild test -skip-testing:ClaveUITests on iPhone 17 / iOS 26.4:
  - Pre-baseline (main @ 11c7117): 232 passed / 0 failed
  - Post-extraction (this branch): 232 passed / 0 failed
  - ** TEST SUCCEEDED ** in both runs

AppState.swift: 1,483 -> 922 LOC (-561, -37.8%).
Combined Stages 1+2+3a+3b+3c: 2,338 -> 922 LOC (-60.6% from original).

Stage 4 (AccountManager + PendingApprovalCoordinator, ~700 LOC) is
the final and highest-risk extraction — touches multi-account
@observable surface and the alert-chain state machine.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant