From 91f3f036ae036212c624f727f73c547867f09352 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 16:35:42 -0400 Subject: [PATCH] Making poll votes work with Private Zaps. --- .../vitorpamplona/amethyst/PrivateZapTests.kt | 139 ++++++++++++++++++ .../vitorpamplona/amethyst/model/Account.kt | 41 +++++- .../com/vitorpamplona/amethyst/model/Note.kt | 6 +- .../service/model/LnZapRequestEvent.kt | 21 +-- .../amethyst/ui/note/PollNote.kt | 42 ++++-- .../amethyst/ui/note/PollNoteViewModel.kt | 21 ++- .../amethyst/ui/note/ReactionsRow.kt | 12 +- .../ui/screen/loggedIn/AccountViewModel.kt | 4 + 8 files changed, 253 insertions(+), 33 deletions(-) create mode 100644 app/src/androidTest/java/com/vitorpamplona/amethyst/PrivateZapTests.kt diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/PrivateZapTests.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/PrivateZapTests.kt new file mode 100644 index 000000000..c131a8af3 --- /dev/null +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/PrivateZapTests.kt @@ -0,0 +1,139 @@ +package com.vitorpamplona.amethyst + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.model.toHexKey +import com.vitorpamplona.amethyst.service.model.Event +import com.vitorpamplona.amethyst.service.model.LnZapEvent +import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent +import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent.Companion.createEncryptionPrivateKey +import fr.acinq.secp256k1.Hex +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.fail +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PrivateZapTests { + + @Test + fun testPollZap() { + val poll = Event.Companion.fromJson( + """{ + "content": "New poll \n\n #zappoll", + "created_at": 1682440713, + "id": "16291ba452bb0786a4bf5c278d38de73c96b58c056ed75c5ea466b0795197288", + "kind": 6969, + "pubkey": "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", + "sig": "ac05fa4004c3f7c42913c87b11bf9714bb61a3f0940863a6b9ff0f8105b399add72dbc09bf944c79b9a72ef009ec6905adedbd2c4c8fb3d2f57007bad8fcb279", + "tags": [ + [ + "poll_option", + "0", + "Test 1" + ], + [ + "poll_option", + "1", + "Test 2" + ], + [ + "value_maximum", + "null" + ], + [ + "value_minimum", + "null" + ], + [ + "consensus_threshold", + "null" + ], + [ + "closed_at", + "null" + ] + ], + "seenOn": [ + "wss://relay.damus.io/" + ] +} +""", + true + ) + + val loggedIn = Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598") + + val privateZapRequest = LnZapRequestEvent.create( + poll, + setOf("wss://relay.damus.io/"), + loggedIn, + 0, + "", + LnZapEvent.ZapType.PRIVATE + ) + + val recepientPK = privateZapRequest.zappedAuthor().firstOrNull() + val recepientPost = privateZapRequest.zappedPost().firstOrNull() + + if (recepientPK != null && recepientPost != null) { + val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt) + val decodedPrivateZap = + LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK) + + println(decodedPrivateZap?.toJson()) + assertNotNull(decodedPrivateZap) + } else { + fail("Should not be null") + } + } + + @Test + fun testKind1PrivateZap() { + val textNote = Event.Companion.fromJson( + """{ + "content": "Testing copied author. \n\nnostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", + "created_at": 1682369982, + "id": "c757e1371d715c711ec9ef9740a3df6475d64b3d0af45ffcbfca08d273baf1c1", + "kind": 1, + "pubkey": "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", + "sig": "1fb5b6fd980f4c2ef058d5f4f7b166c0e5fb21eff26fe9cacd87a9aa4feb344485841ebcc26a233bf8d6ea0a66acf0db2bfdb11ad1cb04bcea4cfa3e78c3eaf1", + "tags": [ + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ], + "seenOn": [ + "wss://relay.damus.io/" + ] +} +""", + true + ) + + val loggedIn = Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598") + + val privateZapRequest = LnZapRequestEvent.create( + textNote, + setOf("wss://relay.damus.io/", "wss://relay.damus2.io/", "wss://relay.damus3.io/"), + loggedIn, + null, + "test", + LnZapEvent.ZapType.PRIVATE + ) + + val recepientPK = privateZapRequest.zappedAuthor().firstOrNull() + val recepientPost = privateZapRequest.zappedPost().firstOrNull() + + if (recepientPK != null && recepientPost != null) { + val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt) + val decodedPrivateZap = + LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK) + + println(decodedPrivateZap?.toJson()) + assertNotNull(decodedPrivateZap) + } else { + fail("Should not be null") + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index f86103abb..95b037e9a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -692,7 +692,7 @@ class Account( event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray()) } else if (event is LnZapRequestEvent && loggedIn.privKey != null) { - LnZapRequestEvent.checkForPrivateZap(event, loggedIn.privKey!!)?.content() + decryptZapContentAuthor(note)?.content() } else { event?.content() } @@ -700,8 +700,43 @@ class Account( fun decryptZapContentAuthor(note: Note): Event? { val event = note.event - return if (event is LnZapRequestEvent && loggedIn.privKey != null) { - LnZapRequestEvent.checkForPrivateZap(event, loggedIn.privKey!!) + val loggedInPrivateKey = loggedIn.privKey + + return if (event is LnZapRequestEvent && loggedInPrivateKey != null && event.isPrivateZap()) { + val recipientPK = event.zappedAuthor().firstOrNull() + val recipientPost = event.zappedPost().firstOrNull() + + if (recipientPK == userProfile().pubkeyHex) { + // if the receiver is logged in, these are the params. + val privateKeyToUse = loggedInPrivateKey + val pubkeyToUse = event.pubKey + + LnZapRequestEvent.checkForPrivateZap(event, privateKeyToUse, pubkeyToUse) + } else { + // if the sender is logged in, these are the params + val altPubkeyToUse = recipientPK + val altPrivateKeyToUse = if (recipientPost != null) { + LnZapRequestEvent.createEncryptionPrivateKey( + loggedInPrivateKey.toHexKey(), + recipientPost, + event.createdAt + ) + } else if (recipientPK != null) { + LnZapRequestEvent.createEncryptionPrivateKey( + loggedInPrivateKey.toHexKey(), + recipientPK, + event.createdAt + ) + } else { + null + } + + if (altPrivateKeyToUse != null && altPubkeyToUse != null) { + LnZapRequestEvent.checkForPrivateZap(event, altPrivateKeyToUse, altPubkeyToUse) + } else { + null + } + } } else { null } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 0b145008f..4c4959398 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -200,9 +200,11 @@ open class Note(val idHex: String) { } } - fun isZappedBy(user: User): Boolean { + fun isZappedBy(user: User, account: Account): Boolean { // Zaps who the requester was the user - return zaps.any { it.key.author === user } + return zaps.any { + it.key.author === user || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex + } } fun isReactedBy(user: User): Boolean { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt index b672fb570..0fc12d565 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt @@ -24,6 +24,8 @@ class LnZapRequestEvent( fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } + fun isPrivateZap() = tags.any { t -> t.size >= 2 && t[0] == "anon" && t[1].isNotBlank() } + companion object { const val kind = 9734 @@ -135,26 +137,27 @@ class LnZapRequestEvent( if (parts.size != 2) { throw IllegalArgumentException("Invalid message format") } - val iv = parts[1].run { Bech32.decode(this) } - val encryptedMsg = parts.first().run { Bech32.decode(this) } + val iv = parts[1].run { Bech32.decode(this).second } + val encryptedMsg = parts.first().run { Bech32.decode(this).second } + val encryptedBytes = Bech32.five2eight(encryptedMsg, 0) val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(Bech32.five2eight(iv.second, 0))) + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(Bech32.five2eight(iv, 0))) try { - val decryptedMsgBytes = cipher.doFinal(Bech32.five2eight(encryptedMsg.second, 0)) + val decryptedMsgBytes = cipher.doFinal(encryptedBytes) return String(decryptedMsgBytes) } catch (ex: BadPaddingException) { - throw IllegalArgumentException("Bad padding") + throw IllegalArgumentException("Bad padding: ${ex.message}") } } - fun checkForPrivateZap(zaprequest: Event, loggedInUserPrivKey: ByteArray): Event? { - val anonTag = zaprequest.tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" } + fun checkForPrivateZap(zapRequest: LnZapRequestEvent, loggedInUserPrivKey: ByteArray, pubKey: HexKey): Event? { + val anonTag = zapRequest.tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" } if (anonTag != null) { val encnote = anonTag[1] - if (encnote != null && encnote != "") { + if (encnote.isNotBlank()) { try { - val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, zaprequest.pubKey.toByteArray()) + val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.toByteArray()) val decryptedEvent = fromJson(note) if (decryptedEvent.kind == 9733) { return decryptedEvent diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index 28ffb025f..ee2f86dc2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -31,12 +31,12 @@ import androidx.compose.ui.window.Popup import androidx.navigation.NavController import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.util.* import kotlin.math.roundToInt @@ -51,7 +51,11 @@ fun PollNote( val zapsState by baseNote.live().zaps.observeAsState() val zappedNote = zapsState?.note ?: return + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return + val pollViewModel = PollNoteViewModel() + pollViewModel.account = account pollViewModel.load(zappedNote) pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op -> @@ -69,7 +73,7 @@ fun PollNote( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 3.dp) ) { - if (accountViewModel.isLoggedUser(zappedNote.author) || zappedNote.isZappedBy(accountViewModel.userProfile())) { + if (pollViewModel.canZap()) { ZapVote( baseNote, accountViewModel, @@ -288,7 +292,17 @@ fun ZapVote( clickablePrepend() - if (pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())) { + var optionWasZappedByLoggedInUser by remember { mutableStateOf(false) } + + LaunchedEffect(key1 = zappedNote) { + withContext(Dispatchers.IO) { + if (!optionWasZappedByLoggedInUser) { + optionWasZappedByLoggedInUser = pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile()) + } + } + } + + if (optionWasZappedByLoggedInUser) { zappingProgress = 1f Icon( imageVector = Icons.Default.Bolt, @@ -315,8 +329,18 @@ fun ZapVote( } } + var wasZappedByLoggedInUser by remember { mutableStateOf(false) } + + LaunchedEffect(key1 = zappedNote) { + withContext(Dispatchers.IO) { + if (!wasZappedByLoggedInUser) { + wasZappedByLoggedInUser = zappedNote?.isZappedBy(accountViewModel.userProfile(), account) == true + } + } + } + // only show tallies after a user has zapped note - if (baseNote.author == accountViewModel.userProfile() || zappedNote?.isZappedBy(accountViewModel.userProfile()) == true) { + if (baseNote.author == accountViewModel.userProfile() || wasZappedByLoggedInUser) { Text( showAmount(pollViewModel.zappedPollOptionAmount(pollOption)), fontSize = 14.sp, @@ -378,7 +402,7 @@ fun FilteredZapAmountChoicePopup( context, onError, onProgress, - LnZapEvent.ZapType.PUBLIC + account.defaultZapType ) onDismiss() } @@ -404,7 +428,7 @@ fun FilteredZapAmountChoicePopup( context, onError, onProgress, - LnZapEvent.ZapType.PUBLIC + account.defaultZapType ) onDismiss() } @@ -496,8 +520,7 @@ fun ZapVoteAmountChoicePopup( "", context, onError, - onProgress, - LnZapEvent.ZapType.PUBLIC + onProgress ) onDismiss() } @@ -522,8 +545,7 @@ fun ZapVoteAmountChoicePopup( "", context, onError, - onProgress, - LnZapEvent.ZapType.PUBLIC + onProgress ) onDismiss() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt index b24d78fbd..3ad8ac90f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt @@ -33,6 +33,13 @@ class PollNoteViewModel { totalZapped = totalZapped() } + fun canZap(): Boolean { + val account = account ?: return false + val user = account.userProfile() ?: return false + val note = pollNote ?: return false + return user != note.author && !note.isZappedBy(user, account) + } + fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum fun isPollClosed(): Boolean = closedAt?.let { // allow 2 minute leeway for zap to propagate @@ -87,14 +94,12 @@ class PollNoteViewModel { } fun isPollOptionZappedBy(option: Int, user: User): Boolean { - if (pollNote?.zaps?.any { it.key.author === user } == true) { - pollNote!!.zaps - .any { - val event = it.value?.event as? LnZapEvent - event?.zappedPollOption() == option && event.zappedRequestAuthor() == user.pubkeyHex - } - } - return false + return pollNote!!.zaps + .any { + val zapEvent = it.value?.event as? LnZapEvent + val privateZapAuthor = account?.decryptZapContentAuthor(it.key) + zapEvent?.zappedPollOption() == option && (it.key.author?.pubkeyHex == user.pubkeyHex || privateZapAuthor?.pubKey == user.pubkeyHex) + } } fun zappedPollOptionAmount(option: Int): BigDecimal { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index f334df087..c019d3acc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -314,6 +314,16 @@ fun ZapReaction( var zappingProgress by remember { mutableStateOf(0f) } + var wasZappedByLoggedInUser by remember { mutableStateOf(false) } + + LaunchedEffect(key1 = zappedNote) { + withContext(Dispatchers.IO) { + if (!wasZappedByLoggedInUser) { + wasZappedByLoggedInUser = zappedNote?.isZappedBy(account.userProfile(), account) == true + } + } + } + Row( verticalAlignment = CenterVertically, modifier = Modifier @@ -412,7 +422,7 @@ fun ZapReaction( ZapCustomDialog({ wantsToSetCustomZap = false }, account = account, accountViewModel, baseNote) } - if (zappedNote?.isZappedBy(account.userProfile()) == true) { + if (wasZappedByLoggedInUser) { zappingProgress = 1f Icon( imageVector = Icons.Default.Bolt, 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 7c3f24079..8cce0da97 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 @@ -55,6 +55,10 @@ class AccountViewModel(private val account: Account) : ViewModel() { account.delete(account.boostsTo(note)) } + fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) { + zap(note, amount, pollOption, message, context, onError, onProgress, account.defaultZapType) + } + fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) { val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()