feat: draw cyberspace avatars, and only the ones that paid

Kind 11333 carries the same SNO payload in a replaceable container, so it
now renders like any other object — except that §8.10 gates the drawing
rather than the parsing: an avatar that has not paid for its reach and its
detail, or whose content cannot be read, MUST NOT be drawn. An avatar is
the one thing in cyberspace that lands on other people's screens whether
they asked for it or not.

SnoAvatarEvent extends BaseReplaceableEvent rather than plain Event, which
is both what §8.10 means by moving the kind out of the addressable range
and what LocalCache needs to key it.

The payment check keeps both of §8.10's conditions separate, and the test
pins the case a shortcut gets wrong: an avatar owing 16 bits, committed to
30 and mined to 20 reads as 20 through Event.pow(), which clears the 16 it
owes while plainly falling short of the 30 its publisher committed to.

Not wired: replacing a user's NIP-01 profile picture with their cyberspace
avatar across the app. That is a product decision about every feed row,
not plumbing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JwXApJjoZYtkD3sPRWbPNa
This commit is contained in:
Vitor Pamplona
2026-09-21 22:49:06 +00:00
co-authored by Claude Opus 5
parent e792a2228b
commit e18d176481
8 changed files with 336 additions and 3 deletions
@@ -145,6 +145,7 @@ import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
@@ -3806,6 +3807,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
is GeocacheCurationListEvent,
is GeohashListEvent,
is SnoObjectEvent,
is SnoAvatarEvent,
is GitRepositoryEvent,
is GitRepositoryStateEvent,
is UserGraspListEvent,
@@ -213,6 +213,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventConfirmation
import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventReport
import com.vitorpamplona.amethyst.ui.note.types.RenderRootNappletEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderRootSiteEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderSnoAvatar
import com.vitorpamplona.amethyst.ui.note.types.RenderSnoObject
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareApplication
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset
@@ -258,6 +259,7 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.buzz.notifications.MemberAddedNotificationEvent
import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
@@ -1373,6 +1375,10 @@ private fun RenderNoteRow(
RenderSnoObject(baseNote)
}
is SnoAvatarEvent -> {
RenderSnoAvatar(baseNote)
}
is ChessGameEvent -> {
RenderChessGame(
baseNote,
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.sno.ui.SnoObjectViewer
import com.vitorpamplona.amethyst.commons.ui.note.SnoAvatarCard
import com.vitorpamplona.amethyst.commons.ui.note.SnoAvatarUnpaidCard
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
private val VIEWER_HEIGHT = 360.dp
/**
* Entry for a cyberspace avatar (kind 11333).
*
* §8.10 gates the drawing rather than the parsing: an avatar that has not paid
* for its reach and its detail, or whose content cannot be read, MUST NOT be
* drawn. Empty content is the default avatar and owes no work, so there is
* nothing to show for it either.
*/
@Composable
fun RenderSnoAvatar(baseNote: Note) {
val noteEvent = baseNote.event as? SnoAvatarEvent ?: return
if (noteEvent.isDefaultAvatar()) return
val shape: SnoPayload? = remember(noteEvent) { if (noteEvent.isPaid()) noteEvent.sno().payloadOrNull() else null }
if (shape == null) {
SnoAvatarUnpaidCard()
return
}
var turning by remember(noteEvent) { mutableStateOf(false) }
SnoAvatarCard(
payload = shape,
eventId = noteEvent.id,
name = noteEvent.nameTag(),
onClick = { turning = true },
)
if (turning) {
Dialog(onDismissRequest = { turning = false }) {
SnoObjectViewer(
payload = shape,
eventId = noteEvent.id,
contentDescription = noteEvent.nameTag() ?: shape.name.ifBlank { null },
modifier = Modifier.fillMaxWidth().height(VIEWER_HEIGHT),
)
}
}
}
@@ -225,6 +225,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderRelayReview
import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventConfirmation
import com.vitorpamplona.amethyst.ui.note.types.RenderRoadEventReport
import com.vitorpamplona.amethyst.ui.note.types.RenderRootSiteEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderSnoAvatar
import com.vitorpamplona.amethyst.ui.note.types.RenderSnoObject
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareApplication
import com.vitorpamplona.amethyst.ui.note.types.RenderSoftwareAsset
@@ -265,6 +266,7 @@ import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.selectedNote
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent
import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
@@ -1068,6 +1070,8 @@ private fun FullBleedNoteCompose(
RenderBirdDetection(baseNote)
} else if (noteEvent is SnoObjectEvent) {
RenderSnoObject(baseNote)
} else if (noteEvent is SnoAvatarEvent) {
RenderSnoAvatar(baseNote)
} else if (noteEvent is Ps1SaveEvent) {
RenderPs1Save(baseNote)
} else if (noteEvent is GeocacheListingEvent) {
@@ -3084,4 +3084,6 @@
<string name="sno_object_details">%1$d vertices · %2$d faces</string>
<string name="sno_object_unreadable">This 3D object could not be read (rule %1$s)</string>
<string name="sno_object_turn_hint">Drag to turn</string>
<string name="sno_avatar_title">Cyberspace avatar</string>
<string name="sno_avatar_unpaid">This avatar has not paid for its size, so it is not drawn</string>
</resources>
@@ -0,0 +1,110 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.note
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.sno_avatar_title
import com.vitorpamplona.amethyst.commons.resources.sno_avatar_unpaid
import com.vitorpamplona.amethyst.commons.resources.sno_object_details
import com.vitorpamplona.amethyst.commons.sno.ui.SnoThumbnail
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPayload
import org.jetbrains.compose.resources.stringResource
private val AVATAR_THUMBNAIL_SIZE = 96.dp
/**
* A cyberspace avatar (`CYBERSPACE_V2.md` §8.10 kind 11333), which is an SNO
* payload in a replaceable container.
*/
@Composable
fun SnoAvatarCard(
payload: SnoPayload,
eventId: String,
name: String?,
onClick: (() -> Unit)? = null,
) {
val modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp)
Row(
modifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier,
verticalAlignment = Alignment.CenterVertically,
) {
SnoThumbnail(
payload = payload,
eventId = eventId,
size = AVATAR_THUMBNAIL_SIZE,
contentDescription = name ?: payload.name.ifBlank { null },
)
Spacer(Modifier.width(12.dp))
Column {
Text(
text = name ?: payload.name.ifBlank { stringResource(Res.string.sno_avatar_title) },
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringResource(Res.string.sno_object_details, payload.vertexCount, payload.faceCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
)
}
}
}
/**
* What stands in for an avatar a client may not draw.
*
* `CYBERSPACE_V2.md` §8.10 is normative here: "a client MUST NOT draw an avatar
* event that is not paid, or that carries content it cannot read". An avatar is
* the one thing in cyberspace that lands on other people's screens whether they
* asked for it or not, so its size and its detail are paid for in proof of work
* on the event that publishes it, and a client draws nothing it cannot verify
* has paid.
*/
@Composable
fun SnoAvatarUnpaidCard() {
Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) {
Text(
text = stringResource(Res.string.sno_avatar_unpaid),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.placeholderText,
)
}
}
@@ -21,7 +21,7 @@
package com.vitorpamplona.quartz.cyberspace.deck0003Sno
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip13Pow.miner.PoWRankEvaluator
import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag
@@ -32,7 +32,9 @@ import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag
* default avatar.
*
* Replaceable, so relays keep the newest per `(pubkey, kind)` and an identity
* has exactly one avatar. This kind **was** 33331 with a `d` fixed at
* has exactly one avatar — which is why this extends [BaseReplaceableEvent]
* rather than plain `Event`, giving it the fixed-empty-`d` address the local
* store keys replaceables on. This kind **was** 33331 with a `d` fixed at
* `"avatar"` before the spec moved it here, and 33331 was then handed to the
* standalone objects of DECK-0003 §3.1 — so an old 33331 whose `d` is literally
* `avatar` is not a surprise, merely stale.
@@ -51,7 +53,7 @@ class SnoAvatarEvent(
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
/** True when this identity asked for the default avatar, which owes no work. */
fun isDefaultAvatar(): Boolean = content.isBlank()
@@ -0,0 +1,126 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.cyberspace.deck0003Sno
import com.vitorpamplona.quartz.nip13Pow.miner.PoWRankEvaluator
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* The drawing gate of `CYBERSPACE_V2.md` §8.10: "a client MUST NOT draw an
* avatar event that is not paid, or that carries content it cannot read".
*
* Both conditions are required — the committed target must cover the work the
* payload owes, AND the id must carry that many leading zero bits — and the
* interesting case is the one where only the second fails.
*/
class SnoAvatarPaidTest {
/** A shape that owes exactly the 16-bit floor. */
private val floorShape =
"""{"v":2,"name":"dot","unit":0,"mode":"points","vertices":[[0,0,0],[1,0,0]],"colors":[225,225],"faces":[]}"""
private fun idWithRank(bits: Int): String {
val zeros = bits / 4
val remainder = bits % 4
val stopper =
when (remainder) {
0 -> "f"
1 -> "4"
2 -> "2"
else -> "1"
}
val head = "0".repeat(zeros) + stopper
return head + "f".repeat(64 - head.length)
}
private fun avatar(
content: String,
committed: Int?,
idBits: Int,
) = SnoAvatarEvent(
idWithRank(idBits),
"11".repeat(32),
0L,
committed?.let { arrayOf(arrayOf("nonce", "1", it.toString())) } ?: emptyArray(),
content,
"22".repeat(64),
)
@Test
fun theIdHelperProducesTheRankItClaims() {
listOf(12, 16, 20, 30).forEach {
assertEquals(it, PoWRankEvaluator.calculatePowRankOf(idWithRank(it)), "rank for $it bits")
}
}
@Test
fun theFloorShapeOwesSixteenBits() {
assertEquals(16, SnoAvatarWork.required(SnoParser.parse(floorShape).payloadOrNull()!!))
}
@Test
fun anAvatarMinedToItsCommitmentIsPaid() {
assertTrue(avatar(floorShape, committed = 16, idBits = 16).isPaid())
assertTrue(avatar(floorShape, committed = 16, idBits = 24).isPaid(), "over-mining is fine")
}
@Test
fun emptyContentIsTheDefaultAvatarAndOwesNothing() {
val default = avatar("", committed = null, idBits = 0)
assertTrue(default.isDefaultAvatar())
assertTrue(default.isPaid())
}
@Test
fun anAvatarWithoutANonceTagIsNotPaid() {
// §8.10 requires the tag: committing the target before mining is what
// stops a lucky id being claimed against a lower bar than it was mined
// for, so an uncommitted avatar has not paid however long its id is.
assertFalse(avatar(floorShape, committed = null, idBits = 32).isPaid())
}
@Test
fun anAvatarCommittingLessThanItOwesIsNotPaid() {
assertFalse(avatar(floorShape, committed = 8, idBits = 32).isPaid())
}
@Test
fun anIdShortOfItsOwnCommitmentIsNotPaid() {
// The case a naive check gets wrong. Event.pow() is
// PoWRankEvaluator.compute(id, committed), which returns
// min(actualRank, committed) — here min(20, 30) = 20, which clears the
// 16 bits the shape owes while the id plainly falls short of the 30 the
// publisher committed to.
val event = avatar(floorShape, committed = 30, idBits = 20)
assertEquals(20, PoWRankEvaluator.compute(event.id, 30), "the shortcut would say 20...")
assertTrue(20 >= SnoAvatarWork.required(event.sno().payloadOrNull()!!), "...which clears the required 16...")
assertFalse(event.isPaid(), "...but §8.10 needs the id to carry the committed 30")
}
@Test
fun anAvatarWhoseContentCannotBeReadIsNotPaid() {
assertFalse(avatar("not json at all", committed = 32, idBits = 32).isPaid())
assertFalse(avatar("""{"v":9}""", committed = 32, idBits = 32).isPaid())
}
}