diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
index 9a4762c162..a275cc1647 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
@@ -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,
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt
index 605d4f3ab1..6bb004b1c9 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt
@@ -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,
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoAvatar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoAvatar.kt
new file mode 100644
index 0000000000..10e65f43a6
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SnoAvatar.kt
@@ -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),
+ )
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt
index fc01413d31..ddf60814e4 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt
@@ -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) {
diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml
index 039b93686c..2f6edc531c 100644
--- a/commonsUI/src/commonMain/composeResources/values/strings.xml
+++ b/commonsUI/src/commonMain/composeResources/values/strings.xml
@@ -3084,4 +3084,6 @@
%1$d vertices · %2$d faces
This 3D object could not be read (rule %1$s)
Drag to turn
+ Cyberspace avatar
+ This avatar has not paid for its size, so it is not drawn
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoAvatarCard.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoAvatarCard.kt
new file mode 100644
index 0000000000..d5054b3199
--- /dev/null
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/SnoAvatarCard.kt
@@ -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,
+ )
+ }
+}
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt
index cb97c255c3..8e8c6ad188 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarEvent.kt
@@ -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>,
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()
diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarPaidTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarPaidTest.kt
new file mode 100644
index 0000000000..b9b0031b99
--- /dev/null
+++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoAvatarPaidTest.kt
@@ -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())
+ }
+}