Making poll votes work with Private Zaps.
This commit is contained in:
@@ -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())
|
||||
} 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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user