Merge pull request #1828 from vitorpamplona/claude/implement-nip47-wallet-Fjcds

Implement NIP-47 Wallet Connect protocol with full method support
This commit is contained in:
Vitor Pamplona
2026-03-14 11:32:51 -04:00
committed by GitHub
43 changed files with 5927 additions and 66 deletions
@@ -171,6 +171,7 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftEventCache
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.Request
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -591,6 +592,14 @@ class Account(
suspend fun calculateZappedAmount(zappedNote: Note): BigDecimal = zappedNote.zappedAmountWithNWCPayments(nip47SignerState)
suspend fun sendNwcRequest(
request: Request,
onResponse: (Response?) -> Unit,
) {
val (event, relay) = nip47SignerState.sendNwcRequest(request, onResponse)
client.send(event, setOf(relay))
}
suspend fun sendZapPaymentRequestFor(
bolt11: String,
zappedNote: Note?,
@@ -138,6 +138,45 @@ class NwcSignerState(
return zapPaymentResponseDecryptionCache.value.decryptResponse(event)
}
/**
* Sends a generic NIP-47 request to the connected wallet.
* Subscribes to responses and waits up to 60s for a reply.
*
* @param request the NIP-47 request to send
* @param onResponse callback to handle the response from the wallet
* @return a pair containing the request event and target relay URL
* @throws IllegalArgumentException if no NIP-47 wallet is set up
*/
suspend fun sendNwcRequest(
request: Request,
onResponse: (Response?) -> Unit,
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
val walletService = nip47Setup.value ?: throw IllegalArgumentException("No NIP47 setup")
val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, nip47Signer.value)
val filter =
NWCPaymentQueryState(
fromServiceHex = walletService.pubKeyHex,
toUserHex = event.pubKey,
replyingToHex = event.id,
relay = walletService.relayUri,
)
nwcFilterAssembler.subscribe(filter)
scope.launch(Dispatchers.IO) {
delay(60000)
nwcFilterAssembler.unsubscribe(filter)
}
cache.consume(event, null, true, walletService.relayUri) {
onResponse(decryptResponse(it))
}
return Pair(event, walletService.relayUri)
}
/**
* Sends a zap payment request to a connected Lightning wallet.
* Subscribes to responses and waits up to 60s for a reply.
@@ -120,6 +120,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScr
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletSendScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletTransactionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog
import com.vitorpamplona.amethyst.ui.uriToRoute
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
@@ -152,6 +156,11 @@ fun AppNavigation(
composable<Route.Notification> { NotificationScreen(accountViewModel, nav) }
composable<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
composableFromEnd<Route.Wallet> { WalletScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletSend> { WalletSendScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletReceive> { WalletReceiveScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletTransactions> { WalletTransactionsScreen(accountViewModel, nav) }
composableFromEnd<Route.Lists> { ListOfPeopleListsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.MyPeopleListView> { PeopleListScreen(it.dTag, accountViewModel, nav) }
composableFromEndArgs<Route.MyFollowPackView> { FollowPackScreen(it.dTag, accountViewModel, nav) }
@@ -46,6 +46,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.AccountBalanceWallet
import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.material.icons.outlined.Drafts
import androidx.compose.material.icons.outlined.GroupAdd
@@ -463,6 +464,14 @@ fun ListContent(
route = Route.Drafts,
)
NavigationRow(
title = R.string.wallet,
icon = Icons.Outlined.AccountBalanceWallet,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Wallet,
)
NavigationRow(
title = R.string.route_chess,
icon = R.drawable.ic_chess,
@@ -43,6 +43,14 @@ sealed class Route {
@Serializable object Chess : Route()
@Serializable object Wallet : Route()
@Serializable object WalletSend : Route()
@Serializable object WalletReceive : Route()
@Serializable object WalletTransactions : Route()
@Serializable object Search : Route()
@Serializable object SecurityFilters : Route()
@@ -0,0 +1,268 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.ui.stringRes
import java.text.NumberFormat
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WalletReceiveScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val walletViewModel: WalletViewModel = viewModel()
LaunchedEffect(accountViewModel) {
walletViewModel.init(accountViewModel.account)
}
DisposableEffect(Unit) {
onDispose { walletViewModel.resetReceiveState() }
}
val receiveState by walletViewModel.receiveState.collectAsState()
var amountText by remember { mutableStateOf("") }
var descriptionText by remember { mutableStateOf("") }
val context = LocalContext.current
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringRes(R.string.wallet_receive)) },
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringRes(R.string.back),
)
}
},
)
},
) { padding ->
Column(
modifier =
Modifier
.padding(padding)
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (val state = receiveState) {
is ReceiveState.Idle -> {
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = amountText,
onValueChange = { amountText = it.filter { c -> c.isDigit() } },
label = { Text(stringRes(R.string.wallet_amount_sats)) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = descriptionText,
onValueChange = { descriptionText = it },
label = { Text(stringRes(R.string.wallet_description)) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
singleLine = true,
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
val amount = amountText.toLongOrNull()
if (amount != null && amount > 0) {
walletViewModel.createInvoice(
amountSats = amount,
description = descriptionText.ifBlank { null },
)
}
},
modifier =
Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(16.dp),
enabled = amountText.isNotBlank() && (amountText.toLongOrNull() ?: 0L) > 0,
) {
Text(
stringRes(R.string.wallet_create_invoice),
fontWeight = FontWeight.SemiBold,
)
}
}
is ReceiveState.Creating -> {
Spacer(modifier = Modifier.weight(1f))
CircularProgressIndicator(modifier = Modifier.size(48.dp))
Spacer(modifier = Modifier.height(16.dp))
Text(
stringRes(R.string.wallet_creating_invoice),
style = MaterialTheme.typography.bodyLarge,
)
Spacer(modifier = Modifier.weight(1f))
}
is ReceiveState.Created -> {
Spacer(modifier = Modifier.height(8.dp))
val formattedAmount =
remember(state.amount) {
val fmt = NumberFormat.getIntegerInstance()
fmt.format(state.amount)
}
Text(
text = "$formattedAmount ${stringRes(R.string.wallet_sats)}",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(16.dp))
QrCodeDrawer(
contents = state.invoice,
modifier =
Modifier
.fillMaxWidth()
.weight(1f),
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = state.invoice,
style = MaterialTheme.typography.bodySmall,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(
onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("invoice", state.invoice))
},
modifier =
Modifier
.weight(1f)
.height(48.dp),
shape = RoundedCornerShape(16.dp),
) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringRes(R.string.wallet_copy_invoice))
}
}
Spacer(modifier = Modifier.height(24.dp))
}
is ReceiveState.Error -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
state.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyLarge,
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { walletViewModel.resetReceiveState() }) {
Text(stringRes(R.string.back))
}
}
}
}
}
}
}
@@ -0,0 +1,278 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import java.text.NumberFormat
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WalletScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val walletViewModel: WalletViewModel = viewModel()
walletViewModel.init(accountViewModel.account)
val hasWallet by walletViewModel.hasWalletSetup.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringRes(R.string.wallet)) },
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringRes(R.string.back),
)
}
},
)
},
) { padding ->
if (!hasWallet) {
NoWalletSetup(
modifier = Modifier.padding(padding),
nav = nav,
)
} else {
WalletHomeContent(
walletViewModel = walletViewModel,
modifier = Modifier.padding(padding),
nav = nav,
)
}
}
}
@Composable
private fun NoWalletSetup(
modifier: Modifier,
nav: INav,
) {
Column(
modifier =
modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringRes(R.string.wallet_no_connection),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringRes(R.string.wallet_no_connection_description),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = { nav.nav(Route.Nip47NWCSetup()) }) {
Text(stringRes(R.string.wallet_setup))
}
}
}
@Composable
private fun WalletHomeContent(
walletViewModel: WalletViewModel,
modifier: Modifier,
nav: INav,
) {
val balance by walletViewModel.balanceSats.collectAsState()
val walletAlias by walletViewModel.walletAlias.collectAsState()
val isLoading by walletViewModel.isLoading.collectAsState()
val error by walletViewModel.error.collectAsState()
LaunchedEffect(Unit) {
walletViewModel.fetchBalance()
walletViewModel.fetchInfo()
}
Column(
modifier =
modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(32.dp))
// Wallet name
if (walletAlias != null) {
Text(
text = walletAlias!!,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
}
// Balance display
if (isLoading && balance == null) {
CircularProgressIndicator(modifier = Modifier.size(48.dp))
} else {
val formattedBalance =
remember(balance) {
val fmt = NumberFormat.getIntegerInstance()
fmt.format(balance ?: 0L)
}
Text(
text = formattedBalance,
fontSize = 48.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = stringRes(R.string.wallet_sats),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Error
if (error != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(modifier = Modifier.weight(1f))
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Button(
onClick = { nav.nav(Route.WalletReceive) },
modifier =
Modifier
.weight(1f)
.height(56.dp),
shape = RoundedCornerShape(16.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
) {
Icon(
imageVector = Icons.Filled.ArrowDownward,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringRes(R.string.wallet_receive), fontWeight = FontWeight.SemiBold)
}
Button(
onClick = { nav.nav(Route.WalletSend) },
modifier =
Modifier
.weight(1f)
.height(56.dp),
shape = RoundedCornerShape(16.dp),
) {
Icon(
imageVector = Icons.Filled.ArrowUpward,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringRes(R.string.wallet_send), fontWeight = FontWeight.SemiBold)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Transactions button
OutlinedButton(
onClick = { nav.nav(Route.WalletTransactions) },
modifier =
Modifier
.fillMaxWidth()
.height(48.dp),
shape = RoundedCornerShape(16.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.List,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringRes(R.string.wallet_transactions))
}
Spacer(modifier = Modifier.height(24.dp))
}
}
@@ -0,0 +1,219 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
import android.content.ClipboardManager
import android.content.Context
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WalletSendScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val walletViewModel: WalletViewModel = viewModel()
LaunchedEffect(accountViewModel) {
walletViewModel.init(accountViewModel.account)
}
DisposableEffect(Unit) {
onDispose { walletViewModel.resetSendState() }
}
val sendState by walletViewModel.sendState.collectAsState()
var invoiceText by remember { mutableStateOf("") }
val context = LocalContext.current
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringRes(R.string.wallet_send)) },
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringRes(R.string.back),
)
}
},
)
},
) { padding ->
Column(
modifier =
Modifier
.padding(padding)
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (val state = sendState) {
is SendState.Idle -> {
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = invoiceText,
onValueChange = { invoiceText = it },
label = { Text(stringRes(R.string.wallet_paste_invoice)) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
minLines = 3,
maxLines = 5,
trailingIcon = {
IconButton(onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = clipboard.primaryClip
if (clip != null && clip.itemCount > 0) {
invoiceText = clip.getItemAt(0).text?.toString() ?: ""
}
}) {
Icon(
imageVector = Icons.Filled.ContentPaste,
contentDescription = "Paste",
)
}
},
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
if (invoiceText.isNotBlank()) {
walletViewModel.sendPayment(invoiceText.trim())
}
},
modifier =
Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(16.dp),
enabled = invoiceText.isNotBlank(),
) {
Text(
stringRes(R.string.wallet_pay),
fontWeight = FontWeight.SemiBold,
)
}
}
is SendState.Sending -> {
Spacer(modifier = Modifier.weight(1f))
CircularProgressIndicator(modifier = Modifier.size(48.dp))
Spacer(modifier = Modifier.height(16.dp))
Text(
stringRes(R.string.wallet_payment_sending),
style = MaterialTheme.typography.bodyLarge,
)
Spacer(modifier = Modifier.weight(1f))
}
is SendState.Success -> {
Spacer(modifier = Modifier.weight(1f))
Icon(
imageVector = Icons.Filled.CheckCircle,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
stringRes(R.string.wallet_payment_success),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = { nav.popBack() },
modifier =
Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(16.dp),
) {
Text(stringRes(R.string.back))
}
Spacer(modifier = Modifier.height(24.dp))
}
is SendState.Error -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
state.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyLarge,
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { walletViewModel.resetSendState() }) {
Text(stringRes(R.string.back))
}
}
}
}
}
}
}
@@ -0,0 +1,328 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip47WalletConnect.NwcTransaction
import com.vitorpamplona.quartz.nip47WalletConnect.NwcTransactionType
import java.text.NumberFormat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WalletTransactionsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val walletViewModel: WalletViewModel = viewModel()
LaunchedEffect(accountViewModel) {
walletViewModel.init(accountViewModel.account)
walletViewModel.fetchTransactions()
}
val transactions by walletViewModel.transactions.collectAsState()
val isLoading by walletViewModel.isLoading.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringRes(R.string.wallet_transactions)) },
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringRes(R.string.back),
)
}
},
actions = {
IconButton(onClick = { walletViewModel.fetchTransactions() }) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = stringRes(R.string.wallet_refresh),
)
}
},
)
},
) { padding ->
if (isLoading && transactions.isEmpty()) {
Column(
modifier =
Modifier
.padding(padding)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator(modifier = Modifier.size(48.dp))
Spacer(modifier = Modifier.height(16.dp))
Text(
stringRes(R.string.wallet_loading),
style = MaterialTheme.typography.bodyLarge,
)
}
} else if (transactions.isEmpty()) {
Column(
modifier =
Modifier
.padding(padding)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
stringRes(R.string.wallet_no_transactions),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(
modifier = Modifier.padding(padding),
) {
items(transactions) { tx ->
TransactionItem(tx, accountViewModel, nav)
HorizontalDivider()
}
}
}
}
}
@Composable
private fun TransactionItem(
tx: NwcTransaction,
accountViewModel: AccountViewModel,
nav: INav,
) {
val isIncoming = tx.type == NwcTransactionType.INCOMING
val amountSats = (tx.amount ?: 0L) / 1000L
val formattedAmount =
remember(amountSats) {
val fmt = NumberFormat.getIntegerInstance()
(if (isIncoming) "+" else "-") + fmt.format(amountSats)
}
val dateText =
remember(tx.created_at) {
tx.created_at?.let {
val sdf = SimpleDateFormat("MMM d, HH:mm", Locale.getDefault())
sdf.format(Date(it * 1000L))
} ?: ""
}
val parsed = remember(tx.metadata) { tx.parsedMetadata() }
// For incoming: show who sent it (nostr pubkey or payer name/email)
// For outgoing: show who received it (nostr recipient or recipient identifier)
val counterpartyPubkeyHex =
remember(parsed) {
if (isIncoming) parsed?.senderPubkeyHex() else parsed?.recipientPubkeyHex()
}
val counterpartyDisplayName =
remember(parsed) {
if (isIncoming) {
parsed?.senderDisplayName()
} else {
parsed?.recipientIdentifier()
}
}
// Show comment only if it differs from description
val commentText =
remember(parsed, tx.description) {
parsed?.comment?.let { comment ->
if (tx.description == null || !comment.equals(tx.description, ignoreCase = true)) {
comment
} else {
null
}
}
}
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (counterpartyPubkeyHex != null) {
UserPicture(
userHex = counterpartyPubkeyHex,
size = 40.dp,
accountViewModel = accountViewModel,
nav = nav,
)
Spacer(modifier = Modifier.width(12.dp))
} else {
Icon(
imageVector =
if (isIncoming) Icons.Filled.ArrowDownward else Icons.Filled.ArrowUpward,
contentDescription =
if (isIncoming) {
stringRes(R.string.wallet_incoming)
} else {
stringRes(R.string.wallet_outgoing)
},
modifier = Modifier.size(40.dp),
tint =
if (isIncoming) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
Spacer(modifier = Modifier.width(12.dp))
}
Column(modifier = Modifier.weight(1f)) {
if (counterpartyPubkeyHex != null) {
TransactionUserName(counterpartyPubkeyHex, counterpartyDisplayName, accountViewModel)
} else if (counterpartyDisplayName != null) {
Text(
text = counterpartyDisplayName,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else {
Text(
text = tx.description ?: if (isIncoming) stringRes(R.string.wallet_incoming) else stringRes(R.string.wallet_outgoing),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (commentText != null) {
Text(
text = commentText,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else if (counterpartyPubkeyHex != null || counterpartyDisplayName != null) {
val descOrType = tx.description ?: if (isIncoming) stringRes(R.string.wallet_incoming) else stringRes(R.string.wallet_outgoing)
Text(
text = descOrType,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = dateText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = "$formattedAmount sats",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color =
if (isIncoming) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onBackground
},
)
}
}
@Composable
private fun TransactionUserName(
pubkeyHex: String,
fallbackName: String?,
accountViewModel: AccountViewModel,
) {
LoadUser(baseUserHex = pubkeyHex, accountViewModel = accountViewModel) { user ->
if (user != null) {
UsernameDisplay(
baseUser = user,
fontWeight = FontWeight.Medium,
accountViewModel = accountViewModel,
)
} else {
Text(
text = fallbackName ?: pubkeyHex.take(8) + "...",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -0,0 +1,283 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoMethod
import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsMethod
import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.NwcErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.NwcTransaction
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
sealed class SendState {
data object Idle : SendState()
data object Sending : SendState()
data class Success(
val preimage: String?,
) : SendState()
data class Error(
val message: String,
) : SendState()
}
sealed class ReceiveState {
data object Idle : ReceiveState()
data object Creating : ReceiveState()
data class Created(
val invoice: String,
val amount: Long,
) : ReceiveState()
data class Error(
val message: String,
) : ReceiveState()
}
class WalletViewModel : ViewModel() {
private var account: Account? = null
private val _hasWalletSetup = MutableStateFlow(false)
val hasWalletSetup = _hasWalletSetup.asStateFlow()
private val _balanceSats = MutableStateFlow<Long?>(null)
val balanceSats = _balanceSats.asStateFlow()
private val _walletAlias = MutableStateFlow<String?>(null)
val walletAlias = _walletAlias.asStateFlow()
private val _transactions = MutableStateFlow<List<NwcTransaction>>(emptyList())
val transactions = _transactions.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading = _isLoading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error = _error.asStateFlow()
private val _sendState = MutableStateFlow<SendState>(SendState.Idle)
val sendState = _sendState.asStateFlow()
private val _receiveState = MutableStateFlow<ReceiveState>(ReceiveState.Idle)
val receiveState = _receiveState.asStateFlow()
fun init(account: Account) {
this.account = account
_hasWalletSetup.value = account.nip47SignerState?.hasWalletConnectSetup() == true
}
fun refreshWalletSetup() {
_hasWalletSetup.value = account?.nip47SignerState?.hasWalletConnectSetup() == true
}
fun fetchBalance() {
val acc = account ?: return
viewModelScope.launch(Dispatchers.IO) {
_isLoading.value = true
_error.value = null
try {
acc.sendNwcRequest(GetBalanceMethod.create()) { response ->
when (response) {
is GetBalanceSuccessResponse -> {
// NWC balance is in millisats, convert to sats
_balanceSats.value = (response.result?.balance ?: 0L) / 1000L
}
is NwcErrorResponse -> {
_error.value = response.error?.message ?: "Balance request failed"
}
else -> {}
}
_isLoading.value = false
}
} catch (e: Exception) {
_error.value = e.message
_isLoading.value = false
}
}
}
fun fetchInfo() {
val acc = account ?: return
viewModelScope.launch(Dispatchers.IO) {
try {
acc.sendNwcRequest(GetInfoMethod.create()) { response ->
when (response) {
is GetInfoSuccessResponse -> {
_walletAlias.value = response.result?.alias
}
else -> {}
}
}
} catch (e: Exception) {
// ignore info errors
}
}
}
fun fetchTransactions(
limit: Int = 20,
offset: Int = 0,
) {
val acc = account ?: return
viewModelScope.launch(Dispatchers.IO) {
_isLoading.value = true
try {
acc.sendNwcRequest(
ListTransactionsMethod.create(
limit = limit,
offset = offset,
unpaid = false,
),
) { response ->
when (response) {
is ListTransactionsSuccessResponse -> {
_transactions.value = response.result?.transactions ?: emptyList()
}
is NwcErrorResponse -> {
_error.value = response.error?.message ?: "Failed to load transactions"
}
else -> {}
}
_isLoading.value = false
}
} catch (e: Exception) {
_error.value = e.message
_isLoading.value = false
}
}
}
fun sendPayment(bolt11: String) {
val acc = account ?: return
viewModelScope.launch(Dispatchers.IO) {
_sendState.value = SendState.Sending
try {
acc.sendNwcRequest(PayInvoiceMethod.create(bolt11)) { response ->
when (response) {
is PayInvoiceSuccessResponse -> {
_sendState.value = SendState.Success(response.result?.preimage)
// Refresh balance after payment
fetchBalance()
}
is PayInvoiceErrorResponse -> {
_sendState.value =
SendState.Error(
response.error?.message ?: "Payment failed",
)
}
is NwcErrorResponse -> {
_sendState.value =
SendState.Error(
response.error?.message ?: "Payment failed",
)
}
else -> {
_sendState.value = SendState.Error("Unexpected response")
}
}
}
} catch (e: Exception) {
_sendState.value = SendState.Error(e.message ?: "Payment failed")
}
}
}
fun createInvoice(
amountSats: Long,
description: String? = null,
) {
val acc = account ?: return
viewModelScope.launch(Dispatchers.IO) {
_receiveState.value = ReceiveState.Creating
try {
// NWC expects millisats
acc.sendNwcRequest(
MakeInvoiceMethod.create(
amount = amountSats * 1000L,
description = description,
),
) { response ->
when (response) {
is MakeInvoiceSuccessResponse -> {
val invoice = response.result?.invoice
if (invoice != null) {
_receiveState.value = ReceiveState.Created(invoice, amountSats)
} else {
_receiveState.value = ReceiveState.Error("No invoice returned")
}
}
is NwcErrorResponse -> {
_receiveState.value =
ReceiveState.Error(
response.error?.message ?: "Invoice creation failed",
)
}
else -> {
_receiveState.value = ReceiveState.Error("Unexpected response")
}
}
}
} catch (e: Exception) {
_receiveState.value = ReceiveState.Error(e.message ?: "Invoice creation failed")
}
}
}
fun resetSendState() {
_sendState.value = SendState.Idle
}
fun resetReceiveState() {
_receiveState.value = ReceiveState.Idle
}
fun clearError() {
_error.value = null
}
}
+23
View File
@@ -1236,6 +1236,29 @@
<string name="route_global">Global</string>
<string name="route_video">Shorts</string>
<string name="route_chess">Chess</string>
<string name="wallet">Wallet</string>
<string name="wallet_balance">Balance</string>
<string name="wallet_send">Send</string>
<string name="wallet_receive">Receive</string>
<string name="wallet_transactions">Transactions</string>
<string name="wallet_no_connection">No wallet connected</string>
<string name="wallet_no_connection_description">Set up a Nostr Wallet Connect (NWC) connection in your zap settings to use the wallet.</string>
<string name="wallet_setup">Set Up Wallet</string>
<string name="wallet_sats">sats</string>
<string name="wallet_paste_invoice">Paste a BOLT-11 invoice</string>
<string name="wallet_pay">Pay</string>
<string name="wallet_payment_success">Payment successful</string>
<string name="wallet_payment_sending">Sending payment…</string>
<string name="wallet_amount_sats">Amount (sats)</string>
<string name="wallet_description">Description (optional)</string>
<string name="wallet_create_invoice">Create Invoice</string>
<string name="wallet_creating_invoice">Creating invoice…</string>
<string name="wallet_copy_invoice">Copy Invoice</string>
<string name="wallet_no_transactions">No transactions yet</string>
<string name="wallet_loading">Loading…</string>
<string name="wallet_incoming">Received</string>
<string name="wallet_outgoing">Sent</string>
<string name="wallet_refresh">Refresh</string>
<string name="route_security_filters">Security Filters</string>
<string name="route_import_follows">Import Follows</string>
@@ -52,25 +52,47 @@ class LnZapPaymentRequestEvent(
return OptimizedJsonMapper.fromJsonTo<Request>(jsonText)
}
fun encryptionScheme() = tags.firstOrNull { it.size > 1 && it[0] == "encryption" }?.get(1)
companion object {
const val KIND = 23194
const val ALT = "Zap payment request"
const val ALT = "NWC request"
suspend fun create(
lnInvoice: String,
walletServicePubkey: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): LnZapPaymentRequestEvent {
val serializedRequest = OptimizedJsonMapper.toJson(PayInvoiceMethod.create(lnInvoice))
): LnZapPaymentRequestEvent =
createRequest(
PayInvoiceMethod.create(lnInvoice),
walletServicePubkey,
signer,
createdAt,
)
val tags = arrayOf(arrayOf("p", walletServicePubkey), AltTag.assemble(ALT))
suspend fun createRequest(
request: Request,
walletServicePubkey: String,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
useNip44: Boolean = false,
): LnZapPaymentRequestEvent {
val serializedRequest = OptimizedJsonMapper.toJson(request)
val tags =
if (useNip44) {
arrayOf(arrayOf("p", walletServicePubkey), AltTag.assemble(ALT), arrayOf("encryption", "nip44_v2"))
} else {
arrayOf(arrayOf("p", walletServicePubkey), AltTag.assemble(ALT))
}
val encrypted =
signer.nip04Encrypt(
serializedRequest,
walletServicePubkey,
)
if (useNip44) {
signer.nip44Encrypt(serializedRequest, walletServicePubkey)
} else {
signer.nip04Encrypt(serializedRequest, walletServicePubkey)
}
return signer.sign(createdAt, KIND, tags, encrypted)
}
@@ -26,6 +26,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class LnZapPaymentResponseEvent(
@@ -55,6 +57,43 @@ class LnZapPaymentResponseEvent(
companion object {
const val KIND = 23195
const val ALT = "Zap payment response"
const val ALT = "NWC response"
/**
* Creates an NWC response event (server-side).
*
* @param response the NWC response object to send
* @param requestEvent the original request event being responded to
* @param signer the wallet service signer
* @param useNip44 whether to use NIP-44 encryption (default: false for NIP-04)
* @param createdAt event timestamp
*/
suspend fun createResponse(
response: Response,
requestEvent: LnZapPaymentRequestEvent,
signer: NostrSigner,
useNip44: Boolean = false,
createdAt: Long = TimeUtils.now(),
): LnZapPaymentResponseEvent {
val serializedResponse = OptimizedJsonMapper.toJson(response)
val clientPubkey = requestEvent.pubKey
val tags =
arrayOf(
arrayOf("p", clientPubkey),
arrayOf("e", requestEvent.id),
AltTag.assemble(ALT),
)
val encrypted =
if (useNip44) {
signer.nip44Encrypt(serializedResponse, clientPubkey)
} else {
signer.nip04Encrypt(serializedResponse, clientPubkey)
}
return signer.sign(createdAt, KIND, tags, encrypted)
}
}
}
@@ -0,0 +1,260 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip47WalletConnect
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
/**
* High-level NIP-47 Wallet Connect client.
*
* Simplifies the NWC protocol by handling URI parsing, signer creation,
* event building, filter construction, and response decryption.
*
* Usage:
* ```kotlin
* val client = Nip47Client.fromUri("nostr+walletconnect://pubkey?relay=...&secret=...")
*
* // Build a request event
* val requestEvent = client.payInvoice("lnbc50n1...")
*
* // Send requestEvent to client.relayUrl via your relay connection
* // Subscribe using client.responseFilter(requestEvent.id) for the response
*
* // When response arrives:
* val response = client.parseResponse(responseEvent)
* when (response) {
* is PayInvoiceSuccessResponse -> println("Paid! Preimage: ${response.result?.preimage}")
* is NwcErrorResponse -> println("Error: ${response.error?.message}")
* }
* ```
*/
class Nip47Client(
val walletPubKeyHex: HexKey,
val relayUrl: NormalizedRelayUrl,
val signer: NostrSigner,
val useNip44: Boolean = false,
) {
companion object {
/**
* Creates an Nip47Client from a NWC connection URI string.
*
* @param uri NWC URI (e.g., "nostr+walletconnect://pubkey?relay=...&secret=...")
* @throws IllegalArgumentException if the URI is invalid or has no secret
*/
fun fromUri(uri: String): Nip47Client {
val config = Nip47WalletConnect.parse(uri)
return fromNip47URI(config)
}
/**
* Creates an Nip47Client from parsed NWC connection details.
*
* @param config parsed NWC URI with wallet pubkey, relay, and secret
* @throws IllegalArgumentException if config has no secret
*/
fun fromNip47URI(config: Nip47WalletConnect.Nip47URINorm): Nip47Client {
val secret = config.secret ?: throw IllegalArgumentException("NWC connection requires a secret")
val signer = NostrSignerInternal(KeyPair(secret.hexToByteArray()))
return Nip47Client(
walletPubKeyHex = config.pubKeyHex,
relayUrl = config.relayUri,
signer = signer,
)
}
}
// --- Request builders ---
/**
* Builds a pay_invoice request event.
*/
suspend fun payInvoice(
bolt11: String,
amount: Long? = null,
): LnZapPaymentRequestEvent =
buildRequest(
if (amount != null) {
PayInvoiceMethod.create(bolt11, amount)
} else {
PayInvoiceMethod.create(bolt11)
},
)
/**
* Builds a pay_keysend request event.
*/
suspend fun payKeysend(
amount: Long,
pubkey: String,
preimage: String? = null,
tlvRecords: List<TlvRecord>? = null,
): LnZapPaymentRequestEvent = buildRequest(PayKeysendMethod.create(amount, pubkey, preimage, tlvRecords))
/**
* Builds a get_balance request event.
*/
suspend fun getBalance(): LnZapPaymentRequestEvent = buildRequest(GetBalanceMethod.create())
/**
* Builds a get_info request event.
*/
suspend fun getInfo(): LnZapPaymentRequestEvent = buildRequest(GetInfoMethod.create())
/**
* Builds a make_invoice request event.
*/
suspend fun makeInvoice(
amount: Long,
description: String? = null,
descriptionHash: String? = null,
expiry: Long? = null,
): LnZapPaymentRequestEvent = buildRequest(MakeInvoiceMethod.create(amount, description, descriptionHash, expiry))
/**
* Builds a lookup_invoice request event by payment hash.
*/
suspend fun lookupInvoiceByHash(paymentHash: String): LnZapPaymentRequestEvent = buildRequest(LookupInvoiceMethod.createByHash(paymentHash))
/**
* Builds a lookup_invoice request event by BOLT11 invoice.
*/
suspend fun lookupInvoiceByInvoice(invoice: String): LnZapPaymentRequestEvent = buildRequest(LookupInvoiceMethod.createByInvoice(invoice))
/**
* Builds a list_transactions request event.
*/
suspend fun listTransactions(
from: Long? = null,
until: Long? = null,
limit: Int? = null,
offset: Int? = null,
unpaid: Boolean? = null,
type: String? = null,
): LnZapPaymentRequestEvent = buildRequest(ListTransactionsMethod.create(from, until, limit, offset, unpaid, type))
/**
* Builds a get_budget request event.
*/
suspend fun getBudget(): LnZapPaymentRequestEvent = buildRequest(GetBudgetMethod.create())
/**
* Builds a sign_message request event.
*/
suspend fun signMessage(message: String): LnZapPaymentRequestEvent = buildRequest(SignMessageMethod.create(message))
/**
* Builds a make_hold_invoice request event.
*/
suspend fun makeHoldInvoice(
amount: Long,
paymentHash: String,
description: String? = null,
descriptionHash: String? = null,
expiry: Long? = null,
minCltvExpiryDelta: Int? = null,
): LnZapPaymentRequestEvent = buildRequest(MakeHoldInvoiceMethod.create(amount, paymentHash, description, descriptionHash, expiry, minCltvExpiryDelta))
/**
* Builds a cancel_hold_invoice request event.
*/
suspend fun cancelHoldInvoice(paymentHash: String): LnZapPaymentRequestEvent = buildRequest(CancelHoldInvoiceMethod.create(paymentHash))
/**
* Builds a settle_hold_invoice request event.
*/
suspend fun settleHoldInvoice(preimage: String): LnZapPaymentRequestEvent = buildRequest(SettleHoldInvoiceMethod.create(preimage))
/**
* Builds a request event from any [Request] object.
* This is the low-level method used by all convenience methods above.
*/
suspend fun buildRequest(request: Request): LnZapPaymentRequestEvent =
LnZapPaymentRequestEvent.createRequest(
request = request,
walletServicePubkey = walletPubKeyHex,
signer = signer,
useNip44 = useNip44,
)
// --- Response handling ---
/**
* Decrypts and parses a response event from the wallet.
*/
suspend fun parseResponse(event: LnZapPaymentResponseEvent): Response = event.decrypt(signer)
/**
* Decrypts and parses a notification event from the wallet.
*/
suspend fun parseNotification(event: NwcNotificationEvent): Notification = event.decryptNotification(signer)
// --- Filter helpers ---
/**
* Creates a filter to subscribe for responses to a specific request.
* Use this to subscribe on [relayUrl] after sending a request event.
*/
fun responseFilter(requestEventId: HexKey): Filter =
Filter(
kinds = listOf(LnZapPaymentResponseEvent.KIND),
authors = listOf(walletPubKeyHex),
tags = mapOf("e" to listOf(requestEventId)),
)
/**
* Creates a filter to subscribe for all responses from the wallet
* directed to this client.
*/
fun allResponsesFilter(since: Long? = null): Filter =
Filter(
kinds = listOf(LnZapPaymentResponseEvent.KIND),
authors = listOf(walletPubKeyHex),
tags = mapOf("p" to listOf(signer.pubKey)),
since = since,
)
/**
* Creates a filter to subscribe for wallet notifications.
*/
fun notificationsFilter(since: Long? = null): Filter =
Filter(
kinds = listOf(NwcNotificationEvent.KIND, NwcNotificationEvent.LEGACY_KIND),
authors = listOf(walletPubKeyHex),
tags = mapOf("p" to listOf(signer.pubKey)),
since = since,
)
/**
* Creates a filter to fetch the wallet's info event (kind 13194).
*/
fun infoFilter(): Filter =
Filter(
kinds = listOf(NwcInfoEvent.KIND),
authors = listOf(walletPubKeyHex),
limit = 1,
)
}
@@ -0,0 +1,301 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip47WalletConnect
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
/**
* High-level NIP-47 Wallet Connect server (wallet service).
*
* Simplifies building a wallet service that receives NWC requests from clients,
* processes them, and sends back responses and notifications.
*
* Usage:
* ```kotlin
* val server = Nip47Server(walletSigner, supportedMethods, relayUrl)
*
* // Publish capabilities
* val infoEvent = server.buildInfoEvent()
* // Send infoEvent to relay
*
* // Subscribe using server.requestsFilter() on your relay
*
* // When a request arrives:
* val request = server.parseRequest(requestEvent)
* when (request) {
* is GetBalanceMethod -> {
* val response = server.respondGetBalance(requestEvent, balance = 2100000L)
* // Send response to relay
* }
* is PayInvoiceMethod -> {
* // Process payment, then:
* val response = server.respondPayInvoice(requestEvent, preimage = "abc123")
* // Or on error:
* val error = server.respondError(requestEvent, NwcErrorCode.PAYMENT_FAILED, "Route not found")
* // Send response to relay
* }
* }
* ```
*/
class Nip47Server(
val signer: NostrSigner,
val capabilities: List<String> = emptyList(),
val useNip44: Boolean = false,
val encryptionSchemes: List<String>? = null,
val notificationTypes: List<String>? = null,
) {
// --- Info event ---
/**
* Builds a kind 13194 info event advertising wallet capabilities.
* Sign and publish this event to your relay.
*/
fun buildInfoEvent() =
NwcInfoEvent.build(
capabilities = capabilities,
encryptionSchemes = encryptionSchemes,
notificationTypes = notificationTypes,
)
// --- Request parsing ---
/**
* Decrypts and parses an incoming client request.
*/
suspend fun parseRequest(event: LnZapPaymentRequestEvent): Request = event.decryptRequest(signer)
// --- Response builders ---
/**
* Builds a response event from any [Response] object.
*/
suspend fun buildResponse(
response: Response,
requestEvent: LnZapPaymentRequestEvent,
): LnZapPaymentResponseEvent =
LnZapPaymentResponseEvent.createResponse(
response = response,
requestEvent = requestEvent,
signer = signer,
useNip44 = useNip44,
)
/**
* Builds an error response for any method.
*/
suspend fun respondError(
requestEvent: LnZapPaymentRequestEvent,
code: NwcErrorCode,
message: String,
resultType: String? = null,
): LnZapPaymentResponseEvent {
val method = resultType ?: requestEvent.decryptRequest(signer).method ?: NwcMethod.PAY_INVOICE
return buildResponse(NwcErrorResponse(method, NwcError(code, message)), requestEvent)
}
/**
* Builds a pay_invoice success response.
*/
suspend fun respondPayInvoice(
requestEvent: LnZapPaymentRequestEvent,
preimage: String? = null,
feesPaid: Long? = null,
): LnZapPaymentResponseEvent =
buildResponse(
PayInvoiceSuccessResponse(PayInvoiceSuccessResponse.PayInvoiceResultParams(preimage, feesPaid)),
requestEvent,
)
/**
* Builds a pay_keysend success response.
*/
suspend fun respondPayKeysend(
requestEvent: LnZapPaymentRequestEvent,
preimage: String? = null,
feesPaid: Long? = null,
): LnZapPaymentResponseEvent =
buildResponse(
PayKeysendSuccessResponse(PayKeysendSuccessResponse.PayKeysendResult(preimage, feesPaid)),
requestEvent,
)
/**
* Builds a get_balance success response.
*/
suspend fun respondGetBalance(
requestEvent: LnZapPaymentRequestEvent,
balance: Long,
): LnZapPaymentResponseEvent =
buildResponse(
GetBalanceSuccessResponse(GetBalanceSuccessResponse.GetBalanceResult(balance)),
requestEvent,
)
/**
* Builds a get_info success response.
*/
suspend fun respondGetInfo(
requestEvent: LnZapPaymentRequestEvent,
alias: String? = null,
color: String? = null,
pubkey: String? = null,
network: String? = null,
blockHeight: Long? = null,
blockHash: String? = null,
methods: List<String>? = null,
notifications: List<String>? = null,
lud16: String? = null,
): LnZapPaymentResponseEvent =
buildResponse(
GetInfoSuccessResponse(
GetInfoSuccessResponse.GetInfoResult(
alias,
color,
pubkey,
network,
blockHeight,
blockHash,
methods,
notifications,
null,
lud16,
),
),
requestEvent,
)
/**
* Builds a make_invoice success response.
*/
suspend fun respondMakeInvoice(
requestEvent: LnZapPaymentRequestEvent,
transaction: NwcTransaction,
): LnZapPaymentResponseEvent = buildResponse(MakeInvoiceSuccessResponse(transaction), requestEvent)
/**
* Builds a lookup_invoice success response.
*/
suspend fun respondLookupInvoice(
requestEvent: LnZapPaymentRequestEvent,
transaction: NwcTransaction,
): LnZapPaymentResponseEvent = buildResponse(LookupInvoiceSuccessResponse(transaction), requestEvent)
/**
* Builds a list_transactions success response.
*/
suspend fun respondListTransactions(
requestEvent: LnZapPaymentRequestEvent,
transactions: List<NwcTransaction>,
totalCount: Long? = null,
): LnZapPaymentResponseEvent =
buildResponse(
ListTransactionsSuccessResponse(
ListTransactionsSuccessResponse.ListTransactionsResult(transactions, totalCount),
),
requestEvent,
)
/**
* Builds a get_budget success response.
*/
suspend fun respondGetBudget(
requestEvent: LnZapPaymentRequestEvent,
usedBudget: Long? = null,
totalBudget: Long? = null,
renewsAt: Long? = null,
renewalPeriod: String? = null,
): LnZapPaymentResponseEvent =
buildResponse(
GetBudgetSuccessResponse(
GetBudgetSuccessResponse.GetBudgetResult(usedBudget, totalBudget, renewsAt, renewalPeriod),
),
requestEvent,
)
/**
* Builds a sign_message success response.
*/
suspend fun respondSignMessage(
requestEvent: LnZapPaymentRequestEvent,
message: String,
signature: String,
): LnZapPaymentResponseEvent =
buildResponse(
SignMessageSuccessResponse(SignMessageSuccessResponse.SignMessageResult(message, signature)),
requestEvent,
)
// --- Notification builders ---
/**
* Builds a payment_received notification event.
*/
suspend fun notifyPaymentReceived(
clientPubkey: HexKey,
transaction: NwcTransaction,
): NwcNotificationEvent =
NwcNotificationEvent.createNotification(
notification = PaymentReceivedNotification(transaction),
clientPubkey = clientPubkey,
signer = signer,
)
/**
* Builds a payment_sent notification event.
*/
suspend fun notifyPaymentSent(
clientPubkey: HexKey,
transaction: NwcTransaction,
): NwcNotificationEvent =
NwcNotificationEvent.createNotification(
notification = PaymentSentNotification(transaction),
clientPubkey = clientPubkey,
signer = signer,
)
/**
* Builds a notification event from any [Notification] object.
*/
suspend fun buildNotification(
notification: Notification,
clientPubkey: HexKey,
): NwcNotificationEvent =
NwcNotificationEvent.createNotification(
notification = notification,
clientPubkey = clientPubkey,
signer = signer,
)
// --- Filter helpers ---
/**
* Creates a filter to subscribe for incoming client requests.
* Use this to subscribe on your relay.
*/
fun requestsFilter(since: Long? = null): Filter =
Filter(
kinds = listOf(LnZapPaymentRequestEvent.KIND),
tags = mapOf("p" to listOf(signer.pubKey)),
since = since,
)
}
@@ -30,11 +30,10 @@ import com.vitorpamplona.quartz.utils.UriParser
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.Serializable
// Rename to the corect nip number when ready.
class Nip47WalletConnect {
companion object {
fun parse(uri: String): Nip47URINorm {
// nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D
// nostr+walletconnect://b889ff5b...?relay=wss%3A%2F%2Frelay.damus.io&secret=...&lud16=user@example.com
val url = UriParser(uri)
@@ -55,8 +54,9 @@ class Nip47WalletConnect {
val relay = url.getQueryParameter("relay") ?: throw IllegalArgumentException("Relay cannot be null")
val relayNorm = RelayUrlNormalizer.normalizeOrNull(relay) ?: throw IllegalArgumentException("Invalid relay Url")
val secret = url.getQueryParameter("secret")
val lud16 = url.getQueryParameter("lud16")
return Nip47URINorm(pubkeyHex, relayNorm, secret)
return Nip47URINorm(pubkeyHex, relayNorm, secret, lud16)
}
}
@@ -65,6 +65,7 @@ class Nip47WalletConnect {
val pubKeyHex: HexKey,
val relayUri: String,
val secret: HexKey?,
val lud16: String? = null,
) {
fun normalize(): Nip47URINorm? =
RelayUrlNormalizer.normalizeOrNull(relayUri)?.let {
@@ -72,6 +73,7 @@ class Nip47WalletConnect {
pubKeyHex,
it,
secret,
lud16,
)
}
@@ -86,7 +88,8 @@ class Nip47WalletConnect {
val pubKeyHex: HexKey,
val relayUri: NormalizedRelayUrl,
val secret: HexKey?,
val lud16: String? = null,
) {
fun denormalize(): Nip47URI? = Nip47URI(pubKeyHex, relayUri.url, secret)
fun denormalize(): Nip47URI? = Nip47URI(pubKeyHex, relayUri.url, secret, lud16)
}
}
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip47WalletConnect
import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable
object NwcNotificationType {
const val PAYMENT_RECEIVED = "payment_received"
const val PAYMENT_SENT = "payment_sent"
const val HOLD_INVOICE_ACCEPTED = "hold_invoice_accepted"
}
// NOTIFICATION OBJECTS
abstract class Notification(
val notification_type: String,
) : OptimizedSerializable
// payment_received notification
class PaymentReceivedNotification(
val notification: NwcTransaction? = null,
) : Notification(NwcNotificationType.PAYMENT_RECEIVED)
// payment_sent notification
class PaymentSentNotification(
val notification: NwcTransaction? = null,
) : Notification(NwcNotificationType.PAYMENT_SENT)
// hold_invoice_accepted notification
class HoldInvoiceAcceptedNotification(
val notification: HoldInvoiceAcceptedData? = null,
) : Notification(NwcNotificationType.HOLD_INVOICE_ACCEPTED)
class HoldInvoiceAcceptedData(
var type: String? = null,
var invoice: String? = null,
var payment_hash: String? = null,
var amount: Long? = null,
var created_at: Long? = null,
var expires_at: Long? = null,
var settle_deadline: Long? = null,
)
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip47WalletConnect
enum class NwcErrorCode {
RATE_LIMITED,
NOT_IMPLEMENTED,
INSUFFICIENT_BALANCE,
PAYMENT_FAILED,
QUOTA_EXCEEDED,
RESTRICTED,
UNAUTHORIZED,
INTERNAL,
UNSUPPORTED_ENCRYPTION,
BAD_REQUEST,
NOT_FOUND,
EXPIRED,
OTHER,
}
class NwcError(
var code: NwcErrorCode? = null,
var message: String? = null,
)
@@ -0,0 +1,69 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip47WalletConnect
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip47WalletConnect.tags.EncryptionTag
import com.vitorpamplona.quartz.nip47WalletConnect.tags.NotificationsTag
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class NwcInfoEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun capabilities(): List<String> = content.split(" ").filter { it.isNotBlank() }
fun supportsMethod(method: String): Boolean = capabilities().contains(method)
fun supportsNotifications(): Boolean = capabilities().contains("notifications")
fun encryptionSchemes() = tags.mapNotNull(EncryptionTag::parse).flatten()
fun notificationTypes() = tags.mapNotNull(NotificationsTag::parse).flatten()
companion object {
const val KIND = 13194
const val ALT_DESCRIPTION = "Wallet service info"
fun build(
capabilities: List<String>,
encryptionSchemes: List<String>? = null,
notificationTypes: List<String>? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<NwcInfoEvent>.() -> Unit = {},
) = eventTemplate(KIND, capabilities.joinToString(" "), createdAt) {
alt(ALT_DESCRIPTION)
encryptionSchemes?.let { addUnique(EncryptionTag.assemble(it)) }
notificationTypes?.let { addUnique(NotificationsTag.assemble(it)) }
initializer()
}
}
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip47WalletConnect
object NwcMethod {
const val PAY_INVOICE = "pay_invoice"
const val PAY_KEYSEND = "pay_keysend"
const val MAKE_INVOICE = "make_invoice"
const val LOOKUP_INVOICE = "lookup_invoice"
const val LIST_TRANSACTIONS = "list_transactions"
const val GET_BALANCE = "get_balance"
const val GET_INFO = "get_info"
const val GET_BUDGET = "get_budget"
const val SIGN_MESSAGE = "sign_message"
const val CREATE_CONNECTION = "create_connection"
const val MAKE_HOLD_INVOICE = "make_hold_invoice"
const val CANCEL_HOLD_INVOICE = "cancel_hold_invoice"
const val SETTLE_HOLD_INVOICE = "settle_hold_invoice"
}
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip47WalletConnect
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class NwcNotificationEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
override fun isContentEncoded() = true
fun clientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.get(1)
fun talkingWith(oneSideHex: String): HexKey = if (pubKey == oneSideHex) clientPubKey() ?: pubKey else pubKey
fun canDecrypt(signer: NostrSigner) = pubKey == signer.pubKey || clientPubKey() == signer.pubKey
suspend fun decryptNotification(signer: NostrSigner): Notification {
if (!canDecrypt(signer)) throw SignerExceptions.UnauthorizedDecryptionException()
val jsonText = signer.decrypt(content, talkingWith(signer.pubKey))
return OptimizedJsonMapper.fromJsonTo<Notification>(jsonText)
}
companion object {
const val KIND = 23197
const val LEGACY_KIND = 23196
const val ALT = "Wallet notification"
/**
* Creates an NWC notification event (server-side).
* Uses NIP-44 encryption (kind 23197).
*
* @param notification the notification to send
* @param clientPubkey the client's public key to encrypt to
* @param signer the wallet service signer
* @param createdAt event timestamp
*/
suspend fun createNotification(
notification: Notification,
clientPubkey: HexKey,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): NwcNotificationEvent {
val serialized = OptimizedJsonMapper.toJson(notification)
val tags =
arrayOf(
arrayOf("p", clientPubkey),
AltTag.assemble(ALT),
)
val encrypted = signer.nip44Encrypt(serialized, clientPubkey)
return signer.sign(createdAt, KIND, tags, encrypted)
}
}
}
@@ -0,0 +1,73 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip47WalletConnect
object NwcTransactionType {
const val INCOMING = "incoming"
const val OUTGOING = "outgoing"
}
object NwcTransactionState {
const val PENDING = "PENDING"
const val SETTLED = "SETTLED"
const val FAILED = "FAILED"
const val ACCEPTED = "ACCEPTED"
fun isSettled(state: String?) = state.equals(SETTLED, ignoreCase = true)
fun isPending(state: String?) = state.equals(PENDING, ignoreCase = true)
fun isFailed(state: String?) = state.equals(FAILED, ignoreCase = true)
fun isAccepted(state: String?) = state.equals(ACCEPTED, ignoreCase = true)
}
object NwcBudgetRenewal {
const val DAILY = "daily"
const val WEEKLY = "weekly"
const val MONTHLY = "monthly"
const val YEARLY = "yearly"
const val NEVER = "never"
}
class NwcTransaction(
var type: String? = null,
var state: String? = null,
var invoice: String? = null,
var description: String? = null,
var description_hash: String? = null,
var preimage: String? = null,
var payment_hash: String? = null,
var amount: Long? = null,
var fees_paid: Long? = null,
var created_at: Long? = null,
var expires_at: Long? = null,
var settled_at: Long? = null,
var settle_deadline: Long? = null,
var metadata: Any? = null,
) {
fun parsedMetadata(): NwcTransactionMetadata? = NwcTransactionMetadata.parse(metadata)
}
class TlvRecord(
var type: Long? = null,
var value: String? = null,
)
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip47WalletConnect
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
class NwcTransactionMetadata(
val comment: String?,
val payerData: PayerData?,
val recipientData: RecipientData?,
val nostr: NostrZapData?,
) {
class PayerData(
val name: String?,
val email: String?,
val pubkey: String?,
)
class RecipientData(
val identifier: String?,
)
class NostrZapData(
val pubkeyHex: String?,
val recipientPubkeyHex: String?,
)
fun senderPubkeyHex(): String? = nostr?.pubkeyHex ?: payerData?.pubkey?.let { decodePublicKeyAsHexOrNull(it) }
fun senderDisplayName(): String? = payerData?.name ?: payerData?.email
fun recipientIdentifier(): String? = recipientData?.identifier
fun recipientPubkeyHex(): String? = nostr?.recipientPubkeyHex
companion object {
fun parse(metadata: Any?): NwcTransactionMetadata? {
val map = metadata as? Map<*, *> ?: return null
val comment = map["comment"] as? String
val payerData = (map["payer_data"] as? Map<*, *>)?.let { pd ->
PayerData(
name = pd["name"] as? String,
email = pd["email"] as? String,
pubkey = pd["pubkey"] as? String,
)
}
val recipientData = (map["recipient_data"] as? Map<*, *>)?.let { rd ->
RecipientData(
identifier = rd["identifier"] as? String,
)
}
val nostr = (map["nostr"] as? Map<*, *>)?.let { n ->
val rawPubkey = n["pubkey"] as? String
val pubkeyHex = rawPubkey?.let { decodePublicKeyAsHexOrNull(it) }
val tags = n["tags"] as? List<*>
val recipientHex = tags?.firstNotNullOfOrNull { tag ->
val tagList = tag as? List<*>
if (tagList != null && tagList.size >= 2 && tagList[0] == "p") {
tagList[1] as? String
} else {
null
}
}
NostrZapData(
pubkeyHex = pubkeyHex,
recipientPubkeyHex = recipientHex,
)
}
if (comment == null && payerData == null && recipientData == null && nostr == null) {
return null
}
return NwcTransactionMetadata(
comment = comment,
payerData = payerData,
recipientData = recipientData,
nostr = nostr,
)
}
}
}
@@ -0,0 +1,362 @@
# NIP-47 Wallet Connect (Quartz)
Quartz implementation of [NIP-47](https://github.com/nostr-protocol/nips/blob/master/47.md) — Nostr
Wallet Connect (NWC). This module provides everything needed to build both **wallet client apps**
(like Amethyst) and **wallet service backends** (like Alby Hub).
## Quick Start — Wallet Client
Use `Nip47Client` for a high-level API that handles URI parsing, signer creation,
event building, filter construction, and response decryption:
```kotlin
// 1. Create client from NWC URI
val client = Nip47Client.fromUri("nostr+walletconnect://pubkey?relay=...&secret=...")
// 2. Build request events — one method per NWC command
val payEvent = client.payInvoice("lnbc50n1...")
val balanceEvent = client.getBalance()
val infoEvent = client.getInfo()
val invoiceEvent = client.makeInvoice(amount = 50000L, description = "Coffee")
val txEvent = client.listTransactions(limit = 20)
// 3. Send event to client.relayUrl via your relay connection
// 4. Subscribe using client.responseFilter(payEvent.id) for the response
// 5. When response arrives, parse it
val response = client.parseResponse(responseEvent)
when (response) {
is PayInvoiceSuccessResponse -> println("Paid! Preimage: ${response.result?.preimage}")
is GetBalanceSuccessResponse -> println("Balance: ${response.result?.balance} msats")
is NwcErrorResponse -> println("Error: ${response.error?.message}")
}
// Filter helpers for relay subscriptions
val filter = client.responseFilter(payEvent.id) // Filter for a specific response
val allFilter = client.allResponsesFilter() // Filter for all responses
val notifFilter = client.notificationsFilter() // Filter for notifications
val walletInfo = client.infoFilter() // Filter for wallet info event
```
## Quick Start — Wallet Service
Use `Nip47Server` to build a wallet service that receives requests and sends responses:
```kotlin
// 1. Create server
val server = Nip47Server(
signer = walletSigner,
capabilities = listOf(NwcMethod.PAY_INVOICE, NwcMethod.GET_BALANCE, NwcMethod.GET_INFO),
)
// 2. Publish capabilities (kind 13194)
val infoTemplate = server.buildInfoEvent()
// Sign and send: walletSigner.sign(infoTemplate)
// 3. Subscribe using server.requestsFilter() on your relay
// 4. When a request arrives, parse and respond
val request = server.parseRequest(requestEvent)
when (request) {
is GetBalanceMethod -> {
val response = server.respondGetBalance(requestEvent, balance = 2100000L)
// Send response to relay
}
is PayInvoiceMethod -> {
// Process payment, then:
val response = server.respondPayInvoice(requestEvent, preimage = "abc123")
// Or on error:
val error = server.respondError(requestEvent, NwcErrorCode.PAYMENT_FAILED, "Route not found")
}
is MakeInvoiceMethod -> {
val tx = NwcTransaction(type = NwcTransactionType.INCOMING, invoice = "lnbc...")
val response = server.respondMakeInvoice(requestEvent, tx)
}
}
// 5. Send notifications
val notifEvent = server.notifyPaymentReceived(clientPubkey, transaction)
```
## Architecture
```
nip47WalletConnect/
├── Nip47Client.kt # High-level client API (URI → requests → responses)
├── Nip47Server.kt # High-level server API (requests → responses → notifications)
├── Nip47WalletConnect.kt # URI parsing (nostr+walletconnect://)
├── Request.kt # All 13 NWC request methods + params
├── Response.kt # All response types (success + error)
├── Notification.kt # Wallet notification types
├── NwcMethod.kt # Method name constants
├── NwcErrorCode.kt # Error codes enum + NwcError
├── NwcTransaction.kt # Transaction, state, budget, TLV models
├── NwcInfoEvent.kt # Kind 13194 — wallet capabilities
├── LnZapPaymentRequestEvent.kt # Kind 23194 — client → wallet request
├── LnZapPaymentResponseEvent.kt # Kind 23195 — wallet → client response
├── NwcNotificationEvent.kt # Kind 23197 — wallet → client notification
├── NostrWalletConnectRequestCache.kt # Request decryption cache
├── NostrWalletConnectResponseCache.kt # Response decryption cache
└── tags/
├── EncryptionTag.kt # "encryption" tag parsing
└── NotificationsTag.kt # "notifications" tag parsing
```
## Event Kinds
| Kind | Class | Direction | Purpose |
|-------|------------------------------|-----------------|----------------------|
| 13194 | `NwcInfoEvent` | Wallet → Relay | Service capabilities |
| 23194 | `LnZapPaymentRequestEvent` | Client → Wallet | NWC request |
| 23195 | `LnZapPaymentResponseEvent` | Wallet → Client | NWC response |
| 23196 | `NwcNotificationEvent` | Wallet → Client | Notification (NIP-04, legacy) |
| 23197 | `NwcNotificationEvent` | Wallet → Client | Notification (NIP-44) |
## Supported Methods
| Method | `Nip47Client` method | Request Class | Success Response Class |
|----------------------|-----------------------------|---------------------------|-----------------------------------|
| `pay_invoice` | `payInvoice()` | `PayInvoiceMethod` | `PayInvoiceSuccessResponse` |
| `pay_keysend` | `payKeysend()` | `PayKeysendMethod` | `PayKeysendSuccessResponse` |
| `make_invoice` | `makeInvoice()` | `MakeInvoiceMethod` | `MakeInvoiceSuccessResponse` |
| `lookup_invoice` | `lookupInvoiceByHash/ByInvoice()` | `LookupInvoiceMethod`| `LookupInvoiceSuccessResponse` |
| `list_transactions` | `listTransactions()` | `ListTransactionsMethod` | `ListTransactionsSuccessResponse` |
| `get_balance` | `getBalance()` | `GetBalanceMethod` | `GetBalanceSuccessResponse` |
| `get_info` | `getInfo()` | `GetInfoMethod` | `GetInfoSuccessResponse` |
| `get_budget` | `getBudget()` | `GetBudgetMethod` | `GetBudgetSuccessResponse` |
| `sign_message` | `signMessage()` | `SignMessageMethod` | `SignMessageSuccessResponse` |
| `create_connection` | `buildRequest()` | `CreateConnectionMethod` | `CreateConnectionSuccessResponse` |
| `make_hold_invoice` | `makeHoldInvoice()` | `MakeHoldInvoiceMethod` | `MakeHoldInvoiceSuccessResponse` |
| `cancel_hold_invoice`| `cancelHoldInvoice()` | `CancelHoldInvoiceMethod` | `CancelHoldInvoiceSuccessResponse`|
| `settle_hold_invoice`| `settleHoldInvoice()` | `SettleHoldInvoiceMethod` | `SettleHoldInvoiceSuccessResponse`|
Any method can also return `NwcErrorResponse` or (for `pay_invoice`) `PayInvoiceErrorResponse`.
## Low-Level API
The high-level `Nip47Client` and `Nip47Server` classes wrap the lower-level event
builders. You can use these directly if you need more control.
### Wallet Client (Low-Level)
#### 1. Parse the NWC Connection URI
```kotlin
val uri = "nostr+walletconnect://b889ff5b...?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c..."
val nwcConfig = Nip47WalletConnect.parse(uri)
```
Supported URI schemes: `nostr+walletconnect://`, `nostrwalletconnect://`,
`amethyst+walletconnect://`
#### 2. Create the Client Signer
```kotlin
val clientSigner = NostrSignerInternal(
KeyPair(nwcConfig.secret!!.hexToByteArray())
)
```
#### 3. Build and Send Requests
```kotlin
val balanceRequest = GetBalanceMethod.create()
val event = LnZapPaymentRequestEvent.createRequest(
request = balanceRequest,
walletServicePubkey = nwcConfig.pubKeyHex,
signer = clientSigner,
)
// Send `event` to `nwcConfig.relayUri`
```
To use NIP-44 encryption instead of NIP-04:
```kotlin
val event = LnZapPaymentRequestEvent.createRequest(
request = GetInfoMethod.create(),
walletServicePubkey = nwcConfig.pubKeyHex,
signer = clientSigner,
useNip44 = true,
)
```
#### 4. Receive and Parse Responses
Subscribe to kind `23195` events on the NWC relay, filtered by the wallet
service pubkey and the request event ID:
```kotlin
val response: Response = responseEvent.decrypt(clientSigner)
when (response) {
is GetBalanceSuccessResponse -> {
val balanceSats = (response.result?.balance ?: 0L) / 1000L
}
is PayInvoiceSuccessResponse -> {
val preimage = response.result?.preimage
}
is NwcErrorResponse -> {
val errorMessage = response.error?.message
}
}
```
#### 5. Listen for Notifications
```kotlin
val notification: Notification = notificationEvent.decryptNotification(clientSigner)
when (notification) {
is PaymentReceivedNotification -> {
val tx: NwcTransaction? = notification.notification
}
is PaymentSentNotification -> {
val tx: NwcTransaction? = notification.notification
}
}
```
### Wallet Service (Low-Level)
#### 1. Publish Capabilities
```kotlin
val infoTemplate = NwcInfoEvent.build(
capabilities = listOf(NwcMethod.PAY_INVOICE, NwcMethod.GET_BALANCE),
encryptionSchemes = listOf("nip04", "nip44_v2"),
notificationTypes = listOf(NwcNotificationType.PAYMENT_RECEIVED),
)
// Sign with wallet signer: walletSigner.sign(infoTemplate)
```
#### 2. Parse Requests and Build Responses
```kotlin
val request: Request = requestEvent.decryptRequest(walletSigner)
// Build response
val balanceResponse = GetBalanceSuccessResponse(
GetBalanceSuccessResponse.GetBalanceResult(balance = 2100000L)
)
val responseEvent = LnZapPaymentResponseEvent.createResponse(
response = balanceResponse,
requestEvent = requestEvent,
signer = walletSigner,
)
// Error response
val errorResponse = NwcErrorResponse(
resultType = NwcMethod.PAY_INVOICE,
error = NwcError(NwcErrorCode.INSUFFICIENT_BALANCE, "Not enough funds"),
)
val errorEvent = LnZapPaymentResponseEvent.createResponse(
response = errorResponse,
requestEvent = requestEvent,
signer = walletSigner,
)
```
#### 3. Send Notifications
```kotlin
val notifEvent = NwcNotificationEvent.createNotification(
notification = PaymentReceivedNotification(
notification = NwcTransaction(
type = NwcTransactionType.INCOMING,
state = NwcTransactionState.SETTLED,
invoice = "lnbc...",
amount = 50000L,
payment_hash = "abc123",
settled_at = TimeUtils.now(),
created_at = TimeUtils.now(),
),
),
clientPubkey = clientPubkeyHex,
signer = walletSigner,
)
```
## Transaction State Helpers
Transaction states from different wallet implementations may use different
casing. Use the case-insensitive helpers:
```kotlin
NwcTransactionState.isSettled(tx.state) // true for "SETTLED" or "settled"
NwcTransactionState.isPending(tx.state) // true for "PENDING" or "pending"
NwcTransactionState.isFailed(tx.state) // true for "FAILED" or "failed"
NwcTransactionState.isAccepted(tx.state) // true for "ACCEPTED" or "accepted"
```
## URI Persistence
```kotlin
// Save
val json = Nip47WalletConnect.Nip47URI.serializer(nwcConfig.denormalize()!!)
// Restore
val restored = Nip47WalletConnect.Nip47URI.parser(json).normalize()!!
```
## Error Codes
| Code | When to Use |
|-------------------------|-------------------------------------------|
| `RATE_LIMITED` | Too many requests |
| `NOT_IMPLEMENTED` | Method not supported by wallet |
| `INSUFFICIENT_BALANCE` | Not enough funds for payment |
| `PAYMENT_FAILED` | Payment could not be completed |
| `QUOTA_EXCEEDED` | Budget/spending limit exceeded |
| `RESTRICTED` | Method not allowed for this connection |
| `UNAUTHORIZED` | Invalid or expired credentials |
| `INTERNAL` | Internal wallet error |
| `UNSUPPORTED_ENCRYPTION`| Requested encryption not supported |
| `BAD_REQUEST` | Malformed request parameters |
| `NOT_FOUND` | Invoice or resource not found |
| `EXPIRED` | Connection or invoice expired |
| `OTHER` | Unspecified error |
## Encryption
NWC supports two encryption schemes:
- **NIP-04** (default): `Nip47Client(useNip44 = false)` or `useNip44 = false` in event builders
- **NIP-44 v2**: `Nip47Client(useNip44 = true)` or `useNip44 = true` in event builders
Clients can check a wallet's supported encryption via the info event:
```kotlin
val infoEvent: NwcInfoEvent = ...
val schemes: List<String> = infoEvent.encryptionSchemes()
// e.g., ["nip04", "nip44_v2"]
```
## Caching
For apps handling many concurrent NWC events, use the built-in LRU caches:
```kotlin
val requestCache = NostrWalletConnectRequestCache(signer)
val responseCache = NostrWalletConnectResponseCache(signer)
val request: Request? = requestCache.decryptRequest(requestEvent)
val response: Response? = responseCache.decryptResponse(responseEvent)
```
## Amounts
All amounts in NWC are in **millisatoshis** (1 sat = 1000 msats). Convert for
display:
```kotlin
val balanceMsats = response.result?.balance ?: 0L
val balanceSats = balanceMsats / 1000L
```
## Interoperability
This implementation is tested against:
- **Alby Hub** (server) — uppercase transaction states, all error codes
- **Alby JS SDK** (client) — lowercase transaction states, budget renewal
periods, structured metadata
See `AlbyInteropTest.kt` for real-world test vectors.
@@ -27,15 +27,232 @@ abstract class Request(
var method: String? = null,
) : OptimizedSerializable
// PayInvoice Call
// pay_invoice
class PayInvoiceParams(
var invoice: String? = null,
var amount: Long? = null,
var metadata: Any? = null,
)
class PayInvoiceMethod(
var params: PayInvoiceParams? = null,
) : Request("pay_invoice") {
) : Request(NwcMethod.PAY_INVOICE) {
companion object {
fun create(bolt11: String): PayInvoiceMethod = PayInvoiceMethod(PayInvoiceParams(bolt11))
fun create(
bolt11: String,
amount: Long,
): PayInvoiceMethod = PayInvoiceMethod(PayInvoiceParams(bolt11, amount))
}
}
// pay_keysend
class PayKeysendParams(
var amount: Long? = null,
var pubkey: String? = null,
var preimage: String? = null,
var tlv_records: List<TlvRecord>? = null,
)
class PayKeysendMethod(
var params: PayKeysendParams? = null,
) : Request(NwcMethod.PAY_KEYSEND) {
companion object {
fun create(
amount: Long,
pubkey: String,
preimage: String? = null,
tlvRecords: List<TlvRecord>? = null,
): PayKeysendMethod = PayKeysendMethod(PayKeysendParams(amount, pubkey, preimage, tlvRecords))
}
}
// make_invoice
class MakeInvoiceParams(
var amount: Long? = null,
var description: String? = null,
var description_hash: String? = null,
var expiry: Long? = null,
var metadata: Any? = null,
)
class MakeInvoiceMethod(
var params: MakeInvoiceParams? = null,
) : Request(NwcMethod.MAKE_INVOICE) {
companion object {
fun create(
amount: Long,
description: String? = null,
descriptionHash: String? = null,
expiry: Long? = null,
): MakeInvoiceMethod = MakeInvoiceMethod(MakeInvoiceParams(amount, description, descriptionHash, expiry))
}
}
// lookup_invoice
class LookupInvoiceParams(
var payment_hash: String? = null,
var invoice: String? = null,
)
class LookupInvoiceMethod(
var params: LookupInvoiceParams? = null,
) : Request(NwcMethod.LOOKUP_INVOICE) {
companion object {
fun createByHash(paymentHash: String): LookupInvoiceMethod = LookupInvoiceMethod(LookupInvoiceParams(payment_hash = paymentHash))
fun createByInvoice(invoice: String): LookupInvoiceMethod = LookupInvoiceMethod(LookupInvoiceParams(invoice = invoice))
}
}
// list_transactions
class ListTransactionsParams(
var from: Long? = null,
var until: Long? = null,
var limit: Int? = null,
var offset: Int? = null,
var unpaid: Boolean? = null,
var unpaid_outgoing: Boolean? = null,
var unpaid_incoming: Boolean? = null,
var type: String? = null,
)
class ListTransactionsMethod(
var params: ListTransactionsParams? = null,
) : Request(NwcMethod.LIST_TRANSACTIONS) {
companion object {
fun create(
from: Long? = null,
until: Long? = null,
limit: Int? = null,
offset: Int? = null,
unpaid: Boolean? = null,
type: String? = null,
unpaid_outgoing: Boolean? = null,
unpaid_incoming: Boolean? = null,
): ListTransactionsMethod = ListTransactionsMethod(ListTransactionsParams(from, until, limit, offset, unpaid, unpaid_outgoing, unpaid_incoming, type))
}
}
// get_balance
class GetBalanceMethod : Request(NwcMethod.GET_BALANCE) {
companion object {
fun create(): GetBalanceMethod = GetBalanceMethod()
}
}
// get_info
class GetInfoMethod : Request(NwcMethod.GET_INFO) {
companion object {
fun create(): GetInfoMethod = GetInfoMethod()
}
}
// make_hold_invoice
class MakeHoldInvoiceParams(
var amount: Long? = null,
var description: String? = null,
var description_hash: String? = null,
var expiry: Long? = null,
var payment_hash: String? = null,
var min_cltv_expiry_delta: Int? = null,
)
class MakeHoldInvoiceMethod(
var params: MakeHoldInvoiceParams? = null,
) : Request(NwcMethod.MAKE_HOLD_INVOICE) {
companion object {
fun create(
amount: Long,
paymentHash: String,
description: String? = null,
descriptionHash: String? = null,
expiry: Long? = null,
minCltvExpiryDelta: Int? = null,
): MakeHoldInvoiceMethod =
MakeHoldInvoiceMethod(
MakeHoldInvoiceParams(amount, description, descriptionHash, expiry, paymentHash, minCltvExpiryDelta),
)
}
}
// cancel_hold_invoice
class CancelHoldInvoiceParams(
var payment_hash: String? = null,
)
class CancelHoldInvoiceMethod(
var params: CancelHoldInvoiceParams? = null,
) : Request(NwcMethod.CANCEL_HOLD_INVOICE) {
companion object {
fun create(paymentHash: String): CancelHoldInvoiceMethod = CancelHoldInvoiceMethod(CancelHoldInvoiceParams(paymentHash))
}
}
// settle_hold_invoice
class SettleHoldInvoiceParams(
var preimage: String? = null,
)
class SettleHoldInvoiceMethod(
var params: SettleHoldInvoiceParams? = null,
) : Request(NwcMethod.SETTLE_HOLD_INVOICE) {
companion object {
fun create(preimage: String): SettleHoldInvoiceMethod = SettleHoldInvoiceMethod(SettleHoldInvoiceParams(preimage))
}
}
// get_budget
class GetBudgetMethod : Request(NwcMethod.GET_BUDGET) {
companion object {
fun create(): GetBudgetMethod = GetBudgetMethod()
}
}
// sign_message
class SignMessageParams(
var message: String? = null,
)
class SignMessageMethod(
var params: SignMessageParams? = null,
) : Request(NwcMethod.SIGN_MESSAGE) {
companion object {
fun create(message: String): SignMessageMethod = SignMessageMethod(SignMessageParams(message))
}
}
// create_connection
class CreateConnectionParams(
var pubkey: String? = null,
var name: String? = null,
var request_methods: List<String>? = null,
var notification_types: List<String>? = null,
var max_amount: Long? = null,
var budget_renewal: String? = null,
var expires_at: Long? = null,
var isolated: Boolean? = null,
var metadata: Any? = null,
)
class CreateConnectionMethod(
var params: CreateConnectionParams? = null,
) : Request(NwcMethod.CREATE_CONNECTION) {
companion object {
fun create(
pubkey: String,
name: String,
requestMethods: List<String>? = null,
notificationTypes: List<String>? = null,
maxAmount: Long? = null,
budgetRenewal: String? = null,
expiresAt: Long? = null,
isolated: Boolean? = null,
metadata: Any? = null,
): CreateConnectionMethod =
CreateConnectionMethod(
CreateConnectionParams(pubkey, name, requestMethods, notificationTypes, maxAmount, budgetRenewal, expiresAt, isolated, metadata),
)
}
}
@@ -27,49 +27,131 @@ abstract class Response(
val resultType: String,
) : OptimizedSerializable
// PayInvoice Call
// Generic error response for any method
class NwcErrorResponse(
resultType: String,
val error: NwcError? = null,
) : Response(resultType)
// pay_invoice success response
class PayInvoiceSuccessResponse(
val result: PayInvoiceResultParams? = null,
) : Response("pay_invoice") {
) : Response(NwcMethod.PAY_INVOICE) {
class PayInvoiceResultParams(
val preimage: String? = null,
val fees_paid: Long? = null,
)
}
// pay_invoice error response (kept for backward compatibility)
class PayInvoiceErrorResponse(
val error: PayInvoiceErrorParams? = null,
) : Response("pay_invoice") {
) : Response(NwcMethod.PAY_INVOICE) {
class PayInvoiceErrorParams(
val code: ErrorType? = null,
val code: NwcErrorCode? = null,
val message: String? = null,
)
enum class ErrorType {
RATE_LIMITED,
// The client is sending commands too fast. It should retry in a few seconds.
NOT_IMPLEMENTED,
// The command is not known or is intentionally not implemented.
INSUFFICIENT_BALANCE,
// The command is not known or is intentionally not implemented.
PAYMENT_FAILED,
// The wallet does not have enough funds to cover a fee reserve or the payment amount.
QUOTA_EXCEEDED,
// The wallet has exceeded its spending quota.
RESTRICTED,
// This public key is not allowed to do this operation.
UNAUTHORIZED,
// This public key has no wallet connected.
INTERNAL,
// An internal error.
OTHER, // Other error.
}
}
// pay_keysend success response
class PayKeysendSuccessResponse(
val result: PayKeysendResult? = null,
) : Response(NwcMethod.PAY_KEYSEND) {
class PayKeysendResult(
val preimage: String? = null,
val fees_paid: Long? = null,
)
}
// make_invoice success response
class MakeInvoiceSuccessResponse(
val result: NwcTransaction? = null,
) : Response(NwcMethod.MAKE_INVOICE)
// lookup_invoice success response
class LookupInvoiceSuccessResponse(
val result: NwcTransaction? = null,
) : Response(NwcMethod.LOOKUP_INVOICE)
// list_transactions success response
class ListTransactionsSuccessResponse(
val result: ListTransactionsResult? = null,
) : Response(NwcMethod.LIST_TRANSACTIONS) {
class ListTransactionsResult(
val transactions: List<NwcTransaction>? = null,
val total_count: Long? = null,
)
}
// get_balance success response
class GetBalanceSuccessResponse(
val result: GetBalanceResult? = null,
) : Response(NwcMethod.GET_BALANCE) {
class GetBalanceResult(
val balance: Long? = null,
)
}
// get_info success response
class GetInfoSuccessResponse(
val result: GetInfoResult? = null,
) : Response(NwcMethod.GET_INFO) {
class GetInfoResult(
val alias: String? = null,
val color: String? = null,
val pubkey: String? = null,
val network: String? = null,
val block_height: Long? = null,
val block_hash: String? = null,
val methods: List<String>? = null,
val notifications: List<String>? = null,
val metadata: Any? = null,
val lud16: String? = null,
)
}
// make_hold_invoice success response
class MakeHoldInvoiceSuccessResponse(
val result: NwcTransaction? = null,
) : Response(NwcMethod.MAKE_HOLD_INVOICE)
// cancel_hold_invoice success response
class CancelHoldInvoiceSuccessResponse(
val result: Any? = null,
) : Response(NwcMethod.CANCEL_HOLD_INVOICE)
// settle_hold_invoice success response
class SettleHoldInvoiceSuccessResponse(
val result: Any? = null,
) : Response(NwcMethod.SETTLE_HOLD_INVOICE)
// get_budget success response
class GetBudgetSuccessResponse(
val result: GetBudgetResult? = null,
) : Response(NwcMethod.GET_BUDGET) {
class GetBudgetResult(
val used_budget: Long? = null,
val total_budget: Long? = null,
val renews_at: Long? = null,
val renewal_period: String? = null,
)
}
// sign_message success response
class SignMessageSuccessResponse(
val result: SignMessageResult? = null,
) : Response(NwcMethod.SIGN_MESSAGE) {
class SignMessageResult(
val message: String? = null,
val signature: String? = null,
)
}
// create_connection success response
class CreateConnectionSuccessResponse(
val result: CreateConnectionResult? = null,
) : Response(NwcMethod.CREATE_CONNECTION) {
class CreateConnectionResult(
val wallet_pubkey: String? = null,
)
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip47WalletConnect.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class EncryptionTag {
companion object {
const val TAG_NAME = "encryption"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): List<String>? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag.drop(1)
}
fun assemble(schemes: List<String>) = arrayOf(TAG_NAME, *schemes.toTypedArray())
}
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip47WalletConnect.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
class NotificationsTag {
companion object {
const val TAG_NAME = "notifications"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): List<String>? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag.drop(1)
}
fun assemble(types: List<String>) = arrayOf(TAG_NAME, *types.toTypedArray())
}
}
@@ -78,6 +78,8 @@ import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.NwcInfoEvent
import com.vitorpamplona.quartz.nip47WalletConnect.NwcNotificationEvent
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
@@ -256,6 +258,9 @@ class EventFactory {
LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentRequestEvent.KIND -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentResponseEvent.KIND -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
NwcInfoEvent.KIND -> NwcInfoEvent(id, pubKey, createdAt, tags, content, sig)
NwcNotificationEvent.KIND -> NwcNotificationEvent(id, pubKey, createdAt, tags, content, sig)
NwcNotificationEvent.LEGACY_KIND -> NwcNotificationEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPrivateEvent.KIND -> LnZapPrivateEvent(id, pubKey, createdAt, tags, content, sig)
LnZapRequestEvent.KIND -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
LongTextNoteEvent.KIND -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
@@ -0,0 +1,588 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip47WalletConnect
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Interoperability tests using JSON structures matching Alby Hub (server)
* and Alby JS SDK (client) NIP-47 implementations.
* These tests verify that Amethyst can correctly parse responses from Alby wallets.
*/
class AlbyInteropTest {
// --- Alby Hub pay_invoice response format ---
@Test
fun testAlbyPayInvoiceSuccess() {
val json = """{"result_type":"pay_invoice","result":{"preimage":"6565656565656565656565656565656565656565656565656565656565656565","fees_paid":1}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<PayInvoiceSuccessResponse>(response)
assertEquals("6565656565656565656565656565656565656565656565656565656565656565", response.result?.preimage)
assertEquals(1L, response.result?.fees_paid)
}
@Test
fun testAlbyPayInvoiceInsufficientBalance() {
val json = """{"result_type":"pay_invoice","error":{"code":"INSUFFICIENT_BALANCE","message":"insufficient funds available to send"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<PayInvoiceErrorResponse>(response)
assertEquals(NwcErrorCode.INSUFFICIENT_BALANCE, response.error?.code)
assertEquals("insufficient funds available to send", response.error?.message)
}
@Test
fun testAlbyPayInvoiceBadRequest() {
val json = """{"result_type":"pay_invoice","error":{"code":"BAD_REQUEST","message":"Failed to decode bolt11 invoice: bolt11 too short"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<PayInvoiceErrorResponse>(response)
assertEquals(NwcErrorCode.BAD_REQUEST, response.error?.code)
}
// --- Alby Hub get_balance response format ---
@Test
fun testAlbyGetBalanceResponse() {
val json = """{"result_type":"get_balance","result":{"balance":21000000}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<GetBalanceSuccessResponse>(response)
assertEquals(21000000L, response.result?.balance)
}
// --- Alby Hub get_info response format (with extended fields) ---
@Test
fun testAlbyGetInfoFullResponse() {
val json =
"""{"result_type":"get_info","result":{"alias":"AlbyHub","color":"#3399ff","pubkey":"02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc","network":"mainnet","block_height":800000,"block_hash":"00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72f2e4b10","methods":["pay_invoice","pay_keysend","get_balance","get_budget","get_info","make_invoice","lookup_invoice","list_transactions","sign_message"],"notifications":["payment_received","payment_sent"],"lud16":"satoshi@getalby.com"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<GetInfoSuccessResponse>(response)
val result = response.result
assertNotNull(result)
assertEquals("AlbyHub", result.alias)
assertEquals("#3399ff", result.color)
assertEquals("02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc", result.pubkey)
assertEquals("mainnet", result.network)
assertEquals(800000L, result.block_height)
assertEquals("00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72f2e4b10", result.block_hash)
assertEquals(9, result.methods?.size)
assertEquals(2, result.notifications?.size)
assertEquals("satoshi@getalby.com", result.lud16)
}
// --- Alby Hub get_budget response format ---
@Test
fun testAlbyGetBudgetWithRenewal() {
val json = """{"result_type":"get_budget","result":{"used_budget":50000,"total_budget":100000,"renews_at":1700000000,"renewal_period":"monthly"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<GetBudgetSuccessResponse>(response)
val result = response.result
assertNotNull(result)
assertEquals(50000L, result.used_budget)
assertEquals(100000L, result.total_budget)
assertEquals(1700000000L, result.renews_at)
assertEquals("monthly", result.renewal_period)
}
@Test
fun testAlbyGetBudgetUnlimited() {
val json = """{"result_type":"get_budget","result":{"used_budget":0,"total_budget":0,"renewal_period":"never"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<GetBudgetSuccessResponse>(response)
assertEquals(0L, response.result?.used_budget)
assertEquals(0L, response.result?.total_budget)
assertNull(response.result?.renews_at)
assertEquals("never", response.result?.renewal_period)
}
// --- Alby Hub make_invoice response format ---
@Test
fun testAlbyMakeInvoiceResponse() {
val json =
"""{"result_type":"make_invoice","result":{"type":"incoming","state":"PENDING","invoice":"lnbc10n1pj3xyz...","description":"Test invoice","payment_hash":"abc123def456","amount":1000,"fees_paid":0,"created_at":1693876497,"expires_at":1694876497}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<MakeInvoiceSuccessResponse>(response)
val txn = response.result
assertNotNull(txn)
assertEquals(NwcTransactionType.INCOMING, txn.type)
assertEquals(NwcTransactionState.PENDING, txn.state)
assertEquals("lnbc10n1pj3xyz...", txn.invoice)
assertEquals("Test invoice", txn.description)
assertEquals("abc123def456", txn.payment_hash)
assertEquals(1000L, txn.amount)
assertEquals(0L, txn.fees_paid)
}
// --- Alby Hub lookup_invoice with settled state ---
@Test
fun testAlbyLookupInvoiceSettled() {
val json =
"""{"result_type":"lookup_invoice","result":{"type":"incoming","state":"SETTLED","invoice":"lnbc50n1...","preimage":"preimage123","payment_hash":"hash456","amount":5000,"fees_paid":0,"created_at":1693876497,"settled_at":1694876500}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<LookupInvoiceSuccessResponse>(response)
val txn = response.result
assertNotNull(txn)
assertEquals(NwcTransactionType.INCOMING, txn.type)
assertEquals(NwcTransactionState.SETTLED, txn.state)
assertEquals("preimage123", txn.preimage)
assertEquals(1694876500L, txn.settled_at)
}
@Test
fun testAlbyLookupInvoiceNotFound() {
val json = """{"result_type":"lookup_invoice","error":{"code":"NOT_FOUND","message":"transaction not found"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<NwcErrorResponse>(response)
assertEquals(NwcErrorCode.NOT_FOUND, response.error?.code)
}
// --- Alby Hub list_transactions with total_count ---
@Test
fun testAlbyListTransactionsWithTotalCount() {
val json =
"""{"result_type":"list_transactions","result":{"transactions":[{"type":"incoming","state":"SETTLED","invoice":"lnbc1...","payment_hash":"h1","amount":1000,"fees_paid":0,"created_at":1693876497,"settled_at":1694876500},{"type":"outgoing","state":"SETTLED","invoice":"lnbc2...","payment_hash":"h2","amount":2000,"fees_paid":10,"created_at":1693876400,"settled_at":1694876400}],"total_count":100}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<ListTransactionsSuccessResponse>(response)
assertEquals(2, response.result?.transactions?.size)
assertEquals(100L, response.result?.total_count)
val first = response.result?.transactions?.get(0)
assertEquals(NwcTransactionType.INCOMING, first?.type)
assertEquals(NwcTransactionState.SETTLED, first?.state)
val second = response.result?.transactions?.get(1)
assertEquals(NwcTransactionType.OUTGOING, second?.type)
assertEquals(10L, second?.fees_paid)
}
// --- Alby Hub sign_message response ---
@Test
fun testAlbySignMessageResponse() {
val json = """{"result_type":"sign_message","result":{"message":"Hello Nostr","signature":"3045022100..."}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<SignMessageSuccessResponse>(response)
assertEquals("Hello Nostr", response.result?.message)
assertEquals("3045022100...", response.result?.signature)
}
// --- Alby Hub create_connection response ---
@Test
fun testAlbyCreateConnectionResponse() {
val json = """{"result_type":"create_connection","result":{"wallet_pubkey":"02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<CreateConnectionSuccessResponse>(response)
assertEquals("02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc", response.result?.wallet_pubkey)
}
// --- Alby Hub notification formats ---
@Test
fun testAlbyPaymentReceivedNotification() {
val json =
"""{"notification_type":"payment_received","notification":{"type":"incoming","state":"SETTLED","invoice":"lnbc50n1...","preimage":"pre123","payment_hash":"hash123","amount":5000,"fees_paid":0,"created_at":1693876497,"settled_at":1694876500}}"""
val notification = OptimizedJsonMapper.fromJsonTo<Notification>(json)
assertIs<PaymentReceivedNotification>(notification)
val txn = notification.notification
assertNotNull(txn)
assertEquals(NwcTransactionType.INCOMING, txn.type)
assertEquals(NwcTransactionState.SETTLED, txn.state)
assertEquals(5000L, txn.amount)
}
@Test
fun testAlbyPaymentSentNotification() {
val json =
"""{"notification_type":"payment_sent","notification":{"type":"outgoing","state":"SETTLED","invoice":"lnbc100n1...","preimage":"pre456","payment_hash":"hash456","amount":10000,"fees_paid":10,"created_at":1693876497,"settled_at":1694876500}}"""
val notification = OptimizedJsonMapper.fromJsonTo<Notification>(json)
assertIs<PaymentSentNotification>(notification)
val txn = notification.notification
assertNotNull(txn)
assertEquals(NwcTransactionType.OUTGOING, txn.type)
assertEquals(NwcTransactionState.SETTLED, txn.state)
assertEquals(10000L, txn.amount)
assertEquals(10L, txn.fees_paid)
}
@Test
fun testAlbyHoldInvoiceAcceptedNotification() {
val json =
"""{"notification_type":"hold_invoice_accepted","notification":{"type":"incoming","invoice":"lnbc200n1...","payment_hash":"hash789","amount":20000,"created_at":1693876497,"expires_at":1694876497,"settle_deadline":800000}}"""
val notification = OptimizedJsonMapper.fromJsonTo<Notification>(json)
assertIs<HoldInvoiceAcceptedNotification>(notification)
val data = notification.notification
assertNotNull(data)
assertEquals("incoming", data.type)
assertEquals(20000L, data.amount)
assertEquals(800000L, data.settle_deadline)
}
// --- Alby Hub error code interop ---
@Test
fun testAlbyRestricted() {
val json = """{"result_type":"pay_invoice","error":{"code":"RESTRICTED","message":"This app does not have the pay_invoice scope"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<PayInvoiceErrorResponse>(response)
assertEquals(NwcErrorCode.RESTRICTED, response.error?.code)
}
@Test
fun testAlbyExpiredConnection() {
val json = """{"result_type":"get_balance","error":{"code":"EXPIRED","message":"This app has expired"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<NwcErrorResponse>(response)
assertEquals(NwcErrorCode.EXPIRED, response.error?.code)
}
@Test
fun testAlbyQuotaExceeded() {
val json = """{"result_type":"pay_invoice","error":{"code":"QUOTA_EXCEEDED","message":"Exceeded budget limit"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<PayInvoiceErrorResponse>(response)
assertEquals(NwcErrorCode.QUOTA_EXCEEDED, response.error?.code)
}
// --- Alby Hub request format interop ---
@Test
fun testAlbyPayInvoiceRequestFormat() {
val json = """{"method":"pay_invoice","params":{"invoice":"lnbc10u1pj3xyz..."}}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<PayInvoiceMethod>(request)
assertEquals("lnbc10u1pj3xyz...", request.params?.invoice)
}
@Test
fun testAlbyGetBudgetRequestFormat() {
val json = """{"method":"get_budget","params":{}}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<GetBudgetMethod>(request)
}
@Test
fun testAlbySignMessageRequestFormat() {
val json = """{"method":"sign_message","params":{"message":"Hello from Amethyst"}}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<SignMessageMethod>(request)
assertEquals("Hello from Amethyst", request.params?.message)
}
@Test
fun testAlbyCreateConnectionRequestFormat() {
val json =
"""{"method":"create_connection","params":{"pubkey":"02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc","name":"Amethyst","request_methods":["pay_invoice","get_balance","get_info","make_invoice","lookup_invoice","list_transactions"],"notification_types":["payment_received","payment_sent"],"max_amount":100000,"budget_renewal":"monthly","isolated":false}}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<CreateConnectionMethod>(request)
assertEquals("02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc", request.params?.pubkey)
assertEquals("Amethyst", request.params?.name)
assertEquals(6, request.params?.request_methods?.size)
assertEquals(2, request.params?.notification_types?.size)
assertEquals(100000L, request.params?.max_amount)
assertEquals("monthly", request.params?.budget_renewal)
assertEquals(false, request.params?.isolated)
}
// --- Alby Hub real bolt11 test vectors ---
@Test
fun testAlbyRealBolt11PayInvoiceRequest() {
val json =
"""{"method":"pay_invoice","params":{"invoice":"lntbs1230n1pnkqautdqyw3jsnp4q09a0z84kg4a2m38zjllw43h953fx5zvqe8qxfgw694ymkq26u8zcpp5yvnh6hsnlnj4xnuh2trzlnunx732dv8ta2wjr75pdfxf6p2vlyassp5hyeg97a3ft5u769kjwsn7p0e85h79pzz8kladmnqhpcypz2uawjs9qyysgqcqpcxq8zals8sq9yeg2pa9eywkgj50cyzxd5elatujuc0c0wh6j9nat5mn34pgk8u9ufpgs99tw9ldlfk42cqlkr48au3lmuh09269prg4qkggh4a8cyqpfl0y6j","metadata":{"a":123}}}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<PayInvoiceMethod>(request)
assertNotNull(request.params?.invoice)
assertNotNull(request.params?.metadata)
}
@Test
fun testAlbyPayKeysendWithTlvRecords() {
val json =
"""{"method":"pay_keysend","params":{"amount":123000,"pubkey":"123pubkey2","preimage":"018465013e2337234a7e5530a21c4a8cf70d84231f4a8ff0b1e2cce3cb2bd03b","tlv_records":[{"type":5482373484,"value":"fajsn341414fq"}]}}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<PayKeysendMethod>(request)
assertEquals(123000L, request.params?.amount)
assertEquals("123pubkey2", request.params?.pubkey)
assertEquals("018465013e2337234a7e5530a21c4a8cf70d84231f4a8ff0b1e2cce3cb2bd03b", request.params?.preimage)
assertNotNull(request.params?.tlv_records)
assertEquals(1, request.params?.tlv_records?.size)
assertEquals(
5482373484L,
request.params
?.tlv_records
?.first()
?.type,
)
assertEquals(
"fajsn341414fq",
request.params
?.tlv_records
?.first()
?.value,
)
}
@Test
fun testAlbyMakeInvoiceWithNestedMetadata() {
val json =
"""{"method":"make_invoice","params":{"amount":1000,"description":"Hello, world","expiry":3600,"metadata":{"a":1,"b":"2","c":{"d":3,"e":[{"f":"g"},{"h":"i"}]}}}}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<MakeInvoiceMethod>(request)
assertEquals(1000L, request.params?.amount)
assertEquals("Hello, world", request.params?.description)
assertEquals(3600L, request.params?.expiry)
assertNotNull(request.params?.metadata)
}
@Test
fun testAlbyMakeHoldInvoiceWithPaymentHash() {
val json =
"""{"method":"make_hold_invoice","params":{"amount":1000,"description":"Hello, world","payment_hash":"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef","expiry":3600}}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<MakeHoldInvoiceMethod>(request)
assertEquals(1000L, request.params?.amount)
assertEquals("1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", request.params?.payment_hash)
assertEquals("Hello, world", request.params?.description)
}
@Test
fun testAlbySettleHoldInvoiceWithPreimage() {
val json =
"""{"method":"settle_hold_invoice","params":{"preimage":"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"}}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<SettleHoldInvoiceMethod>(request)
assertEquals("1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", request.params?.preimage)
}
@Test
fun testAlbyListTransactionsWithUnpaidFilters() {
val json = """{"method":"list_transactions","params":{"from":0,"until":0,"limit":10,"offset":0,"unpaid_outgoing":true}}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<ListTransactionsMethod>(request)
assertEquals(10, request.params?.limit)
assertEquals(true, request.params?.unpaid_outgoing)
}
@Test
fun testAlbyCreateConnectionIsolated() {
val json =
"""{"method":"create_connection","params":{"pubkey":"02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc","name":"Test 123","request_methods":["get_info","pay_invoice"],"notification_types":["payment_received"],"max_amount":100000000,"budget_renewal":"monthly","isolated":true}}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<CreateConnectionMethod>(request)
assertEquals("Test 123", request.params?.name)
assertEquals(true, request.params?.isolated)
assertEquals(100000000L, request.params?.max_amount)
assertEquals("monthly", request.params?.budget_renewal)
assertEquals(listOf("get_info", "pay_invoice"), request.params?.request_methods)
assertEquals(listOf("payment_received"), request.params?.notification_types)
}
// --- Transaction with settle_deadline from Alby hold invoice ---
@Test
fun testAlbyMakeHoldInvoiceWithSettleDeadline() {
val json =
"""{"result_type":"make_hold_invoice","result":{"type":"incoming","state":"PENDING","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"expires_at":2000,"settle_deadline":144}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<MakeHoldInvoiceSuccessResponse>(response)
val txn = response.result
assertNotNull(txn)
assertEquals(144L, txn.settle_deadline)
assertEquals(NwcTransactionState.PENDING, txn.state)
}
// ===================================================================
// Alby JS SDK (client) interop tests
// The JS SDK uses lowercase transaction states while Hub uses uppercase.
// Both formats must be handled correctly.
// ===================================================================
@Test
fun testJsSdkLowercaseSettledState() {
val json =
"""{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash123","amount":1000,"settled_at":1694876497}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<LookupInvoiceSuccessResponse>(response)
assertEquals("settled", response.result?.state)
assertTrue(NwcTransactionState.isSettled(response.result?.state))
}
@Test
fun testJsSdkLowercasePendingState() {
val json =
"""{"result_type":"make_invoice","result":{"type":"incoming","state":"pending","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<MakeInvoiceSuccessResponse>(response)
assertEquals("pending", response.result?.state)
assertTrue(NwcTransactionState.isPending(response.result?.state))
}
@Test
fun testJsSdkLowercaseStatesInListTransactions() {
val json =
"""{"result_type":"list_transactions","result":{"transactions":[{"type":"incoming","state":"settled","amount":1000,"created_at":1000},{"type":"outgoing","state":"failed","amount":2000,"created_at":2000},{"type":"incoming","state":"accepted","amount":3000,"created_at":3000}],"total_count":3}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<ListTransactionsSuccessResponse>(response)
val txns = response.result?.transactions
assertNotNull(txns)
assertEquals(3, txns.size)
assertTrue(NwcTransactionState.isSettled(txns[0].state))
assertTrue(NwcTransactionState.isFailed(txns[1].state))
assertTrue(NwcTransactionState.isAccepted(txns[2].state))
}
@Test
fun testJsSdkLowercaseStatesInNotification() {
val json =
"""{"notification_type":"payment_received","notification":{"type":"incoming","state":"settled","invoice":"lnbc...","amount":5000,"created_at":1000,"settled_at":2000}}"""
val notification = OptimizedJsonMapper.fromJsonTo<Notification>(json)
assertIs<PaymentReceivedNotification>(notification)
assertTrue(NwcTransactionState.isSettled(notification.notification?.state))
}
@Test
fun testJsSdkEmptyGetBudgetResponse() {
// JS SDK allows get_budget to return empty object when no budget is set
val json = """{"result_type":"get_budget","result":{}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<GetBudgetSuccessResponse>(response)
assertNull(response.result?.used_budget)
assertNull(response.result?.total_budget)
assertNull(response.result?.renews_at)
assertNull(response.result?.renewal_period)
}
@Test
fun testJsSdkGetBudgetWithAllRenewalPeriods() {
for (period in listOf("daily", "weekly", "monthly", "yearly", "never")) {
val json = """{"result_type":"get_budget","result":{"used_budget":0,"total_budget":100000,"renewal_period":"$period"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<GetBudgetSuccessResponse>(response)
assertEquals(period, response.result?.renewal_period)
}
}
@Test
fun testJsSdkTransactionWithMetadata() {
// JS SDK supports structured metadata with comment, payer_data, nostr fields
val json =
"""{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settled_at":2000,"metadata":{"comment":"Thanks!","payer_data":{"name":"Alice","pubkey":"abc123"},"nostr":{"pubkey":"npub1...","tags":[["p","def456"]]}}}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<LookupInvoiceSuccessResponse>(response)
assertNotNull(response.result?.metadata)
}
@Test
fun testMetadataParserComment() {
val json =
"""{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settled_at":2000,"metadata":{"comment":"Great post!"}}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<LookupInvoiceSuccessResponse>(response)
val parsed = response.result?.parsedMetadata()
assertNotNull(parsed)
assertEquals("Great post!", parsed.comment)
assertNull(parsed.payerData)
assertNull(parsed.nostr)
}
@Test
fun testMetadataParserPayerData() {
val json =
"""{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settled_at":2000,"metadata":{"payer_data":{"name":"Alice","email":"alice@example.com","pubkey":"abc123"}}}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<LookupInvoiceSuccessResponse>(response)
val parsed = response.result?.parsedMetadata()
assertNotNull(parsed)
assertEquals("Alice", parsed.payerData?.name)
assertEquals("alice@example.com", parsed.payerData?.email)
assertEquals("abc123", parsed.payerData?.pubkey)
assertEquals("Alice", parsed.senderDisplayName())
}
@Test
fun testMetadataParserNostrZap() {
val senderHex = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e"
val recipientHex = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
val json =
"""{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":21000,"created_at":1000,"settled_at":2000,"metadata":{"nostr":{"pubkey":"$senderHex","tags":[["p","$recipientHex"],["amount","21000"]]}}}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<LookupInvoiceSuccessResponse>(response)
val parsed = response.result?.parsedMetadata()
assertNotNull(parsed)
assertEquals(senderHex, parsed.senderPubkeyHex())
assertEquals(recipientHex, parsed.recipientPubkeyHex())
}
@Test
fun testMetadataParserRecipientData() {
val json =
"""{"result_type":"lookup_invoice","result":{"type":"outgoing","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settled_at":2000,"metadata":{"recipient_data":{"identifier":"alice@getalby.com"}}}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<LookupInvoiceSuccessResponse>(response)
val parsed = response.result?.parsedMetadata()
assertNotNull(parsed)
assertEquals("alice@getalby.com", parsed.recipientIdentifier())
}
@Test
fun testMetadataParserNullForSimpleMetadata() {
val json =
"""{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settled_at":2000,"metadata":{"a":123}}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<LookupInvoiceSuccessResponse>(response)
val parsed = response.result?.parsedMetadata()
assertNull(parsed)
}
@Test
fun testMetadataParserNullMetadata() {
val json =
"""{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settled_at":2000}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<LookupInvoiceSuccessResponse>(response)
val parsed = response.result?.parsedMetadata()
assertNull(parsed)
}
@Test
fun testJsSdkGetInfoWithAllMethods() {
// JS SDK advertises all 13 single methods + notifications
val json =
"""{"result_type":"get_info","result":{"alias":"TestNode","methods":["get_info","get_balance","get_budget","make_invoice","pay_invoice","pay_keysend","lookup_invoice","list_transactions","sign_message","create_connection","make_hold_invoice","settle_hold_invoice","cancel_hold_invoice"],"notifications":["payment_received","payment_sent","hold_invoice_accepted"]}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<GetInfoSuccessResponse>(response)
assertEquals(13, response.result?.methods?.size)
assertEquals(3, response.result?.notifications?.size)
assertTrue(response.result?.methods?.contains(NwcMethod.GET_BUDGET) == true)
assertTrue(response.result?.methods?.contains(NwcMethod.SIGN_MESSAGE) == true)
assertTrue(response.result?.methods?.contains(NwcMethod.CREATE_CONNECTION) == true)
}
}
@@ -0,0 +1,207 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip47WalletConnect
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class LnZapPaymentRequestEventTest {
@Test
fun testEventKind() {
assertEquals(23194, LnZapPaymentRequestEvent.KIND)
}
@Test
fun testCreatePayInvoiceRequest() =
runTest {
val clientKeyPair = KeyPair()
val walletKeyPair = KeyPair()
val clientSigner = NostrSignerInternal(clientKeyPair)
val walletServicePubkey: HexKey =
walletKeyPair.pubKey.joinToString("") { "%02x".format(it) }
val event =
LnZapPaymentRequestEvent.create(
lnInvoice = "lnbc50n1...",
walletServicePubkey = walletServicePubkey,
signer = clientSigner,
createdAt = 1000L,
)
assertEquals(23194, event.kind)
assertEquals(walletServicePubkey, event.walletServicePubKey())
assertTrue(event.isContentEncoded())
assertNotNull(event.content)
assertTrue(event.content.isNotEmpty())
}
@Test
fun testCreateGenericRequest() =
runTest {
val clientKeyPair = KeyPair()
val walletKeyPair = KeyPair()
val clientSigner = NostrSignerInternal(clientKeyPair)
val walletServicePubkey: HexKey =
walletKeyPair.pubKey.joinToString("") { "%02x".format(it) }
val request = GetBalanceMethod.create()
val event =
LnZapPaymentRequestEvent.createRequest(
request = request,
walletServicePubkey = walletServicePubkey,
signer = clientSigner,
createdAt = 1000L,
)
assertEquals(23194, event.kind)
assertEquals(walletServicePubkey, event.walletServicePubKey())
// Should not have encryption tag for NIP-04
assertNull(event.encryptionScheme())
}
@Test
fun testCreateRequestWithNip44() =
runTest {
val clientKeyPair = KeyPair()
val walletKeyPair = KeyPair()
val clientSigner = NostrSignerInternal(clientKeyPair)
val walletServicePubkey: HexKey =
walletKeyPair.pubKey.joinToString("") { "%02x".format(it) }
val request = GetBalanceMethod.create()
val event =
LnZapPaymentRequestEvent.createRequest(
request = request,
walletServicePubkey = walletServicePubkey,
signer = clientSigner,
createdAt = 1000L,
useNip44 = true,
)
assertEquals(23194, event.kind)
assertEquals("nip44_v2", event.encryptionScheme())
}
@Test
fun testDecryptPayInvoiceRequest() =
runTest {
val clientKeyPair = KeyPair()
val walletKeyPair = KeyPair()
val clientSigner = NostrSignerInternal(clientKeyPair)
val walletSigner = NostrSignerInternal(walletKeyPair)
val walletServicePubkey: HexKey =
walletKeyPair.pubKey.joinToString("") { "%02x".format(it) }
val event =
LnZapPaymentRequestEvent.create(
lnInvoice = "lnbc50n1...",
walletServicePubkey = walletServicePubkey,
signer = clientSigner,
)
// Wallet service should be able to decrypt
val decrypted = event.decryptRequest(walletSigner)
assertIs<PayInvoiceMethod>(decrypted)
assertEquals("lnbc50n1...", decrypted.params?.invoice)
}
@Test
fun testDecryptGenericRequest() =
runTest {
val clientKeyPair = KeyPair()
val walletKeyPair = KeyPair()
val clientSigner = NostrSignerInternal(clientKeyPair)
val walletSigner = NostrSignerInternal(walletKeyPair)
val walletServicePubkey: HexKey =
walletKeyPair.pubKey.joinToString("") { "%02x".format(it) }
val request = MakeInvoiceMethod.create(5000L, "test payment")
val event =
LnZapPaymentRequestEvent.createRequest(
request = request,
walletServicePubkey = walletServicePubkey,
signer = clientSigner,
)
val decrypted = event.decryptRequest(walletSigner)
assertIs<MakeInvoiceMethod>(decrypted)
assertEquals(5000L, decrypted.params?.amount)
assertEquals("test payment", decrypted.params?.description)
}
@Test
fun testDecryptNip44Request() =
runTest {
val clientKeyPair = KeyPair()
val walletKeyPair = KeyPair()
val clientSigner = NostrSignerInternal(clientKeyPair)
val walletSigner = NostrSignerInternal(walletKeyPair)
val walletServicePubkey: HexKey =
walletKeyPair.pubKey.joinToString("") { "%02x".format(it) }
val request = GetInfoMethod.create()
val event =
LnZapPaymentRequestEvent.createRequest(
request = request,
walletServicePubkey = walletServicePubkey,
signer = clientSigner,
useNip44 = true,
)
assertEquals("nip44_v2", event.encryptionScheme())
// Wallet service should be able to decrypt NIP-44 encrypted request
val decrypted = event.decryptRequest(walletSigner)
assertIs<GetInfoMethod>(decrypted)
}
@Test
fun testCanDecrypt() =
runTest {
val clientKeyPair = KeyPair()
val walletKeyPair = KeyPair()
val otherKeyPair = KeyPair()
val clientSigner = NostrSignerInternal(clientKeyPair)
val walletSigner = NostrSignerInternal(walletKeyPair)
val otherSigner = NostrSignerInternal(otherKeyPair)
val walletServicePubkey: HexKey =
walletKeyPair.pubKey.joinToString("") { "%02x".format(it) }
val event =
LnZapPaymentRequestEvent.create(
lnInvoice = "lnbc50n1...",
walletServicePubkey = walletServicePubkey,
signer = clientSigner,
)
assertTrue(event.canDecrypt(clientSigner))
assertTrue(event.canDecrypt(walletSigner))
assertTrue(!event.canDecrypt(otherSigner))
}
}
@@ -0,0 +1,169 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip47WalletConnect
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class Nip47WalletConnectTest {
@Test
fun testParseWalletConnectUri() {
val uri =
"nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100571c5"
val parsed = Nip47WalletConnect.parse(uri)
assertEquals("b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4", parsed.pubKeyHex)
assertEquals("wss://relay.damus.io/", parsed.relayUri.url)
assertEquals("71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100571c5", parsed.secret)
assertNull(parsed.lud16)
}
@Test
fun testParseWalletConnectUriWithLud16() {
val uri =
"nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100571c5&lud16=user%40example.com"
val parsed = Nip47WalletConnect.parse(uri)
assertEquals("b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4", parsed.pubKeyHex)
assertNotNull(parsed.lud16)
assertEquals("user@example.com", parsed.lud16)
}
@Test
fun testParseNostrWalletConnectScheme() {
val uri =
"nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=abc"
val parsed = Nip47WalletConnect.parse(uri)
assertEquals("b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4", parsed.pubKeyHex)
}
@Test
fun testParseAmethystWalletConnectScheme() {
val uri =
"amethyst+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&secret=abc"
val parsed = Nip47WalletConnect.parse(uri)
assertEquals("b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4", parsed.pubKeyHex)
}
@Test
fun testParseWithoutSecret() {
val uri =
"nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io"
val parsed = Nip47WalletConnect.parse(uri)
assertNull(parsed.secret)
}
@Test
fun testParseInvalidSchemeThrows() {
val uri = "https://example.com?relay=wss%3A%2F%2Frelay.damus.io"
assertFailsWith<IllegalArgumentException> {
Nip47WalletConnect.parse(uri)
}
}
@Test
fun testParseWithoutRelayThrows() {
val uri = "nostr+walletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4"
assertFailsWith<IllegalArgumentException> {
Nip47WalletConnect.parse(uri)
}
}
// --- Alby JS SDK URI test vector ---
@Test
fun testParseAlbyJsSdkUri() {
// Test vector from @getalby/js-sdk NWCClient.test.ts
val uri =
"nostr+walletconnect://69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9?relay=wss%3A%2F%2Frelay.getalby.com%2Fv1&secret=e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b&lud16=hello%40getalby.com"
val parsed = Nip47WalletConnect.parse(uri)
assertEquals("69effe7b49a6dd5cf525bd0905917a5005ffe480b58eeb8e861418cf3ae760d9", parsed.pubKeyHex)
assertEquals("e839faf78693765b3833027fefa5a305c78f6965d0a5d2e47a3fcb25aa7cc45b", parsed.secret)
assertEquals("hello@getalby.com", parsed.lud16)
}
// --- Nip47URI serialization ---
@Test
fun testNip47UriSerializationRoundTrip() {
val original =
Nip47WalletConnect.Nip47URI(
pubKeyHex = "b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4",
relayUri = "wss://relay.damus.io",
secret = "abc123",
lud16 = "user@example.com",
)
val json = Nip47WalletConnect.Nip47URI.serializer(original)
val deserialized = Nip47WalletConnect.Nip47URI.parser(json)
assertEquals(original.pubKeyHex, deserialized.pubKeyHex)
assertEquals(original.relayUri, deserialized.relayUri)
assertEquals(original.secret, deserialized.secret)
assertEquals(original.lud16, deserialized.lud16)
}
@Test
fun testNip47UriSerializationWithNullLud16() {
val original =
Nip47WalletConnect.Nip47URI(
pubKeyHex = "abc123",
relayUri = "wss://relay.damus.io",
secret = "secret",
)
val json = Nip47WalletConnect.Nip47URI.serializer(original)
val deserialized = Nip47WalletConnect.Nip47URI.parser(json)
assertEquals(original.pubKeyHex, deserialized.pubKeyHex)
assertNull(deserialized.lud16)
}
// --- Normalize/Denormalize ---
@Test
fun testNormalizeDenormalizeRoundTrip() {
val uri =
Nip47WalletConnect.Nip47URI(
pubKeyHex = "abc123",
relayUri = "wss://relay.damus.io",
secret = "secret",
lud16 = "user@example.com",
)
val normalized = uri.normalize()
assertNotNull(normalized)
assertEquals("user@example.com", normalized.lud16)
val denormalized = normalized.denormalize()
assertNotNull(denormalized)
assertEquals("abc123", denormalized.pubKeyHex)
assertEquals("secret", denormalized.secret)
assertEquals("user@example.com", denormalized.lud16)
}
}
@@ -0,0 +1,97 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip47WalletConnect
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class NotificationTest {
@Test
fun testPaymentReceivedDeserialization() {
val json =
"""{"notification_type":"payment_received","notification":{"type":"incoming","invoice":"lnbc50n1...","description":"coffee","preimage":"abc","payment_hash":"hash123","amount":5000,"fees_paid":10,"created_at":1693876497,"expires_at":1694876497,"settled_at":1694876500}}"""
val notification = OptimizedJsonMapper.fromJsonTo<Notification>(json)
assertIs<PaymentReceivedNotification>(notification)
assertNotNull(notification.notification)
assertEquals("incoming", notification.notification?.type)
assertEquals("lnbc50n1...", notification.notification?.invoice)
assertEquals("coffee", notification.notification?.description)
assertEquals("abc", notification.notification?.preimage)
assertEquals("hash123", notification.notification?.payment_hash)
assertEquals(5000L, notification.notification?.amount)
assertEquals(10L, notification.notification?.fees_paid)
assertEquals(1693876497L, notification.notification?.created_at)
assertEquals(1694876497L, notification.notification?.expires_at)
assertEquals(1694876500L, notification.notification?.settled_at)
}
@Test
fun testPaymentSentDeserialization() {
val json =
"""{"notification_type":"payment_sent","notification":{"type":"outgoing","invoice":"lnbc100n1...","preimage":"def456","payment_hash":"hash456","amount":10000,"fees_paid":50,"created_at":1693876497,"settled_at":1694876500}}"""
val notification = OptimizedJsonMapper.fromJsonTo<Notification>(json)
assertIs<PaymentSentNotification>(notification)
assertNotNull(notification.notification)
assertEquals("outgoing", notification.notification?.type)
assertEquals("lnbc100n1...", notification.notification?.invoice)
assertEquals("def456", notification.notification?.preimage)
assertEquals(10000L, notification.notification?.amount)
assertEquals(50L, notification.notification?.fees_paid)
}
@Test
fun testHoldInvoiceAcceptedDeserialization() {
val json =
"""{"notification_type":"hold_invoice_accepted","notification":{"type":"incoming","invoice":"lnbc200n1...","payment_hash":"hash789","amount":20000,"created_at":1693876497,"expires_at":1694876497,"settle_deadline":800000}}"""
val notification = OptimizedJsonMapper.fromJsonTo<Notification>(json)
assertIs<HoldInvoiceAcceptedNotification>(notification)
assertNotNull(notification.notification)
assertEquals("incoming", notification.notification?.type)
assertEquals("lnbc200n1...", notification.notification?.invoice)
assertEquals("hash789", notification.notification?.payment_hash)
assertEquals(20000L, notification.notification?.amount)
assertEquals(800000L, notification.notification?.settle_deadline)
assertEquals(1693876497L, notification.notification?.created_at)
assertEquals(1694876497L, notification.notification?.expires_at)
}
@Test
fun testPaymentReceivedMinimalFields() {
val json = """{"notification_type":"payment_received","notification":{"type":"incoming","amount":100}}"""
val notification = OptimizedJsonMapper.fromJsonTo<Notification>(json)
assertIs<PaymentReceivedNotification>(notification)
assertEquals("incoming", notification.notification?.type)
assertEquals(100L, notification.notification?.amount)
assertNull(notification.notification?.invoice)
assertNull(notification.notification?.preimage)
}
@Test
fun testUnknownNotificationTypeReturnsNull() {
val json = """{"notification_type":"unknown_type","notification":{}}"""
val notification = OptimizedJsonMapper.fromJsonTo<Notification>(json)
assertNull(notification)
}
}
@@ -0,0 +1,124 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip47WalletConnect
import com.vitorpamplona.quartz.utils.DeterministicSigner
import com.vitorpamplona.quartz.utils.nsecToKeyPair
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class NwcInfoEventTest {
private val signer = DeterministicSigner("nsec1w4uucmeyyng0kegm7486r23sv4majkmvqsj6eypprq0xttxss55s5mgg9t".nsecToKeyPair())
@Test
fun testBuildInfoEvent() {
val capabilities = listOf("pay_invoice", "get_balance", "make_invoice", "notifications")
val template = NwcInfoEvent.build(capabilities)
val event = signer.sign<NwcInfoEvent>(template)
assertEquals(NwcInfoEvent.KIND, event.kind)
assertEquals("pay_invoice get_balance make_invoice notifications", event.content)
}
@Test
fun testCapabilities() {
val capabilities = listOf("pay_invoice", "get_balance", "make_invoice")
val template = NwcInfoEvent.build(capabilities)
val event = signer.sign<NwcInfoEvent>(template)
val parsed = event.capabilities()
assertEquals(3, parsed.size)
assertTrue(parsed.contains("pay_invoice"))
assertTrue(parsed.contains("get_balance"))
assertTrue(parsed.contains("make_invoice"))
}
@Test
fun testSupportsMethod() {
val capabilities = listOf("pay_invoice", "get_balance")
val template = NwcInfoEvent.build(capabilities)
val event = signer.sign<NwcInfoEvent>(template)
assertTrue(event.supportsMethod("pay_invoice"))
assertTrue(event.supportsMethod("get_balance"))
assertFalse(event.supportsMethod("make_invoice"))
assertFalse(event.supportsMethod("pay_keysend"))
}
@Test
fun testSupportsNotifications() {
val capabilities = listOf("pay_invoice", "notifications")
val template = NwcInfoEvent.build(capabilities)
val event = signer.sign<NwcInfoEvent>(template)
assertTrue(event.supportsNotifications())
}
@Test
fun testDoesNotSupportNotifications() {
val capabilities = listOf("pay_invoice", "get_balance")
val template = NwcInfoEvent.build(capabilities)
val event = signer.sign<NwcInfoEvent>(template)
assertFalse(event.supportsNotifications())
}
@Test
fun testEncryptionSchemes() {
val capabilities = listOf("pay_invoice")
val template = NwcInfoEvent.build(capabilities, encryptionSchemes = listOf("nip44_v2", "nip04"))
val event = signer.sign<NwcInfoEvent>(template)
val schemes = event.encryptionSchemes()
assertEquals(2, schemes.size)
assertTrue(schemes.contains("nip44_v2"))
assertTrue(schemes.contains("nip04"))
}
@Test
fun testNotificationTypes() {
val capabilities = listOf("pay_invoice", "notifications")
val template = NwcInfoEvent.build(capabilities, notificationTypes = listOf("payment_received", "payment_sent"))
val event = signer.sign<NwcInfoEvent>(template)
val types = event.notificationTypes()
assertEquals(2, types.size)
assertTrue(types.contains("payment_received"))
assertTrue(types.contains("payment_sent"))
}
@Test
fun testInfoEventKind() {
assertEquals(13194, NwcInfoEvent.KIND)
}
@Test
fun testBuildWithNoOptionalTags() {
val capabilities = listOf("pay_invoice")
val template = NwcInfoEvent.build(capabilities)
val event = signer.sign<NwcInfoEvent>(template)
assertTrue(event.encryptionSchemes().isEmpty())
assertTrue(event.notificationTypes().isEmpty())
}
}
@@ -0,0 +1,116 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip47WalletConnect
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class NwcMethodTest {
@Test
fun testMethodConstants() {
assertEquals("pay_invoice", NwcMethod.PAY_INVOICE)
assertEquals("pay_keysend", NwcMethod.PAY_KEYSEND)
assertEquals("make_invoice", NwcMethod.MAKE_INVOICE)
assertEquals("lookup_invoice", NwcMethod.LOOKUP_INVOICE)
assertEquals("list_transactions", NwcMethod.LIST_TRANSACTIONS)
assertEquals("get_balance", NwcMethod.GET_BALANCE)
assertEquals("get_info", NwcMethod.GET_INFO)
assertEquals("get_budget", NwcMethod.GET_BUDGET)
assertEquals("sign_message", NwcMethod.SIGN_MESSAGE)
assertEquals("create_connection", NwcMethod.CREATE_CONNECTION)
assertEquals("make_hold_invoice", NwcMethod.MAKE_HOLD_INVOICE)
assertEquals("cancel_hold_invoice", NwcMethod.CANCEL_HOLD_INVOICE)
assertEquals("settle_hold_invoice", NwcMethod.SETTLE_HOLD_INVOICE)
}
@Test
fun testNotificationTypeConstants() {
assertEquals("payment_received", NwcNotificationType.PAYMENT_RECEIVED)
assertEquals("payment_sent", NwcNotificationType.PAYMENT_SENT)
assertEquals("hold_invoice_accepted", NwcNotificationType.HOLD_INVOICE_ACCEPTED)
}
@Test
fun testErrorCodeValues() {
val codes = NwcErrorCode.entries
assertEquals(13, codes.size)
assertEquals(NwcErrorCode.RATE_LIMITED, NwcErrorCode.valueOf("RATE_LIMITED"))
assertEquals(NwcErrorCode.NOT_IMPLEMENTED, NwcErrorCode.valueOf("NOT_IMPLEMENTED"))
assertEquals(NwcErrorCode.INSUFFICIENT_BALANCE, NwcErrorCode.valueOf("INSUFFICIENT_BALANCE"))
assertEquals(NwcErrorCode.PAYMENT_FAILED, NwcErrorCode.valueOf("PAYMENT_FAILED"))
assertEquals(NwcErrorCode.QUOTA_EXCEEDED, NwcErrorCode.valueOf("QUOTA_EXCEEDED"))
assertEquals(NwcErrorCode.RESTRICTED, NwcErrorCode.valueOf("RESTRICTED"))
assertEquals(NwcErrorCode.UNAUTHORIZED, NwcErrorCode.valueOf("UNAUTHORIZED"))
assertEquals(NwcErrorCode.INTERNAL, NwcErrorCode.valueOf("INTERNAL"))
assertEquals(NwcErrorCode.UNSUPPORTED_ENCRYPTION, NwcErrorCode.valueOf("UNSUPPORTED_ENCRYPTION"))
assertEquals(NwcErrorCode.BAD_REQUEST, NwcErrorCode.valueOf("BAD_REQUEST"))
assertEquals(NwcErrorCode.NOT_FOUND, NwcErrorCode.valueOf("NOT_FOUND"))
assertEquals(NwcErrorCode.EXPIRED, NwcErrorCode.valueOf("EXPIRED"))
assertEquals(NwcErrorCode.OTHER, NwcErrorCode.valueOf("OTHER"))
}
@Test
fun testTransactionTypeConstants() {
assertEquals("incoming", NwcTransactionType.INCOMING)
assertEquals("outgoing", NwcTransactionType.OUTGOING)
}
@Test
fun testTransactionStateConstants() {
assertEquals("PENDING", NwcTransactionState.PENDING)
assertEquals("SETTLED", NwcTransactionState.SETTLED)
assertEquals("FAILED", NwcTransactionState.FAILED)
assertEquals("ACCEPTED", NwcTransactionState.ACCEPTED)
}
@Test
fun testTransactionStateCaseInsensitive() {
// Alby Hub uses uppercase, Alby JS SDK uses lowercase
assertTrue(NwcTransactionState.isSettled("SETTLED"))
assertTrue(NwcTransactionState.isSettled("settled"))
assertTrue(NwcTransactionState.isPending("PENDING"))
assertTrue(NwcTransactionState.isPending("pending"))
assertTrue(NwcTransactionState.isFailed("FAILED"))
assertTrue(NwcTransactionState.isFailed("failed"))
assertTrue(NwcTransactionState.isAccepted("ACCEPTED"))
assertTrue(NwcTransactionState.isAccepted("accepted"))
assertFalse(NwcTransactionState.isSettled("pending"))
assertFalse(NwcTransactionState.isSettled(null))
}
@Test
fun testBudgetRenewalConstants() {
assertEquals("daily", NwcBudgetRenewal.DAILY)
assertEquals("weekly", NwcBudgetRenewal.WEEKLY)
assertEquals("monthly", NwcBudgetRenewal.MONTHLY)
assertEquals("yearly", NwcBudgetRenewal.YEARLY)
assertEquals("never", NwcBudgetRenewal.NEVER)
}
@Test
fun testNwcError() {
val error = NwcError(NwcErrorCode.UNAUTHORIZED, "not allowed")
assertEquals(NwcErrorCode.UNAUTHORIZED, error.code)
assertEquals("not allowed", error.message)
}
}
@@ -0,0 +1,144 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip47WalletConnect
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class NwcNotificationEventTest {
@Test
fun testKindConstants() {
assertEquals(23197, NwcNotificationEvent.KIND)
assertEquals(23196, NwcNotificationEvent.LEGACY_KIND)
}
@Test
fun testIsContentEncoded() {
val event =
NwcNotificationEvent(
id = "a".repeat(64),
pubKey = "b".repeat(64),
createdAt = 1234L,
tags = arrayOf(arrayOf("p", "c".repeat(64))),
content = "encrypted_content",
sig = "d".repeat(128),
)
assertTrue(event.isContentEncoded())
}
@Test
fun testClientPubKey() {
val clientPubKey = "c".repeat(64)
val event =
NwcNotificationEvent(
id = "a".repeat(64),
pubKey = "b".repeat(64),
createdAt = 1234L,
tags = arrayOf(arrayOf("p", clientPubKey)),
content = "encrypted",
sig = "d".repeat(128),
)
assertEquals(clientPubKey, event.clientPubKey())
}
@Test
fun testClientPubKeyMissing() {
val event =
NwcNotificationEvent(
id = "a".repeat(64),
pubKey = "b".repeat(64),
createdAt = 1234L,
tags = emptyArray(),
content = "encrypted",
sig = "d".repeat(128),
)
assertNull(event.clientPubKey())
}
@Test
fun testTalkingWithAsWalletService() {
val walletPubKey = "b".repeat(64)
val clientPubKey = "c".repeat(64)
val event =
NwcNotificationEvent(
id = "a".repeat(64),
pubKey = walletPubKey,
createdAt = 1234L,
tags = arrayOf(arrayOf("p", clientPubKey)),
content = "encrypted",
sig = "d".repeat(128),
)
// Wallet service asking "who am I talking with?" -> client
assertEquals(clientPubKey, event.talkingWith(walletPubKey))
}
@Test
fun testTalkingWithAsClient() {
val walletPubKey = "b".repeat(64)
val clientPubKey = "c".repeat(64)
val event =
NwcNotificationEvent(
id = "a".repeat(64),
pubKey = walletPubKey,
createdAt = 1234L,
tags = arrayOf(arrayOf("p", clientPubKey)),
content = "encrypted",
sig = "d".repeat(128),
)
// Client asking "who am I talking with?" -> wallet service (pubkey)
assertEquals(walletPubKey, event.talkingWith(clientPubKey))
}
@Test
fun testEventKindInFactory() {
val event =
NwcNotificationEvent(
id = "a".repeat(64),
pubKey = "b".repeat(64),
createdAt = 1234L,
tags = emptyArray(),
content = "",
sig = "c".repeat(128),
)
assertEquals(23197, event.kind)
}
@Test
fun testCanDecryptReturnsFalseForUnrelatedSigner() {
val walletPubKey = "b".repeat(64)
val clientPubKey = "c".repeat(64)
val event =
NwcNotificationEvent(
id = "a".repeat(64),
pubKey = walletPubKey,
createdAt = 1234L,
tags = arrayOf(arrayOf("p", clientPubKey)),
content = "encrypted",
sig = "d".repeat(128),
)
// A signer that is neither the wallet nor the client shouldn't be able to decrypt
assertFalse(event.clientPubKey() == "e".repeat(64))
assertFalse(event.pubKey == "e".repeat(64))
}
}
@@ -0,0 +1,402 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip47WalletConnect
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class RequestTest {
// --- PayInvoice ---
@Test
fun testPayInvoiceCreate() {
val request = PayInvoiceMethod.create("lnbc50n1...")
assertEquals(NwcMethod.PAY_INVOICE, request.method)
assertEquals("lnbc50n1...", request.params?.invoice)
assertNull(request.params?.amount)
}
@Test
fun testPayInvoiceCreateWithAmount() {
val request = PayInvoiceMethod.create("lnbc50n1...", 1000L)
assertEquals(NwcMethod.PAY_INVOICE, request.method)
assertEquals("lnbc50n1...", request.params?.invoice)
assertEquals(1000L, request.params?.amount)
}
@Test
fun testPayInvoiceSerialization() {
val request = PayInvoiceMethod.create("lnbc50n1...")
val json = OptimizedJsonMapper.toJson(request)
assertTrue(json.contains("\"method\":\"pay_invoice\""))
assertTrue(json.contains("\"invoice\":\"lnbc50n1...\""))
}
@Test
fun testPayInvoiceDeserialization() {
val json = """{"method":"pay_invoice","params":{"invoice":"lnbc50n1..."}}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<PayInvoiceMethod>(request)
assertEquals("lnbc50n1...", request.params?.invoice)
}
@Test
fun testPayInvoiceWithAmountDeserialization() {
val json = """{"method":"pay_invoice","params":{"invoice":"lnbc50n1...","amount":1000}}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<PayInvoiceMethod>(request)
assertEquals("lnbc50n1...", request.params?.invoice)
assertEquals(1000L, request.params?.amount)
}
// --- PayKeysend ---
@Test
fun testPayKeysendCreate() {
val request = PayKeysendMethod.create(1000L, "abcdef1234567890")
assertEquals(NwcMethod.PAY_KEYSEND, request.method)
assertEquals(1000L, request.params?.amount)
assertEquals("abcdef1234567890", request.params?.pubkey)
assertNull(request.params?.preimage)
assertNull(request.params?.tlv_records)
}
@Test
fun testPayKeysendWithTlvRecords() {
val tlvRecords = listOf(TlvRecord(7629169L, "hex_value"))
val request = PayKeysendMethod.create(1000L, "pubkey123", "preimage123", tlvRecords)
assertEquals(1000L, request.params?.amount)
assertEquals("pubkey123", request.params?.pubkey)
assertEquals("preimage123", request.params?.preimage)
assertNotNull(request.params?.tlv_records)
assertEquals(1, request.params?.tlv_records?.size)
assertEquals(
7629169L,
request.params
?.tlv_records
?.first()
?.type,
)
assertEquals(
"hex_value",
request.params
?.tlv_records
?.first()
?.value,
)
}
@Test
fun testPayKeysendSerialization() {
val request = PayKeysendMethod.create(1000L, "pubkey123")
val json = OptimizedJsonMapper.toJson(request)
assertTrue(json.contains("\"method\":\"pay_keysend\""))
assertTrue(json.contains("\"amount\":1000"))
assertTrue(json.contains("\"pubkey\":\"pubkey123\""))
}
@Test
fun testPayKeysendDeserialization() {
val json = """{"method":"pay_keysend","params":{"amount":1000,"pubkey":"pubkey123"}}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<PayKeysendMethod>(request)
assertEquals(1000L, request.params?.amount)
assertEquals("pubkey123", request.params?.pubkey)
}
// --- MakeInvoice ---
@Test
fun testMakeInvoiceCreate() {
val request = MakeInvoiceMethod.create(5000L, "test payment", null, 3600L)
assertEquals(NwcMethod.MAKE_INVOICE, request.method)
assertEquals(5000L, request.params?.amount)
assertEquals("test payment", request.params?.description)
assertNull(request.params?.description_hash)
assertEquals(3600L, request.params?.expiry)
}
@Test
fun testMakeInvoiceSerialization() {
val request = MakeInvoiceMethod.create(5000L, "test")
val json = OptimizedJsonMapper.toJson(request)
assertTrue(json.contains("\"method\":\"make_invoice\""))
assertTrue(json.contains("\"amount\":5000"))
}
@Test
fun testMakeInvoiceDeserialization() {
val json = """{"method":"make_invoice","params":{"amount":5000,"description":"test","expiry":3600}}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<MakeInvoiceMethod>(request)
assertEquals(5000L, request.params?.amount)
assertEquals("test", request.params?.description)
assertEquals(3600L, request.params?.expiry)
}
// --- LookupInvoice ---
@Test
fun testLookupInvoiceByHash() {
val request = LookupInvoiceMethod.createByHash("abc123")
assertEquals(NwcMethod.LOOKUP_INVOICE, request.method)
assertEquals("abc123", request.params?.payment_hash)
assertNull(request.params?.invoice)
}
@Test
fun testLookupInvoiceByInvoice() {
val request = LookupInvoiceMethod.createByInvoice("lnbc50n1...")
assertEquals(NwcMethod.LOOKUP_INVOICE, request.method)
assertNull(request.params?.payment_hash)
assertEquals("lnbc50n1...", request.params?.invoice)
}
@Test
fun testLookupInvoiceDeserialization() {
val json = """{"method":"lookup_invoice","params":{"payment_hash":"abc123"}}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<LookupInvoiceMethod>(request)
assertEquals("abc123", request.params?.payment_hash)
}
// --- ListTransactions ---
@Test
fun testListTransactionsCreate() {
val request = ListTransactionsMethod.create(from = 1000L, until = 2000L, limit = 10, offset = 0, unpaid = false, type = "incoming")
assertEquals(NwcMethod.LIST_TRANSACTIONS, request.method)
assertEquals(1000L, request.params?.from)
assertEquals(2000L, request.params?.until)
assertEquals(10, request.params?.limit)
assertEquals(0, request.params?.offset)
assertEquals(false, request.params?.unpaid)
assertEquals("incoming", request.params?.type)
}
@Test
fun testListTransactionsDeserialization() {
val json = """{"method":"list_transactions","params":{"from":1000,"until":2000,"limit":10}}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<ListTransactionsMethod>(request)
assertEquals(1000L, request.params?.from)
assertEquals(2000L, request.params?.until)
assertEquals(10, request.params?.limit)
}
// --- GetBalance ---
@Test
fun testGetBalanceCreate() {
val request = GetBalanceMethod.create()
assertEquals(NwcMethod.GET_BALANCE, request.method)
}
@Test
fun testGetBalanceSerialization() {
val request = GetBalanceMethod.create()
val json = OptimizedJsonMapper.toJson(request)
assertTrue(json.contains("\"method\":\"get_balance\""))
}
@Test
fun testGetBalanceDeserialization() {
val json = """{"method":"get_balance"}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<GetBalanceMethod>(request)
}
// --- GetInfo ---
@Test
fun testGetInfoCreate() {
val request = GetInfoMethod.create()
assertEquals(NwcMethod.GET_INFO, request.method)
}
@Test
fun testGetInfoDeserialization() {
val json = """{"method":"get_info"}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<GetInfoMethod>(request)
}
// --- MakeHoldInvoice ---
@Test
fun testMakeHoldInvoiceCreate() {
val request = MakeHoldInvoiceMethod.create(10000L, "payment_hash_abc", "hold invoice", null, 7200L, 144)
assertEquals(NwcMethod.MAKE_HOLD_INVOICE, request.method)
assertEquals(10000L, request.params?.amount)
assertEquals("payment_hash_abc", request.params?.payment_hash)
assertEquals("hold invoice", request.params?.description)
assertEquals(7200L, request.params?.expiry)
assertEquals(144, request.params?.min_cltv_expiry_delta)
}
@Test
fun testMakeHoldInvoiceDeserialization() {
val json = """{"method":"make_hold_invoice","params":{"amount":10000,"payment_hash":"abc","description":"test"}}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<MakeHoldInvoiceMethod>(request)
assertEquals(10000L, request.params?.amount)
assertEquals("abc", request.params?.payment_hash)
}
// --- CancelHoldInvoice ---
@Test
fun testCancelHoldInvoiceCreate() {
val request = CancelHoldInvoiceMethod.create("payment_hash_abc")
assertEquals(NwcMethod.CANCEL_HOLD_INVOICE, request.method)
assertEquals("payment_hash_abc", request.params?.payment_hash)
}
@Test
fun testCancelHoldInvoiceDeserialization() {
val json = """{"method":"cancel_hold_invoice","params":{"payment_hash":"abc123"}}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<CancelHoldInvoiceMethod>(request)
assertEquals("abc123", request.params?.payment_hash)
}
// --- SettleHoldInvoice ---
@Test
fun testSettleHoldInvoiceCreate() {
val request = SettleHoldInvoiceMethod.create("preimage_xyz")
assertEquals(NwcMethod.SETTLE_HOLD_INVOICE, request.method)
assertEquals("preimage_xyz", request.params?.preimage)
}
@Test
fun testSettleHoldInvoiceDeserialization() {
val json = """{"method":"settle_hold_invoice","params":{"preimage":"preimage_xyz"}}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<SettleHoldInvoiceMethod>(request)
assertEquals("preimage_xyz", request.params?.preimage)
}
// --- GetBudget ---
@Test
fun testGetBudgetCreate() {
val request = GetBudgetMethod.create()
assertEquals(NwcMethod.GET_BUDGET, request.method)
}
@Test
fun testGetBudgetSerialization() {
val request = GetBudgetMethod.create()
val json = OptimizedJsonMapper.toJson(request)
assertTrue(json.contains("\"method\":\"get_budget\""))
}
@Test
fun testGetBudgetDeserialization() {
val json = """{"method":"get_budget"}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<GetBudgetMethod>(request)
}
// --- SignMessage ---
@Test
fun testSignMessageCreate() {
val request = SignMessageMethod.create("Hello Nostr")
assertEquals(NwcMethod.SIGN_MESSAGE, request.method)
assertEquals("Hello Nostr", request.params?.message)
}
@Test
fun testSignMessageSerialization() {
val request = SignMessageMethod.create("test message")
val json = OptimizedJsonMapper.toJson(request)
assertTrue(json.contains("\"method\":\"sign_message\""))
assertTrue(json.contains("\"message\":\"test message\""))
}
@Test
fun testSignMessageDeserialization() {
val json = """{"method":"sign_message","params":{"message":"Hello Nostr"}}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<SignMessageMethod>(request)
assertEquals("Hello Nostr", request.params?.message)
}
// --- CreateConnection ---
@Test
fun testCreateConnectionCreate() {
val request =
CreateConnectionMethod.create(
pubkey = "abc123",
name = "My App",
requestMethods = listOf("pay_invoice", "get_balance"),
notificationTypes = listOf("payment_received"),
maxAmount = 100000L,
budgetRenewal = "monthly",
)
assertEquals(NwcMethod.CREATE_CONNECTION, request.method)
assertEquals("abc123", request.params?.pubkey)
assertEquals("My App", request.params?.name)
assertEquals(listOf("pay_invoice", "get_balance"), request.params?.request_methods)
assertEquals(listOf("payment_received"), request.params?.notification_types)
assertEquals(100000L, request.params?.max_amount)
assertEquals("monthly", request.params?.budget_renewal)
}
@Test
fun testCreateConnectionSerialization() {
val request = CreateConnectionMethod.create(pubkey = "abc123", name = "My App")
val json = OptimizedJsonMapper.toJson(request)
assertTrue(json.contains("\"method\":\"create_connection\""))
assertTrue(json.contains("\"pubkey\":\"abc123\""))
assertTrue(json.contains("\"name\":\"My App\""))
}
@Test
fun testCreateConnectionDeserialization() {
val json =
"""{"method":"create_connection","params":{"pubkey":"abc123","name":"Test App","request_methods":["pay_invoice"],"max_amount":50000,"budget_renewal":"monthly"}}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertIs<CreateConnectionMethod>(request)
assertEquals("abc123", request.params?.pubkey)
assertEquals("Test App", request.params?.name)
assertEquals(listOf("pay_invoice"), request.params?.request_methods)
assertEquals(50000L, request.params?.max_amount)
assertEquals("monthly", request.params?.budget_renewal)
}
// --- Unknown method ---
@Test
fun testUnknownMethodReturnsNull() {
val json = """{"method":"unknown_method","params":{}}"""
val request = OptimizedJsonMapper.fromJsonTo<Request>(json)
assertNull(request)
}
}
@@ -0,0 +1,403 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip47WalletConnect
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class ResponseTest {
// --- PayInvoice Success ---
@Test
fun testPayInvoiceSuccessDeserialization() {
val json = """{"result_type":"pay_invoice","result":{"preimage":"0123456789abcdef"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<PayInvoiceSuccessResponse>(response)
assertEquals("0123456789abcdef", response.result?.preimage)
}
@Test
fun testPayInvoiceSuccessWithFeesPaid() {
val json = """{"result_type":"pay_invoice","result":{"preimage":"abc","fees_paid":100}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<PayInvoiceSuccessResponse>(response)
assertEquals("abc", response.result?.preimage)
assertEquals(100L, response.result?.fees_paid)
}
@Test
fun testPayInvoiceSuccessGuessWithoutResultType() {
val json = """{"result":{"preimage":"0123456789abcdef"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<PayInvoiceSuccessResponse>(response)
assertEquals("0123456789abcdef", response.result?.preimage)
}
// --- PayInvoice Error ---
@Test
fun testPayInvoiceErrorDeserialization() {
val json = """{"result_type":"pay_invoice","error":{"code":"INSUFFICIENT_BALANCE","message":"Not enough funds"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<PayInvoiceErrorResponse>(response)
assertEquals(NwcErrorCode.INSUFFICIENT_BALANCE, response.error?.code)
assertEquals("Not enough funds", response.error?.message)
}
@Test
fun testPayInvoicePaymentFailedError() {
val json = """{"result_type":"pay_invoice","error":{"code":"PAYMENT_FAILED","message":"Route not found"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<PayInvoiceErrorResponse>(response)
assertEquals(NwcErrorCode.PAYMENT_FAILED, response.error?.code)
}
// --- PayKeysend Success ---
@Test
fun testPayKeysendSuccessDeserialization() {
val json = """{"result_type":"pay_keysend","result":{"preimage":"abc123","fees_paid":50}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<PayKeysendSuccessResponse>(response)
assertEquals("abc123", response.result?.preimage)
assertEquals(50L, response.result?.fees_paid)
}
// --- MakeInvoice Success ---
@Test
fun testMakeInvoiceSuccessDeserialization() {
val json =
"""{"result_type":"make_invoice","result":{"type":"incoming","invoice":"lnbc50n1...","description":"test","payment_hash":"abc","amount":5000,"fees_paid":0,"created_at":1693876497,"expires_at":1694876497}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<MakeInvoiceSuccessResponse>(response)
assertNotNull(response.result)
assertEquals("incoming", response.result?.type)
assertEquals("lnbc50n1...", response.result?.invoice)
assertEquals("test", response.result?.description)
assertEquals("abc", response.result?.payment_hash)
assertEquals(5000L, response.result?.amount)
assertEquals(1693876497L, response.result?.created_at)
assertEquals(1694876497L, response.result?.expires_at)
}
// --- LookupInvoice Success ---
@Test
fun testLookupInvoiceSuccessDeserialization() {
val json = """{"result_type":"lookup_invoice","result":{"type":"incoming","state":"settled","invoice":"lnbc...","payment_hash":"hash123","amount":1000,"settled_at":1694876497}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<LookupInvoiceSuccessResponse>(response)
assertNotNull(response.result)
assertEquals("incoming", response.result?.type)
assertEquals("settled", response.result?.state)
assertEquals("hash123", response.result?.payment_hash)
assertEquals(1000L, response.result?.amount)
assertEquals(1694876497L, response.result?.settled_at)
}
// --- ListTransactions Success ---
@Test
fun testListTransactionsSuccessDeserialization() {
val json =
"""{"result_type":"list_transactions","result":{"transactions":[{"type":"incoming","invoice":"lnbc1...","amount":100,"created_at":1000},{"type":"outgoing","invoice":"lnbc2...","amount":200,"created_at":2000}]}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<ListTransactionsSuccessResponse>(response)
assertNotNull(response.result?.transactions)
assertEquals(2, response.result?.transactions?.size)
assertEquals(
"incoming",
response.result
?.transactions
?.get(0)
?.type,
)
assertEquals(
100L,
response.result
?.transactions
?.get(0)
?.amount,
)
assertEquals(
"outgoing",
response.result
?.transactions
?.get(1)
?.type,
)
assertEquals(
200L,
response.result
?.transactions
?.get(1)
?.amount,
)
}
@Test
fun testListTransactionsEmptyResult() {
val json = """{"result_type":"list_transactions","result":{"transactions":[]}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<ListTransactionsSuccessResponse>(response)
assertNotNull(response.result?.transactions)
assertEquals(0, response.result?.transactions?.size)
}
// --- GetBalance Success ---
@Test
fun testGetBalanceSuccessDeserialization() {
val json = """{"result_type":"get_balance","result":{"balance":21000}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<GetBalanceSuccessResponse>(response)
assertEquals(21000L, response.result?.balance)
}
@Test
fun testGetBalanceZero() {
val json = """{"result_type":"get_balance","result":{"balance":0}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<GetBalanceSuccessResponse>(response)
assertEquals(0L, response.result?.balance)
}
// --- GetInfo Success ---
@Test
fun testGetInfoSuccessDeserialization() {
val json =
"""{"result_type":"get_info","result":{"alias":"MyNode","color":"#ff9900","pubkey":"abc123","network":"mainnet","block_height":800000,"block_hash":"hash","methods":["pay_invoice","get_balance"],"notifications":["payment_received"]}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<GetInfoSuccessResponse>(response)
assertNotNull(response.result)
assertEquals("MyNode", response.result?.alias)
assertEquals("#ff9900", response.result?.color)
assertEquals("abc123", response.result?.pubkey)
assertEquals("mainnet", response.result?.network)
assertEquals(800000L, response.result?.block_height)
assertEquals("hash", response.result?.block_hash)
assertEquals(listOf("pay_invoice", "get_balance"), response.result?.methods)
assertEquals(listOf("payment_received"), response.result?.notifications)
}
// --- MakeHoldInvoice Success ---
@Test
fun testMakeHoldInvoiceSuccessDeserialization() {
val json = """{"result_type":"make_hold_invoice","result":{"type":"incoming","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"expires_at":2000}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<MakeHoldInvoiceSuccessResponse>(response)
assertNotNull(response.result)
assertEquals("lnbc...", response.result?.invoice)
assertEquals("hash", response.result?.payment_hash)
}
// --- CancelHoldInvoice Success ---
@Test
fun testCancelHoldInvoiceSuccessDeserialization() {
val json = """{"result_type":"cancel_hold_invoice","result":{}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<CancelHoldInvoiceSuccessResponse>(response)
}
// --- SettleHoldInvoice Success ---
@Test
fun testSettleHoldInvoiceSuccessDeserialization() {
val json = """{"result_type":"settle_hold_invoice","result":{}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<SettleHoldInvoiceSuccessResponse>(response)
}
// --- Generic Error Response ---
@Test
fun testGenericErrorForGetBalance() {
val json = """{"result_type":"get_balance","error":{"code":"UNAUTHORIZED","message":"No wallet connected"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<NwcErrorResponse>(response)
assertEquals("get_balance", response.resultType)
assertEquals(NwcErrorCode.UNAUTHORIZED, response.error?.code)
assertEquals("No wallet connected", response.error?.message)
}
@Test
fun testGenericErrorForGetInfo() {
val json = """{"result_type":"get_info","error":{"code":"NOT_IMPLEMENTED","message":"Not supported"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<NwcErrorResponse>(response)
assertEquals("get_info", response.resultType)
assertEquals(NwcErrorCode.NOT_IMPLEMENTED, response.error?.code)
}
@Test
fun testGenericErrorForMakeInvoice() {
val json = """{"result_type":"make_invoice","error":{"code":"QUOTA_EXCEEDED","message":"Spending limit reached"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<NwcErrorResponse>(response)
assertEquals(NwcErrorCode.QUOTA_EXCEEDED, response.error?.code)
}
@Test
fun testGenericErrorRateLimited() {
val json = """{"result_type":"pay_keysend","error":{"code":"RATE_LIMITED","message":"Too many requests"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<NwcErrorResponse>(response)
assertEquals(NwcErrorCode.RATE_LIMITED, response.error?.code)
}
@Test
fun testGenericErrorUnsupportedEncryption() {
val json = """{"result_type":"pay_invoice","error":{"code":"UNSUPPORTED_ENCRYPTION","message":"Use nip44"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
// pay_invoice errors go to PayInvoiceErrorResponse for backward compat
assertIs<PayInvoiceErrorResponse>(response)
}
// --- GetBudget Success ---
@Test
fun testGetBudgetSuccessDeserialization() {
val json = """{"result_type":"get_budget","result":{"used_budget":50000,"total_budget":100000,"renews_at":1700000000,"renewal_period":"monthly"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<GetBudgetSuccessResponse>(response)
assertNotNull(response.result)
assertEquals(50000L, response.result?.used_budget)
assertEquals(100000L, response.result?.total_budget)
assertEquals(1700000000L, response.result?.renews_at)
assertEquals("monthly", response.result?.renewal_period)
}
@Test
fun testGetBudgetNoBudgetLimit() {
val json = """{"result_type":"get_budget","result":{"used_budget":0,"total_budget":0,"renewal_period":"never"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<GetBudgetSuccessResponse>(response)
assertEquals(0L, response.result?.used_budget)
assertEquals(0L, response.result?.total_budget)
assertNull(response.result?.renews_at)
assertEquals("never", response.result?.renewal_period)
}
// --- SignMessage Success ---
@Test
fun testSignMessageSuccessDeserialization() {
val json = """{"result_type":"sign_message","result":{"message":"Hello Nostr","signature":"sig123abc"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<SignMessageSuccessResponse>(response)
assertNotNull(response.result)
assertEquals("Hello Nostr", response.result?.message)
assertEquals("sig123abc", response.result?.signature)
}
// --- CreateConnection Success ---
@Test
fun testCreateConnectionSuccessDeserialization() {
val json = """{"result_type":"create_connection","result":{"wallet_pubkey":"walletpub123"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<CreateConnectionSuccessResponse>(response)
assertNotNull(response.result)
assertEquals("walletpub123", response.result?.wallet_pubkey)
}
// --- GetInfo with extended fields ---
@Test
fun testGetInfoWithMetadataAndLud16() {
val json =
"""{"result_type":"get_info","result":{"alias":"AlbyHub","methods":["pay_invoice","get_balance"],"notifications":["payment_received"],"lud16":"user@getalby.com"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<GetInfoSuccessResponse>(response)
assertEquals("AlbyHub", response.result?.alias)
assertEquals("user@getalby.com", response.result?.lud16)
assertEquals(listOf("pay_invoice", "get_balance"), response.result?.methods)
}
// --- ListTransactions with total_count ---
@Test
fun testListTransactionsWithTotalCount() {
val json =
"""{"result_type":"list_transactions","result":{"transactions":[{"type":"incoming","amount":100,"created_at":1000}],"total_count":42}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<ListTransactionsSuccessResponse>(response)
assertEquals(1, response.result?.transactions?.size)
assertEquals(42L, response.result?.total_count)
}
// --- Transaction with settle_deadline ---
@Test
fun testTransactionWithSettleDeadline() {
val json =
"""{"result_type":"lookup_invoice","result":{"type":"incoming","state":"ACCEPTED","invoice":"lnbc...","payment_hash":"hash","amount":5000,"created_at":1000,"settle_deadline":800000}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<LookupInvoiceSuccessResponse>(response)
assertEquals(800000L, response.result?.settle_deadline)
assertEquals("ACCEPTED", response.result?.state)
}
// --- Error responses for new error codes ---
@Test
fun testBadRequestError() {
val json = """{"result_type":"pay_invoice","error":{"code":"BAD_REQUEST","message":"Invalid invoice"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<PayInvoiceErrorResponse>(response)
assertEquals(NwcErrorCode.BAD_REQUEST, response.error?.code)
}
@Test
fun testNotFoundError() {
val json = """{"result_type":"lookup_invoice","error":{"code":"NOT_FOUND","message":"Invoice not found"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<NwcErrorResponse>(response)
assertEquals(NwcErrorCode.NOT_FOUND, response.error?.code)
}
@Test
fun testExpiredError() {
val json = """{"result_type":"pay_invoice","error":{"code":"EXPIRED","message":"Connection expired"}}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
assertIs<PayInvoiceErrorResponse>(response)
assertEquals(NwcErrorCode.EXPIRED, response.error?.code)
}
// --- Null/missing result ---
@Test
fun testResponseWithNoResultOrError() {
val json = """{"result_type":"pay_invoice"}"""
val response = OptimizedJsonMapper.fromJsonTo<Response>(json)
// Should still deserialize since result_type is present
assertIs<PayInvoiceSuccessResponse>(response)
assertNull(response.result)
}
}
@@ -0,0 +1,138 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.tags.EncryptionTag
import com.vitorpamplona.quartz.nip47WalletConnect.tags.NotificationsTag
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class TagsTest {
// --- EncryptionTag ---
@Test
fun testEncryptionTagParse() {
val tag = arrayOf("encryption", "nip44_v2", "nip04")
val result = EncryptionTag.parse(tag)
assertNotNull(result)
assertEquals(listOf("nip44_v2", "nip04"), result)
}
@Test
fun testEncryptionTagParseSingleScheme() {
val tag = arrayOf("encryption", "nip44_v2")
val result = EncryptionTag.parse(tag)
assertNotNull(result)
assertEquals(listOf("nip44_v2"), result)
}
@Test
fun testEncryptionTagParseWrongTagName() {
val tag = arrayOf("other", "nip44_v2")
val result = EncryptionTag.parse(tag)
assertNull(result)
}
@Test
fun testEncryptionTagParseTooShort() {
val tag = arrayOf("encryption")
val result = EncryptionTag.parse(tag)
assertNull(result)
}
@Test
fun testEncryptionTagParseEmptyValue() {
val tag = arrayOf("encryption", "")
val result = EncryptionTag.parse(tag)
assertNull(result)
}
@Test
fun testEncryptionTagAssemble() {
val tag = EncryptionTag.assemble(listOf("nip44_v2", "nip04"))
assertEquals("encryption", tag[0])
assertEquals("nip44_v2", tag[1])
assertEquals("nip04", tag[2])
assertEquals(3, tag.size)
}
@Test
fun testEncryptionTagIsTag() {
assertTrue(EncryptionTag.isTag(arrayOf("encryption", "nip44_v2")))
assertFalse(EncryptionTag.isTag(arrayOf("other", "nip44_v2")))
assertFalse(EncryptionTag.isTag(arrayOf("encryption")))
assertFalse(EncryptionTag.isTag(arrayOf("encryption", "")))
}
// --- NotificationsTag ---
@Test
fun testNotificationsTagParse() {
val tag = arrayOf("notifications", "payment_received", "payment_sent")
val result = NotificationsTag.parse(tag)
assertNotNull(result)
assertEquals(listOf("payment_received", "payment_sent"), result)
}
@Test
fun testNotificationsTagParseSingleType() {
val tag = arrayOf("notifications", "payment_received")
val result = NotificationsTag.parse(tag)
assertNotNull(result)
assertEquals(listOf("payment_received"), result)
}
@Test
fun testNotificationsTagParseWrongTagName() {
val tag = arrayOf("other", "payment_received")
val result = NotificationsTag.parse(tag)
assertNull(result)
}
@Test
fun testNotificationsTagParseTooShort() {
val tag = arrayOf("notifications")
val result = NotificationsTag.parse(tag)
assertNull(result)
}
@Test
fun testNotificationsTagAssemble() {
val tag = NotificationsTag.assemble(listOf("payment_received", "payment_sent", "hold_invoice_accepted"))
assertEquals("notifications", tag[0])
assertEquals("payment_received", tag[1])
assertEquals("payment_sent", tag[2])
assertEquals("hold_invoice_accepted", tag[3])
assertEquals(4, tag.size)
}
@Test
fun testNotificationsTagIsTag() {
assertTrue(NotificationsTag.isTag(arrayOf("notifications", "payment_received")))
assertFalse(NotificationsTag.isTag(arrayOf("other", "payment_received")))
assertFalse(NotificationsTag.isTag(arrayOf("notifications")))
assertFalse(NotificationsTag.isTag(arrayOf("notifications", "")))
}
}
@@ -52,8 +52,10 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerRequestDeseriali
import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerRequestSerializer
import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerResponseDeserializer
import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerResponseSerializer
import com.vitorpamplona.quartz.nip47WalletConnect.Notification
import com.vitorpamplona.quartz.nip47WalletConnect.Request
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip47WalletConnect.jackson.NotificationDeserializer
import com.vitorpamplona.quartz.nip47WalletConnect.jackson.RequestDeserializer
import com.vitorpamplona.quartz.nip47WalletConnect.jackson.ResponseDeserializer
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor
@@ -93,6 +95,7 @@ class JacksonMapper {
// nip 47
.addDeserializer(Response::class.java, ResponseDeserializer())
.addDeserializer(Request::class.java, RequestDeserializer())
.addDeserializer(Notification::class.java, NotificationDeserializer())
// nip 46
.addDeserializer(BunkerMessage::class.java, BunkerMessageDeserializer())
.addSerializer(BunkerRequest::class.java, BunkerRequestSerializer())
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip47WalletConnect.jackson
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.vitorpamplona.quartz.nip47WalletConnect.HoldInvoiceAcceptedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.Notification
import com.vitorpamplona.quartz.nip47WalletConnect.NwcNotificationType
import com.vitorpamplona.quartz.nip47WalletConnect.PaymentReceivedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.PaymentSentNotification
import com.vitorpamplona.quartz.utils.asTextOrNull
class NotificationDeserializer : StdDeserializer<Notification>(Notification::class.java) {
override fun deserialize(
jp: JsonParser,
ctxt: DeserializationContext,
): Notification? {
val jsonObject: JsonNode = jp.codec.readTree(jp)
val notificationType = jsonObject.get("notification_type")?.asTextOrNull()
return when (notificationType) {
NwcNotificationType.PAYMENT_RECEIVED -> jp.codec.treeToValue(jsonObject, PaymentReceivedNotification::class.java)
NwcNotificationType.PAYMENT_SENT -> jp.codec.treeToValue(jsonObject, PaymentSentNotification::class.java)
NwcNotificationType.HOLD_INVOICE_ACCEPTED -> jp.codec.treeToValue(jsonObject, HoldInvoiceAcceptedNotification::class.java)
else -> null
}
}
}
@@ -24,8 +24,21 @@ import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionMethod
import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.GetBudgetMethod
import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoMethod
import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsMethod
import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendMethod
import com.vitorpamplona.quartz.nip47WalletConnect.Request
import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.SignMessageMethod
import com.vitorpamplona.quartz.utils.asTextOrNull
class RequestDeserializer : StdDeserializer<Request>(Request::class.java) {
@@ -36,9 +49,21 @@ class RequestDeserializer : StdDeserializer<Request>(Request::class.java) {
val jsonObject: JsonNode = jp.codec.readTree(jp)
val method = jsonObject.get("method")?.asTextOrNull()
if (method == "pay_invoice") {
return jp.codec.treeToValue(jsonObject, PayInvoiceMethod::class.java)
return when (method) {
NwcMethod.PAY_INVOICE -> jp.codec.treeToValue(jsonObject, PayInvoiceMethod::class.java)
NwcMethod.PAY_KEYSEND -> jp.codec.treeToValue(jsonObject, PayKeysendMethod::class.java)
NwcMethod.MAKE_INVOICE -> jp.codec.treeToValue(jsonObject, MakeInvoiceMethod::class.java)
NwcMethod.LOOKUP_INVOICE -> jp.codec.treeToValue(jsonObject, LookupInvoiceMethod::class.java)
NwcMethod.LIST_TRANSACTIONS -> jp.codec.treeToValue(jsonObject, ListTransactionsMethod::class.java)
NwcMethod.GET_BALANCE -> jp.codec.treeToValue(jsonObject, GetBalanceMethod::class.java)
NwcMethod.GET_INFO -> jp.codec.treeToValue(jsonObject, GetInfoMethod::class.java)
NwcMethod.GET_BUDGET -> jp.codec.treeToValue(jsonObject, GetBudgetMethod::class.java)
NwcMethod.SIGN_MESSAGE -> jp.codec.treeToValue(jsonObject, SignMessageMethod::class.java)
NwcMethod.CREATE_CONNECTION -> jp.codec.treeToValue(jsonObject, CreateConnectionMethod::class.java)
NwcMethod.MAKE_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, MakeHoldInvoiceMethod::class.java)
NwcMethod.CANCEL_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, CancelHoldInvoiceMethod::class.java)
NwcMethod.SETTLE_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, SettleHoldInvoiceMethod::class.java)
else -> null
}
return null
}
}
@@ -24,9 +24,24 @@ import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.GetBudgetSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.NwcError
import com.vitorpamplona.quartz.nip47WalletConnect.NwcErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.SignMessageSuccessResponse
import com.vitorpamplona.quartz.utils.asTextOrNull
class ResponseDeserializer : StdDeserializer<Response>(Response::class.java) {
@@ -36,25 +51,86 @@ class ResponseDeserializer : StdDeserializer<Response>(Response::class.java) {
): Response? {
val jsonObject: JsonNode = jp.codec.readTree(jp)
val resultType = jsonObject.get("result_type")?.asTextOrNull()
val hasError = jsonObject.has("error") && !jsonObject.get("error").isNull
val hasResult = jsonObject.has("result") && !jsonObject.get("result").isNull
if (resultType == "pay_invoice") {
val result = jsonObject.get("result")
val error = jsonObject.get("error")
if (result != null) {
return jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java)
}
if (error != null) {
return jp.codec.treeToValue(jsonObject, PayInvoiceErrorResponse::class.java)
}
} else {
// tries to guess
if (jsonObject.get("result")?.get("preimage") != null) {
return jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java)
}
if (jsonObject.get("error")?.get("code") != null) {
return jp.codec.treeToValue(jsonObject, PayInvoiceErrorResponse::class.java)
if (hasError) {
return when (resultType) {
NwcMethod.PAY_INVOICE -> {
jp.codec.treeToValue(jsonObject, PayInvoiceErrorResponse::class.java)
}
else -> {
val error = jp.codec.treeToValue(jsonObject.get("error"), NwcError::class.java)
NwcErrorResponse(resultType ?: "", error)
}
}
}
if (hasResult || resultType != null) {
return when (resultType) {
NwcMethod.PAY_INVOICE -> {
jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java)
}
NwcMethod.PAY_KEYSEND -> {
jp.codec.treeToValue(jsonObject, PayKeysendSuccessResponse::class.java)
}
NwcMethod.MAKE_INVOICE -> {
jp.codec.treeToValue(jsonObject, MakeInvoiceSuccessResponse::class.java)
}
NwcMethod.LOOKUP_INVOICE -> {
jp.codec.treeToValue(jsonObject, LookupInvoiceSuccessResponse::class.java)
}
NwcMethod.LIST_TRANSACTIONS -> {
jp.codec.treeToValue(jsonObject, ListTransactionsSuccessResponse::class.java)
}
NwcMethod.GET_BALANCE -> {
jp.codec.treeToValue(jsonObject, GetBalanceSuccessResponse::class.java)
}
NwcMethod.GET_INFO -> {
jp.codec.treeToValue(jsonObject, GetInfoSuccessResponse::class.java)
}
NwcMethod.GET_BUDGET -> {
jp.codec.treeToValue(jsonObject, GetBudgetSuccessResponse::class.java)
}
NwcMethod.SIGN_MESSAGE -> {
jp.codec.treeToValue(jsonObject, SignMessageSuccessResponse::class.java)
}
NwcMethod.CREATE_CONNECTION -> {
jp.codec.treeToValue(jsonObject, CreateConnectionSuccessResponse::class.java)
}
NwcMethod.MAKE_HOLD_INVOICE -> {
jp.codec.treeToValue(jsonObject, MakeHoldInvoiceSuccessResponse::class.java)
}
NwcMethod.CANCEL_HOLD_INVOICE -> {
jp.codec.treeToValue(jsonObject, CancelHoldInvoiceSuccessResponse::class.java)
}
NwcMethod.SETTLE_HOLD_INVOICE -> {
jp.codec.treeToValue(jsonObject, SettleHoldInvoiceSuccessResponse::class.java)
}
else -> {
// tries to guess for backward compatibility
if (jsonObject.get("result")?.get("preimage") != null) {
return jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java)
}
null
}
}
}
return null
}
}