diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 724d0252e..4316e8668 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -50,6 +50,7 @@ + Unit) { if (!isWriteable()) return zapPaymentRequest?.let { - val event = LnZapPaymentRequestEvent.create(lnInvoice, it.pubKeyHex, it.secret?.toByteArray() ?: loggedIn.privKey!!) + val event = LnZapPaymentRequestEvent.create(bolt11, it.pubKeyHex, it.secret?.toByteArray() ?: loggedIn.privKey!!) - Client.send(event, it.relayUri) + val wcListener = NostrLnZapPaymentResponseDataSource(it.pubKeyHex, loggedIn.pubKey.toHexKey(), event.id) + wcListener.start() + + LocalCache.consume(event, onResponse) + Client.send(event, it.relayUri, wcListener.feedTypes) { + wcListener.destroy() + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 140a30849..4554deb62 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -31,6 +31,8 @@ object LocalCache { val channels = ConcurrentHashMap() val addressables = ConcurrentHashMap(100) + val awaitingPaymentRequests = ConcurrentHashMap Unit>(10) + fun checkGetOrCreateUser(key: String): User? { if (isValidHexNpub(key)) { return getOrCreateUser(key) @@ -675,6 +677,22 @@ object LocalCache { refreshObservers(note) } + fun consume(event: LnZapPaymentRequestEvent) { + // Does nothing without a response callback. + } + + fun consume(event: LnZapPaymentRequestEvent, onResponse: (LnZapPaymentResponseEvent) -> Unit) { + awaitingPaymentRequests.put(event.id, onResponse) + } + + fun consume(event: LnZapPaymentResponseEvent) { + val responseCallback = awaitingPaymentRequests[event.requestId()] + + if (responseCallback != null) { + responseCallback(event) + } + } + fun findUsersStartingWith(username: String): List { return users.values.filter { (it.anyNameStartsWith(username)) || diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt index 4fb1bdcde..854cf4b1c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt @@ -14,7 +14,7 @@ object Nip47 { val url = Uri.parse(uri) - if (url.scheme != "nostrwalletconnect") { + if (url.scheme != "nostrwalletconnect" || url.scheme != "nostr+walletconnect") { throw IllegalArgumentException("Not a Wallet Connect QR Code") } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index cb867066c..1d2327426 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -88,6 +88,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, + LnZapPaymentResponseEvent.kind, ChannelMessageEvent.kind, BadgeAwardEvent.kind ), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index 61fa8bedf..206a6538a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import java.util.Date import java.util.UUID +import kotlin.Error abstract class NostrDataSource(val debugName: String) { private var subscriptions = mapOf() @@ -81,6 +82,8 @@ abstract class NostrDataSource(val debugName: String) { LocalCache.consume(event) } is LnZapRequestEvent -> LocalCache.consume(event) + is LnZapPaymentRequestEvent -> LocalCache.consume(event) + is LnZapPaymentResponseEvent -> LocalCache.consume(event) is LongTextNoteEvent -> LocalCache.consume(event, relay) is MetadataEvent -> LocalCache.consume(event) is PrivateDmEvent -> LocalCache.consume(event, relay) @@ -129,6 +132,11 @@ abstract class NostrDataSource(val debugName: String) { Client.subscribe(clientListener) } + fun destroy() { + stop() + Client.unsubscribe(clientListener) + } + open fun start() { println("DataSource: ${this.javaClass.simpleName} Start") resetFilters() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt new file mode 100644 index 000000000..faa301cce --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt @@ -0,0 +1,39 @@ +package com.vitorpamplona.amethyst.service + +import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent +import com.vitorpamplona.amethyst.service.relays.FeedType +import com.vitorpamplona.amethyst.service.relays.JsonFilter +import com.vitorpamplona.amethyst.service.relays.TypedFilter + +class NostrLnZapPaymentResponseDataSource( + private var fromServiceHex: String, + private var toUserHex: String, + private var replyingToHex: String, +): NostrDataSource("LnZapPaymentResponseFeed") { + + val feedTypes = setOf(FeedType.WALLET_CONNECT) + + private fun createWalletConnectServiceWatcher(): TypedFilter { + // downloads all the reactions to a given event. + return TypedFilter( + types = feedTypes, + filter = JsonFilter( + kinds = listOf(LnZapPaymentResponseEvent.kind), + authors = listOf(fromServiceHex), + tags = mapOf( + "e" to listOf(replyingToHex), + "p" to listOf(toUserHex) + ), + limit = 1 + ) + ) + } + + val channel = requestNewChannel() + + override fun updateChannelFilters() { + val wc = createWalletConnectServiceWatcher() + + channel.typedFilters = listOfNotNull(wc).ifEmpty { null } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt index 6d857328c..9b63fa799 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt @@ -225,6 +225,7 @@ open class Event( FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig) 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) LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig) LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig) MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt index c35c75fda..e1d95ed87 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log +import com.google.gson.annotations.SerializedName import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.model.toHexKey @@ -16,6 +17,8 @@ class LnZapPaymentRequestEvent( sig: HexKey ) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + fun walletServicePubKey() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1) + fun lnInvoice(privKey: ByteArray): String? { return try { val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray()) @@ -37,9 +40,10 @@ class LnZapPaymentRequestEvent( createdAt: Long = Date().time / 1000 ): LnZapPaymentRequestEvent { val pubKey = Utils.pubkeyCreate(privateKey) + val serializedRequest = gson.toJson(PayInvoiceMethod(lnInvoice)) val content = Utils.encrypt( - lnInvoice, + serializedRequest, privateKey, walletServicePubkey.toByteArray() ) @@ -53,3 +57,21 @@ class LnZapPaymentRequestEvent( } } } + +// REQUEST OBJECTS + +abstract class Request(val method: String, val params: Params) +abstract class Params + + +// PayInvoice Call + +class PayInvoiceMethod(bolt11: String): Request("pay_invoice", PayInvoiceParams(bolt11)) { + class PayInvoiceParams(val invoice: String): Params() +} + + + + + + diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt new file mode 100644 index 000000000..1b6889878 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt @@ -0,0 +1,83 @@ +package com.vitorpamplona.amethyst.service.model + +import android.util.Log +import com.google.gson.annotations.SerializedName +import com.google.gson.reflect.TypeToken +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toByteArray +import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Utils +import java.util.Date + +class LnZapPaymentResponseEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + + fun requestAuthor() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1) + fun requestId() = tags.firstOrNull() { it.size > 1 && it[0] == "e" }?.get(1) + + fun response(): Response? = try { + if (content.isNotEmpty()) { + gson.fromJson(content, Response::class.java) + } else { + null + } + } catch (e: Exception) { + Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e) + null + } + + companion object { + const val kind = 23195 + } +} + +// RESPONSE OBJECTS +abstract class Response( + @SerializedName("result_type") + val resultType: String +) + +// PayInvoice Call + +class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams): + Response("pay_invoice") +{ + class PayInvoiceResultParams(val preimage: String) +} + +class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null): + Response("pay_invoice") +{ + class PayInvoiceErrorParams(val code: ErrorType?, val message: String?) + + enum class ErrorType { + @SerializedName(value = "rate_limited", alternate = ["RATE_LIMITED"]) + RATE_LIMITED, // The client is sending commands too fast. It should retry in a few seconds. + @SerializedName(value = "not_implemented", alternate = ["NOT_IMPLEMENTED"]) + NOT_IMPLEMENTED, // The command is not known or is intentionally not implemented. + @SerializedName(value = "insufficient_balance", alternate = ["INSUFFICIENT_BALANCE"]) + INSUFFICIENT_BALANCE, // The wallet does not have enough funds to cover a fee reserve or the payment amount. + @SerializedName(value = "quota_exceeded", alternate = ["QUOTA_EXCEEDED"]) + QUOTA_EXCEEDED, // The wallet has exceeded its spending quota. + @SerializedName(value = "restricted", alternate = ["RESTRICTED"]) + RESTRICTED, // This public key is not allowed to do this operation. + @SerializedName(value = "unauthorized", alternate = ["UNAUTHORIZED"]) + UNAUTHORIZED, // This public key has no wallet connected. + @SerializedName(value = "internal", alternate = ["INTERNAL"]) + INTERNAL, // An internal error. + @SerializedName(value = "other", alternate = ["OTHER"]) + OTHER, // Other error. + } +} + + + + + + diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt index 6f841deb6..ddcc98db1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt @@ -1,11 +1,14 @@ package com.vitorpamplona.amethyst.service.relays +import com.vitorpamplona.amethyst.service.NostrDataSource import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.EventInterface import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.util.UUID /** @@ -64,22 +67,43 @@ object Client : RelayPool.Listener { RelayPool.sendFilterOnlyIfDisconnected() } - fun send(signedEvent: EventInterface, relay: String? = null) { + fun send(signedEvent: EventInterface, relay: String? = null, feedTypes: Set? = null, onDone: (() -> Unit)? = null) { if (relay == null) { RelayPool.send(signedEvent) } else { - val useConnectedRelay = relays.filter { it.url == relay } + val useConnectedRelayIfPresent = relays.filter { it.url == relay } - if (useConnectedRelay.isNotEmpty()) { - useConnectedRelay.forEach { + if (useConnectedRelayIfPresent.isNotEmpty()) { + useConnectedRelayIfPresent.forEach { it.send(signedEvent) } } else { /** temporary connection */ - Relay(relay, false, true, emptySet()).requestAndWatch() { - it.send(signedEvent) - it.disconnect() - } + newSporadicRelay(relay, feedTypes, + onConnected = { relay -> + relay.send(signedEvent) + }, + onDone = onDone + ) + } + } + } + + @OptIn(DelicateCoroutinesApi::class) + fun newSporadicRelay(url: String, feedTypes: Set?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) { + val relay = Relay(url, true, true, feedTypes ?: emptySet()) + RelayPool.addRelay(relay) + + relay.requestAndWatch { + onConnected(relay) + + GlobalScope.launch(Dispatchers.IO) { + delay(10000) // waits for a reply + relay.disconnect() + RelayPool.removeRelay(relay) + + if (onDone != null) + onDone() } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt index 87407844b..279c1bbf0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt @@ -3,8 +3,10 @@ package com.vitorpamplona.amethyst.service.relays import android.util.Log import com.google.gson.JsonElement import com.vitorpamplona.amethyst.BuildConfig +import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.EventInterface +import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response @@ -13,7 +15,7 @@ import okhttp3.WebSocketListener import java.util.Date enum class FeedType { - FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH + FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH, WALLET_CONNECT } class Relay( @@ -93,11 +95,18 @@ class Relay( val type = msg[0].asString val channel = msg[1].asString + Log.w("Relay", "New Message $type, $url, $channel, ${msg[2]}") + when (type) { "EVENT" -> { + val event = Event.fromJson(msg[2], Client.lenient) + if (event.kind == LnZapPaymentResponseEvent.kind) { + println("This " + event.toJson()) + } + // Log.w("Relay", "Relay onEVENT $url, $channel") listeners.forEach { - it.onEvent(this@Relay, channel, Event.fromJson(msg[2], Client.lenient)) + it.onEvent(this@Relay, channel, event) if (afterEOSE) { it.onRelayStateChange(this@Relay, Type.EOSE, channel) } @@ -109,15 +118,15 @@ class Relay( it.onRelayStateChange(this@Relay, Type.EOSE, channel) } "NOTICE" -> listeners.forEach { - // Log.w("Relay", "Relay onNotice $url, $channel") + Log.w("Relay", "Relay onNotice $url, $channel") it.onError(this@Relay, channel, Error("Relay sent notice: " + channel)) } "OK" -> listeners.forEach { - // Log.w("Relay", "Relay onOK $url, $channel") + Log.w("Relay", "Relay on OK $url, $channel") it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString) } else -> listeners.forEach { - // Log.w("Relay", "Relay something else $url, $channel") + Log.w("Relay", "Relay something else $url, $channel") it.onError( this@Relay, channel, @@ -213,6 +222,26 @@ class Relay( } } + fun sendUnregisteredFilter(request: JsonFilter, subscriptionId: String?) { + if (read) { + if (isConnected()) { + if (isReady) { + val request = """["REQ","${subscriptionId ?: ""}",${request.toJson(url)}]""" + println("FILTERSSENT $url $request") + socket?.send(request) + eventUploadCounterInBytes += request.bytesUsedInMemory() + afterEOSE = false + } + } else { + // waits 60 seconds to reconnect after disconnected. + if (Date().time / 1000 > closingTime + 60) { + // sends all filters after connection is successful. + requestAndWatch() + } + } + } + } + fun sendFilterOnlyIfDisconnected() { if (socket == null) { // waits 60 seconds to reconnect after disconnected. diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index c88352b6c..6f3a89df8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -15,6 +15,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.service.model.LnZapEvent +import com.vitorpamplona.amethyst.service.model.PayInvoiceErrorResponse import com.vitorpamplona.amethyst.service.model.ReportEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -80,7 +81,22 @@ class AccountViewModel(private val account: Account) : ViewModel() { onSuccess = { onProgress(0.7f) if (account.hasWalletConnectSetup()) { - account.sendZapPaymentRequestFor(it) + account.sendZapPaymentRequestFor( + bolt11 = it, + onResponse = { + val response = it.response() + if (response is PayInvoiceErrorResponse) { + onProgress(0.0f) + onError( + response.error?.message + ?: response.error?.code?.toString() + ?: "Error parsing error message" + ) + } else { + // awaits for confirmation from Receiver or timeout. + } + }, + ) onProgress(0.8f) // Awaits for the event to come back to LocalCache. diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index 4d9bf93cc..040063733 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.content.Intent import android.net.Uri +import android.widget.Toast import androidx.compose.foundation.* import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.* @@ -59,6 +60,9 @@ import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent import com.vitorpamplona.amethyst.service.model.IdentityClaim +import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent +import com.vitorpamplona.amethyst.service.model.PayInvoiceErrorResponse +import com.vitorpamplona.amethyst.service.model.PayInvoiceSuccessResponse import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView import com.vitorpamplona.amethyst.ui.components.DisplayNip05ProfileStatus @@ -420,6 +424,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode val uri = LocalUriHandler.current val clipboardManager = LocalClipboardManager.current val context = LocalContext.current + val scope = rememberCoroutineScope() Row(verticalAlignment = Alignment.Bottom) { user.bestDisplayName()?.let { @@ -561,7 +566,28 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode onSuccess = { // pay directly if (account.hasWalletConnectSetup()) { - account.sendZapPaymentRequestFor(it) + account.sendZapPaymentRequestFor(it) { + val response = it.response() + if (response is PayInvoiceSuccessResponse) { + scope.launch { + Toast.makeText( + context, + "Payment Successful", // Turn this into a UI animation + Toast.LENGTH_LONG + ).show() + } + } else if (response is PayInvoiceErrorResponse) { + scope.launch { + Toast.makeText( + context, + response.error?.message + ?: response.error?.code?.toString() + ?: "Error parsing error message", + Toast.LENGTH_LONG + ).show() + } + } + } } else { runCatching { val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it"))