feat(wallet): cross-platform NWC connection backup via NIP-78

Publish the active NWC URI as a NIP-44 v2 self-encrypted kind-30078
event on every successful connect, and surface a one-tap "Restore
previous wallet" affordance on the NWC setup screen when an existing
backup is found on relays.

Implements the cross-platform contract documented in
NWC_BACKUP_PARITY.md (PR #560) so an NWC connection published from iOS
restores on Android — and vice versa — with no re-paste.

Contract honored:
- kind 30078, d-tag "nwc-wallet-backup" (flat, no namespacing)
- NIP-44 v2 self-to-self encryption, plaintext = raw NWC URI
- Publish best-effort on every connect; no NIP-09 on disconnect
- Restore search runs when the NWC setup screen opens; suppressed
  if the backup matches the active connection
This commit is contained in:
The Daniel
2026-05-22 19:41:16 -04:00
parent 521562be15
commit 2ed2d53def
4 changed files with 227 additions and 0 deletions
@@ -128,4 +128,48 @@ object Nip78 {
/** Extract the d-tag value from an event, or null. */
fun extractDTag(event: NostrEvent): String? =
event.tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
// ─── NWC connection backup ────────────────────────────────────────
//
// Cross-platform NWC URI backup per `NWC_BACKUP_PARITY.md`. iOS
// and Android both publish to / read from the same flat `d` tag
// (`nwc-wallet-backup` — no `wisp-` prefix, intentional, for
// cross-platform interop). Content is the raw NWC URI string,
// NIP-44 v2 encrypted to self.
const val NWC_BACKUP_D_TAG = "nwc-wallet-backup"
/** Build + sign a kind-30078 event carrying the encrypted NWC URI. */
suspend fun createNwcBackupEvent(signer: NostrSigner, uri: String): NostrEvent {
val encrypted = signer.nip44Encrypt(uri.trim(), signer.pubkeyHex)
val tags = listOf(
listOf("d", NWC_BACKUP_D_TAG),
listOf("client", "Wisp"),
listOf("encryption", "nip44")
)
return signer.signEvent(kind = KIND, content = encrypted, tags = tags)
}
/**
* Decrypt a kind-30078 NWC-backup event and return the raw URI.
* Returns null when the content is empty, decrypt fails, or the
* plaintext doesn't look like a `nostr+walletconnect://` URI.
*/
suspend fun decryptNwcBackup(signer: NostrSigner, event: NostrEvent): String? {
if (event.content.isBlank()) return null
return try {
val decrypted = signer.nip44Decrypt(event.content, event.pubkey).trim()
if (decrypted.startsWith("nostr+walletconnect://", ignoreCase = true)) decrypted else null
} catch (_: Exception) {
null
}
}
/** Filter to fetch the user's NWC backup (single addressable event). */
fun nwcBackupFilter(pubkeyHex: String): Filter = Filter(
kinds = listOf(KIND),
authors = listOf(pubkeyHex),
dTags = listOf(NWC_BACKUP_D_TAG),
limit = 1
)
}
@@ -155,6 +155,7 @@ import com.wisp.app.ui.component.NsecPasteGuard
import com.wisp.app.ui.component.SatsNumpad
import com.wisp.app.ui.util.AmountFormatter
import com.wisp.app.viewmodel.AutoCheckState
import com.wisp.app.viewmodel.NwcRestoreState
import com.wisp.app.viewmodel.FeeState
import com.wisp.app.viewmodel.BackupStatus
import com.wisp.app.viewmodel.DeleteBackupStatus
@@ -268,9 +269,12 @@ fun WalletScreen(
walletState = walletState,
connectionString = viewModel.connectionString.collectAsState().value,
statusLines = viewModel.statusLines.collectAsState().value,
nwcRestoreState = viewModel.nwcRestoreState.collectAsState().value,
onConnectionStringChange = { viewModel.updateConnectionString(it) },
onConnect = { viewModel.connectNwcWallet() },
onDisconnect = { viewModel.disconnectWallet() },
onRestoreFromBackup = { viewModel.restoreFromNwcBackup() },
onDismissRestore = { viewModel.dismissNwcRestore() },
onClose = { viewModel.navigateHome() }
)
is WalletPage.SparkSetup -> SparkSetupContent(
@@ -600,9 +604,12 @@ private fun WalletConnectionContent(
walletState: WalletState,
connectionString: String,
statusLines: List<String>,
nwcRestoreState: NwcRestoreState = NwcRestoreState.Idle,
onConnectionStringChange: (String) -> Unit,
onConnect: () -> Unit,
onDisconnect: () -> Unit,
onRestoreFromBackup: () -> Unit = {},
onDismissRestore: () -> Unit = {},
onClose: () -> Unit = {}
) {
val context = LocalContext.current
@@ -668,6 +675,51 @@ private fun WalletConnectionContent(
.padding(horizontal = 16.dp)
)
if (nwcRestoreState is NwcRestoreState.Found) {
Spacer(Modifier.height(16.dp))
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = !isConnecting, onClick = onRestoreFromBackup),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Outlined.CloudDownload,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
stringResource(R.string.wallet_nwc_restore_title),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
stringResource(R.string.wallet_nwc_restore_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
IconButton(onClick = onDismissRestore, enabled = !isConnecting) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.btn_cancel),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
}
Spacer(Modifier.height(24.dp))
// Paste / Scan card — top half displays the pasted string (or
@@ -81,6 +81,13 @@ sealed class AutoCheckState {
object NotFound : AutoCheckState()
}
sealed class NwcRestoreState {
object Idle : NwcRestoreState()
object Searching : NwcRestoreState()
data class Found(val uri: String) : NwcRestoreState()
object NotFound : NwcRestoreState()
}
sealed class FeeState {
object Idle : FeeState()
object Loading : FeeState()
@@ -254,6 +261,11 @@ class WalletViewModel(
private val _autoCheckState = MutableStateFlow<AutoCheckState>(AutoCheckState.Idle)
val autoCheckState: StateFlow<AutoCheckState> = _autoCheckState
// NWC connection-string restore from NIP-78 backup (per NWC_BACKUP_PARITY.md)
private val _nwcRestoreState = MutableStateFlow<NwcRestoreState>(NwcRestoreState.Idle)
val nwcRestoreState: StateFlow<NwcRestoreState> = _nwcRestoreState
private var nwcRestoreJob: Job? = null
// Per-relay backup status
private val _relayBackupStatuses = MutableStateFlow<List<RelayBackupInfo>>(emptyList())
val relayBackupStatuses: StateFlow<List<RelayBackupInfo>> = _relayBackupStatuses
@@ -297,6 +309,7 @@ class WalletViewModel(
relayPool.registerDedupBypass("auto-check-")
relayPool.registerDedupBypass("wallet-backup-")
relayPool.registerDedupBypass("delete-backup-")
relayPool.registerDedupBypass("nwc-restore-")
val mode = walletModeRepo.getMode()
when (mode) {
@@ -424,6 +437,7 @@ class WalletViewModel(
fun selectNwcMode() {
navigateTo(WalletPage.NwcSetup)
searchNwcBackup()
}
fun selectSparkMode() {
@@ -567,6 +581,114 @@ class WalletViewModel(
_autoCheckState.value = AutoCheckState.Idle
}
// --- NWC backup (NIP-78 kind 30078, d=nwc-wallet-backup) ---
//
// Cross-platform with iOS per `NWC_BACKUP_PARITY.md`. Publish on every
// successful connect (best-effort); search on NWC setup screen open and
// surface a "Restore previous wallet" affordance when found.
private fun searchNwcBackup() {
val signer = buildSigner() ?: run {
_nwcRestoreState.value = NwcRestoreState.NotFound
return
}
// Don't re-search while one is in flight or already has a result the
// user hasn't dismissed yet.
if (_nwcRestoreState.value is NwcRestoreState.Searching) return
nwcRestoreJob?.cancel()
_nwcRestoreState.value = NwcRestoreState.Searching
nwcRestoreJob = viewModelScope.launch {
try {
relayPool.ensureWriteRelaysConnected()
val pubkey = signer.pubkeyHex
val subId = "nwc-restore-${System.currentTimeMillis()}"
val filter = Nip78.nwcBackupFilter(pubkey)
val seenIds = mutableSetOf<String>()
val events = mutableListOf<NostrEvent>()
var eoseCount = 0
val collectJob = launch {
relayPool.relayEvents.collect { relayEvent: RelayEvent ->
if (relayEvent.subscriptionId == subId && seenIds.add(relayEvent.event.id)) {
events.add(relayEvent.event)
}
}
}
val eoseJob = launch {
relayPool.eoseSignals.collect { id ->
if (id == subId) eoseCount++
}
}
yield()
val allCount = relayPool.getRelayUrls().size
val minEose = (allCount * 2 + 2) / 3
relayPool.sendToAll(ClientMessage.req(subId, filter))
withTimeoutOrNull(10_000) {
while (eoseCount < allCount) {
delay(200)
if (eoseCount >= minEose && events.isNotEmpty()) break
}
}
collectJob.cancel()
eoseJob.cancel()
relayPool.closeOnAllRelays(subId)
val newest = events
.filter { !it.content.isBlank() }
.maxByOrNull { it.created_at }
if (newest == null) {
_nwcRestoreState.value = NwcRestoreState.NotFound
return@launch
}
val uri = withContext(Dispatchers.Default) {
Nip78.decryptNwcBackup(signer, newest)
}
if (uri.isNullOrBlank()) {
_nwcRestoreState.value = NwcRestoreState.NotFound
} else {
// Don't offer to restore if it's already the active connection.
val active = nwcRepo.getConnectionString()
if (active != null && active.trim() == uri.trim()) {
_nwcRestoreState.value = NwcRestoreState.NotFound
} else {
_nwcRestoreState.value = NwcRestoreState.Found(uri)
}
}
} catch (_: Exception) {
_nwcRestoreState.value = NwcRestoreState.NotFound
}
}
}
fun restoreFromNwcBackup() {
val state = _nwcRestoreState.value
if (state is NwcRestoreState.Found) {
_nwcRestoreState.value = NwcRestoreState.Idle
_connectionString.value = state.uri
connectNwcWallet(state.uri)
}
}
fun dismissNwcRestore() {
nwcRestoreJob?.cancel()
_nwcRestoreState.value = NwcRestoreState.Idle
}
private suspend fun publishNwcBackup(uri: String) {
val signer = buildSigner() ?: return
val trimmed = uri.trim()
if (trimmed.isEmpty()) return
try {
relayPool.ensureWriteRelaysConnected()
val event = withContext(Dispatchers.Default) {
Nip78.createNwcBackupEvent(signer, trimmed)
}
val sent = relayPool.sendToWriteRelays(ClientMessage.event(event))
Log.d("NwcBackup", "publish: sent to $sent relays")
} catch (e: Exception) {
Log.d("NwcBackup", "publish failed (non-fatal): ${e.message}")
}
}
// --- NWC Connection ---
fun updateConnectionString(value: String) {
@@ -714,6 +836,13 @@ class WalletViewModel(
// needed there.
if (provider === nwcRepo) {
launch { nwcRepo.fetchNodeInfo() }
// Best-effort cross-device backup of the URI per
// NWC_BACKUP_PARITY.md. Publish each connect so a
// reconnect / wallet swap replaces the prior backup.
val uri = nwcRepo.getConnectionString()
if (!uri.isNullOrBlank()) {
launch { publishNwcBackup(uri) }
}
}
}
}
+2
View File
@@ -736,6 +736,8 @@
<string name="wallet_nwc_subtitle">Paste a connection string from Alby, Zeus, Rizful, Minibits, etc.</string>
<string name="wallet_nwc_paste_prompt">Paste the connection string from your NWC-compatible wallet.</string>
<string name="wallet_nwc_connection_hint">Connection string starts with nostr+walletconnect://</string>
<string name="wallet_nwc_restore_title">Restore previous wallet</string>
<string name="wallet_nwc_restore_subtitle">Encrypted NWC backup from another device</string>
<string name="spark_setup_subtitle">Self-custodial Lightning, powered by Spark and Breez.</string>
<string name="wallet_create_title">Create new wallet</string>
<string name="wallet_create_subtitle">Generate a fresh 12-word seed phrase</string>