Replace the Quartz cache with a verified local catalog store.

This commit is contained in:
franzap
2026-09-27 20:07:26 -03:00
parent 9b1651a096
commit 29041ef777
49 changed files with 6475 additions and 3431 deletions
+9 -3
View File
@@ -1,7 +1,6 @@
[versions]
agp = "9.2.1"
kotlin = "2.4.0"
quartz = "1.13.1"
coroutines = "1.11.0"
coil = "3.5.0"
okhttp = "5.4.0"
@@ -12,9 +11,15 @@ lifecycle = "2.11.0"
navigation = "2.9.8"
androidx-test = "1.7.0"
junit = "4.13.2"
secp256k1 = "0.23.0"
zstd = "1.5.7-4"
onnxruntime = "1.23.2"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
secp256k1 = { module = "fr.acinq.secp256k1:secp256k1-kmp", version.ref = "secp256k1" }
secp256k1-jni-android = { module = "fr.acinq.secp256k1:secp256k1-kmp-jni-android", version.ref = "secp256k1" }
secp256k1-jni-jvm = { module = "fr.acinq.secp256k1:secp256k1-kmp-jni-jvm", version.ref = "secp256k1" }
zstd-jni = { module = "com.github.luben:zstd-jni", version.ref = "zstd" }
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" }
@@ -26,7 +31,6 @@ sqlite = { module = "androidx.sqlite:sqlite", version.ref = "sqlite" }
sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
compose-foundation = { module = "androidx.compose.foundation:foundation" }
compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
compose-material3 = { module = "androidx.compose.material3:material3" }
compose-ui = { module = "androidx.compose.ui:ui" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
@@ -40,6 +44,8 @@ lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel
navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
navigation-testing = { module = "androidx.navigation:navigation-testing", version.ref = "navigation" }
junit = { module = "junit:junit", version.ref = "junit" }
onnxruntime-android = { module = "com.microsoft.onnxruntime:onnxruntime-android", version.ref = "onnxruntime" }
onnxruntime = { module = "com.microsoft.onnxruntime:onnxruntime", version.ref = "onnxruntime" }
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test" }
androidx-test-junit = { module = "androidx.test.ext:junit", version = "1.3.0" }
+11 -2
View File
@@ -22,13 +22,22 @@ kotlin {
}
dependencies {
api(libs.quartz)
api(libs.secp256k1)
implementation(libs.secp256k1.jni.android)
implementation(libs.coroutines.android)
implementation(libs.sqlite)
implementation(libs.sqlite.bundled)
implementation("com.github.luben:zstd-jni:${libs.versions.zstd.get()}@aar")
testImplementation("org.json:json:20250517")
testImplementation(libs.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.secp256k1.jni.jvm)
testImplementation(libs.sqlite.bundled)
testImplementation(libs.zstd.jni)
testImplementation("org.xerial:sqlite-jdbc:3.50.3.0")
androidTestImplementation(libs.androidx.test.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.coroutines.test)
androidTestImplementation(libs.okhttp)
}
+7 -1
View File
@@ -1 +1,7 @@
# Iolite requires no consumer-specific shrinking rules.
# JNI libraries Iolite loads by class / native name.
-keepclasseswithmembernames,includedescriptorclasses class * {
native <methods>;
}
-keep class fr.acinq.secp256k1.** { *; }
# Native GetFieldID("srcPos"|"dstPos", "J") on the stream classes.
-keep class com.github.luben.zstd.** { *; }
@@ -1,199 +0,0 @@
package dev.zapstore.iolite
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SdkSuppress
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.crypto.verifyId
import com.vitorpamplona.quartz.nip01Core.crypto.verifySignature
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.store.ObservableEventStore
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import java.io.File
import java.util.UUID
import kotlin.time.Duration.Companion.hours
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@SdkSuppress(minSdkVersion = 29)
class QuartzCompatibilityInstrumentedTest {
@Test
fun queryRefreshMetadataPersistsAndRotatesWithDatabaseIdentity() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val path = context.getDatabasePath("refresh-cache-${UUID.randomUUID()}.db").absolutePath
val fingerprint = "profile-query"
val refreshedAt = System.currentTimeMillis()
val database = File(path)
val replacement = File("$path.replacement")
database.parentFile?.mkdirs()
database.writeText("first database")
try {
val first = SharedPreferencesQueryRefreshCache.create(context, path, databaseExisted = false)
assertTrue(first.recordRefresh(fingerprint, refreshedAt))
val reopened = SharedPreferencesQueryRefreshCache.create(context, path, databaseExisted = true)
assertEquals(refreshedAt, reopened.lastRefresh(fingerprint))
replacement.writeText("replacement database")
assertTrue(database.delete())
assertTrue(replacement.renameTo(database))
val recreated = SharedPreferencesQueryRefreshCache.create(context, path, databaseExisted = true)
assertEquals(null, recreated.lastRefresh(fingerprint))
} finally {
database.delete()
replacement.delete()
}
}
@Test
fun publishedStoreEmitsAfterCommitAndSurvivesReopen() = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val databaseName = "quartz-compat-${UUID.randomUUID()}.db"
val path = context.getDatabasePath(databaseName).absolutePath
val event = knownValidEvent()
context.deleteDatabase(databaseName)
val observable = ObservableEventStore(EventStore(dbName = path, relay = null))
try {
val change = async(start = CoroutineStart.UNDISPATCHED) {
observable.changes.first()
}
observable.insert(event)
assertTrue(change.await() is ObservableEventStore.StoreChange.Insert)
assertEquals(listOf(event.id), observable.query<Event>(Filter(ids = listOf(event.id))).map(Event::id))
runCatching { observable.insert(event) }
assertEquals(1, observable.count(Filter(ids = listOf(event.id))))
} finally {
observable.close()
}
val reopened = EventStore(dbName = path, relay = null)
try {
assertEquals(listOf(event.id), reopened.query<Event>(Filter(ids = listOf(event.id))).map(Event::id))
assertEquals(listOf(event.id), reopened.query<Event>(Filter(search = "test")).map(Event::id))
} finally {
reopened.close()
context.deleteDatabase(databaseName)
}
}
@Test
fun facadeRecreationReadsPersistedRowsWithoutNetwork() = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val databaseName = "iolite-offline-${UUID.randomUUID()}.db"
val path = context.getDatabasePath(databaseName).absolutePath
val event = knownValidEvent()
context.deleteDatabase(databaseName)
EventStore(dbName = path, relay = null).also { store ->
try {
store.insert(event)
} finally {
store.close()
}
}
val okHttpClient = OkHttpClient()
try {
repeat(2) {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val purpleQuartz = Iolite.create(
context = context,
websocketBuilder = BasicOkHttpWebSocket.Builder { okHttpClient },
parentScope = scope,
config = IoliteConfig(databaseName = databaseName),
)
try {
val state = purpleQuartz.query(Filter(ids = listOf(event.id))).first()
assertEquals(QueryPhase.LocalOnly, state.phase)
assertEquals(listOf(event.id), state.items.map(Event::id))
} finally {
purpleQuartz.close()
scope.cancel()
}
}
} finally {
okHttpClient.dispatcher.executorService.shutdown()
okHttpClient.connectionPool.evictAll()
context.deleteDatabase(databaseName)
}
}
@Test
fun api29ConsumerConstructsQueriesAllSourcesAndCloses() = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val databaseName = "iolite-consumer-${UUID.randomUUID()}.db"
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val okHttpClient = OkHttpClient()
val relay = "ws://127.0.0.1:1".normalizeRelayUrl()
val purpleQuartz = Iolite.create(
context = context,
websocketBuilder = BasicOkHttpWebSocket.Builder { okHttpClient },
parentScope = scope,
config = IoliteConfig(databaseName = databaseName),
)
try {
assertEquals(QueryPhase.LocalOnly, purpleQuartz.query(Filter(kinds = listOf(1))).first().phase)
assertEquals(
QueryPhase.Connecting,
purpleQuartz.query(
Filter(kinds = listOf(1)),
QueryOptions.localAndRemote(
relays = setOf(relay),
remoteMode = RemoteMode.Stream,
cachedFor = 6.hours,
),
).take(1).toList().single().phase,
)
assertEquals(
QueryPhase.Connecting,
purpleQuartz.query(
Filter(kinds = listOf(1)),
QueryOptions.remote(setOf(relay), RemoteMode.Stream),
).take(1).toList().single().phase,
)
} finally {
purpleQuartz.close()
scope.cancel()
okHttpClient.dispatcher.executorService.shutdown()
okHttpClient.connectionPool.evictAll()
context.deleteDatabase(databaseName)
}
}
private fun knownValidEvent(): Event {
val event = NostrSignerSync(KeyPair()).sign<Event>(
EventTemplate(
createdAt = 1_753_988_264,
kind = 1,
tags = emptyArray(),
content = "test",
),
)
assertTrue(event.verifyId())
assertTrue(event.verifySignature())
return event
}
}
@@ -0,0 +1,89 @@
package dev.zapstore.iolite
private const val BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
private val BECH32_GENERATOR = longArrayOf(0x3b6a57b2L, 0x26508e6dL, 0x1ea119faL, 0x3d4233ddL, 0x2a1462b3L)
/** NIP-19 `npub` for a 64-char hex pubkey; other strings are returned unchanged. */
fun String.toNpub(): String {
if (!Hex.isHex(lowercase(), 64)) return this
val data = convertBits(Hex.decode(this).map { it.toInt() and 0xff }, 8, 5)
val checksum = bech32Checksum("npub", data)
return buildString {
append("npub1")
(data + checksum).forEach { append(BECH32_CHARSET[it]) }
}
}
private fun convertBits(data: List<Int>, fromBits: Int, toBits: Int): List<Int> {
var accumulator = 0
var bits = 0
val result = mutableListOf<Int>()
val maxValue = (1 shl toBits) - 1
data.forEach { value ->
accumulator = (accumulator shl fromBits) or value
bits += fromBits
while (bits >= toBits) {
bits -= toBits
result += (accumulator shr bits) and maxValue
}
}
if (bits > 0) result += (accumulator shl (toBits - bits)) and maxValue
return result
}
/** Hex pubkey for an `npub`, or null when the string is not a valid NIP-19 public key. */
fun String.decodeNpub(): String? {
val value = lowercase()
if (!value.startsWith("npub1")) return null
val dataChars = value.substring(5)
if (dataChars.length < 6) return null
val data = dataChars.map { char ->
val index = BECH32_CHARSET.indexOf(char)
if (index < 0) return null
index
}
if (polymod(hrpExpand("npub") + data) != 1L) return null
val bytes = convertBitsDown(data.dropLast(6)) ?: return null
if (bytes.size != 32) return null
return Hex.encode(bytes)
}
private fun hrpExpand(hrp: String): List<Int> =
hrp.map { it.code shr 5 } + listOf(0) + hrp.map { it.code and 31 }
private fun polymod(values: List<Int>): Long {
var checksum = 1L
values.forEach { value ->
val top = checksum shr 25
checksum = ((checksum and 0x1ffffffL) shl 5) xor value.toLong()
BECH32_GENERATOR.forEachIndexed { index, generator ->
if (((top shr index) and 1L) != 0L) checksum = checksum xor generator
}
}
return checksum
}
private fun bech32Checksum(hrp: String, data: List<Int>): List<Int> {
val polymod = polymod(hrpExpand(hrp) + data + List(6) { 0 }) xor 1L
return (0 until 6).map { shift -> ((polymod shr (5 * (5 - shift))) and 31).toInt() }
}
/** 5-bit bech32 payload to bytes. Leftover padding must be zero. */
private fun convertBitsDown(data: List<Int>): ByteArray? {
var accumulator = 0
var bits = 0
val out = ArrayList<Byte>(data.size * 5 / 8)
val maxValue = (1 shl 8) - 1
val maxAccumulator = (1 shl (5 + 8 - 1)) - 1
for (value in data) {
if (value < 0 || value shr 5 != 0) return null
accumulator = ((accumulator shl 5) or value) and maxAccumulator
bits += 5
while (bits >= 8) {
bits -= 8
out += ((accumulator shr bits) and maxValue).toByte()
}
}
if (bits >= 5 || ((accumulator shl (8 - bits)) and maxValue) != 0) return null
return out.toByteArray()
}
@@ -0,0 +1,17 @@
package dev.zapstore.iolite
object Bolt11 {
fun amountSats(invoice: String): Long {
val lower = invoice.lowercase()
val match = Regex("^lnbc(\\d+)([munp]?)").find(lower) ?: return 0
val amount = match.groupValues[1].toLongOrNull() ?: return 0
return when (match.groupValues[2]) {
"m" -> amount * 100_000
"u" -> amount * 100
"n" -> amount / 10
"p" -> amount / 10_000
"" -> amount * 100_000_000
else -> 0
}
}
}
@@ -0,0 +1,115 @@
package dev.zapstore.iolite
/**
* Artifact members of a catalog bundle. App files are `<app-id>/about`, `security`, `facts`,
* `vector`, and `icon.webp`. Avatars are `<64 hex characters>.webp`.
* A missing file leaves the previous local value in place.
*/
internal data class CatalogArtifact(
val apps: List<AppArtifact> = emptyList(),
val avatars: List<AvatarArtifact> = emptyList(),
) {
companion object {
val EMPTY = CatalogArtifact()
}
}
internal data class AppArtifact(
val appId: String,
val webp: ByteArray?,
val embedding: ByteArray?,
val facts: String?,
val about: String?,
val security: String?,
)
internal data class AvatarArtifact(
val pubkey: String,
val webp: ByteArray,
)
internal object CatalogArtifacts {
private const val MAX_APP_ID = 255
fun fromMembers(members: Map<String, TarMember>, maxIconBytes: Long): CatalogArtifact {
val apps = LinkedHashMap<String, Partial>()
val avatars = ArrayList<AvatarArtifact>()
for ((name, member) in members) {
if (name == "manifest.json" || name == "diff.jsonl" || name == "index") continue
val pubkey = avatarPubkey(name)
if (pubkey != null) {
if (member.data.isEmpty() || member.data.size.toLong() > maxIconBytes) {
fail("bad avatar $name")
}
avatars += AvatarArtifact(pubkey, member.data)
continue
}
val slash = name.indexOf('/')
if (slash <= 0 || slash != name.lastIndexOf('/')) fail("unexpected catalog member $name")
val appId = name.substring(0, slash)
val file = name.substring(slash + 1)
if (!validAppId(appId) || file !in APP_FILES) fail("unexpected catalog member $name")
val app = apps.getOrPut(appId) { Partial(appId) }
when (file) {
"about", "security", "facts" -> applyText(app, file, member.data)
"vector" -> {
if (member.data.size != EMBEDDING_DIMS) fail("vector $appId")
app.embedding = member.data
}
"icon.webp" -> {
if (member.data.isEmpty() || member.data.size.toLong() > maxIconBytes) fail("icon $appId exceeds size limit")
app.webp = member.data
}
}
}
return CatalogArtifact(
apps = apps.values.map { it.toArtifact() },
avatars = avatars,
)
}
fun allowedMember(name: String): Boolean {
if (name == "diff.jsonl" || name == "index") return true
if (avatarPubkey(name) != null) return true
val slash = name.indexOf('/')
if (slash <= 0 || slash != name.lastIndexOf('/')) return false
val appId = name.substring(0, slash)
val file = name.substring(slash + 1)
return validAppId(appId) && file in APP_FILES
}
private fun applyText(app: Partial, file: String, raw: ByteArray) {
val text = raw.toString(Charsets.UTF_8)
if (text.any { it == '\u0000' }) fail("$file ${app.appId} contains NUL")
when (file) {
"about" -> app.about = text
"security" -> app.security = text
"facts" -> app.facts = text
}
}
private fun avatarPubkey(name: String): String? {
if (name.contains('/')) return null
val pubkey = name.removeSuffix(".webp")
if (pubkey.length == name.length) return null
if (!Hex.isHex(pubkey, 64)) return null
return pubkey
}
private fun validAppId(id: String): Boolean =
id.length in 1..MAX_APP_ID && id.none { it == '\u0000' || it == '/' || it == '\\' } && ".." !in id
private fun fail(reason: String): Nothing = throw CatalogImportException(reason)
private class Partial(val appId: String) {
var webp: ByteArray? = null
var embedding: ByteArray? = null
var facts: String? = null
var about: String? = null
var security: String? = null
fun toArtifact() = AppArtifact(appId, webp, embedding, facts, about, security)
}
private val APP_FILES = setOf("about", "security", "facts", "vector", "icon.webp")
}
@@ -0,0 +1,215 @@
package dev.zapstore.iolite
import java.io.ByteArrayInputStream
import java.io.File
import org.json.JSONObject
data class CatalogImportResult(val from: Long, val to: Long)
/** Outcome of `GET /deltas`. [imported] is null when the relay answered 304. */
data class CatalogSyncResult(
val imported: CatalogImportResult?,
val bytes: Long,
) {
val notModified: Boolean get() = imported == null
}
/**
* Verifies a catalog-sync bundle (signed manifest, member hashes, event signatures) and hands the
* verified events and artifact files to [IoliteStore.importCatalog].
*/
class CatalogImporter(
private val store: IoliteStore,
private val iconsDir: File,
private val config: IoliteConfig,
private val device: DeviceProfile,
private val nowSeconds: () -> Long = { System.currentTimeMillis() / 1_000 },
) {
fun importBundle(catalogId: Long, compressed: ByteArray, endpoint: RelayUrl? = null): CatalogImportResult {
val existing = store.catalog(catalogId)
val members = try {
TarZstd.read(
ByteArrayInputStream(compressed),
maxCompressedBytes = config.maxCompressedBytes,
maxUncompressedBytes = config.maxUncompressedBytes,
maxMembers = config.maxMembers,
maxMemberBytes = config.maxUncompressedBytes,
)
} catch (failure: Exception) {
throw CatalogImportException(failure.message ?: "invalid catalog bundle")
}
val byName = members.associateBy { it.name }
val manifest = parseAndVerifyManifest(members.first().data, existing?.manifestPubkey, existing?.epoch ?: 0L)
val names = members.drop(1).map { it.name }
if (names != names.sorted()) throw CatalogImportException("tar members after manifest.json must be lexicographic")
val fileTags = manifest.event.tags.filter { it.firstOrNull() == "file" }
verifyFileTags(fileTags, byName)
val index = byName["index"] ?: throw CatalogImportException("index is missing")
verifyIndex(index.data, members)
val unexpected = names.filter { !CatalogArtifacts.allowedMember(it) }
if (unexpected.isNotEmpty()) throw CatalogImportException("unexpected catalog member ${unexpected.first()}")
val (events, deletes) = parseDiff(byName["diff.jsonl"], config.maxJsonLineBytes)
validateOperations(events, deletes)
validateStacks(events, deletes, config.catalogStackPubkey)
events.filter { it.kind == Kinds.IdentityProof }.forEach(ListingRows::validateProof)
val artifacts = CatalogArtifacts.fromMembers(byName, config.maxIconBytes)
if (existing == null || existing.relays.isEmpty()) {
val relay = endpoint ?: throw CatalogImportException("catalog relay is missing")
store.upsertCatalogRelays(catalogId, listOf(relay), replaceIdentity = true)
}
if (existing?.manifestPubkey == null) store.pinManifestPubkeyIfAbsent(catalogId, manifest.event.pubkey)
val catalog = store.catalog(catalogId) ?: throw CatalogImportException("unknown catalog $catalogId")
val removed = try {
store.importCatalog(
catalog = catalog,
events = events,
deletes = deletes,
replaceAll = manifest.fromEpoch == 0L,
toEpoch = manifest.toEpoch,
device = device,
now = nowSeconds(),
artifacts = artifacts,
stackPubkey = config.catalogStackPubkey,
)
} catch (failure: Throwable) {
throw if (failure is CatalogImportException) failure else CatalogImportException(failure.message ?: "import failed")
}
removed.forEach { appId -> File(iconsDir, "$catalogId/$appId.webp").delete() }
return CatalogImportResult(manifest.fromEpoch, manifest.toEpoch)
}
private fun verifyFileTags(fileTags: List<List<String>>, byName: Map<String, TarMember>) {
val seenFiles = HashSet<String>()
for (tag in fileTags) {
if (tag.size < 3) throw CatalogImportException("malformed file tag")
val name = tag[1]
if (!seenFiles.add(name)) throw CatalogImportException("duplicate file tag $name")
val member = byName[name] ?: throw CatalogImportException("manifest file $name is missing")
if (member.data.sha256Hex() != tag[2]) throw CatalogImportException("hash mismatch for $name")
}
}
private fun verifyIndex(raw: ByteArray, members: List<TarMember>) {
val lines = raw.toString(Charsets.UTF_8).split('\n').filter { it.isNotEmpty() }
val listed = HashMap<String, String>()
for (line in lines) {
if (line.length < 66 || line[64] != ' ') throw CatalogImportException("malformed index line")
val hash = line.substring(0, 64)
val name = line.substring(65)
if (listed.put(name, hash) != null) throw CatalogImportException("duplicate index entry $name")
}
for (member in members) {
if (member.name == "manifest.json" || member.name == "index") continue
val hash = listed.remove(member.name) ?: throw CatalogImportException("index missing ${member.name}")
if (member.data.sha256Hex() != hash) throw CatalogImportException("hash mismatch for ${member.name}")
}
if (listed.isNotEmpty()) throw CatalogImportException("index entry ${listed.keys.first()} is missing")
}
private fun parseAndVerifyManifest(raw: ByteArray, expectedPubkey: String?, localEpoch: Long): Manifest {
val event = Event.parse(raw.toString(Charsets.UTF_8))
if (event.kind != Kinds.Preferences) throw CatalogImportException("manifest kind must be 30078")
if (!event.verify()) throw CatalogImportException("manifest signature is invalid")
if (expectedPubkey != null && event.pubkey != expectedPubkey) {
throw CatalogImportException("manifest pubkey does not match the pinned key")
}
val from = event.tagValue("from")?.toLongOrNull() ?: throw CatalogImportException("manifest from is missing")
val to = event.tagValue("to")?.toLongOrNull() ?: throw CatalogImportException("manifest to is missing")
val version = event.tagValue("v")?.toIntOrNull() ?: throw CatalogImportException("manifest v is missing")
if (from != localEpoch) throw CatalogImportException("manifest from $from does not match catalog epoch $localEpoch")
if (to < from) throw CatalogImportException("manifest to is behind from")
if (version != 1) throw CatalogImportException("unsupported catalog-sync version $version")
return Manifest(from, to, event)
}
private data class Manifest(
val fromEpoch: Long,
val toEpoch: Long,
val event: Event,
)
companion object {
private fun parseDiff(member: TarMember?, maxLine: Long): Pair<List<Event>, List<CatalogDelete>> {
if (member == null) return emptyList<Event>() to emptyList()
val text = member.data.toString(Charsets.UTF_8)
if (text.isEmpty()) return emptyList<Event>() to emptyList()
val events = ArrayList<Event>()
val deletes = ArrayList<CatalogDelete>()
for (line in text.split('\n')) {
if (line.isEmpty()) continue
if (line.length.toLong() > maxLine) throw CatalogImportException("JSONL line exceeds limit")
val json = JSONObject(line)
when {
json.has("id") -> {
val event = Event.parse(line)
if (!event.verify()) throw CatalogImportException("event ${event.id} failed verification")
events += event
}
json.has("delete") -> {
if (json.has("kind") || json.has("pubkey") || json.has("d") || json.has("app_id")) {
throw CatalogImportException("delete line has mixed forms")
}
val ids = json.optJSONArray("delete") ?: throw CatalogImportException("invalid delete line")
for (index in 0 until ids.length()) {
val appId = ids.optString(index)
if (appId.isBlank()) throw CatalogImportException("invalid delete line")
deletes += CatalogDelete.Listing(appId)
}
}
else -> {
val kind = json.optInt("kind", -1)
val pubkey = json.optString("pubkey")
val d = json.optString("d")
if (kind < 0 || pubkey.isBlank() || d.isBlank() || json.has("app_id")) {
throw CatalogImportException("invalid delete line")
}
deletes += CatalogDelete.Coordinate(kind, pubkey, d)
}
}
}
return events to deletes
}
private fun validateStacks(events: List<Event>, deletes: List<CatalogDelete>, catalogPubkey: String) {
for (event in events) {
if (event.kind != Kinds.AppStack) continue
if (event.pubkey != catalogPubkey) throw CatalogImportException("stack is not signed by the curator")
if (event.dTag().isNullOrBlank()) throw CatalogImportException("stack missing d")
}
for (delete in deletes) {
if (delete is CatalogDelete.Coordinate && delete.kind == Kinds.AppStack && delete.pubkey != catalogPubkey) {
throw CatalogImportException("stack is not signed by the curator")
}
}
}
private fun validateOperations(events: List<Event>, deletes: List<CatalogDelete>) {
val listingKeys = HashSet<String>()
val eventKeys = HashSet<String>()
for (event in events) {
if (ListingRows.isListingKind(event.kind)) {
listingKeys += ListingRows.listingAppId(event) ?: continue
} else {
val key = "${event.kind}:${event.pubkey}:${event.dTag().orEmpty()}"
if (!eventKeys.add(key)) throw CatalogImportException("duplicate logical key $key")
}
}
for (delete in deletes) {
when (delete) {
is CatalogDelete.Listing -> if (!listingKeys.add(delete.appId)) {
throw CatalogImportException("event and delete for ${delete.appId}")
}
is CatalogDelete.Coordinate -> {
val key = "${delete.kind}:${delete.pubkey}:${delete.d}"
if (!eventKeys.add(key)) throw CatalogImportException("event and delete for $key")
}
}
}
}
}
}
@@ -0,0 +1,66 @@
package dev.zapstore.iolite
internal object ChaCha20 {
fun xor(key: ByteArray, nonce: ByteArray, data: ByteArray): ByteArray {
require(key.size == 32) { "ChaCha20 key must be 32 bytes" }
require(nonce.size == 12) { "ChaCha20 nonce must be 12 bytes" }
val out = data.copyOf()
val state = IntArray(16)
state[0] = 0x61707865
state[1] = 0x3320646e
state[2] = 0x79622d32
state[3] = 0x6b206574
for (i in 0 until 8) state[4 + i] = key.leInt(i * 4)
for (i in 0 until 3) state[13 + i] = nonce.leInt(i * 4)
val block = ByteArray(64)
var offset = 0
var counter = 0
while (offset < out.size) {
state[12] = counter
block(state, block)
counter += 1
val n = minOf(64, out.size - offset)
for (i in 0 until n) {
out[offset + i] = (out[offset + i].toInt() xor (block[i].toInt() and 0xff)).toByte()
}
offset += n
}
return out
}
private fun block(input: IntArray, out: ByteArray) {
val x = input.copyOf()
repeat(10) {
quarter(x, 0, 4, 8, 12)
quarter(x, 1, 5, 9, 13)
quarter(x, 2, 6, 10, 14)
quarter(x, 3, 7, 11, 15)
quarter(x, 0, 5, 10, 15)
quarter(x, 1, 6, 11, 12)
quarter(x, 2, 7, 8, 13)
quarter(x, 3, 4, 9, 14)
}
for (i in x.indices) x[i] += input[i]
for (i in x.indices) {
val word = x[i]
val o = i * 4
out[o] = word.toByte()
out[o + 1] = (word ushr 8).toByte()
out[o + 2] = (word ushr 16).toByte()
out[o + 3] = (word ushr 24).toByte()
}
}
private fun quarter(x: IntArray, a: Int, b: Int, c: Int, d: Int) {
x[a] += x[b]; x[d] = (x[d] xor x[a]).rotateLeft(16)
x[c] += x[d]; x[b] = (x[b] xor x[c]).rotateLeft(12)
x[a] += x[b]; x[d] = (x[d] xor x[a]).rotateLeft(8)
x[c] += x[d]; x[b] = (x[b] xor x[c]).rotateLeft(7)
}
private fun ByteArray.leInt(offset: Int): Int =
(this[offset].toInt() and 0xff) or
((this[offset + 1].toInt() and 0xff) shl 8) or
((this[offset + 2].toInt() and 0xff) shl 16) or
((this[offset + 3].toInt() and 0xff) shl 24)
}
@@ -0,0 +1,45 @@
package dev.zapstore.iolite
import fr.acinq.secp256k1.Secp256k1
object Crypto {
private val secp: Secp256k1 = Secp256k1.get()
fun xOnlyPubkey(secretKey: ByteArray): ByteArray {
require(secretKey.size == 32) { "secret key must be 32 bytes" }
val uncompressed = secp.pubkeyCreate(secretKey)
val compressed = secp.pubKeyCompress(uncompressed)
return compressed.copyOfRange(1, 33)
}
fun signSchnorr(message: ByteArray, secretKey: ByteArray, auxRand: ByteArray? = ByteArray(32)): ByteArray {
require(message.size == 32) { "message must be 32 bytes" }
require(secretKey.size == 32) { "secret key must be 32 bytes" }
return secp.signSchnorr(message, secretKey, auxRand)
}
fun verifySchnorr(signature: ByteArray, message: ByteArray, xOnlyPubkey: ByteArray): Boolean {
if (signature.size != 64 || message.size != 32 || xOnlyPubkey.size != 32) return false
return runCatching { secp.verifySchnorr(signature, message, xOnlyPubkey) }.getOrDefault(false)
}
fun sha256(data: ByteArray): ByteArray =
java.security.MessageDigest.getInstance("SHA-256").digest(data)
fun randomBytes(size: Int): ByteArray = ByteArray(size).also { java.security.SecureRandom().nextBytes(it) }
fun sharedX(secretKey: ByteArray, xOnlyPubkey: ByteArray): ByteArray {
require(secretKey.size == 32) { "secret key must be 32 bytes" }
require(xOnlyPubkey.size == 32) { "public key must be 32 bytes" }
val compressed = ByteArray(33)
xOnlyPubkey.copyInto(compressed, 1)
val uncompressed = runCatching {
compressed[0] = 0x02
secp.pubkeyParse(compressed)
}.recoverCatching {
compressed[0] = 0x03
secp.pubkeyParse(compressed)
}.getOrElse { throw IllegalArgumentException("invalid public key") }
return secp.pubKeyTweakMul(uncompressed, secretKey).copyOfRange(1, 33)
}
}
@@ -0,0 +1,231 @@
package dev.zapstore.iolite
import java.security.MessageDigest
import org.json.JSONArray
import org.json.JSONObject
data class Event(
val id: String,
val pubkey: String,
val createdAt: Long,
val kind: Int,
val tags: List<List<String>>,
val content: String,
val sig: String,
) {
fun tagValue(name: String): String? = tags.firstOrNull { it.firstOrNull() == name }?.getOrNull(1)
fun tagValues(name: String): List<String> =
tags.mapNotNull { tag -> tag.getOrNull(1)?.takeIf { tag.firstOrNull() == name } }
fun tagCount(name: String): Int = tags.count { it.firstOrNull() == name }
fun dTag(): String? = tagValue("d")
fun address(): Address? = when {
kind.isAddressable() -> Address(kind, pubkey, dTag().orEmpty())
kind.isReplaceable() -> Address(kind, pubkey, "")
else -> null
}
fun toJsonObject(): JSONObject = JSONObject()
.put("id", id)
.put("pubkey", pubkey)
.put("created_at", createdAt)
.put("kind", kind)
.put("tags", tags.toJsonArray())
.put("content", content)
.put("sig", sig)
fun toJson(): String = toJsonObject().toString()
fun verify(): Boolean = id == computeId(pubkey, createdAt, kind, tags, content) &&
Crypto.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubkey))
companion object {
fun parse(raw: String): Event = parse(JSONObject(raw))
fun parse(json: JSONObject): Event {
val tags = json.getJSONArray("tags").toStringLists()
return Event(
id = json.getString("id"),
pubkey = json.getString("pubkey"),
createdAt = json.getLong("created_at"),
kind = json.getInt("kind"),
tags = tags,
content = json.optString("content", ""),
sig = json.getString("sig"),
)
}
fun computeId(
pubkey: String,
createdAt: Long,
kind: Int,
tags: List<List<String>>,
content: String,
): String {
val digest = MessageDigest.getInstance("SHA-256")
digest.update(canonicalBytes(pubkey, createdAt, kind, tags, content))
return Hex.encode(digest.digest())
}
fun sign(
secretKey: ByteArray,
createdAt: Long,
kind: Int,
tags: List<List<String>>,
content: String,
): Event {
val pubkey = Hex.encode(Crypto.xOnlyPubkey(secretKey))
val id = computeId(pubkey, createdAt, kind, tags, content)
val sig = Hex.encode(Crypto.signSchnorr(Hex.decode(id), secretKey))
return Event(id, pubkey, createdAt, kind, tags, content, sig)
}
internal fun canonicalBytes(
pubkey: String,
createdAt: Long,
kind: Int,
tags: List<List<String>>,
content: String,
): ByteArray {
// NIP-01 id bytes must match go-nostr / JSON.stringify: compact JSON,
// UTF-8, no escaped solidus. Android org.json escapes '/' and breaks verify.
val json = StringBuilder(64 + pubkey.length + content.length + tags.sumOf { it.sumOf { value -> value.length + 3 } })
json.append("[0,")
appendJsonString(json, pubkey)
json.append(',').append(createdAt).append(',').append(kind).append(',')
json.append('[')
tags.forEachIndexed { tagIndex, tag ->
if (tagIndex > 0) json.append(',')
json.append('[')
tag.forEachIndexed { valueIndex, value ->
if (valueIndex > 0) json.append(',')
appendJsonString(json, value)
}
json.append(']')
}
json.append("],")
appendJsonString(json, content)
json.append(']')
return json.toString().toByteArray(Charsets.UTF_8)
}
private fun appendJsonString(out: StringBuilder, value: String) {
out.append('"')
for (ch in value) {
when (ch) {
'"' -> out.append("\\\"")
'\\' -> out.append("\\\\")
'\b' -> out.append("\\b")
'\u000C' -> out.append("\\f")
'\n' -> out.append("\\n")
'\r' -> out.append("\\r")
'\t' -> out.append("\\t")
else -> if (ch.code < 0x20) {
out.append("\\u")
val hex = ch.code.toString(16)
repeat(4 - hex.length) { out.append('0') }
out.append(hex)
} else {
out.append(ch)
}
}
}
out.append('"')
}
}
}
data class Address(val kind: Int, val pubkey: String, val d: String) {
override fun toString(): String = "$kind:$pubkey:$d"
}
data class Filter(
val ids: List<String>? = null,
val authors: List<String>? = null,
val kinds: List<Int>? = null,
val tags: Map<String, List<String>>? = null,
val since: Long? = null,
val until: Long? = null,
val limit: Int? = null,
val search: String? = null,
) {
fun toJsonObject(): JSONObject {
val json = JSONObject()
ids?.let { json.put("ids", JSONArray(it)) }
authors?.let { json.put("authors", JSONArray(it)) }
kinds?.let { kinds ->
val values = JSONArray()
kinds.forEach(values::put)
json.put("kinds", values)
}
tags?.forEach { (key, values) -> json.put("#$key", JSONArray(values)) }
since?.let { json.put("since", it) }
until?.let { json.put("until", it) }
limit?.let { json.put("limit", it) }
search?.let { json.put("search", it) }
return json
}
}
fun Int.isReplaceable(): Boolean = this == 0 || this == 3 || this in 10_000..19_999
fun Int.isEphemeral(): Boolean = this in 20_000..29_999
fun Int.isAddressable(): Boolean = this in 30_000..39_999
/** Replaceable-event precedence: newer `created_at` wins, then the lower event ID. */
fun supersedes(nextCreatedAt: Long, nextId: String, currentCreatedAt: Long, currentId: String): Boolean = when {
nextCreatedAt != currentCreatedAt -> nextCreatedAt > currentCreatedAt
else -> nextId < currentId
}
internal fun List<List<String>>.toJsonArray(): JSONArray {
val tags = JSONArray()
forEach { tag ->
val values = JSONArray()
tag.forEach(values::put)
tags.put(values)
}
return tags
}
internal fun JSONArray?.toStringLists(): List<List<String>> {
if (this == null) return emptyList()
return List(length()) { index ->
val tag = getJSONArray(index)
List(tag.length()) { tag.getString(it) }
}
}
object Kinds {
const val Profile = 0
const val Comment = 1_111
const val Auth = 22_242
const val Asset = 3_063
const val Zap = 9_735
const val RelayList = 10_002
const val CatalogRelayList = 10_067
const val AppStack = 30_267
const val Preferences = 30_078
const val Release = 30_063
const val IdentityProof = 30_509
const val App = 32_267
}
/** Signs the Zapstore curated stacks (kind 30267). Not the catalog manifest key. */
const val CatalogStackPubkey = "acfeaea6e51420e8068fac446ca9d17d7a9ef6a5d20d93894e50fee3d4902a84"
/** NIP-65: unmarked or `read`-marked `r` tags. */
fun List<List<String>>.readRelayUrls(): Set<RelayUrl> = markedRelayUrls("read")
private fun List<List<String>>.markedRelayUrls(marker: String): Set<RelayUrl> =
asSequence()
.filter { tag -> tag.getOrNull(0) == "r" }
.filter { tag -> tag.getOrNull(2).let { it == null || it == marker } }
.mapNotNull { tag -> tag.getOrNull(1) }
.filter { it.startsWith("ws://") || it.startsWith("wss://") }
.mapNotNull { runCatching { it.normalizeRelayUrl() }.getOrNull() }
.toSet()
@@ -0,0 +1,365 @@
package dev.zapstore.iolite
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.SQLiteStatement
import androidx.sqlite.execSQL
import org.json.JSONArray
import org.json.JSONObject
/** Per-process facts every ingest needs: which device this is and how to decrypt device-private payloads. */
internal class IngestContext(
val device: DeviceProfile,
val deviceSigner: LocalSigner?,
)
/**
* Derives SQLite rows from verified relay events (kinds 0, 10002, 30267, 9735, 1111, 10067, 30078).
* Event JSON is not retained beyond what rehydration needs.
*/
internal object EventRows {
/** Key of the `preferences` row that stores the device's kind 10067. */
const val CATALOG_RELAY_LIST_KEY = "\u000010067"
val STACK_MODELED_TAGS = setOf("d", "name", "description", "a", "f")
/** Writes [event] if it is admissible and newer than the stored row. Returns whether a row changed. */
fun persist(tx: Tx, event: Event, context: IngestContext): Boolean {
val db = tx.db
return when (event.kind) {
Kinds.Profile -> ifNewer(event, profileVersion(db, event.pubkey)) {
upsertProfile(db, event)
tx.touch(Table.Profiles)
}
Kinds.RelayList -> ifNewer(event, relayListVersion(db, event.pubkey)) {
upsertRelayList(db, event)
tx.touch(Table.Profiles)
}
Kinds.AppStack -> {
val identifier = event.dTag() ?: return false
if (!stackMatchesDevice(event, context.device)) return false
ifNewer(event, stackVersion(db, event.pubkey, identifier)) {
upsertStack(db, event, identifier, context.deviceSigner)
tx.touch(Table.Stacks)
}
}
Kinds.Zap -> {
val appId = zapAppId(event) ?: return false
val amount = zapAmountSats(event)
if (amount <= 0) return false
insertZap(db, event, appId, amount)
tx.touch(Table.Zaps)
true
}
Kinds.Comment -> {
insertComment(db, event)
tx.touch(Table.Comments)
true
}
Kinds.CatalogRelayList -> {
if (context.deviceSigner?.publicKey != event.pubkey) return false
ifNewer(event, preferenceVersion(db, CATALOG_RELAY_LIST_KEY)) {
upsertCatalogRelayList(db, event, context.deviceSigner)
tx.touch(Table.Catalogs, Table.Preferences)
}
}
Kinds.Preferences -> {
val identifier = event.dTag() ?: return false
if (isCatalogManifest(event)) return false
ifNewer(event, preferenceVersion(db, identifier)) {
upsertPreference(db, event, identifier)
tx.touch(Table.Preferences)
}
}
else -> false
}
}
fun insertOutbox(tx: Tx, event: Event) {
tx.db.prepare("INSERT OR REPLACE INTO outbox (event_id, event_json) VALUES (?, ?)").use { statement ->
statement.bindBlob(1, Hex.decode(event.id))
statement.bindText(2, event.toJson())
statement.step()
}
tx.touch(Table.Outbox)
}
// --- replaceable-event versions -------------------------------------------------------------
private class Version(val createdAt: Long, val id: String)
private inline fun ifNewer(event: Event, current: Version?, write: () -> Unit): Boolean {
if (current != null && !supersedes(event.createdAt, event.id, current.createdAt, current.id)) return false
write()
return true
}
private fun profileVersion(db: SQLiteConnection, pubkey: String): Version? =
profileMetadata(db, pubkey)?.let { (updatedAt, metadata) ->
metadata.string("id")?.let { Version(updatedAt, it) }
}
private fun relayListVersion(db: SQLiteConnection, pubkey: String): Version? =
profileMetadata(db, pubkey)?.let { (_, metadata) ->
metadata.string("nip65_id")?.let { Version(metadata.optLong("nip65_created_at"), it) }
}
private fun stackVersion(db: SQLiteConnection, pubkey: String, identifier: String): Version? =
db.prepare("SELECT updated_at, event_id FROM stacks WHERE pubkey = ? AND identifier = ?").use { statement ->
statement.bindBlob(1, Hex.decode(pubkey))
statement.bindText(2, identifier)
if (statement.step()) Version(statement.getLong(0), Hex.encode(statement.getBlob(1))) else null
}
private fun preferenceVersion(db: SQLiteConnection, identifier: String): Version? =
db.prepare("SELECT updated_at, metadata FROM preferences WHERE identifier = ?").use { statement ->
statement.bindText(1, identifier)
if (!statement.step()) return null
JSONObject(statement.getText(1)).string("id")?.let { Version(statement.getLong(0), it) }
}
/** `(updated_at, metadata)` of a profile row, or null. */
fun profileMetadata(db: SQLiteConnection, pubkey: String): Pair<Long, JSONObject>? =
db.prepare("SELECT updated_at, metadata FROM profiles WHERE pubkey = ?").use { statement ->
statement.bindBlob(1, Hex.decode(pubkey))
if (statement.step()) statement.getLong(0) to JSONObject(statement.getText(1)) else null
}
// --- writers -----------------------------------------------------------------------------------
private fun upsertProfile(db: SQLiteConnection, event: Event) {
val content = runCatching { JSONObject(event.content) }.getOrElse { JSONObject() }
val metadata = profileMetadata(db, event.pubkey)?.second ?: JSONObject()
metadata.put("id", event.id)
metadata.put("content", event.content)
db.prepare(
"""
INSERT INTO profiles (pubkey, updated_at, name, picture_url, metadata)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(pubkey) DO UPDATE SET
updated_at = excluded.updated_at,
name = excluded.name,
picture_url = excluded.picture_url,
metadata = excluded.metadata
""".trimIndent(),
).use { statement ->
statement.bindBlob(1, Hex.decode(event.pubkey))
statement.bindLong(2, event.createdAt)
statement.bindTextOrNull(3, content.string("name"))
statement.bindTextOrNull(4, content.string("picture") ?: content.string("image"))
statement.bindText(5, metadata.toString())
statement.step()
}
}
private fun upsertRelayList(db: SQLiteConnection, event: Event) {
val existing = profileMetadata(db, event.pubkey)
val metadata = existing?.second ?: JSONObject()
metadata.put("nip65_id", event.id)
metadata.put("nip65_created_at", event.createdAt)
metadata.put("nip65_tags", event.tags.toJsonArray())
db.prepare(
"""
INSERT INTO profiles (pubkey, updated_at, name, picture_url, metadata)
VALUES (?, ?, NULL, NULL, ?)
ON CONFLICT(pubkey) DO UPDATE SET metadata = excluded.metadata
""".trimIndent(),
).use { statement ->
statement.bindBlob(1, Hex.decode(event.pubkey))
statement.bindLong(2, existing?.first ?: event.createdAt)
statement.bindText(3, metadata.toString())
statement.step()
}
}
private fun insertZap(db: SQLiteConnection, event: Event, appId: String, amountSats: Long) {
db.prepare(
"""
INSERT OR IGNORE INTO zaps (event_id, pubkey, amount_sats, created_at, app_id, metadata)
VALUES (?, ?, ?, ?, ?, ?)
""".trimIndent(),
).use { statement ->
statement.bindBlob(1, Hex.decode(event.id))
statement.bindBlob(2, Hex.decode(event.pubkey))
statement.bindLong(3, amountSats)
statement.bindLong(4, event.createdAt)
statement.bindText(5, appId)
statement.bindText(6, JSONObject().put("tags", event.tags.toJsonArray()).toString())
statement.step()
}
}
private fun insertComment(db: SQLiteConnection, event: Event) {
val appId = event.tagValue("i")
?: event.tagValue("a")?.let { AppCoordinate.parse(it)?.appId }
?: event.tagValue("A")?.let { AppCoordinate.parse(it)?.appId }
db.prepare(
"""
INSERT OR IGNORE INTO comments (
event_id, pubkey, created_at, content, app_id, stack, parent_event_id, metadata
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent(),
).use { statement ->
statement.bindBlob(1, Hex.decode(event.id))
statement.bindBlob(2, Hex.decode(event.pubkey))
statement.bindLong(3, event.createdAt)
statement.bindText(4, event.content)
statement.bindTextOrNull(5, appId)
statement.bindTextOrNull(6, event.tagValue("A"))
statement.bindBlobOrNull(7, event.tagValue("e")?.let(Hex::decode))
statement.bindText(8, JSONObject().put("tags", event.tags.toJsonArray()).toString())
statement.step()
}
}
private fun upsertStack(db: SQLiteConnection, event: Event, identifier: String, deviceSigner: LocalSigner?) {
val metadata = JSONObject()
.put("tags", event.tags.toJsonArray())
.put("content", event.content)
.put("private_apps", JSONArray(decryptJsonArray(event.content, deviceSigner)))
db.prepare(
"""
INSERT INTO stacks (pubkey, identifier, event_id, name, description, public_apps, updated_at, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(pubkey, identifier) DO UPDATE SET
event_id = excluded.event_id,
name = excluded.name,
description = excluded.description,
public_apps = excluded.public_apps,
updated_at = excluded.updated_at,
metadata = excluded.metadata
""".trimIndent(),
).use { statement ->
statement.bindBlob(1, Hex.decode(event.pubkey))
statement.bindText(2, identifier)
statement.bindBlob(3, Hex.decode(event.id))
statement.bindText(4, event.tagValue("name") ?: identifier)
statement.bindTextOrNull(5, event.tagValue("description"))
statement.bindText(6, JSONArray(event.tagValues("a")).toString())
statement.bindLong(7, event.createdAt)
statement.bindText(8, metadata.toString())
statement.step()
}
}
private fun upsertPreference(db: SQLiteConnection, event: Event, identifier: String) {
upsertPreferenceRow(db, identifier, event)
}
private fun upsertCatalogRelayList(db: SQLiteConnection, event: Event, deviceSigner: LocalSigner?) {
val publicUrls = event.tagValues("r").mapNotNull { runCatching { it.normalizeRelayUrl() }.getOrNull() }
val privateUrls = decryptJsonArray(event.content, deviceSigner)
.mapNotNull { runCatching { it.normalizeRelayUrl() }.getOrNull() }
db.execSQL("UPDATE catalogs SET position = NULL")
val seen = HashSet<String>()
var position = 0
fun select(url: RelayUrl, isPrivate: Boolean) {
if (!seen.add(url.url)) return
db.prepare(
"""
INSERT INTO catalogs (relay_url, position, is_private, epoch)
VALUES (?, ?, ?, 0)
ON CONFLICT(relay_url) DO UPDATE SET
position = excluded.position,
is_private = excluded.is_private
""".trimIndent(),
).use { statement ->
statement.bindText(1, url.url)
statement.bindLong(2, position.toLong())
statement.bindLong(3, if (isPrivate) 1 else 0)
statement.step()
}
position++
}
publicUrls.forEach { select(it, false) }
privateUrls.forEach { select(it, true) }
upsertPreferenceRow(db, CATALOG_RELAY_LIST_KEY, event)
}
private fun upsertPreferenceRow(db: SQLiteConnection, identifier: String, event: Event) {
val metadata = JSONObject()
.put("id", event.id)
.put("pubkey", event.pubkey)
.put("content", event.content)
.put("tags", event.tags.toJsonArray())
db.prepare(
"""
INSERT INTO preferences (identifier, updated_at, metadata)
VALUES (?, ?, ?)
ON CONFLICT(identifier) DO UPDATE SET
updated_at = excluded.updated_at,
metadata = excluded.metadata
""".trimIndent(),
).use { statement ->
statement.bindText(1, identifier)
statement.bindLong(2, event.createdAt)
statement.bindText(3, metadata.toString())
statement.step()
}
}
// --- event helpers -----------------------------------------------------------------------------
fun stackMatchesDevice(event: Event, device: DeviceProfile): Boolean {
val platforms = event.tagValues("f").filter { it.startsWith("android-") }
return platforms.isEmpty() || platforms.any { it in device.platforms }
}
fun deleteStack(tx: Tx, pubkey: String, identifier: String) {
tx.db.prepare("DELETE FROM stacks WHERE pubkey = ? AND identifier = ?").use { statement ->
statement.bindBlob(1, Hex.decode(pubkey))
statement.bindText(2, identifier)
statement.step()
}
tx.touch(Table.Stacks)
}
/** Drops catalog stacks whose identifier is not in [keep]. Other authors stay. */
fun retainCatalogStacks(tx: Tx, pubkey: String, keep: Set<String>) {
val stale = tx.db.prepare("SELECT identifier FROM stacks WHERE pubkey = ?").use { statement ->
statement.bindBlob(1, Hex.decode(pubkey))
buildList {
while (statement.step()) {
val identifier = statement.getText(0)
if (identifier !in keep) add(identifier)
}
}
}
stale.forEach { deleteStack(tx, pubkey, it) }
}
private fun zapAppId(event: Event): String? =
event.tagValue("i") ?: event.tagValue("a")?.let { AppCoordinate.parse(it)?.appId }
private fun zapAmountSats(event: Event): Long {
event.tagValue("bolt11")?.let { invoice ->
val amount = Bolt11.amountSats(invoice)
if (amount > 0) return amount
}
return (event.tagValue("amount")?.toLongOrNull() ?: 0) / 1_000
}
fun isCatalogManifest(event: Event): Boolean =
event.kind == Kinds.Preferences &&
event.tagValue("from") != null &&
event.tagValue("to") != null &&
event.tagValue("v") != null
fun decryptJsonArray(content: String, deviceSigner: LocalSigner?): List<String> {
if (content.isBlank() || deviceSigner == null) return emptyList()
return runCatching { JSONArray(deviceSigner.decryptFromSelf(content)).toStringList() }.getOrDefault(emptyList())
}
fun leftoverTags(stored: List<List<String>>, modeled: Set<String>): List<List<String>> =
stored.filter { tag -> tag.firstOrNull() !in modeled }
}
internal fun SQLiteStatement.bindTextOrNull(index: Int, value: String?) {
if (value == null) bindNull(index) else bindText(index, value)
}
internal fun SQLiteStatement.bindBlobOrNull(index: Int, value: ByteArray?) {
if (value == null) bindNull(index) else bindBlob(index, value)
}
internal fun SQLiteStatement.textOrNull(index: Int): String? = if (isNull(index)) null else getText(index)
internal fun SQLiteStatement.blobHexOrNull(index: Int): String? = if (isNull(index)) null else Hex.encode(getBlob(index))
@@ -0,0 +1,33 @@
package dev.zapstore.iolite
object Hex {
fun encode(bytes: ByteArray): String = buildString(bytes.size * 2) {
for (byte in bytes) {
val value = byte.toInt() and 0xff
append("0123456789abcdef"[value ushr 4])
append("0123456789abcdef"[value and 0x0f])
}
}
fun decode(value: String): ByteArray {
require(value.length % 2 == 0) { "hex length must be even" }
require(value.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) { "invalid hex" }
return ByteArray(value.length / 2) { index ->
((digit(value[index * 2]) shl 4) or digit(value[index * 2 + 1])).toByte()
}
}
fun isHex(value: String, length: Int): Boolean =
value.length == length && value.all { it in '0'..'9' || it in 'a'..'f' }
private fun digit(char: Char): Int = when (char) {
in '0'..'9' -> char - '0'
in 'a'..'f' -> char - 'a' + 10
in 'A'..'F' -> char - 'A' + 10
else -> error("invalid hex")
}
}
fun ByteArray.toHex(): String = Hex.encode(this)
fun String.decodeHex(): ByteArray = Hex.decode(this)
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
package dev.zapstore.iolite
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@@ -8,17 +9,25 @@ import kotlin.time.Duration.Companion.seconds
data class IoliteConfig(
val databaseName: String = "iolite.db",
val oneShotTimeout: Duration = 30.seconds,
val eoseGrace: Duration = 200.milliseconds,
val ingestionCapacity: Int = 1_024,
val expirationSweepInterval: Duration = 1.minutes,
val ingestBatchSize: Int = 100,
val ingestFlushInterval: Duration = 250.milliseconds,
/**
* Retention policy for unbounded regular kinds, as `kind -> maxAge`.
* Each expiration sweep deletes events of these kinds whose
* `created_at` is older than the configured age. Addressable kinds
* need no rule — supersession already bounds them.
*/
val pruneRules: Map<Int, Duration> = emptyMap(),
/** How long a resolved NIP-65 relay list is trusted before relays are asked again. */
val relayListCacheFor: Duration = 6.hours,
val relayListTimeout: Duration = 10.seconds,
val maxCompressedBytes: Long = 32L * 1024 * 1024,
/** Whole tar after zstd. Epoch 0 is every current listing; 20k apps is tens of MB. */
val maxUncompressedBytes: Long = 512L * 1024 * 1024,
/** Tar members in one bundle. A from-0 catalog is about three files per app, plus one avatar each. */
val maxMembers: Int = 100_000,
/** One JSONL line (one event or delete). Matches the relay's 0.5 MiB event cap. */
val maxJsonLineBytes: Long = 512L * 1024,
val maxIconBytes: Long = 256L * 1024,
/** Kind 30267 author included in catalog bundles. */
val catalogStackPubkey: String = CatalogStackPubkey,
) {
internal fun validate() {
require(databaseName.isNotBlank() && databaseName.none { it == '/' || it == '\\' || it == '\u0000' }) {
@@ -27,14 +36,29 @@ data class IoliteConfig(
require(oneShotTimeout.isFinite() && oneShotTimeout.isPositive()) {
"oneShotTimeout must be finite and positive"
}
require(eoseGrace.isFinite() && eoseGrace.isPositive()) {
"eoseGrace must be finite and positive"
}
require(ingestionCapacity >= 1) { "ingestionCapacity must be at least one" }
require(expirationSweepInterval.isFinite() && expirationSweepInterval.isPositive()) {
"expirationSweepInterval must be finite and positive"
}
require(ingestBatchSize >= 1) { "ingestBatchSize must be at least one" }
require(relayListCacheFor.isFinite() && relayListCacheFor.isPositive()) {
"relayListCacheFor must be finite and positive"
}
require(relayListTimeout.isFinite() && relayListTimeout.isPositive()) {
"relayListTimeout must be finite and positive"
}
require(ingestFlushInterval.isFinite() && ingestFlushInterval.isPositive()) {
"ingestFlushInterval must be finite and positive"
}
require(maxCompressedBytes > 0 && maxUncompressedBytes > 0 && maxMembers > 0) {
"catalog bundle limits must be positive"
}
require(maxJsonLineBytes > 0 && maxJsonLineBytes <= maxUncompressedBytes) {
"maxJsonLineBytes must be positive and at most maxUncompressedBytes"
}
pruneRules.forEach { (kind, maxAge) ->
require(kind >= 0) { "pruneRules kinds must be non-negative" }
require(maxAge.isFinite() && maxAge.isPositive()) {
@@ -0,0 +1,752 @@
package dev.zapstore.iolite
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.SQLiteStatement
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.sqlite.execSQL
import java.io.File
import java.util.EnumSet
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.callbackFlow
import org.json.JSONArray
import org.json.JSONObject
/** Selects visible catalog listings. All fields are optional and combine with AND. */
data class AppFilter(
val appIds: Collection<String>? = null,
/** Matches the proof owner or the app event signer. */
val author: String? = null,
/**
* Case-insensitive exact or prefix match on the name or app ID.
* With [queryVector], listings are also ranked by embedding similarity.
*/
val search: String? = null,
/** leaf-ir-v1 query embedding, [EMBEDDING_DIMS] int8 bytes. Ignored unless [search] is set. */
val queryVector: ByteArray? = null,
val limit: Int? = null,
)
/** One open transaction. Row writers record which tables they touch so observers can be notified precisely. */
class Tx internal constructor(val db: SQLiteConnection) {
internal val touched: EnumSet<Table> = EnumSet.noneOf(Table::class.java)
fun touch(vararg tables: Table) {
touched += tables
}
}
/**
* The SQLite file. Every write is one transaction; every commit notifies listeners of the touched tables.
* Reads return typed records. No event JSON is rehydrated for reads.
*/
class IoliteStore internal constructor(
path: String,
private val iconsDir: File = File(File(path).parentFile, "icons"),
private val avatarsDir: File = File(File(path).parentFile, "avatars"),
openConnection: (String) -> SQLiteConnection = ::openBundledOrJdbc,
) : AutoCloseable {
private class Listener(val tables: Set<Table>, val notify: () -> Unit)
private val lock = ReentrantLock()
private val connection: SQLiteConnection = open(path, openConnection)
private val listeners = CopyOnWriteArrayList<Listener>()
// --- change notification -----------------------------------------------------------------------
/** Emits once on subscription and after every commit that touched any of [tables]. Conflated. */
fun changes(tables: Set<Table>): Flow<Unit> = callbackFlow {
val listener = Listener(tables) { trySend(Unit) }
listeners += listener
trySend(Unit)
awaitClose { listeners -= listener }
}.buffer(Channel.CONFLATED)
fun <T> read(block: (SQLiteConnection) -> T): T = lock.withLock { block(connection) }
fun write(block: (Tx) -> Unit) {
val touched = lock.withLock {
val tx = Tx(connection)
connection.execSQL("BEGIN IMMEDIATE")
try {
block(tx)
connection.execSQL("COMMIT")
} catch (failure: Throwable) {
runCatching { connection.execSQL("ROLLBACK") }
throw failure
}
tx.touched
}
if (touched.isEmpty()) return
listeners.forEach { listener ->
if (listener.tables.any(touched::contains)) runCatching(listener.notify)
}
}
override fun close() {
lock.withLock {
checkpointWalLocked()
connection.close()
}
}
/** Folds the WAL into the main file so a large import does not leave a second copy on disk. */
private fun checkpointWal() {
lock.withLock { checkpointWalLocked() }
}
private fun checkpointWalLocked() {
runCatching {
connection.prepare("PRAGMA wal_checkpoint(TRUNCATE)").use { it.step() }
}
}
// --- ingest ------------------------------------------------------------------------------------
/** Relay path: verified non-catalog events, one transaction. Returns how many rows changed. */
internal fun ingest(events: List<Event>, context: IngestContext): Int {
val admissible = events.filter { it.kind !in ListingRows.KINDS && it.verify() }
if (admissible.isEmpty()) return 0
var written = 0
write { tx -> admissible.forEach { if (EventRows.persist(tx, it, context)) written++ } }
return written
}
/**
* Catalog path: one epoch's verified events and deletes, one transaction. When [replaceAll] is set,
* listings absent from [events] are removed. Returns the app IDs whose listings were deleted.
*/
internal fun importCatalog(
catalog: CatalogRecord,
events: List<Event>,
deletes: List<CatalogDelete>,
replaceAll: Boolean,
toEpoch: Long,
device: DeviceProfile,
now: Long,
artifacts: CatalogArtifact = CatalogArtifact.EMPTY,
stackPubkey: String = CatalogStackPubkey,
): Set<String> {
val removed = LinkedHashSet<String>()
write { tx ->
for (event in events) {
if (event.kind != Kinds.AppStack) continue
if (event.pubkey != stackPubkey) {
throw CatalogImportException("stack is not signed by the curator")
}
if (event.dTag().isNullOrBlank()) throw CatalogImportException("stack missing d")
}
for (delete in deletes) {
if (delete is CatalogDelete.Coordinate && delete.kind == Kinds.AppStack && delete.pubkey != stackPubkey) {
throw CatalogImportException("stack is not signed by the curator")
}
ListingRows.delete(tx, catalog, delete)
if (delete is CatalogDelete.Listing) removed += delete.appId
}
val listings = events.filter { ListingRows.isListingKind(it.kind) }.groupBy(ListingRows::listingAppId)
for ((appId, listing) in listings) {
if (appId == null) continue
ListingRows.persistListing(tx, catalog, appId, listing, device, now)
}
events.filter { it.kind == Kinds.IdentityProof }.forEach { ListingRows.persistProof(tx, catalog, it) }
val keptStacks = LinkedHashSet<String>()
val context = IngestContext(device, null)
for (event in events) {
if (event.kind == Kinds.Profile) EventRows.persist(tx, event, context)
}
for (event in events) {
if (event.kind != Kinds.AppStack || !EventRows.stackMatchesDevice(event, device)) continue
val identifier = event.dTag() ?: continue
keptStacks += identifier
EventRows.persist(tx, event, context)
}
if (replaceAll) EventRows.retainCatalogStacks(tx, stackPubkey, keptStacks)
if (replaceAll) {
for (appId in ListingRows.staleAppIds(tx.db, catalog, listings.keys.filterNotNull().toSet())) {
ListingRows.delete(tx, catalog, CatalogDelete.Listing(appId))
removed += appId
}
}
applyArtifacts(tx, catalog, artifacts)
ListingRows.recomputeAppPubkeys(tx, catalog.id, now)
tx.db.prepare("UPDATE catalogs SET epoch = ? WHERE id = ?").use { statement ->
statement.bindLong(1, toEpoch)
statement.bindLong(2, catalog.id)
statement.step()
}
tx.touch(Table.Catalogs)
}
checkpointWal()
return removed
}
/** Local avatar WebP for [pubkey], whether or not the file has been written yet. */
fun avatarFile(pubkey: String): File {
require(Hex.isHex(pubkey, 64)) { "pubkey is not 32-byte hex" }
return File(avatarsDir, "${pubkey.lowercase()}.webp")
}
/**
* Writes the vector and about, security, and facts for apps that already have a listing row.
* A record that omits one of those keeps the value stored earlier.
* Icon and avatar bytes land on disk for Coil.
*/
private fun applyArtifacts(tx: Tx, catalog: CatalogRecord, artifacts: CatalogArtifact) {
if (artifacts.apps.isEmpty() && artifacts.avatars.isEmpty()) return
val db = tx.db
for (artifact in artifacts.apps) {
val appRow = db.prepare("SELECT id FROM apps WHERE catalog_id = ? AND app_id = ?").use { statement ->
statement.bindLong(1, catalog.id)
statement.bindText(2, artifact.appId)
if (statement.step()) statement.getBlob(0) else null
} ?: continue
artifact.about?.let { about ->
db.prepare("UPDATE apps SET about = ? WHERE catalog_id = ? AND app_id = ?").use { statement ->
statement.bindText(1, about)
statement.bindLong(2, catalog.id)
statement.bindText(3, artifact.appId)
statement.step()
}
}
artifact.security?.let { security ->
db.prepare("UPDATE apps SET security = ? WHERE catalog_id = ? AND app_id = ?").use { statement ->
statement.bindText(1, security)
statement.bindLong(2, catalog.id)
statement.bindText(3, artifact.appId)
statement.step()
}
}
artifact.facts?.let { facts ->
db.prepare("UPDATE apps SET facts = ? WHERE catalog_id = ? AND app_id = ?").use { statement ->
statement.bindText(1, facts)
statement.bindLong(2, catalog.id)
statement.bindText(3, artifact.appId)
statement.step()
}
}
val embedding = artifact.embedding
if (embedding != null) {
db.prepare(
"""
INSERT INTO apps_search (id, embedding)
VALUES (?, ?)
ON CONFLICT(id) DO UPDATE SET
embedding = excluded.embedding
""".trimIndent(),
).use { statement ->
statement.bindBlob(1, appRow)
statement.bindBlob(2, embedding)
statement.step()
}
}
artifact.webp?.let { writeBytes(File(iconsDir, "${catalog.id}/${artifact.appId}.webp"), it) }
}
for (avatar in artifacts.avatars) {
writeBytes(avatarFile(avatar.pubkey), avatar.webp)
}
if (artifacts.apps.isNotEmpty()) tx.touch(Table.Apps)
}
/** Re-derives `apps.pubkey` for every catalog; run at open and at each proof expiry. */
fun recomputeProofs(now: Long) {
write { tx ->
allCatalogs().forEach { ListingRows.recomputeAppPubkeys(tx, it.id, now) }
}
}
/** Signing path: derived rows and the outbox row commit together. */
internal fun commitPublish(event: Event, context: IngestContext) {
require(event.kind != Kinds.Auth) { "NIP-42 AUTH is never added to the outbox" }
require(event.verify()) { "published event failed verification" }
write { tx ->
EventRows.persist(tx, event, context)
EventRows.insertOutbox(tx, event)
}
}
fun wipe() {
write { tx ->
listOf("comments", "zaps", "stacks", "profiles", "outbox", "preferences", "certificate_proofs", "apps", "query_refresh")
.forEach { tx.db.execSQL("DELETE FROM $it") }
tx.db.execSQL("UPDATE catalogs SET epoch = 0")
tx.db.execSQL("UPDATE catalogs SET manifest_pubkey = NULL WHERE id = 1")
tx.touch(*Table.values())
}
checkpointWal()
}
fun pruneOlderThan(kind: Int, createdBefore: Long) {
val (table, touched) = when (kind) {
Kinds.Zap -> "zaps" to Table.Zaps
Kinds.Comment -> "comments" to Table.Comments
else -> return
}
write { tx ->
tx.db.prepare("DELETE FROM $table WHERE created_at < ?").use { statement ->
statement.bindLong(1, createdBefore)
statement.step()
}
tx.touch(touched)
}
}
// --- catalogs ----------------------------------------------------------------------------------
fun catalog(id: Long): CatalogRecord? = read { db ->
db.prepare("$SELECT_CATALOG WHERE id = ?").use { statement ->
statement.bindLong(1, id)
statement.rowOrNull { it.toCatalog() }
}
}
fun selectedCatalogs(): List<CatalogRecord> = read { db ->
db.prepare("$SELECT_CATALOG WHERE position IS NOT NULL ORDER BY position ASC").use { it.rows { toCatalog() } }
}
fun allCatalogs(): List<CatalogRecord> = read { db ->
db.prepare("$SELECT_CATALOG ORDER BY COALESCE(position, 999999), id").use { it.rows { toCatalog() } }
}
/** Writes the catalog endpoint. [replaceIdentity] updates the stable catalog URL used in app ids. */
fun upsertCatalogRelays(catalogId: Long, relays: List<RelayUrl>, replaceIdentity: Boolean) {
val canonical = relays.firstOrNull { !it.isOnion } ?: relays.first()
val encoded = JSONArray().apply { relays.forEach { put(it.url) } }.toString()
write { tx ->
val exists = tx.db.prepare("SELECT 1 FROM catalogs WHERE id = ?").use { statement ->
statement.bindLong(1, catalogId)
statement.step()
}
if (!exists) {
tx.db.prepare(
"""
INSERT INTO catalogs (id, relay_url, position, is_private, epoch, endpoints)
VALUES (?, ?, (SELECT COALESCE(MAX(position), -1) + 1 FROM catalogs), 0, 0, ?)
""".trimIndent(),
).use { statement ->
statement.bindLong(1, catalogId)
statement.bindText(2, canonical.url)
statement.bindText(3, encoded)
statement.step()
}
} else if (replaceIdentity) {
tx.db.prepare("UPDATE catalogs SET relay_url = ?, endpoints = ? WHERE id = ?").use { statement ->
statement.bindText(1, canonical.url)
statement.bindText(2, encoded)
statement.bindLong(3, catalogId)
statement.step()
}
} else {
tx.db.prepare("UPDATE catalogs SET endpoints = ? WHERE id = ?").use { statement ->
statement.bindText(1, encoded)
statement.bindLong(2, catalogId)
statement.step()
}
}
tx.touch(Table.Catalogs)
}
}
fun pinManifestPubkeyIfAbsent(catalogId: Long, pubkey: String) {
write { tx ->
tx.db.prepare("UPDATE catalogs SET manifest_pubkey = ? WHERE id = ? AND manifest_pubkey IS NULL").use { statement ->
statement.bindBlob(1, Hex.decode(pubkey))
statement.bindLong(2, catalogId)
statement.step()
}
tx.touch(Table.Catalogs)
}
}
// --- apps --------------------------------------------------------------------------------------
/** Visible listings: for each app ID, the row from the first selected catalog. */
fun apps(filter: AppFilter = AppFilter()): List<AppRecord> = read { db ->
val where = StringBuilder()
val binds = mutableListOf<(SQLiteStatement, Int) -> Unit>()
filter.appIds?.let { ids ->
if (ids.isEmpty()) return@read emptyList()
where.append(" AND a.app_id IN (").append(ids.joinToString(",") { "?" }).append(")")
ids.forEach { id -> binds += { s, i -> s.bindText(i, id) } }
}
filter.author?.let { author ->
val bytes = Hex.decode(author)
where.append(" AND (a.pubkey = ? OR a.event_pubkey = ?)")
binds += { s, i -> s.bindBlob(i, bytes) }
binds += { s, i -> s.bindBlob(i, bytes) }
}
val query = filter.search?.let(::normalizeSearchQuery)?.takeIf { it.isNotEmpty() }
if (query == null) {
val limit = filter.limit?.let { " LIMIT $it" } ?: ""
return@read db.prepare("$SELECT_VISIBLE_APP$where ORDER BY a.app_event_created_at DESC, a.app_id ASC$limit").use { statement ->
binds.forEachIndexed { index, bind -> bind(statement, index + 1) }
statement.rows { toApp(iconsDir) }
}
}
val ranker = SearchRanker(query, filter.queryVector, filter.limit)
db.prepare("$SELECT_SEARCH_KEY$where").use { statement ->
binds.forEachIndexed { index, bind -> bind(statement, index + 1) }
while (statement.step()) {
ranker.consider(
id = statement.getBlob(0),
appId = statement.getText(1),
name = statement.getText(2),
embedding = if (statement.isNull(3)) null else statement.getBlob(3),
)
}
}
val ids = ranker.ids()
if (ids.isEmpty()) return@read emptyList()
val sql = "$SELECT_VISIBLE_APP$where AND a.id IN (${ids.joinToString(",") { "?" }})"
val byId = db.prepare(sql).use { statement ->
binds.forEachIndexed { index, bind -> bind(statement, index + 1) }
ids.forEachIndexed { index, id -> statement.bindBlob(binds.size + index + 1, id) }
statement.rows { toApp(iconsDir) }.associateBy { Hex.encode(it.id) }
}
ids.mapNotNull { byId[Hex.encode(it)] }
}
fun app(appId: String): AppRecord? = apps(AppFilter(appIds = listOf(appId), limit = 1)).firstOrNull()
fun nextProofExpiry(nowSeconds: Long): Long? = read { db ->
db.prepare("SELECT MIN(expiry) FROM certificate_proofs WHERE revoked = 0 AND expiry > ?").use { statement ->
statement.bindLong(1, nowSeconds)
if (!statement.step() || statement.isNull(0)) null else statement.getLong(0)
}
}
// --- profiles ----------------------------------------------------------------------------------
/** Stored kind 0 projections for [pubkeys]; rows that only carry a relay list are omitted. */
fun profiles(pubkeys: Collection<String>): Map<String, ProfileRecord> = read { db ->
if (pubkeys.isEmpty()) return@read emptyMap()
val sql = "SELECT pubkey, updated_at, name, picture_url, metadata FROM profiles WHERE pubkey IN (" +
pubkeys.joinToString(",") { "?" } + ")"
db.prepare(sql).use { statement ->
pubkeys.forEachIndexed { index, pubkey -> statement.bindBlob(index + 1, Hex.decode(pubkey)) }
buildMap {
while (statement.step()) {
val metadata = JSONObject(statement.getText(4))
val eventId = metadata.string("id") ?: continue
val content = runCatching { JSONObject(metadata.optString("content")) }.getOrElse { JSONObject() }
val pubkey = Hex.encode(statement.getBlob(0))
put(
pubkey,
ProfileRecord(
pubkey = pubkey,
updatedAt = statement.getLong(1),
eventId = eventId,
name = statement.textOrNull(2),
displayName = content.string("display_name"),
about = content.string("about"),
picture = statement.textOrNull(3),
banner = content.string("banner"),
website = content.string("website"),
nip05 = content.string("nip05"),
),
)
}
}
}
}
/** NIP-65 read relays for [pubkey], or null when no relay list is stored. */
fun readRelays(pubkey: String): Set<RelayUrl>? = read { db ->
EventRows.profileMetadata(db, pubkey)?.second
?.takeIf { it.has("nip65_id") }
?.optJSONArray("nip65_tags").toStringLists()
.takeIf { it.isNotEmpty() }
?.readRelayUrls()
}
// --- stacks, zaps, comments --------------------------------------------------------------------
fun stacks(author: String, identifier: String? = null, limit: Int? = null, deviceSigner: LocalSigner? = null): List<StackRecord> =
read { db ->
val where = if (identifier == null) "" else " AND identifier = ?"
val limitSql = limit?.let { " LIMIT $it" } ?: ""
db.prepare(
"SELECT pubkey, identifier, event_id, name, description, public_apps, updated_at, metadata FROM stacks " +
"WHERE pubkey = ?$where ORDER BY updated_at DESC$limitSql",
).use { statement ->
statement.bindBlob(1, Hex.decode(author))
if (identifier != null) statement.bindText(2, identifier)
statement.rows {
val metadata = JSONObject(getText(7))
val privateApps = if (deviceSigner != null) metadata.stringList("private_apps") else emptyList()
StackRecord(
pubkey = Hex.encode(getBlob(0)),
identifier = getText(1),
eventId = Hex.encode(getBlob(2)),
name = getText(3),
description = textOrNull(4).orEmpty(),
apps = (JSONArray(getText(5)).toStringList() + privateApps).mapNotNull(AppCoordinate::parse),
updatedAt = getLong(6),
)
}
}
}
fun zaps(appId: String, limit: Int? = null): List<ZapRecord> = read { db ->
val limitSql = limit?.let { " LIMIT $it" } ?: ""
db.prepare(
"SELECT event_id, pubkey, amount_sats, created_at FROM zaps WHERE app_id = ? ORDER BY created_at DESC$limitSql",
).use { statement ->
statement.bindText(1, appId)
statement.rows {
ZapRecord(Hex.encode(getBlob(0)), Hex.encode(getBlob(1)), getLong(2), getLong(3), appId)
}
}
}
fun comments(appId: String, limit: Int? = null): List<CommentRecord> = read { db ->
val limitSql = limit?.let { " LIMIT $it" } ?: ""
db.prepare(
"SELECT event_id, pubkey, created_at, content, stack, parent_event_id FROM comments " +
"WHERE app_id = ? ORDER BY created_at DESC$limitSql",
).use { statement ->
statement.bindText(1, appId)
statement.rows {
CommentRecord(
eventId = Hex.encode(getBlob(0)),
pubkey = Hex.encode(getBlob(1)),
createdAt = getLong(2),
content = getText(3),
appId = appId,
stack = textOrNull(4),
parentEventId = blobHexOrNull(5),
)
}
}
}
// --- outbox ------------------------------------------------------------------------------------
fun pendingOutbox(): List<Event> = read { db ->
db.prepare("SELECT event_json FROM outbox").use { it.rows { Event.parse(getText(0)) } }
}
fun deleteOutbox(eventId: String) {
write { tx ->
tx.db.prepare("DELETE FROM outbox WHERE event_id = ?").use { statement ->
statement.bindBlob(1, Hex.decode(eventId))
statement.step()
}
tx.touch(Table.Outbox)
}
}
// --- query refresh cache -----------------------------------------------------------------------
fun lastRefresh(fingerprint: String): Long? = read { db ->
db.prepare("SELECT refreshed_at FROM query_refresh WHERE fingerprint = ?").use { statement ->
statement.bindText(1, fingerprint)
if (statement.step()) statement.getLong(0) else null
}
}
fun recordRefresh(fingerprint: String, epochMillis: Long) {
if (epochMillis <= 0) return
// No listener cares about this table, so no notification is needed.
read { db ->
db.prepare("INSERT OR REPLACE INTO query_refresh (fingerprint, refreshed_at) VALUES (?, ?)").use { statement ->
statement.bindText(1, fingerprint)
statement.bindLong(2, epochMillis)
statement.step()
}
}
}
// --- rehydration templates ---------------------------------------------------------------------
fun catalogRelayListTemplate(deviceSigner: LocalSigner): Pair<List<List<String>>, String> {
val selected = selectedCatalogs()
val tags = selected.filter { !it.isPrivate }.map { listOf("r", it.relayUrl.url) }
val privateUrls = selected.filter { it.isPrivate }.map { it.relayUrl.url }
val content = if (privateUrls.isEmpty()) "" else deviceSigner.encryptToSelf(JSONArray(privateUrls).toString())
return tags to content
}
fun preferenceTemplate(identifier: String): Pair<List<List<String>>, String> {
val row = read { db ->
db.prepare("SELECT metadata FROM preferences WHERE identifier = ?").use { statement ->
statement.bindText(1, identifier)
statement.rowOrNull { JSONObject(it.getText(0)) }
}
} ?: error("missing preference $identifier")
val leftovers = EventRows.leftoverTags(row.optJSONArray("tags").toStringLists(), setOf("d"))
return (listOf(listOf("d", identifier)) + leftovers) to row.optString("content")
}
fun stackTemplate(pubkey: String, identifier: String, device: DeviceProfile, deviceSigner: LocalSigner?): Pair<List<List<String>>, String> {
val (stack, storedTags) = read { db ->
db.prepare("SELECT metadata FROM stacks WHERE pubkey = ? AND identifier = ?").use { statement ->
statement.bindBlob(1, Hex.decode(pubkey))
statement.bindText(2, identifier)
statement.rowOrNull { JSONObject(it.getText(0)).optJSONArray("tags").toStringLists() }
}
}?.let { tags -> stacks(pubkey, identifier, deviceSigner = deviceSigner).first() to tags }
?: error("missing stack $identifier")
val privateApps = if (deviceSigner == null) emptyList() else read { db ->
db.prepare("SELECT metadata FROM stacks WHERE pubkey = ? AND identifier = ?").use { statement ->
statement.bindBlob(1, Hex.decode(pubkey))
statement.bindText(2, identifier)
statement.rowOrNull { JSONObject(it.getText(0)).stringList("private_apps") }
}
}.orEmpty()
val privateSet = privateApps.toSet()
val tags = buildList {
add(listOf("d", identifier))
add(listOf("name", stack.name))
stack.description.takeIf { it.isNotBlank() }?.let { add(listOf("description", it)) }
stack.apps.map(AppCoordinate::toString).filter { it !in privateSet }.forEach { add(listOf("a", it)) }
device.platforms.forEach { add(listOf("f", it)) }
addAll(EventRows.leftoverTags(storedTags, EventRows.STACK_MODELED_TAGS))
}
val content = if (privateApps.isEmpty() || deviceSigner == null) {
""
} else {
deviceSigner.encryptToSelf(JSONArray(privateApps).toString())
}
return tags to content
}
companion object {
private const val SELECT_CATALOG =
"SELECT id, relay_url, position, is_private, manifest_pubkey, epoch, endpoints FROM catalogs"
/**
* Visible listings: join the catalog for its manifest key, keep only selected catalogs, and for each
* app ID keep the row whose catalog comes first in selection order.
*/
private val SELECT_VISIBLE_APP = """
SELECT a.id, a.catalog_id, a.app_id, a.certificate_hash, a.app_event_created_at, a.pubkey, a.event_pubkey,
c.manifest_pubkey, a.name, a.summary, a.repository, a.version, a.version_code, a.channel, a.metadata,
a.about, a.security, a.facts
FROM apps a
JOIN catalogs c ON c.id = a.catalog_id
WHERE c.position IS NOT NULL
AND a.catalog_id = (
SELECT a2.catalog_id FROM apps a2 JOIN catalogs c2 ON c2.id = a2.catalog_id
WHERE a2.app_id = a.app_id AND c2.position IS NOT NULL
ORDER BY c2.position ASC LIMIT 1
)
""".trimIndent()
/** Columns [SearchRanker] needs. Full listings are loaded only for the ids it keeps. */
private val SELECT_SEARCH_KEY = """
SELECT a.id, a.app_id, a.name, s.embedding
FROM apps a
JOIN catalogs c ON c.id = a.catalog_id
LEFT JOIN apps_search s ON s.id = a.id
WHERE c.position IS NOT NULL
AND a.catalog_id = (
SELECT a2.catalog_id FROM apps a2 JOIN catalogs c2 ON c2.id = a2.catalog_id
WHERE a2.app_id = a.app_id AND c2.position IS NOT NULL
ORDER BY c2.position ASC LIMIT 1
)
""".trimIndent()
fun open(
path: String,
openConnection: (String) -> SQLiteConnection = ::openBundledOrJdbc,
): SQLiteConnection {
File(path).parentFile?.mkdirs()
val connection = openConnection(path)
connection.execSQL("PRAGMA foreign_keys = ON")
connection.execSQL("PRAGMA journal_mode = WAL")
val version = connection.prepare("PRAGMA user_version").use { statement ->
statement.step()
statement.getLong(0).toInt()
}
when (version) {
Schema.USER_VERSION -> Unit
0 -> {
connection.execSQL("BEGIN IMMEDIATE")
try {
Schema.statements.forEach(connection::execSQL)
connection.execSQL("PRAGMA user_version = ${Schema.USER_VERSION}")
connection.execSQL("COMMIT")
} catch (failure: Throwable) {
runCatching { connection.execSQL("ROLLBACK") }
throw failure
}
}
else -> error("unsupported Iolite schema version $version")
}
runCatching {
connection.prepare("PRAGMA wal_checkpoint(TRUNCATE)").use { it.step() }
}
return connection
}
}
}
internal fun openBundledOrJdbc(path: String): SQLiteConnection {
if (runCatching { Class.forName("org.sqlite.JDBC") }.isSuccess) {
return JdbcSqliteConnection(path)
}
return BundledSQLiteDriver().open(path)
}
private fun SQLiteStatement.toCatalog(): CatalogRecord {
val relayUrl = getText(1).normalizeRelayUrl()
val relays = decodeRelays(if (isNull(6)) "[]" else getText(6)).ifEmpty { listOf(relayUrl) }
return CatalogRecord(
id = getLong(0),
relayUrl = relayUrl,
relays = relays,
position = if (isNull(2)) null else getInt(2),
isPrivate = getLong(3) == 1L,
manifestPubkey = blobHexOrNull(4),
epoch = getLong(5),
)
}
private fun decodeRelays(raw: String): List<RelayUrl> {
if (raw.isBlank() || raw == "[]") return emptyList()
val array = JSONArray(raw)
return List(array.length()) { index -> array.getString(index).normalizeRelayUrl() }
}
private fun writeBytes(file: File, bytes: ByteArray) {
file.parentFile?.mkdirs()
val tmp = File(file.parentFile, "${file.name}.tmp")
tmp.writeBytes(bytes)
if (!tmp.renameTo(file)) {
tmp.copyTo(file, overwrite = true)
tmp.delete()
}
}
private fun SQLiteStatement.toApp(iconsDir: File): AppRecord = AppRecord(
id = getBlob(0),
catalogId = getLong(1),
appId = getText(2),
certificateHash = Hex.encode(getBlob(3)),
createdAt = getLong(4),
proofPubkey = blobHexOrNull(5),
eventPubkey = Hex.encode(getBlob(6)),
catalogManifestPubkey = blobHexOrNull(7),
name = getText(8),
summary = getText(9),
repository = textOrNull(10),
version = getText(11),
versionCode = getLong(12),
channel = textOrNull(13),
metadata = JSONObject(getText(14)),
about = getText(15),
security = getText(16),
facts = getText(17),
iconFile = File(iconsDir, "${getLong(1)}/${getText(2)}.webp").takeIf { it.isFile },
)
private fun <T> SQLiteStatement.rowOrNull(map: (SQLiteStatement) -> T): T? = if (step()) map(this) else null
private fun <T> SQLiteStatement.rows(map: SQLiteStatement.() -> T): List<T> = buildList {
while (step()) add(map())
}
@@ -0,0 +1,100 @@
package dev.zapstore.iolite
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.SQLiteStatement
import java.sql.Connection
import java.sql.DriverManager
import java.sql.PreparedStatement
import java.sql.ResultSet
import java.sql.Types
internal class JdbcSqliteConnection(path: String) : SQLiteConnection {
private val connection: Connection
init {
Class.forName("org.sqlite.JDBC")
connection = DriverManager.getConnection("jdbc:sqlite:$path")
connection.autoCommit = true
}
override fun prepare(sql: String): SQLiteStatement = JdbcSqliteStatement(connection.prepareStatement(sql))
override fun inTransaction(): Boolean = !connection.autoCommit
override fun close() {
connection.close()
}
}
private class JdbcSqliteStatement(
private val statement: PreparedStatement,
) : SQLiteStatement {
private var result: ResultSet? = null
private var executed = false
override fun bindBlob(index: Int, value: ByteArray) {
statement.setBytes(index, value)
}
override fun bindDouble(index: Int, value: Double) {
statement.setDouble(index, value)
}
override fun bindLong(index: Int, value: Long) {
statement.setLong(index, value)
}
override fun bindText(index: Int, value: String) {
statement.setString(index, value)
}
override fun bindNull(index: Int) {
statement.setNull(index, Types.NULL)
}
override fun getBlob(index: Int): ByteArray = result!!.getBytes(index + 1)
override fun getDouble(index: Int): Double = result!!.getDouble(index + 1)
override fun getLong(index: Int): Long = result!!.getLong(index + 1)
override fun getText(index: Int): String = result!!.getString(index + 1)
override fun isNull(index: Int): Boolean {
result!!.getObject(index + 1)
return result!!.wasNull()
}
override fun getColumnCount(): Int = result!!.metaData.columnCount
override fun getColumnName(index: Int): String = result!!.metaData.getColumnName(index + 1)
override fun getColumnType(index: Int): Int = result!!.metaData.getColumnType(index + 1)
override fun step(): Boolean {
if (!executed) {
executed = true
val hasResult = statement.execute()
result = if (hasResult) statement.resultSet else statement.resultSet
val rs = result ?: return false
return rs.next()
}
return result?.next() == true
}
override fun reset() {
result?.close()
result = null
executed = false
statement.clearParameters()
}
override fun clearBindings() {
statement.clearParameters()
}
override fun close() {
result?.close()
statement.close()
}
}
@@ -0,0 +1,325 @@
package dev.zapstore.iolite
import androidx.sqlite.SQLiteConnection
import java.io.ByteArrayInputStream
import java.security.Signature
import java.security.cert.CertificateFactory
import java.security.interfaces.ECPublicKey
import java.security.interfaces.RSAPublicKey
import java.util.Base64
import org.json.JSONArray
import org.json.JSONObject
class CatalogImportException(message: String) : Exception(message)
/** A catalog delete operation from `deletes.jsonl`. */
sealed interface CatalogDelete {
data class Listing(val appId: String) : CatalogDelete
data class Coordinate(val kind: Int, val pubkey: String, val d: String) : CatalogDelete
}
/**
* Derives `apps` and `certificate_proofs` rows from verified catalog events (kinds 32267, 30063, 3063, 30509).
* Each listing event writes its own columns. Sibling events are not required.
*/
internal object ListingRows {
val KINDS = setOf(Kinds.App, Kinds.Release, Kinds.Asset, Kinds.IdentityProof)
private val LISTING_KINDS = setOf(Kinds.App, Kinds.Release, Kinds.Asset)
private const val ANDROID_APK = "application/vnd.android.package-archive"
fun listingAppId(event: Event): String? = when (event.kind) {
Kinds.App -> event.dTag()
Kinds.Release, Kinds.Asset -> event.tagValue("i")
else -> null
}
fun isListingKind(kind: Int): Boolean = kind in LISTING_KINDS
/**
* Applies whatever listing events arrived for [appId].
* Each event writes its own columns. A missing sibling leaves those columns as stored.
*/
fun persistListing(tx: Tx, catalog: CatalogRecord, appId: String, events: List<Event>, device: DeviceProfile, now: Long) {
val db = tx.db
val app = events.firstOrNull { it.kind == Kinds.App }
val release = events.firstOrNull { it.kind == Kinds.Release && it.tagValue("c") == "main" }
val asset = events
.filter { it.kind == Kinds.Asset && it.tagValue("i") == appId && assetMatchesDevice(it, device) }
.maxWithOrNull(compareBy<Event> { it.tagValue("version_code")?.toLongOrNull() ?: -1 }.thenBy { it.id })
if (app == null && release == null && asset == null) return
val stored = storedListing(db, catalog.id, appId)
val certificate = asset?.tagValue("apk_certificate_hash")?.takeIf { Hex.isHex(it, 64) }
?: stored?.certificate
?: return
val eventPubkey = app?.pubkey ?: stored?.eventPubkey ?: release?.pubkey ?: asset?.pubkey ?: return
if (!Hex.isHex(eventPubkey, 64)) return
val metadata = JSONObject(stored?.metadata ?: "{}").apply {
if (app != null) putAppMetadata(app)
if (release != null) putReleaseMetadata(release)
if (asset != null) putAssetMetadata(asset)
}
db.prepare(
"""
INSERT INTO apps (
id, catalog_id, app_id, variant_id, certificate_hash, app_event_created_at, pubkey, event_pubkey,
name, summary, repository, version, version_code, channel, metadata
) VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'main', ?)
ON CONFLICT(catalog_id, app_id) DO UPDATE SET
certificate_hash = excluded.certificate_hash,
app_event_created_at = excluded.app_event_created_at,
pubkey = excluded.pubkey,
event_pubkey = excluded.event_pubkey,
name = excluded.name,
summary = excluded.summary,
repository = excluded.repository,
version = excluded.version,
version_code = excluded.version_code,
channel = excluded.channel,
metadata = excluded.metadata
""".trimIndent(),
).use { statement ->
statement.bindBlob(1, identity(catalog.relayUrl.url, appId))
statement.bindLong(2, catalog.id)
statement.bindText(3, appId)
statement.bindBlob(4, Hex.decode(certificate))
statement.bindLong(5, app?.createdAt ?: stored?.createdAt ?: 0)
statement.bindBlobOrNull(6, currentProofPubkey(db, catalog.id, certificate, now)?.let(Hex::decode))
statement.bindBlob(7, Hex.decode(eventPubkey))
statement.bindText(8, app?.tagValue("name")?.takeIf { it.isNotBlank() } ?: stored?.name ?: appId)
statement.bindText(9, if (app != null) app.tagValue("summary") ?: "" else stored?.summary ?: "")
statement.bindTextOrNull(10, if (app != null) app.tagValue("repository") else stored?.repository)
statement.bindText(
11,
asset?.tagValue("version")?.takeIf { it.isNotBlank() }
?: stored?.version
?: "",
)
statement.bindLong(12, asset?.tagValue("version_code")?.toLongOrNull() ?: stored?.versionCode ?: 0)
statement.bindText(13, metadata.toString())
statement.step()
}
tx.touch(Table.Apps)
}
private data class StoredListing(
val certificate: String,
val createdAt: Long,
val eventPubkey: String,
val name: String,
val summary: String,
val repository: String?,
val version: String,
val versionCode: Long,
val metadata: String,
)
private fun storedListing(db: SQLiteConnection, catalogId: Long, appId: String): StoredListing? =
db.prepare(
"""
SELECT certificate_hash, app_event_created_at, event_pubkey, name, summary, repository,
version, version_code, metadata
FROM apps WHERE catalog_id = ? AND app_id = ?
""".trimIndent(),
).use { statement ->
statement.bindLong(1, catalogId)
statement.bindText(2, appId)
if (!statement.step()) return null
StoredListing(
certificate = Hex.encode(statement.getBlob(0)),
createdAt = statement.getLong(1),
eventPubkey = Hex.encode(statement.getBlob(2)),
name = statement.getText(3),
summary = statement.getText(4),
repository = if (statement.isNull(5)) null else statement.getText(5),
version = statement.getText(6),
versionCode = statement.getLong(7),
metadata = statement.getText(8),
)
}
private fun JSONObject.putAppMetadata(app: Event) {
put("description", app.content)
put("icon", app.tagValue("icon") ?: "")
put("url", app.tagValue("url") ?: "")
put("license", app.tagValue("license") ?: "")
put("image", JSONArray(app.tagValues("image")))
put("app_event_id", app.id)
}
private fun JSONObject.putReleaseMetadata(release: Event) {
put("release_event_id", release.id)
put("release_created_at", release.createdAt)
put("release_notes", release.content)
}
private fun JSONObject.putAssetMetadata(asset: Event) {
put("asset_event_id", asset.id)
asset.tagValue("url")?.let { put("download_url", it) }
asset.tagValue("x")?.takeIf { it.isNotBlank() }?.let { put("apk_hash", it) }
asset.tagValue("filename")?.let { put("filename", it) }
asset.tagValue("m")?.let { put("mime", it) }
}
fun persistProof(tx: Tx, catalog: CatalogRecord, event: Event) {
validateProof(event)
val hash = event.tagValue("d")!!
val revoked = event.tagCount("revoked") > 0
val expiry = if (revoked) 0 else event.tagValue("expiry")!!.toLong()
tx.db.prepare(
"""
INSERT INTO certificate_proofs (
id, catalog_id, certificate_hash, pubkey, event_id, created_at, expiry, revoked, delegations
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(catalog_id, certificate_hash, pubkey) DO UPDATE SET
event_id = excluded.event_id,
created_at = excluded.created_at,
expiry = excluded.expiry,
revoked = excluded.revoked,
delegations = excluded.delegations
""".trimIndent(),
).use { statement ->
statement.bindBlob(1, identity(catalog.relayUrl.url, hash, event.pubkey))
statement.bindLong(2, catalog.id)
statement.bindBlob(3, Hex.decode(hash))
statement.bindBlob(4, Hex.decode(event.pubkey))
statement.bindBlob(5, Hex.decode(event.id))
statement.bindLong(6, event.createdAt)
statement.bindLong(7, expiry)
statement.bindLong(8, if (revoked) 1 else 0)
statement.bindText(9, JSONArray(event.tagValues("delegation")).toString())
statement.step()
}
tx.touch(Table.Proofs)
}
fun delete(tx: Tx, catalog: CatalogRecord, delete: CatalogDelete) {
when (delete) {
is CatalogDelete.Listing -> {
tx.db.prepare("DELETE FROM apps WHERE catalog_id = ? AND app_id = ?").use { statement ->
statement.bindLong(1, catalog.id)
statement.bindText(2, delete.appId)
statement.step()
}
tx.touch(Table.Apps)
}
is CatalogDelete.Coordinate -> {
if (delete.kind == Kinds.AppStack) {
EventRows.deleteStack(tx, delete.pubkey, delete.d)
return
}
if (delete.kind != Kinds.IdentityProof) return
tx.db.prepare(
"DELETE FROM certificate_proofs WHERE catalog_id = ? AND pubkey = ? AND certificate_hash = ?",
).use { statement ->
statement.bindLong(1, catalog.id)
statement.bindBlob(2, Hex.decode(delete.pubkey))
statement.bindBlob(3, Hex.decode(delete.d))
statement.step()
}
tx.touch(Table.Proofs)
}
}
}
/** App IDs stored for [catalog] that are not in [keep]. */
fun staleAppIds(db: SQLiteConnection, catalog: CatalogRecord, keep: Set<String>): List<String> =
db.prepare("SELECT app_id FROM apps WHERE catalog_id = ?").use { statement ->
statement.bindLong(1, catalog.id)
buildList {
while (statement.step()) {
val appId = statement.getText(0)
if (appId !in keep) add(appId)
}
}
}
/** Re-derives `apps.pubkey` from the currently active proofs. */
fun recomputeAppPubkeys(tx: Tx, catalogId: Long, now: Long) {
val db = tx.db
val rows = db.prepare("SELECT id, certificate_hash, pubkey FROM apps WHERE catalog_id = ?").use { apps ->
apps.bindLong(1, catalogId)
buildList {
while (apps.step()) add(Triple(apps.getBlob(0), Hex.encode(apps.getBlob(1)), apps.blobHexOrNull(2)))
}
}
var changed = false
for ((id, certificate, stored) in rows) {
val pubkey = currentProofPubkey(db, catalogId, certificate, now)
if (pubkey == stored) continue
db.prepare("UPDATE apps SET pubkey = ? WHERE id = ?").use { update ->
update.bindBlobOrNull(1, pubkey?.let(Hex::decode))
update.bindBlob(2, id)
update.step()
}
changed = true
}
if (changed) tx.touch(Table.Apps)
}
private fun currentProofPubkey(db: SQLiteConnection, catalogId: Long, certificateHash: String, now: Long): String? =
db.prepare(
"""
SELECT pubkey FROM certificate_proofs
WHERE catalog_id = ? AND certificate_hash = ? AND revoked = 0 AND expiry > ?
ORDER BY created_at DESC, event_id ASC LIMIT 1
""".trimIndent(),
).use { statement ->
statement.bindLong(1, catalogId)
statement.bindBlob(2, Hex.decode(certificateHash))
statement.bindLong(3, now)
if (statement.step()) Hex.encode(statement.getBlob(0)) else null
}
private fun identity(vararg parts: String): ByteArray =
Crypto.sha256(parts.joinToString("\u0000").toByteArray(Charsets.UTF_8))
// --- validation --------------------------------------------------------------------------------
private fun assetMatchesDevice(event: Event, device: DeviceProfile): Boolean {
if (!event.tagValue("variant").isNullOrBlank()) return false
if (event.tagValue("m") != ANDROID_APK) return false
val platforms = event.tagValues("f").filter { it.startsWith("android-") }
if (platforms.isNotEmpty() && platforms.none { it in device.platforms }) return false
val minSdk = event.tagValue("min_platform_version")?.toIntOrNull()
return minSdk == null || device.sdk >= minSdk
}
fun validateProof(event: Event) {
if (event.kind != Kinds.IdentityProof) throw CatalogImportException("expected proof")
if (event.tagCount("d") != 1) throw CatalogImportException("proof must have one d tag")
val hash = event.tagValue("d") ?: throw CatalogImportException("proof missing d")
if (!Hex.isHex(hash, 64)) throw CatalogImportException("proof d is not a sha256")
if (event.tagCount("revoked") > 0) {
if (event.tagCount("signature") != 0 || event.tagCount("expiry") != 0 ||
event.tagCount("cert") != 0 || event.tagCount("delegation") != 0
) {
throw CatalogImportException("revocation must omit active tags")
}
return
}
if (event.tagCount("signature") != 1 || event.tagCount("expiry") != 1) {
throw CatalogImportException("active proof requires signature and expiry")
}
val expiry = event.tagValue("expiry")?.toLongOrNull() ?: throw CatalogImportException("invalid expiry")
if (expiry <= event.createdAt) throw CatalogImportException("expiry must be after created_at")
val cert = event.tagValue("cert") ?: return
val der = Base64.getDecoder().decode(cert)
if (Hex.encode(Crypto.sha256(der)) != hash) throw CatalogImportException("embedded certificate hash does not match d")
verifyCertificateKey(der, event, expiry)
}
private fun verifyCertificateKey(der: ByteArray, event: Event, expiry: Long) {
val certificate = CertificateFactory.getInstance("X.509").generateCertificate(ByteArrayInputStream(der))
val message = "Verifying at ${event.createdAt} until $expiry that I control the following Nostr public key: ${event.pubkey}"
val signature = Base64.getDecoder().decode(event.tagValue("signature"))
val algorithm = when (certificate.publicKey) {
is RSAPublicKey -> "SHA256withRSA"
is ECPublicKey -> "SHA256withECDSA"
else -> throw CatalogImportException("unsupported certificate key type")
}
val verifier = Signature.getInstance(algorithm)
verifier.initVerify(certificate.publicKey)
verifier.update(message.toByteArray(Charsets.UTF_8))
if (!verifier.verify(signature)) throw CatalogImportException("invalid certificate-key signature")
}
}
@@ -0,0 +1,296 @@
package dev.zapstore.iolite
import java.io.File
import org.json.JSONArray
import org.json.JSONObject
/** Which SQLite tables a commit touched. Observers subscribe to the tables they read. */
enum class Table {
Catalogs,
Apps,
Proofs,
Profiles,
Stacks,
Zaps,
Comments,
Preferences,
Outbox,
}
data class CatalogRecord(
val id: Long,
/** Stable catalog identity: the first clearnet endpoint, or the first endpoint when every one is onion. */
val relayUrl: RelayUrl,
/** Endpoints from the signed manifest, clearnet and Tor. */
val relays: List<RelayUrl>,
val position: Int?,
val isPrivate: Boolean,
val manifestPubkey: String?,
val epoch: Long,
) {
/** Onion when Tor is in use and one is published; otherwise the first clearnet endpoint. */
fun syncRelay(useOnion: Boolean): RelayUrl {
val onion = relays.firstOrNull { it.isOnion }
if (useOnion && onion != null) return onion
return relays.firstOrNull { !it.isOnion } ?: relays.first()
}
}
/** `32267:<pubkey>:<app_id>` as carried by stack `a` tags and release `a` tags. */
data class AppCoordinate(val pubkey: String, val appId: String) {
override fun toString(): String = "${Kinds.App}:$pubkey:$appId"
companion object {
fun parse(value: String): AppCoordinate? {
val parts = value.split(":", limit = 3)
if (parts.size != 3 || parts[0] != Kinds.App.toString()) return null
if (parts[1].isBlank() || parts[2].isBlank()) return null
return AppCoordinate(parts[1], parts[2])
}
}
}
/**
* One visible catalog listing: the `apps` row for the main channel and no-variant asset.
* All values come from SQLite; nothing is re-derived from event JSON.
*/
class AppRecord(
val id: ByteArray,
val catalogId: Long,
val appId: String,
val certificateHash: String,
/** Kind 32267 timestamp; sorts recently updated lists. */
val createdAt: Long,
/** Active NIP-C1 proof owner; null when the certificate has no current proof. */
val proofPubkey: String?,
/** Kind 32267 signer. */
val eventPubkey: String,
/** The catalog manifest signer; an author equal to it is hidden. */
private val catalogManifestPubkey: String?,
val name: String,
val summary: String,
val repository: String?,
val version: String,
val versionCode: Long,
val channel: String?,
private val metadata: JSONObject,
/** Generated paragraph of what the app does. Bundle member `about`. */
val about: String = "",
/** Security paragraph, then one warning per line. Bundle member `security`. */
val security: String = "",
/** Scanner fact sheet. Bundle member `facts`. CSV columns are fact, value, and reason. */
val facts: String = "",
/** Local WebP from the bundle, when the file is present. */
val iconFile: File? = null,
) {
val coordinate: AppCoordinate get() = AppCoordinate(eventPubkey, appId)
val isVerified: Boolean get() = proofPubkey != null
/** Publisher to show: proof owner, else app signer, hidden when it is the catalog relay itself. */
val authorPubkey: String? get() = (proofPubkey ?: eventPubkey).takeUnless { it == catalogManifestPubkey }
val description: String get() = metadata.optString("description").ifBlank { summary }
/** Warning lines folded into [security]. Each line starts with a warning mark. */
val securityWarnings: String
get() = security.lineSequence().map { it.trim() }.filter { it.startsWith("⚠") }.joinToString("\n")
/** Security paragraph, without the warning lines. */
val securityBody: String
get() = security.lineSequence().filter { !it.trim().startsWith("⚠") }.joinToString("\n").trim()
val iconUrl: String? get() = metadata.string("icon")
val screenshots: List<String> get() = metadata.stringList("image")
val website: String? get() = metadata.string("url")
val license: String? get() = metadata.string("license")
val downloadUrl: String? get() = metadata.string("download_url")
val apkHash: String? get() = metadata.string("apk_hash")
val releaseNotes: String get() = metadata.optString("release_notes")
/**
* [facts] in file order, one row per key.
* The file is a CSV with columns fact, value, and reason.
* The sheet repeats a key once per library. A yes wins over a no.
*/
val factRows: List<AppFact>
get() = parseFactRows(facts)
/** Kind 30063 timestamp, in Unix seconds. */
val releasedAt: Long get() = metadata.optLong("release_created_at", createdAt)
override fun equals(other: Any?): Boolean = other is AppRecord && id.contentEquals(other.id)
override fun hashCode(): Int = id.contentHashCode()
override fun toString(): String = "AppRecord($appId@$version)"
}
/** Reads a facts CSV. A header is required. Repeated keys collapse to one row; a yes wins. */
internal fun parseFactRows(facts: String): List<AppFact> {
val table = parseCsv(facts)
if (table.isEmpty()) return emptyList()
val header = table.first().map { it.trim() }
val factIdx = header.indexOf("fact")
val valueIdx = header.indexOf("value")
val reasonIdx = header.indexOf("reason")
if (factIdx < 0 || valueIdx < 0) return emptyList()
val rows = LinkedHashMap<String, AppFact>()
for (record in table.drop(1)) {
val key = record.getOrNull(factIdx)?.trim().orEmpty()
if (key.isEmpty()) continue
val yes = when (record.getOrNull(valueIdx)?.trim()) {
"yes" -> true
"no" -> false
else -> continue
}
val reason = if (reasonIdx < 0) "" else record.getOrNull(reasonIdx)?.trim().orEmpty()
val previous = rows[key]
rows[key] = AppFact(
key = key,
yes = previous?.yes == true || yes,
reason = reason.ifBlank { previous?.reason.orEmpty() },
)
}
return rows.values.toList()
}
internal fun parseCsv(text: String): List<List<String>> {
val rows = mutableListOf<MutableList<String>>()
var row = mutableListOf<String>()
val field = StringBuilder()
var quoted = false
var i = 0
while (i < text.length) {
val char = text[i]
if (quoted) {
if (char == '"') {
if (i + 1 < text.length && text[i + 1] == '"') {
field.append('"')
i += 2
continue
}
quoted = false
} else {
field.append(char)
}
i++
continue
}
when (char) {
'"' -> quoted = true
',' -> {
row.add(field.toString())
field.clear()
}
'\n' -> {
row.add(field.toString())
field.clear()
rows.add(row)
row = mutableListOf()
}
'\r' -> Unit
else -> field.append(char)
}
i++
}
if (field.isNotEmpty() || row.isNotEmpty()) {
row.add(field.toString())
rows.add(row)
}
return rows
}
/** One scanner fact. [reason] is empty when the facts file has no phrase for this key. */
data class AppFact(
val key: String,
val yes: Boolean,
val reason: String = "",
)
/** Kind 0 projection. `name`/`displayName` are already trimmed to non-blank. */
data class ProfileRecord(
val pubkey: String,
val updatedAt: Long,
val eventId: String,
val name: String?,
val displayName: String?,
val about: String?,
val picture: String?,
val banner: String?,
val website: String?,
val nip05: String?,
) {
val displayNameOrNpub: String get() = displayName ?: name ?: pubkey.toNpub()
}
/** Kind 30267 projection. Private (device-encrypted) coordinates are already decrypted. */
data class StackRecord(
val pubkey: String,
val identifier: String,
val eventId: String,
val name: String,
val description: String,
val apps: List<AppCoordinate>,
val updatedAt: Long,
) {
val appIds: List<String> get() = apps.map(AppCoordinate::appId)
}
/** Kind 9735 projection. */
data class ZapRecord(
val eventId: String,
val pubkey: String,
val amountSats: Long,
val createdAt: Long,
val appId: String,
)
/** Kind 1111 projection. */
data class CommentRecord(
val eventId: String,
val pubkey: String,
val createdAt: Long,
val content: String,
val appId: String?,
val stack: String?,
val parentEventId: String?,
)
data class DeviceProfile(
val abis: List<String>,
val sdk: Int,
) {
/** `android-<abi>` platform tags as used by NIP-82 `f` tags. */
val platforms: List<String> get() = abis.map { if (it.startsWith("android-")) it else "android-$it" }
}
data class HttpResponse(
val status: Int,
val headers: Map<String, String>,
val body: ByteArray?,
)
fun interface HttpTransport {
fun get(url: String, headers: Map<String, String>): HttpResponse
}
interface WebSocketSession {
fun send(text: String)
fun close()
}
interface WebSocketListener {
fun onOpen()
fun onMessage(text: String)
fun onClosing(code: Int, reason: String)
fun onFailure(error: Throwable)
}
fun interface WebSocketFactory {
fun open(url: String, listener: WebSocketListener): WebSocketSession
}
internal fun JSONObject.string(key: String): String? = optString(key).takeIf { it.isNotBlank() }
internal fun JSONObject.stringList(key: String): List<String> = optJSONArray(key).toStringList()
internal fun JSONArray?.toStringList(): List<String> {
if (this == null) return emptyList()
return List(length()) { index -> optString(index) }.filter { it.isNotBlank() }
}
@@ -0,0 +1,22 @@
package dev.zapstore.iolite
import org.json.JSONArray
object Nip42 {
suspend fun auth(signer: Signer, relay: RelayUrl, challenge: String, createdAt: Long): Event {
require(challenge.isNotEmpty()) { "NIP-42 challenge must not be empty" }
val event = signer.sign(
createdAt = createdAt,
kind = Kinds.Auth,
tags = listOf(
listOf("relay", relay.url),
listOf("challenge", challenge),
),
content = "",
)
require(event.verify()) { "NIP-42 AUTH signature is invalid" }
return event
}
fun wire(event: Event): String = JSONArray().put("AUTH").put(event.toJsonObject()).toString()
}
@@ -0,0 +1,129 @@
package dev.zapstore.iolite
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.MessageDigest
import java.util.Base64
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
object Nip44 {
private const val VERSION: Byte = 0x02
private val salt = "nip44-v2".toByteArray(Charsets.UTF_8)
fun conversationKey(secretKey: ByteArray, publicKey: ByteArray): ByteArray {
require(secretKey.size == 32) { "secret key must be 32 bytes" }
require(publicKey.size == 32) { "public key must be 32 bytes" }
return hkdfExtract(salt, Crypto.sharedX(secretKey, publicKey))
}
fun encrypt(plaintext: String, conversationKey: ByteArray, nonce: ByteArray = Crypto.randomBytes(32)): String {
require(conversationKey.size == 32) { "invalid conversation_key length" }
require(nonce.size == 32) { "invalid nonce length" }
val (chachaKey, chachaNonce, hmacKey) = messageKeys(conversationKey, nonce)
val ciphertext = ChaCha20.xor(chachaKey, chachaNonce, pad(plaintext))
val mac = hmacAad(hmacKey, ciphertext, nonce)
val payload = ByteArray(1 + nonce.size + ciphertext.size + mac.size)
payload[0] = VERSION
nonce.copyInto(payload, 1)
ciphertext.copyInto(payload, 33)
mac.copyInto(payload, 33 + ciphertext.size)
return Base64.getEncoder().encodeToString(payload)
}
fun decrypt(payload: String, conversationKey: ByteArray): String {
require(conversationKey.size == 32) { "invalid conversation_key length" }
if (payload.isEmpty() || payload[0] == '#') throw IllegalArgumentException("unknown version")
if (payload.length < 132) throw IllegalArgumentException("invalid payload size")
val data = try {
Base64.getDecoder().decode(payload)
} catch (_: IllegalArgumentException) {
throw IllegalArgumentException("invalid payload")
}
if (data.size < 99) throw IllegalArgumentException("invalid data size")
if (data[0] != VERSION) throw IllegalArgumentException("unknown version ${data[0].toInt() and 0xff}")
val nonce = data.copyOfRange(1, 33)
val ciphertext = data.copyOfRange(33, data.size - 32)
val mac = data.copyOfRange(data.size - 32, data.size)
val (chachaKey, chachaNonce, hmacKey) = messageKeys(conversationKey, nonce)
val expected = hmacAad(hmacKey, ciphertext, nonce)
if (!MessageDigest.isEqual(expected, mac)) throw IllegalArgumentException("invalid MAC")
return unpad(ChaCha20.xor(chachaKey, chachaNonce, ciphertext))
}
internal fun messageKeys(conversationKey: ByteArray, nonce: ByteArray): Triple<ByteArray, ByteArray, ByteArray> {
require(conversationKey.size == 32) { "invalid conversation_key length" }
require(nonce.size == 32) { "invalid nonce length" }
val keys = hkdfExpand(conversationKey, nonce, 76)
return Triple(keys.copyOfRange(0, 32), keys.copyOfRange(32, 44), keys.copyOfRange(44, 76))
}
internal fun paddedLength(unpadded: Int): Int {
require(unpadded in 1..Int.MAX_VALUE)
if (unpadded <= 32) return 32
val nextPower = 1 shl (32 - Integer.numberOfLeadingZeros(unpadded - 1))
val chunk = if (nextPower <= 256) 32 else nextPower / 8
return chunk * (((unpadded - 1) / chunk) + 1)
}
private fun pad(plaintext: String): ByteArray {
val unpadded = plaintext.toByteArray(Charsets.UTF_8)
if (unpadded.isEmpty()) throw IllegalArgumentException("invalid plaintext length")
val prefix = if (unpadded.size >= 65_536) {
ByteBuffer.allocate(6).order(ByteOrder.BIG_ENDIAN).putShort(0).putInt(unpadded.size).array()
} else {
ByteBuffer.allocate(2).order(ByteOrder.BIG_ENDIAN).putShort(unpadded.size.toShort()).array()
}
val paddedLen = paddedLength(unpadded.size)
val out = ByteArray(prefix.size + paddedLen)
prefix.copyInto(out)
unpadded.copyInto(out, prefix.size)
return out
}
private fun unpad(padded: ByteArray): String {
if (padded.size < 2) throw IllegalArgumentException("invalid padding")
val firstTwo = ((padded[0].toInt() and 0xff) shl 8) or (padded[1].toInt() and 0xff)
val (unpaddedLen, prefixLen) = if (firstTwo == 0) {
if (padded.size < 6) throw IllegalArgumentException("invalid padding")
val len = ByteBuffer.wrap(padded, 2, 4).order(ByteOrder.BIG_ENDIAN).int
if (len < 65_536) throw IllegalArgumentException("invalid padding")
len to 6
} else {
firstTwo to 2
}
if (unpaddedLen <= 0 || padded.size != prefixLen + paddedLength(unpaddedLen)) {
throw IllegalArgumentException("invalid padding")
}
val unpadded = padded.copyOfRange(prefixLen, prefixLen + unpaddedLen)
if (unpadded.size != unpaddedLen) throw IllegalArgumentException("invalid padding")
return String(unpadded, Charsets.UTF_8)
}
private fun hmacAad(key: ByteArray, message: ByteArray, aad: ByteArray): ByteArray {
require(aad.size == 32) { "AAD associated data must be 32 bytes" }
return hmac(key, aad + message)
}
private fun hkdfExtract(salt: ByteArray, ikm: ByteArray): ByteArray = hmac(salt, ikm)
private fun hkdfExpand(prk: ByteArray, info: ByteArray, length: Int): ByteArray {
val hashLen = 32
val n = (length + hashLen - 1) / hashLen
val out = ByteArray(n * hashLen)
var previous = ByteArray(0)
var offset = 0
for (i in 1..n) {
previous = hmac(prk, previous + info + byteArrayOf(i.toByte()))
previous.copyInto(out, offset)
offset += hashLen
}
return out.copyOf(length)
}
private fun hmac(key: ByteArray, message: ByteArray): ByteArray {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(key, "HmacSHA256"))
return mac.doFinal(message)
}
}
@@ -1,119 +0,0 @@
package dev.zapstore.iolite
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
/**
* NIP-65 outbox routing on top of [Iolite].
*
* Resolution is local-first: kind-10002 relay lists are read from the local
* store and only fetched from [bootstrapRelays] when the TTL'd freshness cache
* ([QueryOptions.cachedFor]) reports a miss or staleness. A fetch
* that cannot complete within [resolveTimeout] resolves to whatever the local
* store holds.
*/
class OutboxRouter(
private val client: Iolite,
private val bootstrapRelays: Set<NormalizedRelayUrl>,
private val cacheDuration: Duration = 6.hours,
private val resolveTimeout: Duration = 10.seconds,
) {
init {
require(bootstrapRelays.isNotEmpty()) { "bootstrapRelays must not be empty" }
}
/**
* Read relays advertised by [pubkey] (unmarked `r` tags count as read+write,
* per NIP-65). Empty when no relay list is known or reachable.
*/
suspend fun resolveReadRelays(pubkey: String): Set<NormalizedRelayUrl> {
val state = client.query(
Filter(authors = listOf(pubkey), kinds = listOf(RELAY_LIST_KIND), limit = 1),
QueryOptions.localAndRemote(
relays = bootstrapRelays,
remoteMode = RemoteMode.OneShot(resolveTimeout),
cachedFor = cacheDuration,
),
).first { it.phase.isTerminal }
return state.items.maxByOrNull(Event::createdAt)?.readRelayUrls().orEmpty()
}
/**
* Runs [filters] against [fallbackRelays] immediately so local data flows without
* waiting, resolves the [authors]' read relays in parallel, then transparently
* restarts the remote side on the expanded relay set when resolution lands.
*
* With [unionWithFallback] the resolved relays are added to the fallback set;
* otherwise the resolved set replaces it whenever it is non-empty.
*/
fun queryWithOutbox(
filters: List<Filter>,
authors: List<String>,
fallbackRelays: Set<NormalizedRelayUrl>,
cachedFor: Duration? = null,
remoteMode: RemoteMode = RemoteMode.Stream,
unionWithFallback: Boolean = true,
): Flow<QueryState> = channelFlow {
fun options(relays: Set<NormalizedRelayUrl>) =
QueryOptions.localAndRemote(
relays = relays,
remoteMode = remoteMode,
cachedFor = cachedFor,
)
if (authors.isEmpty()) {
client.query(filters, options(fallbackRelays)).collect { send(it) }
return@channelFlow
}
val fallbackJob = launch {
client.query(filters, options(fallbackRelays)).collect { send(it) }
}
val resolved = authors
.map { async { resolveReadRelays(it) } }
.flatMap { it.await() }
.toSet()
val expanded = when {
resolved.isEmpty() -> fallbackRelays
unionWithFallback -> fallbackRelays + resolved
else -> resolved
}
if (expanded == fallbackRelays) {
fallbackJob.join()
} else {
fallbackJob.cancel()
fallbackJob.join()
client.query(filters, options(expanded)).collect { send(it) }
}
}
companion object {
const val RELAY_LIST_KIND = 10_002
private val QueryPhase.isTerminal: Boolean
get() = this == QueryPhase.Cached ||
this == QueryPhase.Complete ||
this == QueryPhase.TimedOut ||
this == QueryPhase.Failed
private fun Event.readRelayUrls(): Set<NormalizedRelayUrl> =
tags.asSequence()
.filter { tag -> tag.getOrNull(0) == "r" }
.filter { tag -> tag.getOrNull(2).let { marker -> marker == null || marker == "read" } }
.mapNotNull { tag -> tag.getOrNull(1) }
.filter { it.startsWith("wss://") }
.mapNotNull { runCatching { it.normalizeRelayUrl() }.getOrNull() }
.toSet()
}
}
@@ -0,0 +1,79 @@
package dev.zapstore.iolite
/**
* A typed query: the relay filters that fetch it, the tables whose commits re-run it, and the SQLite read
* that produces its items. Relay events flow through the store; the read never sees event JSON.
*/
class Query<T> internal constructor(
val filters: List<Filter>,
internal val tables: Set<Table>,
/** Authors whose NIP-65 read relays are added when `QueryOptions.useAuthorRelays` is set. */
internal val authors: List<String>,
/** Items shown by a `Remote` query before the relays answer. */
internal val empty: T,
internal val read: (IoliteStore, LocalSigner?) -> T,
) {
companion object {
fun profiles(pubkeys: Collection<String>): Query<Map<String, ProfileRecord>> {
val distinct = pubkeys.distinct()
return Query(
filters = listOf(Filter(authors = distinct, kinds = listOf(Kinds.Profile))),
tables = setOf(Table.Profiles),
authors = distinct,
empty = emptyMap(),
read = { store, _ -> store.profiles(distinct) },
)
}
fun profile(pubkey: String): Query<ProfileRecord?> = profiles(listOf(pubkey)).map { it[pubkey] }
fun relayList(pubkey: String): Query<Set<RelayUrl>?> = Query(
filters = listOf(Filter(authors = listOf(pubkey), kinds = listOf(Kinds.RelayList), limit = 1)),
tables = setOf(Table.Profiles),
authors = emptyList(),
empty = null,
read = { store, _ -> store.readRelays(pubkey) },
)
fun stacks(author: String, limit: Int? = null): Query<List<StackRecord>> = Query(
filters = listOf(Filter(authors = listOf(author), kinds = listOf(Kinds.AppStack), limit = limit)),
tables = setOf(Table.Stacks),
authors = listOf(author),
empty = emptyList(),
read = { store, signer -> store.stacks(author, limit = limit, deviceSigner = signer) },
)
fun stack(author: String, identifier: String): Query<StackRecord?> = Query(
filters = listOf(
Filter(authors = listOf(author), kinds = listOf(Kinds.AppStack), tags = mapOf("d" to listOf(identifier)), limit = 1),
),
tables = setOf(Table.Stacks),
authors = listOf(author),
empty = null,
read = { store, signer -> store.stacks(author, identifier, limit = 1, deviceSigner = signer).firstOrNull() },
)
fun zaps(app: AppCoordinate, limit: Int? = null): Query<List<ZapRecord>> = Query(
filters = listOf(
Filter(kinds = listOf(Kinds.Zap), tags = mapOf("a" to listOf(app.toString())), limit = limit),
),
tables = setOf(Table.Zaps),
authors = emptyList(),
empty = emptyList(),
read = { store, _ -> store.zaps(app.appId, limit) },
)
fun comments(app: AppCoordinate, limit: Int? = null): Query<List<CommentRecord>> = Query(
filters = listOf(
Filter(kinds = listOf(Kinds.Comment), tags = mapOf("A" to listOf(app.toString())), limit = limit),
),
tables = setOf(Table.Comments),
authors = listOf(app.pubkey),
empty = emptyList(),
read = { store, _ -> store.comments(app.appId) },
)
}
fun <R> map(transform: (T) -> R): Query<R> =
Query(filters, tables, authors, transform(empty)) { store, signer -> transform(read(store, signer)) }
}
@@ -1,7 +1,5 @@
package dev.zapstore.iolite
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import java.io.ByteArrayOutputStream
import java.io.DataOutputStream
import java.security.MessageDigest
@@ -11,7 +9,7 @@ internal object QueryFingerprint {
fun create(
filters: List<Filter>,
relays: Set<NormalizedRelayUrl>,
relays: Set<RelayUrl>,
): String {
val encodedFilters = filters.map(::encodeFilter).sortedWith(::compareBytes)
val canonical = ByteArrayOutputStream().use { bytes ->
@@ -38,7 +36,6 @@ internal object QueryFingerprint {
output.writeNullableStrings(filter.authors)
output.writeNullableInts(filter.kinds)
output.writeNullableStringMap(filter.tags)
output.writeNullableStringMap(filter.tagsAll)
output.writeNullableLong(filter.since)
output.writeNullableLong(filter.until)
output.writeNullableInt(filter.limit)
@@ -1,149 +0,0 @@
package dev.zapstore.iolite
import android.content.Context
import android.content.SharedPreferences
import java.io.File
import java.nio.file.Files
import java.nio.file.attribute.BasicFileAttributes
import java.security.MessageDigest
import java.util.UUID
import kotlin.time.Duration
internal fun interface EpochMillisClock {
fun now(): Long
}
internal interface QueryRefreshCache {
fun lastRefresh(fingerprint: String): Long?
fun recordRefresh(fingerprint: String, epochMillis: Long): Boolean
}
internal data class QueryFreshness(
val isFresh: Boolean,
val remainingMillis: Long,
)
internal fun QueryRefreshCache.freshness(
fingerprint: String,
cachedFor: Duration,
now: Long,
): QueryFreshness {
val refreshedAt = lastRefresh(fingerprint)
?: return QueryFreshness(isFresh = false, remainingMillis = 0)
if (refreshedAt <= 0 || now < refreshedAt) {
return QueryFreshness(isFresh = false, remainingMillis = 0)
}
val cachedForMillis = maxOf(1, cachedFor.inWholeMilliseconds)
val age = now - refreshedAt
val remaining = cachedForMillis - age
return QueryFreshness(
isFresh = remaining > 0,
remainingMillis = remaining.coerceAtLeast(0),
)
}
internal class InMemoryQueryRefreshCache : QueryRefreshCache {
private val entries = mutableMapOf<String, Long>()
override fun lastRefresh(fingerprint: String): Long? = synchronized(entries) {
entries[fingerprint]
}
override fun recordRefresh(fingerprint: String, epochMillis: Long): Boolean = synchronized(entries) {
if (epochMillis <= 0) return@synchronized false
entries[fingerprint] = epochMillis
true
}
}
internal class SharedPreferencesQueryRefreshCache private constructor(
private val preferences: SharedPreferences,
private val entryPrefix: String,
) : QueryRefreshCache {
private val lock = Any()
override fun lastRefresh(fingerprint: String): Long? = runCatching {
preferences.takeIf { it.contains(entryPrefix + fingerprint) }
?.getLong(entryPrefix + fingerprint, 0L)
?.takeIf { it > 0 }
}.getOrNull()
override fun recordRefresh(fingerprint: String, epochMillis: Long): Boolean {
if (epochMillis <= 0) return false
synchronized(lock) {
val key = entryPrefix + fingerprint
val currentEntries = preferences.all.asSequence()
.filter { (entryKey, value) -> entryKey.startsWith(entryPrefix) && value is Long }
.sortedBy { (_, value) -> value as Long }
.toList()
val removals = if (preferences.contains(key)) {
0
} else {
(currentEntries.size - MAX_ENTRIES + 1).coerceAtLeast(0)
}
val editor = preferences.edit()
currentEntries.take(removals).forEach { (entryKey, _) -> editor.remove(entryKey) }
val committed = editor.putLong(key, epochMillis).commit()
if (!committed) {
// commit updates SharedPreferences memory before attempting disk I/O.
// Remove the optimistic value so this process also fails open.
preferences.edit().remove(key).apply()
}
return committed
}
}
companion object {
private const val PREFERENCES_NAME = "dev.zapstore.iolite.query-refresh-v1"
private const val MAX_ENTRIES = 1_024
fun create(
context: Context,
databasePath: String,
databaseExisted: Boolean = File(databasePath).exists(),
): SharedPreferencesQueryRefreshCache {
val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
val pathHash = sha256(databasePath)
val generationKey = "database:$pathHash:generation"
val identityKey = "database:$pathHash:identity"
val previousGeneration = runCatching { preferences.getString(generationKey, null) }.getOrNull()
val previousIdentity = runCatching { preferences.getString(identityKey, null) }.getOrNull()
val currentIdentity = databaseIdentity(databasePath)
val canReuseGeneration =
databaseExisted &&
currentIdentity != null &&
currentIdentity == previousIdentity &&
previousGeneration != null
val generation = previousGeneration?.takeIf { canReuseGeneration } ?: UUID.randomUUID().toString()
if (!canReuseGeneration) {
val oldPrefix = "refresh:$pathHash:"
val editor = preferences.edit()
.putString(generationKey, generation)
.putString(identityKey, currentIdentity)
preferences.all.keys.filter { it.startsWith(oldPrefix) }.forEach(editor::remove)
editor.commit()
}
return SharedPreferencesQueryRefreshCache(
preferences = preferences,
entryPrefix = "refresh:$pathHash:$generation:",
)
}
private fun sha256(value: String): String =
MessageDigest.getInstance("SHA-256")
.digest(value.toByteArray(Charsets.UTF_8))
.joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) }
private fun databaseIdentity(databasePath: String): String? = runCatching {
val attributes = Files.readAttributes(File(databasePath).toPath(), BasicFileAttributes::class.java)
val fileKey = attributes.fileKey()?.toString().orEmpty()
val createdAt = attributes.creationTime().toMillis()
if (fileKey.isEmpty() && createdAt <= 0) return@runCatching null
sha256("$fileKey:$createdAt")
}.getOrNull()
}
}
@@ -1,20 +1,38 @@
package dev.zapstore.iolite
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlin.time.Duration
enum class SourceMode {
/** SQLite only. Emits on every commit touching the observed tables. */
Local,
/** SQLite first, then relays; re-emits after each commit. */
LocalAndRemote,
/** Empty first, then relays; re-emits after each commit. */
Remote,
}
sealed interface RemoteMode {
/** Ends 200 ms after the first EOSE, or at [timeout]. */
data class OneShot(val timeout: Duration? = null) : RemoteMode {
init {
require(timeout == null || (timeout.isFinite() && timeout.isPositive())) {
"One-shot timeout must be finite and positive"
}
}
}
/** Keeps the subscription open until the collector is cancelled. */
data object Stream : RemoteMode
}
data class QueryOptions(
val sourceMode: SourceMode = SourceMode.Local,
val remoteMode: RemoteMode? = null,
val relays: Set<NormalizedRelayUrl> = emptySet(),
val relays: Set<RelayUrl> = emptySet(),
/** Skip relay work when the same query completed within this duration. Local-and-remote only. */
val cachedFor: Duration? = null,
/** Also query the NIP-65 read relays of the observed authors. */
val useAuthorRelays: Boolean = false,
) {
init {
when (sourceMode) {
@@ -22,6 +40,7 @@ data class QueryOptions(
require(remoteMode == null) { "Local queries cannot have a remote mode" }
require(relays.isEmpty()) { "Local queries cannot have relays" }
require(cachedFor == null) { "Local queries cannot be cached" }
require(!useAuthorRelays) { "Local queries cannot use author relays" }
}
SourceMode.LocalAndRemote,
SourceMode.Remote,
@@ -38,42 +57,41 @@ data class QueryOptions(
}
}
val isRemote: Boolean get() = sourceMode != SourceMode.Local
companion object {
fun local(): QueryOptions = QueryOptions()
fun localAndRemote(
relays: Set<NormalizedRelayUrl>,
relays: Set<RelayUrl>,
remoteMode: RemoteMode,
cachedFor: Duration? = null,
): QueryOptions = QueryOptions(SourceMode.LocalAndRemote, remoteMode, relays, cachedFor)
useAuthorRelays: Boolean = false,
): QueryOptions = QueryOptions(SourceMode.LocalAndRemote, remoteMode, relays, cachedFor, useAuthorRelays)
fun remote(
relays: Set<NormalizedRelayUrl>,
relays: Set<RelayUrl>,
remoteMode: RemoteMode,
): QueryOptions = QueryOptions(SourceMode.Remote, remoteMode, relays)
useAuthorRelays: Boolean = false,
): QueryOptions = QueryOptions(SourceMode.Remote, remoteMode, relays, useAuthorRelays = useAuthorRelays)
}
}
sealed interface RemoteMode {
data class OneShot(
val timeout: Duration? = null,
) : RemoteMode {
init {
require(timeout == null || (timeout.isFinite() && timeout.isPositive())) {
"One-shot timeout must be finite and positive"
}
}
}
data object Stream : RemoteMode
}
data class QueryState(
val items: List<Event>,
/** One emission of a collected query: the current SQLite read plus relay progress. */
data class QueryState<out T>(
val items: T,
val phase: QueryPhase,
val relays: Map<NormalizedRelayUrl, RelayQueryState> = emptyMap(),
val relays: Map<RelayUrl, RelayQueryState> = emptyMap(),
val error: QueryError? = null,
)
) {
/** True while the first remote request is still unfinished. */
val isLoading: Boolean get() = phase == QueryPhase.Connecting || phase == QueryPhase.CatchingUp
/** True once no further relay work will happen for this session. */
val isTerminal: Boolean get() = phase.isTerminal
fun <R> map(transform: (T) -> R): QueryState<R> = QueryState(transform(items), phase, relays, error)
}
sealed interface QueryPhase {
data object LocalOnly : QueryPhase
@@ -84,6 +102,9 @@ sealed interface QueryPhase {
data object Complete : QueryPhase
data object TimedOut : QueryPhase
data object Failed : QueryPhase
val isTerminal: Boolean
get() = this == LocalOnly || this == Cached || this == Complete || this == TimedOut || this == Failed
}
internal fun isLegalPhaseTransition(from: QueryPhase?, to: QueryPhase): Boolean = when {
@@ -123,40 +144,8 @@ enum class RelayConnectionState {
sealed interface QueryError {
val message: String
data class InvalidQuery(override val message: String) : QueryError
data class UnsupportedLocalProjection(override val message: String) : QueryError
data class RelayFailure(
val relay: NormalizedRelayUrl,
override val message: String,
) : QueryError
data class VerificationRejected(
val relay: NormalizedRelayUrl,
val eventId: String?,
override val message: String,
) : QueryError
data class ProtocolViolation(
val relay: NormalizedRelayUrl,
val eventId: String?,
override val message: String,
) : QueryError
data class PersistenceFailure(
val eventId: String?,
override val message: String,
) : QueryError
data class IngestionSaturated(
val relay: NormalizedRelayUrl,
override val message: String,
) : QueryError
data class Timeout(
val duration: Duration,
override val message: String,
) : QueryError
data class RelayFailure(val relay: RelayUrl, override val message: String) : QueryError
data class VerificationRejected(val relay: RelayUrl, val eventId: String?, override val message: String) : QueryError
data class Timeout(val duration: Duration, override val message: String) : QueryError
data class IncompatibleDependency(override val message: String) : QueryError
data class Lifecycle(override val message: String) : QueryError
}
@@ -0,0 +1,286 @@
package dev.zapstore.iolite
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.json.JSONArray
import org.json.JSONObject
internal class RelayPool(
private val factory: WebSocketFactory,
private val scope: CoroutineScope,
private val signer: Signer?,
private val nowSeconds: () -> Long,
) {
private val connections = ConcurrentHashMap<RelayUrl, RelayConnection>()
private val trafficEnabled = AtomicBoolean(true)
fun setTrafficEnabled(enabled: Boolean) {
trafficEnabled.set(enabled)
if (!enabled) {
connections.values.forEach { it.close("traffic disabled") }
} else {
connections.values.forEach { it.ensureOpen() }
}
}
fun refreshConnections() {
connections.values.forEach { it.reconnect("refresh") }
}
fun subscribe(
id: String,
filters: List<Filter>,
relays: Set<RelayUrl>,
onEvent: (RelayUrl, Event) -> Unit,
onEose: (RelayUrl) -> Unit,
onState: (RelayUrl, RelayQueryState) -> Unit,
): () -> Unit {
val subscription = Subscription(id, filters, onEvent, onEose, onState)
relays.forEach { url -> connection(url).add(subscription) }
return {
relays.forEach { url -> connections[url]?.remove(id) }
}
}
fun publish(event: Event, relays: Set<RelayUrl>, onOk: (RelayUrl, Boolean, String) -> Unit) {
relays.forEach { url -> connection(url).publish(event, onOk) }
}
fun close() {
trafficEnabled.set(false)
connections.values.forEach { it.close("closed") }
connections.clear()
}
private fun connection(url: RelayUrl): RelayConnection =
connections.getOrPut(url) {
RelayConnection(
url = url,
factory = factory,
scope = scope,
signer = signer,
nowSeconds = nowSeconds,
trafficEnabled = trafficEnabled,
)
}.also { it.ensureOpen() }
}
private class Subscription(
val id: String,
val filters: List<Filter>,
val onEvent: (RelayUrl, Event) -> Unit,
val onEose: (RelayUrl) -> Unit,
val onState: (RelayUrl, RelayQueryState) -> Unit,
)
private class RelayConnection(
private val url: RelayUrl,
private val factory: WebSocketFactory,
private val scope: CoroutineScope,
private val signer: Signer?,
private val nowSeconds: () -> Long,
private val trafficEnabled: AtomicBoolean,
) {
private val lock = Any()
private val subscriptions = ConcurrentHashMap<String, Subscription>()
private val okWaiters = ConcurrentHashMap<String, CopyOnWriteArrayList<(RelayUrl, Boolean, String) -> Unit>>()
private val pendingEvents = ConcurrentHashMap<String, Event>()
private val generation = AtomicLong(0)
private var session: WebSocketSession? = null
private var reconnectJob: Job? = null
private var authEventId: String? = null
private var lastError: String? = null
private var connected = false
fun add(subscription: Subscription) {
subscriptions[subscription.id] = subscription
ensureOpen()
synchronized(lock) {
if (connected) sendReq(subscription)
}
emitState(RelayConnectionState.Connecting, eose = false)
}
fun remove(id: String) {
subscriptions.remove(id) ?: return
synchronized(lock) {
if (connected) session?.send(JSONArray().put("CLOSE").put(id).toString())
}
if (subscriptions.isEmpty() && pendingEvents.isEmpty()) {
close("idle")
}
}
fun publish(event: Event, onOk: (RelayUrl, Boolean, String) -> Unit) {
pendingEvents[event.id] = event
okWaiters.getOrPut(event.id) { CopyOnWriteArrayList() }.add(onOk)
ensureOpen()
synchronized(lock) {
if (connected) sendEvent(event)
}
}
fun ensureOpen() {
if (!trafficEnabled.get()) return
synchronized(lock) {
if (session != null || reconnectJob?.isActive == true) return
openLocked()
}
}
fun reconnect(reason: String) {
close(reason)
ensureOpen()
}
fun close(reason: String) {
synchronized(lock) {
reconnectJob?.cancel()
reconnectJob = null
runCatching { session?.close() }
session = null
connected = false
authEventId = null
}
emitState(RelayConnectionState.Disconnected, eose = false, error = reason)
}
private fun openLocked() {
emitState(RelayConnectionState.Connecting, eose = false)
val listener = object : WebSocketListener {
override fun onOpen() {
synchronized(lock) {
connected = true
lastError = null
generation.incrementAndGet()
subscriptions.values.forEach(::sendReq)
pendingEvents.values.forEach(::sendEvent)
}
emitState(RelayConnectionState.Connected, eose = false)
}
override fun onMessage(text: String) = handle(text)
override fun onClosing(code: Int, reason: String) {
synchronized(lock) {
connected = false
session = null
}
emitState(RelayConnectionState.Disconnected, eose = false, error = reason)
scheduleReconnect()
}
override fun onFailure(error: Throwable) {
synchronized(lock) {
connected = false
session = null
lastError = error.message
}
emitState(RelayConnectionState.Disconnected, eose = false, error = error.message)
scheduleReconnect()
}
}
session = try {
factory.open(url.url, listener)
} catch (failure: Throwable) {
lastError = failure.message
emitState(RelayConnectionState.Disconnected, eose = false, error = failure.message)
scheduleReconnect()
null
}
}
private fun handle(text: String) {
val message = try {
JSONArray(text)
} catch (_: Exception) {
return
}
if (message.length() < 2) return
when (message.optString(0)) {
"EVENT" -> {
if (message.length() < 3) return
val subId = message.optString(1)
val raw = message.opt(2) as? JSONObject ?: return
val event = runCatching { Event.parse(raw) }.getOrNull() ?: return
val subscription = subscriptions[subId] ?: return
subscription.onEvent(url, event)
}
"EOSE" -> subscriptions[message.optString(1)]?.onEose(url)
"OK" -> {
val eventId = message.optString(1)
val accepted = message.optBoolean(2)
val reason = message.optString(3)
if (eventId == authEventId && accepted) {
synchronized(lock) { subscriptions.values.forEach(::sendReq) }
}
val waiters = okWaiters.remove(eventId)
if (accepted) pendingEvents.remove(eventId)
waiters?.forEach { it(url, accepted, reason) }
}
"AUTH" -> {
val challenge = message.optString(1)
val current = signer
if (current == null) {
lastError = "auth required"
emitState(RelayConnectionState.Connected, eose = false, error = lastError)
return
}
scope.launch {
runCatching {
val event = Nip42.auth(current, url, challenge, nowSeconds())
authEventId = event.id
synchronized(lock) { session?.send(Nip42.wire(event)) }
}.onFailure { failure ->
lastError = failure.message
emitState(RelayConnectionState.Connected, eose = false, error = lastError)
}
}
}
"CLOSED" -> {
val subId = message.optString(1)
lastError = message.optString(2).ifBlank { "closed" }
subscriptions[subId]?.onEose(url)
emitState(RelayConnectionState.Connected, eose = true, error = lastError)
}
}
}
private fun sendReq(subscription: Subscription) {
val payload = JSONArray().put("REQ").put(subscription.id)
subscription.filters.forEach { payload.put(it.toJsonObject()) }
session?.send(payload.toString())
}
private fun sendEvent(event: Event) {
session?.send(JSONArray().put("EVENT").put(event.toJsonObject()).toString())
}
private fun scheduleReconnect() {
if (!trafficEnabled.get()) return
if (subscriptions.isEmpty() && pendingEvents.isEmpty()) return
synchronized(lock) {
if (reconnectJob?.isActive == true || session != null) return
reconnectJob = scope.launch {
delay(500)
synchronized(lock) {
reconnectJob = null
if (session == null && trafficEnabled.get()) openLocked()
}
}
}
}
private fun emitState(connection: RelayConnectionState, eose: Boolean, error: String? = lastError) {
val state = RelayQueryState(generation.get(), connection, eose, error)
subscriptions.values.forEach { it.onState(url, state) }
}
}
internal fun okThreshold(relayCount: Int): Int = if (relayCount <= 2) 1 else 2
@@ -0,0 +1,45 @@
package dev.zapstore.iolite
@JvmInline
value class RelayUrl(val url: String) {
override fun toString(): String = url
}
fun String.normalizeRelayUrl(): RelayUrl {
val trimmed = trim()
require(trimmed.startsWith("ws://") || trimmed.startsWith("wss://")) {
"relay URL must be ws:// or wss://"
}
val withoutTrailing = trimmed.trimEnd('/')
return RelayUrl(withoutTrailing)
}
fun String.httpOriginToRelayUrl(): RelayUrl {
val trimmed = trim().trimEnd('/')
val ws = when {
trimmed.startsWith("https://", ignoreCase = true) -> "wss://" + trimmed.substring(8)
trimmed.startsWith("http://", ignoreCase = true) -> "ws://" + trimmed.substring(7)
else -> trimmed
}
return ws.normalizeRelayUrl()
}
fun RelayUrl.originHttpUrl(): String =
url.replaceFirst("wss://", "https://").replaceFirst("ws://", "http://")
/** `GET /deltas` on the relay origin; path, query, and fragment are ignored. */
fun RelayUrl.deltasUrl(from: Long): String = origin() + "/deltas?from=$from"
/** True when the host is a Tor onion service. */
val RelayUrl.isOnion: Boolean
get() = url.substringAfter("://").substringBefore('/').substringBefore(':').endsWith(".onion")
private fun RelayUrl.origin(): String {
val http = originHttpUrl().substringBefore('#').substringBefore('?')
val schemeEnd = http.indexOf("://")
require(schemeEnd > 0) { "invalid relay URL $url" }
val afterScheme = http.substring(schemeEnd + 3)
val slash = afterScheme.indexOf('/')
val authority = (if (slash == -1) afterScheme else afterScheme.substring(0, slash)).trimEnd('/')
return http.substring(0, schemeEnd + 3) + authority
}
@@ -0,0 +1,160 @@
package dev.zapstore.iolite
internal object Schema {
const val USER_VERSION = 1
const val APPS_SEARCH = """
CREATE TABLE apps_search (
id BLOB PRIMARY KEY REFERENCES apps(id) ON DELETE CASCADE,
embedding BLOB,
CHECK (embedding IS NULL OR length(embedding) = 768)
)
"""
val statements: List<String> = listOf(
// Last successful relay refresh per query fingerprint; backs QueryOptions.cachedFor.
"""
CREATE TABLE query_refresh (
fingerprint TEXT PRIMARY KEY,
refreshed_at INTEGER NOT NULL
)
""".trimIndent(),
"""
CREATE TABLE catalogs (
id INTEGER PRIMARY KEY,
relay_url TEXT NOT NULL UNIQUE,
position INTEGER UNIQUE,
is_private INTEGER NOT NULL DEFAULT 0 CHECK (is_private IN (0, 1)),
manifest_pubkey BLOB,
epoch INTEGER NOT NULL DEFAULT 0,
endpoints TEXT NOT NULL DEFAULT '[]'
)
""".trimIndent(),
"""
CREATE TABLE apps (
id BLOB PRIMARY KEY,
catalog_id INTEGER NOT NULL
REFERENCES catalogs(id) ON DELETE CASCADE,
app_id TEXT NOT NULL,
variant_id BLOB,
certificate_hash BLOB NOT NULL,
app_event_created_at INTEGER NOT NULL,
pubkey BLOB,
event_pubkey BLOB NOT NULL,
name TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
repository TEXT,
version TEXT NOT NULL,
version_code INTEGER NOT NULL,
channel TEXT,
metadata TEXT NOT NULL DEFAULT '{}',
about TEXT NOT NULL DEFAULT '',
security TEXT NOT NULL DEFAULT '',
facts TEXT NOT NULL DEFAULT ''
)
""".trimIndent(),
"CREATE UNIQUE INDEX apps_identity_idx ON apps(catalog_id, app_id)",
"CREATE INDEX apps_pubkey_idx ON apps(pubkey)",
"CREATE INDEX apps_event_pubkey_idx ON apps(event_pubkey)",
"CREATE INDEX apps_updated_idx ON apps(app_event_created_at DESC)",
"CREATE INDEX apps_visible_idx ON apps(app_id)",
"CREATE INDEX apps_version_idx ON apps(app_id, version_code DESC)",
"""
CREATE TABLE certificate_proofs (
id BLOB PRIMARY KEY,
catalog_id INTEGER NOT NULL
REFERENCES catalogs(id) ON DELETE CASCADE,
certificate_hash BLOB NOT NULL,
pubkey BLOB NOT NULL,
event_id BLOB NOT NULL,
created_at INTEGER NOT NULL,
expiry INTEGER NOT NULL,
revoked INTEGER NOT NULL DEFAULT 0
CHECK (revoked IN (0, 1)),
delegations TEXT NOT NULL DEFAULT '[]',
UNIQUE (catalog_id, certificate_hash, pubkey)
)
""".trimIndent(),
"CREATE INDEX certificate_proofs_certificate_idx ON certificate_proofs(catalog_id, certificate_hash)",
"""
CREATE TABLE app_variants (
id BLOB PRIMARY KEY,
app_id BLOB NOT NULL
REFERENCES apps(id) ON DELETE CASCADE,
channel TEXT NOT NULL DEFAULT '',
variant TEXT NOT NULL DEFAULT '',
certificate_hash BLOB NOT NULL,
version TEXT NOT NULL,
version_code INTEGER NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}',
UNIQUE (app_id, channel, variant),
CHECK (channel <> '' OR variant <> '')
)
""".trimIndent(),
"CREATE INDEX app_variants_version_idx ON app_variants(app_id, channel, version_code DESC)",
"""
CREATE TABLE preferences (
identifier TEXT PRIMARY KEY,
updated_at INTEGER NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}'
)
""".trimIndent(),
APPS_SEARCH.trimIndent(),
"""
CREATE TABLE comments (
event_id BLOB PRIMARY KEY,
pubkey BLOB NOT NULL,
created_at INTEGER NOT NULL,
content TEXT NOT NULL,
app_id TEXT,
stack TEXT,
parent_event_id BLOB,
metadata TEXT NOT NULL DEFAULT '{}'
)
""".trimIndent(),
"CREATE INDEX comments_app_idx ON comments(app_id, created_at DESC)",
"CREATE INDEX comments_stack_idx ON comments(stack, created_at DESC)",
"CREATE INDEX comments_parent_idx ON comments(parent_event_id)",
"""
CREATE TABLE zaps (
event_id BLOB PRIMARY KEY,
pubkey BLOB NOT NULL,
amount_sats INTEGER NOT NULL CHECK (amount_sats > 0),
created_at INTEGER NOT NULL,
app_id TEXT NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}'
)
""".trimIndent(),
"CREATE INDEX zaps_app_idx ON zaps(app_id, created_at DESC)",
"CREATE INDEX zaps_pubkey_idx ON zaps(pubkey, created_at DESC)",
"""
CREATE TABLE stacks (
pubkey BLOB NOT NULL,
identifier TEXT NOT NULL,
event_id BLOB NOT NULL,
name TEXT NOT NULL,
description TEXT,
public_apps TEXT NOT NULL DEFAULT '[]',
updated_at INTEGER NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}',
PRIMARY KEY (pubkey, identifier)
)
""".trimIndent(),
"CREATE INDEX stacks_updated_idx ON stacks(updated_at DESC)",
"""
CREATE TABLE profiles (
pubkey BLOB PRIMARY KEY,
updated_at INTEGER NOT NULL,
name TEXT,
picture_url TEXT,
metadata TEXT NOT NULL DEFAULT '{}'
)
""".trimIndent(),
"""
CREATE TABLE outbox (
event_id BLOB PRIMARY KEY,
event_json TEXT NOT NULL
)
""".trimIndent(),
)
}
@@ -0,0 +1,119 @@
package dev.zapstore.iolite
import java.util.PriorityQueue
/** leaf-ir-v1 document and query width. */
const val EMBEDDING_DIMS = 768
/** Queries are bounded to the same size as `POST /search`. */
const val MAX_QUERY_BYTES = 256
/**
* int8 dot of two leaf-ir-v1 vectors. Cosine is about `dot * (0.3/127)^2`.
* A maps query is about 100000 against a maps listing and about 33000 against a password manager.
*/
internal const val MIN_VECTOR_DOT = 57_348
/** About cosine 0.45, so a close vector can outrank a name that merely shares a prefix. */
internal const val PREFIX_BOOST = 80_645
/** Larger than any int8 dot, so an exact name or app ID stays above semantic hits. */
internal const val EXACT_BOOST = 20_000_000
/** Trimmed, whitespace-collapsed, lowercased, and cut to [MAX_QUERY_BYTES]. */
fun normalizeSearchQuery(query: String): String {
val folded = foldSearchText(query)
val bytes = folded.encodeToByteArray()
if (bytes.size <= MAX_QUERY_BYTES) return folded
var end = MAX_QUERY_BYTES
while (end > 0 && bytes[end].toInt() and 0xC0 == 0x80) end--
return bytes.decodeToString(0, end).trimEnd()
}
internal fun foldSearchText(value: String): String {
val out = StringBuilder(value.length)
var pendingSpace = false
for (ch in value.trim()) {
if (ch.isWhitespace()) {
if (out.isNotEmpty()) pendingSpace = true
continue
}
if (pendingSpace) {
out.append(' ')
pendingSpace = false
}
out.append(ch.lowercaseChar())
}
return out.toString()
}
/**
* Exact and prefix name or app-ID hits stay in the list without an embedding.
* Other listings are included only when [queryVector] clears [MIN_VECTOR_DOT].
* Keeps ids only, and at most [limit] of them, so the caller can load full rows for the survivors.
*/
internal class SearchRanker(private val query: String, queryVector: ByteArray?, private val limit: Int?) {
private val vector = queryVector?.takeIf { it.size == EMBEDDING_DIMS }
private val kept = ArrayList<Ranked>()
private val worstFirst = limit?.takeIf { it > 0 }?.let { PriorityQueue(it, dropFirst) }
fun consider(id: ByteArray, appId: String, name: String, embedding: ByteArray?) {
if (limit == 0) return
val score = score(name, appId, embedding) ?: return
val heap = worstFirst
if (heap == null) {
kept += Ranked(id, appId, score)
return
}
if (heap.size < limit!!) {
heap += Ranked(id, appId, score)
return
}
val worst = checkNotNull(heap.peek())
if (score > worst.score || (score == worst.score && appId < worst.appId)) {
heap.poll()
heap += Ranked(id, appId, score)
}
}
fun ids(): List<ByteArray> {
val ranked = worstFirst?.toMutableList() ?: kept
ranked.sortWith(bestFirst)
return ranked.map { it.id }
}
private fun score(name: String, appId: String, embedding: ByteArray?): Int? {
val boost = textBoost(query, name, appId)
val dot = vector?.let { left ->
embedding?.takeIf { it.size == EMBEDDING_DIMS }?.let { embeddingDot(left, it) }
}
if (boost == 0 && (dot == null || dot < MIN_VECTOR_DOT)) return null
return boost + (dot ?: 0)
}
}
private class Ranked(val id: ByteArray, val appId: String, val score: Int)
private val bestFirst = compareByDescending<Ranked> { it.score }.thenBy { it.appId }
/** Lowest score first, and the larger app id first when scores tie, so the queue head is the first to drop. */
private val dropFirst = compareBy<Ranked> { it.score }.thenByDescending { it.appId }
private fun textBoost(query: String, name: String, appId: String): Int {
if (query.isEmpty()) return 0
val foldedName = foldSearchText(name)
val foldedId = appId.lowercase()
return when {
query == foldedName || query == foldedId -> EXACT_BOOST
foldedName.startsWith(query) || foldedId.startsWith(query) -> PREFIX_BOOST
else -> 0
}
}
private fun embeddingDot(left: ByteArray, right: ByteArray): Int {
var sum = 0
for (i in 0 until EMBEDDING_DIMS) {
sum += left[i].toInt() * right[i].toInt()
}
return sum
}
@@ -0,0 +1,57 @@
package dev.zapstore.iolite
import org.json.JSONObject
interface Signer {
val publicKey: String
suspend fun sign(createdAt: Long, kind: Int, tags: List<List<String>>, content: String): Event
}
class LocalSigner(secretKey: ByteArray) : Signer {
private val secret = secretKey.copyOf()
private val selfConversationKey: ByteArray =
Nip44.conversationKey(secret, Crypto.xOnlyPubkey(secret))
override val publicKey: String = Hex.encode(Crypto.xOnlyPubkey(secret))
override suspend fun sign(
createdAt: Long,
kind: Int,
tags: List<List<String>>,
content: String,
): Event = Event.sign(secret, createdAt, kind, tags, content)
fun encryptToSelf(plaintext: String): String = Nip44.encrypt(plaintext, selfConversationKey)
fun decryptFromSelf(payload: String): String = Nip44.decrypt(payload, selfConversationKey)
}
/**
* NIP-55 signer. Android supplies [requestSignedEvent] by talking to a signer
* app such as Amber. Iolite never holds the user's nsec for user content.
*/
class Nip55Signer(
override val publicKey: String,
private val requestSignedEvent: suspend (unsignedJson: String) -> String,
) : Signer {
override suspend fun sign(
createdAt: Long,
kind: Int,
tags: List<List<String>>,
content: String,
): Event {
val unsigned = JSONObject()
.put("created_at", createdAt)
.put("kind", kind)
.put("tags", tags.toJsonArray())
.put("content", content)
.put("pubkey", publicKey)
val signed = Event.parse(requestSignedEvent(unsigned.toString()))
require(signed.verify()) { "NIP-55 returned an invalid signature" }
require(signed.pubkey == publicKey) { "NIP-55 pubkey does not match the requested signer" }
require(signed.kind == kind && signed.createdAt == createdAt && signed.content == content) {
"NIP-55 returned a different event template"
}
return signed
}
}
@@ -0,0 +1,162 @@
package dev.zapstore.iolite
import com.github.luben.zstd.ZstdInputStream
import java.io.ByteArrayOutputStream
import java.io.EOFException
import java.io.InputStream
import java.util.Locale
internal data class TarMember(val name: String, val data: ByteArray)
internal object TarZstd {
fun read(
compressed: InputStream,
maxCompressedBytes: Long,
maxUncompressedBytes: Long,
maxMembers: Int,
maxMemberBytes: Long,
): List<TarMember> {
val limited = compressed.bounded(maxCompressedBytes, "compressed catalog bundle exceeds limit")
val zstd = ZstdInputStream(limited)
val tar = CountingInputStream(zstd)
val members = ArrayList<TarMember>()
val names = HashSet<String>()
while (true) {
if (tar.bytesRead >= maxUncompressedBytes) {
error("uncompressed catalog bundle exceeds limit")
}
val header = ByteArray(512)
if (!tar.readFully(header)) break
if (header.all { it == 0.toByte() }) {
val next = ByteArray(512)
tar.readFully(next)
break
}
val name = header.cString(0, 100)
val size = header.octal(124, 12)
val type = header[156].toInt().toChar()
val magic = header.cString(257, 6)
if (magic.startsWith("ustar")) {
// ustar
}
val prefix = header.cString(345, 155)
val fullName = if (prefix.isEmpty()) name else "$prefix/$name"
rejectName(fullName)
when (type) {
'0', '\u0000' -> Unit
'x', 'g', 'L', 'K' -> error("PAX or GNU tar extensions are not allowed")
'1', '2', '3', '4', '5', '6' -> error("directories, links, and devices are not allowed")
else -> error("unsupported tar type $type")
}
if (size > maxMemberBytes) error("catalog member $fullName exceeds size limit")
if (members.size >= maxMembers) error("catalog bundle has too many members")
if (!names.add(fullName)) error("duplicate tar path $fullName")
val data = tar.readExact(size.toInt())
val padding = ((512 - (size % 512)) % 512).toInt()
if (padding > 0) tar.skipFully(padding.toLong())
if (tar.bytesRead > maxUncompressedBytes) error("uncompressed catalog bundle exceeds limit")
members += TarMember(fullName, data)
}
if (members.isEmpty() || members.first().name != "manifest.json") {
error("manifest.json must be the first tar member")
}
return members
}
private fun rejectName(name: String) {
if (name.isEmpty() || name.startsWith("/") || name.contains('\u0000') || name.contains("..") || name.contains('\\')) {
error("illegal tar path $name")
}
}
}
private fun ByteArray.cString(offset: Int, length: Int): String {
var end = offset
val limit = offset + length
while (end < limit && this[end] != 0.toByte()) end++
return copyOfRange(offset, end).toString(Charsets.UTF_8)
}
private fun ByteArray.octal(offset: Int, length: Int): Long {
val raw = cString(offset, length).trim()
if (raw.isEmpty()) return 0
return raw.trimEnd { it == ' ' || it == '\u0000' }.toLong(8)
}
private class CountingInputStream(private val input: InputStream) : InputStream() {
var bytesRead = 0L
private set
override fun read(): Int {
val value = input.read()
if (value >= 0) bytesRead++
return value
}
override fun read(b: ByteArray, off: Int, len: Int): Int {
val n = input.read(b, off, len)
if (n > 0) bytesRead += n
return n
}
fun readFully(buffer: ByteArray): Boolean {
var offset = 0
while (offset < buffer.size) {
val n = read(buffer, offset, buffer.size - offset)
if (n < 0) {
if (offset == 0) return false
throw EOFException("truncated tar")
}
offset += n
}
return true
}
fun readExact(size: Int): ByteArray {
val out = ByteArrayOutputStream(size)
var remaining = size
val buf = ByteArray(minOf(size, 16_384))
while (remaining > 0) {
val n = read(buf, 0, minOf(remaining, buf.size))
if (n < 0) throw EOFException("truncated tar member")
out.write(buf, 0, n)
remaining -= n
}
return out.toByteArray()
}
fun skipFully(n: Long) {
var remaining = n
val buf = ByteArray(512)
while (remaining > 0) {
val read = read(buf, 0, minOf(remaining, buf.size.toLong()).toInt())
if (read < 0) throw EOFException("truncated tar padding")
remaining -= read
}
}
}
private fun InputStream.bounded(limit: Long, message: String): InputStream = object : InputStream() {
var readCount = 0L
override fun read(): Int {
val value = this@bounded.read()
if (value >= 0) {
readCount++
if (readCount > limit) error(message)
}
return value
}
override fun read(b: ByteArray, off: Int, len: Int): Int {
val n = this@bounded.read(b, off, len)
if (n > 0) {
readCount += n
if (readCount > limit) error(message)
}
return n
}
}
internal fun ByteArray.sha256Hex(): String = Hex.encode(Crypto.sha256(this))
internal fun String.asciiLower(): String = lowercase(Locale.ROOT)
@@ -0,0 +1,180 @@
package dev.zapstore.iolite
import java.io.File
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class CatalogArtifactsTest {
@Test
fun parsesFactCsvAndKeepsReason() {
val rows = parseFactRows(
"fact,value,reason\n" +
"gms,no,\"no play services, com.google\"\n" +
"gms,yes,\n" +
"fcm,yes,\"uses, cloud\"\n",
)
assertEquals(2, rows.size)
assertEquals("gms", rows[0].key)
assertTrue(rows[0].yes)
assertEquals("no play services, com.google", rows[0].reason)
assertEquals("fcm", rows[1].key)
assertEquals("uses, cloud", rows[1].reason)
}
@Test
fun parsesAboutSecurityFactsWarningsVectorIconAndHexAvatar() {
val pubkey = "ab".repeat(32)
val parsed = CatalogArtifacts.fromMembers(
mapOf(
"com.example.app/about" to TarMember("com.example.app/about", "Maps.".toByteArray()),
"com.example.app/security" to TarMember("com.example.app/security", "It asks for location.\n⚠️ It runs a shell.".toByteArray()),
"com.example.app/facts" to TarMember("com.example.app/facts", "gms: no".toByteArray()),
"com.example.app/vector" to TarMember("com.example.app/vector", ByteArray(EMBEDDING_DIMS) { 7 }),
"com.example.app/icon.webp" to TarMember("com.example.app/icon.webp", "icon".toByteArray()),
"$pubkey.webp" to TarMember("$pubkey.webp", "avatar".toByteArray()),
),
maxIconBytes = 256L * 1024,
)
val app = parsed.apps.single()
assertEquals("com.example.app", app.appId)
assertEquals("Maps.", app.about)
assertEquals("It asks for location.\n⚠️ It runs a shell.", app.security)
assertEquals("gms: no", app.facts)
assertEquals(EMBEDDING_DIMS, app.embedding!!.size)
assertEquals(pubkey, parsed.avatars.single().pubkey)
assertArrayEquals("avatar".toByteArray(), parsed.avatars.single().webp)
}
@Test
fun rejectsPrefixedPathsAndNpubAvatars() {
assertFalse(CatalogArtifacts.allowedMember("artifacts/com.example.app/about"))
assertFalse(CatalogArtifacts.allowedMember("com.example.app/analysis"))
assertFalse(CatalogArtifacts.allowedMember("com.example.app/warnings"))
assertFalse(CatalogArtifacts.allowedMember("artifacts.bin"))
assertFalse(CatalogArtifacts.allowedMember("npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqkxqqu.webp"))
assertTrue(CatalogArtifacts.allowedMember("diff.jsonl"))
assertTrue(CatalogArtifacts.allowedMember("index"))
assertTrue(CatalogArtifacts.allowedMember("${"cd".repeat(32)}.webp"))
val failure = runCatching {
CatalogArtifacts.fromMembers(
mapOf("com.example.app/vector" to TarMember("com.example.app/vector", ByteArray(32 + EMBEDDING_DIMS))),
maxIconBytes = 256L * 1024,
)
}.exceptionOrNull()
assertTrue(failure is CatalogImportException)
assertEquals("vector com.example.app", failure!!.message)
}
@Test
fun keepsVectorAndAboutWhenALaterRecordOmitsThem() {
val dir = File("build/tmp/catalog-artifacts-${System.nanoTime()}").apply { mkdirs() }
val store = IoliteStore(File(dir, "iolite.db").absolutePath)
store.insertFixtureCatalog()
val catalog = store.catalog(1)!!
val device = DeviceProfile(abis = listOf("arm64-v8a"), sdk = 34)
val pubkey = "cd".repeat(32)
try {
store.write { tx ->
tx.db.prepare(
"""
INSERT INTO apps (
id, catalog_id, app_id, certificate_hash, app_event_created_at, pubkey, event_pubkey,
name, summary, repository, version, version_code, channel, metadata
) VALUES (?, 1, 'com.example.app', ?, 1, NULL, ?, 'Example', '', NULL, '1.0.0', 1, 'main', '{}')
""".trimIndent(),
).use { statement ->
statement.bindBlob(1, ByteArray(32) { 1 })
statement.bindBlob(2, ByteArray(32) { 2 })
statement.bindBlob(3, ByteArray(32) { 3 })
statement.step()
}
}
store.importCatalog(
catalog = catalog,
events = emptyList(),
deletes = emptyList(),
replaceAll = false,
toEpoch = 1,
device = device,
now = 0,
artifacts = CatalogArtifact(
apps = listOf(
AppArtifact(
appId = "com.example.app",
webp = "one".toByteArray(),
embedding = ByteArray(EMBEDDING_DIMS) { 7 },
facts = "maps",
about = "first note",
security = "Asks for location.\n⚠️ first",
),
),
avatars = listOf(AvatarArtifact(pubkey, "face".toByteArray())),
),
)
val imported = store.apps().single()
assertEquals("first note", imported.about)
assertEquals("Asks for location.\n⚠️ first", imported.security)
assertEquals("⚠️ first", imported.securityWarnings)
assertEquals("one", imported.iconFile!!.readText())
assertEquals("face", store.avatarFile(pubkey).readText())
val embedding = searchEmbedding(store)
store.importCatalog(
catalog = store.catalog(1)!!,
events = emptyList(),
deletes = emptyList(),
replaceAll = false,
toEpoch = 2,
device = device,
now = 0,
artifacts = CatalogArtifact(
apps = listOf(
AppArtifact(
appId = "com.example.app",
webp = "two".toByteArray(),
embedding = null,
facts = null,
about = null,
security = null,
),
),
),
)
val kept = store.apps().single()
assertEquals("first note", kept.about)
assertEquals("Asks for location.\n⚠️ first", kept.security)
assertEquals("two", kept.iconFile!!.readText())
assertArrayEquals(embedding, searchEmbedding(store))
assertEquals("maps", searchFeatures(store))
} finally {
store.close()
}
}
@Test
fun npubRoundTrip() {
val pubkey = "cd".repeat(32)
assertEquals(pubkey, pubkey.toNpub().decodeNpub())
}
private fun searchEmbedding(store: IoliteStore): ByteArray = store.read { db ->
db.prepare(
"SELECT embedding FROM apps_search WHERE id = (SELECT id FROM apps WHERE app_id = 'com.example.app')",
).use { statement ->
assertTrue(statement.step())
statement.getBlob(0)
}
}
private fun searchFeatures(store: IoliteStore): String = store.read { db ->
db.prepare(
"SELECT facts FROM apps WHERE app_id = 'com.example.app'",
).use { statement ->
assertTrue(statement.step())
statement.getText(0)
}
}
}
@@ -0,0 +1,492 @@
package dev.zapstore.iolite
import java.io.ByteArrayInputStream
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.job
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class CatalogImporterTest {
@Test
fun importsRelayGoldenFromZero() {
withStore { store, importer ->
val bundle = golden("bundle-0-1.tar.zst")
val result = importer.importBundle(1, bundle, localRelay)
assertEquals(0, result.from)
assertEquals(1, result.to)
val app = store.apps().single()
assertEquals("com.example.app", app.appId)
assertEquals("1.0.0", app.version)
assertEquals(100, app.versionCode)
assertEquals(1, store.catalog(1)!!.epoch)
assertEquals(listOf("ws://127.0.0.1:3334"), store.catalog(1)!!.relays.map { it.url })
assertEquals(manifestPubkey(bundle), store.catalog(1)!!.manifestPubkey)
}
}
@Test
fun importsCoalescedFromZeroToTwo() {
withStore { store, importer ->
val result = importer.importBundle(1, golden("bundle-0-2.tar.zst"), localRelay)
assertEquals(0, result.from)
assertEquals(2, result.to)
val app = store.apps().single()
assertEquals("com.example.app", app.appId)
assertEquals("1.1.0", app.version)
assertEquals(110, app.versionCode)
assertEquals(2, store.catalog(1)!!.epoch)
}
}
@Test
fun appliesAdjacentDeltaFromOneToTwo() {
withStore { store, importer ->
importer.importBundle(1, golden("bundle-0-1.tar.zst"), localRelay)
val result = importer.importBundle(1, golden("bundle-1-2.tar.zst"))
assertEquals(1, result.from)
assertEquals(2, result.to)
val app = store.apps().single()
assertEquals("1.1.0", app.version)
assertEquals(110, app.versionCode)
assertEquals(2, store.catalog(1)!!.epoch)
}
}
@Test
fun wipeClearsAppsAndEpoch() {
withStore { store, importer ->
importer.importBundle(1, golden("bundle-0-1.tar.zst"), localRelay)
assertEquals(1, store.catalog(1)!!.epoch)
assertEquals(1, store.apps().size)
store.wipe()
assertEquals(0, store.catalog(1)!!.epoch)
assertEquals(0, store.apps().size)
assertNull(store.catalog(1)!!.manifestPubkey)
assertEquals(listOf("ws://127.0.0.1:3334"), store.catalog(1)!!.relays.map { it.url })
}
}
@Test
fun fromZeroReplacesLeftoverListings() {
withStore { store, importer ->
store.insertFixtureCatalog()
store.write { tx ->
tx.db.prepare(
"""
INSERT INTO apps (
id, catalog_id, app_id, certificate_hash, app_event_created_at, pubkey, event_pubkey,
name, summary, repository, version, version_code, channel, metadata
) VALUES (?, 1, 'com.example.leftover', ?, 1, NULL, ?, 'Leftover', '', NULL, '9.0.0', 9, 'main', '{}')
""".trimIndent(),
).use { statement ->
statement.bindBlob(1, ByteArray(32) { 1 })
statement.bindBlob(2, ByteArray(32) { 2 })
statement.bindBlob(3, ByteArray(32) { 3 })
statement.step()
}
}
importer.importBundle(1, golden("bundle-0-1.tar.zst"))
assertEquals(listOf("com.example.app"), store.apps().map { it.appId })
}
}
@Test
fun observeAppsEmitsAfterImport() = runBlocking {
val dir = File("build/tmp/iolite-observe-${System.nanoTime()}").apply { mkdirs() }
val scope = CoroutineScope(SupervisorJob())
val iolite = Iolite.createForTesting(
databasePath = File(dir, "iolite.db").absolutePath,
parentScope = scope,
)
iolite.importCatalogUpdate(1, golden("bundle-0-1.tar.zst"), localRelay)
val apps = iolite.observeApps().first { it.isNotEmpty() }
assertEquals("com.example.app", apps.single().appId)
iolite.close()
scope.coroutineContext.job.cancel()
}
@Test
fun observeAppAndSearchReadImportedListing() = runBlocking {
val dir = File("build/tmp/iolite-query-${System.nanoTime()}").apply { mkdirs() }
val scope = CoroutineScope(SupervisorJob())
val iolite = Iolite.createForTesting(
databasePath = File(dir, "iolite.db").absolutePath,
parentScope = scope,
)
iolite.importCatalogUpdate(1, golden("bundle-0-1.tar.zst"), localRelay)
val app = iolite.observeApp("com.example.app").first { it != null }!!
assertEquals("com.example.app", app.appId)
assertEquals(listOf("com.example.app"), iolite.apps(AppFilter(search = app.name)).map { it.appId })
assertEquals(emptyList<AppRecord>(), iolite.apps(AppFilter(search = "nomatch")))
iolite.close()
scope.coroutineContext.job.cancel()
}
@Test
fun bundledImportThenSyncUsesStoredEndpoint() = runBlocking {
val dir = File("build/tmp/iolite-sync-${System.nanoTime()}").apply { mkdirs() }
val scope = CoroutineScope(SupervisorJob())
val requested = mutableListOf<String>()
val http = HttpTransport { url, _ ->
requested += url
when (url) {
"http://127.0.0.1:3334/deltas?from=1" -> HttpResponse(304, emptyMap(), null)
else -> error("unexpected catalog URL $url")
}
}
val iolite = Iolite.createForTesting(
databasePath = File(dir, "iolite.db").absolutePath,
parentScope = scope,
http = http,
)
val bundle = golden("bundle-0-1.tar.zst")
val first = iolite.syncCatalog(catalogId = 1, bundled = bundle, endpoint = localRelay)
val imported = first.imported!!
assertEquals(0, imported.from)
assertEquals(1, imported.to)
assertEquals(bundle.size.toLong(), first.bytes)
assertEquals(manifestPubkey(bundle), iolite.defaultCatalog().manifestPubkey)
assertEquals(listOf("ws://127.0.0.1:3334"), iolite.defaultCatalog().relays.map { it.url })
val unchanged = iolite.syncCatalog()
assertNull(unchanged.imported)
assertTrue(unchanged.notModified)
assertEquals(
listOf(
"http://127.0.0.1:3334/deltas?from=1",
"http://127.0.0.1:3334/deltas?from=1",
),
requested,
)
iolite.close()
scope.coroutineContext.job.cancel()
}
@Test
fun emptyCatalogFetchesFirstBundleFromEndpoint() = runBlocking {
val dir = File("build/tmp/iolite-bootstrap-${System.nanoTime()}").apply { mkdirs() }
val scope = CoroutineScope(SupervisorJob())
val bundle = golden("bundle-0-1.tar.zst")
val requested = mutableListOf<String>()
val http = HttpTransport { url, _ ->
requested += url
when (url) {
"http://127.0.0.1:3334/deltas?from=0" -> HttpResponse(200, emptyMap(), bundle)
else -> error("unexpected catalog URL $url")
}
}
val iolite = Iolite.createForTesting(
databasePath = File(dir, "iolite.db").absolutePath,
parentScope = scope,
http = http,
)
val first = iolite.syncCatalog(catalogId = 1, bundled = ByteArray(0), endpoint = localRelay)
val imported = first.imported!!
assertEquals(0, imported.from)
assertEquals(1, imported.to)
assertEquals(bundle.size.toLong(), first.bytes)
assertEquals(manifestPubkey(bundle), iolite.defaultCatalog().manifestPubkey)
assertEquals(listOf("ws://127.0.0.1:3334"), iolite.defaultCatalog().relays.map { it.url })
assertEquals(listOf("http://127.0.0.1:3334/deltas?from=0"), requested)
iolite.close()
scope.coroutineContext.job.cancel()
}
@Test
fun syncRelayUsesOnionOnlyWhenAsked() {
val clearnet = "wss://relay.zapstore.dev".normalizeRelayUrl()
val onion = "ws://abcdefghijklmnopqrstuvwxyz234567.onion".normalizeRelayUrl()
val catalog = CatalogRecord(
id = 1,
relayUrl = clearnet,
relays = listOf(clearnet, onion),
position = 0,
isPrivate = false,
manifestPubkey = null,
epoch = 1,
)
assertEquals(onion.url, catalog.syncRelay(useOnion = true).url)
assertEquals(clearnet.url, catalog.syncRelay(useOnion = false).url)
}
@Test
fun pinnedKeyRejectsADifferentSigner() {
withStore { store, importer ->
store.insertFixtureCatalog()
store.pinManifestPubkeyIfAbsent(1, "aa".repeat(32))
val failure = runCatching { importer.importBundle(1, golden("bundle-0-1.tar.zst")) }.exceptionOrNull()
assertTrue(failure is CatalogImportException)
assertEquals("manifest pubkey does not match the pinned key", failure!!.message)
assertEquals("aa".repeat(32), store.catalog(1)!!.manifestPubkey)
assertEquals(0, store.catalog(1)!!.epoch)
}
}
@Test
fun importsCatalogStackForCuratedStacks() {
withStore { store, _ ->
store.insertFixtureCatalog()
val secret = Hex.decode("00".repeat(31) + "01")
val otherSecret = Hex.decode("00".repeat(31) + "03")
val device = DeviceProfile(abis = listOf("arm64-v8a"), sdk = 34)
val catalogStack = stackEvent(secret, "editors")
val otherStack = stackEvent(otherSecret, "mine")
store.pinManifestPubkeyIfAbsent(1, catalogStack.pubkey)
store.ingest(listOf(otherStack), IngestContext(device, null))
val catalog = store.catalog(1)!!
val rejected = runCatching {
store.importCatalog(
catalog = catalog,
events = listOf(otherStack),
deletes = emptyList(),
replaceAll = true,
toEpoch = 1,
device = device,
now = 1_700_000_000,
stackPubkey = catalogStack.pubkey,
)
}.exceptionOrNull()
assertTrue(rejected is CatalogImportException)
store.importCatalog(
catalog = catalog,
events = listOf(catalogStack, stackEvent(secret, "desktop", platform = "android-x86_64")),
deletes = emptyList(),
replaceAll = true,
toEpoch = 1,
device = device,
now = 1_700_000_000,
stackPubkey = catalogStack.pubkey,
)
val curated = store.stacks(catalogStack.pubkey)
assertEquals(listOf("editors"), curated.map { it.identifier })
assertEquals(listOf("com.example.app"), curated.single().appIds)
assertEquals(listOf("mine"), store.stacks(otherStack.pubkey).map { it.identifier })
store.importCatalog(
catalog = store.catalog(1)!!,
events = emptyList(),
deletes = listOf(CatalogDelete.Coordinate(Kinds.AppStack, catalogStack.pubkey, "editors")),
replaceAll = false,
toEpoch = 2,
device = device,
now = 1_700_000_000,
stackPubkey = catalogStack.pubkey,
)
assertEquals(emptyList<StackRecord>(), store.stacks(catalogStack.pubkey))
assertEquals(listOf("mine"), store.stacks(otherStack.pubkey).map { it.identifier })
}
}
@Test
fun partialDeltaPatchesStoredListing() {
withStore { store, _ ->
store.insertFixtureCatalog()
val secret = Hex.decode("11".repeat(32))
val ditto = fixtureListing(secret, "pub.ditto.app", "Ditto", "1.0.0", 10)
import(store, ditto.events, 1)
val renamed = appEvent(secret, "pub.ditto.app", "Ditto Nightly", 1_700_000_100, "renamed description")
import(store, listOf(renamed), 2)
val app = store.apps().single()
assertEquals("Ditto Nightly", app.name)
assertEquals("renamed description", app.description)
assertEquals("1.0.0", app.version)
assertEquals(10, app.versionCode)
assertEquals(2, store.catalog(1)!!.epoch)
}
}
@Test
fun assetOnlyDeltaUpdatesVersionCode() {
withStore { store, _ ->
store.insertFixtureCatalog()
val secret = Hex.decode("11".repeat(32))
val ditto = fixtureListing(secret, "pub.ditto.app", "Ditto", "1.0.0", 10)
import(store, ditto.events, 1)
val asset = assetEvent(secret, "pub.ditto.app", "9.9.9", 42, 1_700_000_100, hash = "ef".repeat(32))
import(store, listOf(asset), 2)
val app = store.apps().single()
assertEquals("Ditto", app.name)
assertEquals("9.9.9", app.version)
assertEquals(42, app.versionCode)
assertEquals(asset.tagValue("x"), app.apkHash)
}
}
@Test
fun malformedListingDoesNotAbortSync() {
withStore { store, _ ->
store.insertFixtureCatalog()
val secret = Hex.decode("11".repeat(32))
val ditto = fixtureListing(secret, "pub.ditto.app", "Ditto", "1.0.0", 10)
import(store, ditto.events, 1)
val broken = Event.sign(
secret,
1_700_000_050,
Kinds.Release,
listOf(
listOf("a", "32267:${"ab".repeat(32)}:other.app"),
listOf("i", "pub.ditto.app"),
listOf("version", "3.0.0"),
listOf("d", "pub.ditto.app@3.0.0"),
listOf("c", "main"),
listOf("e", ditto.asset.id),
),
"notes",
)
val renamed = appEvent(secret, "pub.ditto.app", "Ditto Nightly", 1_700_000_050, "renamed description")
val other = fixtureListing(secret, "com.example.keep", "Keep", "2.0.0", 20, 1_700_000_200)
val stray = assetEvent(secret, "com.example.unknown", "0.0.1", 1, 1_700_000_300)
import(store, listOf(broken, renamed, stray) + other.events, 2)
val apps = store.apps().associateBy { it.appId }
assertEquals(setOf("pub.ditto.app", "com.example.keep", "com.example.unknown"), apps.keys)
assertEquals("Ditto Nightly", apps.getValue("pub.ditto.app").name)
assertEquals("notes", apps.getValue("pub.ditto.app").releaseNotes)
assertEquals("1.0.0", apps.getValue("pub.ditto.app").version)
assertEquals(10, apps.getValue("pub.ditto.app").versionCode)
assertEquals("Keep", apps.getValue("com.example.keep").name)
assertEquals("0.0.1", apps.getValue("com.example.unknown").version)
assertEquals(1, apps.getValue("com.example.unknown").versionCode)
assertEquals(2, store.catalog(1)!!.epoch)
}
}
private fun import(store: IoliteStore, events: List<Event>, epoch: Long) {
store.importCatalog(
catalog = store.catalog(1)!!,
events = events,
deletes = emptyList(),
replaceAll = false,
toEpoch = epoch,
device = DeviceProfile(abis = listOf("arm64-v8a"), sdk = 34),
now = 1_700_000_000,
)
}
private data class FixtureListing(val app: Event, val release: Event, val asset: Event) {
val events: List<Event> get() = listOf(app, release, asset)
}
private fun fixtureListing(
secret: ByteArray,
appId: String,
name: String,
version: String,
versionCode: Long,
createdAt: Long = 1_700_000_000,
): FixtureListing {
val asset = assetEvent(secret, appId, version, versionCode, createdAt)
val pubkey = asset.pubkey
val release = Event.sign(
secret,
createdAt,
Kinds.Release,
listOf(
listOf("a", "32267:$pubkey:$appId"),
listOf("i", appId),
listOf("version", version),
listOf("d", "$appId@$version"),
listOf("c", "main"),
listOf("e", asset.id),
),
"notes",
)
return FixtureListing(appEvent(secret, appId, name, createdAt), release, asset)
}
private fun appEvent(
secret: ByteArray,
appId: String,
name: String,
createdAt: Long,
content: String = "nightly build",
): Event = Event.sign(
secret,
createdAt,
Kinds.App,
listOf(listOf("d", appId), listOf("name", name), listOf("summary", "short")),
content,
)
private fun assetEvent(
secret: ByteArray,
appId: String,
version: String,
versionCode: Long,
createdAt: Long,
hash: String = "ab".repeat(32),
): Event = Event.sign(
secret,
createdAt,
Kinds.Asset,
listOf(
listOf("i", appId),
listOf("x", hash),
listOf("version", version),
listOf("m", "application/vnd.android.package-archive"),
listOf("f", "android-arm64-v8a"),
listOf("version_code", versionCode.toString()),
listOf("apk_certificate_hash", "cd".repeat(32)),
listOf("url", "https://example.invalid/$appId.apk"),
listOf("filename", "$appId.apk"),
),
"",
)
private fun stackEvent(secret: ByteArray, identifier: String, platform: String = "android-arm64-v8a"): Event {
val pubkey = Hex.encode(Crypto.xOnlyPubkey(secret))
return Event.sign(
secret,
1_700_000_000,
Kinds.AppStack,
listOf(
listOf("d", identifier),
listOf("name", identifier),
listOf("a", "32267:$pubkey:com.example.app"),
listOf("f", platform),
),
"",
)
}
private fun withStore(block: (IoliteStore, CatalogImporter) -> Unit) {
val dir = File("build/tmp/iolite-import-${System.nanoTime()}").apply { mkdirs() }
val store = IoliteStore(File(dir, "iolite.db").absolutePath)
val importer = CatalogImporter(
store = store,
iconsDir = File(dir, "icons"),
config = IoliteConfig(),
device = DeviceProfile(abis = listOf("arm64-v8a"), sdk = 34),
)
try {
block(store, importer)
} finally {
store.close()
}
}
private fun manifestPubkey(bundle: ByteArray): String {
val config = IoliteConfig()
val members = TarZstd.read(
ByteArrayInputStream(bundle),
maxCompressedBytes = config.maxCompressedBytes,
maxUncompressedBytes = config.maxUncompressedBytes,
maxMembers = config.maxMembers,
maxMemberBytes = config.maxUncompressedBytes,
)
return Event.parse(members.first().data.toString(Charsets.UTF_8)).pubkey
}
private fun golden(name: String): ByteArray {
val file = File("src/test/resources/golden", name)
check(file.isFile) { "missing golden $name" }
return file.readBytes()
}
companion object {
private val localRelay = "ws://127.0.0.1:3334".normalizeRelayUrl()
}
}
@@ -0,0 +1,58 @@
package dev.zapstore.iolite
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class EventCryptoTest {
@Test
fun signAndVerifyRoundTrip() {
val secret = Hex.decode("0000000000000000000000000000000000000000000000000000000000000001")
val event = Event.sign(
secretKey = secret,
createdAt = 1_700_000_000,
kind = 1,
tags = listOf(listOf("t", "zapstore")),
content = "hello",
)
assertEquals("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", event.pubkey)
assertTrue(event.verify())
assertEquals(event.id, Event.computeId(event.pubkey, event.createdAt, event.kind, event.tags, event.content))
}
@Test
fun rejectsTamperedContent() {
val secret = Hex.decode("0000000000000000000000000000000000000000000000000000000000000001")
val event = Event.sign(secret, 1, 1, emptyList(), "hello")
val tampered = event.copy(content = "bye")
assertTrue(!tampered.verify())
}
@Test
fun replacementOrderingPrefersNewerThenLowestId() {
val older = Event("b".repeat(64), "a".repeat(64), 10, 1, emptyList(), "", "c".repeat(128))
val newer = older.copy(createdAt = 11, id = "c".repeat(64))
val sameTimeLowerId = older.copy(id = "a".repeat(64))
assertTrue(supersedes(newer.createdAt, newer.id, older.createdAt, older.id))
assertTrue(supersedes(sameTimeLowerId.createdAt, sameTimeLowerId.id, older.createdAt, older.id))
assertTrue(!supersedes(older.createdAt, older.id, newer.createdAt, newer.id))
}
@Test
fun filterSerializesTagKeysWithHash() {
val json = Filter(kinds = listOf(Kinds.App), tags = mapOf("d" to listOf("com.example.app"))).toJsonObject()
assertEquals("com.example.app", json.getJSONArray("#d").getString(0))
assertEquals(Kinds.App, json.getJSONArray("kinds").getInt(0))
}
@Test
fun verifiesRelayAssetWhoseUrlContainsSlash() {
val event = Event.parse(
"""{"id":"fb5b64912c37e33f0da48745032f0c7be9e571bfb82da10a09b31c085fccff85","pubkey":"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5","created_at":1789657776,"kind":3063,"tags":[["i","com.example.app"],["x","a000000000000000000000000000000000000000000000000000000000000000"],["version","1.0.0"],["m","application/vnd.android.package-archive"],["version_code","100"],["apk_certificate_hash","b000000000000000000000000000000000000000000000000000000000000000"],["url","https://cdn.example.com/com.example.app.apk"]],"content":"","sig":"de1a0c2353acc9ceb8fc9122ff14d205a62b8bef0f93742b4286e988e0a3de6612d285424ae8bec5b56ca7b1b6f26164346685703930d8bcec34c2cadd37956d"}""",
)
assertTrue(event.verify())
val canonical = String(Event.canonicalBytes(event.pubkey, event.createdAt, event.kind, event.tags, event.content), Charsets.UTF_8)
assertTrue(canonical.contains("https://cdn.example.com/com.example.app.apk"))
assertTrue(!canonical.contains("\\/"))
}
}
@@ -0,0 +1,441 @@
package dev.zapstore.iolite
import androidx.sqlite.execSQL
import java.io.File
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.job
import kotlinx.coroutines.runBlocking
import org.json.JSONArray
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class IoliteContractsTest {
@Test
fun catalogRelayListDoesNotPinFromTheNetwork() = runBlocking {
val signer = LocalSigner(SECRET)
val extra = "wss://relay.example.com"
val privateRelay = "wss://private.example"
val http = HttpTransport { _, _ -> error("catalog relay list must not fetch NIP-11") }
withIolite(http = http, signer = signer) { iolite ->
val content = signer.encryptToSelf(JSONArray().put(privateRelay).toString())
val event = signer.sign(
1_700_000_100,
Kinds.CatalogRelayList,
listOf(listOf("r", "wss://relay.zapstore.dev"), listOf("r", extra)),
content,
)
iolite.ingest(listOf(event))
val selected = iolite.catalogs()
assertEquals(
listOf("wss://relay.zapstore.dev", extra, privateRelay),
selected.map { it.relayUrl.url },
)
assertEquals(listOf(false, false, true), selected.map { it.isPrivate })
assertTrue(selected.all { it.manifestPubkey == null })
delay(80)
assertTrue(iolite.allCatalogs().all { it.manifestPubkey == null })
}
}
@Test
fun rehydrateCatalogPreferencesAndStack() = runBlocking {
val signer = LocalSigner(SECRET)
withIolite(signer = signer) { iolite ->
val privateRelay = "wss://private.example"
val list = signer.sign(
1_700_000_100,
Kinds.CatalogRelayList,
listOf(listOf("r", "wss://relay.zapstore.dev")),
signer.encryptToSelf(JSONArray().put(privateRelay).toString()),
)
iolite.ingest(listOf(list))
iolite.ingest(
listOf(signer.sign(
1_700_000_101,
Kinds.Preferences,
listOf(listOf("d", "ui"), listOf("client", "zapstore")),
"""{"theme":"dark"}""",
)),
)
iolite.ingest(
listOf(signer.sign(
1_700_000_102,
Kinds.AppStack,
listOf(
listOf("d", "privacy"),
listOf("name", "Privacy"),
listOf("a", "32267:${signer.publicKey}:com.example.app"),
listOf("client", "zapstore"),
),
signer.encryptToSelf(JSONArray().put("32267:${signer.publicKey}:com.hidden").toString()),
)),
)
val restoredList = iolite.rehydrateCatalogRelayList()
assertTrue(restoredList.id != list.id)
assertEquals(listOf("wss://relay.zapstore.dev"), restoredList.tagValues("r"))
assertEquals(
listOf(privateRelay),
JSONArray(signer.decryptFromSelf(restoredList.content)).let { array ->
List(array.length()) { array.getString(it) }
},
)
val restoredPrefs = iolite.rehydratePreferences("ui", signer)
assertEquals("ui", restoredPrefs.dTag())
assertEquals("""{"theme":"dark"}""", restoredPrefs.content)
assertEquals("zapstore", restoredPrefs.tagValue("client"))
val restoredStack = iolite.rehydrateStack("privacy", signer)
assertEquals("privacy", restoredStack.dTag())
assertEquals("Privacy", restoredStack.tagValue("name"))
assertEquals("android-arm64-v8a", restoredStack.tagValue("f"))
assertEquals("zapstore", restoredStack.tagValue("client"))
assertEquals(
listOf("32267:${signer.publicKey}:com.hidden"),
JSONArray(signer.decryptFromSelf(restoredStack.content)).let { array ->
List(array.length()) { array.getString(it) }
},
)
}
}
@Test
fun catalogManifestsAreNotStoredAsPreferences() = runBlocking {
val signer = LocalSigner(SECRET)
withIolite(signer = signer) { iolite ->
val manifest = signer.sign(
1_700_000_100,
Kinds.Preferences,
listOf(listOf("d", "catalog"), listOf("from", "0"), listOf("to", "1"), listOf("v", "1")),
"{}",
)
assertEquals(0, iolite.ingest(listOf(manifest)))
assertTrue(runCatching { iolite.rehydratePreferences("catalog", signer) }.isFailure)
}
}
@Test
fun pruneRemovesOldZaps() = runBlocking {
val signer = LocalSigner(SECRET)
withIolite(
signer = signer,
config = IoliteConfig(
expirationSweepInterval = 20.milliseconds,
pruneRules = mapOf(Kinds.Zap to 1.seconds),
),
nowMillis = { 10_000L },
) { iolite ->
iolite.ingest(
listOf(signer.sign(
1,
Kinds.Zap,
listOf(listOf("i", "com.example.app"), listOf("amount", "1000000")),
"",
)),
)
val newZap = signer.sign(
10,
Kinds.Zap,
listOf(listOf("i", "com.example.app"), listOf("amount", "2000000")),
"",
)
iolite.ingest(listOf(newZap))
delay(80)
val zaps = iolite.query(Query.zaps(AppCoordinate(signer.publicKey, "com.example.app"))).first { true }
assertEquals(QueryPhase.LocalOnly, zaps.phase)
assertEquals(listOf(newZap.id), zaps.items.map { it.eventId })
assertEquals(2_000L, zaps.items.single().amountSats)
}
}
@Test
fun commentsAreIndexedFromAddressTag() = runBlocking {
val signer = LocalSigner(SECRET)
withIolite(signer = signer) { iolite ->
val coordinate = AppCoordinate(signer.publicKey, "com.example.app")
val event = signer.sign(
1_700_000_000,
Kinds.Comment,
listOf(listOf("A", coordinate.toString())),
"great app",
)
iolite.ingest(listOf(event))
val comments = iolite.query(Query.comments(coordinate)).first { it.items.isNotEmpty() }
assertEquals(listOf(event.id), comments.items.map { it.eventId })
assertEquals(listOf("great app"), comments.items.map { it.content })
assertEquals(listOf("com.example.app"), comments.items.map { it.appId })
}
}
@Test
fun commentsIndexParentEventTag() = runBlocking {
val signer = LocalSigner(SECRET)
withIolite(signer = signer) { iolite ->
val coordinate = AppCoordinate(signer.publicKey, "com.example.app")
val root = signer.sign(
1_700_000_000,
Kinds.Comment,
listOf(listOf("A", coordinate.toString())),
"root",
)
val reply = signer.sign(
1_700_000_100,
Kinds.Comment,
listOf(listOf("A", coordinate.toString()), listOf("e", root.id)),
"reply",
)
iolite.ingest(listOf(root, reply))
val comments = iolite.query(Query.comments(coordinate)).first { it.items.size == 2 }
assertEquals(root.id, comments.items.single { it.eventId == reply.id }.parentEventId)
assertNull(comments.items.single { it.eventId == root.id }.parentEventId)
}
}
@Test
fun proofExpiryClearsAppPubkey() {
val dir = File("build/tmp/iolite-c1-${System.nanoTime()}").apply { mkdirs() }
val store = IoliteStore(File(dir, "iolite.db").absolutePath)
store.insertFixtureCatalog()
val appId = ByteArray(32) { 1 }
val cert = ByteArray(32) { 2 }
val pubkey = Hex.decode(LocalSigner(SECRET).publicKey)
store.write { tx ->
tx.db.prepare(
"""
INSERT INTO apps (
id, catalog_id, app_id, certificate_hash, app_event_created_at, pubkey, event_pubkey,
name, summary, repository, version, version_code, channel, metadata
) VALUES (?, 1, 'com.example.app', ?, 1, ?, ?, 'Example', '', NULL, '1.0', 1, 'main', '{}')
""".trimIndent(),
).use { statement ->
statement.bindBlob(1, appId)
statement.bindBlob(2, cert)
statement.bindBlob(3, pubkey)
statement.bindBlob(4, pubkey)
statement.step()
}
tx.db.prepare(
"""
INSERT INTO certificate_proofs (
id, catalog_id, certificate_hash, pubkey, event_id, created_at, expiry, revoked, delegations
) VALUES (?, 1, ?, ?, ?, 50, 150, 0, '[]')
""".trimIndent(),
).use { statement ->
statement.bindBlob(1, ByteArray(32) { 3 })
statement.bindBlob(2, cert)
statement.bindBlob(3, pubkey)
statement.bindBlob(4, ByteArray(32) { 4 })
statement.step()
}
}
store.recomputeProofs(now = 100L)
assertEquals(Hex.encode(pubkey), store.app("com.example.app")!!.proofPubkey)
assertTrue(store.app("com.example.app")!!.isVerified)
store.recomputeProofs(now = 200L)
assertNull(store.app("com.example.app")!!.proofPubkey)
assertEquals(150L, store.nextProofExpiry(100L))
store.close()
}
@Test
fun appSearchMatchesNameAndAppIdPrefix() {
val dir = File("build/tmp/iolite-search-${System.nanoTime()}").apply { mkdirs() }
val store = IoliteStore(File(dir, "iolite.db").absolutePath)
store.insertFixtureCatalog()
insertApp(store, 1, "com.example.app", "Zapstore", embedding = null)
assertEquals(listOf("com.example.app"), store.apps(AppFilter(search = "zap")).map { it.appId })
assertEquals(emptyList<AppRecord>(), store.apps(AppFilter(search = "example")))
assertEquals(listOf("com.example.app"), store.apps(AppFilter(search = "com.example")).map { it.appId })
assertEquals(listOf("com.example.app"), store.apps(AppFilter(search = "COM.EXAMPLE")).map { it.appId })
store.close()
}
@Test
fun appSearchRanksEmbeddings() {
val dir = File("build/tmp/iolite-search-vec-${System.nanoTime()}").apply { mkdirs() }
val store = IoliteStore(File(dir, "iolite.db").absolutePath)
store.insertFixtureCatalog()
val close = ByteArray(EMBEDDING_DIMS) { 20 }
val far = ByteArray(EMBEDDING_DIMS) { 1 }
val opposite = ByteArray(EMBEDDING_DIMS) { -20 }
insertApp(store, 1, "app.map", "Map", opposite)
insertApp(store, 2, "app.maple", "Maple", embedding = null)
insertApp(store, 3, "app.atlas", "Atlas", close)
insertApp(store, 4, "app.noise", "Noise", far)
val vector = ByteArray(EMBEDDING_DIMS) { 20 }
assertEquals(
listOf("app.map", "app.atlas", "app.maple"),
store.apps(AppFilter(search = "map", queryVector = vector)).map { it.appId },
)
assertEquals(listOf("app.atlas"), store.apps(AppFilter(search = "nope", queryVector = vector)).map { it.appId })
assertEquals(listOf("app.map", "app.maple"), store.apps(AppFilter(search = "map")).map { it.appId })
assertEquals(listOf("app.map"), store.apps(AppFilter(search = "map", queryVector = vector, limit = 1)).map { it.appId })
store.close()
}
@Test
fun appLimitReturnsNewestRows() {
val dir = File("build/tmp/iolite-limit-${System.nanoTime()}").apply { mkdirs() }
val store = IoliteStore(File(dir, "iolite.db").absolutePath)
store.insertFixtureCatalog()
val pubkey = Hex.decode(LocalSigner(SECRET).publicKey)
store.write { tx ->
repeat(25) { index ->
tx.db.prepare(
"""
INSERT INTO apps (
id, catalog_id, app_id, certificate_hash, app_event_created_at, pubkey, event_pubkey,
name, summary, repository, version, version_code, channel, metadata
) VALUES (?, 1, ?, ?, ?, ?, ?, ?, '', NULL, '1.0', 1, 'main', '{}')
""".trimIndent(),
).use { statement ->
statement.bindBlob(1, ByteArray(32) { index.toByte() })
statement.bindText(2, "app.$index")
statement.bindBlob(3, ByteArray(32) { 2 })
statement.bindLong(4, index + 1L)
statement.bindBlob(5, pubkey)
statement.bindBlob(6, pubkey)
statement.bindText(7, "App $index")
statement.step()
}
}
}
val page = store.apps(AppFilter(limit = 20))
assertEquals(20, page.size)
assertEquals((24 downTo 5).map { "app.$it" }, page.map { it.appId })
store.close()
}
@Test
fun wipeAndCloseTruncateWal() {
val dir = File("build/tmp/iolite-wal-${System.nanoTime()}").apply { mkdirs() }
val db = File(dir, "iolite.db")
val wal = File("${db.path}-wal")
val store = IoliteStore(db.absolutePath)
store.insertFixtureCatalog()
val pubkey = Hex.decode(LocalSigner(SECRET).publicKey)
store.write { tx ->
repeat(40) { index ->
tx.db.prepare(
"""
INSERT INTO apps (
id, catalog_id, app_id, certificate_hash, app_event_created_at, pubkey, event_pubkey,
name, summary, repository, version, version_code, channel, metadata
) VALUES (?, 1, ?, ?, ?, ?, ?, ?, '', NULL, '1.0', 1, 'main', '{}')
""".trimIndent(),
).use { statement ->
statement.bindBlob(1, ByteArray(32) { index.toByte() })
statement.bindText(2, "app.$index")
statement.bindBlob(3, ByteArray(32) { 2 })
statement.bindLong(4, index + 1L)
statement.bindBlob(5, pubkey)
statement.bindBlob(6, pubkey)
statement.bindText(7, "App $index")
statement.step()
}
}
}
store.wipe()
assertTrue(!wal.exists() || wal.length() == 0L)
store.write { tx ->
tx.db.execSQL("UPDATE catalogs SET epoch = 1")
tx.touch(Table.Catalogs)
}
store.close()
assertTrue(!wal.exists() || wal.length() == 0L)
}
@Test
fun ingestBatchCommitsTogether() {
val dir = File("build/tmp/iolite-batch-${System.nanoTime()}").apply { mkdirs() }
val store = IoliteStore(File(dir, "iolite.db").absolutePath)
val signer = LocalSigner(SECRET)
val device = DeviceProfile(listOf("arm64-v8a"), 34)
val first = runBlocking { signer.sign(1, Kinds.Profile, emptyList(), """{"name":"Ada"}""") }
val second = runBlocking { signer.sign(2, Kinds.Profile, emptyList(), """{"name":"Bob"}""") }
assertEquals(2, store.ingest(listOf(first, second), IngestContext(device, signer)))
val profile = store.profiles(listOf(signer.publicKey)).getValue(signer.publicKey)
assertEquals("Bob", profile.name)
assertEquals(second.id, profile.eventId)
store.close()
}
private suspend fun withIolite(
signer: LocalSigner,
http: HttpTransport? = null,
config: IoliteConfig = IoliteConfig(eoseGrace = 5.milliseconds, oneShotTimeout = 2.seconds),
nowMillis: () -> Long = { System.currentTimeMillis() },
block: suspend (Iolite) -> Unit,
) {
val dir = File("build/tmp/iolite-contracts-${System.nanoTime()}").apply { mkdirs() }
val scope = CoroutineScope(SupervisorJob())
val iolite = Iolite.createForTesting(
databasePath = File(dir, "iolite.db").absolutePath,
parentScope = scope,
config = config,
http = http,
signer = signer,
nowMillis = nowMillis,
)
try {
block(iolite)
} finally {
iolite.close()
scope.coroutineContext.job.cancel()
}
}
private fun insertApp(store: IoliteStore, index: Int, appId: String, name: String, embedding: ByteArray?) {
val pubkey = Hex.decode(LocalSigner(SECRET).publicKey)
val id = ByteArray(32) { index.toByte() }
store.write { tx ->
tx.db.prepare(
"""
INSERT INTO apps (
id, catalog_id, app_id, certificate_hash, app_event_created_at, pubkey, event_pubkey,
name, summary, repository, version, version_code, channel, metadata
) VALUES (?, 1, ?, ?, ?, ?, ?, ?, 'example summary', NULL, '1.0', 1, 'main', '{}')
""".trimIndent(),
).use { statement ->
statement.bindBlob(1, id)
statement.bindText(2, appId)
statement.bindBlob(3, ByteArray(32) { 2 })
statement.bindLong(4, index.toLong())
statement.bindBlob(5, pubkey)
statement.bindBlob(6, pubkey)
statement.bindText(7, name)
statement.step()
}
if (embedding != null) {
tx.db.prepare("INSERT INTO apps_search (id, embedding) VALUES (?, ?)").use { statement ->
statement.bindBlob(1, id)
statement.bindBlob(2, embedding)
statement.step()
}
}
}
}
companion object {
private val SECRET = Hex.decode("0000000000000000000000000000000000000000000000000000000000000001")
}
}
fun IoliteStore.insertFixtureCatalog() {
write { tx ->
tx.db.prepare(
"""
INSERT INTO catalogs (id, relay_url, position, is_private, epoch, endpoints)
VALUES (1, 'ws://127.0.0.1:3334', 0, 0, 0, '["ws://127.0.0.1:3334"]')
""".trimIndent(),
).use { it.step() }
}
}
@@ -1,53 +0,0 @@
package dev.zapstore.iolite
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.Flow
import kotlin.time.Duration
/**
* Keeps the pre-refactor behavioral tests readable while they exercise the new
* QueryOptions API. New contract tests should use QueryOptions directly.
*/
internal sealed interface QuerySource {
val options: QueryOptions
data object Local : QuerySource {
override val options = QueryOptions.local()
}
data class LocalAndRemote(
val relays: Set<NormalizedRelayUrl>,
val mode: RemoteMode = RemoteMode.Stream,
val cachedFor: Duration? = null,
) : QuerySource {
override val options = QueryOptions.localAndRemote(relays, mode, cachedFor)
}
data class Remote(
val relays: Set<NormalizedRelayUrl>,
val mode: RemoteMode = RemoteMode.Stream,
) : QuerySource {
override val options = QueryOptions.remote(relays, mode)
}
}
internal object QuerySync {
val LocalOnly: QueryPhase = QueryPhase.LocalOnly
val Cached: QueryPhase = QueryPhase.Cached
val Connecting: QueryPhase = QueryPhase.Connecting
val CatchingUp: QueryPhase = QueryPhase.CatchingUp
val Live: QueryPhase = QueryPhase.Live
val Complete: QueryPhase = QueryPhase.Complete
val TimedOut: QueryPhase = QueryPhase.TimedOut
val Failed: QueryPhase = QueryPhase.Failed
}
internal val QueryState.sync: QueryPhase
get() = phase
internal fun Iolite.query(filter: Filter, source: QuerySource): Flow<QueryState> =
query(filter, source.options)
internal fun Iolite.query(filters: List<Filter>, source: QuerySource): Flow<QueryState> =
query(filters, source.options)
@@ -0,0 +1,89 @@
package dev.zapstore.iolite
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class Nip44Test {
private val vectors = JSONObject(
javaClass.classLoader!!.getResourceAsStream("nip44.vectors.json")!!.reader().readText(),
).getJSONObject("v2")
@Test
fun conversationKeysMatchOfficialVectors() {
val cases = vectors.getJSONObject("valid").getJSONArray("get_conversation_key")
for (i in 0 until cases.length()) {
val c = cases.getJSONObject(i)
val key = Nip44.conversationKey(c.getString("sec1").hex(), c.getString("pub2").hex())
assertEquals("case $i", c.getString("conversation_key"), Hex.encode(key))
}
}
@Test
fun messageKeysMatchOfficialVectors() {
val block = vectors.getJSONObject("valid").getJSONObject("get_message_keys")
val conversationKey = block.getString("conversation_key").hex()
val keys = block.getJSONArray("keys")
for (i in 0 until keys.length()) {
val c = keys.getJSONObject(i)
val (chachaKey, chachaNonce, hmacKey) = Nip44.messageKeys(conversationKey, c.getString("nonce").hex())
assertEquals(c.getString("chacha_key"), Hex.encode(chachaKey))
assertEquals(c.getString("chacha_nonce"), Hex.encode(chachaNonce))
assertEquals(c.getString("hmac_key"), Hex.encode(hmacKey))
}
}
@Test
fun paddedLengthsMatchOfficialVectors() {
val cases = vectors.getJSONObject("valid").getJSONArray("calc_padded_len")
for (i in 0 until cases.length()) {
val pair = cases.getJSONArray(i)
assertEquals(pair.getInt(1), Nip44.paddedLength(pair.getInt(0)))
}
}
@Test
fun encryptDecryptMatchesOfficialVectors() {
val cases = vectors.getJSONObject("valid").getJSONArray("encrypt_decrypt")
for (i in 0 until cases.length()) {
val c = cases.getJSONObject(i)
val sec1 = c.getString("sec1").hex()
val sec2 = c.getString("sec2").hex()
val pub2 = Crypto.xOnlyPubkey(sec2)
val conversationKey = Nip44.conversationKey(sec1, pub2)
assertEquals(c.getString("conversation_key"), Hex.encode(conversationKey))
val payload = Nip44.encrypt(c.getString("plaintext"), conversationKey, c.getString("nonce").hex())
assertEquals("encrypt $i", c.getString("payload"), payload)
val pub1 = Crypto.xOnlyPubkey(sec1)
val roundTripKey = Nip44.conversationKey(sec2, pub1)
assertEquals(c.getString("plaintext"), Nip44.decrypt(payload, roundTripKey))
}
}
@Test
fun invalidConversationKeysAreRejected() {
val cases = vectors.getJSONObject("invalid").getJSONArray("get_conversation_key")
for (i in 0 until cases.length()) {
val c = cases.getJSONObject(i)
val failed = runCatching {
Nip44.conversationKey(c.getString("sec1").hex(), c.getString("pub2").hex())
}.isFailure
assertTrue("case $i should fail", failed)
}
}
@Test
fun invalidPayloadsAreRejected() {
val cases = vectors.getJSONObject("invalid").getJSONArray("decrypt")
for (i in 0 until cases.length()) {
val c = cases.getJSONObject(i)
val failed = runCatching {
Nip44.decrypt(c.getString("payload"), c.getString("conversation_key").hex())
}.isFailure
assertTrue("case $i ${c.optString("note")} should fail", failed)
}
}
private fun String.hex(): ByteArray = Hex.decode(this)
}
@@ -1,397 +0,0 @@
package dev.zapstore.iolite
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.store.FtsReindexProgress
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
class ObservationTest {
private val relay = "wss://relay.example".normalizeRelayUrl()
private val eventSequence = AtomicInteger()
@Test
fun `sessions do not re-emit on other sessions inserts`() = runBlocking {
val client = MultiSubClient()
val purpleQuartz = Iolite.createForTesting(
RecordingEventStore(),
client,
this,
eventVerifier = { true },
)
val statesA = CopyOnWriteArrayList<QueryState>()
val statesB = CopyOnWriteArrayList<QueryState>()
val collectionA = collect(purpleQuartz, Filter(kinds = listOf(1)), statesA)
val collectionB = collect(purpleQuartz, Filter(kinds = listOf(7)), statesB)
client.awaitSubscriptions(2)
client.started(relay)
client.eose(relay)
awaitState(statesA) { it.sync == QuerySync.Live }
awaitState(statesB) { it.sync == QuerySync.Live }
val emittedA = statesA.size
client.event(relay, signedEvent(kind = 7, content = "for-b"))
awaitState(statesB) { state -> state.items.any { it.content == "for-b" } }
delay(150)
assertEquals(emittedA, statesA.size)
collectionA.cancel()
collectionB.cancel()
collectionA.join()
collectionB.join()
purpleQuartz.close()
}
@Test
fun `replaceable update re-emits with the new version`() = runBlocking {
val client = MultiSubClient()
val purpleQuartz = Iolite.createForTesting(
RecordingEventStore(),
client,
this,
eventVerifier = { true },
)
val states = CopyOnWriteArrayList<QueryState>()
val collection = collect(purpleQuartz, Filter(kinds = listOf(0)), states)
client.awaitSubscriptions(1)
client.started(relay)
client.eose(relay)
awaitState(states) { it.sync == QuerySync.Live }
val v1 = signedEvent(kind = 0, content = "v1", createdAt = 100)
client.event(relay, v1)
awaitState(states) { state -> state.items.any { it.id == v1.id } }
val v2 = signedEvent(kind = 0, content = "v2", createdAt = 200)
client.event(relay, v2)
val updated = awaitState(states) { it.items.singleOrNull()?.id == v2.id }
assertEquals("v2", updated.items.single().content)
collection.cancel()
collection.join()
purpleQuartz.close()
}
@Test
fun `events are persisted via a single batch at eose`() = runBlocking {
val client = MultiSubClient()
val store = RecordingEventStore()
val purpleQuartz = Iolite.createForTesting(
store,
client,
this,
config = IoliteConfig(ingestFlushInterval = 10.seconds),
eventVerifier = { true },
)
val states = CopyOnWriteArrayList<QueryState>()
val source = QuerySource.LocalAndRemote(
relays = setOf(relay),
mode = RemoteMode.OneShot(5.seconds),
cachedFor = 6.hours,
)
val collection = launch {
purpleQuartz.query(Filter(kinds = listOf(1)), source).collect(states::add)
}
client.awaitSubscriptions(1)
client.started(relay)
val events = listOf(
signedEvent(content = "one"),
signedEvent(content = "two"),
signedEvent(content = "three"),
)
events.forEach { client.event(relay, it) }
client.eose(relay)
val complete = awaitState(states) { it.sync == QuerySync.Complete }
assertEquals(events.map { it.id }.toSet(), complete.items.map { it.id }.toSet())
assertEquals(listOf(events.map { it.id }), store.batches)
withTimeout(2.seconds) { collection.join() }
purpleQuartz.close()
}
@Test
fun `remote burst is coalesced and complete state contains every event`() = runBlocking {
val client = MultiSubClient()
val purpleQuartz = Iolite.createForTesting(
RecordingEventStore(),
client,
this,
config = IoliteConfig(ingestBatchSize = 1_000, ingestFlushInterval = 10.seconds),
eventVerifier = { true },
)
val states = CopyOnWriteArrayList<QueryState>()
val collection = launch {
purpleQuartz.query(
Filter(kinds = listOf(1)),
QueryOptions.remote(setOf(relay), RemoteMode.OneShot(5.seconds)),
).collect(states::add)
}
client.awaitSubscriptions(1)
client.started(relay)
val events = (0 until 500).map { signedEvent(content = "burst-$it") }
events.forEach { client.event(relay, it) }
client.eose(relay)
val complete = awaitState(states) { it.phase == QueryPhase.Complete }
assertEquals(events.map(Event::id), complete.items.map(Event::id))
assertTrue("burst snapshots were not coalesced", states.size < events.size / 2)
withTimeout(2.seconds) { collection.join() }
purpleQuartz.close()
}
@Test
fun `duplicate batch rejection is verified with one id query`() = runBlocking {
val client = MultiSubClient()
val store = RecordingEventStore(rejectBatchInserts = true)
val events = (0 until 100).map { signedEvent(content = "duplicate-$it") }
events.forEach { store.insert(it) }
val purpleQuartz = Iolite.createForTesting(
store,
client,
this,
config = IoliteConfig(ingestBatchSize = 100, ingestFlushInterval = 10.seconds),
eventVerifier = { true },
)
val states = CopyOnWriteArrayList<QueryState>()
val collection = launch {
purpleQuartz.query(
Filter(kinds = listOf(1)),
QueryOptions.remote(setOf(relay), RemoteMode.OneShot(5.seconds)),
).collect(states::add)
}
client.awaitSubscriptions(1)
client.started(relay)
events.forEach { client.event(relay, it) }
client.eose(relay)
awaitState(states) { it.phase == QueryPhase.Complete }
assertEquals(1, store.queryCalls.get())
withTimeout(2.seconds) { collection.join() }
purpleQuartz.close()
}
@Test
fun `slow local collector cannot block remote persistence`() = runBlocking {
val client = MultiSubClient()
val purpleQuartz = Iolite.createForTesting(
RecordingEventStore(),
client,
this,
config = IoliteConfig(ingestBatchSize = 500, ingestFlushInterval = 10.seconds),
eventVerifier = { true },
)
val slowCollection = launch {
purpleQuartz.query(Filter(kinds = listOf(1))).collect {
delay(100)
}
}
val remoteStates = CopyOnWriteArrayList<QueryState>()
val remoteCollection = launch {
purpleQuartz.query(
Filter(kinds = listOf(1)),
QueryOptions.remote(setOf(relay), RemoteMode.OneShot(5.seconds)),
).collect(remoteStates::add)
}
client.awaitSubscriptions(1)
client.started(relay)
repeat(400) { client.event(relay, signedEvent(content = "write-$it")) }
client.eose(relay)
val complete = awaitState(remoteStates) { it.phase == QueryPhase.Complete }
assertEquals(400, complete.items.size)
withTimeout(2.seconds) { remoteCollection.join() }
slowCollection.cancel()
slowCollection.join()
purpleQuartz.close()
}
private fun kotlinx.coroutines.CoroutineScope.collect(
purpleQuartz: Iolite,
filter: Filter,
states: MutableList<QueryState>,
): Job = launch {
purpleQuartz.query(
filter,
QuerySource.LocalAndRemote(setOf(relay), RemoteMode.Stream, cachedFor = 6.hours),
).collect(states::add)
}
private suspend fun awaitState(
states: List<QueryState>,
predicate: (QueryState) -> Boolean,
): QueryState = withTimeout(5.seconds) {
while (true) {
states.lastOrNull(predicate)?.let { return@withTimeout it }
delay(10)
}
error("unreachable")
}
private fun signedEvent(
kind: Int = 1,
content: String,
createdAt: Long = 1_750_000_000,
): Event {
val id = eventSequence.incrementAndGet().toString(16).padStart(64, '0')
return Event(
id = id,
pubKey = "1".repeat(64),
createdAt = createdAt,
kind = kind,
tags = emptyArray(),
content = content,
sig = "2".repeat(128),
)
}
}
private class MultiSubClient : INostrClient by EmptyNostrClient() {
private data class Sub(
val listener: SubscriptionListener,
val filters: Map<NormalizedRelayUrl, List<Filter>>,
)
private val subs = CopyOnWriteArrayList<Sub>()
override fun subscribe(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
) {
if (listener != null) subs += Sub(listener, filters)
}
override fun unsubscribe(subId: String) = Unit
suspend fun awaitSubscriptions(count: Int) {
withTimeout(2.seconds) {
while (subs.size < count) delay(10)
}
}
fun started(relay: NormalizedRelayUrl) {
subs.forEach { it.listener.onSubscriptionStarted(relay.url, it.filters[relay].orEmpty()) }
}
fun eose(relay: NormalizedRelayUrl) {
subs.forEach { it.listener.onEose(relay, it.filters[relay]) }
}
fun event(relay: NormalizedRelayUrl, event: Event) {
subs.forEach { sub ->
val filters = sub.filters[relay].orEmpty()
if (filters.any { it.match(event) }) {
sub.listener.onEvent(event, true, relay, filters)
}
}
}
}
private class RecordingEventStore(
private val rejectBatchInserts: Boolean = false,
) : IEventStore {
override val relay: NormalizedRelayUrl? = null
val batches = CopyOnWriteArrayList<List<String>>()
val queryCalls = AtomicInteger()
private val events = mutableListOf<Event>()
override suspend fun insert(event: Event) {
synchronized(events) {
if (events.none { it.id == event.id }) events += event
}
}
override suspend fun batchInsert(events: List<Event>): List<IEventStore.InsertOutcome> {
batches += events.map { it.id }
if (rejectBatchInserts) return events.map { IEventStore.InsertOutcome.Rejected("duplicate") }
return events.map { event ->
synchronized(this.events) {
if (this.events.none { it.id == event.id }) this.events += event
}
IEventStore.InsertOutcome.Accepted
}
}
override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) {
body(object : IEventStore.ITransaction {
override fun insert(event: Event) {
synchronized(events) {
if (events.none { it.id == event.id }) events += event
}
}
})
}
@Suppress("UNCHECKED_CAST")
override suspend fun <T : Event> query(filter: Filter): List<T> {
queryCalls.incrementAndGet()
return synchronized(events) { events.filter(filter::match).map { it as T } }
}
@Suppress("UNCHECKED_CAST")
override suspend fun <T : Event> query(filters: List<Filter>): List<T> {
queryCalls.incrementAndGet()
return synchronized(events) {
events.filter { event -> filters.any { it.match(event) } }.map { it as T }
}
}
override suspend fun <T : Event> query(filter: Filter, onEach: (T) -> Unit) {
query<T>(filter).forEach(onEach)
}
override suspend fun <T : Event> query(filters: List<Filter>, onEach: (T) -> Unit) {
query<T>(filters).forEach(onEach)
}
override suspend fun count(filter: Filter): Int = query<Event>(filter).size
override suspend fun count(filters: List<Filter>): Int = query<Event>(filters).size
override suspend fun delete(filter: Filter) {
synchronized(events) { events.removeAll(filter::match) }
}
override suspend fun delete(filters: List<Filter>) {
synchronized(events) {
events.removeAll { event -> filters.any { it.match(event) } }
}
}
override suspend fun deleteExpiredEvents() = Unit
override suspend fun reindexFullTextSearch() = Unit
override suspend fun reindexFullTextSearch(
resumeFrom: String?,
batchSize: Int,
): FtsReindexProgress = throw UnsupportedOperationException()
override fun close() = Unit
}
@@ -1,276 +0,0 @@
package dev.zapstore.iolite
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.store.FtsReindexProgress
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import java.util.concurrent.CopyOnWriteArrayList
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.time.Duration.Companion.seconds
class OutboxRouterTest {
private val bootstrap = setOf("wss://bootstrap.example".normalizeRelayUrl())
private val fallback = setOf("wss://fallback.example".normalizeRelayUrl())
private val outbox = "wss://outbox.example".normalizeRelayUrl()
private val author = "2".repeat(64)
@Test
fun `resolveReadRelays returns read and unmarked relays from the local store`() = runBlocking {
val store = RouterTestStore()
store.insert(relayListEvent(arrayOf(
arrayOf("r", "wss://read.example"),
arrayOf("r", "wss://read2.example", "read"),
arrayOf("r", "wss://write.example", "write"),
arrayOf("r", "http://insecure.example"),
)))
val client = RouterTestClient()
val purpleQuartz = Iolite.createForTesting(store, client, this)
val router = OutboxRouter(purpleQuartz, bootstrap)
var resolved: Set<NormalizedRelayUrl>? = null
val resolveJob = launch { resolved = router.resolveReadRelays(author) }
client.eose(client.awaitSub(kinds = listOf(OutboxRouter.RELAY_LIST_KIND)))
withTimeout(2.seconds) { resolveJob.join() }
assertEquals(
setOf("wss://read.example".normalizeRelayUrl(), "wss://read2.example".normalizeRelayUrl()),
resolved,
)
purpleQuartz.close()
}
@Test
fun `resolveReadRelays uses the freshness cache without touching relays`() = runBlocking {
val store = RouterTestStore()
store.insert(relayListEvent(arrayOf(arrayOf("r", "wss://read.example"))))
val refreshCache = InMemoryQueryRefreshCache()
val filter = Filter(authors = listOf(author), kinds = listOf(OutboxRouter.RELAY_LIST_KIND), limit = 1)
refreshCache.recordRefresh(QueryFingerprint.create(listOf(filter), bootstrap), System.currentTimeMillis())
val client = RouterTestClient()
val purpleQuartz = Iolite.createForTesting(store, client, this, refreshCache = refreshCache)
val router = OutboxRouter(purpleQuartz, bootstrap)
val resolved = withTimeout(2.seconds) { router.resolveReadRelays(author) }
assertEquals(setOf("wss://read.example".normalizeRelayUrl()), resolved)
assertTrue(client.subs.isEmpty())
purpleQuartz.close()
}
@Test
fun `queryWithOutbox restarts the remote side on the expanded relay set`() = runBlocking {
val store = RouterTestStore()
store.insert(relayListEvent(arrayOf(arrayOf("r", outbox.url))))
val client = RouterTestClient()
val purpleQuartz = Iolite.createForTesting(store, client, this)
val router = OutboxRouter(purpleQuartz, bootstrap)
val collection = launch {
router.queryWithOutbox(
listOf(Filter(kinds = listOf(9735), limit = 10)),
authors = listOf(author),
fallbackRelays = fallback,
).collect { }
}
val fallbackSub = client.awaitSub(kinds = listOf(9735), relays = fallback)
client.eose(client.awaitSub(kinds = listOf(OutboxRouter.RELAY_LIST_KIND)))
val expandedSub = client.awaitSub(kinds = listOf(9735), relays = fallback + outbox, skip = fallbackSub)
assertEquals(fallback + outbox, expandedSub.relays)
collection.cancelAndJoin()
purpleQuartz.close()
}
@Test
fun `queryWithOutbox can replace the fallback set with resolved relays`() = runBlocking {
val store = RouterTestStore()
store.insert(relayListEvent(arrayOf(arrayOf("r", outbox.url))))
val client = RouterTestClient()
val purpleQuartz = Iolite.createForTesting(store, client, this)
val router = OutboxRouter(purpleQuartz, bootstrap)
val collection = launch {
router.queryWithOutbox(
listOf(Filter(kinds = listOf(0), limit = 1)),
authors = listOf(author),
fallbackRelays = fallback,
unionWithFallback = false,
).collect { }
}
client.awaitSub(kinds = listOf(0), relays = fallback)
client.eose(client.awaitSub(kinds = listOf(OutboxRouter.RELAY_LIST_KIND)))
val expandedSub = client.awaitSub(kinds = listOf(0), relays = setOf(outbox))
assertEquals(setOf(outbox), expandedSub.relays)
collection.cancelAndJoin()
purpleQuartz.close()
}
@Test
fun `queryWithOutbox keeps the fallback relays when nothing resolves`() = runBlocking {
val store = RouterTestStore()
val client = RouterTestClient()
val purpleQuartz = Iolite.createForTesting(store, client, this)
val router = OutboxRouter(purpleQuartz, bootstrap)
val collection = launch {
router.queryWithOutbox(
listOf(Filter(kinds = listOf(9735), limit = 10)),
authors = listOf(author),
fallbackRelays = fallback,
).collect { }
}
client.awaitSub(kinds = listOf(9735), relays = fallback)
client.eose(client.awaitSub(kinds = listOf(OutboxRouter.RELAY_LIST_KIND)))
delay(300)
assertEquals(1, client.subs.count { it.kinds == listOf(9735) })
collection.cancelAndJoin()
purpleQuartz.close()
}
@Test
fun `resolveReadRelays returns empty when the fetch cannot complete`() = runBlocking {
val store = RouterTestStore()
val client = RouterTestClient()
val purpleQuartz = Iolite.createForTesting(store, client, this)
val router = OutboxRouter(purpleQuartz, bootstrap, resolveTimeout = 1.seconds)
var resolved: Set<NormalizedRelayUrl>? = null
val resolveJob = launch { resolved = router.resolveReadRelays(author) }
client.fail(client.awaitSub(kinds = listOf(OutboxRouter.RELAY_LIST_KIND)))
withTimeout(4.seconds) { resolveJob.join() }
assertEquals(emptySet<NormalizedRelayUrl>(), resolved)
purpleQuartz.close()
}
private fun relayListEvent(tags: Array<Array<String>>): Event = Event(
id = "3".repeat(64),
pubKey = author,
createdAt = 1_750_000_000,
kind = OutboxRouter.RELAY_LIST_KIND,
tags = tags,
content = "",
sig = "4".repeat(128),
)
}
private class RouterTestClient : INostrClient by EmptyNostrClient() {
class Sub(
val filters: Map<NormalizedRelayUrl, List<Filter>>,
val listener: SubscriptionListener,
) {
val relays: Set<NormalizedRelayUrl> get() = filters.keys
val kinds: List<Int> get() = filters.values.flatten().flatMap { it.kinds.orEmpty() }.distinct().sorted()
}
val subs = CopyOnWriteArrayList<Sub>()
override fun subscribe(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
) {
if (listener != null) subs += Sub(filters, listener)
}
override fun unsubscribe(subId: String) = Unit
suspend fun awaitSub(
kinds: List<Int>,
relays: Set<NormalizedRelayUrl>? = null,
skip: Sub? = null,
): Sub = withTimeout(2.seconds) {
while (true) {
subs.firstOrNull {
it !== skip && it.kinds == kinds && (relays == null || it.relays == relays)
}?.let { return@withTimeout it }
delay(10)
}
@Suppress("UNREACHABLE_CODE") error("unreachable")
}
fun eose(sub: Sub) {
sub.filters.forEach { (relay, filters) ->
sub.listener.onSubscriptionStarted(relay.url, filters)
sub.listener.onEose(relay, filters)
}
}
fun fail(sub: Sub) {
sub.filters.forEach { (relay, filters) ->
sub.listener.onCannotConnect(relay, "relay unreachable", filters)
}
}
}
private class RouterTestStore : IEventStore {
override val relay: NormalizedRelayUrl? = null
private val events = mutableListOf<Event>()
override suspend fun insert(event: Event) {
events += event
}
override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) {
body(object : IEventStore.ITransaction {
override fun insert(event: Event) {
events += event
}
})
}
@Suppress("UNCHECKED_CAST")
override suspend fun <T : Event> query(filter: Filter): List<T> =
events.filter(filter::match).map { it as T }
@Suppress("UNCHECKED_CAST")
override suspend fun <T : Event> query(filters: List<Filter>): List<T> =
events.filter { event -> filters.any { it.match(event) } }.map { it as T }
override suspend fun <T : Event> query(filter: Filter, onEach: (T) -> Unit) {
query<T>(filter).forEach(onEach)
}
override suspend fun <T : Event> query(filters: List<Filter>, onEach: (T) -> Unit) {
query<T>(filters).forEach(onEach)
}
override suspend fun count(filter: Filter): Int = query<Event>(filter).size
override suspend fun count(filters: List<Filter>): Int = query<Event>(filters).size
override suspend fun delete(filter: Filter) {
events.removeAll(filter::match)
}
override suspend fun delete(filters: List<Filter>) {
events.removeAll { event -> filters.any { it.match(event) } }
}
override suspend fun deleteExpiredEvents() = Unit
override suspend fun reindexFullTextSearch() = Unit
override suspend fun reindexFullTextSearch(
resumeFrom: String?,
batchSize: Int,
): FtsReindexProgress = throw UnsupportedOperationException()
override fun close() = Unit
}
@@ -1,28 +1,18 @@
package dev.zapstore.iolite
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.ObservableEventStore
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.nip40Expiration.isExpired
import java.lang.reflect.Modifier
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class PublicContractsTest {
@Test
fun `config rejects invalid lifecycle values`() {
fun configRejectsInvalidLifecycleValues() {
assertFails { IoliteConfig(databaseName = "../store.db").validate() }
assertFails { IoliteConfig(oneShotTimeout = 0.milliseconds).validate() }
assertFails { IoliteConfig(eoseGrace = 0.milliseconds).validate() }
assertFails { IoliteConfig(ingestionCapacity = 0).validate() }
assertFails { IoliteConfig(expirationSweepInterval = 0.milliseconds).validate() }
assertFails { IoliteConfig(ingestBatchSize = 0).validate() }
@@ -32,7 +22,7 @@ class PublicContractsTest {
}
@Test
fun `query options enforce complementary source and remote modes`() {
fun queryOptionsEnforceModes() {
assertFails { QueryOptions(SourceMode.Remote, RemoteMode.Stream) }
assertFails { QueryOptions(SourceMode.LocalAndRemote, RemoteMode.Stream) }
assertFails { QueryOptions(SourceMode.Remote) }
@@ -51,41 +41,10 @@ class PublicContractsTest {
QueryOptions(SourceMode.Remote, RemoteMode.Stream, setOf(relay), cachedFor = 6.hours)
}
assertTrue(QueryOptions.remote(setOf(relay), RemoteMode.Stream).relays.contains(relay))
assertTrue(
QueryOptions.localAndRemote(setOf(relay), RemoteMode.Stream, cachedFor = 6.hours)
.relays.contains(relay),
)
}
@Test
fun `published Quartz APIs used by the facade remain callable`() {
// This is deliberately a compile-level compatibility gate for the public APIs
// Iolite relies on. Behavioral compatibility is covered by Android tests.
val invalidEvent = Event(
id = "0".repeat(64),
pubKey = "0".repeat(64),
createdAt = 0,
kind = 1,
tags = emptyArray(),
content = "",
sig = "0".repeat(128),
)
assertTrue(invalidEvent.isExpired().not())
val filter = Filter(kinds = listOf(1))
assertTrue(filter.match(invalidEvent))
assertPublicType<INostrClient>()
assertPublicType<NostrClient>()
assertPublicType<SubscriptionListener>()
assertPublicType<WebsocketBuilder>()
assertPublicType<IEventStore>()
assertPublicType<ObservableEventStore>()
assertPublicType<EventStore>()
}
@Test
fun `query phases reject transitions out of terminal states`() {
fun queryPhasesRejectTransitionsOutOfTerminalStates() {
assertTrue(isLegalPhaseTransition(null, QueryPhase.Connecting))
assertTrue(isLegalPhaseTransition(QueryPhase.Connecting, QueryPhase.CatchingUp))
assertTrue(isLegalPhaseTransition(QueryPhase.CatchingUp, QueryPhase.Live))
@@ -96,8 +55,21 @@ class PublicContractsTest {
assertTrue(!isLegalPhaseTransition(QueryPhase.Failed, QueryPhase.Connecting))
}
private inline fun <reified T> assertPublicType() {
assertTrue(Modifier.isPublic(T::class.java.modifiers))
@Test
fun deltasUrlUsesRelayOrigin() {
assertEquals(
"https://relay.example.com/deltas?from=0",
"wss://relay.example.com/nostr?x=1#frag".normalizeRelayUrl().deltasUrl(0),
)
assertEquals(
"http://127.0.0.1:3334/deltas?from=7",
"ws://127.0.0.1:3334".normalizeRelayUrl().deltasUrl(7),
)
}
@Test
fun bolt11ParsesMilliAmount() {
assertEquals(100_000, Bolt11.amountSats("lnbc1m1p..."))
}
private fun assertFails(block: () -> Unit) {
@@ -1,819 +0,0 @@
package dev.zapstore.iolite
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.store.FtsReindexProgress
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
class QueryInvariantTest {
private val relay = "wss://relay.example".normalizeRelayUrl()
private val eventSequence = AtomicInteger()
@Test
fun `successful empty one-shot is not cached across facades`() = runBlocking {
val cache = InMemoryQueryRefreshCache()
val clock = MutableEpochClock(1_000)
val filter = Filter(kinds = listOf(1))
val source = QuerySource.LocalAndRemote(
relays = setOf(relay),
mode = RemoteMode.OneShot(5.seconds),
cachedFor = 6.hours,
)
val firstClient = ControlledClient()
val first = Iolite.createForTesting(
ControlledEventStore(),
firstClient,
this,
databasePath = "persistent-cache-test",
eventVerifier = { true },
refreshCache = cache,
clock = clock,
)
val firstStates = CopyOnWriteArrayList<QueryState>()
val firstCollection = launch { first.query(filter, source).collect(firstStates::add) }
firstClient.awaitSubscription()
firstClient.started(relay)
firstClient.eose(relay)
awaitState(firstStates) { it.sync == QuerySync.Complete }
withTimeout(2.seconds) { firstCollection.join() }
first.close()
val secondClient = ControlledClient()
val second = Iolite.createForTesting(
ControlledEventStore(),
secondClient,
this,
databasePath = "persistent-cache-test",
eventVerifier = { true },
refreshCache = cache,
clock = clock,
)
val staleCollection = launch { second.query(filter, source).collect { } }
secondClient.awaitSubscription()
assertEquals(1, secondClient.requests.size)
staleCollection.cancel()
staleCollection.join()
second.close()
}
@Test
fun `fresh one-shot completes from a non-empty local projection`() = runBlocking {
val cache = InMemoryQueryRefreshCache()
val clock = MutableEpochClock(10_000)
val filter = Filter(kinds = listOf(1))
val options = QueryOptions.localAndRemote(
relays = setOf(relay),
remoteMode = RemoteMode.OneShot(5.seconds),
cachedFor = 1.hours,
)
cache.recordRefresh(QueryFingerprint.create(listOf(filter), setOf(relay)), clock.now())
val store = ControlledEventStore()
val event = signedEvent(content = "persisted")
store.insert(event)
val client = ControlledClient()
val purpleQuartz = Iolite.createForTesting(
store,
client,
this,
eventVerifier = { true },
refreshCache = cache,
clock = clock,
)
val states = purpleQuartz.query(filter, options).toList()
assertEquals(listOf(QueryPhase.Cached), states.map(QueryState::phase))
assertEquals(listOf(event.id), states.single().items.map(Event::id))
assertTrue(client.requests.isEmpty())
purpleQuartz.close()
}
@Test
fun `freshness marker is ignored when local projection is empty`() = runBlocking {
val cache = InMemoryQueryRefreshCache()
val clock = MutableEpochClock(10_000)
val filter = Filter(kinds = listOf(1))
val options = QueryOptions.localAndRemote(
relays = setOf(relay),
remoteMode = RemoteMode.Stream,
cachedFor = 1.hours,
)
cache.recordRefresh(QueryFingerprint.create(listOf(filter), setOf(relay)), clock.now())
val client = ControlledClient()
val purpleQuartz = Iolite.createForTesting(
ControlledEventStore(),
client,
this,
eventVerifier = { true },
refreshCache = cache,
clock = clock,
)
val states = CopyOnWriteArrayList<QueryState>()
val collection = launch { purpleQuartz.query(filter, options).collect(states::add) }
client.awaitSubscription()
assertTrue(states.none { it.phase == QueryPhase.Cached })
collection.cancel()
collection.join()
purpleQuartz.close()
}
@Test
fun `fresh stream defers remote and observes an extended refresh window`() = runBlocking {
val cache = InMemoryQueryRefreshCache()
val filter = Filter(kinds = listOf(1))
val source = QuerySource.LocalAndRemote(
relays = setOf(relay),
mode = RemoteMode.Stream,
cachedFor = 1.seconds,
)
val fingerprint = QueryFingerprint.create(listOf(filter), setOf(relay))
cache.recordRefresh(fingerprint, System.currentTimeMillis())
val client = ControlledClient()
val store = ControlledEventStore()
store.insert(signedEvent(content = "cached"))
val purpleQuartz = Iolite.createForTesting(
store,
client,
this,
eventVerifier = { true },
refreshCache = cache,
)
val states = CopyOnWriteArrayList<QueryState>()
val collection = collect(purpleQuartz, source, states)
val cached = awaitState(states) { it.sync == QuerySync.Cached }
assertTrue(cached.relays.isEmpty())
assertTrue(client.requests.isEmpty())
cache.recordRefresh(fingerprint, System.currentTimeMillis())
delay(700)
cache.recordRefresh(fingerprint, System.currentTimeMillis())
delay(500)
assertTrue(client.requests.isEmpty())
client.awaitSubscription()
assertEquals(1, client.requests.size)
collection.cancel()
collection.join()
purpleQuartz.close()
}
@Test
fun `timeout and persistence failure do not populate freshness`() = runBlocking {
val filter = Filter(kinds = listOf(1))
val source = QuerySource.LocalAndRemote(
relays = setOf(relay),
mode = RemoteMode.OneShot(50.milliseconds),
cachedFor = 1.hours,
)
val fingerprint = QueryFingerprint.create(listOf(filter), setOf(relay))
val timeoutCache = InMemoryQueryRefreshCache()
val timeoutClient = ControlledClient()
val timeoutQuartz = Iolite.createForTesting(
ControlledEventStore(),
timeoutClient,
this,
config = IoliteConfig(oneShotTimeout = 50.milliseconds),
eventVerifier = { true },
refreshCache = timeoutCache,
)
val timeoutStates = CopyOnWriteArrayList<QueryState>()
val timeoutCollection = collect(timeoutQuartz, source, timeoutStates)
timeoutClient.awaitSubscription()
awaitState(timeoutStates) { it.sync == QuerySync.TimedOut }
withTimeout(2.seconds) { timeoutCollection.join() }
assertEquals(null, timeoutCache.lastRefresh(fingerprint))
timeoutQuartz.close()
val failureCache = InMemoryQueryRefreshCache()
val failureClient = ControlledClient()
val failureQuartz = Iolite.createForTesting(
ControlledEventStore(failInserts = true),
failureClient,
this,
eventVerifier = { true },
refreshCache = failureCache,
)
val failureStates = CopyOnWriteArrayList<QueryState>()
val failureCollection = collect(failureQuartz, source, failureStates)
failureClient.awaitSubscription()
failureClient.started(relay)
failureClient.event(relay, signedEvent(content = "failed-refresh"))
awaitState(failureStates) { it.sync == QuerySync.Failed }
withTimeout(2.seconds) { failureCollection.join() }
assertEquals(null, failureCache.lastRefresh(fingerprint))
failureQuartz.close()
}
@Test
fun `failed final local projection fails query without caching`() = runBlocking {
val cache = InMemoryQueryRefreshCache()
val client = ControlledClient()
val filter = Filter(kinds = listOf(1))
val source = QuerySource.LocalAndRemote(
relays = setOf(relay),
mode = RemoteMode.OneShot(5.seconds),
cachedFor = 1.hours,
)
val fingerprint = QueryFingerprint.create(listOf(filter), setOf(relay))
val purpleQuartz = Iolite.createForTesting(
ControlledEventStore(failQueriesAfter = 1),
client,
this,
eventVerifier = { true },
refreshCache = cache,
)
val states = CopyOnWriteArrayList<QueryState>()
val collection = collect(purpleQuartz, source, states)
client.awaitSubscription()
client.started(relay)
client.eose(relay)
val failed = awaitState(states) { it.sync == QuerySync.Failed }
assertTrue(failed.error is QueryError.UnsupportedLocalProjection)
assertEquals(null, cache.lastRefresh(fingerprint))
withTimeout(2.seconds) { collection.join() }
purpleQuartz.close()
}
@Test
fun `reconnect advances generation and accepts replacement EOSE`() = runBlocking {
val client = ControlledClient()
val purpleQuartz = Iolite.createForTesting(
ControlledEventStore(),
client,
this,
eventVerifier = { true },
)
val states = CopyOnWriteArrayList<QueryState>()
val collection = collect(purpleQuartz, QuerySource.Remote(setOf(relay)), states)
client.awaitSubscription()
client.started(relay)
client.eose(relay)
awaitState(states) { it.sync == QuerySync.Live && it.relays[relay]?.generation == 1L }
client.disconnected(relay)
client.connecting(relay)
client.started(relay)
client.eose(relay)
val reconnected = awaitState(states) {
it.sync == QuerySync.Live && it.relays[relay]?.generation == 2L
}
assertEquals(RelayConnectionState.Connected, reconnected.relays.getValue(relay).connection)
assertTrue(reconnected.relays.getValue(relay).eose)
collection.cancel()
collection.join()
purpleQuartz.close()
}
@Test
fun `local and remote stream preserves items through disconnect and reconnect`() = runBlocking {
val client = ControlledClient()
val purpleQuartz = Iolite.createForTesting(
ControlledEventStore(),
client,
this,
eventVerifier = { true },
)
val options = QueryOptions.localAndRemote(setOf(relay), RemoteMode.Stream)
val states = CopyOnWriteArrayList<QueryState>()
val collection = launch {
purpleQuartz.query(Filter(kinds = listOf(1)), options).collect(states::add)
}
client.awaitSubscription()
client.started(relay)
val event = signedEvent(content = "offline-survivor")
client.event(relay, event)
client.eose(relay)
awaitState(states) { it.phase == QueryPhase.Live && it.items.any { item -> item.id == event.id } }
client.disconnected(relay)
val disconnected = awaitState(states) {
it.phase == QueryPhase.Connecting && it.items.any { item -> item.id == event.id }
}
assertEquals(listOf(event.id), disconnected.items.map(Event::id))
client.connecting(relay)
client.started(relay)
client.eose(relay)
val reconnected = awaitState(states) {
it.phase == QueryPhase.Live && it.relays[relay]?.generation == 2L
}
assertEquals(listOf(event.id), reconnected.items.map(Event::id))
collection.cancel()
collection.join()
purpleQuartz.close()
}
@Test
fun `facade close emits lifecycle failure and completes active collectors`() = runBlocking {
val client = ControlledClient()
val store = ControlledEventStore()
val purpleQuartz = Iolite.createForTesting(store, client, this, eventVerifier = { true })
val states = CopyOnWriteArrayList<QueryState>()
val collection = collect(purpleQuartz, QuerySource.Remote(setOf(relay)), states)
client.awaitSubscription()
awaitState(states) { it.sync == QuerySync.Connecting }
purpleQuartz.close()
withTimeout(2.seconds) { collection.join() }
assertTrue(states.last().error is QueryError.Lifecycle)
assertEquals(QuerySync.Failed, states.last().sync)
assertEquals(1, client.unsubscribeCount.get())
assertEquals(1, client.closeCount.get())
assertEquals(1, store.closeCount.get())
purpleQuartz.close()
}
@Test
fun `local first completion waits for commit and final projection`() = runBlocking {
val client = ControlledClient()
val store = ControlledEventStore(blockInserts = true)
val purpleQuartz = Iolite.createForTesting(store, client, this, eventVerifier = { true })
val states = CopyOnWriteArrayList<QueryState>()
val source = QuerySource.LocalAndRemote(setOf(relay), RemoteMode.OneShot(5.seconds))
val collection = collect(purpleQuartz, source, states)
val event = signedEvent(content = "local-first")
client.awaitSubscription()
client.started(relay)
client.event(relay, event)
client.eose(relay)
withTimeout(2.seconds) { store.insertionStarted.await() }
assertFalse(states.any { state -> state.items.any { it.id == event.id } })
assertFalse(states.any { it.sync == QuerySync.Complete })
store.releaseInsert.complete(Unit)
val complete = awaitState(states) { it.sync == QuerySync.Complete }
assertTrue(complete.items.any { it.id == event.id })
withTimeout(2.seconds) { collection.join() }
purpleQuartz.close()
}
@Test
fun `timeout unsubscribes immediately then drains accepted persistence`() = runBlocking {
val client = ControlledClient()
val store = ControlledEventStore(blockInserts = true)
val config = IoliteConfig(oneShotTimeout = 100.milliseconds)
val purpleQuartz = Iolite.createForTesting(store, client, this, config, eventVerifier = { true })
val states = CopyOnWriteArrayList<QueryState>()
val source = QuerySource.Remote(setOf(relay), RemoteMode.OneShot())
val collection = collect(purpleQuartz, source, states)
val event = signedEvent(content = "timeout")
client.awaitSubscription()
client.started(relay)
client.event(relay, event)
withTimeout(2.seconds) { store.insertionStarted.await() }
client.eose(relay)
withTimeout(2.seconds) {
while (client.unsubscribeCount.get() == 0) delay(10)
}
assertFalse(states.any { it.sync == QuerySync.TimedOut })
assertFalse(states.any { it.sync == QuerySync.Complete })
assertTrue(states.any { state -> state.items.any { it.id == event.id } })
store.releaseInsert.complete(Unit)
val timedOut = awaitState(states) { it.sync == QuerySync.TimedOut }
assertTrue(timedOut.items.any { it.id == event.id })
assertFalse(states.any { it.sync == QuerySync.Complete })
withTimeout(2.seconds) { collection.join() }
purpleQuartz.close()
}
@Test
fun `bounded ingestion fails closed instead of dropping EOSE`() = runBlocking {
val client = ControlledClient()
val store = ControlledEventStore(blockInserts = true)
val config = IoliteConfig(ingestionCapacity = 1, ingestBatchSize = 1)
val purpleQuartz = Iolite.createForTesting(store, client, this, config, eventVerifier = { true })
val states = CopyOnWriteArrayList<QueryState>()
val source = QuerySource.Remote(setOf(relay), RemoteMode.OneShot(5.seconds))
val collection = collect(purpleQuartz, source, states)
client.awaitSubscription()
client.started(relay)
client.event(relay, signedEvent(content = "first"))
withTimeout(2.seconds) { store.insertionStarted.await() }
client.event(relay, signedEvent(content = "queued"))
client.eose(relay)
val failed = awaitState(states) { it.sync == QuerySync.Failed }
assertTrue(failed.error is QueryError.IngestionSaturated)
assertFalse(states.any { it.sync == QuerySync.Complete || it.sync == QuerySync.Live })
assertEquals(1, client.unsubscribeCount.get())
withTimeout(2.seconds) { collection.join() }
purpleQuartz.close()
}
@Test
fun `remote persistence failure keeps directly emitted item visible`() = runBlocking {
val client = ControlledClient()
val store = ControlledEventStore(failInserts = true)
val purpleQuartz = Iolite.createForTesting(store, client, this, eventVerifier = { true })
val states = CopyOnWriteArrayList<QueryState>()
val collection = collect(purpleQuartz, QuerySource.Remote(setOf(relay)), states)
val event = signedEvent(content = "visible-before-save")
client.awaitSubscription()
client.started(relay)
client.event(relay, event)
val failed = awaitState(states) { it.sync == QuerySync.Failed }
assertTrue(failed.error is QueryError.PersistenceFailure)
assertTrue(failed.items.any { it.id == event.id })
withTimeout(2.seconds) { collection.join() }
purpleQuartz.close()
}
@Test
fun `prune rules delete events beyond max age and keep the rest`() = runBlocking {
val store = ControlledEventStore()
val nowMillis = 1_800_000_000_000L
val clock = MutableEpochClock(nowMillis)
val purpleQuartz = Iolite.createForTesting(
store,
ControlledClient(),
this,
config = IoliteConfig(
expirationSweepInterval = 50.milliseconds,
pruneRules = mapOf(9735 to 1.hours),
),
eventVerifier = { true },
clock = clock,
)
val nowSeconds = nowMillis / 1_000
val old = signedEvent(content = "old", kind = 9735, createdAt = nowSeconds - 2.hours.inWholeSeconds)
val recent = signedEvent(content = "recent", kind = 9735, createdAt = nowSeconds)
val otherKind = signedEvent(content = "other", kind = 1, createdAt = nowSeconds - 2.hours.inWholeSeconds)
store.insert(old)
store.insert(recent)
store.insert(otherKind)
withTimeout(2.seconds) {
while (store.query<Event>(Filter(kinds = listOf(9735))).size > 1) delay(10)
}
val remaining = store.query<Event>(Filter())
assertEquals(setOf(recent.id, otherKind.id), remaining.map { it.id }.toSet())
purpleQuartz.close()
}
@Test
fun `relay CLOSED exposes closed state and fails the query`() = runBlocking {
val client = ControlledClient()
val purpleQuartz = Iolite.createForTesting(
ControlledEventStore(),
client,
this,
eventVerifier = { true },
)
val states = CopyOnWriteArrayList<QueryState>()
val collection = collect(purpleQuartz, QuerySource.Remote(setOf(relay)), states)
client.awaitSubscription()
client.started(relay)
client.closed(relay)
val failed = awaitState(states) { it.sync == QuerySync.Failed }
assertTrue(failed.error is QueryError.RelayFailure)
assertEquals(RelayConnectionState.Closed, failed.relays.getValue(relay).connection)
withTimeout(2.seconds) { collection.join() }
purpleQuartz.close()
}
@Test
fun `one closed relay does not terminate a healthy multi-relay stream`() = runBlocking {
val otherRelay = "wss://other.example".normalizeRelayUrl()
val client = ControlledClient()
val purpleQuartz = Iolite.createForTesting(
ControlledEventStore(),
client,
this,
eventVerifier = { true },
)
val states = CopyOnWriteArrayList<QueryState>()
val options = QueryOptions.remote(setOf(relay, otherRelay), RemoteMode.Stream)
val collection = launch {
purpleQuartz.query(Filter(kinds = listOf(1)), options).collect(states::add)
}
client.awaitSubscription()
client.started(relay)
client.started(otherRelay)
client.closed(relay)
client.eose(otherRelay)
val live = awaitState(states) { it.phase == QueryPhase.Live }
assertEquals(RelayConnectionState.Closed, live.relays.getValue(relay).connection)
assertEquals(RelayConnectionState.Connected, live.relays.getValue(otherRelay).connection)
assertTrue(collection.isActive)
collection.cancel()
collection.join()
purpleQuartz.close()
}
@Test
fun `subscription startup failure is classified as incompatible dependency`() = runBlocking {
val client = ControlledClient(failSubscribe = true)
val purpleQuartz = Iolite.createForTesting(
ControlledEventStore(),
client,
this,
eventVerifier = { true },
)
val states = CopyOnWriteArrayList<QueryState>()
val collection = collect(purpleQuartz, QuerySource.Remote(setOf(relay)), states)
val failed = awaitState(states) { it.sync == QuerySync.Failed }
assertTrue(failed.error is QueryError.IncompatibleDependency)
withTimeout(2.seconds) { collection.join() }
purpleQuartz.close()
}
@Test
fun `stream query emits every ingested event at eose`() = runBlocking {
val client = ControlledClient()
val purpleQuartz = Iolite.createForTesting(
ControlledEventStore(),
client,
this,
eventVerifier = { true },
)
val states = CopyOnWriteArrayList<QueryState>()
val collection = collect(
purpleQuartz,
QuerySource.LocalAndRemote(setOf(relay), RemoteMode.Stream),
states,
)
client.awaitSubscription()
client.started(relay)
val events = (1..5).map { signedEvent(content = "note-$it", createdAt = 1_750_000_000L + it) }
events.forEach { client.event(relay, it) }
client.eose(relay)
val live = awaitState(states) { it.sync == QuerySync.Live }
assertEquals(events.map { it.id }.toSet(), live.items.map { it.id }.toSet())
collection.cancel()
withTimeout(2.seconds) { collection.join() }
purpleQuartz.close()
}
private fun kotlinx.coroutines.CoroutineScope.collect(
purpleQuartz: Iolite,
source: QuerySource,
states: MutableList<QueryState>,
): Job = launch {
purpleQuartz.query(Filter(kinds = listOf(1)), source).collect(states::add)
}
private suspend fun awaitState(
states: List<QueryState>,
predicate: (QueryState) -> Boolean,
): QueryState = withTimeout(5.seconds) {
while (true) {
states.lastOrNull(predicate)?.let { return@withTimeout it }
delay(10)
}
error("unreachable")
}
private fun signedEvent(
content: String,
kind: Int = 1,
createdAt: Long = 1_750_000_000,
): Event {
val id = eventSequence.incrementAndGet().toString(16).padStart(64, '0')
return Event(
id = id,
pubKey = "1".repeat(64),
createdAt = createdAt,
kind = kind,
tags = emptyArray(),
content = content,
sig = "2".repeat(128),
)
}
}
private class ControlledClient(
private val failSubscribe: Boolean = false,
) : INostrClient by EmptyNostrClient() {
val requests = CopyOnWriteArrayList<Map<NormalizedRelayUrl, List<Filter>>>()
val unsubscribeCount = AtomicInteger()
val closeCount = AtomicInteger()
private val connectionListeners = CopyOnWriteArrayList<RelayConnectionListener>()
@Volatile
private var subscriptionListener: SubscriptionListener? = null
override fun subscribe(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
) {
if (failSubscribe) throw IllegalStateException("controlled subscription failure")
requests += filters
subscriptionListener = listener
}
override fun unsubscribe(subId: String) {
unsubscribeCount.incrementAndGet()
}
override fun addConnectionListener(listener: RelayConnectionListener) {
connectionListeners += listener
}
override fun removeConnectionListener(listener: RelayConnectionListener) {
connectionListeners -= listener
}
override fun close() {
closeCount.incrementAndGet()
}
suspend fun awaitSubscription(expectedCount: Int = 1) {
withTimeout(2.seconds) {
while (subscriptionListener == null || requests.size < expectedCount) delay(10)
}
}
fun started(relay: NormalizedRelayUrl) {
subscriptionListener?.onSubscriptionStarted(relay.url, requests.last().getValue(relay))
}
fun event(relay: NormalizedRelayUrl, event: Event) {
subscriptionListener?.onEvent(event, false, relay, requests.last().getValue(relay))
}
fun eose(relay: NormalizedRelayUrl) {
subscriptionListener?.onEose(relay, requests.last().getValue(relay))
}
fun closed(relay: NormalizedRelayUrl) {
subscriptionListener?.onClosed("controlled close", relay, requests.last().getValue(relay))
}
fun connecting(relay: NormalizedRelayUrl) {
val client = TestRelayClient(relay)
connectionListeners.forEach { it.onConnecting(client) }
}
fun disconnected(relay: NormalizedRelayUrl) {
val client = TestRelayClient(relay)
connectionListeners.forEach { it.onDisconnected(client) }
}
}
private class MutableEpochClock(
private var current: Long,
) : EpochMillisClock {
override fun now(): Long = current
fun advance(milliseconds: Long) {
current += milliseconds
}
}
private class TestRelayClient(
override val url: NormalizedRelayUrl,
) : IRelayClient {
override fun connect() = Unit
override fun needsToReconnect(): Boolean = false
override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) = Unit
override fun isConnected(): Boolean = true
override fun sendOrConnectAndSync(cmd: Command) = Unit
override fun sendIfConnected(cmd: Command) = Unit
override fun disconnect() = Unit
}
private class ControlledEventStore(
private val blockInserts: Boolean = false,
private val failInserts: Boolean = false,
private val failQueriesAfter: Int? = null,
) : IEventStore {
override val relay: NormalizedRelayUrl? = null
val insertionStarted = CompletableDeferred<Unit>()
val releaseInsert = CompletableDeferred<Unit>()
val closeCount = AtomicInteger()
private val queryCount = AtomicInteger()
private val events = mutableListOf<Event>()
override suspend fun insert(event: Event) {
insertionStarted.complete(Unit)
if (blockInserts) releaseInsert.await()
if (failInserts) throw IllegalStateException("controlled insert failure")
synchronized(events) {
if (events.none { it.id == event.id }) events += event
}
}
override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) {
body(object : IEventStore.ITransaction {
override fun insert(event: Event) {
synchronized(events) {
if (events.none { it.id == event.id }) events += event
}
}
})
}
@Suppress("UNCHECKED_CAST")
override suspend fun <T : Event> query(filter: Filter): List<T> {
beforeQuery()
return synchronized(events) { events.filter(filter::match).map { it as T } }
}
@Suppress("UNCHECKED_CAST")
override suspend fun <T : Event> query(filters: List<Filter>): List<T> {
beforeQuery()
return synchronized(events) {
events.filter { event -> filters.any { it.match(event) } }.map { it as T }
}
}
override suspend fun <T : Event> query(filter: Filter, onEach: (T) -> Unit) {
query<T>(filter).forEach(onEach)
}
override suspend fun <T : Event> query(filters: List<Filter>, onEach: (T) -> Unit) {
query<T>(filters).forEach(onEach)
}
override suspend fun count(filter: Filter): Int = query<Event>(filter).size
override suspend fun count(filters: List<Filter>): Int = query<Event>(filters).size
override suspend fun delete(filter: Filter) {
synchronized(events) { events.removeAll(filter::match) }
}
override suspend fun delete(filters: List<Filter>) {
synchronized(events) {
events.removeAll { event -> filters.any { it.match(event) } }
}
}
override suspend fun deleteExpiredEvents() = Unit
override suspend fun reindexFullTextSearch() = Unit
override suspend fun reindexFullTextSearch(
resumeFrom: String?,
batchSize: Int,
): FtsReindexProgress = throw UnsupportedOperationException()
override fun close() {
closeCount.incrementAndGet()
}
private fun beforeQuery() {
val allowed = failQueriesAfter ?: return
if (queryCount.incrementAndGet() > allowed) {
throw IllegalStateException("controlled query failure")
}
}
}
@@ -1,72 +0,0 @@
package dev.zapstore.iolite
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import kotlin.time.Duration.Companion.seconds
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class QueryRefreshCacheTest {
@Test
fun `fingerprint canonicalizes filter and relay ordering`() {
val relayOne = "wss://one.example".normalizeRelayUrl()
val relayTwo = "wss://two.example".normalizeRelayUrl()
val idOne = "a".repeat(64)
val idTwo = "b".repeat(64)
val first = listOf(
Filter(
ids = listOf(idOne, idTwo),
kinds = listOf(1, 3),
tags = linkedMapOf("t" to listOf("one", "two"), "x" to listOf("value")),
),
Filter(search = "profile", limit = 1),
)
val reordered = listOf(
Filter(search = "profile", limit = 1),
Filter(
ids = listOf(idOne, idTwo),
kinds = listOf(1, 3),
tags = linkedMapOf("x" to listOf("value"), "t" to listOf("one", "two")),
),
)
assertEquals(
QueryFingerprint.create(first, linkedSetOf(relayOne, relayTwo)),
QueryFingerprint.create(reordered, linkedSetOf(relayTwo, relayOne)),
)
}
@Test
fun `fingerprint isolates filters and relay sets`() {
val relayOne = "wss://one.example".normalizeRelayUrl()
val relayTwo = "wss://two.example".normalizeRelayUrl()
val authorOne = "a".repeat(64)
val authorTwo = "b".repeat(64)
val profile = listOf(Filter(authors = listOf(authorOne), kinds = listOf(0)))
val baseline = QueryFingerprint.create(profile, setOf(relayOne))
assertNotEquals(
baseline,
QueryFingerprint.create(listOf(Filter(authors = listOf(authorTwo), kinds = listOf(0))), setOf(relayOne)),
)
assertNotEquals(baseline, QueryFingerprint.create(profile, setOf(relayTwo)))
}
@Test
fun `freshness expires and treats a backwards clock as stale`() {
val cache = InMemoryQueryRefreshCache()
assertTrue(cache.recordRefresh("profile", 1_000))
val fresh = cache.freshness("profile", 60.seconds, 31_000)
assertTrue(fresh.isFresh)
assertEquals(30_000, fresh.remainingMillis)
assertFalse(cache.freshness("profile", 60.seconds, 61_000).isFresh)
assertFalse(cache.freshness("profile", 60.seconds, 999).isFresh)
assertFalse(cache.freshness("missing", 60.seconds, 1_000).isFresh)
}
}
@@ -1,228 +0,0 @@
package dev.zapstore.iolite
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.store.FtsReindexProgress
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import java.util.concurrent.CopyOnWriteArrayList
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class QueryWiringTest {
@Test
fun `local query emits an empty local seed without subscribing`() = runBlocking {
val client = RecordingClient()
val purpleQuartz = Iolite.createForTesting(MemoryEventStore(), client, this)
val states = purpleQuartz.query(Filter(kinds = listOf(1))).take(1).toList()
assertEquals(listOf(QueryPhase.LocalOnly), states.map { it.phase })
assertTrue(states.single().items.isEmpty())
assertTrue(client.requests.isEmpty())
purpleQuartz.close()
}
@Test
fun `remote request uses exactly the supplied relay set`() = runBlocking {
val client = RecordingClient()
val purpleQuartz = Iolite.createForTesting(MemoryEventStore(), client, this)
val relays = setOf(
"wss://one.example".normalizeRelayUrl(),
"wss://two.example".normalizeRelayUrl(),
)
val collection = launch {
purpleQuartz.query(
Filter(kinds = listOf(1)),
QueryOptions.remote(relays, RemoteMode.Stream),
).collect { }
}
withTimeout(1_000) {
while (client.requests.isEmpty()) delay(10)
}
assertEquals(1, client.requests.size)
assertEquals(relays, client.requests.single().keys)
assertTrue(client.requests.single().values.all { it.single().kinds == listOf(1) })
collection.cancelAndJoin()
purpleQuartz.close()
}
@Test
fun `foreground refresh bypasses relay retry delays`() = runBlocking {
val client = RecordingClient()
val purpleQuartz = Iolite.createForTesting(MemoryEventStore(), client, this)
purpleQuartz.refreshConnections()
assertEquals(listOf(ReconnectCall(true, true)), client.reconnectCalls)
purpleQuartz.close()
}
@Test
fun `relay traffic toggle disconnects and reconnects the client`() = runBlocking {
val client = RecordingClient()
val purpleQuartz = Iolite.createForTesting(MemoryEventStore(), client, this)
purpleQuartz.setRelayTrafficEnabled(false)
purpleQuartz.setRelayTrafficEnabled(true)
assertEquals(listOf(false, true), client.traffic)
assertEquals(listOf(ReconnectCall(true, true)), client.reconnectCalls)
purpleQuartz.close()
}
@Test
fun `local and remote query seeds persisted rows while relay traffic is suspended`() = runBlocking {
val client = RecordingClient()
val store = MemoryEventStore()
val event = testEvent("offline")
store.insert(event)
val purpleQuartz = Iolite.createForTesting(store, client, this)
val relay = "wss://offline.example".normalizeRelayUrl()
purpleQuartz.setRelayTrafficEnabled(false)
val states = CopyOnWriteArrayList<QueryState>()
val collection = launch {
purpleQuartz.query(
Filter(kinds = listOf(1)),
QueryOptions.localAndRemote(setOf(relay), RemoteMode.Stream),
).collect(states::add)
}
val state = withTimeout(1_000) {
while (states.isEmpty()) delay(10)
states.first()
}
assertEquals(QueryPhase.Connecting, state.phase)
assertEquals(listOf(event.id), state.items.map(Event::id))
assertEquals(false, client.isActive())
assertTrue(client.requests.isEmpty())
purpleQuartz.setRelayTrafficEnabled(true)
withTimeout(1_000) {
while (client.requests.isEmpty()) delay(10)
}
assertEquals(1, client.requests.size)
collection.cancelAndJoin()
purpleQuartz.close()
}
}
private class RecordingClient : INostrClient by EmptyNostrClient() {
val requests = mutableListOf<Map<NormalizedRelayUrl, List<Filter>>>()
val reconnectCalls = mutableListOf<ReconnectCall>()
val traffic = mutableListOf<Boolean>()
private var active = true
override fun subscribe(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
) {
requests += filters
}
override fun reconnect(onlyIfChanged: Boolean, ignoreRetryDelays: Boolean) {
reconnectCalls += ReconnectCall(onlyIfChanged, ignoreRetryDelays)
}
override fun connect() {
active = true
traffic += true
}
override fun disconnect() {
active = false
traffic += false
}
override fun isActive() = active
}
private data class ReconnectCall(
val onlyIfChanged: Boolean,
val ignoreRetryDelays: Boolean,
)
private fun testEvent(content: String): Event = Event(
id = content.padEnd(64, '0'),
pubKey = "1".repeat(64),
createdAt = 1_750_000_000,
kind = 1,
tags = emptyArray(),
content = content,
sig = "2".repeat(128),
)
private class MemoryEventStore : IEventStore {
override val relay: NormalizedRelayUrl? = null
private val events = mutableListOf<Event>()
override suspend fun insert(event: Event) {
events += event
}
override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) {
body(object : IEventStore.ITransaction {
override fun insert(event: Event) {
events += event
}
})
}
@Suppress("UNCHECKED_CAST")
override suspend fun <T : Event> query(filter: Filter): List<T> =
events.filter(filter::match).map { it as T }
@Suppress("UNCHECKED_CAST")
override suspend fun <T : Event> query(filters: List<Filter>): List<T> =
events.filter { event -> filters.any { it.match(event) } }.map { it as T }
override suspend fun <T : Event> query(filter: Filter, onEach: (T) -> Unit) {
query<T>(filter).forEach(onEach)
}
override suspend fun <T : Event> query(filters: List<Filter>, onEach: (T) -> Unit) {
query<T>(filters).forEach(onEach)
}
override suspend fun count(filter: Filter): Int = query<Event>(filter).size
override suspend fun count(filters: List<Filter>): Int = query<Event>(filters).size
override suspend fun delete(filter: Filter) {
events.removeAll(filter::match)
}
override suspend fun delete(filters: List<Filter>) {
events.removeAll { event -> filters.any { it.match(event) } }
}
override suspend fun deleteExpiredEvents() = Unit
override suspend fun reindexFullTextSearch() = Unit
override suspend fun reindexFullTextSearch(
resumeFrom: String?,
batchSize: Int,
): FtsReindexProgress = throw UnsupportedOperationException()
override fun close() = Unit
}
@@ -0,0 +1,252 @@
package dev.zapstore.iolite
import java.io.File
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.json.JSONArray
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class RelayOutboxTest {
@Test
fun localSignerRoundTripAndNip42Auth() = runBlocking {
val signer = LocalSigner(SECRET)
val event = signer.sign(1_700_000_000, 1, emptyList(), "hello")
assertTrue(event.verify())
assertEquals(signer.publicKey, event.pubkey)
val auth = Nip42.auth(signer, "wss://relay.example".normalizeRelayUrl(), "challenge-1", 1_700_000_001)
assertEquals(Kinds.Auth, auth.kind)
assertEquals("challenge-1", auth.tagValue("challenge"))
assertEquals("wss://relay.example", auth.tagValue("relay"))
assertTrue(auth.verify())
assertTrue(Nip42.wire(auth).startsWith("""["AUTH","""))
}
@Test
fun nip55SignerAcceptsVerifiedCallback() = runBlocking {
val local = LocalSigner(SECRET)
val nip55 = Nip55Signer(local.publicKey) { unsigned ->
val json = org.json.JSONObject(unsigned)
local.sign(
json.getLong("created_at"),
json.getInt("kind"),
json.getJSONArray("tags").toStringLists(),
json.getString("content"),
).toJson()
}
val signed = nip55.sign(10, 1, listOf(listOf("t", "zap")), "hi")
assertTrue(signed.verify())
assertEquals(local.publicKey, signed.pubkey)
}
@Test
fun queryLocalAndRemoteIngestsProfileAfterEoseGrace() = runBlocking {
val network = MemoryNetwork()
val signer = LocalSigner(SECRET)
val profile = signer.sign(1_700_000_000, Kinds.Profile, emptyList(), """{"name":"Ada"}""")
val relay = "ws://relay.test".normalizeRelayUrl()
withIolite(webSocket = network.factory(), writeRelays = setOf(relay), signer = signer) { iolite ->
val states = async {
withTimeout(5_000) {
iolite.query(
Query.profile(signer.publicKey),
QueryOptions.localAndRemote(setOf(relay), RemoteMode.OneShot(2.seconds)),
).first { state -> state.phase == QueryPhase.Complete && state.items?.name == "Ada" }
}
}
network.awaitOpen(relay.url)
val req = network.relays.getValue(relay.url).sent.first { it.startsWith("""["REQ"""") }
val subId = JSONArray(req).getString(1)
network.push(relay.url, JSONArray().put("EVENT").put(subId).put(profile.toJsonObject()).toString())
network.push(relay.url, JSONArray().put("EOSE").put(subId).toString())
val complete = states.await()
assertEquals(QueryPhase.Complete, complete.phase)
assertEquals("Ada", complete.items!!.name)
}
}
@Test
fun durableOutboxDeletesAfterOkThreshold() = runBlocking {
val network = MemoryNetwork()
val signer = LocalSigner(SECRET)
val one = "ws://one.test".normalizeRelayUrl()
val two = "ws://two.test".normalizeRelayUrl()
val three = "ws://three.test".normalizeRelayUrl()
withIolite(webSocket = network.factory(), writeRelays = setOf(one, two, three), signer = signer) { iolite ->
val event = iolite.publish(1, emptyList(), "note", signer, relays = setOf(one, two, three))
assertEquals(1, iolite.pendingOutbox().size)
network.awaitOpen(one.url)
network.awaitOpen(two.url)
fun ok(url: RelayUrl) {
network.push(url.url, JSONArray().put("OK").put(event.id).put(true).put("").toString())
}
ok(one)
assertEquals(1, iolite.pendingOutbox().size)
ok(two)
assertTrue(iolite.pendingOutbox().isEmpty())
}
}
@Test
fun oneOrTwoRelaysNeedSingleOk() = runBlocking {
val network = MemoryNetwork()
val signer = LocalSigner(SECRET)
val relay = "ws://one.test".normalizeRelayUrl()
withIolite(webSocket = network.factory(), writeRelays = setOf(relay), signer = signer) { iolite ->
val event = iolite.publish(1, emptyList(), "note", signer)
network.awaitOpen(relay.url)
network.push(relay.url, JSONArray().put("OK").put(event.id).put(true).put("").toString())
assertTrue(iolite.pendingOutbox().isEmpty())
}
}
@Test
fun authChallengeIsNotOutboxed() = runBlocking {
val network = MemoryNetwork()
val signer = LocalSigner(SECRET)
val relay = "ws://auth.test".normalizeRelayUrl()
withIolite(webSocket = network.factory(), writeRelays = setOf(relay), signer = signer) { iolite ->
val collected = async {
iolite.query(
Query.profile(signer.publicKey),
QueryOptions.localAndRemote(setOf(relay), RemoteMode.OneShot(2.seconds)),
).take(3).toList()
}
network.awaitOpen(relay.url)
network.push(relay.url, JSONArray().put("AUTH").put("abc").toString())
val auth = withTimeout(2_000) {
while (true) {
val found = network.relays.getValue(relay.url).sent.firstOrNull { it.startsWith("""["AUTH"""") }
if (found != null) return@withTimeout found
kotlinx.coroutines.delay(5)
}
error("unreachable")
}
val event = Event.parse(JSONArray(auth).getJSONObject(1))
assertEquals(Kinds.Auth, event.kind)
assertTrue(iolite.pendingOutbox().none { it.kind == Kinds.Auth })
collected.cancel()
}
}
@Test
fun cachedQuerySkipsRelay() = runBlocking {
val network = MemoryNetwork()
val signer = LocalSigner(SECRET)
val profile = signer.sign(1_700_000_000, Kinds.Profile, emptyList(), """{"name":"Ada"}""")
val relay = "ws://cache.test".normalizeRelayUrl()
var now = 1_000L
withIolite(
webSocket = network.factory(),
writeRelays = setOf(relay),
signer = signer,
nowMillis = { now },
config = IoliteConfig(eoseGrace = 5.milliseconds, oneShotTimeout = 2.seconds),
) { iolite ->
val first = async {
iolite.query(
Query.profile(signer.publicKey),
QueryOptions.localAndRemote(setOf(relay), RemoteMode.OneShot(2.seconds), cachedFor = 30.seconds),
).first { it.phase == QueryPhase.Complete }
}
network.awaitOpen(relay.url)
val req = network.relays.getValue(relay.url).sent.first { it.startsWith("""["REQ"""") }
val subId = JSONArray(req).getString(1)
network.push(relay.url, JSONArray().put("EVENT").put(subId).put(profile.toJsonObject()).toString())
network.push(relay.url, JSONArray().put("EOSE").put(subId).toString())
first.await()
val sentAfter = network.relays.getValue(relay.url).sent.size
now = 2_000L
val cached = iolite.query(
Query.profile(signer.publicKey),
QueryOptions.localAndRemote(setOf(relay), RemoteMode.OneShot(2.seconds), cachedFor = 30.seconds),
).first { it.phase == QueryPhase.Cached }
assertEquals("Ada", cached.items!!.name)
assertEquals(sentAfter, network.relays.getValue(relay.url).sent.size)
}
}
private suspend fun withIolite(
webSocket: WebSocketFactory,
writeRelays: Set<RelayUrl>,
signer: Signer,
nowMillis: () -> Long = { System.currentTimeMillis() },
config: IoliteConfig = IoliteConfig(eoseGrace = 5.milliseconds, oneShotTimeout = 2.seconds),
block: suspend (Iolite) -> Unit,
) {
val dir = File("build/tmp/iolite-relay-${System.nanoTime()}").apply { mkdirs() }
val scope = CoroutineScope(SupervisorJob())
val iolite = Iolite.createForTesting(
databasePath = File(dir, "iolite.db").absolutePath,
parentScope = scope,
config = config,
webSocket = webSocket,
signer = signer,
writeRelays = writeRelays,
nowMillis = nowMillis,
)
try {
block(iolite)
} finally {
iolite.close()
scope.coroutineContext.job.cancel()
}
}
companion object {
private val SECRET = Hex.decode("0000000000000000000000000000000000000000000000000000000000000001")
}
}
internal class MemoryNetwork {
val relays = mutableMapOf<String, MemoryRelay>()
fun factory(): WebSocketFactory = WebSocketFactory { url, listener ->
relays.getOrPut(url) { MemoryRelay(url) }.connect(listener)
}
fun push(url: String, text: String) {
relays.getValue(url).listener?.onMessage(text)
}
suspend fun awaitOpen(url: String) {
withTimeout(2_000) {
while (relays[url]?.listener == null) {
kotlinx.coroutines.delay(5)
}
}
}
}
internal class MemoryRelay(val url: String) {
val sent = CopyOnWriteArrayList<String>()
@Volatile var listener: WebSocketListener? = null
fun connect(listener: WebSocketListener): WebSocketSession {
this.listener = listener
listener.onOpen()
return object : WebSocketSession {
override fun send(text: String) {
sent += text
}
override fun close() {
listener.onClosing(1000, "")
}
}
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,648 @@
{
"v2": {
"valid": {
"get_conversation_key": [
{
"sec1": "315e59ff51cb9209768cf7da80791ddcaae56ac9775eb25b6dee1234bc5d2268",
"pub2": "c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133",
"conversation_key": "3dfef0ce2a4d80a25e7a328accf73448ef67096f65f79588e358d9a0eb9013f1"
},
{
"sec1": "a1e37752c9fdc1273be53f68c5f74be7c8905728e8de75800b94262f9497c86e",
"pub2": "03bb7947065dde12ba991ea045132581d0954f042c84e06d8c00066e23c1a800",
"conversation_key": "4d14f36e81b8452128da64fe6f1eae873baae2f444b02c950b90e43553f2178b"
},
{
"sec1": "98a5902fd67518a0c900f0fb62158f278f94a21d6f9d33d30cd3091195500311",
"pub2": "aae65c15f98e5e677b5050de82e3aba47a6fe49b3dab7863cf35d9478ba9f7d1",
"conversation_key": "9c00b769d5f54d02bf175b7284a1cbd28b6911b06cda6666b2243561ac96bad7"
},
{
"sec1": "86ae5ac8034eb2542ce23ec2f84375655dab7f836836bbd3c54cefe9fdc9c19f",
"pub2": "59f90272378089d73f1339710c02e2be6db584e9cdbe86eed3578f0c67c23585",
"conversation_key": "19f934aafd3324e8415299b64df42049afaa051c71c98d0aa10e1081f2e3e2ba"
},
{
"sec1": "2528c287fe822421bc0dc4c3615878eb98e8a8c31657616d08b29c00ce209e34",
"pub2": "f66ea16104c01a1c532e03f166c5370a22a5505753005a566366097150c6df60",
"conversation_key": "c833bbb292956c43366145326d53b955ffb5da4e4998a2d853611841903f5442"
},
{
"sec1": "49808637b2d21129478041813aceb6f2c9d4929cd1303cdaf4fbdbd690905ff2",
"pub2": "74d2aab13e97827ea21baf253ad7e39b974bb2498cc747cdb168582a11847b65",
"conversation_key": "4bf304d3c8c4608864c0fe03890b90279328cd24a018ffa9eb8f8ccec06b505d"
},
{
"sec1": "af67c382106242c5baabf856efdc0629cc1c5b4061f85b8ceaba52aa7e4b4082",
"pub2": "bdaf0001d63e7ec994fad736eab178ee3c2d7cfc925ae29f37d19224486db57b",
"conversation_key": "a3a575dd66d45e9379904047ebfb9a7873c471687d0535db00ef2daa24b391db"
},
{
"sec1": "0e44e2d1db3c1717b05ffa0f08d102a09c554a1cbbf678ab158b259a44e682f1",
"pub2": "1ffa76c5cc7a836af6914b840483726207cb750889753d7499fb8b76aa8fe0de",
"conversation_key": "a39970a667b7f861f100e3827f4adbf6f464e2697686fe1a81aeda817d6b8bdf"
},
{
"sec1": "5fc0070dbd0666dbddc21d788db04050b86ed8b456b080794c2a0c8e33287bb6",
"pub2": "31990752f296dd22e146c9e6f152a269d84b241cc95bb3ff8ec341628a54caf0",
"conversation_key": "72c21075f4b2349ce01a3e604e02a9ab9f07e35dd07eff746de348b4f3c6365e"
},
{
"sec1": "1b7de0d64d9b12ddbb52ef217a3a7c47c4362ce7ea837d760dad58ab313cba64",
"pub2": "24383541dd8083b93d144b431679d70ef4eec10c98fceef1eff08b1d81d4b065",
"conversation_key": "dd152a76b44e63d1afd4dfff0785fa07b3e494a9e8401aba31ff925caeb8f5b1"
},
{
"sec1": "df2f560e213ca5fb33b9ecde771c7c0cbd30f1cf43c2c24de54480069d9ab0af",
"pub2": "eeea26e552fc8b5e377acaa03e47daa2d7b0c787fac1e0774c9504d9094c430e",
"conversation_key": "770519e803b80f411c34aef59c3ca018608842ebf53909c48d35250bd9323af6"
},
{
"sec1": "cffff919fcc07b8003fdc63bc8a00c0f5dc81022c1c927c62c597352190d95b9",
"pub2": "eb5c3cca1a968e26684e5b0eb733aecfc844f95a09ac4e126a9e58a4e4902f92",
"conversation_key": "46a14ee7e80e439ec75c66f04ad824b53a632b8409a29bbb7c192e43c00bb795"
},
{
"sec1": "64ba5a685e443e881e9094647ddd32db14444bb21aa7986beeba3d1c4673ba0a",
"pub2": "50e6a4339fac1f3bf86f2401dd797af43ad45bbf58e0801a7877a3984c77c3c4",
"conversation_key": "968b9dbbfcede1664a4ca35a5d3379c064736e87aafbf0b5d114dff710b8a946"
},
{
"sec1": "dd0c31ccce4ec8083f9b75dbf23cc2878e6d1b6baa17713841a2428f69dee91a",
"pub2": "b483e84c1339812bed25be55cff959778dfc6edde97ccd9e3649f442472c091b",
"conversation_key": "09024503c7bde07eb7865505891c1ea672bf2d9e25e18dd7a7cea6c69bf44b5d"
},
{
"sec1": "af71313b0d95c41e968a172b33ba5ebd19d06cdf8a7a98df80ecf7af4f6f0358",
"pub2": "2a5c25266695b461ee2af927a6c44a3c598b8095b0557e9bd7f787067435bc7c",
"conversation_key": "fe5155b27c1c4b4e92a933edae23726a04802a7cc354a77ac273c85aa3c97a92"
},
{
"sec1": "6636e8a389f75fe068a03b3edb3ea4a785e2768e3f73f48ffb1fc5e7cb7289dc",
"pub2": "514eb2064224b6a5829ea21b6e8f7d3ea15ff8e70e8555010f649eb6e09aec70",
"conversation_key": "ff7afacd4d1a6856d37ca5b546890e46e922b508639214991cf8048ddbe9745c"
},
{
"sec1": "94b212f02a3cfb8ad147d52941d3f1dbe1753804458e6645af92c7b2ea791caa",
"pub2": "f0cac333231367a04b652a77ab4f8d658b94e86b5a8a0c472c5c7b0d4c6a40cc",
"conversation_key": "e292eaf873addfed0a457c6bd16c8effde33d6664265697f69f420ab16f6669b"
},
{
"sec1": "aa61f9734e69ae88e5d4ced5aae881c96f0d7f16cca603d3bed9eec391136da6",
"pub2": "4303e5360a884c360221de8606b72dd316da49a37fe51e17ada4f35f671620a6",
"conversation_key": "8e7d44fd4767456df1fb61f134092a52fcd6836ebab3b00766e16732683ed848"
},
{
"sec1": "5e914bdac54f3f8e2cba94ee898b33240019297b69e96e70c8a495943a72fc98",
"pub2": "5bd097924f606695c59f18ff8fd53c174adbafaaa71b3c0b4144a3e0a474b198",
"conversation_key": "f5a0aecf2984bf923c8cd5e7bb8be262d1a8353cb93959434b943a07cf5644bc"
},
{
"sec1": "8b275067add6312ddee064bcdbeb9d17e88aa1df36f430b2cea5cc0413d8278a",
"pub2": "65bbbfca819c90c7579f7a82b750a18c858db1afbec8f35b3c1e0e7b5588e9b8",
"conversation_key": "2c565e7027eb46038c2263563d7af681697107e975e9914b799d425effd248d6"
},
{
"sec1": "1ac848de312285f85e0f7ec208aac20142a1f453402af9b34ec2ec7a1f9c96fc",
"pub2": "45f7318fe96034d23ee3ddc25b77f275cc1dd329664dd51b89f89c4963868e41",
"conversation_key": "b56e970e5057a8fd929f8aad9248176b9af87819a708d9ddd56e41d1aec74088"
},
{
"sec1": "295a1cf621de401783d29d0e89036aa1c62d13d9ad307161b4ceb535ba1b40e6",
"pub2": "840115ddc7f1034d3b21d8e2103f6cb5ab0b63cf613f4ea6e61ae3d016715cdd",
"conversation_key": "b4ee9c0b9b9fef88975773394f0a6f981ca016076143a1bb575b9ff46e804753"
},
{
"sec1": "a28eed0fe977893856ab9667e06ace39f03abbcdb845c329a1981be438ba565d",
"pub2": "b0f38b950a5013eba5ab4237f9ed29204a59f3625c71b7e210fec565edfa288c",
"conversation_key": "9d3a802b45bc5aeeb3b303e8e18a92ddd353375710a31600d7f5fff8f3a7285b"
},
{
"sec1": "7ab65af72a478c05f5c651bdc4876c74b63d20d04cdbf71741e46978797cd5a4",
"pub2": "f1112159161b568a9cb8c9dd6430b526c4204bcc8ce07464b0845b04c041beda",
"conversation_key": "943884cddaca5a3fef355e9e7f08a3019b0b66aa63ec90278b0f9fdb64821e79"
},
{
"sec1": "95c79a7b75ba40f2229e85756884c138916f9d103fc8f18acc0877a7cceac9fe",
"pub2": "cad76bcbd31ca7bbda184d20cc42f725ed0bb105b13580c41330e03023f0ffb3",
"conversation_key": "81c0832a669eea13b4247c40be51ccfd15bb63fcd1bba5b4530ce0e2632f301b"
},
{
"sec1": "baf55cc2febd4d980b4b393972dfc1acf49541e336b56d33d429bce44fa12ec9",
"pub2": "0c31cf87fe565766089b64b39460ebbfdedd4a2bc8379be73ad3c0718c912e18",
"conversation_key": "37e2344da9ecdf60ae2205d81e89d34b280b0a3f111171af7e4391ded93b8ea6"
},
{
"sec1": "6eeec45acd2ed31693c5256026abf9f072f01c4abb61f51cf64e6956b6dc8907",
"pub2": "e501b34ed11f13d816748c0369b0c728e540df3755bab59ed3327339e16ff828",
"conversation_key": "afaa141b522ddb27bb880d768903a7f618bb8b6357728cae7fb03af639b946e6"
},
{
"sec1": "261a076a9702af1647fb343c55b3f9a4f1096273002287df0015ba81ce5294df",
"pub2": "b2777c863878893ae100fb740c8fab4bebd2bf7be78c761a75593670380a6112",
"conversation_key": "76f8d2853de0734e51189ced523c09427c3e46338b9522cd6f74ef5e5b475c74"
},
{
"sec1": "ed3ec71ca406552ea41faec53e19f44b8f90575eda4b7e96380f9cc73c26d6f3",
"pub2": "86425951e61f94b62e20cae24184b42e8e17afcf55bafa58645efd0172624fae",
"conversation_key": "f7ffc520a3a0e9e9b3c0967325c9bf12707f8e7a03f28b6cd69ae92cf33f7036"
},
{
"sec1": "5a788fc43378d1303ac78639c59a58cb88b08b3859df33193e63a5a3801c722e",
"pub2": "a8cba2f87657d229db69bee07850fd6f7a2ed070171a06d006ec3a8ac562cf70",
"conversation_key": "7d705a27feeedf78b5c07283362f8e361760d3e9f78adab83e3ae5ce7aeb6409"
},
{
"sec1": "63bffa986e382b0ac8ccc1aa93d18a7aa445116478be6f2453bad1f2d3af2344",
"pub2": "b895c70a83e782c1cf84af558d1038e6b211c6f84ede60408f519a293201031d",
"conversation_key": "3a3b8f00d4987fc6711d9be64d9c59cf9a709c6c6481c2cde404bcc7a28f174e"
},
{
"sec1": "e4a8bcacbf445fd3721792b939ff58e691cdcba6a8ba67ac3467b45567a03e5c",
"pub2": "b54053189e8c9252c6950059c783edb10675d06d20c7b342f73ec9fa6ed39c9d",
"conversation_key": "7b3933b4ef8189d347169c7955589fc1cfc01da5239591a08a183ff6694c44ad"
},
{
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364139",
"pub2": "0000000000000000000000000000000000000000000000000000000000000002",
"conversation_key": "8b6392dbf2ec6a2b2d5b1477fc2be84d63ef254b667cadd31bd3f444c44ae6ba",
"note": "sec1 = n-2, pub2: random, 0x02"
},
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdeb",
"conversation_key": "be234f46f60a250bef52a5ee34c758800c4ca8e5030bf4cc1a31d37ba2104d43",
"note": "sec1 = 2, pub2: rand"
},
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
"pub2": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"conversation_key": "3b4610cb7189beb9cc29eb3716ecc6102f1247e8f3101a03a1787d8908aeb54e",
"note": "sec1 == pub2"
}
],
"get_message_keys": {
"conversation_key": "a1a3d60f3470a8612633924e91febf96dc5366ce130f658b1f0fc652c20b3b54",
"keys": [
{
"nonce": "e1e6f880560d6d149ed83dcc7e5861ee62a5ee051f7fde9975fe5d25d2a02d72",
"chacha_key": "f145f3bed47cb70dbeaac07f3a3fe683e822b3715edb7c4fe310829014ce7d76",
"chacha_nonce": "c4ad129bb01180c0933a160c",
"hmac_key": "027c1db445f05e2eee864a0975b0ddef5b7110583c8c192de3732571ca5838c4"
},
{
"nonce": "e1d6d28c46de60168b43d79dacc519698512ec35e8ccb12640fc8e9f26121101",
"chacha_key": "e35b88f8d4a8f1606c5082f7a64b100e5d85fcdb2e62aeafbec03fb9e860ad92",
"chacha_nonce": "22925e920cee4a50a478be90",
"hmac_key": "46a7c55d4283cb0df1d5e29540be67abfe709e3b2e14b7bf9976e6df994ded30"
},
{
"nonce": "cfc13bef512ac9c15951ab00030dfaf2626fdca638dedb35f2993a9eeb85d650",
"chacha_key": "020783eb35fdf5b80ef8c75377f4e937efb26bcbad0e61b4190e39939860c4bf",
"chacha_nonce": "d3594987af769a52904656ac",
"hmac_key": "237ec0ccb6ebd53d179fa8fd319e092acff599ef174c1fdafd499ef2b8dee745"
},
{
"nonce": "ea6eb84cac23c5c1607c334e8bdf66f7977a7e374052327ec28c6906cbe25967",
"chacha_key": "ff68db24b34fa62c78ac5ffeeaf19533afaedf651fb6a08384e46787f6ce94be",
"chacha_nonce": "50bb859aa2dde938cc49ec7a",
"hmac_key": "06ff32e1f7b29753a727d7927b25c2dd175aca47751462d37a2039023ec6b5a6"
},
{
"nonce": "8c2e1dd3792802f1f9f7842e0323e5d52ad7472daf360f26e15f97290173605d",
"chacha_key": "2f9daeda8683fdeede81adac247c63cc7671fa817a1fd47352e95d9487989d8b",
"chacha_nonce": "400224ba67fc2f1b76736916",
"hmac_key": "465c05302aeeb514e41c13ed6405297e261048cfb75a6f851ffa5b445b746e4b"
},
{
"nonce": "05c28bf3d834fa4af8143bf5201a856fa5fac1a3aee58f4c93a764fc2f722367",
"chacha_key": "1e3d45777025a035be566d80fd580def73ed6f7c043faec2c8c1c690ad31c110",
"chacha_nonce": "021905b1ea3afc17cb9bf96f",
"hmac_key": "74a6e481a89dcd130aaeb21060d7ec97ad30f0007d2cae7b1b11256cc70dfb81"
},
{
"nonce": "5e043fb153227866e75a06d60185851bc90273bfb93342f6632a728e18a07a17",
"chacha_key": "1ea72c9293841e7737c71567d8120145a58991aaa1c436ef77bf7adb83f882f1",
"chacha_nonce": "72f69a5a5f795465cee59da8",
"hmac_key": "e9daa1a1e9a266ecaa14e970a84bce3fbbf329079bbccda626582b4e66a0d4c9"
},
{
"nonce": "7be7338eaf06a87e274244847fe7a97f5c6a91f44adc18fcc3e411ad6f786dbf",
"chacha_key": "881e7968a1f0c2c80742ee03cd49ea587e13f22699730f1075ade01931582bf6",
"chacha_nonce": "6e69be92d61c04a276021565",
"hmac_key": "901afe79e74b19967c8829af23617d7d0ffbf1b57190c096855c6a03523a971b"
},
{
"nonce": "94571c8d590905bad7becd892832b472f2aa5212894b6ce96e5ba719c178d976",
"chacha_key": "f80873dd48466cb12d46364a97b8705c01b9b4230cb3ec3415a6b9551dc42eef",
"chacha_nonce": "3dda53569cfcb7fac1805c35",
"hmac_key": "e9fc264345e2839a181affebc27d2f528756e66a5f87b04bf6c5f1997047051e"
},
{
"nonce": "13a6ee974b1fd759135a2c2010e3cdda47081c78e771125e4f0c382f0284a8cb",
"chacha_key": "bc5fb403b0bed0d84cf1db872b6522072aece00363178c98ad52178d805fca85",
"chacha_nonce": "65064239186e50304cc0f156",
"hmac_key": "e872d320dde4ed3487958a8e43b48aabd3ced92bc24bb8ff1ccb57b590d9701a"
},
{
"nonce": "082fecdb85f358367b049b08be0e82627ae1d8edb0f27327ccb593aa2613b814",
"chacha_key": "1fbdb1cf6f6ea816349baf697932b36107803de98fcd805ebe9849b8ad0e6a45",
"chacha_nonce": "2e605e1d825a3eaeb613db9c",
"hmac_key": "fae910f591cf3c7eb538c598583abad33bc0a03085a96ca4ea3a08baf17c0eec"
},
{
"nonce": "4c19020c74932c30ec6b2d8cd0d5bb80bd0fc87da3d8b4859d2fb003810afd03",
"chacha_key": "1ab9905a0189e01cda82f843d226a82a03c4f5b6dbea9b22eb9bc953ba1370d4",
"chacha_nonce": "cbb2530ea653766e5a37a83a",
"hmac_key": "267f68acac01ac7b34b675e36c2cef5e7b7a6b697214add62a491bedd6efc178"
},
{
"nonce": "67723a3381497b149ce24814eddd10c4c41a1e37e75af161930e6b9601afd0ff",
"chacha_key": "9ecbd25e7e2e6c97b8c27d376dcc8c5679da96578557e4e21dba3a7ef4e4ac07",
"chacha_nonce": "ef649fcf335583e8d45e3c2e",
"hmac_key": "04dbbd812fa8226fdb45924c521a62e3d40a9e2b5806c1501efdeba75b006bf1"
},
{
"nonce": "42063fe80b093e8619b1610972b4c3ab9e76c14fd908e642cd4997cafb30f36c",
"chacha_key": "211c66531bbcc0efcdd0130f9f1ebc12a769105eb39608994bcb188fa6a73a4a",
"chacha_nonce": "67803605a7e5010d0f63f8c8",
"hmac_key": "e840e4e8921b57647369d121c5a19310648105dbdd008200ebf0d3b668704ff8"
},
{
"nonce": "b5ac382a4be7ac03b554fe5f3043577b47ea2cd7cfc7e9ca010b1ffbb5cf1a58",
"chacha_key": "b3b5f14f10074244ee42a3837a54309f33981c7232a8b16921e815e1f7d1bb77",
"chacha_nonce": "4e62a0073087ed808be62469",
"hmac_key": "c8efa10230b5ea11633816c1230ca05fa602ace80a7598916d83bae3d3d2ccd7"
},
{
"nonce": "e9d1eba47dd7e6c1532dc782ff63125db83042bb32841db7eeafd528f3ea7af9",
"chacha_key": "54241f68dc2e50e1db79e892c7c7a471856beeb8d51b7f4d16f16ab0645d2f1a",
"chacha_nonce": "a963ed7dc29b7b1046820a1d",
"hmac_key": "aba215c8634530dc21c70ddb3b3ee4291e0fa5fa79be0f85863747bde281c8b2"
},
{
"nonce": "a94ecf8efeee9d7068de730fad8daf96694acb70901d762de39fa8a5039c3c49",
"chacha_key": "c0565e9e201d2381a2368d7ffe60f555223874610d3d91fbbdf3076f7b1374dd",
"chacha_nonce": "329bb3024461e84b2e1c489b",
"hmac_key": "ac42445491f092481ce4fa33b1f2274700032db64e3a15014fbe8c28550f2fec"
},
{
"nonce": "533605ea214e70c25e9a22f792f4b78b9f83a18ab2103687c8a0075919eaaa53",
"chacha_key": "ab35a5e1e54d693ff023db8500d8d4e79ad8878c744e0eaec691e96e141d2325",
"chacha_nonce": "653d759042b85194d4d8c0a7",
"hmac_key": "b43628e37ba3c31ce80576f0a1f26d3a7c9361d29bb227433b66f49d44f167ba"
},
{
"nonce": "7f38df30ceea1577cb60b355b4f5567ff4130c49e84fed34d779b764a9cc184c",
"chacha_key": "a37d7f211b84a551a127ff40908974eb78415395d4f6f40324428e850e8c42a3",
"chacha_nonce": "b822e2c959df32b3cb772a7c",
"hmac_key": "1ba31764f01f69b5c89ded2d7c95828e8052c55f5d36f1cd535510d61ba77420"
},
{
"nonce": "11b37f9dbc4d0185d1c26d5f4ed98637d7c9701fffa65a65839fa4126573a4e5",
"chacha_key": "964f38d3a31158a5bfd28481247b18dd6e44d69f30ba2a40f6120c6d21d8a6ba",
"chacha_nonce": "5f72c5b87c590bcd0f93b305",
"hmac_key": "2fc4553e7cedc47f29690439890f9f19c1077ef3e9eaeef473d0711e04448918"
},
{
"nonce": "8be790aa483d4cdd843189f71f135b3ec7e31f381312c8fe9f177aab2a48eafa",
"chacha_key": "95c8c74d633721a131316309cf6daf0804d59eaa90ea998fc35bac3d2fbb7a94",
"chacha_nonce": "409a7654c0e4bf8c2c6489be",
"hmac_key": "21bb0b06eb2b460f8ab075f497efa9a01c9cf9146f1e3986c3bf9da5689b6dc4"
},
{
"nonce": "19fd2a718ea084827d6bd73f509229ddf856732108b59fc01819f611419fd140",
"chacha_key": "cc6714b9f5616c66143424e1413d520dae03b1a4bd202b82b0a89b0727f5cdc8",
"chacha_nonce": "1b7fd2534f015a8f795d8f32",
"hmac_key": "2bef39c4ce5c3c59b817e86351373d1554c98bc131c7e461ed19d96cfd6399a0"
},
{
"nonce": "3c2acd893952b2f6d07d8aea76f545ca45961a93fe5757f6a5a80811d5e0255d",
"chacha_key": "c8de6c878cb469278d0af894bc181deb6194053f73da5014c2b5d2c8db6f2056",
"chacha_nonce": "6ffe4f1971b904a1b1a81b99",
"hmac_key": "df1cd69dd3646fca15594284744d4211d70e7d8472e545d276421fbb79559fd4"
},
{
"nonce": "7dbea4cead9ac91d4137f1c0a6eebb6ba0d1fb2cc46d829fbc75f8d86aca6301",
"chacha_key": "c8e030f6aa680c3d0b597da9c92bb77c21c4285dd620c5889f9beba7446446b0",
"chacha_nonce": "a9b5a67d081d3b42e737d16f",
"hmac_key": "355a85f551bc3cce9a14461aa60994742c9bbb1c81a59ca102dc64e61726ab8e"
},
{
"nonce": "45422e676cdae5f1071d3647d7a5f1f5adafb832668a578228aa1155a491f2f3",
"chacha_key": "758437245f03a88e2c6a32807edfabff51a91c81ca2f389b0b46f2c97119ea90",
"chacha_nonce": "263830a065af33d9c6c5aa1f",
"hmac_key": "7c581cf3489e2de203a95106bfc0de3d4032e9d5b92b2b61fb444acd99037e17"
},
{
"nonce": "babc0c03fad24107ad60678751f5db2678041ff0d28671ede8d65bdf7aa407e9",
"chacha_key": "bd68a28bd48d9ffa3602db72c75662ac2848a0047a313d2ae2d6bc1ac153d7e9",
"chacha_nonce": "d0f9d2a1ace6c758f594ffdd",
"hmac_key": "eb435e3a642adfc9d59813051606fc21f81641afd58ea6641e2f5a9f123bb50a"
},
{
"nonce": "7a1b8aac37d0d20b160291fad124ab697cfca53f82e326d78fef89b4b0ea8f83",
"chacha_key": "9e97875b651a1d30d17d086d1e846778b7faad6fcbc12e08b3365d700f62e4fe",
"chacha_nonce": "ccdaad5b3b7645be430992eb",
"hmac_key": "6f2f55cf35174d75752f63c06cc7cbc8441759b142999ed2d5a6d09d263e1fc4"
},
{
"nonce": "8370e4e32d7e680a83862cab0da6136ef607014d043e64cdf5ecc0c4e20b3d9a",
"chacha_key": "1472bed5d19db9c546106de946e0649cd83cc9d4a66b087a65906e348dcf92e2",
"chacha_nonce": "ed02dece5fc3a186f123420b",
"hmac_key": "7b3f7739f49d30c6205a46b174f984bb6a9fc38e5ccfacef2dac04fcbd3b184e"
},
{
"nonce": "9f1c5e8a29cd5677513c2e3a816551d6833ee54991eb3f00d5b68096fc8f0183",
"chacha_key": "5e1a7544e4d4dafe55941fcbdf326f19b0ca37fc49c4d47e9eec7fb68cde4975",
"chacha_nonce": "7d9acb0fdc174e3c220f40de",
"hmac_key": "e265ab116fbbb86b2aefc089a0986a0f5b77eda50c7410404ad3b4f3f385c7a7"
},
{
"nonce": "c385aa1c37c2bfd5cc35fcdbdf601034d39195e1cabff664ceb2b787c15d0225",
"chacha_key": "06bf4e60677a13e54c4a38ab824d2ef79da22b690da2b82d0aa3e39a14ca7bdd",
"chacha_nonce": "26b450612ca5e905b937e147",
"hmac_key": "22208152be2b1f5f75e6bfcc1f87763d48bb7a74da1be3d102096f257207f8b3"
},
{
"nonce": "3ff73528f88a50f9d35c0ddba4560bacee5b0462d0f4cb6e91caf41847040ce4",
"chacha_key": "850c8a17a23aa761d279d9901015b2bbdfdff00adbf6bc5cf22bd44d24ecabc9",
"chacha_nonce": "4a296a1fb0048e5020d3b129",
"hmac_key": "b1bf49a533c4da9b1d629b7ff30882e12d37d49c19abd7b01b7807d75ee13806"
},
{
"nonce": "2dcf39b9d4c52f1cb9db2d516c43a7c6c3b8c401f6a4ac8f131a9e1059957036",
"chacha_key": "17f8057e6156ba7cc5310d01eda8c40f9aa388f9fd1712deb9511f13ecc37d27",
"chacha_nonce": "a8188daff807a1182200b39d",
"hmac_key": "47b89da97f68d389867b5d8a2d7ba55715a30e3d88a3cc11f3646bc2af5580ef"
}
]
},
"calc_padded_len": [
[16, 32],
[32, 32],
[33, 64],
[37, 64],
[45, 64],
[49, 64],
[64, 64],
[65, 96],
[100, 128],
[111, 128],
[200, 224],
[250, 256],
[320, 320],
[383, 384],
[384, 384],
[400, 448],
[500, 512],
[512, 512],
[515, 640],
[700, 768],
[800, 896],
[900, 1024],
[1020, 1024],
[65536, 65536]
],
"encrypt_decrypt": [
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000001",
"sec2": "0000000000000000000000000000000000000000000000000000000000000002",
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
"nonce": "0000000000000000000000000000000000000000000000000000000000000001",
"plaintext": "a",
"payload": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"
},
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
"sec2": "0000000000000000000000000000000000000000000000000000000000000001",
"conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
"nonce": "f00000000000000000000000000000f00000000000000000000000000000000f",
"plaintext": "🍕🫃",
"payload": "AvAAAAAAAAAAAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAAPSKSK6is9ngkX2+cSq85Th16oRTISAOfhStnixqZziKMDvB0QQzgFZdjLTPicCJaV8nDITO+QfaQ61+KbWQIOO2Yj"
},
{
"sec1": "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a",
"sec2": "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d",
"conversation_key": "3e2b52a63be47d34fe0a80e34e73d436d6963bc8f39827f327057a9986c20a45",
"nonce": "b635236c42db20f021bb8d1cdff5ca75dd1a0cc72ea742ad750f33010b24f73b",
"plaintext": "表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀",
"payload": "ArY1I2xC2yDwIbuNHN/1ynXdGgzHLqdCrXUPMwELJPc7s7JqlCMJBAIIjfkpHReBPXeoMCyuClwgbT419jUWU1PwaNl4FEQYKCDKVJz+97Mp3K+Q2YGa77B6gpxB/lr1QgoqpDf7wDVrDmOqGoiPjWDqy8KzLueKDcm9BVP8xeTJIxs="
},
{
"sec1": "8f40e50a84a7462e2b8d24c28898ef1f23359fff50d8c509e6fb7ce06e142f9c",
"sec2": "b9b0a1e9cc20100c5faa3bbe2777303d25950616c4c6a3fa2e3e046f936ec2ba",
"conversation_key": "d5a2f879123145a4b291d767428870f5a8d9e5007193321795b40183d4ab8c2b",
"nonce": "b20989adc3ddc41cd2c435952c0d59a91315d8c5218d5040573fc3749543acaf",
"plaintext": "ability🤝的 ȺȾ",
"payload": "ArIJia3D3cQc0sQ1lSwNWakTFdjFIY1QQFc/w3SVQ6yvbG2S0x4Yu86QGwPTy7mP3961I1XqB6SFFTzqDZZavhxoWMj7mEVGMQIsh2RLWI5EYQaQDIePSnXPlzf7CIt+voTD"
},
{
"sec1": "875adb475056aec0b4809bd2db9aa00cff53a649e7b59d8edcbf4e6330b0995c",
"sec2": "9c05781112d5b0a2a7148a222e50e0bd891d6b60c5483f03456e982185944aae",
"conversation_key": "3b15c977e20bfe4b8482991274635edd94f366595b1a3d2993515705ca3cedb8",
"nonce": "8d4442713eb9d4791175cb040d98d6fc5be8864d6ec2f89cf0895a2b2b72d1b1",
"plaintext": "pepper👀їжак",
"payload": "Ao1EQnE+udR5EXXLBA2Y1vxb6IZNbsL4nPCJWisrctGxY3AduCS+jTUgAAnfvKafkmpy15+i9YMwCdccisRa8SvzW671T2JO4LFSPX31K4kYUKelSAdSPwe9NwO6LhOsnoJ+"
},
{
"sec1": "eba1687cab6a3101bfc68fd70f214aa4cc059e9ec1b79fdb9ad0a0a4e259829f",
"sec2": "dff20d262bef9dfd94666548f556393085e6ea421c8af86e9d333fa8747e94b3",
"conversation_key": "4f1538411098cf11c8af216836444787c462d47f97287f46cf7edb2c4915b8a5",
"nonce": "2180b52ae645fcf9f5080d81b1f0b5d6f2cd77ff3c986882bb549158462f3407",
"plaintext": "( ͡° ͜ʖ ͡°)",
"payload": "AiGAtSrmRfz59QgNgbHwtdbyzXf/PJhogrtUkVhGLzQHv4qhKQwnFQ54OjVMgqCea/Vj0YqBSdhqNR777TJ4zIUk7R0fnizp6l1zwgzWv7+ee6u+0/89KIjY5q1wu6inyuiv"
},
{
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
"nonce": "e4cd5f7ce4eea024bc71b17ad456a986a74ac426c2c62b0a15eb5c5c8f888b68",
"plaintext": "مُنَاقَشَةُ سُبُلِ اِسْتِخْدَامِ اللُّغَةِ فِي النُّظُمِ الْقَائِمَةِ وَفِيم يَخُصَّ التَّطْبِيقَاتُ الْحاسُوبِيَّةُ،",
"payload": "AuTNX3zk7qAkvHGxetRWqYanSsQmwsYrChXrXFyPiItoIBsWu1CB+sStla2M4VeANASHxM78i1CfHQQH1YbBy24Tng7emYW44ol6QkFD6D8Zq7QPl+8L1c47lx8RoODEQMvNCbOk5ffUV3/AhONHBXnffrI+0025c+uRGzfqpYki4lBqm9iYU+k3Tvjczq9wU0mkVDEaM34WiQi30MfkJdRbeeYaq6kNvGPunLb3xdjjs5DL720d61Flc5ZfoZm+CBhADy9D9XiVZYLKAlkijALJur9dATYKci6OBOoc2SJS2Clai5hOVzR0yVeyHRgRfH9aLSlWW5dXcUxTo7qqRjNf8W5+J4jF4gNQp5f5d0YA4vPAzjBwSP/5bGzNDslKfcAH"
},
{
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
"nonce": "38d1ca0abef9e5f564e89761a86cee04574b6825d3ef2063b10ad75899e4b023",
"plaintext": "الكل في المجمو عة (5)",
"payload": "AjjRygq++eX1ZOiXYahs7gRXS2gl0+8gY7EK11iZ5LAjbOTrlfrxak5Lki42v2jMPpLSicy8eHjsWkkMtF0i925vOaKG/ZkMHh9ccQBdfTvgEGKzztedqDCAWb5TP1YwU1PsWaiiqG3+WgVvJiO4lUdMHXL7+zKKx8bgDtowzz4QAwI="
},
{
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
"nonce": "4f1a31909f3483a9e69c8549a55bbc9af25fa5bbecf7bd32d9896f83ef2e12e0",
"plaintext": "𝖑𝖆𝖟𝖞 社會科學院語學研究所",
"payload": "Ak8aMZCfNIOp5pyFSaVbvJryX6W77Pe9MtmJb4PvLhLgh/TsxPLFSANcT67EC1t/qxjru5ZoADjKVEt2ejdx+xGvH49mcdfbc+l+L7gJtkH7GLKpE9pQNQWNHMAmj043PAXJZ++fiJObMRR2mye5VHEANzZWkZXMrXF7YjuG10S1pOU="
},
{
"sec1": "d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e",
"sec2": "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214",
"conversation_key": "75fe686d21a035f0c7cd70da64ba307936e5ca0b20710496a6b6b5f573377bdd",
"nonce": "a3e219242d85465e70adcd640b564b3feff57d2ef8745d5e7a0663b2dccceb54",
"plaintext": "🙈 🙉 🙊 0️⃣ 1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣ 6️⃣ 7️⃣ 8️⃣ 9️⃣ 🔟 Powerلُلُصّبُلُلصّبُررً ॣ ॣh ॣ ॣ冗",
"payload": "AqPiGSQthUZecK3NZAtWSz/v9X0u+HRdXnoGY7LczOtUf05aMF89q1FLwJvaFJYICZoMYgRJHFLwPiOHce7fuAc40kX0wXJvipyBJ9HzCOj7CgtnC1/cmPCHR3s5AIORmroBWglm1LiFMohv1FSPEbaBD51VXxJa4JyWpYhreSOEjn1wd0lMKC9b+osV2N2tpbs+rbpQem2tRen3sWflmCqjkG5VOVwRErCuXuPb5+hYwd8BoZbfCrsiAVLd7YT44dRtKNBx6rkabWfddKSLtreHLDysOhQUVOp/XkE7OzSkWl6sky0Hva6qJJ/V726hMlomvcLHjE41iKmW2CpcZfOedg=="
}
],
"encrypt_decrypt_long_msg": [
{
"conversation_key": "8fc262099ce0d0bb9b89bac05bb9e04f9bc0090acc181fef6840ccee470371ed",
"nonce": "326bcb2c943cd6bb717588c9e5a7e738edf6ed14ec5f5344caa6ef56f0b9cff7",
"pattern": "x",
"repeat": 65535,
"plaintext_sha256": "09ab7495d3e61a76f0deb12cb0306f0696cbb17ffc12131368c7a939f12f56d3",
"payload_sha256": "90714492225faba06310bff2f249ebdc2a5e609d65a629f1c87f2d4ffc55330a"
},
{
"conversation_key": "56adbe3720339363ab9c3b8526ffce9fd77600927488bfc4b59f7a68ffe5eae0",
"nonce": "ad68da81833c2a8ff609c3d2c0335fd44fe5954f85bb580c6a8d467aa9fc5dd0",
"pattern": "!",
"repeat": 65535,
"plaintext_sha256": "6af297793b72ae092c422e552c3bb3cbc310da274bd1cf9e31023a7fe4a2d75e",
"payload_sha256": "8013e45a109fad3362133132b460a2d5bce235fe71c8b8f4014793fb52a49844"
},
{
"conversation_key": "7fc540779979e472bb8d12480b443d1e5eb1098eae546ef2390bee499bbf46be",
"nonce": "34905e82105c20de9a2f6cd385a0d541e6bcc10601d12481ff3a7575dc622033",
"pattern": "🦄",
"repeat": 16383,
"plaintext_sha256": "a249558d161b77297bc0cb311dde7d77190f6571b25c7e4429cd19044634a61f",
"payload_sha256": "b3348422471da1f3c59d79acfe2fe103f3cd24488109e5b18734cdb5953afd15"
}
]
},
"invalid": {
"encrypt_msg_lengths": [0, 65536, 100000, 10000000],
"get_conversation_key": [
{
"sec1": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"note": "sec1 higher than curve.n"
},
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000000",
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"note": "sec1 is 0"
},
{
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364139",
"pub2": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
"note": "pub2 is invalid, no sqrt, all-ff"
},
{
"sec1": "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141",
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"note": "sec1 == curve.n"
},
{
"sec1": "0000000000000000000000000000000000000000000000000000000000000002",
"pub2": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
"note": "pub2 is invalid, no sqrt"
},
{
"sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
"pub2": "0000000000000000000000000000000000000000000000000000000000000000",
"note": "pub2 is point of order 3 on twist"
},
{
"sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
"pub2": "eb1f7200aecaa86682376fb1c13cd12b732221e774f553b0a0857f88fa20f86d",
"note": "pub2 is point of order 13 on twist"
},
{
"sec1": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
"pub2": "709858a4c121e4a84eb59c0ded0261093c71e8ca29efeef21a6161c447bcaf9f",
"note": "pub2 is point of order 3319 on twist"
}
],
"decrypt": [
{
"conversation_key": "ca2527a037347b91bea0c8a30fc8d9600ffd81ec00038671e3a0f0cb0fc9f642",
"nonce": "daaea5ca345b268e5b62060ca72c870c48f713bc1e00ff3fc0ddb78e826f10db",
"plaintext": "n o b l e",
"payload": "#Atqupco0WyaOW2IGDKcshwxI9xO8HgD/P8Ddt46CbxDbrhdG8VmJdU0MIDf06CUvEvdnr1cp1fiMtlM/GrE92xAc1K5odTpCzUB+mjXgbaqtntBUbTToSUoT0ovrlPwzGjyp",
"note": "unknown encryption version"
},
{
"conversation_key": "36f04e558af246352dcf73b692fbd3646a2207bd8abd4b1cd26b234db84d9481",
"nonce": "ad408d4be8616dc84bb0bf046454a2a102edac937c35209c43cd7964c5feb781",
"plaintext": "⚠️",
"payload": "AK1AjUvoYW3IS7C/BGRUoqEC7ayTfDUgnEPNeWTF/reBZFaha6EAIRueE9D1B1RuoiuFScC0Q94yjIuxZD3JStQtE8JMNacWFs9rlYP+ZydtHhRucp+lxfdvFlaGV/sQlqZz",
"note": "unknown encryption version 0"
},
{
"conversation_key": "ca2527a037347b91bea0c8a30fc8d9600ffd81ec00038671e3a0f0cb0fc9f642",
"nonce": "daaea5ca345b268e5b62060ca72c870c48f713bc1e00ff3fc0ddb78e826f10db",
"plaintext": "n o s t r",
"payload": "Atфupco0WyaOW2IGDKcshwxI9xO8HgD/P8Ddt46CbxDbrhdG8VmJZE0UICD06CUvEvdnr1cp1fiMtlM/GrE92xAc1EwsVCQEgWEu2gsHUVf4JAa3TpgkmFc3TWsax0v6n/Wq",
"note": "invalid base64"
},
{
"conversation_key": "cff7bd6a3e29a450fd27f6c125d5edeb0987c475fd1e8d97591e0d4d8a89763c",
"nonce": "09ff97750b084012e15ecb84614ce88180d7b8ec0d468508a86b6d70c0361a25",
"plaintext": "¯\\_(ツ)_/¯",
"payload": "Agn/l3ULCEAS4V7LhGFM6IGA17jsDUaFCKhrbXDANholyySBfeh+EN8wNB9gaLlg4j6wdBYh+3oK+mnxWu3NKRbSvQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"note": "invalid MAC"
},
{
"conversation_key": "cfcc9cf682dfb00b11357f65bdc45e29156b69db424d20b3596919074f5bf957",
"nonce": "65b14b0b949aaa7d52c417eb753b390e8ad6d84b23af4bec6d9bfa3e03a08af4",
"plaintext": "🥎",
"payload": "AmWxSwuUmqp9UsQX63U7OQ6K1thLI69L7G2b+j4DoIr0oRWQ8avl4OLqWZiTJ10vIgKrNqjoaX+fNhE9RqmR5g0f6BtUg1ijFMz71MO1D4lQLQfW7+UHva8PGYgQ1QpHlKgR",
"note": "invalid MAC"
},
{
"conversation_key": "5254827d29177622d40a7b67cad014fe7137700c3c523903ebbe3e1b74d40214",
"nonce": "7ab65dbb8bbc2b8e35cafb5745314e1f050325a864d11d0475ef75b3660d91c1",
"plaintext": "elliptic-curve cryptography",
"payload": "Anq2XbuLvCuONcr7V0UxTh8FAyWoZNEdBHXvdbNmDZHB573MI7R7rrTYftpqmvUpahmBC2sngmI14/L0HjOZ7lWGJlzdh6luiOnGPc46cGxf08MRC4CIuxx3i2Lm0KqgJ7vA",
"note": "invalid padding"
},
{
"conversation_key": "fea39aca9aa8340c3a78ae1f0902aa7e726946e4efcd7783379df8096029c496",
"nonce": "7d4283e3b54c885d6afee881f48e62f0a3f5d7a9e1cb71ccab594a7882c39330",
"plaintext": "noble",
"payload": "An1Cg+O1TIhdav7ogfSOYvCj9dep4ctxzKtZSniCw5MwRrrPJFyAQYZh5VpjC2QYzny5LIQ9v9lhqmZR4WBYRNJ0ognHVNMwiFV1SHpvUFT8HHZN/m/QarflbvDHAtO6pY16",
"note": "invalid padding"
},
{
"conversation_key": "0c4cffb7a6f7e706ec94b2e879f1fc54ff8de38d8db87e11787694d5392d5b3f",
"nonce": "6f9fd72667c273acd23ca6653711a708434474dd9eb15c3edb01ce9a95743e9b",
"plaintext": "censorship-resistant and global social network",
"payload": "Am+f1yZnwnOs0jymZTcRpwhDRHTdnrFcPtsBzpqVdD6b2NZDaNm/TPkZGr75kbB6tCSoq7YRcbPiNfJXNch3Tf+o9+zZTMxwjgX/nm3yDKR2kHQMBhVleCB9uPuljl40AJ8kXRD0gjw+aYRJFUMK9gCETZAjjmrsCM+nGRZ1FfNsHr6Z",
"note": "invalid padding"
},
{
"conversation_key": "5cd2d13b9e355aeb2452afbd3786870dbeecb9d355b12cb0a3b6e9da5744cd35",
"nonce": "b60036976a1ada277b948fd4caa065304b96964742b89d26f26a25263a5060bd",
"plaintext": "0",
"payload": "",
"note": "invalid payload length: 0"
},
{
"conversation_key": "d61d3f09c7dfe1c0be91af7109b60a7d9d498920c90cbba1e137320fdd938853",
"nonce": "1a29d02c8b4527745a2ccb38bfa45655deb37bc338ab9289d756354cea1fd07c",
"plaintext": "1",
"payload": "Ag==",
"note": "invalid payload length: 4"
},
{
"conversation_key": "873bb0fc665eb950a8e7d5971965539f6ebd645c83c08cd6a85aafbad0f0bc47",
"nonce": "c826d3c38e765ab8cc42060116cd1464b2a6ce01d33deba5dedfb48615306d4a",
"plaintext": "2",
"payload": "AqxgToSh3H7iLYRJjoWAM+vSv/Y1mgNlm6OWWjOYUClrFF8=",
"note": "invalid payload length: 48"
},
{
"conversation_key": "9f2fef8f5401ac33f74641b568a7a30bb19409c76ffdc5eae2db6b39d2617fbe",
"nonce": "9ff6484642545221624eaac7b9ea27133a4cc2356682a6033aceeef043549861",
"plaintext": "3",
"payload": "Ap/2SEZCVFIhYk6qx7nqJxM6TMI1ZoKmAzrO7vBDVJhhuZXWiM20i/tIsbjT0KxkJs2MZjh1oXNYMO9ggfk7i47WQA==",
"note": "invalid payload length: 92"
}
]
}
}
}