Nip13 pow (#357)

* add pow

* animation

* matrix style

* animation better

* improve animation

* improve animation

* works

* fix jump

* difficulty indicator

* 10 is default

* animation runs forever

* pow in message timestamp

* pow works

* default on

* adjust animation

* no printing
This commit is contained in:
callebtc
2025-08-31 13:23:22 +02:00
committed by GitHub
parent 5b62118336
commit b62b15a21f
13 changed files with 1182 additions and 108 deletions
@@ -169,7 +169,7 @@ class NostrClient private constructor(private val context: Context) {
// Derive geohash-specific identity
val geohashIdentity = NostrIdentityBridge.deriveIdentity(geohash, context)
// Create ephemeral event
// Create ephemeral event (with PoW if enabled)
val event = NostrProtocol.createEphemeralGeohashEvent(
content = content,
geohash = geohash,
@@ -281,6 +281,16 @@ class NostrClient private constructor(private val context: Context) {
handler: (content: String, senderPubkey: String, nickname: String?, timestamp: Int) -> Unit
) {
try {
// Check Proof of Work validation for incoming geohash events
val powSettings = PoWPreferenceManager.getCurrentSettings()
if (powSettings.enabled && powSettings.difficulty > 0) {
if (!NostrProofOfWork.validateDifficulty(event, powSettings.difficulty)) {
Log.w(TAG, "🚫 Rejecting geohash event ${event.id.take(8)}... due to insufficient PoW (required: ${powSettings.difficulty})")
return
}
Log.v(TAG, "✅ PoW validation passed for geohash event ${event.id.take(8)}...")
}
// Extract nickname from tags
val nickname = event.tags.find { it.size >= 2 && it[0] == "n" }?.get(1)
@@ -19,6 +19,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.*
import kotlin.random.Random
/**
* Service responsible for all Nostr and Geohash business logic extracted from ChatViewModel
@@ -242,6 +243,37 @@ class NostrGeohashService(
fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) {
coroutineScope.launch {
try {
// Generate a temporary message ID for tracking animation
val tempMessageId = "temp_${System.currentTimeMillis()}_${Random.nextInt(1000)}"
// Add local echo message IMMEDIATELY (with temporary ID)
val powSettingsLocal = PoWPreferenceManager.getCurrentSettings()
val localMessage = BitchatMessage(
id = tempMessageId,
sender = nickname ?: myPeerID,
content = content,
timestamp = Date(),
isRelay = false,
senderPeerID = "geohash:${channel.geohash}",
channel = "#${channel.geohash}",
powDifficulty = if (powSettingsLocal.enabled) powSettingsLocal.difficulty else null
)
// Store and display the message immediately
storeGeohashMessage(channel.geohash, localMessage)
messageManager.addMessage(localMessage)
Log.d(TAG, "📝 Added message immediately with temp ID: $tempMessageId")
// Check if PoW is enabled before starting animation
val powSettings = PoWPreferenceManager.getCurrentSettings()
if (powSettings.enabled && powSettings.difficulty > 0) {
// Start matrix animation for this message
com.bitchat.android.ui.PoWMiningTracker.startMiningMessage(tempMessageId)
Log.d(TAG, "🎭 Started matrix animation for message: $tempMessageId")
}
// Now begin the async PoW process
val identity = NostrIdentityBridge.deriveIdentity(
forGeohash = channel.geohash,
context = application
@@ -257,6 +289,12 @@ class NostrGeohashService(
teleported = teleported
)
// Stop animation when PoW completes
if (powSettings.enabled && powSettings.difficulty > 0) {
com.bitchat.android.ui.PoWMiningTracker.stopMiningMessage(tempMessageId)
Log.d(TAG, "🎭 Stopped matrix animation for message: $tempMessageId")
}
val nostrRelayManager = NostrRelayManager.getInstance(application)
nostrRelayManager.sendEventToGeohash(
event = event,
@@ -267,22 +305,10 @@ class NostrGeohashService(
Log.i(TAG, "📤 Sent geohash message to ${channel.geohash}: ${content.take(50)}")
// Add local echo message
val localMessage = BitchatMessage(
sender = nickname ?: myPeerID,
content = content,
timestamp = Date(),
isRelay = false,
senderPeerID = "geohash:${channel.geohash}",
channel = "#${channel.geohash}"
)
// Store our own message in geohash history
storeGeohashMessage(channel.geohash, localMessage)
messageManager.addMessage(localMessage)
} catch (e: Exception) {
Log.e(TAG, "Failed to send geohash message: ${e.message}")
// Make sure to stop animation even if there's an error
com.bitchat.android.ui.PoWMiningTracker.stopMiningMessage("temp_${System.currentTimeMillis()}")
}
}
}
@@ -1156,6 +1182,16 @@ class NostrGeohashService(
return@launch
}
// Check Proof of Work validation BEFORE other processing
val powSettings = PoWPreferenceManager.getCurrentSettings()
if (powSettings.enabled && powSettings.difficulty > 0) {
if (!NostrProofOfWork.validateDifficulty(event, powSettings.difficulty)) {
Log.w(TAG, "🚫 Rejecting geohash event ${event.id.take(8)}... due to insufficient PoW (required: ${powSettings.difficulty})")
return@launch
}
Log.v(TAG, "✅ PoW validation passed for event ${event.id.take(8)}...")
}
// Check if this user is blocked in geohash channels BEFORE any processing
if (isGeohashUserBlocked(event.pubkey)) {
Log.v(TAG, "🚫 Skipping event from blocked geohash user: ${event.pubkey.take(8)}...")
@@ -1238,6 +1274,9 @@ class NostrGeohashService(
// Note: mentions parsing needs peer nicknames parameter
// val mentions = messageManager.parseMentions(content, peerNicknames, nickname)
// Calculate actual PoW difficulty from the finalized event ID so we can show it for incoming messages too
val actualPow = try { NostrProofOfWork.calculateDifficulty(event.id) } catch (e: Exception) { 0 }
val message = BitchatMessage(
id = event.id,
sender = senderName,
@@ -1247,7 +1286,8 @@ class NostrGeohashService(
originalSender = senderHandle,
senderPeerID = "nostr:${event.pubkey.take(8)}",
mentions = null, // mentions need to be passed from outside
channel = "#$geohash"
channel = "#$geohash",
powDifficulty = actualPow.takeIf { it > 0 }
)
// Store in geohash history for persistence across channel switches
@@ -0,0 +1,216 @@
package com.bitchat.android.nostr
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.security.MessageDigest
import kotlin.random.Random
/**
* Nostr Proof of Work (PoW) implementation following NIP-13
*
* This implements the Proof of Work system for Nostr events to provide spam deterrence.
* The difficulty is defined as the number of leading zero bits in the event ID.
*
* Reference: https://github.com/nostr-protocol/nips/blob/master/13.md
*/
object NostrProofOfWork {
private const val TAG = "NostrProofOfWork"
/**
* Calculate the difficulty (number of leading zero bits) of an event ID
* @param eventIdHex The hexadecimal event ID
* @return The number of leading zero bits
*/
fun calculateDifficulty(eventIdHex: String): Int {
var count = 0
for (i in eventIdHex.indices) {
val nibble = eventIdHex[i].toString().toInt(16)
if (nibble == 0) {
count += 4
} else {
// Count leading zeros in the nibble
count += when (nibble) {
1 -> 3 // 0001
2, 3 -> 2 // 001x
4, 5, 6, 7 -> 1 // 01xx
else -> 0 // 1xxx
}
break
}
}
return count
}
/**
* Validate that an event meets the minimum difficulty requirement
* @param event The Nostr event to validate
* @param minimumDifficulty The minimum required difficulty
* @return true if the event meets the difficulty requirement
*/
fun validateDifficulty(event: NostrEvent, minimumDifficulty: Int): Boolean {
if (minimumDifficulty <= 0) return true
val actualDifficulty = calculateDifficulty(event.id)
val committedDifficulty = getCommittedDifficulty(event)
Log.d(TAG, "Validating PoW: actual=$actualDifficulty, required=$minimumDifficulty, committed=$committedDifficulty")
// Check if actual difficulty meets requirement
if (actualDifficulty < minimumDifficulty) {
Log.w(TAG, "Event ${event.id.take(16)}... has insufficient difficulty: $actualDifficulty < $minimumDifficulty")
return false
}
// If there's a committed difficulty, it should match or exceed the minimum
if (committedDifficulty != null && committedDifficulty < minimumDifficulty) {
Log.w(TAG, "Event ${event.id.take(16)}... has committed difficulty $committedDifficulty but achieved $actualDifficulty (possible spam)")
return false
}
return true
}
/**
* Mine a Nostr event to achieve the target difficulty
* @param event The event to mine (will be modified with nonce tag)
* @param targetDifficulty The target difficulty to achieve
* @param maxIterations Maximum number of iterations before giving up (default: 1,000,000)
* @return The mined event with nonce tag, or null if mining failed
*/
suspend fun mineEvent(
event: NostrEvent,
targetDifficulty: Int,
maxIterations: Int = 1_000_000
): NostrEvent? = withContext(Dispatchers.Default) {
if (targetDifficulty <= 0) return@withContext event
Log.d(TAG, "Starting PoW mining for difficulty $targetDifficulty...")
val startTime = System.currentTimeMillis()
var nonce = Random.nextLong(0, 1_000_000).toString()
var iterations = 0
while (iterations < maxIterations) {
// Create a copy of the event with the nonce tag
val eventWithNonce = addNonceTag(event, nonce, targetDifficulty)
// Calculate the event ID
val eventId = eventWithNonce.computeEventIdHex()
val actualDifficulty = calculateDifficulty(eventId)
if (actualDifficulty >= targetDifficulty) {
val timeElapsed = System.currentTimeMillis() - startTime
Log.i(TAG, "✅ PoW mining successful! Difficulty: $actualDifficulty, iterations: $iterations, time: ${timeElapsed}ms")
// Return the event with the computed ID
return@withContext eventWithNonce.copy(id = eventId)
}
// Increment nonce and try again
nonce = (nonce.toLongOrNull()?.plus(1) ?: Random.nextLong()).toString()
iterations++
// Log progress every 100,000 iterations
if (iterations % 100_000 == 0) {
val timeElapsed = System.currentTimeMillis() - startTime
Log.d(TAG, "PoW mining progress: $iterations iterations, ${timeElapsed}ms elapsed")
}
}
val timeElapsed = System.currentTimeMillis() - startTime
Log.w(TAG, "❌ PoW mining failed after $maxIterations iterations (${timeElapsed}ms)")
return@withContext null
}
/**
* Add or update the nonce tag in an event
* @param event The original event
* @param nonce The nonce value
* @param targetDifficulty The target difficulty being attempted
* @return A new event with the nonce tag added/updated
*/
private fun addNonceTag(event: NostrEvent, nonce: String, targetDifficulty: Int): NostrEvent {
val newTags = event.tags.toMutableList()
// Remove existing nonce tag if present
newTags.removeAll { tag -> tag.isNotEmpty() && tag[0] == "nonce" }
// Add new nonce tag with format: ["nonce", nonce_value, target_difficulty]
newTags.add(listOf("nonce", nonce, targetDifficulty.toString()))
// Update created_at as recommended by NIP-13
val updatedCreatedAt = (System.currentTimeMillis() / 1000).toInt()
return event.copy(
tags = newTags,
createdAt = updatedCreatedAt
)
}
/**
* Get the committed difficulty from an event's nonce tag
* @param event The event to check
* @return The committed difficulty, or null if not present
*/
private fun getCommittedDifficulty(event: NostrEvent): Int? {
val nonceTag = event.tags.find { tag ->
tag.isNotEmpty() && tag[0] == "nonce" && tag.size >= 3
}
return nonceTag?.get(2)?.toIntOrNull()
}
/**
* Check if an event has a nonce tag (indicating it was mined)
* @param event The event to check
* @return true if the event has a nonce tag
*/
fun hasNonce(event: NostrEvent): Boolean {
return event.tags.any { tag -> tag.isNotEmpty() && tag[0] == "nonce" }
}
/**
* Get the nonce value from an event
* @param event The event to check
* @return The nonce value, or null if not present
*/
fun getNonce(event: NostrEvent): String? {
val nonceTag = event.tags.find { tag ->
tag.isNotEmpty() && tag[0] == "nonce" && tag.size >= 2
}
return nonceTag?.get(1)
}
/**
* Estimate the computational work required for a given difficulty
* @param difficulty The target difficulty
* @return Estimated number of hash operations required
*/
fun estimateWork(difficulty: Int): Long {
return if (difficulty <= 0) 1L else 1L shl difficulty
}
/**
* Get a human-readable description of the estimated mining time
* @param difficulty The target difficulty
* @param hashesPerSecond Estimated hashes per second (default: 100,000)
* @return Human-readable time estimate
*/
fun estimateMiningTime(difficulty: Int, hashesPerSecond: Int = 100_000): String {
val estimatedHashes = estimateWork(difficulty)
val estimatedSeconds = estimatedHashes / hashesPerSecond
return when {
estimatedSeconds < 1 -> "< 1 second"
estimatedSeconds < 60 -> "${estimatedSeconds}s"
estimatedSeconds < 3600 -> "${estimatedSeconds / 60}m ${estimatedSeconds % 60}s"
estimatedSeconds < 86400 -> "${estimatedSeconds / 3600}h ${(estimatedSeconds % 3600) / 60}m"
else -> "${estimatedSeconds / 86400}d ${(estimatedSeconds % 86400) / 3600}h"
}
}
}
@@ -3,6 +3,8 @@ package com.bitchat.android.nostr
import android.util.Log
import com.google.gson.Gson
import com.google.gson.JsonParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* NIP-17 Protocol Implementation for Private Direct Messages
@@ -90,14 +92,15 @@ object NostrProtocol {
/**
* Create a geohash-scoped ephemeral public message (kind 20000)
* Includes Proof of Work mining if enabled in settings
*/
fun createEphemeralGeohashEvent(
suspend fun createEphemeralGeohashEvent(
content: String,
geohash: String,
senderIdentity: NostrIdentity,
nickname: String? = null,
teleported: Boolean = false
): NostrEvent {
): NostrEvent = withContext(Dispatchers.Default) {
val tags = mutableListOf<List<String>>()
tags.add(listOf("g", geohash))
@@ -110,7 +113,7 @@ object NostrProtocol {
tags.add(listOf("t", "teleport"))
}
val event = NostrEvent(
var event = NostrEvent(
pubkey = senderIdentity.publicKeyHex,
createdAt = (System.currentTimeMillis() / 1000).toInt(),
kind = NostrKind.EPHEMERAL_EVENT,
@@ -118,7 +121,36 @@ object NostrProtocol {
content = content
)
return senderIdentity.signEvent(event)
// Check if Proof of Work is enabled
val powSettings = PoWPreferenceManager.getCurrentSettings()
if (powSettings.enabled && powSettings.difficulty > 0) {
Log.d(TAG, "PoW enabled for geohash event: difficulty=${powSettings.difficulty}")
try {
// Start mining state for animated indicators
PoWPreferenceManager.startMining()
// Mine the event before signing
val minedEvent = NostrProofOfWork.mineEvent(
event = event,
targetDifficulty = powSettings.difficulty,
maxIterations = 2_000_000 // Allow up to 2M iterations for reasonable mining time
)
if (minedEvent != null) {
event = minedEvent
val actualDifficulty = NostrProofOfWork.calculateDifficulty(event.id)
Log.d(TAG, "✅ PoW mining successful: target=${powSettings.difficulty}, actual=$actualDifficulty, nonce=${NostrProofOfWork.getNonce(event)}")
} else {
Log.w(TAG, "❌ PoW mining failed, proceeding without PoW")
}
} finally {
// Always stop mining state when done (success or failure)
PoWPreferenceManager.stopMining()
}
}
return@withContext senderIdentity.signEvent(event)
}
// MARK: - Private Methods
@@ -0,0 +1,149 @@
package com.bitchat.android.nostr
import android.content.Context
import android.content.SharedPreferences
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Manages Proof of Work preferences for Nostr events
*/
object PoWPreferenceManager {
private const val PREFS_NAME = "pow_preferences"
private const val KEY_POW_ENABLED = "pow_enabled"
private const val KEY_POW_DIFFICULTY = "pow_difficulty"
// Default values
private const val DEFAULT_POW_ENABLED = true
private const val DEFAULT_POW_DIFFICULTY = 10 // Reasonable default for geohash spam prevention
// State flows for reactive UI
private val _powEnabled = MutableStateFlow(DEFAULT_POW_ENABLED)
val powEnabled: StateFlow<Boolean> = _powEnabled.asStateFlow()
private val _powDifficulty = MutableStateFlow(DEFAULT_POW_DIFFICULTY)
val powDifficulty: StateFlow<Int> = _powDifficulty.asStateFlow()
// Mining state for animated indicators
private val _isMining = MutableStateFlow(false)
val isMining: StateFlow<Boolean> = _isMining.asStateFlow()
private lateinit var sharedPrefs: SharedPreferences
private var isInitialized = false
/**
* Initialize the preference manager with application context
* Should be called once during app startup
*/
fun init(context: Context) {
if (isInitialized) return
sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
// Load current values
_powEnabled.value = sharedPrefs.getBoolean(KEY_POW_ENABLED, DEFAULT_POW_ENABLED)
_powDifficulty.value = sharedPrefs.getInt(KEY_POW_DIFFICULTY, DEFAULT_POW_DIFFICULTY)
isInitialized = true
}
/**
* Get current PoW enabled state
*/
fun isPowEnabled(): Boolean {
return _powEnabled.value
}
/**
* Set PoW enabled state
*/
fun setPowEnabled(enabled: Boolean) {
_powEnabled.value = enabled
if (::sharedPrefs.isInitialized) {
sharedPrefs.edit().putBoolean(KEY_POW_ENABLED, enabled).apply()
}
}
/**
* Get current PoW difficulty setting
*/
fun getPowDifficulty(): Int {
return _powDifficulty.value
}
/**
* Set PoW difficulty (clamped between 0 and 32)
*/
fun setPowDifficulty(difficulty: Int) {
val clampedDifficulty = difficulty.coerceIn(0, 32)
_powDifficulty.value = clampedDifficulty
if (::sharedPrefs.isInitialized) {
sharedPrefs.edit().putInt(KEY_POW_DIFFICULTY, clampedDifficulty).apply()
}
}
/**
* Get current settings as a data class
*/
data class PoWSettings(
val enabled: Boolean,
val difficulty: Int
)
/**
* Get current settings
*/
fun getCurrentSettings(): PoWSettings {
return PoWSettings(
enabled = _powEnabled.value,
difficulty = _powDifficulty.value
)
}
/**
* Reset to default settings
*/
fun resetToDefaults() {
setPowEnabled(DEFAULT_POW_ENABLED)
setPowDifficulty(DEFAULT_POW_DIFFICULTY)
}
/**
* Get difficulty levels with descriptions for UI
*/
fun getDifficultyLevels(): List<Pair<Int, String>> {
return listOf(
0 to "Disabled (no PoW)",
8 to "Very Low (instant)",
12 to "Low (~0.1s)",
16 to "Medium (~2s)",
20 to "High (~30s)",
24 to "Very High (~8m)",
28 to "Extreme (~2h)",
32 to "Maximum (~8h)"
)
}
/**
* Get current mining state
*/
fun isMining(): Boolean {
return _isMining.value
}
/**
* Start mining state - triggers animated indicators
*/
fun startMining() {
_isMining.value = true
}
/**
* Stop mining state - stops animated indicators
*/
fun stopMining() {
_isMining.value = false
}
}