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:
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user