Making poll votes work with Private Zaps.

This commit is contained in:
Vitor Pamplona
2023-04-25 16:35:42 -04:00
parent 4ef90e26cf
commit 91f3f036ae
8 changed files with 253 additions and 33 deletions
@@ -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")
}
}
}
@@ -692,7 +692,7 @@ class Account(
event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray()) event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray())
} else if (event is LnZapRequestEvent && loggedIn.privKey != null) { } else if (event is LnZapRequestEvent && loggedIn.privKey != null) {
LnZapRequestEvent.checkForPrivateZap(event, loggedIn.privKey!!)?.content() decryptZapContentAuthor(note)?.content()
} else { } else {
event?.content() event?.content()
} }
@@ -700,8 +700,43 @@ class Account(
fun decryptZapContentAuthor(note: Note): Event? { fun decryptZapContentAuthor(note: Note): Event? {
val event = note.event val event = note.event
return if (event is LnZapRequestEvent && loggedIn.privKey != null) { val loggedInPrivateKey = loggedIn.privKey
LnZapRequestEvent.checkForPrivateZap(event, 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 { } else {
null null
} }
@@ -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 // 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 { fun isReactedBy(user: User): Boolean {
@@ -24,6 +24,8 @@ class LnZapRequestEvent(
fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } 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 { companion object {
const val kind = 9734 const val kind = 9734
@@ -135,26 +137,27 @@ class LnZapRequestEvent(
if (parts.size != 2) { if (parts.size != 2) {
throw IllegalArgumentException("Invalid message format") throw IllegalArgumentException("Invalid message format")
} }
val iv = parts[1].run { Bech32.decode(this) } val iv = parts[1].run { Bech32.decode(this).second }
val encryptedMsg = parts.first().run { Bech32.decode(this) } val encryptedMsg = parts.first().run { Bech32.decode(this).second }
val encryptedBytes = Bech32.five2eight(encryptedMsg, 0)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") 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 { try {
val decryptedMsgBytes = cipher.doFinal(Bech32.five2eight(encryptedMsg.second, 0)) val decryptedMsgBytes = cipher.doFinal(encryptedBytes)
return String(decryptedMsgBytes) return String(decryptedMsgBytes)
} catch (ex: BadPaddingException) { } catch (ex: BadPaddingException) {
throw IllegalArgumentException("Bad padding") throw IllegalArgumentException("Bad padding: ${ex.message}")
} }
} }
fun checkForPrivateZap(zaprequest: Event, loggedInUserPrivKey: ByteArray): Event? { fun checkForPrivateZap(zapRequest: LnZapRequestEvent, loggedInUserPrivKey: ByteArray, pubKey: HexKey): Event? {
val anonTag = zaprequest.tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" } val anonTag = zapRequest.tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" }
if (anonTag != null) { if (anonTag != null) {
val encnote = anonTag[1] val encnote = anonTag[1]
if (encnote != null && encnote != "") { if (encnote.isNotBlank()) {
try { try {
val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, zaprequest.pubKey.toByteArray()) val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.toByteArray())
val decryptedEvent = fromJson(note) val decryptedEvent = fromJson(note)
if (decryptedEvent.kind == 9733) { if (decryptedEvent.kind == 9733) {
return decryptedEvent return decryptedEvent
@@ -31,12 +31,12 @@ import androidx.compose.ui.window.Popup
import androidx.navigation.NavController import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note 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.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.* import java.util.*
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -51,7 +51,11 @@ fun PollNote(
val zapsState by baseNote.live().zaps.observeAsState() val zapsState by baseNote.live().zaps.observeAsState()
val zappedNote = zapsState?.note ?: return val zappedNote = zapsState?.note ?: return
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val pollViewModel = PollNoteViewModel() val pollViewModel = PollNoteViewModel()
pollViewModel.account = account
pollViewModel.load(zappedNote) pollViewModel.load(zappedNote)
pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op -> pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op ->
@@ -69,7 +73,7 @@ fun PollNote(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 3.dp) modifier = Modifier.padding(vertical = 3.dp)
) { ) {
if (accountViewModel.isLoggedUser(zappedNote.author) || zappedNote.isZappedBy(accountViewModel.userProfile())) { if (pollViewModel.canZap()) {
ZapVote( ZapVote(
baseNote, baseNote,
accountViewModel, accountViewModel,
@@ -288,7 +292,17 @@ fun ZapVote(
clickablePrepend() 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 zappingProgress = 1f
Icon( Icon(
imageVector = Icons.Default.Bolt, 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 // 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( Text(
showAmount(pollViewModel.zappedPollOptionAmount(pollOption)), showAmount(pollViewModel.zappedPollOptionAmount(pollOption)),
fontSize = 14.sp, fontSize = 14.sp,
@@ -378,7 +402,7 @@ fun FilteredZapAmountChoicePopup(
context, context,
onError, onError,
onProgress, onProgress,
LnZapEvent.ZapType.PUBLIC account.defaultZapType
) )
onDismiss() onDismiss()
} }
@@ -404,7 +428,7 @@ fun FilteredZapAmountChoicePopup(
context, context,
onError, onError,
onProgress, onProgress,
LnZapEvent.ZapType.PUBLIC account.defaultZapType
) )
onDismiss() onDismiss()
} }
@@ -496,8 +520,7 @@ fun ZapVoteAmountChoicePopup(
"", "",
context, context,
onError, onError,
onProgress, onProgress
LnZapEvent.ZapType.PUBLIC
) )
onDismiss() onDismiss()
} }
@@ -522,8 +545,7 @@ fun ZapVoteAmountChoicePopup(
"", "",
context, context,
onError, onError,
onProgress, onProgress
LnZapEvent.ZapType.PUBLIC
) )
onDismiss() onDismiss()
} }
@@ -33,6 +33,13 @@ class PollNoteViewModel {
totalZapped = totalZapped() 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 isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum
fun isPollClosed(): Boolean = closedAt?.let { // allow 2 minute leeway for zap to propagate 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 { fun isPollOptionZappedBy(option: Int, user: User): Boolean {
if (pollNote?.zaps?.any { it.key.author === user } == true) { return pollNote!!.zaps
pollNote!!.zaps .any {
.any { val zapEvent = it.value?.event as? LnZapEvent
val event = it.value?.event as? LnZapEvent val privateZapAuthor = account?.decryptZapContentAuthor(it.key)
event?.zappedPollOption() == option && event.zappedRequestAuthor() == user.pubkeyHex zapEvent?.zappedPollOption() == option && (it.key.author?.pubkeyHex == user.pubkeyHex || privateZapAuthor?.pubKey == user.pubkeyHex)
} }
}
return false
} }
fun zappedPollOptionAmount(option: Int): BigDecimal { fun zappedPollOptionAmount(option: Int): BigDecimal {
@@ -314,6 +314,16 @@ fun ZapReaction(
var zappingProgress by remember { mutableStateOf(0f) } 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( Row(
verticalAlignment = CenterVertically, verticalAlignment = CenterVertically,
modifier = Modifier modifier = Modifier
@@ -412,7 +422,7 @@ fun ZapReaction(
ZapCustomDialog({ wantsToSetCustomZap = false }, account = account, accountViewModel, baseNote) ZapCustomDialog({ wantsToSetCustomZap = false }, account = account, accountViewModel, baseNote)
} }
if (zappedNote?.isZappedBy(account.userProfile()) == true) { if (wasZappedByLoggedInUser) {
zappingProgress = 1f zappingProgress = 1f
Icon( Icon(
imageVector = Icons.Default.Bolt, imageVector = Icons.Default.Bolt,
@@ -55,6 +55,10 @@ class AccountViewModel(private val account: Account) : ViewModel() {
account.delete(account.boostsTo(note)) 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) { 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() val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()