mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 00:16:59 +00:00
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:
@@ -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?,
|
||||
|
||||
+39
@@ -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) }
|
||||
|
||||
+9
@@ -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()
|
||||
|
||||
+268
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+278
@@ -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))
|
||||
}
|
||||
}
|
||||
+219
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+328
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+283
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
+30
-8
@@ -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)
|
||||
}
|
||||
|
||||
+40
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+260
@@ -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,
|
||||
)
|
||||
}
|
||||
+301
@@ -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,
|
||||
)
|
||||
}
|
||||
+7
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+59
@@ -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,
|
||||
)
|
||||
+42
@@ -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,
|
||||
)
|
||||
+69
@@ -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"
|
||||
}
|
||||
+88
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -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,
|
||||
)
|
||||
+106
@@ -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.
|
||||
+219
-2
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+114
-32
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+41
@@ -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())
|
||||
}
|
||||
}
|
||||
+41
@@ -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)
|
||||
|
||||
+588
@@ -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)
|
||||
}
|
||||
}
|
||||
+207
@@ -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))
|
||||
}
|
||||
}
|
||||
+169
@@ -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)
|
||||
}
|
||||
}
|
||||
+97
@@ -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)
|
||||
}
|
||||
}
|
||||
+124
@@ -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())
|
||||
}
|
||||
}
|
||||
+116
@@ -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)
|
||||
}
|
||||
}
|
||||
+144
@@ -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))
|
||||
}
|
||||
}
|
||||
+402
@@ -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)
|
||||
}
|
||||
}
|
||||
+403
@@ -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", "")))
|
||||
}
|
||||
}
|
||||
+3
@@ -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())
|
||||
|
||||
+49
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-3
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+92
-16
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user