diff --git a/amethyst/plans/2026-09-21-deck-0003-sno.md b/amethyst/plans/2026-09-21-deck-0003-sno.md index a9c1ab4376..8678b88e7a 100644 --- a/amethyst/plans/2026-09-21-deck-0003-sno.md +++ b/amethyst/plans/2026-09-21-deck-0003-sno.md @@ -839,17 +839,22 @@ are where the risk is, and they are testable without a device. of our code will check the wrong arbiter first. - **D1b — `name`.** Required in §1.1's table, enforced by neither reference implementation (§2.6). Treat as optional with a fallback. -- **D2 — `kind 3330` bag items. Settled: the container ships, the bag does not.** - `SnoShardEvent` reads a shard handed over directly — quoted in a note, or fetched - by id — through the same parser and the same card as a standalone object, because - §3.2 is two containers for one format rather than two formats. What is *not* - implemented is opening a `kind 33330` bag: its payload is AES-256-GCM ciphertext - keyed to a Cyberspace region, so reading one means §2 coordinates, §4's Cantor - trees, §7.2 key derivation and a §7.7 sweep. That is the protocol, not a renderer, - and §7.6 is explicit that a failed decryption "MUST NOT be treated as an error in - the bag". Note that "we have no position in cyberspace" is *not* the reason — - §7.7 is explicit that a seeker's position never enters the cost of a sweep. The - reason is §2.2's: the compute, and the protocol surface behind it. +- **D2 — `kind 3330` bag items. Settled twice: the container shipped first, the bag + followed.** `SnoShardEvent` reads a shard handed over directly — quoted in a note, + or fetched by id — through the same parser and the same card as a standalone + object, because §3.2 is two containers for one format rather than two formats. + + Opening the `kind 33330` bag around it was deferred here and is now built; see + `quartz/plans/2026-09-22-cyberspace-region-bags.md`. The deferral's *reason* was + restated correctly before it was lifted, and the restatement is what made the work + tractable: "we have no position in cyberspace" was never the obstacle — §7.7 is + explicit that a seeker's position never enters the cost of a sweep — the obstacle + was the compute and the protocol surface behind it. Both turned out to be + affordable in the corner anyone uses: a region key is 1.2 ms at height 8 against + the spec's own 1.3 ms, and a bag's own hint prices its search before a tree is + built. A bag whose hint prices it out of reach still says so and shows nothing, + which is §7.6 working as written: a failed decryption "MUST NOT be treated as an + error in the bag". **The v1 tag form was built and then removed, deliberately.** Every `kind 3330` publicly reachable on a relay predates the deck: 21 events, one pubkey, all inside 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 643eeaed3a..8d1962f64e 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 @@ -158,6 +158,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderCitation import com.vitorpamplona.amethyst.ui.note.types.RenderClassifieds import com.vitorpamplona.amethyst.ui.note.types.RenderCodeSnippetEvent import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity +import com.vitorpamplona.amethyst.ui.note.types.RenderCyberspaceBag import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack import com.vitorpamplona.amethyst.ui.note.types.RenderEntityRating import com.vitorpamplona.amethyst.ui.note.types.RenderExternalReaction @@ -284,6 +285,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.CyberspaceBagEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoShardEvent @@ -1430,6 +1432,10 @@ private fun RenderNoteRow( RenderSnoShard(baseNote, accountViewModel) } + is CyberspaceBagEvent -> { + RenderCyberspaceBag(baseNote, accountViewModel) + } + is ChessGameEvent -> { RenderChessGame( baseNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CyberspaceBag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CyberspaceBag.kt new file mode 100644 index 0000000000..6249d38fed --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CyberspaceBag.kt @@ -0,0 +1,340 @@ +/* + * 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.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.cyberspace.BagSweep +import com.vitorpamplona.amethyst.commons.cyberspace.BagSweepQuote +import com.vitorpamplona.amethyst.commons.cyberspace.BagSweepState +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_box +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_cancel +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_damaged +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_destination +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_dropped +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_dropped_many +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_empty +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_measuring +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_no_hint +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_not_found +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_opaque +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_out_of_reach +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_progress +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_search +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_title +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_too_slow_hours +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_too_slow_minutes +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_unknown_kind +import com.vitorpamplona.amethyst.commons.resources.cyberspace_bag_unsigned +import com.vitorpamplona.amethyst.commons.ui.note.SnoObjectCard +import com.vitorpamplona.amethyst.commons.ui.note.SnoObjectUnreadableCard +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText +import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.cyberspace.CyberspaceBagContents +import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent +import com.vitorpamplona.quartz.cyberspace.CyberspaceBagItem +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoPaletteRef +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoResult +import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoShardEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource + +/** + * Entry for a region bag (`CYBERSPACE_V2.md` §7.6, kind 33330) — content + * somebody hid at a place, addressed by a hash of a hash. + * + * The card's job is to say what the search costs and then not start it. §7.7's + * hint is the hider's difficulty knob, which makes the price part of the + * content: a box of 4,096 regions is a minute of a phone, and a box of 2^30 is + * a day of it, chosen by a stranger. So the gap is read off two tags while the + * row composes — arithmetic, no trees — and the card either offers a button or + * says the box is out of reach. + * + * **Every sweep is a tap, including a gap-0 hint.** §7.7 calls that one "a + * destination the seeker can compute or walk to directly, not a search", and it + * costs about as long as a frame. It still waits for the tap, because a reader + * who learns that some bags open themselves has learned an expectation a + * hostile bag can hide inside. + * + * What comes out renders through the cards that already exist: a `3330` shard + * through [SnoObjectCard], a `kind 1` as its text, anything else named and + * skipped. §7.6 decides the attribution and it is not the obvious one — + * *placement* belongs to the bag's author, *authorship* only to a signed item's + * own pubkey — so an unsigned item says so under it rather than borrowing + * either name. + */ +@Composable +fun RenderCyberspaceBag( + baseNote: Note, + accountViewModel: AccountViewModel, +) { + val noteEvent = baseNote.event as? CyberspaceBagEvent ?: return + val quote = remember(noteEvent) { BagSweep.quote(noteEvent) } + + var state by remember(noteEvent) { mutableStateOf(null) } + var job by remember(noteEvent) { mutableStateOf(null) } + + // Scrolling the row away is a cancel: this scope dies with the composition, + // and the sweep is a cold flow, so the work stops at the next candidate. + // Nothing here should outlive the card that asked for it. + val scope = rememberCoroutineScope() + + Column(MaterialTheme.colorScheme.replyModifier.padding(10.dp)) { + Text( + text = stringResource(Res.string.cyberspace_bag_title), + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + text = + when (quote) { + is BagSweepQuote.Hidden -> stringResource(Res.string.cyberspace_bag_no_hint) + is BagSweepQuote.OutOfReach -> stringResource(Res.string.cyberspace_bag_out_of_reach, quote.gapBits) + is BagSweepQuote.Searchable -> + if (quote.destination) { + stringResource(Res.string.cyberspace_bag_destination) + } else { + stringResource(Res.string.cyberspace_bag_box, quote.candidates.toString()) + } + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + + if (quote is BagSweepQuote.Searchable) { + Sweeper( + state = state, + onStart = { + job = + scope.launch { + BagSweep + .sweep(noteEvent) + .flowOn(Dispatchers.Default) + .collect { state = it } + } + }, + onCancel = { + job?.cancel() + job = null + state = null + }, + ) + } + + when (val settled = state) { + is BagSweepState.Opened -> Contents(settled.contents, accountViewModel) + is BagSweepState.NotFound -> Footnote(stringResource(Res.string.cyberspace_bag_not_found)) + is BagSweepState.OutOfReach -> Footnote(outOfReach(settled.estimateMillis)) + else -> {} + } + } +} + +/** The button, or the progress and the stop that replace it while a sweep runs. */ +@Composable +private fun Sweeper( + state: BagSweepState?, + onStart: () -> Unit, + onCancel: () -> Unit, +) { + when (state) { + null -> TextButton(onClick = onStart) { Text(stringResource(Res.string.cyberspace_bag_search)) } + + is BagSweepState.Measuring -> Progress(null, stringResource(Res.string.cyberspace_bag_measuring), onCancel) + + is BagSweepState.Running -> + Progress( + fraction = if (state.candidates > 0) state.examined.toFloat() / state.candidates else null, + label = stringResource(Res.string.cyberspace_bag_progress, state.examined.toString(), state.candidates.toString()), + onCancel = onCancel, + ) + + // Settled. A cancel resets the state to null and the button comes back; + // an outcome does not, because sweeping the same box again derives the + // same keys and reaches the same answer. + else -> {} + } +} + +@Composable +private fun Progress( + fraction: Float?, + label: String, + onCancel: () -> Unit, +) { + Row(Modifier.fillMaxWidth().padding(top = 6.dp)) { + Column(Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + Spacer(Modifier.height(4.dp)) + if (fraction == null) { + LinearProgressIndicator(Modifier.fillMaxWidth()) + } else { + LinearProgressIndicator(progress = { fraction }, modifier = Modifier.fillMaxWidth()) + } + } + Spacer(Modifier.width(8.dp)) + TextButton(onClick = onCancel) { Text(stringResource(Res.string.cyberspace_bag_cancel)) } + } +} + +/** + * What the bag held (§7.6's two plaintext shapes). + * + * A null [contents] is the one case that is genuinely a broken bag: the + * `lookup_id` matched, and §7.2 makes that a hash of the very key we tried, so + * a GCM tag that then fails is damage rather than a wrong reader. + */ +@Composable +private fun Contents( + contents: CyberspaceBagContents?, + accountViewModel: AccountViewModel, +) { + when (contents) { + null -> Footnote(stringResource(Res.string.cyberspace_bag_damaged)) + + is CyberspaceBagContents.Opaque -> + Footnote(asText(contents.bytes) ?: stringResource(Res.string.cyberspace_bag_opaque, contents.bytes.size)) + + is CyberspaceBagContents.Items -> { + if (contents.items.isEmpty() && contents.dropped == 0) { + Footnote(stringResource(Res.string.cyberspace_bag_empty)) + } + contents.items.forEach { item -> + Spacer(Modifier.height(6.dp)) + Item(item, accountViewModel) + } + // §7.6: a forged item costs itself and nothing else, but it is + // still worth saying that something was thrown away. + if (contents.dropped > 0) { + Spacer(Modifier.height(6.dp)) + Footnote( + if (contents.dropped == 1) { + stringResource(Res.string.cyberspace_bag_dropped, contents.dropped) + } else { + stringResource(Res.string.cyberspace_bag_dropped_many, contents.dropped) + }, + ) + } + } + } +} + +/** + * One item, through whichever card already draws its kind. + * + * The `3330` case is what a bag is usually for: DECK-0003 §3.2's shard, whose + * payload is the same format a standalone object carries, which is why + * [RenderSnoShard] and this end at the same card. An item that came out of a + * bag is never in [com.vitorpamplona.amethyst.commons.model.cache.LocalCache] — + * it was ciphertext a moment ago — so there is no `Note` to hand the usual + * renderers, and these draw from the event directly. + */ +@Composable +private fun Item( + item: CyberspaceBagItem, + accountViewModel: AccountViewModel, +) { + when (val event = item.event) { + is SnoShardEvent -> { + val first = remember(event) { event.shard() } + WithSnoPalette(first.payloadOrNull()?.paletteRef ?: SnoPaletteRef.BuiltIn, accountViewModel) { palette -> + when (val parsed = remember(event, palette) { if (palette == null) first else event.shard(palette) }) { + is SnoResult.Invalid -> SnoObjectUnreadableCard(parsed.rule) + is SnoResult.Valid -> SnoObjectCard(payload = parsed.payload, eventId = event.id) + } + } + } + + is TextNoteEvent -> Text(text = event.content, style = MaterialTheme.typography.bodyMedium) + + // §7.6: "a reader that does not understand an item's kind skips it and + // renders the rest". Named rather than silent, so the count adds up. + else -> Footnote(stringResource(Res.string.cyberspace_bag_unknown_kind, event.kind)) + } + + // §7.6: an item without a `sig` is allowed, "its `pubkey` is then a claim, + // and readers MUST NOT present it as verified". Placement still belongs to + // the bag's author either way, which is why this only disclaims authorship. + if (!item.verified) Footnote(stringResource(Res.string.cyberspace_bag_unsigned)) +} + +/** A small grey line: this card's only other voice. */ +@Composable +private fun Footnote(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + ) +} + +/** The refusal, in whichever unit does not read as a wall of minutes. */ +@Composable +private fun outOfReach(estimateMillis: Long): String { + val minutes = estimateMillis / 60_000L + return if (minutes >= MINUTES_PER_HOUR) { + stringResource(Res.string.cyberspace_bag_too_slow_hours, (minutes / MINUTES_PER_HOUR).toInt()) + } else { + stringResource(Res.string.cyberspace_bag_too_slow_minutes, minutes.toInt()) + } +} + +/** + * §7.6's other shape as text, or null when it is not UTF-8 — "a text note or a + * file", and a file should not be shown as a wall of replacement characters. + */ +private fun asText(bytes: ByteArray): String? { + val text = bytes.decodeToString() + return if (text.encodeToByteArray().contentEquals(bytes)) text else null +} + +private const val MINUTES_PER_HOUR = 60L 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 d3afd79f85..19b60e3ed4 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 @@ -184,6 +184,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage import com.vitorpamplona.amethyst.ui.note.types.RenderChat import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile import com.vitorpamplona.amethyst.ui.note.types.RenderCitation +import com.vitorpamplona.amethyst.ui.note.types.RenderCyberspaceBag import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack import com.vitorpamplona.amethyst.ui.note.types.RenderEntityRating import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource @@ -272,6 +273,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.CyberspaceBagEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoShardEvent @@ -1081,6 +1083,8 @@ private fun FullBleedNoteCompose( RenderSnoAvatar(baseNote, accountViewModel) } else if (noteEvent is SnoShardEvent) { RenderSnoShard(baseNote, accountViewModel) + } else if (noteEvent is CyberspaceBagEvent) { + RenderCyberspaceBag(baseNote, accountViewModel) } else if (noteEvent is Ps1SaveEvent) { RenderPs1Save(baseNote) } else if (noteEvent is GeocacheListingEvent) { diff --git a/commons/ARCHITECTURE.md b/commons/ARCHITECTURE.md index 00aaa43c71..21673b3013 100644 --- a/commons/ARCHITECTURE.md +++ b/commons/ARCHITECTURE.md @@ -87,6 +87,8 @@ in `commonsUI`, under the same package. | `account` | no | New-account bootstrap events. | | `onchain` | no | On-chain zap splitting/broadcasting. | | `marmot` | no | MLS group-chat event processing. | +| `cyberspace` | no | `CYBERSPACE_V2` §7.7 region-bag search: the free quote off a bag's `hint`/`h` tags, the device-measured budget, and the cold sweep flow over quartz's `RegionSweep`. The protocol itself (coordinates, Cantor trees, keys, the bag) is `quartz/.../cyberspace`. | +| `sno` | mixed | DECK-0003 object rendering math — rasterizer, lighting, face winding, default avatar — here; the Compose viewer/thumbnail and the Coil fetcher in `commonsUI` under `sno` and `sno/ui`. | | `nip53LiveActivities` | mixed | Live-activity zapper aggregation (logic) + the stream card in `nip53LiveActivities/ui`. | | `search` | no | Event search filtering/ranking, kind registry. | | `preview` | no | OpenGraph / meta-tag link-preview parsing. | diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cyberspace/BagSweep.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cyberspace/BagSweep.kt new file mode 100644 index 0000000000..dea1a42a27 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/cyberspace/BagSweep.kt @@ -0,0 +1,272 @@ +/* + * 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.cyberspace + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.cyberspace.CyberspaceBagContents +import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent +import com.vitorpamplona.quartz.cyberspace.CyberspaceHint +import com.vitorpamplona.quartz.cyberspace.RegionSweep +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlin.time.TimeSource + +/** + * What a bag's `hint` tag promises, before anything is spent on it. + * + * The whole quote is arithmetic on two tags — §7.7's three heights and §8.6's + * `h` — so a card can carry it without building a single Cantor tree. That is + * deliberate: a feed may scroll past a bag whose hider chose a gap of 75, and + * the reader has to be able to say "out of reach" without having taken a + * stranger's dare first. + */ +@Immutable +sealed class BagSweepQuote { + /** A search this reader is willing to offer, and how big it is. */ + @Immutable + data class Searchable( + val gapBits: Int, + val candidates: Long, + /** + * §7.7: three heights equal to the bag's "name the region itself: the + * hint is then a destination the seeker can compute or walk to + * directly, not a search". + * + * It is still a tap. A reader who learns that some bags open themselves + * has learned something a hostile bag can hide inside, so the one + * candidate is offered exactly like the million. + */ + val destination: Boolean, + ) : BagSweepQuote() + + /** + * A hint whose box is past what this client sweeps, with the gap that + * decided it — so the card can say how far past, rather than only that it + * is. §7.7's own table runs from "seconds" to "days to never", and the far + * end is where a sector-only hint on a shallow bag lands: a gap of 75, + * which nobody will ever sweep. Such a hint "says where to travel, not + * where to search", and Amethyst cannot travel. + */ + @Immutable + data class OutOfReach( + val gapBits: Int, + ) : BagSweepQuote() + + /** + * Nothing to search from: no `hint` tag, a malformed one (§7.7 says those + * are the same thing), or no `d` tag to recognise the region by. + * + * Not an error in the bag. It is content hidden the hard way, and §7.7 is + * clear about what that means: "any given bag is equally likely to be at + * any point in the full 2^256 coordinate space". + */ + @Immutable + data object Hidden : BagSweepQuote() +} + +/** Where a sweep has got to, for a card that has to show progress and a cancel. */ +@Immutable +sealed class BagSweepState { + /** Pricing the first candidate on this device, before committing to the rest. */ + @Immutable + data object Measuring : BagSweepState() + + @Immutable + data class Running( + val examined: Long, + val candidates: Long, + ) : BagSweepState() + + /** + * The region key came up, and the bag opened. + * + * [contents] is null when it did not, which at this point means the + * ciphertext is damaged rather than that the key is wrong — the `lookup_id` + * already matched, and §7.2 makes that a hash of this very key. + */ + @Immutable + data class Opened( + val contents: CyberspaceBagContents?, + ) : BagSweepState() + + /** The whole box was swept and the bag was not in it: the hint was wrong, or it was bait. */ + @Immutable + data class NotFound( + val examined: Long, + ) : BagSweepState() + + /** + * Measured, not guessed: the first candidate cost [millisPerCandidate] on + * *this* device, so the box would take [estimateMillis], which is past what + * this client spends without being asked again. + */ + @Immutable + data class OutOfReach( + val estimateMillis: Long, + val millisPerCandidate: Long, + ) : BagSweepState() +} + +/** + * §7.7's hint-and-sweep, priced for a reader with a battery. + * + * The protocol half is [RegionSweep]; this is the budget around it, and the + * budget is the entire product decision. A hint is a stranger's declaration of + * how hard they want the search to be, so everything here is arranged so the + * reader finds out the price before paying it: + * + * 1. [quote] reads the two tags and costs nothing. A box past [MAX_GAP_BITS] or + * a bag deeper than [MAX_BAG_HEIGHT] is out of reach and never becomes a + * button. + * 2. [sweep] times the first candidate on the device it is actually running on + * before committing to the rest, and stops if that measurement says the box + * is past [BUDGET_MILLIS]. The plan for this feature said to measure the + * Android cost before quoting a number in the UI; measuring it at the moment + * of the tap is the same answer without a constant that goes stale on the + * next handset. + * 3. The sweep is a cold [Flow] over a cold [Sequence], so a cancelled + * collection stops paying immediately. + * + * Nothing here talks to a relay, because the bag is already in hand: this is + * §7.7's search applied to one event someone put in front of you, not a crawl. + * And nothing starts on its own — [sweep] runs when it is collected. + */ +object BagSweep { + /** + * The largest box offered as a button, as §7.7's gap exponent. + * + * 2^20 is about a million candidate regions. §7.7's own table calls 12 + * "seconds" and 24 "hours"; this sits between them, at the scale a phone + * can finish while someone watches. Past it the card says so instead, which + * is the honest answer to a hint that was chosen to be expensive. + */ + const val MAX_GAP_BITS = 20 + + /** + * The deepest bag this offers to search. + * + * Not about the box but about a single key: a region key is three folds of + * integers that double in width at every level, so one candidate at height + * 16 is seconds on its own and one at height 20 is minutes. ONOSENDAI draws + * the same line, capping its own discovery scan at height 12 and selling + * the deeper ones as a service. + */ + const val MAX_BAG_HEIGHT = 12 + + /** + * How long a sweep may be expected to take before it is refused outright. + * + * Two minutes is where §7.7's "seconds" has clearly ended, and it is + * checked against a measurement from this device rather than a table, so a + * slower phone refuses boxes a faster one accepts. That asymmetry is + * correct: the cost is the reader's, so the reader's own hardware decides. + */ + const val BUDGET_MILLIS = 120_000L + + /** How many candidates pass between progress emissions. */ + private const val PROGRESS_EVERY = 64L + + /** + * What this bag's hint promises, from its tags alone. + * + * Costs two subtractions and an addition — safe to call while composing a + * feed row, and deliberately so, because the card has to be able to show + * the price of a search it will not run. + */ + fun quote(bag: CyberspaceBagEvent): BagSweepQuote { + if (!bag.isKnownVersion()) return BagSweepQuote.Hidden + if (bag.lookupId() == null) return BagSweepQuote.Hidden + if (bag.payload() == null) return BagSweepQuote.Hidden + + val height = bag.height() ?: return BagSweepQuote.Hidden + val hint = bag.hint() ?: return BagSweepQuote.Hidden + + val gap = hint.gapBits(height) + if (height > MAX_BAG_HEIGHT || gap > MAX_GAP_BITS) return BagSweepQuote.OutOfReach(gap) + + val candidates = hint.candidates(height) ?: return BagSweepQuote.OutOfReach(gap) + return BagSweepQuote.Searchable(gap, candidates, hint.isDestination(height)) + } + + /** + * Sweep this bag's box, emitting progress, and open it if the key turns up. + * + * Cold: nothing runs until collected, and cancelling the collection stops + * the work at the next candidate. Collect it off the main thread — the + * arithmetic is arbitrary-precision and the whole point is that it is slow. + */ + fun sweep(bag: CyberspaceBagEvent): Flow = + flow { + val quote = quote(bag) + if (quote !is BagSweepQuote.Searchable) { + // Priced and declined before a tree was built. There is nothing + // to emit that the card did not already know from [quote]. + return@flow + } + + val height = bag.height() ?: return@flow + val hint = bag.hint() ?: return@flow + val target = bag.lookupId() ?: return@flow + + emit(BagSweepState.Measuring) + + // The box's own base region, priced on the way past. A gap-0 hint + // names exactly this one, so the measurement is never wasted work: + // it is the first candidate either way. + val mark = TimeSource.Monotonic.markNow() + val base = RegionSweep.of(CyberspaceHint(hint.base, height, height, height), height).first() + val perCandidate = mark.elapsedNow().inWholeMilliseconds + + if (base.lookupId == target) { + emit(BagSweepState.Opened(bag.open(base.decryptionKey))) + return@flow + } + + // One candidate here is three axis roots and a combine; every + // candidate after it is one combine, because §4.7's axes are reused + // across the box. So this over-quotes — by about four times at the + // shallow heights and two at the deep ones — and over-quoting is + // the safe direction for a number whose only job is to decide + // whether to spend somebody's battery. + val estimate = perCandidate * quote.candidates + if (estimate > BUDGET_MILLIS) { + emit(BagSweepState.OutOfReach(estimate, perCandidate)) + return@flow + } + + var examined = 0L + emit(BagSweepState.Running(examined, quote.candidates)) + + for (material in RegionSweep.of(hint, height)) { + currentCoroutineContext().ensureActive() + examined++ + if (material.lookupId == target) { + emit(BagSweepState.Opened(bag.open(material.decryptionKey))) + return@flow + } + if (examined % PROGRESS_EVERY == 0L) emit(BagSweepState.Running(examined, quote.candidates)) + } + + emit(BagSweepState.NotFound(examined)) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt index 2ed724a813..6a4712db99 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt @@ -138,6 +138,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.CyberspaceBagEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoShardEvent @@ -3837,6 +3838,7 @@ open class EventCache : is GeohashListEvent, is SnoObjectEvent, is SnoAvatarEvent, + is CyberspaceBagEvent, is GitRepositoryEvent, is GitRepositoryStateEvent, is UserGraspListEvent, diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/cyberspace/BagSweepTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/cyberspace/BagSweepTest.kt new file mode 100644 index 0000000000..ced3d3c4bc --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/cyberspace/BagSweepTest.kt @@ -0,0 +1,190 @@ +/* + * 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.cyberspace + +import com.vitorpamplona.quartz.cyberspace.CyberspaceBagContents +import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * The budget around §7.7's sweep, on a bag the reference implementation sealed. + * + * The payload and the `d` tag below are the london height-4 vector that + * `CyberspaceBagEventTest` pins against `cyberspace-cli`'s own cipher, so a + * sweep that finds it here has reproduced the hider's region key from nothing + * but a box — which is the whole claim §7.7 makes. + */ +class BagSweepTest { + /** `cyberspace-cli`'s AES-256-GCM over a plain text note, at the london height-4 key. */ + private val payload = "AAECAwQFBgcICQoLu08nGyGW/7Vdw9GJIb/FveUqGhVW7YsSZuQ83LcizhHAJWNnQG/CHIcDKA==" + + /** The aligned bases of the boxes around that coordinate, from the reference's own interleave. */ + private val box444 = "c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e33c8000" + private val box666 = "c492492492492492492492edf5bee7267451c787d95ba4d7840c76d1e3380000" + private val box121212 = "c492492492492492492492edf5bee7267451c787d95ba4d7840c76c000000000" + + private fun bag(vararg extra: Array): CyberspaceBagEvent = + CyberspaceBagEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1700000000, + tags = + arrayOf( + arrayOf("d", "a1d82532c354e690c6bffdb1fb20ccda716e037586feaf70092cbc442a635916"), + arrayOf("version", "2"), + arrayOf("h", "4"), + arrayOf("encrypted", "aes-256-gcm", payload), + ) + extra, + content = "", + sig = "0".repeat(128), + ) + + private fun hint( + base: String, + h: Int, + ) = arrayOf("hint", base, h.toString(), h.toString(), h.toString()) + + @Test + fun aBoxIsPricedFromTwoTagsAndNothingElse() { + val quote = BagSweep.quote(bag(hint(box666, 6))) + assertIs(quote) + assertEquals(6, quote.gapBits, "(6-4) three times") + assertEquals(64, quote.candidates) + assertEquals(false, quote.destination) + } + + @Test + fun aHintThatNamesTheRegionIsStillOfferedAsASearch() { + // §7.7 calls three heights equal to the bag's "a destination the seeker + // can compute or walk to directly, not a search". It is still a tap: a + // reader who learned that some bags open themselves would have learned + // an expectation a hostile bag could hide inside. + val quote = BagSweep.quote(bag(hint(box444, 4))) + assertIs(quote) + assertEquals(0, quote.gapBits) + assertEquals(1, quote.candidates) + assertTrue(quote.destination) + } + + @Test + fun aBoxPastTheCapIsNeverOfferedAsAButton() { + // 3 x (12 - 4) = 24, which §7.7's own table calls "hours". + val quote = BagSweep.quote(bag(hint(box121212, 12))) + assertIs(quote) + assertEquals(24, quote.gapBits) + } + + @Test + fun aBagWithNoUsableHintIsHiddenRatherThanBroken() { + // §7.7: a malformed hint "MUST be treated as absent", and §7.6 says a + // bag nobody can find is not an error in the bag. + assertIs(BagSweep.quote(bag())) + // Two hint tags is one of §7.7's malformed cases. + assertIs(BagSweep.quote(bag(hint(box666, 6), hint(box444, 4)))) + // And a box smaller than the region it claims to hold is another. + assertIs(BagSweep.quote(bag(hint(box444, 3)))) + } + + @Test + fun aBagWhoseVersionIsUnknownIsNotSwept() { + // §8.6: "A reader MUST ignore a bag whose version it does not know." + val future = + CyberspaceBagEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1700000000, + tags = + arrayOf( + arrayOf("d", "a1d82532c354e690c6bffdb1fb20ccda716e037586feaf70092cbc442a635916"), + arrayOf("version", "3"), + arrayOf("h", "4"), + arrayOf("encrypted", "aes-256-gcm", payload), + hint(box666, 6), + ), + content = "", + sig = "0".repeat(128), + ) + assertIs(BagSweep.quote(future)) + } + + @Test + fun sweepingTheBoxFindsTheRegionAndOpensTheBag() = + runTest { + val states = BagSweep.sweep(bag(hint(box666, 6))).toList() + + assertIs(states.first(), "priced on this device before the rest is spent") + val opened = assertIs(states.last()) + val contents = assertIs(opened.contents) + assertEquals("just some words, not a list", contents.bytes.decodeToString()) + } + + @Test + fun aDestinationHintOpensOnTheOneCandidateItNames() = + runTest { + // The measurement is never wasted: the box's own base region is the + // first candidate either way, so a gap-0 hint is answered by it. + val states = BagSweep.sweep(bag(hint(box444, 4))).toList() + assertEquals(2, states.size, "measure, then open") + assertIs(states.last()) + } + + @Test + fun aBoxTheHintWasWrongAboutIsSweptToTheEndAndSaysSo() = + runTest { + // The same box, against a bag addressed to a region that is not in + // it. Every candidate is derived and none matches, which is the + // honest outcome of a hint that was wrong or was bait. + val elsewhere = + CyberspaceBagEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1700000000, + tags = + arrayOf( + arrayOf("d", "f".repeat(64)), + arrayOf("version", "2"), + arrayOf("h", "4"), + arrayOf("encrypted", "aes-256-gcm", payload), + hint(box666, 6), + ), + content = "", + sig = "0".repeat(128), + ) + + val states = BagSweep.sweep(elsewhere).toList() + val notFound = assertIs(states.last()) + assertEquals(64, notFound.examined, "the whole box, and not one region more") + } + + @Test + fun aQuoteThatWasDeclinedEmitsNothingAtAll() = + runTest { + // Nothing is built for a box the card already refused, which is the + // point of pricing from the tags: the refusal costs no arithmetic. + assertTrue(BagSweep.sweep(bag(hint(box121212, 12))).toList().isEmpty()) + assertTrue(BagSweep.sweep(bag()).toList().isEmpty()) + } +} diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 65f66de3ef..b0fc2cb0e0 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -5332,4 +5332,23 @@ Hidden in ideaspace · %1$s Default avatar No shape published, so everyone sees this one + Hidden at a place + Hidden in a box of %1$s regions + Hidden in one region, which the hint names exactly + No hint, so this could be anywhere in cyberspace + Out of reach: the hint names 2^%1$d regions to search + Search for it + Stop + Pricing the search on this device… + Searched %1$s of %2$s regions + Out of reach: about %1$d minutes of searching on this device + Out of reach: about %1$d hours of searching on this device + Not in the box the hint named + Found the region, but the contents could not be read + %1$d item hidden here did not match its signature and was dropped + %1$d items hidden here did not match their signatures and were dropped + Opened, and there was nothing inside + Unsigned, so this author is a claim + An item of kind %1$d, which this client does not draw + %1$d bytes of something this client does not read diff --git a/quartz/plans/2026-09-22-cyberspace-region-bags.md b/quartz/plans/2026-09-22-cyberspace-region-bags.md index cd74376452..401442a0f4 100644 --- a/quartz/plans/2026-09-22-cyberspace-region-bags.md +++ b/quartz/plans/2026-09-22-cyberspace-region-bags.md @@ -198,9 +198,12 @@ is not offered as a button at all — it is reported as out of reach. 3. **Hints.** Parse, validate, price. `amy cyberspace hint`. The three golden vectors. **Done** — see §10. 4. **The bag.** AES-256-GCM, plaintext shapes, item verification. `amy cyberspace - open`, round-tripped against the reference CLI's `encrypt`. -5. **The sweep**, as a budgeted cold sequence. `amy cyberspace sweep`. + open`, round-tripped against the reference CLI's `encrypt`. **Done** — see §11. +5. **The sweep**, as a budgeted cold sequence. `amy cyberspace sweep`. **Done** — + see §11. 6. **The card**, last, once every number it quotes is measured on a device. + **Done** — see §12, which measures them on the device at the tap rather than + baking in a constant. Steps 1 to 5 have no product risk and every one of them is diffable against a reference implementation. Step 6 is the only judgement call, and it is small. @@ -308,3 +311,108 @@ The canonical-integer rule is worth keeping for the same reason the aligned base is: both exist so that two hiders who hint the same box publish the same bytes, and a reader that accepts `"05"` alongside `"5"` lets one box have two spellings and breaks comparison by equality. + +## 11. Steps 4 and 5 as built + +`CyberspaceBagEvent` and `RegionSweep` in quartz, `amy cyberspace open` and +`amy cyberspace sweep` over them, and two more harness sections. The harness is +**15 of 15**. + +### What the two new sections actually prove + +Everything before this diffed a *number* against the reference — a key, a +lookup id, a hint tag. These diff a **ciphertext**, which is the only test that +catches a chain that agrees at every step and still cannot open a bag. +`cyberspace-cli` derives the region key with `location_encryption`, seals the +plaintext with `encrypt_with_location_key`, and writes the §8.6 tags with +`make_encrypted_content_event`. `amy` is handed the event and a coordinate and +has to reach the same 32 bytes: §2.2's interleave, §4.7's three Cantor roots, +§7.2's two hashes, §7.6's `nonce || ciphertext || tag`. Section 8 then takes the +coordinate away and makes it find the same bag from its hint box alone. + +The box in section 8 comes from the reference's own `coord_to_xyz` / +`xyz_to_coord` rather than from ours, so a disagreement about alignment shows up +as a bag that is not in the box we were handed — not as a test grading its own +arithmetic. + +### The two design decisions worth recording + +**`open` exits 0 on the wrong key.** §7.6: "A failed decryption therefore means +only that the reader does not hold this region's key; it MUST NOT be treated as +an error in the bag." So a wrong key is a verdict (`opened: false`), the way +`sno parse` reports an invalid payload, and the harness asserts the exit code as +well as the field. What *does* fail is a bag that cannot be attempted at all: an +unknown `version` (§8.6 says ignore it) or no `aes-256-gcm` payload to try. + +**`sweep` refuses before it spends.** The gap is read from the `hint` and `h` +tags and checked against `--max-gap` (default 20) before a single tree is built, +and the refusal quotes the exponent and names the flag that would buy it. A +harness case pins it: a gap-33 hint is declined rather than swept. This is the +same shape the card needs, which is why it lives in the CLI first — a budget +that can be tested in a shell script is a budget that can be trusted in a feed. + +### A shell trap worth remembering + +`jq`'s `//` treats `false` as empty, so `.opened // "null"` turns a correct +`false` into `"null"` and fails a passing test. Read booleans with a plain +`jq -r .field`. + +## 12. Step 6 as built + +`BagSweep` in `commons/cyberspace/` (headless: the quote, the budget and the +cold flow) and `RenderCyberspaceBag` in `amethyst/ui/note/types/` (the card), +wired into `NoteCompose` and `ThreadFeedView`, with kind 33330 routed through +`EventCache`'s addressable path. Nine tests in `commons`. + +### Pricing without a constant + +§3 of this plan ended with "Android will be slower — measure it before quoting a +number in the UI". What shipped measures it **at the moment of the tap, on the +device that is about to pay**, which is the same answer without a constant that +goes stale on the next handset. The order is: + +1. **Free, while the row composes.** `BagSweep.quote` reads two tags and does + three subtractions. A box past `MAX_GAP_BITS` (20) or a bag deeper than + `MAX_BAG_HEIGHT` (12, the ceiling ONOSENDAI puts on its own discovery scan) + is reported as out of reach and never becomes a button. No Cantor tree is + built for a hint the reader has already declined — a test pins that the flow + emits nothing at all in that case. +2. **One candidate, timed.** On the tap, the box's own base region is derived + and the elapsed time recorded. That candidate is never wasted work: a gap-0 + hint names exactly it, so the measurement *is* the search for a destination + hint. +3. **The estimate, then the rest.** `perCandidate × candidates` against a + two-minute budget. It over-quotes — the first candidate is three axis roots + plus a combine and every later one is a combine, so by about 4x at shallow + heights and 2x at deep ones — and over-quoting is the safe direction for a + number whose only job is to decide whether to spend somebody's battery. + +A slower phone therefore refuses boxes a faster one accepts. That asymmetry is +correct: the cost is the reader's, so the reader's hardware should decide. + +### What the card says, and what it never does + +Every sweep is a tap, **including a gap-0 hint**, which §7.7 itself calls "a +destination the seeker can compute or walk to directly, not a search" and which +costs about a frame. A reader who learned that some bags open themselves would +have learned an expectation a hostile bag could hide inside, so the one +candidate is offered exactly like the million. A test pins it. + +Scrolling the row away cancels: the flow is cold over a cold sequence and the +card disposes its job, so a sweep never outlives the card that asked for it. + +Items render through the cards that already exist — a `3330` shard through +`SnoObjectCard` (palette fetched by `WithSnoPalette`, as a standalone object +would be), a `kind 1` as its text, anything else named and skipped, because +§7.6 says a reader that does not understand an item's kind "skips it and renders +the rest". The attribution is the non-obvious part and §7.6 fixes it: placement +belongs to the bag's author, authorship only to a signed item's own pubkey. An +unsigned item therefore carries a line saying its author is a claim, rather than +borrowing either name. + +### One correction carried back into quartz + +`SnoShardEvent`'s KDoc said Amethyst "implements none of it" and that a reader +without the key sees base64 and nothing else. Both were true when it was +written. The class now points at `RegionSweep` and says what actually decides +whether a bag opens — whether its own hint prices the search into reach. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt index 2fef0a61fa..482965d146 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/cyberspace/deck0003Sno/SnoShardEvent.kt @@ -47,14 +47,16 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey * * Not for want of somewhere to stand — §7.7 is explicit that "the seeker's own * position never enters this cost, because §7.1 makes looking and walking - * equivalent", so a client with no avatar could open a hinted bag. What stops - * it is the work: a key is three `O(2^h)` folds of BigInts that double in width - * every level, which the spec measures at 816 ms per key at height 16 on a - * desktop core and which grows about 2.2x per height above that. Amethyst - * implements none of it and a reader without the key sees base64 and nothing - * else — §7.6 is explicit that a failed decryption "MUST NOT be treated as an - * error in the bag". What this class reads is a shard handed over directly: - * quoted in a note, or fetched by id. + * equivalent", so a client with no avatar can open a hinted bag, and + * [com.vitorpamplona.quartz.cyberspace.RegionSweep] does. What decides whether + * it is worth trying is the work: a key is three `O(2^h)` folds of BigInts that + * double in width every level, which the spec measures at 816 ms per key at + * height 16 on a desktop core and which grows about 2.2x per height above that, + * and a hint's box multiplies that by up to 2^75. So a bag is opened only when + * its own hint prices the search into reach, and a reader who does not get + * there sees base64 and nothing else — §7.6 is explicit that a failed + * decryption "MUST NOT be treated as an error in the bag". A shard can also + * arrive with no bag at all: quoted in a note, or fetched by id. * * **An item MAY be unsigned** (§7.6, and §6 of the deck), in which case its * `pubkey` is a claim and a client MUST NOT present it as verified authorship.