fix(nip46): time-out the first-connect consent prompt; drop dead code

Audit fixes:

- requestConnect had no timeout while requestOp did. Since authorize()/
  onConnect() run inline in the signer service's single-consumer loop, an
  ignored first-connect dialog blocked every other client's requests forever.
  Both consent prompts now fail closed on the shared 120s timeout (per-op →
  deny-once, connect → declined) so a stuck dialog can't hold the loop hostage.
- Remove the now-unused Nip46ActivityLog.forClient() (the detail screen filters
  the collected flow instead).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
This commit is contained in:
Claude
2026-07-17 15:18:30 +00:00
parent 5c73a7c1f7
commit 31cd6c8bb7
2 changed files with 9 additions and 9 deletions
@@ -53,7 +53,4 @@ class Nip46ActivityLog(
fun record(entry: Nip46ActivityEntry) {
_entries.update { (listOf(entry) + it).take(capacity) }
}
/** The most recent entries for one client (newest first). */
fun forClient(clientPubKey: HexKey): List<Nip46ActivityEntry> = _entries.value.filter { it.clientPubKey == clientPubKey }
}
@@ -49,8 +49,9 @@ import kotlinx.coroutines.withTimeoutOrNull
*/
object Nip46ConsentBridge {
/**
* Upper bound on how long a per-op prompt may block the signer's single-consumer loop. A user who
* ignores the dialog eventually fails the request closed (deny-once) instead of wedging the signer.
* Upper bound on how long a consent prompt may block the signer's single-consumer loop. A user who
* ignores the dialog eventually fails the request closed (deny / declined) instead of wedging the
* signer for every other client whose requests queue behind that one blocked prompt.
*/
private const val CONSENT_TIMEOUT_MS = 120_000L
@@ -64,10 +65,12 @@ object Nip46ConsentBridge {
val meta = request.clientMetadata
val title = meta?.name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app)
val domain = meta?.url?.ifBlank { null } ?: (clientPubKey.take(12) + "")
return SignerConnectCoordinator.requestConnect(
context,
SignerConnectInfo(appletTitle = title, coordinate = coordinate, domain = domain, iconUrl = meta?.image),
)
val info = SignerConnectInfo(appletTitle = title, coordinate = coordinate, domain = domain, iconUrl = meta?.image)
// Fail closed (declined) if the prompt is never answered, so a stuck first-connect dialog can't
// hold the single-consumer loop hostage against every other client.
return withTimeoutOrNull(CONSENT_TIMEOUT_MS) {
SignerConnectCoordinator.requestConnect(context, info)
} ?: AppConnectResult.Cancelled
}
/** Per-operation consent: describe the request (op + event preview) and await the user's grant. */